← board

--emit-obj retains __pxxAssert, so one AnsiString in it imports the whole ESP PAL

What breaks

test-emit-obj#src:test/test_emit_obj.pas@3 — the xtensa link step. The recipe links the emitted object against newlib and a three-symbol C shim (ext_notify, ext_aliased_link, main), deliberately with no ESP-IDF: its own comment says these checks "need NO qemu and NO ESP-IDF". The object now imports symbols only ESP-IDF provides, so ld fails with 25 undefined references.

Measured

Reproduces at master (cd2d0f3ca, compiler/pascal26 = 0a20bae79296, converged after 2 round(s)):

object UND symbols PalBackend syms
riscv32 2 — exactly ext_notify + ext_aliased_link 114
xtensa 35 — adds 18 lwip_*, vTaskDelay, esp_timer_get_time, rmdir 114

riscv32 is not clean because it imports less — it carries the same 114 PAL symbols. It selects the POSIX backend, which defines those calls in-object (syscalls), where the ESP backend imports them from lwIP/FreeRTOS. The failure therefore looks target-specific and is backend-specific.

Bisected

Archive spans 2026-07-07 → 09-05, 1906 reports. This job is red in 7, all on 09-05; the recipe is unchanged across the window (0 diff lines naming test_emit_obj), so the @3 key is stable and its earlier absence means "not red" rather than "different key" — worth checking, because [[bug-t-the-job-map-cannot-be-asked-whether-a-given-source-was-exercised]] fakes exactly this shape.

Endpoints measured, not inferred (git bisect never tests its own):

b8e3b3010   riscv32_link=ok  xtensa_link=ok    xt_lwip_und=0
5b5fdb0b3   riscv32_link=ok  xtensa_link=fail  xt_lwip_und=18

The xtensa link RAN and PASSED at the good endpoint, which rules out the tempting reading that this is coverage arriving because make used to abort at the riscv32 link — the recipe's own comment says it once did, and that is not what happened here.

Bisect over 158 commits, 7 probes, no skips, monotonic:

674bc0a1e 18 · 502f273d1 18 · d8afd1979 18 · f0a1a8be9 18  <- first bad
49194d2ab  0 · 7701b8d40  0 · a5b77e3b4  0

Confirmed at the commit's own boundary: f0a1a8be9~1 link ok / lwip=0, f0a1a8be9 link fail / lwip=18.

Narrowed inside the commit

Run at f0a1a8be9 so no later change confounds it. Reverting builtin.pas alone does not build — the parser injects a third argument — so the splits go the other way:

control lwip_und
baseline 18
Apasparser_stmt.inc reverted (position composition at Assert sites) 18
B__pxxAssert BODY reverted, 3-arg signature kept 0

So it is the body, not the position machinery: the new var text: AnsiString plus text := text + pos. An earlier control that swapped only the writeln(text, '.') line came back 18 and looked exonerating — it left the concatenation in place and could not have isolated anything. A control that leaves half the suspect standing cannot clear it.

Why this is nobody's gate failure, and why f0a1a8be9 should not be reverted

That commit is right: it made six assertion rows byte-identical to fpc 3.2.2, having measured FPC rather than trusting the ticket's sketch, and it gated properly — converged, quick GREEN, changed test-core rows run standalone, plus NilPy and C probes against the signature change.

Nothing available to it could have shown this. The self-host fixedpoint never targets ESP; gate.sh quick never links xtensa; on x86-64 every one of those symbols resolves from libc without comment. This is only observable where there is no OS to resolve them, which is the same structural blind spot as bug-a-set-membership-32-bit-backends-truncate-the-set-constant and the method-pointer width class: the dev loop, quick and the pin all run on the one host that cannot see it.

Reverting would trade FPC parity for a link. Reshaping __pxxAssert to dodge the string path is a compiler-appeasement workaround and is out.

The actual defect is the pairing

