//########################################################################### // btl4fe -- the miniconsole front end (BT_GLASS only; compiled only when // the gate is on -- see CMakeLists.txt). Design: btl4fe.hpp. //########################################################################### #include "btl4fe.hpp" #ifdef BT_STEAM #include "btl4lobby.hpp" #endif #include #include #include #include //########################################################################### // Catalogs -- the verified content values (context/content-archives.md map // sweep; vehicle/color values from the shipped eggs). The egg writer // accepts anything; these are the menu's cycle sets. //########################################################################### //########################################################################### // The 4 static rank-place ordinal bitmaps -- byte-identical to the console // eggs (content/MP.EGG [ordinals] block: "1st" "2nd" "3rd" "4th" as 128x32 // plasma pages). //########################################################################### static const char *kOrdinalBlock = "[ordinals]\n" "bitmap=Ordinal::BitMap::1\n" "bitmap=Ordinal::BitMap::2\n" "bitmap=Ordinal::BitMap::3\n" "bitmap=Ordinal::BitMap::4\n" "[Ordinal::BitMap::1]\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=000038000003C000001FF00000000F00\n" "bitmap=0000F8000003C000003FF80000000F00\n" "bitmap=0001F8000003C00000707C0000000F00\n" "bitmap=0001F8000003C00000603C0000000F00\n" "bitmap=00007801FC0FF00000003C3DF807FF00\n" "bitmap=00007803FE0FF00000003C3FFC0FFF00\n" "bitmap=00007803C703C00000003C3E3C1F0F00\n" "bitmap=000078078303C0000000783C1E1E0F00\n" "bitmap=000078078003C0000000783C1E1E0F00\n" "bitmap=00007807C003C0000000F03C1E1E0F00\n" "bitmap=00007807F003C0000001E03C1E1E0F00\n" "bitmap=00007803FC03C0000003C03C1E1E0F00\n" "bitmap=00007801FE03C0000007803C1E1E0F00\n" "bitmap=000078007F03C000000F003C1E1E0F00\n" "bitmap=000078001F03C000001E003C1E1E0F00\n" "bitmap=000078000F03C000003C003C1E1E0F00\n" "bitmap=000078060F03C0000078003C1E1E0F00\n" "bitmap=000078071E03E0000078003C1E1F1F00\n" "bitmap=00007803FE01F000007FFC3C1E0FFF00\n" "bitmap=00007801FC00F000007FFC3C1E07EF00\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "x=128\n" "y=32\n" "width=8\n" "[Ordinal::BitMap::2]\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=001FFF0000003C0000003C03C0780000\n" "bitmap=001FFF0000003C0000007C03C0780000\n" "bitmap=00000E0000003C000000FC03C0780000\n" "bitmap=00003C0000003C000001BC03C0780000\n" "bitmap=0000700F3E1FFC000003BC0FF07BF000\n" "bitmap=0001E00F7E3FFC0000073C0FF07FF800\n" "bitmap=0003800FFE7C3C0000063C03C07C7800\n" "bitmap=0007F80FFE783C00000C3C03C0783C00\n" "bitmap=0007FE0F80783C0000183C03C0783C00\n" "bitmap=00001E0F00783C0000383C03C0783C00\n" "bitmap=00000F0F00783C0000703C03C0783C00\n" "bitmap=00000F0F00783C00007FFF03C0783C00\n" "bitmap=00000F0F00783C00007FFF03C0783C00\n" "bitmap=00000F0F00783C0000003C03C0783C00\n" "bitmap=00000F0F00783C0000003C03C0783C00\n" "bitmap=00000F0F00783C0000003C03C0783C00\n" "bitmap=00180F0F00783C0000003C03C0783C00\n" "bitmap=001C1F0F007C7C0000003C03E0783C00\n" "bitmap=000FFE0F003FFC0000003C01F0783C00\n" "bitmap=0007FC0F001FBC0000003C00F0783C00\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "x=128\n" "y=32\n" "width=8\n" "[Ordinal::BitMap::3]\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=001FFF03C07800000000FC03C0780000\n" "bitmap=001FFF03C07800000003FC03C0780000\n" "bitmap=001E0003C07800000007C003C0780000\n" "bitmap=001E0003C0780000000F0003C0780000\n" "bitmap=001E000FF07BF000000F000FF07BF000\n" "bitmap=001E000FF07FF800001E000FF07FF800\n" "bitmap=001E0003C07C7800001E0003C07C7800\n" "bitmap=001FFC03C0783C00001EFC03C0783C00\n" "bitmap=001FFE03C0783C00001FFE03C0783C00\n" "bitmap=00001F03C0783C00001F1F03C0783C00\n" "bitmap=00000F03C0783C00001E0F03C0783C00\n" "bitmap=00000F03C0783C00001E0F03C0783C00\n" "bitmap=00000F03C0783C00001E0F03C0783C00\n" "bitmap=00000F03C0783C00001E0F03C0783C00\n" "bitmap=00000F03C0783C00001E0F03C0783C00\n" "bitmap=00000F03C0783C00001E0F03C0783C00\n" "bitmap=00180F03C0783C00001E0F03C0783C00\n" "bitmap=001C1F03E0783C00001F1F03E0783C00\n" "bitmap=000FFE01F0783C00000FFE01F0783C00\n" "bitmap=0007FC00F0783C000007FC00F0783C00\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "x=128\n" "y=32\n" "width=8\n" "[Ordinal::BitMap::4]\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=001FFF03C07800000007FC03C0780000\n" "bitmap=001FFF03C0780000000FFE03C0780000\n" "bitmap=00000F03C0780000001F1F03C0780000\n" "bitmap=00000F03C0780000001E0F03C0780000\n" "bitmap=00000F0FF07BF000001E0F0FF07BF000\n" "bitmap=00001F0FF07FF800001E0F0FF07FF800\n" "bitmap=00001E03C07C7800001E0F03C07C7800\n" "bitmap=00003E03C0783C00001E0F03C0783C00\n" "bitmap=00003C03C0783C00000F1E03C0783C00\n" "bitmap=00003C03C0783C000007FC03C0783C00\n" "bitmap=00007803C0783C000007FC03C0783C00\n" "bitmap=00007803C0783C00000F1E03C0783C00\n" "bitmap=00007803C0783C00001E0F03C0783C00\n" "bitmap=0000F003C0783C00001E0F03C0783C00\n" "bitmap=0000F003C0783C00001E0F03C0783C00\n" "bitmap=0000F003C0783C00001E0F03C0783C00\n" "bitmap=0000F003C0783C00001E0F03C0783C00\n" "bitmap=0000F003E0783C00000F1E03E0783C00\n" "bitmap=0000F001F0783C00000FFE01F0783C00\n" "bitmap=0000F000F0783C000007FC00F0783C00\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "bitmap=00000000000000000000000000000000\n" "x=128\n" "y=32\n" "width=8\n"; //########################################################################### // GDI-rendered pilot-name plasma bitmaps. // // The console rendered each pilot's name into 128x32 (large) and 64x16 // (small) 1bpp plasma pages. We render with GDI into a mono bitmap and // hex-encode: `bitmap=` lines carry 16 BYTES each (32 hex chars) -- one // line per row for the large page (16-byte rows), TWO 8-byte rows per // line for the small page -- byte-identical framing to MP.EGG. //########################################################################### static int RenderNameBits(const char *name, int width, int height, int font_height, unsigned char *bits /* width/8*height bytes */) { int stride = width / 8; // mono GDI stride for 64/128 px HDC screen = GetDC(NULL); HDC dc = CreateCompatibleDC(screen); ReleaseDC(NULL, screen); if (dc == NULL) { return -1; } HBITMAP bitmap = CreateBitmap(width, height, 1, 1, NULL); HBITMAP old_bitmap = (HBITMAP)SelectObject(dc, bitmap); HFONT font = CreateFontW(-font_height, 0, 0, 0, FW_BOLD, 0, 0, 0, DEFAULT_CHARSET, 0, 0, NONANTIALIASED_QUALITY, 0, L"Arial"); HFONT old_font = (HFONT)SelectObject(dc, font); RECT r = { 0, 0, width, height }; SetBkColor(dc, RGB(0, 0, 0)); SetTextColor(dc, RGB(255, 255, 255)); HBRUSH black = (HBRUSH)GetStockObject(BLACK_BRUSH); FillRect(dc, &r, black); WCHAR wide[64]; int n = 0; for (const char *s = name; *s && n < 63; ++s) { wide[n++] = (WCHAR)*s; } wide[n] = 0; DrawTextW(dc, wide, -1, &r, DT_CENTER | DT_VCENTER | DT_SINGLELINE); GdiFlush(); int got = GetBitmapBits(bitmap, stride * height, bits); SelectObject(dc, old_font); DeleteObject(font); SelectObject(dc, old_bitmap); DeleteObject(bitmap); DeleteDC(dc); return (got == stride * height) ? 0 : -1; } static void WriteBitmapPage(FILE *f, const char *page_name, const char *pilot_name, int width, int height, int font_height) { unsigned char bits[16 * 32]; // worst case 128x32 memset(bits, 0, sizeof(bits)); RenderNameBits(pilot_name, width, height, font_height, bits); fprintf(f, "[%s]\n", page_name); int total = (width / 8) * height; for (int off = 0; off < total; off += 16) { fprintf(f, "bitmap="); for (int b = 0; b < 16; ++b) { fprintf(f, "%02X", bits[off + b]); } fprintf(f, "\n"); } fprintf(f, "x=%d\ny=%d\nwidth=%d\n", width, height, width / 16); } //########################################################################### // The egg writer //########################################################################### int BTFeMission_WriteEgg(const BTFeMission *mission, const char *path) { FILE *f = fopen(path, "wt"); if (f == NULL) { return -1; } fprintf(f, "[mission]\n" "adventure=BattleTech\n" "map=%s\n" "scenario=freeforall\n" "time=%s\n" "weather=%s\n" "temperature=%d\n" "length=%d\n", mission->map, mission->time, mission->weather, mission->temperature, mission->lengthSeconds); fputs(kOrdinalBlock, f); fprintf(f, "[pilots]\n"); for (int p = 0; p < mission->pilotCount; ++p) { fprintf(f, "pilot=%s\n", mission->pilots[p].address); } for (int p = 0; p < mission->pilotCount; ++p) { const BTFePilot &pilot = mission->pilots[p]; fprintf(f, "[%s]\n" "hostType=0\n" "advancedDamage=%d\n" "loadzones=1\n" "name=%s\n" "bitmapindex=%d\n" "experience=%s\n" "badge=%s\n" "patch=%s\n" "role=Role::%s\n" "dropzone=%s\n" "vehicle=%s\n" "vehicleValue=1000\n" "color=%s\n", pilot.address, pilot.advancedDamage ? 1 : 0, pilot.name, p + 1, pilot.experience[0] ? pilot.experience : "veteran", pilot.badge[0] ? pilot.badge : "VGL", pilot.patch[0] ? pilot.patch : "Red", mission->roleKey[0] ? mission->roleKey : "Default", pilot.dropzone[0] ? pilot.dropzone : "one", pilot.vehicle, pilot.color[0] ? pilot.color : "Grey"); } // // Name plasma pages. The shipped eggs carry ONE large/small pair (the // console personalized per pod); list every pilot's pair -- the engine // resolves by name. // fprintf(f, "[largebitmap]\n"); for (int p = 0; p < mission->pilotCount; ++p) { fprintf(f, "bitmap=BitMap::Large::%s\n", mission->pilots[p].name); } for (int p = 0; p < mission->pilotCount; ++p) { char page[64]; sprintf(page, "BitMap::Large::%s", mission->pilots[p].name); WriteBitmapPage(f, page, mission->pilots[p].name, 128, 32, 24); } fprintf(f, "[smallbitmap]\n"); for (int p = 0; p < mission->pilotCount; ++p) { fprintf(f, "bitmap=BitMap::Small::%s\n", mission->pilots[p].name); } for (int p = 0; p < mission->pilotCount; ++p) { char page[64]; sprintf(page, "BitMap::Small::%s", mission->pilots[p].name); WriteBitmapPage(f, page, mission->pilots[p].name, 64, 16, 12); } fprintf(f, "[Role::Default]\n" "model=dfltrole\n" "[Role::NoReturn]\n" "model=noretun\n"); fclose(f); return 0; } //########################################################################### // The menu -- the RP412 front-end pattern (RP_L4/RPL4FE.cpp, per Cyd): // full catalogs as always-visible clickable LISTS in three columns with // friendly display names -- bright green = selected, dim green = the rest -- // a real EDIT control for the pilot name (and one for LAN peers), framed // LAUNCH / HOST STEAM / JOIN STEAM buttons, double-buffered paint, and the // player's loadout persisted across runs (fe_last.ini beside the eggs; our // per-mission process relaunch would otherwise forget it). // ENTER = launch, ESC = quit. //########################################################################### struct CatalogEntry { const char *key; const char *name; }; // ALL option catalogs are the AUTHENTIC BattleTech mission options recovered // from the Mac 4.10 operator console's BT:: adventure tree // (C:\VWE\TeslaSuite\410console\4_10-console-extracted\Console.ini); the `key` // is the egg field value (the console's `tag=`), the `name` is the display // label. Full catalog + provenance: docs/GLASS_COCKPIT.md 2026-07-19. static const CatalogEntry kMaps[] = { { "cavern", "Cavern" }, { "grass", "Savannah" }, { "rav", "Ravines" }, { "polar3", "Polar Missile Base" }, { "polar4", "Helicopter Base" }, { "arena1", "Abandoned Arena" }, { "arena2", "Destroyed Arena" }, { "dbase", "Badlands" }, }; // SCENARIO / role: the tag is always freeforall; the choice is the RESPAWN // role -- Default (respawn) vs NoReturn (one life). Stored as key=role model. struct RoleEntry { const char *roleKey; const char *model; const char *name; }; static const RoleEntry kScenarios[] = { { "Default", "dfltrole", "Free For All" }, { "NoReturn", "noretun", "No Return (1 life)" }, // model = the game's 8.3 resource }; // The 18 mechs (console friendly name -> egg tag), matching the 2026-07-18 // ModelList certification. static const CatalogEntry kVehicles[] = { { "blkhawk", "Black Hawk" }, { "loki", "Loki" }, { "bhk1", "Nova" }, { "madcat", "Mad Cat" }, { "thor", "Thor" }, { "owens", "Owens" }, { "own1", "Kotori" }, { "thr1", "Summoner" }, { "lok1", "Hellbringer" }, { "mad1", "Timber Wolf" }, { "avatar", "Avatar" }, { "ava1", "Satyr" }, { "sunder", "Sunder" }, { "snd1", "Sunder V1" }, { "vulture", "Vulture" }, { "vul1", "Mad Dog" }, { "lok2", "Mischief" }, { "mad2", "Zanin Neko" }, }; // experience = the pod's per-pilot simulation-fidelity tier (see // context/experience-levels.md); 4 game levels. static const CatalogEntry kExperience[] = { { "novice", "Novice" }, { "standard", "Standard" }, { "veteran", "Veteran" }, { "expert", "Expert" }, }; // color/camo (console display -> egg tag: Red=Crimson, Gray=Grey) static const CatalogEntry kColors[] = { { "White", "White" }, { "Black", "Black" }, { "Brown", "Brown" }, { "Crimson","Crimson" }, { "Green", "Green" }, { "Grey", "Grey" }, { "Tan", "Tan" }, }; // badge / team emblem (the egg `badge=` field) static const CatalogEntry kBadges[] = { { "VGL", "VGL" }, { "Davion", "Davion" }, { "Kurita", "Kurita" }, { "Liao", "Liao" }, { "Marik", "Marik" }, { "Steiner", "Steiner" }, { "None", "None" }, }; // patch / badge colour (the egg `patch=` field) static const CatalogEntry kPatches[] = { { "Red", "Red" }, { "Yellow", "Yellow" }, { "Blue", "Blue" }, { "Green", "Green" }, { "Violet", "Violet" }, { "Grey", "Grey" }, { "White", "White" }, { "Black", "Black" }, }; // drop zone (the egg `dropzone=` field) static const CatalogEntry kDropZones[] = { { "one", "One" }, { "two", "Two" }, { "three", "Three" }, { "four", "Four" }, { "five", "Five" }, }; // advanced damage model (the egg `advancedDamage=` field) static const CatalogEntry kAdvDamage[] = { { "1", "On" }, { "0", "Off" }, }; static const CatalogEntry kTimes[] = { { "morning","Morning" }, { "day", "Day" }, { "evening","Evening" }, { "night", "Night" }, }; static const CatalogEntry kWeathers[] = { { "clear", "Clear" }, { "fog", "Light Fog" }, { "soup", "Heavy Fog" }, }; struct LengthEntry { int seconds; const char *name; }; static const LengthEntry kLengths[] = { { 60, "1:00" }, { 300, "5:00" }, { 600, "10:00" }, { 1200, "20:00" }, }; static const CatalogEntry kModes[] = { { "solo", "SOLO MISSION" }, { "raw", "RAW SOLO (endless)" }, { "hostlan", "HOST LAN" }, { "joinlan", "JOIN LAN" }, }; static const int kPorts[] = { 1501, 1601, 1701, 1801 }; #define COUNT(a) ((int)(sizeof(a)/sizeof((a)[0]))) enum MenuGroup { GroupMap = 0, GroupScenario, GroupTime, GroupWeather, GroupLength, GroupVehicle, GroupExperience, GroupColor, GroupBadge, GroupPatch, GroupDropZone, GroupAdvDamage, GroupMode, GroupPort, GroupLastSelectable = GroupPort, // the last catalog group (persist + headers) GroupLaunch, GroupSteamHost, GroupSteamJoin, GroupCount }; struct MenuItem { int group; int index; RECT rect; }; struct MenuState { int selection[GroupCount]; MenuItem items[128]; int itemCount; HWND window; HWND nameEdit; HWND peersEdit; HFONT textFont; HFONT titleFont; HBRUSH editBrush; int result; // 0 pending, 1 launch, -1 quit int steamAction; // 0 none, 1 host lobby, 2 join lobby }; static MenuState menu; static const COLORREF kGreenBright = RGB(64, 255, 64); static const COLORREF kGreenDim = RGB(24, 140, 24); // // BT_FE_SOLO=1 (play_solo.bat): a SOLO-ONLY menu. The mode cycle keeps just // the two solo entries (kModes lists them FIRST: solo, raw), and the network // rows/buttons (NET PORT, HOST/JOIN STEAM) are not laid out -- LAN play goes // through the join bats + operator, Steam through play_steam.bat. A stale // fe_last.ini mode of hostlan/joinlan is rejected by the GroupSize clamp in // LoadPersisted, so the selection can never land on a hidden mode. // static int FeSoloOnly() { static int s = -1; if (s < 0) { const char *e = getenv("BT_FE_SOLO"); s = (e != NULL && *e != '0') ? 1 : 0; } return s; } static int GroupSize(int group) { switch (group) { case GroupMap: return COUNT(kMaps); case GroupScenario: return COUNT(kScenarios); case GroupTime: return COUNT(kTimes); case GroupWeather: return COUNT(kWeathers); case GroupLength: return COUNT(kLengths); case GroupVehicle: return COUNT(kVehicles); case GroupExperience: return COUNT(kExperience); case GroupColor: return COUNT(kColors); case GroupBadge: return COUNT(kBadges); case GroupPatch: return COUNT(kPatches); case GroupDropZone: return COUNT(kDropZones); case GroupAdvDamage: return COUNT(kAdvDamage); case GroupMode: return FeSoloOnly() ? 2 : COUNT(kModes); // solo, raw case GroupPort: return COUNT(kPorts); default: return 1; } } static void GroupRowText(int group, int index, char *out /*64*/) { switch (group) { case GroupMap: strcpy(out, kMaps[index].name); break; case GroupScenario: strcpy(out, kScenarios[index].name); break; case GroupTime: strcpy(out, kTimes[index].name); break; case GroupWeather: strcpy(out, kWeathers[index].name); break; case GroupLength: strcpy(out, kLengths[index].name); break; case GroupVehicle: strcpy(out, kVehicles[index].name); break; case GroupExperience: strcpy(out, kExperience[index].name); break; case GroupColor: strcpy(out, kColors[index].name); break; case GroupBadge: strcpy(out, kBadges[index].name); break; case GroupPatch: strcpy(out, kPatches[index].name); break; case GroupDropZone: strcpy(out, kDropZones[index].name); break; case GroupAdvDamage: strcpy(out, kAdvDamage[index].name); break; case GroupMode: strcpy(out, kModes[index].name); break; case GroupPort: sprintf(out, "%d", kPorts[index]); break; default: out[0] = 0; break; } } static const char * GroupHeader(int group) { switch (group) { case GroupMap: return "MAP"; case GroupScenario: return "SCENARIO"; case GroupTime: return "TIME"; case GroupWeather: return "WEATHER"; case GroupLength: return "LENGTH"; case GroupVehicle: return "MECH"; case GroupExperience: return "EXPERIENCE"; case GroupColor: return "COLOR"; case GroupBadge: return "BADGE"; case GroupPatch: return "PATCH"; case GroupDropZone: return "DROP ZONE"; case GroupAdvDamage: return "ADV. DAMAGE"; case GroupMode: return "MODE"; case GroupPort: return "NET PORT"; default: return ""; } } // // Layout: five catalog columns + the button column. // col1 MISSION: MAP, SCENARIO, TIME, WEATHER, LENGTH // col2 MECH (18) col3 EXPERIENCE, COLOR, DROP ZONE, ADV. DAMAGE // col4 BADGE, PATCH col5 MODE, NET PORT + name/peers edits + buttons // enum { MenuClientW = 1180, MenuClientH = 672, // col1 stack (MAP 8 + SCENARIO 2 + TIME 4 + WEATHER 3 + // LENGTH 4 = 630px) + the 30px footer band; 620 overlapped // LENGTH's last rows with the footer (user-reported) MenuRowH = 20, MenuColW = 180, MenuCol1X = 24, MenuCol2X = 224, MenuCol3X = 424, MenuCol4X = 624, MenuCol5X = 824, MenuTopY = 60, MenuNameLabelY = 316, // col5: PILOT NAME label + edit MenuNameEditY = 336, MenuPeersLabelY = 368, // col5: LAN PEERS label + edit MenuPeersEditY = 388 }; static void AddGroup(int group, int x, int *y) { *y += MenuRowH; // the header row int count = GroupSize(group); for (int i = 0; i < count; ++i) { MenuItem *item = &menu.items[menu.itemCount++]; item->group = group; item->index = i; item->rect.left = x; item->rect.top = *y; item->rect.right = x + MenuColW; item->rect.bottom = *y + MenuRowH; *y += MenuRowH; } *y += MenuRowH / 2; // gap below the group } static void AddButton(int group, int x, int y, int width, int height) { MenuItem *item = &menu.items[menu.itemCount++]; item->group = group; item->index = 0; item->rect.left = x; item->rect.top = y; item->rect.right = x + width; item->rect.bottom = y + height; } static void LayoutMenu() { menu.itemCount = 0; int y; // col1: the mission y = MenuTopY; AddGroup(GroupMap, MenuCol1X, &y); AddGroup(GroupScenario, MenuCol1X, &y); AddGroup(GroupTime, MenuCol1X, &y); AddGroup(GroupWeather, MenuCol1X, &y); AddGroup(GroupLength, MenuCol1X, &y); // col2: the mech (tall) y = MenuTopY; AddGroup(GroupVehicle, MenuCol2X, &y); // col3: the pilot loadout y = MenuTopY; AddGroup(GroupExperience, MenuCol3X, &y); AddGroup(GroupColor, MenuCol3X, &y); AddGroup(GroupDropZone, MenuCol3X, &y); AddGroup(GroupAdvDamage, MenuCol3X, &y); // col4: emblem + patch y = MenuTopY; AddGroup(GroupBadge, MenuCol4X, &y); AddGroup(GroupPatch, MenuCol4X, &y); // col5: the session + controls (solo-only: no net port, no steam buttons) y = MenuTopY; AddGroup(GroupMode, MenuCol5X, &y); if (!FeSoloOnly()) { AddGroup(GroupPort, MenuCol5X, &y); } AddButton(GroupLaunch, MenuCol5X, MenuClientH - 190, 220, 52); #ifdef BT_STEAM if (!FeSoloOnly()) { AddButton(GroupSteamHost, MenuCol5X, MenuClientH - 120, 220, 34); AddButton(GroupSteamJoin, MenuCol5X, MenuClientH - 76, 220, 34); } #endif } // // fe_last.ini -- the persisted loadout (our process-relaunch model would // otherwise forget it between missions; RP412 persists in-process). // static void LoadPersisted(char *pilot_name, int name_capacity, char *peers, int peers_capacity) { FILE *f = fopen("fe_last.ini", "rt"); if (f == NULL) { return; } char line[160]; while (fgets(line, sizeof(line), f)) { char *newline = strchr(line, '\n'); if (newline) *newline = 0; char *equals = strchr(line, '='); if (equals == NULL) continue; *equals = 0; const char *value = equals + 1; for (int g = 0; g < GroupCount; ++g) { if (_stricmp(line, GroupHeader(g)) == 0) { int v = atoi(value); if (v >= 0 && v < GroupSize(g)) menu.selection[g] = v; } } if (_stricmp(line, "NAME") == 0 && value[0]) { strncpy(pilot_name, value, name_capacity - 1); pilot_name[name_capacity - 1] = 0; } if (_stricmp(line, "PEERS") == 0) { strncpy(peers, value, peers_capacity - 1); peers[peers_capacity - 1] = 0; } } fclose(f); } static void SavePersisted(const char *pilot_name, const char *peers) { FILE *f = fopen("fe_last.ini", "wt"); if (f == NULL) { return; } for (int g = 0; g <= GroupPort; ++g) { fprintf(f, "%s=%d\n", GroupHeader(g), menu.selection[g]); } fprintf(f, "NAME=%s\nPEERS=%s\n", pilot_name, peers); fclose(f); } static void DrawMenuText(HDC dc, const char *text, RECT *rect, COLORREF color, UINT format) { WCHAR wide[128]; int n = 0; for (const char *s = text; *s && n < 127; ++s) wide[n++] = (WCHAR)*s; wide[n] = 0; SetTextColor(dc, color); DrawTextW(dc, wide, -1, rect, format); } static void PaintMenu(HWND window) { PAINTSTRUCT paint; HDC screen = BeginPaint(window, &paint); RECT client; GetClientRect(window, &client); // double-buffered (the RP412 pattern) HDC dc = CreateCompatibleDC(screen); HBITMAP backing = CreateCompatibleBitmap(screen, client.right, client.bottom); HBITMAP old_backing = (HBITMAP)SelectObject(dc, backing); HBRUSH background = CreateSolidBrush(RGB(0, 0, 0)); FillRect(dc, &client, background); DeleteObject(background); SetBkMode(dc, TRANSPARENT); HFONT old_font = (HFONT)SelectObject(dc, menu.titleFont); RECT title_rect = { MenuCol1X, 14, client.right - 24, 54 }; DrawMenuText(dc, "BATTLETECH -- MISSION CONSOLE", &title_rect, kGreenBright, DT_LEFT | DT_TOP | DT_SINGLELINE); SelectObject(dc, menu.textFont); // // Group headers (above each group's first row). // for (int i = 0; i < menu.itemCount; ++i) { const MenuItem &item = menu.items[i]; if (item.index != 0 || item.group > GroupPort) { continue; } RECT header = item.rect; header.top -= MenuRowH; header.bottom = item.rect.top; DrawMenuText(dc, GroupHeader(item.group), &header, kGreenDim, DT_LEFT | DT_VCENTER | DT_SINGLELINE); } // // Catalog rows: bright = selected, dim = not. // for (int i = 0; i < menu.itemCount; ++i) { const MenuItem &item = menu.items[i]; if (item.group > GroupPort) { continue; } char text[64]; GroupRowText(item.group, item.index, text); int selected = (menu.selection[item.group] == item.index); RECT row = item.rect; row.left += 14; if (selected) { RECT marker = item.rect; marker.right = marker.left + 14; DrawMenuText(dc, ">", &marker, kGreenBright, DT_LEFT | DT_VCENTER | DT_SINGLELINE); } DrawMenuText(dc, text, &row, selected ? kGreenBright : kGreenDim, DT_LEFT | DT_VCENTER | DT_SINGLELINE); } // // Edit labels (the controls themselves are child windows in col5). // RECT label = { MenuCol5X, MenuNameLabelY, MenuCol5X + 240, MenuNameLabelY + MenuRowH }; DrawMenuText(dc, "PILOT NAME", &label, kGreenDim, DT_LEFT | DT_VCENTER | DT_SINGLELINE); label.top = MenuPeersLabelY; label.bottom = label.top + MenuRowH; DrawMenuText(dc, "LAN PEERS (host: ip:port,...)", &label, kGreenDim, DT_LEFT | DT_VCENTER | DT_SINGLELINE); // // Buttons (framed, RP412 style). // HBRUSH frame = CreateSolidBrush(kGreenBright); for (int i = 0; i < menu.itemCount; ++i) { const MenuItem &item = menu.items[i]; const char *text = (item.group == GroupLaunch) ? "L A U N C H" : (item.group == GroupSteamHost) ? "HOST STEAM LOBBY" : (item.group == GroupSteamJoin) ? "JOIN STEAM LOBBY" : NULL; if (text == NULL) { continue; } RECT box = item.rect; FrameRect(dc, &box, frame); DrawMenuText(dc, text, &box, kGreenBright, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } DeleteObject(frame); RECT help = { MenuCol1X, client.bottom - 30, client.right - 24, client.bottom - 6 }; DrawMenuText(dc, "click to select ENTER = launch ESC = quit", &help, kGreenDim, DT_LEFT | DT_TOP | DT_SINGLELINE); SelectObject(dc, old_font); BitBlt(screen, 0, 0, client.right, client.bottom, dc, 0, 0, SRCCOPY); SelectObject(dc, old_backing); DeleteObject(backing); DeleteDC(dc); EndPaint(window, &paint); } static LRESULT CALLBACK MenuWndProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { switch (message) { case WM_PAINT: PaintMenu(window); return 0; case WM_CTLCOLOREDIT: // // The edits render green-on-black like everything else. // SetTextColor((HDC)wparam, kGreenBright); SetBkColor((HDC)wparam, RGB(6, 20, 6)); return (LRESULT)menu.editBrush; case WM_LBUTTONDOWN: { int x = (int)(short)LOWORD(lparam); int y = (int)(short)HIWORD(lparam); for (int i = 0; i < menu.itemCount; ++i) { const MenuItem &item = menu.items[i]; if (x < item.rect.left || x >= item.rect.right || y < item.rect.top || y >= item.rect.bottom) { continue; } if (item.group == GroupLaunch) { menu.result = 1; } else if (item.group == GroupSteamHost) { menu.steamAction = 1; menu.result = 1; } else if (item.group == GroupSteamJoin) { menu.steamAction = 2; menu.result = 1; } else { menu.selection[item.group] = item.index; InvalidateRect(window, NULL, FALSE); } break; } SetFocus(window); // clicks outside the edits take focus back } return 0; case WM_KEYDOWN: if (wparam == VK_RETURN) { menu.result = 1; } else if (wparam == VK_ESCAPE) { menu.result = -1; } return 0; case WM_CLOSE: menu.result = -1; return 0; } return DefWindowProcW(window, message, wparam, lparam); } int BTFrontEnd_Run(BTFeLaunchSpec *spec) { memset(spec, 0, sizeof(*spec)); memset(&menu, 0, sizeof(menu)); menu.selection[GroupLength] = 2; // 10:00, the console default menu.selection[GroupExperience] = 2; // veteran menu.selection[GroupColor] = 5; // Grey (the console default) menu.selection[GroupBadge] = 0; // VGL menu.selection[GroupDropZone] = 0; // one char pilot_name[32] = "Dev"; char peers[128] = ""; LoadPersisted(pilot_name, sizeof(pilot_name), peers, sizeof(peers)); LayoutMenu(); WNDCLASSW window_class; memset(&window_class, 0, sizeof(window_class)); window_class.lpfnWndProc = MenuWndProc; window_class.hInstance = GetModuleHandleW(NULL); window_class.hCursor = LoadCursorW(NULL, (LPCWSTR)IDC_ARROW); window_class.lpszClassName = L"BTFrontEndWnd"; RegisterClassW(&window_class); RECT frame_rect = { 0, 0, MenuClientW, MenuClientH }; DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX; AdjustWindowRect(&frame_rect, style, FALSE); menu.window = CreateWindowW( L"BTFrontEndWnd", L"BattleTech - Mission Console", style, 100, 80, frame_rect.right - frame_rect.left, frame_rect.bottom - frame_rect.top, NULL, NULL, GetModuleHandleW(NULL), NULL); if (menu.window == NULL) { return -1; } menu.textFont = CreateFontW(-16, 0, 0, 0, FW_NORMAL, 0, 0, 0, DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, FIXED_PITCH, L"Consolas"); menu.titleFont = CreateFontW(-24, 0, 0, 0, FW_BOLD, 0, 0, 0, DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, FIXED_PITCH, L"Consolas"); menu.editBrush = CreateSolidBrush(RGB(6, 20, 6)); // // The two edit controls (name; LAN peers for host mode). // menu.nameEdit = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_AUTOHSCROLL, MenuCol5X, MenuNameEditY, 240, MenuRowH + 4, menu.window, NULL, GetModuleHandleW(NULL), NULL); menu.peersEdit = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_AUTOHSCROLL, MenuCol5X, MenuPeersEditY, 240, MenuRowH + 4, menu.window, NULL, GetModuleHandleW(NULL), NULL); SendMessageW(menu.nameEdit, WM_SETFONT, (WPARAM)menu.textFont, TRUE); SendMessageW(menu.peersEdit, WM_SETFONT, (WPARAM)menu.textFont, TRUE); SetWindowTextA(menu.nameEdit, pilot_name); SetWindowTextA(menu.peersEdit, peers); ShowWindow(menu.window, SW_SHOW); SetForegroundWindow(menu.window); MSG message; while (menu.result == 0 && GetMessageW(&message, NULL, 0, 0)) { // // ENTER/ESC work with an edit focused too. // if (message.message == WM_KEYDOWN && (message.wParam == VK_RETURN || message.wParam == VK_ESCAPE)) { menu.result = (message.wParam == VK_RETURN) ? 1 : -1; continue; } TranslateMessage(&message); DispatchMessageW(&message); } GetWindowTextA(menu.nameEdit, pilot_name, sizeof(pilot_name)); GetWindowTextA(menu.peersEdit, peers, sizeof(peers)); DestroyWindow(menu.window); DeleteObject(menu.textFont); DeleteObject(menu.titleFont); DeleteObject(menu.editBrush); if (menu.result != 1) { return 1; // quit } SavePersisted(pilot_name[0] ? pilot_name : "Dev", peers); // // Resolve the launch spec + write the egg. // int console_port = kPorts[menu.selection[GroupPort]]; const RoleEntry &scenario = kScenarios[menu.selection[GroupScenario]]; BTFeMission mission; memset(&mission, 0, sizeof(mission)); strcpy(mission.map, kMaps[menu.selection[GroupMap]].key); strcpy(mission.time, kTimes[menu.selection[GroupTime]].key); strcpy(mission.weather, kWeathers[menu.selection[GroupWeather]].key); strcpy(mission.roleKey, scenario.roleKey); strcpy(mission.roleModel, scenario.model); mission.temperature = 27; mission.lengthSeconds = kLengths[menu.selection[GroupLength]].seconds; mission.pilotCount = 1; BTFePilot &self = mission.pilots[0]; strcpy(self.name, pilot_name[0] ? pilot_name : "Dev"); strcpy(self.vehicle, kVehicles[menu.selection[GroupVehicle]].key); strcpy(self.color, kColors[menu.selection[GroupColor]].key); strcpy(self.experience, kExperience[menu.selection[GroupExperience]].key); strcpy(self.badge, kBadges[menu.selection[GroupBadge]].key); strcpy(self.patch, kPatches[menu.selection[GroupPatch]].key); strcpy(self.dropzone, kDropZones[menu.selection[GroupDropZone]].key); self.advancedDamage = (kAdvDamage[menu.selection[GroupAdvDamage]].key[0] == '1'); spec->consolePort = console_port; spec->missionSeconds = mission.lengthSeconds; strcpy(spec->eggPath, "frontend.egg"); int mode = menu.selection[GroupMode]; #ifdef BT_STEAM if (menu.steamAction == 1) { BTLobbyRoster roster; if (BTLobby_HostAndRoom(self.name, self.vehicle, self.color, &roster, spec->steamMyToken, sizeof(spec->steamMyToken), spec->steamMap, sizeof(spec->steamMap)) != 0 || roster.memberCount == 0) { return 1; // cancelled / Steam unavailable } spec->mode = BTFeLaunchHostSteam; mission.pilotCount = 0; sprintf(spec->podList, "127.0.0.1:%d", console_port); for (int m = 0; m < roster.memberCount && m < 8; ++m) { const BTLobbyMember &member = roster.members[m]; BTFePilot &pilot = mission.pilots[mission.pilotCount++]; strncpy(pilot.name, member.name, sizeof(pilot.name) - 1); strncpy(pilot.vehicle, member.vehicle[0] ? member.vehicle : kVehicles[0].key, sizeof(pilot.vehicle) - 1); strncpy(pilot.color, member.color[0] ? member.color : kColors[0].key, sizeof(pilot.color) - 1); sprintf(pilot.address, "%s:%d", member.fakeAddress, member.gamePort); if (!member.isSelf) { char entry[64]; sprintf(entry, ",%s:%d", member.fakeAddress, member.consolePort); strcat(spec->podList, entry); } } if (BTFeMission_WriteEgg(&mission, spec->eggPath) != 0) { return -1; } return 0; } if (menu.steamAction == 2) { if (BTLobby_JoinAndWait(self.name, self.vehicle, self.color, spec->steamMyToken, sizeof(spec->steamMyToken), spec->steamMap, sizeof(spec->steamMap)) != 0) { return 1; } spec->mode = BTFeLaunchJoinSteam; spec->eggPath[0] = 0; return 0; } #endif switch (mode) { case 0: // SOLO MISSION: networked 1-pod, marshal feeds self spec->mode = BTFeLaunchSolo; sprintf(self.address, "127.0.0.1:%d", console_port + 1); sprintf(spec->podList, "127.0.0.1:%d", console_port); break; case 1: // RAW SOLO: the plain -egg endless path spec->mode = BTFeLaunchRawSolo; strcpy(self.address, "127.0.0.1"); break; case 2: // HOST LAN: marshal feeds self + the peers list { spec->mode = BTFeLaunchHostLan; sprintf(self.address, "127.0.0.1:%d", console_port + 1); sprintf(spec->podList, "127.0.0.1:%d", console_port); char peers_copy[128]; strcpy(peers_copy, peers); char *cursor = strtok(peers_copy, ", "); while (cursor != NULL && mission.pilotCount < 8) { char host[48]; int port = 0; const char *colon = strrchr(cursor, ':'); if (colon != NULL && (port = atoi(colon + 1)) > 0 && (int)(colon - cursor) < (int)sizeof(host)) { memcpy(host, cursor, colon - cursor); host[colon - cursor] = 0; BTFePilot &peer = mission.pilots[mission.pilotCount]; sprintf(peer.name, "Pilot%d", mission.pilotCount + 1); strcpy(peer.vehicle, kVehicles[ (menu.selection[GroupVehicle] + mission.pilotCount) % COUNT(kVehicles)].key); strcpy(peer.color, kColors[ (menu.selection[GroupColor] + mission.pilotCount) % COUNT(kColors)].key); // peers inherit the host's loadout defaults (the operator // sets per-pilot detail on the real console) strcpy(peer.experience, self.experience); strcpy(peer.badge, self.badge); strcpy(peer.patch, self.patch); strcpy(peer.dropzone, self.dropzone); peer.advancedDamage = self.advancedDamage; sprintf(peer.address, "%s:%d", host, port + 1); ++mission.pilotCount; char entry[64]; sprintf(entry, ",%s:%d", host, port); strcat(spec->podList, entry); } cursor = strtok(NULL, ", "); } } break; default: // JOIN LAN: plain -net pod; the host's marshal feeds us spec->mode = BTFeLaunchJoinLan; spec->eggPath[0] = 0; return 0; } if (BTFeMission_WriteEgg(&mission, spec->eggPath) != 0) { return -1; } return 0; }