← board

Make uses builtin; compile on a bare ESP boot

Today it does not, and that is the documented, intended state: (not TargetIsEspClass) on 22 arms of needsBuiltin is the honest constraint, and [[bug-a-builtin-pas-calls-a-declaration-that-esp-compiles-out]] closed as working-as-intended on that basis. This ticket exists so the option survives that closure instead of being lost with it.

Why it is a feature and not a bug fix

Making it compile is not a repair with a known endpoint. It is an open-ended sequence of judgement calls about what the bare profile offers, measured four steps deep and still going:

step 0   undefined PXXVarBinOp (1148), undefined PxxSciDigits17 (1702)
step 1   guard those two      -> __pxx_d2i_rne not linked   (1235, VariantToDouble)
step 2   guard the Variant* group -> __pxx_dcmp not linked  (1586)
step 3   ...

Identical on bare riscv32, line for line — a profile property, not an ISA one. Each guard exposes the next, and each step decides whether a feature (variant arithmetic, float formatting, float comparison) belongs on a target whose whole campaign is size. That is design work, and it is why this cannot ride a bug ticket.

What is already known, so nobody re-derives it

The measure to apply

root-cause-over-microfix says count tickets closed per change. Nobody has yet named a program that wants uses builtin; on a bare boot. Rank it against that, not against how close it looks.

2026-09-19 (frankS): a named program now wants it

The ranking test above asked for one. A NilPy program on a bare ESP boot (--esp-profile=bare, either ISA) stops at step 0 here, undefined variable (PXXVarBinOp), because NilPy's runtime is built on builtin. The IDF profile does NOT need this: the same program now runs on an ESP32-C3 under IDF (examples/esp32/nilpy-c3). So this is the bare route of "a static Python application on an ESP", not the only route. That is why it is not wired as a blocker of bug-a-nilpy-on-cross-targets-four-remaining-walls. Rank it knowing that.

2026-09-24 (frank, frankb-8e) — re-measured at HEAD, and the mechanism named exactly

The chain data above is from 2026-08-30 and its LINE NUMBERS have moved (PXXVarBinOp is now builtin.pas:1355, was 1148). The behaviour has not. What follows is measured at HEAD, riscv32 and xtensa, in a scratch probe.

Still step 0, still identical on both ISAs, line for line — so the ticket's "profile property, not an ISA one" claim holds unchanged:

pascal26:1355: error: undefined variable (PXXVarBinOp)
  in: ./compiler/builtin/builtin.pas

THE PROBING METHOD ABOVE IS CORRECT, AND CLAUDE.md APPEARS TO CONTRADICT IT WITHOUT DOING SO — worth stating because a seat that reads both will stop. CLAUDE.md warns that a relocated binary finds no builtin beside itself and falls through to a CWD-relative last resort, which silently picks up a sibling checkout. Both are true about different layouts: --where at the repo root reports the builtin root as <exedir>/builtin/, so a scratch dir works only if builtin is copied next to the binary, which is exactly what this ticket says to do. Mirror the repo — pr/compiler/{pascal26,builtin} plus pr/lib — and --where shows every root resolving. Check --where rather than assuming; with lib merely symlinked, the RTL roots print [MISSING] and the failure looks like a compiler bug.

And a symlinked lib is a live hazard in that probe: edits through it land in the repo. Guard edits belong in the builtin COPY.

Why pulling the unit explicitly does not help, which nobody had recorded

The obvious first move is uses builtinheap, builtin;. Measured: identical error. PXXVarBinOp and PxxSciDigits17 are declared inside {$ifndef PXX_ESP} in builtinheap.pas (around :583 and :597), and builtinheap.pas opens with {$ifdef PXX_ESP_BARE}{$define PXX_ESP}{$endif}. So on bare the declarations are not in the unit at all — this is not a link or search-path problem and no uses, -Fu or unit ordering can reach it. The espassert.pas header already records the same two names as the reason that unit exists separately; this is that note's mechanism.

The surface the cascade would have to cover, sized

The ticket calls the cascade open-ended, and it is, but the SIZE is measurable and had not been measured:

So it is roughly two features, not two hundred call sites, which is a friendlier shape than "four steps deep and still going" suggests.

