Oliver VieBrooks

6diss / 6diss manual

Contents

6diss

An interactive disassembler for Commodore 64 binaries, written in C# / Avalonia.

Feed it a .d64 and it gives you a project: every file on the disk extracted, load-addressed, traced from its entry points, and disassembled — with the C64's hardware and KERNAL named, register writes explained, graphics and text recognised, and the result exported as 6asm source that reassembles to the same bytes.

File ▸ Add disk image…  →  a project tree of every file, already disassembled

What it does

Reads disks, not just files. Disk-image access is 6disk's SixDisk.Core, so D64 works today and D71 / D81 / D80 / D82 / G64 / NIB / CMD containers come through the same interface. Packed files are recognised through 6crunch: the original keeps its packer stub and the decrunched body is added as a second, separately disassemblable binary. That runs on import for whole files, and on demand for any stretch of one — see Depacking part of a file. When no codec can undo it, import still names the packer from its stub signature and says the file is still packed, because that is most of what you needed to know — 6crunch identifies several hundred more crunchers than it decompresses.

Knows the machine. A memory map of ~380 documented locations — every VIC-II, SID and CIA register with its bit fields, the KERNAL jump table, the BASIC and KERNAL routines worth naming, the interrupt vectors, and every byte below $0400 — drives the operand naming, the inline comments and the inspector panel. A sta $d018 reads as sta VMCSB, and the lda #$15 before it is annotated → VMCSB: CB=2 VM=1.

Zero page and pages 2-3 are documented in full but annotated with judgement, because a demo that stores to $57 is using the RAM, not talking to BASIC. By default only the locations a machine-code program plausibly means are commented — the banking register, the jiffy clock, the keyboard queue, the KERNAL file parameters, the screen editor's cursor state, the $0300-$0333 vectors:

        sta   $01                       ; R6510: 6510 I/O port: memory banking + Datasette
        lda   $D6                       ; TBLX: Cursor row (0-24)
        sta   $C6                       ; NDX: Number of characters waiting in the keyboard buffer
        lda   $FB                       ;   ← nothing: free zero page, so it's yours

Set LowMemoryComments to All in the settings file to annotate every documented byte down there (right when you are reading BASIC or KERNAL code), or Off to silence it. The inspector always shows the entry for whatever the caret is on, whichever you pick.

Knows the other machine too. A fastloader's drive code sits inside a C64 file and runs on the 1541 at $0300, where $00 is the disk controller's job queue and $1C00 is the motor and head port. Mark the block as drive code and it is read against the drive's map instead — RAM, both VIAs bit by bit, and the DOS ROM's routines — with its jumps followed through to where they really go and its labels named for the addresses it will run at. Same for a block the program merely relocates. See Code that doesn't run where it sits.

Knows which part is resident. A release reuses the address space: six parts that all load at $2000, a loader that calls into each in turn, drive code copied to $0300 before it runs. jsr $2000 has no answer in the bytes — which part is there when that call runs is a fact about how the program is built. Press X on the reference and say, per reference or as a file-wide default, and two identical jsr $2000s become jsr PART1::one_init and jsr PART2::two_init. Blocks that run somewhere other than where they are stored are candidates too, which is the only way to reference them at all. The claim decides the name, where Enter goes, and which .include the exported source carries. Unclaimed references keep the old guess and say so. The project comes up as a tree of disks and their files, so which file on which disk is a choice you can see; a file can be claimed at the address it is loaded to rather than the one its header claims; and nowhere in this project is one of the answers, for the buffer or the run-time table that is nobody's routine. See Which file does this reference mean?.

Puts an address back together. The 6502 cannot hold sixteen bits, so every address a program passes around is taken apart first — ldx #$96 / ldy #$88 / jsr setup is $8896, and lda #$96 / sta $fb / lda #$88 / sta $fc is the same address built in a zero-page pointer. Read as written, both are a pair of unrelated constants and the address appears nowhere at all. Select the two instructions, press H, name the address, and they become ldx #<sprites / ldy #>sprites — with the target carrying both instructions in its cross-reference list, and, when a pointer is being filled, the lda (srcptr),y five hundred bytes away annotated with what it is reading. Nothing is guessed: which two instructions form a pair is a fact about what the routine does with them, so you say it. See Addresses built in pieces.

Follows what a static tracer can't. Interrupt handlers are installed through a vector, never called, so a plain recursive-descent trace stops at cli and never sees them. 6diss reconstructs the address written into $0314 / $0318 / $FFFA / $FFFE from the lda #<handler / sta pairs and traces from there — which is usually the difference between half a file disassembled and all of it. The same for an RTS jump: lda #>target / pha / lda #<target / pha / rts ends in what a walk reads as a return, so it stops and the code it really goes to falls out as data — which is exactly why the trick turns up in code that doesn't want reading. 6diss reconstructs the pushed address and starts there.

Reads a sequence, not just an instruction. Naming a register is a fact about one address; what a run of instructions means is where nearly all the C64-specific knowledge actually lives. lda #$7f / sta $dc0d / sta $dd0d is three annotated stores, or it is "turn the CIAs off before taking the interrupt" — and only one of those is what the author was thinking. See Idioms.

Says when the listing is lying. A store that lands on a byte the trace decoded as an instruction is self-modifying code, and it says so on both lines — the instruction that gets patched, and the store that patches it. A write to the opcode is called out louder than one to the operand, because what runs there is then not what is printed at all.

Reads a jump table as a jump table. A library or overlay file opens with a run of jmps — its export list. jmp is an unconditional transfer, so a recursive-descent trace takes the first entry and never comes back, and the other thirty-one, plus everything only they reach, fall out as unclassified bytes. 6diss recognises the shape, traces every entry, names both the slot and the routine behind it sub_, and marks the slots exported — so another file's jsr $4003 reads as LIBRARY::sub_4003.

Recognises BASIC. A tokenised BASIC program is detokenised in place and shown as source, and the 10 SYS 2061 stub that fronts nearly every release hands the tracer its real entry point. Works at $0801 (C64), $0401 (PET), $1001 (VIC-20) or anywhere else — the chain is validated rather than the address assumed.

And disassembles a BASIC file as BASIC. When a file is BASIC all the way to its last byte there is no 6502 in it to disassemble, so 6diss doesn't pretend: the listing is the program — each line against the address and bytes it came from — and it exports as a .bas that 6basic tokenises back to the identical binary. That is a per-file switch, set on import for a whole-file program and left off for the stub in front of a machine-code release, where the code is the point. Detokenising shares 6basic's own token and PETSCII tables, and every export is re-tokenised through 6basic's compiler to check it — so a line 6basic will not give back verbatim is named in the header rather than quietly shipped. Comments go on BASIC lines the same way they go on instructions, written as the ' tick comment 6basic strips before it tokenises anything — so a program you have annotated line by line still rebuilds to the byte.

Is genuinely interactive. Select a range and press one key to say what it is; every decision immediately re-renders, and every re-trace folds in what you've told it since the last one.

Draws what it finds. A charset, sprites, a bitmap or a screen render in the listing as annotated .byte with the shape in the comment — and in the inspector's Graphics tab as pixels, through 6disk's C64 palette and PNG writer. Colours are part of the region, so a multicolour sprite is stored as one and exports as one; a bitmap picks up whatever screen and colour RAM you have already marked in the same file. Point the tab at data you haven't classified and it will draw it speculatively, which is usually how you find out that a block of "random data" is a font. Every pixel knows which byte drew it: click one to land on it in the listing, and save the picture as a PNG.

