Artifact 98edf8a06156d483f9617d77e8a4f1a7c885d24f5cb3f9ee34a55f32da5caad9:
- File revival/doc/porting-assessment.md — part of check-in [f0fb2de6b4] at 2026-08-01 21:42:19 on branch andrew-6.4 — docs: retire M3 prompts/reports/runbook/batches into claude-history/m3/ subdir (38 files); repoint all cross-references; document in claude-history/README.md (user: wdc size: 139481)
Porting Assessment: AUIS 6.3.1 on Modern Linux
An assessment of what it would take to build the AUIS 6.3.1 C codebase on a current Linux distribution.
Good news: Linux support already exists
The 6.3.1 tree includes a Linux port in config/i386_Linux/ with
system.h and system.mcr. The platform.tmpl wires it in via
#if defined(i386) && defined(linux). The port uses gcc, flex, POSIX
calls, and standard X11 paths. The system.h is only 137 lines — mostly
mapping AUIS's osi_ portability wrappers to standard POSIX functions.
Build system
AUIS uses imake (from X11) driven by Imakefiles, platform.tmpl,
allsys.mcr, per-platform system.mcr, and site.mcr/site.h for
local customization. imake still exists in most distributions
(package xutils-dev on Debian/Ubuntu). A CMake or plain Makefile
migration might be worthwhile eventually but isn't necessary to get started.
Strategic decision: compiler leniency over wholesale modernization
Early in the revival, we tried mass-converting the K&R C source to ANSI C
(explicit prototypes, typed parameters) using an automated tool
(revival/tools/modernize). This was unreliable at scale — edge cases
in K&R parsing (multi-name declarations, function pointers, split-line
definitions) caused the tool to silently introduce bugs across hundreds
of files. A single mass-modernization pass took the error count from
roughly zero to over 2000.
The working strategy instead: leave the K&R source untouched and use compiler flags to relax modern clang/gcc's strict defaults back to behavior compatible with 1990s C compilers:
COMPILERFLAGS = -Wno-implicit-int -Wno-implicit-function-declaration \
-Wno-incompatible-function-pointer-types
This took the build from ~1062 errors (after the modernizer revert) down
to ~344 — and critically, those 344 are real portability problems
(sys_errlist removed from libc, Display/FILE struct internals
hidden by modern headers) rather than self-inflicted tool damage.
Current policy: Do not run the modernizer across the tree. Only
hand-edit or modernize a file when:
- Build forces it (a structural incompatibility, e.g. <a.out.h>
doesn't exist on Darwin) — fix narrowly, not wholesale
- We are touching that file for an unrelated reason anyway
Follow-on effort (now underway): A deliberate, careful pass to bring
the codebase to full ANSI/POSIX C — the working runtime baseline this
paragraph was waiting for now exists. Assessed 2026-07-08; full plan in
§14. Note the tool verdict changed: modernize's regex K&R converter is
not the vehicle for that pass (§14 explains why), so its "known
limitations" are moot rather than a to-fix list. Leniency flags remain in
force per subtree until that subtree is converted and its flags ratcheted
to errors.
Issues to address
1. gcc -fwritable-strings — RESOLVED 2026-07-23 by re-enabling the flag
Correction to the original assessment below: this was not, in fact,
one of the issues a compiler flag can't paper over. The Linux
system.mcr specifies CC = gcc -fwritable-strings; real gcc dropped
the flag in 4.0 (2005), which is what led to the "HIGH effort,
tedious-but-mechanical, defer it" verdict this section originally
recorded. But Apple clang (the cc this tree actually builds
with on Darwin) still implements -fwritable-strings — verified
directly: char *p = "hello"; p[0] = 'H'; bus-errors when compiled
plain, exits clean with -fwritable-strings added. config/darwin/system.mcr
had never set it (only the legacy i386_Linux/i386_bsdi/i386_bsd/
i386_mach configs did), which is exactly why the Gate-1 hand sweep
below (revival/doc/claude-history/strlit-sweep-prompt.md →
strlit-REPORT.md, both retired there together) kept finding
live-if-narrow instances. Fix applied: CC = cc -fwritable-strings
in config/darwin/system.mcr, full clean rebuild (make Clean,
rm -rf build, make World) — confirmed the flag reached all 893
compile invocations in the rebuild log. Two link/type-error failures
surfaced by the full wipe (ams/msclients/nns missing $(SSLLIB),
contrib/zip/utility/ltapp.c incompatible-pointer-conversion) are
pre-existing and unrelated to this flag; ez/messages/cui/help/runapp
all built clean.
Scope, quantified after the fact with a -Wwrite-strings
(-Wincompatible-pointer-types-discards-qualifiers) compile-only scan
across the same 849 translation units: 26,628 raw literal→char*
sites; 13,937 of those are struct classheader-style fields in
generated .ih/.eh (every class-based .c file inherits a couple
from traced.ih/observe.ih/atom.ih/etc. — inert boilerplate, would
need a classpp template change, not source-by-source fixes, to
clean up "properly"). The remaining 12,691 are anchored in
hand-written .c code across 444 files — but spot-checking the
heaviest hitters (atk/text/txtvcmds.c 816, atk/eq/symbols.c 399)
shows these are dominated by large static descriptor tables
(keybinding tables, symbol tables, command tables: {"name", "key",
..., "description"} rows) whose char * fields are never written
back through — a struct-typing habit, not a mutation. The much
smaller subset that's an actual mutate-through-the-pointer bug (the
class the Gate-1 sweep targeted by tracing real mutator functions —
StripWhiteEnds, LowerCase, MapParens, etc.) is a few dozen call
sites at most, tree-wide.
With the flag in place none of this — inert or genuinely
mutate-in-place — is live risk anymore; the counts above are kept for
context (why the flag is load-bearing, and roughly how much code
leans on it) rather than as a remaining to-fix list. The Gate-1 sweep
findings in claude-history/strlit-REPORT.md are superseded as action
items by this fix; that report's per-site detail remains useful if the
flag is ever dropped (e.g. a future toolchain migration away from
Apple clang).
Considered and rejected: a full cleanup pass to make the tree
-fwritable-strings-independent. The 26,628 warnings break into
three tiers of very different cost: (1) struct classheader's two
fields, centralized, ~13,937 warnings, one struct + one classpp
codegen template — bounded and mechanical, reusable via the same
byte-identical-header diffing the M1 rollout already proved out; (2) a
dozen-ish table-owning struct types (bind_Description etc.) behind
most of the remaining volume — per-type auditing, moderate effort; (3)
the actual mutate-through-the-pointer functions (StripWhiteEnds,
ReduceWhiteSpace, LowerCase, MapParens, ProcessCommand,
HandleAddress, hexout, html__ChangeAttribute, ~150 call sites
combined) — these can't just be retyped const, since they genuinely
write through the pointer; each needs the atomlist private-copy
treatment (fossil f91cb255) and per-call-site behavior verification,
since several reuse the pointer in place
(arg = StripWhiteEnds(arg);) and a copy-based rewrite changes memory
ownership. Tier 3 alone is essentially Gate 2 of the original
strlit-sweep-prompt.md. Net judgment: multiple sessions of real
engineering effort to reach a state the compiler flag already gives
for one line and ten minutes. Deferred — not needed unless/until a
future toolchain genuinely can't provide an equivalent flag.
Future Linux port: does the safety net survive? The concern that
prompted the above: real gcc dropped -fwritable-strings in 4.0
(2005), and a Linux port might default to gcc, silently losing the
protection this section relies on. Resolution: -fwritable-strings is
a standard upstream Clang driver flag (from LLVM's own Options.td,
not an Apple SDK patch), so a Linux clang should have it too —
distro packages (apt install clang / dnf install clang) are the
same LLVM frontend for this purpose. Not yet verified on an actual
Linux box (none available from this environment) — a 30-second
clang --help | grep writable-strings check the day a Linux config is
revived should confirm it before relying on it. If confirmed, the fix
for that future config is the same one-line move made here:
CC = clang -fwritable-strings instead of assuming the distro's
default gcc, mirroring how config/i386_Linux/system.mcr already
hardcoded a specific compiler for its own reasons. This is the same
risk category as this build's existing reliance on -std=gnu89 and
other legacy-compatibility flags — not a new kind of fragility.
Editorial note (2026-07-23): this section has grown long in the
telling. When the Linux port actually starts, it's worth condensing
issue #1 down to current-state-and-decision (flag set where, why,
what to check on the new platform) with the reasoning/history moved to
claude-history/ — a human skimming "issues to address" shouldn't have
to read the full investigation to see the current picture.
<details> <summary>Original assessment (superseded, kept for history)</summary>
The Linux system.mcr specifies CC = gcc -fwritable-strings. This flag
was removed from gcc in version 4.0 (2005). It allowed code to modify
string literal contents in place, e.g.:
char *p = "hello";
p[0] = 'H'; /* undefined behavior without the flag */
This implies such patterns exist throughout the codebase. Finding and
fixing all instances is tedious but mechanical — change string literals
to char[] arrays or allocate writable copies. A modern compiler will
warn or crash on the unfixed ones, so they're findable.
This is one of the few K&R-era issues compiler flags cannot paper over — writing into a string literal is undefined behavior, not just a stricter diagnostic. Per the strategic decision above, defer fixing this broadly; address it only in files we touch for other reasons, until the follow-on ANSI/POSIX modernization effort.
</details>
2. glibc FILE struct internals (LOW effort)
#define FILE_HAS_IO(f) ((f)->_IO_read_end - (f)->_IO_read_ptr)
This reaches into glibc's internal FILE struct layout, which has changed.
Modern glibc hides these fields. Replace with a portable alternative or
remove the optimization (it's a buffering check).
Similarly, FILE_NEEDS_FLUSH is defined to always return 1, which is
already the safe/portable behavior.
3. Dynamic object loader (MEDIUM effort, but a simplification)
The overhead/class/ directory contains AUIS's custom dynamic loader for
.do (dynamic object) files. This was the hardest part of every port —
it had to understand each platform's object file format.
Modern replacement: dlopen()/dlsym()/dlclose() from <dlfcn.h>,
which is standard POSIX and works everywhere. The .do files become
standard .so shared objects. This is actually a simplification over
the original, but touches the core of the system — every inset is loaded
through this machinery.
Note: genstatl already exists for building without dynamic loading.
A static-linked build could be the first milestone, deferring the
dlopen() migration.
4. Andrew custom malloc (LOW effort)
ANDREW_MALLOC_ENV is defined by default. This custom allocator may
conflict with modern allocators and address space layout randomization.
Can be disabled by #undef ANDREW_MALLOC_ENV in site.h.
5. Platform configuration cleanup (LOW effort)
The config/ directory has 30+ platform directories (VAX, RT, Apollo,
Mac II, NeXT, etc.) and platform.tmpl is a 220-line cascade of
#ifdef blocks for all of them. For the revival, strip everything
except the Linux path. This is just cleanup, not a porting problem.
6. X11 paths and libraries (LOW effort)
The system.mcr hardcodes:
XUTILDIR = /usr/bin/X11
XLIBDIR = /usr/lib
XLIB = -L$(XLIBDIR) -lX11
Modern Linux puts X11 in /usr/include/X11, /usr/lib/x86_64-linux-gnu,
etc. Fix the paths in site.mcr or update the defaults. The X11 API
itself hasn't changed in the ways that matter — Xlib is remarkably stable.
7a. Deferred: legacy sgtty-based terminal clients — tm, vui (LOW priority, defer)
contrib/tm and ams/msclients/vui are curses-style terminal mail clients
built in part on the pre-POSIX BSD sgtty tty API (TIOCGETP, TIOCREMOTE,
struct sgttyb, CBREAK/RAW/CRMOD modes). That ioctl interface was
removed from the kernel decades ago (macOS keeps the #defines in
<sys/ioctl_compat.h> for source compatibility only — the ioctls themselves
are gone), so even a clean compile wouldn't produce a client with working raw
terminal input. Making these actually function means rewriting the tty layer
to use termios (tcgetattr/tcsetattr, cfmakeraw), which is a real
porting project in its own right, not a mechanical fix. vui additionally
calls curses raw()/termcap globals (CM, SO, ...) that modern ncurses
no longer exports directly — a separate curses-port problem on top of the
sgtty one.
messages (the GUI ez client, built on atkams/) is the primary
destination for mail in this revival, so tm/vui are low priority. They
remain conditionalized out of the build rather than patched to merely
compile:
contrib/Imakefile:TMstill requires#define MK_TM(was unconditional except on SGI)ams/msclients/Imakefile:VUIstill requires#define MK_VUI(was unconditional)
Revisit as a dedicated termios/curses-port task if a terminal-based mail
client is ever wanted alongside messages.
ams/msclients/cui turned out not to belong on this list. It was
originally grouped here on the assumption that it shared vui/tm's sgtty
dependency. In fact cui doesn't use curses at all (only vui does), and
its one BSD-sgtty reference — a #ifdef POSIX_ENV/#else fallback in
GetBodyFromCUID(), under the rarely-built METAMAIL_ENV — was already dead
code on this platform: POSIX_ENV is unconditionally defined in
config/darwin/system.h, so the termios branch was the one actually
compiling. cui was still failing to build, but for an unrelated reason —
its Imakefile never got the ${RESOLVER_LIB} link fix that nns received
on 2026-07-05 (§ above). Fixed 2026-07-07: ${RESOLVER_LIB} added to
ams/msclients/cui/Imakefile's ProgramTarget lines; cuin now compiles,
links, and installs cleanly. MK_CUI is enabled in config/site.h. Full
detail in porting-changelog.md's 2026-07-07 entry.
7b. Deferred: contrib/bdffont (LOW priority, defer)
contrib/bdffont's parser splits bison's output across two files: a
generated bdfparse.tab.c plus a hand-maintained bdfparse.act
containing the grammar's C action bodies (#included separately at
bdffont.c). bdfparse.act does not exist anywhere in the source tree —
no fossil history, no Imakefile rule that generates it. overhead/mkparser
is a working, already-fixed tool for a related scheme, but it emits one
merged prefix.c/prefix.h, not this split .tab.c+.act convention, so
it doesn't apply here. Reconstructing bdfparse.act means hand-writing the
parser's semantic actions from bdfparse.y's grammar with nothing to
verify against — exploratory reverse-engineering, not a mechanical fix.
(The same broken split-file convention also appears in atk/ness/type,
atk/ness/objects, and atk/syntax/parse's testparse test target, but
none of those are currently reachable: atk/ness requires
MK_NESS/MK_AUTHORING, which isn't defined, and testparse isn't part
of make install. bdffont was the only one actually blocking the
build, since contrib/Imakefile listed it unconditionally.)
Conditionalized out of the build rather than patched to merely compile:
contrib/Imakefile:BDFFONTnow requires#define MK_BDFFONT(was unconditional)
Revisit if bdfparse.act can be recovered from an original CMU
distribution, or if someone is willing to hand-write it against the
grammar.
7. Console/stats module (LOW priority, defer)
atk/console/stats/i386_Linux/ contains platform-specific code for
reading system statistics from /dev/kmem and /proc. The kernel
interfaces have changed completely. This module is not essential —
defer or disable it.
8. Misc POSIX drift (LOW-MEDIUM effort)
setreuid(r,e)is mapped tosetuid(r)— modern Linux hassetreuid()osi_vfork()maps tofork()— fine, vfork() is deprecated anyway_setjmp/_longjmp— still available but may need reviewFNDELAYmapped toO_NONBLOCK— correctgetwd()mapped togetcwd()— correctNDEBUGis forced on to work around a missing___eprintf()in shared libs — this is long since fixed in modern glibc, remove the#define
Recommended build-up strategy
Phase 1: Static build of core ATK (smallest surface)
Disable in site.h:
```c
#undef AMS_ENV /* skip mail system entirely */
#undef ANDREW_MALLOC_ENV /* use system malloc */ ```
Build target: overhead/ (minus class dynamic loader) + atk/basics/ +
atk/text/ + atk/support/. Use genstatl for static linking.
Goal: get the class preprocessor and core text objects compiling.
Phase 2: ez running
Add atk/ez/, atk/frame/, atk/supportviews/, atk/textaux/,
atk/textobjects/. Goal: ez starts and can edit a document.
Phase 3: Dynamic loading via dlopen()
Replace the overhead/class/ loader with a dlopen() wrapper.
Insets become standard .so files. Goal: ez can load insets dynamically.
Phase 4: Additional insets and applications
Bring up figure, raster, table, help, etc. one at a time. Each is relatively independent once dynamic loading works.
Phase 5 (optional): AMS and other subsystems
The Andrew Message System, if desired. This has its own large set of dependencies (mail delivery, white pages, etc.) and could reasonably be left for much later or not at all.
9. Font system (in progress as of 2026-07)
AUIS was written for the X core font protocol — server-side bitmap font rendering with XLFD naming, custom "Andy" bitmap fonts (BDF/PCF format), and integer glyph metrics. The revival is migrating to client-side Xft rendering in phases, while retaining the Andy symbol fonts for characters that have no standard substitute.
What was found
The build tree includes 40 compiled PCF fonts in build/X11fonts/. The
fonts.alias file in that directory maps all Andy text font names
(andysans*, andytype*) to standard Adobe Helvetica/Courier XLFD names
already present in XQuartz — so no custom Andy text bitmaps need to be
installed separately for text rendering. The symbol fonts (symba*.pcf,
five sizes: 8, 10, 12, 16, 22 point) are CMU-custom with no standard
substitute; without them, bullet characters render as 7.
Text rendering quality with the original X core path (before Xft) was
surprisingly good — font appearance was correct, and bold/italic/size
changes all worked properly. The visible rendering problem was a frame-size
reporting bug in the help application causing text to appear clipped at
the right margin; this was unrelated to fonts.
Current approach: hybrid Xft + X core
Two rendering paths exist in the revival; Xft is being introduced conditionally:
| Rendering path | Status |
|---|---|
| Body text | Migrated to Xft (2026-07, phase 1 complete) |
| Menus | Xft migration in progress (2026-07, phase 2) |
| Symbol characters (bullets, math) | Andy symba*.pcf via X core — permanent |
| Cursor shapes | Andy cursor PCF via X core — permanent |
Because the symbol and cursor fonts have no Xft/fontconfig equivalent,
xset fp+ build/X11fonts && xset fp rehash remains a required setup step
even after the Xft text migration is complete. See quickstart.md for the
exact invocation.
Key source files
atk/basics/x/xgraphic.c,xfontd.c— font abstraction and X drawingatk/text/— text measurement and drawingatk/support/— style and font selectionbuild/X11fonts/fonts.alias— Andy→Adobe XLFD name mappings
11. %d / %ld mismatch in scanf family (MEDIUM effort, systemic)
AUIS was written for ILP32 platforms where sizeof(int) == sizeof(long)
== 4. Throughout the codebase, long variables used for sizes, IDs, and
dimensions are read with %d format specifiers in scanf/fscanf/
sscanf. On LP64 arm64, long is 64 bits but %d tells fscanf to
write only 32 bits — the upper 32 bits of each long remain as stack
garbage.
Unlike printf mismatches (wrong output, no memory write), scanf
mismatches corrupt the stack frame: dimensions come back as plausibly-
small values when garbage upper bits happen to be zero, and as wildly
wrong large values otherwise, causing crashes or corrupt rendering.
Known instances (confirmed 2026-07-02):
- atk/raster/lib/raster.c:660 — long version, width, height read
with %d %d %d
- atk/raster/lib/rasterio.c:391 — identical pattern
Fix: Change %d to %ld for long * arguments. Also initialize
long locals to 0 before the fscanf so a partial write leaves 0
in the upper bytes rather than stack garbage.
To audit the full tree:
grep -rn 'fscanf\|sscanf\|scanf' src/ --include="*.c" | grep '%d'
For each hit, verify whether the corresponding argument is int * (correct)
or long * (needs %ld). This pattern is likely widespread given the
codebase's ILP32 heritage; every graphical inset that reads dimension or
ID values from a file is a candidate.
This is LP64 variant #4, distinct from the three fixed earlier:
- #1 Undeclared function → implicit int return, pointer truncated
- #2 >8-arg call through (void(*)()) → stack argument dropped
- #3 int constant through untyped dispatch → zero-extended, comparison fails
12. LP64 untyped dispatch: long parameter / int argument mismatch (MEDIUM effort, systemic)
Root cause
The ATK class system generates method dispatch macros of the form:
#define lpair_Init(self,l1,l2,x) \
((* ((void (*)())((self)->header.lpair_methods->routines[59]))) (self,l1,l2,x))
Every virtual method call goes through an untyped void (*)() cast. Because
the compiler sees no parameter types at the call site, it cannot insert the
sign-extension or zero-extension instructions it would emit for a typed call.
On arm64 (LP64), when an int value of -N is passed through such a dispatch
to a function that declares the receiving parameter as long:
- The caller stores the 32-bit value in a register:
0xFFFFFFE7(for -25) - The upper 32 bits of the 64-bit register are zero, not sign-extended
- The callee reads a
longfrom that register:0x00000000FFFFFFFFE7= 4,294,967,271 (positive)
The net effect: any function that (a) is dispatched through the class vtable,
(b) declares a parameter as long, and (c) is called with a negative int
expression will receive a large positive value instead of the intended negative
one. Sign-dependent logic (if (x < 0)) silently takes the wrong branch.
This is LP64 variant #3 (extended). Earlier LP64 variants:
- #1 Undeclared pointer-returning function → implicit int return → pointer truncated to 32 bits
- #2 >8-arg call through void (*)() → 9th+ args spilled to stack with wrong ABI, dropped
- #3 int constant through untyped dispatch → zero-extended, sign-dependent branch fails
- #4 %d with long * in scanf → only 32 bits written, upper 32 bits garbage (see §11)
Confirmed instances (as of 2026-07-02)
| File | Function | Parameter | Effect when wrong |
|---|---|---|---|
atk/supportviews/lpair.c |
lpair__Init |
long x receiving int -MAINPCT |
calls VFixed instead of VSplit; PERCENTAGE→BOTTOMFIXED; panel gets 0 width |
atk/textobjects/panel.c |
call to style__SetNewIndentation |
Operand declared long, called with int -16384 |
indentation becomes +4 billion units; all panel text rendered off-screen |
The lpair__Init fix changed the parameter declaration from long to int.
The panel.c fix cast the literal to (long)-16384 at the call site.
New sub-variant (2026-07-05): mismatch isn't limited to dispatch-macro call sites
CUI_GetHeaders (ams/libs/cui/cuilib.c, reached from atkams/messages/lib's
captions.c/capaux.c via the ams_CUI_GetHeaders class dispatch) showed the
same root mechanism firing across a plain unprototyped C function call,
not just the void (*)() vtable macros — worth recording since it means the
grep-for-dispatch-macros audit query in this section won't find every
instance:
- By-value case:
startbytewaslongincuilib.c'sCUI_GetHeadersand in the already-longams/libs/snap/cuisnap.c(dormant SNAP-networked variant), butintin the.chclass interfaces (ams.ch/amsn.ch/amss.ch), their.cimplementations, and the real caller (atkams/messages/lib/capaux.c'stotalbytes, accumulated across a header- read loop inInsertUpdatesInDocument). Register-garbage-dependent — worked on one host, segfaulted immediately on a second host (spoon) with the same fossil checkout, confirming the "upper 32 bits are whatever was there before" mechanism rather than a deterministic value bug. - By-pointer case (new, more dangerous):
nbytes/statushad the same int-vs-long mismatch, but as pointer types (int *vslong *) rather than by-value.ms/libs/ms/headers.c'sMS_HeadersSince(the actual local- mailbox implementation that writes through these pointers) declaredint *numbytes, *bytesleft; everything above it in the active call chain read them back aslong *. This isn't just a misread value — a write through the narrower-than-expected type only fills half the register-width the reader expects, so the reader picks up genuine adjacent-memory garbage in the upper bits, same failure mode as the by-value case, but the underlying hazard (writer and reader disagreeing on a pointer's pointee size) is the more serious member of this family: if the size relationship were reversed (writer wider than the true allocation), it would be an actual out-of-bounds write, not just a garbage read.ams/libs/snap/cuisnap.calready had the correctlong *throughout, confirminglongwas always the intended type andheaders.cwas the file that never got updated.
Fixed by widening the int/int * side to long/long * everywhere in the
active chain (headers.c, .ch interfaces, .c implementations, capaux.c,
foldaux.c), plus the dormant SNAP-server side (ams/ms/ms.c) for
forward-compatibility. Full file list in porting-changelog.md's 2026-07-05
entries.
New sub-variant (2026-07-24): explicit cast masks the mismatch from the compiler
The M2 census (revival/doc/claude-history/m2-census-REPORT.md) found this
family's by-pointer shape mechanically, via -Wincompatible-pointer-types
warnings against typed .ch/.ih signatures — but that method has a blind
spot: an explicit pointer cast at the call site silences the warning outright,
so a real instance can sit in the tree indefinitely without ever showing up in
a compiler-driven sweep.
Found by hand while root-causing the figotext label-rendering corruption
(figure-inset text showing as stray single characters, position correct):
fontdesc.c's fontdesc__StringBoundingBox calls its sibling method
fontdesc_StringSize like this:
int w, a, d, ascent, descent, junk;
...
fontdesc_StringSize (font, graphic, string, (long *) &w, (long *) &junk);
StringSize's real implementation (xfontdesc__StringSize, atk/basics/x/
xfontd.c:960) writes through both out-params as genuine long * (8-byte
stores: *XWidth = retWidth; *YWidth = 0;). w and junk are int — 4-byte
stack slots — so every call overflows 4 bytes past each one. Confirmed via
fossil artifact against the original trunk import
(90afc0c28e, both the 2026-06-24 initial import and the 2026-06-29
"revert all .c to trunk" commit): this cast is verbatim 1990s CMU source, not
something introduced during this port. On the original 32-bit platforms
int/long were the same width, so the cast was always a no-op there; LP64
is what turns it into a live stack overflow.
Census method for this sub-variant (a compiler-warning sweep won't find it — it has to be syntactic): grep the tree for the masking shape itself, then manually check the target variable's real declared type at each hit.
grep -rnE '\((long|int|short)\s*\*\)\s*&[A-Za-z_]' --include=*.c .
Run tree-wide 2026-07-24: 21 hits total.
- 1 confirmed bug: fontdesc.c:378 (above).
- 14 safe: rm.c (×4), dataobj.c (×2), view.c (×2), frame/
framecmd.c (×1, plus osi_Times.Secs which is already unsigned long)
all cast a struct * pointer to (long *) — pointers and long are both
8 bytes on this LP64 build, so these are the ordinary "store a pointer in
a long-typed slot" (rock-style) idiom, not a mismatch.
- 6 hits are dead code, confirmed not built in this configuration (no
.o under build/ for any of them): contrib/mit/fxlib/rpc3.9/* (old
RPC library) and overhead/class/machdep/next_mach/doload.c (NeXT-only
machdep, never selected by config/site.h on Darwin).
Narrow in this tree as of 2026-07-24 — one real site — but worth re-running
this grep any time more of the tree gets ANSI-converted, since a .ch
signature widening (like StringBoundingBox's own int*→long* fix earlier
the same day, m2-census-REPORT.md row 22) is exactly the kind of change
that turns a previously-matched, harmless cast into a fresh mismatch. This
method only catches concealment via an explicit narrowing/widening cast; it
doesn't add coverage beyond what m2-census's compiler-warning method
already found for uncast mismatches.
Strategic options
Option A — Fix the dispatch mechanism: Change the generated .ih macros
from void (*)() to properly typed function pointers. Correct in principle;
requires modifying the class preprocessor in overhead/class/, regenerating
all .ih files, and careful verification across the whole system. High risk,
high reward.
Option B — Systematic parameter audit: Identify all vtable methods whose
implementations declare long parameters, then audit call sites for negative
int arguments. Fix either by changing long → int in the implementation
(appropriate for values that will never exceed 32-bit range: pixel sizes,
percentages, style margins) or by casting to (long) at the call site
(appropriate when the value genuinely needs 64-bit range). This is mechanical
and can be driven by grep.
Option C — Fix on contact (current approach): Fix each instance as it manifests as a visual or runtime bug. Low risk per fix, high whack-a-mole factor.
Recommendation
Option B — systematic audit is the right next step after the immediate runtime issues are stable. It is a one-time effort that eliminates the entire class of bugs without the risk of touching the dispatch mechanism.
Update 2026-07-08: superseded by the ANSI conversion plan (§14).
Option A is milestone M1 there — extended beyond typed dispatch casts to
typed .eh prototypes, both generated from the .ch signatures classpp
already parses. With types emitted by the generator, Option B's audit
becomes a set of located compile errors rather than a grep exercise.
The audit query:
# Find functions with 'long' parameter declarations (K&R style)
grep -rn "^long\b" src/atk/supportviews/lpair.c src/atk/support/style.c \
src/atk/text/ src/atk/textobjects/ src/atk/supportviews/ \
src/atk/frame/ src/atk/basics/
# Cross-reference against vtable method list in each class's .ih
# Focus on parameters that are passed negative int literals at any call site
grep -rn "lpair_Init\|lpair_VSplit\|style_SetNew" src/ --include="*.c" | grep '\-[0-9]'
For each hit: if the parameter is used only for small integers (sizes in
pixels or percent, style units), long → int in the implementation is
the right fix. If it needs 64-bit range (file offsets, text positions,
accumulated sizes), keep long and cast at the call site instead.
13. Modern flex generator/init-flag polarity mismatch (LOW effort, closed 2026-07-07)
Root cause
Several subsystems embed a flex-generated lexer alongside hand-written C
that calls back into flex's generated internals directly, rather than
through flex's public API. Two of these (overhead/mail/lib/parsel.flex,
overhead/eli/lib/elil.flex) define a small reset_lexer-style function,
called before every parse to force the scanner to discard state and start
fresh on new input:
int pareset_lexer()
{
yy_init = 1;
}
yy_init is not part of flex's public interface — it's a private
implementation detail of the generated scanner, and its meaning changed
between the flex version this code was written against (circa 1994) and
modern flex (2.6.x, what ships on macOS/Homebrew today):
- Old flex:
yy_initnonzero means "please (re)initialize on next call." - Modern flex:
yy_initnonzero means "already initialized, buffer exists, skip setup."
Neither parsel.c nor elil.c has fossil history — both are regenerated
at build time from their .flex source via FlexOrLexFileRule/
LexWithReplacement, so this build's use of a modern flex silently changed
the behavior of code that hadn't been touched in 30 years. Forcing
yy_init = 1 before the first-ever lex call in a process now makes the
generated yylex() skip creating its scan buffer entirely, leaving the
static buffer-position pointer at NULL. The first character read or write
of the very first parse in the process dereferences that NULL pointer.
Confirmed instances (2026-07-07)
| File | Caller | Symptom |
|---|---|---|
overhead/mail/lib/parsel.flex (pareset_lexer) |
parseadd.c ParseAddressList, called from BuildCaption/MS_ReconstructDirectory |
cui's recon command (used by gendemo) segfaulted on the very first address-caption build |
overhead/eli/lib/elil.flex (reset_lexer) |
eliy.gra, ELI/FLAMES filter-language parser |
not yet observed as a runtime crash (no currently-built code path reliably exercises it — see below), but structurally identical and pre-emptively fixed |
Fix: replace the direct flag poke with flex's actual public, version-stable
API, yyrestart(yyin) (renamed by each file's build-time sed step to
mail_parseyyrestart(mail_parseyyin) / eliyyrestart(eliyyin)).
yyrestart has meant "discard current buffer, start fresh" since flex's
earliest releases — using it is strictly more portable across flex
versions than poking yy_init, not less.
Swept, not affected
doc/mkbrowse/browserpp.flexalready callsyyrestart(yyin)correctly.overhead/class/pp/classpp.flexhas no reset-lexer pattern at all — the class preprocessor lexes exactly once per invocation, so the bug's precondition (reusing one process's scanner across multiple parses) never arises.- A full-tree search (
andrew-6.4/, not justsrc/) for.flex/.lex/.llfiles, cross-checked against every Imakefile referencing flex/lex build rules, confirms these four are the only flex-based lexers in the tree. This bug class is fully swept, not just fixed where noticed.
Legacy-platform interaction: none
FlexOrLexFileRule (config/andrew.rls) selects between the .flex source
(when FLEX_ENV is defined — unconditional on Darwin, config/darwin/
system.h:75-76) and a parallel, separately-fossil-tracked .lex source
(for sites without flex, i.e. genuine AT&T lex). The .lex siblings'
equivalent functions are no-ops:
int pareset_lexer() { return 0;} /* parsel.lex */
int reset_lexer() {} /* elil.lex */
Plain lex's generated scanner has no persistent multi-buffer state to reset
in the first place — the whole yy_init trick, and the bug in it, is
specific to flex's buffering model. The two code paths are chosen at
Imake-configuration time and cannot interact, so this fix has no bearing on
non-flex legacy builds either way.
Verification
Static: confirmed correct post-rename output in generated parsel.c/
elil.c; both compile clean. Dynamic: cui's recon verified crash-free
across repeated runs after the parsel.flex fix. The elil.flex fix could
not be dynamically exercised — bglisp (ELI's own test REPL, and the most
direct way to drive its lexer) hangs uninterruptibly at process startup in
the sandbox used for this session, independent of any input (reproduces
with /dev/null on stdin) — a separate, pre-existing issue, not caused by
or diagnostic of this fix. Confidence rests on the byte-for-byte identical
mechanism and generator to the dynamically-proven parsel.flex fix.
14. ANSI C conversion plan (assessed 2026-07-08)
How to complete the conversion abandoned in June (checkin 5e57549713,
779 files, reverted in 99fe31066c). Analysis lives here; the ordered
work plan (milestones M1–M4) lives in roadmap.md → Medium-term →
ANSI C conversion.
Why the June mass conversion failed
Three compounding causes:
modernizeinferred parameter types from K&R declaration blocks with regexes — silently mis-converts split-line definitions, macro types, and multi-name declarations.- All 779 files landed in one commit with no per-file compile gate — ~2000 errors arrived at once, with no way to bisect tool damage from real findings.
- Converted definitions conflicted with the typeless declarations in
generated
.ehfiles.
Cause 3 is the pivotal observation: those conflicts were the compiler
correctly cross-checking two declarations of the same function — but
neither side was authoritative, so the errors were noise. Invert it: emit
the true .ch signature into the .eh, and every such conflict becomes a
located, genuine bug report.
Keystone: .ch files already carry full ANSI signatures
atk/text/text.ch:46 reads Read(FILE *file, long id) returns long; —
classpp parses this, then throws the types away, emitting
long text__Read(); into the .eh and (void (*)()) casts into the
.ih dispatch macros. Emitting what it already knows gives whole-tree
type checking with zero hand edits:
- Typed
.ihcasts → the compiler converts arguments correctly at every method call site. Kills LP64 Variants 2/3/5 structurally (§12 Option A, extended to all methods, not just ≥9-arg). - Typed
.ehprototypes → every method definition, even while still K&R, is checked against the.chtruth (C89 promoted-compatibility rule). Signature drift like theCUI_GetHeaderslong/int mess (§12) becomes a compile error instead of a host-dependent segfault.
Scale
1,544 .c files in src/; ~1,301 of them contain ~13,700 K&R
definitions (same-line-name heuristic; split-line forms push the true
count toward ~15k). ~5,100 (37%) are __ class methods — every one has
its authoritative signature in a .ch file, so no type inference is
needed for the entire highest-risk cohort.
Tool verdicts
| Tool | Verdict |
|---|---|
modernize |
Discard the regex K&R→ANSI core — it is what failed in June and cannot be patched into reliability. The include-adding passes are marginal; compile errors drive the same fixes more safely. |
fix-static-methods |
Keep as-is. Correct diagnosis (class methods need external linkage for the dispatch table and dynamic loader), narrow, line-based. |
fix-missing-static-decl |
Keep as-is. Idempotent, brace-depth aware, libc-collision skip list, splits multi-name declaration lists. |
Replacement is the ansify driver (revival/tools/ansify, built
2026-07-08) — a per-file pipeline, not a merge of the old code:
fix-static-methods, thenfix-missing-static-decl- Class methods and class procedures (
__names): rewrite the definition header by lookup in the signature database (ansify --build-dbrunsclass -D -Nover every.chintobuild/desc/; 565/566 classes covered — the one failure iscontrib/atkbook/console/disk1.ch, unresolvable superclass). Never inference. Implicit first parameters are supplied by convention (struct CLASS *selffor methods,struct classheader *classIDfor classprocs). A.ch-vs-.cargument-count mismatch is reported as DRIFT and left unconverted — historically these are real bugs (§12'sCUI_GetHeaders). - File-local helpers: converted from their own K&R declaration
block, which is authoritative for file-scope functions; the parser
is strict and bails with a report rather than guessing. (
cprotowas evaluated for this job and rejected: its internal parser cannot read modern macOS SDK headers — chokes on__darwin_size_tand private includes.) - Compile gate:
make base.oin the file's directory; on failure the original file is restored automatically.
The per-file compile gate is the guardrail the June attempt lacked.
Validated 2026-07-08 on atk/eq/eq.c (pilot-A directory): 20
methods, 3 classprocs, 2 helpers converted; zero DRIFT. The first run
failed the compile gate and auto-restored — exactly as designed —
because eq__WriteFILE's char sep is a promotable narrow type, so
its ANSI definition conflicts with the typeless .eh declaration.
Regenerating eq.eh with -pe resolved it: zero errors. This
confirms the M3↔-pe coupling concretely: converting a class's .c
requires regenerating its .eh with -pe in the same step (the
roadmap already sequences them together). All test artifacts were
restored; the committed tree is unchanged by the validation.
Delegation
M2 sweeps and M3 subtree conversion runs are delegable to smaller models
(Sonnet class) under these guardrails: per-file compile gate, signature
DB as ground truth, §12's long-vs-int policy, one subtree per commit, no
edits to generated files, no concurrent builds. Pure audits and dry-run
triage are Haiku class. Kept at the top level: the classpp codegen change
(M1), ansify construction, and adjudicating .ch-vs-.c signature
disagreements — those are real bugs, not conversion noise.
M1 mechanics (clarified 2026-07-08)
fossil annotate shows the classpp machinery already exists — checkin
f4bf876da4 (2026-07-01) built both halves and deliberately throttled
them:
usePrototypesImport(typed casts in.ihdispatch macros): default on, but the three general emitters (method macros, classprocs,super_) are gatedargcount >= 8— the minimum for the arm64 ABI fix. The special classprocs (New/Initialize/Destroy/Finalize) already emit full typed prototypes ungated.usePrototypesExport(ANSI prototypes for method implementations in the.eh, fromrealargtypes): fully implemented, default off, commented/* K&R decls: compatible with unconverted .c files */.-pswitches both fully on.-Dwritesclassname.desc— method name, return type, full argument list, defined-by, vtable index — which is the signature databaseansifyneeds.- The
.ch.ih/.ch.ehsuffix rules inconfig/andrew.rlsalready pass$(CLASSFLAGS), so per-directory opt-in is one Imakefile variable.
So M1's code component is small: split -p into -pi (Import, all
methods — drops the >= 8 gates) and -pe (Export), keep -p as both,
defaults unchanged. The substance of M1 is the rollout below.
Design constraint (the one rule): classpp's compiled-in defaults do
not change until the whole tree is opted in. All rollout state lives in
committed Imakefile CLASSFLAGS. Consequences:
- Top-level builds — including
make Clean; make dependInstall— are always safe: every directory regenerates with its own committed flags, so a clean build deterministically reproduces the committed converted/unconverted mix. The clean build is the gold-standard verification for each step, not a hazard. (Generated.ih/.ehare untracked in-tree build products; regeneration cannot dirty fossil.) - The June failure mode cannot recur here: only generated, uncommitted output changes; a mistake is cured by resetting a flag and regenerating.
Timestamp wrinkle: the suffix rules fire on .ch-newer-than-.ih,
so flipping CLASSFLAGS does not regenerate by itself. Each opt-in
step must force regeneration (delete the directory's generated
.ih/.eh, or touch its .ch files). Step rhythm: set flag → force
regen → clean build → fix fallout → runtime spot-check → commit.
Blast-radius asymmetry (why -pi/-pe split): a class's .eh is
included only by its own implementation files, so Export fallout is
local to the flagged directory — it rides along with M3's per-subtree
conversion. But its .ih is installed to build/include and included
by every consumer tree-wide, so Import fallout surfaces at call sites
in other directories: missing type visibility (FILE *, typedefs)
where the .ih is included, and pointer-through-long-rock arguments
that clang treats as errors once the cast is prototyped.
Import ordering — by consumer count, not directory tree: classpp
reads the whole parent .ch chain when generating a subclass's .ih,
so a flagged leaf directory gets typed casts for inherited-method
macros without its parents being flagged; nothing structurally forces
bottom-up order. Fallout size of flagging a directory ≈ how many files
include the .ihs it generates. A 2026-07-08 survey (counting
#include <X.ih> across src/ against each class's defining
directory) ranks the tree:
| Directory | external .ih includes |
classes |
|---|---|---|
atk/basics/common |
2,351 | 41 (im 257, view 245, fontdesc 187, environ 176, message 174, proctbl 148, menulist 135, ...) |
atk/support |
450 | 19 (style 94, envrment 81, buffer 60) |
atk/text |
321 | 21 (text 169, textv 94) |
atk/supportviews |
178 | 17 |
atk/frame |
95 | 5 (frame 85) |
| ~50 leaf directories | 0 | (pilot candidates) |
Strategy: pilot on zero-consumer leaves to learn the fix patterns
cheaply, then invert to the most-consumed core — that is where LP64
Variants 3/5 actually lived, and typing those .ihs protects all
consumers tree-wide at once, including directories not yet converted.
The ordered rollout checklist lives in roadmap.md → M1 rollout
points.
Pilot A findings (atk/eq, 2026-07-08)
Gate green (clean make Clean; make dependInstall, zero real compile
errors, mixed typed/typeless install state verified), eq inset
visually verified in Sherman.Alloc. Four findings for the runbook:
- Macro-parameter capture (new bug class, fixed structurally).
A
.chparameter name can collide with a type token in the typed cast:Changed(enum changed changed)made the macro parameterchangedsubstitute the caller's argument into the cast'senum changed(→enum EQVIEW_caret, incomplete-type error; andDoScript's parameterscriptvs tagenum scriptwould mangle the same way). Struct tags are immune only because classpp already rewritesstruct X *cast params tovoid *. Fix: under-pi, classpp emits positional macro parameters (_a1, _a2, ...) for non-macrodef entries; macrodef macros keep declared names (their hand-written bodies reference them). Default output remains byte-identical — sanitization only activates with-pi, and the legacy ≥8-arg typed casts pick up the protection when Import-all becomes the default (rollout step 11). - First DRIFT catch, in the very first directory:
eq.chdeclaredDoScript(long pos, enum script *script, ...)but the implementation and all six callers useenum scriptby value — a stray*in the interface, unnoticed for ~35 years because nothing ever type-checked dispatch. Fixed ineq.ch. Rate so far: one real interface bug per directory. - Make wrinkle:
.ofiles do not depend on the local.ih/.ehin the generated Makefiles, so deleting the generated headers and re-runningmakerebuilds nothing. A flagged directory needsmake cleanlocally (which also removes generated parser files — run the parser target first if the directory has one) or the full tree Clean. A localmakealone can silently validate stale objects. - Unkillable AUIS terminal apps are real — attribute hangs
carefully. During the gate, a
cuiin unkillable uninterruptible-exit state (UE, immune to all signals) was found and initially blamed on the build's demo-reconstruction step; it was actually a zombie from an earlier interactive test whose start time happened to coincide with the build window. The build completed exit-0 on its own (mscheckpoints past its known pre-existing date-parser segfault during the demo step). Two standing lessons: AUIS terminal apps can goUE-unkillable under VS Code/sandboxed shells (bglispprecedent, §13), so runtime checks belong in a real user terminal — the "user verifies visually" protocol is the default for rollout steps; and before killing anything during a slow gate, check provenance (psetime/PPID) —dependInstalllegitimately takes a long time, and its demo step emits alarming-but-nonfatalcui/msmessages.
Pilot B findings (atk/figure, 2026-07-09)
Three new fallout patterns for the runbook, all interface-side (.ch
fixes only, no .c changes needed):
- Typeless
.chdeclarations exist — a third DRIFT flavor.MoveHandle(x, y, ptref)was declared with no types at all in five.chfiles (figobj + four overriders); classpp silently treats each unknown token as a type name and rewrites it tovoid *in the typed cast, so callers passinglongs failed. All five implementations agree onlong x, y, ptref; typed the declarations to match. Runbook rule: an all-void *cast for a method whose callers pass integers usually means the.chnever had types, not that the callers are wrong. - The rock idiom: parameters declared
long rockwhere every caller passes a pointer (figure.chEnumerate{Objects,ObjectGroup,ObjectTree},figv.chEnumerateSelection— 13 call sites). Changed tovoid *rock, the LP64-correct direction; the K&R implementations keeplonginternally, which round-trips pointers on LP64 and gets cleaned up at M3. Counter-example in the same directory:ToolName/ToolModify/Instantiaterocks are fed from alongfield inobjectlayout[]— genuinely integers, left alone. Judge each rock by its callers, not its name. - Whole-parameter transposition — the biggest DRIFT class yet.
figobj.ch(and five subclass.chs) declaredBuild(enum view_MouseAction action, struct figview *v, ...), but all six implementations and every dispatch caller use(v, action, ...). The declaration has been wrong for ~35 years; runtime was consistent because nothing ever read the.chorder. Fixed the six declarations to the runtime truth. Consequence worth stating: had M3 run before M1, the.descsignature DB would have handed ansify the transposed order for everyfigobj__Buildconversion — the pilots are validating the M1-before-M3 sequencing in exactly the way we hoped.
Also observed (benign): -Wincompatible-pointer-types warnings where
a subclass pointer is passed to a typed cast whose self parameter is
the defining superclass (struct traced *). That is the class
system's prefix-layout subtyping working as designed; these warnings
are expected wherever inherited methods are dispatched and are not
fallout.
Point 5 findings (atk/frame, 2026-07-09)
One new fallout pattern, and the first confirmation that Import fallout reaches outside the flagged directory:
(long)casts launder a rock through the interface, they don't change its meaning.frame.ch Enumerate'slong functionDatais a rock: everymapFunctioncallback treats it as a pointer, and every real caller passes one — but four of the six call sites wroteframe_Enumerate(fn, (long) &x), matching the oldlongdecl with an explicit cast rather than passing the pointer bare. Retyping the.chtovoid *(the correct rock-idiom fix) turned those four casts into-Wint-conversionerrors, since a pointer laundered throughlongno longer converts tovoid *implicitly. The fifth call site (framecmd.c:768) had no cast at all — passing the pointer bare, disagreeing with its four siblings — and was what surfaced the pattern first, under the "implementations disagree with dispatch callers" hard stop. Resolution (2026-07-09): treat the cast as noise, not a second caller's type — delete it along with the retype. Updated the runbook's rock-idiom rule to pre-authorize this narrow class of.cedit (delete-only, rock argument only) as part of the interface fix, rather than a full hard stop each time.- Import fallout crossed into other directories, as predicted.
Three more call sites of the same
(long)-laundered pattern turned up during the gate, outsideatk/frameentirely:atk/textaux/contentv.c,atk/extensions/compile.c(x2), and (one more gate cycle later)atk/extensions/tags.candatk/extensions/deskey.c—tags.candcompile.cshare a near-identicalViewEqual/FindByViewhelper pair, evidently copy-pasted at some point. This is the blast-radius asymmetry.ih-vs-.ehsplit predicted above made concrete: a tree-widegrepfor the rock's call sites after the first fix, rather than waiting for each to surface one gate cycle at a time, would have caught all six in one pass. Recommendation for future rock-idiom fixes: grep for the classproc's call sites tree-wide immediately after retyping the.ch, not just within the flagged directory.
Point 9 findings (atk/basics/common, 2026-07-09)
The core: 41 classes, 2,351 external consumers, four gate cycles.
The directory's own .chs were accurate (zero local fallout — like
raster/lib, remarkable for the oldest interfaces in the tree); ALL
fallout was consumers colliding with newly-typed rocks. New patterns,
all now in the runbook:
- The census supersedes the gate log. A directory's build stops
at its first failing file, so each gate cycle reveals only the
shallowest error per directory (
tmv.c's bare-pointerSetOverridehid behindtm.c's zombie-handler error, etc.). Andlongrocks accept laundered casts and integers silently — the gate only ever shows bare-pointer callers. Decision basis for every retype was therefore a static tree-wide call-site census (~600 sites classified bare-pointer / laundered / integer / zero across 23 methods), which also proved delegable to a cheaper-model agent, as did the resulting ~100 mechanical call-site edits (93/93 applied without deviation from spec). - Integer-majority rock —
keymap_BindToKey: 161 integer/zero callers vs one bare pointer. The rock keepslong; the pointer site gets(long). Mirror of the point-8 dual-use ruling. Contrastmenulist_AddToML(236 sites: 139 pointer-ish, 51 integer) which wentvoid *with(void *)casts at the integer sites. Both directions now precedented; majority rules. - Dual-use attribute values —
suite/chartItemAttribute()returnslongthat is sometimes a string pointer; sites feeding it to typedchar *params get(char *)casts. Sibling: magic int constants for struct-pointer params (graphic_BLACK=0xFF as a Fill Tile) — cast at the site, convention preserved. - First M2-class catch:
clockv.cusedNewString()(returnschar *) with no declaration in scope — implicit-int truncation of the pointer on LP64, invisible until the argument position became typed. Fixed with the#include <util.h>its sibling files already had. - First live caller bug requiring a semantic
.cfix:htmlview.cpassedDisplayString(self, "msg", 0)— priority and string transposed; the messages have never displayed. Ruled: fix the caller, separate commit. (Transposition class previously seen interface-side in Pilot B'sBuild; this is the caller-side variant.) -pitypes struct-pointer method args asvoid *(self and scalar/char types are fully typed). So the rollout catches every int/pointer confusion — the LP64 killer class — but not wrong-struct-pointer mistakes; and typed checking reaches unflagged directories through macromethods that expand into a flagged class's dispatch (chartobj.ih→graphic_DrawString).
Point 10 batch 1 findings (atk/value+adew+apt/{apt,suite,tree}+controllers, 2026-07-09)
First batch under the point-10 batch amendment (one session, one gate,
census-first per directory). atk/controllers is flagged but inert —
not in the default build (MK_AUTHORING/MK_CONTROLLERS off), like
ness/objects (celv's only external callers). contrib/mit IS in the
build (popts.c compiled and needed (void *) casts at two
PostResource integer sites — the point-9 retype reaching contrib).
Consumer fallout arrived in RINGS across three gates: gate 1 exposed
org/bushv (treev pair macros), gate 2 exposed chart + popts
(CaptureString/PostResource one ring further out) — the gate walks on
past a failing directory, so its log is complete per-ring but blind to
files behind each directory's first failure; local make -k rebuilds
(runbook step-3 amendment, adopted mid-batch) flush a directory in one
pass. Findings:
- atk/value came in as predicted near-zero (census-first works):
the lone rock,
value.ch AddCallBackObserver, is integer-unanimous across ~55 sites → stayslong, zero edits;SetValueType's by-design dual-use rock is fully laundered by its own macromethods. Actual fallout was two typeless override decls (DrawButtonTextpushdin entrstrv/entrintv vs the typed defining decl in buttonv.ch) and one real LP64 bug:enterstrV__LinkTree(and the unbuilt entrintv sibling) never declaredparent— implicit int truncated every view pointer passed through. Extends the clockv precedent to missing K&R param declarations. - Typeless-decl cluster in apt:
apt.chSetAreaSpread*mode,ReadObjectreader;aptv.chPrintObjectprinter,OpenPrintStream(3 of 4 params);cel.chSetVisibilityBit; plusapts.ch CaptureStringdeclaringchar *targetwhere the impl takeschar **— worth noting that classpp never validated.chagainst impls, so a wrong declaration was FREE until now. - Unsigned rock — a live-bug variant of the rock idiom
(
suite.chApply(unsigned anchor, unsigned datum),Create(..., unsigned anchor)): unlikelong,unsignedis 32-bit on LP64, and the impls declared it too, so every suite-based control panel (bush, chart, org, cmap, zip ltv) handed truncatedselfpointers to its Hit/Sort/Title/Exception handlers on arm64. Ruled:.ch→void *AND impl K&R decls →long(separate live-bug commit). A third truncation of the same species was found laundered as(unsigned int)itemat a suite.c vector call site. - Variadic-by-macro-convention attribute family — the biggest
structural find of the rollout so far; see the runbook's new
signature-drift bullet for the mechanism (attribute-pair macros
riding one macro argument into unprototyped calls) and resolution
(true
.charity + 95 dispatch sites mechanically expanded across 12 files; pair macros fenced for spec-table use only). Getter sites had passed a never-read dummy pair (suite_ItemName(0)) for 35 years.treev.chturned out to carry its own copy of the convention — its parameter was literally namedattribute_codevalue— caught by the gate one consumer ring out (orgv/bushv), a reminder to sweep EVERY.chin a flagged directory for#define X(x) code, (long)(x)macros up front. Caller bugs that fell out, each a separate caller-bug commit:bushv.cpassed a bare string as the attribute CODE ("No Current Directory"title — silent no-op since 1989), andchartv.cpassed*LabelFontName/*ScaleFontName/*TitleFontName(the string's first CHAR) where CaptureString takes achar **target — memory corruption whenever those font attribute cases executed; siblings correctly pass&X. Ruling extends to atk/chart's identicalchart_ItemAttributefamily when that directory is flagged. - Datum-rock families:
tree.chCreate*Node/NodeOfDatum/ SetNodeDatum datum →void *(pointer-unanimous incl. laundered(long)textin org,(char*)dirin bush);vector.chitem methods →void *(dual-use: 6 pointer callers, 1 genuine offset in suiteev gets(void *));suite.chCreateItem/ItemOfDatum datum STAYSlong(integer-majority: ~35 code sites vs bushv's 5 pointer sites, which get(long)casts). BothCreate(spec, ...)classprocs also declared their spec param by-value where impls take pointers (tree_Specification *,suite_Specification *).
Point 10 batch 3 findings (atkams/messages/lib, 2026-07-10)
Gate green first pass; all fallout was local (19 errors, one ring),
none of the 95 external consumers broke. No pair macros. Seven .ch
drift fixes, mostly known taxonomy (cvEng typeless params;
DisplayNewBody bare params; fldtreev PostMenus by-value unnamed
struct; folders AlterSubscriptionStatus declared (dir, shortname,
status) vs impl+all-four-callers (dir, status, shortname) — the
pilot-B transposition class again; SetCUIRock long rock → void *
with the laundered forward at ams.c:120 getting a pre-authorized
(char *) cast). Two new mechanisms:
- Unknown type tokens emit implicit-
intcast params — a typed cast that lies.CUI_Initialize(proc TimerFunction, ...)(ams, amsn, amss) andorgv.ch SetHitHandler((long *handler)(), ...): classpp copies an unrecognized type token verbatim into the cast, where gnu89 parses the bare identifier as a parameter name with implicitint— so the "typed" parameter is 32-bit and would truncate any function pointer passed on LP64 (callers passing a real function error visibly; callers passing NULL/0 compile silently against the lying cast). Same species:sendmsg.chdeclaredBoolean, a typedef private to sendmsg.c — any type name in a.chmust resolve in EVERY consumer's translation unit. Fix: the class-system typedefprocedurefor function pointers (keystate.ch/value.chare the existing convention), the underlying public type (short) for private typedefs. Census rule added: sweep flagged.chs for type tokens that are not C keywords /struct|enum|union/FILE/boolean/procedure. - classpp does NOT comma-share types, and override macros take
the defining class's decl. Minimal-case verified:
Foo(int a, b, c)emits(int, void *, void *)— each bare identifier is treated as an unknown TYPE (pilot-B MoveHandle behavior), never as a second name under the first type. Batch 2's contrary census note (GrayPattern(short a, b)→(short, short)) observed a different mechanism: that decl is an OVERRIDE in xgraphic.ch, and a subclass's.ihmacros for inherited/overridden methods carry the DEFINING class's declared signature (graphic.ch, fully typed). Consequence for the rollout: flagging a directory types the casts of every ANCESTOR method into the subclass.ih, so a malformed decl in an unflagged parent directory (orgv.ch here) surfaces under the child's flag; and since classpp resolves parent.chs from the INSTALLED include tree, such a fix only takes effect aftermake installin the parent's directory.
15. mkparser/cparser.c: fixed-width table assumption vs. modern bison's per-table type narrowing (MEDIUM effort, closed 2026-07-11)
Root cause
overhead/mkparser/ is a code-sharing layer, not a from-scratch parser
generator: mkparser (an awk-based shell script) post-processes bison's own
generated .tab.c output, stripping bison's yyparse() and rewriting its
LALR tables into a struct parser_tables consumed by one shared,
hand-written engine (cparser.c's parser_Parse()). Every AUIS grammar
compiles down to one instance of this same table struct plus one shared
parser_Parse() — the point is to avoid N copies of parser machinery for N
grammars.
cparser.h's struct parser_tables declares every table pointer uniformly
as short *, matching the (Andrew-patched, circa-1994) bison version this
was written against, which always emitted plain short for every generated
LALR table regardless of its actual value range. Modern bison (2.3, verified
on this build) does not: it picks the narrowest C integer type that fits
each table's value range, per table, per grammar — yytype_uint8 (1 byte)
when all values fit 0–255, yytype_int16 (2 bytes, i.e. an actual short)
otherwise, and so on. For ams/libs/ms/prsdate.gra specifically, three
tables (yyr1, yyr2, yydefact) narrow to yytype_uint8 because their
values (rule numbers, RHS lengths) are all small, while five others
(yypact, yypgoto, yydefgoto, yytable, yycheck) still need the full
16 bits and stay yytype_int16. mkparser's generated struct initializer
casts all eight to (short *) unconditionally:
(short *)yydefact, /* defred */
Reading a 1-byte array through a pointer with 2-byte element stride merges
each pair of adjacent yytype_uint8 entries into one bogus value. Every
indexed access into any table modern bison happened to narrow returns
effectively random data — for prsdate.gra, this is lhs (from yyr1),
rhssz (from yyr2), and — the one that broke every single parse —
defred (from yydefact): a state whose only valid action is "always
reduce by rule 125" instead read back as rule 0 ("no action"), which the
engine (before the fix below) misinterpreted as "no valid transition,"
raising a false syntax error on a token sequence bison's own tables prove is
perfectly well-formed.
Two more bugs turned up in the same investigation, in the hand-written engine logic rather than the type-width mismatch, but from the same root pattern (code written against one bison version's exact conventions, silently violated by a later one):
- Goto-table (
nextx/yypgoto) short-circuit. The engine testednextx[lhs] == defflag(defflagholdsYYPACT_NINF) and, if true, skipped straight to the default-goto table without ever consulting the compressed goto table. Bison's ownyyparse()has no such short-circuit for the goto table at all — it always computesyypgoto[lhs] + *yyssp, bounds-checks it againstyycheck, and only falls back to the default (yydefgoto) if that specific check fails.yypgotois not gated byYYPACT_NINFthe wayyypactis; it can coincidentally equal that sentinel value for a nonterminal with no special meaning at all. Confirmed by direct inspection ofprsdate.gra's generated tables: 21 of 47 nonterminals collide withYYPACT_NINF, including — critically —date,yearday,partial_date,months,years, anddays, i.e. almost every nonterminal a real date parse passes through. The short-circuit sent the parser to the single generic default-goto state instead of the context-correct one for all of them, whenever the specific state on the stack wasn't the statistically-most-common case bison chose as the default. - Action-table sentinel conflation. Bison uses two different
constants for two different "no entry" meanings:
YYPACT_NINF(the action tableyypact's "always use the default reduction" flag) andYYTABLE_NINF(the compressed tableyytable's own, separate "no valid action here — syntax error" flag) —-179and-149respectively forprsdate.gra, i.e. genuinely different values. The engine only knew about one (defflag/YYPACT_NINF) and used it to test ayytablelookup result, so a realYYTABLE_NINFentry (which should mean "syntax error") was never recognized as such and fell through to being (mis)treated as a negative rule number to reduce by. Separately, the engine treated a rawyytableentry of exactly0as "fall back to the default reduction" — bison's ownyyparse()treats0as a syntax error too, not a fallback.
Fix
struct parser_tables(cparser.h) gained a new field,tblflag, holdingYYTABLE_NINF— distinct from the pre-existingdefflag(YYPACT_NINF).mkparser's generated table initializer now emits both.- The goto-table lookup in
cparser.cno longer special-casesdefflagat all; it now matches bison's own formula exactly — always index, bounds- check, and consultvalid[]/yycheck, falling back todefnext/yydefgotoonly when that check fails. - The action-table decode now treats
tact == 0 || tact == desc->tblflagas the syntax-error case (matching bison), instead of testing againstdefflagand instead of treating0as "use the default reduction." mkparser's awk post-processor gained a rule that force-normalizes any of the eight table declarations it pointer-casts to(short *)—yyr1,yyr2,yydefact,yypact,yypgoto,yydefgoto,yytable,yycheck, plus the debug-onlyyyprhs/yyrhs/yyrline— back to plainshort, regardless of which narroweryytype_*type modern bison assigned each one for a given grammar:
This makes the fix generic across grammars and bison versions/value-range combinations, rather than a one-off patch for/yytype_u?int(8|16|32) yy(r1|r2|defact|pact|pgoto|defgoto|table|check|prhs|rhs|rline)\[\]/ { sub(/yytype_u?int(8|16|32)/, "short") }prsdate.gra's specific narrowing choices — a different grammar could have bison narrow a completely different subset of these eight tables, and the fix still holds because it forces all of them back to a consistent width.
Scope: every mkparser-based grammar, tree-wide
Because all AUIS grammars share this one engine, the fix applies tree-wide
by construction — but the generated .c file for each grammar still had
to be regenerated against the fixed mkparser, since Makefiles depend on
each grammar's own .gra source, not on the mkparser tool itself; make
had no reason to know these files were stale. Five grammars use mkparser,
found by grepping every Imakefile for bin/mkparser:
| Grammar | Location | Subsystem | Dynamically verified? |
|---|---|---|---|
prsdate |
ams/libs/ms |
AMS date-header parsing | Yes — end-to-end via gendemo/amsdemo, captions and message ordering both confirmed correct |
eliy |
overhead/eli/lib |
ELI (Embedded Lisp Interpreter), the basis for FLAMES, AMS's mail-filtering/scripting language; linked into ms, cui, vui, nns, messages (amsn.do), and ELI's own bglisp test REPL |
No — compiles/links clean against the fixed engine, not separately exercised. bglisp was noted hanging uninterruptibly at startup during §13's verification (2026-07-07), dismissed then as "a separate, pre-existing issue" — given how closely that symptom (uninterruptible hang, not a clean crash) matches this bug's signature, it may well be the same root cause. Not confirmed. |
parsey/parseadd |
overhead/mail/lib |
RFC822 address parsing (ParseAddressList) — alias/address-book resolution, self-address stripping, From:-header "pretty name" extraction for message captions, forwarding validation, trymail/eatmail delivery |
No — same as above. Notable: the original gendemo crash found in this revival (§13, 2026-07-07) was in this exact code path (ParseAddressList → FindPrettiestFromString → BuildCaption), for an unrelated flex reason at the time; this engine bug may have been a latent second failure mode in the same call path all along. |
eqparse |
atk/eq |
Equation/math-formula typesetting inset grammar (ATK's analogue of troff's eqn) |
No |
num |
atk/rofftext |
Numeric-expression evaluation inside roff-formatted document text (register arithmetic, akin to troff's \n expressions) |
No |
The other four are flagged as follow-up work if any user-facing symptom turns up in ELI/FLAMES filtering, address parsing, equation rendering, or roff numeric expressions — there is no reason to expect they don't work now that the shared engine is fixed, but none of them were exercised this session beyond a clean rebuild.
How this was found
Started from roadmap.md's amsdemo thread: caption dates displaying wrong
("7-Jul-126") and demo message ordering (Part 1…23) scrambled. Two real,
smaller bugs were found and fixed first — a tm_year % 100 Y2K display bug
in bldcapt.c/shrkdate.c, and a missing tiebreak in recon.c's
sort-by-time comparator (MsgListEntry_CompareTimes) for messages sharing
the same one-second-resolution AMS_DATE. Neither fully explained the
ordering symptom: instrumenting blddate.c directly showed
parsedateheader() returning failure for all 23 real gendemo messages,
every time — not an occasional/data-dependent failure. A standalone test
harness (linking a byte-for-byte-identical-to-deployed sandbox rebuild of
prsdate.c against libcparser.a) reproduced the same universal failure
outside the full application, confirming it wasn't specific to the recon
call path. parser_SetDebug(1) state-machine tracing, cross-referenced
against bison's own -v/.output state and rule listings and the raw
generated tables (yypact, yytable, yycheck, yydefact, yypgoto,
yydefgoto, yyr1), pinpointed each of the three engine bugs above by
direct comparison against bison's documented/generated reference behavior
for the identical grammar.
Relationship to the LP64 bug family (§12)
A close cousin, not a member of that family — same underlying shape, a
different mechanism. Both are: 1990s C code that hardcoded an assumption
about a fixed data width or representation, which a different tool
further down the modern toolchain silently violated decades later, and
which an untyped/blind access (here, a raw pointer cast across a width
mismatch; there, register-level zero-extension through an untyped void
(*)() vtable dispatch on an LP64 ABI) then corrupts without any compiler
error to flag it. The lesson is identical even though the failure mode
isn't: code that assumes "this will always be N bits" is fragile against
any link in the toolchain — compiler, ABI, or in this case, a
code-generator's own storage-optimization choices — deciding otherwise for
reasons invisible at the point where the assumption was written.
Verification
Static: all five grammars' generated .c files recompile clean against the
fixed engine (0 error: lines across a full dependInstall). Dynamic:
prsdate verified end-to-end via gendemo — every previously-failing
test input (including the literal arpadate() output format, "Jul 10"
with no year, and pure-numeric "7/10/1992") now parses to the correct
tm_year/tm_mon/tm_mday/tm_hour/tm_min/tm_sec, both in the
standalone harness and against the actual deployed build/lib libraries.
The other four grammars (eliy, parsey, eqparse, num) are unverified
beyond a clean compile/link — see the scope table above.
16. classpp typed-dispatch signedness mismatch: .ch declared type vs. implementation's actual type (MEDIUM effort, closed 2026-07-11)
Root cause
M1's classpp typed-dispatch conversion (§12 point 11) generates each
method's caller-side dispatch macro by casting the vtable slot to the
return/parameter types declared in that class's .ch spec — e.g.
Superior_Image_Line_Width(zip_type_image image) returns char; in
contrib/zip/lib/zip.ch generates:
#define zip_Superior_Image_Line_Width(self,_a1) \
((* ((char (*)(struct zip *, zip_type_image))(...))) (self,_a1))
This assumes the .ch declaration and the real C implementation agree on
type — but nothing enforces that. zip__Superior_Image_Line_Width
(zipdf01.c) is explicitly defined unsigned char-returning, using 255
as its "nothing configured" sentinel, walking a figure→image→
superior-image→stream inheritance chain. The .ch said plain (signed)
char.
At default optimization, the caller
(zip__Contextual_Figure_Line_Width, zipd000.c) computed its
(width = zip_Superior_Image_Line_Width(...)) != 255 check by exploiting
the declared signedness: since a signed char value of 255 sign-
extends to -1, the optimizer rewrote the comparison as cmn w0, #1
(== -1) instead of the plain cmp w0, #0xff used a few lines earlier
for the figure's/image's own direct (correctly unsigned char-typed)
field reads. But the callee's actual unsigned char return zero-extends
255 to 0x000000FF in the return register, not 0xFFFFFFFF — the two
never compare equal, so the caller always concluded "found a real width"
and returned 255 even when nothing was configured anywhere in the
chain. That 255 flowed straight into zipview_SetLineWidth(self, 255)
in zipv000.c's Ensure_Line_Attributes, producing a 255-pixel-wide
stroke that filled the entire figure with solid foreground color — the
zip inset "solid black rectangle" bug (see roadmap.md → Insets to
Repair → zip, and claude-history/zip-black-render-investigation.md for the full
bisection trail).
-O0 "fixed" the symptom by accident: at that optimization level the
compiler emits a naive truncate-the-return-value-then-compare-as-unsigned
sequence instead of the sign-extension shortcut, which happens to still
produce the right answer regardless of the declared/actual signedness
disagreement — which is exactly why this class of bug is easy to miss:
the code looks correct, compiles clean, and only misbehaves once the
optimizer is aggressive enough to exploit the (wrong) signedness contract
implied by the .ch declaration.
Fix
One-line type correction in the .ch spec to match the implementation:
- Superior_Image_Line_Width( zip_type_image image ) returns char;
+ Superior_Image_Line_Width( zip_type_image image ) returns unsigned char;
followed by make zip.eh zip.ih (classpp regeneration) and a normal
rebuild — no source change needed in either the caller or the callee,
since both were internally consistent; only the contract between them
(the .ch declaration) was wrong. Confirmed working end-to-end at normal
default optimization, no per-file/per-function flags needed anywhere.
Scope: tree-wide, not zip-specific
This is a systemic risk of the M1 typed-dispatch conversion itself, not a
zip peculiarity — any class whose .ch narrow-type declaration
(char/short) disagrees in signedness with its C implementation's
actual return type is a candidate, if that method also uses a sentinel
value whose sign-extended and zero-extended bit patterns differ (a
narrow-type equivalent of the LP64 family's core failure shape, but not
an LP64/pointer-width issue at all — this reproduces identically on
ILP32). Swept tree-wide: compared all returns char;/returns short;
(and the mirror, returns unsigned char;/returns unsigned short;)
declarations across all 566 .ch files in the tree against their actual
implementations' explicit return-type declarations, in both directions.
Zero other live instances found — Superior_Image_Line_Width was the
only one. Re-run after any future bulk .ch editing or when enabling a
previously-inert subtree (M1's typed-dispatch flip only covers the
active tree — see §12's census — so a .ch/implementation mismatch in
an inert directory wouldn't have been caught by compilation and could
still be latent there).
Audit query (compares declared vs. actual return type for every narrow-typed method; adapt the risky-type set and search directories as needed): ```
see revival/tools or ask for the scan script used for this sweep —
not yet checked in as a standalone tool; walks every .ch file's
"Method(...) returns TYPE;" lines for TYPE in {char, short, unsigned
char, unsigned short}, resolves the real class name from that .ch's
own "class NAME[...] : parent" line (NOT the filename — several zip
classes differ, e.g. zipofcap.ch declares class zipofcapt), and
diffs against the explicit return-type token on the implementation's
definition line in every .c file in the same directory.
#### Relationship to the LP64 bug family (§12)
Same shape as §15's relationship to that family: a close cousin, not a
member. The LP64 family (§12) is about *register width* — a 32-bit value
crossing an untyped dispatch boundary into a 64-bit-declared parameter
without the sign-extension a typed call would have inserted. This bug is
about *signedness*, at a fixed width (8 bits) — a value crossing a
*typed* (post-M1) dispatch boundary where the type itself, not just the
call site's type-erasure, disagrees between declaration and
implementation. Different mechanism, same lesson: any place where two
sides of a call boundary can silently disagree about how to interpret the
same bits is a latent bug, whether the disagreement is over width (LP64
family) or signedness (this one) — and both are invisible until an
optimizer is willing to exploit the (wrong) contract.
#### Verification
Static: tree-wide scan (566 `.ch` files, both signedness directions)
found zero other instances after the fix. Dynamic: zip inset renders
correctly at normal default `-O` (previously required `-O0` or
per-function `__attribute__((optnone))` to work at all), confirmed
end-to-end against both `src/doc/papers/atk/Cattey.turnin` and
`contrib/zip/samples/dragon.zip`, and against a full `make Clean; make
dependInstall` world rebuild.
### 17. Xft "erase by redraw" uses stale foreground color in WHITE transfer mode (LOW-MEDIUM effort, partially closed 2026-07-12)
#### Root cause
`graphic_WHITE` transfer mode is the "erase by redrawing in background
color" convention used tree-wide (e.g. `aptv__ClearBoundedString`).
`xgraphic_LocalSetTransferFunction` (`xgraphic.c`, ~line 408-417)
implements the color swap for the *core X* rendering path only —
`XSetForeground(GC, self->backgroundpixel)` — which changes the X
graphics context's current foreground pixel, but never touches
`self->foregroundpixel` itself. Xft rendering doesn't use the GC's
foreground pixel at all; `GetXftForeColor` (`xgraphic.c`, ~line 68) read
`self->foregroundpixel` directly, unconditionally — meaning a WHITE-mode
Xft text draw was silently using the **old, unswapped foreground color**
(typically black). "Erasing" text by redrawing it in white was actually
redrawing it in black — reinforcing the old text rather than erasing it.
Found via `contrib/calc`'s display area (see roadmap.md → Insets to
Repair → calc, and `claude-history/calc-text-rendering-investigation.md`), which
exercises frequent `Clear`-then-`Draw` cycles as its digit display
updates — a pattern uncommon enough elsewhere in the active tree that
this had gone unnoticed.
#### Fix
`GetXftForeColor` now checks the current transfer mode and substitutes
`self->backgroundpixel` when in `graphic_WHITE` mode:
c
unsigned long fgpixel = (self->header.graphic.transferMode == graphic_WHITE)
? self->backgroundpixel : self->foregroundpixel;
``
**Confirmed via live lldb trace**, not just static reasoning: breakpointed
XQueryColor(the point where the resolved pixel value is actually used)
across a full calc keystroke sequence — everyClearBoundedStringcall
now requests0xFFFFFF, everyDrawBoundedStringcall requests
0x000000`, perfectly alternating. This part of the bug is resolved.
Scope: tree-wide, not calc-specific — partially closed
Like §16, this is a general core-ATK bug, not specific to the inset that
happened to surface it. Any Xft-rendered view using WHITE-mode
erase-by-redraw was affected. Marked partially closed because a
related symptom — a faint "ghost" of prior text remaining visible after
an erase/redraw cycle — persisted in contrib/calc's display area even
after this fix, and had been proven (via the same lldb-trace methodology)
to not be a further instance of this same color bug, nor a content
or draw/erase-position bug at the API level traced (aptv__DrawBoundedString/
ClearBoundedString arguments were correct and self-consistent in every
case checked).
Follow-up 2026-07-12: ghost root-caused and fixed; new redraw bug surfaced
The residual ghost turned out to be a different bug in the same
function: xgraphic_DrawChars's Xft path erases text by redrawing the
same glyphs in the background color, which only exactly restores pixels
where a glyph's anti-aliasing alpha is 1 — partially-covered edge
pixels stay gray forever and accumulate across draw/erase cycles. Fixed
by filling the glyph's advance-cell rectangle with the background color
(XftDrawRect) instead of redrawing glyph shapes, whenever
transferMode == graphic_WHITE. User-confirmed in ez: the ghost is
gone.
That fix immediately surfaced a new, distinct, still-open bug:
during incremental multi-keystroke redraws, the calc display now shows
only a suffix of the correct string (leading characters go missing —
e.g. typing 123+4= shows 1, 2, 23, 3+, 23+4 instead of 1,
12, 123, 123+, 123+4). The final = result always draws
correctly, and a forced full repaint (window focus-loss/regain) shows
the correct string, so calc's own value tracking is fine — this is
purely a defect in the incremental Clear/Draw redraw path, most likely
in how the new rect-fill erase interacts with the freshly-drawn new
string's cells. Not yet root-caused. See
claude-history/calc-ghost-fix-prompt.md's "Outcome" section and
claude-history/calc-text-rendering-investigation.md for the full trail and untried
next leads.
No tree-wide audit for other latent instances of the specific
GetXftForeColor bug has been done (unlike §16's exhaustive .ch sweep)
— the fix is in the single shared function all Xft text rendering
funnels through, so no other call sites need auditing; this note is
about the fix's blast radius (affects every WHITE-mode Xft draw
tree-wide) rather than about finding more instances.
Verification
Static: single fix in the one shared function
(xgraphic.c GetXftForeColor) all Xft rendering funnels through — no
other call sites to audit. Dynamic: XQueryColor lldb trace across a
full 123+4= keystroke sequence in contrib/calc, confirmed correct
color alternation at every step. Full make Clean && make dependInstall
world rebuild done 2026-07-12, zero new errors introduced (one
pre-existing, unrelated contrib/zip/utility/ltapp.c error remains, see
roadmap.md → Insets to Repair → zip).
10. Messages with IMAP backend (UNKNOWN effort, needs investigation)
Resolved 2026-07-04 for the local-store case — see roadmap.md Near-term →
Messages application prerequisites, Stream 2/3. The build already has a
clean seam: AMS_ENV on with AMS_DELIVERY_ENV/SNAP_ENV/WHITEPAGES_ENV
left off builds messages against a local, non-networked mbox-backed
message store (ams/libs/ms), with none of the AFS/AMDS delivery machinery
involved. An IMAP adapter remains a viable fallback (notes below still
apply to that scenario) but is no longer the near-term plan.
The messages application is the UI for mail and bulletin boards. It
sits on top of AMS, which implements its own storage, delivery, and
locking model based on shared filesystems (AFS). The question is whether
messages can be separated from AMS and connected to an IMAP server.
Key source areas to investigate once the full source is available:
- atkams/ — the bridge between ATK and AMS; how thick is this interface?
- ams/ — where does the storage abstraction live, and is there one?
- atk/ez/ and the messages application — does the UI talk to AMS
directly, or through a clean API boundary?
- What assumptions does messages make about the message store?
(e.g., local files, specific directory structures, AFS locking
primitives, white pages integration)
- How much of AMS is delivery/transport (replaceable by SMTP) vs.
storage/retrieval (replaceable by IMAP) vs. tightly coupled to both?
The value proposition is significant: a mail client that renders rich compound documents inline with embedded ATK objects. But the feasibility depends entirely on whether there's a seam between the UI and the store. Previous experience suggests AMS internals are deeply complex — approach with caution and investigate the interface boundaries before committing.
18. Variadic function called through a K&R (empty-parens) extern declaration — arm64 calling-convention mismatch (MEDIUM effort, found 2026-07-22)
Root cause
Apple's arm64 (AAPCS64) ABI passes variadic arguments differently from
fixed arguments at the call site: named/fixed arguments go in registers,
but once a call crosses into its variadic tail, the compiler must know
that at the call site to generate the stack-passing code the callee's
va_start/va_arg machinery expects. A K&R-style empty-parens
declaration (extern int Foo();) gives the compiler no arity or
variadic information, so a call through it is code-generated as if every
argument were fixed (registers). If the actual definition is genuinely
variadic (Foo(fmt, ...), reading with va_arg), the callee reads
arguments from the stack that the caller never put there — silent
garbage, not a crash at the call site itself, and no compiler warning
(an empty-parens extern is legal, unprototyped C).
Found building the IMAP writeback change-journal (ams/libs/ms/msjournal.c,
revival/doc/ams-IMAP-project.md Milestone 4): a first-cut K&R extern for
the new variadic MSJournal_Record(dir, fmt, ...) compiled clean but
crashed cui live on the first real folder mutation — EXC_BAD_ACCESS
inside vsnprintf, called from MSJournal_Record, called from
MS_AlterSnapshot. Confirmed via lldb backtrace; see
revival/doc/claude-history/imap-writeback-REPORT.md.
Symptom signature
Distinct from the LP64 truncation family below: not a huge-positive-number
value, but a crash inside a variadic libc function
(vsnprintf/vfprintf/vprintf) one frame below a plausible, otherwise-
correct-looking call site. The call site itself never looks wrong in the
source.
Fix
A full prototype with ... at every call site of the new function
(extern void MSJournal_Record(const char *dir, const char *fmt, ...);),
not just at its definition. Confirmed fixed by rebuilding and repeating
the exact live crash repro cleanly, multiple times.
Scope
Applies to any new variadic function added anywhere in this tree, not
to existing ones (existing variadic libc/AUIS functions already have
correct prototypes wherever they're currently called, or they'd already
be crashing). Relevant going forward whenever a delegated session or
future porting work introduces a new function with a ... parameter —
check every call site has the real prototype in scope, not an
empty-parens or otherwise unprototyped declaration.
Relationship to the LP64 bug family (§12)
Adjacent but distinct: §12 and its relatives are about int/long/
pointer width mismatches surviving 32-to-64-bit widening. This bug is a
calling-convention mismatch (register vs. stack argument passing) —
it would reproduce identically even on a hypothetical ILP64 rebuild of
the original 1991 code, because the defect is "declaration doesn't say
variadic," not "declaration says the wrong width." Grouped here because
both families share the same root pathology: K&R-era declarations
carrying too little type information for a modern ABI to code-generate
correctly. Recorded in revival/doc/sonnet-playbook.md's LP64
bug-class list as item 6 (with the same distinguishing note) since that
list is what delegated sessions read first when debugging a crash.
Verification
Live lldb backtrace showing the fault inside vsnprintf before the fix;
clean repeated live repro (real folder mutations via cui against a
mirrored folder) after the fix, no further crashes. No tree-wide audit
needed — this is the only new variadic function added in this work, and
no existing variadic function in the tree was touched.
19. .ch/wrapper vs. real K&R implementation out-param width drift — invisible across the untyped call boundary (MEDIUM effort, ongoing; 5 confirmed instances)
Root cause
Several ams/libs/ms functions are exposed as class methods: a hand-written
K&R wrapper (amss__Foo/ams__Foo/amsn__Foo) whose parameter types are
copied from the class's .ch spec, forwarding unchanged to a bare global
function (Foo(...) in ams/libs/ms/*.c) that has its own,
independently-written K&R parameter declaration. Nothing checks these two
declarations agree — the wrapper-to-bare-function call is unprototyped
K&R, so if the .ch spec says long *x and the real function says
int *x, the compiler has no way to see the conflict. classpp's typed
dispatch (§12/§16) only checks caller → wrapper; it has no visibility
past the wrapper into the real implementation.
Confirmed via fossil blame to be original 1990s source, not a
porting-introduced regression: for every instance below, both the
.ch long * declaration and the real function's int * parameter
trace to b28115fb2e, "Initial import of AUIS sources" (2026-06-24) —
before any Darwin/LP64 porting work existed. On the original ILP32
targets this code shipped on, int and long are both 4 bytes, so the
mismatch was byte-for-byte harmless there; it only becomes live on a
platform where sizeof(long) > sizeof(int), i.e. this LP64 port. A
~35-year-old inconsistency, dormant until now.
When this drifts, the real function only ever writes the low 4 bytes at
the address it's given, regardless of what the caller allocated:
- If the caller's local is genuinely int-sized (matching the real
function, not .ch), the write is correct by accident — this is how
three of the five instances below survived unnoticed until this port:
the .ch/wrapper mismatch produces a compiler warning at the caller
(-Wincompatible-pointer-types, since the caller's int* doesn't match
the wrapper's typed long*), but the actual runtime write was harmless
because the real function never touched more than 4 bytes anyway. This
warning is exactly what the M2 point-0 census's Group A category
catches and, by its general rule, fixes by widening the caller to
long to match .ch.
- If the caller's local is widened to long — whether by hand, or (as
happened live in this tree) by mechanically applying Group A's general
rule without first checking the real implementation — the low 4 bytes
get the correct value and the upper 4 bytes keep whatever was already
on the stack: silently correct if the local happened to be
zero-initialized and the value never exceeds 2^31, silently wrong (a
huge garbage number) otherwise. No compiler warning either way —
this is the dangerous direction, and the one that bit this tree live
(see instances 3–4 below): the general Group A rule is only safe once
the real implementation has been checked, which the stretch-goal sweep
exists to do, but initially missed these four on a grep technicality.
Symptom signature
None at compile time. At runtime: either no symptom (accidental width
match), or a value that reads as garbage — typically a huge number,
sometimes negative — coming out of a long local that was passed by
address to one of the affected functions and never itself written to
afterward. Distinct from LP64 variant #4 (scanf) in that no %d/%ld
format string is involved; distinct from §16 (classpp signedness) in
that the mismatch is one hop further out, past the wrapper, into a
second independently-declared K&R function.
Instances found (chronological)
MS_GetConfigurationParameters(ams/libs/ms/init.c) — first found 2026-07-18. Recorded insonnet-playbook.md's LP64 bug-class list as item 4, but never added here or toporting-changelog.mduntil now.MS_ParseDate(ams/libs/ms/msparse.c) — found 2026-07-24 during the M2 point-0 census's stretch-goal sweep (greppingams/libs/ms/ams/libs/cuiforint *out-params with no matching warning). Real implementation:int *year, *month, *day, *hour, *min, *sec, *wday, butint *gtmtoo — except every other caller in the tree (ms.c,cui.c×3,vuibase.c,vuipnl.c) already declaresgtmaslong(it holds atime_t/gtime()result, which genuinely needs 64 bits), leaving onlyyear..wdayasint. Fixed by widening the real implementation'sgtmparameter tolong *(matchinggtime()'s actual return width and every existing bare caller), and widening.ch/the three wrappers to match, keepingyear..wdayasint *throughout. The one caller reached through class dispatch (captions__MarkRangeOfMessages) had all eight locals declaredlong— confirmed viafossil blameto be original 1990s source (same initial-import commit as everything else here), not a later widening — with uninitializedyear..wdaylocals, worse than the usual accidental-zero-init survival case, and live (reached by the "mark messages since/through date" feature). 3–4.MS_GetDirInfo,MS_GetNewMessageCount,MS_GetSubscriptionEntry,MS_NameChangedMapFile(ams/libs/ms/getdiri.c,getnmct.c,getsubs.c,namechg.c) — found 2026-07-24, initially missed by the same stretch-goal grep because it only checked the line immediately following a function's signature line; these four declare theirint *parameters two or more lines down (some behind along Foo (...)return-type-prefixed signature line, which the grep's pattern didn't match at all). All four real implementations takeint *out-params;.chhad all four typedlong *— both confirmed viafossil blameto be original 1990s source (same initial-import commit), not a Darwin-era change. Every other caller in the tree (ms.c,cui.c,cuifns.c,vuibase.c) already correctly usedintlocals, matching the real functions — that's exactly what let the mismatch sit dormant: it only produces a compiler warning at the three class-dispatch callers (atkams/messages/lib/capaux.c,folders.c,foldaux.c), which originally also usedint(matching the real functions and accidentally correct). This session's own Group A fixing pass — applying the M2 point-0 census's general rule of widening a warning-flagged caller to match.ch'slong *— widened those three callers' locals tolongwithout first checking the real implementation, which is exactly what turned the dormant mismatch live. Corrected the opposite way fromMS_ParseDate: narrowed.chand the three wrappers back toint *(since the real implementations and every bare-call caller were unanimous), and reverted the threeatkams/messages/libcallers' locals back toint, undoing that same-session widening. Confirmed live: after the erroneous widening, openingmessagesand viewing Inbox produced "Zero of your two subscriptions have changed, (-<huge number>) have nothing new" fromMS_NameChangedMapFile's uninitialized upper 32 bits; after the correction, wdc confirmed the message reads correctly ("Zero of your two subscriptions have changed. (2 have nothing new.)").
Fix
No single fix — this bug class requires checking the real K&R
implementation's declared types directly, not trusting .ch, before
touching any out-param width. Direction determines the correct fix:
- If the real implementation's width is idiosyncratic and every other
caller already independently agrees on the wider type for a specific
parameter (as gtm did), widen the real implementation to match —
the .ch/wrapper were "ahead of" a stale real body.
- If the real implementation and every other caller agree on the
narrower type, and only the .ch-derived class-dispatch caller(s)
disagree, narrow .ch/the wrappers back — .ch was wrong from the
start (original source, not a porting artifact), and widening the
caller to match it (the general Group A rule) was the mistake, not
the real body.
Either way, the caller-side local width must end up matching the real
K&R function's declared width, not the class spec's, since it's the real
function that ultimately performs the store.
Scope
Confirmed limited to ams/libs/ms functions exposed through the
amss/ams/amsn class wrappers — this is where the wrapper-forwards-
to-independently-declared-bare-function shape occurs. Not yet swept
tree-wide; the two grep passes that found instances 2–4 (stretch-goal
sweep during M2 point 0, revival/doc/claude-history/m2-census-REPORT.md)
were both narrow and had to be run twice after the first missed instances
3–4 on a declaration-style technicality. A thorough sweep would need to
check every ams/libs/ms function's real parameter declaration against
its .ch entry directly (e.g. diffing extracted signatures), not grep
for a specific K&R declaration shape.
Relationship to the LP64 bug family (§12)
Same underlying pathology as items 4/6 in sonnet-playbook.md's LP64
list (§12's cousins) — a width or ABI mismatch invisible to the
compiler because it crosses an unprototyped K&R call. Distinguishing
feature here: the mismatch is specifically between a .ch spec (and its
mechanically-generated wrapper) and a separately hand-written real
implementation one call further out, not between a caller and its
immediate callee.
Verification
Instance 2 (MS_ParseDate): full rebuild clean, zero new warnings; live
smoke test of "mark messages since/through date" not independently
re-verified after the fix (recommended before relying on it further).
Instances 3–4: full rebuild clean, zero new warnings; live smoke test by
wdc — Inbox's subscription-status message read correctly after the
correction, confirmed garbled before it (see above).
17. ansify DRIFT false-positive: classpp's own InitializeClass/InitializeObject/FinalizeObject special-casing (found 2026-07-25, M3 tree-wide census)
A tree-wide ansify --dry-run --dir src census (M3's "first concrete
step," run before any batch execution) found 56 DRIFT findings across
1,486 files. Not all are real .ch-vs-.c bugs — a majority are a
tool-side false positive with a confirmed, code-level root cause.
Root cause
ansify's DRIFT check (revival/tools/ansify, convert_file) assumes
exactly one implicit leading parameter for every class method/
classproc — self for methods, classID for classprocs — and compares
len(.c params) against len(.ch declared args) + 1. That convention
is what classpp itself uses for ordinary methods and classprocs, but
not for three specially-named classprocs, confirmed directly in
overhead/class/pp/class.c:
InitializeObject/FinalizeObject: classpp hardcodes a full 2-arg prototype (struct classheader *,struct <class> *— i.e. bothclassIDANDself) unconditionally (class.c:1122,"boolean %s__InitializeObject(struct classheader *, struct %s *);"), regardless of what the.chdeclares. Every real implementation therefore takes 2 implicit params, whether or not the.chrestates them.InitializeClass: dispatched via a fully untyped(boolean (*)())cast (class.c:1202) with no compiler-enforced arg count at all — the real-world convention is 1 implicit param (classIDonly, no specific instance exists yet at class-init time), but nothing enforces it.
A related but distinct mechanism, traced precisely by AMS2
(2026-08-01): whether InitializeObject gets a live, auto-wired call
at all — independent of the DRIFT-vs-signature question above —
is controlled by class.c:2814, which sets the initializeobject
flag TRUE for any class with a non-empty data: section,
whether or not InitializeObject appears in the .ch's
classprocedures section (class.c:165's own comment: "TRUE if this
class has data or initializeobject procedure was found"). This is the
exact mechanism behind ansify's "no signature in DB" skip for a
class with a data: section but no declared InitializeObject
(atk/apt/chart/chartx1a.ch/chartx1app, I2;
atkams/messages/lib's messagesapp/text822, AMS2 — see
claude-history/m3/m3-rollout-runbook.md's I2 and AMS2 entries) — the DB only captures classprocs a .ch explicitly
declares, but classpp wires the call regardless of the .ch, so these
are genuine live gaps, not dead code, and need hand-conversion.
ansify's DRIFT check doesn't know about this special-casing, so it
misfires in two shapes depending on how the .ch happens to be
written:
.chrestates the implicit param by name (e.g.fldtreev.ch'sInitializeClass(struct classheader *classID) returns boolean;): the tool countsclassIDas a real declared arg, then expects one more on top (dbargs + 1) — one too many. Reported as.c has N params, .ch has N+1..chuses empty parens (the more common, "undecorated" form, e.g.suite.ch'sInitializeObject() returns boolean;): the tool expects only the single default implicit param, butInitializeObject/FinalizeObject's true convention is 2 — one too few. Reported as.c has 2 params, .ch has 0+1.
Scope of the false positive in the 2026-07-25 census
42 of the 56 DRIFT findings name exactly InitializeClass,
InitializeObject, or FinalizeObject. The mechanism above was
directly verified against two of them end-to-end (.ch, .c, and the
class.c codegen source all cross-checked) —
foldertreev__InitializeClass/InitializeObject/FinalizeObject
(atkams/messages/lib/fldtreev.c, restated-param shape) and
suite__InitializeObject/FinalizeObject (atk/apt/suite/suite.c,
empty-parens shape). The remaining ~39 share the identical DRIFT-
message shape against the same three method names and are almost
certainly the same mechanism, but were not each individually
hand-verified — treat as very likely false positives, not certain
ones, and do a 30-second sanity check (does the .c definition's real
param count match 1 for InitializeClass or 2 for
InitializeObject/FinalizeObject?) rather than blind-trusting the
label when a batch actually reaches one.
One confirmed real exception: dialog__InitializeClass
(atk/utils/dialog.c) genuinely defines (classID, self) — 2 params
— where the ordinary InitializeClass convention is 1. Since
InitializeClass dispatch is untyped, this never mattered at runtime
(the generated call site only ever passes classID; the extra self
parameter reads whatever garbage is in that argument slot, but the
implementation returns TRUE unconditionally without touching it) —
a real, ~35-year-old, benign interface inconsistency, not a DRIFT-
shaped tool artifact. Logged here, not fixed (out of scope, no
observable effect).
A second real exception, the opposite direction — found M3 batch B3 (2026-07-30)
Unlike InitializeObject, InitializeClass is not hardcoded by
classpp — it goes through the ordinary classproc-emission loop, which
always prefixes struct classheader * and appends whatever the .ch
declares verbatim. A .ch that restates the implicit param by name
(InitializeClass(struct foo *self) returns boolean;) therefore gets
counted as an extra argument on top of the automatic prefix,
producing a 2-param exported prototype. Confirmed by directly
test-compiling atk/textobjects/unknownv.eh under a temporary -pe
flag: boolean unknownv__InitializeClass(struct classheader *, struct
unknownv *); — 2 params — against unknownv.c's real, correct,
1-param definition (unknownv__InitializeClass(c) struct classheader
*c;). Same mechanism hits FinalizeObject when a .ch restates
both implicit params instead of just self (atk/apt/suite/suiteev.ch
declared FinalizeObject(struct classheader *ClassID, struct suiteev
*self), producing a 3-param prototype against a real 2-param
definition — confirmed the same way).
This is not universal — check the real .c param count before
assuming either direction. Three pre-existing instances of the
restated-InitializeClass shape in atk/value
(metextv.ch/eintv.ch/etextv.ch) are not bugs: their .c
definitions already, correctly, take the full 2 real params (already
-pe'd and committed in B2) — the unused 2nd param just reads
garbage, exactly the same harmless shape as dialog__InitializeClass
above. The difference between "safe" and "broken" is entirely whether
the real .c definition happens to match the inflated count, which
must be checked per instance, not assumed from the .ch shape alone.
FinalizeObject is not symmetric with InitializeClass — found M3 batch C1 (2026-08-01)
InitializeClass and FinalizeObject look like the same shape (both
go through the ordinary classproc-emission loop, both get an automatic
struct classheader * prefix) but their safe .ch forms differ, and
conflating them produces a wrong fix. Traced directly against
class.c (~line 1146-1153): for FinalizeObject specifically, when
the .ch's restated arg list is empty (mp->realargtypes == NULL ||
mp->realargtypes[0] == '\0'), classpp takes a special hardcoded
branch that emits struct classheader *, struct CLASSNAME *self —
2 params — regardless of whether the .ch used true empty parens
(FinalizeObject();) or restated just self
(FinalizeObject( struct CLASSNAME *self );); both produce identical
realargtypes state and hit the same branch. InitializeClass has no
such hardcoded branch for the ordinary classproc loop — empty parens
there really does mean 1 param (classID only), and any restatement
(even self-only) genuinely over-counts. The safe/established .ch
form for FinalizeObject is the self-only restatement, not empty
parens — confirmed against 11 real precedents tree-wide (atk/eq/
eq.c, atkams/messages/lib/fldtreev.c/mailobjv.c, and 8 more), all
using the 2-param form with a self-only-restated .ch. A .ch whose
FinalizeObject genuinely has only 1 real .c param needs the .c
widened to 2 (classID unused) — not the .ch simplified further to
empty parens, which would change nothing about the exported prototype
and leave the arity mismatch in place. (contrib/zip/lib/zipstat.ch
was exactly this case: already correctly self-only-restated; only
zipstat.c's definition needed widening from 1 param to 2.)
Fixed (B3) by simplifying the 3 broken .ch declarations
(atk/textobjects/unknownv.ch's InitializeClass,
atk/apt/suite/suiteev.ch's InitializeClass and FinalizeObject)
back to the true convention, rather than a classpp-level fix — a
tree-wide grep confirmed only 8 total restated-InitializeClass
instances and 1 double-restated-FinalizeObject instance exist
anywhere in the source tree, small and bounded enough not to warrant
touching the tool a third time in one day. atkams/messages/lib/
fldtreev.ch (Wave 6) resolved 2026-08-01 (AMS2): broken, same shape
as unknownv.ch/suiteev.ch — InitializeClass restated classID
by name (1 param) producing a 2-param exported prototype against the
real 1-param definition, and FinalizeObject restated both
classID/self (matching the double-restatement shape) producing 3
params against the real 2. Both fixed to the true convention (empty
parens / self-only), confirmed via a live -pe compile check before
the real run. 2 more live instances remain unchecked —
contrib/zip/utility/schedv.ch/ltv.ch (Wave 7) — not yet checked
for which direction (safe like metextv.ch, or broken like
unknownv.ch) they resolve to; worth a 30-second per-instance check
(matching the method above) whenever that wave is prepared.
The other 14 DRIFT findings — mixed, ordinary per-batch triage
The remaining 14 (not one of the three special names) are NOT covered by the mechanism above and should get normal DRIFT triage when their directory's batch runs. Two were checked now because they were cheap and instructive:
tree__TreeWidth/TreeHeight(atk/apt/tree/tree.c) — confirmed genuine:tree.chdeclaresTreeWidth() returns long;(zero explicit args) but the implementation takes(self, node)— the interface is simply missing a real parameter, same species as Pilot B's typeless-.chfindings.menterstrV__WantInputFocus/clicklistV__WantInputFocus(atk/value/mentstrv.c,clklistv.c) — confirmed genuine:view.ch/im.chdeclareWantInputFocus(struct view *requestor), but both overrides define only(self)and hardcodeself->etextviewas the requestor argument to their delegated call instead of accepting the caller's realrequestor— the interface parameter is silently dropped. Likely benign (compound-view focus forwarding) but a real, live interface violation, not a tool artifact — worth a closer look wheneveratk/valueis batched.
valueview__Changed/valueview__DrawFromScratch (atk/value) — now
checked, M3 batch B2 (2026-07-30): same species as the
WantInputFocus pair above (a base-class virtual-method stub ignoring
a declared parameter it doesn't need — DrawFromScratch's body is
literally /* Subclass responsibility */; every real subclass already
overrides it with the full, correct signature; Changed's body is
similarly a no-op comment). Resolved with the same padding fix as
WantInputFocus (add the unused parameter, no body change) — see
claude-history/m3/m3-rollout-runbook.md's B2 findings for full detail on all 4 of this
shape found in that batch.
Not yet individually checked: type__GetDeclaration
(directory TBD), zipobject__Print_Object/
Normalize_Object_Points/Highlight_Object_Points/
Expose_Object_Points/Hide_Object_Points, and
zipstatus__Issue_Status_Message/Acknowledge_Status_Message
(contrib/zip/lib, Wave 7) — no reason to believe these are tool
artifacts (none match the three-special-name pattern); handle as
ordinary DRIFT when those batches run.
Tool fix (not done)
The real fix belongs in ansify itself — special-case
InitializeClass/InitializeObject/FinalizeObject the same way
classpp's own codegen does, rather than assuming a uniform
one-implicit-param rule. Not fixed here, per the Delegation ruling
(§14): tool construction stays top-level. Until fixed, any M3 session
encountering a DRIFT report for one of these three names should
consult this section before escalating it as a real interface bug.
Related, but distinct: classpp's FinalizeObject prototype/call-site inconsistency (found M3 batch B2, 2026-07-30)
Not a DRIFT false positive — a genuine classpp codegen bug, confirmed
directly in overhead/class/pp/class.c. InitializeObject gets a
fully hardcoded 2-arg (classID, self) exported prototype
(class.c:1121-1122) regardless of what the .ch declares — this is
the mechanism §17 above documents. FinalizeObject does not get
the same treatment: its prototype is explicitly not skipped from the
ordinary classproc-emission loop (class.c:1139-1142, comment:
"FinalizeObject is NOT skipped: it may have a non-void return type,
so it must go through the loop to pick up the correct
mp->methodtype"), so its prototype is built from whatever the
.ch actually declares. But the internal call site inside the
generated __Finalize function (class.c:1334,
" %s__FinalizeObject(classID, self);\n") is unconditionally
hardcoded to pass 2 arguments, with no check against what the
prototype above it declared. Any class using the ordinary empty-parens
FinalizeObject() .ch convention (0 declared args → 1-arg
prototype, classID only) therefore gets a self-inconsistent
.eh the moment -pe is on: a hard compile error inside the .eh
itself (too many arguments to the internal call), independent of
anything the class's own .c does. Confirmed in atk/value/buttonv.ch/
sliderv.ch.
Workaround (applied twice so far, both in batch B2): restate
FinalizeObject's self parameter explicitly in the .ch, matching
the class's real type (FinalizeObject(struct buttonV *self);) — this
routes the prototype through the same ordinary-classproc path but now
producing the matching 2-arg signature. Pure interface-side fix, no
.c/runtime-behavior change.
Fixed centrally in classpp, 2026-07-30 (wdc's ruling): once the
retest scope was bounded precisely (the bug lives entirely behind
usePrototypesExport, i.e. -pe, which only 7 directories had turned
on at the time — every other directory in the tree is structurally
unreachable by this code path), fixing the tool once was clearly lower
total cost/risk than re-diagnosing this per-.ch for the rest of M3.
The fix is narrower than mirroring InitializeObject's full
hardcoded treatment — that would have been wrong. 7 classes in
atk/value (menttext.ch, entrtext.ch, clklistv.ch, entrintv.ch,
entrint.ch, mentstrv.ch, entrstrv.ch) declare
FinalizeObject(...) returns boolean — a real, non-void return type
already handled correctly by the existing ordinary-classproc-loop
path (this is exactly what the code's own comment about
FinalizeObject's non-void return type was warning about). The fix
only synthesizes the missing self parameter when FinalizeObject is
declared with literal empty parens (mp->realargtypes empty),
leaving every class that already restates self — with any return
type — going through the unchanged path:
if (usePrototypesExport) {
- sprintf(proto, "struct classheader *%s", mp->realargtypes);
+ if (strcmp(mp->name, "FinalizeObject") == 0
+ && (mp->realargtypes == NULL || mp->realargtypes[0] == '\0')) {
+ sprintf(proto, "struct classheader *, struct %s *", FinalClassName);
+ }
+ else {
+ sprintf(proto, "struct classheader *%s", mp->realargtypes);
+ }
}
Verified two ways (claude-history/m3-classpp-finalizeobject-fix-REPORT.md,
independently re-verified by the orchestrator): (1) zero retroactive
effect — all 124 .eh files across the 7 already--pe'd directories
regenerate byte-identical before/after the fix, including atk/value's
7 non-void overrides; (2) the fix actually works — a standalone toy
class reproduced the exact diagnosed compile error before the fix and
compiled clean after. One more live empty-parens instance found beyond
the two already known (atk/org/orga.ch:72) — not yet -pe'd, so not
live fallout, but this fix will make it self-consistent for free
whenever that directory's wave arrives.
A third, distinct classpp bug: an unnamed classproc parameter loses its type entirely (found M3 batch B2, 2026-07-30)
atk/basics/x/xfontd.ch declared Deallocate(struct xfontdesc *); —
correctly typed, but with no parameter name. Allocate/Deallocate
are (like FinalizeObject) call-site-hardcoded by classpp
(class.c:1316/1391); the unnamed parameter confuses classpp's
realargtypes construction enough that the emitted .eh prototype
came out as void xfontdesc__Deallocate(struct classheader *, struct
*); — the type name itself dropped, an uncompilable bare struct *.
Root cause not traced further into classpp's parser internals (out of
scope, same boundary as the finding above). Workaround, confirmed
working: give the parameter a name (Deallocate(struct xfontdesc
*self);) — routes it through classpp's normal named-parameter path
and produces the correct prototype. Checked for the same shape
elsewhere in batch B2 — not found again; may be rare (an unnamed
classproc parameter is unusual style to begin with), but worth the
same "note it if you see it" awareness as the other two classpp
findings above whenever a future batch's -pe rollout hits it.
20. %d / %ld mismatch in the write direction — printf/fprintf family (MEDIUM effort, systemic; found 2026-07-26)
Section 11 above (scanf family) noted in passing that "unlike printf
mismatches (wrong output, no memory write), scanf mismatches corrupt
the stack frame" — implying the printf-side mismatch was assumed
lower-stakes, cosmetic at worst. That assumption doesn't hold for one
important category: ids that get written out and then read back and
matched against each other later. For those, "wrong output" is data
loss, silently.
Root cause
The same ILP32-heritage mistake as #11, mirrored on the write side: code
that serializes a long-typed unique id (dataobject_UniqueID() —
literally (long)(self), the object's own pointer — or
dataobject_GetID()/a self->header.dataobject.id field, also long)
into a .ez datastream's \begindata{classname,ID} / \enddata{...} /
view-tag markers via fprintf/sprintf, using %d instead of %ld. On
LP64 arm64, the caller still passes the full 64-bit value, but %d only
reads back the low 32 bits, silently truncating the id to its bottom
half. Harmless on the ILP32 platforms this shipped on (long/int both
32 bits); live only on LP64.
Why this one does corrupt data, unlike an ordinary printf typo
Found while root-causing calc/zip insets vanishing when embedded in a
mixed document and getting lost on save (roadmap.md's "Insets to
Repair" → zip/calc entry). The shared apt__WriteObject helper
(atk/apt/apt/apt.c:524, used by calc) wrote a truncated id into
calc's own \begindata/\enddata tags, while the unrelated code that
writes the paired \view{calcv,id,...} reference (atk/text/
text.c:1332) computed the full, untruncated id fresh from the same
live pointer. Verified numerically: 4346500352 = 0x103125500 (view,
full) vs. 51533056 = 0x03125500 (calc's own tag, truncated) — identical
bits, missing top word. On reload, dictionary_Insert (keyed by the
truncated id) and the \view{...} line's dictionary_LookUp (keyed by
the full id) never match — the lookup fails completely silently
(text.c:705-709, a bare return 0 on miss, no stderr) — so the object
just vanishes, and a subsequent save has nothing left to write back. No
crash, no error message, no stack corruption — just quietly wrong output
in exactly the field whose correctness the rest of the file format
depends on for cross-referencing. zip had an independent instance of
the identical mistake in its own zip__Write (contrib/zip/lib/
zip.c:307,324) — not shared code with apt.c, just the same K&R idiom
copy-pasted into a second file.
Confirmed systemic — tree-wide sweep (2026-07-26)
Grepping for the same \begindata{%s,%d} / \enddata{%s,%d} idiom
tree-wide turned up ~60 call sites across ~30 files — essentially
every inset type's Write() implementation that was hand-written against
this same original template. All fixed (%d→%ld, spot-checked against
each site's declared type rather than blind-replaced — a few sites have
unrelated, genuinely-int %ds in the same format string, e.g. "
Datastream version: %d", that must stay untouched):
atk/org/org.c, atk/eq/eqvcmds.c (eq's Cut/Copy-to-cutbuffer path, not
its ordinary save — eq's normal save was already correct), atk/lookz/
lookz.c, atk/utils/dialog.c, atk/supportviews/{label,lprruler,
sbutton,strtbl}.c, atk/createinset/null/null.c, atk/hyplink/{link,
pshbttn}.c, atk/bush/bush.c, atk/text/text.c (the outer text
wrapper's own begindata/enddata pair — same bit-split arithmetic
confirmed independently), atk/ness/objects/ness.c, atk/raster/cmd/
raster.c (6 sites — including raster__Write, raster's own independent
main save path, meaning an embedded raster inset almost certainly had
the identical vanish-on-embed symptom as calc/zip, just never
previously reported/tested), atkams/messages/lib/text822.c,
contrib/{alink,time/clock,time/timeoday,tm,zip/lib,champ/{chimp,
month},mit/util/header}, and the atk/examples/ex{11,12,13,16,17,18,19}/
hello.c tutorial files. One sibling bug found by checking parameter
types rather than just format strings: atk/basics/common/image.c's
SendEndData's id parameter was declared plain int in both the
.c and the .ch class declaration, truncating the id before it ever
reached the %d — retyped to long in both files.
Fix: %d→%ld at each confirmed site; image.ch/image.c
additionally needed int id→long id. Full file/line inventory and
per-site skip/false-positive reasoning: project_lp64_printf_id_truncation
memory (Claude auto-memory, this project).
To audit for regressions or missed sites:
grep -rn '\\\\begindata{%s,%d}\|\\\\enddata{%s,%d}\|"data{%s, *%d}' src/ --include="*.c"
For each hit, confirm the argument bound to %d is genuinely int (fine)
vs. a long/dataobject_UniqueID()/*_GetID() call (needs %ld) — do
not blind-replace, several sites mix a genuine int (a version number, a
count) with the long id in the same format string.
Primary build environment: macOS/Darwin
The initial development platform is macOS (POSIX Darwin), not Linux.
X11 on macOS
XQuartz is the X11 server for macOS (formerly X11.app). Available at
xquartz.org or via brew install --cask xquartz. It works well but is
a separate install that needs to be set up.
No macOS platform config in 6.3.1
There is no config/darwin/ or config/macos/ in the original tree.
The closest starting points for a new system.h and system.mcr:
- config/i386_bsd/ — Darwin's userland descends from FreeBSD/NetBSD
- config/next_mach20/ — macOS descends from NeXTSTEP/Mach
Either way, a new platform config will be needed.
Apple clang vs gcc
macOS's gcc is actually clang in disguise. The -fwritable-strings
problem still applies but diagnostics will differ. Real gcc is available
via brew install gcc if needed.
Mach-O vs ELF
macOS uses Mach-O object format, not ELF. This affects the dynamic loader
replacement: dlopen() works on macOS but shared objects are .dylib
not .so, and linking flags differ (-dynamiclib instead of -shared).
Source control
The revival codebase will be managed under Fossil SCM, not git.
Vendored bison (Andrew Bison A2.6) disabled on Darwin/arm64
overhead/bison/ bundles CMU's own bison fork (derived from GNU Bison
1.24) so that mkparser can post-process its output into Andrew's
shared-object-code parser runtime. On Darwin/arm64 the built binary
hangs in an uninterruptible kernel wait when run on real grammars —
not killable even with kill -9. Not worth chasing; the vendored
bison's own README (2002) already recommended moving to stock FSF
bison. overhead/bison/Imakefile now builds it (for reference) but
does not install it, so the system bison is used instead. Required
one matching fix: config/andrew.rls's Parser() macro now passes
-o classname.tab.c explicitly, since modern bison derives output
names from the input extension and AUIS's grammars use the nonstandard
.gra extension. See porting-changelog.md (2026-06-29) for the full
investigation. One grammar, atk/ness/objects/ness.gra, uses the fork's
multi-character-string-token extension and isn't yet handled.
overhead/class/Imakefile SUBDIRS order breaks a truly clean build
SUBDIRS = machdep lib cmd pp testing doc — machdep (whose
machdep/darwin/classproc.c does #include <class.h>, no relative
include path of its own) is built before lib, which is the
directory whose InstallFile(class.h, ...) actually populates
build/include/. On a build/ that already exists (the normal case
— nobody has wiped it since the tree was first bootstrapped), this is
invisible: class.h is already sitting in build/include/ from long
ago. On a genuinely empty build/, make World fails immediately
with classproc.c:12:10: fatal error: 'class.h' file not found and
libclass.a never gets built, cascading into dozens of unrelated
"file not found" errors elsewhere. Confirmed present since initial
import (fossil finfo on the Imakefile shows no changes ever).
Workaround, not yet fixed upstream: seed it manually before make
World —
mkdir -p build/include
cp src/overhead/class/lib/class.h build/include/class.h
one-time per empty build/; the real InstallFile step overwrites it
correctly once overhead/class/lib is reached.
gendemo's cui recon step wedges unkillably on any checkout before 2026-07-11
Same underlying bug family as the vendored-bison note above, different
consumer: prsdate.c (mkparser-generated) segfaults inside cui
recon on any checkout before the fixed-width-table fix
(abdc97546c, 2026-07-11). The cui child process lands in
uninterruptible (UE) kernel-wait state and does not respond to
kill -9 — confirmed by direct testing, not just inferred from the
bison note. make World/make dependInstall on ams/demo blocks on
it indefinitely. Fix: don't try to kill cui itself; SIGTERM the
parent /bin/csh -f gendemo wrapper instead — the Imakefile's
install.time:: recipe line has a leading - (ignore exit status),
so make continues past the now-failed step normally. The orphaned
cui process is harmless afterward (detached, uses no CPU) and can be
left running. Already fixed at and after abdc97546c; only bites
when deliberately checking out something earlier.
Fossil checkout timestamps make incremental rebuilds untrustworthy across revisions
Fossil sets a file's mtime to its commit timestamp, not to
checkout wall-clock time. Every commit in this repo's history is
necessarily "in the past" relative to whenever a rebuild session is
actually happening — so after fossil update <any-rev>, any source
file that changed relative to what's currently compiled gets an mtime
older than the already-compiled .o/.do sitting in the tree from
today. make's timestamp-based dependency check then wrongly
concludes the object is up to date and skips recompiling it — silently
leaving stale, wrong-revision code linked into the binary. This isn't
directional (doesn't matter whether the checkout moves forward or
backward through history) and isn't hypothetical: it produced a
confirmed false reading during the 2026-07-12 Media-menu bisection
(an incremental rebuild at f46de124ed showed the bug present; a
subsequent from-scratch rebuild of the identical revision showed it
absent). It's most visible for dynamically-loaded .do classes gated
by a build flag (e.g. zip.do/calc.do from MK_ZIP/MK_CALC
persisting in build/dlib/atk/ and loading successfully at a revision
where they're not supposed to exist at all — make Clean's recursive
descent follows the current Imakefile's SUBDIRS, so it never even
visits a now-degated directory to clean its old outputs), but applies
to any statically-linked file too. The only trustworthy way to test
a different revision is a full wipe: move or remove build/ and
src/'s generated .o/.do/.eh/.ih/.a (make Clean) before
every make World, every time, regardless of which direction the
checkout moves. A full from-scratch make World (tree already
Imake-bootstrapped, just build/ and generated files cleared) took
3m36s real (1m20s user, 58s sys) on this machine as of
2026-07-12 — cheap enough that there's no excuse to skip it when
bisecting. Run revival/tools/prime-class-header (codifies the
class.h seed above) right after clearing build/, before make
World.
MK_CONSOLE being off silently breaks con10/con12 icon fonts used outside console
atk/Imakefile:132 gates the entire atk/console subtree (including
console/fonts, not just console/lib/console/cmd) behind
#ifdef MK_CONSOLE, which is undefined in this revival (console is
an intentionally inert subsystem — see the directory census in
roadmap.md). console/fonts/Imakefile already correctly declares
DeclareFont(con10)/DeclareFont(con12) — nothing wrong with that
recipe — but since the whole directory is never visited, con10.fdb/
con12.fdb (custom .fdb-format icon fonts, a different format from
the .pcf bitmap fonts everything else uses) never get compiled or
installed to build/X11fonts/, and the fonts are silently absent.
Symptom (found 2026-07-12): the fad (animation) inset in
ams/demo/d10 declares con10 as one of its two icon fonts ($N
con10 — see atk/fad/fad.c:341, fad__iconnum) to draw a console/
terminal-shaped icon for its "Client Program" node. With con10
unresolvable, fontdesc_Create falls back to some default font, and
the intended icon glyph code renders as a literal fallback-font
character — looked exactly like a fad-view drawing bug (a wrong
glyph appeared as "M") and was initially suspected as one during a
bisection, before being traced to this font gap. fad itself has no
drawing defect; once con10 resolves, the animation renders and plays
correctly — confirmed by direct testing, closing out that bisection.
Codified fix: revival/tools/install-console-fonts. Builds
only atk/console/fonts (bypassing the MK_CONSOLE gate entirely —
never touches console/lib/cmd/stats/consoles), installs both
con10.pcf and con12.pcf to build/X11fonts/, regenerates
fonts.dir via mkfontdir (not a hand-edited line count — fonts.dir
itself isn't Imake-generated or tracked in source anywhere; it's
always been a manually-maintained artifact, mkfontdir is the correct
tool for it), and runs xset fp rehash. console/fonts/Makefile is
checked in as an empty stub (the parent Imakefile never regenerates
it, same root cause as the gate itself), so the script regenerates it
in place with a direct imake invocation the first time it's run —
MK_CONSOLE is irrelevant to that step since it only affects whether
the parent recurses here, not this leaf directory's own contents.
Safe to re-run any time after a clean rebuild wipes build/X11fonts/.
Still not truly permanent: this only fixes con10/con12
specifically; any other file outside console that happens to
reference a console-only resource would still be silently broken, and
nobody has checked for that. The real upstream fix, not yet decided
between: (a) define MK_CONSOLE to bring back the whole console/
terminal-emulator subsystem, much bigger scope than needed just for
two fonts; or (b) carve console/fonts out of the MK_CONSOLE gate in
atk/Imakefile so it always builds regardless of whether console
itself does — the smaller, more targeted fix.
clock inset's InitializeObject depends on the icon12 font resolving, via its cursor
Clock was manually bisected across 9 checkpoints from 6338ade7de
(2026-07-07, before the M1 rollout) through HEAD, each a full
from-scratch rebuild, and found blank at every single one — logged as
a confirmed pre-existing, not-yet-root-caused bug (see roadmap.md →
Insets to Repair → clock). Reopened the same day: inserting a fresh
clock via ez's <ESC><TAB>clock ("insert inset by name") rendered
correctly in the same session and build where a parsed clock (from
serialized datastream text in the test file) had been failing.
Found the mechanism: clockview__InitializeObject
(contrib/time/clockv.c:288-289) does
if (!(self->cursor = cursor_Create(self))) return(FALSE);
cursor_SetStandard(self->cursor, Cursor_Gunsight);
and xcursor__SetStandard (atk/basics/x/xcursor.c:97-107) hardcodes
```c
#define DEFAULTFONTNAME "icon"
#define DEFAULTFONTSIZE 12
...
self->header.cursor.fillFont = fontdesc_Create(DEFAULTFONTNAME,0,DEFAULTFONTSIZE);
``
— i.e. clock's cursor shape (the "gunsight" cursor shown while
interacting with it) depends on theicon12font resolving. If that
lookup fails,cursor_Createreturns null andInitializeObject
returnsFALSEimmediately — the clockview never finishes
initializing, so nothing inRedraw` (hands, face, labels) ever runs.
This matches the observed symptom precisely: a completely blank
clock, not just missing labels.
Not fully closed. icon12 is not MK_CONSOLE-gated like
con10/con12 (different root cause than the note above) and was
already confirmed present in fonts.dir and resolvable via xlsfonts
before any of today's console-font work — so it isn't obviously
"the font was missing" in the same way. Best current explanation: a
transient X-server font-cache/session-state issue specific to
whatever long-running ez process the old, broken clock instance
lived in, cleared up as a side effect of today's repeated
mkfontdir/xset fp rehash work for con10/con12 — not a
permanent gap, not a code defect, but not confirmed either. Not yet
isolated: whether the old serialized-datastream clock (as opposed to
one freshly created by name) also now renders correctly in a fresh
ez launch, which would settle whether this is pure session/font-cache
state versus something specific to how a clock gets instantiated from
parsed data versus by-name insertion.
Archive fetch: missing files (404)
The following files were not available when the archive was mirrored from CMU on 2026-06-24. None are AUIS source code.
web/amz.html — web page (not source)
misc/FACTS.andrewis.1.gif — brochure illustration
FACTS/EZ/FACTS.andrewis.1.gif — duplicate reference to same
andrew-8.0/WWW/Protocols/HTTP/Methods.html
andrew-8.0/WWW/Library/Implementation/HTEpToClient.c
andrew-8.0/WWW/Protocols/rfc1341/5_Content-Transfer-Encoding.html
andrew-8.0/WWW/Daemon/Inplementation/HTSUtils.c
andrew-8.0/WWW/Library/Implementation/HTMLDTD.c
andrew-8.0/WWW/Library/Implementation/HTStream.c
andrew-8.0/WWW/Daemon/WAISGate.html
The andrew-8.0/WWW/ files are bundled copies of early CERN/W3C libwww
source and HTTP protocol documentation — part of the v8.0 web browser
support, not original AUIS code. Not relevant to the 6.3.1 revival.
Sources
config/i386_Linux/system.h— the 6.3.1 Linux system definitionsconfig/i386_Linux/system.mcr— the 6.3.1 Linux build macrosconfig/allsys.h— system-independent feature flagsconfig/platform.tmpl— platform detection and dispatchANNOUNCE/ANNOUNCE.6.2.changes— documents POSIX work andgenstatl