joeylib2/LLVM816-ASKS.md

24 KiB

Asks for the llvm816 toolchain session

JoeyLib-side findings that need toolchain-side fixes. Context: PERF-AUDIT.md (findings #79, #77) and PERF-AUDIT-PLAN.md Phase 1. JoeyLib does not modify toolchains/iigs/llvm-mos per the ownership split -- these are requests, with evidence.

1. MVN-based memset/memcpy in runtime/src/libc.c (PERF-AUDIT #79)

memset, memcpy (and memmove's copy loops) in toolchains/iigs/llvm-mos/runtime/src/libc.c are one-byte-at-a-time loops through 24-bit pointers:

void *memset(void *dst, int c, size_t n) {
    char *d = (char *)dst;
    while (n--) *d++ = (char)c;
    return dst;
}

That is ~30 cycles/byte on the 65816. The ORCA/C baseline these replaced lowers both to MVN block moves (~7 cycles/byte after setup).

Measured impact on the reference platform (UBER, honest post-Phase-0 clock): jlScbSetRange -- a 200-byte memset plus a flag write -- measures 163 ops/sec on the clang build vs 1005 ops/sec on the ORCA-era capture (~4x regression at the whole-op level). Every memset/memcpy-based JoeyLib path pays it: surface copies, sprite save/restore fallbacks, SCB range fills, stage-alloc clears, the log buffer, and all example/game code.

Ask: MVN-based memset/memcpy in libc.c (or intrinsic lowering in the backend). Requirements from the JoeyLib side:

  • Handle bank-crossing source/destination (MVN wraps within a bank; the routine must split runs at bank boundaries).
  • memset needs a 1-byte seed write + overlapping MVN (classic self-copy fill), or an unrolled STA loop for short fills.
  • Keep the size_t=unsigned long (32-bit) signature -- JoeyLib call sites pass 32-bit counts today and the current behavior is correct, just slow.
  • A short-count fast path (n < ~16) matters too: the sprite interpreter's per-row copies are 4-17 bytes.

2. FYI: sprite-codegen corruption under investigation (PERF-AUDIT #77)

JoeyLib's IIgs runtime sprite code generation corrupts memory when enabled (bisected; disabled by default in spriteCompile.c until root-caused). The current suspect list is JoeyLib-side (emit-time buffer handling), but if the hunt lands on codegen/ABI behavior of clang-emitted calls into arena-resident routines, it becomes a toolchain question. Nothing to do yet -- flagged so the context is not a surprise if it arrives.

3. fflush -> FST commit semantics (PERF-AUDIT #80)

JoeyLib's logger (src/core/debug.c) fflush()es after every line so a crash/hang leaves the log on disk. On the clang runtime the lines do not reach the disk image until fclose: a run that executed 33 logged operations left 3 lines on disk after a forced emulator exit. Question: does the libc fflush propagate to a GS/OS FST flush for the file, or only drain the userland buffer? If the latter, an FST-flush call in fflush (or an exposed fsync-like hook) would restore crash-durable logging. Not urgent -- JoeyLib works around it with a memory mailbox under emulation -- but real-hardware crash forensics wants it.

4. malloc/calloc returns 1 (not NULL) at heap exhaustion (PERF-AUDIT #81) -- CRITICAL

Empirically confirmed under MAME with in-app probes: with the default link-layout heap (665 bytes at $00:BC67-$00:BF00 in the JoeyLib UBER build), calloc(1, ~720) returns 1. The bump-path overflow branch in libc.c malloc -- whose comment already records a "historical p + HDR_SZ + n > heapEnd over-heap miscompile (only manifested for oversized n)" and assumes the path is unexercised -- is still miscompiled in exactly that oversized case. Downstream, JoeyLib ran for weeks on a phantom struct at (void*)1 whose first field happened to alias the real framebuffer pointer; see PERF-AUDIT.md #81 for the full forensics. JoeyLib no longer allocates anything large on the C heap, but ANY program that exhausts this heap gets silent corruption instead of NULL. Minimal repro: link any GS/OS app with default heap, call calloc(1, 720), observe the return value.

5. Segment BSS/heap spans are not reserved from the Memory Manager (PERF-AUDIT #81/#77/#85) -- CRITICAL, EMPIRICALLY PROVEN

Three independent confirmations, in escalating severity:

  1. The bank-0 C heap (unreserved) -- #81's phantom-stage chain.
  2. The pinned SHR framebuffer $01:2000-$9CFF (unreserved) -- an MM handle landed at $01:8B04 inside the display (#85).
  3. THE GENERAL CASE: a 32KB NewHandle was placed at $08:4200 -- inside the app's own ENTRY-SEGMENT BANK, overlapping seg1's BSS span. The loader only claims each segment's image bytes; everything above (BSS, heap) is MM-free. ANY GS/OS allocation -- the app's own or the system's -- can land on live program data.

JoeyLib has stopgap claims for cases 1-2 (jlpInit reserves the framebuffer and $00:B900-$BEFF). Case 3 was attempted app-side on 2026-07-05 (spacetaxi acceptance test) and the attempt produced two findings you should have:

(a) The app CAN learn its BSS spans: the linker-emitted __bss_seg{0..3}_{lo16,bank,size} symbols (the ones crt0.s zeroes BSS through) are readable from C via a .long DATA32 table in an asm TU -- direct C IMM16 references fail to link ("cross-segment reloc ... uses 2-byte form; only IMM24 / DATA32 supports cINTERSEG patching"). The DATA32 entries resolve as RELOCATED FULL ADDRESSES: the lo16 entry reads back as the 24-bit runtime span base ($08:4F00 observed for the spacetaxi build), the size entry carries the length in its low 16 bits, empty segments read as bank-base-only. Recipe verified working end-to-end under MAME.

(b) BUT actually claiming the span kills the app: NewHandle( attrAddr|attrFixed|attrLocked, base=$08:4F00, size=$6619) from jlpInit SUCCEEDS -- and the app then dies before reaching main's first log line, with the PC profile showing GS/OS desktop idle (silent return-to-Finder). Reproduced twice; reverting only the claim restores the previous behavior. Mechanism unknown from the app side -- presumably the Loader/MM bookkeeping objects to a user handle overlapping a loaded segment's address range, or the claim starves something during the remaining init. The reverted implementation was never committed; the full code (asm table + claim loop) is reproduced verbatim in the copy of this item handed to the toolchain session at /home/scott/claude/llvm816/JOEYLIB-ASKS.md.

Ask: crt0/omfEmit/loader cooperation so every segment's FULL in-memory span (image + BSS + heap) is Memory-Manager-reserved at load time -- with (b) suggesting the reservation must be made BY the loader glue (or before/while segments are registered), not by app code after the fact. This single fix unblocks IIgs sprite codegen (PERF-AUDIT #77), the Space Taxi IIgs port (a 32KB font-surface jlpBigAlloc lands on live state layout-dependently -- three distinct failure modes observed as the binary layout shifted), and removes the whole corruption class.

link816 defines [__heap_start, __heap_end) as the leftover entry-bank space above BSS, but crt0Gsos never claims that range from the GS/OS Memory Manager, so NewHandle can legally place blocks on top of live malloc data (a second, independent corruption channel -- and the crt0Gsos comments already record a Phase-1.1 fopen-hang caused by this class). Ask: reserve the heap range at startup (attrAddr|attrBank| attrFixed|attrLocked), or reimplement malloc on top of an MM handle. Related hardening: pass attrNoSpec on NewHandle calls in the runtime, and consider reserving the pinned SHR stage range $01:2000-$9FFF that JoeyLib's jlpStageAllocPixels hands out as a bare constant.

6. Post-main exit path crashes to the monitor (COP $00 at $00:0002) -- EMPIRICALLY PROVEN, app-independent (2026-07-20)

Every JoeyLib IIgs example that RETURNS from main() dies in the monitor instead of quitting to Finder. Reproduced identically for KEYS and STAXI under MAME (deterministic, ~3 min): press the app's exit key, and execution ends at COP #$00 executing at $00:0002 with S=$17FD. Nobody saw this before because every harness kills the emulator after reading logs; an interactive ESC was the first real exit ever exercised.

MAME debugger execution history at the crash (bp at $000002):

00:9EE0: sta $00c068     ; GS/OS call tail: state reg restore
00:9EE8: xce
00:9EE9: plp
00:9EEA: rtl             ; returns to the app-side thunk:
08:0072: php             ; <- pushes 1 byte...
08:0073: rtl             ; <- ...then pulls 3: garbage return
00:0002: cop #$00        ; monitor

The thunk at segment offset $0072 (bank $08 at run time) executes php / rtl after a GS/OS call returns to it -- the php mis-balances the stack and the rtl returns to $00:0002. This is in the crt0Gsos / runtime exit code (the offset is constant across different apps, so it is runtime code, not app code). Suspects: a Quit stub whose body was meant to sit between the php and the rtl, or a mis-assembled epilogue.

Reproducer scripts (MAME, headless, breakpoint + history + RAM dump): this session's scratchpad escCrash.sh / escCrashKeys.sh -- ask me and I'll re-stage them anywhere durable.

WORKAROUND LANDED IN JOEYLIB (so this is no longer user-visible): src/iigs/hal.c jlpShutdown now issues GS/OS QuitGS ($2029, pCount=0) itself via a runtime-built jsl stub and never returns to the runtime exit path. jlShutdown therefore does not return on IIgs. The root cause still deserves a runtime fix: any app that returns from main WITHOUT calling jlShutdown (or any non-JoeyLib program built on this runtime) still crashes.

7. Volatile uint8_t loads through variable pointers widen to 16-bit bus reads (2026-08-18) -- EMPIRICALLY PROVEN

A volatile uint8_t LOAD through a variable (long) pointer is emitted as a 16-bit lda [dp],y with m=0 and the value masked afterwards (and #$ff) -- so the BUS performs a phantom read of address+1. For memory that is harmless; for I/O it reads a neighbouring register, and hardware with adjacent registers has read side effects. Concrete damage on the IIgs SCC (Z8530, four adjacent registers $C038-$C03B): a data-register read of channel B ($C03A) phantom-pops channel A's RX FIFO ($C03B -- the AppleTalk port), and in a printer-port configuration a status poll of $C039 phantom-pops the MODEM port's data at $C03A. (A per-poll RR1 harvest also correlates with MAME 0.264 SCC RX-deafness, but that is NOT this bug: it reproduced through a guaranteed 8-bit read too, so the phantom read is refuted as its mechanism and the harvest ships disabled under emulation. The width violation itself stands proven by disassembly.)

The width handling is inconsistent, which is what makes it look like a bug rather than a policy:

  • STORES through the same pointers are correctly sep #$20-wrapped 8-bit.
  • LOADS from CONSTANT addresses are correctly sep-wrapped 8-bit (sep #$20 / lda $c038 / rep #$20 -- verified via a probe TU).
  • Only variable-pointer loads take the 16-bit lda [dp],y + mask form.

Ask: honour the declared access width for volatile loads in the long-indirect form -- sep-wrap them exactly like the constant-address and store cases.

WORKAROUND LANDED IN JOEYLIB: src/iigs/serial.c routes every serial register read through a hand-asm jlpIoRead8 (global function, so no call site can be inlined back into the widened form): sep #$20 / lda [$e0],y / rep #$30 / and #$ff. ABI verified from a compiled probe: pointer lo16 in A, bank in X, return in A, $e0-$e3 imaginary-register scratch. Any OTHER volatile I/O read through a variable pointer (input/video HALs, future code) is still exposed until the codegen fix lands.

8. Peeled-first-iteration address negation is one's complement, not two's (2026-08-20) -- FIXED IN TOOLCHAIN same day (see end of item)

A loop that indexes a local array at a base computed by SUBTRACTING a uint8_t parameter (k = 8u - nb; then t[(uint8_t)(k + i)] inside a counted loop) is compiled with the FIRST iteration peeled, and that peeled iteration's address is computed as

base + sign_extend(~nb)        ; one byte LOW: ~nb = -nb - 1

where two's complement (-nb) was needed. Every later iteration increments correctly, so exactly the first element is read one byte low and the remaining seven are right -- a silent wrong-VALUES class with no crash, in the same derived-induction-variable family as the modulo-advance item.

The emitted idiom (RetroNet src/crypto/rnSipHash.c sipRot, w65816 -O2 -ffreestanding -ffunction-sections, disassembly of the peeled first read's address):

lda 0xb6,s          ; nb
eor #0x1aff         ; low byte: nb ^ 0xff = ~nb (high byte junk)
and #0xff
eor #0x80           ; sign-extend byte
clc
adc #0xff80         ;   (x ^ 0x80) - 0x80
...
tsc
clc
adc #0xa3           ; t[] frame base (t + 8 region)
clc
adc 0x1f,s          ; += sext(~nb)  -->  t + 8 - nb - 1 = t[k-1]

Blast radius measured before the workaround: EVERY SipHash-2-4 tag the IIgs client computed was wrong (the rotate primitive feeds every round), so every sealed uplink failed server-side authentication. Nothing client-side ever caught it because the protocol's selective encryption leaves downlink frames plaintext -- silent until a peer verifies. Root-caused via on-target known-answer tests + a per-primitive state probe (SIPBLOCK) diffed against the host build of the same source; the "first iteration reads index k-1" model reproduces both on-target wrong tags bit-for-bit end to end.

WORKAROUND LANDED IN RETRONET: sipRot now takes k (the precomputed read base) instead of nb, removing the 8-nb negation from the function; the emitted first-iteration address becomes a plain base + k and all KATs pass on target. Any other loop whose peeled first iteration derives its address from a subtracted narrow parameter is still exposed.

RESOLUTION (2026-08-20): root cause was NOT the address lowering -- the IR and ISel were correct (a true NEGA8 pseudo). The AsmPrinter expanded NEGA8 as an UNWRAPPED EOR #$FF ; INA on a false "already 8-bit M" assumption: in the real 16-bit-M context the CPU decoded the 2-byte EOR #imm8 as 3-byte EOR #imm16 and swallowed the INA opcode ($1A) as the immediate's high byte -- the disassembly tell is eor #$1AFF. FIXED in W65816AsmPrinter.cpp: NEGA8 now emits emitSepM/emitRepM around the pair, exactly like LDA8absX/STA8absX (safe: SepRepCleanup's isMNeutral never extends an M=8 window across the pseudo). Audited: NEGA8 was the only unwrapped Imm8 expansion. Blast radius before the fix: 6 sites in the RetroNet IIgs client -- the SipHash rotate (every sealed uplink failed to authenticate) and 5 rnWidget functions (index corruption; wild-jump BRK during the download offer frame; likely the phantom-FORM garbage too).

Original ask (kept for the repro): compile the pre-workaround sipRot shape below at -O2 for w65816 and inspect the first read's address chain. (Verbatim pre-workaround function; gSipV is a 40-byte global.)

static void sipRot(uint8_t a, uint8_t nb, uint8_t mode) {
    uint8_t t[16];
    uint8_t i;
    uint8_t k;
    uint8_t x;

    for (i = 0u; i < 8u; i++) {
        x = gSipV[(uint8_t)(a + i)];
        t[i] = x;
        t[(uint8_t)(i + 8u)] = x;
    }
    k = (uint8_t)(8u - nb);
    for (i = 0u; i < 8u; i++) {
        x = t[(uint8_t)(k + i)];
        if (mode == 3u) {
            x = (uint8_t)((x >> 3) | (uint8_t)(t[(uint8_t)(k + i + 1u)] << 5));
        } else if (mode == 1u) {
            x = (uint8_t)((x << 1) | (uint8_t)(t[(uint8_t)(k + i - 1u)] >> 7));
        }
        gSipV[(uint8_t)(a + i)] = x;
    }
}

9. i8 store to a 1-byte stack slot is a 16-bit STA d,s that overflows the return address (2026-08-20) -- FIXED IN TOOLCHAIN same day

M=16 is the default mode, so sta d,s writes TWO bytes. When the target frame object is only 1 byte (an i8 spill / i8 arg-save) at the TOP slot of a frame, the second byte lands on the caller's saved RETURN ADDRESS low byte and the callee's RTL jumps mid-instruction into a different function. Live proof: RetroNet's rnClientOnFileRecord saving its uint8_t op clobbered its own return 0x80->0x01, so its RTL landed inside a different rasterizer switch case, skipped the caller's arg cleanup, and left the DISPLAY record-walk's stack pointer 4 low -> $F6 (big-frame pointer) wrong -> the walk byte-crawled forever on the offer frame (the IIgs download "offer-silence": FILE_OFFER never processed, client never drained the serial).

Minimal repro: void fwd(uint8_t op, const uint8_t *r){ sink(op, r); } at -O2 for w65816 emits sta 0x2, s (16-bit) to save op adjacent to the return.

FIX (W65816RegisterInfo.cpp eliminateFrameIndex, STAfi case): when MFI.getObjectSize(FI) == 1, wrap the STA_StackRel in SEP #$20 / REP #$20 so a 1-byte slot gets a genuine 8-bit store. Only the STORE is a hazard; a 16-bit LOAD of the extra byte is a harmless read (the high byte of an i8 value is ignored), so LDAfi is left 16-bit. Verified: fwd now emits sep #$20 ; sta 0x1,s ; rep #$20. Same defect family as ASKS #8 (NEGA8) and the $F8 far-frame bank byte -- "an 8-bit operation emitted without the mode wrap in default-16-bit-M code."

10. Pointer-deref to a GLOBAL hardcodes bank 0 (STZ $E2) under the GS/OS Loader (2026-08-20) -- FIXED IN TOOLCHAIN 2026-08-21 (provenance-based; see end)

The LDAptr / STAptr / STBptr custom inserter (W65816ISelLowering.cpp) lowers a [dp],Y indirect-long deref with the BANK byte forced to 0 via STZ $E2 (unless the LoaderBankDeref path, which uses $BE, is taken). Bank-0 is correct for a pointer to a STACK local (the 65816 stack is always bank 0) but WRONG for a pointer to a GLOBAL: under the GS/OS Loader the program's BSS/globals are placed in the program bank ($BE, e.g. $0A), not bank 0. So &someGlobal dereferenced through this path reads bank-0 garbage, while the SAME global accessed directly via sta/lda abs (DBR-relative, DBR=$BE) is correct -- a silent, layout- dependent miscompile. The inserter's own comment already flags this tension (gmtime's __gmtimeBuf hit it).

Live proof: RetroNet's inlined rnXferRequest materialised &gX.id (a static global) and read it through LDAptr; the FILE_REQUEST then carried xferId=0xAD (bank-0 garbage) instead of 1, and the server rejected the download. 0xAD is not any valid gX field -- the tell of a wrong-bank read. Disasm: lda #<gX.id off> ; sta $e0 ; stz $e2 ; lda [$e0],y.

WORKAROUND in app: noinline on the function so the global is read in the caller through DBR-relative abs16 and passed by the ABI (same shape as the existing rnXferState workaround).

Ask: the deref bank cannot be a blanket 0 or blanket $BE when stack pointers (bank 0) and global pointers ($BE) are both derefed through one pseudo. The principled fix is a 24-bit pointer representation (carry the bank in the pointer value) or provenance tracking so a global-derived pointer derefs with $BE and a stack-derived pointer with 0.

Fixes EVALUATED and rejected from the app side (2026-08-20):

  1. -w65816-loader-bank-deref (existing flag): whole-program, flips EVERY LDAptr to $BE. Fixes global derefs but breaks stack-pointer derefs (the reason LDAptrBank0 exists for va_arg). Also the runtime *.o are shared across Loader AND non-Loader (GNO/smoke) builds, so it can't be enabled for just the S16/Loader target without a per-mode runtime split.
  2. Fold (load ) -> abs16+DBR at selection -- the clean targeted fix, but NOT minimally reproducible: every small repro (inlined forwarder, barrier-forced re-read, struct field) correctly selects abs16. The mis-selection only appears inside a very large inlined function (RetroNet rnFileRecord, ~0x916 bytes, three transfer routines inlined), so it is a scheduling/CSE artifact of address materialization under register pressure, not a stable pattern -- hard to fix safely without provenance. RESOLUTION (2026-08-21, W65816ISelLowering.cpp LDAptr/STAptr/STBptr custom inserter): instead of a blanket bank policy, use PROVENANCE. When the pointer being derefed traces (through COPYs) to an LDAi16imm of a global / external symbol, the deref is exactly a DBR-relative absolute access to that global -- the same thing a direct global reference compiles to -- so emit LDAabs / STAabs / STA8abs with the global operand and skip the [dp],Y deref entirely. That lands in DBR's bank (correct for a global) with no bank guess, and only GLOBAL-address derefs are touched -- stack pointers (bank 0) and heap/other pointers keep the existing deref path, so no stack-out-param regression. NB the bank canNOT be sourced from $BE: $BE is the code segment's PBR and in a multi-segment program the data (BSS) segment is a different bank; only DBR (via abs) is right. Single-segment builds are unaffected (DBR==0==old STZ bank). Verified: RetroNet's rnXferRequest/rnXferState no longer need noinline (both removed) and the inlined rnFileRecord emits zero bank-0 pointer-derefs; the IIgs download DISK-VERIFIES from the pure-clean build. Also fixes the runtime gmtime __gmtimeBuf case this item's parent note describes. What is still NOT covered: a global address that flows through pointer ARITHMETIC the trace can't follow (e.g. p = &g; p += i; *p) -- there the provenance is lost and the deref falls back to bank 0; those remain candidates for the 24-bit-pointer rework.

11. ADDframe (stack-slot LEA) scheduled between a CMP and its branch clobbers the flags (2026-08-24) -- FIXED IN TOOLCHAIN same day

ADDframe - the LEA-equivalent that puts the address of a stack slot in A - expands at PEI (W65816RegisterInfo.cpp eliminateFrameIndex) to TSC; CLC; ADC #disp. That sequence rewrites N/Z (TSC, ADC), C (CLC, ADC) and V (ADC). The pseudo deliberately carries no Defs = [P] (the same reasoning as the LDA pseudos, see the NOTE above LDAi16imm in W65816InstrInfo.td) and is isReMaterializable, so a post-RA pass may legally place it between a CMP and the Bxx that consumes the CMP's flags. Machine Copy Propagation did exactly that in RetroNet's src/platform/rnClient.c (main, the widget action dispatch):

lda 0xf,s          ; act.kind
cmp #0x3           ; == RN_WA_SUBMIT ?
tsc                ; \
clc                ;  > ADDframe: &act, hoisted here for the SUBMIT arm
adc #0xc1          ; /
bne .LBB3_61       ; tests (SP+0xC1) != 0 - ALWAYS taken

so act.kind == RN_WA_SUBMIT could never be true: the LOGIN button activated, rnWidgetActivate wrote kind=3, and rnClientOnSubmit was never reached. On the IIgs the login form was never submitted; the harness's Tab/Return retry loop then produced exactly nine no-widget Return uplinks, which is what pinned it. A scan of every TU in the IIgs client found this ONE instance (cmp immediately followed by tsc before the branch), which is why only the submit was affected.

The existing mitigation - the PHP/PLP wrap in W65816StackSlotCleanup.cpp Pass -2.5 - did not catch it for two reasons: (1) ADDframe was not in the pass's corrupting set (isLdaLike), so the backward walk from the branch hit it, took isFlagDefining's default ("anything else defines flags") and called it the TEST, then declined to wrap; (2) the pass only collects BEQ/BNE/BMI/ BPL, on the (true) grounds that LDA-like ops leave C alone - but ADDframe also clobbers C and V, so a cmp; ADDframe; bcc would have been miscompiled with no protection at all.

RESOLUTION (2026-08-24, W65816StackSlotCleanup.cpp Pass -2.5):

  • ADDframe is now in isLdaLike (N/Z-corrupting), so the walk continues past it to the real CMP and wraps it: cmp #3; php; tsc; clc; adc #disp+1; plp; bne. It was already in isStackRel and the bump list, so its ImmOffset gets the +1 that compensates PHP's S decrement; the FP-relative exemption in the bump is skipped for ADDframe because it has no FP- relative expansion (always TSC+ADC, i.e. always SP-relative).
  • BCC/BCS/BVC/BVS are collected as well, with a separate isCarryCorrupting predicate (ADDframe only): under a C/V branch an N/Z-only corrupter is walked past without a wrap, so the size of every existing carry-branch sequence is unchanged.
  • Pinned by llvm/test/CodeGen/W65816/addframe-flag-wrap.mir (nz, carry, and the lda-under-carry no-wrap case). Verified: the rnClient.c TU rescans clean (zero cmp/tsc/branch sequences in all 20 IIgs client TUs) and the IIgs login submits live - see the RetroNet e2e matrix (joey-iigs session).