← board

Repro

program NarrowShapes;
function ByName(a: Int64): Integer;   begin ByName := a;          end;
function ByResult(a: Int64): Integer; begin Result := a;          end;
function ViaLocal(a: Int64): Integer; var t: Integer;
                                      begin t := a; ViaLocal := t; end;
function ByCast(a: Int64): Integer;   begin ByCast := Integer(a);  end;
function SmallRes(a: Int64): SmallInt; begin SmallRes := a;        end;
procedure ToVarParam(a: Int64; var o: Integer); begin o := a;      end;
var v: Integer;
begin
  writeln(ByName(4294967299));
  writeln(ByResult(4294967299));
  writeln(ViaLocal(4294967299));
  writeln(ByCast(4294967299));
  writeln(SmallRes(4294967299));
  ToVarParam(4294967299, v); writeln(v);
end.
shape fpc 3.2.2 pxx x86-64
ByName := a (result by name) 3 4294967299
Result := a 3 4294967299
via an Integer local 3 3
Integer(a) cast 3 3
SmallRes := a (SmallInt result) 3 4294967299
o := a (var parameter) 3 3

Both compilers on the same file; FPC run with -Mobjfpc. Three of six wrong, and the three are exactly the assignments whose target is the function result.

Why this is a bug and not dialect laxness

CLAUDE.md's compat table: "real Pascal source compiles but runs wrong" → bug, via the silent-wrong-behaviour escape. The caller of ByName has been handed a value its declared Integer type cannot represent; every subsequent use of it is operating on something the type system says is impossible. Nothing warns.

Shape

This is the double case devdocs/dev/normalise-dont-special-case.md describes: one concept — "store a value into a typed destination" — reachable through two paths, and the second path is the one that stays broken. The four working shapes narrow; the result shape does not.

A hypothesis worth one measurement before designing the fix, NOT a claim: the four working shapes all end in a store to a slot allocated at the destination's own TypeSize, and on x86-64 a 4-byte store is the truncation — nothing has to insert it. If the result slot is allocated pointer-width and the return reads it whole, the narrowing that the other arms get for free never happens. That would make it a slot-width question rather than a missing conversion node, and would also predict SmallRes failing, which it does. Confirm before fixing; PXXDBG=n.locals prints what was inferred.

Found

By the wasm32 backend (branch wasm), 2026-08-28. It is the second finding of its kind from that lane and for the same structural reason: wasm distinguishes a value's type from its memory width at the instruction level, so the backend must emit an explicit i32.wrap_i64 where x86-64 picks a sub-register, and getting that right made it disagree with the native build. The wasm side and FPC agree; the native build is the odd one out.

Blast radius beyond the lane: the wasm Phase 2 differential (test/wasm/phase2_slice.pas) has to keep its Narrow case inside Integer range to avoid going red for this, so a real conversion path is currently covered only for values where the bug is invisible. That constraint lifts when this closes.

This is a class, not an instance — look for the other members

Two bugs of this exact shape came out of one sitting on the wasm lane, and they are almost certainly not the only two:

The common cause is not a shared code path; it is a shared permission. On x86-64 a value's declared width does not have to be enforced, because a 64-bit register holds any narrower value correctly for most operations and the enforcement only shows up where something reads the register whole. So every place that could have narrowed and didn't is latent, silent, and passes every test whose values happen to fit.

That is what makes a sweep worth more than these two tickets. The productive question is not "where else is this bug" but "where does the compiler rely on a store to a correctly-sized slot to perform a narrowing it never emitted?" — because those are the sites where removing or bypassing the store (a function result, an operator evaluated in a register, an argument passed in a register) turns a correct program into a quietly wrong one.

A differential probe finds them cheaply: tools/fpc_diff_probe.sh over expressions that mix widths, with operands chosen ABOVE the destination's range, since in-range values make every instance of this class invisible. Both tickets above were found precisely because a target that cannot leave a width implicit was forced to state one.

Track A's to run, and deliberately not filed as a third ticket — the point is that these two are examples of a search, not a list.


2026-08-31 — FIXED (191af3440), and it was the INLINER, not the assignment (frankC)

The repro is right and every number in it is right. The cause is not: every arm of the assignment narrows correctly. The -O2 inliner discards the store the narrowing lives in.

Four measurements, each of which moved the suspect:

result
-O0 / -O1 correct — so it is an optimisation, not the lowering
i386 / arm32 / riscv32 correct — 64-bit codegen only
the OUT-OF-LINE body, disassembled correct: mov %eax,-0xc(%rbp) then movslq
direct call vs the same call through a procvar direct wrong, indirect right

The last one names it: a procvar call cannot be inlined. Register pressure in the caller changes nothing, which rules out the residency pass — the first place I looked, and the assembly of the standalone body is what sent me there wrongly.

inline_expand.inc, shape 1. For a body that is exactly Result := E it retains E and drops the assignment (i := CloneToInlineRegion(rhs, procIdx)), so IRInlineExpand yields the bare expression with no store. Shape 3 — any body with a second statement — allocates resSym := AllocVar('', Procs[cpi].RetType) and stores through it, which is exactly why ViaLocal looked like a working "other arm": it is not a different arm of the assignment, it is a different inline shape. Shape 2a (the ternary) was never affected.

Fixed by handing the narrowing cases to shape 3 rather than teaching shape 1 to narrow: the store is the mechanism that is already right, and a second one is the path that stays broken.

The blast radius is much larger than this ticket's repro

function AddOne(a: Integer): Integer; begin AddOne := a + 1; end returned 2147483648 for MaxInt at -O2. FPC and pxx's own -O0 both say -2147483648. Integer arithmetic promotes to 64 bits, so every Integer-returning function whose body is a single arithmetic expression was returning an unwrapped value — one of the commonest shapes in Pascal, and one this ticket's Int64 parameter made look exotic.

Cost

Zero, measured: identical code size for both example programs and for compiler.pas's own self-host. Widening and same-width bodies still take shape 1, asserted by rows 7-9 of test/test_inline_result_narrows.pas so that a future guard cannot pass by quietly disabling the optimisation.

Verified on x86-64, i386, arm32, aarch64 and riscv32 at -O0..-O3, matching fpc 3.2.2 on all nine rows. Positive control: the pre-fix compiler gets the first six wrong at -O2 and the last three right.

Log