← board

Non-float Str on the bare ESP profile

Measured 2026-09-24 at c4d759f5c5, compiler/pascal26 = bb681c88af9f.

The pair that states the whole ticket

Bare esp32c3, same assertion, one difference:

Assert(n > 99, 'count too low');            { ok: 22,512 B code, boots, prints }
Assert(n > 99, 'count too low: ' + s);      { needs Str(n, s) -> REFUSED }
pascal26:5: error: Str: StrInt not loaded
  near: Str ( n , s ) >>> ; Assert (

espassert.pas exists only so that a bare assertion can be heard — its header records that the first draft reused builtin's body, compiled on both chips and printed nothing, so it hand-rolls a write to the UART0 TX FIFO at $60000000. That work was done so assertions could speak. They can speak only in string literals.

This is the program the parent ticket asked for. Its ranking test is "Nobody has yet named a program that wants uses builtin; on a bare boot. Rank it against that" — and the program is the RTL's own assertion path.

The ruling this is ranked against, and why 30 rather than 40

The owner ruled 2026-09-23 that IDF is the assumed profile and bare is a test vehicle: "the bare-bones compilation merely serves as test and has so many drawbacks that it serves niche cases." Bare work is therefore not to be ranked as if bare were the shipping target, and this ticket was filed at p40 on the consumer argument alone, which would have put it above everything left on Track S. That was wrong and the number is now 30.

What survives the ruling is not a feature argument, it is an instrument one. Nothing here asks bare to do more; it asks the profile's only debugging instrument to be able to say what failed. espassert.pas was built, and a UART write hand-rolled inside it, precisely so a bare assertion could be heard — the gap is that it can be heard saying only constants. A test vehicle whose assertions cannot report a value taxes every future investigation of the test vehicle, which is a cost borne by whoever debugs bare, not by anyone shipping on it.

30 places it below [[feature-a-one-guard-excludes-both-the-unimplementable-and-the-merely-adjacent]] (p35 — broader, same profile, already partly landed) and above the p20 cascade it slices from. If you are re-ranking this, argue against the instrument framing, not against the consumer count — the consumer count is what moved it off p20 and it is not a claim that bare has been promoted.

Refused for every type, not just floats

All four ESP targets, --esp-profile=bare, identical:

statement bare why
Str(n, s), n: Integer Str: StrInt not loaded pasparser_stmt.inc:4152
Str(d, s), d: Double Str: StrFloat not loaded pasparser_stmt.inc:4136
WriteLn(d) warns and compiles no console; the statement has no result

The WriteLn row is there as the control, and the asymmetry with it is correct — see the parent ticket. WriteLn has no result, so dropping it loses nothing observable; Str yields a value the program ships or compares, so a warn-and-drop would be a silent wrong answer. Do not "normalise" these.

Scope: the five non-float arms, and why the boundary is there

TextStrArg (pasparser_stmt.inc:4106) resolves one formatter per type via FindProc. Five of its arms are bare-safe and one is not:

arm routine builtin.pas closure
string StrStrW :1810 AnsiString concat, Length
Char StrChar :1798 as above
Boolean StrBool :1820 calls StrStrW
signed ordinal StrInt :1826 div/mod, Chr, Ord
unsigned ordinal StrQWord :1589 as above
float StrFloat :1978 PxxSciDigits17 -> softfloat

The five carry no float, no Variant, no syscall, no filesystem, and bare already links AnsiString (code=3,768 B for a concat program). The float arm is excluded for a measured reason and not for tidiness: PullSoftFloatBeforeBuiltinHeap (frontend_prologue.inc) skips bare deliberately so a float-free MCU program does not pay ~54-64 KB of flash. Marginal code= on esp32c3 over an AnsiString baseline: integer +23,724 B, float +89,116 B.

So the principle this establishes is narrower and better-founded than the parent ticket's draft: bare gets Str for everything except floats.

Shape — prescribed by espassert.pas, including the part about copying

espassert.pas is the precedent for giving bare one thing out of builtin without the rest: an on-demand unit, pulled by a token scan, target-gated, never ambient. The wiring is three lines plus a pull:

Do NOT copy the five bodies. espassert.pas's header already ruled on this case, for itself, in advance:

The shared-code alternative is an include both units pull, and that is the right shape ONLY once something else needs it — one caller is not a second path.

A second caller is exactly what this ticket adds. So the five bodies move into an include that builtin.pas and the new bare unit both pull. Copying them would mint five twin spellings, which is the defect class this tree pays for repeatedly (LINE_BUF_SIZE/PXXLineBuf, EspArena's two spellings).

The spans are not contiguous — StrQWord is at :1589 and the other four are at :1798–:1850, with a CPURISCV32/CPUXTENSA guard between them whose own comment warns that a declaration whose body compiles out is a known bug class. Two spans, and the extraction must not move a body across that guard.

The containment control, which is not optional

builtin.pas is linked into every program on every target, and it is part of the pin. The extraction must be a no-op everywhere else, and that is cheap to assert the way frankS asserted it for the builtinheap half of [[feature-a-one-guard-excludes-both-the-unimplementable-and-the-merely-adjacent]]: compile a program exercising Str of a string, Char, Boolean, signed and unsigned ordinal on x86-64 before and after, and cmp the binaries. Expect byte-identical.

That control is not vacuous here: the same change must move a bare fixture from compiler error to ok, so it demonstrably reaches output.

Verification — the harness exists and both qemus are installed

test-esp-bare's Assert rows (Makefile:36612) are the template, and the three-line oracle shape there is deliberate: the compile stands alone so make aborts on it, the run tolerates only its own expected rc, and test -s proves the oracle has bytes before either chip boots — "a comparison whose inputs were never proven to exist cannot fail."

The fixture must assert the TEXT, not compilation. espassert.pas's own lesson is the one that applies: a draft compiled on both chips and printed nothing, and "only the serial bytes can tell a working Assert from a silent one." A row that only builds would pass over a Str that returns ''.

Positive control to write beside it: a formatted value whose correct output differs from the empty string and from the failure value. Str(0, s) is a poor choice — '0' is one character and a blank result is zero characters, but a padded-width row can collide. Use a negative number and Low(Int64), which StrInt's body calls out as the case that once produced just "-".

Relationship to the two adjacent tickets

RESOLVED 2026-09-24 (frank, frankb-8e) — built, booted on both chips, UART bytes equal to the x86-64 oracle

Str now works on the bare ESP profile for every non-float type, on all four ESP targets. The float arm stays refused, deliberately, with its own diagnostic.

Compiler af40370a8a91d298, make compiler/pascal26 -> converged after 1 round(s); tools/gate.sh quick -> gate: GREEN (exit 0), self-host fixedpoint PASS.

What was built, and where it DIVERGES from what this ticket prescribed

The ticket said: extract the five bodies into an include that builtin.pas and a new bare-only unit both pull. I built a shared UNIT instead, and the reason is not preference:

So compiler/builtin/strfmt.pas holds the five; builtin.pas uses strfmt for its own StrFloat, which calls StrInt. The ticket's anti-copying rule was the right instinct and is honoured — there is exactly one body of each.

The wiring

Verification — output equality, not compilation

The ticket said the fixture must assert the TEXT, because espassert's first draft compiled on both chips and printed nothing. test/test_esp_bare_str.pas is a runtime row in test-esp-bare, booted under Espressif qemu on esp32c3 and esp32s3, UART bytes diffed against the x86-64 oracle. Both chips: byte-for-byte equal, 107 bytes.

int=-4095
low=-9223372036854775808
qw=18446744073709551615
chr=z
bool=[   FALSE]
wid=[    42]
strw=[   hi]

Values chosen so a correct answer differs from the failure value '' and from every other row: Low(Int64) is the case StrInt's body records as having once produced just "-", and a QWord >= 2^63 through the signed formatter prints a minus sign, which is the only thing StrQWord exists for.

POSITIVE CONTROL, run against a real pre-fix compiler rather than asserted: the pinned binary 4d148c23cc723afe — which is this tree's own compiler from before the split — refuses the exact test file on both chips with pascal26:51: error: Str: StrInt not loaded, while HEAD accepts it. The asymmetry is the control.

BOUNDARY CONTROL: Str(d, s) for d: Double on bare still answers Str: StrFloat not loaded, and still compiles on x86-64. The float arm was never in scope and is not silently enabled.

Containment — measured three ways, and one of them is byte-exact

program pre-split (pinned) post-split (HEAD)
never calls Str code=19093B data=4384B byte-identical binary
one Str arm code=21546B data=6896B code=21546B data=6936B
eight Str arms code=24368B data=6936B code=24368B data=6976B

code= is identical in every row; a program that never calls Str is byte-identical. The cost is +40 bytes of data, FIXED — the same for one arm as for eight, so it is one additional unit descriptor and not duplicated literals. That distinction is the reason the one-arm row is in the table: a per-arm cost and a per-unit cost are indistinguishable from the eight-arm row alone.

gate.sh quick's pinned builds live lib/rtl passes, and the pinned compiler resolves its OWN snapshot at stable_linux_amd64/default/builtin/ rather than the live tree — verified with --where. So Track B/E are not exposed: $(PXX_STABLE) consumers keep working against the split tree. The corollary is the usual one — this fix is INERT for $(PXX_STABLE) consumers until the next pin, and a bare program wanting Str must be built with compiler/pascal26 until then.

One measured correction to this ticket's own premise

The ticket scoped the five arms as bare-safe by reading their closures. The pull site's comment says builtin uses "frozen-string concat, which the ESP backends cannot lower" — which, read straight, says the five could not work either. It is not true of these five, and that was measured before the split, not after: ' ' + r, '-' + digits, Chr(...) + digits, the indexed store r[1] := c and the QWord digit loop all compile and boot on both chips. That comment is now scoped in place, with what is still unmeasured named explicitly — nobody has established that uses builtin as a whole compiles on bare, and Variant and Val were not part of this.

Docs

docs/targets/esp32.md's bullet said Str was unavailable for any type. I wrote that bullet earlier the same day and it is now false — rewritten, with both snippets compiled on bare esp32c3 and the documented float refusal reproduced verbatim, per Track D's rule that snippets are verified by compiling them.

Umbrella

[[meta-a-pxx-produces-linkable-code]]

Log

2026-09-24 — PIN v420 CARRIES THIS, so the containment control above names the PREVIOUS pin

75beaed74a pinned v420 at binary af40370a8a91d298, which is the binary this work built, and strfmt.pas is in its snapshot. Two consequences, both worth stating because both are ways a later reader gets this wrong:

The measurements themselves are unaffected: they were taken with both binaries named by sha, and code= equality plus the byte-identical no-Str program stand as recorded.

2026-09-24 — the containment control WAS non-vacuous when run, and is vacuous NOW; the pin snapshot is a second instance of a hazard already in CLAUDE.md

franks-5b measured that compiler/builtin/ and stable_linux_amd64/default/builtin/ are byte-identical — 14 of 14 files, strfmt.pas included, and drew the right conclusion: identical is the dangerous state, not the safe one. Reproduced here.

Why the control was sound when taken. The claim it supported was that the pinned compiler reads its own snapshot rather than the live tree, so the split could not break $(PXX_STABLE) consumers. At that moment the snapshot was v419's — pre-split, no strfmt.pas — while the live builtin.pas had already had the five formatters removed. The pinned compiler then compiled an eight-arm Str program successfully, with pre-split numbers (code=24368B data=6936B). Had it been reading the live tree it would have failed outright, because live builtin.pas no longer declares StrInt. That is a differential that could have come out the other way, which is the only reason the conclusion was worth anything.

Why re-running it now proves nothing. v420 was cut from this tree, so the two copies agree, and the same command compares the split tree with itself. Recorded at 82ec739118 for the control BINARY; this is the same mechanism reaching the control SOURCE TREE, and it arrives automatically at every pin rather than through anybody's mistake.

And it is the same structure CLAUDE.md already documents one layer down, in the bootstrap-CWD rule: "a byte-comparison does not catch the silent arm while the two trees' builtins happen to agree ... measured, a build from a sibling root came out BYTE-IDENTICAL." That is about ~20 sibling checkouts; this is about the pin snapshot, in-tree, and guaranteed identical for as long as nobody touches compiler/builtin/ after a pin. Two copies of builtin/ that agree until they do not, in both cases, with the agreement doing the concealing.

The practical residue, and it is one question rather than a procedure: when a control's two sides can become the same object, say what made them different at the time you ran it. For this one it was strfmt.pas's absence from the pinned snapshot, and that sentence stays true after the copies converge.