A commandline multitool for CHIP-8 variant development
- Chiplet
Chiplet is the result of my Work on Cadmium, my CHIP-8 emulator environment. It contains an execution path tracing disassembler, a preprocessor that is compatible with Octopus from Tim Franssen, both written by me, combined with an Octo Assembly Language compatible assembler now written in C++ but originally based on Code from C-Octo from John Earnest.
It combines these to allow using modular CHIP8 projects with multiple files, while retaining error message output that refers to those original files by translating C-Octo errors back to the original locations before the preprocessing step.
The disassembler also has options to search for specific opcode patterns and give opcode statistics or estimate the CHIP-8 variants that could execute the code.
USAGE: chiplet [options] ...
OPTIONS:
Assembler/Preprocessor:
--no-line-info
omit generation of line info comments in the preprocessed output
-I <arg>, --include-path <arg>
add directory to include search path
-P, --preprocess
only preprocess the file and output the result
-o <arg>, --output <arg>
name of output file, default stdout for preprocessor, a.out.ch8 for binary
Disassembler/Analyzer:
--list-duplicates
show found duplicates while scanning directories
--round-trip
decompile and assemble and compare the result
-d, --disassemble
dissassemble a given file
-f <arg>, --find <arg>
search for use of opcodes
-p, --full-path
print file names with path
-s, --scan
scan files or directories for chip roms and analyze them, giving some information
-u, --opcode-use
show usage of found opcodes
General:
--version
just shows version info and exits
-q, --quiet
suppress all output during operation
-v, --verbose
more verbose progress output
...
Files or directories to work on
Running a file that uses preprocessor directives through the preprocessor for later assembly by Octo:
chiplet -o output.8o -P octo-source-file.8o
If the output is not set, it will be written to stdout.
This runs the file through the preprocessor and the Octo assembler and generates a binary program.
chiplet -o output.ch8 octo-source-file.8o
If the output is not set, a file named a.out.ch8 is generated.
The Disassembler uses heuristic execution path tracing to detect data and code parts and automatically generate labels to create an Octo assembly language source that one can use to regenerate the binary.
Creating Octo source from a CHIP-8 variant binary:
chiplet -o output.8o -d some-program.ch8
If no output is set, the disassembly will be dumped to stdout.
Note: The as is the nature of heuristics, they are not perfect. In
rare cases the disassembler doesn't report an error but doesn't manage
to generate source that is compilable or is identical to the binary.
In case of doubt, using the call with the option --round-trip will
disassemble into memory, run the result through the assembler and report
an error for any difference, so if that reports nothing, the source
generated by the disassembler is guaranteed to generate an identical
binary that was used as input, if assembled. A later version will make
this step implicit to ensure consistent disassembly. Starting with the
enhancements of v1.1.0 Chiplet is able to round-trip every program
file I could find that fits in 64k memory. (I currently only know of two
MegaChip examples that don't.)
Relocate a working CHIP-8 binary from its original load address (default 0x200)
using the disassembler's execution path tracing:
chiplet --relocate 0x600 -o program-eti.hex --hex program.ch8
chiplet --start-address 0x600 --relocate 0x200 -o program.ch8 program-eti.hex
Relocation requires exactly one binary or Intel HEX input file and an output
filename supplied with -o. --hex produces Intel HEX with relocated record
addresses; otherwise output is binary. HEX input retains its record addresses,
so set --start-address to the original entry address. Sparse HEX ranges remain
sparse in HEX output; binary output fills gaps with zero bytes and begins at the
relocated lowest input address.
Only address operands in traced instructions are changed; other bytes, including
sprite data that resembles instructions, are preserved. Addresses below the
lowest input address are left unchanged as interpreter/system references; higher
addresses move with the program, including references to scratch RAM beyond its
end. Operands that would overflow their instruction's address range cause an
error rather than being truncated. CHIP-8 addresses, XO-CHIP i := long, and
MegaChip ldhi operands are supported.
This is a best-effort relocation of a working program, not a repair of an already corrupted conversion. Self-modifying code, addresses built in registers or stored in data, computed jump targets and native machine code may need manual changes. Detected computed jumps, native calls and writes to traced code produce warnings.
For the old Emma conversions that relocated every aligned word resembling an address instruction, undo that transformation in Python, then use Chiplet's path tracer to relocate the recovered binary:
python3 tools/repair-emma-hex.py broken.hex -o repaired.hex
python3 tools/repair-emma-hex.py broken.hex -o repaired.hex --reference original.ch8
The output is Intel HEX at the input's first address (normally 0x600). The
original binary address defaults to 0x200; --start-address and --relocate
override these addresses. The script finds a local native Chiplet build or uses
PATH; --chiplet selects an executable explicitly. It accepts contiguous HEX
and tolerates the converter's checksum 100 bug when the correct value is 00.
Overflow in the faulty converter discarded a hex digit, which cannot be
recovered from the damaged word alone. Without a reference, the script warns
and guesses zero (--lost-nibble changes that guess). --tail byte or
--tail word resolves an ambiguous expanded final byte; automatic mode only
folds the unambiguous 0400 case. A --reference binary resolves both forms of
information loss, and is accepted only if the faulty converter reproduces the
complete input payload from it. Originals are preserved; a failed Chiplet run
also preserves any existing output file.
The comparison results record the trial against the existing ETI repair copies, with per-file details in the adjacent CSV. These are byte comparisons, not full gameplay checks.
Analyzing a single binary or a directory of binaries, suppressing unneeded output:
Example:
> chiplet -q -s IBM-Logo.ch8
IBM Logo.ch8, 6 opcodes used (0ms)
possible variants: chip-8, chip-10, chip-48, schip-1.0, schip-1.1, xo-chip
Used opcodes:
00E0: 1
1000: 1
6000: 2
7000: 5
A000: 6
D00F: 6
This can run over a directory of files, the opcode statistics are then for
all detected CHIP-8 files in the directory tree. Files scanned can have the
endings: .ch8, .c8x, .ch10, .sc8, .xo8 or .mc8
It will try to identify which CHIP-8 variants this binary is made for.
The estimation is far from perfect, and mainly excludes variants when
encountering opcode that are only supported by a specific version like
a long I load from XO-CHIP.
Looking for binaries in a directory tree, that make use of an opcode
with a given pattern (e.g. Dx0F looks for binaries that use
sprite * v0 0xF):
> chiplet -q -f Dx0F my-chip-archive/
Dx0F: Flappy Pong (by cnelmortimer)(2017).ch8
Dx0F: Octo Bird (by Cody Hoover)(2016).ch8
Done scanning/decompiling 534 files, not counting 125 redundant copies, found opcodes in 2 files (50ms)
The pattern rule is case-insensitive and any character that is not a
hex digit will be seen as nibble sized wildcard. Multiple -f options
can be used to look for multiple opcodes at one run.
For conditional assembly of parts of the source, the directives :if,
:unless, :else and :end can be used. The condition that defines
if the code is used or not is based on options given by -D command-line
option or by :const directives in the source.
A typical use case might be:
:if XOCHIP
i := long data
:else
i := data
:end
If compiling with -D XOCHIP this sequence will emit the long
variant of the opcode (F000) else the normal opcode will be used.
The :unless directive has the inverted logic, emitting the following
code if the option is not set.
XO-CHIP architecture imposes a memory constraint where executable
code must reside within the first 3.5KB of memory space, while the
remaining 60KB can be allocated for data storage. This limitation
exists because while XO-CHIP provides the i := long <label>
instruction for loading 16-bit addresses into the index register,
it lacks corresponding instructions for jumping to or calling 16-bit
memory locations.
During development, it's often more intuitive and maintainable to organize related code and data elements together in the same source files. Chiplet addresses this challenge through intelligent automatic code reorganization using segment annotations.
By marking sections of your code with :segment code and :segment data
directives, Chiplet can automatically restructure your program to
meet XO-CHIP's memory layout requirements. The assembler ensures
that all executable instructions are placed in the lower memory region while data elements are relocated to the extended memory space.
Note: Files are implicitly treated as starting with :segment code, so this annotation can be omitted when your file begins with executable instructions.
Example:
i := long lookup_table
load v4
# Process the loaded data...
:segment data
: lookup_table
0 1 2 3 4
In this example, the executable code will be placed in the restricted memory area, while the lookup_table data will be automatically moved to the extended memory region, maintaining proper program functionality while adhering to XO-CHIP's architectural constraints.
To organize a complex project it is often useful to split the code into multiple files, this can also be used to have some library of macros that can be used in multiple projects.
Including another source file:
:include "my-macros.8o"
The include-directive can also be used to import image files that are converted into 1-bit sprite data. The general syntax is:
:include "<path-to-image-file>" <modifiers>
Where <path-to-image-file> is the image file to import (valid image
formats/extension are .png, .gif, .bmp, .jpg, .jpeg and .tga).
While Octopus supports TIFF import, Chiplet does not, and while it supports TGA, Octopus does not support TGA import. Also note that due to the way JPEG works, the resulting images of Chiplet and Octopus are not bit identical, due to math rounding differences in the used JPEG readers. For modern development of CHIP-8 variant games I simply recommend to stick to PNG.
To influence behavior of the image importer, the following modifiers can be used:
megachip- The image will ge seed as a max. 255 color palette based image. A palette needs to be given somewhere after themegachipmodifier, and the image will be converted into fitting that palette, defaulting to nearest color matching.<width>x<height>- The image will be split into sprites of the given size. The size must be a valid integer divisor of the image size. If no size is given, the image will be split into 8 pixels wide chunks that are even dividers of the image size and at most 15 pixels high.[<color> ...]- A palette to be used by theimporter. The colors must be given as hex values in the way used in e.g. CSS, so eitherRRGGBBorRGB, and the latter gets expanded by doubling the nibbles as in CSS. The brackets may be attached to the colors ([000 FFF]) or separated by whitespace ([ 000 FFF ]), and the list may span several lines. For XO-CHIP like color support the number of colors needs to be a power of two from 2 to 16. Formegachip2 to 254 colors are allowed. In that mode the palette is internally prefixed with a transparent black, and all transparent pixels are mapped to that in import.dither- The image will be Floyd-Steinberg dithered when reducing it to the palette.no-labels- The image will be split into sprites, but no labels are generated.debug- the generated sprite data will be annotated with ASCII art.
If no-labels is not used, the generated sprite data blocks are prefixed by a
label of the form:
<file-basename>-<column>-<row>
If a palette of more than two colors is used, an additional label section is added and the form becomes:
<file-basename>-<plane>-<column>-<row>
If no labels should be generated, the optional argument no-labels can
be used.
The optional debug option can be used to let Chiplet dump the generated
sprite data on the console as annotated ASCII (actually Unicode) art.
Example:
:include "screen.png"
This includes the image, converts it into grayscale and every pixel brighter than 128 is interpreted as a 1, the others are 0. The image will be cut into sprites automatically, but with the additional sprite-size parameter a different splitting can be triggered. The sizes must be valid integer divisors of the image size or an error will be reported.
Binary files with the extension .bin or .ch8 are included as data bytes:
:include "<path-to-file>.bin" [no-labels]
The bytes are prefixed by a label with the base name of the file, unless
no-labels is given.
WAV files (.wav, PCM with 8 or 16 bit, or 32 bit float, mono or stereo) are
included as unsigned 8-bit mono samples, centered at 128:
:include "<path-to-file>.wav" [<sample-rate>] [megachip] [no-labels]
<sample-rate>- Resample the sound to this rate in Hz. Without it, the rate of the file is kept. The rate must be given on the line of the:include, a number on a later line is data.megachip- Prefix the samples with the six byte header of the MegaChipdigisndinstruction: the sample rate (16 bit, so at most 65535 Hz), the number of samples (24 bit) and a zero byte.no-labels- Don't generate the label with the base name of the file.
Example, a sound for ldhi and digisnd:
:include "explosion.wav" 8000 megachip
An Octo cartridge is a GIF file holding a program together with its quirks, speed, colors and rotation, so the configuration can't get separated from the program:
chiplet --cartridge-label "MY GAME" --cartridge-variant xo-chip -o game.gif game.8o
With --hex, the cartridge holds a hex dump of the assembled program instead of
its source, so emulators can read it without an assembler. See
cartridge-format.md for the format, written for emulator
authors, and tools/octocart.py for a reference decoder.
Chiplet is written in C++17 and uses CMake as a build solution. To build it, checkout or download the source.
Open a terminal, enter the directory where the code was extracted and run:
cmake -S . -B build
to configure the project, and
cmake --build build
to compile it.
Currently, Chiplet is not tested to compile with MSVC++ so the recommended toolchain is W64devkit. I might try to make it compile on MSVC++, but I am not working on that in the near future. That being said, I would not per-se reject PRs helping with this.
To build in the Bash from W64devkit:
export PATH="$PATH;C:/Program Files/CMake/bin"
cmake -G"Unix Makefiles" -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++ -S . -B build-w64dev
cmake --build build-w64dev
- Octopus - This is the preprocessor that defined the syntax and feature set, we talked about this idea during it's creation time and I followed his specification to implement the preprocessor in Chiplet, and it should behave the same, with the only exception that Chiplet supports TGA but doesn't support TIFF.
- Octo assembler syntax - The preprocessor needs to make sure it doesn't break Octo, so it's lexer is implemented with respecto to the Octo specification by John Earnest.
- c-octo assembler - the octo
assembler for support of generating binaries from Octo assembler syntax
(containied in
external/c-octo) - fmtlib - a modern formatting library in
the style of C++20 std::format (contained in
external/fmt) - ghc::filesystem - An implementation of std::filesystem for C++11/14/17/20 that also works on MingW variants that don't have a working std::filesystem in C++17 and with better utf8 support (automatically fetched during configuration)
- stb_image - An image
format loader from Sean Barrets great
nothingscollection, used to support image import - libresample - A fast resampler library for audio data by Dominic Mazzoni, based on resample-1.7 by by Julius O. Smith III
- tl::expected - A lightweight implementation of the C++23 std::expected type for error handling by TartanLlama.
Chiplet accepts inline CDP1802 assembly in Octo programs:
:const BG_PORT 1
: main
native inc_bgcol
loop again
: inc_bgcol
:asm cdp1802 {
SEX R9
OUT BG_PORT
SEP R4
}
The block emits its native bytes at the current Octo address. It does not
insert a call, return, alignment, or padding. Use Octo's native label for a
CHIP-8 0nnn call, and the target interpreter's return convention inside the
block. SEP R4 is appropriate for the ETI-660 example; the convention is not
chosen automatically from the CPU name.
The existing CDP1802 assembler handles native syntax. Statements are separated
by newlines or semicolons; hexadecimal numbers use #39 or X'39'; native
comments start with ... Octo # comments, directives, macros, and macro
argument substitutions are not interpreted inside the block. Braces inside
native strings and comments do not terminate the block. The preprocessor
preserves the complete block, including in included files and Octo macros.
Each expansion of a block gets independent native local symbols.
Previously defined Octo numeric constants, calculated values, and labels are
readable inside the block. Their values are captured at that occurrence of the
block. Forward references to Octo labels are also supported, including labels
in later native routines. Forward references to later :const/:calc
definitions are not supported. Native names use letters, digits, and
underscores, starting with a letter or underscore. Shared symbol spelling is
case-sensitive; CPU mnemonics and register names are case-insensitive. CPU
register names are reserved within native assembly. Imported values must be
finite integers that fit the operand being encoded.
Native labels (name:) and equates (name = expression, name EQU expression)
are local to their block and may shadow imported symbols. They are never
exported into Octo. Put an outer Octo label before each block for externally
callable entry points. Native expressions use the CDP1802 expression grammar,
including +, -, the current statement address */$, and A.1(expr) /
A.0(expr) to select an address's high / low byte.
This first version accepts only cdp1802 and requires contiguous blocks with a
fixed size in the 16-bit address space. Native ORG, PAGE, and END are
rejected; use outer Octo :org for placement. No instruction relaxation is
performed. Short branches must target the page containing their operand;
otherwise use a long branch. Data width is explicit: bare DB/DC expressions
and A.0/A.1 emit one byte, A(expr) emits a two-byte big-endian address,
and text emits its bytes. An out-of-range byte is an error instead of widening
it after layout.
Octo reserves a native block's size during layout and reassembles it at its actual address once the program's labels are known. Errors point into the native source, including included files; source maps cover individual native instructions.
The example test/hybrid/wipeout-eti660.8o reconstructs W. F. Kreykes's
ETI-660 Wipeout hybrid using three native blocks and symbolic CHIP-8 references:
chiplet --start-address 1536 -o wipeout.ch8 test/hybrid/wipeout-eti660.8oThe result is exactly 606 bytes, identical to the supplied original image:
SHA-256 e7c71e4f3fd9839204429f6d00b1361b83ceb71fd4c6c08aae4db5fa46e13913.
The original-image comparison is included in the hybrid regression tests.