From 6eb785d57cb33584d2ff9475a8c50081b1ebd1d7 Mon Sep 17 00:00:00 2001 From: Nekura Date: Tue, 20 Oct 2026 14:01:12 +0200 Subject: [PATCH] Add the REM interface generators (tools) and ignore dumps, logs and server files make_icon_3db, make_image_3db and make_hud_txm with their helpers, standalone (the name CRC table is in flcrc.py). The .gitignore keeps crash dumps, logs, traces, debug symbols and everything of the server out of the mod. --- .gitignore | 21 +++- tools/README.md | 19 ++++ tools/flcrc.py | 56 +++++++++++ tools/make_hud_txm.py | 46 +++++++++ tools/make_icon_3db.py | 212 ++++++++++++++++++++++++++++++++++++++++ tools/make_image_3db.py | 94 ++++++++++++++++++ tools/utf.py | 151 ++++++++++++++++++++++++++++ 7 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 tools/README.md create mode 100644 tools/flcrc.py create mode 100644 tools/make_hud_txm.py create mode 100644 tools/make_icon_3db.py create mode 100644 tools/make_image_3db.py create mode 100644 tools/utf.py diff --git a/.gitignore b/.gitignore index 6dd020ea..8f2bd3a7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,23 @@ SAVE/PerfOptions.ini SAVE/WindowSettings.ini SAVE/Accts/SinglePlayer/Restart.fl SAVE/Accts/SinglePlayer/AutoSave.fl -SAVE/Accts/SinglePlayer/nostory.fl \ No newline at end of file +SAVE/Accts/SinglePlayer/nostory.fl + +# crash dumps, logs, traces, debug symbols, build leftovers +*.dmp +*.log +*.trace +*.pdb +*.obj +*.bak +EXE/codec-samples.jsonl + +# the server is a private project, nothing of it goes into the mod +EXE/rem-server* +EXE/flserver_rebuilt.* +EXE/flbot.exe +EXE/LargeID.dat +EXE/REM/ServerModule/ +EXE/REM/npc_profiles.ini +EXE/REM/rem-server-*.txt +flserver-reconstruction/ diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 00000000..f832e230 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,19 @@ +# REM tools + +Generators for the REM interface art (Python 3, no extra packages). Inputs are PNGs (8 or 16 bit), +the pictures of the old ClientHook are in `EXE/REM/UI`. + +| Script | Makes | +|---|---| +| `make_icon_3db.py [--color]` | a NeuroNet-style icon (grey, tinted by the game), e.g. the REM bar icons in `DATA/INTERFACE/NEURONET/ICONS/rem_*.3db` | +| `make_image_3db.py [--size N] [--nocrop] [--crop x0,y0,x1,y1]` | a coloured picture for the REM windows (medals, row icons, banners) in `DATA/INTERFACE/REM/IMAGES` | +| `make_hud_txm.py = ...` | the HUD texture file `DATA/INTERFACE/REM/rem_hud.txm` (target marks) | + +`` must be unique per file: it becomes the material, texture and mesh names, and +Freelancer keys materials globally by the CRC of the name. `utf.py` (UTF files) and `flcrc.py` +(Freelancer's name CRC) are shared helpers. + +Examples: + + python tools/make_image_3db.py EXE/REM/UI/ICON/Cargo_icon.png DATA/INTERFACE/REM/IMAGES/icon_cargo.3db icon_cargo --size 128 + python tools/make_image_3db.py EXE/REM/UI/CONTRACTS/combat_image.png DATA/INTERFACE/REM/IMAGES/contracts_banner.3db contracts_banner --size 512 --nocrop --crop 0,420,1600,1220 diff --git a/tools/flcrc.py b/tools/flcrc.py new file mode 100644 index 00000000..a1aae6b0 --- /dev/null +++ b/tools/flcrc.py @@ -0,0 +1,56 @@ +"""Freelancer's name CRC (dacom.dll GetCRC32): its own table, names folded to lower case. + +VMeshRef library ids and material ids in VMeshData are this CRC of the name. +""" + +_TABLE = [ + 0x00000000, 0x09073096, 0x120E612C, 0x1B0951BA, 0xFF6DC419, 0xF66AF48F, 0xED63A535, 0xE46495A3, + 0xFEDB8832, 0xF7DCB8A4, 0xECD5E91E, 0xE5D2D988, 0x01B64C2B, 0x08B17CBD, 0x13B82D07, 0x1ABF1D91, + 0xFDB71064, 0xF4B020F2, 0xEFB97148, 0xE6BE41DE, 0x02DAD47D, 0x0BDDE4EB, 0x10D4B551, 0x19D385C7, + 0x036C9856, 0x0A6BA8C0, 0x1162F97A, 0x1865C9EC, 0xFC015C4F, 0xF5066CD9, 0xEE0F3D63, 0xE7080DF5, + 0xFB6E20C8, 0xF269105E, 0xE96041E4, 0xE0677172, 0x0403E4D1, 0x0D04D447, 0x160D85FD, 0x1F0AB56B, + 0x05B5A8FA, 0x0CB2986C, 0x17BBC9D6, 0x1EBCF940, 0xFAD86CE3, 0xF3DF5C75, 0xE8D60DCF, 0xE1D13D59, + 0x06D930AC, 0x0FDE003A, 0x14D75180, 0x1DD06116, 0xF9B4F4B5, 0xF0B3C423, 0xEBBA9599, 0xE2BDA50F, + 0xF802B89E, 0xF1058808, 0xEA0CD9B2, 0xE30BE924, 0x076F7C87, 0x0E684C11, 0x15611DAB, 0x1C662D3D, + 0xF6DC4190, 0xFFDB7106, 0xE4D220BC, 0xEDD5102A, 0x09B18589, 0x00B6B51F, 0x1BBFE4A5, 0x12B8D433, + 0x0807C9A2, 0x0100F934, 0x1A09A88E, 0x130E9818, 0xF76A0DBB, 0xFE6D3D2D, 0xE5646C97, 0xEC635C01, + 0x0B6B51F4, 0x026C6162, 0x196530D8, 0x1062004E, 0xF40695ED, 0xFD01A57B, 0xE608F4C1, 0xEF0FC457, + 0xF5B0D9C6, 0xFCB7E950, 0xE7BEB8EA, 0xEEB9887C, 0x0ADD1DDF, 0x03DA2D49, 0x18D37CF3, 0x11D44C65, + 0x0DB26158, 0x04B551CE, 0x1FBC0074, 0x16BB30E2, 0xF2DFA541, 0xFBD895D7, 0xE0D1C46D, 0xE9D6F4FB, + 0xF369E96A, 0xFA6ED9FC, 0xE1678846, 0xE860B8D0, 0x0C042D73, 0x05031DE5, 0x1E0A4C5F, 0x170D7CC9, + 0xF005713C, 0xF90241AA, 0xE20B1010, 0xEB0C2086, 0x0F68B525, 0x066F85B3, 0x1D66D409, 0x1461E49F, + 0x0EDEF90E, 0x07D9C998, 0x1CD09822, 0x15D7A8B4, 0xF1B33D17, 0xF8B40D81, 0xE3BD5C3B, 0xEABA6CAD, + 0xEDB88320, 0xE4BFB3B6, 0xFFB6E20C, 0xF6B1D29A, 0x12D54739, 0x1BD277AF, 0x00DB2615, 0x09DC1683, + 0x13630B12, 0x1A643B84, 0x016D6A3E, 0x086A5AA8, 0xEC0ECF0B, 0xE509FF9D, 0xFE00AE27, 0xF7079EB1, + 0x100F9344, 0x1908A3D2, 0x0201F268, 0x0B06C2FE, 0xEF62575D, 0xE66567CB, 0xFD6C3671, 0xF46B06E7, + 0xEED41B76, 0xE7D32BE0, 0xFCDA7A5A, 0xF5DD4ACC, 0x11B9DF6F, 0x18BEEFF9, 0x03B7BE43, 0x0AB08ED5, + 0x16D6A3E8, 0x1FD1937E, 0x04D8C2C4, 0x0DDFF252, 0xE9BB67F1, 0xE0BC5767, 0xFBB506DD, 0xF2B2364B, + 0xE80D2BDA, 0xE10A1B4C, 0xFA034AF6, 0xF3047A60, 0x1760EFC3, 0x1E67DF55, 0x056E8EEF, 0x0C69BE79, + 0xEB61B38C, 0xE266831A, 0xF96FD2A0, 0xF068E236, 0x140C7795, 0x1D0B4703, 0x060216B9, 0x0F05262F, + 0x15BA3BBE, 0x1CBD0B28, 0x07B45A92, 0x0EB36A04, 0xEAD7FFA7, 0xE3D0CF31, 0xF8D99E8B, 0xF1DEAE1D, + 0x1B64C2B0, 0x1263F226, 0x096AA39C, 0x006D930A, 0xE40906A9, 0xED0E363F, 0xF6076785, 0xFF005713, + 0xE5BF4A82, 0xECB87A14, 0xF7B12BAE, 0xFEB61B38, 0x1AD28E9B, 0x13D5BE0D, 0x08DCEFB7, 0x01DBDF21, + 0xE6D3D2D4, 0xEFD4E242, 0xF4DDB3F8, 0xFDDA836E, 0x19BE16CD, 0x10B9265B, 0x0BB077E1, 0x02B74777, + 0x18085AE6, 0x110F6A70, 0x0A063BCA, 0x03010B5C, 0xE7659EFF, 0xEE62AE69, 0xF56BFFD3, 0xFC6CCF45, + 0xE00AE278, 0xE90DD2EE, 0xF2048354, 0xFB03B3C2, 0x1F672661, 0x166016F7, 0x0D69474D, 0x046E77DB, + 0x1ED16A4A, 0x17D65ADC, 0x0CDF0B66, 0x05D83BF0, 0xE1BCAE53, 0xE8BB9EC5, 0xF3B2CF7F, 0xFAB5FFE9, + 0x1DBDF21C, 0x14BAC28A, 0x0FB39330, 0x06B4A3A6, 0xE2D03605, 0xEBD70693, 0xF0DE5729, 0xF9D967BF, + 0xE3667A2E, 0xEA614AB8, 0xF1681B02, 0xF86F2B94, 0x1C0BBE37, 0x150C8EA1, 0x0E05DF1B, 0x0702EF8D, +] + + +def _fold(ch): + i = ch - 256 if ch >= 128 else ch # signed char + if i < 0: + off = 0x400 + i + return (_TABLE[off >> 2] >> ((off & 3) * 8)) & 0xFF + if ord('A') <= i <= ord('Z'): + return i + 32 + return i + + +def crc(name): + c = 0xFFFFFFFF + for ch in name.encode('latin-1'): + c = _TABLE[(_fold(ch) ^ (c & 0xFF))] ^ (c >> 8) + return (~c) & 0xFFFFFFFF diff --git a/tools/make_hud_txm.py b/tools/make_hud_txm.py new file mode 100644 index 00000000..f540dc9b --- /dev/null +++ b/tools/make_hud_txm.py @@ -0,0 +1,46 @@ +"""The REM HUD texture file (.txm) -- the icons drawn in the target brackets (rem_target_hud.cpp). + +A .txm is a UTF file with one "Texture library" node, as DATA\\INTERFACE\\ButtonTextures.txm. Each +PNG becomes a grey 32-bit TGA with alpha (brightest pixel white, three mips) named +"REM_.tga"; the HUD tints it with the bracket colour like Freelancer's own target corners. +DATA\\INTERFACE\\REM\\rem_hud.ini lists the textures as shapes (tex_shape = REM_.tga). + + python tools/make_hud_txm.py = [= ...] + +Example (the shipped file): + + python tools/make_hud_txm.py DATA/INTERFACE/REM/rem_hud.txm \\ + skull=EXE/REM/UI/ICON/skull_icon.png \\ + probe=EXE/REM/UI/ICON/sensorprobe_active.png \\ + menu=EXE/REM/UI/ICON/target_menu.png +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +import make_icon_3db as icon # noqa: E402 +import utf # noqa: E402 + +TEX_SIZE = 64 + + +def make(out, entries): + N = utf.Node + textures = [] + for name, png in entries: + w, h, rows = icon.read_png(png) + w, h, rows = icon.crop_to_content(w, h, rows) + mips = [icon.tga(icon.to_grey(icon.resample(w, h, rows, size))) + for size in (TEX_SIZE, TEX_SIZE // 2, TEX_SIZE // 4)] + textures.append(N('REM_' + name + '.tga', children=[N('MIP%d' % i, data=m) for i, m in enumerate(mips)])) + root = N('\\', children=[N('Texture library', children=textures)]) + os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True) + utf.write(out, root) + + +if __name__ == '__main__': + if len(sys.argv) < 3 or any('=' not in a for a in sys.argv[2:]): + print(__doc__) + sys.exit(1) + make(sys.argv[1], [a.split('=', 1) for a in sys.argv[2:]]) + print('wrote', sys.argv[1]) diff --git a/tools/make_icon_3db.py b/tools/make_icon_3db.py new file mode 100644 index 00000000..dccd2336 --- /dev/null +++ b/tools/make_icon_3db.py @@ -0,0 +1,212 @@ +"""A Freelancer interface icon (.3db) from a PNG -- like the NeuroNet icons (NN_info.3db ...). + +Retail icons are one textured quad: the mesh lives in DATA\\INTERFACE\\interface.generic.vms, the +material (DcDtOcOt, Dt_flags 64) and a 32-bit TGA with three mips are inside the .3db. The +texture is grey; the button tints it (white, yellow under the mouse). A new icon gets the same +quad in its own embedded VMeshLibrary, its own material name (material ids are global, keyed by +the name's CRC) and the PNG as grey + alpha, brightest pixel white. + + python tools/make_icon_3db.py [--color] + + becomes the material "UI_REM_", the texture "REM_.tga" and the mesh library +"rem_icon_.lod0.vms". --color keeps the PNG's colours (not tinted like the originals). +""" +import os +import struct +import sys +import zlib + +sys.path.insert(0, os.path.dirname(__file__)) +import flcrc # noqa: E402 +import utf # noqa: E402 + +# the retail NN_info quad (interface.generic-102.vms, start vertex 11026): x, y, z, u, v +QUAD_HALF = 0.020543185994029045 +QUAD_Z = -2.1847961306775687e-06 +QUAD = [(-QUAD_HALF, QUAD_HALF, QUAD_Z, 0.0, 1.0), (-QUAD_HALF, -QUAD_HALF, QUAD_Z, 0.0, 0.0), + (QUAD_HALF, QUAD_HALF, QUAD_Z, 1.0, 1.0), (QUAD_HALF, -QUAD_HALF, QUAD_Z, 1.0, 0.0)] +QUAD_INDICES = [0, 1, 2, 1, 3, 2] +TEX_SIZE = 128 + + +def read_png(path): + """RGBA rows (top first) of an 8-bit PNG: grey, grey+alpha, RGB, RGBA or palette.""" + d = open(path, 'rb').read() + assert d[:8] == b'\x89PNG\r\n\x1a\n', path + pos = 8 + idat = b'' + palette = trns = None + while pos < len(d): + n, kind = struct.unpack('>I4s', d[pos:pos + 8]) + body = d[pos + 8:pos + 8 + n] + if kind == b'IHDR': + w, h, depth, ctype, _c, _f, interlace = struct.unpack('>IIBBBBB', body) + elif kind == b'PLTE': + palette = body + elif kind == b'tRNS': + trns = body + elif kind == b'IDAT': + idat += body + pos += 12 + n + assert depth in (8, 16) and interlace == 0, path + ': only 8/16-bit non-interlaced PNG' + assert depth == 8 or ctype != 3, path + ': 16-bit palette PNG' + # a 16-bit sample is filtered byte-wise as two bytes; its high byte is kept below + channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[ctype] * (depth // 8) + raw = zlib.decompress(idat) + stride = w * channels + rows = [] + prev = bytearray(stride) + p = 0 + for _ in range(h): + f = raw[p] + line = bytearray(raw[p + 1:p + 1 + stride]) + p += 1 + stride + for i in range(stride): + a = line[i - channels] if i >= channels else 0 + b = prev[i] + c = prev[i - channels] if i >= channels else 0 + if f == 1: + line[i] = (line[i] + a) & 0xFF + elif f == 2: + line[i] = (line[i] + b) & 0xFF + elif f == 3: + line[i] = (line[i] + ((a + b) >> 1)) & 0xFF + elif f == 4: + pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c) + pr = a if pa <= pb and pa <= pc else (b if pb <= pc else c) + line[i] = (line[i] + pr) & 0xFF + prev = line + rows.append(line if depth == 8 else line[0::2]) + channels //= depth // 8 + out = [] + for line in rows: + px = [] + for x in range(w): + s = line[x * channels:(x + 1) * channels] + if ctype == 6: + px.append(tuple(s)) + elif ctype == 2: + px.append((s[0], s[1], s[2], 255)) + elif ctype == 4: + px.append((s[0], s[0], s[0], s[1])) + elif ctype == 0: + px.append((s[0], s[0], s[0], 255)) + else: + i = s[0] + a = trns[i] if trns is not None and i < len(trns) else 255 + px.append((palette[3 * i], palette[3 * i + 1], palette[3 * i + 2], a)) + out.append(px) + return w, h, out + + +def resample(w, h, rows, size): + """Box filter to size x size, colour weighted by alpha (no dark fringes).""" + out = [] + for y in range(size): + y0, y1 = y * h // size, max((y + 1) * h // size, y * h // size + 1) + line = [] + for x in range(size): + x0, x1 = x * w // size, max((x + 1) * w // size, x * w // size + 1) + r = g = b = a = n = 0 + for yy in range(y0, y1): + for xx in range(x0, x1): + pr, pg, pb, pa = rows[yy][xx] + r += pr * pa + g += pg * pa + b += pb * pa + a += pa + n += 1 + line.append((r // a, g // a, b // a, a // n) if a else (0, 0, 0, 0)) + out.append(line) + return out + + +def to_grey(rows, gamma=0.55): + """Luminance, the brightest visible pixel white and the rest lifted (gamma < 1) -- the retail + icons are near-white shapes the button tints.""" + lum = [[(0.2126 * r + 0.7152 * g + 0.0722 * b, a) for (r, g, b, a) in line] for line in rows] + peak = max([l for line in lum for (l, a) in line if a > 32] or [255.0]) + out = [] + for line in lum: + out.append([(min(int(255.0 * (min(l / max(peak, 1.0), 1.0) ** gamma) + 0.5), 255),) * 3 + (a,) + for (l, a) in line]) + return out + + +def crop_to_content(w, h, rows, margin=0.06): + """The visible part (alpha > 8) as a centred square with a small margin, so the icon fills + the quad like the retail ones.""" + xs = [x for line in rows for x, p in enumerate(line) if p[3] > 8] + ys = [y for y, line in enumerate(rows) if any(p[3] > 8 for p in line)] + if not xs: + return w, h, rows + x0, x1, y0, y1 = min(xs), max(xs) + 1, min(ys), max(ys) + 1 + side = int(max(x1 - x0, y1 - y0) * (1.0 + 2.0 * margin)) + 1 + cx, cy = (x0 + x1) // 2, (y0 + y1) // 2 + ox, oy = cx - side // 2, cy - side // 2 + empty = (0, 0, 0, 0) + out = [[rows[y][x] if 0 <= y < h and 0 <= x < w else empty for x in range(ox, ox + side)] + for y in range(oy, oy + side)] + return side, side, out + + +def tga(rows): + """32-bit uncompressed TGA, bottom row first, 8 alpha bits -- as the retail icons.""" + size = len(rows) + head = struct.pack(' [--size 256] [--nocrop] [--crop x0,y0,x1,y1] + +The transparent border is cut off (unless --nocrop), the picture is stretched into a square +texture of --size pixels (three mips) and drawn on a quad of the picture's aspect, the longer +side as long as the retail icon quad. --crop cuts that pixel rectangle out first (a banner +strip from a square picture). gives the material "UI_REMIMG_", the texture +"REMIMG_.tga" and the mesh library "rem_img_.lod0.vms". +""" +import os +import struct +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +import flcrc # noqa: E402 +import make_icon_3db as icon # noqa: E402 +import utf # noqa: E402 + +HALF = icon.QUAD_HALF +Z = icon.QUAD_Z +INDICES = [0, 1, 2, 1, 3, 2] + + +def crop(w, h, rows): + xs = [x for line in rows for x, p in enumerate(line) if p[3] > 8] + ys = [y for y, line in enumerate(rows) if any(p[3] > 8 for p in line)] + if not xs: + return w, h, rows + x0, x1, y0, y1 = min(xs), max(xs) + 1, min(ys), max(ys) + 1 + return x1 - x0, y1 - y0, [line[x0:x1] for line in rows[y0:y1]] + + +def make(png, out, name, size=256, do_crop=True, rect=None): + w, h, rows = icon.read_png(png) + if rect is not None: + x0, y0, x1, y1 = rect + w, h, rows = x1 - x0, y1 - y0, [line[x0:x1] for line in rows[y0:y1]] + if do_crop: + w, h, rows = crop(w, h, rows) + hx, hy = (HALF, HALF * h / w) if w >= h else (HALF * w / h, HALF) + quad = [(-hx, hy, Z, 0.0, 1.0), (-hx, -hy, Z, 0.0, 0.0), (hx, hy, Z, 1.0, 1.0), (hx, -hy, Z, 1.0, 0.0)] + mips = [] + s = size + for _ in range(3): + mips.append(icon.tga(icon.resample(w, h, rows, s))) + s = max(4, s // 2) + material = 'UI_REMIMG_' + name + texture = 'REMIMG_' + name + '.tga' + library = 'rem_img_' + name.lower() + '.lod0.vms' + data = struct.pack('= 3 and sys.argv[1] == 'dump': + for f in sys.argv[2:]: + print('==', f) + dump(f) + else: + print(__doc__)