Oliver VieBrooks

6diss / 6diss control channel

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:

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:

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.