libbgi: W0-атласы спрайтов — загрузчик, движок, упаковщик (все проверки PASS)

Реализация §3.1/§9в: атлас = один .atl-файл = одна EMM-страница,
подключаемая в W0 на время блита.

- atlas_load: read() файла целиком в страницу через W3 + патч ISR-стаба
  (0x38: JP _gfx_w0_isr; 0x66: RETN); atlas_free/atlas_image/
  atlas_sprite_init; gfx_w0_map/unmap для ручных вызовов.
- _gfx_w0_isr (стаб из tests/w0page): свап на ядро DSS → честный 0x38 →
  restore спрайт-страницы; покрывает IM1 и IM2-чейн.
- sprite_t.page (0 = обычная память); sprite_update в блит-проходе
  мапит страницу по смене (один OUT на атлас), эпилог возвращает DSS
  (+52 Б на движок — цена фичи).
- toolchain/mkatlas.py: PNG (indexed) / raw → .atl; заголовок 0x100,
  каталог 0x68 (19 лент), файл-офсет == офсет страницы == W0-адрес.
- Сплит _gfx_sprite_fns → _gfx_blit_fn.c + _gfx_heal_fn.c (1 указатель
  = 1 модуль): putsprite-only программа не тянет heal-ядро (spriteclip
  ловил +292 Б; теперь −68 Б от эталона).

tests/atlas (MAME dev, PASS): загрузка (count/страница), каталог и
данные по W0-адресам (заголовок ленты через gfx_w0_map), 3 спрайта из
двух лент через движок — пиксели проверены read_vram побайтно
(0x10/0x12/0x21, прозрачный угол = фон).

docs: §9г — предложение авто-анимации (кадровая ±step без умножений,
tween-Брезенхэм порциями, тикер через funcptr) — ОБСУЖДАЕТСЯ.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 15:52:00 +03:00
parent c4a512200f
commit 0e935343d9
22 changed files with 648 additions and 29 deletions
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
mkatlas.py — упаковщик атласов спрайтов Sprinter (.atl, вариант II —
docs/sprite-api-design.md §3.1).
Формат файла (== образ EMM-страницы, файл-офсет == W0-адрес):
0x000 'S','P','A','1', count u8, резерв ×3
0x008 резерв (0x38-0x3A и 0x66-0x67 — под патч ISR-стаба загрузчиком)
0x068 каталог: count × 8 Б {offset u16 LE, fw u8, fh u8, nx u8, ny u8,
резерв u16} — offset указывает на getimage-ЗАГОЛОВОК ленты
0x100 ленты подряд: u16 w=fw*nx, u16 h=fh*ny (LE) + пиксели построчно
Вход — список лент:
img.png:FWxFH индексированный PNG (mode P/L; байты как есть,
прозрачность = индекс 0xFF); сетка nx,ny из
размеров картинки
img.raw:FWxFH:NXxNY сырые байты (fw*nx)×(fh*ny), построчно
Пример:
mkatlas.py sprites.atl hero.png:16x16 coin.png:16x16 boss.raw:24x24:2x2
Ограничения: count ≤ 19, файл ≤ 16384 Б, кадр ≤ 64×64.
"""
import os
import sys
HDR_SIZE = 0x100
DIR_OFF = 0x68
MAX_ENTRY = (HDR_SIZE - DIR_OFF) // 8 # 19
PAGE_SIZE = 16384
def die(msg):
sys.exit(f"mkatlas: {msg}")
def load_png(path):
from PIL import Image
im = Image.open(path)
if im.mode not in ("P", "L"):
die(f"{path}: нужен индексированный PNG (mode P/L), не {im.mode}")
return im.width, im.height, bytes(im.tobytes())
def parse_spec(spec):
parts = spec.split(":")
if len(parts) < 2:
die(f"'{spec}': нужен вид file:FWxFH[:NXxNY]")
path = parts[0]
fw, fh = (int(v) for v in parts[1].lower().split("x"))
if not (1 <= fw <= 64 and 1 <= fh <= 64):
die(f"{path}: кадр {fw}x{fh} вне 1..64 (контракт спрайтов)")
if path.lower().endswith(".png"):
if len(parts) != 2:
die(f"{path}: для PNG сетка берётся из размеров картинки")
w, h, pix = load_png(path)
else:
if len(parts) != 3:
die(f"{path}: для raw обязательна сетка :NXxNY")
nx, ny = (int(v) for v in parts[2].lower().split("x"))
w, h = fw * nx, fh * ny
pix = open(path, "rb").read()
if len(pix) != w * h:
die(f"{path}: {len(pix)} байт, ожидалось {w}x{h}={w * h}")
if w % fw or h % fh:
die(f"{path}: {w}x{h} не делится на кадр {fw}x{fh}")
nx, ny = w // fw, h // fh
if not (1 <= nx <= 255 and 1 <= ny <= 255):
die(f"{path}: сетка {nx}x{ny} вне 1..255")
return dict(path=path, fw=fw, fh=fh, nx=nx, ny=ny, w=w, h=h, pix=pix)
def main():
if len(sys.argv) < 3:
sys.exit(__doc__.strip())
out, specs = sys.argv[1], sys.argv[2:]
if len(specs) > MAX_ENTRY:
die(f"лент {len(specs)} > {MAX_ENTRY} (каталог 0x68-0xFF)")
entries = [parse_spec(s) for s in specs]
blob = bytearray(HDR_SIZE)
blob[0:4] = b"SPA1"
blob[4] = len(entries)
off = HDR_SIZE
for i, e in enumerate(entries):
d = DIR_OFF + i * 8
blob[d:d + 2] = off.to_bytes(2, "little")
blob[d + 2] = e["fw"]
blob[d + 3] = e["fh"]
blob[d + 4] = e["nx"]
blob[d + 5] = e["ny"]
blob += e["w"].to_bytes(2, "little") # getimage-заголовок ленты
blob += e["h"].to_bytes(2, "little")
blob += e["pix"]
off = len(blob)
if len(blob) > PAGE_SIZE:
die(f"итог {len(blob)} Б > {PAGE_SIZE} (одна EMM-страница); "
f"разбейте на несколько .atl")
with open(out, "wb") as f:
f.write(blob)
print(f"mkatlas: {out}: лент {len(entries)}, "
f"{len(blob)} Б (свободно {PAGE_SIZE - len(blob)})")
for i, e in enumerate(entries):
print(f" [{i}] {os.path.basename(e['path'])}: кадр "
f"{e['fw']}x{e['fh']}, сетка {e['nx']}x{e['ny']}")
if __name__ == "__main__":
main()