#!/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(" 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(" limit or hi - lo < 1e-6: continue planenum = struct.unpack_from("= nplanes: continue nx, ny, nz, d = planes[planenum][:4] child0, child1 = struct.unpack_from(" 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("= nplanes: return 0 nx, ny, nz, d = planes[planenum][:4] child0, child1 = struct.unpack_from(" 0 else child1 o = (-node - 1) * 32 if o + 4 > len(leafs): return 0 return struct.unpack_from(" 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("= 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("= 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(" 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: " "/.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())