← board

Cross-target lua 5.4 + sqlite3 — build & run on all backends

Progress log (session 2026-07-06 #3, riscv32 sqlite: heap + variadic FIXED, core CRUD works)

riscv32 sqlite CREATE TABLE / INSERT / SELECT now RUN (was: SIGSEGV at the first CREATE TABLE). Two root causes fixed, both GENERAL riscv32 bugs, both committed; make test + self-host byte-identical + test-lua-cross 24/24 green.

  1. fix(riscv32): hosted heap uses mmap, not the 64 KiB ESP static arena (commit 8f59aad3). THE "sqlite3EndTable crash." builtinheap.pas line 7 did {$ifdef CPU_RISCV32}{$define PXX_ESP} — hosted riscv32 (qemu-user linux) was forced onto the bare-metal ESP path: a single 64 KiB static EspArena, no mmap. Cross lua fits in 64 KiB so it passed; sqlite runs hundreds of allocs during CREATE TABLE, exhausts the arena → HeapMmap returns 0 → PXXAlloc bump path sets base=0 and stores the size header through NULL (PWord(0)^ := size, the sw a1,0(a0) a0=0 crash). Fix: only ESP-heap for bare-metal riscv32 (PXX_ESP_BARE); hosted riscv32 uses the normal 256 MiB mmap pool (its read/write already use linux syscalls 63/64) via a new HeapMmap branch (generic mmap = 222). Verified: malloc+realloc torture (>64 KiB, 300 blocks) bad=0. DIAGNOSIS: the crash disasm was PXXAlloc's PWord(base)^:=size; Result:=base+8; markers showed all mallocs valid until the arena ran dry.

  2. fix(riscv32): variadic args that spill past a0-a7 (commit b9d924de). After the heap fix, CREATE TABLE reached VDBE exec and crashed in OP_SeekRowid indexing aMem[pOp->p3] with p3 = a stack pointer. Traced UP: p3 ← ExprCodeTarget TK_REGISTER iTable ← the #N token in the nested-parse SQL ← sqlite3NestedParse ("...WHERE rowid=#%d", ..., pParse->regRowid). regRowid was 1, but the formatted SQL was rowid=#724214432 (a stack ptr in decimal). Root: the format has 9 total args (2 named + 7 varargs); the trailing %d is the 7th vararg = the FIRST to spill past a0-a7 onto the stack, and it read garbage. Two bugs in the riscv32 variadic ABI: (a) va_start overflow anchor hardcoded s0+8 but the callee's incoming stack-arg base is s0+16 (=entry_sp, per parser.inc rv32 spill); (b) the call site pushes args in index order so the stack tail is REVERSED — arm32 already reverses its variadic tail, riscv32 didn't. Fix: anchor at s0+16 (cparser.inc) + reverse the (nArgs-8) variadic stack-tail words at the call site (ir_codegen_riscv32.inc, mirrors arm32). Repro: variadic fn, 2 named + 7..N int varargs → 7th+ read garbage/reversed; now in order. GENERAL riscv32 bug (any variadic call with >8 total arg words).

Verified working on riscv32 (qemu-user): CREATE TABLE, INSERT (incl multi-row

3rd bug — ORDER BY (VDBE sorter) crash — FIXED (commit 2c83830c, fix(cfront): keep base record for double-pointer struct fields). Turned out NOT riscv32-specific: SELECT ... ORDER BY <non-indexed column> (the sorter path) SIGSEGV'd on ALL 32-bit targets (i386/arm32/riscv32), and was latently wrong on LP64 too. The extended test only did ORDER BY id (primary key → btree already ordered → no sorter), so it was never exercised. Root cause = a general C-frontend miscompile: a struct field declared T **m dropped its base record (cparser.inc's C struct-field declarator loop set declElemRec := REC_NONE for declStars>=2), so p->m[i]->field resolved the trailing ->field to REC_NONE → offset 0. sqlite's p->apCsr[pOp->p3]->cacheStatus = CACHE_STALE in OP_SorterData thus wrote offset 0, zeroing the pseudo-cursor's eCurType/nullRow instead of cacheStatus@24. OP_Column then saw a bogus CURTYPE_BTREE cursor, dereferenced sqlite's fake cursor (sqlite3BtreeFakeValidCursor, a 1-byte static), and crashed in getCellInfo→btreeParseCell(NULL pPage). On LP64 the fake cursor's static neighbour happened to be nonzero (info.nSize), so getCellInfo skipped the parse and survived. Fix mirrors the existing local-variable T** fix in ParseCDeclType: keep declElemRec := baseElemRec (declElemTk stays tyPointer for stride). Minimal repro: struct Vm{int x; struct Cur **apCsr;}; p->apCsr[i]->cacheStatus=0; clobbered apCsr[i]->eCurType before the fix. DEBUG PATH (recorded): bisected VdbeExec by per-opcode markers → OP_SorterData zeroed cursor 2 → bracketed the exact write → local-pointer split fixed it → minimal 3-level p->arr[i]->field repro reproduced on x64 too → cparser declStars>=2. riscv32 ORDER BY a now sorts correctly; make test + self-host byte-identical + test-lua-cross 24/24 green. GENERAL cfront fix (any struct{...T**m...}; p->m[i]->field).

i386 + arm32 sqlite extended test = BYTE-IDENTICAL to the x86-64 oracle (full CRUD + CREATE INDEX/sorter + transactions + aggregates), confirming the double-pointer-field fix. All three 2026-07-06 #3 fixes (heap, variadic, double-pointer-field) are GENERAL compiler/runtime fixes, not riscv32 hacks.

FIXED (2026-07-06, commit 59831ced) — 4th distinct bug: int-valued REAL hang. Root cause was NOT the formatter/FpDecode and NOT context-dependent register pressure. It was a plain riscv32 codegen gap in IR_STORE_MEM: storing an integer value through a double* pointer stored the raw integer bits with no int->double conversion. sqlite3AtoF parses an integer-valued literal ("2.0") down to *pResult = s (s = u64 = 2), so the column was stored as 0x0000000000000002 — an IEEE denormal ~= 0. Every later read then spun forever in FpDecode's Dekker L1 loop (rr[0] never grows). Fractional reals took AtoF's dekker multiply path, never hit the bare *pResult = s, so they were fine. The earlier "word-swap MEASUREMENT artifact" note steered off — a char-based byte dump (reliable, unlike %llx) showed the store wrote raw int bits. Fix mirrors the x86-64 IR_STORE_MEM cvtsi2sd path and the riscv32 IR_STORE_SYM float path: route a float-target store's RHS through EmitFloatOperandRISCV32 (i2d/l2d/i2s) before the store; a genuine-double source is a no-op repack. riscv32 sqlite extended test now BYTE-IDENTICAL to the x86-64 oracle — all 5 targets identical. make test + self-host + test-lua-cross 24/24 green. Minimal repro (no sqlite): void f(double*p,unsigned long long s){*p=s;} with s=2 → bytes 00..02 before, 40 00.. after.

(historical, now fixed) — riscv32 ONLY, 4th distinct bug: formatting an integer-valued REAL hangs. SELECT of a REAL whose value is integer-valued (2.0, 100.0, 35.00, Bob's 2000.0) HANGS on riscv32; fractional values (1500.5, 2.5) work. So the extended test hangs in the first SELECT * ... ORDER BY id after row 1 (it's NOT the sorter — SELECT ... ORDER BY <col> sorts fine now). i386/arm32/x64/aarch64 are unaffected → riscv32-specific, and never seen before because riscv32 didn't get this far. LOCALIZED: the hang is sqlite3FpDecode's Dekker double-double scaling loop while(rr[0]<9.22e7){ dekkerMul2(rr,1e10,0); } (L1) spinning — for an integer-valued real, rr[0] never grows past the threshold, so the loop never exits. Confirmed by per-opcode + in-loop markers: reaches OP_ResultRow → vdbeMemRenderNum (RN.real) → sqlite3_str_appendf("%!.15g") → FpDecode → L1 spins. RULED OUT (all work standalone on riscv32): int→double (double)i (c2.c — the apparent word-swap was a memcpy(u64,&double,8) MEASUREMENT artifact, not a real bug), the softfloat i2d/l2d kernels (k.c — return correct lo/hi), dekkerMul2 + the exact L1/L2 loops (dek.c — terminate for 2.0/100.0/1500.5), long double (sizeof=8 → bUseLongDouble=false, Dekker path). So the bug is a CONTEXT-DEPENDENT riscv32 codegen issue: the Dekker loop that works standalone spins inside the large sqlite3FpDecode (suspect: register/double spilling of the non-volatile double rr[2] across the volatile-pointer dekkerMul2 call, or the loop-condition double compare miscompiled under register pressure). NEXT SESSION: minimal repro = the FpDecode Dekker else block verbatim inside a large function with many live doubles; instrument L1 with %g-printed rr[0] (NOT memcpy — memcpy-of-double bit-printing is unreliable on rv32) to see if rr[0] fails to grow (dekkerMul2 result lost) vs the compare is wrong. riscv32 = LOWEST PRIO (gimmick/assume-IDF); core sqlite (CRUD, ORDER BY, non-integer-real formatting) works.

Progress log (session 2026-07-06 #2, i386 + arm32 sqlite GREEN — 4/5 targets)

MILESTONE: sqlite3 runs byte-identical to the oracle on x86-64, aarch64, i386, AND arm32 — all libc-free static. Only riscv32 left (lowest priority). Chased the shared "32-bit schema-write segfault" from the previous log to root causes:

i386 (commit fix(i386): sqlite green): two bugs.

  1. IR_CALL_IND pushed every arg as 4 bytes; a 64-bit (sqlite_int64) or double by-value arg through a function pointer needs 8 — sqlite's VFS xWrite takes an i64 offset, so memjrnlWrite got a shifted sqlite3_file* and segfaulted. Marshal by the signature proc's param types.
  2. double->int64 (Trunc, used by C casts) did cvttsd2si eax + cdq = 32-bit convert sign-extended, so |x|>=2^31 became the 0x80000000 sentinel. sqlite's %f casts a ~1.5e18 double to u64 (sqlite3FpDecode) -> u64-max garbage (balance = 18446.744...). Use x87 fld+fisttp/fistp for a true 64-bit convert. DIAGNOSIS: gdb-under-qemu (qemu-i386 -g 1234 + gdb-multiarch) + --dump-cpp to map the amalgamation TU line -> memjrnlWrite; then isolated sqlite3_mprintf("%f",1500.5) -> va_arg(double) OK -> sqlite3FpDecode -> (u64)rr[0] -> minimal (u64)1.5e18 repro.

arm32 (commit fix(arm32): sqlite green): two bugs, both shared. 3. C struct-by-value ABI (shared, all backends): a C record param uses the by-ref ABI (caller copies to a temp, passes &temp; callee derefs). BUT RegisterProc (symtab.inc) hardcoded Procs[].Params[].IsRef := False, so the arm32/riscv32 CALLER's record-by-value branch fired and pushed the 8-byte record as TWO words (value + a stale 2nd word = 0) while the callee read ONE pointer word -> the 2nd struct arg (sqlite3AddColumn's Token sType) came through NULL -> segfault at the first CREATE TABLE. FIX: Params[].IsRef := CProgramMode and (ptypes[i] = tyRecord). Minimal repro /tmp/sbv6.c (justA OK, justB crashed). This also silently fixed riscv32's struct-by-value. 4. double->int64 (shared arm32+riscv32): VFP vcvt.s32.f64 / soft __pxx_d2i are 32-bit only (saturate at 2^31). Added __pxx_d2i64/_rne soft-float kernels (full Int64 range) + routed arm32 Trunc/Round and riscv32 Trunc/Round for Int64 results through them. arm32 now pulls softfloat (C + Pascal; falls back to 32-bit VFP if absent so no hard dep). LANDMINE: the Pascal RTL's float formatter uses Trunc->Int64, so the arm32 softfloat pull had to cover the Pascal path too or -g --target=arm32 (dwarf smoke gate) broke.

Gate: make test + self-host byte-identical + test-lua-cross 24/24 (all 4 targets, no regression).

OPEN — riscv32 only (LOWEST PRIO, user: gimmick/assume-IDF): builds libc-free, sqlite3_open OK, struct-by-value + double-conv now fixed (sbv6 passes), but still SIGSEGVs at the first CREATE TABLE with a distinct bug: sw a1,0(a0) with a0=0 — a local pointer at s0-40 is NULL and stored through (*p = v, p NULL). No source line (riscv32 -g DWARF not read by gdb-multiarch; addr2line = ??:0). LOCALIZED (session #2, printf-marker bisection of the scratch sqlite3.c): SELECT/PRAGMA/BEGIN all run fine — only DDL crashes. sqlite3StartTable and every sqlite3AddColumn complete (markers M1 aNew / M2 pCol / M3 done all print, struct args correct). The crash is INSIDE sqlite3EndTable (sqlite3.c ~123985, the CREATE TABLE VDBE-codegen / schema-write function): x64 prints "EndTable ENTER" then r=0; riscv32 prints "EndTable ENTER" then SIGSEGV. NEXT SESSION: printf-bisect within sqlite3EndTable (it's large — writes sqlite_master row, builds the VDBE program, updates the in-memory schema hash) to find the NULL-base store; likely a riscv32-specific codegen gap (a pointer local not initialized, or a returned pointer read wrong on rv32). Fast repro: in-memory CREATE TABLE t(x) = /tmp/sqx.c pattern, build --target=riscv32 -g, run under qemu-riscv32 (crashes ~instantly).

Progress log (session 2026-07-06, cross sqlite libc-free + all 4 targets build)

MILESTONE: sqlite3 now builds LIBC-FREE STATIC on all 5 targets; x86-64 + aarch64 run byte-identical to the oracle. Before this session x86-64/aarch64 sqlite were dynamically linked against libc.so.6 (the unix VFS POSIX syscalls resolved via GOT); i386/arm32/riscv32 could not build at all. Now zero libc, zero external symbols, one static binary — the golden-demo direction.

Landed (all pushed, master green: make test + self-host byte-identical + test-lua-cross 24/24 all 4 targets):

  1. libc-free POSIX syscall veneer (feat(crtl), commit on master): sqlite's unix VFS calls open/fstat/lstat/stat/fcntl/fsync/fchmod/mkdir/getpid/ nanosleep/gettimeofday/utimes/sysconf/mmap directly. Added real wrappers routed crtl C → pxxcio __pxx_* → platform PAL:
    • PAL backend (lib/rtl/platform/posix/platform_backend.pas): statx(2)-based fstat/lstat/stat (arch-neutral struct — ONE field-offset map for all targets; returns real (dev,ino) so sqlite's POSIX lock manager keys file identity correctly) + fcntl/fsync/fchmod/getpid/nanosleep/realtime/utimes/ mmap/munmap, with per-arch SYS_* numbers for all 5 arches. esp backend = unsupported stubs. Extended TPalFileStat with Ino/Dev/Blocks/BlkSize.
    • pxxcio: __pxx_fstat/stat/lstat fill a fixed 48-byte TPxxStatBuf (5×i64+2×i32) the C veneer copies into struct stat; fcntl/etc pass through. off_t == native long, so struct flock / lseek offsets match each arch's kernel ABI with no translation (the keystone that makes fcntl a raw passthrough).
    • crtl src (auto-pulled via header→sibling-.c mechanism): fcntl.c, sys/stat.c, unistd.c, sys/time.c, sys/mman.c (mmap→MAP_FAILED: sqlite mmap defaults off, only needs to link), nanosleep in time.c.
  2. C octal literals (fix(cfront) clexer.inc): 0100/01000 were parsed base-10 → the fcntl O_* flag macros came out 100/1000 not 64/512, injecting a phantom O_EXCL into open(2) flags (second open of a file → EEXIST). Now base-8. GENERAL cfront bug.
  3. 64-bit C signed >> (fix(cross) i386/arm32/riscv32): binop64 lowering only handled shr via tkIdent (Pascal shr = always logical). C >> is tkShr, arithmetic on signed Int64 — added sar/asr (+ sign-fill on shift≥32) branches.
  4. riscv32 @external routine (symtab.inc EmitExternalProcAddr): was a hard error; now mirrors the external CALL path (auipc + DynCall-patched inline literal), loading the resolved fn addr into a0.
  5. arm32 indirect call >4 params (ir_codegen_arm32.inc IR_CALL_IND): capped at 4; sqlite's pVfs->xOpen has 5. Mirror the IR_VIRTUAL_CALL >4 stack-spill.
  6. i386 record variables + tyRecord load-through-pointer (ir_codegen386.inc): the fat-slot backend refused records; admit tyRecord (value = slot address, by-ref model like x86-64; IR_LOAD_MEM loads 8 bytes edx:eax matching x64).

Verified: x86-64 + aarch64 sqlite3 = statically linked, MATCH oracle byte-for-byte (CRUD, transactions, COUNT/SUM/AVG, floats 2000.75/35.00, NULL). Minimal syscall test (open/write/fstat/fcntl/fsync/getpid) works on arm32/riscv32. (A printf %lld-with-many-args display drift on arm32 is a SEPARATE cosmetic crtl varargs issue — the raw struct bytes are correct; not a wrapper bug.)

OPEN — shared 32-bit schema-write segfault (i386 + arm32 + riscv32, IDENTICAL): All three now build libc-free static, link, sqlite3_open succeeds ("DB opened successfully" prints), then SIGSEGV during the FIRST CREATE TABLE (schema write), before "Table and index created". 64-bit targets are perfect → this is a single SHARED 32-bit bug (codegen or struct-layout), NOT per-backend and NOT the syscall wrappers. i386 gdb-under-qemu backtrace: crash mov (%eax),%eax with eax bad, in the pager/pcache sub-allocation carve region (~sqlite3PagerOpen, sqlite3.c ~61970: pPager=(Pager*)pPtr; pPtr += ROUND8(sizeof(*pPager)) pattern). Call chain (amalgamation TU lines): #0 75409 → … → #13 145809. STRONG hypothesis: a 32-bit sizeof/struct-field-offset or pointer-arithmetic miscompile (cf. the aarch64 v177 BtCursor.iPage struct-layout family) — probe with an offsetof/sizeof TU comparing pxx vs gcc layout on i386. NEXT SESSION: root-cause this one bug → lights up all three 32-bit targets at once (i386/arm32 practical; riscv32 = bonus, USER SAYS LOWEST PRIO / "assume IDF", fine to leave last).

Progress log (session 2026-07-05 #3, riscv32 hosted C brought up to near-lua)

19. riscv32 hosted C lua — from "won't link" to "runs all init + scalars, one remaining table-rehash corruption." Chain of fixes (all committed, test-riscv32 green, self-host byte-identical, x86-64 lua 6/6 + cross 18/18 unaffected):

RESOLVED (session #3b→c): riscv32 lua now 6/6 — ALL FOUR cross targets pass lua 6/6 (24/24), riscv32 wired into make test-lua-cross default. Two fixes:

LESSON: the "heisenbug + wild store of a small constant" signature = a stack-frame sizing/overflow bug; check the PROLOGUE frame reservation against the target's immediate range FIRST (each backend's PatchProcPrologue has its own limit).

--- historical (the hunt that led here) --- REMAINING riscv32 lua bug (razor-sharp repro): storing a NEW key that triggers rehash crashes. local t={} t[1]=5 (or t.x=9) SIGSEGVs; local t={1,2,3} + t[2] read is fine (array preallocated, no rehash). Localized via __pxx_write markers in ltable.c: the crash is in the luaH_newkeyrehashluaH_resize path. It is a HEISENBUG — adding markers moves the crash point downstream, the classic MEMORY-CORRUPTION signature. arm32/i386 pass this, so it is riscv32-specific.

Deep localization (session #3b, gdb on the clean binary — addresses stable, no ASLR under qemu-user):

Progress log (session 2026-07-05 #2, 32-bit heap corruption ROOT-CAUSED + FIXED)

18. THE "arm32/i386 lua/sqlite emit garbage" BUG — FIXED (commit eb972e79). It was NOT garbage output or per-backend codegen — it was one shared 32-bit heap-corruption bug. builtinheap.pas PXXAlloc (zero-on-reuse) and PXXRealloc (grow-copy) loops walk with PWord (= ^NativeInt, machine-word: 8 bytes on 64-bit, 4 on 32-bit) but advanced the index by a hardcoded 8. On ILP32 each PWord write moves 4 bytes while the loop steps 8, so bytes [0-3],[8-11],… are written and [4-7],[12-15],… are skipped: every realloc silently dropped half the payload, reused blocks were half-uninitialised. Any 32-bit program doing real realloc corrupted — lua/sqlite hammer realloc, so BOTH i386 and arm32 broke; x86-64 was fine only because there NativeInt=8=step. Fix: step by SizeOf(NativeInt) in both loops. Also retyped the crtl↔RTL bridge __pxx_malloc/__pxx_realloc params Int64NativeInt (latent width mismatch vs their C long externs after long=native; byte-identical on x86-64).

Progress log (session 2026-07-05, aarch64 first)

Commits: 3f0954bf (headers + setjmp + variadic + deref), d3672df7 (unsigned div), 851ff448 (unary ~ type). All keep x86-64 self-host byte-identical + make test green.

Fixed (all verified with minimal C repros):

  1. crtl headers (Phase 1, B): float.h, time.h+time.c; __pxx_time/ __pxx_clock bridges in pxxcio.pas (per-arch clock_gettime). Cleared the float.h→time.h preprocessor walls.

  2. setjmp/longjmp cross stubs (cparser.inc EmitCSetjmpStubs) — was x86-64-only; per-ABI save/restore for i386/aarch64/arm32/riscv32.

  3. Variadic C call site (aarch64/arm32/i386): strict nArgs=ParamCount check now bypassed for ProcVariadic.

  4. Variadic callee prologue: SysV register-save was emitted UNCONDITIONALLY (x86-64 bytes → SIGILL when a variadic fn was called on cross). Now per-target; aarch64 GP-only save area + __pxx_va_arg_cross. arm32/i386/riscv32 raise a clear "not yet" error — their 4-byte-slot variadic model is still TODO.

  5. Deref-of-call double-eval (all 4 cross backends): statement driver's else catch-all emitted IR_LOAD_MEM standalone, re-running its address operand — *f() called f twice, corrupting va_arg. Added IR_LOAD_MEM to each no-op list.

  6. Unsigned 64-bit div/mod on aarch64 used SDIV not UDIV → MAX_SIZET/N=0 → lua bogus "table overflow"; also broke %lu of large values. Now keys off TypeDivideUnsigned(IRTk[left]) like arm32/riscv32.

  7. Unary ~ result type hardcoded tyInteger → (~(size_t)0)/N divided signed. Now preserves the promoted operand type.

  8. Unsigned integer compares on aarch64 used signed condition codes (EmitSetccA64 always lt/le/gt/ge) — this was the limit=-1 wall. luaM_limitN's guard cast_sizet(MAX_INT) <= MAX_SIZET/sizeof(ls_byte) (= <= 0xFFFF…F) went FALSE because 0xFFFF…F read as -1, so the else branch returned cast_uint(0xFFFF…F) = -1 as the opcode-array limit → "too many opcodes (limit is -1)". The 4-byte Instruction case had slipped through only because MAX_SIZET/4 = 0x3FFF…F reads positive-as-signed. Added EmitSetccA64Ex(op,isUnsigned) → lo/ls/hi/hs; the compare site passes unsigned when either operand is an unsigned ordinal.

  9. (bonus, same root family) the two-step diagnosis above also depended on the #6 unsigned-div and #7 ~-type fixes to get MAX_SIZET/N right first.

Phase 4 (partial): make test-lua-cross added (LUA_CROSS_TARGETS, default aarch64); mirrors test-lua's skip guard, runs each script under qemu vs the same .expected. Green for aarch64. NOT wired into make test.

Phase 3 — aarch64 sqlite3 (commit de9741a0): compiles + links (6.3MB, 3861 procs) and sqlite3_open(":memory:") works. Bugs fixed to get there: 10. crtl VFS headers (B): fcntl.h, inttypes.h, sys/{stat,time,ioctl,mman}.h + time.h timespec/nanosleep/clock_gettime + utimes. Declarations only (the :memory: DB never calls the file VFS; they just must compile/link). 11. fn-ptr param with a (void) signature dropped from ParamCount — parsing void (*x)(void) leaked global CTypeIsVoid so the outer param list skipped the whole fn-ptr param. GENERAL bug (x86-64 silently miscompiled, pushing a garbage extra arg; aarch64's strict arg-count check caught it). Fix in cparser: clear CTypeIsVoid once the declarator is a pointer. 12. @extern (address of an external routine) for aarch64 + arm32 (was x86-64-only) — reuse the GOT-slot machinery with a load instead of a call (sqlite aSyscall[] pointer table). symtab.inc EmitExternalProcAddr. 13. aarch64 external variadic calls guarded (fcntl/open int f(int,int,...)).

14. sqlite CREATE TABLE segfault (aarch64) — FIXED (bug-aarch64-signed-subword-load-32bit-extend). Root cause: narrow signed loads (ldrsb/ldrsh) sign-extended to only 32 bits, not 64. ir_codegen_aarch64.inc emitted the 32-bit-Wt variant (opc=11, $39C0…/$79C0…) for signed byte/half loads. Since aarch64 W-register writes zero the top 32 bits, a stored i8 = -1 (0xFF) loaded via ldrsb w0 became x0 = 0x0000_0000_FFFF_FFFF — then used in a 64-bit compare it reads as a large POSITIVE number. sqlite's BtCursor.iPage (i8, init -1) thus tested iPage >= 0 TRUE, so moveToRoot took the "page already loaded" branch with pCur->pPage == NULL → NULL deref in getAndInitPage/sqlite3PagerPageRefcount( pPage->pDbPage). Fix: emit the 64-bit-Xt sign-extending variant (opc=10, $3980…/$7980…) at all 8 signed sub-word load sites (IR_LOAD_MEM deref + EmitLoadVar global/ref-param/local; sz=4 already used ldrsw=64-bit, correct; unsigned ldrb/ldrh zero-extend to 64 via top-zeroing, correct). Matches x86-64's movsbq/movswq. GENERAL bug — any negative i8/i16 compared/used as 64-bit on aarch64 was wrong; only surfaced here because the value flowed into a signed >= 0 guard. DEBUG PATH: 18-deep qemu stack → walked frame chain by x29 → identified crashing struct via BFS of its object graph for ASCII strings (":memory:", "sqlite_master", column names) → BtCursor (iPage@84 i8, pPage@136) → pxx struct layout == gcc (verified via a standalone offsetof-probe TU), so gcc ptype /o gave field names. Marker recipe (decl-style, cfront-safe): long __pxm = __pxx_write(2,"[tag]\n",N); among the function's leading decls.

15. arm32 + riscv32 variadic C ABI — DONE (direct va_arg), commit 2647f41f. The va_arg machinery (was x86-64 + aarch64 only) now covers the 32-bit cross targets. Verified end-to-end via exit codes under qemu (int / int64 / pointer sequences, in loops, order-sensitive across the reg/stack boundary):

16. i386 variadic C ABI — DONE (commit c5f80ac6), printf incl %f byte-identical to x86-64. i386 has no arg registers (all-stack cdecl) and normally pushes leftmost-deepest (reversed) to match the callee's reversed spill — undecodable for a variadic callee. Fix, variadic-call-only: call site pushes ALL args in reverse index order → FORWARD layout (arg0 at [ebp+8]) via an order array (reuses the per-type push logic; 64-bit tail arg = two dwords); callee named-spill uses the forward disp (params to the LEFT) for variadic fns; prologue reg-area size 0, overflow = [ebp+8+namedBytes]. i386's all-stack ABI passes the 24-byte va_list by value naturally, so printf→__crtl_vformat "just works" (unlike arm32/riscv32). LANDMINE self-caught: the float branch's arg-advance wasn't converted to the order array → a double param not-last reprocessed the next arg under the wrong index (non-variadic regression); all advance sites now go through the order array. i386 lua/sqlite get much further but hit SEPARATE i386 gaps: sqlite = a non-variadic "external call argument count mismatch" (ir_codegen386 external path's strict nArgs=ParamCount check); lua = a SIGSEGV (unrelated i386 codegen).

17. va_list → array type (commit 74d6d4b7), the by-value blocker below is CLEARED for the simple case. typedef struct __pxx_va_elem va_list[1] — a local va_list is still 24 bytes on the stack (no alloc) but the bare name decays to a pointer, so printf → __crtl_vformat(va_list) passes a pointer, not a 24-byte copy. ONE-line change: cfront already handles the array typedef, CVaListAddr's &ap is right for both local (→&ap[0]) and a param (already a decayed pointer) — compiler binary byte-identical. Verified: x86-64/aarch64/i386 printf incl %f byte-identical (no regression); direct va_arg on all 5 targets unchanged; simple printf→helper va_list-passing now works on arm32/riscv32.

REMAINING arm32/riscv32 printf blocker — ROOT-CAUSED: cfront drops the array dimension when an array-TYPEDEF is used as a PARAMETER. va_list ap (with typedef struct __pxx_va_elem va_list[1]) should decay the param to a pointer (C: array params → pointers). Instead cfront types it as a BY-VALUE tyRecord (the struct) — confirmed via a DBGPUSH writeln in the arm32 call loop: inner's ap param = tk=5 (tyRecord) isarr=0. The explicit-bracket decay at cparser.inc:4780 only fires on a literal T name[...], never for a typedef-array param. Consequence on arm32: the by-value-record push (the RecSize<=8 branch, ir_codegen_arm32.inc ~2090) pushes the arg as TWO words (&ap + garbage r1) instead of one pointer word, shifting every following arg so the callee reads ap from the wrong stack slot → helper gets ap=NULL → SIGSEGV. x86-64/aarch64 tolerate it (real struct-by-value + &ap still lands on the copy); i386 tolerates it (all-stack). It only surfaces once crtl's size_t (=unsigned long=8 on arm32, see below) pushes the va_list arg past r3 onto the stack. Minimal repro (no va_list): inner(int,int,int,int, Box b) with typedef struct{int a;} Box[1] SIGSEGVs; a plain-pointer 5th arg works.

FIX (well-scoped cfront work, the real "A"): make a typedef-array PARAMETER decay to a pointer, the same as T name[...]. Needs (1) ParseCTypedef to record the array dimension of typedef T Y[N], and (2) the param loop (cparser.inc ~4777-4806) to apply the pointer decay when the resolved param type is an array typedef. Then va_list params are 1-word pointers on every target and printf works uniformly. (Verified the array typedef itself is fine for LOCALS + direct va_arg on all 5 targets; only the PARAM decay is missing.)

RESOLVED (commit 1b12f4a6): long is now native (machine-word-sized). long/size_t=8 on LP64 (x86-64/aarch64, unchanged/byte-identical), 4 on ILP32 (i386/arm32/riscv32); long long always 64-bit. 3-line ParseCDeclType change, crtl typedefs follow automatically. arm32 printf incl %f now byte-identical to x86-64 — size_t=4 keeps crtl snprintf's va_list arg in a register, dodging the typedef-array-param stack bug (which still exists for the >4-word case but is no longer hit by common printf). make test + self-host + test-{i386,arm32,riscv32} green (fixed one test that hardcoded long=64). STILL OPEN: riscv32 printf = softfloat (__pxx_dcmp, %f); arm32/i386 lua/sqlite build but emit garbage / hit separate 32-bit codegen bugs; the typedef-array-param decay (item 17) is still worth doing for correctness (>4-word va_list args). The remaining consideration below is now historical/optional.

Historical DESIGN NOTE (was future work, now done above): long sizing. pxx made long/size_t 64-bit on every target (sizeof(long)=8 even on arm32/i386) — consistent but C-divergent (real ILP32 long=4). This is what pushes crtl's va_list arg onto the stack in the first place. Purists / memory-tight targets (riscv on ESP) may want native long. Option B — make long/size_t 32-bit on 32-bit targets — would ALSO unblock printf (keeps the va_list in registers) and is more C-correct, but reverses the consistent-64-bit model. Deferred; do the cfront typedef-array-param decay (A) instead, which is orthogonal and correct regardless of the long choice.

PRIOR BLOCKER (now cleared for simple case) — va_list passed BY VALUE. crtl's printf (and sqlite's/lua's own printf) do va_start(ap,fmt) then hand the whole va_list (24-byte struct) to a formatter (__crtl_vformat, sqlite sqlite3VXPrintf) by value. arm32/riscv32 have no struct-by-value >8 bytes ABI, so ap's reg_save_area pointer arrives garbage → SIGSEGV. Direct va_arg works; only the pass-va_list-to-a-helper pattern breaks. FIX OPTIONS (next session): (a) array-typed va_list (typedef struct __pxx_va_elem va_list[1]) so it decays to a pointer on any call — but CVaListAddr unconditionally emits &ap, which is wrong for an array-decayed pointer parameter (double indirection); needs __builtin_va_arg/va_start to detect ap-is-already-a-pointer. (b) 32-bit struct-by-value >8 bytes (or by-hidden-ref) in the arm32/riscv32 backends. Also: riscv32 printf independently needs softfloat (__pxx_dcmp …); riscv32 has a separate int64-local + int-local-both-used codegen bug (an int64 va_arg result added to an int va_arg result dropped the int — (int)a+b returned a only; non-variadic pointer-deref equivalent works, so it is a riscv32 int64-local interaction, not the variadic ABI). i386 variadic still gated off (reversed cdecl arg order needs a forward-order call-site pass).

PRIOR WALL (now cleared) — sqlite CREATE TABLE segfault (aarch64): minimal repro = the extended-test head (SQLITE_THREADSAFE 0 + amalgam includes) with body sqlite3_exec(db,"CREATE TABLE t(x INTEGER)",0,0,&e). x86-64 rc=0; aarch64 SIGSEGV after "DB opened". Fault at a tiny accessor f(arg){ …arg->[0x70]… } with arg=NULL; its caller passed P->[0x88] which is NULL on aarch64 but set on x86-64. So a struct pointer field at offset 0x88 is unpopulated. NARROWED via __pxx_write markers: the initial CREATE codegen runs fine; sqlite3EndTable is entered TWICE (codegen with init.busy=0, then during the schema reparse with init.busy=1 = the in-memory-representation insert). The crash is in that schema-load path (VDBE OP_ParseSchema re-running the CREATE to build the in-memory Table), AFTER EndTable's sqlite3HashInsert — not the first codegen. So the NULL field is on a Table/Schema struct built during schema load. Bitfield struct layout was verified identical aarch64-vs-x86-64, so suspect a larger/ nested struct offset, an aggregate initializer, or a field written on one path and read on another with a mismatched offset. Debug: gdb-multiarch via qemu-aarch64 -g; instrument the schema-load callbacks (sqlite3InitCallback / the OP_ParseSchema VDBE handler) and trace who writes struct+0x88. Marker recipe: file-scope extern long __pxx_write(int,const void*,unsigned long);, block decls before statements (cfront rejects mid-block extern). The sqlite tree is gitignored scratch — debug edits there are untracked and were reverted.

Then (future session):

Goal

Make the real C programs lua 5.4 and sqlite3 compile+run on the CROSS targets (i386, aarch64, arm32, riscv32), not only AMD64. Long stretch of Pascal-focused compiler work may have left cross C→IR / backend regressions; these two large real programs are the coverage. External libs — keep OUT of make test; green cross runs go behind their own targets.

Phase 0 — AMD64 baseline (DONE 2026-07-05, no regression)

Root cause of the cross gap (diagnosed)

Cross build of lua stops at #include <float.h>. cpreproc.inc:1503 gates the /usr/include host-header fallback on TargetArch = TARGET_X86_64 — deliberate and correct (host headers = wrong ABI for a cross target). So cross builds must resolve every system header from pxx-owned crtl headers (lib/crtl/include).

crtl missing (present: assert/ctype/errno/limits/locale/math/setjmp/signal/ stdarg/stdbool/stddef/stdint/stdio/stdlib/string/unistd/wchar/wctype + sys,arpa, netinet dirs):

Platform-guarded headers (windows/readline/unicode/malloc/process) are not reached on a Linux build — ignore them (AMD64 built fine without them).

Plan (land only green, one phase at a time)

Gates

Each green cross combo runs correct output under qemu; any compiler change keeps self-host byte-identical (make all) + make test. Commit small; push when the lane's gate is green.

Landmines

First step

Add lib/crtl/include/float.h, then immediately probe the aarch64 lua build to confirm it unblocks (or surfaces the next wall).