PCC-Decoder-Bugfix: RLE-Runs duerfen Zeilengrenzen ueberschreiten (Row-Reset war falsch)

Stefan hat zurecht bemaengelt dass KARTE.PCC/KELLOGGS.PCC verzerrt/verrutscht
aussehen (Smacks-Frosch an falscher Position, Notch-Fehlstelle im Logo-Rahmen).

Root Cause: Der Decoder hat RLE-Runs am Ende jeder Bildzeile hart abgeschnitten
und den Rest verworfen (klassisches PCX-Verhalten angenommen). Dieses Format
haelt sich aber NICHT an die PCX-Konvention 'ein Run ueberschreitet nie eine
Zeile' -- Runs laufen frei ueber Zeilengrenzen. Verifiziert per Pixel-fuer-Pixel-
Vergleich gegen echten DOSBox-Screenshot (world map): mit durchgehender
Dekodierung (kein Row-Reset) ist das Ergebnis jetzt pixel-identisch zur Referenz.

- tools/pcc_to_png.py: Row-Reset entfernt, decodiert jetzt width*height Pixel
  am Stueck aus dem RLE-Strom
- png_out/: alle 77 PNGs mit dem Fix neu generiert
- tools/wip_bob_sprite_research/: Backup der laufenden (noch ungeloesten)
  BOB-Sprite-Format-Exploration von aria-wohnung, damit nichts bei einem
  VM-Neustart verloren geht
