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))
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.
9e1c8c9established that ca65 resolves.incbinagainst the current directory as well as the including source file, and#115closed the case it caused (a missing blob assembling silently from outside the checkout) by adding thebuild/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.scarrying the production spelling.incbin "../../../ip65-build/ip65-c64.bin":Remove only the parent's copy and re-assemble, everything else identical:
So the precedence is cwd first, including-file second. The comment at
src/net/ip65/ip65_blob.s:23is 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'sip65-build/. So for every ip65 build run in a worktree while the primary checkout has a blob:make ip65-blobbuilds the worktree's ownip65-build/ip65-c64.bin;$(IP65_BIN)— including chore: finish the pytest-boundary rename, correct CLAUDE.md against measurement, and fix #89's fresh-clone build #115's new dependency edge — tracks that worktree file;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
ip65submodule pin, so both copies arecf1a5ff7…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:ip65submodule 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;ip65-build/ip65.cfg, a driver change);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-blobfirst 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, and9e1c8c9's independent fresh-clone run reproduced the samed522e684…— 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-buildwith a bare.incbin "ip65-c64.bin", and flagged that "whether ca65 applies-Ito.incbinneeds confirming". It does not. Measured (ca65 V2.18, same scratch layout):-Iis the source-include path. cc65 ships a separate flag for binary includes, both visible inca65 --help:Full option matrix measured by LaneContract2 and posted as a comment below; the three rows that decide the patch, re-confirmed independently here:
"ip65-c64.bin"-I ip65-build"../../../ip65-build/…"--bin-include-dir ip65-build"ip65-c64.bin"--bin-include-dir <absolute>The trap, and the acceptance criterion
The middle row is the one to be careful about. Adding
--bin-include-dirwhile 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.bincopies, 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
Absolute rather than relative:
--bin-include-dir ip65-buildalso 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:23states the rule backwards — "ca65 does NOT resolve.incbinrelative 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:23should 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 touchesMakefileandsrc/net/ip65/ip65_blob.s, both changed by #115 within the last few commits.