← board

A string-alias cast over a Pointer slot is treated as a no-op, so the expression stays a pointer

The two faces, both measured 2026-09-05 against fpc 3.2.2 -Mdelphi

type t = AnsiString;
var p: Pointer;
begin
  t(p) := 'abc';
  writeln(t(p));        { pxx: 4261104     fpc: abc }
  SetLength(t(p), 2);   { pxx: SetLength expects a string variable in IR codegen
                          fpc: compiles, prints ab }
end.

The silent one is the dangerous one and is not what the pascal-script ticket describes: it says the wall is SetLength. A pointer printed as a number is a plausible-looking value, and this idiom appears 93 times in uPSCompiler.pas alone — it is how that codebase stores every string.

The boundary, varied rather than assumed

operand of the cast SetLength(t(...), 2)
var s: AnsiString works
p^ where p: ^AnsiString works
record field of type AnsiString works
var p: Pointer fails
Pointer field, reached directly fails
Pointer field through a record pointer fails

So it is not the indirection and not the record — it is that the underlying slot is Pointer-typed, which is exactly when the cast stops being a no-op and becomes a reinterpret.

Cause

pasparser_expr.inc, the C4 alias-cast arm (strAliasCast). It exits with LastExprTk := IntToTypeKind(ASTTk[CurASTNode]) — the OPERAND's own kind. Its comment says "a value-level no-op, NOT a pointer reinterpret. Keep the operand node and its own string kind", which assumes the operand is a string. The tyRecord arm directly below is the model for the other case: it retypes the node rather than returning it unchanged.

This is the third face of the cause 9339d6661 fixed the second of ("a cast to a string alias no longer drops the index that follows it"). Same arm, same assumption, one operand type over.

A retype-only fix was tried and REVERTED — this is the part worth reading

Making the arm retype the node when the operand is not already a string (keeping the no-op when it is, since that is load-bearing for Pos(tbtstring(' '), s)) is the obvious change and it is not sufficient:

And the store is already correct: t(p) := s; p = Pointer(s) is TRUE, and Length(s) is 3. So the value in the slot is right and every READ path is wrong. The fix therefore spans the IR lowering as well as the parser.

CORRECTED 2026-09-05, same day, asked for by frank-optimize before it took this ticket. This paragraph first said the lowering "re-derives the type from the SYMBOL rather than from the node", and called it the same durable-column seam as the pointee-element bug. That was a hypothesis written from the retag not helping, and the code does not support it as stated. The refusal is ir_codegen.inc:10778, the final else of the specialId = 101 chain whose arms are if IRKind[val1Node] = IR_LEA then else if IRNodeIsFrozenStrAddr(val1Node) — it dispatches on NODE predicates, not on a symbol. The measured fact underneath is only this: retagging the AST node's ASTTk did not change the refusal, so whatever those predicates read, it is not that tag.

Five siblings, so a fix keyed on the x86-64 arm alone will pass a native gate and leave four targets refusing: ir_codegen386.inc:3284, ir_codegen_aarch64.inc:3246, ir_codegen_arm32.inc:2613, ir_codegen_riscv32.inc:2906, ir_codegen_xtensa.inc:3013, each with the same message under a target prefix.

The comment directly above that chain cites bug-a-setlength-is-refused-for-any-frozen-string-that-is-not-a-plain-symbol, and the IRNodeIsFrozenStrAddr arm is its fix. The case here is the next shape past it — a Pointer-typed slot rather than a frozen-string address — so that arm is the model and plausibly the place.

Reverted rather than shipped: a half-fix that turns a wrong number into an out-of-bounds read is not an improvement, and CLAUDE.md's rule is to bank the diagnosis and park it rather than microfix.

The diff itself was restored with git checkout -- and not stashed, which was a mistake — CLAUDE.md says park held work as a patch or a stash, and "reverted because it was worse" is still held work if the next taker wants it as a positive control. Reconstructed here so it is not lost: the arm's exit, today LastExprTk := IntToTypeKind(ASTTk[CurASTNode]);, became

strAliasOpTk := IntToTypeKind(ASTTk[CurASTNode]);
if TypeIsFrozenString(strAliasOpTk) or
   (strAliasOpTk = tyAnsiString) or (strAliasOpTk = tyString) then
  LastExprTk := strAliasOpTk
else
begin
  ASTTk[CurASTNode] := AliasTk[aliasIdx];
  LastExprTk := IntToTypeKind(AliasTk[aliasIdx]);
end;

mirroring the tyRecord arm directly below, which already retypes rather than returning the operand unchanged.

Both halves sit on DUPLICATED arms, and here are the counts

Neither number is in this ticket's cause section and both decide whether a green means anything.

Establish both before the fix rather than after — and make the CURRENT compiler fail on each arm you claim to have covered, because reading every hit of a name in the file that declares it is a weaker claim than it feels.

What a fix has to satisfy

  1. writeln(t(p)) prints abc; Length(t(p)) answers 3.
  2. SetLength(t(p), 2) compiles and yields ab.
  3. Pos(tbtstring(' '), s) still binds a string overload — the no-op path for a string operand must survive, it is why that arm exists.
  4. A frozen-string alias operand keeps whatever it does today; only the non-string operand changes.

RESOLVED 2026-09-06 (requirements 1, 3, 4) — requirement 2 SPLIT OUT, because it is not an alias defect

The fix is a ROUTE, not a new lowering, and the measurement that showed it took one probe

AnsiString(p) and String(p) over a Pointer slot have always been correct — they print abc and answer Length 3 on the unfixed compiler. Only the ALIAS spelling was wrong. So this is the same two-spellings-one-taught seam as the rest of this file's history, and the built-in path's own node is the answer: an AN_PTR_CAST with ASTIVal = -1, tagged with the cast's string kind. The alias arm now builds exactly that when the operand is a pointer.

That is also why the previous session's retype-in-place was worse than the bug: ASTTk is not what the lowering reads. The cast NODE is. The reverted diff was a correct diagnosis of "the tag does not help" and the wrong conclusion from it.

THE DISCRIMINATOR IS = tyPointer, AND not TypeIsAnyString BREAKS REQUIREMENT 3

Written the obvious way — "if the operand is not already a string, reinterpret" — this arm passes every pointer row and silently breaks Pos(tbtstring(' '), s), which is requirement 3 and the reason the no-op exists. The one-character literal ' ' arrives tagged tyChar, so a negative test sweeps it into the reinterpret and Pos answers nothing. Measured on a build that was otherwise green.

A char operand is a CONVERSION — the built-in String(c) has a whole AN_STR_FROM_CHAR arm for it. The boundary table in this ticket measured exactly one failing operand, a Pointer-typed slot, so that is the operand the arm changes. A wider negative test is not a wider fix; it is a wider blast radius wearing the same green.

THE STORE WAS NOT ALREADY CORRECT — the ticket measured one of its two shapes

This ticket recorded "the store is already correct: t(p) := s; p = Pointer(s) is TRUE". That is true for a string variable source and false for a literal:

t(p) := s;      Length(AnsiString(p))  ->  3            (as the ticket says)
t(q) := 'abc';  Length(AnsiString(q))  ->  1073741824   (not measured before)
AnsiString(q) := 'abc';                ->  3            (the built-in spelling)

Nothing at the call site distinguishes them. The variable form writes the live payload pointer, which happens to be exactly what a raw pointer store produces; the literal form does not. Fixed by routing the pointer-operand store to the same ParseCastAsLValueStore tail the built-in spelling takes — FinishCastAsLValueStore, split out rather than copied, because the C4 arm has already consumed ( expr ) by the time it knows the operand's kind.

The existing string-operand arm (TS(s) := 'z's := v, keeping refcounting) is untouched and sits directly above the new one.

Requirement 2 is now [[bug-p-setlength-over-a-string-cast-of-a-pointer-slot-has-no-lowering]]

SetLength(t(p), 2) and SetLength(AnsiString(p), 2) fail identically, so the alias is not in the cause and closing an alias ticket on it would have been closing it on a fix that had nothing to do with aliases. The parser drops the cast and hands the classifier a Pointer symbol; forcing the other arm answers SetLength expects an ARRAY variable instead, so neither classification has a lowering and it is an ir.inc job with five per-target twins. Probe reverted, binary sha back to b8985660920b byte-identical.

Landed

Log