The Journey of Building PatLang, Continued Once More Still: Retiring the Last External Dependency
For new readers
This is one instalment in an ongoing, chronological diary of building PatLang, written up in numbered "Acts" as the project actually happened, warts included. You don't need to have read the earlier instalments to follow this one, but it helps to know the shape of the previous gap: the native x64 backend generates real machine-code instructions as NASM-syntax text, but for every actual build, that text still had to be handed to the real, external nasm assembler and gcc linker to become a runnable .exe — the one remaining place a genuinely self-hosted compiler still depended on tools written in someone else's language. A separate, self-hosted PatLang assembler and linker already existed for this, mostly finished, sitting behind a switch defaulting to off. This instalment is the story of finally trusting that switch, and what turned up once real programs were pushed through it hard.
A direct continuation of the previous instalment (Acts LIX-LXV: closing the GOAP-unification research question, a bidirectional search engine for example-driven synthesis, and a hard look at what "smallest satisfying program" doesn't capture) — though, as with several earlier splits, not a continuation of its subject. This arc returns to the native x64 backend last covered in full in the sixth instalment, picks up a switch that had been sitting deliberately off since then, and ends with it genuinely on: a real correctness bug found and fixed along the way, a second, more severe one found only because the switch's first "it works now" moment was treated as a starting point for harder testing rather than a finish line, and an honest number for what turning the switch on actually costs.
Act LXVI: a bug that made every string comparison lie
The starting point was concrete: flip x64_build_use_native_toolchain() from false to true, rebuild, and see what breaks. Two real assembler-encoding bugs turned up almost immediately and were fixed cleanly — a variable-count shift instruction (shl reg, cl) that had always been mis-encoded as a fixed shift-by-one, and a negative 64-bit immediate whose two's-complement encoding briefly needed to compute a value one bit past what a signed 64-bit integer can represent as an intermediate step. Both were the kind of bug a small, targeted regression fixture catches and keeps caught.
What came next was much stranger: rt_str_eq("abc", "abc"), called directly, returned false. Not sometimes — every time, for any two identical strings, anywhere the runtime needed a string comparison. print("value is " + 5) printed "0" instead of "value is 5", because the dispatch code that decides "is this a string?" was itself built on the same broken comparison. This is precisely the class of bug most likely to be missed by a normal test suite, because it doesn't crash and it doesn't look wrong in isolation — it looks like a completely different feature (string concatenation, type dispatch, printing) is broken, and each of those symptoms leads the investigation somewhere else first.
Three successive, structurally different attempts at fixing what looked like a local-symbol-scoping problem in the linker each turned out to be real, worth keeping, and insufficient on their own. The actual root cause was found by chasing the runtime's own TRUE constant — a specific 64-bit tagged value — and discovering it was completely absent from the compiled binary, appearing nowhere despite being written eighty-four times in the generated assembly source. The assembler's own decimal-literal parser, it turned out, was quietly routing every large integer literal through a 64-bit floating-point conversion internally, which silently rounds off the low bits of any value needing more precision than a double can hold — and the runtime's own TRUE constant happened to be exactly one such value, one bit different from its own FALSE counterpart. Every time that literal was assembled, it silently became FALSE. Every string-equality check that should have returned true, returned false, because the constant meaning "true" had never actually been correctly written into the binary in the first place. Fixed by parsing decimal digit strings with plain, exact integer arithmetic instead of ever routing through a float. A brief detour along the way tried using a runtime helper meant only for the compiled target program's own memory layout, inside the assembler's own very different, differently-represented compiled body — caught immediately by the loudest possible signal, a trivial print("hello") program failing to compile at all, rather than a subtle wrong answer. Lesson: a bug that manifests as several apparently unrelated symptoms — wrong string concatenation, wrong type dispatch, wrong printed output — is worth actively hunting for a single shared root cause before treating each symptom as its own separate fix; here, all of it traced back to one constant, silently corrupted at the exact moment it was written into the binary.
Act LXVII: "we keep finding old bugs" — and one more, worse one
With the string-equality bug fixed and the daily regression suite green, the natural next step would have been to call the switch flipped and move on. The actual next instruction was different: "craft some new examples to push the boundaries a bit to make sure everything really does still work (the test suite is not as complete as it should be, after all, as we keep finding old bugs...); and we could do some timing tests too to see how good the new system is compared to the old." Three new stress-test programs went in — string-equality edge cases, numeric-boundary arithmetic, and a combination of lists, recursion, and closures — deliberately reaching past what the existing fixtures happened to cover. Two passed cleanly. The third gave a wrong sum for a two-hundred-element list of squares. A separate probe of an existing, previously-working BigInt demo, run again purely as a sanity check, came back with numbers that were close in shape to the right answer but wrong in every digit past the first few.
This was a second, independent, more serious bug — one that produced no crash and no error, just quietly wrong arithmetic, and it took a genuinely long stretch of dead ends to find. Four separate, increasingly precise standalone test programs, each built to match the suspected broken function's structure more and more exactly, all came back correct. A user pushback along the way sharpened the investigation rather than derailing it: told the leading suspect was a NASM-style local label (a jump target scoped, by convention, to whichever ordinary label precedes it), the direct question came back — "this is the native build you have found a problem in but say it is to do with the dot-labels which sounds like it is nasm related not the native generation of actual binaries, surely?" — a fair challenge that forced a precise answer: the labels in question are NASM syntax, appearing in generated text that both the real nasm and this project's own self-hosted assembler consume identically; the bug candidate was specifically in how the self-hosted assembler resolves that syntax into real bytes, nowhere near the actual external tool. The dot-labels turned out to be a complete red herring anyway.
What actually broke the case was abandoning guesswork for a direct, decisive comparison: assemble the exact same generated assembly text with both the real, trusted nasm and the self-hosted assembler, disassemble both resulting binaries, strip away the differences that are expected and harmless (different but equally valid choices of instruction encoding, raw addresses that only matter after linking), and diff the two normalized instruction streams against each other directly. Out of over seventy thousand instructions, the diff surfaced exactly one kind of divergence: a three-term multiply-by-immediate instruction, i * 8, correctly encoded by the real assembler and silently reduced to i * i by the self-hosted one — the assembler's own instruction dispatcher had only ever handled multiplying two registers together, or a single register by itself, and never learned the third form, a register multiplied by an immediate constant, at all. Every array-element and struct-field address computed as base + index * 8 anywhere in the entire compiled runtime had been silently wrong the whole time this backend existed — corrupting BigInt arithmetic, list indexing, anything built on that one common addressing idiom. The exact wrongness could be predicted by hand before looking at the actual broken output, and it matched to the byte: with the stride bug, writing a value at what should have been byte offset twenty-four instead landed at byte offset seventeen, overlapping and corrupting whatever had already been written next to it. Lesson: when two independent implementations of the same well-specified thing disagree, and small hand-built repros keep failing to reproduce the disagreement, stop trying to guess the trigger condition and instead directly diff both implementations' actual output for the real, full-scale input — a technique that needed no theory about which code path was broken, just a mechanical comparison that pointed at the one true divergence immediately.
Confirming the fix meant a full run of the growing stress-test suite, a fresh benchmark, and a fair timing comparison — native assembler-and-linker against the external tools it was built to replace, both starting from a fully cleared build cache so neither had an unfair head start. The correctness side closed out cleanly: every stress test passed, a diff of the entire compiled runtime against the reference assembler's own output now showed zero remaining semantic disagreement across all seventy-one thousand instructions. The timing side was a genuinely mixed result, reported honestly rather than rounded up: the self-hosted assembler and linker took roughly nine minutes to build a small benchmark program that the external tools built in under eight seconds — a real, currently unoptimized, sixty-eight-times slowdown — while the two resulting programs ran at effectively identical speed once built. A last piece of feedback shaped how the newly-passing stress test itself should look: several of its assertions covered numeric-tower operators — subtraction and bitwise-and on arbitrary-precision values — that are separately, deliberately not yet wired up at all, a known and already-documented gap rather than anything broken this session. The instinct was to simplify the test down to only what currently passes; the correction was direct: "Erm, those failing points should still be tested for; we will be wanting to fix them, after all." The test was restructured instead — every assertion kept, split into what must currently pass and what's a documented, currently-expected gap, both reported every time the test runs, so the coverage stays honest and the gap starts reporting itself as closed automatically the day someone actually wires those operators up. Lesson: a test that currently fails for a known, accepted reason is not the same thing as a test that should be deleted — narrowing a test's scope to make it pass quietly throws away exactly the signal that was the whole point of writing it.
Lessons from this arc, the short version
- A bug producing several apparently unrelated symptoms is worth actively hunting for one shared root cause, rather than treating each symptom as its own separate investigation — here, broken string concatenation, broken type dispatch, and broken printing all traced back to one silently-corrupted constant.
- A trivial program failing to build at all is a louder, more useful signal than a subtle wrong answer. A misapplied helper function crashed compilation completely on the very first attempt to use it, catching the mistake immediately rather than letting it hide.
- "It works now" is a starting point for harder testing, not a finishing line. The first fix, once verified, was treated as an invitation to push harder rather than a reason to stop — and the harder push found a second, more severe bug the original test suite had no way of catching.
- A direct challenge to an explanation ("are you sure that's really the cause?") is worth taking seriously enough to give a precise answer, not just a reassurance — answering it properly here forced a clear separation between "this is NASM syntax" and "this is where the bug actually lives," and the thing being challenged turned out to be a red herring anyway.
- When two independent implementations of the same well-specified thing disagree, diff their actual output directly rather than guessing at a trigger condition. Several precise, carefully-built standalone repros all failed to reproduce a real bug; a mechanical instruction-stream comparison against the full-scale input found the one true divergence immediately.
- Predicting a bug's exact wrong output by hand, before looking at the real broken output, is a strong way to confirm a root cause rather than just a plausible one — a hand-computed corrupted value matched the actual observed corruption to the byte.
- Report a timing comparison honestly, including the parts that don't flatter the newer thing. A sixty-eight-times build-time slowdown is a real, unglamorous number worth stating plainly rather than burying next to the parts that did improve.
- A test that currently fails for a known, accepted reason should stay in the suite, clearly labeled, not be quietly narrowed away. Deleting a failing assertion to make a suite pass throws away the exact signal that was the point of writing it in the first place.
See also
The Journey of Building PatLang (Acts I-VI), the second instalment (Acts VII-XIV), the third (Acts XV-XXIII), the fourth (Acts XXIV-XXVIII), the fifth (Acts XXIX-XXXV), the sixth (Acts XXXVI-XLV), the seventh (Acts XLVI-L), the eighth (Acts LI-LVII), and the ninth (Acts LIX-LXV) for where this page picks up from. The self-hosted x64 assembler and linker this arc finally cut over to live in self_hosting/lib/x64_asm.patlang and self_hosting/lib/x64_pe_link.patlang, with the production switch itself in self_hosting/lib/x64_build.patlang; the earlier native-codegen work they sit downstream of is covered in the sixth instalment above.