diff --git a/game/reconstructed/btplayer.cpp b/game/reconstructed/btplayer.cpp index 3490e3c..7defb72 100644 --- a/game/reconstructed/btplayer.cpp +++ b/game/reconstructed/btplayer.cpp @@ -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) diff --git a/game/reconstructed/btplayer.hpp b/game/reconstructed/btplayer.hpp index 0e3bbe5..968506c 100644 --- a/game/reconstructed/btplayer.hpp +++ b/game/reconstructed/btplayer.hpp @@ -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 diff --git a/game/reconstructed/mechmppr.cpp b/game/reconstructed/mechmppr.cpp index 2a65d2a..a09b50a 100644 --- a/game/reconstructed/mechmppr.cpp +++ b/game/reconstructed/mechmppr.cpp @@ -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; } //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/scratchpad/commstest.py b/scratchpad/commstest.py new file mode 100644 index 0000000..a74c13d --- /dev/null +++ b/scratchpad/commstest.py @@ -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]) diff --git a/scratchpad/lampgallery.py b/scratchpad/lampgallery.py new file mode 100644 index 0000000..90a9a8f --- /dev/null +++ b/scratchpad/lampgallery.py @@ -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)) diff --git a/scratchpad/valvetest.py b/scratchpad/valvetest.py new file mode 100644 index 0000000..6f08a69 --- /dev/null +++ b/scratchpad/valvetest.py @@ -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)))