diff --git a/content/MP_BHMC.EGG b/content/MP_BHMC.EGG index ff91cb1..52db13e 100644 --- a/content/MP_BHMC.EGG +++ b/content/MP_BHMC.EGG @@ -188,6 +188,7 @@ vehicleValue=1000 color=Crimson [largebitmap] bitmap=BitMap::Large::Aeolus +bitmap=BitMap::Large::Boreas [BitMap::Large::Aeolus] bitmap=00000000000000000000000000000000 bitmap=00000000000000000000000000000000 @@ -224,17 +225,82 @@ bitmap=00000000000000000000000000000000 x=128 y=32 width=8 +[BitMap::Large::Boreas] +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00001FFE000000000000000000000000 +bitmap=00001FFF000000000000000000000000 +bitmap=00001FFF800000000000000000000000 +bitmap=00001FFFC00000000000000000000000 +bitmap=00001E07C00000000000000000000000 +bitmap=00001E03C03E01EF07E00FE00FE00000 +bitmap=00001E03C0FF81FE0FF03FF83FF80000 +bitmap=00001E0781FFC1FE1FF83FFC7FFC0000 +bitmap=00001FFF83E3E1F03C3C787C78300000 +bitmap=00001FFF03C1E1E0781C303C78000000 +bitmap=00001FFF8780F1E0781E007C7E000000 +bitmap=00001FFFC780F1E07FFE07FC3FC00000 +bitmap=00001E03E780F1E07FFE1FBC1FF00000 +bitmap=00001E01E780F1E07FFE3C3C07F80000 +bitmap=00001E01E780F1E07800783C00FC0000 +bitmap=00001E03E3C1E1E07800783C003C0000 +bitmap=00001FFFE3E3E1E03C187C7C183C0000 +bitmap=00001FFFC1FFC1E03FFE7FFC7FF80000 +bitmap=00001FFF80FF81E01FFC3FDC3FF80000 +bitmap=00001FFF003E01E007F01F1E0FE00000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +x=128 +y=32 +width=8 [smallbitmap] bitmap=BitMap::Small::Aeolus +bitmap=BitMap::Small::Boreas [BitMap::Small::Aeolus] -bitmap=00000000000000000000000000000000 -bitmap=0000000000000000003E00000C000000 -bitmap=006300000C00000000631F0F8CC63C00 -bitmap=00633198CCC66200007F3198CCC66000 -bitmap=00633F98CCC6780000633018CCC61E00 -bitmap=00633018CCC6060000633098CCC64600 -bitmap=00631F0F8C7E3C000000000000000000 -bitmap=00000000000000000000000000000000 +bitmap=0000000000000000 +bitmap=0000000000000000 +bitmap=0000000000000000 +bitmap=003E00000C000000 +bitmap=006300000C000000 +bitmap=00631F0F8CC63C00 +bitmap=00633198CCC66200 +bitmap=007F3198CCC66000 +bitmap=00633F98CCC67800 +bitmap=00633018CCC61E00 +bitmap=00633018CCC60600 +bitmap=00633098CCC64600 +bitmap=00631F0F8C7E3C00 +bitmap=0000000000000000 +bitmap=0000000000000000 +bitmap=0000000000000000 +x=64 +y=16 +width=4 +[BitMap::Small::Boreas] +bitmap=0000000000000000 +bitmap=0000000000000000 +bitmap=0000000000000000 +bitmap=001F800000000000 +bitmap=0018C00000000000 +bitmap=0018CF3CF1E3C000 +bitmap=0018D9B19A366000 +bitmap=001F99B198F70000 +bitmap=0018D9B1F9B3C000 +bitmap=0018D9B18330E000 +bitmap=0018D9B19B366000 +bitmap=001F8F30F1F3C000 +bitmap=0000000000000000 +bitmap=0000000000000000 +bitmap=0000000000000000 +bitmap=0000000000000000 x=64 y=16 width=4 diff --git a/content/OPERATOR.EGG b/content/OPERATOR.EGG index dc42936..a6a4e88 100644 --- a/content/OPERATOR.EGG +++ b/content/OPERATOR.EGG @@ -176,7 +176,7 @@ color=White hostType=0 advancedDamage=1 loadzones=1 -name=Aeolus +name=Boreas bitmapindex=2 experience=expert badge=VGL @@ -188,6 +188,7 @@ vehicleValue=1000 color=Crimson [largebitmap] bitmap=BitMap::Large::Aeolus +bitmap=BitMap::Large::Boreas [BitMap::Large::Aeolus] bitmap=00000000000000000000000000000000 bitmap=00000000000000000000000000000000 @@ -195,26 +196,62 @@ bitmap=00000000000000000000000000000000 bitmap=00000000000000000000000000000000 bitmap=00000000000000000000000000000000 bitmap=00000000000000000000000000000000 -bitmap=000003FE0000000001E0000000000000 -bitmap=000007FF0000000001E0000000000000 -bitmap=00000F8F8000000001E0000000000000 -bitmap=00000F078000000001E0000000000000 -bitmap=00000F0781FF00FF81E0F0781FC00000 -bitmap=00000F0783FF81FFC1E0F0783FE00000 -bitmap=00000F0787C7C3E3E1E0F0783C700000 -bitmap=00000F078783C3C1E1E0F07878300000 -bitmap=00000F078783C3C1E1E0F07878000000 -bitmap=00000FFF8783C3C1E1E0F0787C000000 -bitmap=00000FFF8783C3C1E1E0F0787F000000 -bitmap=00000F0787FFC3C1E1E0F0783FC00000 -bitmap=00000F0787FFC3C1E1E0F0781FE00000 -bitmap=00000F07878003C1E1E0F07807F00000 -bitmap=00000F07878003C1E1E0F07801F00000 -bitmap=00000F07878003C1E1E0F07800F00000 -bitmap=00000F078780C3C1E1E0F07860F00000 -bitmap=00000F0787C1C3E3E1E078F871E00000 -bitmap=00000F0783FF81FFC1E07FF83FE00000 -bitmap=00000F0781FF00FF81E03F781FC00000 +bitmap=000000FC0000000000F0000000000000 +bitmap=000000FE0000000000F0000000000000 +bitmap=000000FE0000000000F0000000000000 +bitmap=000001FE0000000000F0000000000000 +bitmap=000001EF0000000000F0000000000000 +bitmap=000001EF003F001F00F0F0781FC00000 +bitmap=000003EF007F807FC0F0F0787FF00000 +bitmap=000003C780FFC0FFE0F0F078FFF80000 +bitmap=000003C781E1E1F1F0F0F078F0600000 +bitmap=000007C783C0E1E0F0F0F078F0000000 +bitmap=00000783C3C0F3C078F0F078FC000000 +bitmap=00000783C3FFF3C078F0F0787F800000 +bitmap=00000FFFC3FFF3C078F0F0783FE00000 +bitmap=00000FFFE3FFF3C078F0F0780FF00000 +bitmap=00000FFFE3C003C078F0F07801F80000 +bitmap=00001F01E3C001E0F0F0F07800780000 +bitmap=00001E00F1E0C1F1F0F0F8F830780000 +bitmap=00001E00F1FFF0FFE0F0FFF8FFF00000 +bitmap=00003E00F0FFE07FC0F07F787FF00000 +bitmap=00003C00783F801F00F03E781FC00000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +x=128 +y=32 +width=8 +[BitMap::Large::Boreas] +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00000000000000000000000000000000 +bitmap=00001FFE000000000000000000000000 +bitmap=00001FFF000000000000000000000000 +bitmap=00001FFF800000000000000000000000 +bitmap=00001FFFC00000000000000000000000 +bitmap=00001E07C00000000000000000000000 +bitmap=00001E03C03E01EF07E00FE00FE00000 +bitmap=00001E03C0FF81FE0FF03FF83FF80000 +bitmap=00001E0781FFC1FE1FF83FFC7FFC0000 +bitmap=00001FFF83E3E1F03C3C787C78300000 +bitmap=00001FFF03C1E1E0781C303C78000000 +bitmap=00001FFF8780F1E0781E007C7E000000 +bitmap=00001FFFC780F1E07FFE07FC3FC00000 +bitmap=00001E03E780F1E07FFE1FBC1FF00000 +bitmap=00001E01E780F1E07FFE3C3C07F80000 +bitmap=00001E01E780F1E07800783C00FC0000 +bitmap=00001E03E3C1E1E07800783C003C0000 +bitmap=00001FFFE3E3E1E03C187C7C183C0000 +bitmap=00001FFFC1FFC1E03FFE7FFC7FF80000 +bitmap=00001FFF80FF81E01FFC3FDC3FF80000 +bitmap=00001FFF003E01E007F01F1E0FE00000 bitmap=00000000000000000000000000000000 bitmap=00000000000000000000000000000000 bitmap=00000000000000000000000000000000 @@ -226,15 +263,44 @@ y=32 width=8 [smallbitmap] bitmap=BitMap::Small::Aeolus +bitmap=BitMap::Small::Boreas [BitMap::Small::Aeolus] -bitmap=00000000000000000000000000000000 -bitmap=0000000000000000003E00000C000000 -bitmap=006300000C00000000631F0F8CC63C00 -bitmap=00633198CCC66200007F3198CCC66000 -bitmap=00633F98CCC6780000633018CCC61E00 -bitmap=00633018CCC6060000633098CCC64600 -bitmap=00631F0F8C7E3C000000000000000000 -bitmap=00000000000000000000000000000000 +bitmap=0000000000000000 +bitmap=0000000000000000 +bitmap=0000000000000000 +bitmap=0003800030000000 +bitmap=0003800030000000 +bitmap=0006C78F36678000 +bitmap=0006CCD9B66CC000 +bitmap=0006CCD9B66E0000 +bitmap=0006CFD9B6678000 +bitmap=000FEC19B661C000 +bitmap=000C6CD9B6ECC000 +bitmap=000C678F33678000 +bitmap=0000000000000000 +bitmap=0000000000000000 +bitmap=0000000000000000 +bitmap=0000000000000000 +x=64 +y=16 +width=4 +[BitMap::Small::Boreas] +bitmap=0000000000000000 +bitmap=0000000000000000 +bitmap=0000000000000000 +bitmap=001F800000000000 +bitmap=0018C00000000000 +bitmap=0018CF3CF1E3C000 +bitmap=0018D9B19A366000 +bitmap=001F99B198F70000 +bitmap=0018D9B1F9B3C000 +bitmap=0018D9B18330E000 +bitmap=0018D9B19B366000 +bitmap=001F8F30F1F3C000 +bitmap=0000000000000000 +bitmap=0000000000000000 +bitmap=0000000000000000 +bitmap=0000000000000000 x=64 y=16 width=4 diff --git a/context/multiplayer.md b/context/multiplayer.md index 7941a04..f3101fd 100644 --- a/context/multiplayer.md +++ b/context/multiplayer.md @@ -335,6 +335,25 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`. gets the SAME file, the relay seats them (`players/`). Wire logic stays in btconsole.py/eggmodel.py (GUI = UI + QProcess only). E2E-verified: app-driven relay session → 2 local pods → LAUNCH observed. +- **OPERATOR-SET CALLSIGNS (2026-07-18) [T2]**: player callsigns are 1bpp BITMAPS in the egg, + not text — `[largebitmap]`/`[smallbitmap]` list page names in `bitmapindex` order (1-based; + `Mission::LoadNameBitmaps` MISSION.cpp:147); each `[BitMap::Large::]` page = hex raster + rows + `x/y/width`-in-words keys, large 128×32 (8 words), small 64×16 (4), MSB-first + (0x8000 = leftmost pixel; parse GRAPH2D.cpp:340, bit order :558). Large renders on the + score/ranking display (D3D textures, `DPLRenderer::LoadNameBitmaps` / + `SortAndReloadNameBitmaps` L4VIDEO.cpp:9420/9512, `mNameTextures[MAX_PLAYER_NAMES=12]`); + small = kill/damage-feed + radar/target labels (btplayer.cpp:1486, btl4rdr.cpp:1156). The + textual `name=` pilot key is DEAD in the binary — the pilot-page parse (decomp part_014, + BTL4MSSN) reads `bitmapindex` only [T1]. Historical bug found: every repo egg listed only ONE + name bitmap (Aeolus) while pilot 2 pointed at index 2 → player 2+'s callsign never rendered + (null-checked, silently blank). Now: the operator console has a Callsign roster column; + on save `eggmodel.make_callsigns` rasterizes the text via Qt (auto-shrink to fit, NoAntialias, + threshold to 1bpp) and `EggDoc.set_callsigns` writes the pages + lists + per-pilot + `name=`/`bitmapindex=` (duplicate names get disambiguated page tags); `get_callsign_rows` + decodes back (bit-exact round-trip in the self-test); `validate()` catches dangling + bitmapindexes. MP_BHMC.EGG template fixed (authentic Aeolus raster preserved bit-exact, + Boreas generated). Verified: eggmodel self-test 20/20; ASCII-art decode of app-generated + VIPER/MONGOOSE rasters; live 2-node session on the generated egg. - **STILL UNTESTED (config, not code)**: a real two-physical-machine / internet run (only the relay's console+game ports need to be reachable inbound — one forwarded port total; or a playit.gg-style tunnel in front of the relay, host-side only). diff --git a/tools/btoperator.py b/tools/btoperator.py index af14c40..1f00716 100644 --- a/tools/btoperator.py +++ b/tools/btoperator.py @@ -141,8 +141,8 @@ class SessionMonitor: # --------------------------------------------------------------------------- class Operator(QMainWindow): - COLS = ["Identity / Address", "Vehicle", "Color", "Badge", "Patch", - "Experience", "Local"] + COLS = ["Identity / Address", "Callsign", "Vehicle", "Color", "Badge", + "Patch", "Experience", "Local"] def __init__(self): super().__init__() @@ -345,15 +345,19 @@ class Operator(QMainWindow): if self.mode.currentIndex() == 0: # relay tags are auto-managed addr.setFlags(addr.flags() & ~Qt.ItemIsEditable) self.table.setItem(r, 0, addr) - self.table.setCellWidget(r, 1, self._combo( - self.values.vehicles, pilot.get("vehicle") or "bhk1")) + # callsign: free text, becomes the name BITMAPS in the egg on save + # (the 1995 console's job -- see eggmodel.set_callsigns) + self.table.setItem(r, 1, QTableWidgetItem( + pilot.get("name") or "PLAYER%d" % (r + 1))) self.table.setCellWidget(r, 2, self._combo( - self.values.colors, pilot.get("color") or "White")) + self.values.vehicles, pilot.get("vehicle") or "bhk1")) self.table.setCellWidget(r, 3, self._combo( - self.values.badges, pilot.get("badge") or "VGL")) + self.values.colors, pilot.get("color") or "White")) self.table.setCellWidget(r, 4, self._combo( - self.values.patches, pilot.get("patch") or "Yellow")) + self.values.badges, pilot.get("badge") or "VGL")) self.table.setCellWidget(r, 5, self._combo( + self.values.patches, pilot.get("patch") or "Yellow")) + self.table.setCellWidget(r, 6, self._combo( self.values.experiences, pilot.get("experience") or "expert")) chk = QCheckBox() chk.setChecked(local) @@ -362,22 +366,28 @@ class Operator(QMainWindow): wl.setContentsMargins(0, 0, 0, 0) wl.setAlignment(Qt.AlignCenter) wl.addWidget(chk) - self.table.setCellWidget(r, 6, wrap) + self.table.setCellWidget(r, 7, wrap) def _row_pilot(self, r, template): """Read one roster row back into a pilot dict.""" p = dict(template) p["address"] = self.table.item(r, 0).text().strip() - p["vehicle"] = self.table.cellWidget(r, 1).currentText() - p["color"] = self.table.cellWidget(r, 2).currentText() - p["badge"] = self.table.cellWidget(r, 3).currentText() - p["patch"] = self.table.cellWidget(r, 4).currentText() - p["experience"] = self.table.cellWidget(r, 5).currentText() - p["bitmapindex"] = str(min(r + 1, 8)) + p["name"] = self._row_callsign(r) + p["vehicle"] = self.table.cellWidget(r, 2).currentText() + p["color"] = self.table.cellWidget(r, 3).currentText() + p["badge"] = self.table.cellWidget(r, 4).currentText() + p["patch"] = self.table.cellWidget(r, 5).currentText() + p["experience"] = self.table.cellWidget(r, 6).currentText() + p["bitmapindex"] = str(r + 1) return p + def _row_callsign(self, r): + item = self.table.item(r, 1) + text = item.text().strip() if item else "" + return text or "PLAYER%d" % (r + 1) + def _row_local(self, r): - wrap = self.table.cellWidget(r, 6) + wrap = self.table.cellWidget(r, 7) return wrap.findChild(QCheckBox).isChecked() if wrap else False def _pilot_template(self): @@ -401,6 +411,7 @@ class Operator(QMainWindow): address = "192.168.1.%d:1502" % (99 + n) p = self._pilot_template() p["address"] = address + p.pop("name", None) # new row gets the PLAYERn default callsign self._add_pilot_row(p, local=(self.mode.currentIndex() == 0)) self._renumber_relay_tags() @@ -458,6 +469,10 @@ class Operator(QMainWindow): pilots = [self._row_pilot(r, template) for r in range(self.table.rowCount())] self.egg.set_pilots(pilots) + # rasterize the callsigns into the egg's name-bitmap pages (what the + # 1995 console did when the operator typed a callsign) + self.egg.set_callsigns(eggmodel.make_callsigns( + [self._row_callsign(r) for r in range(self.table.rowCount())])) return self.egg.validate(self.values) def _egg_new(self): diff --git a/tools/eggmodel.py b/tools/eggmodel.py index 2759c7b..08cc9f8 100644 --- a/tools/eggmodel.py +++ b/tools/eggmodel.py @@ -274,6 +274,104 @@ class EggDoc: 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"): @@ -321,6 +419,28 @@ class EggDoc: 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 @@ -334,6 +454,61 @@ 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 # --------------------------------------------------------------------------- @@ -393,6 +568,46 @@ def _selftest(): 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