Two facts, each harmless alone:

  1. Nothing is pruned on xtensa at all, so REACHABILITY is the only filter. test_emit_obj carries 114 PalBackend symbols; test_esp_hello carries 0; neither program contains an Assert.

    CORRECTION, 2026-09-06 — my first wording here said object output "keeps __pxxAssert where a normal build drops it", and that is wrong. frankA measured the gate: dce.inc's first test is if TargetArch <> TARGET_X86_64 then why := 'target is not x86-64', because the pass only knows how to re-patch x86-64's rel32 call/jmp. Asked directly on this exact shape:

    --target=xtensa --emit-obj --dce-report  ->  dce: off: target is not x86-64
    --emit-obj --dce-report                  ->  dce: bodies 130 live 44 dead 85
    

    and it is not an --emit-obj property — plain executables behave the same (i386 106348B, riscv32 261996B, byte-identical with and without --dce). So the retention is not a mechanism holding these symbols; it is the absence of any pruning on every target but one. test_esp_hello carries no PAL because it never REACHES it, not because anything dropped it. My code=336324B on both sides is this, exactly.

  2. __pxxAssert acquired a PAL-reaching dependency. Its string path reaches the PAL's file I/O, and on ESP that is the same translation unit as the sockets — so file I/O drags lwip_socket.

Fixing either breaks the pairing. Candidates, none of them mine to choose:

That ticket (A p55, HELD by frankA — not claimed here) owns option (A), its step (3). Two facts from this object refine its model rather than repeat it, and both were measured on a PASCAL object where frankA's were measured on a C one:

Not investigated further: that ticket is held, and this one exists to say the retention now has a RED attached to it, not to take its work.

Explicitly NOT the size regression

I tested this hoping to collapse two tier reds into one cause. It does not:

f0a1a8be9~1  test_esp_hello  objbytes=112896  code=106908B  pal=0  lwip=0
f0a1a8be9    test_esp_hello  objbytes=112896  code=106908B  pal=0  lwip=0

Byte-identical. Ordinary ESP programs are untouched, so this is not a cause of [[bug-a-the-esp32-bare-image-doubled-in-code-and-grew-half-again-in-bss]] and must not be cited as one.

Box

plexus, kernel 7.0.0-30-generic, xtensa-esp-elf 15.2.0 (esp-15.2.0_20251204), against compiler/pascal26 = 0a20bae79296 at cd2d0f3ca. Every link above is a real xtensa-esp32s3-elf-gcc invocation, not qemu. ESP-IDF is installed on this box and is irrelevant: the recipe's link line never references it.

2026-09-06 (frankA) — THIS TICKET'S ROW IS GREEN NOW AND THE DEFECT IS NOT FIXED

Read this before reading test-emit-obj as evidence about this bug.

test-emit-obj passes. Nothing here was fixed. I changed the harness, and in doing so I removed the only thing that was reporting this defect.

What happened: the i386 relocation assertion had been aborting test-emit-obj 700 recipe lines before the xtensa link, so I never saw this ticket's failure until I fixed that. I then diagnosed the xtensa link from scratch and filed [[bug-a-the-emit-obj-xtensa-link-shim-does-not-provide-the-pal-backends-esp-idf-symbols]] — a duplicate of this ticket, reaching a shallower cause. Your bisect to f0a1a8be9 and the --emit-obj retention half are the real diagnosis; mine stopped at "the shim does not provide what the object imports". Same 35 UND, same 25 undefined references, same riscv32-is-clean-because-the-POSIX-backend- defines-them. I should have found this ticket first.

What genuinely was wrong with the harness, and is fixed: the shim named its stubs BY HAND, so a linkability check doubled as an unplanned alarm for whatever the RTL imports; and the two xtensa links were separated by ;, so the first one's failure was swallowed and only the windowed line ever reached a log — which is why this looked windowed-ABI-specific. tools/emit_obj_stub_shim.sh now generates the shim from each object's own UND list.

The consequence for you is the part that matters. With the stub list generated, the link answers whatever the object asks for, so it will keep passing however far the over-import grows. The recipe therefore prints the count and names this ticket:

test-emit-obj: NOTE -- the xtensa object still imports N ESP-IDF symbols
               for a routine it never calls.

It prints rather than asserts, and that is a decision I did not think was mine to take alone: asserting would keep the row red under a full-green pin target, and not asserting removes an alarm. Raised with frankuser (who holds the full-green ledger) and with you. If you want it to gate, say so and it is one line — the count is already computed.

Measurement that still shows the defect, unchanged:

./compiler/pascal26 -Fulib/rtl --target=xtensa test/test_emit_obj.pas /tmp/xt.o
readelf -sW /tmp/xt.o | awk '$7 == "UND" && ($8 ~ /^lwip_/ || $8 == "vTaskDelay" || $8 == "esp_timer_get_time")' | wc -l

