Contents
The control channel — driving the running 6diss
6diss listens on a Unix domain socket so another process can ask it what it is showing and tell it what to change. The window stays in charge: an edit that arrives over the socket goes through the same code as the menu item, so it is one undo step, the listing redraws under the caret, and the project is dirty until you save it.
This is the answer to the one real problem with scripting a disassembler that a person is also using: the file on disk is not the truth. It is the truth as of the last save — whichever of Ctrl+S and the window's five-minute auto-save came last — and writing to it races the next one. Ask the window instead.
Is it on?
It is, unless ControlChannel is false in ~/.config/6diss/settings.json. Each running
instance binds $XDG_RUNTIME_DIR/6diss/6diss-<pid>.sock, owner-only; the next instance to
start sweeps away sockets whose process has gone.
6dcli --live instances
# pid 2537361 /home/me/game.6diss /run/user/1000/6diss/6diss-2537361.sock
With more than one instance running, --pid N picks one; without it the newest wins and
says so on stderr.
From the shell
Every read verb 6dcli has against a file works against the window with --live, and the
project argument goes away — the window already has one open.
6dcli --live status # project, open files, caret, what undo would take back
6dcli --live files
6dcli --live list LIBRARY --from '$2dc8' --to '$2e28'
6dcli --live labels LIBRARY --user
6dcli --live xrefs LIBRARY '$2e84'
6dcli --live bytes LIBRARY '$33dd' --len 32
Then the ones that only make sense against something running:
6dcli --live caret # where the user is looking, and at what
6dcli --live goto LIBRARY '$3355' # move the caret there, in front of them
6dcli --live undo | redo | save
And the writes:
6dcli --live rename LIBRARY --role sub \
'$2e13=lib_char_load:party member $7EB1 -> the work area at $7C00' \
'$2e16=lib_char_store'
6dcli --live comment LIBRARY '$2e84=row A -> screen ($4C) and colour ($A3)' [--block]
6dcli --live unlabel LIBRARY '$2e84'
6dcli --live region LIBRARY '$33dd' '$33e8' Bytes
6dcli --live entry LIBRARY '$3046' # trace from there, keep it as an entry point
And the rest of the menu, which is the part that arrived later. The decisions above are
about what things are called; these are about what the file is — and a client that
can name a routine but cannot say the file loads at $C000 is naming things in the wrong
address space:
6dcli --live load-address LIBRARY '$c000' # every label, region and comment moves with the bytes
6dcli --live load-address LOOSE --embedded # take the file's own first two bytes
6dcli --live describe LIBRARY --description 'resident runtime, called from every overlay'
6dcli --live same-file LIBRARY.BAK # same bytes as LIBRARY: use its recipe, not a second one
6dcli --live define LIBRARY '$ffd2' 'CHROUT' --scope project
6dcli --live constant LIBRARY '$1b' SCREEN_ON # what twenty-seven means
6dcli --live address-load LIBRARY '$8896' --low '$2e13' --high '$2e17' --name work_area
6dcli --live overlay LIBRARY '$3000' '$30ff' '$1c00' --kind DriveCode --name loader
6dcli --live ref LOADER PART3 --site '$2004' # which $2000 that jsr means
6dcli --live split TEXT --from '$4000' --to '$40ff' --every 8
6dcli --live move-label LIBRARY '$2e14' --from '$2e13'
And the analyses, which propose rather than apply — read them, then say which you believe:
6dcli --live retrace LIBRARY
6dcli --live find-code LIBRARY # runs the trace never reached
6dcli --live find-code LIBRARY --apply --accept 0,3,7
6dcli --live detect LIBRARY # what the unclassified bytes look like
6dcli --live brain LIBRARY # what you have classified before
6dcli --live players LIBRARY --apply # which playroutine is in here
6dcli --live depack LOADER --from '$0900' --to '$1fff' --apply
6dcli --live find 'a9 ?? 8d 20 d0' --project # a byte pattern across every file
6dcli --live regions LIBRARY
And the ones that write a file rather than an answer. Reach for these whenever you want the whole of something — to grep it, to count things in it, or to hand it to a tool that takes a path:
6dcli --live export /tmp/src --exact # then ./build.sh, and it compares
6dcli --live render LIBRARY /tmp/font.png --kind Charset --from '$3800'
6dcli --live dump LIBRARY /tmp/library.lst # the listing, as a file
6dcli --live dump LIBRARY /tmp/library.bin --bytes
6dcli --live dump --all --directory /tmp/base # every file's image, plus a manifest
dump --all is the one that is hard to do any other way. A project assembled from eight
disk sides already holds every byte that was imported; re-extracting them from the D64s to
run a format decoder over them is doing work 6diss has done, and it loses the load addresses
the project worked out. The manifest carries those.
Files 6diss cannot read itself
A Gold Box ECLnn script is bytecode for an interpreter, not 6502, and neither a code
trace nor a hex dump is a reading of it. A tool that knows the format can be pointed at it
and its output shown in place of the listing — with the gutter, the byte cursor and search
still working, because an address column in the tool's output is read back as the line's
address.
6dcli --live source-tool --tool ecl --command "ecldis.py --game auto --addresses"
6dcli --live source-tool --match 'ECL??' --tool ecl
6dcli --live describe ECL64 --no-tool # this one file, whatever the glob says
6dcli --live source-tool # what is set up, and which files it covers
The command lives in this machine's settings and only the key lives in the project, so
opening someone else's project cannot run a program they chose. A tool says how it wants
the file (--input prg|raw, for a format a PRG header would corrupt) and what a comment
looks like in its language (--comment), because comments you write on the listing are
exported with it.
The questions 6diss can now answer itself
# A table's VALUES, not its listing text. Give it a jump table's address and it
# enumerates where all 32 entries go, which is the only reason anyone opens one.
6dcli --live decode LIBRARY '$2dc8'
6dcli --live decode TEXT '$9658' --kind PointersSplit
# Which byte offsets in a record actually carry anything — the first analysis anybody
# does on an unknown format, and the one that turns 69 anonymous blobs into a field map.
6dcli --live variance 'MON*' --varying-only
6dcli --live variance BESTIARY --stride 480 --apply # writes the map back as comments
# Whether two ranges hold the same bytes, and — the part that matters — how they differ.
6dcli --live compare MON01 MON02 --stride 480
6dcli --live compare --all # which files are really the same file, and which
# names quietly carry two different builds
# Aggregate xrefs: the most-called routines are the library; the zero page a file
# touches is its state; the registers it writes say what it is for.
6dcli --live graph LIBRARY --calls-only
6dcli --live graph LIBRARY --touched write --from '$d000' --to '$dfff'
# Find a pointer table nobody declared, or score one somebody did. Tries BOTH
# half-orderings and every plausible entry count, because those are the two mistakes a
# manual search makes — and both produce a plausible-looking wrong answer.
6dcli --live pointers TEXT --strings-from '$9400' --strings-to '$9657'
6dcli --live pointers TEXT --validate '$9658'
# What A, X and Y hold at an instruction — computed already, for the listing comments.
6dcli --live registers LIBRARY --from '$3007' --to '$3020' --known-only
# Execute it. Not a C64 — no VIC, no SID, no IRQs, $D000-$DFFF is RAM — but it answers
# "what does this routine actually produce" without starting an emulator.
6dcli --live run LIBRARY --entry '$3007' --a 6 --x 7
6dcli --live run LOADER --entry '$0900' --write-to /tmp/decrunched.bin
Every one of these works against a file on disk too, without --live and with the project
path in front — none of them needs a window.
Driving the window itself
For working on 6diss rather than on a project. Before these existed, testing a UI change
meant xdotool and scrot and clicks at hard-coded pixel coordinates — which breaks when a
menu moves and is invisible in CI.
6dcli --live invoke # every command, its label, and whether it is enabled
6dcli --live invoke MenuClaudeTab # fire one, through the same handler the menu runs
6dcli --live screenshot /tmp/win.png [--control ListingPane] [--scale 2]
6dcli --live measure ConversationScroll # geometry — and whether a control is below the fold
measure is the one that is easy to leave out and hardest to replace. "Are the Allow and
Deny buttons below the fold?" is a number, not a picture, and it is answerable even when
the control is currently scrolled out of sight — which is exactly the case worth asking
about.
And the project's notes, which are markdown files in <project>.docs/ rather than anything
inside the .6diss — see Notes below:
6dcli --live docs # every note, with its path and first line
6dcli --live doc 'Character record' # print one
6dcli --live open-doc 'Character record' # put it in front of the user
echo '## The class byte at +$0f' | 6dcli --live write-doc 'Character record' --append
A batch of names is one undo step. That is deliberate: thirty-two names worked out in one pass are one decision, and the undo stack is a hundred deep — applying them one step at a time would swallow the history in front of them.
From Claude Code — the MCP server
6diss-mcp puts the same channel behind MCP tools, so Claude Code can read the listing the
window is drawing and name things in it directly, instead of shelling out to a CLI and
parsing the output back. It is in the download.
claude mcp add 6diss -- 6diss-mcp # once, for every project
The repo also carries a .mcp.json, so Claude Code started inside the 6diss checkout picks
it up with no setup at all.
| Tool | |
|---|---|
status |
project, open tabs, caret, undo — call this first |
list_files |
every binary with its address range |
read_disassembly |
the rendered listing for a range |
read_labels |
names, with user_only to see just the human's |
find_xrefs |
who references an address, across files |
read_bytes |
hex + ASCII |
read_caret |
resolves "this", "here", "the routine I'm looking at" |
list_regions |
what has been classified so far, plus overlays and entry points |
search |
text, names, operands or a byte pattern (a9 ?? 8d 20 d0), one file or all of them |
go_to |
move the caret and raise the window |
set_labels |
a batch as one undo step |
set_comments, remove_labels, set_region, trace_from, undo |
|
set_load_address |
where the file really runs — the decision every other one depends on |
describe_file |
the banner at the top of a file; renaming a module repoints what refers to it |
mark_duplicate |
this file is the same file as that one — it borrows the recipe rather than needing one |
define_address |
name an address the file refers to and may not contain |
name_value |
what lda #$1b is loading — the half of naming no analysis can reach |
move_label |
put a label on the byte it belongs to, keeping its name and comment |
mark_address_load |
the two immediates that are really halves of one address |
set_overlay |
this block runs somewhere else — drive code, or code copied and jumped to |
point_reference |
which of six files loaded at $2000 that jsr $2000 means |
set_splits |
where a line starts inside a table |
retrace, find_code, detect_data, recall_patterns, identify_players, depack |
the analyses — they propose, and apply only what you accept |
export_source |
6asm source on disk; exact makes the rebuild verifiable byte for byte |
render_graphics |
a charset, sprites or a bitmap as a PNG — the one answer that is not text |
list_docs |
the project's notes — read this early, a previous session may have written down what you are about to work out |
read_doc, write_doc, open_doc |
prose, where the findings go |
write_listing, write_bytes |
the same reads, onto disk — for when you want the whole of something rather than a piece you are about to read |
write_all_files |
every file's image into a directory, with a manifest — the project, back out as files |
decode_region |
a table's decoded values: pointer targets, strings, the 32 entries of a jump table |
column_variance |
which byte offsets in a record carry information — the field map, in one call |
compare_ranges |
equal, or exactly how they differ; all sweeps the project for copies and for names carrying two builds |
call_graph |
the most-called routines, and every address the code reads or writes |
find_pointers |
find an undeclared pointer table, or score a declared one — both orderings, both alignments |
read_register_state |
what A, X and Y hold at an instruction, and where each came from |
run_code |
execute a routine against the project's memory and report what changed — not a C64, but it answers "what does this produce" |
invoke_command, screenshot_window, measure_control |
the window itself, for working on 6diss |
The analyses are deliberately two calls. In the window each of them opens a review dialog
with the reasoning behind every proposal, because a heuristic that silently retypes half a
file is worse than no heuristic — and dropping the dialog for a headless caller must not
drop the review with it. So they return an indexed list, and apply with accept: [0,3,7]
takes the ones you believe.
There is deliberately no save tool. An edit lands in the open project and is not written
out by you — committing someone's project is not a decision to hand to a model. What writes
it is the person at the keyboard pressing Ctrl+S, or the window's own five-minute auto-save
picking the change up like any other; either way the timing is theirs, not yours.
6dcli --live save exists for when you want it.
The tool descriptions carry the working rules with them: batch your names or you bury the user's undo history, read the human's labels before inventing a style, name the vector rather than its target in a jump table. That is on purpose — the model reads those descriptions on every request, and they are the only guidance that always arrives.
From anything else
The protocol is one JSON object per line each way, so a client is nc and a heredoc:
echo '{"cmd":"labels","module":"LIBRARY","user":true}' \
| nc -U /run/user/1000/6diss/6diss-$(pgrep -x 6diss).sock
Requests are {"cmd":"…", …}. Replies are {"ok":true,"result":…} or
{"ok":false,"error":"…"}. Addresses may be "$2dc8", "2dc8", "0x2dc8" or a decimal
number, in any field that takes one. Omitting module means "the file the user is looking
at".
| cmd | arguments | |
|---|---|---|
hello |
pid, socket, open project path | |
status |
project, dirty, open modules, active caret, undo/redo names | |
files |
every binary, with its range and label count | |
list |
module, from, to |
rendered listing lines |
labels |
module, from, to, user |
|
xrefs |
module, address |
from this file and from the others |
bytes |
module, address, length |
hex + ASCII rows |
caret |
module, address, line text, selection | |
goto |
module, address |
moves the caret and raises the window |
label |
module, labels[], origin |
one undo step for the batch |
unlabel |
module, addresses[] |
|
comment |
module, comments[] |
each {address, text, block?, assistant?} |
region |
module, start, end, kind, stride |
|
entry |
module, address |
trace from there and keep it as an entry point |
docs |
the project's notes, with paths | |
doc |
title |
one note's text — from the tab when it is open and edited |
writedoc |
title, text, append |
creates the note if there is none of that name |
removedoc |
title, delete |
delete also removes the file |
opendoc |
title |
opens it in a tab and raises the window |
undo / redo |
||
save |
||
regions |
module |
every region with kind, stride and options; the overlays; the entry points |
find |
text, module, project, comments, labels, operands, bytes, text_data, match_case, limit |
nothing said about scope means all of them |
loadaddress |
module, address, header, embedded |
header and address move together — see below |
describe |
module, description, notes, name, module_name, type, show_header, basic, tool |
tool is a source-tool key, "" for none, default to let the project's patterns decide |
duplicate |
module, of, clear |
no of means "find the file with these exact bytes"; see below |
define |
module, address, name, comment, scope (file/owner/project), remove |
|
movelabel |
module, to, from |
no from means the nearest label |
constant |
module, value, name, scope (project/file/instruction), site, comment, raw, remove, clear |
value is decimal unless written $1b |
split |
module, address (toggle), or start+end+every, or clear |
|
addressload |
module, target, low, high, pointer, name, pointer_name; or clear+start+end |
|
overlay |
module, start, end, exec, kind, name, note; or clear |
|
ref |
module, target, site or start+end, scope: file, overlay, load, name, clear |
"target": "" means "nothing in this project" |
retrace |
module |
|
findcode / detect / brain |
module, apply, accept[], min_confidence |
proposals out, acceptances in |
players |
module, start, end, apply |
|
depack |
module, start, end, apply |
identification alone is worth having |
export |
directory, module, exact |
|
render |
module, path, kind, start, end, and the preview options |
writes a PNG |
The load address
loadaddress is the one worth reading twice, because the two numbers are one question. A
PRG's first two bytes are a claim, not a fact — a packer, a loader with its own directory,
or a release that simply lies will all put a file somewhere other than its header says.
What you worked out is about the bytes, which have not moved, so the labels, regions,
comments, entry points and splits all shift with them and the listing reads the same at the
address it really runs at.
header says how many bytes in front of the image are not part of the program. Set it
with address: setting either alone slides every annotation against the bytes it
describes by two, which is the classic way a corrected load address ends up two bytes out.
"embedded": true takes the file's own first two bytes, for a blob that was imported raw
and turns out to be a PRG after all.
The same file twice
A disk holds the same file twice more often than not — the loader on both sides, the music
under two names, a part next to its backup, the same routine in two releases on one
compilation. Disassembling the second copy is not more work, it is the same work done
again and then kept in step by hand: you name a routine in one and the other is still
loc_0810.
duplicate says so instead. The copy keeps its own name, module, notes and provenance, and
borrows the original's recipe — every label, comment, region, split, overlay, entry
point, constant and reference claim. Borrowing is live rather than a snapshot: a name typed
in either file is the same name.
6dcli --live same-file LOADER.BAK # only succeeds if some file has these exact bytes here
6dcli --live same-file PART2A --of PART2 # or say which, when the bytes differ
6dcli --live same-file LOADER.BAK --clear # take a private copy of the recipe and diverge
Called with no of, the command asks the project which of its files holds these exact bytes
at this exact address, and fails rather than guesses — which makes it a confirmation, and
the form to reach for. files reports the answer both ways: duplicate_of on the copy,
duplicates on the original.
Three consequences, all of them the point:
- the copy adopts the original's load address and header size, because a shared recipe
is a set of absolute addresses.
loadaddressaimed at a copy is applied to the original and the copies move with it; - the copy stops being exported separately, stops answering cross-file references (the
original is what a
jsrinto those bytes is attributed to), and stops appearing as a second caller in project-wide xrefs; - whatever the copy had worked out on its own is dropped. It is one undo step.
Removing the original does not take the recipe with it while a copy is still in the project: one of the survivors inherits it and the rest follow that one.
Notes
writedoc is the one write here that is not an undo step, and deliberately so. A note is
a real .md file in <project>.docs/, which an editor and Claude Code are also writing; an
undo stack that claimed to be able to take back a write to it would be claiming something it
cannot do. What it does instead is land in the tab while the user is reading it.
Two consequences worth knowing:
- If the note is open in a tab, the write goes through the tab and saves it, rather than behind it. Writing the file underneath an open tab would be thrown away by that tab's next save.
- The folder is the truth about which notes exist. A note written into it by any other means
is picked up by the next
docscall, so a client with file tools may simply write the.mditself — the paths in thedocsanswer are there for exactly that.
A project that has never been saved has no folder to keep notes in, and writedoc says so
rather than guessing a location.
Writing to the file instead
6dcli rename <project> … still edits a .6diss directly, and now it asks the running
instances whether any of them has that project open before it does. If one does, it stops
and points at --live; if none answers — an older build, the channel off — nothing is known
to be at risk and it goes ahead. --force overrides, still writes a .bak, and still means
the project has to be reopened rather than saved.
What it is not
No authentication, and none needed: the socket is 0600 in the user's own runtime
directory, so anything that can reach it can already read the user's files and attach a
debugger to the process. It is never bound to a network socket. If that is still one door
too many, "ControlChannel": false closes it.