applications/PoP: порт Prince of Persia — PoC (roomtest) + пайплайн

Порт PoP на Sprinter.  Текущий PoC — applications/PoP/roomtest/:
комната 1 (фон-композиция тайлов) + Kid с управлением на raw-клавиатуре
и коллизией с картой.

- roomtest — pop_bg (фон), pop_kid (спрайты Kid, column-major флип,
  seqtbl-анимация), pop_ctrl (порт control() PoP на held-state
  kbd_raw), pop_map (коллизия seg004/005: бег/стоп у стены,
  падение/приземление, отскок seq_47, вертикальный прыжок K4.1).
  MEMORY=small (DATA сразу за CODE, ~23КБ кода не лезет в huge).
- toolchain (PoP) — pop_pack_kid/pop_pack_bg/render_room/extract —
  распаковка res-графики MSDOS в атласы + композиция комнат.
- toolchain/ (корень) — make_hdd.sh (быстрый HDD-тест вместо FDD),
  png_strip.py / room_compose.py (ассет-пайплайн).
- docs — PORT_PLAN, KID_PLAN, форматы ресурсов (Apple II / MSDOS / DAT).
- bgtest/coltest/poc — ранние PoC (фон, коллизия, первый прототип).

.gitignore: build-артефакты applications/*/*/*; исключены внешние
референс-репозитории (SDLPoP/mininim/PR/Apple-II — свои git-клоны) и
оригинальные game-данные MSDOS/ (копирайт, только для реверса форматов).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 17:51:08 +03:00
parent 484b18d10c
commit cd8d566d82
196 changed files with 6630 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# make_hdd.sh OUT.chd FILE1 [FILE2 ...]
#
# Собирает 32 МБ RAW-образ жёсткого диска (MBR + один раздел FAT16) с
# указанными файлами в корне и конвертит его в CHD для MAME (монтируется
# как -hard2). Быстрая альтернатива floppy (mc.img): MAME эмулирует FDD
# с реальной черепашьей скоростью, HDD — в разы быстрее.
#
# Требует: mtools (mpartition/mformat/mcopy) + chdman (из MAME).
# Sprinter DSS штатно монтирует стандартный MBR+FAT16 (подтверждено).
#
# Использование:
# toolchain/make_hdd.sh mame/v306/IMG/test_hdd.chd prog.exe data1 data2 ...
set -e
if [ $# -lt 2 ]; then
echo "usage: $0 OUT.chd FILE1 [FILE2 ...]" >&2
exit 1
fi
OUT_CHD="$1"; shift
# Геометрия: 128*16*32*512 = 32 МБ. BPS=512.
CYLS=128; HEADS=16; SECS=32
SECTORS=$((CYLS * HEADS * SECS)) # 65536
# macOS mktemp требует X'ы в КОНЦЕ шаблона.
RAW=$(mktemp /tmp/sphdd.raw.XXXXXX)
MTC=$(mktemp /tmp/sphdd.mtc.XXXXXX)
trap 'rm -f "$RAW" "$MTC"' EXIT
dd if=/dev/zero of="$RAW" bs=512 count=$SECTORS status=none
# mtools-диск z: = раздел 1 нашего RAW-образа.
printf 'drive z: file="%s" partition=1\n' "$RAW" > "$MTC"
export MTOOLSRC="$MTC" MTOOLS_SKIP_CHECK=1
# MBR + раздел 1 на весь диск (тип FAT16), затем формат FAT16.
mpartition -I -c -t $CYLS -h $HEADS -s $SECS z:
mformat z:
for f in "$@"; do
mcopy -o "$f" z:
done
echo "--- содержимое образа ---"
mdir z:
# RAW -> CHD (без сжатия = быстро; та же геометрия).
chdman createhd -i "$RAW" -o "$OUT_CHD" -c none \
-chs $CYLS,$HEADS,$SECS -ss 512 -f
echo "готово: $OUT_CHD"
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
png_strip.py — генерик-конвертер произвольного набора PNG-кадров (РАЗНОГО
размера каждый) в одну raw-ленту + палитру для sprite-движка Sprinter
(формат getimage/atlas — см. libbgi/include/sprite.h, toolchain/mkatlas.py).
Не завязан ни на какой конкретный проект/игру — просто берёт список
картинок, которые вы сами укажете, и паковает их в ленту с общим
канвасом (кадр движка обязан быть фиксированного размера).
Использование:
png_strip.py OUT.raw OUT.pal img1.png img2.png ... [опции]
Опции:
--canvas WxH размер канваса кадра (по умолчанию — максимум
ширины/высоты среди входных картинок)
--anchor MODE bottom-center (по умолчанию) | top-left | center
--base-index N с какого индекса палитры начинать свои цвета
(по умолчанию 16 — под 16 EGA-слотов initgraph)
--ega добавить в палитру 16 EGA-цветов BGI перед своими
(по умолчанию включено; --no-ega выключает)
--seed-pal FILE переиспользовать цвета уже существующей палитры
(EGA16 + N своих) — свои индексы этой ленты
продолжаются ПОСЛЕ них (не с --base-index), а
OUT.pal получится ПОЛНОЙ merged-палитрой
(ega+seed+свои), готовой как --seed-pal для
следующего вызова. Так несколько независимо
собранных лент (напр. разные типы тайлов) можно
свести в ОДНУ палитру для одновременного показа
на экране (см. applications/PoP/poc/tools/
build_room_palette.py).
Каждая картинка — источник ОДНОГО кадра ленты (не атлас-сетка;
для сетки — см. mkatlas.py raw:FWxFH:NXxNY после этого шага, если
нужно). Прозрачность — альфа < 128 → индекс 0xFF (сентинел движка).
Дальше: toolchain/mkatlas.py OUT.atl OUT.raw:CW x CH:1xN
"""
import argparse
import sys
from PIL import Image
EGA = [
(0, 0, 0), (168, 0, 0), (0, 168, 0), (168, 168, 0),
(0, 0, 168), (168, 0, 168), (0, 84, 168), (168, 168, 168),
(84, 84, 84), (255, 84, 84), (84, 255, 84), (255, 255, 84),
(84, 84, 255), (255, 84, 255), (84, 255, 255), (255, 255, 255),
]
def die(msg):
sys.exit(f"png_strip: {msg}")
def main():
ap = argparse.ArgumentParser(add_help=False)
ap.add_argument("out_raw")
ap.add_argument("out_pal")
ap.add_argument("images", nargs="+")
ap.add_argument("--canvas", default=None)
ap.add_argument("--anchor", default="bottom-center",
choices=["bottom-center", "top-left", "center"])
ap.add_argument("--base-index", type=int, default=16)
ap.add_argument("--ega", dest="ega", action="store_true", default=True)
ap.add_argument("--no-ega", dest="ega", action="store_false")
ap.add_argument("--seed-pal", default=None)
args = ap.parse_args()
imgs = [Image.open(p).convert("RGBA") for p in args.images]
seed_records = []
if args.seed_pal:
raw = open(args.seed_pal, "rb").read()
if len(raw) % 4:
die(f"--seed-pal {args.seed_pal}: длина не кратна 4")
n_ega = 16 if args.ega else 0
for i in range(n_ega, len(raw) // 4):
b, g, r, _ = raw[i * 4:i * 4 + 4]
seed_records.append((r, g, b))
args.base_index = 16 + len(seed_records)
if args.canvas:
cw, ch = (int(v) for v in args.canvas.lower().split("x"))
else:
cw = max(im.width for im in imgs)
ch = max(im.height for im in imgs)
if cw > 64 or ch > 64:
die(f"канвас {cw}x{ch} превышает лимит движка 64x64")
for p, im in zip(args.images, imgs):
if im.width > cw or im.height > ch:
die(f"{p}: {im.width}x{im.height} не влезает в канвас {cw}x{ch}")
colors = {}
for i, rgb in enumerate(seed_records):
colors[rgb] = 16 + i
def color_index(r, g, b):
key = (r, g, b)
if key not in colors:
idx = args.base_index + (len(colors) - len(seed_records))
if idx >= 0xFF:
die("палитра переполнена (>= 0xFF своих цветов)")
colors[key] = idx
return colors[key]
data = bytearray()
for im in imgs:
if args.anchor == "top-left":
ox, oy = 0, 0
elif args.anchor == "center":
ox, oy = (cw - im.width) // 2, (ch - im.height) // 2
else: # bottom-center
ox, oy = (cw - im.width) // 2, ch - im.height
canvas = [0xFF] * (cw * ch)
for y in range(im.height):
for x in range(im.width):
r, g, b, a = im.getpixel((x, y))
if a < 128:
continue
canvas[(oy + y) * cw + (ox + x)] = color_index(r, g, b)
data.extend(canvas)
open(args.out_raw, "wb").write(data)
pal = bytearray()
if args.ega:
for b, g, r in EGA:
pal += bytes((b, g, r, 0))
new_colors = [rgb for rgb in colors if rgb not in seed_records]
for (r, g, b) in (*seed_records, *new_colors):
pal += bytes((b, g, r, 0))
open(args.out_pal, "wb").write(pal)
print(f"png_strip: {len(imgs)} кадров, канвас {cw}x{ch}, "
f"{len(data)} Б -> {args.out_raw}")
print(f" палитра {len(seed_records)} seed + {len(new_colors)} новых "
f"своих цветов ({len(pal)} Б) -> {args.out_pal}")
print(f"дальше: mkatlas.py OUT.atl {args.out_raw}:{cw}x{ch}:1x{len(imgs)}")
if __name__ == "__main__":
main()
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""
room_compose.py — генерик-компоновщик полноэкранного фона комнаты из
тайлов ПРОИЗВОЛЬНОГО размера (см. applications/PoP/docs/PORT_PLAN.md §4:
один полноэкранный фон на комнату готовится офлайн, поверх — спрайты
через sprite.h). Не завязан ни на какой конкретный проект/игру — просто
берёт раскладку тайлов, которую вы сами опишете, и картинки, которые
сами укажете.
Раскладка — JSON:
{
"rows": [
{"height": 20, "tiles": ["a.png", "b.png", ...]},
{"height": 60, "tiles": [...]},
...
]
}
Тайлы в ряду кладутся слева направо; высота ряда — из "height" (тайл
меньшей высоты — сверху, остаток — прозрачность 0xFF; выше — ошибка).
Ширина итоговой картинки = сумма ширин тайлов первого ряда — остальные
ряды обязаны давать ТУ ЖЕ сумму (иначе ошибка, во избежание кривого
экрана по краю).
Использование:
room_compose.py OUT.raw OUT.pal --layout layout.json [--base-index N]
Выход:
OUT.raw — ГОТОВЫЙ формат getimage (uint16 w, uint16 h LE + пиксели
построчно) — грузится fread'ом целиком и передаётся
напрямую в gfx_blit(x, y, buf).
OUT.pal — палитра (EGA 16 + свои цвета), формат gfx_pal_fload.
"""
import argparse
import json
import struct
import sys
from pathlib import Path
from PIL import Image
EGA = [
(0, 0, 0), (168, 0, 0), (0, 168, 0), (168, 168, 0),
(0, 0, 168), (168, 0, 168), (0, 84, 168), (168, 168, 168),
(84, 84, 84), (255, 84, 84), (84, 255, 84), (255, 255, 84),
(84, 84, 255), (255, 84, 255), (84, 255, 255), (255, 255, 255),
]
def die(msg):
sys.exit(f"room_compose: {msg}")
def main():
ap = argparse.ArgumentParser(add_help=False)
ap.add_argument("out_raw")
ap.add_argument("out_pal")
ap.add_argument("--layout", required=True)
ap.add_argument("--base-index", type=int, default=16)
ap.add_argument("--ega", dest="ega", action="store_true", default=True)
ap.add_argument("--no-ega", dest="ega", action="store_false")
args = ap.parse_args()
spec = json.loads(Path(args.layout).read_text())
rows_spec = spec["rows"]
if not rows_spec:
die("layout: пустой список rows")
rows_imgs = []
width = None
for ri, row in enumerate(rows_spec):
rh = int(row["height"])
tiles = row["tiles"]
if not tiles:
die(f"row {ri}: пустой список tiles")
imgs = [Image.open(t).convert("RGBA") for t in tiles]
for t, im in zip(tiles, imgs):
if im.height > rh:
die(f"{t}: высота {im.height} > высоты ряда {rh}")
row_w = sum(im.width for im in imgs)
if width is None:
width = row_w
elif row_w != width:
die(f"row {ri}: ширина {row_w} != ширины первого ряда {width} "
f"(все ряды должны давать одинаковую суммарную ширину)")
rows_imgs.append((rh, imgs))
height = sum(rh for rh, _ in rows_imgs)
if width > 65535 or height > 65535:
die(f"{width}x{height}: превышает предел формата (u16)")
colors = {}
def color_index(r, g, b):
key = (r, g, b)
if key not in colors:
idx = args.base_index + len(colors)
if idx >= 0xFF:
die("палитра переполнена (>= 0xFF своих цветов)")
colors[key] = idx
return colors[key]
canvas = [0xFF] * (width * height)
y0 = 0
for rh, imgs in rows_imgs:
x0 = 0
for im in imgs:
for y in range(im.height):
for x in range(im.width):
r, g, b, a = im.getpixel((x, y))
if a < 128:
continue
canvas[(y0 + y) * width + (x0 + x)] = color_index(r, g, b)
x0 += im.width
y0 += rh
with open(args.out_raw, "wb") as f:
f.write(struct.pack("<HH", width, height))
f.write(bytes(canvas))
pal = bytearray()
if args.ega:
for b, g, r in EGA:
pal += bytes((b, g, r, 0))
for (r, g, b) in colors:
pal += bytes((b, g, r, 0))
open(args.out_pal, "wb").write(pal)
print(f"room_compose: {width}x{height} ({len(rows_imgs)} рядов) "
f"-> {args.out_raw} ({4 + width * height} Б, вкл. getimage-заголовок)")
print(f" палитра {len(colors)} своих цветов "
f"({len(pal)} Б) -> {args.out_pal}")
if __name__ == "__main__":
main()