A wide string literal should be a static block, not a runtime transcode
- Type: feature — Track A, tagged O (optimisation).
compiler/emit.inc(InternStr),compiler/defs.inc(one constant),compiler/ir.inc(IRStrWidthConv's literal arm). - Deferred from
feature-unicodestring-modelstep 7c, deliberately and with the reason recorded there.
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_ASCIImeans "no byte >= $80", which for UTF-16 is true of any ASCII text and says nothing useful, whilePXXStrAsciiCached'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
- A wide-aware intern path — a separate entry point, not a parameter:
InternStrhas ~100 call sites. MSTR_KIND_WIDESTRindefs.inc, mirroringbuiltinheap.pas'sPXX_KIND_WIDESTR = 5. (defs.incdocuments that its copies of these constants are pinned to the runtime's bytest_static_string_literalsasserting behaviour rather than values — extend that test.)- The 2-byte NUL terminator.
InternStrwrites one explicit NUL then pads to 8; a wide length is always even, solen+1is 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. IRStrWidthConvgrows 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.