Amiga ILBM: Parse IFF Chunks, Planar Rows, and ByteRun1 Safely
Build a defensive Amiga ILBM reader by validating IFF chunk bounds, planar row layout, mask semantics, and ByteRun1 output lengths.
An Amiga ILBM image is not a bitmap header followed by one contiguous pixel array. It is an IFF FORM ILBM container whose chunks carry dimensions, palette and display metadata, and an interleaved stream of planar rows. The BMHD compression field determines whether the BODY contains raw rows or ByteRun1-compressed rows. A parser that assumes fixed chunk offsets, forgets the IFF pad byte, or decodes compression across row boundaries can shift every later field or manufacture an image from malformed input.
This article describes a bounded reader for ordinary FORM ILBM still images. It is not a universal IFF interpreter, Amiga display-mode emulator, or promise that every historical extension has one standardized meaning. Preserve unknown chunks when round-tripping files, and reject a FORM variant your implementation does not understand rather than silently rewriting its structure.
Walk the IFF container by declared lengths
An IFF file begins with a grouping chunk such as FORM, followed by a big-endian 32-bit ckSize. A FORM’s contents begin with its four-byte form type, in this case ILBM, then contain subchunks. Every ordinary chunk begins with a four-byte ID and a big-endian size. If its declared data length is odd, one pad byte follows; that pad byte is not included in ckSize. The next chunk begins after both data and any required pad.
Treat each enclosing size as a hard boundary. Before reading a header or allocating a buffer, validate that the required bytes fit inside both the file and the parent FORM. Compute the next offset with checked arithmetic:
data_start = chunk_start + 8
data_end = data_start + ckSize
next_chunk = data_end + (ckSize mod 2)
Reject integer overflow, a child chunk extending beyond its parent, a truncated pad, or a file whose FORM boundary is inconsistent with the input length. The IFF standard says a reader must treat a chunk’s ckSize as a virtual end-of-file even when nested contents are malformed. Unknown chunks should be skipped by their declared length, not by searching the byte stream for something that looks like a known ID. A byte pattern such as BODY may occur inside compressed pixel data.
For archival conversion, record unrecognized chunk IDs and retain their bytes if the output format permits it. The IFF design allows application-specific chunks, and a reader cannot infer that unfamiliar bytes are disposable. If your tool edits only the raster but cannot preserve or validate a containing structure, write a new standalone ILBM rather than modifying an unknown nested FORM in place.
Validate the ILBM header before decoding pixels
BMHD is required and must appear before BODY; it provides width, height, plane count, masking mode, and compression type. The structure defined by the ILBM supplement is 20 bytes, with byte-packed fields. Do not deserialize it by reading a native C structure whose compiler may insert padding or whose host byte order differs from IFF’s big-endian fields.
Each bitplane row is padded to a 16-bit word boundary. For a width w, the number of bytes in one uncompressed plane row is:
row_bytes = ((width + 15) // 16) * 2
This is not always ceil(width / 8): a 17-pixel row occupies four bytes, not three. The BODY is arranged by scanline: for each image row, plane 0’s row is followed by plane 1’s row and so on; if masking says a mask plane is present, its row follows the image planes. Repeat that group for each scanline. Bits from the corresponding position in each plane form a color-map index, and plane 0 contributes the low-order bit.
Masking modes are semantic, not interchangeable flags. mskHasMask means an extra interleaved plane is present. mskHasTransparentColor instead marks one source-plane index as transparent and does not add a plane. mskLasso is a separate legacy convention that allows a reader to construct a mask from a transparent-color border; either implement that behavior intentionally or reject it as unsupported. Do not increment the stored plane count for transparent-color masking.
Set application-level limits before decoding attacker-controlled dimensions. Check that width and height are nonzero if your product requires a visible image, cap their product and total decoded bytes, bound nPlanes, and check every multiplication for overflow. These are resource policies, not extra ILBM format rules. Validate that the compression value is one you implement; do not treat unknown compression as raw data. A CAMG chunk is Amiga-specific mode metadata, not a substitute for the planar pixel layout. Preserve it for faithful round trips, and do not assume every image can be displayed with a mode supported by the current hardware or emulator.
Decode ByteRun1 one expected row at a time
When BMHD.compression is cmpNone, each plane row consumes exactly row_bytes. When it is cmpByteRun1, decode each plane or mask row separately until it produces exactly that many output bytes. The control byte is interpreted as a signed value:
| Control value | Decoder action |
|---|---|
| 0 through 127 | Copy the next control + 1 bytes literally |
| -1 through -127 | Repeat the next byte 1 - control times |
| -128 | No operation; consume the control byte and continue |
The -128 value is not an end marker. It produces no output. A malicious or corrupt stream containing endless no-ops must still be bounded by the containing chunk and by a defensive work limit. Before copying a literal run or expanding a replicate run, verify that its input bytes exist and that the output will not exceed the remaining row capacity. Stop exactly at the expected row size; a run that would overshoot is malformed rather than a reason to write into the following row.
def decode_byterun1_row(src, offset, expected):
out = bytearray()
while len(out) < expected:
if offset >= len(src):
raise ValueError("truncated ByteRun1 control")
control = src[offset]
offset += 1
signed = control if control < 128 else control - 256
if 0 <= signed <= 127:
count = signed + 1
if offset + count > len(src) or len(out) + count > expected:
raise ValueError("invalid literal run")
out.extend(src[offset:offset + count])
offset += count
elif -127 <= signed <= -1:
count = 1 - signed
if offset >= len(src) or len(out) + count > expected:
raise ValueError("invalid replicate run")
out.extend([src[offset]] * count)
offset += 1
# -128 is a no-op, not a terminator.
return bytes(out), offset
This routine intentionally receives the BODY chunk’s bounded byte slice, not the entire input file. The caller still must ensure the decoder cannot consume past the chunk, that each expected row is decoded in the correct plane order, and that exactly the intended raster data is consumed. Do not “fix” a short row by borrowing bytes from the next plane; that turns one error into silent image corruption.
Preserve palette, mode, and storage semantics
CMAP entries are red, green, and blue bytes, and may be absent or contain fewer registers than a particular display pipeline would ideally use; the ILBM material describes reader fallback behavior. A robust importer should retain the original palette values even if it must create a normalized preview for a modern display. The CAMG chunk stores Amiga display-mode information; later versions can use a 32-bit ModeID, so truncating it to a 16-bit flag word can discard meaningful mode data. Treat CAMG as interpretation metadata and report when the current rendering backend cannot reproduce that mode.
Keep byte-exact originals and record hashes before conversion when preservation matters. A decoded PNG is useful for access but is not a substitute for the source: it may lose indexed palette cycling, mask semantics, display-mode information, nonstandard chunks, or the original ByteRun1 stream. If writing an ILBM, recalculate each chunk’s data length, append but do not count an even-alignment pad byte, and update the containing FORM size. Never assume a chunk appears at a fixed offset or that every producer writes a canonical ordering among property chunks; the critical rule is that the information needed for BODY interpretation appears before BODY.
Test the reader with structural and raster fixtures
Build a small conformance corpus containing an uncompressed image, a compressed image with literal and replicate runs, a -128 no-op, a mask-plane image, transparent-color masking, an odd-length chunk requiring pad, and unknown chunks before BODY. Add malformed cases: truncated FORM, chunk size beyond parent, missing BMHD, unsupported compression, truncated literal data, replicate with no value byte, output overshoot, dimensions that overflow arithmetic, and a BODY that ends between rows.
For every valid fixture, compare decoded plane bytes and output dimensions with a trusted independent decoder. For invalid inputs, require a controlled error and bounded memory/CPU use, not partial output with a success status. If your encoder is part of the pipeline, decode its output with another implementation and compare both the pixels and metadata you promise to preserve.
Related:
- Amiga Hunk Executables: Parse Segment Tables and Relocations
- Amiga Copper and Bitplane DMA: Beam-Synchronized Display Lists
Sources: