dmcsb 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dmcsb/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ """dmcsb - tools for reverse-engineering Dungeon Master / Chaos Strikes Back data files.
2
+
3
+ Public API:
4
+
5
+ from dmcsb import open_graphics, GraphicsFile, DungeonFile
6
+
7
+ g = open_graphics("GRAPHICS.DAT") # GRAPHICS.DAT -> decoded assets
8
+ d = DungeonFile().load("DUNGEON.DAT") # DUNGEON.DAT -> maps / things
9
+
10
+ See dmcsb.graphics and dmcsb.dungeon for details.
11
+ """
12
+
13
+ from .graphics import (
14
+ GraphicsFile, open_graphics,
15
+ decode_img1, decode_fnt1, decode_snd2, decode_txt2, decode_lay1,
16
+ write_wav, lay1_to_text, load_lay1_zones,
17
+ load_palettes, load_map, item_types_for_count,
18
+ amiga12_to_rgb, DEFAULT_PALETTE, DEFAULT_SND_RATE,
19
+ NibbleReader, LAY1_TYPES,
20
+ __version__,
21
+ )
22
+ from .dungeon import (
23
+ DungeonFile, open_dungeon, decompress_dungeon,
24
+ THING_TYPES, THING_NONE, CELL_NAMES, SKILL_NAMES,
25
+ decode_dungeon_header, decode_map_info,
26
+ decode_dungeon_text, decode_champion, load_item_names,
27
+ decode_doorlist, decode_teleporterlist, decode_textstringlist,
28
+ decode_sensorlist, decode_creaturelist, decode_weaponlist,
29
+ decode_armorlist, decode_scrolllist, decode_potionlist,
30
+ decode_containerlist, decode_junklist, decode_projectilelist,
31
+ decode_explosionlist,
32
+ )
33
+
34
+ __all__ = [
35
+ "__version__",
36
+ "GraphicsFile", "open_graphics", "DungeonFile", "open_dungeon",
37
+ "decode_img1", "decode_fnt1", "decode_snd2", "decode_txt2", "decode_lay1",
38
+ "write_wav", "lay1_to_text", "load_lay1_zones",
39
+ "load_palettes", "load_map", "item_types_for_count",
40
+ "amiga12_to_rgb", "DEFAULT_PALETTE", "DEFAULT_SND_RATE",
41
+ "NibbleReader", "LAY1_TYPES",
42
+ "decompress_dungeon", "THING_TYPES", "THING_NONE", "CELL_NAMES", "SKILL_NAMES",
43
+ "decode_dungeon_header", "decode_map_info",
44
+ "decode_dungeon_text", "decode_champion", "load_item_names",
45
+ "decode_doorlist", "decode_teleporterlist", "decode_textstringlist",
46
+ "decode_sensorlist", "decode_creaturelist", "decode_weaponlist",
47
+ "decode_armorlist", "decode_scrolllist", "decode_potionlist",
48
+ "decode_containerlist", "decode_junklist", "decode_projectilelist",
49
+ "decode_explosionlist",
50
+ ]
dmcsb/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m dmcsb`` -> the dmcsb command-line front-end."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
dmcsb/cli.py ADDED
@@ -0,0 +1,361 @@
1
+ """
2
+ dmcsb.cli - command-line front-end for the dmcsb library (``dmcsb`` / ``python -m dmcsb``).
3
+
4
+ This is the sanctioned presentation layer: the library decoders in dmcsb.graphics /
5
+ dmcsb.dungeon stay silent (no prints), and all human-readable output lives here. The
6
+ ``examples/`` scripts are thin shims that import the functions below.
7
+
8
+ Subcommands:
9
+ dmcsb dump GRAPHICS.DAT [out_dir] [--map ...] [--palettes ...] [--zones ...]
10
+ dmcsb dungeon DUNGEON.DAT [--level N]
11
+ dmcsb uncompress DUNGEON.DAT [output.dat]
12
+ """
13
+
14
+ import argparse
15
+ import os
16
+ import re
17
+ import struct
18
+ import sys
19
+
20
+ from . import (open_graphics, open_dungeon, decompress_dungeon,
21
+ decode_img1, decode_fnt1, decode_snd2, decode_txt2, decode_lay1,
22
+ lay1_to_text, load_lay1_zones, write_wav,
23
+ DEFAULT_PALETTE, SKILL_NAMES)
24
+ from .graphics import _write_png
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # dump - GRAPHICS.DAT -> a folder of PNG/WAV/TXT/.bin + an HTML contact sheet
29
+ # ---------------------------------------------------------------------------
30
+ def _safe(name):
31
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("_")[:80]
32
+
33
+
34
+ def dump(path, out_dir="graphics_dump", map_path=None, palettes_path=None,
35
+ zones_path=None):
36
+ g = open_graphics(path, map=map_path, palettes=palettes_path, zones=zones_path)
37
+ os.makedirs(out_dir, exist_ok=True)
38
+
39
+ zones = load_lay1_zones(zones_path) # None -> bundled zones.txt
40
+ print("Map entries:", len(g.image_map),
41
+ "| Palettes:", len(g.named_palettes))
42
+
43
+ summary = {} # type -> count
44
+ failed = [] # (index, reason)
45
+ images = [] # (index, filename, w, h) for the contact sheet
46
+ sounds = 0 # SND items exported as WAV
47
+ texts = 0 # TXT2 items exported as TXT
48
+ layouts = 0 # LAY1 items exported as JSON
49
+
50
+ for i in range(g.item_count):
51
+ typ = g.item_type(i)
52
+ summary[typ] = summary.get(typ, 0) + 1
53
+
54
+ if typ == "COD":
55
+ continue # copy-protection code, not an image
56
+
57
+ try:
58
+ data = g.item_data(i) # LZW-decompressed if needed (Atari ST)
59
+ except NotImplementedError:
60
+ failed.append((i, "LZW needed (Atari ST) - see _lzw_decompress"))
61
+ continue
62
+ except Exception as e:
63
+ failed.append((i, "item_data: %s" % e))
64
+ continue
65
+
66
+ desc = g.name_for(i) # from the map (or None)
67
+
68
+ if typ == "IMG1":
69
+ try:
70
+ w, h, idx = decode_img1(data)
71
+ if w == 0 or h == 0:
72
+ failed.append((i, "empty image %dx%d" % (w, h)))
73
+ continue
74
+ ipal = g.palette_for(i)
75
+ rgb = bytearray(w * h * 3)
76
+ for p, ci in enumerate(idx):
77
+ rgb[p * 3:p * 3 + 3] = bytes(ipal[ci & 0xF])
78
+ tag = "_" + _safe(desc) if desc else ""
79
+ name = "img_%04d_%dx%d%s.png" % (i, w, h, tag)
80
+ _write_png(os.path.join(out_dir, name), w, h, bytes(rgb))
81
+ images.append((i, name, w, h))
82
+ except Exception as e:
83
+ failed.append((i, "IMG1 decode: %s" % e))
84
+ elif typ == "FNT1":
85
+ try:
86
+ w, h, idx = decode_fnt1(data) # 1-bit font -> 0/15
87
+ rgb = bytearray(w * h * 3)
88
+ for p, ci in enumerate(idx):
89
+ rgb[p * 3:p * 3 + 3] = bytes(DEFAULT_PALETTE[ci & 0xF])
90
+ tag = "_" + _safe(desc) if desc else ""
91
+ name = "img_%04d_%dx%d%s.png" % (i, w, h, tag)
92
+ _write_png(os.path.join(out_dir, name), w, h, bytes(rgb))
93
+ images.append((i, name, w, h))
94
+ except Exception as e:
95
+ failed.append((i, "FNT1 decode: %s" % e))
96
+ elif typ == "SND":
97
+ try:
98
+ pcm = decode_snd2(data)
99
+ tag = "_" + _safe(desc) if desc else ""
100
+ name = "snd_%04d%s.wav" % (i, tag)
101
+ write_wav(os.path.join(out_dir, name), pcm)
102
+ sounds += 1
103
+ except Exception as e:
104
+ failed.append((i, "SND2 decode: %s" % e))
105
+ elif typ == "TXT2":
106
+ try:
107
+ strings = decode_txt2(data)
108
+ tag = "_" + _safe(desc) if desc else ""
109
+ name = "txt_%04d%s.txt" % (i, tag)
110
+ with open(os.path.join(out_dir, name), "w", encoding="utf-8") as f:
111
+ f.write("\n".join(strings))
112
+ texts += 1
113
+ except Exception as e:
114
+ failed.append((i, "TXT2 decode: %s" % e))
115
+ elif typ == "LAY1":
116
+ # Raw binary = compact form for the engine (parse with decode_lay1).
117
+ tag = "_" + _safe(desc) if desc else ""
118
+ with open(os.path.join(out_dir, "lay_%04d%s.bin" % (i, tag)), "wb") as f:
119
+ f.write(data)
120
+ # Additionally a READABLE listing annotated with zone names
121
+ # (for inspection only; zones optional via --zones).
122
+ with open(os.path.join(out_dir, "lay_%04d%s.txt" % (i, tag)),
123
+ "w", encoding="utf-8") as f:
124
+ f.write(lay1_to_text(decode_lay1(data, zones), zones))
125
+ layouts += 1
126
+ else:
127
+ # Binary tables and others: save the raw data
128
+ tag = "_" + _safe(desc) if desc else ""
129
+ name = "%s_%04d%s.bin" % (typ.lower(), i, tag)
130
+ with open(os.path.join(out_dir, name), "wb") as f:
131
+ f.write(data)
132
+
133
+ _write_contact_sheet(os.path.join(out_dir, "index.html"), images, g)
134
+ _print_summary(g, summary, failed, images, sounds, texts, layouts, out_dir)
135
+
136
+
137
+ def _write_contact_sheet(path, images, g):
138
+ cells = []
139
+ for i, name, w, h in images:
140
+ disp_w = max(w * 2, 48) # scale up small sprites
141
+ cells.append(
142
+ '<figure><img src="%s" width="%d" '
143
+ 'style="image-rendering:pixelated;background:#222;border:1px solid #444">'
144
+ '<figcaption>#%d &middot; %d&times;%d</figcaption></figure>'
145
+ % (name, disp_w, i, w, h)
146
+ )
147
+ html = (
148
+ "<!doctype html><meta charset=utf-8>"
149
+ "<title>GRAPHICS.DAT Dump</title>"
150
+ "<style>body{background:#111;color:#ccc;font:13px sans-serif;margin:16px}"
151
+ "figure{display:inline-block;margin:6px;text-align:center;vertical-align:top}"
152
+ "figcaption{font-size:11px;color:#888;margin-top:2px}</style>"
153
+ "<h1>GRAPHICS.DAT &mdash; %d images (%s)</h1>%s"
154
+ % (len(images), g.format, "".join(cells))
155
+ )
156
+ with open(path, "w", encoding="utf-8") as f:
157
+ f.write(html)
158
+
159
+
160
+ def _print_summary(g, summary, failed, images, sounds, texts, layouts, out_dir):
161
+ print("Format:", g.format, "| Items total:", g.item_count)
162
+ print("Types :", ", ".join("%s=%d" % (t, n) for t, n in sorted(summary.items())))
163
+ print("Images as PNG:", len(images), "| Sounds as WAV:", sounds,
164
+ "| Texts as TXT:", texts, "| Layouts (.bin):", layouts)
165
+ if failed:
166
+ print("Not decoded:", len(failed))
167
+ for i, reason in failed[:20]:
168
+ print(" #%-4d %s" % (i, reason))
169
+ if len(failed) > 20:
170
+ print(" ... (%d more)" % (len(failed) - 20))
171
+ print("Contact sheet ->", os.path.join(out_dir, "index.html"))
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # dungeon - print one DUNGEON.DAT level (map, things, champions)
176
+ # ---------------------------------------------------------------------------
177
+ def print_level(d, level):
178
+ """Render one dungeon Level: header summary, ornament lists, ASCII tile map."""
179
+ map_info = d.mapsinfo[str(level)]
180
+
181
+ print(f"\nMap at Level {map_info['Level']} ----------------")
182
+ print(f" RawMapDataByteOffset: {map_info['RawMapDataByteOffset']}")
183
+ print(f" OffsetMapX: {map_info['OffsetMapX']}, OffsetMapY: {map_info['OffsetMapY']}")
184
+ print(f" Width: {map_info['Width']}, Height: {map_info['Height']}, Level: {map_info['Level']}")
185
+ print(f" RandomFloorOrnamentCount: {map_info['RandomFloorOrnamentCount']}")
186
+ print(f" FloorOrnamentCount: {map_info['FloorOrnamentCount']}")
187
+ print(f" RandomWallOrnamentCount: {map_info['RandomWallOrnamentCount']}")
188
+ print(f" WallOrnamentCount: {map_info['WallOrnamentCount']}")
189
+ print(f" Difficulty: {map_info['Difficulty']}")
190
+ print(f" CreatureTypeCount: {map_info['CreatureTypeCount']}")
191
+ print(f" DoorOrnamentCount: {map_info['DoorOrnamentCount']}")
192
+ print(f" DoorSet1: {map_info['DoorSet1']}, DoorSet0: {map_info['DoorSet0']}")
193
+ print(f" WallSet: {map_info['WallSet']}, FloorSet: {map_info['FloorSet']}")
194
+
195
+ # The ornament/creature index tables sit right after this map's tile grid.
196
+ w = map_info['Width'] + 1
197
+ h = map_info['Height'] + 1
198
+ base = map_info['RawMapDataByteOffset'] + (w * h)
199
+ counts = [
200
+ ("Creature", map_info['CreatureTypeCount']),
201
+ ("WallOrnate", map_info['WallOrnamentCount']),
202
+ ("FloorOrnate", map_info['FloorOrnamentCount']),
203
+ ("DoorDeco", map_info['DoorOrnamentCount']),
204
+ ]
205
+ print("\nCreatures/Wall/Floor/Doors used ----------------")
206
+ off = 0
207
+ parts = []
208
+ for name, count in counts:
209
+ vals = "".join(str(d.tile_data[base + off + c]) + ", " for c in range(count))
210
+ parts.append(f" {name}: {vals}")
211
+ off += count
212
+ print("\n".join(parts) + "\n")
213
+
214
+ print("MapData ----------------\n")
215
+ grid = d.tile_grid(level)
216
+ lines = []
217
+ for row in grid:
218
+ cells = []
219
+ for val in row:
220
+ kind = (val >> 5) & 0x7
221
+ cells.append(" " if kind == 0 else f"{kind} ")
222
+ lines.append(" " + "".join(cells))
223
+ print("\n".join(lines))
224
+
225
+
226
+ def print_things_on_level(d, level):
227
+ """Every Thing located on the selected level, grouped by type, with its map
228
+ (x, y) position. TextString things also show their decoded message."""
229
+ located = d.located_things(level)
230
+ grouped = {}
231
+ for item in located:
232
+ grouped.setdefault(item["type"], []).append(item)
233
+ print(f"\nThings on Level {level} ({len(located)}) ----------------")
234
+ for name, items in grouped.items():
235
+ print(f" {name}: {len(items)}")
236
+ for item in items:
237
+ pos = f"({item['x']},{item['y']}){item['dir']}"
238
+ rec = item["thing"]
239
+ if name == "TextString":
240
+ label = d.text(rec).replace("\n", " / ")
241
+ elif name == "Scroll":
242
+ txt = d.scroll_text(rec)
243
+ label = txt.replace("\n", " / ") if txt else None
244
+ else:
245
+ label = d.item_name(name, rec.get("Type"))
246
+ line = f" {pos:>9} {rec}"
247
+ if label:
248
+ line += f" -> {label!r}"
249
+ print(line)
250
+
251
+
252
+ def print_champions(d, level):
253
+ """Champions whose sheet sits on this level (e.g. the Hall of Champions)."""
254
+ champs = d.champions_on_level(level)
255
+ if not champs:
256
+ return
257
+ print(f"\nChampions on Level {level} ({len(champs)}) ----------------")
258
+ for c in champs:
259
+ pos = f"({c['x']},{c['y']}){c['dir']}"
260
+ title = (" " + c["title"]) if c["title"] else ""
261
+ print(f" {pos:>9} {c['name']}{title} [{c['gender']}] "
262
+ f"Health={c['Health']} Stamina={c['Stamina'] // 10} Mana={c['Mana']} "
263
+ f"Str={c['Strength']} Dex={c['Dexterity']} Wis={c['Wisdom']} "
264
+ f"Vit={c['Vitality']} AntiMagic={c['AntiMagic']} AntiFire={c['AntiFire']}")
265
+ # 16 hidden-skill starting levels, named (SKILL_NAMES[4:] in 4 groups).
266
+ for gi, basic in enumerate(("Fighter", "Ninja", "Priest", "Wizard")):
267
+ sub = SKILL_NAMES[4 + gi * 4: 8 + gi * 4]
268
+ named = " ".join(f"{n}={v}" for n, v in zip(sub, c[basic]))
269
+ print(f" {basic:8} {named}")
270
+
271
+
272
+ def show_dungeon(path, level):
273
+ """Load a DUNGEON.DAT and print one level. Returns a process exit code."""
274
+ d = open_dungeon(path)
275
+ if str(level) not in d.mapsinfo:
276
+ levels = ", ".join(sorted(d.mapsinfo, key=int))
277
+ print(f"No level {level} in this dungeon. Available levels: {levels}")
278
+ return 1
279
+ print_level(d, level)
280
+ print_things_on_level(d, level)
281
+ print_champions(d, level)
282
+ return 0
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # uncompress - DUNGEON.DAT -> raw decompressed bytes
287
+ # ---------------------------------------------------------------------------
288
+ def uncompress(in_path, out_path):
289
+ with open(in_path, "rb") as f:
290
+ buffer = f.read()
291
+
292
+ signature, decompressed_byte_count, _dungeon_id = struct.unpack(">HlH", buffer[:8])
293
+ if signature == 0x8104: # big-endian, compressed
294
+ data = bytes(decompress_dungeon(buffer[8:], decompressed_byte_count))
295
+ note = "decompressed %d -> %d bytes" % (len(buffer), len(data))
296
+ elif signature == 0x0481 or buffer[1] == 0x00: # little-endian variants
297
+ print("Little-endian (PC) DUNGEON.DAT is not supported yet.")
298
+ return 1
299
+ elif buffer[1] == 0x63: # big-endian, already uncompressed
300
+ data = buffer
301
+ note = "already uncompressed (%d bytes), copied as-is" % len(buffer)
302
+ else:
303
+ print("Not a recognized DUNGEON.DAT file.")
304
+ return 1
305
+
306
+ with open(out_path, "wb") as f:
307
+ f.write(data)
308
+ print("%s -> %s (%s)" % (in_path, out_path, note))
309
+ return 0
310
+
311
+
312
+ # ---------------------------------------------------------------------------
313
+ # argparse front-end
314
+ # ---------------------------------------------------------------------------
315
+ def build_parser():
316
+ p = argparse.ArgumentParser(
317
+ prog="dmcsb",
318
+ description="Tools for Dungeon Master / Chaos Strikes Back data files.")
319
+ sub = p.add_subparsers(dest="command", metavar="{dump,dungeon,uncompress}")
320
+
321
+ pd = sub.add_parser("dump", help="extract GRAPHICS.DAT to a folder of assets")
322
+ pd.add_argument("input", metavar="GRAPHICS.DAT")
323
+ pd.add_argument("out_dir", nargs="?", default="graphics_dump",
324
+ help="output directory (default: graphics_dump)")
325
+ pd.add_argument("--map", dest="map_path", help="override bundled graphics_map.txt")
326
+ pd.add_argument("--palettes", dest="palettes_path", help="override bundled palettes.txt")
327
+ pd.add_argument("--zones", dest="zones_path", help="override bundled zones.txt")
328
+
329
+ pg = sub.add_parser("dungeon", help="print one DUNGEON.DAT level")
330
+ pg.add_argument("input", metavar="DUNGEON.DAT")
331
+ pg.add_argument("--level", type=int, default=1, help="dungeon level (default: 1)")
332
+
333
+ pu = sub.add_parser("uncompress", help="write a decompressed copy of DUNGEON.DAT")
334
+ pu.add_argument("input", metavar="DUNGEON.DAT")
335
+ pu.add_argument("output", nargs="?",
336
+ help="output file (default: <input>.uncompressed)")
337
+
338
+ return p
339
+
340
+
341
+ def main(argv=None):
342
+ """Console entry point. Returns a process exit code."""
343
+ parser = build_parser()
344
+ args = parser.parse_args(argv)
345
+
346
+ if args.command == "dump":
347
+ dump(args.input, args.out_dir, map_path=args.map_path,
348
+ palettes_path=args.palettes_path, zones_path=args.zones_path)
349
+ return 0
350
+ if args.command == "dungeon":
351
+ return show_dungeon(args.input, args.level)
352
+ if args.command == "uncompress":
353
+ out = args.output or args.input + ".uncompressed"
354
+ return uncompress(args.input, out)
355
+
356
+ parser.print_help()
357
+ return 1
358
+
359
+
360
+ if __name__ == "__main__":
361
+ sys.exit(main())