SE2Revival/tools/siegelet.py

333 lines
17 KiB
Python

"""Build a small Dungeon Siege II map (siegelet) from one region of an existing map.
usage: python -I tools/siegelet.py SOURCE OUT.ds2map [--header TANK] [--region NAME] [--name MAPNAME] [--title TEXT]
[--bare] [--clutter N] [--clutter-at X] [--template T]
[--field WIDTH DEPTH SPACING [--field-side 1|-1] [--field-gap M]]
[--place x,y,z,0xNODE ...]
SOURCE a map tank, e.g. "...\\Dungeon Siege 2\\Maps\\World.ds2map", or Siege Editor 2's Bits folder
(Documents\\My Games\\Dungeon Siege 2 Mod\\Bits; --header TANK then supplies the tank header)
--start start position x,y,z,0xNODE (node-relative; default: the west end of the designers' path)
--src-map map folder inside SOURCE (default ds2_world; Broken World's own world is ds2x_world)
--region region to copy (default a1_06_08_petra, a 364 x 72 m canyon strip)
--bare remove the region's objects and monsters: bare terrain (lighting kept)
--clutter N add N small props (--template, default rubble_rrd_28) around the retail prop positions
8-15 m from the designers' path at x = --clutter-at (absolute metres, default 0)
--place one --template object at a node-relative position (repeatable; turned 180 degrees about y), e.g.
a position from OUT.route.txt
--field a regular grid of --template objects, WIDTH x DEPTH metres at SPACING, beside the path at
--clutter-at (side +1/-1, starting --field-gap metres from it), placed in absolute coordinates and
converted into the nearest terrain node's frame (node_frames)
Also writes OUT.route.txt: the region's dev_path_point positions from west to east (absolute x, then the
node-relative SiegePos), for tools that walk the hero along the strip.
Map layout (a tank holding world/maps/<name>/):
main.gas [t:map]: screen name, camera limits, worlds (difficulties)
info/start_positions.gas start groups; ours is enabled and the default for single and multiplayer
index/stitch_index.gas links between regions; empty here (a stitch into a missing region dereferences a
missing node: crash in the node lookup, Steam 0x6ab7b0 -> null at 0x6e22e8)
info/*, moods/, radar/ copied from the source map (the loader expects them)
regions/<region>/ copied; objects/ and tuning/ replaced with --bare / --clutter
A directory whose .gas files change loses its compiled dir.lqd22 so that the engine reads the text instead.
Object ids (scids) are the region's scid_range << 20 | n; positions are relative to a terrain node
(x,y,z,node guid); index/streamer_node_content_index.gas lists node -> scid for the streamer.
"""
import math, os, random, re, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from tank import Tank, pack_tree
SRC_MAP = 'ds2_world'
MAP_LEVEL_OURS = ('main.gas', 'info/start_positions.gas', 'index/stitch_index.gas', 'index/dir.lqd22',
'dir.lqd22', 'info/dir.lqd22')
STITCH_INDEX = '[stitch_index]\n{\n}\n'
def map_main(screen):
return '''[t:map,n:map]
{
description = "DS2 Revival test map";
b dev_only = false;
screen_name = "%s";
timeofday = 16h0m;
show_intro = false;
b use_node_mesh_index = true;
f world_frustum_radius = 45.000000;
f world_interest_radius = 4.000000;
f mp_party_level = 0.0;
[camera]
{
f azimuth = 0.5600000;
f distance = 18.000000;
f farclip = 70.000000;
f fov = 60.000000;
f max_azimuth = 85.000000;
f max_distance = 18.000000;
f min_azimuth = 32.000000;
f min_distance = 3.0000000;
f nearclip = 0.5000000;
f orbit = 3.1400000;
position = 0.000000,0.000000,0.000000,0x00000000;
}
[worlds]
{
[mercenary]
{
screen_name = "Mercenary";
description = "Parties of all levels can play here.";
required_level = 0;
}
}
}
''' % screen
def start_positions(pos, title):
sp = ''.join(''' [start_position]
{
i id = %d;
position = %s;
[camera]
{
f azimuth = 0.500000;
f distance = 20.000000;
f orbit = 0.000000;
position = %s;
}
}
''' % (i, pos, pos) for i in range(1, 9))
return '''[start_positions]
{
[t:start_group,n:revival_test_start]
{
f clothmap_pos_x = 0.000000;
f clothmap_pos_y = 0.000000;
f clothmap_pos_z = 0.000000;
b default = true;
description = "%s";
b dev_only = false;
b enabled = true;
i id = 1;
mood_name = "";
b mp_default = true;
screen_name = "%s";
%s [world_levels]
{
}
}
}
''' % (title, title, sp)
def parse_objects(text):
"""[(template, scid, 'x,y,z,0xnode', body)] for the top-level objects of an objects/*.gas file."""
out = []
for m in re.finditer(r'^\[t:(\w+),n:(0x[0-9a-fA-F]+)\]\s*\n\{(.*?)\n\}', text, re.S | re.M):
pos = re.search(r'p position = ([^;]+);', m.group(3))
out.append((m.group(1), int(m.group(2), 16), pos.group(1) if pos else None, m.group(3)))
return out
class Folder:
"""A loose map tree - Siege Editor 2's Bits folder (Documents\\My Games\\Dungeon Siege 2 Mod\\Bits) - with the
Tank reading interface (files, read). Only world/maps/ is read. A dir.lqd22 (compiled gas) older than a .gas file
beside it is left out: Siege Editor saves the .gas text, and the game would otherwise load the stale compiled copy."""
def __init__(self, root):
self.files = []
for d, _, names in os.walk(os.path.join(root, 'world', 'maps')):
newest_gas = max((os.path.getmtime(os.path.join(d, n)) for n in names if n.lower().endswith('.gas')), default=0)
for n in names:
p = os.path.join(d, n)
if n.lower() == 'dir.lqd22' and os.path.getmtime(p) < newest_gas - 2: # extraction order differs by ms
print('stale %s left out (edited .gas beside it)' % os.path.relpath(p, root))
continue
self.files.append(dict(path=os.path.relpath(p, root).replace('\\', '/'), file=p))
def read(self, f):
with open(f['file'], 'rb') as fh:
return fh.read()
def node_frames(objs, absp):
"""Terrain node frames: {node: to_local(x, y, z)} mapping absolute metres (editor/abs_positions.gas) into the
node's frame, and [(node, abs x, abs z)] of the objects used. A node's frame is a rotation about y plus an
offset, fitted (least squares) from its objects whose node-relative and absolute positions are both known;
nodes with fewer than two such objects or a residual over 5 cm are left out."""
pts = {}
for lst in objs.values():
for _, s, pos, _ in lst:
if pos and s in absp:
lx, ly, lz, node = pos.split(',')
pts.setdefault(node, []).append(((float(lx), float(ly), float(lz)), absp[s]))
frames, near = {}, []
for node, pp in pts.items():
if len({p[0] for p in pp}) < 2:
continue
k = len(pp)
lcx, lcz = sum(p[0][0] for p in pp) / k, sum(p[0][2] for p in pp) / k
acx, acz = sum(p[1][0] for p in pp) / k, sum(p[1][2] for p in pp) / k
best = None
for mirror in (1, -1): # rotation, or rotation with z mirrored (whichever fits)
sxy = sxx = 0.0
for (lx, _, lz), (ax, _, az) in pp:
lx, lz, ax, az = lx - lcx, (lz - lcz) * mirror, ax - acx, az - acz
sxy += lx * az - lz * ax
sxx += lx * ax + lz * az
th = math.atan2(sxy, sxx)
c, s = math.cos(th), math.sin(th)
res = max(math.hypot(c * (lx - lcx) - s * (lz - lcz) * mirror + acx - ax, s * (lx - lcx) + c * (lz - lcz) * mirror + acz - az)
for (lx, _, lz), (ax, _, az) in pp)
if best is None or res < best[0]:
best = (res, c, s, mirror)
res, c, s, mirror = best
if res > 0.05:
continue
ty = sum(p[1][1] - p[0][1] for p in pp) / k
def to_local(x, y, z, c=c, s=s, mirror=mirror, lcx=lcx, lcz=lcz, acx=acx, acz=acz, ty=ty):
dx, dz = x - acx, z - acz # inverse rotation of the centred point
return lcx + c * dx + s * dz, y - ty, lcz + (-s * dx + c * dz) * mirror
frames[node] = to_local
near += [(node, p[1][0], p[1][2]) for p in pp]
return frames, near
def main():
a = sys.argv[1:]
opt = lambda k, d=None: a[a.index(k) + 1] if k in a else d
src, out = a[0], a[1]
region, name, title = opt('--region', 'a1_06_08_petra'), opt('--name', 'revival_test'), opt('--title', 'Revival Test')
bare, clutter = '--bare' in a, int(opt('--clutter', 0))
field = tuple(float(v) for v in a[a.index('--field') + 1:a.index('--field') + 4]) if '--field' in a else None
clutter_at, template = float(opt('--clutter-at', 0)), opt('--template', 'rubble_rrd_28')
if os.path.isdir(src): # Siege Editor 2's Bits folder; the tank header comes from --header (e.g. World.ds2map)
if '--header' not in a:
sys.exit('a Bits folder as the source needs --header TANK (any .ds2map, for the tank header)')
t, header = Folder(src), Tank(opt('--header'))
else:
t = header = Tank(src)
base, root = 'world/maps/%s/' % opt('--src-map', SRC_MAP), 'world/maps/%s/' % name # --src-map ds2x_world: Broken World
rbase = base + 'regions/%s/' % region
by = {f['path']: f for f in t.files}
text = lambda p: t.read(by[p]).decode('latin1')
absp = {int(m.group(1), 16): tuple(float(v) for v in m.group(2, 3, 4)) for m in re.finditer(
r'\[(0x[0-9a-fA-F]+)\]\s*\{\s*f x = ([-\d.]+);\s*f y = ([-\d.]+);\s*f z = ([-\d.]+);',
text(rbase + 'editor/abs_positions.gas'))}
objs = {}
for p in by:
if p.startswith(rbase + 'objects/') and p.endswith('.gas'):
objs[p.split('/')[-1]] = parse_objects(text(p))
path = sorted((absp[s], pos) for tpl, s, pos, _ in objs.get('test.gas', []) if tpl == 'dev_path_point' and s in absp and pos)
route = []
for ap, pos in path: # every ~8 m along x
if not route or ap[0] - route[-1][0][0] >= 8:
route.append((ap, pos))
scid_range = int(re.search(r'x scid_range = (0x[0-9a-fA-F]+);', text(rbase + 'main.gas')).group(1), 16)
# start: the west end of the designers' path
start = opt('--start') or (route[0][1] if route else None) # --start x,y,z,0xNODE (node-relative)
if not start: # no designers' path in this region: the first placed object's position
start = next((pos for f in ('non_interactive.gas', 'container.gas', 'interactive.gas', 'special.gas')
for _, _, pos, _ in objs.get(f, []) if pos), None)
if not start:
sys.exit('no start position: give --start x,y,z,0xNODE')
files = [(root + 'main.gas', map_main(title)), (root + 'info/start_positions.gas', start_positions(start, title)),
(root + 'index/stitch_index.gas', STITCH_INDEX)]
changed_dirs = set()
new_objects, content_index = [], []
if clutter:
# anchors: retail prop positions 8-15 m from the path point nearest x = clutter_at (props stand where
# the ground is), each repeated with small node-local offsets until there are N props
cx, _, cz = min((ap for ap, _ in path), key=lambda ap: abs(ap[0] - clutter_at))
anchors = sorted({pos for f in ('non_interactive.gas',) for tpl, s, pos, _ in objs.get(f, [])
if pos and s in absp and 8 <= math.hypot(absp[s][0] - cx, absp[s][2] - cz) <= 15})
rnd = random.Random(1)
for i in range(clutter):
x, y, z, node = anchors[i % len(anchors)].split(',')
if i >= len(anchors):
x, z = float(x) + rnd.uniform(-0.8, 0.8), float(z) + rnd.uniform(-0.8, 0.8)
scid = (scid_range << 20) | (0xF0000 + i)
new_objects.append('[t:%s,n:0x%08x]\n{\n\t[placement]\n\t{\n\t q orientation = 0,%f,0,%f;\n'
'\t p position = %s,%s,%s,%s;\n\t}\n}\n' % (
template, scid, math.sin(i), math.cos(i), x, y, z, node))
content_index.append(' x %s = 0x%08X;\n' % (node.lower(), scid))
print('clutter: %d x %s around x=%.0f z=%.0f (%d anchor positions)' % (clutter, template, cx, cz, len(anchors)))
if field:
# a regular grid of objects in ABSOLUTE coordinates beside the path at x = clutter_at, each converted into
# the frame of the terrain node nearest to it (frames fitted from the retail objects, node_frames)
width, depth, spacing = field
side, gap = float(opt('--field-side', 1)), float(opt('--field-gap', 2))
frames, near = node_frames(objs, absp)
cx, _, cz = min((ap for ap, _ in path), key=lambda ap: abs(ap[0] - clutter_at))
z0 = cz + side * gap
z1 = z0 + side * depth
x0 = cx - width / 2
inside = [ap for ap in absp.values() if x0 <= ap[0] <= x0 + width and min(z0, z1) <= ap[2] <= max(z0, z1)]
ground = sorted(ap[1] for ap in inside)[len(inside) // 2] if inside else 0.0
n = skipped = 0
cols, rows = int(width / spacing) + 1, int(depth / spacing) + 1
for r in range(rows):
for c in range(cols):
px, pz = x0 + c * spacing, z0 + side * r * spacing
node, dist = min(((nd, math.hypot(ax - px, az - pz)) for nd, ax, az in near), key=lambda v: v[1])
if dist > 8:
skipped += 1
continue
lx, ly, lz = frames[node](px, ground, pz)
scid = (scid_range << 20) | (0xE0000 + n)
new_objects.append('[t:%s,n:0x%08x]\n{\n\t[placement]\n\t{\n\t q orientation = 0,0,0,1;\n'
'\t p position = %.4f,%.4f,%.4f,%s;\n\t}\n}\n' % (template, scid, lx, ly, lz, node))
content_index.append(' x %s = 0x%08X;\n' % (node.lower(), scid))
n += 1
print('field: %d x %s, %.0f x %.0f m grid at %.2f m beside the path at x=%.0f (path z=%.1f, field z %.1f..%.1f, '
'ground y %.2f; %d points with no node nearby skipped)' % (n, template, width, depth, spacing, cx, cz, z0, z1,
ground, skipped))
# --place x,y,z,0xNODE (repeatable): one --template object at a node-relative position (e.g. from OUT.route.txt)
places = [a[i + 1] for i, v in enumerate(a) if v == '--place' and i + 1 < len(a)]
for i, pos in enumerate(places):
x, y, z, node = pos.split(',')
scid = (scid_range << 20) | (0xD0000 + i)
new_objects.append('[t:%s,n:0x%08x]\n{\n\t[placement]\n\t{\n\t q orientation = 0,1,0,0;\n'
'\t p position = %s,%s,%s,%s;\n\t}\n}\n' % (template, scid, x, y, z, node))
content_index.append(' x %s = 0x%08X;\n' % (node.lower(), scid))
if places:
print('placed %d x %s' % (len(places), template))
for f in t.files:
p = f['path']
if p.startswith(rbase):
rel = p[len(rbase):]
d = rel.rsplit('/', 1)[0] if '/' in rel else ''
if bare and d in ('objects', 'tuning') and rel != 'tuning/tuning_data.tnp':
changed_dirs.add(d)
continue
if (clutter or field) and d in ('objects',) and rel.endswith('non_interactive.gas'):
continue
files.append((root + 'regions/%s/' % region + rel, t.read(f)))
elif p.startswith(base) and '/regions/' not in p and p[len(base):] not in MAP_LEVEL_OURS:
files.append((root + p[len(base):], t.read(f)))
rroot = root + 'regions/%s/' % region
if bare:
for name_, body in (('region_monsters', ''), ('tuning_monsters', ''), ('tuning_placement', '')):
files.append((rroot + 'tuning/%s.gas' % name_, '[%s]\n{\n%s}\n' % (name_, body)))
if bare or clutter or field:
kept = [] if bare else [l for l in text(rbase + 'index/streamer_node_content_index.gas').splitlines(True)[2:-1]]
files = [f for f in files if f[0] not in (rroot + 'index/streamer_node_content_index.gas', rroot + 'index/dir.lqd22',
rroot + 'objects/dir.lqd22')]
files.append((rroot + 'index/streamer_node_content_index.gas',
'[streamer_node_content_index]\n{\n' + ''.join(kept) + ''.join(content_index) + '}\n'))
if bare:
files = [f for f in files if f[0] not in (rroot + 'index/scid_names.gas',)]
files.append((rroot + 'index/scid_names.gas', '[scid_names]\n{\n}\n'))
original = '' if bare else text(rbase + 'objects/non_interactive.gas')
files.append((rroot + 'objects/non_interactive.gas', original + ''.join(new_objects) or '\n'))
files = [(p, b.encode('latin1') if isinstance(b, str) else b) for p, b in files]
open(out, 'wb').write(pack_tree(header, files))
with open(out + '.route.txt', 'w') as f:
for ap, pos in route:
f.write('%.1f %s\n' % (ap[0], pos))
print('wrote %s: %d files, map %s, region %s, start %s, route %d points' % (out, len(files), name, region, start, len(route)))
if __name__ == '__main__':
main()