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:
co-authored by
Claude Opus 5
parent
cb50a1d491
commit
2dd4ee3910
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -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])
|
||||
@@ -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))
|
||||
@@ -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)))
|
||||
Reference in New Issue
Block a user