Skip to content

ip65 blob: .incbin resolves cwd-first, so every worktree build silently reads the PRIMARY checkout's blob #116

Closed

Description

@JC-000

9e1c8c9 established that ca65 resolves .incbin against the current directory as well as the including source file, and #115 closed the case it caused (a missing blob assembling silently from outside the checkout) by adding the build/net/ip65/ip65_blob.o: $(IP65_BIN) edge.

The dependency edge fixes the fresh clone. It does not fix the resolution ambiguity, and I measured which side wins — it is the one that makes worktrees wrong.

Measured: cwd-relative wins; source-relative is only a fallback

ca65 V2.18, scratch tree mimicking the repo's worktree layout — a "parent checkout" with a blob, a "worktree" three levels down with its own blob, and src/net/ip65/blob.s carrying the production spelling .incbin "../../../ip65-build/ip65-c64.bin":

scratch/ip65-build/ip65-c64.bin            = "PARENTPARENTPARE"
scratch/x/y/z/ip65-build/ip65-c64.bin      = "WORKTREEWORKTREE"
scratch/x/y/z/src/net/ip65/blob.s          = .incbin "../../../ip65-build/ip65-c64.bin"

$ cd scratch/x/y/z && ca65 src/net/ip65/blob.s -o out.o
exit=0
$ strings out.o
PARENTPA                       <- the PARENT's blob, not its own

Remove only the parent's copy and re-assemble, everything else identical:

$ rm ../../../ip65-build/ip65-c64.bin
$ ca65 src/net/ip65/blob.s -o out2.o
exit=0
$ strings out2.o
WORKTREE                       <- falls back to source-relative

So the precedence is cwd first, including-file second. The comment at src/net/ip65/ip65_blob.s:23 is correct that resolution is not source-relative-only; what is new here is that source-relative does not merely coexist, it loses.

Why that matters in this repo specifically

A git worktree lives at <repo>/.claude/worktrees/<name>/, exactly three levels below the primary checkout. From a worktree root, cwd-relative ../../../ip65-build/ is the primary checkout's ip65-build/. So for every ip65 build run in a worktree while the primary checkout has a blob:

Both the build graph and the operator are describing a different file from the one that lands in the PRG.

This is currently benign only by coincidence: the blob is byte-deterministic at a fixed ip65 submodule pin, so both copies are cf1a5ff7… and the resulting PRG is correct. It stops being benign the moment the two copies can differ, which is exactly the situation any of these creates:

  • bumping the ip65 submodule on a branch in a worktree — the worktree rebuilds its blob, ca65 keeps reading the old one from the primary checkout, and the PRG silently contains the old network stack with no diagnostic;
  • any deliberate blob experiment in a worktree (different ip65-build/ip65.cfg, a driver change);
  • concurrent agents in sibling worktrees, where whoever last wrote the primary checkout's blob decides what everyone else compiles.

It also silently weakens blob-related evidence taken from a worktree. My own #113 ip65 numbers are an instance: I ran make ip65-libs && make ip65-blob first and reported the blob hash, but the PRGs were in fact assembled from the primary checkout's copy. The hashes I reported are still right — the blob is deterministic, and 9e1c8c9's independent fresh-clone run reproduced the same d522e684… — but the provenance is not what I stated, and I would not have known without this measurement.

Suggested fix

CORRECTED — the first version of this section was wrong. It recommended -I ip65-build with a bare .incbin "ip65-c64.bin", and flagged that "whether ca65 applies -I to .incbin needs confirming". It does not. Measured (ca65 V2.18, same scratch layout):

$ ca65 -I ip65-build src/net/ip65/bare.s -o a.o
src/net/ip65/bare.s(2): Error: Cannot open include file 'ip65-c64.bin': No such file or directory

-I is the source-include path. cc65 ships a separate flag for binary includes, both visible in ca65 --help:

-I dir                    Set an include directory search path
--bin-include-dir dir     Set a search path for binary includes

Full option matrix measured by LaneContract2 and posted as a comment below; the three rows that decide the patch, re-confirmed independently here:

spelling flags both blobs present
bare "ip65-c64.bin" -I ip65-build ERROR
"../../../ip65-build/…" --bin-include-dir ip65-build PARENT — bug survives
bare "ip65-c64.bin" --bin-include-dir <absolute> WORKTREE — correct

