The 1995 console let the operator type player callsigns; the pods render them from 1bpp BITMAPS streamed in the egg ([largebitmap]/[smallbitmap] lists + hex raster pages, bitmapindex per pilot; large 128x32 on the score/ranking display via DPLRenderer name textures, small 64x16 on the kill-feed and radar/target labels). The textual name= pilot key is dead in the shipped binary -- its pilot parse reads bitmapindex only (decomp part_014). Historical bug surfaced by this work: every egg in the repo listed only ONE name bitmap (Aeolus) while pilot 2 pointed at index 2, so player 2+'s callsign has never rendered (null-checked, silently blank). eggmodel.py: EggDoc.set_callsigns rewrites the lists + one raster page per callsign per size + per-pilot name=/bitmapindex= (duplicate names get disambiguated page tags); get_callsign_rows decodes pages back (exact inverse of the engine's nibble-stream parse, GRAPH2D.cpp:340, MSB-first bit order :558); rasterize_callsign/make_callsigns render text via Qt (auto-shrink to fit, NoAntialias, 1bpp threshold) -- the only PySide6-dependent corner of the module. validate() now catches dangling bitmapindexes and missing raster pages. btoperator.py: Callsign column in the roster (free text, PLAYERn default); every save rasterizes the column into the egg exactly like the original console did. MP_BHMC.EGG template repaired: authentic Aeolus raster preserved bit-exact, Boreas page generated and listed. Verified: eggmodel self-test 20/20 (bit-exact raster round-trip, dup disambiguation, dangling-index catch); ASCII-art decode of the app-generated VIPER/MONGOOSE rasters reads correctly; live 2-node relay session on the generated egg -- full ladder, launch, zero name-bitmap load warnings; operator GUI e2e PASS after the column shift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
618 lines
26 KiB
Python
618 lines
26 KiB
Python
#!/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=<address> lines; each address names a [<address>] 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("<ii", data, 4)
|
|
offsets = struct.unpack_from("<%dI" % max_id, data, 12)
|
|
resources = {}
|
|
for o in offsets:
|
|
if o == 0:
|
|
continue
|
|
rid, rtype = struct.unpack_from("<ii", data, o)
|
|
name = data[o + 8:o + 40].split(b"\0")[0].decode("ascii", "replace")
|
|
prio, flags, roff, rlen = struct.unpack_from("<iIII", data, o + 40)
|
|
resources[rid] = Res(rid, rtype, name, prio, flags, roff, rlen)
|
|
return data, resources
|
|
|
|
|
|
def _parse_table_text(text):
|
|
"""Parse the vehicletable resource: NUL-separated lines (the NotationFile
|
|
wire format), ';' comment lines, '//' trailing comments."""
|
|
sections = {}
|
|
current = None
|
|
for raw in text.replace("\0", "\n").replace("\r", "\n").split("\n"):
|
|
line = raw.split("//")[0].strip()
|
|
if not line or line.startswith(";"):
|
|
continue
|
|
if line.startswith("[") and line.endswith("]"):
|
|
current = line[1:-1].strip().lower()
|
|
sections[current] = collections.OrderedDict()
|
|
elif "=" in line and current is not None:
|
|
key, _, value = line.partition("=")
|
|
sections[current][key.strip()] = value.strip()
|
|
return sections
|
|
|
|
|
|
class ValueSets:
|
|
"""Every legal dropdown value, read live from BTL4.RES."""
|
|
|
|
def __init__(self, res_path=DEFAULT_RES):
|
|
data, resources = read_res(res_path)
|
|
by_type = collections.defaultdict(list)
|
|
for r in resources.values():
|
|
by_type[r.rtype].append(r)
|
|
# maps: need BOTH type 14 and type 26
|
|
maps14 = {r.name for r in by_type[14]}
|
|
maps26 = {r.name for r in by_type[26]}
|
|
self.maps = sorted(maps14 & maps26)
|
|
# vehicletable (type 25)
|
|
self.colors, self.badges, self.patches = [], [], []
|
|
for r in by_type[25]:
|
|
text = data[r.off:r.off + r.length].decode("latin-1", "replace")
|
|
table = _parse_table_text(text)
|
|
self.colors = list(table.get("color", {}).keys())
|
|
self.badges = list(table.get("badge", {}).keys())
|
|
self.patches = list(table.get("patch", {}).keys())
|
|
# vehicles: known-good first, then candidate mechs present in type 1
|
|
type1 = {r.name for r in by_type[1]}
|
|
self.vehicles = [v for v in KNOWN_GOOD_VEHICLES if v in type1]
|
|
self.vehicles += [v for v in CANDIDATE_VEHICLES
|
|
if v in type1 and v not in self.vehicles]
|
|
self.all_models = sorted(type1)
|
|
self.experiences = list(EXPERIENCES)
|
|
self.times = list(TIMES)
|
|
self.weathers = list(WEATHERS)
|
|
self.scenarios = list(SCENARIOS)
|
|
self.dropzones = list(DROPZONES)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Egg document: order-preserving section parse/emit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class EggDoc:
|
|
"""An egg as an ordered list of (section_name, [raw 'key=value' lines]).
|
|
|
|
Lines before the first [section] live under section name None. Everything
|
|
the editor does not manage (bitmap rasters, role pages, ordinals) survives
|
|
byte-identically.
|
|
"""
|
|
|
|
def __init__(self, sections=None):
|
|
self.sections = sections if sections is not None else []
|
|
|
|
# ---- parse / emit ----
|
|
|
|
@classmethod
|
|
def load(cls, path):
|
|
return cls.parse(open(path, "r", encoding="latin-1").read())
|
|
|
|
@classmethod
|
|
def parse(cls, text):
|
|
sections = []
|
|
name, lines = None, []
|
|
for raw in text.replace("\r\n", "\n").split("\n"):
|
|
line = raw.rstrip()
|
|
if line.startswith("[") and line.endswith("]"):
|
|
sections.append((name, lines))
|
|
name, lines = line[1:-1], []
|
|
else:
|
|
if line:
|
|
lines.append(line)
|
|
sections.append((name, lines))
|
|
return cls(sections)
|
|
|
|
def emit(self):
|
|
out = []
|
|
for name, lines in self.sections:
|
|
if name is not None:
|
|
out.append("[%s]" % name)
|
|
out.extend(lines)
|
|
return "\n".join(out) + "\n"
|
|
|
|
def save(self, path):
|
|
with open(path, "w", encoding="latin-1", newline="\n") as f:
|
|
f.write(self.emit())
|
|
|
|
# ---- section access ----
|
|
|
|
def index_of(self, name):
|
|
for i, (n, _) in enumerate(self.sections):
|
|
if n is not None and n.lower() == name.lower():
|
|
return i
|
|
return -1
|
|
|
|
def get_lines(self, name):
|
|
i = self.index_of(name)
|
|
return self.sections[i][1] if i >= 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':..., <pilot keys>...}] 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())
|