Talks to a model, on your terms. The assistant reads the disassembly window you select plus the project's memory map, and proposes labels, comments and data classifications. Nothing is applied until you tick it, and what you accept is tagged as machine-written so it stays distinguishable from your own work. Point it at Anthropic's API or at LM Studio / Ollama on localhost.


Keeps what you worked out, not only what you decided. A label says what a routine is called and a comment says what one instruction does. Neither holds "the record is 0x244 bytes, the first fifteen are the name in PETSCII, and the two saved games disagree about byte +$1C". That goes in a note — a markdown file under Notes in the project tree, read and written in a tab beside the listing, with hex addresses in the prose drawn as links that put the listing on them. The notes are real files in <project>.docs/, so git diffs them, your editor opens them, and Claude Code writes them as it works. See Notes.

The window

Laid out like VS Code: project tree on the left, tabbed editors in the middle, inspector on the right.

Keys

With the listing focused:

Key
C B W P T F S mark the selection as code, bytes, words, pointers, text, charset, sprites
N name the current address
Shift+N define a label at an address — the one this line points at, by default
V name the value the current line loads — every #$1b in the project, or just this one
; comment the current address
D describe the file — the banner at the top of the listing
M move the nearest label onto the cursor
[ / ] shift that label one byte back / on
/ split the line here (again to unsplit)
H the selected immediates are the halves of one address
X point a reference at the file (or block) it really means
E trace code from here
R every reference to the address under the cursor, in a list you can filter, walk and copy
Enter follow the branch, jump or data reference
Ctrl+click the same follow, from the mouse — including an address written inside a comment, so an xref: list is a set of links. The pointer turns into a hand over anything that goes somewhere
Backspace, Alt+← go back to where you were
Alt+→ go forward again — or the ◀ ▶ toolbar buttons, or the mouse's side buttons
← → put the byte cursor on a byte and walk it
Shift+arrows extend the byte selection, across rows if it needs to
Home / End first / last byte of the row
Esc leave byte select
Ctrl+Z / Ctrl+Shift+Z undo / redo the last change
Ctrl+C / Ctrl+Shift+C copy the selection as source / with the address and byte columns. Under the byte cursor it copies the bytes
Ctrl+A select the whole listing
Ctrl+W close the file in front — it stays in the project
Ctrl+\ split the editor into two panes, and put it back
F6 move between the two panes
Ctrl+E trace code from here
Ctrl+F find text, a label or a byte pattern
F3 / Shift+F3 next / previous match
Ctrl+Shift+F find code the trace missed, across the whole file
Ctrl+Shift+U ask 6crunch what packed the selection, and undo it
Ctrl+Shift+M identify the playroutine, from SIDId's signatures
Ctrl+Shift+B ask the brain what it recognises from earlier disks
Ctrl+G go to an address or label
Ctrl+L set where this file really loads
Ctrl+Shift+D the selection is drive code — it runs on the 1541
Ctrl+Shift+R the selection is relocated — it runs elsewhere in the C64
Ctrl+R re-trace from the entry points
Ctrl+T run the data-type detector
Ctrl+I ask the assistant about the selection
F1 the manual — Help ▸ Documentation, sectioned and filterable, and it stays open beside the listing

Right-click for the rest. The menu is built when you open it, out of what is actually under the pointer: it names the address it will act on, the label it will move and the file it will remove, and it leaves out what those bytes have no answer for — no "name the value" on an rts, no "split the line" in the middle of a routine, no "point this reference at" where nothing is referred to. Shift-click and shift-arrows extend the selection; every "mark as" acts on the whole of it.

Finding things

Ctrl+F opens the Find panel in the inspector. "Where did I see that" has four different answers in a disassembly, so one box searches four different things and you tick which:

comments yours, the knowledgebase's (border colour), the xref: lists, the text decoded beside a .byte run, the shape drawn next to a charset glyph
labels by name, including the auto ones
code the rendered mnemonic and operand — sta EXTCOL, jsr CHROUT, lda #$1b
bytes a hex pattern: A9 00 8D 20 D0, $a9,$00, a9008d20d0. ? is any nibble, so A9 ?? 8D 20 D0 is any immediate written to the border
text in data a string in the raw bytes, decoded as PETSCII, screen codes and ASCII — found whatever the file is currently classified as

The first three run over the rendered listing, so they see exactly what you see. The last two run over the raw image, so they find a message or a sequence in a stretch nothing has classified yet — which is usually how you work out what to classify it as. whole project searches every file on the disk, opening whichever one a result lands in.

F3 and Shift+F3 walk the results without leaving the listing; the box keeps its term, so narrowing a search is a keystroke rather than a retype. Ctrl+Shift+F is the code sweep that used to be on Ctrl+F.

Back and forward

