amos-abk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ # WTFPL + Warranty
2
+
3
+ Licensed under the WTFPL with one additional clause:
4
+
5
+ 1. Don't blame me.
6
+
7
+ Do whatever the fuck you want, just don't blame me.
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: amos_abk
3
+ Version: 0.1.0
4
+ Summary: Load AMOS banks in Python
5
+ Author-email: Gareth Davidson <gaz@bitplane.net>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE.md
9
+ Requires-Dist: Pillow
10
+ Requires-Dist: pre-commit ; extra == "dev"
11
+ Requires-Dist: pytest ; extra == "dev"
12
+ Requires-Dist: coverage ; extra == "dev"
13
+ Requires-Dist: pytest-cov ; extra == "dev"
14
+ Requires-Dist: build ; extra == "dev"
15
+ Requires-Dist: twine ; extra == "dev"
16
+ Requires-Dist: ruff ; extra == "dev"
17
+ Requires-Dist: pydoc-markdown ; extra == "dev"
18
+ Project-URL: Documentation, https://bitplane.net/dev/python/amos-abk/pydoc
19
+ Project-URL: Homepage, https://bitplane.net/dev/python/amos-abk
20
+ Project-URL: Repository, https://github.com/bitplane/amos-abk
21
+ Provides-Extra: dev
22
+
23
+ # amos-abk
24
+
25
+ A Python library for loading AMOS `.abk` bank files from the Amiga.
26
+
27
+ Parses the container format and three image bank types:
28
+
29
+ * **Sprites** — hardware sprite images with hotspot and transparency
30
+ * **Icons** — software bob images (same format as sprites)
31
+ * **Pac.Pic.** — packed full-screen pictures with three-stream RLE compression
32
+
33
+ Non-image banks (Music, Samples, Amal, Datas) are loaded as raw `DataBank`
34
+ objects.
35
+
36
+ ## Links
37
+
38
+ * [🏠 home](https://bitplane.net/dev/python/amos-abk)
39
+ * [📚 pydoc](https://bitplane.net/dev/python/amos-abk/pydoc)
40
+ * [🐍 pypi](https://pypi.org/project/amos-abk)
41
+ * [🐱 github](https://github.com/bitplane/amos-abk)
42
+
43
+ ## Install
44
+
45
+ ```
46
+ pip install amos-abk
47
+ ```
48
+
49
+ ## Library usage
50
+
51
+ ```python
52
+ from amos_abk import load, parse_sprites, parse_packed_picture
53
+
54
+ # Load a bank file
55
+ abk = load("sprites.abk")
56
+ print(abk) # AbkFile([Sprites])
57
+
58
+ # Parse sprites and convert to PIL Images
59
+ sprites = parse_sprites(abk.banks[0].data)
60
+ for i, sprite in enumerate(sprites):
61
+ sprite.to_image().save(f"sprite_{i}.png")
62
+
63
+ # Parse a packed picture
64
+ abk = load("picture.abk")
65
+ pic = parse_packed_picture(abk.banks[0].data)
66
+ pic.to_image().save("picture.png")
67
+
68
+ # Access raw data
69
+ print(pic.width, pic.height, pic.num_planes)
70
+ print(pic.palette[:4]) # first 4 RGB tuples
71
+ ```
72
+
73
+ ## Pillow plugin
74
+
75
+ `import amos_abk` registers a Pillow plugin, so you can open `.abk` files
76
+ directly:
77
+
78
+ ```python
79
+ from PIL import Image
80
+ import amos_abk
81
+
82
+ img = Image.open("sprites.abk")
83
+ img.save("first_sprite.png")
84
+
85
+ # Multi-frame: seek through all images in the file
86
+ for i in range(img.n_frames):
87
+ img.seek(i)
88
+ img.save(f"frame_{i}.png")
89
+
90
+ # The parsed AbkFile is available in metadata
91
+ abk = img.info["abk"]
92
+ print(abk.banks[0].name)
93
+ ```
94
+
95
+ ## Extract all images
96
+
97
+ The `images()` function extracts every image from every bank in a file:
98
+
99
+ ```python
100
+ import amos_abk
101
+
102
+ # From a file path
103
+ imgs = amos_abk.images("sprites.abk")
104
+ for i, img in enumerate(imgs):
105
+ img.save(f"image_{i}.png")
106
+
107
+ # Or from an already-loaded AbkFile
108
+ abk = amos_abk.load("sprites.abk")
109
+ imgs = amos_abk.images(abk)
110
+ ```
111
+
112
+ ## Format overview
113
+
114
+ AMOS was a game-creation environment for the Amiga, released in 1990. It stored
115
+ graphics and other resources in `.abk` memory bank files.
116
+
117
+ ### Bank container
118
+
119
+ Each `.abk` file contains one or more banks, either bare (AmSp/AmIc magic) or
120
+ wrapped in an AmBk header with a bank number, name, and memory type.
121
+
122
+ ### Sprite and icon banks
123
+
124
+ Sprites and icons share the same format: a count, per-image headers (dimensions,
125
+ bitplane count, hotspot), interleaved planar bitmap data, and a shared 32-entry
126
+ 12-bit Amiga palette.
127
+
128
+ ### Pac.Pic. banks
129
+
130
+ Packed pictures are compressed full-screen Amiga images. They have two layers:
131
+
132
+ 1. **Screen header** (magic `0x12031990`) — dimensions, display window, flags,
133
+ and palette
134
+ 2. **Packed bitmap** (magic `0x06071963`) — three-stream RLE compression where
135
+ POINTS controls RLEDATA, and RLEDATA controls PICDATA
136
+
137
+ ## License
138
+
139
+ WTFPL: do as you like, but don't blame me if it banks your sprites.
140
+
@@ -0,0 +1,117 @@
1
+ # amos-abk
2
+
3
+ A Python library for loading AMOS `.abk` bank files from the Amiga.
4
+
5
+ Parses the container format and three image bank types:
6
+
7
+ * **Sprites** — hardware sprite images with hotspot and transparency
8
+ * **Icons** — software bob images (same format as sprites)
9
+ * **Pac.Pic.** — packed full-screen pictures with three-stream RLE compression
10
+
11
+ Non-image banks (Music, Samples, Amal, Datas) are loaded as raw `DataBank`
12
+ objects.
13
+
14
+ ## Links
15
+
16
+ * [🏠 home](https://bitplane.net/dev/python/amos-abk)
17
+ * [📚 pydoc](https://bitplane.net/dev/python/amos-abk/pydoc)
18
+ * [🐍 pypi](https://pypi.org/project/amos-abk)
19
+ * [🐱 github](https://github.com/bitplane/amos-abk)
20
+
21
+ ## Install
22
+
23
+ ```
24
+ pip install amos-abk
25
+ ```
26
+
27
+ ## Library usage
28
+
29
+ ```python
30
+ from amos_abk import load, parse_sprites, parse_packed_picture
31
+
32
+ # Load a bank file
33
+ abk = load("sprites.abk")
34
+ print(abk) # AbkFile([Sprites])
35
+
36
+ # Parse sprites and convert to PIL Images
37
+ sprites = parse_sprites(abk.banks[0].data)
38
+ for i, sprite in enumerate(sprites):
39
+ sprite.to_image().save(f"sprite_{i}.png")
40
+
41
+ # Parse a packed picture
42
+ abk = load("picture.abk")
43
+ pic = parse_packed_picture(abk.banks[0].data)
44
+ pic.to_image().save("picture.png")
45
+
46
+ # Access raw data
47
+ print(pic.width, pic.height, pic.num_planes)
48
+ print(pic.palette[:4]) # first 4 RGB tuples
49
+ ```
50
+
51
+ ## Pillow plugin
52
+
53
+ `import amos_abk` registers a Pillow plugin, so you can open `.abk` files
54
+ directly:
55
+
56
+ ```python
57
+ from PIL import Image
58
+ import amos_abk
59
+
60
+ img = Image.open("sprites.abk")
61
+ img.save("first_sprite.png")
62
+
63
+ # Multi-frame: seek through all images in the file
64
+ for i in range(img.n_frames):
65
+ img.seek(i)
66
+ img.save(f"frame_{i}.png")
67
+
68
+ # The parsed AbkFile is available in metadata
69
+ abk = img.info["abk"]
70
+ print(abk.banks[0].name)
71
+ ```
72
+
73
+ ## Extract all images
74
+
75
+ The `images()` function extracts every image from every bank in a file:
76
+
77
+ ```python
78
+ import amos_abk
79
+
80
+ # From a file path
81
+ imgs = amos_abk.images("sprites.abk")
82
+ for i, img in enumerate(imgs):
83
+ img.save(f"image_{i}.png")
84
+
85
+ # Or from an already-loaded AbkFile
86
+ abk = amos_abk.load("sprites.abk")
87
+ imgs = amos_abk.images(abk)
88
+ ```
89
+
90
+ ## Format overview
91
+
92
+ AMOS was a game-creation environment for the Amiga, released in 1990. It stored
93
+ graphics and other resources in `.abk` memory bank files.
94
+
95
+ ### Bank container
96
+
97
+ Each `.abk` file contains one or more banks, either bare (AmSp/AmIc magic) or
98
+ wrapped in an AmBk header with a bank number, name, and memory type.
99
+
100
+ ### Sprite and icon banks
101
+
102
+ Sprites and icons share the same format: a count, per-image headers (dimensions,
103
+ bitplane count, hotspot), interleaved planar bitmap data, and a shared 32-entry
104
+ 12-bit Amiga palette.
105
+
106
+ ### Pac.Pic. banks
107
+
108
+ Packed pictures are compressed full-screen Amiga images. They have two layers:
109
+
110
+ 1. **Screen header** (magic `0x12031990`) — dimensions, display window, flags,
111
+ and palette
112
+ 2. **Packed bitmap** (magic `0x06071963`) — three-stream RLE compression where
113
+ POINTS controls RLEDATA, and RLEDATA controls PICDATA
114
+
115
+ ## License
116
+
117
+ WTFPL: do as you like, but don't blame me if it banks your sprites.
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "amos_abk"
3
+ description = "Load AMOS banks in Python"
4
+ version = "0.1.0"
5
+ authors = [
6
+ { name = "Gareth Davidson", email = "gaz@bitplane.net" }
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+
11
+ dependencies = [
12
+ "Pillow",
13
+ ]
14
+
15
+ [project.urls]
16
+ Homepage = "https://bitplane.net/dev/python/amos-abk"
17
+ Documentation = "https://bitplane.net/dev/python/amos-abk/pydoc"
18
+ Repository = "https://github.com/bitplane/amos-abk"
19
+
20
+ [project.optional-dependencies]
21
+ dev = [
22
+ "pre-commit",
23
+ "pytest",
24
+ "coverage",
25
+ "pytest-cov",
26
+ "build",
27
+ "twine",
28
+ "ruff",
29
+ "pydoc-markdown",
30
+ ]
31
+
32
+ [build-system]
33
+ build-backend = "flit_core.buildapi"
34
+ requires = ["flit_core >=3.2,<4"]
35
+
36
+ [tool.ruff]
37
+ line-length = 120
38
+ target-version = "py310"
39
+
40
+ [tool.ruff.format]
41
+ docstring-code-format = true
@@ -0,0 +1,15 @@
1
+ from amos_abk.bank import AbkFile, DataBank, load
2
+ from amos_abk.pacpic import PackedPicture, parse_packed_picture
3
+ from amos_abk.pillow_plugin import images
4
+ from amos_abk.sprite import Sprite, parse_sprites
5
+
6
+ __all__ = [
7
+ "AbkFile",
8
+ "DataBank",
9
+ "PackedPicture",
10
+ "Sprite",
11
+ "images",
12
+ "load",
13
+ "parse_packed_picture",
14
+ "parse_sprites",
15
+ ]
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import struct
4
+ from dataclasses import dataclass
5
+ from io import BytesIO
6
+ from pathlib import Path
7
+
8
+ # Magic bytes for supported bank types
9
+ MAGIC_AMBK = b"AmBk"
10
+ MAGIC_AMSP = b"AmSp"
11
+ MAGIC_AMIC = b"AmIc"
12
+
13
+ # Known but unsupported magic bytes
14
+ MAGIC_AMBS = b"AmBs"
15
+ MAGIC_CRM2 = b"CrM2"
16
+ MAGIC_IMP = b"IMP!"
17
+ MAGIC_PPBK = b"PPbk"
18
+
19
+ # Conventional bank numbers for bare sprite/icon files
20
+ SPRITE_BANK_NUMBER = 1
21
+ ICON_BANK_NUMBER = 2
22
+
23
+ # AmBk header: magic(4) + bank_num(2) + mem_type(2) + length(4) + name(8) = 20
24
+ AMBK_HEADER_SIZE = 20
25
+ # The length field counts from byte 12 onward (name + data), so
26
+ # the fixed prefix not counted is magic(4) + bank_num(2) + mem_type(2) + length(4) = 12
27
+ AMBK_PREFIX_SIZE = 12
28
+
29
+ LENGTH_MASK = 0x0FFFFFFF
30
+ FLAGS_MASK = 0xF0000000
31
+
32
+
33
+ @dataclass
34
+ class DataBank:
35
+ """A single AMOS memory bank."""
36
+
37
+ number: int
38
+ name: str
39
+ memory_type: int
40
+ flags: int
41
+ data: bytes
42
+
43
+
44
+ class AbkFile:
45
+ """An AMOS bank file containing one or more banks."""
46
+
47
+ def __init__(self, banks: list[DataBank]):
48
+ self.banks = banks
49
+
50
+ def __repr__(self) -> str:
51
+ bank_names = ", ".join(b.name for b in self.banks)
52
+ return f"AbkFile([{bank_names}])"
53
+
54
+
55
+ def _read_ambk_bank(stream: BytesIO) -> DataBank:
56
+ """Read a single AmBk-wrapped bank from the stream."""
57
+ header = stream.read(AMBK_HEADER_SIZE)
58
+ if len(header) < AMBK_HEADER_SIZE:
59
+ raise ValueError(f"Truncated AmBk header: expected {AMBK_HEADER_SIZE} bytes, got {len(header)}")
60
+
61
+ magic = header[0:4]
62
+ if magic != MAGIC_AMBK:
63
+ raise ValueError(f"Expected AmBk magic, got {magic!r}")
64
+
65
+ bank_num, mem_type, length_raw = struct.unpack_from(">HHI", header, 4)
66
+ name = header[12:20].decode("ascii", errors="replace").rstrip()
67
+
68
+ flags = length_raw & FLAGS_MASK
69
+ data_length = (length_raw & LENGTH_MASK) - 8 # subtract name field (8 bytes)
70
+
71
+ if data_length < 0:
72
+ raise ValueError(f"Invalid bank length: {length_raw:#x} (data_length={data_length})")
73
+
74
+ data = stream.read(data_length)
75
+ if len(data) < data_length:
76
+ raise ValueError(f"Truncated bank data for '{name}': expected {data_length} bytes, got {len(data)}")
77
+
78
+ return DataBank(
79
+ number=bank_num,
80
+ name=name,
81
+ memory_type=mem_type,
82
+ flags=flags,
83
+ data=data,
84
+ )
85
+
86
+
87
+ def _read_bare_bank(stream: BytesIO, magic: bytes) -> DataBank:
88
+ """Read a bare AmSp or AmIc bank (no AmBk wrapper)."""
89
+ data = stream.read()
90
+
91
+ if magic == MAGIC_AMSP:
92
+ return DataBank(number=SPRITE_BANK_NUMBER, name="Sprites", memory_type=0, flags=0, data=data)
93
+
94
+ return DataBank(number=ICON_BANK_NUMBER, name="Icons", memory_type=0, flags=0, data=data)
95
+
96
+
97
+ def load(source: str | Path | BytesIO) -> AbkFile:
98
+ """Load an AMOS bank file.
99
+
100
+ Args:
101
+ source: file path (str or Path) or a readable BytesIO stream.
102
+
103
+ Returns:
104
+ An AbkFile containing the parsed banks.
105
+ """
106
+ if isinstance(source, (str, Path)):
107
+ with open(source, "rb") as f:
108
+ return _load_stream(BytesIO(f.read()))
109
+
110
+ return _load_stream(source)
111
+
112
+
113
+ def _load_stream(stream: BytesIO) -> AbkFile:
114
+ """Parse banks from a byte stream."""
115
+ magic = stream.read(4)
116
+ if len(magic) < 4:
117
+ raise ValueError(f"File too short: expected at least 4 bytes, got {len(magic)}")
118
+
119
+ if magic in (MAGIC_AMBS, MAGIC_CRM2, MAGIC_IMP, MAGIC_PPBK):
120
+ kind = magic.decode("ascii", errors="replace")
121
+ raise ValueError(f"Unsupported format: {kind} (not a bank file)")
122
+
123
+ if magic in (MAGIC_AMSP, MAGIC_AMIC):
124
+ bank = _read_bare_bank(stream, magic)
125
+ return AbkFile([bank])
126
+
127
+ if magic == MAGIC_AMBK:
128
+ stream.seek(0)
129
+ banks = []
130
+ while True:
131
+ banks.append(_read_ambk_bank(stream))
132
+ next_magic = stream.read(4)
133
+ if len(next_magic) < 4:
134
+ break
135
+ if next_magic != MAGIC_AMBK:
136
+ raise ValueError(f"Expected AmBk magic at offset {stream.tell() - 4}, got {next_magic!r}")
137
+ stream.seek(stream.tell() - 4)
138
+ return AbkFile(banks)
139
+
140
+ raise ValueError(f"Unknown magic: {magic!r}")
@@ -0,0 +1,213 @@
1
+ """Parser for AMOS Pac.Pic. (Packed Picture) banks.
2
+
3
+ A Pac.Pic. bank contains a compressed full-screen Amiga picture with two layers:
4
+ 1. A screen header (90 bytes, magic 0x12031990) with dimensions and palette
5
+ 2. A packed bitmap (magic 0x06071963) using three-stream RLE compression
6
+
7
+ Reference: https://www.exotica.org.uk/wiki/AMOS_Pac.Pic._format
8
+ Reference: https://github.com/kyz/amostools (dumpamos.c)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import struct
14
+ from dataclasses import dataclass
15
+
16
+ from PIL import Image
17
+
18
+ from amos_abk.planar import indexed_to_rgb, indexed_to_rgba, planar_to_indexed
19
+ from amos_abk.sprite import _parse_palette
20
+
21
+ SCREEN_HEADER_MAGIC = 0x12031990
22
+ SCREEN_HEADER_MAGIC_ALT1 = 0x00031990
23
+ SCREEN_HEADER_MAGIC_ALT2 = 0x12030090
24
+ PACKED_BITMAP_MAGIC = 0x06071963
25
+ PACKED_BITMAP_MAGIC_ALT = 0x06070063
26
+
27
+ SCREEN_HEADER_SIZE = 90
28
+
29
+
30
+ @dataclass
31
+ class PackedPicture:
32
+ """A decompressed packed picture from an AMOS Pac.Pic. bank."""
33
+
34
+ width: int
35
+ height: int
36
+ num_planes: int
37
+ display_width: int
38
+ display_height: int
39
+ flags: int
40
+ palette: list[tuple[int, int, int]]
41
+ planes: list[bytes]
42
+
43
+ @property
44
+ def num_colors(self) -> int:
45
+ return 1 << self.num_planes
46
+
47
+ def to_indexed(self) -> bytes:
48
+ """Convert planar data to chunky indexed pixels (1 byte per pixel)."""
49
+ return planar_to_indexed(self.planes, self.width, self.height)
50
+
51
+ def to_rgb(self) -> bytes:
52
+ """Convert to RGB pixel data (3 bytes per pixel)."""
53
+ return indexed_to_rgb(self.to_indexed(), self.palette)
54
+
55
+ def to_rgba(self) -> bytes:
56
+ """Convert to RGBA pixel data (4 bytes per pixel).
57
+
58
+ Palette index 0 is treated as transparent.
59
+ """
60
+ return indexed_to_rgba(self.to_indexed(), self.palette)
61
+
62
+ def to_image(self) -> Image.Image:
63
+ """Convert to a PIL Image (RGB)."""
64
+ return Image.frombytes("RGB", (self.width, self.height), self.to_rgb())
65
+
66
+
67
+ def _decompress_bitmap(data: bytes, packed_offset: int) -> tuple[list[bytes], int, int, int]:
68
+ """Decompress a packed bitmap from three interleaved RLE streams.
69
+
70
+ The three streams are:
71
+ - PICDATA: the actual pixel bytes
72
+ - RLEDATA: 1 bit per output byte; bit=1 means read new PICDATA byte, 0=repeat
73
+ - POINTS: 1 bit per RLEDATA byte; bit=1 means read new RLEDATA byte, 0=repeat
74
+
75
+ Args:
76
+ data: raw data containing the packed bitmap.
77
+ packed_offset: offset to the packed bitmap header (0x06071963 magic).
78
+
79
+ Returns:
80
+ (planes, width_pixels, height, num_planes) where planes is a list of
81
+ bytes objects, one per bitplane, in row-major order.
82
+ """
83
+ magic = struct.unpack_from(">I", data, packed_offset)[0]
84
+ if magic not in (PACKED_BITMAP_MAGIC, PACKED_BITMAP_MAGIC_ALT):
85
+ raise ValueError(f"Bad packed bitmap magic: 0x{magic:08x}")
86
+
87
+ (x_offset, y_offset, width_bytes, lumps, lump_height, num_planes, rledata_offset, points_offset) = (
88
+ struct.unpack_from(">HHHHHHII", data, packed_offset + 4)
89
+ )
90
+
91
+ height = lumps * lump_height
92
+ width_pixels = width_bytes * 8
93
+
94
+ # Stream positions (offsets are relative to packed bitmap header start)
95
+ picdata = memoryview(data)[packed_offset + 24 :]
96
+ rledata = memoryview(data)[packed_offset + rledata_offset :]
97
+ points = memoryview(data)[packed_offset + points_offset :]
98
+
99
+ # Initialise streams: first picdata and rledata bytes are read unconditionally
100
+ pic_pos = 0
101
+ rle_pos = 0
102
+ pts_pos = 0
103
+
104
+ picbyte = picdata[pic_pos]
105
+ pic_pos += 1
106
+
107
+ rlebyte = rledata[rle_pos]
108
+ rle_pos += 1
109
+
110
+ # Check first points bit (MSB); if set, read another rledata byte
111
+ rbit = 7 # current bit position in rlebyte (MSB first)
112
+ rrbit = 6 # current bit position in points byte (bit 7 already checked)
113
+ if points[pts_pos] & 0x80:
114
+ rlebyte = rledata[rle_pos]
115
+ rle_pos += 1
116
+
117
+ # Allocate output plane buffers
118
+ plane_size = width_bytes * height
119
+ plane_bufs = [bytearray(plane_size) for _ in range(num_planes)]
120
+
121
+ # Decompress: iterate in plane/lump/column/row order
122
+ for plane_idx in range(num_planes):
123
+ for lump_idx in range(lumps):
124
+ for col in range(width_bytes):
125
+ for row in range(lump_height):
126
+ # Check rlebyte bit to decide whether to read new picdata
127
+ if rlebyte & (1 << rbit):
128
+ picbyte = picdata[pic_pos]
129
+ pic_pos += 1
130
+ rbit -= 1
131
+
132
+ # Write output byte
133
+ screen_row = lump_idx * lump_height + row
134
+ plane_bufs[plane_idx][screen_row * width_bytes + col] = picbyte
135
+
136
+ # When rlebyte is exhausted (8 bits used), check points
137
+ if rbit < 0:
138
+ rbit = 7
139
+ if points[pts_pos] & (1 << rrbit):
140
+ rlebyte = rledata[rle_pos]
141
+ rle_pos += 1
142
+ rrbit -= 1
143
+ if rrbit < 0:
144
+ rrbit = 7
145
+ pts_pos += 1
146
+
147
+ return [bytes(p) for p in plane_bufs], width_pixels, height, num_planes
148
+
149
+
150
+ def parse_packed_picture(data: bytes) -> PackedPicture:
151
+ """Parse a Pac.Pic. bank's raw data.
152
+
153
+ Args:
154
+ data: the raw bank data (after AmBk header). May start with either
155
+ a screen header (0x12031990) or a bare packed bitmap (0x06071963).
156
+
157
+ Returns:
158
+ A PackedPicture with decompressed bitplane data.
159
+ """
160
+ if len(data) < 24:
161
+ raise ValueError(f"Pac.Pic. data too short: {len(data)} bytes")
162
+
163
+ magic = struct.unpack_from(">I", data, 0)[0]
164
+
165
+ if magic in (SCREEN_HEADER_MAGIC, SCREEN_HEADER_MAGIC_ALT1, SCREEN_HEADER_MAGIC_ALT2):
166
+ if len(data) < SCREEN_HEADER_SIZE + 24:
167
+ raise ValueError(f"Pac.Pic. data too short for screen header: {len(data)} bytes")
168
+
169
+ screen_width, screen_height, flags, _unknown = struct.unpack_from(">HHHH", data, 4)
170
+ display_width, display_height = struct.unpack_from(">HH", data, 12)
171
+ num_planes = struct.unpack_from(">H", data, 24)[0]
172
+ palette = _parse_palette(data, 26)
173
+ packed_offset = SCREEN_HEADER_SIZE
174
+
175
+ elif magic in (PACKED_BITMAP_MAGIC, PACKED_BITMAP_MAGIC_ALT):
176
+ # Bare packed bitmap without screen header
177
+ packed_offset = 0
178
+ num_planes_from_header = struct.unpack_from(">H", data, 14)[0]
179
+ palette = [(((i & 0xF) * 0x111 >> 8) & 0xF) * 17 for i in range(32)]
180
+ # Generate a greyscale fallback palette
181
+ palette = [((i * 17) % 256, (i * 17) % 256, (i * 17) % 256) for i in range(32)]
182
+ screen_width = 0
183
+ screen_height = 0
184
+ display_width = 0
185
+ display_height = 0
186
+ flags = 0
187
+ num_planes = num_planes_from_header
188
+
189
+ else:
190
+ raise ValueError(f"Unknown Pac.Pic. magic: 0x{magic:08x}")
191
+
192
+ planes, width_pixels, height, num_planes = _decompress_bitmap(data, packed_offset)
193
+
194
+ # Use screen header dimensions if available, otherwise derive from packed bitmap
195
+ if screen_width == 0:
196
+ screen_width = width_pixels
197
+ if screen_height == 0:
198
+ screen_height = height
199
+ if display_width == 0:
200
+ display_width = width_pixels
201
+ if display_height == 0:
202
+ display_height = height
203
+
204
+ return PackedPicture(
205
+ width=width_pixels,
206
+ height=height,
207
+ num_planes=num_planes,
208
+ display_width=display_width,
209
+ display_height=display_height,
210
+ flags=flags,
211
+ palette=palette,
212
+ planes=planes,
213
+ )
@@ -0,0 +1,101 @@
1
+ """Pillow plugin for AMOS .abk bank files.
2
+
3
+ Registers the ABK format so that ``Image.open("file.abk")`` works
4
+ after ``import amos_abk``.
5
+
6
+ Sprite and icon banks produce one frame per image (RGBA, index 0 transparent).
7
+ Pac.Pic. banks produce one frame per picture (RGBA).
8
+ Non-image banks (music, samples, amal, data) are skipped.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from io import BytesIO
14
+
15
+ from PIL import Image, ImageFile
16
+
17
+ from amos_abk.bank import AbkFile
18
+ from amos_abk.bank import load as _load_abk
19
+ from amos_abk.pacpic import parse_packed_picture
20
+ from amos_abk.sprite import parse_sprites
21
+
22
+ # Bank names that contain parseable image data
23
+ _SPRITE_NAMES = {"Sprites", "Icons"}
24
+
25
+
26
+ def _accept(prefix):
27
+ return prefix[:4] in (b"AmBk", b"AmSp", b"AmIc")
28
+
29
+
30
+ def _build_frames(abk: AbkFile) -> list[Image.Image]:
31
+ """Extract all images from all banks, in bank order."""
32
+ frames = []
33
+ for bank in abk.banks:
34
+ if bank.name in _SPRITE_NAMES:
35
+ for sprite in parse_sprites(bank.data):
36
+ frames.append(sprite.to_image())
37
+ elif bank.name == "Pac.Pic.":
38
+ pic = parse_packed_picture(bank.data)
39
+ frames.append(pic.to_image().convert("RGBA"))
40
+ return frames
41
+
42
+
43
+ def images(source) -> list[Image.Image]:
44
+ """Extract all images from an ABK file.
45
+
46
+ Args:
47
+ source: file path (str/Path), BytesIO, or an already-loaded AbkFile.
48
+
49
+ Returns:
50
+ List of PIL Images (RGBA). Sprites and icons get one image each,
51
+ packed pictures get one image. Non-image banks are skipped.
52
+ """
53
+ if isinstance(source, AbkFile):
54
+ return _build_frames(source)
55
+ return _build_frames(_load_abk(source))
56
+
57
+
58
+ class AmosAbkFile(ImageFile.ImageFile):
59
+ format = "ABK"
60
+ format_description = "AMOS Memory Bank"
61
+
62
+ def _open(self):
63
+ self.fp.seek(0)
64
+ abk = _load_abk(BytesIO(self.fp.read()))
65
+ self.info["abk"] = abk
66
+
67
+ self._frames = _build_frames(abk)
68
+ if not self._frames:
69
+ raise SyntaxError("No image data in ABK file")
70
+
71
+ self._frame = 0
72
+ self._n_frames = len(self._frames)
73
+ self.is_animated = self._n_frames > 1
74
+ first = self._frames[0]
75
+ self._size = first.size
76
+ self._mode = first.mode
77
+
78
+ @property
79
+ def n_frames(self):
80
+ return self._n_frames
81
+
82
+ def seek(self, frame):
83
+ if not self._seek_check(frame):
84
+ return
85
+ self._frame = frame
86
+
87
+ def tell(self):
88
+ return self._frame
89
+
90
+ def load(self):
91
+ if self._frame >= len(self._frames):
92
+ raise EOFError("no more frames")
93
+ im = self._frames[self._frame]
94
+ self.im = im.im.copy()
95
+ self._size = im.size
96
+ self._mode = im.mode
97
+ return self.im
98
+
99
+
100
+ Image.register_open(AmosAbkFile.format, AmosAbkFile, _accept)
101
+ Image.register_extension(AmosAbkFile.format, ".abk")
@@ -0,0 +1,60 @@
1
+ """Shared Amiga planar bitmap conversion utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def planar_to_indexed(planes: list[bytes], width: int, height: int) -> bytes:
7
+ """Convert planar bitplane data to chunky indexed pixels.
8
+
9
+ Args:
10
+ planes: list of bytearrays/bytes, one per bitplane.
11
+ width: image width in pixels.
12
+ height: image height in pixels.
13
+
14
+ Returns:
15
+ One byte per pixel, each byte being a palette index.
16
+ """
17
+ width_bytes = (width + 7) // 8
18
+ num_planes = len(planes)
19
+ result = bytearray(width * height)
20
+
21
+ for y in range(height):
22
+ for x in range(width):
23
+ byte_idx = y * width_bytes + x // 8
24
+ bit = 7 - (x % 8)
25
+ index = 0
26
+ for plane_num in range(num_planes):
27
+ if planes[plane_num][byte_idx] & (1 << bit):
28
+ index |= 1 << plane_num
29
+ result[y * width + x] = index
30
+
31
+ return bytes(result)
32
+
33
+
34
+ def indexed_to_rgb(indexed: bytes, palette: list[tuple[int, int, int]]) -> bytes:
35
+ """Convert indexed pixel data to RGB (3 bytes per pixel)."""
36
+ result = bytearray(len(indexed) * 3)
37
+ for i, idx in enumerate(indexed):
38
+ r, g, b = palette[idx] if idx < len(palette) else (0, 0, 0)
39
+ result[i * 3] = r
40
+ result[i * 3 + 1] = g
41
+ result[i * 3 + 2] = b
42
+ return bytes(result)
43
+
44
+
45
+ def indexed_to_rgba(indexed: bytes, palette: list[tuple[int, int, int]]) -> bytes:
46
+ """Convert indexed pixel data to RGBA (4 bytes per pixel).
47
+
48
+ Palette index 0 is treated as transparent.
49
+ """
50
+ result = bytearray(len(indexed) * 4)
51
+ for i, idx in enumerate(indexed):
52
+ if idx == 0:
53
+ result[i * 4 : i * 4 + 4] = b"\x00\x00\x00\x00"
54
+ else:
55
+ r, g, b = palette[idx] if idx < len(palette) else (0, 0, 0)
56
+ result[i * 4] = r
57
+ result[i * 4 + 1] = g
58
+ result[i * 4 + 2] = b
59
+ result[i * 4 + 3] = 255
60
+ return bytes(result)
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import struct
4
+ from dataclasses import dataclass
5
+
6
+ from PIL import Image
7
+
8
+ from amos_abk.planar import indexed_to_rgb, indexed_to_rgba, planar_to_indexed
9
+
10
+
11
+ @dataclass
12
+ class Sprite:
13
+ """A single sprite or icon image from an AMOS bank."""
14
+
15
+ width: int
16
+ height: int
17
+ num_planes: int
18
+ hotspot_x: int
19
+ hotspot_y: int
20
+ palette: list[tuple[int, int, int]]
21
+ planes: list[bytes]
22
+
23
+ @property
24
+ def num_colors(self) -> int:
25
+ return 1 << self.num_planes
26
+
27
+ def to_indexed(self) -> bytes:
28
+ """Convert planar data to chunky indexed pixels.
29
+
30
+ Returns one byte per pixel, each byte being a palette index.
31
+ """
32
+ return planar_to_indexed(self.planes, self.width, self.height)
33
+
34
+ def to_rgb(self) -> bytes:
35
+ """Convert to RGB pixel data (3 bytes per pixel)."""
36
+ return indexed_to_rgb(self.to_indexed(), self.palette)
37
+
38
+ def to_rgba(self) -> bytes:
39
+ """Convert to RGBA pixel data (4 bytes per pixel).
40
+
41
+ Palette index 0 is treated as transparent.
42
+ """
43
+ return indexed_to_rgba(self.to_indexed(), self.palette)
44
+
45
+ def to_image(self) -> Image.Image:
46
+ """Convert to a PIL Image (RGBA, palette index 0 is transparent)."""
47
+ return Image.frombytes("RGBA", (self.width, self.height), self.to_rgba())
48
+
49
+
50
+ def _parse_palette(data: bytes, offset: int) -> list[tuple[int, int, int]]:
51
+ """Parse a 32-entry Amiga 12-bit palette."""
52
+ palette = []
53
+ for i in range(32):
54
+ color = struct.unpack_from(">H", data, offset + i * 2)[0]
55
+ r = ((color >> 8) & 0xF) * 17
56
+ g = ((color >> 4) & 0xF) * 17
57
+ b = (color & 0xF) * 17
58
+ palette.append((r, g, b))
59
+ return palette
60
+
61
+
62
+ def parse_sprites(data: bytes) -> list[Sprite]:
63
+ """Parse sprite or icon image data from a bank's raw data.
64
+
65
+ Args:
66
+ data: the raw bank data (after magic for bare files, after AmBk header
67
+ for wrapped files). Starts with the u16 image count.
68
+
69
+ Returns:
70
+ List of Sprite objects.
71
+ """
72
+ if len(data) < 2:
73
+ raise ValueError(f"Sprite data too short: {len(data)} bytes")
74
+
75
+ num_images = struct.unpack_from(">H", data, 0)[0]
76
+ if num_images == 0:
77
+ return []
78
+
79
+ # Palette is at the end: 32 entries * 2 bytes = 64 bytes
80
+ palette = _parse_palette(data, len(data) - 64)
81
+
82
+ sprites = []
83
+ offset = 2
84
+ for i in range(num_images):
85
+ if offset + 10 > len(data):
86
+ raise ValueError(f"Truncated sprite header at image {i}, offset {offset}")
87
+
88
+ w_words, h, num_planes, hx, hy = struct.unpack_from(">HHHHH", data, offset)
89
+ offset += 10
90
+
91
+ width = w_words * 16
92
+ plane_size = w_words * 2 * h # bytes per bitplane
93
+ total_size = plane_size * num_planes
94
+
95
+ if offset + total_size > len(data):
96
+ raise ValueError(
97
+ f"Truncated sprite data at image {i}: need {total_size} bytes at offset {offset}, "
98
+ f"but only {len(data) - offset} remain"
99
+ )
100
+
101
+ planes = []
102
+ for p in range(num_planes):
103
+ plane_start = offset + p * plane_size
104
+ planes.append(data[plane_start : plane_start + plane_size])
105
+ offset += total_size
106
+
107
+ # Mask out flip flags from hotspot_x (bits 14-15)
108
+ hotspot_x = hx & 0x3FFF
109
+ hotspot_y = hy
110
+
111
+ sprites.append(
112
+ Sprite(
113
+ width=width,
114
+ height=h,
115
+ num_planes=num_planes,
116
+ hotspot_x=hotspot_x,
117
+ hotspot_y=hotspot_y,
118
+ palette=palette,
119
+ planes=planes,
120
+ )
121
+ )
122
+
123
+ return sprites