Files
BT411/scratchpad/night6/AGENT_FINDINGS.md
T

427 KiB
Raw Blame History

Agent findings, 2026-07-29/30 field triage (raw dump from workflow journals)

Preserved verbatim so nothing depends on session state or journal files.

run wf_02c59115-f37 -- 2 completed agent(s)

{
 "route": "C — constants and data",
 "found": true,
 "addresses": [
  "0x0042f16c — DropZone::DropZone (ctor). Exported as FUN_0042f16c but unlabelled. Does UseGroup(\"DropZones\") + Add(this), Str_Copy(dropZoneName), MemoryStream(msg, msgLen, 0x70), dropZoneCount = msg[+0x6C], then allocates the FOUR parallel slot arrays and inits lastDeathCount[i] = -3 (0xfffffffd).",
  "0x0042f2f8 — DropZone::Make. operator new(0x1F8) => sizeof(DropZone) = 0x1F8.",
  "0x0042f328 — DropZone::~DropZone. Frees the four arrays; also vtable slot +0x00 at 0x004e6c60.",
  "0x0042f38c — DropZone::IsAvailable(int index). EXPORTED but unlabelled. Recovered exactly: if (lastUsageTime[i].ticks == 0) return 1; if ((Now-last)/ticksPerSec <= 5.0f) return 0; if (app->applicationState == 5 /*RunningMission*/) return 1; return 0;",
  "0x0042f400 — float literal 5.0f = DOWN_TIME. EXACTLY ONE xref (the fcomp at 0x0042f3d2 inside IsAvailable). This is BT's confirmed 5-second slot cooldown. [T1]",
  "0x0042f404 — DropZone::AssignDropZoneMessageHandler. **UNEXPORTED / RECOVERED** (the decomp index jumps 0x42f38c+114=0x42f3fe straight to 0x42f9bc). Self-labelled by the binary: the HandlerEntry at 0x004e6bc0+0x1C points here and +0x18 points at the string \"AssignDropZone\".",
  "0x0042f80c — float literal 4.0f. TWO xrefs with TWO DIFFERENT MEANINGS: (a) fcomp at 0x0042f5e4 = the proximity test LengthSquared() <= 4.0f (a 2 m radius); (b) fld at 0x0042f647 = the compiler-CONSTANT-FOLDED `DOWN_TIME - 1.0f` (5.0-1.0) in `lastUsageTime[i] -= DOWN_TIME - 1.0f`. This is the Route-C false-positive trap and the reason I disassembled instead of value-scanning.",
  "0x0042f810 — float literal 0.5f. NOT a game constant: it is the rounding addend of the inlined seconds->ticks conversion (int)(sec*ticksPerSec + 0.5). Two xrefs, both that idiom.",
  "0x0042f814 — float literal 0.1f = the repost delay. One xref, at 0x0042f73b, in the no-slot path.",
  "0x0042f731..0x0042f783 — THE GHOST PATH, confirmed present in BT [T1]: when drop_zone == NULL it computes when = Now() + 0.1s and calls Post(priority=4 /*MaxEventPriority*/, this, message, when) via app+0x60, then jumps to the shared epilogue at 0x0042f804 WITHOUT dispatching any reply. Identical defect to WinTesla.",
  "0x0042f785 — Found_One: builds the 0x3C-byte ReplyMessage (dropZoneLocation at +0x1C, deathCount at +0x38) and calls Entity::Dispatch via vtable+0x0C on the requesting entity.",
  "0x0042f818 — DropZone::CreateMakeMessage. **UNEXPORTED / RECOVERED**. Sets classToCreate = 0x30 (DropZoneClassID = 48), instanceFlags = 0xA00 (MapFlag|TrappedFlag), calls MakeEntryList(model_name, \"dropzone\") @0x00404720 then EntryCount() @0x00403b60, stores the result to msg+0x6C, and sets messageLength = 0x70 + 28*count. Errors with \"Error: <model> hasn't specified any drop zones!\" and returns False when count == 0.",
  "0x0042fa79 / 0x0042fa81 — the vector-new element-ctor thunks for the Origin[] and Time[] arrays (passed to 0x004de998).",
  "0x004e6c60 — DropZone vtable. 13 slots; only +0x00 (dtor) is DropZone's own, everything else is inherited Entity. Proves the recovered function set is COMPLETE.",
  "0x004e6bc0 — DropZone::DefaultData / SharedData. +0x14 = 0x15 (AssignDropZoneMessageID = 21), +0x18 = 0x004e6bf9 (\"AssignDropZone\"), +0x1C = 0x0042f404 (the handler). The binary self-labels the recovery.",
  "0x0042db80 — BT Player::VehicleDeadMessageHandler (the REQUESTER, in the export as FUN_0042db80, munga/player.cpp). Sends msgLen 0x2C / msgID 0x15. Confirms Entity::Message = 28 bytes and AssignDropZoneMessage{+0x1C requestingEntity(EntityID,8), +0x24 replyMessageID, +0x28 deathCount}.",
  "0x004d3f44 — the WIN-PAD victory teleport (btl4mssn area). Iterates the DropZones group matching dropZoneName (+0x1C8) against a patched \"win<N>\" pattern and calls Mech::Reset(dz->localOrigin) @0x0049fb74. Explains the win1..win8 1-slot DropZones and independently re-confirms +0x1C8.",
  "0x004d2c30 — btl4mssn.cpp pilot-block parser: reads the egg's `dropzone=` NAME (string \"dropzone\" @0x0051e5dc) into missionPlayer+0x28, aborting with \"Error: dropzone not specified!\" (@0x0051e5e5).",
  "0x0052140c — ticksPerSecond (runtime-initialised global, 0 in the file image). fdiv'd in IsAvailable, fmul'd in both tick conversions. Written at 0x00401ace (0x41E00000 = 28.0f) or 0x00401ada (0x4191A6E0 = 18.20648f, the DOS PIT rate) on a startup branch. Does not affect the 5.0 s semantics, only Time resolution.",
  "0x00414b60 = Now() -> Time*; 0x004dcd94 = ftol/round; 0x0040807c = Random(int); 0x00403ad0 = EntityManager::FindGroup; 0x004212b0 = EntityManager::UseGroup; 0x00421414/0x00421452 = ChainIterator ctor/dtor; 0x0042104c = EntityID::operator==; 0x00420ef4 = EntityID::operator=; 0x00420ebc = EntityID::EntityID(int); 0x00408644 = Vector3D::Subtract; 0x0041acbc = Application::Post; 0x004de998 = vector-new-with-ctor."
 ],
 "howVerified": "METHOD (deliberately avoiding the value-scan trap): I never byte-scanned for float values. I first located the class STRUCTURALLY via string xrefs (\"DropZones\" @0x4e6c08 -> the ctor; \"dropzone\" @0x4e6c1a + \" hasn't specified any drop zones!\" @0x4e6c2b -> CreateMakeMessage), then disassembled the un-exported gap with capstone and read the constants as the ABSOLUTE MEMORY OPERANDS of decoded fcomp/fld/fdiv/fmul instructions. A call-rel32 byte sequence or a coefficient table can therefore never be mistaken for a constant: every value I report is the operand of an FPU instruction inside a function whose identity was established independently of that value.\n\nRULING OUT FALSE POSITIVES, specifically:\n(1) XREF COUNTING proves single purpose. 0x42f400 (5.0f) has EXACTLY ONE xref in the whole image, the fcomp in IsAvailable -> it is DOWN_TIME and nothing else. 0x42f810 (0.5f) has two xrefs, both the (int)(x*tps+0.5) rounding idiom -> it is NOT a game constant and I do not report it as one. 0x42f814 (0.1f) has exactly one.\n(2) THE 4.0 TRAP, caught and separated. 0x42f80c has exactly TWO xrefs, both inside AssignDropZoneMessageHandler, with UNRELATED semantics: fcomp at 0x42f5e4 is `LengthSquared() <= 4.0f` (the 2 m proximity test), while fld at 0x42f647 begins `fld 4.0 / fmul ticksPerSec / fadd 0.5 / call ftol / sub [lastUsageTime[i]]` — i.e. the Borland compiler constant-folded `DOWN_TIME - 1.0f` (5.0-1.0) and REUSED the same literal. A value-scan for \"4.0\" would have conflated one constant into a single wrong meaning; only instruction-level context separates them. This also independently corroborates DOWN_TIME == 5.0.\n(3) SELF-LABELLING GROUND TRUTH. The strongest check: DropZone::DefaultData at 0x004e6bc0 holds the MessageHandlerSet entry {+0x14 = 21 = AssignDropZoneMessageID, +0x18 = ptr to the literal string \"AssignDropZone\", +0x1C = 0x0042f404}. The binary itself names the function at the exact address I had identified from the prologue after the 5.0f literal. No inference required.\n(4) COMPLETENESS. The vtable at 0x004e6c60 has 13 slots and only +0x00 (the dtor) is DropZone's own; every other slot is an inherited Entity method. So the six functions I recovered are the entire class — there is no other code that could resize the pool.\n(5) LAYOUT CONSISTENCY, six independent confirmations of dropZoneName at +0x1C8: the ctor's Str_Copy target (param_1+0x72), the two strncmp(...,\"win\",3) sites in FUN_0042db80 (0x42dbf7, 0x42dc5b, both `lea ecx,[ebx+0x1c8]`), the win-pad matcher at 0x004d3f44 (iVar5+0x1c8), and the MakeMessage's +0x4C source field. sizeof(Origin) == 28 is confirmed FOUR independent ways: the ctor's 0x1C element size, the `shl 3 / sub / shl 2` (=28i) index math at three sites, the messageLength formula 0x70+28n in CreateMakeMessage, and the 28-byte field-by-field copy in the reply builder.\n(6) DATA CENSUS used a 3-WAY SELF-CONSISTENCY INVARIANT, not a byte pattern: a record qualifies only if classToCreate(+0x1C)==0x30 AND instanceFlags(+0x2C)==0xA00 AND messageLength(+0x00)==0x70+28*dropZoneCount(+0x6C). Chance agreement on all three is negligible; 78 records matched across BTL4.RES and every one lies inside a type-14 MakeMessageStream resource, never in bitmap or geometry data.\n(7) GEOMETRIC VALIDATION (the decisive one). Decoding the slot arrays yields, for all 78 records, quaternions of EXACTLY unit norm (|q| = 1.0000), a CONSTANT ground Y per map (cavern -0.35, polar3 +75.00, rav 0.00), pure-yaw rotations (x=z=0, correct for pads on flat ground), and mirror-symmetric hand-authored layouts in rav (+/-456.83, +/-448.33). A wrong stride or a misread count field would produce non-unit quaternions and a wandering Y. The layout is certain.\n(8) SELF-CAUGHT FALSE NEGATIVE: my first census stepped 4 bytes from offset 0 and missed the `adrop` resource (offset 2982581, == 1 mod 4). I re-ran byte-aligned, which raised the count from 56 to 78. Reported numbers are from the byte-aligned pass.\n(9) Application state gate cross-checked against our engine: BT compares app+0x88 == 5, and engine/MUNGA/APP.h:188-194 puts RunningMission at ordinal 5. Agreement, no divergence.",
 "notes": "ANSWER TO THE ROUTE-C QUESTIONS\n\n1) BT's constants are IDENTICAL to WinTesla's [T1]. DOWN_TIME = 5.0f (literal 0x0042f400, single xref). Proximity test = LengthSquared() <= 4.0f, i.e. 2 m (literal 0x0042f80c, fcomp at 0x42f5e4). Repost delay = 0.1f at MaxEventPriority = 4. Proximity-blocked slots are penalised by the constant-folded DOWN_TIME-1.0f = 4.0f, i.e. marked busy for only 1 more second (WinTesla's \"3 seconds\" comment is stale in BT too). No BT-specific tuning divergence exists on the engine side.\n\n2) WHERE THE SLOT POOL LIVES. DropZone object, sizeof 0x1F8:\n   +0x100 localOrigin (Entity base)   +0x184 entityID (Entity base, 8 B)\n   +0x1C4 int   dropZoneCount\n   +0x1C8 char  dropZoneName[32]\n   +0x1E8 Origin*   dropZones       (stride 28: Point3D 12 + UnitQuaternion 16)\n   +0x1EC Time*     lastUsageTime   (stride 4,  init Time::Null)\n   +0x1F0 EntityID* lastUsedBy      (stride 8,  init EntityID::Null)\n   +0x1F4 int*      lastDeathCount  (stride 4,  init -3)\n   Exactly WinTesla's \"slot count int + parallel arrays of Origin/Time/EntityID/int\", four separate heap allocations sized at construction. DropZone::MakeMessage (sizeof 0x70): +0x1C classToCreate=0x30 (DropZoneClassID = 48, a NEW [T1] fact not in decomp-reference.md), +0x2C instanceFlags=0xA00, +0x30 origin(28), +0x4C dropZoneName[32], +0x6C dropZoneCount, then count x Origin(28).\n\n3) HOW MANY SLOTS: NOT a constant, NOT a fixed array — it is PER-MAP, PER-DROPZONE-ENTITY data. CreateMakeMessage @0x42f818 counts the `dropzone` entries in the map's notation block at MAP-BUILD time and bakes count + the Origins into the map's type-14 MakeMessageStream in BTL4.RES; the runtime ctor just replays it. Full census (78 records, every type-14 stream):\n   adrop (include fragment) : two=4 three=4 four=4 five=4\n   arenall / arenall2       : one=10  + win1..win8\n   cavern / dbase / grass   : one=8 two=8 three=8 four=8 five=8  + win1\n   polar3                   : one=9 two=9 three=8 four=8        + win1..win8   (NO \"five\")\n   polar4                   : one=9 two=4 three=4 four=4 five=4 + win1..win8\n   rav                      : one=8 two=4 three=4 four=4 five=4 + win1..win8\n   win1..win8 are 1-slot marker zones used by the end-of-match victory teleport (0x004d3f44), not for respawn.\n\n4) THE CONTENT-SIDE SUSPICION IS LARGELY REFUTED, WITH A TWIST. tools/eggmodel.py:42 (`DROPZONES = [\"one\"]  # only confirmed-valid name`) has the wrong REASON but accidentally the safest VALUE. \"one\" is not the only valid name — one..five are all embedded in map streams (docs/GLASS_COCKPIT.md:349 already widened this; my census now makes it authoritative and per-map). But \"one\" IS the only name valid on EVERY shipped map: arena1/arena2 resolve through `arenall`, which defines ONLY \"one\", and polar3 has no \"five\". So `dropzone=` needs PER-MAP validation, and eggmodel.py:42's comment should be corrected to say so rather than claiming one is the only valid name.\n   Crucially, DropZone \"one\" is an 8-, 9- or 10-SLOT pool on every map, not a single slot. Cooldown saturation would need >8 deaths inside 5 s; with 5 players that is not credible. So `dropzone=one` for all pods is a lost-spread issue (one 8-10 slot pool instead of spreading over 5 pools = 24-40 slots), NOT the ghost's proximate cause.\n\n5) THE DATA-SIDE MECHANISM ROUTE C ACTUALLY EXPOSES (hand this to whoever owns the fix). IsAvailable is a three-way test, and the third clause is the hazard:\n     ticks == 0                                  -> available (never used)\n     (Now-last)/tps <= 5.0                       -> BUSY (cooldown)\n     cooled down AND appState == RunningMission   -> available\n     cooled down AND appState != RunningMission   -> BUSY\n   The RunningMission conjunct applies ONLY to previously-used slots. Therefore: once every slot in a pool has been used at least once — guaranteed after 8-10 respawns in a 5-player match — grantability becomes wholly conditional on appState == 5. Any excursion out of RunningMission (round transition, mission end, suspend/resume, abort) makes EVERY slot permanently unavailable, and every pending request then reposts to itself at 10 Hz forever with no reply. That is a permanent ghost, and it explains the field observation that it \"self-corrects if the stall eventually clears\" — recovery happens exactly when appState returns to 5. It is also consistent with the observed player-count/death-rate dependence: more deaths burn through the never-used slots faster, after which the pool is entirely appState-gated. [T1 for the code; T4 for this being THE field cause — it needs an appState trace to confirm.]\n   Secondary, lesser hazards, both authentic: on a proximity block the code does `lastUsedBy[i] = NULL` (0x42f5f3-0x42f618), destroying the record of who actually holds the slot, which defeats the first-loop resend path for that slot; and the proximity scan skips only `player != entity`, so a mech parked on a pad blocks it repeatedly.\n\n6) BONUS FINDING, worth a look by the route that owns the caller. BT's VehicleDeadMessageHandler @0x0042db80 does NOT match a DropZone by name. It skips entities whose dropZoneName starts with \"win\" (strncmp at 0x42dbf7 / 0x42dc5b) and otherwise picks the GEOMETRICALLY CLOSEST remaining DropZone to the player's position, then caches that entity's EntityID in the message (msg+0x20) so later deaths reuse it. The task brief's premise \"hunts the closest DropZone entity by name\" is half right: closest by distance, filtered only by the \"win\" prefix. Which means the egg's `dropzone=` name (parsed into missionPlayer+0x28 at 0x004d2c30) does NOT select the respawn pool on the death path — so changing `dropzone=` per pod may not spread respawn load at all. Open question for the caller route: where, if anywhere, missionPlayer+0x28 reaches drop-zone selection. [T1 for the code read; the \"dropzone= is inert on the death path\" implication is T3 until the initial-spawn path is traced.]\n\n7) KB CORRECTIONS this turn implies (not applied — I am one of several parallel routes and did not want to conflict on shared context files): add DropZoneClassID = 48/0x30 and the DropZone layout table to context/decomp-reference.md; record that munga/dropzone.cpp's gap at 0x0042f3fe-0x0042f9bc is now RECOVERED (2 functions) and should be added to the Ghidra export like the 0x4c05c4 gap noted in docs/KD_SCOREBOARD_PLAN.md:92; fix the tools/eggmodel.py:42 comment; and note in context/multiplayer.md:633 (\"4 DISTINCT spawn positions\") that the pool is 8-10 on \"one\", not 4 — the =4 counts belong to the secondary zones two..five supplied by the `adrop` fragment. context/multiplayer.md:757 (\"`adrop` (id 1994) ... the map's four DropZone make [messages]\") is CORRECT and now quantified: 4 messages, named two/three/four/five, 4 slots each, 900 bytes = 4-byte count header + 4 x 224.\n\nScratchpad harness (reusable): .../scratchpad/dz/pe.py (PE VA<->offset, f32/u32/cstr readers), dzdis.py (windowed capstone disassembly with float/string operand annotation), xref.py (absolute-VA xref scan)."
}
{
 "route": "A — via the \"DropZones\" group-name string",
 "found": true,
 "addresses": [
  "0x004e6924 = \"DropZones\" literal copy #1 (player.cpp pool) — 1 ref",
  "0x004e6936 = \"DropZones\" literal copy #2 (player.cpp pool) — 1 ref",
  "0x004e6c08 = \"DropZones\" literal copy #3 (dropzone.cpp pool) — 1 ref",
  "0x0051f19a = \"DropZones\" literal copy #4 (BT/L4 pool) — 1 ref",
  "0x0042dbd2 push \"DropZones\" — inside FUN_0042db80 = Player::VehicleDeadMessageHandler (msgid 0x17), closest-zone hunt, EXPORTED (part_003.c:12029)",
  "0x0042dde4 push \"DropZones\" — inside UN-EXPORTED fn at 0x0042ddcc = Player-side exact-NAME zone lookup + AssignDropZone send",
  "0x0042f1d8 push \"DropZones\" — inside FUN_0042f16c = DropZone::DropZone ctor, self-registers into the group, EXPORTED (part_004.c:489)",
  "0x004d3f82 push \"DropZones\" — inside FUN_004d3f44 = BT-side victory/podium placement (\"win?\"->\"win<n>\"), EXPORTED (part_014.c:8677)",
  "0x0042f16c = DropZone::DropZone (ctor); sizeof=0x1f8; allocates the 4 parallel slot arrays [EXPORTED]",
  "0x0042f2f8 = DropZone::Create / allocator (new 0x1f8 -> ctor) [EXPORTED]",
  "0x0042f328 = DropZone::~DropZone (vtable slot 0); frees the 4 slot arrays [EXPORTED]",
  "0x0042f38c = DropZone::IsAvailable(this, i) [EXPORTED, previously unrecognised]",
  "0x0042f404 = DropZone::AssignDropZoneMessageHandler ***UN-EXPORTED, RECOVERED*** (the ghost-mech repost site)",
  "0x0042f818 = DropZone definition/stream reader (parses the \"dropzone\" slot list) ***UN-EXPORTED, RECOVERED*** — NOTE: zero references anywhere in the binary",
  "0x0042f9bc = DropZone class-name/RTTI thunk [EXPORTED]",
  "0x0042f9d8 = DropZone class static registration (RegisterClass + msg-table install) ***UN-EXPORTED, RECOVERED***",
  "0x0042fa3c = DropZone class static teardown ***UN-EXPORTED***",
  "0x0042f121 / 0x0042f140 = AssignDropZoneMessage ctor/dtor (vtable 0x004e6b44) [EXPORTED]",
  "0x0042fa79 / 0x0042fa81 = array-element ctor thunks passed to the array-new helper [EXPORTED]",
  "0x0042ddcc = UN-EXPORTED Player-side named-DropZone request (sends class-0x15 AssignDropZone, reposts VehicleDead at +2.0 s)",
  "0x0042da4c = base Player::DropZoneReplyMessageHandler — the assert stub (\"Drop zone reply should not be handled by base player class!\")",
  "0x004bffd0 = BTPlayer::DropZoneReplyMessageHandler — the real subclass reply handler that clears deathPending",
  "0x0042db80 = Player::VehicleDeadMessageHandler (msgid 0x17)",
  "0x004d3f44 = BT-side per-player podium placement via zones \"win1\"..\"winN\"",
  "0x004e6bd4 = DropZone message table: single entry {msgid 0x15, \"AssignDropZone\", 0x0042f404}",
  "0x004e6bc0 = DropZone class descriptor (runtime-filled by 0x0042f9d8; [+0x10]=0x0042f2f8 factory)",
  "0x004e6c60 = DropZone vtable (13 slots; [0]=0x0042f328 dtor; no DropZone-specific overrides -> IsAvailable is non-virtual)",
  "0x004e6790 = base Player message table, stride 20: {0x15,\"DropZoneReply\",0x42da4c} {0x16,\"Score\",0x42da20} {0x17,\"VehicleDead\",0x42db80} {0x18,\"MissionStarting\",0x42d9c0} {0x19,\"MissionEnding\",0x42d9e0}",
  "0x00512fd8 = BTPlayer message table entry {0x15,\"DropZoneReply\",0x004bffd0}",
  "0x004e6740 = Player ATTRIBUTE table (stride 16): {0x1cd,0,7,\"DropZoneLocation\"} {0x1d1,0,9,\"StatusMessagePointer\"}",
  "0x0042f400 = 5.0f  DOWN_TIME (slot busy window)",
  "0x0042f80c = 4.0f  proximity radius SQUARED = (2 m)^2",
  "0x0042f810 = 0.5f  float->int rounding addend",
  "0x0042f814 = 0.1f  no-slot repost delay",
  "0x0042df84 = 2.0f  Player-side VehicleDead retry delay",
  "0x0051ee64 = \"win?\" template, byte[3] patched to '1'+i by 0x004d3f44",
  "0x004e692e / 0x004e6932 = \"win\" (3-char prefix excluded from the respawn hunt)",
  "0x004e6c12 = \"Players\" (group scanned for the 2 m proximity test)",
  "0x0040807c = UniformRand(state, modulus) — rejection-sampled, used to pick the slot",
  "0x0041acbc = Post(queue, priority, sender, message, time)",
  "0x0042104c = 8-byte handle equality (so slot holder at +0x1f0 is a handle, not a string)"
 ],
 "howVerified": "Five independent filters, applied because call-rel32 bytes and coefficient tables have produced false positives here before.\n\n(1) PE .reloc cross-check — the decisive filter. Parsed DIRECTORY_ENTRY_BASERELOC and built the set of file offsets carrying IMAGE_REL_BASED_HIGHLOW (32-bit absolute) fixups: 27019 entries. Every one of the 15 string-address hits sits at an offset present in that set. A call-rel32 displacement is a self-relative operand and is NEVER relocated; a float/coefficient table is not relocated either. So relocation presence deterministically excludes both false-positive classes. No hit was accepted without a reloc entry.\n\n(2) Instruction-encoding check — every CODE hit's immediately preceding byte is 0x68 (push imm32), i.e. the 4 bytes are a real operand, not a straddling alignment coincidence. Confirmed by capstone linear disassembly (CS_MODE_32) started from a verified `push ebp; mov ebp, esp` prologue, not from the middle of the hit, so instruction boundaries are sound.\n\n(3) Table-interpretation prediction — the DATA-side hits were only accepted after the surrounding structure decoded self-consistently AND predicted other strings correctly. Reading 0x004e6790 as stride-20 {msgid, name, handler, pad, pad} yields 0x15/\"DropZoneReply\"/0x42da4c, 0x16/\"Score\"/0x42da20, 0x17/\"VehicleDead\"/0x42db80, 0x18/\"MissionStarting\"/0x42d9c0, 0x19/\"MissionEnding\"/0x42d9e0 — five consecutive ascending msgids each with a valid CODE handler. A wrong stride cannot produce that. Same for 0x004e6bd4.\n\n(4) Independent triangulation of the layout. DropZone::name at this+0x1c8 falls out of THREE unrelated sites: the strcmp at 0x0042de14 (ebx+0x1c8), the `piVar3 + 0x72` dword index in exported FUN_0042db80, and the strcmp at 0x004d3fea in exported FUN_004d3f44. Slot count at +0x1c4 likewise: the ctor reads param_2[0x1b] (=+0x6c) and the recovered reader at 0x0042f818 writes ebx+0x6c. The message field offsets used by the recovered handler (msg+0x1c handle, +0x24, +0x28 token) match exactly what the two senders write into the {0x2c, 0x15, 1} message — 0x0042db80 (exported decomp) and 0x0042ddcc (recovered). Recovered code and exported code agree at every seam.\n\n(5) Constants read as raw dwords and float-decoded, not inferred: 5.0 / 4.0 / 0.5 / 0.1 / 2.0 — all sane magnitudes, and 5.0 independently equals WinTesla's DOWN_TIME, which is corroboration rather than an assumption.\n\nTU attribution: the literal pool 0x004e6c08-0x004e6c60 is contiguous and single-purpose (\"DropZones\", \"Players\", \"dropzone\", \"Error: \", \" hasn't specified any drop zones!\\n\", \"%f %f %f %f %f %f\") and the code range 0x0042f121-0x0042fa78 is contiguous — the signature of one translation unit, i.e. the missing munga/dropzone.cpp.\n\nGap identification: functions_index.tsv start+size arithmetic exposed the holes — 42db80+580=0x42ddc4 vs next index 42df8c; 42f38c+114=0x42f3fe vs next index 42f9bc; 42f9bc+27=0x42f9d7 vs next index 42fa79. Prologue scan (55 8b ec) inside each hole found exactly 0x42ddcc, 0x42f404, 0x42f818, 0x42f9d8 — and 0x0042f404 is precisely the handler pointer stored in the message table at 0x004e6bdc, so the recovered entry point is confirmed by the binary's own dispatch data, not by my scan alone.\n\nCaller enumeration used both absolute-dword search over the whole file and a full call/jmp rel32 sweep of CODE (displacement arithmetic src+5+disp==target).",
 "notes": "THE GHOST-MECH BUG IS CONFIRMED IN THE 1995 BINARY [T1], not merely in WinTesla. DropZone::AssignDropZoneMessageHandler = 0x0042f404, recovered in full (0x0042f404-0x0042f80a). Structure:\n\nPhase 1 (0x42f43b): re-grant loop. For each slot i, if !IsAvailable(i) && slotHolder[i]==msg->handle(+0x1c) && slotToken[i]==msg->token(+0x28) -> refresh lastUsed[i]=Now(), return that slot. So a repeat request from a player who already holds a busy slot is idempotent.\nPhase 2 (0x42f4ba): count available slots, track min/max available index -> range.\nPhase 3 (0x42f50f): if availCount==0 -> jump straight to the no-slot path.\nPhase 4 (0x42f51a): pick i = lo + UniformRand(range) (0x0040807c). If gamestate(app+0x88)==5, scan the \"Players\" group; for any OTHER player's vehicle (p+0x1fc, skipping the requester) whose position (veh+0x100) is within squared distance 4.0 (= 2 m) of the slot, mark the slot taken, back-date lastUsed[i] by 4.0 s (so with DOWN_TIME 5.0 it frees again in ~1 s), decrement avail and retry. Otherwise commit: lastUsed[i]=Now(), slotHolder[i]=msg->handle, slotToken[i]=msg->token.\nPhase 5 (0x42f72a): IF NO SLOT WAS OBTAINED -> Post(app->queue(+0x60), priority 4, this, SAME message, Now() + 0.1 s) and RETURN WITHOUT REPLYING. The reply is only ever sent at 0x42f785 via replyTarget->vtbl[+0xc]. So the handler reposts to itself at 10 Hz forever and the requester never receives a DropZoneLocation -> deathPending never clears -> ghost mech. Exactly the field symptom (a death-cycle START with no matching RESET).\n\nDivergence vs the WinTesla mechanism in the brief: BT reposts at priority 4, not MaxEventPriority. Otherwise identical in kind. Also note the proximity test and the DOWN_TIME test are BOTH gated on gamestate==5; IsAvailable (0x0042f38c) reads `if (lastUsed[i]!=0 && (elapsed<=5.0 || gamestate!=5)) return 0; return 1;` — so OUTSIDE gamestate 5 a once-used slot is unavailable permanently, not for 5 s. That is the opposite of a timeout and is worth checking against whatever state the pods sit in during a respawn.\n\nDropZone layout [T1]: sizeof 0x1f8. +0x1c4 slotCount; +0x1c8 name (char[], inline); +0x1e8 slot transform array, stride 0x1c = {float pos[3]; float quat[4]}; +0x1ec lastUsed int ticks (init 0 from 0x004e17f4); +0x1f0 holder handle, stride 8 (init null handle 0x00522524); +0x1f4 holder token, stride 4 (init 0xfffffffd = -3). Slots come from the map: the reader at 0x0042f818 reads the entity's \"dropzone\" attribute group, child count -> slotCount, and sscanf \"%f %f %f %f %f %f\" per child = pos xyz + euler hpr converted to the quaternion at +0xc. Zero children -> prints \"Error: <name> hasn't specified any drop zones!\\n\" and fails construction.\n\nTWO CONTENT-SIDE DIVERGENCES, both directly implicated:\n\n(a) BT's real maps carry MANY named DropZone entities in the \"DropZones\" group, in two families. FUN_004d3f44 (0x004d3f44) builds names from the template \"win?\" at 0x0051ee64 by writing '1'+i into byte[3], i.e. \"win1\"..\"winN\" — one podium zone PER PLAYER, used to place each player at end of round. Complementarily, Player::VehicleDeadMessageHandler (0x0042db80) EXCLUDES any zone whose name starts with \"win\" (strncmp against 0x004e692e/0x004e6932, len 3) and picks the geometrically CLOSEST remaining zone. So the live respawn pool is the set of non-\"win\" zones scattered across the map — plural by design. tools/eggmodel.py:42 pinning DROPZONES = [\"one\"] collapses that pool to a single entity, so all pods contend for one slot array. With 5 players and fewer than 5 free slots in that one zone, Phase 5 is reachable by construction — which matches the observed player-count and death-rate dependence exactly.\n\n(b) The un-exported Player-side function at 0x0042ddcc takes a DIFFERENT route: it resolves the zone by EXACT NAME from a string at player+0x1f8 -> +0x28 (rather than by proximity), sends the class-0x15 AssignDropZone message synchronously to it, and reposts VehicleDead at Now()+2.0 s. That is the code path a per-pilot `dropzone=` attribute would feed, and it has no proximity fallback at all — if the named zone is saturated, this path relies entirely on the 2 s repost. Worth confirming which of 0x0042db80 / 0x0042ddcc our port actually mirrors.\n\nCAVEATS / OPEN ITEMS, flagged honestly:\n- 0x0042f818 (the slot-list reader) has ZERO references in the entire file: no absolute dword anywhere, no call/jmp rel32 in CODE. It is a real function (clean prologue after the constant pool at 0x42f814), and its output record exactly matches what the ctor at 0x0042f16c consumes (+0x4c name, +0x6c count, this[0] = count*0x1c+0x70), so its identity is not in doubt — but how DropZone definitions actually reach the ctor at runtime is NOT established. Either a generic attribute-table-driven loader builds the record, or this reader is vestigial. Do not assume the \"dropzone\" attribute group is parsed by this function at runtime until the loader is found. [T1 observation, T4 on the implication]\n- 0x0042ddcc's trigger is not pinned. Its only reference is the DATA dword at 0x004e6808, which sits in a stride-12 table at 0x004e67fc of {fnptr,0,0} triples (0x0041c078, 0x0042ddcc, 0x0042e168); 0x42ddcc itself copies the first triple into player+0x1c/+0x20/+0x24, so this looks like a state/delegate table. One more hop is needed to say which event invokes it. [T3]\n- msgid 0x15 is shared by both \"DropZoneReply\" (Player/BTPlayer tables) and \"AssignDropZone\" (DropZone table), so 0x15 is not a globally unique message id — likely a per-class dispatch slot or a category. Do not treat it as a global message ClassID.\n- The 4.0f at 0x0042f80c is used BOTH as the squared proximity radius and as the seconds to back-date lastUsed. These may be two distinct source constants that the compiler pooled because the values coincide; do not assume one source symbol.\n- The decomp export has NO munga/dropzone.cpp, confirmed: `grep -rho \"file=munga/[a-z0-9_]*\\.cpp\"` lists 46 TUs, player.cpp among them, dropzone.cpp absent. Everything above at 0x0042f404 / 0x0042f818 / 0x0042f9d8 / 0x0042ddcc is newly recovered by byte-scan + windowed disassembly, following the FUN_004a6344 precedent.\n\nNothing was written to context/*.md or docs/*.md; the durable findings (DropZone layout, the five constants, the four call sites, the recovered addresses, and the two content divergences) are candidates for context/multiplayer.md + context/translocation-warp.md + context/decomp-reference.md once the parent agent decides where they belong."
}

run wf_10090e10-12f -- 1 completed agent(s)

{
 "area": "Replicant path across death and respawn: which records/messages a peer's Mech replicant receives, what each writes, and what clears (or fails to clear) the wreck visuals",
 "mechanism": "WHAT A REPLICANT RECEIVES, RECORD BY RECORD.\n\nA) THE PLUMBING. A master's dirty bits are walked bit-by-bit into one stream: `Simulation::WriteSimulationUpdate` (engine/MUNGA/SIMULATE.cpp:314-338) consumes-and-zeroes `updateModel` and calls `WriteUpdateRecord(update, bit)` per set bit, so **record type == the updateModel BIT INDEX**. All records produced in one frame are packed into ONE `Entity::UpdateMessage` (`Entity::Execute`, ENTITY.cpp:525-593), which sets `update->messageFlags = 0` at ENTITY.cpp:590 — i.e. the whole burst rides the UNRELIABLE channel in relay mode. Masters emit at UPDATE.cpp:150-195 (`entity->Execute` -> `InterestManager::EntityUpdateReplicants`); replicants also run `Execute` but their return value is DISCARDED (UPDATE.cpp:115-133), so a replicant can never emit a competing record. On receipt: `Entity::UpdateMessageHandler` (ENTITY.cpp:364-393) frames the stream on `recordLength` and dispatches each record to `GetSimulation(subsystemID-1)`; subsystemID 0 routes to the mech itself.\n\nB) `Mech::ReadUpdateRecord` — game/reconstructed/mech.cpp:2189-2506, switch on `message->recordID`:\n- case 0 POSE (2225-2273): chains `Mover::ReadUpdateRecord` (MOVER.cpp:698-759) -> `Entity::ReadUpdateRecord` case Default (ENTITY.cpp:405-431) -> `Simulation::ReadUpdateRecord` (SIMULATE.cpp:282-290) which stamps `lastUpdate` and **`SetSimulationState(message->simulationState)`**. Writes `updateOrigin`, velocities/accelerations, `poseSyncLatch=1`, `bodyTargetSpeed`. Touches NO damage/graphic/effect state.\n- cases 2,3,4,5,7,8: `Simulation::ReadUpdateRecord` only (state + clock, with the port's clock guard) plus their own scalars. No damage/graphic/effect state.\n- **case 6 DEATH (mech.cpp:2400-2431): writes ONLY `heatAlarm`, `throttleState`, `fallDirection`, `fallScalar`, `bodyStateAlarm(0)`, `legResetLatch/bodyResetLatch=1`, zeroed `projectedVelocity`, `bodyTargetSpeed`. It carries NO wreck-visual payload at all** — no zone level, no graphic state, no effect id. The only thing that makes the replicant \"dead\" is `simulationState == 9` riding the header (+0xC) through the base reader. There is no inverse \"un-death\" record type; the return to life rides only the header state of the next record that chains `Simulation::ReadUpdateRecord`.\n- **default (mech.cpp:2486-2488) = record type 1, the DAMAGE ZONES**: `Mover::ReadUpdateRecord` default (MOVER.cpp:754-756) -> `Entity::ReadUpdateRecord` case `DamageZoneUpdateModelBit` (ENTITY.cpp:433-435) -> `Entity::ReadDamageUpdateRecord` (ENTITY.cpp:447-469), which walks the sub-stream and calls `damageZones[idx]->ReadUpdateRecord` per zone. Note this branch deliberately does NOT chain `Simulation::ReadUpdateRecord`, so a type-1 record does not apply simulationState (matches the known \"bogus simState on the type-1 rx line\").\n- `DamageZone::ReadUpdateRecord` (engine/MUNGA/DAMAGE.cpp:157-168) applies FOUR fields: `damageLevel`, `damageZoneState.SetState(...)`, **`damageZoneGraphicState.SetState(...)`**, `changedFlags`. The writer side is `DamageZone::WriteUpdateRecord` (DAMAGE.cpp:136-153) and `Entity::WriteDamageUpdateRecord` (ENTITY.cpp:478-521) writes **every** zone every time (\"Update Every DamageZone For NOW\", :498). `DamageZoneUpdateModelBit == 1`, flag `== 2` (ENTITY.h:205-210).\n\nC) WHAT DRESSES THE WRECK ON A PEER (three independent local layers, two of which the replicant drives itself):\n1. Destroyed SKINS + burning zone plumes: the replicant runs `MechDeathHandler::Tick` every frame (mechdmg.cpp:1170, called unconditionally from `Mech::PerformAndWatch`, mech4.cpp:2158-2159). Its rising-edge branch (1219-1280) sees the replicated `damageLevel` climb, fires `BTStartZoneEffect` (the band descriptor's ember/FIRE plume, attached to the zone's segment via `BTStartPfxAttached`), calls `zone->ApplyDamageGraphicState(d->graphicState)` and `BTRemakeMechModel` (btl4vid.cpp:764-862 swaps each segment's destroyed mesh variant in place), and latches **`wasWrecked = 1`** at mechdmg.cpp:1234 — but only inside `if (level >= 1.0f && prev < 1.0f)`.\n2. The burning HULK: `Mech::UpdateDeathState`'s once-per-death transition block (mech4.cpp:1993-2052) builds the victim's `blhdead` death `Explosion`. That Explosion is a master Entity, so its MakeMessage is broadcast to peers (`Entity` ctor -> `HostManager::NotifyOfEntityCreation`, ENTITY.cpp:1031 -> `InterestManager::NotifyOfEntityCreation` case MasterInstance -> `DynamicEntityCreation` -> `BroadcastMessage`, INTEREST.cpp:740-743 and 926-944). On the peer the replicant Explosion resolves `entityHit` by id (EXPLODE.cpp:304-308, 445-459) = the victim's REPLICANT, and the renderer's model-list walk hits **effect 104 -> `BTSwapMechToWreck(GetEntityHit())`** (engine/MUNGA_L4/L4VIDEO.cpp:7602-7606) -> `BTL4VideoRenderer::SwapToWreck` (btl4vid.cpp:1101-1229), which sets **every body segment's draw object to NULL** (1184-1190), hangs `<mech>dbr` hulk + `ldbr` debris + `flamebig` flames on the tree ROOT renderable with identity offset (1191-1216 — the root pushes the mech's live `localToWorld`, so the pieces follow the entity), and latches `render_tree.wrecked = 1` (1217).\n3. The re-armed death plume: the replicant's own `UpdateDeathState` mode-9 branch (mech4.cpp:1873-1948) calls `BTWreckSinkTick` (the quadratic burial, btl4vid.cpp:1244-1319) and re-fires `BTStartPfx(1)` (ddthsmk) every 10 s.\nCritically, the replicant NEVER runs the death transition block: its `MovementMode` arrives as 9 from the type-6 header, so `UpdateDeathState` takes the mode-9 early return. `graphicAlarm` is never raised on a replicant (damage is applied master-side only), so `IsMechDestroyed()` on a replicant is purely `MovementMode()==2||9` (mech4.cpp:1707-1709).\n\nD) WHAT UNDRESSES IT — and where the two sides diverge. `Mech::Reset` (mech4.cpp:1730-1863) is the respawn, and it is called ONLY at btplayer.cpp:1427 (the owner's DropZoneReply path) and btl4pb.cpp:555 (mission review). **A replicant never runs `Mech::Reset`.** Reset does five un-dressing things, of which only ONE is replicable:\n  (i) `application->GetRendererManager()->StopAllEntityEffects(this)` (1757-1758) — kills the attached burning plumes. This is a purely LOCAL renderer message (RENDERER.h:710 builds `Renderer__StopAllEntityEffectsMessage` and dispatches to the local renderer chain; RENDERER.cpp:552-564 -> `BTStopEntityPfx`, L4VIDEO.cpp:920-927). Nothing puts it on the wire.\n  (ii) `graphicAlarm.SetLevel(0)` (1776) — local, not in any record.\n  (iii) `zone->Heal()` for every zone (1779-1784) -> `damageLevel = 0`, `ApplyDamageGraphicState(ExistsGraphicState)`, `SetDamageZoneState(0)` (mechdmg.cpp:1125-1134). `SetGraphicState` also sets `GraphicStateChangedFlag` (DAMAGE.h:200-205). **This is the only part that can cross the wire.**\n  (iv) `BTRebuildMechModel(this)` (1831-1834) -> `RebuildMechRenderables` (btl4vid.cpp:880-925) — the ONLY function that clears `render_tree.wrecked`, drops hulk/debris/flames, and restores the segment meshes via `ApplyViewSkeleton`. Local bridge; not networked.\n  (v) `if (GetInstance() != ReplicantInstance) ForceUpdate(0x1f)` (1842-1843) — bits 0..4, which includes bit 1 = the damage-zone record. `Mech::ForceUpdate` (mech.hpp:467-475) would mask to `0xfe03` if disabled, but Reset already did `SetMovementMode(0)` at 1770, so the full mask survives.\nOn a peer, the reversal of (iv) is reached by exactly one route: `MechDeathHandler::Tick`'s falling-edge inference, mechdmg.cpp:1195-1216 — `if (level < prev) { if (wasWrecked && prev >= 1.0f && level < 1.0f) { wasWrecked = 0; if (instance == ReplicantInstance) { BTRebuildMechModel; BTStartWarpEffect; } } }`. There is no reversal of (i) at all on a peer.\n\nE) DIRECT ANSWER TO THE QUESTION. **Yes — there is exactly one wire mechanism, and it is narrow: the type-1 DamageZone update record.** A master that clears a zone's destroyed graphic state and heals it CAN tell a peer, because `DamageZone::WriteUpdateRecord`/`ReadUpdateRecord` (DAMAGE.cpp:136-168) carry `damageZoneGraphicState`, `damageZoneState`, `damageLevel` and `changedFlags`, and `Mech::Reset`'s `ForceUpdate(0x1f)` sets bit 1 so that record is produced once at respawn.\n**But for STOPPING THE BURNING EFFECT there is no wire mechanism whatsoever.** `StopAllEntityEffects` is a local renderer message; the mech's Set/ClearBurningState messages (binary ids 0x17/0x18 in the Mech message table @0x50bdf8) are NOT reconstructed in the port at all — the only `SetBurningState` in the tree is `CulturalIcon`'s (engine/MUNGA/CULTURAL.cpp:47/61, cultural.h:50/57), and it is a local `Dispatch`, not a replicated one. And the authentic engine channel that would have translated the replicated graphic-state change into a peer-side effect restart — `EntityEffectWatcher::Execute`, EXPTBL.cpp:481-589 — **is dead code in this port**: the only thing that ever registers one is its own ctor (`entityWatched->AddEffectWatcher(this)`, EXPTBL.cpp:451) and NOTHING in engine/ or game/ ever constructs an `EntityEffectWatcher`. So `effectWatcherSocket` (SIMULATE.h:262-279, iterated by SIMULATE.cpp:547) is always empty, and EXPTBL.cpp:537-551 never executes on either side. That directly corrects the premise handed to me: \"zone graphic-state changes force a DamageZone update record on a master (EXPTBL ~537-550)\" is true of the engine SOURCE and false of the RUNNING PORT [T1].\nNet: the wreck is DRESSED on a peer by a replicated ENTITY CREATION (the death Explosion -> effect 104) plus the peer's own reaction to replicated zone levels and `simulationState==9`; it is UNDRESSED by a single unreliable one-shot type-1 record whose only consumer is a port-invented level-delta inference. The dress and undress triggers are different in kind, so they can and do disagree — that is the bug.",
 "callSites": [
  "engine/MUNGA/UPDATE.cpp:115-133 -- replicant loop: `if (replicant->IsReplicantExecutable()) replicant->Execute(target_render_time);` and the returned UpdateMessage is DISCARDED (replicants never emit). Runs on: every instance, for peers' mechs.",
  "engine/MUNGA/UPDATE.cpp:150-195 -- master loop: `entity->Execute(...)` then `InterestManager::EntityUpdateReplicants(entity, update_message)`. Runs on: master only.",
  "engine/MUNGA/ENTITY.cpp:525-593 -- `Entity::Execute`: packs all records of the frame into one UpdateMessage; :590 `update->messageFlags = 0` (unreliable channel). Master.",
  "engine/MUNGA/ENTITY.cpp:364-393 -- `Entity::UpdateMessageHandler`: frames the stream on recordLength, routes each record via `GetSimulation(subsystemID-1)->ReadUpdateRecord`. Replicant.",
  "engine/MUNGA/SIMULATE.cpp:314-338 -- `Simulation::WriteSimulationUpdate`: bit-walks and zeroes updateModel; record type == bit index. Master.",
  "engine/MUNGA/SIMULATE.cpp:282-290 -- `Simulation::ReadUpdateRecord`: `lastUpdate = Now()` + `SetSimulationState(message->simulationState)` -- the ONLY channel by which death (9) and un-death (0/1) reach a replicant. Replicant.",
  "game/reconstructed/mech.cpp:2189-2506 -- `Mech::ReadUpdateRecord`; case 0 pose @2225, case 6 death @2400-2431, default (type 1 damage zones) @2486-2488, state-2 edge tail @2501-2505. Replicant.",
  "game/reconstructed/mech.cpp:2525-2741 -- `Mech::WriteUpdateRecord`; default (type 1) @2729-2731 chains Mover. Master.",
  "engine/MUNGA/MOVER.cpp:698-759 -- `Mover::ReadUpdateRecord`; default @754-756 falls to Entity. Replicant.",
  "engine/MUNGA/ENTITY.cpp:398-443 -- `Entity::ReadUpdateRecord`; case DamageZoneUpdateModelBit @433-435 (note: does NOT chain Simulation::ReadUpdateRecord). Replicant.",
  "engine/MUNGA/ENTITY.cpp:447-469 -- `Entity::ReadDamageUpdateRecord`: per-zone sub-stream walk -> `damageZones[idx]->ReadUpdateRecord`. Replicant.",
  "engine/MUNGA/ENTITY.cpp:478-521 -- `Entity::WriteDamageUpdateRecord`: writes EVERY zone unconditionally. Master.",
  "engine/MUNGA/DAMAGE.cpp:136-153 / 157-168 -- `DamageZone::Write/ReadUpdateRecord`: damageLevel, damageZoneState, damageZoneGraphicState, changedFlags (+ `ResetChangedFlags()` on the master at :151). Master / replicant.",
  "game/reconstructed/mech4.cpp:2152 -- `UpdateDeathState(dt)` called from `Mech::PerformAndWatch` for EVERY mech, replicants included.",
  "game/reconstructed/mech4.cpp:2158-2159 -- `((MechDeathHandler*)deathHandler)->Tick()` called for EVERY mech, replicants included.",
  "game/reconstructed/mech4.cpp:1866-1948 -- `Mech::UpdateDeathState`: alive early-out @1868-1872 (re-arms matchlogDeathLogged), mode-9 branch @1873 -> `BTWreckSinkTick` @1906-1907 + ddthsmk re-arm @1936-1947. Both instances; a replicant only ever reaches the mode-9 branch.",
  "game/reconstructed/mech4.cpp:1977-1987 -- death sender `ForceUpdate(Default | 1<<MechDeathUpdateModelBit)` BEFORE `SetMovementMode(9)`. Master only (a replicant never reaches the transition).",
  "game/reconstructed/mech4.cpp:1993-2052 -- the death transition block: BTPostKillScore + the `blhdead` `Explosion::Make`. Master only.",
  "game/reconstructed/mech4.cpp:1730-1863 -- `Mech::Reset` (the respawn): StopAllEntityEffects @1757-1758, graphicAlarm 0 @1776, zone `Heal()` @1779-1784, `BTRebuildMechModel` @1831-1834, `if (GetInstance() != ReplicantInstance) ForceUpdate(0x1f)` @1842-1843. MASTER ONLY -- called from btplayer.cpp:1427 and btl4pb.cpp:555.",
  "game/reconstructed/mechdmg.cpp:1170-1282 -- `MechDeathHandler::Tick`: falling-edge replicant un-wreck @1195-1216, `wasWrecked = 1` latch @1234, `BTStartZoneEffect` + `ApplyDamageGraphicState` @1251-1257, `ForceUpdate(DamageZoneUpdateModelFlag)` + `BTRemakeMechModel` @1271-1275 (gated `d->graphicState != ExistsGraphicState`). Both instances.",
  "game/reconstructed/mechdmg.cpp:1125-1134 -- `Mech__DamageZone::Heal()`: damageLevel 0, ApplyDamageGraphicState(Exists), SetDamageZoneState(0). Master only (called from Mech::Reset).",
  "engine/MUNGA_L4/L4VIDEO.cpp:7602-7606 -- effect 104 -> `BTSwapMechToWreck(((Explosion*)entity)->GetEntityHit())`. Runs on EVERY node that has the replicated death Explosion, i.e. both.",
  "game/reconstructed/btl4vid.cpp:1101-1229 -- `SwapToWreck`: NULLs every segment drawObj @1184-1190, hangs hulk/debris/flamebig on the ROOT renderable @1191-1216, latches `wrecked=1` @1217. Both.",
  "game/reconstructed/btl4vid.cpp:1244-1319 -- `TickWreck` (reveal @1263-1274, sink, burial @1280-1293); only caller is `BTWreckSinkTick` @1325-1334, whose only caller is mech4.cpp:1907 inside the mode-9 branch. Both.",
  "game/reconstructed/btl4vid.cpp:880-925 -- `RebuildMechRenderables`: the ONLY clearer of `render_tree.wrecked` + hulk/flames + segment meshes. Reached from `Mech::Reset` (master) and mechdmg.cpp:1205 (replicant, gated).",
  "engine/MUNGA/RENDERER.h:710 + RENDERER.cpp:552-564 + L4VIDEO.cpp:920-927 -- `StopAllEntityEffects` -> local renderer message -> `BTStopEntityPfx`. LOCAL ONLY, never networked.",
  "engine/MUNGA/EXPTBL.cpp:446-451 (ctor + the only `AddEffectWatcher` call) and 481-589 (`EntityEffectWatcher::Execute`, graphic-state watch @537-551) -- NEVER CONSTRUCTED anywhere in engine/ or game/: dead code on both instances.",
  "engine/MUNGA/ENTITY.cpp:1031 -> INTEREST.cpp:740-743 -> INTEREST.cpp:926-944 -- the Entity MakeMessage ctor broadcasts a master entity's creation, which is how the death Explosion (and therefore effect 104 / SwapToWreck) reaches a peer."
 ],
 "replicationBehaviour": "WHAT ACTUALLY CROSSES THE WIRE FOR THE DEATH/WRECK VISUAL STATE, and what the replicant does with it:\n\n1. `simulationState` — rides the header (+0xC) of every record that chains `Simulation::ReadUpdateRecord` (types 0,2,3,4,5,6,7,8 — NOT type 1). Death = 9 arrives with the type-6 record; the return to life (0, then the master's drive re-selects 1) arrives with the next type-0/2/3/4 record. Replicant action: `SetSimulationState` -> `MovementMode()` -> `IsMechDestroyed()` flips -> `UpdateDeathState` enters/leaves the mode-9 branch. This is the ONLY authoritative alive/dead bit a replicant has (its own `graphicAlarm` is never raised).\n\n2. Type-6 DEATH record (`Mech__FallUpdateRecord`, 0x2c) — payload is heatLevel, throttleState, fallDirection, fallScalar, speedDemand. Replicant action (mech.cpp:2400-2431): sets those four fields, `bodyStateAlarm(0)`, both reset latches, zeroes projectedVelocity. **It carries and clears no visual state.** There is no inverse record type; nothing named \"un-death\" exists.\n\n3. Type-1 DAMAGE-ZONE record (`Entity::WriteDamageUpdateRecord` -> N x `DamageZone::UpdateRecord`) — carries, per zone: `damageLevel`, `damageZoneState` (BurningState), **`damageZoneGraphicState` (Exists/Destroyed/Gone)**, `changedFlags`, `damageZoneIndex`. Replicant action: `DamageZone::ReadUpdateRecord` writes all four into the zone (DAMAGE.cpp:163-166). Downstream, the replicant's own `MechDeathHandler::Tick` reacts to the LEVEL delta (rising: destroyed skin swap + fire plume; falling from >=1.0: `BTRebuildMechModel` + warp). NOTHING reacts to the graphic-state field itself, because the engine's consumer (`EntityEffectWatcher::Execute`) is never instantiated and the renderer's authentic \"RemakeEntity\" state was not ported.\n   Emission cadence for this record is the crux: on the DAMAGE side it is dirtied by mechdmg.cpp:1273, explicitly gated `if (d->graphicState != DamageZone::ExistsGraphicState)` — i.e. it fires when wrecking and is skipped when healing. On the RESPAWN side it is dirtied exactly once, by `Mech::Reset`'s `ForceUpdate(0x1f)` (mech4.cpp:1843). **So the healed zone state is broadcast ONE TIME, in ONE UpdateMessage, with `messageFlags = 0` (ENTITY.cpp:590) = the unreliable UDP channel, with no retransmit and no idempotent re-assert.** The engine's own \"always update for a graphic state change\" re-assert (EXPTBL.cpp:544-550) is dead because no `EntityEffectWatcher` is ever constructed.\n\n4. The death `Explosion` ENTITY — replicated as an entity CREATION, not as a state record: `Entity` ctor -> `NotifyOfEntityCreation` (ENTITY.cpp:1031) -> `DynamicEntityCreation` -> `BroadcastMessage(InterestManagerClientID, creation_message)` (INTEREST.cpp:926-944). On the peer the replicant Explosion resolves `entityHit` to the victim's replicant (EXPLODE.cpp:304-308) and the video renderer's model-list walk fires effect 104 -> `SwapToWreck` (L4VIDEO.cpp:7602-7606). **This is how the flaming hulk gets onto a peer's screen, and it is a one-way, fire-and-forget entity creation with no paired \"un-swap\" entity or message.**\n\n5. Set/ClearBurningState (mech message ids 0x17/0x18 in the binary's Mech message table @0x50bdf8) — **NOT reconstructed in the port.** The only `SetBurningState` handler in the tree is `CulturalIcon`'s (CULTURAL.cpp:47/61), and it is a local dispatch. These ids are not a channel today [T1 for their existence in the binary, T0 for their absence from the port].\n\n6. `StopAllEntityEffects` — never crosses the wire. `RendererManager::StopAllEntityEffects` (RENDERER.h:710) constructs a `Renderer__StopAllEntityEffectsMessage` and dispatches it to the LOCAL renderer chain only (RENDERER.cpp:552-564 -> `BTStopEntityPfx`, L4VIDEO.cpp:920-927).\n\n7. `graphicAlarm` — never replicated (mechdmg.cpp:650-673 documents the split: the port writes `graphicAlarm` where the binary shared a cell with simulationState). `Mech::Reset`'s `graphicAlarm.SetLevel(0)` is invisible to peers, which is harmless only because a replicant's graphicAlarm was never raised in the first place.\n\n8. Weapon side-effect worth flagging: `mechweap.cpp:418` disables a weapon when its zone `GetGraphicState() == DestroyedGraphicState`. Since graphic state DOES replicate, a peer's replicant weapon state tracks it — so the replicated graphic-state field is load-bearing for more than cosmetics, and a lost type-1 heal record leaves a peer's model of the mech's weapons wrong too.\n\nREPLICANT-SIDE SUMMARY OF THE CYCLE. Death: type-6 (+state 9) freezes it and starts its local sink/plume; replicated zone levels give it destroyed skins + attached fire; the replicated Explosion gives it the hulk/flames/hidden body. Respawn: the next header state un-freezes it (so `BTWreckSinkTick` stops being called and the sink FREEZES mid-burial with the pieces still revealed) and one unreliable type-1 record heals the zone data; the hulk/flames/hidden-body reversal depends entirely on a level-delta inference, and the attached fire plumes are never stopped at all.",
 "gaps": [
  "NO WIRE MECHANISM AT ALL FOR STOPPING THE BURNING EFFECT ON A PEER. `Mech::Reset` clears the plumes with `application->GetRendererManager()->StopAllEntityEffects(this)` (mech4.cpp:1757-1758), which is a purely local renderer message (RENDERER.h:710 -> RENDERER.cpp:552-564 -> `BTStopEntityPfx`, L4VIDEO.cpp:920-927). Nothing networked corresponds to it, the mech's own 0x17/0x18 Set/ClearBurningState handlers are not reconstructed in the port, and the replicant un-wreck branch (mechdmg.cpp:1201-1213) calls `BTRebuildMechModel` + `BTStartWarpEffect` but NOT `StopAllEntityEffects`/`BTStopEntityPfx`. Stale-wreck effect on a peer: every band FIRE plume `BTStartZoneEffect` attached to the wreck's segments keeps emitting and keeps riding those segments (`followSeg`, L4VIDEO.cpp:892-914), so the respawned mech walks around trailing its corpse's fire. Bounded only by the authored `maxIssue` (L4VIDEO.cpp:1226-1227), which is why this layer alone looks like a wreck that eventually stops smoking rather than one that never does.",
  "THE RENDER WRECK SWAP IS ONE-WAY ON A PEER AND ITS REVERSAL IS GATED ON A SIGNAL THAT NEED NOT EXIST. `render_tree.wrecked`, the NULLed segment draw objects, and the hulk/debris/`flamebig` hung on the ROOT renderable (btl4vid.cpp:1184-1222) are cleared ONLY by `RebuildMechRenderables` (btl4vid.cpp:880-925). On a master that runs from `Mech::Reset` (mech4.cpp:1831-1834). On a peer the ONLY route is `MechDeathHandler::Tick`'s falling edge, `if (level < prev) { if (wasWrecked && prev >= 1.0f && level < 1.0f) ...}` (mechdmg.cpp:1195-1214), where `wasWrecked` is latched only by `if (level >= 1.0f && prev < 1.0f)` at mechdmg.cpp:1232-1234. A death that raises `graphicAlarm` to 9 WITHOUT driving any mech zone to damageLevel 1.0 -- the vital-subsystem/crit kill path (mechdmg.cpp:700, :788; `MechSubsystem::TakeDamage` accumulating on the subsystem's own private zone) -- never latches `wasWrecked` on either node, so the peer's falling edge never fires. Stale-wreck effect on a peer: the hulk + flames stay hung on the root renderable and every body segment stays drawObj=NULL, i.e. exactly 'flaming wreckage sliding around, no mech redraw', on an entity that is alive, driven and firing. This also explains the self-correction after several respawns: the first later death that DOES destroy a zone to 1.0 finally latches `wasWrecked`, and that respawn's falling edge runs the un-wreck.",
  "THE HEALED ZONE STATE IS BROADCAST EXACTLY ONCE, ON THE UNRELIABLE CHANNEL, WITH NO RE-ASSERT. `Mech::Reset` dirties bit 1 one time (`ForceUpdate(0x1f)`, mech4.cpp:1843); the whole burst goes out in one `Entity::UpdateMessage` with `messageFlags = 0` (ENTITY.cpp:590) = UDP in relay mode, subject to the per-sender seq gate that drops stale/out-of-order datagrams. After Reset, nothing ever re-dirties bit 1 for a heal: the only other site is mechdmg.cpp:1271-1273, explicitly gated `if (d->graphicState != DamageZone::ExistsGraphicState)` (fires on wrecking, skipped on healing). Stale-wreck effect on a peer: lose or reorder that single datagram and the peer's zones stay at damageLevel 1.0 / DestroyedGraphicState forever, so the falling edge never happens, the destroyed skins never swap back, the hulk is never dropped -- and (per `mechweap.cpp:418`) the peer's model of the mech's weapons stays destroyed too. This is also the intermittency mechanism: subsequent respawns re-roll the dice on one packet.",
  "THE ENGINE'S AUTHENTIC GRAPHIC-STATE WATCHER IS DEAD CODE, WHICH REMOVES BOTH ENDS OF THE ONE AUTHENTIC CHANNEL. `EntityEffectWatcher` is never constructed anywhere in engine/ or game/ -- the only registration is its own ctor (`entityWatched->AddEffectWatcher(this)`, EXPTBL.cpp:451), and `AddEffectWatcher`/`effectWatcherSocket` (SIMULATE.h:262-279, iterated at SIMULATE.cpp:547) therefore always see an empty chain. So EXPTBL.cpp:537-551 never runs: the master's 'Always update for a graphic state change -> `ForceUpdate(DamageZoneUpdateModelFlag)`' never fires (the port hand-rolls a wreck-only substitute at mechdmg.cpp:1273), and the replicant's `SelectGraphicStateEntry(GetGraphicState()) -> RendererManager::StartEntityEffect` never fires either. Stale-wreck effect on a peer: the replicated `damageZoneGraphicState` return to `ExistsGraphicState` lands in the zone object and is read by NOBODY -- the port has no consumer of a graphic-state RETURN, only of a level DROP. This also directly contradicts the working premise that 'zone graphic-state changes DO force a DamageZone update record on a master (EXPTBL ~537-550)': true of the engine source, false of the running port [T1].",
  "THE DRESS AND UNDRESS TRIGGERS ARE DIFFERENT IN KIND, SO THEY CANNOT BE KEPT CONSISTENT. The wreck arrives on a peer as a replicated ENTITY CREATION (the `blhdead` Explosion -> effect 104 -> `SwapToWreck`, ENTITY.cpp:1031 / INTEREST.cpp:926-944 / L4VIDEO.cpp:7602-7606) plus the peer's own reaction to `simulationState == 9`; it departs on a peer as a per-zone LEVEL DELTA inference. No single record or message expresses 'this mech is visually a wreck / is visually intact'. Stale-wreck effect on a peer: any death whose Explosion replicates but whose zone-level story does not match the latch conditions leaves the peer permanently dressed. A concrete race in the same family: `SwapToWreck` on a peer whose render tree is not yet built queues the wreck in `gBTPendingWrecks` (btl4vid.cpp:1089-1098) and applies it at first tree build (btl4vid.cpp:636-638) -- if the respawn's type-1 record and the falling edge have already been consumed by then, `RebuildMechRenderables` (which clears a pending wreck, :886) has already run and the queued wreck is applied AFTER the mech is alive again.",
  "THE SINK FREEZES REVEALED INSTEAD OF FINISHING. `BTWreckSinkTick` -> `TickWreck` (btl4vid.cpp:1244-1319) is called from exactly one place: inside `UpdateDeathState`'s mode-9 branch (mech4.cpp:1906-1907). When the respawn's header state un-freezes the replicant, that branch stops being entered, so the burial stops mid-sink with `wreckRevealed = 1` and the hulk/debris/flames still parented and still drawn. Stale-wreck effect on a peer: the wreck neither buries itself (which would at least hide it, per :1280-1293) nor is torn down -- it is pinned visible at whatever sink offset it reached in the ~5 s before respawn (respawn is the +5 s VehicleDead re-post, mech4.cpp:2072-2079), and it then tracks the live mech's `localToWorld` because the pieces hang on the root renderable with identity offset (btl4vid.cpp:1178-1180). Sibling nit in the same function: `wreckSmokeTimer` is not reset by `Mech::Reset` (absent from mech4.cpp:1730-1863), unlike `matchlogDeathLogged` which self-re-arms at mech4.cpp:1870.",
  "ASYMMETRY OF LAST RESORT: `RemakeEntityRenderables` early-returns while `render_tree.wrecked` is set (btl4vid.cpp:777-778), and `MechSegmentPick` returns 0 for a wrecked tree (btl4vid.cpp:1353). Stale-wreck effect on a peer: once a peer's replicant is stuck wrecked, no later graphic-state change can repair its meshes and the per-part aimed pick (#73) is disabled against it -- so the stale visual is self-sealing rather than self-healing, and the peer additionally cannot aim per-part at that mech for the rest of the round.",
  "DOCUMENTATION TENSION TO RESOLVE WHILE FIXING THIS [T3]: `Mech::Reset`'s own comments disagree about the 1995 behaviour -- mech4.cpp:1713-1718 says 'the authentic respawn REUSES the same mech entity', while mech4.cpp:1798-1802 says 'the binary respawn severed the vehicle and built a NEW mech (fresh ctor ...); our port REUSES the entity'. If the binary really created a new mech on respawn, then 1995 never needed a replicated un-dress at all (the wreck entity simply stayed wrecked on every node and a new entity appeared), and the port's reset-based respawn is a divergence that REQUIRES a channel the original did not have. That distinction decides whether the authentic fix is 'send the un-dress' or 'make the peer's un-dress deterministic'. Either way the durable mechanism to build on is the one that already exists on the wire: the type-1 `damageZoneGraphicState` return to `ExistsGraphicState`, consumed on the replicant the way EXPTBL.cpp:537-551 was written to consume it (i.e. revive `EntityEffectWatcher`, or give the port an explicit equivalent), rather than the level-delta inference."
 ],
 "tier": "T0/T1 for the code path itself (engine source + reconstructed sources read line-by-line; every claim cited to file:line). T1 for the two hard negatives -- `EntityEffectWatcher` has zero construction sites in engine/ or game/ (only EXPTBL.cpp:451 self-registration), and the port has no Mech Set/ClearBurningState (0x17/0x18) handlers (only CulturalIcon's local ones). T1 for the binary-side facts cited from context (the Mech message table @0x50bdf8, `Mech::Reset` @0x49fb74 with the `ForceUpdate(0x1f)` tail @0x49fdfc, `Mech::ForceUpdate` @0x4a4c54 with the 0xfe03 disabled filter, `IsDisabled` @0x49fb54). T3 for the RANKING of which gap fired in the field on build 4.11.642 -- the vital-subsystem-kill `wasWrecked` miss (gap 2) and the lost single unreliable type-1 heal record (gap 3) both fit the report exactly, including the self-correction after several respawns, but neither is yet proven from a log; gaps 1, 4, 5, 6 and 7 are structural and hold regardless. No runtime verification was performed in this pass (no T2 claims made). Recommended discriminator: BT_DEATH_LOG + BT_REPL_LOG on an observer, checking for the `[respawn] replicant un-wrecked + warp` line (mechdmg.cpp:1209-1212) and for the `[mrec-rx] type=1` arrival at the respawn instant -- absence of the first with presence of the second isolates gap 2; absence of both isolates gap 3.\""
}

run wf_41268191-ac9 -- 3 completed agent(s)

{
 "lens": "Bookkeeping: reconstruct every death / swallow / START / RESET / tally / paint, per player, in order, across all five machines — then test whether swallows, stranded cycles, or spurious resets predict ghosting.\n\nMETHOD NOTE (important for the parent): the five digests could NOT answer this lens. digest.py's KEEP regex includes `\\[tick\\]`, which flooded the 260-event budget, so every digest elided its middle (\"... 1347 events elided ...\") — and every death cycle lives in that elided middle. The digests retain only each session's *first* [respawn] line. I recovered the rest with a single narrow tag extraction (`grep -n '\\[respawn\\]'` = 36 lines total across all five logs, plus 8 DESTROYED lines and the [lobby] maps). Total raw volume read: well under 400 lines. Nothing was grepped broadly.\n\nKEY DECODER (this is what the previous reading got wrong): a machine logs death/respawn bookkeeping for its OWN local (master) player ONLY. Player node = lobby token octet + 1, and the host is always token .1 = player 2:1. So the same human is a different player ID in every match, and \"player 3:1\" in five different sessions is five different people (or the same person five times).\n  MATCH 1 (all clocks HH:01:2x, 642, 5 players, HOST=CONN MAN): connman=2:1 rajel=3:1 elengil=4:1 vex=5:1 sauron=6:1\n  MATCH 2 (all clocks HH:16:3x, 642, 5 players, HOST=SAURON):   sauron=2:1 elengil=3:1 vex=4:1 rajel=5:1 connman=6:1\n  MATCH 3 (all clocks HH:23:4x, 642, 3 players, HOST=RAJEL):    rajel=2:1 sauron=3:1 elengil=4:1\nMachine clocks differ by hours but the minute:second of each launch is identical across all five machines, which is what pins the three matches together.",
 "findings": [
  {
   "claim": "THE LEDGER. There were exactly 8 death cycles in the entire field session (not dozens). Every one has the identical 3-line signature at consecutive line numbers N, N+1, N+2: START, then DESTROYED, then SWALLOWED. Only 2 of the 8 ever reached a RESET. Full table (machine | session | build | match | player | START/DESTROYED/SWALLOWED/RESET line | outcome | seconds of session runtime remaining after the START):\n1. sauron | s7 | 641 | pre-match 4p | 3:1 | 8867/8868/8869/8952 | COMPLETE ~1s | -\n2. rajel | s5 | 642 | MATCH 1 | 3:1 | 21796/21797/21798/21878 | COMPLETE <1s | -\n3. vex | s2 | 642 | MATCH 1 | 5:1 | 15985/15986/15987/none | STRANDED | 45 s\n4. sauron | s13 | 642 | MATCH 1 | 6:1 | 58820/58821/58822/none | STRANDED | 32 s\n5. elengil | s6 | 642 | MATCH 2 | 3:1 | 12722/12723/12724/none | STRANDED | 24 s\n6. connman | s6 | 642 | MATCH 2 | 6:1 | 51424/51425/51426/none | STRANDED | 22 s\n7. rajel | s7 | 642 | MATCH 2 | 5:1 | 56169/56170/56171/none | STRANDED | 12 s\n8. vex | s4 | 642 | MATCH 2 | 4:1 | 54027/54028/54029/none | STRANDED | 6 s\nZero-death sessions: connman s2 (M1), elengil s4 (M1), sauron s16 (M2, the match he hosted), and all three MATCH 3 sessions. Tally values: every RESET reads \"(death #0)\" except cycles 1 and 2 which read \"(death #1)\". Seconds measured from the [rstat] cadence, which is 1 Hz (frames x avg ms = 1000 in all five logs, e.g. 416x2.40834=1002, 666x1.50158=1000).",
   "evidence": "All 8 cycles, raw logs (the digests elided every one of these lines). C:/git/bt411/scratchpad/night6/vex_LEGION.log:15985-15987 \"[respawn] player 5:1 death cycle START (death #1) -- drop-zone hunt in 5s\" / \"[damage] *** 5:21 DESTROYED (death effects dispatched from the death transition) ***\" / \"[respawn] WARNING: death for player 5:1 SWALLOWED -- a death cycle is already pending (deaths so far 1)\". Same triple at sauron_XIAOLONG.log:8867-8869 (+ RESET 8952 \"[respawn] player 3:1 RESET at drop zone (death #1) -- death cycle complete, latch cleared\"), sauron_XIAOLONG.log:58820-58822, rajel_GAMERSLAB.log:21796-21798 (+ RESET 21878), rajel_GAMERSLAB.log:56169-56171, elengil_ALIA.log:12722-12724, connman_MSFIREFLY.log:51424-51426, vex_LEGION.log:54027-54029. DESTROYED count == START count == SWALLOWED count in every log (sauron 2, elengil 1, rajel 2, vex 2, connman 1). rstat cadence: vex_LEGION.log window 54020-54140 \"[rstat] frames=517 avg=1.93447ms\".",
   "tier": "T1"
  },
  {
   "claim": "A SWALLOWED death has ZERO correlation with ghosting, because its base rate is 100%: it fires on 8 of 8 deaths, including both cycles that completed cleanly and including a build-641 death. It is also NOT a second death. The swallow is the SAME death re-entering the respawn notify from inside the death-effects dispatch: the three lines are always exactly consecutive (START at N, the DESTROYED / death-effects dispatch at N+1, the swallow at N+2), never separated by a frame. The #57 latch is deduping a self-echo, exactly as designed. This dismantles the framing in established fact #1 (\"a SECOND death that arrived while a cycle was in flight\") and removes the mechanism from the operator's theory #5: real crits did not create a multi-path death race, because the swallow already fires identically on 641 and fires on the two deaths that respawned perfectly.",
   "evidence": "Counter-examples are the two completions: C:/git/bt411/scratchpad/night6/rajel_GAMERSLAB.log:21796-21878 SWALLOWED at 21798 yet \"[respawn] player 3:1 RESET at drop zone (death #1) -- death cycle complete, latch cleared\" at 21878 (rajel = 3:1 in MATCH 1, did not ghost). And C:/git/bt411/scratchpad/night6/sauron_XIAOLONG.log:8867-8952, which is build 4.11.641 (session s7 header at line 3559: \"build=4.11.641 (cfa52a2) ... pid=10492 ... 22:13:19\") — SWALLOWED at 8869, RESET at 8952. Ordering START->DESTROYED->SWALLOWED is identical in all 8 instances (line numbers N, N+1, N+2 above).",
   "tier": "T1"
  },
  {
   "claim": "A STRANDED cycle correlates with ghosting PERFECTLY — 8/8 cycles and 10/10 player-matches, in both directions. MATCH 2: four players stranded (elengil 3:1, vex 4:1, rajel 5:1, connman 6:1) = \"everybody ghosted\"; SAURON alone did not ghost and his match-2 log contains no death cycle at all. MATCH 1: exactly two stranded (vex 5:1, sauron 6:1) = the reported \"2+ players in match 1\". The decisive counter-example is rajel in MATCH 1: he DIED, was SWALLOWED, and COMPLETED — and is not among the ghosts. So the predictor is the missing RESET, not death and not the swallow.",
   "evidence": "MATCH 2 stranded set: elengil_ALIA.log:12722 (elengil=3:1), vex_LEGION.log:54027 (vex=4:1), rajel_GAMERSLAB.log:56169 (rajel=5:1), connman_MSFIREFLY.log:51424 (connman=6:1) — none has a following RESET (the whole log holds only 5/9/6/4 [respawn] lines respectively). SAURON's match-2 session (s16, lines 70106-95559) contains exactly one [respawn] line, the spawn: digest_sauron_XIAOLONG.txt line 45 \"70232: [respawn] player 2:1 RESET at drop zone (death #0) -- death cycle complete, latch cleared\". MATCH 1 stranded set: vex_LEGION.log:15985, sauron_XIAOLONG.log:58820; MATCH 1 completion: rajel_GAMERSLAB.log:21878.",
   "tier": "T1"
  },
  {
   "claim": "MECHANISM, and it is visible in the log: the RESET step is what emits the repaint, and the repaint is what takes the wreck mesh back off. A completing cycle emits, in order, \"[paint] mech egg ... entity=3:21\" then \"[view] skeleton A (inside)\" then the RESET. A stranded cycle emits the wreck swap and then NOTHING — no paint, no view, no reset — so the player keeps the debris mesh. That is simultaneously the ghost and Gitea #81 (\"wrecked mech still moving and shooting\"): locally the mech is alive and driving (posZ ranges over 224..602 for 32 s after sauron's stranded start, 205..-715 for 45 s after vex's), while on every peer it is still wearing 'snddbr.bgf'/'lokdbr.bgf' debris.",
   "evidence": "Completing cycle, C:/git/bt411/scratchpad/night6/rajel_GAMERSLAB.log window 21790-21885: \"[BTrender] wreck swap: victim -> 'snddbr.bgf' + ldbr debris + flamebig flames (reveal in 0.25s)\" ... then \"[paint] mech egg color='Grey' badge='VGL' patch='Red' -> ... serno=0 entity=3:21\", \"[view] skeleton A (inside): 2 segment mesh(es) shown, 19 hidden (paint serno 0)\", \"[respawn] player 3:1 RESET at drop zone (death #1) -- death cycle complete, latch cleared\". Stranded cycle, vex_LEGION.log window 54020-54140: byte-identical through \"[BTrender] wreck swap: victim -> 'snddbr.bgf' ...\", then only \"[sync] advSum=3637.42 legSum=3670.27 posZ=-98.6178\" repeating frozen at the death position — no paint, no view, no reset. Local revival despite no reset: sauron_XIAOLONG.log 58820-70090 posZ samples 518.114 602.709 224.201 421.023 391.479 361.041 388.778; vex_LEGION.log 15985-29558 posZ samples 205.855 338.676 276.87 229.985 426.767 446.009 305.831 321.608 -15.9199 -715.553.",
   "tier": "T1"
  },
  {
   "claim": "There are NO spurious resets. Established fact #2's second half — \"player 3:1 received FIVE 'RESET at drop zone' lines against ONE START, three of them spurious, with the death tally reverted to #0, re-painting the same entity each time\" — is an artifact of aggregating ONE log file across 18 separate PROCESSES. Sauron's log is 18 sessions; four of those five \"3:1 RESET (death #0)\" lines are four different process launches' session-initial spawn, and in each of those sessions \"3:1\" is SAURON'S OWN local ID under that session's token map. The tally reads #0 because it IS a fresh spawn with deathTally 0 — nothing reverted. Within any single session, no player ever receives more than one RESET per START. Therefore spurious resets have zero correlation with ghosting: they do not exist.",
   "evidence": "The five lines and their owning sessions (session headers from C:/git/bt411/scratchpad/night6/sauron_XIAOLONG.log): 3659 in s7 (header line 3559, pid=10492, 22:13:19, build 641); 8952 in s7 — the single genuine \"(death #1)\" reset; 25040 in s9 (header 24931, pid=284, 22:32:47); 37156 in s11 (header 37034, pid=5524, 22:46:38); 95660 in s18 (header 95568, pid=4012, 23:23:40). Each of those sessions' lobby line assigns sauron token .2 => player 3:1: sauron_XIAOLONG.log:3557 \"[lobby] join: my token [169.254.77.2] ...\", :24929 same, :37032 same, :95566 same. Only one START exists in that whole log region (8867) and it is matched by 8952.",
   "tier": "T1"
  },
  {
   "claim": "Established fact #2's FIRST half is also wrong in attribution. The stranded 6:1 cycle is not a peer stranded on the host's log — it is in sauron's session 13, which is MATCH 1, where sauron was a CLIENT holding token .5 => player 6:1. 6:1 IS SAURON. In the match sauron actually hosted (MATCH 2, s16) his log contains no death cycle whatsoever. Being host protected nobody: sauron was a client in MATCH 1, died, and stranded like everyone else.",
   "evidence": "C:/git/bt411/scratchpad/night6/sauron_XIAOLONG.log:39957 \"[lobby] join: my token [169.254.77.5], map [169.254.77.1=76561198049064449;169.254.77.2=76561197970523304;169.254.77.3=76561198147980449;169.254.77.4=76561197969401853;169.254.77.5=76561198020775163]\" — .5 is sauron's own identity 76561198020775163 (cf. :70110 \"[steamnet] up -- identity 76561198020775163\"), and .1 is connman, the host. Session 13 = lines 39959-70090; the stranded cycle is at 58820-58822 inside it. Sauron's own hosted match: sauron_XIAOLONG.log:70103-70104 \"[lobby] host: lobby up (109775244803327128)\" / \"host: GO with 5 member(s), map [169.254.77.1=76561198020775163;...]\".",
   "tier": "T1"
  },
  {
   "claim": "The local-only-logging decoder is confirmed 13/13, which is what makes the whole table trustworthy: in every one of the 13 match sessions across the five machines, the session-initial \"RESET at drop zone (death #0)\" names exactly the player ID predicted by that session's lobby token octet + 1, and never any other player. Independently confirmed by the one available matchlog, which marks that same ID as the master instance.",
   "evidence": "MATCH 1 (host connman, token .1): connman_MSFIREFLY.log:136 \"player 2:1\"; rajel_GAMERSLAB.log:4024 \"player 3:1\"; elengil_ALIA.log:136 \"player 4:1\"; vex_LEGION.log:116 \"player 5:1\"; sauron_XIAOLONG.log:40072 \"player 6:1\". MATCH 2 (host sauron): sauron 70232 \"2:1\"; elengil 7847 \"3:1\"; vex 29673 \"4:1\"; rajel 33346 \"5:1\"; connman 29308 \"6:1\". MATCH 3 (host rajel): rajel 58641 \"2:1\"; sauron 95660 \"3:1\"; elengil 14704 \"4:1\". Token maps: connman_MSFIREFLY.log:8, sauron_XIAOLONG.log:70104, rajel_GAMERSLAB.log:58539. Matchlog cross-check: C:/git/bt411/scratchpad/night6/elengil_matchlog.txt:6 \"PLAYER_LINK ... mech=4:23 inst=M playerID=4:1 resolved=1\" and :9 \"RESPAWN ... player=4:1 deaths=0 alive=1\" for elengil's MATCH 3 session (pid=11724, matching elengil_ALIA.log session header at 14620).",
   "tier": "T1"
  },
  {
   "claim": "Ordering, not build, separates the working respawns from the broken ones: the only 2 completions are the 2 EARLIEST deaths of the night. Every death from rajel's MATCH 1 respawn onward stranded — 6 for 6. Both completions finished in about 1 second despite the log's advertised \"drop-zone hunt in 5s\", and the six stranded cycles then ran 6, 12, 22, 24, 32 and 45 further seconds without ever firing. This looks like a one-way state flip (a latch or drop-zone bookkeeping that never recovers after the first successful respawn) rather than a per-death race, and it is not a 642 regression in the swallow — rajel's completion is itself on 642.",
   "evidence": "Chronology by build and session header: completion 1 is sauron_XIAOLONG.log:8867-8952, session header :3559 \"build=4.11.641 (cfa52a2) ... 22:13:19\". Completion 2 is rajel_GAMERSLAB.log:21796-21878, session header :3923 \"build=4.11.642 (1ac4258+) ... 20:01:25\" (MATCH 1). All six later cycles stranded (line numbers in the ledger finding). Remaining-runtime seconds via 1 Hz [rstat] counts: vex s4 6, rajel s7 12, connman s6 22, elengil s6 24, sauron s13 32, vex s2 45 — every one exceeds both the ~1 s observed completion time and the 5 s advertised deadline.",
   "tier": "T1"
  },
  {
   "claim": "The recurring \"three repaints of my own mech with no death\" pattern is benign and must not be mistaken for a death or a self-correction: it is a cockpit/chase VIEW TOGGLE re-applying the paint. It appears once per match on the one machine whose player did not die (sauron in MATCH 2, elengil in MATCH 1).",
   "evidence": "C:/git/bt411/scratchpad/night6/sauron_XIAOLONG.log window 93350-93470: \"[paint] mech egg ... serno=1 entity=2:21\" / \"[view] skeleton A (inside): 3 segment mesh(es) shown, 19 hidden\" / \"[view] COCKPIT eyepoint (cockpit live)\" then \"[paint] ... entity=2:21\" / \"[view] skeleton N (outside): 14 segment mesh(es) shown, 8 hidden\" / \"[view] external chase (chase live)\" then \"[paint] ... entity=2:21\" / \"[view] COCKPIT eyepoint (cockpit live)\". Same shape in elengil_ALIA.log session s4 at +2914/+2938/+2993 (entity 4:21 = elengil's own mech in MATCH 1).",
   "tier": "T1"
  },
  {
   "claim": "Gitea #59 is re-confirmed RULED OUT independently of the brief: its always-on self-report guard emits zero lines in all five logs. Likewise there is no [warp]/translocation instrumentation at all, so the \"respawn warp lasted several seconds longer\" report cannot be measured from these logs — the only two completions took ~1 s and a hunt that never completes emits nothing.",
   "evidence": "Tag counts across all five raw logs in C:/git/bt411/scratchpad/night6/: RECOVERED = 0/0/0/0/0; deathfx = 0/0/0/0/0; \\[warp\\] = 0/0/0/0/0; translocat = 0/0/0/0/0. DESTROYED = 2/1/2/2/1 (sauron/elengil/rajel/vex/connman), which is exactly one per death cycle.",
   "tier": "T1"
  }
 ],
 "crossMachine": "The single most important cross-machine fact: for a STRANDED death, the victim's own machine logs three lines and the other four machines log NOTHING AT ALL — no death, no respawn, no repaint, no entity event. There is no peer-side death/respawn bookkeeping in this log stream whatsoever. Only one thing ever crosses the wire, and it is the repaint that a COMPLETED cycle produces.\n\nThat gives a clean natural experiment, because MATCH 1 contains one completion and two strands:\n- MATCH 1, rajel (3:1) COMPLETED at rajel_GAMERSLAB.log:21878. Every one of the four other machines logged a repaint of his entity 3:21 at the corresponding moment: connman_MSFIREFLY.log session s2 offset +17997, rajel's own +17954, vex_LEGION.log +17864, sauron_XIAOLONG.log +18426 (all \"[paint] mech egg ... entity=3:21\"). Everybody saw rajel come back. He is not on the ghost list.\n- MATCH 1, vex (5:21) and sauron (6:21) STRANDED. No machine anywhere — including their own — ever logged a repaint of entity 5:21 or 6:21. Both stayed wrecks on every peer. That is the reported \"2+ players ghosted in match 1\", and the count is exactly 2.\n- MATCH 2 is the same experiment with the completion removed. Four players stranded, and across all five logs the ONLY mid-match repaints of any kind are on SAURON's own machine, of SAURON's own mech (entity 2:21, three times, and those are the benign view-toggle repaints). Zero repaints of 3:21/4:21/5:21/6:21 anywhere. Hence \"everybody ghosted except SAURON\" — and the reason is not that he was host, it is that he never died in that match. He died in MATCH 1 as a client and ghosted with everyone else.\n- MATCH 3 (3 players, rajel host) has zero death cycles in all three logs — no START, no DESTROYED, no SWALLOWED, only the three session-initial spawns. Whatever went wrong in match 3, it is not in the death/respawn ledger.\n\nAlignment was done purely by event order plus the identical minute:second of each process launch (HH:01:2x, HH:16:3x, HH:23:4x on every machine); wall clocks differ by up to 3 hours and were never used as evidence. Elapsed times come from the [rstat] line, which is a verified 1 Hz tick on all five machines.",
 "unexplained": [
  "WHY the drop-zone hunt strands. Six of eight cycles never fire it, and they exit SILENTLY — no WARNING, no ERROR, no Fail(), no assert accompanies any stranded cycle (the digests' KEEP regex covers all of those patterns and shows none). The next step is the hunt code itself: find what emits '[respawn] player N:1 RESET at drop zone' and what early-returns before it, given that the wreck swap immediately before it always succeeds.",
  "The one-way pattern: the only 2 completions are the 2 earliest deaths of the night, and all 6 deaths after rajel's MATCH 1 respawn stranded. Suggests global/latched state that never recovers after the first successful respawn (or drop-zone slot occupancy), not a per-death race. Not decidable from logs — needs the code.",
  "Why both completions took ~1 s when the START line advertises 'drop-zone hunt in 5s'. Either the 5 s is a timeout rather than a delay, or the completing path is being driven by something other than the hunt timer. This matters because it means the 5 s figure cannot be used to bound how long a healthy respawn should take, and so the 'warp lasted several seconds longer than normal' report stays unquantified.",
  "Elengil's MATCH 1 log shows NO repaint of entity 3:21 for rajel's completed respawn, although the other three observers all logged one. Elengil's log is ~5x thinner than the others (~9.5 vs ~46 lines/s for the same match), so this may be a genuine missed peer repaint — i.e. a fifth, unreported ghost instance, elengil seeing rajel as a wreck — or an artifact of his session's logging. Worth one narrow check of elengil_ALIA.log around session offset +3700.",
  "vex's MATCH 2 strand is the weakest of the six: only 6 s of session runtime followed it. That is past the 5 s deadline and 6x the observed ~1 s completion, so truncation is unlikely, but it is the one instance where end-of-match truncation cannot be excluded with a wide margin. The other five (12-45 s) are safe.",
  "NOTHING in the death/respawn ledger touches the desync cluster (throttle gauge reading zero, no weapon audio, weapons dealing no damage, peers mispositioned) or the single 'unable to fire' report. No [respawn], DESTROYED, or paint anomaly attaches to them, and the Gitea #59 guard is silent in all five logs. If the desync cluster shares a root cause with the ghosts, the evidence for it is not in this instrumentation — a different lens is needed.",
  "The DESTROYED entity index is not stable across sessions (3:12 in sauron's 641 session, X:21 in matches 1-2, X:23 in match 3). Harmless for this analysis since the player ID is authoritative, but any tooling that keys off entity index across sessions will be wrong.",
  "Recommended instrumentation before the next session, since the current logs cannot see a peer's death at all: log the death/respawn transition on the OBSERVER side too (peer entity, wreck-swap state, repaint), and log every exit path of the drop-zone hunt including the failing ones."
 ]
}
{
 "lens": "ghost — who ghosted, when relative to their own death/respawn, and which side logs what",
 "findings": [
  {
   "claim": "SESSION MAP CORRECTION (load-bearing): the two bad matches are NOT the last session in each digest. They are the two 5-player matches. MATCH A (bad match #1, 600s clock, host = CONN MAN at node .1) and MATCH B (bad match #2, 300s clock, host = SAURON at node .1). The 3-player session that the digests expand LAST (sauron s18 / elengil s8 / rajel s9) had ZERO deaths and is not a bad match. Node->player: match A connman=2, rajel=3, elengil=4, vex=5, sauron=6; match B sauron=2, elengil=3, vex=4, rajel=5, connman=6 (entity id is <node>:<index>; index 1 = BTPlayer, index 21 = the mech).",
   "evidence": "Raw-log session headers (digests elide the mid-match, where every death is). MATCH A = C:/git/bt411/scratchpad/night6/sauron_XIAOLONG.log:39959-70090, elengil_ALIA.log:25-7739, rajel_GAMERSLAB.log:3923-33223, vex_LEGION.log:10-29558, connman_MSFIREFLY.log:10-29181. MATCH B = sauron_XIAOLONG.log:70106-95559 (digest_sauron_XIAOLONG.txt session 16), elengil_ALIA.log:7748-14611 (digest s6), vex_LEGION.log:29567-54615, rajel_GAMERSLAB.log:33232-58532, connman_MSFIREFLY.log:29202-53771. Host identified by the marshal: connman_MSFIREFLY.log:17 '[marshal] connecting pod 127.0.0.1:1501' … :387 '[marshal] RunMission #1 sent to 5 pod(s)' (match A) vs sauron_XIAOLONG.log:70113/70502 the same two lines (match B). Node map: sauron_XIAOLONG.log:70232 '[respawn] player 2:1 RESET at drop zone (death #0)' vs connman_MSFIREFLY.log:29308 'player 6:1 RESET…' in the same match.",
   "tier": "T2"
  },
  {
   "claim": "MATCH B: all four NON-HOST players died and EVERY ONE of them stranded — four 'death cycle START' lines and ZERO 'RESET at drop zone' lines anywhere in the match. Order of death: elengil (72.5% into his session), connman, rajel, vex (~30 s before mission end). The host SAURON never died at all.",
   "evidence": "elengil_ALIA.log:12722 '[respawn] player 3:1 death cycle START (death #1) -- drop-zone hunt in 5s' / :12723 '[damage] *** 3:21 DESTROYED …' — no RESET in the remaining 1,889 lines to mission end at :14232. connman_MSFIREFLY.log:51424 START (6:1), no RESET. rajel_GAMERSLAB.log:56169 START (5:1), no RESET. vex_LEGION.log:54027 START (4:1), no RESET. sauron_XIAOLONG.log:70106-95559 (25,454 lines) contains no 'death cycle START' and no '*** 2:21 DESTROYED ***' at all. This is exactly RajelAran's 'everybody ghosted except SAURON'.",
   "tier": "T2"
  },
  {
   "claim": "MATCH A: three deaths — two stranded (= the '2+ players' ghosted) and ONE that completed (= the reported 'self-corrected for one player after a later respawn'). The completion is the control case that proves the whole mechanism.",
   "evidence": "STRANDED: vex_LEGION.log:15985 '[respawn] player 5:1 death cycle START (death #1)' with no RESET in the following 13,538 lines; sauron_XIAOLONG.log:58820 'player 6:1 death cycle START (death #1)' with no RESET. COMPLETED: rajel_GAMERSLAB.log:21796 'player 3:1 death cycle START (death #1) -- drop-zone hunt in 5s' -> :21876 '[paint] mech egg … entity=3:21' -> :21878 '[respawn] player 3:1 RESET at drop zone (death #1) -- death cycle complete, latch cleared'. connman (host) and elengil never died in match A.",
   "tier": "T2"
  },
  {
   "claim": "The ghost is class (b): an entity the peers HAVE and keep simulating, but whose presentation is frozen in the dead/wreck state and then sunk out of the world. It is NOT (a) an entity peers never learned about — every peer received the death and applied the wreck swap at the right moment, in a bit-identical order.",
   "evidence": "In match B all four healthy machines log exactly four wreck swaps in the identical order mad,snd,mad,snd — one per dead player: sauron_XIAOLONG.log:90151/93244/93290/94996, vex_LEGION.log:49466/52417/52466/54037, rajel_GAMERSLAB.log:53208/56179/56225/57972, connman_MSFIREFLY.log:48561/51373/51435/53145, each reading \"[BTrender] wreck swap: victim -> 'maddbr.bgf' + ldbr debris + flamebig flames (reveal in 0.25s)\". The peers therefore had the entity and got the death message.",
   "tier": "T2"
  },
  {
   "claim": "The peers then BURY the wreck and the entity becomes permanently un-renderable — that is the moment the player becomes a ghost. All four match-B ghosts were buried on every peer.",
   "evidence": "sauron_XIAOLONG.log:92876, :93487, :93516, :95199 '[BTrender] wreck buried (sink complete)' — one per dead player; same four on vex_LEGION.log:52051/52662/52694/54238, rajel_GAMERSLAB.log:55876/56412/56444/58173, connman_MSFIREFLY.log:50928/51632/51664/53362. Code: C:/git/bt411/game/reconstructed/btl4vid.cpp:1278-1291 — 'float sink = -0.025f * t * t; if (sink < -8.0f) { … SetDrawObj(NULL) … render_tree.wreckHulk = NULL; … DEBUG_STREAM << \"[BTrender] wreck buried (sink complete)\"; return 0; }'. The renderables are nulled and the tick stops: no way back except MakeMechRenderables being called again. Sink completes 18.0 s after the 0.25 s reveal at the authored rate; observed swap->burial intervals were ~20-30 s (host: 90151 -> 92876).",
   "tier": "T1"
  },
  {
   "claim": "DECISIVE TEST: after burying the wreck the peers are STILL receiving and animating the ghost's live position. So it is not a stale-position problem (class c) and not a lost entity (class a) — the replication link is intact and only the presentation is dead.",
   "evidence": "The ghost's own track, elengil_ALIA.log post-death [drive]: :12731 'pos=(-109.98, 8.00406, -247.369)' -> :13187 '(-67.6, …, -234.5)' -> :13298 '(-76.9, …, -237.7)' -> :13771 '(12.7, …, -224.7)', with :13464 '[mppr] in thr=1 … speedDemand=48.2224' — he walks ~130 units for the 90 s after death. On the HOST, AFTER elengil's wreck was buried at sauron_XIAOLONG.log:92876: :93671 '** DPLIndependantEffect([-61.1, 7.40, -239.4], 11)', :93684 '([-71.8, …, -228.4], 12)', :93731 '([-76.1, …, -232.6], 10)' — footfall/dust effects at exactly the ghost's coordinates, 70 such hits in his post-burial region. The host is animating a mech it draws as nothing.",
   "tier": "T2"
  },
  {
   "claim": "The recovery signature confirms the defect is the missing RESET, not the network: when the cycle completes, peers rebuild the renderable for the SAME entity id and NO burial ever happens for that wreck.",
   "evidence": "Rajel's completed match-A respawn as seen on each peer: connman_MSFIREFLY.log:17937 wreck swap (snddbr) -> :18006 '[paint] mech egg … entity=3:21' + MakeMechRenderables, and NO 'wreck buried' for that swap; identically vex_LEGION.log:17807 -> :17873, sauron_XIAOLONG.log:58318 -> :58384. Every OTHER wreck in both matches (i.e. every stranded death) ends in 'wreck buried (sink complete)'.",
   "tier": "T2"
  },
  {
   "claim": "CORRECTION to established fact #4: in build 642 a respawn is NOT a replacement entity creation — it is an in-place reset of the same entity. HostManager::NotifyOfReplacementEntityCreation / unfinished late-ENTITY sync is therefore NOT the ghost path. The ghost path is: DropZone__ReplyMessage never arrives or never matches -> Mech::Reset is never called -> no 'RESET at drop zone' -> the peers' wreck is never replaced and sinks out of the world.",
   "evidence": "C:/git/bt411/game/reconstructed/btplayer.cpp:1391-1399: 'RESPAWN (reset-based): the mech STAYS ours through death; this reply (deathCount matches) resets the EXISTING dead mech in place -- fall through to the placement below (Mech::Reset heals + moves it). … (The old path pulsed DropZoneAcquiredState and looped through the dead handler to build a NEW mech -- removed…)' and :1424 'mech->Reset(message->dropZoneLocation, 1 /* True */); // FUN_0049fb74 (heal+move)' immediately followed by the 'RESET at drop zone' log at :1449. The reply is BTPlayer::DropZoneReplyMessageHandler (btplayer.cpp:1320), dispatched from MESSAGE_ENTRY(BTPlayer, DropZoneReply) at :295.",
   "tier": "T1"
  },
  {
   "claim": "'Cannot damage others': damage is shooter-resolved, and every ghost's impact resolution goes to ZERO the instant his cycle strands. Not one [projectile] IMPACT resolved after any stranded death, on any of the five machines, in either match.",
   "evidence": "elengil_ALIA.log last impact :12700, death START :12722, then zero impacts in 1,889 remaining lines. rajel_GAMERSLAB.log last :56118 vs death :56169. vex_LEGION.log last :53986 vs death :54027. connman_MSFIREFLY.log last :50435 vs death :51424. Even the surviving host stops: sauron_XIAOLONG.log last impact :87769, which precedes the first wreck swap at :90151 — after the first player ghosted, the host's shots stopped resolving too. Impact line form: '[projectile] IMPACT damage=13 subsys=23 (msgmgr bundled) (zone cyl-resolved)'.",
   "tier": "T2"
  },
  {
   "claim": "'Unable to fire' and 'armor damage persisted' are the same stranded cycle: at death the mech is flagged disabled and every ammo bin is killed, and only Mech::Reset (i.e. the RESET that never came) heals and re-arms it.",
   "evidence": "Four identical lines at each stranded death — elengil_ALIA.log:12725-12728 '[ammo] AFC50 -> NoAmmo (gate1): destroyed=0 failHeat=0 heatLvl=0 mechDisabled=1' (then LRM15, SRM6_1, SRM6_2); same block at rajel_GAMERSLAB.log:56173-56175, vex_LEGION.log:54031-54033, connman_MSFIREFLY.log:51427-51430. Vex's report of persisting armor damage is the missing heal in btplayer.cpp:1424 mech->Reset(…).",
   "tier": "T2"
  },
  {
   "claim": "The 'respawn drop-zone warp lasted several seconds longer than normal' is the same defect: the warp is released only by the DropZoneReply, so on a stranded cycle it never ends. This is also the most likely reason the ghost clips through mechs and the arena (the collapsed POV sphere raises the SetIsDead world mask).",
   "evidence": "C:/git/bt411/game/reconstructed/btplayer.cpp:474-481: 'WARP (task #52): our OWN death = the engine\\'s Idle -> InitialCollapse … Collapse the eye-centred sphere 100x -> 1x, then it raises the SetIsDead world mask and throbs until the respawn (DropZoneReply) releases the expand-reveal.' Corroborated by the ghost never being relocated: elengil_ALIA.log:12731 onward he walks from his death spot at ground height (y 8.0 -> 10) and is never teleported to a drop zone. The collision claim itself is an inference from the SetIsDead mask, not directly logged.",
   "tier": "T3"
  },
  {
   "claim": "WHY THE HOST DID NOT GHOST: no host immunity — the host is simply the only player in either bad match who never died. Proof: SAURON, playing as a JOINER (node 6) in match A, died and ghosted exactly like everyone else. Being host is correlated, being alive is causal.",
   "evidence": "Match B host sauron: no 'death cycle START' and no '*** 2:21 DESTROYED ***' in sauron_XIAOLONG.log:70106-95559. Match A host connman: no 'death cycle START' in connman_MSFIREFLY.log:10-29181. Same match A, sauron as joiner: sauron_XIAOLONG.log:58820 '[respawn] player 6:1 death cycle START (death #1)' -> :58829 his own \"[BTrender] wreck swap: victim -> 'lokdbr.bgf'\" -> :59103 '[BTrender] wreck buried (sink complete)' -> no RESET, ever. He ghosted.",
   "tier": "T2"
  },
  {
   "claim": "Gitea #81 ('a wrecked mech still MOVING AND SHOOTING') is not a separate bug — it is the FIRST ~18 s of this same defect, before the sink completes. Wreck-visual-on-a-live-mech, then invisibility.",
   "evidence": "Every peer's pairing shows the window: sauron_XIAOLONG.log:90151 wreck swap (elengil) -> :92876 'wreck buried', i.e. 2,725 lines (~25-30 s at ~85 lines/s) in which the peer draws a wreck at a position the host is still updating from elengil's live sim (the DPLIndependantEffect coordinate track at :93671+ continues past the burial). Sink duration from btl4vid.cpp:1278 '-0.025f * t * t' crossing -8.0 = 18.0 s.",
   "tier": "T2"
  }
 ],
 "crossMachine": "Take Elengil's match-B death, the cleanest case, and read all five machines at that instant. ON HIS OWN MACHINE (elengil_ALIA.log): :12722 '[respawn] player 3:1 death cycle START (death #1) -- drop-zone hunt in 5s', :12723 '[damage] *** 3:21 DESTROYED …', :12724 the #57 SWALLOWED warning, :12725-12728 four '[ammo] … mechDisabled=1' lines — and then, remarkably, NO [BTrender] entry at all: his own machine never even performs the wreck swap or RemakeEntity for his own death (contrast vex_LEGION.log:54030 RemakeEntity + :54037 wreck swap and rajel_GAMERSLAB.log:56172/56179 for their own deaths). No 'RESET at drop zone' ever follows, and he keeps driving for 90 s (:13464 'thr=1 … speedDemand=48.2224', track -110,-247 -> +12.7,-224.7). ON ALL FOUR PEERS the same event is the FIRST of four wreck swaps, in the same order: sauron_XIAOLONG.log:90151, vex_LEGION.log:49466, rajel_GAMERSLAB.log:53208, connman_MSFIREFLY.log:48561, each \\\"[BTrender] wreck swap: victim -> 'maddbr.bgf' + ldbr debris + flamebig flames (reveal in 0.25s)\\\" — so all four learned of the death. ~25-30 s later each peer independently logs '[BTrender] wreck buried (sink complete)' (sauron :92876, vex :52051, rajel :55876, connman :50928) and his entity has no renderable at all from then to mission end — while the host keeps emitting locomotion effects at his live coordinates (sauron :93671/93684/93731). So the asymmetry is: the OWNER logs a START with no RESET and keeps playing; the PEERS log swap-then-burial and never log a rebuild. The one counter-example in the whole dataset is rajel's match-A death, where the owner logged 'RESET at drop zone (death #1)' (rajel_GAMERSLAB.log:21878) and all three peers logged '[paint] … entity=3:21' + MakeMechRenderables instead of a burial (connman :18006, vex :17873, sauron :58384) — he recovered on every screen. One more asymmetry worth flagging for the desync lens: Elengil's machine in match A logged ZERO replicated effects for the whole 10-minute match (DPLIndependantEffect=0, 'Explosion Created'=0, zero wreck swaps, zero RemakeEntity) while connman/rajel/vex/sauron logged 8,805-9,285 DPL effects and 3,477-3,481 explosions each — near-identical counts, i.e. a deterministic replicated stream he was cut out of. His tick count was the HIGHEST of the five (3,185 vs 2,965-3,011), so his machine was healthy; the inbound stream was gone. On his screen the three players who died in match A never even got a wreck — they stayed intact, walking mechs. In match B his effect feed died at :11692, 1,030 lines BEFORE his own death at :12722.",
 "unexplained": [
  "WHY the DropZone reply strands. 6 of 7 deaths across both bad matches stranded; the single completion (rajel, match A, rajel_GAMERSLAB.log:21878) is the outlier, not the rule. The next step is to determine whether AssignDropZone / DropZone__ReplyMessage is answered locally or must round-trip to the master of the 'DropZones' group (btplayer.cpp:417-434 checks entity_manager->FindGroup(\"DropZones\")). If that master lives on the host, joiners' replies are network-dependent and the host's would be local — which would upgrade the host/joiner asymmetry from coincidence to cause.",
  "Whether a HOST's own death would strand is completely untested: no host died in either bad match, so there is no evidence either way. Do not ship a fix premised on host immunity.",
  "The host machine is also the only one running the veteran profile — '[exp] master player experience=3 simLive=1 heatModelOn=1 advDamage=1' (sauron_XIAOLONG.log:70190 in match B; connman_MSFIREFLY.log:88 advDamage=1 in match A) while every joiner ran 'experience=2 … advDamage=0'. Whether that asymmetric damage model is why the host survived (or is itself a desync source, since advDamage changes crit/limb resolution) is an untested hypothesis. [T4]",
  "Elengil's own machine performs NO wreck swap and NO RemakeEntity for his OWN death (elengil_ALIA.log:12722-12728), whereas vex/rajel/connman all do for theirs. Either his local death visual was also suppressed by whatever killed his inbound stream, or there is a second bug in the local death-visual path. Unresolved.",
  "Causal direction between Elengil's message-stream blackout and his ghosting is unresolved. In match A he received nothing and never died; in match B his effect feed died 1,030 lines before his death and then his cycle stranded. Whether a starved inbound stream also drops the DropZone reply — which would unify the ghost and desync clusters into one transport defect — is the highest-value follow-up and is not answerable from these logs alone.",
  "No log records inbound per-entity replication directly. The proof that peers still track a ghost rests on DPLIndependantEffect world coordinates matching the ghost's own drive track; it is strong for elengil in match B but was not reproducible for the match-A cases (their tracks covered too much of the arena for the coordinate test to discriminate)."
 ]
}
{
 "lens": "Desync cluster (throttle gauge zero / no weapon audio / weapons fire without damage / other mechs lagging) — local degradation vs replication failure, per symptom",
 "findings": [
  {
   "claim": "THE FOUR SYMPTOMS ARE NOT ONE BUG. No single condition accounts for all four. Two are LOCAL and pre-existing (throttle gauge, weapon audio) and are present on every machine in every session including the good ones; two (no damage, mechs in the wrong place) come from a stale-world condition on the complaining machine. Treating the 'desync cluster' as one root cause will send the fix at the wrong layer.",
   "evidence": "Composite of findings 2-6 below. Decisive negative control: the throttle and audio defects are equally present in the EARLIER good 600 s sessions (sauron_XIAOLONG.log s7/s9/s13, rajel_GAMERSLAB.log s5, vex_LEGION.log s2, connman_MSFIREFLY.log s2) which nobody complained about, so neither can be what went wrong tonight.",
   "tier": "T2"
  },
  {
   "claim": "THROTTLE GAUGE ZERO = LOCAL, pre-existing, and not a gauge bug at all: the cockpit speed/throttle arc is bound to LinearSpeed (measured gait advance), never to lever position — there is no Throttle attribute in the gauge config. Separately, the local `throttle` variable that gates run-vs-walk clip selection and the reverse sign is provably DEAD on every pod, because with a real device present the keyboard bridge is disabled and the device writes the mapper directly.",
   "evidence": "3960/3960 [drive] samples across all five raw logs and ALL sessions have thr=0, while 3162 of them show the mech moved >1 unit since the previous sample (sauron 1085/0 with 1011 moved; elengil 918/0, 530; rajel 699/0, 691; vex 490/0, 394; connman 768/0, 536). Same instant, elengil_ALIA.log L11700: `[mppr] in thr=0.470536 pre=0.470536 rev=0 stickX=0.388601 ... -> speedDemand=26.3718` — pre==in proves the DEVICE supplied it, not our bridge. Code: mech4.cpp:3636 `bridge_writes = gBTDrive.forced || !BTRIODevicePresent()`; mech4.cpp:3392 `float throttle = gBTDrive.forced ? gBTDrive.forcedThrottle : gBTDrive.throttle` (bridge-only, never written by the device); mech4.cpp:3653 reassigns `turn = mppr->turnDemand` but throttle is NEVER reassigned from speedDemand; mech4.cpp:4205 gates the [drive] log on `(throttle != 0.0f || turn != 0.0f)` so every one of those 3960 prints fired on turn alone. Gauge binding: content/GAUGE CFG lines 4960/4970-4971 bind `LinearSpeed` + `MaxRunSpeed`; linearSpeed is written only at mech4.cpp:4119/4179 from |gait advance|/dt. Every [spike] line in all five logs also reads thr=0, including ones with turn=0.58 and turn=-0.849.",
   "tier": "T2"
  },
  {
   "claim": "NO WEAPON AUDIO = LOCAL audio-source starvation, and it is driven by the MAP's ambient effect emitters, not by combat, crits, or the network. The arena2_morning map spawns ~10.6 DPL effects/s x ~2.5 voices each, which alone pins the 256-source OpenAL pool for the whole match; every weapon voice requested while pinned is silently dropped.",
   "evidence": "Match 1 (arena2, 300 s) acquire failures: SAURON 1904, Vex 1682, Connman 1444, Rajel 1407 — all `[audio] ACQUIRE FAILED (requested=4 live=256 fails=N) -- the pool is exhausted; expect dropouts` (first sauron_XIAOLONG.log L71216, last L94184). SAURON's census is pinned for 6 of 7 in-match samples: L71168 live=218, L73838 live=251, L77233 live=252, L81276 live=250, L85847 live=256, L91626 live=254, then L93458 live=8 at teardown. Match 2 on the polar map (448 ambient spawns instead of 3184) gives only 138/143/88 fails. Arithmetic check: 3184 spawns/300 s x 2.5 voices x ~10 s voice life ≈ 260 live ≈ the observed 250-256. This CONTRADICTS the existing premise in engine/MUNGA_L4/L4AUDRND.cpp:383-407 ('the night-5 census sits at a healthy 45-60 live, spikes to the 256 ceiling during firefights, and drains straight back... very likely sub-perceptual') — on arena2 it does not spike, it stays pinned, and the cap-raise is currently OFF by default on that reasoning.",
   "tier": "T2"
  },
  {
   "claim": "ELENGIL'S MACHINE (ALIA) — the one that reported the cluster — stopped processing the DPL effect / death-transition stream 57% into match 1 and never recovered. It missed ALL FOUR deaths in that match, including its own: zero death explosions, zero wreck swaps, zero wreck burials, while all four other machines processed 4/4/4 each.",
   "evidence": "Per-machine counts for the match-1 session: ELENGIL s6 ExplosionScripts=0, wreck swap=0, wreck buried=0; SAURON s16 / RAJEL s7 / VEX s4 / CONNMAN s6 all = 4/4/4. Elengil's own death is logged (elengil_ALIA.log L12722 `[respawn] player 3:1 death cycle START (death #1)`, L12723 `[damage] *** 3:21 DESTROYED (death effects dispatched from the death transition) ***`) yet produced no ExplosionScripts/wreck-swap line. The effect stream's last output is L11686 `Explosion Created in MakeEntityRenderables:` / L11691 `** DPLIndependantEffect([-147.102, 13.791, -236.577], 1023);` — 57.4% into the session — after which the last four deciles contain ZERO effect lines while frames keep rendering ([rstat] to L14542, frame rate actually RISING 164->217/s) and Elengil's own outbound fire keeps resolving ([projectile] IMPACT lines to L12700).",
   "tier": "T2"
  },
  {
   "claim": "WEAPONS FIRE WITHOUT DAMAGE = REPLICATION / stale-state, not a local firing failure. The shooter resolves the hit, picks a zone, and self-awards score entirely locally, so a shooter always sees hits and score even when the victim applies nothing. On ALIA specifically the targets it was shooting were already dead+wrecked on every other machine but still rendered alive and intact locally.",
   "evidence": "Shooter side works: elengil_matchlog.txt L14 `FIRE ... shooter=4:23 wpn=21 tgt=2:23 tinst=R mech=1 zone=10 amt=15.696 type=4` immediately preceded by L13 `SCORE ... player=4:1 award=15.70 total=15.70` — the shooter awards itself score off its own local damage computation. Local resolution is complete: elengil_ALIA.log L11749 `[projectile] IMPACT damage=13 subsys=23 (msgmgr bundled) (zone cyl-resolved)` (23 such impacts L11749-L12700). Combined with finding 4: ALIA never processed any of the 4 death transitions, so from L11686 onward its remote mechs were frozen pre-death copies — shooting them is shooting a stale model.",
   "tier": "T3"
  },
  {
   "claim": "OTHER MECHS LAGGING = REPLICATION-model, NOT transport loss. No machine lost a connection mid-match. Remote mech motion is locally PREDICTED by running the gait state machine against the last-sent commanded speed, with no position correction — and ALIA ran at 1/3.4 the frame rate of the fastest peer.",
   "evidence": "The only [steamnet] closes in the whole data set are Elengil's, and they are all AFTER its mission already ended: mission end elengil_ALIA.log L14232 `[mission] solo game clock expired (300s)`, then L14340-14342 `[steamnet] connection 2/1/3 closed (Timeout; remote problem. Rx age server 11.0s relay 0.0s)`, L14378 conn 4, L14431 conn 0. Every other machine's [steamnet] lines are all in the connect burst at session start (e.g. digest_rajel_GAMERSLAB.txt lines 11-29, last steamnet L33308 of a session ending 58532). Frame rate ([rstat], 1 s samples): Rajel 587/s, SAURON 341-454/s, Elengil 121-217/s. Prediction mechanism from code: mech4.cpp:3867-3885 'the invisible mj=0 body channel becomes the PEER's predictor, so it must slew toward the LAST-SENT commanded speed (bodyTargetSpeed@0x6b4)'; mech4.cpp:4104-4108 the BT_SLIDE probe exists specifically to distinguish 'the peer's stand-slide is a peer leg-SM-too-fast desync'.",
   "tier": "T3"
  },
  {
   "claim": "THE SUPPLIED DIGESTS CANNOT ANSWER THIS LENS — do not trust them for the audio or effect questions. digest.py classifies `[audio]` as NOISE and counts it away BEFORE the `acquireFails|source census` KEEP rule can fire, so 1904 pool-exhaustion lines are invisible in the digest; and `[tick]` is in KEEP, so per-frame tick spam consumed the 260-event budget and elided the real events in every long session.",
   "evidence": "scratchpad/night6/digest.py:36-38 NOISE includes `\\[audio\\]` and is tested first (line 64-67: `if NOISE.search(l): noise += 1; continue`) while KEEP (line 30) contains `acquireFails|source census|pool exhaust|DROP transient`. KEEP line 31 includes `\\[tick\\]`. Result: digest_sauron_XIAOLONG.txt lines 149 and 420 read `... 1347 events elided ...` / `... 399 events elided ...` and the visible remainder is almost entirely `[tick] subsystems simulated:` — the [mission], [weap], [ammo], [emitter] and audio events never appear.",
   "tier": "T1"
  },
  {
   "claim": "HOST ATTRIBUTION FOR MATCH 2 IS WRONG IN THE BRIEF: Rajel hosted match 2, not SAURON. This matters because 'everybody ghosted except SAURON' then has nothing to do with being host.",
   "evidence": "digest_rajel_GAMERSLAB.txt lines 276-277: `[lobby] host: lobby up (109775244803328513)` / `[lobby] host: GO with 3 member(s), map [169.254.77.1=76561197970523304;...]` — 169.254.77.1 is Rajel's own identity. digest_sauron_XIAOLONG.txt lines 285-286: `[lobby] join: in lobby, waiting for GO` / `my token [169.254.77.2]`. Match 2 transport confirms it: Rajel dials both peers on channel 0 and receives both on channel 1 (digest_rajel lines 284-291) — the hub role SAURON held in match 1 (digest_sauron lines 21-28). Entity IDs follow: the hub is entity 2 (SAURON 2:1 in match 1, Rajel 2:1 in match 2).",
   "tier": "T2"
  },
  {
   "claim": "ALL FOUR non-host death cycles in match 1 STRANDED — 'death cycle START (death #1)' with no matching 'RESET at drop zone (death #1)' anywhere in the owner's own log — whereas in the earlier good sessions the same cycle completed in under a second. This is the shared root of the ghost reports and of the 'warp lasted several seconds longer than normal' report, and it is the condition that makes a victim stop applying damage.",
   "evidence": "Match 1 strandings (each START+DESTROYED+SWALLOWED with no later RESET): elengil_ALIA.log L12722-12724; rajel_GAMERSLAB.log L56169-56171; vex_LEGION.log L54027-54029; connman_MSFIREFLY.log L51424-51426. SAURON (host) never died in match 1 — its s16 contains exactly ONE [respawn] line, L70232. Completed control cases: sauron_XIAOLONG.log s7 L8867 START -> L8952 `RESET at drop zone (death #1) -- death cycle complete, latch cleared` (85 lines, ~1 s); rajel_GAMERSLAB.log s5 L21796 START -> L21878 RESET#1. Tally across the night: 2 completed, 6 stranded.",
   "tier": "T2"
  },
  {
   "claim": "The mission on every machine ends on its own LOCAL 'solo' clock, and the deathTally arrives at spawn as -2. Both are consistency smells worth a look while the respawn path is open.",
   "evidence": "Five independent local expiries in match 1: sauron_XIAOLONG.log L95547, elengil_ALIA.log L14232, rajel_GAMERSLAB.log L58522, vex_LEGION.log L54604, connman_MSFIREFLY.log L53735 — all `[mission] solo game clock expired (300s) -- ending the mission` (600s in the earlier sessions). elengil_matchlog.txt L5: `VEHICLE ... player=4:1 mech=4:23 deaths=-2 pos=563.3,25.0,-290.0` then L9 `RESPAWN ... player=4:1 deaths=0 alive=1`.",
   "tier": "T2"
  }
 ],
 "crossMachine": "All five machines loaded the SAME map in each match — I checked and disproved a wrong-map theory: match 1 = arena2_morning_default + ar2mornfog on all five ([pfx] page names, per session), match 2 = polar_day_default + pldayclear on all three; ground plane agrees too (all match-1 [drive] pos have y=0, all match-2 y=25).\\n\\nThe machine-independent ambient effect count is the cleanest cross-machine ruler in this data set, because every machine that runs a map correctly produces the SAME total: ar2mornclear sessions = 3481 / 3481 / 3481 / 3477; ar2mornfog (match 1) = 3184 / 3184 / 3184 / 3184; polar (match 2) = 448 / 448 / 448. Against that ruler ELENGIL/ALIA is the sole outlier and only on arena2: 440 of the expected 3184 in match 1 (then a hard stop at L11686), and ZERO of the expected 3481 in its earlier 600 s arena2 session (s4) — yet a perfect 448 of 448 on the polar map in match 2. So ALIA's DPL effect stream fails on arena2 specifically, and match 2 (where Elengil's damage exchange looks healthy both ways in elengil_matchlog.txt) is on the map where it works.\\n\\nSame event, four other machines vs Elengil: the four deaths of match 1 produced 4 ExplosionScripts + 4 wreck swaps + 4 wreck burials on SAURON, Rajel, Vex and Connman, and 0 + 0 + 0 on Elengil — including Elengil's own death, which its log records at L12722-12723. Audio inverts the same way: acquire failures were 1904 / 1682 / 1444 / 1407 on the four machines whose effect stream ran, and 0 on Elengil, whose effect stream had died — so Elengil's 'no weapon audio' and everyone else's 'no weapon audio' are two different mechanisms with the same perceived result, and both are local.\\n\\nUniform across all five machines and every session (so: not tonight's regression): [drive]/[spike] thr=0 in 3960/3960 samples; one 'solo game clock expired' mission end per machine per match; the deathPending SWALLOWED warning one line after every death START. Uniform ABSENCE across all five: zero lines of [wire], [rx0], [mrec-rx], [syncF], [repl*, [mslide], [zone-armor], [crit*, [gimp*, MakeMessage, interest — every replication, crit and gimp diagnostic is env-gated off, so no machine's inbound wire is observable at all.",
 "unexplained": [
  "WHY ALIA's DPL effect stream dies on arena2 and only on arena2 — entirely (0 of 3481) in its 600 s session 4, and partially then permanently (440 of 3184, hard stop at elengil_ALIA.log L11686) in match 1, while producing a perfect 448 of 448 on the polar map. This is the single highest-value reproducible lead in the data set (one machine, one map, repeatable) and nothing in the logs names the failure — there is no error, warning or [pfx]/[particles] anomaly at the stop point; [particles] device objects created (max=8192, texture=loaded) is identical to the healthy machines.",
  "Whether any victim ever APPLIED a shooter's damage. There is no victim-side damage telemetry in the raw logs ([damage] appears exactly once per session, at death) and elengil_matchlog.txt is the only matchlog supplied — and it covers match 2, not the bad match 1. The 'weapons fire, no damage' verdict is therefore inference from the shooter side plus the stale-model condition, not a measurement.",
  "The brief's established fact #2 is NOT in the host's match-1 session and needs re-sourcing before the ghost analysis builds on it: sauron_XIAOLONG.log session 16 (match 1, lines 70106-95559) contains exactly ONE [respawn] line — L70232, the initial spawn — so it holds neither a stranded cycle for 6:1 nor five RESET lines for 3:1. The likely true source is SAURON session 13 (lines 40072-70079, the 23:01 arena2 match, which does contain a stranded 6:1 START at L58820) — a different match from the two bad ones.",
  "Why 6 of the night's 8 death cycles stranded while 2 completed in under a second. The logs give the START, the DESTROYED and the SWALLOWED dedupe but nothing from inside the drop-zone hunt, so the failing step in the 5 s hunt is invisible.",
  "Remote mech position error is unmeasurable here — replicant positions are never logged (only the local mech's [drive]/[sync]/[gait]) and all wire telemetry is off. The frame-rate-dependent local prediction is a mechanism from code, not an observation.",
  "Which consumers of the dead local `throttle` actually bite in the pod. wantGait (mech4.cpp:3794) can never select the RUN clip and the reverse sign (3884/4061/4176) can never go negative, but the leg channel reads the live mapper demand independently, so the net effect on the LinearSpeed arc and on reverse needs a live pod check rather than more log reading.",
  "Elengil s6 has [loadobj]=107 — 3 to 13x every other machine in match 1 (SAURON 37, Rajel 18, Connman 18, Vex 8) — while performing zero wreck swaps, the usual reason to stream debris models. Unexplained, and possibly the same underlying fault as the effect stall."
 ]
}

run wf_912965d1-8f0 -- 21 completed agent(s)

{
 "area": "tools/btoperator.py — generated player bats + operator local-instance logging (vs. game/btl4main.cpp self-naming and players/*.bat)",
 "findings": [
  {
   "title": "Exported play_solo.bat names a log the exe will never write (solo stem vs. join_YYYYMMDD.log)",
   "severity": "high",
   "location": "C:\\git\\bt411\\tools\\btoperator.py:1434-1439 (sets \"set BT_FE_SOLO=1\", sign-off says \"echo send the operator the newest content\\\\join_YYYYMMDD.log.\"); stem logic C:\\git\\bt411\\game\\btl4main.cpp:595-604",
   "failure_scenario": "I generated the three bats by exec'ing the exact literals from _export_scripts. The exported play_solo.bat contains `set BT_FE_SOLO=1`, no BT_RELAY and no BT_FE_JOIN. btl4main.cpp:597 resolves the stem to \"solo\", so the file on disk is content\\solo_20260728.log. The bat's closing line tells the tester to send content\\join_YYYYMMDD.log — a file that does not exist for a solo-only player. Before the change the bat wrote `set BT_LOG=join.log` so the instruction matched what was produced; removing BT_LOG made the wording wrong instead of merely odd. Net effect on the goal of the change: the solo tester reports \"there is no such file\" and the actual crash log is never requested."
  },
  {
   "title": "Generated bats never clear an inherited BT_LOG / BT_FE_SOLO, so the self-naming (always-append) path can be silently defeated",
   "severity": "high",
   "location": "C:\\git\\bt411\\tools\\btoperator.py:1359-1385 (guarded() preamble emits only BT_PLATFORM), 1405-1407, 1423-1424, 1434-1435",
   "failure_scenario": "Confirmed against btl4main.cpp:584-618: BT_LOG UNSET -> logSelfNamed=1 -> open mode is unconditionally (ios::out|ios::app), then _putenv_s pins BT_LOG/BT_LOG_APPEND=1 for the relaunch, so a clean double-click no longer truncates. But BT_LOG SET wins verbatim, and the generated bats also no longer set BT_LOG_APPEND — so if BT_LOG is present in the launching environment (a cmd window that already ran a pre-change bat or an mp_*.sh/dev shell, a machine-level var, the operator's own shell), btl4main opens that file with plain std::ios::out and TRUNCATES it. That is precisely the evidence-destroying behaviour this change exists to remove, and nothing in the bat hints at it. Same class, no truncation but wrong file: a leftover `BT_FE_SOLO=1` from running play_solo.bat earlier in the same window makes the exported join.bat log to solo_20260728.log while its sign-off asks for join_YYYYMMDD.log (btl4main.cpp:597 is checked before the BT_FE_JOIN/BT_RELAY branch at :598). players/play_steam.bat:19-21 guards exactly this shell-inheritance case with `set BT_FE_SOLO=`; the generated bats have no equivalent for BT_LOG, BT_LOG_APPEND or BT_FE_SOLO."
  },
  {
   "title": "Generated bats carry no lastrun.txt bracket and overwrite the shipped bats that do",
   "severity": "medium",
   "location": "C:\\git\\bt411\\tools\\btoperator.py:1396-1439 (writes join.bat / join_lan.bat / play_solo.bat); cf. C:\\git\\bt411\\players\\join.bat:33+, players\\play_steam.bat:49-53; C:\\git\\bt411\\tools\\mkdist.py:79-82",
   "failure_scenario": "All five players/*.bat now do `if exist lastrun.txt del lastrun.txt` before the exe and `if not exist lastrun.txt echo THE EXE NEVER REACHED WinMain ... > lastrun.txt` + the exit-code append after it. The three generated bats do none of it, and they use the SAME basenames that mkdist ships at the zip root (join.bat, join_lan.bat, play_solo.bat) and are exported into the extracted game folder (\"Keep this file next to content\\ + build\\\"), so exporting REPLACES the bracketed bats with unbracketed ones for exactly the population the operator console serves. Consequences: (a) no exit code is ever recorded for a remote joiner; (b) nothing deletes lastrun.txt, and btl4main.cpp:706 opens it with ios::app, so it accumulates forever and a stale \"exe reached WinMain\" block from an earlier run makes the presence test lie — an AV-blocked launch looks healthy; (c) the sign-off never mentions lastrun.txt, so the #41 launch forensics are never collected from exported players at all."
  },
  {
   "title": "Divergence from players/*.bat is now net LARGER, and exported play_solo.bat re-exposes the 2026-07-25 premature sign-off",
   "severity": "medium",
   "location": "C:\\git\\bt411\\tools\\btoperator.py:1359-1439 vs C:\\git\\bt411\\players\\play_solo.bat:34-95, players\\join.bat:33-95",
   "failure_scenario": "Rotation pair: gone from both sides, so that axis converged to zero. Everything else diverged. lastrun bracket: 5/5 shipped bats vs 0/3 generated. OUR-GENERATION pid snapshot + handoff wait loop: present in join/join_lan/solo/steam, absent from all three generated. Correct per-stem sign-off: shipped bats name their own stem, generated play_solo.bat does not. Worst case is the generated play_solo.bat: it launches with NO args, so the front end CreateProcess()es the mission generation and ExitProcess(0)s itself — the bat immediately prints \"The game has exited\" and pauses while the mission is still loading (the exact bug players/play_solo.bat's wait loop was added to fix on 2026-07-25), and the file it then names does not exist. They should gain the delete/probe/exit-line trio and the correct stem before this ships."
  },
  {
   "title": "btoperator's own local instances are now the only launcher left on two-generation rotation — the failure mode the change exists to fix",
   "severity": "medium",
   "location": "C:\\git\\bt411\\tools\\btoperator.py:1299-1305 (rotate_log + BT_LOG=operator_N.log + BT_LOG_APPEND=1) and the rotate_log helper at :40-55",
   "failure_scenario": "The verbatim contract still holds — btl4main.cpp:588-591 takes BT_LOG as-is and :613-616 honours BT_LOG_APPEND=1 — so nothing about the operator path breaks, and rotate_log still mechanically works (the exe is not running when the button is pressed). But rotate_log keeps exactly TWO generations (operator_N.log + .log.1), which is verbatim the situation the change's own rationale calls fatal (\"rotation kept only TWO generations ... a player crashed twice and his own relaunches deleted both crash sessions\"). Press \"Launch local instances\" three times while chasing a crash and the first session's operator_N.log is gone — on the operator's box, where the developer is the one collecting evidence. The comment at :1299-1301 (\"Fresh file per launch press\") now states a policy the rest of the tree just abandoned. Either let these self-name a day file too, or make rotate_log keep dated generations."
  },
  {
   "title": "Every local instance appends to one shared content\\lastrun.txt that nothing deletes and the GUI never reads",
   "severity": "low",
   "location": "C:\\git\\bt411\\tools\\btoperator.py:1306-1311 (proc.setWorkingDirectory(CONTENT), N instances) with C:\\git\\bt411\\game\\btl4main.cpp:700-720",
   "failure_scenario": "All local instances run with cwd = content\\, so each one appends its \"exe reached WinMain\" block to the same content\\lastrun.txt as the player bats use, with no delete step and concurrent handles from up to 8 processes. On the operator machine the file grows without bound and its \"absence means blocked\" semantics — the whole point of the breadcrumb — is meaningless there, while btoperator never reads it, so the launch evidence it now produces for local instances is collected by nobody."
  },
  {
   "title": "Consequence of the change left unfinished: shipped README and docs still tell players to send content\\join.log",
   "severity": "low",
   "location": "C:\\git\\bt411\\players\\README.txt:32 and :41 (shipped in the zip by tools/mkdist.py:109-122); also C:\\git\\bt411\\docs\\OPERATOR_GUIDE.md:244, C:\\git\\bt411\\context\\operator-console.md:384",
   "failure_scenario": "README.txt is versioned into every distribution zip and still reads \"If the game closes unexpectedly, send the operator content\\join.log\" and \"IF ANYTHING CRASHED OR MISBEHAVED, send content\\join.log\". After this change no launcher ever produces join.log, so the one document every playtester reads names a nonexistent file — directly against the stated goal of reducing what a tester has to send."
  },
  {
   "title": "Stem is resolved before environ.ini is applied, so BT_STEAM_NET set in the settings file logs to btl4_YYYYMMDD.log",
   "severity": "low",
   "location": "C:\\git\\bt411\\game\\btl4main.cpp:595-604 (stem resolution) vs :731 (BTLoadEnvironIni()); template advertises the option at :356-358",
   "failure_scenario": "The shipped default environ.ini documents `#BT_STEAM_NET=1` as a supported persistent setting. BTLoadEnvironIni() runs at line 731, long after the stem is chosen at 595 and after the log is already open, so a player who enables Steam that way (rather than via play_steam.bat) writes btl4_20260728.log while the operator, the play_steam.bat sign-off and the new docs all expect steam_YYYYMMDD.log. Same for BT_FE_SOLO. Pinning BT_LOG via _putenv_s then makes the ini line inert in the relaunched generation too, so the mismatch is stable for the whole session."
  },
  {
   "title": "No Python or cmd defect found in the sign-off strings (verified, not asserted)",
   "severity": "info",
   "location": "C:\\git\\bt411\\tools\\btoperator.py:1386-1395 (net_tail), :1405-1407, :1423-1424, :1438",
   "failure_scenario": "`python -m py_compile tools/btoperator.py` passes. The backslashes are correctly doubled in non-raw literals (\"content\\\\join_YYYYMMDD.log\" -> content\\join_YYYYMMDD.log); implicit concatenation binds before %, and the placeholder/argument counts still match after `set BT_LOG=join.log\\n` was deleted from both format strings (\"set BT_RELAY=%s:%d\\n\" with (host, port); \"set BT_RELAY=%s\\n\" with lan_relay). I generated all three bats from the real literals and ran the new parenthesised echo lines through cmd.exe: `echo (one file per day, every session in it, each headed by a BT411 SESSION line.)` and `echo (crash, or it never connected), send the operator the` print literally — none of them sits inside a parenthesised block, so nothing closes a block early. The generated cmd is valid; the defects above are semantic, not syntactic."
  }
 ]
}
{
 "title": "Operator-exported play_solo.bat tells the tester to send content\\join_YYYYMMDD.log, but the exe writes content\\solo_YYYYMMDD.log",
 "real": true,
 "severity": "medium",
 "reasoning": "Confirmed by reading both sides; I tried and failed to refute it.\n\nEXPORTED BAT (tools/btoperator.py:1427-1439): _export_scripts writes play_solo.bat via guarded(header, sets, run, tail) with sets = \"set BT_START_INSIDE=1\\nset BT_DEV_GAUGES=1\\nset BT_FE_SOLO=1\\n\". No BT_LOG, no BT_RELAY, no BT_FE_JOIN. guarded() (1359-1370) contributes only \"set BT_PLATFORM=glass\" and \"cd content\", so cwd is content\\. The tail at 1437-1438 is: echo send the operator the newest content\\join_YYYYMMDD.log.\n\nEXE (game/btl4main.cpp:587-604): with BT_LOG unset the stem chain is BT_JOYCONFIG -> BT_STEAM_NET -> BT_FE_SOLO -> (BT_FE_JOIN||BT_RELAY). BT_FE_SOLO is tested at line 597, BEFORE the join/relay branch at 598, so stem resolves to \"solo\" and wsprintfA produces solo_YYYYMMDD.log, written into content\\. The file the bat names never exists for a solo-only player.\n\nREFUTATION ATTEMPTS THAT FAILED:\n- Guard elsewhere: grep for BT_LOG in tools/btoperator.py returns only line 1304 (env.insert(\"BT_LOG\", log_name)), which applies to the operator GUI's own QProcess launches (operator_N.log), never to the exported bats.\n- Matchlog confusion: no. Both sides are the BT_LOG std::cout game log; marshal.log/matchlog are not involved.\n- Env leakage making stem \"join\": even if BT_RELAY were inherited from a prior shell, BT_FE_SOLO is evaluated first, so the stem is still \"solo\". Unreachable as a rescue.\n- Pre-existing rather than introduced: the diff proves otherwise. Old code was \"set BT_RELAY=... set BT_LOG=join.log\" and for solo \"set BT_LOG=join.log\\nset BT_FE_SOLO=1\", with tail \"echo send the operator content\\join.log\" - self-consistent. This change removed BT_LOG (making the exe self-name) and only date-stamped the sign-off, leaving the wrong stem.\n\nCORROBORATION: the hand-maintained sibling players/play_solo.bat:97 was correctly updated by this same change to \"content\\solo_YYYYMMDD.log\" (and 16 carries the comment \"content\\solo_YYYYMMDD.log\"). The generated twin in btoperator.py was simply missed. The shared net_tail at btoperator.py:1386-1395 is correct for join.bat/join_lan.bat because those set BT_RELAY -> stem \"join\"; only the solo tail is wrong.\n\nSeverity medium rather than high: no evidence is destroyed - the correct content\\solo_YYYYMMDD.log is on disk, opened append-always and never rotated, and the header line identifies it - so the operator can re-ask. But it does defeat the change's stated goal for anyone using the operator-exported solo bat, since the first request names a nonexistent file.",
 "fix": "In tools/btoperator.py, line 1438, change the stem in the play_solo.bat sign-off from \"join\" to \"solo\".\n\nReplace:\n                \"echo.\\necho The game has exited.  If it closed unexpectedly,\\n\"\n                \"echo send the operator the newest content\\\\join_YYYYMMDD.log.\\n\"\n                \"pause\\n\"))\n\nWith:\n                \"echo.\\necho The game has exited.  If it closed unexpectedly,\\n\"\n                \"echo send the operator the newest content\\\\solo_YYYYMMDD.log.\\n\"\n                \"pause\\n\"))\n\nThat is the whole fix - the join.bat/join_lan.bat net_tail (line 1389) is already correct because those bats set BT_RELAY, which resolves the stem to \"join\". Optionally, for parity with players/play_solo.bat, add the day-file explainer line the net_tail already has (\"one file per day, every session in it, each headed by a BT411 SESSION line.\"), but that is cosmetic."
}
{
 "title": "Generated bats never clear an inherited BT_LOG / BT_FE_SOLO, defeating the self-naming path",
 "real": false,
 "severity": "low",
 "reasoning": "The mechanism is described accurately, but the failure requires a stale BT_LOG that nothing in this repo actually produces, and the one path that can produce it destroys nothing.\n\nCODE AS READ (all confirmed):\n- C:\\git\\bt411\\tools\\btoperator.py:1359-1385 — guarded() emits only `set BT_PLATFORM=glass` plus the per-bat `sets` string; no setlocal, no `set BT_LOG=`, no `set BT_LOG_APPEND=`, no `set BT_FE_SOLO=`. Same for the three call sites (1405-1407, 1423-1424, 1434-1435).\n- C:\\git\\bt411\\game\\btl4main.cpp:587-616 — BT_LOG set wins verbatim, and mode is plain std::ios::out unless BT_LOG_APPEND==1. So an inherited BT_LOG-without-APPEND would truncate.\n- btl4main.cpp:597 does precede :598, so BT_FE_SOLO beats BT_FE_JOIN/BT_RELAY for the stem.\n- players/play_steam.bat:19-21 does carry `set BT_FE_SOLO=`.\n\nWHY IT IS NOT REACHABLE — every enumerated leak source fails:\n1. \"an mp_*.sh / dev shell\": false. Every harness uses a PER-COMMAND env prefix, not export — `BT_LOG=mp_a.log BT_AFFINITY=0x1 ../build/Debug/btl4.exe ... &` (scratchpad/mp_play.sh:4, mp_test.sh:11, mp_respawn*.sh, hunt53.sh:12-15, all 20+ files). The assignment dies with the command; the shell never carries BT_LOG onward.\n2. \"the operator's own shell\": false. btoperator.py:1304-1308 inserts BT_LOG/BT_LOG_APPEND into a QProcessEnvironment handed to that one QProcess — not os.environ. The GUI writes the exported bats to disk (1396-1439); it never executes them.\n3. \"a cmd window that already ran a pre-change bat\" (checked-in players/*.bat): no truncation. Those bats set the pair — `set BT_LOG=join.log` AND `set BT_LOG_APPEND=1` (diff lines 251-252, 306-307, 361-362, 405-406, 480-481) — so an inheriting run opens append mode. Wrong filename, zero data loss.\n4. The post-change bats leave nothing behind: btl4main.cpp:634-638 pins BT_LOG via _putenv_s inside the exe's own process; that never propagates back to the parent cmd window.\n5. Machine/user-level BT_LOG: nothing in the repo sets one, and the shipping path is double-click from Explorer (the bats' own \"right-click the .zip - Extract All ... run this file from THERE\" instructions plus trailing `pause`), which is a fresh cmd per launch — no inheritance between bats at all.\n\nThe single surviving path is narrow and harmless: a *pre-change generated* bat set `set BT_LOG=join.log` with no APPEND (diff:547/556/565, which is exactly what btl4main.cpp:612's parenthetical documents), so a window that ran an old exported bat and then a new one truncates legacy join.log. But pre-change that same join.log was truncated on *every* launch — that is the bug being fixed — so nothing is lost relative to baseline, the new per-day join_YYYYMMDD.log is never touched, and the condition self-heals the moment the window closes or the old bat is replaced. That is not \"the evidence-destroying behaviour this change exists to remove\"; it is a stale-window nit.\n\nThe BT_FE_SOLO half also does not support the claimed severity: a leaked BT_FE_SOLO=1 hits btl4fe.cpp:579-589 (FeSoloOnly) and gives a SOLO-ONLY menu with the network rows/buttons not laid out — the player cannot join at all. That is loud, pre-existing (the pre-change generated join.bat did not clear it either), and untouched by this change; a misnamed log is the least of it. play_steam.bat's guard is justified in its own comment as \"full menu here (all modes + steam buttons); clear the solo trim\" — a front-end-mode guard, not a logging guard, so citing it as precedent for a logging defect misreads it.\n\nFinally, the claim's location scoping is wrong: the checked-in players/join.bat, join_lan.bat, play_solo.bat, joyconfig.bat equally never clear BT_LOG, so this is a property of the whole design (BT_LOG-set-wins-verbatim is the deliberate, documented contract at btl4main.cpp:565-572 that the harnesses and operator GUI depend on), not a defect the generated bats introduced.\n\nSEPARATE, GENUINE BUG SPOTTED WHILE VERIFYING (not the claim): the exported play_solo.bat sets BT_FE_SOLO=1 (btoperator.py:1435, stem \"solo\") but its sign-off at btoperator.py:1437-1439 tells the player to send \"content\\join_YYYYMMDD.log\" — a file a solo player will never have. That mismatch is unconditional, not dependent on any inherited environment. Worth routing to the author independently.",
 "fix": "No fix required for the claim as filed. If the author wants belt-and-braces hardening anyway, it is one line in the guarded() preamble at C:\\git\\bt411\\tools\\btoperator.py:1365, next to `set BT_PLATFORM=glass`: `set BT_LOG=` + `set BT_LOG_APPEND=` (and mirror it in players/*.bat), accepting that it removes the ability to run a bat under a deliberate BT_LOG override. Do NOT blanket-clear BT_FE_SOLO in guarded() — play_solo.bat needs it set; it would have to go in the join/join_lan `sets` only. The real item to fix is the unrelated one above: change btoperator.py:1438 to name solo_YYYYMMDD.log for play_solo.bat."
}
{
 "findings": [
  {
   "title": "play_solo.bat and play_steam.bat never ask for lastrun.txt — the only file that exists when the exe is blocked",
   "severity": "high",
   "location": "C:\\git\\bt411\\players\\play_solo.bat:95-100 and C:\\git\\bt411\\players\\play_steam.bat:87-90 (probe written at play_solo.bat:60 / play_steam.bat:52)",
   "failure_scenario": "Antivirus or a loader failure kills btl4.exe before WinMain. No solo_YYYYMMDD.log / steam_YYYYMMDD.log is ever created, so the bat's probe fires and writes 'THE EXE NEVER REACHED WinMain: blocked before it could run' into content\\lastrun.txt. The wait loop then falls straight through and the window prints 'send the operator TODAY'S log ... content\\solo_YYYYMMDD.log (the newest solo_*.log)' — a file that does not exist. The playtester reports 'there is no log' and the single artifact carrying the diagnosis is never requested. join.bat:87 and join_lan.bat:90 correctly say 'send the operator BOTH files: content\\join_YYYYMMDD.log ... and content\\lastrun.txt'; solo and steam do not. This defeats the stated purpose of the bracket newly added to play_steam.bat, whose own comment (play_steam.bat:44-48) says it exists because 'a Steam player whose exe was killed before WinMain left no evidence at all'."
  },
  {
   "title": "Removing 'set BT_LOG=' lets a gate leaked from a previous bat in the same console silently redirect the log to the wrong stem",
   "severity": "medium",
   "location": "C:\\git\\bt411\\players\\join.bat:29 / join_lan.bat:32 / play_solo.bat:27 / joyconfig.bat:13,22 (gates set before setlocal); stem precedence at C:\\git\\bt411\\game\\btl4main.cpp:595-598",
   "failure_scenario": "Every bat sets its gate BEFORE 'setlocal EnableDelayedExpansion' (play_solo.bat:27 vs :53, play_steam.bat:11 vs :39, join.bat:29 vs :42, join_lan.bat:32 vs :45; joyconfig.bat has no setlocal at all), so the gate survives the implicit endlocal at 'exit /b' and persists in the calling shell — verified in cmd: BT_FE_SOLO=1 read back as 1 after the bat returned. In one cmd window run play_solo.bat, quit, then join.bat: join.bat sets BT_FE_JOIN=1 and BT_RELAY but never clears the leaked BT_FE_SOLO, and BT_FE_SOLO is checked BEFORE BT_FE_JOIN/BT_RELAY (btl4main.cpp:597-598), so the exe resolves stem='solo'. The entire multiplayer session, crash included, is written to solo_YYYYMMDD.log while join.bat:87 tells the player to send content\\join_YYYYMMDD.log, which does not exist. Symmetrically play_steam.bat then play_solo.bat writes the solo session into steam_YYYYMMDD.log (play_solo never clears BT_STEAM_NET), and joyconfig.bat (no setlocal, leaks BT_JOYCONFIG=1, top precedence) sends every later bat's log to joyconfig_YYYYMMDD.log. Before this change each bat set BT_LOG=<stem>.log explicitly, so no leaked gate could move the log. play_steam.bat:19-21 already documents this same-console sequence ('clear the solo trim in case this shell ran play_solo.bat first') as a real occurrence."
  },
  {
   "title": "No bat clears an inherited BT_LOG, and none sets BT_LOG_APPEND=1 any more, so an inherited BT_LOG opens the log in TRUNCATE mode",
   "severity": "medium",
   "location": "all five C:\\git\\bt411\\players\\*.bat (BT_LOG / BT_LOG_APPEND assignments removed); open mode at C:\\git\\bt411\\game\\btl4main.cpp:613-616",
   "failure_scenario": "On a machine or console where BT_LOG is already defined (a user-level env var, a wrapper .cmd, an operator/dev shell), the exe takes it verbatim and logSelfNamed stays 0; with BT_LOG_APPEND absent the ternary at btl4main.cpp:613-616 selects plain std::ios::out — TRUNCATE. No <stem>_YYYYMMDD.log is created at all, so the sign-off names a nonexistent file, and worse, every launch AND every menu->mission relaunch truncates that file, so the crashing mission generation erases the menu's lines. That is exactly the evidence loss this change exists to remove, and it was impossible before because all five bats unconditionally set both BT_LOG=<stem>.log and BT_LOG_APPEND=1. A single defensive 'set BT_LOG=' per bat restores the guarantee."
  },
  {
   "title": "play_solo.bat's new forensics comment contradicts itself and the code: 'truncate-writes' vs 'APPENDS'",
   "severity": "low",
   "location": "C:\\git\\bt411\\players\\play_solo.bat:31 vs :39 (two merged drafts, lines 39-43 duplicate 29-38); actual behaviour at C:\\git\\bt411\\game\\btl4main.cpp:691",
   "failure_scenario": "Line 31 states 'The exe truncate-writes content\\lastrun.txt at its first breath', line 39 states 'Delete it and do NOT pre-write: the exe APPENDS its own block'. The code opens std::ios::out | std::ios::app — append. A maintainer who believes line 31 restores the bat-side pre-launch line this diff removed ('echo [%DATE% %TIME%] launching btl4.exe > lastrun.txt'); lastrun.txt then always exists after launch, the 'if not exist lastrun.txt' probe never fires again, and a blocked exe is silently reported as a healthy run — the exact failure the probe was introduced to replace. The mirror comment at btl4main.cpp:684-687 is already stale for the same reason: it justifies append with 'the .bat ... writes its own \"launching\" line into it FIRST', which no bat does any more."
  },
  {
   "title": "Orphaned rem text still describes the deleted rotation and names logs the bats no longer write",
   "severity": "low",
   "location": "C:\\git\\bt411\\players\\joyconfig.bat:16-21, C:\\git\\bt411\\players\\play_steam.bat:12-13, C:\\git\\bt411\\players\\join.bat:12-15, C:\\git\\bt411\\players\\join_lan.bat:15-18",
   "failure_scenario": "joyconfig.bat:16-21 still says 'one fresh log per bat run', 'Rotate, never delete' and 'The wizard writes joyconfig.log' — the rotation pair is gone, the log is now per-day not per-run, and joyconfig.log is never written (it is joyconfig_YYYYMMDD.log). These sit two lines above the new LOGGING rem at joyconfig.bat:23-30 that says the opposite; play_steam.bat:12-13 and join.bat:12-15 / join_lan.bat:15-18 carry the same stranded 'Rotate, never delete' pair. A maintainer restoring 'rotation' per these comments reintroduces 'del *.old.log' / 'ren *.log *.old.log', which against per-day names rotates solo_20260728.log into solo_20260728.old.log and deletes the previous day's file — recreating the two-generation window this change was made to eliminate."
  },
  {
   "title": "players/README.txt, shipped alongside the bats, still tells playtesters to send content\\join.log",
   "severity": "low",
   "location": "C:\\git\\bt411\\players\\README.txt:32 and :41",
   "failure_scenario": "README.txt:32 says 'If the game closes unexpectedly, send the operator content\\join.log' and :41 says 'IF ANYTHING CRASHED OR MISBEHAVED, send content\\join.log -- that one is never uploaded and only exists on your machine'. No bat writes join.log any more. Because the rotation pair was also removed, nothing now deletes the pre-upgrade join.log / join.old.log / solo.log / steam.log left in content\\ by an earlier install, so a playtester following the README finds a plausible-looking stale join.log and sends that instead of join_YYYYMMDD.log. The operator receives a log with no trace of the crash, and the change's goal of reducing the files a playtester must send is inverted — content\\ now holds the old fixed-name files plus one accumulating file per day, with no pruning anywhere."
  }
 ],
 "area": "players/*.bat — CMD execution review of the log-consolidation change (all five bats read as-is, CMD semantics verified empirically)"
}
{
 "area": "Forensics contract of the log-consolidation change (btl4main.cpp day-log naming, lastrun.txt breadcrumb, marshal/lobby fold, players/*.bat, btoperator.py) — reviewed against the LIVE working tree, which moved during review: game/btl4main.cpp was rewritten at 07:10 (after scratchpad/logconsolidation.diff was captured at 07:08) to use _putenv_s instead of SetEnvironmentVariableA. That fix is correct and load-bearing: SetEnvironmentVariableA does not update the MSVC CRT's environ copy, so the getenv(\"BT_LOG\") in btl4console.cpp/btl4lobby.cpp would have fallen back to marshal.log and the fold would never have happened. The diff handed to me is stale on that hunk.\n\nSCENARIO VERDICTS:\n(1) Crash mid-mission, 5 more bat runs, play to 2am — SURVIVES. Nothing rotates or deletes; content\\solo_YYYYMMDD.log (stem from BT_FE_SOLO/BT_STEAM_NET/BT_FE_JOIN|BT_RELAY/BT_JOYCONFIG) is opened ios::app unconditionally when BT_LOG is unset, and BTCrashFilter flushes the [crash] block through std::cout. Operator attribution is by the nearest preceding \"===== BT411 SESSION build=/machine=/pid=\" header — reliable for a single instance, NOT reliable when two processes append concurrently, because the [crash] line itself carries no pid (finding 9). Caveats: the file is uncapped (finding 8), and on the operator-exported solo bat the player is told to send a filename that never exists (finding 1).\n(2) Midnight crossing mid-session — SURVIVES, NOT split. Gen 1 resolves the name once and _putenv_s-pins BT_LOG + BT_LOG_APPEND=1, and CreateProcessW(...,NULL env) in BTFE_RelaunchSelfAndExit lets every later generation inherit it verbatim, so a 23:50 session stays wholly in *_20260727.log. A NEW bat run after midnight correctly starts *_20260728.log. This part of the design works.\n(3) Exe killed by AV before WinMain 3x — PARTIALLY. The operator sees one content\\lastrun.txt holding \"THE EXE NEVER REACHED WinMain\" plus one exit line; attempts 1 and 2 are gone (each bat run deletes the file), and no day log is created at all. Verdict is correct but only for the FRONT-END process: an AV kill of the menu->mission generation is invisible (finding 7), BT_CRASHTEST self-test reports the same false verdict (finding 11), and on the solo/steam paths the player is never asked for lastrun.txt (finding 6) — so in the exact scenario it was built for, the evidence often does not reach the operator.\n(4) Two instances from one content\\ — WITH BT_LOG SET: log files unaffected (verbatim path preserved; mp_*.sh, tools/mp_launch.sh, btoperator.py operator_N.log all still work). But lastrun.txt is now written UNCONDITIONALLY by every btl4 process regardless of BT_LOG, so the operator's N local instances and any player bat all append to one shared file. WITH BT_LOG UNSET: both instances self-name the identical <stem>_YYYYMMDD.log and append concurrently from two processes, and the lastrun.txt bracket produces a false \"never reached WinMain\" (finding 2). This is a genuine clobbering/interleaving regression.\n(5) Steam HOST (marshal thread + lobby + main thread) — AT RISK. Three independent buffered writers now target one file: std::cout/std::ofstream logfile (main), marshalLogFile FILE* (MarshalThread, started at btl4main.cpp:1141), lobbyLogFile FILE* (FE, btl4lobby.cpp, NO lock at all). The new CRITICAL_SECTION serialises MarshalLog against MarshalLog only (finding 3). On the InterlockedCompareExchange sequence specifically: it is CORRECT. Exactly one thread wins CAS(1,0); losers see 1 and spin on CAS(2,2) until the winner publishes 2; late arrivals see 2 in both the CAS and the spin and fall straight through; on x86 lock cmpxchg / xchg are full barriers so InitializeCriticalSection's stores are ordered before the 2 becomes visible. No double-init, no missed-init, no torn publish. The defects around it are the Sleep(0) spin and the misplaced scope (finding 4).\n(6) Console auto-upload, route -9 / matchlog — UNAFFECTED, confirmed. matchlog.cpp names its own file matchlog_YYYYMMDD_HHMMSS_<pid>.txt via GetLocalTime+GetCurrentProcessId, arms off BT_MATCHLOG or \"-net\" in the command line, and never reads BT_LOG; BTRelayUploadMatchLog (engine/MUNGA_L4/L4NET.CPP:702) closes and re-reads BTMatchLogPath() with its own 8 MB cap. No path in this change touches BT_MATCHLOG, matchlog.cpp, or L4NET.CPP. The only matchlog-adjacent damage is documentation: context/operator-console.md:384 and docs/OPERATOR_GUIDE.md:244 still say the never-uploaded companion is \"content\\join.log\".",
 "findings": [
  {
   "title": "Operator-exported play_solo.bat asks the playtester for a log filename that will never exist",
   "severity": "high",
   "location": "tools/btoperator.py:1434-1438",
   "failure_scenario": "The exported bat now sets only `BT_START_INSIDE=1 / BT_DEV_GAUGES=1 / BT_FE_SOLO=1` and no BT_LOG, so btl4main.cpp:597 resolves stem=\"solo\" and the exe writes content\\solo_YYYYMMDD.log — while the generated sign-off says \"send the operator the newest content\\join_YYYYMMDD.log\". Before this change the same bat set BT_LOG=join.log, so the instruction was true. A playtester who crashes in solo looks in content\\, finds no join_*.log (or finds a stale one from a different join session), and either sends nothing or sends the wrong file. This is the channel the operator GUI uses to ship bats to real playtesters, so it is the most likely path to lose evidence."
  },
  {
   "title": "Two instances from one content\\ dir: the second bat deletes the first exe's breadcrumb, producing a false \"never reached WinMain\" verdict",
   "severity": "high",
   "location": "players/join.bat:33,49; players/play_solo.bat:44,60; players/play_steam.bat:49,52; players/join_lan.bat; players/joyconfig.bat:35,38",
   "failure_scenario": "Bat A deletes lastrun.txt and launches; exe A appends its WinMain block. Bat B (same content\\) runs `if exist lastrun.txt del lastrun.txt` and deletes A's block while A is still running. When A's front end exits, bat A evaluates `if not exist lastrun.txt` — if B's exe has not reached WinMain yet the file is absent, so bat A writes \"THE EXE NEVER REACHED WinMain: blocked before it could run (loader failure or antivirus)\" about a process that ran perfectly, sending the operator hunting a nonexistent antivirus problem. If B has written, bat A silently attributes B's block to itself. The inverse also holds: a genuinely blocked exe A is masked because B created the file. Every btl4 process writes lastrun.txt unconditionally (btl4main.cpp:699-717) regardless of BT_LOG, so the operator GUI's N local instances (btoperator.py:1302-1305) collide with any player bat on the same box. With BT_LOG unset both instances also self-name the identical <stem>_YYYYMMDD.log and append from two processes."
  },
  {
   "title": "Three unsynchronised writers now share one file; the new CRITICAL_SECTION guards only one of them, and the crash block is the thing at risk",
   "severity": "high",
   "location": "game/glass/btl4console.cpp:27-61; game/glass/btl4lobby.cpp:25-48; game/btl4main.cpp:608-617,194-235",
   "failure_scenario": "After the fold, one file is written by (a) the main thread through std::cout -> std::ofstream logfile, (b) MarshalThread through marshalLogFile (started at btl4main.cpp:1141, so it runs for the whole mission), and (c) the FE through lobbyLogFile, which has NO lock at all. MarshalLog's new lock serialises (b) against (b) and nothing else. BTCrashFilter accumulates ~30 stream inserts and flushes once at the end, so the entire [crash] stack goes out as a single buffered write; the CRT implements append as seek-to-EOF followed by write, which is not atomic across independent handles. A crash during mission start/stop — exactly when MarshalLog is loudest (\"RunMission #1 sent\", \"mission clock expired\", \"relaunching the menu\") — can have its stack block interleaved or overwritten by a marshal line. That is the specific evidence the whole change exists to preserve."
  },
  {
   "title": "MarshalLog lazy-init: the interlocked sequence is sound, but the Sleep(0) spin can stall against a higher-priority caller and the lock is never deleted",
   "severity": "medium",
   "location": "game/glass/btl4console.cpp:33-42",
   "failure_scenario": "Rigorous read: CAS(&ready,1,0) is won by exactly one thread, which calls InitializeCriticalSection then InterlockedExchange(...,2); every loser observes 1 and spins on CAS(&ready,2,2) until 2 appears; a thread arriving after publication sees 2 in both the CAS and the spin and falls through. On x86 `lock cmpxchg`/`xchg` are full barriers, so the CS's initialised state is ordered before the 2 becomes visible — no double-init, no missed-init, no torn publish. The defects are around it: (1) `Sleep(0)` yields only to a READY thread of equal-or-higher priority on the same core, so a spinner that outranks the initialiser does not yield to it — the repo does create a THREAD_PRIORITY_TIME_CRITICAL thread (engine/MUNGA_L4/L4PCSPAK.cpp:325) — and the spin runs until the balance-set manager boosts the starved initialiser (~seconds), a stall inside a logging call; SwitchToThread()/Sleep(1) or one-time init before the threads start would be correct. (2) DeleteCriticalSection is never called. (3) The spin's CAS(2,2) performs a needless locked write-back where a volatile read suffices. (4) When fopen fails, marshalLogFile stays NULL so every subsequent call retries fopen under the lock."
  },
  {
   "title": "Removing `set BT_LOG=` lets a leftover env var from an earlier bat in the same shell misname the log",
   "severity": "medium",
   "location": "game/btl4main.cpp:594-598; players/play_solo.bat:27; players/join.bat:11,29; players/joyconfig.bat:13,22",
   "failure_scenario": "Stem precedence is BT_JOYCONFIG > BT_STEAM_NET > BT_FE_SOLO > BT_FE_JOIN|BT_RELAY, and no bat clears the others. Run play_steam.bat then play_solo.bat from the SAME cmd window: BT_STEAM_NET=1 is still set, so the solo session's crash lands in content\\steam_YYYYMMDD.log while play_solo.bat's sign-off tells the player to send solo_YYYYMMDD.log — which either does not exist or holds an unrelated older session. Same for join.bat after play_steam.bat, and for any bat after joyconfig.bat (BT_JOYCONFIG persists in the shell and wins outright). play_steam.bat:19-21 explicitly clears BT_FE_SOLO \"in case this shell ran play_solo.bat first\", so the codebase already treats shell reuse as real. The old bats were immune because each named BT_LOG explicitly."
  },
  {
   "title": "The solo and Steam bats never ask the player for lastrun.txt — the only file carrying the \"never reached WinMain\" verdict",
   "severity": "medium",
   "location": "players/play_solo.bat:95-101; players/play_steam.bat:87-90; players/joyconfig.bat:40",
   "failure_scenario": "Scenario 3 (antivirus kills the exe before WinMain) produces NO day log at all — the only artifact is content\\lastrun.txt. play_solo.bat's sign-off asks only for \"content\\solo_YYYYMMDD.log ... the newest solo_*.log\"; play_steam.bat asks only for steam_YYYYMMDD.log; joyconfig.bat prints no sign-off at all and just `exit /b`. The player looks for a log that was never created, concludes there is nothing to send, and the diagnosis is lost. join.bat:86-87 and join_lan.bat get this right (\"send the operator BOTH files\"), and play_steam.bat GAINED the bracket in this change yet still does not request the file it now produces."
  },
  {
   "title": "The launch bracket's exit half describes the front end, not the generation that crashes; an AV kill of the mission generation is undetectable",
   "severity": "medium",
   "location": "players/play_solo.bat:58-61; players/join.bat:47-50; game/glass/btl4console.cpp:257-263",
   "failure_scenario": "`..\\build\\Release\\btl4.exe` returns as soon as the FRONT END hands off (BTFE_RelaunchSelfAndExit CreateProcessW's the mission generation then ExitProcess(0)), so `set BT_EXITCODE=%ERRORLEVEL%` and the exit line always record the menu process exiting 0. The mission generation then appends its own \"exe reached WinMain\" block AFTER that line, and its exit code is never recorded by anyone — so a mission generation that crashes leaves lastrun.txt reading \"btl4.exe exited with code 0\". Worse for scenario 3: if the mission generation is the one blocked by antivirus, lastrun.txt already exists (gen 1's block), so `if not exist lastrun.txt` cannot fire and the block goes unreported; the player just sees \"The game has exited\"."
  },
  {
   "title": "The per-day log has no cap, no rotation and no cleanup — it can outgrow the channel playtesters use to send it",
   "severity": "medium",
   "location": "game/btl4main.cpp:608-617; players/*.bat (rotation pair removed)",
   "failure_scenario": "In-repo evidence of single-session sizes: scratchpad/night5/'steam (1)_3.log' is 4.3 MB, connman_steam_old.log 1.3 MB, content/solo.log 405 KB. Scenario 1 (crash, five more launches, play until 2am) concatenates all of them into one uncapped file; six Steam-scale sessions plausibly exceed Discord's 25 MB non-Nitro attachment limit — and btl4main.cpp:640-645 cites Discord as how the 2026-07-27 evidence actually arrived. There is now no rotation at all, so the file only grows, and nothing ever removes yesterday's <stem>_YYYYMMDD.log files from content\\. Compare BTRelayUploadMatchLog (engine/MUNGA_L4/L4NET.CPP:720), which does enforce an 8 MB sanity cap."
  },
  {
   "title": "The [crash] block carries no pid or timestamp, so session attribution depends on a header that can belong to another process",
   "severity": "medium",
   "location": "game/btl4main.cpp:194-235 (BTCrashFilter)",
   "failure_scenario": "The crash filter emits \"[crash] UNHANDLED EXCEPTION code=... addr=... (btl4+0xNNNN)\" and a bare stack. In a per-day file the operator must attribute it to the nearest preceding \"===== BT411 SESSION build=...\" header to pick the matching PDB. Whenever two processes append to the same file — two instances with BT_LOG unset, or the normal FE-parent/mission-child overlap between CreateProcessW and ExitProcess — the nearest preceding header can be the other process's, so btl4+0xNNNN offsets get resolved against the wrong build and the symbolised stack is silently wrong. One `pid=` on the [crash] line would make attribution self-contained, which is the stated goal of the header."
  },
  {
   "title": "BT_CRASHTEST (the end-to-end self-test of this forensics path) reports the opposite of the truth",
   "severity": "low",
   "location": "game/btl4main.cpp:670-673 vs 699-717",
   "failure_scenario": "The deliberate AV at btl4main.cpp:672 fires before the lastrun.txt breadcrumb is written at :699, so a BT_CRASHTEST=1 run leaves no breadcrumb and the bat prints \"THE EXE NEVER REACHED WinMain: blocked before it could run (loader failure or antivirus)\" for a process that reached WinMain and wrote a full [crash] block to the day log. Anyone using BT_CRASHTEST to verify the new bracket on a player's machine will conclude it is broken. The same false verdict covers any real crash between WinMain entry and :699 — BTEnsureContentDirectory, the log open, environ-independent startup."
  },
  {
   "title": "Comments state a bat behaviour that no bat implements, and contradict themselves within one block",
   "severity": "low",
   "location": "game/btl4main.cpp:695-698; players/play_solo.bat:29-43; players/play_steam.bat:12-13; players/joyconfig.bat:16-21; players/join.bat:12-15",
   "failure_scenario": "btl4main.cpp:695-698 justifies APPEND with \"the .bat deletes this file before launching and writes its own 'launching' line into it FIRST, so truncating here would erase the outside half of the bracket\" — no bat writes a launching line; play_solo.bat:39-41 says the exact opposite (\"Delete it and do NOT pre-write ... Writing a line here first would make that test always pass\"). play_solo.bat:31 says \"The exe truncate-writes content\\lastrun.txt\" while :39 of the same block says \"the exe APPENDS\". play_steam.bat:12-13 and joyconfig.bat:16-21 and join.bat:12-15 still describe the rotation and the per-run fresh log that this change deleted. The next maintainer reading btl4main.cpp will 'fix' the append back to truncate and re-break the bracket."
  },
  {
   "title": "Runbook and docs still name the log files this change removed",
   "severity": "low",
   "location": "docs/STEAM_TEST.md:19,23,25-26; context/operator-console.md:384; docs/OPERATOR_GUIDE.md:244; players/README.txt:32,41; context/glass-cockpit.md:448; docs/GLASS_COCKPIT.md:116; phases/phase-12-orphan-processes.md:72",
   "failure_scenario": "docs/STEAM_TEST.md is the Steam bring-up checklist and instructs the tester to read \"both machines' content\\btl4.log + marshal.log\" and to look for '[lobby]' lines in marshal.log — after the fold those lines are in <stem>_YYYYMMDD.log and marshal.log is only ever written if BT_LOG is somehow unset, so the checklist finds an empty or absent file and reports the Steam path broken. players/README.txt:32,41 (the file shipped IN the player zip) still says \"send the operator content\\join.log\", which now contradicts the sign-off the same bats print. context/operator-console.md:384 and docs/OPERATOR_GUIDE.md:244 still describe \"content/join.log\" as the never-uploaded companion to the matchlog."
  },
  {
   "title": "Operator-exported bats have no lastrun.txt handling, so on that path the breadcrumb accumulates forever and the blocked-launch probe does not exist",
   "severity": "low",
   "location": "tools/btoperator.py:1360-1439",
   "failure_scenario": "The exported join.bat / join_lan.bat / play_solo.bat never delete lastrun.txt and never test for it, but the exe appends its block unconditionally (btl4main.cpp:702, ios::out|ios::app). Nothing ever truncates the file on that path, so content\\lastrun.txt grows one 3-line block per launch indefinitely, and the \"never reached WinMain\" detection — the #41 forensics this change extends to play_steam.bat — is entirely absent for the players who receive the operator's exported bats. A playtester who mixes an exported join.bat with players/play_solo.bat also gets their accumulated history deleted by the latter's `del lastrun.txt`."
  }
 ]
}
{
 "title": "Operator-generated bats shadow the shipped ones without the lastrun.txt bracket",
 "real": true,
 "severity": "low",
 "reasoning": "Core facts verified. btoperator.py's guarded() (1359-1385) emits header/cd/BT_PLATFORM/badpath/sets/\"cd content\"/run/tail with no lastrun.txt handling, while all five players/*.bat carry the delete-before + never-reached-WinMain + exit-code triple (join.bat:33,49-50; join_lan.bat:36,52-53; joyconfig.bat:35,38-39; play_solo.bat:44,60-61; play_steam.bat:49,52-53). The basename collision is real: mkdist.py:79-82 + 152-154 flatten players/*.bat to the zip root, and the export writes join.bat/join_lan.bat/play_solo.bat with mode \"w\" into a folder that must hold build\\ + content\\ (the badpath guard enforces it) -- same dir, same names, truncating write. Confirmed empirically: C:\\git\\bt411\\play_solo.bat is byte-identical to players\\play_solo.bat, and REPO is the export dialog's default target. btl4main.cpp:702 is ios::out|ios::app unconditionally, so under an exported bat nothing ever truncates lastrun.txt.\n\nBUT the claim overreaches on two of its three consequences, so severity is low, not medium:\n\n(b) is UNREACHABLE. Every bat that runs \"if not exist lastrun.txt\" also runs \"if exist lastrun.txt del lastrun.txt\" earlier in the SAME file; the exported bats have neither. Staleness left by an exported bat cannot fool any probe, because the next shipped bat deletes first. And the exe's block carries local=YYYY-MM-DD HH:MM:SS, so an accumulated file is not silently misleading to a human either. Unbounded growth is ~200 bytes/run -- immaterial.\n\n(a) is PRE-EXISTING, not introduced by this change. The pre-image (logconsolidation.diff:526+) shows the generated bats only ever had \"set BT_LOG=join.log\"; they never carried the launch_report.txt bracket that the shipped bats had. This diff removed nothing from them.\n\nThe reviewer did NOT confuse matchlog with BT_LOG -- those are distinct files (players/README.txt:35).\n\nWhat IS a genuine in-diff omission is (c): the diff edited exactly these generated sign-off strings (swapping content\\join.log for join_YYYYMMDD.log at 1389-1392 and 1437-1439) in the same commit that made lastrun.txt the forensic carrier and updated all five shipped sign-offs to demand it -- yet the operator-served population is never told to send it and produces no negative-case evidence. Also found while verifying: generated play_solo.bat sets BT_FE_SOLO=1, so btl4main.cpp:597 names it solo_YYYYMMDD.log, but btoperator.py:1438 tells the player to send join_YYYYMMDD.log -- a file that bat never writes. Net: a real but low-severity completeness gap in a file this change touched, not the medium-severity forensics collapse described.",
 "fix": "In tools/btoperator.py, put the bracket inside guarded() so all three generated bats inherit it, and name the right log in the tails.\n\n1) guarded() (~line 1370), replace\n       \"cd content\\n\" + run + tail +\n   with\n       \"cd content\\n\"\n       \"if exist lastrun.txt del lastrun.txt\\n\" + run +\n       \"set BT_EXITCODE=%ERRORLEVEL%\\n\"\n       \"if not exist lastrun.txt echo THE EXE NEVER REACHED WinMain: \"\n       \"blocked before it could run (loader failure or antivirus) \"\n       \"> lastrun.txt\\n\"\n       \"echo [%DATE% %TIME%] btl4.exe exited with code %BT_EXITCODE% \"\n       \">> lastrun.txt\\n\" + tail +\n\n2) net_tail (~1389): change \"send the operator the newest file content\\join_YYYYMMDD.log\" to ask for BOTH, matching players/join.bat:86-87 -- \"send the operator BOTH files: content\\join_YYYYMMDD.log (today's, newest) and content\\lastrun.txt\".\n\n3) play_solo tail (~1438): \"content\\join_YYYYMMDD.log\" is wrong for that bat -- BT_FE_SOLO yields stem \"solo\" (btl4main.cpp:597). Change to \"content\\solo_YYYYMMDD.log and content\\lastrun.txt\".\n\n(Separately, players/README.txt:32,41 still names content\\join.log and was not updated by this change.)"
}
{
 "area": "Repo-wide dependents of the old log filenames / launch_report.txt / marshal.log (docs, players/README.txt, tools, harnesses, .gitignore)",
 "findings": [
  {
   "title": "Shipped tester README still tells players to send content\\join.log, which no bat will ever create again",
   "severity": "high",
   "location": "C:\\git\\bt411\\players\\README.txt:32 and :41 (shipped verbatim into every zip by C:\\git\\bt411\\tools\\mkdist.py:109-122)",
   "failure_scenario": "mkdist.py copies players/README.txt into the zip root as README.txt, substituting only {VERSION}/{STEAM}/{EXPIRE}. Lines 32 (\"If the game closes unexpectedly, send the operator content\\join.log.\") and 41 (\"IF ANYTHING CRASHED OR MISBEHAVED, send content\\join.log -- that one is never uploaded\") are the ONLY crash-evidence instruction most players read (join.bat's on-screen sign-off scrolls past in a console window). After this change join.bat writes content\\join_YYYYMMDD.log. A fresh installer looks for join.log, finds nothing, and reports 'there is no log'. Worse for the upgrade path the README itself mandates (extract over the top, lines 8-14): the player's OLD content\\join.log survives from the previous build and nothing deletes it, so they send a stale pre-upgrade log as evidence for a post-upgrade crash. That is exactly the evidence-loss class the change exists to fix. The README also never mentions content\\lastrun.txt, which join.bat:87 now asks for as one of BOTH files."
  },
  {
   "title": "btoperator.py's exported play_solo.bat asks for join_YYYYMMDD.log but the exe writes solo_YYYYMMDD.log",
   "severity": "high",
   "location": "C:\\git\\bt411\\tools\\btoperator.py:1432-1439",
   "failure_scenario": "The exported solo bat now emits `set BT_START_INSIDE=1 / set BT_DEV_GAUGES=1 / set BT_FE_SOLO=1` with the `set BT_LOG=join.log` line deleted, then a sign-off `echo send the operator the newest content\\join_YYYYMMDD.log.`. With BT_LOG unset, btl4main.cpp:594-598 resolves the stem by precedence BT_JOYCONFIG > BT_STEAM_NET > BT_FE_SOLO > BT_FE_JOIN|BT_RELAY; BT_FE_SOLO=1 is set, so the exe writes content\\solo_YYYYMMDD.log. The bat instructs the player to send a file that does not exist. Before this diff the message was true because BT_LOG=join.log forced the name. The exported join.bat/join_lan.bat are fine (BT_RELAY => stem 'join'), so only the solo export is self-inconsistent."
  },
  {
   "title": "OPERATOR_GUIDE and operator-console context still name content\\join.log as the file to demand from a player",
   "severity": "high",
   "location": "C:\\git\\bt411\\docs\\OPERATOR_GUIDE.md:244 and C:\\git\\bt411\\context\\operator-console.md:384",
   "failure_scenario": "OPERATOR_GUIDE.md:244 (\"Their crash log, `content\\join.log`, is **never** uploaded — ask for it explicitly when something went wrong\") and the evidence table row in operator-console.md:384 (\"| `content/join.log` | the pod's own log |\") are the operator's own runbook for a live games night. The operator asks for join.log; the player either has nothing (fresh install) or a stale pre-upgrade file (extract-over-top install), and the real evidence in content\\join_YYYYMMDD.log plus content\\lastrun.txt is never requested. Neither doc mentions lastrun.txt at all. This is the same class of loss recorded in docs/HANDOFF.md:52 (#35, Conn Man's overwritten join.log) that motivated the change."
  },
  {
   "title": "STEAM_TEST.md's verification procedure keys on two files that will no longer exist",
   "severity": "high",
   "location": "C:\\git\\bt411\\docs\\STEAM_TEST.md:19, :23, :25, :26",
   "failure_scenario": "STEAM_TEST.md is the step-by-step for the one untested feature leg (cross-machine ConnectP2P). Its procedure says to launch `build-steam\\Debug\\btl4.exe` zero-arg from content\\ (line 14/17 — no bat, so BT_STEAM_NET is NOT set) and then check \"both machines' content\\btl4.log + marshal.log\", with step 2 keyed specifically on `marshal.log: egg -> <token>` and step 3 on `B btl4.log: [steamnet] incoming connection queued`. After the change: (a) btl4.log is now btl4_YYYYMMDD.log (bare launch => stem 'btl4'); (b) marshal.log is never created at all — btl4main.cpp:634-638 pins BT_LOG via _putenv_s, so btl4console.cpp:45-46 and btl4lobby.cpp:34-35 always take the getenv branch and the \"marshal.log\" fallback is unreachable in practice. A tester following the doc looks for two files that do not exist and cannot complete the exit criterion."
  },
  {
   "title": "README.md / build-and-run.md tell you to identify a player's build from \"btl4.log line 2\" / \"the top of their btl4.log\" — wrong on filename, wrong line, and wrong session",
   "severity": "high",
   "location": "C:\\git\\bt411\\README.md:21-23 and C:\\git\\bt411\\context\\build-and-run.md:41, :104-105",
   "failure_scenario": "README.md:21-23 (\"shows in the boot banner (`btl4.log` line 2) ... To identify any player's build, ask for the title bar or the top of their `btl4.log`\") and build-and-run.md:104-105 (\"shown in the boot banner (btl4.log head) ... Ask a tester for their title bar / btl4.log head to identify a build\") break three ways at once: (1) the file is now <stem>_YYYYMMDD.log; (2) the new SESSION header (btl4main.cpp:652-667) writes a leading \"\\n\" then the header before the `[boot]` banner, so the banner is no longer on line 2; (3) the file is per-day and append-only, so \"the head\" is the FIRST session of the day, which — as btl4main.cpp:653-657 itself concedes (\"a never-rotated day file spans builds\") — can be a different build than the crash under investigation. Reading the head to attribute a btl4+0xNNNN offset to a PDB now silently picks the wrong build. build-and-run.md:41 (\"Logs to `btl4.log` in `content\\`\") is also now false for run/run.cmd."
  },
  {
   "title": "Player bats no longer set BT_LOG, so a leaked env var from a previous bat in the same shell silently retargets the day-log stem",
   "severity": "medium",
   "location": "C:\\git\\bt411\\players\\play_solo.bat:10-27 (no BT_STEAM_NET/BT_JOYCONFIG clear), C:\\git\\bt411\\players\\play_steam.bat:10-21 (sets BT_STEAM_NET=1 before its setlocal at :39), C:\\git\\bt411\\players\\joyconfig.bat:13 (leaks BT_JOYCONFIG=1)",
   "failure_scenario": "play_steam.bat sets BT_PLATFORM/BT_STEAM_NET/BT_START_INSIDE at lines 10-18, BEFORE its only `setlocal` (line 39), so those exports survive into the calling cmd window. Run play_steam.bat and then play_solo.bat from the same window: BT_STEAM_NET is still 1, and btl4main.cpp:596 gives BT_STEAM_NET precedence over BT_FE_SOLO, so the solo session is written to content\\steam_YYYYMMDD.log — while play_solo.bat:97 tells the player to send content\\solo_YYYYMMDD.log, which does not exist. Same shape for joyconfig.bat (leaks BT_JOYCONFIG=1, highest precedence) followed by any other bat. This failure mode is new: the old bats forced the name with `set BT_LOG=<name>`. That the project cares is proven by play_steam.bat:19-21, which explicitly clears BT_FE_SOLO \"in case this shell ran play_solo.bat first\" — the reverse guards were never added."
  },
  {
   "title": "run/run.cmd now appends every dev run of the day into one file, breaking the documented \"run env-gated; read btl4.log\" verification loop",
   "severity": "medium",
   "location": "C:\\git\\bt411\\run\\run.cmd:40-41 (never sets BT_LOG); instructions at C:\\git\\bt411\\CLAUDE.md:50, C:\\git\\bt411\\context\\reconstruction-method.md:20, C:\\git\\bt411\\context\\reconstruction-gotchas.md:531",
   "failure_scenario": "run.cmd cds to content and runs the exe with BT_LOG unset, so btl4main.cpp:602-604 self-names btl4_YYYYMMDD.log and 613-616 forces ios::app unconditionally. The project's core verification rule (CLAUDE.md:50 \"run env-gated; read `btl4.log`\"; reconstruction-method.md:20 \"read `btl4.log` (grep `[anim]/[drive]/[target]/[fire]/[damage]` markers)\") now (a) names a file that does not exist, and (b) is actively unsafe once corrected to the new name: the log is never truncated, so `grep '[respawn] ammo bin'` after a fix matches the marker emitted by a run from three hours ago and the change reports as verified when it regressed. Every checklist built on this pattern is affected — docs/RESPAWN_REARM_PLAN.md:225-229, docs/KD_SCOREBOARD_PLAN.md:189-199, docs/INPUT_PATH_AUDIT.md:145, docs/RECONCILE.md:228."
  },
  {
   "title": "GLASS_COCKPIT docs still describe marshal.log as the marshal's own file",
   "severity": "medium",
   "location": "C:\\git\\bt411\\docs\\GLASS_COCKPIT.md:116 and C:\\git\\bt411\\context\\glass-cockpit.md:448",
   "failure_scenario": "Both say the marshal \"logs `content\\marshal.log`\" as part of the architecture description used to debug the front-end handoff. After btl4console.cpp:45-46 / btl4lobby.cpp:34-35 the `[marshal]` and `[lobby]` lines land in the BT_LOG day file, and the marshal.log fallback is dead code on every real path because btl4main.cpp:634-638 always pins BT_LOG. Anyone debugging a lobby/handoff failure per these docs opens a file that will not be created. Note that real field evidence in scratchpad/night5/ (marshal.log, 'marshal (1).log') shows players were being asked for that exact file as recently as 2026-07-27."
  },
  {
   "title": "scratchpad/checkbank.py defaults to a log filename that no longer exists",
   "severity": "medium",
   "location": "C:\\git\\bt411\\scratchpad\\checkbank.py:73",
   "failure_scenario": "`path = sys.argv[1] if len(sys.argv) > 1 else \"btl4.log\"` — the RIO button-field verifier's zero-arg default. Its documented workflow is a BT_RIOBANK_LOG=1 run (typically via run\\run.cmd or a bare launch, neither of which sets BT_LOG) followed by `python checkbank.py`. That run now produces btl4_YYYYMMDD.log, so the zero-arg invocation raises FileNotFoundError in parse(). This is the only harness in the repo that hard-codes a log name instead of setting BT_LOG."
  },
  {
   "title": "Bat comments contradict the code and each other after the rewrite",
   "severity": "low",
   "location": "C:\\git\\bt411\\players\\play_solo.bat:31 vs :39, C:\\git\\bt411\\players\\joyconfig.bat:16-21, C:\\git\\bt411\\players\\play_steam.bat:12-13, C:\\git\\bt411\\players\\join.bat:12-15, C:\\git\\bt411\\game\\btl4main.cpp:695-697",
   "failure_scenario": "play_solo.bat:31 says \"The exe truncate-writes content\\lastrun.txt at its first breath\" while :39-41 of the same comment block says \"Delete it and do NOT pre-write: the exe APPENDS its own block\" — the code (btl4main.cpp:702) appends, so :31 is wrong. btl4main.cpp:695-697 justifies the append mode with \"the .bat deletes this file before launching and writes its own 'launching' line into it FIRST\" — no bat does that any more; the diff replaced `echo [...] launching btl4.exe > launch_report.txt` with a bare `del`, so the stated reason for the append no longer exists. joyconfig.bat:16-21 still carries the whole pre-change block (\"one fresh log per bat run\", \"Rotate, never delete\", \"The wizard writes joyconfig.log\") immediately above the new block that says rotation is gone; play_steam.bat:12-13 and join.bat:12-15/join_lan.bat keep the orphaned \"KEEP the previous run's log ... Rotate, never delete\" lines. The next person reading these files gets contradictory statements about which file to look for."
  },
  {
   "title": ".gitignore: new artifacts are covered, but the launch_report.txt entry is dead and lastrun.txt is only ignored under content/",
   "severity": "low",
   "location": "C:\\git\\bt411\\.gitignore:14-18",
   "failure_scenario": "Coverage is correct for the intended cases: `*.log` (line 12) matches every new <stem>_YYYYMMDD.log and every newly-orphaned join.old.log / solo.old.log / marshal.log, and content/lastrun.txt is explicit — `git check-ignore` confirms all of them, and `git status` is clean of untracked content artifacts. Two gaps: (1) `content/launch_report.txt` (line 17) is added for a file nothing writes any more — harmless but it documents a live artifact that is dead; (2) `content/lastrun.txt` is path-anchored, but the exe writes the breadcrumb as a plain relative \"lastrun.txt\" (btl4main.cpp:702), so any launch where BTEnsureContentDirectory fails to locate content\\ (btl4main.cpp:430, \"NO content directory found\") drops an untracked, un-ignored lastrun.txt in whatever cwd the exe was started from — repo root or build\\Release. Separately and pre-existing (not caused by this diff): rotate_log's `<name>.log.1` outputs (tools/btoperator.py:50; documented at docs/OPERATOR_GUIDE.md:143 as parked_relay.log.1) are NOT matched by `*.log` and remain untracked-but-not-ignored."
  },
  {
   "title": "Latent log corruption when BT_LOG is set without BT_LOG_APPEND on a front-end run: three handles, one file, only two of them appending",
   "severity": "low",
   "location": "C:\\git\\bt411\\game\\btl4main.cpp:613-616 vs C:\\git\\bt411\\game\\glass\\btl4console.cpp:46 and C:\\git\\bt411\\game\\glass\\btl4lobby.cpp:35",
   "failure_scenario": "MarshalLog and LobbyLog now fopen(getenv(\"BT_LOG\"), \"at\") — always append. But btl4main.cpp:613-616 opens std::cout's ofstream WITHOUT ios::app whenever BT_LOG is set and BT_LOG_APPEND is not ('1'), i.e. plain truncate mode with an independently tracked write position. In that configuration the marshal appends past EOF while the ofstream keeps writing at its own smaller offset and overwrites those bytes (and leaves gaps). No in-repo caller hits it today — the operator GUI sets BT_LOG_APPEND=1 (tools/btoperator.py:1305), the player bats no longer set BT_LOG at all, and the BT_LOG-setting harnesses (tools/mp_launch.sh:27,29; scratchpad/mp_*.sh; rig_relay.ps1:37,42; test_remote_console_e2e.py:152-154) all launch with -egg/-net so the glass marshal never starts. It becomes a live corruption path the moment anyone adds a front-end harness with BT_LOG set and no append flag, which is the natural thing to write given the documented contract."
  },
  {
   "title": "All BT_LOG-setting harnesses and tools still work — verified, no action needed",
   "severity": "info",
   "location": "C:\\git\\bt411\\tools\\mp_launch.sh:27,29; C:\\git\\bt411\\tools\\btoperator.py:1302-1305; C:\\git\\bt411\\scratchpad\\mp_*.sh, rig_*.ps1, orphan_win.ps1:58, btwait_probe.ps1:8, test_loop_logic.ps1:32, avionics/baytest/commstest/destest/sim3/revtest/thermtest/valvetest/keysweep/loopab/loopaudit/lamptest/lampflash/flushsnd*/baypurge.py, test_mixed_version.py:128, test_remote_console_e2e.py:154",
   "failure_scenario": "No failure — the BT_LOG-verbatim contract is preserved by btl4main.cpp:587-591, so every harness that names its own file (mp_a.log/mp_b.log, fa.log/fb.log, operator_N.log, pod<N>.log, r_a.log/r_b.log, mixed_<port>.log) still greps the file it named. Two harmless deltas to be aware of: every such log now starts with a blank line plus a \"===== BT411 SESSION ...\" header before the `[boot]` line, so anything asserting on line 1/line 2 (none found — all consumers use substring or regex search) would need updating; and every launch now also appends a block to content\\lastrun.txt, which no harness deletes, so it accumulates on a dev box. Separately verified: scratchpad/test_loop_logic.ps1:8-9 lifts the `setlocal EnableDelayedExpansion` + `set \"BT_PRE=,\"` and `:btwait` blocks out of players/play_solo.bat by regex and throws if they are missing — both still match at play_solo.bat:53-57 and :82-94. tools/matchcheck.py (matchlog_*.txt only) and tools/mkdist.py (packages `git ls-files content/`, so no runtime log or breadcrumb can leak into a zip) are entirely unaffected."
  },
  {
   "title": "Historical and plan documents referencing the old names — stale only, no action required",
   "severity": "info",
   "location": "C:\\git\\bt411\\phases\\phase-12-orphan-processes.md:72; C:\\git\\bt411\\docs\\HANDOFF.md:37,:52; C:\\git\\bt411\\docs\\DIST_LAYOUT_PLAN.md:23,78,135; C:\\git\\bt411\\docs\\PROGRESS_LOG.md:824,916; C:\\git\\bt411\\docs\\RECONCILE.md:228,444,507,767; C:\\git\\bt411\\scratchpad\\crash_20260726_2316_notes.md; C:\\git\\bt411\\content\\ARENA_DEBUG.cmd:2; C:\\git\\bt411\\engine\\MUNGA_L4\\L4VB16.cpp:2206",
   "failure_scenario": "No failure — these are dated records of what happened (phase-12's \"pid + launch_report.txt confirmed\", HANDOFF's 4.11.600 release verification \"log rotation proven inside the shipped zip (solo run rotates solo.log)\", RECONCILE's captured btl4.log excerpts) or forward-looking plans (DIST_LAYOUT_PLAN's verification step 1 expecting btl4.log at the extracted root). They are correct as history and should not be rewritten; only HANDOFF.md:37 is worth a one-line note since it certifies a rotation behaviour this change deliberately removes. docs/CONTROLS.md and docs/CONTROLS.html — both shipped by mkdist.py:127-151 — contain no log-filename references and need no change."
  }
 ]
}
{
 "title": "play_solo.bat and play_steam.bat sign-offs never ask for lastrun.txt, the only artifact that exists when the exe is blocked pre-WinMain",
 "real": true,
 "reasoning": "Could not refute; the code plainly supports it. play_solo.bat:44 deletes lastrun.txt, :60 writes \"THE EXE NEVER REACHED WinMain: blocked before it could run (loader failure or antivirus) > lastrun.txt\", :61 appends the exit line — but the sign-off at :95-100 names only content\\solo_YYYYMMDD.log. play_steam.bat:49/52/53 is the identical bracket with a sign-off at :87-90 naming only content\\steam_YYYYMMDD.log. lastrun.txt is never echoed, typed, or mentioned in either sign-off. By contrast join.bat:86-87 and join_lan.bat:89-90 do say \"send the operator BOTH files: content\\join_YYYYMMDD.log (today's, newest) and content\\lastrun.txt\". Reachability confirmed: with the exe killed before WinMain there is no child process, so the :btwait loop (solo :82-94, steam :74-86) falls straight through to the sign-off; no guard anywhere prints or requests the file. Not a matchlog/BT_LOG confusion — the claim is strictly about sign-off wording versus the lastrun.txt the same bat just wrote.\n\nTwo corrections to the reviewer's framing, checked against scratchpad/logconsolidation.diff. (1) On the solo path the omission is largely pre-existing: the old sign-off was \"echo send the operator content\\solo.log.\" and never mentioned launch_report.txt either (diff line 461). But this change makes it worse rather than neutral — under the old per-run naming a blocked launch left genuinely no solo.log, so \"there is no log\" was itself an unambiguous signal; under per-day naming an earlier day's solo_YYYYMMDD.log still sits in content\\ (solo_20260728.log and solo.log are present today) and IS \"the newest solo_*.log\", so the instruction now points the playtester at a stale, clean, plausible-looking file that leads the operator to conclude nothing went wrong. (2) On the steam path the defect is entirely self-inflicted by this diff: the bracket at :44-53 is new, its own comment says it exists because \"a Steam player whose exe was killed before WinMain left no evidence at all\", and the sign-off line 89 was edited in the same hunk (steam.log -> steam_YYYYMMDD.log) without adding the very file the bracket was created to produce.\n\nSeverity is medium rather than the claimed high: this is instruction wording, not a code fault. The evidence is written and preserved on disk and is recoverable with one follow-up message from the operator; it does not destroy evidence the way the two-generation rotation bug this change fixes did.",
 "severity": "medium",
 "fix": "Two echo-block edits; no logic changes.\n\nC:\\git\\bt411\\players\\play_solo.bat — replace lines 96-100 with:\necho The game has exited.  If it closed unexpectedly, send the operator\necho BOTH files from the content folder:\necho   content\\solo_YYYYMMDD.log (today's, newest)  and  content\\lastrun.txt\necho (each session in the day log starts with a \"===== BT411 SESSION\" line\necho  naming your build and PC.  If there is no solo_*.log dated TODAY,\necho  content\\lastrun.txt alone is the answer -- send it by itself.)\n\nC:\\git\\bt411\\players\\play_steam.bat — replace lines 88-90 with:\necho The game has exited.  If it closed unexpectedly, send the operator\necho BOTH files:  content\\steam_YYYYMMDD.log (today's, newest)  and\necho content\\lastrun.txt (send lastrun.txt even if there is no log for today\necho -- it is the only evidence when the exe was blocked before it started).\necho Steam not running = the menu still works but lobbies will not appear.\n\nThis brings both bats in line with join.bat:86-87 / join_lan.bat:89-90 and makes the steam bracket added at play_steam.bat:44-53 actually deliver the evidence its own comment says it exists to capture. The explicit \"even if there is no log for today\" phrasing also stops the per-day naming from steering the playtester to a stale previous-day log."
}
{
 "title": "Operator-exported play_solo.bat tells the playtester to send content\\join_YYYYMMDD.log, but the exe writes content\\solo_YYYYMMDD.log",
 "real": true,
 "severity": "medium",
 "reasoning": "Confirmed by reading both sides. tools/btoperator.py:1427-1439 builds play_solo.bat via guarded() with sets = \"set BT_START_INSIDE=1 / set BT_DEV_GAUGES=1 / set BT_FE_SOLO=1\" and no BT_LOG (grep shows BT_LOG survives in btoperator.py only at line 1304, the GUI's own QProcessEnvironment for operator_N.log, which never reaches an exported bat). game/btl4main.cpp:587-605 then takes the BT_LOG-unset branch and walks BT_JOYCONFIG -> BT_STEAM_NET -> BT_FE_SOLO -> BT_FE_JOIN||BT_RELAY; only BT_FE_SOLO is set, so stem=\"solo\" and, since guarded() does \"cd content\" before launching, the exe writes content\\solo_YYYYMMDD.log. The _putenv_s(\"BT_LOG\", resolvedLog) pin at line 636 carries that same name through the menu->mission relaunch, so nothing downstream can turn it into a join_* name. Meanwhile the generated tail at lines 1437-1438 says \"send the operator the newest content\\join_YYYYMMDD.log\", and unlike the hand-maintained players/play_solo.bat:97 it offers no lastrun.txt fallback and no \"newest *_*.log\" hedge. The change introduced this: the diff removed \"set BT_LOG=join.log\" from the solo bat but only mechanically rewrote the tail string join.log -> join_YYYYMMDD.log, missing that the stem is now derived from the FE flag; players/play_solo.bat was updated correctly to solo_YYYYMMDD.log in the same change, so the operator-generated copy is a missed spot rather than a design decision. Two exculpatory readings were checked and excluded: (a) this is not a matchlog/marshal.log mixup -- marshal output folds into getenv(\"BT_LOG\"), which is the same solo_* file here; (b) net_tail's join_YYYYMMDD.log is correct for join.bat and join_lan.bat, both of which set BT_RELAY and so genuinely resolve stem \"join\" -- only the solo tail is wrong. Severity downgraded from high to medium: the evidence is not lost, because the per-day file no longer rotates and each session carries a \"===== BT411 SESSION\" header naming build/pid/machine, so the operator can detect a wrong attachment and re-ask. The real cost is a wasted round trip plus the concrete bad case the reviewer names -- join.bat, join_lan.bat and play_solo.bat are all exported into the same folder, so a playtester who also joined earlier that day has a stale join_YYYYMMDD.log sitting right there and will send that instead of the solo crash.",
 "fix": "In tools/btoperator.py, change the play_solo.bat tail (lines 1437-1438) from \"echo send the operator the newest content\\\\join_YYYYMMDD.log.\\n\" to \"echo send the operator the newest content\\\\solo_YYYYMMDD.log.\\n\". Optionally also append the lastrun.txt mention the hand-maintained players/play_solo.bat carries, e.g. \"echo (also content\\\\lastrun.txt).\\n\", so a stem mismatch degrades gracefully instead of pointing at a nonexistent file."
}
{
 "title": "players/*.bat no longer neutralize an inherited BT_LOG, so an ambient BT_LOG reopens the log in truncate mode across the menu→mission relaunch",
 "real": true,
 "severity": "low",
 "reasoning": "The mechanism is plainly in the code, and the guard the bats used to provide was unambiguously deleted.\n\nbtl4main.cpp:587-590 takes a non-empty getenv(\"BT_LOG\") verbatim and leaves logSelfNamed at 0. btl4main.cpp:613-616 then opens with (logSelfNamed || BT_LOG_APPEND=='1') ? out|app : out — i.e. plain std::ios::out, TRUNCATE. The rescue at btl4main.cpp:635-638 (_putenv_s BT_LOG / BT_LOG_APPEND=1) is gated on `if (logSelfNamed)`, so the inherited path never acquires append. btl4console.cpp:257 relaunches with CreateProcessW(..., lpEnvironment=NULL, ...), which inherits the parent block, so the mission generation reopens the same file in truncate mode and erases the menu's lines. That is exactly issue #33, which tools/btoperator.py:1300-1305 records as a previously observed field failure (\"each generation used to TRUNCATE the log — the night-2 crash loop left a 2-line file\"), which is why btoperator still sets BT_LOG_APPEND=1 next to its own BT_LOG.\n\nThe removal is confirmed in the diff: lines 251-252, 306-307, 361-362, 405-406 and 480-481 delete `set BT_LOG=<stem>.log` + `set BT_LOG_APPEND=1` from join.bat, join_lan.bat, joyconfig.bat, play_solo.bat and play_steam.bat respectively. A repo-wide grep confirms the five bats now contain only `rem` mentions of BT_LOG — no assignment and no clear. So the reviewer is factually right on every code point, including the downstream consequences: no <stem>_YYYYMMDD.log is created, and the sign-off text (e.g. play_solo.bat:97) names a file that does not exist.\n\nThis is NOT a matchlog/BT_LOG confusion — btl4console.cpp:45 and btl4lobby.cpp:34 fold marshal.log into the very same getenv(\"BT_LOG\") file, so the whole consolidated log is what gets truncated.\n\nWhere I disagree is severity, not existence. The scenario needs an ambient BT_LOG in the environment that launches a player bat, and nothing in the repo produces one: btoperator.py:1304-1305 sets BT_LOG only into its own QProcessEnvironment for its own pod and sets BT_LOG_APPEND=1 alongside it (safe); its exported bats (btoperator.py:1396-1437) no longer set BT_LOG at all, so they self-name; every scratchpad/mp_*.sh and scratchpad/*.py harness sets BT_LOG per-command for a direct btl4.exe launch and none invokes players/*.bat (verified by grep — only tools/mkdist.py packages them). Leftover console state from a PRE-change bat run is self-neutralizing, because those bats exported BT_LOG_APPEND=1 together with BT_LOG, yielding append. Players double-click from Explorer, so only a persistent user/system-level BT_LOG would reach them, and BT_LOG is a bespoke name nobody has set persistently. Also, truncate-on-BT_LOG-without-APPEND is the pre-existing documented contract, deliberately preserved by this change, not introduced by it.\n\nWhat tips this to real rather than refuted: the bats already use exactly the defensive-clear idiom the reviewer asks for — play_steam.bat:21 does `set BT_FE_SOLO=` with the comment \"clear the solo trim in case this shell ran play_solo.bat first\" — so cross-run environment leakage is an acknowledged hazard in these very files, and the change dropped the only thing that covered BT_LOG. And a strictly more reachable instance of the same gap is live right now: play_solo.bat never clears BT_STEAM_NET, while the stem ladder at btl4main.cpp:594-598 tests BT_STEAM_NET before BT_FE_SOLO, so running play_steam.bat and then play_solo.bat in one console makes the solo session self-name steam_YYYYMMDD.log. Same root cause, one-line fix, worth doing together.\n\nThe proposed fix is also verifiably correct: cmd's `set VAR=` deletes the variable, and even if an empty value survived, btl4main.cpp:588 guards with `*env_log != '\\0'`, so the exe falls into the self-naming branch and appends unconditionally. Clearing BT_LOG alone is sufficient; clearing BT_LOG_APPEND too is cosmetic symmetry.",
 "fix": "In each of C:\\git\\bt411\\players\\join.bat, join_lan.bat, joyconfig.bat, play_solo.bat and play_steam.bat, add a defensive clear next to the existing LOGGING comment block (before `cd content`) — e.g. in play_solo.bat immediately after line 27 (`set BT_FE_SOLO=1`):\n\n    rem clear any inherited BT_LOG so the exe self-names and ALWAYS appends\n    set BT_LOG=\n    set BT_LOG_APPEND=\n\n`set BT_LOG=` alone is sufficient (btl4main.cpp:588 treats empty as unset, which forces logSelfNamed=1 and ios::out|ios::app); clearing BT_LOG_APPEND keeps the pair symmetric. This matches the idiom already used at play_steam.bat:21 (`set BT_FE_SOLO=`).\n\nWhile there, close the more reachable sibling leak: add `set BT_STEAM_NET=` to play_solo.bat, join.bat, join_lan.bat and joyconfig.bat (and `set BT_JOYCONFIG=` to the non-joyconfig bats), since btl4main.cpp:594-598 tests BT_STEAM_NET/BT_JOYCONFIG ahead of BT_FE_SOLO when picking the day-log stem.\n\nOptionally mirror the same two `set BT_LOG=` / `set BT_LOG_APPEND=` lines into the three bats generated by tools/btoperator.py (the hunks at btoperator.py:1402, 1420 and 1432 that just dropped `set BT_LOG=join.log`)."
}
{
 "title": "Shipped tester README still tells players to send content\\join.log, a file no bat creates after this change",
 "real": true,
 "severity": "medium",
 "reasoning": "Confirmed by direct reading; refutation failed on every angle.\n\n(1) players/README.txt:32 (\"If the game closes unexpectedly, send the operator content\\join.log.\") and :41 (\"IF ANYTHING CRASHED OR MISBEHAVED, send content\\join.log -- that one is never uploaded\") exist verbatim and carry no {VERSION}/{STEAM}/{EXPIRE} marker, so mkdist.py:109-122 -- which substitutes ONLY those three tokens -- writes them byte-identical into every zip root as README.txt.\n\n(2) players/README.txt is absent from the diff's file list (.gitignore, btl4main.cpp, btl4console.cpp, btl4lobby.cpp, five bats, btoperator.py). Mtimes agree: README.txt Jul 27 21:09 vs all bats Jul 28 07:03.\n\n(3) The diff removes \"set BT_LOG=join.log\" from join.bat; grep -rn BT_LOG players/ now matches only comment lines in all five bats. btl4main.cpp:587-605 therefore takes the UNSET branch, stem \"join\" (line 598: BT_FE_JOIN || BT_RELAY), writing content\\join_YYYYMMDD.log. Nothing writes join.log anymore. Before the change the README was accurate -- this change made it wrong.\n\n(4) Not a matchlog confusion. README:34-40 covers content\\matchlog_<date>_<time>_<n>.txt separately and is still correct; lines 32/41 name the BT_LOG game log.\n\n(5) No guard prevents it and the stale-file trap is real: the rotation pair (del *.old.log / ren *.log *.old.log) was deleted, so on the extract-over-top upgrade README:8-14 mandates, the previous build's content\\join.log survives indefinitely with nothing to remove it. A player following README:41 sends a pre-upgrade log as evidence for a post-upgrade crash.\n\n(6) The README also never mentions content\\lastrun.txt, which join.bat:87 now asks for as one of BOTH files, nor the other new stems (solo_/steam_/joyconfig_YYYYMMDD.log).\n\n(7) Corroboration that it is an oversight: tools/btoperator.py:1389 and :1438 WERE updated to \"content\\join_YYYYMMDD.log\", and join.bat:84-89 was too. Only the shipped README was missed.\n\nSeverity lowered from high to medium: the claim's premise that the console sign-off \"scrolls past\" is wrong -- join.bat:84-88 prints the correct filenames immediately before pause at :89, so the correct instruction sits on screen in an open window. The defect is documentation-only and partly mitigated by that sign-off and by the new \"===== BT411 SESSION build=...\" header (btl4main.cpp:652-667), which lets an operator recognise a stale pre-change log by its absence. Still a genuine misdirection of the primary written crash-reporting instruction shipped to every tester.",
 "fix": "Edit players/README.txt only (no code change needed).\n\nLine 32, replace:\n  If the game closes unexpectedly, send the operator content\\join.log.\nwith:\n  If the game closes unexpectedly, send the operator BOTH content\\join_<date>.log\n  (today's date, e.g. join_20260728.log -- pick the NEWEST) and content\\lastrun.txt.\n\nLines 41-42, replace:\n  IF ANYTHING CRASHED OR MISBEHAVED, send content\\join.log -- that one is\n  never uploaded and only exists on your machine.\nwith:\n  IF ANYTHING CRASHED OR MISBEHAVED, send the NEWEST content\\<name>_<date>.log\n  plus content\\lastrun.txt -- those are never uploaded and only exist on your\n  machine.  The name matches the bat you ran: join.bat/join_lan.bat write\n  join_<date>.log, play_solo.bat solo_<date>.log, play_steam.bat\n  steam_<date>.log, joyconfig.bat joyconfig_<date>.log.\n  UPGRADED OVER AN OLDER VERSION?  You may still have an old content\\join.log\n  left by the previous build.  It is DEAD -- never written to again.  Do not\n  send it; delete it.\n\nOptionally also drop the same \"delete any leftover content\\join.log\" one-liner into the UPGRADING block at README:8-14, since nothing in the bats removes it now that the rotation pair is gone."
}
{
 "title": "Removing 'set BT_LOG=' lets a gate leaked from a previous bat in the same console redirect the day log to the wrong stem",
 "real": true,
 "severity": "low",
 "reasoning": "CONFIRMED as a real regression, but narrower and quieter than claimed; severity downgraded medium -> low.\n\nMECHANISM VERIFIED EMPIRICALLY. I ran a minimal repro in cmd: a bat setting BT_FE_SOLO=1 BEFORE 'setlocal EnableDelayedExpansion' and BT_INNER=1 after, then 'exit /b'. The caller printed \"AFTER: BT_FE_SOLO=[1] BT_INNER=[]\". Pre-setlocal vars do survive the implicit endlocal.\n\nPLACEMENT VERIFIED IN ALL FIVE BATS. C:\\git\\bt411\\players\\join.bat:29 (BT_FE_JOIN) vs setlocal at :42; join_lan.bat:32 vs :45; play_solo.bat:27 vs :53; play_steam.bat:11 (BT_STEAM_NET) vs :39; joyconfig.bat sets BT_JOYCONFIG=1 at :13 and BT_FE_SOLO=1 at :22 and has NO setlocal at all. Only play_steam.bat:21 ('set BT_FE_SOLO=') clears a foreign gate; nothing else clears anything.\n\nPRECEDENCE VERIFIED, NO GUARD. C:\\git\\bt411\\game\\btl4main.cpp:594-598 reads bare getenv with no cross-gate validation:\n  if (getenv(\"BT_JOYCONFIG\"))                          stem = \"joyconfig\";\n  else if (getenv(\"BT_STEAM_NET\"))                     stem = \"steam\";\n  else if (getenv(\"BT_FE_SOLO\"))                       stem = \"solo\";\n  else if (getenv(\"BT_FE_JOIN\") || getenv(\"BT_RELAY\")) stem = \"join\";\nenviron.ini cannot compensate: BTLoadEnvironIni() is called at :731, long after the log opens at :613.\n\nREGRESSION CONFIRMED BY THE DIFF. scratchpad/logconsolidation.diff lines 251, 306, 361, 405, 480 remove 'set BT_LOG=<stem>.log' from all five bats; previously the name was fixed by the bat and immune to leaked gates. tools/btoperator.py's guarded() template (:1359-1385) emits player bats with no setlocal at all, so those leak unconditionally.\n\nWHERE THE CLAIM IS WRONG. Its headline scenario (play_solo -> join, \"the entire multiplayer session written to solo_*.log\") does not happen: a leaked BT_FE_SOLO also makes FeSoloOnly() true (game/glass/btl4fe.cpp:579-589), so that shell gets the SOLO-ONLY menu and there is no MP session to misfile. Same for joyconfig: a leaked BT_JOYCONFIG re-runs the capture wizard (btl4main.cpp:744) — loud, immediately obvious, and a pre-existing leak bug independent of this change.\n\nMITIGATIONS THE CLAIM MISSED. lastrun.txt records \"log=<resolvedLog>\" (btl4main.cpp:714), and join.bat:87 / join_lan.bat:90 explicitly tell the player to send it alongside the log. Every session also carries a self-identifying \"===== BT411 SESSION build=/machine=/pid=/log= =====\" header (btl4main.cpp:652-667). So the evidence is misfiled in the same content\\ folder, never lost. And players are told to DOUBLE-CLICK (players/README.txt:16-24), which gives each bat a fresh cmd.exe inheriting Explorer's environment — the default player path cannot leak at all.\n\nWHAT SURVIVES. Exactly one silent, log-only case: play_steam.bat then play_solo.bat in one console. BT_STEAM_NET leaks (play_steam.bat:11 before setlocal:39), outranks BT_FE_SOLO (btl4main.cpp:596 vs :597), the solo menu is still correct, and BTSteamNet_Install \"degrades to Winsock\" (btl4main.cpp:1124-1130) so the session plays normally — the whole solo session lands in steam_YYYYMMDD.log while play_solo.bat:97 asks for solo_YYYYMMDD.log, and neither play_solo.bat:95-100 nor play_steam.bat:87-90 mentions lastrun.txt, so the one path without the breadcrumb hint is the one path that is silent. Reachable for the dev/tester console workflow (which is precisely play_steam.bat's stated audience, \"EXPERIMENTAL, dev testers\"), and the repo itself documents same-shell sequencing as a real occurrence at play_steam.bat:19-21. Not reachable for a double-clicking player.",
 "fix": "Fix the leak at its source instead of patching stems: hoist setlocal above the gates so nothing escapes the bat.\n\nIn C:\\git\\bt411\\players\\join.bat, join_lan.bat, play_solo.bat and play_steam.bat, MOVE the existing 'setlocal EnableDelayedExpansion' line (join.bat:42, join_lan.bat:45, play_solo.bat:53, play_steam.bat:39) up to immediately after 'cd /d %~dp0', deleting it from its current position. In C:\\git\\bt411\\players\\joyconfig.bat, ADD 'setlocal EnableDelayedExpansion' immediately after 'cd /d %~dp0' (line 9). Add the same line to the guarded() template in C:\\git\\bt411\\tools\\btoperator.py, right after the \"cd /d %~dp0\\n\" at :1361, so exported bats inherit the fix.\n\nThis is safe: nothing after the bat consumes BT_PLATFORM/BT_RELAY/BT_FE_*/BT_STEAM_NET/BT_JOYCONFIG, the exe is launched inside the setlocal scope so it still inherits every gate, delayed expansion is already what the wait loop needs, and endlocal restoring the cwd is a bonus (the 'cd content' stops leaking too). One line moved per bat, one line added to joyconfig.bat and to the generator.\n\nIf you would rather not touch setlocal placement, the narrower alternative is to extend the play_steam.bat:21 pattern — each bat explicitly clears every gate it does not own before 'cd content' (play_solo.bat adds 'set BT_STEAM_NET=' and 'set BT_JOYCONFIG='; join.bat/join_lan.bat add 'set BT_FE_SOLO=', 'set BT_STEAM_NET=', 'set BT_JOYCONFIG='; play_steam.bat adds 'set BT_JOYCONFIG='). That is more lines and stays fragile as gates are added, so prefer the setlocal hoist.\n\nIndependently of this finding, two nearby sign-off bugs are worth fixing while you are in these files: tools/btoperator.py:1438 tells a generated-solo player to send join_YYYYMMDD.log when that bat sets BT_FE_SOLO=1 (stem is \"solo\"), and players/README.txt:33 still says \"send the operator content\\join.log\", which no longer exists under the new naming."
}
{
 "title": "Shared content\\lastrun.txt: a second launch from the same folder destroys the first run's launch evidence and can invert its verdict",
 "real": true,
 "severity": "medium",
 "reasoning": "Could not refute; the code plainly supports it. game/btl4main.cpp:699-717 writes the breadcrumb as a fixed relative name with no BT_LOG gate, no pid/stem qualifier and no lock: `std::ofstream breadcrumb(\"lastrun.txt\", std::ios::out | std::ios::app);`. The stream closes at line 717, so the file is not held open and a concurrent `del` from another bat succeeds. cwd is content\\ for every launch path (bats `cd content`; gBTCwdFixNote chdirs a directly-launched exe there). All five bats delete-then-probe that one shared name (join.bat:33,49; join_lan.bat:36,52; joyconfig.bat:35,38; play_solo.bat:44,60; play_steam.bat:49,52 — cited lines are accurate), and tools/btoperator.py:1302-1309 launches N local instances with setWorkingDirectory(CONTENT), each appending to the same file.\n\nRefutation attempts that failed: (1) Not a matchlog/BT_LOG mix-up — lastrun.txt is a distinct file. (2) Not pre-existing. The diff (lines 280-282, 335-337, 381-383, 449-451) shows the old probe was PER-STEM (`if not exist join.log` / `solo.log` / `joyconfig.log`) and rotation (`ren *.log *.old.log`) guaranteed the stem log was absent at launch, so the probe was sound and cross-bat interference was impossible; launch_report.txt was a shared name but carried no verdict (the bat truncate-wrote it before launch). This change converts a per-stem signal into one global file whose mere existence is the verdict, and newly enrolls play_steam.bat. (3) Reachability is not exotic — the bats' own \"OUR GENERATION ONLY\" comment (join.bat:35-40) documents that a second client / the operator's pod / a crash orphan is an observed condition, and the same comments record the window \"looked hung\", which is exactly how a playtester ends up double-launching. On a dev box the root play_solo.bat and the operator GUI share C:\\git\\bt411\\content. (4) The delete window is the entire front-end menu lifetime (minutes), not milliseconds.\n\nWhere the claim overstates: the FALSE \"never reached WinMain\" line requires bat A's front end to exit inside [B's del, B's first breath], a sub-second race. The deterministic damage is the other two branches — A's forensic block is destroyed outright whenever B launches during A's run (A then appends its exit code under B's pid/build/log, misattributed), and a genuinely blocked exe A is silently masked because B or an operator instance created the file (the GUI never deletes, so it can only mask). That defeats the stated purpose of the change, so medium rather than high.\n\nScoping correction for the parent: the operator's EXPORTED bats (btoperator.py:1359-1439) never touch lastrun.txt, so they are not deleters — only the hand-maintained players/*.bat are. Two stale-comment defects found alongside: play_solo.bat:31 says the exe \"truncate-writes\" lastrun.txt, contradicting lines 39-41 of the same block and btl4main.cpp:702 (append); and btl4main.cpp:695-698 justifies append by \"the .bat ... writes its own 'launching' line into it FIRST\", which the diff removed. Related lesser issue: with BT_LOG unset, two same-stem instances self-name the identical <stem>_YYYYMMDD.log and both append via the CRT, whose seek-then-write is not atomic, so lines can tear; the per-session pid= header keeps it readable but not safe.",
 "fix": "Give each bat run a private handshake file and keep lastrun.txt as an append-only journal no concurrent run can destroy.\n\n1. game/btl4main.cpp:702 — honor an env override, defaulting to the current name:\n   const char *bc = getenv(\"BT_LASTRUN\");\n   std::ofstream breadcrumb(bc ? bc : \"lastrun.txt\", std::ios::out | std::ios::app);\n\n2. In each of players/join.bat, join_lan.bat, joyconfig.bat, play_solo.bat, play_steam.bat, replace `if exist lastrun.txt del lastrun.txt` with:\n   set \"BT_LASTRUN=lastrun.%RANDOM%.tmp\"\n   (delete nothing), and replace the two post-launch lines with:\n   if not exist \"%BT_LASTRUN%\" echo THE EXE NEVER REACHED WinMain: blocked before it could run (loader failure or antivirus) >> lastrun.txt\n   if exist \"%BT_LASTRUN%\" type \"%BT_LASTRUN%\" >> lastrun.txt\n   echo [%DATE% %TIME%] btl4.exe exited with code %BT_EXITCODE% >> lastrun.txt\n   if exist \"%BT_LASTRUN%\" del \"%BT_LASTRUN%\"\n\n`type` is a cmd builtin, so it is not shadowed by MSYS/GnuWin32 the way find/findstr/timeout are (the constraint the bats already document). Each run's verdict now derives only from its own file, the player still sends one lastrun.txt, and operator-GUI instances (which set no BT_LASTRUN) keep appending to the journal without ever masking a bat's verdict.\n\nAlso fix the stale comments: play_solo.bat:31 (\"truncate-writes\" -> appends) and btl4main.cpp:695-698 (no bat writes a \"launching\" line any more)."
}
{
 "title": "Exported play_solo.bat tells the player to send join_YYYYMMDD.log, but with BT_LOG removed the exe writes solo_YYYYMMDD.log",
 "real": true,
 "severity": "medium",
 "reasoning": "Verified in the working tree, not inferred from the diff summary.\n\ntools/btoperator.py:1427-1439 writes play_solo.bat with sets = \"set BT_START_INSIDE=1 / set BT_DEV_GAUGES=1 / set BT_FE_SOLO=1\" and nothing else; guarded() (1359-1385) only prepends \"set BT_PLATFORM=glass\". So at launch BT_LOG, BT_RELAY and BT_FE_JOIN are all unset and BT_FE_SOLO=1.\n\ngame/btl4main.cpp:584-606: BT_LOG unset -> logSelfNamed path; stem precedence is BT_JOYCONFIG > BT_STEAM_NET > BT_FE_SOLO > (BT_FE_JOIN|BT_RELAY), so stem = \"solo\" and the file is solo_YYYYMMDD.log. The bat does `cd content` before running ..\\build\\Release\\btl4.exe, so it lands at content\\solo_YYYYMMDD.log. btl4main.cpp:634-638 then _putenv_s(\"BT_LOG\", resolvedLog), so the menu->mission relaunch inherits the same solo_* name — there is no code path that yields join_*.log for this bat.\n\ntools/btoperator.py:1438 nevertheless emits: echo send the operator the newest content\\join_YYYYMMDD.log.\n\nRefutation attempts that failed:\n- Not a marshal.log / matchlog confusion: both the instruction and the actual file are the same BT_LOG day log.\n- No guard elsewhere injects BT_RELAY or BT_LOG into the solo export.\n- Not pre-existing: the diff hunk @@ -1430,10 +1432,11 @@ deletes \"set BT_LOG=join.log\" from the solo sets while rewriting the sign-off from \"content\\join.log\" to \"join_YYYYMMDD.log\" — before the change the message was true because BT_LOG forced the name.\n- The hand-maintained players\\play_solo.bat lines 16 and 97 correctly say content\\solo_YYYYMMDD.log, confirming \"solo\" is the intended stem and only the generated copy diverged.\n- The exported join.bat/join_lan.bat share net_tail and are correct (BT_RELAY set => stem \"join\").\n\nSeverity medium rather than the claimed high: nothing crashes and no log data is lost (the solo day log opens in append mode and captures everything); the harm is that a solo playtester is directed to a nonexistent filename, which defeats the crash-evidence-collection purpose of this very change. Single generated string, trivially fixed.",
 "fix": "In tools/btoperator.py, in the play_solo.bat tail at line 1438, change the filename to match the stem BT_FE_SOLO produces:\n\n-                \"echo send the operator the newest content\\\\join_YYYYMMDD.log.\\n\"\n+                \"echo send the operator the newest content\\\\solo_YYYYMMDD.log.\\n\"\n\n(Optionally mirror players\\play_solo.bat's wording by adding the \"one file per day, each session headed by a BT411 SESSION line\" note, as net_tail now does for the join bats. No change is needed to join.bat/join_lan.bat.)"
}
{
 "area": "game/btl4main.cpp log-naming + lastrun.txt breadcrumb, and the stem contract it implies for players/*.bat, game/glass/btl4console.cpp, game/glass/btl4lobby.cpp, tools/btoperator.py. NOTE - the working tree is AHEAD of the supplied diff and three of the prompt's suspicions are already clean, verified by running code, not by reading: (a) SetEnvironmentVariableA is gone - C:\\git\\bt411\\game\\btl4main.cpp:634-638 now uses _putenv_s, and the scratchpad's own envtest.exe reproduces it exactly (after SetEnvironmentVariableA: getenv=NULL, GetEnvironmentVariableA=set; after _putenv_s: both set), so in-process getenv(\"BT_LOG\") in btl4console.cpp/btl4lobby.cpp DOES see the pinned name and CreateProcessW(...,NULL env) still inherits it; (b) \"set BT_FE_SOLO=\" in play_steam.bat genuinely DELETES the variable - a cmd test confirms getenv returns NULL, not an empty string, so the steam stem does not mis-route through that path; (c) ordering is right - BTEnsureContentDirectory() at line 540 runs before logfile.open() at 613 and before the lastrun.txt write at 702, so both land in content\\; (d) wsprintfA cannot overflow (stem is one of four literals, max output 22 bytes into MAX_PATH) and lstrcpynA always NUL-terminates on the getenv path; (e) cmd's \"if not exist lastrun.txt echo ... > lastrun.txt\" does NOT truncate the exe's block when the file exists - tested, the block survives; (f) the [crash] block still reaches the day log and the session header always precedes it for any fault after line 617.",
 "findings": [
  {
   "title": "Operator-exported play_solo.bat tells testers to send a log the exe never writes",
   "severity": "high",
   "location": "C:\\git\\bt411\\tools\\btoperator.py:1434-1439",
   "failure_scenario": "The generated solo bat now sets only \"set BT_FE_SOLO=1\" (the \"set BT_LOG=join.log\" line was removed), so btl4main.cpp:597 resolves the stem to \"solo\" and the exe writes content\\solo_YYYYMMDD.log. Its sign-off still says \"send the operator the newest content\\join_YYYYMMDD.log\". A tester who only ever runs the exported play_solo.bat has no join_*.log at all, so they report \"there is no such file\" and send nothing; a tester who also ran join.bat that day sends join_YYYYMMDD.log, which contains an unrelated multiplayer session while the solo crash sits unsent in solo_YYYYMMDD.log. Before this change the bat pinned BT_LOG=join.log so the instruction was accurate; the change broke the pairing in the operator's own distribution path, which is exactly the path playtesters receive."
  },
  {
   "title": "Shipped players/README.txt still instructs testers to send content\\join.log, which no longer exists",
   "severity": "medium",
   "location": "C:\\git\\bt411\\players\\README.txt:32 and :41",
   "failure_scenario": "README.txt is the instruction sheet shipped beside the five bats and says twice \"send the operator content\\join.log\" / \"IF ANYTHING CRASHED OR MISBEHAVED, send content\\join.log\". After this change join.bat/join_lan.bat produce content\\join_YYYYMMDD.log and nothing ever creates join.log. A tester who crashes follows the README, finds no join.log (the folder now holds join_20260728.log plus lastrun.txt), and either sends nothing or sends a stale join.log left over from a pre-upgrade build - the crash evidence the change exists to preserve is still lost at the last step. docs\\OPERATOR_GUIDE.md:244 and docs\\HANDOFF.md:37 carry the same dead filename."
  },
  {
   "title": "Launch breadcrumb is written ~5 steps too late, so an early crash is reported as \"blocked before it could run (antivirus)\"",
   "severity": "medium",
   "location": "C:\\git\\bt411\\game\\btl4main.cpp:699-717",
   "failure_scenario": "lastrun.txt is written only after BTEnsureContentDirectory (540), logfile.open (613), the session header (646-668) and the BT_CRASHTEST fault (670-673). Set BT_CRASHTEST=1 and run play_solo.bat - the exe reaches WinMain, writes the header, then AVs at line 672 before touching lastrun.txt. The bat's probe fires and writes \"THE EXE NEVER REACHED WinMain: blocked before it could run (loader failure or antivirus)\", directly contradicting content\\solo_YYYYMMDD.log sitting next to it, which contains the SESSION header and the [crash] stack. The same false verdict is produced by any fault in the cwd guard or the log open, sending the operator hunting antivirus/loader problems when the real evidence is one file over. The breadcrumb should be written immediately after BTEnsureContentDirectory, before anything that can fault."
  },
  {
   "title": "logfile.open() failure is never checked; a failed open silently discards the entire session including the crash stack",
   "severity": "medium",
   "location": "C:\\git\\bt411\\game\\btl4main.cpp:613-617",
   "failure_scenario": "std::cout.rdbuf(logfile.rdbuf()) is executed unconditionally. If the open fails, the filebuf is closed, every insertion sets badbit and is silently dropped - header, [boot], and the BTCrashFilter stack. Concretely: the change makes the day log a long-lived file that the player is explicitly told to open and send, so a tester who is zipping content\\ (7-Zip/WinRAR hold members open) or has an upload/AV scan touching solo_20260728.log and relaunches to reproduce the crash gets a session that logs NOTHING, while content\\lastrun.txt still asserts \"log=solo_20260728.log\" - a breadcrumb that points at a file with no trace of that run. The old rotate-then-open flow opened a freshly renamed name each launch, so this was far less reachable. The same hole swallows the lstrcpynA truncation case at line 590: a BT_LOG of >=MAX_PATH chars is cut to 259 and the resulting path fails to open, with no diagnostic anywhere. Note the breadcrumb block at 702 does check is_open(); the log itself does not."
  },
  {
   "title": "BT_STEAM_NET leaks between launchers in one console and outranks the solo/join stems, mis-routing the day log",
   "severity": "medium",
   "location": "C:\\git\\bt411\\game\\btl4main.cpp:595-598 (order) with C:\\git\\bt411\\players\\play_solo.bat:27",
   "failure_scenario": "Verified in cmd: play_steam.bat's \"set BT_STEAM_NET=1\" (line 11) is set BEFORE its setlocal (line 39), so exit /b's implicit endlocal restores it and the variable survives in the calling console. Run play_steam.bat, quit, then run play_solo.bat in the same window: BT_STEAM_NET is still set, and because it is tested before BT_FE_SOLO the solo session writes content\\steam_20260728.log while its own sign-off tells the player to send content\\solo_YYYYMMDD.log - stale or absent. Same shape with joyconfig.bat (BT_JOYCONFIG=1, no setlocal at all): every later launcher in that window logs to joyconfig_YYYYMMDD.log. Each bat used to pin BT_LOG explicitly and was immune; play_steam.bat still guards the reverse direction with \"set BT_FE_SOLO=\", so the contamination risk is acknowledged but only handled one way. No bat clears BT_STEAM_NET or BT_JOYCONFIG."
  },
  {
   "title": "Marshal/lobby FILE* collides with the day-log ofstream when BT_LOG is set without BT_LOG_APPEND",
   "severity": "low",
   "location": "C:\\git\\bt411\\game\\glass\\btl4console.cpp:46 and C:\\git\\bt411\\game\\glass\\btl4lobby.cpp:35",
   "failure_scenario": "MarshalLog/LobbyLog now fopen(getenv(\"BT_LOG\"), \"at\") - O_APPEND, so every write seeks to EOF. But btl4main.cpp:613 only opens in append mode when logSelfNamed or BT_LOG_APPEND==1; with BT_LOG set and BT_LOG_APPEND unset the ofstream opens ios::out, i.e. mode \"w\" with its OWN advancing file offset and no O_APPEND. The two handles are then writing to one file at different positions: the marshal/lobby lines are appended at EOF and the next std::cout flush overwrites them at the smaller offset, leaving spliced half-lines. Repro: BT_LOG=steamtest.log build\\Release\\btl4.exe on the Steam path from docs\\STEAM_TEST.md - the [lobby] lines the doc tells you to look for are clobbered by the engine's next flush. Before the fold, marshal.log was a separate file and the handles never met. Every shipped path happens to be safe today (player bats self-name and pin APPEND=1, btoperator.py:1304-1305 sets both), so this bites developers and any future caller that sets BT_LOG alone."
  },
  {
   "title": "lastrun.txt: exe block and the bat's exit line race, and the exit code recorded is the menu's, not the game's",
   "severity": "low",
   "location": "C:\\git\\bt411\\game\\btl4main.cpp:699-717 with C:\\git\\bt411\\players\\play_solo.bat:60-61",
   "failure_scenario": "BTFE_RelaunchSelfAndExit CreateProcessW()es the mission generation and then ExitProcess(0)s, so the bat's \"echo ... exited with code %BT_EXITCODE% >> lastrun.txt\" runs concurrently with the child's own append at line 702. Either order can win, so the file reads \"...pid=A / exited with code 0 / ...pid=B\" or \"...pid=A / ...pid=B / exited with code 0\" from run to run, and the exit line cannot be attributed to a block. Worse, %BT_EXITCODE% is always the MENU's exit status (0 on a normal handoff): a mission generation that dies with 0xC0000005 leaves lastrun.txt ending in \"exe reached WinMain pid=B\" with the only exit-code line in the file saying \"exited with code 0\", so triage reads a crash as a clean quit. There is also a small window in which cmd's >> and the child's ofstream contend for the file; the child's write is dropped silently because the is_open() failure path produces no output at all."
  },
  {
   "title": "Comments describe a lastrun.txt contract that no bat implements, and contradict themselves and the code",
   "severity": "low",
   "location": "C:\\git\\bt411\\game\\btl4main.cpp:695-698 and C:\\git\\bt411\\players\\play_solo.bat:31 vs :39",
   "failure_scenario": "btl4main.cpp:695-698 justifies the append with \"the .bat deletes this file before launching and writes its own 'launching' line into it FIRST, so truncating here would erase the outside half of the bracket\" - but all five bats now only \"if exist lastrun.txt del lastrun.txt\"; none pre-writes a launching line. play_solo.bat:31 states \"The exe truncate-writes content\\lastrun.txt at its first breath\" while :39 in the same comment block states \"the exe APPENDS its own block\" - only the second is true. joyconfig.bat:16-21 still documents the deleted rotation (\"Rotate, never delete\", \"The wizard writes joyconfig.log\") and play_steam.bat:12-13 leaves \"KEEP the previous run's log ... Rotate, never delete\" orphaned above unrelated lines. The concrete failure: the next maintainer reading btl4main.cpp:695-698 sees a premise that no longer holds, \"fixes\" the append back to a truncate to avoid a duplicate-looking file, and silently destroys the relaunch generations' breadcrumbs - the exact evidence loss this change was written to stop."
  }
 ]
}
{
 "title": "Operator docs still name content\\join.log, the file this change stopped creating (and never mention lastrun.txt)",
 "real": true,
 "severity": "medium",
 "reasoning": "CONFIRMED, with a severity downgrade and a correction to which files matter most.\n\nBoth cited lines exist verbatim and are now factually wrong:\n- docs\\OPERATOR_GUIDE.md:244-245 — \"Their crash log, `content\\join.log`, is **never** uploaded — ask for it explicitly when something went wrong on their end.\"\n- context\\operator-console.md:384 — \"| `content/join.log` | the pod's own log; **never** uploaded, only on the player's machine |\"\n\nThe code plainly supports the claim:\n1. game\\btl4main.cpp:587-605 — with BT_LOG unset the exe self-names `<stem>_YYYYMMDD.log` via wsprintfA, and line 598 selects stem \"join\" for `BT_FE_JOIN || BT_RELAY`. cwd is content\\ (join.bat:26 `cd content`), so the file is content\\join_20260728.log.\n2. players\\join.bat no longer sets BT_LOG (diff line 251 deletes `set BT_LOG=join.log`). A repo-wide grep for `BT_LOG\\s*=` outside scratchpad returns only README.md, tools\\mp_launch.sh, docs\\PROGRESS_LOG.md and context\\ files — nothing under players\\. Same for join_lan.bat, play_solo.bat, play_steam.bat, and the bats generated by tools\\btoperator.py (diff lines 547/556/565 remove `set BT_LOG=join.log`).\n3. Nothing else creates content\\join.log. The only remaining occurrences in non-doc files are a historical comment (btl4main.cpp:676) and a stale comment (joyconfig.bat:20).\n\nAttribution is clean, not pre-existing doc debt: before this change join.bat set BT_LOG=join.log, so both doc lines were correct. The diff's file list is .gitignore, btl4main.cpp, btl4console.cpp, btl4lobby.cpp, five players\\*.bat, and tools\\btoperator.py — no documentation file is touched, so the change renamed the artifact and left every runbook behind. Confirmed by grep that no doc mentions lastrun.txt.\n\nWhere I disagree with the reviewer — severity is medium, not high:\n- No behavioral defect. The evidence is captured better than before (per-day, unconditional append, no rotation window, self-identifying SESSION header).\n- The primary collection path already says the right thing without the operator: join.bat:85-88 prints \"send the operator BOTH files: content\\join_YYYYMMDD.log (today's, newest) and content\\lastrun.txt\" at exit. A player is instructed correctly regardless of what the operator asks for, and a player told \"send join.log\" who opens content\\ finds join_20260728.log by obvious name similarity.\n- The reviewer cited the two weakest instances. The genuinely evidence-losing one is players\\README.txt:32 (\"If the game closes unexpectedly, send the operator content\\join.log.\") and :41 (\"IF ANYTHING CRASHED OR MISBEHAVED, send content\\join.log -- that one is never uploaded\"). That is the player-facing doc, and on an extract-over-top install a stale pre-change content\\join.log is still sitting in the folder, so a player following README sends a real-looking file full of pre-upgrade data. That is the only path where the wrong file actually gets handed over. Fix README.txt in the same pass or the operator-doc fix is cosmetic.",
 "fix": "Three text edits, no code change. Fix all three together — README.txt is the load-bearing one.\n\n1. docs\\OPERATOR_GUIDE.md:244-245, replace:\n   \"`content\\matchlog_*.txt` directly. Their crash log, `content\\join.log`, is **never** uploaded — ask\n   for it explicitly when something went wrong on their end.\"\n   with:\n   \"`content\\matchlog_*.txt` directly. Their crash log is **never** uploaded — ask explicitly for BOTH\n   `content\\join_YYYYMMDD.log` (today's date, newest; `solo_`/`steam_`/`joyconfig_` on those launchers)\n   and `content\\lastrun.txt` when something went wrong on their end. The day log appends every session\n   of the day and is never rotated, so nothing is lost by asking late.\"\n\n2. context\\operator-console.md:384, replace the single row with two:\n   \"| `content/<stem>_YYYYMMDD.log` (`join_`, `solo_`, `steam_`, `joyconfig_`) | the pod's own per-day log, appended across every session that day, each with a `===== BT411 SESSION ... =====` header naming build/machine/pid; **never** uploaded, only on the player's machine |\n   | `content/lastrun.txt` | launch bracket (bat deletes → exe appends at WinMain → bat appends exit code). Absent/only-the-bat-line means the exe never reached WinMain (loader failure / antivirus); it also records which day-log that launch wrote to |\"\n\n3. players\\README.txt:32 → \"If the game closes unexpectedly, send the operator content\\join_YYYYMMDD.log (today's date) and content\\lastrun.txt.\"\n   players\\README.txt:41-42 → \"IF ANYTHING CRASHED OR MISBEHAVED, send content\\join_YYYYMMDD.log (the newest one) and content\\lastrun.txt -- those are never uploaded and only exist on your machine. Ignore any old content\\join.log left over from a previous version; it is no longer written to.\"\n\nOptional tidy: the stale comment at players\\joyconfig.bat:20 still says \"do NOT touch join.log (the MULTIPLAYER log)\" — harmless, but rename to join_YYYYMMDD.log while in there."
}
{
 "title": "Operator-generated play_solo.bat tells testers to send join_YYYYMMDD.log, but the exe writes solo_YYYYMMDD.log",
 "real": true,
 "reasoning": "Confirmed by reading the code, not inferred. tools/btoperator.py:1427-1439 emits play_solo.bat with sets = \"set BT_START_INSIDE=1 / set BT_DEV_GAUGES=1 / set BT_FE_SOLO=1\" and nothing else; the guarded() helper (btoperator.py:1359-1371) adds only BT_PLATFORM=glass, the path probes and \"cd content\", so BT_LOG really is unset in that process. btl4main.cpp:587-605 then takes the self-naming branch, and the stem ladder checks BT_FE_SOLO (line 597) BEFORE BT_FE_JOIN/BT_RELAY (line 598), so the resolved name is solo_YYYYMMDD.log, opened relative to the content cwd -> content\\solo_YYYYMMDD.log. The generated sign-off at btoperator.py:1438 still reads \"send the operator the newest content\\join_YYYYMMDD.log\", naming a file that solo-only testers never produce. The diff (scratchpad/logconsolidation.diff:561-571) shows this pairing was intact before the change (\"set BT_LOG=join.log\\nset BT_FE_SOLO=1\" pinned the log to join.log) and was broken by it: the BT_LOG line was deleted while the tail was only reworded from join.log to join_YYYYMMDD.log. Adversarial checks that did NOT rescue it: (a) no other layer sets BT_LOG for the exported package - environ.ini's shipped template (btl4main.cpp:259-358) has no BT_LOG key at all; (b) it is not a matchlog/marshal.log confusion - marshal.log is folded into the same BT_LOG day file, so there is no second file that would happen to be named join; (c) the two generated join bats are correct (BT_RELAY set -> stem \"join\"), so only the solo path is wrong; (d) every hand-maintained bat in players\\ was updated to the right stem (join.bat:87 join_, joyconfig.bat:24 joyconfig_, play_solo.bat:97 solo_, play_steam.bat:89 steam_), which shows the generator's string is a missed edit, not a deliberate convention. Reachability is total: any tester who runs the exported play_solo.bat and reads the on-screen instruction looks for a file that does not exist, or sends an unrelated join_*.log from a multiplayer session that day. I downgrade severity from high to medium because the evidence is not destroyed - the per-day solo log is never rotated and content\\lastrun.txt records which day-log the exe chose - so the cost is a wasted round trip and possible \"there is no such file\" dead end, not permanent loss.",
 "severity": "medium",
 "fix": "In C:\\git\\bt411\\tools\\btoperator.py line 1438, change the generated solo sign-off from join_ to solo_ so it matches the stem BT_FE_SOLO produces at btl4main.cpp:597:\n\n    \"echo.\\necho The game has exited.  If it closed unexpectedly,\\n\"\n    \"echo send the operator the newest content\\\\solo_YYYYMMDD.log.\\n\"\n    \"pause\\n\"))\n\n(One-line string change; the join.bat / join_lan.bat tail at line 1389 is already correct because those bats set BT_RELAY. Optionally mirror players\\play_solo.bat:98-100 and add the \"each session starts with a ===== BT411 SESSION line\" hint, but the stem fix is the whole defect.)"
}
{
 "title": "Three unsynchronised writers share the day log; the new CRITICAL_SECTION guards only the marshal and the crash block is at risk",
 "real": false,
 "reasoning": "The claim's material particulars do not survive the code.\n\n(1) There is no third writer. C:\\git\\bt411\\game\\glass\\btl4lobby.cpp's LobbyLog is reachable only via BTLobby_HostAndRoom/BTLobby_JoinAndWait, called only from C:\\git\\bt411\\game\\glass\\btl4fe.cpp:1190 and :1229 inside BTFrontEnd_Run. BTFrontEnd_Run runs only under `if (fe_menu_mode)` at C:\\git\\bt411\\game\\btl4main.cpp:1053-1056, and that branch always ends at BTFE_RelaunchSelfAndExit (btl4main.cpp:1118, never returns), so the FE process never reaches BTLocalConsole_Start (btl4main.cpp:1141). Lobby and marshal are never in the same process. Within the FE everything is single-threaded (WaitApiCall polls SteamAPI_RunCallbacks + Sleep on the calling thread, btl4lobby.cpp:56-77), so LobbyLog is strictly sequential with that process's std::cout and needs no lock. The parent calls ExitProcess(0) immediately after CreateProcessW with nothing logged in between (btl4console.cpp:257-263), so there is no cross-process lobby/mission overlap either.\n\n(2) The marshal thread does not exist in the process the change exists to protect. CreateThread(MarshalThread) (btl4console.cpp:587) is gated on getenv(\"BT_FE_EGG\") at btl4main.cpp:1136-1144, and every join path clears BT_FE_EGG before relaunching: btl4main.cpp:1067 (RawSolo), :1071 (JoinLan), :1081 (JoinRelay), :1092 (JoinSteam). So join.bat / join_lan.bat / play_steam.bat clients -- the population that produced the 2026-07-27 Owens crash the change cites -- run a mission process with exactly ONE writer on the log. A second handle exists only on a host/solo-hosted machine.\n\n(3) The failure mode is far narrower than \"interleaved or overwritten\". Both handles are FAPPEND -- logfile.open(resolvedLog, ios::out|ios::app) unconditionally for self-named logs (btl4main.cpp:613-616) and fopen(day_log,\"at\") (btl4console.cpp:46) -- so neither carries an independent position that drifts behind EOF; there is no systematic overwrite. The reviewer's own premise (one buffered flush of ~30 inserts) means the crash block is a single _write, i.e. one lseek(END) + one WriteFile, so it lands contiguously and cannot be split by a marshal line. The only damage possible is a marshal line overlaying its leading bytes, requiring the marshal thread to be preempted inside the ~10-instruction gap between its seek and its write AND the crash to fall in that gap. The symmetric (more likely) ordering clobbers the [marshal] line, not the crash block.\n\n(4) Exposure is negligible and mistimed. The 11 MarshalLog sites (btl4console.cpp:175,195,432,457,461,473,483,493,499,560,571) are all one-shot; none polls. Between \"RunMission #2 sent\" (:499) and \"mission clock expired\" (:560) the thread is blocked in the mission-clock wait and emits nothing for the whole mission -- roughly 8 short writes per 10-minute mission, each with a microsecond-scale vulnerable window.\n\n(5) Concurrent multi-handle appends to the game log are not new: join.bat/join_lan.bat already set BT_LOG_APPEND=1, so the exiting FE parent and the starting mission child have always held simultaneous append handles on the same BT_LOG. The change adds one in-process instance of a hazard the design already accepted.\n\n(6) The new CRITICAL_SECTION is not a mislabelled or useless guard. MarshalLog is genuinely called from two threads: the worker, and the MAIN thread through BTFE_RelaunchSelfAndExit (btl4main.cpp:1461,1475 and btl4console.cpp:463 -> btl4console.cpp:175,195). Without the lock the lazy marshalLogFile init could double-fopen and the three fprintf calls could interleave. It does exactly the job it claims; it was never advertised as serialising against std::cout.\n\nThe underlying mechanism (two CRT handles appending to one file without cross-handle synchronisation) is technically present, but the claim's premises -- three concurrent writers, an unlocked FE writer, and the crash block as the thing at risk -- are each contradicted by the code, and the residual race is a theoretical sub-quantum window on the host-only path, not a threat to the crash evidence the change preserves.",
 "severity": "low",
 "fix": "No fix required. If belt-and-braces hardening is ever wanted on the host/solo path, the minimal change is to give MarshalLog a kernel-atomic append instead of the CRT's seek+write pair: replace the fopen(day_log,\"at\") handle with CreateFileA(day_log, FILE_APPEND_DATA, FILE_SHARE_READ|FILE_SHARE_WRITE, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL), format the \"[marshal] ...\\n\" line into a stack buffer under the existing lock, and emit it with a single WriteFile. FILE_APPEND_DATA-only access makes the append atomic in the kernel, closing the marshal side of the window without touching std::cout. This is optional polish, not a defect fix."
}
{
 "title": "Shipped players/README.txt still tells testers to send content\\join.log, which the change stopped creating",
 "real": true,
 "reasoning": "Verified line by line; I could not refute it.\n\n1. The text is there and is player-facing. C:\\git\\bt411\\players\\README.txt:32 \"If the game closes unexpectedly, send the operator content\\join.log.\" and :41 \"IF ANYTHING CRASHED OR MISBEHAVED, send content\\join.log -- that one is never uploaded and only exists on your machine.\"\n\n2. The file was not touched by the change. scratchpad/logconsolidation.diff contains only .gitignore, game/btl4main.cpp, game/glass/btl4console.cpp, game/glass/btl4lobby.cpp, players/{join,join_lan,joyconfig,play_solo,play_steam}.bat and tools/btoperator.py. players/README.txt is absent.\n\n3. Nothing creates join.log any more. players/join.bat:18-19 and join_lan.bat:21-22 state the bat no longer names the log and deliberately leave BT_LOG unset; game/btl4main.cpp:585-604 then self-names wsprintfA(resolvedLog, \"%s_%04d%02d%02d.log\", stem, ...) with stem=\"join\" for BT_FE_JOIN/BT_RELAY. A repo-wide grep for join.log finds only comments and docs -- no code path that opens it.\n\n4. It really is shipped verbatim. tools/mkdist.py:109-122 reads players/README.txt, substitutes only {VERSION}, {STEAM}, {EXPIRE}, and writes it to the zip root. No filename rewriting, and no doc-audit tool exists that would catch a stale filename.\n\n5. Not a matchlog/BT_LOG confusion. README:34-40 handles content\\matchlog_*.txt separately (auto-uploaded); :41 says the file is \"never uploaded\", which matches the game-log description at docs/OPERATOR_GUIDE.md:244. The two files are correctly distinguished in the README; it is the game log whose name is dead.\n\n6. The stale-file variant is not hypothetical: content\\ in this repo currently holds both the legacy solo.log and the new solo_20260728.log side by side. README:8-14 instructs upgraders to extract the new zip OVER the existing folder and say yes to overwrite, so a pre-upgrade join.log survives verbatim next to the live day file. A crashing tester following README:41 finds a file with exactly the right name, from the wrong build, and sends it -- worse than sending nothing, since the operator then debugs a stale log.\n\nPartial mitigation (why this is a doc defect, not a functional one): the .bat console prints the correct instruction on exit -- join.bat:87 and join_lan.bat:90 echo \"content\\join_YYYYMMDD.log (today's, newest)  and  content\\lastrun.txt\", and play_solo.bat:97 / play_steam.bat:89 do the same for their stems. So the runtime path is consistent; only the durable instruction sheet that ships beside the bats is wrong. That keeps it at medium rather than high, but it is the last step of the very handoff this change exists to protect, and it is the artifact a tester consults after the console window is gone.\n\nOne element of the claim is wrong and should not be acted on: docs/HANDOFF.md:37 is historical prose recording what was verified inside the already-shipped 4.11.600 zip (\"log rotation proven inside the shipped zip (solo run rotates solo.log, leaves a seeded join.log untouched)\"). That is an accurate record of a past artifact built before this change, not a live instruction; editing it would falsify the record. docs/OPERATOR_GUIDE.md:244 is current operator instruction and is genuinely stale.",
 "severity": "medium",
 "fix": "Two one-line text edits, matching the wording the bats already print.\n\nC:\\git\\bt411\\players\\README.txt:32 — replace\n  If the game closes unexpectedly, send the operator content\\join.log.\nwith\n  If the game closes unexpectedly, send the operator TODAY'S log --\n  content\\join_YYYYMMDD.log (the newest one) -- and content\\lastrun.txt.\n\nC:\\git\\bt411\\players\\README.txt:41-42 — replace\n  IF ANYTHING CRASHED OR MISBEHAVED, send content\\join.log -- that one is\n  never uploaded and only exists on your machine.\nwith\n  IF ANYTHING CRASHED OR MISBEHAVED, send content\\join_YYYYMMDD.log (today's\n  date, the newest one) and content\\lastrun.txt -- those are never uploaded\n  and only exist on your machine.  Ignore any old content\\join.log left over\n  from a previous build; it is not this session.\n\nThe explicit \"ignore any old join.log\" sentence matters because README:8-14 tells upgraders to extract over the top, so the dead file survives with a name that still looks right.\n\nAlso update the operator-facing instruction at C:\\git\\bt411\\docs\\OPERATOR_GUIDE.md:244: change \"Their crash log, `content\\join.log`, is never uploaded\" to \"Their crash log, `content\\join_YYYYMMDD.log` (per-day, plus `content\\lastrun.txt`), is never uploaded\". Optionally the reference table at context/operator-console.md:384 (`content/join.log`) for the same reason.\n\nLeave docs/HANDOFF.md:37 alone -- it is a historical verification record of the 4.11.600 zip, not an instruction."
}
{
 "title": "Launch breadcrumb written ~5 steps too late, so an early crash is reported as \"blocked before it could run (antivirus)\"",
 "real": false,
 "severity": "low",
 "reasoning": "The ordering the reviewer describes is factually correct — btl4main.cpp:540 BTEnsureContentDirectory, :613 logfile.open, :634 _putenv_s, :646-668 session header, :670-673 BT_CRASHTEST fault, :680 first-breath line, :699-717 lastrun.txt — but it is not a defect of consequence, for four independent reasons.\n\n1. THE ONLY CONCRETE TRIGGER IS A DEVELOPER-ONLY FAULT INJECTOR THAT NO PLAYER PATH CAN SET. grep for BT_CRASHTEST across the whole repo returns exactly two hits, both in btl4main.cpp itself (:670, :672) plus their diff copies. No .bat, no tools/btoperator.py, no scratchpad harness sets it. It also cannot arrive from environ.ini: BTLoadEnvironIni() runs at :731, sixty lines AFTER the :670 check, so a file line is read too late to arm it. \"Set BT_CRASHTEST=1 and run play_solo.bat\" therefore means a developer deliberately exporting an access-violation into the parent shell — and the comment at :542 states the placement is intentional (\"deliberately AV **after the log opens** -- verifies the crash filter's forensic path end-to-end\"). Producing a confusing lastrun.txt while deliberately injecting an AV is a self-test artifact, not a field failure.\n\n2. THE RESIDUAL WINDOW CONTAINS NO PLAUSIBLE FAULT. Minus the test hook, everything between :540 and :699 is GetFileAttributesA / GetModuleFileNameA / SetCurrentDirectoryA (:392-431, all bounded — exeDir[MAX_PATH] formatted into candidate[MAX_PATH*2] via sprintf_s), getenv, _CrtSetDbgFlag, GetLocalTime, wsprintfA whose longest possible output is \"joyconfig_20260728.log\" = 22 chars into resolvedLog[MAX_PATH], ofstream::open, two _putenv_s, GetComputerNameA, and stream inserts. There is no dereference of untrusted data anywhere in it.\n\n3. THIS CHANGE DID NOT REGRESS THE PRE-LOG WINDOW, WHICH IS THE BULK OF IT. The old probe (diff lines 280-281, 449-450) was `if not exist solo.log echo ... blocked before it could run`, i.e. it keyed on the log file created at logfile.open. So under the old scheme the same false verdict was produced by any fault from WinMain entry through :613 — the cwd guard and the log-name resolution the reviewer names. The change moves the marker later by only _putenv_s, GetComputerNameA, the header cout, the test hook, and one cout. The \"fault in the cwd guard or the log open\" half of the scenario is pre-existing, not introduced here, and for a pre-:613 fault there is no log at all, so nothing contradicts the message.\n\n4. THE BAT'S OWN OUTPUT DISAMBIGUATES, AND NO EVIDENCE IS LOST. lastrun.txt never ends at the \"never reached WinMain\" line: play_solo.bat:60-61 (and the identical pairs in join.bat:49-50, join_lan.bat:52-53, joyconfig.bat:38-39, play_steam.bat:52-53) immediately append \"btl4.exe exited with code %BT_EXITCODE%\". BTCrashFilter returns EXCEPTION_EXECUTE_HANDLER (:234), so the crashtest process exits with 0xC0000005 (-1073741819) — which is not what a genuine loader/AV block yields (missing exe = 9009, ACCESS_DENIED = -1073741790, missing DLL = -1073741515). The two lines read together are self-correcting. More importantly the change's actual goal is untouched: the [crash] block and the \"===== BT411 SESSION\" header are in content\\solo_YYYYMMDD.log, and every bat's sign-off (play_solo.bat:96-100) tells the player to send exactly that file. Note also that with per-day append logs a \"never reached WinMain\" verdict routinely sits next to a non-empty day log from earlier sessions, so the operator already reconciles the two by session header rather than by file existence.\n\nThe reviewer did not confuse the matchlog with the BT_LOG game log — the files named are correct. The claim is an accurate reading of the ordering with an overstated consequence.\n\nIncidental (not the claim, worth a one-line cleanup): play_solo.bat:31 still says \"The exe truncate-writes content\\lastrun.txt\", contradicting lines 39-41 in the same comment block and btl4main.cpp:702, which append.",
 "fix": "No fix required. If you want the window narrowed anyway, do NOT apply the reviewer's suggestion of moving the block to just after BTEnsureContentDirectory (btl4main.cpp:540) — resolvedLog does not exist until :606, so the breadcrumb would lose its `log=<resolvedLog>` line, which the comment at :692-694 identifies as its second job (\"records which day-log this launch actually wrote to, which the .bat cannot derive itself\"), and play_solo.bat:37-38 relies on because cmd's %DATE% is locale-ordered. The correct minimal move, if any, is to relocate the whole :699-717 block to immediately after the _putenv_s at :638 — after the name is resolved and pinned, before the session header and the BT_CRASHTEST hook. That preserves `log=` and costs nothing, but it buys only the deliberate test-hook case."
}
"**VERDICT: SHIP WITH FIXES** — the C++ log-consolidation core is sound and no confirmed defect is a code fault; every blocking item is a text string that mis-names the file a playtester must send. All of them are fixable in ~10 minutes with no recompile (`tools/mkdist.py:107-122` reads the working tree, so edits ship without a commit).\n\n---\n\n## CONFIRMED DEFECTS (verifier real=true)\n\n### BLOCKING — fix before cutting tonight's zip\n\n**1. `players\\README.txt:32` and `:41` — the shipped instruction sheet names a file nothing writes.**\nConfirmed independently by 3 reviewers. Both lines say \"send the operator `content\\join.log`\". With `set BT_LOG=` deleted from `players/join.bat`, `game/btl4main.cpp:598` self-names `join_YYYYMMDD.log`; nothing creates `join.log` any more. `tools/mkdist.py:107-122` substitutes only `{VERSION}/{STEAM}/{EXPIRE}` and writes these lines byte-identical into every zip root. Worse on the extract-over-the-top upgrade path the README itself mandates (`:8-14`): the rotation pair was removed, so the **previous build's `join.log` survives with exactly the right name** and a crashing tester sends a stale pre-upgrade file.\n*Minimal fix:* line 32 → \"send the operator `content\\join_YYYYMMDD.log` (today's date, the NEWEST one) and `content\\lastrun.txt`\"; lines 41-42 → same, plus one sentence: \"Ignore any old `content\\join.log` from a previous version — it is no longer written to; delete it.\"\n\n**2. `tools/btoperator.py:1438` — exported `play_solo.bat` asks for a log that bat never writes.**\nConfirmed by 5 reviewers, verified against the generated output. `sets` at `:1434-1436` is `BT_START_INSIDE/BT_DEV_GAUGES/BT_FE_SOLO` with `set BT_LOG=join.log` deleted; `btl4main.cpp:597` tests `BT_FE_SOLO` before `BT_FE_JOIN|BT_RELAY`, so the file is `content\\solo_YYYYMMDD.log`. The sign-off still says `join_YYYYMMDD.log`. This is the operator's own distribution channel. The hand-maintained twin `players/play_solo.bat:97` was updated correctly — only the generator was missed.\n*Minimal fix:* one string, `join_YYYYMMDD.log` → `solo_YYYYMMDD.log`. (`net_tail:1389` is correct — those bats set `BT_RELAY`.)\n\n**3. `players/play_solo.bat:95-100` and `players/play_steam.bat:87-90` — sign-offs never request `lastrun.txt`.**\nBoth bats write the \"THE EXE NEVER REACHED WinMain\" verdict into `lastrun.txt` (`play_solo.bat:60`, `play_steam.bat:52`) and then never ask for it. `join.bat:87` / `join_lan.bat:90` correctly say \"BOTH files\". The steam bracket is **new in this diff** (`play_steam.bat:44-53`) and its own comment says it exists because a blocked Steam exe \"left no evidence at all\" — yet the file it was added to produce is never requested. Newly aggravated on the solo path: with per-day naming, \"send the newest `solo_*.log`\" now points at a *previous day's clean log* when today's launch was blocked, so the operator concludes nothing went wrong.\n*Minimal fix:* append `content\\lastrun.txt` to both sign-offs, with the hedge \"send `lastrun.txt` even if there is no log dated today\".\n\n### NON-BLOCKING — real, but ship-safe tonight\n\n**4. `game/btl4main.cpp:699-717` + `players/*.bat:33/36/35/44/49` — one shared `lastrun.txt`, no per-run identity.** Every btl4 process appends unconditionally to a fixed relative `lastrun.txt`; every bat deletes it first. Two launches from one `content\\` (second client, operator GUI pod at `tools/btoperator.py:1302-1309`, or a player double-launching a window that \"looked hung\") means bat B's `del` destroys exe A's block, and A's exit line is then filed under B's pid. A genuinely blocked launch can be masked because another process created the file. This is a regression: the old probe was per-stem (`if not exist solo.log`) and rotation guaranteed absence. **Do not fix tonight** — the correct fix (`BT_LASTRUN` env override + per-run temp file, `btl4main.cpp:702`) touches the exe and needs a rebuild. Single-instance playtesters are unaffected.\n\n**5. `docs/OPERATOR_GUIDE.md:244` and `context/operator-console.md:384` — still name `content\\join.log`.** Your own runbook. Cheap to fix, cosmetic for tonight since you are the operator; fix in the same pass as #1.\n\n**6. Gate leakage → wrong stem (`btl4main.cpp:594-598` precedence; gates set before `setlocal` at `play_steam.bat:11` vs `:39`, `play_solo.bat:27` vs `:53`, `joyconfig.bat` has no `setlocal`).** Empirically verified: pre-`setlocal` vars survive `exit /b`. Only surviving *silent* case is `play_steam.bat` → `play_solo.bat` in one console: solo session lands in `steam_YYYYMMDD.log`. **Not reachable for the double-click player** (`README.txt:16-24` — fresh cmd per launch, Explorer environment). Dev/tester consoles only. The clean fix (hoist `setlocal` above the gates in all five bats + `guarded()` at `btoperator.py:1361`) is correct but is a control-flow edit to five launchers hours before a build — defer.\n\n**7. No bat clears an inherited `BT_LOG`; none sets `BT_LOG_APPEND=1` any more (`btl4main.cpp:613-616` picks `ios::out` = TRUNCATE when `BT_LOG` is set without `BT_LOG_APPEND`).** Real gap, but no in-repo source produces an ambient `BT_LOG` — harnesses use per-command env prefixes, `btoperator.py:1304` scopes it to a `QProcessEnvironment`. If you want one line of insurance: add `set BT_LOG=` next to the LOGGING comment in each of the five bats (`btl4main.cpp:588` guards empty, so this forces self-name + unconditional append). Zero risk, optional.\n\n**8. `tools/btoperator.py:1359-1385` — generated bats carry no `lastrun.txt` bracket and use the same basenames as the shipped ones.** Exporting into the extracted game folder **overwrites** the bracketed `join.bat`/`join_lan.bat`/`play_solo.bat` with unbracketed ones, so exported players produce no blocked-launch evidence at all. Low severity (the exe still writes its own block; the accumulation is ~200 B/run and cannot fool a probe, since every bat that tests also deletes). Fix when you next touch the generator — or simply **don't press Export tonight** if you ship the mkdist zip.\n\n---\n\n## REFUTED — do not chase\n\n- \"Launch breadcrumb written ~5 steps too late → false antivirus verdict\" — only trigger is `BT_CRASHTEST`, which nothing outside `btl4main.cpp:670-673` sets and `environ.ini` cannot arm (loaded at `:731`); the pre-`:613` window is pre-existing, and the bat's exit-code line disambiguates.\n- \"Generated bats never clear inherited `BT_LOG`/`BT_FE_SOLO`, defeating self-naming\" — every enumerated leak source fails; pre-change bats set `BT_LOG_APPEND=1` alongside `BT_LOG`, so inheritance yields append, not truncation. (The narrower `players/*.bat` variant survives as item 7.)\n- \"Three unsynchronised writers on one file; the `[crash]` block is at risk\" — `LobbyLog` and `MarshalLog` never coexist in one process (`btl4main.cpp:1053-1056`/`1118` vs `:1141`), the marshal thread is `BT_FE_EGG`-gated and absent on every join client, both handles are `O_APPEND`, and the crash block flushes as one write.\n\n---\n\n## NEEDS A HUMAN CALL BEFORE / SHORTLY AFTER SHIPPING\n\nThese were raised but never adjudicated by a verifier — I did not confirm or refute them:\n\n1. **Unbounded day log (`btl4main.cpp:608-617`).** No cap, no rotation, no cleanup. In-repo single sessions reach 4.3 MB (`scratchpad/night5/`), and a full night of Scenario-1 relaunches concatenates into one file that can exceed Discord's 25 MB limit — the channel `btl4main.cpp:640-645` cites as how evidence actually arrives. **Decide tonight whether that is acceptable for a multi-hour playtest.**\n2. **`logfile.open()` return is never checked (`btl4main.cpp:613-617`).** `std::cout.rdbuf(logfile.rdbuf())` runs unconditionally; a failed open silently drops the header, `[boot]` and the crash stack while `lastrun.txt` still asserts `log=<name>`. Newly reachable because the day log is now a long-lived file testers are told to open, zip, and upload. Three-line `if (!logfile.is_open())` guard, but it needs a rebuild.\n3. **`run/run.cmd` + `scratchpad/checkbank.py:73`.** Your own verify loop (`CLAUDE.md:50`, `context/reconstruction-method.md:20`) says \"read `btl4.log`\" — that file is now `btl4_YYYYMMDD.log`, append-only, so a grep for a marker can match a run from hours ago and report a regression as verified. `checkbank.py`'s zero-arg default will `FileNotFoundError`. Dev-workflow only, but it will bite you the moment you debug a playtest report.\n4. **`docs/STEAM_TEST.md:19,23,25-26`** keys the Steam bring-up exit criterion on `btl4.log` + `marshal.log`; both names are now dead (`marshal.log` is unreachable because `btl4main.cpp:634-638` always pins `BT_LOG`). If anyone runs the Steam leg tonight, they will report it broken.\n5. **Stale/self-contradicting comments** — `btl4main.cpp:695-698` justifies the append with a bat behaviour no bat implements; `play_solo.bat:31` (\"truncate-writes\") contradicts `:39` (\"APPENDS\") and the code. Cosmetic tonight, but it is a live trap: the next maintainer \"fixes\" the append back to a truncate and destroys the relaunch generations' breadcrumbs — the exact loss this change exists to stop."

run wf_b8561edd-ca7 -- 13 completed agent(s)

{
 "area": "RELAY / CONSOLE side of automatic log retrieval — tools/btconsole.py (route -9 MATCHLOG handler + control channel), tools/btoperator.py (operator GUI, local-instance launch, exported player bats), tools/btrelay_park.py (parked-relay supervisor), with tools/matchcheck.py as the only consumer of the collected files",
 "facts": [
  {
   "claim": "Q1 — The MATCHLOG upload is handled inline in the relay's game-TCP frame dispatcher, and ONLY on a pre-HELLO connection. A registered pod cannot upload at all.",
   "evidence": "C:\\git\\bt411\\tools\\btconsole.py:1385-1407 — `def _handle_game_frame(self, conn, route, payload):` then `if conn.host_id is None and route == ROUTE_MATCHLOG:`. Route id at :284 (`ROUTE_MATCHLOG = -9`). If a REGISTERED conn (host_id set) sends -9 it falls past the ROUTE_BCAST/`route >= FIRST_GAME_HOST_ID` branches to :1619-1620 `print(f\"[relay] {conn.name()} bad route {route} -- ignored\")`. Client side dials a fresh throwaway socket after the mission loop ends: game/btl4main.cpp:1301-1311 (`RunMissions()` returns → `BTRelayUploadMatchLog()`), engine/MUNGA_L4/L4NET.CPP:702-780."
  },
  {
   "claim": "Q1 (where) — Files land in a flat `matchlogs/` directory created relative to the relay process's CURRENT WORKING DIRECTORY, not to any configured root.",
   "evidence": "C:\\git\\bt411\\tools\\btconsole.py:1400 `os.makedirs(\"matchlogs\", exist_ok=True)` and :1402 `out_path = os.path.join(\"matchlogs\", f\"{peer}_{name}\")` — both relative. CWD is content\\ only when the parked supervisor starts it: C:\\git\\bt411\\tools\\btrelay_park.py:106 `subprocess.Popen(cmd, cwd=CONTENT, ...)`. The operator GUI does NOT set it — C:\\git\\bt411\\tools\\btoperator.py:902-906 starts btconsole with no `setWorkingDirectory` (the only such call is :1307, for GAME processes), so a GUI-launched relay drops matchlogs/ wherever the GUI was started from. btconsole.py:333-335 nevertheless asserts \"The relay always runs with cwd = content\\ (operator GUI and all rigs set it)\"."
  },
  {
   "claim": "Q1 (naming) — `matchlogs/<sender-IP>_<client-supplied-basename>`. The sender prefix is the peer IP with ':' → '_' (IPv6-safe); the rest of the name is chosen entirely by the client and only pattern-checked.",
   "evidence": "btconsole.py:1401-1402 `peer = conn.sock.getpeername()[0].replace(\":\", \"_\")` / `f\"{peer}_{name}\"`; :1391-1395 payload is `filename NUL + bytes`, `nul = payload.find(b\"\\0\")`, rejected if `nul <= 0 or nul > 150`, then `name = os.path.basename(payload[:nul].decode(\"ascii\", \"replace\"))`; :1396-1398 `if not (name.startswith(\"matchlog_\") and name.endswith(\".txt\")): self._drop_game(conn, f\"suspicious matchlog name {name!r}\")`. The client name is minted once per process: game/reconstructed/matchlog.cpp:81-83 `sprintf(s_matchPath, \"matchlog_%04u%02u%02u_%02u%02u%02u_%lu.txt\", …, GetCurrentProcessId())`, latched by `s_matchState` (matchlog.cpp:56-88)."
  },
  {
   "claim": "Q1 (COLLISION) — SILENT OVERWRITE. The handler opens the destination \"wb\" with no existence check, no suffix, no .1 generation, no dedup; the success log line is byte-identical for a first save and a clobber.",
   "evidence": "btconsole.py:1403-1406 `with open(out_path, \"wb\") as f: f.write(data)` then `print(f\"[relay] matchlog saved: {out_path} ({len(data)} bytes)\")`. Contrast the two places in this codebase that DO preserve a generation: btoperator.py:40-55 `rotate_log()` and btrelay_park.py:44-52 `rotate()` (both `os.replace(path, path + \".1\")`). Neither is used on the matchlog path. Collisions are practically nil TODAY only because the client name embeds HHMMSS+pid (matchlog.cpp:81-83) — same-second + same-pid from one IP. Two clients behind one NAT share the `<peer>` prefix, so only the timestamp+pid separates them."
  },
  {
   "claim": "Q1 (trust) — The upload path is completely unauthenticated and unmetered: any TCP peer that can reach the game port can create/overwrite files under matchlogs/. The only constraints are the name pattern, the 150-byte name limit and the size cap. There is no quota, no retention and no pruning anywhere.",
   "evidence": "btconsole.py:1386-1407 (no secret, no roster check — it runs BEFORE the HELLO/roster gate at :1537-1570). The relay's ENTIRE file I/O surface is: read eggs (:177, :389, :399, :497, :702, :731, :1270, :2173), read/write operator_secret.txt (:337, :345), write matchlogs (:1400-1404). No `os.remove`, `os.listdir`, `glob` or `shutil` appears in the file."
  },
  {
   "claim": "Q2 — The server-side cap is 8 MiB, enforced per FRAME on the envelope's length field before a single payload byte is buffered; every other route is capped at 1600 bytes.",
   "evidence": "btconsole.py:291 `MATCHLOG_CAP = 8 * 1024 * 1024   # matchlog upload frame cap (client caps at 8MB)`; :370 `FRAME_CAP = 1600  # NETWORKMANAGER_BUFFER_SIZE (NETWORK.h:89)`; :1368-1375 `route, length = struct.unpack_from(ENV_FMT_TCP, conn.buf, 0)` → `cap = MATCHLOG_CAP if route == ROUTE_MATCHLOG else FRAME_CAP` → `if length > cap: self._drop_game(conn, f\"oversize frame ({length})\"); return`."
  },
  {
   "claim": "Q2 (behavior on excess) — The relay KILLS THE CONNECTION and writes nothing. No error frame is sent back, the client cannot distinguish it from a network failure, and the operator-visible log line does not even mention matchlogs.",
   "evidence": "btconsole.py:1373-1375 calls `_drop_game`, which at :1679-1689 prints `[relay] <name> dropped: oversize frame (N)`, unregisters the selector key and closes the socket. Nothing in the -9 handler ever executes. Because the upload conn has `host_id is None` and `seat_host is None`, the drop is otherwise harmless (no PEER_DOWN, no `_abort_round` — :1690-1728)."
  },
  {
   "claim": "Q2 (off-by-name) — Client and relay measure DIFFERENT quantities: the client caps `size`, the relay caps `name_length + size`. A file at exactly 8 MiB passes the client's check and is then dropped by the relay.",
   "evidence": "engine/MUNGA_L4/L4NET.CPP:718-723 `if (size <= 0 || size > 8L * 1024L * 1024L) { fclose(f); return; }` — and it returns SILENTLY, writing no DEBUG_STREAM line, so an over-cap file produces no evidence anywhere. L4NET.CPP:753-756 `int name_length = (int)strlen(path) + 1; envelope.length = (unsigned int)(name_length + size);` vs btconsole.py:1373 `if length > cap`."
  },
  {
   "claim": "Q2 (cost) — The 8 MiB is absorbed by the relay's SINGLE-THREADED selector loop: bytes accumulate with `conn.buf += data` in 8 KiB recv chunks, and the file write is a synchronous blocking open/write on that same thread, which is also servicing every live game connection.",
   "evidence": "btconsole.py:602-626 `run()` is one `while True: for key, _ in self.sel.select(timeout=0.5)` loop dispatching console/game/udp/ctl; :1357-1367 `data = conn.sock.recv(8192)` / `conn.buf += data`; :1376-1379 the frame is only sliced out when complete; :1403-1404 the write. An 8 MiB upload is ~1000 immutable-bytes concatenations plus one blocking disk write inside the loop that keeps the round alive."
  },
  {
   "claim": "Q3 — There is NO pull mechanism of any kind. Upload is always client-initiated push; the relay never asks a pod for a file, and no operator command can request one.",
   "evidence": "Relay→pod traffic is push-only: egg chunks (btconsole.py:200-214 `egg_packets`, :668 `_send_egg`, :216-233 `serve_pod`), RunMission/StopMission packets (:154-171), and 4-byte control frames PEER_UP/PEER_DOWN/SEAT_ASSIGN/SEAT_FULL/REJOIN (:1622-1624). The console channel only ever RECEIVES the pod's AcknowledgeEggFile (:1044-1053 `if clid == 0 and mid == 4`). The operator control-channel verb set is closed: :1934-1973 `launch | stop | rearm | newround | restart | ping | get | set`, and `get` re-reads the relay's OWN egg from disk (:1975-1986), while `set` is whitelisted to 6 mission keys (:326-327 `CONTROL_SET_KEYS`)."
  },
  {
   "claim": "Q3 (documented consequence) — The project already states in writing that the game log is never retrievable and must be requested by hand, and that solo/Steam players have no relay to upload through at all.",
   "evidence": "C:\\git\\bt411\\docs\\OPERATOR_GUIDE.md:239-245 — \"At the end of each clean round, every player's client uploads its own match report to you automatically (relay mode only). They land in `matchlogs\\` named by the sender's address… a client that crashes mid-round loses its report… Their crash log, `content\\join.log`, is **never** uploaded — ask for it explicitly.\" Same in context/multiplayer.md:105-106 and context/operator-console.md:83-84."
  },
  {
   "claim": "Q4 — The storage layout has NO per-round structure of its own: one flat directory, no subdirectory per round/session, no index, no manifest. Round separation is carried entirely by the timestamp+pid the CLIENT baked into the filename.",
   "evidence": "btconsole.py:1400-1402 (flat `matchlogs/`, single f-string name) vs matchlog.cpp:81-83 (`matchlog_YYYYMMDD_HHMMSS_<pid>.txt`). The relay never enumerates the directory (no `os.listdir`/`glob` in btconsole.py) and never correlates an upload with `self.launches_sent`, the round counter or the roster."
  },
  {
   "claim": "Q4 — \"One file per round\" holds today only as an emergent property of two client-side facts, not as a relay invariant: the matchlog path is resolved ONCE per process, and relay mode RELAUNCHES the process between rounds.",
   "evidence": "matchlog.cpp:56-88 (`s_matchState` latch; path minted on first `BTMatchLogActive()`); game/btl4main.cpp:1321-1341 — the lobby loop `BTFE_RelaunchSelfAndExit`s after `gBTMissionStoppedByConsole`, so the next round is a new pid and a new filename. Upload fires once, after `RunMissions()` returns (btl4main.cpp:1301-1311)."
  },
  {
   "claim": "Q4 — A file that GROWS cannot even be re-sent by the current client: the upload CLOSES the matchlog and disarms further logging before reading it, so the file is dead after its one upload.",
   "evidence": "engine/MUNGA_L4/L4NET.CPP:713 `BTMatchLogClose();  // complete file on disk`, and matchlog.cpp:41-53 `BTMatchLogClose()` sets `s_matchState = 0; // further events: no-op`. Re-uploading a grown file would mean re-sending the WHOLE file each round (the wire format has no offset/resume field — btconsole.py:272 `ENV_FMT_TCP = \"<iI\"  # route, length`)."
  },
  {
   "claim": "Q5 — The operator GUI never opens, parses or displays any pod-side file. Its entire model of the session is regexes over the relay's stdout, and \"matchlog saved\" is not one of them.",
   "evidence": "C:\\git\\bt411\\tools\\btoperator.py:78-119 — `SessionMonitor` regexes cover pod ACK, REGISTERED, RunMission, relay-stats, dropped, WAITING FOR OPERATOR, SEATED/LEFT/READY, launch HELD, round RESET, roster, RE-ARM, StopMission. Ingestion path :1054-1087 (`_console_output` → tee to content\\operator_relay.log → `_ingest_line` → `monitor.feed`). A grep for \"matchlog\" in btoperator.py returns nothing. So an upload is visible only as raw text in the log pane."
  },
  {
   "claim": "Q5 — matchcheck.py IS one-mission-per-file by construction: it globs any `*matchlog_*.txt`, then aggregates each file as a single peer's single match.",
   "evidence": "C:\\git\\bt411\\tools\\matchcheck.py:124-127 accepts \"both a pod's own file (matchlog_*.txt) and the relay's collected copy (<peer-ip>_matchlog_*.txt)\" by testing `\"matchlog_\" in f and f.endswith(\".txt\")`; :95-103 `self_host` = the FIRST `VEHICLE` line; :105-113 `mission_events()` = \"everything from the first MISSION line onward\"; :170-209 damage summed over the whole file into one `recv`/`dealt` matrix; :276-285 SCORE = last value per player; :288-299 the disconnect check tracks ONE `run_state` that each later MISSION line overwrites."
  },
  {
   "claim": "Q5 — Consequently a two-round file is silently reported as one round: matrices add, kill/death censuses merge, and every non-final round's normal end-of-mission PEER_DOWN is misreported as a MID-MISSION disconnect.",
   "evidence": "matchcheck.py:292-299 — `run_state` is reassigned at each MISSION line and the anomaly fires when `e[\"st\"] == run_state`; the comment at :289-291 explicitly relies on \"a PEER_DOWN in any LATER state … is the normal end-of-mission exit\", which stops being true once a second MISSION line resets the baseline."
  },
  {
   "claim": "Q5 — Downstream forensic conclusions already treat one file == one round as load-bearing evidence.",
   "evidence": "C:\\git\\bt411\\docs\\KD_SCOREBOARD_PLAN.md:98 counts rounds as \"88 files in content/matchlogs/, 42 -egg OPERATOR.EGG, 32 with MISSION st=4 run\" and argues from \"Each of those five logs contains exactly one MISSION run and each victim has exactly one DEATH inst=M\"; C:\\git\\bt411\\docs\\RESPAWN_REARM_PLAN.md:54 runs a per-file 1:1 DEATH↔PLAYER_DEAD census."
  },
  {
   "claim": "Context — the game log's open mode currently DEFAULTS TO TRUNCATE; append is opt-in via BT_LOG_APPEND, and the operator GUI's own exported join.bat does not set it.",
   "evidence": "game/btl4main.cpp:568-575 `logfile.open(getenv(\"BT_LOG\") ? … : \"btl4.log\", (getenv(\"BT_LOG_APPEND\") != NULL && *getenv(\"BT_LOG_APPEND\") == '1') ? (std::ios::out | std::ios::app) : std::ios::out);`. players/join.bat:18-19 sets both; btoperator.py:1403-1405 (the GUI-EXPORTED join.bat) sets `BT_LOG=join.log` and NO `BT_LOG_APPEND` and no rotation — the two flavors of join.bat already disagree."
  },
  {
   "claim": "Context — a real per-run BT_LOG is ~400 KB for one short local session, i.e. a per-day file plausibly lands in the tens of MB.",
   "evidence": "`C:\\git\\bt411\\content\\solo.log` = 405,274 bytes and `solo.old.log` = 352,273 bytes (one rotation pair, 2026-07-27). By contrast the matchlogs in content\\ are 7352,152 bytes (matchlog_20260727_221916_14560.txt, matchlog_20260727_223931_14492.txt)."
  }
 ],
 "conflicts": [
  {
   "title": "crashes.log can never reach the operator: the only retrieval channel rejects the name AND kills the connection, and there is no pull to fall back on",
   "mechanism": "Route -9 is the sole file-ingest path in the console/relay. Its name filter is a hard allowlist: `if not (name.startswith(\"matchlog_\") and name.endswith(\".txt\")): self._drop_game(conn, f\"suspicious matchlog name {name!r}\")`. Any attempt to reuse the route for `crashes.log` (or `steam_20260727.log`) is not a no-op — it drops that pod's TCP connection and prints an accusatory line into the operator log. And a REGISTERED pod cannot use -9 at all (the handler is gated on `conn.host_id is None`; otherwise the frame falls through to \"bad route -9 -- ignored\"), so the upload must be a fresh post-mission dial exactly like the matchlog one. Since Q3 establishes there is NO pull mechanism anywhere (no route, no control verb, no code path that requests a file from a pod), the proposal as written produces a permanent crash record that STILL only exists on the tester's disk. The stated goal — the operator ends up holding the crash stacks — is not achieved by (b) alone; it needs relay code.",
   "severity": "high",
   "evidence": "C:\\git\\bt411\\tools\\btconsole.py:1386 (`if conn.host_id is None and route == ROUTE_MATCHLOG`), :1396-1398 (name filter + `_drop_game`), :1619-1620 (bad-route fallthrough); control verbs :1934-1973; docs/OPERATOR_GUIDE.md:244 (\"Their crash log, content\\join.log, is never uploaded\")."
  },
  {
   "title": "A stable filename converts the handler's silent overwrite from a theoretical case into every single upload — the exact evidence loss the proposal is fixing, moved server-side",
   "mechanism": "The handler writes `open(out_path, \"wb\")` with no existence check and no generation suffix, and prints the same \"matchlog saved\" line whether it created or clobbered. Today that is safe only because the client name carries HHMMSS+pid, so `<peer>_matchlog_<stamp>_<pid>.txt` is unique per process. An append-only `crashes.log` (or a per-day `steam_YYYYMMDD.log`) has a STABLE name by design: `<peer>_crashes.log` would be truncated and rewritten on every upload, so the relay retains only the last one — and because two testers behind one NAT share the `<peer>` prefix, they would also silently overwrite each other. Note the collected copies have no retention or pruning either, so the directory's only data-loss mechanism is precisely this overwrite: the 'never delete' guarantee the proposal wants does NOT hold on the relay side today.",
   "severity": "high",
   "evidence": "C:\\git\\bt411\\tools\\btconsole.py:1401-1406 (peer prefix, `open(...,\"wb\")`, unconditional success print) vs the generation-preserving `rotate_log`/`rotate` used everywhere else (btoperator.py:40-55, btrelay_park.py:44-52); client naming matchlog.cpp:81-83."
  },
  {
   "title": "Per-day naming lands on a DEFAULT-TRUNCATE open mode: the second launch of the day wipes the morning unless BT_LOG_APPEND semantics are inverted first",
   "mechanism": "btl4main opens the log `std::ios::out` (truncate) unless BT_LOG_APPEND=1 is set — append is opt-in. Today truncation is survivable because the .bat rotates the file to *.old.log first, giving a 2-generation window. Per-day naming REMOVES the rotate but does not change the open mode: `content\\steam_20260727.log` would be created at 19:00, truncated at 19:40 when the tester relaunches, truncated again at 20:15 — a strictly WORSE outcome than today, because today's rotation at least kept one prior generation. Worse still, the operator-GUI-exported join.bat never sets BT_LOG_APPEND at all, so that flavor is already truncate-only. The proposal must make append unconditional for the self-named file (or make BT_LOG_APPEND default-on) in the same change, or it inverts its own goal.",
   "severity": "high",
   "evidence": "game/btl4main.cpp:568-575 (ternary: app only when `BT_LOG_APPEND==1`, else `std::ios::out`); players/join.bat:16-19 and players/play_steam.bat:14-17 (rotate + append pair); C:\\git\\bt411\\tools\\btoperator.py:1403-1405 (exported join.bat sets BT_LOG only — no append, no rotation)."
  },
  {
   "title": "The transport is sized for a per-round file (8 MiB), and both over-cap paths fail SILENTLY and differently on each side",
   "mechanism": "If the day log or an accumulated crashes.log is ever routed through the upload path, size becomes the failure mode. A single short session already produces a ~400 KB BT_LOG (solo.log = 405,274 bytes), so a full playtest day is plausibly tens of MB. Two independent cutoffs then apply and neither leaves usable evidence: (1) the client silently `return`s without uploading and without writing a DEBUG_STREAM line when `size > 8 MiB`; (2) the relay compares `length` (= name_length + size) against MATCHLOG_CAP and, on excess, calls `_drop_game(conn, \"oversize frame (N)\")` — no reply frame to the client, and a log line that never mentions matchlogs. Because the client tests `size` while the relay tests `name+size`, a file at exactly 8 MiB is sent by the client and then killed by the relay. Separately, the 8 MiB is absorbed by `conn.buf += data` in 8 KiB chunks and written with a blocking `open()`/`write()` inside the SAME single-threaded selector loop that services every live round, so bigger uploads stall the relay for every other pod.",
   "severity": "high",
   "evidence": "btconsole.py:291 (`MATCHLOG_CAP`), :1372-1375 (cap + `_drop_game`), :1357-1379 (buffering), :1403-1404 (blocking write), :602-626 (single selector loop); engine/MUNGA_L4/L4NET.CPP:718-723 (silent client skip) and :753-756 (`envelope.length = name_length + size`); size baseline C:\\git\\bt411\\content\\solo.log (405,274 bytes)."
  },
  {
   "title": "An EXE-chosen log name collides head-on with BT_LOG's documented multi-instance role and with the operator GUI's per-instance rotation",
   "mechanism": "BT_LOG exists specifically so several clients can run from one cwd: btl4main states it is \"REQUIRED for multi-instance runs from one cwd (instance 2 would truncate instance 1's log)\". The operator GUI depends on that: for each roster row with 'Local' checked it sets `BT_LOG=operator_<n>.log`, calls `rotate_log()` on that exact path first, and adds BT_LOG_APPEND=1. If the EXE names the file itself and IGNORES BT_LOG, every local instance plus the operator's own pod write concurrently into one `content\\<name>_YYYYMMDD.log` — independent std::ofstream buffers flushing into the same file interleave mid-line — and `rotate_log()` at btoperator.py:1303 now rotates a filename nothing writes, silently voiding the \"previous run survives as .1\" guarantee the GUI advertises to the operator. If instead BT_LOG still wins, testers get per-day files while the operator's own instances keep per-launch files: two conventions, and every doc line becomes conditional. The proposal must state the precedence explicitly; there is no safe default.",
   "severity": "high",
   "evidence": "game/btl4main.cpp:565-567 (the multi-instance rationale) and :572-575 (BT_LOG read); C:\\git\\bt411\\tools\\btoperator.py:1299-1310 (`log_name = \"operator_%d.log\" % (r + 1)`, `rotate_log(os.path.join(CONTENT, log_name))`, `env.insert(\"BT_LOG\", log_name)`, `env.insert(\"BT_LOG_APPEND\", \"1\")`, then `proc.setWorkingDirectory(CONTENT)`)."
  },
  {
   "title": "The #41 launch forensics in every player bat probe the literal filename and would start manufacturing a FALSE \"antivirus blocked the exe\" verdict on every run",
   "mechanism": "join.bat brackets the exe from outside and writes launch_report.txt, then tests for the log by hard-coded name: `if not exist join.log echo join.log WAS NEVER CREATED: the exe was blocked before it could run (loader failure or antivirus)`, followed by a size probe `for %%A in (join.log) do echo join.log size: %%~zA bytes`. With the EXE naming the file `join_20260727.log`, the `if not exist` test is true on EVERY launch, so launch_report.txt permanently asserts the one diagnosis it was written to make trustworthy — a loader/AV block — while the log sits right beside it, and the size line disappears. This is worse than silence: it injects wrong evidence into the exact artifact the operator is told to collect. Identical code in join_lan.bat and play_solo.bat.",
   "severity": "high",
   "evidence": "C:\\git\\bt411\\players\\join.bat:46-47; C:\\git\\bt411\\players\\join_lan.bat:49-50; C:\\git\\bt411\\players\\play_solo.bat:48-49 (all three with the same `if not exist <name>.log` / size-probe pair)."
  },
  {
   "title": "\"Send the operator content\\join.log\" is hard-coded in at least eight places — including a SECOND, generated copy of the player bats inside the operator GUI that already diverges from players/",
   "mechanism": "The filename is a published contract with playtesters, repeated in the bats' sign-off text, the tester README and the operator guide. Renaming it per-day orphans all of them at once, and the crash-day instruction becomes \"send me a file that does not exist\". Critically the bats exist TWICE: the tracked `players/*.bat` (shipped by mkdist) and a second set generated at runtime by btoperator's Export player scripts, whose text is built inline in `_export_scripts` — and the generated copy already differs from the tracked one (no BT_LOG_APPEND, no rotation, no launch_report.txt bracket). A rename touched in only one place produces two flavors of join.bat that disagree about what the log is called and whether it is appended.",
   "severity": "medium",
   "evidence": "players/join.bat:84, players/join_lan.bat:87, players/play_solo.bat:85, players/play_steam.bat:76, players/README.txt:32 and :41, docs/OPERATOR_GUIDE.md:244; generated copies at C:\\git\\bt411\\tools\\btoperator.py:1386-1393 (`net_tail`), :1403-1407 (join.bat), :1421-1424 (join_lan.bat), :1432-1436 (play_solo.bat); mkdist ships only the tracked set (tools/mkdist.py:78-81)."
  },
  {
   "title": "The [crash] block carries no version, pid or timestamp — so a permanent, never-rotated crashes.log accumulates btl4+0xNNNN offsets across builds that can no longer be matched to a PDB",
   "mechanism": "BTCrashFilter prints exception code, address and an EBP-walk of MODULE-RELATIVE offsets (\"btl4+0x1234\") and nothing else. Those offsets are only resolvable against the PDB of the exact build that produced them, and mkdist archives one PDB per distributed zip, keyed by version. Today the association is implicit and reliable: the block lands in the same BT_LOG a few lines under `[boot] btl4 <version> pid=… args=…`, and the log is per-run. Tee'ing ONLY the [crash] block into a separate append-only file severs that link — and because .gitignore's `*.log` keeps crashes.log out of the zip, an extract-over-top upgrade preserves it, so the file deliberately accumulates blocks from multiple builds and multiple installs with nothing to tell them apart. A tester's crashes.log after two version bumps is a stack of unresolvable hex. The teed block must carry BT_VERSION_STRING + pid + local time, or (b) fails at its own stated purpose.",
   "severity": "high",
   "evidence": "game/btl4main.cpp:193-235 (the whole filter: code/addr/access/stack, no version/pid/time) vs :587-591 (the `[boot] btl4 <version> pid=…` line that currently supplies it); tools/mkdist.py:174-183 (\"archive this build's PDB … so the [crash] btl4+0xNNNN offsets in player logs stay resolvable per distributed version\"); .gitignore `*.log`."
  },
  {
   "title": "Tee'ing from inside the unhandled-exception filter adds allocation and file-open work to a crash path that today only touches an already-open stream — and two clients in one content\\ can interleave blocks",
   "mechanism": "BTCrashFilter currently writes exclusively to std::cout, whose rdbuf was redirected at startup to an already-open ofstream — no allocation, no CreateFile, no CRT heap work at fault time. Opening a std::ofstream (or fopen) inside the filter allocates and touches the CRT heap, which on a heap-corruption or stack-overflow exception is precisely what cannot be trusted; a second failure there costs the crash report entirely — the failure mode the change is meant to eliminate. Second, the operator routinely runs several clients from the same content\\ directory, and two processes appending to one crashes.log with buffered stream writes can interleave partial lines of two different stacks. The safe mechanism is a HANDLE opened at startup with FILE_APPEND_DATA | FILE_SHARE_READ|FILE_SHARE_WRITE and ONE WriteFile per complete block.",
   "severity": "medium",
   "evidence": "game/btl4main.cpp:193-235 (filter body, std::cout only) and :568-575 (the redirect that makes it allocation-free); C:\\git\\bt411\\tools\\btoperator.py:1252-1310 (multiple local instances, all `setWorkingDirectory(CONTENT)`)."
  },
  {
   "title": "The console side keeps the very 2-generation policy the proposal abolishes, so the operator's half of the same incident still evaporates — and a relay crash-loop destroys it in seconds",
   "mechanism": "The proposal makes client evidence permanent while both relay-side logs stay on a one-generation rotate. The parked supervisor rotates parked_relay.log to .1 at the top of EVERY generation, and its shortest backoff is 2 s — so a relay that dies on startup (port in use, missing egg) burns through both generations in about four seconds and the original fault is gone. The GUI does the same to operator_relay.log on every Start Session, which is why OPERATOR_GUIDE has a whole section headed \"The one thing to do before restarting a session\" telling the operator to hand-copy the file first. On the night described, the relay's view of those crashes is subject to exactly the deletion mechanism being removed on the client — and the rotate_log docstring in btoperator names that same 2026-07-26 games night as its own motivating incident. Extending the change to (b)'s doctrine without touching these leaves the correlation half-blind.",
   "severity": "medium",
   "evidence": "C:\\git\\bt411\\tools\\btrelay_park.py:44-52 (`rotate`), :98-100 (rotate per generation), :41 (`BACKOFF = [2, 5, 15, 30, 60]`); C:\\git\\bt411\\tools\\btoperator.py:40-55 (`rotate_log` docstring citing the 2026-07-26 loss) and :901; docs/OPERATOR_GUIDE.md:227-233."
  }
 ]
}
{
 "area": "Log lifecycle across launchers: players/*.bat rotation, BT_LOG/BT_LOG_APPEND in btl4main.cpp, the BTFE_RelaunchSelfAndExit generation chain, matchlog/marshal.log, and the proposed per-day game log + never-rotated crashes.log",
 "facts": [
  {
   "claim": "play_solo.bat rotates exactly one file family, solo.log, keeping 2 generations (current + .old); it deliberately does not touch join.log. It also truncates launch_report.txt every run (single '>'), so that forensic file keeps ZERO history.",
   "evidence": "C:\\git\\bt411\\players\\play_solo.bat:22-25 ('if exist content\\solo.old.log del content\\solo.old.log' / 'ren content\\solo.log solo.old.log' / BT_LOG=solo.log / BT_LOG_APPEND=1); rationale comments at :17-21; launch_report truncate at :31, appends at :47-49"
  },
  {
   "claim": "join.bat rotates join.log, 2 generations, and truncates launch_report.txt.",
   "evidence": "C:\\git\\bt411\\players\\join.bat:16-19 (del join.old.log / ren join.log join.old.log / BT_LOG=join.log / BT_LOG_APPEND=1); launch_report truncate :29; probes :45-47"
  },
  {
   "claim": "join_lan.bat rotates the SAME join.log family as join.bat — the two launchers consume each other's 2-generation window, so a player who alternates internet/LAN join loses evidence twice as fast.",
   "evidence": "C:\\git\\bt411\\players\\join_lan.bat:19-22 (identical del/ren on content\\join.log) vs join.bat:16-19"
  },
  {
   "claim": "play_steam.bat rotates steam.log, 2 generations, and is the ONLY player bat with no launch_report.txt at all — no '[%DATE% %TIME%] launching', no exit code, no 'log was never created' probe.",
   "evidence": "C:\\git\\bt411\\players\\play_steam.bat:14-17 (del steam.old.log / ren steam.log steam.old.log / BT_LOG=steam.log / BT_LOG_APPEND=1); the exe is launched bare at :40 with no forensic bracket, cf. play_solo.bat:31,46-49"
  },
  {
   "claim": "joyconfig.bat rotates joyconfig.log, 2 generations, truncates launch_report.txt, and has no handoff wait loop (it exits immediately after the exe returns).",
   "evidence": "C:\\git\\bt411\\players\\joyconfig.bat:22-25, :31 (truncate), :34-36 (probes), :37 ('exit /b' with no :btwait block)"
  },
  {
   "claim": "run/run.cmd rotates nothing and sets neither BT_LOG nor BT_LOG_APPEND, so the exe opens the default btl4.log in TRUNCATE mode: zero generations survive, and any relaunch inside that run wipes the earlier generation's lines. It also targets the Debug build.",
   "evidence": "C:\\git\\bt411\\run\\run.cmd:21 (set CFG=Debug), :41 (launch, no BT_LOG); btl4main.cpp:572-574 (default \"btl4.log\", std::ios::out unless BT_LOG_APPEND=1)"
  },
  {
   "claim": "The repo-root C:\\git\\bt411\\play_solo.bat is a byte-identical duplicate of players\\play_solo.bat; only the players/ copies are shipped.",
   "evidence": "diff of the two files reports no differences; tools\\mkdist.py:79-82 lists players/play_solo.bat, players/join.bat, players/join_lan.bat, players/joyconfig.bat (+ play_steam.bat when Steam is on)"
  },
  {
   "claim": "BTFE_RelaunchSelfAndExit does NOT re-run any .bat: it re-launches the exe directly via GetModuleFileNameW(NULL) + CreateProcessW, then ExitProcess(0). cmd.exe is never involved, so no rotation happens on a relaunch.",
   "evidence": "C:\\git\\bt411\\game\\glass\\btl4console.cpp:198-199 (GetModuleFileNameW), :238-243 (CreateProcessW), :244 (ExitProcess(0))"
  },
  {
   "claim": "The new generation inherits the parent environment (lpEnvironment == NULL in CreateProcessW), so BT_LOG and BT_LOG_APPEND carry through unchanged and every generation writes the SAME file the bat named.",
   "evidence": "btl4console.cpp:238 ('CreateProcessW(exe_path, command_line, NULL, NULL, FALSE, 0, NULL, NULL, ...)' — 7th arg lpEnvironment is NULL); btl4main.cpp:572-574 reads BT_LOG/BT_LOG_APPEND via getenv at each process start"
  },
  {
   "claim": "BT_LOG_APPEND=1 does preserve prior generations' lines: the log is opened with ios::out|ios::app, otherwise ios::out (truncate). Verified live — one solo.log holds three generations.",
   "evidence": "btl4main.cpp:572-574; content\\solo.log lines 1, 4, 5451 carry three '[boot] btl4 4.11.603 pid=...' banners (pids 10196, 14492, 17356) in one file"
  },
  {
   "claim": "There are five relaunch call sites, including one on the marshal WORKER thread that ExitProcess()es while the mission's main thread is still live.",
   "evidence": "btl4main.cpp:992 (menu -> mission), :1335 (round ended -> rejoin), :1349 (BT_FE_LOOP mission over -> menu); btl4console.cpp:444 (pod never answered) and :553 (mission clock expired, inside the marshal thread)"
  },
  {
   "claim": "Generation boundaries inside the shared log are NOT reliably marked: the menu relaunch clears BT_FE_LOOP, so a relaunched menu prints '(first process)'. Only the pid distinguishes generations, and the boot banner carries no timestamp.",
   "evidence": "btl4console.cpp:190-196 (SetEnvironmentVariableA(\"BT_FE_LOOP\", NULL) for empty arguments); btl4main.cpp:587-591 (banner, no clock); content\\solo.log:5451 shows pid=17356 labelled '(first process)' although it is the post-mission menu relaunch"
  },
  {
   "claim": "Arithmetic for play_steam.bat: a session's log is permanently deleted on the SECOND subsequent bat run — i.e. it survives its own run plus exactly one more launch.",
   "evidence": "play_steam.bat:14-15. Let S_k be the k-th bat run's log. Run k does: (1) del steam.old.log -> destroys S_(k-2); (2) ren steam.log -> steam.old.log -> S_(k-1) demoted; (3) the exe creates a fresh steam.log for S_k (append mode, file absent). Post-run-k on disk: steam.log=S_k, steam.old.log=S_(k-1), S_(k-2) gone. So a crash in run k is intact after run k+1 and destroyed at line 14 of run k+2 — 2 launches after the crash, 3 counting the crashing run. In-session lobby-loop relaunches consume none of that window (they never touch the files); double-clicking the bat twice consumes all of it in seconds even if the exe dies instantly."
  },
  {
   "claim": "The matchlog is immune to the rotation problem: unique filename per process (local date+time to the second + pid), opened 'w', and no launcher mentions it.",
   "evidence": "game\\reconstructed\\matchlog.cpp:78-88 (GetLocalTime + sprintf \"matchlog_%04u%02u%02u_%02u%02u%02u_%lu.txt\" + GetCurrentProcessId, fopen(path,\"w\")); grep for del/ren of *.log across the repo hits only the five player bats' own log families"
  },
  {
   "claim": "But the matchlog does NOT solve the crash-evidence problem: it is per-PROCESS not per-mission, it is gated off for solo/menu launches, and it never receives the [crash] block.",
   "evidence": "matchlog.cpp:54-89 (lazy one-shot gate; BT_MATCHLOG override, else armed only if GetCommandLineA() contains \"-net\" — the front-end/menu process and raw-solo have no -net); the [crash] text is written only to std::cout at btl4main.cpp:200-233; content\\ currently holds two matchlog_20260727_*.txt from one evening (one per generation)"
  },
  {
   "claim": "Yes — content\\marshal.log already survives every launch forever: opened append ('at') by two writers and touched by no launcher.",
   "evidence": "game\\glass\\btl4console.cpp:29 (fopen(\"marshal.log\",\"at\")) and game\\glass\\btl4lobby.cpp:31 (same file, 'shares the marshal's file'); live file content\\marshal.log (531 bytes). matchlog_*.txt files likewise accumulate. It is also the cautionary precedent: its lines carry no timestamp and no pid (btl4console.cpp:37-39 writes only '[marshal] <text>'), so its permanent history is unsegmentable."
  },
  {
   "claim": "%DATE% is used in four bats (never in play_steam.bat, never in run.cmd) and produces a locale-dependent string that includes a weekday and forward slashes.",
   "evidence": "play_solo.bat:31,47; join.bat:29,45; join_lan.bat:32,48; joyconfig.bat:31,34. Live: 'cmd /c echo [%DATE% %TIME%]' -> '[Tue 07/28/2026  0:03:16.47]'; content\\launch_report.txt line 1 -> '[Mon 07/27/2026 22:39:27.86]'"
  },
  {
   "claim": "A date-stamped filename built in cmd from %DATE% would NOT be safe across locales: it contains '/' (a path separator), a weekday token, and the field order/separator changes per regional settings; %TIME% additionally pads single-digit hours with a space.",
   "evidence": "Observed en-US 'Tue 07/28/2026' (weekday + MM/DD/YYYY with slashes) — 'steam_07/28/2026.log' is a path, not a name. Other locales give 28.07.2026 / 28/07/2026 (dd/MM defeats %DATE:~10,4% slicing) / 2026-07-28, with the weekday present or absent. Observed %TIME% '  0:03:16.47' (leading space). The escape hatches are all blocked or costly here: the bats deliberately refuse external helpers because find/findstr/timeout are shadowed by MSYS/GnuWin32 on PATH (play_solo.bat:59-68), wmic is removed on current Windows, and a powershell Get-Date call adds startup latency to every launch. Naming in the exe with GetLocalTime — exactly what matchlog.cpp:78-84 already does — is the locale-safe route, which argues FOR proposal (a)."
  }
 ],
 "conflicts": [
  {
   "title": "Every bat's launch forensics hard-code the old filename and will scream 'the exe was blocked' on healthy runs",
   "mechanism": "Four bats probe the literal log name after the exe exits: 'if not exist solo.log echo solo.log WAS NEVER CREATED: the exe was blocked before it could run (loader failure or antivirus)'. Once the exe names the file solo_20260727.log, that branch fires on EVERY launch, plus the 'size: %%~zA bytes' line disappears. launch_report.txt then permanently misreports a loader/antivirus failure to the exact playtesters and operator this change is meant to help.",
   "severity": "high",
   "evidence": "players\\play_solo.bat:48-49; players\\join.bat:46-47; players\\join_lan.bat:49-50; players\\joyconfig.bat:35-36"
  },
  {
   "title": "Player-facing 'send the operator content\\join.log' instructions become wrong in five bats, the README and the operator guide",
   "mechanism": "The sign-off text and the shipped README name the exact file to send. A renamed/date-stamped log leaves playtesters looking for a file that no longer exists, and the operator guide still tells the operator to ask for it. The dist README is generated from players/README.txt at package time, so a stale string ships.",
   "severity": "high",
   "evidence": "play_solo.bat:85; join.bat:84; join_lan.bat:87; play_steam.bat:76; players\\README.txt:32,41; docs\\OPERATOR_GUIDE.md:244; tools\\mkdist.py:109-110 (README is copied into the zip)"
  },
  {
   "title": "Redefining BT_LOG inside the exe breaks every headless/test harness that greps the file it named",
   "mechanism": "If the exe transforms BT_LOG='steam.log' into 'steam_YYYYMMDD.log', callers that set BT_LOG and then read that exact path stop finding output. That includes the MP two-instance harness (fa.log/fb.log) and the operator GUI, which additionally calls its own rotate_log() on a path the exe would no longer write. Documented contract 'BT_LOG=<file> per-instance log file' is broken repo-wide.",
   "severity": "high",
   "evidence": "tools\\mp_launch.sh:27,29; tools\\btoperator.py:1302-1305 (rotate_log(operator_N.log) then env BT_LOG=operator_N.log); context\\decomp-reference.md:417; context\\multiplayer.md:126; README.md:99,127-128; btl4main.cpp:565-567"
  },
  {
   "title": "Per-day naming collapses the per-instance namespace that BT_LOG exists to provide, and MSVC ios::app appends are not atomic between processes",
   "mechanism": "btl4main's own comment says BT_LOG is REQUIRED for multi-instance runs from one cwd. A date-only name makes two clients on one box (operator local instances, or a dev A/B pair) share one file. Concurrent appends through std::ofstream(ios::app) go via the CRT's lseek-to-end + write, which races between processes and can interleave or clobber lines. Today the overlap window is short (the parent CreateProcess()es the child and then ExitProcess()es two statements later); with one shared daily file, overlap becomes the normal case.",
   "severity": "high",
   "evidence": "btl4main.cpp:565-567 (the multi-instance warning), :568-575 (std::ofstream + ios::app); btl4console.cpp:238-244 (child spawned, parent exits immediately); tools\\btoperator.py:1302 (operator_1..N.log per local instance)"
  },
  {
   "title": "Writing crashes.log from inside BTCrashFilter risks deadlock/failure in an already-corrupt process",
   "mechanism": "The filter runs after arbitrary memory corruption (this project's dominant crash class; there is a whole BT_HEAPCHECK mode for it). Calling fopen/iostreams there can block on a CRT file lock held by the faulting thread, or fail outright on a corrupted heap — and the marshal worker thread is itself a concurrent file writer. The tee must not allocate or use CRT stdio: open the handle once at startup and emit with WriteFile from a stack buffer, no std::ostream, no malloc.",
   "severity": "high",
   "evidence": "btl4main.cpp:193-235 (filter body, currently pure std::cout), :549-554 (BT_HEAPCHECK heap-corruption hunt); btl4console.cpp:24-42 (MarshalLog writes files from the worker thread)"
  },
  {
   "title": "Filter-arm vs log-open vs cwd ordering: get it wrong and crashes.log lands next to the exe or misses the window it is supposed to cover",
   "mechanism": "SetUnhandledExceptionFilter runs at line 536, the cwd guard at 540, the log open at 572. A crash in that gap currently vanishes (cout is not yet redirected) — a crashes.log opened early would genuinely fix that, which is the strongest argument for (b). But it must be opened AFTER BTEnsureContentDirectory() or it is created wherever the player launched from, reproducing the documented stray-file bug. Note also that BT_CRASHTEST fires after the redirect, so the current test hook never exercises the pre-log window the new file is meant to cover.",
   "severity": "medium",
   "evidence": "btl4main.cpp:536 (arm), :540 (BTEnsureContentDirectory), :572-575 (log open + cout redirect), :577-580 (BT_CRASHTEST); :360-372 (the stray bindings.txt/environ.ini/btl4.log-next-to-the-exe report)"
  },
  {
   "title": "A never-deleted crashes.log spanning versions records unresolvable stacks unless each entry carries version + pid + local time",
   "mechanism": "The stack is printed as module-relative 'btl4+0xNNNN' offsets, resolvable only against that exact build's PDB (archived per zip). The block prints no version, no pid, no wall-clock — it relies on the [boot] banner sitting above it in the same file. Once the crash text lives in a separate long-lived file that spans several distributed builds, the offsets become meaningless numbers, and marshal.log is the standing proof of how bad an untimestamped append-only file is to read.",
   "severity": "high",
   "evidence": "btl4main.cpp:200-233 ([crash] block: code/addr/offsets only); tools\\mkdist.py:175-183 (PDB archived per zip so offsets 'stay resolvable per distributed version'); btl4console.cpp:37-39 (marshal.log lines have no timestamp/pid)"
  },
  {
   "title": "Per-day appending removes the only size control on a per-frame-chatty log",
   "mechanism": "Rotation currently doubles as size capping. The log carries per-frame telemetry and every player bat sets BT_DEV_GAUGES=1. One evening with three generations already produced 405 KB / 5,453 lines, of which roughly 620 lines each are [tick]/[sync]/[mppr]/[gait]/[gaitSM]/[ctrlmap]/[target]. A full playtest day of back-to-back missions turns 'send the operator your log' into a multi-tens-of-MB attachment. A size roll (e.g. steam_20260727_02.log past N MB) is needed alongside the date.",
   "severity": "medium",
   "evidence": "content\\solo.log (405,274 bytes / 5,453 lines, 3 boot banners); play_solo.bat:14, join.bat:21, join_lan.bat:24, play_steam.bat:22 (BT_DEV_GAUGES=1)"
  },
  {
   "title": "Midnight rollover splits a live session across two files — the exact failure being fixed, inverted",
   "mechanism": "If each generation computes the date at its own process start, a session running from 23:50 through a lobby-loop relaunch at 00:05 writes two files, separating the crash from its boot context and its earlier mission. Fix by naming the file once in the first generation and passing it down via SetEnvironmentVariableA before the relaunch — the child inherits the environment, which is exactly how BT_LOG already survives today.",
   "severity": "medium",
   "evidence": "btl4console.cpp:238 (lpEnvironment NULL = inherit), :190-196 (the existing pattern of stamping env before relaunch); btl4main.cpp:1345-1350 (post-mission menu relaunch)"
  },
  {
   "title": "Session segmentation inside a per-day file has no reliable marker today",
   "mechanism": "With one file per launch, file boundaries delimited sessions. In a per-day file the only separator is the pid on the boot banner — and the generation label lies, because the menu relaunch clears BT_FE_LOOP so a relaunched menu prints '(first process)'. Land an explicit session banner (local time + pid + version + BT_LOG name) before switching to per-day, or triage gets worse, not better.",
   "severity": "medium",
   "evidence": "btl4console.cpp:190-196; btl4main.cpp:587-591; content\\solo.log:5451 ('pid=17356 ... (first process)' for a post-mission menu relaunch)"
  },
  {
   "title": "btoperator.py exports its own player bats that are already stale and would be left on the old policy",
   "mechanism": "The operator's 'export scripts' writes join.bat / join_lan.bat / play_solo.bat with NO rotation, NO BT_LOG_APPEND, and sets BT_LOG=join.log even for play_solo.bat — the precise collision players/play_solo.bat:19-21 exists to prevent (a solo run destroying MP crash evidence). Any player who received exported bats runs a different policy from the shipped ones, so the change must be applied here too.",
   "severity": "high",
   "evidence": "tools\\btoperator.py:1394-1407 (join.bat), :1416-1424 (join_lan.bat), :1425-1436 (play_solo.bat with 'set BT_LOG=join.log'); cf. players\\play_solo.bat:19-21,24"
  },
  {
   "title": "A third, independent 2-generation rotation policy in the operator GUI keeps destroying the operator's own evidence",
   "mechanism": "rotate_log() renames to '<path>.1' and deletes the previous '.1' on every Start Session / Launch-instances press — same 2-generation window, different naming scheme, and its own docstring records the same 2026-07-26 evidence loss. Fixing only the player bats leaves the operator side losing crash sessions after two button presses.",
   "severity": "medium",
   "evidence": "tools\\btoperator.py:40-55 (rotate_log), :901 (operator_relay.log), :1303 (operator_N.log)"
  },
  {
   "title": "run/run.cmd is strictly worse than the bats and is untouched by the proposal",
   "mechanism": "It sets neither BT_LOG nor BT_LOG_APPEND, so btl4.log is opened in truncate mode: zero generations survive and each in-process relaunch wipes the prior generation. It also runs build\\Debug\\btl4.exe, whose offsets do not match the archived Release PDB. Dev-side crash evidence is lost at the very next run.",
   "severity": "medium",
   "evidence": "run\\run.cmd:21,41; btl4main.cpp:572-574"
  },
  {
   "title": "play_steam.bat has no launch bracket at all, so the exe-named-log change silently removes the last external check on the Steam path",
   "mechanism": "The other four bats at least record launch time, exit code and log size in launch_report.txt (itself only 1 generation, truncated by the single '>'). play_steam.bat records nothing. With the exe naming its own log, nothing outside the process observes whether the Steam path ever wrote one — and launch_report.txt is shared and clobbered by whichever bat ran last, so cross-launcher evidence is already destroyed on the first re-run.",
   "severity": "medium",
   "evidence": "players\\play_steam.bat:40 (bare launch, no forensics); play_solo.bat:31 / join.bat:29 / join_lan.bat:32 / joyconfig.bat:31 (all use '>' truncate on the same shared launch_report.txt)"
  },
  {
   "title": "crashes.log has no delivery path — preserved forever on a machine nobody asks",
   "mechanism": "The only automatic upload is the matchlog, and the operator guide states outright that the client crash log is never uploaded. Unless BTRelayUploadMatchLog is extended (relay sessions only — Steam and solo have no relay), every bat sign-off and players/README.txt must be rewritten to name crashes.log, or the change preserves the evidence and still fails to collect it. Note it will NOT be shipped or clobbered by an extract-over-top upgrade, since mkdist zips only tracked content/ paths.",
   "severity": "medium",
   "evidence": "btl4main.cpp:1304-1311 (BTRelayUploadMatchLog, relay mode only); docs\\OPERATOR_GUIDE.md:244 ('Their crash log, content\\join.log, is never uploaded'); tools\\mkdist.py:48 (git ls-files content/); players\\README.txt:32,41"
  },
  {
   "title": "The repo-root play_solo.bat duplicate will drift",
   "mechanism": "C:\\git\\bt411\\play_solo.bat is byte-identical to players\\play_solo.bat but only the players/ copy is packaged. Editing one leaves the dev box and the shipped zip on different log policies — and the dev-box copy is the one most likely to be used while validating the change.",
   "severity": "low",
   "evidence": "diff reports the files identical; tools\\mkdist.py:79-82 lists only players/*.bat"
  },
  {
   "title": "Leftover *.old.log files and dead del/ren lines after the cutover",
   "mechanism": "If the bats keep their del/ren lines they will rotate a filename the exe no longer writes (harmless but it strands the last pre-change session half-rotated); if the lines are removed, solo.old.log / join.old.log / steam.old.log / joyconfig.old.log remain on disk indefinitely next to the new date-stamped files and will be mistaken for current evidence during triage. One of the two must be handled explicitly in the same commit.",
   "severity": "low",
   "evidence": "content\\ currently holds solo.log (405 KB) and solo.old.log (352 KB); rotation lines at play_steam.bat:14-15, play_solo.bat:22-23, join.bat:16-17, join_lan.bat:19-20, joyconfig.bat:22-23"
  }
 ]
}
{
 "area": "Client side of automatic log retrieval — matchlog auto-upload (btl4main WinMain tail → L4NET BTRelayUploadMatchLog → route -9), matchlog file lifecycle, and the BT_LOG / BT_LOG_APPEND / BTCrashFilter game-log path",
 "facts": [
  {
   "claim": "The auto-upload has exactly ONE trigger site: the WinMain tail, after the mission loop returns. It is not a per-mission hook.",
   "evidence": "game/btl4main.cpp:1301-1311 — `app_manager->RunMissions();` … then `{ extern void BTRelayUploadMatchLog(void); BTRelayUploadMatchLog(); }`. Repo-wide grep finds no other call site (only the decl at engine/MUNGA_L4/L4NET.CPP:702 and the doc reference at game/reconstructed/matchlog.hpp:53)."
  },
  {
   "claim": "The pod sends the MATCHLOG ONLY. The BT_LOG game log is never uploaded by any code path.",
   "evidence": "engine/MUNGA_L4/L4NET.CPP:709 `const char *path = BTMatchLogPath();` is the only file opened (fopen at :714, read at :726, sent at :780). `BT_LOG` is read in exactly one place in the whole tree — game/btl4main.cpp:572-573 — and only to name the ofstream. Confirmed by docs/OPERATOR_GUIDE.md:244 \"Their crash log, content\\join.log, is **never** uploaded\"."
  },
  {
   "claim": "The matchlog filename is generated in-process from local time + PID and is stored in a 160-byte file-scope buffer.",
   "evidence": "game/reconstructed/matchlog.cpp:81-85 `sprintf(s_matchPath, \"matchlog_%04u%02u%02u_%02u%02u%02u_%lu.txt\", …GetLocalTime fields…, GetCurrentProcessId())`; buffer declared at matchlog.cpp:34 `static char s_matchPath[160]`; exposed via `BTMatchLogPath()` matchlog.cpp:36-40 / matchlog.hpp:54."
  },
  {
   "claim": "The filename is transmitted as the first payload field: the NUL-terminated path, strlen(path)+1 bytes, immediately followed by the raw file bytes.",
   "evidence": "engine/MUNGA_L4/L4NET.CPP:757 `int name_length = (int)strlen(path) + 1;  // filename + NUL`, then :778-780 three SendAll calls — envelope, `path` (name_length), `data` (size)."
  },
  {
   "claim": "Envelope route is RELAY_ROUTE_MATCHLOG = -9 on a throwaway TCP connection to the relay GAME port.",
   "evidence": "engine/MUNGA_L4/L4NET.CPP:163 `RELAY_ROUTE_MATCHLOG = -9,  // pod->relay: matchlog upload` (payload comment at :164); envelope filled at :758-760; dialed to `s_relayGameAddrCache` at :745-746, which was cached from `relayGameAddress` (console port + 1, set at :536-538)."
  },
  {
   "claim": "Payload layout: 8-byte native-endian envelope { int32 route; uint32 length; } where length = (strlen(filename)+1) + filesize, then filename+NUL, then file bytes. No byte-order conversion is applied.",
   "evidence": "engine/MUNGA_L4/L4NET.CPP:178-182 `struct RelayTcpEnvelope { int route; unsigned int length; };`; :760 `envelope.length = (unsigned int)(name_length + size);`; :778-780 sends the struct raw. Relay side agrees: tools/btconsole.py:284 ROUTE_MATCHLOG = -9, :1391-1404 splits on the first NUL and writes the remainder."
  },
  {
   "claim": "There IS a client-side size cap: 8 MB, and hitting it is a completely SILENT skip (it returns before the report line).",
   "evidence": "engine/MUNGA_L4/L4NET.CPP:720-724 `if (size <= 0 || size > 8L * 1024L * 1024L) { fclose(f); return; }` — the DEBUG_STREAM SENT/FAILED report is at :798-800, after that return. Relay mirrors the cap: tools/btconsole.py:291 `MATCHLOG_CAP = 8 * 1024 * 1024   # (client caps at 8MB)` and :1372-1375 drops an oversize frame."
  },
  {
   "claim": "The dial is bounded (3 s connect) but the SEND is not — the socket is switched back to blocking before the payload goes out, so a half-open relay can stall process exit despite the comment claiming otherwise.",
   "evidence": "engine/MUNGA_L4/L4NET.CPP:738 comment \"a dead relay must not hang process exit\"; :743-744 non-blocking; :753 `select(..., 3s)`; :755-756 `non_blocking = 0; ioctlsocket(...)  // blocking for the send`; the SendAll loop at :765-776 has no deadline. Graceful finish: `shutdown(sock, SD_SEND)` :784 plus a 2 s drain read :787-792."
  },
  {
   "claim": "The upload gate is a single file-scope flag, set ONLY inside the BT_RELAY parse block — i.e. relay mode only.",
   "evidence": "engine/MUNGA_L4/L4NET.CPP:707 `if (!s_relayGameAddrCached) return;  // mesh/solo: no relay`; the flag is set at :598-602 `BTRelayRememberGameAddress`, whose only caller is :572 `BTRelayRememberGameAddress(relayGameAddress); // matchlog auto-upload`, inside `if (relay_env != NULL && relay_env[0] != '\\0')` at :451-452."
  },
  {
   "claim": "SOLO: double no-op. The network manager returns before the BT_RELAY block when there is no netcom, and the matchlog does not arm without \"-net\" on the command line.",
   "evidence": "engine/MUNGA_L4/L4NET.CPP:354-378 — no netcom → posts the local egg and `return;` at :378, never reaching the relay parse at :444. game/reconstructed/matchlog.cpp:72-73 `const char *cmd = GetCommandLineA(); on = (cmd != 0 && strstr(cmd, \"-net\") != 0);`."
  },
  {
   "claim": "CLASSIC MESH (-net, no BT_RELAY): the matchlog arms and is written, but the upload is a silent no-op — manual collection only.",
   "evidence": "matchlog arms via matchlog.cpp:72-73; upload returns at L4NET.CPP:707-708; design note at L4NET.CPP:588-589 \"Relay-mode only (the mesh has no central point -- manual send remains the fallback there…)\"; docs/OPERATOR_GUIDE.md:243-245."
  },
  {
   "claim": "STEAM (BT_STEAM_NET): the upload does NOT fire. Steam launches carry \"-net <port>\" (so the matchlog IS written) but nothing on the Steam path ever sets BT_RELAY, so s_relayGameAddrCached stays 0.",
   "evidence": "game/btl4main.cpp:965-975 — BTFeLaunchJoinSteam/HostSteam do `SetEnvironmentVariableA(\"BT_STEAM_NET\", \"1\")` and build args `\"-net %d -platform glass\"` (:970, and HostSteam falls through to the default at :983). Grep for BT_RELAY across game/ + engine/ returns no glass/Steam site — only L4NET.CPP:451 (parse), btl4main.cpp:1321, L4APP.cpp:615, APP.cpp:683, matchlog.cpp:98. docs/OPERATOR_GUIDE.md:243-245 states it plainly: \"players on solo or Steam have no relay to upload through\"."
  },
  {
   "claim": "Menu-launched \"solo\"/HostLan sessions DO arm the matchlog, contradicting the header's \"Solo stays silent\" — only the raw-solo front-end mode omits -net.",
   "evidence": "game/btl4main.cpp:978-984 default case (Solo / HostLan) → `sprintf(fe_arguments, \"-net %d -platform glass\", …)`; the only -net-free launch is BTFeLaunchRawSolo at :940-943. Header claim at game/reconstructed/matchlog.hpp:14 \"Solo stays silent.\" Live evidence in this tree: content\\matchlog_20260727_221916_14560.txt and content\\matchlog_20260727_223931_14492.txt exist alongside solo.log with no relay in use."
  },
  {
   "claim": "On failure the local file is ALWAYS kept — nothing in the tree ever deletes a matchlog — and it is closed/flushed before the read so it is complete on disk.",
   "evidence": "engine/MUNGA_L4/L4NET.CPP:713 `BTMatchLogClose();  // complete file on disk` (impl: matchlog.cpp:42-52, fflush+fclose); every failure path is a bare `return`/`fclose`+`return` (:708, :711, :716, :723, :730) with no unlink; report line :798-800 `\"[relay] matchlog upload \" << (ok ? \"SENT: \" : \"FAILED (file kept for manual send): \")`."
  },
  {
   "claim": "Four of the six failure modes are silent — only a failed CONNECT/SEND after a successful file read produces a log line.",
   "evidence": "engine/MUNGA_L4/L4NET.CPP: relay address unknown :707-708, empty path :710-711, fopen failure :715-716, size 0 or >8 MB :720-724, malloc/fread failure :726-731 all return before the DEBUG_STREAM at :798."
  },
  {
   "claim": "A mid-round CRASH loses the upload but not the file: the crash filter kills the process before the WinMain tail, while per-line fflush leaves the matchlog complete up to the crash.",
   "evidence": "game/btl4main.cpp:234 `return EXCEPTION_EXECUTE_HANDLER;  // die (after the evidence is out)` — control never returns to :1310. game/reconstructed/matchlog.cpp:141 `fflush(s_matchLog);` after every line (contract stated at matchlog.hpp:20)."
  },
  {
   "claim": "Two NON-crash exit paths also bypass the upload entirely, because they call ExitProcess from inside the mission process instead of returning from RunMissions.",
   "evidence": "engine/MUNGA_L4/L4NET.CPP:641-666 `BTRelayRejoinNow` (relay ordered REJOIN / relay died pre-mission) ends in `BTFE_RelaunchSelfAndExit(...)`; game/glass/btl4console.cpp:552-553 the marshal thread's clock-expiry `BTFE_RelaunchSelfAndExit(\"\")`; that function ends at btl4console.cpp:244 `ExitProcess(0);`. The clean path instead returns from RunMissions, uploads at btl4main.cpp:1310, and only then relaunches at :1321-1336 (flag set in engine/MUNGA/APP.cpp:1724-1725)."
  },
  {
   "claim": "The matchlog is written to the process CWD, which the exe forces to content\\ before any relative path resolves.",
   "evidence": "game/reconstructed/matchlog.cpp:85 `s_matchLog = fopen(s_matchPath, \"w\");` (relative); game/btl4main.cpp:538-540 \"Sit in content\\\\ before ANYTHING resolves a relative path\" → `BTEnsureContentDirectory();`, implemented at :390-425 (probes `..\\..\\content`, `..\\content`, `\\content`, …). Header contract: matchlog.hpp:15-16."
  },
  {
   "claim": "NOTHING rotates or deletes matchlogs — they accumulate indefinitely in content\\ (and in the relay's matchlogs\\).",
   "evidence": "No del/ren of matchlog_* in any launcher (players/*.bat only touch *.log — e.g. players/play_steam.bat:14-15); no unlink/DeleteFile of matchlogs in source; corpus evidence docs/KD_SCOREBOARD_PLAN.md:6 \"88 matchlogs in content/matchlogs/\" and :274-275 (tools scan content/matchlog*.txt + content/matchlogs/**)."
  },
  {
   "claim": "Relay-side filename policy the client must satisfy: name ≤150 bytes before the NUL, must start with \"matchlog_\" and end \".txt\", saved as matchlogs/<peer-ip>_<name>.",
   "evidence": "tools/btconsole.py:1391-1404 — `nul = payload.find(b\"\\0\"); if nul <= 0 or nul > 150: drop`; `if not (name.startswith(\"matchlog_\") and name.endswith(\".txt\")): drop`; `out_path = os.path.join(\"matchlogs\", f\"{peer}_{name}\")`."
  },
  {
   "claim": "BT_LOG names the game log; BT_LOG_APPEND=1 selects append instead of truncate; the whole engine log stream is then redirected into it.",
   "evidence": "game/btl4main.cpp:572-575 `logfile.open(getenv(\"BT_LOG\") ? getenv(\"BT_LOG\") : \"btl4.log\", (getenv(\"BT_LOG_APPEND\") != NULL && *getenv(\"BT_LOG_APPEND\") == '1') ? (std::ios::out | std::ios::app) : std::ios::out); std::cout.rdbuf(logfile.rdbuf());` — and DEBUG_STREAM is std::cout (engine/MUNGA/STYLE.H:6), so engine lines including the relay's \"[relay] matchlog upload …\" (L4NET.CPP:798) land there."
  },
  {
   "claim": "BT_LOG_APPEND exists because BT_LOG is INHERITED across the menu→mission relaunch chain: each generation would otherwise truncate the shared file.",
   "evidence": "game/btl4main.cpp:569-571 comment; the relaunch inherits the environment — game/glass/btl4console.cpp:238-239 `CreateProcessW(exe_path, command_line, NULL, NULL, FALSE, 0, NULL, NULL, …)` (NULL environment = inherit)."
  },
  {
   "claim": "BTCrashFilter writes ONLY to std::cout — i.e. into whatever BT_LOG names, with no file of its own — then lets the process die.",
   "evidence": "game/btl4main.cpp:193-235: `std::cout << \"\\n[crash] UNHANDLED EXCEPTION code=0x\" …` (:200-208), `\"[crash] stack:\"` (:209), up to 24 module-relative frames via the EBP walk (:213-232), `std::cout << std::dec << \"\\n\" << std::flush;` (:233), `return EXCEPTION_EXECUTE_HANDLER;` (:234). Intent stated at :186-188 \"…logs its code, address, and an EBP-chain stack walk … into BT_LOG before the process dies\"."
  },
  {
   "claim": "The filter is armed BEFORE the cwd fix and before the log is opened, so a crash in that window produces no evidence at all (GUI app, no console stdout).",
   "evidence": "game/btl4main.cpp:536 `SetUnhandledExceptionFilter(BTCrashFilter);` → :540 `BTEnsureContentDirectory();` → :572 log open → :575 rdbuf swap. BT_CRASHTEST is deliberately placed after the log opens (:577-580)."
  },
  {
   "claim": "The log stream is a WinMain LOCAL — after WinMain returns, std::cout's rdbuf points at a destroyed streambuf.",
   "evidence": "game/btl4main.cpp:568 `std::ofstream logfile;` declared inside WinMain; :575 `std::cout.rdbuf(logfile.rdbuf());`; WinMain returns at :1359."
  },
  {
   "claim": "environ.ini is loaded AFTER the log is opened, so BT_LOG (and any future log-naming key) cannot be configured from that file today.",
   "evidence": "game/btl4main.cpp:572 (log open) vs :605 `BTLoadEnvironIni();`; the file was already moved earlier once for exactly this class of ordering bug — comment at :242-249."
  },
  {
   "claim": "Rotation lives entirely in the .bat launchers and keeps exactly 2 generations (current + .old), per-launcher name-exact.",
   "evidence": "players/play_steam.bat:14-17, players/join.bat:16-19, players/join_lan.bat:19-22, players/play_solo.bat:22-25, players/joyconfig.bat:22-25 — `if exist content\\X.old.log del content\\X.old.log` / `if exist content\\X.log ren content\\X.log X.old.log` / `set BT_LOG=X.log` / `set BT_LOG_APPEND=1`. The 2026-07-26 loss is called out in-line, e.g. play_steam.bat:12-13 \"KEEP the previous run's log (a crash report died 2026-07-26 …). Rotate, never delete.\""
  },
  {
   "claim": "The operator-EXPORTED player bats (the ones actual remote playtesters run) set BT_LOG but NOT BT_LOG_APPEND and do NOT rotate — so every launch TRUNCATES join.log outright.",
   "evidence": "tools/btoperator.py:1403 `\"set BT_RELAY=%s:%d\\nset BT_LOG=join.log\\n\"` (join.bat), :1421 (join_lan.bat), :1433 `\"set BT_LOG=join.log\\nset BT_FE_SOLO=1\\n\"` (play_solo.bat) — no BT_LOG_APPEND, no del/ren; btl4main.cpp:574 therefore uses `std::ios::out` (truncate). The operator's own LOCAL instances are the safe case: rotate_log() → .1 (tools/btoperator.py:40-55, 1302-1305, which also sets BT_LOG_APPEND=1)."
  },
  {
   "claim": "The .bat launch forensics (#41) reference the log by EXACT filename to decide whether the exe ever started.",
   "evidence": "players/join.bat:46-47 `if not exist join.log echo join.log WAS NEVER CREATED: the exe was blocked before it could run (loader failure or antivirus) >> launch_report.txt` / `if exist join.log for %%A in (join.log) do echo join.log size: %%~zA bytes >> launch_report.txt`; same pattern at join_lan.bat:49-50, play_solo.bat:48-49, joyconfig.bat:35-36. The 0-byte heuristic is documented at btl4main.cpp:582-586 and players/join.bat:13."
  },
  {
   "claim": "Player- and operator-facing instructions hardcode the current log names.",
   "evidence": "players/play_steam.bat:75-77 \"send the operator content\\steam.log\"; players/join.bat:84 \"content\\join.log and content\\launch_report.txt\"; players/play_solo.bat:85; tools/btoperator.py:1389 and :1436 (generated bat text); docs/OPERATOR_GUIDE.md:244."
  }
 ],
 "conflicts": [
  {
   "title": "The EXE has no way to know the \"steam\"/\"join\"/\"solo\" stem — that fact exists only in BT_LOG, and repurposing BT_LOG as a stem breaks every existing caller",
   "mechanism": "Nothing in the process distinguishes which launcher started it: play_steam.bat, join.bat and play_solo.bat all end up at the same `..\\build\\Release\\btl4.exe` (play_steam.bat:40, join.bat:44, play_solo.bat:~46) and differ only in `set BT_LOG=…` and side env (BT_FE_SOLO, BT_RELAY, BT_STEAM_NET). So a per-day name \"named by the EXE itself\" must either (a) keep reading BT_LOG and reinterpret it as a STEM — changing the contract of a variable whose current meaning is \"the full filename\" for tools/btoperator.py:1304 (operator_%d.log), :1403/:1421/:1433 (join.log), tools/mp_launch.sh:27-29, README.md:99 and a dozen scratchpad rigs, all of which would silently acquire a date suffix — or (b) drop the distinction and put every mode in one per-day file, which re-creates precisely the hazard the bats were fixed for: play_solo.bat:19-21 and joyconfig.bat:20-21 exist to stop a solo/wizard run from destroying a player's MP crash evidence.",
   "severity": "high",
   "evidence": "game/btl4main.cpp:572-573 (only BT_LOG reader); players/play_solo.bat:19-21; players/joyconfig.bat:20-21; tools/btoperator.py:1304, 1403, 1421, 1433"
  },
  {
   "title": "Per-day naming silently breaks the #41 launch forensics and inverts the 0-byte \"never started\" heuristic",
   "mechanism": "Each bat probes the log by literal name after the exe exits: `if not exist join.log echo join.log WAS NEVER CREATED: the exe was blocked before it could run (loader failure or antivirus)`. If the exe writes join_20260727.log instead, that test is true on EVERY run and launch_report.txt fills with a false AV/loader accusation — killing the one diagnostic that separates \"blocked before WinMain\" from \"crashed later\". Symmetrically, the `%%~zA bytes` size probe and the documented \"0-byte log = killed pre-start\" rule stop working the other way: on the second launch of the same day the appended file is never 0 bytes even if the new process never reached WinMain's first-breath line.",
   "severity": "high",
   "evidence": "players/join.bat:46-47; players/join_lan.bat:49-50; players/play_solo.bat:48-49; players/joyconfig.bat:35-36; game/btl4main.cpp:582-591 (first-breath line + 0-byte reasoning)"
  },
  {
   "title": "The change does not reach the players who lost the crash logs — operator-exported bats truncate and would keep truncating",
   "mechanism": "The reported loss happened on remote playtesters' machines, and those testers run bats GENERATED by tools/btoperator.py, not the checked-in players/*.bat. The generated ones set `BT_LOG=join.log` with no BT_LOG_APPEND and no rotation, so btl4main.cpp:574 opens with std::ios::out and each launch truncates the file outright — worse than the 2-generation window described in the premise. If the new per-day naming honours BT_LOG when it is set (the compatible design), those testers get no per-day file and no fix; if it overrides BT_LOG, the generator's `echo … send the operator content\\join.log` text (btoperator.py:1389, 1436) becomes wrong for every export already in players' hands. Either way tools/btoperator.py must change in the same commit.",
   "severity": "high",
   "evidence": "tools/btoperator.py:1403, 1421, 1433 (no BT_LOG_APPEND), 1389, 1436 (generated instruction text); game/btl4main.cpp:574"
  },
  {
   "title": "Per-day naming collapses the per-INSTANCE separation BT_LOG was introduced to provide",
   "mechanism": "btl4main.cpp:566-567 states BT_LOG is \"REQUIRED for multi-instance runs from one cwd (instance 2 would truncate instance 1's log)\". A name derived only from the date reunifies them: the operator's two local instances (currently operator_1.log / operator_2.log, tools/btoperator.py:1302) and two-node dev runs (mp_a.log / mp_b.log) would target one file. Even single-player has overlapping writers by design — the front end CreateProcessW()es the mission child and only THEN ExitProcess(0)s itself (btl4console.cpp:238-244), so parent and child hold the same handle for a moment; today that is contained by the append mode the bats set, but with a shared per-day name the overlap widens to unrelated launchers. Any per-day scheme has to keep a per-instance discriminator (pid or -net port) or keep honouring BT_LOG.",
   "severity": "high",
   "evidence": "game/btl4main.cpp:566-567; tools/btoperator.py:1302-1305; game/glass/btl4console.cpp:238-244; tools/mp_launch.sh:27-29"
  },
  {
   "title": "Writing crashes.log with CRT stdio from inside BTCrashFilter can deadlock or double-fault the very report it is meant to save",
   "mechanism": "The filter runs in an already-corrupt process on ANY unhandled exception, explicitly including heap corruption (the BT_HEAPCHECK path exists for that, btl4main.cpp:549-554). fopen/fprintf take the CRT stdio lock and may allocate; if the crashing thread faulted while holding that lock, or the heap is the thing that is broken, the tee hangs or re-faults inside the filter — losing not only crashes.log but the existing std::cout block at btl4main.cpp:200-233 that works today. Compounding it: the block is emitted as ~15 chained `<<` with hex/dec manipulators, so \"tee the block\" means either formatting into a stack buffer first or duplicating every operator call, doubling the fault surface inside the filter. The safe shape is a pre-formatted stack buffer plus CreateFileA(FILE_APPEND_DATA)/WriteFile with no allocation and no CRT stream.",
   "severity": "high",
   "evidence": "game/btl4main.cpp:193-235 (filter body), :549-554 (BT_HEAPCHECK rationale), :234 (process dies immediately after)"
  },
  {
   "title": "The crash path's existing output window is narrower than the proposal assumes — and a naive crashes.log inherits the same holes",
   "mechanism": "The filter is armed at btl4main.cpp:536, but std::cout is not redirected until :575; a crash in between goes to a GUI process's default stdout, i.e. nowhere. And `logfile` is a WinMain local (:568), so after WinMain returns at :1359 std::cout's rdbuf points at a destroyed streambuf — a crash during CRT teardown/static destructors writes through freed memory. If crashes.log is opened lazily from inside the filter it has the same second problem; if it is opened once at process start with a raw handle (before :536), it actually CLOSES both windows and becomes strictly better than the status quo. This is a design constraint on (b), and an argument for doing it with a raw handle rather than a second ofstream.",
   "severity": "medium",
   "evidence": "game/btl4main.cpp:536, 568, 575, 1359"
  },
  {
   "title": "An unrotated per-day BT_LOG collides with the \"send us the log\" support flow it is meant to serve",
   "mechanism": "The game log is the entire DEBUG_STREAM (engine/MUNGA/STYLE.H:6) with BT_PLANE_AUDIT default-ON, and the relay lobby loop starts a FRESH process per round (btl4main.cpp:1321-1336), all appending. A games-night's worth of rounds concatenates into one file that a tester is asked to hand over by hand — while the file that IS auto-collected, the matchlog, is capped at 8 MB (L4NET.CPP:720) precisely so an upload stays sane. Nothing in the exe or the bats would cap or trim the new per-day file, and it is never uploaded, so there is no back-pressure at all on its size.",
   "severity": "medium",
   "evidence": "engine/MUNGA/STYLE.H:6; context/decomp-reference.md:422 (BT_PLANE_AUDIT DEFAULT-ON); game/btl4main.cpp:1321-1336; engine/MUNGA_L4/L4NET.CPP:720"
  },
  {
   "title": "\"Never rotated or deleted\" is not enforceable by crashes.log's own name — it sits in the same content\\*.log namespace the bats already sweep",
   "mechanism": "The only reaper in the project is the bats' name-exact `del`/`ren` pair (play_steam.bat:14-15 and siblings). Nothing in the exe deletes logs. Once log names become date-stamped, the natural follow-up is a WILDCARD sweep (the premise itself describes rotation as `del *.old.log` / `ren *.log *.old.log`) — and any `del content\\*.log` broom takes crashes.log with it, defeating the whole point. If crashes.log must be immortal it should be named or located out of the *.log pattern (e.g. crashes.txt, or a crashes\\ subfolder) and that exclusion must be written into every bat and into tools/btoperator.py's generator.",
   "severity": "medium",
   "evidence": "players/play_steam.bat:14-15; players/join.bat:16-17; players/play_solo.bat:22-23; players/joyconfig.bat:22-23; tools/btoperator.py:40-55"
  },
  {
   "title": "Every player-facing instruction naming the log file goes stale, including bats already distributed",
   "mechanism": "The sign-off text is hardcoded: \"send the operator content\\steam.log\" (play_steam.bat:76), \"content\\join.log and content\\launch_report.txt\" (join.bat:84), \"send the operator content\\join.log\" (btoperator-generated, :1436), and docs/OPERATOR_GUIDE.md:244. Under per-day naming none of those paths exist, so a tester is told to send a file that is not there and the operator receives nothing. The generated bats are the worst case: copies already sitting in players' folders cannot be updated with the exe.",
   "severity": "medium",
   "evidence": "players/play_steam.bat:75-77; players/join.bat:84; players/play_solo.bat:85; tools/btoperator.py:1389, 1436; docs/OPERATOR_GUIDE.md:244"
  },
  {
   "title": "A per-day naming POLICY cannot be configured or opted out of via environ.ini without moving the log open",
   "mechanism": "The log is opened at btl4main.cpp:572, but environ.ini — the player's settings file, and the documented place for persistent switches — is not loaded until :605. Any key like BT_LOG_DAILY=0 placed in environ.ini would be read 30+ lines of execution too late to affect the filename. The file's load was already relocated once for exactly this ordering class (comment at :242-249, \"every setting the profile reads … was silently ignored from the file\"), so re-introducing a setting that lives on the wrong side of the boundary repeats a fixed bug.",
   "severity": "low",
   "evidence": "game/btl4main.cpp:572, 605, 242-249"
  },
  {
   "title": "The proposal preserves the crash STACK but leaves the matching matchlog unidentifiable — the crash case is exactly the case with no upload",
   "mechanism": "A mid-round crash skips btl4main.cpp:1310 entirely, so that round's matchlog is never uploaded (docs/OPERATOR_GUIDE.md:243) and just joins the growing pile of content\\matchlog_*.txt, which nothing rotates or indexes. After (a)+(b) the operator has a crash stack in crashes.log and a folder of same-day matchlog files with no link between them. Cheap fix inside the same change: have the crash block also record BTMatchLogPath() (exposed for exactly this purpose at matchlog.hpp:52-54) and the PID, so the crash record names its own round's matchlog — otherwise crashes.log entries from multiple concurrent instances are also indistinguishable, since the PID currently appears only in the separate BT_LOG's [boot] line.",
   "severity": "low",
   "evidence": "game/btl4main.cpp:1301-1311, 234, 587-591; game/reconstructed/matchlog.hpp:52-54; docs/OPERATOR_GUIDE.md:243-245"
  }
 ]
}
{
 "title": "Relay matchlog-overwrite conflict (crashes.log would be clobbered server-side)",
 "real": false,
 "reasoning": "REFUTED. The claim conflates the matchlog upload with the BT_LOG game log / proposed crashes.log — they are different files on different code paths, and neither of the proposal's files can ever reach the cited handler.\n\n1) The cited code is real but narrowly gated. C:\\git\\bt411\\tools\\btconsole.py:1385-1407 does contain `open(out_path, \"wb\")` with no existence check and an unconditional \"matchlog saved\" print. But four lines above it, at btconsole.py:1396, sits a hard name filter:\n   `if not (name.startswith(\"matchlog_\") and name.endswith(\".txt\")): self._drop_game(conn, f\"suspicious matchlog name {name!r}\"); return`\n   A file named `crashes.log` or `steam_20260727.log` does not start with `matchlog_` and does not end with `.txt`. It would be REJECTED and the connection dropped — not silently overwritten. The claimed `<peer>_crashes.log` path literally cannot be constructed by this handler.\n\n2) Nothing uploads the game log or a crashes.log to the relay — no such code exists. The only sender of ROUTE_MATCHLOG (-9) is `BTRelayUploadMatchLog()` at engine/MUNGA_L4/L4NET.CPP:702-770, and the filename it sends is `BTMatchLogPath()` (L4NET.CPP:709, 757), which is the hard-coded `sprintf(s_matchPath, \"matchlog_%04u%02u%02u_%02u%02u%02u_%lu.txt\", ...)` at game/reconstructed/matchlog.cpp:81-84. There is no generic file-upload route: grepping btconsole.py for network-driven file writes returns exactly two `open(...)` calls — line 345 (a local secrets token file, unrelated) and line 1403 (this handler).\n\n3) The proposal touches neither file. BTCrashFilter (game/btl4main.cpp:193-235) writes only to `std::cout`, which is redirected to BT_LOG at btl4main.cpp:572. BT_LOG is set by the .bat files (players/play_steam.bat:15-17, join.bat:18-19, play_solo.bat:24-25) and by btoperator.py:1304. It is a purely local file. The proposal's (a) per-day EXE-named log and (b) crashes.log tee are both local-file changes in btl4main.cpp; they do not touch matchlog.cpp, L4NET.CPP, or any relay route. Adopting the proposal changes nothing about what crosses the wire.\n\n4) The \"two testers behind one NAT overwrite each other\" scenario does not hold even for matchlogs. The shared `<peer>` prefix is combined with the client-generated name carrying YYYYMMDD + HHMMSS + PID; a collision needs two different machines to start a matchlog in the same second with the same PID.\n\n5) The claim's own evidence undercuts its conclusion. It notes the relay has no retention or pruning — which is precisely why the relay side DOES satisfy \"never delete\" for collected matchlogs. The single overwrite path is name-locked to `matchlog_*.txt`, so it can only ever clobber a same-second/same-PID duplicate.\n\nThe comparison to `rotate_log` (btoperator.py:40-55) is apples-to-oranges: that guards operator-side logs that are truncated on every Start Session, a genuine repeat event. The matchlog collector's write is a one-shot per unique upload.",
 "correction": "The accurate, much smaller version: btconsole.py:1403 is a truncating write with no existence check, so it is a latent robustness gap in the matchlog collector — it would silently clobber on any true filename collision, and it prints \"matchlog saved\" identically whether it created or overwrote. Today that is unreachable in practice (name carries date+HHMMSS+PID from matchlog.cpp:81-84), and it is entirely unreachable for the proposal's files because btconsole.py:1396 rejects any upload not named `matchlog_*.txt`. This would only become a real conflict if a FUTURE change added relay upload of crashes.log AND relaxed that name filter — neither of which the proposal does. If someone later wants crash logs on the relay, the right fix at that time is an append-mode or generation-suffixed write plus a widened, still-explicit name allowlist. It is not a blocker for, or an argument against, the per-day log or the local crashes.log tee."
}
{
 "title": "Default-truncate open mode must be inverted for a self-named per-day log — real, but the shipped bats already set BT_LOG_APPEND=1",
 "real": true,
 "reasoning": "The core mechanism is verified in code, on the live path, with no guard anywhere. C:\\git\\bt411\\game\\btl4main.cpp:572-574 opens the log with `(getenv(\"BT_LOG_APPEND\") != NULL && *getenv(\"BT_LOG_APPEND\") == '1') ? (std::ios::out | std::ios::app) : std::ios::out` — truncate is the default, append is opt-in. A grep for `logfile|std::ofstream|rdbuf` across btl4main.cpp returns only lines 568, 572, 575: this is the ONLY log open in the process, executed unconditionally in WinMain right after BTEnsureContentDirectory(). Nothing re-opens or re-modes it. C:\\git\\bt411\\tools\\btoperator.py:1403-1405 is quoted accurately: the exported join.bat emits `set BT_RELAY=...` + `set BT_LOG=join.log` + BT_START_INSIDE + BT_DEV_GAUGES, with no BT_LOG_APPEND and no rotation; same gap at 1421-1422 (join_lan.bat) and 1433 (play_solo.bat). So a self-named per-day file created under the current ternary WOULD be truncated on every launch for every operator-exported-bat user, and the change genuinely must set the append flag for the self-named file in the same edit. Not a matchlog confusion: game/reconstructed/matchlog.cpp:81-85 writes `matchlog_%04u%02u%02u_%02u%02u%02u_%lu.txt` via fopen(...,\"w\"), already unique per process and unrelated to BT_LOG. The crash-tee half is also coherent: BTCrashFilter (btl4main.cpp:193-235) writes only through std::cout, whose rdbuf is redirected to the BT_LOG file at line 575, so crash stacks live in the truncatable file. However, two of the claim's supporting assertions are false and one is understated — see correction. Most importantly, the shipped players\\*.bat do NOT rely on rotation to survive truncation; they all set BT_LOG_APPEND=1 (join.bat:19, join_lan.bat:22, play_solo.bat:25, play_steam.bat:17, joyconfig.bat:25), so on the shipped path the exe never truncates at all. Marking real=true because the verified default-truncate open mode plus the verified exported-bat gap make the claim's operative conclusion correct, even though its stated rationale is partly wrong.",
 "correction": "Accurate version: the exe's default open mode IS truncate (btl4main.cpp:572-574, the only log open, no guard elsewhere), so per-day self-naming must set append for the self-named file in the same change. But the reasoning must be restated on three points.\n\n(1) The shipped bats do not depend on rotation to survive truncation. All five set BT_LOG_APPEND=1 — players\\join.bat:18-19, join_lan.bat:21-22, play_solo.bat:24-25, play_steam.bat:16-17, joyconfig.bat:24-25. Two distinct mechanisms are at work: the `del *.old.log` / `ren *.log *.old.log` pair separates RUNS (giving the 2-generation window), while BT_LOG_APPEND=1 preserves the front-end -> mission relaunch CHAIN within a single run. Truncation is not \"survivable\" on the shipped path; it does not occur.\n\n(2) Therefore per-day naming is only \"strictly worse\" for shipped-bat users if the implementer, having made BT_LOG vestigial, also deletes the adjacent `set BT_LOG_APPEND=1` line. That is a real and likely hazard (the two `set` lines are adjacent in all five bats and read as a pair) but it is an implementation caution, not an automatic regression. If BT_LOG_APPEND=1 survives in the bats, per-day naming works with the ternary untouched.\n\n(3) The exported-bat exposure is real and WORSE than described, but not \"worse than today\" — it is already broken today and per-day naming without an append change would leave it exactly as broken. btoperator.py:1403-1405/1421-1422/1433 set BT_LOG with neither append nor rotation, so those users already truncate per launch AND mid-session: btl4console.cpp:190-196 clears only BT_FE_EGG/PODS/SECS/LOOP before CreateProcess, the child inherits the environment, re-enters WinMain, and reopens join.log with std::ios::out — wiping the menu generation's lines, crash block included, with no .old.log to fall back on. This is a present-day evidence-loss bug independent of the proposal, and it is the strongest argument FOR making append unconditional rather than an argument against per-day naming. Note btoperator.py:1302-1305 shows the operator's own in-GUI launch does rotate and does set BT_LOG_APPEND=1, so only the exported bats are exposed.\n\nRecommended resolution: make the self-named per-day file open with std::ios::app unconditionally (BT_LOG_APPEND then only governs the legacy BT_LOG path, or is retired), and fix the operator exporter in the same change. Separately and unrelated to this conflict: exported play_solo.bat (btoperator.py:1433) sets BT_LOG=join.log, so solo runs clobber the multiplayer log — the exact bug that shipped players\\play_solo.bat:19-21 documents having fixed."
}
{
 "title": "Hard-coded log filename in the four shipped bats' #41 launch forensics turns into a permanent false \"exe was blocked / antivirus\" report once the exe names the log per-day",
 "real": true,
 "reasoning": "I tried to refute this four ways and could not.\n\n1. Cited lines are verbatim accurate. players\\play_solo.bat:48-49, players\\join.bat:46-47, players\\join_lan.bat:49-50, players\\joyconfig.bat:35-36 each contain the exact pair: `if not exist <name>.log echo <name>.log WAS NEVER CREATED: the exe was blocked before it could run (loader failure or antivirus) >> launch_report.txt` followed by `if exist <name>.log for %%A in (<name>.log) do echo <name>.log size: %%~zA bytes >> launch_report.txt`. The probed name is the literal BT_LOG value set 20-25 lines earlier in the same file.\n\n2. It is on the live path, not dead code. All four bats `cd content`, run `..\\build\\Release\\btl4.exe`, then execute both lines unconditionally — no guard, no label, no branch around them. C:\\git\\bt411\\content\\launch_report.txt on disk right now proves it runs and currently reports correctly: \"[Mon 07/27/2026 22:39:27.86] launching btl4.exe / btl4.exe exited with code 0 / solo.log size: 163 bytes\". Under the change that third line becomes the antivirus scream.\n\n3. No guard elsewhere prevents it, and the exe cannot save it. game\\btl4main.cpp:568-575 has exactly ONE log open in the whole binary: `logfile.open(getenv(\"BT_LOG\") ? getenv(\"BT_LOG\") : \"btl4.log\", ...)`. It uses the env value verbatim; there is no second stream, no alias, no literal-name touch file. `grep -rn \"strftime|localtime|%Y%m%d\" game/ engine/` returns nothing, confirming no dated-name logic exists yet — so the change genuinely introduces a name the bats do not know. If BT_LOG stays \"solo.log\" and the exe derives solo_20260727.log from it, the literal solo.log is never created and `if not exist` is TRUE on every healthy run.\n\n4. It is not matchlog confusion. content\\ does hold matchlog_20260727_*.txt from the separate game\\reconstructed\\matchlog.cpp subsystem, but the bats probe solo.log/join.log/joyconfig.log — the BT_LOG game log, the exact file this change renames. Different files, and the claim cites the right one.\n\n5. It reaches real playtesters. tools\\mkdist.py:79-82 ships precisely `players/play_solo.bat, players/join.bat, players/join_lan.bat, players/joyconfig.bat` (plus play_steam.bat when Steam is on). join.bat:84 and join_lan.bat:87 explicitly tell the player to send `content\\launch_report.txt` to the operator, so the false report is mailed to the exact operator this change is meant to help.",
 "correction": "Real, with three refinements to the headline and one added fix surface.\n\n(a) \"Every bat\" overstates it — it is four of the five shipped bats. players\\play_steam.bat has NO #41 launch-forensics block at all (it goes straight from the PID snapshot at line 37-39 to `..\\build\\Release\\btl4.exe` at line 40 to the wait loop; it never writes launch_report.txt). The claim's own mechanism and evidence correctly say four, so only the title is loose.\n\n(b) There is a fifth copy to fix: C:\\git\\bt411\\play_solo.bat at the repo root is byte-identical to players\\play_solo.bat (verified with diff) and carries the same probe at lines 48-49. mkdist ships players/, but the root copy is what a dev double-clicks.\n\n(c) Nuance on in-place upgrades, which changes the symptom without fixing it: today all four bats rotate before launch (`if exist content\\solo.log ren content\\solo.log solo.old.log`). If the change removes those rotation lines (implied by \"appended to all day instead of rotated per launch\") while a stale pre-change solo.log is still sitting in content\\ — one is there right now at 163 bytes — the probe instead prints a frozen \"solo.log size: 163 bytes\" that never changes again, silently misreporting a healthy session as a 163-byte stub. On a fresh extract, or if the rotation lines are kept (the first run's `ren` moves the stale file away), the claimed \"WAS NEVER CREATED ... loader failure or antivirus\" line fires on every healthy launch exactly as described. Both outcomes are wrong; the claim describes the fresh-install case.\n\n(d) Additional fix surface the claim misses: the human-facing sign-off text names the same dead filename. play_solo.bat:85 \"send the operator content\\solo.log\", join.bat:84 and join_lan.bat:87 \"content\\join.log and content\\launch_report.txt\", play_steam.bat:76 \"content\\steam.log\". After the change these instruct players to send a file that does not exist — so play_steam.bat needs a text fix even though it has no forensics block.\n\nNot affected: tools\\btoperator.py's exported bats (lines 1394-1436) use an older template with no forensics probe, so the operator-exported scripts do not carry this bug (they do hard-code `set BT_LOG=join.log`, including in the exported play_solo.bat at line 1433, which is a pre-existing separate wart)."
}
{
 "title": "crashes.log unreachable by operator (route -9 rejects it, no pull)",
 "real": false,
 "reasoning": "Every atomic code fact in the claim is true, but they are assembled into a conflict that does not exist, because the load-bearing premise (\"the only retrieval channel\") is refuted by the exact line cited as evidence.\n\nVERIFIED TRUE (all confirmed by reading):\n- C:\\git\\bt411\\tools\\btconsole.py:1386 — `if conn.host_id is None and route == ROUTE_MATCHLOG` gates -9 to unregistered conns; ROUTE_MATCHLOG = -9 at :284.\n- :1396-1398 — hard allowlist, exactly as quoted, and `_drop_game` is a real connection kill.\n- :1619-1620 — `else: print(f\"[relay] {conn.name()} bad route {route} -- ignored\")`.\n- Control verbs :1935-1973 are launch/stop/rearm/newround/restart/ping/get/set; `get` is `_ctl_get_mission` (mission params from the EGG), not a file fetch. There is genuinely NO pull anywhere in btconsole.py or btoperator.py.\n\nWHY THE CONFLICT IS NOT REAL:\n\n1. The cited evidence line is truncated, and the untruncated version IS the retrieval channel. docs\\OPERATOR_GUIDE.md reads: \"Their crash log, `content\\join.log`, is **never** uploaded — ask for it explicitly when something went wrong on their end.\" The claim quotes up to \"never uploaded\" and drops the clause that names the channel. The same section says the same for matchlogs on solo/Steam: \"ask them for `content\\matchlog_*.txt` directly.\"\n\n2. The manual send channel is a first-class designed feature, not a fallback I inferred. C:\\git\\bt411\\tools\\btoperator.py:1386-1393 builds `net_tail`, embedded into every exported join.bat / join_lan.bat, printing at exit: \"send the operator the file content\\join.log so they can see what happened\"; :1435-1436 does the same for play_solo.bat. The shipped C:\\git\\bt411\\players\\join.bat carries the same sign-off (\"send the operator BOTH files: content\\join.log and content\\launch_report.txt\"). C:\\git\\bt411\\engine\\MUNGA_L4\\L4NET.CPP:2462 even comments \"matchlog kept for manual send\". C:\\git\\bt411\\scratchpad\\crash_20260726_2316_notes.md operationalizes it: \"EVIDENCE TO COLLECT from Conn Man: content\\join.log (has the [crash] stack)\" and \"STILL OWED: Conn Man's join.log (the laser-crash stack)\". The operator retrieves game logs by asking, and always has.\n\n3. The claim conflates two different files, which is the failure mode the task warned about. Route -9 carries matchlog_*.txt (match forensics, game/reconstructed/matchlog.cpp). The BT_LOG game log (join.log/steam.log/solo.log) has never traveled route -9 and the proposal never suggests it should. Proposal (a)+(b) is entirely EXE-side file naming and file lifetime under content\\; it contains no transport, no relay change, no route. \"Any attempt to reuse the route for crashes.log\" is the verifier's invention, not the proposal's text.\n\n4. The defect being fixed is destruction, not transport. The stated CONTEXT is that the tester crashed, kept playing, and the .bat's 2-generation window (`del *.old.log` then `ren *.log *.old.log` — confirmed at players\\join.bat, whose own comment says \"a crash report died 2026-07-26 because re-running this bat deleted the only evidence\") deleted the crash generation before the operator could ask. (a) removes rotation entirely; (b) adds a never-rotated append-only file. Both fix exactly the observed loss. Asking still works — it only failed before because the file was already gone.\n\n5. The claim's own framing of BTCrashFilter is misleading. btl4main.cpp:575 executes `std::cout.rdbuf(logfile.rdbuf())`, so std::cout IS the BT_LOG file; the function header at :188 says it logs \"into BT_LOG before the process dies\". The [crash] block already lands on disk today. (b) is a second, unrotatable copy of bytes that already reach the operator whenever the file survives long enough to be requested — which is precisely what (a) guarantees.\n\nDefault is real=false and nothing in the code overcomes it. The mechanism is a true description of route -9 attached to a goal the proposal never states, against a premise the cited doc line contradicts.",
 "correction": "The accurate, non-conflicting version: the relay has no automated ingest for crashes.log and no pull mechanism — btconsole.py's control verbs (:1935-1973) are launch/stop/rearm/newround/restart/ping/get/set, where `get` returns mission parameters, not files, and route -9 (:1386) is gated to unregistered connections and name-filtered to matchlog_*.txt (:1396-1398). So IF anyone ever wanted crashes.log auto-uploaded, that would require new client and relay code; (b) alone would not deliver it.\n\nBut that is not a conflict with the proposal, because the proposal never asks for auto-upload. crashes.log rides the same channel join.log has always ridden: the player sends it. That channel is generated by the operator tool itself (btoperator.py:1386-1393 stamps \"send the operator the file content\\join.log\" into every exported .bat) and documented in OPERATOR_GUIDE.md §9, whose cited sentence actually ends \"— ask for it explicitly when something went wrong on their end.\"\n\nTwo genuine, smaller notes worth carrying forward:\n1. A future implementer must NOT try to reuse route -9 for crashes.log. It would not be a harmless no-op: it drops the pod's TCP connection and prints `suspicious matchlog name 'crashes.log'` into the operator log. Same for a per-day steam_20260727.log. This is a real hazard, just not one the proposal walks into.\n2. The player-facing sign-off text in both players\\join.bat and btoperator.py's `net_tail` names content\\join.log by hardcoded literal. If (a) renames the game log to steam_20260727.log and (b) adds crashes.log, that sign-off text and OPERATOR_GUIDE §9 go stale and would tell testers to send a file that no longer exists. That is the actual follow-up work item (a doc/string update in tools\\btoperator.py and players\\*.bat), not a blocking conflict."
}
{
 "title": "Player-facing \"send content\\<name>.log\" strings in 5 bats, README.txt and OPERATOR_GUIDE go stale under a date-stamped log",
 "real": true,
 "reasoning": "Tried to refute; every cited line is exactly as claimed and the shipping path is real.\n\nVERIFIED SIGN-OFF STRINGS (all present, all naming the BT_LOG game log):\n- players\\play_solo.bat:85 \"echo send the operator content\\solo.log.\"\n- players\\join.bat:84 \"echo   content\\join.log  and  content\\launch_report.txt\"\n- players\\join_lan.bat:87 same string\n- players\\play_steam.bat:76 \"echo content\\steam.log.  Steam not running...\"\n- play_solo.bat:85 (repo root) - byte-identical duplicate of players\\play_solo.bat (diff reports no differences). This is the fifth bat; the count of five is correct.\n\nVERIFIED DOCS: players\\README.txt:32 \"If the game closes unexpectedly, send the operator content\\join.log.\" and :41 \"IF ANYTHING CRASHED OR MISBEHAVED, send content\\join.log\". docs\\OPERATOR_GUIDE.md:244 \"Their crash log, `content\\join.log`, is **never** uploaded - ask for it explicitly\".\n\nVERIFIED SHIP PATH: tools\\mkdist.py:108-122 reads players/README.txt, substitutes only {VERSION}/{STEAM}/{EXPIRE}, and z.writestr(name + \"/README.txt\", readme). No log-filename rewriting exists anywhere in mkdist. mkdist.py:79-82 lists players/*.bat and :152-155 writes them into the zip root verbatim via z.write. So a stale filename string ships in BOTH the README and the bats.\n\nNOT A MATCHLOG MIX-UP: OPERATOR_GUIDE.md:240-245 and README.txt:34-41 explicitly separate content\\matchlog_*.txt (auto-uploaded on clean round end) from the BT_LOG game log. The claim targets the correct file.\n\nSTRINGS ARE LOAD-BEARING, NOT COSMETIC: game\\btl4main.cpp:572 opens getenv(\"BT_LOG\") and :574 does std::cout.rdbuf(logfile.rdbuf()); BTCrashFilter (btl4main.cpp:194-215) writes the [crash] block to std::cout. So join.log/solo.log/steam.log IS the file carrying the crash stack today, and the sign-off text is its only delivery mechanism. Renaming it without updating these strings is exactly the failure the proposal exists to prevent.\n\nNO GUARD ELSEWHERE: nothing writes a stable-named copy, symlink, or alias of the log; the name comes solely from BT_LOG.",
 "correction": "The claim is right but under-counts, and its headline filename is imprecise. Two corrections:\n\n1) PHRASING: the five bats do not all say \"join.log\". play_solo.bat (both copies) says content\\solo.log; join.bat/join_lan.bat say content\\join.log; play_steam.bat says content\\steam.log. Five bats each name their own BT_LOG file. Also note the fifth bat is play_solo.bat at the REPO ROOT, a byte-identical duplicate of players\\play_solo.bat that mkdist does NOT ship (mkdist ships players/*.bat) - so four of the five ship, and the root copy is dev-only drift that still needs the same edit.\n\n2) THE CLAIM UNDERSTATES SEVERITY - this is functional breakage, not only doc drift. Every bat also has a post-exit existence check keyed to the literal filename:\n   players\\join.bat:46 - \"if not exist join.log echo join.log WAS NEVER CREATED: the exe was blocked before it could run (loader failure or antivirus) >> launch_report.txt\"\n   :47 - \"if exist join.log for %%A in (join.log) do echo join.log size: %%~zA bytes >> launch_report.txt\"\n   Same pair at join_lan.bat:49-50, players\\play_solo.bat:48-49, play_solo.bat (root):48-49, joyconfig.bat:35-36. Once the EXE date-stamps the name, these emit a FALSE \"WAS NEVER CREATED / antivirus blocked\" verdict into launch_report.txt on EVERY launch - actively misleading the #41 launch forensics, which is worse than a stale doc string.\n\n3) TWO SURFACES THE CLAIM MISSES ENTIRELY:\n   - tools\\btoperator.py generates player bats at runtime with the same strings: :1386-1392 (net_tail) emits \"echo file  content\\join.log\", :1403 / :1421 / :1433 emit \"set BT_LOG=join.log\", and :1436 emits \"echo send the operator content\\join.log.\" The operator can export a fresh join.bat/join_lan.bat/play_solo.bat set from the GUI at any time, regenerating stale text even after the checked-in bats are fixed. (Separately, btoperator.py:1433 sets BT_LOG=join.log for its generated SOLO bat, contradicting the deliberate solo.log/join.log separation documented at players\\play_solo.bat:19-21 - a pre-existing bug this rename would surface.)\n   - context\\operator-console.md:384 (table row \"content/join.log | the pod's own log; never uploaded\") and docs\\HANDOFF.md:37 and :52 (#35 Owens laser crash is blocked on a tester's join.log) also go stale.\n\n4) ADDITIONAL EDIT IMPLIED BY PART (b): once content\\crashes.log exists as the never-rotated crash tee, the correct player-facing instruction becomes \"send content\\crashes.log\" (plus the day log), not just a renamed game log. All the sign-off strings, README.txt:32/41, and OPERATOR_GUIDE.md:244 need to point at crashes.log, not merely at a date-stamped filename."
}
{
 "title": "Exe-side renaming of the BT_LOG path breaks callers that read the file they named",
 "real": true,
 "reasoning": "Verified line by line. btl4main.cpp:572-575 opens exactly getenv(\"BT_LOG\") with no transformation and no guard, on the unconditional WinMain path; the comment at 565-567 says per-instance naming is REQUIRED for multi-instance runs. Callers do read that exact path back: scratchpad/mp_respawn6.sh:20-21, mp_respawn4.sh:19 and mp_tloc_test.sh:20,27-28 set BT_LOG=mp_b.log then run `until grep -q ... mp_b.log` poll loops (they would spin to their 150-200s timeout and report nothing); scratchpad/gtrace_plot.py:9 hardcodes open(\"../content/gtrace_a.log\") which mp_gtrace.sh:4 named via BT_LOG. Strongest case, uncited by the claimant: players/join.bat:46-47 (and join_lan.bat:49-50, play_solo.bat:48-49, root play_solo.bat) post-check the literal filename -- `if not exist join.log echo join.log WAS NEVER CREATED: the exe was blocked before it could run (loader failure or antivirus)` -- so a date-stamped name makes every successful run write a false antivirus/loader verdict into launch_report.txt, and :84 instructs the player to send a file that does not exist. The [crash] block (btl4main.cpp:200,209) goes to std::cout which is rdbuf'd to the BT_LOG file, so the very crash stack this change exists to preserve would land in join_YYYYMMDD.log while OPERATOR_GUIDE.md:244, join.bat:84 and btoperator.py:1436 all name join.log. btoperator.py:1302-1305 confirmed verbatim, and :1403/:1421/:1433 emit `set BT_LOG=join.log` into exported player bats. Documented contract confirmed at decomp-reference.md:417, multiplayer.md:126, README.md:99 and 127-128. Not a matchlog confusion: matchlogs are a separate already-date-stamped family (multiplayer.md:80, operator-console.md:383) and are unaffected. No existing guard prevents any of this.",
 "correction": "Two citation inaccuracies, both of which understate rather than support-by-overstatement. (1) tools/mp_launch.sh:25,29 does NOT grep fa.log/fb.log -- it only `rm -f`s them and echoes their paths at :37. The harnesses that actually grep the BT_LOG-named file are scratchpad/mp_respawn6.sh:20-21, mp_respawn4.sh:19, mp_tloc_test.sh:20,27-28 (BT_LOG=mp_b.log + `until grep -q ... mp_b.log`) and scratchpad/gtrace_plot.py:9. (2) The operator GUI never READS operator_N.log -- no reader exists in btoperator.py or anywhere else -- so its breakage is narrower than \"breaks\": rotate_log(operator_N.log) at :1303 would silently no-op on a name the exe no longer writes (rotate_log guards on os.path.exists), losing the one-generation preservation added after the 2026-07-26 loss, plus doc drift in OPERATOR_GUIDE.md and context/gauges-hud.md:117. The much larger breakage the claim missed is players/join.bat:46-47 and siblings, whose existence check would print \"join.log WAS NEVER CREATED: the exe was blocked before it could run (loader failure or antivirus)\" into launch_report.txt on every healthy run. Also note the conflict is contingent on the implementation reading of proposal (a): if the exe only auto-names when BT_LOG is UNSET and an explicitly-set BT_LOG still wins verbatim, every caller above keeps working and only the .bat files need to compose the date themselves. That \"BT_LOG wins when set\" rule is the resolution the design must state explicitly."
}
{
 "title": "Per-day naming silently breaks the #41 launch forensics and guts the 0-byte \"never started\" heuristic",
 "real": true,
 "reasoning": "I tried to refute this and could not. Every load-bearing element checks out in code.\n\n1. CITED LINES EXIST VERBATIM. C:\\git\\bt411\\players\\join.bat:46-47, join_lan.bat:49-50, play_solo.bat:48-49, joyconfig.bat:35-36 each contain, unconditionally after the exe returns:\n   `if not exist join.log echo join.log WAS NEVER CREATED: the exe was blocked before it could run (loader failure or antivirus) >> launch_report.txt`\n   `if exist join.log for %%A in (join.log) do echo join.log size: %%~zA bytes >> launch_report.txt`\n   (with solo.log / joyconfig.log substituted respectively). No guard, no conditional block, no dead-code path — they sit between the exit-code echo and the handoff wait.\n\n2. THE FILENAME COMES ONLY FROM BT_LOG. C:\\git\\bt411\\game\\btl4main.cpp:572 is the sole opener: `logfile.open(getenv(\"BT_LOG\") ? getenv(\"BT_LOG\") : \"btl4.log\", ...)`. A repo-wide grep for other .log creators finds only marshal.log (btl4console.cpp:29), trace.log (APP.cpp:809) and framerate.log (L4VIDEO.cpp:3387) — nothing else ever creates join.log/solo.log/joyconfig.log. So if the exe names the file itself with a date suffix, nothing on the machine creates the literal name the bats probe, and `if not exist join.log` is true on EVERY run. The false AV/loader accusation is unavoidable.\n\n3. THE FIRST-BREATH / 0-BYTE REASONING IS REAL AND CITED CORRECTLY. btl4main.cpp:582-591 carries the comment \"a player's exe died repeatedly with a 0-byte join.log -- indistinguishable from 'never ran'. After this line, an empty log can only mean the exe was killed before WinMain (antivirus / loader)\" followed by the flushed `[boot] btl4 <ver> pid=...` line. All four bats restate the rule in their rotation comments (\"0-byte logs = killed pre-start\").\n\n4. IT IS ON THE SHIPPED PATH. tools/mkdist.py:79-82 zips players/play_solo.bat, join.bat, join_lan.bat, joyconfig.bat byte-for-byte (only README.txt gets templated). Live proof on disk: C:\\git\\bt411\\content\\launch_report.txt currently reads \"[Mon 07/27/2026 22:39:27.86] launching btl4.exe / btl4.exe exited with code 0 / solo.log size: 163 bytes\" — the size probe firing for real. Under the proposed change that third line becomes \"solo.log WAS NEVER CREATED: ... antivirus\".\n\n5. NOT A MATCHLOG CONFUSION. Matchlogs are a separate system (context/multiplayer.md:77-106): `matchlog_<date>_<time>_<pid>.txt`, already per-run/pid named, auto-uploaded to the relay as matchlogs/<peer>_<name>. The bats never touch them; OPERATOR_GUIDE.md:244 explicitly contrasts them with join.log. The claim is correctly about the BT_LOG game log.\n\n6. cwd ALIGNS TODAY. The bats `cd content` before launching, BTEnsureContentDirectory() runs at btl4main.cpp:540 (before the log open at 572) and is a no-op when BTL4.RES is already in cwd, so the log lands in the same directory the bat probes. The probe is genuinely working today, which is exactly why breaking the name breaks it.",
 "correction": "The conflict is real but three details in the claim need fixing:\n\n(a) THE SIZE PROBE IS DELETED, NOT INVERTED. With the exe naming the file per-day and the bat rotation removed, `if exist join.log` is FALSE, so line 47/50/49/36 prints nothing at all — launch_report.txt loses the size line entirely rather than reporting a misleading one. The inversion the claim describes (\"second launch of the same day is never 0 bytes\") only materializes in the follow-up world where someone updates the bats to probe the dated name but keeps append-all-day semantics. There is also a nastier transitional case the claim misses: a player upgrading over an existing install still has a stale content\\join.log on disk, so `if exist` fires and launch_report.txt reports a frozen byte count of a file nothing writes any more — a silently wrong number, worse than a missing line.\n\n(b) THE 0-BYTE RULE IS ALREADY ONLY VALID FOR THE FIRST GENERATION OF A RUN. BT_LOG_APPEND=1 plus the front end's CreateProcess relaunch (btl4console.cpp) already means generation 2+ appends to a non-empty file. Confirmed live: content\\solo.log contains two boot lines, \"[boot] btl4 4.11.603 pid=10196 args='' (first process)\" and \"[boot] btl4 4.11.603 pid=14492 args='-net 1501 -platform glass' (relaunched generation)\". Per-day naming WIDENS that hole from \"within one bat run\" to \"for the rest of the day\"; it does not create it. The bats' own comments acknowledge the existing partial state.\n\n(c) SCOPE IS FIVE FILES, NOT FOUR — AND STEAM IS UNAFFECTED. The claim missed the root duplicate C:\\git\\bt411\\play_solo.bat:48-49, which carries the identical probes. Conversely, C:\\git\\bt411\\players\\play_steam.bat — the proposal's own headline example (steam.log) — has NO #41 block at all: no launch_report.txt, no exit-code echo, no probes. The steam path is untouched by this conflict.\n\nFRAMING: this is a required-companion-edit coupling, not an unresolvable design conflict. Per-day naming is fine provided the same change teaches the bats the name. The cheapest fix that preserves both diagnostics: have the .bat compute the day stamp and pass it in (set BT_LOG=join_%YYYYMMDD%.log) so the exe keeps honoring BT_LOG and the bat still knows the literal name — then replace the \"0-byte\" rule with a first-breath-line grep, since size alone can no longer distinguish \"blocked pre-WinMain\" from \"appended to yesterday's... today's earlier session\". Do not leave the probes pointing at the undated name."
}
{
 "title": "EXE cannot know the steam/join/solo stem except via BT_LOG",
 "real": false,
 "reasoning": "REFUTED at the premise. The claim's headline — \"the EXE has no way to know the steam/join/solo stem; that fact exists only in BT_LOG\" — is false, and the claim's own mechanism text concedes it (\"differ only in BT_LOG and side env (BT_FE_SOLO, BT_RELAY, BT_STEAM_NET)\"). Those side-env vars are not inert: the EXE already reads all of them.\n\n1) Mode IS visible to the EXE, via getenv, today:\n- C:\\git\\bt411\\game\\btl4main.cpp:1000 reads BT_STEAM_NET (set by players\\play_steam.bat:11).\n- C:\\git\\bt411\\game\\glass\\btl4fe.cpp:604 reads BT_FE_JOIN (players\\join.bat:25, join_lan.bat:28, tools\\btoperator.py:1292).\n- C:\\git\\bt411\\game\\glass\\btl4fe.cpp:585 reads BT_FE_SOLO (players\\play_solo.bat:26, joyconfig.bat:26).\n- C:\\git\\bt411\\game\\btl4main.cpp:618 reads BT_JOYCONFIG (players\\joyconfig.bat:13) — this is what separates the wizard from plain solo, since joyconfig sets BT_FE_SOLO=1 too.\n- BT_RELAY is read in engine\\MUNGA\\APP.cpp:683, engine\\MUNGA_L4\\L4APP.cpp:615, game\\reconstructed\\matchlog.cpp:98 (players\\join.bat:11).\nAll are plain process env set by the .bat before CreateProcess, so they are readable at btl4main.cpp:568 — before the ofstream opens. Nothing orders them after the log open (BTLoadEnvironIni at :605 only supplies defaults, and real env wins per the comment at :239-240).\n\n2) They survive the relaunch chain. game\\glass\\btl4console.cpp:238-239 calls CreateProcessW with lpEnvironment = NULL, so the child inherits the whole environment; :190-196 clears only BT_FE_EGG/PODS/SECS/LOOP. btl4main.cpp:967/973 even re-asserts BT_STEAM_NET=1 into the child. So generation 2..N knows the mode exactly as generation 1 did.\n\n3) Precedent that the EXE already self-names a dated forensic file: game\\reconstructed\\matchlog.cpp:81 does sprintf(\"matchlog_%04u%02u%02u_%02u%02u%02u_%lu.txt\") and fopen()s it — the exact pattern the proposal asks for, in this codebase, under its own env var (BT_MATCHLOG, :65). And matchlog is a DIFFERENT file from the BT_LOG game log, so no confusion there.\n\nTherefore the claimed dilemma (a-or-b) is a false dichotomy. Option (c) exists and is the obvious implementation: derive the stem from the mode env the EXE already reads (or add a new BT_LOG_STEM), and keep BT_LOG's meaning as \"the exact filename, highest precedence\" so every existing caller is untouched.\n\n4) The branch-(b) hazard argument is also wrong on mechanism. play_solo.bat:19-21 and joyconfig.bat:20-21 are comments; the actual guard is the rotation at play_solo.bat:22-23 and joyconfig.bat:22-23 (`del *.old.log` / `ren *.log *.old.log`). The evidence-destroying act was the DELETE, executed by the solo/wizard bat against the MP log. The proposal removes rotation entirely (append-all-day, never deleted) and adds a never-rotated crashes.log, so even a single merged per-day file could not destroy anything — it would only interleave. The hazard is deleted by construction, not re-created.\n\n5) BT_LOG is not read anywhere else in C++: grep over *.cpp/*.h yields only game\\btl4main.cpp:572-573 (plus comments at :188, :565-570). No second consumer to break.\n\nThe only true kernel of the claim is the inventory of BT_LOG-as-exact-filename callers (see correction), which is a constraint on the implementation, not a conflict with the proposal.",
 "correction": "The accurate, non-blocking version:\n\nTRUE PART — BT_LOG's contract really is \"the full filename\", and it is relied on verbatim by: tools\\btoperator.py:1302-1305 (log_name = \"operator_%d.log\" % (r+1), rotate_log(), env.insert(\"BT_LOG\", log_name)) where the distinct filename is what keeps N local instances from sharing one file — exactly the reason btl4main.cpp:566-567 says BT_LOG is \"REQUIRED for multi-instance runs from one cwd\"; tools\\btoperator.py:1403/1421/1433 (generated join.bat / join_lan.bat / play_solo.bat); tools\\mp_launch.sh:27,29 (fa.log/fb.log, plus `rm -f fa.log fb.log` at :25); README.md:99 and README.md:127-128; context\\decomp-reference.md:417, context\\multiplayer.md:126, context\\build-and-run.md:41; and 62 files under scratchpad\\. So an implementation must NOT silently reinterpret BT_LOG as a stem.\n\nCORRECT DESIGN (no conflict): make the per-day name the DEFAULT when BT_LOG is unset, and keep \"BT_LOG set => use it verbatim, unchanged\" as the override. Pick the stem from mode env the EXE already reads, in this precedence: BT_JOYCONFIG -> \"joyconfig\", BT_STEAM_NET -> \"steam\", BT_FE_JOIN or BT_RELAY -> \"join\", BT_FE_SOLO -> \"solo\", else \"btl4\". Then drop `set BT_LOG=` from the four player bats and every caller above keeps its current behavior byte-for-byte.\n\nREAL IMPLEMENTATION TO-DOS the claim missed (bat-side, not EXE-side): the launcher forensics hard-code the filename and would go stale — join.bat:46-47, play_solo.bat:48-49, joyconfig.bat:35-36 (\"<name>.log WAS NEVER CREATED\" / size report) and the sign-off echoes at play_steam.bat:76, join.bat:84, play_solo.bat:85. These must be updated in the same change or they will falsely report \"never created\". Also the .bat rotation blocks (play_steam.bat:14-15, join.bat:16-17, play_solo.bat:22-23, joyconfig.bat:22-23) should be deleted, not left as no-ops.\n\nBONUS: the change FIXES a live bug the claim cites as evidence — tools\\btoperator.py:1433 exports a play_solo.bat containing `set BT_LOG=join.log`, i.e. the operator's generated solo bat still clobbers the MP log, the exact hazard the shipped players\\play_solo.bat:19-23 was fixed for. Mode-derived naming removes that class of mistake."
}
{
 "title": "Operator-exported bats truncate, but they are not what the affected testers run — and both halves of the change reach them anyway",
 "real": false,
 "reasoning": "The claim's code facts are right; its load-bearing premise and its conclusion are both refuted.\n\nVERIFIED TRUE (the narrow mechanism):\n- C:\\git\\bt411\\tools\\btoperator.py:1403, 1421, 1433 do set `BT_LOG=join.log` with no `BT_LOG_APPEND`, and the `guarded()` template (lines 1359-1385) emits no del/ren rotation.\n- C:\\git\\bt411\\game\\btl4main.cpp:572-574 opens the log with `std::ios::out` unless `BT_LOG_APPEND=1`, so those bats truncate. No guard rescues it: `BTLoadEnvironIni()` runs at line 605, AFTER the open at 572, so environ.ini cannot supply BT_LOG_APPEND; grep shows no other BT_LOG/BT_LOG_APPEND writer anywhere in game/ or engine/.\n\nREFUTED (premise): the testers who lost the stacks were NOT running generated bats.\n- C:\\git\\bt411\\tools\\mkdist.py:79-82 zips `players/play_solo.bat, players/join.bat, players/join_lan.bat, players/joyconfig.bat` (+play_steam.bat when steam is ON) into the tester distribution, and mkdist.py:109-122 ships players/README.txt as the player-facing README. That zip is the tester delivery path.\n- The documented loss is recorded against the SHIPPED bat. Commit 35bdb50 (\"Conn Man's owens-laser crash stack (#35) was in his join.log -- and re-running join.bat deleted it\") removed the line `if exist content\\join.log del content\\join.log` from players/join.bat — a line that does not exist in the generator's output at all. The pre-fix shipped bat already had `set BT_LOG_APPEND=1`, so its loss mechanism was the bat's own `del`, not the exe's truncate. Commit 39cb864 likewise reconstructs the loss through shipped play_solo.bat/joyconfig.bat deleting join.log. docs\\OPERATOR_GUIDE.md:49 points at `players/join.bat` as the player artifact.\n\nREFUTED (the dilemma): it is false. The proposal's own example, `content\\steam_20260727.log`, is the BT_LOG stem plus the date — steam.log comes only from players\\play_steam.bat:16. So per-day naming derives FROM BT_LOG: a generated bat with `BT_LOG=join.log` gets `join_20260728.log`, appended all day. That honours BT_LOG and delivers the per-day file simultaneously, and it strictly REMOVES the generated bats' truncation without touching btoperator.py.\n\nREFUTED (part b alone kills the headline): `content\\crashes.log` is a fixed name written by the exe, independent of BT_LOG, tee'd from the BTCrashFilter block at btl4main.cpp:200-233 (armed at line 536, before the log even opens). It reaches every launcher — generated bats, shipped bats, and a bare btl4.exe double-click. The artifact that was actually lost (the [crash] stack) is preserved for exactly the population the claim says is unreachable.\n\nREFUTED (the text objection is not specific): \"send the operator content\\join.log\" is not unique to btoperator.py:1389/1436. The same string lives in players\\join.bat:84, players\\play_steam.bat:76 (\"content\\steam.log\"), players\\play_solo.bat, and players\\README.txt:32 and :41. Any rename touches five text sites in this repo; the generator is one more line of prose, not a design conflict, and not a reason to block the change.\n\nNot a matchlog confusion — the claim correctly stays on the BT_LOG game log; matchlog_<date>_<time>_<n>.txt (players\\README.txt:34-40) is a different file and is not implicated either way.",
 "correction": "The accurate, smaller finding: tools\\btoperator.py's \"Export player scripts…\" generator (btoperator.py:1322-1440, live from the button at :484-488) is STALE and carries pre-existing bugs — but this is independent of the proposed change, not a conflict with it.\n\nIts export block was last touched 2026-07-21 (04d50c9). Since then the shipped players/*.bat gained: BT_FE_JOIN=1 (the callsign/mech menu, 07-22), issue #41 launch forensics writing launch_report.txt (41e899a), the PID-snapshot handoff wait (145bf36 / bf40c53 / 7afbda9), and rotation + BT_LOG_APPEND=1 (35bdb50, 39cb864). The generator emits none of them. Worst of all, btoperator.py:1433 makes the generated play_solo.bat set `BT_LOG=join.log` — precisely the \"a solo run destroys the MULTIPLAYER log\" bug that commit 39cb864 fixed in the shipped bats by giving solo its own solo.log.\n\nCombined with btl4main.cpp:572-574, a generated bat truncates join.log at every process — including the front-end -> mission handoff inside a single launch (the case btl4main.cpp:569-571 documents), so even one session's own FE lines are lost. That is worse than the 2-generation window, as the claim says.\n\nCorrect disposition: file this as its own defect (\"the operator exporter emits bats five fixes out of date\"), or delete the exporter in favour of mkdist.py. It does not gate the per-day/crashes.log change: crashes.log reaches those bats regardless of BT_LOG, and stem+date per-day naming fixes their truncation for free. If the exporter is kept, its echo text (btoperator.py:1389, 1436) should be updated in the same sweep as players\\join.bat:84, players\\play_steam.bat:76, players\\play_solo.bat and players\\README.txt:32/41 — a documentation sweep, not a blocker."
}
"**PARTIAL — but not where you're worried.** The automatic per-game retrieval you're describing is the **matchlog** upload, and the proposal does not touch it: different file, different naming, different transport, no shared code. **No conflict there.** The confirmed conflicts are all local, on the `.bat` / `BT_LOG` side, and each has a cheap fix that must land in the same commit.\n\n## The crux: they are different files\n\n| | matchlog (auto-retrieved) | BT_LOG game log (the proposal) |\n|---|---|---|\n| Name | `matchlog_YYYYMMDD_HHMMSS_<pid>.txt`, minted in-process by `GetLocalTime`+PID — `game/reconstructed/matchlog.cpp:81-85` | `getenv(\"BT_LOG\")`, set by the .bat — `game/btl4main.cpp:572-575` |\n| Written by | `matchlog.cpp` event calls, `fflush` per line (:141) | `std::cout.rdbuf(logfile.rdbuf())` — the whole DEBUG_STREAM, incl. the `[crash]` block (`btl4main.cpp:200-233`) |\n| Uploaded | Yes — route `-9`, `engine/MUNGA_L4/L4NET.CPP:702-800` | **Never.** `docs/OPERATOR_GUIDE.md:244` \"is **never** uploaded — ask for it explicitly\" |\n| Relay stores as | `matchlogs/<peer-ip>_<name>` — `tools/btconsole.py:1400-1406` | n/a |\n\nThe proposal is entirely EXE-side file naming and lifetime under `content\\`. It adds no route, sends nothing, and changes nothing the relay parses.\n\n## Mode matrix — automatic retrieval\n\nGate is `s_relayGameAddrCached`, set only inside the `BT_RELAY` parse block (`L4NET.CPP:707`, set at `:598-602` from `:572`, guarded by `:451`):\n\n| Mode | matchlog written? | auto-uploaded? | gate |\n|---|---|---|---|\n| Relay (`BT_RELAY` set, join.bat/join_lan.bat) | yes | **yes** | `BT_RELAY` present → address cached |\n| Classic mesh (`-net`, no `BT_RELAY`) | yes | no — silent return at `L4NET.CPP:707` | no relay address |\n| Steam (`BT_STEAM_NET`) | yes (`-net` is on the cmdline, `btl4main.cpp:970`) | **no** — nothing on the Steam path sets `BT_RELAY` | same |\n| Menu solo / HostLan | yes (they also get `-net`, `btl4main.cpp:978-984`) | no | same |\n| Raw solo (`BTFeLaunchRawSolo`, `:940-943`) | no (`matchlog.cpp:72-73` needs `-net`) | no | not armed |\n\nAlso: a **crashed** round never uploads at all — `BTCrashFilter` returns `EXCEPTION_EXECUTE_HANDLER` (`btl4main.cpp:234`) and control never reaches the upload at `btl4main.cpp:1310`. So for exactly the sessions this proposal exists to save, automatic retrieval is already off. That is the strongest argument *for* the change, not against it.\n\n## CONFIRMED conflicts (survived adversarial verification)\n\n1. **#41 launch forensics start printing a false \"antivirus blocked the exe\" verdict.** `players/join.bat:46-47`, `join_lan.bat:49-50`, `play_solo.bat:48-49`, `joyconfig.bat:35-36`, plus the root duplicate `C:\\git\\bt411\\play_solo.bat:48-49` probe the literal name. If the exe writes `join_20260728.log`, `if not exist join.log` is true every run and `launch_report.txt` — the file players are told to mail you (`join.bat:83-84`) — permanently accuses the loader/AV. `players/play_steam.bat` has no forensics block at all, so Steam is unaffected.\n2. **Player-facing \"send the operator content\\<name>.log\" goes stale in ~8 places:** `play_solo.bat:85`, `join.bat:84`, `join_lan.bat:87`, `play_steam.bat:75-76`, `players/README.txt:32,41` (shipped verbatim by `tools/mkdist.py:108-122`), `docs/OPERATOR_GUIDE.md:244`, plus the runtime-generated copies at `tools/btoperator.py:1389,1436`.\n3. **`BT_LOG` must keep meaning \"verbatim path\".** Callers set it and then read that exact file: `tools/btoperator.py:1302-1305` (`rotate_log(operator_N.log)` then `BT_LOG=operator_N.log` — rotation would silently no-op on a name nothing writes), and the dev harnesses that `grep -q` the named file (`scratchpad/mp_respawn6.sh:20-21`, `mp_respawn4.sh:19`, `mp_tloc_test.sh:20,27-28`, `scratchpad/gtrace_plot.py:9`). Contract documented at `btl4main.cpp:565-567`, `README.md:99,127-128`.\n4. **The open mode defaults to TRUNCATE.** `btl4main.cpp:572-574` uses `std::ios::out` unless `BT_LOG_APPEND=1`. A self-named per-day file created under that ternary is wiped on every relaunch. Shipped bats set the flag (`join.bat:19`, `play_steam.bat:17`, `play_solo.bat:25`), but the operator-exported bats (`btoperator.py:1403,1421,1433`) do not — those already truncate today.\n\n## REFUTED — do not chase these\n\n- **\"crashes.log can never reach the operator; route -9 rejects it.\"** Dead. The proposal never proposes uploading it. The retrieval channel for the game log has always been \"ask the player\", designed into the bat sign-offs and `OPERATOR_GUIDE.md:244` (the full sentence ends \"— ask for it explicitly\"). The change makes the file *still be there* when you ask.\n- **\"The relay would silently overwrite `<peer>_crashes.log`.\"** Dead. `btconsole.py:1396` rejects any name not `matchlog_*.txt`, and nothing sends crashes.log anyway.\n- **\"The EXE can't know the steam/join/solo stem.\"** Dead. It already reads `BT_STEAM_NET` (`btl4main.cpp:1000`), `BT_FE_JOIN`/`BT_FE_SOLO` (`game/glass/btl4fe.cpp:604,585`), `BT_JOYCONFIG` (`btl4main.cpp:618`), `BT_RELAY` — all before the log opens, all inherited across the relaunch (`btl4console.cpp:238`, `lpEnvironment=NULL`).\n- **\"The change doesn't reach the testers who lost the stacks.\"** Dead. `crashes.log` is a fixed name written by the exe, independent of `BT_LOG`, and reaches every launcher including a bare double-click. (The exporter at `btoperator.py:1322-1440` *is* five fixes stale — file it separately.)\n\n## Recommended design\n\n1. **Leave matchlog and route -9 completely alone.** Never send `crashes.log` or a day log over `-9`: `btconsole.py:1396-1398` would drop the pod's TCP connection and print `suspicious matchlog name`. And **do not** extend per-day naming to matchlogs — `matchlog_20260728.txt` would pass the allowlist, then be truncated by `open(out_path,\"wb\")` at `btconsole.py:1403` on every upload, and `tools/matchcheck.py:288-299` would merge rounds and misreport every non-final `PEER_DOWN` as a mid-mission disconnect.\n2. **Precedence rule, stated explicitly: `BT_LOG` set → used verbatim, unchanged.** Only when unset does the exe self-name `content\\<stem>_YYYYMMDD.log`, stem from `BT_JOYCONFIG`→`joyconfig`, `BT_STEAM_NET`→`steam`, `BT_FE_JOIN`/`BT_RELAY`→`join`, `BT_FE_SOLO`→`solo`, else `btl4`. This makes conflict 3 vanish outright: btoperator, mp harnesses, run.cmd keep working byte-for-byte. Then delete `set BT_LOG=` and the `del`/`ren` pair from the five player bats.\n3. **Open the self-named file `std::ios::out | std::ios::app` unconditionally** (conflict 4). Build the name with `GetLocalTime`, not `%DATE%` — `%DATE%` yields `Tue 07/28/2026` on en-US (slashes = path separators) and reorders per locale.\n4. **Stamp the resolved name into the environment before the relaunch** (`SetEnvironmentVariableA(\"BT_LOG\", resolved)` next to the existing stamps at `btl4console.cpp:190-196`). Generations inherit it, so a 23:50→00:05 session stays in one file instead of splitting — otherwise the change re-creates the fragmentation it's fixing.\n5. **Restore the launch forensics with a fixed-name breadcrumb** (conflict 1). Have the exe truncate-write `content\\lastrun.txt` at the first-breath point (`btl4main.cpp:587-591`) containing version, pid, local time, and the day-log name it chose. Bats `del lastrun.txt` before launch and probe `if not exist lastrun.txt` after. That is strictly better than today's 0-byte heuristic (which is already unreliable — `content\\solo.log` currently holds three `[boot]` banners in one file), and it gives you the launch→day-log-name mapping. Avoid `find`/`findstr` greps in the bats; they're shadowed by MSYS on PATH (`play_solo.bat:59-68`).\n6. **crashes.log mechanics.** Open a raw `CreateFileA(..., FILE_APPEND_DATA, FILE_SHARE_READ|FILE_SHARE_WRITE, ...)` immediately after `BTEnsureContentDirectory()` (`btl4main.cpp:540`) — before the log open at :572. In the filter, format into a stack buffer and emit **one `WriteFile` per block**: no `fopen`, no `std::ostream`, no allocation. The filter runs on heap corruption (that's what `BT_HEAPCHECK`, `:549-554`, exists for); CRT stdio there can deadlock or re-fault and cost you the report you already get today. Bonus: opening early closes the currently-blind window between the filter arm (`:536`) and the `rdbuf` swap (`:575`), where a crash produces zero evidence in a GUI process.\n7. **Make each crashes.log block self-describing** — `BT_VERSION_STRING`, pid, local time, the day-log name, and `BTMatchLogPath()` (exposed at `matchlog.hpp:52-54`). The stack is module-relative `btl4+0xNNNN`, resolvable only against that build's PDB (`mkdist.py:174-183`), and a never-rotated file spans builds. This also solves your **per-mission-upload vs per-day-file mismatch**: the crash record names its own round's matchlog, so a crash stack in the day file lines up with the `matchlogs/<peer>_matchlog_*.txt` you already collected — the only link, since the crashing round itself never uploads.\n8. **Size.** The day log is never uploaded, so the 8 MiB matchlog cap is irrelevant to it — but one short session is already 405,274 bytes (`content\\solo.log`) with `BT_DEV_GAUGES=1` in every bat. Add a size roll (`<stem>_YYYYMMDD_02.log` past ~32 MB) so \"mail me your log\" stays mailable. Separately, while you're in there: the client caps `size` (`L4NET.CPP:720`) but the relay caps `name_length + size` (`btconsole.py:1373`), so a matchlog at exactly 8 MiB is sent and then killed with `_drop_game`, silently on both ends.\n9. **Keep `crashes.log` out of any wildcard broom.** Nothing deletes it today, but the natural follow-up to dated names is `del *.old.log`. Either name it `crashes.txt` or write the exclusion into every bat and into `btoperator.py`'s generator.\n\n## Still uncertain\n\n- **Where your `matchlogs/` actually lands.** `btconsole.py:1400-1402` uses relative paths, so it follows the relay's CWD. `tools/btrelay_park.py:106` sets `cwd=CONTENT`; `tools/btoperator.py:902-906` starts btconsole with **no** `setWorkingDirectory` — a GUI-launched relay drops `matchlogs/` wherever the GUI was started. Worth confirming before you rely on \"retrieved automatically\".\n- **Whether exported bats are in players' hands.** `btoperator.py:1394-1436` emits bats with no rotation, no `BT_LOG_APPEND`, and `BT_LOG=join.log` even for the *solo* script — the exact solo-clobbers-MP bug `players/play_solo.bat:19-21` documents fixing. Independent defect; decide whether to fix or delete the exporter.\n- **Relay-side rotation is untouched by this proposal** and keeps the same 2-generation policy on your half of the incident: `btrelay_park.py:44-52` rotates per generation with a 2 s backoff floor (`:41`), and `btoperator.py:40-55,901` rotates `operator_relay.log` on every Start Session.\n- **Concurrent `ios::app` appends from two processes to one day file** were not empirically tested. The relaunch overlap is milliseconds (`btl4console.cpp:238-244`), and the `BT_LOG`-wins rule keeps genuinely concurrent instances on separate files, so this should stay theoretical — but don't remove the per-instance escape hatch."

run wf_e5921a30-e21 -- 7 completed agent(s)

{
 "area": "Vtable overrides of OneOfSeveralStates vs OneOfSeveral/Int/PixInt in BTL4OPT.EXE",
 "conclusion": "NO — there is no index remap anywhere in OneOfSeveralStates' own vtable code. The four vtables (0x518b24 States, 0x518b68 PixInt, 0x518bac Int, 0x518bf0 base) are contiguous 17-slot (0x44-byte) tables; slots 1-15 are identical across all four classes, slot 0 is each class's dtor, and the ONLY behavioral override is slot 16 — the Execute slot — where OneOfSeveralStates substitutes 0x4c552c for the base 0x4c4f28. Full disassembly of 0x4c552c shows its entire body is: if ((int)this->selected[+0xac] < 0) selected = 0; then chain-call base Execute 0x4c4f28. A negative clamp only — no rows-1-selected, no lookup table, no arithmetic on the index. Base Execute 0x4c4f28 computes row=selected when columns==1 and sy0=row*frameHeight, identical to the port. The ctor 0x4c5470 also does no transform (it wires the StateConnection destination directly to &this->selected at +0xac). Therefore level 0 draws source row 0 (top) and level 2 draws row 2 (bottom) in the 1995 binary exactly as in the port's widget math — the AUTO/OFF inversion cannot originate in this class, leaving the vertical source-rect blit / PCC row-order difference between the 1995 engine and WinTesla as the live suspect. Side correction: the port's label for 0x4c552c (\"BecameActive\") is wrong — it occupies the Execute slot (vtable+0x40) and IS OneOfSeveralStates::Execute, though the described behavior (clamps then chains Execute) is accurate.",
 "confidence": "high",
 "facts": [
  {
   "claim": "The four vtables are contiguous 17-slot (0x44-byte) tables: 0x518b24 (States) + 0x44 = 0x518b68 (PixInt) + 0x44 = 0x518bac (Int) + 0x44 = 0x518bf0 (base OneOfSeveral); values past slot 16 in any dump belong to the next class's table.",
   "evidence": "tools/vtdump.py dumps of all four VAs: slot17 of 0x518b24 (0x4c5364) equals slot0 of 0x518b68; slot17 of 0x518b68 (0x4c51d8) equals slot0 of 0x518bac; slot17 of 0x518bac (0x4c4e7c) equals slot0 of 0x518bf0; the arithmetic spacing is exactly 0x44."
  },
  {
   "claim": "Slots 1-15 are bit-identical across all four classes (0x4173b8, 0x417938, 0x4c4f14, then twelve GraphicGauge base fns 0x444004..0x444600); slot 0 differs per class only as the scalar-deleting dtor.",
   "evidence": "vtdump slot-by-slot diff; 0x4c5500 (States slot0) disassembled: stamps vtable 0x518b24, calls base dtor 0x4c4e7c, conditional operator delete 0x4022d0 — standard dtor glue, no draw or index logic."
  },
  {
   "claim": "Slot 16 is the Execute slot, and it is the ONLY behavioral override in OneOfSeveralStates: base/Int/PixInt all hold Execute 0x4c4f28 there, while 0x518b24 holds 0x4c552c.",
   "evidence": "vtdump: [+0x40] slot16 = 0x4c4f28 in 0x518bf0, 0x518bac, 0x518b68 vs 0x4c552c in 0x518b24; the known Execute VA 0x4c4f28 sits in this slot in the base table."
  },
  {
   "claim": "0x4c552c performs NO index transform: its complete body clamps a negative selected index (+0xac) to 0 and chains the base Execute. It is OneOfSeveralStates::Execute, not BecameActive as labeled in the port.",
   "evidence": "Full disassembly via tools/disas2.py 0x4c552c: mov eax,[ebp+8]; cmp dword [eax+0xac],0; jge skip; xor edx,edx; mov [eax+0xac],edx; skip: push eax; call 0x4c4f28; ret. 12 instructions total, ends at 0x4c554b. Its slot position (+0x40, the Execute slot) proves the identity; the port's behavioral description (clamps then chains Execute) is nonetheless accurate."
  },
  {
   "claim": "Base Execute 0x4c4f28 uses identity row addressing: with columns (+0xa0) == 1 it sets col=0, row=selected (+0xac), computes sx0=col*frameW(+0xa4), sy0=row*frameH(+0xa8), inclusive sx1/sy1=+W-1/+H-1, then SetColor via [view+0x18] and DrawBitMapOpaque via [viewVtbl+0x58] with (color,0,bm,sx0,sy0,sx1,sy1) — the same math as the port's btl4gaug.cpp.",
   "evidence": "Full disassembly via tools/disas2.py 0x4c4f28 0x200: the columns==1 branch at 0x4c4f54-0x4c4f61 (xor esi,esi; mov eax,[ebx+0xac]), the sx/sy computation at 0x4c4f75-0x4c4f92, the opaque path at 0x4c4f9f-0x4c500b calling bitMapBin.Get 0x442aec and [edx+0x58], plus a transparent path via [ecx+0x5c] at 0x4c5041-0x4c5047. Dirty-check against +0xb0 at entry."
  },
  {
   "claim": "Neither the ctor 0x4c5470 nor the shared slot3 0x4c4f14 transforms the index: the ctor wires a newly allocated 0x18-byte StateConnection's destination directly to &this->selected (+0xac) and passes a constant 1 (columns) to the base ctor 0x4c4d88; slot3 just sets +0xb0 = -1 (redraw invalidator).",
   "evidence": "Disassembly of 0x4c5470: push 1 inserted as 8th base-ctor arg at 0x4c5490; operator new(0x18) at 0x4c54c5; lea ecx,[ebx+0xac]; push ecx into StateConnection ctor 0x4c3324 at 0x4c54e1. Disassembly of 0x4c4f14: mov dword [eax+0xb0], 0xffffffff; ret."
  }
 ]
}
{
 "area": "Artwork conventions of the content/GAUGE PCC frame strips (BTEBUS, BTPBUS, BTECMODE, BTEMODE, BTELOOP, BTPLOOP)",
 "conclusion": "The authored convention is split by orientation: HORIZONTAL strips place the frame for value N at position N counting from the LEFT (frame 0 = leftmost = OFF/blank), while VERTICAL strips place the frame for value N at position N counting from the BOTTOM (frame for value 0 = bottom). All four horizontal strips (BTEBUS: OFF,A,B,C,D; BTPBUS: OFF,A,B,C,D; BTELOOP: OFF,1..6; BTPLOOP: OFF,1..6) read left-to-right in value order, and BTEBUS displays correctly in the port. Both vertical strips are consistent ONLY with bottom-up counting: BTEMODE (top=AUTO, middle=MANUAL, bottom=OFF) against the proven level semantics 0=OFF/1=MANUAL/2=AUTO, and BTECMODE (top=ON, bottom=OFF) against coolantAvailable 0/1 — coolantAvailable=1 must show the TOP frame (ON). This bottom-up vertical authoring alone explains the port's inversion with NO code drift: the 1995 engine's blit source-rect y-origin was bottom-left (y-up), so sy=row*frameHeight measured from the bottom picked the right frame, while the port's top-left-origin sy0=row*frameHeight picks the vertically mirrored frame. Fix: for vertical strips use sy0=(numFrames-1-row)*frameHeight (or y-flip the loaded rows). Prediction/second confirmation: the port's BTECMODE coolant lamp should currently be inverted too (coolantAvailable=1 rendering OFF).",
 "confidence": "High (T1 for all frame contents — decoded directly from the shipped PCC assets and visually confirmed at 6-8x scale; the bottom-up-vertical conclusion follows from two independent strips whose value semantics are independently proven, and it exactly reproduces the observed port symptom)",
 "facts": [
  {
   "claim": "BTEBUS.PCC (250x29, five 50px frames horizontal) reads left to right: OFF (inverse video, dark glyphs on lit white block), A, B, C, D (white glyphs on black). Frame 0 = leftmost = no-generator/OFF.",
   "evidence": "Decoded C:\\git\\bt411\\content\\GAUGE\\BTEBUS.PCC via PIL, rendered per-frame with separators to scratchpad png\\BTEBUS.png and read visually; frame 0 pixel histogram is majority-lit (index 1: 926 px vs 524) confirming inverse video, frames 1-4 majority-dark."
  },
  {
   "claim": "BTPBUS.PCC (150x21, five 30px frames horizontal) reads left to right: OFF, A, B, C, D (dark letters on teal).",
   "evidence": "Decoded and visually read from scratchpad png\\BTPBUS.png."
  },
  {
   "claim": "BTELOOP.PCC (217x31, seven 31px frames) and BTPLOOP.PCC (210x21, seven 30px frames) both read left to right: OFF, 1, 2, 3, 4, 5, 6.",
   "evidence": "Decoded and visually read from scratchpad png\\BTELOOP.png and png\\BTPLOOP.png; BTELOOP frame 0 is inverse-video OFF matching BTEBUS frame 0 styling."
  },
  {
   "claim": "BTEMODE.PCC (90x135, three 90x45 frames vertical) reads top to bottom: AUTO, MANUAL, OFF — independently re-confirming the known decode.",
   "evidence": "Decoded and visually read from scratchpad png\\BTEMODE.png (white text on black: AUTO top frame, MANUAL middle, OFF bottom)."
  },
  {
   "claim": "BTECMODE.PCC (51x40, two 51x20 frames vertical) reads top=ON, bottom=OFF; when coolantAvailable=1 the TOP frame (ON) should be shown, so a top-down row=value mapping inverts this lamp exactly like BTEMODE.",
   "evidence": "Decoded and visually read from scratchpad png\\BTECMODE.png; semantic mapping from coolantAvailable 0/1 at +0x134 (0=no coolant=OFF, 1=available=ON)."
  },
  {
   "claim": "Across all six strips the authored convention is: horizontal = value N at position N from the LEFT; vertical = value N at position N from the BOTTOM. Under top-down addressing both vertical strips (and only the vertical strips) invert — matching the port symptom (level 0 shows AUTO, level 2 shows OFF) while BTEBUS stays correct.",
   "evidence": "Four horizontal strips in strict left-to-right value order 0..N with 0=OFF; both vertical strips in strict bottom-to-top value order (BTEMODE bottom=OFF matches proven level 0=OFF, FUN_004b0e30 detach; BTECMODE bottom=OFF matches coolantAvailable=0). A single bottom-left (y-up) source-rect origin in the 1995 blit explains all six strips with the identity SetLevel/sampler/Execute math already proven at 0041bbd8/004c3390/004c4f28; port fix is sy0=(numFrames-1-row)*frameHeight in game/reconstructed/btl4gaug.cpp's vertical DrawBitMapOpaque path (or a row y-flip at PCC load)."
  }
 ]
}
{
 "area": "Port engine blit + loader: DrawBitMapOpaque source-rect y-origin, PCC/PCX->BitMap row order, and gauge-view destination y direction (engine/MUNGA + engine/MUNGA_L4, from source)",
 "conclusion": "DEFINITIVE: in the PORT, the source rectangle's y is measured from the BOTTOM of the source bitmap, and the loader stores the PCX un-flipped (memory row 0 = top scanline). Net effect: sy=0 addresses the BOTTOM scanline as authored, so for a 1x3 vertical strip with sy0=row*frameHeight, row=0 selects the BOTTOM visual frame of the PCX (btemode's OFF frame), row=1 the middle (MANUAL), row=2 the TOP (AUTO). The gauge view's y increases UPWARD on screen (bottom-left origin; converted once at the display via y = maximumY - y; pixelBuffer row 0 = top of window at present). CONSEQUENCE FOR THE MYSTERY: this chain, with the proven identity level->row mapping, produces level0=OFF(bottom)/level2=AUTO(top) -- the CORRECT pod display. The port engine's blit+loader is the 1995 bottom-up convention, unchanged, and CANNOT produce the observed level0=AUTO inversion; a 5x1 horizontal strip (rows=1) is insensitive to the y convention either way, so btebus rendering correctly does not discriminate. The y-origin-drift hypothesis is REFUTED at the engine-source level. If the running build truly shows the inversion, the fault enters UPSTREAM of localView.DrawBitMapOpaque: the value actually reaching `selected` at draw time, a runtime Execute override diverging from btl4gaug.cpp:1648 (the @004c552c candidate), the CFG's columns/rows for btemode (columns!=1 silently changes the row math at btl4gaug.cpp:1655-1664), or the widget isn't drawing through this path at all -- those are game-side, outside my area. One adjacent oddity worth the other agent's eyes: Execute passes foregroundColor into GraphicsView's `background` slot and SetColor(backgroundColor) becomes the blit's set-bit color (btl4gaug.cpp:1680-1682 vs GRAPH2D.cpp:2538-2540 vs L4VB16.cpp:6758-6760) -- fg/bg reach Video16BitBuffered swapped, which touches today's colour fix, though it cannot invert frame SELECTION.",
 "confidence": "High (T0 for every hop -- all claims read directly from the engine source the port compiles: exact row loops, macros, loader loops, and present path; no other DrawBitMapOpaque implementation exists anywhere in the repo to override the chain, and both possible display classes -- Video16BitBuffered and Video8BitBuffered -- have line-identical source math). The only residual uncertainty is runtime, not source: whether the live btemode widget actually executes this compiled path with columns=1, which is game-config territory outside this trace.",
 "facts": [
  {
   "claim": "The widget blit call: sy0 = row*frameHeight, sy1 = sy0+frameHeight-1, then localView.SetColor(backgroundColor) + localView.DrawBitMapOpaque(foregroundColor, 0, bm, sx0, sy0, sx1, sy1); row = selected when columns==1.",
   "evidence": "C:\\git\\bt411\\game\\reconstructed\\btl4gaug.cpp:1648-1685 (row math 1655-1669, call 1680-1682)"
  },
  {
   "claim": "localView is a GraphicsView constructed on renderer->GetGraphicsPort(graphics_port_number); no transform or flip is applied at binding.",
   "evidence": "C:\\git\\bt411\\engine\\MUNGA\\GAUGE.cpp:773-788 (GraphicGauge ctor, localView init 782-786)"
  },
  {
   "claim": "Gauge graphics ports are L4GraphicsPort over the SVGA16 display (SVGA16 : public Video16BitBuffered); external 8-bit ports are L4BytePort over Video8BitBuffered. Both concrete displays implement the identical source-rect math, so the conclusion is display-independent.",
   "evidence": "C:\\git\\bt411\\engine\\MUNGA_L4\\L4GREND.cpp:176-183 (new SVGA16), 549-556 (new L4GraphicsPort), 693-697 (new L4BytePort); C:\\git\\bt411\\engine\\MUNGA_L4\\L4VB16.h:277-278 (SVGA16 : public Video16BitBuffered)"
  },
  {
   "claim": "GraphicsView::DrawBitMapOpaque wraps (sx1,sy1,sx2,sy2) as Rectangle2D sourceBounds with bottomLeft=(sx0,sy0); ClipImage only clips (source to image limits, dest to clippingRectangle) and never inverts y; position = destRect.bottomLeft; sourceBounds.bottomLeft.y is forwarded into the port parameter named sBottom.",
   "evidence": "C:\\git\\bt411\\engine\\MUNGA\\GRAPH2D.cpp:2519-2562 (forward 2537-2547), 3024-3103 (ClipImage); parameter is named sBottom at C:\\git\\bt411\\engine\\MUNGA_L4\\L4VB16.cpp:6721 and L4VB8.cpp:2817"
  },
  {
   "claim": "The port layer (L4GraphicsPort / L4BytePort) passes the source rectangle through UNCHANGED; only the destination goes through convertPortPair, which is identity for baseRotation 0 (gauge ports are built with rotation from CFG; a rotated port would also rotate btebus, which renders correctly).",
   "evidence": "C:\\git\\bt411\\engine\\MUNGA_L4\\L4VB16.cpp:6714-6768 (source coords verbatim at 6766); L4VB8.cpp:2810-2864, convertPortPair L4VB8.cpp:2980-3010"
  },
  {
   "claim": "THE ROW LOOP, read directly: Video16BitBuffered::DrawBitMapOpaque converts dest y = maximumY - y, then sTop = map_max_y - sTop and sBottom = map_max_y - sBottom (map_max_y = bitmap Size.y-1); source_pointer_begin = MapPointer + (sLeft>>4) + sBottom*WidthInWords; each scanline does UP_BITMAP (pointer -= WidthInWords) and UP_DEST (pointer -= width), i.e. walks from the coordinate-bottom row toward memory row 0. The engine's own comment: 'The bitmap is always read left-to-right, bottom-to-top.' Source y is therefore measured FROM THE BOTTOM of the source bitmap.",
   "evidence": "C:\\git\\bt411\\engine\\MUNGA_L4\\L4VB16.cpp:3770-3948 (conversion 3846-3850, start pointer 3884-3887, comment 3898-3901, loop 3928-3948), macros 2137 and 2155; line-identical 8-bit path L4VB8.cpp:1480-1646 (conversion 1549-1553, loop 1616-1646, macros 214-233)"
  },
  {
   "claim": "The BitMap cache load path is WarehouseBinOf<BitMap>::Get(name) -> BitMap::Make(name) (prepends 'gauge\\') -> BitMap::BitMap(const char*) -> PCXRead. PCXRead RLE-decodes scanlines strictly sequentially in FILE order into the body buffer, and the 8bpp->1bpp pack loop (nonzero byte -> bit set) walks source and dest strictly sequentially. PCX files store the top scanline first, so BitMap MapPointer row 0 = TOP scanline of the PCC as authored. NOT flipped.",
   "evidence": "C:\\git\\bt411\\engine\\MUNGA\\WRHOUS.h:243-279 (Get -> T::Make at 262); C:\\git\\bt411\\engine\\MUNGA\\GRAPH2D.cpp:181-205 (BitMap::Make), 274-338 (ctor; pack loop 299-332), 27-174 (PCXRead; sequential decode 94-139)"
  },
  {
   "claim": "Destination orientation, end to end: the shared gauge pixelBuffer's memory row 0 is presented at the TOP of the visible window (ExpandPlaneToBGRA does a 'Native orientation, top-down straight copy' row y -> row y, and the GDI present uses biHeight = -oh, top-down, via StretchDIBits). Combined with y = maximumY - y in the blit, gauge view/port y INCREASES UPWARD on screen: y=0 is the bottom scanline of the surface.",
   "evidence": "C:\\git\\bt411\\engine\\MUNGA_L4\\L4VB16.cpp:701-739 (ExpandPlaneToBGRA, comment at 717); C:\\git\\bt411\\engine\\MUNGA_L4\\L4GLASSWIN.cpp:438-453 (biHeight = -oh at 442, StretchDIBits at 450)"
  },
  {
   "claim": "Net frame selection for a 1x3 vertical strip in the PORT: sy=0 addresses the bitmap's LAST memory row = the BOTTOM authored scanline, so row=0 selects the BOTTOM visual frame (btemode OFF), row=1 the middle (MANUAL), row=2 the TOP (AUTO). With the proven level semantics (0=OFF, 1=MANUAL, 2=AUTO) and identity level->row, this chain yields the CORRECT pod display -- the engine blit/loader cannot be the source of the observed inversion.",
   "evidence": "Deduction from the three T0 facts above: loader top-down storage (GRAPH2D.cpp:299-332) + source y bottom-up conversion (L4VB16.cpp:3848-3850) + un-flipped present (L4VB16.cpp:715-739, L4GLASSWIN.cpp:442)"
  },
  {
   "claim": "A 5x1 HORIZONTAL strip is insensitive to the source-y convention: rows=1 makes sy0=0 and frameHeight=image height, so map_max_y - sTop / - sBottom maps [0,h-1] onto itself; btebus rendering correctly is consistent with EITHER y convention and does not discriminate the hypothesis.",
   "evidence": "Same conversion code path, C:\\git\\bt411\\engine\\MUNGA_L4\\L4VB16.cpp:3848-3853, with sBottom=0, sTop=Size.y-1"
  },
  {
   "claim": "No alternate blit exists to override this chain: every DrawBitMapOpaque implementation in the repo is GraphicsDisplay/GraphicsPort Fail-stubs, GraphicsView, L4BytePort, L4GraphicsPort, Video8BitBuffered, or Video16BitBuffered; game/glass contains none.",
   "evidence": "Repo-wide grep for DrawBitMapOpaque: engine\\MUNGA\\GRAPH2D.{h,cpp}, engine\\MUNGA_L4\\L4VB8.{h,cpp}, L4VB16.{h,cpp}, L4LAMP.cpp/L4GAUGE.cpp (callers only), game\\reconstructed\\* (callers only); grep of game\\glass for DrawBitMapOpaque/GraphicsDisplay overrides: no matches"
  },
  {
   "claim": "Adjacent finding (colour, not geometry): OneOfSeveral::Execute passes foregroundColor into GraphicsView's 'background' parameter while SetColor(backgroundColor) sets currentColor, which the port forwards as the display's set-bit ('foreground') colour -- fg/bg arrive at Video16BitBuffered swapped relative to their names. Cannot affect frame selection, but is load-bearing for today's colour fix.",
   "evidence": "C:\\git\\bt411\\game\\reconstructed\\btl4gaug.cpp:1680-1682 -> C:\\git\\bt411\\engine\\MUNGA\\GRAPH2D.cpp:2538-2540 (currentColor first, background second) -> C:\\git\\bt411\\engine\\MUNGA_L4\\L4VB16.cpp:6757-6760"
  }
 ]
}
{
 "area": "PORT HISTORY — git archaeology of the ENG-page connect-mode stateLamp (btemode.pcc) wiring across all revisions of game/reconstructed/btl4gau2.cpp, btl4gaug.cpp, powersub.cpp/.hpp, plus the L4GAUGE.CFG config-factory path",
 "conclusion": "The stateLamp wiring NEVER changed in any committed revision: source was always subsystem+0x2b8 (modeAlarm; the sampler's +0x14 lands on the level at +0x2cc), image always btemode.pcc, always 1 column x 3 rows at (0xbe,0) on engPort. The only changes ever were visibility, not addressing: (1) 674c172 (2026-07-07) wrote the call but the IsGeneratorDerived gate was stubbed False, so the lamp was never constructed; (2) dc6af8c (same day) made the gate real, constructing the lamp — but the OneOfSeveralStates ctor (written 6fceb58) dropped the binary's caller-supplied colours and hardcoded bg=0/fg=0, so it drew colour-0-on-colour-0 and was INVISIBLE in every committed build; (3) e634709 (07-21) fixed that exact colour-drop class for the sibling Animated*Lamps (making horizontal btebus visible+correct) but not for OneOfSeveralStates/Int; (4) today's UNCOMMITTED working-tree change restores 0xff/0 (byte-verified vs the binary call site @004c866c) and is the FIRST build in which btemode draws at all. The task's hypothesis that an early wiring read +0x264 electricalStateAlarm is REFUTED: git log --all -S\"0x264\" over both gauge files is empty, and the ConnectMode writer values (1=select, 2=auto, 0=detach; enum 0/1/2) are unchanged since the initial commit. No commit ever changed frame addressing: the Execute math (columns==1 -> row=selected, sy0=row*frameHeight) exists only in a538d64 (07-06) and was never edited; the only engine-side lamp-drawing commit (6bb03ae, Gitea #48) fixed translation-table pixel leaks, not row addressing. No CFG line constructs btemode: L4GAUGE.CFG has no oneOfSeveralStates verb (the port never registered a Make for it); the only oneOfSeveral* config lines are four horizontal oneOfSeveralPixInt lamps with no colour params. Therefore the maintainer's \"three states with one blank\" memory matches NO committed build of this lamp — it was unbuilt, then invisible, until today; and BTEMODE.PCC decodes to three inked frames (AUTO 361px/MANUAL 593px/OFF 279px), while the mode writers only emit 0..2 onto a 3-row strip, so a blank state was never geometrically possible here. Best-fit alternatives for the memory [T4, flagged]: (a) the generator-letter lamp btebus in the e634709..e26f4e6 window (07-21..07-27) — BUS-MODE press three: detach set level 0 and blanked the letter lamp to frame 0 (\"OFF\"), and the Gitea #62 bug made auto-re-attach unreachable so the blank stuck; the #62 fix (e26f4e6, 07-27) is \"the later change that altered it\"; or (b) a memory of the 1995 pod itself, whose dim 279-px OFF frame reads as near-blank beside MANUAL. Bonus for the main investigation: btecmode (1x2) and bteseek (1x4) are two MORE vertical BitMap strips, still invisible today because OneOfSeveralInt still drops the binary's 0xff/0 colours (decomp part_014.c:1479-1481 proves the binary passes them); giving bteseek its colours would provide a second vertical-addressing witness with proven 0..3 semantics.",
 "confidence": "High for all git-history facts, ctor arguments, enum/writer values, CFG contents, and the PCC frame decode (directly read/measured). Low-to-moderate (T4, flagged) for which artifact the maintainer's \"3 states + one blank\" memory corresponds to — the btebus stuck-blank window is the best documented candidate but is an interpretation, not a recorded fact.",
 "facts": [
  {
   "claim": "The stateLamp source field was +0x2b8 (modeAlarm) in every revision that ever contained the call; +0x264/electricalStateAlarm was never wired to any lamp in any commit.",
   "evidence": "git log -L '/stateLamp = new OneOfSeveralStates/,+3:game/reconstructed/btl4gau2.cpp' shows the line born at 674c172 with '(Entity *)((char *)subsystem_in + 0x2b8)' and never edited until the working tree; git log --all -p -S\"0x264\" -- game/reconstructed/btl4gaug.cpp game/reconstructed/btl4gau2.cpp returns zero commits; the initial-commit skeleton comment (7b7d465:game/reconstructed/btl4gau2.cpp line 341) already documents 'OneOfSeveralStates (btemode, subsys+0x2b8) @004c5470'."
  },
  {
   "claim": "The lamp was never constructed before dc6af8c (2026-07-07): 674c172 wrote the call behind a gate stubbed to False.",
   "evidence": "674c172 diff shows 'static Logical IsGeneratorDerived(Subsystem *) { return False; }' with the comment 'the generator-only stateLamp is simply not built'; git log -L on the function shows dc6af8c changed it to 'return BTIsPoweredSubsystem(sub) ? True : False;'."
  },
  {
   "claim": "From dc6af8c until today's uncommitted fix the lamp was constructed but invisible: the OneOfSeveralStates ctor dropped the binary's bg/fg colours and hardcoded 0,0, producing SetColor(0)+DrawBitMapOpaque(0) — colour-0 on colour-0.",
   "evidence": "6fceb58 diff introduces 'OneOfSeveral(..., True, image, 0, 0, columns, rows, ...)' in the OneOfSeveralStates ctor; e634709's commit message documents the identical failure class for the sibling lamps ('SetColor(0)+DrawBitMapOpaque(0), i.e. black-on-black') and fixed only AnimatedSubsystemLamp/AnimatedSourceLamp; the working-tree diff (git diff HEAD, 2026-07-28, UNCOMMITTED) adds background_color/foreground_color parameters and passes 0xff,0 at the call site, commented 'byte-verified from the binary's own call site @004c866c'."
  },
  {
   "claim": "No commit ever changed frame addressing: the Execute row math and the frameHeight division exist only in their introducing commit a538d64 (2026-07-06).",
   "evidence": "git log --all -S\"row = selected\" and -S\"sy0\" on game/reconstructed/btl4gaug.cpp each return only a538d64 (plus the unrelated initial commit for the file); current code at btl4gaug.cpp:1655-1669 is 'columns==1 -> col=0,row=selected; sy0=row*frameHeight; sy1=frameHeight+sy0-1'. The only engine-side commit matching DrawBitMapOpaque is 6bb03ae (Gitea #48, uninitialized translation-table entries leaking pixels) — palette/translation, not source-rect addressing."
  },
  {
   "claim": "The mode-level writers always emitted the binary-proven values 0/1/2 and never changed: ctor and SelectGeneratorA-D write 1, toggle writes 2 (below 2) or detaches and writes 0 (at 2); the enum values are 0/1/2 since the initial commit.",
   "evidence": "powersub.cpp lines 252, 606, 666-673 (modeAlarm.SetLevel(Connected)=1, SetLevel(AutoConnect)=2, DetachFromVoltageSource()+SetLevel(ManualConnect)=0); git show d7b900d shows the toggle handler born with identical values (2026-07-11); git show 7b7d465:game/reconstructed/powersub.hpp lines 87-91 shows enum ConnectMode {ManualConnect=0, Connected=1, AutoConnect=2} in the initial commit; git log -S\"modeAlarm.SetLevel\" hits only 7b7d465/9d82be4/d7b900d/e26f4e6, none altering the values."
  },
  {
   "claim": "No config path ever built btemode: L4GAUGE.CFG contains no oneOfSeveralStates verb and no btemode line, and the port never registered a config Make for OneOfSeveralStates; the SubsystemCluster hardwired ctor is the sole constructor in both the port and the shipped data.",
   "evidence": "grep -i 'oneOfSeveral|btemode|btecmode' on content/GAUGE/L4GAUGE.CFG matches only four oneOfSeveralPixInt lines (5001/5012/5023/5034: bduck.pcc 3x1 DuckState, blamp.pcc 2x1 Searchlight/LightOn, sdspmod.pcc 3x1 DisplayMode, smode.pcc 3x1 ControlMode — PixMap8 path, rate/mode/strip/columns/rows/attr, no colour params); btl4gaug.cpp has methodDescription registrations for PixInt and other widgets but none for OneOfSeveralStates; GeneratorCluster CFG entries build TwoState lamps (subsys+0x1D4 generatorOn), not OneOfSeveral."
  },
  {
   "claim": "The maintainer's 'three states with one blank' memory cannot match any committed build of the btemode lamp: it never displayed until today's uncommitted colour fix, all three PCC frames carry ink, and the writers only produce in-range values 0..2 on the 3-row strip.",
   "evidence": "Timeline above (unbuilt 07-05..07-07, invisible 07-07..07-28); direct decode of content/GAUGE/BTEMODE.PCC: 90x135 8bpp, three 45px frames with 361/593/279 nonzero pixels reading AUTO/MANUAL/OFF — no blank frame; a blank would require a level >=3, which no writer ever produced."
  },
  {
   "claim": "Best-fit candidate for the memory [T4]: the btebus generator-letter lamp between e634709 (2026-07-21, first visible) and e26f4e6 (2026-07-27): the third BUS-MODE press (detach, level 0) blanked it to frame 0 ('OFF') and the Gitea #62 bug made re-attach unreachable so the blank state stuck; the #62 fix is 'the later change that altered it'.",
   "evidence": "btl4gau2.cpp:206-217 PowerSourceConnection ('resolved+0x1E0 == generatorNumber 1..4 -> A..D; 0 == OFF'); e26f4e6 commit message ('AutoConnect can re-attach again... a subsystem detached by the BUS MODE button could never re-attach (dark recharge arc for the rest of the mission)'); e634709 made AnimatedSourceLamp (btebus) visible for the first time. Interpretation flagged T4 — plausible, not proven."
  },
  {
   "claim": "Cross-check lead for the vertical-addressing investigation: btemode is only the port's only VISIBLE vertical strip — btecmode (1x2, +0x134 coolantAvailable) and bteseek (1x4, seek step 0..3) are vertical BitMap strips still invisible today because OneOfSeveralInt still hardcodes 0/0 while the binary passes 0xff,0 to the Int ctor too.",
   "evidence": "btl4gaug.cpp:1920-1930 OneOfSeveralInt ctor still passes 'True, image, 0, 0'; reference/decomp/all/part_014.c:1479-1481 shows the binary's call 'FUN_004c5148(..., s_btecmode_pcc, 0xff, 0, 1, 2, param_8+0x134, ...)'; btl4gau2.cpp:1468-1470, 1757-1763, 1943-1945 are the three Int call sites (btecmode 1x2, bteseek 1x4 twice). Fixing their colours would give a second vertical witness with proven 0..3 semantics (Myomers::currentSeekVoltageIndex@0x320)."
  }
 ]
}
{
 "area": "The 1995 binary's blit chain: OneOfSeveral::Execute -> GraphicsView::DrawBitMapOpaque -> L4GraphicsPort::DrawBitMapOpaque -> SVGA16 display DrawBitMapOpaque (BTL4OPT.EXE)",
 "conclusion": "DEFINITIVE: in the 1995 engine, the source-rect y coordinate of the blit is BOTTOM-UP — sy0=0 addresses the bitmap's LAST memory row (data + (height-1)*pitch), i.e. the BOTTOM visual frame as authored in the PCX. For btemode (vertical 1x3, H=3*frameH): level 0 -> sy0=0 -> bottom frame = OFF; level 1 -> sy0=frameH -> middle = MANUAL; level 2 -> sy0=2*frameH -> top frame = AUTO. This matches the proven level semantics (0=OFF, 1=MANUAL, 2=AUTO) and the pod veterans' testimony exactly. The flip lives in ONE place: the concrete display device's DrawBitMapOpaque @0046bdfc, which computes srcRow = (bitmapHeight-1) - sy1 and dest y' = (screenHeight-1) - y, then walks BOTH pointers backward (bottom-to-top) per row — the entire 1995 2D system is y-up, so each frame renders upright, but frame SELECTION maps sy0=k*frameH to the (2-k)th authored frame because the flip uses the FULL strip height. Every layer above the display (Execute @004c4f28 row math, GraphicsView::DrawBitMapOpaque @0044b88c clip @0044c174, L4GraphicsPort::DrawBitMapOpaque @0046ded8) passes sy through untransformed — identity, same as the port. This is precisely why only the port's ONLY vertical strip (btemode) inverts while the horizontal btebus (strip height == frame height, so the flip degenerates to within-frame row order that cancels against the dest flip) displays correctly. Port fix direction: the reconstructed gauge blit must select the source band bottom-up — memRowStart = stripHeight - (row+1)*frameHeight (equivalently treat Execute's sy as measured from the bitmap's bottom row), NOT sy0 = row*frameHeight from the top. Corroborating T0 ground truth: WinTesla's own port of this exact function, Video16BitBuffered::DrawBitMapOpaque in C:\\git\\bt411\\engine\\MUNGA_L4\\L4VB16.cpp lines 3770-3950, names the parameters sLeft, sBottom, sRight, sTop, performs the identical flips (y = maximumY - y; sTop = map_max_y - sTop; sBottom = map_max_y - sBottom), and carries the comment 'The bitmap is always read left-to-right, bottom-to-top' with UP_BITMAP(ptr) -= WidthInWords — so the engine port already contains the correct bottom-up implementation at the display level; the port's gauge path is evidently not routing sy through such a flip.",
 "confidence": "High (T1 throughout — every link disassembled from the binary and cross-checked against the Ghidra decomp text, with the display blit additionally corroborated by the WinTesla source of the same function [T0]). The single T3 element: the claim that loaded BitMap memory row 0 = the authored TOP scanline rests on the shared content archives plus the port's observed behavior (top-down blit shows frame0=AUTO for sy0=0), not on a disassembly of the 1995 PCC loader itself.",
 "facts": [
  {
   "claim": "OneOfSeveral::Execute @004c4f28 computes sy0 = (selected/columns) * frameHeight and calls the embedded view's vtbl+0x58 with (fg=[widget+0x9c], rotation=0, bitmap, sx0, sy0, sx1=sx0+w-1, sy1=sy0+h-1) — identity row math, same as the port; the view object is embedded at widget+0x48",
   "evidence": "Disassembly 0x4c4f5b-0x4c4ff2: 'mov eax,[ebx+0xac]' (selected), idiv by [ebx+0xa0] (columns), 'imul edi,[ebx+0xa8]' (row*frameHeight -> sy0=edi), 'lea eax,[ebx+0x48]; mov edx,[ebx+0x48]; call dword ptr [edx+0x58]' with pushes sy1,sx1,edi,esi,bm,0,[ebx+0x9c],&view. SetColor = vtbl+0x18 @0x4c4fad. Tool: python tools/disas2.py 0x4c4f28 0x180"
  },
  {
   "claim": "The embedded view is a GraphicsView (vtable 004ef59c) holding a GraphicsPort pointer at view+0x40, obtained from GaugeRenderer::GetGraphicsPort(index) = FUN_00447f84 reading the renderer's 16-slot array at +0x54",
   "evidence": "GraphicGauge base ctor @0x444818: 'call 0x447f84' then 'call 0x44ad78(&this->view@+0x48, port)'; view ctor @0x44ad78: 'mov dword ptr [ebx], 0x4ef59c' and 'mov [ebx+0x40], esi' (port), copies port bounds from port+4. FUN_00447f84 = 'return *(param_1 + 0x54 + param_2*4)' (reference/decomp/all/part_006.c:5544). Class identity: C:\\git\\bt411\\engine\\MUNGA\\GAUGREND.h line 673 'GraphicsPort *graphicsPort[maximumGraphicsPorts]' (=16, line 405); GRAPH2D.h line 655 GraphicsView::DrawBitMapOpaque(background, rotation, bitmap, sx1, sy1, sx2, sy2) matches the 8-dword call exactly"
  },
  {
   "claim": "GraphicsView::DrawBitMapOpaque @0044b88c (vtbl 004ef59c slot 22, +0x58) clips the source rect with pure min/max intersection (no flip) and forwards it UNCHANGED to the port's vtbl+0x24 = GraphicsPort::DrawBitMapOpaque(color, background, operation, rotation, x, y, bm, sx1, sy1, sx2, sy2); SetColor (vtbl+0x18 @0044b250) merely stores the color at view+0x28 — no coordinate role",
   "evidence": "Decomp reference/decomp/all/part_008.c... (view fn at part_006.c:8968): FUN_0044b88c calls FUN_0044c174 (clip: '*param_2<0 -> 0', 'param_4<=param_2[2] -> param_4-1', rect intersect vs view bounds at +0x2c — no y inversion, part_006.c @0044c174) then '(**(code**)(**(int**)(param_1+0x40)+0x24))(port, [view+0x28], param_2, [view+0x3c], param_3, destX, destY, bm, local_1c, local_18, local_14, local_10)'. Disasm confirms: tools/disas2.py 0x44b88c 0x160 (call [edx+0x24], add esp,0x30) and 0x44b250 0x20 (mov [eax+0x28],edx)"
  },
  {
   "claim": "The concrete port is L4GraphicsPort (size 0x544, vtable 004f4d0c, ctor @0046d850), created by the 1995 L4GaugeRenderer::BuildGraphicsPort @0046fdd0 which writes the renderer's +0x54 array; its DrawBitMapOpaque (vtbl+0x24 = 0046ded8) translates color via the port LUT (port+0x28[color]), adds port rotation (+0x528, checked against 90/180), transforms only the DEST point (FUN_0046e114), and passes sx1..sy2 straight through to the display's vtbl+0x28",
   "evidence": "Byte-scan for 'mov [ebx+esi*4+0x54], eax' found exactly 0x46fe3b/0x46ff14 (scratchpad scan54.py); disasm @0x46fdd0: checks [this+0x1c52c] (graphicsDisplay), 'push 0x544; call operator new; call 0x46d850', bitAllocation (0,0x100) -> SetSecondaryPalette @0x46db84 — structurally identical to WinTesla L4GaugeRenderer::BuildGraphicsPort (C:\\git\\bt411\\engine\\MUNGA_L4\\L4GREND.cpp:510-585). Ctor @0x46d850: 'mov dword ptr [ebx], 0x4f4d0c', rotation at +0x528 vs 0x5a/0xb4. Blit @0x46ded8 (decomp part_008.c:15215): 'mov eax,[esi+ecx*4+0x28]' color LUT, 'call dword ptr [edx+0x28]' on [this+0x14] with [ebp+0x28..0x34] (sx1..sy2) unmodified, add esp,0x34"
  },
  {
   "claim": "THE FLIP: the concrete SVGA16 display's DrawBitMapOpaque @0046bdfc (vtable 004f4d3c slot +0x28; display ctor @0046ced4, VGA port 0x3C6 access; created in the renderer ctor @0046f97c which reads env L4GAUGE/L4PLASMA and GAUGE\\L4GAUGE.INI) computes the source row pointer as data + (sx1>>4)*2 + ((bitmapHeight-1) - sy1)*pitchWords*2 and the dest as y' = (screenHeight-1) - y, then decrements BOTH source and dest pointers by one row per output row (bottom-to-top walk), copying (sy2-sy1+1) rows, MSB-first 1bpp words (mask 1<<(15-(sx1&15)))",
   "evidence": "Disasm 0x46be1f-0x46be6d: 'mov esi,[esi+0x28]; sub esi,[ebp+0x24]' (dest flip; base display ctor @0x46ab50 sets [this+0x28]=height-1 at 0x46ab8e-0x46ab97: 'mov eax,[ebx+0x20]; dec eax; mov [ebx+0x28],eax'); 'mov esi,[edi+0x10]; dec esi; sub esi,edx' ((H-1)-sy1); 'mov edi,[edi+0x14]; imul edi,edx; add edi,edi; add ecx,edi' (src = data+(H-1-sy1)*pitch*2); row epilogue 0x46befd: 'sub [ebp-0x10], pitch*2' (source walks backward). Decomp reference/decomp/all/part_008.c:13618-13627 renders it verbatim: 'iVar8 = iVar7 - param_11' with iVar7 = bitmapHeight-1, 'local_14 = data + (param_10>>4)*2 + pitch*iVar8*2', 'iVar6 = *(int*)(param_1+0x28) - param_8'"
  },
  {
   "claim": "T0 corroboration: WinTesla's port of this very function keeps the 1995 semantics and names them explicitly — the source-rect y args ARE bottom-up ('sBottom' is the sy1 slot), proving the reading of the 1995 disassembly and showing the correct flip already exists in the engine at the display level (the port's gauge inversion therefore arises above/beside this layer, in whatever blit path the reconstructed gauge localView actually reaches)",
   "evidence": "C:\\git\\bt411\\engine\\MUNGA_L4\\L4VB16.cpp lines 3770-3950: 'Video16BitBuffered::DrawBitMapOpaque(..., int sLeft, int sBottom, int sRight, int sTop)'; lines 3846-3850: 'y = maximumY - y; sTop = map_max_y - sTop; sBottom = map_max_y - sBottom'; line 3887: 'source_pointer_begin = MapPointer + (sLeft>>4) + (sBottom * WidthInWords)'; lines 3899-3901 comment 'The bitmap is always read left-to-right, bottom-to-top'; line 2155: '#define UP_BITMAP(pointer,map) pointer -= map->Data.WidthInWords'"
  },
  {
   "claim": "Why only the vertical strip breaks: the flip uses the FULL bitmap height (H=3*frameH for btemode), so selecting source band [k*fH, (k+1)*fH-1] grabs the authored frame (2-k); for btebus (5x1, H==frameH) the band is always the whole bitmap and the within-frame bottom-up read cancels against the bottom-up dest write, rendering identically to a top-down engine — a y-origin drift only a rows>1 strip can expose, exactly as hypothesized",
   "evidence": "Follows arithmetically from the proven blit math @0046bdfc: for H==fH, sy1=0,sy2=fH-1 -> src band = entire bitmap regardless of orientation convention; upright rendering guaranteed because source AND dest both walk backward (disasm 0x46befd-0x46bf14: source -=pitch*2, dest -=[this+0x1c]*4 and -=[this+0x18]*2 per row). Frame-selection difference only occurs when sy1 != 0 is possible, i.e. columns==1 strips with >1 row — btemode is the port's only such widget"
  }
 ]
}
{
 "claim": "The ENG-page connect-mode lamp (btemode.pcc) inversion observed in the port (level 0 shows AUTO, level 2 shows OFF) is caused by a y-origin drift between the 1995 engine's blit and the port's — either the 1995 blit addressed source rows bottom-up while the port's is top-down (a), the strips are authored bottom-up and both engines read top-down so the pod was also wrong (b), or an overlooked remap in the binary's widget code (c).",
 "verdict": "MECHANISM (d) — NONE OF (a)/(b)/(c). There is NO code inversion anywhere in the current build: the 1995 engine and the WinTesla port implement the IDENTICAL bottom-up source-rect convention, the artwork is authored to match it, and the compiled port provably draws level 0=OFF / 1=MANUAL / 2=AUTO — the pod-correct display. The faulty link is the runtime OBSERVATION itself, almost certainly a phase misattribution of the 2<->0 toggle alternation. Minimal faithful fix: NO geometry change — in particular DO NOT apply the proposed sy0=(numFrames-1-row)*frameHeight flip (on this engine it would CREATE the level0=AUTO inversion it claims to cure). Keep today's colour fix as-is. Settling measurement below.",
 "reasoning": "The investigators DISAGREED: Inv 1 (port source, T0) proved the port blit is bottom-up and cannot invert; Inv 2 and 5 assumed the port is top-down and prescribed a row flip. I re-verified every hop myself, adjudicating for Inv 1. (1) PORT CHAIN (all re-read): widget sy0=row*45 with frameHeight=135/3 (game/reconstructed/btl4gaug.cpp:1599-1606,1655-1669); GraphicsView::DrawBitMapOpaque and ClipImage pass source y through with zero transform (engine/MUNGA/GRAPH2D.cpp:2518-2562,3024-3103); L4GraphicsPort::DrawBitMapOpaque forwards sLeft/sBottom/sRight/sTop verbatim (engine/MUNGA_L4/L4VB16.cpp:6713-6768); Video16BitBuffered::DrawBitMapOpaque flips source y bottom-up — sTop=map_max_y-sTop, sBottom=map_max_y-sBottom, source_pointer=MapPointer+sBottom*WidthInWords, UP_BITMAP walking toward row 0 (L4VB16.cpp:3846-3948); PCXRead+BitMap ctor store scanlines strictly in file order, memory row 0 = top scanline (GRAPH2D.cpp:27-174,274-338); repo-wide grep confirms no alternate DrawBitMapOpaque implementation. Concrete numbers: level 0 -> sy0=0..44 -> converted rows 134..90 = BOTTOM 45 memory rows, drawn upright. (2) SAMPLER: StateConnection::Update raw-reads alarm+0x14 (btl4gaug.cpp:1804-1807) — a databinding-trap-style read, but verified benign: GaugeAlarm54 (heat.hpp:93-139) has no virtuals, layout char _hdr[0x0c]/levelA@0x0c/levelB@0x10/level@0x14, and modeAlarm is static_assert-locked at PoweredSubsystem+0x2B8 (powersub.hpp:298,313), so `selected` = the CURRENT level — the one-step-lag (oldState) theory is dead. (3) ARTWORK (the pivot — decoded MYSELF with a hand-written PCX RLE decoder, no library, memory row 0 rendered at PNG top, then read visually): content/GAUGE/BTEMODE.PCC (the only copy in the repo) is 90x135, file/memory order TOP=AUTO(361 ink px), MIDDLE=MANUAL(593), BOTTOM=OFF(279) — confirming the prior decodes. (4) 1995 SIDE (re-disassembled @0x46be1f via tools/disas2.py): srcRow=(Size.y@+0x10 -1)-sy times WidthInWords@+0x14, base MapPointer@+0x18, dest flipped via [this+0x28] — the identical bottom-up flip, so the pod showed level0=OFF: matches veteran testimony. (b) also fails independently: a top-down 1995 read of top-first art would have shown the pod level0=AUTO, contradicting testimony (and a bottom-first 1995 loader would have rendered all bitmap text upside down). (c) stands refuted per Inv 3's vtable walk — the only States override is the negative clamp @0x4c552c chaining base Execute @0x4c4f28. (5) BUILD=SOURCE: build/Release/btl4.exe (2026-07-28 12:21) postdates the gauge sources (12:20) whose only uncommitted delta is the colour fix (git diff verified — parameters added, 0xff/0 passed; row math untouched, and it has been identity since a538d64 anyway). Therefore the running build CANNOT display level 0 as AUTO through this path. (6) THE OBSERVATION: content/solo_20260728.log (12:34, same build) records the actual session — rapid [gensel] toggling with levels alternating 2,0,2,0 (lines 2437-2653, 8443-8704), no record of what the lamp displayed. From spawn (level 1) the cycle is MANUAL -> press -> AUTO(2) -> press -> OFF(0) -> press -> AUTO(2)...; a correct lamp and a truly inverted lamp produce the SAME alternating AUTO/OFF sequence differing only in phase, so a tester who believes the first press detaches reads a correct lamp as 'level 0 = AUTO, level 2 = OFF' — exactly the reported symptom. SETTLING MEASUREMENT: fresh spawn (lamp reads MANUAL — phase-uninformative since the middle frame is inversion-invariant), then press BUS-MODE exactly ONCE with BT_FIRE_LOG set: the log must print '[gensel] ... mode -> 2' and the lamp must simultaneously read AUTO; and/or add a one-line DEBUG_STREAM in OneOfSeveral::Execute printing selected/sy0 at draw. Secondary witness: give OneOfSeveralInt its binary-verified 0xff/0 colours (decomp part_014.c:1479-1481) so btecmode (1x2 vertical, coolantAvailable) becomes visible — under this verdict it will show ON when coolantAvailable=1, i.e. NOT inverted, directly falsifying Inv 5's prediction if I am wrong. Side notes: Inv 1's fg/bg parameter-naming swap through the view layer is real but cannot affect frame selection; Inv 3's correction stands (0x4c552c is Execute, mislabeled BecameActive in the port). Files: C:\\git\\bt411\\game\\reconstructed\\btl4gaug.cpp, C:\\git\\bt411\\game\\reconstructed\\btl4gau2.cpp, C:\\git\\bt411\\game\\reconstructed\\heat.hpp, C:\\git\\bt411\\game\\reconstructed\\powersub.hpp, C:\\git\\bt411\\game\\reconstructed\\powersub.cpp, C:\\git\\bt411\\engine\\MUNGA\\GRAPH2D.cpp, C:\\git\\bt411\\engine\\MUNGA_L4\\L4VB16.cpp, C:\\git\\bt411\\content\\GAUGE\\BTEMODE.PCC, C:\\git\\bt411\\content\\solo_20260728.log."
}
"1. THE MECHANISM — there is no code inversion; level 0 does NOT draw AUTO in the compiled build.\n\nProven chain (every hop re-verified):\n- Widget math: `columns==1 -> row=selected; sy0=row*frameHeight` — `game/reconstructed/btl4gaug.cpp:1655-1669` [T0], identical to the binary's Execute @004c4f28 [T1, disassembled]. The only OneOfSeveralStates override @0x4c552c is a negative-clamp-then-chain (it IS Execute; the port's \"BecameActive\" label is wrong — fix the comment).\n- Sampler: reads the CURRENT level at alarm+0x14 (`btl4gaug.cpp:1804-1807`; GaugeAlarm54 layout has no virtuals, `heat.hpp:93-139`, static_assert-locked at PoweredSubsystem+0x2B8, `powersub.hpp:298,313`). Level semantics proven: 0=OFF, 1=MANUAL, 2=AUTO.\n- Engine blit source-y is BOTTOM-UP: `Video16BitBuffered::DrawBitMapOpaque`, `engine/MUNGA_L4/L4VB16.cpp:3846-3850` (`sTop = map_max_y - sTop; sBottom = map_max_y - sBottom`), rows walked bottom-to-top (comment 3899-3901, `UP_BITMAP` :2155). No layer above transforms sy (`engine/MUNGA/GRAPH2D.cpp:2519-2562`, ClipImage :3024-3103; `L4VB16.cpp:6714-6768`), and no alternate DrawBitMapOpaque exists in the repo [T0].\n- Loader stores the PCX top scanline at memory row 0 (`GRAPH2D.cpp:27-174, 274-338`); present is un-flipped top-down (`L4VB16.cpp:701-739`, `L4GLASSWIN.cpp:442`) [T0].\n- Artwork: BTEMODE.PCC decodes top=AUTO / mid=MANUAL / bottom=OFF; vertical strips are authored bottom-up, horizontals left-to-right [T1, decoded].\n- Net: sy0=0 selects the BOTTOM authored frame -> level 0=OFF, 1=MANUAL, 2=AUTO. The 1995 display blit @0046bdfc does the identical bottom-up flip [T1, disassembled], so pod and port agree — matching veteran testimony.\n\nInferred part (flagged): the \"inversion\" was the observation itself. `content/solo_20260728.log` shows the BUS-MODE cycle alternating levels 2,0,2,0 from spawn (level 1=MANUAL); a correct lamp and an inverted lamp produce the same AUTO/OFF alternation differing only in phase. A tester assuming press-one detaches reads a correct lamp as \"level 0 = AUTO\". Today's uncommitted colour fix is the FIRST build in which btemode drew at all (it was colour-0-on-colour-0 since dc6af8c, unbuilt before that), so there was no prior baseline. The log records levels, not lamp contents — this is best-explanation [T3], everything above it is T0/T1.\n\n2. THE FIX — no geometry change at any layer.\n- Do NOT apply `sy0=(numFrames-1-row)*frameHeight` (or any loader/blit flip). On this engine it would CREATE the level0=AUTO inversion and simultaneously break btecmode/bteseek. Horizontal strips (btebus/btpbus, rows==1) are insensitive either way.\n- Keep the working-tree colour fix (0xff/0, byte-verified vs the binary call site @004c866c). Commit it.\n- Settle it empirically: fresh spawn, press BUS-MODE exactly once with BT_FIRE_LOG set — the log must print `[gensel] ... mode -> 2` while the lamp reads AUTO. Optionally one `DEBUG_STREAM` line in `OneOfSeveral::Execute` printing selected/sy0.\n- Land the same colour fix for OneOfSeveralInt (`btl4gaug.cpp:1920-1930` still hardcodes 0,0; the binary passes 0xff,0 — `reference/decomp/all/part_014.c:1479-1481`). This makes btecmode (1x2) and bteseek (1x4) visible and gives a second vertical witness: btecmode must show ON when coolantAvailable=1. If it shows OFF instead, the verdict is falsified — that is the tripwire.\n\n3. RISK — multi-row strips in the codebase:\n- Hardwired BitMap strips: btemode (1x3), btecmode (1x2), bteseek (1x4, two call sites in `btl4gau2.cpp`). All go through the verified bottom-up path; correct as-is once visible.\n- CFG-built oneOfSeveralPixInt lamps (bduck 3x1, blamp 2x1, sdspmod 3x1, smode 3x1 in `content/GAUGE/L4GAUGE.CFG`) are single-row horizontal — y-convention-insensitive, unaffected.\n- Residual [T3]: the PixMap8 branch (`DrawPixelMap8`, btl4gaug.cpp:1675) was not traced for its source-y convention; no vertical PixMap8 strip exists today, but verify before adding one.\n- Adjacent, colour-only: fg/bg arrive at Video16BitBuffered swapped relative to their parameter names (`btl4gaug.cpp:1680-1682` -> `GRAPH2D.cpp:2538-2540` -> `L4VB16.cpp:6757-6760`). Cannot affect frame selection; do not \"correct\" the naming without re-checking today's colour values against actual arrival order.\n\n4. THE \"THREE STATES ONE BLANK\" MEMORY — settled: it cannot be btemode. Btemode was never constructed before dc6af8c (07-07, gate stubbed False), then invisible until today's colour fix; all three PCC frames carry ink (361/593/279 px), and the writers only ever emit 0..2 onto a 3-row strip — a blank frame was never reachable. Best-fit candidate [T4, interpretation]: the btebus generator-letter lamp between e634709 (07-21, first visible) and e26f4e6 (07-27) — the third BUS-MODE press detached to level 0 (frame 0) and Gitea #62 made re-attach unreachable, so the lamp stuck there; the #62 fix is \"the later change that altered it\". Alternative: a memory of the 1995 pod, whose dim 279-px OFF frame reads near-blank beside MANUAL.\n\nKnowledge-base follow-ups: correct the 0x4c552c label in `btl4gaug.cpp`, and record the bottom-up source-rect convention + bottom-up vertical artwork convention in `context/gauges-hud.md` (cite L4VB16.cpp:3846-3850 and @0046bdfc) so the flip is never \"fixed\" again."