Reading a disassembly is following a thread — a jsr into a routine, a label into the file that loads there, an xref back to the callers. Alt+← / Alt+→ (or Backspace, the ◀ ▶ toolbar buttons, or a mouse's side buttons) walk it. One history for the whole project, because the jumps most worth undoing are the ones that changed file, and the buttons name their destination rather than the direction:

◀   Back to LOADER.PRG $C005   (Alt+←)

Every deliberate jump is recorded — an operand followed, a cross-reference, a search hit, a region or label in the inspector, Ctrl+G, opening a file from the tree. Scrolling is not.

Following goes where the name on the line says, not where the address alone would suggest: an operand reading DUNGEON::PrintPrompt lands in DUNGEON even when COMBAT loads over that address too and was imported first. A MODULE::$hhhh link in a comment — the form the cross-file xref list uses — is one link, module included, and goes to that file. And a file's own bytes are its own: a reference to an address the file holds itself stays in it, whatever else loads over that address, unless a claim says otherwise.

What calls this from another file

A file traced on its own only knows about references made inside it, so a routine the rest of the disk calls shows an empty cross-reference list — which reads as "nothing calls this" when the truth is "everything does". 6diss traces the other files too and attributes each reference back to the file that made it:

2000  PrintPrompt:      ; xref: LOADER::$C000, LOADER::$C005

They appear in the inspector's list as well, in the cross-file colour and naming the file; clicking one opens that file at that instruction, and back brings you home. Attribution follows the same rules the operand does, claims included — a call pointed at PART2 shows up in PART2's list and not in PART1's.

It is built once and mended, not rebuilt. Working it out means tracing every binary in the project, which on a six-hundred-file disk set takes a second and a half — far too long to pay on a keystroke. So it is kept as what each file refers to, and an edit only invalidates as much of that as it really changed:

the edit what it costs
A label, an equate, a named value, a comment, a line split nothing. The trace runs off entry points and regions and a reference is an address in the bytes, so none of them changes what refers to what
Marking bytes, tracing a routine, pointing a reference somewhere one file re-traced. Nothing else in the project refers to anything different
A load address, an overlay, a file added or removed the lot. Those move what everyone else's references are measured against

The first row is the one that matters, because it is most of what you do. A command that hasn't said which row it is in gets the third — the slow answer rather than a stale one.

Following an address in a comment

Any $xxxx written in a comment is drawn in the link colour and is a live target: Ctrl+ click or double-click it and you go there, with Backspace to come back. That makes the xref: list on a label a set of links —

C05A  sub_c05a:                    ; xref: $C012, $C0A7, $C104

The line names the first four and then says how many it left out — +37 more — because a routine called from forty places would otherwise be a label nobody can read. The rest are a keypress away: R (Edit ▸ Cross-references here, or the right-click menu, which names the address and the count) opens the whole list in a window — filterable, every row a way to the instruction that makes it, cross-file references included, and Copy all for the lot. Asked from a line in the middle of a routine it answers about the routine: the nearest label at or before the cursor, which is the label the +n more was printed on. Four is only the default — NamedXrefs in the settings file changes it, and 0 names every one of them, which is what an exported source usually wants.

— and the same for the addresses a split pointer table reconstructs in its comment, the range a .fill covers, and anything you wrote yourself. Typing $D020 in a comment makes it clickable.

Selecting bytes, not lines

A data run rarely starts and ends where the listing happened to break the row. ←/→ put a byte cursor on a byte and walk it, and every row it touches expands:

4080  'PRESS FIRE TO START     '  .text   D0 D2 C5 D3 D3 A0 C6 C9 D2 C5 …
           ▔

The decoded text comes first, hard against the left margin, so the character under the cursor stays on screen however wide the row is — forty bytes of hex in front of it would push the string off the right-hand edge exactly when you needed to read it. The cursor byte is highlighted in both columns, and clicking a character selects that character's byte just as clicking its hex cell does. Dragging in either column sweeps a range, across rows if it needs to.

Marking a byte range splits whatever region it lands in, so a table that runs from mid-row to mid-row is one mark rather than a hand-typed address range. The selection survives the re-render the mark causes, which is what lets you keep carving.

Splitting lines

Splitting is separate from marking, because one PETSCII block is one table and a dozen strings. / says only a line starts here: the region stays one region, no label has to be invented to force the break, and the .text ends where the string does instead of at the sixty characters the disassembler would otherwise wrap at.

4080                    .text "PRESS FIRE TO START     GAME OVER      HI-SCORE"

4080                    .text "PRESS FIRE TO START     "
4098  ⟩                 .text "GAME OVER      "
40A7  ⟩                 .text "HI-SCORE"

/ again on the same byte takes the split away. A split you placed is marked with a tick in the gutter, so it's distinguishable from a break the disassembler chose — and findable again when you want it gone. Edit ▸ Split selection into lines… does a whole block at a fixed width (40 for screen rows, 8 for a character, 3 for a sprite row), and Clear splits in selection undoes a block's worth at once.

Splits work in any data kind, not just text — a split in a .byte run ends that line the same way. A region given an explicit stride (Mark as ▸ Other…, bytes per line) wraps its text at that width too, which is the other way to say "this is forty-column screen data".

Addresses built in pieces

The pattern is everywhere and no disassembler can see it:

8000  A2 96     ldx #$96
8002  A0 88     ldy #$88
8004  20 40 C1  jsr sub_c140

$96 and $88 are not two numbers — they are $8896, split because the CPU has no way to pass a sixteen-bit value. The same shape fills a zero-page pointer before an (fb),y walks through it:

8010  A9 00     lda #$00
8012  85 FB     sta $fb
8014  A9 90     lda #$90
8016  85 FC     sta $fc

Select the two instructions and press H. 6diss offers both readings — $9096 and $9000 are different places, and only you know which one the routine means — with what each address is spelled out beside it, so a pair that lands inside a file on the disk is easy to tell from one that lands nowhere. Name it, and the listing reads:

8000  A2 96     ldx #<sprites
8002  A0 88     ldy #>sprites
8004  20 40 C1  jsr sub_c140

The name goes where the address goes: a label in this file, or the owning file's label (written MODULE::name and exported with it) when the address lands in another binary in the project, or a project equate when it lands nowhere. Leave the name blank and the operands read #<$8896 / #>$8896, which still says the thing that matters.

No instruction here names $8896, which is exactly why it had to be said by hand — so once said, it counts. The address carries both instructions in its xref list, marked address rather than read or write. When the pair fills a zero-page pointer, 6diss offers to name the pointer too, and the read at the far end says what it is reading:

8020  B1 FB     lda (srcptr),y             ; → charset

— but only for a pointer nothing else ever fills, because one reloaded from six places points at six things and none of them at that line.

Nothing is ever marked for you: a byte that looks like a page number is usually just a byte. And if a mark and the code ever disagree — the wrong pair marked, or an operand patched at run time — the line says so rather than quietly showing a name for a byte that is not there. Edit ▸ Clear address marks in selection takes them off again.

Which file does this reference mean?

Every C64 binary on a disk shares one 64K address space, and a release reuses it. Six parts at $2000, a loader calling into each, a fastloader whose drive code is copied to $0300. So jsr $2000 cannot be resolved by its address, and picking the first file in import order is not a decision — it's the order you added the files in, silently putting one part's name on another part's call.

Put the cursor on the reference and press X, and the project comes up as a tree — each disk with its files under it, the blocks inside each file, the loose files last. Containers fold away, and whichever one holds the address opens:

Where does $2000 go?
  ▾ DEMO.D64      a demo  [D64]
      LOADER      $C000-$C003  PRG   — the file you are reading
      PART1       $2000-$20FF  PRG   holds $2000 — one_init
      PART2       $2000-$20FF  PRG   holds $2000 — two_init
    ▾ FASTLOAD    $4000-$40FF  PRG   does not reach $2000 where it loads
        fx        stored $4000, runs $2000-$20FF   runs at $2000
  ▸ Files         not from a disk image
    Nowhere in this project

Two identical instructions then mean two different files:

C000  20 00 20  jsr PART1::one_init
C003  20 00 20  jsr PART2::two_init
C006  20 D2 FF  jsr CHROUT

Blocks count as targets. A block that runs somewhere other than where it's stored is unreachable by address otherwise — the bytes are at $4000, the caller says $2000 — so any overlay whose run range covers the address is offered alongside the files, including one in the same file.

Nowhere is an answer. The last row is the one the others can't give. A buffer the program fills, a table it builds at run time, an address it happens to share with another part of the release: none of those is a reference into another file, and without a way to say so 6diss names it after whichever file loads over it, puts that file's .include at the top of the exported source, and lists the instruction among its callers.

C000  8D 00 20  sta PART1::one_init    ; before
C000  8D 00 20  sta $2000              ; after

It doesn't silence the machine — $D020 is still EXTCOL — and it doesn't stop the file naming the address itself. Said once for the whole file (Edit ▸ No reference in this file leaves it) it's how a ripped routine loses a page of .include lines it never needed.

A file can be loaded somewhere other than where it says. A part whose header says $2000 can be read off the disk by a loader that puts it at $C000: same bytes, different address, and nothing connecting jsr $C123 to the routine $123 bytes into that part. Correcting the part's own load address is the wrong fix — three other things load it where it says. So the shift belongs to the reference: pick any file in the tree, say where it's loaded, and the listing, Enter and the cross-references all follow it into the right byte of the right file. The export can't use that file's exports, which define its labels where it says it loads, so the calling file defines the names it uses at the addresses it calls them at:

; ---- names from a file loaded somewhere other than it says ---------
; PART1 says it loads at $2000; this file is calling it at $C000, so its
; names are defined here rather than included from PART1.exports.asm.
PART1__tune = $C123   ; in PART1

One decision or forty. Apply to this reference, to every reference in the selection that lands there, or to every unclaimed reference leaving the file. The last is the file's default, and a per-reference claim always beats it. The default stays quiet about addresses it has nothing to do with — with a default of PART1, jsr $FFD2 is still CHROUT.

Unclaimed and misclaimed both say so. An ambiguous reference nobody has claimed keeps the old guess with a note; a claim that can't do what it says is reported rather than ignored:

C000  20 00 20  jsr PART1::one_init   ; also in PART2 — unclaimed
C003  20 00 20  jsr $2000             ; claimed → PART1, nothing named at $2000 there

A claim that reaches an address decides it outright, including deciding there's no name — falling back to another file's label would answer a question you've already answered.

It follows through to the source. Enter and Ctrl+click go where the claim says. The exported file includes exactly the modules its references use, so one loader can pull in both PART1.exports.asm and PART2.exports.asm; and a claimed block's labels are exported at the addresses they run at — PART2__fx_start = $2000 ; where it runs — stored at $2080 — which is the number the reference assembles to.

In the project file it's a list per binary, by module name, with a null site for the default:

"ExternalRefs": [
  { "Site": null,  "Target": "PART1" },
  { "Site": 49155, "Target": "PART2" },
  { "Site": 49164, "Target": "FASTLOAD", "Overlay": 16384 },
  { "Site": 49170, "Target": "PART1", "Load": 49152 },
  { "Site": 49176, "Target": "" }
]

An empty target is nowhere; a Load is where that file sits for that one reference.

It remembers where you were

Saving a project also records which files you had open, which was in front, and the cursor address in each; reopening puts them back. That lives beside the recipe rather than in it — written only on save, so opening a tab never makes a project ask to be saved — and a project with no session opens the largest file, as before.

"Session": {
  "Open": [ "LOADER", "PART1" ],
  "Active": "LOADER",
  "Caret": { "LOADER": 49155, "PART1": 8192 }
}

It saves itself

A project that has been saved once writes itself out again every five minutes, whenever there is something new to write — an afternoon's naming is hours of reading that exists only in the window it was done in, and the moment it is worth the most is the moment nobody remembers to press Ctrl+S. It only ever overwrites the file you chose: a project that has never been saved is never asked about, and no file dialog appears out of a timer. The status bar says so and the •unsaved marker clears; nothing else interrupts you.

Ctrl+S is still worth pressing, and not only for the four minutes: saving by hand is also what feeds the brain, because learning generalises from decisions you have called finished and a timer is not that moment.

The window's own shape — size, position, the widths you dragged the tree and inspector to, which inspector tab was in front, the listing font size and each pane's zoom — is remembered per user instead, in the settings file, because it belongs to your screen and not to the disk you're reversing. A position on a monitor you no longer have is re-centred rather than left off-screen.

Arranging the tabs

Drag a tab left or right to reorder the strip.

Ctrl+\ splits the editor into two panes, so a routine and the table it indexes are on the screen at once rather than one keystroke apart. The tab in front moves across; after that, drag tabs between the panes, or use View ▸ Move this tab to the other pane. F6 moves the keyboard between them, and the pane you last touched — the one with the lit edge — is the one the menus, the chords and the inspector are about. View ▸ Stack the panes puts them one above the other instead of side by side. Taking the last tab out of the second pane closes it, and Ctrl+\ again puts everything back in one strip.

A split is a rearrangement of tabs, never a second copy of a file: one binary has one tab, one caret and one undo history wherever it is. Which pane each tab was in is saved with the project, alongside what was open.

Closing files

A tab closes with the ✕ on it, a middle-click, or Ctrl+W; right-click a tab for Close the others and Close all (offered when there are others), and the File menu has the same. The same menu is the file's: its description, its settings, where it sits in the project tree, handing it to VICE or 6hex, and taking it out of the project. Closing throws nothing away — the file stays in the project and in the tree, and what was open is remembered per project.

Leaving

Closing the window with decisions that aren't in the file asks first: Save and close, Close without saving, or Cancel — which is the default, because a window closed by accident is the case the question exists for. Notes are not part of the question: a note is a real .md file, and typed prose is written out on the way past whatever you answer, the same as it is on Ctrl+S, on a tab close and on the auto-save.

Removing a file or a disk

Right-click a file or a disk image in the tree ▸ Remove … from the project, or File ▸ Remove this file from the project for the one in front. It takes the file's bytes and every decision made about it, and a disk takes the files imported from it — including what was depacked out of them. The image on your disk isn't touched.

The part worth knowing is what happens to the files that stay. Others have been pointed at the one that's going: a jsr $2000 in the loader was claimed to mean PART2, and that claim names PART2 by module. Left behind it would read

C003  20 00 20  jsr $2000        ; claimed → PART2, nothing named at $2000 there

— a worse answer than the plain number the reference started as. So the claims go with the file, and every reference pointed at it reverts to what it meant before anyone pointed it: whichever other file covers the address, or the address alone. The confirmation says how many references in how many files that is before you agree to it. The cross-file xref lists, the open tab, the back/forward history and the undo steps for the removed files all follow.

It can't be undone — Ctrl+Z steps through changes to a file, not through the shape of the project — so it asks first and names what it's about to take.

Copying

Ctrl+C copies the selection as assembler source, ready to paste into an editor or into 6asm. Ctrl+Shift+C copies it with the address and byte columns — what you are actually looking at, which is what a bug report or a note to yourself needs. Under the byte cursor both copy the bytes instead, sixteen to a line, because a selection made in the hex column is about the bytes by definition. Ctrl+A selects the whole listing.

Moving labels

Analysis puts a label where it guessed a thing started, and that's routinely a byte or two out — a table whose first entry is really the count in front of it, a string whose length byte the disassembler swallowed.

Put the cursor where the label belongs and press M: the nearest label is picked up and dropped there, keeping its name, role, comment and exported flag. [ and ] walk it one byte at a time and take the cursor with it. A moved label becomes yours — its origin is promoted from auto to user — so the next re-trace leaves it where you put it rather than silently snapping it back. An auto name that spells its own address (sub_4010) is respelt to the address it moved to.

Removing labels

Right-click a line and the menu names the label it's about to take: Remove label loc_4012. Edit ▸ Remove label is the same command from the menu bar.

A trace that wandered into a data block leaves a loc_ on every byte it took for a branch target, and picking those off one at a time is a screenful of commands for one decision. Select the block and the menu offers the lot — Remove the 37 labels in $2000-$2FFF, and, when your own names are mixed in with the junk, Remove the 35 auto labels here, keeping yours.

An auto label is the tracer's opinion rather than a fact about the file, so removing one holds until something re-forms that opinion: a re-trace (Ctrl+R), or the trace that follows accepting found code, puts it back. To keep it off for good, say what the bytes are — a block marked as data isn't traced into, so no sub_ or loc_ names are invented inside it. An address code genuinely reads still gets a dat_ name, which is the point of that one: it's telling you the table is used.

Saying what a file is

A disk's directory names files, it doesn't describe them, and a directory of MON01 through MON30 tells you only that somebody was consistent. Press D (Analyse ▸ Describe this file…, or right-click the file in the tree) and say what it is:

; ==============================================================
;  MON04   [MON04]
;  Monster data — red dragon
;
;  16 bytes per stat block: HP, AC, XP, then the attack table
; ==============================================================

The description heads the banner, directly under the filename and above everything 6diss worked out, because what a file is outranks where it loads. It is also the line the project tree shows under the name — which is what turns thirty identical-looking entries into a list you can read — the tab's tooltip, part of what the assistant is told about the file, and a comment block at the top of the exported .asm. Multi-line is fine; a blank line in the middle stays a blank line.

It is deliberately not the same field as Notes. Notes are where import writes what it worked out — the load geometry, the packer, "decrunched body of…" — and it rewrites them whenever a file is re-imported or depacked. Something you typed must not be collateral damage when that happens, so it lives in its own field that nothing but you ever writes.

The same dialog carries the switch for the rest of the banner. Show 6diss's own header lines off leaves the file's name and your description and nothing else — worth having for a data file whose one-line description is the entire story, and where six lines of provenance above it are noise. On is the default, and for a BASIC file the lines that warn a program won't tokenise back byte-identically are never silenced with it: those are not provenance, they are the one thing in that header you have to act on.

Ctrl+Z puts a description back, like any other decision.

Where the file really loads

A PRG's first two bytes are a claim, not a fact. A packer, a loader with its own directory, or a release built to be awkward will all put a file somewhere other than its header says — and a disk where every file claims $4000 is a deliberate obfuscation, not a coincidence.

Ctrl+L (Analyse ▸ Set load address…) says where it really runs. Everything worked out so far is about the bytes, and the bytes haven't moved, so the regions, labels, comments, entry points and splits all shift with them and the listing reads exactly as it did at the old address. Cross-file references start resolving too, which is usually the point: once the loader's jsr $c003 lands inside a file that admits to living at $C000, following it opens that file.

Ctrl+Z puts it back.

And the opposite: a file whose header nobody read. A binary that arrives without a .prg extension — a rip, a .dat, something out of an archive — has no header as far as import is concerned, so it's mapped at $1000 because something had to be picked. If its first two bytes are 00 c0, they're a load address of $C000 and the file is a PRG nobody labelled as one. 6diss says so — in the status line at import and in the file's notes at the top of the listing — rather than assuming it, because two bytes at the front of a ripped bitmap look exactly the same and eating them would be silent damage.

Analyse ▸ Load address from the file's first two bytes… takes them at their word: they become the header, the image starts at the third byte and loads where they say, and everything already marked moves with the byte it was marked on. Anything landing on those two bytes goes with them, because they stop being part of the program. The same thing is a button in File settings, next to the load-address and header-size boxes it fills in — and changing those by hand now moves nothing against the bytes either, so a header appearing in front of the image no longer slides every label two addresses along.

Naming an address the file doesn't contain

N names the byte under the cursor. Shift+N names the address the code refers to — a buffer the program clears and fills, a routine in the file that loads next, a register you'd rather call by the program's name for it. None of them is a byte in front of you, all of them appear in operand after operand as four hex digits, and there's no line in the listing to hang the name on.

It opens on the address the current line points at, so reading jsr $c123 and pressing Shift+N asks about $C123 rather than about the jsr. Where the name lives is the one other choice: in this file (this file's own reading of the address, which beats any guess about who else loads there), in the file that holds those bytes (the project's name for it, exported so every file reads MODULE::name), or a project equate for an address no file in the project holds at all.

Correcting a load address afterwards leaves definitions where they are — moving this file says nothing about the buffer it writes to — while labels on its own bytes move with the bytes as they always did. And the exported source defines what it used, so the listing you read is the source you get:

; ---- addresses this file names but does not contain -----------------
screen_copy = $C800   ; where the double buffer goes   1 ref: $C005

Addresses that land inside something named

A name covers the byte it sits on, and a program reaches for the ones after it constantly. The second half of a two-byte pointer, the third field of a record, the operand of an instruction that another instruction patches: every one of them is a fixed distance from something you have already named, and every one of them read as four hex digits that said nothing about the line above.

They are named with the distance in them:

        lda savefile          ; $4000
        lda savefile+1        ; $4001 — was, and told you nothing
        sta patch+1           ; the operand of the lda below, not a stray address

It follows a reference into another file too — SAVEFILE::savefile+1 — and the exported source assembles as it reads, because savefile is what gets defined and +1 is arithmetic 6asm does.

What matters is how far it will reach, because a name that claims bytes it has nothing to do with is worse than the number it replaced. Inside a file the reach stops at the next label, at a region boundary, and at a block that runs somewhere else — the project already knows where one thing ends, so it is not guessing. Outside every file — a buffer, a save file the program loads somewhere — nothing says how big the thing is, so the only bound is a distance, 256 bytes by default and MaxLooseOffset in the settings. Zero page is left alone entirely: it is 256 independent one-byte variables packed edge to edge, and the one relationship that really does span two of them is a pointer pair, which you mark with H and which has spelled name+1 all along.

It also prefers names you typed. loc_4000+1 says nothing $4001 did not, so the tracer's own labels don't lend their names unless OffsetsFromAutoLabels asks them to — though one still stops the reach either way, because it owns its bytes whether or not it is willing to be named on them.

The definitions at the top of the file

Every other name in a listing is defined by a line you can see — a label sits on the bytes it names. The ones above have no bytes anywhere, so they used to exist only in the operands they changed: nothing said what SCREEN_ON was, what it was worth, or how many places had quietly started reading that way.

So the file carries them at its top, above the *=, in the three groups the exported source has always had:

; ---- addresses this file names but does not contain -----------------
ptr         = $FB
screen_copy = $C800                    ; double buffer   1 ref: $C005

; ---- project equates ------------------------------------------------
part2_entry = $2000                    ; loaded later   1 ref: $C00D

; ---- named values ---------------------------------------------------
SCREEN_ON   = $1B                      ; bitmap off, screen on   2 uses: $C000, $C008

Each line says where it is used, and every $hhhh in a comment is a link — so the definition is the way into every line reading it, the same way a label's xref: list is. The line itself is a link too: Enter, a double-click or a Ctrl+click on it goes to the first place in this file that reads it, and the value is drawn in the link colour when there is somewhere to go. A definition nothing uses yet is left alone rather than made to look like an offer it can't keep.

It goes to the use, not to the address — these are this file's own names for addresses it does not contain, and whatever else in the release happens to load over $2000 is a different thing that this name says nothing about. Following the definition never leaves the file.

The comment goes where the name goes. A label on bytes this file holds has a line of its own to carry what you wrote about it; these have none, so what you say at the top is read where you actually are:

; ---- addresses this file names but does not contain -----------------
savefile_age = $4074                   ; Age of character   1 ref: $C1D1

  ⋮

C1D1   AE 74 40   ldx savefile_age     ; Age of character

Right-click one to edit it or remove it: the dialog that made it opens on the thing it made, and removing it puts every operand that was reading it back to the number it was before anyone named it. Renaming really renames — the old definition goes, and any instruction pinned to the old name follows it.

Only what this file actually used appears: a project equate the file never refers to is not this file's business. And these lines are the export — the exporter no longer writes its own copy, so what you read at the top of the listing is what assembles at the top of the source.

Naming a value

An operand that refers somewhere can be named after what is there. lda #$1b refers nowhere: twenty-seven is a screen control byte, or a sprite colour, or a delay, or an index into something, and nothing in the bytes says which. That is a fact about the program, so it gets recorded like every other one.

V — or right-click the instruction — names the value. Say once that $1b is SCREEN_ON and every immediate in the project that loads it reads that way, including the ones you haven't found yet:

0810   A9 1B      lda #SCREEN_ON      ; $1B — bitmap off, 25 rows, screen on
0812   8D 11 D0   sta SCROLY

The number stays in the comment, because a listing that shows only the name can't be checked against the bytes column beside it.

Which is also why the dialog asks how far the name reaches, and counts what each choice would touch before you pick:

And a name that applies by value needs an exception, or it can't be used on the values that actually occur. $0f is LIGHT_GREY in the four places that set a sprite colour and a nibble mask in the and #$0f two lines down — Show #$0f here, not LIGHT_GREY says so at the one instruction without giving up the name at the other four.

Right-clicking an instruction offers whichever of the two questions it can answer — the address for an operand that points somewhere, the value for one that loads a byte, and neither for rts, which refers to nothing. As with the equates, the exported source defines what it used, so what you read is what assembles:

; ---- named values -------------------------------------------------
SCREEN_ON = $1B   ; bitmap off, 25 rows, screen on

Marked address halves are left alone: #<target is an address written as a value and is already named as one. Data tables are left alone too — a .byte run of $1b is not a run of immediates, and naming it would be a guess.

Code that doesn't run where it sits — drive code and relocated code

The load address answers where does this file live. A separate question, and one a fastloader asks on nearly every disk, is where does this part of it end up.

A loader carries its drive code inside an ordinary C64 file — a couple of hundred bytes that get sent over with M-W and run by the 1541's own 6502 at $0300. A depacker copies itself out of the way before it starts. A demo part moves a routine under the KERNAL. In every case the block's jsrs, branches, pointer tables and hardware addresses are written in the address space it will run in, and disassembling it where it is stored produces a listing in which nothing lands anywhere and no register has a name.

Select the block and say where it ends up:

From then on 6diss reads it there:

This is deliberately not a region kind. A region says what bytes are — and a block of drive code is routinely code and tables and a scratch buffer, all of which move together. The two axes cross: a pointer table inside a drive-code block is a table of drive addresses.

Mark as ▸ Runs where it sits takes it back, as does Ctrl+Z.

On export the block still assembles where it sits, so the rebuild stays byte-identical; its labels become equates at the addresses it runs at, so jsr sub_0310 assembles to 20 10 03 — the bytes the original had. Any 1541 names it used are defined in their own drive1541.equates.asm, never mixed into the C64 one.

Trace code from here

The static trace only finds what something reaches. A handler reached through a vector 6diss doesn't recognise, a routine a loader jumps to from another file, an overlay called by RAM-resident code — none of it is reachable, so it renders as a .byte blob even though you can see the lda #$00 / sta $d020 in the hex.

Put the cursor on the first byte of it and press E. The trace starts there and walks through the current classification — data regions do not stop it — so the routine, everything it branches to and everything it calls all come back as code in one step. The address is kept as an entry point, so later re-traces still start there.

This is the part marking a byte as code can't do: marking one byte seeds the trace, but the byte after it is still data, so the walk hits that wall and decodes a single instruction.

Go to (Ctrl+G) lands on the exact address you type even when it falls inside a .byte row — the byte cursor moves to it — so Ctrl+G 4022 then E traces from $4022, not from the start of the row it happens to be printed on.

If it decoded rubbish, Ctrl+Z.

Find code the trace missed

The same thing for the whole file at once, without being told where to look (Ctrl+Shift+F, or Analyse ▸ Find code the trace missed). Use it when you don't know how much you're missing — which is most of the time on a file you didn't write.

It decodes every unclassified byte as if it were an opcode and keeps the runs that hold together. About 40% of the 256 opcodes are undocumented, so a long run of clean ones that falls through instruction to instruction does not happen by accident, and the confidence on each proposal is that odds calculation — measured against how opcode-like the bytes in front of it actually are, then adjusted by what a person would check:

Every run is shown with that reasoning before anything is applied, ticked or unticked, and the whole thing is one Ctrl+Z. Accepting a run usually finds far more than the run itself: it becomes a trace root, so everything it calls comes in behind it.

Only genuinely unclassified bytes are examined — a region you (or the detector) have already typed is a decision, and this doesn't second-guess it. If you know there's code inside one, put the cursor on it and press E.

Idioms

Above the code that forms them, once, in the terms the author was thinking in:

C001  ; -- Turn off both CIAs' interrupts
C001  ;    Bit 7 clear in a write to ICR means 'clear the mask bits I have set', so
C001  ;    $7f clears all five sources. Reading ICR afterwards is what acknowledges
C001  ;    an interrupt already latched — skip it and the first IRQ after the cli
C001  ;    is the CIA's, not yours.
C001          lda   #$7F                      ; → CIA1ICR: TA=1 TB=1 ALRM=1 SP=1 FLG=1
C003          sta   CIA1ICR
C006          sta   CIA2ICR
C009          lda   CIA1ICR
C00C          lda   CIA2ICR

Eleven rules, each of which either matches exactly or says nothing: the raster wait (with the reminder that $d012 is eight bits of a nine-bit counter, so the loop matches two lines a frame), the $d011 bit-7 wait, the double-IRQ jitter equaliser, an IRQ chain arming its successor, the CIA disable above, the VIC interrupt acknowledge, the $01 bank switch spelled out per value, the $dd00 VIC bank with its two inverted bits and the character-ROM shadow in banks 0 and 2, screen blanking and the badline cycles it buys, the indexed and indirect copy loops, and the RTS jump.

This is a fixed rule, not the brain and not the assistant: it costs nothing to run, it knows the same on the hundredth disk as the first, and rules that would have to be hedged are simply absent — a listing full of "possibly a sprite multiplexer" is worse than a bare one. Nothing fires inside drive code, because $dc0d on a 1541 is not a CIA.

A constant is followed from where it is loaded to where it is stored, so the store is explained even when the two aren't adjacent — lda #$1b / sei / clc / sta $d011 reads as writes #$1B, loaded at $C000 → SCROLY: …. Deliberately shallow: it gives up at a branch target, a call, arithmetic, or a load from memory, because a wrong constant on a listing line costs far more than a missing one.

Depacking part of a file

Ctrl+Shift+U, or Analyse ▸ Depack selection…. Import already asks 6crunch about every file it reads — off a disk image or added loose, both the same way: decompressing it where a codec can, naming the cruncher from its stub signature where none can. That covers the packed release. What it can't reach is the packed blob that is not a file: a loader carrying its next part inside itself, a multi-part release whose second half starts eight hundred bytes in, a rip with a header nobody documented in front of the crunched data. In all three you can see the decruncher in the listing and the entropy in the hex, and the missing move is being able to say from here to here.

The selection is tested against every 6crunch codec and against ~600 packer stub signatures, and the two answers are kept apart because they are different claims: a codec sniff means "this can be decompressed, now"; a signature means "this is Exomizer" and nothing more. 6crunch names several hundred more packers than it undoes, and a button that failed on most of them would be worse than no button.

On a hit the range is marked Compressed with the packer named, and the payload is added as a separate binary at its own load address and opened — so the decruncher stub and what it unpacks are both readable. 6diss holds no packer knowledge of its own: what 6crunch learns to identify or undo lights up here with nothing to change. When it grows an execution engine — which is how unp64 depacks these in the first place — every one of those signatures becomes depackable through the same call.

Selecting from one byte off finds nothing, because a stub signature is tested at a fixed offset from the start of the selection. And since a decoder handed a stream that starts late reads garbage length fields, the attempt is given a deadline: the largest legitimate C64 payload is 64K and takes milliseconds, so anything still running after eight seconds is decoding noise and is reported as the misidentification it is.

Packers that are named but not undone

Most packed C64 files are self-extractors, and 6crunch decompresses about fifteen of the formats it can name. For all the rest, import records the identification anyway: the tree shows [Time Cruncher v4.2, still packed], the listing header says the code you are reading is the decruncher stub, and the file's notes carry the confidence and the other signature names the same stub matched. No Compressed region is set and no decrunched sibling appears — the payload really is still inside the file, and typing the whole thing as compressed would hide the decruncher, which is the one part of it that reads.

Three things will still miss, all of them properties of the signature set rather than bugs:

When a file you know to be packed isn't named, 6crunch identify <file> settles where the gap is: if 6crunch names it, 6diss should have shown it; if 6crunch is silent too, it is one of the three above.

Which playroutine is this?

Ctrl+Shift+M, or Analyse ▸ Identify music players… — the selection when there is one, the whole file otherwise. It matches the 788 players of Cadaver's SIDId database, shipped with 6diss under its BSD licence (sidid.LICENCE.txt in the download); drop a newer sidid.cfg beside the settings file and that one is used instead.

Naming the player is worth more than it looks. It tells you which of the two hundred unclassified bytes below the IRQ's jsr are the frequency table and which are the pulse table, it tells you the routine called every frame is somebody else's code and not worth reading, and it is often the only way to date a release.

The matcher is SIDId's own, ported token for token including its restart behaviour and the v1.07 fix for a false start inside an AND gap — the signatures were written against that algorithm over fifteen years, and a tidier one would quietly disagree with the database on exactly the awkward entries. Proposals are scored by how many bytes the signature pinned exactly, since that is the whole argument against coincidence. A match covers a fragment of the routine and says nothing about where the player's code and tables end, so the region it proposes is a starting point to widen. SIDId's own readme is blunt and worth repeating: no responsibility whatsoever is taken for the correctness of the existing signatures.

What have I seen before?

Ctrl+Shift+B, or Analyse ▸ What have I seen before?. Everything above is a fixed rule: it knows exactly as much on the hundredth disk as it did on the first. This is the part that gets better as you use it. It watches what you actually decide, keeps what it learns in a file, and brings it to the next binary. It is not the assistant — nothing leaves the machine, it costs nothing to run, and it can always tell you why.

Three things it learns, from the regions you confirm:

Each proposal says which of the three it came from and what decided it, and nothing is applied until you accept it. Ctrl+Z takes the lot back.

It learns from every file in the project on save, so ordinary work feeds it without your doing anything; Learn from this file does it now instead. What the brain knows prints what it has actually got — how many fingerprints, which kinds it has enough examples of to guess at, and what you've made of its proposals so far.

Two stores, one brain. The shared corpus lives beside the settings and carries across every project; the open project's own share of it is written into the .6diss as well, so a project you hand on arrives knowing what you worked out. Keep this project's learning private keeps a job off the shared pile without stopping it working inside its own project.

Undo

Ctrl+Z / Ctrl+Shift+Z step back and forth through every change made to any file in the project: labels, comments, region marks, entry points, traces, detector proposals and assistant suggestions. The Edit menu names the step it would take back. A hundred steps are kept, and they're per-session — opening a project starts a fresh history.

What it doesn't cover is the shape of the project: adding a file, and removing one. A step is one binary's decisions, which is the right shape for editing a file and the wrong one for the file itself going — so removal asks first instead, and takes its own undo steps with it.


Driving it from a script

6diss listens on a Unix domain socket ($XDG_RUNTIME_DIR/6diss/6diss-<pid>.sock, owner-only), so another process can ask the running window what it is showing and tell it what to change. Edits arrive through the same code as the menu items: one undo step, the listing redrawn under the caret, the project dirty until it is saved.

6dcli --live status                             # project, open files, where the caret is
6dcli --live list   LIBRARY --from '$2dc8' --to '$2e28'
6dcli --live goto   LIBRARY '$3355'             # move the caret, in front of you
6dcli --live rename LIBRARY '$2e13=char_load:party member $7EB1 -> $7C00' …
6dcli --live docs                               # the project's notes, and where they are
echo '## The class byte' | 6dcli --live write-doc 'Character record' --append

The whole menu is there, not only the naming. The decisions a listing is built on — where the file loads, that a block is drive code, which of six files loaded at $2000 a jsr means — are the ones a script most needs, because a name applied over a wrong load address is a name in the wrong address space:

6dcli --live load-address LIBRARY '$c000'       # everything moves with the bytes
6dcli --live overlay LOADER '$3000' '$30ff' '$1c00' --kind DriveCode
6dcli --live ref     LOADER PART3 --site '$2004'
6dcli --live find    'a9 ?? 8d 20 d0' --project # a byte pattern across every file
6dcli --live detect  LIBRARY                    # guesses, with reasons — applies nothing
6dcli --live render  LIBRARY font.png --kind Charset --from '$3800'
6dcli --live export  /tmp/src --exact           # then ./build.sh, and it compares

The analyses propose rather than apply: in the window each opens a review dialog showing the reasoning behind every guess, because a heuristic that silently retypes half a file is worse than no heuristic — so from a script they return an indexed list, and --apply --accept 0,3,7 takes the ones you believe.

That matters because the project file is only true as of the last save — the one you pressed or the one the five-minute timer made: reading it means reading a photograph, and writing to it races the next save. 6dcli also works against a .6diss directly for when nothing is running — and refuses to write to one a running instance has open.

The protocol is one JSON object per line each way, so nc -U and a heredoc is a complete client — and 6diss-mcp puts the same verbs behind MCP tools, so Claude Code can read the listing and name things in it directly:

claude mcp add 6diss -- 6diss-mcp

The control channel has the verbs and the tool list; 6dcli has the command line. Turn it off with "ControlChannel": false in the settings file.

Claude Code in a tab

Assistant ▸ Claude Code in a tab hosts the whole CLI as a child process and draws the conversation in the editor tab strip — the same Claude Code you would run in a terminal, with your account, your MCP servers and your skills, but beside the listing instead of beside the application. It is handed the 6diss MCP server on the way in, so it reads the window as you are looking at it and applies names into it as single undo steps.

It asks before it acts: a tool that needs approval stops and shows the call and its arguments in the transcript, with Allow and Deny. It runs in the project file's own directory, and it keeps one conversation per project, resumed the next time you open the tab. Findings that will not fit in a label go into a note — list_docs, read_doc, write_doc, open_doc — so what it works out stays with the project rather than leaving with the conversation.

Claude Code is found wherever it is — on PATH, in ~/.claude/local, or the self-contained binary the VS Code extension unpacks — so on most machines there is nothing to install. Keep 6diss-mcp on your PATH so the tab can find it.

Claude integration has the walkthrough.

Projects

A project is one JSON file, something.6diss. It holds the disk images it was built from, and for each binary: the bytes, the load address, the entry points, the regions, the labels, the comments and the line splits.

The bytes are embedded. A C64 binary is at most 64K and a whole disk's worth is a couple of hundred kilobytes, so a project is self-contained — it opens correctly after the source .d64 has moved or gone. The image path and hash are kept only so 6diss can tell you the disk has changed since you imported it.

The paths are relative. The disk images, and the host file a loose binary came from, are written relative to the project file whenever they are anywhere near it — so a project directory can be copied, shared or moved whole and still resolve them. When the file moves and the project doesn't, the tree marks a disk image [image missing]; right-click it for Locate image… (Change image path… when it isn't missing but you want to point at a different copy), and a loose binary has the same entry. Opening a missing file in VICE, 6hex or 6disk offers to locate it there and then. Re-pointing an image says whether the new file is the one the project was built from — the hash is checked, and a mismatch is reported rather than quietly accepted.

