Skip to content

feat(asm): 6502 disassembler -- bytes back to source, verified by round-trip #121

Description

@relmer

Goal

Turn assembled bytes back into source. CassoCore has no disassembler today — the only occurrences of "disassemble" in the tree are comments in Cpu.h and Microcode.h about opcode tables.

This closes the build loop in reverse. Three of the four steps already work:

CassoCli disk list mydisk.dsk                                  # A 002 HELLO / B 002 PROG / A 002 STARTUP
CassoCli disk get mydisk.dsk PROG --out back.bin               # PROG: loads at $6000, 33 bytes
CassoCli disk get mydisk.dsk STARTUP --basic --out back.bas    # 10  PRINT  CHR$ (4);"BRUN PROG"

back.bin comes back byte-identical, and the load address survives — DOS 3.3 does not record it in the catalog, but a B file carries it in its own 4-byte header and get reports it. What is missing is the step that turns back.bin into prog.a65.

Three customers, one component

All three need the same "bytes at an address → one rendered instruction" primitive. Build it once, in core, where a test can reach it.

Code versus data is undecidable in general

Recursive descent from a known entry point is the right starting algorithm, and on ordinary code it is close to sufficient. It is not theoretically complete, and the 6502 hits every exception in ordinary Apple II code rather than in contrived cases:

  • Indirect jumps. JMP ($06) and jump tables. The target is a runtime value; static analysis cannot name it.
  • Computed RTS. Push an address, RTS to it. A standard 6502 dispatch idiom, and invisible to a flow walk.
  • Self-modifying code. Writing an operand byte before executing it. The bytes on disk are not the bytes that run.
  • Inline data after JSR. The callee pops the return address, reads the string that follows the call, and adjusts the return. The bytes after a JSR are data, and only the callee's behavior says so.
  • Entry points nobody declared. Interrupt vectors, callbacks handed to ROM.

Deciding reachability with computed control flow is the halting problem. So the design should not pretend otherwise: it should be verifiable rather than provably complete.

The oracle makes it tractable

The output has a property that can be checked: assemble it and compare bytes.

prog.a65 --assemble--> prog.bin --put--> disk --get--> back.bin --disassemble--> back.a65
back.a65 --assemble--> must equal back.bin, byte for byte

If it round-trips, the disassembly is correct as a reproduction, even where a block of real code was rendered as .byte because nothing reached it. That is a weaker claim than "we identified all the code" and it is the one that can actually be tested, on any binary, without a human reading the output.

This is the same guarantee --basic already makes for Applesoft, and it should be asserted the same way.

Casso can do better than static analysis

The unique advantage here is that this repository contains an emulator. Cpu already keeps a circular instruction trace (Cpu.h:195, --trace), stamped with interrupt-dispatch tags.

So: run the binary and record which addresses actually executed. A trace-informed disassembly resolves indirect jumps, computed RTS and self-modifying code for the paths taken, which static analysis cannot do at all. Static recursive descent and emulator coverage are complementary — the walk finds code the trace never reached, the trace finds code the walk could not follow.

Nothing else in the loop needs building for this: HeadlessHost already drives the CPU with no window.

Sketch

  1. Disassembler in CassoCore: bytes + origin → rendered instructions. Reads the opcode tables the assembler already owns, so it is dialect-aware for free, and honors assemblerHidden in Microcode.h so undocumented opcodes render honestly.
  2. Recursive descent from declared entry points; everything unreached emitted as .byte.
  3. Optional trace input to widen the reachable set.
  4. Caller-supplied hints: extra entry points, explicit data ranges, label names.
  5. CLI surface — a disasm mode, plus --org for a raw file with no header to carry the address.

Acceptance

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions