Skip to content

Place Sprites Between Byte Boundaries

Prepare shifted mask/image phases once, then choose the right one for a pixel position. Working Spectrum, BBC and Amiga code makes the memory and integration costs explicit.

Moving eight pixels at a time is quite a leap for a little spaceship. A byte-aligned renderer can be useful, but eventually you want the object one pixel further right. Then another. The memory still comes in bytes or words; the player should not have to care.

One answer is pre-shifting: prepare several copies of the mask and image, each starting one pixel further into its first byte or word. At runtime, split the pixel position into an aligned address and a phase. Select that prepared image and feed it to a bounded renderer.

The code here does exactly that, including objects crossing the screen edges. The complete callers draw a static gallery of phases and keep servicing frame waits. They demonstrate placement and cropping; animation and removal of old pixels remain the surrounding program’s job.

Implementation Prepared phases Bank size, including pointers Renderer
Spectrum Eight 784 bytes Three-byte masked rows; bitmap attributes stay alone
BBC Micro Eight 784 bytes Three-byte masked rows in normal MODE4
Amiga Sixteen 2,176 bytes The existing two-word clipped blitter renderer

All three start from the same original 16×16 artwork. The runtime interfaces and mask representations remain machine-specific. C64 and NES already expose pixel positions for hardware sprites; their sprite entries take that different route.

Keep the address and the phase together

On Spectrum and BBC, divide pixel X by eight to find its byte column. The low three bits select a phase from zero to seven. A pixel position of 13 therefore uses byte column 1 and phase 5.

Negative positions need a little care. At X = −1, the object starts in byte column −1, shifted seven pixels into that byte. Its visible pixels continue into column zero. Dividing towards zero would give the wrong column. The wrappers use arithmetic right shifts, which round the signed position down.

Amiga uses the same reasoning with sixteen-pixel words: X = 19 becomes word 1, phase 3; X = −1 becomes word −1, phase 15. Only after that split does the existing clipping calculation select the surviving source region and form screen addresses.

The same image row prepared at phases zero, one and seven inside three bytes. Transparent padding moves around the original ink and opaque-off pixels.

Prepare masks as well as pixels

The original image has three useful states: transparent, opaque off and opaque ink. An opaque off pixel matters: it clears a lit background pixel. A transparent pixel leaves that background alone.

The small preparation tool reads sixteen rows of sixteen ., 0 or 1 samples. It shifts both coverage and ink, then writes the target representation. Spectrum/BBC use interleaved keep-mask/image bytes. Amiga uses separate opaque-mask/image planes, with the opposite mask polarity.

Use each machine folder’s make regenerate command after changing the art. make check-assets rejects stale generated assembly. The generated phases.inc file is also included in the source repository, so ordinary builds do not need image packages or a separate graphics editor. An optional prefix gives each sprite bank distinct labels; the runtime routine takes a table pointer, so it can serve several banks.

Prepare and validate the target-specific phase banks

prepare.py
"""Prepare original 16x16 three-state artwork for aligned masked renderers.

No image packages: . is transparent, 0 opaque black, 1 opaque ink.
Eight byte phases for Spectrum/BBC; sixteen word phases for Amiga.
"""

from __future__ import annotations

import argparse
import json
import re
from pathlib import Path


def validate(rows: object) -> list[str]:
    if (
        not isinstance(rows, list)
        or len(rows) != 16
        or any(
            not isinstance(row, str) or len(row) != 16 or set(row) - set(".01")
            for row in rows
        )
    ):
        raise ValueError("Expected sixteen rows of sixteen . / 0 / 1 samples")
    return rows


