The bare ESP profile cannot compile any NilPy program
Measured 2026-09-20, compiler at c6c5f5f1b, stock SocNilPyArenaSize.
$ printf 'print(1)\n' > tiny.npy
$ tools/esp_run_bare.sh --chip esp32c3 tiny.npy
pascal26:1355: error: undefined variable (PXXVarBinOp)
pascal26:2029: error: undefined variable (PxxSciDigits17)
With str() and string concatenation the program dies earlier still, inside
compiler/builtin/promocore.pas:
pascal26:193: error: compiler error: PXXRecordRelease not found
Every one of these is in a unit the compiler appends itself — the diagnostic says so — so this is not the user program's doing.
Why nothing caught it
test-esp-bare's rows are Pascal fixtures. Nothing anywhere compiles a
.npy for --esp-profile=bare, so the wall produces no red and no ticket.
This is a coverage hole that produces SILENCE, which is the shape nobody finds by watching for reds. A broken row goes red and someone bisects it; a row that does not exist generates nothing at all, and the profile reads as healthy for exactly as long as nobody tries it. It was found only because a DIFFERENT ticket needed this profile as a control — i.e. by someone walking in from outside, which is the only way an absence is ever found.
Why it matters beyond itself
It blocks the proof of [[bug-a-the-nilpy-heap-arena-is-64-kib-of-dead-sram-on-the-esp-idf-profile]]. That ticket's claim is that a 64 KiB arena is dead on IDF, evidenced partly by a 16-byte arena leaving the demo passing. That evidence is only worth something if a live 16-byte arena would fail loudly — and bare is the only profile where the arena is the heap, so it is the only place that control can be run.
It also means the arena constant currently helps nobody: it costs 64 KiB on the profile that works and serves the profile that does not compile.
What would retire this
A NilPy program booting under esp_run_bare.sh on both chips, and a row in
test-esp-bare that compiles a .npy — the absence of which is the actual
defect here, since the compile wall is a thing anyone could have hit and
nobody did.
ANSWERED 2026-09-20 — one cause, and a second wall behind it
Expectation recorded before the probe (frankz-e5 and frankS independently): three errors in units the compiler appends itself smells like one missing emission rather than three defects, and fixing it buys the control AND the bare NilPy profile in one go. Half right. One cause, yes. The profile, no.
The cause is one guard doing two jobs, three times over
builtinheap.pas:18 reads {$ifdef PXX_ESP_BARE}{$define PXX_ESP}{$endif}, so
bare inherits every {$ifndef PXX_ESP} exclusion in the file. Those blocks are
not lists of things an ESP chip cannot do — the unit's own header says so in as
many words, "none of these bodies is unimplementable on an ESP chip... Do not
read the list as 'ESP cannot do this'". Each block contains a small genuine
core and a large lumped remainder:
| block | genuine bare dependency | lumped in beside it |
|---|---|---|
{$ifndef PXX_ESP} 553-598 |
PXXStrLoadFile (needs a filesystem) |
the whole record/dynarray retain-release walk, the variant runtime, PxxSciDigits17 |
{$ifndef PXX_ESP_BARE} 2944-3312 |
console read/write over PXXSysWrite |
PXXCStrToFrozen, which is a bounded memcpy with a length prefix |
All three reported errors are that: PXXVarBinOp, PxxSciDigits17 and
PXXRecordRelease are pure pointer code excluded because they share a guard
with a filesystem routine. Clearing the marker walks the build straight to the
next lumped name (PXXCStrToFrozen) and then off the end of the stack
entirely.
One genuine dependency did turn up and it is not lumping: the float bodies
need softfloat, and PullSoftFloatBeforeBuiltinHeap deliberately skips bare so
a float-free MCU program does not pay ~54-64 KB of flash. That reasoning does
not transfer to NilPy, where every number is a float — so a bare NilPy build
must pull softfloat, and a bare Pascal build must keep not doing so.
The wall behind it is size, and it is not close
With the markers neutralised and softfloat pulled, the program compiles —
all the way to CheckBareImageFitsSram. Compiler f18adc62d2ac, esp32c3:
| program | --dce |
--no-dce |
|---|---|---|
| the full nilpy-c3 demo | over by 1,967,792 B | — |
a six-line while/append program |
over by 1,958,988 B | over by 2,815,572 B |
The six-line program is within 8,804 bytes of the full demo, so ~99.6% of
the image is fixed NilPy runtime, and bare has no flash mapping — the whole
image lives in SRAM. Over by ~1.9 MB against a window of a few hundred KiB is
not a DCE problem: DCE is on by default here and already removes 856,584 B
(--no-dce minus --dce, matching dce: code 2906044B -> 2049456B to within
4 bytes of the report).
That A/B is also a retraction: a first pass compared no-flag against --dce,
got byte-identical addresses, and I began writing up "a pass reports a drop the
image does not show". The flag was already on. --no-dce is the control that
existed the whole time.
What to do with this
-
Split the guards on their own merits. A bare Pascal program gains the same ~25 routines — managed records, dynarrays, variants — and that is worth doing whether or not NilPy ever fits. File as its own item; it is not this ticket's original goal.
-
Do not rank this as the arena control's blocker any more. The control is unbuyable on bare for NilPy at any plausible runtime size. The arena question was settled another way — see [[bug-a-the-nilpy-heap-arena-is-64-kib-of-dead-sram-on-the-esp-idf-profile]], which now proves the arena dead by REACHABILITY (
HeapMmapsurvives--no-dceand is dropped by--dce) instead of by survival. -
What would retire the size row — say it out loud, because as written it reads permanent and it is not. The number is not a fact about NilPy; it is a fact about NilPy under an assumption the next reader inherits silently: a bare image executes entirely from SRAM. Bare is one RWX IRAM region with no flash mapping, so code, data and bss all compete for a few hundred KiB. Three things would each make ~1.9 MB mean something different:
- bare grows a flash-mapped text section (what IDF already does) — then only data+bss must fit, and the object's own figures say that is 88,656 + 89,352 B, which is a different conversation entirely;
- the NilPy runtime stops being a ~2 MB fixed cost — it is fixed today
because
pylib/pyevalare pulled whole; anything that makes the pull demand-driven changes the denominator, and the six-line program is the measurement to re-run, not the demo; - a larger SoC — every number here is
esp32c3; the window is a per-chip constant (SocIramBasetoESP_BARE_STACK_TOP), not a property of the profile.
Re-run the two-row table and record the chip beside it.