Files
2026-10-27 10:52:16 +00:00

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())