Projektstruktur: game/-Package angelegt, README ergaenzt
Laufzeit-Code vom Dev-Werkzeug getrennt: - game/: das eigentliche Spiel, startbar per `python -m game [--once]`. - formats.py (war tools/kellogg_formats.py) -- Asset-Parser, Kern-Bibliothek - audio.py (war tools/tfmx_audio.py) -- TFMX-Musik-Wrapper - intro.py (war tools/intro_sequence.py) -- Boot-/Intro-Sequenz - tfmx_player/(war tools/tfmx_player/) -- MIT-C-Renderer - __init__.py / __main__.py fuer `python -m game` - tools/: nur noch Dev-Werkzeuge (dat_extract, pcc_to_png, render_assets), importieren jetzt aus game.formats. - README.md: Ueberblick, Start, Struktur, Voraussetzungen, Rechtliches. - .gitignore: tfmx_player-Binary-Pfad auf game/ nachgezogen. Intro laeuft unveraendert (headless-Smoke-Test `python -m game --once` exit 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3fe92d01b8
commit
85404c91f4
+304
@@ -0,0 +1,304 @@
|
||||
'''
|
||||
Shared decode library for Kellogg's Tony & Friends asset formats.
|
||||
Ported (not copied) from the C# reference project
|
||||
https://github.com/movAX13h/tony-and-friends-in-kelloggs-land (no LICENSE file,
|
||||
therefore reimplemented from scratch in Python using their format docs as a guide).
|
||||
|
||||
Container: PCKELL.DAT holds all 178 assets, footer-indexed (see DATContainer).
|
||||
'''
|
||||
import struct
|
||||
import os
|
||||
from collections import namedtuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Container (PCKELL.DAT)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DATContainer:
|
||||
def __init__(self, path):
|
||||
with open(path, 'rb') as f:
|
||||
self.data = f.read()
|
||||
self.entries = {} # filename -> bytes
|
||||
self._parse()
|
||||
|
||||
def _parse(self):
|
||||
data = self.data
|
||||
num_entries = struct.unpack_from('<H', data, len(data) - 4)[0] + 1
|
||||
offset = struct.unpack_from('<i', data, len(data) - 8)[0]
|
||||
index_offset = offset
|
||||
|
||||
raw = []
|
||||
for _ in range(num_entries):
|
||||
name_len = struct.unpack_from('<H', data, offset)[0]
|
||||
offset += 2
|
||||
filename = data[offset:offset + name_len].decode('ascii', errors='replace')
|
||||
offset += name_len
|
||||
e_offset = struct.unpack_from('<i', data, offset)[0]
|
||||
offset += 4
|
||||
raw.append([filename, e_offset])
|
||||
|
||||
for i, e in enumerate(raw):
|
||||
length = (raw[i + 1][1] if i + 1 < len(raw) else index_offset) - e[1]
|
||||
self.entries[e[0]] = data[e[1]:e[1] + length]
|
||||
|
||||
def extract_all(self, out_dir):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
for name, payload in self.entries.items():
|
||||
with open(os.path.join(out_dir, name), 'wb') as f:
|
||||
f.write(payload)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Palette (from a .PCC / PCX file's trailing 256-color block)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_pcx_palette(pcc_bytes):
|
||||
'''Returns list of 256 (r,g,b) tuples from a PCX v5 file's end-of-file palette.'''
|
||||
marker = pcc_bytes[-769]
|
||||
if marker != 0x0C:
|
||||
raise ValueError('PCX palette marker (0x0C) not found')
|
||||
pal_bytes = pcc_bytes[-768:]
|
||||
return [tuple(pal_bytes[i:i+3]) for i in range(0, 768, 3)]
|
||||
|
||||
|
||||
def load_pcc(pcc_bytes):
|
||||
'''Decode a .PCC (= plain PCX v5, 8bpp, RLE, own trailing 256-color
|
||||
palette) via Pillow's battle-tested PCX decoder instead of a hand-rolled
|
||||
RLE parser -- see git history 2026-07-22 (commits 5efba4b/62af15e/3410a53)
|
||||
for why: TWO from-scratch RLE implementations silently produced visibly
|
||||
wrong pixels (shifted/duplicated content) for full-screen 320x200 images
|
||||
(KARTE.PCC, KELLOGGS.PCC) despite being "verified" -- Pillow avoids that
|
||||
whole class of bug entirely.
|
||||
|
||||
Returns (width, height, indices, palette):
|
||||
indices -- flat list[int] of palette indices, len width*height, row-major
|
||||
palette -- list of 256 (r,g,b) tuples (this file's OWN embedded palette,
|
||||
not any global one)
|
||||
'''
|
||||
from PIL import Image
|
||||
import io
|
||||
img = Image.open(io.BytesIO(pcc_bytes))
|
||||
img.load()
|
||||
if img.mode != 'P':
|
||||
raise ValueError(f'expected palette (P) mode PCX, got {img.mode}')
|
||||
width, height = img.size
|
||||
indices = list(img.getdata())
|
||||
raw_pal = img.getpalette() # flat [r,g,b, r,g,b, ...], may be shorter than 768
|
||||
raw_pal = (raw_pal + [0] * 768)[:768]
|
||||
palette = [tuple(raw_pal[i:i+3]) for i in range(0, 768, 3)]
|
||||
return width, height, indices, palette
|
||||
|
||||
|
||||
def pcc_to_rgba(pcc_bytes, transparent_index=None):
|
||||
'''Convenience wrapper around load_pcc(): returns (width, height, rgba_bytes)
|
||||
ready for e.g. pygame.image.frombuffer(rgba, (w,h), 'RGBA') or PIL Image.frombytes.
|
||||
If transparent_index is given, that palette index becomes alpha=0.'''
|
||||
width, height, indices, palette = load_pcc(pcc_bytes)
|
||||
out = bytearray(width * height * 4)
|
||||
for i, idx in enumerate(indices):
|
||||
r, g, b = palette[idx & 0xFF]
|
||||
a = 0 if (transparent_index is not None and idx == transparent_index) else 255
|
||||
out[i*4:i*4+4] = bytes([r, g, b, a])
|
||||
return width, height, bytes(out)
|
||||
|
||||
|
||||
def get_bob_palette(entries, name):
|
||||
'''name = basename without extension, e.g. 'TONY' for TONY.BOB.
|
||||
Implements the palette-selection rules from Form1.cs (BOB case).'''
|
||||
pcc_name = name + '.PCC'
|
||||
if pcc_name in entries:
|
||||
return list(get_pcx_palette(entries[pcc_name]))
|
||||
if len(name) == 1:
|
||||
# the ants have names like A.BOB .. O.BOB
|
||||
return list(get_pcx_palette(entries['ANTS.PCC']))
|
||||
pal = list(get_pcx_palette(entries['W2.PCC']))
|
||||
pal[0] = (0, 0, 0)
|
||||
return pal
|
||||
|
||||
|
||||
def get_world_palette(entries, world):
|
||||
'''world = 'W1'|'W2'|'W3'. Implements ICO/MAP palette rule (addW2Palette):
|
||||
W2.PCC carries 16 shared colors (indices 16..31) used for animations/items
|
||||
across all worlds, so W1/W3 palettes borrow that slice from W2.'''
|
||||
pal = list(get_pcx_palette(entries[world + '.PCC']))
|
||||
if world != 'W2':
|
||||
w2 = get_pcx_palette(entries['W2.PCC'])
|
||||
for i in range(16):
|
||||
pal[16 + i] = w2[16 + i]
|
||||
return pal
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BOB (animated sprites: self-modifying x86 'draw code' + embedded pixel data)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CopyInstr = namedtuple('CopyInstr', ['ega_page', 'offset', 'data'])
|
||||
|
||||
def _parse_bob_executable(data):
|
||||
'''Interprets the tiny x86 instruction stream used to blit pixels.
|
||||
Returns list of CopyInstr, or None if an unrecognized opcode is hit.'''
|
||||
instrs = []
|
||||
pc = 0
|
||||
ega_page = -1
|
||||
n = len(data)
|
||||
while True:
|
||||
if pc >= n:
|
||||
return None
|
||||
op = data[pc]
|
||||
if op == 0x03: # add si, cx (0x03 0xF1)
|
||||
if data[pc+1] != 0xF1:
|
||||
return None
|
||||
pc += 2
|
||||
elif op == 0xCB: # retf
|
||||
pc += 1
|
||||
break
|
||||
elif op in (0x56, 0x58, 0x5E, 0x50): # push si / push ax / pop si / push ax
|
||||
pc += 1
|
||||
elif op == 0xEE: # out dx, al -> advance EGA page (cycles 0..3)
|
||||
ega_page = (ega_page + 1) & 3
|
||||
pc += 1
|
||||
elif op == 0xD0: # rol al, 1 (0xD0 0xC0)
|
||||
if data[pc+1] != 0xC0:
|
||||
return None
|
||||
pc += 2
|
||||
elif op == 0x8A: # mov cl, ah/bl/bh (no-op for our purposes)
|
||||
if data[pc+1] not in (0xCC, 0xCB, 0xCF):
|
||||
return None
|
||||
pc += 2
|
||||
elif op == 0xC6:
|
||||
sub = data[pc+1]
|
||||
if sub == 0x84: # mov byte ptr [si+AAAA], BB
|
||||
offset = data[pc+2] | (data[pc+3] << 8)
|
||||
const = data[pc+4]
|
||||
pc += 5
|
||||
instrs.append(CopyInstr(ega_page, offset, bytes([const])))
|
||||
elif sub == 0x44: # mov byte ptr [si+AA], BB
|
||||
offset = data[pc+2]
|
||||
const = data[pc+3]
|
||||
pc += 4
|
||||
instrs.append(CopyInstr(ega_page, offset, bytes([const])))
|
||||
else:
|
||||
return None
|
||||
elif op == 0xC7:
|
||||
sub = data[pc+1]
|
||||
if sub == 0x44: # mov word ptr [si+AA], BBBB
|
||||
offset = data[pc+2]
|
||||
const = data[pc+3] | (data[pc+4] << 8)
|
||||
pc += 5
|
||||
instrs.append(CopyInstr(ega_page, offset, bytes([const & 0xFF, (const >> 8) & 0xFF])))
|
||||
elif sub == 0x84: # mov word ptr [si+AAAA], BBBB
|
||||
offset = data[pc+2] | (data[pc+3] << 8)
|
||||
const = data[pc+4] | (data[pc+5] << 8)
|
||||
pc += 6
|
||||
instrs.append(CopyInstr(ega_page, offset, bytes([const & 0xFF, (const >> 8) & 0xFF])))
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
return instrs
|
||||
|
||||
|
||||
BOB_STRIDE = 84
|
||||
|
||||
class BobFrame:
|
||||
__slots__ = ('width', 'height', 'pixels') # pixels: list of palette indices, width*height, -1 = transparent
|
||||
def __init__(self, width, height):
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.pixels = [-1] * (width * height)
|
||||
|
||||
def set(self, x, y, idx):
|
||||
if 0 <= x < self.width and 0 <= y < self.height:
|
||||
self.pixels[y * self.width + x] = idx
|
||||
|
||||
|
||||
def parse_bob(data):
|
||||
'''Returns list of BobFrame (palette-index pixel grids, -1 = transparent/unset).'''
|
||||
frames = []
|
||||
pos = 0
|
||||
n = len(data)
|
||||
while pos < n:
|
||||
header = data[pos:pos+14]
|
||||
if len(header) < 14:
|
||||
raise ValueError(f'truncated header at {pos}')
|
||||
width = struct.unpack_from('<h', header, 6)[0]
|
||||
height = struct.unpack_from('<h', header, 8)[0]
|
||||
ptr_seg_len = struct.unpack_from('<h', header, 10)[0]
|
||||
pos += 14
|
||||
|
||||
pointers_data = data[pos:pos+ptr_seg_len]
|
||||
pos += ptr_seg_len
|
||||
|
||||
last_instruction = struct.unpack_from('<h', pointers_data, len(pointers_data) - 2)[0]
|
||||
next_frame_pos = pos + last_instruction
|
||||
exec_segment = data[pos:next_frame_pos]
|
||||
pos = next_frame_pos
|
||||
|
||||
instrs = _parse_bob_executable(exec_segment)
|
||||
if instrs is None:
|
||||
raise ValueError(f'failed to parse exec segment for frame {len(frames)} (len={len(exec_segment)})')
|
||||
|
||||
frame = BobFrame(width, height)
|
||||
for instr in instrs:
|
||||
for i, byte in enumerate(instr.data):
|
||||
p = instr.offset + i
|
||||
x = (p % BOB_STRIDE) * 4 + instr.ega_page
|
||||
y = p // BOB_STRIDE
|
||||
idx = byte - 0x80
|
||||
frame.set(x, y, idx)
|
||||
frames.append(frame)
|
||||
|
||||
if pos != n:
|
||||
raise ValueError(f'missed {n - pos} extra bytes at end of file')
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ICO (16x16 tiles, EGA scrambled pixel order)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_ico(data):
|
||||
'''Returns list of 16x16 palette-index grids (flat lists, len 256).'''
|
||||
num_tiles = (len(data) - 1) // 256
|
||||
tiles = []
|
||||
ptr = 0
|
||||
for _ in range(num_tiles):
|
||||
grid = [0] * 256
|
||||
for y in range(16):
|
||||
for page in range(4):
|
||||
for x in range(4):
|
||||
k = data[ptr]
|
||||
ptr += 1
|
||||
col = x * 4 + page
|
||||
grid[y * 16 + col] = k - 128
|
||||
tiles.append(grid)
|
||||
return tiles
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MAP (grid of tile refs + collision type, big-endian!)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_map(data):
|
||||
if data[0:4] != b'TLE1':
|
||||
raise ValueError('MAP signature TLE1 not found')
|
||||
width = struct.unpack_from('>h', data, 4)[0]
|
||||
height = struct.unpack_from('>h', data, 6)[0]
|
||||
unknown = struct.unpack_from('>h', data, 8)[0]
|
||||
if unknown != 9:
|
||||
raise ValueError(f'unexpected constant {unknown} (expected 9)')
|
||||
|
||||
pos = 10
|
||||
cells = [] # row-major, len width*height, each (tile, type)
|
||||
for _ in range(width * height):
|
||||
value = struct.unpack_from('>H', data, pos)[0]
|
||||
pos += 2
|
||||
tile = value & 0x1FF
|
||||
ctype = value >> 9
|
||||
cells.append((tile, ctype))
|
||||
|
||||
if pos != len(data):
|
||||
raise ValueError(f'missed {len(data) - pos} extra bytes at end of MAP')
|
||||
|
||||
return width, height, cells
|
||||
Reference in New Issue
Block a user