Glass cockpit refit: one mode resolver, one button geometry, and a cockpit that scales

Replicates the RP412 cockpit line into BT411's own architecture -- porting the
geometry and keeping our renderers, so BT_SHOT single-frame verification stays
intact.

MODE RESOLVER.  Where the secondary displays go was decided in TWO places with
duplicated precedence, and the boot banner read NEITHER -- it announced
"per-display cockpit windows" for every glass boot, surround included.  The
split had also broken BT_COCKPIT=0: documented as the dock-bottom opt-out, the
profile block converted it to BT_GLASS_PANELS=1, so the docked strip was
UNREACHABLE under the glass profile.  One resolver now, consumed by the banner,
the pad-panel decision and the window sizing; BT_GLASS_PANELS is explicit-only;
dock/window modes auto-raise BT_PAD_PANEL so the button field always has a home.

L4RIOBANK -- one button geometry.  Both renderers carried their own copy and had
drifted: an MFD button was 156x138 reaching under the glass in the exploded
window and a 76x24 sliver entirely OUTSIDE the glass in the surround.  One
module owns it now, both are consumers, placement stays per-renderer.  The pod's
under-glass rule (RP412 L4MFDVIEW): reach half the glass in behind the display,
leave a lamp strip clearing the edge, paint buttons first and imagery over --
so the lamp reads as a bar and practically the whole display is the press
target.  The strip scales off the display's SHORT axis (the map is portrait)
with a readable floor.  Retired L4GLASSWIN's three local placers and seven
layout constants.

LAMP FLASH DECODE was wrong [T1].  BTLampBrightnessOf returned max(state1,
state2) and blanked on the alternate phase; RIO::LampState (L4RIO.h [T0]) says
solid shows state 1 and flashing ALTERNATES the two.  Agrees only when one state
is Off -- true for the Panic lamp, which is why it survived -- but L4LAMP.cpp:252
commands flashFast + state1Dim + state2Bright, a dim->bright pulse that rendered
as a hard bright->off blink.  Three copies existed (l4vb16.h, L4GLASSWIN,
L4PADPANEL), all three wrong; the two locals now forward to the one fixed inline.

