← board

Complete the builtin unit on the ESP-class targets

Re-scoped 2026-08-27 after the user's second correction: "the IDF-linked route is more interesting than the bare metal route, bare tests the compiler but misses out on a lot of functionality." That reframes this ticket, and measuring against the IDF profile makes the defect much sharper than the version below found.

Under --platform=esp (relocatable .o linked by idf.py into a FreeRTOS app — what tools/esp_run.sh builds and what actually ships):

Variant + UpCase + Str
--target=riscv32 --platform=esp (esp32c3) compiles — the full builtin unit, 341416B
--target=xtensa --xtensa-abi=windowed --platform=esp (esp32s3) UpCase: builtin helper unavailable ... not on ESP

So it is not "the ESP targets cannot have builtin" — riscv32 under IDF already has all of it, on an ESP target, today. It is that xtensa is hardcoded as bare-metal on both axes, regardless of profile:

{ util.inc — the COMPILER side }
Result := (TargetArch = TARGET_XTENSA) or
          ((TargetArch = TARGET_RISCV32) and EspBareBoot);

{ builtinheap.pas — the UNIT side }
{$ifdef CPU_XTENSA}{$define PXX_ESP}{$endif}
{$ifdef CPU_RISCV32}{$ifdef PXX_ESP_BARE}{$define PXX_ESP}{$endif}{$endif}

Both read "xtensa means bare metal". That was true before the IDF profile existed and is now wrong, and it is wrong in the worst place: xtensa is the S2/S3, the user's own hardware, and it is locked out of precisely the profile where FreeRTOS tasks, IDF drivers, VFS file I/O and sockets live.

