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.
This commit is contained in:
2026-10-20 14:01:12 +02:00
parent 574536f419
commit 6eb785d57c
7 changed files with 598 additions and 1 deletions
+19
View File
@@ -7,3 +7,22 @@ SAVE/WindowSettings.ini
SAVE/Accts/SinglePlayer/Restart.fl SAVE/Accts/SinglePlayer/Restart.fl
SAVE/Accts/SinglePlayer/AutoSave.fl SAVE/Accts/SinglePlayer/AutoSave.fl
SAVE/Accts/SinglePlayer/nostory.fl 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/
+19
View File
@@ -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 <in.png> <out.3db> <name> [--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 <in.png> <out.3db> <name> [--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 <out.txm> <name>=<in.png> ...` | the HUD texture file `DATA/INTERFACE/REM/rem_hud.txm` (target marks) |
`<name>` 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
+56
View File
@@ -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
+46
View File
@@ -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_<name>.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_<name>.tga).
python tools/make_hud_txm.py <out.txm> <name>=<in.png> [<name>=<in.png> ...]
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])
+212
View File
@@ -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 <in.png> <out.3db> <name> [--color]
<name> becomes the material "UI_REM_<name>", the texture "REM_<name>.tga" and the mesh library
"rem_icon_<name>.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('<BBBHHBHHHHBB', 0, 0, 2, 0, 0, 0, 0, 0, size, size, 32, 8)
body = bytearray()
for line in reversed(rows):
for r, g, b, a in line:
body += bytes((b, g, r, a))
return head + bytes(body)
def vmesh_data(material_crc):
"""One mesh, four XYZ+TEX1 vertices (FVF 0x102), six indices."""
d = struct.pack('<IIHHHH', 1, 4, 1, len(QUAD_INDICES), 0x102, len(QUAD))
d += struct.pack('<IHHHH', material_crc, 0, len(QUAD) - 1, len(QUAD_INDICES), 204)
d += struct.pack('<%dH' % len(QUAD_INDICES), *QUAD_INDICES)
for v in QUAD:
d += struct.pack('<5f', *v)
return d
def vmesh_ref(library_crc):
"""VMeshRef (60 bytes): the whole library, bounds and sphere of the quad."""
return struct.pack('<IIHHHHHH10f', 60, library_crc, 0, len(QUAD), 0, len(QUAD_INDICES), 0, 1,
QUAD_HALF, -QUAD_HALF, QUAD_HALF, -QUAD_HALF, QUAD_Z, QUAD_Z, 0.0, 0.0, QUAD_Z,
QUAD_HALF * 1.5094)
def make(png, out, name, color=False):
w, h, rows = read_png(png)
w, h, rows = crop_to_content(w, h, rows)
mips = []
for size in (TEX_SIZE, TEX_SIZE // 2, TEX_SIZE // 4):
img = resample(w, h, rows, size)
mips.append(tga(img if color else to_grey(img)))
material = 'UI_REM_' + name
texture = 'REM_' + name + '.tga'
library = 'rem_icon_' + name.lower() + '.lod0.vms'
N = utf.Node
root = N('\\', children=[
N('Exporter Version', data=b'REM make_icon_3db\0'),
N('VMeshLibrary', children=[N(library, children=[N('VMeshData', data=vmesh_data(flcrc.crc(material)))])]),
N('VMeshPart', children=[N('VMeshRef', data=vmesh_ref(flcrc.crc(library)))]),
N('Material library', children=[
N('Material count', data=struct.pack('<I', 1)),
N(material, children=[N('Type', data=b'DcDtOcOt\0'), N('Dt_name', data=texture.encode() + b'\0'),
N('Dt_flags', data=struct.pack('<I', 64))])]),
N('Texture library', children=[
N(texture, children=[N('MIP%d' % i, data=m) for i, m in enumerate(mips)])]),
])
utf.write(out, root)
if __name__ == '__main__':
args = [a for a in sys.argv[1:] if not a.startswith('--')]
if len(args) != 3:
print(__doc__)
sys.exit(1)
make(args[0], args[1], args[2], color='--color' in sys.argv)
print('wrote', args[1])
+94
View File
@@ -0,0 +1,94 @@
"""A coloured Freelancer interface image (.3db) from a PNG -- for pictures in the REM windows
(statistics medals, banners, portraits), shown with a StaticControl like the inventory's item
icons. Unlike make_icon_3db.py the colours stay and the quad keeps the picture's aspect.
python tools/make_image_3db.py <in.png> <out.3db> <name> [--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). <name> gives the material "UI_REMIMG_<name>", the texture
"REMIMG_<name>.tga" and the mesh library "rem_img_<name>.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('<IIHHHH', 1, 4, 1, len(INDICES), 0x102, len(quad))
data += struct.pack('<IHHHH', flcrc.crc(material), 0, len(quad) - 1, len(INDICES), 204)
data += struct.pack('<%dH' % len(INDICES), *INDICES)
for v in quad:
data += struct.pack('<5f', *v)
ref = struct.pack('<IIHHHHHH10f', 60, flcrc.crc(library), 0, len(quad), 0, len(INDICES), 0, 1,
hx, -hx, hy, -hy, Z, Z, 0.0, 0.0, Z, max(hx, hy) * 1.5094)
N = utf.Node
root = N('\\', children=[
N('Exporter Version', data=b'REM make_image_3db\0'),
N('VMeshLibrary', children=[N(library, children=[N('VMeshData', data=data)])]),
N('VMeshPart', children=[N('VMeshRef', data=ref)]),
N('Material library', children=[
N('Material count', data=struct.pack('<I', 1)),
N(material, children=[N('Type', data=b'DcDtOcOt\0'), N('Dt_name', data=texture.encode() + b'\0'),
N('Dt_flags', data=struct.pack('<I', 64))])]),
N('Texture library', children=[
N(texture, children=[N('MIP%d' % i, data=m) for i, m in enumerate(mips)])]),
])
os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True)
utf.write(out, root)
return w, h
if __name__ == '__main__':
argv = sys.argv[1:]
size = 256
if '--size' in argv:
i = argv.index('--size')
size = int(argv[i + 1])
del argv[i:i + 2]
rect = None
if '--crop' in argv:
i = argv.index('--crop')
rect = tuple(int(v) for v in argv[i + 1].split(','))
del argv[i:i + 2]
args = [a for a in argv if not a.startswith('--')]
if len(args) != 3:
print(__doc__)
sys.exit(1)
w, h = make(args[0], args[1], args[2], size, '--nocrop' not in argv, rect)
print('wrote', args[1], '(%dx%d)' % (w, h))
+151
View File
@@ -0,0 +1,151 @@
"""Freelancer UTF container (.3db, .cmp, .mat, .txm): read, edit and write.
A UTF file is a tree of named nodes; a directory node has children, a leaf node carries bytes.
Header (0x38 bytes): "UTF ", version 0x101, tree offset/size, node size 44, string table
offset/allocated/used, data offset. Node (44 bytes): next sibling offset, name offset, flags
(0x10 directory, 0x80 leaf), 0, child offset (directory) or data offset (leaf), allocated,
size, size again, three FILETIME words.
python tools/utf.py dump file.3db
"""
import struct
import sys
class Node:
def __init__(self, name, data=None, children=None):
self.name = name
self.data = data # bytes for a leaf
self.children = children # list for a directory
@property
def is_dir(self):
return self.children is not None
def find(self, path):
"""The node at a '\\'-separated path below this one (case-insensitive), or None."""
node = self
for part in [p for p in path.replace('/', '\\').split('\\') if p]:
if not node.is_dir:
return None
nxt = None
for c in node.children:
if c.name.lower() == part.lower():
nxt = c
break
if nxt is None:
return None
node = nxt
return node
def walk(self, prefix=''):
path = prefix + '\\' + self.name if prefix else self.name
yield path, self
if self.is_dir:
for c in self.children:
yield from c.walk(path)
def read(path):
raw = open(path, 'rb').read()
sig, ver, tree_ofs, tree_size, _res, node_size, str_ofs, str_alloc, str_used, data_ofs = \
struct.unpack_from('<4sIIIIIIIII', raw, 0)
if sig != b'UTF ':
raise ValueError(path + ': not a UTF file')
strings = raw[str_ofs:str_ofs + str_alloc]
def name_at(o):
end = strings.index(b'\0', o)
return strings[o:end].decode('latin-1')
def node_at(o):
nxt, name_o, flags, _z, ptr, alloc, size, _size2 = struct.unpack_from('<8I', raw, tree_ofs + o)
name = name_at(name_o)
if flags & 0x10:
children = []
c = ptr
while True:
child, c_next = node_at(c)
children.append(child)
if c_next == 0:
break
c = c_next
return Node(name, children=children), nxt
return Node(name, data=raw[data_ofs + ptr:data_ofs + ptr + size]), nxt
root, _ = node_at(0)
return root
def write(path, root):
"""Writes the tree: nodes in depth-first order, names deduplicated, data 4-byte aligned."""
nodes = []
for _, n in root.walk():
nodes.append(n)
index = {id(n): i for i, n in enumerate(nodes)}
names = bytearray(b'\0')
name_ofs = {}
for n in nodes:
if n.name not in name_ofs:
name_ofs[n.name] = len(names)
names += n.name.encode('latin-1') + b'\0'
while len(names) % 4:
names += b'\0'
data = bytearray()
data_ofs = {}
for n in nodes:
if not n.is_dir:
data_ofs[id(n)] = len(data)
data += n.data
while len(data) % 4:
data += b'\0'
# the next sibling of each node
sibling = {}
for n in nodes:
if n.is_dir:
for a, b in zip(n.children, n.children[1:]):
sibling[id(a)] = b
node_size = 44
tree = bytearray()
for n in nodes:
nxt = sibling.get(id(n))
nxt_o = index[id(nxt)] * node_size if nxt is not None else 0
if n.is_dir:
child_o = index[id(n.children[0])] * node_size if n.children else 0
tree += struct.pack('<8I3I', nxt_o, name_ofs[n.name], 0x10, 0, child_o, 0, 0, 0, 0, 0, 0)
else:
size = len(n.data)
alloc = (size + 3) & ~3
tree += struct.pack('<8I3I', nxt_o, name_ofs[n.name], 0x80, 0, data_ofs[id(n)], alloc, size, size,
0, 0, 0)
header_size = 0x38
tree_ofs = header_size
str_ofs = tree_ofs + len(tree)
dat_ofs = str_ofs + len(names)
header = struct.pack('<4sIIIIIIIIIII', b'UTF ', 0x101, tree_ofs, len(tree), 0, node_size, str_ofs, len(names),
len(names), dat_ofs, 0, 0) + b'\0' * 8
open(path, 'wb').write(header + tree + names + data)
def dump(path):
root = read(path)
for p, n in root.walk():
if n.is_dir:
print(p + '\\')
else:
preview = n.data[:24].hex(' ')
print('%s [%d] %s' % (p, len(n.data), preview))
if __name__ == '__main__':
if len(sys.argv) >= 3 and sys.argv[1] == 'dump':
for f in sys.argv[2:]:
print('==', f)
dump(f)
else:
print(__doc__)