← board

C imports a Pascal unit under a mangled name

Why

Pascal-into-C is an intended, existing capability — a C file today reaches Pascal routines through implicit, global, case-insensitive FindProc matching. What it lacks is not permission but a name.

That namelessness is the actual defect, and it is expensive:

Case sensitivity gives distinguishability; mangling gives identity. Both are needed — that distinction is the design's hinge, and it is the user's.

THE SPEC

1. The import site is an include

#include "math.pas"

Chosen over a #pragma deliberately: gcc tries to textually include Pascal source and dies loudly. An unknown pragma is silently ignored, producing a file that compiles elsewhere and behaves differently — the worst failure shape. Honest non-portability beats silent divergence.

2. Names are mangled, case preserved

<unit>_<ext>_<Identifier>math.pas exporting Sqrt gives math_pas_Sqrt.

3. Collisions are path-qualified

Two units named math under different -Fu roots mangle identically. Then the path participates: path_math_pas_Sqrt.

State this property in the user docs, because the file that breaks is not the file someone edited: the short form is valid only while the unit name is unique across the search path. Adding a second math.pas anywhere on the path invalidates every C file using the short form. That fails loudly at compile time as an undefined symbol, which is acceptable — but it is surprising unless documented.

4. Overloads resolve by the declared C signature — NOT a fork

Raised as a hard problem and dissolved by the user: it is not one.

extern double math_pas_Max(double, double);

The C prototype names the unit, the routine, and the signature, in syntax C already has. Resolve mathMax → the (Double, Double) overload by ordinary signature matching. Pascal and C scalar types map cleanly, so this is mechanical.

No suffix scheme. No _ii mangling. Nothing new to spell. A declaration that cannot discriminate (K&R extern double math_pas_Max();) is refused by name rather than guessed.

5. Non-mappable types are refused by name

Pascal types with no C spelling — AnsiString, sets, variants, open arrays — make a routine not importable. Refuse at the declaration site, naming the routine. Partial importability is expected and fine: a unit may export twelve importable routines and two refused ones.

AnsiString specifically, and the reasoning matters more than the rule (user):

Recorded, deliberately NOT built: a const AnsiString parameter is genuinely safe — the Pascal caller holds the reference across the call, so the refcount is stable for the call's duration and C sees an ordinary const char*. It is safe provided the library does not retain the pointer, which is unverifiable from our side. That makes it a future explicit opt-in, never a default. Written down so nobody re-derives it as a discovery.

6. The bare name is an EXPERIMENT, not a decision

Open question: should implicit cross-namespace binding be removed once the mangled name exists? Two mechanisms for one concept is the shape this repo's own rules warn about — the second path is the one that stays broken.

Do not decide this by reasoning. Measure it. cparser.inc:9448 defends the implicit bind on one ground: "lua's <math.h> sqrt/exp/sin/… resolve to the RTL math routines." That justification may be stale — lib/crtl/src/math.c now DEFINES exp (:282), log (:341), sin (:644), cos (:656), atan (:724) and sqrt (:965) in C, correctly-rounded, and CPullCrtlForPrototypes pulls that module when a C file declares those prototypes.

The experiment: delete the cross-namespace declaration bind and build the C corpus — lua, tcc, quickjs, zlib. If nothing breaks, it was never a breaking change and there is nothing to weigh. If something breaks, those failures ARE the spec for what must stay. Report the result; do not quietly keep or quietly remove it.

Sequencing

bug-c-definition-of-an-intrinsic-name-overwrites-the-pascal-routine (C, p55) lands first and is a hard prerequisite. It is correct regardless of every choice above: a C definition must never overwrite a non-C proc's body (cparser.inc:9401 finds it, :9558 overwrites BodyAddr). A definition claims this translation unit provides the function; that is a different act from a declaration, and only the declaration case has a justification.

It is also currently producing a silent wrong value out of the RTLSqrt(16.0) returns 42.0 when a C file in the same program defines sqrt, and math.Sqrt follows the hijacked entry, so the qualified spelling does not save you.