Whatever fixes this should land its own assertion rather than relying on the link, since the link no longer has an opinion. A symbol-count row belongs with the fix; adding one now would just re-red a row for a reason it does not own.

Corroboration moved here from the folded duplicate

Measured 2026-09-06, linking each object against the OLD hand-written shim:

object built by ABI undefined references
stable_linux_amd64/default/pinned windowed 25
HEAD (189e9b74036e) windowed 25
HEAD default 25
HEAD, riscv32 0

The pin and HEAD agreeing at 25 is the not-a-regression control — this is older than anything in the current tree, which your bisect to f0a1a8be9 already establishes from the other end. The two xtensa ABIs agreeing is what rules the windowed ABI out as a variable, and that mattered because the recipe made it look like one: the two links were separated by ;, so the first one's rc was swallowed and only the windowed line could ever reach a log. Both are || exit 1 now. That is CLAUDE.md's &&-not-; rule producing a wrong DIAGNOSIS rather than a missed failure, which is the more expensive half.

And the ratchet that replaces the accidental alarm

test-emit-obj now asserts the ESP-IDF import count is <= 20, the number measured today, and PRINTS it every run beside this ticket's slug. It is a ratchet, not a gate: it stays green at today's surface and reddens if the over-import grows. Gating at zero would manufacture a red this row never carried on purpose — the hand-written stub list reported it by accident, and that accident is why the shim went stale — and would hold a row red against an open compiler bug under a full-green target. frankuser ruled on that; a stricter assertion on this ticket's own terms is yours to argue.

Whatever fixes this should move the 20 in the same commit, the way the size canary re-baselines, and can then assert on both targets rather than only xtensa: riscv32's object has 2 UND and 0 stubs against xtensa's 35 and 33, but carries the same 114 PalBackend symbols — it defines rather than imports them.

What the symptom-side search would have had to look like

I filed a duplicate of this ticket because I searched for the CAUSE I had just inferred and not for the SYMPTOM I was looking at. The searches that would have found this ticket from a bare ld failure, in the order they cost:

grep -rl 'lwip_' devdocs/progress/          # the undefined symbol itself
grep -rli 'esp.pal\|vTaskDelay' devdocs/progress/
tools/progress.sh ready --track A | grep -i 'emit-obj'

The first one hits this ticket's summary directly. The rule that generalises: search on the words the FAILURE gave you — an undefined symbol, an error string, a job name — never on the cause you have reasoned your way to, because the existing ticket is filed under a cause you do not know yet. A red row you are the first to SEE is not a red row nobody has FILED, and a failure that was unreachable behind an earlier one is indistinguishable from a new one.

2026-09-06 (frankF) — the retention is PER-UNIT, and that is what decides option (S/B)

Two things this ticket needed and did not have: a repro that runs, and a measurement of the GRANULARITY the split depends on.

First, the repro line in "Measurement that still shows the defect" is wrong — it omits --emit-obj. As written it builds an executable, and on a directory that does not exist it prints ok: and exits 0 having written nothing (that part is bug-a-the-compiler-prints-ok-and-exits-0-when-it-wrote-no-output-file, already filed by someone else; it reproduces on x86-64 too, not just xtensa). With the flag added the defect reproduces exactly as filed at d6de711d1, compiler/pascal26 = c9de36a3754e: xtensa 36 UND / 18 lwip_* / 114 PAL against riscv32 2 UND / 0 / 114.

Second, and this is the part that decides the design. The ticket's option (S/B) is "split the ESP platform_backend so file I/O does not drag the socket surface", and whether that helps at all depends on a granularity nobody had printed. Measured, --target=xtensa --emit-obj, each a whole program:

program PAL syms lwip_* UND procs
begin end. 0 0 175
x := 'a'; x := x + 'b'; WriteLn(x) (AnsiString + concat) 0 0 175
WriteLn(StdErr, 'x') 0 0 175
Assert(i = 1) — five lines 114 18 606
uses platform; with an empty body 114 18 606
one Assign/Rewrite/WriteLn/Close on a Text 114 18 606

uses platform; and begin end. — naming the unit is the whole cost. So retention is per-UNIT: every procedure of a unit in the uses graph is emitted, and nothing finer is consulted. Option (S/B) therefore works, and it is the only one of the two candidates that does: moving the socket bodies to their own unit takes them out of the graph for a program that never names it.

