Boot-Sequenz vermessen + Intro-Player gebaut, echten PCC-Decoder-Regressionsbug gefixt

- Regression gefunden+gefixt: pcc_to_png.py war durch einen frueheren Commit
  versehentlich auf den kaputten Hand-RLE-Decoder zurueckgefallen (der
  ImageMagick-Fix ging verloren). Jetzt: kellogg_formats.load_pcc() nutzt
  Pillows PCX-Decoder direkt, kein eigener RLE-Code mehr im Projekt. Alle
  77 PCC neu gerendert, AE=0 (pixelidentisch) zu den alten guten PNGs.
- Boot-Sequenz per echter DOSBox-Screenshot-Serie (0.3s-Intervall) vermessen:
  Rauser-Karte -> Factor5-Logo (statisch, keine Animation) -> Kellogg's-Logo
  mit Palette-Fade des "praesentiert"-Texts (Indizes 34/42/43/44/45) ->
  Titelbild "Tony & Friends in Kellogg's Land" mit echtem Vorhang-Wipe-Effekt
  (320x480 aus 4 Quadranten KELL256A-D, obere Haelfte faehrt runter, untere
  hoch, treffen sich in der Mitte). Korrigiert eine fruehere Fehlannahme:
  der Wipe sitzt auf dem TITELBILD, nicht auf dem Kellogg's-Markenlogo.
- tools/intro_sequence.py: erster spielbarer pygame-Meilenstein, baut die
  komplette Sequenz nach (Asset-Loading, Palette-Rendering, Timing, echter
  Palette-Fade, Wipe-Effekt). Headless getestet, visuell verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ARIA
