#!/usr/bin/env python3 """ gamemap.py -- reader for the game-side ".MAP" arena/track format and its ".MOD" game-object definitions (BTDAVE / BTLIVE / BTRAVINE / RPDAVE / RPLIVE MAPS dirs). A .MAP is INI-style text: [hall2] ; declare an asset section type=model modelfile=hall2.mod [one] type=dropzone ; pod spawn ring dropzone= x y z rx ry rz ; rotations in RADIANS [arena1] ; the map itself type=map instance=hall2 x y z rx ry rz instancedropzone=one x y z rx ry rz [inc] type=include ; pull in another .MAP's sections file=other.map A .MOD is the game object: `[video] object=X.bgf` (render geometry, DIV-BIZ2), `[collision] name=X.sld`, `[gamedata]` physics/damage/animations. We only need the video geometry. expand(path) resolves everything to a flat list of world-space placements: [(bgf_name, matrix4x4), ...], plus dropzone positions. Maps may instance other maps; expansion is recursive with matrix composition (row-vector convention, as everywhere in DPL -- see SCN_FORMAT.md). """ import math import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import scn def _parse_ini(path): """-> ordered {section: {'type':..,'modelfile':..,'file':.., 'instances':[(name,[6f])], 'dropzones':[[6f]], 'instancedropzones':[(name,[6f])]}}""" sections = {} cur = None try: lines = open(path, encoding="latin-1").read().splitlines() except OSError: return sections for raw in lines: line = raw.split("//")[0].strip() if not line: continue if line.startswith("[") and line.endswith("]"): name = line[1:-1].strip().lower() cur = sections.setdefault(name, {"type": None, "instances": [], "dropzones": [], "instancedropzones": []}) continue if cur is None or "=" not in line: continue key, _, val = line.partition("=") key = key.strip().lower() val = val.strip() if key == "type": cur["type"] = val.lower() elif key == "instance": p = val.split() cur["instances"].append((p[0].lower(), [float(x) for x in p[1:7]])) elif key == "instancedropzone": p = val.split() cur["instancedropzones"].append((p[0].lower(), [float(x) for x in p[1:7]])) elif key == "dropzone": cur["dropzones"].append([float(x) for x in val.split()[:6]]) else: cur[key] = val.lower() # generic key (modelfile, MotionExtent, ...) return sections def load_sections(path, _seen=None): """Parse a .MAP plus its type=include files (same directory).""" _seen = _seen or set() key = os.path.normcase(os.path.abspath(path)) if key in _seen: return {} _seen.add(key) sections = _parse_ini(path) merged = dict(sections) for name, sec in sections.items(): if sec.get("type") == "include" and sec.get("file"): inc = os.path.join(os.path.dirname(path), sec["file"]) for n, s in load_sections(inc, _seen).items(): merged.setdefault(n, s) return merged def _matrix(vals): """instance 6-tuple: x y z rx ry rz (RADIANS) -> row-vector matrix, same composition as the SCN STATIC transform (unit scale).""" x, y, z, rx, ry, rz = (vals + [0.0] * 6)[:6] return scn.instance_matrix(1.0, x, y, z, math.degrees(rx), math.degrees(ry), math.degrees(rz)) def pick_root(sections, path): """The map section to expand: prefer one named like the file, else the map-typed section with the most instances.""" stem = os.path.splitext(os.path.basename(path))[0].lower() maps = {n: s for n, s in sections.items() if s.get("type") in ("map",)} if stem in maps: return stem if maps: return max(maps, key=lambda n: len(maps[n]["instances"])) return None def expand(path, mod_resolver, max_depth=6): """Flatten a .MAP to world space. mod_resolver(modfile_basename) -> bgf_name or None (looks up the .MOD's [video] object=). Returns (placements, dropzones): placements = [(bgf_name, matrix)] dropzones = [[x,y,z,rx,ry,rz]] (world space)""" sections = load_sections(path) root = pick_root(sections, path) placements, dropzones = [], [] if root is None: return placements, dropzones def emit(sec_name, parent_mtx, depth): sec = sections.get(sec_name) if sec is None or depth > max_depth: return t = sec.get("type") if t == "model": # mod_resolver -> list of (bgf_name, anim_or_None); a MOD may carry # several video objects (door frame + sliding leaves, laser drill...) for item in (mod_resolver(sec.get("modelfile", "")) or []): placements.append((item, parent_mtx)) elif t == "map": for name, vals in sec["instances"]: emit(name, scn.matmul(_matrix(vals), parent_mtx), depth + 1) for name, vals in sec["instancedropzones"]: dz = sections.get(name) if dz: m = scn.matmul(_matrix(vals), parent_mtx) for d in dz["dropzones"]: wx, wy, wz = scn.xform_point(m, d[0], d[1], d[2]) dropzones.append([wx, wy, wz, d[3], d[4], d[5]]) elif t == "dropzone": for d in sec["dropzones"]: wx, wy, wz = scn.xform_point(parent_mtx, d[0], d[1], d[2]) dropzones.append([wx, wy, wz, d[3], d[4], d[5]]) emit(root, scn.ident(), 0) return placements, dropzones def parse_mod_objects(path): """.MOD -> list of [video] object= geometry basenames (no extension). A MOD may carry several objects ("number and order is significant!"), e.g. doors: frame + leaves (+ low-poly LOD twins, which we drop: any name equal to an earlier name + 'l'). Also returns the [gamedata] class and Subsystems file. -> (objects, gameclass, subsystems_file)""" sec = _parse_ini(path) vid = sec.get("video") or {} raw = vid.get("object") or vid.get("name") or "" if not raw: for s in sec.values(): if s.get("object"): raw = s["object"] break names = [] for tok in raw.split(): base = os.path.splitext(os.path.basename(tok))[0].lower() if not tok.lower().endswith((".bgf", ".b2z", ".biz")): continue # skip node-name tokens if base[:-1] in names and base.endswith("l"): continue # low-poly LOD twin of an earlier object names.append(base) game = sec.get("gamedata") or {} return names, (game.get("class") or "").lower(), game.get("subsystems") def parse_mod(path): """Back-compat: the first video object basename, or None.""" names, _, _ = parse_mod_objects(path) return names[0] if names else None def parse_sub(path): """.SUB subsystems -> {collision_stem: {'extent':[x,y,z], 'travel':s, 'dead':s}} for DoorClassID sections. The collision stem (dr1_cv -> dr1) links a subsystem to its video object by name.""" out = {} for name, sec in _parse_ini(path).items(): if "motionextent" not in sec: continue ext = [float(x) for x in sec["motionextent"].split()[:3]] coll = sec.get("collision", name) stem = os.path.splitext(os.path.basename(coll))[0].lower() if stem.endswith("_cv"): stem = stem[:-3] out[stem] = {"extent": ext, "travel": float(sec.get("traveltime", 10.0)), "dead": float(sec.get("deadtime", 3.0))} return out if __name__ == "__main__": path = sys.argv[1] secs = load_sections(path) types = {} for n, s in secs.items(): types.setdefault(s.get("type"), []).append(n) print("root:", pick_root(secs, path)) for t, names in types.items(): print(" %-10s x%-3d %s" % (t, len(names), names[:8]))