- Zig 88.4%
- Python 11.2%
- Nix 0.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
`corpus.zig` holds four whole jobs -- a letter, a banner, a screen dump, and a fourth of the corners those three miss. A fragment is one command; a seed is a printer that has been driven for a while, and the states worth reaching take a page of driving to get to. They are written from the command set, not captured from a machine, and they are named for the shape of the work rather than for AppleWorks, Print Shop and Dazzle Draw, because calling a file appleworks.iw would claim a provenance it does not have. A test asserts they reach every command the parser has -- all forty-seven, not most -- so a command added later fails that test until a seed exercises it. The fuzzer now splices fragments, mutates one seed, and crosses two. The last is the one worth explaining: a printer interrupted mid-job by a different program is a real thing and nothing else generates it. It paid for itself on the first proper run. The tab-list accumulator had nothing bounding how many digits a stop could carry, so ESC ( and five digits overflowed a u16. Four hundred thousand iterations of spliced fragments had never found it, because a fragment is one whole command and this needs a malformed one -- the corpus supplied a tab list and the mutator lengthened it. A stop is a three-digit field, "the maximum for each three-digit number depends on the character pitch" (Apple Computer, 1986, p. 66), so a fourth digit is now a malformed argument and is read back as text like every other fixed-width field already did. Two things that made the finding usable. The fuzzer installs a panic handler that prints the seed, the iteration and the input first: a returned error can be reported where it is caught, a panic cannot, and a panic is most of what this is looking for. And `run -- trace` on the saved input gave the real line in Debug, where ReleaseSafe had inlined it into the dispatch and blamed the wrong function. Also fixed before it could mislead anyone: the language switching in the corpus named the switches backwards, selecting French where it said Italian. Parsing cleanly proves nothing there -- opening a switch that is already open parses perfectly -- so there is now a test that runs those commands through the printer's own decode and checks where they land. 100,000 iterations across five seeds, clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FnPyYLDpDiVdMMZgfL12z3 |
||
| assets | ||
| LICENSES | ||
| src | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| CLAUDE.md | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| REUSE.toml | ||
imagewriter2
A Zig library that emulates an Apple ImageWriter II dot-matrix printer.
The host pushes a byte stream in; the library interprets the control codes and escape sequences exactly as the printer would, and renders the "printed" pages as images. It is not a filter that turns text into a picture — it is a model of the machine, with the machine's own state, geometry and limits.
The emulator is the sink. It implements std.Io.Writer, so a program writes
to it the way it would write to a serial port, and interpretation happens as the
bytes drain. There is no separate "parse this buffer" entry point, because a
real printer never had one.
Why
Software written for the ImageWriter II is still readable, still runnable under emulation, and still produces output that nothing can print. The printers are scarce, their ribbons scarcer, and a page of AppleWorks or a Print Shop banner is a document that currently has no way of being seen as it was meant to look. A faithful emulator turns that byte stream back into a page.
Faithful is the operative word. Where the real machine's behaviour is awkward or surprising, this library keeps it and explains it rather than smoothing it over. Deviations live behind options that are off by default.
Status
Phase 0 — the page surface — is done. Page holds a sheet of paper, stamps
soft-edged dots onto it at positions given in the printer's own units, and
writes it out as a PNG. zig build run renders a test grid for judging the dot
by eye. The parser, the fonts and the text and graphics pipelines are not
written yet, so nothing here prints anything you would want to read.
Output resolution defaults to 720 dpi. Only two resolutions are exact in both of the printer's axes — 720, at 2 horizontal position units to the pixel and 5 pixels to the vertical unit, and 288, at 5 and 2 — so no dot moves through rounding alone at either. 720 is the finer and shows the shape of a dot rather than an approximation of it; 288 is cheaper by twenty-five times the pixels, which is what the unit tests use. Golden images are rendered at 720.
The dot diameter is still a guess. 1/100 inch is the starting estimate and wants tuning against a real print sample.
Phase 1 — the parser — is done. parser.zig decodes the printer's whole
command language into a Command union: every table in appendix A of the
technical reference manual, one byte at a time, with no allocator and nothing
imported but std. Feed it a byte, get a command back or null while a sequence
is still arriving. zig build fuzz throws byte streams at it.
Phase 2 prints text. Font.zig parses assets/fonts/*.font at compile
time — not for speed, but so that a misread glyph is a build error: the plates
are a photographic scan, the extractor measures rather than parses, and the
hardware's own constraint (no two adjacent dots in a row, because at 250
characters a second the head cannot fire a wire twice running) is checked on
every glyph as it is read. Printer.zig holds the head position, pitch, margin
and spacing, and turns characters into dots.
zig build run -- print FILE OUT.png
renders a page. Draft font, carriage return, line feed, form feed, left margin and line spacing. Everything else the parser understands is counted and ignored, so a real byte stream runs through and the parts that work can be looked at.
Phase 3 is done. All six fixed pitches and both proportional ones, the draft-to-correspondence state machine, boldface, underline, double width, half-height, sub/superscript, tabs, top of form, reverse line feed and perforation skip. The state machine is the interesting one: boldface, double width, half-height, sub/superscript and proportional printing are unavailable in draft, so asking for any of them switches the printer to correspondence and switching it off puts it back. What the host requested and what is in effect are therefore two different things, and only the first is state.
The reduced styles are modelled on the wires rather than by scaling. Half-height is one pass through wires 4 to 7, a paper movement of 1/144 inch, and an overprint, which is what halves the height; the scripts are the same construction moved to the top or bottom five wires with the paper left alone, so a superscript and a subscript share a line with full-size text. An underline follows: wire 7 normally, wire 9 under a script, because that is where a rule has to go once the characters above it have been pushed down.
Every font in the printer's ROM is extracted from the manual's plates — draft, correspondence
fixed and proportional, and near letter quality — each with its seven
alternate-language sets, and the language switches select between them. They
substitute exactly ten code points and nothing else, so a Dane gets Æ Ø Å æ ø å where an American gets [ \ ] { | } and the rest of the page is unchanged.
MouseText is there too, in all three qualities — dithered in draft, because a
solid block cannot be printed solid at 250 characters a second.
zig build run -- selftest OUT.png prints the page a real printer prints when
Form Feed is held while it is switched on: the character set at every pitch and
quality, the scripts, and the seven ribbon colours. It is modelled on a scan of
a real one, not extracted from ROM — the manuals say what the test is for, not
what it prints — so it is recognisably the same page and not a byte-identical
one. It is also the best golden in the repo, exercising nearly everything at
once.
Fuzzing. zig build fuzz -- SEED ITERATIONS throws byte streams at the
whole emulator, looking for the things a printer must never do whatever it is
sent: overflow an integer, index past a font, allocate without bound, or stop
consuming its input. A host cannot send an invalid byte, only a byte that means
something odd. A seed is always required, because a fuzzer whose failures
cannot be run again finds nothing twice.
Two of its three checks are not "did it crash". The same stream written whole, a byte at a time, and in sevens must print identical pages — the property the sink design rests on, since the state lives in the printer and where the host broke its writes cannot matter. And in hex dump mode no command may be obeyed, whatever the stream contains.
Streams come from a corpus of whole jobs — a letter, a banner, a screen dump,
and a fourth of the corners those three miss — which the fuzzer splices,
mutates and crosses with each other. Starting from a real job matters because
the interesting states take a page of driving to reach; a fuzzer starting from
nothing spends its budget discovering that ESC is interesting. The seeds are
written from the command set rather than captured from a machine, and are named
for the shape of the work rather than for the programs that produced that
shape, since a name like appleworks.iw would claim a provenance they do not
have. A test asserts that between them they reach every command the parser has.
This is not decoration. Seeding it with real jobs found a genuine bug on its
first proper run: the tab-list accumulator had nothing bounding how many digits
a stop could carry, so ESC ( followed by five of them overflowed. Four hundred
thousand iterations of spliced command fragments had never reached it, because
a fragment is one whole command and the bug needs a malformed one — the corpus
supplied a tab list and the mutator made it too long.
It is not Zig's own fuzzer, which would have been preferable. zig build test --fuzz does not compile on Zig 0.16.0: its bundled test runner passes a
builtin.StackTrace where writeStackTrace wants a debug.StackTrace, so the
failure is in the toolchain rather than here. The std.testing.fuzz targets are
in the source, waiting for that to be fixed.
Golden-image tests. assets/golden/NAME.iw is a stream of printer commands
and NAME.png the page it must produce, checked in beside it. Unit tests can
say a margin is where it was put; they cannot say a page looks right, and an
emulator that passes every unit test can still put the ink in the wrong place.
zig build golden regenerates them, deliberately, and the diff is the thing
worth looking at.
zig build run -- trace FILE prints the command sequence a file decodes to.
When a page is wrong, that says whether the parser or the renderer is to blame.
The DIP switches are switches. Switches models the two banks under the
front cover rather than the settings they imply, because that is what a person
had in front of them and what every table in the manual is keyed by. SW 1 sets
the state a host can then override — language, form length, pitch, perforation
skip — and SW 2 sets the machine: baud, protocol, the 32K memory option.
They also settled something that had been wrong: a printer out of the box prints at twelve characters to the inch, not ten. The factory setting is SW 1-6 closed, and everything here that assumed pica was assuming.
The emulator is the sink. Emulator implements std.Io.Writer: a host
writes bytes to it the way it would write to a serial port, and interpretation
happens as they drain. There is no "now parse this buffer" call, because a real
printer never had one — it had a serial line and 2K of buffer, and much of how
it behaved followed from that. What comes back up the line is to_host: the
answer to ESC ?, and XON/XOFF when the buffer fills.
Hex dump mode is in too, which a real printer enters by holding SELECT while switching on. Every byte received is printed as two hexadecimal digits instead of being obeyed, which makes it an oracle for the parser: what comes out is exactly what went in.
Colour works. The ribbon has four bands — black, yellow, magenta, cyan — and the printer makes orange, green and purple by printing one and overprinting another. So does this: the composites are not looked up, they fall out of laying two inks on the same dot. That works because ink is composited subtractively, a dot leaving the product of the ink and what was under it rather than replacing it.
Phase 4 is under way: bit-image graphics at all eight horizontal densities,
ESC V column repeating, and ESC F head placement. The bit order — bit 0 is
the top dot of a column, bit 7 the bottom — has a test of its own, because a
wrong bit order produces plausible-looking garbage that everything else would
pass.
Pages come out through a Document, which decides when one is finished. That
cannot be the moment the head crosses into the next page, because this printer
feeds paper backwards and the head may come back — so three form lengths stay
in the machine and a page is handed on once the head has passed the bottom of
the one after it, or on a form feed, or at the end of the job. A character that
straddles a perforation is split between two pages, which is what continuous
form does.
The output has been held against a scan of the printer's own self-test page. The glyphs match it character for character, which is the check the extraction needed; the dot diameter was tuned against it from the original 1/100 inch guess to 0.013 inch.
The plan, in order: parser and command table; draft text in one pitch; the rest
of the pitches and type attributes; bit-image graphics; print quality and the
colour ribbon; and finally the back channel — XON/XOFF, self ID, the self-test
and hex dump mode. CLAUDE.md carries the detail, including the design
decisions that are settled and what each was chosen over.
Getting the source
The repository has two homes, and they carry the same history.
The everyday one is a Forgejo instance:
git clone ssh://git@git.ocjtech.us/jeff/imagewriter2.git
It is also published on Radicle, a peer-to-peer code forge built on git, where a repository is found by its Repository ID (RID) rather than by a server name:
rad:z2Qji3S9kqDkH6RKWZgJstrzxuonf
If you do not have Radicle yet, install it, create an identity, and start your node:
curl -sSf https://radicle.dev/install | sh
rad auth
rad node start
Then clone the repository:
rad clone rad:z2Qji3S9kqDkH6RKWZgJstrzxuonf
rad clone consults your node's routing table to find seeds that carry the
repository, and drops the working copy into an imagewriter2 directory. Pass a
target path as a second argument to put it somewhere else. If your node has not
discovered a seed yet, name one directly with --seed <NID>.
The result is an ordinary git working copy on the main branch, with a rad
remote wired up, so everyday work is just git. To pull in changes published
since your clone:
rad sync --fetch
git pull rad main
Building
Zig 0.16 or later. z2d 0.10.0 was the last release that supported Zig 0.15, so the floor is genuinely 0.16 rather than a preference.
zig build
zig build test
A flake.nix pins the toolchain — nix develop gives you the Zig, reuse and
supporting tools this repository is developed with.
How it is put together
Two rules shape most of the code.
The parser is pure. parser.zig takes bytes and emits a Command. No
allocator, no z2d, no I/O. Most of the bugs in a project like this live in
sequence decoding, so that part has to be unit-testable and fuzzable on its own,
without a rendering surface anywhere near it.
It reports what was asked for and never what it means. ESC Z and ESC D carry
a two-byte mask that selects among a dozen unrelated functions, and the parser
passes the bytes on undecoded for the layer that owns the switches to sort out.
Arguments outside their documented range are passed through rather than
rejected, because the parser does not know what the printer did with them.
Two consequences worth knowing. Sequences with a counted payload — ESC G,
ESC g, ESC S — have no terminator in the stream, so the parser needs the
arity of every command; an escape byte inside graphics data is data. And a
malformed argument abandons the sequence and re-reads the offending byte from
the ground state, so a truncated command cannot silently swallow the text that
follows it.
Positions are integers in the printer's own units. Horizontal position is 1/1440 inch, which is exact for six of the eight horizontal densities and rounds only for the two that were approximations in the hardware to begin with. Vertical position is 1/144 inch, the printer's line-feed granularity, exactly. An x coordinate is always derived from the column index rather than accumulated step by step, so rounding cannot drift across a line.
What is deliberately not modelled
The mechanism is ideal, and so is the ribbon. Two omissions are worth stating outright, because both are things a real ImageWriter II visibly does.
Backlash and the rest of the carriage's mechanics. A real printer runs the head in both directions, and the play in the drive train means a dot laid down on a right-to-left pass does not land in quite the same place as the same dot laid down left to right. Draft is where it shows most: it is the fastest mode, the head is moving quickest, and the printer's own answer to the problem is two DIP switches — SW 2-5 and SW 2-6 — that the manual says are "factory-set to optimize bidirectional dot placement" and are not to be touched. Nothing here reads them. Nor is anything else the moving parts do to the page reproduced: carriage acceleration and settling, head skew, wire wear, platen slop, or paper creeping in the tractor feed. A column lands exactly where the arithmetic says it lands, on every pass, in either direction.
An ideal ribbon. The ribbon is always full, evenly inked, and lays down exactly the same ink on its ten-thousandth pass as on its first. It does not fade along its length, it does not smear, it never runs out, and on the colour ribbon the four bands never wear at different rates — so an overprint that makes orange behaves the same way on a cartridge at the end of its life as on one out of the box, which on a real machine it emphatically does not.
Neither omission is an oversight. Both are analog properties of one particular printer on one particular day: they vary between machines, with age, with temperature and with what kind of paper is in the tractor, and neither manual specifies any of them. Reproducing them would mean inventing numbers and then citing them, which is the one thing the rest of this project is arranged not to do — every claim it makes about the hardware can be opened at the page it came from, and "this is roughly how worn ribbons look" cannot. What is emulated is the printer's specification. Wear is not in the specification.
If any of it is ever added, it belongs behind an option that is off by default, and the numbers behind it need a source like everything else.
Sources
Everything the emulator claims about the hardware comes from Apple's own manuals, cited in the code by chapter and printed page. Both are filed in a local documentation archive with their provenance, a page-by-page text extraction and a note on where that text is not to be trusted — these are scans, so prose can be quoted from the extracted text and numbers cannot.
Where the two manuals disagree, the Technical Reference Manual wins. That has already happened more than once: the Owner's Manual reverses the labels on the two proportional pitches, and implies that near-letter-quality characters are sixteen rows tall when they are eighteen.
Licence
MIT. Every file carries an SPDX header and reuse lint enforces it; the licence
texts are in LICENSES/. z2d, the rasteriser, is MPL-2.0 — a file-level
copyleft that imposes nothing on this code, but any change to z2d's own files
stays MPL and belongs upstream rather than in a fork. See the Licensing
section of CLAUDE.md.
Reference list
Sources used in developing this software.
Apple Computer, Inc. (1985). ImageWriter II owner's manual (030-2002-D). Apple Computer, Inc. archive.org
The book that shipped in the box. Authoritative for the printer as an object: the control panel, the paper path, the ribbon, the self-test and what the lights mean. Appendix C carries the printer specifications and the complete DIP switch tables; Appendix A gives the command set as an owner met it. The seven colours of the colour ribbon and the order of the language fonts on switches SW1-1 to SW1-3 are from here.
Apple Computer, Inc. (1986). Apple ImageWriter II technical reference manual. Addison-Wesley. ISBN 0-201-17739-0. apple2.org.za mirror
The programmer's book, and the primary source for this project. Every control code and escape sequence, the DIP and software switches, the paper and head model, and — in Appendix C — the dot pattern of every character in the printer's ROM, which is the only published source for the fonts. Chapter 8 is the graphics command, including the statement that bit 0 of a data byte is the top dot of the column and bit 7 the bottom.
Vancluever, C. (2025). z2d (Version 0.12) [Computer software]. GitHub. https://github.com/vancluever/z2d
A pure-Zig 2D rasteriser, used here to hold the page surface, composite the dots onto it and export PNG. MPL-2.0.
ImageWriter II print samples [Scanned printout]. Internet Archive. https://archive.org/download/ImageWriter_II_Print_Samples/ImageWriter_II_Print_Samples.pdf
A scan of the printer's own self-test page: the character set at every pitch and print quality, the script and half-height styles, and the seven ribbon colours. Not documentation but evidence — it is what the emulator's output is held against. It confirmed the extracted draft font glyph for glyph, settled the dot diameter, and is a third witness that colours 2 and 3 are magenta and cyan, which the Owner's Manual gets wrong.
Bibliography
Reference material of interest that has not been used in developing this software.
Apple Computer, Inc. (1985). Apple II Super Serial Card user's manual. Apple Computer, Inc.
The other end of the cable for an Apple II. Relevant because the Super Serial Card intercepts
CONTROL-Ias its own command character, which is why the ImageWriter's tab command is awkward to reach from a II — a detail the Technical Reference Manual flags but does not explain.
Apple Computer, Inc. (1985). Apple IIe technical reference manual. Addison-Wesley.
Describes the host side of a IIe-to-printer connection, including the slot firmware a program goes through to reach the serial card.
Synertek. (n.d.). SY6551 asynchronous communication interface adapter. Synertek.
The ACIA behind the Super Serial Card. Of interest if the emulator ever grows a timing-accurate serial model rather than treating the link as a byte pipe.