← board

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

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 LengthLength 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.

  1. 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", turning load p into lea p and 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.

  2. 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