333 lines
17 KiB
Python
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()
|