Operator-set callsigns: the console's name-bitmap generator recreated
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f04e8019c2
commit
5e993de99d
+74
-8
@@ -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
|
||||
|
||||
+95
-29
@@ -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
|
||||
|
||||
@@ -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::<X>]` 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).
|
||||
|
||||
+30
-15
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user