From 7bae65da59c2b09af6b33fbf2f823e0e3bc29aa3 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:57:43 +0200 Subject: [PATCH 001/116] M0: add ARCHITECTURE.md --- ARCHITECTURE.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 ARCHITECTURE.md 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. From ef6ef6ce02065cdbeeb50cf197e7075abef869c9 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:57:46 +0200 Subject: [PATCH 002/116] M0: add pyproject.toml --- pyproject.toml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..421dc67 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "retroasset" +version = "0.0.1" +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"] From bec542269e39988db99c8654e46f231fa38ea755 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:57:48 +0200 Subject: [PATCH 003/116] M0: add src/retroasset/__init__.py --- src/retroasset/__init__.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/retroasset/__init__.py 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" From a947e13b8e4eb977dad79a76dfa1d231b28b3b23 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:57:50 +0200 Subject: [PATCH 004/116] M0: add src/retroasset/model.py --- src/retroasset/model.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/retroasset/model.py 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) From 0a35dcc6c9f0c3e4ed2bc433eb3cb0528d372dfc Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:57:52 +0200 Subject: [PATCH 005/116] M0: add src/retroasset/targets.py --- src/retroasset/targets.py | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/retroasset/targets.py diff --git a/src/retroasset/targets.py b/src/retroasset/targets.py new file mode 100644 index 0000000..71bc462 --- /dev/null +++ b/src/retroasset/targets.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from .model import AssetManifest + + +@dataclass(frozen=True) +class ValidationResult: + errors: tuple[str, ...] = () + warnings: tuple[str, ...] = () + + @property + def ok(self) -> bool: + return not self.errors + + +def validate_amiga_ocs(asset: AssetManifest) -> 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 asset.colors > 32: + errors.append("amiga-ocs baseline supports at most 32 indexed colors") + elif asset.colors not in (2, 4, 8, 16, 32): + warnings.append("color count does not map exactly to a full OCS bitplane set") + + return ValidationResult(tuple(errors), tuple(warnings)) + + +VALIDATORS = { + "amiga-ocs": validate_amiga_ocs, +} + + +def validate(asset: AssetManifest) -> ValidationResult: + try: + validator = VALIDATORS[asset.target] + except KeyError as exc: + raise ValueError(f"unknown target: {asset.target}") from exc + return validator(asset) From 2ea2ae96d72d37fd0e2d947308c8098837288cd6 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:57:54 +0200 Subject: [PATCH 006/116] M0: add src/retroasset/cli.py --- src/retroasset/cli.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/retroasset/cli.py diff --git a/src/retroasset/cli.py b/src/retroasset/cli.py new file mode 100644 index 0000000..4f33a6e --- /dev/null +++ b/src/retroasset/cli.py @@ -0,0 +1,40 @@ +import argparse +import json +from pathlib import Path + +from .model import AssetManifest +from .targets import VALIDATORS, validate + + +def _load_manifest(path: Path) -> AssetManifest: + data = json.loads(path.read_text(encoding="utf-8")) + return AssetManifest(**data) + + +def main() -> int: + parser = argparse.ArgumentParser(prog="retroasset") + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("targets", help="list available targets") + check = sub.add_parser("validate", help="validate an asset manifest") + check.add_argument("manifest", type=Path) + + args = parser.parse_args() + + if args.command == "targets": + for target in sorted(VALIDATORS): + print(target) + 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()) From 7c3380c18706f578f033e5cb1cb7dc9349d20ef9 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:57:56 +0200 Subject: [PATCH 007/116] M0: add examples/amiga-ocs-sprite.json --- examples/amiga-ocs-sprite.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 examples/amiga-ocs-sprite.json 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" + } +} From ab0224c7ecc3acf51e07a1f3af40a122cc86b10d Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:57:58 +0200 Subject: [PATCH 008/116] M0: add tests/test_targets.py --- tests/test_targets.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 tests/test_targets.py diff --git a/tests/test_targets.py b/tests/test_targets.py new file mode 100644 index 0000000..499ada1 --- /dev/null +++ b/tests/test_targets.py @@ -0,0 +1,18 @@ +import unittest + +from retroasset.model import AssetManifest +from retroasset.targets import validate + + +class AmigaOCSTests(unittest.TestCase): + def test_valid_manifest(self): + result = validate(AssetManifest("hero", "amiga-ocs", "sprite", 32, 48, 16)) + self.assertTrue(result.ok) + + def test_too_many_colors(self): + result = validate(AssetManifest("hero", "amiga-ocs", "bitmap", 320, 256, 64)) + self.assertFalse(result.ok) + + +if __name__ == "__main__": + unittest.main() From 84b6cd1aab5ff991b3b36ccfe099e3a171720264 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:58:00 +0200 Subject: [PATCH 009/116] M0: add .github/workflows/ci.yml --- .github/workflows/ci.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..501d3c1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +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 validate examples/amiga-ocs-sprite.json From 0e372c57e79dc486e447064a229525954091af59 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:58:02 +0200 Subject: [PATCH 010/116] M0: add LICENSE --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE 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. From 3a09351183827966fc926220cc9fabcdd9d42518 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:58:13 +0200 Subject: [PATCH 011/116] M0: document RetroAsset foundation --- README.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f19a283..044ca14 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,63 @@ # 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 foundation is under development. The first executable target is **Amiga OCS**. + +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. + +## License + +Software is licensed under the MIT License. From 1edf37b282eabd6a85e193f9637596eeb6b28479 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:59:07 +0200 Subject: [PATCH 012/116] M0.1: add src/retroasset/profiles.py --- src/retroasset/profiles.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/retroasset/profiles.py diff --git a/src/retroasset/profiles.py b/src/retroasset/profiles.py new file mode 100644 index 0000000..519ae88 --- /dev/null +++ b/src/retroasset/profiles.py @@ -0,0 +1,32 @@ +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), + "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 From c5954ef6aa9b528bf83ac8d9c22a8e5d97869039 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:59:09 +0200 Subject: [PATCH 013/116] M0.1: add src/retroasset/character.py --- src/retroasset/character.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/retroasset/character.py diff --git a/src/retroasset/character.py b/src/retroasset/character.py new file mode 100644 index 0000000..c4511f1 --- /dev/null +++ b/src/retroasset/character.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CharacterCell: + codepoint: int + foreground: int = 7 + background: int = 0 + blink: bool = False + + +@dataclass(frozen=True) +class CharacterAsset: + columns: int + rows: int + cells: tuple[CharacterCell, ...] + + def expected_cells(self) -> int: + return self.columns * self.rows From 3aa4fc85ef94ed77b9d747da21c0901c1babdd50 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:59:11 +0200 Subject: [PATCH 014/116] M0.1: add examples/ansi-cp437-screen.json --- examples/ansi-cp437-screen.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 examples/ansi-cp437-screen.json 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" + } +} From 723981f1c966a246241e6b9c804d2c3a12779456 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:59:14 +0200 Subject: [PATCH 015/116] M0.1: add tests/test_profiles.py --- tests/test_profiles.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/test_profiles.py 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() From 99893bb8b0fbf1e6cad2378fdabeea938519e3ee Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:59:17 +0200 Subject: [PATCH 016/116] M0.1: add tests/test_character.py --- tests/test_character.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 tests/test_character.py 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() From 55daa409602c554368abe87d5054b847176bfce9 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:59:19 +0200 Subject: [PATCH 017/116] M0.1: make validation profile-driven --- src/retroasset/targets.py | 40 +++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/src/retroasset/targets.py b/src/retroasset/targets.py index 71bc462..9f46cd8 100644 --- a/src/retroasset/targets.py +++ b/src/retroasset/targets.py @@ -1,18 +1,18 @@ -from dataclasses import dataclass from .model import AssetManifest +from .profiles import PROFILES, TargetProfile, get_profile -@dataclass(frozen=True) class ValidationResult: - errors: tuple[str, ...] = () - warnings: tuple[str, ...] = () + def __init__(self, errors=(), warnings=()): + self.errors = tuple(errors) + self.warnings = tuple(warnings) @property def ok(self) -> bool: return not self.errors -def validate_amiga_ocs(asset: AssetManifest) -> ValidationResult: +def _validate_common(asset: AssetManifest, profile: TargetProfile) -> ValidationResult: errors: list[str] = [] warnings: list[str] = [] @@ -23,22 +23,26 @@ def validate_amiga_ocs(asset: AssetManifest) -> ValidationResult: if asset.colors is not None: if asset.colors <= 0: errors.append("colors must be positive") - elif asset.colors > 32: - errors.append("amiga-ocs baseline supports at most 32 indexed colors") - elif asset.colors not in (2, 4, 8, 16, 32): - warnings.append("color count does not map exactly to a full OCS bitplane set") + 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" + ) - return ValidationResult(tuple(errors), tuple(warnings)) + 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") -VALIDATORS = { - "amiga-ocs": validate_amiga_ocs, -} + return ValidationResult(errors, warnings) def validate(asset: AssetManifest) -> ValidationResult: - try: - validator = VALIDATORS[asset.target] - except KeyError as exc: - raise ValueError(f"unknown target: {asset.target}") from exc - return validator(asset) + return _validate_common(asset, get_profile(asset.target)) + + +VALIDATORS = {profile_id: validate for profile_id in PROFILES} From d1997704003a9276322a3653158a512cb23f122c Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:59:30 +0200 Subject: [PATCH 018/116] M0.1: expose target profiles in CLI --- src/retroasset/cli.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/retroasset/cli.py b/src/retroasset/cli.py index 4f33a6e..2cd6e64 100644 --- a/src/retroasset/cli.py +++ b/src/retroasset/cli.py @@ -3,7 +3,8 @@ from pathlib import Path from .model import AssetManifest -from .targets import VALIDATORS, validate +from .profiles import PROFILES +from .targets import validate def _load_manifest(path: Path) -> AssetManifest: @@ -16,14 +17,25 @@ def main() -> int: 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) args = parser.parse_args() if args.command == "targets": - for target in sorted(VALIDATORS): - print(target) + 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 asset = _load_manifest(args.manifest) From a85b1726de7624316a1f599546710302a10b1e3b Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 01:59:33 +0200 Subject: [PATCH 019/116] M0.1: exercise raster and character profiles in CI --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 501d3c1..d45b127 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,4 +15,7 @@ jobs: - 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 From 64478883106063e77ff8bfb692c3c25c92c39b31 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:00:21 +0200 Subject: [PATCH 020/116] M0.2: add src/retroasset/indexed.py --- src/retroasset/indexed.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/retroasset/indexed.py 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()) From 634287ff6aa29ae27e423cb7f2c4d6de234be8da Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:00:23 +0200 Subject: [PATCH 021/116] M0.2: add src/retroasset/amiga.py --- src/retroasset/amiga.py | 49 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/retroasset/amiga.py diff --git a/src/retroasset/amiga.py b/src/retroasset/amiga.py new file mode 100644 index 0000000..c125c30 --- /dev/null +++ b/src/retroasset/amiga.py @@ -0,0 +1,49 @@ +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 From ad8bcfa52465a434b46ee8947ee815787b209dbb Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:00:26 +0200 Subject: [PATCH 022/116] M0.2: add examples/amiga-ocs-checker.json --- examples/amiga-ocs-checker.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 examples/amiga-ocs-checker.json 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 + ] +} From 8e12576b33349216559d6d1c2041775ab1496c78 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:00:29 +0200 Subject: [PATCH 023/116] M0.2: add tests/test_amiga.py --- tests/test_amiga.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/test_amiga.py diff --git a/tests/test_amiga.py b/tests/test_amiga.py new file mode 100644 index 0000000..e19b5ee --- /dev/null +++ b/tests/test_amiga.py @@ -0,0 +1,35 @@ +import struct +import unittest + +from retroasset.amiga import encode_ilbm, 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) + + +if __name__ == "__main__": + unittest.main() From 0b5452f71d3a873ae74e8839d67e38a0cba10817 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:00:38 +0200 Subject: [PATCH 024/116] M0.2: add native ILBM export command --- src/retroasset/cli.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/retroasset/cli.py b/src/retroasset/cli.py index 2cd6e64..378baa9 100644 --- a/src/retroasset/cli.py +++ b/src/retroasset/cli.py @@ -2,6 +2,8 @@ import json from pathlib import Path +from .amiga import encode_ilbm +from .indexed import IndexedBitmap from .model import AssetManifest from .profiles import PROFILES from .targets import validate @@ -12,6 +14,16 @@ def _load_manifest(path: Path) -> AssetManifest: 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) @@ -22,6 +34,12 @@ def main() -> int: 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",)) + export.add_argument("-o", "--output", type=Path, required=True) + args = parser.parse_args() if args.command == "targets": @@ -38,6 +56,19 @@ def main() -> int: print(json.dumps(profile.__dict__, indent=2)) return 0 + if args.command == "export": + if not args.target.startswith("amiga-") or args.format != "ilbm": + print("error: ILBM export requires 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 + args.output.write_bytes(encode_ilbm(bitmap)) + print(f"WROTE {args.output}") + return 0 + asset = _load_manifest(args.manifest) result = validate(asset) for warning in result.warnings: From be03690745a7d11cbf26d930a1541c6bdded26bc Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:00:40 +0200 Subject: [PATCH 025/116] M0.2: smoke-test ILBM export --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d45b127..3f3a44a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,3 +19,5 @@ jobs: - 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 From 91483ba53c93d51668a35c530a6af75de97b2589 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:01:42 +0200 Subject: [PATCH 026/116] M0.3: add src/retroasset/exporters.py --- src/retroasset/exporters.py | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/retroasset/exporters.py 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" + ) From 3cb644bd6818429f3f63c6284d04f8cbc85fd18c Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:01:44 +0200 Subject: [PATCH 027/116] M0.3: add tests/test_exporters.py --- tests/test_exporters.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/test_exporters.py 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() From dca9044c1186f1c0c924d004ab23092188f9cdec Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:01:55 +0200 Subject: [PATCH 028/116] M0.3: add planar and ILBM round-trip decoding --- src/retroasset/amiga.py | 45 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/retroasset/amiga.py b/src/retroasset/amiga.py index c125c30..2100753 100644 --- a/src/retroasset/amiga.py +++ b/src/retroasset/amiga.py @@ -47,3 +47,48 @@ def encode_ilbm(bitmap: IndexedBitmap) -> bytes: 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: + raise ValueError("compressed ILBM decoding not implemented yet") + palette = tuple(tuple(cmap[i:i + 3]) for i in range(0, len(cmap), 3)) + pixels = unpack_planar(body, width, height, planes) + return IndexedBitmap(width, height, pixels, palette) From 4b703fa22c69c457a0e33c7e33b6098b103cbee6 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:01:57 +0200 Subject: [PATCH 029/116] M0.3: test ILBM round trip --- tests/test_amiga.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_amiga.py b/tests/test_amiga.py index e19b5ee..77732ce 100644 --- a/tests/test_amiga.py +++ b/tests/test_amiga.py @@ -1,7 +1,7 @@ import struct import unittest -from retroasset.amiga import encode_ilbm, pack_planar, row_bytes +from retroasset.amiga import decode_ilbm, encode_ilbm, pack_planar, row_bytes from retroasset.indexed import IndexedBitmap @@ -30,6 +30,10 @@ def test_ilbm_form(self): 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) + if __name__ == "__main__": unittest.main() From 15ac993af871d3d9f6c0e2f48a5fef6b110bada8 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:02:08 +0200 Subject: [PATCH 030/116] M0.3: expose raw C and ASM exporters --- src/retroasset/cli.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/retroasset/cli.py b/src/retroasset/cli.py index 378baa9..1cdd714 100644 --- a/src/retroasset/cli.py +++ b/src/retroasset/cli.py @@ -3,6 +3,7 @@ from pathlib import Path from .amiga import encode_ilbm +from .exporters import export_asm, export_c, export_raw_planar from .indexed import IndexedBitmap from .model import AssetManifest from .profiles import PROFILES @@ -37,7 +38,8 @@ def main() -> int: 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",)) + export.add_argument("--format", required=True, choices=("ilbm", "raw", "c", "asm")) + export.add_argument("--symbol", default="asset") export.add_argument("-o", "--output", type=Path, required=True) args = parser.parse_args() @@ -57,15 +59,22 @@ def main() -> int: return 0 if args.command == "export": - if not args.target.startswith("amiga-") or args.format != "ilbm": - print("error: ILBM export requires an Amiga target") + 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 - args.output.write_bytes(encode_ilbm(bitmap)) + if args.format == "ilbm": + args.output.write_bytes(encode_ilbm(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 From 271de87828a2ac20b1aefb33f0947f29d8b05b67 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:03:27 +0200 Subject: [PATCH 031/116] M0.4: add ByteRun1 encoding and compressed ILBM --- src/retroasset/amiga.py | 77 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/retroasset/amiga.py b/src/retroasset/amiga.py index 2100753..51e5a51 100644 --- a/src/retroasset/amiga.py +++ b/src/retroasset/amiga.py @@ -92,3 +92,80 @@ def decode_ilbm(data: bytes) -> IndexedBitmap: palette = tuple(tuple(cmap[i:i + 3]) for i in range(0, len(cmap), 3)) pixels = unpack_planar(body, 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(data: bytes, expected_size: int) -> bytes: + out = bytearray() + i = 0 + while i < len(data) and len(out) < expected_size: + control = data[i] + i += 1 + signed = control if control < 128 else control - 256 + if 0 <= signed <= 127: + count = signed + 1 + out.extend(data[i:i + count]) + i += count + elif -127 <= signed <= -1: + if i >= len(data): + raise ValueError("truncated ByteRun1 repeat") + out.extend(data[i:i + 1] * (1 - signed)) + i += 1 + # -128 is a no-op. + if len(out) != expected_size: + raise ValueError("ByteRun1 output size mismatch") + return bytes(out) + + +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 From 30aad23181c5562e043e7aa30dfc0d364e1d9607 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:03:29 +0200 Subject: [PATCH 032/116] M0.4: add native CP437 ANSI writer --- src/retroasset/ansi.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/retroasset/ansi.py diff --git a/src/retroasset/ansi.py b/src/retroasset/ansi.py new file mode 100644 index 0000000..7b5e0ce --- /dev/null +++ b/src/retroasset/ansi.py @@ -0,0 +1,41 @@ +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) From 0db543cd4b69df92bb8056250f3bc76c40173cfd Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:03:39 +0200 Subject: [PATCH 033/116] M0.4: test ByteRun1 and compressed ILBM --- tests/test_amiga.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/test_amiga.py b/tests/test_amiga.py index 77732ce..727b9c4 100644 --- a/tests/test_amiga.py +++ b/tests/test_amiga.py @@ -1,7 +1,7 @@ import struct import unittest -from retroasset.amiga import decode_ilbm, encode_ilbm, pack_planar, row_bytes +from retroasset.amiga import byterun1_decode, byterun1_encode, decode_ilbm, encode_ilbm, encode_ilbm_compressed, pack_planar, row_bytes from retroasset.indexed import IndexedBitmap @@ -34,6 +34,16 @@ 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) + if __name__ == "__main__": unittest.main() From 6ac7189e95bb2fdada0e2ee4f8c8c7ac4ad431ad Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:03:42 +0200 Subject: [PATCH 034/116] M0.4: test native ANSI output --- tests/test_ansi.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/test_ansi.py diff --git a/tests/test_ansi.py b/tests/test_ansi.py new file mode 100644 index 0000000..86522dd --- /dev/null +++ b/tests/test_ansi.py @@ -0,0 +1,28 @@ +import unittest + +from retroasset.ansi import 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),))) + + +if __name__ == "__main__": + unittest.main() From b0769ab798d8e46655b849499d91477ea5d1d8bf Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:03:44 +0200 Subject: [PATCH 035/116] M0.4: add native ANSI fixture --- examples/ansi-cp437-native.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 examples/ansi-cp437-native.json 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} + ] +} From c1503db9bacdae28da6887fc75cd1e37a6712498 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:05:01 +0200 Subject: [PATCH 036/116] M0.5: add ANSI SGR subset parser --- src/retroasset/ansi.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/retroasset/ansi.py b/src/retroasset/ansi.py index 7b5e0ce..45ed606 100644 --- a/src/retroasset/ansi.py +++ b/src/retroasset/ansi.py @@ -39,3 +39,41 @@ def encode_ans(asset: CharacterAsset) -> bytes: 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)) From eeee16ef56788b1dfbf4bfd73e6b7c683074eb57 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:05:03 +0200 Subject: [PATCH 037/116] M0.5: add native C64 screen representation --- src/retroasset/petscii.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/retroasset/petscii.py diff --git a/src/retroasset/petscii.py b/src/retroasset/petscii.py new file mode 100644 index 0000000..95a5e16 --- /dev/null +++ b/src/retroasset/petscii.py @@ -0,0 +1,39 @@ +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) From c36c77bc1d02cc53187dbe8b16508d85f45bce7f Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:05:12 +0200 Subject: [PATCH 038/116] M0.5: add C64 screen and PETSCII profiles --- src/retroasset/profiles.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/retroasset/profiles.py b/src/retroasset/profiles.py index 519ae88..7922697 100644 --- a/src/retroasset/profiles.py +++ b/src/retroasset/profiles.py @@ -18,6 +18,14 @@ class TargetProfile: "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" From 4f4a2ab7c18877d9d2a93486f5c573f2b9a8afa8 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:05:14 +0200 Subject: [PATCH 039/116] M0.5: add ANSI round-trip test --- tests/test_ansi.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/test_ansi.py b/tests/test_ansi.py index 86522dd..87197c2 100644 --- a/tests/test_ansi.py +++ b/tests/test_ansi.py @@ -1,6 +1,6 @@ import unittest -from retroasset.ansi import encode_ans +from retroasset.ansi import decode_ans, encode_ans from retroasset.character import CharacterAsset, CharacterCell @@ -23,6 +23,17 @@ 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() From 4111efa12eb8c010ceabc2759360e7ccaaeefa1e Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:05:16 +0200 Subject: [PATCH 040/116] M0.5: test native C64 screen/color RAM export --- tests/test_petscii.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/test_petscii.py diff --git a/tests/test_petscii.py b/tests/test_petscii.py new file mode 100644 index 0000000..b11479f --- /dev/null +++ b/tests/test_petscii.py @@ -0,0 +1,21 @@ +import unittest + +from retroasset.petscii import C64Screen, PetsciiCell, export_color_ram, export_screen_ram + + +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) + + +if __name__ == "__main__": + unittest.main() From 15e6f241f8b718df7bbe54858929d5202a80a5ab Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:06:14 +0200 Subject: [PATCH 041/116] M0.6: add explicit PETSCII screen-code mapping --- src/retroasset/petscii.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/retroasset/petscii.py b/src/retroasset/petscii.py index 95a5e16..56646ab 100644 --- a/src/retroasset/petscii.py +++ b/src/retroasset/petscii.py @@ -37,3 +37,40 @@ def export_screen_ram(screen: C64Screen) -> bytes: 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") From d83da3e27e61e14b51b7615ebee976c01ea766c0 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:06:16 +0200 Subject: [PATCH 042/116] M0.6: add SAUCE metadata codec --- src/retroasset/sauce.py | 58 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/retroasset/sauce.py 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(" Date: Tue, 6 Oct 2026 02:06:23 +0200 Subject: [PATCH 043/116] M0.6: test PETSCII mapping rules --- tests/test_petscii.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_petscii.py b/tests/test_petscii.py index b11479f..3ec6ae3 100644 --- a/tests/test_petscii.py +++ b/tests/test_petscii.py @@ -1,6 +1,6 @@ import unittest -from retroasset.petscii import C64Screen, PetsciiCell, export_color_ram, export_screen_ram +from retroasset.petscii import C64Screen, PetsciiCell, export_color_ram, export_screen_ram, petscii_to_screen_code, screen_code_to_petscii class C64ScreenTests(unittest.TestCase): @@ -16,6 +16,15 @@ 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() From 476019126f793ff711cbf11308f4ce8297c8a74a Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:06:25 +0200 Subject: [PATCH 044/116] M0.6: test SAUCE round trip --- tests/test_sauce.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/test_sauce.py 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() From 1b8e2c6d953517df3ecb7cc942106d1ffe66c29f Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:08:55 +0200 Subject: [PATCH 045/116] M0.7: add PETSCII stream state machine --- src/retroasset/petscii.py | 61 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/retroasset/petscii.py b/src/retroasset/petscii.py index 56646ab..29506fa 100644 --- a/src/retroasset/petscii.py +++ b/src/retroasset/petscii.py @@ -74,3 +74,64 @@ def screen_code_to_petscii(value: int) -> int: 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)) From b32e9233264d58e1863a8c0e66bd482977c70082 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:09:10 +0200 Subject: [PATCH 046/116] M0.7: add first deterministic C64 PRG viewer --- src/retroasset/c64prg.py | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/retroasset/c64prg.py diff --git a/src/retroasset/c64prg.py b/src/retroasset/c64prg.py new file mode 100644 index 0000000..f119197 --- /dev/null +++ b/src/retroasset/c64prg.py @@ -0,0 +1,50 @@ +import struct + +from .petscii import C64Screen, export_color_ram, export_screen_ram + + +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 + basic = bytes([ + 0x0b, 0x08, # pointer to next BASIC line + 0x0a, 0x00, # line 10 + 0x9e, # SYS token + 0x32, 0x30, 0x36, 0x31, # "2061" + 0x00, + 0x00, 0x00, # end of BASIC program + ]) + + # 6502 routine at $080d. Copy 1000 bytes in four pages from data + # immediately following the routine. Screen data is followed by color data. + # Uses self-contained absolute indexed loads/stores and four page loops. + code = bytearray() + screen_src = 0x080d + 0 # patched after code length is known + + # We emit four unrolled page-copy loops for screen RAM and color RAM. + # Final page copies 232 useful bytes; remaining 24 bytes are harmless padding + # inside screen/color memory and are supplied as zeros in the payload. + def page_copy(src: int, dst: int): + return bytes([ + 0xa2, 0x00, # LDX #$00 + 0xbd, src & 0xff, src >> 8, # LDA src,X + 0x9d, dst & 0xff, dst >> 8, # STA dst,X + 0xe8, # INX + 0xd0, 0xf7, # BNE back to LDA + ]) + + # Code size is deterministic: 8 page loops * 11 bytes + RTS. + code_size = 8 * 11 + 1 + screen_src = 0x080d + code_size + color_src = screen_src + 1024 + for page in range(4): + code.extend(page_copy(screen_src + page * 256, 0x0400 + page * 256)) + for page in range(4): + code.extend(page_copy(color_src + page * 256, 0xd800 + page * 256)) + code.append(0x60) # RTS + + screen_data = export_screen_ram(screen).ljust(1024, b"\x20") + color_data = export_color_ram(screen).ljust(1024, b"\x00") + return struct.pack(" Date: Tue, 6 Oct 2026 02:09:12 +0200 Subject: [PATCH 047/116] M0.7: test PETSCII state and C64 PRG structure --- tests/test_c64prg.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 tests/test_c64prg.py 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() From eb1ec36b0fdced1d45bb469d398e54b6e133561b Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:09:25 +0200 Subject: [PATCH 048/116] M0.7: document 6502 copy-loop branch displacement --- src/retroasset/c64prg.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/retroasset/c64prg.py b/src/retroasset/c64prg.py index f119197..4717ce2 100644 --- a/src/retroasset/c64prg.py +++ b/src/retroasset/c64prg.py @@ -32,7 +32,7 @@ def page_copy(src: int, dst: int): 0xbd, src & 0xff, src >> 8, # LDA src,X 0x9d, dst & 0xff, dst >> 8, # STA dst,X 0xe8, # INX - 0xd0, 0xf7, # BNE back to LDA + 0xd0, 0xf7, # BNE back to LDA (relative -9) ]) # Code size is deterministic: 8 page loops * 11 bytes + RTS. From 01cda806f12e6cc5921c3ef49cad015c98b905fe Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:12:19 +0200 Subject: [PATCH 049/116] M0.8: document verified C64 PRG address layout --- src/retroasset/c64prg.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/retroasset/c64prg.py b/src/retroasset/c64prg.py index 4717ce2..b4dcb80 100644 --- a/src/retroasset/c64prg.py +++ b/src/retroasset/c64prg.py @@ -35,6 +35,7 @@ def page_copy(src: int, dst: int): 0xd0, 0xf7, # BNE back to LDA (relative -9) ]) + # Each page loop is 11 bytes: LDX(2)+LDA abs,X(3)+STA abs,X(3)+INX(1)+BNE(2). # Code size is deterministic: 8 page loops * 11 bytes + RTS. code_size = 8 * 11 + 1 screen_src = 0x080d + code_size From ce9162e40d4a5456cd26a5432f50c71ef4fe6344 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:12:21 +0200 Subject: [PATCH 050/116] M0.8: add byte-exact C64 PRG layout tests --- tests/test_c64prg_layout.py | 40 +++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/test_c64prg_layout.py diff --git a/tests/test_c64prg_layout.py b/tests/test_c64prg_layout.py new file mode 100644 index 0000000..e2adbd6 --- /dev/null +++ b/tests/test_c64prg_layout.py @@ -0,0 +1,40 @@ +import unittest + +from retroasset.c64prg import 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 test_machine_code_starts_at_sys_address(self): + load = int.from_bytes(self.prg[:2], "little") + sys_addr = 2061 + offset = 2 + (sys_addr - load) + self.assertEqual(offset, 14) + self.assertEqual(self.prg[offset:offset + 2], bytes([0xa2, 0x00])) + + def test_first_copy_source_points_to_payload(self): + load = int.from_bytes(self.prg[:2], "little") + code_offset = 2 + (0x080d - load) + code_size = 89 + payload_addr = 0x080d + code_size + self.assertEqual( + self.prg[code_offset + 3:code_offset + 5], + payload_addr.to_bytes(2, "little"), + ) + + def test_branch_returns_to_lda_not_ldx(self): + load = int.from_bytes(self.prg[:2], "little") + code_offset = 2 + (0x080d - load) + # BNE opcode at +9, operand -9 => next-PC + (-9) = LDA at +2. + self.assertEqual(self.prg[code_offset + 9:code_offset + 11], bytes([0xd0, 0xf7])) + + +if __name__ == "__main__": + unittest.main() From c9cc15d84e5530ba90c39554399b57c0b9d8e717 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:12:37 +0200 Subject: [PATCH 051/116] M0.8: separate emulator qualification workflow --- .github/workflows/qualification.yml | 30 +++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/qualification.yml diff --git a/.github/workflows/qualification.yml b/.github/workflows/qualification.yml new file mode 100644 index 0000000..d996d3c --- /dev/null +++ b/.github/workflows/qualification.yml @@ -0,0 +1,30 @@ +name: Emulator qualification + +on: + workflow_dispatch: + push: + branches: [m0-foundation] + paths: + - "src/retroasset/c64prg.py" + - "src/retroasset/petscii.py" + - "qualification/**" + - ".github/workflows/qualification.yml" + +jobs: + 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 + - name: Record qualification boundary + run: | + mkdir -p qualification/evidence + printf '%s\n' 'C64 structural qualification: PASS' 'VICE runtime qualification: NOT YET RUN' > qualification/evidence/c64.txt + - uses: actions/upload-artifact@v4 + with: + name: retroasset-c64-qualification + path: qualification/evidence/c64.txt From 85c3b6047d3d0e456b96ed787b98d8f80314ce4e Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:12:38 +0200 Subject: [PATCH 052/116] M0.8: document runtime qualification boundary --- qualification/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 qualification/README.md diff --git a/qualification/README.md b/qualification/README.md new file mode 100644 index 0000000..f010d88 --- /dev/null +++ b/qualification/README.md @@ -0,0 +1,22 @@ +# Runtime qualification + +RetroAsset distinguishes deterministic structural tests from emulator/runtime +qualification. + +## Status + +| Target | Structural | Runtime | +|---|---|---| +| C64 PRG | PASS when qualification workflow is green | VICE: not yet qualified | +| Amiga ILBM uncompressed | unit-tested / round-trip | Amiga runtime: not yet qualified | +| Amiga ILBM ByteRun1 | structurally tested | Amiga runtime: not yet qualified | + +A native file format is not labelled runtime-qualified until it has been loaded +by the documented emulator/runtime and evidence has been captured. + +## Planned runtimes + +- C64: VICE +- Amiga: project Amiga runtime infrastructure / compatible ILBM consumer + +Qualification jobs remain separate from fast unit-test CI. From fd6f978bbe3c4e234b473f05bd0930f26042a90d Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:16:46 +0200 Subject: [PATCH 053/116] M0.9: add deterministic C64 qualification fixture --- qualification/generate_c64_fixture.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 qualification/generate_c64_fixture.py 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() From 754bdb38b83a794df3ea44f62c0a6866ee76ca01 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:16:48 +0200 Subject: [PATCH 054/116] M0.9: add VICE monitor memory dump script --- qualification/vice-monitor.txt | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 qualification/vice-monitor.txt diff --git a/qualification/vice-monitor.txt b/qualification/vice-monitor.txt new file mode 100644 index 0000000..3e4dcc6 --- /dev/null +++ b/qualification/vice-monitor.txt @@ -0,0 +1,5 @@ +; VICE monitor commands for RetroAsset C64 qualification. +; Autostart fixture.prg before executing this script. +break 0820 +command 1 "save \"qualification/generated/screen-dump.bin\" 0 0400 07e7; save \"qualification/generated/color-dump.bin\" 0 d800 dbe7; quit" +x From 4abce90225090df5880af7aeac717ea4df9d93a3 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:16:50 +0200 Subject: [PATCH 055/116] M0.9: verify VICE RAM dumps against source fixture --- qualification/verify_c64.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 qualification/verify_c64.py 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()) From dd8714009cec27420478879600add1c82ff01ca0 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:16:58 +0200 Subject: [PATCH 056/116] M0.9: run generated C64 PRG under VICE and verify RAM --- .github/workflows/qualification.yml | 33 ++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/workflows/qualification.yml b/.github/workflows/qualification.yml index d996d3c..5952135 100644 --- a/.github/workflows/qualification.yml +++ b/.github/workflows/qualification.yml @@ -20,11 +20,34 @@ jobs: python-version: "3.12" - run: python -m pip install -e . - run: python -m unittest tests.test_c64prg tests.test_c64prg_layout -v - - name: Record qualification boundary + - run: python qualification/generate_c64_fixture.py + - uses: actions/upload-artifact@v4 + with: + name: retroasset-c64-fixture + path: qualification/generated/ + + c64-vice: + 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 + run: | + sudo apt-get update + sudo apt-get install -y vice + x64sc --version + - name: Run VICE qualification run: | - mkdir -p qualification/evidence - printf '%s\n' 'C64 structural qualification: PASS' 'VICE runtime qualification: NOT YET RUN' > qualification/evidence/c64.txt + xvfb-run -a x64sc -autostart qualification/generated/fixture.prg -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-qualification - path: qualification/evidence/c64.txt + name: retroasset-c64-vice-evidence + path: qualification/generated/ From 449ed23bb0658b635f65c16826c7e25fcfc01ab0 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:33:32 +0200 Subject: [PATCH 057/116] M0.9: qualify C64 with redistributable Open ROMs --- .github/workflows/qualification.yml | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/qualification.yml b/.github/workflows/qualification.yml index 5952135..dc7a851 100644 --- a/.github/workflows/qualification.yml +++ b/.github/workflows/qualification.yml @@ -26,7 +26,7 @@ jobs: name: retroasset-c64-fixture path: qualification/generated/ - c64-vice: + c64-vice-open-roms: runs-on: ubuntu-latest needs: c64-structural steps: @@ -36,18 +36,30 @@ jobs: python-version: "3.12" - run: python -m pip install -e . - run: python qualification/generate_c64_fixture.py - - name: Install VICE + - name: Install VICE and Open ROMs build dependencies run: | sudo apt-get update - sudo apt-get install -y vice + sudo apt-get install -y vice git make gcc x64sc --version - - name: Run VICE qualification + - name: Build redistributable C64 Open ROMs run: | - xvfb-run -a x64sc -autostart qualification/generated/fixture.prg -moncommands qualification/vice-monitor.txt -console + 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" -autostart qualification/generated/fixture.prg -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-evidence + name: retroasset-c64-vice-open-roms-evidence path: qualification/generated/ From bd12cd347775103fc6f3be20ea58b8feb03ed857 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:33:34 +0200 Subject: [PATCH 058/116] M0.9: discover Open ROMs qualification firmware --- qualification/find_open_roms.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 qualification/find_open_roms.py 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() From e209bf3ff8a443f9cb1dda62fe133913ac9f9999 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:38:52 +0200 Subject: [PATCH 059/116] M0.9: install libpng headers for Open ROMs build --- .github/workflows/qualification.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/qualification.yml b/.github/workflows/qualification.yml index dc7a851..1a688b1 100644 --- a/.github/workflows/qualification.yml +++ b/.github/workflows/qualification.yml @@ -39,7 +39,7 @@ jobs: - name: Install VICE and Open ROMs build dependencies run: | sudo apt-get update - sudo apt-get install -y vice git make gcc + sudo apt-get install -y vice git make gcc libpng-dev x64sc --version - name: Build redistributable C64 Open ROMs run: | From 7344376b18d653a49d64bca0f17a69b9f00de823 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:41:00 +0200 Subject: [PATCH 060/116] M0.9: fix C64 BASIC next-line pointer --- src/retroasset/c64prg.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/retroasset/c64prg.py b/src/retroasset/c64prg.py index b4dcb80..e605c1a 100644 --- a/src/retroasset/c64prg.py +++ b/src/retroasset/c64prg.py @@ -9,7 +9,7 @@ def export_viewer_prg(screen: C64Screen) -> bytes: # $0801 BASIC line: 10 SYS2061 basic = bytes([ - 0x0b, 0x08, # pointer to next BASIC line + 0x0c, 0x08, # pointer to next BASIC line/end marker 0x0a, 0x00, # line 10 0x9e, # SYS token 0x32, 0x30, 0x36, 0x31, # "2061" From 89a086321c4b3fd158f8664b957168f60faf8d31 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:41:02 +0200 Subject: [PATCH 061/116] M0.9: assert valid BASIC end-marker pointer --- tests/test_c64prg_layout.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_c64prg_layout.py b/tests/test_c64prg_layout.py index e2adbd6..b64997c 100644 --- a/tests/test_c64prg_layout.py +++ b/tests/test_c64prg_layout.py @@ -12,6 +12,9 @@ def setUp(self): ) self.prg = export_viewer_prg(self.screen) + def test_basic_next_line_pointer_targets_end_marker(self): + self.assertEqual(self.prg[2:4], bytes([0x0c, 0x08])) + def test_machine_code_starts_at_sys_address(self): load = int.from_bytes(self.prg[:2], "little") sys_addr = 2061 From b3a9b80653f5c322a2b7707a3980d3981a173a59 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 03:54:18 +0200 Subject: [PATCH 062/116] M0.9: load C64 PRG directly in VICE monitor --- qualification/vice-monitor.txt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/qualification/vice-monitor.txt b/qualification/vice-monitor.txt index 3e4dcc6..e51205c 100644 --- a/qualification/vice-monitor.txt +++ b/qualification/vice-monitor.txt @@ -1,5 +1,6 @@ -; VICE monitor commands for RetroAsset C64 qualification. -; Autostart fixture.prg before executing this script. -break 0820 +; RetroAsset C64 qualification. +; Load the PRG directly, bypassing disk/drive autostart and its proprietary ROMs. +load "qualification/generated/fixture.prg" 0 +break 0866 command 1 "save \"qualification/generated/screen-dump.bin\" 0 0400 07e7; save \"qualification/generated/color-dump.bin\" 0 d800 dbe7; quit" -x +g 080d From 5f5ee2c652bb605c5db21abbbf61929ecdb9258e Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 03:54:20 +0200 Subject: [PATCH 063/116] M0.9: bypass drive ROMs in C64 qualification From a94f37e6bac17201d512bec85f5c55e602e2c7db Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 04:11:31 +0200 Subject: [PATCH 064/116] M0.9: trigger direct-monitor C64 qualification --- qualification/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/qualification/README.md b/qualification/README.md index f010d88..780c70b 100644 --- a/qualification/README.md +++ b/qualification/README.md @@ -20,3 +20,5 @@ by the documented emulator/runtime and evidence has been captured. - Amiga: project Amiga runtime infrastructure / compatible ILBM consumer Qualification jobs remain separate from fast unit-test CI. + + From 819ed4f6a5b8d47135cfc88e84e207571d552127 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 04:16:26 +0200 Subject: [PATCH 065/116] M0.9: actually remove VICE autostart dependency --- .github/workflows/qualification.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/qualification.yml b/.github/workflows/qualification.yml index 1a688b1..6ad39ee 100644 --- a/.github/workflows/qualification.yml +++ b/.github/workflows/qualification.yml @@ -55,7 +55,12 @@ jobs: set -a . qualification/generated/roms.env set +a - xvfb-run -a x64sc -basic "$RETROASSET_BASIC_ROM" -kernal "$RETROASSET_KERNAL_ROM" -chargen "$RETROASSET_CHARGEN_ROM" -autostart qualification/generated/fixture.prg -moncommands qualification/vice-monitor.txt -console + 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 From 40bcbaf492c0a45382db4981c96acd5ea75cd7c4 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 04:37:24 +0200 Subject: [PATCH 066/116] M0.9: bound C64 viewer copies to visible 1000 cells --- src/retroasset/c64prg.py | 79 ++++++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/src/retroasset/c64prg.py b/src/retroasset/c64prg.py index e605c1a..7e86d5c 100644 --- a/src/retroasset/c64prg.py +++ b/src/retroasset/c64prg.py @@ -2,50 +2,67 @@ 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 + # $0801 BASIC line: 10 SYS2061. The link points at the $080b end marker. basic = bytes([ - 0x0c, 0x08, # pointer to next BASIC line/end marker - 0x0a, 0x00, # line 10 - 0x9e, # SYS token - 0x32, 0x30, 0x36, 0x31, # "2061" + 0x0b, 0x08, + 0x0a, 0x00, + 0x9e, + 0x32, 0x30, 0x36, 0x31, 0x00, - 0x00, 0x00, # end of BASIC program + 0x00, 0x00, ]) - # 6502 routine at $080d. Copy 1000 bytes in four pages from data - # immediately following the routine. Screen data is followed by color data. - # Uses self-contained absolute indexed loads/stores and four page loops. - code = bytearray() - screen_src = 0x080d + 0 # patched after code length is known + 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, + ]) - # We emit four unrolled page-copy loops for screen RAM and color RAM. - # Final page copies 232 useful bytes; remaining 24 bytes are harmless padding - # inside screen/color memory and are supplied as zeros in the payload. - def page_copy(src: int, dst: int): + def tail_copy(src: int, dst: int) -> bytes: return bytes([ - 0xa2, 0x00, # LDX #$00 - 0xbd, src & 0xff, src >> 8, # LDA src,X - 0x9d, dst & 0xff, dst >> 8, # STA dst,X - 0xe8, # INX - 0xd0, 0xf7, # BNE back to LDA (relative -9) + 0xa2, 0x00, + 0xbd, src & 0xff, src >> 8, + 0x9d, dst & 0xff, dst >> 8, + 0xe8, + 0xe0, TAIL_BYTES, + 0xd0, 0xf5, ]) - # Each page loop is 11 bytes: LDX(2)+LDA abs,X(3)+STA abs,X(3)+INX(1)+BNE(2). - # Code size is deterministic: 8 page loops * 11 bytes + RTS. - code_size = 8 * 11 + 1 - screen_src = 0x080d + code_size - color_src = screen_src + 1024 - for page in range(4): - code.extend(page_copy(screen_src + page * 256, 0x0400 + page * 256)) - for page in range(4): - code.extend(page_copy(color_src + page * 256, 0xd800 + page * 256)) - code.append(0x60) # RTS + 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(" Date: Tue, 6 Oct 2026 04:39:59 +0200 Subject: [PATCH 067/116] M0.9: test bounded C64 viewer layout --- tests/test_c64prg_layout.py | 51 ++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/tests/test_c64prg_layout.py b/tests/test_c64prg_layout.py index b64997c..ab3c939 100644 --- a/tests/test_c64prg_layout.py +++ b/tests/test_c64prg_layout.py @@ -1,6 +1,8 @@ import unittest -from retroasset.c64prg import export_viewer_prg +from retroasset.c64prg import ( + CODE_ADDRESS, COLOR_SOURCE, SCREEN_SOURCE, TAIL_BYTES, export_viewer_prg, +) from retroasset.petscii import C64Screen, PetsciiCell @@ -12,31 +14,34 @@ def setUp(self): ) self.prg = export_viewer_prg(self.screen) - def test_basic_next_line_pointer_targets_end_marker(self): - self.assertEqual(self.prg[2:4], bytes([0x0c, 0x08])) - - def test_machine_code_starts_at_sys_address(self): + def offset(self, address: int) -> int: load = int.from_bytes(self.prg[:2], "little") - sys_addr = 2061 - offset = 2 + (sys_addr - load) - self.assertEqual(offset, 14) - self.assertEqual(self.prg[offset:offset + 2], bytes([0xa2, 0x00])) + return 2 + address - load - def test_first_copy_source_points_to_payload(self): - load = int.from_bytes(self.prg[:2], "little") - code_offset = 2 + (0x080d - load) - code_size = 89 - payload_addr = 0x080d + code_size - self.assertEqual( - self.prg[code_offset + 3:code_offset + 5], - payload_addr.to_bytes(2, "little"), - ) + def test_basic_next_line_pointer_targets_end_marker(self): + self.assertEqual(self.prg[2:4], bytes([0x0b, 0x08])) - def test_branch_returns_to_lda_not_ldx(self): - load = int.from_bytes(self.prg[:2], "little") - code_offset = 2 + (0x080d - load) - # BNE opcode at +9, operand -9 => next-PC + (-9) = LDA at +2. - self.assertEqual(self.prg[code_offset + 9:code_offset + 11], bytes([0xd0, 0xf7])) + 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__": From 83352c1e1827517a1e72ef0adebae817ea3698d6 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 04:40:01 +0200 Subject: [PATCH 068/116] M0.9: stop VICE at bounded viewer completion --- qualification/vice-monitor.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/qualification/vice-monitor.txt b/qualification/vice-monitor.txt index e51205c..d1cc1d3 100644 --- a/qualification/vice-monitor.txt +++ b/qualification/vice-monitor.txt @@ -1,6 +1,7 @@ ; RetroAsset C64 qualification. -; Load the PRG directly, bypassing disk/drive autostart and its proprietary ROMs. +; Load PRG directly; no disk/drive ROM is required. load "qualification/generated/fixture.prg" 0 -break 0866 +; Viewer code is 93 bytes at $080d, so RTS is at $0869. +break 0869 command 1 "save \"qualification/generated/screen-dump.bin\" 0 0400 07e7; save \"qualification/generated/color-dump.bin\" 0 d800 dbe7; quit" g 080d From 5a1dab7593e45ba5e4ceb856f671efec87131e92 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 04:44:03 +0200 Subject: [PATCH 069/116] M0.9: dump raw C64 RAM evidence with bsave --- qualification/vice-monitor.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qualification/vice-monitor.txt b/qualification/vice-monitor.txt index d1cc1d3..aa36a38 100644 --- a/qualification/vice-monitor.txt +++ b/qualification/vice-monitor.txt @@ -1,7 +1,7 @@ ; RetroAsset C64 qualification. ; Load PRG directly; no disk/drive ROM is required. load "qualification/generated/fixture.prg" 0 -; Viewer code is 93 bytes at $080d, so RTS is at $0869. +; Viewer code completes at the RTS at $0869. break 0869 -command 1 "save \"qualification/generated/screen-dump.bin\" 0 0400 07e7; save \"qualification/generated/color-dump.bin\" 0 d800 dbe7; quit" +command 1 "bsave \"qualification/generated/screen-dump.bin\" 0 0400 07e8; bsave \"qualification/generated/color-dump.bin\" 0 d800 dbe8; quit" g 080d From ba5ed7e3e0f710dc0a7eeb6022d781e05c5d95b0 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 04:44:05 +0200 Subject: [PATCH 070/116] M0.9: disable VICE sound in headless qualification From f238b9ccf2157014a921f94de3f3f949d26a808b Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 04:46:43 +0200 Subject: [PATCH 071/116] M0.9: dump C64 RAM after completion breakpoint --- qualification/vice-monitor.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/qualification/vice-monitor.txt b/qualification/vice-monitor.txt index aa36a38..e3a195c 100644 --- a/qualification/vice-monitor.txt +++ b/qualification/vice-monitor.txt @@ -3,5 +3,8 @@ load "qualification/generated/fixture.prg" 0 ; Viewer code completes at the RTS at $0869. break 0869 -command 1 "bsave \"qualification/generated/screen-dump.bin\" 0 0400 07e8; bsave \"qualification/generated/color-dump.bin\" 0 d800 dbe8; quit" g 080d +; Playback resumes here after the breakpoint is hit. +bsave "qualification/generated/screen-dump.bin" 0 0400 07e8 +bsave "qualification/generated/color-dump.bin" 0 d800 dbe8 +quit From e0c41f55ba48ebab59797e1f33bda2536b320474 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 04:48:10 +0200 Subject: [PATCH 072/116] M0.9: use inclusive VICE bsave ranges --- qualification/vice-monitor.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qualification/vice-monitor.txt b/qualification/vice-monitor.txt index e3a195c..e7ac498 100644 --- a/qualification/vice-monitor.txt +++ b/qualification/vice-monitor.txt @@ -5,6 +5,6 @@ load "qualification/generated/fixture.prg" 0 break 0869 g 080d ; Playback resumes here after the breakpoint is hit. -bsave "qualification/generated/screen-dump.bin" 0 0400 07e8 -bsave "qualification/generated/color-dump.bin" 0 d800 dbe8 +bsave "qualification/generated/screen-dump.bin" 0 0400 07e7 +bsave "qualification/generated/color-dump.bin" 0 d800 dbe7 quit From 9ed03a34d7ee8cc3c172931a9d9d2a7520a7546e Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 04:49:37 +0200 Subject: [PATCH 073/116] M0.9: record C64 VICE runtime qualification PASS --- qualification/README.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/qualification/README.md b/qualification/README.md index 780c70b..2bb35c6 100644 --- a/qualification/README.md +++ b/qualification/README.md @@ -7,9 +7,9 @@ qualification. | Target | Structural | Runtime | |---|---|---| -| C64 PRG | PASS when qualification workflow is green | VICE: not yet qualified | +| C64 PRG | PASS | PASS — VICE 3.7.1 + MEGA65 Open ROMs | | Amiga ILBM uncompressed | unit-tested / round-trip | Amiga runtime: not yet qualified | -| Amiga ILBM ByteRun1 | structurally tested | Amiga runtime: not yet qualified | +| Amiga ILBM ByteRun1 | encoder/unit tested | Amiga runtime: not yet qualified | A native file format is not labelled runtime-qualified until it has been loaded by the documented emulator/runtime and evidence has been captured. @@ -21,4 +21,17 @@ by the documented emulator/runtime and evidence has been captured. 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 + From 9c70428c48aa7a3d101718537cf06b04f66a63ee Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 04:52:41 +0200 Subject: [PATCH 074/116] M0.4: decode ByteRun1 ILBM rows --- src/retroasset/amiga.py | 43 +++++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/src/retroasset/amiga.py b/src/retroasset/amiga.py index 51e5a51..a7fcecc 100644 --- a/src/retroasset/amiga.py +++ b/src/retroasset/amiga.py @@ -87,10 +87,21 @@ def decode_ilbm(data: bytes) -> IndexedBitmap: width, height, _, _, planes, _, compression, _, _, _, _, _, _ = struct.unpack( ">HHhhBBBBHBBhh", bmhd ) - if compression != 0: - raise ValueError("compressed ILBM decoding not implemented yet") + 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(body, width, height, planes) + pixels = unpack_planar(planar, width, height, planes) return IndexedBitmap(width, height, pixels, palette) @@ -121,26 +132,38 @@ def byterun1_encode(data: bytes) -> bytes: return bytes(out) -def byterun1_decode(data: bytes, expected_size: int) -> bytes: +def byterun1_decode_from(data: bytes, offset: int, expected_size: int) -> tuple[bytes, int]: out = bytearray() - i = 0 - while i < len(data) and len(out) < expected_size: + 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") - out.extend(data[i:i + 1] * (1 - signed)) + 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. - if len(out) != expected_size: - raise ValueError("ByteRun1 output size mismatch") - return bytes(out) + 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: From 61c4837dac4706650dd11066ef3253d1f758bb0b Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 04:52:48 +0200 Subject: [PATCH 075/116] M0.4: test compressed ILBM round trip --- tests/test_amiga.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_amiga.py b/tests/test_amiga.py index 727b9c4..db863f9 100644 --- a/tests/test_amiga.py +++ b/tests/test_amiga.py @@ -44,6 +44,18 @@ def test_compressed_ilbm_header(self): 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() From 5b2dc2090a01fa721c68105041efa83b49515768 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 04:58:40 +0200 Subject: [PATCH 076/116] M0.4: expose ByteRun1 ILBM compression in CLI --- src/retroasset/cli.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/retroasset/cli.py b/src/retroasset/cli.py index 1cdd714..35c30d2 100644 --- a/src/retroasset/cli.py +++ b/src/retroasset/cli.py @@ -2,7 +2,7 @@ import json from pathlib import Path -from .amiga import encode_ilbm +from .amiga import encode_ilbm, encode_ilbm_compressed from .exporters import export_asm, export_c, export_raw_planar from .indexed import IndexedBitmap from .model import AssetManifest @@ -40,6 +40,8 @@ def main() -> int: 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) args = parser.parse_args() @@ -67,8 +69,12 @@ def main() -> int: 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": - args.output.write_bytes(encode_ilbm(bitmap)) + 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": From ef7bec581d4bc33c7c7d4fac3aa9934c4748bf2a Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 04:58:45 +0200 Subject: [PATCH 077/116] M0.4: record ByteRun1 round-trip PASS --- qualification/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qualification/README.md b/qualification/README.md index 2bb35c6..a90a1cb 100644 --- a/qualification/README.md +++ b/qualification/README.md @@ -9,7 +9,7 @@ qualification. |---|---|---| | C64 PRG | PASS | PASS — VICE 3.7.1 + MEGA65 Open ROMs | | Amiga ILBM uncompressed | unit-tested / round-trip | Amiga runtime: not yet qualified | -| Amiga ILBM ByteRun1 | encoder/unit tested | Amiga runtime: not yet qualified | +| Amiga ILBM ByteRun1 | encode/decode round-trip PASS | Amiga runtime: not yet qualified | A native file format is not labelled runtime-qualified until it has been loaded by the documented emulator/runtime and evidence has been captured. From df562ea0d0e0469264c8336c01d13a7cbf0a347a Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 05:00:54 +0200 Subject: [PATCH 078/116] M0.4: add Amiga ILBM qualification fixtures --- qualification/generate_amiga_fixtures.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 qualification/generate_amiga_fixtures.py 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() From b8b3c2fa60dafc5a60bb9cec8dc7e4d6eeb30eaa Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 05:01:03 +0200 Subject: [PATCH 079/116] M0.4: qualify ILBM with independent consumer --- .github/workflows/qualification.yml | 30 +++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/qualification.yml b/.github/workflows/qualification.yml index 6ad39ee..d7caf2d 100644 --- a/.github/workflows/qualification.yml +++ b/.github/workflows/qualification.yml @@ -7,6 +7,7 @@ on: paths: - "src/retroasset/c64prg.py" - "src/retroasset/petscii.py" + - "src/retroasset/amiga.py" - "qualification/**" - ".github/workflows/qualification.yml" @@ -68,3 +69,32 @@ jobs: 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: 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/ From 0f45e7d20534fa3e47971d243eae01e0597ab4c1 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 05:05:22 +0200 Subject: [PATCH 080/116] M0.4: record independent ILBM interoperability PASS --- qualification/README.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/qualification/README.md b/qualification/README.md index a90a1cb..77d1981 100644 --- a/qualification/README.md +++ b/qualification/README.md @@ -8,12 +8,26 @@ qualification. | Target | Structural | Runtime | |---|---|---| | C64 PRG | PASS | PASS — VICE 3.7.1 + MEGA65 Open ROMs | -| Amiga ILBM uncompressed | unit-tested / round-trip | Amiga runtime: not yet qualified | -| Amiga ILBM ByteRun1 | encode/decode round-trip PASS | Amiga runtime: not yet qualified | +| Amiga ILBM uncompressed | round-trip + independent consumer PASS | Amiga runtime: not yet qualified | +| Amiga ILBM ByteRun1 | round-trip + independent consumer PASS | Amiga runtime: not yet qualified | 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. It is intentionally +not labelled Amiga runtime qualification until the assets are loaded in the +documented Amiga emulator/runtime environment. + ## Planned runtimes - C64: VICE From a40b2d7c99ba483d481e9d4faeb7f81dbe51d577 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 08:42:45 +0200 Subject: [PATCH 081/116] M0.5: add independent m68k ILBM guest verifier --- qualification/amiga/ilbm_verify.c | 118 ++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 qualification/amiga/ilbm_verify.c diff --git a/qualification/amiga/ilbm_verify.c b/qualification/amiga/ilbm_verify.c new file mode 100644 index 0000000..5ff5b6b --- /dev/null +++ b/qualification/amiga/ilbm_verify.c @@ -0,0 +1,118 @@ +#include +#include +#include +#include + +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(const char *path) { + FILE *f; + unsigned char *data, *bmhd = NULL, *body = NULL; + long file_size; + 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; + + f = fopen(path, "rb"); + if (!f) return 0; + fseek(f, 0, SEEK_END); + file_size = ftell(f); + rewind(f); + if (file_size < 12) { fclose(f); return 0; } + data = (unsigned char *)malloc((size_t)file_size); + if (!data) { fclose(f); return 0; } + if (fread(data, 1, (size_t)file_size, f) != (size_t)file_size) { + free(data); fclose(f); return 0; + } + fclose(f); + + if (memcmp(data, "FORM", 4) || memcmp(data + 8, "ILBM", 4)) { + free(data); return 0; + } + while (pos + 8 <= (size_t)file_size) { + uint32_t size = be32(data + pos + 4); + unsigned char *payload = data + pos + 8; + if (pos + 8 + size > (size_t)file_size) { free(data); return 0; } + if (!memcmp(data + pos, "BMHD", 4)) bmhd = payload; + if (!memcmp(data + pos, "BODY", 4)) { body = payload; body_size = size; } + pos += 8 + size + (size & 1); + } + if (!bmhd || !body) { free(data); return 0; } + + width = be16(bmhd); + height = be16(bmhd + 2); + planes = bmhd[8]; + compression = bmhd[10]; + if (!width || !height || !planes || compression > 1) { free(data); return 0; } + + stride = ((width + 15) / 16) * 2; + rows = (size_t)height * planes; + row = (unsigned char *)malloc(stride); + if (!row) { free(data); return 0; } + + if (compression == 0) { + if (body_size != stride * rows) { free(row); free(data); return 0; } + } else { + for (i = 0; i < rows; ++i) { + if (!decode_row(body, body_size, &stream_pos, row, stride)) { + free(row); free(data); return 0; + } + } + if (stream_pos != body_size) { free(row); free(data); return 0; } + } + + printf("RETROASSET_ILBM_%s_PASS %ux%u %u-plane\n", + compression ? "BYTERUN1" : "UNCOMPRESSED", + (unsigned)width, (unsigned)height, planes); + free(row); + free(data); + return 1; +} + +int main(int argc, char **argv) { + int i; + 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; +} From 7efa00fe53a106c85677992069cd6db5e487962e Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 08:42:54 +0200 Subject: [PATCH 082/116] M0.5: smoke-test standalone ILBM verifier --- .github/workflows/qualification.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/qualification.yml b/.github/workflows/qualification.yml index d7caf2d..b9c497d 100644 --- a/.github/workflows/qualification.yml +++ b/.github/workflows/qualification.yml @@ -80,6 +80,12 @@ jobs: - 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: Install independent ILBM consumer run: | sudo apt-get update From 91f901dcec0b3e4d41785d51c47f52f0af7d50d5 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 09:42:05 +0200 Subject: [PATCH 083/116] M0.5: add generated ILBM fixture embedder --- qualification/embed_amiga_fixtures.py | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 qualification/embed_amiga_fixtures.py 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() From 288fed07edb303628e74266997ba70fc262c4e5d Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 09:45:59 +0200 Subject: [PATCH 084/116] M0.5: support embedded ILBM guest fixtures --- qualification/amiga/ilbm_verify.c | 65 +++++++++++++++++++------------ 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/qualification/amiga/ilbm_verify.c b/qualification/amiga/ilbm_verify.c index 5ff5b6b..8caf098 100644 --- a/qualification/amiga/ilbm_verify.c +++ b/qualification/amiga/ilbm_verify.c @@ -3,6 +3,10 @@ #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]); } @@ -36,74 +40,85 @@ static int decode_row(const unsigned char *src, size_t size, size_t *pos, return 1; } -static int verify(const char *path) { - FILE *f; - unsigned char *data, *bmhd = NULL, *body = NULL; - long file_size; +static int verify_buffer(const unsigned char *data, size_t file_size) { + unsigned char *bmhd = NULL, *body = NULL; 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; - f = fopen(path, "rb"); - if (!f) return 0; - fseek(f, 0, SEEK_END); - file_size = ftell(f); - rewind(f); - if (file_size < 12) { fclose(f); return 0; } - data = (unsigned char *)malloc((size_t)file_size); - if (!data) { fclose(f); return 0; } - if (fread(data, 1, (size_t)file_size, f) != (size_t)file_size) { - free(data); fclose(f); return 0; - } - fclose(f); + if (file_size < 12) return 0; if (memcmp(data, "FORM", 4) || memcmp(data + 8, "ILBM", 4)) { - free(data); return 0; + return 0; } while (pos + 8 <= (size_t)file_size) { uint32_t size = be32(data + pos + 4); unsigned char *payload = data + pos + 8; - if (pos + 8 + size > (size_t)file_size) { free(data); return 0; } + if (pos + 8 + size > (size_t)file_size) { return 0; } if (!memcmp(data + pos, "BMHD", 4)) bmhd = payload; if (!memcmp(data + pos, "BODY", 4)) { body = payload; body_size = size; } pos += 8 + size + (size & 1); } - if (!bmhd || !body) { free(data); return 0; } + if (!bmhd || !body) { return 0; } width = be16(bmhd); height = be16(bmhd + 2); planes = bmhd[8]; compression = bmhd[10]; - if (!width || !height || !planes || compression > 1) { free(data); return 0; } + if (!width || !height || !planes || compression > 1) { return 0; } stride = ((width + 15) / 16) * 2; rows = (size_t)height * planes; row = (unsigned char *)malloc(stride); - if (!row) { free(data); return 0; } + if (!row) { return 0; } if (compression == 0) { - if (body_size != stride * rows) { free(row); free(data); return 0; } + if (body_size != stride * rows) { free(row); return 0; } } else { for (i = 0; i < rows; ++i) { if (!decode_row(body, body_size, &stream_pos, row, stride)) { - free(row); free(data); return 0; + free(row); return 0; } } - if (stream_pos != body_size) { free(row); free(data); return 0; } + 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); - free(data); 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; From 3c352820ee454bf89827d7862ef97b75d43e53de Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 09:46:27 +0200 Subject: [PATCH 085/116] M0.5: add AROS m68k runtime contract --- qualification/amiga/amiga-runtime.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 qualification/amiga/amiga-runtime.json 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 + } +} From e217e439976727afef700667a66fd11b82affad4 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 09:46:37 +0200 Subject: [PATCH 086/116] M0.5: smoke-test embedded Amiga guest payload --- .github/workflows/qualification.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/qualification.yml b/.github/workflows/qualification.yml index b9c497d..808acec 100644 --- a/.github/workflows/qualification.yml +++ b/.github/workflows/qualification.yml @@ -86,6 +86,13 @@ jobs: 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 From 8a35cd1465b49bc0ff30e93c61aa8934119c3f97 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 09:49:05 +0200 Subject: [PATCH 087/116] M0.5: preserve const data in embedded ILBM verifier --- qualification/amiga/ilbm_verify.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qualification/amiga/ilbm_verify.c b/qualification/amiga/ilbm_verify.c index 8caf098..0ef49de 100644 --- a/qualification/amiga/ilbm_verify.c +++ b/qualification/amiga/ilbm_verify.c @@ -41,7 +41,7 @@ static int decode_row(const unsigned char *src, size_t size, size_t *pos, } static int verify_buffer(const unsigned char *data, size_t file_size) { - unsigned char *bmhd = NULL, *body = NULL; + const unsigned char *bmhd = NULL, *body = NULL; size_t pos = 12, body_size = 0, stream_pos = 0; uint16_t width, height; unsigned planes, compression; @@ -55,7 +55,7 @@ static int verify_buffer(const unsigned char *data, size_t file_size) { } while (pos + 8 <= (size_t)file_size) { uint32_t size = be32(data + pos + 4); - unsigned char *payload = data + pos + 8; + 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; if (!memcmp(data + pos, "BODY", 4)) { body = payload; body_size = size; } From 16fbf7ec6c321368223c097c398946668ce58011 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 09:52:30 +0200 Subject: [PATCH 088/116] M0.5: build Amiga m68k Q1 runtime payload --- .github/workflows/qualification.yml | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.github/workflows/qualification.yml b/.github/workflows/qualification.yml index 808acec..f25e076 100644 --- a/.github/workflows/qualification.yml +++ b/.github/workflows/qualification.yml @@ -12,6 +12,42 @@ on: - ".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 \ + -v "$PWD:/workspace" \ + -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 qualification/runtime-input/ilbm-verify && amiga-inspect qualification/runtime-input/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 + c64-structural: runs-on: ubuntu-latest steps: From ff4986a762a3d68b095296d87acbc864ce9c5b9d Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 09:54:57 +0200 Subject: [PATCH 089/116] M0.5: fix m68k payload container output --- .github/workflows/qualification.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/qualification.yml b/.github/workflows/qualification.yml index f25e076..ef00c35 100644 --- a/.github/workflows/qualification.yml +++ b/.github/workflows/qualification.yml @@ -34,10 +34,11 @@ jobs: run: | mkdir -p qualification/runtime-input docker run --rm \ - -v "$PWD:/workspace" \ + -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 qualification/runtime-input/ilbm-verify && amiga-inspect qualification/runtime-input/ilbm-verify' + sh -ec 'amiga-check && m68k-amigaos-gcc -m68000 -Os -Wall -Wextra -Werror -DRETROASSET_EMBEDDED_FIXTURES qualification/amiga/ilbm_verify.c -o /tmp/ilbm-verify && amiga-inspect /tmp/ilbm-verify && 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 From 87f9b5385c64996b4e8083f3dd96643305809416 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 09:57:08 +0200 Subject: [PATCH 090/116] M0.5: make Q1 payload output runner-writable --- .github/workflows/qualification.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/qualification.yml b/.github/workflows/qualification.yml index ef00c35..3cb1b7f 100644 --- a/.github/workflows/qualification.yml +++ b/.github/workflows/qualification.yml @@ -34,11 +34,12 @@ jobs: 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 && amiga-inspect /tmp/ilbm-verify && cp /tmp/ilbm-verify /output/ilbm-verify' + 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 From 5d13bae5c51bea5349612ea3d5f8608b9e5cbcb1 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 09:59:57 +0200 Subject: [PATCH 091/116] M0.5: run ILBM verifier in AROS m68k guest --- .github/workflows/qualification.yml | 45 +++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/.github/workflows/qualification.yml b/.github/workflows/qualification.yml index 3cb1b7f..8ec46fa 100644 --- a/.github/workflows/qualification.yml +++ b/.github/workflows/qualification.yml @@ -50,6 +50,51 @@ jobs: 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: From 80278ca9a523cafbf361722abaab2f6d9a311873 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 12:42:18 +0200 Subject: [PATCH 092/116] M0.5: record Amiga AROS m68k Q3 qualification PASS --- qualification/README.md | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/qualification/README.md b/qualification/README.md index 77d1981..9272731 100644 --- a/qualification/README.md +++ b/qualification/README.md @@ -8,8 +8,8 @@ qualification. | Target | Structural | Runtime | |---|---|---| | C64 PRG | PASS | PASS — VICE 3.7.1 + MEGA65 Open ROMs | -| Amiga ILBM uncompressed | round-trip + independent consumer PASS | Amiga runtime: not yet qualified | -| Amiga ILBM ByteRun1 | round-trip + independent consumer PASS | Amiga runtime: not yet qualified | +| 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. @@ -24,9 +24,29 @@ ImageMagick 6.9.12-98 (via ilbmtoppm) successfully reads both fixtures as - uncompressed ILBM: PASS - ByteRun1-compressed ILBM: PASS -This establishes independent file-format interoperability. It is intentionally -not labelled Amiga runtime qualification until the assets are loaded in the -documented Amiga emulator/runtime environment. +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. + +This is redistributable AROS/m68k runtime evidence. It is **not** Q4 classic +AmigaOS qualification and must not be reported as such. ## Planned runtimes From c8138f55a6f92b39cea5ad9d437203b411703280 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 14:50:18 +0200 Subject: [PATCH 093/116] M0.8: verify exact Amiga ILBM pixels and palette --- qualification/amiga/ilbm_verify.c | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/qualification/amiga/ilbm_verify.c b/qualification/amiga/ilbm_verify.c index 0ef49de..f725dba 100644 --- a/qualification/amiga/ilbm_verify.c +++ b/qualification/amiga/ilbm_verify.c @@ -41,46 +41,62 @@ static int decode_row(const unsigned char *src, size_t size, size_t *pos, } static int verify_buffer(const unsigned char *data, size_t file_size) { - const unsigned char *bmhd = NULL, *body = NULL; + 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; + 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 || !body) { return 0; } + 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 || !height || !planes || compression > 1) { return 0; } + 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 != stride * rows) { free(row); return 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; } } From 4719ee4043a5e182e37037db09497f0c6f1563cb Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 14:56:13 +0200 Subject: [PATCH 094/116] M0.8: record exact-content Amiga Q3 PASS --- qualification/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/qualification/README.md b/qualification/README.md index 9272731..b5cb0e2 100644 --- a/qualification/README.md +++ b/qualification/README.md @@ -43,7 +43,10 @@ both guest markers: - `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. +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. From 6ebc8d9aa939d51e1a830f24007b6d603dcbd108 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 20:39:26 +0200 Subject: [PATCH 095/116] M0.2: complete Amiga asset readiness validator --- src/retroasset/targets.py | 56 +++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/src/retroasset/targets.py b/src/retroasset/targets.py index 9f46cd8..d30f670 100644 --- a/src/retroasset/targets.py +++ b/src/retroasset/targets.py @@ -3,14 +3,27 @@ class ValidationResult: - def __init__(self, errors=(), warnings=()): + def __init__(self, errors=(), warnings=(), memory_bytes=None): self.errors = tuple(errors) self.warnings = tuple(warnings) + self.memory_bytes = memory_bytes @property def ok(self) -> 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] = [] @@ -24,9 +37,7 @@ def _validate_common(asset: AssetManifest, profile: TargetProfile) -> Validation 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" - ) + 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): @@ -41,8 +52,43 @@ def _validate_common(asset: AssetManifest, profile: TargetProfile) -> Validation 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: - return _validate_common(asset, get_profile(asset.target)) + 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} From 09739a474706e10e8e018f10f4bd7028c8b2c514 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 21:01:50 +0200 Subject: [PATCH 096/116] M0.2: test Amiga target readiness and metadata --- tests/test_targets.py | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/tests/test_targets.py b/tests/test_targets.py index 499ada1..33200c1 100644 --- a/tests/test_targets.py +++ b/tests/test_targets.py @@ -4,14 +4,37 @@ from retroasset.targets import validate -class AmigaOCSTests(unittest.TestCase): - def test_valid_manifest(self): - result = validate(AssetManifest("hero", "amiga-ocs", "sprite", 32, 48, 16)) - self.assertTrue(result.ok) - - def test_too_many_colors(self): - result = validate(AssetManifest("hero", "amiga-ocs", "bitmap", 320, 256, 64)) - self.assertFalse(result.ok) +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__": From bb7efafba9c4c417b650c167f1ff375533731c2f Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 21:08:48 +0200 Subject: [PATCH 097/116] OCI: add Alpine runtime image --- Dockerfile | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1937706 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +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 + +WORKDIR /work +USER retroasset +ENTRYPOINT ["retroasset"] +CMD ["--help"] From 61808ff5a1898dd3601657ba3ba03122a007859b Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 21:14:29 +0200 Subject: [PATCH 098/116] OCI: smoke-test Alpine image in CI --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f3a44a..57a2b0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,3 +21,17 @@ jobs: - 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 From bec426322d3972bcd489e2b02212a8cca2a64803 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 21:14:45 +0200 Subject: [PATCH 099/116] OCI: add multiarch GHCR publishing --- .github/workflows/oci.yml | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/oci.yml diff --git a/.github/workflows/oci.yml b/.github/workflows/oci.yml new file mode 100644 index 0000000..7bcf404 --- /dev/null +++ b/.github/workflows/oci.yml @@ -0,0 +1,40 @@ +name: OCI + +on: + push: + branches: [main] + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + publish: + 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 }} From 5cb49d3920dfbf22f5861b1d14d4c295febc4b19 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 21:19:04 +0200 Subject: [PATCH 100/116] OCI: document container contract and distribution --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 044ca14..f34ee12 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,32 @@ 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:edge \ + 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. From 34270026971577ba9233108409228934fc4367b0 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 22:11:39 +0200 Subject: [PATCH 101/116] M0.5: add ANSI character import/export CLI --- src/retroasset/cli.py | 45 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/retroasset/cli.py b/src/retroasset/cli.py index 35c30d2..731ed9b 100644 --- a/src/retroasset/cli.py +++ b/src/retroasset/cli.py @@ -3,6 +3,8 @@ 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 .exporters import export_asm, export_c, export_raw_planar from .indexed import IndexedBitmap from .model import AssetManifest @@ -44,6 +46,18 @@ def main() -> int: 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",)) + char_import.add_argument("--columns", type=int, required=True) + char_import.add_argument("--rows", type=int, required=True) + char_import.add_argument("-o", "--output", type=Path, required=True) + + 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",)) + char_export.add_argument("-o", "--output", type=Path, required=True) + args = parser.parse_args() if args.command == "targets": @@ -60,6 +74,37 @@ def main() -> int: print(json.dumps(profile.__dict__, indent=2)) return 0 + if args.command == "char-import": + payload = args.source.read_bytes() + asset = decode_ans(payload, args.columns, args.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 + ], + } + args.output.write_text(json.dumps(data, 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")) + asset = CharacterAsset( + columns=data["columns"], + rows=data["rows"], + cells=tuple(CharacterCell(**cell) for cell in data["cells"]), + ) + args.output.write_bytes(encode_ans(asset)) + 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") From 895f7774cf6864fdf98a811bba02b33b42e081ac Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 22:11:51 +0200 Subject: [PATCH 102/116] M0.5: test ANSI CLI round trip --- tests/test_character_cli.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/test_character_cli.py diff --git a/tests/test_character_cli.py b/tests/test_character_cli.py new file mode 100644 index 0000000..2dd7425 --- /dev/null +++ b/tests/test_character_cli.py @@ -0,0 +1,34 @@ +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) + + +if __name__ == "__main__": + unittest.main() From f57a509f8592643f66ba2432943b0d193e8f83d6 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 22:17:17 +0200 Subject: [PATCH 103/116] M0.5: preserve SAUCE metadata in character CLI --- src/retroasset/cli.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/retroasset/cli.py b/src/retroasset/cli.py index 731ed9b..249fd30 100644 --- a/src/retroasset/cli.py +++ b/src/retroasset/cli.py @@ -9,6 +9,7 @@ from .indexed import IndexedBitmap from .model import AssetManifest from .profiles import PROFILES +from .sauce import Sauce, decode_sauce, encode_sauce from .targets import validate @@ -49,8 +50,8 @@ def main() -> int: 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",)) - char_import.add_argument("--columns", type=int, required=True) - char_import.add_argument("--rows", type=int, required=True) + 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_export = sub.add_parser("char-export", help="export a native character-art asset") @@ -75,8 +76,13 @@ def main() -> int: return 0 if args.command == "char-import": - payload = args.source.read_bytes() - asset = decode_ans(payload, args.columns, args.rows) + 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, @@ -90,6 +96,17 @@ def main() -> int: 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, + } args.output.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") print(f"WROTE {args.output}") return 0 @@ -101,7 +118,11 @@ def main() -> int: rows=data["rows"], cells=tuple(CharacterCell(**cell) for cell in data["cells"]), ) - args.output.write_bytes(encode_ans(asset)) + payload = encode_ans(asset) + if "sauce" in data: + meta = Sauce(**data["sauce"]) + payload += encode_sauce(meta, len(payload)) + args.output.write_bytes(payload) print(f"WROTE {args.output}") return 0 From 953ec075b3f167aef2a4ec2cbed4bca7c917c7f7 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 22:17:25 +0200 Subject: [PATCH 104/116] M0.5: test SAUCE CLI preservation --- tests/test_character_cli.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_character_cli.py b/tests/test_character_cli.py index 2dd7425..78e58bd 100644 --- a/tests/test_character_cli.py +++ b/tests/test_character_cli.py @@ -30,5 +30,37 @@ def test_ansi_json_round_trip(self): 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) + + if __name__ == "__main__": unittest.main() From 910156e10c8592aa70fb5ccfd4977dff986edb7e Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 22:22:44 +0200 Subject: [PATCH 105/116] M0.6: expose native C64 screen exports in CLI --- src/retroasset/cli.py | 43 +++++++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/src/retroasset/cli.py b/src/retroasset/cli.py index 249fd30..1a59624 100644 --- a/src/retroasset/cli.py +++ b/src/retroasset/cli.py @@ -5,6 +5,8 @@ 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 .petscii import C64Screen, PetsciiCell, export_color_ram, export_screen_ram from .exporters import export_asm, export_c, export_raw_planar from .indexed import IndexedBitmap from .model import AssetManifest @@ -56,7 +58,8 @@ def main() -> int: 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",)) + 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) args = parser.parse_args() @@ -113,15 +116,35 @@ def main() -> int: if args.command == "char-export": data = json.loads(args.source.read_text(encoding="utf-8")) - 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)) + 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) print(f"WROTE {args.output}") return 0 From 3ff012fd60c57ceabcce3ed81e0034a8e46c2de3 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 22:22:53 +0200 Subject: [PATCH 106/116] M0.6: test C64 native CLI exports --- tests/test_character_cli.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_character_cli.py b/tests/test_character_cli.py index 78e58bd..386db95 100644 --- a/tests/test_character_cli.py +++ b/tests/test_character_cli.py @@ -62,5 +62,27 @@ def test_ansi_sauce_round_trip_and_geometry(self): 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) + + if __name__ == "__main__": unittest.main() From 4b657d3e0b41434bd2253b7bd7c5a1f85ff8b8ab Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 22:35:35 +0200 Subject: [PATCH 107/116] M0.6: add PETSCII stream import CLI --- src/retroasset/cli.py | 75 +++++++++++++++++++++++++------------------ 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/src/retroasset/cli.py b/src/retroasset/cli.py index 1a59624..510b2d4 100644 --- a/src/retroasset/cli.py +++ b/src/retroasset/cli.py @@ -6,7 +6,7 @@ from .ansi import decode_ans, encode_ans from .character import CharacterAsset, CharacterCell from .c64prg import export_viewer_prg -from .petscii import C64Screen, PetsciiCell, export_color_ram, export_screen_ram +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 @@ -51,7 +51,7 @@ def main() -> int: 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",)) + 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) @@ -79,36 +79,49 @@ def main() -> int: return 0 if args.command == "char-import": - 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, + 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, } - 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") print(f"WROTE {args.output}") From 8be66b92e423469cec7e1060a35bc9979f807176 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 22:35:43 +0200 Subject: [PATCH 108/116] M0.6: test PETSCII control-state import --- tests/test_character_cli.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_character_cli.py b/tests/test_character_cli.py index 386db95..3183b12 100644 --- a/tests/test_character_cli.py +++ b/tests/test_character_cli.py @@ -84,5 +84,20 @@ def test_c64_native_exports(self): 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}) + + if __name__ == "__main__": unittest.main() From 9694b054848f63f26da7cec08971ed9f4e44c760 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 02:16:22 +0200 Subject: [PATCH 109/116] M0.6: add machine-readable conversion reports --- src/retroasset/conversion.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/retroasset/conversion.py 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) From 299e3b32d55d15a07b38c695504101e0643b55dc Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 02:16:31 +0200 Subject: [PATCH 110/116] M0.6: expose character conversion reports in CLI --- src/retroasset/cli.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/retroasset/cli.py b/src/retroasset/cli.py index 510b2d4..c222cc5 100644 --- a/src/retroasset/cli.py +++ b/src/retroasset/cli.py @@ -6,6 +6,7 @@ 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 @@ -55,12 +56,14 @@ def main() -> int: 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() @@ -124,6 +127,9 @@ def main() -> int: ], } 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 @@ -159,6 +165,9 @@ def main() -> int: 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 From 3bd2323604ccfc5627eeac94887b3af147a967c2 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 02:16:41 +0200 Subject: [PATCH 111/116] M0.6: test machine-readable conversion report --- tests/test_character_cli.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_character_cli.py b/tests/test_character_cli.py index 3183b12..830a411 100644 --- a/tests/test_character_cli.py +++ b/tests/test_character_cli.py @@ -99,5 +99,27 @@ def test_petscii_stream_import_preserves_color_and_reverse(self): 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() From be5b8a3f16a123c1a52ee8a5d237a587e397159d Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 07:26:19 +0200 Subject: [PATCH 112/116] OCI: restrict publishing to main and release tags --- .github/workflows/oci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/oci.yml b/.github/workflows/oci.yml index 7bcf404..29064dd 100644 --- a/.github/workflows/oci.yml +++ b/.github/workflows/oci.yml @@ -12,6 +12,8 @@ permissions: 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 From 1794558e84437764a3fa8d341f5fca2d17c53dc6 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 07:26:29 +0200 Subject: [PATCH 113/116] OCI: create stable work directories --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 1937706..37219ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,9 @@ 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"] From d5904ed68c713fcd8c364c2834515387e2b23a9f Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 07:26:41 +0200 Subject: [PATCH 114/116] OCI: add minimal Docker build context --- .dockerignore | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .dockerignore 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 From d83a16bc245b894eb23d1fa48c80654d554c0754 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 10:54:20 +0200 Subject: [PATCH 115/116] Release: set M0 version to 0.1.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 421dc67..25dbda6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "retroasset" -version = "0.0.1" +version = "0.1.0" description = "Compiler and validator for genuine retro-computing assets" requires-python = ">=3.10" license = {text = "MIT"} From 2c52cde8192964d87415f860348d67a7d1313099 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 10:54:32 +0200 Subject: [PATCH 116/116] Docs: mark RetroAsset 0.1.0 release-ready --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f34ee12..3d62565 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ RetroAsset turns generated, imported or hand-authored source material into asset ## Status -M0 foundation is under development. The first executable target is **Amiga OCS**. +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: @@ -75,7 +77,7 @@ Example after the image has been published: docker run --rm \ -v "$PWD/examples:/work/input:ro" \ -v "$PWD/output:/work/output" \ - ghcr.io/ploos-as/retroasset:edge \ + 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 ```