← board

A wide string literal should be a static block, not a runtime transcode

What is paid today

7c lowers w := 'abcd' to PXXWideFromStr('abcd') — a call and a heap allocation on every evaluation, including inside a loop. The narrow equivalent s := 'abcd' costs neither: InternStr lays down a complete static managed block in the data section ([meta][rc][len] at handle−24/−16/−8, the refcount born saturated), so the assignment is a pointer store.

There is no reason the wide literal cannot be the same object with different bytes. The transcode is compile-time work being done at runtime.

Why 7c did not do it

InternStr computes the ASCII answer from the bytes and stamps it unconditionally:

meta := MSTR_KIND_LEGACY or MSTR_FLAG_STATIC or MSTR_FLAG_ASCII_KNOWN;
if (orAll and $80) = 0 then meta := meta or MSTR_FLAG_ASCII;

For a UTF-16 'abcd' the bytes are 61 00 62 00 … — no byte ≥ $80 — so it would set ASCII. builtinwide.pas refuses to, and says why:

No ASCII flag is stamped. PXX_FLAG_ASCII means "no byte >= $80", which for UTF-16 is true of any ASCII text and says nothing useful, while PXXStrAsciiCached's contract is about BYTE positions equalling CHARACTER positions — false here for every string.

So a folded literal and a runtime-built wide string — the same kind of object — would disagree on a flag, with ASCII_KNOWN set so no consumer rescans to discover the truth. That is a silent divergence between two producers, in the direction that skips the check.

It is unreachable today for a Pascal WideString, because Pascal's indexing lowers off the element type and never consults the flag; only NilPy's character-position machinery reads it. That is not a reason to ship it. It is the exact shape of the UFldStrElemTk := Ord(tyChar) comment that 7b proved wrong — "tyChar is the only thing reachable today" was true when written and false the moment a reader existed.

What it needs

  1. A wide-aware intern path — a separate entry point, not a parameter: InternStr has ~100 call sites.
  2. MSTR_KIND_WIDESTR in defs.inc, mirroring builtinheap.pas's PXX_KIND_WIDESTR = 5. (defs.inc documents that its copies of these constants are pinned to the runtime's by test_static_string_literals asserting behaviour rather than values — extend that test.)
  3. The 2-byte NUL terminator. InternStr writes one explicit NUL then pads to 8; a wide length is always even, so len+1 is never 8-aligned and at least one pad byte always follows. True, but currently by accident — state it, the way that function already states its alignment property rather than inheriting it.
  4. IRStrWidthConv grows a literal arm; the runtime path stays for the variable case, so this is a constant-fold on top of a correct mechanism and not a second mechanism.

Watch out

InternStr dedupes by byte value. A wide 'ab' is 61 00 62 00; a narrow literal containing those bytes needs #0 in the source, which is exotic but legal — and the two would then share one entry and one meta word, whichever producer got there first. Either key the table on width as well as bytes, or prove the collision unreachable and say so.

Gate

make test + self-host byte-identical, test_widestring_lowering and test_widestring_surrogate_pair unchanged, test_static_string_literals extended to the wide block, and a loop benchmark showing the allocation gone.

READ FIRST — this introduces a THIRD value kind at seven unguarded sites

Raised by frankwasm, 2026-08-30, so the next agent here does not rediscover it.

ir.inc decides "does this parameter want an owning managed-string temp?" at seven sites, with zero guards — one concept, seven copies, no shared predicate. See [[bug-a-managed-string-arg-temp-predicate-is-duplicated-seven-times-and-guarded-nowhere]] [A p20], which carries the full site list (four argIsManagedTemp predicates at 11060 / 11305 / 11813 / 12931, seven AllocVar('', tyAnsiString) at 11069 / 11360 / 11532 / 11704 / 11831 / 12825 / 12951) and the condition that the five call paths be measured before anything is edited.

Why it lands on this ticket specifically: a static block is neither a normally-refcounted heap handle nor the existing static literal — a third kind of value reaching all seven. The neighbouring safety argument for leaving ParamWantsManagedStrTemp alone (frankB, with the literal-argument fast path, 849 → 84 ms) is "managed→managed on a saturated refcount is a no-op" — and saturated is a property of the object, not of the path. So the duplication is latent only until this ticket or that fast path widens.

That is a note, not a blocked-by: — it does not gate this work, it changes what you must read before starting it. The p20 on the sibling is about reach today, not about difficulty or importance.