- NOTES.md: Root-Cause-Analyse dokumentiert, alte Fehldiagnose ('byte-identisch
  zwischen Row-Reset und kontinuierlich') korrigiert, Rauser-Intro-Frage
  beantwortet (3 Frames vorhanden, Text-Logo, keine Video-Datei)
This commit is contained in:
ARIA
2026-07-22 07:33:01 +00:00
parent 3c650e6aef
commit 5efba4b165
91 changed files with 561 additions and 43 deletions
@@ -0,0 +1,23 @@
import os
base = "/home/aria/kellogg_remake/extracted_pre/"
names = ["A","B","C","D","E","F","G","H","I","J","K","L","N","O","BLUME","WOLKE",
"VITAMIN","COCOPOPS","SMACKIES","FROSTIES","LOOPS","TONY","SMACKS","TOUCAN",
"COCO","PLATFW1","KEYS","GAMEBAR","KROKO","BOSSNAKE","DRAGON","DOORS"]
N = 128
headers = {}
for nm in names:
path = base+nm+".BOB"
data = open(path,"rb").read()
headers[nm] = data[:N]
print(f"{nm:10s} size={len(data):6d} header={data[:N].hex()}")
print()
print("Per-byte-position constant analysis (position: set of distinct values, only show non-constant or show const marker):")
for pos in range(N):
vals = set(h[pos] for h in headers.values() if len(h) > pos)
if len(vals) == 1:
print(f"pos {pos:3d}: CONST 0x{next(iter(vals)):02x}")
else:
print(f"pos {pos:3d}: VARIES ({len(vals)} distinct)")
@@ -0,0 +1,61 @@
import struct
def decode_scanline(data, width, max_rows=100000):
i = 0
n = len(data)
rows = 0
row_len = 0
while i < n:
if row_len >= width:
rows += 1
row_len = 0
if rows > max_rows:
return rows, i, False
continue
b = data[i]
if (b & 0xC0) == 0xC0:
count = b & 0x3F
i += 1
if i >= n:
return rows, i, False
i += 1
need = width - row_len
take = min(count, need)
row_len += take
else:
row_len += 1
i += 1
if row_len == 0:
return rows, i, True
else:
rows += 1 # partial
return rows, i, False
base = "/home/aria/kellogg_remake/extracted_pre/"
files = ["B.BOB","E.BOB","N.BOB","O.BOB","A.BOB","J.BOB","K.BOB","D.BOB"]
for fn in files:
data = open(base+fn, "rb").read()
n = len(data)
print(f"=== {fn} size={n} ===")
found_any = False
for headerlen in range(0, 17, 1):
if headerlen % 2 != 0:
continue
header = data[:headerlen]
body = data[headerlen:]
num_u16 = headerlen // 2
u16s = struct.unpack(f"<{num_u16}H", header) if headerlen else ()
# try every pair (w_idx, h_idx) among header u16 fields as (width,height) candidates
for wi in range(num_u16):
w = u16s[wi]
if w < 2 or w > 400:
continue
rows, consumed, ok = decode_scanline(body, w)
if ok:
# check if any header u16 equals rows (=height)
match_h = [hi for hi,val in enumerate(u16s) if val == rows]
print(f" headerlen={headerlen} width_idx={wi} width={w} -> rows={rows} clean=True header_u16={u16s} height_field_match_idx={match_h}")
found_any = True
if not found_any:
print(" (no clean header+width combo found in range)")
@@ -0,0 +1,30 @@
def rle_decode_pcx_style(data):
out = bytearray()
i = 0
n = len(data)
while i < n:
b = data[i]
if (b & 0xC0) == 0xC0:
count = b & 0x3F
i += 1
if i >= n:
break
val = data[i]
out.extend([val]*count)
i += 1
else:
out.append(b)
i += 1
return bytes(out)
base = "/home/aria/kellogg_remake/extracted_pre/"
for fn in ["TONY.BOB","O.BOB","A.BOB"]:
data = open(base+fn,"rb").read()
dec = rle_decode_pcx_style(data)
from collections import Counter
c = Counter(dec)
print(fn, "distinct values:", len(c), "min", min(dec), "max", max(dec))
top = c.most_common(10)
print(" top10:", top)
below16 = sum(v for k,v in c.items() if k<16)
print(f" fraction of pixels <16: {below16/len(dec):.3f} total_pixels={len(dec)}")
@@ -0,0 +1,15 @@
import os
base = "/home/aria/kellogg_remake/extracted_pre/"
names = ["A","B","C","D","E","F","G","H","I","J","K","L","N","O","BLUME","WOLKE",
"VITAMIN","COCOPOPS","SMACKIES","FROSTIES","LOOPS","TONY","SMACKS","TOUCAN",
"COCO","PLATFW1","KEYS","GAMEBAR","KROKO","BOSSNAKE","DRAGON","DOORS"]
landmark = bytes.fromhex("7682b43a9f068882")
for nm in names:
path = base+nm+".BOB"
data = open(path,"rb").read()
idx = data.find(landmark, 0, 64)
pre = data[:idx] if idx>=0 else None
print(f"{nm:10s} size={len(data):6d} landmark_at={idx:3d} preamble={pre.hex() if pre else 'NOTFOUND'}")
@@ -0,0 +1,51 @@
from PIL import Image
import math
def rle_decode_pcx_style(data):
out = bytearray()
i = 0
n = len(data)
while i < n:
b = data[i]
if (b & 0xC0) == 0xC0:
count = b & 0x3F
i += 1
if i >= n:
break
val = data[i]
out.extend([val]*count)
i += 1
else:
out.append(b)
i += 1
return bytes(out)
base = "/home/aria/kellogg_remake/extracted_pre/"
fn = "O.BOB"
data = open(base+fn, "rb").read()
dec = rle_decode_pcx_style(data)
print("decoded len:", len(dec))
widths = list(range(8, 60, 2))
cell_h = 80
cell_w = 60
cols = 8
rows = math.ceil(len(widths)/cols)
sheet = Image.new("L", (cols*cell_w, rows*cell_h), 40)
for idx, w in enumerate(widths):
h = (len(dec) + w - 1)//w
padded = dec + bytes(w*h - len(dec))
img = Image.frombytes("L", (w, h), padded)
# scale to fit cell, keep aspect
scale = min(cell_w/w, cell_h/h)
nw, nh = max(1,int(w*scale)), max(1,int(h*scale))
img_r = img.resize((nw, nh), Image.NEAREST)
cx = (idx % cols) * cell_w
cy = (idx // cols) * cell_h
sheet.paste(img_r, (cx, cy))
sheet = sheet.resize((sheet.width*3, sheet.height*3), Image.NEAREST)
outpath = "/home/aria/kellogg_remake/tools/o_bob_grid.png"
sheet.save(outpath)
print("saved", outpath, "widths:", widths)
@@ -0,0 +1,66 @@
from PIL import Image
import math, sys
def rle_decode_scanline_clipped(data, width, max_rows=400):
"""Decode with PCX-style per-scanline clipping: exactly `width` px per row,
overshoot from a run is discarded, continues to next row."""
rows = []
row = []
i = 0
n = len(data)
while i < n and len(rows) < max_rows:
if len(row) >= width:
rows.append(row)
row = []
continue
b = data[i]
if (b & 0xC0) == 0xC0:
count = b & 0x3F
i += 1
if i >= n:
break
val = data[i]
i += 1
need = width - len(row)
take = min(count, need)
row.extend([val]*take)
else:
row.append(b)
i += 1
if row:
row.extend([0]*(width-len(row)))
rows.append(row)
return rows
base = "/home/aria/kellogg_remake/extracted_pre/"
fn = sys.argv[1] if len(sys.argv) > 1 else "O.BOB"
skip = int(sys.argv[2]) if len(sys.argv) > 2 else 0
data = open(base+fn, "rb").read()[skip:]
widths = list(range(8, 60, 2))
cell_h = 90
cell_w = 60
cols = 8
rows_n = math.ceil(len(widths)/cols)
sheet = Image.new("L", (cols*cell_w, rows_n*cell_h), 40)
for idx, w in enumerate(widths):
rows = rle_decode_scanline_clipped(data, w, max_rows=cell_h)
h = len(rows)
if h == 0:
continue
flat = bytearray()
for r in rows:
flat.extend(r)
img = Image.frombytes("L", (w, h), bytes(flat))
scale = min(cell_w/w, cell_h/h)
nw, nh = max(1,int(w*scale)), max(1,int(h*scale))
img_r = img.resize((nw, nh), Image.NEAREST)
cx = (idx % cols) * cell_w
cy = (idx // cols) * cell_h
sheet.paste(img_r, (cx, cy))
sheet = sheet.resize((sheet.width*3, sheet.height*3), Image.NEAREST)
outpath = f"/home/aria/kellogg_remake/tools/{fn}_grid_clip_skip{skip}.png"
sheet.save(outpath)
print("saved", outpath, "widths:", widths)
@@ -0,0 +1,40 @@
import sys, struct
def rle_decode_pcx_style(data):
"""Decode assuming classic PCX RLE over the whole byte stream (no scanline boundary)."""
out = bytearray()
i = 0
n = len(data)
while i < n:
b = data[i]
if (b & 0xC0) == 0xC0:
count = b & 0x3F
i += 1
if i >= n:
break
val = data[i]
out.extend([val]*count)
i += 1
else:
out.append(b)
i += 1
return bytes(out)
def factor_pairs_near_sqrt(total, tol=0):
import math
res = []
for w in range(1, int(math.isqrt(total))+50):
if w == 0: continue
if total % w == 0:
h = total // w
res.append((w,h))
return res
files = ["A.BOB","B.BOB","E.BOB","N.BOB","O.BOB","D.BOB","H.BOB","I.BOB","J.BOB","K.BOB","TONY.BOB"]
base = "/home/aria/kellogg_remake/extracted_pre/"
for fn in files:
path = base+fn
data = open(path,"rb").read()
dec = rle_decode_pcx_style(data)
print(fn, "raw_len=",len(data), "decoded_len=",len(dec))
@@ -0,0 +1,64 @@
import sys
def try_decode_scanline(data, width, max_height=2000):
"""PCX-style per-scanline RLE decode: for each row, decode exactly `width`
pixels (excess from a run discarded), continue until input consumed.
Returns (rows_pixels_list, bytes_consumed, ok, error_msg)."""
i = 0
n = len(data)
rows = []
row = []
while i < n:
if len(row) >= width:
rows.append(row)
row = []
if len(rows) > max_height:
return rows, i, False, "too many rows"
continue
b = data[i]
if (b & 0xC0) == 0xC0:
count = b & 0x3F
i += 1
if i >= n:
return rows, i, False, "truncated run (missing value byte)"
val = data[i]
i += 1
need = width - len(row)
take = min(count, need)
row.extend([val]*take)
# NOTE: if count > need, the overshoot pixels for the *next* row
# are DISCARDED per classic PCX semantics (not carried over)
else:
row.append(b)
i += 1
if row:
# leftover partial row at EOF
rows.append(row)
return rows, i, False, f"EOF with partial row of {len(row)} px"
return rows, i, True, "clean"
def scan_widths(data, wmin=4, wmax=250):
results = []
for w in range(wmin, wmax+1):
rows, consumed, ok, msg = try_decode_scanline(data, w)
# figure out leftover partial-row size from msg if present
leftover = None
if "partial row of" in msg:
leftover = int(msg.split("partial row of")[1].split("px")[0].strip())
elif msg == "clean":
leftover = 0
results.append((w, len(rows), leftover, ok, msg))
return results
base = "/home/aria/kellogg_remake/extracted_pre/"
files = ["A.BOB","B.BOB","E.BOB","N.BOB","O.BOB","D.BOB","J.BOB"]
for fn in files:
data = open(base+fn,"rb").read()
print(f"=== {fn} (size={len(data)}) ===")
results = scan_widths(data)
clean = [r for r in results if r[2]==0]
if clean:
for r in clean:
print(" CLEAN (leftover=0):", r)
else:
print(" no width in range gives leftover==0")
@@ -0,0 +1,29 @@
import struct, sys
path = "/home/aria/kellogg_remake/raw/PCKELL.DAT"
data = open(path, "rb").read()
print("total size:", len(data))
# Read uint16 LE values until they stop being monotonically non-decreasing
vals = []
off = 0
prev = -1
while off + 2 <= len(data):
v = struct.unpack_from("<H", data, off)[0]
vals.append(v)
off += 2
if len(vals) > 5000:
break
# find longest monotonic non-decreasing prefix
mono_len = 1
for i in range(1, len(vals)):
if vals[i] >= vals[i-1]:
mono_len = i+1
else:
break
print("monotonic non-decreasing prefix length (uint16 entries):", mono_len)
print("that's byte offset:", mono_len*2)
print("first 40 vals:", vals[:40])
print("vals around break point:", vals[max(0,mono_len-10):mono_len+10])
@@ -0,0 +1,24 @@
import struct
path = "/home/aria/kellogg_remake/raw/PCKELL.DAT"
data = open(path, "rb").read()
vals = []
off = 0
while off + 2 <= len(data):
v = struct.unpack_from("<H", data, off)[0]
vals.append(v)
off += 2
start = 6 # skip first 6 uint16 (12 bytes) which look like a header
mono_len = start+1
for i in range(start+1, len(vals)):
if vals[i] >= vals[i-1]:
mono_len = i+1
else:
break
print("monotonic non-decreasing run starting at idx", start, "-> length", mono_len-start, "entries")
print("breaks at idx", mono_len, "byte offset", mono_len*2)
print("values around break:", vals[mono_len-5:mono_len+15])
print("last mono value:", vals[mono_len-1])
@@ -0,0 +1,12 @@
import struct
path = "/home/aria/kellogg_remake/raw/PCKELL.DAT"
data = open(path, "rb").read()
# look at region from 580 to 900, print as hex with offsets
start = 580
end = 900
for i in range(start, end, 16):
chunk = data[i:i+16]
hexs = ' '.join(f'{b:02x}' for b in chunk)
print(f'{i:06x}: {hexs}')
+16
View File
@@ -0,0 +1,16 @@
import struct
path = "/home/aria/kellogg_remake/raw/PCKELL.DAT"
data = open(path, "rb").read()
start = 588
end = 900
vals = []
off = start
while off+2 <= end:
v = struct.unpack_from('<H', data, off)[0]
vals.append((off, v))
off += 2
for off, v in vals:
print(off, hex(v), v)