THE COCKPIT SCALES.  The fixed canvas was stretched into whatever the client
area was, so a window dragged to a different shape squashed the instruments (the
projection was aspect-corrected in task #20; the panels never were).  Now one
uniform scale, centred, leftover black.  D3D9 applies it as a Present
destination rect, which DISCARD forbids -- so the WINDOWED swap effect becomes
COPY when the surround is up and multisampling is off.  The click mapping had to
follow (mapping against the full client drifts the hit test off every button by
the bar width), as did the world aspect (under a uniform scale it is the view
rect's own).  -fit / -windowed-fullscreen: borderless over the monitor.

 - ordering trap: the first WM_SIZE beats the device, so a -fit boot logged
   aspect=3.14 and applied it on frame 1.  The letterbox INTENT is decided in
   btl4main; L4VIDEO only confirms or withdraws it.

PLAYER-TUNABLE DISPLAYS.  BT_MFD_SCALE (+ _UL/_UC/_UR/_LL/_LR), BT_RADAR_SCALE,
BT_RADAR_POS (CENTER/LEFT/RIGHT/MIDLEFT/MIDRIGHT).  The surround BANDS derive
from the resolved sizes -- that is why the sizes could not stay constants: the
band a display hangs in has to grow with it or the canvas clips it.  100%
reproduces the historical L276 R276 T223 B336 exactly.  A corner map goes flush
to the CANVAS edge and the lower MFD slides beside it (measuring off the view
edge overlapped them by 232px).

MAP LEGEND GRID -- measured, not inherited.  scratchpad/measurelegend.py over a
native capture: top 3, cell 102, pitch 107 of 640.  RP412's map is 13 + 6x102 @
105 -- same cell height, different top and pitch, so its numbers do NOT
transfer.  Our old even division had the pitch right by luck and sat 3px high of
the labels.

environ.ini.  It was read ~300 lines into WinMain, AFTER the platform-profile
block had run its getenv()s -- so every setting the profile reads was silently
ignored FROM THE FILE and only worked as a real env var.  It also putenv()'d
comments verbatim.  Now loaded immediately after the first-breath line, comments
skipped, the real environment WINS over the file, and a fully documented default
is written on first run (the bindings.txt convention: untracked, so
extract-over-top never clobbers a player's settings).

VERIFICATION HARNESS (new, reusable): BT_RIOBANK_LOG=1 dumps every bank;
checkbank.py proves no address is SHADOWED (an address whose rect is covered by
earlier buttons is dead however big it looks -- the overlapping under-glass banks
make that a live hazard); clickbank.py posts a real click at every button centre.

Verified: 72/72 placed, 0 shadowed, 72/72 dispatched in BOTH modes, after a
resize, at 150%/135%, and at 75%+BOTTOMRIGHT; wide/tall drags and -fit
undistorted on a 3440x1440; exploded/dock/pod/dev un-regressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-26 02:23:40 -05:00
co-authored by Claude Opus 5
parent 1cda880c6d
commit 61563c9efe
15 changed files with 1670 additions and 282 deletions
+111
View File
@@ -0,0 +1,111 @@
"""Verify the RIO button field from a BT_RIOBANK_LOG=1 run.
The banks now reach UNDER the glass and deliberately overlap (the map's foot
row sits inside both side columns' reach), so "the address is in the list" is
not enough -- the hit test takes the FIRST match, so an address whose rect is
entirely shadowed by earlier buttons is dead however big it looks.
Reads btl4.log, groups by bank tag, and for each button asks: is there any
point in my rect that no EARLIER button in this bank covers? Reports the
address census per bank and any shadowed address.
python checkbank.py <log> [--expect 0x00-0x47]
"""
import re
import sys
from collections import OrderedDict
BANK_RE = re.compile(r"\[riobank\] (.+?) bounds=\((-?\d+),(-?\d+),(-?\d+),(-?\d+)\) (\d+) buttons")
BTN_RE = re.compile(r"\[riobank\] addr=0x([0-9a-f]+) rect=\((-?\d+),(-?\d+),(-?\d+),(-?\d+)\) class=(\d+)")
def parse(path):
banks = OrderedDict()
current = None
with open(path, "r", errors="replace") as handle:
for line in handle:
match = BANK_RE.search(line)
if match:
current = match.group(1)
# a re-run appends; keep the FIRST pass of each tag
banks.setdefault(current, [])
if banks[current]:
current = None # already captured this tag
continue
match = BTN_RE.search(line)
if match and current is not None:
addr = int(match.group(1), 16)
x, y, w, h = (int(match.group(i)) for i in range(2, 6))
banks[current].append((addr, x, y, w, h, int(match.group(6))))
return banks
def reachable(rect, earlier):
"""Any point of `rect` not covered by an earlier rect?
Sampled on the coordinate grid induced by the earlier rects' edges -- if a
gap exists at all, one of these sample points lands in it.
"""
_, x, y, w, h = rect[:5]
xs = {x, x + w - 1}
ys = {y, y + h - 1}
for _, ex, ey, ew, eh, _ in earlier:
for candidate in (ex - 1, ex, ex + ew - 1, ex + ew):
if x <= candidate < x + w:
xs.add(candidate)
for candidate in (ey - 1, ey, ey + eh - 1, ey + eh):
if y <= candidate < y + h:
ys.add(candidate)
for px in sorted(xs):
for py in sorted(ys):
hit = None
for button in earlier:
_, ex, ey, ew, eh, _ = button
if ex <= px < ex + ew and ey <= py < ey + eh:
hit = button
break
if hit is None:
return True
return False
def main():
path = sys.argv[1] if len(sys.argv) > 1 else "btl4.log"
banks = parse(path)
if not banks:
print("no [riobank] lines -- was BT_RIOBANK_LOG=1 set?")
return 1
seen = set()
failures = []
for tag, buttons in banks.items():
addresses = [b[0] for b in buttons]
duplicates = {a for a in addresses if addresses.count(a) > 1}
shadowed = []
for index, button in enumerate(buttons):
if not reachable(button, buttons[:index]):
shadowed.append(button[0])
seen.update(addresses)
status = "ok"
if duplicates or shadowed:
status = "FAIL"
failures.append(tag)
print("%-22s %2d buttons 0x%02x-0x%02x %s"
% (tag, len(buttons), min(addresses), max(addresses), status))
if duplicates:
print(" duplicate addresses: %s"
% ", ".join("0x%02x" % a for a in sorted(duplicates)))
if shadowed:
print(" SHADOWED (no first-hit point): %s"
% ", ".join("0x%02x" % a for a in sorted(shadowed)))
print("\n%d distinct addresses across %d banks" % (len(seen), len(banks)))
missing = [a for a in range(0x00, 0x48) if a not in seen]
if missing:
print("not placed by any bank: %s"
% ", ".join("0x%02x" % a for a in missing))
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+161
View File
@@ -0,0 +1,161 @@
"""Click every RIO button the layout claims, and check each one dispatched.
Reads a BT_RIOBANK_LOG=1 dump for the rects, posts a real WM_LBUTTONDOWN/UP at
each button's centre, then compares the addresses the game LOGGED against the
addresses the layout placed. This is the end-to-end check the geometry dump
alone cannot give: it proves the hit test, the coordinate mapping and the RIO
dispatch agree with the rects.
python clickbank.py <log> <window-title-substring> [--mode surround|exploded]
surround: one window, buttons are in backbuffer canvas space (== client space
for a windowed run), so the centres go straight to the main window.
exploded: one window per bank, each with its own client space; the bank tag
names the window.
"""
import ctypes
import re
import sys
import time
from ctypes import wintypes
user32 = ctypes.WinDLL("user32", use_last_error=True)
WM_LBUTTONDOWN = 0x0201
WM_LBUTTONUP = 0x0202
MK_LBUTTON = 0x0001
BANK_RE = re.compile(r"\[riobank\] (.+?) bounds=")
BTN_RE = re.compile(r"\[riobank\] addr=0x([0-9a-f]+) rect=\((-?\d+),(-?\d+),(-?\d+),(-?\d+)\)")
def parse(path):
banks, current, done = {}, None, set()
with open(path, "r", errors="replace") as handle:
for line in handle:
match = BANK_RE.search(line)
if match:
current = match.group(1)
if current in done:
current = None
else:
done.add(current)
banks[current] = []
continue
match = BTN_RE.search(line)
if match and current is not None:
addr = int(match.group(1), 16)
x, y, w, h = (int(match.group(i)) for i in range(2, 6))
banks[current].append((addr, x + w // 2, y + h // 2))
return banks
def find_windows(substring):
found = []
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
def callback(hwnd, _):
length = user32.GetWindowTextLengthW(hwnd)
if length:
buffer = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, buffer, length + 1)
if substring.lower() in buffer.value.lower() and user32.IsWindowVisible(hwnd):
found.append((hwnd, buffer.value))
return True
user32.EnumWindows(callback, 0)
return found
class RECT(ctypes.Structure):
_fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long),
("right", ctypes.c_long), ("bottom", ctypes.c_long)]
def canvas_size(path):
"""The cockpit canvas from the boot line, e.g. '[cockpit] view 900x500 canvas 1452x1059'."""
with open(path, "r", errors="replace") as handle:
for line in handle:
match = re.search(r"\[cockpit\] view \d+x\d+ canvas (\d+)x(\d+)", line)
if match:
return int(match.group(1)), int(match.group(2))
return None
def fit_rect(client_w, client_h, canvas_w, canvas_h):
"""Same uniform-scale centred fit the game computes (BTCockpitFitRect)."""
w = client_w
h = client_w * canvas_h // canvas_w
if h > client_h:
h = client_h
w = client_h * canvas_w // canvas_h
return ((client_w - w) // 2, (client_h - h) // 2, max(w, 1), max(h, 1))
def click(hwnd, x, y):
lparam = (y << 16) | (x & 0xFFFF)
user32.PostMessageW(hwnd, WM_LBUTTONDOWN, MK_LBUTTON, lparam)
time.sleep(0.02)
user32.PostMessageW(hwnd, WM_LBUTTONUP, 0, lparam)
time.sleep(0.02)
def main():
log = sys.argv[1]
title = sys.argv[2]
mode = "surround"
if "--mode" in sys.argv:
mode = sys.argv[sys.argv.index("--mode") + 1]
banks = parse(log)
if not banks:
print("no [riobank] lines in", log)
return 1
# exploded: each bank is its OWN window with its own caption, so match
# across every visible window rather than filtering by one title
windows = find_windows("" if mode == "exploded" else title)
if not windows:
print("no window matching", title)
return 1
# surround: the dump is in CANVAS space; if the window has been resized the
# canvas is letterboxed inside the client, so map through the same fit the
# game uses or every click lands off its button
transform = None
if mode == "surround":
canvas = canvas_size(log)
client = RECT()
user32.GetClientRect(windows[0][0], ctypes.byref(client))
if canvas and client.right > 0 and client.bottom > 0:
ox, oy, fw, fh = fit_rect(client.right, client.bottom, canvas[0], canvas[1])
if (fw, fh) != canvas:
transform = (ox, oy, fw, fh, canvas[0], canvas[1])
print("client %dx%d, canvas %dx%d -> fit %dx%d at (%d,%d)"
% (client.right, client.bottom, canvas[0], canvas[1], fw, fh, ox, oy))
posted = set()
for tag, buttons in banks.items():
if mode == "surround":
target = windows[0][0]
else:
# the bank tag is the window caption
match = [h for h, caption in windows if caption.strip() == tag.strip()]
if not match:
print(" no window for bank %r -- skipped" % tag)
continue
target = match[0]
for addr, cx, cy in buttons:
if transform is not None:
ox, oy, fw, fh, cw, ch = transform
cx = ox + cx * fw // cw
cy = oy + cy * fh // ch
click(target, cx, cy)
posted.add(addr)
print("posted clicks for %d addresses" % len(posted))
print(" ".join("0x%02x" % a for a in sorted(posted)))
return 0
if __name__ == "__main__":
sys.exit(main())
+91
View File
@@ -0,0 +1,91 @@
"""Measure the map's PAINTED legend grid, and compare the lamp bars against it.
The side button columns should line up with the legend cells the map imagery
paints beside them. RP412 measured ITS map at 13 + 6x102 on a 105 pitch of 640
-- a different game's art, so those numbers do not transfer. This finds ours.
Two scans over a native Secondary/Radar capture:
* the LEGEND: horizontal rules of the six bordered cells in the map imagery
* the LAMPS: the runs of the yellow button strip at the window's left edge
and reports the offset between them, which is what a misalignment looks like.
py measurelegend.py <capture.png> <caption_h> <lamp_x> <legend_x0> <legend_x1>
"""
import sys
from PIL import Image
def main():
path = sys.argv[1]
caption = int(sys.argv[2]) if len(sys.argv) > 2 else 30
lamp_x = int(sys.argv[3]) if len(sys.argv) > 3 else 12
lx0 = int(sys.argv[4]) if len(sys.argv) > 4 else 22
lx1 = int(sys.argv[5]) if len(sys.argv) > 5 else 118
image = Image.open(path).convert("RGB")
w, h = image.size
px = image.load()
print("capture %dx%d (client starts at y=%d)" % (w, h, caption))
# --- the legend: rows carrying a long horizontal ORANGE rule -------------
def orange(p):
return p[0] > 110 and p[1] > 45 and p[2] < 90 and p[0] > p[2] + 60
rules = []
for y in range(caption, h):
run = 0
best = 0
for x in range(lx0, min(lx1, w)):
if orange(px[x, y]):
run += 1
best = max(best, run)
else:
run = 0
if best >= (lx1 - lx0) * 0.55:
rules.append(y - caption)
# collapse adjacent rows into single rules
grouped = []
for y in rules:
if grouped and y - grouped[-1][-1] <= 2:
grouped[-1].append(y)
else:
grouped.append([y])
rule_tops = [g[0] for g in grouped]
print("\nlegend rules (client y): %s" % rule_tops)
if len(rule_tops) >= 2:
print("gaps: %s" % [rule_tops[i+1] - rule_tops[i]
for i in range(len(rule_tops) - 1)])
# --- the lamps: runs of lit pixels in the button strip -------------------
lit = []
for y in range(caption, h):
p = px[lamp_x, y]
lit.append(p[0] > 55 and p[1] > 40 and p[2] < 90)
runs = []
start = None
for i, on in enumerate(lit):
if on and start is None:
start = i
elif not on and start is not None:
if i - start >= 4:
runs.append((start, i - start))
start = None
if start is not None and len(lit) - start >= 4:
runs.append((start, len(lit) - start))
print("\nlamp bars (client y, height): %s" % runs)
if len(runs) >= 2:
print("lamp pitch: %s" % [runs[i+1][0] - runs[i][0]
for i in range(len(runs) - 1)])
# --- compare ------------------------------------------------------------
if rule_tops and runs:
print("\ncell top vs lamp top:")
for i, (top, height) in enumerate(runs[:len(rule_tops)]):
print(" lamp %d top=%4d nearest rule=%4d offset=%+d"
% (i, top, rule_tops[i], top - rule_tops[i]))
if __name__ == "__main__":
main()