zendis
A disassembler for the Zilog Z80 and the Z80N (the Spectrum Next's
extended Z80), written in Go.
zendis is a new sibling to zen80
(a Z80/Z80N CPU core) and zenas (a
Z80 assembler). It doesn't depend on either at runtime: zen80 is an
execution engine with no mnemonic tables to reuse, so zendis's decoder
is a new implementation of the standard opcode decomposition, built to
follow the same x/y/z/p/q bit-field structure zen80 uses internally
for dispatch. (zen80 is pulled in as a test-only dependency, to
cross-check zendis's Z80N decode claims against zen80's own execution
— see pkg/disasm/decode_oracle_test.go.) Output uses 0x-prefixed
hex, one of the numeric forms zenas already accepts as input, so a
zendis listing can be fed back into zenas without translation.
Status
Unprefixed, CB-prefixed, ED-prefixed (classic Z80 and Z80N), and
DD/FD-prefixed (including the DDCB/FDCB sub-form) instructions all
decode to mnemonics. Z80N's own ED-prefixed extensions (NEXTREG,
MUL D,E, the LDIX/LDDX/LDWS block-copy family, and the rest —
all 29 opcodes) resolve under --target=z80n; the default
--target=z80 leaves those slots undefined rather than risk
mislabelling real classic-Z80 code, and warns on stderr
(--warn-z80n, on by default) whenever an undefined opcode matches a
Z80N instruction. JP/JR/CALL/DJNZ targets are replaced with generated
labels (Lxxxx) by default. See docs/TRACKING.md for what's tracked
as open work.
Usage
go build -o zendis ./cmd/zendis
./zendis --org=0x8000 game.bin
./zendis --org=0x8000 --target=z80n next-game.bin
--target selects z80 (default) or z80n. Under --target=z80,
--warn-z80n (default: on) prints a stderr line whenever an undefined
opcode matches a Z80N instruction, as a nudge to rerun with
--target=z80n if the binary is actually Next code; --warn-z80n=false
silences it.
8000: 00 NOP
8001: 3E 2A LD A,0x2A
8003: DD 21 00 80 LD IX,0x8000
8007: DD 7E 05 LD A,(IX+0x05)
800A: CB 7C BIT 7,H
800C: ED B0 LDIR
800E: C3 00 00 JP L0000
--labels (default: on) replaces the JP target above with a generated
Lxxxx label, one per unique referenced address, resolved in a first
pass over the whole buffer so forward references work — a branch to
an address decoded later still gets the same label a backward branch
to it would. --labels=false reverts to raw 0x-prefixed addresses.
Only JP, JR, CALL, and DJNZ get labels; JP (HL)/(IX)/(IY)
(register-computed, no encoded target) and RST (a fixed, well-known
vector) are left alone.
A line prefixed with * instead of a space is an opcode zendis
couldn't resolve to a mnemonic under the active target (see Status
above). A truncated instruction at the end of the buffer is emitted as
a DB line of raw bytes rather than causing a hard failure, so a
partial or misaligned binary still produces a listing for the bytes
that do decode.
Real ZX Spectrum software is overwhelmingly distributed as tape images
or machine snapshots, not pre-extracted raw binaries -- --tape and
--snapshot load directly from either instead of requiring that
extraction as a separate step first:
./zendis --tape=game.tap --format=listing
./zendis --tape=game.tzx --block-name=CODE --format=listing
./zendis --snapshot=game.sna --format=pasmo
./zendis --snapshot=game.z80 --org=0x8000 --length=0x2000 --format=listing
Exactly one input source is required: a positional raw-binary path,
--tape=FILE (.tap or .tzx, auto-detected from the file's own
bytes), or --snapshot=FILE (.sna or .z80, v1/v2/v3, 48K only --
see below).
--org and --length apply uniformly across all three input kinds
via --org/--length overriding whatever the source itself provides
-- a tape block's own load address, or a snapshot's own PC -- rather
than requiring it. Omitted, --org defaults to 0 for a raw binary, or
the source's own answer for --tape/--snapshot; --length defaults
to everything the source provides (the whole file, the whole tape
block, or up to the top of the 64K address space for a snapshot).
For --tape, --block=N selects a block by its position in the
tape's own block list (not a header+data pair count -- some real
tapes, confirmed by inspecting an actual commercial release, have a
single header for a small BASIC loader followed by several bare data
blocks with no headers at all, for a custom loading routine that
already knows their addresses; --block can reach those too, but
needs --org supplied explicitly since there's no header to default
one from). --block-name=NAME selects by exact header name instead,
taking precedence over --block. Neither given, the first block whose
header type is Code is used -- a reasonable default, not a guarantee
of finding the "main" code block on a tape with several Code blocks
(a screen or data buffer can legitimately come first; override with
--block/--block-name when it does).
--snapshot handles 128K-family snapshots too, auto-detected from the
file the same way 48K ones are -- no separate flag needed. Banks 5 and
2 are always mapped at 0x4000/0x8000 in every normal 128K/+2/+2A/+3
mode; only the top quarter, 0xC000-0xFFFF, varies. By default that
means whichever bank the snapshot's own paging state has selected (a
"current view" -- reconstructing exactly what the CPU would see if
resumed, --org defaulting to the snapshot's own PC as for 48K).
--bank=N (0-7) overrides that, mapping bank N at 0xC000 instead,
for inspecting a bank that isn't currently paged in -- --org, if also
given, must then fall in 0xC000-0xFFFF, the only range a bank can
ever occupy; omitted, it defaults to 0xC000.
Two things are deliberately out of scope, refused with a clear error
rather than guessed at: the +3's special all-RAM paging mode (a
genuinely different memory layout selected by port 0x1FFD's own bit
0, which zentools carries as a raw, undecoded byte -- no foundation
here to reconstruct it correctly on top of), and any --org below
0x4000 (ROM, never captured in a snapshot of any kind).
Built on github.com/ha1tch/zentools's
format parsers (pkg/tap, pkg/tzx, pkg/snapshot) -- pkg/load's
own job is picking which piece of a tape or snapshot to disassemble
and resolving its load address, not re-parsing bytes zentools already
understands. Exported at the library level (load.FromTape/FromTAP/
FromTZX/FromSnapshot48K/FromSNA48K/FromZ80), the same as
pkg/render, for the same reason: an embedder gets the real thing,
not a CLI-only capability.
Hints file
--hints=<path> loads a plain-text file of directives that let you tell
zendis things it can't infer on its own:
label 0x800B Main
comment 0x800B program entry point
data 0x8003 8
label <addr> <name> names a branch-target address, overriding the
generated Lxxxx form -- a user-supplied name always wins, whether
or not --labels is set.
comment <addr> <text> attaches a trailing ; text comment to the
instruction or data line at that address. Addresses match zenas's own
comment syntax, so a comment survives round-tripping through
--format=plain and back through zenas.
data <addr> <length> marks a byte range as data rather than code.
The decode loop checks every address against these ranges before
attempting to decode, and renders a match as DB lines instead of
mis-decoding it as instructions.
entry <addr> is parsed and stored but not yet consumed by
anything -- reserved for a future control-flow-guided traversal mode
that would use it as an additional starting point beyond --org.
Addresses and lengths accept 0x-prefixed hex or plain decimal. Blank
lines and lines starting with # are ignored.
--format selects one of:
-
listing (default): the address/hex/mnemonic form shown above.
-
plain / zenas / pasmo / sjasmplus / snasm: an ORG
directive followed by one instruction or DB line per record, no
address/hex columns, and a Name: declaration at every labeled
address -- genuinely re-assemblable, not just readable. All five
share one renderer: the instruction/DB/label/comment text needs no
per-dialect transformation for most of them (0x hex, DB/DEFB,
an optional trailing colon on labels, and ; comments are all
pre-registered forms in real pasmo and sjasmplus, confirmed by
reading their source). Per-dialect divergences, each confirmed
against the real assembler, not assumed:
- pasmo: a Z80N instruction, an undocumented bare
IN (C)/OUT (C),0, or DDCB/FDCB's undocumented copy-to-register
form (e.g. RLC (IX+d),B) -- pasmo has none of these -- falls
back to a DB line of raw bytes with a stderr warning naming the
reason.
- sjasmplus: indents every non-label line (an unindented
directive at column 0 is parsed as a label by default; labels
themselves stay unindented) and uppercases the
IXh/IXl
half-register spelling zendis shares with zenas and pasmo (real
sjasmplus only accepts all-caps IXH/IXL).
- snasm (Mike Dailly's SNASM,
freely distributed but not open source): converts every
0x
literal to $ (SNASM's only accepted hex prefix -- 0x8000
fails outright); emits an OPT Z80/OPT ZXNEXT mode directive
first, which SNASM requires and no other dialect here does;
rewrites MUL D,E to bare MUL (SNASM's Z80N MUL takes no
operand syntax at all -- every other Z80N form checked matched
zendis's rendering unchanged); and falls back to DB for the same
three undocumented categories as pasmo -- IN (C)/OUT (C),0
fail the same way, but DDCB/FDCB's copy-to-register form is worse
for the rotate group specifically (RLC/RRC/RL/RR/SLA/
SRA/SLL/SRL): SNASM silently accepts the syntax and emits
the wrong (non-copying) opcode with zero warning, checked
mechanically across all 56 mnemonic x register combinations, no
exceptions. --sym isn't available for snasm: SNASM's own
-sym/-vice flags produce no output at all in the tested release
(V3.2.3.0, checked both single- and double-hyphen forms), and its
working -map output is a different kind of artifact (source-line
debug info, not just a symbol table).
All four re-assemblable targets beyond plain/zenas are verified
against the real assemblers, not just read from source or tested
against zenas: tools/roundtrip covers zenas continuously (it's a Go
dependency); pasmo, sjasmplus, and snasm are covered by
tools/dialectcheck, a self-bootstrapping harness (dormant guard
G-02, docs/KNOWN_ISSUES.md) that finds pasmo/sjasmplus on $PATH,
in a build cache, or downloads and compiles them from source --
it isn't part of the automated go test suite (needs
git/g++/make, not just Go), but cd tools/dialectcheck && go run . is a real, repeatable check, not a one-off manual
reproduction, with twelve adversarial cases (real-world binary
extracts included) beyond its original six. snasm is checked the
same way but treated as optional, never auto-fetched: it's not open
source, so the harness only looks for it locally ($PATH or its
cache dir) and skips those checks with a clear message -- pointing
at where to get it -- if it's not there, rather than failing the
whole run. This harness is what caught every divergence listed
above, plus a real, dialect-independent bug it found along the way:
a hints-file label that wasn't also a branch target was in --sym
output but never actually declared in the accompanying source --
fixed by seeding label resolution with every hints-file label
unconditionally, not just branch targets.
-
json: one object per instruction or data record --
address/bytes/mnemonic/operands/text/length/undefined/
possible_z80n/label/comment -- for feeding a listing into other
tooling.
--sym=<path> writes a symbol file alongside any of the four
re-assemblable formats (not listing or json), in whichever format
matches: plain/zenas/pasmo share pasmo's own convention
(NAME<tabs>EQU 0XXXXH, 4-digit hex, tab-padded to align by name
length, matching pasmo's own real output byte-for-byte); sjasmplus
gets its own, unrelated shape (NAME: EQU 0xVVVVVVVV, 8 hex digits,
0x-prefixed -- also matching sjasmplus's own real --sym output
byte-for-byte). The symbol set includes every resolved label plus every
hints-file label, whether or not it's actually referenced by a branch
in the listing.
Library
pkg/api: the recommended entry point
pkg/api is the whole pipeline cmd/zendis itself runs -- resolving
an input source (a raw binary, a tape, a snapshot, or an RZX
recording) into code+org, then decoding and rendering it -- wrapped
into one call, for a caller embedding zendis (a tracer, a build tool,
anything wanting a disassembly without shelling out to the compiled
binary) rather than duplicating that orchestration itself:
import "github.com/ha1tch/zendis/pkg/api"
d, err := api.Disassemble(api.Options{
Kind: api.SourceTape, // or SourceRaw/SourceSnapshot/SourceRecording
Image: tapeBytes, // already-read bytes, matching pkg/load's own
// convention -- this package never touches disk
Target: disasm.TargetZ80N,
AutoLabels: true,
})
d.WriteListing(os.Stdout, true, os.Stderr)
Options.Kind states which pkg/load function resolves Image --
not content-sniffing auto-detection, since none of the formats are
reliably distinguishable by content alone without also knowing which
one the caller means, the same way the CLI's own
--tape/--snapshot/--recording flags require the caller to name
the kind. api.ResolveInput is also exported directly, for a caller
that wants the raw (code, org) pair without a full Disassembly --
custom analysis, or feeding a different decoder entirely.
cmd/zendis's own main() is now a thin wrapper around exactly this:
flag parsing, reading the chosen file from disk, and exit codes live
there; pkg/api.Disassemble is the one call doing the actual work,
so a library caller and the CLI can never drift out of sync on what
"disassemble this" actually means.
A future GUI-based disassembler in zenzx is expected to reuse this
same package; see docs/KNOWN_ISSUES.md's "Recorded decisions for
future consumers" section for the design choices already made with
that consumer in mind (Options's permissiveness, why no Validate
was added, and so on) before re-deciding any of them.
pkg/disasm: the decoder, as a library
The decoder is usable directly as a library:
import "github.com/ha1tch/zendis/pkg/disasm"
in, err := disasm.Decode(code, offset, addr)
// Decode is a TargetZ80 shorthand. For Z80N resolution:
in, err = disasm.DecodeTarget(code, offset, addr, disasm.TargetZ80N)
// in.Address, in.Bytes, in.Mnemonic, in.Operands, in.Length, in.Prefix
// in.PossibleZ80N is set when Undefined, Target was TargetZ80, and the
// opcode is one Z80N repurposes -- naming what it would have resolved to.
disasm.Decode decodes exactly one instruction starting at
code[offset] and reports addr on the result — callers step
offset and addr forward by in.Length between calls. Truncated
input, and opcodes zendis hasn't resolved to a mnemonic, are reported
through the error return and the Instruction.Undefined flag
respectively, so a caller can always tell the two apart from a
successfully decoded instruction.
Beyond full decoding, the tables and formatting a decoder needs are
exported in their own right, for a caller that wants to build its own
lighter-weight view without duplicating them — a live disassembly
panel over another project's own memory (zenzx), for instance:
f := disasm.Decompose(op) // x/y/z/p/q bit fields
disasm.RegisterName(f.Z) // "B".."A", "(HL)" at index 6
disasm.RegisterPairName(f.P) // "BC","DE","HL","SP"
disasm.RegisterPairNameAF(f.P) // same, PUSH/POP's AF-at-3 form
disasm.ConditionName(f.Y) // "NZ","Z","NC","C","PO","PE","P","M"
disasm.ALUName(f.Y) // "ADD".."CP"
disasm.ALUTakesA(f.Y) // does ALUName(f.Y) take "A," explicitly
disasm.RotateName(f.Y) // "RLC".."SRL" (CB group)
disasm.HexByte(n) // "0x2A"
disasm.HexWord(nn) // "0x8000"
disasm.DispText(d) // "+0x05" / "-0x03"
disasm.Z80NName(op) // "MUL D,E" etc., or ok=false
disasm.Z80NLookup(op) // the full Z80NEntry (Mnemonic/Pre/Kind/Post)
All of these are the exact tables and functions the decoder itself
uses internally, not a parallel copy kept in sync by hand — the field
accessors (RegisterName and friends) and Z80NLookup/Z80NName are
functions rather than exported variables specifically so an importer
can't mutate the underlying tables out from under zendis's own
decoder; every field accessor also masks its argument to the table's
valid range, so an out-of-range byte gets a defined answer rather than
a panic.
Everything cmd/zendis itself does beyond raw decoding -- the
data-range-aware decode loop, label resolution, and all six output
forms -- is github.com/ha1tch/zendis/pkg/render, not CLI-internal
logic. cmd/zendis is a thin wrapper over it, the same relationship
pkg/disasm's Decode/DecodeTarget already has with the CLI. This
is also why tools/roundtrip and tools/dialectcheck no longer need
to shell out to a compiled zendis binary to test the dialect-specific
logic -- they still do, since that's also a faithful test of the CLI
wrapper itself (an unrelated regression in flag handling or exit-code
logic wouldn't show up in a library-level call), but the package
underneath is now directly callable too.
The recommended entry point is render.Disassembly, which
cmd/zendis itself uses -- decode once, render as many times as you
want from the same result, without re-threading records/labelFor/
hints/org/z80nTarget through every call by hand:
import (
"github.com/ha1tch/zendis/pkg/disasm"
"github.com/ha1tch/zendis/pkg/render"
)
hints := &disasm.Hints{} // or disasm.ParseHints(r)
d := render.NewDisassembly(code, org, disasm.TargetZ80N, hints, true /* autoLabels */)
var buf bytes.Buffer
d.WriteAssemblySource(&buf, render.DialectSnasm, os.Stderr /* warnings */)
d.WriteListing(os.Stdout, true, os.Stderr)
symbols := d.Symbols()
This is more than convenience: d.WriteAssemblySource always uses the
org/target d was actually decoded with, since they're captured
once at construction rather than re-supplied by the caller on every
call -- with the package-level functions below, nothing stops passing
a z80nTarget that doesn't match what DecodeBuffer actually decoded
with.
Disassembly's fields are unexported; Records()/LabelFor()/
Hints() give read access (sharing Disassembly's own backing
slice/map, not a defensive copy) for a caller that wants to do
something the five Write*/Symbols methods don't cover.
The package-level functions Disassembly is built from remain
available directly, for anything more custom -- resolving labels with
a different strategy, inspecting intermediate state between decoding
and rendering, or the sort of exploratory calls this section's earlier
example showed:
records := render.DecodeBuffer(code, org, disasm.TargetZ80N, hints)
labelFor := render.ResolveLabels(records, hints, true)
render.WriteAssemblySource(&buf, records, labelFor, hints, org,
render.DialectSnasm, true /* z80nTarget */, os.Stderr)
render.Record is the decode/data-range union every Write* function
(and Disassembly) walks. render.Dialect
(DialectPlain/DialectZenas/DialectPasmo/DialectSjasmplus/
DialectSnasm) selects WriteAssemblySource's target -- see its own
doc comment for the exact, source-verified divergence each one needs.
render.WriteListing and render.WriteJSON cover the other two
formats; render.CollectSymbols plus
render.WritePasmoSym/WriteSjasmplusSym cover --sym.
render.IsZ80NInstruction, render.PasmoUnrepresentable, and
render.SnasmUnrepresentable are exported in their own right too --
the specific, hard-won knowledge of what each real assembler can't
safely represent (the DDCB/FDCB copy-to-register matrix particularly)
is arguably the most valuable part of this package, not just a detail
of how the CLI's fallback works.
Requirements
Licence
Apache 2.0. See LICENSE.