ROOT CAUSE: target designation wrote a Mech* into deathPending, disabling respawn (#57/#48)

MEASURED our compiled BTPlayer layout (new one-shot [layout] ctor diagnostic):
  sizeof=652 (0x28c)  killCount@0x270  pad_0x280@0x274
  objectiveMech@0x278  deathPending@0x284

The three target-designation sites wrote RAW at pilotArray[0]+0x284 intending the
BINARY's objectiveMech.  On our object 0x284 is deathPending -- the death-cycle
latch.  So EVERY target designation (the Comm bank 0x30-0x37, or the typed
t/y/u/i/o keys) stored a Mech pointer into deathPending, and a non-zero
deathPending makes VehicleDeadMessageHandler take its dedup early-return, which
skips the warp, ++deathCount, the PLAYER_DEAD record AND the drop-zone hunt.

=> designating a target silently disabled your respawn for the rest of the
   process.  That is #57's missing first cause: it explains David's very first
   death being swallowed with no prior death, and the 3-of-3 swallowed deaths in
   the final round.  It also explains the operator's persistent observation that
   the trouble began when the comms panel went live -- before the pilot list
   populated, there was nothing to designate.
   And the designation never worked either: the value never reached
   objectiveMech (input-audit finding #1).

FIX: all three sites now use named-member bridges in the complete-BTPlayer TU --
BTPilotSetObjectiveMech / BTPilotObjectiveMech / BTPilotVehicle / BTPilotPosition
/ BTVehicleDestroyed.  Also retires the raw +0x1fc (playerVehicle) reads and the
raw +0x100 position reads in ChooseNearestPilot, and routes its destroyed-check
through the real Mech predicate instead of the Is_Destroyed stub.

LAYOUT LOCKS: static_asserts on objectiveMech@0x278, deathPending@0x284,
killCount@0x270 and sizeof==0x28c, in the friend fn BTPlayerLayoutSelfCheck, so
a member shift fails the BUILD instead of silently clobbering the latch again.

Rigs: scratchpad/commstest.py (drives designation + captures panels),
valvetest.py, lampgallery.py (428-bitmap gallery for #48 visual search).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
This commit is contained in:
arcattack
2026-07-25 08:33:22 -05:00
co-authored by Claude Opus 5
parent cb50a1d491
commit 2dd4ee3910
6 changed files with 478 additions and 34 deletions
+139
View File
@@ -203,6 +203,39 @@ static_assert(offsetof(BTPlayer::MakeMessage, roleName) == 0x90,
static_assert(sizeof(BTPlayer::MakeMessage) == 0xD0,
"BTPlayer::MakeMessage wire size must be 0xD0");
//
// LAYOUT LOCK (Gitea #48/#57). Our compiled BTPlayer is NOT the binary's -- the
// Entity base differs -- so the 1995 offsets must never be used as raw byte
// addresses on this object. These are the MEASURED offsets (printed by the ctor
// 2026-07-25); they exist to make the difference explicit and to fail the BUILD
// if a member shifts under code that assumes it.
//
// The bug they memorialise: the target-designation path wrote raw at byte 0x284
// intending `objectiveMech` (the binary's offset). Here 0x284 is
// **`deathPending`** -- so designating a target silently set the death-cycle
// latch and disabled the pilot's respawn for the rest of the process, while the
// designation itself never reached `objectiveMech` at all.
//
// If one of these fires, do NOT "fix" it by changing the number: find whoever is
// using a raw offset and give them a named-member bridge instead (this file's
// BTPilot* functions are the pattern).
//
void BTPlayerLayoutSelfCheck() // never called -- a compile-time lock only
{
static_assert(offsetof(BTPlayer, objectiveMech) == 0x278,
"BTPlayer::objectiveMech moved -- the target-designation bridge "
"(BTPilotSetObjectiveMech) and any raw 0x284-era offset must be re-checked");
static_assert(offsetof(BTPlayer, deathPending) == 0x284,
"BTPlayer::deathPending moved -- it is what the old raw 0x284 designation "
"write actually clobbered (Gitea #57); re-verify nothing raw-writes it");
static_assert(offsetof(BTPlayer, killCount) == 0x270,
"BTPlayer::killCount moved -- the scoreboard bridges (BTPilotKills) and the "
"binary's 0x27c-era offsets must be re-checked");
static_assert(sizeof(BTPlayer) == 0x28c,
"BTPlayer size changed -- re-measure the offsets above before trusting any "
"raw-offset code that touches this class");
}
static const char *SelfDestructName = "self destruct"; // &DAT_00524b38
static const Scalar RespawnDelay = 5.0f; // death -> drop-zone hunt (@004c0830)
static const Scalar TicksPerSecond = 1.0f; // (see note in PlayerSimulation)
@@ -1354,6 +1387,32 @@ BTPlayer::BTPlayer(
currentScore = 0; // this[0x9e]
deathPending = 0; // this[0xa4]
//
// LAYOUT GROUND TRUTH (Gitea #48/#57 investigation, one-shot). The target
// designation path writes RAW at `pilotArray[0] + 0x284` (mechmppr.cpp:1344,
// :1364, :1420) intending the binary's `objectiveMech`. Our compiled layout
// is NOT the binary's (our Entity base is ~0x1BC vs the binary's 0x300), so
// that write lands on whatever member actually occupies byte 0x284 HERE.
// Print the real offsets once so we know what it clobbers -- if 0x284 falls
// on `deathPending` this single line explains BOTH the #48 artifacts AND
// #57's mysterious first-respawn failures.
//
{
static int s_once = 0;
if (!s_once)
{
s_once = 1;
DEBUG_STREAM << "[layout] sizeof(BTPlayer)=" << (int)sizeof(BTPlayer)
<< " (0x" << std::hex << (int)sizeof(BTPlayer) << std::dec << ")"
<< " killCount@0x" << std::hex << (int)offsetof(BTPlayer, killCount)
<< " pad_0x280@0x" << (int)offsetof(BTPlayer, pad_0x280)
<< " objectiveMech@0x" << (int)offsetof(BTPlayer, objectiveMech)
<< " deathPending@0x" << (int)offsetof(BTPlayer, deathPending)
<< std::dec << " <-- the raw write targets byte 0x284\n"
<< std::flush;
}
}
//
// Remember our team name from the creation message.
//
@@ -1558,6 +1617,86 @@ int BTPilotDeaths(void *pilot)
// roster row was a blank box + blanked-zero numerals: invisible until the
// pilot scored. Camera players carry index -1 (PLAYER.cpp:744) -> NULL ->
// DrawMechIcon's authentic cache-miss box branch.
//
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// TARGET DESIGNATION bridges (Gitea #48/#57 -- THE `+0x284` BUG).
//
// `MechControlsMapper`'s three designation sites wrote the chosen target RAW at
// `pilotArray[0] + 0x284`, intending the binary's `BTPlayer::objectiveMech`, and
// read the target's vehicle RAW at `chosen + 0x1fc` (`Player::playerVehicle`) and
// its position at `+0x100`. Those are the 1995 offsets. MEASURED ground truth
// for OUR compiled object (printed by the ctor, 2026-07-25):
//
// sizeof(BTPlayer) = 652 (0x28c)
// killCount @ 0x270 pad_0x280 @ 0x274
// objectiveMech @ 0x278 deathPending @ 0x284 <-- what 0x284 really is
//
// So every target designation wrote a Mech pointer into **`deathPending`**, the
// death-cycle latch. A non-zero `deathPending` makes
// `VehicleDeadMessageHandler` take its dedup early-return (btplayer.cpp:~404),
// which skips the warp, `++deathCount`, the `PLAYER_DEAD` record AND the
// drop-zone hunt -- i.e. **designating a target silently disabled your respawn
// for the rest of the process.** That is the missing first cause in #57 (and
// why David's very first death was already swallowed with no prior death to
// explain it). The designation itself also never worked, because the value
// never reached `objectiveMech` (input-audit finding #1).
//
// Fixed the only correct way: named members, resolved in this complete-type TU.
//
void BTPilotSetObjectiveMech(void *pilot, void *target_vehicle)
{
if (pilot == 0)
return;
((BTPlayer *)pilot)->objectiveMech = (Mech *)target_vehicle;
}
void *BTPilotObjectiveMech(void *pilot)
{
return (pilot != 0) ? (void *)((BTPlayer *)pilot)->objectiveMech : 0;
}
//
// The designated target's VEHICLE (the binary's raw `chosen + 0x1fc`) --
// `Player::playerVehicle`, via the engine accessor.
//
void *BTPilotVehicle(void *pilot)
{
return (pilot != 0) ? (void *)((BTPlayer *)pilot)->GetPlayerVehicle() : 0;
}
//
// The pilot's world position (the binary's raw `+ 0x100`). Reads it off the
// pilot's VEHICLE, which is where a Player's position actually lives in our
// layout -- a Player is not a positioned entity. Returns 0 if the pilot has no
// vehicle (dead / not yet acquired), so callers can skip it in a distance test.
//
int BTPilotPosition(void *pilot, float *out_xyz)
{
if (pilot == 0 || out_xyz == 0)
return 0;
Entity *veh = (Entity *)((BTPlayer *)pilot)->GetPlayerVehicle();
if (veh == 0)
return 0;
const Point3D &p = ((Mover *)veh)->localOrigin.linearPosition;
out_xyz[0] = p.x; out_xyz[1] = p.y; out_xyz[2] = p.z;
return 1;
}
//
// Is this pilot's vehicle destroyed? (the binary's `Is_Destroyed(entity)` on the
// raw `+0x1fc` handle -- our `Is_Destroyed` is still a stub, btstubs.cpp:76, so
// resolve it through the real Mech predicate in this complete-type TU).
//
int BTVehicleDestroyed(void *vehicle)
{
if (vehicle == 0)
return 1; // no vehicle == not targetable
Entity *e = (Entity *)vehicle;
if (e->GetClassID() != Mech::MechClassID)
return 0;
return ((Mech *)e)->IsMechDestroyed() ? 1 : 0;
}
BitMap *BTPilotNameBitmap(void *pilot)
{
if (pilot == 0 || application == 0)
+7
View File
@@ -387,6 +387,13 @@ class DropZone__ReplyMessage;
friend int BTPlayerExperienceSimLive(void *); // +0x25c reader (issue #2): jams / lights / powersub short path
friend int BTPlayerExperienceHeatModelOn(void *); // +0x260 reader (issue #2): FUN_004ad7d4 heat-model gate
friend void BTPlayerCountObservedDeath(void *); // observed-death tally (MP DEATHS fix)
// TARGET DESIGNATION (Gitea #48/#57): the mapper used to write the target
// RAW at `pilot + 0x284` -- the BINARY's objectiveMech offset, which on our
// compiled object is `deathPending` (the respawn latch). These bridges
// replace all three raw sites with named members.
friend void BTPilotSetObjectiveMech(void *, void *);
friend void *BTPilotObjectiveMech(void *);
friend void BTPlayerLayoutSelfCheck(); // the offset static_asserts
int
killCount; // @0x27c kills credited to this player
+53 -34
View File
@@ -81,6 +81,21 @@
extern void ToggleVoiceAssist(int voice_assist_subsystem);
extern Logical Is_Destroyed(int entity_handle);
//
// TARGET-DESIGNATION bridges (btplayer.cpp, the complete-BTPlayer TU). The three
// designation sites used to write RAW at `pilotArray[0] + 0x284` -- the BINARY's
// `objectiveMech` offset. Measured on our compiled object, 0x284 is
// **`deathPending`**, the death-cycle latch, so every designation silently
// disabled the pilot's respawn (Gitea #57) while the target never reached
// `objectiveMech` at all (Gitea #48 / input-audit finding #1). Named members
// only, from here on -- see the static_asserts in btplayer.cpp.
//
extern void BTPilotSetObjectiveMech(void *pilot, void *target_vehicle);
extern void *BTPilotObjectiveMech(void *pilot);
extern void *BTPilotVehicle(void *pilot);
extern int BTPilotPosition(void *pilot, float *out_xyz);
extern int BTVehicleDestroyed(void *vehicle);
//
// Mech::GetHorizontalFiringReach -- the reachable horizontal firing half-arc
// (radians) the mech's torso can bring its guns to bear off dead-ahead. Weapons
@@ -1337,11 +1352,9 @@ void
}
Pilot *chosen = pilotArray[index];
if (chosen != 0)
{
target = *(int *)((int)chosen + 0x1fc);
}
*(int *)((int)pilotArray[0] + 0x284) = target;
void *target_vehicle = (chosen != 0) ? BTPilotVehicle(chosen) : 0;
BTPilotSetObjectiveMech(pilotArray[0], target_vehicle);
(void)target;
}
}
@@ -1355,13 +1368,9 @@ void
{
if (pilotArrayBuilt && pilotArray[0] != 0)
{
int target = 0;
Pilot *chosen = pilotArray[page];
if (chosen != 0)
{
target = *(int *)((int)chosen + 0x1fc);
}
*(int *)((int)pilotArray[0] + 0x284) = target;
void *target_vehicle = (chosen != 0) ? BTPilotVehicle(chosen) : 0;
BTPilotSetObjectiveMech(pilotArray[0], target_vehicle);
}
}
@@ -1383,28 +1392,40 @@ void
int nearest_index = 0;
Logical none_yet = True;
//
// Positions come off each pilot's VEHICLE through the bridge (the binary's
// raw `pilot + 0x100` is an Entity-layout offset that does not exist on our
// Player). A pilot with no vehicle -- dead, or not yet acquired -- has no
// position and cannot be the nearest target, so it is skipped.
//
float self_pos[3];
if (!BTPilotPosition(pilotArray[0], self_pos))
{
Check_Fpu();
return;
}
void *self_vehicle = BTPilotVehicle(pilotArray[0]);
(void)self_id; // the binary compared raw handles
for (int i = 1; i < pilotCount; ++i)
{
int entity = *(int *)((int)pilotArray[i] + 0x1fc);
Vector3D delta;
delta.Subtract( // FUN_00408644
*(Vector3D *)((int)pilotArray[i] + 0x100),
*(Vector3D *)((int)pilotArray[0] + 0x100)
);
Scalar distance = delta.x * delta.x + delta.y * delta.y + delta.z * delta.z;
void *entity = BTPilotVehicle(pilotArray[i]);
float pos[3];
if (entity == 0 || entity == self_vehicle)
continue;
if (BTVehicleDestroyed(entity)) // the binary's Is_Destroyed(+0x1fc)
continue;
if (!BTPilotPosition(pilotArray[i], pos))
continue;
if (none_yet)
{
if (!Is_Destroyed(entity) && entity != self_id)
{
none_yet = False;
nearest_distance = distance;
nearest_index = i;
}
}
else if (!Is_Destroyed(entity) && entity != self_id
&& distance < nearest_distance)
const float dx = pos[0] - self_pos[0];
const float dy = pos[1] - self_pos[1];
const float dz = pos[2] - self_pos[2];
Scalar distance = dx * dx + dy * dy + dz * dz;
if (none_yet || distance < nearest_distance)
{
none_yet = False;
nearest_distance = distance;
nearest_index = i;
}
@@ -1413,12 +1434,10 @@ void
if (!none_yet)
{
Pilot *chosen = pilotArray[nearest_index];
if (chosen != 0)
{
target = *(int *)((int)chosen + 0x1fc);
}
*(int *)((int)pilotArray[0] + 0x284) = target;
void *target_vehicle = (chosen != 0) ? BTPilotVehicle(chosen) : 0;
BTPilotSetObjectiveMech(pilotArray[0], target_vehicle);
}
(void)target;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+92
View File
@@ -0,0 +1,92 @@
"""#48 solo reproduction: drive the COMMS panel's target-designation path.
The one comms action never exercised in any rig. Target designation (typed
t/y/u/i/o, and the Comm bank buttons 0x30-0x37) performs three RAW writes at
`pilotArray[0] + 0x284` (mechmppr.cpp:1344/:1364/:1420) intending the binary's
`objectiveMech`. Our compiled layout differs from the binary's, so that write
lands on whatever member really sits at byte 0x284 -- the ctor now prints the
real offsets, and this run hammers the designation keys while capturing frames.
Also toggles the pilot list's SELECT-TARGET highlight, which is the branch that
makes PilotList::DrawMechIcon repaint (filled box / inverted blit).
Kills only the PID it spawns.
"""
import ctypes
import os
import re
import shutil
import subprocess
import sys
import time
KEYUP = 0x0002
user32 = ctypes.windll.user32
REPO = r"C:\git\bt411"
CONTENT = os.path.join(REPO, "content")
EXE = os.path.join(REPO, "build", "Release", "btl4.exe")
SHOT = os.path.join(REPO, "scratchpad_comms.png")
LOG = os.path.join(REPO, "scratchpad_comms.log")
OUT = os.path.join(REPO, "scratchpad", "comms")
os.makedirs(OUT, exist_ok=True)
for f in (SHOT, LOG):
if os.path.exists(f):
os.remove(f)
env = dict(os.environ)
env.update({
"BT_START_INSIDE": "1",
"BT_KEY_NOFOCUS": "1",
"BT_DEV_GAUGES": "1", # cockpit surround -> BT_SHOT captures all panels
"BT_SPAWN_ENEMY": "1", # something to designate
"BT_SCORE_LOG": "1", # pilot roster + score diagnostics
"BT_SHOT": SHOT,
"BT_LOG": LOG,
})
proc = subprocess.Popen([EXE, "-egg", "LAST.EGG"], cwd=CONTENT, env=env)
print("pid", proc.pid)
def tap(ch, hold=0.10):
vk = ord(ch.upper())
user32.keybd_event(vk, 0, 0, 0)
time.sleep(hold)
user32.keybd_event(vk, 0, KEYUP, 0)
time.sleep(0.12)
saved = []
try:
deadline = time.time() + 220
while time.time() < deadline:
if os.path.exists(SHOT) and os.path.getsize(SHOT) > 20000:
break
time.sleep(2)
else:
print("FAIL: never rendered"); sys.exit(2)
time.sleep(6)
shutil.copy(SHOT, os.path.join(OUT, "00_baseline.png")); saved.append("00_baseline")
# hammer the designation keys -- each press runs the raw +0x284 write path
for rnd in range(6):
for ch in "tyuio":
tap(ch)
time.sleep(4)
n = "%02d_after_%d_designations" % (rnd + 1, (rnd + 1) * 5)
try:
shutil.copy(SHOT, os.path.join(OUT, n + ".png")); saved.append(n)
except Exception as e:
print(" copy failed:", e)
print(" round %d: 5 designations sent" % (rnd + 1))
finally:
proc.terminate()
time.sleep(1)
print("\nsaved %d frames to %s" % (len(saved), OUT))
txt = open(LOG, errors="replace").read() if os.path.exists(LOG) else ""
for pat, label in ((r"^\[layout\].*$", "LAYOUT"),
(r"^\[score\] pilot roster built.*$", "roster"),
(r"^\[target\].*$", "target")):
hits = re.findall(pat, txt, re.M)
print("--- %s (%d):" % (label, len(hits)))
for h in hits[:3]:
print(" ", h[:150])
+94
View File
@@ -0,0 +1,94 @@
"""#48: contact-sheet gallery of every gauge bitmap, so the artifact can be
identified by eye instead of by theory.
Page 0 = CANDIDATES: small, mostly-solid images (the artifacts are featureless
blocks/bars), sorted most-solid first -- the shapes worth checking.
Pages 1+= the complete set, name-sorted, for manual browsing.
Each tile is labelled with its filename and native pixel size. Mono-MFD art is
stored paletted and gets tinted green at draw time, so judge SHAPE and SIZE, not
colour.
"""
import os
from PIL import Image, ImageDraw
SRC = r"C:\git\bt411\content\GAUGE"
OUT = r"C:\git\bt411\scratchpad\gallery"
os.makedirs(OUT, exist_ok=True)
COLS, ROWS = 10, 8
IMGW, IMGH = 186, 126
LBL = 26
TILEW, TILEH = IMGW, IMGH + LBL
names = sorted(f for f in os.listdir(SRC) if f.upper().endswith((".PCC", ".PCX")))
loaded = []
for n in names:
try:
im = Image.open(os.path.join(SRC, n)).convert("RGB")
loaded.append((n, im))
except Exception:
pass
print("decoded %d of %d" % (len(loaded), len(names)))
def solidity(im):
"""fraction of pixels that are not background-black"""
small = im.resize((min(im.size[0], 64), min(im.size[1], 64)))
px = list(small.getdata())
nz = sum(1 for r, g, b in px if r + g + b > 40)
return nz / float(len(px))
def tile_for(name, im):
t = Image.new("RGB", (TILEW, TILEH), (18, 18, 18))
w, h = im.size
s = min(float(IMGW - 6) / w, float(IMGH - 6) / h)
if s > 6:
s = 6 # do not blow tiny art up beyond 6x
d = im.resize((max(1, int(w * s)), max(1, int(h * s))), Image.NEAREST)
t.paste(d, ((TILEW - d.size[0]) // 2, (IMGH - d.size[1]) // 2))
dr = ImageDraw.Draw(t)
dr.rectangle([0, 0, TILEW - 1, TILEH - 1], outline=(60, 60, 60))
dr.text((4, IMGH + 2), "%s" % name, fill=(210, 210, 210))
dr.text((4, IMGH + 13), "%dx%d" % (w, h), fill=(130, 170, 130))
return t
def make_page(items, path, title):
pw, ph = COLS * TILEW, ROWS * TILEH + 30
page = Image.new("RGB", (pw, ph), (8, 8, 8))
dr = ImageDraw.Draw(page)
dr.text((8, 9), title, fill=(255, 235, 120))
for i, (n, im) in enumerate(items):
r, c = divmod(i, COLS)
page.paste(tile_for(n, im), (c * TILEW, 30 + r * TILEH))
page.save(path)
return path
# ---- page 0: candidates (small + solid = featureless block/bar shapes) -------
cands = []
for n, im in loaded:
w, h = im.size
if w <= 80 and h <= 80: # the artifacts were ~12x54 / ~18x30
cands.append((solidity(im), n, im))
cands.sort(reverse=True, key=lambda t: t[0])
top = [(n, im) for _s, n, im in cands[:COLS * ROWS]]
pages = [make_page(top, os.path.join(OUT, "page0_CANDIDATES.png"),
"PAGE 0 -- BEST CANDIDATES: small (<=80px) and mostly SOLID, "
"most-solid first. Artifacts were ~12x54 and ~18x30 native.")]
# ---- pages 1+: everything, name-sorted --------------------------------------
per = COLS * ROWS
for p in range((len(loaded) + per - 1) // per):
chunk = loaded[p * per:(p + 1) * per]
pages.append(make_page(
chunk, os.path.join(OUT, "page%d.png" % (p + 1)),
"PAGE %d/%d -- all gauge art, name-sorted (%s .. %s)"
% (p + 1, (len(loaded) + per - 1) // per, chunk[0][0], chunk[-1][0])))
print("wrote %d pages to %s" % (len(pages), OUT))
for p in pages:
print(" " + os.path.basename(p))
+93
View File
@@ -0,0 +1,93 @@
"""#48 solo test: does moving a coolant VALVE after the coolant BARS have
repainted leave a stuck XOR block on the Heat panel?
Hypothesis (from operator_1.log): VertNormalSlider paints its indicator with an
XOR fill and "erases" by XORing the same spot. Firing drops CoolantMass, which
repaints the coolant vertBars over the valve track; a later valve move then XORs
pixels that are no longer the indicator -> a permanently stuck block.
Sequence: autofire (coolant falls) + periodic 'C' valve presses, capturing the
composite each cycle, then diff the Heat panel region for pixels that turn ON and
STAY on.
Kills only the PID it spawns.
"""
import ctypes
import os
import shutil
import subprocess
import sys
import time
KEYUP = 0x0002
VK_C = ord('C')
user32 = ctypes.windll.user32
REPO = r"C:\git\bt411"
CONTENT = os.path.join(REPO, "content")
EXE = os.path.join(REPO, "build", "Release", "btl4.exe")
SHOT = os.path.join(REPO, "scratchpad_valve.png")
LOG = os.path.join(REPO, "scratchpad_valve.log")
OUT = os.path.join(REPO, "scratchpad", "valve")
os.makedirs(OUT, exist_ok=True)
for f in (SHOT, LOG):
if os.path.exists(f):
os.remove(f)
env = dict(os.environ)
env.update({
"BT_START_INSIDE": "1",
"BT_KEY_NOFOCUS": "1",
"BT_DEV_GAUGES": "1", # cockpit surround -> BT_SHOT captures the panels
"BT_AUTOFIRE": "1", # hold the trigger: coolant mass falls, bars repaint
"BT_SHOT": SHOT,
"BT_LOG": LOG,
})
proc = subprocess.Popen([EXE, "-egg", "LAST.EGG"], cwd=CONTENT, env=env)
print("pid", proc.pid)
def tap(vk, hold=0.12):
user32.keybd_event(vk, 0, 0, 0)
time.sleep(hold)
user32.keybd_event(vk, 0, KEYUP, 0)
frames = []
try:
# wait for the mission (the shot file only appears once rendering starts)
deadline = time.time() + 220
while time.time() < deadline:
if os.path.exists(SHOT) and os.path.getsize(SHOT) > 20000:
break
time.sleep(2)
else:
print("FAIL: never rendered"); sys.exit(2)
time.sleep(6)
for cycle in range(6):
# let autofire burn coolant so the bars repaint
time.sleep(7)
dst = os.path.join(OUT, "f%d_before_valve.png" % cycle)
try:
shutil.copy(SHOT, dst); frames.append(dst)
except Exception as e:
print(" copy failed:", e)
# move a coolant valve -> XOR erase at the old row
tap(VK_C)
print(" cycle %d: valve tap sent" % cycle)
time.sleep(4)
dst = os.path.join(OUT, "f%d_after_valve.png" % cycle)
try:
shutil.copy(SHOT, dst); frames.append(dst)
except Exception as e:
print(" copy failed:", e)
finally:
proc.terminate()
time.sleep(1)
print("\ncaptured %d frames in %s" % (len(frames), OUT))
txt = open(LOG, errors="replace").read() if os.path.exists(LOG) else ""
import re
print("[valve] events :", len(re.findall(r"^\[valve\]", txt, re.M)))
for l in re.findall(r"^\[valve\].*$", txt, re.M)[:8]:
print(" ", l)
print("[emitter] FIRED:", len(re.findall(r"\[emitter\] FIRED", txt)))