This also retires a judgement call from the version below. It said {$ifndef PXX_ESP} around file I/O was "a genuine platform statement". It is not — under IDF, basic VFS file I/O works (CLAUDE.md's own ESP note: "sockets and basic VFS file I/O are what work"). The exclusions are keyed on the ISA where they should be keyed on the profile, and that single mis-keying is the bug. --esp-profile=bare is the axis that legitimately withholds an OS; TARGET_XTENSA is not.

The repo already knew. test-esp-idf's own comment, on the timer demo: "passing a 64-bit argument to a C function was broken on BOTH backends for a month with no symptom other than a callback that never fired. Nothing in the bare-metal suite calls into C, so nothing there could have caught it." That target builds and runs on esp32c3 AND esp32s3 under the Espressif qemu — and it is skipping too, for want of IDF ([[task-s-install-esp-idf-to-turn-on-the-skipped-esp-execution-rows]]).

Revised plan — do this first, it is smaller than what follows:

  1. make TargetIsEspClass and the units' PXX_ESP profile-aware, so xtensa-under-IDF behaves like riscv32-under-IDF (which is proven working);
  2. [[bug-a-xtensa-cannot-lower-an-int64-to-float-conversion]] — measured to be the next wall behind the guards, and already filed;
  3. [[bug-a-xtensa-codegen-has-no-variant-support]] — already filed;
  4. only then consider what, if anything, genuinely belongs behind PXX_ESP_BARE for the bare profile.

Everything below stands as the bare-profile measurement and is still the reference for the three guard layers; read it after the above.

Filed after the user pushed back on [[feature-a-hosted-xtensa-so-qemu-xtensa-can-be-an-oracle]]: "builtins not being complete for ESP is a minor issue, isn't it." Measured rather than argued, and the answer is yes — this is the cheap path to the same place.

The wall, and why it looked structural

Every blocked xtensa ticket's gate names a test that needs the builtin unit, and on an ESP-class target you get:

error: UpCase: builtin helper unavailable (needs the builtin unit; not on ESP)
error: Str: builtin unit not loaded

Read quickly, that says "the ESP targets cannot run this code". It does not. There are three independent layers, each added at a different time, each with a comment asserting the same thing, and none of them a hardware or ISA fact:

layer where shape
1. the units exclude their own bodies compiler/builtin/builtinheap.pas, builtin.pas 14 {$ifndef PXX_ESP} blocks — "Not yet on ESP: file I/O, ... variant, float formatting"
2. the unit is never PULLED pasparser_prog.inc 21 (not TargetIsEspClass) guards on the needsBuiltin token triggers — "ESP can't compile builtin"
3. the forwards are never REGISTERED pasparser_prog.inc:882 "File load, variant and float-format helpers are not on ESP yet" — and note it is the arch-only spelling (TargetArch <> TARGET_XTENSA) and (TargetArch <> TARGET_RISCV32), the one util.inc's TargetIsEspClass header warns must not be confused with the profile-aware test

Layer 2 is why the diagnostic is misleading: FindProc('__pxxUpCase') fails not because the helper cannot exist but because nothing asked for the unit.

Measured — what is actually left when the layers come off

Probe (scratch copies under PXX_HOME, plus a throwaway compiler build widening the ESP softfloat pull; both reverted, fixedpoint back to 07fbc8c97b3c):

So the residue is small and already known:

  1. softfloat must be pulled alongside builtin on ESP-class. The on-demand scan added by [[bug-a-esp32c3-bare-profile-cannot-find-the-softfloat-repack-helper]] reads the user program's tokens, and builtin's own variant/float bodies bring float code the program never mentions. That is unreachable today — verified: Str(i, s) on bare answers Str: builtin unit not loaded, so builtin cannot be pulled on ESP at all and the hole cannot open — but it opens the moment layer 2 comes off. Widen the ESP arm to needsSoftFloat or needsBuiltin as part of this work, not before.
  2. [[bug-a-xtensa-cannot-lower-an-int64-to-float-conversion]] (filed).
  3. [[bug-a-xtensa-codegen-has-no-variant-support]] (filed) — for the variant rows specifically.

Why this rather than a hosted xtensa profile

[[feature-a-hosted-xtensa-so-qemu-xtensa-can-be-an-oracle]] was filed to make the blocked gates reachable, and its argument was that only a hosted profile gets the RTL. That is true of the hosted RTL and irrelevant if builtin works on ESP — the tests need builtin, not a POSIX platform. This route:

The hosted profile keeps a separate, weaker justification (running the whole test_cross_* corpus on xtensa the way riscv32 does) and should be re-ranked accordingly, not treated as the unblock.

Do it in layers, and keep the size discipline

The ESP exclusions are not all wrong: an ESP image pays for what it links, and {$ifndef PXX_ESP} around file I/O is a genuine platform statement, while the same directive around variant and float formatting is not. Take them one group at a time — variant, then string/char helpers (UpCase/Pos/Move), then float formatting — measuring image size at each step, and leave the ones that are real platform facts alone. test_esp_bare.pas staying its current size is the canary: the pull is on demand, so a program that uses none of this must pay none of it.

Gate

test_div_by_zero_raises_on_every_target.pas and test_cross_variant_payload_widths.pas building for --target=xtensa --esp-profile=bare and --target=esp32c3 --esp-profile=bare, and — with IDF installed — running under the Espressif fork with UART output matching the x86-64 oracle. Plus a float-free bare image unchanged in size, plus Track A's usual gate.


2026-08-27 — done, together with the entry-jump fix it exposed

The fix

  1. util.inc TargetIsEspClassEspBareBoot and (XTENSA or RISCV32), replacing XTENSA or (RISCV32 and EspBareBoot). Profile, not ISA. EspBareBoot is already validated to those two ISAs in compiler.pas, so the ISA test is redundant as well as wrong.
  2. builtin/builtinheap.pas{$ifdef PXX_ESP_BARE}{$define PXX_ESP} replacing the unconditional {$ifdef CPU_XTENSA} arm. Same defect, unit side. The capability list above the {$ifndef PXX_ESP} block ("Not yet on ESP: file I/O, ... variant, float formatting") was reworded: it is a statement about the BARE profile, not about what an ESP chip can do.
  3. pasparser_prog.inc + cparser.inc — xtensa joins riscv32 in the wholesale softfloat pull for no-FPU targets that have an RTL (the demand comes from the UNIT, not the user's tokens). Bare stays on the on-demand token scan: no RTL, and softfloat is ~54-64 KB of flash a float-free MCU program must not pay.

Measured

program before after
hello-s3 / hello-s2 47375B / 90 procs 188115B / 174 procs
timer-s3 49731B / 101 procs 264003B / 291 procs
hello-c3 / timer-c3 / net-c3 byte-identical, no regression

UpCase / Str / Pos / Int64->float all lower on S3 now. Every bare image stays byte-identical to the pre-change compiler.

It could not land alone

Making xtensa images large for the first time immediately hit [[bug-a-xtensa-entry-jump-cannot-reach-a-main-body-past-128kb]] — the entry stub's j reaches only ±128 KiB and a 264 KB image is 2x that. Before this change the esp32s3 IDF row passed because xtensa pulled almost no RTL: a green row bought with the very crippling this ticket exists to remove, which is worth recording as a case where green meant "we compile almost nothing".

Both fixes landed together. Verified on the real Espressif emulator:

esp32s3 IDF esp_timer callback: PXX timer: started .. done ticks=5 status=0
esp32c3 IDF esp_timer callback: 7/7 lines
bare-float esp32c3 / esp32s3  : == x86-64 oracle

tools/gate.sh quick GREEN, self-host fixedpoint verified.

Remaining gap

[[bug-a-xtensa-codegen-has-no-variant-support]] — var v: Variant; v := 1; still fails unsupported node in IR codegen: var_store on xtensa. riscv32 got its variant support the same day and is the exact template.

Not the blocker

Two xtensa float gaps found while measuring this were fixed and landed separately, since they are real on bare too: [[bug-a-xtensa-cannot-lower-an-int64-to-float-conversion]] (refused outright) and its unsigned-32 sibling (did not refuse — answered wrong, (double)$FFFFFFFF = -1). Both now execute on the real emulator against the x86-64 oracle.

Log