#!/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())