It says what it is doing while it opens. A six-hundred-file project is a minute of work — one trace per file to build the cross-file index, then a disassembly for each file that was open when it was saved — and for that minute a window that doesn't repaint is indistinguishable from one that has crashed. So the work reports itself, one file per line, before it reads each one: the last line in the log names the file currently taking the time, which is what you want to know while you are waiting and the one thing a percentage can't tell you.

Cross-referencing 607 files…
   MON10                 28 of 607
   MON13                 29 of 607
   …
Reopening 8 files…
   COMBAT                disassembling $0800-$2CF8
   LIBRARY               disassembling $2DC8-$4AD2

Nothing appears for a project that opens quickly — the window is held back until the work has run past a quarter of a second, then shown with everything logged so far already in it. Importing a disk image is the same loop and gets the same account of itself, and there the log stays up at the end, because the list of what was imported and what was skipped is the report.


Notes

File ▸ New note (Ctrl+Shift+N), or right-click the Notes row in the tree. A note is a markdown file in a folder named after the project:

champions.6diss
champions.docs/
  character-record.md
  how-the-loader-works.md

Not inside the .6diss, on purpose. The bytes are embedded because nothing else needs to read them; prose is the opposite. In a folder, git diffs it as prose rather than as a JSON string full of \n, your own editor opens it, and Claude Code reads and writes it with its ordinary file tools instead of through a hole punched in the control channel.

