Contents
- What it does
- The window
- Keys
- Finding things
- Back and forward
- What calls this from another file
- Following an address in a comment
- Selecting bytes, not lines
- Splitting lines
- Addresses built in pieces
- Which file does this reference mean?
- It remembers where you were
- It saves itself
- Arranging the tabs
- Closing files
- Leaving
- Removing a file or a disk
- Copying
- Moving labels
- Removing labels
- Saying what a file is
- Where the file really loads
- Naming an address the file doesn't contain
- Addresses that land inside something named
- The definitions at the top of the file
- Naming a value
- Code that doesn't run where it sits — drive code and relocated code
- Trace code from here
- Find code the trace missed
- Idioms
- Depacking part of a file
- Packers that are named but not undone
- Which playroutine is this?
- What have I seen before?
- Undo
- Driving it from a script
- Claude Code in a tab
- Projects
- Notes
- Region kinds
- Cross-file labels
- Exporting, and byte-identical rebuilds
- The assistant
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.
- Tree — one node per disk image with its files beneath, plus a node for loose and
derived binaries. Double-click to open. A file you have described shows that description
under its name, which is what tells
MON01fromMON29. Right-click to describe it without opening it, or to hand the image or the file to another program: a disk image goes to VICE (autostarted), 6hex or 6disk; a file goes to the first two, since a PRG is not a disk image and the third entry would exist only to produce an error. A file taken off a disk image has no host path, so 6diss writes it out to/tmp/6dissas a PRG first — with the load address it has worked out, not the one the header claims, so what VICE runs is what the listing describes. - Editor — the listing: address, raw bytes, label, mnemonic, operand, comment.
Undocumented opcodes are red, cross-file references teal, graphics data purple, BASIC
program lines yellow. Inside the operand, a literal, a name and an index register are
three different things and are coloured as three:
lda #$1bputs the#back with the brackets and commas,sta dat_2000,xseparates the table from the register indexing it, and a.textstring and a.byterun no longer read as the same grey wall. What the name is coloured stays a fact about where it points — teal for another file, the region's colour for data — because that outranks a fact about its spelling. - Inspector — Info (what's at the caret, the knowledgebase entry for what the operand names, the register's bit fields with their current values, and the cross-references), Graphics, Regions, Labels, Find, and Assistant. Clicking a row in any of the lists goes to it; right-clicking one acts on it — rename or remove the label, change what the region is or reset it, open the file a cross-reference was made in, copy the knowledgebase entry as a comment block ready to paste above a routine. The row is not selected on the way, because selecting one navigates, and a menu asking what you can do with a row should not go there before you have asked. Right-click the empty space below the rows for the list itself: copy them all, clear the filter, run the detector.
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:
- the whole project — every
#$1bin every file. The default, and the reason this is worth doing at all; - this file — when the other parts use the same byte for something else;
- this instruction only — what
$00and$ffare for. The definition still exports, and another instruction can be pointed at it later.
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:
- Mark as ▸ Drive code… (
Ctrl+Shift+D) — it runs on the 1541. - Mark as ▸ Relocated code… (
Ctrl+Shift+R) — it runs elsewhere in the C64.
From then on 6diss reads it there:
- The gutter counts in the addresses the block will have, in a different colour, so you can always see which of the two spaces you are looking at. Everything underneath is still keyed by where the bytes are — selecting, marking and editing are unchanged — and the inspector shows both addresses.
- The tracer follows the block's own jumps.
jsr $0310inside drive code means twelve bytes further into the block, not$0310in the C64's memory, so the routine it calls gets disassembled instead of the walk stopping dead at the first call. - Labels are named for where it runs —
sub_0310, notsub_4012— because that is the number every operand in the block already refers to it by. A named block prefixes them, so two routines both sent to$0300don't fight over the name. - Drive code is annotated from the 1541's map, not the C64's.
$00–$05is the job queue (and the value about to be written to it is named: READ a sector into the buffer),$1800and$1C00are the drive's two VIAs down to the bit — stepper, motor, LED, write-protect sense, density, SYNC — and$C000–$FFFFis the DOS ROM with its routines named, sojsr $F2F9reads as head transport. On a C64 those same addresses are a CIA mirror and free RAM; annotating drive code from the C64 map isn't unhelpful, it's wrong. - A reference from ordinary code into the block says what it is really touching. The
loader patching a byte of its drive code before sending it gets
sta $4003 ; drv, $0303 once it runs on the 1541rather than a label that would claim it stored somewhere it didn't.
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:
- does the run end in an
rts/rti/jmp, or run into code already traced - does it call or jump into something already known to be code
- do its branches land on its own instruction boundaries rather than mid-operand
- do its operands name addresses that exist
- does it start where a routine ended, or at an address the code assembles a byte at a time and stores somewhere the tracer can't follow
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:
- The load address has to be real. Signatures test absolute addresses, and nearly all
of them sit between
$080Band$09C4— they assume the file loads at$0801. A packed file that loads at$C000won't match even with an identical stub. For a.prgthe address comes from the file; for a headerless blob it is your guess, so fix the load address (Ctrl+L) before drawing conclusions. - Matching is all-or-nothing. Every anchor in a signature must match, so one patched byte in a cracked stub loses the identification entirely.
- The set is not all of unp64. Only scanners that are a flat set of fixed-offset compares survived the port into 6crunch; ones using loops or computed offsets did not.
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:
- Signatures — exact recall. Every routine and table you classify is fingerprinted.
Code is fingerprinted with its absolute operands masked out, so the same routine hashes
the same wherever it loads: the player you named at
$1000last week is recognised at$C000this week. Tables are fingerprinted by their length and both ends. On a scene disk, where the same cruncher, IRQ loader and player turn up release after release, this is the part that pays. - Shape — generalisation. Every confirmed region is reduced to twenty measurements — entropy, bit density, 8- and 64-byte periodicity, how much decodes as opcodes, how many of its words land inside the image — and those become per-kind statistics it scores unclassified bytes against. It doesn't recognise a particular charset; it recognises what your charsets look like, which is what lets it name one it has never seen. It needs a few examples of a kind, and two kinds to weigh against each other, before it says anything at all.
- Calibration — taste. Every proposal you tick or untick trains the rule that produced it, so one you keep rejecting stops shouting. Unticking is worth as much as ticking, which is why the review dialog is worth going through rather than round.
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:
- Anthropic — via the official SDK. Leave the key blank to use
ANTHROPIC_API_KEYor anant auth loginprofile, which keeps it out of 6diss's settings file. Default modelclaude-opus-5; reasoning effort is a setting. - Local — any OpenAI-compatible server: LM Studio (
http://localhost:1234/v1), Ollama (:11434/v1), llama.cpp, vLLM. Nothing leaves the machine.
Three actions:
- Analyse selection (
Ctrl+I) — proposes labels, comments, region types and entry points for the selected code. - Classify unknown data — a whole-file pass over everything still unclassified, with a hex sample of each run.
- Ask a question — free-form, prose back.
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.