a7bcf1cbabc2549d984663be29c2dcb888fc7bbc
313
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8408165d92 |
Find content\ ourselves: a bare exe launch no longer scatters config and dies
Field report: `.\btl4.exe -fit` from build\Release opens the mission console,
then the mission crashes on Launch.
ROOT CAUSE (pre-existing landmine, newly reachable). The engine resolves
BTL4.RES, VIDEO\, BTDPL.INI, the eggs AND both player config files
(bindings.txt, environ.ini) RELATIVE TO CWD. Every launcher .bat does
`cd content` first, so this never showed. A bare launch of the exe therefore
found no resources ("Resource file btl4.res v1.0.0.0 is obsolete!"), wrote a
stray bindings.txt / environ.ini / btl4.log NEXT TO THE EXE -- so the player's
real ones in content\ looked like they had never been created -- and killed the
mission generation the menu launched (the child inherits the parent's cwd).
Recent work made that path inviting rather than obscure: glass is the desktop
default, a zero-arg launch opens the menu, and -fit is documented as something
you pass on the command line.
FIX: BTEnsureContentDirectory() runs before anything resolves a relative path
(the log file included, so a bare launch logs where the launchers log). If cwd
has no BTL4.RES, probe from the exe's own directory -- ..\..\content for the
shipped layout, plus the flattened and in-content cases -- and
SetCurrentDirectory there. Says so in the boot line when it had to go looking.
Belt and braces: the environ.ini writer refuses to write unless BTL4.RES is
beside it, so a failed probe cannot scatter a settings file either.
ALSO: -fit was silently dropped when the menu relaunched into a mission. The
front end rebuilds the child's command line from scratch, and the flag was
parsed at window creation -- code the menu process exits long before reaching.
Parsed early into gBTFitDisplay now, and appended to the relaunch.
Verified: bare `btl4.exe -fit` from build\Release leaves ZERO files next to the
exe, writes environ.ini + btl4.log into content\, and the mission child's exact
command line (-net 1501 -platform glass) from that same wrong cwd now logs
"cwd: found the content directory from the exe path", loads resources and
enters RunMissions instead of dying.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
1debbeb240 |
Mech::DuckRequest -- the CROUCH button, and a dead-button census that lied
The 2026-07-20 audit listed 8 panel buttons dispatching a streamed message with
no reconstructed handler. Only ONE was still missing: generator on/off,
ToggleSeekVoltage, EjectAmmo, ToggleCooling and BalanceCoolant had all landed
between 07-20 and 07-25 while pod-hardware.md and open-questions.md still called
them dead. docs/INPUT_PATH_AUDIT.md had already flagged the census as "stale in
both directions" and was right -- swept both files, and recorded the rule: check
the code, not the census.
DuckRequest @0049fa00 (id 0x1a, RIO 0x13 -- the manual gives crouch a whole
section). The binary's entire body:
if (0 < *(int *)(param_2 + 0xc)) { *(undefined4 *)(param_1 + 0x398) = 1; }
Press-only; sets duckState (mech+0x398, attribute 0x37). A one-shot REQUEST
flag, not a posture toggle -- the handler never clears it and the only other
writer in the whole binary is the mech reset (part_012.c:9439, the same reset
that zeroes incomingLock, which is how that region was already mapped).
WHY THE FLAG HAS NO READER, AND WHY THAT IS CORRECT. duckState has ZERO readers
anywhere in the decomp, because it is published as an ATTRIBUTE and consumed
through DATABINDING: content/GAUGE/L4GAUGE.CFG runs a 3-frame bduck.pcc
oneOfSeveralPixInt bound to DuckState -- "crouch mode: button 4" on the map's
legend column. Verified live by capturing the Secondary/Radar window before and
after a 0x13 press: the crouch icon goes grey -> orange. So the handler is
COMPLETE. A crouch pose invented here would be a stand-in for data we have not
found (no SQUAT clip name survives in the decomp or in content/, only
DuckServo01.wav in AUDIO1.RES).
Bonus: those gauge widgets sit at offsets 537/430/322/215/108 -- a 107 pitch,
independently corroborating the map legend grid measured from pixels.
Still open from that census: MechRIOMapper's own Keypress @004d2514 (id 0x19).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
61563c9efe |
Glass cockpit refit: one mode resolver, one button geometry, and a cockpit that scales
Replicates the RP412 cockpit line into BT411's own architecture -- porting the geometry and keeping our renderers, so BT_SHOT single-frame verification stays intact. MODE RESOLVER. Where the secondary displays go was decided in TWO places with duplicated precedence, and the boot banner read NEITHER -- it announced "per-display cockpit windows" for every glass boot, surround included. The split had also broken BT_COCKPIT=0: documented as the dock-bottom opt-out, the profile block converted it to BT_GLASS_PANELS=1, so the docked strip was UNREACHABLE under the glass profile. One resolver now, consumed by the banner, the pad-panel decision and the window sizing; BT_GLASS_PANELS is explicit-only; dock/window modes auto-raise BT_PAD_PANEL so the button field always has a home. L4RIOBANK -- one button geometry. Both renderers carried their own copy and had drifted: an MFD button was 156x138 reaching under the glass in the exploded window and a 76x24 sliver entirely OUTSIDE the glass in the surround. One module owns it now, both are consumers, placement stays per-renderer. The pod's under-glass rule (RP412 L4MFDVIEW): reach half the glass in behind the display, leave a lamp strip clearing the edge, paint buttons first and imagery over -- so the lamp reads as a bar and practically the whole display is the press target. The strip scales off the display's SHORT axis (the map is portrait) with a readable floor. Retired L4GLASSWIN's three local placers and seven layout constants. LAMP FLASH DECODE was wrong [T1]. BTLampBrightnessOf returned max(state1, state2) and blanked on the alternate phase; RIO::LampState (L4RIO.h [T0]) says solid shows state 1 and flashing ALTERNATES the two. Agrees only when one state is Off -- true for the Panic lamp, which is why it survived -- but L4LAMP.cpp:252 commands flashFast + state1Dim + state2Bright, a dim->bright pulse that rendered as a hard bright->off blink. Three copies existed (l4vb16.h, L4GLASSWIN, L4PADPANEL), all three wrong; the two locals now forward to the one fixed inline. THE COCKPIT SCALES. The fixed canvas was stretched into whatever the client area was, so a window dragged to a different shape squashed the instruments (the projection was aspect-corrected in task #20; the panels never were). Now one uniform scale, centred, leftover black. D3D9 applies it as a Present destination rect, which DISCARD forbids -- so the WINDOWED swap effect becomes COPY when the surround is up and multisampling is off. The click mapping had to follow (mapping against the full client drifts the hit test off every button by the bar width), as did the world aspect (under a uniform scale it is the view rect's own). -fit / -windowed-fullscreen: borderless over the monitor. - ordering trap: the first WM_SIZE beats the device, so a -fit boot logged aspect=3.14 and applied it on frame 1. The letterbox INTENT is decided in btl4main; L4VIDEO only confirms or withdraws it. PLAYER-TUNABLE DISPLAYS. BT_MFD_SCALE (+ _UL/_UC/_UR/_LL/_LR), BT_RADAR_SCALE, BT_RADAR_POS (CENTER/LEFT/RIGHT/MIDLEFT/MIDRIGHT). The surround BANDS derive from the resolved sizes -- that is why the sizes could not stay constants: the band a display hangs in has to grow with it or the canvas clips it. 100% reproduces the historical L276 R276 T223 B336 exactly. A corner map goes flush to the CANVAS edge and the lower MFD slides beside it (measuring off the view edge overlapped them by 232px). MAP LEGEND GRID -- measured, not inherited. scratchpad/measurelegend.py over a native capture: top 3, cell 102, pitch 107 of 640. RP412's map is 13 + 6x102 @ 105 -- same cell height, different top and pitch, so its numbers do NOT transfer. Our old even division had the pitch right by luck and sat 3px high of the labels. environ.ini. It was read ~300 lines into WinMain, AFTER the platform-profile block had run its getenv()s -- so every setting the profile reads was silently ignored FROM THE FILE and only worked as a real env var. It also putenv()'d comments verbatim. Now loaded immediately after the first-breath line, comments skipped, the real environment WINS over the file, and a fully documented default is written on first run (the bindings.txt convention: untracked, so extract-over-top never clobbers a player's settings). VERIFICATION HARNESS (new, reusable): BT_RIOBANK_LOG=1 dumps every bank; checkbank.py proves no address is SHADOWED (an address whose rect is covered by earlier buttons is dead however big it looks -- the overlapping under-glass banks make that a live hazard); clickbank.py posts a real click at every button centre. Verified: 72/72 placed, 0 shadowed, 72/72 dispatched in BOTH modes, after a resize, at 150%/135%, and at 75%+BOTTOMRIGHT; wide/tall drags and -fit undistorted on a 3440x1440; exploded/dock/pod/dev un-regressed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
48d47ef806 |
#45 review pass: correct three claims the fix's own comments got wrong
An adversarial review of the change returned NO BLOCKERS but caught three
statements that were wrong or unverifiable. All three are corrections to my own
comments/docs, not behaviour changes -- the verified binary is unchanged.
1. The phantom-kill note had the ORDERING backwards. It claimed the owner's
record "overwrites it on the next record -- the phantom stops being visible".
The engine's event priorities run the other way: an inbound update record is
posted at UpdateEventPriority == MaxEventPriority and drained by
ExecuteBackgroundTask, while the rerouted score message sits at
EntityManagerEventPriority == DefaultEventPriority and is popped later -- so
the correction usually lands FIRST and the phantom AFTER it. The artifact is
BOUNDED, not masked: on the killer's pod only, that victim's KILLS reads +1
until the next record, i.e. <= one 2s heartbeat instead of "until the victim
next scores or dies". Swept in btplayer.cpp, KD_SCOREBOARD_PLAN.md and
RECONCILE.md.
2. The corpus figures were not reproducible, and were mis-tiered. I cited
"125 of 125 SCORE type=2 over 255 node-logs" and "0 of 8800 DMG rows" -- true
when measured, but the corpus is append-only and this fix's OWN verification
runs grew it from ~255 to 425 files, so nobody can re-derive them. Replaced
everywhere with invariant RATIOS that hold at any corpus size, re-measured
here:
* 0 of 18818 DMG rows are inst=R (damage is applied master-side only)
* 439 of 439 DEATH inst=R rows read killer=0:0 killdmg=0.000, against
0 of 225 inst=M rows (sole lastInflictingID writer: mech.cpp:746)
Also re-tagged T1 -> T2: log-corpus field evidence is T2 per the CLAUDE.md
tier table. Lesson recorded in the banner: cite ratios, not counts, for a
corpus your own rigs append to.
3. The recordLength guard's justification was wrong (and the plan's risk 5 with
it). A short legacy record does NOT desync the stream -- both sides advance
by the WRITER's stamped recordLength and no reader asserts a length. The real
hazard is a read PAST THE ALLOCATION: an inbound update message lives in a
per-event heap block sized from messageLength, so on a TRAILING legacy record
the two new fields would be read off the end of it.
Plus: the record struct's own field comment still said "deathCount -- the DEATHS
column", the exact wrong-field belief #45 removed, sitting on the wire struct.
Filed the review's other finding on #60: a SECOND decomp export gap, index
jumping FUN_0049fe80 (ends 0x49ffc8) -> FUN_004a1232, 4714 unexported bytes
containing Mech::TakeDamageMessageHandler @0x4a0230 -- the producer of every
score and death message in the game. Verified independently against
functions_index.tsv before posting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a52207d779 |
#45 scoreboard: reclaim the binary's DEATHS field, add a heartbeat, sweep the false KB claims
Follow-up to
|
||
|
|
4fa7eee54f |
K/D scoreboard ROOT-CAUSED for real (#45): the kill credit was rerouted, not lost
Kills/deaths only ever appeared on ONE machine. The cause is not a missing
tally -- it is Entity::Dispatch:
if (GetInstance() == ReplicantInstance) // ENTITY.cpp:244-251
application->SendMessage(ownerID, EntityManagerClientID, message);
BTPostKillScore runs on the VICTIM's node (the only node whose mech carries a
populated lastInflictingID), resolves the killer's Player -- a REPLICANT there --
and Dispatch()es to it. The engine reroutes that to the owning host, so
++killCount lands on the killer's own PC and nowhere else, and nothing carried it
back out: Player__UpdateRecord is currentScore + dropZoneLocation only. Every
other pod's copy therefore read 0 all mission. playerLink was never NULL for
these kills -- the dispatch proves it resolved.
Field proof over the whole corpus (255 node-logs): 125 of 125 SCORE type=2 rows
credit the LOGGING node's own player, not one credits a remote pilot; and 0 of
8800 DMG rows target a replicant, so no other node can even know the killer.
This means the owner's counter is already the single authoritative copy,
incremented exactly once per kill. So the "design decision" the plan was blocked
on collapses: there is no second writer to reconcile, only a one-way
owner->replicant mirror to add.
* BTPlayer__UpdateRecord = Player::UpdateRecord + killTally + deathTally, with
Read/WriteUpdateRecord overrides. No new data member, no new virtual ->
sizeof(BTPlayer)==0x28c and every existing offset lock still holds.
* Both counter writes already ForceUpdate() (:508 death, :829/:840 score), so
no dirty-bit edit was needed (plan risk 6 avoided).
* recordLength guard in ReadUpdateRecord: a pod on a build without the
extension degrades to "remote counters don't move" instead of reading a
neighbouring record as a kill count.
* Size locks added per plan risk 2 (no false offsetof assert).
Rig-verified, 2-node loopback (scratchpad/rig45_up.ps1, BT_MP_FORCE_DMG):
owner 3:1 finished kills 3/deaths 2 and the peer read kills=3 deaths=2; owner 2:1
finished kills 2/deaths 3 and the peer read kills=2 deaths=3. Exact convergence
where a remote column previously never left 0. 3 respawns per side with intact
death sequences (deathCount is only overwritten on replicant copies; the
handshake runs on masters), no crash, no GLITCH rows.
Kept byte-faithful: the kill handler's partner increment (the binary's
inc [ebx+0x27c] / inc [edx+0x27c] wrong-column slip) is still reproduced. It
always lands on a replicant copy, so the owner's record now overwrites it -- the
rig caught the correction repeatedly (wasKills=3 -> kills=2). The phantom kill
stops being visible without silently deciding the fidelity question.
Still open and deliberately untouched: which field the LOCAL pod's DEATHS column
should read (+0x280 vs deathCount), last-hitter-takes-all/ram kills, and the
slip's fidelity. Awaiting live multi-pod verification by a human.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
373889760c |
#66: joyconfig.bat no longer hangs the game -- clear BT_JOYCONFIG once the wizard is done
Found while sweeping the shipped launchers for the PATH-shadowing bug class.
Previously recorded ONLY as row 10 of docs/INPUT_PATH_AUDIT.md and never
filed, so it was invisible on the tracker.
joyconfig.bat sets BOTH `BT_JOYCONFIG=1` and `BT_FE_SOLO=1`, so after the
wizard writes bindings.txt the boot continues into the FRONT END, which hands
off by CreateProcessW()ing the next generation with the CURRENT environment.
That clear-list (btl4console.cpp) covers only BT_FE_EGG/PODS/SECS/LOOP --
BT_JOYCONFIG is never cleared -- so the handed-off generation re-entered
BTJoyConfigWizard() and blocked on _getch() BEHIND the 3D window: an
unrecoverable hang, hitting exactly the players most likely to run that bat
(flight stick / HOTAS / pedals).
Fix: SetEnvironmentVariableA("BT_JOYCONFIG", NULL) immediately after the
wizard returns. Cleared in the WIN32 environment (what CreateProcess
inherits, matching the console's own pattern) rather than the CRT copy, so
exactly one generation ever runs it. Chosen over extending the console's
clear-list because it also covers any future relaunch path.
NOT YET REBUILT: the exe was locked by a live play session when this landed.
Must be rebuilt + verified (run joyconfig.bat, finish the wizard, confirm no
second "=== BattleTech joystick setup ===" banner and that the game proceeds
into the menu) before the next zip is cut.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
f3179ef020 |
#47 follow-up: restore the DAMAGE TIER to the heat branch -- HeatableSubsystem::GetStatusFlags was a stub returning 0
Found while auditing today's vtable change before release. The binary's
HeatSink::GetStatusFlags @004add30 OPENS with `call 0x4ac144` --
MechSubsystem::GetStatusFlags, the damage tier (damageLevel >= 1.0 -> bit 0
Destroyed; > 0 -> bit 1 Damaged). There is no zero-returning
HeatableSubsystem override anywhere in the image.
Our port routed that call through `HeatableSubsystem::GetStatusFlags()
{ return 0; }` -- a "neutral default" that silently dropped bits 0 and 1 for
the ENTIRE heat branch (every weapon, sensor, emitter, PPC, generator,
powered subsystem). Two live consequences:
* Destroyed / Damaged could never reach MechTech's annunciator scan --
gutting half the shipped mechalrm table (Destroyed -> gotoEngineering +
engCooling + engBusMode) hours after #47 shipped the flash;
* PoweredSubsystem's AutoConnect gate (`GetStatusFlags() == 0`,
powersub.cpp:358) read a DAMAGED subsystem as pristine.
One-line fix: chain the real tier, reproducing @004add30 exactly. The call
site's own comment already said `// FUN_004ac144` -- the implementation had
simply never matched it.
Also corrected a stale comment on MechSubsystem::GetStatusFlags that read the
field as a STRUCTURE level ("1.0 = intact, 0 = dead") -- backwards. The
engine member is damageLevel and ACCUMULATES to 1.0 = destroyed (mechdmg's
crit cascade tests `level >= StructureMax` for exactly that), which is what
makes bits 0/1 line up with TechStatusType Destroyed/Damaged. Behaviour was
always right; the comment was a leftover of the same structureLevel misreading
#46 retired.
VERIFIED (3-pod combat sim, BT_LAMP_LOG): the annunciator is genuinely live --
Jammed x7, AmmoBurning x5, BadPower x20, Overheating x207 (Overheating
correctly maps to NO lamp: the shipped table has no entry for it). The lamp
resolution is per-subsystem as designed -- different weapons flash their own
panel buttons (lamps 0xf/0x5/0xb/0x3 under distinct mode masks), 32 flash
writes across a whole battle, no thrash. Zero crashes, zero plane/OOB
tripwire hits across all three pods.
FILED, deliberately NOT fixed tonight (open-questions.md): vtable slot +0x40
is misattributed. The binary calls it `(this, 0)` in AutoConnect's outer gate
and `(this, candidateGenerator)` in the roster walk -- a one-arg voltage query
(HasVoltage(source)), not GetStatusFlags(). Modelling both as the no-arg
GetStatusFlags makes the outer gate demand "no flags" and the inner "some
flag" with nothing between them, so AutoConnect's loop body can never execute.
Pre-existing before and after this change; it is a live power-bus behaviour
change and deserves its own playtest cycle, not a release-eve patch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
6bb03aed0b |
Gitea #48 ROOT-CAUSED + FIXED: MFD "stray blocks / misplaced lamps" = uninitialized translation-table entries leaking pixels into other displays' bit-planes
The conviction was empirical, not theoretical: a new write-site trap
(BT_PLANE_AUDIT) in the seven L4VB16 drawing primitives logs any draw whose
color carries bits outside its port's plane mask -- the exact cross-display
corruption condition (Replace ORs an unmasked color; Or/Xor ignore the mask
entirely; And clears foreign planes). A quiet solo session produced 15-30
leaks per minute:
[plane] PORT 'sec' pixmap draw at(0,0) idx 217 entry=0xffffff00 mask=0x3f
[plane] LEAK DrawPixelMap8[table] at(639,0) color=0xffffff00 mask=0x3f leak=0xff00
THE DEFECT: L4GraphicsPort::translationTable[256] is never initialized in the
ctor, and BuildSecondaryTranslation fills only the entries its BitWrangler
reaches -- 2^numberOfBits: 64 for the sec plane (mask 0x3F), 4 for the overlay
(0xC0). Entries above that stay heap garbage. Every draw resolves color
through this table, and the PIXMAP path indexes it with raw pixel values
0..255: the 480x640 radar background carries pixel index 217, whose garbage
entry's high bits (0xFF00 = ALL EIGHT MFD planes) were stamped into the shared
640x480 buffer -- invisible on the culprit page (the in-plane bits happened
dark) and visible as bright fragments at the same coordinates on every OTHER
display. That is the operator's screenshot exactly: the same-position blips
on Mfd1+Mfd2 and the Heat-display block.
FIX (engine, both layers):
* zero translationTable in the L4GraphicsPort ctor (plane-neutral default);
* BuildSecondaryTranslation now cycles the in-plane pattern across entries
[2^bits..255] -- high-index art degrades to its (index mod 2^bits) colour
IN-PLANE and can never leak. The 1995 binary ships the SAME 64-entry fill
and survived on 6-bit art discipline; garbage is not a preservable
behaviour, so the cycle-fill is a guarded PORT deviation (documented).
A/B PROOF: same probe, 60s -- 0 leaks after the fix (15-30/min before).
sim3 3-pod regression: zero crashes.
SECOND real defect found + fixed en route: sessions configure the CAMERA seat
first ("cameraInit" -- which includes the MISSION-REVIEW context:
configure(0, sec, 0, 0x00FF, native, rgb, mrpal.pcc), a DirectColor context
whose translation tables legitimately hold full RGB565 values spanning all 16
plane bits), and the viewpoint swap to the mech re-configured WITHOUT tearing
that tree down (btl4app's s_gaugeTreeBuilt latch treats the mech build as the
first). The orphaned review gauges kept executing through stale DirectColor
ports. BTL4GaugeRenderer::ConfigureForModel now overrides (ConfigureForModel
made virtual in L4GREND.h) and tears the prior entity-bound tree down before
every rebuild -- safe on first build, and the review screen rebuilds the same
way when the seat returns to the camera.
Also logged (open-questions): the review-screen PlayerStatus panels read the
compiled player at RAW BINARY OFFSETS (+0x1FC vehicle / +0x1C8 score /
+0x1C4 alive-dead box) -- the databinding trap, dormant until the review
screen runs; bridge before enabling the review.
Ruled out along the way (with evidence): the pilot-list label/erase geometry
(erase box 128x32 covers the 64x16 name rasters), the radar name labels
(view-level ClipImage clips them), PlayerStatus in-game execution ([ps] probe:
never runs in-game), and the PNAME bitmap dimensions (egg generator emits
64x16 exactly).
Why it "started with the comms feature" (#43): that wave registered the new
gauge classes with the config interpreter, letting more of the authored page
furniture parse and draw than before -- the leaking high-index pixmaps rode in
with it. [T3 correlation detail; the leak + fix are T2 live-verified.]
Diagnostics kept (env-gated): BT_PLANE_AUDIT (the leak trap, now a permanent
regression tripwire), BT_PS_LOG, the port-level pixmap identity trap.
KB: gauges-hud.md (#48 section), decomp-reference.md (BT_PLANE_AUDIT),
open-questions.md (PlayerStatus databinding entry). checkctx CLEAN.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
6f5a264835 |
Gitea #47 COMPLETE: the ENG-button attention FLASH is live (jam / bay fire) -- MechTech status scan + BTL4GaugeAlarmManager + lamp chain, all from the binary
The pod behaviour Cyd described -- "on a jam or bay fire the display eng button
for the system flashes" -- is authored data + a five-stage chain, now running:
MechTech::TechnicalAssistance (@004ad33c, per frame)
edge-scans every monitored subsystem's GetStatusFlags() 7-bit condition
mask (TechStatusType: Destroyed 0, Damaged 1, CoolantLeaking 2,
Overheating 3, AmmoBurning 4, Jammed 5, BadPower 6)
-> Start/StopEntityAlarmMessage (@00436688 id 7 size 0x20 / @004366b8 id 8
size 0x1C; broadcast @004364e4; port shape: direct
Start/StopEntityAlarmImplementation calls on the gauge renderer)
-> GaugeAlarmManager::Activate(alarmModel) -- alarmModel = MechTech+0x100 =
the 'mechalrm' ModelList (id 83) -> SearchList(83, type 31) -> the baked
GaugeAlarmStream (id 331, 11 items {condition, lampCode}), decoded:
Destroyed -> gotoEngineering + engCooling + engBusMode
CoolantLeaking -> gotoEngineering + engCooling
AmmoBurning -> gotoEngineering + engEject
Jammed -> gotoEngineering + engEject
BadPower -> gotoEngineering + engBusMode
-> BTL4GaugeAlarmManager::ReadGaugeAlarmStreamItem -- THE REAL BODY, from
@004cc2fc + helpers @004cc108/148/1a0/264/27c + tables @0051cf1c..0x51d084
(gotoEngineering 0x80 = the subsystem's QUAD-SELECT bezel button via
lamp[aux]/mode[aux] tables; 0x81+ descend the eng-page bank; Condenser /
Generator specials on CoolantLeaking; <0x80 = the heat-bank fixed map).
btl4galm.cpp's old bodies were admitted fabrications and its provenance
note ("no override body exists in the image") was wrong -- corrected.
-> LampManager::FindLamp (@00444c80) -> Lamp::SetAlertState (@00444e64, a
COUNTER so stacked alarms hold the flash) -> L4Lamp::NotifyOfStateChange
emits RIO flashFast states 0x37/0x13 (== T0 L4LAMP.cpp:234-239) -> the
pod's physical lamps AND the glass panels (PadRIO IS rioPointer there).
FOUR load-bearing defects found and fixed en route -- each independently fatal
to the feature:
1. HeatableSubsystem's Derivation chained Subsystem directly, SKIPPING
MechSubsystem (written before the WAVE-1 re-basing) -- so EVERY subsystem
on both family branches failed IsDerivedFrom(MechSubsystem), which is
exactly MechTech's monitor filter: the status scan watched NOTHING.
2. MechSubsystem::GetStatusFlags was non-virtual (binary: vtable slot 12) --
the scan's MechSubsystem* call bound statically to the base tier and the
weapon bits could never surface.
3. ProjectileWeapon::GetStatusFlags sat in a Ghidra export gap -- raw-disasm
@004bbf88: base | 0x20 (weaponAlarm==5 Jammed) | 0x10 (linked bin
cookOffArmed@0x18C AmmoBurning). The port had "defer to base".
4. Mech::Reset's respawn sweep blanket-cast every roster entry to
MechSubsystem and called RespawnRepair -- wrong for the Subsystem-level
entries (MechTech 0xBDC, SubsystemMessageManager 0xBD3). On MechTech the
recon damageZone slot lands on its subsystemMonitors chain head: NULL
while the scan was broken (fix #1's bug MASKED this one), but the moment
the monitors populated, RespawnRepair virtual-called through a
SubsystemMonitor as if it were a DamageZone -> load-time crash in
StateIndicator::SetState (caught with cdb; call [edx+14h] on code bytes).
The sweep now filters IsDerivedFrom(MechSubsystem) before the cast.
Also: GUID identities pinned -- 0x50f4bc = PoweredSubsystem::ClassDerivations
(via @004b1208 = its TestInstance; btl4gau2's two "Generator" comments were
wrong, swept), 0x50fb60 = Generator's. A shadow-field instance documented for
the deferred de-shadow: MechSubsystem::damageZone re-declares the PUBLIC engine
Subsystem::damageZone (SUBSYSTM.h:159) -- mechtech's naive `sub->damageZone`
read the never-written engine member (0 monitors again); now reads the recon
member via GetDamageZoneProxy(). Logged in open-questions.
Wiring: BTL4GaugeRenderer now constructs + assigns the BTL4GaugeAlarmManager
(the base ctor NULLs it and Activate Check()s it); MechTech's Report*/
StatusMessageSink stubs are real; alarmModel confirmed baked (= 83) at runtime.
VERIFIED LIVE (scratchpad/lampflash.py, BT_LAMP_LOG chain trace):
[techstat] MechTech id 32 monitors 29 subsystems, alarmModel 83
[techstat] LRM15_1 condition 4 SET (alarmModel 83) <- bay fire armed
[galarm] condition 4 code 0x80 -> lamp 0xd mode 0x1 FLASH
[lamp] 0xd <- 0x37 (FLASHING) <- the select button
[galarm] condition 4 code 0x85 -> lamp 0xb mode 0x4 FLASH <- engEject
[techstat] LRM15_1 condition 4 CLEARED <- detonation clears
[techstat] AmmoBinLRM15_1 condition 0 SET <- bin Destroyed
Regressions: baytest PASS (bay fire kills), baypurge PASS (purge extinguishes),
sim3 3-pod brawl PASS with ZERO crashes (6 kills, organic heat-route bay fires,
respawns clean through the new sweep filter).
Env: BT_LAMP_LOG ([techstat]/[galarm]/[lamp]). KB: gauges-hud (the flash
section), decomp-reference (the GaugeAlarm closure + tables + GUIDs),
open-questions (built-note + the de-shadow deferred item), btl4gau2 comment
sweep. checkctx CLEAN.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
5ae4410914 |
Gitea #46: ammo bay fire now DETONATES and KILLS (+ #47 fire icon) -- three stacked kill-switches removed, the fuse decoded, and a vptr-alias corruption caught by regression
Field report (night 3): "two ammo bay fires and no death" (Cyd + RajelAran; one
purged, one left burning). Root cause = THREE independent kill-switches stacked
on the same path, all in ammobin.cpp:
1. `GameClock::Now() { return 0; }` -- `cookOffTime < Now()` was `0 < 0`, so
an ARMED bay fire never detonated.
2. `InjectHeat(void*) {}` -- the detonation body was a no-op.
3. The bin's Damage record was never stamped -- 0 damage of type 0 (which the
mech TakeDamage handler drops) even if 1+2 had fired.
THE FUSE (raw disasm, scratchpad/disammo.py): the old "RandomDelay" was a Ghidra
carve artifact -- FUN_004dcd94 is __ftol and the export DROPPED the caller's x87
expression. The real bytes @004bd450: fld 10.0 / fmul [ticksPerSecond] /
fadd 0.5 / __ftol -- a FIXED 10.0-SECOND fuse in clock ticks. New gotcha #19
(reconstruction-gotchas.md) documents the __ftol export blind spot.
THE DAMAGE RECORD: bin+0x1F0..0x21C is a real engine `Damage` (FUN_0041db7c IS
Damage::Damage(), byte-matched to T0 DAMAGE.cpp). The linked ProjectileWeapon's
ctor @004bc3fc stamps it from weapon->damageData @0x3A8 via
owner->roster[0x128][res+0x1C0] -- projweap.cpp's old comment called this "the
bin's HUD display block ... wired in the AmmoBin family"; both halves were wrong
and it was wired nowhere. Now stamped (before MissileLauncher's ctor divides by
missileCount -- a missile bin authentically holds the per-SALVO amount).
THE DETONATION (@004ac274 = MechSubsystem::DistributeCriticalHit -- the old
"HeatableSubsystem::InjectHeat" label was wrong, and the old reconstruction
iterated a stand-in CriticalChain whose First()/Next() returned 0):
statusAlarm pulse Exploding(2)->Destroyed(1) (slot 13 = the printSimulationState
state PRINT @004ac8c0, not an "explosion notify"), own private zone pinned
destroyed, then collect the mech DamageZones whose crit entries plug the bin
(the binary filters plug classID 0x4E = DamageZoneClassID -- VDATA.h idx 78,
cross-checked via idx 28 = AudioStateTrigger), split the amount evenly, and send
the OWNER one full Entity::TakeDamageMessage per zone: inflictingEntity = SELF,
damageZone = the zone index, inflictingSubsystemID = the bin (the message-
manager explosion-bundling key, ENTITY3.h's own NOTE), printing the binary's
exact "ammo explosion damaging <zoneName>" @0050df61.
Port shape: Mech::AmmoExplosionFanOut (mechdmg.cpp) behind a databinding bridge;
guarded deviation: zoneCount==0 warns instead of the binary's unguarded divide.
CriticalChain/CriticalEntry stand-ins DELETED from mechrecon.hpp.
VERIFIED LIVE (BT_BAYTEST hook = message 1, the crit-induced arm channel):
scratchpad/baytest.py : arm -> 10s -> "20 rounds x 35 = 700 (type 2)" ->
"ammo explosion damaging dz_ltorso" -> zone cascade -> mech DESTROYED
(authentic death list).
scratchpad/baypurge.py: arm -> eject-hold purge -> "bay fire EXTINGUISHED
(bin empty)", no detonation.
scratchpad/sim3.py : the HEAT route arms organically in combat (overheated
AFC100), detonates "11 x 25 = 275 (type 1)" split across dz_larm + dz_lgun.
BAYBOOM matchlog record added for MP field forensics.
FIX-OF-THE-FIX (caught by the sim3 regression, would have shipped a crash):
MechSubsystem's ReconDamageZone proxy puts structureLevel at OFFSET 0 -- which
ALIASES THE REAL DamageZone's VTABLE POINTER (the private zone is `new
DamageZone`, mechsub.cpp:154; mechsub.hpp:260 documents the alias). My first
DistributeCriticalHit kept the old body's `damageZone->structureLevel = 1.0f`
and OVERWROTE THE ZONE'S VPTR with 0x3F800000; the respawn sweep's virtual
SetGraphicState (vtable+0xC) then called through it -> AV at 0x3f80000c in
RespawnRepair, one frame after a bay-fire death. ALL EIGHT proxy-view sites in
mechsub.cpp swept to the engine view (((DamageZone*)damageZone)->damageLevel
@0x158) -- including two silently-wrong LIVE readers: GetStatusFlags (vptr as
float -> always "intact") and ApplyDamageAndMeasure (the crit cascade's
measure). Ruled out first by evidence: the weapon->bin stamps were all clean
(six stamps, all classID 0xbcb, logged).
#47 (half 1 -- the FIRE ICON): BallisticWeaponCluster::Execute @004c9a38 reads
bin+0x18C = cookOffArmed into the btefire.pcc TwoState, and while armed computes
(Now - cookOffTime)/ticksPerSecond -- the COOK-OFF COUNTDOWN -- into the numeric
beside it. The old reconstruction misread 0x18C as "the reload state" and
bridged the icon to BTAmmoBinFeeding, so it blinked on every feed and never lit
on a bay fire (RajelAran: "it doesn't"). Now driven by the
BTAmmoBinCookOffArmed/CookOffTime complete-type bridges. [T2 -- the data path
is rig-verified; the pixels await the next live session.]
#47 (half 2 -- the ENG-BUTTON FLASH): fully mapped, deliberately NOT built this
session. The authored data SHIPS (BTL4.RES carries exactly one type-31
GaugeAlarmStream); the chain is alarm SetLevel -> gauge-watcher socket ->
Renderer msg 7 -> GaugeAlarmManager::Activate @00448d00 (T0) -> the BTL4
override @004cc148..@004cc2fc (btl4galm.cpp's provenance note claiming "no
override body exists" is WRONG -- corrected in-file) -> LampManager::FindLamp
@00444c80 -> Lamp::SetAlertState @00444e64 (flash counter) -> the L4 flush
@00474e94 emitting flashFast states 0x37/0x13 (== T0 L4LAMP.cpp:234-239).
Missing: the override bodies, the gauge-watcher sender, the aux-button lamps.
3-piece plan in context/open-questions.md.
Also logged: HandleMessage is vtable slot 8/9 in the binary but NON-virtual
across 10 reconstruction classes (bit the BT_BAYTEST hook; typed call used, gap
documented in open-questions).
KB: combat-damage.md (the full cook-off section), decomp-reference.md (the
cluster addresses + the GaugeAlarm/lamp map + BT_BAYTEST env), gauges-hud.md
(the fire-icon correction), reconstruction-gotchas.md #19 (__ftol),
open-questions.md (2 entries), btl4galm.cpp provenance correction.
checkctx CLEAN. 40 LNK2019 unchanged (the two pre-existing families).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
f3bdb3b85a |
Searchlight + ThermalSight ToggleLamp WIRED (#61) -- and a swapped table attribution ROOT-CAUSED, retracting a false "1995 latent bug"
Both classes' Receiver::MessageHandlerSet were default-constructed blackholes (input-path audit systemic cause #1): entryCount 0, no parent chain, Find() returns NullHandler for every id, Receive drops silently. The new unhandled- message trace printed it on the first press: [btntest] PRESS 0x14 at poll 400 [msg] UNHANDLED: Searchlight has no handler for message id 3 Each class now has a GetMessageHandlers() function-local static chained to PowerWatcher's (which name-resolves through HeatWatcher/MechSubsystem to Receiver's empty ROOT set, so id 3 does NOT collide with HeatSink's ToggleCooling), plus a correctly-typed forwarder -- Receiver::Handler is void(const Message*) while the decomp shape is Logical(Message&), so a cast would be UB that merely happens to work on x86 __thiscall. DefaultData re-pointed off the dead empty sets. MessageArg now reads the NAMED ReceiverDataMessageOf<int>::dataContents with a static_assert locking it to the binary's message+0xC (was a raw offset read -- databinding rule). ROOT CAUSE of a long-standing misattribution: @004b860c is **ThermalSight's** ToggleLamp (table @0x51120C), NOT Searchlight's. The two TUs emit parallel shared-data blocks with identical stride (msg entry, +0x5C attrs, +0x84 Performance triple); what pins each entry to its TU is that "ToggleLamp" is NOT pooled across them -- two copies exist, each emitted immediately before its own class-name string ("Searchlight"@0x51144B, "ThermalSight"@0x511475). [T1: reference/decomp/section_dump.txt:69661-69711] Searchlight's own handler is @004b838c, which sits in a Ghidra EXPORT GAP (#60). RECOVERED by raw disassembly of content/BTL4OPT.EXE (scratchpad/dis838c.py, the EjectAmmo technique) -- and it corrected two things I had inferred wrong: * NO ControlsAllowLights/+0x25C novice gate. Searchlight tests the press alone; that lock is ThermalSight-only. A NOVICE pilot CAN work the lamp. * `or word ptr [this+0x18],1` sits AT the jle target -> the graphics-dirty bit (updateModel == ForceUpdate(), per #59) is raised UNCONDITIONALLY. Consequence: the "ORIGINAL 1995 LATENT BUG -- the searchlight can never light" claim is RETRACTED. It compared Searchlight's Performance (@004b841c, reads requestedOn@0x1E0) against ThermalSight's toggle (0x1DC) -- two different classes -- and so invented a missing 0x1DC->0x1E0 bridge. Every sibling toggles the field its own Performance reads. The searchlight DID light in the arcade, the searchlight->fog swap was NOT inert there, and building PullFogRenderable is FAITHFUL rather than a designer-intent deviation (the 2026-07-13 "left as-is" decision is void; the sim needs no repair). Verified live, real click seam (BT_BTNTEST): 0x14 -> [light] requested ON -> reported lightState 0 -> 1 (lamp lights) 0x12 -> [light] thermal sight requested ON -> thermalActive 0 -> 1 UNHANDLED lines for both classes gone; 40 LNK2019 unchanged (the pre-existing CreateStreamedSubsystem + Entity__SharedData::DefaultData families only). Swept the misattribution out of searchlight.cpp/.hpp, thermalsight.cpp/.hpp, hud.cpp:282 (it had claimed @00511180/@004b838c as HUD's), btplayer.cpp's +0x25C consumer list, context/{decomp-reference,subsystems,rendering,open-questions, pod-hardware,experience-levels}.md, docs/{GLASS_COCKPIT,INPUT_PATH_AUDIT}.md. checkctx.py CLEAN. Still open (documented, not fixed): ThermalSight has no visible IR effect (ToggleGlobalThermalVision is a marked no-op, pvision unported, IsLocallyViewed returns False); ThermalSight publishes "LightState"->0x1D8 where the binary has "LightOn"->0x1D8 and "LightState"->0x1E0; Searchlight's commandedOn@0x1DC has no identified role and measured 21 live, hinting our SubsystemResource +0x28 differs from the binary's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
62513b2f8a |
Gitea #53: generators can be switched off -- ToggleGeneratorOnOff reconstructed from @004b1ed0
TWO missing pieces, both now in place.
1. Generator had NO handler set of its own, so every message aimed at it hit a
default-constructed blackhole. Proved empirically with the new
unhandled-message trace by pressing button 0x1A:
[msg] UNHANDLED: Generator has no handler for message id 4 -- silently dropped
Added Generator::GetMessageHandlers() chained to **HeatSink**, deliberately NOT
PoweredSubsystem: id 4 there is SelectGeneratorA (the weapon-side generator
SELECTION that already works), so chaining to it would have made these four
buttons invoke the WRONG function instead of doing nothing. HeatSink owns id 3
(ToggleCooling), leaving id 4 free.
2. ToggleGeneratorOnOff had no body at all (0 references in game/). Transcribed
instruction-for-instruction from @004b1ed0:
- call @004ac9c8 -> BTPlayerRoleLocksAdvanced: a NOVICE pilot cannot use it
- [msg+0xc] <= 0 -> press only, ignore the release
- ON : startTimer = 0; if heatAlarm(@0x184) < 1 also stateAlarm -> 0
(Starting, so it spins up -- a heat-faulted generator keeps its fault
level); generatorOn = 1, coolantAvailable = 1, coolantFlowScale = 1.0
- OFF : stateAlarm -> 1 (GeneratorIdle); generatorOn = 0,
coolantAvailable = 0, coolantFlowScale = 0 (its coolant share is
released back to the loop)
So taking a generator off line idles it, drops its coolant draw and stops it
feeding its taps -- the authentic heat-management trade from the manual.
VERIFIED LIVE (BT_BTNTEST=0x1A,400,900 through the real click seam):
[btntest] PRESS 0x1a at poll 400
[gensel] GeneratorA generator OFF LINE (stateAlarm 1, coolantFlowScale 0)
and the UNHANDLED warning for Generator is gone.
Answers Cyd's report from two playtests ('still cannot turn of gennies'). Note
this is the ON/OFF control -- generator SELECTION (which generator a subsystem
draws from) is a different message and already worked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
|
||
|
|
33ca99eb69 |
Input-audit cause #1: revive the Avionics power bank + add the unhandled-message trace
THE CLASS OF BUG. A default-constructed Receiver::MessageHandlerSet is a total blackhole: entryCount 0 and NO parent chain, so Find() returns NullHandler for every id and Receiver::Receive dropped the message with no log, no counter, nothing. docs/INPUT_PATH_AUDIT.md ranked this systemic cause #1; running its own lint finds SEVEN such sets (Sensor, Searchlight, ThermalSight, Gyroscope, MechTech, SubsystemMessageManager, Torso). It is also why generators cannot be switched off (#53) -- same shape. FIX 1 -- Sensor / Avionics (the big one). The streamed mappings aim 108 records across the 18 mechs at it: the MFD2 ENG1 power bank, 6 clickable buttons plus keys F5-F9 (route Avionics power to generators A-D, gen mode, coolant cut). All silently swallowed. Sensor needed NO handlers of its own -- ids 4-8 already live on PoweredSubsystem, which chains HeatSink for id 3, covering the whole 3-8 range those buttons send. It only ever needed to CHAIN, which is exactly why the byte-identical bank on ENG2 works (Myomers chains, myomers.cpp:88-100; Sensor did not). Added Sensor::GetMessageHandlers() with the same function-local-static idiom and pointed DefaultData at it instead of the empty set. VERIFIED LIVE (scratchpad/avionics.py -- pages MFD2 and presses the bank): [gensel] Avionics -> GeneratorA (tapped) ... B, C, D [gensel] Avionics mode -> 2 i.e. Avionics now behaves exactly like Myomers. 24 handler hits, 0 unhandled. FIX 2 -- the standing guard. Receiver::Receive now logs once per (class, message id) pair when no handler is found: '[msg] UNHANDLED: <class> has no handler for message id N -- silently dropped (is its MessageHandlerSet chained?)'. Once-per-pair on purpose, since some ids broadcast every frame. This one line would have printed Avionics, Searchlight, ThermalSight, crouch and all four generator buttons on the first boot instead of costing us months of player reports. Uses SharedData::derivedClasses -> Derivation::className. DEFERRED, with reasons (not guessed at): Searchlight's ToggleLamp body exists but is unreachable -- it needs a correctly-typed forwarder (Receiver::Handler is void(const Message*); ToggleLamp is Logical(Message&), so a cast would be UB that happens to work on x86) AND an id check, because its id 3 collides with HeatSink's ToggleCooling and I have not traced whether PowerWatcher's chain reaches HeatSink. #53 needs more than chaining: ToggleGeneratorOnOff @004b1ed0 has no body at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg |
||
|
|
08977ff128 |
Gitea #55: respawn now restores COOLANT / heat / generator state (+ a Generator reset re-transcribed from the binary)
THE GAP: Mech::Reset sweeps every subsystem with the virtual DeathReset (mech4.cpp:1789), but only 7 weapon/ammo classes overrode it -- the entire heat/coolant/power family fell through to the empty Subsystem::DeathReset base (SUBSYSTM.h:161), so the respawn reset was a SILENT NO-OP for them. All the ResetToInitialState bodies already existed; nothing ever called them. Hence the field report: 'respawned with drained coolant'. Added DeathReset forwarders (ResetToInitialState is not virtual, so each class with its own body needs one): HeatableSubsystem, HeatSink, Condenser, PoweredSubsystem, Generator. HeatSink's also serves Reservoir -- the coolant tank -- which has no body of its own; that is the one that refills coolant. TWO MIS-TRANSCRIPTIONS FIXED, both verified against the binary with capstone: 1. HeatSink::ResetToInitialState chained HeatableSubsystem::ResetToInitialState with the comment 'FUN_004ac22c'. But @004ac22c is Subsystem's terminus -- disassembled: it reads the damage zone at this+0xe0, clears zone+0x158, and Set_Alarm_Levels zone+0x10 and this+0x2c to 0. And the binary's HeatSink @004ad760 calls only @004ad884 (ClearHeatFilter), @004ad7f0 (UpdateHeatLoad) and @004ac22c -- never HeatableSubsystem. Worse, HeatableSubsystem's body sets currentTemperature = 300.0f, CLOBBERING the startingTemperature that HeatSink assigned four lines earlier. Dropped the call; the terminus work is already done for every subsystem by MechSubsystem::RespawnRepair (mech4.cpp:1790). 2. Generator::ResetToInitialState chained HeatableSubsystem and set outputVoltage = 0 -- which matches GNRATOR.TCP, but BT's BINARY DIVERGES from the TCP source, and the binary is what shipped. Disassembled @004b215c instruction by instruction: it chains HeatSink (so the coolant refill DOES run for a generator), then generatorOn=1, startTimer=0, stateAlarm 0 then 2, **outputVoltage = ratedVoltage**, coolantAvailable=1, coolantFlowScale=1.0, startTimer=startTime. Every offset matches our declared members exactly. The old version brought generators back from a respawn at ZERO output voltage, so energy weapons could never charge -- no recharge ring, no ready dot, nothing damaged. That is a strong candidate for #54 (David's three dead lasers). LIVE VERIFICATION (sim3.py, 3 mechs + force damage): 4 death/respawn cycles, and every heat sink logged coolant == capacity on every respawn, with temp restored to startingTemperature (77) rather than the clobbered 300. Left as a silent regression guard that only speaks if a respawn leaves coolant short. KNOWN REMAINING [T3, noted in code]: Condenser's body chains HeatableSubsystem rather than HeatSink, so a coolant loop's VALVE DETENT may still persist across a respawn -- the binary's Condenser reset is not yet decompiled. Do not claim valves are fixed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg |
||
|
|
2518e43719 |
Gitea #59: the first death permanently disabled ExecuteWatchers -- simulationFlags |= 0x1 should be ForceUpdate()
Verified the claim in the binary myself with capstone before changing behaviour: 0x4c0155: or word ptr [ebx + 0x18], 1 +0x18 is Simulation::updateModel, a **Word** -- and the 16-bit 'or word ptr' settles it, since simulationFlags is an LWord at +0x28 and would assemble as 'or dword ptr'. So the authentic op is updateModel |= DefaultUpdateModelFlag, which is exactly Simulation::ForceUpdate() (SIMULATE.h:146-147). The old transcription wrote simulationFlags |= 0x1 instead. Bit 0 there is DelayWatchersFlag (SIMULATE.h:170); Simulation::Simulate then skips ExecuteWatchers() forever (SIMULATE.cpp:461), and NOTHING clears it -- the only ClearWatcherDelay() in the tree is in the encore path (UPDATE.cpp:215), which a Player never takes. So every pilot's first death permanently disabled watcher execution on their simulation, and the replication mark the binary intended was never set at all. context/reconstruction-gotchas.md had flagged this exact line as the un-audited sibling of the #12 dirty-bit class, asking for the disasm first; that is now on the record. LIVE VERIFICATION (scratchpad/sim3.py, 3 mechs + BT_MP_FORCE_DMG, 180s): 4 consecutive death/respawn cycles on pod3, every one completing -- death cycle START (death #N) -> RESET at drop zone [COMPLETE] with 'player watchersDelayed=0' after every death, and no crash. Also re-confirms the #57 latch fix under repeated deaths (the interleaved SWALLOWED lines are the authentic dedup of a second notification, each followed by a completed RESET). Leaves a silent regression guard: the death path now logs only if it ever finds the watcher-delay flag set again. Rigs: sim3.py (3-mech combat + force damage), destest.py (2-node designation proof: 18 designations, deathPending clean on all of them). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg |
||
|
|
2dd4ee3910 |
ROOT CAUSE: target designation wrote a Mech* into deathPending, disabling respawn (#57/#48)
MEASURED our compiled BTPlayer layout (new one-shot [layout] ctor diagnostic): sizeof=652 (0x28c) killCount@0x270 pad_0x280@0x274 objectiveMech@0x278 deathPending@0x284 The three target-designation sites wrote RAW at pilotArray[0]+0x284 intending the BINARY's objectiveMech. On our object 0x284 is deathPending -- the death-cycle latch. So EVERY target designation (the Comm bank 0x30-0x37, or the typed t/y/u/i/o keys) stored a Mech pointer into deathPending, and a non-zero deathPending makes VehicleDeadMessageHandler take its dedup early-return, which skips the warp, ++deathCount, the PLAYER_DEAD record AND the drop-zone hunt. => designating a target silently disabled your respawn for the rest of the process. That is #57's missing first cause: it explains David's very first death being swallowed with no prior death, and the 3-of-3 swallowed deaths in the final round. It also explains the operator's persistent observation that the trouble began when the comms panel went live -- before the pilot list populated, there was nothing to designate. And the designation never worked either: the value never reached objectiveMech (input-audit finding #1). FIX: all three sites now use named-member bridges in the complete-BTPlayer TU -- BTPilotSetObjectiveMech / BTPilotObjectiveMech / BTPilotVehicle / BTPilotPosition / BTVehicleDestroyed. Also retires the raw +0x1fc (playerVehicle) reads and the raw +0x100 position reads in ChooseNearestPilot, and routes its destroyed-check through the real Mech predicate instead of the Is_Destroyed stub. LAYOUT LOCKS: static_asserts on objectiveMech@0x278, deathPending@0x284, killCount@0x270 and sizeof==0x28c, in the friend fn BTPlayerLayoutSelfCheck, so a member shift fails the BUILD instead of silently clobbering the latch again. Rigs: scratchpad/commstest.py (drives designation + captures panels), valvetest.py, lampgallery.py (428-bitmap gallery for #48 visual search). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg |
||
|
|
cb50a1d491 |
Gitea #48: add BT_NO_PILOTLIST=1 -- make the operator's comms hypothesis decidable
The operator reports consistently, across two sessions, that the MFD artifacts began the night the Comm pilot list was enabled (#43) and were never seen before. Four static theories of mine died -- plane masks (all 7 primitives invert correctly), L4GraphicLamp (never instantiated, dead code), the sec-port lamps (wrong plane AND wrong colour, operator confirmed on sight from the decoded art), vertBar/VertTwoPartBar (clamps every value, sets its cursor per draw). And no rig reproduces it: not 2 pilots, not 5 pilots on last night's exact roster, not autofire + repeated valve moves. So stop theorising and make the field observation decidable: BT_NO_PILOTLIST=1 makes PilotList::Execute draw nothing, everything else untouched. artifacts gone with it / present without => confirmed, and it is the workaround artifacts either way => not the pilot list Diagnostic only, default OFF. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg |
||
|
|
5174c638ce |
K/D scoreboard ROOT-CAUSED (#45) + respawn latch fix (#57) + plate stage ops (#58)
FIXES (compile clean; exe link pending -- a game client still holds btl4.exe): #57 respawn latch: deathPending was released in ONE place (btplayer.cpp:343) and only if a LATER re-post happened to find the mech alive -- so if the +5s re-post landed before the drop-zone reply, or never arrived, the flag stayed set while the pilot respawned and fought on, and their NEXT death hit the dedup at :391 and was swallowed whole (no warp, no tally, no hunt) for the rest of the process. Tonight 3 of 3 deaths in the final round were swallowed, each by a player who had respawned successfully earlier in the same process. Clear moved to the actual completion point (after Mech::Reset at the drop zone) + the four abandon paths that also stranded it. Cycle logging promoted to always-on (START / RESET / SWALLOWED warning) + a RESPAWN matchlog record. #58 reticle callsign plate drew as a SOLID RECTANGLE on some maps: my dpl2d_DrawTexturedRect bound a texture but never set COLOROP/ALPHAOP, and L4D3D.cpp:1085 sets both to SELECTARG1 from TFACTOR (ignores the texture, fills with a constant). Which state was live depended on what the 3D pass drew last -- hence 'depends on the level'. Now sets MODULATE texture x diffuse for both channels and restores. Latent sibling noted (CameraHUDDrawQuad, same omission). ROOT-CAUSE DOC (no code): docs/KD_SCOREBOARD_PLAN.md. Headlines: - the port reads DEATHS from the WRONG FIELD: the binary's PilotList draws +0x27c/+0x280; we labelled +0x280 'pad_0x280', never write it, and substituted the engine's Player::deathCount (+0x200, the respawn identity number seeded to -2) -> remote rows read a clamped fake 0 by construction; - BTPlayer::VehicleDeadMessageHandler @0x4c05c4 is MISSING from our decomp export (functions_index.tsv jumps 4c052c -> 4c083c, verified) -- that silence is what produced the 'pad' belief. An absent function is not evidence of an absent behaviour; - neither counter rides an update record, so divergence never self-heals and bystanders show 0/0 all mission; BTPlayerCountObservedDeath is unreachable dead code; - 'a kill I didn't earn' = last-hitter-takes-all via lastInflictingID, and COLLISIONS count (5 of 47 deaths had a type=0 killing blow); - the phantom kill is authentic 1995 (the kill handler bumps the victim's killCount too); - btplayer.cpp:453 does simulationFlags |= 0x1 where the binary does ForceUpdate() -- bit 0 is DelayWatchersFlag and nothing clears it, so the first death of any pilot permanently disables ExecuteWatchers on that player's simulation. Filing separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg |
||
|
|
23229e573e |
Reverse thrust was dead on the desktop (user report): wire the 0x3F throttle-handle button
LALT -> button 0x3F was bound in both binding layers and PadRIO pushed the RIO ButtonPressed event correctly, but nothing ever set the mapper's ReverseThrust: - the authentic route is BTL4.RES 'L4' streamed record [2] (Button Throttle1 0x3F -> attr 6 ReverseThrust@0x124), but our streamed BUTTON mappings are not consumed at all -- MechControlsMapper::AddOrErase is still the unreconstructed Fail stub, so the event never reaches the attribute; - the ONLY writer of reverseThrust was the keyboard bridge's 'key_throttle < 0' test, which is bypassed whenever a RIO is operational (the pad RIO always is, so the bridge is OFF on the desktop) AND is unreachable regardless, because L4PADRIO clamps the Throttle channel to [0,1]. Net: Alt did nothing and the flag was re-zeroed every frame. FIX: publish the 0x3F hold state from PadRIO::EmitButton -- the one chokepoint every desktop source funnels through (keyboard, gamepad, DirectInput joystick, glass-panel clicks via SetScreenButton) -- and apply it in InterpretControls gated on BTPadRIOActive() so real pod hardware is untouched (there the streamed mapping owns the flag). Marked [T3]; delete once streamed button mappings land. Reverse is a HOLD (manual: hold the red throttle button, release = forward). Verified with scratchpad/revtest.py (keybd_event injection, BT_KEY_NOFOCUS): forward = rev=0 dmd +61.501; LALT held = rev=1 dmd -61.501; release edge logged. KB: pod-hardware.md now records the streamed-button-mapping gap + this seam. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg |
||
|
|
6c3fca2f67 |
Gitea #38 ROOT CAUSE + FIX: mech paint lost on geometry re-load (view toggle / respawn)
Found from the user's live 2-node demo: a Crimson MadCat went GREY in its own view after a V (inside/outside) toggle, with NO new [paint] or MakeMechRenderables line in the log -- so no rebuild, just a re-parse. ROOT CAUSE: the per-pilot colour/badge/patch is applied by rewriting MATERIAL NAMES while a BGF parses, and only while the substitution callback is installed (SetupMaterialSubstitutionList .. TearDown, bgfload.cpp:15-18). BTL4VideoRenderer::ApplyViewSkeleton re-parses every shown segment BGF on each view toggle AND on respawn (that is what its fresh graphic-state read is for), but did so OUTSIDE that bracket -> raw %color% placeholders -> unpainted materials -> grey. Nothing caches the geometry (d3d_OBJECT caches only textures), so every call re-parses. This is #38's mechanism: 'colors not preserved on respawn' was never a replication or teardown bug. FIX: re-install the substitution list around the reload, reusing the serial the mech was BUILT with -- SetupMaterialSubstitutionList advances the global %serno% per call, so a naive re-bracket would stamp a different serial and still resolve the wrong names. MechRenderTree gains paintSerno (captured before Setup at build); ApplyViewSkeleton installs/restores around the loop and logs it. Rig-verified: the crimson MadCat stays crimson (yellow patch intact) across repeated inside/outside toggles; each toggle now logs '[paint] color=red serno=0' + '(paint serno 0)'. ALSO -- corrections to yesterday's #44 name plate from the adversarial pass: - kPlateARGB 0xFF808080 -> 0x80FFFFFF. BMAP.BMF tag 0x0027 is dpfB_MATERIAL_OPACITY_TAG [T0 libDPL/dsys/PFBIZTAG.H], NOT a diffuse colour: the materials carry NO colour tag at all, so the plate is the unmodulated callsign raster at ~50% opacity, not a grey label. (Two independent workflows converged on this.) - the plate's K = 1/2.8 is NOT the hotbox's constant (2.8145) -- de-unified. - the Lock attribute is HUD attr id 10, an int/Logical*, not a Scalar*. - the PNAME pair split is the V half, not U (our per-player texture is one 128x32 cell, so the port quad samples v 0..1). - retracted bgf-format's 'tag 0x0027 likely SPECULAR [T4]'. KB: new reconstruction-gotchas section on the whole bug class (load-time-only state must be re-installed on every RE-load, incl. the serno trap). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg |
||
|
|
35c750dc7c |
Gitea #44: the reticle TARGET NAME PLATE (PNAME1-8) -- the target's callsign under the crosshair
Reconstructed the last deferred piece of the 1995 reticle, decoded from the Execute disassembly @004cdcf0 [T1]: - selection: target_mech+0x190 (owning BTPlayer) -> player+0x1e0 (playerBitmapIndex), 1-based into the mesh table at this+0x2e8 - placement: re-placed every frame the aim moves -- scale 0.12 uniform, translate (K*retPos.x, K*retPos.y - 0.08, -1.0), K = 1/2.8 (the x87 long double @0x4cee64) -- so the label TRACKS THE AIM POINT, not screen centre - visibility: the LOCK attribute (this+0x184, cached +0x188) AND an owning player; reticle-off / simple-X also hide it - art: the plate quad UV-addresses a shared 128x64 bitslice texmap whose texels the pod OVERWROTE each mission with the egg's callsign rasters (original source commented out at L4VIDEO.cpp:5682-5710) -- the baked 'PLAYER n' art in BMAP.BSL is only the shipped fallback. Material colour is a neutral (0.5,0.5,0.5): grey-white, not phosphor green. Port: same geometry in reticle units (0.224 below the aim point, 0.336x0.084) drawn as the target's egg callsign texture through the reticle's own MapX/MapY mapping, so it follows the world view in every layout and BT_SHOT captures it. New: dpl2d_DrawTexturedRect, BTReticleTargetPlate, BTGetPlayerNameTexture. Also: kRetCaret corrected 0.02f -> 0.025f (_DAT_004cd7f4 read from the binary; the old value was a T3 guess, 20% small). KB: gauges-hud + open-questions corrected (the chain was filed as deferred and mis-attributed to 'the 3D marker'); TWO PNAME consumers now distinguished (this plate, and CameraShipHUDRenderable's ranking window which shipped 2026-07-18); new bgf-format section on the plate atlas. Rig-verified: 2-node relay, BT_GOTO=enemy -> '[hud] name plate: bmp=2 tex=1' and 'Boreas' rendered under the crosshair on the locked target. Solo clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg |
||
|
|
b7107b1020 |
Gitea #43: the Comm pilot list now shows EVERY pilot -- three stacked fixes
1. Roster count used the binary's raw entry+0x29&0x40 read on our compiled objects (databinding trap) -> garbage latched pilotCount=1. Decoded: the flag is Player::NonScoringPlayerFlag (bit 14 = Entity::NextBit); both loops now use IsScoringPlayer(). Also closes the pilotIDs[1] overrun. 2. The build-once latch races async replicant arrival (rig-proven 1-then-2): rebuild on scoring-census change [T3 accommodation, demand-latch precedent]; also un-dangles departed peers. Forensic: '[score] pilot roster built: N'. 3. Name icons were a NULL stub + zeros blank authentically -> rows invisible. Wired BTPilotNameBitmap (playerBitmapIndex -> Mission small callsign raster, the radar-label chain); replaces the raw pilot+0x1e0 key. Rig-verified: Aeolus + Boreas rows with callsigns on both nodes incl. the staggered-start race; solo unregressed (1 scoring pilot). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg |
||
|
|
a35cef4c01 |
Issue #42: doubled MFD joystick -- restore the dropped MoveToAbsolute(0,0)
The binary's ConfigMapGauge dirty-redraw (@004c6f1c) is SetColor(0xFF) -> MoveToAbsolute(0,0) -> DrawBitMapOpaque; the transcription dropped the reposition. The FIRST draw worked by accident (fresh view cursor at the origin); every forced redraw -- the weapon panel's DISPLAY round-trip (main -> ENG DATA -> main, BecameActive sets dirty) -- blitted btjoy.pcc at the state-lamp loop's leftover cursor (0xc, 0x55), painting an offset ghost joystick over the original on every MFD. Repro pinned live by the operator (initial draw correct; doubles on the DISPLAY round-trip, every MFD). LIVE-VERIFIED fixed by the operator: round-trips redraw cleanly. NOT a regression -- present since the joystick went live (89b4f53; bisect-confirmed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f7daf03507 |
Crash self-report + round-completed latch + PDB archiving
CRASH SELF-REPORT (the #35/#41 heisenbug hunt): every unhandled exception now logs its code, address, and an EBP-walked stack as module-relative offsets (btl4+0xNNNN) into BT_LOG before dying -- any field crash on any machine hands us a resolvable stack. The walker is per-dereference guarded (a corrupt chain truncates). BT_CRASHTEST=1 exercises the path end-to-end (verified: forced AV logs code/addr/5-frame stack). mkdist archives each build's PDB next to its zip so offsets resolve per distributed version. Context: a flaky release-only crash in the mech build path surfaced during #38 rigs -- it moves with build layout, spares neither peer, and masks under a debugger heap (13 clean cdb runs vs 2/3 crashes without). A real latent memory bug; the self-report will catch it wherever it strikes next. ROUND-COMPLETED LATCH (issue #38, awaiting verification): relay-mode clients refuse RunMission after a round has ended -- the lobby-loop relaunch owns the next round (in-process round 2 rebuilt every player/mech entity on stale globals; matchlog caught player=3:1->3:36 in one generation). Latch sets ONLY when the stop ends a RUNNING mission (a stray pre-mission StopMission must not poison round 1 -- the first cut ate a legit launch, caught + fixed in rig). Regression verification pending (contaminated by the heisenbug above). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ac7f0cb13a |
Issue #38 (partial): egg camo-color validation + paint forensics
Respawn-paint investigation: 10 forced respawns on the i22 rig showed paint STABLE across in-place respawns on both master and replicant -- the in-place reset does NOT repaint. Two real findings landed: 1. 'Red' is NOT a camo color (the vehicle table authors Crimson=red for camo; Red exists only as a PATCH color) -- an egg with color=Red paints the mech silent gray from first spawn (the engine tolerates the table miss by design). The relay now WARNS at egg load on any color= outside the camo set; our MPSHORT test egg had exactly this bug (fixed). 2. [paint] log now carries the entity id, so the one-time-observed nondeterministic video-model rebuilds (sernos 2-5 with swapped colors, run 1 only) will be attributable when they recur. The reported color/style change on respawn remains unreproduced in the deterministic rig -- needs the observer detail (whose mech, which view) from the next field sighting. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
3d8bfdeba6 |
Hardening: unknown vehicle= in the egg fails loud instead of segfaulting
Found during the #35 (Owens laser crash) hunt: FindResourceDescription returns NULL for an unknown vehicle name and release builds (Check compiled out) segfaulted on Lock() -- a typo'd or corrupted vehicle tag in a served egg would crash EVERY pod at drop. Now logs '[spawn] FATAL: unknown vehicle' + Fail() with a clean exit. Verified: vehicle=nosuch -> message + exit 127 (was a segfault). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d3ede2e635 |
Issue #33/#34: relaunch-storm + duplicate-seat fixes (pod-grade session stability)
Night-2 capture: one flapping pod cascaded 3 ROUND ABORTs in 8s; every
client relaunch-churned (~1 gen/s) until the D3D device-creation 20s
deadline fired on every machine ('could not connect direct 3d'), and
claim-less restarts minted duplicate roster seats.
Console (relay):
- Round abort clears stale console-conn ACK flags and HOLDS the next
egg release for an 8s settle window (_tick_settle re-releases when
quiet) -- the abort->instant-re-release->drop->abort cycle is broken.
- HELLO from a stale identity gets a REJOIN reply (identity resync via
the seat-request path, 5s/IP rate limit) instead of a bare drop.
- STATIC SEATS: seats remember (IP + callsign); a claim-less restart
from the same machine+name RECLAIMS its own seat (displacing its
zombie beacon) instead of minting a duplicate -- the pod contract:
same box, same seat, always.
- Operator local instances log with BT_LOG_APPEND (the night-2 crash
loop left a 2-line file; generations now leave a full trail).
Client:
- Relaunch storm damper: a generation that lived <15s sleeps 5s before
respawning (storm decays to a gentle cadence; normal multi-minute
round relaunches untouched; menu clicks explicitly never delayed).
Rig-verified: normal round + mid-MISSION drop = no abort (correct);
mid-LOAD kill = exactly ONE abort + 8s hold + seat reclaimed + no
cascade. The night-2 'host 7' identity-derivation subtlety remains
under forensics (append logs + first-breath line will capture it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
26439c2e1a |
Boot forensics: first-breath log line + generation-safe bat logging
Field 2026-07-23 (second night): a player's join.bat failed 3x in a row with a 0-byte join.log -- indistinguishable from 'never ran'. Two causes of evidence loss fixed: 1. The first flushed log line now happens the INSTANT the log opens (version, pid, args, first-process vs relaunched-generation). An empty log after this build means the exe was killed before WinMain -- an external cause (antivirus/loader), not ours. 2. The menu->game relaunch chain truncated join.log each generation (BT_LOG_APPEND was unset in the player bats): the crashed generation's evidence was erased by the next attempt. The bats now delete the log once per bat run and append across generations. Also explains the confusing 'The game has exited' bat message on SUCCESSFUL launches (the menu process exits after relaunching the game detached) -- the log now records the whole chain. |
||
|
|
c2ee7299b2 |
Generic joystick / HOTAS / pedals support: DirectInput 8 layer + capture wizard
Tester ask: configure non-Xbox controllers. New L4JOY layer (DI8): enumerates every non-XInput game device (up to 4), DIJOYSTATE2 polling with range normalization, reacquire-on-loss, 3s hot-plug re-enum. XInput devices are excluded via the documented RawInput IG_ VID/PID check (live-verified skipping a real XBOX360 pad -- no double-feed). bindings.txt grows joydev/joyaxis/joybutton/joyhat rows (device slots by name substring or ordinal; axes X..RZ/SL0/SL1 with invert/slew/ deadzone; hats as 4-direction buttons with diagonal chords). PadRIO runs them through the existing channel/event machinery -- per-binding edge arrays release automatically on detach (the #24 rule), and a direct throttle axis maps full lever travel onto the 0..1 channel while the gait detent still snaps a lever parked in the walk/run dead band. BT_JOYCONFIG=1 (joyconfig.bat) runs a console capture wizard: move each control when prompted (stick/twist/throttle/fire), invert derived from the move direction, hat look cluster auto-added, output written between markers in bindings.txt with the rest of the file preserved. Legacy L4DINPUT DIJoystick (L4CONTROLS=DIJOYSTICK) untouched. Verified: grammar accept/reject per line, XInput-exclusion live, 30s in-mission no-device polling clean. Awaiting a tester with real stick hardware for axis verification. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
86e387b6c7 |
Valve forensics + KB: the boost mechanic is real; the field cook-off was a detent question
Measured A/B/C (Blackhawk, sustained autofire, 150s soaks): SRM6 on
Condenser2 plateaus ~531 and COOLS at share 0.91 (boost 2), climbs to
the jam band at balanced (0.167), and runs at the 2000 failure line
starved (0.018). MoveValve reruns the redistribute every press;
veteran passes the novice guard. The field 'boosted loop 2 still
cooked' therefore means the valve was NOT at the 50 detent during the
fight -- the 1->5->50->0 cycle turns the loop OFF one press past max.
MoveValve presses now log always-on ('[valve] <name> -> detent N' +
a CLOSED warning) so the next field session records the detents.
Also: solo mission clock verified live (60s egg self-ends,
'[mission] solo game clock expired' -> RunMissions returns).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
2edd1b5363 |
Issue #31: wire the EJECT button (EjectAmmo msg 0xb @004bb9b8) -- the in-mission unjam
Disasm-faithful transcription of the export-gap handler onto ProjectileWeapon (MESSAGE_ENTRY id 0xb, the #19 pattern). PRESS arms the ~3s UpdateEject countdown (bin alarm -> Ejecting(3); gate 2 parks the weapon offline mid-eject -- authentic). HOLD to completion = DumpAmmo jettisons the bay -> NoAmmo. TAP (release early) cycles ONE round out via FeedAmmo and returns the weapon to Loading -- clears a jam at the cost of a round (and recovers a FailureHeat 7 while rounds remain). Binary structure kept exactly incl. the press-no-bin fall-through into the release block. AmmoBin: Ejecting(3)/Ejected(4) alarm levels decoded + named SetAmmoState accessor (databinding rule). Always-on forensics: '[weap] EJECT tap' + '[ammo] bay DUMPED overboard (N rounds)'. Rig-verified live (BT_EJECTTEST=1 / =hold on LRM15): taps eject one round each and return the weapon to service (15->8 across cycles); hold dumps all 16 -> NoAmmo. Awaiting a human press of the actual ENG-page button on a jammed weapon. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
225fe43f0c |
CORRECTION: EJECT tap IS the in-mission unjam (EjectAmmo @004bb9b8 decoded)
Old-timer testimony (tap = eject one round, hold = eject the bay, eject clears a jam) checked against the binary: raw disasm of the export-gap handler @004bb9b8 confirms it byte-for-byte. PRESS arms the ~3s UpdateEject countdown (hold-to-completion = DumpAmmo whole bay -> NoAmmo); RELEASE before completion (TAP) ejects ONE round (@004bd4f4) and, with ammo remaining, sets weaponAlarm 3 + restarts the reload -- the weapon returns to service. A tap even recovers a FailureHeat level-7 launcher while rounds remain (empty-bin gate 2 re-pins 7 only when the bay is dry). The earlier 'jams are mission-permanent / no unjam exists' claim read only the HOLD path -- corrected + swept: projweap.cpp case-5/jam-log/ CheckForJam comments, players/README.txt, decomp-reference.md (message-tables entry), open-questions.md (state-7 recovery note). Gitea #31 updated; wiring the handler now restores the pod's actual jam-recovery mechanic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
cbebb0f1c1 |
Audio source-pool census + fix the unbuilt jam-log literal
Field report (playtest night + operator): audio cutting in and out
toward the end of the match -- the classic shape of source-pool
pressure (sources release only at entity teardown; long matches
accumulate looping occupants like wreck burn/smoke until transients
lose the voice fight; OpenAL mixing capacity is finite). L4AUDRND now
tracks live/deleted/failed sources: a 30s '[audio] source census' line
plus an ALWAYS-logged line on every acquisition failure (the smoking
gun). Soak test next to confirm the leak/occupation slope.
Also: the
|
||
|
|
cc6639d952 |
projweap: correct the msg-2 jam claim (it CAUSES jams, decomp-verified)
HandleMessage @004bcabc: message 2 -> SetLevel(weaponAlarm, 5) -- a malfunction INDUCER, not an unjam; the state-machine comment had it backwards. There is NO in-mission unjam in the binary (jams clear only at ResetToInitialState = mission reset/respawn), and the EJECT cycle is the anti-cook-off ammo jettison (DumpAmmo -> NoAmmo), not an unjam. The old 'unjam delay' follow-up suggestion is retired as unauthentic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ffa39335e0 |
Weapon jam/failure/dry transitions always log (field diagnosability)
Solo field report: both SRM launchers went inert with ammo remaining and the default log could not answer whether they jammed or heat-failed -- every weapon-state transition log was gated behind BT_AMMO_LOG. The edges are rare and mission-permanent, so they now log unconditionally: [weap] JAMMED with temperature/alarm evidence at the CheckForJam hit, and both NoAmmo edge logs (gate 1 destroyed/failHeat/disabled, gate 2 dry bin) ungated. The missing MFD presentation for these states is issue #30. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
12a1fea823 |
Score replication: peers' scores now reach every pod (rank fix)
Field report (playtest night, voice channel): everyone's HUD showed them in 1st place. Root cause: the engine's score-replication machinery was complete -- Player::WriteUpdateRecord ships currentScore, the replicant ReadUpdateRecord applies it, CalcRanking sums every scoring player -- but nothing marked the player's update record dirty on a score change (the engine only ForceUpdates at mission end for the fade sync). Every peer's replicant player therefore stayed at score 0 and each machine ranked its own (only-nonzero) player first. Fix: ForceUpdate() after every score application in the BTPlayer handlers (main, inflicted, and the severed-vehicle branch) -- the existing update-record path does the rest. Verified 2-node (force-damage kill scoring): the victim's machine ranks the killer's REPLICANT at its replicated score (24) above its own master (0); standings consistent on both nodes. BT_RANK_LOG=1 kept as the 1 Hz per-player ranking dump. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c8cba8c764 |
Connection audit: every connect/disconnect scenario handled + verified
Systematic actor-death x lifecycle-phase sweep (operator request after the WaitingForEgg zombie). New handling, all live-verified: 1. PRE-EGG console-pad loss (the real zombie phase): before the egg the pod's only link is the console pad -- the game socket doesn't exist, so the earlier RelayGameDown hook could never fire (the verification run itself caught this). Detection now lives in HostDisconnectedMessageHandler ConsoleHostType (relayMode + pre-scene -> BTRelayRejoinNow); mesh keeps 1995 behavior. 2. MID-MISSION relay loss: the STOP is relay-sent (1995: the console owned the clock), so a pod losing its relay mid-match played a peer-less FOREVER-mission. RelayGameDown (scene presented) now posts StopMissionMessage at +15s -> normal end -> lobby relaunch. 3. POST-RELEASE pod death stalled the round (survivors wait forever on the dead peer's connection gate): relay _abort_round -> route -11 REJOIN to survivors -> everyone re-seats in seconds (reset-first ordering makes the drop cascade re-trigger-proof). 4. BTRelayRejoinNow carries BT_SEAT_CLAIM (mirrored tag): without it the rejoiner's own reclaim-hold blocked assignment -> ROSTER FULL loop for the 90s window (found by verification round 2). 5. Beacon NAT keepalive (SIO_KEEPALIVE_VALS 60s/10s) so long between-rounds waits can't be silently unseated. 6. BT_LOG_APPEND=1 preserves the log across lobby-loop generations (truncation erased round-1 verification evidence). Verified non-issues: relay pods never bind the -net listener (no double-launch collision on the internet path); mesh bind-fail exits cleanly. Full matrix + evidence: context/multiplayer.md; scratchpad/audit_verify.sh is the repeatable rig. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
02a41f165f |
Issue #27: projectiles carry the weapon's real damage type (ballistic)
Playtest matchlog forensics: across 2,591 applied-damage events in two rounds, Ballistic (type 1) damage NEVER appeared -- Collision/Explosive/ Laser/Energy only -- despite 136 projectile impacts and autocannon/Gauss mechs. The weapon parses its authentic damageData.damageType correctly (mechweap.cpp:316 maps "BallisticDamage" -> 1), but BTPushProjectile never carried the type and every projectile-impact site in mech4.cpp hardcoded ExplosiveDamageType. So autocannon/Gauss rounds were scaled against armor by damageScale[Explosive] instead of damageScale[Ballistic] (the model indexes a distinct scale cell per damage type) -- every ballistic weapon did the wrong damage all night. Lasers/PPCs were unaffected (their SendDamageMessage path carries the weapon's own damageData); missiles happen to BE Explosive so only autocannon & Gauss were mis-scaled. BTProjectile gains a damageType field, threaded from the firing weapon (damageData.damageType) at launch through to the impact dispatch; missiles resolve Explosive from their own streamed type, autocannon/ Gauss now resolve Ballistic. The missile-splash template stays Explosive. Enum is anonymous so the field casts to Enumeration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4e0fcbf328 |
Issue #26: runtime master volume (-/= keys, persisted)
The game audio plays hot with no in-game control -- testers couldn't hear voice chat without the Windows mixer (playtest night). The -/= keys now step the OpenAL listener gain (the master scale every source inherits) in 5% steps, clamped 0..150%, edge-detected in the per-frame poll with a foreground guard; the value persists to content\volume.cfg and reloads at boot (BT_AUDIO_VOLUME env still wins when set; default stays 0.6). README updated. Verified: volume.cfg 0.25 -> boot log "master gain=0.25"; key stepping needs a live focused session (awaiting live verification). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5f115eca19 |
Issue #22: respawn resets ammo + repairs weapons (the +0x28 mislabel)
ROOT CAUSE [T1]: Mech::Reset's subsystem sweep (@0049fb74 loop-2) calls
vtable slot +0x28, which the DATA-section vtables prove is
ResetToInitialState (0050f9d8/00511d2c/00512078/0051242c all carry the
class RTIS at +0x28) -- our reconstruction mislabeled it DeathReset,
the EMPTY engine base virtual, so the whole per-subsystem reset was a
silent no-op ("ammo and weapons do not reset on respawn", playtest
night; matchlogs: 3 of 7 players fired zero projectiles after their
first death).
SECOND LAYER: the binary never refilled ammo at all -- its respawn
severed the vehicle and DropZoneReply built a NEW mech (fresh ctor =
full bins, pristine subsystems: the pod behavior testers remember).
Our port deliberately reuses the entity (the new-mech path was the
two-mech/camera-glitch family), so Reset now EMULATES ctor-freshness:
- the sweep starts at i=2 (binary-faithful: skips mapper + voltage
bus) and dispatches the engine DeathReset virtual as the vehicle;
per-class overrides chain each class's own authentic slot-10 RTIS
body (the recon RTIS namesakes are statically bound -- no common
virtual): MechWeapon @004b96ec, Emitter @004ba4d0 (charge cycle
restarts from Loading), ProjectileWeapon @004bbaf8 (unjams).
- MechSubsystem::RespawnRepair (PORT): heals the subsystem's PRIVATE
crit damage zone + status alarm -- the mech-zone heal never touched
those, which kept destroyed weapons dead across respawns.
- AmmoBin::DeathReset (PORT): refills to initialAmmoCount (@0x220, the
ctor value) + Loaded alarm + idle feed + cook-off disarmed;
ResetToInitialState stays authentically EMPTY per AMMOBIN.TCP.
Verified (2-node force-damage kill/respawn rig, BT_DEATH_LOG): every
respawn logs [respawn] ammo bin refills for both bins to streamed
capacity across 4 death cycles; mission un-regressed. KB corrected +
swept (multiplayer.md task #52 notes). Awaiting live tester
verification of depleted-bin refill + revived weapons.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
8368163b04 |
Lobby loop: auto-rejoin between rounds + seat reclaim + live mission edits
The between-rounds arcade flow (first-playtest-night field request): at
mission end every pod exited, seats dropped, and every round required
everyone to re-run join.bat with no way to adjust the mission.
1. AUTO-REJOIN: StopMission (operator END / mission clock -- NOT a
window close) sets gBTMissionStoppedByConsole; after the matchlog
upload btl4main relaunches the pod into the join wait with the same
cmdline (BT_CALLSIGN/BT_MECH ride the inherited environment).
2. SEAT RECLAIM: the assigned tag is mirrored file-scope
(BTRelaySelfTag) and re-presented as the 3rd SEAT_REQUEST payload
field (BT_SEAT_CLAIM); the relay holds a dropped seat for its tag
for 90s and a returning player gets their EXACT seat back -- static
ordering across rounds, the real-pod model. Non-claim joiners skip
reclaim-held seats.
3. LIVE MISSION EDITS: the console gains "Apply mission settings"
(enabled while a session runs) writing arena/time/weather/length
into the session egg; the relay re-reads the file at round reset and
egg release (roster shape locked mid-session).
Verified 2-pod 3-round: pods self-relaunched after each clock end; the
SECOND-to-rejoin still reclaimed its original seat (ordering held); the
between-rounds edit landed ("mission length now 75s"). Known edge
noted in the KB: a stale ready flag from a not-yet-exited old conn can
satisfy the launch gate mid-rejoin -- operators launch on green dots.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
95735db5ae |
Operator console: relay-mode local instances go through the JOIN menu
The Launch-local-instances path pinned BT_SELF, which claims a seat directly and SKIPS the seat request -- the join menu (and the callsign/ mech choice it carries) never ran, so a local test instance couldn't exercise the walk-up flow. Relay-mode local instances now launch zero-arg with BT_FE_JOIN=1: the JOIN-GAME menu opens, the relay assigns the seat, and the choice lands in the egg like any join.bat player. Each instance gets a distinct relaunch port via BT_FE_JOINPORT (btl4main JoinRelay case; default 1501) -- two -net listeners can't share a port on one box. Mesh-mode locals keep the direct path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
fcedc046cf |
Walk-up callsign/mech request: join menu -> relay -> the session egg
The 1995 front-desk conversation, over the internet. join.bat/
join_lan.bat now open a JOIN-GAME menu (the FE in BT_FE_JOIN trim:
CALLSIGN edit + the 18-mech list + JOIN); the choice relaunches into the
normal join with BT_CALLSIGN/BT_MECH env, rides the relay SEAT_REQUEST
as {callsign NUL mech NUL} (empty payload = roster defaults, wire-
compatible), and the relay validates the mech tag, HOLDS egg delivery
until every pod's console pad is connected, rewrites the egg via
eggmodel (vehicle= + rasterized callsign bitmaps, graceful no-PySide6
fallback) and streams it to all pads -- so every player's egg copy
carries every player's callsign.
The hold is gated on CONSOLE-PAD count, not game-side registration: a
pod HELLOs only after parsing the egg's roster, so a registration gate
deadlocks (hit live in the first run; pods animate in WAITING FOR
MISSION ASSIGNMENT during the hold).
Verified 2-node e2e (env-injected requests): thor/vulture requested over
roster defaults bhk1/ava1 -> relay held 1/2, released at 2/2, rewrote
both seats (relay log), the delivered egg carries vehicle=thor/vulture +
name=VIPER/MONGOOSE, both pods launched and built [cyl] tables for both
requested mechs. Join-menu layout screenshot-verified; the menu's
click-through relaunch awaits live human verification.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
eb618fe9e6 |
BT_EXPIRE: tester builds stop working 14 days after build (compile gate)
Distributed test zips go stale and stray. BT_EXPIRE (CMake option,
DEFAULT ON so a zip can never accidentally ship without it) makes the
exe refuse to start BT_EXPIRE_DAYS (default 14) after its build day:
boot logs the remaining window, an expired build logs + shows a
"test build expired -- ask the operator for the current build" box and
exits. btversion.h now stamps BT_BUILD_UNIX (UTC-midnight-rounded so
the only-on-change header churns once per day, not every build) +
BT_BUILD_DATE. Dev is unaffected: every build re-stamps the day.
-DBT_EXPIRE=OFF builds a non-expiring exe; mkdist reads the cache and
marks such a zip "-noexpire" + prints a warning, and the tester README
expiry note is {EXPIRE}-marker-gated like the steam lines. Second
compile-gate exception after BT_STEAM (convention updated in
glass-cockpit.md). Quality gate, not DRM -- a clock rollback defeats it.
Verified live: default build logs "test build window: 14 day(s) left";
a -DBT_EXPIRE_DAYS=0 build logs TEST BUILD EXPIRED and parks on the
explanation box (window title checked) without starting the game;
restored to 14 and rebuilt clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
753540de96 |
MP match forensics: per-peer matchlog + relay auto-upload + matchcheck
For the 8-player playtest. Damage authority is VICTIM-side, so no single peer sees the whole match: every -net instance now writes a compact machine-parseable matchlog_<date>_<time>_<pid>.txt (auto-armed by -net; BT_MATCHLOG=1/0 overrides; solo silent), and at mission end a relay-mode pod dials the relay game port (the seat-request throwaway-dial pattern, envelope route -9) and streams the file back -- the relay saves every peer's copy under matchlogs/ on the operator's machine, so testers send nothing by hand. tools/matchcheck.py <dir> reconciles all of them: damage claimed (FIRE/PROJ/SPLASH/RAM, shooter-side) vs applied (DMG, victim-side authoritative), kill/death replication across observers, PLAYER_DEAD counts, scores, version skew, replicant-application and unattributed-damage anomalies, mid-mission disconnects. Hooks at the authoritative sites: Mech::TakeDamageMessageHandler (DMG, post-base, zone + post-application damageLevel), MechWeapon:: SendDamageMessage (FIRE), projectile impact/splash/collision dispatch (PROJ/SPLASH/RAM), wreck entry (DEATH -- a ONE-SHOT latch at MovementMode 9, NOT the transition: a replicant arrives at 9 straight from the type-6 record header and never runs the transition branch), BTPlayer vehicle/death/score handlers (VEHICLE/PLAYER_DEAD/SCORE), zone crit cascade (CRIT), APP.cpp RunningMission (MISSION), L4NET host handlers (PEER_UP/PEER_DOWN). Every line t=/w=/st=-stamped + fflushed. Verified 2-node: mesh run pairs A's 22 FIRE 1:1 with B's 22 DMG (same amounts, cylinder-resolved zones, ~200ms latency); force-damage kill run logs 103 DMG + 3 DEATH inst=M + 3 PLAYER_DEAD across three respawn cycles victim-side and the kill SCOREs shooter-side; relay-mode run auto-uploaded BOTH peers' files at the mission-clock stop and matchcheck produced the full report with exactly one (correct) anomaly -- the force hook's claim-less damage, the unattributed-damage detector working. Relay gained a route-specific 8MB cap for the upload frame (game frames stay capped at 1600). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9e85f6dc56 |
Desktop default platform = GLASS (kills the recurring "old keymap" trap)
Root cause of the keymap whack-a-mole: three input layers exist (PadRIO/ bindings.txt under glass; btinput/CONTROLS.MAP under DEV; the bring-up drive bridge also under DEV, which clobbers e.g. Alt-reverse every frame), and DEV was the DEFAULT -- so every launcher that forgot BT_PLATFORM=glass booted the old stack: the play bats, then play_steam, then the operator console's local instances, each fixed with the same one line. Omission can't resurrect it now: with no BT_PLATFORM, desktop boots GLASS (and the menu on a zero-arg launch -- bare double-click of btl4.exe opens the mission menu). BT_PLATFORM=dev explicitly opts into the legacy DEV profile for debugging. BT_PLATFORM=pod is the cabinet profile; the real pod's SETENV.BAT must set it (contract recorded in glass-cockpit.md -- the old "bare boot == cabinet shape" menu-guard assumption is retired). Verified: no-env mission launch boots GLASS; bare no-env launch opens the MENU; BT_PLATFORM=dev boots DEV. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
af22c4c78d |
FE menu: column 1 overlapped the footer (MenuClientH 620 -> 672)
The col-1 stack (MAP 8 + SCENARIO 2 + TIME 4 + WEATHER 3 + LENGTH 4 with headers/gaps) needs 630px; at 620 the last LENGTH rows collided with the 'click to select' footer band (client.bottom-30) -- user-reported from a screenshot. 672 fits the stack + footer. The LAUNCH/steam buttons and frame rect derive from MenuClientH and follow automatically. Also: 'No Return (one life)' clipped at the column edge -> '(1 life)'. Verified by screen capture of the live menu: LENGTH fully above the footer, scenario label unclipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
21b2bfc1c9 |
Issue #21: overcharge-bricked weapons -- latent ARCADE bug, deliberate divergence
The tester's ER small laser died permanently (recharge arc + ready dot dark, no fire) after idle seek-gear clicking. Root cause, byte-verified + headless- reproduced -- a latent bug in the SHIPPED ARCADE BINARY: - The charge integrates toward the GENERATOR voltage (@004ba838), not the gear target; "full" is only detected inside the +-1% snap window around seekVoltage[idx] (@004ba738), and overcharge reads ZERO (the byte-verified _DAT_004ba830 = 0.0 clamp). - Toggle seek while a charge is in flight and the level can land ABOVE the new gear's window: rechargeLevel pins to 0, the ==1.0 Loaded test can never fire, and charging continues to the generator ceiling where EVERY gear reads overcharged. Permanent brick; generator reselect cannot help. - The arcade dodged it by circumstance (locked 60 fps, rare seek use); the port's clickable seek button hits it in seconds of casual clicking. Fix (marked divergence in the Loading tick): overcharge counts as fully charged -> Loaded. The arcade's own discharge algebra says full == the gear's seek voltage, so an overcharged weapon IS full. Verified: the same 2.5-min BT_SEEKTEST abuse that bricked the laser pre-fix now fires 38 overcharge rescues and ends Loaded/pct=1. [seek] log now also prints the per-gear voltage table + gen voltages (diagnosis instrumentation). Ruled out on the way (documented in #21): overheat fuse (no firing occurred; a 4-min autofire+coolant-abuse run wouldn't fuse), generator detach (gen cycling + healthy siblings), seek-table corruption (table {6000,7000,8000, 9900} healthy). KB: decomp-reference.md records the arcade-latent-bug analysis. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e9db161404 |
Issue #20: wire Mech BalanceCoolant (id 0x16) -- the auto-cooling-balance button
Third instance of the silent-swallow pattern (unregistered handler id ->
Receiver NullHandler). The Mech handler table @0x50BDF8 binds
{0x16, "BalanceCoolant" -> @0049f728}; the reconstruction never registered it.
Disasm-verified body (@0049f728): press-only guard (msg+0xc > 0; the binary
has NO novice lockout on this one), set EVERY condenser's valveState@0x1D0
to 1 (equal weights), then the shared redistribute @0049f788 -- which the
port ALREADY had faithfully as BTRecomputeCondenserValves (flow =
valve/total + condenserAlarm pulse; MoveValve calls it). So the fix is:
- mech.hpp/mech.cpp: BalanceCoolantMessageID=0x16 + handler + MESSAGE_ENTRY
- heatfamily_reslice.cpp: BTBalanceCondenserValves bridge (Condenser is a
complete type there; sets valves to 1 + redistributes)
- mech4.cpp: BT_BALTEST scripted harness (MoveValve press alternating with
a Balance press)
VERIFIED headless (BT_BALTEST=1 BT_VALVE_LOG=1):
boot -> all valves 1, flows 0.1667 each
MoveValve-> condenser#1 valve 5, flows 0.5 / 0.1x5 (the priority boost)
BALANCE -> all valves 1, flows 0.1667 each (equalized)
Awaiting the tester's live ADV-mode confirmation.
KB: decomp-reference.md Mech-table row marked wired.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|