#!/usr/bin/env python3 """ bsl.py -- reader for DIV-BSL2 ".BSL" bit-slice texture packs. A BSL packs several 4-bit mono textures ("slices" A..F) into the nibbles of one 32-bit texel map -- the formalised successor of GLOMM's /m: packing (GLOMM.C). Materials reference a slice via `MAP {"name"} + BITSLICE {k}` in .VMF/.BMF libraries; the material's DIFFUSE colours the mono plane. Layout (reversed from STEH.BSL / F15_GLOM.BSL): "DIV-BSL2" 8-byte magic u32 version, u32 ? (0x100, 1) u32 bits_per_slice (4) u32 dir_bytes directory size hint u32 n_slices n x { u32 type, u32 slice_index, cstr name } ... texel words follow; data size = edge*edge*4, edge inferred from remaining bytes (64/128/256), same convention as .SVT Slice k occupies a nibble of each 32-bit word; the exact nibble order was determined empirically (see spec): slice k = bits [28-4k .. 31-4k]. python bsl.py info python bsl.py png [out.png] python bsl.py probe # dump ALL 8 nibble planes for eyeballing """ import struct import sys import os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import svt # PNG writer SIZE_TO_EDGE = {16384 * 4: 256, 65536: 128, 16384: 64, 262144: 256} def load(path): with open(path, "rb") as fp: data = fp.read() if data[:8] != b"DIV-BSL2": raise ValueError("Not a DIV-BSL2 file") pos = 8 version, one, bits, dirbytes, nslices = struct.unpack_from("<5i", data, pos) pos += 20 entries = [] for _ in range(nslices): typ, idx = struct.unpack_from("<2i", data, pos) pos += 8 end = data.index(b"\0", pos) name = data[pos:end].decode("latin-1") pos = end + 1 entries.append({"name": name, "type": typ, "slice": idx}) body = data[pos:] edge = {16384: 64, 65536: 128, 262144: 256}.get(len(body)) if edge is None: # tolerate padding: use largest fitting square for sz, e in ((262144, 256), (65536, 128), (16384, 64)): if len(body) >= sz: body = body[:sz] edge = e break words = struct.unpack("<%dI" % (edge * edge), body) return {"entries": entries, "edge": edge, "words": words, "bits": bits, "version": version} def nibble_plane(b, nib): """Extract nibble `nib` (0 = bits 0-3 ... 7 = bits 28-31) as 8-bit luminance.""" edge, words = b["edge"], b["words"] shift = nib * 4 out = bytearray(edge * edge * 4) for i, w in enumerate(words): v = (w >> shift) & 0xF L = v * 17 # 0..15 -> 0..255 o = i * 4 out[o] = out[o + 1] = out[o + 2] = L out[o + 3] = 255 return bytes(out) def slice_nibble(slice_index): """slice k -> nibble (k+2)^1. Slices occupy bits 8..31 in ascending order, pair-swapped within each byte by the writer's 32-bit byte reversal (as in GLOMM's save_svt); the low byte (nibbles 0-1) is reserved for an 8-bit colour plane. Verified by correlating all 12 source TGAs (STEH1-6, KLNG1-6) against every nibble plane: r >= 0.98 for this mapping on every slice of both packs.""" return (slice_index + 2) ^ 1 def slice_plane(b, slice_index): return nibble_plane(b, slice_nibble(slice_index)) if __name__ == "__main__": cmd, path = sys.argv[1], sys.argv[2] b = load(path) if cmd == "info": print("edge %dx%d bits/slice=%d slices:" % (b["edge"], b["edge"], b["bits"])) for e in b["entries"]: print(" slice %d type=%d %s" % (e["slice"], e["type"], e["name"])) elif cmd == "probe": base = path.rsplit(".", 1)[0] for nib in range(8): out = "%s_nib%d.png" % (base, nib) svt.write_png(out, b["edge"], b["edge"], nibble_plane(b, nib)) print("wrote", out) elif cmd == "png": sel = sys.argv[3] nib = int(sel.split(":")[1]) if sel.startswith("nibble:") else slice_nibble(int(sel)) out = sys.argv[4] if len(sys.argv) > 4 else path.rsplit(".", 1)[0] + "_s%s.png" % sel svt.write_png(out, b["edge"], b["edge"], nibble_plane(b, nib)) print("wrote", out)