Cookbook: adopting index_base / index_region

index_base() and index_region() fix a real defect — a table indexed by a key no longer masquerades on the memory map as a single owned byte with phantom reads and writes (see Indexed-base addresses and regions for the API, and the reference-kinds memo in docs/design/reference-kinds-memo.md for the why). But they are annotation-only: they never change emitted bytes, so verify passing tells you nothing about whether a conversion was correct. Correctness comes from one habit:

Read the label and its usage in context before converting it. The tooling finds candidates; only the code tells you what a label actually is.

This how-to is the workflow and judgement for retrofitting these calls onto a real, already-annotated driver — distilled from migrating a full multi-version 6502 disassembly (Acorn NFS/ANFS, 11 ROMs) to dasmos 2.0: roughly 480 index_base conversions, plus a careful decision not to use index_region anywhere in that codebase.

1. Sequence the work

Do the bare version bump on its own first — regenerate, and confirm verify still passes byte-identically and only cross-reference prose changed. dasmos 2.0 is annotation / JSON-shape only; if bytes move, stop and investigate before editing anything. Commit that clean upgrade separately, then start converting on a known-good baseline.

If you have local tooling that parses the output JSON, note the schema is now versioned: references entries are {addr, kind, move_id?} objects, and each memory_map row’s access is an ordered ["r", "w", "b"] array — a base is a row carrying "b" (schema v4; earlier dasmos used a separate index_bases array).

2. Find candidates — as leads, not as an oracle

A pure index base shows in the .asm cross-reference as used as index base N times with no referenced. A mixed address (referenced … also used as index base …) is touched both ways — leave it a plain label(). In the JSON, pure bases you have already declared show as memory_map rows whose access is ["b"]; references[].kind gives you the split programmatically.

Two blind spots to compensate for:

  • Low-page (zero-page / page 0–2) bases are under-reported in the JSON. The .asm cross-reference does classify them (used as index base), but the JSON references[].kind currently does not carry the kind for those targets — so anything built on the JSON (including orchestration CLIs) will miss them. Trust the .asm there.

  • Relocated-code entries masquerade as pure data bases. A block copied to RAM by add_move() has its code items at the source address; the destination entry point therefore has no local code item, and its copy loop writes it as sta dest,Y — which looks exactly like an index base. Always also exclude entry() targets and move destinations. Converting one moves a live entry point off the map — the cardinal error this feature must not cause.

Orchestration tools can list the pure set for you (for example fantasm’s labels list --index-base-only, which also flags type=='code' items). Treat their output as a worklist to read, never as a batch to convert.

3. Decide per label, by reading

You’re looking at…

Do

a label() data base — RAM/ZP scratch or a ROM table / template / offsets / lookup, only ever lda / sta …,X / ,Y

Convert to index_base, keeping description / group / length; drop any access= (index_base asserts the b flag itself, and a pure base has no r / w).

any type=='code' item, or an entry() / move destination

Leave — code label (the caveat).

a subroutine()-style data banner (a documented data region with a title / structure)

Leave — it isn’t a label(); converting strips the banner, and its cross-reference already reads correctly.

an auto-generated (l####) or environment label

Leave — no label() to convert, no value as a base.

The name usually gives the class away — table / template / offsets / strings / lookup is data; entry / handler / start / a jmp-or-branch target is code — but when name and usage disagree, trust the usage. A …_entry that is only ever read as lda …,X to scan a header is a data base; a …_table that is jumped into is not. Read the inline comments you already wrote: they usually say which.

A throwaway script that rewrites label( → index_base( over a curated address set (dropping access=, handling multi-line calls) saves tedium — but the set must come from your reading, never straight from a tool. Afterwards, re-run verify and your linter, and assert that no entry() target became an index_base in any driver. verify passing proves you did not change bytes; it does not prove the classification is right.

4. index_region: use it only where it earns its place

index_region() renders in-window neighbours as anchor±k,X, so you do not hand-write a label per slot. Its payoff is eliminating tedious per-slot labels for mechanically-related notional slots around a real anchor — the free-space-map-scratch case in the API docs.

Do not reach for it just because addresses are close. Most “clusters” are the opposite of its use case, and a region makes them worse:

  • lo/hi address-table pairs (*_lo / *_hi): the names carry the meaning; anchor+k,X buries it. Keep two index_bases.

  • distinct adjacent tables that merely abut: different data → different names.

  • N-byte-spaced parallel arrays (one column per field, shared index): each is its own documented array, not a slot.

  • single base-adjustment tricks (a base chosen so base+Y lands in another region, often already carrying an explanatory comment): one meaningful base, not a swept range — a region flattens the arithmetic and discards the comment.

Rule of thumb: if you would otherwise be forced to hand-name many interchangeable slots, a region helps. If each address has a distinct role and a comment explaining it, naming beats arithmetic — leave them as index_base. Decide by reading the usages, not by address spacing. (Region windows must be disjoint; an explicit label() inside a window still wins.)

5. Wrap up

Re-run lint / verify across every version (clean and byte-identical). Spot-check the JSON: converted addresses should sit in memory_map with access containing "b" (a ["b"]-only row for a pure base). Commit the conversions separately from the bare upgrade, and record the data-vs-code / banner / auto-label rules and the “don’t force index_region” guidance in your project’s own notes so the next pass stays consistent.