def phases(rows: list[str], system: str) -> list[tuple[bytes, bytes]]:
    validate(rows)
    if system not in ["spectrum", "bbc", "amiga"]:
        raise ValueError("Unknown target")
    bits = 16 if system == "amiga" else 8
    width = 32 if system == "amiga" else 24
    result = []
    for shift in range(bits):
        mask = bytearray()
        image = bytearray()
        for row in rows:
            opaque = int("".join("0" if v == "." else "1" for v in row), 2) << (
                width - 16 - shift
            )
            ink = int("".join("1" if v == "1" else "0" for v in row), 2) << (
                width - 16 - shift
            )
            m = opaque if system == "amiga" else ((1 << width) - 1) ^ opaque
            mask.extend(m.to_bytes(width // 8, "big"))
            image.extend(ink.to_bytes(width // 8, "big"))
        result.append((bytes(mask), bytes(image)))
    return result


def assembly(rows: list[str], system: str, prefix: str = "sprite") -> str:
    if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", prefix):
        raise ValueError("Prefix must be an assembly identifier")
    prepared = phases(rows, system)
    lines = [
        "; SPDX-License-Identifier: MIT",
        "; Generated from original sprite.json by prepare.py. Regenerate, do not hand-edit.",
        "assets_begin:",
        "phase_table:",
    ]
    if system == "amiga":
        lines += [f"    dc.l phase_{n}_mask,phase_{n}_image" for n in range(16)]
    else:
        directive = "defw" if system == "spectrum" else "!word"
        lines += [f"    {directive} phase_{n}" for n in range(8)]
    for n, (mask, image) in enumerate(prepared):
        if system == "amiga":
            for label, data in [("mask", mask), ("image", image)]:
                lines.append(f"phase_{n}_{label}:")
                for i in range(0, len(data), 16):
                    lines.append("    dc.b " + ",".join(map(str, data[i : i + 16])))
        else:
            data = bytes(v for pair in zip(mask, image, strict=True) for v in pair)
            lines.append(f"phase_{n}:")
            for i in range(0, len(data), 16):
                lines.append(
                    "    "
                    + ("defb" if system == "spectrum" else "!byte")
                    + " "
                    + ",".join(map(str, data[i : i + 16]))
                )
    lines.append("assets_end:")
    text = "\n".join(lines) + "\n"
    return re.sub(
        r"\b(?:assets_begin|assets_end|phase_table|phase_\d+(?:_mask|_image)?)\b",
        lambda m: prefix + "_" + m[0],
        text,
    )


def main() -> None:
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("source", type=Path)
    p.add_argument("--system", choices=["spectrum", "bbc", "amiga"], required=True)
    p.add_argument("--output", type=Path, required=True)
    p.add_argument("--prefix", default="sprite")
    p.add_argument("--check", action="store_true")
    a = p.parse_args()
    text = assembly(validate(json.loads(a.source.read_text())), a.system, a.prefix)
    if a.check:
        if not a.output.is_file() or a.output.read_text() != text:
            raise ValueError("Generated assembly differs: " + str(a.output))
    else:
        a.output.write_text(text)


if __name__ == "__main__":
    main()

The extra column is part of the cost

A sixteen-pixel image shifted into a byte can spill into a third byte. The Spectrum/BBC prepared rows therefore occupy three byte columns, with a keep-mask and image byte for each: six bytes per row, ninety-six per phase. Eight phases and a sixteen-byte pointer table total 784 bytes, compared with 64 unshifted mask/image bytes.

All phases use that same stride, even phase zero with its transparent padding. Cropping a side does not shrink the original source row. Both renderers preserve the six-byte stride when advancing to the next row.

For Amiga, each phase is padded to two words across. A mask and image occupy 128 bytes per phase; sixteen phases and 128 pointer-table bytes total 2,176 bytes, compared with 64 unshifted bytes. This buys reuse of the already checked word-aligned clipping path, at a clear chip-RAM cost.

The Amiga hardware shifter is an important alternative. A runtime-shifted implementation needs its own source-width, carry and edge-mask checks. On other machines, shifting while drawing can save stored phases while adding runtime work. Fewer prepared phases can also be a deliberate movement-style choice. None of these choices establishes a frame budget on its own.

Fit the padded footprint into the game

Keep logical position and animation state in the object. Reload the wrapper’s inputs before each draw. A phase bank is read-only; each active call needs exclusive working state and the machine’s required resources.

For movement over scenery, background restoration must cover the old padded and clipped footprint, including its old dimensions and address. Spectrum’s largest prepared footprint needs 48 background bytes, separate from the source bank. Amiga’s needs 64. BBC’s existing 8×8 cell-save helper is too small for this job; use a larger backing store or redraw from world/tile data.

This is a useful component to combine with input, movement, clipping and animation. The native gallery and execution record show the bounded implementations, their actual outputs and deliberate failure controls. The machine entries give the calling contracts and complete programs.