Gate

Track C's: C tests green + self-host byte-identical + cross. Land only green; incremental or behind a flag, never a long-lived branch. New C tests should cover: the short form, a path-qualified collision, an overload selected by prototype, and each refusal (AnsiString param, AnsiString result, K&R declaration) failing by name at compile time.

Provenance

Designed in conversation with the user, 2026-08-19, against measured code rather than recollection. The coordinator raised overloads as a fork (it is not) and initially argued the implicit bind should simply be deleted (premature — Pascal-into-C is intended and works; it is the naming that is missing). Both corrections are the user's and are why the spec has the shape it has.

Triage 2026-08-19 (Track D re-triage pass, pin v364)

Genuine feature, still wanted, unchanged — and still correctly blocked. Measured against v364, after the import/uses work landed:

#include "mymod.pas"
int main(void){ printf("%d\n", mymod_pas_Twice(21)); }
pascal26:1: error: stray token at top level (not a declaration): 'unit'

So #include of a .pas is still plain textual inclusion — the import site this ticket specifies does not exist, and nothing about it landed incidentally. Its blocked-by (bug-c-definition-of-an-intrinsic-name-overwrites-the-pascal-routine) was read as still open in backlog/ when this was written. It was not — that bug was resolved 2026-08-19 in eb5c7be11, which is in master. The edge was stale and has been cleared (2026-08-20); the ticket is back in ready for Track C.

Progress — 2026-08-19 (frank2-C)

Landed and self-host green: the #include "<unit>.pas" preprocessor marker (cpreproc.inc), the pass-1 marker handler that runs ParseUsesUnit, mangled lookup by FORWARD re-mangling out of Procs[] (never by splitting at _pas_, which is ambiguous in both directions), prototype-selects-the-overload, and the §5 non-mappable-type refusals.

Verified by hand against a scratch unit:

use.c       mymod_pas_Twice(21)                             -> 42
t_ovl2.c    extern double mymod_pas_Max(double,double)      -> 9.2   (picks the Double overload)
t_redecl.c  identical redeclaration                         -> 9.25
t_ovl.c     bare call to an overloaded name                 refused, names the fix
t_ovl3.c    two conflicting prototypes for one name         refused (previously: silent 0)
t_case.c    mymod_pas_twice                                 refused — case is significant
AnsiString parameter                                        refused by name
open/dynamic array parameter                                refused by name

Not yet exercised: the AnsiString RESULT refusal. The code is in place and takes the same path, but it cannot be reached today — ANY Pascal unit whose body touches a managed string dies at import with compiler error: call to a runtime stub that was never emitted, before the C side ever names the routine. That is a Track A gap in the C driver's stub emission, filed separately; the result refusal is testable the day it is fixed.

Still open from the spec: §3 path-qualified collisions, §6 the bare-name experiment (delete the cross-namespace declaration bind, build the C corpus, report), real test/ cases + Makefile recipe lines, and the missing-unit diagnostic still speaking Pascal (uses: unit source not found: nosuch) at a C author who wrote #include "nosuch.pas".

§3 and the test/ cases — 2026-08-20 (frank2-C)

§3 as specified is not implementable from Track C, and the reason is the finding

§3 says a collision is resolved by letting the PATH participate in the mangled name (path_math_pas_Sqrt). Measured before writing any code, against pin v367: two files, r1/math.pas and r2/math.pas, both unit math, Twice returning x*2 and x*3.

#include "r1/math.pas"
#include "r2/math.pas"
math_pas_Twice(21)   ->  42        (r1's answer; the author asked for r2's 63)

The second include is a silent no-op. CompiledUnits (defs.inc:2418) is keyed on the unit NAME, so the loader sees math already compiled and returns without reading r2 at all. A routine present only in r2 does not merely resolve to the wrong body — it falls through to the "crtl does not define ..." warning and dies at link with undefined symbol.