The folder is what says which notes exist. Drop a .md in — write one elsewhere, pull one from a colleague — and 6diss picks it up next time the project opens, or straight away from Notes ▸ Look for notes added outside 6diss. A note whose file has gone drops out of the tree the same way. The title comes from the note's own first heading when it has one. The paths are relative to the project file, so the notes move with the project directory.

Two sides, Ctrl+E between them. Read draws the markdown — headings, bullets and numbered lists, tables, block quotes, fenced and indented code, task lists. Edit gives you the source. Ctrl+S saves the project and every note you have typed into; a dot on the tab means there is typing not yet written.

Addresses are links. $c000, $2dc8, $fb in the prose are drawn as links, and clicking one puts the listing on it — in whichever file covers that address. That is the whole reason for reading notes here rather than in an editor beside the window. An address no file covers is named from the C64 memory map in the status bar instead.

Something else writing the file is the normal case. The tab watches it, so a note being written by Claude appears as it is written. The exception is a note you are part-way through editing: then the tab says the file changed and offers Keep mine and Take theirs, and picks neither.

Notes are the one thing in 6diss that Ctrl+Z does not cover — they are files that other programs are also writing, and an undo stack that claimed to reach into your editor would be claiming something it cannot do. The edit box has its own undo. Removing a note asks twice, because taking it out of the tree and deleting the only copy of an afternoon's reading are two different sizes of decision.


