From ac6a736d287a1a4f95884c1e2fda787061fb34fb Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 25 Jul 2026 14:59:11 +0200 Subject: [PATCH] Weltkarten-Daten fest eingebettet (SEGMENTS/LEVEL_NAMES), parse_worldmap raus aus dem Spiel -- Extraktor als tools/worldmap_extract.py, Laufzeit braucht nur noch PCKELL.DAT --- NOTES.md | 12 ++++ README.md | 13 ++-- game/worldmap.py | 143 +++++++++++++++++--------------------- tools/worldmap_extract.py | 99 ++++++++++++++++++++++++++ 4 files changed, 180 insertions(+), 87 deletions(-) create mode 100644 tools/worldmap_extract.py diff --git a/NOTES.md b/NOTES.md index f798eef..5236b12 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1777,3 +1777,15 @@ Verifikation: eigener Karten-Render vs. DOSBox-Frame, mittlerer Pixelfehler 5.0 (identisch mit dem Ganzbild-Grundrauschen der VGA-DAC-Quantisierung) in beiden Blinkphasen => pixelidentisch. --once exit 0. + +## 26.07.2026 (17) -- Weltkarten-Daten fest eingebettet, START.EXE-Abhaengigkeit weg + +Stefans Wunsch: Daten dauerhaft ins Spiel, parse_worldmap() raus. +Umgesetzt: SEGMENTS + LEVEL_NAMES als feste Tabellen in game/worldmap.py +(Rohkoordinaten wie im Binary, Transform/Anker unveraendert zur Laufzeit). +Der Parser lebt als Dev-Werkzeug tools/worldmap_extract.py weiter (gibt +die Tabelle als Python-Literal aus, nur fuer etwaige Regeneration). +Verifiziert: Extraktor-Output == eingebettete Tabelle, Karten-Render +weiterhin Pixelfehler 5.14 (= VGA-DAC-Grundrauschen) gegen die +DOSBox-Referenz in beiden Blinkphasen. --once exit 0. Das Spiel braucht +zur Laufzeit jetzt wieder NUR PCKELL.DAT. diff --git a/README.md b/README.md index 7670c82..7beee05 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,7 @@ der Python-Code hier ist eine eigenständige Neuimplementierung. - `Pillow` (PCX-Dekodierung), `numpy` (optional, für den Scanline-Effekt im Intro) - `gcc` (einmalig, um den TFMX-Renderer zu bauen — passiert automatisch beim ersten Start) -- Die Original-Datei `PCKELL.DAT` unter `game/original/` (nicht im Repo); - `START.EXE` daneben ist optional — daraus liest die Weltkarte zur Laufzeit - die Original-Pfad- und Levelpunkt-Daten (ohne sie: Fallback nur Level 1) +- Die Original-Datei `PCKELL.DAT` unter `game/original/` (nicht im Repo) ``` sudo apt install python3-pygame python3-pil python3-numpy gcc @@ -105,6 +103,8 @@ tools/ Reine Dev-Werkzeuge (Assets auspacken / als PNG ansehen) dat_extract.py PCKELL.DAT → alle 178 Einzeldateien (nach extracted_dat/) pcc_to_png.py Eine PCC-Bilddatei → PNG render_assets.py Batch-Render aller BOB/ICO/MAP → preview/render_out/ + worldmap_extract.py START.EXE → Weltkarten-Pfaddaten (Quelle der Tabelle + in game/worldmap.py; nur zur Regeneration nötig) preview/ Vorab gerenderte Assets zum Ansehen/Anhören (Archiv, KEINE Laufzeit-Abhängigkeit — das Spiel rendert alles selbst on the fly) png_out/ Vorab gerenderte PCC-Bilder @@ -116,9 +116,10 @@ NOTES.md Vollständiges Reverse-Engineering-Log `game/formats.py` ist das gemeinsame Fundament: sowohl das Spiel als auch die Dev-Werkzeuge in `tools/` importieren daraus. Zur Laufzeit braucht das Spiel -**nur** `game/original/PCKELL.DAT` (plus optional `START.EXE` für die -Weltkarten-Daten) — alles unter `preview/` sind Verifikations-/Archiv- -Artefakte, keine Laufzeit-Abhängigkeit. Auch die Musik unter `preview/audio/` +**nur** `game/original/PCKELL.DAT` — alles unter `preview/` sind Verifikations-/Archiv- +Artefakte, keine Laufzeit-Abhängigkeit. Die Weltkarten-Pfaddaten (einmalig +aus `START.EXE` extrahiert, siehe `tools/worldmap_extract.py`) sind fest in +`game/worldmap.py` eingebettet. Auch die Musik unter `preview/audio/` wird vom Spiel nicht gelesen: `game/tfmx_player/` rendert sie zur Laufzeit frisch aus `PCKELL.DAT` (nach `audio_cache/`). diff --git a/game/worldmap.py b/game/worldmap.py index f9c2504..9a4f93b 100644 --- a/game/worldmap.py +++ b/game/worldmap.py @@ -1,7 +1,9 @@ -"""Weltkarte mit Levelauswahl -- Pfad, Levelpunkte und Namen aus dem -START.EXE-Binary (NE-Datensegment), Marker/Koepfe aus KARTE0-9.PCC. +"""Weltkarte mit Levelauswahl -- Marker/Koepfe aus KARTE0-9.PCC, Pfad-, +Levelpunkt- und Namensdaten fest eingebettet (siehe SEGMENTS unten). -Binary-Struktur (Fund 25.07.2026, Offsets ~145114..146760 in START.EXE): +Die Daten wurden einmalig aus dem START.EXE-Binary extrahiert +(NE-Datensegment, Offsets ~145114..146760; Extraktor: +tools/worldmap_extract.py). Binary-Struktur dort, Fund 25.07.2026: Header, dann pro Level ein Segment aus 0xffff-terminierten u16-Records: [x y 0 25] Standpunkt (Wiederholung des vorigen Endpunkts) [x y 1 25] ... Wegpunkte (die Figur laeuft sie ab; 25 = Ticks) @@ -12,12 +14,8 @@ Binary-Struktur (Fund 25.07.2026, Offsets ~145114..146760 in START.EXE): Karten-Sprites: KARTE0/2 = grosser oranger/blauer Punkt, KARTE1/3 = kleine Varianten, KARTE4-7 = Mini-Koepfe (Tony, Smacks, Toucan, Coco) als -Spielfigur-Marker. Blink-Animation des aktiven Punkts laut Binary-Tabelle: -[delay 100] 900,902,900 = KARTE0/KARTE2-Wechsel. +Spielfigur-Marker. """ -import re -import struct -import sys import time import pygame @@ -46,64 +44,58 @@ def _tf(p): return (p[0], MAP_Y_FLIP - p[1]) -def parse_worldmap(exe_path): - '''Liest Pfadsegmente + Levelnamen aus START.EXE. Rueckgabe: - (segments, names) mit segments = Liste von dicts - {waypoints: [(x,y)...], stand: (x,y), label: (x,y)}. - Wirft bei Strukturbruch Exception (Aufrufer faellt zurueck).''' - d = open(exe_path, 'rb').read() - anchor = struct.pack('<4H', 58, 40, 0, 25) - start = d.find(anchor) - if start < 0: - raise ValueError('Kartenpfad-Anker nicht gefunden') - names_at = d.find(b'\x04W1L0\x04W1L1') - if names_at < 0: - raise ValueError('Levelnamen nicht gefunden') - # Namen lesen (22 Pascal-Strings) - names = [] - p = names_at - while len(names) < 22 and p < len(d) - 1: - ln = d[p] - if not (1 <= ln <= 8): - break - names.append(d[p + 1:p + 1 + ln].decode('ascii')) - p += 1 + ln +# 21 Pfadsegmente (Rohkoordinaten wie im Binary, y noch ungeflippt) -- +# extrahiert am 26.07.2026 aus START.EXE mit tools/worldmap_extract.py. +# Segment i gehoert zu LEVEL_NAMES[i+1]: W1L0 hat KEINEN Kartenpunkt +# (Bonus-/Geheimlevel?), die 21 Punkte sind W1L1..W3L7 (3 Welten x 7). +LEVEL_NAMES = ['W1L0', 'W1L1', 'W1L2', 'W1L3', 'W1L4', 'W1L5', 'W1L6', 'W1L7', + 'W2L1', 'W2L2', 'W2L3', 'W2L4', 'W2L5', 'W2L6', 'W2L7', + 'W3L1', 'W3L2', 'W3L3', 'W3L4', 'W3L5', 'W3L6', 'W3L7'] - # Records lesen - def read_list(p): - vals = [] - while p <= names_at - 2: - v = struct.unpack(' 8: - return None, p - return vals, p - - segments = [] - p = start - cur = {'waypoints': [], 'stand': None, 'label': None} - while p < names_at - 2: - vals, p = read_list(p) - if vals is None: - continue - if len(vals) == 4 and vals[3] == 25: - x, y, flag = vals[0], vals[1], vals[2] - if x > 320 or y > 200: - continue # Sonder-/Sprungrecords (Weltwechsel) ueberspringen - cur['waypoints'].append((x, y)) - if flag == 0 and len(cur['waypoints']) > 1: - cur['stand'] = (x, y) - elif len(vals) == 2: - cur['label'] = (vals[0], vals[1]) - if cur['stand'] is None and cur['waypoints']: - cur['stand'] = cur['waypoints'][-1] - segments.append(cur) - cur = {'waypoints': [], 'stand': None, 'label': None} - # len==1 ([0]-Flag) und leere Listen: ignorieren - return segments, names +SEGMENTS = [ + dict(waypoints=[(58, 40), (58, 40)], + stand=(58, 40), label=(53, 56)), + dict(waypoints=[(58, 40), (68, 37), (74, 36), (81, 36), (87, 35), (94, 35), (101, 35), (107, 35), (113, 35)], + stand=(113, 35), label=(108, 51)), + dict(waypoints=[(113, 35), (119, 41), (122, 47), (125, 53), (128, 58), (131, 65)], + stand=(131, 65), label=(126, 81)), + dict(waypoints=[(131, 65), (139, 58), (142, 52), (144, 47), (146, 42), (147, 37)], + stand=(147, 37), label=(142, 53)), + dict(waypoints=[(147, 37), (155, 40), (160, 47)], + stand=(160, 47), label=(155, 63)), + dict(waypoints=[(160, 47), (168, 44), (173, 42), (177, 42)], + stand=(177, 42), label=(172, 58)), + dict(waypoints=[(177, 42), (187, 39), (192, 39), (197, 39), (202, 41)], + stand=(202, 41), label=(197, 57)), + dict(waypoints=[(202, 41), (211, 39), (215, 42), (217, 47), (218, 53), (218, 59), (220, 65), (223, 75)], + stand=(223, 75), label=(218, 91)), + dict(waypoints=[(223, 75), (226, 79), (226, 85), (224, 91), (219, 93), (210, 96)], + stand=(210, 96), label=(205, 112)), + dict(waypoints=[(210, 96), (204, 93), (199, 93), (189, 96)], + stand=(189, 96), label=(184, 112)), + dict(waypoints=[(189, 96), (184, 94), (178, 92), (172, 90), (163, 91)], + stand=(163, 91), label=(158, 107)), + dict(waypoints=[(163, 91), (158, 89), (153, 89), (147, 89), (137, 91)], + stand=(137, 91), label=(132, 107)), + dict(waypoints=[(137, 91), (132, 89), (127, 89), (121, 89), (112, 91)], + stand=(112, 91), label=(107, 107)), + dict(waypoints=[(112, 91), (107, 89), (102, 86), (97, 84), (92, 82), (86, 81), (80, 80), (74, 79), (65, 81)], + stand=(65, 81), label=(60, 97)), + dict(waypoints=[(65, 81), (65, 86), (63, 92), (61, 98), (56, 103), (50, 113)], + stand=(50, 113), label=(45, 129)), + dict(waypoints=[(50, 113), (58, 113), (63, 116), (68, 120), (73, 123), (78, 127), (83, 130)], + stand=(83, 130), label=(78, 146)), + dict(waypoints=[(83, 130), (92, 129), (98, 130), (103, 130), (108, 131), (113, 132), (119, 134), (125, 135), (131, 137), (136, 139)], + stand=(136, 139), label=(131, 155)), + dict(waypoints=[(136, 139), (144, 138), (149, 139), (154, 140), (159, 141), (164, 142), (169, 143), (174, 144), (179, 147)], + stand=(179, 147), label=(174, 163)), + dict(waypoints=[(179, 147), (188, 145), (194, 145), (200, 142), (206, 138), (212, 135), (218, 135), (224, 136), (229, 139), (223, 141), (237, 143)], + stand=(237, 143), label=(232, 159)), + dict(waypoints=[(237, 143), (245, 138), (250, 134)], + stand=(250, 134), label=(245, 150)), + dict(waypoints=[(250, 134), (256, 137), (255, 144), (256, 151), (259, 158)], + stand=(259, 158), label=(254, 174)), +] class WorldMap: @@ -113,22 +105,11 @@ class WorldMap: self.assets = assets self.karte = menu_assets.karte self.font = menu_assets.font_small - exe = None - import os - base = os.path.dirname(os.path.abspath(assets.container.path)) \ - if hasattr(assets.container, 'path') else None - try: - from .intro import PROJECT_ROOT - exe = os.path.join(PROJECT_ROOT, 'game', 'original', 'START.EXE') - self.segments, self.names = parse_worldmap(exe) - except Exception as exc: - print(f'[worldmap] Binary-Pfaddaten nicht lesbar ({exc}) -- ' - f'Fallback nur Level 1', file=sys.stderr) - self.segments = [dict(waypoints=[(53, 56)], stand=(53, 56), label=(53, 56))] - self.names = ['W1L0', 'W1L1'] - # 21 Segmente fuer 21 anwaehlbare Level (W1L1..W3L7, 3 Welten x 7); - # W1L0 (Name Nr. 0) hat KEINEN Kartenpunkt -> segments[i] = names[i+1]. - self.level_names = self.names[1:] + self.segments = [dict(waypoints=list(s['waypoints']), + stand=s['stand'], label=s['label']) + for s in SEGMENTS] + # segments[i] gehoert zu LEVEL_NAMES[i+1] (W1L0 ohne Kartenpunkt) + self.level_names = LEVEL_NAMES[1:] # Positionen ins Screen-System transformieren (y-Flip) for seg in self.segments: seg['waypoints'] = [_tf(p) for p in seg['waypoints']] diff --git a/tools/worldmap_extract.py b/tools/worldmap_extract.py new file mode 100644 index 0000000..59e6ec4 --- /dev/null +++ b/tools/worldmap_extract.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Extrahiert die Weltkarten-Daten (Pfadsegmente, Levelpunkte, Levelnamen) +aus dem Original-START.EXE und gibt sie als Python-Literal aus -- damit +wurde die eingebettete SEGMENTS/LEVEL_NAMES-Tabelle in game/worldmap.py +erzeugt (26.07.2026). Nur noetig, falls die Tabelle je neu generiert +werden muss; das Spiel selbst liest START.EXE nicht mehr. + +Aufruf: python3 tools/worldmap_extract.py [pfad/zu/START.EXE] + +Binary-Struktur (NE-Datensegment, Offsets ~145114..146760): + Header, dann pro Level ein Segment aus 0xffff-terminierten u16-Records: + [x y 0 25] Standpunkt (Wiederholung des vorigen Endpunkts) + [x y 1 25] ... Wegpunkte (die Kartenfigur laeuft sie ab) + [x y 0 25] Endpunkt = Standpunkt an diesem Level + [label_x label_y] Position des Levelpunkt-Markers + [0] (Flag/Reserve) + danach die 22 Levelnamen als Pascal-Strings (\x04W1L0...). + y-Achse ist geflippt (Screen-Basis = (x, 218 - y), siehe game/worldmap.py). +""" +import os +import struct +import sys + + +def parse_worldmap(exe_path): + '''Rueckgabe: (segments, names); segments = Liste von dicts + {waypoints: [(x,y)...], stand: (x,y), label: (x,y)}.''' + d = open(exe_path, 'rb').read() + anchor = struct.pack('<4H', 58, 40, 0, 25) + start = d.find(anchor) + if start < 0: + raise ValueError('Kartenpfad-Anker nicht gefunden') + names_at = d.find(b'\x04W1L0\x04W1L1') + if names_at < 0: + raise ValueError('Levelnamen nicht gefunden') + # Namen lesen (22 Pascal-Strings) + names = [] + p = names_at + while len(names) < 22 and p < len(d) - 1: + ln = d[p] + if not (1 <= ln <= 8): + break + names.append(d[p + 1:p + 1 + ln].decode('ascii')) + p += 1 + ln + + def read_list(p): + vals = [] + while p <= names_at - 2: + v = struct.unpack(' 8: + return None, p + return vals, p + + segments = [] + p = start + cur = {'waypoints': [], 'stand': None, 'label': None} + while p < names_at - 2: + vals, p = read_list(p) + if vals is None: + continue + if len(vals) == 4 and vals[3] == 25: + x, y, flag = vals[0], vals[1], vals[2] + if x > 320 or y > 200: + continue # Sonder-/Sprungrecords (Weltwechsel) ueberspringen + cur['waypoints'].append((x, y)) + if flag == 0 and len(cur['waypoints']) > 1: + cur['stand'] = (x, y) + elif len(vals) == 2: + cur['label'] = (vals[0], vals[1]) + if cur['stand'] is None and cur['waypoints']: + cur['stand'] = cur['waypoints'][-1] + segments.append(cur) + cur = {'waypoints': [], 'stand': None, 'label': None} + # len==1 ([0]-Flag) und leere Listen: ignorieren + return segments, names + + +def main(): + default = os.path.join(os.path.dirname(__file__), '..', + 'game', 'original', 'START.EXE') + exe = sys.argv[1] if len(sys.argv) > 1 else default + segments, names = parse_worldmap(exe) + print('LEVEL_NAMES = %r' % (names,)) + print() + print('SEGMENTS = [') + for s in segments: + print(' dict(waypoints=%r,' % (s['waypoints'],)) + print(' stand=%r, label=%r),' % (s['stand'], s['label'])) + print(']') + print('# %d Segmente, %d Namen' % (len(segments), len(names)), + file=sys.stderr) + + +if __name__ == '__main__': + main()