← board

The range-checked FPC seed cannot be built

Found 2026-08-24 while diagnosing [[bug-a-aarch64-o3-segfaults-the-compiler-on-an-empty-program]] — an out-of-bounds write into three parallel arrays. A range-checked build would have named the array and the index in one run. It could not be built.

Measured

$ fpc -O1 -g -gl -Crtoi -Tlinux -Px86_64 -opxx-dbg compiler/compiler.pas
ir_codegen_aarch64.inc(1774,40) Error: range check error while evaluating constants (3019898882 must be between -2147483648 and 2147483647)
ir_codegen_arm32.inc(312,34)    Error: ... (3925868544 ...)
ir_codegen_arm32.inc(333,34)    Error: ... (3120562176 ...)
ir_codegen_arm32.inc(334,34)    Error: ... (3388997632 ...)
ir_codegen_arm32.inc(357,33)    Error: ... (3925868544 ...)
Fatal: There were 5 errors compiling module, stopping

Five sites, all the same shape: a machine-code word written as a hex literal with the top bit set, passed to a routine taking an Integer.

EmitOvfCheckA64($B4000002);             { cbz x2, skip }
BrArm32(bSt, CodeLen, $EA000000);       { b   .st      }

Without -Cr FPC folds these silently as the intended bit patterns; with it, the fold is checked against Integer's range before the (wrapping) assignment and fails. Note it is a constant-folding error, not a runtime check — the code is correct, the spelling is what -Cr will not accept.

Why it matters more than five lines suggest

-Cr is the only tool this repo has that reports an array index out of bounds with a file, a line and the offending value. The self-hosted compiler has no such mode, and an out-of-bounds write into a parallel-array family is a failure shape this codebase has by construction: symtab.inc alone grows ~30 arrays in lockstep, defs.inc warns "a missed one is a silent out-of-bounds", and the bug that prompted this ticket was three arrays sized for the wrong backend.

The alternative used today is to reason about which table might overflow and test the guess — which worked, and took far longer than a checked run would.

Shape

Write the five constants in a form both FPC-with--Cr and pxx accept — a LongWord/Cardinal-typed constant, or an explicit cast at the call site — then add a make target (make fpc-seed-checked or similar) that builds compiler/compiler.pas with -Criot -g -gl, so the checked seed is one command away the next time something writes past an array.

Check the whole set, not just the five that error today. -Cr stops at the first five errors per module; there may be more behind them. And the constants must keep their exact bit patterns — an encoder that emits a different word is a far worse bug than the one this closes, so the gate is the encoders' own cross tests, not just "it compiles".

Gate

fpc -Criot -O1 -g -gl compiler/compiler.pas builds and runs; the resulting binary compiles the test corpus; make compiler/pascal26 fixedpoint byte-identical (the constants must not change what is emitted); tools/gate.sh quick GREEN, plus cross rows for aarch64 and arm32 since those are the two files touched.

2026-08-30 (frank-optimize) — the seed BUILDS. It does not yet RUN, and that is a different bug.

Reproduced at HEAD before planning, as asked. It still failed — and with 10 errors, not the filed 5, in the two files the ticket names. The ticket's own warning ("-Cr stops at the first five errors per module; there may be more behind them") was correct, and the line numbers had drifted (aarch64 1774 → 2409).

Fixed: the constant-folding errors

All 10 sites wrapped as Integer($XXXXXXXX). fpc -O1 -g -gl -Crit now exits 0, and a second iteration of the build found nothing behind them.

The spelling was chosen by measurement, not preference, and the first candidate I tested looked catastrophically wrong before I noticed my own test was misconfigured — recorded because the trap is cheap to fall into:

spelling fpc -Cr value
Integer($CA000000) in {$mode objfpc} accepts -905969664
Integer($CA000000) with no mode directive accepts 0
LongInt($EA000000) with no mode directive rejects

With no {$mode}, FPC defaults to fpc mode where Integer is Smallint (16-bit), so the cast silently truncates to the low 16 bits — $CA000000 → 0, $B4000002 → 2. My scratch test had no mode line, so it printed 0 2 0 0 and read exactly like "FPC miscompiles this". compiler.pas opens with {$mode objfpc}{$H+}, where Integer is 32-bit and every value is correct. A dialect test written outside the dialect answers a different question.

Verified in both compilers, in the real mode: -905969664 / -1275068414 / -369098752 / -373424128, identical, matching the intended bit patterns.

The bit patterns are unchanged — measured, not argued

The ticket's real requirement is that the encoders emit the same words. The pre-change (f2bfbb3c94a5) and post-change (d965960c9973) compilers were built and compared on aarch64 and arm32, four programs each (test_cross_record, test_cross_int64, test_cross_sets, test_cross_dynarray): 8 of 8 byte-identical, 0 differ.

Note the compiler's own sha does change (f2bfbbd965960c), and that is expected rather than a failure of the gate's "byte-identical" clause: the source text changed, so the binary built from it differs. The clause is about what the encoders emit, which is what the A/B above measures.

Added

make fpc-seed-checkedbuild/pxx-checked, with the flag choice documented at the target.

-Co is deliberately excluded. The ticket asked for -Criot, but -Co (overflow) is a different argument from -Cr (bounds): this compiler wraps on purpose in several places, and -Co rejects every one. The stated value of this ticket is "the only tool this repo has that reports an array index out of bounds" — that is -Cr. -Ci and -Ct are free and kept.

NOT fixed, because it is a different bug in other lanes' files

The checked seed builds and then traps at first use. Dropping -Co does not avoid it; -Cr alone still fires:

ERangeError: Range check error
  SYMNAMEFOLDHASH,  line 3730 of compiler/symtab.inc
  SYMHASHINSERT,    line 3750 of compiler/symtab.inc
  ADDCONST,         line 4908 of compiler/symtab.inc

SymNameFoldHash is FNV-1a: h := (h xor LongWord(b)) * LongWord($01000193) overflows 32 bits by design. -Cr range-checks the assignment back into LongWord and refuses it.

Fix shape measured and confirmed neutral — mask the product:

h := ((h xor LongWord(b)) * LongWord($01000193)) and LongWord($FFFFFFFF);

Both compilers return 3959789884 for the same input; the unmasked form traps under -Crtoi and the masked one does not. Applied locally and reverted, it gets past the hash and the next trap is the hex-literal lexer, lexer.inc:2481 (n := n*16 + digit accumulating a 64-bit pattern) — so this is a chain, not one site.

Those live in symtab.inc (frankwasm's, typeref) and lexer.inc (shared A/P). Not edited concurrently. Diagnosis banked in bug-a-the-range-checked-seed-traps-on-deliberate-wraparound-arithmetic with the verified fix, per "bank the diagnosis and park it, never microfix as a consolation".

Resolving this ticket on its own title and scope: the seed could not be built, and now it can. That the built binary then trips on deliberate wraparound is a defect nobody had seen, because nobody had got far enough to see it.

Gate

make compiler/pascal26 converged, d965960c9973. make fpc-seed-checked exits 0. Encoder A/B 8/8 identical on the two touched targets. tools/gate.sh quick GREEN.

One process note, second occurrence this session: the stash-based A/B leaves the pre-change binary on disk while the sources carry the change, and gate.sh then reports a fixedpoint mismatch that looks like a miscompile. It is not — it is the procedure. Rebuild before gating after any stash A/B.

Log