Region kinds

What a stretch of a file is — the central editorial decision, and what the renderer and the exporter both key off:

Code 6502 machine code
Bytes / Words .byte / .word tables
Pointers little-endian addresses; each entry becomes a label reference and seeds the tracer
Split pointers the lda lo,x / lda hi,x layout — rendered with the reconstructed addresses in the comment
Text (PETSCII / ASCII / screen codes) .text / .ascii / .screen
Character set 8 bytes per glyph, with the bitmap drawn in the comment
Sprites 24×21, one row per line, hi-res or multicolour
Bitmap / Screen RAM / Colour RAM VIC memory
BASIC program detokenised in place; a file that is BASIC throughout is a per-file setting, not a region — see below
Music a SID player and its data
Compressed with the packer named
Fill collapsed to a single .fill
Random data looked at and deliberately left unclassified

Where a block runs is not a region kind either. Drive code and relocated code are a separate axis that crosses this one — see Code that doesn't run where it sits.

A whole BASIC file is not a region. A region says what a stretch of one file is within a 6502 listing, which is right for the 10 SYS 2061 stub in front of a demo and wrong for a file that is BASIC from $0801 to its last byte — there is no listing for it to sit inside. That case is a per-file switch instead, Analyse ▸ File settings ▸ Disassemble as BASIC source: the file lists as the program, and exports as .bas rather than .asm. It is set on import when the line chain covers the whole file and left off otherwise, and you can change it either way. (Per-region BASIC source, for the file that is half program and half data, is not there yet.)