But the cascade CHANGES CLASS after step 0, and that is why a declaration census cannot size it. Steps 1 and 2 above end in __pxx_d2i_rne not linked and __pxx_dcmp not linked — LINK failures from float kernels, not undefined declarations. A static intersection of "what builtinheap excludes" against "what builtin.pas references" would answer confidently about the declaration class and be silent about the link class, which is the shape CLAUDE.md warns about: a census that cannot contain its subject. Do not build one and quote it.

A principle that would make the remaining judgement calls mechanical

The ticket's real cost is that each step is an independent decision. One rule collapses most of them, and the evidence for it is already in the tree:

On the bare profile, builtin offers no Variant support and no float FORMATTING.

Float formatting first, because that argument is the strong one and it is documented rather than aesthetic: writeln is a no-op on bare — docs/targets/esp32.md:70 says so, espassert.pas hand-rolls a UART write for exactly that reason, and a draft of that unit compiled and printed nothing. A profile with no console cannot be paying for 17-significant-digit decimal conversion; that is dead weight by construction, not a trade-off. Variant arithmetic on a flash-constrained part follows the same way.

Adopting that rule turns the cascade from "decide again at each step" into "guard the two groups and keep going until it links", which is a bounded job with a stated endpoint.

NOT DONE HERE, and deliberately. It is still a behaviour change to a unit every program gets, the endpoint is unproven until something links, and the rule above is a proposal with a name on it rather than a settled position — the sort of thing that should be agreed before a seat spends an evening on it. What is banked is the mechanism, the sizing, the corrected probing note, and the principle.

What would retire this ticket is unchanged, plus one addition: a bare NilPy program building AND running on a device, since on this profile ok: from the compiler is not a result — that is espassert.pas's own lesson and it applies to every claim made here.

2026-09-24, later (frank, frankb-8e) — THE GAP IS Str, NOT "FLOAT FORMATTING", AND THE SECTION ABOVE MIS-ATTRIBUTED ITS OWN COST FIGURE

Measured at c4d759f5c5, clean tree, compiler/pascal26 = bb681c88af9f (rounds 1, srccount 234). Population: seven single-statement programs I wrote for this, listed in the table. Oracle: none — these are size figures read off the compiler's own ok: summary line, not a differential.

What is actually refused on bare, and it is wider than this ticket said

Str — the language statement, no uses clause anywhere near it — is refused on the bare profile for both integer and float, on all four ESP targets (esp32c3, riscv32, esp32s3, xtensa), with two purpose-built diagnostics:

pascal26:1: error: Str: StrInt not loaded      (pasparser_stmt.inc:4152)
pascal26:1: error: Str: StrFloat not loaded    (pasparser_stmt.inc:4136)

So this is not a uses builtin question and not a float question. The compiler already knows the exact routine it wants and names it.

AND THE SIBLING SPELLING DIVERGES. WriteLn(d) on bare warns by name and compiles — "write/writeln emits nothing on the bare ESP profile: there is no console" — while Str(d, s) errors. Two spellings of one concept, render a number as text, opposite outcomes on the same profile.

This reads as normalise-dont-special-case.md's sibling rule — the second path being the one that stayed broken — and it is not; see the closing section. Flagged here rather than silently omitted because that is the reading a seat will arrive at, and it is the reading I arrived at and had to back out: the two statements differ in whether they produce a value, so the divergence is correct and normalising it would ship a silent wrong answer.

The principle above is refuted by its own scope, by me, the author

The section above argues float formatting should go first because the argument is "documented rather than aesthetic: writeln is a no-op on bare".

That argument is about the CONSOLE and Str does not use one. Str renders into an AnsiString the program then ships over its own UART — which is precisely what espassert.pas hand-rolls and what docs/targets/esp32.md tells a bare program to do. So a bare program has a live reason to want Str and no reason at all to want writeln. The argument I called the strong one is the weak one here, and it points the wrong way.

Cost, corrected — the ~88 KB is FLOAT FORMATTING, not Variants

esp32c3, code= bytes (see the caveat below on why not codeseg=):

program IDF bare marginal over ansistr, IDF
base 636 144 —
ansistr 33,948 3,768 baseline
wr_int 35,872 44 +1,924
wr_flt 66,756 368 +32,808
str_int 57,672 REFUSED +23,724
variant 69,600 REFUSED +35,652
str_flt 123,064 REFUSED +89,116