2026-07-22 16:13:32 +00:00
co-authored by Claude Sonnet 5
parent 3622bd0a1a
commit ea1f5b951e
4 changed files with 456 additions and 89 deletions
+111
View File
@@ -778,3 +778,114 @@ zu einer Sequenz zusammengesetzt):**
geht und einfach hier weitergelesen werden kann. geht und einfach hier weitergelesen werden kann.
Laeuft jetzt selbstaendig weiter, keine Rueckfrage noetig. Laeuft jetzt selbstaendig weiter, keine Rueckfrage noetig.
## Boot-Sequenz vermessen + Intro-Player gebaut (2026-07-22 abends, Fortsetzung)
### 1) Echter Regressions-Bug gefunden + gefixt (tools/pcc_to_png.py)
Beim Draufschauen fuer die Intro-Arbeit aufgefallen: Commit `62af15e` hatte
den PCC-Decoder korrekt auf ImageMagick umgestellt (nachdem zwei eigene
Hand-RLE-Parser nachweislich falsche Pixel lieferten). Der naechste Commit
`3410a53` (eigentlich nur fuer BOB/ICO/MAP gedacht) hat `pcc_to_png.py`
aber **versehentlich durch eine AELTERE Version mit dem kaputten
Hand-RLE-Decoder ueberschrieben** -- vermutlich ein Merge-/Copy-Fehler beim
Zusammenfuehren von Aenderungen. Die bereits committeten 77 PNGs in
`png_out/` waren zum Glueck NICHT betroffen (die stammen noch von der guten
ImageMagick-Version), aber ein Rerun des Skripts haette wieder kaputte
Bilder erzeugt, ohne dass es aufgefallen waere.
**Fix:** Statt ImageMagick-Subprocess jetzt `kellogg_formats.load_pcc()` --
nutzt Pillows eingebauten, robusten PCX-Decoder direkt (`PIL.Image.open`
erkennt PCX an den Magic-Bytes, nicht am Dateinamen). Kein eigener
RLE-Parser mehr im Projekt, an keiner Stelle. Alle 77 PCC neu aus
`PCKELL.DAT` gerendert und via `compare -metric AE` **byte-fuer-byte
pixelidentisch (AE=0)** zu den alten (guten) PNGs verifiziert -- keine
Regression, sauberer Fix. Neue Funktionen `load_pcc()` / `pcc_to_rgba()`
in `kellogg_formats.py`, wiederverwendbar fuer den Intro-Player.
### 2) Boot-Sequenz per echter Screenshot-Serie vermessen (0.3s-Intervall)
`xvfb-run`-Setup (Xvfb + `import -window root` alle 0.3s) waehrend
`START.EXE` bootet, ~90 Frames ueber ~27s. Ergebnis (Hash-Dedup + visuelle
Pruefung der Uebergaenge):
1. **Rauser-Advertainment-Karte** -- Komposit aus `RAUSER1.PCC` (dunkle Box
"RAUSER"), `RAUSER3.PCC` (blaue Box, Ausrufezeichen), `RAUSER2.PCC`
("ADVERTAINMENT"-Schriftzug) auf weissem Grund. Statisch gehalten (kein
Aufbau-Effekt beobachtet), ~3s.
2. Kurze schwarze Pause (~0.3-0.5s).
3. **Factor5-Studio-Logo** (`FACTOR5.PCC`, 320x199) -- **komplett statisches
Bild**, keine Laufzeit-Animation. Das "Geister"-Doppellogo (zwei leicht
versetzte FACTOR-Schriftzuege + Spiegelung am Boden) ist Teil des Assets
selbst (Byte-Konsum exakt 100%, kein Bug). Lange gehalten, ~6s.
4. Kurze schwarze Pause.
5. **Kellogg's-Markenlogo** (`KELLOGGS.PCC`, 320x200) -- erscheint SOFORT
komplett und bleibt die ganze Zeit scharf. **KORREKTUR einer fruehen
Fehlannahme:** es gibt HIER keinen Vorhang-/Wipe-Effekt auf dem Logo
selbst. Was tatsaechlich passiert: der Text **"praesentiert"** darunter
faedet per **Palette-Animation** von blass (fast weiss) zu kraeftigem
Rot ein (~3-4s beobachtet). Per Index-Analyse verifiziert: die Pixel des
Textes nutzen exklusiv die Palette-Indizes **34, 42, 43, 44, 45** (Bbox
x=71-241, y=108-141 im 320x200-Bild) -- alles andere im Bild bleibt
waehrend des Fades unveraendert.
6. Kurze schwarze Pause.
7. **Titelbild "TONY & FRIENDS in Kellogg's Land"** -- **DAS ist der
eigentliche Vorhang-Effekt**, den Stefan urspruenglich meinte. Wichtiger
Fund: dieses Bild ist intern **320x480** (doppelt so hoch wie die
anderen Screens!), zusammengesetzt aus den 4 PCC-Quadranten `KELL256A`
(oben links) / `KELL256B` (oben rechts) / `KELL256C` (unten links) /
`KELL256D` (unten rechts), je 160x240 -- durch Stitchen zu einem
320x480-Gesamtbild verifiziert (siehe `/tmp/kell_full_stitch.png`
waehrend der Session, zeigt exakt das erwartete Titelbild). Der
Reveal-Effekt: **obere Bildhaelfte (A|B) faehrt von oben nach unten
ein, untere Haelfte (C|D) von unten nach oben** -- wie ein Theater-
vorhang, nur vertikal, beide Haelften treffen sich exakt in der Mitte
(y=240 im 480px-Bild). Dauer ~3s (Frames 73->83 in der Aufnahme).
DOSBox-Fenster ist fuer diesen Screen sichtbar groesser/anders
proportioniert als bei den 320x240-Screens davor -- passt zur These
eines eigenen 320x480-Modus nur fuer dieses Bild.
Alle anderen Screens (Rauser/Factor5/Kellogg's-Logo) laufen im
Fenster-Seitenverhaeltnis 320x240 (aus Trim-Analyse des DOSBox-Fensters:
640x480 Xvfb-Ausschnitt = exakt 2x 320x240) -- die PCC-Bilder selbst sind
oft nur 320x199/200 und werden darin mit schwarzen Balken oben/unten
letterboxed (gut sichtbar am schwarzen Rand um die Kellogg's-Box in den
Screenshots).
### 3) Erster spielbarer Meilenstein: `tools/intro_sequence.py`
pygame-Player, der die komplette oben beschriebene Sequenz nachbaut:
Asset-Loading aus `PCKELL.DAT` (ueber `kellogg_formats`), Bild-Rendering
mit Palette, Timing/Sequenzer, Palette-Fade-Effekt (echte Index-Animation,
kein Alpha-Crossfade-Hack), Vorhang-Wipe-Effekt fuer das Titelbild.
Headless getestet (Xvfb + Screenshot-Serie) -- **visuell verifiziert,
sieht in allen Phasen sehr nah am echten DOSBox-Boot aus**:
[FILE: /shared/uploads/aria_intro_rauser.png] (Rauser-Karte, Komposit)
[FILE: /shared/uploads/aria_intro_factor5.png] (Factor5-Logo, statisch)
[FILE: /shared/uploads/aria_intro_kelloggs_fade.png] ("praesentiert" mitten im Fade)
[FILE: /shared/uploads/aria_intro_titel_wipe.png] (Titelbild-Vorhang mitten im Reveal)
[FILE: /shared/uploads/aria_intro_titel_final.png] (Titelbild komplett offen)
**Bekannte Ungenauigkeiten (v1, nicht pixelperfekt, aber nah dran):**
- Rauser-Komposit-Layout (Position von RAUSER1/2/3 zueinander) ist per
Augenmass an die Screenshots angenaehert, nicht pixelgenau vermessen --
bei Bedarf spaeter per genauerem Screenshot-Vergleich nachjustieren.
- Fade-/Wipe-Dauern sind aus der 0.3s-Sample-Screenshot-Serie abgeleitet
(+/- ein paar hundert ms Unschaerfe), nicht aus dem Originalcode gelesen
(RTM.EXE ist compiliert, kein Quellcode verfuegbar).
- Sound fehlt komplett (TFMX/SAM noch nicht decodiert, siehe unten).
**Ausfuehren:** `python3 tools/intro_sequence.py` auf `aria-wohnung`
(pygame jetzt per `apt install python3-pygame` installiert, NICHT pip --
System-Python ist "externally managed", pip-User-Install schlug fehl).
`--once` fuer einen Durchlauf statt Endlosschleife wie im Original.
### Naechste Schritte (Prioritaet)
1. Sound (SAM/TFX TFMX-Format) decodieren + an die Intro-Sequenz haengen --
`tools/tfmx_player/` (C-Referenztool) liegt bereit.
2. Rauser-Layout und Fade/Wipe-Timings feinjustieren falls gewuenscht.
3. Danach: Uebergang von Intro zu echtem Gameplay -- Tony-Bewegung auf
Level W1L0 als naechster grosser Meilenstein (MAP+ICO+BOB sind fertig).
Laeuft selbstaendig weiter, keine Rueckfrage noetig.
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""
Boot-/Intro-Sequenz von "Tony & Friends in Kellogg's Land" -- erster
spielbarer (bzw. hier: zuschaubarer) pygame-Meilenstein des Remakes.
Reihenfolge + Timing wurde am 22.07.2026 durch eine echte DOSBox-Screenshot-
Serie (0.3s-Intervall) waehrend des Originalboots vermessen, siehe NOTES.md
Abschnitt "Boot-Sequenz vermessen". Kurzfassung:
1. Rauser-Advertainment-Karte (Komposit aus RAUSER1+2+3.PCC), ~3s
2. kurze schwarze Pause
3. Factor5-Studio-Logo (FACTOR5.PCC, STATISCHES Bild -- keine Animation,
das "Geister"-Doppellogo ist Teil des Assets selbst), ~6s
4. kurze schwarze Pause
5. Kellogg's-Markenlogo (KELLOGGS.PCC) erscheint SOFORT komplett; darunter
faedet der Text "praesentiert" per Palette-Fade von blass nach saettigt-
rot ein (~3s). WICHTIG: das ist NICHT der vom Stefan vermutete "Vorhang"-
Effekt auf dem Logo selbst -- das Logo ist die ganze Zeit scharf/komplett
da, nur der Text darunter faedet.
6. kurze schwarze Pause
7. Titelbild "TONY & FRIENDS in Kellogg's Land" -- DAS ist der eigentliche
Vorhang-Effekt: das Bild ist intern 320x480 (doppelt so hoch wie die
anderen 320x200/240-Screens!), zusammengesetzt aus den 4 Quadranten
KELL256A (oben links) / B (oben rechts) / C (unten links) / D (unten
rechts), je 160x240. Obere Haelfte (A|B) faehrt von oben nach unten ein,
untere Haelfte (C|D) von unten nach oben -- wie ein Theatervorhang, nur
vertikal, beide Haelften treffen sich in der Bildmitte. Dauer ~3s.
Alle Nicht-BOB/ICO/MAP-Assets werden per kellogg_formats.load_pcc() (Pillows
PCX-Decoder) aus PCKELL.DAT gelesen -- kein eigener RLE-Code mehr (siehe
Regressions-Warnung in pcc_to_png.py).
Rauser-Layout und Fade-Text-Bounding-Box sind aus den echten Screenshots per
Augenmass/Index-Analyse bestimmt (siehe tools/analyze_kelloggs.py-Fund:
Indizes 34/42/43/44/45 in KELLOGGS.PCC = "praesentiert"-Text, bbox x71-241
y108-141) -- nicht 100% pixelidentisch zum Original, aber nah dran. Kann bei
Bedarf spaeter nachjustiert werden.
Aufruf: python3 intro_sequence.py [--once] (--once: nach einem Durchlauf beenden,
sonst loopt die Sequenz weiter wie im Original-Titelbildschirm)
"""
import sys
import os
import time
sys.path.insert(0, os.path.dirname(__file__))
from kellogg_formats import DATContainer, load_pcc
import pygame
DAT_PATH = os.environ.get('KELLOGG_DAT', '/home/aria/kellogg_remake/raw/PCKELL.DAT')
WINDOW_W, WINDOW_H = 960, 720
BG_COLOR = (0, 0, 0)
# "praesentiert"-Text-Faelle in KELLOGGS.PCC (siehe Docstring)
PRAESENTIERT_FADE_INDICES = [34, 42, 43, 44, 45]
PRAESENTIERT_FADE_SECONDS = 2.5
def indices_to_surface(width, height, indices, palette):
'''Baut ein pygame.Surface (RGB) aus Palette-Indizes + (r,g,b)-Liste.'''
buf = bytearray(width * height * 3)
for i, idx in enumerate(indices):
r, g, b = palette[idx & 0xFF]
buf[i*3:i*3+3] = bytes([r, g, b])
surf = pygame.image.frombuffer(bytes(buf), (width, height), 'RGB')
return surf.convert()
class Assets:
def __init__(self, dat_path):
self.container = DATContainer(dat_path)
def pcc(self, name):
'''name z.B. "RAUSER1" (ohne .PCC). Gibt (w,h,indices,palette) zurueck.'''
return load_pcc(self.container.entries[name + '.PCC'])
def pcc_surface(self, name):
w, h, indices, palette = self.pcc(name)
return indices_to_surface(w, h, indices, palette)
def fit_scale(src_w, src_h, max_w, max_h):
return min(max_w / src_w, max_h / src_h)
def blit_scaled_centered(screen, surface, window_w, window_h):
scale = fit_scale(surface.get_width(), surface.get_height(), window_w, window_h)
new_size = (max(1, int(surface.get_width() * scale)), max(1, int(surface.get_height() * scale)))
scaled = pygame.transform.scale(surface, new_size)
x = (window_w - new_size[0]) // 2
y = (window_h - new_size[1]) // 2
screen.fill(BG_COLOR)
screen.blit(scaled, (x, y))
return scale, x, y
def wait_or_skip(clock, seconds, screen_update_fn, fps=30):
'''Laeuft seconds lang, ruft pro Frame screen_update_fn(t_norm 0..1) auf.
Bricht per ESC/Klick vorzeitig ab (gibt True zurueck wenn User quit will).'''
start = time.monotonic()
while True:
now = time.monotonic()
t = (now - start) / seconds if seconds > 0 else 1.0
if t >= 1.0:
screen_update_fn(1.0)
pygame.display.flip()
return False
for event in pygame.event.get():
if event.type == pygame.QUIT:
return True
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
return True
screen_update_fn(t)
pygame.display.flip()
clock.tick(fps)
def build_rauser_composite(assets):
'''RAUSER1 (dunkle Box "RAUSER") + RAUSER3 (Ausrufezeichen, blaue Box)
+ RAUSER2 ("ADVERTAINMENT"-Schriftzug) zu EINER Karte auf weissem Grund
zusammengesetzt (320x240 nativ, wie der Rest der Boot-Screens).'''
canvas = pygame.Surface((320, 240))
canvas.fill((255, 255, 255))
r1 = assets.pcc_surface('RAUSER1') # 182x46 "RAUSER"
r2 = assets.pcc_surface('RAUSER2') # 228x21 "ADVERTAINMENT"
r3 = assets.pcc_surface('RAUSER3') # 16x55 "!"
total_w = max(r1.get_width() + r3.get_width(), r2.get_width())
x0 = (320 - total_w) // 2
y0 = (240 - (r1.get_height() + r2.get_height())) // 2
canvas.blit(r1, (x0, y0))
canvas.blit(r3, (x0 + r1.get_width(), y0 - (r3.get_height() - r1.get_height()) // 2))
canvas.blit(r2, (x0, y0 + r1.get_height()))
return canvas
def build_letterboxed(surface, canvas_w=320, canvas_h=240):
'''Zentriert ein kleineres PCC-Bild (z.B. 320x199/200) auf eine 320x240-
Leinwand mit schwarzen Balken -- wie im echten DOSBox-Fenster beobachtet.'''
canvas = pygame.Surface((canvas_w, canvas_h))
canvas.fill((0, 0, 0))
x = (canvas_w - surface.get_width()) // 2
y = (canvas_h - surface.get_height()) // 2
canvas.blit(surface, (x, y))
return canvas
def build_kelloggs_frame(w, h, indices, palette, fade_t):
'''fade_t: 0..1, faedet PRAESENTIERT_FADE_INDICES von blass (Richtung
Hintergrundweiss) zu ihrer echten Palettenfarbe.'''
dyn_pal = list(palette)
for idx in PRAESENTIERT_FADE_INDICES:
r, g, b = palette[idx]
# start: sehr blass (nah am weissen Logo-Hintergrund), Ende: echte Farbe
pale = (255, 245, 245)
nr = int(pale[0] + (r - pale[0]) * fade_t)
ng = int(pale[1] + (g - pale[1]) * fade_t)
nb = int(pale[2] + (b - pale[2]) * fade_t)
dyn_pal[idx] = (nr, ng, nb)
return indices_to_surface(w, h, indices, dyn_pal)
def build_title_image(assets):
'''Stitcht KELL256A/B (oben) + C/D (unten) zu einem 320x480-Gesamtbild.'''
a = assets.pcc_surface('KELL256A')
b = assets.pcc_surface('KELL256B')
c = assets.pcc_surface('KELL256C')
d = assets.pcc_surface('KELL256D')
w = a.get_width() + b.get_width()
h = a.get_height() + c.get_height()
canvas = pygame.Surface((w, h))
canvas.blit(a, (0, 0))
canvas.blit(b, (a.get_width(), 0))
canvas.blit(c, (0, a.get_height()))
canvas.blit(d, (a.get_width(), a.get_height()))
return canvas, a.get_height() # (Gesamtbild, Hoehe der oberen Haelfte)
def run_once(screen, clock, assets):
quit_requested = False
# 1) Rauser
rauser = build_rauser_composite(assets)
def show_rauser(_t):
blit_scaled_centered(screen, rauser, WINDOW_W, WINDOW_H)
quit_requested = wait_or_skip(clock, 3.0, show_rauser)
if quit_requested:
return True
# 2) schwarze Pause
screen.fill(BG_COLOR)
pygame.display.flip()
quit_requested = wait_or_skip(clock, 0.5, lambda t: screen.fill(BG_COLOR))
if quit_requested:
return True
# 3) Factor5 (statisch)
factor5 = build_letterboxed(assets.pcc_surface('FACTOR5'))
def show_factor5(_t):
blit_scaled_centered(screen, factor5, WINDOW_W, WINDOW_H)
quit_requested = wait_or_skip(clock, 3.0, show_factor5)
if quit_requested:
return True
# 4) schwarze Pause
quit_requested = wait_or_skip(clock, 0.5, lambda t: screen.fill(BG_COLOR))
if quit_requested:
return True
# 5) Kellogg's-Logo + "praesentiert"-Palettenfade
kw, kh, kidx, kpal = assets.pcc('KELLOGGS')
def show_kelloggs(t):
frame = build_kelloggs_frame(kw, kh, kidx, kpal, t)
letterboxed = build_letterboxed(frame)
blit_scaled_centered(screen, letterboxed, WINDOW_W, WINDOW_H)
quit_requested = wait_or_skip(clock, PRAESENTIERT_FADE_SECONDS, show_kelloggs)
if quit_requested:
return True
# kurz mit vollem Text stehen lassen
quit_requested = wait_or_skip(clock, 1.0, lambda t: show_kelloggs(1.0))
if quit_requested:
return True
# 6) schwarze Pause
quit_requested = wait_or_skip(clock, 0.5, lambda t: screen.fill(BG_COLOR))
if quit_requested:
return True
# 7) Titelbild -- Vorhang-Wipe (obere Haelfte faehrt runter, untere hoch)
title_img, half_h = build_title_image(assets)
full_w, full_h = title_img.get_size()
def show_title_wipe(t):
canvas = pygame.Surface((full_w, full_h))
canvas.fill((0, 0, 0))
reveal = int(half_h * t)
if reveal > 0:
top_src = pygame.Surface.subsurface(title_img, (0, half_h - reveal, full_w, reveal))
canvas.blit(top_src, (0, 0))
bottom_src = pygame.Surface.subsurface(title_img, (0, half_h, full_w, reveal))
canvas.blit(bottom_src, (0, full_h - reveal))
blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H)
quit_requested = wait_or_skip(clock, 3.0, show_title_wipe)
if quit_requested:
return True
# danach: fertig zusammengesetztes Titelbild halten
def show_title_full(_t):
blit_scaled_centered(screen, title_img, WINDOW_W, WINDOW_H)
quit_requested = wait_or_skip(clock, 3.0, show_title_full)
return quit_requested
def main():
once = '--once' in sys.argv
pygame.init()
screen = pygame.display.set_mode((WINDOW_W, WINDOW_H))
pygame.display.set_caption("Tony & Friends in Kellogg's Land -- Intro (Remake)")
clock = pygame.time.Clock()
assets = Assets(DAT_PATH)
quit_requested = run_once(screen, clock, assets)
while not quit_requested and not once:
quit_requested = run_once(screen, clock, assets)
pygame.quit()
if __name__ == '__main__':
main()
+41
View File
@@ -61,6 +61,47 @@ def get_pcx_palette(pcc_bytes):
return [tuple(pal_bytes[i:i+3]) for i in range(0, 768, 3)] 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): def get_bob_palette(entries, name):
'''name = basename without extension, e.g. 'TONY' for TONY.BOB. '''name = basename without extension, e.g. 'TONY' for TONY.BOB.
Implements the palette-selection rules from Form1.cs (BOB case).''' Implements the palette-selection rules from Form1.cs (BOB case).'''
+26 -89
View File
@@ -1,105 +1,42 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Finaler PCC-Decoder fuer Kellogg's Tony and Friends (2026-07-22, ARIA). """PCC-Decoder fuer Kellogg's Tony and Friends -- nutzt Pillows PCX-Decoder.
Format (verifiziert gegen MENU.PCC/RAUSER1-3/FACTOR5, visuell + byte-exakt): .PCC ist stinknormales PCX v5 (8bpp, RLE, eigene 256-Farb-Palette am
- Byte 0-15: echter PCX-Header-Anfang (Manufacturer=0x0A, Version=5, Dateiende) -- bestaetigt gegen https://github.com/movAX13h/tony-and-friends-in-kelloggs-land.
Encoding=1/RLE, BPP=8, dann Xmin/Ymin/Xmax/Ymax als LE16 bei Offset 4-11).
WICHTIG (Bugfix 2026-07-22): Width/Height MUESSEN aus Xmax-Xmin+1 /
Ymax-Ymin+1 berechnet werden. Die 2 LE16-Werte bei Offset 12-15 sehen fuer
Vollbild-Screens (320x200) zufaellig identisch aus und wurden erst dafuer
gehalten -- sind aber tatsaechlich NICHT die Bilddimensionen (vermutlich
ein DPI/Reserved-Feld wie im echten 128-Byte-PCX-Header), sondern ein
Konstantwert der bei kleinen Sprites/Logos (z.B. RAUSER1.PCC: 182x46,
FAC0.PCC: 16x16) komplett falsch war und zu kaputten/leeren Bildern fuehrte.
Mit dem Xmax/Ymax-Fix konsumiert z.B. FACTOR5.PCC exakt 100% der
komprimierten Bytes (51609/51609) statt vorher mit falscher Hoehe (200
statt echten 199) zu ueberlaufen.
- Byte 16 .. (len-769): RLE-komprimierte Pixel-Indexdaten, SCANLINE-weise
decodiert (pro Zeile wird bis width Pixel gefuellt, ueberschuessige Pixel
eines Runs am Zeilenende werden verworfen -- klassisches PCX-Verhalten).
RLE-Tupel: Byte mit oberen 2 Bits gesetzt (0xC0-0xFF) = Lauflaenge (&0x3F),
gefolgt von einem Wert-Byte. Sonst literaler Pixel.
- Letzte 769 Bytes: 0x0C-Marker + 768 Byte (256 x RGB) eingebettete Palette
(klassische PCX-v5-256-Farben-Erweiterung). PRO DATEI eigene Palette,
keine globale Palette noetig.
BEKANNTER OFFENER BUG (Stand 2026-07-22, noch nicht geloest): REGRESSIONS-WARNUNG (gefunden 2026-07-22 abends): Commit 62af15e hatte hier
KELLOGGS.PCC und KARTE.PCC (volle 320x200-Screens) zeigen weiterhin einen schon mal korrekt auf ImageMagick (`convert pcx:...`) umgestellt, nachdem
lokalen Deko-Fehler (ein "Kerbe"/Notch-Artefakt rechts neben dem Kellogg's- ZWEI eigene Hand-RLE-Parser nachweislich falsche Pixel lieferten (Bild
Schriftzug, roter/gelber Fleck der nicht ins Referenz-Screenshot passt). verschoben/dupliziert bei Vollbild-Screens wie KARTE.PCC/KELLOGGS.PCC).
Ausgeschlossen als Ursache: (a) horizontales Rollen/Verschieben des ganzen Der naechste Commit (3410a53, eigentlich nur fuer BOB/ICO/MAP gedacht) hat
Bilds -- getestet mit 18 Shift-Kandidaten, Artefakt bleibt IMMER an diese Datei aber versehentlich durch eine AELTERE Version mit dem kaputten
derselben Position relativ zum Bildinhalt, nicht zur Leinwand -> kein Hand-Decoder ersetzt -- die bereits committeten PNGs in png_out/ waren davon
Rotations-/Scroll-Bug. (b) Zeilenweises Verwerfen von RLE-Overshoot -- zum Glueck nicht betroffen (die sind noch von der guten ImageMagick-Version),
zeilenbasierte und "kontinuierliche" (ohne Row-Reset) Decodierung liefern aber ein Rerun dieses Skripts haette wieder kaputte Bilder erzeugt. Fix jetzt:
BYTE-IDENTISCHE Ergebnisse fuer diese Dateien. Vermutung: entweder eine Pillow statt Hand-RLE oder externem `convert`-Subprocess -- kein Format-
Palette-Fehlzuordnung fuer einzelne Indizes, oder eine RLE-Ambiguitaet bei Parsing mehr in eigenem Code, nutzt tools/kellogg_formats.load_pcc().
literalen Pixelwerten >= 0xC0 (die durch (b&0xC0)==0xC0 faelschlich als
Lauflaengen-Token statt als literaler Indexwert interpretiert werden
koennten) -- noch nicht verifiziert. FACTOR5.PCC's "Geister"-Doppellogo
ist dagegen vermutlich KEIN Bug, sondern ein Reflexions-/Schatten-
Designelement (Byte-Konsum ist bei diesem File exakt 100%).
Nutzung: python3 pcc_to_png.py <input.PCC> <output.png> Nutzung: python3 pcc_to_png.py <input.PCC> <output.png>
Schreibt ein PPM und konvertiert via ImageMagick `convert` zu PNG.
""" """
import sys, struct, subprocess, os import sys, os
sys.path.insert(0, os.path.dirname(__file__))
from kellogg_formats import load_pcc
def decode_pcc(data):
manuf, version, encoding, bpp = data[0], data[1], data[2], data[3]
xmin, ymin, xmax, ymax = struct.unpack('<HHHH', data[4:12])
width, height = xmax - xmin + 1, ymax - ymin + 1
pal_start = len(data) - 769
marker = data[pal_start]
pixel_region = data[16:pal_start]
pal_bytes = data[pal_start+1:pal_start+1+768]
palette = [(pal_bytes[i], pal_bytes[i+1], pal_bytes[i+2]) for i in range(0, 768, 3)]
out = bytearray()
i = 0
n = len(pixel_region)
for row in range(height):
row_out = bytearray()
while len(row_out) < width and i < n:
b = pixel_region[i]; i += 1
if (b & 0xC0) == 0xC0:
count = b & 0x3F
if i >= n:
break
val = pixel_region[i]; i += 1
row_out.extend([val] * count)
else:
row_out.append(b)
row_out = row_out[:width]
if len(row_out) < width:
row_out.extend([0] * (width - len(row_out)))
out.extend(row_out)
return dict(width=width, height=height, marker_ok=(marker == 0x0C),
pixels=bytes(out), palette=palette,
consumed=i, pixel_region_len=n)
def write_png(pixels, palette, width, height, out_png):
ppm = out_png + '.ppm'
with open(ppm, 'wb') as f:
f.write(f'P6\n{width} {height}\n255\n'.encode())
buf = bytearray()
for px in pixels[:width*height]:
r, g, b = palette[px]
buf.extend([r, g, b])
f.write(bytes(buf))
subprocess.run(['convert', ppm, out_png], check=True)
os.remove(ppm)
def main(): def main():
inp, outp = sys.argv[1], sys.argv[2] inp, outp = sys.argv[1], sys.argv[2]
with open(inp, 'rb') as f: with open(inp, 'rb') as f:
data = f.read() data = f.read()
res = decode_pcc(data) width, height, indices, palette = load_pcc(data)
print(f"{inp}: {res['width']}x{res['height']} marker_ok={res['marker_ok']} " print(f"{inp}: {width}x{height}")
f"consumed={res['consumed']}/{res['pixel_region_len']}")
write_png(res['pixels'], res['palette'], res['width'], res['height'], outp) from PIL import Image
img = Image.new('P', (width, height))
img.putpalette([c for rgb in palette for c in rgb])
img.putdata(indices)
img.convert('RGB').save(outp)
print(f"-> {outp}") print(f"-> {outp}")
if __name__ == '__main__': if __name__ == '__main__':
main() main()