Detect data types (Ctrl+T) proposes classifications by the evidence a human uses: run lengths, the unused 64th byte of a sprite block, the blank-row density of a character set, words that all land inside the image, $2000 alignment. Every proposal shows its confidence and its reasoning, and nothing is applied until you accept it.


Cross-file labels

Every C64 binary on a disk shares one 64K address space, so a loader's jsr $2000 lands inside whichever file loads at $2000. 6diss resolves that automatically: the operand renders as MAIN::init, Enter opens that file at that address, and the inspector shows the reference from both sides.

Labels are module-scoped — two files may both define init — and you write MODULE::label anywhere 6diss asks for an address. A module name comes from the filename, sanitised to an identifier and made unique within the project; change it in Analyse ▸ File settings.

On export, since 6asm has no module syntax, :: is lowered to __:

; LOADER.asm
        .include "MAIN.exports.asm"
        jsr     MAIN__init

; MAIN.exports.asm  — generated
        .ifndef _MAIN_EXPORTS_
_MAIN_EXPORTS_ = 1
MAIN__init      = $2000
        .endif

MODULE__label is an ordinary identifier, so the emitted source assembles with today's 6asm unmodified.


Exporting, and byte-identical rebuilds

File ▸ Export project as 6asm source writes one .asm per binary, the generated MODULE.exports.asm for any file with exported labels, a c64.equates.asm defining every hardware name the disassembly used, and a build.sh.

