← board

The convention, which was the part nobody had written down

ir.inc's low-bound ladder resolves an array's lo from the BASE node, and it has arms for a symbol, an alias and a field — but none for AN_DEREF. So for p^[i] the subtraction cannot happen in the IR at all, and the parser has to hand it a subscript that is already zero-based.

That makes the normalisation a precondition of the node shape, not an optimisation, and every site that mints AN_INDEX over a deref owes it. It was enforced by one site doing it and nobody stating why.

The minting sites, measured at f8b9e4394673 — THREE THEN, FOUR NOW

site had the fold?
pasparser_lval.inc chain loop (~1848) yes — the only one
pasparser_expr.inc pointer-alias cast loop (~7190) no — the READ face
pasparser_lval.inc ParseClassRecordSelectors (~4847) no — the STORE face

Independently corroborated by test/ast_slot_writes.expected: the census keys on the local variable NAME that writes each slot, and collapsing the three sites removed exactly ONE name (dpaLoNode) and added one (fdLoNode). Had either of the other two ever done the arithmetic, its own name would have disappeared too.

How the second face was found

The READ was fixed first, from a differential against fpc 3.2.2, and the test passed. The STORE was still wrong — and would have stayed wrong, because a test that only reads through the same broken-then-fixed path cannot see it. It surfaced only after a WRITE row (wrote) was added to the test: PLo(@lo)^[2] := 55 then printed lo[4] = 55 while lo[2] was untouched.

A fix verified through one face of a two-faced construct is verified on one face. The read and the write are parsed by different walkers here, which is the whole reason this ticket exists.

Why the N-D shapes are excluded

BuildFlatNDIndex already subtracts every dimension's low bound. Folding again would index (i - 2*lo0), correct only for lo=0 — the case that hides it. All three call sites pass their N-D state to the helper and it declines, which is what the previously-inline copy did too.

Gate

make compiler/pascal26 (converged), tools/gate.sh quick, and test/test_inline_ptr_cast_low_bound.pas — positive-controlled against the pinned compiler, which differs on four rows (viacast 0 vs 99, lastelem 10 vs 111, neg 0 vs 8, wrote 88 vs 55).


2026-09-05 (frankA) — a FOURTH minting site, and my own count was the thing that hid it

ApplyCallResultPtrSuffix (pasparser_lval.inc) mints an AN_INDEX over a deref and did not subtract. Measured against fpc 3.2.2 for array[1..5] of Integer:

pxx before fpc
GetP^[3] 44 33
GetP()^[3] 44 33
GetP^[2] := 77 wrote lo[3] wrote lo[2]

Silent, both faces, present in pin v403 and in the compiler from before yesterday's fix — so pre-existing, not a regression from the three-site fold.

How it was found matters more than the bug. Not by anyone hitting it: by the escape census that [[refactor-p-three-hand-rolled-postfix-loops]] asks for. Tabulating which shared routines each of the five Pascal postfix loops reaches shows one loop (pasparser_expr.inc's pointer-alias cast) reaching six and the other four reaching one or two. FoldDerefArrayLowBound was in exactly one of them. The table predicted the defect and a probe confirmed it in a minute.

And yesterday's census was mine. I wrote "THREE minting sites owe it" into this ticket, its summary, and a source comment, having enumerated by DerefPtrArrayInfo callers rather than by "who allocates an AN_INDEX whose left is a deref". A count in a ticket is read as a census and stops the recount — the exact failure this repo already has a rule for, committed by the person who had just written the rule down for a different ticket the same night. The summary above is corrected rather than annotated, because the summary is the part everyone reads.

Regression rows added to test/test_inline_ptr_cast_low_bound.pas: callres, callres2 and callwrote, both spellings because GetP^ and GetP()^ enter by different routes. .expected regenerated from fpc 3.2.2. Positive control: the pinned compiler answers callres=0.