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.
- amos_abk-0.1.0/LICENSE.md +7 -0
- amos_abk-0.1.0/PKG-INFO +140 -0
- amos_abk-0.1.0/README.md +117 -0
- amos_abk-0.1.0/pyproject.toml +41 -0
- amos_abk-0.1.0/src/amos_abk/__init__.py +15 -0
- amos_abk-0.1.0/src/amos_abk/bank.py +140 -0
- amos_abk-0.1.0/src/amos_abk/pacpic.py +213 -0
- amos_abk-0.1.0/src/amos_abk/pillow_plugin.py +101 -0
- amos_abk-0.1.0/src/amos_abk/planar.py +60 -0
- amos_abk-0.1.0/src/amos_abk/sprite.py +123 -0
amos_abk-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
|
amos_abk-0.1.0/README.md
ADDED
|
@@ -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
|