The trap, and the acceptance criterion

The middle row is the one to be careful about. Adding --bin-include-dir while leaving the ../../../ operand in place resolves to the parent's blob, exits 0, and changes nothing. This is a two-part edit — flag and operand — and a half-applied version is indistinguishable from a working one by exit status, by PRG hash (the blob is deterministic today), and by the whole existing evidence bar.

So the acceptance criterion for this patch is not a green build. It is a marker test: plant distinguishable bytes in the two ip65-build/ip65-c64.bin copies, assemble from a worktree, and confirm the object contains the worktree's marker. Anything less cannot tell the fixed state from the broken one.

Recommendation

CA65FLAGS += --bin-include-dir $(abspath $(IP65_BUILD))
.incbin "ip65-c64.bin"

Absolute rather than relative: --bin-include-dir ip65-build also works from the repo root, but it is itself cwd-relative and would silently re-acquire a variant of this bug if any recipe ever assembles from elsewhere — and cwd sensitivity is the whole defect. Every working option fails loudly on a missing blob, so together with #115's dependency edge the file is both guaranteed present and guaranteed to be the worktree's.

Keep #115's dependency edge either way; it is orthogonal and still needed.

Also part of this patch: src/net/ip65/ip65_blob.s:23 states the rule backwards — "ca65 does NOT resolve .incbin relative to the including source file" reads as symmetric, when source-relative is specifically the loser. That comment is what makes the current ../../../ look deliberate, so a fixed path left beside it invites someone to re-derive the escape.

Worth doing regardless of the fix chosen: the caution comment at ip65_blob.s:23 should say cwd-relative takes precedence, since "does not resolve only relative to the source file" reads as symmetric and the asymmetry is the whole hazard.

Provenance

Found by LaneContract2 during #114 review as a residual on 9e1c8c9/#115 ("harmless while the blob is deterministic, but they are not the same file"); precedence measured here. Filed rather than patched because it touches Makefile and src/net/ip65/ip65_blob.s, both changed by #115 within the last few commits.

Activity

  1. JC-000 commented on Aug 15, 2026

    @JC-000
    OwnerAuthor

    Measured: the proposed -I ip65-build fix does not work — but --bin-include-dir does

    Settling the open caveat in the issue ("whether ca65 applies -I to .incbin needs confirming first"). Confirmed, and the answer is no. ca65 V2.18, same scratch layout as the issue (primary blob PARENTPARENTPARE, worktree blob WORKTREEWORKTREE, cwd = worktree root).

    # spelling flags both blobs present worktree blob removed
    A .incbin "../../../ip65-build/ip65-c64.bin" — PARENT (the bug) worktree
    B .incbin "ip65-c64.bin" -I ip65-build ERROR ERROR
    C .incbin "ip65-build/ip65-c64.bin" — WORKTREE ✅ fails loudly ✅
    E .incbin "ip65-c64.bin" --bin-include-dir ip65-build WORKTREE ✅ fails loudly ✅
    F .incbin "ip65-c64.bin" --bin-include-dir <ABSOLUTE> WORKTREE ✅ fails loudly ✅
    G .incbin "../../../ip65-build/ip65-c64.bin" --bin-include-dir ip65-build PARENT ❌ —

    B fails even though the file exists at that path:

    src/net/ip65/blob.s(2): Error: Cannot open include file 'ip65-c64.bin': No such file or directory
    

    -I is the source-include path and does not feed .incbin. cc65 ships a separate flag for binary includes — ca65 --help:

      -I dir                        Set an include directory search path
      --bin-include-dir dir         Set a search path for binary includes
    

    So the fix as written in the issue would have failed the build outright. Loudly, at least — but it would have cost whoever implemented it a confusing detour.

    Row G is the one to be careful about

    Adding --bin-include-dir while leaving the ../../../ operand in place keeps the bug — still resolves to the primary checkout. The flag only helps if the operand also drops the escape. A patch that adds the flag and stops there looks correct, builds clean, and changes nothing; it needs the two edits together, verified by the marker test above rather than by the build succeeding.

    Recommendation: F

    CA65FLAGS += --bin-include-dir $(abspath $(IP65_BUILD))
    .incbin "ip65-c64.bin"

    Why F over C and E: all three resolve correctly today, but C and E are cwd-relative, so they silently re-acquire a variant of this bug if any recipe ever assembles from a directory other than the repo root. $(abspath …) is cwd-independent, and make already expands it against its own working directory, which is the thing we actually mean.

    Every working option fails loudly when the blob is absent — none of them falls back to the primary checkout — so combined with #115's build/net/ip65/ip65_blob.o: $(IP65_BIN) edge the file is both guaranteed present and guaranteed to be the worktree's.

    One more thing to fix in the same patch

    src/net/ip65/ip65_blob.s currently documents the wrong rule:

    ; ca65 resolves .incbin paths relative to the including source file, so
    ; from src/net/ip65/ip65_blob.s the blob is three levels up from repo root.

    Source-relative is only the fallback; cwd is tried first and wins, which is the whole defect. That comment is what makes the current spelling look deliberate and correct, so it should be replaced rather than left next to a fixed path — otherwise the next person to reorganise the tree re-derives the ../../../ from it.


    Not patching this myself — agreed with your reasoning about not surprising LaneFollowups on Makefile / ip65_blob.s twice in a few commits, and my own lane (#114) is merged and closed. Happy to implement if it's useful; otherwise the measurements above should make it a short patch for whoever owns it.

    On provenance: same correction applies to my #114 ip65 numbers as to your #113 ones — my worktree had its own blob throughout, but ca65 was reading the primary's, so the PRGs I hashed were assembled from the primary checkout's copy. The hashes stand for the same reason yours do (deterministic blob, cf1a5ff7… reproduced independently several ways, and 9e1c8c9's fresh-clone run reproducing d522e684…), but the provenance claim is weaker than it looked and is worth stating that way.

  2. JC-000 commented on Aug 15, 2026

    @JC-000
    OwnerAuthor

    Implemented — PR #117.

    Two things changed shape from the issue as filed, both measured:

    1. Four sites, not one. #116 is scoped to the ip65 blob, but the identical escape was in both overlay TUs:

    src/crypto/shared/p256_overlay_blobs.s  ../../../build/lib/nistcurves-p256-verify.bin
    src/crypto/shared/p384_overlay_blobs.s  ../../../build/lib/overlay-p384-sha384.bin
    src/crypto/shared/p384_overlay_blobs.s  ../../../build/lib/overlay-p384-curve.bin
    

    These are the worse case. ip65-build/ is deterministic, which is the only reason the blob version stayed benign; build/ is branch- and flag-dependent, so a borrowed overlay image could have come from a different pin entirely. All three files also carried the same wrong comment ("resolved by ca65 relative to this source file") — replaced, since that is what made the ../../../ look deliberate.

    2. The gate had to move from the object to the PRG. My first marker test reported FAIL against a working fix. Cause: ca65 stores .incbin data in fragments — measured, only 8 bytes of the blob appear contiguously in the .o — so a planted marker never appears as a searchable string even when the correct file was read. Anyone building this gate at object level will get a false failure. ld65 emits the blob raw, so the check lives at the linked PRG.

    Gate results, and it discriminates rather than merely passing:

    worktree blob marked, both present   pre-fix FAIL (primary's bytes)  post-fix PASS
    worktree blob absent                 pre-fix FAIL (escaped)          post-fix PASS
    exit                                 pre-fix 1                       post-fix 0
    

    The test never writes the primary checkout's blob — it marks only the worktree's copy and restores it byte-for-byte.

    Verification: all 5 backend × profile builds PASS with hashes identical to master, i.e. output-neutral. Overlay operands verified in isolation (resolve with a stub .bin present, fail loudly when absent, real TUs and real defines) — because both overlay-embed builds are blocked by pre-existing defects confirmed identical on master (P-256 LIB_NISTCURVES_P256_CODE overflows OVERLAY_REGION by 644 bytes; P-384 SHA-384 table). Their .incbin lines are unreachable by a real build, so I said that plainly rather than let the green matrix imply coverage they do not have.

    $(abspath ...) behaved normally throughout — no oddity to report, and I did not fall back to the relative form.

  3. added a commit that references this issue on Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions