# AUIS Revival: What Was Done and Why
Last updated: 2026-08-08
This document is a narrative account of reviving Carnegie Mellon's Andrew
User Interface System (AUIS) on a modern Mac, for readers who already know
ATK and readers encountering it for the first time.
For the complete technical record this document summarizes, see:
| Doc | Description |
|---|---|
| `roadmap.md` | Current status, active projects, and open issues |
| `porting-changelog.md` | Chronological log of every fix |
| `porting-assessment.md` | Bug-class analysis and the full ANSI C conversion plan |
| `version-comparison.md` | Why 6.3.1 (the last C release) was chosen over the later, unfinished C++ rewrite |
| `quickstart.md` | Build and run instructions |
## What AUIS is
AUIS is the latest name for the user-interface toolkit work that came out
of the IBM-funded Information Technology Center at Carnegie Mellon
University in the 1980s and 1990s. It was originally the "Andrew Base
Editor," then a second version, "Base Editor II" (BE2 for short), then the
"Andrew Toolkit" (ATK for short). As the project began to be shown outside
CMU and adopted by others, the more ambitious name "Andrew User Interface
System" (AUIS) was adopted.
The applications from AUIS of primary interest now are `ez` and
`messages`. `ez` is a word processor capable of embedding spreadsheets,
equations, drawings, and animations directly inside a document.
`messages` is a mail and bulletin-board client with the same rich
embedding. In addition to `ez` and `messages`, AUIS is the basis for a
dozen smaller applications, including `bush`, a filesystem browser.
The version being revived, 6.3.1 (August 1994), is the last release CMU
shipped as plain C before the project moved to C++. (See
`version-comparison.md` for the reasoning behind reviving 6.3.1 rather
than the later, never-finished C++ line.) The target platform is macOS on
Apple Silicon, using XQuartz to provide the X11 display server this
software was originally written against.
ATK's central idea is the *inset*: a self-contained, interactive object — a
spreadsheet cell, an equation, a drawing, an animation, a footnote, a
scripted widget — embedded inside a document, able to embed further insets
inside itself, recursively and without a fixed limit. A single `ez`
document can contain a spreadsheet nested inside a drawing nested inside a
footnote. Each inset reads and writes its own portion of the file format,
draws itself, and handles its own input, independent of what it's embedded
in. The model predates the industry's later attempts at the same idea —
Microsoft's OLE and Apple's OpenDoc, both later in the 1990s, OpenDoc
discontinued in 1997 — built in portable C on a small object system called
"Class," contemporary with Objective-C. The application framework —
windows, menus, dialogs, scrolling, printing — is shared across every
ATK program, so `ez`, `messages`, and `help` are entry points onto the same
document-and-inset substrate rather than separate applications.
This code hadn't been compiled in roughly thirty years. Restoring it
required addressing two things: the environment around the code had
changed (new compilers, a new processor architecture, deprecated
operating-system interfaces), and the code itself contained real defects
that had never been exercised in three decades of use. The rest of this
document covers both, along with the strategy used to manage them.
## Modernizing
Some of what changed was required just to make 1994 code run in a 2026
environment. A smaller
amount was optional: bringing part of the software's appearance up to a
standard users now expect, even though the original approach still worked.
Both count as modernization, for different reasons.
### Required to run at all
- **Deleting a character from the middle of a string was commonly done by
copying the remainder a few bytes to the left, within the same
buffer** — technically undefined behavior by the letter of the C
standard, but a pattern that produced correct results on essentially
every C library for decades. Apple's runtime-hardened library now
detects that overlapping copy and aborts the program outright. Fifteen
call sites across nine files used this idiom, and it crashed three
applications (a file browser, an outliner, and an HTML viewer) under
ordinary use. Each site needed only a one-line fix — a function
explicitly defined for overlapping ranges — but locating all fifteen
required a deliberate, tree-wide search.
- **`dlopen()`/`dlsym()`**, the standard POSIX dynamic-library interface,
replaced AUIS's own hand-built, per-platform dynamic code loader — the
component responsible for loading an inset's code on demand. The
original loader had to understand the executable file format of every
target platform individually and had no notion of the current one; the
standard library call accomplishes the same task in roughly seventy
lines of code rather than the original's five hundred, and is what
actually loads insets in the revived system.
- **A compiler-leniency strategy**, as the default response to the volume
of errors modern, stricter compilers raise against 1990s C. Rather than
rewriting the source wholesale, the build disables a small, specific set
of modern strictness checks corresponding to conventions this code
relied on, reserving actual rewriting for places that genuinely require
it. (Discussed further below.)
### Bison and flex
The project's vendored bison fork hangs outright on current processor
architecture, so the build now uses the system's installed bison instead;
flex was already the system tool, but 1990s wrapper code around both
generators still assumed specific behavior of the versions in use at the
time. An early fix — passing bison's output filename explicitly, since
modern bison's default naming convention had changed — flagged that risk
directly, since the original code already carried its own accommodations
for 1990s bison behavior. That prompted a closer look for other places
where old assumptions about generator output might no longer hold, and
turned up two:
- A shared code generator assumed every parser lookup table was a fixed
storage width, matching 1994 bison's output. Modern bison narrows some
tables to a smaller type when the data range allows it; reading a
narrowed table through the old, fixed-width assumption merged adjacent
entries into garbage — surfacing as a date parser that failed on every
input, for reasons unrelated to dates.
- A hand-written reset routine poked an internal flex flag whose meaning
had reversed between old and new flex — nonzero used to mean
"reinitialize on next call," modern flex treats it as "already
initialized." The first call in a process skipped its own setup and
wrote through an unset pointer, crashing a mail-address parser on the
first address it ever processed.
### Adopted by choice: anti-aliased text
X11's original design placed font rendering on the server: the X server
itself rasterized glyphs from bitmap font files and returned pre-rendered
pixels on request, a scheme known as the core font protocol. This was the
normal way to draw text throughout the 1990s, and it is what AUIS was
built against — every text-rendering call in the codebase assumes it.
Modern desktop environments instead render text on the client side, using
libraries such as Xft and fontconfig, which support anti-aliasing, scalable
outline fonts, and subpixel hinting — the smooth, high-resolution text
users now consider normal.
The old, server-side bitmap path still works: XQuartz, the X server used
here, still implements the core font protocol, so nothing required this
change. It was made anyway, because 1990s bitmap fonts, while entirely
serviceable on 1990s monitors, look noticeably coarse next to the
anti-aliased text every other current application renders, and that
difference was judged worth correcting. The revival adopted a hybrid:
ordinary document text now renders through Xft, while the small set of
custom CMU symbol and cursor glyphs (bullets, math marks, cursor shapes)
that have no modern equivalent still render through the original
bitmap path — replacing only the parts that had a clean modern substitute.
This was a comparatively small amount of new code, and the visual
improvement was judged worth the effort — but, being new code rather than
old code regaining exercise, it introduced its own new defects rather than
exposing dormant ones. The calculator inset's display, once it received
real interactive testing under the new rendering path, showed two: text
was being "erased" by redrawing it in the background color, which exactly
cancels only the fully opaque center of each anti-aliased character, so the
partially transparent edge pixels accumulated into a visible ghost after
repeated use; and, separately, the X display server sometimes wrote the
correct pixels into a window's buffer without promptly making them
visible, requiring an incidental redraw — a window focus change, for
instance — before the correct text actually appeared. Both were resolved,
but both took debugging effort disproportionate to the size of the
feature, which is the general cost of this kind of optional modernization:
low volume of new code, comparatively high difficulty per defect.
The project's own infrastructure was modernized in a smaller way as well:
source control runs under Fossil, a self-contained modern system, in place
of whatever distribution mechanism carried the source in the 1990s.
## Finding ATK lessons in modern implementations
Not every observation from this project is about a defect. A few times,
working through thirty-year-old code turned up a design decision that
still holds up, validated later by a standard that didn't exist yet when
ATK was written.
- **Raster insets and PNG converged on the same bitmap encoding.** Adding
image support to `ez2md` (the tool that converts `.ez` documents to
Markdown) meant first decoding ATK's `raster` inset format: a
one-bit-per-pixel bitmap, packed eight pixels to a byte, most-significant
bit first, one bit value meaning "black." PNG's one-bit grayscale mode —
standardized in 1996, years after this raster format was written
(`rasterio.c` carries an IBM copyright of 1988) — packs pixels exactly
the same way: MSB-first, one bit each, a bit value mapping straight to
black or white. The only real work in the conversion was undoing ATK's
own run-length encoding (a compact ASCII scheme that made sense when
screen bitmaps had to move over modems and fit on small disks) back into
raw packed bits, then flipping which bit value means black, since the two
formats picked opposite conventions for that one detail. No resampling,
no color-model translation, no coordinate reconciliation — two systems,
designed for unrelated reasons the better part of a decade apart, landed
on the same underlying representation for a scanline bitmap.
- **AMS's 1988 client-server interface anticipates several modern IMAP
extensions.** AMS and IMAP were contemporaries — Crispin's first IMAP
drafts and Borenstein's message server were designed in the same few
years, for the same problem, at different institutions. Both settled on
durable per-message identifiers that survive reconnection (AMS's
18-character unique ids; IMAP's UIDVALIDITY plus UID). Both retrieve
message bodies by offset with a bytes-remaining count — AMS's
`MS_GetPartialBody` has essentially the signature of IMAP's partial
`FETCH BODY[]<offset.size>`. Both use a two-phase delete: mark, then
purge (`AMS_ATT_DELETED` and `MS_PurgeDeletedMessages`; `\Deleted` and
`EXPUNGE`). Both maintain a per-user subscription list over a shared
folder space. Both send fixed-size summary records in place of messages
(AMS snapshots; IMAP's ENVELOPE/FLAGS/INTERNALDATE items). The AMS
spec's engineering doctrine — every call idempotent, a server that "can
die at any moment, with the client automatically reconnecting and
continuing its work without any loss of context" — is the same
discipline a modern IMAP client's reconnect logic depends on. The
parallels reach past core IMAP, too: AMS computed reply-chain threading
server-side in 1988 (the snapshot's chain field), which IMAP added as
the THREAD extension years later; its "master update file" answering
*what changed since I last looked* cheaply is CONDSTORE's job
description; its 168 user-defined per-folder attributes are IMAP
keywords. In one respect AMS is still ahead: the server computes each
message's one-line caption once, for every client — a summary IMAP
clients still each rebuild for themselves from ENVELOPE, four decades
on. The revival's IMAP work relied on this directly: mapping the
store's operations onto IMAP was mostly transcription, because the two
designs had already agreed on what a mail store is.
## Old bugs never found till now
Bringing 1994 code onto a 2026 machine did more than require accommodating
a changed environment — it also surfaced defects that had existed in the
source for decades without ever manifesting. Two changes in particular
turned latent mistakes into visible ones: stricter runtime checks in
Apple's C library, which refuses certain unsafe patterns outright rather
than silently permitting them, and, later in the project, a build
configuration that type-checks function calls the original toolchain never
checked. Neither change introduced a defect; both exposed defects that had
been present, and untriggered, since the code was written. A sample of the
most illustrative follows. The fuller catalog — several dozen more of the
same species — is not yet consolidated in one place: some are in
`porting-assessment.md`'s bug-class writeups, some only in
`porting-changelog.md`'s dated log or in `claude-history/roadmap-old.md`'s retired detail,
and a few live only in fossil's commit history. Bringing them all into
`porting-assessment.md` as the single technical reference is a natural
next step, not yet done:
- **A core interface's arguments were declared in the wrong order for
roughly 35 years.** The class-definition file for the figure-drawing inset declared
one method as `Build(action, view, ...)`; every implementation, and every
call site, used `(view, action, ...)`. This had no runtime effect for the
entire life of the code, because nothing had ever cross-checked the
declaration against actual usage — until the ANSI C conversion effort,
described below, began doing exactly that.
- **A parser that never checked whether an embedded inset loaded.** `ez`'s
plain-text container read each inset via a success-or-failure code it
never checked. When a figure's reader failed partway through — on a
document with an attribute this older reader didn't recognize — the
container kept parsing from wherever the failed read had abandoned the
file: the figure's raw internal data appeared on screen as literal text,
and nothing after it in the document rendered at all. Fixed by falling
back to a generic reader that resynchronizes by counting nested begin/end
markers instead of assuming every read succeeds.
- **A debug-tracing macro that could corrupt the C library's own
declarations.** A debugging header renames `open` to `dbg_open` with a
preprocessor macro, so that file-descriptor leaks can be traced through a
wrapper. In source files that happened to include this header before the
system's `fcntl.h`, the macro rewrote the C library's *own declaration*
of `open()` — producing a declaration of `dbg_open` with `open`'s
variadic signature, which the wrapper's fixed three-argument definition
does not have. For the code's whole prior life this was harmless twice
over: K&R compilation had no prototypes to mismatch, and the calling
conventions of the era passed fixed and variadic arguments identically
anyway. On Apple Silicon they differ — variadic arguments travel on the
stack, fixed arguments in registers — so caller and wrapper disagreed
about where the file-permission argument was. Files were silently
created with garbage permission bits, and code that had just written a
temporary file found itself unable to reopen it, an "impossible"
permission error that traced back, through the disassembly, to a macro
and an include order. Fixed at the root: the debugging header now parses
`fcntl.h` itself before performing the rename, making the poisoning
impossible in any include order.
- **The message store's id scheme assumed case-sensitive filenames.** The
message store names each message's body file after its unique id — an
18-character string over a base-64 alphabet in which uppercase and
lowercase letters are distinct values. That scheme silently assumes
filenames are case-sensitive. In 1988 this looked like a safe
assumption: the case-insensitive filesystems were the *older*
systems — TOPS-10, VMS, CP/M, DOS — and UNIX, the newer one,
distinguished case. The filesystems that ended up dominant on the
desktop — HFS+, then APFS, and NTFS as Windows uses it — are
case-insensitive (case-*preserving*, which makes the trap quieter
still: names display exactly as written, they just refuse to be
distinct). On a Mac, two AMS ids differing only in letter
case are different strings and the same file. For thirty-five years
nothing noticed, because native ids derive from host and timestamp and
differ in many character positions at once. The revival's IMAP mirror
found it in an afternoon: it synthesized ids from dense sequential
message numbers, adjacent ids collided as filenames every time the
encoding walked the alphabet across a case pair, and a "new" message's
body file turned out to already exist — containing a different
message. The mirror's fix is an id alphabet with no case pairs; the
store's native ids remain a documented, if astronomically unlikely,
hazard on this platform.
- **Adew's build silently destroyed its own sample document, and stayed
that way for weeks.** `src/atk/adew/Imakefile` builds a launcher
script from `genarb` and writes it to a file named `arb` in the same
directory as `Arb`, the tracked `.ez` arbiter document the launcher is
meant to open — a real ATK datastream, not source code, so a build
silently overwriting it produces no compiler warning and no diff
anyone would think to check. On this checkout's case-insensitive
APFS volume, `arb` and `Arb` are the same directory entry: an early
build run replaced the datastream with the generated script, and that
change rode into a commit alongside unrelated work, unnoticed. The
loss surfaced independently and from outside the project entirely —
Adew's original author emailed asking whether an old copy of `Arb`
still existed anywhere, having hit the same collision in his own
history and concluded it might be unrecoverable. It wasn't: the
revival's `trunk` branch had never rebuilt `adew/` and still carried
the file untouched from the original import, byte-identical to what
he was describing. Restored from that copy on both branches; the
Imakefile now builds the launcher under a name (`arb-launcher`) that
can't collide with tracked source and renames it to `arb` only at
install time, in `$(DESTDIR)/bin`, where nothing tracked shares the
name.
- **Two independent header parsers, written decades apart, made the same
assumption about where a message's headers end.** RFC822 says a blank
line separates headers from body; both AMS's own mail reader
(`text822.c`'s `GetHeader`) and the vendored `metamail` program
(`Read822Prefix`) implemented that check by testing for a bare `'\n'`.
That is correct for every message the code had ever seen — the Andrew
mail system generated and stored its own messages internally with LF
line endings — until this project's IMAP mirror started handing it real
wire-format mail from Fastmail/Gmail/Outlook, whose blank line is
`"\r\n\r\n"`. Neither check ever fires on that input, with two different
failure shapes: `text822.c`'s header loop never terminates, so it reads
straight through the "blank line" and treats the entire message body as
a run of unrecognized headers (displayed in a tiny font, un-decoded,
every colon making the text before it bold — see `porting-changelog.md`'s
2026-07-21 entry for the full symptom list this produced); `metamail`'s
version reads to EOF looking for a separator that never arrives and
exits with "Could not find end of mail headers", which is what actually
blocked `cui`'s `type` command on the very first message in a real
mirrored inbox. Both are the same one-line fix — stop letting a bare
`'\r'` reset the "did we just see a newline" state — applied
independently in each file, since neither parser shares code with the
other. A third site in the same `text822.c` function turned up a day
later: finding the blank line correctly doesn't mean the lines
*before* it are clean — `GetHeader` still handed back each header's
raw `"...\r\n"` verbatim, and every one of those trailing `\r` bytes
got inserted as a literal, visible character in the ATK `text`
object, one per header line — "double spaced" was the visible result.
Same root cause, one level deeper in code this project had already
touched once.
- **An explicit cast was correct on 32-bit hosts and wrong on 64-bit
ones.** The font toolkit's bounding-box
routine measured a string's width by calling its own sibling method
through an explicit `(long *) &w` cast, where `w` was a plain `int`
local. On the 32-bit hosts this was written for, `int` and `long`
were both four bytes, so the cast changed nothing — a stylistic
choice, not a mistake. On this LP64 port `long` is eight bytes, and
the same cast became a real four-byte stack overflow on every call,
silently corrupting whatever local variable happened to sit next to
it in the frame — every time a figure-inset text label recomputed
its size. It produced no crash, just gradually garbled label text,
and it evaded the ANSI conversion's own compiler-warning-driven
audit for a specific reason: that audit worked by grepping for
`-Wincompatible-pointer-types` warnings, and an explicit cast is
exactly what that warning exists to suppress. A census built
entirely from compiler diagnostics could not, by construction, see
a mismatch the programmer had already cast into silence; only
reading the function by hand, then checking the original 1990s
source to confirm the cast predated this project, found it.
- **A folder-action dialog held pointers into a cache that could be freed
while it waited.** Clicking a folder in the
mail overview brings up a "What do you want to do with 'X'?" menu —
implemented not as a native modal but as ordinary event dispatch, so
background timers and callbacks kept running while the mouse waited over
the choices. The function held onto the folder's name strings as raw
pointers straight out of the directory cache; if anything freed that
cache entry during the wait — another window's rename/remove
notification, a periodic refresh — the strings went stale in place, and
clicking "Subscribe" afterward walked the freed memory character-by-
character and crashed. One sibling path in the very same function,
reached by "see the messages," already defended against exactly this by
duplicating the strings before doing anything that could invalidate
them — a comment even says so — but the defense was never extended to
subscribe/unsubscribe or "alter subscription status." For decades this
raced against, at most, an occasional slow local change and rarely lost.
The revival's IMAP mirror gave the directory cache far more frequent
invalidation traffic than the original single-user model ever produced,
turning a latent race from 1994 into a reproducible crash the first time
someone tried to subscribe to INBOX. Fixed by giving the affected paths
the same heap-copy discipline the "see the messages" path already had.
- **An error-cleanup path deleted the wrong file on a name collision.**
When the store
writes a message's body file, it opens with `O_CREAT|O_EXCL` — and if
the open *fails*, the error path does `unlink(File)` before returning.
That unlink is meant to remove a partially-written file, which is the
right cleanup everywhere later in the function, after the open has
succeeded. On the open-failure path there is no partial file — and in
the one failure mode where the target *does* exist, `EEXIST`, the file
belongs to an earlier message. So for thirty-five years, any append
that collided with an existing message file quietly deleted the
existing message. The author suspected something: the line above the
open reads `/* Bogus -- what if not overwrite? */`. It never fired
because the store's duplicate check (which keys on the RFC-822
Message-ID header) almost always intercepted true duplicates before
the filesystem could, and id collisions otherwise didn't happen. The
IMAP mirror lined up the holes: a message with no Message-ID slipped
the duplicate check, an RFC 3501 `n:*` range quirk re-presented an
already-mirrored message as new, and the mirror watched a message
file vanish from a directory nothing was supposed to delete from.
Fixed by deleting the one erroneous line.
- **Misspelled function names linked silently to the wrong library for
thirty-five years.** Three mail-filtering primitives — regular-
expression search and decomposition operations available to the mail
system's rule-based filtering language — called functions named
`regcomp` and `regexec` to compile and run a pattern. No function by
either name has ever existed anywhere in this codebase; the regular-
expression engine actually built and shipped with the software, a few
directories over, is named `reg_comp` and `reg_exec`, with an
underscore neither call site had. Pre-standard C never checked that a
called function actually existed before compiling the call, so the
compiler raised no objection, and the linker simply resolved the
misspelled names against whatever else provided them — in this case,
the operating system's own built-in `regcomp`/`regexec`, a wholly
different regular-expression implementation expecting a different
kind of first argument than the plain string these call sites
actually pass. Every call, for the software's entire life, therefore
invoked the wrong function with the wrong argument shape — a defect
invisible to any compiler or linker, on this platform or any other,
because both misspelled names happened to already mean something.
It came to light not through an automated check (nothing about it
would trip one) but during a close reading of the file while fixing
unrelated compile errors as part of the ANSI C conversion effort.
Corrected to call the codebase's own, correctly named and typed,
`reg_comp`/`reg_exec`.
- **The same declaration mismatch, independently, in five unrelated parts
of the codebase.** An interface file declaring
a wrong-but-plausible type for a constructor or destructor's own
object parameter — invisible because K&R never checked it — turned up
on its own in form widgets and a diagram-editing framework (six
instances), the mail reader's own message-display view classes (three
more), and the drawing library's arrow and polyline figure classes
(two more). Every real implementation was already correct in all
eleven cases; only the paperwork was wrong, apparently copy-pasted
from a neighboring class's declaration each time. A related mistake
in the same family ran the other direction — a drawing-library base
class silently dropped an argument all fifteen of its real subclasses
supplied — and a dead-end variant turned up too: two destructor
methods misspelled by one transposed letter pair ("Finialize" for
"Finalize"), never wired to anything and never missed, caught before
they could do damage rather than after. None of it needed a runtime
symptom to find; it surfaced by the same method every time, once the
ANSI C conversion's typed interfaces finally gave the compiler
something to check these old declarations against.
- **Attacker-controlled mail headers, used as a format string.** Two
routines in the metamail viewer — one saving a MIME attachment under a
name derived from its `Content-Type` parameters, one decoding an RFC
2047 encoded word from a message header — each pass text taken
directly from the *message itself* as the format argument to
`printf`/`fprintf`, rather than as an ordinary data argument. Any
percent-conversion sequence a message's author places in a filename or
an encoded-word payload is interpreted as a format directive against
whatever the stack happens to hold at that call, not displayed
literally — a live format-string vulnerability sitting in
header-parsing code that has processed arbitrary, untrusted mail for
over thirty years. It surfaced not from a security review but from the
same mechanical format-string sweep that found every other bug of this
shape in this project — the compiler's own diagnostic for a
non-literal format argument flags this pattern regardless of whether
anyone was looking for a vulnerability. Corrected by passing each
string as an ordinary `"%s"` argument at both sites.
- **A stale, argument-less forward declaration shadowed a correctly typed
real definition.** Dragging a
figure in the drawing editor worked vertically but froze horizontally
after the first motion event. The function that computes each drag
step's constrained coordinates was, at its real definition, already
correctly typed to write a `long` through each of its two output
pointers — but two duplicate copies of its forward declaration,
sitting above every call site in the same file, still used the old
argument-less style (`static int Set_Constraints();`), which is legal
ANSI C and gives the compiler no parameter information at all. The one
call site that mattered — the continuous drag-motion handler — held
its output variables in ordinary 4-byte `int`s, so every motion event
wrote 8 bytes through a pointer to a 4-byte stack slot, silently
corrupting whatever sat next to it. Retyping the file's forward
declarations to match their real definitions closed the hole and
immediately surfaced a second bug of the same shape in the same file.
Neither was reachable by any warning this project had enabled up to
that point — `-Werror=int-conversion` and its siblings only fire when
a real prototype is in scope to compare against, and an argument-less
old-style declaration is, by definition, not one. That gap raised an
obvious question — how many other stale, argument-less forward
declarations were sitting above correctly-typed real definitions
elsewhere in the tree? — and a tree-wide census answered it: roughly
6,700 such declarations, across nearly every active directory. A
mechanical pass retyped each one to match its real definition and let
the compiler itself separate harmless old style from genuine bugs,
turning up a short, specific list of further defects — an AMS API
call whose byte-count out-parameter had been declared half the width
every caller expected, a file handle typed as a plain integer where
every caller and the function's own body treated it as `FILE *`,
and nine ordinary argument-count/type drifts — all fixed, none of it
needing its own rollout milestone the way M1–M4 did.
- **A crop rectangle was never checked against the image it was
cropping.** A raster-format converter's crop option never validated its
rectangle against the source image's actual dimensions before reading
it — the underlying bitmap routines don't bounds-check either, by
design, on the assumption that callers already have. A crop rectangle
larger than the source image read straight past the end of the
allocated buffer and kept going, producing an 18-megabyte file of heap
garbage instead of an error, for an operation that should have been
rejected outright. Fixed with an explicit bounds check ahead of the
crop.
- **Backward scrolling silently no-op'd through any embedded view taller
than one screen — including plain raster images, not just HTML mail.**
`textview`'s backward-page command (`BackSpace` in `atk/text/textv.c`)
had a fast-path shortcut that computed how far to move back from a
cached line's `y`-coordinate plus its full height. For an ordinary short
line this arithmetic was essentially inert — a real page-up request is
always bigger than one line's height, so the shortcut almost never
fired — but for a single line taller than the screen, partially
scrolled into, the same arithmetic swallowed the entire backward-move
request every time, so `Escape-v`/prior-screen did nothing at all
while inside such a view. Confirmed live (2026-08-18) against the HTML
mail renderer's table layouts, but the bug is generic to `textview`
itself, not specific to HTML content — a plain `raster` inset larger
than the window hits the identical code path and was independently
observed with the same symptom. Effectively dead code for 30-some
years because nothing embedded had ever been taller than a screen
before. Fixed by removing the erroneous shortcut and relying on the
(already correct) shortcut beside it, based on how many pixels of the
current line are actually scrolled off-screen rather than the line's
total height.
- **`textview_Visible` treated text-position containment as proof of
pixel-level visibility.** `textview__Visible` answers "is this position
currently on screen," but for a position sitting exactly at the end of
the last laid-out line it only checked whether that position fell
within the line's text range — never whether the line's own bottom
edge actually fit inside the viewport. For ordinary short lines the two
questions have the same answer, but for a document whose entire
content is a single embedded view many screens tall, only partway
scrolled into view, they diverge: the position is trivially "contained"
while almost none of the line is actually visible. This made
"go to end" (`Escape >`, via `textview_FrameDot`) silently no-op,
because the framing logic saw `Visible()` return true and skipped
re-scrolling. Fixed by also requiring the last line's bottom edge fit
within the viewport, mirroring a check the scrollbar code (`getinfo`)
already made in the same file.
- **Backward-move-to-a-position always failed to land inside the very
first, tallest line of the document.** Once `Visible()` correctly
detected "not on screen" and asked `BackSpace` to scroll a target
position into view, `BackSpace`'s own pixel-budget search only ever
credited the height of lines strictly *before* the one containing the
target position — correct when the target sits at its own line's top
edge (the normal case, since the search is normally driven off the
current top-of-screen mark), but wrong when the target sits at the
line's *end*, as happens when the target is the very end of a document
whose entire content is one line. With no preceding lines to credit,
the search always concluded there wasn't enough room and fell through
to "can't go any farther," clamping to absolute position 0 — so
`Escape >` reset the scroll to the very top instead of leaving the
target framed near the bottom. Fixed by also checking whether the
line's own height (not just its predecessors') covers the remaining
budget, landing partway into it when so.
- **Forward-paging past the end of a document landed the scroll-top on a
position with no line of its own, blanking the screen.**
`textview__MoveForward`'s pixel-based branch, when a forward page
(`^v`, `textview_NextScreenCmd`) consumed the entire remaining height of
the document's last line, unconditionally advanced the scroll-top to
`text_GetLength()` — one past all real content. The layout code has no
fallback for that: it formats one empty line there and whites out the
rest of the view. This isn't specific to the one-giant-embedded-view
case that exposed it — it's latent for any document where forward-
paging exactly exhausts the last line — it just took a document like
this project's HTML tables to trigger it in ordinary use. This is
suspected to be the actual mechanism behind the project's original
motivating complaint, that repeatedly pressing `<space>` while reading
a message skips its last chunk and jumps straight to the next message.
Fixed by refusing to advance past the last line; instead reveal further
into it, capped so its bottom never scrolls past the viewport's bottom
edge — once reached, further forward-paging is a no-op instead of a
blank screen.
- **The scrollbar bottom-endzone click blanked the screen, but only on a
held click.** A single quick tap already worked. Targeted `write(2)`
tracing in both `scroll.c` and `textv.c` showed that `scroll.c`'s
auto-repeat timer for the endzone buttons (~100ms, easy for an ordinary
click to exceed) fires a second "jump to end" request identically to
the first, before the mouse button is released. The second request's
backward pixel-offset step hit `BackSpace`'s "stay within the current
line" fast-path shortcut — a shortcut whose own comment already stated
its precondition as operating on the current top line, but which was
applied unconditionally to whatever position was passed in. The
endzone jump's target is never the current top; it's a freshly
computed line near the end of the document. Because the first
(correct) landing had already set the view's "how far scrolled into
this line" state to approximately the same distance being requested
again, the second call's shortcut fired against that unrelated
leftover value and returned the target completely unmoved — landing
the scroll-top exactly at end-of-document, the same no-line-of-its-own
position the forward-paging bug above already established renders as a
blank screen. Fixed by only taking the shortcut when the position
passed in actually is the current top position, matching the
precondition the shortcut's own comment already claimed.
None of these are new mistakes. Each was introduced once, decades ago, and
never triggered — because the exercising code path was never run, because
nothing had checked a declared interface against its actual usage, or
because an earlier C library was more permissive. There was no test suite
to find them: what did was a stricter compiler, real use, and weeks of
manual, heuristic-driven work — grepping for a suspect pattern, rebuilding
with `make -k` to surface every hit at once, then reading each one by
hand. This development and repeated toil was done by Claude Code,
primarily with the Sonnet model managing delegated sessions following an
evolving `sonnet-playbook.md`, with the Fable model occasionally used for
reviews and for difficult problems.
**One exception, caught the same day it was made.** A drawing editor's
on-disk format for a figure's "mode" attributes packs three single-
character flags onto one line; the code that writes that line built all
three characters but only ever wrote two, silently dropping the third —
"halo" — on every save, a plain thirty-year-old bug fixed like the rest
above. Fixing it exposed a second, previously dormant bug in the very
same function: the three flag buffers were declared
`static char foo[2] = "?"`, scratch space seeded with a literal `?` and
only ever overwritten when their flag was on, never reset when it was
off. Because `halo` had never actually appeared in the output before,
its stale `?` sentinel was invisible; the moment the fix started
printing it, every figure with the halo flag off — effectively all of
them — got a literal `?` appended to its Mode line, desynchronizing the
reader for the rest of the document. Caught within hours by manual
round-trip testing (open, edit, save, reopen), not by any compiler
warning — nothing about a string that compiles cleanly but holds the
wrong content trips a diagnostic. Fixed by dropping `static` and letting
the buffers default to empty, which is arguably a second correction to
the same original mistake: true K&R C never allowed a stack variable to
carry an initializer at all, so the 1988 author most likely reached for
`static` only to make the `= "?"` syntax legal, not because persistence
across calls was ever wanted.
## Word size issues
The largest, most systemic category of defect came from a single
architectural fact: this code was written when a C `int` and a C `long`
were the same size — 32 bits, on the machines of the early 1990s. On
today's 64-bit Apple Silicon, `long` is 64 bits while `int` remains 32.
Code that quietly assumed the two were interchangeable — a reasonable
assumption for decades — now loses or corrupts data at every boundary
where the two are confused.
This is most acute in AUIS's object system, which implements polymorphism
(one class of object overriding a method defined by another) in plain C,
using function pointers stored in a dispatch table. Historically, every
method call went through generated code that cast that function pointer to
a completely generic, typeless signature — meaning the compiler, seeing no
argument types at the call site, could not insert the instructions needed
to correctly widen a 32-bit value to 64 bits, or to preserve its sign. Five
distinct, recurring failure patterns followed from this one root cause,
each responsible for real, visible bugs during the revival:
1. **A function returning a pointer — or any other 64-bit value — called
with no declaration in scope.** Pre-standard C assumes an undeclared
function returns a plain 32-bit `int`. If the function actually returns
something wider, the upper half is silently discarded — typically
producing a crash the moment a truncated pointer is used, or a
silently wrong number when the return is an ordinary integer instead.
This was the single most common defect in the codebase: more than
twenty separate sites, all the same shape, once the pattern was
recognized in one core function used to locate files on disk. The
later, tree-wide sweep for exactly this class of gap (M2, below) found
the same shape recurring by the hundreds across the rest of the tree —
including several genuinely `long`-returning (not pointer-returning)
library functions, such as a mail client's directory-lookup and
session-initialization calls, each confirmed against its real
definition, independently, before any declaration was written.
2. **More arguments than the processor's registers hold.** Apple Silicon
passes the first eight integer/pointer arguments in registers and
spills the remainder to the stack — but only if the compiler knows, at
the call site, that there are more than eight. Through the generic,
typeless dispatch above, it did not, and the ninth and later arguments
were silently dropped.
3. **A sentinel value corrupted by zero-extension.** The value `-1` is
commonly used as a special "unset" or "apply to everything" marker.
Passed as a plain 32-bit constant through the same typeless dispatch, it
is not sign-extended as it should be — the receiving 64-bit field ends
up holding 4,294,967,295 rather than "negative one," and any code
checking "is this negative?" or "does this equal -1?" silently takes the
wrong branch. This is what made a help-browser list open scrolled to the
bottom instead of the top: the "no position set yet" marker for a
freshly opened list was corrupted into a large number crossing one of
these dispatch calls, and the scroll position duly followed it.
4. **A 64-bit number read with a 32-bit text-parsing format.** Numbers
stored as text within document files were read back with `%d` (32-bit)
into variables that were actually 64 bits wide, leaving the upper half
as whatever value happened to occupy the stack. The clearest instance:
a drawing's on-page position was parsed this way and came out corrupted
by exactly 4,294,967,296 — the figure was being drawn correctly, just
roughly four billion pixels off-screen.
5. **A 64-bit parameter fed a corrupted 32-bit argument** — the same
zero-extension mechanism as pattern 3, but for ordinary values rather
than named sentinels, tripped by negative numbers used for scroll
positions, indentation, and margins.
Three related but mechanically distinct defects showed up alongside these
five, corrupting data across a similar boundary disagreement without being,
strictly, width problems:
- **A signedness mismatch rather than a width mismatch.** One inset type's
interface declared a method as returning a plain (signed) character
where the implementation actually returned an *unsigned* one. At low
compiler-optimization levels the discrepancy had no effect; at normal
optimization, the compiler exploited the declared (incorrect) signedness
and generated a comparison that could never succeed, so a "no line width
configured" sentinel was silently read as "maximum width," and an entire
figure was painted over in solid black. This belongs to the same family
as the width bugs above — a value crossing a boundary where the two sides
disagree about how to interpret its bits — differing only in that the
disagreement is over sign rather than size.
- **A pointer stored in a field only wide enough for a plain number.** Two
font-style fields in the note and troff-text insets had, since the
1990s, been mistakenly assigned the literal text `"fontdesc_Plain"`
rather than the symbolic constant of the same name — a copy error that
was harmless on the original 32-bit machines, where a pointer and an
`int` were the same size, and became live pointer corruption only once
the same code ran on a 64-bit processor. A related instance: a widget
class declared a parameter as a plain `unsigned` value where the actual,
pointer-carrying implementation needed something wide enough to hold a
full address, silently truncating a `self` pointer on every
control-panel widget built from that class.
- **A file-header struct declared its fields `long`, matching the on-disk
format only by 32-bit coincidence.** A raster-format command-line
converter, untouched by every earlier width-bug sweep because nothing
about it resembled the dispatch-related patterns above, declared its
file-header struct's three fields `long` — matching the format's actual
on-disk layout, a fixed 14-byte header, only on the 32-bit machines this
was written for. On a 64-bit build the struct is more than twice that
size, so every field after the first lands at the wrong offset; reading
such a file back fed uninitialized stack memory into an image-resize
call that ran away, consuming multiple gigabytes of memory before being
killed. The same file's color-inversion routine carried an independent
case of the identical assumption: a hand-optimized loop inverting four
bytes at a time walked a raw pointer cast to `long *`, with address
arithmetic sized for a 4-byte word; on this LP64 build each step
silently inverted eight bytes instead, missing a row's first few bytes
and overrunning its last few into whatever memory followed. Both had
sat unexercised since the format was written — nobody had tested this
converter's own output against itself on this platform until it finally
was, byte-for-byte, against the original image.
A mechanically unrelated defect, described above in "Modernizing," produced
a very similar-looking symptom: a code generator's own choice of table
storage width — not the processor's register width — changed underneath
old code. It does not belong to this family mechanically, but the
underlying lesson is the same: 1990s code that hard-assumed a fixed width
is fragile against any later link in the toolchain — compiler, processor,
or code generator — making a different, reasonable-at-the-time choice.
## Migrating to ANSI C
The oldest parts of this codebase predate the C language standard
itself — functions are defined in the pre-standard "K&R" style, with no
declared argument types anywhere. An early attempt to correct this
wholesale, by running an automated tool across the entire source tree in
one pass, made matters considerably worse: the tool's pattern-matching
approach to inferring argument types mishandled enough edge cases that a
single mass conversion took the build from roughly zero errors to over two
thousand, with no way to distinguish genuine bugs from the tool's own
mistakes. That attempt was reverted in full.
The approach that ultimately worked rests on one observation: this
codebase's class-definition files already describe every method's real
argument types in full, for an unrelated reason — they are the input to
the code generator that builds the object system's dispatch tables — and
that generator had simply been discarding the type information once it
parsed it. Teaching the generator to emit what it already knows turns the
compiler itself into the auditor: any place where a real implementation
disagrees with its own declared interface becomes an immediate, located
compile error, rather than a bug waiting to be found by accident at
runtime.
The resulting plan runs in four stages:
- **M1 — teach the class-definition compiler to emit typed code.**
Completed tree-wide in July 2026. This alone eliminated the
sentinel-corruption and too-many-arguments bug families by construction,
and caught dozens of decades-old interface mistakes — including the
backwards `Build()` arguments described above — simply by enabling real
type checking at every method call site. The rollout itself proceeded
one subdirectory at a time, verified by a clean full rebuild and a
runtime check after every step — the discipline the failed mass-attempt
had lacked.
- **M2 — sweep for missing function declarations** throughout the rest of
the tree, not just class methods, closing the undeclared-function
pointer-truncation pattern for good. The sweep surfaced two
complications of its own along the way: `malloc`/`free`/`realloc`/
`calloc` are compiler built-ins, so a call to one with no declaration in
scope anywhere never triggers the diagnostic being swept for — invisible
to the compiler-driven pass, closed only by a second, deliberate search
across every file regardless of how clean a directory's compile already
looked; and one class's method-dispatch macro, an optimization that
calls its internal implementation directly instead of through the usual
indirect table, turned out to declare that implementation inside a
header guard whose actual purpose was unrelated — avoiding duplicate
method tables across a multi-file loadable module — which had the side
effect of hiding a real, correctly-typed declaration from the one
caller that needed it, harmlessly, for over thirty years.
- **M3 — convert the function definitions themselves** from pre-standard
to standard C, one subsystem at a time, using a purpose-built tool that
looks up each function's real signature from the class-definition files
rather than inferring it — the safer approach the original mass-
conversion attempt should have taken. Completed tree-wide August 2026,
15 sessions across all 91 active directories. Consistent with M1's
experience, real type/arity checking surfaced numerous decades-old
interface bugs along the way — several are recounted below — and the
conversion tool itself needed two rounds of fixes for K&R declaration
shapes its own parser had missed.
- **M4 — enable full compiler strictness** tree-wide, once every subsystem
has been converted, which closes the remaining scanf-format-code bug
family as a side effect. Completed August 2026, directory by directory,
seven waves plus an initial pilot batch.
As of this writing, M1 through M4 are complete across the entire active
codebase, each having turned up several of the decades-old defects
described above. A follow-on tree-wide census closed the one remaining blind
spot the four milestones couldn't see on their own — stale argument-less
forward declarations shadowing correctly-typed real definitions — and is
described in its own entry above.
## Where things stand today
`ez` (the word processor), `help`, and `messages` (mail, running against a
local mailbox and, now, a live IMAP mirror, rather than the original 1990s
shared-filesystem delivery system) all run and render correctly, including
most embedded inset types: text, equations, tables, drawings, animations,
footnotes, spreadsheets, and more. `bush`, `org`, `chart`, and `layout` all
run cleanly too. The ANSI C conversion described above (M1 through M4) is
complete across the entire active tree — every method call and function
definition in it is now compiler-checked. A live, itemized table of what's
fully working versus still rough is in `roadmap.md`, rather than repeated
here.
One deliberate scope decision from early in the project: the Console
(terminal-emulator) subsystem is not part of this build.
Console's interprocess-communication layer would need a rewrite of its own,
and the project set that aside rather than take it on alongside everything
else. A small side effect: two icon fonts used by an unrelated animation
inset happen to live inside Console's own build directory, so they go
silently unbuilt too, unless Console — or just its font directory — is
built on its own.
A handful of smaller, known issues remain open, described below in "Open
issues" and tracked in full in `roadmap.md`.
## Open issues
A few problems remain unresolved, either because the root cause itself
isn't confirmed or because a workaround is in place without a real
upstream fix.
- **A live deadlock, cause unconfirmed.** While chasing an unrelated
defect, a `help` window occasionally froze permanently. A debugger
traced this to the process's single thread trying to reacquire a lock
on the X display connection it already held indirectly, triggered by a
keyboard-layout-change notification arriving mid-operation. Whether this
is a genuine defect in how the toolkit calls back into the X client
library, or a quirk specific to this environment's X server, hasn't been
determined.
- **A completely empty build directory fails on the very first build.**
The class-system directory everything else depends on builds its own
pieces in a fixed order, and that order builds one piece needing
`class.h` already in place before the piece that actually installs
`class.h` runs. Any build directory that already has a copy sitting
around from an earlier build never hits this; only a genuinely empty one
does. The current fix is a manual step — copying the header into place
before the first build — and a build-ordering problem is the leading
explanation, though not a settled one: this step wasn't always necessary
earlier in the project, and it isn't yet certain whether the analysis
has found the real root cause or only a reliable workaround.
- **`metamail` has never successfully run in this environment.** Invoking
it — directly, or indirectly through `messages`' MIME-attachment
handling — raises `SIGTTOU` inside the routine that spawns a
mailcap-entry viewer subprocess: ordinary 1980s terminal job-control
code performing an `ioctl()` a foreground process doesn't expect.
Confirmed unrelated to the ANSI C conversion work happening alongside
it — the crash lives entirely in terminal/process-group handling,
untouched by any declaration or typing fix — and predates this project;
nobody has reported metamail working here at any point. Root cause
identified; not yet fixed.
## Further reading
- `porting-changelog.md` — the complete, dated history of every fix, in
the order it happened
- `porting-assessment.md` — the underlying bug-class analysis, strategy
decisions, and the full ANSI C conversion plan
- `roadmap.md` — current status, open issues, and near-term plans
- `version-comparison.md` — why 6.3.1 (the last C release) was chosen over
the later, unfinished C++ rewrite
- `quickstart.md` — how to build and run this on a Mac