So the two units never COEXIST, and a path-qualified mangled name would denote a unit that was never loaded — there would be nothing for it to resolve to. Making unit identity the FILE rather than the NAME is a change to the shared unit table: Track A ground, and already an open question there (decide-one-answer-to-have-i-already-compiled-this-unit, which parser.inc's own Python-module dedup cites). Not edited under C, per the lane rule; escalated here rather than guessed at.

What landed instead

CCheckPascalUnitCollision (cparser.inc, above CParsePascalUnitMarker): turn the silent wrong value into a refusal that names both files.

pascal26:9: error: two Pascal units are both named 'mymod':
  'test/cpasunit/mymod.pas' was imported first and 'test/cpasunit2/mymod.pas'
  cannot also be imported -- a unit's identity is its NAME here, so the second
  include would be silently ignored and every mymod_pas_* would resolve to the
  first. Rename one of the units

Keyed on the RESOLVED path run through NormalizePath, so the same file included twice — including a spelling that differs only by a ./ — stays allowed. Only when the include spelling is a PATH: a bare #include "math.pas" is answered by the loader's -Fu search and this side never learns which file won, so it records nothing and compares nothing. Reuses CompiledUnitFile (defs.inc:2429) for its documented meaning ("the file this compiled unit resolved to"); it was simply only written on the Python path until now. Nothing declared, no shared structure changed, no parser.inc / pyparser.inc edit.

Limit, stated plainly: this refuses the collision, it does not resolve it. Two same-named units still cannot be used from one program. That resolution is the Track A question above.

A real bug found by writing the tests

#include "cpasunit/mymod.pas" from test/c_pasunit.c looked for test/test/cpasunit/mymod. The marker handler prepends the includer's directory (C's rule), and the unit loader then prepends CurUnitDir again (parser.inc:33693) — the same directory twice. It went unnoticed because every hand-verification so far compiled the C file by an ABSOLUTE path, which takes the loader's name[1] = '/' branch and skips the second prepend. The first repository-relative test found it immediately.

Fixed in CParsePascalUnitMarker: climb back out of CurUnitDir with one .. per segment and let NormalizePath collapse the pair. An include written in a header in another directory still lands where the C author meant.

test/ cases + Makefile recipe lines

test/cpasunit/mymod.pas (Twice, and Max overloaded on Integer/Double), test/cpasunit2/mymod.pas (the same unit NAME, Twice returning x*3), test/cpasunit/strmod.pas (an AnsiString-bearing routine beside an importable one). Nine recipe lines in test-core, beside the existing test_c_* block:

test covers
c_pasunit.c the short form (42) + the Integer overload picked by prototype (7)
c_pasunit_ovl.c the Double overload of the SAME Pascal name (9.25)
c_pasunit_twice.c one file included twice, once spelled ./ — allowed
c_pasunit_collide_fail.c two files, one unit name — refused, names both
c_pasunit_case_fail.c mymod_pas_twice does not reach Twice
c_pasunit_ovl_fail.c bare call to an overloaded name — refused, names the fix
c_pasunit_knr_fail.c K&R extern double mymod_pas_Max(); — refused
c_pasunit_two_overloads_fail.c two overloads in one .c — refused
c_pasunit_ansistring_fail.c AnsiString parameter — refused by name

The AnsiString RESULT refusal is still unreachable for the reason recorded in the previous section (a unit whose body touches a managed string dies at import in the C driver — a Track A stub-emission gap), so it has no test.

Gate: make compiler/pascal26 (converged, 1 round) + all nine recipe lines hand-run green + tools/gate.sh quick GREEN. Not pinned — the pin is the coordinator's.

Still open

Parked to unfinished/ — 2026-08-20 (frank2-C)

Everything in this ticket is landed and green except §6, and §6 is blocked on permission the owner has to grant (PXX_ALLOW_FULL_SUITE=1 for the four corpus builds; see "Still open"). Two smaller items are blocked on Track A holding parser.inc: the collision RESOLUTION (refactor-a-one-resolved-file-identity-for-a-translation-unit, raised to p60 carrying the Twice(21) = 42 measurement) and the C-flavoured missing-unit diagnostic.

So the ticket is not being actively worked and should not hold the working/ lock. Moved to unfinished/. Nothing here is half-applied: the compiler change is complete, self-host green, and pushed.

2026-08-30 — RE-MEASURE (triage only, nothing applied): PARTIALLY DISSOLVED

Checked as part of a pass over parked tickets whose park names another ticket as its resume condition. Two of this park's three stated blockers no longer hold.

1. The collision-resolution blocker is gone. The park says two items are "blocked on Track A holding parser.inc", one of them refactor-a-one-resolved-file-identity-for-a-translation-unit. That ticket resolved 2026-08-20, commit 883ef0c05 — the same day this park was written. Ten days unread.

2. The file it names no longer exists, and the lane it names is wrong. compiler/parser.inc was sliced into the pasparser_*.inc set on 2026-08-20. The missing-unit diagnostic this ticket wants to change is now at compiler/pasparser_proc.inc:3504 and :4103 — a Track P file, not Track A ground. So "blocked on Track A holding parser.inc" is stale on both counts: the file and the owner. The concern under it still stands and is unchanged — a pre-check in cparser.inc would have to duplicate the loader's case-insensitive + .pp + -Fu search — but that is a design argument, not a lane conflict, and it is now a Track C ↔ Track P conversation.

3. §6 is still genuinely blocked, and on the one thing no agent can clear. It needs PXX_ALLOW_FULL_SUITE=1 for the four corpus builds, which is the user's to grant. The park is right that a coordinator cannot supply it and that hand-running the recipe bodies would be reshaping a denied command; that reasoning is unchanged and is restated here so the next reader does not retry it. This is the item to put in front of the user — the park's own estimate is one deletion plus four corpus builds, an afternoon for whoever holds the escape.

Re-priced: from "three blockers, two of them cross-lane" to one blocker that is a user permission grant, plus a Track C ↔ P design conversation that was previously mis-recorded as a lane lock. Nothing here is waiting on Track A.

Nothing applied. Measured at HEAD; triage pass, not work.

2026-08-30 — RE-CLAIMED and RE-MEASURED at HEAD. One item left, and it is the user's.

frankC, resuming from unfinished/. Re-claimed before the first commit per CLAUDE.md's resume rule. Compiler aa78a7faf63amake compiler/pascal26, converged after 1 round, sha distinct from pinned, so the fixedpoint was actually proved rather than skipped by an up-to-date no-op.

The landed feature is green — 11 of 11, run individually, not as a suite

test result
c_pasunit (short form 42 + Integer overload 7) PASS
c_pasunit_ovl (Double overload, 9.25) PASS
c_pasunit_twice (same file twice, one spelled ./) PASS
c_pasunit_collide_fail PASS
c_pasunit_case_fail PASS
c_pasunit_ovl_fail PASS
c_pasunit_knr_fail PASS
c_pasunit_two_overloads_fail PASS
c_pasunit_ansistring_fail (parameter) PASS
c_pasunit_ansistring_result_fail (result) PASS
c_pasunit_strings (differential vs the Pascal driver on the same unit) PASS

Two items this ticket still lists as open are DONE, and neither was recorded here

1. The AnsiString RESULT refusal is reachable, tested, and green. This ticket says twice that it "cannot be reached today" and "has no test", because any unit whose body touched a managed string died at import with "call to a runtime stub that was never emitted". That was fixed in 6b26c38e8fix(A): a Pascal unit included from C is compiled as Pascal, shims and all — which also landed test/c_pasunit_ansistring_result_fail.c and its Makefile recipe.

2. A differential test exists that this ticket never asked for, and it is the strongest one in the block: c_pasunit_strings.c imports a managed-string unit from C while test_c_pasunit_strings.pas compiles the same unit through the Pascal driver, and the two outputs are diffed. The oracle is the other driver, so there is no expected string a future regression could be edited to match. It caught a second defect at the time — CProgramMode left on while parsing the imported unit, so a Pascal literal in a concat was adjusted +8 into a char* and Length('ab' + 'cdef') returned 3.

Both landed 2026-08-29 under a Track A bug ticket, so this ticket's own "Still open" list was never updated. Ten days of this ticket's stated scope was already closed by someone else's fix.

The last non-§6 item is now filed separately, and it is worse than recorded

The missing-unit diagnostic: this ticket describes it as "still speaks Pascal". Measured, it does three further things, and one of them is not wording —

#include "nosuch.pas"        <- line 1
int main(void){return 0;}    <- line 2

pascal26:2: error: uses: unit source not found: /abs/path/nosuch
  near: __pxx_pascal_unit /abs/path/ nosuch.pas  >>>  main

It reports line 2. Consistently include-line + 1 (verified at lines 1 and 5), because CParsePascalUnitMarker consumes the marker's tokens before calling ParseUsesUnit, so the raise sees the next line. The line it names is valid user code, which is the expensive kind of wrong. It also leaks the internal __pxx_pascal_unit marker into the near: context.

Filed as bug-c-a-missing-pascal-unit-diagnostic-points-at-the-wrong-line-and-leaks-an-internal-marker [p30]. I checked for a Track-C-only fix rather than inheriting the previous session's conclusion, and there isn't one — the previous reasoning ruled out C refusing on its own pre-check, but not C checking merely to reword; that escape fails too, because the loader's failure is fatal at the raise site with nothing to intercept. The fix is a ParseUsesUnit signature change in pasparser_proc.incTrack P's file. Not edited under C.

So the whole remaining scope of this ticket is §6

Everything in §1-§5 is landed, tested and green at aa78a7faf63a. §3's resolution stays out of scope by the 2026-08-20 finding (two same-named units cannot coexist; identity-is-the-name is Track A ground and that ticket resolved in 883ef0c05) — what this ticket owes for §3 is the refusal, and the refusal is green.

§6 is blocked on a user permission grant and on nothing else. It is one deletion (cparser.inc:9448, the cross-namespace declaration bind) plus four corpus builds — lua, tcc, quickjs, zlib — and those builds are make test-lua and friends, refused for this lane by .claude/hooks/no-full-suite.sh rule 1. The escape is PXX_ALLOW_FULL_SUITE=1 and only when the user has asked for it. A coordinator cannot supply it and hand-running the recipe bodies to dodge the refusal would be reshaping a denied command.

Put to the user 2026-08-30. The spec is emphatic that this is measured and not reasoned ("Do not decide this by reasoning. Measure it... If something breaks, those failures ARE the spec for what must stay"), so there is no defensible way to close §6 from the armchair, and no way to run it without the grant. Ticket stays in working/ only while that question is live; if the answer is "not now", it goes back to unfinished/ with §6 as its sole open item.

§6 ANSWERED BY THE USER, 2026-08-30 — and the answer re-reads the experiment

"we deliberately split math and do not want to fall back to pascal functions. because some make different assumptions. iirc, like round() returning int or float, or other details. hence we said — we totally re-implement math for both languages, there is no fallback. PAL stays a shared layer though."

This is not the experiment's result; it is a prior design decision the experiment did not know about, and it OUTRANKS the experiment.

What it changes — the meaning of a red corpus, which is the whole point

§6 as written says: "If something breaks, those failures ARE the spec for what must stay." That instruction assumed the bind was value-neutral plumbing, so a breakage would be evidence the bind was load-bearing. Under the user's ruling it is not, and the reading inverts:

corpus result §6's original reading the reading now
nothing breaks the bind was never load-bearing; delete it same — delete it
something breaks that failure is the spec; restore the bind for it crtl is MISSING a C implementation; write it. The bind does not come back.

A C program reaching a Pascal math routine is the defect, not the feature — the two are deliberately separate implementations with different assumptions (round()'s return type is the user's example), so a silent fallback is a silent wrong value, not a convenience. Restoring the bind to fix a breakage would re-introduce exactly the coupling the split exists to prevent.

Recorded because a future agent reading a red tstate would otherwise do the wrong thing with it, and would be able to cite §6's own words while doing it.

Scope of the ruling: math, not everything. The user's words are about the math split. The deletion below is broader — it drops the cross-namespace bind for any differently-cased Pascal twin. Nothing in the ruling contradicts that (and §6 already specified the broader deletion), but if the corpus reds land somewhere that is not math, that is a case the user has not ruled on and it goes back to Track U rather than being decided from this paragraph.

PAL stays shared — explicitly unaffected. This change touches CParseCDeclOrDef's name binding only.

§6 LANDED — the deletion, and what it does NOT prove

One token: if isDefinition then procIdx := -1procIdx := -1, in cparser.inc's RUNG 0. The branch only ever runs when Procs[procIdx].Name <> name, i.e. a Pascal twin spelled differently, so identical spellings are untouched — crtl's deliberate redefinitions (malloc/memcpy/strtod and dozens more) must keep overwriting the one entry or a C malloc and a Pascal malloc would be two allocators in one program.

It subsumes the two rungs below it (arity mismatch, float-class mismatch): both only fire when Name <> name, which now never binds. They are left in place rather than deleted — dead by construction, but deleting them is a second change and this one must stay revertible as a single token.

The differential, and it is a NEGATIVE result worth more than a green tick

Both arms built at one HEAD, stash → build → measure → pop → build → measure, per the provenance discipline slice 1c paid for:

pre  aa78a7faf63a (deletion stashed)     post fd1bd8abae8a (deletion applied)
sqrt sin cos exp log atan round(2.5) round(-2.5) floor(-2.5) ceil(2.1)
pre vs post: IDENTICAL      post vs gcc oracle: MATCHES-GCC
MATH-BIND-DIFFERENTIAL-COMPLETE

So the bind was already dormant for math, and the deletion fixes no live wrong value. CPullCrtlForPrototypes pulls crtl's C implementations — math.c defines sqrt (:965), exp (:282), log (:341), sin (:644), cos (:656), atan (:724) — and those win before the fallback is ever consulted. The user's round() hazard is real but latent: it bites only for a routine crtl does not define in C.

That makes this a guard against future drift rather than a bug fix, and saying so matters. The tempting write-up — "deleted the fallback, math now correct" — would be a wrong root cause of the flattering kind, claiming a fix for a behaviour that was already correct.

Gate

make compiler/pascal26 converged after 1 round, fd1bd8abae8a, distinct from pinned. tools/gate.sh quick GREEN (read from the log's own gate: GREEN line, not a wrapper's exit code — slice 1b's lesson). The 11 c_pasunit rungs plus quick_canary_c: 12/12 PASS. Ten C math calls byte-equal to the gcc oracle.

The corpus verdict is Seven's, not mine — the user's call, and it is the repo's own division of labour: breadth is Track T's, run against the exact sha. The previous park recorded §6 as blocked on a PXX_ALLOW_FULL_SUITE=1 grant; that was wrong, and inherited rather than checked. test-lua, test-zlib and test-cjson are in T's limited and full tiers already.

But the corpus §6 names is not the corpus that will run. Of lua, tcc, quickjs, zlib, only lua and zlib are in any tier: test-quickjs exists and is enrolled nowhere, and test-tcc does not exist at all. Filed as task-t-the-c-corpus-is-two-rungs-not-four-and-a-missing-tree-reports-pass [T p45], which also carries the sharper finding — test-quickjs self-skips exit 0 when its tree is absent, so enrolling it alone would report green coverage on every box that has not fetched the tree.

So §6 is landed but not yet fully measured, and the gap is named. Lua and zlib will answer for themselves on the next sweep of this sha; quickjs and tcc cannot answer until T's ticket lands.

Log