PShortString derefs a ShortString at the wrong prefix offset
Measured
var s: ShortString; p: PShortString;
begin s := 'hello'; p := @s; WriteLn('[', p^, '] len=', Length(p^)); end.
pxx fpc
WriteLn(p^) ~1000 NUL bytes hello
Length(p^) 5 5
t := p^; WriteLn(t) hello hello
SizeOf(s) 263 256
The copy path is CORRECT and only the direct write is wrong, which is what
makes this survive a casual test. t := p^ reads the chars from offset 8 and
gets them right; WriteLn(p^) does not.
Raw bytes of s confirm the storage: byte 0 = 5, byte 8 = 104 ('h'). That is
the tyFixedString layout, an 8-byte NativeInt length word followed by chars.
Cause: two names for one type disagree about its prefix
ShortStringas a TYPE NAME resolves totyFixedString(255 cap, cap+8 = 263). Nothing here is a shortstring in the classic sense.PShortStringsets its POINTEE kind totyShortString(pasparser_lval.inc:6742), whoseFrozenStrSlotSizerule is cap+1.
So the deref believes the chars start at offset 1 and they start at offset 8.
Length survives because it reads the length word, which both conventions put
at offset 0 — it just disagrees about the width, and the low byte is the same
on a little-endian target for any length under 256. That is why the bug reads
as half-working, which is the shape that survives review.
Why this is worth more than one wrong WriteLn
tyShortString is nearly unreachable today — ShortString does not produce it
and string[N] does not either. The producers are PShortString, the
TypeInfo('shortstring') name arms (pasparser_expr.inc:4606,
pyparser.inc:46449) and RTTI naming. PShortString is the one that yields a
VALUE, and it is already wrong.
That makes it a ready-made positive control for [[feature-p-implement-the-real-tyshortstring-byte-prefix-layout]]: step 1 of that plan is an audit for sites that assume an 8-byte prefix without going through the named emit pair, and this is one, reachable from four lines of Pascal. A control that already exists beats one that has to be built.
It also falsifies, mildly, the reading that steps 1-2 of that plan touch nothing
live: they change tyShortString codegen, and there is a live path into
tyShortString today. It is not a path that carries a string[N] CAPACITY, so
it does not disturb the capacity thread — see the note on
[[bug-p-a-string-n-element-loses-its-capacity-in-three-container-shapes]].
Gate
make test + self-host + cross. Assert the CHARS, not just Length — Length
is correct today and would certify this as working.
RESOLVED — the write path was the third reader, and it needed TWO fixes
Length(p^), p^ = 'hello', p^[1] and t := p^ were all correct in the same
binary while Write(p^) was not, because those four resolve the pointee through
PtrElemTk and the writer instead consumes the operand node as a bare ADDRESS.
-
x86-64: the AN_DEREF rvalue arm retags its address node with the frozen kind (correct, and the comparison path depends on it — removing it made
p^ = 'hello'answer FALSE). But on an IR_LOAD_SYM that tag also decides how the operand is COMPUTED: codegen reads a frozen kind on a load as "this symbol IS a frozen string, take its address", turningload pintolea pand handing the writer the address of the POINTER VARIABLE — a huge length read out of the pointer value, then NULs. The write path now asks IRLowerAddress directly, which for AN_DEREF already yields the pointer load. -
That alone fixed x86-64 and left aarch64, arm32, riscv32 and xtensa printing an EMPTY field under -dPXX_SHORTSTRING: those backends pick between PXXWriteFrozenW and its one-byte sibling PXXWriteFrozenBW via IRStrTkOf, which only consulted IRFrozenKindOfAddr when the node was ALREADY tagged frozen — so an honest pointer load fell through to the 8-byte helper. IRStrTkOf now resolves a tyPointer node too, taking the answer only when it comes back frozen. One substitution, all seven backends, no per-backend edit.
Verified against the FPC 3.2.2 oracle in both modes, and the wired test matches
its expected block byte-for-byte on all 12 configurations (4 native modes;
x86-64, aarch64, arm32, riscv32, xtensa x 2 modes). New drfw row. gate quick
GREEN, FPC seed canary PASS.
Log
- 2026-09-03 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 3b0f71ccd.