The earlier pricing in this ticket's other section is superseded and the reason matters. An sz_var / sz_str pair gave "Variants cost ~88 KB marginal over the string machinery" — the quantity was real and the attribution was invented, because that pair never isolated: both programs pulled unit builtin, visible as an identical procs=620. The 88 KB belongs to Str(double). Variants are +35,652. So float formatting is the most expensive of the three groups, inverting the ordering the proposal implied. Carrying both rows rather than replacing one: the old row measured two programs that differed in more than one feature; this one varies one statement at a time.

Two instrument caveats, both of which bit me here

StrFloat and FloatToStr are not program-level surface on ANY target

Separate correction, to a note of mine earlier today that said FloatToStr "doesn't exist on the ESP surface at all". It does not exist at program level on x86-64 either — undefined variable (StrFloat) at the same line, which is the control that exculpates ESP. They are internals of unit builtin and become visible only as a side effect of whatever else caused that unit to be appended: a Variant in the program makes StrFloat resolve, an AnsiString does not. So "is this name available" is not a property of the target or the profile; it is a property of what else the program happens to mention.

What this does to the two-group proposal

It supports guarding the groups independently rather than as one rule, and there is a call-graph reason: StrFloat's closure is string-and-float only — PxxSciDigits17, StrInt, FloatToStr, FloatToExpStr, AnsiString concat — with no Variant in it, and bare already carries AnsiString at 3,768 B. The substrate the float half needs is present on the profile that refuses it.

Still not implemented, still deliberately. What changed is the framing and the numbers, not the decision.

The obvious reading of the WriteLn-warns / Str-errors divergence above is "make Str warn too". That is wrong, and the asymmetry is correct. WriteLn's no-op is defensible because it has no result: there is no console, the statement's whole effect is absent, and dropping it loses nothing the program can observe. Str(x, s) yields a value the program then uses — ships over its own UART, compares, hashes. Warn-and-drop there produces an empty or garbage string at runtime: a silent wrong answer, which is the failure class this tree spends most of its effort on. So today's hard error is the right behaviour and the divergence is not a defect to normalise away.

Three options, then, and only one of them is a no-op-shaped one:

What IS bounded, and is not this cascade

The diagnostic. Str: StrInt not loaded is an internal-implementation message reaching a user who wrote plain Pascal — it names a routine no program mentions and gives no remedy, where the WriteLn warning beside it names the profile, explains why, and points at docs/targets/esp32.md. Restating the two Str errors in that register is a small, verifiable job with no design fork in it, and docs/targets/esp32.md documents the console no-op while being silent about Str. Neither needs the 2,863-line cascade this ticket is about.

THE RANKING TEST IS MET FOR ONE SLICE, AND THE SLICE IS NOW ITS OWN TICKET

"The measure to apply" above asks for a named program that wants builtin on a bare boot, and says to rank this against that. There is one, and it is in-tree: the RTL's own Assert. espassert.pas hand-rolls a UART write so that a bare assertion can be heard at all, and then the message can only contain string literals -- Assert(n > 99, 'count too low') compiles and boots, while the same assertion carrying the value that failed is refused, because the Str(n, s) that would render it needs StrInt from the unit bare does not link.

That is a bounded slice and it is filed separately as [[feature-a-non-float-str-on-the-bare-esp-profile]] (p40): the five non-float TextStrArg arms are pure AnsiString-and-integer code, espassert.pas is the precedent for the shape, and its own header already prescribes an include over a copy for exactly the moment a second caller appears.

It does not retire this ticket and it does not need it. Str needs five routines out of builtin; this ticket is about making the whole unit compile, Variants included. Read the ranking test as satisfied for the slice only -- the cascade still has no named consumer, and the Variant half still has none at all.

2026-09-24 (frank, frankb-8e) — the non-float Str slice LANDED, so three rows above are now historical

Str for every non-float type works on bare, all four ESP targets, booted on esp32c3 and esp32s3 with UART bytes equal to the x86-64 oracle. See [[feature-a-non-float-str-on-the-bare-esp-profile]] for the build, the controls and the containment numbers.

Which rows above are now historical rather than wrong. They were correct when measured and are kept with their date, because a cost table whose population is undated is unquotable either way:

What this does NOT retire, and the reason is the cascade's own shape. Bare withholds builtin wholesale, so extracting one group does not open the next — Val, Variant and the float formatters are each still unreachable, and each is a separate decision about what a size-constrained profile offers. The slice met this ticket's ranking test for its own slice only, exactly as that test was written. Val is the next obvious member (its closure is string-and-integer like the five that moved) and nobody has looked at it.