#!/usr/bin/env python """eggmodel.py -- headless mission-egg data model for the BT411 operator console. Parses/edits/generates mission eggs with full preservation of the sections the editor does not manage (bitmap rasters, role pages), and derives every legal value set LIVE from content/BTL4.RES so the editor's dropdowns are authoritative: - maps = resource names present as BOTH MakeMessageStream (14) AND ExistanceBoxStream (26) -- the game aborts unless both exist (MISSION.cpp:344-373) - colors/badges/patches = the VehicleTable resource (type 25) [color]/[badge]/ [patch] sections (btl4vid.cpp SetupMaterialSubstitutionList) - vehicles = ModelList (type 1) names, with the known-good mech models surfaced first (egg 'vehicle=' resolves via FindResourceDescription(..., ModelListResourceType)) Egg schema rules encoded here (readers: engine/MUNGA/MISSION.cpp ctor, game/reconstructed/btl4mssn.cpp SetPlayerData): [mission] REQUIRED: map, time, weather, scenario (missing => abort) [pilots] pilot=
lines; each address names a [
] page pilot page REQUIRED: hostType, vehicle, dropzone, color, patch, badge, experience (unless vehicle=camera), role (-> a [role] page w/ model=) badge doubles as the team name. experience in {novice,standard,veteran,expert}. This module is GUI-free and importable (the console app and tests both use it). Self-test: python eggmodel.py [--selftest] """ import collections import os import struct HERE = os.path.dirname(os.path.abspath(__file__)) DEFAULT_RES = os.path.join(HERE, "..", "content", "BTL4.RES") DEFAULT_TEMPLATE = os.path.join(HERE, "..", "content", "MP_BHMC.EGG") # Static (observed / code-enumerated) value sets. EXPERIENCES = ["novice", "standard", "veteran", "expert"] # btl4mssn.cpp:296-321 TIMES = ["night", "day"] # env-table tokens WEATHERS = ["clear", "fog", "soup"] # env-table tokens SCENARIOS = ["freeforall"] # observed HOST_TYPES = {"0": "GameMachine", "1": "CameraShip", "2": "MissionReview"} DROPZONES = ["one"] # only confirmed-valid name (embedded in map streams) # Mech game models: known-good first (verified live), then candidates that ship # skeleton/dead assets but are unverified as player vehicles. KNOWN_GOOD_VEHICLES = ["bhk1", "madcat"] CANDIDATE_VEHICLES = ["ava1", "avatar", "blkhawk", "lok1", "lok2", "loki", "mad1", "mad2", "own1", "owens", "snd1", "sunder", "thor", "thr1", "vul1", "vulture", "camera"] RELAY_TAG_FMT = "10.99.0.{n}:1502" # relay-mode [pilots] identity tags # --------------------------------------------------------------------------- # BTL4.RES reader (the resscan.py parser, importable) # --------------------------------------------------------------------------- Res = collections.namedtuple("Res", "rid rtype name prio flags off length") def read_res(path=DEFAULT_RES): """Parse BTL4.RES -> {rid: Res}.""" data = open(path, "rb").read() _lab_only, max_id = struct.unpack_from("= 0 else None def get_kv(self, section, key): lines = self.get_lines(section) if lines is None: return None for line in lines: k, _, v = line.partition("=") if k.strip().lower() == key.lower(): return v.strip() return None def set_kv(self, section, key, value): i = self.index_of(section) if i < 0: self.sections.append((section, ["%s=%s" % (key, value)])) return name, lines = self.sections[i] for j, line in enumerate(lines): k, _, _v = line.partition("=") if k.strip().lower() == key.lower(): lines[j] = "%s=%s" % (key, value) return lines.append("%s=%s" % (key, value)) def remove_section(self, name): i = self.index_of(name) if i >= 0: del self.sections[i] def replace_section(self, name, lines, after=None): """Replace (or insert after section `after`) a whole section.""" i = self.index_of(name) if i >= 0: self.sections[i] = (name, list(lines)) return pos = len(self.sections) if after is not None: j = self.index_of(after) if j >= 0: pos = j + 1 self.sections.insert(pos, (name, list(lines))) # ---- the managed model ---- MISSION_KEYS = ["adventure", "map", "scenario", "time", "weather", "temperature", "length"] PILOT_KEYS = ["hostType", "advancedDamage", "loadzones", "name", "bitmapindex", "experience", "badge", "patch", "role", "dropzone", "vehicle", "vehicleValue", "color"] def mission(self): return {k: self.get_kv("mission", k) for k in self.MISSION_KEYS} def pilots(self): """[{'address':..., ...}] in [pilots] order.""" lines = self.get_lines("pilots") or [] out = [] for line in lines: k, _, v = line.partition("=") if k.strip().lower() != "pilot": continue address = v.strip() entry = {"address": address} for key in self.PILOT_KEYS: entry[key] = self.get_kv(address, key) out.append(entry) return out def set_mission(self, **kv): for k, v in kv.items(): if v is not None and v != "": self.set_kv("mission", k, v) def set_pilots(self, pilots): """Rewrite [pilots] + every per-pilot page from a pilots() -shaped list. Old pilot pages are removed; page order matches roster order; everything else in the egg is untouched.""" for old in self.pilots(): self.remove_section(old["address"]) self.replace_section( "pilots", ["pilot=%s" % p["address"] for p in pilots]) anchor = "pilots" for p in pilots: lines = [] for key in self.PILOT_KEYS: value = p.get(key) if value is not None and value != "": lines.append("%s=%s" % (key, value)) self.replace_section(p["address"], lines, after=anchor) anchor = p["address"] # ---- callsigns (the console's name-bitmap generator) ---- # # The 1995 pods render player callsigns from 1bpp BITMAPS streamed in the # egg, not from text: [largebitmap]/[smallbitmap] list the page names in # bitmapindex order (1-based, Mission::LoadNameBitmaps MISSION.cpp:147); # each page carries hex raster rows + x/y/width-in-words keys # (BitMap::BitMap GRAPH2D.cpp:340). Bit order is MSB-first: 0x8000 = # leftmost pixel of a 16-bit word (GRAPH2D.cpp:558), so a row is one # straight big-endian bitstring and hex nibbles map to pixels in order. # The textual name= key is console-side bookkeeping only -- the shipped # binary's pilot parse never reads it (decomp part_014 @004d2b60 area). LARGE_W, LARGE_H = 128, 32 # width=8 words SMALL_W, SMALL_H = 64, 16 # width=4 words @staticmethod def _bitmap_page_lines(rows, width_px, height_px): assert len(rows) == height_px, "raster height mismatch" digits = width_px // 4 lines = [] for row in rows: assert 0 <= row < (1 << width_px), "raster row wider than bitmap" lines.append("bitmap=%0*X" % (digits, row)) lines.append("x=%d" % width_px) lines.append("y=%d" % height_px) lines.append("width=%d" % (width_px // 16)) return lines @staticmethod def _page_tag(name, taken): tag = "".join(c if c.isalnum() else "_" for c in name.strip()) or "X" base, n = tag, 2 while tag.lower() in taken: tag = "%s_%d" % (base, n) n += 1 taken.add(tag.lower()) return tag def set_callsigns(self, callsigns): """Rewrite the callsign system from [(name, large_rows, small_rows)] in pilot order: per-pilot name=/bitmapindex=, the [largebitmap]/ [smallbitmap] lists, and one raster page per callsign per size. large_rows = LARGE_H ints of LARGE_W bits (MSB = leftmost pixel); small_rows likewise SMALL_H x SMALL_W.""" pilots = self.pilots() assert len(callsigns) == len(pilots), \ "need one callsign per pilot (%d != %d)" % (len(callsigns), len(pilots)) # strip every old name-bitmap page (template leftovers included) for name, _lines in list(self.sections): if name and (name.lower().startswith("bitmap::large::") or name.lower().startswith("bitmap::small::")): self.remove_section(name) taken = set() tags = [self._page_tag(cs[0], taken) for cs in callsigns] for p, (cs_name, _l, _s), i in zip(pilots, callsigns, range(len(pilots))): self.set_kv(p["address"], "name", cs_name) self.set_kv(p["address"], "bitmapindex", str(i + 1)) anchor = pilots[-1]["address"] if self.index_of("largebitmap") < 0 \ else None self.replace_section( "largebitmap", ["bitmap=BitMap::Large::%s" % t for t in tags], after=anchor) prev = "largebitmap" for tag, (_n, large_rows, _s) in zip(tags, callsigns): page = "BitMap::Large::%s" % tag self.replace_section( page, self._bitmap_page_lines(large_rows, self.LARGE_W, self.LARGE_H), after=prev) prev = page self.replace_section( "smallbitmap", ["bitmap=BitMap::Small::%s" % t for t in tags], after=prev) prev = "smallbitmap" for tag, (_n, _l, small_rows) in zip(tags, callsigns): page = "BitMap::Small::%s" % tag self.replace_section( page, self._bitmap_page_lines(small_rows, self.SMALL_W, self.SMALL_H), after=prev) prev = page def get_callsign_rows(self, page): """Decode a raster page back to row ints (None if absent) -- the exact inverse of the engine's nibble stream parse.""" lines = self.get_lines(page) if lines is None: return None width_px = int(self.get_kv(page, "x") or 0) stream = "".join(line.partition("=")[2].strip() for line in lines if line.partition("=")[0].strip().lower() == "bitmap") rows, digits = [], width_px // 4 for i in range(0, len(stream), digits): chunk = stream[i:i + digits] if len(chunk) == digits: rows.append(int(chunk, 16)) return rows # ---- validation (the crash-prevention layer) ---- def validate(self, values: "ValueSets"): """Return a list of problem strings; empty == launchable.""" problems = [] m = self.mission() for key in ("map", "time", "weather", "scenario"): if not m.get(key): problems.append("[mission] missing required key '%s'" % key) if m.get("map") and m["map"] not in values.maps: problems.append("map '%s' not in BTL4.RES (legal: %s)" % (m["map"], ", ".join(values.maps))) pilots = self.pilots() if not pilots: problems.append("no [pilots] entries") seen = set() for p in pilots: a = p["address"] if a in seen: problems.append("duplicate pilot address '%s'" % a) seen.add(a) if self.index_of(a) < 0: problems.append("pilot '%s' has no [%s] page" % (a, a)) continue is_camera = (p.get("vehicle") == "camera") required = ["hostType", "vehicle", "dropzone", "color", "patch", "badge", "role"] if not is_camera: required.append("experience") for key in required: if not p.get(key): problems.append("pilot '%s' missing required '%s'" % (a, key)) if p.get("color") and p["color"] not in values.colors: problems.append("pilot '%s' color '%s' illegal (legal: %s)" % (a, p["color"], ", ".join(values.colors))) if p.get("badge") and p["badge"] not in values.badges: problems.append("pilot '%s' badge '%s' illegal" % (a, p["badge"])) if p.get("patch") and p["patch"] not in values.patches: problems.append("pilot '%s' patch '%s' illegal" % (a, p["patch"])) if p.get("experience") and p["experience"] not in values.experiences: problems.append("pilot '%s' experience '%s' illegal (legal: %s)" % (a, p["experience"], ", ".join(values.experiences))) role = p.get("role") if role and self.index_of(role) < 0: problems.append("pilot '%s' role page [%s] missing" % (a, role)) elif role and not self.get_kv(role, "model"): problems.append("role page [%s] missing 'model='" % role) # callsign name bitmaps: every pilot's bitmapindex must resolve in # BOTH lists (a miss = blank name in the score/kill-feed displays) large = [l.partition("=")[2].strip() for l in (self.get_lines("largebitmap") or []) if l.partition("=")[0].strip().lower() == "bitmap"] small = [l.partition("=")[2].strip() for l in (self.get_lines("smallbitmap") or []) if l.partition("=")[0].strip().lower() == "bitmap"] for p in pilots: bi = p.get("bitmapindex") if not bi or not bi.isdigit(): continue # optional in the engine (warns) i = int(bi) for count, pages, which in ((len(large), large, "large"), (len(small), small, "small")): if i < 1 or i > count: problems.append( "pilot '%s' bitmapindex %d has no %sbitmap entry " "(%d listed)" % (p["address"], i, which, count)) elif self.index_of(pages[i - 1]) < 0: problems.append("%sbitmap page [%s] missing" % (which, pages[i - 1])) return problems def new_from_template(template_path=DEFAULT_TEMPLATE): """A fresh editable egg seeded from the shipped template (keeps the bitmap rasters + role pages the editor never touches).""" return EggDoc.load(template_path) def relay_tags(count): return [RELAY_TAG_FMT.format(n=i + 1) for i in range(count)] # --------------------------------------------------------------------------- # Callsign rasterizer (the console's job in 1995: type a callsign, it becomes # a bitmap in the egg). Needs PySide6 for font rendering -- the ONLY function # here that does; everything else stays stdlib. # --------------------------------------------------------------------------- _qt_app = None def rasterize_callsign(text, width_px, height_px): """Render `text` to a 1bpp raster: list of height_px ints, width_px bits each, MSB = leftmost pixel (the GRAPH2D.cpp word/bit order). The font is shrunk until the text fits.""" global _qt_app from PySide6.QtCore import Qt, QRect from PySide6.QtGui import (QGuiApplication, QImage, QPainter, QFont, QFontMetrics) if QGuiApplication.instance() is None: _qt_app = QGuiApplication(["eggmodel"]) img = QImage(width_px, height_px, QImage.Format.Format_Grayscale8) img.fill(0) painter = QPainter(img) font = QFont("Arial") font.setBold(True) font.setStyleStrategy(QFont.StyleStrategy.NoAntialias) size = height_px - 4 while size > 5: font.setPixelSize(size) fm = QFontMetrics(font) if (fm.horizontalAdvance(text) <= width_px - 4 and fm.height() <= height_px): break size -= 1 painter.setFont(font) painter.setPen(Qt.GlobalColor.white) painter.drawText(QRect(0, 0, width_px, height_px), Qt.AlignmentFlag.AlignCenter, text) painter.end() rows = [] for y in range(height_px): row = 0 for x in range(width_px): row = (row << 1) | (1 if img.pixelColor(x, y).red() >= 128 else 0) rows.append(row) return rows def make_callsigns(names): """[(name, large_rows, small_rows)] for EggDoc.set_callsigns.""" return [(n, rasterize_callsign(n, EggDoc.LARGE_W, EggDoc.LARGE_H), rasterize_callsign(n, EggDoc.SMALL_W, EggDoc.SMALL_H)) for n in names] # --------------------------------------------------------------------------- # Self-test # --------------------------------------------------------------------------- def _selftest(): ok = True def check(cond, what): nonlocal ok print(" [%s] %s" % ("ok" if cond else "FAIL", what)) ok = ok and cond values = ValueSets() check(values.maps == ["arena1", "arena2", "artrucks", "cavern", "dbase", "grass", "polar3", "polar4", "rav"], "maps from RES: %s" % values.maps) check(values.colors == ["Black", "Brown", "Crimson", "Green", "Grey", "Tan", "White"], "colors: %s" % values.colors) check(len(values.badges) == 7 and "VGL" in values.badges, "badges: %s" % values.badges) check(len(values.patches) == 8 and "Yellow" in values.patches, "patches: %s" % values.patches) check(values.vehicles[:2] == ["bhk1", "madcat"], "vehicles (known-good first): %s" % values.vehicles[:6]) # Round-trip the shipped template: parse -> emit -> parse, semantics equal. doc = EggDoc.load(DEFAULT_TEMPLATE) doc2 = EggDoc.parse(doc.emit()) check(doc.mission() == doc2.mission(), "mission round-trips") check(doc.pilots() == doc2.pilots(), "pilots round-trip") check(len(doc.sections) == len(doc2.sections), "section count preserved (%d)" % len(doc.sections)) check(doc.validate(values) == [], "shipped template validates clean: %s" % doc.validate(values)) # Build a fresh 3-player relay egg and validate. egg = new_from_template() egg.set_mission(map="arena1", time="day", weather="fog") tags = relay_tags(3) base = doc.pilots()[0] pilots = [] for i, tag in enumerate(tags): p = dict(base) p["address"] = tag p["vehicle"] = ["bhk1", "madcat", "bhk1"][i] p["color"] = ["White", "Crimson", "Green"][i] pilots.append(p) egg.set_pilots(pilots) check(egg.validate(values) == [], "generated 3-player relay egg validates: %s" % egg.validate(values)) check(len(egg.pilots()) == 3, "3 pilots emitted") check(egg.get_kv(tags[2], "color") == "Green", "per-pilot page rewritten") # bitmap sections survive check(egg.index_of("largebitmap") >= 0 and egg.index_of("ordinals") >= 0, "bitmap/ordinal sections preserved") # bad values are caught egg.set_kv(tags[0], "color", "Red") check(any("Red" in p for p in egg.validate(values)), "illegal color=Red caught by validation") egg.set_kv(tags[0], "color", "White") # Callsigns: synthetic rasters round-trip through set_callsigns exactly. def synth(w, h, seed): return [((0x9E3779B97F4A7C15 * (seed + y + 1)) & ((1 << w) - 1)) for y in range(h)] names = ["Maverick", "Ice Man", "Maverick"] # dup + space on purpose cs = [(n, synth(EggDoc.LARGE_W, EggDoc.LARGE_H, i * 100), synth(EggDoc.SMALL_W, EggDoc.SMALL_H, i * 100 + 7)) for i, n in enumerate(names)] egg.set_callsigns(cs) egg2 = EggDoc.parse(egg.emit()) check(egg2.validate(values) == [], "egg with callsigns validates: %s" % egg2.validate(values)) large_list = [l.partition("=")[2] for l in egg2.get_lines("largebitmap")] check(len(large_list) == 3 and large_list[0] != large_list[2], "3 large bitmaps listed, duplicate name disambiguated: %s" % large_list) ok_rt = True for i, (n, lg, sm) in enumerate(cs): p = egg2.pilots()[i] ok_rt &= (p["name"] == n and p["bitmapindex"] == str(i + 1)) ok_rt &= (egg2.get_callsign_rows(large_list[i]) == lg) check(ok_rt, "name=/bitmapindex= set + rasters decode back bit-exact") check(egg2.index_of("BitMap::Large::Aeolus") < 0, "stale template name-bitmap pages removed") # a broken index is caught egg2.set_kv(egg2.pilots()[2]["address"], "bitmapindex", "9") check(any("bitmapindex 9" in p for p in egg2.validate(values)), "dangling bitmapindex caught by validation") # The Qt rasterizer produces something non-empty that fits (skipped if # PySide6 is unavailable -- e.g. a bare CI box). try: rows = rasterize_callsign("BOREAS", EggDoc.SMALL_W, EggDoc.SMALL_H) check(len(rows) == EggDoc.SMALL_H and any(rows) and all(r < (1 << EggDoc.SMALL_W) for r in rows), "rasterize_callsign('BOREAS') non-empty, in-bounds") except ImportError: print(" [--] rasterizer test skipped (no PySide6)") print("\n%s" % ("ALL PASS" if ok else "FAILURES PRESENT")) return 0 if ok else 1 if __name__ == "__main__": raise SystemExit(_selftest())