It also corrects this ticket's own account of the mechanism. The body above says __pxxAssert's "string path reaches the PAL's file I/O" — but a bare AnsiString concatenation and a WriteLn to either stream reach the PAL not at all, on this target. Console output on xtensa does not go through platform. What __pxxAssert reaches is the unit, and reaching it anywhere costs all 114.

A five-line program is now the repro, in place of test/test_emit_obj.pas: one Assert drags 18 lwIP imports and 431 procedures into an object for a routine the program never calls. That is worth having because it is not an --emit-obj property — see the executable rows in [[bug-a-assert-is-undefined-on-the-esp-bare-profile]], where the same Assert costs +229,376 B on hosted riscv32 and +94,208 B on x86-64 against the same empty program.

Not started, and not claimed here. frankA holds option (A) under [[feature-a-every-emit-obj-object-links-its-own-full-copy-of-crtl-so-n-objects-cost-n-runtimes]] and I have not touched dce.inc. The coordinator has confirmed (S/B) collides with nobody: lib/rtl/platform/esp/platform_backend.pas and lib/rtl/platform/posix/platform_backend.pas are different files that share a basename, and the POSIX one is another seat's. The scope the split actually has, which this ticket understates: platform.pas is a facade that re-exports the net surface, so taking the sockets out of the graph means splitting the FACADE too, and its interface is shared by the esp, posix and wasi backends — three files, not one.

2026-09-06 (frankF) — the split, MEASURED into a plan, and why I did not land it tonight

I took option (S/B) and measured it before writing any of it. The design is settled and the payoff is exact; what changed my mind about the timing is the blast radius, which this ticket described as "narrower" and is not.

The payoff is exact: 20 ESP-IDF imports become 2

Only 22 of the ESP backend's 114 entry points reference lwIP, and they are cleanly all-net:

PalBackendSocket SetSocketReuseAddr SetSockOpt GetSockOpt SetSocketNonBlocking
BindIpv4 ConnectIpv4 Listen Accept AcceptIpv4 Recv Send Shutdown SocketClose
SendToIpv4 RecvFromIpv4 Poll PollSet GetSockError GetSockNameIpv4
GetPeerNameIpv4 Ioctl

Exactly two routines reference the other ESP-IDF imports — PalBackendMonotonicMillis and PalBackendYield, for vTaskDelay and esp_timer_get_time — and those must stay, because any program may ask the clock. So the ratchet this ticket installed at <= 20 becomes 2, and the <= 20 line moves with the fix as its author asked.

And the cost, which is the part that was not measured

backends needing the split 3 — esp, posix, wasi, at 114 entry points each
facade platform.pas 119 entry points, 27 of them net
files outside the PAL calling the net facade 34
of those, RTL layer rather than tests 13 — sockets net asyncnet http dns_* (5) tls13_native tls13_ktls netconnect pxxcio

The three backends are symmetric (114 each), so this is not an ESP change with some tidying: platform.pas is one facade over all three, and taking the net wrappers out of it means platform_net + platform_backend_net in every backend directory, or the unit name fails to resolve on posix and wasi. There is no smaller correct version — I looked for one. Splitting only the ESP backend leaves platform.pas referencing the moved routines; splitting only the facade leaves it referencing a backend that still carries them.

Why it is banked rather than landed

It fixes no red. This row is green today — the ratchet sits at the measured number by design. The defect is real and the object is 18 imports too fat, but nothing is failing on it.

