Publish source: Unlicense, public README, repo hygiene

The remote was previously a distribution-only point for pre-built DLLs.
This opens the source.

- LICENSE: Unlicense, with a GPL-3.0 carve-out for src/dpslog/WeirdDPSMate
  (a DPSMate fork that keeps its own license)
- README.md replaces the stale internal one with the user-facing docs from
  DLL_README.md, swapping the 'Why No Source Code?' section for build and
  layout notes. DLL_README.md is dropped; one README now serves both.
- RELEASING.md: drop the trim-the-README-per-release dance and the
  remote/WeirdUtils/ distribution clone, both obsolete now
- gitignore agent/editor scratch, build caches, the vendored WSBT addon,
  and the WeirdThreat/uwu-logs checkouts (separate upstream repos)
- Commit outstanding module work: superweirdo, clickthrough portal visuals,
  transform44 decompiles, worldmarkers demo presets, tools/
This commit is contained in:
MarcelineVQ
2026-07-27 21:47:55 -07:00
parent f500147fc7
commit b12fc820ef
42 changed files with 5563 additions and 727 deletions
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Convert PNG to BLP2 (DXT3 compressed, with mipmaps)."""
import struct
import sys
from PIL import Image
def rgb_to_565(r, g, b):
return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)
def color_565_to_rgb(c):
r = ((c >> 11) & 0x1F) << 3
g = ((c >> 5) & 0x3F) << 2
b = (c & 0x1F) << 3
return r, g, b
def color_distance(r0, g0, b0, r1, g1, b1):
return (r0 - r1) ** 2 + (g0 - g1) ** 2 + (b0 - b1) ** 2
def encode_dxt1_block(pixels):
"""Encode a 4x4 block of (r,g,b) tuples into 8-byte DXT1 color block.
pixels: list of 16 (r,g,b) tuples in row-major order.
"""
# Find min/max colors by luminance
min_c = min(pixels, key=lambda c: c[0] * 299 + c[1] * 587 + c[2] * 114)
max_c = max(pixels, key=lambda c: c[0] * 299 + c[1] * 587 + c[2] * 114)
color0 = rgb_to_565(*max_c)
color1 = rgb_to_565(*min_c)
# Ensure color0 > color1 for 4-color mode
if color0 == color1:
# All same color, indices all 0
return struct.pack('<HHI', color0, color1, 0)
if color0 < color1:
color0, color1 = color1, color0
max_c, min_c = min_c, max_c
# Reconstruct actual RGB values after 565 quantization
r0, g0, b0 = color_565_to_rgb(color0)
r1, g1, b1 = color_565_to_rgb(color1)
# Build 4-color palette
palette = [
(r0, g0, b0),
(r1, g1, b1),
((2 * r0 + r1) // 3, (2 * g0 + g1) // 3, (2 * b0 + b1) // 3),
((r0 + 2 * r1) // 3, (g0 + 2 * g1) // 3, (b0 + 2 * b1) // 3),
]
# Find best index for each pixel
indices = 0
for i, (r, g, b) in enumerate(pixels):
best_idx = 0
best_dist = float('inf')
for j, (pr, pg, pb) in enumerate(palette):
d = color_distance(r, g, b, pr, pg, pb)
if d < best_dist:
best_dist = d
best_idx = j
indices |= best_idx << (i * 2)
return struct.pack('<HHI', color0, color1, indices)
def encode_dxt3_block(pixels):
"""Encode a 4x4 block of (r,g,b,a) tuples into 16-byte DXT3 block.
pixels: list of 16 (r,g,b,a) tuples in row-major order.
"""
# Alpha: 4 bits per pixel, 64 bits total
alpha_bits = 0
for i, (_, _, _, a) in enumerate(pixels):
alpha_bits |= (a >> 4) << (i * 4)
alpha_data = struct.pack('<Q', alpha_bits)
# Color: DXT1 block (ignore alpha)
rgb_pixels = [(r, g, b) for r, g, b, _ in pixels]
color_data = encode_dxt1_block(rgb_pixels)
return alpha_data + color_data
def get_block_pixels(img_data, width, height, bx, by):
"""Extract 4x4 pixel block from RGBA image data."""
pixels = []
for py in range(4):
for px in range(4):
x = bx * 4 + px
y = by * 4 + py
if x < width and y < height:
idx = (y * width + x) * 4
pixels.append(tuple(img_data[idx:idx + 4]))
else:
pixels.append((0, 0, 0, 0))
return pixels
def encode_dxt3_image(img_data, width, height):
"""Encode full RGBA image as DXT3."""
blocks_wide = (width + 3) // 4
blocks_high = (height + 3) // 4
result = bytearray()
for by in range(blocks_high):
for bx in range(blocks_wide):
pixels = get_block_pixels(img_data, width, height, bx, by)
result.extend(encode_dxt3_block(pixels))
return bytes(result)
def generate_mipmaps(img):
"""Generate mipmap chain from PIL Image. Returns list of (width, height, rgba_bytes)."""
levels = [img]
w, h = img.size
while w > 1 or h > 1:
w = max(1, w // 2)
h = max(1, h // 2)
levels.append(img.resize((w, h), Image.LANCZOS))
return [(l.size[0], l.size[1], l.tobytes()) for l in levels]
def png_to_blp2(png_path, blp_path):
img = Image.open(png_path).convert('RGBA')
width, height = img.size
mipmaps = generate_mipmaps(img)
num_mipmaps = min(len(mipmaps), 16)
# Encode each mipmap level as DXT3
encoded = []
for w, h, data in mipmaps[:num_mipmaps]:
encoded.append(encode_dxt3_image(data, w, h))
# BLP2 header: 1172 bytes
# 4 (magic) + 4 (type) + 1 (compression) + 1 (alpha_depth) + 1 (alpha_type)
# + 1 (has_mips) + 4 (width) + 4 (height) + 64 (offsets) + 64 (lengths)
# + 1024 (palette) = 1172
header_size = 4 + 4 + 1 + 1 + 1 + 1 + 4 + 4 + 64 + 64 + 1024
# Calculate offsets
offsets = [0] * 16
lengths = [0] * 16
offset = header_size
for i, data in enumerate(encoded):
offsets[i] = offset
lengths[i] = len(data)
offset += len(data)
# Build header
header = bytearray()
header.extend(b'BLP2')
header.extend(struct.pack('<I', 1)) # type = 1 (BLP/DXTC/Uncompressed)
header.extend(struct.pack('<B', 2)) # compression = 2 (DXTC)
header.extend(struct.pack('<B', 8)) # alpha_depth = 8
header.extend(struct.pack('<B', 1)) # alpha_type = 1 (DXT3)
header.extend(struct.pack('<B', 1)) # has_mips = 1
header.extend(struct.pack('<I', width))
header.extend(struct.pack('<I', height))
for o in offsets:
header.extend(struct.pack('<I', o))
for l in lengths:
header.extend(struct.pack('<I', l))
header.extend(b'\x00' * 1024) # palette (unused for DXTC)
assert len(header) == header_size
with open(blp_path, 'wb') as f:
f.write(header)
for data in encoded:
f.write(data)
print(f'{png_path} -> {blp_path}')
print(f' {width}x{height}, {num_mipmaps} mipmaps, DXT3')
print(f' {offset} bytes total')
if __name__ == '__main__':
if len(sys.argv) != 3:
print(f'Usage: {sys.argv[0]} input.png output.blp')
sys.exit(1)
png_to_blp2(sys.argv[1], sys.argv[2])
+330
View File
@@ -0,0 +1,330 @@
"""
StormLib ctypes wrapper for MPQ archive operations.
Auto-downloads prebuilt StormLib from GitHub releases if not found locally.
"""
import ctypes
from ctypes import c_bool, c_char_p, c_uint32, c_uint64, c_void_p, byref, POINTER
from pathlib import Path
import platform
SCRIPT_DIR = Path(__file__).parent
LIB_DIR = SCRIPT_DIR / "lib"
STORMLIB_VERSION = "v9.31"
STORMLIB_RELEASE_URL = f"https://github.com/ladislav-zezula/StormLib/releases/download/{STORMLIB_VERSION}"
def _download_stormlib():
"""Download prebuilt StormLib for the current platform."""
import urllib.request
import ssl
import tempfile
import shutil
import subprocess
system = platform.system().lower()
machine = platform.machine().lower()
LIB_DIR.mkdir(parents=True, exist_ok=True)
if system == "linux" and machine in ("x86_64", "amd64"):
lib_name = "libstorm.so"
deb_name = f"libstorm-dev_{STORMLIB_VERSION}_amd64.deb"
url = f"{STORMLIB_RELEASE_URL}/{deb_name}"
lib_path = LIB_DIR / lib_name
print(f"Downloading StormLib {STORMLIB_VERSION}...")
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = Path(tmpdir)
pkg_path = tmpdir / "stormlib.deb"
req = urllib.request.Request(url, headers={"User-Agent": "M2Editor/1.0"})
# Try default SSL first, fall back to unverified if certs missing
try:
ctx = ssl.create_default_context()
resp_obj = urllib.request.urlopen(req, timeout=60, context=ctx)
except ssl.SSLCertVerificationError:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
resp_obj = urllib.request.urlopen(req, timeout=60, context=ctx)
with resp_obj as resp:
pkg_path.write_bytes(resp.read())
subprocess.run(["ar", "x", str(pkg_path)], cwd=tmpdir, check=True, capture_output=True)
for data_tar in tmpdir.glob("data.tar.*"):
subprocess.run(["tar", "xf", str(data_tar)], cwd=tmpdir, check=True, capture_output=True)
break
for so_file in tmpdir.rglob("libstorm.so*"):
if so_file.is_file() and not so_file.is_symlink():
shutil.copy2(so_file, lib_path)
return lib_path
elif system == "windows" and machine in ("x86_64", "amd64"):
lib_name = "storm.dll"
url = f"{STORMLIB_RELEASE_URL}/stormlib_dll.zip"
lib_path = LIB_DIR / lib_name
print(f"Downloading StormLib {STORMLIB_VERSION}...")
import zipfile
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = Path(tmpdir)
pkg_path = tmpdir / "stormlib.zip"
req = urllib.request.Request(url, headers={"User-Agent": "M2Editor/1.0"})
with urllib.request.urlopen(req, timeout=60) as resp:
pkg_path.write_bytes(resp.read())
with zipfile.ZipFile(pkg_path) as zf:
zf.extractall(tmpdir)
for dll in tmpdir.rglob("storm.dll"):
if "x64" in str(dll.parent).lower() or "64" in str(dll.parent):
shutil.copy2(dll, lib_path)
return lib_path
for dll in tmpdir.rglob("storm.dll"):
shutil.copy2(dll, lib_path)
return lib_path
raise ImportError(f"No prebuilt StormLib for {platform.system()} {platform.machine()}")
def _load_stormlib():
"""Find and load the StormLib shared library."""
system = platform.system().lower()
lib_name = "libstorm.so" if system == "linux" else "storm.dll"
search_paths = [
LIB_DIR / lib_name,
Path("/usr/local/lib") / lib_name,
Path("/usr/lib") / lib_name,
]
for path in search_paths:
if path.exists():
try:
return ctypes.CDLL(str(path))
except OSError:
continue
lib_path = _download_stormlib()
return ctypes.CDLL(str(lib_path))
_lib = _load_stormlib()
# Type aliases
HANDLE = c_void_p
DWORD = c_uint32
LCID = c_uint32
ULONGLONG = c_uint64
TCHAR = c_char_p
# MPQ constants
MPQ_CREATE_LISTFILE = 0x00100000
MPQ_CREATE_ATTRIBUTES = 0x00200000
MPQ_CREATE_ARCHIVE_V1 = 0x00000000
MPQ_FILE_COMPRESS = 0x00000200
MPQ_FILE_REPLACEEXISTING = 0x80000000
MPQ_COMPRESSION_ZLIB = 0x02
MPQ_OPEN_READ_ONLY = 0x00000100
SFILE_OPEN_FROM_MPQ = 0x00000000
# Function signatures
_lib.SFileCreateArchive.argtypes = [TCHAR, DWORD, DWORD, POINTER(HANDLE)]
_lib.SFileCreateArchive.restype = c_bool
_lib.SFileOpenArchive.argtypes = [TCHAR, DWORD, DWORD, POINTER(HANDLE)]
_lib.SFileOpenArchive.restype = c_bool
_lib.SFileCloseArchive.argtypes = [HANDLE]
_lib.SFileCloseArchive.restype = c_bool
_lib.SFileFlushArchive.argtypes = [HANDLE]
_lib.SFileFlushArchive.restype = c_bool
_lib.SFileAddFileEx.argtypes = [HANDLE, TCHAR, c_char_p, DWORD, DWORD, DWORD]
_lib.SFileAddFileEx.restype = c_bool
_lib.SFileCreateFile.argtypes = [HANDLE, c_char_p, ULONGLONG, DWORD, LCID, DWORD, POINTER(HANDLE)]
_lib.SFileCreateFile.restype = c_bool
_lib.SFileWriteFile.argtypes = [HANDLE, c_void_p, DWORD, DWORD]
_lib.SFileWriteFile.restype = c_bool
_lib.SFileFinishFile.argtypes = [HANDLE]
_lib.SFileFinishFile.restype = c_bool
_lib.SFileCloseFile.argtypes = [HANDLE]
_lib.SFileCloseFile.restype = c_bool
_lib.SFileHasFile.argtypes = [HANDLE, c_char_p]
_lib.SFileHasFile.restype = c_bool
_lib.SFileCompactArchive.argtypes = [HANDLE, TCHAR, c_bool]
_lib.SFileCompactArchive.restype = c_bool
_lib.SFileOpenFileEx.argtypes = [HANDLE, c_char_p, DWORD, POINTER(HANDLE)]
_lib.SFileOpenFileEx.restype = c_bool
_lib.SFileGetFileSize.argtypes = [HANDLE, POINTER(DWORD)]
_lib.SFileGetFileSize.restype = DWORD
_lib.SFileReadFile.argtypes = [HANDLE, c_void_p, DWORD, POINTER(DWORD), c_void_p]
_lib.SFileReadFile.restype = c_bool
_lib.SFileRemoveFile.argtypes = [HANDLE, c_char_p, DWORD]
_lib.SFileRemoveFile.restype = c_bool
class SFILE_FIND_DATA(ctypes.Structure):
_fields_ = [
("cFileName", ctypes.c_char * 1024),
("szPlainName", ctypes.c_char_p),
("dwHashIndex", DWORD),
("dwBlockIndex", DWORD),
("dwFileSize", DWORD),
("dwFileFlags", DWORD),
("dwCompSize", DWORD),
("dwFileTimeLo", DWORD),
("dwFileTimeHi", DWORD),
("lcLocale", LCID),
]
_lib.SFileFindFirstFile.argtypes = [HANDLE, c_char_p, POINTER(SFILE_FIND_DATA), c_char_p]
_lib.SFileFindFirstFile.restype = HANDLE
_lib.SFileFindNextFile.argtypes = [HANDLE, POINTER(SFILE_FIND_DATA)]
_lib.SFileFindNextFile.restype = c_bool
_lib.SFileFindClose.argtypes = [HANDLE]
_lib.SFileFindClose.restype = c_bool
class StormLibError(Exception):
pass
class MPQArchive:
"""Context manager for MPQ archive operations."""
def __init__(self, path, mode='r', max_files=0):
self.path = Path(path)
self.mode = mode
self.handle = HANDLE()
self._closed = False
path_bytes = str(self.path).encode('utf-8')
if mode == 'r':
if not _lib.SFileOpenArchive(path_bytes, 0, MPQ_OPEN_READ_ONLY, byref(self.handle)):
raise StormLibError(f"Failed to open archive: {path}")
elif mode == 'w':
if max_files == 0:
max_files = 4096
flags = MPQ_CREATE_LISTFILE | MPQ_CREATE_ATTRIBUTES | MPQ_CREATE_ARCHIVE_V1
if not _lib.SFileCreateArchive(path_bytes, flags, max_files, byref(self.handle)):
raise StormLibError(f"Failed to create archive: {path}")
elif mode == 'a':
if not _lib.SFileOpenArchive(path_bytes, 0, 0, byref(self.handle)):
raise StormLibError(f"Failed to open archive for writing: {path}")
else:
raise ValueError(f"Invalid mode: {mode}")
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False
def close(self):
if not self._closed and self.handle:
_lib.SFileFlushArchive(self.handle)
_lib.SFileCloseArchive(self.handle)
self._closed = True
def add_file(self, source_path, archive_name,
compression=MPQ_COMPRESSION_ZLIB,
flags=MPQ_FILE_COMPRESS | MPQ_FILE_REPLACEEXISTING):
"""Add a file from disk to the archive."""
if self.mode == 'r':
raise StormLibError("Archive opened in read-only mode")
source_bytes = str(source_path).encode('utf-8')
archive_bytes = archive_name.replace('/', '\\').encode('utf-8')
if not _lib.SFileAddFileEx(self.handle, source_bytes, archive_bytes,
flags, compression, compression):
raise StormLibError(f"Failed to add file: {source_path} -> {archive_name}")
def add_data(self, data, archive_name,
compression=MPQ_COMPRESSION_ZLIB,
flags=MPQ_FILE_COMPRESS | MPQ_FILE_REPLACEEXISTING):
"""Add raw bytes as a file in the archive."""
if self.mode == 'r':
raise StormLibError("Archive opened in read-only mode")
archive_bytes = archive_name.replace('/', '\\').encode('utf-8')
file_handle = HANDLE()
if not _lib.SFileCreateFile(self.handle, archive_bytes, 0, len(data), 0,
flags, byref(file_handle)):
raise StormLibError(f"Failed to create file in archive: {archive_name}")
try:
if not _lib.SFileWriteFile(file_handle, data, len(data), compression):
raise StormLibError(f"Failed to write file data: {archive_name}")
if not _lib.SFileFinishFile(file_handle):
raise StormLibError(f"Failed to finish file: {archive_name}")
except:
_lib.SFileCloseFile(file_handle)
raise
def has_file(self, archive_name):
archive_bytes = archive_name.replace('/', '\\').encode('utf-8')
return _lib.SFileHasFile(self.handle, archive_bytes)
def read_file(self, archive_name):
"""Read a file from the archive."""
archive_bytes = archive_name.replace('/', '\\').encode('utf-8')
file_handle = HANDLE()
if not _lib.SFileOpenFileEx(self.handle, archive_bytes, SFILE_OPEN_FROM_MPQ,
byref(file_handle)):
raise StormLibError(f"Failed to open file: {archive_name}")
try:
high_size = DWORD()
size = _lib.SFileGetFileSize(file_handle, byref(high_size))
if size == 0xFFFFFFFF:
raise StormLibError(f"Failed to get file size: {archive_name}")
buffer = ctypes.create_string_buffer(size)
read_size = DWORD()
if not _lib.SFileReadFile(file_handle, buffer, size, byref(read_size), None):
raise StormLibError(f"Failed to read file: {archive_name}")
return buffer.raw[:read_size.value]
finally:
_lib.SFileCloseFile(file_handle)
def remove_file(self, archive_name):
if self.mode == 'r':
raise StormLibError("Archive opened in read-only mode")
archive_bytes = archive_name.replace('/', '\\').encode('utf-8')
if not _lib.SFileRemoveFile(self.handle, archive_bytes, 0):
raise StormLibError(f"Failed to remove file: {archive_name}")
def compact(self):
if self.mode == 'r':
raise StormLibError("Archive opened in read-only mode")
if not _lib.SFileCompactArchive(self.handle, None, False):
raise StormLibError("Failed to compact archive")
def list_files(self, pattern="*"):
"""List all files in the archive matching a pattern."""
files = []
find_data = SFILE_FIND_DATA()
find_handle = _lib.SFileFindFirstFile(self.handle, pattern.encode('utf-8'),
byref(find_data), None)
if not find_handle:
return files
try:
while True:
filename = find_data.cFileName.decode('utf-8', errors='replace')
if filename and not filename.startswith('('):
files.append(filename)
if not _lib.SFileFindNextFile(find_handle, byref(find_data)):
break
finally:
_lib.SFileFindClose(find_handle)
return files