1921 lines
78 KiB
Python
1921 lines
78 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
bsp2cgz.py - convert a Source engine .bsp map into an AssaultCube .cgz map.
|
|
|
|
python3 source/dev_tools/bsp2cgz.py surf_map.bsp
|
|
|
|
Only the map itself is converted:
|
|
|
|
* every brush that blocks movement (solid, playerclip, monsterclip, grate,
|
|
window, moveable clip) becomes the cube world. func_detail geometry is part
|
|
of the world model, brushes of brush entities (func_brush, doors, ...) are
|
|
baked in their compiled position.
|
|
* info_player_* entities become PLAYERSTART entities (a map without spawns can
|
|
not be played), light entities become LIGHT entities (--entities spawns
|
|
drops them) and the sun (light_environment) becomes the map's ambient light,
|
|
because an AssaultCube map without lights is pitch black.
|
|
* Source maps are usually a sealed playable area plus a 3D skybox (a scaled
|
|
down copy of the map placed far away). Only the area that is connected to
|
|
the player spawns is converted - the skybox would add junk and, worse, make
|
|
the whole map coarse. -w/--whole-map converts everything.
|
|
* everything else (entity i/o, triggers, func_ boxes, displacements, overlays,
|
|
static props, models, cubemaps, scripts) is dropped.
|
|
|
|
Caveats - these are properties of the target format, not bugs:
|
|
|
|
* AssaultCube worlds are columns: one floor and one ceiling height per (x, y)
|
|
cell with 1/4 cube slope precision. Overhangs, bridges, stacked floors and
|
|
interiors can not be represented, so one open space per column is kept
|
|
(--space, default: the topmost one, which is what surf/aim maps want).
|
|
* heights are limited to +-127 cubes, and a smoothed (heightfield) surface can
|
|
dip at most 63.5 cubes below its highest point. When a map is too tall for
|
|
that, ramps are written as stair steps instead - still walkable, but the
|
|
surface is rendered as small steps (pass a coarser -s for smooth ramps).
|
|
* the AC network protocol refuses map files of 64k and more, so big
|
|
conversions are for local/bot play unless the scale is made coarser.
|
|
"""
|
|
|
|
import argparse
|
|
import gzip
|
|
import math
|
|
import os
|
|
import re
|
|
import struct
|
|
import sys
|
|
import time
|
|
from array import array
|
|
from collections import defaultdict
|
|
|
|
# --------------------------------------------------------------- BSP lumps
|
|
|
|
L_ENTITIES = 0
|
|
L_PLANES = 1
|
|
L_TEXDATA = 2
|
|
L_VERTEXES = 3
|
|
L_NODES = 5
|
|
L_TEXINFO = 6
|
|
L_FACES = 7
|
|
L_LEAFS = 10
|
|
L_EDGES = 12
|
|
L_SURFEDGES = 13
|
|
L_MODELS = 14
|
|
L_BRUSHES = 18
|
|
L_BRUSHSIDES = 19
|
|
L_TEXDATA_STRING_DATA = 43
|
|
L_TEXDATA_STRING_TABLE = 44
|
|
L_BRUSH_PLANES = 22 # version 21 moved brush planes to their own lump
|
|
|
|
CONTENTS_SOLID = 0x1
|
|
CONTENTS_WINDOW = 0x2
|
|
CONTENTS_GRATE = 0x8
|
|
CONTENTS_ORIGIN = 0x8000000 # no collision: only there to place an entity
|
|
CONTENTS_SLIME = 0x10
|
|
CONTENTS_WATER = 0x20
|
|
CONTENTS_MOVEABLECLIP = 0x8000
|
|
CONTENTS_PLAYERCLIP = 0x10000
|
|
CONTENTS_MONSTERCLIP = 0x20000
|
|
|
|
BLOCKING = (CONTENTS_SOLID | CONTENTS_WINDOW | CONTENTS_GRATE
|
|
| CONTENTS_MOVEABLECLIP | CONTENTS_PLAYERCLIP | CONTENTS_MONSTERCLIP)
|
|
LIQUID = CONTENTS_SLIME | CONTENTS_WATER
|
|
|
|
I32 = struct.Struct("<i")
|
|
PLANE = struct.Struct("<4fi")
|
|
VERTEX = struct.Struct("<3f")
|
|
EDGE = struct.Struct("<2H")
|
|
SURFEDGE = struct.Struct("<i")
|
|
MODEL = struct.Struct("<9f3i")
|
|
TEXDATA = struct.Struct("<8i")
|
|
TEXINFO = struct.Struct("<16f2i") # texture vectors, lightmap vectors, flags, texdata
|
|
FACE = struct.Struct("<HBBihhhh4BifiiiiiHHI")
|
|
BRUSH = struct.Struct("<3i")
|
|
BRUSHSIDE = struct.Struct("<Hhhh")
|
|
ENTITY = struct.Struct("<hhhhBBBBhbB") # x y z attr1 type attr2 attr3 attr4 attr5 attr6 attr7
|
|
|
|
|
|
class BspError(Exception):
|
|
pass
|
|
|
|
|
|
class Bsp(object):
|
|
"""The parts of a Source .bsp the converter needs."""
|
|
|
|
def __init__(self, path):
|
|
with open(path, "rb") as f:
|
|
self.data = f.read()
|
|
if len(self.data) < 1036:
|
|
raise BspError("%s is too small to be a .bsp" % path)
|
|
magic, self.version = struct.unpack_from("<4si", self.data, 0)
|
|
if magic not in (b"VBSP", b"Vbsp"):
|
|
raise BspError("%s is not a Source .bsp (magic %r); Quake 1/2/3 and "
|
|
"GoldSrc maps are a different format" % (path, magic))
|
|
if self.version not in (20, 21):
|
|
raise BspError("unsupported .bsp version %d (only Source version 20 "
|
|
"and 21 are supported)" % self.version)
|
|
self.lumps = [struct.unpack_from("<4i", self.data, 8 + 16 * i)
|
|
for i in range(64)]
|
|
|
|
def lump(self, index):
|
|
offset, length, _ver, _unc = self.lumps[index]
|
|
if length <= 0:
|
|
return b""
|
|
if offset < 0 or offset + length > len(self.data):
|
|
raise BspError("lump %d is out of bounds" % index)
|
|
return self.data[offset:offset + length]
|
|
|
|
def lump_entries(self, index, fmt):
|
|
raw = self.lump(index)
|
|
if len(raw) % fmt.size:
|
|
raise BspError("lump %d has a size (%d) that is not a multiple of %d, "
|
|
"this .bsp layout is not supported"
|
|
% (index, len(raw), fmt.size))
|
|
return [fmt.unpack_from(raw, i) for i in range(0, len(raw), fmt.size)]
|
|
|
|
# -- geometry --------------------------------------------------------
|
|
|
|
def load_geometry(self):
|
|
self.planes = self.lump_entries(L_PLANES, PLANE)
|
|
self.vertices = [v[:3] for v in self.lump_entries(L_VERTEXES, VERTEX)]
|
|
self.edges = self.lump_entries(L_EDGES, EDGE)
|
|
self.surfedges = [e[0] for e in self.lump_entries(L_SURFEDGES, SURFEDGE)]
|
|
self.models = self.lump_entries(L_MODELS, MODEL)
|
|
planes = self.planes
|
|
# version 21 moved the brush planes to their own lump, but not every map
|
|
# written by a v21 tool actually uses it (the lump may hold something
|
|
# else), so it is only used when the brush sides really index into it
|
|
brushsides = self.lump_entries(L_BRUSHSIDES, BRUSHSIDE)
|
|
maxplane = max((s[0] for s in brushsides), default=-1)
|
|
self.brushplanes = planes
|
|
if self.version >= 21:
|
|
try:
|
|
bp = self.lump_entries(L_BRUSH_PLANES, PLANE)
|
|
except BspError:
|
|
bp = []
|
|
if bp and maxplane < len(bp):
|
|
self.brushplanes = bp
|
|
self.faces = []
|
|
raw = self.lump(L_FACES)
|
|
if len(raw) % FACE.size:
|
|
raise BspError("the face lump has an unexpected size (%d)" % len(raw))
|
|
for i in range(0, len(raw), FACE.size):
|
|
(planenum, side, _onn, firstedge, numedges, texinfo, _disp, _fog,
|
|
_s0, _s1, _s2, _s3, _lightofs, _area, _a, _b, _c, _d, _orig,
|
|
_np, _fp, _smooth) = FACE.unpack_from(raw, i)
|
|
if planenum >= len(planes):
|
|
raise BspError("face %d references plane %d" % (i // FACE.size, planenum))
|
|
nx, ny, nz, dist, _ptype = planes[planenum]
|
|
if side:
|
|
nx, ny, nz, dist = -nx, -ny, -nz, -dist
|
|
self.faces.append(((nx, ny, nz, dist),
|
|
self.face_polygon(firstedge, numedges), texinfo))
|
|
self.brushes = []
|
|
for firstside, numsides, contents in self.lump_entries(L_BRUSHES, BRUSH):
|
|
if firstside < 0 or numsides < 0 or firstside + numsides > len(brushsides):
|
|
raise BspError("brush side range is out of bounds")
|
|
hs = []
|
|
for s in range(firstside, firstside + numsides):
|
|
planenum = brushsides[s][0]
|
|
if planenum >= len(self.brushplanes):
|
|
raise BspError("brush side references plane %d" % planenum)
|
|
hs.append(self.brushplanes[planenum][:4])
|
|
if len(hs) >= 4:
|
|
self.brushes.append((contents, hs))
|
|
|
|
# -- what the engine thinks is solid ---------------------------------
|
|
|
|
def load_tree(self):
|
|
"""the BSP tree of the world model: the same thing the engine walks to
|
|
decide whether a point is inside a wall."""
|
|
self.nodes = self.lump(L_NODES)
|
|
self.leafs = self.lump(L_LEAFS)
|
|
self.headnode = self.models[0][9] if self.models else 0
|
|
self.tree = bool(self.nodes) and bool(self.leafs) and self.headnode >= 0
|
|
return self.tree
|
|
|
|
def column_segments(self, x, y, zlo, zhi):
|
|
"""the vertical sequence of (lo, hi, contents) the map has at (x, y).
|
|
|
|
Walking the tree instead of the brush boxes gives exactly what the game
|
|
uses: trigger volumes, buyzones, origin helpers (which are brushes too,
|
|
but are not solid) and clip brushes all come out right.
|
|
"""
|
|
nodes, leafs, planes = self.nodes, self.leafs, self.planes
|
|
out = []
|
|
stack = [(self.headnode, zlo, zhi)]
|
|
limit = len(nodes) - 24
|
|
nplanes = len(planes)
|
|
while stack:
|
|
node, lo, hi = stack.pop()
|
|
if node < 0:
|
|
o = (-node - 1) * 32
|
|
contents = struct.unpack_from("<i", leafs, o)[0] if o + 4 <= len(leafs) else 0
|
|
out.append((lo, hi, contents))
|
|
continue
|
|
o = node * 32
|
|
if o > limit or hi - lo < 1e-6:
|
|
continue
|
|
planenum = struct.unpack_from("<i", nodes, o)[0]
|
|
if planenum >= nplanes:
|
|
continue
|
|
nx, ny, nz, d = planes[planenum][:4]
|
|
child0, child1 = struct.unpack_from("<ii", nodes, o + 4)
|
|
if nz > 1e-9 or nz < -1e-9:
|
|
zs = (d - nx * x - ny * y) / nz
|
|
front, back = (child0, child1) if nz > 0 else (child1, child0)
|
|
if zs > lo + 1e-9:
|
|
stack.append((back, lo, min(hi, zs)))
|
|
if zs < hi - 1e-9:
|
|
stack.append((front, max(lo, zs), hi))
|
|
else:
|
|
front = (nx * x + ny * y - d) > 0
|
|
stack.append((child0 if front else child1, lo, hi))
|
|
out.sort()
|
|
return out
|
|
|
|
def leaf_contents(self, x, y, z):
|
|
"""what the map has at one point: a single walk down to a leaf"""
|
|
nodes, leafs, planes = self.nodes, self.leafs, self.planes
|
|
node = self.headnode
|
|
limit = len(nodes) - 24
|
|
nplanes = len(planes)
|
|
while node >= 0:
|
|
o = node * 32
|
|
if o > limit:
|
|
return 0
|
|
planenum = struct.unpack_from("<i", nodes, o)[0]
|
|
if planenum >= nplanes:
|
|
return 0
|
|
nx, ny, nz, d = planes[planenum][:4]
|
|
child0, child1 = struct.unpack_from("<ii", nodes, o + 4)
|
|
node = child0 if (nx * x + ny * y + nz * z - d) > 0 else child1
|
|
o = (-node - 1) * 32
|
|
if o + 4 > len(leafs):
|
|
return 0
|
|
return struct.unpack_from("<i", leafs, o)[0]
|
|
|
|
def face_polygon(self, firstedge, numedges):
|
|
if firstedge < 0 or numedges < 0 or firstedge + numedges > len(self.surfedges):
|
|
return []
|
|
poly = []
|
|
for i in range(firstedge, firstedge + numedges):
|
|
se = self.surfedges[i]
|
|
if se >= 0:
|
|
v = self.edges[se][0] if se < len(self.edges) else -1
|
|
else:
|
|
v = self.edges[-se][1] if -se < len(self.edges) else -1
|
|
if 0 <= v < len(self.vertices):
|
|
poly.append(self.vertices[v])
|
|
return poly
|
|
|
|
# -- names and entities ----------------------------------------------
|
|
|
|
def material_names(self):
|
|
data = self.lump(L_TEXDATA_STRING_DATA)
|
|
names = []
|
|
for (off,) in self.lump_entries(L_TEXDATA_STRING_TABLE, I32):
|
|
if off < 0 or off >= len(data):
|
|
names.append("")
|
|
continue
|
|
end = data.find(b"\0", off)
|
|
if end < 0:
|
|
end = len(data)
|
|
names.append(data[off:end].decode("latin-1"))
|
|
out = []
|
|
for td in self.lump_entries(L_TEXDATA, TEXDATA):
|
|
idx = td[3]
|
|
out.append(names[idx].lower() if 0 <= idx < len(names) else "")
|
|
return out
|
|
|
|
def face_materials(self):
|
|
names = self.material_names()
|
|
texinfos = self.lump_entries(L_TEXINFO, TEXINFO)
|
|
mats = []
|
|
for _n, _poly, texinfo in self.faces:
|
|
if 0 <= texinfo < len(texinfos):
|
|
td = texinfos[texinfo][17]
|
|
mats.append(names[td] if 0 <= td < len(names) else "")
|
|
else:
|
|
mats.append("")
|
|
return mats
|
|
|
|
def entities(self):
|
|
raw = self.lump(L_ENTITIES)
|
|
if not raw:
|
|
return []
|
|
text = raw.decode("utf-8", "replace")
|
|
out = []
|
|
i = 0
|
|
while True:
|
|
start = text.find("{", i)
|
|
if start < 0:
|
|
break
|
|
end = text.find("}", start)
|
|
if end < 0:
|
|
break
|
|
i = end + 1
|
|
ent = {}
|
|
for m in re.finditer(r'"([^"]*)"\s+"([^"]*)"', text[start:end]):
|
|
ent[m.group(1).lower()] = m.group(2)
|
|
if ent:
|
|
out.append(ent)
|
|
return out
|
|
|
|
|
|
# --------------------------------------------------------------- AC textures
|
|
|
|
DEFAULT_SKY, DEFAULT_LIQUID, DEFAULT_WALL, DEFAULT_FLOOR, DEFAULT_CEIL = 0, 1, 2, 3, 4
|
|
ROLE_ROOTS = {
|
|
"floor": ("floor", "ground", "grass", "sand", "dirt", "gravel", "cobble",
|
|
"tile", "pavement", "road", "concrete", "metal", "wood", "grate",
|
|
"rock", "snow", "ice", "carpet"),
|
|
"ceil": ("ceil", "roof", "plaster", "concrete", "panel", "sky", "metal"),
|
|
"wall": ("wall", "brick", "stone", "concrete", "metal", "panel", "plaster",
|
|
"wood", "rock", "cliff", "tile", "grate"),
|
|
}
|
|
SKY_WORDS = ("skybox", "sky")
|
|
INVISIBLE_WORDS = ("nodraw", "clip", "trigger", "hint", "skip", "invisible",
|
|
"origin", "areaportal", "occluder", "blocklight", "fog",
|
|
"ladder", "func_")
|
|
WATER_WORDS = ("water", "slime", "lava", "liquid")
|
|
|
|
|
|
def tokenize(name):
|
|
"""'concrete/concretefloor011a' -> {'concrete', 'floor'}"""
|
|
base = (name or "").replace("\\", "/").split("/")[-1].lower()
|
|
base = re.sub(r"\.(jpg|jpeg|png|tga|vmt|vtf)$", "", base)
|
|
out = set()
|
|
for w in re.split(r"[^a-z]+", base):
|
|
if len(w) < 3: # 'a', 'b2' and friends are noise, they match everything
|
|
continue
|
|
if len(w) > 3 and w.endswith("s") and not w.endswith("ss"):
|
|
w = w[:-1]
|
|
out.add(w)
|
|
return out
|
|
|
|
|
|
class AcTextures(object):
|
|
"""The texture slot list of config/default_map_settings.cfg."""
|
|
|
|
def __init__(self, cfg_path):
|
|
self.names = []
|
|
if cfg_path and os.path.exists(cfg_path):
|
|
with open(cfg_path, "r", errors="replace") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line.startswith("//"):
|
|
continue
|
|
m = re.match(r'texture\s+\d+\s+"([^"]+)"', line)
|
|
if m:
|
|
self.names.append(m.group(1))
|
|
self.tokens = [tokenize(n) for n in self.names]
|
|
self.cache = {}
|
|
|
|
def _default(self, role):
|
|
return {"floor": DEFAULT_FLOOR, "ceil": DEFAULT_CEIL}.get(role, DEFAULT_WALL)
|
|
|
|
def slot(self, material, role):
|
|
key = (material, role)
|
|
hit = self.cache.get(key)
|
|
if hit is None:
|
|
hit = self._slot(material, role)
|
|
self.cache[key] = hit
|
|
return hit
|
|
|
|
def _slot(self, material, role):
|
|
m = (material or "").lower()
|
|
if any(w in m for w in SKY_WORDS):
|
|
return DEFAULT_SKY
|
|
if any(w in m for w in WATER_WORDS):
|
|
return DEFAULT_LIQUID
|
|
if not m or any(w in m for w in INVISIBLE_WORDS) or m.startswith("tools/") \
|
|
or m.startswith("editor/"):
|
|
return self._default(role)
|
|
# the material name is matched against the AC texture names as strings:
|
|
# the interesting word is usually part of a longer token ('concretefloor')
|
|
roots = ROLE_ROOTS.get(role, ())
|
|
best, bestscore = None, 0
|
|
for i, stokens in enumerate(self.tokens):
|
|
if i == DEFAULT_SKY:
|
|
continue
|
|
score = 0
|
|
for w in stokens:
|
|
if len(w) >= 4 and w in m:
|
|
score += 3
|
|
for w in roots:
|
|
if w in stokens and w in m:
|
|
score += 2
|
|
if score > bestscore:
|
|
best, bestscore = i, score
|
|
if best is None or bestscore < 3:
|
|
return self._default(role)
|
|
return best
|
|
|
|
|
|
def find_actextures(explicit):
|
|
if explicit:
|
|
return explicit if os.path.exists(explicit) else None
|
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
for up in (here, os.path.dirname(here), os.path.dirname(os.path.dirname(here))):
|
|
cand = os.path.join(up, "config", "default_map_settings.cfg")
|
|
if os.path.exists(cand):
|
|
return cand
|
|
return None
|
|
|
|
|
|
# --------------------------------------------------------------- brushes
|
|
|
|
BIG = 1.0e5
|
|
EPS = 1e-4
|
|
|
|
|
|
def brush_footprint(halfspaces, z_abs):
|
|
"""XY footprint polygon of a brush (from its vertical planes only).
|
|
|
|
It is a superset of the real footprint, which is all that binning needs."""
|
|
poly = [(-BIG, -BIG), (BIG, -BIG), (BIG, BIG), (-BIG, BIG)]
|
|
for nx, ny, nz, dist in halfspaces:
|
|
if abs(nz) > 0.001:
|
|
continue
|
|
if math.hypot(nx, ny) < 1e-9:
|
|
continue
|
|
c = (dist + abs(nz) * z_abs + 0.01) / math.hypot(nx, ny)
|
|
a, b = nx / math.hypot(nx, ny), ny / math.hypot(nx, ny)
|
|
out = []
|
|
n = len(poly)
|
|
for i in range(n):
|
|
x1, y1 = poly[i]
|
|
x2, y2 = poly[(i + 1) % n]
|
|
d1 = a * x1 + b * y1 - c
|
|
d2 = a * x2 + b * y2 - c
|
|
if d1 <= 0:
|
|
out.append((x1, y1))
|
|
if (d1 <= 0) != (d2 <= 0):
|
|
t = d1 / (d1 - d2)
|
|
out.append((x1 + (x2 - x1) * t, y1 + (y2 - y1) * t))
|
|
poly = out
|
|
if len(poly) < 3:
|
|
return []
|
|
return poly
|
|
|
|
|
|
def solid_span(planes, px, py):
|
|
"""the (lo, hi) the brush occupies at column (px, py), or None"""
|
|
lo, hi = -BIG, BIG
|
|
for i in range(0, len(planes), 4):
|
|
nx, ny, nz, d = planes[i:i + 4]
|
|
b = nx * px + ny * py - d
|
|
if nz > EPS:
|
|
t = -b / nz
|
|
if t < hi:
|
|
hi = t
|
|
elif nz < -EPS:
|
|
t = -b / nz
|
|
if t > lo:
|
|
lo = t
|
|
elif b > EPS:
|
|
return None
|
|
if lo >= hi:
|
|
return None
|
|
return (lo, hi) if lo < hi else None
|
|
|
|
|
|
def column_intervals(cand, brushes, px, py):
|
|
"""merged solid intervals along the vertical line through (px, py)"""
|
|
ivs = []
|
|
for i in cand:
|
|
iv = solid_span(brushes[i], px, py)
|
|
if iv is not None:
|
|
ivs.append(iv)
|
|
if not ivs:
|
|
return ()
|
|
ivs.sort()
|
|
out = []
|
|
lo, hi = ivs[0]
|
|
for a, b in ivs[1:]:
|
|
if a <= hi + EPS:
|
|
if b > hi:
|
|
hi = b
|
|
else:
|
|
out.append((lo, hi))
|
|
lo, hi = a, b
|
|
out.append((lo, hi))
|
|
return tuple(out)
|
|
|
|
|
|
BLOCKING_LEAF = (CONTENTS_SOLID | CONTENTS_WINDOW | CONTENTS_GRATE |
|
|
CONTENTS_MOVEABLECLIP | CONTENTS_PLAYERCLIP)
|
|
|
|
|
|
def solid_segments(segments):
|
|
"""merge the (lo, hi, contents) runs of a column into solid intervals"""
|
|
out = []
|
|
lo = hi = None
|
|
for a, b, contents in segments:
|
|
if contents & BLOCKING_LEAF:
|
|
if lo is None:
|
|
lo, hi = a, b
|
|
elif a <= hi + EPS:
|
|
hi = max(hi, b)
|
|
else:
|
|
out.append((lo, hi))
|
|
lo, hi = a, b
|
|
elif lo is not None:
|
|
out.append((lo, hi))
|
|
lo = hi = None
|
|
if lo is not None:
|
|
out.append((lo, hi))
|
|
return out
|
|
|
|
|
|
def air_spans(ivs, zlo, zhi):
|
|
out = []
|
|
z = zlo
|
|
for lo, hi in ivs:
|
|
if lo > z:
|
|
out.append((z, lo))
|
|
if hi > z:
|
|
z = hi
|
|
if z >= zhi:
|
|
break
|
|
if z < zhi:
|
|
out.append((z, zhi))
|
|
return out
|
|
|
|
|
|
# --------------------------------------------------------- the conversion
|
|
|
|
SOLID, CORNER, FHF, CHF, SPACE = 0, 1, 2, 3, 4
|
|
CUBE_TYPE_NAMES = ("solid", "corner", "floor-hf", "ceil-hf", "space")
|
|
MINBORD = 2
|
|
REGION_CELL = 256 # source units per cell when picking the main area
|
|
ZOFF = 4 # lowest geometry sits 4 cubes above height 0
|
|
SKY_HEADROOM = 12 # an open column keeps this much room under its sky
|
|
SMOOTH_LIMIT = 63.5 # vdelta: 8 bit, 1/4 cube steps
|
|
HEIGHT_LIMIT = 126
|
|
MIN_SCALE = 10.6666666 # 8 source units; AC players move 30 cells/s
|
|
MAX_SCALE = 256.0
|
|
MAX_SFACTOR = 11
|
|
MAXMAPSENDSIZE = 65536
|
|
MAXCOL = 6 # open spaces considered per column
|
|
CONNECT_CLEARANCE = 1.1 # cubes two spaces must overlap to count as connected
|
|
A_AMBIENT_BASE = 32 # map base light: the dark part of the sky light
|
|
A_AMBIENT_SUN = 56 # ... plus this much, scaled by the sky brightness
|
|
|
|
E_LIGHT, E_PLAYERSTART = 1, 2
|
|
SPAWN_CLASSES = {
|
|
"info_player_start": 100, "info_player_deathmatch": 100,
|
|
"info_player_teamspawn": 100, "info_player_combine": 100,
|
|
"info_player_rebel": 100, "info_player_allies": 100, "info_player_axis": 100,
|
|
"info_player_terrorist": 1, "info_player_counterterrorist": 0,
|
|
}
|
|
|
|
|
|
def parse_vec(text):
|
|
try:
|
|
parts = (text or "").replace(",", " ").split()
|
|
return float(parts[0]), float(parts[1]), float(parts[2])
|
|
except (ValueError, IndexError):
|
|
return None
|
|
|
|
|
|
class Converter(object):
|
|
def __init__(self, bsp, args, log):
|
|
self.bsp = bsp
|
|
self.args = args
|
|
self.log = log
|
|
self.warnings = []
|
|
self.stats = defaultdict(int)
|
|
self.textures = None
|
|
|
|
def warn(self, msg):
|
|
self.warnings.append(msg)
|
|
self.log("warning: " + msg)
|
|
|
|
# -- setup -----------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def box(poly):
|
|
xs = [v[0] for v in poly]
|
|
ys = [v[1] for v in poly]
|
|
zs = [v[2] for v in poly]
|
|
return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
|
|
|
|
@staticmethod
|
|
def union_boxes(boxes):
|
|
return (min(b[0] for b in boxes), min(b[1] for b in boxes),
|
|
min(b[2] for b in boxes), max(b[3] for b in boxes),
|
|
max(b[4] for b in boxes), max(b[5] for b in boxes))
|
|
|
|
def select_area(self):
|
|
"""the part of the map that is worth converting, and its bounds.
|
|
|
|
Source maps often carry a 3D skybox: a scaled down copy of the map placed
|
|
away from the playable area (a sky_camera entity marks where). Converting
|
|
it would both add junk and blow up the world so much that the real map
|
|
turns coarse, so only the area that is connected to the player spawns (or
|
|
the largest area when a map has no spawns) is kept - unless --whole-map
|
|
says otherwise. Returns the bounds of the kept area.
|
|
"""
|
|
boxes = self.face_boxes
|
|
if not boxes:
|
|
return None
|
|
if self.args.whole_map:
|
|
self.use_faces = set(self.face_ids)
|
|
self.keep_cells = None # nothing is filtered out
|
|
self.dropped_faces = 0
|
|
self.area_bounds = self.union_boxes(boxes)
|
|
return self.area_bounds
|
|
|
|
occupied = set()
|
|
members = defaultdict(list)
|
|
for k, b in enumerate(boxes):
|
|
for i in range(int(math.floor(b[0] / REGION_CELL)),
|
|
int(math.floor(b[3] / REGION_CELL)) + 1):
|
|
for j in range(int(math.floor(b[1] / REGION_CELL)),
|
|
int(math.floor(b[4] / REGION_CELL)) + 1):
|
|
occupied.add((i, j))
|
|
members[(int(math.floor((b[0] + b[3]) / 2.0 / REGION_CELL)),
|
|
int(math.floor((b[1] + b[4]) / 2.0 / REGION_CELL)))].append(k)
|
|
|
|
spawns = []
|
|
for ent in self.bsp.entities():
|
|
if ent.get("classname", "").lower() in SPAWN_CLASSES:
|
|
o = parse_vec(ent.get("origin"))
|
|
if o:
|
|
spawns.append(o)
|
|
|
|
seen = set()
|
|
areas = []
|
|
for start in occupied:
|
|
if start in seen:
|
|
continue
|
|
seen.add(start)
|
|
stack = [start]
|
|
cells = []
|
|
while stack:
|
|
i, j = stack.pop()
|
|
cells.append((i, j))
|
|
for di in (-1, 0, 1):
|
|
for dj in (-1, 0, 1):
|
|
n = (i + di, j + dj)
|
|
if n in occupied and n not in seen:
|
|
seen.add(n)
|
|
stack.append(n)
|
|
faces = [k for c in cells for k in members.get(c, ())]
|
|
if faces:
|
|
areas.append((faces, cells))
|
|
if not areas:
|
|
self.use_faces = set(self.face_ids)
|
|
self.keep_cells = None
|
|
self.dropped_faces = 0
|
|
self.area_bounds = self.union_boxes(boxes)
|
|
return self.area_bounds
|
|
|
|
spawnarea = None
|
|
for k, (faces, _cells) in enumerate(areas):
|
|
b = self.union_boxes([boxes[f] for f in faces])
|
|
inside = sum(1 for s in spawns if b[0] <= s[0] <= b[3] and
|
|
b[1] <= s[1] <= b[4] and b[2] <= s[2] <= b[5])
|
|
if inside and (spawnarea is None or
|
|
len(faces) > len(areas[spawnarea][0])):
|
|
spawnarea = k
|
|
best = spawnarea if spawnarea is not None else max(
|
|
range(len(areas)), key=lambda k: len(areas[k][0]))
|
|
self.keep_cells = set(areas[best][1])
|
|
self.use_faces = set(self.face_ids[f] for f in areas[best][0])
|
|
self.dropped_faces = len(boxes) - len(areas[best][0])
|
|
self.area_bounds = self.union_boxes([boxes[f] for f in areas[best][0]])
|
|
return self.area_bounds
|
|
|
|
def keeps(self, box):
|
|
"""does a piece of geometry belong to the area that was kept?"""
|
|
cells = self.keep_cells
|
|
if cells is None:
|
|
return True
|
|
for i in range(int(math.floor(box[0] / REGION_CELL)),
|
|
int(math.floor(box[3] / REGION_CELL)) + 1):
|
|
for j in range(int(math.floor(box[1] / REGION_CELL)),
|
|
int(math.floor(box[4] / REGION_CELL)) + 1):
|
|
if (i, j) in cells:
|
|
return True
|
|
return False
|
|
|
|
def choose_scale(self, b):
|
|
ext = max(b[3] - b[0], b[4] - b[1])
|
|
zext = b[5] - b[2]
|
|
if self.args.scale != "auto":
|
|
return float(self.args.scale)
|
|
scale = MIN_SCALE
|
|
for _ in range(24):
|
|
if zext / scale + ZOFF + SKY_HEADROOM + 2 > HEIGHT_LIMIT or \
|
|
ext / scale > self.args.max_cells - 2 * MINBORD - 2:
|
|
scale *= 1.15
|
|
else:
|
|
break
|
|
return min(scale, MAX_SCALE)
|
|
|
|
def run(self):
|
|
args = self.args
|
|
bsp = self.bsp
|
|
self.face_boxes = []
|
|
self.face_ids = []
|
|
for f, (_n, poly, _t) in enumerate(bsp.faces):
|
|
if len(poly) >= 3:
|
|
self.face_boxes.append(self.box(poly))
|
|
self.face_ids.append(f)
|
|
b = self.select_area()
|
|
if b is None:
|
|
raise BspError("the map has no faces at all, there is nothing to convert")
|
|
self.bounds = b
|
|
scale = self.scale = self.choose_scale(b)
|
|
if args.sfactor:
|
|
sfactor = args.sfactor
|
|
else:
|
|
need = max(b[3] - b[0], b[4] - b[1]) / scale + 2 * MINBORD + 2
|
|
sfactor = 6
|
|
while (1 << sfactor) < need and sfactor < MAX_SFACTOR:
|
|
sfactor += 1
|
|
sfactor = max(sfactor, args.min_sfactor)
|
|
ssize = self.ssize = 1 << sfactor
|
|
self.sfactor = sfactor
|
|
if (b[3] - b[0]) / scale > ssize - 2 * MINBORD or \
|
|
(b[4] - b[1]) / scale > ssize - 2 * MINBORD:
|
|
raise BspError("the map does not fit into a %d cell world at %g units "
|
|
"per cube (-s %.2f or --sfactor %d)"
|
|
% (ssize, scale, scale, min(MAX_SFACTOR, sfactor + 1)))
|
|
if ssize > args.max_cells * 2:
|
|
self.warn("the world is %dx%d cells, this is a large conversion" % (ssize, ssize))
|
|
self.x0 = (b[0] + b[3]) / 2.0 - ssize / 2.0 * scale
|
|
self.y0 = (b[1] + b[4]) / 2.0 - ssize / 2.0 * scale
|
|
self.z0 = b[2]
|
|
# the clip window has to reach a bit above the highest geometry, or the
|
|
# space above surfaces that come close to it would look too thin to use
|
|
zpad = max((args.min_span + 1.0) * scale, scale)
|
|
self.zlo = b[2] - zpad
|
|
self.zhi = b[5] + zpad
|
|
|
|
self.log("%s: bsp version %d, %.0f x %.0f x %.0f source units"
|
|
% (os.path.basename(args.bsp), bsp.version,
|
|
b[3] - b[0], b[4] - b[1], b[5] - b[2]))
|
|
if self.dropped_faces:
|
|
self.log("kept the main area of the map; %d faces outside it (a 3D "
|
|
"skybox or a detached area) are left out (-w keeps them)"
|
|
% self.dropped_faces)
|
|
self.log("scale %.2f units/cube, world %dx%d cells (sfactor %d), %d brushes"
|
|
% (scale, ssize, ssize, sfactor, len(bsp.brushes)))
|
|
if len(bsp.models) > 1:
|
|
self.log("%d models in the map: brush entity geometry is baked in "
|
|
"its compiled position" % len(bsp.models))
|
|
|
|
self.use_tree = bsp.load_tree()
|
|
if not self.use_tree:
|
|
self.log("this .bsp has no BSP tree, falling back to tracing brush "
|
|
"boxes (trigger volumes can not be told apart then)")
|
|
self.prepare_brushes()
|
|
self.trace_columns()
|
|
self.compute_cell_corners()
|
|
self.pick_textures()
|
|
self.build_world()
|
|
ents = self.build_entities()
|
|
self.ambient = self.build_ambient()
|
|
out = self.write_cgz(ents)
|
|
self.report(out)
|
|
|
|
def prepare_brushes(self):
|
|
z_abs = max(abs(self.zlo), abs(self.zhi))
|
|
self.brushes = []
|
|
self.brushes_outside = 0
|
|
self.water_planes = []
|
|
for contents, hs in self.bsp.brushes:
|
|
if contents & CONTENTS_ORIGIN:
|
|
continue # used to place brush entities, not solid in Source
|
|
keep = True
|
|
if self.keep_cells is not None:
|
|
poly = brush_footprint(hs, z_abs)
|
|
keep = not poly or self.keeps(self.box([(p[0], p[1], 0.0)
|
|
for p in poly]))
|
|
if contents & LIQUID:
|
|
if keep:
|
|
self.water_planes.append(tuple(x for p in hs for x in p))
|
|
continue
|
|
if contents & BLOCKING:
|
|
if keep:
|
|
self.brushes.append(tuple(x for p in hs for x in p))
|
|
else:
|
|
self.brushes_outside += 1
|
|
if self.brushes_outside:
|
|
self.log("%d of %d brushes are outside the kept area"
|
|
% (self.brushes_outside, len(self.bsp.brushes)))
|
|
if not self.brushes:
|
|
raise BspError("the map contains no blocking brushes")
|
|
self.binsize = 8
|
|
self.bins = defaultdict(list)
|
|
for idx, flat in enumerate(self.brushes):
|
|
hs = [flat[j:j + 4] for j in range(0, len(flat), 4)]
|
|
poly = brush_footprint(hs, z_abs)
|
|
if not poly:
|
|
poly = [((self.x0), (self.y0))]
|
|
xs = [((p[0] - self.x0) / self.scale) for p in poly]
|
|
ys = [((p[1] - self.y0) / self.scale) for p in poly]
|
|
bx = by = self.binsize
|
|
for cy in range(int(math.floor(min(ys) / by)) - 1,
|
|
int(math.floor(max(ys) / by)) + 2):
|
|
for cx in range(int(math.floor(min(xs) / bx)) - 1,
|
|
int(math.floor(max(xs) / bx)) + 2):
|
|
self.bins[(cx, cy)].append(idx)
|
|
|
|
def candidates(self, i, j):
|
|
return self.bins.get((i // self.binsize, j // self.binsize), ())
|
|
|
|
# -- pass 1: one open space per column -------------------------------
|
|
|
|
def trace_columns(self):
|
|
ssize = self.ssize
|
|
n = ssize * ssize
|
|
self.state = bytearray(n) # 0 solid, 1 open, 2 open to the bottom
|
|
self.floor_src = array("f", bytes(4 * n))
|
|
self.ceil_src = array("f", bytes(4 * n))
|
|
self.corner_own = array("f", bytes(4 * n))
|
|
self.corner_min = array("f", bytes(4 * n))
|
|
self.corner_max = array("f", bytes(4 * n))
|
|
self.corner_avg = array("f", bytes(4 * n))
|
|
self.min_span_src = self.args.min_span * self.scale
|
|
self.overhang = self.bottom = 0
|
|
policy = self.args.space
|
|
if policy in ("playable", "spawns"):
|
|
# "spawns" is the old name for the same thing
|
|
if not (self.use_tree and self.trace_playable()):
|
|
if self.use_tree:
|
|
self.warn("could not work out the playable space of this map, "
|
|
"keeping the highest space of every column instead")
|
|
self.trace_simple("top")
|
|
else:
|
|
self.trace_simple(policy)
|
|
self.stats["columns_overhanging"] = self.overhang
|
|
self.stats["columns_open_bottom"] = self.bottom
|
|
self.log("%d open columns (%d had more than one open space, %d were open "
|
|
"to the bottom of the world and were filled)"
|
|
% (sum(1 for s in self.state if s), self.overhang, self.bottom))
|
|
|
|
def column_air(self, i, j, px, py):
|
|
"""the open spaces of one column, in source units"""
|
|
if self.use_tree:
|
|
ivs = solid_segments(self.bsp.column_segments(px, py, self.zlo, self.zhi))
|
|
else:
|
|
ivs = column_intervals(self.candidates(i, j), self.brushes, px, py)
|
|
if not ivs:
|
|
return ()
|
|
return [s for s in air_spans(ivs, self.zlo, self.zhi)
|
|
if s[1] - s[0] >= self.min_span_src]
|
|
|
|
def open_at(self, x, y, z):
|
|
"""is the map open (not solid) at this point?"""
|
|
return not (self.bsp.leaf_contents(x, y, z) & BLOCKING_LEAF)
|
|
|
|
def connected(self, midx, midy, lo, hi):
|
|
"""do two spaces of neighbouring columns really touch?
|
|
|
|
They have to overlap, and the map has to be open where they meet - a
|
|
thin wall between two rooms must not turn into a door.
|
|
"""
|
|
h = hi - lo
|
|
return (self.open_at(midx, midy, lo + h * 0.34) or
|
|
self.open_at(midx, midy, lo + h * 0.66))
|
|
|
|
def trace_playable(self):
|
|
"""keep the space a player can actually be in, not the top of the map.
|
|
|
|
Every open space of every column is a node, two spaces of neighbouring
|
|
columns are joined when they overlap enough and the map is open where
|
|
they meet. The component that holds the player spawns (or the biggest
|
|
one when a map has no spawns) is what gets converted, so roofs, the sky
|
|
above the map and sealed off rooms stay solid - that is what makes a
|
|
converted map still read like the original instead of a landscape of
|
|
wall tops you can walk on.
|
|
"""
|
|
ssize, scale = self.ssize, self.scale
|
|
n = ssize * ssize
|
|
clearance = CONNECT_CLEARANCE * scale
|
|
max_step = self.args.max_step * scale
|
|
ivlo = array("f", bytes(4 * MAXCOL * n))
|
|
ivhi = array("f", bytes(4 * MAXCOL * n))
|
|
cnt = bytearray(n)
|
|
overhang = 0
|
|
for j in range(ssize):
|
|
pyc = self.y0 + (j + 0.5) * scale
|
|
for i in range(ssize):
|
|
spans = self.column_air(i, j, self.x0 + (i + 0.5) * scale, pyc)
|
|
if not spans:
|
|
continue
|
|
if len(spans) > 1:
|
|
overhang += 1
|
|
idx = j * ssize + i
|
|
k = 0
|
|
for lo, hi in spans[:MAXCOL]:
|
|
ivlo[idx * MAXCOL + k] = lo
|
|
ivhi[idx * MAXCOL + k] = hi
|
|
k += 1
|
|
cnt[idx] = k
|
|
self.overhang = overhang
|
|
|
|
# which spaces of which neighbouring columns connect, and which way
|
|
connR = array("H", bytes(2 * n))
|
|
connRb = array("H", bytes(2 * n))
|
|
connD = array("H", bytes(2 * n))
|
|
connDb = array("H", bytes(2 * n))
|
|
for j in range(ssize):
|
|
pyc = self.y0 + (j + 0.5) * scale
|
|
for i in range(ssize):
|
|
idx = j * ssize + i
|
|
ca = cnt[idx]
|
|
if not ca:
|
|
continue
|
|
px = self.x0 + (i + 0.5) * scale
|
|
if i + 1 < ssize and cnt[idx + 1]:
|
|
connR[idx], connRb[idx] = self.link_masks(
|
|
idx, idx + 1, ca, cnt[idx + 1], ivlo, ivhi, clearance,
|
|
px + scale * 0.5, pyc, max_step)
|
|
if j + 1 < ssize and cnt[idx + ssize]:
|
|
connD[idx], connDb[idx] = self.link_masks(
|
|
idx, idx + ssize, ca, cnt[idx + ssize], ivlo, ivhi,
|
|
clearance, px, pyc + scale * 0.5, max_step)
|
|
|
|
# label the components
|
|
nodes = n * MAXCOL
|
|
label = array("i", bytes(4 * nodes))
|
|
areas = [0.0]
|
|
stack = []
|
|
for j in range(ssize):
|
|
for i in range(ssize):
|
|
idx = j * ssize + i
|
|
for k in range(cnt[idx]):
|
|
node = idx * MAXCOL + k
|
|
if label[node]:
|
|
continue
|
|
label[node] = len(areas)
|
|
areas.append(0.0)
|
|
stack.append(node)
|
|
area = 0.0
|
|
while stack:
|
|
cur = stack.pop()
|
|
cidx, kk = divmod(cur, MAXCOL)
|
|
ci, cj = cidx % ssize, cidx // ssize
|
|
area += ivhi[cur] - ivlo[cur]
|
|
if ci + 1 < ssize and connR[cidx]:
|
|
m = connR[cidx]
|
|
for b in range(cnt[cidx + 1]):
|
|
if (m >> (kk * MAXCOL + b)) & 1:
|
|
nn = (cidx + 1) * MAXCOL + b
|
|
if not label[nn]:
|
|
label[nn] = len(areas) - 1
|
|
stack.append(nn)
|
|
if ci and connRb[cidx - 1]:
|
|
m = connRb[cidx - 1]
|
|
for a in range(cnt[cidx - 1]):
|
|
if (m >> (a * MAXCOL + kk)) & 1:
|
|
nn = (cidx - 1) * MAXCOL + a
|
|
if not label[nn]:
|
|
label[nn] = len(areas) - 1
|
|
stack.append(nn)
|
|
if cj + 1 < ssize and connD[cidx]:
|
|
m = connD[cidx]
|
|
for b in range(cnt[cidx + ssize]):
|
|
if (m >> (kk * MAXCOL + b)) & 1:
|
|
nn = (cidx + ssize) * MAXCOL + b
|
|
if not label[nn]:
|
|
label[nn] = len(areas) - 1
|
|
stack.append(nn)
|
|
if cj and connDb[cidx - ssize]:
|
|
m = connDb[cidx - ssize]
|
|
for a in range(cnt[cidx - ssize]):
|
|
if (m >> (a * MAXCOL + kk)) & 1:
|
|
nn = (cidx - ssize) * MAXCOL + a
|
|
if not label[nn]:
|
|
label[nn] = len(areas) - 1
|
|
stack.append(nn)
|
|
areas[label[node]] = area
|
|
|
|
# the component the spawns are in, or the biggest one
|
|
votes = defaultdict(int)
|
|
spawns = defaultdict(list)
|
|
for ent in self.bsp.entities():
|
|
if ent.get("classname", "").lower() not in SPAWN_CLASSES:
|
|
continue
|
|
o = parse_vec(ent.get("origin"))
|
|
if not o:
|
|
continue
|
|
i = int(math.floor((o[0] - self.x0) / scale))
|
|
j = int(math.floor((o[1] - self.y0) / scale))
|
|
if not (0 <= i < ssize and 0 <= j < ssize):
|
|
continue
|
|
idx = j * ssize + i
|
|
for k in range(cnt[idx]):
|
|
if ivlo[idx * MAXCOL + k] - 1 <= o[2] <= ivhi[idx * MAXCOL + k] + 1:
|
|
votes[label[idx * MAXCOL + k]] += 1
|
|
spawns[idx].append(o[2])
|
|
break
|
|
if votes:
|
|
best = max(votes, key=lambda l: votes[l])
|
|
else:
|
|
best = max(range(1, len(areas)), key=lambda l: areas[l], default=0)
|
|
if best <= 0:
|
|
return False
|
|
|
|
# one space per column: the lowest one of that component, so the map keeps
|
|
# its ground level (a spawn point always keeps the space it is in)
|
|
state, floor_src, ceil_src = self.state, self.floor_src, self.ceil_src
|
|
zlo = self.zlo
|
|
cols = 0
|
|
for j in range(ssize):
|
|
base = j * ssize
|
|
for i in range(ssize):
|
|
idx = base + i
|
|
pick = -1
|
|
spawn_here = spawns.get(idx)
|
|
for k in range(cnt[idx]):
|
|
node = idx * MAXCOL + k
|
|
if label[node] != best:
|
|
continue
|
|
lo, hi = ivlo[node], ivhi[node]
|
|
if spawn_here is not None and any(lo - 1 <= z <= hi + 1
|
|
for z in spawn_here):
|
|
pick = k
|
|
break
|
|
if pick < 0 or lo < ivlo[idx * MAXCOL + pick]:
|
|
pick = k
|
|
if pick < 0:
|
|
continue
|
|
node = idx * MAXCOL + pick
|
|
flo, cei = ivlo[node], ivhi[node]
|
|
cols += 1
|
|
if flo <= zlo + 1e-3:
|
|
state[idx] = 2
|
|
self.bottom += 1
|
|
continue
|
|
state[idx] = 1
|
|
floor_src[idx] = flo
|
|
ceil_src[idx] = cei
|
|
opencols = sum(1 for i in range(n) if cnt[i])
|
|
if cols < max(48, opencols // 100):
|
|
return False
|
|
self.log("playable space: %d columns%s (out of %d open ones)"
|
|
% (cols, " around the spawns" if votes else " (the biggest one)",
|
|
opencols))
|
|
return True
|
|
|
|
def link_masks(self, ia, ib, ca, cb, ivlo, ivhi, clearance, midx, midy,
|
|
max_step):
|
|
"""how the spaces of two neighbouring columns connect.
|
|
|
|
Returns (forward, backward) masks: which space of column ia touches which
|
|
space of column ib, and which way the player can actually get across -
|
|
dropping down any distance is fine, climbing is not.
|
|
"""
|
|
fwd = back = 0
|
|
for a in range(ca):
|
|
alo, ahi = ivlo[ia * MAXCOL + a], ivhi[ia * MAXCOL + a]
|
|
for b in range(cb):
|
|
blo, bhi = ivlo[ib * MAXCOL + b], ivhi[ib * MAXCOL + b]
|
|
lo, hi = max(alo, blo), min(ahi, bhi)
|
|
if hi - lo < clearance:
|
|
continue
|
|
if not self.connected(midx, midy, lo, hi):
|
|
continue
|
|
if blo - alo <= max_step:
|
|
fwd |= 1 << (a * MAXCOL + b)
|
|
if alo - blo <= max_step:
|
|
back |= 1 << (b * MAXCOL + a)
|
|
return fwd, back
|
|
|
|
def trace_simple(self, policy):
|
|
"""one open space per column, picked by policy (no connectivity)"""
|
|
ssize, scale = self.ssize, self.scale
|
|
zlo = self.zlo
|
|
state, floor_src, ceil_src = self.state, self.floor_src, self.ceil_src
|
|
spawns = defaultdict(list)
|
|
if policy == "spawns":
|
|
for ent in self.bsp.entities():
|
|
if ent.get("classname", "").lower() in SPAWN_CLASSES:
|
|
o = parse_vec(ent.get("origin"))
|
|
if o:
|
|
spawns[(int((o[0] - self.x0) / scale),
|
|
int((o[1] - self.y0) / scale))].append(o[2])
|
|
for j in range(ssize):
|
|
pyc = self.y0 + (j + 0.5) * scale
|
|
base = j * ssize
|
|
for i in range(ssize):
|
|
usable = self.column_air(i, j, self.x0 + (i + 0.5) * scale, pyc)
|
|
if not usable:
|
|
continue
|
|
if policy == "largest":
|
|
chosen = max(usable, key=lambda s: s[1] - s[0])
|
|
elif policy == "spawns" and spawns.get((i, j)):
|
|
cs = spawns[(i, j)]
|
|
chosen = max(usable, key=lambda s: sum(
|
|
1 for z in cs if s[0] - 1 <= z <= s[1] + 1))
|
|
else:
|
|
chosen = usable[-1]
|
|
flo, cei = chosen
|
|
idx = base + i
|
|
if flo <= zlo + 1e-3:
|
|
state[idx] = 2
|
|
self.bottom += 1
|
|
continue
|
|
state[idx] = 1
|
|
floor_src[idx] = flo
|
|
ceil_src[idx] = cei
|
|
|
|
def compute_cell_corners(self):
|
|
"""Corner heights from the cell surfaces instead of the geometry itself.
|
|
|
|
A heightfield vertex is shared by up to four cells, and the engine reads
|
|
it from the cell it belongs to, so it has to be the same for all of them.
|
|
The average of the surrounding cell surfaces gives exactly the surface
|
|
height at that corner for sloped (linear) geometry."""
|
|
ssize, scale = self.ssize, self.scale
|
|
stride = ssize + 1
|
|
state, floor_src = self.state, self.floor_src
|
|
vh = array("f", bytes(4 * stride * stride))
|
|
filled = bytearray(stride * stride)
|
|
offsets = ((-1, -1), (0, -1), (-1, 0), (0, 0))
|
|
for j in range(stride):
|
|
row = j * stride
|
|
for i in range(stride):
|
|
s = 0.0
|
|
cnt = 0
|
|
for di, dj in offsets:
|
|
ii, jj = i + di, j + dj
|
|
if 0 <= ii < ssize and 0 <= jj < ssize:
|
|
idx = jj * ssize + ii
|
|
if state[idx] == 1:
|
|
s += floor_src[idx]
|
|
cnt += 1
|
|
if cnt:
|
|
vh[row + i] = s / cnt
|
|
filled[row + i] = 1
|
|
own, cmin, cmax, cavg = (self.corner_own, self.corner_min,
|
|
self.corner_max, self.corner_avg)
|
|
for j in range(ssize):
|
|
for i in range(ssize):
|
|
idx = j * ssize + i
|
|
if state[idx] != 1:
|
|
continue
|
|
cs = []
|
|
for di, dj in ((0, 0), (1, 0), (0, 1), (1, 1)):
|
|
k = (j + dj) * stride + (i + di)
|
|
cs.append(vh[k] if filled[k] else floor_src[idx])
|
|
own[idx] = cs[0]
|
|
cmin[idx] = min(cs)
|
|
cmax[idx] = max(cs)
|
|
cavg[idx] = sum(cs) * 0.25
|
|
self.vheight = vh
|
|
self.vfilled = filled
|
|
self.vstride = stride
|
|
|
|
# -- pass 2: textures -------------------------------------------------
|
|
|
|
def pick_textures(self):
|
|
ssize, scale = self.ssize, self.scale
|
|
n = ssize * ssize
|
|
bsp = self.bsp
|
|
mats = bsp.face_materials()
|
|
self.floor_mat = array("i", bytes(4 * n))
|
|
self.ceil_mat = array("i", bytes(4 * n))
|
|
self.wall_mat = array("i", bytes(4 * n))
|
|
self.floor_err = array("f", struct.pack("<f", 1e30) * n)
|
|
self.ceil_err = array("f", struct.pack("<f", 1e30) * n)
|
|
self.wall_w = array("f", bytes(4 * n))
|
|
for f in range(len(bsp.faces)):
|
|
if f not in self.use_faces:
|
|
continue
|
|
(nx, ny, nz, dist), poly, _ti = bsp.faces[f]
|
|
if len(poly) < 3:
|
|
continue
|
|
name = mats[f]
|
|
if abs(nz) >= 0.25:
|
|
self.raster_horizontal(poly, nx, ny, nz, dist, name,
|
|
"floor" if nz > 0 else "ceil")
|
|
else:
|
|
self.raster_wall(poly, name)
|
|
|
|
def cell_range(self, poly, pad=0.0):
|
|
"""the world cells a face can touch, or None when it misses the world"""
|
|
s = self.scale
|
|
xs = [(p[0] - self.x0) / s for p in poly]
|
|
ys = [(p[1] - self.y0) / s for p in poly]
|
|
i0 = int(math.floor(min(xs) - pad))
|
|
i1 = int(math.ceil(max(xs) + pad))
|
|
j0 = int(math.floor(min(ys) - pad))
|
|
j1 = int(math.ceil(max(ys) + pad))
|
|
if i1 < 0 or j1 < 0 or i0 > self.ssize - 1 or j0 > self.ssize - 1:
|
|
return None
|
|
return (max(0, i0), min(self.ssize - 1, i1),
|
|
max(0, j0), min(self.ssize - 1, j1))
|
|
|
|
def raster_horizontal(self, poly, nx, ny, nz, dist, name, role):
|
|
ssize, s = self.ssize, self.scale
|
|
state = self.state
|
|
r = self.cell_range(poly)
|
|
if r is None:
|
|
return
|
|
i0, i1, j0, j1 = r
|
|
slot = self.textures.slot(name, role)
|
|
pts = [((p[0] - self.x0) / s, (p[1] - self.y0) / s) for p in poly]
|
|
level = self.floor_src if role == "floor" else self.ceil_src
|
|
err = self.floor_err if role == "floor" else self.ceil_err
|
|
mat = self.floor_mat if role == "floor" else self.ceil_mat
|
|
n = len(pts)
|
|
for j in range(j0, j1 + 1):
|
|
yc = j + 0.5
|
|
xs = []
|
|
for k in range(n):
|
|
x1, y1 = pts[k]
|
|
x2, y2 = pts[(k + 1) % n]
|
|
if (y1 <= yc < y2) or (y2 <= yc < y1):
|
|
xs.append(x1 + (x2 - x1) * (yc - y1) / (y2 - y1))
|
|
if len(xs) < 2:
|
|
continue
|
|
xs.sort()
|
|
for k in range(0, len(xs) - 1, 2):
|
|
ia = max(i0, int(math.ceil(xs[k] - 0.5)))
|
|
ib = min(i1, int(math.floor(xs[k + 1] - 0.5)))
|
|
for i in range(ia, ib + 1):
|
|
idx = j * ssize + i
|
|
if state[idx] != 1:
|
|
continue
|
|
xw = self.x0 + (i + 0.5) * s
|
|
yw = self.y0 + (j + 0.5) * s
|
|
d = abs((dist - nx * xw - ny * yw) / nz - level[idx])
|
|
if d < err[idx] and d <= 1.5 * s:
|
|
err[idx] = d
|
|
mat[idx] = slot
|
|
|
|
def raster_wall(self, poly, name):
|
|
ssize, s = self.ssize, self.scale
|
|
state = self.state
|
|
flat = [((p[0] - self.x0) / s, (p[1] - self.y0) / s) for p in poly]
|
|
zs = [p[2] for p in poly]
|
|
ax, ay = flat[0]
|
|
bx, by = flat[-1]
|
|
length = math.hypot(bx - ax, by - ay)
|
|
area = (max(zs) - min(zs)) * max(length, s)
|
|
if area <= 0:
|
|
return
|
|
slot = self.textures.slot(name, "wall")
|
|
r = self.cell_range(poly, pad=1.5)
|
|
if r is None:
|
|
return
|
|
i0, i1, j0, j1 = r
|
|
dx, dy = bx - ax, by - ay
|
|
len2 = dx * dx + dy * dy
|
|
for j in range(j0, j1 + 1):
|
|
for i in range(i0, i1 + 1):
|
|
idx = j * ssize + i
|
|
if state[idx] != 1:
|
|
continue
|
|
px, py = i + 0.5, j + 0.5
|
|
if len2 > 1e-9:
|
|
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / len2))
|
|
d = math.hypot(px - (ax + t * dx), py - (ay + t * dy))
|
|
else:
|
|
d = math.hypot(px - ax, py - ay)
|
|
if d > 1.0:
|
|
continue
|
|
if max(zs) < self.floor_src[idx] - 0.5 or \
|
|
min(zs) > self.ceil_src[idx] + 0.5:
|
|
continue
|
|
if area > self.wall_w[idx]:
|
|
self.wall_w[idx] = area
|
|
self.wall_mat[idx] = slot
|
|
|
|
# -- pass 3: the cube world -------------------------------------------
|
|
|
|
def build_world(self):
|
|
ssize, scale = self.ssize, self.scale
|
|
n = ssize * ssize
|
|
state, own = self.state, self.corner_own
|
|
z0, zc = self.z0, lambda z: (z - self.z0) / self.scale + ZOFF
|
|
floor_ac = array("f", bytes(4 * n))
|
|
ceil_ac = array("f", bytes(4 * n))
|
|
sloped = bytearray(n)
|
|
vmax = -1e30
|
|
for idx in range(n):
|
|
if state[idx] != 1:
|
|
continue
|
|
floor_ac[idx] = zc(self.corner_min[idx])
|
|
ceil_ac[idx] = zc(self.ceil_src[idx])
|
|
if self.corner_max[idx] > vmax:
|
|
vmax = self.corner_max[idx]
|
|
if (self.corner_max[idx] - self.corner_min[idx]) / scale > 0.03:
|
|
sloped[idx] = 1
|
|
if vmax < -1e29:
|
|
raise BspError("no open space found, there is nothing to convert")
|
|
base = self.base = int(math.ceil(zc(vmax)))
|
|
if base + SKY_HEADROOM + 1 > HEIGHT_LIMIT:
|
|
raise BspError("the map is too tall for AC (%d cubes); use a coarser "
|
|
"scale (-s %.1f or more)"
|
|
% (base, (self.bounds[5] - self.bounds[2]) /
|
|
(HEIGHT_LIMIT - SKY_HEADROOM - 6.0)))
|
|
|
|
smooth = not self.args.no_smooth
|
|
dipcells = 0
|
|
if smooth:
|
|
read = bytearray(n)
|
|
for j in range(ssize):
|
|
for i in range(ssize):
|
|
if not sloped[j * ssize + i]:
|
|
continue
|
|
for dj in (-1, 0, 1):
|
|
for di in (-1, 0, 1):
|
|
ii, jj = i + di, j + dj
|
|
if 0 <= ii < ssize and 0 <= jj < ssize and \
|
|
state[jj * ssize + ii] == 1:
|
|
read[jj * ssize + ii] = 1
|
|
low = min((self.corner_min[i] for i in range(n) if read[i]), default=None)
|
|
if low is not None and base - zc(low) > SMOOTH_LIMIT:
|
|
dipcells = sum(1 for i in range(n) if read[i])
|
|
smooth = False
|
|
best = math.ceil((self.bounds[5] - self.bounds[2]) / SMOOTH_LIMIT)
|
|
self.warn("the walkable surface drops %.0f cubes from its highest "
|
|
"point (the AC heightfield limit is %.0f), so ramps are "
|
|
"stair-stepped here. Pass -s %d (or more) for smooth "
|
|
"ramps - cubes get bigger and the map gets lower detail."
|
|
% (base - zc(low), SMOOTH_LIMIT, max(16, int(best))))
|
|
self.log("world %s, floor level %d, heightfields %s"
|
|
% ("smoothed" if smooth else "stair-stepped", base,
|
|
"on" if smooth else "off"))
|
|
|
|
vh, vfilled, vstride = self.vheight, self.vfilled, self.vstride
|
|
|
|
def vdelta_at(i, j):
|
|
"""a cube's own corner vdelta; solid cubes carry one as well, because
|
|
the heightfield cell next to them reads it to place that corner"""
|
|
if not smooth:
|
|
return 0
|
|
k = j * vstride + i
|
|
if not vfilled[k]:
|
|
return 0
|
|
v = int(round(4.0 * (base - zc(vh[k]))))
|
|
return 0 if v < 0 else (255 if v > 255 else v)
|
|
|
|
def cube(idx):
|
|
"""the file representation of one cube"""
|
|
i, j = idx % ssize, idx // ssize
|
|
wall = int(self.wall_mat[idx]) or DEFAULT_WALL
|
|
if state[idx] != 1:
|
|
return (SOLID, 0, 0, wall, 0, 0, vdelta_at(i, j), 0, 0)
|
|
ftex = int(self.floor_mat[idx]) or DEFAULT_FLOOR
|
|
skyceil = self.ceil_src[idx] >= self.zhi - 1e-3
|
|
ctex = DEFAULT_SKY if skyceil else (int(self.ceil_mat[idx]) or DEFAULT_CEIL)
|
|
c = int(round(ceil_ac[idx]))
|
|
if skyceil:
|
|
# a sky column keeps its own headroom, and a heightfield cell has
|
|
# to keep its ceiling above the base it stores as its floor
|
|
c = max(c, int(round(zc(self.corner_avg[idx]) + SKY_HEADROOM)), base + 1)
|
|
if smooth and sloped[idx] and c > base:
|
|
return (FHF, base, min(c, HEIGHT_LIMIT), wall, ftex, ctex,
|
|
vdelta_at(i, j), wall, 0)
|
|
f = int(round(zc(self.corner_avg[idx])))
|
|
f = max(1, min(f, HEIGHT_LIMIT - 1))
|
|
c = max(c, f + 1)
|
|
return (SPACE, f, min(c, HEIGHT_LIMIT), wall, ftex, ctex,
|
|
vdelta_at(i, j), wall, 0)
|
|
|
|
self.log("materials in use: %d floor, %d ceiling, %d wall"
|
|
% (len(set(self.floor_mat)), len(set(self.ceil_mat)),
|
|
len(set(self.wall_mat))))
|
|
self.cubedata = self.encode(cube)
|
|
|
|
def encode(self, cube):
|
|
ssize = self.ssize
|
|
out = bytearray()
|
|
sc = 0
|
|
prev = None
|
|
|
|
def purge():
|
|
nonlocal sc
|
|
while sc:
|
|
out.append(255)
|
|
if sc > 255:
|
|
out.append(255)
|
|
sc -= 255
|
|
else:
|
|
out.append(sc)
|
|
sc = 0
|
|
|
|
counts = self.stats
|
|
for j in range(ssize):
|
|
base = j * ssize
|
|
for i in range(ssize):
|
|
idx = base + i
|
|
if i < MINBORD or j < MINBORD or i >= ssize - MINBORD or \
|
|
j >= ssize - MINBORD:
|
|
c = (SOLID, 0, 0, DEFAULT_WALL, 0, 0, 0, 0, 0)
|
|
else:
|
|
c = cube(idx)
|
|
counts[CUBE_TYPE_NAMES[c[0]]] += 1
|
|
if c[0] == SOLID:
|
|
if prev is not None and prev[0] == SOLID and \
|
|
prev[3] == c[3] and prev[6] == c[6]:
|
|
sc += 1
|
|
else:
|
|
purge()
|
|
out.append(SOLID)
|
|
out.append(c[3])
|
|
out.append(c[6])
|
|
else:
|
|
if prev is not None and prev == c:
|
|
sc += 1
|
|
else:
|
|
purge()
|
|
out.append(c[0])
|
|
out.append(c[1] & 0xff)
|
|
out.append(c[2] & 0xff)
|
|
out.append(c[3] & 0xff)
|
|
out.append(c[4] & 0xff)
|
|
out.append(c[5] & 0xff)
|
|
out.append(c[6] & 0xff)
|
|
out.append(c[7] & 0xff)
|
|
out.append(c[8] & 0xff)
|
|
prev = c
|
|
purge()
|
|
return out
|
|
|
|
# -- entities ---------------------------------------------------------
|
|
|
|
def build_entities(self):
|
|
args = self.args
|
|
if args.entities == "none":
|
|
return []
|
|
ssize, scale = self.ssize, self.scale
|
|
ents = []
|
|
ffa = teams = skipped = 0
|
|
for ent in self.bsp.entities():
|
|
cls = ent.get("classname", "").lower()
|
|
if cls not in SPAWN_CLASSES:
|
|
continue
|
|
o = parse_vec(ent.get("origin"))
|
|
if not o:
|
|
skipped += 1
|
|
continue
|
|
i = int(math.floor((o[0] - self.x0) / scale))
|
|
j = int(math.floor((o[1] - self.y0) / scale))
|
|
if not (0 <= i < ssize and 0 <= j < ssize):
|
|
skipped += 1
|
|
continue
|
|
idx = j * ssize + i
|
|
if self.state[idx] != 1 or not (self.floor_src[idx] - 1.0 <= o[2]
|
|
<= self.ceil_src[idx] + 1.0):
|
|
skipped += 1
|
|
continue
|
|
z = int(round(self.corner_avg[idx] / scale - self.z0 / scale + ZOFF + 4.5))
|
|
zmax = int(round((self.ceil_src[idx] - self.z0) / scale + ZOFF)) - 1
|
|
z = max(1, min(z, max(1, zmax)))
|
|
yaw = 0.0
|
|
if "angles" in ent:
|
|
a = ent["angles"].split()
|
|
if len(a) >= 2:
|
|
try:
|
|
yaw = float(a[1])
|
|
except ValueError:
|
|
yaw = 0.0
|
|
elif "angle" in ent:
|
|
try:
|
|
yaw = float(ent["angle"])
|
|
except ValueError:
|
|
yaw = 0.0
|
|
attr1 = int(round(((yaw + 90.0) % 360.0) * 10.0)) % 3600
|
|
team = SPAWN_CLASSES[cls]
|
|
ents.append(self.entity(i, j, z, attr1, E_PLAYERSTART, team))
|
|
if team == 100:
|
|
ffa += 1
|
|
else:
|
|
teams += 1
|
|
if skipped:
|
|
self.log("%d spawn entities were dropped (outside the world or in a "
|
|
"space that was not kept)" % skipped)
|
|
lights = self.build_lights() if args.entities == "all" else []
|
|
self.stats["lights"] = len(lights)
|
|
ents.extend(lights)
|
|
if args.spawn_fill and ffa < 5:
|
|
added = self.fill_spawns(ents, 5 - ffa)
|
|
ffa += added
|
|
if added:
|
|
self.log("added %d FFA spawn points so the map is playable" % added)
|
|
self.stats["spawns_ffa"] = ffa
|
|
self.stats["spawns_team"] = teams
|
|
if ffa + teams == 0:
|
|
self.warn("no usable spawn points; the map can only be looked at")
|
|
self.log("%d spawn entities: %d free for all, %d team" % (ffa + teams, ffa, teams))
|
|
return ents
|
|
|
|
@staticmethod
|
|
def entity(i, j, z, attr1, typ, a2, a3=0, a4=0, a5=0, a6=0, a7=0):
|
|
return (i, j, z, attr1, typ, a2, a3, a4, a5, a6, a7)
|
|
|
|
def build_ambient(self):
|
|
"""the map's base light (the ambient field of the AC map header).
|
|
|
|
AssaultCube lights a map from its LIGHT entities plus this base value, so
|
|
a converted map that only kept the geometry would be almost black. The
|
|
map's sun/sky (light_environment) is the closest thing Source has to a
|
|
base light, so its colour is used for it.
|
|
"""
|
|
if self.args.ambient != "auto":
|
|
try:
|
|
return max(0, min(0xFFFFFF, int(self.args.ambient, 0)))
|
|
except ValueError:
|
|
self.warn("--ambient %r is not a number, using auto"
|
|
% self.args.ambient)
|
|
col = [255, 246, 232]
|
|
bright = 200.0
|
|
source = "default daylight"
|
|
for ent in self.bsp.entities():
|
|
if ent.get("classname", "").lower() != "light_environment":
|
|
continue
|
|
a = (ent.get("_ambient") or "").split() or (ent.get("_light") or "").split()
|
|
try:
|
|
vals = [abs(float(v)) for v in a[:4]]
|
|
except ValueError:
|
|
continue
|
|
if len(vals) >= 3 and max(vals[:3]) > 0:
|
|
col = vals[:3]
|
|
bright = vals[3] if len(vals) >= 4 else 200.0
|
|
source = "the map's sky/sun"
|
|
break
|
|
# a daylight map reads well around a third of full brightness
|
|
level = A_AMBIENT_BASE + int(round(A_AMBIENT_SUN * min(bright, 400.0) / 400.0))
|
|
m = max(col) or 1.0
|
|
r = min(255, int(round(level * col[0] / m)))
|
|
g = min(255, int(round(level * col[1] / m)))
|
|
b = min(255, int(round(level * col[2] / m)))
|
|
self.log("ambient light 0x%02x%02x%02x (from %s)" % (r, g, b, source))
|
|
return (r << 16) | (g << 8) | b
|
|
|
|
def build_lights(self):
|
|
"""Source lights as AC LIGHT entities: radius in cubes, then the colour
|
|
(AC's coloured light form, attr2..4 = r-g-b)"""
|
|
ents = []
|
|
ssize, scale = self.ssize, self.scale
|
|
default_reach = max(2, self.args.light_reach)
|
|
for ent in self.bsp.entities():
|
|
cls = ent.get("classname", "").lower()
|
|
if cls == "light_environment":
|
|
continue # the sun became the map's ambient light
|
|
if cls not in ("light", "light_spot"):
|
|
continue
|
|
o = parse_vec(ent.get("origin"))
|
|
if not o:
|
|
continue
|
|
i = int(math.floor((o[0] - self.x0) / scale))
|
|
j = int(math.floor((o[1] - self.y0) / scale))
|
|
if not (0 <= i < ssize and 0 <= j < ssize):
|
|
continue
|
|
idx = j * ssize + i
|
|
if self.state[idx] != 1:
|
|
continue
|
|
zmin = int(round((self.floor_src[idx] - self.z0) / scale + ZOFF))
|
|
zmax = int(round((self.ceil_src[idx] - self.z0) / scale + ZOFF))
|
|
z = int(round((o[2] - self.z0) / scale + ZOFF))
|
|
z = max(zmin, min(z, zmax))
|
|
col = [255, 255, 255]
|
|
vals = []
|
|
for v in (ent.get("_light") or "").split()[:4]:
|
|
try:
|
|
vals.append(abs(float(v)))
|
|
except ValueError:
|
|
pass
|
|
if len(vals) >= 3:
|
|
col = [max(0, min(255, int(round(v)))) for v in vals[:3]]
|
|
if max(col) == 0:
|
|
col = [255, 255, 255]
|
|
reach = 0.0
|
|
for key in ("_distance", "_lightradius", "radius"):
|
|
r = parse_vec(ent.get(key))
|
|
if r and r[0] > 0:
|
|
reach = r[0]
|
|
break
|
|
rad = default_reach if reach <= 0 else int(round(reach / scale))
|
|
rad = max(2, min(255, rad))
|
|
if col[0] == col[1] == col[2]:
|
|
attrs = (col[0], 0, 0) # AC's plain white light form
|
|
else:
|
|
attrs = tuple(col) # coloured light
|
|
ents.append(self.entity(i, j, z, rad, E_LIGHT, attrs[0], attrs[1],
|
|
attrs[2]))
|
|
if ents:
|
|
self.log("%d light entities (%d cubes of reach by default)"
|
|
% (len(ents), default_reach))
|
|
else:
|
|
self.warn("the map has no lights that could be converted, so it is "
|
|
"lit by its ambient light alone (-a/--ambient sets it)")
|
|
return ents
|
|
|
|
def fill_spawns(self, ents, count):
|
|
ssize = self.ssize
|
|
step = max(4, ssize // 12)
|
|
cand = []
|
|
scale = self.scale
|
|
for j in range(MINBORD + 2, ssize - MINBORD - 2, step):
|
|
for i in range(MINBORD + 2, ssize - MINBORD - 2, step):
|
|
idx = j * ssize + i
|
|
if self.state[idx] != 1 or \
|
|
self.ceil_src[idx] - self.floor_src[idx] < 3 * scale:
|
|
continue
|
|
# only flat, open spots, so spawns do not end up on wall tops
|
|
h = self.floor_src[idx]
|
|
for di, dj in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
|
ni, nj = i + di, j + dj
|
|
nidx = nj * ssize + ni
|
|
if not (0 <= ni < ssize and 0 <= nj < ssize) or \
|
|
self.state[nidx] != 1 or \
|
|
abs(self.floor_src[nidx] - h) > scale:
|
|
break
|
|
else:
|
|
cand.append((i, j))
|
|
used = [(e[0], e[1]) for e in ents]
|
|
added = 0
|
|
for (i, j) in cand:
|
|
if added >= count:
|
|
break
|
|
if used and min(max(abs(i - u[0]), abs(j - u[1])) for u in used) < step:
|
|
continue
|
|
idx = j * ssize + i
|
|
z = int(round((self.corner_avg[idx] - self.z0) / self.scale + ZOFF + 4.5))
|
|
ents.append(self.entity(i, j, z, 0, E_PLAYERSTART, 100))
|
|
used.append((i, j))
|
|
added += 1
|
|
return added
|
|
|
|
# -- writer -----------------------------------------------------------
|
|
|
|
def waterlevel(self):
|
|
if not self.water_planes:
|
|
return -10000 * 10
|
|
tops = []
|
|
for flat in self.water_planes:
|
|
for k in range(0, len(flat), 4):
|
|
nz = flat[k + 2]
|
|
if nz > 1e-6:
|
|
tops.append(flat[k + 3] / nz)
|
|
if not tops:
|
|
return -10000 * 10
|
|
top = (max(tops) - self.z0) / self.scale + ZOFF
|
|
return int(round(top * 10))
|
|
|
|
def write_cgz(self, ents):
|
|
args = self.args
|
|
t0 = time.time()
|
|
water = self.waterlevel()
|
|
title = (args.title or ("%s (source conversion)" % args.name))[:127]
|
|
head = struct.pack("<4s4i", b"ACMP", 10, 980, self.sfactor, len(ents))
|
|
head += title.encode("latin-1", "replace").ljust(128, b"\0")[:128]
|
|
head += bytes(range(256)) * 3
|
|
head += struct.pack("<i", water)
|
|
head += bytes((25, 70, 110, 178))
|
|
head += struct.pack("<4i", args.revision, self.ambient, 0, int(time.time()))
|
|
head += b"\0" * 40
|
|
if len(head) != 980:
|
|
raise BspError("internal error: header size %d" % len(head))
|
|
body = bytearray()
|
|
for e in ents:
|
|
x, y, z, a1, typ, a2, a3, a4, a5, a6, a7 = e
|
|
x = max(-32768, min(32767, x))
|
|
y = max(-32768, min(32767, y))
|
|
z = max(-128, min(127, z))
|
|
body += ENTITY.pack(x, y, z, a1, typ, a2, a3, a4, a5, a6, a7)
|
|
raw = head + bytes(body) + bytes(self.cubedata)
|
|
comp = gzip.compress(raw, 9)
|
|
out = args.output or os.path.join(args.outputdir, args.name + ".cgz")
|
|
d = os.path.dirname(out)
|
|
if d and not os.path.isdir(d):
|
|
os.makedirs(d)
|
|
with open(out, "wb") as f:
|
|
f.write(comp)
|
|
self.log("wrote %s: %d entities, %d bytes of geometry, %d bytes file (%.1fs)"
|
|
% (out, len(ents), len(self.cubedata), len(comp), time.time() - t0))
|
|
if len(comp) >= MAXMAPSENDSIZE:
|
|
self.warn("the file is %d bytes and AC only sends maps below %d bytes to "
|
|
"players, so servers can not hand this map out (local and bot "
|
|
"play are fine)" % (len(comp), MAXMAPSENDSIZE))
|
|
if water > -10000 * 10 and water > 0:
|
|
self.log("water level %.1f cubes" % (water / 10.0))
|
|
return out
|
|
|
|
def report(self, path):
|
|
errs = verify(path)
|
|
self.log("self check: %s" % ("ok" if not errs else "; ".join(errs)))
|
|
self.log("cubes: %s"
|
|
% ", ".join("%s %d" % (k, self.stats[k]) for k in CUBE_TYPE_NAMES
|
|
if k in self.stats))
|
|
self.log("ambient light 0x%06x, %d lights"
|
|
% (getattr(self, "ambient", 0), self.stats["lights"]))
|
|
if self.warnings:
|
|
self.log("")
|
|
self.log("%d warning(s):" % len(self.warnings))
|
|
for w in self.warnings:
|
|
self.log(" * %s" % w)
|
|
|
|
|
|
# --------------------------------------------------------------- verifying
|
|
|
|
def verify(path):
|
|
"""independent decode of the written file; returns a list of problems"""
|
|
problems = []
|
|
try:
|
|
raw = gzip.open(path, "rb").read()
|
|
except OSError as e:
|
|
return ["could not read the written file: %s" % e]
|
|
if len(raw) < 980:
|
|
return ["file shorter than a map header"]
|
|
magic, version, headersize, sfactor, numents = struct.unpack_from("<4s4i", raw, 0)
|
|
if magic not in (b"ACMP", b"CUBE"):
|
|
problems.append("bad map magic %r" % magic)
|
|
if version != 10:
|
|
problems.append("bad version %d" % version)
|
|
if not (6 <= sfactor <= 11):
|
|
problems.append("bad sfactor %d" % sfactor)
|
|
if headersize != 980:
|
|
problems.append("unexpected header size %d" % headersize)
|
|
ssize = 1 << sfactor
|
|
waterlevel = struct.unpack_from("<i", raw, 916)[0]
|
|
off = headersize
|
|
for i in range(numents):
|
|
if off + 16 > len(raw):
|
|
problems.append("entity %d is out of bounds" % i)
|
|
break
|
|
x, y, z, _a1 = struct.unpack_from("<4h", raw, off)
|
|
typ = raw[off + 8]
|
|
if typ not in (E_LIGHT, E_PLAYERSTART):
|
|
problems.append("entity %d has type %d" % (i, typ))
|
|
if not (0 <= x < ssize and 0 <= y < ssize):
|
|
problems.append("entity %d is at %d,%d outside the world" % (i, x, y))
|
|
off += 16
|
|
geo = raw[off:]
|
|
n = len(geo)
|
|
p = 0
|
|
i = 0
|
|
prev = None
|
|
counts = defaultdict(int)
|
|
while i < ssize * ssize:
|
|
if p >= n:
|
|
problems.append("geometry ended after %d of %d cubes" % (i, ssize * ssize))
|
|
break
|
|
t = geo[p]
|
|
p += 1
|
|
if t == 255:
|
|
if p >= n:
|
|
problems.append("truncated run")
|
|
break
|
|
cnt = geo[p]
|
|
p += 1
|
|
if prev is None:
|
|
problems.append("run before the first cube")
|
|
break
|
|
counts[prev[0]] += cnt
|
|
i += cnt
|
|
continue
|
|
if t == SOLID:
|
|
if p + 2 > n:
|
|
problems.append("truncated solid cube")
|
|
break
|
|
prev = (SOLID, 0, 16, geo[p], DEFAULT_FLOOR, DEFAULT_CEIL, geo[p + 1],
|
|
geo[p], 0)
|
|
p += 2
|
|
else:
|
|
if t >= 5:
|
|
problems.append("cube %d has type %d" % (i, t))
|
|
break
|
|
if p + 8 > n:
|
|
problems.append("truncated cube")
|
|
break
|
|
f = geo[p]
|
|
ce = geo[p + 1]
|
|
if f > 127:
|
|
f -= 256
|
|
if ce > 127:
|
|
ce -= 256
|
|
if f >= ce:
|
|
problems.append("cube %d has floor %d >= ceil %d" % (i, f, ce))
|
|
break
|
|
prev = (t, f, ce, geo[p + 2], geo[p + 3], geo[p + 4], geo[p + 5],
|
|
geo[p + 6], geo[p + 7])
|
|
p += 8
|
|
counts[prev[0]] += 1
|
|
i += 1
|
|
if i != ssize * ssize:
|
|
problems.append("decoded %d cubes, expected %d" % (i, ssize * ssize))
|
|
if p != n:
|
|
problems.append("%d trailing bytes after the geometry" % (n - p))
|
|
print(" cubes: " + ", ".join("%s %d" % (CUBE_TYPE_NAMES[k], v)
|
|
for k, v in sorted(counts.items())), file=sys.stderr)
|
|
print(" entities: %d, water level %d" % (numents, waterlevel), file=sys.stderr)
|
|
return problems
|
|
|
|
|
|
# --------------------------------------------------------------- main
|
|
|
|
def sanitize(name):
|
|
name = re.sub(r"\.[^.]*$", "", os.path.basename(name))
|
|
name = re.sub(r"[^A-Za-z0-9_-]", "_", name)
|
|
return (name or "converted")[:24]
|
|
|
|
|
|
def main(argv=None):
|
|
ap = argparse.ArgumentParser(
|
|
description="convert a Source engine .bsp map into an AssaultCube .cgz map",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="examples:\n"
|
|
" %(prog)s surf_ski_2.bsp\n"
|
|
" %(prog)s -s 21.33 --space spawns --lights de_dust2.bsp\n")
|
|
ap.add_argument("bsp", help="the Source .bsp file to convert")
|
|
ap.add_argument("-o", "--output", help="output .cgz path (default: "
|
|
"<outputdir>/<name>.cgz)")
|
|
ap.add_argument("--outputdir", default="packages/maps",
|
|
help="where to put the map (default: packages/maps)")
|
|
ap.add_argument("--name", help="map name, also the output file name")
|
|
ap.add_argument("--title", help="map title (max 127 characters)")
|
|
ap.add_argument("-s", "--scale", default="auto",
|
|
help="source units per cube or 'auto' (default). AC maps use "
|
|
"about 10.67 (8 source units); bigger values make the "
|
|
"world coarser but fit taller maps")
|
|
ap.add_argument("--sfactor", type=int, default=0, help="force the world size: "
|
|
"6..11 for 64..2048 cells")
|
|
ap.add_argument("--min-sfactor", type=int, default=6, help=argparse.SUPPRESS)
|
|
ap.add_argument("--max-cells", type=int, default=512,
|
|
help="keep the world at most this many cells wide when picking "
|
|
"a scale automatically (default 512)")
|
|
ap.add_argument("-w", "--whole-map", dest="whole_map", action="store_true",
|
|
help="convert every area in the .bsp, including outlying ones "
|
|
"such as a 3D skybox (default: only the area the player "
|
|
"spawns are in, or the largest one if there are none)")
|
|
ap.add_argument("--space", choices=("playable", "top", "spawns", "largest"),
|
|
default="playable",
|
|
help="which open space above a column survives: 'playable' "
|
|
"(default) is the connected space around the player "
|
|
"spawns, so roofs, the sky and sealed off rooms stay "
|
|
"solid; 'top' keeps the highest space of every column "
|
|
"(what surf and aim maps want), 'largest' the tallest "
|
|
"space ('spawns' is the old name for 'playable')")
|
|
ap.add_argument("--min-span", type=float, default=2.0,
|
|
help="ignore open spaces thinner than this many cubes "
|
|
"(default 2)")
|
|
ap.add_argument("--max-step", type=float, default=2.0,
|
|
help="how high the player can climb between two spaces, in "
|
|
"cubes, when working out what is reachable; dropping "
|
|
"down is always allowed (default 2)")
|
|
ap.add_argument("--entities", choices=("none", "spawns", "all"), default="all",
|
|
help="none: geometry only, spawns: geometry + player spawns, "
|
|
"all (default): also convert the lights")
|
|
ap.add_argument("--lights", dest="entities", action="store_const", const="all",
|
|
help="alias for --entities all (the default)")
|
|
ap.add_argument("-a", "--ambient", default="auto",
|
|
help="the map's base light: 'auto' (default) takes it from the "
|
|
"map's sun/sky, otherwise an 0xRRGGBB or decimal value "
|
|
"(0 keeps AssaultCube's very dark default)")
|
|
ap.add_argument("--light-reach", type=int, default=32,
|
|
help="radius in cubes for lights that have no distance set "
|
|
"(default 32; AssaultCube's own maps use 2 to 20, but "
|
|
"they are much smaller than a converted Source map)")
|
|
ap.add_argument("--no-spawn-fill", dest="spawn_fill", action="store_false",
|
|
help="do not add spawn points if the map has fewer than 5")
|
|
ap.add_argument("--no-smooth", dest="no_smooth", action="store_true",
|
|
help="never use heightfields, always write stair steps")
|
|
ap.add_argument("--actextures", help="path to config/default_map_settings.cfg, "
|
|
"used to map source materials to AC texture "
|
|
"slots")
|
|
ap.add_argument("--revision", type=int, default=1, help=argparse.SUPPRESS)
|
|
args = ap.parse_args(argv)
|
|
|
|
def log(msg, _to=sys.stderr):
|
|
print(msg, file=_to)
|
|
|
|
if not os.path.exists(args.bsp):
|
|
log("error: %s does not exist" % args.bsp)
|
|
return 1
|
|
args.name = sanitize(args.name or args.bsp)
|
|
if not re.match(r"^[A-Za-z0-9_-]+$", args.name):
|
|
log("error: map name %r may only contain letters, digits, - and _" % args.name)
|
|
return 1
|
|
try:
|
|
bsp = Bsp(args.bsp)
|
|
bsp.load_geometry()
|
|
conv = Converter(bsp, args, log)
|
|
conv.textures = AcTextures(find_actextures(args.actextures))
|
|
if conv.textures.names:
|
|
log("%d textures known from config/default_map_settings.cfg"
|
|
% len(conv.textures.names))
|
|
else:
|
|
log("no texture list found, using the default AC textures")
|
|
conv.run()
|
|
except BspError as e:
|
|
log("error: %s" % e)
|
|
return 1
|
|
except KeyboardInterrupt:
|
|
return 130
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|