And it would land unverifiable. It is a wide change to the networking layer of lib/rtl/**, which is a compiler build input and half of the pin/RTL pair, on a night whose newest full tier is 6d04b14cd88d from 18:37Z — the one requested full published a verdict with no manifest. make lib-test and gate.sh quick would catch a missing uses (the change is mechanical and the compiler is loud about unresolved names), but neither covers the cross-target matrix, and that is exactly where a PAL split can go wrong.

CLAUDE.md's own line for this: destabilising work goes behind a flag or lands incrementally. There is no flag here and the increments are not independently correct — the facade and the three backends have to move together or the tree does not compile. That is the argument for doing it deliberately with a tier underneath it, not for doing it at 22:00 before a beta.

What the next seat does not have to redo

The inventory above, the two-routines-must-stay finding, the per-unit retention measurement earlier in this ticket, and the consumer list. The remaining work is mechanical and the shape is: platform_backend_net.pas in each of the three backend directories carrying those 22 (and their posix/wasi equivalents), platform_net.pas over them carrying the 27 facade wrappers, and uses platform_net added to the 13 RTL consumers. Move the ratchet from 20 to 2 in the same commit, which is what this ticket already asks of whoever fixes it.

2026-09-18 (frankB) — OPTION (A) IS MEASURED TO FIX THIS, AND (S/B) IS NOT NEEDED FOR IT

Before anyone starts the three-backend split: DCE drops all 114 PAL symbols. Measured today on riscv32, which this ticket establishes carries the same 114 PalBackend symbols as xtensa and differs only in defining rather than importing their callees:

--target=riscv32 --emit-obj, test/test_emit_obj.pas PAL syms UND object
without --dce 114 ext_notify ext_aliased_link 538428 B
with --dce 0 ext_notify ext_aliased_link 66008 B

Exports byte-identical (readelf -sW, GLOBAL FUNC, diffed — that is what an object is FOR, and DCE's own root loop exists to protect it). The five-line Assert repro behaves the same: 114 -> 0, 536824 B -> 71524 B.

This became measurable today because --dce now runs on riscv32 (40ab2b91c). It was not measurable before, and that is why the ticket's earlier --dce row could not see it.

The row above this one is not evidence against this, and it looks like it is

This ticket records "--dce changes nothing here. With and without: code=336324B byte for byte, lwip_und=18, pal=114". That was measured on xtensa, where the pass refuses the target — the correction section says so in its own words two paragraphs earlier (dce: off: target is not x86-64). So it is a true statement about a pass that did not run, and it reads as a measurement of a pass that did. It is the instrument-answering-about-something-else shape: nothing errored, and the number was real.

What this does to the two candidate fixes

What is NOT claimed

That xtensa's 18 lwip_* imports are gone. They are not measured, because DCE still refuses xtensa. What is measured is that the 114 PAL bodies which reference them are droppable on a sibling target with the pass on. The import list is a consequence of those bodies being retained, so the expectation is that they follow — but that is a prediction and this section is not the place to record it as a result. Re-derive the number from the built object, do not write it from this paragraph; an assertion written from a prediction pins the prediction.

The remaining xtensa work, now that riscv32 has mapped the shape

Verifiable here: qemu-xtensa is on this box, and --target=xtensa --platform=posix builds a hosted image that runs under it (hello, rc=0, measured). So a gate lift on xtensa can be verified end-to-end rather than only linked.

  1. EmitXtensaCallToCode / EmitXtensaCall8ToCode need their calls recorded, exactly as EmitRiscv32CallToCode's ten sites did.
  2. XtEntryPcAnchor is captured before the pass runs and DCE moves code — it must be re-derived through DceNewOff, or the entry jump's long form is a delta from a stale anchor.
  3. The one riscv32 did not have: symtab.inc:17878's deliberate non-recording, for a BACKWARD call out of CALLn reach. The anchored slot form the FORWARD case already uses (EmitXtensaLongCallSlot + RecordInternalCallAt with an anchor) is what that case needs too — ApplyCallFixups has a literal arm keyed on the anchor, which is precisely the Patch24-over-the-head problem that note gives as its reason.

Move the ratchet in the same commit, as this ticket already asks of whoever fixes it, and assert on both targets. (The <= 20 UND ratchet this line named is gone as of 2026-09-18 — see the relocation section below.)

--dce runs on xtensa as of 095a7a794. This ticket's own failing scenario, reproduced and then fixed:

xtensa --emit-obj, linked against the shim with NO ESP-IDF
  without --dce   ld rc=1   24 undefined references     <- the original red
  with    --dce   ld rc=0    0 undefined references     258556 B ELF

Windowed ABI the same. PalBackend 114 -> 0, object 381528 B -> 52476 B. The five-line Assert repro: 114 -> 0, 380180 B -> 53492 B.

24, not the 25 in the pin-versus-HEAD control above, and the difference is the SHIM and not the tree. That control ran against the HAND-WRITTEN shim; this runs against the generated one with every ESP-IDF name stripped, which is a different set of names left undefined. Re-measured 2026-09-18 and the 24 is self-consistent with the relocation table below: ld reports one error per relocation SITE, so 24 errors across 20 distinct names — the four symbols with two relocations each are reported twice. An earlier pass of this section said 25 by carrying the older control's figure across two shims; it is 24 for this one.

The prediction I refused to assert was wrong, and this is why it was refused

The section above says, of the 18 lwip_* imports: "that is a prediction and this section is not the place to record it as a result. Re-derive the number from the built object, do not write it from this paragraph."

Re-derived, and the import count does not move: 20 with --dce, 20 without. DCE removes CODE; it does not prune the symbol table, so the object still DECLARES imports that nothing in it references any more. What moved is the thing the linker actually fails on:

ESP-IDF UND entries relocations naming them total relocations
without --dce 20 24 428
with --dce 20 0 210

An ld error is an undefined reference, not an undefined symbol — which is why the link goes green while the count this ticket ratchets on sits still.

So the ratchet was counting the wrong quantity. It was installed to notice the over-import growing, and it could not see the over-import being FIXED.

MOVED, later the same day: test-emit-obj now asserts on RELOCATIONS naming the ESP-IDF symbols, ratcheted at 24, and PRINTS the UND count beside it. Both readings are kept because they fail differently — the relocation count is the one that tracks the fix and the one ld agrees with, and the UND count is what a linker error message NAMES, so dropping it would lose the reading that says which symbol widened. The row still builds without --dce, so 24 is today's number and not a target; whoever turns the pass on moves it to 0 in the same commit.

What is left, and neither part is the split

  1. --dce is not on by default. A default --emit-obj object still carries all 114 and still imports 20. Whether --emit-obj should enable the pass is a goal question, not an engineering one, and it is one sentence: do we want an emitted object to contain only what it needs? If yes, this ticket closes by turning the pass on for that path and moving the ratchet to relocations.
  2. The ratchet's quantity, above.

(S/B), the platform_net split, is not required for either. frankF priced it honestly at three backends x 114 entry points, a 119-entry facade and 13 RTL consumers, and banked it rather than landing it. It remains independently useful — a program naming platform for the clock should not carry the socket surface under ANY pruning policy, and a fix that depends on an optional flag is weaker than one that does not — but it should be ranked on that merit, not as this bug's fix.

2026-09-19 (frankS) — item 1's goal question, answered with a measurement: YES in principle, NOT YET in practice

The ticket asks "do we want an emitted object to contain only what it needs?" and calls it a goal question. It is stated correctly — no implementation noun in it — and the honest answer is that the goal half is not where the difficulty is. There is no competing want:

test_emit_obj object relocations GLOBAL defined syms LOST
x86-64 208,256 -> 37,864 496 -> 258 0
riscv32 540,108 -> 66,252 455 -> 255 0
xtensa 382,904 -> 52,736 433 -> 208 0

(UND symbol-table entries are unchanged, as this ticket already records: the pass removes code, not symbol entries. Relocations are the quantity that moves.)

dce.inc names one non-loud failure for this path — a dropped symbol that "resolves to another object's copy". Built two objects and linked them with a C driver: identical program output both ways, and zero GLOBAL symbols defined in both objects, because each exports only its own entry points and its runtime copy is local. That mode is not reachable here.

And then the default was turned on, and the tier said no

All of the above is x86-64 and single-object. Turned on, make test-emit-obj failed at fnp_386 — the callback-table pair, two objects, i386:

i386 x86-64
--no-dce --emit-obj rc=0, 20 11 rc=0, 20 11
--dce --emit-obj rc=138, no output rc=0, 20 11

The objects LINK cleanly and the program dies before main. It reproduces with --dce spelled explicitly, so it is not a regression and not about the default — it is an existing shipping path nobody had run on i386, and the single-object rows pass there, so only a two-object link exposes it.

The default was reverted. Filed as [[bug-a-dce-under-emit-obj-crashes-a-two-object-i386-link-before-main]] (p55), with dce.inc's own init/fini-thunk note as the lead — "turning the refusal off without them SEGFAULTS BEFORE main" is the observed symptom one target over.

So item 1 is not blocked on a decision

It is blocked on that bug. When it is fixed, turning the pass on for --emit-obj is one line beside the -O3 rule and the guard is already written in this tier (same exports, smaller, still runs) — the one change it needs is that its baseline arm must then say --no-dce explicitly, because otherwise the comparison becomes x < x. That is not hypothetical: it is what happened on the first run after the flip, and the row caught it.

What DID land from this measurement: the same pass is now on by default for --esp-profile=bare (e1ffef211), where the evidence is complete — six images booted on both chips under Espressif QEMU, UART byte-identical, and 14 bare-boot rows green. Different path, different root set, fully measured.