A binary marked Disassemble as BASIC source (Analyse ▸ File settings) writes a MODULE.bas instead, and build.sh rebuilds it with 6basic bas2prg. 6diss re-tokenises what it wrote through 6basic's own compiler before the export dialog closes, so it can tell you the file rebuilds byte-identically rather than leaving you to find out.

Your comments come with it. A comment on a BASIC line is written after a ', which 6basic's preprocessor removes in the pass before anything is expanded or tokenised — so the comment can say {clear} or PRINT or quote a string without any of it turning into bytes:

{loadaddr:$0801}

10 PRINT"{clear}{white}"  ' clears to white before anything is drawn
20 POKE53280,0:POKE53281,0  ' border and background to black
30 FORi=0TO7:POKE2040+i,192+i:NEXTi  ' point the sprites at $3000
' the shape itself, six bytes to a row
40 DATA255,129,129,129,129,255

A tick is only a comment where 6basic reads code, so a line that ends inside a REM, an unclosed DATA statement or an unterminated string takes its comment on the line above instead — as line 40 does there, and as a multi-line comment always does. You write the comment; where it can safely go is 6diss's problem. And because the round-trip check runs on the commented text rather than on the bare program, a comment that somehow failed to be a comment would show up in the header as a line that no longer rebuilds.

It asks whether you want readable or byte-identical output. In byte-identical mode any instruction 6asm would not reassemble to the same opcode is emitted as .byte with the disassembly in a comment:

        .byte $EB,$00                    ; sbc #$00   (undocumented alias — 6asm emits the documented encoding)
        .byte $AD,$12,$00                ; lda $0012  (6asm would optimise this to zero page)

build.sh reassembles and cmps against the original bytes.


The assistant

Assistant ▸ Provider settings picks the back-end:

Three actions:

The model is sent the rendered listing window, the file's load geometry, the other files in the project and their address ranges, and the documented hardware the selection touches. Suggestions arrive as a checklist. The design rule throughout: the model is a fast second reader, not an authority — nothing it proposes reaches the project until you tick it, and what you accept is tagged Assistant and shown in a distinct colour.

Reverse engineering sits close enough to what safety classifiers watch that a request is occasionally declined; 6diss reports that plainly rather than as an error.

Claude integration is the step-by-step: getting a credential in place, the first analysis pass, exactly what leaves the machine — and the three ways to work with Claude Code: beside 6diss on the exported source, over the control channel into the running window, or inside it as a tab.