diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7ba31ff --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.github +__pycache__ +*.py[cod] +*.egg-info +.pytest_cache +.venv +venv +dist +build +tests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..57a2b0a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install -e . + - run: python -m unittest discover -s tests -v + - run: retroasset targets + - run: retroasset profile amiga-ocs + - run: retroasset profile ansi-cp437 + - run: retroasset validate examples/amiga-ocs-sprite.json + - run: retroasset validate examples/ansi-cp437-screen.json + - run: retroasset export examples/amiga-ocs-checker.json --target amiga-ocs --format ilbm -o /tmp/checker.ilbm + - run: test -s /tmp/checker.ilbm + - name: Build OCI image + run: docker build -t retroasset:test . + - name: Smoke-test OCI CLI + run: docker run --rm retroasset:test targets + - name: Smoke-test OCI native export + run: | + mkdir -p /tmp/retroasset-oci + chmod 777 /tmp/retroasset-oci + docker run --rm \ + -v "$PWD/examples:/work/input:ro" \ + -v "/tmp/retroasset-oci:/work/output" \ + retroasset:test export /work/input/amiga-ocs-checker.json \ + --target amiga-ocs --format ilbm -o /work/output/checker.ilbm + test -s /tmp/retroasset-oci/checker.ilbm diff --git a/.github/workflows/oci.yml b/.github/workflows/oci.yml new file mode 100644 index 0000000..29064dd --- /dev/null +++ b/.github/workflows/oci.yml @@ -0,0 +1,42 @@ +name: OCI + +on: + push: + branches: [main] + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + publish: + # Manual runs must not turn feature branches into published package sources. + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v5 + id: meta + with: + images: ghcr.io/ploos-as/retroasset + tags: | + type=sha,prefix=sha- + type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/qualification.yml b/.github/workflows/qualification.yml new file mode 100644 index 0000000..8ec46fa --- /dev/null +++ b/.github/workflows/qualification.yml @@ -0,0 +1,196 @@ +name: Emulator qualification + +on: + workflow_dispatch: + push: + branches: [m0-foundation] + paths: + - "src/retroasset/c64prg.py" + - "src/retroasset/petscii.py" + - "src/retroasset/amiga.py" + - "qualification/**" + - ".github/workflows/qualification.yml" + +jobs: + + amiga-m68k-q1: + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install -e . + - name: Generate and embed ILBM fixtures + run: | + python qualification/generate_amiga_fixtures.py + python qualification/embed_amiga_fixtures.py + - name: Pull qualified Amiga development environment + run: docker pull ghcr.io/ploos-as/amiga-dev:edge + - name: Cross-build m68k guest verifier + run: | + mkdir -p qualification/runtime-input + docker run --rm \ + --user "$(id -u):$(id -g)" \ + -v "$PWD:/workspace:ro" \ + -v "$PWD/qualification/runtime-input:/output" \ + -w /workspace \ + ghcr.io/ploos-as/amiga-dev:edge \ + sh -ec 'amiga-check && m68k-amigaos-gcc -m68000 -Os -Wall -Wextra -Werror -DRETROASSET_EMBEDDED_FIXTURES qualification/amiga/ilbm_verify.c -o /tmp/ilbm-verify && file /tmp/ilbm-verify | tee /tmp/ilbm-file.txt && grep -Fq "AmigaOS loadseg()ble executable/binary" /tmp/ilbm-file.txt && cp /tmp/ilbm-verify /output/ilbm-verify' + cp qualification/amiga/amiga-runtime.json qualification/runtime-input/amiga-runtime.json + test -s qualification/runtime-input/ilbm-verify + test -s qualification/runtime-input/amiga-runtime.json + - name: Upload Q1 runtime payload + uses: actions/upload-artifact@v4 + with: + name: retroasset-amiga-m68k-q1 + path: qualification/runtime-input/ + if-no-files-found: error + + + amiga-aros-q3: + needs: amiga-m68k-q1 + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + env: + AMIGA_RUNTIME_IMAGE: ghcr.io/ploos-as/amiga-runtime:sha-fd7cc88 + steps: + - name: Download Q1 payload + uses: actions/download-artifact@v4 + with: + name: retroasset-amiga-m68k-q1 + path: runtime-input + - name: Verify project contract + run: | + test -s runtime-input/amiga-runtime.json + jq -e '.schema == 1 and .architecture == "m68k"' runtime-input/amiga-runtime.json >/dev/null + - name: Pull immutable Amiga runtime + run: docker pull "$AMIGA_RUNTIME_IMAGE" + - name: Prepare AROS/m68k runtime + run: | + mkdir -p runtime-evidence runtime-aros + docker run --rm \ + -v "$PWD/runtime-aros:/work/aros" \ + -e AROS_RUNTIME_DIR=/work/aros \ + "$AMIGA_RUNTIME_IMAGE" prepare-aros | tee runtime-evidence/aros-prepare.log + grep -Fq 'AROS m68k runtime prepared: PASS' runtime-evidence/aros-prepare.log + - name: Qualify RetroAsset ILBM payload in AROS/m68k + run: | + docker run --rm \ + -v "$PWD/runtime-input:/work/input:ro" \ + -v "$PWD/runtime-evidence:/work/evidence" \ + -v "$PWD/runtime-aros:/work/aros" \ + -e AROS_RUNTIME_DIR=/work/aros \ + "$AMIGA_RUNTIME_IMAGE" qualify-contract /work/input --profile a1200-020-aros + - name: Upload AROS qualification evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: retroasset-amiga-aros-q3-evidence + path: runtime-evidence/ + if-no-files-found: warn + + c64-structural: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install -e . + - run: python -m unittest tests.test_c64prg tests.test_c64prg_layout -v + - run: python qualification/generate_c64_fixture.py + - uses: actions/upload-artifact@v4 + with: + name: retroasset-c64-fixture + path: qualification/generated/ + + c64-vice-open-roms: + runs-on: ubuntu-latest + needs: c64-structural + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install -e . + - run: python qualification/generate_c64_fixture.py + - name: Install VICE and Open ROMs build dependencies + run: | + sudo apt-get update + sudo apt-get install -y vice git make gcc libpng-dev + x64sc --version + - name: Build redistributable C64 Open ROMs + run: | + git clone --depth 1 https://github.com/MEGA65/open-roms.git /tmp/open-roms + make -C /tmp/open-roms + find /tmp/open-roms/bin -maxdepth 1 -type f -print + - name: Locate Open ROMs + run: | + python qualification/find_open_roms.py /tmp/open-roms/bin qualification/generated/roms.env + cat qualification/generated/roms.env + - name: Run VICE qualification with Open ROMs + run: | + set -a + . qualification/generated/roms.env + set +a + xvfb-run -a x64sc \ + -basic "$RETROASSET_BASIC_ROM" \ + -kernal "$RETROASSET_KERNAL_ROM" \ + -chargen "$RETROASSET_CHARGEN_ROM" \ + -moncommands qualification/vice-monitor.txt \ + -console + - name: Verify emulated RAM + run: python qualification/verify_c64.py + - uses: actions/upload-artifact@v4 + if: always() + with: + name: retroasset-c64-vice-open-roms-evidence + path: qualification/generated/ + + amiga-ilbm-consumer: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install -e . + - run: python -m unittest tests.test_amiga -v + - run: python qualification/generate_amiga_fixtures.py + - name: Build standalone ILBM verifier + run: | + cc -std=c99 -Wall -Wextra -Werror -O2 qualification/amiga/ilbm_verify.c -o qualification/generated/ilbm-verify-host + qualification/generated/ilbm-verify-host qualification/generated/amiga-uncompressed.ilbm qualification/generated/amiga-byterun1.ilbm | tee qualification/generated/ilbm-verify-host.log + grep -Fq 'RETROASSET_ILBM_UNCOMPRESSED_PASS' qualification/generated/ilbm-verify-host.log + grep -Fq 'RETROASSET_ILBM_BYTERUN1_PASS' qualification/generated/ilbm-verify-host.log + - name: Smoke-test embedded guest payload mode + run: | + python qualification/embed_amiga_fixtures.py + cc -std=c99 -Wall -Wextra -Werror -O2 -DRETROASSET_EMBEDDED_FIXTURES qualification/amiga/ilbm_verify.c -o qualification/generated/ilbm-verify-embedded-host + qualification/generated/ilbm-verify-embedded-host | tee qualification/generated/ilbm-verify-embedded-host.log + grep -Fq 'RETROASSET_ILBM_UNCOMPRESSED_PASS' qualification/generated/ilbm-verify-embedded-host.log + grep -Fq 'RETROASSET_ILBM_BYTERUN1_PASS' qualification/generated/ilbm-verify-embedded-host.log + - name: Install independent ILBM consumer + run: | + sudo apt-get update + sudo apt-get install -y imagemagick + convert -version + - name: Decode generated ILBM files independently + run: | + identify qualification/generated/amiga-uncompressed.ilbm + identify qualification/generated/amiga-byterun1.ilbm + convert qualification/generated/amiga-uncompressed.ilbm qualification/generated/amiga-uncompressed.ppm + convert qualification/generated/amiga-byterun1.ilbm qualification/generated/amiga-byterun1.ppm + test -s qualification/generated/amiga-uncompressed.ppm + test -s qualification/generated/amiga-byterun1.ppm + - uses: actions/upload-artifact@v4 + if: always() + with: + name: retroasset-amiga-ilbm-consumer-evidence + path: qualification/generated/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..756c98d --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,25 @@ +# RetroAsset architecture + +RetroAsset is an AI-assisted compiler/toolchain for native retro-computing assets. + +## Design principles + +1. **Hardware-authentic output.** A preview that looks retro is not sufficient; target validators decide whether an asset is valid. +2. **Provider-independent AI.** Generation is an optional source stage. The core pipeline must work without any AI service. +3. **Deterministic compilation.** Given a source asset, manifest and tool version, conversion and validation should be reproducible. +4. **Targets own constraints.** Machine-specific rules live in target profiles and validators, not in the generic image pipeline. +5. **Native output first.** PNG/SVG may be previews; ILBM, planar data, character cells and other native forms are first-class outputs. + +## Pipeline + +source -> import/generate -> RetroAsset IR -> transform -> target validator -> exporter -> native asset + +## Target families + +- raster: Amiga OCS/ECS/AGA, later Atari ST, EGA/VGA and Mac68k +- character: ANSI/CP437, PETSCII, ATASCII +- tile/sprite: machine-specific tile, Bob and sprite representations + +## M0 + +M0 establishes the IR/manifest, CLI, target registry, Amiga OCS baseline validator and character-art extension points. AI backends are intentionally deferred. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..37219ba --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-alpine + +LABEL org.opencontainers.image.source="https://github.com/Ploos-AS/RetroAsset" +LABEL org.opencontainers.image.description="RetroAsset native retro-asset compiler and validator" +LABEL org.opencontainers.image.licenses="MIT" + +WORKDIR /app +COPY pyproject.toml README.md LICENSE ./ +COPY src ./src +RUN pip install --no-cache-dir . \ + && addgroup -S retroasset \ + && adduser -S -G retroasset retroasset + +RUN mkdir -p /work/input /work/output \ + && chown retroasset:retroasset /work/output + +WORKDIR /work +USER retroasset +ENTRYPOINT ["retroasset"] +CMD ["--help"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5d59f21 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ploos AS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index f19a283..3d62565 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,91 @@ # RetroAsset -RetroAsset + +**AI-assisted compiler and validator for genuine retro-computing assets.** + +RetroAsset turns generated, imported or hand-authored source material into assets that obey the constraints of real retro platforms. The goal is not merely to make modern images *look* retro. + +## Status + +M0 is release-ready as **RetroAsset 0.1.0**. + +The qualified M0 baseline includes native Amiga OCS/ECS/AGA profiles and exporters, ANSI/CP437 with SAUCE metadata, and C64 PETSCII/screen assets with raw and PRG export. Amiga and C64 outputs have emulator/runtime qualification in CI. + +Planned target families include: + +- Amiga OCS / ECS / AGA +- ANSI / IBM CP437 +- Commodore PETSCII +- Atari ATASCII +- Atari ST +- DOS EGA / VGA +- Mac68k + +## Pipeline + +```text +source + | + +-- hand authored + +-- imported + +-- AI generated + | + v +RetroAsset IR / manifest + | + v +deterministic transforms + | + v +target validator + | + v +native exporter +``` + +AI is optional and provider-independent. Native target validation remains deterministic. + +## M0 CLI + +```sh +python -m pip install -e . +retroasset targets +retroasset validate examples/amiga-ocs-sprite.json +``` + +Expected validation result: + +```text +PASS +``` + +See [ARCHITECTURE.md](ARCHITECTURE.md) for the design. + + +## OCI image + +RetroAsset is also designed to run as a reproducible OCI toolchain. The image uses an Alpine/Python baseline and runs as an unprivileged user. + +The stable container I/O contract is: + +- mount source assets read-only at `/work/input` +- mount generated assets read-write at `/work/output` +- invoke the same `retroasset` CLI used by native installations + +Example after the image has been published: + +```sh +docker run --rm \ + -v "$PWD/examples:/work/input:ro" \ + -v "$PWD/output:/work/output" \ + ghcr.io/ploos-as/retroasset:0.1.0 \ + export /work/input/amiga-ocs-checker.json \ + --target amiga-ocs --format ilbm -o /work/output/checker.ilbm +``` + +Published images target `linux/amd64` and `linux/arm64`. Release workflows produce immutable `sha-*` tags, semantic-version tags, and `edge` for main. Qualification and reproducible builds should prefer immutable tags rather than `edge`. + +The baseline image contains no AI provider runtime, proprietary ROMs, AmigaOS files, or other licensed machine firmware. + +## License + +Software is licensed under the MIT License. diff --git a/examples/amiga-ocs-checker.json b/examples/amiga-ocs-checker.json new file mode 100644 index 0000000..4f26189 --- /dev/null +++ b/examples/amiga-ocs-checker.json @@ -0,0 +1,9 @@ +{ + "width": 16, + "height": 2, + "palette": [[0, 0, 0], [255, 255, 255]], + "pixels": [ + 0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1, + 1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0 + ] +} diff --git a/examples/amiga-ocs-sprite.json b/examples/amiga-ocs-sprite.json new file mode 100644 index 0000000..bf946c9 --- /dev/null +++ b/examples/amiga-ocs-sprite.json @@ -0,0 +1,11 @@ +{ + "name": "hero", + "target": "amiga-ocs", + "kind": "sprite", + "width": 32, + "height": 48, + "colors": 16, + "metadata": { + "note": "M0 manifest fixture" + } +} diff --git a/examples/ansi-cp437-native.json b/examples/ansi-cp437-native.json new file mode 100644 index 0000000..38d9b25 --- /dev/null +++ b/examples/ansi-cp437-native.json @@ -0,0 +1,22 @@ +{ + "columns": 8, + "rows": 2, + "cells": [ + {"codepoint": 82, "foreground": 15, "background": 1}, + {"codepoint": 69, "foreground": 15, "background": 1}, + {"codepoint": 84, "foreground": 15, "background": 1}, + {"codepoint": 82, "foreground": 15, "background": 1}, + {"codepoint": 79, "foreground": 14, "background": 1}, + {"codepoint": 65, "foreground": 14, "background": 1}, + {"codepoint": 83, "foreground": 14, "background": 1}, + {"codepoint": 84, "foreground": 14, "background": 1}, + {"codepoint": 219, "foreground": 9, "background": 0}, + {"codepoint": 219, "foreground": 11, "background": 0}, + {"codepoint": 219, "foreground": 10, "background": 0}, + {"codepoint": 219, "foreground": 14, "background": 0}, + {"codepoint": 219, "foreground": 12, "background": 0}, + {"codepoint": 219, "foreground": 13, "background": 0}, + {"codepoint": 219, "foreground": 15, "background": 0}, + {"codepoint": 219, "foreground": 7, "background": 0} + ] +} diff --git a/examples/ansi-cp437-screen.json b/examples/ansi-cp437-screen.json new file mode 100644 index 0000000..c04db1e --- /dev/null +++ b/examples/ansi-cp437-screen.json @@ -0,0 +1,12 @@ +{ + "name": "bbs-login", + "target": "ansi-cp437", + "kind": "character-screen", + "width": 80, + "height": 25, + "colors": 16, + "metadata": { + "charset": "cp437", + "note": "M0.1 character target fixture" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..25dbda6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "retroasset" +version = "0.1.0" +description = "Compiler and validator for genuine retro-computing assets" +requires-python = ">=3.10" +license = {text = "MIT"} + +[project.scripts] +retroasset = "retroasset.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/qualification/README.md b/qualification/README.md new file mode 100644 index 0000000..b5cb0e2 --- /dev/null +++ b/qualification/README.md @@ -0,0 +1,74 @@ +# Runtime qualification + +RetroAsset distinguishes deterministic structural tests from emulator/runtime +qualification. + +## Status + +| Target | Structural | Runtime | +|---|---|---| +| C64 PRG | PASS | PASS — VICE 3.7.1 + MEGA65 Open ROMs | +| Amiga ILBM uncompressed | round-trip + independent consumer PASS | PASS — Q3 AROS/m68k under FS-UAE | +| Amiga ILBM ByteRun1 | round-trip + independent consumer PASS | PASS — Q3 AROS/m68k under FS-UAE | + +A native file format is not labelled runtime-qualified until it has been loaded +by the documented emulator/runtime and evidence has been captured. + +## Amiga ILBM interoperability evidence + +The qualification workflow generates ILBM fixtures with RetroAsset and then +decodes them with an independent consumer rather than RetroAsset's own decoder. +ImageMagick 6.9.12-98 (via ilbmtoppm) successfully reads both fixtures as +16x2, 1-plane ILBM images: + +- uncompressed ILBM: PASS +- ByteRun1-compressed ILBM: PASS + +This establishes independent file-format interoperability. + +## Amiga AROS/m68k runtime evidence + +The qualification workflow embeds the exact generated uncompressed and +ByteRun1 fixtures into an independent C verifier, cross-builds it as an m68k +Amiga Hunk with the qualified `Ploos-AS/amiga-dev` toolchain, and hands the +Q1 artifact to the stable `Ploos-AS/amiga-runtime` consumer contract. + +GitHub Actions run `37433194653`, job `amiga-aros-q3`, passed with the +immutable runtime image `ghcr.io/ploos-as/amiga-runtime:sha-fd7cc88`. +The runtime prepared the redistributable AROS/m68k environment and executed +the project contract with profile `a1200-020-aros`. The contract requires +both guest markers: + +- `RETROASSET_ILBM_UNCOMPRESSED_PASS 16x2 1-plane` +- `RETROASSET_ILBM_BYTERUN1_PASS 16x2 1-plane` + +A successful contract run requires these lines, so both ILBM variants are +qualified at Q3 under AROS/m68k with FS-UAE. The strengthened verifier also +requires the exact source fixture palette (black/white) and decoded planar +bytes (`55 55 aa aa`). Actions run `37466194514` passed these exact-content +checks for both uncompressed and ByteRun1 ILBM. + +This is redistributable AROS/m68k runtime evidence. It is **not** Q4 classic +AmigaOS qualification and must not be reported as such. + +## Planned runtimes + +- C64: VICE +- Amiga: project Amiga runtime infrastructure / compatible ILBM consumer + +Qualification jobs remain separate from fast unit-test CI. + +## C64 evidence + +The public qualification workflow builds the redistributable MEGA65 Open ROMs, +loads the generated PRG directly into VICE 3.7.1, executes the viewer at +`$080d`, stops at its completion point, and compares raw emulator RAM against +the expected generated assets. + +Qualified evidence: + +- screen RAM `$0400-$07e7`: PASS (1000 bytes) +- color RAM `$d800-$dbe7`: PASS (1000 bytes) +- proprietary Commodore ROMs are not stored in or required by the repository + + diff --git a/qualification/amiga/amiga-runtime.json b/qualification/amiga/amiga-runtime.json new file mode 100644 index 0000000..f32a0a4 --- /dev/null +++ b/qualification/amiga/amiga-runtime.json @@ -0,0 +1,22 @@ +{ + "schema": 1, + "project": "RetroAsset", + "milestone": "M0.5", + "architecture": "m68k", + "payload": { + "kind": "program", + "program": "ilbm-verify" + }, + "emulator_profiles": ["a1200-020-aros"], + "result": { + "log": "retroasset-ilbm-runtime.log", + "required_lines": [ + "RETROASSET_ILBM_UNCOMPRESSED_PASS 16x2 1-plane", + "RETROASSET_ILBM_BYTERUN1_PASS 16x2 1-plane" + ] + }, + "policy": { + "roms_in_repository": false, + "amigaos_files_in_repository": false + } +} diff --git a/qualification/amiga/ilbm_verify.c b/qualification/amiga/ilbm_verify.c new file mode 100644 index 0000000..f725dba --- /dev/null +++ b/qualification/amiga/ilbm_verify.c @@ -0,0 +1,149 @@ +#include +#include +#include +#include + +#ifdef RETROASSET_EMBEDDED_FIXTURES +#include "generated_fixtures.h" +#endif + +static uint16_t be16(const unsigned char *p) { + return (uint16_t)((p[0] << 8) | p[1]); +} + +static uint32_t be32(const unsigned char *p) { + return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | + ((uint32_t)p[2] << 8) | p[3]; +} + +static int decode_row(const unsigned char *src, size_t size, size_t *pos, + unsigned char *dst, size_t expected) { + size_t out = 0; + while (out < expected) { + int8_t n; + size_t count; + if (*pos >= size) return 0; + n = (int8_t)src[(*pos)++]; + if (n >= 0) { + count = (size_t)n + 1; + if (*pos + count > size || out + count > expected) return 0; + memcpy(dst + out, src + *pos, count); + *pos += count; + out += count; + } else if (n != -128) { + count = (size_t)(1 - n); + if (*pos >= size || out + count > expected) return 0; + memset(dst + out, src[(*pos)++], count); + out += count; + } + } + return 1; +} + +static int verify_buffer(const unsigned char *data, size_t file_size) { + const unsigned char *bmhd = NULL, *cmap = NULL, *body = NULL; + size_t bmhd_size = 0, cmap_size = 0; + size_t pos = 12, body_size = 0, stream_pos = 0; + uint16_t width, height; + unsigned planes, compression; + size_t stride, rows, i; + unsigned char *row; + static const unsigned char expected_cmap[] = {0, 0, 0, 255, 255, 255}; + static const unsigned char expected_planar[] = {0x55, 0x55, 0xaa, 0xaa}; + size_t planar_pos = 0; + + if (file_size < 12) return 0; + + if (memcmp(data, "FORM", 4) || memcmp(data + 8, "ILBM", 4)) { + return 0; + } + if ((size_t)be32(data + 4) + 8 != file_size) return 0; + while (pos + 8 <= (size_t)file_size) { + uint32_t size = be32(data + pos + 4); + const unsigned char *payload = data + pos + 8; + if (pos + 8 + size > (size_t)file_size) { return 0; } + if (!memcmp(data + pos, "BMHD", 4)) { bmhd = payload; bmhd_size = size; } + if (!memcmp(data + pos, "CMAP", 4)) { cmap = payload; cmap_size = size; } + if (!memcmp(data + pos, "BODY", 4)) { body = payload; body_size = size; } + pos += 8 + size + (size & 1); + } + if (!bmhd || !cmap || !body || bmhd_size < 20) { return 0; } + if (cmap_size != sizeof(expected_cmap) || + memcmp(cmap, expected_cmap, sizeof(expected_cmap))) return 0; + + width = be16(bmhd); + height = be16(bmhd + 2); + planes = bmhd[8]; + compression = bmhd[10]; + if (width != 16 || height != 2 || planes != 1 || compression > 1) { return 0; } + + stride = ((width + 15) / 16) * 2; + rows = (size_t)height * planes; + row = (unsigned char *)malloc(stride); + if (!row) { return 0; } + + if (stride * rows != sizeof(expected_planar)) { free(row); return 0; } + if (compression == 0) { + if (body_size != sizeof(expected_planar) || + memcmp(body, expected_planar, sizeof(expected_planar))) { + free(row); return 0; + } + } else { + for (i = 0; i < rows; ++i) { + if (!decode_row(body, body_size, &stream_pos, row, stride)) { + free(row); return 0; + } + if (memcmp(row, expected_planar + planar_pos, stride)) { + free(row); return 0; + } + planar_pos += stride; + } + if (stream_pos != body_size) { free(row); return 0; } + } + + printf("RETROASSET_ILBM_%s_PASS %ux%u %u-plane\n", + compression ? "BYTERUN1" : "UNCOMPRESSED", + (unsigned)width, (unsigned)height, planes); + free(row); + return 1; +} + +static int verify(const char *path) { + FILE *f; + unsigned char *data; + long size; + int ok; + f = fopen(path, "rb"); + if (!f) return 0; + fseek(f, 0, SEEK_END); size = ftell(f); rewind(f); + if (size < 0) { fclose(f); return 0; } + data = (unsigned char *)malloc((size_t)size); + if (!data) { fclose(f); return 0; } + if (fread(data, 1, (size_t)size, f) != (size_t)size) { free(data); fclose(f); return 0; } + fclose(f); + ok = verify_buffer(data, (size_t)size); + free(data); + return ok; +} + +int main(int argc, char **argv) { + int i; +#ifdef RETROASSET_EMBEDDED_FIXTURES + if (argc == 1) { + if (!verify_buffer(fixture_uncompressed, fixture_uncompressed_len)) return 1; + if (!verify_buffer(fixture_byterun1, fixture_byterun1_len)) return 1; + return 0; + } +#endif + if (argc < 2) { + fprintf(stderr, "usage: ilbm-verify file.ilbm [...]\n"); + return 2; + } + for (i = 1; i < argc; ++i) { + if (!verify(argv[i])) { + fprintf(stderr, "RETROASSET_ILBM_FAIL %s\n", argv[i]); + return 1; + } + } + return 0; +} diff --git a/qualification/embed_amiga_fixtures.py b/qualification/embed_amiga_fixtures.py new file mode 100644 index 0000000..bf374af --- /dev/null +++ b/qualification/embed_amiga_fixtures.py @@ -0,0 +1,32 @@ +from pathlib import Path + + +def emit(name: str, data: bytes) -> str: + rows = [] + for offset in range(0, len(data), 12): + chunk = ", ".join(f"0x{b:02x}" for b in data[offset:offset + 12]) + rows.append(" " + chunk) + return ( + f"static const unsigned char {name}[] = {{\n" + + ",\n".join(rows) + + "\n};\n" + + f"static const unsigned long {name}_len = sizeof({name});\n" + ) + + +def main() -> None: + root = Path("qualification/generated") + out = Path("qualification/amiga/generated_fixtures.h") + pairs = [ + ("fixture_uncompressed", root / "amiga-uncompressed.ilbm"), + ("fixture_byterun1", root / "amiga-byterun1.ilbm"), + ] + text = "#ifndef RETROASSET_GENERATED_FIXTURES_H\n#define RETROASSET_GENERATED_FIXTURES_H\n\n" + for name, path in pairs: + text += emit(name, path.read_bytes()) + "\n" + text += "#endif\n" + out.write_text(text, encoding="ascii") + + +if __name__ == "__main__": + main() diff --git a/qualification/find_open_roms.py b/qualification/find_open_roms.py new file mode 100644 index 0000000..51eb340 --- /dev/null +++ b/qualification/find_open_roms.py @@ -0,0 +1,29 @@ +from pathlib import Path +import sys + + +def choose(root: Path, patterns: tuple[str, ...]) -> Path: + matches = [] + for pattern in patterns: + matches.extend(root.glob(pattern)) + matches = sorted({p.resolve() for p in matches if p.is_file()}) + if not matches: + raise SystemExit(f"no ROM matched {patterns}") + return matches[0] + + +def main() -> None: + root = Path(sys.argv[1]) + output = Path(sys.argv[2]) + basic = choose(root, ("*basic*.rom", "*BASIC*.rom")) + kernal = choose(root, ("*kernal*.rom", "*KERNAL*.rom")) + chargen = choose(root, ("*chargen*.rom", "*CHARGEN*.rom", "*pxl*.rom", "*PXL*.rom")) + output.write_text( + f"RETROASSET_BASIC_ROM={basic}\n" + f"RETROASSET_KERNAL_ROM={kernal}\n" + f"RETROASSET_CHARGEN_ROM={chargen}\n" + ) + + +if __name__ == "__main__": + main() diff --git a/qualification/generate_amiga_fixtures.py b/qualification/generate_amiga_fixtures.py new file mode 100644 index 0000000..3d04c0b --- /dev/null +++ b/qualification/generate_amiga_fixtures.py @@ -0,0 +1,20 @@ +from pathlib import Path + +from retroasset.amiga import encode_ilbm, encode_ilbm_compressed +from retroasset.indexed import IndexedBitmap + + +def main() -> None: + out = Path("qualification/generated") + out.mkdir(parents=True, exist_ok=True) + bitmap = IndexedBitmap( + 16, 2, + tuple([0, 1] * 8 + [1, 0] * 8), + ((0, 0, 0), (255, 255, 255)), + ) + (out / "amiga-uncompressed.ilbm").write_bytes(encode_ilbm(bitmap)) + (out / "amiga-byterun1.ilbm").write_bytes(encode_ilbm_compressed(bitmap)) + + +if __name__ == "__main__": + main() diff --git a/qualification/generate_c64_fixture.py b/qualification/generate_c64_fixture.py new file mode 100644 index 0000000..b6e1d24 --- /dev/null +++ b/qualification/generate_c64_fixture.py @@ -0,0 +1,21 @@ +from pathlib import Path + +from retroasset.c64prg import export_viewer_prg +from retroasset.petscii import C64Screen, PetsciiCell, export_color_ram, export_screen_ram + + +def main() -> None: + out = Path("qualification/generated") + out.mkdir(parents=True, exist_ok=True) + cells = tuple( + PetsciiCell((i % 64), (i // 40) % 16) + for i in range(1000) + ) + screen = C64Screen(40, 25, cells) + (out / "fixture.prg").write_bytes(export_viewer_prg(screen)) + (out / "expected-screen.bin").write_bytes(export_screen_ram(screen)) + (out / "expected-color.bin").write_bytes(export_color_ram(screen)) + + +if __name__ == "__main__": + main() diff --git a/qualification/verify_c64.py b/qualification/verify_c64.py new file mode 100644 index 0000000..ab91b04 --- /dev/null +++ b/qualification/verify_c64.py @@ -0,0 +1,26 @@ +from pathlib import Path +import sys + + +def check(name: str, actual: Path, expected: Path) -> bool: + a = actual.read_bytes() + e = expected.read_bytes() + if a == e: + print(f"{name}: PASS ({len(a)} bytes)") + return True + first = next((i for i, pair in enumerate(zip(a, e)) if pair[0] != pair[1]), None) + if first is None and len(a) != len(e): + first = min(len(a), len(e)) + print(f"{name}: FAIL first_difference={first} actual={len(a)} expected={len(e)}") + return False + + +def main() -> int: + root = Path("qualification/generated") + ok = check("screen RAM", root / "screen-dump.bin", root / "expected-screen.bin") + ok &= check("color RAM", root / "color-dump.bin", root / "expected-color.bin") + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/qualification/vice-monitor.txt b/qualification/vice-monitor.txt new file mode 100644 index 0000000..e7ac498 --- /dev/null +++ b/qualification/vice-monitor.txt @@ -0,0 +1,10 @@ +; RetroAsset C64 qualification. +; Load PRG directly; no disk/drive ROM is required. +load "qualification/generated/fixture.prg" 0 +; Viewer code completes at the RTS at $0869. +break 0869 +g 080d +; Playback resumes here after the breakpoint is hit. +bsave "qualification/generated/screen-dump.bin" 0 0400 07e7 +bsave "qualification/generated/color-dump.bin" 0 d800 dbe7 +quit diff --git a/src/retroasset/__init__.py b/src/retroasset/__init__.py new file mode 100644 index 0000000..9344084 --- /dev/null +++ b/src/retroasset/__init__.py @@ -0,0 +1,3 @@ +"""RetroAsset core package.""" + +__version__ = "0.0.1" diff --git a/src/retroasset/amiga.py b/src/retroasset/amiga.py new file mode 100644 index 0000000..a7fcecc --- /dev/null +++ b/src/retroasset/amiga.py @@ -0,0 +1,194 @@ +import struct + +from .indexed import IndexedBitmap + + +def row_bytes(width: int) -> int: + """ILBM rows are word aligned.""" + return ((width + 15) // 16) * 2 + + +def pack_planar(bitmap: IndexedBitmap) -> bytes: + stride = row_bytes(bitmap.width) + out = bytearray() + + # ILBM BODY is interleaved by row: plane 0 row, plane 1 row, ... + for y in range(bitmap.height): + for plane in range(bitmap.planes): + row = bytearray(stride) + for x in range(bitmap.width): + pixel = bitmap.pixels[y * bitmap.width + x] + if pixel & (1 << plane): + row[x // 8] |= 1 << (7 - (x % 8)) + out.extend(row) + return bytes(out) + + +def _chunk(chunk_id: bytes, payload: bytes) -> bytes: + if len(chunk_id) != 4: + raise ValueError("IFF chunk id must be four bytes") + pad = b"\0" if len(payload) & 1 else b"" + return chunk_id + struct.pack(">I", len(payload)) + payload + pad + + +def encode_ilbm(bitmap: IndexedBitmap) -> bytes: + if bitmap.planes > 8: + raise ValueError("M0.2 ILBM exporter supports at most 8 bitplanes") + + # BMHD: w,h,x,y,nPlanes,masking,compression,pad1,transparent, + # xAspect,yAspect,pageWidth,pageHeight + bmhd = struct.pack( + ">HHhhBBBBHBBhh", + bitmap.width, bitmap.height, 0, 0, + bitmap.planes, 0, 0, 0, 0, + 10, 11, bitmap.width, bitmap.height, + ) + cmap = bytes(channel for rgb in bitmap.palette for channel in rgb) + body = pack_planar(bitmap) + contents = b"ILBM" + _chunk(b"BMHD", bmhd) + _chunk(b"CMAP", cmap) + _chunk(b"BODY", body) + return b"FORM" + struct.pack(">I", len(contents)) + contents + + +def unpack_planar(data: bytes, width: int, height: int, planes: int) -> tuple[int, ...]: + stride = row_bytes(width) + expected = stride * height * planes + if len(data) != expected: + raise ValueError("planar data size does not match geometry") + pixels = [0] * (width * height) + offset = 0 + for y in range(height): + for plane in range(planes): + row = data[offset:offset + stride] + offset += stride + for x in range(width): + if row[x // 8] & (1 << (7 - (x % 8))): + pixels[y * width + x] |= 1 << plane + return tuple(pixels) + + +def decode_ilbm(data: bytes) -> IndexedBitmap: + if data[:4] != b"FORM" or data[8:12] != b"ILBM": + raise ValueError("not an ILBM FORM") + pos = 12 + bmhd = cmap = body = None + while pos + 8 <= len(data): + chunk_id = data[pos:pos + 4] + size = struct.unpack(">I", data[pos + 4:pos + 8])[0] + payload = data[pos + 8:pos + 8 + size] + pos += 8 + size + (size & 1) + if chunk_id == b"BMHD": + bmhd = payload + elif chunk_id == b"CMAP": + cmap = payload + elif chunk_id == b"BODY": + body = payload + if bmhd is None or cmap is None or body is None: + raise ValueError("ILBM requires BMHD, CMAP and BODY") + width, height, _, _, planes, _, compression, _, _, _, _, _, _ = struct.unpack( + ">HHhhBBBBHBBhh", bmhd + ) + if compression == 0: + planar = body + elif compression == 1: + stride = row_bytes(width) + decoded = bytearray() + offset = 0 + for _y in range(height): + for _plane in range(planes): + row, offset = byterun1_decode_from(body, offset, stride) + decoded.extend(row) + planar = bytes(decoded) + else: + raise ValueError(f"unsupported ILBM compression: {compression}") + palette = tuple(tuple(cmap[i:i + 3]) for i in range(0, len(cmap), 3)) + pixels = unpack_planar(planar, width, height, planes) + return IndexedBitmap(width, height, pixels, palette) + + +def byterun1_encode(data: bytes) -> bytes: + out = bytearray() + i = 0 + while i < len(data): + run = 1 + while i + run < len(data) and run < 128 and data[i + run] == data[i]: + run += 1 + if run >= 2: + out.append((1 - run) & 0xff) + out.append(data[i]) + i += run + continue + start = i + i += 1 + while i < len(data) and i - start < 128: + run = 1 + while i + run < len(data) and run < 128 and data[i + run] == data[i]: + run += 1 + if run >= 2: + break + i += 1 + literal = data[start:i] + out.append(len(literal) - 1) + out.extend(literal) + return bytes(out) + + +def byterun1_decode_from(data: bytes, offset: int, expected_size: int) -> tuple[bytes, int]: + out = bytearray() + i = offset + while len(out) < expected_size: + if i >= len(data): + raise ValueError("truncated ByteRun1 stream") + control = data[i] + i += 1 + signed = control if control < 128 else control - 256 + if 0 <= signed <= 127: + count = signed + 1 + if i + count > len(data): + raise ValueError("truncated ByteRun1 literal") + if len(out) + count > expected_size: + raise ValueError("ByteRun1 row exceeds expected size") + out.extend(data[i:i + count]) + i += count + elif -127 <= signed <= -1: + if i >= len(data): + raise ValueError("truncated ByteRun1 repeat") + count = 1 - signed + if len(out) + count > expected_size: + raise ValueError("ByteRun1 row exceeds expected size") + out.extend(data[i:i + 1] * count) + i += 1 + # -128 is a no-op. + return bytes(out), i + + +def byterun1_decode(data: bytes, expected_size: int) -> bytes: + decoded, _ = byterun1_decode_from(data, 0, expected_size) + return decoded + + +def _compress_body_by_rows(bitmap: IndexedBitmap, planar: bytes) -> bytes: + stride = row_bytes(bitmap.width) + row_plane_size = stride + out = bytearray() + pos = 0 + for _y in range(bitmap.height): + for _plane in range(bitmap.planes): + block = planar[pos:pos + row_plane_size] + pos += row_plane_size + out.extend(byterun1_encode(block)) + return bytes(out) + + +def encode_ilbm_compressed(bitmap: IndexedBitmap) -> bytes: + if bitmap.planes > 8: + raise ValueError("ILBM exporter supports at most 8 bitplanes") + bmhd = struct.pack( + ">HHhhBBBBHBBhh", + bitmap.width, bitmap.height, 0, 0, + bitmap.planes, 0, 1, 0, 0, + 10, 11, bitmap.width, bitmap.height, + ) + cmap = bytes(channel for rgb in bitmap.palette for channel in rgb) + body = _compress_body_by_rows(bitmap, pack_planar(bitmap)) + contents = b"ILBM" + _chunk(b"BMHD", bmhd) + _chunk(b"CMAP", cmap) + _chunk(b"BODY", body) + return b"FORM" + struct.pack(">I", len(contents)) + contents diff --git a/src/retroasset/ansi.py b/src/retroasset/ansi.py new file mode 100644 index 0000000..45ed606 --- /dev/null +++ b/src/retroasset/ansi.py @@ -0,0 +1,79 @@ +from .character import CharacterAsset, CharacterCell + + +ESC = b"\x1b[" + + +def _sgr(cell: CharacterCell) -> bytes: + fg = cell.foreground + bg = cell.background + if not 0 <= fg <= 15 or not 0 <= bg <= 7: + raise ValueError("ANSI colors out of DOS range") + codes = ["0"] + if fg >= 8: + codes.append("1") + fg -= 8 + codes.append(str(30 + fg)) + codes.append(str(40 + bg)) + if cell.blink: + codes.append("5") + return ESC + ";".join(codes).encode("ascii") + b"m" + + +def encode_ans(asset: CharacterAsset) -> bytes: + if len(asset.cells) != asset.expected_cells(): + raise ValueError("cell count does not match character geometry") + out = bytearray() + current = None + for y in range(asset.rows): + for x in range(asset.columns): + cell = asset.cells[y * asset.columns + x] + style = (cell.foreground, cell.background, cell.blink) + if style != current: + out.extend(_sgr(cell)) + current = style + if not 0 <= cell.codepoint <= 255: + raise ValueError("CP437 cell must contain a byte value") + out.append(cell.codepoint) + if y != asset.rows - 1: + out.extend(b"\r\n") + out.extend(ESC + b"0m") + return bytes(out) + + +def decode_ans(data: bytes, columns: int, rows: int) -> CharacterAsset: + cells = [] + fg, bg, blink = 7, 0, False + i = 0 + while i < len(data) and len(cells) < columns * rows: + if data[i:i + 2] == b"\x1b[": + end = data.find(b"m", i + 2) + if end < 0: + raise ValueError("unterminated ANSI SGR sequence") + raw = data[i + 2:end] + codes = [int(x) for x in raw.split(b";") if x] or [0] + bright = False + for code in codes: + if code == 0: + fg, bg, blink, bright = 7, 0, False, False + elif code == 1: + bright = True + elif code == 5: + blink = True + elif 30 <= code <= 37: + fg = code - 30 + (8 if bright else 0) + elif 40 <= code <= 47: + bg = code - 40 + i = end + 1 + continue + if data[i:i + 2] == b"\r\n": + # Writer emits CR/LF only at exact row boundaries. + if len(cells) % columns: + raise ValueError("unexpected CR/LF before end of ANSI row") + i += 2 + continue + cells.append(CharacterCell(data[i], fg, bg, blink)) + i += 1 + if len(cells) != columns * rows: + raise ValueError("ANSI data does not fill requested geometry") + return CharacterAsset(columns, rows, tuple(cells)) diff --git a/src/retroasset/c64prg.py b/src/retroasset/c64prg.py new file mode 100644 index 0000000..7e86d5c --- /dev/null +++ b/src/retroasset/c64prg.py @@ -0,0 +1,68 @@ +import struct + +from .petscii import C64Screen, export_color_ram, export_screen_ram + +LOAD_ADDRESS = 0x0801 +CODE_ADDRESS = 0x080D +SCREEN_SOURCE = 0x0900 +COLOR_SOURCE = 0x0D00 +SCREEN_DEST = 0x0400 +COLOR_DEST = 0xD800 +VISIBLE_CELLS = 1000 +TAIL_BYTES = VISIBLE_CELLS - 3 * 256 + + +def export_viewer_prg(screen: C64Screen) -> bytes: + if screen.columns != 40 or screen.rows != 25: + raise ValueError("M0.7 PRG viewer requires a 40x25 C64 screen") + + # $0801 BASIC line: 10 SYS2061. The link points at the $080b end marker. + basic = bytes([ + 0x0b, 0x08, + 0x0a, 0x00, + 0x9e, + 0x32, 0x30, 0x36, 0x31, + 0x00, + 0x00, 0x00, + ]) + + def page_copy(src: int, dst: int) -> bytes: + return bytes([ + 0xa2, 0x00, + 0xbd, src & 0xff, src >> 8, + 0x9d, dst & 0xff, dst >> 8, + 0xe8, + 0xd0, 0xf7, + ]) + + def tail_copy(src: int, dst: int) -> bytes: + return bytes([ + 0xa2, 0x00, + 0xbd, src & 0xff, src >> 8, + 0x9d, dst & 0xff, dst >> 8, + 0xe8, + 0xe0, TAIL_BYTES, + 0xd0, 0xf5, + ]) + + code = bytearray() + for page in range(3): + code.extend(page_copy(SCREEN_SOURCE + page * 256, SCREEN_DEST + page * 256)) + code.extend(tail_copy(SCREEN_SOURCE + 3 * 256, SCREEN_DEST + 3 * 256)) + for page in range(3): + code.extend(page_copy(COLOR_SOURCE + page * 256, COLOR_DEST + page * 256)) + code.extend(tail_copy(COLOR_SOURCE + 3 * 256, COLOR_DEST + 3 * 256)) + code.append(0x60) + + if CODE_ADDRESS + len(code) > SCREEN_SOURCE: + raise AssertionError("viewer code overlaps screen payload") + + prefix = struct.pack(" int: + return self.columns * self.rows diff --git a/src/retroasset/cli.py b/src/retroasset/cli.py new file mode 100644 index 0000000..c222cc5 --- /dev/null +++ b/src/retroasset/cli.py @@ -0,0 +1,209 @@ +import argparse +import json +from pathlib import Path + +from .amiga import encode_ilbm, encode_ilbm_compressed +from .ansi import decode_ans, encode_ans +from .character import CharacterAsset, CharacterCell +from .c64prg import export_viewer_prg +from .conversion import native_report +from .petscii import C64Screen, PetsciiCell, export_color_ram, export_screen_ram, interpret_petscii +from .exporters import export_asm, export_c, export_raw_planar +from .indexed import IndexedBitmap +from .model import AssetManifest +from .profiles import PROFILES +from .sauce import Sauce, decode_sauce, encode_sauce +from .targets import validate + + +def _load_manifest(path: Path) -> AssetManifest: + data = json.loads(path.read_text(encoding="utf-8")) + return AssetManifest(**data) + + +def _load_indexed(path: Path) -> IndexedBitmap: + data = json.loads(path.read_text(encoding="utf-8")) + return IndexedBitmap( + width=data["width"], + height=data["height"], + pixels=tuple(data["pixels"]), + palette=tuple(tuple(rgb) for rgb in data["palette"]), + ) + + +def main() -> int: + parser = argparse.ArgumentParser(prog="retroasset") + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("targets", help="list available targets") + show = sub.add_parser("profile", help="show a target profile") + show.add_argument("target") + check = sub.add_parser("validate", help="validate an asset manifest") + check.add_argument("manifest", type=Path) + + export = sub.add_parser("export", help="export a native retro asset") + export.add_argument("source", type=Path) + export.add_argument("--target", required=True, choices=sorted(PROFILES)) + export.add_argument("--format", required=True, choices=("ilbm", "raw", "c", "asm")) + export.add_argument("--symbol", default="asset") + export.add_argument("--compression", choices=("none", "byterun1"), default="none", + help="ILBM BODY compression (default: none)") + export.add_argument("-o", "--output", type=Path, required=True) + + char_import = sub.add_parser("char-import", help="import a native character-art asset") + char_import.add_argument("source", type=Path) + char_import.add_argument("--target", required=True, choices=("ansi-cp437", "c64-petscii")) + char_import.add_argument("--columns", type=int) + char_import.add_argument("--rows", type=int) + char_import.add_argument("-o", "--output", type=Path, required=True) + char_import.add_argument("--report", type=Path, help="write machine-readable conversion report") + + char_export = sub.add_parser("char-export", help="export a native character-art asset") + char_export.add_argument("source", type=Path) + char_export.add_argument("--target", required=True, choices=("ansi-cp437", "c64-screen")) + char_export.add_argument("--format", choices=("ans", "screen", "color", "prg")) + char_export.add_argument("-o", "--output", type=Path, required=True) + char_export.add_argument("--report", type=Path, help="write machine-readable conversion report") + + args = parser.parse_args() + + if args.command == "targets": + for target, profile in sorted(PROFILES.items()): + print(f"{target}\t{profile.family}\t{profile.platform}") + return 0 + + if args.command == "profile": + try: + profile = PROFILES[args.target] + except KeyError: + print(f"error: unknown target: {args.target}") + return 2 + print(json.dumps(profile.__dict__, indent=2)) + return 0 + + if args.command == "char-import": + if args.target == "ansi-cp437": + payload, sauce = decode_sauce(args.source.read_bytes()) + columns = args.columns or (sauce.tinfo1 if sauce else 0) + rows = args.rows or (sauce.tinfo2 if sauce else 0) + if not columns or not rows: + print("error: character geometry requires --columns/--rows or SAUCE dimensions") + return 2 + asset = decode_ans(payload, columns, rows) + data = { + "columns": asset.columns, + "rows": asset.rows, + "cells": [ + { + "codepoint": cell.codepoint, + "foreground": cell.foreground, + "background": cell.background, + "blink": cell.blink, + } + for cell in asset.cells + ], + } + if sauce is not None: + data["sauce"] = { + "title": sauce.title, + "author": sauce.author, + "group": sauce.group, + "date": sauce.date, + "data_type": sauce.data_type, + "file_type": sauce.file_type, + "tinfo1": sauce.tinfo1, + "tinfo2": sauce.tinfo2, + } + else: + columns = args.columns or 40 + rows = args.rows or 25 + screen = interpret_petscii(args.source.read_bytes(), columns, rows) + data = { + "columns": screen.columns, + "rows": screen.rows, + "cells": [ + {"screen_code": cell.screen_code, "color": cell.color} + for cell in screen.cells + ], + } + args.output.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + if args.report: + report = native_report(args.target, "character-ir") + args.report.write_text(json.dumps(report.as_dict(), indent=2) + "\n", encoding="utf-8") + print(f"WROTE {args.output}") + return 0 + + if args.command == "char-export": + data = json.loads(args.source.read_text(encoding="utf-8")) + if args.target == "ansi-cp437": + if args.format not in (None, "ans"): + print("error: ansi-cp437 supports only --format ans") + return 2 + asset = CharacterAsset( + columns=data["columns"], + rows=data["rows"], + cells=tuple(CharacterCell(**cell) for cell in data["cells"]), + ) + payload = encode_ans(asset) + if "sauce" in data: + meta = Sauce(**data["sauce"]) + payload += encode_sauce(meta, len(payload)) + else: + fmt = args.format or "screen" + if fmt not in ("screen", "color", "prg"): + print("error: c64-screen supports --format screen, color, or prg") + return 2 + screen = C64Screen( + columns=data["columns"], + rows=data["rows"], + cells=tuple(PetsciiCell(**cell) for cell in data["cells"]), + ) + if fmt == "screen": + payload = export_screen_ram(screen) + elif fmt == "color": + payload = export_color_ram(screen) + else: + payload = export_viewer_prg(screen) + args.output.write_bytes(payload) + if args.report: + report = native_report("character-ir", args.target) + args.report.write_text(json.dumps(report.as_dict(), indent=2) + "\n", encoding="utf-8") + print(f"WROTE {args.output}") + return 0 + + if args.command == "export": + if not args.target.startswith("amiga-"): + print("error: M0.3 native exporters currently require an Amiga target") + return 2 + bitmap = _load_indexed(args.source) + profile = PROFILES[args.target] + if profile.max_colors is not None and len(bitmap.palette) > profile.max_colors: + print(f"error: {args.target} supports at most {profile.max_colors} colors") + return 1 + if args.compression != "none" and args.format != "ilbm": + print("error: --compression applies only to ILBM export") + return 2 + if args.format == "ilbm": + encoder = encode_ilbm_compressed if args.compression == "byterun1" else encode_ilbm + args.output.write_bytes(encoder(bitmap)) + elif args.format == "raw": + args.output.write_bytes(export_raw_planar(bitmap)) + elif args.format == "c": + args.output.write_text(export_c(bitmap, args.symbol), encoding="utf-8") + elif args.format == "asm": + args.output.write_text(export_asm(bitmap, args.symbol), encoding="utf-8") + print(f"WROTE {args.output}") + return 0 + + asset = _load_manifest(args.manifest) + result = validate(asset) + for warning in result.warnings: + print(f"warning: {warning}") + for error in result.errors: + print(f"error: {error}") + print("PASS" if result.ok else "FAIL") + return 0 if result.ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/retroasset/conversion.py b/src/retroasset/conversion.py new file mode 100644 index 0000000..a06b3a7 --- /dev/null +++ b/src/retroasset/conversion.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ConversionReport: + source: str + target: str + lossy: bool = False + losses: tuple[str, ...] = () + + def as_dict(self) -> dict: + return { + "source": self.source, + "target": self.target, + "lossy": self.lossy, + "losses": list(self.losses), + } + + +def native_report(source: str, target: str) -> ConversionReport: + return ConversionReport(source, target) diff --git a/src/retroasset/exporters.py b/src/retroasset/exporters.py new file mode 100644 index 0000000..877029c --- /dev/null +++ b/src/retroasset/exporters.py @@ -0,0 +1,41 @@ +from .amiga import pack_planar +from .indexed import IndexedBitmap + + +def export_raw_planar(bitmap: IndexedBitmap) -> bytes: + return pack_planar(bitmap) + + +def export_c(bitmap: IndexedBitmap, symbol: str = "asset") -> str: + data = pack_planar(bitmap) + values = ", ".join(f"0x{b:02x}" for b in data) + palette = ", ".join( + f"0x{r:02x}, 0x{g:02x}, 0x{b:02x}" for r, g, b in bitmap.palette + ) + return ( + f"/* Generated by RetroAsset */\n" + f"#define {symbol.upper()}_WIDTH {bitmap.width}\n" + f"#define {symbol.upper()}_HEIGHT {bitmap.height}\n" + f"#define {symbol.upper()}_PLANES {bitmap.planes}\n" + f"static const unsigned char {symbol}_data[] = {{ {values} }};\n" + f"static const unsigned char {symbol}_palette[] = {{ {palette} }};\n" + ) + + +def export_asm(bitmap: IndexedBitmap, symbol: str = "asset") -> str: + data = pack_planar(bitmap) + rows = [] + for offset in range(0, len(data), 16): + rows.append(" dc.b " + ",".join(f"$%02x" % b for b in data[offset:offset + 16])) + palette_words = [] + for r, g, b in bitmap.palette: + word = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4) + palette_words.append("$%03x" % word) + return ( + "; Generated by RetroAsset\n" + + f"{symbol}_width equ {bitmap.width}\n" + + f"{symbol}_height equ {bitmap.height}\n" + + f"{symbol}_planes equ {bitmap.planes}\n" + + f"{symbol}_data:\n" + "\n".join(rows) + "\n" + + f"{symbol}_palette:\n dc.w " + ",".join(palette_words) + "\n" + ) diff --git a/src/retroasset/indexed.py b/src/retroasset/indexed.py new file mode 100644 index 0000000..7920434 --- /dev/null +++ b/src/retroasset/indexed.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class IndexedBitmap: + width: int + height: int + pixels: tuple[int, ...] + palette: tuple[tuple[int, int, int], ...] + + def __post_init__(self): + if self.width <= 0 or self.height <= 0: + raise ValueError("bitmap dimensions must be positive") + if len(self.pixels) != self.width * self.height: + raise ValueError("pixel count does not match bitmap dimensions") + if not self.palette: + raise ValueError("palette must not be empty") + if any(p < 0 or p >= len(self.palette) for p in self.pixels): + raise ValueError("pixel index outside palette") + for rgb in self.palette: + if len(rgb) != 3 or any(c < 0 or c > 255 for c in rgb): + raise ValueError("palette entries must be RGB bytes") + + @property + def planes(self) -> int: + return max(1, (len(self.palette) - 1).bit_length()) diff --git a/src/retroasset/model.py b/src/retroasset/model.py new file mode 100644 index 0000000..7a7f976 --- /dev/null +++ b/src/retroasset/model.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class AssetManifest: + name: str + target: str + kind: str + width: int | None = None + height: int | None = None + colors: int | None = None + metadata: dict[str, Any] = field(default_factory=dict) diff --git a/src/retroasset/petscii.py b/src/retroasset/petscii.py new file mode 100644 index 0000000..29506fa --- /dev/null +++ b/src/retroasset/petscii.py @@ -0,0 +1,137 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PetsciiCell: + """A native C64 character cell. + + screen_code is the value stored in screen RAM. color is the low nibble + stored in color RAM. PETSCII stream bytes are a separate representation. + """ + screen_code: int + color: int = 1 + + def __post_init__(self): + if not 0 <= self.screen_code <= 255: + raise ValueError("C64 screen code must be a byte") + if not 0 <= self.color <= 15: + raise ValueError("C64 color must be 0..15") + + +@dataclass(frozen=True) +class C64Screen: + columns: int + rows: int + cells: tuple[PetsciiCell, ...] + + def __post_init__(self): + if self.columns <= 0 or self.rows <= 0: + raise ValueError("screen geometry must be positive") + if len(self.cells) != self.columns * self.rows: + raise ValueError("cell count does not match C64 screen geometry") + + +def export_screen_ram(screen: C64Screen) -> bytes: + return bytes(cell.screen_code for cell in screen.cells) + + +def export_color_ram(screen: C64Screen) -> bytes: + return bytes(cell.color & 0x0f for cell in screen.cells) + + +def petscii_to_screen_code(value: int) -> int: + """Convert common PETSCII printable bytes to C64 screen codes. + + This intentionally rejects control codes. Reverse-video state belongs to + stream interpretation, not to this byte-level mapping helper. + """ + if not 0 <= value <= 255: + raise ValueError("PETSCII value must be a byte") + if 0x20 <= value <= 0x3f: + return value + if 0x40 <= value <= 0x5f: + return value - 0x40 + if 0x60 <= value <= 0x7f: + return value - 0x20 + if 0xa0 <= value <= 0xbf: + return value - 0x40 + if 0xc0 <= value <= 0xdf: + return value - 0x80 + raise ValueError("PETSCII control/unsupported byte has no direct screen-code mapping") + + +def screen_code_to_petscii(value: int) -> int: + """Canonical printable PETSCII representation for a C64 screen code.""" + if not 0 <= value <= 255: + raise ValueError("screen code must be a byte") + base = value & 0x7f + if 0x00 <= base <= 0x1f: + return base + 0x40 + if 0x20 <= base <= 0x3f: + return base + if 0x40 <= base <= 0x5f: + return base + 0x20 + if 0x60 <= base <= 0x7f: + return base + 0x40 + raise ValueError("unsupported screen code") + + +# Common C64 PETSCII control bytes used by the M0.7 stream interpreter. +_COLOR_CODES = { + 0x90: 0, # black + 0x05: 1, # white + 0x1c: 2, # red + 0x9f: 3, # cyan + 0x9c: 4, # purple + 0x1e: 5, # green + 0x1f: 6, # blue + 0x9e: 7, # yellow + 0x81: 8, # orange + 0x95: 9, # brown + 0x96: 10, # light red + 0x97: 11, # dark gray + 0x98: 12, # gray + 0x99: 13, # light green + 0x9a: 14, # light blue + 0x9b: 15, # light gray +} + + +def interpret_petscii(data: bytes, columns: int = 40, rows: int = 25) -> C64Screen: + cells = [PetsciiCell(0x20, 14) for _ in range(columns * rows)] + x = y = 0 + color = 14 + reverse = False + + def put(screen_code: int): + nonlocal x, y + if 0 <= x < columns and 0 <= y < rows: + if reverse: + screen_code |= 0x80 + cells[y * columns + x] = PetsciiCell(screen_code, color) + x += 1 + if x >= columns: + x = 0 + y = min(rows - 1, y + 1) + + for value in data: + if value in _COLOR_CODES: + color = _COLOR_CODES[value] + elif value == 0x12: # reverse on + reverse = True + elif value == 0x92: # reverse off + reverse = False + elif value == 0x0d: # carriage return / next line + x = 0 + y = min(rows - 1, y + 1) + elif value == 0x93: # clear/home + cells = [PetsciiCell(0x20, color) for _ in range(columns * rows)] + x = y = 0 + elif value == 0x13: # home + x = y = 0 + else: + try: + put(petscii_to_screen_code(value)) + except ValueError as exc: + raise ValueError(f"unsupported PETSCII control byte: 0x{value:02x}") from exc + return C64Screen(columns, rows, tuple(cells)) diff --git a/src/retroasset/profiles.py b/src/retroasset/profiles.py new file mode 100644 index 0000000..7922697 --- /dev/null +++ b/src/retroasset/profiles.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TargetProfile: + id: str + family: str + platform: str + max_colors: int | None = None + default_width: int | None = None + default_height: int | None = None + columns: int | None = None + rows: int | None = None + charset: str | None = None + + +PROFILES = { + "amiga-ocs": TargetProfile("amiga-ocs", "raster", "Amiga OCS", 32, 320, 256), + "amiga-ecs": TargetProfile("amiga-ecs", "raster", "Amiga ECS", 32, 320, 256), + "amiga-aga": TargetProfile("amiga-aga", "raster", "Amiga AGA", 256, 320, 256), + "c64-screen": TargetProfile( + "c64-screen", "character", "Commodore 64 Screen", 16, + columns=40, rows=25, charset="c64-screen-code" + ), + "c64-petscii": TargetProfile( + "c64-petscii", "character", "Commodore 64 PETSCII", 16, + columns=40, rows=25, charset="petscii" + ), + "ansi-cp437": TargetProfile( + "ansi-cp437", "character", "IBM PC ANSI", 16, + columns=80, rows=25, charset="cp437" + ), +} + + +def get_profile(profile_id: str) -> TargetProfile: + try: + return PROFILES[profile_id] + except KeyError as exc: + raise ValueError(f"unknown target: {profile_id}") from exc diff --git a/src/retroasset/sauce.py b/src/retroasset/sauce.py new file mode 100644 index 0000000..d43628c --- /dev/null +++ b/src/retroasset/sauce.py @@ -0,0 +1,58 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Sauce: + title: str = "" + author: str = "" + group: str = "" + date: str = "" + data_type: int = 1 + file_type: int = 1 + tinfo1: int = 0 + tinfo2: int = 0 + + +def _field(text: str, size: int) -> bytes: + raw = text.encode("cp437", errors="replace")[:size] + return raw.ljust(size, b" ") + + +def encode_sauce(meta: Sauce, file_size: int) -> bytes: + if file_size < 0 or file_size > 0xffffffff: + raise ValueError("SAUCE file size out of range") + import struct + return ( + b"SAUCE00" + + _field(meta.title, 35) + + _field(meta.author, 20) + + _field(meta.group, 20) + + _field(meta.date, 8) + + struct.pack(" tuple[bytes, Sauce | None]: + import struct + if len(data) < 128 or data[-128:-121] != b"SAUCE00": + return data, None + rec = data[-128:] + def txt(start, size): + return rec[start:start + size].decode("cp437").rstrip(" \0") + file_size = struct.unpack(" bool: + return not self.errors + + @property + def export_ready(self) -> bool: + return self.ok + + +def _amiga_memory_bytes(asset: AssetManifest) -> int | None: + if asset.width is None or asset.height is None or asset.colors is None or asset.colors <= 0: + return None + planes = max(1, (asset.colors - 1).bit_length()) + row_bytes = ((asset.width + 15) // 16) * 2 + return row_bytes * asset.height * planes + + +def _validate_common(asset: AssetManifest, profile: TargetProfile) -> ValidationResult: + errors: list[str] = [] + warnings: list[str] = [] + + if asset.width is not None and asset.width <= 0: + errors.append("width must be positive") + if asset.height is not None and asset.height <= 0: + errors.append("height must be positive") + if asset.colors is not None: + if asset.colors <= 0: + errors.append("colors must be positive") + elif profile.max_colors is not None and asset.colors > profile.max_colors: + errors.append(f"{profile.id} supports at most {profile.max_colors} colors") + + if profile.family == "raster" and asset.colors is not None: + if profile.id in ("amiga-ocs", "amiga-ecs") and asset.colors not in (2, 4, 8, 16, 32): + warnings.append("color count does not map exactly to a full OCS/ECS bitplane set") + + if profile.family == "character": + if asset.width is not None and profile.columns is not None and asset.width != profile.columns: + warnings.append(f"profile default geometry is {profile.columns} columns") + if asset.height is not None and profile.rows is not None and asset.height != profile.rows: + warnings.append(f"profile default geometry is {profile.rows} rows") + + return ValidationResult(errors, warnings) + + +def _validate_amiga(asset: AssetManifest, profile: TargetProfile) -> ValidationResult: + base = _validate_common(asset, profile) + errors = list(base.errors) + warnings = list(base.warnings) + + if asset.kind not in ("bitmap", "sprite", "bob", "tile", "tileset"): + errors.append("Amiga asset kind must be bitmap, sprite, bob, tile, or tileset") + + if asset.width is None or asset.height is None or asset.colors is None: + errors.append("Amiga export readiness requires width, height, and colors") + + metadata = asset.metadata + if asset.kind in ("sprite", "bob"): + if "hotspot" not in metadata: + warnings.append(f"{asset.kind} metadata has no hotspot") + elif not (isinstance(metadata["hotspot"], (list, tuple)) and len(metadata["hotspot"]) == 2): + errors.append("hotspot must be a two-element [x, y] pair") + + if asset.kind in ("tile", "tileset"): + tile = metadata.get("tile_size") + if tile is None: + errors.append("tile/tileset export readiness requires metadata.tile_size") + elif not (isinstance(tile, (list, tuple)) and len(tile) == 2 and + all(isinstance(v, int) and v > 0 for v in tile)): + errors.append("metadata.tile_size must be a positive [width, height] pair") + elif asset.width and asset.height and (asset.width % tile[0] or asset.height % tile[1]): + errors.append("asset dimensions must be divisible by metadata.tile_size") + + memory_bytes = _amiga_memory_bytes(asset) + return ValidationResult(errors, warnings, memory_bytes) + + +def validate(asset: AssetManifest) -> ValidationResult: + profile = get_profile(asset.target) + if profile.id.startswith("amiga-"): + return _validate_amiga(asset, profile) + return _validate_common(asset, profile) + + +VALIDATORS = {profile_id: validate for profile_id in PROFILES} diff --git a/tests/test_amiga.py b/tests/test_amiga.py new file mode 100644 index 0000000..db863f9 --- /dev/null +++ b/tests/test_amiga.py @@ -0,0 +1,61 @@ +import struct +import unittest + +from retroasset.amiga import byterun1_decode, byterun1_encode, decode_ilbm, encode_ilbm, encode_ilbm_compressed, pack_planar, row_bytes +from retroasset.indexed import IndexedBitmap + + +class AmigaExportTests(unittest.TestCase): + def setUp(self): + self.bitmap = IndexedBitmap( + 16, 2, + tuple([0, 1] * 8 + [1, 0] * 8), + ((0, 0, 0), (255, 255, 255)), + ) + + def test_word_aligned_rows(self): + self.assertEqual(row_bytes(1), 2) + self.assertEqual(row_bytes(16), 2) + self.assertEqual(row_bytes(17), 4) + + def test_planar_fixture(self): + self.assertEqual(pack_planar(self.bitmap), bytes.fromhex("5555aaaa")) + + def test_ilbm_form(self): + data = encode_ilbm(self.bitmap) + self.assertEqual(data[:4], b"FORM") + self.assertEqual(data[8:12], b"ILBM") + self.assertEqual(struct.unpack(">I", data[4:8])[0], len(data) - 8) + self.assertIn(b"BMHD", data) + self.assertIn(b"CMAP", data) + self.assertIn(b"BODY", data) + + def test_ilbm_round_trip(self): + decoded = decode_ilbm(encode_ilbm(self.bitmap)) + self.assertEqual(decoded, self.bitmap) + + def test_byterun1(self): + source = b"AAAABCDDDDDDDXYZ" + encoded = byterun1_encode(source) + self.assertEqual(byterun1_decode(encoded, len(source)), source) + + def test_compressed_ilbm_header(self): + data = encode_ilbm_compressed(self.bitmap) + self.assertEqual(data[:4], b"FORM") + self.assertIn(b"BODY", data) + + def test_compressed_ilbm_round_trip(self): + decoded = decode_ilbm(encode_ilbm_compressed(self.bitmap)) + self.assertEqual(decoded, self.bitmap) + + def test_byterun1_rejects_truncated_literal(self): + with self.assertRaisesRegex(ValueError, "truncated ByteRun1 literal"): + byterun1_decode(bytes([2, 0xaa]), 3) + + def test_byterun1_rejects_row_overflow(self): + with self.assertRaisesRegex(ValueError, "exceeds expected size"): + byterun1_decode(bytes([0xfd, 0xaa]), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ansi.py b/tests/test_ansi.py new file mode 100644 index 0000000..87197c2 --- /dev/null +++ b/tests/test_ansi.py @@ -0,0 +1,39 @@ +import unittest + +from retroasset.ansi import decode_ans, encode_ans +from retroasset.character import CharacterAsset, CharacterCell + + +class AnsiTests(unittest.TestCase): + def test_native_cp437_output(self): + asset = CharacterAsset( + 3, 1, + ( + CharacterCell(ord("A"), 7, 0), + CharacterCell(0xDB, 15, 1), + CharacterCell(ord("B"), 7, 0), + ), + ) + data = encode_ans(asset) + self.assertIn(b"A", data) + self.assertIn(bytes([0xDB]), data) + self.assertTrue(data.endswith(b"\x1b[0m")) + + def test_geometry_is_enforced(self): + with self.assertRaises(ValueError): + encode_ans(CharacterAsset(2, 1, (CharacterCell(65),))) + + def test_round_trip(self): + asset = CharacterAsset( + 3, 1, + ( + CharacterCell(ord("A"), 7, 0), + CharacterCell(0xDB, 15, 1), + CharacterCell(ord("B"), 7, 0), + ), + ) + self.assertEqual(decode_ans(encode_ans(asset), 3, 1), asset) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_c64prg.py b/tests/test_c64prg.py new file mode 100644 index 0000000..7251919 --- /dev/null +++ b/tests/test_c64prg.py @@ -0,0 +1,22 @@ +import unittest + +from retroasset.c64prg import export_viewer_prg +from retroasset.petscii import C64Screen, PetsciiCell, interpret_petscii + + +class C64PrgTests(unittest.TestCase): + def test_stream_state(self): + screen = interpret_petscii(bytes([0x05, 0x41, 0x12, 0x42, 0x92]), 2, 1) + self.assertEqual(screen.cells[0], PetsciiCell(0x01, 1)) + self.assertEqual(screen.cells[1], PetsciiCell(0x82, 1)) + + def test_prg_load_address_and_basic_stub(self): + screen = C64Screen(40, 25, tuple(PetsciiCell(0x20, 1) for _ in range(1000))) + data = export_viewer_prg(screen) + self.assertEqual(data[:2], bytes([0x01, 0x08])) + self.assertIn(bytes([0x9e]) + b"2061", data[:16]) + self.assertGreater(len(data), 2000) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_c64prg_layout.py b/tests/test_c64prg_layout.py new file mode 100644 index 0000000..ab3c939 --- /dev/null +++ b/tests/test_c64prg_layout.py @@ -0,0 +1,48 @@ +import unittest + +from retroasset.c64prg import ( + CODE_ADDRESS, COLOR_SOURCE, SCREEN_SOURCE, TAIL_BYTES, export_viewer_prg, +) +from retroasset.petscii import C64Screen, PetsciiCell + + +class C64PrgLayoutTests(unittest.TestCase): + def setUp(self): + self.screen = C64Screen( + 40, 25, + tuple(PetsciiCell(i & 0x7f, i & 0x0f) for i in range(1000)), + ) + self.prg = export_viewer_prg(self.screen) + + def offset(self, address: int) -> int: + load = int.from_bytes(self.prg[:2], "little") + return 2 + address - load + + def test_basic_next_line_pointer_targets_end_marker(self): + self.assertEqual(self.prg[2:4], bytes([0x0b, 0x08])) + + def test_machine_code_starts_at_sys_address(self): + self.assertEqual(CODE_ADDRESS, 2061) + self.assertEqual(self.prg[self.offset(CODE_ADDRESS):self.offset(CODE_ADDRESS)+2], bytes([0xa2, 0x00])) + + def test_payload_is_page_aligned(self): + self.assertEqual(self.prg[self.offset(SCREEN_SOURCE)], 0x00) + self.assertEqual(self.prg[self.offset(COLOR_SOURCE)], 0x00) + self.assertEqual(COLOR_SOURCE - SCREEN_SOURCE, 1024) + + def test_first_copy_source_is_screen_payload(self): + off = self.offset(CODE_ADDRESS) + self.assertEqual(self.prg[off+3:off+5], SCREEN_SOURCE.to_bytes(2, "little")) + + def test_full_page_branch_returns_to_lda(self): + off = self.offset(CODE_ADDRESS) + self.assertEqual(self.prg[off+9:off+11], bytes([0xd0, 0xf7])) + + def test_tail_is_bounded_to_232_bytes(self): + tail = self.offset(CODE_ADDRESS) + 3 * 11 + self.assertEqual(TAIL_BYTES, 232) + self.assertEqual(self.prg[tail+9:tail+13], bytes([0xe0, 0xe8, 0xd0, 0xf5])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_character.py b/tests/test_character.py new file mode 100644 index 0000000..6400954 --- /dev/null +++ b/tests/test_character.py @@ -0,0 +1,13 @@ +import unittest + +from retroasset.character import CharacterAsset, CharacterCell + + +class CharacterAssetTests(unittest.TestCase): + def test_geometry(self): + asset = CharacterAsset(2, 1, (CharacterCell(65), CharacterCell(66))) + self.assertEqual(asset.expected_cells(), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_character_cli.py b/tests/test_character_cli.py new file mode 100644 index 0000000..830a411 --- /dev/null +++ b/tests/test_character_cli.py @@ -0,0 +1,125 @@ +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +class CharacterCliTests(unittest.TestCase): + def test_ansi_json_round_trip(self): + source = { + "columns": 2, + "rows": 1, + "cells": [ + {"codepoint": 65, "foreground": 7, "background": 0, "blink": False}, + {"codepoint": 219, "foreground": 15, "background": 1, "blink": False}, + ], + } + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "asset.json" + ans = root / "asset.ans" + restored = root / "restored.json" + src.write_text(json.dumps(source), encoding="utf-8") + subprocess.run([sys.executable, "-m", "retroasset.cli", "char-export", str(src), + "--target", "ansi-cp437", "-o", str(ans)], check=True) + subprocess.run([sys.executable, "-m", "retroasset.cli", "char-import", str(ans), + "--target", "ansi-cp437", "--columns", "2", "--rows", "1", + "-o", str(restored)], check=True) + self.assertEqual(json.loads(restored.read_text(encoding="utf-8")), source) + + + def test_ansi_sauce_round_trip_and_geometry(self): + source = { + "columns": 2, + "rows": 1, + "cells": [ + {"codepoint": 65, "foreground": 7, "background": 0, "blink": False}, + {"codepoint": 66, "foreground": 7, "background": 0, "blink": False}, + ], + "sauce": { + "title": "RetroAsset", + "author": "Ploos AS", + "group": "Test", + "date": "20261006", + "data_type": 1, + "file_type": 1, + "tinfo1": 2, + "tinfo2": 1, + }, + } + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "asset.json" + ans = root / "asset.ans" + restored = root / "restored.json" + src.write_text(json.dumps(source), encoding="utf-8") + subprocess.run([sys.executable, "-m", "retroasset.cli", "char-export", str(src), + "--target", "ansi-cp437", "-o", str(ans)], check=True) + subprocess.run([sys.executable, "-m", "retroasset.cli", "char-import", str(ans), + "--target", "ansi-cp437", "-o", str(restored)], check=True) + self.assertEqual(json.loads(restored.read_text(encoding="utf-8")), source) + + + def test_c64_native_exports(self): + cells = [ + {"screen_code": (i % 64), "color": (i % 16)} + for i in range(1000) + ] + source = {"columns": 40, "rows": 25, "cells": cells} + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "c64.json" + src.write_text(json.dumps(source), encoding="utf-8") + expected_sizes = {"screen": 1000, "color": 1000} + for fmt, size in expected_sizes.items(): + out = root / fmt + subprocess.run([sys.executable, "-m", "retroasset.cli", "char-export", str(src), + "--target", "c64-screen", "--format", fmt, "-o", str(out)], check=True) + self.assertEqual(out.stat().st_size, size) + prg = root / "viewer.prg" + subprocess.run([sys.executable, "-m", "retroasset.cli", "char-export", str(src), + "--target", "c64-screen", "--format", "prg", "-o", str(prg)], check=True) + self.assertGreater(prg.stat().st_size, 2000) + + + def test_petscii_stream_import_preserves_color_and_reverse(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "stream.pet" + restored = root / "screen.json" + # red, reverse on, 'A', reverse off, white, 'B' + src.write_bytes(bytes([0x1c, 0x12, 0x41, 0x92, 0x05, 0x42])) + subprocess.run([sys.executable, "-m", "retroasset.cli", "char-import", str(src), + "--target", "c64-petscii", "--columns", "2", "--rows", "1", + "-o", str(restored)], check=True) + data = json.loads(restored.read_text(encoding="utf-8")) + self.assertEqual(data["cells"][0], {"screen_code": 0x81, "color": 2}) + self.assertEqual(data["cells"][1], {"screen_code": 0x02, "color": 1}) + + + def test_machine_readable_conversion_report(self): + source = { + "columns": 1, + "rows": 1, + "cells": [{"codepoint": 65, "foreground": 7, "background": 0, "blink": False}], + } + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "asset.json" + ans = root / "asset.ans" + report = root / "report.json" + src.write_text(json.dumps(source), encoding="utf-8") + subprocess.run([sys.executable, "-m", "retroasset.cli", "char-export", str(src), + "--target", "ansi-cp437", "--report", str(report), "-o", str(ans)], + check=True) + found = json.loads(report.read_text(encoding="utf-8")) + self.assertEqual(found["source"], "character-ir") + self.assertEqual(found["target"], "ansi-cp437") + self.assertFalse(found["lossy"]) + self.assertEqual(found["losses"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_exporters.py b/tests/test_exporters.py new file mode 100644 index 0000000..80a1c1a --- /dev/null +++ b/tests/test_exporters.py @@ -0,0 +1,31 @@ +import unittest + +from retroasset.exporters import export_asm, export_c, export_raw_planar +from retroasset.indexed import IndexedBitmap + + +class ExporterTests(unittest.TestCase): + def setUp(self): + self.bitmap = IndexedBitmap( + 16, 2, + tuple([0, 1] * 8 + [1, 0] * 8), + ((0, 0, 0), (255, 255, 255)), + ) + + def test_raw(self): + self.assertEqual(export_raw_planar(self.bitmap), bytes.fromhex("5555aaaa")) + + def test_c(self): + text = export_c(self.bitmap, "checker") + self.assertIn("#define CHECKER_WIDTH 16", text) + self.assertIn("0x55, 0x55, 0xaa, 0xaa", text) + + def test_asm(self): + text = export_asm(self.bitmap, "checker") + self.assertIn("checker_width equ 16", text) + self.assertIn("dc.b $55,$55,$aa,$aa", text) + self.assertIn("dc.w $000,$fff", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_petscii.py b/tests/test_petscii.py new file mode 100644 index 0000000..3ec6ae3 --- /dev/null +++ b/tests/test_petscii.py @@ -0,0 +1,30 @@ +import unittest + +from retroasset.petscii import C64Screen, PetsciiCell, export_color_ram, export_screen_ram, petscii_to_screen_code, screen_code_to_petscii + + +class C64ScreenTests(unittest.TestCase): + def test_native_planes(self): + screen = C64Screen( + 2, 1, + (PetsciiCell(1, 2), PetsciiCell(65, 14)), + ) + self.assertEqual(export_screen_ram(screen), bytes([1, 65])) + self.assertEqual(export_color_ram(screen), bytes([2, 14])) + + def test_color_range(self): + with self.assertRaises(ValueError): + PetsciiCell(65, 16) + + def test_common_petscii_mapping(self): + self.assertEqual(petscii_to_screen_code(0x41), 0x01) + self.assertEqual(screen_code_to_petscii(0x01), 0x41) + self.assertEqual(petscii_to_screen_code(0x20), 0x20) + + def test_control_code_is_not_silently_mapped(self): + with self.assertRaises(ValueError): + petscii_to_screen_code(0x0d) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_profiles.py b/tests/test_profiles.py new file mode 100644 index 0000000..d541a41 --- /dev/null +++ b/tests/test_profiles.py @@ -0,0 +1,19 @@ +import unittest + +from retroasset.profiles import get_profile + + +class ProfileTests(unittest.TestCase): + def test_amiga_profiles(self): + self.assertEqual(get_profile("amiga-ocs").max_colors, 32) + self.assertEqual(get_profile("amiga-aga").max_colors, 256) + + def test_ansi_profile(self): + profile = get_profile("ansi-cp437") + self.assertEqual(profile.family, "character") + self.assertEqual(profile.columns, 80) + self.assertEqual(profile.charset, "cp437") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sauce.py b/tests/test_sauce.py new file mode 100644 index 0000000..0329d25 --- /dev/null +++ b/tests/test_sauce.py @@ -0,0 +1,30 @@ +import unittest + +from retroasset.sauce import Sauce, decode_sauce, encode_sauce + + +class SauceTests(unittest.TestCase): + def test_round_trip(self): + payload = b"HELLO" + meta = Sauce( + title="RetroAsset", + author="Ploos AS", + group="Test", + date="20261006", + tinfo1=80, + tinfo2=25, + ) + blob = payload + encode_sauce(meta, len(payload)) + decoded, found = decode_sauce(blob) + self.assertEqual(decoded, payload) + self.assertEqual(found, meta) + self.assertEqual(len(blob) - len(payload), 128) + + def test_missing_sauce(self): + payload, meta = decode_sauce(b"ANSI") + self.assertEqual(payload, b"ANSI") + self.assertIsNone(meta) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_targets.py b/tests/test_targets.py new file mode 100644 index 0000000..33200c1 --- /dev/null +++ b/tests/test_targets.py @@ -0,0 +1,41 @@ +import unittest + +from retroasset.model import AssetManifest +from retroasset.targets import validate + + +class AmigaTargetValidationTests(unittest.TestCase): + def test_bitmap_is_export_ready_and_reports_planar_memory(self): + result = validate(AssetManifest("checker", "amiga-ocs", "bitmap", 16, 2, 2)) + self.assertTrue(result.export_ready) + self.assertEqual(result.memory_bytes, 4) + + def test_ocs_rejects_too_many_colors(self): + result = validate(AssetManifest("too-many", "amiga-ocs", "bitmap", 320, 256, 64)) + self.assertFalse(result.export_ready) + + def test_sprite_and_bob_accept_hotspot_hook(self): + for kind in ("sprite", "bob"): + result = validate(AssetManifest(kind, "amiga-ocs", kind, 16, 16, 4, {"hotspot": [8, 8]})) + self.assertTrue(result.export_ready) + + def test_bad_hotspot_is_rejected(self): + result = validate(AssetManifest("sprite", "amiga-ocs", "sprite", 16, 16, 4, {"hotspot": [8]})) + self.assertFalse(result.export_ready) + + def test_tiles_require_valid_divisible_tile_size(self): + missing = validate(AssetManifest("tiles", "amiga-ocs", "tileset", 32, 32, 4)) + self.assertFalse(missing.export_ready) + good = validate(AssetManifest("tiles", "amiga-ocs", "tileset", 32, 32, 4, {"tile_size": [16, 16]})) + self.assertTrue(good.export_ready) + bad = validate(AssetManifest("tiles", "amiga-ocs", "tileset", 30, 32, 4, {"tile_size": [16, 16]})) + self.assertFalse(bad.export_ready) + + def test_incomplete_amiga_manifest_is_not_export_ready(self): + result = validate(AssetManifest("draft", "amiga-ocs", "bitmap")) + self.assertFalse(result.export_ready) + self.assertIsNone(result.memory_bytes) + + +if __name__ == "__main__": + unittest.main()