← board

The measurement

program size
10 PRINT "hello" (.bas, no USES) 559 B
WriteLn('hello') (.pas) 63,760 B
test_basic_comprehensive.bas (has USES) 103,935 B

The mechanism is known and is not subtle: DetectPascalRuntimeNeeds sets needsAnsiRuntime := PasDefineExists('PXX_MANAGED_STRING'), and PasApplyDefaults defines that symbol unconditionally — so every Pascal program pulls builtinheap, always, whether or not it touches a managed string.

Why this is filed as a bug rather than an optimisation

[[feature-emission-size-dce]] is in done/ and its stated goal is "emit only reachable code ... emit a unit routine only if reached from the program entry", with hello.pas named in its own text at ~31.6 KB against a ~29 KB reachable baseline. The current 63,760 bytes is worse than the number the done ticket was arguing about. Something regressed, or the pass never covered the unconditional-PXX_MANAGED_STRING door, or the ticket closed on a narrower slice than its title. root-cause-over-microfix.md applies: find out which before writing anything — the answer changes what the fix is.

What depends on it

[[decide-how-much-string-machinery-the-basic-frontend-gets]] deferred BASIC's 559-byte unit-free binary to this ticket rather than building a BASIC-only conditional runtime pull. If this lands, that property comes back on its own, for every frontend, instead of for one.

Do not

Fix it by making PXX_MANAGED_STRING conditional on a source scan in the Pascal driver. That is the per-frontend special case the BASIC decision rejected, one language over. The general mechanism is reachability, and it already has a home.


2026-08-30 — an EMPTY program is 61,279 B, so the hello is ~2 KB of it

Measured at HEAD 4039216a7f25 while building the size canary for [[bug-a-the-esp32-bare-image-doubled-in-code-and-grew-half-again-in-bss]]:

$ printf 'program e;\nbegin\nend.\n' > empty.pas
$ pxx empty.pas out
ok: out  [code=61279B  data=1960B  bss=42452B  procs=129]

Against this ticket's 63,760 B for WriteLn('hello'), the whole of WriteLn('hello') — the call, the literal, the string machinery it drags in — is about 2.5 KB on top of a 61.3 KB floor that a program with no statements already pays.

That reframes the question the ticket asks. "Either the pass is not reaching this, or the done ticket's scope was narrower than its title" — the measurement says it is not about reaching this program, because there is no program here to reach. Whatever is being emitted is emitted for a unit with an empty body, so the subject is the RTL/startup floor, not the DCE pass's treatment of WriteLn. Anyone starting from the hello-world will spend the first hour looking at string machinery that accounts for 4% of the number.

x86_64-empty is now a watched subject: tools/size_canary.py, baseline in tools/size_baseline.json, running in native/limited/full as size-canary#00. It is a delta gate — it freezes 61,279 B rather than blessing it — so when this ticket is fixed the canary reports the shrink out loud and asks to be re-baselined, and it reds if the floor grows again meanwhile.

(Measurement and instrument: Track T. The 61 KB itself is this ticket, and still Track A's.)

Re-measured 2026-08-30 — the subject of this ticket is 0.1% of the number

Measured by frank-coordinator against stable_linux_amd64/default/pinned (1d69760deabe), after the size canary added an empty-program row nobody had asked for:

program code
program e; begin end. 61,276 B
program h; begin WriteLn('hello'); end. 61,350 B

WriteLn('hello') costs 74 bytes on a 61,276-byte floor.

This ticket's own open question — "either the pass is not reaching this, or the done ticket's scope was narrower than its title" — has a third answer, and it is the right one: there is no this to reach. The body is empty and the number is unchanged. The subject is the RTL/startup floor, not emission-size DCE's treatment of WriteLn or of string machinery.

Anyone starting from the hello-world spends their first hour on the ~0.1% and concludes DCE is broken. It is not this ticket's fault — a hello-world is the obvious probe, and the obvious probe put the entire mass in the part that varies. The fix is to re-scope onto the floor, or to close this and open one named for it.

x86_64-empty is now a watched subject in tools/size_canary.py, so a genuine reduction arrives as a reported shrink against a frozen baseline rather than as prose in a ticket. Note it is advisory: it reports and files, it does not fail a tier.

Scoped 2026-09-18 (frankS) — the prize, the locus, and the one concrete blocker

The prize, measured on bare esp32c3 with a UART-only program using ShortString and never allocating:

code data bss
default 58,900 736 71,452
-uPXX_MANAGED_STRING 1,156 432 5,288

−98% code and −93% bss, with byte-identical program output (noalloc-start 123 noalloc-done from both, under Espressif qemu). On bare ESP this is the largest single saving anywhere in [[umbrella-an-esp32-image-is-as-small-as-it-can-be]] — bigger than the 32 KB alt stack and bigger than the whole rest of the bss floor combined.

The detection already exists and is already conservative. DetectPascalRuntimeNeeds (pasparser_prog.inc) sets needsAnsiRuntime from real evidence — tkUses, and the identifiers AnsiString and Variant — and sets needsHeap from tkUses/tkArray/tkClass, div/mod, floats, New/Dispose/ReallocMem/SetLength/GetMem/FreeMem, and write formatting. All of that careful work is discarded by one line:

needsAnsiRuntime := PasDefineExists('PXX_MANAGED_STRING');   { :104 }

PasApplyDefaults defines that symbol unconditionally, so the variable is True before the scan starts and the scan can only confirm it.

The failure direction is safe, and this is what makes the change tractable. Under-detecting produces a COMPILE error, never a wrong binary — measured:

pascal26:7: error: target riscv32: frozen tyString concat unsupported

THE ONE CONCRETE BLOCKER: the string keyword is not in the scan. var s: string; s2 := s + 'y' has no AnsiString identifier and no uses, so flipping needsAnsiRuntime to evidence-only breaks it — verified, that exact program fails under -uPXX_MANAGED_STRING today. tkString is the string LITERAL token (see defs.inc:6802, "tkString empty is a legitimate ''"), not the type keyword, so whoever takes this must first establish what string, ShortString, WideString and UnicodeString lex as and add them.

2026-09-22 (frankb-8e): the stated blocker is answered, and it was not the blocker

Tree fda77c48b8ee, compiler sha printed beside every row below. Parked patch: devdocs/progress/parked/needsansiruntime-evidence-scan.patch (234 lines).

The lexing question this ticket asks is answered. string is tkString_T, a keyword token (paslexer.inc:166-167), not tkIdent — so the CaseEqual arm the ticket points at could never have seen it, and the test is a token KIND. Note the 6-char keyword table matches only string and String, so a scan wanting every spelling must test the token KIND and the ident NAME, which is what the parked patch does.

THERE IS NO DEFECT BEHIND THAT OBSERVATION, AND IT IS WORTH SAYING SO HERE SO NOBODY WRITES ONE UP. Found by frankh-c0 and re-derived at fda77c48b8ee rather than transcribed, var s: <decl>; s := 'ab'; WriteLn(s, ' ', Length(s)), all nine rows building and printing ab 2:

declaration declaration
string[20] ab 2 string ab 2
String[20] ab 2 STRING ab 2
STRING[20] ab 2 StRiNg ab 2
StRiNg[20] ab 2 AnsiString / ANSISTRING ab 2

c0's four rows are the FROZEN string[N] spelling; the bare and AnsiString rows are added here because bare string is the MANAGED type and is the one this ticket turns on, so the frozen rows alone would not have covered it. The ident path resolves every non-keyword spelling case-insensitively, so the short table has no observable consequence. Real observation, nothing reachable behind it — rejected/, never a low prio, per the four-terminal-folders rule. ShortString/WideString/UnicodeString/UTF8String/ RawByteString/OleVariant are ordinary identifiers and were all measured to need the bundle, as was string[N] — a frozen string still reaches the concat and write helpers, so there is no win in telling frozen from managed here.

The prize is real and reproduces off the ESP profile. Baseline fda77c48b8ee -> evidence scan, same tree, program hello; begin WriteLn('Hello, world!'); end.:

target baseline evidence scan delta
x86-64 (file bytes) 25,016 4,472 -82.1%
xtensa --esp-profile=bare (code=) 860 B, procs 88 58 B, procs 0 -93.3%
riscv32 --esp-profile=bare (code=) 336 B, procs 85 20 B, procs 0 -94.0%
riscv32 hosted (code=) 33,244 B 33,244 B 0%

The pinned compiler agrees with this tree on both bare rows (860/336), so no intervening change contributes to them. The hosted riscv32 row is zero on purpose: write/writeln route through PXXWriteNL/PXXWriteDecW there and on xtensa, so the scan must pull the heap unit for any program that writes — measured across all seven targets, and x86-64/i386/arm32/aarch64/wasm32 need nothing. On the BARE ESP profile write emits nothing at all, which is why the two bare rows keep the whole win.

THE REAL BLOCKER IS ORDERING, AND NO TOKEN SCAN CAN REACH IT. EmitAnsiStringRuntime is decided at pasparser_prog.inc:1977. The ambient RTL units that are themselves written over managed strings are pulled after it — at :2163, and for units from a second, independent pull site at pasparser_proc.inc:7422. So the evidence arrives after the decision, and these three refuse inside lib/rtl/textfile.pas:1344 with no string anywhere in the program:

var p: PChar;        var c: WideChar;        var s: set of Char;

Each pulls a unit whose body calls a string stub. Adding names does not fix a class where the trigger is a unit arriving behind the construct. Two narrowings were tried and measured, and both are in the parked patch: coupling needsHeap/needsTextfile/needsMath/needsThreads/needsWide into needsAnsiRuntime, and broadening the emission condition to every ambient flag. The first fixed test_promoint_bitwise.pas (which needed PromoInt added to the pre-scan, because needHeapUnit is computed before needsPromoInt exists); neither reaches the three above. The fix is to settle the emission after unit pulls, or to emit the stubs on demand — the tidy version moves the ~150-line registration block below the token loop, which is its own verified pass.

THE CHECK THAT WOULD HAVE CAUGHT THIS FROM EITHER END, AND NEITHER END RAN IT. This ticket's blocker was stated as a place to add something — the string keyword is not in DetectPascalRuntimeNeeds' scan. It was relayed as a task and implemented as a task, and it was neither: a blocker phrased as a PLACE TO ADD SOMETHING is a CLAIM about where the decision is made, and here that claim was false twice over — the construct is not an identifier, and the decision is not in the scan at all. Checking the claim costs one grep of paslexer.inc; the work it authorises cost an evening and ended parked. So before treating "add X to Y" as the job, establish that Y is where the outcome is decided. It is available to whoever relays the blocker and to whoever implements it, it is cheaper than the work either way, and on this ticket neither seat ran it. (frankh-c0 relayed, frankb-8e implemented; the apportionment is recorded only because the check is the same one from both sides, and that is the part worth reusing.)

The failure direction is safe — confirmed, and on far more than the one riscv32 row above. Under-detection cannot produce a wrong binary:

AND THE MEASUREMENT OFFERED FOR THAT CLAIM TESTS A DIFFERENT MECHANISM — BOTH TIMES IT WAS TAKEN. The row above cites -uPXX_MANAGED_STRING, and so did the seat re-checking it today, before noticing.

NOT INDEPENDENTLY — CORRECTED 2026-09-22 BY THE SEAT THAT CLAIMED IT, AGAINST ITS OWN TRANSCRIPT. That seat told two peers the two of us had reached the wrong lever independently, from recollection, and it is false: in its session jsonl the literal -uPXX_MANAGED_STRING is in context at record 29522 — its own cat of THIS FILE, carrying the claim and, in the same paragraph, this route offered as how to check it — and the first invocation is at record 30640, 1,118 records later. The route was inherited, not converged on. Which makes the mechanism worse rather than better: a wrong verification route recorded beside a claim is picked up by exactly the person who comes to AUDIT that claim, because auditing means reading the claim and the route is the adjacent sentence. The audit inherits the instrument the audit is about, it needs no second seat, and it recurs on every future re-check until the route is corrected in place — which is what the paragraph above is for. That flag is consulted in ~15 places to decide what string means (util.inc:148 BareStringKind, symtab.inc:3297/3349); undefining it makes string a frozen 255-byte type. Under-detection changes one consumer, needsAnsiRuntime, and never touches the type. The two routes give opposite answers: with -uPXX_MANAGED_STRING a concat loop builds clean and silently truncatesn=26 prints len=255 against an owed 260, rc=0, no diagnostic, boundary exactly at 255 — which reads as a refutation of the safety claim and is not one. Simulated properly (a compiler built with needsAnsiRuntime := False) the same program refuses to compile. The conclusion was right both times and the evidence was about something else, which is this repo's own "an instrument lies by being correct about something else" in a claim that a check is unnecessary. To re-verify this claim, build the compiler with that initialiser forced False; do not reach for the define.

Why it was not done in the same pass as the rest of today's ESP work: the change flips a default for every Pascal program on every target, the detection set is a judgement call whose wrong answers reach users as compile errors, and the quick gate covers x86-64. It wants its own pass with a real sweep, not a bolt-on. The manual lever is documented meanwhile (docs/targets/esp32.md, bare-profile notes).

RE-MEASURED 2026-09-19 (frankS)

program plain -O --dce
WriteLn('hello') 74,096 24,944
begin end. 74,040 24,888

-O3 gives 24,944, identical to --dce.

Two things follow, and the second is the one that changes the ranking.

The table at the top of this ticket is stale upward. 63,760 was measured 2026-08-25; the floor has grown 10,336 bytes since, and nobody re-measured because the number in the summary was never contradicted by anything. A figure only gets checked when someone disagrees with it, and nobody disagrees with a number in a summary.

The general answer the decide ticket bet on exists and delivers. That decision accepted ~100 KB BASIC binaries on the grounds that size is a general problem with a general answer — reachability-gated emission. It is written, it runs on five of six targets, and it takes two thirds off this floor.

What it does not do is change the mechanism this ticket names: the pull is still unconditional, and DCE only removes what the pull dragged in. So the residual is narrower than the title — whether the default -O should enable the pass — and that is not a flag flip. Turning the analogous default on for --emit-obj surfaced [[bug-a-dce-under-emit-obj-crashes-a-two-object-i386-link-before-main]]: an existing shipping path that links cleanly and dies before main on a two-object i386 link, invisible because every single-object row passes. The same per-target evidence is wanted here.

2026-09-22 (frankb-8e) — A CONSTRAINT ON ANY RETRY: wasm32 MUST NOT INHERIT THE DEFAULT

Recorded here rather than in the C-cross bug because it is not a defect and it will not surface as one: it is a property of the harness, and it would land as a mysterious row of failures on whoever retries the promotion.

The wasm harness reaches bodies by EXPORT NAME, and --dce correctly drops an export that nothing reaches. So a wasm32 program that is right, and a pass that is right, produce a harness that cannot find the thing it was going to call. Nothing is broken; the entry contract differs from every other target's.

So a -O2 promotion needs an explicit wasm32 carve-out or a harness that names its roots, and the failures it would otherwise produce are not evidence against the pass — which is exactly how they would read, arriving in the same tier run as real ones.

For the same reason, 8e's wasm32 --dce work guards the renumbering seam with a refusal when WasmPatchCalls is asked for the index of a dropped body. That guard found its own bug on the first run, and the failure it prevents is a valid module calling the wrong function — the silent arm, one target over.

2026-09-22 — SECOND PROMOTION ATTEMPT, ALSO REFUSED. 4949/4955 pass, RED.

The first attempt reduced to one bug (cross-target C entry, fixed by frankb-8e at a79934842, confirmed here by mutation). With that fixed the tier was re-run at --dce on by default at -O2. 101 hard FAILs became 6. Still RED, and the six sort into three groups that want three different things.

TWO CORRECTNESS REGRESSIONS — these are what refuse it. Both are instances of bug-a-compiler-emitted-runtime-stubs-are-invisible-to-every-gate-we-run, which names them in its own summary, and both reproduce in one line:

test flag want --dce --no-dce
test-core#519 --fpc-float-errors div0 208 139 208
test-core#2353 --fpc-mem-errors nilread 216 139 216

The mechanism is NOT "an installed-only handler is invisible" — that was my first reading and a two-sided control refutes it. test_signal_handler_callback_b336 and test_setsignalhandler_call are installed-only and come out rc=0 under --dce. frankb-8e root-caused it: EmitCodeAbsToRdx (ir_codegen.inc:1001) materialises a code address with add rdx, imm32 where the imm32 is a delta fixed at emit time, and records no CodeRef, so the pass neither protects the target range nor re-aims the delta. Only sites with a droppable body in the gap break — exactly the three-survive / two-break split measured. There is an unmeasured i386 twin at ir_codegen386.inc:335.

Recorded in the p55 ticket as two casualties of the gate blindness, which is that ticket's real claim, and explicitly NOT as instances of its mechanism.

ONE REAL BUG CAUGHT BY A GUARD — test-core#1008, wasm32. The backend refuses the build outright: "--dce dropped slot 0 (PXXHdrInit) and something live still references it -- the live set is not closed under the call graph". That is frankb-8e's own renumbering guard, added the same day, firing on somebody else's change. Note it is NOT the failure 8e predicted (it predicted an export nothing reaches being dropped, giving a module that builds and cannot be called); it is a compile-time refusal of an unclosed live set, which is worse and was caught for free.

TWO ROWS WHOSE SUBJECT THE PROMOTION DELETES — test-emit-obj#37 and #38, and they are NOT bugs. Both assert nm ... | grep -q ' t Hidden$': that a non-exported routine is emitted as a LOCAL symbol. Under the pass Hidden is inlined and its dead body correctly removed, so the symbol is absent — and the behaviour is unchanged, both objects link against the C host and print 42 / 42 / -42. Exports are untouched (PxxLibAdd/Mul/Negate all present, 0 undefined), which independently confirms the measurement in decide-a-is-a-pxx-object-a-self-contained-runtime-or-a-translation-unit: exports ARE the root set, so the pass can only remove locals — 142 symbols to 50 here.

These are the SEVENTH and EIGHTH control arms using the default invocation as their baseline, after the five respelled at 311ebde9e and the sixth at 65c3df610. The repair is --no-dce on those two rows, NOT weakening the assertion. Dropping the ' t Hidden$' row and keeping only ! ... ' T Hidden$' would leave a guard that PASSES WHEN THE SYMBOL IS ABSENT — "if the machinery did nothing at all, would this row still pass?" is yes, and the row exists to ask whether a non-exported routine is local rather than global, which needs the symbol to exist.

ONE NOT OURS — test-riscv32#180 (cunsigned_semantics_sweep_b138.c): identical rc with and without the pass, and it had already taken a flake-guard retry earlier in the same run.

THE TREE IS REVERTED. compiler.pas is back to OptLevel >= 3 and the binary is byte-identical to the pre-attempt one (f7dedaea694f). The -O3 comment is rewritten to carry both refusals so a third attempt starts from the evidence.

AND THE ONE THING A THIRD ATTEMPT MUST NOT REPEAT: the self-host fixedpoint converges at the promoted setting, in 2 rounds, and it converged while both correctness regressions above were live. It is not evidence about this class.

A CONSEQUENCE OF THE REFUSAL THAT IS NOT ABOUT SIZE (raised by frankz-e5, 2026-09-22)

Refusing the promotion also declines to answer an open owner fork by the back door, which is worth more than it costs.

decide-a-is-a-pxx-object-a-self-contained-runtime-or-a-translation-unit asks one question: do we promise that a pxx object can supply the runtime for SOMETHING ELSE, or only for ITSELF? Its own summary records the consequence — answer "for something else" and per-object DCE stays off the table until the runtime is shared.

The -O rule is unconditional on OUTPUT MODE. That is deliberate, and frankuser confirmed it should not be special-cased away. So --dce arriving at the default -O2 turns per-object DCE on by the back door — enacting one of the two answers to a question the owner has not been asked.

The exposure is narrow and the decide ticket says so: the pxx-to-pxx pair is not at risk, both objects carrying everything they reach with zero undefined symbols; the risk is a non-pxx consumer or the libcrtl.a direction. And it is not source-auditable — 20 of the 298 exports that would vanish under the re-rooted model are routines a backend lowers onto and no source names, so no consumer can grep for whether it is affected.

A third attempt must say which half it enacts, in the commit, so the decision reads as MADE rather than BYPASSED. It is not this ticket's question and is not answered here.

2026-09-22 (frankb-8e) — THE TWO CORRECTNESS REGRESSIONS ARE FIXED. The remaining work is the tier re-run.

Both reproduce at HEAD and both are gone. EmitCodeAbsToRdx now records its delta as a CodeRef, which is what the refusal above named and what I had root-caused without fixing.

test flag want before --dce after --dce --no-dce (control)
test-core#519 --fpc-float-errors div0 208 139 208 208
test-core#2353 --fpc-mem-errors nilread 216 139 216 216

All arms, --dce: ferr ''/div/ovf/inv = 0/208/205/207 with the exact text Runtime error 208 (division by zero); merr nilread/nilwrite = 216/216.

THE MECHANISM, CONFIRMED BY THE SLOT'S OWN BYTES RATHER THAN BY THE EXIT CODE. The site is call +0 / pop rdx / add rdx, imm32, and the imm32 is target - (the address the call pushed) — an emit-time constant with no record, so DCE neither protected the target's bytes nor re-aimed the delta. Measured before the fix, the four deltas in the --dce and --no-dce binaries of the same program were byte-identical while the code between them had moved:

--no-dce   5bffffff a7feffff bafcfeff c9feffff
--dce      5bffffff a7feffff bafcfeff c9feffff      (before)
--dce      5bffffff a7feffff c9feffff eea1ffff      (after)

One delta moved and three did not, and that is the positive control: bafcfeff (-66886) became eea1ffff (-24082), i.e. 42,804 bytes were dropped inside that one gap. The other three gaps had nothing droppable in them, which is the three-survive / two-break split this ticket measured and could not explain. If the fix had done nothing every row would still read 139.

THE SHAPE ALREADY HAD A HOME, so the patcher needed no new arm design. target - anchor measured from a pushed PC is xtensa's long-form slot one architecture over, and CodeRefAnchor already travels per-slot and is remapped by DceNewOff in DceRun's compaction. PatchCodeRefSlot's x86 tail gained an anchor >= 0 branch above its rel32 default. No existing x86 slot records an anchor (every one passes -1), and the three negative sentinels are xtensa forms that return before the tail, so the branch is unreachable for everything that existed before this commit — --no-dce output is byte-identical to the pre-fix binary on both repro programs, which is the negative control.

The i386 twin EmitCodeAbsToEax386 is fixed in the same commit and was NOT caught by anything — there is no i386 --fpc-*-errors row for it to break. It is the same emitter with the same missing record; i386 exception builds run correctly --no-dce and --dce (identical output, 1..12 both ways).

The size prize, with its population, because this ticket has been quoting numbers without one. Tree fd6965890102, x86-64, program h; begin WriteLn('hello'); end., code= from the compiler's own ok line: 67,541 B plain -O → 18,790 B with --dce, −72.2%. That does NOT refute the −66% / 74,096 → 24,944 row above — it is a different tree and the floor has moved again — so both rows stand, each with what it measured. What would retire mine: any change to the RTL startup floor.

WHAT IS LEFT IS THE TIER RE-RUN AND NOTHING ELSE MECHANICAL. Of the six FAILs from attempt 2: two are these, fixed; two are the test-emit-obj control arms wanting --no-dce (the repair this ticket already specifies, and NOT weakening the assertion); one is test-core#1008, a wasm32 guard firing correctly on an unclosed live set; one is not ours. The wasm32 carve-out this ticket records above is still required and is not evidence against the pass.

The trap this ticket warns about was respected: the self-host fixedpoint is NOT quoted as evidence here. It converged at every one of the seven builds I made this session, including the ones carrying both live regressions. The evidence is the two repros, their --no-dce controls, and the slot bytes.

Tree fd6965890102; gate.sh quick green.

2026-09-22 (frankh-c0) — THE OBJECT-MODEL GATE WAS MINE AND IT IS MEASURED AWAY

I wrote the blocker this retires, in 8f0a...'s neighbour section and in this ticket's summary: the -O rule is unconditional on OUTPUT MODE, therefore --dce arriving at the default -O2 enacts one half of decide-a-is-a-pxx-object-a-self-contained-runtime-or-a-translation-unit silently. The first clause is true. The second does not follow, and I never measured it — I reasoned from the rule to the consequence.

Measured at tree fd6965890102, x86-64, --emit-obj, readelf -sW:

TU flag LOCAL FUNC exports UND export NAME sets
C TU pulling the runtime --no-dce 540 323 (312 WEAK FUNC) 0
same --dce 271 323 (312 WEAK FUNC) 0 IDENTICAL
leaf C TU --no-dce 489 2 0
same --dce 53 2 0 IDENTICAL

The pass removed 269 and 436 local bodies and not one export. That is the decide's own finding — "--dce does not move the export surface at all; the exports ARE the root set" — reproduced at HEAD, after today's CodeRef fix, on two TUs it did not use.

Why it follows that neither half is enacted

The bodies --dce removes are LOCAL. They were never linkable from outside and were never part of the object's external contract. So after the pass the object supplies exactly what it supplied before, and that is true under answer A (it supplies the runtime for something else) and under answer B (only for itself) alike. Nothing about the promise changes.

What WOULD change it is model B's re-rooting at the TU's own two exports — 298 of 307 weak exports vanishing. The decide had to SIMULATE that off the object's relocations precisely because --dce does not do it. Promoting an -O level does not do it either. A third attempt therefore states in its commit that it enacts neither half, and the fork stays open and untouched.

Scope, and one row I am NOT counting

x86-64. The decide flags a Pascal/xtensa object as the gap most likely to matter, so I tried it: exports came out identical — and --dce dropped zero bytes on that TU (390 B both), so the row cannot tell a preserved export from a pass that did nothing. It is the "would this still pass if the machinery did nothing at all?" case and it is vacuous. The x86-64 rows are the evidence; the xtensa question is still open and wants a TU with something to remove.

What would retire this section: an --emit-obj TU on any target where --dce demonstrably removes bodies AND an export disappears. That has not been observed on any target.

2026-09-22 (frankh-c0) — LANDED at 39ca6ac2a, and what is left is the mechanism this ticket is NAMED for

The promotion is in. This ticket is not done, and the remaining half is the one its title is about: PasApplyDefaults defines PXX_MANAGED_STRING unconditionally, so every Pascal program still PULLS builtinheap. DCE removes the CONSEQUENCE, not the pull. frankS scoped that half on 2026-09-18 (-98% code on bare esp32c3).

THE BLOCKER I HANDED ON HERE WAS THE WRONG SHAPE, and frankb-8e answered it rather than inheriting it (2026-09-22, banked b823a08d6, not landed). string lexes as tkString_T, a KEYWORD token (paslexer.inc:166), so the tkIdent/CaseEqual arm the ticket pointed at could never have seen it — adding the name reaches nothing.

The real blocker is ORDERING and no token scan can reach it. EmitAnsiStringRuntime is decided at pasparser_prog.inc:1977, while the ambient RTL units that are themselves written over managed strings are pulled after it (:2163, plus a second independent site at pasparser_proc.inc:7422). The evidence arrives after the decision. Three programs containing no string at all still refuse inside lib/rtl/textfile.pas:1344, each because it pulls a unit whose BODY calls a string stub:

var p: PChar;      var c: WideChar;      var s: set of Char;

The fix is to settle emission after the unit pulls, or to emit on demand. Whoever takes this next takes THAT; the size work below is finished.

Worth noting how the handoff failed, since it is this file's own class: the blocker was relayed from a scoping note, read as a task ("add the name to the scan"), and was really a claim ("the scan is where this is decided"). I passed it on without checking which. One grep of the lexer settles it in a line.

What the third attempt actually changed

attempt 2 attempt 3
FAILs 6 2
of those, attributable to the promotion 4 0

The four that were ours: two correctness regressions (frankb-8e, 5fccc890a), two test-emit-obj t Hidden control arms (pinned to --no-dce, reason inline), and one wasm32 row (carved out). The remaining two are pre-existing and independently recorded — see the summary.

Three things that were believed and turned out false when measured

Recorded together because they are one shape, and two of the three were mine.

  1. "Promoting enacts half the object-model fork." Mine, reasoned from the -O rule to a consequence and never measured. --emit-obj is export-preserving: 323 exports kept, 312 WEAK FUNC, identical name sets, UND 0, while 269 LOCAL bodies go. Locals were never part of the contract.
  2. "The --emit-obj default is refused because --dce segfaults GNU ld on an IRAM object." A hazard block in compiler.pas. Its ticket was in done/ and all four links come back rc=0. Retired at ec8ce7192.
  3. "wasm32 needs a carve-out because the harness reaches bodies by export name." frankb-8e's, written from a prediction. The mechanism was already settled in dce.inc:799 and is deliberate — and the carve-out is right for a BETTER reason: a wasm module's exports are how a host consumes it, so default-on breaks the target's normal usage, and --no-dce on those rows would have hidden that.

All three were safety or consequence claims, and a safety claim is only ever exercised by someone doing the thing it forbids. Believing one produces no signal, which is why all three survived. The general form is in debugging-playbook.md; what belongs here is that a promotion attempt is an unusually good instrument for finding them, because it is forced to read past every refusal in its path.