← board

unqualified call binds to a System builtin over a uses'd unit's routine

Symptom

In examples/solitaire_gui/klondike.pas NewGame:

uses random;   // unit random: RandSeed(proc) + Random(func), xoshiro
...
RandSeed(LongWord(seed));   // -> unit random.RandSeed (a CALL; builtin RandSeed is a VAR)
for i := 51 downto 1 do
  j := Random(i + 1);       // -> the compiler's BUILT-IN System Random (xorshift32) !!

RandSeed(seed) bound to unit random's procedure (seeded xoshiro), but the unqualified Random(i+1) bound to the built-in System Random (compiler/builtin/builtin.pas, xorshift32 over its own RandSeed: Cardinal state var — never seeded here). So the Fisher-Yates shuffle drew from the unseeded builtin generator: the deal (and everything downstream) was non-reproducible despite a fixed NewGame(1) seed. Qualifying random.Random fixed it (verified 20/20 deterministic).

FPC rule (and Delphi): an identifier from an explicitly used unit shadows the System/built-in one for unqualified references — so random.Random should win. pxx bound the builtin instead. Silent: no diagnostic, just wrong values.

MINIMAL repro (nailed 2026-07-15)

The trigger is NOT multi-unit interaction — it is the argument form, because the two Randoms have different parameter widths and the arg type steers the overload pick:

A unit that uses random and calls both:

unit k2; interface procedure Go(seed: Integer); implementation uses random;
procedure Go(seed: Integer); var i, x: Integer;
begin
  RandSeed(1);                 { -> unit random.RandSeed (proc; builtin RandSeed is a var) }
  i := 5;
  x := Random(1000);          { -> unit random.Random  (1000 is tyInteger, exact rank 0) }
  x := Random(i + 1);         { -> BUILT-IN Random      (i+1 typed wider -> Int64 ranks better) MISBIND }
end; end.

Random(1000) binds the unit (literal is tyInteger → exact fit to the unit's Integer param, rank 0; the builtin's Int64 param is rank 1). Random(i + 1) binds the builtin (the expression is typed wide enough that Int64 fits as well or better). So RandSeed seeded xoshiro while Random(i+1) drew from the never-seeded builtin — non-reproducible. Confirmed with per-run deal dumps.

Root cause

pxx treats the pulled builtin Random and the used unit's Random as one overload set and ranks candidates by argument fit (OverloadArgRank). FPC/ Delphi don't: a routine from an explicitly-used unit that is NOT marked overload HIDES the System/builtin one entirely — the arg type is irrelevant, the unit version always wins. The machinery for this already exists (HasNonOverloadProc, symtab.inc), but the call-site candidate gathering still includes the builtin.

Direction

At the call site, when HasNonOverloadProc(name) is true for a non-builtin routine in scope, EXCLUDE builtin-unit candidates (procs whose ProcUnitIdx is the builtin unit) from the overload set — the builtin is a fallback only, reached when nothing else declares the name. Core resolution change (touches every call): gate = make test + self-host byte-identical + conformance/fpjson/ Synapse. Test against the k2 repro above, FPC-differential.

Disposition v1 (2026-07-15) — SUPERSEDED: "intended, no fix"

Originally closed as intended: lax overload-compete is first-class pxx (builtin/ RTL override by name = how impl selection works), FPC's hide rule belongs behind --strict-fpc. That call conflated TWO questions (see v2).

Disposition v2 (2026-07-17) — FIXED as a default correctness bug

The v1 call answered only Q1 (who wins on a name collision — builtin or used-unit?), where "lax, builtin overridable by name" is a defensible dialect choice. It never addressed Q2: when both are in the set, is the winner STABLE across call sites, or does arg width flip it? Q2 is the real defect and is independent of FPC parity:

Fix (commit below): builtin unit is fallback-only. On a plain unqualified call, if any non-builtin routine of the name is in scope, all builtin-unit candidates are demoted out of the overload set — name-level, not arg-width-level. The used unit then owns the whole set; the builtin is reached only when nothing else declares the name (softfloat / platform intrinsics, which no user unit shadows, are unaffected). System.X (qUnit = -2) keeps the builtin, preserving the explicit-System escape hatch.

Why this is right and NOT a --strict-* matter: a default silent miscompile is not "fixed" by a non-default flag most code never sets. --strict-fpc still owns hard FPC parity (error on a used-unit shadow, demand the overload directive, {%FAIL} conformance) — a separate, opt-in policy layer. The v1 blocker ("RTL uses undirectived overloads, StrictOverload would fail the corpora") conflated intra-unit overloading (legit, untouched) with the cross-origin builtin-vs-unit collision this fix scopes to; no directive boilerplate needed.

Implementation: MatchProcCall gains suppressBuiltin; BuiltinUnitIdx caches InternStr('builtin'); MatchElig gates every match phase. Guard test test/test_builtin_name_demote.pas (+ test/builtin_shadow/myrand.pas). Gate: make test GREEN, self-host byte-identical (reseeded), testmgr quick GREEN; matrix/corpus (fpjson/Synapse/cross) offloaded to Track T at the pushed SHA.

Log