Compare commits
85
Commits
@@ -10,12 +10,15 @@
|
||||
`BTL4OPT.EXE` binary on top of the working WinTesla engine.
|
||||
**Repo of record:** the top-level `CMakeLists.txt` + `README.md` build `btl4.exe`. Layout:
|
||||
`engine/ game/ content/ docs/ reference/ tools/ context/`.
|
||||
**Current front (2026-08-06):** core gameplay reconstruction is COMPLETE — SP+MP loop, combat/
|
||||
damage/scoring, locomotion (gait/gimp/CROUCH), night kit (searchlight), death/respawn, replication
|
||||
all authentic + benched (builds 4.11.774→801). The decomp export was REBUILT 2026-08-06 (#60
|
||||
closed: coverage 93.5%, dark code 41 KB — cite `@ADDR`, NOT `part_0NN.c:LINE`; the old export is
|
||||
archived). Active work: the POLISH list. Details + what's-next: `context/project-overview.md`,
|
||||
`context/open-questions.md`, recent git log.
|
||||
**Current front (2026-08-11):** core gameplay reconstruction is COMPLETE and the K/D chain is
|
||||
STRESS-PROVEN (kd4/kd8 benches: receipt-derived invariants exact at 4 and 8 nodes, gotcha §25
|
||||
closed the same-frame double-credit). This week's settled fronts: projectile flight clocks +
|
||||
live-target contact (#168), generator-out voice at the trip (the port's ONE content deviation,
|
||||
#154), annunciator lamp table + stale-flash heal (#135), ordered exit (#163), ram eligibility
|
||||
(#128). The decomp export was REBUILT 2026-08-06 (#60 closed: coverage 93.5% — cite `@ADDR`,
|
||||
NOT `part_0NN.c:LINE`). Active work: field verification (8-player night) + the remaining
|
||||
POLISH list (#156 fade, weapon-state presentation). Details + what's-next:
|
||||
`context/project-overview.md`, `context/open-questions.md`, recent git log.
|
||||
|
||||
---
|
||||
|
||||
@@ -82,6 +85,7 @@ precise than anything you can infer.
|
||||
| Multiplayer, replication, netcode | `context/multiplayer.md` |
|
||||
| **The OPERATOR CONSOLE + RELAY** — how to launch it, ports, seats, the round/launch lifecycle, re-arm | `context/operator-console.md` |
|
||||
| Running a session as sysop (operator-facing how-to) | `docs/OPERATOR_GUIDE.md` |
|
||||
| The tester-facing OPEN-ISSUES handout (for Discord) | `docs/OPEN_ISSUES_FOR_TESTERS.txt` — regenerate from the tracker + update the header date before each playtest |
|
||||
| Pod hardware, monitors, RIO, MFD surfaces, input remap (CONTROLS.MAP/XInput) | `context/pod-hardware.md` |
|
||||
| Glass cockpit desktop dev layer (BT_GLASS/BT_STEAM gates, PadRIO, miniconsole, Steam) | `context/glass-cockpit.md` |
|
||||
| Steam internet MP (the wire seam, identity tokens, lobby) | `context/steam-networking.md` |
|
||||
@@ -91,7 +95,11 @@ precise than anything you can infer.
|
||||
| Term / acronym definitions | `reference/glossary.yaml` |
|
||||
| The ORIGINAL 1995 player manual (controls, per-mech stats, coolant loops) | `reference/manual/Tesla40_BT_manual.pdf` (+ alignment audit in `context/pod-hardware.md` §Manual) |
|
||||
| The complete verbatim detail (fallback) | `docs/PROGRESS_LOG.md` (the old 2236-line CLAUDE.md) |
|
||||
| Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md`, `docs/RESPAWN_REARM_PLAN.md`, `docs/KD_SCOREBOARD_PLAN.md`, `docs/DIST_LAYOUT_PLAN.md`, `docs/MOUSELOOK_PLAN.md`, `docs/GHOST_MECH_ANALYSIS.md` |
|
||||
| Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md`, `docs/RESPAWN_REARM_PLAN.md`, `docs/KD_SCOREBOARD_PLAN.md`, `docs/DIST_LAYOUT_PLAN.md`, `docs/MOUSELOOK_PLAN.md`, `docs/GHOST_MECH_ANALYSIS.md`, `docs/AUDIO_FIDELITY.md`; multi-agent audit ledgers (2026-08-11): `docs/KD_CHAIN_AUDIT_20260811.md`, `docs/MISSION_END_AUDIT_20260811.md`, `docs/RAM_LEAK_AUDIT_20260811.md`, `docs/GEN_WARNING_AUDIT_20260811.md` |
|
||||
|
||||
**`docs/` split note:** most of `docs/` is AI-facing DETAIL (the ledgers above + `PROGRESS_LOG.md`);
|
||||
the HUMAN-facing subset is exactly `OPERATOR_GUIDE.md`, `OPEN_ISSUES_FOR_TESTERS.txt`, and
|
||||
`dist/RELEASE_NOTES_*` — write for the right audience per file.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1384,7 +1384,7 @@ cmHeat(K,ModeSecondaryHeat,58,heatpal.pcc,heatpal2.pcc,Myomers);
|
||||
}
|
||||
|
||||
#=======================================================================
|
||||
# Sunder V1
|
||||
# Denkou (was: Sunder V1)
|
||||
#=======================================================================
|
||||
Snd1Init
|
||||
{
|
||||
|
||||
@@ -69,6 +69,23 @@ run\run.cmd [EGG] # default DEV.EGG; cd's to content\ and runs btl4.exe -eg
|
||||
⚠ **Keep the `if /I "%%P"=="btl4.exe"` guard** — parsing tasklist with `tokens=2` alone reads its
|
||||
*"INFO: No tasks are running"* line as a PID and spins forever with nothing running. Full
|
||||
investigation, including which windows exit vs merely hide: `phases/phase-12-orphan-processes.md`.
|
||||
- **Field-log anatomy — reading a steam night (#157 forensics, 2026-08-10) [T2].** A night's log is
|
||||
a CHAIN of `===== BT411 SESSION` blocks: menu stubs (`args=''`, "first process") alternating with
|
||||
glass generations (`-net 1501 -platform glass`, "relaunched generation"); one glass block = one
|
||||
drop, and boundaries are synchronized across every machine to ±2 s (mission-clock expiry relaunches
|
||||
all peers' menus in the same second). **Stub anatomy:** the steam lobby is CLICK-driven
|
||||
(`steamAction`, btl4fe.cpp), so a menu closed without a click logs exactly **4 boot lines** and
|
||||
exits 0 — a 4-line stub at end-of-night is a player going to bed, NOT a crash (all four machines
|
||||
ended night-14 with identical 4-line stubs at 00:31:2x). 7–8-line stubs = a lobby cycle
|
||||
(`[steamnet] up` + `[lobby] join` + map). `BTCrashFilter` is armed before the first log line and
|
||||
writes a `[crash]` block + module-relative stack on ANY unhandled exception — a silent log end
|
||||
means quit/kill/hang, never an unlogged AV. **`lastrun_<stem>.txt` caveats:** appends stop silently
|
||||
if the file gets locked (the `is_open` guard skips) — a lastrun ending mid-night while the log
|
||||
continues means the file was captured/locked during live troubleshooting, not that the chain died.
|
||||
And the bat sign-off ("The game has exited. If it closed unexpectedly, send…") prints after ANY
|
||||
chain end including a normal quit — it primes players to report quits as crashes; #157 ("did not
|
||||
exit cleanly") was exactly this echo. The arcade-authentic round-end (mission process exits →
|
||||
menu relaunch) itself reads as a crash to first-night players.
|
||||
- **Why this is a BT411-only hazard [T1].** The 1995 pod shipped ONE folder — `BTL4OPT.EXE` sits
|
||||
next to `BTL4.RES`/`VIDEO\`/`GAUGE\`/`AUDIO\` (it is still there in `content\`) — and RP411/RP412
|
||||
keep that shape (`pack-dist.ps1` copies the exe and every asset dir into one dist root). BT411's
|
||||
|
||||
+145
-18
@@ -199,6 +199,14 @@ Fixed-torso mechs (the BLH: `TorsoHorizontalEnabled=0` — no jointtorso in the
|
||||
boresight dead-ahead. Once locked, `Emitter::FireWeapon` converges with NO aim/arc test
|
||||
(part_013.c:7758). `MechWeapon::UpdateTargetState` (`FUN_004b9bdc` [T1]): `targetWithinRange =
|
||||
dist < (1 − hostZoneDamage) × weaponRange`. The 0x388 WRITER is in the same un-exported gap.
|
||||
⚠ `weaponRange` is **fire-control data only** (#168 audit [T1]): projectile FLIGHT is never
|
||||
range-capped — a plain shell (AFC) lives exactly 5.0 s (@4bddec) and a Missile lives
|
||||
BurnTime+10 s with a y<−1 kill-plane (@4bef78); FireWeapon @4bcc60 has no range gate, so an
|
||||
AFC50 (authored range 750) landing hits at 2000+ u is AUTHENTIC arcade behavior. Constants +
|
||||
the whole flight/expiry chain: [[decomp-reference]] §5 "Projectile/Missile flight & expiry".
|
||||
The same audit killed the pool's stale-point auto-hit (unguided contact was tested against
|
||||
the fire-time pick, so a locked AC shell could not miss a moving target — fixed 2026-08-11:
|
||||
the contact test now tracks the target's live position on every round kind).
|
||||
⚠ **`hostZoneDamage` is `Subsystem::damageZone->damageLevel` (weapon `@0xE0 → +0x158`), NOT
|
||||
heatLoad** — the port originally computed `effectiveRange = (1 − heatLoad) × weaponRange`
|
||||
(mechweap.cpp), the SAME `@0xE0`-DamageZone-vs-heat misattribution corrected in
|
||||
@@ -606,10 +614,21 @@ btplayer.hpp, static_assert-locked; `scoreAward`=APPLIED tally in all three):
|
||||
the VICTIM role's `killBonus` (role+0x1c). senderMechID = the victim. Suicide (eject charge,
|
||||
self-damage) IS dispatched — the handler negates the award (@0x4c03ab `fchs`) and skips
|
||||
`killCount++`: **the #134 panic penalty, live** (bench: `type=2 award=-39.00 kills=0`).
|
||||
- **B @0x4a05d9, not newly killed && tally≠0** → type 0 to the shooter. The ONLY registered 0x16
|
||||
receiver Verify-rejects type 0 → 1995 folded an UNINITIALIZED stack float into the shooter's
|
||||
score on every non-lethal hit (real 1995 bug — @0x4c0200, the handler that accepts type 0, is
|
||||
in NO table entry: dead code). Port sends it for wire fidelity, banks award 0.
|
||||
- **B @0x4a05d9, not newly killed && tally≠0** → type 0 to the shooter: the **per-hit INFLICTED
|
||||
credit**, and it is LIVE. ⚠ **CORRECTED 2026-08-07** — this entry previously read "the ONLY
|
||||
registered 0x16 receiver Verify-rejects type 0 → 1995 folded an UNINITIALIZED stack float into
|
||||
the shooter's score on every non-lethal hit (real 1995 bug — @0x4c0200 … is in NO table entry:
|
||||
dead code)". **That was wrong**, and build 787 retired the port's per-hit crediting on the
|
||||
strength of it (the scoring regression players reported on 4.11.817). `BTPlayer` overrides
|
||||
**`Dispatch` — vtable `@00513300` slot 3 = `FUN_004bffa0`** — and splits type 0 off BEFORE base
|
||||
dispatch: `if (id==0x16 && type==0) FUN_004c0200(...); else base;`. `@004c0200` names itself in
|
||||
its own Verify string (`BTPlayer::ScoreInflictedMessageHandler`) and computes
|
||||
`CalcInflicted(basis) → negate if target==self → × (targetTonnage/ownTonnage) → += +0x278`.
|
||||
`ScoreMessageHandler`'s type-0 arm Verify-rejects precisely BECAUSE the interceptor guarantees
|
||||
type 0 never reaches it. Independently corroborated by the original manual's SCORING CHART
|
||||
(`reference/manual/scoring_chart.webp`, via Lynx): "+1 each damage point scored on opponent's armor" and "-1 each
|
||||
self-inflicted point of armor damage" — the negate-if-self arm exactly. Interceptor restored;
|
||||
benched 83 inflicted rows, awards 0.98–25.00, zero type-0 Verify rejections. [T1]
|
||||
- **C @0x4a06c0, tally>0 (kills included)** → type 1 DamageReceivedScore to the VICTIM's player.
|
||||
Basis = INTENDED damage (burstCount×amount). senderMechID = the INFLICTOR. Feeds the received
|
||||
penalty (`CalcDamageReceivedScore` returns the NEGATIVE) + the operator-console VTVDamaged line
|
||||
@@ -617,9 +636,61 @@ btplayer.hpp, static_assert-locked; `scoreAward`=APPLIED tally in all three):
|
||||
Reports carry the LOOP-ENTRY zone (msg+0x24, never rewritten mid-loop), the vital-wreck flag, and
|
||||
`inflictingSubsystemID` (msg+0x5c, engine T0 name).
|
||||
|
||||
**Score model consequence** [T1]: 1995 pod scoring = **kill awards + received-damage penalties +
|
||||
death costs. No per-hit inflicted credit** (the port's old per-hit crediting — and the #95 salvo
|
||||
fix on top of it — were inventions riding the dead @0x4c0200 channel; both retired).
|
||||
**Score model consequence** [T1, ⚠ REWRITTEN 2026-08-07]: 1995 pod scoring = **per-hit inflicted
|
||||
credit + kill awards + received-damage penalties + death costs**. The previous text here claimed
|
||||
"No per-hit inflicted credit … inventions riding the dead @0x4c0200 channel; both retired" — that
|
||||
followed from the dead-code misreading corrected in report B above, and retiring the credit IS the
|
||||
scoring regression reported on 4.11.817. The **original manual's SCORING CHART** (`reference/manual/scoring_chart.webp`)
|
||||
is the cross-check for every row and should be consulted before touching this path again:
|
||||
|
||||
| Points | Event |
|
||||
|---|---|
|
||||
| +1000 | Starting the game |
|
||||
| +1 | Each damage point scored on opponent's armor |
|
||||
| +10..+30 | Destroying an opponent's internal system |
|
||||
| +500 | Destroying an opponent's 'Mech |
|
||||
| −1 | Each self-inflicted point of armor damage |
|
||||
| −10..−30 | Knocking out one of your own internal systems |
|
||||
| −500 | Destroying your own 'Mech by an ammo explosion |
|
||||
| −1000 | Destroying your own 'Mech by ejecting |
|
||||
|
||||
⚠ **THE SCORE AUTHORITY IS THE OPERATOR CONSOLE, not the player object** [T1, 2026-08-07]. The
|
||||
binary sends `ConsolePlayerVTVScoreUpdate(ownerID, currentScore)` every `CONSOLE_UPDATE_INTERVAL`
|
||||
and then does `param_1[0x9e] = 0` — **ungated**. So `+0x278` is a *console DELTA*, never a running
|
||||
total, and it does not matter which NODE computed a delta: every node's contribution is flushed
|
||||
stamped with the scoring player's `ownerID` and the console accumulates. This is almost certainly
|
||||
where the chart's **+1000 starting the game** was seeded, which is why no game-side code grants it.
|
||||
|
||||
**Consequence for the port** (no console as score authority): `GetScore()` (SCORE gauge),
|
||||
`Player::CalcRanking()` and the replicated `Player__UpdateRecord` all read `+0x278` **on the owning
|
||||
node**. Damage is applied on the VICTIM's node, so block B dispatches the inflicted report to the
|
||||
SHOOTER's player object there — a **REPLICANT** — and the credit is banked on the wrong machine,
|
||||
where the master's next update record overwrites it (benched: totals climb to ~35, snap back every
|
||||
few seconds = the field "scoring is screwy"). ⚠ OPEN. **Tried and rejected:** gating the type-0
|
||||
interception to `MasterInstance` so a replicant reroutes — the message arrives but the BT extension
|
||||
fields (`damageAmount`@+0x24, `senderMechID`@+0x34) do NOT survive the wire, only the base
|
||||
`scoreAward`, so every award computes 0.00. That is also WHY the kill report (type 2) already
|
||||
credits cross-node correctly: its value rides `scoreAward`. **Fix shape:** compute the award on the
|
||||
victim's node (where the damage data is) and ship the RESULT in `scoreAward`, as the kill report
|
||||
does — do not ship the basis and recompute where it cannot be seen.
|
||||
|
||||
**The damage-bias term is VESTIGIAL — do not "finish" it** [T1, audit 2026-08-08]. The kill/inflicted
|
||||
formula's `(victimAvgZoneDamage@0x354 × damageBias + 1.0)` factor is **always 1.0** in the shipped
|
||||
binary. `mech+0x354` has exactly one writer — `Mech::Reset` (@0049fb74, part_012.c:14340), which
|
||||
computes `mean(zone+0x158)` across every damage zone *after* the zone heal has zeroed those cells —
|
||||
and exactly one reader, `CalcInflictedScore` (@004c052c). Nothing recomputes it during play, so it
|
||||
holds ~0 for the mech's whole life. `0x358`/`0x35c` are the same computation over subsystem zones
|
||||
and have **no reader at all**. The port's `MECH_DAMAGE_BIAS(m) → 0.0f` therefore reproduces the
|
||||
binary exactly; wiring it to live damage would look like completing a stub and would silently
|
||||
inflate every award (the chart-verified +1/point and +500/kill both assume 1.0).
|
||||
|
||||
⚠ **Three chart rows are NOT yet reconciled with the reconstruction** — treat as open [T4]:
|
||||
(a) a kill benches at `award=4.88`, two orders off the chart's flat **+500**; (b) **+1000 at
|
||||
game start** has no known implementation; (c) **−1000 eject / −500 ammo** would live in
|
||||
`ScenarioRole::specialCaseDeathPenalty` (role+0x20), which the port reads
|
||||
(`GetSpecialCaseDeathPenalty`, the death-cost block) but which **shipped content authors
|
||||
nowhere**, so it is 0 in the field — the #134 symptom by another route. Do not "fix" these by
|
||||
hard-coding chart numbers; find where the binary sources them.
|
||||
Kill award = `(victimKillBonus + tally) × killerRole.damageInflictedModifier ×
|
||||
(victimAvgZoneDamage@0x354 × damageBias + 1.0) × (victimTonnage/killerTonnage)`; same-team kill in
|
||||
a non-FFA game = `-friendlyFirePenalty` basis (inline strcmp of `teamName@0x20c`, gate
|
||||
@@ -637,16 +708,6 @@ Benches: `scratchpad/night12/scorekill.sh` (cross-node kill: killer `kills=1 awa
|
||||
victim respawns, death #1 single-cycle) + `scoreself.sh` (#134 negation). Collision-death tail
|
||||
fallthrough is inspection-tier [T3] — shares the benched tail code; field wall-deaths exercise it.
|
||||
|
||||
**The handler's OTHER damageType branch — `damageType==4` (Energy) = the PPC cockpit-sync glitch
|
||||
[T1, 2026-08-06].** Between the collision divert and the burst loop sits a second type test
|
||||
@`0x4a03f3`: `cmp [esi+0x2c],4 / jne 0x4a0423`. On a match it calls the gauge renderer's vtable
|
||||
slot 19 with `((float)damageType × 0.2, 0)` = `(0.8f, 0)`, which detunes the **VGA CRTC Horizontal
|
||||
Total by −9** for 0.8 s — every secondary cockpit display loses horizontal sync, the main VPX view
|
||||
is untouched. `EnergyDamageType` is authored on **exactly the 14 PPC/ERPPC records and nothing
|
||||
else**, so this is structurally PPC-exclusive. It fires **once per damage message** (outside the
|
||||
burst loop). Full chain + addresses: [[gauges-hud]] §"PPC HIT = a deliberate CRTC horizontal-sync
|
||||
DETUNE"; port spec: `phases/phase-14-ppc-sync-distortion.md`. **Implemented 2026-08-06** (branch `ppc-sync-distortion`).
|
||||
|
||||
## (HISTORICAL — the gap as found 2026-07-29, superseded above) [T1]
|
||||
The authored crit machinery exists and is reconstructed — `Mech__DamageZone::CriticalHit @0049ccc4`
|
||||
(half the damage to armour, half to ONE critical subsystem chosen by `criticalWeight`, capped by
|
||||
@@ -1064,7 +1125,9 @@ player's advancedDamage copy (+0x268, `BTPlayerAdvancedDamageOn` bridge) — the
|
||||
elasticity@+0x244 by Mover-layout walk); scaled <0.5 = FREE. Above: `n=Round(2×amount)`
|
||||
sub-hits of amount/n, each landed on ONE roster subsystem drawn by cumulative
|
||||
`collisionCriticalHitWeight` (@0x10C, MechSubsystem) vs a [0,1) roll, eligibility =
|
||||
IsDerivedFrom HeatSink-family/Gyroscope/Torso (GUIDs 0x50e590/0x50fdc0/0x510b08; all
|
||||
IsDerivedFrom HeatSinkBANK/Gyroscope/Torso (GUIDs 0x50e590/0x50fdc0/0x510b08 -- ⚠ 0x50e590
|
||||
is the AGGREGATE BANK's node, NOT the HeatSink base family: the family-wide reading was the
|
||||
#128 instant-leak bug, corrected 2026-08-11, full record docs/RAM_LEAK_AUDIT_20260811.md; all
|
||||
MechSubsystem-based, so +0x10C is valid), applied via `ApplyDamageAndMeasure` (@0x4ac07c —
|
||||
the subsystem TakeDamage virtual into its PRIVATE crit zone). Un-won rolls land nowhere
|
||||
(weights un-normalized — faithful). Mech-vs-mech ram damage arriving as type 0 prices the
|
||||
@@ -1198,6 +1261,70 @@ code involved; grammar = the binary ctors @0x49ea48/@0x49e5e4). Results:
|
||||
cylinder** -- diverted at the handler head (0x4a0361: test type; 0x4a036d:
|
||||
call 0x49ffcc; jmp out) [T1 disasm].
|
||||
|
||||
## The death transition is SYNCHRONOUS within the damage pass (2026-08-11) [T1/T2]
|
||||
The binary writes movementMode 9 INSIDE the lethal TakeDamage pass — its own death-tail gate
|
||||
(part_012.c:14761) reads 9||10 already flipped in the same call. The port deferred the write to
|
||||
the per-frame UpdateDeathState and a same-frame direct+splash lethal pair re-ran the whole death
|
||||
block (duplicate kill credit + duplicate VehicleDead) — caught by the 4-node bench, fixed by
|
||||
running the once-per-death transition inline at the death tail (1b6d247). Full class write-up:
|
||||
[[reconstruction-gotchas]] §25; stress verification: kd4_bench 9/9/9/9 + kd8_stress 26/26/26/26,
|
||||
SWALLOWED=0 both. The receiver-side dedup remains a TRIPWIRE only (1995 has none; +0x290 never
|
||||
written) — if it ever fires again, a new duplicate source exists upstream.
|
||||
|
||||
## Key Relationships
|
||||
- Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]].
|
||||
- P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5.
|
||||
|
||||
|
||||
## The K/D-score AUTHORITY MODEL -- settled once and for all (2026-08-11, #162) [T1]
|
||||
|
||||
The 1995 chain has ONE emitter and NO receiver dedup; every counter is single-owner.
|
||||
Fixed twice piecemeal (#150 kills, #162 deaths) before the full audit settled it -- do
|
||||
not touch any single limb of this chain again without re-reading this section.
|
||||
|
||||
**Emitter (Mech::TakeDamageMessageHandler @0x4a0230 -- the victim's MASTER only;
|
||||
replicant-asserted part_012.c:14613):** entry latch `local_14` = destroyed-at-entry
|
||||
(modes 2|9, @0x49fb54). Three id-0x16 reports: **A** type-2 KILL -> shooter's player,
|
||||
alive->dead edge only, exclusive with B (basis = victim role's killBonus, @0x4a0506);
|
||||
**B** type-0 Inflicted -> shooter, non-lethal AND corpse hits (tally != 0); **C** type-1
|
||||
Received -> victim's own player, latch-INDEPENDENT (corpse hits included; basis =
|
||||
intended damage = bursts x amount). Death tail (gate `local_14==0 && mode 9|10`,
|
||||
@0x4a07b5): exactly ONE **VehicleDead** (id 0x17, deathCount=-1, killer player id) ->
|
||||
victim's own player, + explosion + delayed burn + splash (fresh TakeDamages, gate
|
||||
advancedDamageOn && !suppressConsole) + ForceUpdate. Collision (type-0 damage) diverts
|
||||
past ALL reports straight to the tail: a ram death counts + costs but credits no one.
|
||||
|
||||
**Consumer (BTPlayer):** 0x16 type-2 @0x4c02e4: CalcKillScore, killCount(+0x27c)++ dual
|
||||
(the phantom wrong-column partner -- keep byte-faithful), suicide fchs negation skips
|
||||
both increments. 0x17 @0x4c05c4 deathCount==-1 arm: deaths(+0x280)++ then STRAIGHT-LINE
|
||||
the -500 cost (gate advancedDamageOn(+0x264) ONLY; -role->specialCaseDeathPenalty
|
||||
(+0x20) via DIRECT Player::ScoreMessageHandler base call -- never on the wire, never in
|
||||
a matchlog; the [deathcost] DEBUG line is its only receipt). **The receiver dedup gate
|
||||
+0x290 is NEVER written non-zero anywhere in BTL4OPT.EXE [T1 image byte-scan]** -- the
|
||||
binary's single-increment guarantee is purely the SENDER's one-shot edge. Its clear
|
||||
pair (+0x290=0, +0x258=0) lives in the DropZoneReply tail (FUN_004c012c,
|
||||
part_013.c:10519) -- the #81-era port put it in the VehicleDead arm by misattribution,
|
||||
disarming the port's defensive latch (corrected 2026-08-11).
|
||||
|
||||
**Replication:** the player update record carries currentScore(+0x1c8) +
|
||||
dropZoneLocation ONLY. killCount/deaths never cross the wire in 1995; each node's
|
||||
scoreboard columns are locally accumulated (the port's SBMIRROR is a deliberate
|
||||
deviation that makes boards consistent).
|
||||
|
||||
**Full evidence record** (every dispatch/arm/layout with decomp line cites, the field
|
||||
receipt A/B, and the fix archaeology): `docs/KD_CHAIN_AUDIT_20260811.md`.
|
||||
|
||||
**Port rules that keep it correct:** (0) **the entry latch is MODE-based (movementMode
|
||||
2||9 == FUN_0049fb54), NEVER the alarm-based IsMechDestroyed()** -- EjectPilot raises
|
||||
graphicAlarm to 10 BEFORE its killBonus self-damage, so an alarm-based latch reads a real
|
||||
punch-out as "already destroyed" and silently eats the suicide type-2 (the -499 arithmetic)
|
||||
while cook-off suicides still score; caught by the eject canary 2026-08-11, the same
|
||||
alarm-vs-mode trap #108 fixed for deathBlastArmed; (1) the report block AND the death tail
|
||||
are both master-gated + entry-latched (mech.cpp) -- replicant-side damage application is a
|
||||
visual-only deviation and must never emit; (2) advancedDamageOn comes from the mission
|
||||
egg per-pilot page -- every stock egg stamps `advancedDamage=1` on every page, and the
|
||||
FE egg writer now stamps the host's menu choice mission-wide (a memset-zero pilot
|
||||
record shipped penalty-off for entire steam nights, #162); (3) the bench for ANY change
|
||||
here is scratchpad/night15/kd_bench.sh -- five HARD invariants (deaths==increments,
|
||||
cost-per-death, credit-per-kill, zero swallowed, arithmetic witness); its predecessor's
|
||||
assertions were greps that matched nothing, which is how both halves shipped broken.
|
||||
|
||||
+148
-1
@@ -253,6 +253,34 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
|
||||
@004b0b18) + @004b0abc (auto/manual, modeAlarm@0x2CC); attach/detach @004b0dd8/@004b0e30
|
||||
(generator client capacity @0x1e4/count @0x1e8).
|
||||
|
||||
### Projectile/Missile flight & expiry (#168 audit, 2026-08-11) [T1]
|
||||
- **No range cap exists anywhere in the flight chain.** WeaponRange feeds fire-control only
|
||||
(effectiveRange @0x328 = (1−hostZoneDamage)×WeaponRange — targeting/reticle "in range" call).
|
||||
FireWeapon @4bcc60 reads no +0x328: the trigger fires at any target distance.
|
||||
- **Plain Projectile (AFC shell) live integrator @4bddec** (master); expiry: age since
|
||||
spawn(+0x19c) > `_DAT_004be054` = **5.0 s** → FUN_0042061c kill. Dead-reckon divergence²
|
||||
vs predicted pos(+0x260, predictor FUN_00422060 via +0x254) > `_DAT_004be050` = 0.1 →
|
||||
updateModel(+0x18)|=1 (ForceUpdate, replication-dirty — NOT a kill).
|
||||
- **Missile live integrator @4bef78** (master); expiry: age > burnTime(+0x340, model rec
|
||||
+0x44) + `_DAT_004bf5ac` = **10.0 s** coast margin, OR pos.y < `_DAT_004bf5b0` = **−1.0**
|
||||
kill-plane → FUN_0042061c. Keepalive: no update for `_DAT_004bf594`=2.0 s → dirty bit.
|
||||
**Proximity fuse**: seeker rangeToTarget(+0x10C) < `_DAT_004bf5a4` = **4.0** → detonate on
|
||||
targetEntity without a geometry hit. Seeker drops a destroyed target (movementMode 2|9,
|
||||
FUN_0049fb54).
|
||||
- **Performance split** (Projectile ctor @4be1bc / Missile ctor @4bf5b4): instance
|
||||
(flags&0xC)==ReplicantInstance(4) → FUN_004221c0 dead-reckoning smoother (no physics);
|
||||
master → 4bddec/4bef78. (CLASSMAP's old authoritative/ghost labels were swapped —
|
||||
corrected 2026-08-11.)
|
||||
- **FUN_0042061c = Entity kill**: removes from world list + simulationFlags(+0x28) |= 0x2000|2.
|
||||
- All tick→seconds via `DAT_0052140c` = 28.0 (the frame-rate global).
|
||||
- Port consequences (both fixed in mech4.cpp, 2026-08-11): (a) the pool's old
|
||||
`p.range = len×1.3+60` distance-to-target cap was an invention — replaced by the
|
||||
authentic time caps; (b) the pool's contact sphere tested UNGUIDED rounds against
|
||||
the FIRE-TIME pick point and dispatched damage to the entity wherever it moved —
|
||||
a locked AC/Gauss could not miss a moving target (bench: 86/86 "hits" vs a
|
||||
circling mech). The pool now tracks the target's live position for the contact
|
||||
test on every round kind; only guided rounds steer.
|
||||
|
||||
---
|
||||
|
||||
### The CONFIG-MODE session chain (task #6) [T1]
|
||||
@@ -293,6 +321,29 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
|
||||
inflictingSubsystemID [T0]), +0x34 senderMechID}; BT VehicleDeadMessage 0x38 {engine 0x28 +
|
||||
0x28=0, +0x2c killed-by PLAYER EntityID, +0x34 kill zone}. Senders: the TakeDamage report tail
|
||||
— see [[combat-damage]].
|
||||
**#162 audit deltas (2026-08-11, full record docs/KD_CHAIN_AUDIT_20260811.md) [T1]:**
|
||||
VehicleDead@0x4c05c4 deathCount==-1 arm: deaths(+0x280)++ dual — `inc [ebx+0x280]` victim
|
||||
@0x4c067a unconditional + `inc [edi+0x280]` killer's copy @0x4c0674 (skipped on self-kill);
|
||||
then STRAIGHT-LINE the death cost @0x4c07cd-0x4c0828: gate `advancedDamageOn(+0x264)` ONLY,
|
||||
amount `-specialCaseDeathPenalty(role+0x20)` via `fld/fchs` @0x4c07dd, applied by a DIRECT
|
||||
call to engine Player::ScoreMessageHandler @0x42da20 (never dispatched, never on the wire —
|
||||
no matchlog receipt exists; the port's `[deathcost]` DEBUG line is the only witness).
|
||||
**deathPending(+0x290) is NEVER written non-zero anywhere in BTL4OPT.EXE** (three writes,
|
||||
all zero-stores: ctor @0x4c0c07, respawn helper @0x4c01e5, DropZoneReply tail) — 1995 has NO
|
||||
receiver-side death dedup; the sender's one-shot edge is the whole guarantee. The clear pair
|
||||
`{+0x290=0, +0x258=0}` lives in **FUN_004c012c = DropZoneReply**'s tail (part_013.c:10519-23)
|
||||
— NOT the VehicleDead arm (the #81-era port misattribution, corrected 2026-08-11). Latch
|
||||
asymmetry: the entry latch (FUN_0049fb54) tests modes **2|9** while the death-tail gate
|
||||
accepts **9|10** (part_012.c:14761). **The mode-9 write is SYNCHRONOUS within the damage
|
||||
pass** [T1 — 14761's gate reads it already flipped in the same call]: the sender one-shot
|
||||
only holds because of this timing. The port deferred it to the per-frame UpdateDeathState
|
||||
and a same-frame direct+splash double-lethal re-ran the death block (duplicate kill credit
|
||||
— the 4-node retest catch, fixed 1b6d247 by running the transition inline at the death
|
||||
tail; gotcha §25). `advancedDamageOn(+0x264)` ← the mission egg's
|
||||
per-pilot `advancedDamage=` key (every stock egg stamps 1 on every page); Player update
|
||||
record replicates ONLY currentScore(+0x1c8) + dropZoneLocation — killCount/deaths never
|
||||
cross the 1995 wire (the port's #45 record extension mirrors them owner→replicant; benched
|
||||
convergent at 4 and 8 nodes, kd4_bench/kd8_stress).
|
||||
- **HeatableSubsystem** @0x50E41C: {3, "ToggleCooling"→@004ad6f8}. **Disassembled 2026-07-20**
|
||||
(`tools/disas2.py 0x4ad6f8`): a per-subsystem coolant on/off TOGGLE (NOT a multi-level "cycle
|
||||
priority" — that's the emergent effect, not the mechanism):
|
||||
@@ -430,6 +481,78 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
|
||||
recomputes output via `(1 − damage) × rated` and zeroes it. stateAlarm 4's
|
||||
producer = the THERMAL BREAKER in GeneratorSimulation itself; a 2026-08-03
|
||||
destruction→state-4 bridge was removed as unfounded.
|
||||
- **Generator trip/recovery + the warning edge (#154 audit, 2026-08-11, all [T1] unless
|
||||
noted).** Trip: heatAlarm==FailureHeat(2) → stateAlarm 4, i.e. T > FailureTemperature;
|
||||
recovery gate (case-4 body @4b1f7c) = heatAlarm back to **NormalHeat(0)**, i.e.
|
||||
T ≤ DegradationTemperature — then 4→0 Starting → StartTime spin-up → 0→2 Ready.
|
||||
Authored generator temps (raw BTL4.RES): start=77, degradation=**1000**,
|
||||
failure=**2000** on all six vehicles — recovery at exactly HALF the trip line;
|
||||
manual p23 prints the half-way rule verbatim, and the heat bar's colored fill
|
||||
(VertTwoPartBar @4c48fc) exists exactly while T > 1000, so "red gone" == the
|
||||
recovery gate to the tick. NO hysteresis in the heatAlarm ladder (@4ad924).
|
||||
**The warning edge is authored backwards in the shipped content**: BTL4.RES
|
||||
streams exactly TWO AudioStateTriggers per generator on GeneratorState —
|
||||
(trigState=1 Idle, inv=0, Start) and (trigState=4 GeneratorOut, **inv=1**, Start,
|
||||
= fire on LEAVING 4). No enter-4 trigger exists (RES-wide census: 144/144 rows,
|
||||
triple-verified decode + live [audioedge] capture). Consequences, ALL live-benched
|
||||
(`scratchpad/night15/gen_edge_bench.sh`, receipts BT_AUDIO_EDGE/[audioedge] +
|
||||
BT_GEN_HEAT injection): thermal trip 2→4 SILENT; natural recovery 4→0 FIRES the
|
||||
warning; respawn-with-tripped-gen RTIS (4,0) FIRES it (ghost warning during
|
||||
respawn); crit-death fires enter-Idle one frame late; 4→1 (cooled while switched
|
||||
off) double-fires. The generators' inv+Start is the ONLY inverse-Start fault
|
||||
trigger in the game (census) — a 1995 authoring slip [T4 intent]. Port machinery
|
||||
is faithful end-to-end. **DELIBERATE DEVIATION shipped 2026-08-11 (user-approved):**
|
||||
the AudioStateTrigger ctor (AUDWTHR.cpp) flips the GeneratorState (trigState=4,
|
||||
inv=1) record to inv=0 at stream time — warning now fires at the TRIP, silent at
|
||||
recovery/respawn; `BT_GEN_WARN_SHIPPED=1` restores shipped behavior. This is the
|
||||
port's ONLY authored-content override (precedent: the self-hit score deviation).
|
||||
**The warning IS a VOICE — CORRECTED 2026-08-11 (a same-day wrong claim swept):**
|
||||
the Warnings01/AllWarning zones are the project's recorded voice material (Yip,
|
||||
original VWE) and the AudioControlSequences chain the zone-WORDS into spoken
|
||||
phrases — established by the #119 leak-voice work (commit 42b0691: "the warning
|
||||
is a phrase-sequenced voice patch (Warnings01 zones as notes)"). The generator
|
||||
sequence (notes 29/16/26 = zones z4/z0/z3) is the spoken "generator out" phrase —
|
||||
**ear-verified 2026-08-11** (zones played in sequence order, rate-patched copies) [T2]. An earlier revision of this entry claimed BT 4.10 shipped no speech and
|
||||
attributed era memories to Firestorm — WRONG: the claim rested on preset NAMES
|
||||
and was contradicted by our own git history. With the voice real, era memories
|
||||
of hearing it at the trip may be genuine (possibly a later floor-content
|
||||
revision fixing the same bit our deviation flips).
|
||||
⚠ Process lesson: before asserting an asset ABSENT, grep the project's own git
|
||||
log and prior tickets for the asset family (#119 was one `git log --grep=voice`
|
||||
away; six audit agents and the synthesizer all missed it).
|
||||
Voice-steal note: night-15 field logs show up to 932 channel steals/session — even
|
||||
fired Starts can be eaten at combat peak (L4AUDRND weight-steal).
|
||||
Diag hooks: BT_GEN_HEAT=<units/s> (+_DELAY/_SECS) injects into viewpoint
|
||||
GeneratorA (thermalMass=726000 raw units — rate ~1.5e8 for ~18-25 s trips it);
|
||||
BT_AUDIO_EDGE=1 prints every state-edge trigger delivery FIRED/no-fire.
|
||||
Related corrections: AmmoState 3 = EJECTING (an earlier lane's "cook-off enter-3"
|
||||
label was wrong — cook-off warning rides FireCountdownStarted matchers); manual
|
||||
p23 also answers #133: the MFD X = generator destroyed by CRIT, not overheat.
|
||||
- **THE CONDITION FLAGS ARE ROUTINE, NOT ALARMS — field-verified 2026-08-08 [T2]:** a
|
||||
`[techstat] <sub> condition <n> SET` is an *operating* flag ("this component is above
|
||||
nominal / browned out RIGHT NOW"), not a fault report. Census over a full Steam match
|
||||
(`steam_20260806_c_michael_XIAOLONG.log`): cond 3 **Overheating** fires 33× on LLaser_2,
|
||||
31× LLaser_1, 26× SRM4, 18× PPC_2 — every weapon trips it on each volley and clears it on
|
||||
cooldown; cond 6 **BadPower** fires 8× on Myomers, 3× each on SLaser_1/PPC_1/MLaser_1 as
|
||||
the bus browns out under simultaneous draw. **Every subsystem's SET/CLEARED counts are
|
||||
balanced** (GeneratorD 5/4, GeneratorA 8/7, Myomers 5/4, Condenser5 1/1 — the odd extra SET
|
||||
is only the log ending mid-heat). Nothing latches. **Never read a post-respawn `condition 3
|
||||
SET` as a reset failure** — that inference cost a full two-sided reset audit (#137 below).
|
||||
Diagnose a heat complaint from the SET/CLEARED *balance* and the throttle state, not from
|
||||
the presence of a SET.
|
||||
- **#137 "respawn came back with MYOMERS heat MAXED" = NOT A BUG, closed 2026-08-08 [T2]:**
|
||||
`Mech::Reset` is clean on both sides — bench: every roster subsystem incl. all six
|
||||
Condensers at `T=77 start=77`; field log: every live condition CLEARs 12–16 lines after each
|
||||
reset. The heat is real and immediate because **the mech respawns still under power**: at the
|
||||
reset the log reads `thr=1`, `cycleSpeed=14.6`, gait state 12 (running), and the myomers →
|
||||
Condenser5 → GeneratorD trip Overheating within ~1–2 s. `Mech::Reset`'s subsystem loop starts
|
||||
at **index 2** and the ControlsMapper is **index 0**, so the throttle is never reset — and the
|
||||
binary does the same, which is correct for a pod whose throttle is a PHYSICAL lever still
|
||||
under the pilot's hand. **Desktop caveat (real, and separate):** the glass bridge emulates
|
||||
that lever with a file-static ramp accumulator `sLever` (mech4.cpp:3250), zeroed ONLY by the
|
||||
X all-stop and a direction-crossing snap — so a pad/keyboard pilot, who is physically holding
|
||||
nothing, likewise respawns at speed with the lever state invisible. Port-layer question, not
|
||||
a heat bug.
|
||||
- **DEATH SCORE COST decoded 2026-08-02 (#118 tail) [T1]:** the death handler tail
|
||||
`@004c07cd-0x4c0828` (inside the @004c05c4 export gap — missing from the #52
|
||||
reconstruction) gates on `advancedDamageOn`(+0x264) and hands the ENGINE base
|
||||
@@ -439,7 +562,17 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
|
||||
Direct call, NOT dispatched — bypasses the BT handlers' type Verifies. NUANCE [T4]:
|
||||
the 1995 engine adds at Player+0x1c8 while every BT scoreboard reads +0x278 — the
|
||||
pod's death cost may never have displayed; our single-cell port shows it. Shipped
|
||||
content authors NO role keys, so the cost is 0 in the field. Other role+0x1c reader:
|
||||
content authors NO role keys, so the cost is 0 in the field. **CORRECTED 2026-08-07 —
|
||||
that sentence was wrong on both halves.** The role's scoring fields are not authored via
|
||||
notation keys at all: the ctor `@00429a9c` loads them from the role MODEL's GameModel
|
||||
record (type 0xf, 7 dwords — rec[0]=killBonus, rec[1]=specialCaseDeathPenalty,
|
||||
rec[2]=dmgRcvd, rec[3]=dmgInflctd, rec[4]=bias, rec[5]=friendlyFire,
|
||||
rec[6]=returnFromDeath); the notation keys are optional OVERRIDES. Shipped content
|
||||
authors `Role::Default` (model `dfltrole`) with **killBonus=500, deathPenalty=500,
|
||||
dmgInf=1, dmgRcv=0, bias=1, ff=1, return=1000** — the original manual's scoring chart
|
||||
verbatim (`reference/manual/scoring_chart.webp`). The cost read 0 only because
|
||||
`BTPlayer::scenarioRole` was never BOUND (the registry lookup sat commented out); wired
|
||||
2026-08-07 and the cost is verified APPLYING at 500. Other role+0x1c reader:
|
||||
`@0x4a0506` inside the deferred id-0x16 report tail (#45) reads killBonus.
|
||||
- **THE SIM TIME MODEL — CLOSED 2026-08-02 (issue #96) [T1]:** the arcade
|
||||
`Simulation::PerformAndWatch` is **`FUN_0041c018`** (part_002.c:5101):
|
||||
@@ -489,6 +622,20 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
|
||||
@00444c80` (lamp map at renderer`+0x1c0c8`) → **`Lamp::SetAlertState @00444e64`** (the flash
|
||||
COUNTER at lamp+0x1C) → the L4 lamp flush `@00474e94` emits `0x37`/`0x13` = `flashFast` RIO
|
||||
states (== T0 L4LAMP.cpp:234-239) → `RIO::SetLamp` (binary `FUN_00476568`; cached wrapper
|
||||
- **#135 (2026-08-11): the GENERATOR special @004cc27c is `table_0051d070[generatorNumber]`**
|
||||
(sub+0x1E0, A=1..D=4, 1-based; bytes byte-read: {0x29, 0x1A,0x1B,0x1C,0x1D}; slot 5 is
|
||||
already string data — bounds-guard 1..4) [T1] — the SAME shape as the condenser special
|
||||
@004cc264 beside it. The port had mis-reconstructed it as an aux-screen placement probe
|
||||
(fields generators never fill) → the field-logged `NO LAMP RESOLVED (generator=1)` silent
|
||||
generator leak; benched fixed (`[lamp] 0x1a <- 0x37 (FLASHING)` on a GeneratorA leak).
|
||||
- **#135 stale-flash gotcha [T0]:** `Lamp::SetAlertState/SetState` notify the panel ONLY when
|
||||
the lamp's modeMask intersects the manager's current mask — an edge landing off-page (or in
|
||||
the one-frame mask race of a page transition) is silently dropped, and the panel value
|
||||
LATCHES the last-asserted flash (Oracle: "Loop 6 NEVER stopped flashing"; his log shows
|
||||
Condenser6 SET/CLEARED balanced 3/3 — the manager was right, the lamp was stale). Port fix:
|
||||
the skipped notify is remembered (`staleAlertNotify`) and delivered by the manager's
|
||||
active-lamp loop at the next in-mode frame (LAMP.cpp). Field SET/CLEARED balance + a stuck
|
||||
flash = this gotcha, not an alarm leak.
|
||||
`@00474d54`). T0-compiled already: LAMP.cpp / L4LAMP.cpp / GAUGALRM.cpp / RENDERER messages.
|
||||
**✅ ALL THREE PIECES BUILT same day (see [[open-questions]] for the full closure note).**
|
||||
Corrections pinned while building: the SENDER is **`MechTech::TechnicalAssistance` @004ad33c**
|
||||
|
||||
+40
-84
@@ -13,7 +13,6 @@ open_questions:
|
||||
- "Upper-MFD PRESET pages RESOLVED 2026-07-19 (Gitea #9): SetPresetMode table @0051dbf0 re-decoded (little-endian -> ModeMFD bits 0-14), per-MFD pod button banks identified from the .CTL dump, desktop J/K/L cycle wired"
|
||||
- "Always-active msg-4 records IDENTIFIED 2026-07-20 (glass input audit): 0x2C = Reservoir InjectCoolant (the flush button), 0x2F/0x2E/0x2D/0x2B/0x2A/0x29 = Condenser1-6 MoveValve, 0x1A-0x1D = GeneratorA-D ToggleGeneratorOnOff (@0050fb90; wired 2026-07-25, powersub.cpp); plus 0x13 = Mech DuckRequest (CROUCH -- COMPLETE 2026-08-06, [[locomotion]]), 0x28 = Mech BalanceCoolant, 0x12/0x14 = ThermalSight/Searchlight toggles (searchlight visuals done 2026-08-05) -- see pod-hardware.md + docs/GLASS_COCKPIT.md; statuses re-swept 2026-08-06"
|
||||
- "MP DEATHS resolved 2026-07-12 (observed-death tally + display clamp); remaining: verify multi-death tallies stay in sync across a long session (GAUGE_COMPOSITE.md)"
|
||||
- "PPC `scrambleVideo` IMPLEMENTED 2026-08-06 (branch ppc-sync-distortion): a PPC hit scrambles every secondary display for 0.8 s (modern per-scanline shear stand-in for the CRTC Horizontal-Total detune). Trigger in Mech::TakeDamageMessageHandler (damageType==4), visual in SVGA16::FunkyVideo/ScrambleRowShift (DrawDevSurface + ExpandPlaneToBGRA), non-stacking latch in L4GaugeRenderer::SpecialEffect. Screenshot-verified; live PPC-fire confirmation + by-eye shear tuning (BT_SCRAMBLE_SHEAR/ROLL) left to playtesters. Spec: phases/phase-14-ppc-sync-distortion.md"
|
||||
---
|
||||
|
||||
# Cockpit Gauges / MFD HUD
|
||||
@@ -415,6 +414,14 @@ Verified live: bay fire → lamp 0xD (the LRM's select button) flashes 0x37 + en
|
||||
on detonation/purge. Diagnostics: `BT_LAMP_LOG` → `[techstat]`/`[galarm]`/`[lamp]`. Details +
|
||||
the four load-bearing fixes en route: [[open-questions]] + [[decomp-reference]] §GaugeAlarm.
|
||||
|
||||
**These conditions are ROUTINE and SELF-CLEARING [T2, field-verified 2026-08-08].** A `SET` is an
|
||||
operating state, not a fault: every laser volley trips Overheating (cond 3) and clears it on
|
||||
cooldown (33× on one LLaser in a single match), and BadPower (cond 6) flickers whenever
|
||||
simultaneous draw browns the bus. Across a full match every subsystem's SET/CLEARED counts are
|
||||
balanced — nothing latches. So a lamp flashing after a respawn is the mech *operating*, not a
|
||||
failed reset; diagnose from the SET/CLEARED balance, never from a lone SET. Full census + the
|
||||
#137 post-mortem it settled: [[decomp-reference]] §TechStatus.
|
||||
|
||||
## ConfigMapGauge (the weapon panel's trigger-config joystick) — LIVE via LinkToEntity (2026-07-21)
|
||||
The per-weapon btjoy.pcc joystick image + 4 cm_* state lamps (off/other/only/both) showing,
|
||||
for each mappable fire button (Pinky/ThumbLow/Trigger/ThumbHigh), whether THIS panel's weapon
|
||||
@@ -550,6 +557,38 @@ and every instrument is now live [T2]:**
|
||||
transcription color bug caught by a period reference screenshot, 2026-07-09; same for the
|
||||
bottom bowtie carets @4569-4570); pegs at 1200 with no target; the DISPLAYED range slides at
|
||||
**500 m/s** toward the true pick range (HudSimulation :5652 [T1]).
|
||||
**The HudSimulation tuning constants, read off .rdata 2026-08-08 [T1]** (`section_dump.txt`
|
||||
rows ` 4b7ec0 8be55dc3 0000403f 0000803f 0000c842` / ` 4b7ed0 00000000`) —
|
||||
`_DAT_004b7ec4` = **0.75f**, `_DAT_004b7ec8` = **1.0f**, `_DAT_004b7ecc` = **100.0f**,
|
||||
`_DAT_004b7ed0` = **0.0f**, `_DAT_004b7f90` = **0.0f**. ec4/ec8 are the fire-control **LOCK**
|
||||
limits (own HUD host zone < 0.75 damage, targeted zone < 1.0 — so a shot-up cockpit drops to
|
||||
"target held, no lock", and a dead zone can't be re-locked); ed0 is the shared **zero** in the
|
||||
range-slide `Abs()` idiom (the 500 is an immediate `0x43fa0000`, NOT a global). `hud.cpp` had
|
||||
carried all five as 0.0f/500.0f stand-ins under guessed names ("SegmentTempLimit … heat
|
||||
threshold for HUD page visibility" was neither heat nor page visibility) — corrected in place,
|
||||
with the live lock/slide implementation staying in mech4.cpp's targeting step, which had both
|
||||
thresholds right all along.
|
||||
**THE CARET CAN DIE FOR THE WHOLE SESSION — #147, fixed 2026-08-08 [T2 code, T3 field link]:**
|
||||
`sShownRange` (mech4's targeting step) is a **function-level static** — one cell per process,
|
||||
shared by every mech, carried across drops. **NaN is absorbing** in `step = trueRange −
|
||||
sShownRange; sShownRange += step`, and neither the producer's clamps nor
|
||||
`BTReticleRenderable::Draw`'s (`range < minRange` / `range > maxRange`) catch it — both
|
||||
comparisons are **false for NaN**. A poisoned value therefore reaches `AddPoint`/`ConcatMatrix`,
|
||||
and the caret + its bar become **degenerate geometry that stops rendering while the static tick
|
||||
marks keep drawing** — the exact reported symptom ("no range finder on this drop", ticks there,
|
||||
caret gone), sticky until relaunch. Fixed four ways: re-seed on mech change (not on respawn —
|
||||
that reuses the entity, and the binary doesn't reset the readout either), a producer NaN trap, a
|
||||
NaN-safe consumer clamp falling back to the 1200 peg, and — the reason no log could settle it —
|
||||
**`BT_RANGE_LOG` now prints the caret's actual input** (`[range] caret input shown=`). It never
|
||||
did before: `BT_RANGE_LOG` instrumented the PICK (#4) and `[target]`'s `range=` is a *separate*
|
||||
locally-recomputed Sqrt in the weapon-range check, so grepping field logs for NaN found nothing
|
||||
because the poisoned variable was never printed. Causal link to the field report is INFERENCE.
|
||||
**GAP — the 100 m RANGE BIAS is NOT reconstructed [T1 read, unimplemented]:** HudSimulation
|
||||
subtracts `_DAT_004b7ecc` (100.0f) from `RangeToTarget@0x1EC` **every frame while the timed
|
||||
flag @0x22C is set**, accumulating @0x21C by `time_slice` until it reaches @0x1D8, then clearing
|
||||
both. The port's targeting step (mech4.cpp) does the slide but never the bias, so whatever
|
||||
in-game state sets @0x22C currently produces no range offset. Trigger for @0x22C not yet
|
||||
identified — see [[open-questions]].
|
||||
**VERDICT (Gitea #4, 2026-07-20): the "range slides in/out crazily while walking" report is
|
||||
AUTHENTIC behavior, not a bug [T2 measured].** Per-frame `BT_RANGE_LOG` traces (mech4.cpp, with
|
||||
an independent Möller-Trumbore cross-check `BTGroundRayHitExact` in btvisgnd.cpp) on scripted
|
||||
@@ -756,89 +795,6 @@ pooling fix) — an alarm that cannot acquire a source is silent.
|
||||
⚠ The bench cannot confirm audibility: it runs with no audio device (`live=0
|
||||
pooled=0`), so the control chain is verified but final playback is not.
|
||||
|
||||
## PPC HIT = a deliberate CRTC horizontal-sync DETUNE on every secondary display (2026-08-06) [T1 disasm-verified]
|
||||
|
||||
**✅ IMPLEMENTED 2026-08-06** (branch `ppc-sync-distortion`; screenshot-verified —
|
||||
every secondary MFD + the radar shear together, the out-the-window view stays
|
||||
clean). Trigger + visual + non-stacking latch — see
|
||||
`phases/phase-14-ppc-sync-distortion.md` for the port details and the
|
||||
`BT_SCRAMBLE_*` tuning envs. Reported by playtesters as "being hit by a PPC makes
|
||||
it look like all of the secondary CRTs were being degaussed" — main (VPX) view
|
||||
unaffected, PPC strikes only. Both observations are exactly what the binary does.
|
||||
The disasm chain below is the ground truth the port was built from.
|
||||
|
||||
**The gate is the damage TYPE, and only the PPC has it.** A `BTL4.RES`
|
||||
subsystem census gives `damageType` 4 (`EnergyDamageType`) = **14 records,
|
||||
every one PPC or ERPPC**; everything else is Ballistic (16), Explosive (30),
|
||||
Laser (78). So a branch keyed on type 4 is structurally PPC-exclusive.
|
||||
|
||||
The chain, on the **VICTIM's** machine (all `@` from `BTL4OPT.EXE`,
|
||||
md5 `a97075bcb5634d13263e9ad5a2b96fd0`):
|
||||
|
||||
1. `Mech::TakeDamageMessageHandler` @`0x4a0230`, branch @**`0x4a03f3`** — sits
|
||||
between the collision divert and the burst loop, so it runs **once per
|
||||
damage message**, not per burst:
|
||||
```
|
||||
004a03f3 mov ecx,[esi+0x2c] ; damage.damageType
|
||||
004a03f6 cmp ecx,4 ; EnergyDamageType
|
||||
004a03f9 jne 0x4a0423 ; everything else -> burst loop
|
||||
004a03fb mov eax,[0x4efc94] ; the global `application`
|
||||
004a0400 mov eax,[eax+0x4c] ; -> gauge renderer
|
||||
004a0405 je 0x4a0423 ; null-guarded
|
||||
004a0407 fild dword [esi+0x2c] ; (float)damageType == 4.0
|
||||
004a040a fld xword [0x4a0c08] ; long double 0.2
|
||||
004a0410 fmulp st(1) ; => 0.8
|
||||
004a041d call dword [edx+0x4c] ; vtable slot 19, args (0.8f, 0)
|
||||
```
|
||||
⚠ The duration is **derived, not constant**: `(float)damageType × 0.2`.
|
||||
2. Gauge-renderer vtable @`0x51cebc`, slot 19 (`+0x4c`) = @**`0x46ffcc`**
|
||||
(an `L4GaugeRenderer` method — the 0x46xxxx MUNGA_L4 range, so the
|
||||
capability is shared-engine; BT is what wires it to Energy damage).
|
||||
Second arg must be 0 (`sub eax,1; jae ret`). Body: `svga16 = this[+0x1c52c]`
|
||||
(null-guarded — same member RP fetches for its `FlashPalette`), then
|
||||
`this[+0x1c534] = 1` (active) and `this[+0x1c538] = now + 0.8 s` in ticks.
|
||||
3. @`0x46d840` — thin wrapper, drops `this`, forwards the flag.
|
||||
4. @**`0x47d76d`** — the payload, straight VGA CRTC I/O:
|
||||
```
|
||||
out(0x3D4,0x11); v=in(0x3D5); out(0x3D5, v & 0x7F) ; unlock CRTC regs 0-7
|
||||
out(0x3D4,0x00) ; CRTC 0 = HORIZONTAL TOTAL
|
||||
if (arg==0) { out(0x3D5, saved); modified=0; } ; restore
|
||||
else if (!modified) { modified=1; saved=in(0x3D5);
|
||||
out(0x3D5, saved-9); } ; <<< shorten the scanline
|
||||
out(0x3D4,0x11); out(0x3D5, v) ; restore write-protect
|
||||
```
|
||||
Globals: `modified` @`0x4fe0fe`, `saved` @`0x4fe0ff`. The `modified` latch
|
||||
makes it **idempotent** — overlapping PPC hits do NOT stack, and a second
|
||||
hit does not re-save an already-detuned value.
|
||||
5. @**`0x47003c`** (per frame): `if (active && now >= expiry) { active = 0;
|
||||
SVGADistortSync(svga16, 0); }` — restores the saved Horizontal Total.
|
||||
|
||||
**Why it reads as a degauss, and why only the secondaries.** CRTC register 0 is
|
||||
the character-clock count per scanline — it *is* the horizontal scan frequency.
|
||||
−9 drives every attached monitor's horizontal oscillator off frequency: the
|
||||
image shears/rolls/wobbles until it re-locks, then snaps back 0.8 s later. All
|
||||
six secondary displays are derived by the VDB from that one VGA's timing, so
|
||||
they glitch **together**; the main view comes off the Division VPX card on an
|
||||
independent timing chain and is untouched. No relay, no VDB register, no
|
||||
palette work — the VDB just propagates a deliberately corrupted sync. (The
|
||||
`LampTesla1/2/3` "solid-state relays" in `L4CTRL.HPP` are NOT involved and are
|
||||
driven by nothing in the surviving tree.)
|
||||
|
||||
⚠ **Do not confuse this with the `SVGA16::FlashPalette` pixel-mask cycler**
|
||||
(`flashRate`/`mask[4]`, ports `0x302/0x30A/0x312`). That machinery is linked and
|
||||
its per-frame cycler runs in BT, but `FlashPalette` @`0x46d5f4` has **zero call
|
||||
sites and zero address-of references** in `BTL4OPT.EXE` — BT never arms it.
|
||||
**RP does**: `RPL4OPT.EXE` @`0x4addce` calls `FlashPalette(palette 1 =
|
||||
SecondaryPalette, rate 2.0, masks {FF,BF,7F,3F})` from its gauge-renderer ctor —
|
||||
hardware-assisted alarm blinking by masking off the top two pixel bits. Same
|
||||
pods, so it is an easy source of cross-game misattribution.
|
||||
|
||||
Scope note: callers of vtable slot 19 were not exhaustively enumerated (virtual
|
||||
dispatch); the PPC site was found via the three `application+0x4c` uses
|
||||
(`0x4a03fb` here, `0x4cc3be` / `0x4d1559` unrelated). The low-level path IS
|
||||
exhaustive — @`0x47d76d` has exactly one caller, and @`0x46d840` exactly two
|
||||
(set @`0x47002b`, restore @`0x470076`).
|
||||
|
||||
## Key Relationships
|
||||
- Full history: `docs/GAUGE_COMPOSITE.md`; reticle recovery: `phases/phase-02-dpl2d-reticle.md`.
|
||||
- Uses: [[attribute-pointer]] + [[reconstruction-gotchas]]; reads [[subsystems]] state.
|
||||
|
||||
@@ -273,6 +273,43 @@ number the peer Standing case walks on). Verified: arena circle replicant 218×s
|
||||
replicant, every `MechControlsMapper` demand cell except those explicitly re-derived
|
||||
(`turnDemand`) is DEAD — any peer-side state machine judging a local mapper read is judging 0.**
|
||||
|
||||
**The #52 SEQUEL: the peer body-channel STANDING-LOCK (root-caused + fixed 2026-08-07) [T2].**
|
||||
Fixing (2) above closed an ACCIDENTAL escape hatch and the skate came back in a new shape. The
|
||||
port's body case 4 is an INSERTION (the task-#64 lockstep twin) sitting between case 0 and the
|
||||
advance group — but in the binary `case 4` is a **member of that advance group**
|
||||
(`FUN_004a5678` @004a5678: `case 2,3,`**`4`**`,5,8,…`, no turn block, no speed exit) [T1], so
|
||||
case 0's fallthrough is supposed to land on `Advance()`. The insertion intercepted it. On a
|
||||
REPLICANT that is fatal and not a race: case 0 arms walk iff `standSpeed < bodyTargetSpeed`, and
|
||||
the inserted block's exit tests `standSpeed < bspd` where `bspd` **IS** `bodyTargetSpeed` on a
|
||||
replicant — the *same expression*. Arm and reset therefore fire on the same frame, every frame,
|
||||
and a peer parked at Standing with a live replicated demand can never start cycling (reverse
|
||||
likewise: both sides test `< ZeroSpeed`). Before e91d447 the replicant branch read the dead mapper
|
||||
cell (0 forever) so the exit never fired and the fallthrough worked by accident. **Fix:** case 0
|
||||
`goto advance_body_normally` — the leg twin's own idiom (`goto advance_normally`, mech2.cpp) —
|
||||
restoring the binary's structure without touching the #64/#82 turn logic. `BT_NO_BODY_FALLTHRU=1`
|
||||
reverts. Measured: legacy 336 consecutive locked seconds with `bspd=39.2324 bts=39.2324` identical
|
||||
on every line; fixed 0 locks across every pass; the MASTER's body-Standing samples also fell 52→21
|
||||
(it was locking too, invisibly — mj=0 writes no joints, and its two tests read *different* cells so
|
||||
it only stalls in the window where they disagree). Turn-in-place re-verified under the fix (pivoter
|
||||
reached body state 4 ×9 / leg state 4 ×8 — armed in lockstep).
|
||||
|
||||
**Why a peer must be able to self-arm walking at all** (the load-bearing bit behind
|
||||
`mech4.cpp` "stand; case 0 walk-begins next tick"): the peer's body state is set directly from
|
||||
`record->legState` only on **type-3 edges** (`ReadUpdateRecord`), and entering Standing emits one
|
||||
while *leaving* it does not. So between gait-change records a replicant is REQUIRED to derive
|
||||
walking itself from the replicated `bodyTargetSpeed`. That is why the lock needs a mech holding a
|
||||
*steady* demand — a mech whose gait keeps changing keeps getting rescued by records, which is why
|
||||
free-walking and wall-jammed benches each reproduce only half the symptom. [T2]
|
||||
|
||||
⚠ **The field symptom link is [T3], not T2.** The Standing-lock is proven and proven removed; that
|
||||
it accounts for the night-13 episodes is inference (a locked peer has `bodyCycleSpeed==0` and never
|
||||
advances its clip, so locked + translating *is* the `[skate]` signature by construction) — but no
|
||||
bench caught the two together. The `[skate]` line now carries `bstate=`, so the next playtest
|
||||
settles it: episodes gone → confirmed; any survivor names its own state. **NB the night-12
|
||||
`skatebench` "reproductions" were a DETECTOR ARTIFACT** — the first detector build tested only
|
||||
`legCycleSpeed==0`, which is normal on a peer (the body channel poses it), so it fired on every
|
||||
healthy movement phase. Old-format lines (`legCycleSpeed=`, no `bodyCyc=`) are not evidence.
|
||||
|
||||
## Controls (`BT_REAL_CONTROLS`, default-on)
|
||||
`MechControlsMapper` (mechmppr.cpp @004afbe0; btl4mppr.cpp mappers) interprets input → `speedDemand`
|
||||
/ `turnDemand`. ⚠ **WndProc NEVER receives WM_KEYUP** (the engine's per-frame reader `GetMessage`s
|
||||
|
||||
+14
-1
@@ -907,4 +907,17 @@ RECONSTRUCTION STATE: coupled path is DEFAULT ON (mech4.cpp: s_drPos gait-couple
|
||||
|
||||
|
||||
## Peer motion: the "random shakiness" is single-box packet jitter, NOT the game (task #50, 2026-07-15) [T2]
|
||||
After the coupled body-channel peer landed (96a896a + turn-step f094d78 + cadence-mirror 23f1532), residual peer shakiness on accel/decel was RANDOM -- sometimes perfect, sometimes shaky. Root cause is the TEST RIG, proven with BT_RXJIT (record inter-arrival ms on the peer): two Debug btl4 nodes on ONE box contend for CPU, so Windows BATCHES their TCP delivery -- update records arrive in bursts (min ~0ms back-to-back, max 56-226ms gaps, burstiness max/avg 3-7x) instead of an even ~17ms. A peer dead-reckons across the long gaps then snaps when a record lands -> random shake. Pinning each node to a DISJOINT core set (ProcessorAffinity 0x00F / 0x3C0) restored even ~17ms delivery (burstiness ~1.0) and the shakiness vanished (user-confirmed). REAL pods are dedicated machines with no contention -- they never see this. DO NOT add an interpolation/jitter buffer or other un-authentic netcode to mask a single-box artifact. Use tools/mp_launch.sh (bakes in the affinity) for all 2-node tests. Diagnostic env: BT_RXJIT (arrival jitter), BT_SLIDE ([slide]/[mslide] slide-in-stand), BT_GAITEV, BT_MIRDIV, BT_NO_MIRROR_CAD / BT_PEER_LEGCH (revert the coupled-peer fixes).
|
||||
After the coupled body-channel peer landed (96a896a + turn-step f094d78 + cadence-mirror 23f1532), residual peer shakiness on accel/decel was RANDOM -- sometimes perfect, sometimes shaky. Root cause is the TEST RIG, proven with BT_RXJIT (record inter-arrival ms on the peer): two Debug btl4 nodes on ONE box contend for CPU, so Windows BATCHES their TCP delivery -- update records arrive in bursts (min ~0ms back-to-back, max 56-226ms gaps, burstiness max/avg 3-7x) instead of an even ~17ms. A peer dead-reckons across the long gaps then snaps when a record lands -> random shake. Pinning each node to a DISJOINT core set (ProcessorAffinity 0x00F / 0x3C0) restored even ~17ms delivery (burstiness ~1.0) and the shakiness vanished (user-confirmed). REAL pods are dedicated machines with no contention -- they never see this. DO NOT add an interpolation/jitter buffer or other un-authentic netcode to mask a single-box artifact. Use tools/mp_launch.sh (bakes in the affinity) for all 2-node tests. Diagnostic env: BT_RXJIT (arrival jitter), BT_SLIDE ([slide]/[mslide] slide-in-stand), BT_GAITEV, BT_MIRDIV, BT_NO_MIRROR_CAD / BT_PEER_LEGCH (revert the coupled-peer fixes).
|
||||
## K/D authority + the scoreboard mirror — STRESS-PROVEN at 4 and 8 nodes (2026-08-11) [T2]
|
||||
The scoring authority model (full map: [[combat-damage]] + `docs/KD_CHAIN_AUDIT_20260811.md`):
|
||||
the VICTIM's master emits everything on its death edge (kill credit to the shooter via
|
||||
replicant-Dispatch reroute, received penalty to itself, one VehicleDead); the killer's master
|
||||
increments `killCount` when the rerouted credit lands; **killCount/deathTally never crossed the
|
||||
1995 wire** — the port's #45 update-record extension mirrors them one-way owner→replicant
|
||||
(read guarded replicant-only, mixed-version length guard, 2s heartbeat re-dirty). Bystander
|
||||
nodes learn tallies ONLY through that mirror. Verified: `kd4_bench.sh` (bystander convergence +
|
||||
no-mirror-exceeds-authority, receipt-derived per player) and `kd8_stress.sh` (8-node all-vs-all
|
||||
melee, 26 deaths: 26/26/26/26 with zero duplicate-death swallows and zero `inst=R` deliveries).
|
||||
The same-frame double-credit this closed (direct+splash lethal pair racing the deferred death
|
||||
transition) is gotcha §25 — the death mode now flips synchronously inside the damage pass,
|
||||
matching part_012.c:14761.
|
||||
|
||||
@@ -954,6 +954,18 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
||||
deterministically leave ~0 at [ebp-0xc]; or replicant-player reroute delivery differs). Port
|
||||
deviates safely (award=0). Curiosity, not a blocker.
|
||||
|
||||
## HUD range-bias @0x22C — read but NOT reconstructed (2026-08-08) [T1 read, unimplemented]
|
||||
`HudSimulation` subtracts `_DAT_004b7ecc` = **100.0f** from `RangeToTarget@0x1EC` on **every frame
|
||||
that the flag @0x22C is non-zero**, while accumulating a timer @0x21C by `time_slice` until it
|
||||
reaches the limit @0x1D8, at which point BOTH the timer and the flag are cleared. So the authentic
|
||||
HUD has a timed **−100 m range offset** state that our targeting step (mech4.cpp — which does
|
||||
reconstruct the 500 m/s slide correctly) never applies.
|
||||
**Open:** what SETS @0x22C. Candidates not yet checked — a targeting-computer damage/jam effect, an
|
||||
ECM/spoof, or a weapon-lock transient. Found while correcting hud.cpp's stand-in constant block
|
||||
(the values are now byte-grounded: ec4 0.75 / ec8 1.0 / ecc 100.0 / ed0 0.0 / f90 0.0). Details:
|
||||
[[gauges-hud]] §Right ladder. Worth resolving before trusting any field report about the range
|
||||
readout being wrong — it is a real behavioural gap, not a cosmetic one.
|
||||
|
||||
## Rendering follow-ups (non-blocking)
|
||||
- ~~Per-pilot mech PAINT (color/badge/patch)~~ — **DONE 2026-07-17, verified live** (crimson MadCat +
|
||||
yellow VGL emblems + hip hazard stripes). Mechanics + the vehicletable color/badge/patch name
|
||||
@@ -1162,3 +1174,23 @@ Gitea #116 and `experience-levels.md`.
|
||||
- **des_night-family pages have subtle fog deltas** (both sets near-black); arena/polar night
|
||||
pages carry the dramatic 5↔40 near-plane swing. If testers report "searchlight does nothing"
|
||||
on some maps, it's page authoring, not the mechanism.
|
||||
|
||||
|
||||
## Replicant-side TakeDamage delivery -- mechanism uncharacterized (2026-08-11, from the #162 audit) [T4]
|
||||
|
||||
Replicant mech copies demonstrably EXECUTE `Mech::TakeDamageMessageHandler` (matchlog `DMG
|
||||
inst=R` rows; the #150 bench observed replicant-side type-2 arms) -- yet every damage producer
|
||||
uses `Entity::Dispatch`, and ENTITY.cpp:235-272 reroutes a replicant target's dispatch to the
|
||||
wire WITHOUT local execution. Something delivers damage to the local replicant copy anyway
|
||||
(wire-seam double-delivery? interest/watcher path? a port-side mirror?), and the 2026-07-25
|
||||
corpus saw 0 of 18,818 DMG rows inst=R -- so the behavior APPEARED between then and 2026-08-09.
|
||||
The #162 fix GATES every scoring/death consequence of it (reports + death tail are master-only),
|
||||
so the scoreboard is safe regardless -- but the delivery mechanism itself is unmapped, and
|
||||
replicant-side execution may have OTHER un-audited side effects (zone/subsystem state divergence
|
||||
on peer copies beyond the intended visual application). To close: instrument the entry with the
|
||||
delivery path (who posted it, from which socket/queue), find the first build where inst=R rows
|
||||
appear, and decide whether the delivery is intended (visual-damage design) or a seam bug.
|
||||
DATAPOINT (2026-08-11, the #162 retest wave): the kd4/kd8 benches audit this explicitly (I8)
|
||||
and saw **0 inst=R DMG rows across three 4-node runs and one 8-node melee** (35 deaths total)
|
||||
on build ~900 -- the behavior did not reproduce on the bench rig; whatever produced the
|
||||
night-15 field rows needs field conditions (steam wire seam?) the LAN relay path lacks.
|
||||
|
||||
@@ -254,6 +254,55 @@ desktops. Two pieces were added for the cab:
|
||||
once, and over CRD you cannot see the panels at all: the log IS the confirmation that a picture
|
||||
landed on the right glass.
|
||||
|
||||
### Boot-STABLE panel binding — `monitor:id:` (DEPLOYED + POD-VERIFIED 2026-08-10)
|
||||
**Live on ALPHA-MR since 2026-08-10** — mapping eye-verified over livestream, then the master
|
||||
`C:\bt411\glass_layout.cfg` flipped to the id form (old cfg saved as
|
||||
`glass_layout.cfg.displaynbak`) and the relaunch bound all three windows to the SAME rects with
|
||||
zero mismatch shouts [T2]. The shipped binding:
|
||||
```
|
||||
Secondary / Radar=monitor:id:Default_Monitor,bare # colour radar LCD, USB adapter
|
||||
VGA Port A=monitor:id:UID224795,bare # RAR0005: Heat + Comm + Mfd2 CRTs
|
||||
VGA Port B=monitor:id:UID200195,bare # RAR0005: Mfd1 + Mfd3 CRTs
|
||||
```
|
||||
⚠ **The radar LCD's USB display adapter presents NO EDID** — its only stable identity is the
|
||||
`Default_Monitor` placeholder (`…#Default_Monitor#…&UID256#`). Unique today; a second EDID-less
|
||||
panel would collide, and the per-boot `[glassid]` dump (BT_GLASS_IDS=1, now PERMANENT in
|
||||
`podprofile.ini`) plus the mismatch shout are the diagnosis tools. The Dell (`DEL4025`) is the
|
||||
PRIMARY main-view panel (BT_FIT owns it; not in the layout cfg) — the 08-08 note calling it a
|
||||
"1908FP" service monitor mis-guessed its role.
|
||||
**Windows renumbers displays.** Nick, after re-cabling + a reboot: *"the order changed … sometimes
|
||||
they change when one gets turned off and back on, at least how windows SEEs them, even if the
|
||||
visual desktop tool looks the same."* So the two original binding forms are both boot-fragile —
|
||||
`monitor:2` is an enumeration index and `monitor:\.\DISPLAY4` is a GDI name Windows reassigns.
|
||||
(Same trap next door in GameOS: its `-tmon` takes **DirectDraw device indices**, which are neither
|
||||
Windows monitor numbers nor stable, and a NULL-device merge shifts every index down by one on top
|
||||
— see Nick's `gos-displays.txt`.)
|
||||
|
||||
**The fix: bind to the panel's own hardware identity.** `EnumDisplayDevices` on a display's MONITOR
|
||||
child returns a DeviceID embedding the **EDID manufacturer + product code** and the connector
|
||||
instance — neither moves across a reboot or a power-cycle.
|
||||
|
||||
- **Discover:** run once on the pod with `BT_GLASS_IDS=1`. Every attached panel logs its
|
||||
`stable-id` **and a ready-to-paste `cfg form`**:
|
||||
```
|
||||
[glassid] index=0 device=\\.\DISPLAY1 PRIMARY rect=0,0 1920x1080
|
||||
stable-id = \\?\DISPLAY#AUO10ED#4&31323a6c&1&UID265988#{e6f07b5f-…}
|
||||
cfg form = monitor:id:AUO10ED
|
||||
```
|
||||
The volatile identifiers print beside the stable one deliberately: run it twice across a
|
||||
power-cycle and `index`/`device` move while `stable-id` does not — that IS the proof.
|
||||
- **Bind:** `Heat MFD=monitor:id:AUO10ED,bare` in `glass_layout.cfg`.
|
||||
- **Identical panels** (the pod's mono MFDs are likely one model, so EDID codes collide): use a
|
||||
longer fragment from `stable-id` — the `UID…`/instance tail differs per connector, so
|
||||
`monitor:id:UID265988` picks exactly one.
|
||||
- **An `id:` that matches nothing WARNS and falls back** to computed placement:
|
||||
`[glasswin] monitor id 'X' matched NO attached panel`. Silence there would put a picture on the
|
||||
wrong glass and look exactly like the bug this form exists to prevent.
|
||||
- **Nothing changes by default.** No env, no `id:` → identical behaviour; `monitor:<name|index>`
|
||||
and raw `x,y` keep working. Playtester glass builds are untouched. [T2 verified on a 1-monitor
|
||||
dev box — discovery, binding, centring and the mismatch warning; **T3 on the pod**, which was
|
||||
offline: multi-panel disambiguation is unproven.]
|
||||
|
||||
**Runbook** (`tools/podprobe.ps1`, PowerShell, no install/admin — run it ON the pod PC):
|
||||
1. Probe: GPUs, every monitor's virtual-desktop rect, EDID make/model (identifies the original
|
||||
panels), serial ports (the RIO board), session type, and a PROPOSED `glass_layout.cfg` that
|
||||
@@ -390,6 +439,124 @@ returns nothing rather than failing. Kill `btl4.exe` BEFORE scp'ing a new exe or
|
||||
locked. The receipts in `podrun.log` are the remote eyes; a missing `DEBUG_STREAM` line in an
|
||||
otherwise-logging run is real evidence that code path did not execute.
|
||||
|
||||
### The remote runbook — READ THIS BEFORE IMPROVISING (2026-08-08)
|
||||
Every line below cost real time to rediscover on a live stream. Follow it in order.
|
||||
|
||||
**1. Connect.** `ssh bt411-pod` (alias in `~/.ssh/config`) → `bt411-pod.tail840fa4.ts.net`, user
|
||||
`user`, key `~/.ssh/bt411_pod`.
|
||||
⚠ **The key has existed since 2026-08-06 but ssh will NOT offer it without the config entry** — it
|
||||
only tries default names (`id_rsa`/`id_ed25519`). Without it you get
|
||||
`Permission denied (publickey,password,keyboard-interactive)`, which reads exactly like "auth was
|
||||
never set up". It was. Also: the Tailscale **node** KeyExpiry (2027-02-02) is what keeps the cab on
|
||||
the tailnet — it is **not** an SSH credential, and Tailscale SSH is **not** enabled on the pod (the
|
||||
peer advertises no `sshHostKeys`), so port 22 is the pod's own Windows OpenSSH.
|
||||
|
||||
**2. Absolute paths for System32 tools.** Over this SSH+cmd session, bare `taskkill` / `setx`
|
||||
return `The system cannot find the path specified.` and silently do nothing. Use
|
||||
`C:\Windows\System32\taskkill.exe`. (`tasklist`, `schtasks`, `dir`, `copy` happen to resolve.)
|
||||
|
||||
**3. Deploy — the SAME procedure a tester uses.** No pod-special exe drops.
|
||||
```
|
||||
python tools/mkdist.py # -> dist/BT411_4.11.NNN.zip
|
||||
scp dist/BT411_4.11.NNN.zip bt411-pod:C:/bt411/
|
||||
ssh bt411-pod "powershell -NoProfile -Command \"Expand-Archive C:\bt411\BT411_4.11.NNN.zip -DestinationPath C:\bt411 -Force\""
|
||||
ssh bt411-pod "powershell -NoProfile -ExecutionPolicy Bypass -File C:\bt411\podkit.ps1 -Content C:\bt411\BT411_4.11.NNN\content"
|
||||
```
|
||||
`runpod.bat` auto-resolves the **newest** `BT411_*` by date, so nothing else needs pointing.
|
||||
Local config is **never clobbered**: `mkdist.py` packs git-TRACKED content only, and
|
||||
`bindings.txt`/`environ.ini`/`glass_layout.cfg` are all gitignored. `podkit.ps1` pushes the frozen
|
||||
rig masters (`podprofile.ini`, `glass_layout.cfg`, `PODTEST.EGG`) from `C:\bt411\` into the install.
|
||||
⚠ **`PODTEST.EGG` is not in the repo** — only podkit carries it. If podkit fails, the launcher runs
|
||||
and *nothing appears*, with no error.
|
||||
|
||||
**4. Launch.** `schtasks /run /tn BT411Run` → runs `C:\bt411\runpod.bat` as `user`,
|
||||
LogonType=Interactive, so it lands in **session 1** and is visible on the panels. Log:
|
||||
`<install>\content\podrun.log`.
|
||||
|
||||
**5. ⚠ THE PODKIT INFINITE LOOP — the trap that ate an evening.** `podkit.ps1` line 30 was
|
||||
```powershell
|
||||
while ($keep.Count -gt 0 -and $keep[-1].Trim() -eq '') { $keep = $keep[0..($keep.Count-2)] }
|
||||
```
|
||||
When `$keep` trims to ONE blank line, `$keep.Count-2` is `-1` and PowerShell's `$keep[0..-1]`
|
||||
returns **two** elements instead of shrinking — infinite loop, RSS climbing past 60 MB. It fires
|
||||
whenever everything outside `environ.ini`'s marker block is blank, i.e. **any `environ.ini` that was
|
||||
already kitted** — exactly what you get carrying it forward from the previous install. Patched to
|
||||
`-gt 1` on the pod 2026-08-08 (`podkit.ps1.bak` is the original); **this fix is NOT in any repo**,
|
||||
so a restored/replaced podkit brings the bug back.
|
||||
*Signature:* a **blank cmd console** on the pod, task stuck `Status: Running`, **no `btl4.exe`, no
|
||||
`podrun.log`**. *The cascade:* each hung run holds `environ.ini`, so every later attempt blocks too —
|
||||
and over SSH your client times out while the REMOTE powershell keeps running, so "it returned
|
||||
instantly and did nothing" actually means "it is still hung". Recover with
|
||||
`taskkill /F /PID <pid>` (absolute path) on the session-1 `cmd`+`powershell` pair, then
|
||||
`schtasks /end /tn BT411Run` before re-running — a task already Running refuses `/run` with
|
||||
`2147946720` (`0x800710E0`, "operator refused the request").
|
||||
|
||||
**6. Panel identity (measured 2026-08-08, over SSH — WMI is session-independent so this works
|
||||
without a GUI):** `DISPLAY\RAR0005\…UID224795`, `DISPLAY\DEL4025\…UID249395` (DELL 1908FP),
|
||||
`DISPLAY\RAR0005\…UID200195`. **The two RAR panels share one EDID code and have blank serials**, so
|
||||
`monitor:id:RAR0005` is ambiguous — they must be bound by the per-connector form
|
||||
(`monitor:id:UID224795` / `monitor:id:UID200195`). **RESOLVED 2026-08-10:** the full map is
|
||||
UID224795 = VGA Port A (Heat+Comm+Mfd2), UID200195 = VGA Port B (Mfd1+Mfd3), `Default_Monitor`
|
||||
(EDID-less USB adapter, UID256) = the colour radar LCD, DEL4025 = the PRIMARY main-view panel
|
||||
(not a service monitor). The master cfg now ships the id form (see the binding section above);
|
||||
`glass_layout.cfg.displaynbak` preserves the old DISPLAYn cfg.
|
||||
|
||||
**7. ⚠ cmd redirect trap when scripting over SSH:** `echo VAR=1>> file` appends `VAR=` — cmd
|
||||
parses the digit-adjacent `1>>` as a stream-handle redirect and eats the value (and MSVC putenv
|
||||
treats an EMPTY value as UNSET, so the flag silently never arms). Space it (`echo VAR=1 >>file`,
|
||||
trailing-space payload) or use PowerShell `Add-Content`. Bit us arming BT_GLASS_IDS 2026-08-10.
|
||||
|
||||
**8. Tester-procedure deploy VERIFIED end-to-end (2026-08-10, build 876):** scp zip →
|
||||
`Expand-Archive` → podkit → `schtasks /run` came up clean on the first try — rig config survived,
|
||||
newest-install auto-resolve worked, no hand-edits. The cab genuinely upgrades like a tester box.
|
||||
|
||||
**9a. ⚠ THE RIO's "COM1" IS A USB-SERIAL ADAPTER — the axis-dropout / frozen-boot fault
|
||||
(2026-08-10, minidump-proven) [T1].** `\Device\VCP0` = an FTDI adapter (FTE7PGA5A); COM2 is a
|
||||
second FTDI (A995JTOYA); **two ghost Prolific PL2303 entries also claim "COM1"** from earlier
|
||||
plug-ins (clean them in Device Manager). When the adapter's **USB side** drops mid-operation:
|
||||
brief drop = analog polls go mute (the field "encoder dropouts", revived by hand-jostle /
|
||||
re-enumeration — NOT by button protocol); drop with a write in flight = the 1995 code's
|
||||
**unbounded `GetOverlappedResult` wait in `PCSerialPacket::SendPacket` parked the whole game at
|
||||
0 CPU forever** (three boots on 2026-08-10; stack: NtWaitForSingleObject ← GetOverlappedResult ←
|
||||
SendPacket ← RIO ctor). **Hardened in 4.11.881**: 500 ms bounded write + CancelIo + 3 s sick
|
||||
window (fast-drop, game keeps frame rate, keyboard fallback lives); `txTO=` in the `[rio]` health
|
||||
line counts the events — proven the same evening (`txTO=1 rRetry=4` on a boot that SURVIVED).
|
||||
USB **selective suspend disabled** system-wide on the cab the same day (powercfg) — prime suspect
|
||||
for the random-period idle dropouts. Hardware to-do: FTDI adapter into a REAR motherboard USB
|
||||
port, reseat its USB end. ⚠ A first "print-deadlock" diagnosis of the boot wedge was WRONG
|
||||
(confounded A/B) and is retracted — the dump is the authority.
|
||||
**Day-end state (2026-08-10 evening) + the pick-up plan.** The 881 soak measured the fault
|
||||
precisely: the board answers for **~13 s after each fresh game start** (254 replies at 20 Hz),
|
||||
then goes MUTE on analog for minutes while host→board writes keep completing (`req=3846 rep=254
|
||||
age=240s`, txTO frozen at 1, abandon=0). Direct hand-poll (`tools/probe_rio.ps1`, also at
|
||||
`C:\bt411\probe_rio.ps1` — raw COM1 CHECK/VERSION/ANALOG with correct [cmd][sum&0x7F] framing,
|
||||
DTR/RTS raised): **zero bytes**. USB re-enumeration of BOTH FTDI converters + immediate re-poll:
|
||||
**still zero** — which argues AGAINST the adapter (a wedged adapter RX clears on re-enum) and AT
|
||||
the board. ⚠ [T4 hypothesis for tomorrow]: 13 s ≈ when the mission render load lights up — a
|
||||
**power rail sagging under the cab's full load** would also explain the original mid-combat
|
||||
dropouts (explosion/load transients) and the button "revival" (load dips + harness jostle).
|
||||
Tomorrow, with hands: (1) loopback plug on the FTDI (TX→RX jumper, probe echoes = adapter fully
|
||||
exonerated); (2) 5 V/12 V at the RIO board while a mission loads (the 13 s correlation is the
|
||||
test); (3) swap in one of the spare Prolific adapters as cheap elimination; (4) rear-mobo USB
|
||||
port + ghost-COM cleanup regardless. ⚠ My STALL/RECOVER edge detector in 881 keys on
|
||||
request-age, which the 50 ms poll keeps resetting — it never fires; the `age=` field carries the
|
||||
real signal (fix the keying to reply-age at the next build).
|
||||
**Remote minidump procedure (worked over SSH, no tools install):** 32-bit dump of the wedged pid
|
||||
via `C:\Windows\SysWOW64\rundll32.exe C:\Windows\SysWOW64\comsvcs.dll, MiniDump <pid>
|
||||
C:\bt411\w.dmp full` → scp home → local x86 cdb (`where.exe cdb` resolves the bt411-tools copy)
|
||||
with `-y C:\git\bt411\dist` (the archived per-build PDBs) → `~*k`. A 64-bit comsvcs dump of the
|
||||
WOW64 process shows only 64-bit frames — use the SysWOW64 pair.
|
||||
|
||||
**9. ⚠ `schtasks /end` ORPHANS the game — the dead-RIO trap (2026-08-10):** `/end` kills the
|
||||
batch, but `runpod.bat` `start ""`s the exe DETACHED, so the old btl4.exe survives, keeps COM1
|
||||
open, and the NEXT launch boots with a dead cockpit board: `InitSerialPort() failed
|
||||
[GetLastError() = 5]` (access denied) → `RIO initialization failed!` → buttons dead while the
|
||||
banner still says "hardware RIO" (the banner is the PROFILE, not the init result). Diagnosis:
|
||||
`tasklist /FI "IMAGENAME eq btl4.exe"` showing ≥2 instances. FIXED at the root the same day:
|
||||
`runpod.bat` now `taskkill /F /IM btl4.exe`s FIRST (original saved as `runpod.bat.bak`), making
|
||||
the BT411Run task an idempotent restart — but any hand-rolled launcher on a future cab needs the
|
||||
same line, and a mid-mission `/run` now RESTARTS the game rather than failing quietly.
|
||||
|
||||
## The 1995 player manual — alignment audit (2026-07-18) [T1, primary source]
|
||||
`reference/manual/Tesla40_BT_manual.pdf` (34pp, from Nick). CONFIRMS the reconstruction on
|
||||
every checked control behavior:
|
||||
|
||||
@@ -863,3 +863,154 @@ warning"), list what STARTS DRAWING at that event -- alarm-driven redraws,
|
||||
state-change repaints -- before suspecting the event's logic; (d) the
|
||||
operator's screenshot is worth ten theories: the red-faces capture identified
|
||||
in one frame what three log-side hypotheses missed.
|
||||
|
||||
## 28. HAND-COMPOSING an engine-derived transform reads a STALE CACHE — and it only bites REPLICANTS (#141, 2026-08-08)
|
||||
|
||||
`EntitySegment::GetSegmentToEntity()` (`SEGMENT.cpp:262`) **recomputes only when
|
||||
`segmentModified` is already set** — otherwise it hands back the cached matrix,
|
||||
and if the segment has no parent it can never recompute at all. The thing that
|
||||
sets that flag after a joint moves is `JointedMover::GetSegmentToWorld`
|
||||
(`JMOVER.cpp:136-146`): it tests `AreJointsModified()` and, when set, walks the
|
||||
whole segment table marking every entry dirty, then clears the joint flag.
|
||||
|
||||
The binary agrees exactly. `MechWeapon::GetMuzzlePoint @004b9948` ends in
|
||||
`FUN_00424da8(owner, segment, out)`, which IS `GetSegmentToWorld`
|
||||
instruction-for-instruction (`GetJointSubsystem` → `if (AreJointsModified())` →
|
||||
mark all → `ModifyJoints(False)` → `× localToWorld`). **So in the 1995 image
|
||||
every muzzle query performs the joints→segments refresh.** [T1]
|
||||
|
||||
Four port sites had replaced that with `mw.Multiply(seg->GetSegmentToEntity(),
|
||||
mech->localToWorld)` — including one commented "the faithful FUN_004b9948".
|
||||
They skip the refresh and read whatever cache is present.
|
||||
|
||||
**Why it hid for a year:** the local mech is refreshed every frame anyway — the
|
||||
renderer and cockpit camera call `GetSegmentToWorld` on it, *after* its torso
|
||||
pushes the joint. So master-side output is correct and solo testing is clean.
|
||||
A **replicant** gets no such pass: its cache stays at the BIND POSE. Measured on
|
||||
a 2-node bench, peer missiles left along the LEG facing with `segYaw == bodyYaw`
|
||||
EXACTLY (`twistDelta` 0.0000 over 165 salvos) while that same peer's copy torso
|
||||
was demonstrably writing its joint (`PushTwist COPY twist=-1.49601`) from
|
||||
correctly replicated records. Twist arrived, joint moved, segment never
|
||||
re-derived.
|
||||
|
||||
Rules:
|
||||
(a) **Never hand-compose `GetSegmentToEntity() × localToWorld`.** Call
|
||||
`GetSegmentToWorld` — it is the binary's own path and it does the refresh.
|
||||
(b) **Do NOT "fix" a stale transform by forcing the dirty flag.** Setting
|
||||
`ModifyJoints(True)` at the read site made the symptom go away and scored
|
||||
identically to the faithful fix — it was a stand-in that patched ONE
|
||||
consumer and left every other peer segment reader stale. The binary only
|
||||
ever *tests* that flag.
|
||||
(c) A cached-transform bug is **master/replicant asymmetric by construction**.
|
||||
If a geometry symptom is reported "peer POV only", suspect a cache that the
|
||||
local render pass refreshes for free — before suspecting replication. Here
|
||||
the replication was provably fine.
|
||||
(d) When a fix lands at a partial percentage, **check whether the failures are
|
||||
interleaved or a PREFIX** before calling it partial. These were a clean
|
||||
prefix that ended the moment the peer first had a non-zero twist to carry —
|
||||
i.e. the fix was complete and the remainder was correct behaviour. Reporting
|
||||
it as "64% fixed" was wrong.
|
||||
(e) Related probe trap: `Torso::PushTwist` sampled ONE shared static every 30th
|
||||
call. With a master torso and a copy torso ticking 1:1, every 30th call is
|
||||
always the SAME instance — the probe showed only the local untwisted torso
|
||||
and hid the copy's writes entirely. Sample **per instance-kind** whenever
|
||||
master and replicant objects share a diagnostic. (See also §gotcha on
|
||||
process-wide statics serving the player's data as the replicant's.)
|
||||
|
||||
## 29. A peer mech does NOT tick before RunningMission — bench-only, and it fakes a replication bug (#148, 2026-08-08)
|
||||
|
||||
`Entity::Execute` (`ENTITY.cpp:556`, real engine source [T0]) calls
|
||||
`PerformAndWatch` **only** when
|
||||
|
||||
```cpp
|
||||
application->GetApplicationState() == Application::RunningMission
|
||||
|| application->GetApplicationState() == Application::EndingMission
|
||||
|| IsPreRunnable()
|
||||
```
|
||||
|
||||
otherwise it just does `WriteSimulationUpdate`. `Entity::DefaultFlags` is
|
||||
`DynamicFlag|MasterInstance` — **no `PreRunFlag`**; only `Player` and `Director`
|
||||
add it in their DefaultFlags, and `Mech::Reset` sets it for a reset MASTER
|
||||
("a reset master must tick"). A **replicant mech never gets it.**
|
||||
|
||||
So during `LoadingMission` / `WaitingForLaunch` / `LaunchingMission` a peer mech
|
||||
performs **zero** subsystem ticks, no matter how much correctly-replicated data
|
||||
is arriving for it. Measured on the observer node:
|
||||
|
||||
```
|
||||
235 [perf-first] mech 3:161 master <- own mech, immediately
|
||||
402 [torso-rec-rx] <- peer's torso records start arriving
|
||||
2754 [perf-first] mech 2:55 REPLICANT <- peer's FIRST performance
|
||||
2758 [torso] PushTwist COPY <- its torso ticks 4 lines later
|
||||
2761 [ent-exec] state=5 <- RunningMission
|
||||
```
|
||||
|
||||
This is correct engine behaviour, **but it silently corrupts any bench that acts
|
||||
before the round starts.** `BT_AUTOFIRE`/`BT_GOTO` begin immediately, so early
|
||||
salvos measure a peer whose torso, gait and subsystems have never run — and the
|
||||
result reads exactly like a replication failure. It cost a full investigation
|
||||
(filed as #148) before the app-state trace showed the peer was simply not
|
||||
executing yet.
|
||||
|
||||
Rules:
|
||||
(a) **Judge a 2-node bench by PREFIX vs INTERLEAVED, never by raw percentage.**
|
||||
A clean leading run of failures that stops for good is almost always the
|
||||
pre-`RunningMission` window; interleaved failures are the real thing.
|
||||
(b) When a peer looks inert, check `[ent-exec] state=` before suspecting
|
||||
replication. States: `2` LoadingMission, `3` WaitingForLaunch,
|
||||
`4` LaunchingMission, `5` RunningMission.
|
||||
(c) Prefer benches that wait for `RunningMission` before acting — or slice the
|
||||
log at the transition — otherwise every peer-side metric carries this bias.
|
||||
(d) The receipts that make this legible: `BT_NET_TRACE` gives `[upd-repl]`
|
||||
(offered to the performer), `[ent-exec]` (state / preRun / instance) and
|
||||
`[perf-first]` (one-shot per mech: entity ID + instance at its FIRST
|
||||
performance). Anonymous per-frame receipts are useless in a 2-node log —
|
||||
**name the mech.**
|
||||
|
||||
## 30. A finite-difference cache the binary re-seeds at Reset — transcribe the WHOLE seed list (#137, 2026-08-09)
|
||||
|
||||
The binary `Mech::Reset @0049fb74` opens with more than the obvious origin writes: its SECOND
|
||||
instruction is `FUN_00408440(mech+0x58c, param_2)` — re-seeding the **previous-position memory**
|
||||
of the AccelerationLastFrame ring feed (+0x81c/0x824/0x828/0x82c) to the new origin. The port
|
||||
reconstructed the ring itself faithfully (ctor `part_012.c:9836`, derivative `:15169`) but its
|
||||
Reset never got that one line. Result: the first post-respawn sample computed
|
||||
`|newPos − prevPos| / dt` = **teleport distance / dt ≈ 1e5** into the velocity ring; the
|
||||
ring-mean derivative turned it into an acceleration spike (with an opposite-sign ECHO ~15 frames
|
||||
later as the sample rotated out of the mean); the myomer heat integrator's
|
||||
`termAccel = (1−accEff)·|v|·|a|·m·dt` turned THAT into a ~3e9 one-tick heat deposit; and the
|
||||
freshly-reset myomers snapped from 77 to ~9000 against failT=2000 — the #137 respawn freeze.
|
||||
|
||||
Rules:
|
||||
(a) **When the binary's Reset writes a cell you don't recognize, that write IS the spec.** The
|
||||
+0x58c re-seed looked like bookkeeping and was silently dropped; it was the only thing
|
||||
standing between a teleport and a position-derivative spike. Transcribe the whole zero/seed
|
||||
list, then map each cell — never the recognizable subset.
|
||||
(b) **Any prev-value cell backing a finite difference must be re-seeded at every discontinuity**
|
||||
(teleport, warp, respawn). If you add such a cache port-side, grep the binary's reset for its
|
||||
analog before assuming none exists.
|
||||
(c) **Derived state hides stale backing state.** `currentTemperature` sampled AT the reset read
|
||||
77 (clean) because RTIS wrote it — while the freeze arrived one frame later through
|
||||
`heatEnergy += pendingHeat` from a live producer. Probing the derived cell at the reset
|
||||
instant proves nothing about the producers; trace the WINDOW after, per producer.
|
||||
(d) The diagnosis chain that worked, for reuse: roster-wide state snapshot (at-death / at-reset /
|
||||
post-reset) → eliminate conduction by trapping flows with full operands → trap the remaining
|
||||
producer's deposits with operands → cross-reference the operand SHAPE (pure local −z,
|
||||
magnitude = distance/dt, echo at ring-length) against the writers. Each trap eliminated a
|
||||
theory the previous data had made plausible; three plausible theories died on operands.
|
||||
|
||||
## §25 — A same-pass edge latch sampling DEFERRED state: synchronous in the binary, next-tick in the port
|
||||
**(2026-08-11, the #162 4-node retest double-credit.)** The binary's death processing writes
|
||||
`movementMode 9` **inside the damage pass itself** — its own tail gate (part_012.c:14761) reads
|
||||
the mode already flipped in the same call. The port put the mode write in the per-frame
|
||||
`UpdateDeathState`, one tick later — and a comment at the death tail *asserted* the write was
|
||||
synchronous, so nobody looked. Consequence: TWO lethal deliveries of one salvo (direct + splash,
|
||||
2 ms apart, no sim tick between) each sampled `destroyedAtEntry = (mode==2||9)` as 0 and re-ran
|
||||
the whole death block — duplicate kill credit + duplicate VehicleDead. Invisible at 2 nodes
|
||||
(clean kills deliver ONE lethal hit); needs crowds + overlapping splash — the 8-player field
|
||||
shape. **Rule:** when a binary function's OWN later code observes state it wrote earlier in the
|
||||
same pass, the port's write must be equally synchronous — a per-frame deferral silently converts
|
||||
an edge latch into a race. Sibling of the alarm-vs-mode trap (#108/#162): both are "the latch
|
||||
sampled a cell whose write timing differs from the binary's". Fix shape: run the once-per-death
|
||||
transition inline at the destruction edge (mech.cpp death_tail → `UpdateDeathState(0)`), keep the
|
||||
per-frame caller for maintenance. Verified: kd4_bench 9/9/9/9 + kd8_stress 26/26/26/26, both
|
||||
SWALLOWED=0 (the receiver dedup tripwire now never fires — no duplicate is ever GENERATED).
|
||||
|
||||
+40
-1
@@ -102,7 +102,10 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
|
||||
(OwnerBaseSpeed reads the real base instead of the 1.0 stub).
|
||||
5. **Electrical sourcing — the dial's REAL stake is TORSO TWIST.** A gear's voltage must be
|
||||
<= the generator's measured output (`(1 − generator damage) × rated`, thermal breaker on
|
||||
FailureHeat; there is NO load model — demand never pulls the bus down), or the myomers leave
|
||||
FailureHeat with recovery gated on NormalHeat — trip at T>2000, re-arm at T≤1000, exactly
|
||||
the manual p23 "cooled down half way" rule; full trip/recovery/warning-edge audit in
|
||||
[[decomp-reference]] §Generator trip/recovery (#154); there is NO load model — demand
|
||||
never pulls the bus down), or the myomers leave
|
||||
Ready. The Torso is a PowerWatcher on the myomers: `TorsoSimulation` ZEROES the twist rate
|
||||
while the watched myomers are not Ready (halves it at DegradationHeat) — so an over-high gear
|
||||
on a damaged generator freezes the pilot's AIM until they downshift. On a healthy mech,
|
||||
@@ -299,3 +302,39 @@ the `BTGetSubsystemAuxScreen` bridge. See `docs/VEHICLE_SUBSYSTEMS.md` + [[gauge
|
||||
- Data: [[decomp-reference]] (ClassIDs/hierarchy). Bugs: [[reconstruction-gotchas]].
|
||||
- Feeds: [[combat-damage]] (weapons/damage), [[gauges-hud]] (attribute state).
|
||||
- Plan: `docs/SUBSYS_PLAN.md`.
|
||||
|
||||
## Myomer drive-heat calibration — VERIFIED FAITHFUL (2026-08-09, #137) [T1]
|
||||
The integrator `@004b8d18` accumulates into `pendingHeat@0x1C8`:
|
||||
`gear² × (1 + X) × [ (1−velEff)·|vy|·m·g·dt + (1−velEff)·work + (1−accEff)·|v|·|a|·m·dt ]`
|
||||
with `work = mass · |v|² · 0.5`. Its constants, read byte-exact from `.rdata`
|
||||
(`section_dump.txt` row ` 4b8ee0 5dc30000 0000003f 00000000 0000803f`):
|
||||
**`_DAT_004b8ee4` = 0.5f** (the kinetic ½), **`_DAT_004b8ee8` = 0.0f** (the `Abs()` idiom),
|
||||
**`_DAT_004b8eec` = 1.0f** (the `1 − efficiency` complements and the gear-ratio clamp floor).
|
||||
All three match what the port computes — the formula and its authored inputs
|
||||
(VelocityEfficiency 0.995, AccelerationEfficiency 0.8, thermalMass 2.5e5, myomers linked
|
||||
Condenser5) are reconstructed correctly.
|
||||
|
||||
**The one deliberate deviation, and why it is the faithful choice.** The binary applies **no
|
||||
`time_slice`** to the kinetic term (`fVar5 * fVar1`) while the climb and accel terms both carry
|
||||
`param_2` — it is a per-frame energy add at the pod's **fixed ~28 Hz**. The port uses
|
||||
`work × (time_slice × 28)`, which is *identical* at 28 Hz (`dt·28 = 1.0`) but holds the same
|
||||
heat-per-SECOND at any frame rate. A literal transcription would add the full term once per
|
||||
frame, so at 170 fps it would inject ~6× the heat the pod ever did. `BT_MYO_HZ` overrides the
|
||||
reference rate for bracketing.
|
||||
|
||||
**Consequence for #137 — CORRECTED 2026-08-09, the paragraph that stood here was wrong.** The
|
||||
"players read 'respawned with heat maxed' as acceleration to top speed" claim did not survive
|
||||
the data: the deposits were e9-scale within 30 frames of the reset, physically impossible from
|
||||
motion input (~6.5e5/frame ceiling). The actual cause was a **dropped binary re-seed**: the
|
||||
binary Reset's second instruction (`FUN_00408440(mech+0x58c, origin)`) re-seeds the
|
||||
previous-position memory of the AccelerationLastFrame ring feed (+0x81c..+0x82c); the port
|
||||
reconstructed the ring but not the re-seed, so the first post-respawn sample computed
|
||||
TELEPORT-DISTANCE/dt (~1e5) into the velocity ring, the ring-mean derivative spiked
|
||||
`AccelerationLastFrame`, and `termAccel = (1-accEff)·|v|·|a|·m·dt` deposited ~3e9 into
|
||||
`pendingHeat` in one tick → myomers snapped from 77 to ~9000 (failT 2000) → speedEffect 0 →
|
||||
frozen until cooled. Fixed by restoring the re-seed (`accelPrevPos = origin.linearPosition` in
|
||||
Mech::Reset). The ~8% field rate was the |v| factor: only pilots whose throttle was still
|
||||
forward at the respawn (physical lever / HOTAS — exactly who reported it) had gait-republished
|
||||
speed in the spike frames. The calibration facts above (constants byte-exact, dt-normalised
|
||||
kinetic term) all STAND; the in-life governor (running hot at sustained top speed derates the
|
||||
myomers) is authentic and remains.
|
||||
|
||||
+18
-1
@@ -204,7 +204,24 @@ sleep <duration>; kill $relay; bt_kill_ours
|
||||
your own legs; pair screenshots with a state probe and READ a frame before concluding.
|
||||
- **Build ritual first** — see [[build-and-run]] §Build ritual (stale exe = phantom results).
|
||||
|
||||
## The standing bench suite (scratchpad/night15/, 2026-08-11 wave — all hard PASS/FAIL)
|
||||
| Bench | Asserts | Hooks it rides |
|
||||
|---|---|---|
|
||||
| `kd_bench.sh` (2-node) | K/D invariants I1-I5: deaths == PLAYER_DEAD == −500 costs == kill credits, no swallows | BT_AUTOFIRE + BT_AF_MISSILE, BT_GOTO, matchlog |
|
||||
| `kd4_bench.sh` (4-node) | I1-I8 **receipt-derived per player** + bystander mirror convergence + no-mirror-exceeds-authority + zero `inst=R` | same + SBMIRROR records |
|
||||
| `kd8_stress.sh` (8-node) | the same invariants under an all-vs-all melee (staggered salvo periods, one drop zone); `LOW STRESS` guard | same, 8 instances 1 core each |
|
||||
| `proj_ttl_bench.sh` | #168 flight clocks: probe fizzles at exactly 5.0s / burn+10s; field phase: authentic ttl stamps, none early, no stale-point hits | BT_PROJ_TEST probe + BT_PROJ_LOG |
|
||||
| `gen_edge_bench.sh` | #154 warning edge: 4/4 trigger fixups, trip edge FIRED, recovery + respawn silent | BT_GEN_HEAT injection + BT_AUDIO_EDGE |
|
||||
| `xfire_bench.sh` | #165 destroyed-weapon gate: pre-kill FIRED, post-kill only REFUSED, survivor unaffected | BT_KILL_SUBSYS + named `[emitter] FIRED` |
|
||||
| `eject_canary.sh` / `ram_bench.sh` / `relaunch_bench.sh` | eject suicide arithmetic / ram-rattle eligibility set / ordered-exit no-zombies | (night-15 fix wave) |
|
||||
|
||||
Multi-node rig notes: extend the egg by CLONING the 1602 pilot page (the kd4/kd8 scripts show
|
||||
the python pattern); **only zone names the MAP defines are valid dropzones** (undefined names
|
||||
pile everyone at a fallback — mechs spawn overlapping and the melee turns into splash-suicide
|
||||
chaos, which the receipt-derived invariants still score exactly, but it isn't the scenario you
|
||||
wrote); affinity masks 2-cores-each up to 4 nodes, 1-each at 8.
|
||||
|
||||
## Key Relationships
|
||||
- Uses: [[build-and-run]] (parity, env gates, BT_SHOT capture) · [[experience-levels]] (expert vs novice gating)
|
||||
- Informs: [[reconstruction-method]] (step 4 "verify honestly" — this file is the how)
|
||||
- Incident sources: [[reconstruction-gotchas]] §23 (pixels), §26 (silent stubs); [[multiplayer]] (replication layers)
|
||||
- Incident sources: [[reconstruction-gotchas]] §23 (pixels), §25 (same-pass latch timing — caught by the 4-node bench), §26 (silent stubs); [[multiplayer]] (replication layers)
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
# Generator-Out Warning Audit — #154 (2026-08-11)
|
||||
|
||||
> **Ledger** (full multi-agent evidence record; the curated digest lives in
|
||||
> `context/decomp-reference.md` §Generator trip/recovery + the warning edge).
|
||||
> Verdict chain: recovery threshold AUTHENTIC (trip 2000 / re-arm 1000 = the
|
||||
> manual p23 half-cooled rule, heat-bar red == the gate); the warning was
|
||||
> AUTHORED on the leave-GeneratorOut edge in shipped BTL4.RES (the game's only
|
||||
> inverse-Start fault trigger, 144-row census) — fixed as the port's one
|
||||
> deliberate content deviation (08fc87e, `BT_GEN_WARN_SHIPPED=1` reverts);
|
||||
> live-benched trip-FIRED/recovery-silent (gen_edge_bench.sh); phrase
|
||||
> ear-verified as Yip's spoken "generator out" (fef6fad).
|
||||
>
|
||||
> ⚠ RECORD CORRECTION (8d2f482): a same-day claim that BT 4.10 shipped no
|
||||
> speech (and that era memories were Firestorm's) was WRONG — the Warnings01
|
||||
> zones ARE the voice, phrase-sequenced (#119, commit 42b0691). The claim
|
||||
> below in lane text predates the correction; read it with that lens. Process
|
||||
> lesson recorded in memory: grep the project's own git log before asserting
|
||||
> an asset absent.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — four investigation lanes (raw returns)
|
||||
|
||||
|
||||
########## LANE 0 ##########
|
||||
## #154 — OUR PORT's generator trip/recovery reconstruction (raw findings)
|
||||
|
||||
### 1. Trip + recovery logic and thresholds
|
||||
|
||||
The generator's thermal trip lives in `Generator::GeneratorSimulation` — C:/git/bt411/game/reconstructed/powersub.cpp:1295-1372, transcribed from @004b1f7c (raw: reference/decomp/all/part_013.c:9049-9107, re-read this session).
|
||||
|
||||
TRIP (powersub.cpp:1304-1312):
|
||||
```cpp
|
||||
if (heatAlarm.GetLevel() == HeatSink::FailureHeat) // this+0x184 == 2
|
||||
{
|
||||
...
|
||||
stateAlarm.SetLevel(GeneratorOut); // SetLevel 4
|
||||
}
|
||||
```
|
||||
This is a HELD condition, not an edge: it re-asserts SetLevel(4) EVERY frame while heatAlarm==FailureHeat. Binary identical [T1]: part_013.c:9062-9064 `if (param_1[0x61] == 2) FUN_0041bbd8(param_1+0x7f, 4);`.
|
||||
|
||||
RECOVERY (powersub.cpp:1340-1357):
|
||||
```cpp
|
||||
case GeneratorOut: // 4
|
||||
outputVoltage = 0.0f;
|
||||
if (heatAlarm.GetLevel() == HeatSink::NormalHeat) // this+0x184 == 0
|
||||
{
|
||||
if (generatorOn == 0) { stateAlarm.SetLevel(GeneratorIdle); } // 1
|
||||
else { startTimer = 0.0f; stateAlarm.SetLevel(GeneratorStarting); } // 0
|
||||
}
|
||||
```
|
||||
Binary identical [T1]: part_013.c:9087-9097 (`param_1[0x61] == 0` gate, generatorOn at [0x75]). After the spin-up (`startTime <= startTimer`) it goes `SetLevel(GeneratorReady)` (2) and recomputes `outputVoltage = (1.0 - SourceLevel()) * ratedVoltage` (powersub.cpp:1316-1324).
|
||||
|
||||
The temperature thresholds behind heatAlarm (heat.cpp:851-862, @004ad924 [T1]): `T > failureTemperature → FailureHeat(2)`, `T > degradationTemperature → DegradationHeat(1)`, else `NormalHeat(0)`. Pure per-frame comparison, NO hysteresis. So: **trip at T > failureTemperature; recovery gate at T ≤ degradationTemperature** (all the way out of the yellow band, not just the red).
|
||||
|
||||
**The tester's era rule ("back online at HALF-cooled, when the red portion of the heat bar is gone") is SATISFIED by the port as built — no divergence.** The generator heat bar is a `VertTwoPartBar` fed `(currentTemp, degradeTemp, failTemp)` (btl4gau2.cpp:980-998); its Execute (btl4gaug.cpp:1321-1360, @004c48fc [T1]) tiles the bar up to the warn pixel `height*low/high` (low=degradationTemperature, high=failureTemperature) and paints `fillColor` only in the degradation..failure segment — i.e. the "red portion" IS exactly the T>degradationTemperature band, and it disappears exactly when the recovery gate opens. With the authored profile degradation=1000/failure=2000 (myomer record from BTL4.RES; generator values not separately confirmed [T3]) the warn line sits at literally HALF the bar. Oracle's 2nd/3rd tests ("came back after cooling halfway") behaviorally confirm the port already does this [T2-field].
|
||||
|
||||
### 2. stateAlarm writers, edges, and marks
|
||||
|
||||
Complete writer inventory of `Generator::stateAlarm` (@0x1FC, level cell @0x210):
|
||||
- powersub.cpp:1311 `SetLevel(4)` — thermal breaker, HELD (per-frame re-assert while FailureHeat). The edge-ification happens inside the alarm: `GaugeAlarm54::SetLevel` (heat.hpp:106) `{ levelB = level; if (n != level) { level = n; NotifyWatchers(); } }` — notify ONLY on change, oldState (levelB@0x10) updated UNCONDITIONALLY. Byte-matches the binary FUN_0041bbd8 [T1] (part_002.c:5512-5546: `+0x10 = +0x14` before the equality check, then the three watcher sockets @+0x18/+0x2c/+0x40 fire only on change) and the T0 engine `StateIndicator::SetState` (engine/MUNGA/SIMULATE.cpp:155-217, including the "old state changes even on no-op set" NOTE at :176-179).
|
||||
- powersub.cpp:1302 `SetLevel(1)` — held while `simulationState==1` (destroyed): a crit-destroyed generator is PINNED Idle forever (authentic).
|
||||
- powersub.cpp:1321/1335 `SetLevel(2)` — spin-up / short-recovery completion.
|
||||
- powersub.cpp:1346/1354 — the recovery arm above (4→1 or 4→0).
|
||||
- powersub.cpp:1392 `SetLevel(3)` — HandleMessage msg 4 short (@004b21d0 [T1]).
|
||||
- powersub.cpp:1212/1221 — ToggleGeneratorOnOff (@004b1ed0, raw re-read part_013.c:9016-9045 [T1]): OFF → SetLevel(1); ON → SetLevel(0) ONLY if heatAlarm==0 (hot ON-press changes NO state — see §4).
|
||||
- powersub.cpp:1269-1270 — ResetToInitialState (@004b215c [T1]): SetLevel(0) then SetLevel(2) — every respawn passes through Starting then Ready.
|
||||
- powersub.cpp:555 + :1564 — `ForceShortRecovery`/`PowerWatcher::HandleMessage` write `source->stateAlarm.SetLevel(3)` externally.
|
||||
- `MarkGeneratorOut()` (powersub.hpp:483) via `BTGeneratorMarkOut` (powersub.cpp:1787-1791): **ZERO live callers** — the ForceCriticalFailure call was removed 2026-08-03 (mechsub.cpp:290-297).
|
||||
|
||||
**Edge delivered to audio:** the trip produces a synchronous `StateChanged(prev→4)` on the trip frame. Mechanism: `ATTRIBUTE_ENTRY(Generator, GeneratorState, stateAlarm)` (powersub.cpp:1051) publishes the alarm; `AudioStateWatcher` registers on the alarm's +0x18 audio socket (engine/MUNGA/AUDWTHR.cpp:918, with the T3 bring-up guard :908-917 that a constructed GaugeAlarm54 passes); `NotifyWatchers` (heat.cpp:96-111) Execute()s it inside SetLevel; `SendNotificationOfChange` (AUDWTHR.cpp:933-945) reads GetOldState()/GetState() at +0x10/+0x14 — layout verified compatible: StateIndicator : Node : Plug : RegisteredClass gives header 0xC then stateCount/oldState/currentState @0xC/0x10/0x14 + three sockets @0x18/0x2C/0x40 = 0x54, identical to GaugeAlarm54 (heat.hpp:126-138). State watchers are NOT on the per-frame poll socket (only `AudioTriggerOf`/`AudioMatchOf` call `simulation->AddAudioWatcher(this)` — AUDWTHR.h:293/:770; `AudioStateWatcher` registers only on the indicator socket), so no spurious held-state notifications.
|
||||
|
||||
**No ForceUpdate/updateModel/SetDirty mark exists at either edge** — grep of powersub.cpp finds none; the binary @004b1f7c has none either [T1]. The notification is purely the alarm-socket synchronous path.
|
||||
|
||||
**So the port's plumbing puts the notification on the TRIP edge (X→4), and also emits 4→0 then 0→2 at recovery.** Which of those the "Generator Out" SPEECH keys on is decided by the authored `AudioStateTrigger` fields (`triggerState/inverseTrigger/exclude_state`, streamed — AUDWTHR.cpp:968-990; firing rule :1046-1105 [T0]): non-inverse fires on `new_state==triggerState`, inverse on `old_state==triggerState` — an inverse trigger on state 4 fires EXACTLY on the recovery edge, which matches Oracle's symptom precisely. The port cannot flip this itself: the alarm mechanics are byte-faithful. The decisive probe is the load-time `[statecfg]` dump (AUDWTHR.cpp:981-987, env BT_ATTRBIND_LOG) — it prints every authored GeneratorState trigger's trigState/inv/exclude. Until that capture, whether the wrong edge is (a) authored-inverse consumed correctly (pod-era authoring quirk), (b) the trip-edge trigger authored against an attribute the port publishes differently, or (c) a dead START half of a start/stop pair, is undetermined FROM THIS LANE.
|
||||
|
||||
### 3. Divergence check vs the knowledge base
|
||||
|
||||
- context/decomp-reference.md:470-477 ("no Ready-case recompute"; stateAlarm 4's producer = the thermal breaker in GeneratorSimulation; the 2026-08-03 destruction→state-4 bridge removed as unfounded): **port conforms** — no Ready-case recompute exists in the port either, `BTGeneratorMarkOut` is dead code as documented.
|
||||
- context/subsystems.md:103-105 ("(1 − generator damage) × rated, thermal breaker on FailureHeat; NO load model"): consistent with powersub.cpp:1322/1336/1398.
|
||||
- One STALE-ish note found: powersub.cpp:1052's comment on GeneratorOn ("static until a shutdown writer drives it to 0") — the shutdown writer EXISTS now (ToggleGeneratorOnOff, powersub.cpp:1214/1223); the comment predates #53. Cosmetic.
|
||||
- No divergence found between the port and @004b1f7c/@004b1ed0/@004b215c/@0041bbd8 — instruction-shape faithful on every path I compared [T1].
|
||||
|
||||
### 4. FailureHeat "permanent brick" family (open-questions)
|
||||
|
||||
The generator does NOT share a recompute path with the weapon FailureHeat family — three distinct recovery disciplines all reading the SAME producer (own HeatSink heatAlarm @+0x184, thresholds heat.cpp:851-861):
|
||||
1. **Emitters** (@004baa88, emitter.cpp:225-229, :426-434): continuous per-tick gate — ResetFiringState + currentLevel=0 while heatAlarm==2, auto-resumes as soon as heatAlarm drops to 1 (DegradationHeat). Weapons recover at "red gone"; the generator demands full NormalHeat — an intentional binary asymmetry [T1].
|
||||
2. **Launchers** (open-questions.md:1086-1102, projweap gate-1): a LATCH (weaponAlarm 7 roach-motel, re-asserted unconditionally), recovery ONLY out-of-band via the EjectAmmo tap (@004bb9b8) or reset — never by cooling. This is the documented "missile spam brick".
|
||||
3. **Generator**: a state-machine arm (case 4) that self-clears at NormalHeat — the OPPOSITE of a latch.
|
||||
|
||||
Family resemblance that could explain the once-seen "stuck offline forever" (did not repro): the generator DOES have latch-shaped states with no thermal recovery arm, and both are binary-authentic [T1]:
|
||||
- **The Idle park**: press generator OFF then ON while heatAlarm==1 (degradation band). The ON branch (@004b1ed0, part_013.c:9027-9033) sets startTimer=0 but does NOT touch stateAlarm when hot → parked at Idle(1) with generatorOn=1; case 1 has no restart arm; the FailureHeat re-trip doesn't fire (heat is only level 1). Recoverable only by another OFF/ON cycle after full cooling, or reset. Requires eng-page button presses — plausible for Oracle's first test.
|
||||
- **Crit-destroyed generator**: `simulationState==1` pins Idle every frame (powersub.cpp:1300-1303) — permanently out, authentically. If trip #1 coincided with a generator crit, "never came back" is correct behavior.
|
||||
- Non-button path: T parked in the degradation band forever keeps the breaker latched — but then the bar still shows red, contradicting "cooled off completely"; the bar and the alarm read the same currentTemperature, so this one is self-consistent and unlikely.
|
||||
|
||||
Bench hooks already in place: `[gen] X TRIPPED (T=...)` / `[gen] X RESTARTING (T=...)` under BT_HEAT_LOG (powersub.cpp:1308-1310, 1350-1352) give the exact trip/recovery timeline to line up against the audio.
|
||||
--- KEY FACTS ---
|
||||
* Trip: powersub.cpp:1304-1312 — stateAlarm.SetLevel(GeneratorOut=4) re-asserted EVERY frame while own heatAlarm==FailureHeat(2), i.e. currentTemperature > failureTemperature; binary-identical @004b1f7c (part_013.c:9062-9064) [T1]
|
||||
* Recovery: powersub.cpp:1340-1357 — requires heatAlarm==NormalHeat(0), i.e. T <= degradationTemperature; then GeneratorStarting(0) -> spin-up startTime -> GeneratorReady(2) with outputVoltage=(1-SourceLevel())*ratedVoltage; binary-identical (part_013.c:9087-9097) [T1]
|
||||
* heatAlarm thresholds heat.cpp:851-862 (@004ad924): >failureT=FailureHeat(2), >degradationT=DegradationHeat(1), else NormalHeat(0); NO hysteresis [T1]
|
||||
* Era rule already satisfied: the generator heat bar (VertTwoPartBar, btl4gau2.cpp:980-998; Execute btl4gaug.cpp:1321-1360 @004c48fc) paints its fill ONLY in the degradationT..failureT segment — 'red portion gone' == T<=degradationT == the recovery gate; with authored deg=1000/fail=2000 the warn line is at half the bar [T1 code, T3 for generator-specific authored values]
|
||||
* Alarm edge semantics faithful: GaugeAlarm54::SetLevel (heat.hpp:106) == binary FUN_0041bbd8 (part_002.c:5512-5546) == engine StateIndicator::SetState (SIMULATE.cpp:155-217): oldState:=current unconditionally, notify watchers only on change [T0/T1]
|
||||
* Trip edge DOES notify audio synchronously in the port: StateChanged(prev->4) on the trip frame via the alarm's +0x18 audio socket (AUDWTHR.cpp:918, 933-945); recovery emits 4->0 then 0->2; layout GaugeAlarm54==StateIndicator verified (header 0xC, oldState@0x10, currentState@0x14, sockets 0x18/0x2C/0x40) [T0+T1]
|
||||
* No ForceUpdate/updateModel/SetDirty mark at either edge in port or binary; AudioStateWatcher/AudioStateTrigger are NOT on the per-frame poll socket (only AudioTriggerOf/AudioMatchOf call simulation->AddAudioWatcher — AUDWTHR.h:293/:770) [T0]
|
||||
* Which edge plays the voice is decided by authored AudioStateTrigger fields (triggerState/inverseTrigger/exclude_state, AUDWTHR.cpp:1046-1105): inverse trigger on state 4 fires exactly on the RECOVERY edge — matching Oracle's symptom; the [statecfg] dump under BT_ATTRBIND_LOG (AUDWTHR.cpp:981-987) prints every authored GeneratorState trigger config at load [T0 mechanism, T4 on which config is authored]
|
||||
* stateAlarm=4 writers: ONLY the thermal breaker; MarkGeneratorOut/BTGeneratorMarkOut (powersub.cpp:1787) has zero callers since 2026-08-03 (mechsub.cpp:290-297) [T2]
|
||||
* Stuck-offline candidates, all binary-authentic [T1]: (a) Idle park — generator ON pressed while heatAlarm==1: @004b1ed0 (part_013.c:9027-9033) sets no state when hot -> parked at Idle(1), no restart arm, FailureHeat re-trip cannot fire at level 1; (b) crit-destroyed generator pinned Idle every frame (powersub.cpp:1300-1303, simulationState==1)
|
||||
* FailureHeat family recompute paths are DISTINCT: emitter auto-resumes at heatAlarm<2 (@004baa88); launcher latches weaponAlarm 7, recovery only via EjectAmmo tap (@004bb9b8) or reset (open-questions.md:1086-1102); generator self-clears only at heatAlarm==0 — shared PRODUCER (own heatAlarm) but no shared recovery path [T1]
|
||||
* Weapons recover at DegradationHeat (red gone) but the generator requires NormalHeat — an intentional binary asymmetry, not a port bug [T1]
|
||||
* Bench hooks: BT_HEAT_LOG prints '[gen] X TRIPPED (T=..)' / '[gen] X RESTARTING (T=..)' (powersub.cpp:1308-1310, 1350-1352) for a trip/recovery-vs-audio timeline [T2]
|
||||
--- UNCERTAINTIES ---
|
||||
* The authored AudioStateTrigger config for Generator.GeneratorState (triggerState/inverseTrigger/exclude_state values) is unknown from this lane — it lives in the 1995 audio stream; the BT_ATTRBIND_LOG [statecfg] load-time dump would settle which edge the 'Generator Out' speech keys on. Until then, whether the wrong edge is authored-inverse (STOP half of a pair firing as the only live trigger) or a dead trip-side binding is T4.
|
||||
* Whether the 'Generator Out' voice is even bound to Generator.GeneratorState vs GeneratorOn (a live x4 attr that does NOT change on thermal trip) vs a PoweredSubsystem electrical-state attribute the binary published but the port does not (port publishes only InputVoltage on PoweredSubsystem, powersub.cpp:100-104) — unverified.
|
||||
* Generator-specific authored DegradationTemperature/FailureTemperature values: only the myomer profile (1000/2000, BTL4.RES) is confirmed; generators are authored per-subsystem in content and could differ, which would shift where 'half-cooled' sits on the bar.
|
||||
* Whether Oracle's once-seen stuck generator was crit-destroyed (authentic permanent-out), the Idle park (requires OFF/ON presses while in the yellow band), or something else — not reproducible from code reading; needs the field log for that session.
|
||||
* The AudioStateWatcher bring-up guard (AUDWTHR.cpp:908-917) is assumed to PASS for GaugeAlarm54 sockets (constructed SChainOf vptr at +0x18); AUDIO_FIDELITY F8's 'all 118 state watchers bind real StateIndicators' supports this, but a per-generator [audiostate] skip line under BT_AUDIO_LOG in the FIELD build has not been ruled out this session.
|
||||
* Prime-time notification (PrimeWatcher -> SendNotificationOfChange at audio-page bind if the streamed sendNotificationOnChange flag is set) delivers StateChanged(0,2) at load — whether any authored trigger misfires on that is unchecked.
|
||||
|
||||
|
||||
########## LANE 1 ##########
|
||||
## The binary's Generator state machine (issue #154 lane: BTL4OPT.EXE simulation)
|
||||
|
||||
### Object map (Generator, classID 0xBC1, ctor @4b225c, vtable 0x50fd74)
|
||||
The generator's operational state machine is a 0x54-byte StateIndicator at gen+0x1FC (ctor `FUN_0041b9ec(this+0x1FC, 5)` = 5 levels; current level @0x210 = the "stateAlarm", previous level @0x20C) [T1]. Fields set by the ctor @4b225c: ratedVoltage@0x1D8←res+0xFC, outputVoltage@0x1DC, maxTapCount@0x1E4←res+0x100, startTime@0x1EC←res+0x104, startTimer@0x1F0, shortRecoveryTime@0x1F4←res+0x108, shortTimer@0x1F8, generatorOn@0x1D4=1, percentAvailable@0x1D0=1.0, generatorNumber@0x1E0 = last char of name − 0x40 (A=1..D=4). Initial state = SetState(2) Ready [T1]. States (2=Ready confirmed by HasVoltage @4b0b5c requiring source+0x210==2; 3=short-recovery and 1/4=off/out confirmed by the consumer-side dump @4b1224 which maps source 3→"Shorted", source 1||4→"GeneratorOff"): 0=Starting, 1=Off/Idle, 2=Ready, 3=ShortRecovery, 4=GeneratorOut.
|
||||
|
||||
The heat alarm is a SECOND, separate 3-level StateIndicator at gen+0x170 (level @0x184), inherited from HeatableSubsystem (ctor @4adda0: `FUN_0041b9ec(this+0x170, 3)`), with debug names @4ae050: 0="NormalHeat", 1="DegradationHeat", 2="FailureHeat" [T1].
|
||||
|
||||
### 1. THE THERMAL TRIP — exact write site
|
||||
`GeneratorSimulation` = FUN_004b1f7c(this, dt) (part_013.c:9049-9107). Its preamble, every tick, BEFORE the state switch:
|
||||
- base call FUN_004ad924 (HeatableSubsystem sim) recomputes temperature@0x114 = heatContent@0x158 / thermalMass@0x154 and drives the heat alarm with NO hysteresis: `if (T > FailureTemperature@0x11C) SetState(heatAlarm,2); else if (T > DegradationTemperature@0x118) SetState(heatAlarm,1); else SetState(heatAlarm,0)` (part_013.c:5638-5646) [T1]. The thresholds are authored content: HeatSink ctor @4adda0 loads 0x114←res+0xE4 StartingTemperature, 0x118←res+0xE8 DegradationTemperature, 0x11C←res+0xEC FailureTemperature (parse @4ae150) [T1].
|
||||
- then `if (heatAlarm@0x184 == 2) SetState(genState@0x1FC, 4)` (part_013.c:9062-9064). **This is the ONLY producer of stateAlarm 4 in the binary** — I walked every SetState on the +0x1FC machine: ctor(2), ResetToInitialState @4b215c (0 then 2), toggle @4b1ed0 (0/1), sim @4b1f7c (1/4/2/2/1/0), HandleMessage msg-4 @4b21d0 (3), PoweredSubsystem-initiated short @4b11bc (3). No other site writes 4 [T1]. (The `SetState(+0x99,4)` calls in @4b0bd0/@4b0e6c are the CONSUMER PoweredSubsystem's own 5-state electrical machine @0x278 — 0=Starting,1=NoVoltage,2=Shorted,3=GeneratorOff,4=Ready per the string dump @4b1224 — do not confuse the two.)
|
||||
- Also in the preamble: `if (this+0x40 == 1) SetState(genState, 1)` — pins a +0x40==1 generator to Off every tick.
|
||||
- No gate on the trip: it fires at any experience level *mechanically*, but temperature can only rise when the heat model integrates (FUN_004ad7d4 gate = player+0x260, veteran+expert), so trips only occur when the heat model is on [T1].
|
||||
|
||||
### 2. THE RECOVERY — exact re-arm condition and threshold
|
||||
Case 4 body (part_013.c:9087-9097): output forced 0 every tick; `if (heatAlarm@0x184 == 0)`: if generatorOn@0x1D4==0 → SetState(1) Off; else → startTimer@0x1F0=0, SetState(0) **Starting** — then case 0 counts startTimer up and at startTime seconds does SetState(2) Ready + `outputVoltage = (1.0 − damageZone->damageLevel) × ratedVoltage` (constant @4b2154 byte-verified = 3F800000 = 1.0f in the exe's CODE section, file offset 0xB1F54).
|
||||
|
||||
**The re-arm threshold is NOT "half" of anything — there is no 0.5 multiplier anywhere in the generator path** (constants adjacent to the sim: @4b2154=1.0, @4b2158=0.0, @4b2258=1.0, @4b297c=0.0 — all byte-verified from BTL4OPT.EXE) [T1]. Recovery requires the heat alarm to return to **0 (NormalHeat), i.e. T ≤ DegradationTemperature** — alarm 1 (DegradationHeat) is NOT enough; the generator stays OUT through the whole degradation band. The hysteresis is structural: trip at T > FailureTemperature, re-arm at T ≤ DegradationTemperature, plus a full StartTime spin-up before voltage returns.
|
||||
|
||||
**The tester's "red portion of the heat bar gone" rule matches this EXACTLY in gauge terms**: the two-part temp bars normalize to FailureTemperature with the warn boundary at DegradationTemperature (warnPix = width·Degrade/Failure, value = CurrentTemperature — gauges-hud.md §TEMP/STATUS bar, @4c4170/@4c4724) [T1]. "Red gone" ⇔ T ≤ DegradationTemperature ⇔ the binary's re-arm condition. Whether that is numerically "half-cooled" depends on authored per-mech values; field notes (open-questions.md: generators equilibrate 1100–1500 K in the degradation band vs the ~2000 FailureHeat line) put Degrade/Failure ≈ 0.55 — approximately half [T3, authored temps not extracted from the archives].
|
||||
|
||||
### 3. WHICH EDGE the annunciator hears
|
||||
The stateAlarm machine IS a StateIndicator; SetState = FUN_0041bbd8: first line unconditionally copies current→old (+0x10←+0x14), and ONLY on an actual value change it stores the new state and synchronously fires all three watcher chains (+0x18 audio / +0x2C video / +0x40 gauge), each watcher's vtbl+0x14 (part_002.c:5510-5546) [T1]. So:
|
||||
- **stateAlarm 4 is written AT TRIP** (edge old=2→new=4 fires the audio chain the same tick T crosses FailureTemperature), **re-asserted every tick while out (edge-filtered no-ops, no re-fire), and REPLACED at recovery** by the edge 4→0 (Starting), followed StartTime seconds later by 0→2 (Ready). Nothing "clears" 4 separately; no write of 4 happens at recovery.
|
||||
- What SOUND plays on which edge is decided by the AUTHORED AudioStateTrigger record streamed from content, engine code AUDWTHR.cpp `AudioStateTrigger::StateChanged` [T0]: fires when `!inverseTrigger && new_state==triggerState` (ENTER edge) or `inverseTrigger && old_state==triggerState` (LEAVE edge), with optional exclude_state suppression. **Both keying modes exist in the engine.** A "Generator Out" sample keyed non-inverse on state 4 speaks at TRIP; one keyed inverse on 4 (or non-inverse on 0/2) speaks at RECOVERY. The binary/engine machinery emits both edges faithfully — if the field build speaks only at recovery, the discriminator is the authored trigger record (trigger_state / inverse_trigger / exclude_state for `Generator.GeneratorState`), which has NOT been dumped ([statecfg] via BT_ATTRBIND_LOG would capture it; no capture exists in scratchpad/docs). Note the port's GaugeAlarm54::SetLevel (heat.hpp:106) and StateIndicator layout (STATE.h: oldState@0x10/currentState@0x14) reproduce the binary semantics exactly, including the unconditional old←current copy — the port sim (powersub.cpp:1296 GeneratorSimulation) is a faithful transcription of @4b1f7c, so the mis-edged audio is NOT a divergence in the generator sim itself [T1].
|
||||
|
||||
### 4. "No Ready-case recompute" — CONFIRMED, with one refinement
|
||||
The switch in @4b1f7c has cases 0,1,3,4 and **NO case 2** — in Ready the sim touches nothing; outputVoltage is recomputed as `(1−damage)×rated` ONLY on the two Ready-ENTRY transitions (case 0 timeout @9069-9073, case 3 timeout @9081-9085) [T1]. Refinement: `Generator::HandleMessage` @4b21d0 ALSO recomputes `(1−damage)×rated` on ANY message id ≠ 4 (gated by player+0x274 ≠ 0, not-novice), and msg 4 (short) drives SetState(3)+shortTimer=0 when +0x40 ∈ {0,2} — so a message delivery is a third recompute path, but the crit distributor delivers damage without messaging the generator, so the decomp-reference claim stands for the destroyed-in-place case [T1].
|
||||
|
||||
### Bonus: a binary-authentic "stuck offline forever" candidate
|
||||
ToggleGeneratorOnOff @4b1ed0 (not-novice gate FUN_004ac9c8, press>0): toggling ON while the heat alarm ≠ 0 sets generatorOn=1 and startTimer=0 but deliberately does NOT SetState — it leaves the machine in whatever state it is in. If the pilot toggles a TRIPPED (state 4) generator OFF (→ state 1, generatorOn=0) and back ON while still hot (state stays 1, generatorOn=1), the machine is now in state 1 with generatorOn=1 — and case 1 has NO exit logic and NO thermal re-arm check. When the heat later cools to normal, nothing transitions: the generator stays offline indefinitely, recoverable only by another full off/on toggle pair after cooling, or by re-tripping to state 4 (whose case body honors generatorOn) [T1, pure case-walk — matches the tester's non-reproducing "stuck offline forever" one-off]. This is the same defect family as the "no Ready-case recompute" latch and the open-questions FailureHeat weapon brick (weaponAlarm 7 — a different machine, same alarm-latch pattern).
|
||||
--- KEY FACTS ---
|
||||
* stateAlarm 4 (GeneratorOut) has exactly ONE producer in the binary: the thermal breaker at the top of GeneratorSimulation @004b1f7c — `if (heatAlarm@0x184 == 2) SetState(genState@0x1FC, 4)` (part_013.c:9062-9064); it is written AT TRIP, held (edge-filtered) while out, and replaced 4→0→2 at recovery — no write of 4 occurs on the recovery edge [T1]
|
||||
* Re-arm condition (case 4 body @004b1f7c, part_013.c:9087-9097): heat alarm must return to 0 = NormalHeat, i.e. currentTemperature ≤ DegradationTemperature (res+0xE8) — NOT half of FailureTemperature; no 0.5 constant exists in the path (sim constants @4b2154=1.0f, @4b2158=0.0f, @4b2258=1.0f byte-verified in BTL4OPT.EXE CODE section) [T1]
|
||||
* Recovery is two-stage: 4→0 Starting (startTimer reset), then after StartTime (res+0x104) seconds 0→2 Ready with outputVoltage = (1.0 − damageZone->damageLevel) × ratedVoltage; if generatorOn@0x1D4==0 at cool-down it goes 4→1 Off instead [T1]
|
||||
* The heat alarm producer FUN_004ad924 (part_013.c:5638-5646) has NO hysteresis: >FailureTemperature→2, >DegradationTemperature→1, else 0, every tick; thresholds loaded by HeatSink ctor @004adda0 from res+0xE4/0xE8/0xEC (StartingTemperature/DegradationTemperature/FailureTemperature, parse @004ae150); alarm names @004ae050: Normal/Degradation/FailureHeat [T1]
|
||||
* The tester's era rule maps exactly: the gauge temp bars place the warn/red boundary at DegradationTemperature over a 0..FailureTemperature scale (warnPix = width·low/high, gauges-hud.md, @004c4170/@004c4724), so 'red portion gone' ⇔ T ≤ DegradationTemperature ⇔ the binary's re-arm threshold [T1]; numerically ≈0.55·Failure from field equilibrium notes (1100–1500 K degradation band vs ~2000 FailureHeat) [T3]
|
||||
* The audio edge is decided by AUTHORED content, not code: engine AudioStateTrigger::StateChanged (AUDWTHR.cpp) fires on ENTER (non-inverse: new_state==triggerState) or LEAVE (inverse: old_state==triggerState) with optional exclude_state; the state machine (FUN_0041bbd8, part_002.c:5510) fires its audio/video/gauge chains synchronously on every level CHANGE, old←current copied unconditionally first [T0/T1]
|
||||
* The authored AudioStateTrigger record for Generator.GeneratorState (trigger_state/inverse_trigger/exclude_state) has never been dumped — no [statecfg] capture exists in scratchpad or docs; BT_ATTRBIND_LOG would print it at load [T2 gap]
|
||||
* 'No Ready-case recompute' CONFIRMED: the @004b1f7c switch has cases 0,1,3,4 and no case 2; recompute happens only on Ready-entry (case 0/3 timeouts) and on Generator::HandleMessage @004b21d0 for any message id ≠ 4 when player+0x274 ≠ 0 (not-novice); msg 4 = short → SetState(3)+shortTimer=0 when this+0x40 ∈ {0,2} [T1]
|
||||
* Binary-authentic stuck-offline latch found: ToggleGeneratorOnOff @004b1ed0 turning ON while heatAlarm ≠ 0 sets generatorOn=1 WITHOUT a state transition; from state 1 (Off) there is no thermal re-arm path, so off→on toggling a hot tripped generator strands it offline after cooling until another cold toggle pair or a fresh alarm-2 trip [T1 case-walk, matches the tester's one-off]
|
||||
* Generator toggle and short handling are novice-locked: FUN_004ac9c8 = (player+0x274 == 0); the heat-model integration gate is player+0x260 (veteran+expert) via FUN_004ad7d4, but the alarm thresholding runs ungated [T1]
|
||||
* Consumer view for cross-checks: PoweredSubsystem sim @004b0bd0 maps source stateAlarm 3→own state 2 'Shorted', 1||4→3 'GeneratorOff', 2→restart via 0 'Starting'; own electrical state names @004b1224: 0 Starting/1 NoVoltage/2 Shorted/3 GeneratorOff/4 Ready (machine @+0x264, level @0x278 — distinct from the generator's @0x1FC/0x210) [T1]
|
||||
* The port's generator machinery is a faithful transcription: powersub.cpp:1296 GeneratorSimulation mirrors @004b1f7c line-for-line, GaugeAlarm54::SetLevel (heat.hpp:106) mirrors FUN_0041bbd8 including the unconditional old←current copy, and engine StateIndicator layout (oldState@0x10/currentState@0x14) matches — the generator sim is NOT the source of the mis-edged audio [T1]
|
||||
--- UNCERTAINTIES ---
|
||||
* The authored trigger_state/inverse_trigger/exclude_state for the Generator.GeneratorState AudioStateTrigger (and which SF2 preset/control it drives) is undumped content data — this alone determines whether 'Generator Out' speaks at trip or recovery in an authentic build; dump with BT_ATTRBIND_LOG=1 ([statecfg]/[trigcfg] lines) on a mech load
|
||||
* Whether the pod's SPOKEN 'Generator Out' warning even rides the AudioStateTrigger/SF2 path at all, versus a separate speech/annunciator system — not established in this lane
|
||||
* The semantics of gen+0x40 (port name simulationState): ==1 pins the machine to Off each tick and excludes the generator from eject-counting, but what writes 1 there is unlocated; decomp-reference's 'destroyed in place keeps stale Ready voltage' implies destruction does NOT set it — the two claims need reconciling
|
||||
* Actual authored Generator StartingTemperature/DegradationTemperature/FailureTemperature/StartTime values per mech are inside the compiled content archives (only the exe and L4GAUGE.CFG reference the key names as text); the ≈0.55 Degrade/Failure ratio is inferred from field equilibrium notes, not extracted values
|
||||
* Whether message id 4 ('short') is ever delivered to Generator::HandleMessage in normal play (vs the @004b11bc ForceShortRecovery direct-call path) — the message's producer was not traced
|
||||
* @004b2164 (Generator::DeathReset thunk cited by the port) sits in an export gap between @4b215c and @4b21d0 — shape assumed from the port comment, not read from pseudocode
|
||||
|
||||
|
||||
########## LANE 2 ##########
|
||||
LANE: the port's generator reconstruction (game/reconstructed/powersub.cpp + heat.cpp/hpp, engine AUDWTHR.cpp watchers).
|
||||
|
||||
== 1. Trip + recovery logic and thresholds ==
|
||||
|
||||
The generator thermal breaker lives in Generator::GeneratorSimulation, C:/git/bt411/game/reconstructed/powersub.cpp:1296-1372, a line-for-line transcription of the binary @004b1f7c (reference/decomp/all/part_013.c:9049-9107).
|
||||
|
||||
TRIP (powersub.cpp:1304-1312):
|
||||
```
|
||||
if (heatAlarm.GetLevel() == HeatSink::FailureHeat) // this+0x184 == 2
|
||||
{
|
||||
... BT_HEAT_LOG "[gen] <name> TRIPPED (T=...)" on the transition only ...
|
||||
stateAlarm.SetLevel(GeneratorOut); // SetLevel 4 -- called EVERY frame while FailureHeat holds
|
||||
}
|
||||
```
|
||||
Binary: `if (param_1[0x61] == 2) FUN_0041bbd8(param_1+0x7f, 4);` (part_013.c:9062-9064) [T1 exact match].
|
||||
|
||||
RECOVERY (powersub.cpp:1340-1357):
|
||||
```
|
||||
case GeneratorOut: // 4
|
||||
outputVoltage = 0.0f;
|
||||
if (heatAlarm.GetLevel() == HeatSink::NormalHeat) // this+0x184 == 0
|
||||
{
|
||||
if (generatorOn == 0) stateAlarm.SetLevel(GeneratorIdle); // 1 -- STAYS DOWN until manual toggle
|
||||
else { startTimer = 0.0f; stateAlarm.SetLevel(GeneratorStarting); } // 0 -> after StartTime secs -> SetLevel(2) Ready
|
||||
}
|
||||
```
|
||||
Binary: `case 4: if (param_1[0x61] == 0) { if (param_1[0x75]==0) SetLevel 1; else { [0x7c]=0; SetLevel 0; } }` (part_013.c:9087-9098) [T1 exact match].
|
||||
|
||||
The heat thresholds are NOT in the generator — they are the HeatSink heatAlarm drive at the tail of HeatSinkSimulation, heat.cpp:851-862: `T > failureTemperature -> FailureHeat(2); else T > degradationTemperature -> DegradationHeat(1); else NormalHeat(0)`. Binary @004ad924 tail is identical (`param_1[0x47] < T -> 2; param_1[0x46] < T -> 1; else 0`, temps @0x11C/@0x118) — NO hysteresis on either side [T1]. Both temperatures are authored per-subsystem resource fields ("DegradationTemperature"/"FailureTemperature"); their generator values live in the streamed .RES model data and were not dumped here.
|
||||
|
||||
CONSEQUENCE FOR THE ERA RULE ("back online when the red bar portion is gone / half-cooled"): the recovery gate is heatAlarm==0, i.e. T must fall BELOW degradationTemperature — the LOWER of the two thresholds, well below the trip point. The heat gauges bind CurrentTemperature with DegradationTemperature + FailureTemperature as zone markers (content/GAUGE/L4GAUGE.CFG:4434-4436 pattern), so if the bar's red zone starts at DegradationTemperature, "red gone" == the exact recovery gate and the port is ALREADY era-correct — which matches the tester's own runs 2 and 3 ("it did come back online after cooling halfway", scratchpad/night14/housekeep14.py:224-240). This is the binary's behavior byte-for-byte; there is nothing to change in the sim [T1 code, T3 for the red-zone==degradation mapping, unverified against the palette].
|
||||
|
||||
== 2. Where stateAlarm is written, and on which edge ==
|
||||
|
||||
All stateAlarm (alarm @0x1FC, level @0x210) writers in the port:
|
||||
- ctor powersub.cpp:1113 `SetLevel(GeneratorReady)` (2)
|
||||
- GeneratorSimulation trip :1311 `SetLevel(4)` — TRIP edge, re-asserted every held frame
|
||||
- GeneratorSimulation recovery :1346/:1354 `SetLevel(1)` or `SetLevel(0)`, then :1321 `SetLevel(2)` after StartTime — RECOVERY is TWO notify edges: 4->0, then 0->2
|
||||
- ToggleGeneratorOnOff :1212/:1221 (0 on healthy re-arm, 1 on switch-off)
|
||||
- ResetToInitialState :1269-1270 `SetLevel(0); SetLevel(2);` — every respawn fires a 0-then-2 notify pair (binary @004b215c does the same, part_013.c:9119-9120)
|
||||
- Generator::HandleMessage msg 4 :1392 `SetLevel(GeneratorShorted)` (3)
|
||||
- ForceShortRecovery (PoweredSubsystem) :555 and PowerWatcher::HandleMessage :1564 (both set 3)
|
||||
- MarkGeneratorOut (powersub.hpp:483) — documented UNUSED; the destruction bridge was removed as unfounded (powersub.hpp:471-481).
|
||||
|
||||
The notify mechanism: GaugeAlarm54::SetLevel (heat.hpp:106) `levelB = level; if (n != level) { level = n; NotifyWatchers(); }` — oldState updated unconditionally FIRST, watchers fired ON CHANGE ONLY. Binary FUN_0041bbd8 (part_002.c:5512-5546) is semantically identical: `+0x10 = +0x14` unconditionally, then if-changed set +0x14 and Execute() every watcher on the three chains @+0x18/+0x2c/+0x40 [T1]. So the per-frame SetLevel(4) spam while tripped notifies exactly once, at the 2->4 trip edge.
|
||||
|
||||
THEREFORE: the port's sim DOES deliver a watcher notification on the TRIP edge (old=2, new=4), and delivers two more on recovery (4->0, 0->2). The generator sim is not the component putting the audio on the wrong edge. There is NO ForceUpdate/SetDirty/updateModel mark at either edge — grep of powersub.cpp + heat.cpp is empty, and the binary @004b1f7c contains nothing but the SetLevel calls and arithmetic; the watcher chains ARE the notification path [T1].
|
||||
|
||||
AUDIO WIRING (adjacent, for synthesis): GeneratorState is published as an attribute directly on stateAlarm (powersub.cpp:1051, ATTRIBUTE_ENTRY(Generator, GeneratorState, stateAlarm)); the audio renderer binds authored AudioStateTriggers to it by name (context/wintesla-port.md:94, "audiostate skips 85 -> 0"). Trigger evaluation is engine T0 (engine/MUNGA/AUDWTHR.cpp:1046-1105): non-inverse fires when new_state==triggerState, INVERSE fires when old_state==triggerState (i.e. on LEAVING the state), with optional exclude pairs. Given the sim edges above, a sound heard ONLY at recovery means the live trigger fired on 4->0 or 0->2 — i.e. either an inverse trigger on state 4 ("leaving GeneratorOut") or a trigger on entering 0/2. Which of those is authored is in the streamed audio pages, NOT in the port code — undumped. BT_ATTRBIND_LOG=1 prints every AudioStateTrigger's `[statecfg] trigState/inv/ctl/excl` at bind time (AUDWTHR.cpp:981-987), and BT_HEAT_LOG prints "[gen] X TRIPPED"/"[gen] X RESTARTING" (powersub.cpp:1308-1311, 1350-1352); one capture correlating those against the heard sample settles the edge question definitively. Related suspicion [T4]: docs/AUDIO_FIDELITY.md F5/F6 document whole blocks of authored warning audio bound to dead attributes (ReportLeak is dead on GeneratorA-D among 19 subsystems, AUDIO_FIDELITY.md:402-411) — if the pod's trip-side warning rides one of those dead bindings, the trip would be silent in the port while a different, live authored cue (e.g. a spin-up/state sound at 4->0 or 0->2) is what the tester heard at recovery and labeled "the generator out warning".
|
||||
|
||||
== 3. Divergence vs the context claims ==
|
||||
|
||||
None found. context/decomp-reference.md:470-477 ("the generator sim has NO Ready-case recompute — a generator destroyed in place keeps stale Ready voltage until any transition recomputes output") is confirmed in both the port and the binary: the GeneratorSimulation switch has cases 0/1/3/4 only, no case 2 — outputVoltage is untouched while Ready [T1]. context/subsystems.md:103-105 ("(1 − generator damage) × rated, thermal breaker on FailureHeat, no load model") matches the transcription (the `(MaxOutputVoltage - SourceLevel()) * ratedVoltage` recomputes at :1322/:1336/:1398). The gnrator.cpp/gnrator.hpp pair is an alias note only — no second class, no code emitted (gnrator.cpp:10-24). One historical note: Generator::ResetToInitialState deliberately diverges from the surviving GNRATOR.TCP source (which zeroed outputVoltage) to follow the shipped binary @004b215c (outputVoltage = ratedVoltage) — documented at powersub.cpp:1238-1263 and docs/RESPAWN_REARM_PLAN.md:116 [T1].
|
||||
|
||||
== 4. FailureHeat "permanent brick" family (open-questions.md:1086-1102) ==
|
||||
|
||||
The generator does NOT share a recompute path with the weapon FailureHeat family — only the PREDICATE. All three families gate on the identical HeatSink heatAlarm level 2 at this+0x184 (open-questions.md:644-647, disasm-verified consumers), but their latch semantics differ:
|
||||
- Emitters (@004baa88): FailureHeat -> ResetFiringState + currentLevel=0 "until it cools" — SELF-RECOVERING.
|
||||
- Ballistic/missile launchers (@004bbd36 / projweap gate 1): FailureHeat -> weaponAlarm 7 (NoAmmo), re-asserted unconditionally inside the machine — a LATCH released only by an EjectAmmo tap or ResetToInitialState (the reproduced 19-rounds-left brick, open-questions.md:1086-1102).
|
||||
- Generator (@004b1f7c case 4): SELF-RECOVERING when heatAlarm returns to 0.
|
||||
So the missile roach-motel mechanism cannot produce the tester's once-seen "stuck offline forever" generator. Port-lane candidates for that non-reproducing report, both mechanisms [T1], scenario attribution [T4]: (a) the generatorOn==0 branch — if the pilot's generator toggle (RIO 0x1A-0x1D) had put generatorOn=0 (or was pressed while tripped: the OFF branch also zeroes coolantFlowScale, powersub.cpp:1219-1225, so the tripped generator loses its own coolant flow and cools only by conduction), a cooled generator lands in Idle(1) and NEVER self-restarts — authentic binary behavior requiring a manual toggle-ON; (b) a cooling stall — recovery needs T <= degradationTemperature (far below the trip point at failureTemperature); with coolant starved the last stretch can take a very long time, reading as "never" in one sitting while the heat BAR (which may visualize a different normalization) reads "cooled".
|
||||
--- KEY FACTS ---
|
||||
* Port trip gate: heatAlarm==FailureHeat(2) -> stateAlarm.SetLevel(GeneratorOut=4), powersub.cpp:1304-1312, re-asserted every frame while held; byte-matches binary @004b1f7c (part_013.c:9062-9064) [T1]
|
||||
* Port recovery gate: case GeneratorOut requires heatAlarm==NormalHeat(0), i.e. T <= degradationTemperature (the LOWER threshold), then 4->0 (Starting) -> after authored StartTime -> 0->2 (Ready); powersub.cpp:1340-1357 + 1316-1324; byte-matches binary part_013.c:9087-9098 [T1]
|
||||
* Heat alarm thresholds have NO hysteresis: T>failureTemperature->2, T>degradationTemperature->1, else 0; heat.cpp:851-862 == binary @004ad924 tail (temps @0x118/@0x11C) [T1]
|
||||
* GaugeAlarm54::SetLevel (heat.hpp:106) notifies watchers ON CHANGE ONLY, oldState updated unconditionally first — semantically identical to binary FUN_0041bbd8 (part_002.c:5512-5546); the per-frame SetLevel(4) spam produces exactly ONE trip-edge notification (old=2,new=4) [T1]
|
||||
* The port sim DOES fire a watcher notification on the trip edge; a sound heard only at recovery means the bound AudioStateTrigger fired on 4->0 or 0->2 — either an authored INVERSE trigger on state 4 (fires on old_state==triggerState, engine T0 AUDWTHR.cpp:1079-1082) or a trigger on entering Starting/Ready; the authored trigger config is in the streamed audio pages, undumped [T1 mechanics, config unknown]
|
||||
* No ForceUpdate/SetDirty/updateModel mark at either edge in port (grep empty over powersub.cpp+heat.cpp) or in binary @004b1f7c — the alarm watcher chains are the only notification path [T1]
|
||||
* GeneratorState attribute is published directly on stateAlarm (powersub.cpp:1051); audio binding verified live ('audiostate skips 85->0', context/wintesla-port.md:94-99) [T2]
|
||||
* decomp-reference.md 'no Ready-case recompute' CONFIRMED: the GeneratorSimulation switch has no case 2 in port or binary; outputVoltage stays stale while Ready [T1]; no divergence found between port and context/decomp-reference.md or context/subsystems.md generator claims
|
||||
* Generator does NOT share the weapon FailureHeat recompute/latch path — only the heatAlarm==2 predicate (this+0x184). Emitters @004baa88 self-recover, ballistic/missile @004bbd36 latch weaponAlarm 7 until EjectAmmo/reset (the reproduced brick, open-questions.md:1086-1102), generator self-recovers at heatAlarm==0 [T1]
|
||||
* Stuck-offline-forever candidates (mechanisms T1, attribution T4): generatorOn==0 at cool-down lands Idle(1) which never self-restarts (manual toggle required, authentic); toggling a generator OFF also zeroes its coolantFlowScale (powersub.cpp:1221-1224) starving its own cooling; recovery needs cooling all the way below degradationTemperature, far under the trip point
|
||||
* ResetToInitialState fires a 0-then-2 SetLevel pair on every respawn (powersub.cpp:1269-1270 == binary @004b215c) — any audio trigger keyed on entering state 0 or 2 also fires at every respawn [T1]
|
||||
* Era-rule alignment: heat gauges bind DegradationTemperature+FailureTemperature as zone markers (L4GAUGE.CFG:4434-4436 pattern); if the red zone starts at DegradationTemperature, 'red gone' == the recovery gate exactly — matching the tester's runs 2-3 recovery at half-cooled [T3]
|
||||
* Diagnostics that settle the audio edge: BT_ATTRBIND_LOG=1 dumps every AudioStateTrigger's trigState/inverse/exclude at bind (AUDWTHR.cpp:981-987); BT_HEAT_LOG prints [gen] TRIPPED/RESTARTING transition lines (powersub.cpp:1308-1311,1350-1352)
|
||||
--- UNCERTAINTIES ---
|
||||
* The authored AudioStateTrigger configuration for GeneratorState (trigger states, inverse flags, exclude pairs, control targets) is in the streamed audio pages of the mech model resources — not dumped; this single datum decides whether the recovery-edge sound is authored (inverse/state-0 trigger, pod would behave the same) or a port parsing/eval defect. Capture with BT_ATTRBIND_LOG=1.
|
||||
* Which sample the tester actually heard ('generator out warning') is unidentified — no GeneratorOut-named wav exists in audiopresets.cpp; it may be a Warnings01 klaxon split or another state cue misattributed. The trip-side warning may ride a DEAD authored binding (the AUDIO_FIDELITY F5/F6 family; ReportLeak is dead on GeneratorA-D) [T4].
|
||||
* Authored DegradationTemperature/FailureTemperature values for the generators (streamed .RES resources) not extracted; the 'red zone == degradation band' heat-bar mapping is inferred from the gauge attribute bindings, not verified against the palette rendering.
|
||||
* Whether the 1995 pod played the warning at the trip edge is era testimony only [T4]; nothing in the decomp examined here identifies the trip-side audio path.
|
||||
* The once-seen stuck-offline generator: neither candidate (generatorOn Idle landing, cooling stall) is confirmed against the tester's session — no log was captured for that run.
|
||||
|
||||
|
||||
########## LANE 3 ##########
|
||||
## #154 Generator Out warning — KB + field-evidence lane, raw findings
|
||||
|
||||
### 1. What the KB already claims (inventory)
|
||||
|
||||
**The generator state machine (binary-grounded):**
|
||||
- `stateAlarm` levels: 0=Starting, 1=Idle, 2=Ready/OnLine, 3=Shorted, 4=GeneratorOut. stateAlarm 4's producer = "the THERMAL BREAKER in GeneratorSimulation itself; a 2026-08-03 destruction→state-4 bridge was removed as unfounded" — `context/decomp-reference.md:470-477` [T1].
|
||||
- "The generator sim has NO Ready-case recompute — a generator destroyed IN PLACE keeps stale Ready voltage (no bit-6 invite) until any transition (thermal trip → stateAlarm 4 GeneratorOut, short, switch-off)" — `context/decomp-reference.md:472-475` [T1, @0049c9a8 + FUN_004b1f7c].
|
||||
- Offsets: `stateAlarm@0x1FC` (the 0x54 GaugeAlarm54 object base — `docs/HARD_PROBLEMS.md:251`, `docs/RESPAWN_REARM_PLAN.md:250`) vs `stateAlarm@0x210` in the eject clause (`decomp-reference.md:425`). These are CONSISTENT, not a conflict: 0x210 = 0x1FC + 0x14 = the alarm's `level` field (GaugeAlarm54 level@+0x14, `context/subsystems.md:31`). Flagging because it reads like a contradiction.
|
||||
- `Generator::ResetToInitialState @004b215c` does `stateAlarm.SetLevel(0)` then `SetLevel(2)` — "a forced *change*, so watchers re-latch" — `docs/RESPAWN_REARM_PLAN.md:172` [T1]. This 0→2 double edge fires at EVERY respawn/mission start.
|
||||
- Condition-flag bit table (the `[techstat]` receipts): bit0 structure≥StatusThreshold, bit1 structure>StatusFloor, bit2 CoolantLeaking, bit3 heatAlarm≠0 (Overheating), bit6 !HasVoltage (BadPower; HasVoltage = source stateAlarm==2 && |V|>ε @004b0b5c) — `decomp-reference.md:454-489` [T1]. Conditions are ROUTINE operating flags, not alarms; never read a lone SET as a latch [T2, field-verified 2026-08-08].
|
||||
- Audio binding: `Generator.GeneratorState`→stateAlarm registered as a real StateIndicator; "SetLevel fires them on change"; "generator/condenser/reservoir/ammo state sounds fire on transition" — `context/wintesla-port.md:87-99` [T2]. Port-side registration: `game/reconstructed/powersub.cpp:1051` `ATTRIBUTE_ENTRY(Generator, GeneratorState, stateAlarm)` with comment "SetLevel fires audio".
|
||||
- `simulationFlags` bit 0 = engine DelayWatchersFlag — "setting it permanently skips the subsystem's ExecuteWatchers (audio watchers)" — `decomp-reference.md:248-250` [T0/T1]. (A watcher-suppression footgun adjacent to this family.)
|
||||
- Experience gates: "generators take damage/go offline (reroute power)" is VETERAN+; standard mode = "no generator outages", MechTech audio limited to the leg-damage warning — `context/experience-levels.md:76-81` [T1 primary source].
|
||||
|
||||
**What the KB does NOT contain:** any claim about WHICH state edge the "Generator Out" audio is authored on, any decode of the authored AudioStateTrigger state→note records for GeneratorState, and any claim about the recovery threshold's relation to the heat bar. The recovery threshold itself was undocumented in `context/` prose — I read it from the decomp directly (below).
|
||||
|
||||
### 2. Binary + authored-data ground truth (read this session)
|
||||
|
||||
- **`FUN_004b1f7c` (GeneratorSimulation), `reference/decomp/all/part_013.c:9053-9107` [T1]:**
|
||||
- Trip: `if (heatAlarm.level==2 /*FailureHeat*/) SetLevel(stateAlarm, 4)` — runs EVERY tick while at FailureHeat; edge-firing therefore depends entirely on GaugeAlarm54::SetLevel's fire-on-change contract.
|
||||
- Recovery: `case 4:` exits ONLY when `heatAlarm.level == 0` (NormalHeat): generatorOn==0 → SetLevel(1) Idle; else `startTimer=0; SetLevel(0)` Starting → spin-up `startTime` → `SetLevel(2)` Ready + voltage restore. So a tripped generator stays out through the ENTIRE FailureHeat+DegradationHeat band and returns only when T ≤ degradationTemperature.
|
||||
- **Authored thresholds, raw `content/BTL4.RES` dump [T1]:** GeneratorA-D (classID 0xBC1, 0x10C records): startingTemp=77, degradationTemp=1000, failureTemp=2000 (all six mech variants identical). heatAlarm banding has NO hysteresis: >2000→2, >1000→1, else 0 (`game/reconstructed/heat.cpp:851-862`, transcribed from @004ad924).
|
||||
- **Therefore the binary's recovery point is T ≤ 1000 = exactly HALF of the 2000 trip line.** Oracle's era rule ("gens return at HALF-cooled / red bar gone") is not a missing feature — it is the shipped binary's arithmetic, and the port (`powersub.cpp:1296-1372`) transcribes it faithfully. The threshold audit flagged in the handout should come back "already authentic," CONTINGENT on one unverified visual claim: that the cockpit heat bar paints its red band as the ≥degradationTemp region (gauge-coloring not audited this pass) [T3 for the bar mapping].
|
||||
- **Recovery emits TWO SetLevel edges (4→0, then 0→2 after spin-up); trip emits ONE (x→4). RTIS emits the same 0→2 pair at every respawn/mission start (@004b215c, part_013.c:9119-9120).** Any audio cue keyed to entering state 0 or state 2 will sound at recovery AND at every respawn/round start; a cue keyed to entering state 4 sounds at trip. This is the structural fingerprint the wrong-edge bug must sit on [T1 for the edge structure; the authored state→sound mapping itself is UNREAD — that is the code/data lane's job].
|
||||
|
||||
### 3. The 1995 manual (primary source, pages read this session)
|
||||
|
||||
- **p23 (GENERATORS / Generator Info Center), verbatim:** "If a generator overheats, it will shut down. That generator will not come back on-line until its heat gauge has cooled down half way, which can take some time. If a generator is destroyed by a critical hit, an 'X' will appear in that generator's info center." — Oracle's era rule is PRINTED, word for word the half-way rule. Also feeds open era question #133 (the X = destroyed-by-crit, per the manual — not shutdown/overheat).
|
||||
- **p14 (MECHTECH / DAMAGE CONTROL):** the warning is listed among the spoken MechTech warnings: "*Warning, coolant leak … Warning, generator out … Warning, ammo bay fire*". MechTech audio warnings + smart buttons are veteran/expert-only; standard mode gets only the leg-damage message and "you are never required to switch generators".
|
||||
- **p19:** "when a generator overheats, it simply shuts down"; auto gen-mode cascade warning (all-auto + two dead gens can lock out sensors/myomers).
|
||||
- **p22:** "If your 'Mech stops moving, and the heat in your myomers is OK, check the generator; it may be the system that has overheated."
|
||||
- The warning is a VOICE line in 1995. **The port's SF2 banks contain no speech presets at all** (241 presets enumerated from `game/reconstructed/audiopresets.cpp`; the only warning-family presets are the `Warnings01` 8-way klaxon split and `AllWarning`). Whatever the tester hears as "Generator Out" in the port is a klaxon zone, not the spoken phrase — and where the original spoken phrases lived (not in AUDIO1/2.RES) is an unrecorded open question.
|
||||
|
||||
### 4. Field evidence (night15, 2026-08-10 steam logs, build 4.11.883)
|
||||
|
||||
- **The trip/recovery edges are FORENSICALLY INVISIBLE in field logs.** The only generator state receipts (`[gen] TRIPPED/RESTARTING`, powersub.cpp:1308-1352) are `BT_HEAT_LOG`-gated and appear 0 times in all six logs. No audio-warning receipts exist at all. #154's wrong-edge claim currently rests solely on Oracle's testimony + the manual; no log can confirm or refute it.
|
||||
- **Generator Overheating (cond 3) cycling is routine and balanced** — e.g. santo/MS-FIREFLY: ~30 GeneratorX cond-3 SETs with matching CLEAREDs; acaci/ZEUS GeneratorC 5 SET/4 CLEARED (log ends mid-heat). No latch anywhere = corroborates the stuck-forever non-repro.
|
||||
- **Probable real thermal trip captured (rajel/GAMERSLAB):** GeneratorB cond 3 SET :99453 (still set) → :103610-103625 ERSLaser_2 + ERPPC_2 cond 6 BadPower SET → cond 6 CLEARED :104109/:104273 → GeneratorB cond 3 CLEARED :104815 → re-SET :105025. **Note the ordering: the weapons regained voltage ~500-700 lines BEFORE the generator's Overheating flag cleared.** Under the binary model a same-generator recovery is impossible before heatAlarm→0, so this is either (a) manual/auto power rerouting to another generator (manual p14 procedure; the auto-hunt is live per `BT_POWER_DETACH_TEST` bench), or (b) an early-recovery defect. Logs cannot disambiguate (no per-weapon source receipts). Every other cond-6 cluster in the six logs traces to crit damage (gen cond 1+2 SET pairs = structure+coolant-leak flags, e.g. santo GeneratorD :107400/:107412) or to the mission-start spin-up wave (Dave :51669-52166: 11 subsystems BadPower while all gens run their startTime spin-up after load — clears in ~500 lines).
|
||||
- **The respawn reset clears gen conditions wholesale** (santo :89209-89210 GeneratorA cond 1+2 CLEARED inside the `[respawn] Mech::Reset` block) — and per @004b215c each respawn replays the 0→2 stateAlarm pair. If the port's warning cue is keyed to state 0 or 2, testers should ALSO hear it at every respawn/round start — a cheap discriminating question to put to Oracle.
|
||||
- **#135 adjacency receipt:** `[galarm] condition 2 code 0x80/0x87 sub 'GeneratorA/D' -> NO LAMP RESOLVED (generator=1)` — the generator-special lamp resolution path (@004cc27c power-family special) returns nothing for a leaking generator, so a generator coolant leak flashes NO annunciator. That is exactly Oracle's #135 ("coolant leak with NO flashing annunciators") signature on generators, in the current build's logs.
|
||||
|
||||
### 5. Prior-ticket / adjacent-defect map
|
||||
|
||||
- `docs/OPEN_ISSUES_FOR_TESTERS.txt:62-64` — #154 refined by Oracle: stuck-forever did NOT reproduce; era rule = half-cooled; "threshold audit due" (answer per §2: threshold already authentic).
|
||||
- #133 (open era question): the MFD X — manual p23 answers it: X = destroyed by critical hit.
|
||||
- The "missile spam trips FailureHeat permanent brick" (`context/open-questions.md:1086-1102`) is the WEAPON-side roach-motel: weaponAlarm 7 re-SetLevel(7) unconditionally (`decomp-reference.md:240-241`), a genuine latch. The GENERATOR breaker, by contrast, self-recovers by design. Different mechanism; only superficially the same family. If Oracle's once-seen "stuck offline forever" was real, the in-model candidates are: generatorOn==0 (state 4 → Idle requires a manual ToggleGeneratorOnOff; a pilot who punched the gen OFF while it was out stays dark forever — authentic), sustained load never letting T below 1000, or a coolant-leaked loop that can no longer shed heat at all.
|
||||
- #55/RESPAWN_REARM history: `Generator::RTIS` was once transcribed with `outputVoltage = 0` (stale GNRATOR.TCP) vs the binary's `= ratedVoltage` (`docs/RESPAWN_REARM_PLAN.md:116`) — a documented past hard-brick vector, since fixed (powersub.cpp now matches @004b215c).
|
||||
- git log has zero #154 commits — untouched territory.
|
||||
|
||||
### 6. Wrong-edge mechanism candidates (flagged for the code lane, all [T4] from this lane)
|
||||
1. The authored AudioStateTrigger record for `Generator.GeneratorState` keys the warning cue at state 0 (Starting) or 2 (Ready) rather than 4 — or the port misreads the authored state value when binding.
|
||||
2. The port's GaugeAlarm54 watcher passes the OLD level on change (off-by-one): a cue authored for level 4 then fires on the FIRST transition AFTER being at 4 — i.e. exactly at recovery. Symptom-exact.
|
||||
3. A Start/Stop inversion on a looped alarm cue (authored: start-on-enter-4 / stop-on-exit-4).
|
||||
Discriminator available without code: ask Oracle whether the same warning also sounds at respawn/round start (the RTIS 0→2 pair).
|
||||
--- KEY FACTS ---
|
||||
* Manual p23 verbatim: 'If a generator overheats, it will shut down. That generator will not come back on-line until its heat gauge has cooled down half way' — Oracle's era rule is printed 1995 doctrine (reference/manual/Tesla40_BT_manual.pdf p23) [T1 primary source]
|
||||
* Binary recovery threshold: GeneratorSimulation @004b1f7c case 4 exits ONLY at heatAlarm==0 (NormalHeat), i.e. T <= degradationTemp (reference/decomp/all/part_013.c:9087-9097) [T1]
|
||||
* Authored generator thresholds from raw BTL4.RES: startT=77, degradationTemp=1000, failureTemp=2000 for GeneratorA-D on all 6 variants — recovery at 1000 = exactly HALF the 2000 trip line, so the half-cooled rule is already the shipped arithmetic and the port transcribes it (powersub.cpp:1296-1372) [T1]
|
||||
* Trip emits ONE stateAlarm edge (x->4); recovery emits TWO (4->0 Starting, then 0->2 Ready after startTime); Generator::ResetToInitialState @004b215c replays the same 0->2 pair at every respawn/mission start (part_013.c:9119-9120) [T1]
|
||||
* The 'Warning, generator out' line is a spoken MechTech warning in 1995 (manual p14), veteran/expert only; the port's SF2 banks contain ZERO speech presets (241 enumerated, audiopresets.cpp) — whatever the port plays is a Warnings01 klaxon zone, and the original voice samples' home is unrecorded [T1/T2]
|
||||
* Audio path on file: Generator.GeneratorState -> stateAlarm bound as a real StateIndicator, 'SetLevel fires them on change' (context/wintesla-port.md:87-99; powersub.cpp:1051) [T2]; the authored state->note mapping for GeneratorState has never been decoded — the KB is silent on which edge the warning is authored to
|
||||
* Field logs (night15, all 6): zero receipts for generator stateAlarm transitions or audio warnings — [gen] TRIPPED/RESTARTING is BT_HEAT_LOG-gated and absent; #154's wrong-edge claim is currently unfalsifiable from logs [T2]
|
||||
* Probable thermal trip in rajel/GAMERSLAB: GenB cond3 SET :99453 -> ERSLaser_2+ERPPC_2 cond6 BadPower :103610 -> cond6 CLEARED :104109/:104273 BEFORE GenB cond3 CLEARED :104815 — weapons regained power while the gen was still hot; auto/manual reroute vs early-recovery defect not distinguishable from the log [T2]
|
||||
* Generator cond-3 SET/CLEARED counts are balanced in every night15 log (no latch) — corroborates Oracle's stuck-forever non-repro [T2]
|
||||
* Stuck-forever in-model candidate: state 4 with generatorOn==0 drops to Idle(1) and stays until a manual ToggleGeneratorOnOff — a pilot who toggled the gen off while it was out stays dark forever, authentically (part_013.c:9089-9091) [T1]
|
||||
* The missile-spam FailureHeat brick (open-questions.md:1086) is the WEAPON weaponAlarm-7 roach-motel (re-SetLevel(7) unconditionally, decomp-reference.md:240-241) — a real latch, mechanically distinct from the self-recovering generator breaker [T1]
|
||||
* #135 adjacency captured live: [galarm] condition 2 sub 'GeneratorA/D' -> NO LAMP RESOLVED (generator=1) in night15 logs — a generator coolant leak flashes no annunciator [T2]
|
||||
* stateAlarm@0x1FC vs @0x210 in the KB is NOT a contradiction: 0x1FC = GaugeAlarm54 base, 0x210 = its level field (+0x14) [T1]
|
||||
* Manual p23 answers open era question #133: the MFD X = generator destroyed by critical hit (not shutdown/overheat) [T1 primary source]
|
||||
* heatAlarm banding has no hysteresis (>2000->2, >1000->1, else 0; heat.cpp:851-862 from @004ad924), so recovery timing is purely the cooling curve crossing 1000 [T1]
|
||||
--- UNCERTAINTIES ---
|
||||
* Whether the cockpit heat bar's RED band is painted over the >=degradationTemp region (required for 'red gone' == the 1000 recovery line); gauge coloring was not audited this pass [T3]
|
||||
* Which authored state value the 'Generator Out' audio cue is keyed to — the AudioStateTrigger state->note records for GeneratorState are undecoded; all three wrong-edge mechanism candidates (cue keyed to state 0/2, old-level off-by-one in the watcher, Start/Stop inversion of a looped cue) are [T4] pending the code/data lane
|
||||
* Where the 1995 spoken warning phrases physically live (not in AUDIO1/2.RES SF2 banks) and what sample the port actually plays for the tester-heard 'Generator Out' warning — no audio receipt exists in any field log
|
||||
* Whether rajel's :103610 BadPower window cleared via auto-hunt/manual reroute (authentic) or an early same-generator recovery (defect) — indistinguishable without per-weapon voltage-source receipts
|
||||
* Which night15 log user is Oracle (handles: Dave/rajel/acaci/Michael/santo/eleng) — could not map the callsign, so the trip he described may not be in these six logs at all
|
||||
* Whether Oracle also hears the warning at respawn/round start (the RTIS 0->2 SetLevel pair) — a cheap testimony discriminator between the state-0/2-keyed and old-level hypotheses that has not been asked
|
||||
* Whether the binary's GaugeAlarm54 change-detection suppresses the per-tick SetLevel(4) re-fires during sustained FailureHeat exactly as the port's does (KB says fire-on-change [T1/T2]; not re-verified against disasm this pass)
|
||||
|
||||
|
||||
########## LANE 4 ##########
|
||||
## ANNUNCIATOR/AUDIO LANE — how stateAlarm 4 becomes (or fails to become) the audible "Generator Out" warning
|
||||
|
||||
### 1. The mechanism: state audio is EDGE-FIRED from SetLevel, not polled
|
||||
|
||||
Chain (all verified in code + binary):
|
||||
|
||||
1. `Generator::stateAlarm` is a `GaugeAlarm54` (the binary's 0x54 alarm, FUN_0041b9ec) at Generator+0x1FC; it IS a StateIndicator: stateCount@+0x0c, oldState@+0x10, currentState@+0x14, three watcher SChains @+0x18/0x2c/0x40 (C:\git\bt411\game\reconstructed\heat.hpp:93-139) [T1].
|
||||
2. `GaugeAlarm54::SetLevel(n)` (heat.hpp:106): `levelB = level; if (n != level) { level = n; NotifyWatchers(); }` — oldState is updated UNCONDITIONALLY, watchers fire only on a change. Byte-identical to the binary's Set_Alarm_Level @0041bbd8 (`*(p+0x10)=*(p+0x14)` unconditionally; on change, walk the three sockets calling vtbl+0x14 Execute) and to engine `StateIndicator::SetState` (C:\git\bt411\engine\MUNGA\SIMULATE.cpp:156-230) [T1/T0].
|
||||
3. `AudioStateWatcher` (C:\git\bt411\engine\MUNGA\AUDWTHR.cpp:886-919) registers DIRECTLY on the alarm's +0x18 audio socket (`Cast_Object(StateIndicator*,attributePointer)->AddAudioWatcher(this)`), NOT on any Simulation's audioWatcherSocket. So subsystem-state audio is synchronous with the SetLevel edge and never passes through `Simulation::ExecuteWatchers`. The binding resolves via `ATTRIBUTE_ENTRY(Generator, GeneratorState, stateAlarm)` (C:\git\bt411\game\reconstructed\powersub.cpp:1051); field log confirms real per-generator pointers, no pad redirects (`[attrbind] class=28 subsys=[GeneratorA] attr=[GeneratorState] -> ptr=052DC844`, C:\git\bt411\content\solo_20260729.log:39445) [T2].
|
||||
4. On a level change, `Execute()` (AttributeWatcherOf<StateIndicator>, C:\git\bt411\engine\MUNGA\WATCHER.h:265-327) compares its StateIndicator COPY (stateCount/oldState/currentState) to the live alarm, then `AudioStateWatcher::SendNotificationOfChange` calls `StateChanged(GetOldState(), GetState())`.
|
||||
5. `AudioStateTrigger::StateChanged` (AUDWTHR.cpp:1046-1105): fires `ReceiveControl(controlID, controlValue)` when `(!inverseTrigger && new_state==triggerState) || (inverseTrigger && old_state==triggerState)` — i.e. NON-INVERSE = "on ENTERING the state"; INVERSE = "on LEAVING the state". The binary's own StateChanged @0043eb40 (part_005.c:6960-6978, member map from ctor @0043e7d4: trig@+0x80, inv@+0x84, ctlID@+0x88, ctlVal@+0x8c, excl@+0x90/+0x94) is logic-identical, and the manifest attributes audwthr.cpp/.hpp code in the image to the surviving engine source (C:\git\bt411\reference\BT410_SOURCE_MANIFEST.md, "Engine/other TUs ... source survives") [T1].
|
||||
|
||||
### 2. THE CORE FINDING: the wrong-edge behavior is AUTHORED IN THE SHIPPED 1995 CONTENT, not a port defect
|
||||
|
||||
The shipped BTL4.RES streams exactly TWO AudioStateTriggers per generator on `GeneratorState`, decoded byte-exactly from C:\git\bt411\content\BTL4.RES @0x2be9a-0x2bf2a (GeneratorB records; A/C/D identical) and confirmed by the runtime dump (solo_20260729.log:39445-39448 etc.) [T1]:
|
||||
|
||||
- `trigState=1 (GeneratorIdle), inverse=0, control=Start(1)/0` — fires on ENTERING Idle.
|
||||
- `trigState=4 (GeneratorOut), inverse=1, control=Start(1)/0` — fires on LEAVING GeneratorOut.
|
||||
|
||||
There is NO trigger on entering state 4. Both triggers target the SAME non-looped warning sequence (comp=054135C0; `[seqcfg] ... looped=0 div=120 tempo=160 events=15`, notes 29/16/26 = Warnings01 zones z4/z0/z3 per C:\git\bt411\game\reconstructed\audiopresets.cpp:542-549), shared by all four generators. ControlID 1 = StartAudioControlID (C:\git\bt411\engine\MUNGA\AUDIO.h:81-95) [T1].
|
||||
|
||||
Cross-checked against the binary's GeneratorSimulation @004b1f7c (reference/decomp/all/part_013.c:9049-9110, matches C:\git\bt411\game\reconstructed\powersub.cpp:1296-1372 line for line) [T1]:
|
||||
|
||||
- THERMAL TRIP: heatAlarm==FailureHeat → `SetLevel(4)` from Ready(2) → StateChanged(2,4). Matches NO authored trigger → **the trip edge is silent, in the binary and in the port**.
|
||||
- RECOVERY: state 4 + heatAlarm==NormalHeat → `SetLevel(0)` (gen on, restart) → StateChanged(4,0) fires the inverse leave-4 trigger → **the warning sequence STARTS on the recovery edge**. (If the gen was switched off: 4→1 fires BOTH triggers — leave-4 AND enter-1.)
|
||||
- The when-it-DOES-play-at-"trip" cases: generator switched OFF LINE by the pilot (`ToggleGeneratorOnOff` → SetLevel(1), powersub.cpp:1221) and generator subsystem going destroyed/dead (`simulationState==1` → SetLevel(1) each tick, first transition fires) — both are enter-Idle, both play the warning at the moment of loss. Only the THERMAL breaker path (2→4) bypasses Idle and is silent until recovery.
|
||||
|
||||
So the port faithfully reproduces the shipped binary+content. Either the era pods really played the voice at thermal-recovery too (and the tester's memory conflates the destruction/switch-off cases, which DO play at loss), or the arcade floor ran a content revision whose GeneratorState triggers differ from this repo's BTL4.RES [T4 — cannot distinguish from the repo].
|
||||
|
||||
Authoring-intent evidence AGAINST the shipped config being intended: a census of ALL 118+ authored state triggers (solo_20260729.log statecfg dump) shows inverse triggers are otherwise used almost exclusively as `inv=1 ctl=2 (Stop)` loop-terminators (WeaponState 3-, 0-, AmmoState 3-leave → Stop). The generators' `inv=1 ctl=1 (Start)` rows are the ONLY subsystem-fault inverse-STARTs in the game (the sole other inverse-Start is Entity.AnimationState leave-1, a legitimate play-on-exit). A one-bit authoring slip (inverse_trigger=1 on a trigger meant as enter-4) fits the pattern; the era rule would then be restored by flipping that one authored flag in our stream/decode path — but that is a deviation from shipped content, flagged, not proposed [T1 census / T4 intent].
|
||||
|
||||
### 3. DelayWatchersFlag / updateModel — RULED OUT for this bug
|
||||
|
||||
- `AudioStateTrigger` never registers with `Simulation::AddAudioWatcher`; only `AudioTriggerOf`/`AudioMatchOf`/`AudioDeltaOf`/`AudioScaleOf` do (AUDWTHR.h:293, 770, 925, 535). `AreWatchersDelayed()` gates ONLY `Simulation::PerformAndWatch → ExecuteWatchers` (SIMULATE.cpp:459-479, mech4.cpp:8377) — the polled watchers. It CANNOT suppress one SetLevel edge and pass another; both trip and recovery notifications ride the same synchronous socket walk. [T0]
|
||||
- What a stuck DelayWatchersFlag on the Generator sim WOULD kill: the two `GeneratorOn` AudioLogicalTriggers (match 1/match 0 → Start ctl1/0.5 — the polled on/off cue) and the ReportLeak matchers, silently and permanently (the projweap/mislanch `simulationFlags|=1` trap, C:\git\bt411\game\reconstructed\projweap.cpp:461,1049). Nothing in the generator path sets it — the known setters are the weapon-family bugs already fixed, plus the btplayer.cpp:622-642 diagnostic. `updateModel|=1` (ForceUpdate) is the replication dirty mark only; no bearing on watchers. [T1]
|
||||
|
||||
### 4. The heat bar / "red portion gone" — the recovery gate ALREADY equals red-gone
|
||||
|
||||
- The generator panel's temperature bar is a `VertTwoPartBar` bound (value=CurrentTemperature, low=DegradationTemperature, high=FailureTemperature) — GeneratorCluster ctor @004c746c, C:\git\bt411\game\reconstructed\btl4gau2.cpp:980-998; config `GeneratorCluster(... sgentemp.pcc, 9, 11 ...)` at C:\git\bt411\content\GAUGE\L4GAUGE.CFG:5046 [T1].
|
||||
- `VertTwoPartBar::Execute` (@004c48fc, C:\git\bt411\game\reconstructed\btl4gaug.cpp:1318-1360): bar spans 0..FailureTemperature; tile bitmap below the warn line (DegradationTemperature), the FILL COLOR segment is drawn ONLY for [warnPix, valPix) — i.e. the colored ("red") portion exists exactly while CurrentTemperature > DegradationTemperature. "Red portion gone" ⇔ T ≤ DegradationTemperature [T1].
|
||||
- The recovery gate is `heatAlarm.GetLevel()==NormalHeat` (powersub.cpp:1342), and heatAlarm is a pure no-hysteresis threshold ladder recomputed every HeatSinkSimulation tick: T>failure→2, T>degradation→1, else 0 (C:\git\bt411\game\reconstructed\heat.cpp:851-862, @004ad924) [T1].
|
||||
- Therefore the generator leaves GeneratorOut on the EXACT tick the red portion of its bar disappears — the tester's era rule is already the implemented (and binary-authentic) threshold. The visible lag: leaving 4 enters Starting(0), voltage stays 0 for the authored StartTime spin-up before Ready(2) — so power (voltage bar/weapons) returns startTime seconds AFTER red-gone; that delay is authentic. Whether DegradationTemperature is numerically "half" of FailureTemperature is per-mech authored data in the streamed subsystem resources (not stored as text; not extracted) [T3].
|
||||
- No-hysteresis corollary: a generator that keeps making heat can duty-cycle across the degradation threshold (trip→cool→restart→re-trip), playing the warning once per RECOVERY — a repeating warning that never coincides with a trip, which plausibly amplified the tester's wrong-edge perception. The "stuck offline forever" non-repro is consistent: exit requires only T ≤ degradationTemperature with the sim ticking; a true brick needs dissipation to be structurally gone (the open-questions "missile-spam FailureHeat brick" entry is about heat delivery, not this audio path).
|
||||
|
||||
### 5. Cross-check: alarms that DO play on their trip edge, and what differs
|
||||
|
||||
Nothing differs in code — one shared `AudioStateTrigger::StateChanged` serves all; the difference is purely the authored trigger rows:
|
||||
- Condenser: `trigState=1 inv=0` AND `trigState=2 inv=0` → Start (ctl 1/4) — fires on ENTERING each fault state (trip edge). [T1, statecfg census]
|
||||
- AmmoBin: enter-3 Start + leave-3 Stop (loop pair, cook-off countdown), enter-4 Start, enter-5 Start — trip-edge Starts, inverse used only as Stop. [T1]
|
||||
- Reservoir: enter-1 Start. Weapons: enter-N Starts, inverse-Stop loop terminators. [T1]
|
||||
- Generator alone has its fault state (4) authored ONLY with an inverse (leaving) Start, and its enter-Start sits on Idle(1) instead of Out(4). [T1]
|
||||
--- KEY FACTS ---
|
||||
* Subsystem state audio is edge-fired synchronously from GaugeAlarm54::SetLevel/StateIndicator::SetState walking the alarm's own +0x18 audio socket (heat.hpp:106, heat.cpp:96-111, SIMULATE.cpp:156-230, binary @0041bbd8) — it never passes through Simulation::ExecuteWatchers [T1]
|
||||
* AudioStateTrigger semantics: non-inverse fires on ENTERING triggerState (new_state==trig); inverse fires on LEAVING it (old_state==trig) — engine source AUDWTHR.cpp:1079-1104 [T0], byte-confirmed in the binary @0043eb40 with member map from ctor @0043e7d4 [T1]
|
||||
* Shipped BTL4.RES authors exactly two GeneratorState triggers per generator: (state 1 Idle, inv=0, Start) and (state 4 GeneratorOut, inv=1, Start) — decoded from BTL4.RES bytes @0x2be9a-0x2bf2a and matching the runtime [statecfg] dump (content/solo_20260729.log:39445-39448); there is NO trigger on entering state 4 [T1]
|
||||
* Thermal trip (heatAlarm==FailureHeat -> SetLevel(4), Ready 2->4, @004b1f7c == powersub.cpp:1304-1312) matches no authored trigger -> trip edge is SILENT in both the port and the 1995 binary; recovery (4->0 or 4->1) fires the inverse leave-4 Start -> the reported wrong-edge behavior is the shipped content+engine behavior, not a port defect [T1]
|
||||
* The warning plays at the moment of LOSS only for the enter-Idle paths: pilot switch-off (SetLevel(1), powersub.cpp:1221) and subsystem-dead simulationState==1 (powersub.cpp:1302) [T1]
|
||||
* Both generator triggers on all four generators target one shared non-looped sequence (notes 29/16/26 = Warnings01 zones z4/z0/z3, audiopresets.cpp:542-549); controlID 1 = StartAudioControlID (AUDIO.h:83) [T1]
|
||||
* DelayWatchersFlag (simulationFlags bit 0) gates only polled watchers (ExecuteWatchers, SIMULATE.cpp:472-474); it cannot suppress one SetLevel edge and pass another; on a generator it would instead silently kill the polled GeneratorOn on/off matchers [T0/T1]
|
||||
* Authoring census: inverse triggers elsewhere are almost exclusively inv=1+Stop loop terminators; the generators' inv=1+Start on a fault state is unique among subsystem alarms — consistent with a one-flag authoring slip in the 1995 content [T1 census, T4 intent]
|
||||
* Generator heat bar (VertTwoPartBar @004c48fc, btl4gau2.cpp:980-998): colored fill exists only while CurrentTemperature > DegradationTemperature, so 'red portion gone' == T <= DegradationTemperature == exactly the existing recovery gate (heatAlarm NormalHeat, heat.cpp:851-862, no hysteresis) — the tester's era rule for the return threshold is already implemented; only the authored StartTime spin-up delays visible power return [T1]
|
||||
* heatAlarm has no hysteresis, so a heat-producing generator can duty-cycle trip/restart, replaying the warning once per RECOVERY [T1]
|
||||
--- UNCERTAINTIES ---
|
||||
* Whether the arcade-floor pods ran a content revision whose GeneratorState audio triggers differ from this repo's BTL4.RES — the era-rule claim (voice at thermal trip) contradicts the shipped authoring we have; cannot be settled from the repo [T4]
|
||||
* Sample identity of Warnings01 zones z4/z0/z3 (notes 29/16/26) — assumed to be the audible 'Generator Out' voice/klaxon material; not verified by listening [T3]
|
||||
* Authored DegradationTemperature/FailureTemperature values per mech (whether 'red gone' is numerically HALF-cooled) — baked into streamed subsystem resources, not extracted [T3]
|
||||
* Replicant-side behavior (whether a passenger/other pod hears the warning on the same edges) — out of this lane, untraced
|
||||
* No field log with BT_HEAT_LOG active captures an actual trip/recovery temperature pair, so the duty-cycle amplification of the wrong-edge perception is mechanism-derived, not field-observed [T3]
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — adversarial verification (two skeptic lanes)
|
||||
|
||||
#### VERIFIER 0 ####
|
||||
ADVERSARIAL VERIFICATION OF #154 "two triggers, no enter-4" — ATTACK FAILED ON THE DECODE; PARTIAL HIT ON THE BEHAVIORAL CLAIM.
|
||||
|
||||
== 1. Read order: source vs binary vs port — NO DIVERGENCE [T1] ==
|
||||
T0 source (engine/MUNGA/AUDWTHR.cpp:974-979): AudioStateTrigger ctor reads triggerState, inverseTrigger, controlID, controlValue, excludeTransition, excludeState, after the base chain reads subsystem CString, attribute CString, sendNotificationOnChange (WATCHER.cpp:60,91,145), dump_value (WATCHER.h:212), audio_component ObjectID (AUDWTHR.h:151). All ints/enums are 4 bytes (STYLE.H:132,137; AUDIO.h:98 AudioControlValue=Scalar float); CString = len(4)+bytes+NUL (CSTR.cpp:353-356).
|
||||
Binary: ctor @0043e7d4 reads six consecutive 4-byte fields into +0x80..+0x94 after base @0043e738. Field MEANINGS (not just positions) are pinned by StateChanged @0043eb40: +0x80 is the state comparand, +0x84 the inverse boolean (selects old-vs-new comparison), +0x88/+0x8c go to ReceiveControl, +0x90/+0x94 the exclude pair, with the exclude-clause structure exactly mirroring the source. Writer @0043e8a4 emits name-list keys in the same order (trigger_state, inverse_trigger, control_ID, control_value, exclude_state).
|
||||
The one attack surface the prior analysis never checked — the CALLER's argument order — also holds: SendNotificationOfChange @0043e79c passes (watcher+0x30, watcher+0x34); the snapshot filler @0041bb88 copies indicator +0x0c/+0x10/+0x14 = stateCount/oldState/currentState, so arg1=oldState, arg2=newState, matching the source. Had the caller passed (new,old), the inverse trigger WOULD have fired on enter-4 (audible trip) — this was the only single-fault reinterpretation that could rescue the testers' expectation, and it is excluded by the binary's own code.
|
||||
|
||||
== 2. Independent byte re-decode (my parser, from the exe-verified layout, not the port's) ==
|
||||
Record A @0x02be8f: classID=0x1c, objID=0x033b @0x2be93, "GeneratorB" (len@0x2be97), "GeneratorState" (len@0x2bea6), sendNotif=1 @0x2beb9, dump=0 @0x2bebd, comp objID=0x032f @0x2bec1; trigger fields @0x2bec5: trigState=1 @0x2bec5, inverse=0 @0x2bec9, controlID=1(Start) @0x2becd, controlValue=0.0 @0x2bed1, exclTrans=0 @0x2bed5, exclState=0 @0x2bed9.
|
||||
Record B @0x02bedd: objID=0x033c, same strings, comp=0x032f; fields @0x2bf13: trigState=4 @0x2bf13, inverse=1 @0x2bf17, controlID=1(Start) @0x2bf1b, controlValue=0.0 @0x2bf1f, exclTrans=0 @0x2bf23, exclState=0 @0x2bf27 (record ends 0x2bf2b — the prior's cited window 0x2be9a-0x2bf2a is these two records).
|
||||
Structural proof of the layout: my strict parser recovered 2306 valid class-0x1c records file-wide with contiguous record chaining, sane enums, and printable strings — impossible under a shifted/swapped layout. RES-wide census: ALL 144 GeneratorState triggers (4 gens x 36 vehicle pages) are exactly the two shapes (1,inv0,Start) and (4,inv1,Start). No enter-4 anywhere, no Stop, no exclude. The prior analysis's decode is CORRECT.
|
||||
|
||||
== 3. State numbering — 0-based, verified against the binary sim and LIVE receipts ==
|
||||
The binary's generator sim FUN_004b1f7c writes raw levels through SetState @0041bbd8 with no translation: heatAlarm==2 -> SetLevel(4) (thermal breaker); switch cases 0/1/3/4; recovery from 4 -> SetLevel(0) (gen on) or SetLevel(1) (gen off). Authored trigState compares verbatim against these [T1, binary-internal, port-independent].
|
||||
Calibration: AmmoState authored set {enter-0 Start (feed whir), leave-0 Stop, enter-1 Start, enter-2 Start, enter-3 Start + leave-3 Stop, enter-4 Start/1, enter-5 Start/3} maps 1:1 onto ammobin.hpp levels 0=Feeding..5=Dumped; LIVE receipts in content/genedge.log: edge 0->3 fired enter-3 Start, edge 3->2 fired leave-3 Stop AND enter-2 Start — inverse semantics and 0-based numbering confirmed live. Condenser: authored enter-1/enter-2 Start/4 matches sim writes 1(degraded)/2(severe)/0(normal) (heatfamily_reslice.cpp:1443-1445).
|
||||
CORRECTION to the prior analysis: its "AmmoBin cook-off enter-3/leave-3" label is WRONG — AmmoState 3 is EJECTING (eject-hold countdown loop); the cook-off warning is gated on FireCountdownStarted match watchers (ammobin.hpp:104-134). The calibration still works; the label must be fixed.
|
||||
If authored trigState were 1-based: (1,inv0)->enter-Starting(0), (4,inv1)->leave-Shorted(3) — STILL no enter-4. Swapped trig/inverse fields on record B (payload 4,1) -> fire-on-leaving-state-1 — still no enter-4, and it would garble hundreds of other records (AnimationState trig=23 would become inverse=23). The ONLY reinterpretation putting a Start on ENTER GeneratorOut(4) is flipping the inverse semantics (inv=1 = fire on new==trig), excluded at T1 by @0043eb40 + @0043e79c/@0041bb88 and by the live AmmoState receipts (flipped semantics would start the eject loop on RELEASE).
|
||||
|
||||
== 4. VERDICT: the decode is SOLID [T1]; the behavioral conclusion is UNDER-EVIDENCED ==
|
||||
The two-trigger/no-enter-4 authoring is now confirmed by three mutually independent routes (T0 source, binary pseudocode semantics, my own byte decode) plus a live semantics calibration. It is not a parser echo chamber anymore.
|
||||
HOWEVER, two things keep the prior CONCLUSION short of certain, and give the testers room to be right:
|
||||
(a) The live trip observation NEVER HAPPENED: content/genedge.log (the #154 bench, run 2026-08-11 06:30) contains ZERO [gen] TRIPPED/RESTARTING lines and zero GeneratorState 2->4 or 4->x [audioedge] edges — the BT_GEN_HEAT injection did not take effect (bench's own verdict: INCONCLUSIVE). "Warning plays on recovery" in the port is still a static prediction. The only generator edges captured live are 0->2 power-ups, which correctly fire nothing.
|
||||
(b) "Silent by authoring" does not mean "testers hear a consistent nothing at trip": the recovery paths are asymmetric (4->0 fires ONE Start; 4->1 fires TWO Starts — leave-4 AND enter-1 — on the SAME component 0x32f), the GeneratorOn logical match watchers fire Start/0.5 on BOTH edges ([matchcfg] receipts), the 38-watcher ReportLeak warning family is known-dead (AUDIO_FIDELITY.md F6), and genedge.log shows a real delivery quirk: old=3 new=3 same-state deliveries fired BOTH the Start and the Stop of one loop (root: SetState/SetLevel writes oldState unconditionally even on no-change — binary @0041bbd8:5520 does the same, and the polling compare @0041bba8 includes oldState — so it may be authentic, but the port's polling cadence vs the binary's is unverified). Any of these is a credible source of "wrong AND inconsistent" without the RES decode being wrong.
|
||||
To call it certain: re-run scratchpad/night15/gen_edge_bench.sh with the heat injection actually reaching the sim (verify the BT_GEN_HEAT gate: generatorNumber==1 && viewpoint entity; confirm the EGG's vehicle carries GeneratorA) and capture trip-edge no-fire + recovery-edge FIRED; identify what sample/sequence audio component 0x32f actually plays (warning klaxon vs generator spin-up whine — decides whether the authored intent was ever a "warning" at all); ideally, one original-cab/emulator recording of a thermal trip as behavioral ground truth.
|
||||
KEY FACTS:
|
||||
* Read order identical across T0 source (AUDWTHR.cpp:974-979), binary ctor @0043e7d4 (+0x80..+0x94), and the port; field meanings pinned semantically by @0043eb40 (StateChanged) and the writer @0043e8a4
|
||||
* Caller argument order verified: @0043e79c passes (oldState,newState) — snapshot filled by @0041bb88 from indicator +0x10/+0x14; a swapped order was the only decode fault that could make the trip audible, and it is excluded
|
||||
* Independent re-decode of BTL4.RES @0x2be8f/@0x2bedd: (trigState=1,inv=0,Start) and (trigState=4,inv=1,Start) on GeneratorB.GeneratorState, comp 0x32f — matches the prior analysis exactly; all field offsets stated (fields @0x2bec5 and @0x2bf13)
|
||||
* RES-wide census (my parser, 2306 class-0x1c records): all 144 GeneratorState triggers are the same two shapes; no enter-4 trigger exists anywhere in the file
|
||||
* State numbering is 0-based and raw: binary sim FUN_004b1f7c writes SetLevel 0/1/2/3/4 with 4=thermal breaker; recovery 4->0 (gen on) or 4->1 (gen off); no translation layer anywhere
|
||||
* Live calibration receipts (content/genedge.log): AmmoState 0->3 fired enter-3 Start, 3->2 fired leave-3 Stop + enter-2 Start — inverse semantics and numbering confirmed at runtime
|
||||
* PRIOR-ANALYSIS ERROR FOUND: AmmoState level 3 is Ejecting (eject-hold countdown), NOT cook-off; the cook-off klaxon is gated on FireCountdownStarted match watchers (ammobin.hpp:104-134)
|
||||
* The #154 live bench never observed a trip: genedge.log (2026-08-11) has 0 TRIPPED/RESTARTING lines and no 2->4/4->x edges — BT_GEN_HEAT injection did not take effect; 'recovery fires the warning' is still static-only for the port
|
||||
* Candidate explanations for 'inconsistent' tester reports that do NOT require a decode error: asymmetric recovery (4->1 double-Start vs 4->0 single), GeneratorOn match watchers firing Start on both edges, dead ReportLeak warning family (AUDIO_FIDELITY.md F6), and live same-state deliveries (old=3 new=3) firing Start+Stop pairs due to the unconditional oldState write in SetState (@0041bbd8, mirrored by port GaugeAlarm54::SetLevel)
|
||||
UNCERTAINTIES:
|
||||
* Whether the port's recovery edge actually fires the sequence live (T2 missing): the gen_edge_bench must be re-run with BT_GEN_HEAT actually reaching GeneratorSimulation (the 2026-08-11 run shows zero [gen] lines — injection gate or vehicle/generator naming issue)
|
||||
* What audio component 0x32f (runtime 054135C0) actually plays — a warning sequence or the generator spin-up/idle sound; this decides whether 'warning plays on recovery' is even the right characterization of the authored intent
|
||||
* Whether the same-state (old==new) Start+Stop double-delivery seen live is authentic 1995 behavior at the same frequency — the binary has the identical unconditional oldState write and snapshot compare, but the port's polling cadence vs the binary's notify path is unverified
|
||||
* Whether the 1995 cab sounded anything else at thermal trip via non-AudioStateTrigger paths (heat-alarm audio, gauge alarm streams, cockpit MFD alerts) — outside this decode's scope but inside the testers' perception
|
||||
* GeneratorState level-name labels (0=Starting,1=Idle) are best-effort [T3] per powersub.hpp — the numbering is certain, the English names are not
|
||||
|
||||
#### VERIFIER 1 ####
|
||||
VERDICT: The prior analysis's AUTHORING DECODE SURVIVES verification, but its CONCLUSION ("thermal trip silent, warning on recovery, therefore behavior is consistent-by-authoring") is REFUTED on three grounds: (1) it was never runtime-verified — the #154 bench (scratchpad/night15/gen_edge_bench.sh -> content/genedge.log) contains ZERO "[gen] TRIPPED"/"RESTARTING" receipts, so no trip or recovery edge was ever observed live; the recovery-edge claim is code-derived [T1], not [T2]; (2) the enter-Idle trigger makes the SAME 3-tone sequence fire on at least five different game events with opposite meanings, four of them not mentioned by the prior analysis; (3) three field-measured port mechanisms make even correctly-fired warnings intermittent.
|
||||
|
||||
== 1. THE COMPLETE EVENT->SOUND TABLE (generator warning sequence = AudioControlSequence, 3 notes 29/16/26 = Warnings01_z4(loop)/z0/z3, ~3.25 s at div=120 tempo=160; ONE sequence component SHARED by all four generators A-D — solo_20260729.log:39452-39476, all four statecfg rows target comp 054135C0) ==
|
||||
Authored triggers per generator (verified in both logs, excl=0/0 so NO exclude-transition clause): T-A trigState=1 inv=0 ctl=Start/0 (fires on ANY entry to state 1) and T-B trigState=4 inv=1 ctl=Start/0 (fires on ANY exit from state 4). GeneratorState enum [T1, powersub.hpp:377-388]: 0=Starting 1=Idle 2=Ready 3=Shorted 4=Out.
|
||||
- Mission start / ResetToInitialState (SetLevel(0);SetLevel(2), powersub.cpp:1269-1270): edges (x,0),(0,2) -> SILENT (bench-confirmed: 4x "old=0 new=2 ... no-fire" in genedge.log).
|
||||
- THERMAL TRIP 2->4 (powersub.cpp:1341, per-frame while heatAlarm==FailureHeat): SILENT. Prior analysis correct on this edge. But outputVoltage collapses -> !HasVoltage status bit (stateAlarm==4 read, powersub.cpp:1836) -> masterAlarm/MechTech + gotoEngineering gauge invite CAN sound at trip [T3 — masterAlarm edges are live in the bench log but the specific generator condition->voice line was not traced]. Testers hearing a VOICE at trip and the TONE 10-60 s later at recovery = "timing feels wrong".
|
||||
- THERMAL RECOVERY 4->0 (generatorOn==1, powersub.cpp:1384): T-B FIRES — warning tone announces power RETURNING. One tone per generator recovery; four generators trip/recover STAGGERED, and each Start on the shared sequence STOPS and RESTARTS it from t=0 (AUDSEQ.cpp:409-412 StartSequence stops-if-running; StopSequence chases pending Stops, cutting the current note) -> stuttered/truncated warnings whose count varies with heat spread. A player riding the heat edge gets a periodic tone on EVERY trip/recover duty cycle — always at the recovery, never at the loss.
|
||||
- RECOVERY WHILE SWITCHED OFF 4->1 (powersub.cpp:1376): BOTH T-A and T-B fire — double Start same frame (second restarts the sequence).
|
||||
- PILOT TOGGLES GENERATOR OFF (ToggleGeneratorOnOff, powersub.cpp:1221, byte-matched to @004b1ed0): x->1, T-A FIRES — the "generator out" warning plays for a deliberate heat-management action (authentic, but a tester counts it as spurious). Toggle ON: SetLevel(0) only if heat healthy — silent; if heat-degraded, NO state write at all (silent, state stays at fault).
|
||||
- GENERATOR CRIT-DEATH: no direct SetLevel on the crit path (BTGeneratorMarkOut has ZERO callers — the powersub.cpp:483 method is documentation-only; hpp:471 says the ForceCriticalFailure bridge was removed), BUT GeneratorSimulation's pin `if (simulationState==1) SetLevel(GeneratorIdle)` (powersub.cpp:1330-1332, authentic @004b1f7c) fires T-A on the next tick: 2->1 (or 4->1 = DOUBLE fire if it died tripped). So a generator LOSS by crit DOES play the warning — one frame late — directly contradicting "loss is silent by authoring".
|
||||
- SHORT 2->3 and short-recovery 3->2 (powersub.cpp:1422,1365): SILENT both ways.
|
||||
- RESPAWN WITH A TRIPPED GENERATOR: ResetToInitialState SetLevel(0) delivers (4,0) -> T-B FIRES the warning right at respawn; dying un-tripped respawns silent. Inconsistent respawn audio depending on how you died. [T1 code-read, unbenched]
|
||||
- GeneratorOn POLLED MATCHERS (AudioLogicalTrigger, solo log: match=1 -> ctl1(Start)/0.5 and match=0 -> ctl1(Start)/0.5, per generator): generatorOn is init 1 and written ONLY by the toggle (powersub.cpp:1214,1222) and reset (=1). Thermal trip does NOT touch it. So: bind-time prime fires the match=1 Start (spawn hum — every AudioMatchOf subclass ctor calls PrimeWatcher, and AudioMatchOf::SendNotificationOfChange has no edge memory: it fires whenever notified AND equal, AUDWTHR.h:818-848); manual OFF fires the match=0 Start (a second sound stacked on the T-A warning). Target components unidentified (matchcfg does not print comp) — [T4 what they sound like].
|
||||
|
||||
== 2. INTERMITTENCY MECHANISMS (the "inconsistent" the testers report) ==
|
||||
A. VOICE STEAL / TRUE DROP AT COMBAT PEAK [T2, field-measured]: L4AUDRND.cpp:1287-1409 — on channel exhaustion the incoming Start steals only from LOWER-weight running sources (transient victims are STOPPED mid-play, sustained SUSPENDED); if nothing outranks, the Start is genuinely dropped (gBTAudioTrueDrops). The 2026-08-10 field logs show up to 932 steals and 22-30 acquire-failure prints per session (scratchpad/night15/steam_20260810_f_eleng_ALIA.log). Heat trips correlate exactly with peak firefight + heat klaxon + MechTech voices, so the 3-note warning's Starts land in the most contended window: any of its three notes can be stolen mid-sequence or dropped. AUDIO_FIDELITY.md F3 adds that steal/cull/ducking are DISTANCE-BLIND in the port (AL linear model), further randomizing which source loses.
|
||||
B. TRANSIENT-START VOLUME CULL: AUDREND.cpp:236-251 silently discards any Start on a transient source whose volume scale < LowAudioVolumeThreshold (0.3), and AUDREND.cpp:175-193 discards Starts on clipped transient sources entirely. Whether the cockpit warning target (seq tgt comp) is transient was not determined [T4]; if it is, this is a second silent-drop gate.
|
||||
C. BIND-TIME PRIME FIRES TRIGGERS [T2, bench-observed]: AudioStateTrigger ctor calls PrimeWatcher() (AUDWTHR.cpp:989) -> GrabCurrentValue -> StateChanged(alarm.oldState, alarm.currentState) at bind. Combined with the GaugeAlarm54::SetLevel oldState-clobber (heat.hpp:106 — `levelB = level` runs even when the level does NOT change, so any state that is re-set per frame reads old==new), a trigger bound while its alarm sits AT trigState fires BOTH polarities immediately: genedge.log lines 878/881 show "[audioedge] old=3 new=3 trig=3 inv=0 ... FIRED" AND "inv=1 ... FIRED" during a mid-run mech build. For generators this is benign at normal spawn (state 2) but fires the warning on any bind/rebuild that catches state 1 or 4.
|
||||
D. THE T3 BRING-UP GUARD CAN SILENTLY UNPLUG A GENERATOR'S STATE AUDIO: AUDWTHR.cpp:893-917 skips AddAudioWatcher when the bound alarm's +0x18 socket reads 0/0xCDCDCDCD — permanently silent for that subsystem, logged only under BT_AUDIO_LOG. GeneratorState binds with vtbl=00000000 (raw GaugeAlarm54 header) in every log; registration currently succeeds (edges were delivered in the bench), but the guard makes generator audio construction-order-dependent — a latent per-mech/per-build inconsistency [T3].
|
||||
E. DelayWatchersFlag: gates ONLY the polled watchers (Simulation::Simulate, SIMULATE.cpp:472; mech4.cpp:8377) — i.e. the GeneratorOn/ReportLeak matchers — NOT the state triggers (those are called directly from the alarm's NotifyWatchers, heat.cpp:96-111). RequestEncore sets it (SIMULATE.cpp:727) and only the encore path clears it (UPDATE.cpp:213-215). The historical bug (death path setting bit 0, permanently muting a player's polled audio) is fixed and canary-guarded (btplayer.cpp:635-643). No current generator-warning exposure, but a regression would kill the GeneratorOn sounds while leaving the state warning alive — a plausible past contributor to "inconsistent" reports depending on build vintage.
|
||||
|
||||
== 3. CROSS-POD / REPLICANT ==
|
||||
Replicant mechs DO build audio objects (interest teardown destroys them — context/multiplayer.md interest-teardown crash section [T2]), and subsystems DO tick on replicants when executable (#148 instance branch, mech4.cpp:8077-8104). Whether a replicant's Generator ever reaches state 1/4 locally depends on whether heat state feeds its sim — per-subsystem heat is not replicated (damage is master-side only per the #45 note), so remote generator warnings should not track the remote pilot's actual thermal state [T3]. Remote sources are also subject to the clip/volume gates, so at typical ranges they are inaudible. An observer/passenger riding a REPLICANT viewpoint would NOT hear that mech's authentic generator warnings (its master simulates elsewhere) [T3].
|
||||
|
||||
== 4. WHAT SURVIVES OF THE PRIOR ANALYSIS ==
|
||||
The RES decode itself is confirmed at runtime: exactly two AudioStateTriggers per generator, trigState=1/inv=0/Start and trigState=4/inv=1/Start, excl=0/0, both targeting the shared 3-note sequence (solo_20260729.log:39445+ and genedge.log both match). "The trip edge 2->4 fires nothing" is true [T1]. Everything downstream of that — "therefore silent-by-authoring at loss, warning on recovery, consistent" — is refuted: crit-loss sounds (via the sim-state pin), manual OFF sounds, respawn-after-tripped sounds, recoveries stutter-restart a shared sequence, and the delivery/rendering pipeline drops or truncates the sound under exactly the conditions in which trips happen. RECOMMENDED NEXT STEP: re-run gen_edge_bench (it never tripped — zero [gen] receipts; verify the BT_GEN_HEAT injection actually reaches the viewpoint Generator A and the build contains the injection block), and add a crit-death + toggle + respawn leg to capture the T-A firings live.
|
||||
KEY FACTS:
|
||||
* The #154 bench log content/genedge.log contains ZERO '[gen] TRIPPED' or 'RESTARTING' receipts — the thermal trip/recovery edge mapping was NEVER observed live; the prior conclusion is code-derived [T1], not runtime-verified
|
||||
* All four generators (A-D) share ONE warning sequence component (statecfg comp=054135C0 in solo_20260729.log:39452-39476); AudioControlSequence::StartSequence stops-and-restarts if already running (AUDSEQ.cpp:409-412), so staggered per-generator recoveries stutter/truncate the warning
|
||||
* GeneratorState enum: 0=Starting 1=Idle 2=Ready 3=Shorted 4=Out (powersub.hpp:377-388); authored triggers: enter-1 Start and leave-4 Start, no exclude clauses (excl=0/0)
|
||||
* The enter-Idle trigger fires the SAME warning on: generator crit-death (simulationState==1 pin, powersub.cpp:1330-1332), pilot toggling a generator OFF (powersub.cpp:1221), and recovery-while-off 4->1 (double-fire with the inverse trigger)
|
||||
* ResetToInitialState does SetLevel(0);SetLevel(2) (powersub.cpp:1269-1270): a mech that died with a tripped generator (state 4) fires the warning AT RESPAWN via the 4->0 edge; dying un-tripped respawns silent
|
||||
* GaugeAlarm54::SetLevel (heat.hpp:106) clobbers oldState unconditionally (levelB=level even when unchanged) and notifies only on change; per-frame same-level SetLevel therefore makes old==new at the alarm
|
||||
* Every concrete audio trigger ctor calls PrimeWatcher() at bind, which delivers StateChanged(old,cur) unconditionally; bench lines show old==new deliveries that FIRED both trigger polarities (genedge.log '[audioedge] old=3 new=3 trig=3 ... FIRED' for inv=0 AND inv=1) — a trigger bound while its alarm sits at trigState fires spuriously
|
||||
* Voice contention is live in current builds: 2026-08-10 field logs show up to 932 voice steals and 22-30 acquire failures per session (scratchpad/night15/steam_20260810_f_eleng_ALIA.log); the steal loop stops transient victims mid-play and true-drops unrankable Starts (L4AUDRND.cpp:1287-1409)
|
||||
* Transient Starts are silently discarded when clipped or below LowAudioVolumeThreshold=0.3 (AUDREND.cpp:175-251), and the cull is distance-blind (AUDIO_FIDELITY.md F3)
|
||||
* The AudioStateWatcher bring-up guard (AUDWTHR.cpp:893-917) silently skips watcher registration on an unconstructed alarm socket — construction-order-dependent permanent silence for that subsystem's state audio [T3 latent]
|
||||
* DelayWatchersFlag gates only POLLED watchers (GeneratorOn/ReportLeak matchers), never the alarm-socket state triggers; the historical death-path bit-0 bug is fixed and canary-guarded (btplayer.cpp:635-643)
|
||||
* GeneratorOn is written ONLY by the pilot toggle and reset — a thermal trip does NOT clear it; its two matchers both issue Start (ctl1/0.5): match=1 at bind/toggle-ON, match=0 at toggle-OFF
|
||||
* BTGeneratorMarkOut has zero call sites — generator crit-death does NOT SetLevel(4); the warning at crit-death comes from the simulationState==1 Idle pin one tick later
|
||||
* Thermal trip zeroes outputVoltage -> !HasVoltage status bit (stateAlarm==4, powersub.cpp:1836) -> masterAlarm/MechTech + gotoEngineering invite can sound AT TRIP while the tone sequence sounds at RECOVERY — a perception split for testers
|
||||
UNCERTAINTIES:
|
||||
* Why the bench never tripped: BT_GEN_HEAT=50000 for 25 s should exceed FailureTemperature — either the injection block wasn't in the running build, the viewpoint-entity/generatorNumber gate didn't match, or the run window missed; needs a re-run before ANY edge claim is called verified
|
||||
* Whether the warning sequence's target source (tgt comp, e.g. 0540FA18) is TransientAudioRenderType — determines exposure to the volume-cull and clip drop gates (AUDREND.cpp:175-251)
|
||||
* The exact source of the REPEATED old==new deliveries to the same comps in genedge.log (lines 814/978/1030): interpreted as bind-time primes of successive identical weapons sharing sequence comps during a mid-run mech build; not traced to completion — an unidentified re-prime/re-execute path would widen mechanism C to mid-life spurious warnings (including old=4,new=4 firing the warning DURING a trip)
|
||||
* What the GeneratorOn matchers' Starts (ctl1/0.5) actually sound like — [matchcfg] does not print the target component; needs a matchfire capture or RES decode of those pages
|
||||
* Whether a MechTech VOICE line specifically fires on the generator !HasVoltage condition at trip time in the port (masterAlarm is live, but the condition->voice mapping for generator-out was not traced) [T3]
|
||||
* Whether replicant Generator subsystems ever reach state 1/4 in MP (per-subsystem heat is believed master-side only) and whether their warning sequences route to spatialized sources or a center-channel path — the passenger/observer-seat audio experience is unaudited
|
||||
* Whether destroyed generators keep ticking GeneratorSimulation long enough for the simulationState==1 pin to deliver its edge in every case (DoNothingOnce/NeverExecute latching order at subsystem death)
|
||||
* Whether any encore/re-stream path rebinds audio pages mid-round (which would prime-fire triggers against in-state alarms); NotifyOfReplacementEntityCreation is Fail('under construction') so late re-creation may be impossible today
|
||||
|
||||
@@ -339,7 +339,7 @@ cavern.egg`. This is every option the original console exposed for a BattleTech
|
||||
- experience (4 [T1 game decode]): novice, standard, veteran, expert ← was hardcoded `expert`
|
||||
- vehicle (18, friendly→tag): Black Hawk=blkhawk, Loki=loki, Nova=bhk1, Mad Cat=madcat, Thor=thor,
|
||||
Owens=owens, Kotori=own1, Summoner=thr1, Hellbringer=lok1, Timber Wolf=mad1, Avatar=avatar,
|
||||
Satyr=ava1, Sunder=sunder, Sunder V1=snd1, Vulture=vulture, Mad Dog=vul1, Mischief=lok2,
|
||||
Satyr=ava1, Sunder=sunder, Denkou=snd1 (#69), Vulture=vulture, Mad Dog=vul1, Mischief=lok2,
|
||||
Zanin Neko=mad2 (the 18 ModelList names; matches the 2026-07-18 vehicle sweep)
|
||||
- color/camo (7, display→tag): White=White, Black=Black, Brown=Brown, Red=**Crimson**, Green=Green,
|
||||
Gray=**Grey**, Tan=Tan ← was White/Black/Crimson only
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
# The K/D-score chain audit -- full findings (2026-08-11, ticket #162)
|
||||
|
||||
> The five-agent authority-model audit that settled the kill/death/score chain
|
||||
> once and for all. CURATED digest: `context/combat-damage.md` section "The
|
||||
> K/D-score AUTHORITY MODEL". This file is the complete evidence record --
|
||||
> every dispatch, handler arm, wire layout, replication fact, and field-receipt
|
||||
> correlation, with decomp line cites. Read it before touching ANY limb of the
|
||||
> scoring chain. The mandatory regression gate is scratchpad/night15/kd_bench.sh
|
||||
> (five hard invariants).
|
||||
>
|
||||
> Fixes shipped from this audit (4.11.885, commit 3545056): death tail
|
||||
> master-gated; #81 latch-clear moved to the binary's DropZoneReply site; steam
|
||||
> eggs stamp advancedDamage mission-wide; #38 badge/patch ride the lobby.
|
||||
|
||||
# Task 5 — #150 fix archaeology + bench coverage hole
|
||||
|
||||
## 1. Exactly what 46ff8dd changed (the #150 fix, 2026-08-10 10:30, "4.11.87x" era)
|
||||
|
||||
Commit `46ff8dd262b20a08334d44338e69fc686a6c7958` touched only `game/reconstructed/mech.cpp` (+28/-1) and added `scratchpad/night14/scorepair.sh` (new, 30 lines). All changes are inside `Mech::TakeDamageMessageHandler`.
|
||||
|
||||
**Before (pre-fix state of the report tail):**
|
||||
- The call to `BTMechPostCombatReports(this, message, damageTally, zoneDestroyed, reportZone, newly_destroyed)` ran **unconditionally** — on BOTH master and replicant instances of the victim mech (the port deliberately applies damage on replicants for local visual response, and the report tail rode along).
|
||||
- The `newly_destroyed` argument was computed as `deathBlastArmed && IsMechDestroyed()` — a movement-mode-derived flag, not an entry/exit edge.
|
||||
|
||||
**After (the two binary-cited gates):**
|
||||
1. **Master-only gate** (mech.cpp:1270 today): `if (GetInstance() != ReplicantInstance) { BTMechPostCombatReports(...); }`. Grounded in the binary's opening assertion `if ((flags & 0xc) == 4) Fail("Replicant Mech recieving takedamage", MECH.CPP, 0x3da)` [part_012.c:14613] — in 1995 the tail could only ever run on the victim's master. Local damage application on replicants stays.
|
||||
2. **destroyedAtEntry latch** (mech.cpp:1039 today): `const int destroyedAtEntry = IsMechDestroyed();` sampled **at handler entry**, and the last argument became `!destroyedAtEntry && IsMechDestroyed()` — the binary's `local_14` 0->1 edge across this handler call (part_012.c:14625-14629). A later salvo of the killing volley (arriving after the mech is already destroyed) takes the inflicted-only path.
|
||||
|
||||
**What the latch gates vs what it does NOT gate (verified against current mech.cpp):**
|
||||
- It gates **only the `newly_destroyed` argument**, i.e. block A (the type-2 KILL report to the shooter's player) inside `BTMechPostCombatReports` (btplayer.cpp, `if (newly_destroyed)` branch).
|
||||
- It does **NOT** gate block B (type-0 inflicted report) or block C (type-1 received report) — post-death salvos on the master still emit those.
|
||||
- It does **NOT** gate the **death_tail** at mech.cpp:1335-1343: `if (deathBlastArmed && IsMechDestroyed()) { BTMechPostVehicleDead(...); BTApplyDeathSplash(...); }` — this still uses the OLD pre-latch predicate, and **the master-only gate does not cover it either** (it sits outside the `GetInstance() != ReplicantInstance` block). `BTMechPostVehicleDead` is the dispatcher of the VehicleDead message whose `deathCount == -1` branch in `BTPlayer` does `++deathCount` / `++deathTally`, logs `PLAYER_DEAD`, and applies the -500 death cost (btplayer.cpp:582-736). **[Inference, flagged]** This ungated, un-latched VehicleDead dispatch is the natural suspect for #162's deathCount multi-increment — 46ff8dd hardened the score reports and left the death notification on the old predicate.
|
||||
|
||||
## 2. Other recent score/death/penalty changes in btplayer.cpp (last 3 weeks)
|
||||
|
||||
`git log` on `game/reconstructed/btplayer.cpp` (all within 2026-07-25 → 2026-08-08):
|
||||
|
||||
- **a52207d / 48d47ef (07-25, #45):** reclaimed the binary's DEATHS field (`deathTally` @+0x280) as the displayed/replicated counter, distinct from the engine's respawn-handshake `deathCount` (@+0x200, seeded -2); PLAYER_DEAD matchlog row logs BOTH (`deaths=` and `tally=`).
|
||||
- **a357dc4 (07-30, #81 ghost):** released the stuck `deathPending` death latch + stopped a duplicate VehicleDead. Note: `deathPending` (btplayer.cpp:546-559) is the ONLY dedup on the death cycle — a death arriving while a cycle is pending is SWALLOWED with a warning.
|
||||
- **1efe8ef (08-02, #118):** restored `SpecialCaseDeathPenalty` (role+0x20) and its consumer: every death with `advancedDamageOn` hands the ENGINE base `Player::ScoreMessageHandler` a type-1 award of `-penalty` via a **DIRECT base call** — deliberately bypassing BT dispatch, therefore **structurally invisible to the matchlog** (btplayer.cpp:726-736 today).
|
||||
- **91bd286 (08-05, #45/#134):** the big one — reconstructed the authentic report tail `BTMechPostCombatReports` (blocks A/B/C @0x4a02f4-0x4a0890) + `BTMechPostVehicleDead` (@0x4a07d4); retired the bring-up producers `BTPostDamageScore`/`BTPostKillScore`. This is what moved report emission into the victim's TakeDamage handler — the code 46ff8dd later had to gate.
|
||||
- **2772175 (08-07):** restored the type-0 Dispatch-override interceptor (`BTPlayer::ScoreInflictedMessageHandler` @004c0200) — per-hit inflicted credit was real 1995 behavior.
|
||||
- **e82f54c (08-07):** finding-only + a console-flush watermark; two recorded wrong turns.
|
||||
- **1324c81 (08-07):** inflicted credit lands on the OWNER's machine — Dispatch intercepts type 0 on a MASTER only; the type-0 arm of ScoreMessageHandler DELEGATES (wire deliveries go straight to the handler table, never the virtual Dispatch).
|
||||
- **b2498ca (08-07):** the delegating type-0 arm must `return`, not `break` — the fall-through clobbered `scoreAward` to 0 and ran the base handler again (a duplicate-row generator, then harmless-by-luck).
|
||||
- **a4bfb64 (08-07):** BOUND `scenarioRole` (was NULL forever — one commented-out lookup). This is what made deathPenalty=500 / killBonus=500 real; it also established that **-500 on an ordinary combat death is authentic** (gate = `advancedDamageOn` alone, @004c05c4 tail).
|
||||
- **e0b91df (08-07):** correction — the death cost was never missing; it is invisible to the matchlog because of the direct base call. Added the ungated `[deathcost] ... -> APPLYING/SKIPPED` DEBUG_STREAM receipt (btplayer.cpp:716-724). Explicit lesson recorded: "a value that cannot appear in the log you are reading is not evidence of absence."
|
||||
- **29b4d68 (08-07):** the +1000 MissionStarting seed grant. **5b7e481 (08-08):** mech+0x354 vestigial label, no behavior change.
|
||||
|
||||
## 3. The bench (scorepair.sh) — what it verified and what it could not see
|
||||
|
||||
`scratchpad/night14/scorepair.sh` (added by 46ff8dd): 2 nodes — B unarmed respawning target (player 3:1), A autofire missile killer that panic-ejects on a timer (player 2:1), relay via btconsole.py, 300 s. Its RESULT section is three checks:
|
||||
1. `grep -ac "Mech::Reset" sc_b.log` — a count of B's resets (proxy for "kills A should have earned").
|
||||
2. `grep -ah "SCORE\|KILL\|EJECT\|DEATH" matchlog_*.txt | head -25` — the score sequence.
|
||||
3. `grep -a "scorepost" sc_a.log / sc_b.log` — **"scorepost" matches NOTHING in the codebase** (grep over game/ + engine/ returns zero hits); these two checks were dead on arrival and always print empty.
|
||||
|
||||
**What the captured receipts actually show:**
|
||||
- `scratchpad/night14/sc.out` (02:56, pre-fix run) and `sc2.out` (10:28, the run cited in the commit): in BOTH, the `head -25` was exhausted by per-hit noise rows (type=0 award≈1.97 spam in sc.out; the victim node's `type=1 award=-0.00` spam in sc2.out) **before a single KILL / type=2 / DEATH row appeared**. The .out receipts therefore contain zero evidence about kills, deaths, or penalties. The commit's verified numbers ("kills 0->1->2, zero doubles, -900/-1050") match the raw matchlogs, so the verification was done by out-of-band manual matchlog reading, not by the bench's printed result.
|
||||
- **PLAYER_DEAD is matched by none of the bench's grep patterns** — "DEATH" does not substring-match "PLAYER_DEAD". The death counter was never surfaced, let alone compared against real deaths.
|
||||
- **`award=-500` can never appear in any matchlog** — the death cost is a direct `Player::ScoreMessageHandler` base call bypassing BTMatchLog (btplayer.cpp:712-715 says so explicitly; e0b91df is the commit that learned this). The only receipts are the `[deathcost]` DEBUG_STREAM lines in the node logs — which the bench never greps. (They DO exist in the post-fix run: `content/sc_a.log` has 4× `[deathcost] player 2:1 ... penalty=500 ... -> APPLYING`, `content/sc_b.log` has 2× for player 3:1 — so **at fix time the -500 was applying on both nodes**, provable only by artifacts the bench never printed.)
|
||||
|
||||
**The smoking gun in the pre-fix artifacts:** `scratchpad/night14/mlbak/matchlog_20260810_0250*.txt` (the run that reproduced #150) already contains the #162 death-side signature, in the same file as the double kill:
|
||||
```
|
||||
PLAYER_DEAD t=1827953 w=02:53:58.090 st=5 player=3:1 deaths=1 tally=1
|
||||
PLAYER_DEAD t=1827953 w=02:53:58.091 st=5 player=3:1 deaths=2 tally=2
|
||||
```
|
||||
— two deathCount increments 1 ms apart for ONE death, immediately adjacent to the double kill rows (`type=2 award=501.96 kills=1` / `award=505.88 kills=2`, also 1 ms apart) that #150 was chasing. Post-fix (10:24 run, `content/matchlog_20260810_102334_1912.txt`) PLAYER_DEAD rows read singly (deaths=1 then deaths=2, minutes apart), so in THIS bench topology the fix incidentally quieted the double too — but nothing asserted it, and no one looked at the death side of either artifact.
|
||||
|
||||
## 4. The precise coverage hole
|
||||
|
||||
1. **The bench asserted only the kill side, and only manually.** Its scripted receipts were (a) a respawn count on the victim, (b) a `head -25` matchlog slice that noise-truncated before any kill/death row in both captured runs, and (c) two greps for a log tag (`scorepost`) that does not exist. Nothing scripted compared type-2 count vs death count, PLAYER_DEAD count vs actual deaths, or score totals vs expected arithmetic.
|
||||
2. **PLAYER_DEAD was un-greppable by the pattern used** ("DEATH" ≠ "PLAYER_DEAD"), so death-count duplication — already present and visible in the pre-fix artifact — was structurally invisible to the bench.
|
||||
3. **The -500 penalty is invisible to the matchlog by design** (direct base-handler call), and the bench never grepped the `[deathcost]` DEBUG_STREAM receipts or computed total-arithmetic deltas — the only two ways to see it. A regression that silenced the penalty would print an identical bench RESULT.
|
||||
4. **The fix itself is asymmetric** and the bench could not detect the asymmetry: 46ff8dd's master-only gate + entry latch cover only `BTMechPostCombatReports` (mech.cpp:1270-1276); the death_tail's `BTMechPostVehicleDead` (mech.cpp:1335-1343) — the sole driver of `++deathCount`/`++deathTally`/PLAYER_DEAD and the death-cost block — kept the old `deathBlastArmed && IsMechDestroyed()` predicate, ungated by instance and un-latched. **[Inference]** That is the seam where #162's death-side breakage shipped: the kill side got both gates and a (manual) receipt; the death side got neither a gate nor a single line of bench assertion.
|
||||
|
||||
==============================================================================
|
||||
|
||||
# TASK 1 — Binary truth: the report tail of `Mech::TakeDamageMessageHandler` @004a0230
|
||||
|
||||
Function: `FUN_004a0230` (`reference/decomp/all/part_012.c:14474-14837`, export tag `file=bt/mech.cpp`), bound as **Mech entity message id 0x12 TakeDamage** (`context/decomp-reference.md:265`). All claims below are [T1] read from this export unless flagged.
|
||||
|
||||
## 0. Entry protocol (context for the tail)
|
||||
|
||||
- **Replicant assert** — part_012.c:14613-14616: `(mech+0x28 & 0xc) == 4` → `Fail("Replicant Mech recieving takedamage", MECH.CPP, 0x3da)`. The handler body runs ONLY on the victim's master node.
|
||||
- **Gyro bounce** — :14617-14624 (`mech+0x528` → `FUN_004b2980(gyro, msg+0x2c damageType, msg+0x30 amount)`); no messages.
|
||||
- **THE LATCH** — :14625-14629: `local_14 = (FUN_0049fb54(mech) != 0)` = destroyed-at-ENTRY. `FUN_0049fb54` @0049fb54 (part_012.c:14174-14188) returns 1 iff `mech+0x40 (mode) == 2 || == 9`. **Note it does NOT count mode 10** (see §3 gate asymmetry).
|
||||
- **lastInflictingID stamp** — :14631: `mech+0x43c ← msg+0x1c` (inflictor EntityID).
|
||||
- **Party resolve** — :14630-14635: registry-find shooter entity by `msg+0x1c`; `local_1c = *(shooter+0x190)` = **SHOOTER's player**; `local_20 = *(mech+0x190)` = **VICTIM's own player**. Both resolved BEFORE the collision divert.
|
||||
- **COLLISION DIVERT** — :14636-14639: `msg+0x2c (damageType) == 0` → `FUN_0049ffcc(mech, &msg+0x2c)` then **`goto LAB_004a07b5`** — jumps straight to the death tail (binary `jmp @0x4a0375 → 0x4a07b5`, `context/combat-damage.md:598-601`). Ram/wall damage therefore emits **NO id-0x16 report of any type** but STILL posts VehicleDead/blast if it killed. `FUN_0049ffcc` @0049ffcc (part_012.c:14374-14430) applies scaled internal-subsystem damage (gated `victimPlayer+0x268 != 0`, random subsystem via `FUN_004ac07c`) and **sends no messages**.
|
||||
- **Damage loop** — :14655-14684: `local_28` (tally) += per-burst applied `msg+0x30` **plus** each critically-destroyed subsystem's value `*(subsys+0x108)` (:14669); `local_30` (vitalHit) = 1 when the struck zone's destroyed flag `zone[9]` goes 1 (:14674-14676); zone re-rolled per burst (:14680); loop breaks when bursts (`msg+0x58`) exhaust OR the mech becomes destroyed mid-volley (`FUN_0049fb54`, :14683). The do-while shape means **the first burst always applies, even on a corpse**, so tally can be nonzero when `local_14 != 0`.
|
||||
|
||||
All dispatches below are `(**(code**)(*player + 0xc))(…)` — vtbl slot 3 = `Entity::Dispatch`, which reroutes over the wire when the target player is a replicant on this node (engine/MUNGA/ENTITY.cpp:244-251 [T0], cited in `docs/KD_SCOREBOARD_PLAN.md:86`).
|
||||
|
||||
## 1. Wire layout of the id-0x16 report (ScoreMessage, sizeof 0x3C) [T1, static_assert-locked per decomp-reference.md:290-293]
|
||||
|
||||
`{+0x00 size 0x3C, +0x04 id 0x16, +0x08 =1, +0x0c destID null (DAT_00522524), +0x14 =0, +0x18 =0, +0x1c scoreAward, +0x20 type, +0x24 basis, +0x28 vitalHit, +0x2c zone, +0x30 inflictingSubsystemID (=msg+0x5c, engine T0 name), +0x34 senderMechID}`
|
||||
|
||||
## 2. The report block — three mutually structured id-0x16 dispatches
|
||||
|
||||
### local_14 == 0 (ALIVE at entry) — part_012.c:14685-14709
|
||||
Re-tests `FUN_0049fb54` (:14686). If **destroyed NOW** (the alive→dead edge of THIS volley):
|
||||
|
||||
**Report A — type 2 KILL → SHOOTER's player** (:14688-14708; binary @0x4a04da, `combat-damage.md:605-608`; the KD plan calls this edge @0x4a052b-0x4a05ce, `KD_SCOREBOARD_PLAN.md:164`):
|
||||
- `+0x1c scoreAward` = applied tally `local_28` (:14693,14700)
|
||||
- `+0x20 type` = **2** (:14702)
|
||||
- `+0x24 basis` = `*(*(victimPlayer+0x208) + 0x1c)` (:14691) = the **VICTIM role's killBonus** (role+0x1c; = 500 in shipped `Role::Default`) — this is the `@0x4a0506` killBonus read (`decomp-reference.md:477-478`)
|
||||
- `+0x28 vitalHit` = `local_30`; `+0x2c zone` = `msg+0x24` (loop-ENTRY zone); `+0x30` = `msg+0x5c`; `+0x34 senderMechID` = **the VICTIM's own mech ID** (`mech+0x184`, :14692,14704)
|
||||
- Dispatch :14708 → `local_1c` = shooter's player. On the victim's node that object is a **replicant**, so Dispatch reroutes it over the wire to the killer's master — where `BTPlayer` 0x16 `Score@0x4c02e4` case 2 runs CalcKillScore × (senderTonnage/ownTonnage), `killCount@0x27c++` (dual — the 1995 phantom partner increment), self-kill `fchs` negation (`decomp-reference.md:280-284`).
|
||||
- Note: A and B are **exclusive** — a killing hit emits NO type-0.
|
||||
|
||||
If NOT destroyed now → `goto LAB_004a05d9` (falls into the same block the already-dead path uses):
|
||||
|
||||
**Report B — type 0 DamageInflicted → SHOOTER's player** (`LAB_004a05d9`, :14711-14734; @0x4a05d9):
|
||||
- Gate: `local_28 != 0.0f` (the constant `_DAT_004a0c14` is a 0.0f literal parked after function end)
|
||||
- `+0x1c scoreAward` = tally; `+0x20 type` = **0** (:14727); `+0x24 basis` = tally again (:14716,14728); `+0x28/2c/30` as above; `+0x34 senderMechID` = victim's mech ID (:14717,14729)
|
||||
- Dispatch :14733 → shooter's player. Receiver side: `BTPlayer::Dispatch` override (`@004bffa0`, vtbl @00513300 slot 3) splits type 0 off to `@004c0200 ScoreInflictedMessageHandler` before base dispatch (`combat-damage.md:609-623`).
|
||||
|
||||
### local_14 != 0 (ALREADY DESTROYED at entry)
|
||||
Enters directly at `LAB_004a05d9` → emits **Report B (type 0) if tally ≠ 0**. The believed "inflicted-only" is **half right**: the latch suppresses only the type-2 kill and the death tail — **Report C below still fires too**, because it sits OUTSIDE both branches.
|
||||
|
||||
### Report C — type 1 DamageReceived → VICTIM's OWN player (:14736-14759; @0x4a06c0) — **unconditional on the latch**
|
||||
- Gate: `0.0f < local_28` only (:14736). Fires on non-lethal hits, on the killing volley, AND on corpse-hits whose first burst applied.
|
||||
- `+0x1c scoreAward` = applied tally; `+0x20 type` = **1** (:14752); `+0x24 basis` = **INTENDED** damage = `msg+0x58 burstCount × msg+0x30 amount` (:14737,14753); `+0x28 vitalHit`; `+0x2c zone`; `+0x30 subsysID`; `+0x34 senderMechID` = **the SHOOTER's mech ID** (`msg+0x1c`, :14742,14754)
|
||||
- Dispatch :14758 → `local_20` = victim's own player (master, local delivery). Receiver: `Score@0x4c02e4` case 1 = `(ownTonnage/senderTonnage) × role->CalcDamageReceivedScore(basis)` + the console VTVDamaged post (`decomp-reference.md:276-279`).
|
||||
|
||||
## 3. The death tail — `LAB_004a07b5` (:14760-14835; @0x4a07b5; also reached by the collision divert)
|
||||
|
||||
**Gate (:14761): `local_14 == 0 && (mech+0x40 == 9 || mech+0x40 == 10)`** — was-alive-at-entry AND now in death mode 9|10 (`combat-damage.md:691` calls +0x40 movementMode).
|
||||
|
||||
⚠ **Latch asymmetry [T1 fact, T4 as bug mechanism]:** the latch (`FUN_0049fb54`) counts modes **2|9** but the tail gate accepts **9|10**. If a mech can sit at mode 10 at the ENTRY of a subsequent TakeDamage (e.g. the 1ms-later duplicate hits of the killing volley), `local_14` stays 0 and the ENTIRE death tail — including VehicleDead — **re-fires**. Whether mode 10 persists across invocations in the binary is not established here; in the port it depends on when the reconstruction advances the mode. This is the one structural hole in the binary's own dedup and maps exactly onto the #162 deathCount multi-increment shape.
|
||||
|
||||
Dispatches inside the tail, in order:
|
||||
|
||||
**(1) VehicleDead — id 0x17, BT-extended sizeof 0x38 → the VICTIM's OWN player** (:14762-14775):
|
||||
- `{+0x00 0x38, +0x04 0x17, +0x08 1, +0x0c destID null, +0x14 0, +0x18 0, +0x1c deathCount = -1 (0xffffffff, :14770), +0x20 dropZone EntityID = null, +0x28 = 0, +0x2c killed-by PLAYER EntityID = shooter-player's ID (local_1c+0x184 via `local_1c + 0x61` int* math, :14762,14773), +0x34 kill zone (msg+0x24, :14763,14774)}` — matches the locked layout at `decomp-reference.md:293-294`.
|
||||
- Dispatch :14775 → `local_20` (victim's own player, master → local). Receiver: **BTPlayer 0x17 `VehicleDead@0x4c05c4`** — increments the DEATHS cell `+0x280` on BOTH victim and killer players (`inc [edi+0x280]/[ebx+0x280]` @0x4c0670-0x4c067a) and, in its tail `@004c07cd-0x4c0828`, gated on `advancedDamageOn` (player+0x264), **direct-calls the ENGINE base `Player::ScoreMessageHandler` @0042da20 with a type-1 ScoreMessage whose `scoreAward = −ScenarioRole::specialCaseDeathPenalty` (role+0x20, 500 in `Role::Default`)** — the −500 death cost (`decomp-reference.md:458-478`, `KD_SCOREBOARD_PLAN.md:88`).
|
||||
- **This is the ONLY VehicleDead-class dispatch anywhere in FUN_004a0230**, and it is emitted at most once per handler invocation, solely under the was-alive latch + mode 9|10 gate. deathCount increment and −500 penalty share this single carrier: in the binary they cannot diverge (duplicate increments + missing penalty can only co-occur if the receiver path was split, which is a port-side property — inference, [T4] w.r.t. build 883).
|
||||
|
||||
**(2) Death explosion — engine message id 3, sizeof 0x5C — NOT dispatched, consumed locally** (:14776-14798):
|
||||
- `{+0x04 id 3, +0x1c model/class 0x31, +0x28 = mech+0x4c0, +0x2c = 0x100, +0x30..0x48 = mech position/orientation (mech+0x100..0x118), +0x4c and +0x54 = victim mech EntityID}` (:14779-14797)
|
||||
- Passed to `FUN_004302ec(&msg)` @004302ec (part_004.c:2362-2385): registry-finds the entity at msg+0x4c, `new`s a 0x1dc-byte object (ctor `FUN_00430180`, class data @DAT_004e6cc4) = the blast/Explosion entity, kept as `local_a0`. Nothing goes on the wire from here.
|
||||
|
||||
**(3) Delayed self-message — id 0x17 to the MECH, sizeof 0x1C — the respawn trigger, SCHEDULED not dispatched** (:14799-14811):
|
||||
- `{0x1c, 0x17, 1, destID null, 0, 0}` (:14799-14804). Receiver id-space is the MECH's: mech-table **0x17 = SetBurningState@0049f674**, whose body actually randomizes position + graphicAlarm 2 + sim re-arm — the respawn (`decomp-reference.md:266`, label/body mismatch).
|
||||
- Scheduled via `FUN_0041acbc(world+0x60, 3, mech, msg, when)` @0041acbc (part_002.c:4641-4658): allocates a scheduler node from pool 0x4e2fa4 (`FUN_0041a818(mech,msg,when)`) and inserts it in the world's delayed-delivery list. `when = *FUN_00414b60() (global clock, part_001.c:7125) + __ftol(mech+0x430)` — `FUN_004dcd94` is an ST0-arg `__ftol` the decompiler renders argless (`decomp-reference.md:278-279`); the float loaded is `mech+0x430` (:14805-14809), the respawn delay. **This is NOT a VehicleDead** despite sharing id 0x17 — per-receiver-class id spaces.
|
||||
- (Do not confuse with mech id **0x16** = BalanceCoolant — the player/mech 0x16 ids are unrelated.)
|
||||
|
||||
**(4) Roster sweep** (:14812-14817): iterator over the `mech+0x418` list (`FUN_004a4eab`/`FUN_004a4ee9`), calls vtbl+0x48 per item — no messages.
|
||||
|
||||
**(5) Splash damage — the one tail helper that DOES send more messages** (:14818-14832):
|
||||
- Gate: `victimPlayer+0x264 != 0 && victimPlayer+0x258 == 0` (:14819; +0x264 = advancedDamageOn per decomp-reference.md:460; the +0x258 slot is labeled suppressConsole in the VTVDamaged context — reuse here unverified [T4]).
|
||||
- Builds damage spec `local_274 {type=2, amount=mech+0x520, victim position, __ftol(...)}` (:14820-14828) and calls `FUN_0042fad0(explosionEntity local_a0, victimMechID, &spec, radius = mech+0x524)` (:14831).
|
||||
- `FUN_0042fad0` @0042fad0 (part_004.c:2059-2307) = the #89 SplashDamage: sweeps a ±radius box, and for each hit entity **dispatches a fresh id-0x12 TakeDamageMessage (size 100=0x64)** via `(**(code**)(*piVar3+0xc))(piVar3,&local_214)` (part_004.c:2276-2299), burst count = `max(1, __ftol(distance falloff))` (:2258-2261), inflictor ID stamped into the target's `+0x184`-relative slot from the passed mech ID (:2272-2275). Exclusions (:2203, :2226): the explosion entity itself, `*(explosion+0x1c8)` (its source — the dying mech, inferred from the ctor message carrying the mech ID [T4 on the identity]), and one under-recovered extra stack arg (`in_stack_00000024` — the decompiled 5-arg signature is short; [T4]). So the corpse does not re-splash itself, but every NEARBY mech's master re-enters this same handler with collateral damage during the killing volley.
|
||||
|
||||
**(6) ForceUpdate** (:14833): `FUN_004a4c54(mech, 1)` @004a4c54 (part_012.c:17180-17193): `mech+0x18 |= 1` (updateModel bit 0 = `Simulation::ForceUpdate`, corroborated `KD_SCOREBOARD_PLAN.md:131`); if destroyed, mask `& 0xfe03` still passes bit 0. Marks the dead mech for replication. No messages.
|
||||
|
||||
## 4. Emission matrix (per single handler invocation)
|
||||
|
||||
| Scenario at entry | type 0 → shooter | type 1 → victim | type 2 → shooter | VehicleDead 0x17 → victim's player | tail extras (blast/respawn-sched/splash) |
|
||||
|---|---|---|---|---|---|
|
||||
| alive, survives volley | ✔ (tally≠0) | ✔ (tally>0) | — | — | — |
|
||||
| alive → DEAD this volley | — | ✔ | ✔ (basis=victim killBonus) | ✔ (exactly once) | ✔ |
|
||||
| already destroyed (mode 2\|9) | ✔ (tally≠0) | ✔ (tally>0) | — | — | — |
|
||||
| collision (type 0 dmg), killed | — | — | — | ✔ (killed-by = rammer's player) | ✔ |
|
||||
| collision, survived | — | — | — | — | — |
|
||||
|
||||
Corrections to the task's priors: (a) the already-destroyed branch is **not** inflicted-only — the type-1 received report to the victim's own player also flows (Report C is outside both latch branches, gated only on tally>0); (b) the killing hit emits **no type-0** (A/B exclusive); (c) collision damage emits **no 0x16 at all** yet still runs the full death tail.
|
||||
|
||||
## 5. Relevance flags for #162 (inference, clearly marked [T4] against build 883)
|
||||
- deathCount++ and the −500 penalty ride the SAME single VehicleDead message in the binary; 8 increments + 0 penalties means the port's VehicleDead emission or its receiver diverged from this map, not the binary's report logic.
|
||||
- The only binary-native re-fire hole is the mode-10 latch gap (§3): destroyed-at-entry is tested as mode 2|9 while the tail fires on 9|10.
|
||||
- "Duplicate pairs" of damage awards is consistent with B+C both existing per hit (one to each party) and/or corpse-hits still emitting B+C — and splash generates additional id-0x12 volleys during a kill.
|
||||
|
||||
==============================================================================
|
||||
|
||||
# FIELD RECEIPTS A/B — build 857 (night14) vs build 883 (night15), ticket #162
|
||||
|
||||
All paths below are absolute. Steam logs have no per-line timestamps (line numbers cited); matchlog receipts carry `t=` (ms tick) and `w=HH:MM:SS.mmm` wall clock. Receipt-format matchlogs were only collected from the ALIA machine on night15 (2 files) plus a dev-machine archive `scratchpad/night14/mlbak/` (builds 4.11.837–4.11.870, DESKTOP-AE1SU9U). The night14 field steam logs (build 857) contain NO receipt-format lines at all — no `SCORE t=`, no `PLAYER_DEAD` (0 matches in all four files); the 857 baseline evidence is the `[score]`/`[respawn]`/`[deathcost]` prose lines. [T2 throughout — all claims below are read directly from field logs]
|
||||
|
||||
## 1. BUILD-857 BASELINE (night14 steam logs)
|
||||
|
||||
**Multi-increment already existed in 857.** Clustering `[respawn] player X:1 death cycle START (death #N)` lines (same player, consecutive N, <200 lines apart) across the four logs:
|
||||
|
||||
| log | increments | real events | multiplicity |
|
||||
|---|---|---|---|
|
||||
| steam_20260808_a_dave_SCREECH.log | 9 | 6 | {1:4, 2:1, 3:1} |
|
||||
| steam_20260808_b_santo_MSFIREFLY.log | 7 | 6 | {1:5, 2:1} |
|
||||
| steam_20260808_c_michael_XIAOLONG.log | 11 | 9 | {1:7, 2:2} |
|
||||
| steam_20260808_e_rajel_GAMERSLAB.log | 13 | 9 | {1:7, 3:2} |
|
||||
| **TOTAL 857** | **40** | **30** | **{1:23, 2:4, 3:3}** |
|
||||
|
||||
**Every increment fired the full chain: KILL report + death cycle + deathcost.** KILL-report count == increment count in every log (9/9, 7/7, 11/11, 13/13) — this is the receipt-level shape of the 857 "kills doubled" bug that 46ff8dd later fixed.
|
||||
|
||||
**The penalty fired once per increment, gated on `advDmg`:** all 8 `-> APPLYING` lines have `advDmg=1`; all 32 `-> SKIPPED` have `advDmg=0`. Perfect 40/40 correlation. Verbatim triple-death, triple-penalty sample (dave log, C:/git/bt411/scratchpad/night14/steam_20260808_a_dave_SCREECH.log:59301-59317, one killing volley, scoreBefore stepping −500 each time — sequential application proof):
|
||||
|
||||
```
|
||||
[score] *** KILL report *** -> shooterPlayer=0BE8BC20 tally=2.45198 bonus=500
|
||||
[respawn] player 2:1 death cycle START (death #2) -- drop-zone hunt in 5s
|
||||
[score] VehicleDead(-1): death #2, respawn hunt in 5s
|
||||
[deathcost] player 2:1 advDmg=1 role=bound penalty=500 scoreBefore=4788.47 -> APPLYING
|
||||
[death] VehicleDead(-1) dispatched to the owning player (killedBy=6:1 zone=21)
|
||||
[score] *** KILL report *** -> shooterPlayer=0BE8BC20 tally=3.43099 bonus=500
|
||||
[respawn] player 2:1 death cycle START (death #3) -- drop-zone hunt in 5s
|
||||
[score] VehicleDead(-1): death #3, respawn hunt in 5s
|
||||
[deathcost] player 2:1 advDmg=1 role=bound penalty=500 scoreBefore=4288.47 -> APPLYING
|
||||
[death] VehicleDead(-1) dispatched to the owning player (killedBy=6:1 zone=21)
|
||||
[score] *** KILL report *** -> shooterPlayer=0BE8BC20 tally=3.43232 bonus=500
|
||||
[respawn] player 2:1 death cycle START (death #4) -- drop-zone hunt in 5s
|
||||
[score] VehicleDead(-1): death #4, respawn hunt in 5s
|
||||
[deathcost] player 2:1 advDmg=1 role=bound penalty=500 scoreBefore=3788.47 -> APPLYING
|
||||
```
|
||||
|
||||
Other verbatim samples: single APPLYING (michael log :82303-82307, `advDmg=1 scoreBefore=1513.08 -> APPLYING`); triple SKIPPED (rajel log :57467-57481, player 6:1, 3× `advDmg=0 scoreBefore=6858.66 -> SKIPPED`, note scoreBefore does NOT move — skipped means no deduction); double SKIPPED (santo log :41879-41889, player 4:1, 2× `scoreBefore=1264.11 -> SKIPPED`).
|
||||
|
||||
**Are there award=-500.00 SCORE receipt lines?** No — in ANY build. In the entire mlbak receipt archive (builds 837–870) plus both 883 matchlogs, `award=` is never −500; the penalty is applied OUTSIDE the id-0x16 SCORE receipt and shows only as a silent −500 drop in the running `total=` between consecutive receipts. E.g. mlbak/matchlog_20260807_235237_15576.txt (4.11.837): total 0.00 → −500.00 between lines 32→40 with all awards −0.00; mlbak/matchlog_20260809_165517_13216.txt (4.11.865) line ~172: total 1000.00 → 501.97 (= −500 + a 1.97 award). The type= on the surrounding lines is type=1 (DamageReceived) with award=-0.00 — the −500 never rides a typed report.
|
||||
|
||||
**−499 eject evidence:** not present in the four night14 field steam logs (greps for `-499`/eject-score hit only coordinates and weapon-jam EJECT lines). The documented evidence is docs/OPEN_ISSUES_FOR_TESTERS.txt:87 — `#151 Panic eject costs -499: verified REAL 1995 scoring (your death scored as suicide + death penalty)` — i.e. −499 = suicide credit (+1) + death penalty (−500) [inference on the arithmetic; the −499 itself is a ledger claim, not in these logs].
|
||||
|
||||
## 2. BUILD-883 (night15)
|
||||
|
||||
**PLAYER_DEAD multiplicity — matchlog receipts (ALIA machine, both matches):**
|
||||
- matchlog_20260810_213656_20476.txt (player 6:1 = Elengil): 8 PLAYER_DEAD, **5 real deaths**, multiplicity per event: **2, 1, 1, 3, 1** (events at w=21:39:49.419, 21:41:17.884, 21:43:13.203, 21:44:36.445, 21:46:29.337). Matches ticket #162's "5 real deaths → 8 increments" exactly.
|
||||
- matchlog_20260810_223651_12120.txt (player 2:1, second match): 10 PLAYER_DEAD, **5 real deaths**, multiplicity: **1, 2, 2, 4, 1**.
|
||||
- DEAD_NOTIFY count == PLAYER_DEAD count in both (8/8, 10/10); every PLAYER_DEAD is immediately preceded by its own `DEAD_NOTIFY ... link=<same ptr>`.
|
||||
|
||||
**All-machine distribution** (six steam logs, death-cycle-START clustering, all players' ledgers): a:{1:4,2:2}, b:{1:16,2:6}, c:{1:9,2:3,4:1}, d:{1:11,2:8,3:5}, e:{1:8,2:6,3:1,4:2}, f:{1:14,2:6,3:1,6:1} → **TOTAL {1:62, 2:31, 3:7, 4:3, 6:1}** — 163 increments over 104 events; ~40% of deaths multi-count, up to 6×.
|
||||
|
||||
**award=-500 count: 0** across both matchlogs (1,326 SCORE lines). No negative award of any magnitude exists (the only negative-looking value is the literal `award=-0.00` on every type=1 line — 745 of them, all exactly −0.00). Also **no silent −500 total-drops**: the only two total-vs-award mismatches (ml1 lines 305, 1117) are the type=2 kill receipts, which print `total=` BEFORE adding the award (next receipt shows total jumped +512.00: 1471.83 → 1992.58 = 1471.83+512+8.75). So in 883 the penalty is gone from the receipts entirely.
|
||||
|
||||
**Deathcost is still evaluated once per increment but always skips:** across all six steam logs 163 `[deathcost]` lines: **162× `advDmg=0 -> SKIPPED`, 1× `advDmg=1 -> APPLYING`** (steam_20260810_a_Dave_SCREECH-PC.log:5383 — player 2:1's FIRST death of the first round, `scoreBefore=1000 -> APPLYING`; the same player's later deathcost lines :19317, :26970 are `advDmg=0 -> SKIPPED`). Contrast 857 where 2:1's advDmg stayed 1 across deaths #1–#4 mid-round (scoreBefore 2907…4788). Receipt-level verdict: **the −500 path is intact but its `advDmg` gate reads 0 on (almost) every death in 883**, where it read 1 persistently for the same player in 857. Eleng's own machine evaluated deathcost for 6:1 exactly 8 times (once per increment) — all SKIPPED.
|
||||
|
||||
## 3. ELENGIL CORRELATION (6:1, matchlog_20260810_213656_20476.txt)
|
||||
|
||||
**death#1 @ w=21:39:49.419 — 2 increments.** DMG victim=6:19 within ±3s: from **7:19**: 12.000 (lvl .8806), 13.000 (lvl .6195) at .180/.181, and 8.750 burst=3 at .558 (post-death); from **5:19**: 1.964/3.432/4.913 (lvl .8488→.9130) then **type=4 amt=14.701 lvl=1.0000** → DEAD_NOTIFY → `PLAYER_DEAD deaths=1`, then **type=4 amt=15.701 lvl=1.0000** → DEAD_NOTIFY → `PLAYER_DEAD deaths=2`, then `DEATH ... killer=5:19 killdmg=15.701` 46ms later. **2 increments = 2 lethal (lvl=1.0000) damage rounds**, both from ONE shooter (5:19). Distinct shooters in window = 2; peers = 8.
|
||||
|
||||
**death#4 @ w=21:44:36.445 — 3 increments.** Window: **8:19** lands 9 rounds .596–.866 (4×3.446, 2×5.889, 13.000, 3.500 burst=9, 1.500 burst=2 — none lethal); **5:19** lands 7 rounds: 1.977×2, 3.444 (lvl .9236→.9653), then **4.903 lvl=1.0000** → PLAYER_DEAD deaths=5, **15.717 lvl=1.0000** → deaths=6, **15.717 lvl=1.0000** → deaths=7, all at t=613221000 same ms, DEATH latch 53ms later. **3 increments = 3 lethal rounds, all from ONE shooter.**
|
||||
|
||||
**Verdict: increment multiplicity == the number of KILLING-WINDOW DAMAGE ROUNDS** (damage applications with lvl=1.0000 that land between the killing blow and the DEATH-transition latch ~30–50ms later). It is NOT the number of distinct shooters (death#1: 2 shooters in window, 2 increments from one; death#4: 2 shooters, 3 increments from one; ml2's 4-increment event at 22:45:40.768 has all 4 lethal rounds from the single shooter 8:19 while earlier lvl=1.0000 rounds from 6:19 and 9:19 at .323/.628 produced NO increment). It is NOT the peer count (8 peers; multiplicity observed 1–6, mode 1–2). Each in-flight round of the killing volley that applies before the wreck latch re-fires DEAD_NOTIFY→PLAYER_DEAD.
|
||||
|
||||
## 4. THE "DUPLICATE SCORE PAIRS" IN 883 RECEIPTS
|
||||
|
||||
Same-key (t + player + type + award) SCORE multiplicity histograms: ml1 type=0 {1:98, **2:63**}, type=1 {1:46, 2:28, 3:10, 4:20, 5:3, 6:19}; ml2 type=0 {1:115, **2:120**}, type=1 up to ×6. The nonzero-award duplicated groups are **all type=0 (DamageInflicted)**; pair members carry **IDENTICAL award and DIFFERENT, sequentially-advancing totals** — e.g. ml1 lines 45-48:
|
||||
|
||||
```
|
||||
SCORE t=612804640 w=21:37:40.090 st=5 player=6:1 type=0 award=1.97 total=1001.97 kills=0
|
||||
SCORE t=612804640 w=21:37:40.090 st=5 player=6:1 type=0 award=1.97 total=1003.94 kills=0
|
||||
SCORE t=612804640 w=21:37:40.090 st=5 player=6:1 type=0 award=3.44 total=1007.38 kills=0
|
||||
SCORE t=612804640 w=21:37:40.090 st=5 player=6:1 type=0 award=3.44 total=1010.82 kills=0
|
||||
```
|
||||
|
||||
**BUT these type=0 pairs are not duplicates.** Each pair member maps 1:1 to a distinct weapon event: the volley above is FIRE wpn=25 amt=3.439 / wpn=26 amt=3.439 / wpn=27 amt=1.971 / wpn=28 amt=1.971 (twin-mounted lasers, ml1 lines 41-44), and the following `award=13.00` pair maps to PROJ wpn=22 + wpn=24 both amt=13.000 (lines 49-52). Aggregate: SCORE type=0 count ≤ hit-event count in both matchlogs (224 vs 242; 355 vs 408 — the excess hits are missile rounds paid as lump awards like 35.00=4×8.75). A build-865 control (mlbak/matchlog_20260809_165517_3316.txt lines 16-19: 2 FIRE → 2 identical 1.97 awards; 105 type=0 vs 108 hits) shows the identical pattern pre-883. **Verdict: the type=0 "duplicate pairs" are twin-weapon awards, present in old builds, correctly paid once per hit — not part of bug #162.** What IS genuinely duplicated per killing volley: the DMG/type=1 receipt (one per damage round, always award=-0.00, ×2–×6 same-ms) and DEAD_NOTIFY/PLAYER_DEAD (the real bug). Kill credit is exact in 883: `SCORE type=2 award=512.00` / `award=500.98` once per kill, SBMIRROR +1 steps (ml1 lines 305, 1117).
|
||||
|
||||
==============================================================================
|
||||
|
||||
# TASK 2 — Binary truth: 1995 player-side score/K-D handlers + replication
|
||||
|
||||
All addresses verified against the 2026-08-06 re-export (`reference/decomp/all/part_013.c`, `part_012.c`, `part_004.c`) and, where load-bearing, re-verified with raw capstone disasm of `content/BTL4OPT.EXE` (`tools/disas2.py`). Tier tags inline.
|
||||
|
||||
## 0. The handler table (who consumes what) [T1, byte-scanned table + export]
|
||||
|
||||
BTPlayer message table (20-byte rows, 6 entries — matches the T0 `PLAYER.h:235-240` enum `DropZoneReply=Entity::NextMessageID(0x15), Score(0x16), VehicleDead(0x17), MissionStarting(0x18), MissionEnding(0x19), NextMessageID(0x1a)`):
|
||||
- **0x15 DropZoneReply → `FUN_004bffd0`** (part_013.c:18814)
|
||||
- **0x16 Score → `FUN_004c02e4`** (part_013.c:18941) — the type-0/1/2 switch
|
||||
- **0x17 VehicleDead → `FUN_004c05c4`** (part_013.c:19066; formerly an export gap, now exported AND raw-disasm-confirmed this pass)
|
||||
- **0x1a ScoreUpdate → `FUN_004c02a8`** (part_013.c:18926)
|
||||
- `FUN_004c0200` "ScoreInflicted" (part_013.c:18896) is bound in NO table entry — **dead code in 1995** (confirms `context/decomp-reference.md:273-275`).
|
||||
|
||||
## 1. The id-0x16 Score handler `@0x4c02e4` — all three arms [T1]
|
||||
|
||||
Entry: replicant Verify (`(this+0x28 & 0xc)==4` → Verify print, btplayer.cpp line 0x1e9), then **MissionEnding gate `this+0x40 != 4`** (whole body skipped during mission end). `iVar3 = GetEntityPointer(msg->senderMechID @+0x34)`; `local_c = playerVehicle(+0x1fc)`.
|
||||
|
||||
**type 0 (DamageInflicted)** — part_013.c:18970-18973: nothing but `Verify` (line 0x296). `local_10` (the award local) is **never written**, and the shared tail (part_013.c:19015-19016) still runs: `msg->scoreAward(+0x1c) = local_10 /*UNINITIALIZED*/; FUN_0042da20(this,msg)` → **folds stack garbage into engine currentScore(+0x1c8)** — the shipped 1995 bug the KB documents (`decomp-reference.md:275-276`). No counter increments.
|
||||
|
||||
**type 1 (DamageReceived)** — part_013.c:18974-18991: only if `senderMech != own vehicle`: `award = (ownTonnage(+0x4bc)/senderTonnage) × CalcDamageReceivedScore(basis@msg+0x24)` where **`FUN_00429b94` (part_003.c:10156-10160) = `−(basis × role->damageReceivedModifier(+0x10))`** — a NEGATIVE (penalty). Then the console VTVDamaged post (code 5; gated ConsoleHost && `!suppressConsole(+0x258)`). **If senderMech == own vehicle (self-inflicted), `local_10` stays uninitialized and the tail still folds it** — same uninit-fold class as type 0 (NEW this pass). No counter increments. dfltrole `dmgRcv=0` ⇒ award 0 in shipped content.
|
||||
|
||||
**type 2 (Kill)** — part_013.c:18992-19013:
|
||||
- `award = CalcKillScore(this, &basis@+0x24, victimMech, tally@+0x1c) × (victimTonnage/ownTonnage)`.
|
||||
- **`CalcKillScore @0x4c052c`** (part_013.c:19027-19060): team game (`freeForAll(+0x250)==0`) + same `teamName(+0x20c)` → `local_8 = −friendlyFirePenalty(role+0x14)`; else `local_8 = victimMech->avgZoneDamage(+0x354) × damageBias(role+0x18) + 1.0 (@0x4c05c0)`; returns `(basis + tally) × damageInflictedModifier(role+0xC) × local_8`.
|
||||
- **Suicide (`victimMech == own vehicle`): `local_10 = -local_10` (the `fchs`) and NO killCount increment.**
|
||||
- Else — **the dual killCount++, exact context** (part_013.c:18998-19001; `inc [+0x27c]` @0x4c0397/@0x4c03a3):
|
||||
```c
|
||||
else {
|
||||
*(int *)(param_1 + 0x27c) = *(int *)(param_1 + 0x27c) + 1; // shooter (this) killCount++
|
||||
piVar1 = (int *)(*(int *)(iVar3 + 400) + 0x27c); // victimMech+0x190 -> victim's player
|
||||
*piVar1 = *piVar1 + 1; // PHANTOM: victim's player killCount++ (wrong column)
|
||||
}
|
||||
```
|
||||
On the shooter's node the second object is the **victim's replicant player copy** — never corrected in 1995 (no replication, §4).
|
||||
- StatusMessage `{type 0 "Destroyed", victim player, 6.0s}` (pool @0x512f6c, vtable @0x513344) added to this player; if `victimMech == objectiveMech(+0x284)` → clear-target `FUN_004b04d8` via own vehicle's roster head (+0x128).
|
||||
- Tail (all types): `msg->scoreAward = award; FUN_0042da20(this,msg)` → `currentScore(+0x1c8) += award; playerVehicle->RespondToScoreMessage(msg)` (part_004.c:449-450 ≡ T0 `engine/MUNGA/PLAYER.cpp:143,150`).
|
||||
|
||||
**killCount(+0x27c) is written NOWHERE else in the image** — only the type-2 arm.
|
||||
|
||||
## 2. WHERE deathCount++ lives and WHERE the deathPenalty is subtracted [T1, raw disasm this pass]
|
||||
|
||||
Both live in **`BTPlayer::VehicleDeadMessageHandler @0x4c05c4`**, in the **`msg->deathCount(+0x1c) == -1` arm** — one straight-line block, no exit between them:
|
||||
|
||||
- Entry gates: `this+0x40 != 4` (MissionEnding); `msg+0x1c == -1` else → engine base `FUN_0042db80` (drop-zone hunt); **`deathPending(+0x290) == 0`** guard @0x4c05f7.
|
||||
- `edi = GetEntityPointer(msg+0x2c)` = the **killer's PLAYER** (the BT 0x38-byte extension field).
|
||||
- StatusMessage `{type 1 "KILLED BY", killer player, 6.0s}` @0x4c062d-0x4c0668.
|
||||
- **The deaths increment @0x4c0670-0x4c067f:**
|
||||
```
|
||||
0x4c0670: cmp ebx, edi ; victim(this) vs killer player
|
||||
0x4c0672: je 0x4c067a ; self-kill: skip the partner bump
|
||||
0x4c0674: inc dword ptr [edi + 0x280] ; killer's player deaths++ (wrong-column slip; on the victim's node this is the killer's REPLICANT copy)
|
||||
0x4c067a: inc dword ptr [ebx + 0x280] ; victim's deaths++ (the real DEATHS cell) — UNCONDITIONAL
|
||||
```
|
||||
- Console death notice (code 5, `FUN_004c18cc`) if `!suppressConsole(+0x258)` && console present.
|
||||
- Revenge lock `objectiveMech(+0x284) = killerPlayer->playerVehicle(+0x1fc)` @0x4c06dd.
|
||||
- Death sweep `FUN_0049fe0c(playerVehicle, 0)` @0x4c06e9 (DeathShutdown arg 0).
|
||||
- Lives branch on `role(+0x208)->returnFromDeath(+0x28)`:
|
||||
- `> 0`: re-post the SAME VehicleDead to **SELF** at Now + **5.0 s** (@0x4c0830) with `msg->deathCount ← this->deathCount(+0x200)` (raw disasm recovered `mov esi,ebx` @0x4c0731 — the Post target is the player, matching the export).
|
||||
- `< 1` (out of lives): post `{size 0x1c, id 0x18}` to the **VEHICLE** at Now + **10.0 s** (@0x4c0838) — in the Mech id space 0x18 = ClearBurningState; no respawn.
|
||||
- **THE DEATH PENALTY @0x4c07cd-0x4c0825** — BOTH branches converge here (`jmp 0x4c07cd` @0x4c0754):
|
||||
```
|
||||
0x4c07cd: mov ecx, [ebx + 0x264] ; advancedDamageOn — THE ONLY GATE
|
||||
0x4c07d5: je 0x4c0828 ; off -> no penalty
|
||||
0x4c07d7: mov eax, [ebx + 0x208] ; scenarioRole
|
||||
0x4c07dd: fld dword ptr [eax + 0x20] ; specialCaseDeathPenalty (dfltrole = 500)
|
||||
0x4c07e0: fchs ; negate
|
||||
... build a BARE 0x20-byte engine ScoreMessage {id 0x16, scoreAward@+0x1c = -penalty} (NO type field — the msg ends at 0x20)
|
||||
0x4c0820: call 0x42da20 ; DIRECT call to ENGINE Player::ScoreMessageHandler
|
||||
```
|
||||
So the −500: **victim's master node, inside the id-0x17 VehicleDead(-1) arm, gated ONLY on `advancedDamageOn(+0x264)`, amount = `role+0x20` read live, applied by a DIRECT call** (no Dispatch → no reroute, no BT type switch, no Verify) into `currentScore(+0x1c8)` + `playerVehicle->RespondToScoreMessage`. Confirms `decomp-reference.md:458-478` (#118) and adds: **the penalty also applies on the out-of-lives path.**
|
||||
|
||||
**The engine respawn counter is a DIFFERENT field:** `Player::deathCount(+0x200)` (ctor seed −2, `PLAYER.cpp:777`; zeroed at first spawn, part_013.c:18834) increments ONLY in **`FUN_004c012c`** (part_013.c:18872, the DropZoneReply respawn helper) — once per completed respawn cycle, alongside `msg->deathCount` restamp, `role->returnFromDeath(+0x28)--`, **`ForceUpdate` (`or word [ebx+0x18],1`)**, sim-state SetLevel(1)=DropZoneAcquired, respawn-location choose (advancedDamageOn → the ORIGINAL drop @DAT_00524b38, else the assigned zone), a delayed self re-post (the 2-pass latch), and **clearing `+0x290` and `suppressConsole(+0x258)`** (part_013.c:18887-18888).
|
||||
|
||||
### NEW [T1, whole-image byte scan]: the `deathPending(+0x290)` guard is NEVER SET in 1995
|
||||
Every code-section instruction touching disp32 0x290: `push 0x290` @0x429294 (an immediate, not a member); zero-writes @0x4b77fd (different object), **@0x4c01e5 (`FUN_004c012c`: = 0)**, **@0x4c0c07 (BTPlayer ctor: = 0)**; the lone READ @0x4c05f9 (the handler guard). **No instruction ever writes it non-zero.** The 1995 receiver has NO dedup — single increment/single penalty is guaranteed solely by the SENDER emitting exactly one VehicleDead per death (§3). Implication for #162: in the binary, deaths++ (@0x4c067a) and the penalty (@0x4c07cd) are one straight-line block — "deaths multi-increments while the penalty never applies" is impossible in a faithful transcription; it requires (i) `advancedDamageOn==0`, (ii) an unbound/zero role (+0x20==0 — the pre-2026-08-07 port state), or (iii) a port-side early exit between the two.
|
||||
|
||||
## 3. The sender: `Mech::TakeDamageMessageHandler @0x4a0230` report tail [T1] (part_012.c:14474-14837)
|
||||
|
||||
Runs ONLY on the **victim mech's MASTER node** (replicant Verify at entry, line 0x3da; field: 0/18818 DMG rows inst=R). Per killing/damaging hit:
|
||||
1. **Destroyed-at-entry latch: `local_14 = FUN_0049fb54(this)` (IsDisabled) at ENTRY** — a hit landing on an already-dead mech can never re-send kill/VehicleDead.
|
||||
2. `lastInflictingID(+0x43c) ← msg->inflictingEntityID(+0x1c)` — every hit, last-writer-wins.
|
||||
3. **`damageType(msg+0x2c) == 0` (Collision) → `FUN_0049ffcc` and `goto` the death tail — SKIPS ALL THREE id-0x16 reports.** 1995 ram kills award NO kill score and NO killCount; they still produce VehicleDead → deaths + penalty (state-9 timing inside FUN_0049ffcc not read this pass, [T3]).
|
||||
4. Burst loop applies damage; `local_28` accumulates applied damage + crit contributions; the loop breaks when the mech goes disabled mid-burst.
|
||||
5. Reports, all ScoreMessage id 0x16 size 0x3C `{+0x1c tally, +0x20 type, +0x24 basis, +0x28 vitalHit, +0x2c zone, +0x30 subsysID, +0x34 senderMechID}`:
|
||||
- **KILL EDGE** (alive at entry && disabled after) part_012.c:14685-14708: **type 2 → Dispatch to the SHOOTER's owning player** (`*(shooterMech+0x190)`; a replicant locally → `Entity::Dispatch` reroutes to the shooter's master). `tally = local_28` (applied damage of the volley), **`basis = the VICTIM's own `role->killBonus(+0x1c)`** (the @0x4a0506 read; dfltrole 500), `senderMechID = the victim mech's own ID(+0x184)`.
|
||||
- **Else** (still alive, or wreck-shooting), if `local_28 != 0` (part_012.c:14710-14734): **type 0 → the SHOOTER's player** (tally = basis = local_28) — lands in the 1995 dead/uninit arm (§1).
|
||||
- If `local_28 > 0` (part_012.c:14736-14758, independent — ALSO fires on the kill edge): **type 1 → the VICTIM's OWN player** (local master → synchronous). `tally = local_28`, `basis = burstCount(+0x58) × damageAmount(+0x30)` (nominal volley), `senderMechID = the inflicting entity (msg+0x1c)`.
|
||||
6. **DEATH TAIL** (part_012.c:14760-14835): gate `local_14 == 0 && (this+0x40 == 9 || == 10)` (10 = eject) — the one-shot alive→dead edge. Builds **BT VehicleDeadMessage `{size 0x38, id 0x17, deathCount(+0x1c) = -1, dropZoneID = Null, +0x28 = 0, +0x2c = the killer's PLAYER EntityID (`shooterPlayer+0x184`), +0x34 = kill zone}`** → **Dispatch to the VICTIM's OWN player** (synchronous, master). Then: death-effect broadcast (id 3, 0x5c), a delayed SetBurningState (id 0x17, 0x1c) to the mech itself (delay = `mech+0x430`), watcher sweep, console damage-effect notify (advancedDamageOn && !suppressConsole), `FUN_004a4c54(this,1)`. **Exactly one VehicleDead per death, sender-side guaranteed.**
|
||||
|
||||
## 4. Replication: what rides the Player update record [T0 + T1]
|
||||
|
||||
- `Player::WriteUpdateRecord` (`PLAYER.cpp:645-661` ≡ binary `FUN_0042e2e4`, part_004.c:859-867): `Entity::WriteUpdateRecord`, `recordLength = 0x3c`, **`record+0x2c = currentScore(+0x1c8)`, `record+0x30 = dropZoneLocation(+0x1d0)`** — nothing else. `ReadUpdateRecord` (`PLAYER.cpp:631-641` ≡ `FUN_0042e2ac`) applies the same two.
|
||||
- **BTPlayer does NOT override** — vtable @0x513300 slots 6/7 = 0x42e2ac/0x42e2e4, the engine's (KD plan §1 [T1]).
|
||||
- Therefore in 1995: **`currentScore(+0x1c8)` is the ONLY replicated scoring field.** `killCount(+0x27c)`, `deaths(+0x280)`, respawn `deathCount(+0x200)`, and the bank `+0x278` NEVER cross the wire; each is authoritative (and only meaningful) on the single node where its increment ran. Bystanders show 0/0 K/D for everyone all mission; the two wrong-column partner increments (on replicant copies) are locally visible and never corrected.
|
||||
- `Player::CalcRanking` (`PLAYER.cpp:542-626` ≡ `FUN_0042e1ac`): per-node sort of the local "Players" group **by `currentScore`** → `playerRanking(+0x1cc)`/`playerHighlighted(+0x1c4)`. Runs in Player/CameraShip simulation each frame.
|
||||
- **The scoreboard PilotList `@0x4cabd0`** (part_014.c:7656-7657) draws per row exactly `(float)[player+0x27c]` (KILLS) and `(float)[player+0x280]` (DEATHS) — no score cell in this widget.
|
||||
- **`+0x278` is a pending-score BANK, not the score** (NEW precision): sole writer = the dead `@0x4c0200` (`+0x278 += tonnageRatio × CalcKillScore(...)`, self-negated); the flusher `FUN_004c083c` (part_013.c:19164-19192, called from the BTPlayer master performance `FUN_004c0904`) every **10.0 s** (@0x4c08fc) self-Dispatches a `{0x20, id 0x1a, award = +0x278}` ScoreUpdate then zeroes the bank; handler `@0x4c02a8` = Verify-master + engine fold. Design = replicant-side observed-score banking with reroute-to-owner; dead in 1995 because the only writer is unbound. The periodic console score post (every 15.0 s @0x4c0bc0) reads **+0x1c8** (`param_1[0x72]`, part_013.c:19274).
|
||||
|
||||
## 5. THE 1995 FLOW — one death event (shooter S kills victim V, weapon damage, advanced damage ON)
|
||||
|
||||
On **V's node** (V master), synchronously inside the killing `TakeDamageMessage` (id 0x12) processing:
|
||||
1. `V.mech.lastInflictingID ← S.mech` ; damage applied; tally accumulated.
|
||||
2. **[out] ScoreMessage 0x16 type 2** → dispatched at S.player-replicant → rerouted to S's node (`ENTITY.cpp:244-251`).
|
||||
3. **[sync] ScoreMessage 0x16 type 1** → V.player (master): `V.currentScore(+0x1c8) += (Vt/St) × −(basis × rcvMod)` (0 with dfltrole); console VTVDamaged.
|
||||
4. **[sync] VehicleDead 0x17 (deathCount=−1, killer=S.player, zone)** → V.player (master):
|
||||
a. "KILLED BY" ticker; b. `S.player-replicant.deaths(+0x280)++` (wrong column, local-only); c. **`V.deaths(+0x280)++`** ← THE death count; d. console death notice (unless suppressConsole — eject sets it); e. `V.objectiveMech = S.vehicle`; f. DeathShutdown(0) sweep; g. lives>0 → repost VehicleDead(deathCount=+0x200) to self @ +5 s; h. **`V.currentScore(+0x1c8) −= role.specialCaseDeathPenalty(500)`** — direct engine call, advancedDamageOn-gated. No ForceUpdate anywhere in this block.
|
||||
|
||||
On **S's node**, when the rerouted type-2 executes on S.player (master):
|
||||
5. `award = (killBonus + tally) × dmgInfMod × (V.avgZoneDmg × bias + 1) × (Vt/St)` [or `−ffPenalty` same-team; negated + no counters on suicide]; **`S.killCount(+0x27c)++`**; **`V.player-replicant.killCount(+0x27c)++`** (phantom, local-only); "DESTROYED" ticker; revenge clear-target; `S.currentScore(+0x1c8) += award`.
|
||||
|
||||
Respawn (V's node): +5 s repost → engine `FUN_0042db80` (gate `msg->deathCount == +0x200 && simState != DropZoneAcquired`): closest non-"win" DropZone, `AssignDropZone{reply 0x15, deathCount}` → DropZone dispatches **DropZoneReply 0x15** → `FUN_004bffd0`: stale gate (`msg+0x38 == +0x200`), MissionEnding gate; pass 1 (state≠1) → **`FUN_004c012c`: `deathCount(+0x200)++`, `returnFromDeath--`, ForceUpdate, state←1, location choose, clear +0x290/+0x258, delayed self-repost**; pass 2 (state==1) → state←2, **`Mech::Reset(vehicle, loc, 1)` (`FUN_0049fb74`)**.
|
||||
|
||||
Convergence: peers learn ONLY `currentScore` (and position) from the Player update record; K/D never converge in 1995.
|
||||
|
||||
## Authority table (1995)
|
||||
|
||||
| field | offset | sole writer(s) | node | replicates |
|
||||
|---|---|---|---|---|
|
||||
| currentScore | +0x1c8 | engine `@0x42da20` (BT 0x16 tail all types; death penalty direct; 0x1a flush) | wherever the owning handler runs (master after reroute) | **YES** (only one) |
|
||||
| killCount | +0x27c | 0x16 type-2 arm only (@0x4c0397 + phantom @0x4c03a3) | shooter's master node | no |
|
||||
| deaths | +0x280 | 0x17 −1 arm only (@0x4c067a + wrong-column @0x4c0674) | victim's master node | no |
|
||||
| deathCount (respawn id) | +0x200 | ctor −2; first-spawn =0; `@0x4c012c` ++ | victim's master | no |
|
||||
| score bank | +0x278 | dead `@0x4c0200`; flushed/zeroed `@0x4c083c` | — | no |
|
||||
| deathPending | +0x290 | **never set**; zeroed ctor + `@0x4c01e5` | — | no |
|
||||
|
||||
==============================================================================
|
||||
|
||||
# TASK 3 — The as-built scoring/death chain at HEAD (build 883, post-46ff8dd)
|
||||
|
||||
## 1. Producer: `Mech::TakeDamageMessageHandler` (game/reconstructed/mech.cpp:1031-1344)
|
||||
|
||||
Handler anatomy, in execution order:
|
||||
|
||||
| Step | Site | Notes |
|
||||
|---|---|---|
|
||||
| `destroyedAtEntry = IsMechDestroyed()` | mech.cpp:1039 | #150 entry latch (added in 46ff8dd), sampled at handler ENTRY; `IsMechDestroyed()` = `graphicAlarm >= 9` (mech.hpp:1145, impl mech4.cpp:2215) |
|
||||
| gyro bounce, lastInflictingID/Damage, HUD threat | mech.cpp:1063-1087 | no score effect |
|
||||
| `deathBlastArmed = !(MovementMode()==9 \|\| MovementMode()==10)` | mech.cpp:1119-1120 | the OTHER once-latch, movementMode-based — arms the death tail |
|
||||
| COLLISION DIVERT (`damageType==0`) | mech.cpp:1142-1146 | `DistributeCollisionDamage` then `goto death_tail` — **bypasses the report tail entirely** (a collision death posts VehicleDead but NO 0x16 reports; credits no one — documented authentic, mech.cpp:1130-1140) |
|
||||
| zone/burst application loop | mech.cpp:1193-1241 | accumulates `damageTally` (applied damage + crit bonuses, :1216/:1227), `zoneDestroyed` (:1229) |
|
||||
| **REPORT TAIL master-gate** | mech.cpp:1270 | `if (GetInstance() != ReplicantInstance)` → `BTMechPostCombatReports(this, msg, damageTally, zoneDestroyed, reportZone, !destroyedAtEntry && IsMechDestroyed())` (:1272-1275). The kill flag = the 0→1 edge of `IsMechDestroyed()` across THIS call |
|
||||
| matchlog `DMG` receipt | mech.cpp:1283-1304 | prints `inst=M/R` (:1294) — runs on BOTH instances, ungated |
|
||||
| **DEATH TAIL** | mech.cpp:1335-1343 | `if (deathBlastArmed && IsMechDestroyed())` → `BTMechPostVehicleDead(...)` (:1339) + `BTApplyDeathSplash(this)` (:1342) |
|
||||
|
||||
**The critical asymmetry (46ff8dd scope):** the commit gated ONLY the 0x16 report tail (mech.cpp:1270) and only the report tail got the `destroyedAtEntry` edge-latch. The DEATH TAIL is (a) **not instance-gated** — it runs on replicant victim copies too — and (b) latched on `deathBlastArmed` (movementMode), **not** on `destroyedAtEntry`. On a replicant, movementMode cannot flip to 9/10 until the owner's death update record round-trips (replicants reach mode 9 only via replicated simulationState — mech4.cpp:2721-2725), while `graphicAlarm` IS raised to 9 locally by the damage application itself (mechdmg.cpp:495, :497, :548, :710, :798 — leg-gone/vital-zone/vital-subsystem/mech-kill writers that run wherever damage is applied). So inside the round-trip window **every additional salvo of the killing volley re-enters the death tail on each peer's replicant copy**: `deathBlastArmed`=1 (mode still 1) && `IsMechDestroyed()`=1 → another VehicleDead + another death splash, per salvo, per peer. This is byte-for-byte the same mechanism 46ff8dd proved for the duplicated kill reports ("two type-2 arms 1ms apart, victim inst=R" — commit message) — the fix covered the reports and left the tail.
|
||||
|
||||
## 2. `BTMechPostCombatReports` (game/reconstructed/btplayer.cpp:2574-2691) — every dispatch
|
||||
|
||||
Resolution: `shooter` from registry by `msg->inflictingEntity` (:2595), `shooter_player = shooter mech playerLink` (:2598), `victim_player = victim playerLink` (:2600).
|
||||
|
||||
| Block | Guard | Message | Destination | kill-flag use |
|
||||
|---|---|---|---|---|
|
||||
| **A** kill (:2602-2644) | `if (newly_destroyed)` && `shooter_player != 0` | ScoreMessage id 0x16 **type 2**, scoreAward=damageTally, damageAmount=victim role killBonus (:2612-2626) | `shooter_player->Dispatch(&kill)` (:2627) — replicant on the victim's node → Entity::Dispatch reroutes to killer's host (ENTITY.cpp:244-251) | **consumed** — sole gate. killer==victim IS dispatched (eject/suicide; handler negates) :2608-2610. Else-arm: matchlog `NOCREDIT` (:2633-2644) |
|
||||
| **B** inflicted (:2646-2667) | `else if (damage_tally != 0 && shooter_player != 0)` | id 0x16 **type 0**, award & basis = tally (:2658-2665) | `shooter_player->Dispatch(&inflicted)` (:2666) | **consumed via the else** — the killing hit posts NO inflicted report (kill replaces it) |
|
||||
| **C** received (:2669-2690) | `if (damage_tally > 0 && victim_player != 0)` — NOT an else | id 0x16 **type 1**, award=tally, damageAmount=INTENDED (burstCount×amount) (:2678-2688), senderMechID=INFLICTOR | `victim_player->Dispatch(&received)` (:2689) — master on the victim's own node → local | **ignored** — runs after A too, kills included (binary behavior, :2672-2674) |
|
||||
|
||||
All three sit INSIDE the mech.cpp:1270 master-gate. Per-TakeDamage cadence: B+C once per non-lethal hit, A+C once on the killing hit, B+C again on every post-mortem salvo that still tallies damage on the wreck (destroyedAtEntry=1 → newly=0 → the "inflicted-only path", mech.cpp:1038).
|
||||
|
||||
## 3. `BTMechPostVehicleDead` (btplayer.cpp:2702-2758)
|
||||
|
||||
Builds `BTPlayer::VehicleDeadMessage` id 0x17, 0x38 bytes, killed_by = shooter PLAYER EntityID, killZone (:2748-2752); engine-base `deathCount` ctor-defaults to **-1** (engine/MUNGA/PLAYER.h:111,120; btplayer.hpp:183) → every dispatch takes the consumer's "-1 immediate death notification" arm. Owner resolved via playerLink with the #55 mission-player fallback (:2711-2723); `DEAD_NOTIFY` matchlog (:2724). `owner->Dispatch(&vehicle_dead)` (:2753) — on a peer node `owner` is a replicant player → reroute to the owning host (ENTITY.cpp:244-251). **No instance gate, no once-per-death latch of its own** — it fires as often as the death tail fires (see §1 asymmetry).
|
||||
|
||||
`BTApplyDeathSplash` (mech4.cpp:1382-1428): gates = victim player's `advancedDamageOn` && !`suppressConsole` (:1399; punch-outs never blast), radius/damage authored per chassis; dispatches a type-2 TakeDamage (inflictor = the DYING mech, :1337 via BTSplashCore :1426) to every live mech in radius via `e->Dispatch(&td)` (mech4.cpp:1335-1338). Because the death tail runs on every node's copy, a bystander's master can receive the SAME death blast once locally (its own node's replicant-victim tail) and once over the wire (the victim-master's tail, rerouted) — **each application then posts its own Block-C received award: a structural producer of "damage awards in duplicate pairs"** [T3 inference from as-built structure; not yet log-confirmed].
|
||||
|
||||
## 4. Consumers (btplayer.cpp) — everything that mutates score/killCount/deathCount
|
||||
|
||||
Handler table: btplayer.cpp:346-356 (DropZoneReply, VehicleDead, Score, ScoreInflicted, ScoreUpdate, MissionStarting, MissionEnding).
|
||||
|
||||
### 4a. `BTPlayer::Dispatch` type-0 interceptor (:891-917)
|
||||
Local id-0x16 type-0 dispatches, `GetInstance()==MasterInstance` only (:911) → `ScoreInflictedMessageHandler`; otherwise falls to `Player::Dispatch` (replicant → wire reroute). Wire deliveries NEVER pass through this vtable — they land in the handler table via Receive (:1092-1105).
|
||||
|
||||
### 4b. `ScoreInflictedMessageHandler` (:963-1022) — type 0
|
||||
Verify-asserts type==0 (:971-980); drops if `our_mech==0` (:994); `currentScore += (tonnage ratio) * award`, negated if target==self (:1001-1012); `ForceUpdate()` (:1013); **matchlog receipt `SCORE ... type=0`** (:1018-1021). No instance guard at all.
|
||||
|
||||
### 4c. `ScoreUpdateMessageHandler` (:1032-1046)
|
||||
Replicant check is `Verify(False, ...)` (:1036) then falls through to the base handler (:1045).
|
||||
|
||||
### 4d. `ScoreMessageHandler` (:1057-1279) — the 0x16 type-1/2 consumer
|
||||
- **Replicant guard is a no-op at runtime**: `Verify(False, ...)` (:1061-1067) — and `Verify` compiles to NOTHING: no `DEBUG_LEVEL` is defined anywhere in the build (grep of CMakeLists), so engine/MUNGA/STYLE.H:10-14 defaults DEBUG_LEVEL=0 → DEBUGOFF.h:42 `#define Verify(c)` (empty). Same for :973 and :1036. **If a 0x16 ever reaches a replicant player copy, the handler executes fully on it.** Delivery-side, `EntityManager::ReceiveNetworkPacket` (engine/MUNGA/NTTMGR.cpp:107-149) posts an inbound wire message to whatever LOCAL copy matches the entityID — **no instance check** — so nothing structural prevents that.
|
||||
- MissionEndingState drop (:1073-1076).
|
||||
- **type 0** (:1086-1115): delegates to ScoreInflictedMessageHandler and RETURNS (wire-rerouted inflicted reports land here; :1114-1115).
|
||||
- **type 1** DamageReceived (:1117-1169): award = tonnage-ratio × `scenarioRole->CalcDamageReceivedScore(msg->damageAmount)` — guards sender/our mech non-null, sender≠self, role bound (:1125-1131); self-hit → award stays 0 (deliberate deviation, :1132-1136); console VTVDamaged side-message (:1149-1167).
|
||||
- **type 2** Kill (:1171-1241): award = tonnage-ratio × `CalcInflictedScore(msg->damageAmount, sender_mech, msg->scoreAward)` (:1188-1190); `sender==our_mech → award = -award` (suicide/eject, :1192-1195 — the previous build's measured **-499**); else `++killCount` (:1204) **and `++sender_owner->killCount`** — the binary's wrong-column slip reproduced on the VICTIM's (replicant) player copy (:1206, banner :2376-2389); status ticker (:1219-1228); objective notify (:1234-1240).
|
||||
- Common tail: `message->scoreAward = award` (:1252); **matchlog `SCORE ... type=<1|2>`** (:1257-1262); `currentScore += award` — via direct add if vehicleless (:1264-1268) else `Player::ScoreMessageHandler` (:1270); `ForceUpdate()` (:1278).
|
||||
|
||||
### 4e. `VehicleDeadMessageHandler` (:418-771) — the deathCount writer
|
||||
- MissionEndingState swallow (:426-439).
|
||||
- `deathCount != -1` branch (:441-539): the +5s re-post / -2 probe → drop-zone hunt via engine base (:537). Moot if mech alive again (:452-462).
|
||||
- **`deathCount == -1` arm** (:541-771), the death notification. Dedup gate `if (deathPending != 0) return` (:546-558) — **but `deathPending` is SET at :559 and CLEARED at :768 inside the SAME synchronous invocation** (the #81 fix moved the clear here, banner :739-767). A queued/wire duplicate arriving even 1ms later sails through. Per accepted -1 message: `++deathCount` (:582), `message->deathCount = deathCount` (:583), `++deathTally` (:592), **matchlog `PLAYER_DEAD`** (:601-602), life debit (:606-610), ForceUpdate (:634), `killerName` (:673), +5s respawn re-post (:690-695), **THE DEATH COST** (below), latch clear (:768), `suppressConsole=0` (:770). **No instance guard anywhere in this handler.**
|
||||
|
||||
### 4f. The -500 deathPenalty — the ONLY site (:698-736)
|
||||
```
|
||||
if (advancedDamageOn && scenarioRole != 0) // :726
|
||||
ScoreMessage death_cost(ScoreInflictedMessageID /*0x16*/, ..., type 1,
|
||||
-scenarioRole->GetSpecialCaseDeathPenalty(), 0.0f, EntityID::Null); // :728-734
|
||||
Player::ScoreMessageHandler(&death_cost); // :735 DIRECT base call
|
||||
```
|
||||
Properties: applied via a DIRECT engine-base call (PLAYER.cpp `currentScore += scoreAward`) — **never dispatched, never on the wire, produces NO `SCORE` matchlog receipt** (only the running total moves; acknowledged at :712-715). The one witness is the **ungated `[deathcost]` DEBUG line** (:716-724) printing `advDmg=/role=/penalty=/scoreBefore=/APPLYING|SKIPPED`. Gates: `advancedDamageOn` — assigned ONLY in the ctor's MASTER branch from `bt_mission->AdvancedDamageOn()` (:2137-2138); on a replicant player copy it is **never assigned** (ctor does not zero it; :1944-1947 zero only console/score/deathPending) [T3: indeterminate there]. `scenarioRole` — bound for both instances with a `Role::Default` fallback (:2066-2084).
|
||||
|
||||
### 4g. `DropZoneReplyMessageHandler` (:1743-1920)
|
||||
`deathCount = 0` on vehicle create (:1798); stale-reply discard when `message->deathCount != deathCount` (:1835-1867 — duplicate deaths that bump deathCount between the re-post and the reply strand the respawn here); `RESPAWN` matchlog (:1906).
|
||||
|
||||
### 4h. Scoreboard replication (:2391-2477)
|
||||
`WriteUpdateRecord` ships killCount/deathTally (:2415-2416); `ReadUpdateRecord` mirrors onto replicants only (:2450-2458), `SBMIRROR` receipt (:2467). **`deathCount` (the respawn-handshake identity) is NOT replicated — only deathTally is** — so replicant-side deathCount writes are never corrected.
|
||||
|
||||
## 5. `MechDeathHandler` (game/reconstructed/mechdmg.cpp:1208-1363+)
|
||||
Nothing score- or count-adjacent: skate detector (:1233-1313), ghost detector (:1315-1342), replicant un-wreck on the mode-9/2 exit edge (:1344-1360). The death-relevant writes in mechdmg.cpp are the `graphicAlarm.SetLevel(9)` sites (:495, :497, :548, :710, :798) which flip `IsMechDestroyed()` wherever damage is applied — including on replicant copies — which is what re-arms the ungated death tail (§1).
|
||||
|
||||
## 6. Every site that can run MORE THAN ONCE per death (vs a once-per-event model)
|
||||
|
||||
1. **Death tail on replicant victim copies** (mech.cpp:1335-1343): ungated by instance, latched on movementMode which is frozen on replicants for the whole death-record round-trip → **one VehicleDead + one death splash per salvo per peer node**. The exact #150 mechanism, un-fixed for the tail. → explains deathCount 5→8, duplicates 1ms apart / same-ms ×N (each duplicate reroutes to the victim's master and is counted).
|
||||
2. **VehicleDeadMessageHandler -1 arm has no effective dedup** (btplayer.cpp:546/559/768): the latch is raised and lowered inside one synchronous body, so every queued duplicate increments deathCount/deathTally, debits a life, posts another +5s re-post, and (when its gates pass) applies another death cost.
|
||||
3. **Death splash multi-application** (mech4.cpp:1382-1428 via mech.cpp:1342): fires from the master's tail AND from every peer replicant's tail (×salvos) → bystander masters take the blast ≥2× → **paired type-1 damage awards** (§3) [T3].
|
||||
4. **Post-mortem salvos on the wreck**: each still posts Block B+C (mech.cpp:1038 "inflicted-only path"; btplayer.cpp:2646-2690) — by design, but it means received penalties continue after death.
|
||||
5. **Replicant execution of the 0x16 consumers is unguarded at runtime**: `Verify` is compiled empty (DEBUG_LEVEL=0 → DEBUGOFF.h:42), VehicleDeadMessageHandler has no guard at all, and NTTMGR.cpp:107-149 delivers wire messages to the local copy by entityID with no instance check.
|
||||
6. **`++sender_owner->killCount` on the victim's replicant player copy** (btplayer.cpp:1206) — bounded by the SBMIRROR correction (banner :2376-2389), kills currently read exact in the field.
|
||||
|
||||
## 7. Why the -500 can read "never applies" — as-built observations (mechanism NOT proven here)
|
||||
The penalty has exactly one producer (§4f) and it is receipt-less: no matchlog row, no wire message — a duplicate-VehicleDead storm shows up as PLAYER_DEAD rows while the penalty leaves only the `[deathcost]` line and the raw total. As-built, the arm CANNOT be reached without also incrementing deathCount (:582 precedes :726 in the same arm), so "deathCount multi-increments AND penalty never applies" requires the :726 gate to fail on every accepted -1 — i.e. field `[deathcost]` lines reading `advDmg=0` or `role=NULL -> SKIPPED`, or the -1 arms executing on player copies whose `advancedDamageOn` was never assigned (§4f). The previous build's -499 eject proof exercised the **type-2 suicide negation** (btplayer.cpp:1192-1195) plus the death cost (46ff8dd message: "1000 - ~999 - 500 = -499"), so the discriminator for build 883 is the ungated `[deathcost]` line and the `[exp] master player ... advDamage=` ctor line (:2141-2145) in the field logs.
|
||||
|
||||
## 8. Cross-node routing spine (for the graph)
|
||||
- `Entity::Dispatch` on a replicant reroutes to the owner host and does NOT execute locally (engine/MUNGA/ENTITY.cpp:235-272).
|
||||
- Wire arrival: `EntityManager::ReceiveNetworkPacket` posts to the local copy by entityID, handler table via Receive — bypassing the `BTPlayer::Dispatch` type-0 interceptor (btplayer.cpp:1092-1105) and any instance discipline (NTTMGR.cpp:107-149).
|
||||
- Matchlog receipt map: `DMG` mech.cpp:1290 · `SCORE type=0` btplayer.cpp:1018 · `SCORE type=1/2` :1258 · `PLAYER_DEAD` :601 · `DEAD_NOTIFY` :2724 · `NOCREDIT` :2637 · `SBMIRROR` :2467 · `RESPAWN` :1906 · `PROJ` mech4.cpp:2001 · `SPLASH` mech4.cpp:1349. The death cost has NO tag.
|
||||
@@ -422,3 +422,10 @@ positions. Consequences for this plan:
|
||||
- §Headline-6's wrong-column slip (dual +0x27c increment) remains faithfully reproduced and
|
||||
replication-masked. The type-1 received report also fixes the console VTVDamaged
|
||||
points_transfered field (was a Now() misread; it is Round(award), raw disasm @0x4c04a7).
|
||||
|
||||
|
||||
## 2026-08-11 -- SETTLED
|
||||
|
||||
The chain was audited end-to-end and fixed at the authority model (ticket #162, build 4.11.885). The definitive references now live in
|
||||
`context/combat-damage.md` (curated model) and `docs/KD_CHAIN_AUDIT_20260811.md`
|
||||
(complete evidence). This plan file is historical from here on.
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
# The mission-end / exit-hang audit -- full findings (2026-08-11, ticket #163)
|
||||
|
||||
> Four-agent audit of the round-end freeze + hang-on-exit family. VERDICT:
|
||||
> the mission-end ladder is CLEAN on every machine (StopMission never even
|
||||
> fires in steam fields -- each pod self-ends on its local clock); the bug
|
||||
> lives in the unlogged exit window: bare ExitProcess with no SteamAPI_Shutdown
|
||||
> anywhere in the codebase -> intermittent DLL-detach deadlock -> the dead
|
||||
> process's fullscreen frame squats on screen while its menu child hangs in
|
||||
> SteamAPI_Init against the wedged sibling (field-captured: ZEUS 22:12:38).
|
||||
> Fix shipped same day: BTSteamNet_ShutdownAll + ordered exit (flush, steam
|
||||
> down BEFORE child spawn, hide windows, TerminateProcess) at every exit path.
|
||||
> Side-yield: #156's fade root cause (POVStartEndRenderable ctor is an empty
|
||||
> stub, btstubs.cpp:339) + the complete binary end-of-mission ladder.
|
||||
|
||||
# #163 — Task 2: The end-of-mission ladder as built, every stall-capable site flagged
|
||||
|
||||
Stall-capable sites are tagged **[S#]**. Evidence tiers: [T0]=our engine source read directly; claims about runtime behavior of third-party code marked [T4].
|
||||
|
||||
## Stage A — the HOST marshal (game/glass/btl4console.cpp)
|
||||
|
||||
**Arming.** btl4main.cpp:1322-1330 — if `BT_FE_EGG` is set, `BTLocalConsole_Start(fe_egg, BT_FE_PODS, atoi(BT_FE_SECS))` runs. For Steam hosting the menu sets `BT_STEAM_NET=1`, `BT_FE_MYFAKE`, `BT_FE_STEAMMAP` and *falls into the marshal-armed default* (btl4main.cpp:1284-1296). `BTLocalConsole_Start` (btl4console.cpp:588-606) copies the args into `marshalState` and `CreateThread(MarshalThread)`; the handle is closed immediately (:604) — **the marshal thread is never joined by anything**.
|
||||
|
||||
**MarshalThread** (btl4console.cpp:434-586), step by step:
|
||||
1. Read the egg (:440-446). Failure → `MarshalLog("cannot read egg...")` and the thread just returns — the pods sit at WaitingForEgg forever **[S0]** (not tonight's shape, but a stall).
|
||||
2. Connect every pod in `BT_FE_PODS` order, **self first**, sequentially (:452-482): `ConnectWithRetry(host, port, 60)` (:388-432). The Steam branch calls `BTSteamNet_Connect` which **blocks up to 30 s per attempt** (L4STEAMNET.cpp:595-609) — worst case 60×~31 s ≈ 31 min per pod **[S1]**. Any pod that never answers → `BTFE_RelaunchSelfAndExit("")` — the whole host process aborts the start (:473-476).
|
||||
3. Egg → every pod (:492-497) via `SendEggChunks` (:334-363). The Winsock branch uses **blocking** `send()` — the marshal's raw sockets are never set FIONBIO (only TCP_NODELAY, :423-425) **[S2]**. Errors surface only as a `-1` chunk count in the log; no retry.
|
||||
4. `Sleep(20s)`; RunMission #1 to all; `Sleep(4s)`; RunMission #2 to all (:500-512). Send results **ignored**.
|
||||
5. **The mission clock** (:519-570): `mission_end = GetTickCount() + missionSeconds*1000`; loop drains and discards pod→console traffic (select() for real sockets, a nonblocking `BTSteamNet_Recv` pass for Steam pseudo-sockets :559-569). Note: a Steam connection that dies mid-mission is **invisible** here — `closedByPeer` makes Recv return 0/err and the loop just moves on; nothing logs or repairs it.
|
||||
6. **StopMission at expiry** (:572-576):
|
||||
```
|
||||
MarshalLog("mission clock expired -- StopMission to all pods");
|
||||
for (int p = 0; p < pod_count; ++p)
|
||||
SendApplicationMessage(pods[p], StopMissionMessageID);
|
||||
```
|
||||
`SendApplicationMessage` (:369-382) writes one 28-byte packet (header clientID=4/ApplicationClientID, msg id 6, ReliableFlag). **One shot per pod. No retry. No acknowledge. The return value is ignored. There is no per-pod success/failure log** — only the single "clock expired" line **[S3 — the silent-drop site]**. Over the Steam seam, `BTSteamNet_Send` (L4STEAMNET.cpp:424-442) returns SOCKET_ERROR if the connection is `closedByPeer` (WSAECONNRESET) or if `SendMessageToConnection` fails (WSAEWOULDBLOCK) — all swallowed. Over raw TCP the blocking `send` could also wedge the loop between pods so *later pods in podList never get their stop* **[S4]** (needs a full send buffer; unlikely for 28 bytes).
|
||||
7. `Sleep(MissionEndGraceSeconds=8s)` (:577), `MarshalClose` every pod (:578-581), `BTFE_RelaunchSelfAndExit("")` (:583-584) → Stage D. During the 8 s grace the marshal **no longer drains** pod traffic (pods push an immediate console score update in EndingMission — btplayer.cpp:1361-1366).
|
||||
8. **Host-side race** **[S5]**: the host's *own* pod (self, first in podList) receives StopMission over its loop link, finishes its fade in ~3-4 s, and the MAIN thread reaches the BT_FE_LOOP relaunch (btl4main.cpp:1669-1674) → `ExitProcess` — *while the marshal thread is still inside its 8 s grace sleep*. Whichever thread reaches `ExitProcess(0)` first kills the other mid-flight; if the main thread is slow (long Shutdown) both can pass `CreateProcessW` → two menu children. Nothing serializes the two relaunch paths.
|
||||
|
||||
## Stage B — the receiving pod: StopMission → RunMissions returns
|
||||
|
||||
**Delivery.** The 28-byte packet is routed by `networkManager->RoutePacket()`, pumped at least once per frame from `Application::ExecuteBackgroundTask` (APP.cpp:771-781; in non-Running states it routes greedily). Steam-carried console bytes are drained lazily by the seam's recv (L4STEAMNET pump is called from the seam entries).
|
||||
|
||||
**Handler chain**: `BTL4Application::StopMissionMessageHandler` (game/reconstructed/btl4app.cpp:765-781, @004d3a94 — plasma display off) → `L4Application::` (L4APP.cpp:693-732 — `PilotIllumination(True)` + a LightsOut self-post at +30 s) → `Application::` (APP.cpp:1706-1778):
|
||||
- `gBTMissionStoppedByConsole = 1` (:1723-1726); if state==RunningMission: `gBTRoundCompleted=1`, launch pends voided (:1733-1739).
|
||||
- switch (:1746-1777): `StoppingMission` → ignore; `EndingMission`/`AbortingMission` → `Stop()`; **default** → `SetState(EndingMission)`, `networkManager->Mode(ReliableMode)` (:1762), dispatch `Player::MissionEndingMessage` to `GetMissionPlayer()` (:1759-1769); **if the mission player is NULL → `Stop()` immediately** (:1771-1774).
|
||||
|
||||
**The fade.** `Player::MissionEndingMessageHandler` (engine/MUNGA/PLAYER.cpp:106-113): `fadeTimeRemaining = 3.0f; ForceUpdate(); SetSimulationState(MissionEndingState)`. **BTPlayer has NO override** — `MESSAGE_ENTRY(BTPlayer, MissionEnding)` (btplayer.cpp:355) resolves to the inherited Player handler; the "fade ForceUpdate site" named in the task brief is PLAYER.cpp:110.
|
||||
|
||||
**EVERY condition required to progress from EndingMission to `Stop()`** — the countdown lives ONLY in `Player::ManageApplicationStatus` (PLAYER.cpp:408-476; the MissionEndingState arm :444-472 counts `fadeTimeRemaining -= time_slice` and, at ≤0 with app state EndingMission, dispatches `StopMissionMessage(NullExitCodeID)` back at the application :451-456):
|
||||
1. A mission player must exist (else Stop() already ran — faster, not a stall).
|
||||
2. `ManageApplicationStatus` is reached ONLY from the player's active Performance: `Player::PlayerSimulation` (PLAYER.cpp:505, called from `BTPlayer::PlayerSimulation` btplayer.cpp:1329) or `CameraShipSimulation` (PLAYER.cpp:486). **If the mission player's Performance is `HuntForDropZone` (PLAYER.cpp:768 — no MAS call) or `DoNothingOnce` (set at PLAYER.cpp:380 once a dropzone answered the initial hunt; `DoNothingOnce` = `NeverExecute()`, SIMULATE.cpp:484-488 — the entity STOPS EXECUTING ENTIRELY), the fade NEVER counts down and the app sits in EndingMission forever** **[S6 — hard in-engine stall]**. This window exists from mission start until `DropZoneReply` creates the vehicle and sets PlayerSimulation + `AlwaysExecute()` (btplayer.cpp:1771/1780/1784). After that, the reset-based respawn keeps the same Performance through death, so a dead-at-mission-end pilot still fades normally.
|
||||
3. Fade 3 s of simulated time must elapse; then the re-dispatched StopMission lands in the `EndingMission` case → `Stop()` (APP.cpp:1751-1754) → `executeFrames = False` + `SetState(StoppingMission)` (APP.cpp:818-820).
|
||||
- There are **no queue-drain gates, no all-entities gates, no peer gates, and no other timers** in this leg.
|
||||
|
||||
**Backstop worth knowing**: the solo-clock shim (APP.cpp:674-695) is **ACTIVE in Steam matches** — its only gate is `getenv("BT_RELAY") == 0`, and glass/steam mode does not set BT_RELAY; the glass egg writes `length=` (btl4fe.cpp:277) → `Mission::gameLength > 0` (MISSION.cpp:336-337). So a pod that never receives the marshal's StopMission still self-posts one when its own clock (started at RunningMission entry, APP.cpp:1559) expires. A *lost* StopMission alone therefore should not strand a pod in RunningMission [T0 code-read; runtime unverified].
|
||||
|
||||
**RunMissions returning** (engine/MUNGA/APPMGR.cpp:39-273): next frame `ExecuteForeground` returns False (APP.cpp:516-519) → APPMGR calls `application->Shutdown()` (APPMGR.cpp:77-91). `Application::Shutdown` (APP.cpp:828-927): gauge/video/audio `Shutdown()`, viewpoint delete, `interestManager->Shutdown()`, `hostManager->Shutdown()`, `networkManager->Shutdown()` — L4NetworkManager::Shutdown (L4NET.CPP:1296-1411) closes every non-console host via `CloseConnection` = `shutdown(SD_BOTH)` + `BTNetClose` (L4NET.CPP:4658-4659), all nonblocking (engine sockets are FIONBIO throughout, e.g. L4NET.CPP:4554-4558) — deletes the mission, and **returns False** (APP.cpp:925). App is moved to the ended list; the next loop pass finds no apps → `Terminate()` (APP.cpp:934+ — deletes renderers/managers; D3D/DirectSound teardown lives here **[S7, T4]**) → `delete application` → **RunMissions returns** (APPMGR.cpp:128-138). Alternate exit: WM_QUIT (window destroyed) → Terminate + return (APPMGR.cpp:102-115).
|
||||
|
||||
## Stage C — after RunMissions returns (game/btl4main.cpp:1618-1684)
|
||||
|
||||
- :1619 `"[boot] RunMissions returned (mission loop exited)."` — **the key forensic marker** separating a Stage-B stall from a Stage-C/D hang.
|
||||
- :1623-1626 `BTProjectilesClearAll()` — static pool scrub, memory-only.
|
||||
- :1632-1635 `BTRelayUploadMatchLog()` — **relay-only**: gated on `s_relayGameAddrCached` (L4NET.CPP:707-708), a **no-op on Steam nights**. When it does run: bounded 3 s nonblocking connect (L4NET.CPP:738-753), but then an **unbounded blocking SendAll** (L4NET.CPP:770) **[S8, relay only]**.
|
||||
- :1645-1660 BT_RELAY rejoin — not taken under Steam.
|
||||
- :1669-1674 `BT_FE_LOOP` set (it always is on menu-launched missions, btl4main.cpp:1248) → `BTFE_RelaunchSelfAndExit("")`.
|
||||
|
||||
## Stage D — the exit/relaunch choke point (btl4console.cpp:156-276)
|
||||
|
||||
1. `gBTUserRequestedExit` (WM_CLOSE stamp, btl4main.cpp:164-172) → `ExitProcess(0)` directly (:171-181).
|
||||
2. Storm damper: generation <15 s → `Sleep(5000)` (:193-202).
|
||||
3. Menu relaunch clears BT_FE_EGG/PODS/SECS/LOOP from the env (:211-217).
|
||||
4. `CreateProcessW` of the menu child (:259-274). **A CreateProcessW failure is completely silent — no log, no retry — and control still falls to ExitProcess: window closes, no menu appears** **[S9]**.
|
||||
5. `ExitProcess(0)` (:275) — **the exit-hang site [S10]**. ExitProcess terminates all other threads (the marshal, or the main thread if the marshal got here first) then runs DLL_PROCESS_DETACH serially under the loader lock. **`SteamAPI_Shutdown()` is never called anywhere in the tree** (grep hits only the SDK header, extern/steamworks_sdk_164/public/steam/steam_api.h:107) — so steam_api64/steamclient are detached with live SDR connections and their service threads hard-terminated; a detach-time deadlock there is the standing hypothesis for "BTL4 hang on exit, hosting" [T4 — inference, matches the host being the machine that hosts the most Steam connections]. D3D/driver DLL detach is the sibling candidate [T4].
|
||||
6. Because every glass-path exit is `ExitProcess`, **CRT atexit/static dtors are skipped** — the RIO dtor's bounded 5 s flush (L4RIO.cpp:1160-1184) and `PCSerialPacket::ShutdownRxThread`'s **unbounded** `WaitForSingleObject(hRxThread, INFINITE)` (L4PCSPAK.cpp:204) **[S11]** only run on the plain `return Exit_Code` path (btl4main.cpp:1683, taken only when BT_FE_LOOP is unset) — and only in serial-RIO builds; glass uses PadRIO which owns no thread (no CreateThread in L4PADRIO.cpp).
|
||||
|
||||
## Stage E — is there any all-pods handshake at mission end? NO.
|
||||
|
||||
- Start side has three gates: the egg acknowledge (`AcknowledgeEggFileMessage`, L4NET.H:276/424-433), the READY notify (APP.cpp:1466-1470), and the connection gate (L4NET.CPP:1957 comment). **The end has none**: no end-acknowledge message exists (grep across engine/), the marshal reads nothing back after StopMission, and pod teardown never waits on a peer.
|
||||
- `HostDisconnected` during EndingMission: GameMachineHostType arm just decrements the count and closes (L4NET.CPP:1672-1677) — no state change, no wait. Console loss mid-mission in glass/steam (non-relay, scene presented): the ConsoleHostType arm recreates the console listener and **continues the mission** (L4NET.CPP:1686-1753) — the relay-mode 15 s graceful self-stop (RelayGameDown, L4NET.CPP:2458-2472) does NOT apply outside BT_RELAY.
|
||||
|
||||
## Cross-cutting observations relevant to the incidents
|
||||
|
||||
- **Thread-safety hole [S12]**: `BTSteamNet_Pump` (SteamAPI_RunCallbacks + unlocked `connections[].ring` writes, L4STEAMNET.cpp:244-306) is invoked from BOTH the marshal thread (via BTSteamNet_Send/Recv/Connect — btl4console.cpp:119-124, 564) and the game thread (seam entries; L4STEAMNET.h:34 documents "game thread"). No lock anywhere. Concurrent RunCallbacks + ring index races are possible on the HOST only (the only process with a marshal) [T0 for the code paths; consequences T4].
|
||||
- **Frozen-view interpretation** [inference]: a stalled EndingMission does NOT freeze the picture — ExecuteForeground has no EndingMission early-out, the update manager keeps simulating and the renderers keep presenting (APP.cpp:560-565 only overlays pre-run states). A literally static final frame with no menu means the frame loop *stopped* while the window lived: i.e., RunMissions returned (or Terminate/Shutdown hung) and the process then wedged in Stage C/D — most plausibly at ExitProcess [S10] or silent CreateProcessW failure [S9]. Log triage for the frozen machines: `"[boot] RunMissions returned"` present → Stage C/D hang; absent but `StopMission`/plasma-off lines present → Stage B stall ([S6]/[S7]); no stop lines at all → the marshal's send was silently dropped ([S3]) and the solo-clock backstop should then have fired ~launch-skew seconds later — if it didn't, check `length=` in the session egg.
|
||||
|
||||
## Stall-site index
|
||||
| # | Site | File:line | Bound |
|
||||
|---|------|-----------|-------|
|
||||
| S0 | egg unreadable → marshal thread exits, pods stranded | btl4console.cpp:440-446 | forever |
|
||||
| S1 | ConnectWithRetry, Steam branch | btl4console.cpp:388-408 + L4STEAMNET.cpp:595-609 | ~31 min/pod |
|
||||
| S2 | blocking egg/TCP sends (marshal sockets never nonblocking) | btl4console.cpp:334-363, 409-431 | OS send timeout |
|
||||
| S3 | StopMission one-shot, errors swallowed, no per-pod log | btl4console.cpp:572-576, 369-382; L4STEAMNET.cpp:424-442 | n/a (silent drop) |
|
||||
| S4 | blocking StopMission send can starve later pods | btl4console.cpp:573-576 | TCP buffer dependent |
|
||||
| S5 | main-thread relaunch races marshal's 8 s grace; dual relaunch | btl4main.cpp:1669-1674 vs btl4console.cpp:577-584 | race |
|
||||
| S6 | fade counts only in PlayerSimulation; DoNothingOnce=NeverExecute strands EndingMission | PLAYER.cpp:380, 444-472; SIMULATE.cpp:484-488 | forever |
|
||||
| S7 | Terminate/Shutdown renderer teardown (D3D/DSound) | APP.cpp:838-865, 944-973 | unbounded [T4] |
|
||||
| S8 | matchlog upload blocking SendAll (relay only) | L4NET.CPP:770 | TCP dependent |
|
||||
| S9 | CreateProcessW failure silent, no menu | btl4console.cpp:259-274 | n/a |
|
||||
| S10 | ExitProcess w/ live SteamAPI (no SteamAPI_Shutdown anywhere), DLL detach | btl4console.cpp:275; steam_api.h:107 | unbounded [T4] |
|
||||
| S11 | PCSerialPacket join INFINITE (static-dtor path only, non-glass) | L4PCSPAK.cpp:204 | unbounded |
|
||||
| S12 | unsynchronized two-thread Steam pump on the host | L4STEAMNET.cpp:244-306; btl4console.cpp:119-124 | corruption risk |
|
||||
|
||||
==============================================================================
|
||||
|
||||
# Task 4 — Steam console-link delivery of StopMission (ticket #163)
|
||||
|
||||
## 1. The exact delivery chain for StopMission under Steam
|
||||
|
||||
**Dial timing — at GO, once, never again.** The host's mission process arms the marshal thread at boot (`game/btl4main.cpp:1322-1329` → `BTLocalConsole_Start`, `game/glass/btl4console.cpp:588-606`). `MarshalThread` dials EVERY podList entry up front, serially, before anything else: `ConnectWithRetry(host, port, 60)` at `btl4console.cpp:470`. For a fake-IP entry the Steam branch (`btl4console.cpp:391-408`) calls `BTSteamNet_Connect` up to 60 times, 1 s apart; each attempt itself blocks up to 30 s in the SDR handshake wait (`engine/MUNGA_L4/L4STEAMNET.cpp:595-609`) — worst case ~31 min of near-silence before the abort at `btl4console.cpp:473-476` (which aborts the mission start for EVERYONE via relaunch). [T1 — read from our own source]
|
||||
|
||||
**Token resolution.** `BTSteamNet_Connect` maps the token IP to a SteamID64 via the `tokens[]` table (`L4STEAMNET.cpp:556-568`), loaded ONCE per process at `BTSteamNet_Install` from env `BT_FE_STEAMMAP` (`L4STEAMNET.cpp:354-384`); port 1501 selects P2P virtual channel 0 = console (`L4STEAMNET.cpp:569`, `TokenConsolePort` at `:67`). The connection is a `ConnectP2P` handle wrapped in a pseudo-SOCKET (`0x5EA0xxxx`, `L4STEAMNET.cpp:93-97`).
|
||||
|
||||
**Send path — RunMission and StopMission ride the SAME connection.** Egg chunks (`SendEggChunks`, `btl4console.cpp:334-363`), RunMission #1 (+20 s), RunMission #2 (+4 s), then the marshal holds the clock (`:519-570`) draining pod→console bytes, then StopMission at clock expiry (`:572-576`), 8 s grace, then `MarshalClose` all pods (`:577-581`) and relaunch (`:583-584`). There is **no re-dial, no ack, no health check anywhere** — StopMission depends on the connection dialed at GO still being alive ~10 min later. Wire message: 28-byte packet, clientID=4 (ApplicationClientID), messageID=6 (`StopMissionMessageID`, `btl4console.cpp:75`, per `APP.h:383`), sent via `BTSteamNet_Send` → `SendMessageToConnection(..., k_nSteamNetworkingSend_ReliableNoNagle)` (`L4STEAMNET.cpp:433-435`).
|
||||
|
||||
## 2. Every silent-failure point in that chain
|
||||
|
||||
1. **Send results are discarded.** `SendApplicationMessage` returns -1 on failure but the return is ignored at all three call sites (`btl4console.cpp:503, 509, 575`). A failed StopMission produces NO log line; the marshal logs only the aggregate "StopMission to all pods" (`:572`) BEFORE sending. [T1]
|
||||
2. **Send-on-dead-connection is an error only if the death was already noticed.** `BTSteamNet_Send` returns SOCKET_ERROR/WSAECONNRESET only when `closedByPeer` was set by the status callback (`L4STEAMNET.cpp:428-431`), and SOCKET_ERROR/WSAEWOULDBLOCK when `SendMessageToConnection` fails (`:436-440`) — both swallowed by (1). Worse, a reliable send on a dying-but-not-yet-flagged connection returns `k_EResultOK`, buffers the bytes, and they are dropped when the connection is later closed — **undetectable even if (1) were fixed**. [T4 — SDK semantics, not exercised in a bench]
|
||||
3. **The marshal never learns a console link died mid-match.** The clock-hold drain loop treats Steam recv()==0 (dead: `L4STEAMNET.cpp:459-462`) identically to -1/no-data (`while (BTSteamNet_Recv(...) > 0)`, `btl4console.cpp:564`); the real-socket path likewise ignores `recv`==0 (`:550`). No close callback reaches the marshal; the only trace is the engine-side `[steamnet] connection N closed (…)` line printed from the status callback during a pump (`L4STEAMNET.cpp:219-231`). No reconnect logic exists. [T1]
|
||||
4. **Mesh/Steam pods have NO console-loss StopMission fallback.** The 15 s self-stop on console loss is RELAY-ONLY (`RelayGameDown`, `engine/MUNGA_L4/L4NET.CPP:2456-2472`). In mesh/steam mode a `ConsoleHostType` disconnect just destroys and re-listens the console host (`L4NET.CPP:1686-1752`, `CreateConsoleHost` at `:885-1023`) — a pod that loses its console link mid-match plays a **forever-mission**: peers time out and freeze as stale replicants — literally "a frozen view of how the game ended" with no menu transition. [T1 code + T2 field, below]
|
||||
5. **Pump concurrency hazard (host only).** `BTSteamNet_Pump` mutates the global `connections[]`/rings and runs `SteamAPI_RunCallbacks` with zero locking (`L4STEAMNET.cpp:244-306`), and on the HOST it is called concurrently from the marshal thread (via Send/Recv/Connect, e.g. `btl4console.cpp:564`) and the main game thread (via the L4NET seam, `L4NET.CPP:48-96`). Only the host has this exposure. [T1 for the race's existence; T3 for consequences]
|
||||
6. **Accept is channel-blind.** `BTSteamNet_Accept` ignores which listener polled and dequeues either channel (`L4STEAMNET.cpp:476-501`); the console-host accept path trusts "whatever connects to a console host" (`L4NET.CPP:3876-3894`). Benign so far, but a game-channel dial can be adopted as the console. [T1, no field hits]
|
||||
7. **Second-round state:** the connection table is per-process (fresh each round — every round is a new process), and mission children get fresh `BT_FE_MYFAKE`/`BT_FE_STEAMMAP` at GO (`btl4main.cpp:1279-1287`). But a MENU relaunch clears only `BT_FE_EGG/PODS/SECS/LOOP` (`btl4console.cpp:211-217`) — menu children carry the PREVIOUS round's token map (visible in the field: relaunched menus log "N roster token(s) incl. self" at boot). The real cross-round asymmetry found is **zombie processes**: a round-N process wedged at exit stays Steam-online into round N+1 (see acaci's ghost lobby seat, below). [T1/T2]
|
||||
|
||||
**Receiving side (pod).** `CreateConsoleHost` listens on 1501 (`L4NET.CPP:962-993`) — under Steam the marshal's dial arrives via the P2P channel-0 listen socket, is accepted inside the status callback (`L4STEAMNET.cpp:189-203`), queued, and dequeued by the console host's `BTNetAccept` poll (`L4NET.CPP:3814-3821`); the console host goes OnLine (`:1593-1595`, logs "Connected to ConsoleHost at <token .1>:1501"). Marshal-side death is detected only by `BTNetRecv` returning 0 (or WSAECONNRESET→0) during `CheckBuffers` (`L4NET.CPP:3989-4019`). At mission end the pod does NOT close the console link — teardown explicitly keeps the console host (`L4NET.CPP:1367-1370`); the MARSHAL closes it 8 s after StopMission (`btl4console.cpp:577-581`), which the pod handles as a normal console disconnect + re-listen. So there is no close-while-the-other-side-needs-it on the pod side. [T1]
|
||||
|
||||
## 3. Field evidence (scratchpad/night15, six logs; frozen = santo/MS-FIREFLY, acaci/ZEUS; Lynx has NO log)
|
||||
|
||||
**StopMission WAS delivered to every logged pod in both incident rounds.** Every mission session on santo ends with the full clean tail `[boot] RunMissions returned` + `[fe] mission over -- relaunching the menu` (14/14, e.g. santo:57956-57957 for the round ending 21:40:50 — the 21:41 incident round — and santo:201693 for the round ending 22:47:21 — the 22:48 incident round). Michael (d:153225-153226) and Dave (a: tail before 218096) likewise ended the 22:47 round cleanly. **No `[steamnet]` close, no ConsoleHost disconnect, no PEER_DOWN anomaly at either incident round-end on any logged machine.** The freeze on the logged machines happened AFTER the last log line of a clean in-process mission end — i.e., in the relaunch/exit seam, NOT in Stop delivery. [T2]
|
||||
|
||||
**But genuine console-link death happened that night — twice — and its signature matches the reported symptom exactly.** Mid-round, all pods lost BOTH links to the host token (.1) with `[steamnet] connection N closed (Timeout; remote problem. Rx age server 12-21s ...)` followed by `Disconnected from ConsoleHost at 169.254.77.1:1501`: eleng f:22366-22372 (+ a second episode f:50374-50455) and the santo(e:35887-35922)/rajel(b:20340-20416) round (same round; rajel's clock is -3 h). After the loss the pod keeps simulating with no console and no fallback (finding 4): peers ghost out (`[ghost] replicant 5:29 has received NO update records for 601 frames`, f:~22434) and the mission never ends — each player eventually closed the window by hand (`[marshal] window closed by the user -- exiting for real`, e:36012, f:22510). The "Timeout; remote problem" reason means the HOST process went silent mid-round (crash/wedge, not graceful close) — the host is the machine with the marshal-thread pump race (finding 5). [T2; the race attribution is T3]
|
||||
|
||||
**The frozen machines' actual signatures:**
|
||||
- **santo ~21:41:** round ended cleanly 21:40:50 (e:57957); the menu child (pid 28112, e:57959-57966) booted fully, auto-joined the lobby within 1 s, and received GO normally at 21:43:10 (mission child pid 31404, `relaunched generation`, e:57967). The 2m20s hole between a clean end and the next GO, with a healthy-but-invisible menu, fits a wedged mission-parent window sitting frozen on top of the live menu (the foreground-handoff problem this code already documents, `btl4console.cpp:262-271`). [T2 logs; interpretation T3]
|
||||
- **acaci 22:48:** ZEUS's log goes SILENT at 22:12:38 — the post-round menu child (pid 29344, c:213897-213900) printed exactly 3 boot lines and never reached `[steamnet] up`, which for a relaunched child is unconditional ~1 s after boot (`BT_STEAM_NET` is inherited; boot-time install at `btl4main.cpp:1306-1316`). It hung at/before `SteamAPI_Init` (`L4STEAMNET.cpp:337`). acaci never played again (file mtime 23:18, nothing after 22:12:38) — **acaci was NOT in the 22:37-22:47 round at all**; yet acaci's SteamID (76561198064247614) still appears as token .2 in the 22:47 lobby map on santo/Michael (e:201702, d:153233) — a ghost lobby seat held by the wedged process. [T2; "hung at SteamAPI_Init" is T3 — the alternative, a user-kill inside the ~1 s boot window, is implausible given the same machine repeated it]
|
||||
- The same 3-line pre-SteamAPI_Init signature ends EVERY machine's log (Dave 22:47:22 — the exact incident round end, a:218096-218099; eleng 21:47:21; rajel 20:25:37; Michael 23:25:36; santo 23:25:37) and appears mid-night followed by ~1-2 min manual-restart gaps (Dave 20:31:52→20:32:40 a:31170; acaci 21:07:11→21:09:13 c:60457). The relaunch/exit seam wedges routinely; nobody calls `SteamAPI_Shutdown` anywhere (grep: SDK headers only), and every round-end exits via `ExitProcess` from `BTFE_RelaunchSelfAndExit` (`btl4console.cpp:275`) — on the HOST from the marshal thread while the main thread is mid-teardown (`btl4console.cpp:583-584`), matching Lynx's "hang on exit, hosting". [T2 signatures; ExitProcess/DLL-detach mechanism is T4]
|
||||
|
||||
## 4. Bottom line for #163
|
||||
|
||||
The StopMission delivery chain is a single unmonitored 10-minute-old connection per pod with at least four fully silent failure modes (send-result discarded; error mapped then discarded; reliable-buffer drop; dead-vs-empty indistinguishable in the drain loop) and no mesh-mode fallback on the pod — a real design hole, and the night's two host-death rounds show exactly what its failure looks like (orphaned forever-mission, frozen peers). **However, for the two ticketed incidents the field logs exonerate delivery:** every logged pod received Stop and ended its mission in-process; the freeze and the exit hang live in the round-end relaunch/exit seam (ExitProcess without Steam shutdown; menu children hanging pre-SteamAPI_Init; ghost lobby seats from wedged processes), on which the marshal's host-side thread-kill-at-clock-expiry piles extra risk for the host machine.
|
||||
|
||||
==============================================================================
|
||||
|
||||
# #163 Field Forensics — night15 (2026-08-10, build 4.11.883)
|
||||
|
||||
## 0. Ground work: clock alignment and identity map (all T2 unless noted)
|
||||
|
||||
Wall clocks were aligned by matching identical `[lobby] join ... map [...]` strings and identical session-cadence across machines (per instructions, not by raw wall clock):
|
||||
|
||||
| Machine | User | Steam ID | Clock offset vs ET | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| MS-FIREFLY | santo | 76561198049064449 | **= ET** | own token, santo log line 15 |
|
||||
| SCREECH-PC | Dave | 76561198022091594 | = ET | sessions 21:30:18/21:40:54… match santo; hosted the 19:40 2-pod round (Dave 766) whose lobby shows .1=…022091594 (eleng line 9) |
|
||||
| XIAOLONG | Michael | 76561198020775163 | = ET | sessions 21:43:10/21:53:46 match santo; hosted the 23:15 round (Michael 209577) whose map has .1=…020775163 (santo 255102) |
|
||||
| DESKTOP-QR9VPJQ | Lynx | 76561198659597127 | = ET | matchlog B HDR (his machine) is exactly +1:00:00 vs ALIA's matchlog A of the same events |
|
||||
| ALIA | eleng | 76561198147980449 | **ET − 1:00:00** | glass sessions 20:30:17/20:43:10/…/21:36:48 = santo's 21:30:17/…/22:36:48 to the second |
|
||||
| ZEUS | acaci | 76561198064247614 | ET − 1:00:13 (≈ALIA−13s) | ZEUS 21:36:35→21:47:11 pairs with ALIA 21:36:48→21:47:21 |
|
||||
| GAMERSLAB | rajel | 76561197970523304 | **ET − 3:00:00** | rajel 18:40:50/18:43:10/18:53:46… = ALIA 20:40:50/20:43:10/20:53:47 exactly −2h (⇒ −3h vs ET, matching the "~3h behind" note) |
|
||||
| (no log) | ? | 76561197976435895, 76561197995508393 | — | extra players, day logs not collected |
|
||||
|
||||
**CRITICAL CORRECTION TO THE TICKET'S PREMISE [T1]:** `matchlog_20260810_213656_20476.txt` (ALIA, local 21:36) and `matchlog_20260810_223651_12120.txt` (DESKTOP-QR9VPJQ, local 22:36) are **the SAME mission** — the 22:36 ET round — recorded on two machines whose clocks differ by exactly 1h. Proof: identical MISSION run instant (A line 22: `MISSION … w=21:37:21.041 st=4 run` vs B line 22: `w=22:37:20.047`), identical terminal events (A line ~1319: `DEATH victim=2:20 … pos=-27.5,0.0,144.2` + final `SBMIRROR player=8:1 kills=4 deaths=2 wasKills=3 wasDeaths=2`; B lines 1816-1818: same death at `pos=-27.6,0.0,144.2`, same SBMIRROR). Matchlogs are written per-machine (each pod keeps one), confirmed by dev-bench pairs in `scratchpad/night15/mlbak/` (two matchlogs per bench round). There is **no matchlog for the 21:41-incident round** in the staged set. Lynx DID host the 22:36 round (host token .1=…659597127 in the round's map, ALIA line 172106) — and in fact hosted **every round from ET 21:08 through 22:47**, including BOTH incident rounds (santo maps at 36020, 46238, 57965, 68589, 101519, 124366, 156700 all show .1=…659597127).
|
||||
|
||||
## 1. The mission-end ladder as it appears in these logs
|
||||
|
||||
There are **no** 'EndingMission'/'StopMission'/state-name lines in this build's day logs. The observed healthy end shape (every clean round, every machine) is:
|
||||
|
||||
```
|
||||
[mission] solo game clock expired (600s|1200s) -- ending the mission
|
||||
[score] gauge read / ~RankAndScore: I think my score is N
|
||||
[lamp] 0xNN <- 0x0 (×41, cockpit lamps off)
|
||||
SVGA16::~SVGA16: pixel management statistics + 4 lines
|
||||
[glasswin] destroy entry #1 windows=0
|
||||
[glasswin] destroy entry #2 windows=0
|
||||
[boot] RunMissions returned (mission loop exited).
|
||||
[fe] mission over -- relaunching the menu <=== LAST LINE the process ever logs
|
||||
===== BT411 SESSION ... ===== (the relaunched MENU process, new pid)
|
||||
```
|
||||
(e.g. santo 57904→57959). ~50-60 lines, wall time ≤3s (the menu header lands the same/next second as the computed 600s expiry). After `[fe] mission over` the process calls `BTFE_RelaunchSelfAndExit("")` → `CreateProcessW` + `ExitProcess(0)` (game/btl4main.cpp:1671-1673, game/glass/btl4console.cpp:157-275) — **everything after the `[fe]` line is unlogged by construction**.
|
||||
|
||||
## 2. Incident A — ~21:41 ET, santo (MS-FIREFLY) frozen
|
||||
|
||||
**Round:** ET 21:30→21:40:50, Lynx-hosted, 6 players (santo 46238 map: Lynx.1, eleng.2, acaci.3, santo.4, Dave.5, rajel.6), 600s clock. Michael not in it.
|
||||
|
||||
**santo's end sequence is textbook-clean and on time [T1]:**
|
||||
- 57887-57903: normal in-mission ticks (santo idle: `speedDemand=0`, alive — targeting active; the last nearby deaths are replicants 7:4 at 54956 and 6:4 at 57832, NOT santo).
|
||||
- 57904 `[mission] solo game clock expired (600s)` → 57906 `~RankAndScore: I think my score is 2686` → lamps → 57948 `SVGA16::~SVGA16` → 57954-5 glasswin destroys → 57956 `RunMissions returned` → 57957 `[fe] mission over -- relaunching the menu` → **57959 new MENU session at 21:40:50** (same second as everyone else, below). The log never goes silent mid-ladder and never keeps ticking past the end. (No `[glassperf]` beats exist on this machine — the tag appears only in Dave's and Michael's logs.)
|
||||
- **Recovery:** the relaunched menu was alive immediately — 57963-57965 it joined the NEXT round's lobby (token .7); santo was playing again at **21:43:10 ET** (57967), ≈2m20s after the freeze report. No manual cold restart visible.
|
||||
|
||||
**Same-round healthy comparison (identical ladder shapes, same wall second):**
|
||||
| machine | EXPIRED line | menu-header line | menu local time (→ET) |
|
||||
|---|---|---|---|
|
||||
| santo (frozen) | 57904 | 57959 | 21:40:50 |
|
||||
| eleng | 73366 | 73424 | 20:40:50 (21:40:50) |
|
||||
| acaci | 11986 | 12045 | 20:40:37 (≈21:40:50) |
|
||||
| Dave | 75044 | 75126 | 21:40:54 |
|
||||
| rajel | 44983 | 45041 | 18:40:50 (21:40:50) |
|
||||
|
||||
**Conclusion [inference, flagged]:** the freeze left NO trace in santo's day log. Everything up to and including the menu relaunch executed on time; the only unlogged window is the old glass process's `ExitProcess(0)` path after `[fe]`. The "frozen view of how the game ended" is consistent with the OLD process hanging after spawning the menu — its last rendered frame stays on the fullscreen/plasma window on top while the new menu opens BEHIND (the build even logs `[marshal] foreground handoff denied -- the next window may open behind` for this Z-order hazard, e.g. santo 46239; emit site btl4console.cpp:267-271).
|
||||
|
||||
## 3. Incident B — ~22:48 ET, acaci (ZEUS) + Lynx (host) frozen
|
||||
|
||||
**Round:** ET 22:36:51→22:47:2x, Lynx-hosted, 8 players (map ALIA 172106: Lynx.1, acaci.2, …976435895.3, santo.4, eleng.5, Michael.6, Dave.7, rajel.8), GO at 22:37:20.0 (matchlog B line 22), 600s ⇒ nominal expiry 22:47:20 ET.
|
||||
|
||||
**acaci (ZEUS) end sequence [T1]** — clean, but with the night's most interesting receipt:
|
||||
- 159680 `[wreck] replicant 2:20 entered wreck state … at (-27.6352,144.223)` — **Lynx's mech dying at 22:47:15.6**, the same death both matchlogs end on.
|
||||
- **159890 `[steamnet] connection 1 closed (closed)` + 159892 `Disconnected from GameMachineHost at 169.254.77.1:1502` — the HOST (Lynx) dropped ZEUS's game link BEFORE ZEUS's own clock expired** (ZEUS's 600s clock ran ~3-10s later than the pack because its RunMission receipt timing; it was the last machine still in-mission).
|
||||
- 159894 `EXPIRED (600s)` → 159909/159911 second disconnect (`169.254.77.5:1502` = eleng, tearing down in parallel) → 159970 `RunMissions returned` → 159971 `[fe]` → **159973 menu session 21:47:11 local (≈22:47:24 ET)**.
|
||||
- **Recovery:** menu joined the 22:52-round lobby immediately (159979); acaci played the 22:52 ET round (159981, glass 21:52:09 local) — recovered ≤5 min; quit for the night cleanly at 23:14:36 ET (`lastrun_steam_acaci_ZEUS_8.txt` line 29: `[2026-08-10 22:14:36] clean exit: player quit from the menu`; his day log's final 3-line menu stub at 213897 is the systemic buffered-tail loss described in §6, not a wedge).
|
||||
|
||||
**Same-round healthy machines:** Michael 153171→153227 (menu 22:47:20), santo 201637→201695 (22:47:21), rajel 192406→192463 (19:47:21=22:47:21), eleng 216583→216639 (21:47:21=22:47:21), Dave 218029→218096 (22:47:22). All within 0-4s of nominal expiry, identical ladders. eleng then quit for the night at 22:47:48 ET (lastrun line 47) — unaffected.
|
||||
|
||||
**Lynx (host, no day log):** his own matchlog (B) stops at 1818 `SBMIRROR … w=22:47:15.805` — his own death (`PLAYER_DEAD … player=2:1 deaths=10` at 22:47:15.642, B line ~1813), **5.6s before expiry, respawn never recorded** — and contains no mission-end receipt. He reported "BTL4 hang on exit, hosting" + Task Manager; he is absent from both later lobbies (22:52 map santo 201701; 23:15 map santo 255102) — **never returned**. The pre-expiry link close seen on ZEUS (`(closed)`, i.e. orderly close, not timeout) proves Lynx's process DID start its teardown (its clock expired first); the hang happened later in his exit path [inference].
|
||||
|
||||
## 4. Did StopMission ever arrive? (Task 4)
|
||||
|
||||
**No — on any machine, at any round, all night [T1]:**
|
||||
- `grep StopMission` over all six day logs (≈90MB): **0 hits**.
|
||||
- Every mission end on every machine is the LOCAL fallback: `[mission] solo game clock expired (Ns) -- ending the mission` (14 on santo, 10 on eleng, 8 on acaci, etc.).
|
||||
- The marshal's only sends all night are launches: `[marshal] RunMission #1 sent to N pod(s)` / `RunMission #2 sent -- mission running; clock Ns` (Dave 766/899, 13401/13588, 16085/16289, 23146/23304, 32151/32319, 50684/50831; rajel 19145/19269; Michael 615/733, 209577/209723). No `[marshal] StopMission` line ever.
|
||||
- Consequence: rounds end as N independent local expiries skewed by RunMission-delivery jitter (0-13s spread observed), and the host tears its links down whenever ITS clock fires — peers see `Disconnected from GameMachineHost … :1502` pre-expiry when their clock is late (ZEUS 159890 in incident B; also Dave 118701 & 141778, Michael 13749, eleng 60403 & 84435 — all Lynx-hosted rounds).
|
||||
- Corroborating shape from earlier the same night: when a HOST vanished mid-round (Dave closed his hosting window at 20:21:27 and 21:06:28 ET), peers did NOT transition — they sat in-mission until SteamNetworkingSockets timed out (`Timeout; remote problem. Rx age server 11-21s`: rajel 20340-20416, santo 35887-35922, eleng 22366-22440 & 50374-50455) and each user had to close the window (`RunMissions returned` + `[marshal] window closed by the user`). There is no host-loss or console-driven end path under Steam in the field [T1].
|
||||
|
||||
## 5. Matchlog tails vs bench baseline (Task 3)
|
||||
|
||||
- Field matchlogs (both machines, incident-B round) end mid-combat with NO end receipt: A ends `SBMIRROR … w=21:47:16.821`, B ends `SBMIRROR … w=22:47:15.805`. Neither contains any `st=6`/stop/`PEER_DOWN` record; the only MISSION line each is `st=4 run`.
|
||||
- Dev-bench matchlogs (mlbak/, builds 874-885) normally end with **`PEER_DOWN t=… host=1 type=3`** (the console/relay link closing at teardown) — e.g. matchlog_20260811_005558_2240.txt last line, matchlog_20260810_102334_9548.txt last line.
|
||||
- So both field matchlogs lost their teardown tail. Since ALIA demonstrably transitioned cleanly, the missing tail is NOT diagnostic of the hang; the likely mechanism is the `ExitProcess(0)` relaunch path discarding the matchlog's unflushed stdio buffer (btl4console.cpp:275) [T3].
|
||||
|
||||
## 6. Systemic log-tail artifact (don't misread it)
|
||||
|
||||
Every collected day log ends with a fresh MENU session header + exactly 3 `[boot]` lines and nothing more (Dave 22:47:22, eleng 21:47:21, rajel 20:25:37, Michael 23:25:36, acaci 22:12:38, santo 23:25:37). The lastrun files prove these menus ran fine and exited cleanly minutes later (ZEUS quit 22:14:36 local; ALIA quit 21:47:48 local; santo's final sessions rotated into `steam_20260810.1.log` — not collected; Dave's post-22:47 activity, including hosting the 22:52 ET round per map token .1=…022091594, is likewise missing from the collected file). The `[boot]` lines carry explicit `std::flush` (btl4main.cpp:1605-1619); the later `[steamnet]`/`[lobby]` lines evidently don't survive the quit path's buffer loss. Truncated final menu stub ≠ wedge.
|
||||
|
||||
## 7. Healthy baseline round (Task 1 control): ET 22:22:36 → 22:33:11, Lynx-hosted, 8 players
|
||||
|
||||
| machine | glass session (local) | EXPIRED line | menu line | menu local (→ET) |
|
||||
|---|---|---|---|---|
|
||||
| santo | 22:22:36 (124368) | 156637 | 156694 | 22:33:11 |
|
||||
| Michael | 22:22:36 (74169) | 107656 | 107712 | 22:33:10 |
|
||||
| Dave | 22:22:37 (141879) | 173829 | 173890 | 22:33:11 |
|
||||
| eleng | 21:22:36 (140157) | 172042 | 172100 | 21:33:11 (22:33:11) |
|
||||
| acaci | 21:22:23 (83362) | 115098 | 115152 | 21:32:57 (≈22:33:10) |
|
||||
| rajel | 19:22:36 (113680) | 146649 | 146702 | 19:33:11 (22:33:11) |
|
||||
|
||||
Identical ~55-line ladders, all six menus stamped within 1s. The incident-round ladders of the frozen machines are line-for-line indistinguishable from this baseline.
|
||||
|
||||
## 8. Synthesis (clearly marked inference where noted)
|
||||
|
||||
1. [T1] The in-log mission-end ladder COMPLETED normally and on time on every frozen machine; the freeze lives entirely in the unlogged post-`[fe]` exit path (`BTFE_RelaunchSelfAndExit` → `ExitProcess(0)`, btl4console.cpp:275).
|
||||
2. [Inference] "Frozen view of the ended mission" = the old glass process hung inside `ExitProcess` (classic DLL-detach/terminated-thread deadlock territory: SNS threads, D3D, the plasma-window thread) with its last frame still on screen, while the already-spawned menu opened BEHIND it (`AllowSetForegroundWindow` hazard logged at btl4console.cpp:269). Task Manager kill of the OLD pid clears it — exactly Lynx's report.
|
||||
3. [T1] Both incidents happened in Lynx-hosted rounds; Lynx's own machine hung at exit in incident B while hosting (7 console links + relay + marshal to unwind). In incident B, Lynx's teardown began BEFORE the last peer's expiry (orderly `(closed)` on ZEUS 159890) and then hung.
|
||||
4. [T1] StopMission is not part of this build's field behavior — every end is the local solo clock; the marshal only launches. Host death mid-round strands every peer in-mission until manual window close (three occurrences 20:08-21:07 ET).
|
||||
5. [T2] Lynx died in-game 5.6s before the clock and his respawn never appears before the end; acaci and santo were alive at their freezes — a death-at-end race is NOT the common factor across the three frozen instances.
|
||||
6. Recovery: santo ≈2m20s (auto-relaunch worked; next round 21:43:10 ET); acaci ≈5 min (next round 22:52 ET, quit cleanly 23:14:36 ET); Lynx: killed via Task Manager, never rejoined.
|
||||
|
||||
==============================================================================
|
||||
|
||||
# Ticket #163 / #156 — TASK 3: The binary's end-of-mission truth
|
||||
|
||||
Scope: the 1995 `BTL4OPT.EXE` end ladder, read from `reference/decomp/all/part_*.c` + the T0 WinTesla MUNGA source the port compiles against, with constants byte-read from the image (`scratchpad/rdva.py`) and one raw disasm (`tools/disas2.py`). Everything is [T1] (decompiled + cross-checked against T0/raw bytes) unless tagged.
|
||||
|
||||
## 0. Headline answer
|
||||
|
||||
**The 1995 end sequence is EVENT → TIME → EVENT, and the only timed leg runs inside the mission player's own simulation tick.** Console `StopMission` (event) puts the app in `EndingMission` (state 6) and dispatches `MissionEnding` to the mission player; the player then counts a **3.0-second** `fadeTimeRemaining` down **in player-Performance time** (`ManageApplicationStatus` @0x42df8c); at ≤0 it self-dispatches a **second `StopMissionMessage`** (event) which, arriving at state 6, calls `Application::Stop()` @0x44e6c0 → `executeFrames=0` → the mission loop unwinds. **Nothing in the ladder waits on the visual fade** — the fog fade renderable (@0x45447c) is a passive video watcher. **The ladder CAN stall in exactly two binary-grounded ways:** (a) StopMission #1 never arrives — the mission runs forever (1995's console owned the clock; the pod never stops itself — `context/multiplayer.md:639-641`); (b) the mission player's Performance never executes while in `MissionEndingState` — then `fadeTimeRemaining` never decrements, StopMission #2 is never dispatched, and the app parks in `EndingMission` FOREVER. Once the countdown completes, StopMission #2 is a **direct synchronous `application->Dispatch`** (PLAYER.cpp:454-455; @0x42df8c line 740) — it cannot be lost in transit.
|
||||
|
||||
## 1. The Application state machine's EndingMission arm (binary addresses)
|
||||
|
||||
State enum (T0 `engine/MUNGA/APP.h:188-202`): Initializing=0, WaitingForEgg=1, LoadingMission=2, WaitingForLaunch=3, LaunchingMission=4, RunningMission=5, **EndingMission=6**, StoppingMission=7, Suspending=8, Resuming=9, Aborting=10, CreatingMission=11. App state read as `*(app+0x88)`; the `StateIndicator` sits at app+0x74 with current state at indicator+0x14, and `SetState` = `FUN_0041bbd8` @0x41bbd8 (part_002.c:5510) — which also **pings three watcher chains** (indicator+0x18/+0x2c/+0x40, vtbl+0x14 notify) — this is how the fade renderable learns of state flips.
|
||||
|
||||
Binary Application TU (part_007.c:3090-3810):
|
||||
- **`FUN_0044eeb4` @0x44eeb4 = `Application::StopMissionMessageHandler`** (part_007.c:3698-3737): state 7 → ignore; state 6 or 10 → `Stop()` (@0x44e6c0) — this is the SECOND arrival; default → `SetState(6)`, and if `missionPlayer(app+0x14)` non-null: `networkManager->Mode(0=ReliableMode)` (vtbl+0x30 on app+0x20, part_007.c:3721 == T0 APP.cpp:1762) then dispatch **`Player::MissionEndingMessage {size 0x1c, id 0x19}`** to the player; if NO mission player → `Stop()` directly. Matches T0 APP.cpp:1705-1778 exactly.
|
||||
- **`FUN_0044ef4c` @0x44ef4c = AbortMissionMessageHandler** — same shape, `SetState(10)`, no ReliableMode switch.
|
||||
- **`FUN_0044e6c0` @0x44e6c0 = `Application::Stop()`**: `executeFrames(app+0x70)=0`; flush DEBUG stream; `SetState(7)`. (T0 APP.cpp:797-821.)
|
||||
- **`FUN_0044e488` @0x44e488 = `Application::ExecuteForeground`** (part_007.c:3138-3176): returns 0 immediately when `executeFrames==0`; at state 5 recomputes `secondsRemainingInGame(app+0x58) = mission->length(+0xe0) − (Now−gameStarted(+0x5c))/tps`; final return is `executeFrames && !Exit_Code` where **Exit_Code = `DAT_004efc98`**. **There is NO pod-side action when the clock hits 0** — app+0x58 is display-only; the stop was the console's job.
|
||||
- **`FUN_0044e6e8` @0x44e6e8 = base `Application::Shutdown`** (part_007.c:3239-3278): gauge/video/audio renderer `Shutdown+UnlinkFromEntity` (+0x4c/+0x48/+0x44), delete viewpointEntity (+0x6c), interestManager/hostManager/networkManager shutdown, delete `currentMission(+0xC8)`, then **`executeFrames=1; SetState(1=WaitingForEgg); return 0`**.
|
||||
- Other rungs for contrast: CheckLoad `@0x44ebec` (2→3, and if **no console host** (`FUN_00429078(app+0x2c)`) self-launches RunMission — the console-less path), RunMission `@0x44ecdc` (3→4 + dispatch **MissionStarting id 0x18** to player; 4→5 + `gameStarted=Now()`; else Fail @APP.CPP:0x609 — the port's launch/load-race crash site), Suspend `@0x44ed88` (5→8 + dispatch id 0x19 — suspend ALSO rides the player MissionEnding fade), Resume `@0x44ee10`, KeyCommand `@0x44efd8` ('&'=0x26 → `Exit_Code=1` + `Stop()`).
|
||||
|
||||
**There is NO app-side timer in the end arm.** The only end-sequence time constant anywhere is the player's 3.0 s fade (plus the fade renderable's cosmetic 1.0 s, §3). No mission-review display period exists at the app level; the ranking window is a separate per-player display driver (§below).
|
||||
|
||||
**Ranking-window behavior at end** — `FUN_0042eb38` @0x42eb38 (part_004.c:1387-1426, called from PlayerSimulation/CameraShipSimulation): at states **6/7 the window flag (player+0x224) is forced 0 (hidden)**; while `secondsRemainingInGame > 30.0` (`_DAT_0042ec2c`=30.0, byte-read) it runs a periodic show/hide machine (+0x228/+0x22c/+0x230/+0x234); at ≤30 s it pins the flag to 1 (solid on). So the authentic end was: standings solid for the final 30 s, then StopMission → **ranking hidden + 1 s fade to black**. (The camera-seat DIRECTOR.cpp:107 additionally shows its ranking during EndingMission — different display, camera seat only.)
|
||||
|
||||
**Crucial executability fact [T0]:** `Entity::Execute` (ENTITY.cpp:556-558, quoted in `context/reconstruction-gotchas.md §29:920-934`) calls `PerformAndWatch` only in `RunningMission || EndingMission || IsPreRunnable()` — so the world (and the player's fade countdown) KEEPS TICKING during state 6, and stops at state 7. A screen showing a *frozen last frame* therefore means the app got PAST state 6 (Stop() ran, `executeFrames=0`, renderers no longer execute) but the process never finished Shutdown/relaunch — whereas a player *stuck in state 6* would see a live, still-simulating world that never returns to menu. (Relevance flag for #163 triage, [T4 inference]: post-`RunMissions` in the modern port comes the matchlog-upload dial + relaunch (`context/multiplayer.md:110`, `:541-545`) — a blocking network call there matches "frozen final frame + hang on exit".)
|
||||
|
||||
## 2. The Player handlers @0x4bfc20 / @0x4bfbe8
|
||||
|
||||
Base handlers (part_004.c:409-430):
|
||||
- **`FUN_0042d9c0` @0x42d9c0 = base `Player::MissionStartingMessageHandler`**: `fadeTimeRemaining(+0x1F4) = 3.0f`; `SetSimulationState(3=MissionStartingState)`.
|
||||
- **`FUN_0042d9e0` @0x42d9e0 = base `Player::MissionEndingMessageHandler`**: `fadeTimeRemaining = 3.0f`; **`ForceUpdate` (`+0x18 |= 1`)** — this ships the final player update record (score) under the just-switched reliable mode; `SetSimulationState(4=MissionEndingState)`. Neither touches the Performance pointer or `simulationFlags`.
|
||||
|
||||
**BT overrides** (part_013.c:18556-18588; raw disasm of @0x4bfc20 via `tools/disas2.py` this session):
|
||||
- **`FUN_004bfbe8` @0x4bfbe8 = BTPlayer::MissionStarting**: base @0x42d9c0, then `if (app->state==4 /*LaunchingMission*/ && !(this+0x29 & 0x40 /*NonScoringPlayerBit*/)) currentScore(+0x1c8) = 1000.0f` — the manual's "+1000 starting the game" seed (port: `game/reconstructed/btplayer.cpp:788-806`).
|
||||
- **`FUN_004bfc20` @0x4bfc20 = BTPlayer::MissionEnding** — full body:
|
||||
1. base @0x42d9e0 (fade 3.0 + ForceUpdate + state 4);
|
||||
2. **`this+0x288 −= Round(15.0f × DAT_0052140c + 0.5)`** — recovered from raw bytes @0x4bfc3b-0x4bfc52 (`fld 15.0f @0x4bfca4; fmul [0x52140c]; fadd 0.5f @0x4bfca8; call __ftol@0x4dcd94; sub [ebx+0x288],eax`): it **rewinds the 15-second console score-total clock by exactly one period**, so the next PlayerSimulation tick immediately pushes the FINAL total score to the console (see §5). The old export dropped the x87 operand (`FUN_004dcd94()` rendered argless — the §19 gotcha); now pinned.
|
||||
3. `if (app->state == 8 /*SuspendingMission*/ && playerVehicle(+0x1fc) IsDerivedFrom Mech (tag 0x50bdb4))` → `FUN_0049fb74(mech, &DAT_00524b38, 1)` (= `Mech::Reset`, per the KB respawn chain — `context/decomp-reference.md` "Respawn SIM path"; the argument blob `DAT_00524b38` semantics unverified [T4]) + `ForceUpdate`. So the operator SUSPEND path resets the vehicle; the normal state-6 path does NOT.
|
||||
- **No score finalization, no fade rendering, no waiting happens in this handler** — it is fire-and-forget bookkeeping; the actual fade/countdown/exit all run elsewhere.
|
||||
|
||||
**The countdown** — **`FUN_0042df8c` @0x42df8c = `Player::ManageApplicationStatus`** (part_004.c:706-752; == T0 PLAYER.cpp:408-476): state 3: `fade −= dt`; at ≤0 (`_DAT_0042e0dc`=0.0) → state 2 (VehicleTranslocated) + dispatch RunMission (app state 4) / Resume (state 9). **State 4 (MissionEnding): `fade −= dt`; at ≤0 → app state 6 → dispatch `StopMissionMessage(exit 0)` (ctor @0x44f5d0); state 8 → SuspendMission; state 10 → AbortMission.** Called every tick from `Player::PlayerSimulation` @0x42e100 and `Player::CameraShipSimulation` @0x42e0e0 (part_004.c:756-781) — both flavors complete the ladder. (KB nit: `btplayer.cpp:1329` cites base PlayerSimulation as `FUN_0042e168`; @0x42e168 is only the vehicle-position-copy tail — the full base is @0x42e100.)
|
||||
|
||||
**When can the countdown not run?** `Simulation::DoNothingOnce` = `NeverExecute()` = `simulationFlags |= DontExecuteFlag` (SIMULATE.cpp:484-488, SIMULATE.h:167-206). In the 1995 binary the master player is parked on DoNothingOnce only **pre-first-spawn** (`Player::HuntForDropZone` @0x42ddcc sets the pmf `PTR_FUN_004e67fc` at part_004.c:669 once the drop zone is found, until DropZoneReply re-arms). The BT death cycle does NOT park it: **`FUN_004c05c4` @0x4c05c4 = BTPlayer::VehicleDead** (part_013.c:19064-19156) contains **no SetPerformance/simulationFlags writes** — a mid-mission-dead player still ticks PlayerSimulation, so a StopMission arriving during a death window still completes the fade in 1995. Its first line is the state-4 gate (`this+0x40==4 → return`): once MissionEnding, all death traffic is swallowed. Also in the -1 arm: deaths dual-increment (+0x280), console **MechKilled id 8** via ctor @0x4c18cc gated `suppressConsole(+0x258)==0`, objectiveMech(+0x284)=killer's vehicle, lives check `role(+0x208)->returnFromDeath(+0x28) < 1` → post Mech id 0x18 (ClearBurningState) to the wreck at +10 s and NO respawn re-post; else re-post VehicleDead to self at +5.0 s (`@0x4c0830`=5.0f, byte-read); then the straight-line death cost gated `advancedDamageOn(+0x264)` via direct call to base ScoreMessageHandler @0x42da20.
|
||||
|
||||
## 3. THE FADE (ticket #156)
|
||||
|
||||
**Mechanism: a fog-color/fog-range animation on the main view — `POVStartEndRenderable`** (T0 `engine/MUNGA_L4/L4VIDRND.cpp:2126-2394`, binary ctor **`FUN_00454394` @0x454394**, Execute **`FUN_0045447c` @0x45447c**, part_007.c:8468-8606). Not a palette fade, not a gauge overlay, not the warp machinery.
|
||||
|
||||
- **Built for the LOCAL (master) player only** by the video builder **`FUN_004d0774` @0x4d0774** (part_014.c:10673-10693): classID 0xBDA (BTPlayer): replicant (`(entity+0x28 & 0xc)==4`) → translocation-warp renderable @0x458d2c; **master → alloc 0x50 → `FUN_00454394(entity, Watcher=2, mainView, mainZone, deathZone, trigger, fogRGB, fogNear/Far, 3, 4)`**. The trigger = the player's **SimulationState attribute** (`FUN_0041bf44(entity,1)`), i.e. the same `StateIndicator` the MissionStarting/Ending handlers write; start state **3** = MissionStartingState, end state **4** = MissionEndingState.
|
||||
- **State machine** (@0x45447c; myState at +0x1c, timer +0x3c, trigger state read at `trigger+0x14`): 0 WaitForStart → on trigger==3: fog snapped to WHITE (1,1,1) with near/far 0.01/0.05 (screen fully fogged = white flash), AddDynamicRenderable; 1 FlashScreen: hold **0.1 s** (`@0x454740`); 2 FadeIn: lerp fog color/ranges back over **1.0 s** (`@0x454744`); 3 MissionRunning: wait for trigger==4 → 4 FadeOut: fog color × percent-left → black, ranges collapse to 0.01/0.05 (`@0x45474c/0x454750`), over **1.0 s in the binary** — note the WinTesla source's `FADE_OUT_TIME (0.5f)` (L4VIDRND.cpp:2150) is a later edit; the shipped 1995 constant is 1.0 (the same `@0x454744` is reused as the fade-out duration at part_007.c:8584); at expiry → back to state 0 + RemoveDynamicRenderable.
|
||||
- **What advances it:** the renderer's frame clock (`GetCurrentFrameTime`), NOT the player sim — once state 4 is set it completes even if the player sim is dormant. **What would happen if it never completed: nothing.** The ladder never reads the renderable; `ManageApplicationStatus` is the sole gatekeeper. The fade-out (1.0 s) simply fits inside the 3.0 s window; the pod then sat on a black screen ~2 s before teardown.
|
||||
- **Why the port has no fade (#156 root cause):** the builder call exists (`game/reconstructed/btl4vid.cpp:311-315`, states 3/4 passed), but **`BTPOVStartEndRenderable`'s ctor is an empty stub — `game/reconstructed/btstubs.cpp:339-342`** — it never registers with the video renderer or the state dial. The white mission-start flash is missing for the same reason. All the plumbing it needs (SetState watcher ping @0x41bbd8, fog style/limits on the D3D renderer) already exists in the port.
|
||||
|
||||
## 4. RunMissions exit + what the 1995 pod did next
|
||||
|
||||
- **Exit condition** (T0 `engine/MUNGA/APPMGR.cpp:39-161`): `RunMissions` loops foreground/packet-route/background passes; when `application->ExecuteForeground(...)` returns False (binary: `executeFrames==0 || Exit_Code(DAT_004efc98)` — @0x44e488) it calls `application->Shutdown(n)`; **Shutdown returning False removes the app from `runningApplications`, and with no apps left RunMissions Terminates them and RETURNS.**
|
||||
- **1995: the pod did NOT exit** — the L4 (pod) application overrides Shutdown: **`FUN_0047c560` @0x47c560** (part_010.c:2805-2820) = base Shutdown @0x44e6e8 (which re-arms `executeFrames=1` + `SetState(WaitingForEgg)`) then **`return (Exit_Code==0 && app+0xd4==0) ? 1 : 0`** — i.e. the app STAYS in the manager and the pod parks in WaitingForEgg for the console's next egg (the between-rounds attract wait). RunMissions only returned on the '&' keystroke (Exit_Code, @0x44efd8) or the `app+0xd4` shutdown latch (set by the two-phase handler **@0x47c2c4** — first receipt latches +0xd4 and re-posts, second receipt tears down; a console-initiated pod shutdown [T3 on its exact message id]).
|
||||
- The modern build diverges deliberately: T0 APP.cpp:922-926 replaced the original `return !Exit_Code` with a hard `return False` (`#if 0` preserved in-source), and `engine/MUNGA_L4/L4APP.cpp` has **no** Shutdown override — so every mission end exits RunMissions, then btl4main uploads the matchlog and BT_FE_LOOP relaunches the menu (`context/multiplayer.md:110,541-545`).
|
||||
|
||||
## 5. The end-of-mission pod↔console handshake
|
||||
|
||||
All pod→console, fire-and-forget over the console link (clientID 5 = ConsoleClientID); **the ladder never waits for any console reply**. Names from the surviving original `game/original/BT/BTCNSL.CPP` (cnslmsgs.cpp) + T0 `engine/MUNGA/CONSOLE.h:11-18`:
|
||||
|
||||
| When | Message {size, id} | Binary ctor / site |
|
||||
|---|---|---|
|
||||
| Every 10 s AND immediately once app state == **6** | score **DELTA** flush: self-dispatch ScoreUpdate {0x20, 0x1a} → the +0x278 delta cell is sent then **zeroed** | **`FUN_004c083c` @0x4c083c** (part_013.c:19160-19192): `10.0(@0x4c08fc) <= (this+0x10 − this+0x28c)/tps \|\| app+0x88==6`; port analog `btplayer.cpp:1361-1427` |
|
||||
| Every **15 s** while state 5 or 6; forced immediately at MissionEnding by the @0x4bfc20 clock rewind | **ConsolePlayerMechScoreUpdate** (running TOTAL, from +0x1c8) {0x14, id 9} | ctor **@0x4c18f4**; send site part_013.c:19269-19279 (`_DAT_004c0bc0`=15.0, clock at +0x288) |
|
||||
| Once, when `fadeTimeRemaining ≤ 1.5` in MissionEndingState (i.e. **halfway through the 3 s fade**, while the link is guaranteed still up), one-shot latch +0x254 | state 6: **ConsoleApplicationEndMissionMessage {0x14, id 7} {host, Round(score)}** (added 10/05/95 per BTCNSL.CPP:10); otherwise (abort/suspend): **ConsoleApplicationAbortMissionMessage {0x10, id 0xb}** | **BTPlayer::PlayerSimulation `FUN_004c0904` @0x4c0904**, block part_013.c:19251-19268, Tell string `"Sending EndMission..."` @0x5132d7; ctors @0x42998c / @0x4299b4 |
|
||||
| On death (any time) | **ConsolePlayerMechKilled** {0x14, id 8} {victim, killer} | ctor @0x4c18cc, sent from VehicleDead @0x4c05c4 (part_013.c:19110-19114), suppressed after eject via +0x258 |
|
||||
| On eject | DeathWithoutHonor (id 5) | `FUN_004c198c` (KB: decomp-reference EJECT cluster) |
|
||||
| (team variants) | TeamScoreUpdate {0x14, 0xd} @0x4c191c; TeamEndMission {0x1c, 0xe} @0x4299d8 | |
|
||||
|
||||
Plus two non-console end actions: **`networkManager->Mode(ReliableMode)`** at StopMission #1 (@0x44eeb4/T0 APP.cpp:1762) and the base MissionEnding **ForceUpdate** shipping the final player record to peers. Also note part_013.c:19280-19282: while state 5 **or 6**, the player record is re-dirtied (`+0x18 |= 1`) whenever none has shipped for 1.0 s (`_DAT_004c0bc4`) — a 1995 1 Hz player-record keepalive that keeps running through the ending window.
|
||||
|
||||
## 6. Stall matrix (the ticket's question, binary-grounded)
|
||||
|
||||
| Leg | Driven by | Can it stall? |
|
||||
|---|---|---|
|
||||
| StopMission #1 → state 6 | console event over the console link | YES — if it never arrives the mission runs forever (1995 design; modern relay adds the +15 s RelayGameDown fallback, multiplayer.md:519-521). World stays LIVE (not frozen). |
|
||||
| state 6 → StopMission #2 | **3.0 s of MISSION-PLAYER SIM TIME** (@0x42df8c) | YES — iff the mission player's Performance stops executing while in MissionEndingState (DontExecuteFlag latched, or its Performance never calls ManageApplicationStatus). In 1995 the only such window is pre-first-spawn (HuntForDropZone→DoNothingOnce, part_004.c:669). Result: app parks in state 6 forever — world keeps ticking (ENTITY.cpp:556-558), ranking window hidden (@0x42eb38), never returns to menu. |
|
||||
| StopMission #2 → Stop() | direct synchronous Dispatch | NO (cannot be lost). |
|
||||
| Stop() → RunMissions return | next foreground pass: ExecuteForeground→0, then Shutdown (synchronous renderer/network/mission teardown @0x44e6e8) | Only if a Shutdown callee BLOCKS (e.g. a network shutdown on a dead transport). From Stop() onward the screen is a FROZEN LAST FRAME (renderers no longer execute) — the shape #163 describes. |
|
||||
| visual fade | renderer frame clock, passive watcher | irrelevant — nothing waits on it; in the port it's a stub anyway (btstubs.cpp:339-342). |
|
||||
@@ -0,0 +1,135 @@
|
||||
=============================================================================
|
||||
BT411 -- ALL OPEN TRACKER ISSUES (59), SORTED FOR PLAYTESTERS
|
||||
Updated 2026-08-10 (evening) -- after Oracle's full annotated sweep, which
|
||||
closed 35 issues. Thank you Oracle: that is what this list is for.
|
||||
=============================================================================
|
||||
HOW TO REPORT: drop a line in Discord with the issue number and PASS /
|
||||
FAIL / what you saw. Screenshots and rough times help; logs get collected
|
||||
as usual.
|
||||
=============================================================================
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
A. FIXED -- PLEASE VERIFY (headliners first: new fixes fielded THIS build)
|
||||
-----------------------------------------------------------------------------
|
||||
#149 Glass cockpit stutter / hitching ** HEADLINER **
|
||||
First fielded build with the fix. Glass users -- especially Oracle:
|
||||
mission load and play should feel smooth again (repaints now skip
|
||||
windows whose gauges did not change).
|
||||
#150 Double kill credit (one kill counted twice) ** HEADLINER **
|
||||
First fielded build with the fix (868's double-kills were pre-fix).
|
||||
TEST: kill someone with a missile volley -- scoreboard +1, not +2.
|
||||
#69 "Sunder V1" renamed to DENKOU
|
||||
TEST: vehicle picker shows Denkou -- era witnesses, confirm spelling.
|
||||
#71 Closing the Plasma window lost it until restart
|
||||
TEST: X the plasma window -- it minimizes to the taskbar; click restores.
|
||||
#25 Gamepad turn profile (stick X could not turn the mech)
|
||||
TEST: RajelAran -- bind an axis to "Turn" in content\bindings.txt.
|
||||
#27 Autocannon/Gauss dealt the wrong damage type
|
||||
Oracle asked for a RETEST before closing: ballistics should feel/score
|
||||
like solid shot (no blast splash).
|
||||
#38 Mech colors reverted on respawn
|
||||
TEST: custom paint, die, respawn -- colors stay.
|
||||
#39 Controls were live before the game screen appeared
|
||||
TEST: mash fire during load -- nothing happens until the scene shows.
|
||||
#41 (David/Auric) launcher killed before it could run -- forensics armed
|
||||
TEST: if join.bat ever fails again, send content\lastrun_join.txt.
|
||||
#56 MFD buttons drift away from where they are drawn
|
||||
TEST: alt-tab / focus changes mid-round -- buttons stay aligned.
|
||||
#147 Range caret vanished for the session (ticks stayed)
|
||||
Monitoring: if it happens, the log traps the culprit -- just report it.
|
||||
#158 (Sauron) could not move after respawn until throttle cycled
|
||||
TEST when it happens: does cycling the throttle free you?
|
||||
YES -> the #146 release mechanic (expected). NO -> map + spawn spot.
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
B. OPEN -- HELP US REPRODUCE / NARROW (what to note when it happens)
|
||||
-----------------------------------------------------------------------------
|
||||
#24 Controller hat/look view sticks (recovers after wiggling)
|
||||
#37 Mech torso appears BACKWARDS to other players (transient)
|
||||
#74 8BitDo gamepad works solo, dead in Steam MP (VexUbiquity)
|
||||
#102 Owens: full-throttle run -> hold reverse = animation/audio hang
|
||||
REPRO known (exterior cam, savannah); self-clears on a torso twist.
|
||||
#125 Peers do not see the FIRST destroyed-zone graphic (2nd hit shows it)
|
||||
#126 Peer weapon fire invisible AND silent from some positions
|
||||
#128 Ramming terrain = INSTANT coolant leak -- Oracle re-confirms STILL LIVE
|
||||
#130 Vulture "skating" (gliding without walk animation)
|
||||
#132 (Conn Man) persistent yellow line on the HUD in every mech
|
||||
#135 Coolant leak with NO flashing annunciators -- Oracle re-confirms LIVE
|
||||
#136 A SHUT leaking loop starts/stops leaking with COMPONENT toggles
|
||||
(Oracle marked "Clarify" -- one more description pass requested.)
|
||||
#143 One panel hit -> smoke erupts from MULTIPLE places at once
|
||||
#153 Basic-mode elevation limit only applies after cycling modes (fix pending)
|
||||
#154 Generator Out warning fires on the WRONG EDGE (at recovery, not trip)
|
||||
Refined by Oracle: the stuck-forever gen did NOT reproduce; era rule =
|
||||
gens return at HALF-cooled (red bar portion gone). Threshold audit due.
|
||||
#155 PANIC lit: Eng + Weapon MFD banks dead; Score/Radar/Coolant stay live
|
||||
(Oracle mapped the exact bank split; #50 closed into this ticket.
|
||||
Flicker half = authentic. Next: is the bank capture authentic too?)
|
||||
#159 (Conn Man) lag during drops -- missiles / fire exploding in empty space
|
||||
NOTE: shooter + target + rough time; match receipts measure the rest.
|
||||
#160 Pod cab: RIO axis dropouts + boot wedges (hardware investigation)
|
||||
Cab-side only. Bounded-write hardening is in this build; the cage
|
||||
hardware work continues with Nick.
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
C. STILL-OPEN ERA QUESTIONS
|
||||
-----------------------------------------------------------------------------
|
||||
#103 Mech-to-mech RAM: armor damage or internals only? (Oracle: don't know;
|
||||
Lynx / Ronin / Draco -- your call. Binary does internals-only.)
|
||||
#133 Generator X-out on the MFD: Oracle recalls an X "under certain
|
||||
circumstances" -- WHICH circumstances? (shutdown / overheat / destroyed)
|
||||
ALSO (#152 rider): did Mid/Adv auto-recentre the torso in the pod, or only
|
||||
Basic? (Oracle: don't know. Binary says: no auto-recentre.)
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
D. DESIGN CALLS -- group opinions wanted
|
||||
-----------------------------------------------------------------------------
|
||||
#151 Panic eject costs -499: verified REAL 1995 scoring (your death scored
|
||||
as suicide + death penalty). Keep authentic, or house-rule a flat cost?
|
||||
#23 Non-glass screen layout: Oracle proposes widescreen-aware sizing --
|
||||
world view wider + taller, moved up, allowed to overlap the radar.
|
||||
Opinions welcome before anyone prototypes it.
|
||||
#77 Resizable Secondary/Radar window (touch-panel cockpits). Want it?
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
E. KNOWN GAPS -- confirmed and on the list, no need to re-report
|
||||
-----------------------------------------------------------------------------
|
||||
#49 Radar/map does not draw the arena WALL geometry
|
||||
#76 Glass layout save/restore rework (some windows not saved)
|
||||
#83 INCOMING MISSILE alarm not implemented yet
|
||||
#90 Flames/smoke from serious damage: less than the original (tuning)
|
||||
#104 Mad Dog "NARC 15" label: NARC postdates our 4.10 binary (Oracle: added
|
||||
in a late 4.x release; zero NARC in our decomp) -- content rename due.
|
||||
#105 Sensor damage effects: Oracle's era spec captured (HUD flicker/loss,
|
||||
radar loss) -- implementation pending.
|
||||
#111 Peers see a dying mech's blue warp bubble (should see explosion only)
|
||||
#112 AFC rounds shotgun across zones (should land as one projectile)
|
||||
#113 LRM burn/hit state lasts too long
|
||||
#115 Hot box / target select not connected (Oracle era note: hotbox was a
|
||||
STANDARD-mode feature; absent in Expert -- gating to reconcile)
|
||||
#121 Sensor HEAT has no consequences yet
|
||||
#123 Searchlight: the visible LIGHT + night-fog swap still to come
|
||||
#145 IR "predator vision": decode done, display not implemented
|
||||
#156 Fade-to-black at mission end not happening
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
F. INTERNAL ENGINEERING BACKLOG (nothing to test)
|
||||
-----------------------------------------------------------------------------
|
||||
#14 Glass panel unreconstructed button handler backlog (census)
|
||||
#17 Cavern upper-butte halves lack a collision stream (authenticity TBD)
|
||||
#21 Weapon recharge arc dark after eng-page use (symptom trail -> #62)
|
||||
#28 Confirm the vital-subsystem-crit death path can fire (machinery in)
|
||||
#30 Weapon "unavailable" (level-7) display verification + accessor cleanup
|
||||
#63 PlayerStatus reads raw 1995 offsets (databinding trap, dormant)
|
||||
#64 damageZone shadow field (half-fixed; consequences disarmed)
|
||||
#65 HandleMessage non-virtual across 10 classes (latent dispatch trap)
|
||||
#72 BTLoadPump swallows WM_QUIT during load (latent)
|
||||
#107 Destroyed HeatSink annunciates nothing (no lamp for condition 0)
|
||||
#117 KB note: per-chassis cockpit bounce was deliberate (Ronin) -- FYI
|
||||
|
||||
=============================================================================
|
||||
End of list. 59 open issues total:
|
||||
12 verify (2 headliners), 17 reproduce, 3 era questions, 3 design calls,
|
||||
14 known gaps, 11 internal.
|
||||
35 issues closed 2026-08-10 on Oracle's annotated sweep.
|
||||
=============================================================================
|
||||
@@ -0,0 +1,204 @@
|
||||
# The terrain-ram coolant-leak audit -- full findings (2026-08-11, ticket #128)
|
||||
|
||||
> Four-agent audit of the collision->damage->leak chain. VERDICT: pricing,
|
||||
> gate, scaling, free floor and sub-hit math are all byte-faithful; the ONE
|
||||
> divergence is ELIGIBILITY -- the port read GUID 0x50e590 as the whole
|
||||
> HeatSink base family (making myomers/condensers/reservoirs rattle-eligible,
|
||||
> every one coolant-bearing -> instant leak per over-floor ram), while the
|
||||
> binary's node is the AGGREGATE BANK only: eligible set {HeatSinkBank 0.30,
|
||||
> Gyroscope 0.35, Torso 0.25}, 10% of rolls land nowhere, myomers' authored
|
||||
> 0.35 weight is DEAD DATA. Gyro/Torso cannot leak; a 1995 ram is gyro/torso
|
||||
> damage with at worst a bounded bank drip. Also established: the 0.5-pt free
|
||||
> floor eats ALL scrape taps (the per-frame [crashdmg] spam is log noise, not
|
||||
> damage -- now sampled), and the +0x268 advancedDamage gate is honored
|
||||
> faithfully (night-15's 'leaks' were combat-caused; the collision leak only
|
||||
> ever fired where advDmg=1 -- and #162's egg fix would have armed it for
|
||||
> every steam match, making this fix urgent). Fixed in mech.cpp
|
||||
> (IsDerivedFrom("HeatSinkBank")) same day; bench ram_bench.sh.
|
||||
|
||||
TICKET #128 — PORT AS-BUILT CHAIN (terrain ram/scrape -> instant coolant leak)
|
||||
|
||||
=========================================================================
|
||||
STAGE 1 — THE EMITTER (physical collision -> TakeDamage dispatch)
|
||||
=========================================================================
|
||||
The [crashdmg] print lives in Mech::AuthenticGroundAndCollide, game/reconstructed/mech4.cpp:8526. Full path:
|
||||
|
||||
1a. Per frame, the master mech (only movers with a collisionAssistant run the collision half — replicants bail at mech4.cpp:8427-8428) fetches contacts: GetCurrentCollisions() (mech4.cpp:8431), then Mover::ProcessCollisionList(cols, dt, old_position, &dmg) (mech4.cpp:8450-8452). `Damage dmg` is stack-constructed: Damage::Damage (engine/MUNGA/DAMAGE.cpp:35-45) sets damageAmount=0, burstCount=1; **damageType is NOT initialized by the ctor** — it is stamped Damage::CollisionDamageType(0) inside Mover::ProcessCollisionList at engine/MUNGA/MOVER.cpp:1228, and the accumulated `total_damage` across the frame's contacts is written at MOVER.cpp:1350.
|
||||
|
||||
1b. Per contact, the engine calls the virtual Mech::ProcessCollision (mech4.cpp:8754-8883, binary @004abb40). It restores frameEntryWorldVelocity first (mech4.cpp:8777 — PORT ADDITION, "COLLISION-DAMAGE ECONOMY GUARD": prevents multi-solid frames compounding StaticBounce reflections; commented as fixing 4x-40x over-pricing), runs the BoxedSolid resolver, then prices via Mover::StaticBounce (mech4.cpp:8796-8797 -> engine/MUNGA/MOVER.cpp:1432-1528).
|
||||
|
||||
1c. THE AMOUNT FORMULA (engine T0, MOVER.cpp:1527): `return -0.0005 * (vn*vp) * moverMass`, where impact = worldLinearVelocity·normal; damage is 0 if impact > 0 (separating, MOVER.cpp:1465-1469). vn/vp are the reflected normal/parallel components scaled by (1+elasticity). Constants: 0.0005 (MOVER.cpp:1527); minimumBounceSpeed only ZEROES elasticity when -impact <= bounce_min*dt (MOVER.cpp:1470-1473) — **there is NO minimum-speed gate on the damage amount itself; any closing contact prices > 0**. Measured economy (context/combat-damage.md:413-415): raw ≈ 600·v² for a head-on mech (59221 @ |v|=9.94; moverMass is tonnage-scale 60000-90000).
|
||||
|
||||
1d. OWNER CLASSIFICATION (mech4.cpp:8815-8866): a Mover owner -> mech-vs-mech dispatch via BTDispatchCollisionDamage, edge-gated by ramLastVictim/ramContactLinger 0.35s (mech4.cpp:8837-8846 — PORT ADDITION, explicit one-bump-one-hit; the binary's edge was implicit in its bounce). A CulturalIcon owner -> crunch dispatch + 0.00123f walk-through sentinel. **Plain UnscalableTerrain owners (ground/hills/cliffs/canyon walls) match NEITHER branch — their StaticBounce damage stands with NO edge gate whatsoever** (comment mech4.cpp:8656-8658).
|
||||
|
||||
1e. THE TERRAIN CRASH RESPONSE (mech4.cpp:8485-8537): if dmg.damageAmount > 0.0f, the frame is fully rejected (velocity zeroed 8487, position restored 8488), [crashdmg] prints (8526-8531, always-on), and an Entity::TakeDamageMessage{inflictor=SELF, zone=-1, dmg VERBATIM} is dispatched at OUR OWN mech (8532-8536, binary @4aa984-4aaab4). Stamps: damageType=0 (from MOVER.cpp:1228), burstCount=1 (Damage ctor), amount=raw StaticBounce kinetic price.
|
||||
|
||||
1f. FIRE RATE: per frame while scraping. The freshBlock hysteresis was deliberately REMOVED (pricing audit 2026-07-31; comment mech4.cpp:8560-8565). The only natural pacing is the knockdown: iv2 > 40 (i.e. |v| > ~6.3 u/s, const @0x4ab184, mech4.cpp:8540) binds the bmp clip whose cycleSpeed 0 collapses velocity. **Below iv2=40 there is no knockdown, so a sustained scrape with a closing normal component re-prices and re-dispatches EVERY FRAME at full drive speed** (the gait re-derives velocity each frame — context/combat-damage.md:427-431 documents exactly this hazard for the mech-vs-mech case, which got the ramContactLinger fix; terrain never did).
|
||||
|
||||
=========================================================================
|
||||
STAGE 2 — THE DISTRIBUTOR (Mech::DistributeCollisionDamage)
|
||||
=========================================================================
|
||||
Divert site: mech.cpp:1160-1164 — `if (message->damageData.damageType == 0) { DistributeCollisionDamage(&message->damageData); goto death_tail; }` (binary @0x4a0368; never reaches the zone/armor loop; jumps to the death tail, not return — mech.cpp:1149-1158).
|
||||
|
||||
Full body mech.cpp:980-1028 (binary FUN_0049ffcc):
|
||||
- GATE (mech.cpp:982-985): `player = GetPlayerLink()` (mech+0x190); return if null or !BTPlayerAdvancedDamageOn(player). The bridge (btplayer.cpp:2201-2204) reads **advancedDamageOn2 = the SECOND cell this[0x9a] = +0x268** — it DOES reproduce the binary's +0x268 gate (@0x49ffde) [T1]. Both cells are stamped together at btplayer.cpp:2131 from btMission->advancedDamageOn (mission+0xf0, the egg "advancedDamage" technician flag). NOTE [inference, T3]: commit 3545056 ("steam eggs stamp the host's advancedDamage mission-wide — penalty was silently off for every steam match") means this gate only recently began PASSING in steam matches — plausibly why two testers suddenly report the leak as new.
|
||||
- SCALE (mech.cpp:987-990): `scale = ((2000/moverMass) / (100·0.27777779)²) / (1 − elasticity²)` — constants match binary (tbyte @0x4a0148 = 1/3.6, 2000f @0x4a0154; mass +0x20c, elasticity +0x244). ≈ 4.5e-5 at 60t/e=0.2 (measured, context/combat-damage.md:1124). Note the mass CANCELS against StaticBounce's ·mass, so scaled amount ≈ (vn·vp)/(771.6·(1−e²)) — mass-independent.
|
||||
- FREE FLOOR (mech.cpp:991-992): scaled < 0.5f -> return (taps free; @0x4a003c). **This floor IS the effective "minimum speed": measured 2.55 pts at 9.6 u/s -> the floor clears at |v| ≈ 4.25 u/s head-on.** Between ~4.3 and ~6.3 u/s a scrape prices 0.5-1.2 pts per frame with no knockdown pacing (see 1f).
|
||||
- SUB-HITS (mech.cpp:994-997): n = Round(amount·2), min 1; each sub-hit = amount/n (~0.5 pts each).
|
||||
- SELECTION (mech.cpp:1007-1025): per sub-hit, one [0,1) roll walks the full subsystem roster accumulating collisionCriticalHitWeight (+0x10C, authored per subsystem resource "CollisionCriticalHitWeight", mechsub.cpp:200/652); eligibility = IsDerivedFrom HeatSink-family(GUID 0x50e590) / Gyroscope(0x50fdc0) / Torso(0x510b08). Measured weights (scratchpad/SESSION_HANDOFF_20260801.md:39): HeatSinkBank 0.3 / Gyro 0.35 / Torso 0.25 / Myomers 0.35. Un-won rolls land nowhere (weights un-normalized — faithful). Winner takes the sub-hit via MechSubsystem::ApplyDamageAndMeasure (mechsub.cpp:459-481, binary @0x4ac07c = the audited FUN_004ac07c) -> MechSubsystem::TakeDamage (mechsub.cpp:513-538, @0x4ac0bc) -> the subsystem's PRIVATE DamageZone::TakeDamage: `damageLevel += amount × damageScale[0]` (engine DAMAGE.cpp:391), scale = points-normalized from authored damageTypePoints/weaponDamagePoints (mechsub.cpp:220-237).
|
||||
- Subsystems per event: up to n (one per sub-hit); a full-run terrain crash (~2.6 pts) = n=5 sub-hits, so up to 5 subsystem rattles per impact.
|
||||
|
||||
MASTER GATING: **DistributeCollisionDamage is NOT master-gated.** The #150 gate (mech.cpp:1288) covers only the score-report tail and the #162 gate (mech.cpp:1372-1373) only the death tail; the type-0 divert at mech.cpp:1160 runs BEFORE both, on any instance. The port deliberately applies damage on replicants for visual response (comment mech.cpp:1276-1282; binary asserted replicants OUT of this handler entirely, part_012.c:14613-14616). So the shooter-side ram dispatch at the victim's local REPLICANT (BTDispatchCollisionDamage, mech4.cpp:8845; victim->Dispatch, mech4.cpp:8717 — also streamed cross-pod to the master per task #47, messmgr.cpp:348) runs the divert on the replicant too, subject only to the replicant's playerLink/advancedDamageOn2 gate. The TERRAIN self-crash emitter itself is master-only (replicants have no collisionAssistant, mech4.cpp:8427), so #128's chain is master-side.
|
||||
|
||||
=========================================================================
|
||||
STAGE 3 — THE LEAK ONSET (subsystem damage -> coolant leak)
|
||||
=========================================================================
|
||||
HeatSink::UpdateCoolant, game/reconstructed/heat.cpp:1061-1118 (binary @004adbf8):
|
||||
- `coolantDraw = ownZone->damageLevel × heatLoad` (heat.cpp:1078-1080; reads the subsystem's OWN private DamageZone.damageLevel — the exact cell the rattle sub-hit raised).
|
||||
- Floors/thresholds (heat.cpp:70-80, byte-verified): draw < 0.0025 -> zeroed (_DAT_0050e3d8); coolantActive flips ON at draw > 0.003 (_DAT_0050e3d4), OFF below 0.0025 — hysteresis (heat.cpp:1110-1117).
|
||||
- coolantActive IS the leak: it feeds the ReportLeak attribute (heat.cpp:489, all 19 authored leak watchers -> the "warning: coolant leak" voice) and coolantDraw is published as CoolantMassLeakRate (heat.cpp:481) -> the cockpit LeakGauge (btl4gau2.cpp:984/1008). Coolant then drains at coolantDraw/s with central-tank top-up via DrawCoolant (heat.cpp:1095-1105).
|
||||
- heatLoad = 15-sample filtered average of 0.002×temperature×coolantLevel, clamped [0,1] (heat.cpp:70-72, 886-926); typical running value ~0.6 (radiatedHeat ~300 band comment heat.cpp:60-61).
|
||||
- THE THRESHOLD: leak springs when damageLevel > 0.003/heatLoad ≈ 0.005 at typical load. A single 0.5-pt sub-hit raises the private zone by 0.5×damageScale[0] ≈ 0.5/points — for any plausible authored points (10-50) that is 0.01-0.05, i.e. **an ORDER OF MAGNITUDE above the leak threshold. One free-floor-clearing sub-hit landing on ANY heat-family subsystem = instant audible leak, deterministically.** And since Myomers is heat-family (weight 0.35) alongside HeatSinkBank/Condensers/Generator/Reservoir, most of the eligible weight IS heat-family; with n=5 sub-hits at run speed, at least one landing heat-family is near-certain — matching "every single time".
|
||||
- Design intent note: heat.cpp:1070-1072 states the AUTHENTIC behavior — draw rises only as the heat subsystem takes battle damage; there is no repair, so the leak persists once lit (clears only when heatLoad drops enough to cross the 0.0025 OFF hysteresis — the "warning coo-- war--" oscillation, heat.cpp:876-879).
|
||||
|
||||
=========================================================================
|
||||
GUESSES / STAND-INS / PORT DEVIATIONS in the chain (admitted in comments)
|
||||
=========================================================================
|
||||
1. mech4.cpp:8868-8873 [T3]: InitialHit(1) vs Slide(2) contact-state split — the two collision floats @0x240/0x244 are fieldAt() STUBS returning 0; port always accumulates InitialHit. (Audio-only; not damage.)
|
||||
2. mech4.cpp:8777 + 8436-8441: frameEntryWorldVelocity restore — PORT ADDITION (economy guard), not in the binary (its ground was a heightfield probe, never a collision-list entry).
|
||||
3. mech4.cpp:8837-8846: ramContactLinger 0.35s one-bump gate — PORT ADDITION, mech-vs-mech ONLY; the binary's edge was implicit in its bounce separation. **Terrain self-damage got no equivalent gate — the per-frame scrape pricing is the port's own emergent behavior**, bounded in the binary by its velocity-reversing bounce.
|
||||
4. mech4.cpp:8566-8567: knockdown mid-clip rebind guard — admitted "cheap belt-and-braces port addition"; the binary has no rebind guard.
|
||||
5. mech4.cpp:8508-8510 TODO: throttleState = fall-surface material not forwarded (binary (**[mech+0x2f8])+0x24) — payload nicety, unmapped.
|
||||
6. mech.cpp:984: `player == 0` null guard is a port guard (binary reads +0x268 through the link; the audit says the link is always live there).
|
||||
7. mech.cpp:1086-1089 [T3]: lastInflictingID write site is a reconstruction guess (binary write site not located).
|
||||
8. Replicant damage application (mech.cpp:1276-1282) — deliberate port deviation from the 1995 replicant assertion; consequence: the divert/distributor can run on replicant copies (see master-gating above).
|
||||
9. Cross-pod TakeDamage streaming (task #47, messmgr.cpp:348) — port addition; in the 1995 pod, ram damage was network-inert (context/combat-damage.md:416-419).
|
||||
10. Subsystem private-zone damageScale values are authored content (res+0x30/+0x44, mechsub.cpp:220-237) — I did not extract the per-subsystem numbers; the "0.01-0.05 per 0.5-pt hit" figure is inference from the points normalization, not a measured value.
|
||||
|
||||
==============================================================================
|
||||
|
||||
# Task 4 — Reconstruction archaeology of the collision→coolant-leak chain (#128)
|
||||
|
||||
## A. TIMELINE — how the chain reached its current shape
|
||||
|
||||
**2026-07-05 (7b7d465, initial commit).** WinTesla engine baseline. The engine already contained the collision PRICING: `Mover::StaticBounce` returns `-0.0005 * (vn·vp) * moverMass` (C:/git/bt411/engine/MUNGA/MOVER.cpp:1527) and `Mover::ProcessCollisionList` stamps `damage->damageType = Damage::CollisionDamageType` (MOVER.cpp:1228) and accumulates the total into the Damage record. The engine RETURNS the Damage to the caller — it never dispatches it. So the pricing formula is ENGINE truth [T0], not a port invention, and the crash-response FORWARDING is game-side policy.
|
||||
|
||||
**2026-07-08 (2af401e) — first collision damage dispatch.** `BTDispatchCollisionDamage` added (mech-vs-mech + cultural-icon crunch), unblocked by the STEP-6 cylinder hit-location work. Terrain explicitly did NOT damage: "Terrain (walls/hills) matches neither branch, so it still BLOCKS without damage (faithful to the binary)" — a claim later falsified (the terrain self-damage was hiding in an export gap). The self-TakeDamage on a blocking hit was left as a marked "DEFERRED" note in the response policy (mech4.cpp ProcessCollision header, still visible at mech4.cpp:8660-8664). Era symptom: "grinding has no effect" was structurally true — wall crashes cost nothing.
|
||||
|
||||
**2026-07-12 (bb795e2) — the ram one-shot + first guard.** First live MP wave found StaticBounce reflections compounding 4-40x across multi-solid frames ("a walking bump one-shot a pristine mech for 112,375 pts"). Fix: `frameEntryWorldVelocity` per-contact restore (port guard for an engine-facility drift, gotcha #16) + `Mech::Reset` motion zeroing. Both are port additions [T3-justified], still in place (mech4.cpp:8436-8441).
|
||||
|
||||
**2026-07-13 (065c114) — the ×1e-3 normalization [T3, admitted guess].** "Port normalizes x1e-3 to the point economy [T3]" — an INVENTED scale patched in because raw kinetic amounts (~59,000 pts @10 m/s, moverMass ~60-90k) were hitting the victim's ARMOR zones directly (the divert did not exist yet). Also added `ramLastVictim`/`ramContactLinger` contact-edge (one-bump-one-hit, 0.35s re-arm) — a port invention [T3] compensating for the port's kinematic gait velocity never reading as "separating" the way the binary's bounce-reversed physics velocity did. The contact edge SURVIVES today (mech4.cpp:8444-8449).
|
||||
|
||||
**2026-07-30 (9c83a46, #82 ROOT FIX) — terrain crash SELF-damage recovered.** The wall-grind "knockdown storm / skating" report decomposed; the missing piece was recovered from the EXPORT GAP by raw disasm (scratchpad/night6/gap_4a9770.txt, binary @4aa89f-4aaab4): fallDirection = worldToLocal(damageForce), fallScalar = -(fallDirection·localVelocity), and a self-dispatched `TakeDamageMessage{0x64, zone=-1, engine Damage verbatim}`. This closed the old DEFERRED note — terrain crashes now hurt, per the 1995 binary [T1 raw disasm]. The `[crashdmg]` log line was added here as the pricing arbiter ("run 14 field report: 'tapped a wall and died instantly', so the forwarded amount is suspect vs binary" — mech4.cpp:8523-8531). Also FALSIFIED en route: the action-request-flags drive-suppressor theory. A port-only mid-clip rebind guard kept as "belt-and-braces" (mech4.cpp:8560-8567).
|
||||
|
||||
**2026-07-30 (14ff351, #83) — the COLLISION DIVERT, hours later.** The self-damage immediately exposed its missing second half: raw kinetic figures were falling into the WEAPON zone/armor loop ("tapped a wall and died instantly"). Recovered @0x4a0368 (hub) → FUN_0049ffcc (distributor, also an export-gap fn, raw disasm scratchpad/night6/gap_49ffcc.txt): gate on victimPlayer advancedDamage copy 2 (+0x268, @0x49ffde); scale = (2000/moverMass)/(100km/h)^2/(1-e²) (tbyte 1/3.6 @0x4a0148, 2000f @0x4a0154); <0.5 FREE (@0x4a015c); Round(2×) sub-hits on random HeatSink-family/Gyro/Torso subsystems via cumulative collisionCriticalHitWeight (+0x10C) and ApplyDamageAndMeasure (@0x4ac07c). All byte-anchored [T1] (mech.cpp:960-1028). Bridges (BTHeatSinkFamily/BTGyroscopeFamily/BTTorsoFamily, BTPlayerAdvancedDamageOn) added same commit.
|
||||
|
||||
**2026-07-31 (37dd7f9) — collision-pricing audit, two REMOVALS of port inventions.** (A) The 0.4s `gBlockCooldown` contact-hysteresis (a port stand-in) DELETED — it had suppressed the knockdown while contact held, letting the drive re-slam at full price every frame → the night-7 SAURON grind death (110 priced frames @|v|~34.7). Binary needs no guard (crash threshold @0x4ab178 = 0.0 read from the exe). (B) The ×1e-3 normalization DELETED — it double-normalized against the divert's ~4e-5 scale, making victim-side rams a no-op. Raw dispatch restored [T1 @part_012:15324]. Bench: scratchpad/night7/mp_rampricing.sh — 150s wall push = 0 rattle 0 deaths; 9.6 m/s ram = 2.55 pts rattle. KB swept: the "moverMass ≈1.3e6" figure corrected to measured 60-90k tonnage scale (an earlier mis-attribution).
|
||||
|
||||
**2026-07-31 (a8a0042, #88) — coolant leaks BECOME POSSIBLE.** Until this commit a leak was STRUCTURALLY IMPOSSIBLE: the MechSubsystem ctor filled only its re-declared SHADOW damageZone (#64, gotcha #1), so `HeatSink::UpdateCoolant`'s engine-member read was NULL forever. Fix aliased the engine base member; from this build on, `coolantDraw = zoneDamage × heatLoad` prices live. Same commit documents the authored collision-rattle weights extracted from BTL4.RES [T1]: HeatSinkBank 0.30 / Gyro 0.35 / Torso 0.25 / Myomers 0.35, Condenser+Reservoir 0 for collisions. NOTE the coupling: #83 (rattle) and #88 (leaks) went live ONE DAY apart — the terrain-ram→leak behavior could not have been observed before 2026-07-31 builds.
|
||||
|
||||
**2026-07-31 (839b040) — night-8: the divert declared "matches the field".** KB records: "RAM damage: the binary's internal-only collision divert MATCHES what players observe (gyro/myomer damage, coolant leak, NO armor damage, grinding inert). May be working as intended; awaiting a player ruling. Gitea #103." (context/open-questions.md:1023-1028). I.e. ram→coolant-leak was seen on night-8 and DISPOSED AS AUTHENTIC-SHAPED, with only the armor question left open.
|
||||
|
||||
**2026-08-02 (night-10, build 716) — #128 filed.** "Ramming terrain = INSTANT coolant leak (4 for 4 that night). NOTE: still 100%? speed at impact; which terrain." (first tester handout, git show bf04e1c:docs/OPEN_ISSUES_FOR_TESTERS.txt:126-127). #125-#130 appear as "carried" in scratchpad/SESSION_HANDOFF_20260803.md:51. **No commit has ever addressed #128** (git -S/--grep sweeps find only the two handout commits).
|
||||
|
||||
**2026-08-05 (91bd286) — divert tail corrected.** The #83 block had shipped with an early `return`; the binary actually JUMPS TO THE DEATH TAIL (raw disasm @0x4a0375: jmp 0x4a07b5) — fixed to `goto death_tail` so a rattle death still posts VehicleDead; no score reports on this path (mech.cpp:1149-1164).
|
||||
|
||||
**2026-08-10 (night-15) — Oracle re-confirms:** "#128 Fail - Happens every single time I collide with terrain at speed, needs to be at some mimimum speed. Confirmed." (scratchpad/night15/OPEN_ISSUES_20260810_commentary2.txt:151). Handout regenerated (40c3594) with "#128 ... Oracle re-confirms STILL LIVE".
|
||||
|
||||
**2026-08-11 (3545056, #162) — advancedDamage stamping.** "Steam eggs stamp the host's advancedDamage mission-wide (penalty was silently off for every steam match)". Night-14 steam logs show advDmg MIXED across players (docs/KD_CHAIN_AUDIT_20260811.md:194: 8 APPLYING all advDmg=1, 32 SKIPPED all advDmg=0). Since the rattle gate is the SECOND advancedDamage cell (+0x268), set from the same mission egg copy (btplayer.cpp:2128-2132: `advancedDamageOn = advancedDamageOn2 = bt_mission->AdvancedDamageOn()`), the collision rattle was per-node-egg-dependent in steam matches before build 885 and is now uniformly the host's setting.
|
||||
|
||||
## B. Provenance verdict per element
|
||||
|
||||
- **Pricing ([crashdmg] amount):** ENGINE (WinTesla MOVER.cpp StaticBounce), shipped 1995 — NOT invented [T0]. The [crashdmg] fields (mass, |v|, force) are diagnostics only.
|
||||
- **Terrain crash→TakeDamage forwarding:** 1995 BT game code @4aa89f-4aaab4, recovered by raw disasm 2026-07-30 [T1]. OUR addition history: absent (pre-07-30) → present raw. The engine never forwarded; the game always did.
|
||||
- **The divert + DistributeCollisionDamage:** byte-anchored reconstruction [T1], constants read from the exe.
|
||||
- **Rattle target weights:** authored data (BTL4.RES) [T1].
|
||||
- **Leak pricing (`coolantDraw = zoneDamage × heatLoad`, floor 0.0025 / ON 0.003):** byte-verified constants (_DAT_0050e3d8/_DAT_0050e3d4, heat.cpp:63-80); an earlier 1e-4 misread made them fire 25-30x too eagerly (task #9 correction).
|
||||
- **Port inventions still live [T3]:** frameEntryWorldVelocity restore; ramLastVictim/ramContactLinger 0.35s contact edge; the mid-clip bmp rebind guard; the collisionAssistant null guard. Port inventions REMOVED: ×1e-3 normalization, gBlockCooldown.
|
||||
- **Known port drift touching the leak:** the heatLoad smoothing window is frame-rate dependent (28 Hz pod vs ~59 fps port halves the window → heatLoad 2x twitchier, heat.cpp:872-880) — documented as the #119 leak-voice stutter cause; also the port's impact |v| comes from the kinematic gait, not binary mover physics (the whole reason the contact-edge guards exist).
|
||||
|
||||
## C. Prior unresolved observations this audit should explain
|
||||
|
||||
1. **#103 (night-8, open era question):** ram → gyro/myomer damage + myomer coolant leak, no armor — filed as possibly-authentic, "awaiting a player ruling" on armor. #128 is the terrain-side escalation of the same mechanism; the night-8 disposition ("matches the divert, may be WAI") is the reason nobody treated the leak itself as a bug for ten days.
|
||||
2. **The determinism testers report is SHAPED like the reconstruction:** ≥0.5 scaled pts (the "minimum speed" testers observe — ~18.5 u/s at 65t per 37dd7f9's message) → guaranteed ≥1 sub-hit → 0.3+0.35 cumulative weight on heat-family targets → any zone damage × running heatLoad ≥ 0.0025 → leak flag. Whether the 1995 pod also leaked on EVERY over-floor terrain crash is the open question — no ledger records a 1995-side check of leak frequency, only of the divert's structure.
|
||||
3. **#135 (leak with NO flashing annunciators) and #97 (three-level leak severity, we only ever produce the lowest)** — both still open, both plausibly entangled with the leak-magnitude side of #128.
|
||||
4. **The steam advancedDamage inconsistency:** before 4.11.885 the +0x268 gate varied per node in steam matches; testers could legitimately disagree about whether terrain rams did anything at all. Post-885 it is the host's egg mission-wide — if the host egg authors advancedDamage ON, every player rattles/leaks.
|
||||
5. **Un-won roll fidelity:** the distributor deliberately does NOT normalize weights (mech.cpp:976-977 "faithful: the binary does not normalize") — with only heat-family+gyro+torso eligible, a sub-hit can land NOWHERE; nobody has verified the eligible-roster cumulative weight against the binary's actual roster order, which affects how often the rattle hits a heat-family (leaking) member vs fizzling.
|
||||
|
||||
==============================================================================
|
||||
|
||||
## Ticket #128 Task 3 — Field receipts from night-14 (build 4.11.857, 4 machines) and night-15 (build 4.11.883, 6 machines) steam logs
|
||||
|
||||
**Method note:** logs have no per-line timestamps; times were reconstructed from `[rstat] frames=N` anchors at 0.017 s/frame (validated against `[drive] dt=0.017`), linearly interpolated by line number (accuracy ~±1 s). Leak receipt = `[techstat] <sub> condition 2 SET` (condition 2 = CoolantLeaking per game/reconstructed/btl4galm.cpp:116) plus first-seen `[galm-item] cond=2` per subsystem. Scripts: scratchpad crash_analysis{,2,3}.py (session scratchpad dir).
|
||||
|
||||
### (1) Distribution
|
||||
Night 14: **8,528** [crashdmg] events (dave 805, santo 1,065, michael 4,351, rajel 2,307). Night 15: **30,715** (Dave 856, rajel 6,468, acaci 9,378, Michael 9,680, santo 2,765, eleng 1,568). Amounts are wildly bimodal: **~98% of events are amount<2,000** (median ~250–530, p95 ~1.1–1.8k) at scrape speeds |v| 3–6.5; a thin tail of discrete rams: amount 10k–145k at |v| 16–60. n14 amt≥10k: 47 events (0.55%); n15: ~145 (0.47%). |v| shows hard structure: scraping saturates at |v|≈6.4–6.5; a dense cluster at |v|=34.3–34.9 (full-throttle wall rams, esp. rajel: 20+ rams all at 34.x); outliers 40–60 (falls/jump landings — inference). mass field is the mover's own mass (35k–90k, varies per mech pick), never the partner's.
|
||||
|
||||
### (2) Correlation + empirical threshold — THE KEY RESULT
|
||||
The collision→instant-leak signature exists **only in advDamage=1 missions** and **only for discrete high-speed rams**, not scrapes:
|
||||
- **Night 14, advDamage=1, amount≥10k: 7 events → 6/7 (86%) followed by an internal-subsystem `[techstat]` receipt within ≤1 s; 4/7 (57%) an explicit CoolantLeaking (cond 2) SET at +0.2–0.4 s.** Michael's 3 rams (14.8k/26.5k/30k raw, |v| 24.4–43.6): ALL THREE → `Myomers condition 2 SET` (coolant leak) at +0.2–0.4 s. Dave's: 76k ram → Gyroscope+HeatSink DMG at +0.0/+0.3 s; 33k ram → Myomers LEAK at +0.3 s. The struck subsystems (Myomers/HeatSink/Gyroscope/Condenser) are exactly the DistributeCollisionDamage lottery families — this is the rattle firing, receipt-latency "instant" [T2 from field receipts].
|
||||
- **advDamage=0 (n14 40 events amt≥10k + n15 ~145): ~2/185 (~1%) show a ≤1 s receipt**, and those are ambiguous (one is ERSLaser_1, not a rattle-family sub — concurrent weapon fire). All other follow-ups sit at +1.3–9.9 s, i.e. ambient combat damage.
|
||||
- **Empirical threshold:** no event below |v|≈15.5 ever priced ≥8k raw; the smallest ram that sprang a leak was raw=14,811 @ |v|=24.4 (65t). Scrape events (|v|≤6.5) never exceeded ~3.1k raw. So the field threshold ≈ **raw amount ~15k ⇔ |v| in the low-to-mid 20s** — matching Oracle's "needs some minimum speed" and consistent with the 0.5-pt free floor of the (2000/mass)/(27.78)² pricing (raw floor ≈ 0.19×mass = 9.6k–17k depending on tonnage/elasticity) [inference from mech.cpp:987-991 + data].
|
||||
- 5-s burst-level correlation over ALL bursts (including scrapes) is only 5–22% (n14) and 0–6% (n15) — the tester "every single time" is about solid rams under advDamage=1, NOT scrapes. Baseline: leaks are common from ordinary weapon fire — 85–100% of cond-2 SETs have NO crashdmg in the prior 5 s (n14: 144/161 orphaned; n15: ~124/142).
|
||||
|
||||
### (3) Cadence — scraping is priced per frame
|
||||
Yes, repeat-fire: consecutive-line [crashdmg] runs of 38–120 events at **25–82 events/s** (frame rate ≈59 → up to ~1.4 pricings per rendered frame). Confirms the santo observation: santo n14 worst run = 50 events in 1.1 s (45/s) totaling 35,601 raw; dave n14 = 50 in 0.6 s (82/s), 51,230 raw; rajel n15 = 94 in 2.2 s, 121,335 raw. Cluster-level (≤2 s gaps): worst = rajel n15, 1,829 events over 43.3 s totaling 1.77M raw. Each per-frame event is individually tiny (med ~300 raw ≈ 0.011 pt after scaling at 70t) → every one falls under the 0.5-pt free floor, so a scrape run costs ZERO through the rattle even when gated on — but the run's raw SUM (25k–121k) would rattle 1–4+ sub-hits if it were priced once as a single impact. The per-frame re-pricing therefore currently under-damages scrapes rather than over-damaging them (through this path).
|
||||
|
||||
### (4) advDamage=0 cross-check — THE GATE HOLDS
|
||||
Night-15 receipts: advDamage=0 for effectively every mission with crash traffic (Dave 11×0/2×1 early, acaci's lone =1 receipt superseded by =0 600 lines later before any ram, Michael's =1 was pre-mission line 56; rajel/santo/eleng all 0). Under those adv=0 receipts, **the instant collision→leak signature is ABSENT** (0–1 borderline in ~145 rattle-eligible rams, 0.7%). The signature appears exactly and only where [exp] advDamage=1 (dave/michael night 14). **Field receipts therefore show the port's rattle path HONORING the +0x268 gate — the leak does NOT come from a gate bypass.** Corollary: the testers who reported "every single time" were almost certainly dave/michael in the n14 advDamage=1 missions (5 and 3 such receipts respectively), or in non-steam sessions outside this evidence set.
|
||||
|
||||
### (5) Wall vs mech-ram
|
||||
Not distinguishable from these receipts: [crashdmg] carries no partner identity and mass= is always the logger's own mover mass. Indirect only: the |v|=34.x pile-up is top ground speed into static geometry; |v| 40–60 outliers imply falls/DFA. No receipt-visible difference in leak outcome by |v| class beyond the amount threshold.
|
||||
|
||||
==============================================================================
|
||||
|
||||
TICKET #128 — BINARY TRUTH: THE COMPLETE 1995 COLLISION-DAMAGE CHAIN
|
||||
|
||||
=== STAGE 1: THE EMITTER (who dispatches, with what amount) ===
|
||||
|
||||
Pricing is ENGINE code we compile against [T0], dispatch is BT binary code [T1].
|
||||
|
||||
1a. Per-contact pricing — Mover::StaticBounce (C:/git/bt411/engine/MUNGA/MOVER.cpp:1433-1528) [T0]:
|
||||
- impact = worldLinearVelocity · surfaceNormal (the NORMAL component only; grazing scrapes contribute little).
|
||||
- impact > 0 (moving away) -> return 0. No other speed gate on damage: minimumBounceSpeed (streamed Mover attr "MinimumBounceSpeed") only zeroes *elasticity for slow hits (MOVER.cpp:1470-1473) — it kills the rebound, NOT the damage.
|
||||
- Return value (MOVER.cpp:1527): -0.0005·(vn·vp)·moverMass, which algebraically reduces to raw = 0.0005 · moverMass · (1−e²) · impact² — the kinetic-energy loss in kilojoules (mass in kg, velocity m/s; the DynamicBounce comment at MOVER.cpp:1569 confirms the kJ intent). This is the amount [crashdmg] prints.
|
||||
- Mover::ProcessCollision (MOVER.cpp:1365-1414) calls StaticBounce per contact; Mover::ProcessCollisionList (MOVER.cpp:1214-1360) SUMS raw damage across the frame's contacts (total_damage, :1283/:1350), sets damageType = Damage::CollisionDamageType (= 0, :1228) and damageForce = post−pre velocity delta (:1351).
|
||||
|
||||
1b. Dispatch — the binary's ground+collide response block (port Mech::AuthenticGroundAndCollide, game/reconstructed/mech4.cpp:8383-8537; binary @4aa630-@4aab5f, recovered byte-by-byte from the export gap, raw disasm scratchpad/night6/gap_4a9770.txt) [T1]:
|
||||
- crushable-icon contact (0.00123f sentinel) -> gyro crunch only (@4aa7ce-4aa871), NO TakeDamage.
|
||||
- blocking contact with damageAmount > 0 -> full frame rejection (velocity zeroed, position restored) + @4aa984-4aaab4 builds TakeDamageMessage{id 0x64, zone=-1, the collision Damage VERBATIM} and Dispatches it at the mech ITSELF. There is NO minimum-amount/speed gate at the emitter: ANY nonzero closing contact dispatches. (iv2 > 40 (u/s)² @0x4ab184 gates only the knockdown/bump ANIMATION ≈ 22.8 km/h.)
|
||||
|
||||
=== STAGE 2: THE DISTRIBUTOR — FUN_0049ffcc @0049ffcc (part_012.c:14374-14430) [T1, decomp + raw disasm scratchpad/night6/gap_49ffcc.txt] ===
|
||||
|
||||
Reached ONLY via the hub Mech::TakeDamageMessageHandler @0x4a0230: part_012.c:14636-14638 — `if (damage.damageType == 0) { FUN_0049ffcc(mech, &damage); goto LAB_004a07b5; }` — the goto jumps past the zone/armor loop entirely. Collision damage NEVER touches zone armor, in either gate state.
|
||||
|
||||
2a. THE GATE (@0x49ffde): `if (*(victimMech+0x190 /*owning BTPlayer*/ +0x268) == 0) return;` — the SECOND advancedDamage cell (egg/technician "splash/collision damage" setting). Gate OFF = TRULY NOTHING: the entire function body is inside this if; combined with the hub's goto, a collision then costs zero armor, zero subsystems, zero anything. [T1]
|
||||
|
||||
2b. THE SCALING (@0x49ffff-0x4a0030): amount *= ((2000/moverMass) / ((100·(1/3.6))²)) / (1 − elasticity²). Constants from raw disasm: tbyte @0x4a0148 = 0.2777778 (1/3.6, km/h→m/s), 2000f @0x4a0154, 1.0f @0x4a0158, mass @mover+0x20c, elasticity @mover+0x244. Multiplied against the raw kJ from Stage 1, EVERYTHING CANCELS: scaled = 0.0005·m·(1−e²)·v_n² × 2000/(m·771.605·(1−e²)) = (v_n/27.778)² — the squared ratio of normal impact speed to 100 km/h, independent of mech mass and elasticity [T1 algebra on T0+T1 inputs].
|
||||
|
||||
2c. THE THRESHOLD (@0x4a003c-0x4a0045): scaled < 0.5f (@0x4a015c) -> return, FREE. => minimum damaging impact: v_n ≥ 27.778·√0.5 = 19.64 m/s ≈ 70.7 km/h closing normal speed, for EVERY mech. Walking scrapes (tangential, or slow) are free by construction.
|
||||
|
||||
2d. SUB-HIT COUNT (@0x4a0051): n = _ftol(scaled × 2.0f (@0x4a0160)) — FUN_004dcd94 is the CRT _ftol, not a helper (Ghidra shows ROUND(ST0); MSVC _ftol truncates — minor rounding uncertainty, n identical at all half-integer boundaries). amount /= n (@0x4a0065). With truncation each sub-hit is 0.5-1.0 points (n=1 for scaled∈[0.5,1), n=2 at 100 km/h, n=3 at ~140 km/h, n=6 at 182 km/h supercharge).
|
||||
|
||||
2e. TARGET SELECTION (the loop @0x4a006b-0x4a0121; NOT FUN_004ac07c): per sub-hit, roll = FUN_00408050() = rand()·(1/32768) ∈ [0,1) (part_000.c:7930); walk the subsystem roster (count mech+0x124, array mech+0x128) in ROSTER ORDER; an entry is ELIGIBLE iff FUN_0041a1a4(its derivation chain, TAG) matches one of THREE tags (part_012.c:14403-14410): 0x50e590, 0x50fdc0, 0x510b08; accumulate eligible entries' collisionCriticalHitWeight (+0x10C, streamed res+0xDC "CollisionCriticalHitWeight"); first entry whose cumulative ≥ roll receives the sub-hit via FUN_004ac07c and the walk breaks. Weights are UN-NORMALIZED: an un-won roll lands NOWHERE (faithful).
|
||||
|
||||
2f. ELIGIBILITY — the tags [T1, with a KB-internal contradiction I must flag]: FUN_0041a1a4 (part_002.c:3843) matches by Derivation-object POINTER IDENTITY walked up the parent chain. 0x50fdc0 = Gyroscope's node (DefaultData 0x50fdb0+0x10), 0x510b08 = Torso's node (DefaultData 0x510af8+0x10). 0x50e590 = the HeatSinkBank/AggregateHeatSink (0xBBE) node — NOT the HeatSink base family: decomp-reference.md rows 55+87 (task #57 correction) say so, and it is logically forced by part_013.c:5864 — the HeatSink base ctor itself tests 0x50e590 to let ONLY the bank skip the sink link-attach (heat.cpp:620-628 task #9, live-verified: treating it broader blocked condenser→bank conduction and caused the heat runaway). So the BINARY's eligible set = {HeatSinkBank, Gyroscope, Torso} — authored weights 0.30/0.35/0.25 (Σ=0.90; 10% of rolls land nowhere). The authored Myomer weight 0.35 is DEAD DATA for collisions (myomers are PoweredSubsystem-derived, no gyro/torso/bank tag in their chain; the other +0x10C-like path, zone crits @0049ccc4, uses the ZONE's crit-entry list weights instead). ⚠ CONTRADICTION: game/reconstructed/mech.cpp:1004-1018 (DistributeCollisionDamage) and context/combat-damage.md:1120 read 0x50e590 as the whole HeatSink FAMILY (BTHeatSinkFamily() returns HeatSink::GetClassDerivations(), heat.cpp:1447-1450) — that broadens eligibility to condenser/reservoir/generator/sensor/MYOMERS/weapons, i.e., nearly the entire coolant-bearing branch. This is a prime #128 root-cause candidate (see verdict below).
|
||||
|
||||
2g. THE APPLICATOR — FUN_004ac07c (part_013.c:4274-4282) [T1]: NOT a picker. It snapshots the target subsystem's PRIVATE zone damageLevel (this[0x38]+0x158, the Subsystem::damageZone @+0xE0), calls the subsystem's TakeDamage virtual (vtbl+0x24) with the (scaled, divided) Damage, returns the delta. The MechSubsystem vtbl+0x24 analog FUN_004ac0bc (part_013.c:4290): zone->TakeDamage (zone vtbl+0x18; DamageZone::TakeDamage @0041e4e0 == DAMAGE.cpp:391: damageLevel += amount × damageScale[type]) then if level ≥ 1.0 (_DAT_004ac140) -> failure states (Destroyed). The private zone's damageScale[0] (collision) is the authored damageTypePoints normalized as scale = 1/(points·defaultArmorPoints) (mechsub.cpp:221-237, ArmourNumerator=1.0 @0x4ac860) — exact authored bank values not pinned [T3].
|
||||
|
||||
=== STAGE 3: THE LEAK ONSET — HeatSink::UpdateCoolant FUN_004adbf8 (part_013.c:5733) [T1 decomp, constants T2] ===
|
||||
|
||||
Run per-frame from HeatSink::Update for every HeatSink-DERIVED subsystem only (CLASSMAP.md tree: HeatSink -> Condenser/Reservoir/HeatSinkBank/Generator/PoweredSubsystem -> Sensor/Myomers/MechWeapon...). Gyroscope and Torso are HeatWatcher->PowerWatcher-derived (CLASSMAP.md:34-98) — they have NO coolant members and CANNOT leak.
|
||||
- coolantDraw(+0x130) = ownZoneDamage (this[0x38]+0x158) × heatLoad (+0x120, the SMOOTHED radiated-heat running average, attr "NormalizedPressure");
|
||||
- draw < 0.0025 (_DAT_0050e3d8) -> zeroed (the floor);
|
||||
- the draw drains own coolantLevel(+0x12C) × dt, replenished by DrawCoolant (vtbl+0x38, @0x4add00) recursing up linkedSinks × coolantFlowScale(+0x15C) to the Reservoir (@0x4af3b0) — the cockpit coolant bar visibly drops;
|
||||
- HYSTERESIS coolantActive(+0x138): OFF->ON when draw > 0.003 (_DAT_0050e3d4); ON->OFF when draw < 0.0025. coolantActive IS the ReportLeak attribute ridden by the 19 authored leak watchers (the 3-note warning) and the "CoolantLeaking" TechStatusType (@50df17).
|
||||
- LEAK CONDITION: zoneDamage × heatLoad > 0.003. One 0.5-1.0-pt rattle sub-hit gives zoneDamage = amount × damageScale[0]; with any working heat load (live port measurements saw myomer heatLoad ≈ 0.014 at idle-ish work: 0.5×0.014 = 0.007 > 0.003) a SINGLE sub-hit plausibly crosses the threshold immediately — the leak is instant once a coolant-bearing subsystem is rattled at all [formula T1; magnitude T3 pending authored bank points].
|
||||
|
||||
=== THE EXPLICIT ANSWERS ===
|
||||
|
||||
(a) When does 1995 dispatch damage at all? The self-TakeDamage(type 0) is dispatched on ANY blocking contact with nonzero closing normal speed (no emitter threshold). But it has EFFECT only when the distributor's scaled amount (v_n/27.778 m/s)² ≥ 0.5, i.e. closing NORMAL speed ≥ 19.64 m/s ≈ 70.7 km/h — mass- and elasticity-independent (exact cancellation). Below that every scrape/tap is free; gate (+0x268) OFF makes ALL collisions free. Mech envelope: stat-sheet speeds 40 (gimped)/143 (normal)/182 (supercharged) kph (context/pod-hardware.md:589-598) — so a run-speed head-on ram crosses easily, a walk or graze never does.
|
||||
|
||||
(b) When does 1995 first spring a coolant leak? Requires ALL of: (i) advancedDamage(+0x268) ON; (ii) v_n ≥ 70.7 km/h (≥1 sub-hit); (iii) the lottery landing on the HeatSinkBank — the ONLY coolant-bearing eligible class — probability 0.30 per sub-hit (gyro 0.35 and torso 0.25 rattles cannot leak); (iv) resulting bank zoneDamage × bank heatLoad > 0.003 (plausibly crossed by one sub-hit on any warm mech). Per-ram leak probability ≈ 1−0.7ⁿ: ~30% at 71 km/h (n=1), ~51% at 100 km/h (n=2), ~66% at 140 km/h (n=3), ~88% at 182 km/h (n=6). 1995 is NEVER "every single time".
|
||||
|
||||
VERDICT for #128 (inference from the above): the deterministic instant leak the testers report cannot be authentic 1995 behavior at any speed. The port's DistributeCollisionDamage (mech.cpp:1016) broadened 0x50e590 from bank-only to the whole HeatSink family — so myomers (weight 0.35 EACH, several per mech) and other coolant-bearing subsystems enter the lottery, the cumulative weight saturates, and nearly every rattle sub-hit lands on something that runs UpdateCoolant -> leak virtually every qualifying ram. Compounding: the steam eggs now stamp advancedDamage mission-wide (commit 3545056), turning the gate ON for matches where it was silently OFF before, and the run-14 note (mech4.cpp:8523-8525) already flags the forwarded [crashdmg] amount as suspect vs binary.
|
||||
+9
-2
@@ -14,8 +14,15 @@ Resolve AFTER all 7 families report (Stage 1 reconciliation), then again at link
|
||||
- **Player__VehicleDeadMessage**: BT build carried `killerName` (+0x1c) the engine base lacks; read via offset.
|
||||
|
||||
### Stubbed (no WinTesla analog) — revisit at integration
|
||||
- BTPlayer ctor role/mission-registry resolution: `GetMissionRegistry()` / `GetRoleRegistry()->Lookup()` /
|
||||
`GetMission()->GetGameModel()` have no WinTesla analog → stubbed; base-set `scenarioRole` stands.
|
||||
- ~~BTPlayer ctor role-registry resolution~~ — **RESOLVED 2026-08-07.** The claim "no WinTesla
|
||||
analog → stubbed; base-set `scenarioRole` stands" was wrong twice over: the base ctor sets
|
||||
`scenarioRole` to **NULL** (PLAYER.cpp:680), so nothing "stood"; and the analog exists —
|
||||
`Mission::GetScenarioRole(name)` (MISSION.h:162) walks the same `scenarioRoleChain` that
|
||||
`BTL4Mission` fills via `AddScenarioRole()` when it parses the role pages. A NULL role zeroed
|
||||
EVERY scoring value (a kill scored 4.88 instead of 505.88, the eject charge read 0, the death
|
||||
cost was skipped). Now bound, with a `Role::Default` fallback and a BOUND/NULL receipt.
|
||||
`GetMission()->GetGameModel()` (the freeForAll compare) stays genuinely stubbed — the shipped
|
||||
code discarded its result anyway.
|
||||
|
||||
### Link-time externs to be PROVIDED by owning modules (Stage 3)
|
||||
- `ToggleVoiceAssist`, `Is_Destroyed` (mechmppr)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include "munga.h"
|
||||
#pragma hdrstop
|
||||
|
||||
@@ -978,6 +979,31 @@ AudioStateTrigger::AudioStateTrigger(
|
||||
MemoryStream_Read(stream, &excludeTransition);
|
||||
MemoryStream_Read(stream, &excludeState);
|
||||
|
||||
// #154 DELIBERATE DEVIATION from shipped 1995 content (the ONLY authored-
|
||||
// content override in the port -- revert with BT_GEN_WARN_SHIPPED=1).
|
||||
// BTL4.RES authors the generator-out warning as (trigState=4, inverse=1,
|
||||
// Start) = fire on LEAVING GeneratorOut -- so a thermal trip was SILENT and
|
||||
// the klaxon played at RECOVERY/respawn instead (live-benched,
|
||||
// gen_edge_bench.sh; bytes @0x2bf13, decode triple-verified). Every other
|
||||
// fault alarm in the game is authored enter-state, inverse is otherwise
|
||||
// only a Stop terminator (144-row census), and the manual (p14/p23) lists
|
||||
// this as a loss-time warning: a one-bit 1995 authoring slip. Flip it to
|
||||
// enter-4 so the warning speaks at the TRIP, stays silent at recovery and
|
||||
// at the respawn reset's 4->0 edge. (Testers' "voice at trip" memories are
|
||||
// Firestorm's speech system -- BT 4.10 shipped no speech samples at all.)
|
||||
{
|
||||
extern char g_lastBoundAttrName[64];
|
||||
if (triggerState == 4 && inverseTrigger != 0
|
||||
&& strcmp(g_lastBoundAttrName, "GeneratorState") == 0
|
||||
&& getenv("BT_GEN_WARN_SHIPPED") == 0)
|
||||
{
|
||||
inverseTrigger = 0;
|
||||
if (getenv("BT_ATTRBIND_LOG"))
|
||||
DEBUG_STREAM << "[statecfg] #154 fixup: GeneratorState leave-4"
|
||||
" Start -> enter-4 Start\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
if (getenv("BT_ATTRBIND_LOG")) { static int s_stc=0; if (s_stc++<120)
|
||||
DEBUG_STREAM << "[statecfg] attrPtr=" << (void*)attributePointer
|
||||
<< " comp=" << (void*)audioComponentSocket.GetCurrent()
|
||||
@@ -1076,10 +1102,21 @@ void
|
||||
//
|
||||
// Check for the trigger state
|
||||
//
|
||||
if (
|
||||
(!inverseTrigger && new_state == triggerState) ||
|
||||
(inverseTrigger && old_state == triggerState)
|
||||
)
|
||||
const int bt_fires =
|
||||
((!inverseTrigger && new_state == triggerState) ||
|
||||
(inverseTrigger && old_state == triggerState));
|
||||
// #154 diag (BT_AUDIO_EDGE=1): EVERY state-edge delivery to EVERY authored
|
||||
// trigger, fired or not -- state edges are rare, so this is low-volume.
|
||||
// Correlate with the [gen] TRIPPED/RESTARTING lines to observe which edge
|
||||
// actually starts a warning sequence.
|
||||
if (getenv("BT_AUDIO_EDGE"))
|
||||
DEBUG_STREAM << "[audioedge] old=" << old_state << " new=" << new_state
|
||||
<< " trig=" << triggerState << " inv=" << (int)inverseTrigger
|
||||
<< " ctl=" << (int)controlID << "/" << controlValue
|
||||
<< " comp=" << (void*)audioComponentSocket.GetCurrent()
|
||||
<< (bt_fires ? " FIRED" : " no-fire")
|
||||
<< "\n" << std::flush;
|
||||
if (bt_fires)
|
||||
{
|
||||
Check(&audioComponentSocket);
|
||||
Check(audioComponentSocket.GetCurrent());
|
||||
|
||||
@@ -117,6 +117,16 @@ EntitySegment*
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// #149 perf telemetry (BT_PERF_LOG; counters cost two increments when unset).
|
||||
// gBTSegWDirty counts the EXPENSIVE arm -- the mark-every-segment pass that
|
||||
// invalidates the whole table -- separately from plain calls, because the #141
|
||||
// sweep put this accessor on per-frame weapon/beam paths and the open question
|
||||
// is whether that multiplied the dirty-pass rate (each pass forces the next
|
||||
// query of EVERY segment to re-derive its parent chain).
|
||||
int gBTSegWCalls = 0;
|
||||
int gBTSegWDirty = 0;
|
||||
double gBTSegWMs = 0.0;
|
||||
|
||||
//
|
||||
void
|
||||
JointedMover::GetSegmentToWorld(
|
||||
@@ -125,6 +135,13 @@ void
|
||||
)
|
||||
{
|
||||
Check(this);
|
||||
// DEFAULT ON (operator, 2026-08-09): every player's session log should
|
||||
// carry this -- cross-machine comparison is the point. BT_PERF_LOG=0
|
||||
// opts out. Cost: ~2 QPC reads per call, microseconds per second.
|
||||
static const int sPerf = !(getenv("BT_PERF_LOG") && *getenv("BT_PERF_LOG") == '0');
|
||||
LARGE_INTEGER t0, t1, fq;
|
||||
++gBTSegWCalls;
|
||||
if (sPerf) QueryPerformanceCounter(&t0);
|
||||
JointSubsystem *joints = GetJointSubsystem();
|
||||
Check(joints);
|
||||
|
||||
@@ -135,6 +152,7 @@ void
|
||||
//
|
||||
if (joints->AreJointsModified())
|
||||
{
|
||||
++gBTSegWDirty;
|
||||
EntitySegment::SegmentTableIterator iterator(segmentTable);
|
||||
EntitySegment *current_segment;
|
||||
while( (current_segment = iterator.ReadAndNext() ) != NULL)
|
||||
@@ -153,6 +171,12 @@ void
|
||||
my_segment.GetSegmentToEntity(),
|
||||
localToWorld
|
||||
);
|
||||
if (sPerf)
|
||||
{
|
||||
QueryPerformanceCounter(&t1);
|
||||
QueryPerformanceFrequency(&fq);
|
||||
gBTSegWMs += 1000.0 * (double)(t1.QuadPart - t0.QuadPart) / (double)fq.QuadPart;
|
||||
}
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,17 @@ void
|
||||
while ((lamp_pointer=i.ReadAndNext()) != NULL)
|
||||
{
|
||||
Check(lamp_pointer);
|
||||
// #135: an edge that arrived while this lamp's page was not current
|
||||
// (or during the one-frame mask race of a page transition) skipped its
|
||||
// NotifyOfStateChange -- the panel value latches the last asserted
|
||||
// flash, so a missed CLEAR left loop buttons flashing forever. The
|
||||
// lamp is on the ACTIVE list here, so its mode is current: deliver
|
||||
// the missed notification now.
|
||||
if (lamp_pointer->staleAlertNotify)
|
||||
{
|
||||
lamp_pointer->staleAlertNotify = False;
|
||||
lamp_pointer->NotifyOfStateChange();
|
||||
}
|
||||
lamp_pointer->Update();
|
||||
}
|
||||
Check_Fpu();
|
||||
@@ -248,6 +259,7 @@ Lamp::Lamp(
|
||||
previousState = LampStateUndefined;
|
||||
manager = lamp_manager;
|
||||
alertActive = 0;
|
||||
staleAlertNotify = False; // #135
|
||||
|
||||
manager->AddLamp(this);
|
||||
Check_Fpu();
|
||||
@@ -281,6 +293,10 @@ void
|
||||
{
|
||||
NotifyOfStateChange();
|
||||
}
|
||||
else
|
||||
{
|
||||
staleAlertNotify = True; // #135: deliver at next in-mode Update
|
||||
}
|
||||
}
|
||||
Check_Fpu();
|
||||
}
|
||||
@@ -311,6 +327,10 @@ void
|
||||
{
|
||||
NotifyOfStateChange();
|
||||
}
|
||||
else
|
||||
{
|
||||
staleAlertNotify = True; // #135: deliver at next in-mode Update
|
||||
}
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,14 @@ protected:
|
||||
*manager;
|
||||
int
|
||||
alertActive;
|
||||
// #135: an alert-state EDGE whose NotifyOfStateChange was skipped by the
|
||||
// mode gate (page not current, or the one-frame mask race during a page
|
||||
// transition). The panel value latches the last asserted flash until the
|
||||
// next assert, so a skipped clear-edge left loop buttons flashing forever
|
||||
// (field: Oracle night-15, "Loop 6 NEVER stopped"). Delivered by Update()
|
||||
// at the lamp's next in-mode frame.
|
||||
Logical
|
||||
staleAlertNotify;
|
||||
};
|
||||
|
||||
//#########################################################################
|
||||
|
||||
@@ -55,9 +55,32 @@ ScenarioRole::ScenarioRole(const CString &role_name, const CString &model_file)
|
||||
damageBias = player_data->damageBias;
|
||||
friendlyFirePenalty = player_data->friendlyFirePenalty;
|
||||
player_res_des->Unlock();
|
||||
|
||||
// SCORE PROVENANCE (ungated, 2026-08-07). Every unmatched row of the
|
||||
// original manual's scoring chart -- +500 a kill, -1000 an eject, -500
|
||||
// an ammo death -- is sourced HERE, from the role's GameModel record
|
||||
// (type 0xf, 7 dwords; the binary's ctor @00429a9c copies rec[0..6]).
|
||||
// A field log showed `charge=0 (role killBonus)`, and the two ways that
|
||||
// happens -- record authored zero, or resource lookup missed -- are
|
||||
// indistinguishable in release because the miss path's Warn/Tell compile
|
||||
// out (DEBUGOFF.h). One line per role settles it.
|
||||
DEBUG_STREAM << "[role] '" << (const char *)role_name
|
||||
<< "' model='" << (const char *)model_file
|
||||
<< "' killBonus=" << (float)killBonus
|
||||
<< " deathPenalty=" << (float)specialCaseDeathPenalty
|
||||
<< " dmgInf=" << (float)damageInflictedModifier
|
||||
<< " dmgRcv=" << (float)damageReceivedModifier
|
||||
<< " bias=" << (float)damageBias
|
||||
<< " ff=" << (float)friendlyFirePenalty
|
||||
<< " return=" << (int)returnFromDeath
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
else
|
||||
{
|
||||
DEBUG_STREAM << "[role] '" << (const char *)role_name
|
||||
<< "' model='" << (const char *)model_file
|
||||
<< "' -- RESOURCE NOT FOUND, all score values default to 0"
|
||||
<< "\n" << std::flush;
|
||||
Tell(role_name);
|
||||
Warn(" does not exists in resource! ");
|
||||
damageReceivedModifier = 0.0f;
|
||||
|
||||
@@ -96,6 +96,19 @@ AttributeWatcher::AttributeWatcher(
|
||||
|
||||
attributePointer = simulation->GetAttributePointer(attribute_name);
|
||||
|
||||
// The bound attribute's NAME, visible to derived ctors (the stream layout
|
||||
// puts the trigger fields AFTER the base bind, so a derived ctor that needs
|
||||
// to know WHICH attribute it just bound -- the #154 GeneratorState fixup --
|
||||
// reads it from here). Valid only during construction.
|
||||
{
|
||||
extern char g_lastBoundAttrName[64];
|
||||
const char *an = (const char *)attribute_name;
|
||||
int i = 0;
|
||||
for (; an != 0 && an[i] != '\0' && i < 63; ++i)
|
||||
g_lastBoundAttrName[i] = an[i];
|
||||
g_lastBoundAttrName[i] = '\0';
|
||||
}
|
||||
|
||||
if (getenv("BT_ATTRBIND_LOG"))
|
||||
{
|
||||
extern int g_curAudioWatcherClass;
|
||||
|
||||
@@ -23,6 +23,7 @@ ALuint *g_buffers;
|
||||
int g_numBuffers;
|
||||
const char *g_bufferNames[512]; // bufferInd -> sample file (diag: the live-playing dump)
|
||||
int g_curAudioWatcherClass = 0;
|
||||
char g_lastBoundAttrName[64] = {0}; // AttributeWatcher bind name, for derived-ctor fixups (#154)
|
||||
|
||||
//
|
||||
// Minimal canonical-PCM WAV reader. The repo's libsndfile-1.dll is a STUB
|
||||
|
||||
@@ -1118,11 +1118,22 @@ LBE4ControlsManager::~LBE4ControlsManager()
|
||||
|
||||
#ifdef BT_GLASS
|
||||
//
|
||||
// Symmetric with the Create above (2026-08-06). ~PadRIO tears the panels
|
||||
// down itself, so this is a no-op there -- but the HARDWARE RIO's dtor
|
||||
// knows nothing about them, and windows that outlive the surfaces they
|
||||
// blit are a crash waiting for the next mission cycle. Destroy is safe
|
||||
// if they were never created.
|
||||
// Symmetric with the Create above (2026-08-06). The HARDWARE RIO's dtor
|
||||
// knows nothing about the panels, and windows that outlive the surfaces
|
||||
// they blit are a crash waiting for the next mission cycle.
|
||||
//
|
||||
// CORRECTION (2026-08-07, #140): the original comment here claimed this was
|
||||
// "a no-op" on the PadRIO path because ~PadRIO tears the panels down itself.
|
||||
// It was not. ~PadRIO runs first (delete rioPointer, above), zeroing the
|
||||
// window list -- and BTGlassPanels_Destroy unconditionally ran SaveLayout
|
||||
// BEFORE looking at whether anything was left, so this second call rewrote
|
||||
// glass_layout.cfg with every MFD and radar line missing. Only the plasma
|
||||
// window survived, because external windows cached a last-known rect and the
|
||||
// per-display windows did not. That is the regression testers hit on the
|
||||
// desktop the same day the pod panels were wired up; the pod itself was
|
||||
// unaffected (no PadRIO, so only ONE destroy, and it runs BT_GLASS_LAYOUT=
|
||||
// load anyway). Fixed on both sides in L4GLASSWIN: the geometry is now
|
||||
// remembered across teardown, and the teardown save is guarded.
|
||||
//
|
||||
BTGlassPanels_Destroy();
|
||||
#endif
|
||||
|
||||
+457
-18
@@ -96,6 +96,8 @@ struct GButton
|
||||
|
||||
struct GWin
|
||||
{
|
||||
double perfMs; // [glassperf] paint ms this window, this second
|
||||
int perfN; // [glassperf] paints this second
|
||||
const char *title;
|
||||
HWND hwnd;
|
||||
|
||||
@@ -127,6 +129,12 @@ struct GWin
|
||||
char monitorName[40]; // the PHYSICAL monitor this window landed on
|
||||
// (\.\DISPLAYn), stamped at creation; shown
|
||||
// by BT_POD_IDENT so the cab can be mapped.
|
||||
|
||||
// Dirty-skip (2026-08-09): the last change token the pump blitted for this
|
||||
// window (plane checksum + lamp render). Zero-init (static storage); the pump
|
||||
// re-blits only when the token differs, so unchanged panels cost nothing.
|
||||
unsigned long lastToken;
|
||||
int haveToken;
|
||||
};
|
||||
|
||||
static GWin gWins[8];
|
||||
@@ -744,7 +752,70 @@ void
|
||||
// Heat MFD=monitor:2,bare (index into the enumeration order)
|
||||
// The surface is centred on that monitor; an exact-size panel fills it.
|
||||
//---------------------------------------------------------------------------
|
||||
struct MonScan { int index; const char *want; int wantIndex; RECT rect; int found; };
|
||||
struct MonScan { int index; const char *want; const char *wantId; int wantIndex; RECT rect; int found; };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// STABLE PANEL IDENTITY (2026-08-08) -- the fix for "the monitors came back in a
|
||||
// different order after I moved cables and rebooted".
|
||||
//
|
||||
// Windows renumbers \\.\DISPLAYn, and reorders the enumeration, when a panel is
|
||||
// power-cycled or re-cabled -- even when the visible desktop arrangement is
|
||||
// unchanged. So BOTH existing binding forms are boot-fragile: `monitor:2` is an
|
||||
// enumeration index and `monitor:\.\DISPLAY4` is a GDI name Windows reassigns.
|
||||
// (The same trap bites GameOS next door: its -tmon takes DirectDraw device
|
||||
// indices, which are neither Windows monitor numbers nor stable -- and its
|
||||
// NULL-device merge shifts every index down by one on top of that.)
|
||||
//
|
||||
// What IS stable is the panel's own hardware identity. EnumDisplayDevices on a
|
||||
// display's MONITOR child returns a DeviceID like
|
||||
// MONITOR\DEL4231\{4d36e96e-e325-11ce-bfc1-08002be10318}\0002
|
||||
// or, with EDD_GET_DEVICE_INTERFACE_NAME,
|
||||
// \\?\DISPLAY#DEL4231#5&1a2b3c&0&UID4353#{e6f07b5f-...}
|
||||
// The `DEL4231` field is the EDID manufacturer + product code, and the UID/
|
||||
// instance identifies the physical connector. Neither moves on a reboot.
|
||||
//
|
||||
// Fill `out` with that string for a \\.\DISPLAYn adapter name. Empty on failure
|
||||
// (no monitor child / remote session) -- callers must treat "" as "no identity"
|
||||
// and fall back, never as a match.
|
||||
static void
|
||||
MonitorStableId(const char *displayName, char *out, size_t outLen)
|
||||
{
|
||||
if (outLen == 0) return;
|
||||
out[0] = '\0';
|
||||
if (displayName == NULL || displayName[0] == '\0') return;
|
||||
|
||||
DISPLAY_DEVICEA mon;
|
||||
memset(&mon, 0, sizeof(mon));
|
||||
mon.cb = sizeof(mon);
|
||||
// index 0 = the attached monitor child. The interface-name flag gives the
|
||||
// richer path (includes the connector UID); it is Vista+, and if the call
|
||||
// fails we retry without it for the plain MONITOR\... form.
|
||||
if (!EnumDisplayDevicesA(displayName, 0, &mon, EDD_GET_DEVICE_INTERFACE_NAME))
|
||||
{
|
||||
memset(&mon, 0, sizeof(mon));
|
||||
mon.cb = sizeof(mon);
|
||||
if (!EnumDisplayDevicesA(displayName, 0, &mon, 0))
|
||||
return;
|
||||
}
|
||||
strncpy(out, mon.DeviceID, outLen - 1);
|
||||
out[outLen - 1] = '\0';
|
||||
}
|
||||
|
||||
// Case-insensitive substring test -- the cfg quotes a FRAGMENT of the identity
|
||||
// (usually just the EDID code, e.g. `id:DEL4231`) rather than the whole path,
|
||||
// because the full string is long and contains characters a config file and a
|
||||
// shell each mangle differently.
|
||||
static int
|
||||
IdContains(const char *haystack, const char *needle)
|
||||
{
|
||||
if (haystack == NULL || needle == NULL || *needle == '\0') return 0;
|
||||
size_t hl = strlen(haystack), nl = strlen(needle);
|
||||
if (nl > hl) return 0;
|
||||
for (size_t i = 0; i + nl <= hl; ++i)
|
||||
if (_strnicmp(haystack + i, needle, nl) == 0)
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static BOOL CALLBACK
|
||||
MonScanProc(HMONITOR mon, HDC, LPRECT, LPARAM param)
|
||||
@@ -756,7 +827,17 @@ static BOOL CALLBACK
|
||||
if (GetMonitorInfoA(mon, (MONITORINFO *)&mi))
|
||||
{
|
||||
int hit = 0;
|
||||
if (sc->want != NULL)
|
||||
// `id:<fragment>` -- match the panel's HARDWARE identity, not its
|
||||
// current \\.\DISPLAYn or enumeration slot. Boot-stable; this is the
|
||||
// form the pod should use.
|
||||
if (sc->wantId != NULL)
|
||||
{
|
||||
char sid[256];
|
||||
MonitorStableId(mi.szDevice, sid, sizeof(sid));
|
||||
if (sid[0] != '\0' && IdContains(sid, sc->wantId))
|
||||
hit = 1;
|
||||
}
|
||||
if (!hit && sc->want != NULL)
|
||||
{
|
||||
// Match the full device name OR just its tail, so a hand-written
|
||||
// cfg can say `monitor:DISPLAY4` and skip the \\.\ prefix entirely
|
||||
@@ -781,6 +862,96 @@ static BOOL CALLBACK
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// BT_GLASS_IDS=1 -- dump every attached panel with its BOOT-STABLE identity, and
|
||||
// a ready-to-paste `monitor:id:` fragment for glass_layout.cfg. This is the
|
||||
// discovery half of the fix: run it once on the pod, copy the fragments into the
|
||||
// cfg, and the assignment survives reboots and re-cabling.
|
||||
//
|
||||
// Prints the volatile identifiers too, deliberately side by side -- seeing
|
||||
// \\.\DISPLAYn and the enumeration index MOVE between two runs while the id
|
||||
// stays put is the proof that the id form is the right one.
|
||||
struct MonDump { int index; };
|
||||
|
||||
static BOOL CALLBACK
|
||||
MonDumpProc(HMONITOR mon, HDC, LPRECT, LPARAM param)
|
||||
{
|
||||
MonDump *d = (MonDump *)param;
|
||||
MONITORINFOEXA mi;
|
||||
memset(&mi, 0, sizeof(mi));
|
||||
mi.cbSize = sizeof(mi);
|
||||
if (GetMonitorInfoA(mon, (MONITORINFO *)&mi))
|
||||
{
|
||||
char sid[256];
|
||||
MonitorStableId(mi.szDevice, sid, sizeof(sid));
|
||||
|
||||
// The EDID make+product sits between the first two separators of the
|
||||
// DeviceID (MONITOR\DEL4231\... or \\?\DISPLAY#DEL4231#...). Offer it
|
||||
// as the suggested fragment -- short, and unique when the panels are
|
||||
// different models. Identical models need more (see the note below).
|
||||
// Find it STRUCTURALLY rather than by position: an EDID PnP code is
|
||||
// exactly 3 letters + 4 hex digits (AUO10ED, DEL4231). Walking the
|
||||
// separators positionally broke on the interface-name form, whose
|
||||
// `\\?\DISPLAY#...` prefix has a different number of leading segments
|
||||
// than the plain `MONITOR\...` form -- and which form you get depends on
|
||||
// whether EDD_GET_DEVICE_INTERFACE_NAME succeeded. Tokenising on all of
|
||||
// \ # ? handles every variant the same way.
|
||||
char frag[64];
|
||||
frag[0] = '\0';
|
||||
for (const char *t = sid; *t != '\0'; )
|
||||
{
|
||||
while (*t == '\\' || *t == '#' || *t == '?') ++t;
|
||||
const char *e = t;
|
||||
while (*e != '\0' && *e != '\\' && *e != '#' && *e != '?') ++e;
|
||||
size_t n = (size_t)(e - t);
|
||||
if (n == 7)
|
||||
{
|
||||
int ok = 1;
|
||||
for (int i = 0; i < 3 && ok; ++i)
|
||||
if (!isalpha((unsigned char)t[i])) ok = 0;
|
||||
for (int i = 3; i < 7 && ok; ++i)
|
||||
if (!isxdigit((unsigned char)t[i])) ok = 0;
|
||||
if (ok)
|
||||
{
|
||||
memcpy(frag, t, 7);
|
||||
frag[7] = '\0';
|
||||
break;
|
||||
}
|
||||
}
|
||||
t = e;
|
||||
}
|
||||
|
||||
DEBUG_STREAM << "[glassid] index=" << d->index
|
||||
<< " device=" << mi.szDevice
|
||||
<< (((mi.dwFlags & MONITORINFOF_PRIMARY) != 0) ? " PRIMARY" : "")
|
||||
<< " rect=" << (int)mi.rcMonitor.left << "," << (int)mi.rcMonitor.top
|
||||
<< " " << (int)(mi.rcMonitor.right - mi.rcMonitor.left)
|
||||
<< "x" << (int)(mi.rcMonitor.bottom - mi.rcMonitor.top)
|
||||
<< "\n stable-id = " << (sid[0] ? sid : "(unavailable)")
|
||||
<< "\n cfg form = monitor:id:" << (frag[0] ? frag : "<see stable-id>")
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
++d->index;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
void
|
||||
BTGlassDumpMonitorIds()
|
||||
{
|
||||
if (getenv("BT_GLASS_IDS") == NULL)
|
||||
return;
|
||||
DEBUG_STREAM << "[glassid] ---- attached panels, boot-STABLE identities ----\n"
|
||||
<< "[glassid] index and device= move across reboots / power-cycles;\n"
|
||||
<< "[glassid] stable-id does not. Bind the pod with monitor:id:<fragment>.\n"
|
||||
<< "[glassid] If two panels are the SAME MODEL their EDID codes match --\n"
|
||||
<< "[glassid] use a longer fragment from stable-id (the UID/instance tail\n"
|
||||
<< "[glassid] differs per connector) so each line matches exactly one.\n"
|
||||
<< std::flush;
|
||||
MonDump d;
|
||||
d.index = 0;
|
||||
EnumDisplayMonitors(NULL, NULL, MonDumpProc, (LPARAM)&d);
|
||||
DEBUG_STREAM << "[glassid] ---- " << d.index << " panel(s) ----\n" << std::flush;
|
||||
}
|
||||
|
||||
// Resolve "monitor:<spec>" to a rect. Returns 1 on success.
|
||||
static int
|
||||
ResolveMonitorSpec(const char *spec, RECT *out)
|
||||
@@ -789,13 +960,31 @@ static int
|
||||
MonScan sc;
|
||||
memset(&sc, 0, sizeof(sc));
|
||||
sc.wantIndex = -1;
|
||||
if (spec[0] >= '0' && spec[0] <= '9')
|
||||
// `monitor:id:<fragment>` -- BOOT-STABLE hardware identity (preferred on the
|
||||
// pod). Anything else keeps its historic meaning exactly: a leading digit is
|
||||
// the enumeration index, otherwise a \\.\DISPLAYn device name (or its tail).
|
||||
if (_strnicmp(spec, "id:", 3) == 0)
|
||||
{
|
||||
sc.wantId = spec + 3;
|
||||
while (*sc.wantId == ' ' || *sc.wantId == '\t') ++sc.wantId;
|
||||
}
|
||||
else if (spec[0] >= '0' && spec[0] <= '9')
|
||||
sc.wantIndex = atoi(spec);
|
||||
else
|
||||
sc.want = spec;
|
||||
EnumDisplayMonitors(NULL, NULL, MonScanProc, (LPARAM)&sc);
|
||||
if (sc.found)
|
||||
*out = sc.rect;
|
||||
else if (sc.wantId != NULL)
|
||||
{
|
||||
// An id: binding that matched nothing is worth shouting about: the panel
|
||||
// is unplugged, asleep, or the cfg fragment is wrong. Silently falling
|
||||
// back to computed placement would put a picture on the wrong glass and
|
||||
// look like the very bug this form exists to prevent.
|
||||
DEBUG_STREAM << "[glasswin] monitor id '" << sc.wantId
|
||||
<< "' matched NO attached panel -- check BT_GLASS_IDS=1 output"
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
return sc.found;
|
||||
}
|
||||
|
||||
@@ -916,9 +1105,50 @@ static void
|
||||
<< layoutFileName << "\n" << std::flush;
|
||||
}
|
||||
|
||||
// Write every window's current on-screen frame rect. Whole-file rewrite (it is
|
||||
// tiny), so partial/hard kills never leave a half-written file for long. Called
|
||||
// on finished-drag and on teardown in save mode.
|
||||
// LAST-KNOWN GEOMETRY, kept ACROSS teardown (2026-08-07, #140).
|
||||
//
|
||||
// SaveLayout rewrites the whole file, so it can only be as complete as what it
|
||||
// can see -- and it could only see LIVE windows. Destroy() nulls every hwnd and
|
||||
// zeroes gWinCount, so any save that ran after a teardown wrote a file with
|
||||
// every MFD and radar line MISSING. The plasma window survived that because
|
||||
// external windows already cached a last-known rect (gExtern[].haveLast); the
|
||||
// per-display windows had no such cache, which is exactly the reported
|
||||
// signature: "all the MFDs and secondary lines missing, but plasma was still
|
||||
// there". Give the glass windows the same guarantee, so the file is monotonic
|
||||
// -- a save can update a line or add one, never drop one.
|
||||
struct SavedGeom
|
||||
{
|
||||
char title[64];
|
||||
RECT r;
|
||||
int noFrame;
|
||||
};
|
||||
static SavedGeom gLastGeom[16];
|
||||
static int gLastGeomCount = 0;
|
||||
|
||||
static void
|
||||
RememberGeom(const char *title, const RECT &r, int noFrame)
|
||||
{
|
||||
if (title == NULL || title[0] == '\0')
|
||||
return;
|
||||
for (int i = 0; i < gLastGeomCount; ++i)
|
||||
if (strcmp(gLastGeom[i].title, title) == 0)
|
||||
{
|
||||
gLastGeom[i].r = r; gLastGeom[i].noFrame = noFrame;
|
||||
return;
|
||||
}
|
||||
if (gLastGeomCount >= (int)(sizeof(gLastGeom) / sizeof(gLastGeom[0])))
|
||||
return;
|
||||
SavedGeom &g = gLastGeom[gLastGeomCount++];
|
||||
strncpy(g.title, title, sizeof(g.title) - 1);
|
||||
g.title[sizeof(g.title) - 1] = '\0';
|
||||
g.r = r; g.noFrame = noFrame;
|
||||
}
|
||||
|
||||
// Write every window's frame rect. Whole-file rewrite (it is tiny), so
|
||||
// partial/hard kills never leave a half-written file for long. Called on
|
||||
// finished-drag and on teardown in save mode. Live windows refresh the
|
||||
// remembered geometry first; the FILE is then written from the remembered set,
|
||||
// so a window that has already been torn down keeps its line.
|
||||
static void
|
||||
SaveLayout()
|
||||
{
|
||||
@@ -945,6 +1175,7 @@ static void
|
||||
"# this list too -- it can be dragged, remembered and set ,noframe.\n",
|
||||
f);
|
||||
int wrote = 0;
|
||||
// 1. refresh the remembered geometry from whatever is currently alive
|
||||
for (int i = 0; i < gWinCount; ++i)
|
||||
{
|
||||
GWin &gw = gWins[i];
|
||||
@@ -953,10 +1184,16 @@ static void
|
||||
RECT r;
|
||||
if (!GetWindowRect(gw.hwnd, &r))
|
||||
continue;
|
||||
fprintf(f, "%s=%ld,%ld,%ld,%ld%s\n", gw.title,
|
||||
(long)r.left, (long)r.top,
|
||||
(long)(r.right - r.left), (long)(r.bottom - r.top),
|
||||
gw.noFrame ? ",noframe" : ""); // keep the hand-added option
|
||||
RememberGeom(gw.title, r, gw.noFrame); // keep the hand-added option
|
||||
}
|
||||
// 2. write the remembered set -- including windows already torn down
|
||||
for (int i = 0; i < gLastGeomCount; ++i)
|
||||
{
|
||||
const SavedGeom &g = gLastGeom[i];
|
||||
fprintf(f, "%s=%ld,%ld,%ld,%ld%s\n", g.title,
|
||||
(long)g.r.left, (long)g.r.top,
|
||||
(long)(g.r.right - g.r.left), (long)(g.r.bottom - g.r.top),
|
||||
g.noFrame ? ",noframe" : "");
|
||||
++wrote;
|
||||
}
|
||||
// External windows (the plasma window) ride the same file. Cache the last
|
||||
@@ -982,8 +1219,15 @@ static void
|
||||
++wrote;
|
||||
}
|
||||
fclose(f);
|
||||
// #140 receipt (ungated): `live=` is the diagnostic that matters. A save
|
||||
// that runs with live=0 is the corruption case -- before the remembered-
|
||||
// geometry cache it wrote a file containing ONLY the plasma line, which is
|
||||
// what testers reported. It stays in the log so the FIELD can tell us which
|
||||
// caller does that (teardown ordering, a drag after a round boundary, ...),
|
||||
// which no bench here managed to reach.
|
||||
DEBUG_STREAM << "[glasswin] saved " << wrote << " window position(s) to "
|
||||
<< layoutFileName << "\n" << std::flush;
|
||||
<< layoutFileName << " (live=" << gWinCount
|
||||
<< " remembered=" << gLastGeomCount << ")\n" << std::flush;
|
||||
}
|
||||
|
||||
// Public save trigger for registered external windows -- their WndProc calls
|
||||
@@ -1180,7 +1424,16 @@ static void
|
||||
info.bmiHeader.biCompression = BI_RGB;
|
||||
|
||||
const RECT &r = w->surfaceRect;
|
||||
SetStretchBltMode(dc, HALFTONE);
|
||||
// STRETCH MODE (perf, 2026-08-09): default COLORONCOLOR. HALFTONE runs a
|
||||
// per-output-pixel resample filter; done synchronously for all 7 glass windows
|
||||
// every ~16 Hz repaint (BTGlassPanels_Tick), it was the per-display-mode perf
|
||||
// sink -- playtesters saw ~20 fps in the exploded panels vs ~130 fps in the
|
||||
// cockpit surround (same scene, same machine). The MFDs are low-res pixel
|
||||
// content, so nearest-neighbour reads crisp (and closer to the pod CRT).
|
||||
// BT_GLASS_SMOOTH=1 restores HALFTONE for anyone who prefers smoothing to speed.
|
||||
static int sSmooth = -1;
|
||||
if (sSmooth < 0) sSmooth = getenv("BT_GLASS_SMOOTH") ? 1 : 0;
|
||||
SetStretchBltMode(dc, sSmooth ? HALFTONE : COLORONCOLOR);
|
||||
SetBrushOrgEx(dc, 0, 0, NULL);
|
||||
StretchDIBits(dc,
|
||||
r.left, r.top, r.right - r.left, r.bottom - r.top,
|
||||
@@ -1459,7 +1712,20 @@ static LRESULT CALLBACK
|
||||
switch (message)
|
||||
{
|
||||
case WM_PAINT:
|
||||
if (w != NULL) { PaintGlass(window, w); return 0; }
|
||||
if (w != NULL)
|
||||
{
|
||||
// [glassperf] (#149): time EVERY panel paint. The blit is a
|
||||
// HALFTONE StretchDIBits whose cost is strongly driver-dependent
|
||||
// -- the whole point is to measure it on the machine that pays it.
|
||||
LARGE_INTEGER t0, t1, fq;
|
||||
QueryPerformanceCounter(&t0);
|
||||
PaintGlass(window, w);
|
||||
QueryPerformanceCounter(&t1);
|
||||
QueryPerformanceFrequency(&fq);
|
||||
w->perfMs += 1000.0 * (double)(t1.QuadPart - t0.QuadPart) / (double)fq.QuadPart;
|
||||
++w->perfN;
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
|
||||
case WM_ERASEBKGND:
|
||||
@@ -1502,6 +1768,14 @@ static LRESULT CALLBACK
|
||||
}
|
||||
else
|
||||
{
|
||||
// #166: a press must never ORPHAN a previous one -- if a
|
||||
// prior button is still logically down (capture stolen by
|
||||
// an overlay/popup so its WM_LBUTTONUP never arrived),
|
||||
// release it before pressing the new one. An orphaned
|
||||
// press on the ammo-EJECT panel button is a permanent
|
||||
// weapon jam (bin parked at Ejecting).
|
||||
if (pressedAddress >= 0 && pressedAddress != a)
|
||||
PadRIO::SetScreenButton(pressedAddress, 0);
|
||||
pressedAddress = a;
|
||||
SetCapture(window);
|
||||
PadRIO::SetScreenButton(a, 1);
|
||||
@@ -1521,6 +1795,21 @@ static LRESULT CALLBACK
|
||||
}
|
||||
return 0;
|
||||
|
||||
case WM_CAPTURECHANGED:
|
||||
// #166: mouse capture stolen mid-press (Steam overlay, popups, focus
|
||||
// change) -- WM_LBUTTONUP will never arrive, so release the held
|
||||
// button NOW instead of leaving its control (e.g. ammo EJECT) pressed
|
||||
// forever from the game's point of view.
|
||||
if (pressedAddress >= 0)
|
||||
{
|
||||
DEBUG_STREAM << "[glasswin] capture LOST with button 0x" << std::hex
|
||||
<< pressedAddress << std::dec << " down -- releasing\n" << std::flush;
|
||||
PadRIO::SetScreenButton(pressedAddress, 0);
|
||||
pressedAddress = -1;
|
||||
InvalidateRect(window, NULL, FALSE);
|
||||
}
|
||||
return 0;
|
||||
|
||||
case WM_RBUTTONDOWN:
|
||||
if (w != NULL)
|
||||
{
|
||||
@@ -1561,6 +1850,11 @@ void
|
||||
if (gWinCount != 0)
|
||||
return; // already up
|
||||
|
||||
// BT_GLASS_IDS=1: dump every panel's boot-stable identity before any window
|
||||
// is placed, so the log shows what the cfg COULD bind to right next to what
|
||||
// it actually did. No-op without the env -- playtesters see nothing new.
|
||||
BTGlassDumpMonitorIds();
|
||||
|
||||
// BT_GAUGE_SEC_ROT: how far to turn the secondary/radar surface.
|
||||
// 0 = none, 1 = 90 CCW, 2 = 180, 3 = 90 CW (default -- the pod's
|
||||
// portrait CRT, user-verified upright).
|
||||
@@ -1741,8 +2035,24 @@ void
|
||||
void
|
||||
BTGlassPanels_Destroy()
|
||||
{
|
||||
SaveLayout(); // backstop for a clean teardown (WM_EXITSIZEMOVE already
|
||||
// caught every finished drag); no-op unless mode==save
|
||||
// #140 receipt (ungated, one line per teardown): this function has two
|
||||
// callers on the desktop path and the ORDER is what broke the layout file.
|
||||
// A run that shows `entry #2 windows=0` is the double-destroy, on the
|
||||
// record, without needing to catch the cfg mid-corruption.
|
||||
{
|
||||
static int s_destroyN = 0;
|
||||
DEBUG_STREAM << "[glasswin] destroy entry #" << ++s_destroyN
|
||||
<< " windows=" << gWinCount << "\n" << std::flush;
|
||||
}
|
||||
|
||||
// Backstop for a clean teardown (WM_EXITSIZEMOVE already caught every
|
||||
// finished drag); no-op unless mode==save. GUARDED on there being windows:
|
||||
// this function has TWO callers (~PadRIO and ~LBE4ControlsManager), so on
|
||||
// the desktop path it runs twice, and the second pass has nothing live to
|
||||
// report. The remembered-geometry cache above already makes that harmless,
|
||||
// but there is no reason to rewrite the file to say the same thing.
|
||||
if (gWinCount > 0)
|
||||
SaveLayout();
|
||||
|
||||
for (int i = 0; i < gWinCount; ++i)
|
||||
{
|
||||
@@ -1778,24 +2088,153 @@ void
|
||||
// one-shot re-snap); a focused window just repaints from whichever fires first.
|
||||
//###########################################################################
|
||||
|
||||
//
|
||||
// GLASS DIRTY-SKIP (2026-08-09): re-blit only the windows whose content changed.
|
||||
// Each window is a plane of the ONE shared gauge pixelBuffer, so its change token is
|
||||
// the masked plane checksum (SVGA16::PlaneChecksum over every port that can feed the
|
||||
// window) combined with each button's RENDERED lamp brightness + held/latched state.
|
||||
// Folding the flash BRIGHTNESS in (not the raw lamp state) means a flashing lamp
|
||||
// repaints exactly when it toggles, and a static panel -- or a fully idle cockpit --
|
||||
// skips its expand + StretchDIBits entirely. The plane checksum is the only added
|
||||
// cost (once per window per ~16 Hz pump) and is far cheaper than the paint it saves.
|
||||
//
|
||||
static unsigned long
|
||||
GlassWindowToken(GaugeRenderer *gr, GWin *w, unsigned long tick)
|
||||
{
|
||||
unsigned long token = 2166136261UL;
|
||||
|
||||
if (w->portPrimary != NULL && gr != NULL)
|
||||
{
|
||||
// Every plane that could feed the window (primary + Eng twin + RGB group +
|
||||
// their twins) -- OR their masks so a change to ANY is caught; checksum once.
|
||||
const char *ports[8];
|
||||
int np = 0;
|
||||
ports[np++] = w->portPrimary;
|
||||
if (w->portAlt != NULL) ports[np++] = w->portAlt;
|
||||
for (int gi = 0; gi < w->groupCount && np < 7; ++gi)
|
||||
{
|
||||
ports[np++] = w->groupPort[gi];
|
||||
if (w->groupAlt[gi] != NULL && np < 8) ports[np++] = w->groupAlt[gi];
|
||||
}
|
||||
int combined = 0;
|
||||
SVGA16 *svga = NULL;
|
||||
for (int i = 0; i < np; ++i)
|
||||
{
|
||||
L4GraphicsPort *p =
|
||||
static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(ports[i]));
|
||||
if (p == NULL) continue;
|
||||
combined |= p->GetBitMask();
|
||||
if (svga == NULL) svga = static_cast<SVGA16*>(p->graphicsDisplay);
|
||||
}
|
||||
if (svga != NULL && combined != 0)
|
||||
token ^= svga->PlaneChecksum(combined);
|
||||
}
|
||||
|
||||
// Lamps: each button's RENDERED brightness + held/latched, so a flash toggle or a
|
||||
// press repaints exactly the window it lives on.
|
||||
for (int j = 0; j < w->buttonCount; ++j)
|
||||
{
|
||||
int addr = w->buttons[j].address;
|
||||
unsigned long shade =
|
||||
(unsigned long)LampBrightnessOf(PadRIO::GetLampState(addr), tick);
|
||||
int held = (addr == pressedAddress) || latched[addr & 0x7F];
|
||||
token = (token ^ ((unsigned long)(addr & 0xFF) << 4)
|
||||
^ (shade << 1) ^ (unsigned long)held) * 16777619UL;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// [rio] health printer (L4RIO.cpp): the serial poll path only COUNTS (kept
|
||||
// I/O-free by design; the 2026-08-10 wedges once blamed on prints there were
|
||||
// really the USB-serial adapter hanging an unbounded overlapped write --
|
||||
// see PCSerialPacket::SendPacket); the report rides this tick, which
|
||||
// already streams [glassperf] every second.
|
||||
extern "C" void BTRioHealthReport();
|
||||
|
||||
void
|
||||
BTGlassPanels_Tick()
|
||||
{
|
||||
if (gWinCount == 0)
|
||||
return;
|
||||
|
||||
BTRioHealthReport();
|
||||
|
||||
static unsigned long sLastPaint = 0;
|
||||
unsigned long now = GetTickCount();
|
||||
if (now - sLastPaint < (unsigned long)RepaintMilliseconds)
|
||||
return;
|
||||
sLastPaint = now;
|
||||
|
||||
// MERGED: Cyd's dirty-skip (glass-panel-perf) + the #149 [glassperf]
|
||||
// telemetry. BT_GLASS_SWEEP=1 bypasses the skip entirely -- the legacy
|
||||
// always-repaint escape hatch, kept so the field can A/B in one env var.
|
||||
static const int sGlassPerf =
|
||||
!(getenv("BT_PERF_LOG") && *getenv("BT_PERF_LOG") == '0');
|
||||
static const int sLegacySweep =
|
||||
(getenv("BT_GLASS_SWEEP") && *getenv("BT_GLASS_SWEEP") == '1');
|
||||
LARGE_INTEGER gt0, gt1, gfq;
|
||||
QueryPerformanceCounter(>0);
|
||||
GaugeRenderer *gr = BTResolveGaugeRenderer();
|
||||
int repainted = 0;
|
||||
for (int i = 0; i < gWinCount; ++i)
|
||||
{
|
||||
if (gWins[i].hwnd != NULL)
|
||||
GWin &w = gWins[i];
|
||||
if (w.hwnd == NULL)
|
||||
continue;
|
||||
unsigned long token = GlassWindowToken(gr, &w, now);
|
||||
if (!sLegacySweep && w.haveToken && token == w.lastToken)
|
||||
continue; // gauges + lamps unchanged -> skip this window
|
||||
w.lastToken = token;
|
||||
w.haveToken = 1;
|
||||
++repainted;
|
||||
InvalidateRect(w.hwnd, NULL, FALSE);
|
||||
UpdateWindow(w.hwnd); // synchronous paint, not the throttled queue
|
||||
}
|
||||
|
||||
// BT_GLASS_DIRTY: report how many window-repaints the dirty-skip let through vs
|
||||
// the old fixed gWinCount-per-pump, so the saving is visible.
|
||||
static int sDirtyLog = -1;
|
||||
if (sDirtyLog < 0) sDirtyLog = getenv("BT_GLASS_DIRTY") ? 1 : 0;
|
||||
if (sDirtyLog)
|
||||
{
|
||||
static unsigned long sWin = 0;
|
||||
static int sPumps = 0, sPaints = 0;
|
||||
++sPumps; sPaints += repainted;
|
||||
if (now - sWin >= 2000)
|
||||
{
|
||||
InvalidateRect(gWins[i].hwnd, NULL, FALSE);
|
||||
UpdateWindow(gWins[i].hwnd); // synchronous paint, not the throttled queue
|
||||
DEBUG_STREAM << "[glass-dirty] " << sPumps << " pumps -> " << sPaints
|
||||
<< " window-repaints (was " << (sPumps * gWinCount) << " always-on)\n"
|
||||
<< std::flush;
|
||||
sWin = now; sPumps = 0; sPaints = 0;
|
||||
}
|
||||
}
|
||||
QueryPerformanceCounter(>1);
|
||||
QueryPerformanceFrequency(&gfq);
|
||||
|
||||
// [glassperf] (#149): one line per second -- the tick's total synchronous
|
||||
// cost plus each window's own paint time. This runs INSIDE the render
|
||||
// frame (L4VIDEO calls the tick), so on a machine where GDI serialises
|
||||
// against D3D present, tickMs IS the per-frame tax and the per-window
|
||||
// split names the guilty panel. Default ON like [segperf]; BT_PERF_LOG=0
|
||||
// opts out.
|
||||
static double sTickMs = 0.0; static int sTicks = 0; static int sPaintAcc = 0;
|
||||
sPaintAcc += repainted;
|
||||
static unsigned long sLastReport = 0;
|
||||
sTickMs += 1000.0 * (double)(gt1.QuadPart - gt0.QuadPart) / (double)gfq.QuadPart;
|
||||
++sTicks;
|
||||
if (sGlassPerf && now - sLastReport >= 1000)
|
||||
{
|
||||
sLastReport = now;
|
||||
DEBUG_STREAM << "[glassperf] ticks=" << sTicks << " tickMs=" << sTickMs
|
||||
<< " paints=" << sPaintAcc;
|
||||
for (int i = 0; i < gWinCount; ++i)
|
||||
{
|
||||
if (gWins[i].perfN > 0)
|
||||
DEBUG_STREAM << " | " << gWins[i].title
|
||||
<< " n=" << gWins[i].perfN << " ms=" << gWins[i].perfMs;
|
||||
gWins[i].perfMs = 0.0; gWins[i].perfN = 0;
|
||||
}
|
||||
DEBUG_STREAM << "\n" << std::flush;
|
||||
sTickMs = 0.0; sTicks = 0; sPaintAcc = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -829,18 +829,13 @@ void
|
||||
switch (type)
|
||||
{
|
||||
case scrambleVideo:
|
||||
// NON-STACKING LATCH (phase-14 fidelity): the binary latches on `modified`
|
||||
// @0x4fe0fe -- a second PPC hit while the scramble is ALREADY live does NOT
|
||||
// re-save the (already detuned) value and does NOT extend the window. This
|
||||
// original unconditionally overwrote scrambleVideoTimeout, so rapid PPC fire
|
||||
// STACKED the effect -- a divergence from the binary. Ignore re-arm while live.
|
||||
if (graphicsDisplay != NULL && !scrambleVideoFlag)
|
||||
if (graphicsDisplay != NULL)
|
||||
{
|
||||
scrambleVideoFlag = True;
|
||||
scrambleVideoTimeout = ((Scalar)Now()) + duration;
|
||||
|
||||
Check(graphicsDisplay);
|
||||
((SVGA16*) graphicsDisplay)->FunkyVideo(True, duration); // duration -> the recovery envelope
|
||||
((SVGA16*) graphicsDisplay)->FunkyVideo(True);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ int gBTPadViewToggleEdges = 0;
|
||||
// desktop bridge, which owns `reverseThrust` (mapper attr 6 @0x124) every frame.
|
||||
//
|
||||
int gBTReverseHeld = 0;
|
||||
int gBTTorsoCenterHeld = 0; // button 0x42 hold (#152; same seam as 0x3F)
|
||||
|
||||
//
|
||||
// The desktop per-MFD preset-page cycle edges (J/K/L -> Mfd1/2/3), consumed
|
||||
@@ -458,6 +459,17 @@ void
|
||||
// via SetScreenButton), so the desktop bridge can honour the button exactly
|
||||
// like the pod's RIO board did.
|
||||
//
|
||||
// TORSO CENTER (pod button 0x42, 'the shipped .RES name' -- UP arrow via
|
||||
// bindings.txt). Same chokepoint pattern as 0x3F below: publish the HOLD
|
||||
// state so the mapper's unified recenter writer (#152) can honour it on
|
||||
// every rig. Before this, no RIO/glass path reached centerCommand at all
|
||||
// -- bench: two scripted 0x42 holds, ctrCmd=0 throughout.
|
||||
if (address == 0x42)
|
||||
{
|
||||
extern int gBTTorsoCenterHeld;
|
||||
gBTTorsoCenterHeld = pressed ? 1 : 0;
|
||||
}
|
||||
|
||||
if (address == 0x3F)
|
||||
{
|
||||
gBTReverseHeld = pressed ? 1 : 0;
|
||||
|
||||
@@ -410,6 +410,20 @@ int PCSerialPacket::ReceivePacket(BYTE *destPtr) // returns len (zero if none)
|
||||
// Returns:
|
||||
// void
|
||||
//-------------------------------------------------------------------------
|
||||
//
|
||||
// Serial-write health (see the bounded wait below): after a write timeout
|
||||
// the link is SICK for 3 s and writes fast-drop -- one stall, not one per
|
||||
// poll. BTPcspakTxTimeouts feeds the [rio] telemetry.
|
||||
//
|
||||
static unsigned gPcspakTxTimeout = 0;
|
||||
static DWORD gPcspakTxSickUntil = 0;
|
||||
|
||||
extern "C" unsigned
|
||||
BTPcspakTxTimeouts()
|
||||
{
|
||||
return gPcspakTxTimeout;
|
||||
}
|
||||
|
||||
void PCSerialPacket::SendPacket(BYTE *srcPtr) //sends packet
|
||||
{
|
||||
//--------------------------------------------------------------
|
||||
@@ -418,6 +432,11 @@ void PCSerialPacket::SendPacket(BYTE *srcPtr) //sends packet
|
||||
if(!enabled) //if disabled, don't even try
|
||||
return;
|
||||
|
||||
if (gPcspakTxSickUntil != 0 && GetTickCount() < gPcspakTxSickUntil)
|
||||
{
|
||||
return; // adapter sick: drop, don't stall
|
||||
}
|
||||
|
||||
BYTE cmd = *srcPtr; //get the command byte
|
||||
cmd &= 0x7F; //remove MSB
|
||||
int length = txLengthPtr[cmd]; //get the length in al
|
||||
@@ -459,11 +478,37 @@ void PCSerialPacket::SendPacket(BYTE *srcPtr) //sends packet
|
||||
return;
|
||||
}
|
||||
}
|
||||
DWORD bytesWritten;
|
||||
GetOverlappedResult(hComm, &overlapSend, &bytesWritten, TRUE);
|
||||
if(bytesWritten != length + 1)
|
||||
//
|
||||
// BOUNDED completion wait (2026-08-10, ALPHA-MR field minidump). The
|
||||
// RIO's "COM1" is a USB-serial adapter; when its USB link drops with a
|
||||
// write in flight the overlapped write NEVER completes, and the original
|
||||
// GetOverlappedResult(..., TRUE) parked the whole game at 0 CPU forever
|
||||
// (cdb-proven: NtWaitForSingleObject <- GetOverlappedResult <-
|
||||
// SendPacket <- RIO ctor). Now: 500 ms bound (9600 baud needs ~21 ms),
|
||||
// CancelIo on expiry, and a 3 s SICK window that fast-drops writes so a
|
||||
// dead adapter costs one stall, not one per 50 ms poll -- the game stays
|
||||
// at frame rate with a degraded RIO and the keyboard fallback alive.
|
||||
// gPcspakTxTimeout feeds the [rio] health line (txTO=).
|
||||
//
|
||||
DWORD bytesWritten = 0;
|
||||
DWORD wait_result = WaitForSingleObject(overlapSend.hEvent, 500);
|
||||
if (wait_result == WAIT_OBJECT_0)
|
||||
{
|
||||
DEBUG_STREAM << "Not all data was written to serial port!\n" << std::flush;
|
||||
GetOverlappedResult(hComm, &overlapSend, &bytesWritten, FALSE);
|
||||
gPcspakTxSickUntil = 0;
|
||||
if (bytesWritten != (DWORD)(length + 1))
|
||||
{
|
||||
DEBUG_STREAM << "Not all data was written to serial port!\n"
|
||||
<< std::flush;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No print here -- serial-path discipline; the counter surfaces in
|
||||
// the [rio] health line (txTO=) from the glass-tick printer.
|
||||
CancelIo(hComm);
|
||||
++gPcspakTxTimeout;
|
||||
gPcspakTxSickUntil = GetTickCount() + 3000;
|
||||
}
|
||||
delete buffer;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,11 @@ static LRESULT CALLBACK
|
||||
return 0;
|
||||
|
||||
case WM_CLOSE:
|
||||
ShowWindow(window, SW_HIDE); // hide only; the renderer owns it
|
||||
// Minimize, never hide (#71): SW_HIDE left no way back short of a
|
||||
// restart. A minimized window keeps its taskbar button (both framed
|
||||
// and ,noframe styles), so the player can recover the display. The
|
||||
// renderer still owns the window either way.
|
||||
ShowWindow(window, SW_MINIMIZE);
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProcW(window, message, wparam, lparam);
|
||||
@@ -50,6 +54,22 @@ PlasmaWindow::PlasmaWindow():
|
||||
}
|
||||
blitBuffer = new unsigned long[plasmaWidth * plasmaHeight];
|
||||
memset(blitBuffer, 0, plasmaWidth * plasmaHeight * sizeof(unsigned long));
|
||||
|
||||
//
|
||||
// INITIALIZE THE DISPLAY AREA (2026-08-10). PixelMap8(w,h) allocates its
|
||||
// pixel bytes UNCLEARED (GRAPH2D.cpp: `new Byte[w*h]`, no memset), so the
|
||||
// 128x32 plasma buffer starts as heap garbage. The real pod never showed
|
||||
// it: the serial PlasmaDisplay transfers only CHANGED lines (changedLine[]
|
||||
// is zeroed by the base ctor), so the garbage never left the buffer and
|
||||
// the hardware marquee sat at its power-on blank. This desktop window
|
||||
// blits the WHOLE buffer every frame, so the junk was visible from boot
|
||||
// until the gauge renderer first drew the callsigns. Clear to index 0
|
||||
// (the near-black background) -- the window comes up blank like the
|
||||
// hardware did.
|
||||
//
|
||||
if (pixelBuffer != NULL && pixelBuffer->Data.MapPointer != NULL)
|
||||
memset(pixelBuffer->Data.MapPointer, 0, plasmaWidth * plasmaHeight);
|
||||
|
||||
DEBUG_STREAM << "[plasmawin] desktop plasma display up (scale x"
|
||||
<< scale << ")\n" << std::flush;
|
||||
}
|
||||
|
||||
@@ -1240,6 +1240,105 @@ RIO::~RIO()
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
//
|
||||
// [rio] telemetry state (see the tracking block in GetNextEvent). File-scope
|
||||
// statics on purpose: one hardware RIO per process, no header/layout churn --
|
||||
// and the PRINTER (BTRioHealthReport, called from the glass tick) reads ONLY
|
||||
// these statics, never RIO members, so it needs no instance and touches
|
||||
// nothing the serial layer locks.
|
||||
//
|
||||
static int gRioPresent = 0;
|
||||
static unsigned gRioReqCount = 0;
|
||||
static unsigned gRioRepCount = 0;
|
||||
static unsigned gRioBtnCount = 0;
|
||||
static int gRioReqPending = 0;
|
||||
static DWORD gRioLastReqTick = 0;
|
||||
static DWORD gRioLastReplyTick = 0;
|
||||
static int gRioStallActive = 0;
|
||||
static DWORD gRioStallStart = 0;
|
||||
static unsigned gRioStallBtnBase = 0;
|
||||
static unsigned gRioStallCount = 0;
|
||||
static DWORD gRioLastStallMs = 0;
|
||||
static unsigned gRioLastStallBtns = 0;
|
||||
static unsigned gRioLineErrMirror = 0;
|
||||
static unsigned gRioOverrunMirror = 0;
|
||||
static unsigned gRioAbandonMirror = 0;
|
||||
static unsigned gRioRRetryMirror = 0;
|
||||
static unsigned gRioRAbandonMirror = 0;
|
||||
static unsigned gRioRFullMirror = 0;
|
||||
|
||||
extern "C" unsigned BTPcspakTxTimeouts(); // L4PCSPAK.cpp: bounded-write stat
|
||||
|
||||
//
|
||||
// The printer half -- called from BTGlassPanels_Tick beside the [glassperf]
|
||||
// report (a stream site that prints every second without incident), NEVER
|
||||
// from the serial path. Edge lines land at the tick's ~1 s granularity,
|
||||
// ample for the multi-second field dropouts this exists to catch.
|
||||
// BT_RIO_LOG=0 opts out; silent when no hardware RIO ever came up.
|
||||
//
|
||||
extern "C" void
|
||||
BTRioHealthReport()
|
||||
{
|
||||
static int sLog = -1;
|
||||
static DWORD sLastLine = 0;
|
||||
static unsigned sSeenStalls = 0;
|
||||
static int sSawActive = 0;
|
||||
|
||||
if (!gRioPresent)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (sLog < 0)
|
||||
{
|
||||
const char *e = getenv("BT_RIO_LOG");
|
||||
sLog = (e != 0 && *e == '0') ? 0 : 1;
|
||||
}
|
||||
if (!sLog)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DWORD now = GetTickCount();
|
||||
if (gRioStallActive && !sSawActive)
|
||||
{
|
||||
sSawActive = 1;
|
||||
DEBUG_STREAM << "[rio] STALL analog silent "
|
||||
<< (now - gRioStallStart) << "ms and counting req="
|
||||
<< gRioReqCount << " rep=" << gRioRepCount
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
else if (!gRioStallActive && sSawActive)
|
||||
{
|
||||
sSawActive = 0;
|
||||
}
|
||||
if (sSeenStalls != gRioStallCount)
|
||||
{
|
||||
sSeenStalls = gRioStallCount;
|
||||
DEBUG_STREAM << "[rio] RECOVER stall#" << gRioStallCount
|
||||
<< " lasted " << gRioLastStallMs
|
||||
<< "ms btnDuring=" << gRioLastStallBtns
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
if (now - sLastLine >= 5000)
|
||||
{
|
||||
sLastLine = now;
|
||||
DEBUG_STREAM << "[rio] req=" << gRioReqCount
|
||||
<< " rep=" << gRioRepCount
|
||||
<< " btn=" << gRioBtnCount
|
||||
<< " age=" << (gRioLastReplyTick != 0
|
||||
? (now - gRioLastReplyTick) : 0)
|
||||
<< "ms stalls=" << gRioStallCount
|
||||
<< " txTO=" << BTPcspakTxTimeouts()
|
||||
<< " lineErr=" << gRioLineErrMirror
|
||||
<< " overrun=" << gRioOverrunMirror
|
||||
<< " abandon=" << gRioAbandonMirror
|
||||
<< " rRetry=" << gRioRRetryMirror
|
||||
<< " rAbandon=" << gRioRAbandonMirror
|
||||
<< " rFull=" << gRioRFullMirror
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
Logical
|
||||
RIO::TestInstance() const
|
||||
{
|
||||
@@ -1264,6 +1363,44 @@ Logical
|
||||
Logical reply, looping;
|
||||
Word errors;
|
||||
|
||||
//
|
||||
// [rio] link-health TRACKING -- pure arithmetic, no I/O here by design.
|
||||
// CORRECTED HISTORY (2026-08-10): the boot wedges first blamed on prints
|
||||
// from this path were actually the USB-serial adapter dropping its link
|
||||
// with a write in flight -- the unbounded GetOverlappedResult in
|
||||
// PCSerialPacket::SendPacket parked the game (minidump-proven; the
|
||||
// print-deadlock A/B was confounded by the adapter behaving that boot).
|
||||
// The count-here / print-from-the-glass-tick split stays anyway: it
|
||||
// keeps this hot polled path I/O-free and the printer at one known
|
||||
// stream site (BTRioHealthReport below, beside [glassperf]).
|
||||
//
|
||||
if (operational)
|
||||
{
|
||||
DWORD now = GetTickCount();
|
||||
// #160 telemetry fix: the detector used to key on REQUEST age -- but the
|
||||
// poll loop sends a fresh request every ~50ms regardless of replies, so
|
||||
// request-age never reached 500ms and the STALL line could never fire
|
||||
// (the field logs' age= field carried the signal instead). Key on the
|
||||
// REPLY drought: requests outstanding AND no AnalogReply for 500ms
|
||||
// since the last good one. Requires one good reply first (boot-mute
|
||||
// shows in the req/rep counters, not here).
|
||||
if (!gRioStallActive && gRioReqPending
|
||||
&& gRioLastReplyTick != 0
|
||||
&& now - gRioLastReplyTick > 500)
|
||||
{
|
||||
gRioStallActive = 1;
|
||||
gRioStallStart = gRioLastReplyTick;
|
||||
gRioStallBtnBase = gRioBtnCount;
|
||||
}
|
||||
gRioPresent = 1;
|
||||
gRioLineErrMirror = (unsigned) lineErrorCount;
|
||||
gRioOverrunMirror = (unsigned) overrunCount;
|
||||
gRioAbandonMirror = (unsigned) abandonCount;
|
||||
gRioRRetryMirror = (unsigned) remoteRetryCount;
|
||||
gRioRAbandonMirror = (unsigned) remoteAbandonCount;
|
||||
gRioRFullMirror = (unsigned) remoteFullBufferCount;
|
||||
}
|
||||
|
||||
//PCSPAKState(this);
|
||||
//
|
||||
//cout << flush;
|
||||
@@ -1377,6 +1514,20 @@ Logical
|
||||
break;
|
||||
|
||||
case AnalogReply:
|
||||
++gRioRepCount;
|
||||
gRioReqPending = 0;
|
||||
gRioLastReplyTick = GetTickCount();
|
||||
if (gRioStallActive)
|
||||
{
|
||||
// The reply that ended the drought -- record only; the
|
||||
// glass-tick printer emits the RECOVER line. btnDuring
|
||||
// is the field theory's verdict: >0 on every recover =
|
||||
// button traffic resyncs the link; 0 = it self-heals.
|
||||
gRioStallActive = 0;
|
||||
++gRioStallCount;
|
||||
gRioLastStallMs = gRioLastReplyTick - gRioStallStart;
|
||||
gRioLastStallBtns = gRioBtnCount - gRioStallBtnBase;
|
||||
}
|
||||
destination->Type = RIO::AnalogEvent;
|
||||
//
|
||||
// NOTE: no data is sent in this packet.
|
||||
@@ -1423,11 +1574,13 @@ Logical
|
||||
break;
|
||||
|
||||
case ButtonPressed:
|
||||
++gRioBtnCount;
|
||||
destination->Type = RIO::ButtonPressedEvent;
|
||||
destination->Data.Unit = receive_buffer[1];
|
||||
break;
|
||||
|
||||
case ButtonReleased:
|
||||
++gRioBtnCount;
|
||||
destination->Type = RIO::ButtonReleasedEvent;
|
||||
destination->Data.Unit = receive_buffer[1];
|
||||
break;
|
||||
@@ -1568,6 +1721,9 @@ void
|
||||
if (operational && !TestModeActive)
|
||||
{
|
||||
SendPacket((Byte *) request_analog_string);
|
||||
++gRioReqCount;
|
||||
gRioReqPending = 1;
|
||||
gRioLastReqTick = GetTickCount();
|
||||
}
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
@@ -632,3 +632,49 @@ unsigned long long
|
||||
}
|
||||
return SteamUser()->GetSteamID().ConvertToUint64();
|
||||
}
|
||||
|
||||
//
|
||||
// #163 -- the ORDERED transport shutdown for process exit (2026-08-11).
|
||||
//
|
||||
// The port never called SteamAPI_Shutdown(): every round-end died via bare
|
||||
// ExitProcess with live SNS connections and Steam callback threads, and the
|
||||
// DLL_PROCESS_DETACH path intermittently DEADLOCKED. When it did, the old
|
||||
// process froze holding its last fullscreen frame ("frozen view of how the
|
||||
// game ended") while its already-spawned menu child hung inside
|
||||
// SteamAPI_Init against the wedged sibling's client session -- field-
|
||||
// captured on ZEUS at 22:12:38 (a 3-line child boot stub that never reached
|
||||
// the unconditional [steamnet] up line). Closing every connection and
|
||||
// shutting the API down BEFORE the child is spawned hands it a clean Steam
|
||||
// client and leaves detach nothing to strangle. Callers then die via
|
||||
// TerminateProcess, which skips DLL detach entirely.
|
||||
//
|
||||
void
|
||||
BTSteamNet_ShutdownAll()
|
||||
{
|
||||
if (!steamActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
ISteamNetworkingSockets *sockets = SteamNetworkingSockets();
|
||||
for (int i = 0; i < MaxConnections; ++i)
|
||||
{
|
||||
if (connections[i].inUse
|
||||
&& connections[i].connection != k_HSteamNetConnection_Invalid)
|
||||
{
|
||||
sockets->CloseConnection(connections[i].connection, 0,
|
||||
"round over", false);
|
||||
}
|
||||
connections[i].inUse = 0;
|
||||
}
|
||||
for (int p = 0; p < FakePortCount; ++p)
|
||||
{
|
||||
if (listenSockets[p] != k_HSteamListenSocket_Invalid)
|
||||
{
|
||||
sockets->CloseListenSocket(listenSockets[p]);
|
||||
listenSockets[p] = k_HSteamListenSocket_Invalid;
|
||||
}
|
||||
}
|
||||
SteamAPI_RunCallbacks(); // let the closes flush client-side
|
||||
steamActive = 0;
|
||||
SteamAPI_Shutdown();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ int
|
||||
BTSteamNet_Active();
|
||||
void
|
||||
BTSteamNet_Pump(); // callbacks + rx drain (game thread)
|
||||
void
|
||||
BTSteamNet_ShutdownAll(); // #163: ordered transport death for
|
||||
// process exit -- close every
|
||||
// connection, then SteamAPI_Shutdown
|
||||
|
||||
//
|
||||
// The wire-seam surface (BTNet* wrappers in L4NET.CPP + the marshal).
|
||||
|
||||
+34
-234
@@ -615,41 +615,17 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa
|
||||
Word *source = pixelBuffer.Data.MapPointer;
|
||||
Word *dest = (Word*)rect.pBits;
|
||||
int postRowIncrement = (rect.Pitch / 2) - w;
|
||||
// PPC sync-scramble (phase-14): map the SOURCE read through the recovery
|
||||
// envelope (collapse toward a line -> broaden -> lock). Identity when not
|
||||
// armed (ScrambleParams returns False). Output columns outside the collapsed
|
||||
// band read index 0 (black), so the picture squeezes to a line and grows back.
|
||||
double scl, shr, roff; int cx, shake;
|
||||
Logical scr = ScrambleParams(w, &scl, &shr, &roff, &cx, &shake);
|
||||
double invS = scr ? (1.0 / scl) : 1.0;
|
||||
if (monoTint < 0)
|
||||
{
|
||||
// PALETTE surface (sec/radar) -- palette-LUT expand (== SVGA16::Update case 0).
|
||||
SVGA16Palette *pal = &palette[paletteID];
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
int sry = y + shake; if (sry < 0) sry = 0; else if (sry >= h) sry = h - 1;
|
||||
Word *srcRow = source + sry * w; // vertical shake bounces the row
|
||||
double rowScroll = roff + shr * (double)y; // scroll + diagonal, per row
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
Word px;
|
||||
if (scr)
|
||||
{
|
||||
double srcBase = ((double)x - cx) * invS + cx; // collapse band
|
||||
if (srcBase < 0.0 || srcBase >= w)
|
||||
px = 0; // black outside the line
|
||||
else
|
||||
{
|
||||
long sx = (long)(srcBase + rowScroll + 0.5);
|
||||
sx %= w; if (sx < 0) sx += w; // roll SCROLLS within source
|
||||
px = srcRow[sx];
|
||||
}
|
||||
}
|
||||
else px = srcRow[x];
|
||||
PaletteTriplet *pe = &(pal->paletteData.Color[px & mask]);
|
||||
PaletteTriplet *pe = &(pal->paletteData.Color[*source & mask]);
|
||||
*dest = ((pe->Red >> 3) << 11) | ((pe->Green >> 2) << 5) | (pe->Blue >> 3);
|
||||
dest++;
|
||||
dest++; source++;
|
||||
}
|
||||
dest += postRowIncrement;
|
||||
}
|
||||
@@ -661,27 +637,10 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa
|
||||
Word tint = (Word) monoTint;
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
int sry = y + shake; if (sry < 0) sry = 0; else if (sry >= h) sry = h - 1;
|
||||
Word *srcRow = source + sry * w; // vertical shake bounces the row
|
||||
double rowScroll = roff + shr * (double)y;
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
Word px;
|
||||
if (scr)
|
||||
{
|
||||
double srcBase = ((double)x - cx) * invS + cx;
|
||||
if (srcBase < 0.0 || srcBase >= w)
|
||||
px = 0;
|
||||
else
|
||||
{
|
||||
long sx = (long)(srcBase + rowScroll + 0.5);
|
||||
sx %= w; if (sx < 0) sx += w;
|
||||
px = srcRow[sx];
|
||||
}
|
||||
}
|
||||
else px = srcRow[x];
|
||||
*dest = (px & mask) ? tint : 0;
|
||||
dest++;
|
||||
*dest = (*source & mask) ? tint : 0;
|
||||
dest++; source++;
|
||||
}
|
||||
dest += postRowIncrement;
|
||||
}
|
||||
@@ -733,6 +692,28 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa
|
||||
device->DrawPrimitiveUP(D3DPT_TRIANGLEFAN, 2, quad, sizeof(InsetVert));
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// GLASS dirty-skip: FNV-1a over the shared pixelBuffer masked to `mask` -- the bits
|
||||
// one glass window can show. The glass repaint pump compares this per window and
|
||||
// re-blits only the ones whose plane changed (L4GLASSWIN BTGlassPanels_Tick). Full
|
||||
// pass (no stride) so a single-word gauge change is never missed; ~640*480 cheap
|
||||
// integer ops, run at most once per window per ~16 Hz pump.
|
||||
//===========================================================================//
|
||||
unsigned long SVGA16::PlaneChecksum(int mask) const
|
||||
{
|
||||
int w = pixelBuffer.Data.Size.x;
|
||||
int h = pixelBuffer.Data.Size.y;
|
||||
const Word *p = pixelBuffer.Data.MapPointer;
|
||||
if (p == NULL || w <= 0 || h <= 0)
|
||||
return 0;
|
||||
unsigned long sum = 2166136261UL; // FNV-1a offset basis
|
||||
Word m = (Word)mask;
|
||||
int n = w * h;
|
||||
for (int i = 0; i < n; ++i)
|
||||
sum = (sum ^ (unsigned long)(p[i] & m)) * 16777619UL;
|
||||
return sum;
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// GLASS per-display windows -- the CPU (no-D3D) analog of DrawDevSurface: expand
|
||||
// one bit-plane of the shared gauge pixelBuffer into a 32-bit BGRA image that the
|
||||
@@ -753,38 +734,16 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
|
||||
Word *base = pixelBuffer.Data.MapPointer;
|
||||
SVGA16Palette *pal = &palette[paletteID];
|
||||
|
||||
// PPC sync-scramble (phase-14) -- the recovery envelope (collapse -> broaden ->
|
||||
// lock), mapped in SOURCE space so it survives the rotation. Identity when not
|
||||
// armed (ScrambleParams False). Reads outside the collapsed band -> index 0 (black).
|
||||
double scl, shr, roff; int cx, shake;
|
||||
Logical scr = ScrambleParams(w, &scl, &shr, &roff, &cx, &shake);
|
||||
double invS = scr ? (1.0 / scl) : 1.0;
|
||||
|
||||
if (rotateQuadrant == 0)
|
||||
{
|
||||
// Native orientation, top-down straight copy.
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
int sry = y + shake; if (sry < 0) sry = 0; else if (sry >= h) sry = h - 1;
|
||||
Word *src = base + sry * w; // vertical shake bounces the row
|
||||
Word *src = base + y * w;
|
||||
unsigned long *d = dst + y * w;
|
||||
double rowScroll = roff + shr * (double)y;
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
Word s;
|
||||
if (scr)
|
||||
{
|
||||
double srcBase = ((double)x - cx) * invS + cx; // collapse band
|
||||
if (srcBase < 0.0 || srcBase >= w)
|
||||
s = 0; // black outside the line
|
||||
else
|
||||
{
|
||||
long sx = (long)(srcBase + rowScroll + 0.5);
|
||||
sx %= w; if (sx < 0) sx += w; // roll SCROLLS within source
|
||||
s = src[sx];
|
||||
}
|
||||
}
|
||||
else s = src[x];
|
||||
Word s = src[x];
|
||||
if (monoTint < 0)
|
||||
{
|
||||
PaletteTriplet *pe = &(pal->paletteData.Color[s & mask]);
|
||||
@@ -832,8 +791,6 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
|
||||
|
||||
// 90-degree rotation: output is transposed (ow = h, oh = w). rotate 3 = CW,
|
||||
// rotate 1 = CCW (the DrawDevSurface convention; BT_GAUGE_SEC_ROT picks it).
|
||||
// The scramble maps the SOURCE column (sx keyed on source row sy) through the
|
||||
// same envelope, so the radar recovers the same way the landscape MFDs do.
|
||||
int ow = h, oh = w;
|
||||
for (int oy = 0; oy < oh; oy++)
|
||||
{
|
||||
@@ -851,21 +808,7 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
|
||||
sx = w - 1 - oy;
|
||||
sy = ox;
|
||||
}
|
||||
int syS = sy + shake; if (syS < 0) syS = 0; else if (syS >= h) syS = h - 1; // shake
|
||||
Word s;
|
||||
if (scr)
|
||||
{
|
||||
double srcBase = ((double)sx - cx) * invS + cx; // collapse band (source col)
|
||||
if (srcBase < 0.0 || srcBase >= w)
|
||||
s = 0;
|
||||
else
|
||||
{
|
||||
long msx = (long)(srcBase + roff + shr * (double)sy + 0.5);
|
||||
msx %= w; if (msx < 0) msx += w; // roll SCROLLS within source
|
||||
s = base[syS * w + msx];
|
||||
}
|
||||
}
|
||||
else s = base[syS * w + sx];
|
||||
Word s = base[sy * w + sx];
|
||||
if (monoTint < 0)
|
||||
{
|
||||
PaletteTriplet *pe = &(pal->paletteData.Color[s & mask]);
|
||||
@@ -5502,9 +5445,6 @@ SVGA16::SVGA16(
|
||||
BuildWindows(init_width,init_height,windowed, secondaryIndex, aux1Index, aux2Index);
|
||||
for (int _i = 0; _i < 10; _i++) // DEV-COMPOSITE: lazily created on first surface draw
|
||||
mDevSurfaceTex[_i] = NULL;
|
||||
scrambleActive = False; // PPC sync-scramble (phase-14) -- armed by FunkyVideo
|
||||
scrambleStartMs = 0;
|
||||
scrambleDurationS = 0.0;
|
||||
//STUBBED: VIDEO RB 1/15/07
|
||||
# if defined(DEBUG)
|
||||
Tell("SVGA16::SVGA16()\n");
|
||||
@@ -6464,153 +6404,13 @@ void
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
//
|
||||
// PPC sync-scramble (phase-14), animated as a RECOVERY. The original
|
||||
// (`//STUBBED: VIDEO RB 1/15/07`) detuned the VGA CRTC Horizontal Total so every
|
||||
// secondary monitor lost horizontal lock for ~0.8 s. There is no CRTC here, so
|
||||
// FunkyVideo records the start + duration and the per-surface expansions
|
||||
// (DrawDevSurface / ExpandPlaneToBGRA) map the pixelBuffer read through the
|
||||
// ScrambleParams envelope: at the hit the image COLLAPSES horizontally toward a
|
||||
// line, then BROADENS back out as the tear + roll DECAY, and LOCKS to the full
|
||||
// clean display at the end. The 0.8 s window + non-stacking latch stay in
|
||||
// L4GaugeRenderer; the shape lives here.
|
||||
//
|
||||
void
|
||||
SVGA16::FunkyVideo(Logical on_off, Scalar duration)
|
||||
SVGA16::FunkyVideo(Logical on_off)
|
||||
{
|
||||
if (on_off)
|
||||
{
|
||||
scrambleActive = True;
|
||||
scrambleStartMs = GetTickCount();
|
||||
scrambleDurationS = (duration > 0.0f) ? (double)duration : 0.8; // the HOLD
|
||||
}
|
||||
// FunkyVideo(False) is a NO-OP: the card restoring the sync (at HOLD end) is
|
||||
// where the RECOVERY begins, so ScrambleParams keeps running past the flag,
|
||||
// self-terminating at hold+recovery. scrambleActive stays armed; the clock gates.
|
||||
}
|
||||
|
||||
//
|
||||
// Two-phase envelope (per the "hold the bad sync, THEN recover" direction):
|
||||
// HOLD [0, hold] -- the card holds the detuned CRTC. Deep collapse (a line),
|
||||
// and a WILDLY fast horizontal roll that decelerates over
|
||||
// the hold (content scrolls, wrapping, so it can't be read).
|
||||
// RECOVERY [hold, +] -- the sync is restored and the monitor re-locks: the line
|
||||
// BROADENS back to full while the residual scroll + tear
|
||||
// settle to zero, then LOCKS (returns False = clean).
|
||||
// Fills the read-loop parameters: scale (collapse band width fraction), rollOff
|
||||
// (SCROLL offset in px, wrapped within the source), shear (px/row diagonal),
|
||||
// centerX. Tunables (read once): BT_SCRAMBLE_COLLAPSE (min band, deeper=smaller),
|
||||
// BT_SCRAMBLE_ROLL (initial scroll px/sec -- "wildly high"), BT_SCRAMBLE_SHEAR
|
||||
// (px/row), BT_SCRAMBLE_RECOVER (recovery seconds), BT_SCRAMBLE_DUR (hold seconds
|
||||
// override). BT_SCRAMBLE_TEST loops it; BT_SCRAMBLE_CYCLE loops stepping the roll.
|
||||
//
|
||||
Logical
|
||||
SVGA16::ScrambleParams(int width, double *scale, double *shear,
|
||||
double *rollOff, int *centerX, int *shakeRow) const
|
||||
{
|
||||
*shakeRow = 0;
|
||||
if (width <= 0)
|
||||
return False;
|
||||
|
||||
static int sInit = 0, sTest = 0, sCycle = 0;
|
||||
static double shearMax = 3.0, rollMax = 9000.0, collapse = 0.03,
|
||||
recover = 0.5, holdEnv = 0.0, shakeAmp = 10.0;
|
||||
if (!sInit)
|
||||
{
|
||||
sInit = 1;
|
||||
sTest = (getenv("BT_SCRAMBLE_TEST") != NULL) ? 1 : 0;
|
||||
sCycle = (getenv("BT_SCRAMBLE_CYCLE") != NULL) ? 1 : 0;
|
||||
const char *e;
|
||||
if ((e = getenv("BT_SCRAMBLE_SHEAR")) != NULL) shearMax = atof(e);
|
||||
if ((e = getenv("BT_SCRAMBLE_ROLL")) != NULL) rollMax = atof(e);
|
||||
if ((e = getenv("BT_SCRAMBLE_COLLAPSE")) != NULL) collapse = atof(e);
|
||||
if ((e = getenv("BT_SCRAMBLE_RECOVER")) != NULL) recover = atof(e);
|
||||
if ((e = getenv("BT_SCRAMBLE_DUR")) != NULL) holdEnv = atof(e);
|
||||
if ((e = getenv("BT_SCRAMBLE_SHAKE")) != NULL) shakeAmp = atof(e);
|
||||
if (collapse < 0.004) collapse = 0.004;
|
||||
if (collapse > 1.0) collapse = 1.0;
|
||||
if (recover < 0.05) recover = 0.05;
|
||||
}
|
||||
|
||||
double hold = (holdEnv > 0.0) ? holdEnv
|
||||
: (scrambleDurationS > 0.0 ? scrambleDurationS : 0.8);
|
||||
double total = hold + recover;
|
||||
double rlMax = rollMax;
|
||||
double elapsed;
|
||||
|
||||
if (sTest || sCycle)
|
||||
{
|
||||
// Tuning: loop the whole effect (hold+recovery) + a short locked pause.
|
||||
// CYCLE steps the initial roll speed each loop.
|
||||
double loop = total + 0.6;
|
||||
double t = (double)GetTickCount() * 0.001;
|
||||
long n = (long)(t / loop);
|
||||
double ph = t - (double)n * loop;
|
||||
if (ph >= total)
|
||||
return False; // locked pause between loops
|
||||
elapsed = ph;
|
||||
if (sCycle)
|
||||
{
|
||||
static const double kR[] = { 3000.0, 6000.0, 9000.0, 14000.0, 20000.0 };
|
||||
int nS = (int)(sizeof(kR) / sizeof(kR[0]));
|
||||
rlMax = kR[(int)(n % nS)];
|
||||
static long lastN = -1;
|
||||
if (n != lastN) { lastN = n;
|
||||
DEBUG_STREAM << "[scramble-cycle] loop " << n << " rollMax=" << rlMax
|
||||
<< "px/s (hold " << hold << "s + recover " << recover << "s)\n"
|
||||
<< std::flush; }
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!scrambleActive)
|
||||
return False;
|
||||
elapsed = ((double)(GetTickCount() - scrambleStartMs)) * 0.001;
|
||||
if (elapsed >= total)
|
||||
return False; // LOCKED -- clean display
|
||||
}
|
||||
if (elapsed < 0.0) elapsed = 0.0;
|
||||
|
||||
double sScale, sScroll, sShear;
|
||||
if (elapsed < hold)
|
||||
{
|
||||
// HOLD: bad sync held. Deep collapse; roll starts wild and decelerates to a
|
||||
// stop by the hold end. scroll = integral of v(t)=rlMax*(1-t/hold): the
|
||||
// content scrolls fast then coasts to rest (rlMax*hold/2 accumulated).
|
||||
sScale = collapse;
|
||||
sScroll = rlMax * elapsed * (1.0 - elapsed / (2.0 * hold));
|
||||
sShear = shearMax;
|
||||
}
|
||||
else
|
||||
{
|
||||
// RECOVERY: sync restored, monitor re-locks. Broaden line->full and settle
|
||||
// the residual scroll + tear to zero (smoothstep), then it hits total = lock.
|
||||
double tr = (elapsed - hold) / recover;
|
||||
double b = tr * tr * (3.0 - 2.0 * tr);
|
||||
sScale = collapse + (1.0 - collapse) * b;
|
||||
sScroll = (rlMax * hold * 0.5) * (1.0 - b);
|
||||
sShear = shearMax * (1.0 - b);
|
||||
}
|
||||
|
||||
// SHAKE: a per-FRAME jitter (LCG, so all surfaces shake together within a frame
|
||||
// but re-roll each frame), violent at the hit and fading through the recovery.
|
||||
// Horizontal folds into the scroll; vertical bounces the source row (shakeRow).
|
||||
double shakeEnv = (elapsed < hold)
|
||||
? (1.0 - 0.5 * (elapsed / hold)) // 1.0 at impact -> 0.5 at hold end
|
||||
: (0.5 * (1.0 - (elapsed - hold) / recover)); // 0.5 -> 0 through recovery
|
||||
if (shakeEnv < 0.0) shakeEnv = 0.0;
|
||||
unsigned int fr = (unsigned int)(GetTickCount() / 16); // ~per-frame index
|
||||
unsigned int r = fr * 1103515245u + 12345u;
|
||||
double jX = ((double)((r >> 16) & 0x7FFF) / 16384.0) - 1.0; // [-1,1)
|
||||
r = r * 1103515245u + 12345u;
|
||||
double jY = ((double)((r >> 16) & 0x7FFF) / 16384.0) - 1.0;
|
||||
|
||||
*scale = sScale;
|
||||
*shear = sShear;
|
||||
*rollOff = sScroll + shakeAmp * shakeEnv * jX; // horizontal shake -> scroll
|
||||
*centerX = width / 2;
|
||||
*shakeRow = (int)(shakeAmp * shakeEnv * jY + (jY >= 0.0 ? 0.5 : -0.5)); // vertical bounce
|
||||
return True;
|
||||
//STUBBED: VIDEO RB 1/15/07
|
||||
//Check(this);
|
||||
//SVGAFunkyVideo(on_off);
|
||||
//Check_Fpu();
|
||||
}
|
||||
|
||||
//########################################################################
|
||||
|
||||
@@ -332,27 +332,7 @@ public:
|
||||
UnflashPalette(int palette_number);
|
||||
|
||||
void
|
||||
FunkyVideo(Logical on_off, Scalar duration = 0.0f);
|
||||
|
||||
// PPC sync-scramble (phase-14): the modern stand-in for the VGA CRTC
|
||||
// Horizontal-Total detune, animated as a RECOVERY over the effect window -- at
|
||||
// the hit the image collapses horizontally toward a line, then broadens back
|
||||
// out as the tear + roll decay, and LOCKS to the full clean display at the end.
|
||||
// FunkyVideo(on, dur) records the start + duration; ScrambleParams derives the
|
||||
// per-frame envelope (returns True while active + fills scale/shear/rollOff/
|
||||
// centerX); DrawDevSurface (surround) + ExpandPlaneToBGRA (glass) map the
|
||||
// pixelBuffer read through it, so all secondary surfaces recover together and
|
||||
// the main 3D view is untouched. Tunable by eye: BT_SCRAMBLE_SHEAR (max
|
||||
// px/line), BT_SCRAMBLE_ROLL (max px/sec), BT_SCRAMBLE_COLLAPSE (min width
|
||||
// fraction 0..1), BT_SCRAMBLE_DUR (anim seconds override). BT_SCRAMBLE_TEST
|
||||
// loops the transition for tuning; BT_SCRAMBLE_CYCLE loops it stepping the
|
||||
// max shear. (Exact look depended on each pod monitor's PLL -- not recoverable.)
|
||||
Logical
|
||||
ScrambleParams(int width, double *scale, double *shear,
|
||||
double *rollOff, int *centerX, int *shakeRow) const;
|
||||
Logical scrambleActive;
|
||||
unsigned long scrambleStartMs;
|
||||
double scrambleDurationS;
|
||||
FunkyVideo(Logical on_off);
|
||||
|
||||
protected:
|
||||
|
||||
@@ -439,6 +419,12 @@ public:
|
||||
// dwords; the image is written TOP-DOWN. *outW/*outH receive the produced size.
|
||||
void ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotateQuadrant,
|
||||
unsigned long *dst, int *outW, int *outH);
|
||||
|
||||
// GLASS dirty-skip (L4GLASSWIN, 2026-08-09): FNV-1a checksum of the shared
|
||||
// gauge pixelBuffer, masked to the bits a given window can show. Lets the
|
||||
// glass repaint pump re-blit ONLY the windows whose plane actually changed
|
||||
// (idle MFDs / static panels skip; the sweeping radar keeps updating).
|
||||
unsigned long PlaneChecksum(int mask) const;
|
||||
};
|
||||
|
||||
//########################################################################
|
||||
|
||||
@@ -8441,6 +8441,107 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
|
||||
lastFrameTime = mTargetRenderTime;
|
||||
currentFrameTime = Now();
|
||||
|
||||
// #156 -- the MISSION START/END FADE (POVStartEndRenderable, ctor @00454394,
|
||||
// Execute @0045447c; the port class is armed by MakeEntityRenderables'
|
||||
// BTPlayer master case via BTMissionFadeArm). The 1995 renderable drives
|
||||
// the POV zone fog through a 5-phase machine keyed on the local player's
|
||||
// SimulationState; constants byte-read from BTL4OPT.EXE @0x454740-0x454750:
|
||||
// flash hold 0.1s, fades 1.0s, plane floors near 0.01 / far 0.05. Phases:
|
||||
// 0 armed -> (MissionStarting) 1 WHITE FLASH (fog 1,1,1 @ 0.01/0.05) ->
|
||||
// 2 FADE-IN (white -> live style; planes floor -> authored) -> 3 running ->
|
||||
// (MissionEnding) 4 FADE-OUT (color x local -> BLACK, planes close) ->
|
||||
// re-arm. The binary re-reads the LIVE fog each frame mid-fade (style
|
||||
// swaps track); we read the STYLE members (fogRed/G/B, fogNear/Far), never
|
||||
// currentFogNear/Far which we are overriding. Placed at the frame head:
|
||||
// the Clear below samples FOGCOLOR, so the fade owns the WHOLE frame
|
||||
// including sky/void. Receipts: [fade] phase lines under BT_FADE_LOG or
|
||||
// BT_FOG_LOG.
|
||||
{
|
||||
extern void *gBTFadeIndicator; // BTMissionFadeArm (btl4vid.cpp)
|
||||
extern int gBTFadeStart, gBTFadeEnd;
|
||||
static int s_phase = 0;
|
||||
static float s_T = 0.0f;
|
||||
if (gBTFadeIndicator != 0)
|
||||
{
|
||||
const float kFlash = 0.1f, kFade = 1.0f; // @0x454740 / @0x454744
|
||||
const float kNearFloor = 0.01f, kFarFloor = 0.05f; // @0x454750 / @0x45474c
|
||||
int st = ((StateIndicator *)gBTFadeIndicator)->GetState();
|
||||
float fdt = (float)dT;
|
||||
if (fdt < 0.0f) fdt = 0.0f; else if (fdt > 0.1f) fdt = 0.1f;
|
||||
float r = -1.0f, g = 0.0f, b = 0.0f, fn = 0.0f, ff = 0.0f;
|
||||
switch (s_phase)
|
||||
{
|
||||
case 0:
|
||||
if (st == gBTFadeStart) { s_phase = 1; s_T = 0.0f; }
|
||||
break;
|
||||
case 1:
|
||||
r = 1.0f; g = 1.0f; b = 1.0f; fn = kNearFloor; ff = kFarFloor;
|
||||
s_T += fdt;
|
||||
if (s_T >= kFlash) { s_phase = 2; s_T = 0.0f; }
|
||||
break;
|
||||
case 2:
|
||||
{
|
||||
float local = kFade - s_T; if (local < 0.0f) local = 0.0f;
|
||||
float el = kFade - local;
|
||||
r = (1.0f - fogRed) * local + fogRed;
|
||||
g = (1.0f - fogGreen) * local + fogGreen;
|
||||
b = (1.0f - fogBlue) * local + fogBlue;
|
||||
fn = fogNear * el + kNearFloor;
|
||||
ff = fogFar * el + kFarFloor;
|
||||
s_T += fdt;
|
||||
if (local <= 0.0f) { s_phase = 3; r = -1.0f; } // fog back to the style
|
||||
break;
|
||||
}
|
||||
case 3:
|
||||
if (st == gBTFadeEnd) { s_phase = 4; s_T = 0.0f; }
|
||||
break;
|
||||
case 4:
|
||||
{
|
||||
float local = kFade - s_T; if (local < 0.0f) local = 0.0f;
|
||||
r = fogRed * local;
|
||||
g = fogGreen * local;
|
||||
b = fogBlue * local;
|
||||
fn = fogNear * local + kNearFloor;
|
||||
ff = fogFar * local + kFarFloor;
|
||||
s_T += fdt;
|
||||
if (local <= 0.0f) { s_phase = 0; } // re-armed; final frame stays black
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (r >= 0.0f)
|
||||
{
|
||||
currentFogNear = fn;
|
||||
currentFogFar = ff;
|
||||
mDevice->SetRenderState(D3DRS_FOGCOLOR,
|
||||
D3DCOLOR_XRGB((int)(255 * r), (int)(255 * g), (int)(255 * b)));
|
||||
mDevice->SetRenderState(D3DRS_FOGENABLE, TRUE);
|
||||
}
|
||||
else if (s_phase == 3 || s_phase == 0)
|
||||
{
|
||||
// Out of the fade: restore the style planes once so a fade that
|
||||
// ended mid-style leaves the world exactly on its authored fog.
|
||||
static int s_prev = -1;
|
||||
if (s_prev != s_phase)
|
||||
{
|
||||
currentFogNear = fogNear;
|
||||
currentFogFar = fogFar;
|
||||
mDevice->SetRenderState(D3DRS_FOGCOLOR,
|
||||
D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen),
|
||||
(int)(255 * fogBlue)));
|
||||
}
|
||||
s_prev = s_phase;
|
||||
}
|
||||
static int s_lastLogged = -1;
|
||||
if (s_phase != s_lastLogged)
|
||||
{
|
||||
s_lastLogged = s_phase;
|
||||
if (getenv("BT_FADE_LOG") || getenv("BT_FOG_LOG"))
|
||||
DEBUG_STREAM << "[fade] phase " << s_phase
|
||||
<< " (simState=" << st << ")\n" << std::flush;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DIAG (turn-hitch hunt): time the render phases -- draw CPU vs Present
|
||||
// (GPU-queue block). Logged on slow frames + 1 Hz stats.
|
||||
LARGE_INTEGER _rt0; QueryPerformanceCounter(&_rt0);
|
||||
@@ -9034,6 +9135,20 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
|
||||
DEBUG_STREAM << "[rstat] frames=" << sFrames << " avg=" << (sAcc / sFrames)
|
||||
<< "ms maxDraw=" << sMaxD << " maxPresent=" << sMaxP
|
||||
<< " batches=" << gNumBatches << " culled=" << gBTNumCulled << "\n" << std::flush;
|
||||
// #149: segment-refresh telemetry on the same cadence (BT_PERF_LOG).
|
||||
// calls = GetSegmentToWorld entries; dirty = the mark-every-segment
|
||||
// invalidation passes (the expensive arm the #141 sweep may have
|
||||
// multiplied); ms = time inside the accessor for the whole window.
|
||||
{
|
||||
static const int sSegPerf = !(getenv("BT_PERF_LOG") && *getenv("BT_PERF_LOG") == '0');
|
||||
extern int gBTSegWCalls, gBTSegWDirty;
|
||||
extern double gBTSegWMs;
|
||||
if (sSegPerf)
|
||||
DEBUG_STREAM << "[segperf] calls=" << gBTSegWCalls
|
||||
<< " dirty=" << gBTSegWDirty
|
||||
<< " ms=" << gBTSegWMs << "\n" << std::flush;
|
||||
gBTSegWCalls = 0; gBTSegWDirty = 0; gBTSegWMs = 0.0;
|
||||
}
|
||||
sAcc = 0.0; sFrames = 0; sMaxD = 0.0; sMaxP = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ void
|
||||
void
|
||||
BTGlassPanels_Destroy();
|
||||
|
||||
// BT_GLASS_IDS=1 -- log every attached panel's BOOT-STABLE hardware identity
|
||||
// plus a ready-to-paste `monitor:id:<fragment>` line for glass_layout.cfg.
|
||||
// Called from BTGlassPanels_Create; no-op unless the env is set.
|
||||
void
|
||||
BTGlassDumpMonitorIds();
|
||||
|
||||
//
|
||||
// Per-frame repaint pump. Call once per frame from the main render loop so the
|
||||
// per-display windows' lamp flash keeps animating even when they are in the
|
||||
|
||||
@@ -234,6 +234,36 @@ static LONG WINAPI
|
||||
return EXCEPTION_EXECUTE_HANDLER; // die (after the evidence is out)
|
||||
}
|
||||
|
||||
//
|
||||
// lastrun_<stem>.txt clean-exit markers (#157, 2026-08-10): the launch record
|
||||
// only ever said "exe reached WinMain", so a night that ended with the player
|
||||
// closing the menu was indistinguishable from a silent death -- and the bat's
|
||||
// sign-off taught players to report normal quits as crashes. The deliberate
|
||||
// exit paths stamp the record themselves now, so the file answers "crash or
|
||||
// quit?" on its own. Name captured at breadcrumb time (cwd is content\ by
|
||||
// then -- the same file the bats bracket).
|
||||
//
|
||||
static char gBTLastrunName[MAX_PATH] = "";
|
||||
|
||||
void
|
||||
BTLastrunNote(const char *note)
|
||||
{
|
||||
if (gBTLastrunName[0] == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
SYSTEMTIME lt;
|
||||
GetLocalTime(<);
|
||||
std::ofstream f(gBTLastrunName, std::ios::out | std::ios::app);
|
||||
if (f.is_open())
|
||||
{
|
||||
char stamp[64];
|
||||
wsprintfA(stamp, "[%04d-%02d-%02d %02d:%02d:%02d] ",
|
||||
lt.wYear, lt.wMonth, lt.wDay, lt.wHour, lt.wMinute, lt.wSecond);
|
||||
f << stamp << note << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// environ.ini -- the player's settings file (cwd = content\). One KEY=VALUE
|
||||
// per line; the real environment always WINS, so a launcher .bat or a shell
|
||||
@@ -833,6 +863,7 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
|
||||
GetComputerNameA(machine, &machine_len);
|
||||
char lastrun_name[MAX_PATH];
|
||||
wsprintfA(lastrun_name, "lastrun_%s.txt", logStem);
|
||||
lstrcpynA(gBTLastrunName, lastrun_name, MAX_PATH);
|
||||
std::ofstream breadcrumb(lastrun_name, std::ios::out | std::ios::app);
|
||||
if (breadcrumb.is_open())
|
||||
{
|
||||
@@ -1209,6 +1240,17 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
|
||||
BTFeLaunchSpec fe_spec;
|
||||
if (BTFrontEnd_Run(&fe_spec) != 0 || fe_spec.mode == BTFeLaunchNone)
|
||||
{
|
||||
BTLastrunNote("clean exit: player quit from the menu");
|
||||
#ifdef BT_STEAM
|
||||
// #163: the menu keeps the Steam transport up for the lobby;
|
||||
// returning through the CRT would run DLL detach with live
|
||||
// Steam threads -- the same intermittent deadlock the
|
||||
// round-end relaunch hit. Tear it down deliberately first.
|
||||
{
|
||||
extern void BTSteamNet_ShutdownAll();
|
||||
BTSteamNet_ShutdownAll();
|
||||
}
|
||||
#endif
|
||||
return 0; // quit from the menu
|
||||
}
|
||||
char fe_arguments[192];
|
||||
|
||||
@@ -94,6 +94,7 @@ static MarshalState marshalState;
|
||||
//
|
||||
#ifdef BT_STEAM
|
||||
extern int BTSteamNet_IsFakeAddress(unsigned long internet_address_be);
|
||||
extern void BTSteamNet_ShutdownAll(); // #163: ordered exit
|
||||
extern SOCKET BTSteamNet_Connect(unsigned long internet_address_be, int remote_port);
|
||||
extern int BTSteamNet_Owns(SOCKET wire_socket);
|
||||
extern int BTSteamNet_Send(SOCKET wire_socket, const char *data, int length);
|
||||
@@ -153,6 +154,48 @@ static unsigned long
|
||||
return gBTBootTick != 0 ? gBTBootTick : GetTickCount();
|
||||
}
|
||||
|
||||
//
|
||||
// #163 -- ORDERLY DEATH (2026-08-11). Bare ExitProcess ran DLL detach with
|
||||
// live Steam threads and intermittently deadlocked: the dying process kept
|
||||
// its last fullscreen frame on screen ("frozen view of how the game ended")
|
||||
// while the healthy menu child it had already spawned hung in SteamAPI_Init
|
||||
// against the wedged sibling -- both round-end freeze reports AND the
|
||||
// hang-on-exit reports, one mechanism (the HOST worst: this exit runs on
|
||||
// the MARSHAL thread while the main thread is mid-teardown). The order:
|
||||
// flush the forensics, tear the Steam transport down, hide every window
|
||||
// this process owns (a residual wedge must never squat on the screen), then
|
||||
// TerminateProcess -- which skips DLL detach entirely, making the deadlock
|
||||
// structurally unreachable. The 1995 pod never exited between rounds at
|
||||
// all (@0x47c560 parks in WaitingForEgg), so exit is pure port territory --
|
||||
// no binary semantic to preserve.
|
||||
//
|
||||
static BOOL CALLBACK
|
||||
HideProcessWindow(HWND window, LPARAM pid)
|
||||
{
|
||||
DWORD owner = 0;
|
||||
GetWindowThreadProcessId(window, &owner);
|
||||
if (owner == (DWORD)pid)
|
||||
{
|
||||
ShowWindow(window, SW_HIDE);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static void
|
||||
BTOrderlyDie(void)
|
||||
{
|
||||
{
|
||||
extern void BTMatchLogClose();
|
||||
BTMatchLogClose(); // complete receipts on disk (the
|
||||
} // field matchlogs lost their tails)
|
||||
fflush(NULL);
|
||||
#ifdef BT_STEAM
|
||||
BTSteamNet_ShutdownAll();
|
||||
#endif
|
||||
EnumWindows(HideProcessWindow, (LPARAM)GetCurrentProcessId());
|
||||
TerminateProcess(GetCurrentProcess(), 0);
|
||||
}
|
||||
|
||||
void
|
||||
BTFE_RelaunchSelfAndExit(const char *arguments)
|
||||
{
|
||||
@@ -174,7 +217,9 @@ void
|
||||
{
|
||||
MarshalLog("window closed by the user -- exiting for real "
|
||||
"(no relaunch)");
|
||||
ExitProcess(0);
|
||||
extern void BTLastrunNote(const char *);
|
||||
BTLastrunNote("clean exit: player closed the game window");
|
||||
BTOrderlyDie(); // #163: never bare ExitProcess
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +294,18 @@ void
|
||||
}
|
||||
command_line[n] = 0;
|
||||
|
||||
// #163 step (a): forensics flushed + the Steam transport DOWN before the
|
||||
// child exists -- it must find a clean Steam client (the field capture:
|
||||
// a menu child hung in SteamAPI_Init against its wedged sibling).
|
||||
{
|
||||
extern void BTMatchLogClose();
|
||||
BTMatchLogClose();
|
||||
}
|
||||
fflush(NULL);
|
||||
#ifdef BT_STEAM
|
||||
BTSteamNet_ShutdownAll();
|
||||
#endif
|
||||
|
||||
STARTUPINFOW startup;
|
||||
PROCESS_INFORMATION process;
|
||||
memset(&startup, 0, sizeof(startup));
|
||||
@@ -257,10 +314,22 @@ void
|
||||
if (CreateProcessW(exe_path, command_line, NULL, NULL, FALSE,
|
||||
0, NULL, NULL, &startup, &process))
|
||||
{
|
||||
// Hand the child our foreground right BEFORE we die (#157): Windows
|
||||
// refuses SetForegroundWindow to a process spawned by an exiting
|
||||
// background parent, so the round-end menu could open BEHIND the
|
||||
// desktop clutter -- which is how a normal between-rounds relaunch
|
||||
// read as "the game crashed" from the cockpit seat.
|
||||
if (!AllowSetForegroundWindow(process.dwProcessId))
|
||||
{
|
||||
MarshalLog("foreground handoff denied -- the next window may "
|
||||
"open behind");
|
||||
}
|
||||
CloseHandle(process.hThread);
|
||||
CloseHandle(process.hProcess);
|
||||
}
|
||||
ExitProcess(0);
|
||||
// #163 step (b): vanish, then die without touching DLL detach.
|
||||
EnumWindows(HideProcessWindow, (LPARAM)GetCurrentProcessId());
|
||||
TerminateProcess(GetCurrentProcess(), 0);
|
||||
}
|
||||
|
||||
//###########################################################################
|
||||
|
||||
+18
-1
@@ -412,7 +412,7 @@ static const CatalogEntry kVehicles[] =
|
||||
{ "avatar", "Avatar" },
|
||||
{ "ava1", "Satyr" },
|
||||
{ "sunder", "Sunder" },
|
||||
{ "snd1", "Sunder V1" },
|
||||
{ "snd1", "Denkou" }, // #69: the 4.x-era name (two era witnesses)
|
||||
{ "vulture", "Vulture" },
|
||||
{ "vul1", "Mad Dog" },
|
||||
{ "lok2", "Mischief" },
|
||||
@@ -1200,6 +1200,7 @@ int
|
||||
{
|
||||
BTLobbyRoster roster;
|
||||
if (BTLobby_HostAndRoom(self.name, self.vehicle, self.color, self.experience,
|
||||
self.badge, self.patch,
|
||||
&roster, spec->steamMyToken, sizeof(spec->steamMyToken),
|
||||
spec->steamMap, sizeof(spec->steamMap)) != 0 ||
|
||||
roster.memberCount == 0)
|
||||
@@ -1207,6 +1208,15 @@ int
|
||||
return 1; // cancelled / Steam unavailable
|
||||
}
|
||||
spec->mode = BTFeLaunchHostSteam;
|
||||
// #162: capture the host's ADV. DAMAGE menu choice BEFORE the roster
|
||||
// loop -- `self` aliases mission.pilots[0], which the loop overwrites
|
||||
// with the first lobby member. advancedDamage is a MISSION-level
|
||||
// setting (every stock 1995 egg stamps the same value on every pilot
|
||||
// page); leaving it unset here shipped advancedDamage=0 for EVERY
|
||||
// pilot of EVERY steam match, which silently disabled the -500 death
|
||||
// cost and the death splash all night (night-15 [exp] receipts:
|
||||
// advDamage=0 on 24 of 26 group-mission spawns).
|
||||
const int host_adv_damage = self.advancedDamage;
|
||||
mission.pilotCount = 0;
|
||||
sprintf(spec->podList, "127.0.0.1:%d", console_port);
|
||||
for (int m = 0; m < roster.memberCount && m < 8; ++m)
|
||||
@@ -1226,6 +1236,12 @@ int
|
||||
// but now only for clients that never published a choice.
|
||||
strncpy(pilot.experience, member.experience,
|
||||
sizeof(pilot.experience) - 1);
|
||||
// #38: badge + patch ride the lobby the same way (absent = the
|
||||
// writer's VGL/Red fallback, which is exactly what every non-host
|
||||
// player got all night while these fields were never carried).
|
||||
strncpy(pilot.badge, member.badge, sizeof(pilot.badge) - 1);
|
||||
strncpy(pilot.patch, member.patch, sizeof(pilot.patch) - 1);
|
||||
pilot.advancedDamage = host_adv_damage; // #162: mission-level
|
||||
sprintf(pilot.address, "%s:%d",
|
||||
member.fakeAddress, member.gamePort);
|
||||
if (!member.isSelf)
|
||||
@@ -1245,6 +1261,7 @@ int
|
||||
if (menu.steamAction == 2)
|
||||
{
|
||||
if (BTLobby_JoinAndWait(self.name, self.vehicle, self.color, self.experience,
|
||||
self.badge, self.patch,
|
||||
spec->steamMyToken, sizeof(spec->steamMyToken),
|
||||
spec->steamMap, sizeof(spec->steamMap)) != 0)
|
||||
{
|
||||
|
||||
@@ -129,7 +129,7 @@ static CSteamID currentLobby;
|
||||
|
||||
static void
|
||||
PublishSelf(const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience)
|
||||
const char *experience, const char *badge, const char *patch)
|
||||
{
|
||||
//
|
||||
// Identity is implicit (the member's SteamID); the roster TOKENS are
|
||||
@@ -140,6 +140,10 @@ static void
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "vh", vehicle);
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "cl", color);
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "xp", experience);
|
||||
// #38: badge + patch complete the paint identity (the egg's fourth and
|
||||
// fifth pilot fields) -- without them every joiner rendered VGL/Red.
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "bd", badge);
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "pt", patch);
|
||||
// BUILD GATE, member half: publish our exact build so the HOST can
|
||||
// reject mismatches at GO time. This is what catches OLD exes -- they
|
||||
// predate the joiner-side lobby filter, but they can't fake a "bv" key
|
||||
@@ -178,6 +182,16 @@ static int
|
||||
if (xp != NULL)
|
||||
strncpy(out->experience, xp, sizeof(out->experience) - 1);
|
||||
}
|
||||
{
|
||||
// #38: badge + patch, same tolerance -- an older client that never
|
||||
// published them keeps the old VGL/Red fallback, nobody else does.
|
||||
const char *bd = matchmaking->GetLobbyMemberData(lobby, member, "bd");
|
||||
if (bd != NULL)
|
||||
strncpy(out->badge, bd, sizeof(out->badge) - 1);
|
||||
const char *pt = matchmaking->GetLobbyMemberData(lobby, member, "pt");
|
||||
if (pt != NULL)
|
||||
strncpy(out->patch, pt, sizeof(out->patch) - 1);
|
||||
}
|
||||
out->isSelf = (member == SteamUser()->GetSteamID());
|
||||
if (out->name[0] == 0)
|
||||
{
|
||||
@@ -193,8 +207,15 @@ static int
|
||||
|
||||
static int roomIsHost = 0;
|
||||
static int roomResult = 0; // 0 pending, 1 go, -1 cancel
|
||||
static RECT roomLaunchRect = { 140, 250, 420, 296 };
|
||||
static RECT roomLeaveRect = { 140, 306, 300, 336 };
|
||||
// #169: the buttons sit BELOW the full 8-slot roster (rows y=60..268), and the
|
||||
// window is sized by CLIENT rect (AdjustWindowRect at creation) -- the old
|
||||
// 560x360 was the OUTER size, so the caption bar ate the bottom ~47px and the
|
||||
// LEAVE button (bottom=336) drew half-clipped, while rosters past 6 members
|
||||
// plowed into the fixed LAUNCH frame (Lynx's 8-player clutter shot).
|
||||
static RECT roomLaunchRect = { 140, 280, 420, 326 };
|
||||
static RECT roomLeaveRect = { 140, 336, 300, 366 };
|
||||
static const int kRoomClientW = 560;
|
||||
static const int kRoomClientH = 382; // LEAVE bottom 366 + 16 margin
|
||||
|
||||
static void
|
||||
PaintRoom(HWND window)
|
||||
@@ -246,7 +267,9 @@ static void
|
||||
member.vehicle[0] ? member.vehicle : "mech",
|
||||
member.color[0] ? member.color : "-",
|
||||
member.isSelf ? L" (you)" : L"",
|
||||
build_ok ? L"" : L" [WRONG BUILD -- WILL NOT LAUNCH]");
|
||||
// #169: keep the tag short -- the long "-- WILL NOT LAUNCH"
|
||||
// tail clipped at the 536px row edge on long name+vehicle rows.
|
||||
build_ok ? L"" : L" [WRONG BUILD]");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -348,10 +371,12 @@ static int
|
||||
window_class.lpszClassName = L"BTLobbyRoomWnd";
|
||||
RegisterClassW(&window_class);
|
||||
|
||||
RECT outer = { 0, 0, kRoomClientW, kRoomClientH }; // #169: size by CLIENT rect
|
||||
AdjustWindowRect(&outer, WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU, FALSE);
|
||||
HWND window = CreateWindowW(
|
||||
L"BTLobbyRoomWnd", L"BattleTech - Steam Lobby",
|
||||
WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU,
|
||||
160, 160, 560, 360,
|
||||
160, 160, outer.right - outer.left, outer.bottom - outer.top,
|
||||
NULL, NULL, GetModuleHandleW(NULL), NULL);
|
||||
if (window == NULL)
|
||||
{
|
||||
@@ -379,7 +404,7 @@ static int
|
||||
int
|
||||
BTLobby_HostAndRoom(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience,
|
||||
const char *experience, const char *badge, const char *patch,
|
||||
BTLobbyRoster *roster_out,
|
||||
char *my_token_out, int my_token_capacity,
|
||||
char *steam_map_out, int steam_map_capacity)
|
||||
@@ -409,7 +434,7 @@ int
|
||||
// one stale-zip player desyncs one connection's stream). Same build or
|
||||
// no entry.
|
||||
SteamMatchmaking()->SetLobbyData(currentLobby, "btl4ver", BT_VERSION_STRING);
|
||||
PublishSelf(pilot_name, vehicle, color, experience);
|
||||
PublishSelf(pilot_name, vehicle, color, experience, badge, patch);
|
||||
LobbyLog("host: lobby up (%llu)", (unsigned long long)created.m_ulSteamIDLobby);
|
||||
|
||||
if (RunRoom(1) != 0)
|
||||
@@ -496,7 +521,7 @@ int
|
||||
int
|
||||
BTLobby_JoinAndWait(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience,
|
||||
const char *experience, const char *badge, const char *patch,
|
||||
char *my_token_out, int my_token_capacity,
|
||||
char *steam_map_out, int steam_map_capacity)
|
||||
{
|
||||
@@ -586,7 +611,7 @@ int
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
PublishSelf(pilot_name, vehicle, color, experience);
|
||||
PublishSelf(pilot_name, vehicle, color, experience, badge, patch);
|
||||
LobbyLog("join: in lobby, waiting for GO");
|
||||
|
||||
int result = RunRoom(0);
|
||||
|
||||
@@ -34,6 +34,9 @@ struct BTLobbyMember
|
||||
// without it on the wire, the host's
|
||||
// egg authored every JOINER as the
|
||||
// WriteEgg "veteran" fallback
|
||||
char badge[16]; // #38: same class as experience --
|
||||
char patch[16]; // absent on the wire, every joiner
|
||||
// spawned VGL-badged with a Red patch
|
||||
int isSelf;
|
||||
unsigned long long steamID;
|
||||
};
|
||||
@@ -51,7 +54,7 @@ struct BTLobbyRoster
|
||||
int
|
||||
BTLobby_HostAndRoom(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience,
|
||||
const char *experience, const char *badge, const char *patch,
|
||||
BTLobbyRoster *roster_out,
|
||||
char *my_token_out, int my_token_capacity,
|
||||
char *steam_map_out, int steam_map_capacity);
|
||||
@@ -63,6 +66,6 @@ int
|
||||
int
|
||||
BTLobby_JoinAndWait(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience,
|
||||
const char *experience, const char *badge, const char *patch,
|
||||
char *my_token_out, int my_token_capacity,
|
||||
char *steam_map_out, int steam_map_capacity);
|
||||
|
||||
@@ -398,10 +398,29 @@ Missile flight-entity cluster (spawned by MissileLauncher::FireWeapon @4bcc60, s
|
||||
members (best-effort, past Entity base): modelUpdateContext@0x250(+bound update PTR_FUN_005129dc@0x254), world@0x300, currentSegment@0x304,
|
||||
segmentIndex@0x308(desc+0x84), sourceEntity@0x30C(desc+0x88), sourceWeapon@0x310(desc+0x8C), launchPosition@0x314(desc+0x90),
|
||||
launchVelocity@0x320(desc+0x9C), aimDirection@0x32C(desc+0xA8), modelIndex@0x338(desc+0xB4), damageRecord@0x33C(desc+0xB8).
|
||||
Performance variants PTR_LAB_005129e8(authoritative)/005129f4(ghost); base MoveAndCollide body past window — live integrator is Missile override @4bef78.
|
||||
Performance variants (CORRECTED 2026-08-11, was swapped): ctor @4be1bc picks by instance —
|
||||
(flags&0xC)==ReplicantInstance(4, ENTITY.h:318 [T0]) → PTR_LAB_005129e8 → FUN_004221c0 = the REPLICANT
|
||||
dead-reckoning smoother (blends toward predicted state, no physics/damage); else (master/local) →
|
||||
PTR_LAB_005129f4 → FUN_004bddec = the LIVE plain-Projectile integrator (was "past window" in the old
|
||||
export; present in the 2026-08-06 rebuild). Same split in the Missile ctor: 512da0→4221c0 (replicant),
|
||||
512dac→4bef78 (master integrator).
|
||||
Plain-Projectile flight/expiry @4bddec [T1]: integrate → world/entity collide FUN_0042291c (shooter
|
||||
excluded via @4be058) → hit: detonate+damage FUN_004be078; no-hit: dead-reckon predictor
|
||||
FUN_00422060 via +0x254 refreshes predicted pos +0x260, divergence² > _DAT_004be050=0.1 sets
|
||||
+0x18|=1 (replication-dirty, NOT kill); tail: age since spawn(+0x19c) > _DAT_004be054=5.0s →
|
||||
FUN_0042061c kill (remove + flags|=0x2000|2). NO WeaponRange / distance cap on flight — a shell
|
||||
flies exactly 5.0s (#168: the authored range=750 is fire-control data only; FireWeapon @4bcc60
|
||||
reads no effectiveRange +0x328 — the trigger has no range gate).
|
||||
└─ Missile vtable 00512f2c ctor @4bf5b4 dtor @4bf890 alloc @4bf8bc (size 0x368) [missile.cpp]
|
||||
MoveAndCollide @4bef78 (age/guide/integrate/collide; collision FUN_0042291c, detonate FUN_004be078
|
||||
streamed ClassID 0x5C), WriteUpdateRecord @4bef4c (slot7, tag 0x78).
|
||||
Expiry @4bef78 tail [T1]: age since spawn(+0x19c) > burnTime(+0x340, model rec +0x44) +
|
||||
_DAT_004bf5ac=10.0s coast margin, OR pos.y(+0x104) < _DAT_004bf5b0=-1.0 kill-plane → FUN_0042061c
|
||||
kill. Also: no replication update for _DAT_004bf594=2.0s → +0x18|=1 (dirty keepalive);
|
||||
dead-reckon divergence² > _DAT_004bf5a8=0.1 → +0x18|=1. Seeker target DROPPED when the victim's
|
||||
movementMode ∈ {2,9} = destroyed (FUN_0049fb54). PROXIMITY FUSE: seeker rangeToTarget(+0x10C) <
|
||||
_DAT_004bf5a4=4.0 → treat targetEntity as the collision victim (detonate) even without a geometry
|
||||
hit. LRM "range" is therefore physics (speed × lifetime), not a gate.
|
||||
Hosts 2 subsystems: this[0x49]=2 @0x124, roster @0x128 → {Seeker, MissileThruster}.
|
||||
enum {SeekerSubsystem=0, MissileThrusterSubsystem=1}. members (best-effort):
|
||||
lifetime@0x340 ageFraction@0x344 thrustScale@0x348 targetConn@0x34C targetOffset@0x350
|
||||
|
||||
@@ -170,7 +170,13 @@ static const int kBTEngBankTop[12] = { 0xF,0xF,0xF,0xF, 0x27,0x27,0x27,0x27, 0
|
||||
// no 0x29) -- overlapping but distinct lamps. Slot 6 (0x29) is also
|
||||
// DAT_0051d070[0], the first per-placement lamp: the two tables abut.
|
||||
static const int kBTCondenserLamp[7] = { 0x7, 0x2F,0x2E,0x2D,0x2B,0x2A, 0x29 };
|
||||
static const int kBTPlacementLamp[5] = { 0x29, 0x1A,0x1B,0x1C,0x1D }; // [auxScreenPlacement]
|
||||
// #135 CORRECTED: these are the GENERATOR leak lamps -- the binary's table at
|
||||
// 0x51d070 (@004cc27c), indexed by generatorNumber 1-BASED (A=1..D=4; slot 0
|
||||
// overlaps the condenser table's last entry and is unused; slot 5 is already
|
||||
// string bytes in the image, hence the bounds guard). The old name/comment
|
||||
// ("auxScreenPlacement") described a mis-reconstruction that indexed this
|
||||
// table with PoweredSubsystem aux fields generators never fill.
|
||||
static const int kBTGeneratorLamp[5] = { 0x29, 0x1A,0x1B,0x1C,0x1D };
|
||||
|
||||
//
|
||||
// @004cc148 -- the fixed (<0x80) map: coolingLoop1..6 -> the heat-display bank.
|
||||
@@ -335,10 +341,15 @@ void
|
||||
{
|
||||
if (the_condition == 2) // CoolantLeaking
|
||||
{
|
||||
int screen = 0, placement = -1;
|
||||
BTGetSubsystemAuxScreen(the_subsystem, &screen, &placement, 0);
|
||||
if (placement >= 0 && placement < 5)
|
||||
lamp_id = kBTPlacementLamp[placement];
|
||||
// #135 FIX: the binary's generator special @004cc27c indexes the
|
||||
// 0x51d070 table by generatorNumber (sub+0x1E0, A=1..D=4) -- NOT by
|
||||
// any aux-screen placement (generators never fill those fields; the
|
||||
// old probe left placement=-1 and a leaking generator annunciated
|
||||
// NOTHING, the field-logged 'NO LAMP RESOLVED (generator=1)').
|
||||
extern int BTGeneratorNumber(::Subsystem *sub); // powersub.cpp
|
||||
int n = BTGeneratorNumber(the_subsystem);
|
||||
if (n >= 1 && n <= 4)
|
||||
lamp_id = kBTGeneratorLamp[n];
|
||||
}
|
||||
}
|
||||
else if (lamp_code < 0x80)
|
||||
|
||||
@@ -1744,10 +1744,27 @@ Logical
|
||||
L4Warehouse *warehouse = (L4Warehouse *)gauge_renderer->warehousePointer;
|
||||
if (warehouse->pixelMap8Bin.Get(p[2].data.string) == NULL) // FUN_00442d2b
|
||||
{
|
||||
DebugStream << "OneOfSeveralPixInt: Missing image '" << p[2].data.string << "'\n";
|
||||
// WAS DebugStream -- the no-op ReconStream (gotcha: use DEBUG_STREAM).
|
||||
// A missing strip therefore failed COMPLETELY SILENTLY, which is
|
||||
// exactly the state #142 was stuck in: the crouch symbol never drew and
|
||||
// nothing anywhere said why.
|
||||
DEBUG_STREAM << "[gauge] oneOfSeveralPixInt: MISSING IMAGE '"
|
||||
<< p[2].data.string << "' -- element not created\n" << std::flush;
|
||||
return False;
|
||||
}
|
||||
warehouse->pixelMap8Bin.Release(p[2].data.string); // FUN_00442e51
|
||||
|
||||
// #142 receipt (ungated, one line per element): does this strip exist, and
|
||||
// did its integer attribute actually RESOLVE? A NULL attributePointer
|
||||
// leaves the connection reading nothing, so the strip pins to frame 0 and
|
||||
// looks like "no animation at all" -- indistinguishable, from outside, from
|
||||
// a missing image or an unbuilt page.
|
||||
DEBUG_STREAM << "[gauge] oneOfSeveralPixInt '" << p[2].data.string
|
||||
<< "' frames=" << p[3].data.integer << "x" << p[4].data.integer
|
||||
<< " port=" << display_port_index
|
||||
<< " at(" << position.x << "," << position.y << ")"
|
||||
<< " attr=" << (p[5].data.attributePointer != 0 ? "BOUND" : "NULL !!")
|
||||
<< "\n" << std::flush;
|
||||
return True;
|
||||
}
|
||||
|
||||
|
||||
@@ -2622,6 +2622,14 @@ void
|
||||
{
|
||||
// the range caret translate, from the live target range
|
||||
Scalar range = (rangeAttr2 != 0) ? *rangeAttr2 : 0.0f;
|
||||
// #147: NaN-SAFE clamp. `range < minRange` and `range > maxRange` are BOTH
|
||||
// false for NaN, so the old pair let a poisoned value straight through into
|
||||
// AddPoint/ConcatMatrix below -- degenerate geometry, and the caret + its
|
||||
// bar silently STOP DRAWING while the static ticks remain. Test for NaN
|
||||
// first (x == x is false only for NaN) and fall back to the binary's
|
||||
// no-target default rather than rendering nothing.
|
||||
if (!(range == range))
|
||||
range = maxRange; // 1200: the authentic no-target peg
|
||||
if (range < minRange) range = minRange;
|
||||
if (range > maxRange) range = maxRange;
|
||||
Scalar frac = (range - minRange) / (maxRange - minRange);
|
||||
@@ -3679,6 +3687,35 @@ namespace {
|
||||
|
||||
extern void BTSetWorldDead(int dead); // L4VIDRND.cpp bridge -> l4_application->SetIsDead
|
||||
|
||||
//===========================================================================//
|
||||
// #156 -- the mission start/end fade arming seam. The phase machine itself
|
||||
// runs at the renderer frame head (L4VIDEO.cpp, beside the FOGCOLOR clear);
|
||||
// these globals are what MakeEntityRenderables' BTPlayer master case arms
|
||||
// through the now-real BTPOVStartEndRenderable ctor below.
|
||||
//===========================================================================//
|
||||
void *gBTFadeIndicator = 0;
|
||||
int gBTFadeStart = 3, gBTFadeEnd = 4;
|
||||
|
||||
// The real ctor (was a btstubs.cpp inert stub -- the #156 root cause: rounds
|
||||
// hard-cut instead of fading). Binary ctor @00454394 stores the indicator +
|
||||
// the authored fog params and registers on the view's watcher list; the port
|
||||
// keeps the object inert (house pattern, like the translocation renderable)
|
||||
// and arms the frame-head machine instead. The fog params ride the renderer
|
||||
// members live, exactly as the binary re-reads them per fade frame.
|
||||
BTPOVStartEndRenderable::BTPOVStartEndRenderable(
|
||||
Entity *entity, int, dpl_VIEW *, dpl_ZONE *, dpl_ZONE *,
|
||||
StateIndicator *sim_state,
|
||||
float, float, float, float, float, int start_state, int end_state)
|
||||
: BTRenderableBase(entity)
|
||||
{
|
||||
gBTFadeIndicator = (void *)sim_state;
|
||||
gBTFadeStart = start_state;
|
||||
gBTFadeEnd = end_state;
|
||||
if (getenv("BT_FADE_LOG") || getenv("BT_FOG_LOG"))
|
||||
DEBUG_STREAM << "[fade] armed (states " << start_state << "/"
|
||||
<< end_state << ")\n" << std::flush;
|
||||
}
|
||||
|
||||
// The renderable objects the entity tree builds for the translocation wiring are
|
||||
// inert now (the warp is the self-contained one-shot below); the ctor/dtor just
|
||||
// satisfy MakeEntityRenderables.
|
||||
|
||||
+353
-59
@@ -305,13 +305,38 @@ static const Scalar TicksPerSecond = 1.0f; // (see note in PlayerSimulation)
|
||||
// tonnage ratio stubbed 1.0 + damage bias stubbed 0.0 for bring-up (SCORE == raw
|
||||
// damage, un-tonnage-scaled; the real per-mech tonnage/bias accessors are a follow-up).
|
||||
#define MECH_TONNAGE(m) (1.0f) // bring-up: ratio == 1
|
||||
#define MECH_DAMAGE_BIAS(m) (0.0f) // bring-up: factor = 0*bias+1 = 1
|
||||
// mech+0x354 -- the score formula's per-target damage-bias factor.
|
||||
//
|
||||
// ⚠ DO NOT "FINISH" THIS. 0.0f is not a stand-in; it is the value the shipped
|
||||
// binary always has, established by audit 2026-08-08:
|
||||
//
|
||||
// * mech+0x354 has exactly ONE writer in the whole image -- Mech::Reset
|
||||
// (@0049fb74, part_012.c:14340). Nothing touches it during play.
|
||||
// * Reset computes it as mean(zone + 0x158) across every damage zone, and it
|
||||
// does that AFTER the zone heal has already zeroed those cells. So it is
|
||||
// ~0 the instant it is written, stays ~0 for the mech's entire life, and is
|
||||
// recomputed as ~0 on the next respawn.
|
||||
// * It has exactly ONE reader -- CalcInflictedScore (@004c052c,
|
||||
// part_013.c:19055) -- where it appears as `avg * role.damageBias + 1.0`.
|
||||
//
|
||||
// So the factor is 1.0 for the whole game, and returning 0.0f here reproduces
|
||||
// the binary EXACTLY. It is a vestigial aggregate (0x358 and 0x35c are the
|
||||
// same computation and have NO reader at all).
|
||||
//
|
||||
// Wiring this to live accumulated damage would look like completing an unfinished
|
||||
// port and would silently inflate every inflicted and kill award -- the numbers
|
||||
// verified against the original manual's scoring chart (+1 a damage point, +500
|
||||
// a kill) all assume this term is 1.0.
|
||||
#define MECH_DAMAGE_BIAS(m) (0.0f) // = the binary's value; see above
|
||||
#define MECH_OWNING_PLAYER(m) ((BTPlayer *)((Mech *)(m))->GetPlayerLink()) // ENTITY.h:430 (NULL for the dummy)
|
||||
|
||||
//#############################################################################
|
||||
//############################### BTPlayer ##############################
|
||||
//#############################################################################
|
||||
|
||||
Scalar BTScoreWatermarkOf(int owner);
|
||||
void BTScoreWatermarkSet(int owner, Scalar sent);
|
||||
|
||||
//#############################################################################
|
||||
// Message Support
|
||||
//
|
||||
@@ -684,6 +709,20 @@ void
|
||||
// scoreboard (they read +0x278), so the pod's penalty may never have been
|
||||
// visible; our port has ONE currentScore, so it shows.
|
||||
//
|
||||
// #45 receipt (ungated): this cost is dispatched by a DIRECT base-handler
|
||||
// call, so it never reaches the BT matchlog and only the running total
|
||||
// exposes it -- which is how a combat death costing -500 while a SELF-KILL
|
||||
// cost nothing went unnoticed. Say out loud whether it fires and why not.
|
||||
DEBUG_STREAM << "[deathcost] player " << BTMatchHostOf(GetEntityID())
|
||||
<< ":" << (int)GetEntityID()
|
||||
<< " advDmg=" << (int)advancedDamageOn
|
||||
<< " role=" << (scenarioRole != 0 ? "bound" : "NULL")
|
||||
<< " penalty=" << (scenarioRole != 0
|
||||
? (float)scenarioRole->GetSpecialCaseDeathPenalty() : 0.0f)
|
||||
<< " scoreBefore=" << (float)currentScore
|
||||
<< ((advancedDamageOn && scenarioRole != 0) ? " -> APPLYING" : " -> SKIPPED")
|
||||
<< "\n" << std::flush;
|
||||
|
||||
if (advancedDamageOn && scenarioRole != 0) // this+0x264 (binary derefs role unguarded)
|
||||
{
|
||||
BTPlayer::ScoreMessage death_cost(
|
||||
@@ -697,40 +736,208 @@ void
|
||||
}
|
||||
|
||||
//
|
||||
// RELEASE THE DEATH LATCH (#81, the GHOST MECH fix -- 2026-07-30).
|
||||
// #162 CORRECTION (2026-08-11): the #81 clear that used to sit HERE was a
|
||||
// MISATTRIBUTION. FUN_004c012c -- whose tail is `Post(...); *(+0x290)=0;
|
||||
// *(+0x258)=0` (part_013.c:10519-10523) -- is the DROP-ZONE REPLY handler,
|
||||
// not this arm. Clearing the latch at the end of THIS arm disarmed the
|
||||
// port's own dedup within the same invocation, so the duplicate
|
||||
// VehicleDeads from the replicant-side death tails (the #162 multi-count:
|
||||
// deaths=1,2 one ms apart) sailed straight through the :546 gate. The
|
||||
// latch now clears where the binary clears it: the DropZoneReply paths
|
||||
// (fresh spawn :1799, abandoned cycle :1810, already-alive probe :1829,
|
||||
// respawn complete :1900) -- which also keep the #81 ghost fix intact:
|
||||
// every terminal outcome of a cycle releases it. suppressConsole rides
|
||||
// the same binary tail, so its clear moves to DropZoneReply with it
|
||||
// (post-eject corpse hits keep their console posts suppressed until the
|
||||
// respawn -- authentic). The binary itself never SETS +0x290 [T1 image
|
||||
// byte-scan: three writes, all zero-stores] -- its guarantee is the
|
||||
// sender's one-shot edge, restored in mech.cpp's death tail the same day;
|
||||
// our set at :559 stays as a defensive tripwire (a duplicate that still
|
||||
// arrives is SWALLOWED and logged loudly instead of counted).
|
||||
//
|
||||
// The binary clears it right here, between the re-post and suppressConsole:
|
||||
// FUN_004c012c's tail is Post(...) ; *(this+0x290) = 0 ; *(this+0x258) = 0
|
||||
// (part_013.c:10519-10523). We had the Post and the suppressConsole and
|
||||
// were missing the middle instruction, so `deathPending` -- which we DO set
|
||||
// (:505, and the dedup gate that reads it IS authentic; the binary's own
|
||||
// @004c05c4 does `mov edx,[ebx+0x290]; test edx,edx; jne ret`) -- was only
|
||||
// ever cleared on the SUCCESS paths. One failed respawn therefore latched
|
||||
// the pilot dead for the rest of the mission: every later death hit the
|
||||
// dedup and was SWALLOWED, so the cycle could never restart. That is what
|
||||
// turned a transient respawn hiccup into a PERMANENT ghost (dead,
|
||||
// un-Reset, still driveable, a burning wreck on every peer that sinks out
|
||||
// of the world after ~18 s and can never be drawn again).
|
||||
//
|
||||
// Field signature it explains exactly: 2026-07-29, 8 death cycles, 6
|
||||
// stranded, NONE of them ever recovering.
|
||||
//
|
||||
// Binary evidence that the latch must not persist: `+0x290` is written in
|
||||
// exactly THREE places in the whole of BTL4OPT.EXE (file offsets 0x0b75fb,
|
||||
// 0x0bffe3, 0x0c0a05) and ALL THREE store a zeroed register (`xor` on the
|
||||
// preceding instruction); there is no write of 1 -- or of any non-zero
|
||||
// value, in any instruction form -- anywhere in the executable. So in 1995
|
||||
// the gate exists but can never block. [T1]
|
||||
//
|
||||
// Ordering matters and is preserved: the re-entrant death that arrives
|
||||
// while the death EFFECTS are being dispatched still lands while the latch
|
||||
// is up, so the "one death, one cycle" dedup is untouched.
|
||||
//
|
||||
deathPending = 0; // this+0x290 (binary: FUN_004c012c tail)
|
||||
|
||||
suppressConsole = 0; // this+0x258
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// MissionStartingMessageHandler (@004bfbe8)
|
||||
//
|
||||
// THE "+1000 STARTING THE GAME" ROW of the original manual's scoring chart,
|
||||
// decoded 2026-08-07. BT overrides the engine's MissionStarting purely to
|
||||
// seed the score, and the override was never reconstructed -- the
|
||||
// MESSAGE_ENTRY resolved to the inherited Player:: handler, so the grant
|
||||
// simply never happened. The binary:
|
||||
//
|
||||
// FUN_004bfbe8(player):
|
||||
// base_MissionStarting(player);
|
||||
// if (app->state == 4 && (player[0x29] & 0x40) == 0)
|
||||
// player[0x1c8] = 0x447a0000; // = 1000.0f
|
||||
//
|
||||
// Both operands decode exactly: application state 4 is LaunchingMission
|
||||
// (APP.h -- the same enum whose 6 is EndingMission, already used by the
|
||||
// console flush), and simulationFlags bit 14 is NonScoringPlayerBit
|
||||
// (PLAYER.h: `NonScoringPlayerBit = Entity::NextBit`), so the byte test
|
||||
// `(+0x29 & 0x40) == 0` IS `IsScoringPlayer()`. Camera-ship and spectator
|
||||
// players are non-scoring and correctly get nothing.
|
||||
//
|
||||
// CELL NOTE: the binary seeds the ENGINE score cell (+0x1c8), not BT's own
|
||||
// (+0x278) -- the 1995 build carried two accumulators, which is why the KB
|
||||
// suspected the pod's death cost "may never have displayed". Our port has a
|
||||
// single currentScore, so the grant, the awards and the death cost all land
|
||||
// together, and the chart reads coherently for a player: start at 1000, +1 a
|
||||
// damage point, +500 a kill, -500 a special-case death.
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
void
|
||||
BTPlayer::MissionStartingMessageHandler(Entity::Message *message)
|
||||
{
|
||||
Player::MissionStartingMessageHandler(message); // FUN_0042d9c0
|
||||
|
||||
if (application->GetApplicationState() == Application::LaunchingMission // app+0x88 == 4
|
||||
&& IsScoringPlayer()) // !(simulationFlags & NonScoringPlayerFlag)
|
||||
{
|
||||
currentScore = 1000.0f; // this+0x1c8 = 0x447a0000
|
||||
// The console watermark is what we have already reported; a fresh
|
||||
// mission must report the grant, not the difference from the last
|
||||
// round's tally.
|
||||
BTScoreWatermarkSet(ownerID, 0.0f);
|
||||
DEBUG_STREAM << "[score] mission start: player "
|
||||
<< BTMatchHostOf(GetEntityID()) << ":" << (int)GetEntityID()
|
||||
<< " seeded to " << (float)currentScore
|
||||
<< " (chart: +1000 starting the game)\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// Console score watermark (port-side, 2026-08-07)
|
||||
//
|
||||
// How much of a player's running score has already been reported to the
|
||||
// operator console. Keyed by ownerID and kept OUTSIDE BTPlayer: sizeof
|
||||
// (BTPlayer) is static_assert-locked at 652 against the binary, so a new data
|
||||
// member is not available (the console timer above is a file static for the
|
||||
// same reason). A pod round is a handful of players; linear scan is free.
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
namespace {
|
||||
struct ScoreWatermark { int owner; Scalar sent; };
|
||||
ScoreWatermark gScoreWatermarks[16];
|
||||
int gScoreWatermarkCount = 0;
|
||||
}
|
||||
|
||||
Scalar
|
||||
BTScoreWatermarkOf(int owner)
|
||||
{
|
||||
for (int i = 0; i < gScoreWatermarkCount; ++i)
|
||||
if (gScoreWatermarks[i].owner == owner)
|
||||
return gScoreWatermarks[i].sent;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
void
|
||||
BTScoreWatermarkSet(int owner, Scalar sent)
|
||||
{
|
||||
for (int i = 0; i < gScoreWatermarkCount; ++i)
|
||||
if (gScoreWatermarks[i].owner == owner)
|
||||
{
|
||||
gScoreWatermarks[i].sent = sent;
|
||||
return;
|
||||
}
|
||||
if (gScoreWatermarkCount
|
||||
< (int)(sizeof(gScoreWatermarks) / sizeof(gScoreWatermarks[0])))
|
||||
{
|
||||
gScoreWatermarks[gScoreWatermarkCount].owner = owner;
|
||||
gScoreWatermarks[gScoreWatermarkCount].sent = sent;
|
||||
++gScoreWatermarkCount;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// Dispatch (@004bffa0, vtable @00513300 slot 3)
|
||||
//
|
||||
// THE TYPE-0 INTERCEPTOR. Restored 2026-08-07 -- it was missing, and its
|
||||
// absence silently deleted per-hit inflicted scoring:
|
||||
//
|
||||
// * the binary carries all three score reports under ONE id (0x16) and
|
||||
// splits type 0 off here, BEFORE base dispatch, straight into
|
||||
// ScoreInflictedMessageHandler (@004c0200 -- which names itself in its own
|
||||
// Verify string, "BTPlayer::ScoreInflictedMessageHandler");
|
||||
// * ScoreMessageHandler's type-0 arm Verify-rejects ON PURPOSE, because this
|
||||
// interceptor guarantees type 0 never gets that far;
|
||||
// * the port had the handler, faithfully reconstructed, and NO interceptor.
|
||||
// Block B sends its inflicted report under Player::ScoreMessageID, so every
|
||||
// one of them landed in the rejecting arm and banked 0.
|
||||
//
|
||||
// The KB previously recorded @004c0200 as "in NO table entry: dead code" and
|
||||
// concluded 1995 folded an uninitialised stack float into the shooter's score
|
||||
// on every non-lethal hit -- and the port's per-hit crediting was retired as an
|
||||
// "invention" on that basis (#45/#134, build 787, the build players report
|
||||
// scoring regressed in). That reading was wrong: the handler is live through
|
||||
// THIS vtable slot, and the original manual's SCORING CHART independently
|
||||
// corroborates what it computes -- "+1 each damage point scored on opponent's
|
||||
// armor" and "-1 each self-inflicted point of armor damage", which is exactly
|
||||
// this handler's negate-if-target-is-self arm. combat-damage.md is corrected.
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
void
|
||||
BTPlayer::Dispatch(Receiver::Message *what)
|
||||
{
|
||||
if (what != 0
|
||||
&& what->messageID == Player::ScoreMessageID
|
||||
&& ((BTPlayer::ScoreMessage *)what)->scoreType
|
||||
== BTPlayer::ScoreMessage::DamageInflictedScore
|
||||
//
|
||||
// MASTER ONLY. The binary intercepts unconditionally because its
|
||||
// +0x278 is a console DELTA -- every node's contribution is flushed
|
||||
// under the scoring player's ownerID and the CONSOLE totals it, so the
|
||||
// computing node is irrelevant. Our port has no console tally
|
||||
// (btconsole.py/btoperator.py handle no score at all) and reads +0x278
|
||||
// on the OWNING node for the SCORE gauge, CalcRanking and the replicated
|
||||
// Player__UpdateRecord. Banking on the victim's replicant copy
|
||||
// therefore loses the credit to the master's next update record.
|
||||
// Falling through lets Entity::Dispatch reroute to the owner, where the
|
||||
// wire delivery lands in ScoreMessageHandler's type-0 arm (which now
|
||||
// delegates back to the inflicted handler).
|
||||
//
|
||||
&& GetInstance() == Entity::MasterInstance)
|
||||
{
|
||||
ScoreInflictedMessageHandler((BTPlayer::ScoreMessage *)what);
|
||||
return;
|
||||
}
|
||||
Player::Dispatch(what);
|
||||
}
|
||||
|
||||
//
|
||||
// ⚠ OPEN, cross-node credit routing (2026-08-07). The interception above is
|
||||
// binary-faithful and restores per-hit inflicted credit -- benched, awards
|
||||
// track damage. What it does NOT yet solve is WHICH MACHINE banks it.
|
||||
//
|
||||
// Damage is applied on the VICTIM's node, so block B dispatches the inflicted
|
||||
// report to the SHOOTER's player object THERE, which on that node is a
|
||||
// REPLICANT. In 1995 that was fine: +0x278 is only ever a console DELTA, and
|
||||
// it is flushed to the operator console stamped with the shooter's ownerID --
|
||||
// the CONSOLE holds the authoritative total, so it does not matter which node
|
||||
// computed a delta. (That is almost certainly where the manual chart's "+1000
|
||||
// starting the game" was seeded, which is why no game-side code grants it.)
|
||||
//
|
||||
// Our port has no console as score authority: GetScore() (the SCORE gauge),
|
||||
// CalcRanking() and the replicated Player__UpdateRecord all read +0x278 on the
|
||||
// OWNING node. So a credit banked on the victim's replicant copy is
|
||||
// overwritten by the master's next update record -- benched as totals climbing
|
||||
// to ~35 and snapping back every few seconds.
|
||||
//
|
||||
// TRIED AND REJECTED: gating the interception to MasterInstance so a replicant
|
||||
// falls through and reroutes to the master. The message arrives, but the BT
|
||||
// extension fields (damageAmount@+0x24, senderMechID@+0x34) do NOT survive the
|
||||
// wire -- only the base Player::ScoreMessage `scoreAward` does -- so every
|
||||
// award computed to 0.00. That is also WHY the kill path (type 2) already
|
||||
// credits cross-node correctly: its value rides `scoreAward`.
|
||||
//
|
||||
// THE FIX SHAPE, therefore: compute the award on the victim's node (where the
|
||||
// damage data lives, exactly as now) and ship the RESULT to the owner in
|
||||
// `scoreAward`, the way the kill report already does -- rather than shipping
|
||||
// the basis and recomputing on a machine that cannot see it.
|
||||
//
|
||||
|
||||
|
||||
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// ScoreInflictedMessageHandler
|
||||
//
|
||||
@@ -865,16 +1072,34 @@ void
|
||||
{
|
||||
case BTPlayer::ScoreMessage::DamageInflictedScore: // 0
|
||||
//
|
||||
// Inflicted-damage messages belong to ScoreInflictedMessageHandler.
|
||||
// Inflicted-damage messages belong to ScoreInflictedMessageHandler, and
|
||||
// the binary's Dispatch override (@004bffa0) guarantees they never reach
|
||||
// here -- which is why the original arm is a bare Verify.
|
||||
//
|
||||
Verify(
|
||||
False,
|
||||
"BTPlayer::ScoreMessageHandler should not be "
|
||||
"given DamageInflictedScoreMessages!", // @0051324a
|
||||
"d:\\tesla_bt\\bt\\btplayer.cpp", // @0051329a
|
||||
0x296
|
||||
);
|
||||
break;
|
||||
// PORT DIVERGENCE (2026-08-07), and it is a DELIVERY-PATH difference,
|
||||
// not a scoring one. Damage is applied on the VICTIM's node, so block B
|
||||
// dispatches the inflicted report to the SHOOTER's player object there --
|
||||
// a REPLICANT -- and Entity::Dispatch reroutes it to the owning host so
|
||||
// the credit lands on the shooter's OWN machine (the same reroute that
|
||||
// carries kill credit, ENTITY.cpp:244-251). But a message arriving over
|
||||
// the WIRE is delivered through Receive(), straight to this handler
|
||||
// table: the virtual Dispatch override is never called on the receiving
|
||||
// side. So the rerouted report lands HERE, and Verify-rejecting it
|
||||
// threw away every cross-node inflicted credit (benched: award=0.00).
|
||||
//
|
||||
// Delegate instead. Local deliveries are still intercepted by Dispatch
|
||||
// exactly as the binary does; wire deliveries land here and get the same
|
||||
// handler. One accumulator, on the machine that owns the score.
|
||||
//
|
||||
// RETURN, not break. The post-switch tail folds the local `award` into
|
||||
// message->scoreAward and hands it to the base handler -- and for this
|
||||
// arm `award` is still 0, so falling through clobbered scoreAward to
|
||||
// zero, added nothing, and emitted a second SCORE row reading
|
||||
// "type=0 award=0.00" (the 80 real + 80 zero rows in the bench).
|
||||
// ScoreInflictedMessageHandler is self-contained: it accumulates,
|
||||
// ForceUpdate()s and logs its own receipt.
|
||||
ScoreInflictedMessageHandler(message);
|
||||
return;
|
||||
|
||||
case BTPlayer::ScoreMessage::DamageReceivedScore: // 1
|
||||
{
|
||||
@@ -1143,20 +1368,38 @@ void
|
||||
//
|
||||
// Only bother if our score actually changed since last time.
|
||||
//
|
||||
if ((Scalar)currentScore != 0.0f) // this[0x9e] != _DAT_004c0900 (0.0f)
|
||||
// DELTA vs RUNNING TOTAL (2026-08-07). The binary sends currentScore
|
||||
// and then ZEROES it, ungated (@FUN_..., `param_1[0x9e] = 0` right after
|
||||
// the send) -- so in 1995 +0x278 was a CONSOLE DELTA and the running
|
||||
// total lived on the operator console, which is also where the manual's
|
||||
// "+1000 starting the game" would have been seeded.
|
||||
//
|
||||
// Our port cannot copy that literally: THREE port-side consumers read
|
||||
// +0x278 as a running total -- GetScore() (the SCORE gauge),
|
||||
// Player::CalcRanking(), and Player__UpdateRecord (the only score field
|
||||
// we replicate to peers). Zeroing it made all three reset every
|
||||
// CONSOLE_UPDATE_INTERVAL, which is what players saw as "scoring went
|
||||
// screwy" (bench: totals climbed to ~35 and dropped back). It was
|
||||
// mostly invisible until the type-0 interceptor was restored, because
|
||||
// before that currentScore barely moved.
|
||||
//
|
||||
// So: keep the WIRE authentic (the console still receives a DELTA) and
|
||||
// keep the OBJECT sane (currentScore stays a true running total). The
|
||||
// last-sent watermark is a file static keyed by player -- a new data
|
||||
// member would change sizeof(BTPlayer) and break the offset locks (same
|
||||
// reason the console timer above is a static).
|
||||
//
|
||||
const Scalar sent_already = BTScoreWatermarkOf(ownerID);
|
||||
const Scalar delta = (Scalar)currentScore - sent_already;
|
||||
if (delta != 0.0f)
|
||||
{
|
||||
int score = (int)currentScore;
|
||||
int score = (int)delta;
|
||||
|
||||
ConsolePlayerVTVScoreUpdateMessage score_message(
|
||||
ownerID,
|
||||
score
|
||||
); // FUN_00420ea4(0x20, 0x1a, 1, ...)
|
||||
|
||||
// gauge scoring wave: the binary's currentScore is a console DELTA that is
|
||||
// flushed to the operator console then zeroed. Our SCORE/RANK gauges read
|
||||
// currentScore as the RUNNING total, so only flush+zero when a console host
|
||||
// is actually present (MP / pod); in solo there is no console -> keep the
|
||||
// running score so the SCORE gauge + CalcRanking don't reset every 10s.
|
||||
Host *console_host =
|
||||
application->GetHostManager()->GetConsoleHost(); // FUN_00429078
|
||||
if (console_host)
|
||||
@@ -1175,7 +1418,10 @@ void
|
||||
NetworkClient::ConsoleClientID, // 5
|
||||
&score_message
|
||||
);
|
||||
currentScore = 0; // this[0x9e] = 0
|
||||
// The binary does `currentScore = 0` here. We advance the
|
||||
// watermark by the amount actually SENT instead, so the console
|
||||
// sees the same deltas while the object keeps the total.
|
||||
BTScoreWatermarkSet(ownerID, sent_already + (Scalar)score);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1540,6 +1786,8 @@ void
|
||||
deathPending = 0; // #55 step 1: a latch carried into a
|
||||
// fresh spawn would permanently kill
|
||||
// every later respawn (the #57 class)
|
||||
suppressConsole = 0; // this+0x258 (binary: FUN_004c012c
|
||||
// tail pairs both clears -- #162)
|
||||
// (warp fired in the shared placement below -- initial drop-in + respawn)
|
||||
}
|
||||
else if (deathCount == message->deathCount) // param_2[0xe] == param_1[0x80]
|
||||
@@ -1639,6 +1887,11 @@ void
|
||||
// the same process. The cycle's completion is HERE, so clear it here.
|
||||
//
|
||||
deathPending = 0; // this+0x290
|
||||
suppressConsole = 0; // this+0x258 -- the binary pairs
|
||||
// both clears in FUN_004c012c's
|
||||
// tail (part_013.c:10519-10523);
|
||||
// moved here from the VehicleDead
|
||||
// arm with the #162 correction
|
||||
DEBUG_STREAM << "[respawn] player " << BTMatchHostOf(GetEntityID())
|
||||
<< ":" << (int)GetEntityID() << " RESET at drop zone (death #"
|
||||
<< deathCount << ") -- death cycle complete, latch cleared\n"
|
||||
@@ -1778,15 +2031,51 @@ BTPlayer::BTPlayer(
|
||||
}
|
||||
|
||||
//
|
||||
// Look the scoring role up in the role registry (keyed by the role name
|
||||
// in the creation message, +0x90) and stash it as our scenarioRole. The
|
||||
// BT role registry (BTMission::GetRoleRegistry()->Lookup) has no WinTesla
|
||||
// analog, so the scenarioRole set by the base Player ctor stands.
|
||||
// CROSS-FAMILY: needs BTMission role-registry access. BEST-EFFORT.
|
||||
// Look the scoring role up in the role registry (keyed by the role name in
|
||||
// the creation message, +0x90) and stash it as our scenarioRole.
|
||||
//
|
||||
// WIRED 2026-08-07. This was commented out with "the BT role registry
|
||||
// (BTMission::GetRoleRegistry()->Lookup) has no WinTesla analog, so the
|
||||
// scenarioRole set by the base Player ctor stands". The base ctor sets it
|
||||
// to NULL (PLAYER.cpp:680), so it stood NULL forever -- and EVERY scoring
|
||||
// value the game has lives on that pointer:
|
||||
//
|
||||
// killBonus -> the kill award basis (authored 500)
|
||||
// specialCaseDeathPenalty-> the death cost (authored 500)
|
||||
// damageInflictedModifier-> the per-hit multiplier (authored 1)
|
||||
// returnFromDeath -> the entry credit (authored 1000)
|
||||
//
|
||||
// With it NULL: kill_bonus reads 0 (a kill scored the damage tally alone --
|
||||
// benched 4.88 instead of ~500), the eject charge is 0 (field log:
|
||||
// "PUNCH-OUT: charge=0 (role killBonus)" = #134's missing penalty), and the
|
||||
// death cost block is skipped entirely. That is four rows of the original
|
||||
// manual's scoring chart, all from one commented-out line.
|
||||
//
|
||||
// The analog DOES exist: Mission::GetScenarioRole(name) (MISSION.h:162)
|
||||
// walks scenarioRoleChain -- the very dictionary BTL4Mission fills via
|
||||
// AddScenarioRole() when it parses the role pages (btl4mssn.cpp), whose own
|
||||
// comment already says the WinTesla base exposes it. Same lookup, same
|
||||
// key, no cross-family gap.
|
||||
//
|
||||
CString role_key(creation_message->roleName); // make+0x90
|
||||
(void)role_key;
|
||||
// scenarioRole = playerMission->GetRoleRegistry()->Lookup(&role_key); // this[0x7e]+0x50, this[0x82]
|
||||
if (playerMission != 0)
|
||||
{
|
||||
ScenarioRole *found = playerMission->GetScenarioRole(role_key); // this[0x7e]+0x50
|
||||
if (found == 0)
|
||||
{
|
||||
// The shipped content authors ONE role page, "Role::Default"
|
||||
// (model dfltrole). A creation message naming anything else -- or
|
||||
// naming nothing -- must still score, so fall back to it rather
|
||||
// than leave the pointer NULL and silently zero every award.
|
||||
found = playerMission->GetScenarioRole(CString("Role::Default"));
|
||||
}
|
||||
scenarioRole = found; // this[0x82]
|
||||
DEBUG_STREAM << "[role] player " << BTMatchHostOf(GetEntityID())
|
||||
<< ":" << (int)GetEntityID() << " key='" << (const char *)role_key
|
||||
<< "' -> " << (scenarioRole != 0 ? "BOUND" : "NULL (scores will be 0)")
|
||||
<< (scenarioRole != 0 ? "" : " !!")
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
|
||||
if ((simulationFlags & 0xc) == 4)
|
||||
{
|
||||
@@ -2351,9 +2640,14 @@ void BTMechPostCombatReports(
|
||||
else if (damage_tally != 0.0f && shooter_player != 0)
|
||||
{
|
||||
//
|
||||
// Block B: the plain inflicted report. Wire fidelity only -- the 0x16
|
||||
// handler Verify-rejects type 0 and banks award 0 (1995 banked an
|
||||
// uninitialized stack float; see the handler's type-0 arm note).
|
||||
// Block B: the inflicted report -- "+1 each damage point scored on
|
||||
// opponent's armor" (original manual scoring chart). Goes out under
|
||||
// Player::ScoreMessageID with type 0, exactly as the binary does; the
|
||||
// Dispatch override (@004bffa0) intercepts it into
|
||||
// ScoreInflictedMessageHandler. It is NOT wire-fidelity-only -- the
|
||||
// old note here claimed the handler banks 0 because @004c0200 was
|
||||
// "dead code", which was a misreading of the vtable; corrected
|
||||
// 2026-08-07 and the interceptor restored.
|
||||
//
|
||||
BTPlayer::ScoreMessage inflicted(
|
||||
Player::ScoreMessageID,
|
||||
|
||||
@@ -84,16 +84,24 @@ class DropZone__ReplyMessage;
|
||||
public:
|
||||
//
|
||||
// Kind of scoring event. Recovered from the branch selector at
|
||||
// @004c02e4 (this->scoreType, message+0x20). NOTE: type 0 has NO
|
||||
// scoring arm in the binary -- @004c02e4 Verify-rejects it (line 662)
|
||||
// and @004c0200, the only function that accepts it, appears in no
|
||||
// handler-table entry (byte-scan 2026-08-05: the BTPlayer table at
|
||||
// file 0x112dxx has exactly 6 entries, none binding it). 1995 pod
|
||||
// scoring = kills + received-damage penalties; per-hit inflicted
|
||||
// credit never existed.
|
||||
// @004c02e4 (this->scoreType, message+0x20).
|
||||
//
|
||||
// CORRECTED 2026-08-07. This note used to read "type 0 has NO scoring
|
||||
// arm in the binary ... @004c0200 appears in no handler-table entry ...
|
||||
// per-hit inflicted credit never existed", and build 787 deleted the
|
||||
// credit on that basis. The byte-scan was right that no TABLE entry
|
||||
// binds @004c0200 and wrong to conclude it is unreachable: BTPlayer
|
||||
// overrides Dispatch (vtable @00513300 slot 3 = FUN_004bffa0), which
|
||||
// splits type 0 off BEFORE base dispatch and calls it directly. That
|
||||
// is also WHY @004c02e4 Verify-rejects type 0 -- the interceptor
|
||||
// guarantees it never arrives there. @004c0200 names itself
|
||||
// "BTPlayer::ScoreInflictedMessageHandler" in its own Verify string,
|
||||
// and the original manual's scoring chart independently confirms what
|
||||
// it computes: "+1 each damage point scored on opponent's armor",
|
||||
// "-1 each self-inflicted point".
|
||||
//
|
||||
enum ScoreType {
|
||||
DamageInflictedScore = 0, // sent, but scores nothing (see above)
|
||||
DamageInflictedScore = 0, // per-hit inflicted credit (LIVE, see above)
|
||||
DamageReceivedScore = 1, // I took damage
|
||||
KillScore = 2 // I destroyed / was destroyed
|
||||
};
|
||||
@@ -288,6 +296,23 @@ class DropZone__ReplyMessage;
|
||||
private:
|
||||
static const HandlerEntry MessageHandlerEntries[];
|
||||
|
||||
public:
|
||||
//
|
||||
// @004bffa0 -- the DISPATCH OVERRIDE (vtable @00513300 slot 3). The
|
||||
// binary uses ONE message id (0x16) for all three score reports and
|
||||
// splits type 0 off HERE, before base dispatch:
|
||||
//
|
||||
// if (msg->id == 0x16 && msg->type == 0) ScoreInflicted(msg);
|
||||
// else base dispatch;
|
||||
//
|
||||
// which is why ScoreMessageHandler's own type-0 arm can Verify-reject:
|
||||
// the interceptor guarantees type 0 never reaches it. Without this
|
||||
// override every inflicted report lands in the rejecting arm and scores
|
||||
// nothing -- see the note in btplayer.cpp.
|
||||
//
|
||||
virtual void
|
||||
Dispatch(Receiver::Message *what); // @004bffa0
|
||||
|
||||
protected:
|
||||
static MessageHandlerSet& GetMessageHandlers();
|
||||
|
||||
@@ -302,6 +327,14 @@ class DropZone__ReplyMessage;
|
||||
void
|
||||
ScoreMessageHandler(ScoreMessage *message); // @004c02e4
|
||||
|
||||
//
|
||||
// @004bfbe8 -- BT's MissionStarting override. Seeds the starting score
|
||||
// ("+1000 Starting the game", original manual scoring chart). The base
|
||||
// Player handler does the fade-in; BT adds the grant.
|
||||
//
|
||||
void
|
||||
MissionStartingMessageHandler(Entity::Message *message); // @004bfbe8
|
||||
|
||||
//
|
||||
// @004bffd0 -- the spawn / respawn handshake. When the drop zone
|
||||
// replies with our spawn location we create (or reset) the player's
|
||||
|
||||
@@ -336,10 +336,9 @@ BTMarkerWatcherRenderable::BTMarkerWatcherRenderable(
|
||||
// BTTranslocationRenderable (the "blue warp" translocation sphere) is now a real
|
||||
// reconstruction in btl4vid.cpp (task #52) -- no longer a stub here.
|
||||
|
||||
BTPOVStartEndRenderable::BTPOVStartEndRenderable(
|
||||
Entity *entity, int, dpl_VIEW *, dpl_ZONE *, dpl_ZONE *, StateIndicator *,
|
||||
float, float, float, float, float, int, int)
|
||||
: BTRenderableBase(entity) {}
|
||||
// BTPOVStartEndRenderable (the mission start/end fade) is now a real
|
||||
// reconstruction: the arming ctor lives in btl4vid.cpp and the 5-phase fog
|
||||
// machine at the renderer frame head (L4VIDEO.cpp) -- #156.
|
||||
|
||||
BTTracerEffectRenderable::BTTracerEffectRenderable(
|
||||
Entity *entity, int, void *, int, dpl_ZONE *, dpl_DCS *, LinearMatrix *)
|
||||
|
||||
@@ -304,7 +304,14 @@ void
|
||||
// BRING-UP verify (rate-limited): prove the real fire path executes + feeds the heat
|
||||
// sim (heatPortion -> pendingHeat -> flows to the central sink -> mech temp climbs).
|
||||
static int s_fireLog = 0;
|
||||
if ((s_fireLog++ % 19) == 0) // 19 prime: rotates across the 5 emitters (20 aliased to one)
|
||||
// #165 bench: EVERY discharge, NAMED, under BT_DMG_LOG (the old unnamed
|
||||
// 1-in-19 sample could not answer "which weapon fired" -- the #110 nit);
|
||||
// the rate-limited unnamed line stays for ambient runs without the env.
|
||||
if (getenv("BT_DMG_LOG"))
|
||||
DEBUG_STREAM << "[emitter] FIRED '" << (GetName() ? GetName() : "?")
|
||||
<< "' damage=" << damagePortion << " heat=" << heatPortion
|
||||
<< "\n" << std::flush;
|
||||
else if ((s_fireLog++ % 19) == 0) // 19 prime: rotates across the 5 emitters (20 aliased to one)
|
||||
DEBUG_STREAM << "[emitter] FIRED #" << s_fireLog << " damage=" << damagePortion
|
||||
<< " heat=" << heatPortion << " pendingHeat=" << pendingHeat << "\n" << std::flush;
|
||||
|
||||
|
||||
+141
-8
@@ -947,6 +947,26 @@ void
|
||||
if (other != 0 && coolantAvailable != 0)
|
||||
{
|
||||
Scalar flow = ComputeHeatFlow(other, time_slice); // FUN_004ad9ec
|
||||
// #137 FLOW TRAP (BT_HEAT_LOG): the myomers gains ~2e9 of energy within
|
||||
// 30 frames of a respawn while every partner reads T=77 -- and the only
|
||||
// writers into its pendingHeat are its (dead-at-spawn) integrator and
|
||||
// THIS line. Any e6-scale single flow is the bug caught in the act;
|
||||
// print the complete operand set so the arithmetic can be re-run by
|
||||
// hand instead of guessed at.
|
||||
if (getenv("BT_HEAT_LOG") != 0 && (flow > 1.0e6f || flow < -1.0e6f))
|
||||
{
|
||||
DEBUG_STREAM << "[heatflow] " << (GetName() ? GetName() : "?")
|
||||
<< " -> " << (other->GetName() ? other->GetName() : "?")
|
||||
<< " flow=" << flow << " dt=" << time_slice
|
||||
<< " | this: T=" << currentTemperature << " E=" << heatEnergy
|
||||
<< " pend=" << pendingHeat << " m=" << thermalMass
|
||||
<< " mScale=" << massScale << " k=" << thermalConductance
|
||||
<< " lvl=" << coolantLevel << " cap=" << thermalCapacity
|
||||
<< " fScale=" << coolantFlowScale
|
||||
<< " | other: T=" << other->currentTemperature
|
||||
<< " E=" << other->heatEnergy << " pend=" << other->pendingHeat
|
||||
<< " m=" << other->thermalMass << "\n" << std::flush;
|
||||
}
|
||||
other->pendingHeat += flow;
|
||||
pendingHeat -= flow;
|
||||
BalanceCoolant(time_slice); // FUN_004ada94
|
||||
@@ -1419,15 +1439,14 @@ int BTCondenserNumber(::Subsystem *sub)
|
||||
|
||||
|
||||
//
|
||||
// #83 bridge: the collision-damage distributor (mech.cpp, FUN_0049ffcc) tests
|
||||
// roster members against this family's derivation chain (binary GUID 0x50e590).
|
||||
// Lives here because HeatSink is a complete type only in this TU.
|
||||
// The #83-era BTHeatSinkFamily bridge that lived here is GONE (#128,
|
||||
// 2026-08-11): it returned the whole HeatSink base family for the
|
||||
// collision distributor's 0x50e590 test, but 0x50e590 is the AGGREGATE
|
||||
// BANK's node (0xBBE "HeatSinkBank") -- the same task-#9 identity the
|
||||
// link-attach guard above proved live (heat.cpp:627). The distributor
|
||||
// now uses the string-name derivation test directly (mech.cpp), so no
|
||||
// complete-type bridge is needed.
|
||||
//
|
||||
Derivation *
|
||||
BTHeatSinkFamily()
|
||||
{
|
||||
return HeatSink::GetClassDerivations();
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// BTHeatSinkBankCoolantFraction -- complete-type bridge for the EJECT-
|
||||
@@ -1446,3 +1465,117 @@ int BTHeatSinkBankCoolantFraction(Subsystem *sub, Scalar *out)
|
||||
*out = bank->CoolantFractionOf();
|
||||
return 1;
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// BTReportHeatAtReset -- #137 forensic (ungated when BT_HEAT_LOG is set).
|
||||
//
|
||||
// The [heat-t] census runs on a 5-second per-instance timer, which is far too
|
||||
// coarse to answer the question #137 actually poses: "respawn came back with
|
||||
// MYOMERS heat MAXED". Is the temperature high BECAUSE the reset did not
|
||||
// clear it, or because it climbs again within the first second? Those need a
|
||||
// sample taken AT the reset, which is what this is. Called from Mech::Reset
|
||||
// immediately after the subsystem sweep, so every heat-bearing subsystem
|
||||
// reports the temperature the reset actually left it at.
|
||||
//===========================================================================//
|
||||
void BTReportHeatAtReset(void *mech_v)
|
||||
{
|
||||
if (mech_v == 0 || getenv("BT_HEAT_LOG") == 0)
|
||||
return;
|
||||
Entity *mech = (Entity *)mech_v;
|
||||
const int count = mech->GetSubsystemCount();
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
Subsystem *s = mech->GetSubsystem(i);
|
||||
if (s == 0)
|
||||
continue;
|
||||
// UNFILTERED first: the earlier pass filtered on IsDerivedFrom(HeatSink)
|
||||
// and reported no Condensers. That test rides a hand-built Derivation
|
||||
// chain, so a false negative there is indistinguishable from "not in the
|
||||
// roster" -- name every roster entry and say whether the test passed.
|
||||
if (!s->IsDerivedFrom(*HeatableSubsystem::GetClassDerivations()))
|
||||
{
|
||||
DEBUG_STREAM << "[heat-reset] roster[" << i << "] "
|
||||
<< (s->GetName() ? s->GetName() : "?")
|
||||
<< " (not HeatSink-derived)" << "\n" << std::flush;
|
||||
continue;
|
||||
}
|
||||
HeatableSubsystem *sink = (HeatableSubsystem *)s;
|
||||
DEBUG_STREAM << "[heat-reset] " << (s->GetName() ? s->GetName() : "?")
|
||||
<< " T=" << (float)sink->currentTemperature
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// BTReportMyomerFreeze -- #137 forensic (BT_HEAT_LOG), the POST-reset trace.
|
||||
//
|
||||
// BTReportHeatAtReset samples AT the reset and showed every subsystem at
|
||||
// T == startingTemperature, which is what made the reset path look innocent and
|
||||
// got #137 wrongly closed. The field then proved the freeze is real (5 of 61
|
||||
// respawns: throttle up, speedDemand pinned at 0) -- so the interesting window
|
||||
// is the frames immediately AFTER the reset, which nothing was sampling.
|
||||
//
|
||||
// Three explanations survive the decomp read and only data separates them:
|
||||
// (a) RESET DIDN'T TAKE -> temp is high right after the reset
|
||||
// (b) STALE CACHE -> temp is at start but speedEffect is still 0
|
||||
// (HeatSink::RTIS @004ad760 writes only bytes
|
||||
// 0x114/0x12C/0x130/0x134/0x138/0x158/0x15C --
|
||||
// it does NOT touch Myomers::speedEffect @0x31C,
|
||||
// and Myomers::RTIS @004b8aa4 only chains to the
|
||||
// PoweredSubsystem one, so the pre-death value
|
||||
// survives until the myomers next ticks)
|
||||
// (c) INSTANT RE-HEAT -> temp starts at start and climbs back at once
|
||||
//
|
||||
// Prints per myomers: temperature, its own speedEffect, and the mech-level MAX
|
||||
// the mover actually multiplies by. Freeze == that MAX at 0.
|
||||
//===========================================================================//
|
||||
void BTReportMyomerFreeze(void *mech_v, const char *when)
|
||||
{
|
||||
if (mech_v == 0 || getenv("BT_HEAT_LOG") == 0)
|
||||
return;
|
||||
Entity *mech = (Entity *)mech_v;
|
||||
extern Scalar BTMyomersSpeedEffectOf(void *subsystem);
|
||||
const int count = mech->GetSubsystemCount();
|
||||
Scalar best = -1.0f;
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
Subsystem *s = mech->GetSubsystem(i);
|
||||
if (s == 0)
|
||||
continue;
|
||||
// ROSTER-WIDE now (2026-08-09): the myomers-only version proved the
|
||||
// myomers is being COOKED FROM OUTSIDE -- +2.3e9 of energy arrives in
|
||||
// <=30 frames while its own integrator reads near-zero v (our Reset
|
||||
// zeroes localVelocity, matching the binary's +0x1c4 zero-fill, so
|
||||
// termKinetic/termAccel are dead at spawn). ConductHeat flow is
|
||||
// bounded by deltaT, so an e9 slug through it demands a NEIGHBOUR at
|
||||
// extreme temperature. Print EVERY heat-bearing subsystem's T plus
|
||||
// the two cells T is actually derived from (heatEnergy @0x158,
|
||||
// pendingHeat @0x1C8) to NAME that neighbour.
|
||||
if (!s->IsDerivedFrom(*HeatableSubsystem::GetClassDerivations()))
|
||||
continue;
|
||||
HeatableSubsystem *h = (HeatableSubsystem *)s;
|
||||
// Every Heatable-positive roster member in this game IS a HeatSink
|
||||
// (the Watcher branch -- Torso/HUD/Gyro -- rides HeatWatcher, which is
|
||||
// not HeatableSubsystem-derived), so the downcast for E/pend is safe.
|
||||
// Deliberately NOT IsDerivedFrom(HeatSink): that hand-built chain
|
||||
// returns false for Condenser (the night-13 trap).
|
||||
HeatSink *hs = (HeatSink *)s;
|
||||
Scalar f = BTMyomersSpeedEffectOf(s); // >= 0 only for a Myomers
|
||||
if (f > best) best = f;
|
||||
DEBUG_STREAM << "[myofreeze] " << when << " " << (s->GetName() ? s->GetName() : "?")
|
||||
<< " T=" << h->currentTemperature
|
||||
<< " E=" << hs->heatEnergy
|
||||
<< " pend=" << hs->pendingHeat
|
||||
<< " fail=" << h->failureTemperature;
|
||||
if (f >= 0.0f)
|
||||
{
|
||||
DEBUG_STREAM << " speedEffect=" << f
|
||||
<< ((f <= 1.0e-4f && h->currentTemperature < h->failureTemperature)
|
||||
? " <<<< STALE (cold but zero)" : "");
|
||||
}
|
||||
DEBUG_STREAM << "\n" << std::flush;
|
||||
}
|
||||
if (best >= 0.0f)
|
||||
DEBUG_STREAM << "[myofreeze] " << when << " CHAIN MAX=" << best
|
||||
<< (best <= 1.0e-4f ? " <<<< FROZEN" : "") << "\n" << std::flush;
|
||||
}
|
||||
|
||||
+57
-10
@@ -52,15 +52,41 @@ struct HUDLayoutCheck
|
||||
};
|
||||
|
||||
//
|
||||
// Tuning constants observed as read-only float globals adjacent to the
|
||||
// HudSimulation body (.rdata, recovered from section_dump.txt).
|
||||
// Tuning constants read as read-only float globals adjacent to the
|
||||
// HudSimulation body. The VALUES BELOW ARE THE BINARY'S, read straight off
|
||||
// the .rdata rows in reference/decomp/section_dump.txt [T1]:
|
||||
// 4b7ec0 8be55dc3 0000403f 0000803f 0000c842
|
||||
// 4b7ed0 00000000
|
||||
// -> ec4 = 0.75f ec8 = 1.0f ecc = 100.0f ed0 = 0.0f
|
||||
//
|
||||
// CORRECTED 2026-08-08. Every entry here used to be a 0.0f/500.0f STAND-IN
|
||||
// under a guessed name, and the names described the wrong mechanism: there is
|
||||
// no "heat threshold for HUD page visibility" at 0x4b7ec4 -- ec4/ec8 are the
|
||||
// fire-control LOCK damage limits, and "MaxTorsoSlew = 500.0f" at ed0 read the
|
||||
// range-slide Abs() idiom backwards (ed0 is the ZERO; 500.0 is an immediate).
|
||||
// The LIVE implementation of the lock rule and the range slide is mech4.cpp's
|
||||
// targeting step, which had both thresholds right all along. These stay so
|
||||
// the addresses resolve to the truth for the next reader.
|
||||
//
|
||||
static const Point3D HudZeroVector(0.0f, 0.0f, 0.0f); // DAT_004e0f74/78/7c
|
||||
static const Scalar SegmentTempLimit = 0.0f; // _DAT_004b7ec4 (heat threshold for HUD page visibility)
|
||||
static const Scalar TargetTempLimit = 0.0f; // _DAT_004b7ec8
|
||||
static const Scalar RangeBias = 0.0f; // _DAT_004b7ecc
|
||||
static const Scalar MaxTorsoSlew = 500.0f; // _DAT_004b7ed0
|
||||
static const Scalar FlickerFloor = 0.0f; // _DAT_004b7f90
|
||||
// LOCK gate (a): your OWN HUD's host zone must be BELOW 75% damage, else
|
||||
// fire-control lock is lost -- the `_DAT_004b7ec4 <= ownZone->damageLevel`
|
||||
// arm of HudSimulation. A shot-up cockpit drops to "target held, no lock".
|
||||
static const Scalar LockOwnZoneDamageLimit = 0.75f; // _DAT_004b7ec4
|
||||
// LOCK gate (b): the TARGETED zone must be below 1.0 damage (a whole-mech
|
||||
// target checks zone 0), so a dead zone cannot be re-locked.
|
||||
static const Scalar LockTargetZoneDamageLimit = 1.0f; // _DAT_004b7ec8
|
||||
// Subtracted from RangeToTarget (@0x1EC) every frame while the timed flag
|
||||
// @0x22C is set (timer @0x21C accumulates to @0x1D8, then both clear).
|
||||
// NOT reconstructed in the port's targeting step -- tracked, not implemented.
|
||||
static const Scalar RangeBias = 100.0f; // _DAT_004b7ecc
|
||||
// The shared ZERO: the right-hand side of the Abs() idiom on the range slide
|
||||
// (`dt * 500.0 <= 0.0` picks the sign) and of an `== 0.0f` test at @0x28C.
|
||||
// The 500 m/s slide RATE is an immediate literal (0x43fa0000), never this.
|
||||
static const Scalar HudZero = 0.0f; // _DAT_004b7ed0
|
||||
// The decay FLOOR for horizontalTorsoOffset (@0x294) in FUN_004b7ed4. The
|
||||
// decay RATE is the object's own @0x298, not a constant. (Value verified.)
|
||||
static const Scalar FlickerFloor = 0.0f; // _DAT_004b7f90
|
||||
|
||||
//
|
||||
// Cross-family helper (definition lives in the mech game layer; declared here
|
||||
@@ -322,9 +348,13 @@ Logical
|
||||
// (+0x100), sliding at 500 m/s (:5652-5670), default 1200.0 with no
|
||||
// target; the compass Scalar @0x214 = yaw euler[0] + torso twist (:5676).
|
||||
// 6. Torso-horizon slew: horizontalTorsoOffset (@0x294) is moved toward the
|
||||
// commanded torso heading at up to MaxTorsoSlew (500/sec), clamped to
|
||||
// +/- horizontalLimit (@0x29C), then written to the graphic at
|
||||
// mech +0x36C. The flicker helper (@004b7ed4) damps the settle.
|
||||
// commanded torso heading, clamped to +/- horizontalLimit (@0x29C), then
|
||||
// written to the graphic at mech +0x36C. The flicker helper (@004b7ed4)
|
||||
// damps the settle: it decays @0x294 toward ZERO at the object's own
|
||||
// @0x298 (horizontalMovementPerSecond) x time_slice. (CORRECTED
|
||||
// 2026-08-08: this used to read "at up to MaxTorsoSlew (500/sec)" -- the
|
||||
// rate is that per-object field, and there is no 500 constant here. The
|
||||
// 500 m/s belongs to the RANGE slide in step 5, as an immediate.)
|
||||
//
|
||||
void
|
||||
HUD::HudSimulation(Scalar time_slice)
|
||||
@@ -475,3 +505,20 @@ void
|
||||
{
|
||||
ResetToInitialState(reset_command != 0); // @004b77bc
|
||||
}
|
||||
|
||||
//
|
||||
// BTSetHudFlickerActive -- complete-type bridge for the CONTROL-MODE switch
|
||||
// (mechmppr.cpp treats the mech's subsystems as opaque pointers, so it cannot
|
||||
// touch HUD members directly; same pattern as torso.cpp's BTGetTorsoTwistAddr).
|
||||
//
|
||||
// @004afbe0's BASIC arm ends in `*(mech+0x5b4 + 0x2a0) = 1` -- mech+0x5b4 is the
|
||||
// HUD subsystem cache and +0x2A0 is flickerActive. Basic mode re-centres the
|
||||
// torso, so the HUD horizon is kicked into its settle animation to follow it
|
||||
// (UpdateFlicker @004b7ed4 decays horizontalTorsoOffset and reports whether it
|
||||
// is still moving). The port never made this call.
|
||||
//
|
||||
void BTSetHudFlickerActive(Subsystem *hud)
|
||||
{
|
||||
if (hud != 0)
|
||||
((HUD *)hud)->SetFlickerActive(1);
|
||||
}
|
||||
|
||||
@@ -196,6 +196,12 @@
|
||||
// Simulation Support
|
||||
//
|
||||
public:
|
||||
// @0x2A0 -- raised by the CONTROL-MODE switch's BASIC arm
|
||||
// (`*(mech+0x5b4 + 0x2a0) = 1`, @004afbe0) so the HUD horizon re-settles
|
||||
// with the torso that Basic just re-centred. Reached from mechmppr via
|
||||
// hud.cpp's BTSetHudFlickerActive bridge (that TU sees Subsystem*, not HUD).
|
||||
void SetFlickerActive(int on) { Check(this); flickerActive = on; }
|
||||
|
||||
typedef void
|
||||
(HUD::*Performance)(Scalar time_slice);
|
||||
|
||||
|
||||
+144
-37
@@ -82,7 +82,6 @@
|
||||
// The torso twist is reached via a BRIDGE (BTGetTorsoTwist, defined in torso.cpp)
|
||||
// for the same reason.
|
||||
#include "dmgtable.hpp"
|
||||
#include <GAUGREND.hpp> // GaugeRenderer::SpecialEffect (virtual) -- PPC scramble trigger
|
||||
extern Scalar BTGetTorsoTwist(Subsystem *torso); // torso.cpp (Torso complete there)
|
||||
// heat-bank ambient bridge (heatfamily_reslice.cpp, AggregateHeatSink complete
|
||||
// there) -- mech.cpp cannot include the subsystem headers (local-stub collision)
|
||||
@@ -511,8 +510,25 @@ void
|
||||
{
|
||||
return;
|
||||
}
|
||||
duckState = 1;
|
||||
DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1" << std::endl << std::flush;
|
||||
// #142: duckState is the POSTURE the cockpit's crouch-symbol animation
|
||||
// reads -- L4GAUGE.CFG:5001 binds attribute 0x37 to a THREE-frame
|
||||
// bduck.pcc strip, confirmed on screen as a duck animation:
|
||||
// 0 = standing 1 = moving between 2 = crouched
|
||||
//
|
||||
// A bare 1 here is therefore exactly right, and is what the binary writes:
|
||||
// it means "in transition", which is both the request AND the middle frame.
|
||||
// The consumer (mech4.cpp) reads the parked leg alarm to decide DIRECTION
|
||||
// -- parked means the pending move is a rise, not parked means a squat --
|
||||
// and settles duckState to 0 or 2 when the clip finishes. No separate
|
||||
// request cell, no toggle, no divergence from @0049fa00.
|
||||
//
|
||||
// (An earlier revision toggled 0<->1 here. That produced a two-pose snap,
|
||||
// which is what the cockpit reported as "it lights up and sticks, no
|
||||
// animation": frame 2 was never reachable.)
|
||||
duckState = 1; // show the MIDDLE frame at once (the binary's write)
|
||||
duckRequest = 1; // and remember that a move is pending (#142)
|
||||
DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1 (in transition)"
|
||||
<< std::endl << std::flush;
|
||||
}
|
||||
|
||||
//
|
||||
@@ -985,7 +1001,18 @@ void
|
||||
<< " pts in " << n << " sub-hits (scale=" << scale << ")"
|
||||
<< std::endl << std::flush;
|
||||
|
||||
extern Derivation *BTHeatSinkFamily(void); // heat.cpp (0x50e590)
|
||||
// #128 CORRECTION (2026-08-11): 0x50e590 is the AGGREGATE BANK's node
|
||||
// (0xBBE "HeatSinkBank"), NOT the HeatSink base family -- the identity
|
||||
// task #9 proved live for the link-attach guard (heat.cpp:627, same
|
||||
// binary test FUN_0041a1a4 vs 0x50e590). The old family-wide test made
|
||||
// condensers, reservoirs, generators, sensors, weapons and MYOMERS
|
||||
// rattle-eligible -- every one coolant-bearing, so a single over-floor
|
||||
// ram sub-hit sprang an instant leak ("every single time"; Michael's
|
||||
// three field rams all logged 'Myomers condition 2 SET', a subsystem
|
||||
// the 1995 tag test can never select -- its authored 0.35 collision
|
||||
// weight is DEAD DATA in the binary). Authentic eligible set:
|
||||
// {HeatSinkBank, Gyroscope, Torso}. Only the bank carries coolant, so
|
||||
// a 1995 ram is gyro/torso damage with at worst a bounded bank drip.
|
||||
extern Derivation *BTGyroscopeFamily(void); // gyro.cpp (0x50fdc0)
|
||||
extern Derivation *BTTorsoFamily(void); // torso.cpp (0x510b08)
|
||||
for (int i = 0; i < n; ++i) // @0x4a006b
|
||||
@@ -997,7 +1024,7 @@ void
|
||||
Subsystem *s = GetSubsystem(b); // roster @+0x128
|
||||
if (s == 0)
|
||||
continue;
|
||||
if (!s->IsDerivedFrom(*BTHeatSinkFamily()) // @0x4a0092
|
||||
if (!s->IsDerivedFrom("HeatSinkBank") // @0x4a0092 (0x50e590, the BANK)
|
||||
&& !s->IsDerivedFrom(*BTGyroscopeFamily()) // @0x4a00b2
|
||||
&& !s->IsDerivedFrom(*BTTorsoFamily())) // @0x4a00d2
|
||||
continue;
|
||||
@@ -1016,6 +1043,30 @@ void
|
||||
{
|
||||
Check(message);
|
||||
|
||||
// #150 -- the binary's ONCE-PER-DEATH latch (local_14 @0x4a0230 body,
|
||||
// part_012.c:14625-14629). The kill report posts only on the 0->1 edge
|
||||
// (!destroyedAtEntry && destroyed-now).
|
||||
// CORRECTED by the #162 audit: a later salvo of the killing volley is NOT
|
||||
// "inflicted-only" -- it emits type-0 Inflicted (shooter) AND type-1
|
||||
// Received (victim, latch-independent, part_012.c:14736-14759); what the
|
||||
// latch suppresses is the type-2 kill and the death tail. Full map:
|
||||
// docs/KD_CHAIN_AUDIT_20260811.md.
|
||||
//
|
||||
// ⚠ PREDICATE (eject canary, 2026-08-11): the binary's local_14 samples
|
||||
// FUN_0049fb54 = movementMode 2||9 (part_012.c:14174-14188) -- the MODE,
|
||||
// not the alarm. The first cut here used the alarm-based
|
||||
// IsMechDestroyed(), and the EJECT arm broke exactly the way #108's
|
||||
// deathBlastArmed once did: EjectPilot raises graphicAlarm to 10 BEFORE
|
||||
// dispatching its killBonus self-damage, so the handler entered reading
|
||||
// "already destroyed" and the SUICIDE type-2 was suppressed -- a real
|
||||
// punch-out charged the -500 cost but never the -(basis+tally) award,
|
||||
// while ammo cook-off suicides (no alarm pre-write) scored correctly.
|
||||
// Mode 2|9 is untouched by the eject's alarm write, so the mode form
|
||||
// latches post-death volley rounds exactly as before AND lets the eject's
|
||||
// suicide report through -- byte-faithful to the 1995 predicate.
|
||||
const int destroyedAtEntry =
|
||||
(MovementMode() == 2 || MovementMode() == 9); // FUN_0049fb54
|
||||
|
||||
// MP DIAGNOSTIC (task #47): confirm the handler runs on the OWNING MASTER
|
||||
// for a cross-pod (network-delivered) TakeDamage + what state it carries.
|
||||
if (getenv("BT_MP_NET"))
|
||||
@@ -1071,7 +1122,31 @@ void
|
||||
// Capturing it after the divert (where #89 first placed it) meant a
|
||||
// COLLISION death could never arm the tail.
|
||||
//
|
||||
const int deathBlastArmed = !IsMechDestroyed(); // [ebp-0x10], inverted
|
||||
// WAS-ALIVE-AT-ENTRY, on the BINARY's predicate (movementMode), not the
|
||||
// graphic alarm. FIXED 2026-08-07 -- this substitution was the eject-ghost.
|
||||
//
|
||||
// The binary tests movementMode 9|10 here; the port used IsMechDestroyed()
|
||||
// (graphicAlarm >= 9) and justified it with "the death transition sets mode
|
||||
// 9 synchronously with the structural flag on every path through here, so
|
||||
// the edges coincide". That is true of every DAMAGE path and false of the
|
||||
// one that matters: Mech::EjectPilotMessageHandler raises graphicAlarm to
|
||||
// 10 (the EJECT state) BEFORE dispatching its self-damage, while
|
||||
// movementMode is still 1. So on an eject the handler entered already
|
||||
// reading "destroyed", the latch never armed, and the whole death tail was
|
||||
// skipped -- no VehicleDead, which IS the respawn trigger.
|
||||
//
|
||||
// Consequences, all three reported from the field on the same night:
|
||||
// * the ejecting player never respawns ("panic button, didn't respawn");
|
||||
// * the peer wrecks the mech and never un-wrecks it, because the un-wreck
|
||||
// rides the master's respawn -> the permanent EJECT GHOST (#108);
|
||||
// * the eject scores only its -500 self-damage: no negated kill award and
|
||||
// no -500 death cost, because both live in the tail that never ran --
|
||||
// which is why the manual chart's "-1000 ejecting" never materialised.
|
||||
//
|
||||
// MovementMode 9|10 is untouched by the eject's alarm write, so the latch
|
||||
// now arms on the eject exactly as it does on a combat death.
|
||||
const int deathBlastArmed =
|
||||
!(MovementMode() == 9 || MovementMode() == 10); // [ebp-0x10], inverted (@0x4a0303)
|
||||
//
|
||||
// The zone the reports + VehicleDead carry: msg+0x24 as of loop entry.
|
||||
// The binary never rewrites msg+0x24 after the initial cylinder resolve;
|
||||
@@ -1123,31 +1198,6 @@ void
|
||||
}
|
||||
}
|
||||
reportZone = message->damageZone; // post-resolve (@0x4a0396 write)
|
||||
|
||||
//
|
||||
// @0x4a03f3 [T1] -- PPC/ERPPC SECONDARY-DISPLAY SCRAMBLE. EnergyDamageType
|
||||
// (==4) is authored on exactly the 14 PPC/ERPPC subsystem records and nothing
|
||||
// else, so this branch is structurally PPC-exclusive -- NO explicit weapon
|
||||
// class check (the data authorship IS the gate). It sits OUTSIDE the burst
|
||||
// loop below, so it fires ONCE per damage message, not per burst. The
|
||||
// duration is DERIVED from the type ordinal, not a literal: (Scalar)4 * 0.2 ==
|
||||
// 0.8s (binary loads long double 0.2 @0x4a0c08, fmulp). SpecialEffect ->
|
||||
// L4GaugeRenderer scrambles every SECONDARY cockpit display (SVGA16 sync
|
||||
// detune, FunkyVideo) for that window; the main 3D view is on an independent
|
||||
// timing chain and is left clean. Spec: phases/phase-14-ppc-sync-distortion.md.
|
||||
//
|
||||
if (message->damageData.damageType == Damage::EnergyDamageType)
|
||||
{
|
||||
GaugeRenderer *gauges =
|
||||
(application != 0) ? application->GetGaugeRenderer() : 0; // APP.h:355 -- named, not application+0x4c
|
||||
if (gauges != 0) // the binary null-guards the renderer too (@0x4a0405)
|
||||
gauges->SpecialEffect(GaugeRenderer::scrambleVideo,
|
||||
(Scalar)message->damageData.damageType * 0.2f);
|
||||
if (BTEnvOn("BT_DMG_LOG", 0))
|
||||
DEBUG_STREAM << "[ppc-scramble] EnergyDamageType hit -> scrambleVideo for "
|
||||
<< ((Scalar)message->damageData.damageType * 0.2f) << "s\n" << std::flush;
|
||||
}
|
||||
|
||||
//
|
||||
// #80 -- the faithful application loop (binary @0x4a0423-0x4a04d8), which
|
||||
// REPLACES the engine-base single application. Three things the base
|
||||
@@ -1227,11 +1277,31 @@ void
|
||||
// vital-wreck flag, and the loop-ENTRY zone. This retires
|
||||
// BTPostDamageScore/BTPostKillScore -- see the btplayer.cpp tombstone.
|
||||
//
|
||||
// #150 DOUBLE-KILL FIX, two binary-grounded gates (2026-08-10):
|
||||
//
|
||||
// (1) MASTER ONLY. The binary OPENS this handler with the assertion
|
||||
// `if ((flags & 0xc) == 4) Fail("Replicant Mech recieving takedam...",
|
||||
// MECH.CPP, 0x3da)` (part_012.c:14613-14616) -- in 1995 a replicant
|
||||
// executing TakeDamage was a BUG, so the report tail could only ever
|
||||
// run on the victim's master, once. This port deliberately applies
|
||||
// damage on replicants too (local visual responsiveness), so the tail
|
||||
// ALSO ran there -- and the replicant's movement mode can't flip to
|
||||
// wreck until the death record round-trips, so a multi-round killing
|
||||
// volley posted a kill report PER ROUND inside that window. Measured:
|
||||
// one death -> two type-2 arms 1ms apart, kills 0->2 (the field's
|
||||
// "one kill counted twice", #150). Gate the REPORTS to the master;
|
||||
// the local damage application stays.
|
||||
//
|
||||
// (2) ENTRY LATCH. The binary's newly-destroyed test is the 0->1 edge of
|
||||
// IsMechDestroyed() across THIS handler call (local_14), not the
|
||||
// movement-mode-derived deathBlastArmed -- on the master both agree,
|
||||
// but the binary's form is the spec.
|
||||
if (GetInstance() != ReplicantInstance)
|
||||
{
|
||||
extern void BTMechPostCombatReports(void *, void *, float, int, int, int);
|
||||
BTMechPostCombatReports((void *)this, (void *)message,
|
||||
(float)damageTally, zoneDestroyed, reportZone,
|
||||
deathBlastArmed && IsMechDestroyed());
|
||||
!destroyedAtEntry && IsMechDestroyed());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1287,18 +1357,54 @@ void
|
||||
// falloff bursts/dist^1.25 per victim inside the shared core.
|
||||
// 4. ForceUpdate(1).
|
||||
// The gate predicate here is IsMechDestroyed() (graphicAlarm >= 9, the
|
||||
// structural flag @0x49fb54) -- the binary tests movementMode 9|10, but
|
||||
// the death transition sets mode 9 synchronously with the structural flag
|
||||
// on every path through here, so the edges coincide; this exact predicate
|
||||
// is the one the #89 blast benches verified both ways.
|
||||
// structural flag @0x49fb54) -- the binary tests movementMode 9|10, and
|
||||
// sets mode 9 SYNCHRONOUSLY inside this same damage pass (its own tail
|
||||
// gate part_012.c:14761 reads mode 9||10 already flipped). An earlier
|
||||
// comment here claimed our transition was synchronous too -- it was NOT
|
||||
// (UpdateDeathState ran per-frame), and the 4-node retest caught the
|
||||
// window: TWO lethal deliveries in one event drain (direct + splash of
|
||||
// the same salvo, 2ms apart, no sim tick between) each read
|
||||
// destroyedAtEntry=0 and re-ran the whole death block -- duplicate kill
|
||||
// credit to the shooter (kills 1->2 in 2ms) and a duplicate VehicleDead
|
||||
// (the receiver dedup swallowed the death cycle but the kill report had
|
||||
// already been posted). The transition is now invoked synchronously
|
||||
// below, closing the window exactly the way the binary does.
|
||||
death_tail:
|
||||
if (deathBlastArmed && IsMechDestroyed())
|
||||
// #162 (2026-08-11) -- MASTER ONLY: the discipline #150 restored for the
|
||||
// score reports, now applied to the death tail it forgot. In 1995 this
|
||||
// ENTIRE handler was replicant-asserted (part_012.c:14613), so the tail
|
||||
// could only run on the victim's master: ONE VehicleDead per death -- and
|
||||
// the receiver needs no dedup, because the binary's deathPending gate
|
||||
// (+0x290) is never written non-zero anywhere in BTL4OPT.EXE [T1 image
|
||||
// byte-scan]; the sender's one-shot edge IS the 1995 guarantee. Our port
|
||||
// deliberately applies damage on replicant copies for visual response,
|
||||
// and on those copies movementMode does not flip to 9 until the owner's
|
||||
// type-6 death record round-trips -- so deathBlastArmed stayed armed
|
||||
// there, and EVERY salvo of the killing volley re-fired this tail on
|
||||
// EVERY peer: a duplicate VehicleDead per salvo per node, rerouted to the
|
||||
// victim's master (night-15 receipts: 5 real deaths -> 8 PLAYER_DEAD,
|
||||
// deaths=1,2 one ms apart, x3 same-millisecond; multiplicity tracks the
|
||||
// killing-window ROUNDS, not shooters or peers), plus a duplicate death
|
||||
// splash per peer. Peers' death visuals ride the type-6 record path
|
||||
// (UpdateDeathState wreck/explosion), never this tail -- gating it here
|
||||
// removes only the phantom emissions the binary never made.
|
||||
if (deathBlastArmed && IsMechDestroyed()
|
||||
&& GetInstance() != ReplicantInstance)
|
||||
{
|
||||
extern void BTMechPostVehicleDead(void *, void *, int);
|
||||
BTMechPostVehicleDead((void *)this, (void *)message, reportZone);
|
||||
|
||||
extern void BTApplyDeathSplash(void *mech_v);
|
||||
BTApplyDeathSplash((void *)this);
|
||||
|
||||
// Run the once-per-death transition NOW (mode 9 + type-6 ForceUpdate +
|
||||
// death effects), not next tick -- the binary flips mode inside this
|
||||
// same pass, which is the entire same-frame double-delivery latch: the
|
||||
// NEXT lethal message of this volley reads destroyedAtEntry=1 and
|
||||
// deathBlastArmed=0, so it can neither re-credit the kill nor re-post
|
||||
// VehicleDead. dt=0: the transition branch has no time dependence; the
|
||||
// per-frame caller takes the wreck-maintenance branch from here on.
|
||||
UpdateDeathState(0.0f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1570,6 +1676,7 @@ Mech::Mech(
|
||||
radarLinearPosition = &localOrigin.linearPosition; // map reads the mech's live world position...
|
||||
radarAngularPosition= &localOrigin.angularPosition; // ...and orientation (pointers into the base origin)
|
||||
duckState = 0; // not crouching
|
||||
duckRequest = 0; // no pending duck request (#142)
|
||||
// (AUDIO_FIDELITY F7) missile alarm: the binary reset writes 0 / FLT_MAX
|
||||
// (part_012.c:9446-9447; FLT_MAX = "no missile" far default)
|
||||
incomingLock = 0;
|
||||
|
||||
@@ -964,7 +964,15 @@ protected:
|
||||
Scalar radarRange; // 0x2f RadarRange (scale/max)
|
||||
Point3D *radarLinearPosition; // 0x30 RadarLinearPosition
|
||||
Quaternion *radarAngularPosition; // 0x31 RadarAngularPosition
|
||||
int duckState; // 0x37 DuckState (crouch posture)
|
||||
int duckState; // 0x37 DuckState (crouch POSTURE the cockpit
|
||||
// strip draws: 0 stand, 1 moving, 2 crouched)
|
||||
// PORT-ONLY (#142): the pending duck REQUEST, kept separate from the
|
||||
// posture above. duckState cannot carry both -- settling it to the
|
||||
// real posture destroys the request, and the consumer then re-issues
|
||||
// the opposite direction on the very frame the clip parks (benched:
|
||||
// 69 squat/rise transitions from 2 presses). Not a binary field; it
|
||||
// is appended, never read by offset.
|
||||
int duckRequest;
|
||||
// (AUDIO_FIDELITY F7) the incoming-missile alarm attributes. Binary
|
||||
// Mech table [T1]: IncomingLock id 54 @0x3fc (Logical; authored match
|
||||
// ==1 Start / ==0 Stop of the looped beeper), DistanceToMissile id 56
|
||||
|
||||
@@ -1246,6 +1246,10 @@ Scalar
|
||||
Mech::AdvanceBodyAnimation(Scalar time_slice, int loop)
|
||||
{
|
||||
Scalar distance = 0.0f;
|
||||
// #52 probe (BT_BODY_SM_LOG): case 0 and the inserted turn block below run
|
||||
// in the SAME invocation, so a plain local proves the arm->reset pair --
|
||||
// no cross-frame state, no per-mech bookkeeping.
|
||||
int armedFromStanding = 0;
|
||||
|
||||
// In the binary `bodyAnimationState`@0x728 IS `bodyStateAlarm`'s level (one field);
|
||||
// the reconstruction split them, so SetBodyAnimation's `bodyStateAlarm.SetLevel(state)`
|
||||
@@ -1297,7 +1301,35 @@ Scalar
|
||||
}
|
||||
SetBodyAnimation(0x10);
|
||||
}
|
||||
// FALLTHROUGH
|
||||
// FALLTHROUGH -- into the ADVANCE GROUP, which is where the binary
|
||||
// sends it. #52 SKATE ROOT CAUSE (2026-08-07): in FUN_004a5678 case 4
|
||||
// is a MEMBER of the advance list (case 2,3,*4*,5,8,...), so a state
|
||||
// just armed away from Standing lands on Advance(). The port's turn
|
||||
// block below is an INSERTION (task #64 lockstep twin) and, sitting
|
||||
// between case 0 and the advance group, it intercepted that fallthrough.
|
||||
// On a REPLICANT that is fatal and not a race: case 0 arms walk iff
|
||||
// `standSpeed < bodyTargetSpeed`, and the inserted block's exit tests
|
||||
// `standSpeed < bspd` where bspd IS bodyTargetSpeed for a replicant --
|
||||
// the SAME expression. Arm and reset therefore fire on the same frame,
|
||||
// every frame, and a peer parked at Standing with a live replicated
|
||||
// demand can never start cycling (reverse likewise: both sides test
|
||||
// `< ZeroSpeed`). It cycles again only when a record sets the state
|
||||
// directly (ReadUpdateRecord, mech.cpp) -- the observed self-recovery.
|
||||
// Masters escape because their two tests read DIFFERENT cells
|
||||
// (bodyTargetSpeed = last-sent vs the live mapper speedDemand) and
|
||||
// because the body channel is mj=0 there, so its stall is invisible.
|
||||
// Introduced by e91d447 (#82): before it the replicant branch read the
|
||||
// dead mapper cell (0 forever), so the exit never fired and this
|
||||
// fallthrough worked BY ACCIDENT. Fixing the dead cell closed the
|
||||
// accidental escape hatch and the trn-lock skate came back as a
|
||||
// Standing-lock skate. BT_NO_BODY_FALLTHRU=1 restores the old path.
|
||||
armedFromStanding = (int)bodyStateAlarm.GetLevel();
|
||||
{
|
||||
static const int s_bodyFallthru = getenv("BT_NO_BODY_FALLTHRU") ? 0 : 1;
|
||||
if (s_bodyFallthru)
|
||||
goto advance_body_normally;
|
||||
}
|
||||
// FALLTHROUGH (legacy path only)
|
||||
|
||||
case 4: // TURN-IN-PLACE, LOCKSTEP twin (task #64)
|
||||
// The body channel runs trn in LOCKSTEP with the leg: armed together at
|
||||
@@ -1324,6 +1356,23 @@ Scalar
|
||||
: (bm != 0) ? bm->speedDemand : 0.0f;
|
||||
if (standSpeed < bspd || bspd < ZeroSpeed) // walk / reverse (leg-symmetric)
|
||||
{
|
||||
// #52 probe: when this fires on a state case 0 JUST armed, the
|
||||
// mech is being pushed straight back to Standing on the same
|
||||
// frame it tried to leave it -- the Standing-lock. On a
|
||||
// replicant `bspd` IS the same cell case 0 tested, so the pair
|
||||
// is unconditional, not a race.
|
||||
if (armedFromStanding != 0 && getenv("BT_BODY_SM_LOG"))
|
||||
{
|
||||
static float s_bsm = 0.0f; s_bsm += time_slice;
|
||||
if (s_bsm >= 1.0f) { s_bsm = 0.0f;
|
||||
DEBUG_STREAM << "[bodySM] " << (GetInstance() == ReplicantInstance
|
||||
? "REPLICANT " : "master ")
|
||||
<< GetEntityID() << " case0 armed " << armedFromStanding
|
||||
<< " -> turn-block RESET to Standing bspd=" << (float)bspd
|
||||
<< " bts=" << (float)bodyTargetSpeed
|
||||
<< " standSpeed=" << (float)standSpeed
|
||||
<< " (STANDING-LOCK)\n" << std::flush; }
|
||||
}
|
||||
bodyStateAlarm.SetLevel(0);
|
||||
ForceUpdate(8);
|
||||
distance = 0.0f;
|
||||
@@ -1347,6 +1396,7 @@ Scalar
|
||||
case 2: case 3: case 5: case 8: case 9: case 10: case 0x0b:
|
||||
case 0x0e: case 0x0f: case 0x10: case 0x11: case 0x14: case 0x15:
|
||||
case 0x1c: case 0x1d: case 0x1e: case 0x1f: case 0x20:
|
||||
advance_body_normally: // case 0's fallthrough target (leg twin: advance_normally)
|
||||
distance = bodyAnimation.Advance( // FUN_0042790c(this+0x6bc, ...)
|
||||
time_slice * globalTimeScale * idleStrideScale, loop);
|
||||
bodyCycleSpeed = distance / time_slice; // this+0x6b8
|
||||
|
||||
+601
-51
@@ -666,6 +666,7 @@ static int gBTPPCKey = 0;
|
||||
static int gBTMissileKey = 0;
|
||||
static int gBTPinkyKey = 0; // key '4' = the pod's 4th fire button (Pinky 0x45)
|
||||
int gBTModeCycle = 0; // 'M' edge: cycle the control mode (mapper consumes)
|
||||
int gBTMyoTrace = 0; // #137: frames of post-reset myomer tracing left (armed by Mech::Reset)
|
||||
int gBTDisplayCycle = 0; // 'N' edge: cycle the secondary schematic (Gitea #6, mapper consumes)
|
||||
int gBTPresetCycle[3] = {0,0,0}; // J/K/L edges: cycle an upper-MFD preset page (Gitea #9, L4 mapper consumes)
|
||||
//
|
||||
@@ -790,8 +791,26 @@ void
|
||||
EntitySegment *seg = m->GetSegment(segIndex); // owner+0x300 table, GetNth(index)
|
||||
if (seg != 0)
|
||||
{
|
||||
AffineMatrix mw;
|
||||
mw.Multiply(seg->GetSegmentToEntity(), m->localToWorld); // segment -> world (== mech4 gun-port path)
|
||||
// #141 -- THE BINARY GOES THROUGH FUN_00424da8, AND SO MUST WE.
|
||||
// @004b9948 ends in `FUN_00424da8(owner, segment, out)`, which is
|
||||
// JointedMover::GetSegmentToWorld instruction-for-instruction:
|
||||
// iVar1 = FUN_00417ab4(param_1 + 0x31c); // GetJointSubsystem()
|
||||
// if (*(int *)(iVar1 + 0xfc) != 0) { // AreJointsModified()
|
||||
// ...walk owner+0x300 setting seg+0xc = 1... // ModifySegment()
|
||||
// *(int *)(iVar1 + 0xfc) = 0; // ModifyJoints(False)
|
||||
// }
|
||||
// FUN_0040b104(out, FUN_004244dc(seg), owner+0xd0); // x localToWorld
|
||||
// So in the 1995 image EVERY muzzle query performs the joints->segments
|
||||
// refresh. This port hand-composed GetSegmentToEntity() x localToWorld
|
||||
// and skipped it -- and GetSegmentToEntity only recomputes when
|
||||
// segmentModified is already set (SEGMENT.cpp:262), so it returned a
|
||||
// stale cache. On the MASTER that was invisible (the render pass
|
||||
// refreshes the local mech every frame); on a REPLICANT nothing did, so
|
||||
// peer muzzles sat at the BIND POSE -- the missile launched along the
|
||||
// leg facing (#141). Use the engine accessor; do NOT force the dirty
|
||||
// flag, the binary does not.
|
||||
LinearMatrix mw;
|
||||
m->GetSegmentToWorld(*seg, &mw);
|
||||
out = mw; // Point3D = matrix W_Axis translation
|
||||
}
|
||||
else
|
||||
@@ -825,8 +844,9 @@ struct BTProjectile {
|
||||
Point3D pos;
|
||||
Vector3D vel; // world velocity (authored MuzzleVelocity, steered per frame)
|
||||
Scalar speed; // |vel| held constant through the steer
|
||||
Scalar traveled;
|
||||
Scalar range;
|
||||
Scalar age; // flight seconds since launch (#168)
|
||||
Scalar ttl; // authentic lifetime cap (s): 5.0 plain shell (@004bddec),
|
||||
// BurnTime+10.0 missile (@004bef78) -- see the launch site
|
||||
Entity *target;
|
||||
Point3D targetPos;
|
||||
Scalar aimOffsetY; // vertical aim offset vs the target's origin (live re-lead)
|
||||
@@ -1044,8 +1064,13 @@ int
|
||||
{
|
||||
if (seg->GetIndex() == seg_index)
|
||||
{
|
||||
AffineMatrix mw;
|
||||
mw.Multiply(seg->GetSegmentToEntity(), m->localToWorld);
|
||||
// #141 sweep: go through the engine accessor, which is the
|
||||
// binary's FUN_00424da8 (the joints->segments refresh). A hand
|
||||
// composed GetSegmentToEntity() x localToWorld reads a STALE
|
||||
// cache on any mech whose segments were not refreshed this frame
|
||||
// -- i.e. every REPLICANT. See BTResolveWeaponMuzzle.
|
||||
LinearMatrix mw;
|
||||
m->GetSegmentToWorld(*seg, &mw);
|
||||
p = mw; // Point3D = matrix translation
|
||||
break;
|
||||
}
|
||||
@@ -1449,6 +1474,8 @@ void
|
||||
? (mz.y - ((Mech *)shooter)->localOrigin.linearPosition.y) : mz.y;
|
||||
DEBUG_STREAM << "[projectile] PUSH target=" << (void*)target
|
||||
<< " len=" << len << " speed=" << speed << " dmg=" << damage
|
||||
<< " guided=" << guided
|
||||
<< " ttl=" << (guided ? ((thrust_burn > 0.0f ? thrust_burn : 0.0f) + 10.0f) : 5.0f)
|
||||
<< " mz=(" << mz.x << "," << mz.y << "," << mz.z << ") relY=" << relY
|
||||
<< " lv=" << (launch_velocity
|
||||
? "(auth)" : "(fallback)") << "\n" << std::flush;
|
||||
@@ -1493,8 +1520,57 @@ void
|
||||
EntitySegment *seg = sm->GetSegment(muzzle_seg);
|
||||
if (seg != 0)
|
||||
{
|
||||
AffineMatrix mw;
|
||||
mw.Multiply(seg->GetSegmentToEntity(), sm->localToWorld);
|
||||
// #141 -- USE THE ENGINE ACCESSOR, not a hand-rolled
|
||||
// GetSegmentToEntity() x localToWorld.
|
||||
//
|
||||
// EntitySegment::GetSegmentToEntity (SEGMENT.cpp:262)
|
||||
// recomputes ONLY when `segmentModified` is set; otherwise it
|
||||
// returns the CACHED matrix. The thing that sets that flag
|
||||
// after a joint moves is JointedMover::GetSegmentToWorld
|
||||
// (JMOVER.cpp:136-146): it tests AreJointsModified() and, when
|
||||
// set, marks EVERY segment dirty and clears the flag. Compose
|
||||
// the matrix by hand and you skip that entirely -- you read
|
||||
// whatever cache happens to be sitting there.
|
||||
//
|
||||
// On the MASTER that was invisible: the renderer / cockpit
|
||||
// camera call GetSegmentToWorld for the LOCAL mech every frame,
|
||||
// so the cache was already fresh when we fired. A REPLICANT
|
||||
// gets no such refresh, so its cache stayed at the BIND POSE
|
||||
// and the torso twist never reached the launch frame.
|
||||
//
|
||||
// Measured (scratchpad/night13/missileframe.sh, 165 salvos
|
||||
// mirrored 1:1): master |twistDelta| max 2.2962 / mean 1.2283,
|
||||
// 100% > 0.1 rad -- REPLICANT max 0.0000, mean 0.0000, 0%,
|
||||
// with segResolved=1 and segYaw == bodyYaw EXACTLY, while that
|
||||
// same peer's copy torso was demonstrably writing its joint
|
||||
// (`PushTwist COPY ... twist=-1.49601`) off correctly
|
||||
// replicated records (`cur=-2.13987 target=-2.13987 copy=1`).
|
||||
// Twist arrived, joint moved, segment cache never refreshed.
|
||||
// That is #141: "missiles launch along the LEG/FOOT facing,
|
||||
// then curve to the target -- peer POV only".
|
||||
// FORCE the recompute. GetSegmentToWorld only refreshes when
|
||||
// AreJointsModified() is set, and by fire time the frame's
|
||||
// renderer/camera pass has already consumed and cleared that
|
||||
// flag on BOTH nodes (measured: jointsDirty=0 master AND peer).
|
||||
// On the master the cache it left behind is correct, because
|
||||
// that pass ran AFTER the local torso pushed its joint. On a
|
||||
// replicant the cache is stale, so seg 18 returned its
|
||||
// bind-pose matrix (segYaw == bodyYaw EXACTLY) even though the
|
||||
// hierarchy is identical -- same parentIdx 4, same non-null
|
||||
// parent + joint subsystem. Setting the flag makes
|
||||
// GetSegmentToWorld mark every segment dirty so the whole
|
||||
// chain re-derives from the CURRENT joint angles. Costs one
|
||||
// segment-table walk per salvo.
|
||||
// NO forced dirty flag here. An earlier pass set
|
||||
// ModifyJoints(True) before this read; it bought 64% of
|
||||
// salvos but it is NOT what the binary does -- @00424da8
|
||||
// tests AreJointsModified() and never sets it. The authentic
|
||||
// refresh happens in the MUZZLE query (GetMuzzlePoint ->
|
||||
// @00424da8), which the launcher calls just above this, so by
|
||||
// the time we compose the launch frame the segment cache is
|
||||
// already current. See BTResolveWeaponMuzzle.
|
||||
LinearMatrix mw;
|
||||
sm->GetSegmentToWorld(*seg, &mw);
|
||||
mw.GetFromAxis(X_Axis, &ax);
|
||||
mw.GetFromAxis(Y_Axis, &ay);
|
||||
mw.GetFromAxis(Z_Axis, &az);
|
||||
@@ -1507,6 +1583,55 @@ void
|
||||
sm->localToWorld.GetFromAxis(Y_Axis, &ay);
|
||||
sm->localToWorld.GetFromAxis(Z_Axis, &az);
|
||||
}
|
||||
// #141 DIAGNOSTIC (BT_PROJ_LOG). The peer-POV report is that the
|
||||
// round leaves along the LEG facing, ignoring torso twist, while the
|
||||
// shooter's own view is correct. Both nodes pass GetSegmentIndex()
|
||||
// as the mount frame, so if this is real the difference is whether
|
||||
// the SEGMENT actually carries the twist on a replicant. Print the
|
||||
// frame we launched through on BOTH sides: twistDelta is the yaw of
|
||||
// the launch forward vs the BODY forward, so it should equal the
|
||||
// torso twist on the master and MUST match on the replicant. A
|
||||
// replicant reading ~0 while the master reads non-zero IS the bug.
|
||||
if (getenv("BT_PROJ_LOG"))
|
||||
{
|
||||
UnitVector bz;
|
||||
sm->localToWorld.GetFromAxis(Z_Axis, &bz);
|
||||
const float kPi = 3.14159265f;
|
||||
float segYaw = atan2f(-(float)az.x, -(float)az.z);
|
||||
float bodyYaw = atan2f(-(float)bz.x, -(float)bz.z);
|
||||
float dYaw = segYaw - bodyYaw;
|
||||
while (dYaw > kPi) dYaw -= 2.0f * kPi;
|
||||
while (dYaw < -kPi) dYaw += 2.0f * kPi;
|
||||
// #141 probe 2: GetSegmentToEntity only RECOMPUTES when
|
||||
// (segmentModified && parentSegment). A null parent means it can
|
||||
// never recompute -- it returns the bind-pose baseOffset forever,
|
||||
// which would read as segYaw == bodyYaw exactly. Print the
|
||||
// hierarchy + joint-dirty state so master and peer can be diffed.
|
||||
EntitySegment *pseg = (muzzle_seg >= 0) ? sm->GetSegment(muzzle_seg) : 0;
|
||||
const void *parent = (pseg != 0) ? (const void *)pseg->GetParent() : 0;
|
||||
int parentIdx = (pseg != 0) ? pseg->GetParentIndex() : -99;
|
||||
JointSubsystem *js = sm->GetJointSubsystem();
|
||||
// #148: the SHOOTER's live torso twist AT THIS INSTANT. The
|
||||
// [torso-copy] probe samples every 120th call, so its "first
|
||||
// non-zero" tells you when it first SAMPLED, not when the twist
|
||||
// started -- that is exactly the artifact that made the earlier
|
||||
// "the peer had no twist to carry" reading look right. Read the
|
||||
// cell directly instead, so twistDelta and the twist that should
|
||||
// be driving it are on the SAME line.
|
||||
extern Scalar *BTGetTorsoTwistAddr(Subsystem *torso);
|
||||
Scalar *twp = BTGetTorsoTwistAddr(sm->GetTorsoSubsystem());
|
||||
DEBUG_STREAM << "[launchframe] "
|
||||
<< (sm->GetInstance() == Entity::ReplicantInstance
|
||||
? "REPLICANT" : "master ")
|
||||
<< " liveTwist=" << (twp != 0 ? (float)*twp : -99.0f)
|
||||
<< " seg=" << muzzle_seg << " segResolved=" << haveFrame
|
||||
<< " segYaw=" << segYaw << " bodyYaw=" << bodyYaw
|
||||
<< " twistDelta=" << dYaw
|
||||
<< " parent=" << parent << " parentIdx=" << parentIdx
|
||||
<< " joints=" << (void *)js
|
||||
<< " jointsDirty=" << (js != 0 ? (int)js->AreJointsModified() : -1)
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
p.vel.x = ax.x*launch_velocity->x + ay.x*launch_velocity->y - az.x*launch_velocity->z;
|
||||
p.vel.y = ax.y*launch_velocity->x + ay.y*launch_velocity->y - az.y*launch_velocity->z;
|
||||
p.vel.z = ax.z*launch_velocity->x + ay.z*launch_velocity->y - az.z*launch_velocity->z;
|
||||
@@ -1523,8 +1648,18 @@ void
|
||||
{
|
||||
p.vel.x = d.x/len*p.speed; p.vel.y = d.y/len*p.speed; p.vel.z = d.z/len*p.speed;
|
||||
}
|
||||
p.traveled = 0.0f;
|
||||
p.range = len * 1.3f + 60.0f; // arc margin; expire past the target
|
||||
p.age = 0.0f;
|
||||
// AUTHENTIC lifetime (#168). The old cap (len*1.3+60, distance-to-target)
|
||||
// was a port invention: the binary never range-caps a round in flight.
|
||||
// The plain Projectile lives exactly 5.0s (@004bddec tail:
|
||||
// age > _DAT_004be054=5.0 -> FUN_0042061c kill) and the Missile lives
|
||||
// BurnTime+10.0s (@004bef78 tail: age > burnTime(+0x340) +
|
||||
// _DAT_004bf5ac=10.0), plus a kill-plane at y < -1 (_DAT_004bf5b0).
|
||||
// WeaponRange is FIRE-CONTROL data only (effectiveRange @0x328) -- the
|
||||
// trigger itself has no range gate (@004bcc60 reads no +0x328), so a
|
||||
// shell landing far beyond its listed range is authentic arcade behavior.
|
||||
p.ttl = guided ? ((thrust_burn > 0.0f ? thrust_burn : 0.0f) + 10.0f)
|
||||
: 5.0f;
|
||||
p.target = (Entity *)target;
|
||||
p.targetPos = tpos; // resolved target position (fallback-aware)
|
||||
// live re-lead (authentic: the Seeker re-leads the MOVING target every
|
||||
@@ -1602,19 +1737,23 @@ static void
|
||||
// squared-error guidance: rotate the velocity toward the lofted aim at
|
||||
// the decomp turn gain (MissileTurnGain = 4.0, _DAT_004bf5a4), speed
|
||||
// held at the authored launch speed.
|
||||
// LIVE TARGET TRACKING -- for EVERY round kind, not just guided (#168
|
||||
// audit). The contact sphere below is the port's stand-in for the
|
||||
// binary's real-geometry collision (FUN_0042291c hits the mech's BODY
|
||||
// wherever it is NOW); testing it against the fire-time pick point let
|
||||
// an unguided shell "contact" empty air after the target moved and
|
||||
// still dispatch full damage to the departed mech -- a locked
|
||||
// autocannon could not miss a moving target. Track the entity for the
|
||||
// contact test; only GUIDED rounds also steer onto it.
|
||||
extern int BTIsRegisteredMech(Entity *e);
|
||||
if (p.target != 0 && BTIsRegisteredMech(p.target)
|
||||
&& !((Mech *)p.target)->IsMechDestroyed())
|
||||
{
|
||||
p.targetPos = ((Mech *)p.target)->localOrigin.linearPosition;
|
||||
p.targetPos.y += p.aimOffsetY;
|
||||
}
|
||||
if (p.guided)
|
||||
{
|
||||
// LIVE RE-LEAD (authentic: Seeker::LeadTarget runs every slice on
|
||||
// the MOVING target): refresh the aim from the target's current
|
||||
// position, preserving the launch aim's body height.
|
||||
extern int BTIsRegisteredMech(Entity *e);
|
||||
if (p.target != 0 && BTIsRegisteredMech(p.target)
|
||||
&& !((Mech *)p.target)->IsMechDestroyed())
|
||||
{
|
||||
p.targetPos = ((Mech *)p.target)->localOrigin.linearPosition;
|
||||
p.targetPos.y += p.aimOffsetY;
|
||||
}
|
||||
|
||||
Point3D aim = p.targetPos;
|
||||
Scalar rx = aim.x - p.pos.x, ry = aim.y - p.pos.y, rz = aim.z - p.pos.z;
|
||||
Scalar range = (Scalar)sqrtf(rx*rx + ry*ry + rz*rz);
|
||||
@@ -1667,7 +1806,7 @@ static void
|
||||
p.speed = ns;
|
||||
}
|
||||
p.pos.x += p.vel.x*dt; p.pos.y += p.vel.y*dt; p.pos.z += p.vel.z*dt;
|
||||
p.traveled += p.speed * dt;
|
||||
p.age += dt;
|
||||
|
||||
// WORLD IMPACT (authentic: the binary missile runs a world collision
|
||||
// query every frame, FUN_0042291c, and DETONATES on geometry). Ray the
|
||||
@@ -1772,10 +1911,13 @@ static void
|
||||
contactD2 = cx*cx + cy*cy + cz*cz;
|
||||
}
|
||||
const int contact = (contactD2 < (10.0f*10.0f));
|
||||
if (!contact && p.traveled >= p.range)
|
||||
if (!contact && (p.age >= p.ttl || (p.guided && p.pos.y < -1.0f)))
|
||||
{
|
||||
if (getenv("BT_PROJ_LOG"))
|
||||
DEBUG_STREAM << "[projectile] FIZZLE (flight cap, no contact)\n" << std::flush;
|
||||
DEBUG_STREAM << "[projectile] FIZZLE t=" << p.age
|
||||
<< " ttl=" << p.ttl << " guided=" << p.guided
|
||||
<< ((p.guided && p.pos.y < -1.0f) ? " KILLPLANE" : "")
|
||||
<< "\n" << std::flush;
|
||||
p.active = 0;
|
||||
continue;
|
||||
}
|
||||
@@ -2038,8 +2180,11 @@ void
|
||||
{
|
||||
if (seg->GetIndex() == segment_index)
|
||||
{
|
||||
AffineMatrix mw;
|
||||
mw.Multiply(seg->GetSegmentToEntity(), mech->localToWorld);
|
||||
// #141 sweep: engine accessor (== the binary's FUN_00424da8),
|
||||
// not a hand-composed product -- otherwise a peer's damage
|
||||
// effect anchors to the BIND-POSE segment.
|
||||
LinearMatrix mw;
|
||||
mech->GetSegmentToWorld(*seg, &mw);
|
||||
fxPos = mw; // Point3D = matrix translation
|
||||
break;
|
||||
}
|
||||
@@ -2146,6 +2291,39 @@ void
|
||||
worldLinearVelocity = Vector3D(0.0f, 0.0f, 0.0f);
|
||||
localVelocity = Motion::Identity;
|
||||
frameEntryWorldVelocity = Vector3D(0.0f, 0.0f, 0.0f);
|
||||
// Binary zero-fills restored (Reset @0049fb74 zero-fills FOUR Motion cells:
|
||||
// +0x1c4/+0x1dc/+0x298/+0x2c8; +0x1dc = localAcceleration by the engine
|
||||
// Mover layout). NOTE: zeroing these did NOT fix #137 on its own -- the
|
||||
// acceleration snapshot is REBUILT from a position finite-difference one
|
||||
// frame later (see accelPrevPos below), so the stale-carryover story first
|
||||
// written here was wrong. The zero-fills stay because the binary does them.
|
||||
localAcceleration = Motion::Identity; // binary +0x1dc zero-fill
|
||||
worldLinearAcceleration = Vector3D(0.0f, 0.0f, 0.0f); // world-space mirror
|
||||
|
||||
// #137 ROOT CAUSE -- the DROPPED RE-SEED. The binary Reset's SECOND
|
||||
// instruction is FUN_00408440(mech+0x58c, param_2): re-seed the Point3D at
|
||||
// +0x58c to the NEW ORIGIN. +0x58c is the previous-position memory of the
|
||||
// AccelerationLastFrame ring feed (+0x81c/0x824/0x828/0x82c -- the F19
|
||||
// block below, port member accelPrevPos). This port reconstructed the
|
||||
// ring (ctor part_012.c:9836-9840, derivative :15169-15195) but its Reset
|
||||
// never got the +0x58c line -- so the first post-respawn sample computed
|
||||
// |newPos - accelPrevPos| / dt = TELEPORT DISTANCE / dt ~ 1e5
|
||||
// into the velocity ring, and the ring-mean derivative turned that into an
|
||||
// AccelerationLastFrame spike of 3e4..2.4e5 (pure forward/-z; with a +z
|
||||
// ECHO ~15 frames later as the garbage sample rotates out of the mean).
|
||||
// The myomer drive-heat integrator (@004b8d18) then computed
|
||||
// termAccel = (1-accEff) * |v| * |a| * mass * dt
|
||||
// with |a|~1e5 while the gait re-published |v|~40 under a still-held
|
||||
// throttle: ONE tick deposited ~2.7e9 into pendingHeat -> heatEnergy,
|
||||
// snapping the freshly-reset myomers from T=77 to T~9000 (failT=2000) ->
|
||||
// speedEffect 0 -> speedDemand *= 0 -> "respawned unable to move until it
|
||||
// cools" (#137). Measured: top deposits 3.35e9/3.29e9/3.05e9, every one
|
||||
// 3-4 log lines after a Mech::Reset, aXYZ pure z, master mech.
|
||||
// Why ~8% in the field: the deposit needs |v| in the SAME 1-2 frames, so
|
||||
// only pilots whose throttle is still forward at the respawn (physical
|
||||
// lever / HOTAS -- exactly who reported it) get the freeze; idle-throttle
|
||||
// respawns read v~0 and deposit nothing.
|
||||
accelPrevPos = origin.linearPosition; // binary +0x58c re-seed
|
||||
ramLastVictim = 0;
|
||||
ramContactLinger = 0.0f;
|
||||
// StopAllEntityEffects (@004d0c14): a respawned mech must not trail its
|
||||
@@ -2172,6 +2350,26 @@ void
|
||||
poseSyncLatch = 0; // @0x77c
|
||||
graphicAlarm.SetLevel(0); // clear >=9 (the vital-kill trigger)
|
||||
|
||||
// --- STAND THE MECH UP. Restored 2026-08-07 (#142, Oracle: "crouch wasn't
|
||||
// resetting on respawn"). These are in the binary's own Reset sweep and the
|
||||
// port had dropped all of them, so a pilot who died CROUCHED respawned
|
||||
// crouched -- leg parked in 'sqd', the cockpit strip still showing the
|
||||
// up-arrow "press to rise" frame. Verbatim from FUN_0049fb74:
|
||||
// *(this+0x398) = 0 duckState -- not crouching
|
||||
// FUN_0041bbd8(this+0x39c, 0) legStateAlarm -> 0 (standing)
|
||||
// FUN_0041bbd8(this+0x714, 0) bodyStateAlarm -> 0 (standing)
|
||||
// *(this+0x650/0x654/0x658) = 0 death + leg/body reset latches
|
||||
// *(this+0x5ac) = 1.0f idleStrideScale
|
||||
duckState = 0; // @0x398
|
||||
duckRequest = 0; // port-only pending flag (#142)
|
||||
legStateAlarm.SetLevel(0); // @0x39c -- stand
|
||||
bodyStateAlarm.SetLevel(0); // @0x714 -- stand
|
||||
stabilityAlarm.SetLevel(1); // risen (the rise path's value)
|
||||
deathAnimationLatched = 0; // @0x650
|
||||
legResetLatch = 0; // @0x654
|
||||
bodyResetLatch = 0; // @0x658
|
||||
idleStrideScale = 1.0f; // @0x5ac = 0x3f800000
|
||||
|
||||
// --- HEAL every damage zone: full structure, intact skin, no burning ---
|
||||
for (int z = 0; z < damageZoneCount; ++z)
|
||||
{
|
||||
@@ -2229,6 +2427,62 @@ void
|
||||
BTRecomputeCondenserValves((Entity *)this);
|
||||
}
|
||||
|
||||
// #137 forensic: sample every heat-bearing subsystem's temperature AT the
|
||||
// reset, so "respawn came back with heat MAXED" can be split into "the
|
||||
// reset did not clear it" vs "it climbs again immediately".
|
||||
{
|
||||
extern void BTReportHeatAtReset(void *mech_v);
|
||||
BTReportHeatAtReset((void *)this);
|
||||
// #137: sample the myomers AT the reset, and arm the POST-reset trace.
|
||||
// Sampling only here is what got this bug wrongly closed -- every
|
||||
// subsystem reads T == start at this instant, which looks innocent.
|
||||
// The freeze shows up in the frames AFTER.
|
||||
extern void BTReportMyomerFreeze(void *mech_v, const char *when);
|
||||
extern int gBTMyoTrace;
|
||||
BTReportMyomerFreeze((void *)this, "at-reset");
|
||||
gBTMyoTrace = 240; // ~4 s of post-reset frames
|
||||
}
|
||||
|
||||
// --- DESKTOP THROTTLE RELEASE (#146) -- PORT LAYER, desktop-only ---------
|
||||
// The pod's throttle is a PHYSICAL lever and the binary deliberately
|
||||
// leaves it alone across a respawn: Reset's subsystem loop starts at
|
||||
// index 2 and the ControlsMapper is index 0, so a pod pilot comes back
|
||||
// under whatever power their hand is still holding. Authentic; it stays.
|
||||
//
|
||||
// The desktop bridge only EMULATES that lever, with the persistent ramp
|
||||
// accumulator sLever below -- and a pad/keyboard pilot is physically
|
||||
// holding nothing, with the lever position invisible to them. So they
|
||||
// respawned at speed for no reason they could see, and the mech earned a
|
||||
// real heat load straight out of the drop zone (myomers -> Condenser5 ->
|
||||
// GeneratorD all tripping Overheating inside 1-2s). That is the true
|
||||
// cause of the "#137 respawn came back with heat MAXED" reports -- the
|
||||
// reset itself was always clean. Field-diagnosed from Sauron's
|
||||
// 2026-08-06 log, which reads thr=1 / cycleSpeed=14.6 AT the reset.
|
||||
//
|
||||
// Reuse the existing all-stop path instead of touching sLever directly:
|
||||
// it already zeroes the lever AND clears the zero-crossing detent, and it
|
||||
// lives inside the virtual-controls block that owns that state.
|
||||
//
|
||||
// LOCAL VIEWPOINT MECH ONLY -- gBTDrive is the local bridge's state and
|
||||
// Reset also runs for replicants, so an ungated write here would all-stop
|
||||
// the player every time a REMOTE mech respawned. (Same guard idiom as
|
||||
// the isPlayerMech test in PerformAndWatch.) Pod-safe besides: with a RIO
|
||||
// present mechmppr's key bridge is off (BTRIODevicePresent, mechmppr.cpp
|
||||
// :672) and gBTDrive.throttle is never read at all.
|
||||
// BT_NO_RESPAWN_THROTTLE_RELEASE=1 reverts.
|
||||
if (application != 0 && (Entity *)this == application->GetViewpointEntity())
|
||||
{
|
||||
static const int s_releaseThrottle =
|
||||
getenv("BT_NO_RESPAWN_THROTTLE_RELEASE") ? 0 : 1;
|
||||
if (s_releaseThrottle)
|
||||
{
|
||||
gBTDrive.allStop = 1;
|
||||
if (getenv("BT_DEATH_LOG"))
|
||||
DEBUG_STREAM << "[respawn] desktop throttle released (all-stop queued)\n"
|
||||
<< std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
// --- locomotion pre-run + interest gates (a reset master must tick) ---
|
||||
SetPreRunFlag();
|
||||
if (interestCount == 0) interestCount = 1;
|
||||
@@ -2397,6 +2651,14 @@ void
|
||||
// (wreck shape: alarms/state settle, ammo
|
||||
// bins do NOT refill the corpse)
|
||||
}
|
||||
// #137 forensic: roster heat state right AFTER the death shutdown sweep --
|
||||
// whatever is still hot here is what the wreck period starts from, and a
|
||||
// member that stays hot through BOTH sweeps is the conduction source that
|
||||
// cooks the fresh myomers after the respawn.
|
||||
{
|
||||
extern void BTReportMyomerFreeze(void *mech_v, const char *when);
|
||||
BTReportMyomerFreeze((void *)this, "at-death");
|
||||
}
|
||||
// Request the DEATH record BEFORE entering the disabled state (the
|
||||
// ForceUpdate filter masks types 2..8 once IsDisabled) -- the binary
|
||||
// death sender is Force(1) + Force(0x40) (@0x4aab2f/@0x4aab3a). The
|
||||
@@ -2512,6 +2774,27 @@ volatile float gBTReplRenderYaw = -999.0f;
|
||||
void
|
||||
Mech::PerformAndWatch(const Time& till, MemoryStream *update_stream)
|
||||
{
|
||||
// #148 probe: one-shot per mech, the FIRST time this mech's per-frame
|
||||
// performance runs. Every other receipt in this file is anonymous, so
|
||||
// master and replicant lines are indistinguishable in a 2-node log -- which
|
||||
// is exactly what made the "when does the peer torso start ticking?" search
|
||||
// go in circles. Name the mech.
|
||||
if (getenv("BT_NET_TRACE"))
|
||||
{
|
||||
static const Mech *s_seen[16]; static int s_seenN = 0;
|
||||
int known = 0;
|
||||
for (int si = 0; si < s_seenN; ++si) if (s_seen[si] == this) { known = 1; break; }
|
||||
if (!known && s_seenN < 16)
|
||||
{
|
||||
s_seen[s_seenN++] = this;
|
||||
DEBUG_STREAM << "[perf-first] mech " << GetEntityID()
|
||||
<< " instance=" << (GetInstance() == Entity::ReplicantInstance
|
||||
? "REPLICANT" : "master")
|
||||
<< " this=" << (const void *)this
|
||||
<< " subsysCount=" << subsystemCount << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
// Frame time slice from the simulation clock (same idiom as Mover::Perform).
|
||||
Scalar dt = till - lastPerformance;
|
||||
lastPerformance = till;
|
||||
@@ -3311,9 +3594,23 @@ void
|
||||
sEjAt = (e && *e) ? atoi(e) : -1;
|
||||
}
|
||||
++sEjFrame;
|
||||
// Receipt: five separate rigs failed to fire a punch-out
|
||||
// and it was never established whether this hook is even
|
||||
// REACHED. Announce once a second while armed.
|
||||
if (sEjAt > 0)
|
||||
{
|
||||
static int sEjLog = 0;
|
||||
if ((++sEjLog % 60) == 0)
|
||||
DEBUG_STREAM << "[ejecttest] armed at " << sEjAt
|
||||
<< ", frame " << sEjFrame << "\n" << std::flush;
|
||||
}
|
||||
if (sEjAt > 0 && sEjFrame >= sEjAt
|
||||
&& ((sEjFrame - sEjAt) % 300) == 0)
|
||||
{
|
||||
ejectPress = 1;
|
||||
DEBUG_STREAM << "[ejecttest] FIRING punch-out at frame "
|
||||
<< sEjFrame << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
if (ejectPress)
|
||||
{
|
||||
@@ -4406,9 +4703,60 @@ void
|
||||
// stability, so peers pose the squat for free) and flip the
|
||||
// stability alarm (ducked = 0, risen = 1). The request is
|
||||
// consumed whenever both gates passed, hit or miss.
|
||||
if (duckState != 0 && squatCapable != 0)
|
||||
// #142 RESTRUCTURE 2026-08-07 -- duckState is the POSTURE, not a
|
||||
// one-shot request, because the COCKPIT ANIMATION reads it.
|
||||
//
|
||||
// L4GAUGE.CFG:5001 authors
|
||||
// oneOfSeveralPixInt(E,ModeAlwaysActive,bduck.pcc,3,1,DuckState)
|
||||
// -- a 3-frame mech-symbol strip beside the CROUCH button, indexed by
|
||||
// attribute 0x37 (= duckState). Asset (content/GAUGE/BDUCK.PCC),
|
||||
// element (OneOfSeveralPixInt @004c5204) and factory registration
|
||||
// (btl4grnd.cpp) are all present. The animation never played because
|
||||
// the old consumer zeroed duckState the frame after the press, in
|
||||
// BOTH directions -- so the strip sat on frame 0 with a one-frame
|
||||
// blip to frame 1. That is the field report verbatim: "button
|
||||
// flickers sometimes on press ... state does not change".
|
||||
//
|
||||
// The binary does NOT zero it per frame. Every writer of +0x398 in
|
||||
// the export is: the DuckRequest handler (=1) and Mech::Reset (=0).
|
||||
// The master perf FUN_004a9b5c -- which contains the address the old
|
||||
// comment cited as "the DuckRequest consumer (@0x4aa011)" -- does not
|
||||
// reference 0x398 anywhere, and mech.hpp's own note already said
|
||||
// "duckState has NO code reader anywhere in the decomp ... whatever
|
||||
// consumes it consumes it through DATABINDING". The databinding
|
||||
// consumer is this gauge strip. The per-frame zeroing was ours.
|
||||
//
|
||||
// So: drive on DESIRED vs ACTUAL instead of on a latch. duckState is
|
||||
// the desired posture (the handler now toggles it); the parked leg
|
||||
// alarm is the actual one. Act only on a mismatch -- no re-fire, and
|
||||
// nothing clears the attribute behind the gauge's back. A frame
|
||||
// where mapPosture is not ready RETRIES next frame instead of
|
||||
// silently dropping the request, which also retires the old
|
||||
// "request consumed, posture=N" miss.
|
||||
// THE POSTURE MACHINE (#142). duckState is the cockpit strip's frame:
|
||||
// 0 = standing 1 = moving between 2 = crouched
|
||||
// The handler writes 1 (the binary's exact behaviour) meaning "a move is
|
||||
// pending", which doubles as the middle frame. Direction comes from the
|
||||
// parked leg alarm, so no separate request cell is needed:
|
||||
// parked -> the pending move is a RISE
|
||||
// !parked -> the pending move is a SQUAT
|
||||
// While the clip runs we hold 1; when it settles we write 0 or 2.
|
||||
// Read the ALARM, not the cached legAnimationState member: the cache
|
||||
// is only refreshed at the top of AdvanceLegAnimation, so in the
|
||||
// frame right after SetLegAnimation it still reads the OLD state.
|
||||
// With the cached read, "am I already moving?" answered no on the
|
||||
// frame after issuing, the consumer re-issued, and the machine
|
||||
// ping-ponged squat/rise -- 68 transitions from 2 presses, benched.
|
||||
// SetLegAnimation writes the alarm synchronously, so the alarm is
|
||||
// true the instant the clip is armed.
|
||||
const int duckLegLvl = (int)legStateAlarm.GetLevel();
|
||||
const int duckParked = (duckLegLvl == 1);
|
||||
const int duckMoving = (duckLegLvl == 2 || duckLegLvl == 3); // 'sqd' / 'squ'
|
||||
|
||||
if (duckRequest != 0 && !duckMoving && squatCapable != 0)
|
||||
{
|
||||
if (mapPosture == 1)
|
||||
duckRequest = 0; // one shot, whatever happens
|
||||
if (!duckParked && mapPosture == 1)
|
||||
{
|
||||
SetLegAnimation(2); // 'sqd' -- squat down
|
||||
ForceUpdate(8);
|
||||
@@ -4417,7 +4765,7 @@ void
|
||||
if (getenv("BT_DUCK_LOG") || getenv("BT_GAIT_LOG"))
|
||||
DEBUG_STREAM << "[duck] SQUAT (posture 1 -> leg clip 2)\n" << std::flush;
|
||||
}
|
||||
else if (mapPosture == 2)
|
||||
else if (duckParked && mapPosture == 2)
|
||||
{
|
||||
SetLegAnimation(3); // 'squ' -- rise
|
||||
ForceUpdate(8);
|
||||
@@ -4426,14 +4774,76 @@ void
|
||||
if (getenv("BT_DUCK_LOG") || getenv("BT_GAIT_LOG"))
|
||||
DEBUG_STREAM << "[duck] RISE (posture 2 -> leg clip 3)\n" << std::flush;
|
||||
}
|
||||
else if (getenv("BT_DUCK_LOG"))
|
||||
DEBUG_STREAM << "[duck] request consumed, posture=" << mapPosture
|
||||
<< " (mode=" << MovementMode()
|
||||
<< " legLvl=" << (int)legStateAlarm.GetLevel()
|
||||
<< " simLive=" << 1 // re-read below costs a bridge call; posture already folded it
|
||||
<< " myo=" << myomerEffectiveness
|
||||
<< " squat=" << squatCapable << ")\n" << std::flush;
|
||||
duckState = 0; // consumed (@0x4aa0a9)
|
||||
else
|
||||
{
|
||||
// Gate refuses -- posture reads 0 for a MOVING mech, which is the
|
||||
// authentic rule (Lynx: "when a mech STOPS, crouch button lowers
|
||||
// its stance"; benched: crouch at a walk gives posture=0). Settle
|
||||
// the strip back to the truth instead of holding the mid frame or
|
||||
// queueing the request for the next time the pilot stops.
|
||||
static int s_duckRefuse = 0;
|
||||
if ((s_duckRefuse++ % 30) == 0)
|
||||
DEBUG_STREAM << "[duck] REFUSED (not stopped): posture="
|
||||
<< mapPosture << " mode=" << MovementMode()
|
||||
<< " myo=" << myomerEffectiveness << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
// SETTLE, unconditionally, re-reading the alarm AFTER any issue
|
||||
// above. This is the part that was missing: duckState stayed 1
|
||||
// forever, so once a clip completed the block above kept firing and
|
||||
// flipped direction every frame -- 68 transitions from 2 presses.
|
||||
// The squat/rise clips finish fast enough that the alarm is already
|
||||
// back to 0/1 by the next visit, so "am I still moving?" has to be
|
||||
// asked fresh, and the strip settled whenever the answer is no.
|
||||
{
|
||||
// SETTLE to the binary's VALUE RANGE: duckState is 0 or 1, never
|
||||
// 2. Corrected 2026-08-07 after checking FUN_004a9b5c -- the
|
||||
// mech4 master perf is fully indexed and exported (5645 bytes,
|
||||
// no dark region inside) and never references +0x398. So the
|
||||
// binary has NO code reader of duckState at all: the gauge
|
||||
// attribute is the only consumer, and the only writers are the
|
||||
// handler (=1) and Mech::Reset (=0). Frame 2 of bduck.pcc was
|
||||
// unreachable in the original too.
|
||||
//
|
||||
// The strip is therefore a TWO-STATE indicator -- standing and
|
||||
// crouched -- and the "crouch animation" is the MECH's 'sqd'
|
||||
// clip, not the symbol stepping. An earlier revision here made
|
||||
// duckState a 3-state posture (0/1/2); that was an invention on
|
||||
// top of a stand-in and it put the crouched pose on the wrong
|
||||
// frame.
|
||||
// #142: report EVERY change of the value the cockpit strip is fed,
|
||||
// so 'which frames actually got shown, and for how long' stops
|
||||
// being guesswork. Ungated: one line per posture change.
|
||||
static int s_lastDuck = -1;
|
||||
// THE STRIP IS A BUTTON-STATE INDICATOR, not a pose animation.
|
||||
// Decoded 2026-08-07 by RENDERING THE ASSET itself
|
||||
// (content/GAUGE/BDUCK.PCC -- PCX, 108x102, three 36x102 frames;
|
||||
// see scratchpad/night13/bduck_frames.png):
|
||||
//
|
||||
// frame 0 GREY mech standing, GREY down-arrow -- UNAVAILABLE
|
||||
// frame 1 ORANGE standing, YELLOW down -- ready to crouch
|
||||
// frame 2 ORANGE CROUCHED, YELLOW up -- press to rise
|
||||
//
|
||||
// There is NO mid-transition pose. The arrow tells the pilot
|
||||
// what the next press will do, and the grey frame says the
|
||||
// button is inert -- which is the visual half of the
|
||||
// must-be-stopped rule (Lynx: "when a mech STOPS").
|
||||
//
|
||||
// Two earlier readings were wrong: a 2-state flag (which
|
||||
// ignores frame 2's up-arrow entirely) and a stand/moving/
|
||||
// crouched pose animation (there is no mid pose). Both were
|
||||
// inferred from code and logs; only the ART settled it, and it
|
||||
// also explains the field report -- a stopped mech that COULD
|
||||
// crouch was drawing the grey "unavailable" frame.
|
||||
const int lvlNow = (int)legStateAlarm.GetLevel();
|
||||
if (lvlNow != 2 && lvlNow != 3) // hold through the clip
|
||||
{
|
||||
if (lvlNow == 1) duckState = 2; // crouched
|
||||
else if (squatCapable != 0 && mapPosture == 1)
|
||||
duckState = 1; // ready
|
||||
else duckState = 0; // inert
|
||||
}
|
||||
}
|
||||
|
||||
// (3b) AIRBORNE AUTO-RISE -- recovered 2026-08-06 by the #60
|
||||
@@ -6091,7 +6501,21 @@ void
|
||||
// clamp(true - shown, +-dt*500) -- so the caret sweeps smoothly as
|
||||
// the boresight crosses near/far ground instead of teleporting.
|
||||
// (Applies to the no-target 1200 default too.)
|
||||
static float sShownRange = 1200.0f;
|
||||
// #147: sShownRange is DISPLAY state, but it is a function-level
|
||||
// static -- one cell for the whole process, shared by every mech and
|
||||
// carried across drops. Re-seed it whenever the viewpoint mech
|
||||
// CHANGES (a new drop hands us a new entity) so a fresh drop starts
|
||||
// at the binary's 1200 default instead of inheriting the last
|
||||
// mission's slid value. A respawn REUSES the entity (Mech::Reset
|
||||
// heals in place), so this deliberately does not fire there -- the
|
||||
// binary does not reset the readout on respawn either.
|
||||
static float sShownRange = 1200.0f;
|
||||
static const void *sShownOwner = 0;
|
||||
if (sShownOwner != (const void *)this)
|
||||
{
|
||||
sShownOwner = (const void *)this;
|
||||
sShownRange = 1200.0f;
|
||||
}
|
||||
float trueRange = 1200.0f; // no target: the binary default
|
||||
Entity *des = MECH_TARGET_ENTITY(this);
|
||||
if (des != 0 && des != hotTarget)
|
||||
@@ -6148,6 +6572,26 @@ void
|
||||
|
||||
// the 500 m/s slide toward trueRange (see the banner above)
|
||||
{
|
||||
// #147 NaN TRAP. NaN is ABSORBING here and the clamps below
|
||||
// cannot catch it: `step > maxStep` and `step < -maxStep` are
|
||||
// BOTH false for NaN, so a single poisoned frame makes
|
||||
// sShownRange NaN and it stays NaN for the life of the process
|
||||
// (the static is never re-seeded except on a mech change).
|
||||
// Downstream, BTReticleRenderable::Draw clamps the same way, so
|
||||
// the NaN reaches AddPoint/ConcatMatrix and the caret + its bar
|
||||
// render as degenerate geometry -- i.e. they VANISH while every
|
||||
// static reticle element (the tick marks) still draws. That is
|
||||
// exactly the reported "no range finder on this drop: ticks
|
||||
// there, moving caret gone". Re-seed instead of propagating.
|
||||
if (!(trueRange == trueRange) || !(sShownRange == sShownRange))
|
||||
{
|
||||
if (getenv("BT_RANGE_LOG"))
|
||||
DEBUG_STREAM << "[range] NaN TRAPPED (true=" << trueRange
|
||||
<< " shown=" << sShownRange << ") -- re-seeded to 1200\n"
|
||||
<< std::flush;
|
||||
trueRange = 1200.0f;
|
||||
sShownRange = 1200.0f;
|
||||
}
|
||||
float maxStep = (float)dt * 500.0f;
|
||||
if (maxStep < 0.0f) maxStep = -maxStep;
|
||||
float step = trueRange - sShownRange;
|
||||
@@ -6155,6 +6599,22 @@ void
|
||||
if (step < -maxStep) step = -maxStep;
|
||||
sShownRange += step;
|
||||
BTSetHudTargetRange((Scalar)sShownRange);
|
||||
// The caret's ACTUAL input had NO diagnostic anywhere: BT_RANGE_LOG
|
||||
// instruments the PICK (#4), and [target]'s `range=` is a separate
|
||||
// locally-recomputed Sqrt in the weapon-range check -- so a field
|
||||
// log could neither confirm nor refute a dead caret. Fixed.
|
||||
if (getenv("BT_RANGE_LOG"))
|
||||
{
|
||||
static float sRlog = 0.0f;
|
||||
sRlog += (float)dt;
|
||||
if (sRlog >= 1.0f)
|
||||
{
|
||||
sRlog = 0.0f;
|
||||
DEBUG_STREAM << "[range] caret input shown=" << sShownRange
|
||||
<< " true=" << trueRange << " lock=" << gBTHudLockState
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BT_RANGE_LOG (Gitea #4 VERDICT instrumentation -- uncommitted diag):
|
||||
@@ -6666,6 +7126,39 @@ void
|
||||
gBTGenSelKey = pulse ? s_gsID : 0;
|
||||
}
|
||||
|
||||
// TIMEOUT PROBE (BT_PROJ_TEST=1, off by default; the #168 bench). At
|
||||
// t=+5s push two DAMAGE-0 rounds aimed at a point 40000u overhead --
|
||||
// unreachable at speed 400, no target, no terrain in the path -- so
|
||||
// each MUST end in the [projectile] FIZZLE receipt at exactly its
|
||||
// lifetime cap: unguided ttl=5.0 (@004bddec), guided burn=2 ->
|
||||
// ttl=12.0 (@004bef78). The 2-node rig proves real flights never
|
||||
// fizzle EARLY; this proves the timeout itself fires at the authentic
|
||||
// second.
|
||||
{
|
||||
static int s_ptArmed = -1;
|
||||
static float s_ptClock = 0.0f;
|
||||
if (s_ptArmed < 0)
|
||||
s_ptArmed = (getenv("BT_PROJ_TEST") != 0) ? 1 : 0;
|
||||
if (s_ptArmed == 1
|
||||
&& (Entity *)this == application->GetViewpointEntity())
|
||||
{
|
||||
s_ptClock += dt;
|
||||
if (s_ptClock >= 5.0f)
|
||||
{
|
||||
s_ptArmed = 2; // once per process
|
||||
extern void BTPushProjectile(const Point3D &, void *, void *,
|
||||
const Point3D &, Scalar, Scalar, const Vector3D *, int,
|
||||
int, int, int, int, Scalar, Scalar, int);
|
||||
Point3D mzp = localOrigin.linearPosition; mzp.y += 14.0f;
|
||||
Point3D upp = mzp; upp.y += 40000.0f;
|
||||
BTPushProjectile(mzp, this, 0, upp, 400.0f, 0.0f, 0, 0,
|
||||
-1, 0, -1, 2, 0.0f, 0.0f, 0);
|
||||
BTPushProjectile(mzp, this, 0, upp, 400.0f, 0.0f, 0, 1,
|
||||
-1, 0, -1, 2, 0.0f, 2.0f, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SELF-DAMAGE HARNESS (BT_SELF_DAMAGE=<amount per second>, off by default).
|
||||
// The whole RESPAWN family -- torso twist after respawn (#70), ammo/weapon
|
||||
// reset (#22), coolant/heat/generator restore (#55), the respawn latch
|
||||
@@ -7581,7 +8074,33 @@ void
|
||||
continue;
|
||||
if (i != 0) // slot 0 = the mapper (task #7)
|
||||
++subsystemsPresent;
|
||||
if (!subsystem->IsNonReplicantExecutable())
|
||||
// #148 -- THE INSTANCE BRANCH. Entity::Perform (ENTITY.cpp:733-793,
|
||||
// real engine source [T0]) picks the predicate by instance:
|
||||
// if (GetInstance() != ReplicantInstance) IsNonReplicantExecutable()
|
||||
// else IsReplicantExecutable()
|
||||
// and they differ exactly on the replicant case (SIMULATE.h:195-206):
|
||||
// NonReplicant: (flags & DontExecuteFlag) == 0
|
||||
// Replicant : (flags & DontExecuteFlag) == 0
|
||||
// || lastUpdate >= lastPerformance
|
||||
// `ExecuteOnUpdate()` SETS DontExecuteFlag -- it means "do not tick me
|
||||
// every frame, tick me when an UPDATE ARRIVES". This loop used the
|
||||
// NonReplicant predicate for every mech, so on a REPLICANT any
|
||||
// ExecuteOnUpdate subsystem never ran at all, no matter how many
|
||||
// records arrived for it.
|
||||
//
|
||||
// Measured (scratchpad/night13/missileframe2.sh): the peer's copy
|
||||
// TORSO received its first record at log line 205 but its Performance
|
||||
// did not run until line 1014 -- ~800 lines of arriving twist data
|
||||
// integrated by nobody, so the peer's torso sat at 0 and its missiles
|
||||
// launched along the body facing (the tail of #141). The records
|
||||
// themselves were fine: they are sent on RATE CHANGE (the sweep's
|
||||
// direction flips -- atUpd +/-2.39 with rate flipping sign), and the
|
||||
// peer dead-reckons `atUpd + rate * elapsed` between them.
|
||||
const Logical execOK =
|
||||
(GetInstance() != Entity::ReplicantInstance)
|
||||
? subsystem->IsNonReplicantExecutable()
|
||||
: subsystem->IsReplicantExecutable();
|
||||
if (!execOK)
|
||||
continue;
|
||||
|
||||
// The controls-mapping subsystem (roster slot 0 via Mech::SetMapping
|
||||
@@ -8054,15 +8573,31 @@ void
|
||||
fallScalar = -(fallDirection.x * impactVel.x
|
||||
+ fallDirection.y * impactVel.y
|
||||
+ fallDirection.z * impactVel.z);
|
||||
// ALWAYS-log the crash pricing (rare event; the arbiter for the
|
||||
// wall-damage economy -- run 14 field report: "tapped a wall and
|
||||
// died instantly", so the forwarded amount is suspect vs binary).
|
||||
DEBUG_STREAM << "[crashdmg] amount=" << dmg.damageAmount
|
||||
<< " mass=" << moverMass
|
||||
<< " |v|=" << (float)Sqrt(impactVel.x * impactVel.x
|
||||
+ impactVel.y * impactVel.y + impactVel.z * impactVel.z)
|
||||
<< " force=(" << dmg.damageForce.x << "," << dmg.damageForce.y
|
||||
<< "," << dmg.damageForce.z << ")\n" << std::flush;
|
||||
// Log the crash pricing -- but SAMPLED for scrape taps (#128 field
|
||||
// measurement: sustained scrapes re-price per frame, 25-82 ev/s,
|
||||
// up to 30k lines/night = ~20% of a field log; every one of those
|
||||
// taps is ~50x under the distributor's 0.5-pt free floor, so the
|
||||
// per-event detail is noise). Every raw amount >= 1000 (the ones
|
||||
// that can matter downstream) still logs unconditionally; the
|
||||
// sub-floor spam logs 1-in-60 with a suppressed-count so scrape
|
||||
// cadence stays measurable from the receipts.
|
||||
{
|
||||
static int sTapSuppressed = 0;
|
||||
int noteworthy = (dmg.damageAmount >= 1000.0f);
|
||||
if (!noteworthy)
|
||||
++sTapSuppressed;
|
||||
if (noteworthy || (sTapSuppressed % 60) == 1)
|
||||
{
|
||||
DEBUG_STREAM << "[crashdmg] amount=" << dmg.damageAmount
|
||||
<< " mass=" << moverMass
|
||||
<< " |v|=" << (float)Sqrt(impactVel.x * impactVel.x
|
||||
+ impactVel.y * impactVel.y + impactVel.z * impactVel.z)
|
||||
<< " force=(" << dmg.damageForce.x << "," << dmg.damageForce.y
|
||||
<< "," << dmg.damageForce.z << ")"
|
||||
<< (noteworthy ? "" : " (scrape sample; taps suppressed)")
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
Entity::TakeDamageMessage td(
|
||||
Entity::TakeDamageMessageID,
|
||||
sizeof(Entity::TakeDamageMessage),
|
||||
@@ -8561,9 +9096,24 @@ void
|
||||
}
|
||||
if (s_portCache[energyOrdinal] != 0)
|
||||
{
|
||||
AffineMatrix mw;
|
||||
mw.Multiply(s_portCache[energyOrdinal]->GetSegmentToEntity(),
|
||||
localToWorld);
|
||||
// #141 sweep: engine accessor (== the binary's FUN_00424da8).
|
||||
// This is the BEAM muzzle -- the same stale-cache exposure the
|
||||
// missile launch had, so a peer's beam would also originate
|
||||
// from the bind-pose gun port instead of the twisted torso.
|
||||
// #149 A/B (BT_BEAM_SEGFRESH=0): revert THIS site to the
|
||||
// pre-sweep plain compose, to measure whether the per-beam
|
||||
// per-frame dirty-pass is the 857 draw-stall regression.
|
||||
// This site runs inside the DRAW path per emitter per frame;
|
||||
// the other swept sites are per-salvo/per-hit and cannot be
|
||||
// a per-frame cost. Default = fresh (the swept behaviour).
|
||||
static const int sBeamFresh =
|
||||
!(getenv("BT_BEAM_SEGFRESH") && *getenv("BT_BEAM_SEGFRESH") == '0');
|
||||
LinearMatrix mw;
|
||||
if (sBeamFresh)
|
||||
GetSegmentToWorld(*s_portCache[energyOrdinal], &mw);
|
||||
else
|
||||
mw.Multiply(s_portCache[energyOrdinal]->GetSegmentToEntity(),
|
||||
localToWorld);
|
||||
mz = mw; // Point3D = matrix translation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1255,26 +1255,50 @@ void
|
||||
+ fabsf((float)owner->bodyCycleSpeed);
|
||||
const int movingNoLegs = (step > 0.08f && step < 5.0f // 5+: teleport/warp
|
||||
&& cyc < 0.05f) ? 1 : 0;
|
||||
// #52 A/B probe (BT_BODY_SM_LOG): the POSITIVE half of the Standing-lock
|
||||
// evidence. The [skate] line only speaks once a lock has ALSO produced
|
||||
// 90 sustained moving frames; this says every second what a moving peer's
|
||||
// body channel is actually doing. Locked: bstate=0, bodyCyc=0. Healthy:
|
||||
// bstate in the walk/run family with a live cycle.
|
||||
if (step > 0.08f && step < 5.0f && getenv("BT_BODY_SM_LOG"))
|
||||
{
|
||||
static float s_pg = 0.0f; s_pg += 1.0f / 30.0f;
|
||||
if (s_pg >= 1.0f) { s_pg = 0.0f;
|
||||
DEBUG_STREAM << "[peergait] replicant " << owner->GetEntityID()
|
||||
<< " step=" << step
|
||||
<< " bstate=" << (int)owner->bodyAnimationState
|
||||
<< " bodyCyc=" << (float)owner->bodyCycleSpeed
|
||||
<< " bts=" << (float)owner->bodyTargetSpeed
|
||||
<< (movingNoLegs ? " <-- IDLE CHANNELS" : "")
|
||||
<< "\n" << std::flush; }
|
||||
}
|
||||
if (movingNoLegs)
|
||||
{
|
||||
if (++skateFrames > 90 && !skateLogged)
|
||||
{
|
||||
skateLogged = 1;
|
||||
// bstate = the peer's BODY animation state (@0x728), the channel
|
||||
// that poses a replicant (s_peerLegCh=0). Added 2026-08-07 after
|
||||
// night 13: the field lines proved "moving with both channels
|
||||
// idle" but not WHICH state it was idling in, and the answer
|
||||
// (0 = Standing, pinned) is the whole diagnosis -- see #52.
|
||||
DEBUG_STREAM << "[skate] replicant " << owner->GetEntityID()
|
||||
<< " SKATING: " << skateFrames << " frames moving ("
|
||||
<< step << " u/frame) with legCyc="
|
||||
<< (float)owner->legCycleSpeed
|
||||
<< " bodyCyc=" << (float)owner->bodyCycleSpeed
|
||||
<< " bodyTargetSpeed=" << (float)owner->bodyTargetSpeed
|
||||
<< " bstate=" << (int)owner->bodyAnimationState
|
||||
<< " destroyed=" << (int)owner->IsMechDestroyed()
|
||||
<< " mode=" << (int)owner->MovementMode()
|
||||
<< " at (" << px << "," << pz << ")\n" << std::flush;
|
||||
if (BTMatchLogActive())
|
||||
BTMatchLog("SKATE", "mech=%d:%d frames=%d step=%.3f cyc=%.3f "
|
||||
"cmdSpd=%.2f destroyed=%d mode=%d x=%.1f z=%.1f",
|
||||
"cmdSpd=%.2f bstate=%d destroyed=%d mode=%d x=%.1f z=%.1f",
|
||||
BTMatchHostOf(owner->GetEntityID()), (int)owner->GetEntityID(),
|
||||
skateFrames, step, cyc,
|
||||
(float)owner->bodyTargetSpeed,
|
||||
(int)owner->bodyAnimationState,
|
||||
(int)owner->IsMechDestroyed(), (int)owner->MovementMode(),
|
||||
px, pz);
|
||||
}
|
||||
@@ -1334,6 +1358,31 @@ void
|
||||
const bool nowDead = (mode == 2 || mode == 9);
|
||||
const bool prevDead = (oldMode == 2 || oldMode == 9);
|
||||
prevMode = mode;
|
||||
// #108 GHOST CENSUS, the ENTER edge (ungated, 2026-08-07). The
|
||||
// un-wreck receipt below has had no partner, so counting ghosts in a
|
||||
// field log meant pairing it against
|
||||
// "[BTrender] wreck: 'thrdbr.bgf' missing -> gendbr.bgf fallback" --
|
||||
// which is a MISSING-ASSET WARNING, not a death: it only prints for
|
||||
// chassis whose wreck model is absent. Night 13's census therefore
|
||||
// found ONE ghost while testers reported many, and there was no way to
|
||||
// tell a real count from a chassis accident. This line is emitted for
|
||||
// EVERY replicant that enters the wreck state, so a log's ghost count
|
||||
// is exactly (wreck-enters minus un-wrecks) per entity.
|
||||
if (!prevDead && nowDead
|
||||
&& owner->GetInstance() == Entity::ReplicantInstance)
|
||||
{
|
||||
DEBUG_STREAM << "[wreck] replicant " << owner->GetEntityID()
|
||||
<< " entered wreck state (mode "
|
||||
<< oldMode << "->" << mode << ") at ("
|
||||
<< owner->localOrigin.linearPosition.x << ","
|
||||
<< owner->localOrigin.linearPosition.z << ")\n" << std::flush;
|
||||
if (BTMatchLogActive())
|
||||
BTMatchLog("WRECK", "mech=%d:%d mode=%d->%d x=%.1f z=%.1f",
|
||||
BTMatchHostOf(owner->GetEntityID()), (int)owner->GetEntityID(),
|
||||
oldMode, mode,
|
||||
(float)owner->localOrigin.linearPosition.x,
|
||||
(float)owner->localOrigin.linearPosition.z);
|
||||
}
|
||||
if (prevDead && !nowDead
|
||||
&& owner->GetInstance() == Entity::ReplicantInstance)
|
||||
{
|
||||
|
||||
+128
-27
@@ -508,25 +508,69 @@ void
|
||||
}
|
||||
NotifyOfControlModeChange(controlMode); // vtable+0x48
|
||||
|
||||
// TYPED torso reconfiguration (2026-07-13): the raw block this
|
||||
// replaces wrote the BINARY's offsets (torso+0x1f0/0x274/0x220...)
|
||||
// straight onto OUR compiled Torso -- the databinding trap: garbage
|
||||
// writes into whatever members live there in this build. The
|
||||
// observable semantics via named members: Basic clears the analog
|
||||
// axes and recenters (the sim's centerCommand -> Recenter); the
|
||||
// assisted modes just free the torso (the sim clamps to the authored
|
||||
// limits on its own).
|
||||
// TYPED torso reconfiguration. The raw block this replaces wrote the
|
||||
// BINARY's offsets straight onto OUR compiled Torso (the databinding
|
||||
// trap); the typed rewrite that followed then got the SEMANTICS wrong in
|
||||
// three ways. Corrected 2026-08-08 against @004afbe0, which is a
|
||||
// complete spec:
|
||||
//
|
||||
// iVar1 = mech+0x438 (TORSO) iVar2 = mech+0x5b4 (HUD)
|
||||
// if (mode == 0) { // BASIC
|
||||
// *(iVar1 + 0x1f0) = 0; // analogTwistAxis
|
||||
// *(iVar1 + 0x274) = 1; // recenterActive
|
||||
// *(iVar1 + 0x220) = *(iVar1 + 0x228); // vertLimitTop
|
||||
// *(iVar1 + 0x224) = *(iVar1 + 0x22c); // vertLimitBottom
|
||||
// *(iVar2 + 0x2a0) = 1; // HUD flickerActive
|
||||
// } else if (mode - 1U < 2) { // STANDARD/VETERAN
|
||||
// *(iVar1 + 0x220) = *(iVar1 + 0x230);
|
||||
// *(iVar1 + 0x224) = *(iVar1 + 0x234);
|
||||
// }
|
||||
//
|
||||
// (1) THE BUG Sauron hit. Basic set `centerCommand` (@0x208) via
|
||||
// CommandRecenter(). That is the HELD-BUTTON cell: TorsoSimulation
|
||||
// re-arms recenterActive from it EVERY frame it is non-zero, and only
|
||||
// the input path clears it -- and a MODE SWITCH has no button release
|
||||
// to follow. So one visit to Basic pinned it at 1 forever, the torso
|
||||
// re-centred every frame, and the digital twist commands (processed
|
||||
// BEFORE the centerCommand block) were overridden as fast as they were
|
||||
// applied. Cycling Standard -> Veteran -> (wraps through BASIC) ->
|
||||
// Standard is enough to trigger it, which is exactly the reported
|
||||
// "toggled to advanced and back, lost torso control". The binary sets
|
||||
// recenterActive (@0x274) directly: a ONE-SHOT that self-clears on
|
||||
// settle (`recenterActive = Recenter(dt)`) and is cancelled by any
|
||||
// twist input.
|
||||
// (2) The ELEVATION LIMIT SWAP was missing entirely. Two authored pairs
|
||||
// exist -- BASIC @0x228/@0x22C (full top, HALF bottom) vs
|
||||
// STANDARD/VETERAN @0x230/@0x234 (the full pair) -- and all four were
|
||||
// ctor-written and never read by anything. So Basic never restricted
|
||||
// downward travel and the assisted modes never restored it.
|
||||
// (3) Basic also raises the HUD's flickerActive (@0x2A0) so the horizon
|
||||
// re-settles with the torso. Not ported.
|
||||
// Also: the binary zeroes ONLY analogTwistAxis (@0x1F0). The extra
|
||||
// SetAnalogElevationAxis(0) was invented; removed.
|
||||
Mech *mech = GetMech();
|
||||
Torso *torso = (mech != 0) ? (Torso *)mech->GetTorsoSubsystem() : 0;
|
||||
if (torso != 0)
|
||||
{
|
||||
if (controlMode == BasicMode)
|
||||
{
|
||||
torso->SetAnalogTwistAxis(0.0f);
|
||||
torso->SetAnalogElevationAxis(0.0f);
|
||||
torso->CommandRecenter();
|
||||
torso->SetAnalogTwistAxis(0.0f); // @0x1F0
|
||||
// BT_LEGACY_MODE_RECENTER=1 restores the defective pre-2026-08-08
|
||||
// behaviour (the sticky centerCommand) for A/B measurement.
|
||||
static const int s_legacyRecenter =
|
||||
getenv("BT_LEGACY_MODE_RECENTER") ? 1 : 0;
|
||||
if (s_legacyRecenter)
|
||||
torso->CommandRecenter(); // @0x208 STICKY -- the bug
|
||||
else
|
||||
torso->BeginRecenterOnce(); // @0x274 (NOT centerCommand)
|
||||
torso->ApplyBasicElevationLimits(); // @0x220/@0x224 <- @0x228/@0x22C
|
||||
extern void BTSetHudFlickerActive(Subsystem *hud);
|
||||
BTSetHudFlickerActive(mech->GetHudSubsystem()); // HUD @0x2A0 = 1
|
||||
}
|
||||
else // StandardMode / VeteranMode -- `mode - 1U < 2` in the binary
|
||||
{
|
||||
torso->ApplyAssistedElevationLimits(); // @0x220/@0x224 <- @0x230/@0x234
|
||||
}
|
||||
// Standard/Veteran: nothing to force -- the sim's limits govern.
|
||||
}
|
||||
DEBUG_STREAM << "[mode] control mode -> " << (int)controlMode
|
||||
<< " (0=Basic 1=Standard 2=Veteran)" << std::endl;
|
||||
@@ -660,6 +704,58 @@ void
|
||||
// after the push, immediately before interpretation -- making the keyboard
|
||||
// authoritative on the dev box. Interpretation below stays 100% authentic.
|
||||
//
|
||||
// BENCH (BT_MODECYCLE_EVERY=<n>): cycle the control mode every n
|
||||
// InterpretControls calls, driving the SAME body the 'M' key and the pod
|
||||
// console button (key 0x13d -- not a RIO button, so BT_BTNTEST cannot press
|
||||
// it) drive. Dev-only; default off.
|
||||
//
|
||||
// ⚠ DELIBERATELY OUTSIDE the key-bridge block below. The ONLY caller of
|
||||
// ClearRecenterCommand() lives inside that block, so forcing BT_KEY_BRIDGE=1
|
||||
// to make this hook run would ALSO switch on the one thing that clears
|
||||
// centerCommand -- masking the very bug under test. That is exactly how the
|
||||
// first run of modecycle.sh came back clean. Keeping the hook out here lets
|
||||
// the bench reproduce the RIO-present (glass/PadRIO) configuration, where the
|
||||
// bridge is OFF and nothing clears the cell.
|
||||
{
|
||||
static const char *s_mcEvery = getenv("BT_MODECYCLE_EVERY");
|
||||
if (s_mcEvery != 0)
|
||||
{
|
||||
static int s_mcN = 0;
|
||||
int period = atoi(s_mcEvery);
|
||||
if (period < 1) period = 300;
|
||||
if (++s_mcN % period == 0)
|
||||
CycleControlModeNow();
|
||||
}
|
||||
}
|
||||
// (#152) TORSO-CENTER -- the ONE writer of the torso's centerCommand
|
||||
// (@0x208, HELD-button semantics: writer asserts while held, clears on
|
||||
// release; TorsoSimulation re-arms recenterActive from it each frame).
|
||||
// Sources OR'd here, deliberately OUTSIDE the key-bridge gate:
|
||||
// * torsoCenter (@0x154) -- this mapper's databound "TorsoCenter" cell,
|
||||
// the streamed pod-button route (button 0x42);
|
||||
// * gBTTorsoRecenter -- the desktop 'X' one-frame pulse (mech4 key poll).
|
||||
// The old writer lived INSIDE the key-bridge block, so on any rig with a
|
||||
// RIO/PadRIO present (glass + the pod -- the bridge is off there) NO path
|
||||
// could reach centerCommand: with the stuck-cell phantom auto-recentre
|
||||
// fixed, those players had no way to recentre the torso in Std/Vet at all
|
||||
// (Oracle's #152 report). Single-writer here also means the two sources
|
||||
// can never stomp each other's clear.
|
||||
{
|
||||
Mech *rcMech = GetMech();
|
||||
Torso *rcTorso = (rcMech != 0) ? (Torso *)rcMech->GetTorsoSubsystem() : 0;
|
||||
if (rcTorso != 0)
|
||||
{
|
||||
int hold = (torsoCenter != 0);
|
||||
extern int gBTTorsoRecenter;
|
||||
extern int gBTTorsoCenterHeld; // pod button 0x42 (L4PADRIO chokepoint)
|
||||
if (gBTTorsoCenterHeld) hold = 1;
|
||||
if (gBTTorsoRecenter) { gBTTorsoRecenter = 0; hold = 1; }
|
||||
if (hold)
|
||||
rcTorso->CommandRecenter();
|
||||
else
|
||||
rcTorso->ClearRecenterCommand();
|
||||
}
|
||||
}
|
||||
{
|
||||
// STAND-DOWN (glass-cockpit step 2c): BT_KEY_BRIDGE unset = AUTO --
|
||||
// the bridge runs only when NO live cockpit device (serial RIO /
|
||||
@@ -871,21 +967,12 @@ void
|
||||
// centerCommand is a pod BUTTON state, so the writer clears
|
||||
// it while unpressed (Basic's own path re-asserts every
|
||||
// frame; this branch owns it in Standard/Veteran).
|
||||
{
|
||||
Torso *rcTorso = (Torso *)mech->GetTorsoSubsystem();
|
||||
if (rcTorso != 0)
|
||||
{
|
||||
if (gBTTorsoRecenter)
|
||||
{
|
||||
gBTTorsoRecenter = 0;
|
||||
rcTorso->CommandRecenter();
|
||||
}
|
||||
else
|
||||
{
|
||||
rcTorso->ClearRecenterCommand();
|
||||
}
|
||||
}
|
||||
}
|
||||
// (#152) the recenter writer moved OUT of this key-bridge
|
||||
// block to the unified consumer below -- inside here it was
|
||||
// DEAD on every RIO/glass rig (bridge off), which left those
|
||||
// players with no torso-centre control at all once the
|
||||
// stuck-cell phantom auto-recentre was fixed. Bench: two
|
||||
// scripted 0x42 holds on the RIO path, ctrCmd=0 throughout.
|
||||
}
|
||||
}
|
||||
// (stickPosition.y no longer zeroed here -- the bridge above
|
||||
@@ -1023,6 +1110,20 @@ void
|
||||
// reads the same live factor (dead/overheated myomers cannot squat
|
||||
// or rise; the posture selector tests |factor| <= 1e-4).
|
||||
mech->myomerEffectiveness = drive;
|
||||
// #137 POST-RESET TRACE. Mech::Reset arms gBTMyoTrace; sample here,
|
||||
// where the mover's actual multiplier is formed, for a few seconds
|
||||
// after a respawn. This is the window nothing was watching -- the
|
||||
// at-reset sample always looks clean.
|
||||
{
|
||||
extern int gBTMyoTrace;
|
||||
extern void BTReportMyomerFreeze(void *mech_v, const char *when);
|
||||
if (gBTMyoTrace > 0)
|
||||
{
|
||||
--gBTMyoTrace;
|
||||
if ((gBTMyoTrace % 30) == 0)
|
||||
BTReportMyomerFreeze((void *)mech, "post-reset");
|
||||
}
|
||||
}
|
||||
if (fabsf(drive) <= 1.0e-4f) // @0x4a9d89 vs _DAT_004ab16c
|
||||
turnDemand = 0.0f; // @0x4a9d9e: mapper+0x12C -- the FREEZE
|
||||
}
|
||||
|
||||
@@ -20,9 +20,23 @@
|
||||
// excluded : the Projectile base ctor @004be1bc and the 0x41xxxx/0x42xxxx
|
||||
// Entity engine vtable slots
|
||||
//
|
||||
// Decoded MoveAndCollide tuning constants (.rdata @004bf594..@004bf5b0):
|
||||
// 0x40000000 = 2.0f 0x38d1b717 = 1.0e-4f 0x40800000 = 4.0f
|
||||
// 0x3dcccccd = 0.1f 0x41200000 = 10.0f 0xbf800000 = -1.0f
|
||||
// Decoded MoveAndCollide tuning constants (.rdata @004bf594..@004bf5b0).
|
||||
// ⚠ RE-DECODED 2026-08-11 (#168 audit, byte-read + full @4bef78 walk) -- the
|
||||
// original decode below misattributed most of these. Verified semantics:
|
||||
// _DAT_004bf594 = 2.0f replication KEEPALIVE: no update sent for 2.0s
|
||||
// -> updateModel(+0x18)|=1 (ForceUpdate), NOT a death
|
||||
// _DAT_004bf598 = 0.0f per-axis drag-coefficient sign pick (rel-vel vs wind)
|
||||
// _DAT_004bf59c/a0 = ∓1e-4 signed-square deadband in the DRAG term
|
||||
// _DAT_004bf5a4 = 4.0f PROXIMITY FUSE: seeker rangeToTarget(+0x10C) < 4.0
|
||||
// -> detonate on targetEntity without a geometry hit
|
||||
// _DAT_004bf5a8 = 0.1f dead-reckon divergence² (vs predictor FUN_00422060
|
||||
// pos +0x260) -> replication-dirty
|
||||
// _DAT_004bf5ac = 10.0f COAST MARGIN: expiry at age > burnTime(+0x340)+10.0s
|
||||
// _DAT_004bf5b0 = -1.0f kill-plane: pos.y < -1 -> FUN_0042061c retire
|
||||
// The steering/lifetime code below still reflects the OLD decode -- this TU is
|
||||
// DORMANT (nothing constructs Missile; the mech4.cpp pool is the live path) --
|
||||
// rework it against context/decomp-reference.md §5 "Projectile/Missile flight
|
||||
// & expiry" before ever wiring it live.
|
||||
//
|
||||
// Helper-function name mapping:
|
||||
// FUN_004be1bc Projectile base constructor (vtable @00512a5c)
|
||||
@@ -53,12 +67,14 @@
|
||||
//
|
||||
// MoveAndCollide tuning constants (read-only globals in the decomp).
|
||||
//
|
||||
static const Scalar MissileLifeWarn = 2.0f; // _DAT_004bf594 age-ratio dead threshold
|
||||
static const Scalar MissileSteerEps = 1.0e-4f;// _DAT_004bf598/9c/a0 steering deadband
|
||||
static const Scalar MissileTurnGain = 4.0f; // _DAT_004bf5a4
|
||||
static const Scalar MissileDriftGain = 0.1f; // _DAT_004bf5a8
|
||||
static const Scalar MissileMinSpeed2 = 10.0f; // _DAT_004bf5ac/a8 min squared-speed before fizzle
|
||||
static const Scalar MissileDead = -1.0f; // _DAT_004bf5b0
|
||||
// ⚠ names below carry the OLD (wrong) decode -- see the corrected table in the
|
||||
// header comment; kept only so the dormant skeleton still compiles unchanged.
|
||||
static const Scalar MissileLifeWarn = 2.0f; // _DAT_004bf594 actually: replication keepalive (s)
|
||||
static const Scalar MissileSteerEps = 1.0e-4f;// _DAT_004bf59c/a0 actually: drag signed-square deadband
|
||||
static const Scalar MissileTurnGain = 4.0f; // _DAT_004bf5a4 actually: proximity-fuse radius (u)
|
||||
static const Scalar MissileDriftGain = 0.1f; // _DAT_004bf5a8 actually: dead-reckon divergence²
|
||||
static const Scalar MissileMinSpeed2 = 10.0f; // _DAT_004bf5ac actually: coast margin (s) past burnTime
|
||||
static const Scalar MissileDead = -1.0f; // _DAT_004bf5b0 kill-plane y (this one was right)
|
||||
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
|
||||
@@ -809,6 +809,31 @@ void Myomers::MyomersDriveHeat(Scalar time_slice)
|
||||
Scalar termAccel = velComplement * velMag * accMag * mass * time_slice;
|
||||
Scalar gain = ratio * ratio * damageGain;
|
||||
|
||||
// #137 DEPOSIT TRAP (BT_HEAT_LOG): conduction into the myomers is measured
|
||||
// ZERO post-respawn (the [heatflow] trap), weapons deposit into themselves,
|
||||
// so THIS add is the only writer left that can carry the observed one-shot
|
||||
// slug (9e7..2.3e9, varying per respawn). The [myoheat] receipt is
|
||||
// time-sampled and would miss a 1-2 frame spike; this prints EVERY add
|
||||
// over 1e6 with the full operand set, unconditionally.
|
||||
{
|
||||
Scalar deposit = gain * (termClimb + termKinetic + termAccel);
|
||||
if (getenv("BT_HEAT_LOG") != 0 && (deposit > 1.0e6f || deposit < -1.0e6f))
|
||||
{
|
||||
// WHOSE mech (master vs the peer's replicant shares this log!) and
|
||||
// the acceleration COMPONENTS (pure-y = gravity accumulation on the
|
||||
// wreck; planar = teleport/warp-derived).
|
||||
Mech *om = (Mech *)owner;
|
||||
const Vector3D &av = om->localAcceleration.linearMotion;
|
||||
DEBUG_STREAM << "[myodep] mech=" << om->GetEntityID()
|
||||
<< (om->GetInstance() == Entity::ReplicantInstance ? " REPL" : " mstr")
|
||||
<< " deposit=" << deposit
|
||||
<< " v=" << velMag << " a=" << accMag
|
||||
<< " aXYZ=(" << av.x << "," << av.y << "," << av.z << ")"
|
||||
<< " vy=" << vy << " dt=" << time_slice << " gain=" << gain
|
||||
<< " climb=" << termClimb << " kinetic=" << termKinetic
|
||||
<< " accel=" << termAccel << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
pendingHeat /* @0x1C8 */ += gain * (termClimb + termKinetic + termAccel);
|
||||
|
||||
if (getenv("BT_MYO_LOG"))
|
||||
|
||||
@@ -1295,6 +1295,49 @@ void
|
||||
void
|
||||
Generator::GeneratorSimulation(Scalar time_slice)
|
||||
{
|
||||
// DEV: BT_GEN_HEAT=<kJ/s> pumps heat into the VIEWPOINT mech's Generator A
|
||||
// for a bounded window (BT_GEN_HEAT_DELAY=<s> lead-in, default 10;
|
||||
// BT_GEN_HEAT_SECS=<s> injection length, default 20) -- the deterministic
|
||||
// thermal-trip bench for #154: drive T past FailureTemperature, stop, let
|
||||
// it cool through DegradationTemperature, and correlate the [gen]
|
||||
// TRIPPED/RESTARTING receipts against the [audioedge] receipts to observe
|
||||
// which stateAlarm edge actually starts the warning sequence.
|
||||
{
|
||||
static const char *s_ghEnv = getenv("BT_GEN_HEAT");
|
||||
if (s_ghEnv != 0)
|
||||
{
|
||||
static int s_ghSaid = 0;
|
||||
if (s_ghSaid < 8)
|
||||
{
|
||||
++s_ghSaid;
|
||||
DEBUG_STREAM << "[genheat] gate: gen#" << generatorNumber
|
||||
<< " owner=" << (void *)owner
|
||||
<< " viewpoint=" << (void *)application->GetViewpointEntity()
|
||||
<< " T=" << currentTemperature
|
||||
<< " mass=" << thermalMass << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
if (s_ghEnv != 0 && generatorNumber == 1
|
||||
&& (Entity *)owner == application->GetViewpointEntity())
|
||||
{
|
||||
static float s_ghClock = 0.0f;
|
||||
static float s_ghDelay = -1.0f, s_ghSecs = -1.0f;
|
||||
if (s_ghDelay < 0.0f)
|
||||
{
|
||||
const char *d = getenv("BT_GEN_HEAT_DELAY");
|
||||
const char *s = getenv("BT_GEN_HEAT_SECS");
|
||||
s_ghDelay = (d != 0 && *d != '\0') ? (float)atof(d) : 10.0f;
|
||||
s_ghSecs = (s != 0 && *s != '\0') ? (float)atof(s) : 20.0f;
|
||||
}
|
||||
s_ghClock += time_slice;
|
||||
if (s_ghClock >= s_ghDelay && s_ghClock < s_ghDelay + s_ghSecs)
|
||||
{
|
||||
heatEnergy += (Scalar)atof(s_ghEnv) * time_slice;
|
||||
currentTemperature = heatEnergy / thermalMass;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HeatSink::HeatSinkSimulation(time_slice); // FUN_004ad924
|
||||
|
||||
if (simulationState == 1) // this[0x10] @0x40
|
||||
@@ -1779,6 +1822,17 @@ int BTSubsystemIsGenerator(::Subsystem *sub)
|
||||
return (sub != 0 && sub->IsDerivedFrom(*Generator::GetClassDerivations())) ? 1 : 0;
|
||||
}
|
||||
|
||||
// #135 bridge: the binary's generator lamp special @004cc27c is
|
||||
// `table_0051d070[*(int*)(sub+0x1E0)]` -- indexed by generatorNumber (A=1..D=4),
|
||||
// the same shape as the condenser special beside it. Expose the number so the
|
||||
// alarm-lamp TU can do the authentic lookup (it had been probing the
|
||||
// PoweredSubsystem aux-screen fields, which generators never fill -- the
|
||||
// field-logged 'NO LAMP RESOLVED (generator=1)' silent leak).
|
||||
int BTGeneratorNumber(::Subsystem *sub)
|
||||
{
|
||||
return BTSubsystemIsGenerator(sub) ? ((Generator *)sub)->generatorNumber : -1;
|
||||
}
|
||||
|
||||
// Destruction -> OUT display state (see Generator::MarkGeneratorOut, the [T3
|
||||
// inferred link]): a destroyed generator must reach stateAlarm 4 or the
|
||||
// authored gotoEngineering/engEject FLASH invite (alarm conditions 4/5) never
|
||||
|
||||
@@ -579,6 +579,18 @@ void
|
||||
|
||||
AmmoBin *bin = (AmmoBin*)ammoBinLink.Resolve(); // FUN_00417ab4(this+0x43c)
|
||||
|
||||
// #166 forensics (ALWAYS on -- ejects are rare): every entry, with the
|
||||
// state that decides which branch runs. Oracle's field jam (bin parked
|
||||
// at Ejecting(3), rounds aboard, countdown dead) can only come from a
|
||||
// press/release sequence this line will capture -- the prime suspect is
|
||||
// a press whose bin-resolve fails falling through to the release block.
|
||||
DEBUG_STREAM << "[weap] " << GetName() << " EJECT msg data=" << message->dataContents
|
||||
<< " bin=" << (void *)bin
|
||||
<< " ejectState=" << ejectState
|
||||
<< " binState=" << (bin != 0 ? bin->GetAmmoState() : -1)
|
||||
<< " rounds=" << (bin != 0 ? bin->GetAmmoCount() : -1)
|
||||
<< "\n" << std::flush;
|
||||
|
||||
if (message->dataContents > 0 && bin != 0) // PRESS @4bb9d0/4bb9e9
|
||||
{
|
||||
bin->SetAmmoState(AmmoBin::Ejecting); // SetLevel(bin+0x194, 3)
|
||||
|
||||
@@ -618,6 +618,30 @@ void
|
||||
if (lsw != 0 && s_lockSweep <= 0.0f) s_lockSweep = 0.12f;
|
||||
if (s_lockSweep > 1.0f) s_lockSweep = 1.0f;
|
||||
}
|
||||
// BENCH (BT_TWIST_PULSE=<n>): deflect the analog twist axis for n ticks,
|
||||
// then RELEASE it for n ticks, repeating. BT_LOCK_SWEEP never releases,
|
||||
// so it cannot show the reported symptom: with a stuck centerCommand the
|
||||
// torso holds while you are actively pushing (the analog arm clears
|
||||
// recenterActive) and snaps back the moment you let go (centerCommand
|
||||
// re-arms it) -- "the torso centering FOUGHT my control". Measure the
|
||||
// RELEASE windows: currentTwist should HOLD, not decay toward 0.
|
||||
{
|
||||
static const char *s_tp = getenv("BT_TWIST_PULSE");
|
||||
if (s_tp != 0)
|
||||
{
|
||||
static int s_tpN = 0;
|
||||
int period = atoi(s_tp);
|
||||
if (period < 1) period = 120;
|
||||
const int phase = (s_tpN++ / period) % 2;
|
||||
analogTwistAxis = phase ? 0.0f : 0.6f;
|
||||
if ((s_tpN % 30) == 0)
|
||||
DEBUG_STREAM << "[twistpulse] phase=" << (phase ? "RELEASE" : "deflect")
|
||||
<< " axis=" << analogTwistAxis
|
||||
<< " twist=" << currentTwist
|
||||
<< " ctrCmd=" << centerCommand
|
||||
<< " recen=" << recenterActive << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
if (s_lockSweep > 0.0f)
|
||||
{
|
||||
effectiveTwistRate = baseTwistRate;
|
||||
@@ -646,6 +670,13 @@ void
|
||||
<< " limits=(" << horizontalLimitRight << ".." << horizontalLimitLeft << ")"
|
||||
<< " axis=" << analogTwistAxis
|
||||
<< " twist=" << currentTwist
|
||||
// control-mode recenter state. centerCommand (@0x208) is the
|
||||
// HELD-button cell -- if it reads 1 with no button down, the
|
||||
// torso re-arms recenterActive every frame and digital twist is
|
||||
// dead (Sauron's "lost torso control" after cycling modes).
|
||||
<< " ctrCmd=" << centerCommand
|
||||
<< " recen=" << recenterActive
|
||||
<< " vLim=(" << verticalLimitBottom << ".." << verticalLimitTop << ")"
|
||||
<< " wIdx=" << watchedSubsystem
|
||||
<< " w=" << (void*)w
|
||||
<< " wElec=" << (w ? w->electricalStateAlarm.GetLevel() : -1)
|
||||
@@ -823,6 +854,13 @@ void
|
||||
<< " vel=" << twistVelocity
|
||||
<< " lastUpd=" << lastUpdateTime
|
||||
<< " now=" << GetCurrentTime()
|
||||
// #148: ComputeTargetTwist ends in Min(limitLeft)/Max(limitRight).
|
||||
// If the COPY's limits never loaded they are 0/0, which pins
|
||||
// targetTwist to EXACTLY 0 no matter what the record carried --
|
||||
// which is what a peer stuck at zero twist would look like.
|
||||
<< " limL=" << horizontalLimitLeft
|
||||
<< " limR=" << horizontalLimitRight
|
||||
<< " enab=" << (int)horizontalEnabled
|
||||
<< " copy=" << (int)isDamagedCopy << std::endl;
|
||||
}
|
||||
}
|
||||
@@ -897,14 +935,21 @@ void
|
||||
|
||||
// bring-up verification (env BT_TORSO_LOG; default OFF): show the first few
|
||||
// joint writes so the per-frame path can be confirmed in a headless run.
|
||||
static const int s_log = getenv("BT_TORSO_LOG") ? 1 : 0;
|
||||
static int s_count = 0;
|
||||
if (s_log && (s_count % 30) == 0 && s_count < 1800) // sample periodically to show the sweep
|
||||
// ⚠ SAMPLING TRAP (fixed 2026-08-08, #141): this used to sample ONE shared
|
||||
// static every 30th call. With a master torso and a replicant COPY torso
|
||||
// both ticking, the calls alternate 1:1 -- so every 30th call is always the
|
||||
// SAME instance, and the probe reported only the local (untwisted) torso
|
||||
// while the copy's writes were invisible. Sample per instance-kind instead.
|
||||
static const int s_log = getenv("BT_TORSO_LOG") ? 1 : 0;
|
||||
static int s_count[2] = { 0, 0 };
|
||||
const int kind = isDamagedCopy ? 1 : 0;
|
||||
if (s_log && (s_count[kind] % 30) == 0 && s_count[kind] < 1800)
|
||||
{
|
||||
DEBUG_STREAM << "[torso] PushTwist node=" << (void*)node << " type=" << (int)jt
|
||||
DEBUG_STREAM << "[torso] PushTwist " << (kind ? "COPY " : "master")
|
||||
<< " node=" << (void*)node << " type=" << (int)jt
|
||||
<< " twist=" << (float)twist << "\n" << std::flush;
|
||||
}
|
||||
++s_count;
|
||||
++s_count[kind];
|
||||
|
||||
switch (jt) // node+0x10
|
||||
{
|
||||
|
||||
@@ -235,8 +235,30 @@ class Joint; // engine skeleton node (JOINT.h); the twist target
|
||||
// Controls (@0x1F0 twist, @0x1F4 elevation); proportional, no button ramp.
|
||||
void SetAnalogTwistAxis(Scalar v) { analogTwistAxis = v; }
|
||||
void SetAnalogElevationAxis(Scalar v) { analogElevationAxis = v; }
|
||||
void CommandRecenter() { centerCommand = 1; } // @0x208 (Basic-mode re-center)
|
||||
void CommandRecenter() { centerCommand = 1; } // @0x208 HELD button -- writer MUST clear it
|
||||
void ClearRecenterCommand() { centerCommand = 0; } // button released (writer-owned state)
|
||||
|
||||
// ⚠ centerCommand (@0x208) is a HELD-BUTTON cell: TorsoSimulation re-arms
|
||||
// `recenterActive` from it EVERY frame it is non-zero, and only the input
|
||||
// path clears it. Do NOT use CommandRecenter() for a one-shot recenter --
|
||||
// nothing releases it and the torso re-centres forever, which reads to the
|
||||
// pilot as "lost torso control" (Sauron, control-mode cycle).
|
||||
//
|
||||
// The one-shot the mode switch actually wants is recenterActive (@0x274)
|
||||
// itself: TorsoSimulation runs `recenterActive = Recenter(dt)`, so it
|
||||
// SELF-CLEARS on settle, and any twist input cancels it. This is exactly
|
||||
// what the binary writes -- `*(torso + 0x274) = 1` @004afbe0.
|
||||
void BeginRecenterOnce() { recenterActive = 1; } // @0x274 one-shot (@004afbe0)
|
||||
|
||||
// The TWO authored elevation-limit pairs the control mode swaps between
|
||||
// (@004afbe0). BASIC gets @0x228/@0x22C (full top, HALF bottom -- reduced
|
||||
// downward travel); STANDARD/VETERAN get @0x230/@0x234 (the full authored
|
||||
// pair). Before 2026-08-08 all four were written by the ctor and never
|
||||
// read by anything -- the port simply never implemented the swap.
|
||||
void ApplyBasicElevationLimits()
|
||||
{ verticalLimitTop = elevationCenter; verticalLimitBottom = elevationHalfBottom; }
|
||||
void ApplyAssistedElevationLimits()
|
||||
{ verticalLimitTop = twistCenterHigh; verticalLimitBottom = twistCenterLow; }
|
||||
Logical GetHorizontalEnabled() const { return horizontalEnabled; } // @0x250 (mapper free-aim gate @004afd10)
|
||||
|
||||
// Reachable horizontal (yaw) half-arc the guns can be brought to bear by
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
# Phase 14 — PPC hit = `scrambleVideo`, the cockpit-CRT sync detune
|
||||
|
||||
**Goal:** restore the PPC's authentic secondary effect — a PPC strike scrambles
|
||||
**every secondary cockpit display for 0.8 s**, leaving the main view untouched.
|
||||
|
||||
**Status:** ✅ IMPLEMENTED 2026-08-06 (branch `ppc-sync-distortion`) [T2 —
|
||||
visual screenshot-verified, trigger by construction]. Discovered 2026-08-06 by
|
||||
disassembly of the shipped `BTL4OPT.EXE` (md5 `a97075bcb5634d13263e9ad5a2b96fd0`)
|
||||
after playtesters reported *"being hit by a PPC makes it look like all of the
|
||||
secondary CRTs were being degaussed."* Full findings: `context/gauges-hud.md`
|
||||
§"PPC HIT = a deliberate CRTC horizontal-sync DETUNE"; cross-ref in
|
||||
`context/combat-damage.md`.
|
||||
|
||||
**What shipped (all three work items):**
|
||||
- **A — trigger:** `game/reconstructed/mech.cpp`, `Mech::TakeDamageMessageHandler`
|
||||
@0x4a03f3 position (after the cylinder resolve, before the burst loop) — fires
|
||||
`GetGaugeRenderer()->SpecialEffect(scrambleVideo, damageType*0.2f)` once per
|
||||
EnergyDamageType message. `BT_DMG_LOG` prints `[ppc-scramble]`.
|
||||
- **B — visual (animated recovery):** `SVGA16::FunkyVideo(on, dur)` (was the 2007
|
||||
stub) records the start + hold; `SVGA16::ScrambleParams` (new) is a two-phase
|
||||
envelope read by BOTH `DrawDevSurface` (surround/dock) and `ExpandPlaneToBGRA`
|
||||
(glass windows, native + rotated radar), so all secondary surfaces recover
|
||||
together and the main 3D view (separate timing chain) is untouched. The sequence
|
||||
per hit: **collapse** to a thin centred line → **HOLD** for the ~0.8 s the card
|
||||
held bad sync (content scrolls wildly fast + decelerating, plus a violent
|
||||
**shake**) → **RECOVERY** (~0.5 s: the line broadens back to full while
|
||||
scroll/tear/shake settle) → **LOCK** (clean). The read loops map the source
|
||||
through the envelope: horizontal `scale` sets a black-bordered collapse band,
|
||||
`rollOff` scrolls (wraps) within it, `shear` is a per-row diagonal, and a
|
||||
per-frame LCG `shake` bounces the row + jitters the scroll. Tunables (by eye —
|
||||
the pod-monitor PLL look is not recoverable): `BT_SCRAMBLE_COLLAPSE` (min band
|
||||
fraction, 0.03), `BT_SCRAMBLE_ROLL` (initial scroll px/s, 9000),
|
||||
`BT_SCRAMBLE_SHAKE` (px, 10), `BT_SCRAMBLE_SHEAR` (px/row, 3), `BT_SCRAMBLE_DUR`
|
||||
(hold s), `BT_SCRAMBLE_RECOVER` (recovery s, 0.5). **`BT_SCRAMBLE_TEST=1` loops
|
||||
the whole transition** for tuning without a hit; `BT_SCRAMBLE_CYCLE=1` loops it
|
||||
stepping the roll speed.
|
||||
- **C — non-stacking latch:** `L4GaugeRenderer::SpecialEffect` now ignores the
|
||||
re-arm while `scrambleVideoFlag` is set (matches the binary's `modified` latch);
|
||||
a second PPC during the window no longer extends it. NB the SVGA16 animation runs
|
||||
the full hold+recovery on its own clock (`FunkyVideo(False)` is a no-op — the
|
||||
card restoring sync is where the recovery *begins*), so the recovery isn't cut
|
||||
off when the 0.8 s latch clears.
|
||||
- Verified [T2]: Release links clean; surround boots + runs with the effect looped
|
||||
(`BT_SCRAMBLE_TEST`) at both slowed and true full speed — every secondary MFD +
|
||||
the radar collapse/roll/shake/recover together while the out-the-window view
|
||||
stays clean, no crash (screenshots). **SHIPPING with the current defaults for
|
||||
playtester feedback.** Open: live PPC-fire confirmation (one arm per hit,
|
||||
non-stacking across two hits) + by-eye tuning of the envelope constants — none
|
||||
recoverable from the binary, so the testers who filed the report are the ground
|
||||
truth.
|
||||
|
||||
**Good news up front:** the engine half already exists in our tree under the
|
||||
**original VWE names** (`SpecialEffect` / `scrambleVideo` / `FunkyVideo`). Only
|
||||
two things are missing: the **trigger** (never ported) and the **visual**
|
||||
(stubbed out in 2007). This is a small, well-bounded job.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the original did [T1 — disasm-verified]
|
||||
|
||||
On the **victim's** machine, `Mech::TakeDamageMessageHandler` @`0x4a0230` tests
|
||||
the damage type between the collision divert and the burst loop:
|
||||
|
||||
```
|
||||
004a03f3 mov ecx,[esi+0x2c] ; damage.damageType
|
||||
004a03f6 cmp ecx,4 ; EnergyDamageType
|
||||
004a03f9 jne 0x4a0423 ; everything else -> burst loop
|
||||
004a03fb mov eax,[0x4efc94] ; global `application`
|
||||
004a0400 mov eax,[eax+0x4c] ; -> gauge renderer
|
||||
004a0405 je 0x4a0423 ; null-guarded
|
||||
004a0407 fild dword [esi+0x2c] ; (float)damageType == 4.0
|
||||
004a040a fld xword [0x4a0c08] ; long double 0.2
|
||||
004a0410 fmulp st(1) ; => 0.8
|
||||
004a041d call dword [edx+0x4c] ; vtable slot 19 == SpecialEffect(0, 0.8f)
|
||||
```
|
||||
|
||||
→ `L4GaugeRenderer::SpecialEffect(scrambleVideo, 0.8f)` @`0x46ffcc`
|
||||
→ `SVGA16::FunkyVideo(True)` @`0x47d76d`, which reprograms the **VGA CRT
|
||||
controller**:
|
||||
|
||||
```
|
||||
out(0x3D4,0x11); v=in(0x3D5); out(0x3D5, v & 0x7F) ; unlock CRTC regs 0-7
|
||||
out(0x3D4,0x00) ; CRTC 0 = HORIZONTAL TOTAL
|
||||
saved = in(0x3D5); out(0x3D5, saved - 9) ; shorten every scanline
|
||||
out(0x3D4,0x11); out(0x3D5, v) ; restore write-protect
|
||||
```
|
||||
|
||||
A per-frame timer @`0x47003c` writes `saved` back 0.8 s later.
|
||||
|
||||
**Why only the PPC:** a `BTL4.RES` census gives `damageType` 4 (`Energy`) =
|
||||
**14 subsystem records, every one PPC or ERPPC**. Everything else is Ballistic
|
||||
(16), Explosive (30), Laser (78). The branch is structurally PPC-exclusive —
|
||||
no extra gating needed.
|
||||
|
||||
**Why only the secondaries:** all six secondary displays are derived by the VDB
|
||||
from that one VGA's timing, so they break together. The main out-the-window
|
||||
view comes off the Division VPX card on an independent timing chain and is
|
||||
unaffected. Playtesters confirm both halves.
|
||||
|
||||
---
|
||||
|
||||
## 2. What our tree already has
|
||||
|
||||
| Piece | Where | State |
|
||||
|---|---|---|
|
||||
| `enum VideoEffectType { scrambleVideo }` (value **0**) | `engine/MUNGA/GAUGREND.h:482` | ✅ present |
|
||||
| `virtual void GaugeRenderer::SpecialEffect(VideoEffectType, Scalar) {}` | `engine/MUNGA/GAUGREND.h:488` | ✅ base no-op |
|
||||
| `L4GaugeRenderer::SpecialEffect` — sets `scrambleVideoFlag`, `scrambleVideoTimeout = Now()+duration`, calls `FunkyVideo(True)` | `engine/MUNGA_L4/L4GREND.cpp:806` | ✅ implemented |
|
||||
| `L4GaugeRenderer::ProcessVideoEffects()` — on timeout, `FunkyVideo(False)` | `engine/MUNGA_L4/L4GREND.cpp:832` | ✅ implemented |
|
||||
| …called every frame from `ExecuteForeground` | `engine/MUNGA_L4/L4GREND.cpp:370` | ✅ live |
|
||||
| `SVGA16::FunkyVideo(Logical)` | `engine/MUNGA_L4/L4VB16.cpp:6358` | ❌ **STUBBED** (`//STUBBED: VIDEO RB 1/15/07`, body commented out) |
|
||||
| Any caller of `SpecialEffect` | — | ❌ **NONE** |
|
||||
|
||||
The base-class declaration carries its original comment:
|
||||
|
||||
> `// Quick and dirty hack to allow calling L4GaugeRenderer::SpecialEffect`
|
||||
> `// from non L4 level. GDU 2/28/96`
|
||||
|
||||
That hack exists **because the damage handler (non-L4 level) had to call it** —
|
||||
independent corroboration that the trigger belonged in `mech.cpp`, and the
|
||||
reason you can call it through the base pointer without dragging L4 headers
|
||||
into a game-layer TU.
|
||||
|
||||
---
|
||||
|
||||
## 3. Work item A — the trigger (`game/reconstructed/mech.cpp`)
|
||||
|
||||
In `Mech::TakeDamageMessageHandler`, **after** the `damageType==0` collision
|
||||
divert and **before** the burst loop, add the type-4 branch.
|
||||
|
||||
```cpp
|
||||
// @0x4a03f3 [T1] -- PPC/ERPPC only: EnergyDamageType is authored on exactly
|
||||
// the 14 PPC/ERPPC subsystem records and nothing else. Duration is DERIVED
|
||||
// from the type ordinal, not a constant: (float)damageType * 0.2 == 0.8f.
|
||||
if (damage.damageType == Damage::EnergyDamageType) // == 4
|
||||
{
|
||||
GaugeRenderer *gauges = (application != 0)
|
||||
? application->GetGaugeRenderer() : 0; // APP.h:355
|
||||
if (gauges != 0) // binary null-guards too
|
||||
{
|
||||
gauges->SpecialEffect(
|
||||
GaugeRenderer::scrambleVideo,
|
||||
(Scalar)damage.damageType * 0.2f); // long double 0.2 @0x4a0c08
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Placement matters.** The binary's branch is *outside* the burst loop, so it
|
||||
fires **once per damage message**, not once per burst. Putting it inside the
|
||||
loop would re-arm it `burstCount` times.
|
||||
|
||||
Use the named accessor — do not raw-read `application+0x4c` (databinding rule,
|
||||
`context/reconstruction-gotchas.md`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Work item B — the visual (`SVGA16::FunkyVideo`)
|
||||
|
||||
There is no CRTC to detune, so reproduce the **look**, applied to the gauge
|
||||
composite only.
|
||||
|
||||
What the original did physically: Horizontal Total sets character clocks per
|
||||
scanline. Subtracting 9 shortens every line by roughly **9%**, far outside any
|
||||
monitor's sync lock range, so the picture breaks into a rolling diagonal tear
|
||||
until the value is restored. VWE's own name for it — `scrambleVideo` — is the
|
||||
best description of the intended result.
|
||||
|
||||
Suggested model (per-scanline horizontal displacement of the gauge buffer):
|
||||
|
||||
```
|
||||
shift(y, t) = ( y * k + roll(t) ) mod width
|
||||
```
|
||||
|
||||
- `k` — per-line shear, the fraction of a line lost. ~9% of width is the
|
||||
physically-derived starting point; **tune by eye** against the playtester
|
||||
description rather than treating it as exact, because the on-screen result
|
||||
depended on how each pod monitor's H-sync PLL misbehaved — that is not
|
||||
recoverable from the binary.
|
||||
- `roll(t)` — a time-varying offset so the tear drifts rather than sitting
|
||||
static. The original rolled because the monitor never re-locked.
|
||||
|
||||
Constraints:
|
||||
|
||||
- **Gauge composite only.** Apply to the `SVGA16` `pixelBuffer`
|
||||
(`engine/MUNGA_L4/l4vb16.h:243`) or at the point the strip/surfaces are
|
||||
presented — *never* the main 3D view. The main view being clean is a
|
||||
confirmed observation, not an assumption.
|
||||
- **All secondary surfaces together.** They are bit-planes of one shared
|
||||
buffer, so a single buffer-level effect is authentic by construction; do not
|
||||
implement it per-MFD.
|
||||
- **Keep the existing timing path.** `SpecialEffect` / `ProcessVideoEffects`
|
||||
already own the flag and the 0.8 s timeout and are already called per frame.
|
||||
`FunkyVideo` should only set/clear state — no timing logic of its own.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fidelity constraints (do not "improve" these)
|
||||
|
||||
1. Duration is `damageType * 0.2f`, **not** a literal `0.8f`.
|
||||
2. Fires on `EnergyDamageType` — never add an explicit PPC class check; the
|
||||
data authorship *is* the gate.
|
||||
3. **Idempotent, non-stacking.** The binary latches on `modified` @`0x4fe0fe`:
|
||||
a second PPC hit while the effect is live does **not** re-save the (already
|
||||
detuned) value and does **not** extend or double the effect. Our
|
||||
`SpecialEffect` currently *does* overwrite `scrambleVideoTimeout`, which
|
||||
extends the effect on a second hit — **that is a divergence.** Match the
|
||||
binary: ignore re-arm while `scrambleVideoFlag` is set. (The original's
|
||||
latch was in `FunkyVideo`; ours must go in `SpecialEffect` or `FunkyVideo`,
|
||||
but it must exist.)
|
||||
4. Victim-side only. The shooter sees nothing; this runs in the victim's
|
||||
damage handler.
|
||||
5. Null-guard the gauge renderer — the binary does, and headless/bench runs
|
||||
have none.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification
|
||||
|
||||
- **Headless:** add a one-line log in the new branch; fire a PPC at a dummy
|
||||
with `BT_DMG_LOG`. Expect exactly one arm per PPC message, zero for laser /
|
||||
autocannon / missile / Gauss.
|
||||
- **Non-stacking:** two PPC hits ~0.2 s apart must produce one 0.8 s effect
|
||||
measured from the *first* hit, not 1.0 s or two effects.
|
||||
- **Live:** confirm every secondary MFD scrambles together and the main view
|
||||
stays clean. Have the playtesters who filed the report compare — they are
|
||||
the only ground truth for `k` and `roll`.
|
||||
- **Against the original (optional, decisive):** in the DOSBox-X fork, log
|
||||
writes to CRTC index 0 via `0x3D4`/`0x3D5` during a real BT mission.
|
||||
Prediction: exactly one write of `saved-9` per PPC strike, one restore 0.8 s
|
||||
later, zero for every other weapon.
|
||||
|
||||
---
|
||||
|
||||
## 7. Gotchas
|
||||
|
||||
- Do **not** confuse this with `SVGA16::FlashPalette` (the pixel-mask cycler on
|
||||
ports `0x302/0x30A/0x312`). That machinery is linked and its per-frame cycler
|
||||
runs, but `FlashPalette` @`0x46d5f4` has **zero call sites and zero
|
||||
address-of references** in `BTL4OPT.EXE` — BT never arms it. **RP does**
|
||||
(`RPL4OPT.EXE` @`0x4addce`, palette 1, rate 2.0, masks `{FF,BF,7F,3F}`) for
|
||||
alarm blinking. Wrong mechanism, wrong game.
|
||||
- The `LampTesla1/2/3` "solid-state relays" in `L4CTRL.HPP` are **not**
|
||||
involved and are driven by nothing in the surviving tree.
|
||||
- Scope note: callers of gauge-renderer vtable slot 19 were not exhaustively
|
||||
enumerated (virtual dispatch), so another arming site may exist. The
|
||||
low-level path *is* exhaustive — @`0x47d76d` has one caller, @`0x46d840` two
|
||||
(set @`0x47002b`, restore @`0x470076`).
|
||||
|
||||
## References
|
||||
- `context/gauges-hud.md` §"PPC HIT = a deliberate CRTC horizontal-sync DETUNE"
|
||||
- `context/combat-damage.md` §`Mech::TakeDamageMessageHandler` (Energy branch)
|
||||
- `HISTORY.md` (TeslaRel410) §"Anatomy of a surviving weapon — the PPC"
|
||||
@@ -93,8 +93,9 @@ if defined BT_STILL_RUNNING (
|
||||
goto btwait
|
||||
)
|
||||
echo.
|
||||
echo The game has exited. If it closed unexpectedly, send the operator
|
||||
echo BOTH of these from the content folder:
|
||||
echo The game has exited. (Quitting from the menu lands here -- normal.)
|
||||
echo If it CRASHED or would not start, send the operator BOTH of these
|
||||
echo from the content folder:
|
||||
echo content\lastrun_solo.txt (tiny -- send it EVEN IF there is
|
||||
echo no solo_*.log at all: that combination
|
||||
echo is how we spot a blocked launch)
|
||||
|
||||
@@ -97,7 +97,10 @@ if defined BT_STILL_RUNNING (
|
||||
goto btwait
|
||||
)
|
||||
echo.
|
||||
echo The game has exited. If it closed unexpectedly, send the operator BOTH
|
||||
echo The game has exited.
|
||||
echo (Normal exits land here too: round ends return everyone to the menu,
|
||||
echo and closing the menu window quits -- neither is a crash.)
|
||||
echo If it CRASHED or would not start, send the operator BOTH:
|
||||
echo content\lastrun_steam.txt (tiny -- send it EVEN IF there is no
|
||||
echo steam_*.log at all: that is how we spot
|
||||
echo a launch blocked before it ran)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,127 @@
|
||||
"""Night 13: close #137 (not a bug) and file the desktop throttle-latch follow-up. ASCII only."""
|
||||
import sys
|
||||
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
|
||||
import gitea
|
||||
|
||||
BODY_137 = """**NOT A BUG -- closing (2026-08-08).** The reset is correct on both sides. The
|
||||
heat is real, and the mech earned it: it respawns still under power.
|
||||
|
||||
## What the field log actually shows
|
||||
|
||||
From Sauron's match log (`steam_20260806_c_michael_XIAOLONG.log`), the respawn at line 39450:
|
||||
|
||||
```
|
||||
39450 [respawn] Mech::Reset 3:30 healed+moved to (...) alive=1 zones=21 subsys=33
|
||||
[techstat] SLaser_1 / LLaser_1 / HUD / Gyroscope / Avionics / GeneratorA / HeatSink
|
||||
... condition CLEARED <- every live condition clears
|
||||
[techstat] Myomers condition 3 SET <- Overheating, immediately
|
||||
[mppr] in thr=1 pre=1 -> ...
|
||||
[gaitSM] cycleSpeed=14.6 legCycle=14.6 state=12 <- already RUNNING
|
||||
[techstat] Condenser5 condition 3 SET <- "dumping into coolant loop 5"
|
||||
39519 [techstat] GeneratorD condition 3 SET <- Sauron's generator D
|
||||
```
|
||||
|
||||
`[rstat]` fires about once per second (98 frames @ 10.2ms), and the reset plus the myomers trip sit
|
||||
inside one rstat block -- so myomers overheat in well under a second, generator D in one to two.
|
||||
|
||||
The reset itself is clean: **every** live condition CLEARs 12-16 lines after each `Mech::Reset`, at
|
||||
every respawn in the log. The synthetic bench agrees -- every roster subsystem, including all six
|
||||
Condensers, reads `T=77 start=77` at the reset.
|
||||
|
||||
## Why it looked like maxed heat
|
||||
|
||||
**`condition 3` is an operating flag, not an alarm.** Census over the whole match:
|
||||
|
||||
| subsystem | cond 3 SET | CLEARED |
|
||||
|---|---|---|
|
||||
| LLaser_2 | 33 | 33 |
|
||||
| LLaser_1 | 31 | 31 |
|
||||
| SRM4 | 26 | 26 |
|
||||
| PPC_2 | 18 | 18 |
|
||||
| GeneratorD | 5 | 4 |
|
||||
| Myomers | 5 | 4 |
|
||||
| Condenser5 | 1 | 1 |
|
||||
|
||||
Every weapon trips Overheating on each volley and clears it on cooldown. **Every subsystem is
|
||||
balanced** -- the odd extra SET is only the log ending mid-heat. Nothing latches, nothing sticks.
|
||||
`condition 6` (BadPower) behaves the same way (Myomers 8/8, PPC_1 3/3, MLaser_1 3/3) as the bus
|
||||
browns out under simultaneous draw. GeneratorD's longest continuous overheat came out normally.
|
||||
|
||||
So the post-respawn SET is the mech *operating*, not a failed reset.
|
||||
|
||||
## Why the heat arrives instantly
|
||||
|
||||
The mech comes back **at whatever throttle the pilot left**: `thr=1`, `cycleSpeed=14.6`, gait state
|
||||
12 at the instant of reset. `Mech::Reset`'s subsystem loop starts at **index 2**, and the
|
||||
ControlsMapper is **index 0** -- so the throttle is never reset.
|
||||
|
||||
**The binary does exactly the same.** That is correct for a pod: the throttle is a physical lever
|
||||
still under the pilot's hand. Respawning under power is authentic behaviour.
|
||||
|
||||
Oracle's read that the myomer heat rate "felt right" was correct, and matches the data.
|
||||
|
||||
## Ruled out along the way
|
||||
|
||||
`Mech::Reset` dispatch, the full subsystem RTIS chain (Generator `@004b215c` is an
|
||||
instruction-for-instruction match; HeatSink `@004ad760` faithful; every RTIS class has a
|
||||
DeathReset), stale coolant-loop links (`linkedSinks` is written only in the streaming ctor and
|
||||
cannot drift), and the valve fractions (`BTRecomputeCondenserValves == @0049f788`).
|
||||
|
||||
## Follow-up
|
||||
|
||||
The one genuine defect found is a desktop-only input issue, filed separately -- see the throttle
|
||||
latch ticket. It does not affect the pod.
|
||||
|
||||
Knowledge base updated so this is not re-chased: `context/decomp-reference.md` §TechStatus (the
|
||||
routine/self-clearing semantics + this post-mortem) and `context/gauges-hud.md`."""
|
||||
|
||||
TITLE_NEW = "Desktop/Steam: virtual throttle lever survives death -- you respawn already running"
|
||||
|
||||
BODY_NEW = """Split out of #137, which was closed as not-a-bug. The heat model and the reset are
|
||||
both correct; this is a port-layer input issue and it affects **desktop/Steam only, never the pod**.
|
||||
|
||||
## The defect
|
||||
|
||||
On the pod the throttle is a physical lever. Respawning under power is authentic -- the pilot's hand
|
||||
is on it, they can see and feel where it is, and `Mech::Reset` deliberately does not touch it (the
|
||||
subsystem loop starts at index 2; the ControlsMapper is index 0). The binary behaves the same way.
|
||||
|
||||
The desktop glass bridge emulates that lever with a **file-static ramp accumulator**:
|
||||
|
||||
* `sLever` -- `static float sLever = 0.0f;` (`game/reconstructed/mech4.cpp:3250`)
|
||||
* published each frame as `gBTDrive.throttle = sLever` (mech4.cpp:3892/3897)
|
||||
* consumed as `key_throttle` -> `throttlePosition` (mechmppr.cpp:677, :780)
|
||||
|
||||
The only things that zero `sLever` are the **X-button all-stop** (mech4.cpp:3754) and a
|
||||
direction-crossing snap (:3842). **Nothing on death or respawn touches it.**
|
||||
|
||||
So a pad/keyboard pilot who dies at speed respawns at speed -- while physically holding nothing, and
|
||||
with the lever position invisible to them. The pod's authenticity argument does not cover this case:
|
||||
there is no lever to feel, and a gamepad stick self-centers, so the input affordance actively
|
||||
contradicts the latched state.
|
||||
|
||||
Observed consequence in the field (Sauron's log, the #137 evidence): the mech leaves the drop zone
|
||||
at `thr=1` / `cycleSpeed=14.6` the instant it spawns, and the myomers, Condenser5 and GeneratorD all
|
||||
trip Overheating within one to two seconds. Reads to the player as "respawned with heat maxed".
|
||||
|
||||
Note this is **not** the same as the Thrustmaster/RIO path, which is already correct:
|
||||
`MechThrustmasterMapper::InterpretControls` (`@004d2150`) recomputes `throttlePosition` from scratch
|
||||
every frame off the live `throttleForward`/`throttleReverse` states, and `throttleForward` is
|
||||
databound (written by the input layer each frame). Only the desktop `sLever` accumulator latches.
|
||||
|
||||
## Suggested fix
|
||||
|
||||
Zero `sLever` (and the detent) on respawn in the desktop bridge only -- the same treatment the
|
||||
X all-stop already applies -- so a desktop pilot comes back stopped. Gate it so pod builds keep the
|
||||
authentic physical-lever behaviour.
|
||||
|
||||
This is a gameplay-behaviour decision as much as a bug fix: it changes whether desktop players
|
||||
respawn moving or stopped. Worth a call before it ships.
|
||||
|
||||
## Not yet done
|
||||
|
||||
Not fixed, not benched. Filed from a static read of the input path plus the field log."""
|
||||
|
||||
gitea.close(137, BODY_137)
|
||||
num = gitea.create(TITLE_NEW, BODY_NEW)
|
||||
print("closed 137; created:", num)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Night 13: report #141 (fixed) and file the torso-cadence follow-up. ASCII only."""
|
||||
import sys
|
||||
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
|
||||
import gitea
|
||||
|
||||
BODY_141 = """**ROOT CAUSE FOUND AND FIXED** (`f01de8c` + sweep `e6c5ac9`), reproduced and measured on a
|
||||
2-node bench (`scratchpad/night13/missileframe.sh`).
|
||||
|
||||
## Reproduced
|
||||
|
||||
Only A sweeps its torso and only A fires, so every REPLICANT line in B's log mirrors one A salvo.
|
||||
New `[launchframe]` receipt (`BT_PROJ_LOG`) prints the yaw of the launch forward vs the BODY forward:
|
||||
|
||||
| | n | max abs(twistDelta) | mean | >0.1 rad |
|
||||
|---|---|---|---|---|
|
||||
| master | 165 | 2.2962 | 1.2283 | **100%** |
|
||||
| REPLICANT | 165 | **0.0000** | 0.0000 | **0%** |
|
||||
|
||||
`segResolved=1` on both, and `segYaw == bodyYaw` EXACTLY on the peer -- the launch frame was the
|
||||
bind pose.
|
||||
|
||||
## What it was NOT
|
||||
|
||||
Everything upstream was already correct, which is why it looked like netcode. Both sides pass the
|
||||
mount segment (`GetSegmentIndex()`, task #67 -- master `mislanch.cpp:363`, replicant mirror `:478`).
|
||||
The peer's torso data is fine end to end: records arrive (`atUpd=2.44/-2.39`, `rate=0.305`), the
|
||||
copy extrapolates correctly (`cur=-2.13987 target=-2.13987 copy=1`), and the copy torso
|
||||
demonstrably writes its joint (`PushTwist COPY twist=-1.49601`). Hierarchy identical on both nodes
|
||||
-- same seg 18, same `parentIdx=4`, non-null parent and joint subsystem.
|
||||
|
||||
The twist reached the joint and died at the **segment cache**.
|
||||
|
||||
## Root cause
|
||||
|
||||
`MechWeapon::GetMuzzlePoint` `@004b9948` ends in `FUN_00424da8(owner, segment, out)`, which is
|
||||
`JointedMover::GetSegmentToWorld` instruction-for-instruction:
|
||||
|
||||
```c
|
||||
iVar1 = FUN_00417ab4(param_1 + 0x31c); // GetJointSubsystem()
|
||||
if (*(int *)(iVar1 + 0xfc) != 0) { // AreJointsModified() <- TESTED, never set
|
||||
... walk owner+0x300, seg+0xc = 1 ... // ModifySegment() on every segment
|
||||
*(int *)(iVar1 + 0xfc) = 0; // ModifyJoints(False)
|
||||
}
|
||||
FUN_0040b104(out, FUN_004244dc(seg), owner+0xd0); // x localToWorld
|
||||
```
|
||||
|
||||
**In the 1995 image every muzzle query performs the joints->segments refresh.** Our
|
||||
`BTResolveWeaponMuzzle` -- labelled "the faithful FUN_004b9948" -- hand-composed
|
||||
`GetSegmentToEntity() x localToWorld` and skipped it. `GetSegmentToEntity` only recomputes when
|
||||
`segmentModified` is already set (`SEGMENT.cpp:262`), so it returned a stale cache. On the MASTER
|
||||
that was invisible (the render pass refreshes the local mech every frame, after its torso pushes
|
||||
the joint); a REPLICANT gets no such refresh, so peer muzzles sat at the bind pose.
|
||||
|
||||
## Fix
|
||||
|
||||
Route the muzzle path through the engine accessor, where the binary puts it. **No forced dirty
|
||||
flag** -- an earlier attempt set `ModifyJoints(True)` and scored the same, so it bought nothing and
|
||||
was removed; the binary only ever tests that flag.
|
||||
|
||||
Swept the same unfaithful pattern at three more sites: the generic segment->world bridge, the
|
||||
damage-effect anchor, and **the energy-beam gun port** -- a peer's BEAM had the identical exposure
|
||||
and would also have originated from the untwisted gun port. Repo-wide there is now exactly one
|
||||
`GetSegmentToEntity` call outside `SEGMENT.cpp`: inside `GetSegmentToWorld` itself, after the
|
||||
refresh.
|
||||
|
||||
## Result
|
||||
|
||||
| | n | max | mean | >0.1 rad |
|
||||
|---|---|---|---|---|
|
||||
| master | 165 | 2.1719 | 1.2781 | 100% |
|
||||
| REPLICANT | 165 | **2.0907** | 0.8201 | **64%** |
|
||||
|
||||
**The 64% is not a partial fix.** The failures are a contiguous PREFIX with zero interleaved cases:
|
||||
|
||||
```
|
||||
ZZZZ...(60)...ZZZZXXXX...(105)...XXXX
|
||||
```
|
||||
|
||||
and they end exactly when the peer acquires a twist to carry:
|
||||
|
||||
```
|
||||
first torso RECORD received : line 206
|
||||
first copy currentTwist != 0 : line 1016
|
||||
first CORRECT launch frame : line 1054 (38 lines = probe sampling granularity)
|
||||
```
|
||||
|
||||
Those 60 salvos fired while the replicated twist was genuinely 0, so launching along the body
|
||||
facing was CORRECT. Once the peer has a twist, 100% of launches carry it.
|
||||
|
||||
## Field-verify
|
||||
|
||||
Unreleased. Next playtest: have a peer watch a twisted mech fire missiles -- rounds should leave
|
||||
along the torso, not the feet. Also worth checking beams for the same reason (same fix).
|
||||
|
||||
Follow-up filed separately: the peer's torso takes far too long to FIRST acquire the master's
|
||||
twist."""
|
||||
|
||||
TITLE_NEW = "Peer torso twist takes far too long to first sync -- only 13 update records across a 5-minute run"
|
||||
|
||||
BODY_NEW = """Split out of #141, whose launch-frame defect is fixed. This is a separate, measured
|
||||
problem in the torso REPLICATION CADENCE.
|
||||
|
||||
## Measurement
|
||||
|
||||
From the #141 bench (`scratchpad/night13/missileframe.sh` / `missileframe2.sh`, 2 nodes, node A
|
||||
sweeping its torso continuously at 0.35 rad/s for the whole run):
|
||||
|
||||
```
|
||||
first torso RECORD received on the peer : line 206
|
||||
first copy currentTwist != 0 : line 1016
|
||||
```
|
||||
|
||||
and across the entire ~5 minute run the peer received only **13** `[torso-rec-rx]` records, despite
|
||||
the master's twist changing continuously the whole time.
|
||||
|
||||
So the master was twisted from very early on, while the peer's copy torso reported `currentTwist`
|
||||
of exactly 0 for a long stretch afterwards. The extrapolator itself is fine once fed --
|
||||
`ComputeTargetTwist` predicts `twistAtUpdate + twistRate * elapsed` and the copy tracks its target
|
||||
exactly (`cur=-2.13987 target=-2.13987 copy=1`). The problem is how rarely it is fed, and how late
|
||||
the first useful feed arrives.
|
||||
|
||||
## Why it matters
|
||||
|
||||
* It is the entire reason #141's fix reads 64% instead of 100% on the bench -- 60 salvos fired
|
||||
before the peer had any twist to carry.
|
||||
* A peer's torso will visibly LAG or sit straight while the mech is actually twisted. That is
|
||||
plausibly relevant to **#37** (MadCat torso is BACKWARDS) and **#70** (torso twist stops working
|
||||
after respawn) -- worth re-testing both against this once it is understood.
|
||||
|
||||
## Not yet investigated
|
||||
|
||||
Whether 13 records is the authentic cadence (the binary may deliberately send torso updates rarely
|
||||
and lean on `twistRate` extrapolation to cover the gaps -- in which case the bug is that our
|
||||
extrapolation is not running or not seeded until late), or whether our send-side gate is simply too
|
||||
conservative. `Torso::WriteUpdateRecord` snapshots `twistAtUpdate = currentTwist` at send, so the
|
||||
send trigger is the thing to read first.
|
||||
|
||||
Diagnostics already in place: `BT_TORSO_LOG` gives `[torso-rec-rx]` (receive), `[torso-copy]`
|
||||
(the copy's cur/target/atUpd/rate), and `[torso] PushTwist master|COPY` (per instance-kind -- note
|
||||
that probe previously sampled one shared static every 30th call, which with two torsos ticking 1:1
|
||||
always reported the SAME instance and hid the copy entirely; fixed in `05d7b58`)."""
|
||||
|
||||
gitea.comment(141, BODY_141)
|
||||
gitea.call("/issues/141", method="PATCH", payload={"state": "closed"})
|
||||
print("commented + closed #141")
|
||||
num = gitea.create(TITLE_NEW, BODY_NEW)
|
||||
print("created #%d" % num["number"])
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Night 13: close #148 as not-a-bug with the measured chain. ASCII only."""
|
||||
import sys
|
||||
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
|
||||
import gitea
|
||||
|
||||
BODY = """**NOT A BUG -- closing (2026-08-08).** There was nothing to fix here. The bench was lying,
|
||||
and this ticket's own premise was wrong twice over.
|
||||
|
||||
## The answer
|
||||
|
||||
`Entity::Execute` (`ENTITY.cpp:556`, real engine source [T0]) calls `PerformAndWatch` **only** when
|
||||
|
||||
```cpp
|
||||
application->GetApplicationState() == Application::RunningMission
|
||||
|| application->GetApplicationState() == Application::EndingMission
|
||||
|| IsPreRunnable()
|
||||
```
|
||||
|
||||
and otherwise merely `WriteSimulationUpdate()`s. `Entity::DefaultFlags` is
|
||||
`DynamicFlag|MasterInstance` -- **no `PreRunFlag`**. Only `Player` and `Director` add it in their
|
||||
DefaultFlags, and `Mech::Reset` sets it for a reset MASTER ("a reset master must tick"). A
|
||||
**replicant mech never gets it.**
|
||||
|
||||
So a peer mech performs **zero** subsystem ticks until the round actually starts, no matter how
|
||||
much correctly-replicated data is arriving for it. Measured on the observer node:
|
||||
|
||||
```
|
||||
235 [perf-first] mech 3:161 master <- own mech, immediately
|
||||
402 [torso-rec-rx] <- peer's torso records start arriving
|
||||
2754 [perf-first] mech 2:55 REPLICANT <- peer's FIRST performance
|
||||
2758 [torso] PushTwist COPY <- its torso ticks 4 lines later
|
||||
2761 [ent-exec] state=5 <- RunningMission
|
||||
```
|
||||
|
||||
The peer starts performing exactly at the RunningMission transition. That is the engine doing
|
||||
what it says it does.
|
||||
|
||||
## So the symptom was a BENCH ARTIFACT
|
||||
|
||||
`BT_AUTOFIRE` starts shooting immediately, during `WaitingForLaunch` -- something no player can do
|
||||
in a real match. Those leading salvos measured a peer whose torso, gait and subsystems had never
|
||||
run. Every `ZZZZ...XXXX` prefix in this investigation was that, and the first `X` lands within a
|
||||
few lines of the state transition.
|
||||
|
||||
**#141 is unaffected and stays fixed** -- its segment-cache defect was real and mid-match.
|
||||
|
||||
## Ruled out along the way (all measured, all recorded so nobody repeats them)
|
||||
|
||||
* **The record cadence is authentic.** My "only 13 records in 5 minutes" premise was wrong. The
|
||||
payloads are the sweep EXTREMES with `rate` flipping sign at each one -- the master sends on
|
||||
**rate change** and the peer dead-reckons `atUpd + rate * elapsed` between them. 12 records for
|
||||
12 direction reversals is correct, not starved.
|
||||
* **The `ComputeTargetTwist` clamp.** The copy's limits load correctly (`limL=2.44346
|
||||
limR=-2.44346 enab=1`), so `Min/Max` was not pinning `targetTwist` to zero.
|
||||
* **The torso's own executable flag.** `Entity::Perform` picks its predicate by instance
|
||||
(`IsNonReplicantExecutable` vs `IsReplicantExecutable`, differing on
|
||||
`|| lastUpdate >= lastPerformance`), and Mech's tick loop had dropped that branch. Restoring it
|
||||
(`f36f013`) is a genuine fidelity fix and is kept -- but it moved this bug by nothing.
|
||||
* **The scheduler.** The replicant entity IS offered to the performer with `executable=1` from
|
||||
line 171, ~2500 lines before its first `PerformAndWatch`. The gate was inside `Execute`, not in
|
||||
who gets offered.
|
||||
|
||||
## What came out of it
|
||||
|
||||
* `[perf-first]` -- a one-shot per-mech receipt naming entity ID + instance at a mech's first
|
||||
performance. Every other per-frame receipt in mech4 is anonymous, which is exactly why this took
|
||||
so long to see in a 2-node log.
|
||||
* `[torso-copy]` now prints `limL/limR/enab`; `[launchframe]` prints the shooter's live torso twist.
|
||||
* **Gotcha #29** in `context/reconstruction-gotchas.md`: judge a 2-node bench by PREFIX vs
|
||||
INTERLEAVED, never by raw percentage; check `[ent-exec] state=` before suspecting replication.
|
||||
`missileframe.sh` carries the same warning inline.
|
||||
|
||||
## Still worth checking separately
|
||||
|
||||
`#37` (MadCat torso BACKWARDS) and `#70` (torso twist stops after respawn) were flagged here as
|
||||
possibly sharing a cause. They do **not** share this one -- it is not a defect. They should be
|
||||
re-tested against the #141 segment-cache fix instead, which is a real mid-match change."""
|
||||
|
||||
gitea.comment(148, BODY)
|
||||
gitea.call("/issues/148", method="PATCH", payload={"state": "closed"})
|
||||
print("closed #148")
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Night 13: report the #52 root cause on the tracker. ASCII only."""
|
||||
import sys
|
||||
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
|
||||
import gitea
|
||||
|
||||
BODY = """**ROOT CAUSE FOUND AND FIXED (2026-08-07, commit `6a96fb6`)** -- but read the
|
||||
"what is NOT proven" section before treating this as closed.
|
||||
|
||||
## The defect
|
||||
|
||||
A replicant cannot start walking between gait-change records.
|
||||
|
||||
The port's body `case 4` (the task-#64 lockstep twin, mech2.cpp) is an INSERTION sitting between
|
||||
`case 0` and the advance group. In the binary it is a MEMBER of that group -- `FUN_004a5678`
|
||||
@004a5678 reads `case 2,3,`**`4`**`,5,8,...` with no turn block and no speed exit [T1] -- so case
|
||||
0's fallthrough is meant to land on `Advance()`. The insertion intercepted it.
|
||||
|
||||
On a replicant that is not a race, it is an identity:
|
||||
|
||||
* case 0 arms walk iff `standSpeed < bodyTargetSpeed`
|
||||
* the inserted block resets iff `standSpeed < bspd`, and `bspd` **IS** `bodyTargetSpeed` on a replicant
|
||||
|
||||
Same expression. Arm and reset fire on the same frame, every frame. A peer parked at Standing with a
|
||||
live replicated demand never cycles: `bodyCycleSpeed` stays 0 while position advances from dead
|
||||
reckoning. That is the skate. (Reverse is dead the same way -- both sides test `< ZeroSpeed`.)
|
||||
|
||||
## Why it appeared when it did
|
||||
|
||||
This is the sequel to `e91d447` (#82). Before that commit the replicant branch read the LOCAL
|
||||
mapper's `speedDemand` -- a dead cell on a peer, 0 forever -- so the exit never fired and the
|
||||
fallthrough worked BY ACCIDENT. Fixing the dead cell (correctly) closed the accidental escape
|
||||
hatch, and the trn-lock skate came back as a Standing-lock skate.
|
||||
|
||||
## Why masters were unaffected
|
||||
|
||||
Two reasons, either sufficient: their two tests read DIFFERENT cells (`bodyTargetSpeed` held at
|
||||
last-sent by the gait mirror, vs the live mapper `speedDemand`), so they only stall in the window
|
||||
where those disagree; and the master's body channel runs `mj=0` and writes no joints, so its stall
|
||||
is invisible -- the leg channel, whose case 0 falls through correctly, drives pose and travel.
|
||||
|
||||
## The load-bearing detail
|
||||
|
||||
`mech4.cpp`'s "stand; case 0 walk-begins next tick" is not an aside. A peer's body state is set from
|
||||
`record->legState` only on **type-3 edges**, and ENTERING Standing emits one while LEAVING it does
|
||||
not. Between gait-change records a replicant is REQUIRED to derive walking itself from the
|
||||
replicated demand. The insertion removed that ability.
|
||||
|
||||
This also explains the shape of the field data: the lock needs a mech holding a STEADY demand, so a
|
||||
mech whose gait keeps changing is continually rescued by records. Night 13's four episodes all carry
|
||||
`bodyTargetSpeed` 39-48 held across 100-400 frames, and each ended when that mech next changed gait.
|
||||
|
||||
## Fix
|
||||
|
||||
`case 0` -> `goto advance_body_normally`, the leg twin's own idiom, restoring the binary's structure
|
||||
without touching the #64/#82 turn logic. `BT_NO_BODY_FALLTHRU=1` reverts.
|
||||
|
||||
## Measured (2-node, `scratchpad/night13/skatelock.sh`)
|
||||
|
||||
| | legacy | fixed |
|
||||
|---|---|---|
|
||||
| STANDING-LOCK seconds | **336 consecutive**, `bspd=39.2324 bts=39.2324` identical every line | **0**, every pass |
|
||||
| master body-Standing samples | 52 | 21 |
|
||||
| turn-in-place | -- | body state 4 x9 / leg state 4 x8, armed in lockstep |
|
||||
|
||||
## What is NOT proven [T3]
|
||||
|
||||
That this accounts for the night-13 episodes. The lock is proven and proven removed; the link to the
|
||||
field symptom is INFERENCE -- a locked peer has `bodyCycleSpeed==0` and never advances its clip, so
|
||||
locked + translating IS the `[skate]` signature by construction -- but no bench caught the two
|
||||
together. Four rigs failed to reproduce the symptom end-to-end.
|
||||
|
||||
The `[skate]` line now carries `bstate=`, which is the diagnostic tonight's logs lacked. Next
|
||||
playtest settles it: episodes gone -> confirmed; any survivor names its own state.
|
||||
|
||||
**Correction for the record:** the night-12 `skatebench` "reproductions" (6 episodes, `sk_run.out`)
|
||||
were a DETECTOR ARTIFACT, not this bug. The first detector build tested only `legCycleSpeed==0`,
|
||||
which is NORMAL on a peer -- the body channel poses it -- so it fired on every healthy movement
|
||||
phase. It was corrected the same day to require both channels idle. Old-format lines
|
||||
(`legCycleSpeed=`, no `bodyCyc=`) are not evidence of anything.
|
||||
|
||||
Related: #130 (Vulture skating) is very likely the same defect -- re-test it against this build
|
||||
before spending separate effort."""
|
||||
|
||||
gitea.comment(52, BODY)
|
||||
gitea.comment(130,
|
||||
"Cross-ref: #52's root cause was found and fixed 2026-08-07 (`6a96fb6`) -- a replicant could not "
|
||||
"start walking between gait-change records, so a peer parked at Standing with a live replicated "
|
||||
"demand kept its position advancing with a dead animation channel. That is the same shape as the "
|
||||
"skating reported here. **Re-test this against a build newer than `6a96fb6` before investigating "
|
||||
"separately.** Full write-up in #52.")
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# #142 CROUCH does not toggle -- name the gate.
|
||||
#
|
||||
# FIELD (Oracle, night 13): "the crouch button did not toggle to display
|
||||
# crouched ... no state change ... the light next to it always flashes when
|
||||
# pressed, but state does not change. Remains in stand mode."
|
||||
#
|
||||
# That is the EJECT bug's signature: the press reaches the handler (lamp
|
||||
# responds) and a GATE silently declines it. DuckRequestMessageHandler
|
||||
# always succeeds -- it just sets duckState=1. The CONSUMER (mech4.cpp) is
|
||||
# where it dies:
|
||||
#
|
||||
# if (duckState != 0 && squatCapable != 0) {
|
||||
# if (mapPosture == 1) squat;
|
||||
# else if (mapPosture == 2) rise;
|
||||
# else if (BT_DUCK_LOG) log; <- only diagnostic, env-gated
|
||||
# duckState = 0;
|
||||
# }
|
||||
#
|
||||
# squatCapable == 0 skips the whole block: no log, and the latch is not even
|
||||
# consumed. A new ungated [duck] REQUEST DROPPED receipt covers both misses.
|
||||
#
|
||||
# Runs the SAME chassis twice is pointless -- squatCapable is per-model
|
||||
# ('squ'/'sqd' clips shipped), so sweep several. CROUCH is button 0x13
|
||||
# (shipped bindings: left column 0x10-0x15 = map+/map-/IR/CROUCH/searchlight/
|
||||
# display). BT_BTNTEST2 exists precisely for "crouch then rise".
|
||||
# =========================================================================
|
||||
set -x
|
||||
V="${1:-madcat}"
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
rm -f cr_${V}.log
|
||||
bt_expert_egg MP.EGG CR.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=${V}/" CR.EGG
|
||||
|
||||
( export BT_BTNTEST=0x13,900,960 # CROUCH press
|
||||
export BT_BTNTEST2=0x13,1500,1560 # and again (toggle back / retry)
|
||||
export BT_DUCK_LOG=1 BT_GAIT_LOG=1 BT_KEY_NOFOCUS=1
|
||||
bt_launch cr_${V}.log CR.EGG 0x03 )
|
||||
for i in $(seq 1 60); do grep -aq "btntest" cr_${V}.log 2>/dev/null && break; sleep 2; done
|
||||
sleep 45
|
||||
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 2
|
||||
|
||||
echo "=================== #142 CROUCH vehicle=$V ==================="
|
||||
echo "--- 1. did the press reach the handler? ---"
|
||||
grep -a "btntest" cr_${V}.log | head -4
|
||||
grep -a "DuckRequest" cr_${V}.log | head -3
|
||||
echo
|
||||
echo "--- 2. THE GATE: why was it dropped? ---"
|
||||
grep -a "REQUEST DROPPED" cr_${V}.log | head -4
|
||||
echo -n "dropped-receipt count: "; grep -ac "REQUEST DROPPED" cr_${V}.log
|
||||
echo
|
||||
echo "--- 3. did a posture change actually happen? ---"
|
||||
grep -aE "\[duck\] (SQUAT|RISE)" cr_${V}.log | head -4
|
||||
echo -n "SQUAT/RISE events: "; grep -acE "\[duck\] (SQUAT|RISE)" cr_${V}.log
|
||||
echo
|
||||
echo "--- 4. does this chassis even ship the squat clips? ---"
|
||||
grep -aiE "squatCapable|squ.*clip|sqd" cr_${V}.log | head -4
|
||||
@@ -0,0 +1,59 @@
|
||||
"""#142 -- correct the report and record what benching established. ASCII only."""
|
||||
import sys
|
||||
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
|
||||
import gitea
|
||||
|
||||
gitea.comment(142, """**RE-SCOPED 2026-08-07 -- the mech crouches fine; this is a missing PANEL ANIMATION.**
|
||||
|
||||
The original report ("the crouch button did not toggle to display crouched ... the light next to
|
||||
it always flashes when pressed, but state does not change") reads like a locomotion or lamp bug.
|
||||
Benching says it is neither.
|
||||
|
||||
## What works (benched, `scratchpad/night13/crouch142.sh` + `crouchmp.sh`)
|
||||
|
||||
* SOLO, madcat: both presses reach `DuckRequestMessageHandler`, zero drops, `[duck] SQUAT (posture
|
||||
1 -> leg clip 2)` then `squat clip parked`, then `[duck] RISE`. A full toggle cycle.
|
||||
* MULTIPLAYER, same chassis: identical -- 2 requests, 0 drops, SQUAT + RISE. So the MP path is not
|
||||
refusing it either.
|
||||
|
||||
A receipt was added at the consumer's silent miss (`[duck] REQUEST DROPPED`, ungated) covering both
|
||||
gates -- `squatCapable == 0` (which skips the consumer entirely AND leaves `duckState` latched at 1,
|
||||
with no log at all today) and `mapPosture` not 1/2 (previously logged only under `BT_DUCK_LOG`,
|
||||
which no player sets). Neither fired on madcat. Chassis without `squ`/`sqd` clips are still
|
||||
untested; the bench takes a vehicle argument for that sweep.
|
||||
|
||||
## What the pilot actually sees
|
||||
|
||||
The crouch button's LAMP is momentary press feedback, not state. Traced with `BT_LAMP_LOG`:
|
||||
|
||||
PRESS -> [lamp] 0x13 <- 0x3c (lit)
|
||||
SQUAT -> mech crouches, clip parked
|
||||
RELEASE -> [lamp] 0x13 <- 0x14 (unlit) <-- while still CROUCHED
|
||||
PRESS2 -> [lamp] 0x13 <- 0x3c
|
||||
RISE -> mech stands
|
||||
RELEASE -> [lamp] 0x13 <- 0x14
|
||||
|
||||
Crouched and standing are visually identical, so the lamp can never carry posture.
|
||||
|
||||
## What it is SUPPOSED to be (era testimony, this day)
|
||||
|
||||
The operator, correcting the framing: the crouch button is supposed to **animate a MECH SYMBOL next
|
||||
to the button**, standing <-> crouching. Not a two-state lamp -- a missing animation.
|
||||
|
||||
VGL Lynx: *"When a mech stops, crouch button lowers its stance and plays crouch animation. Mech is
|
||||
immobilized until crouch is pushed again, and mech rises."* Draco: *"Checks out with my memories."*
|
||||
|
||||
## Two gaps, and the second is the reported one
|
||||
|
||||
1. **No immobilization while crouched.** Nothing in the port gates movement on `duckState` or the
|
||||
parked leg alarm -- grep of every consumer of both across `game/reconstructed/` finds no
|
||||
speed/throttle/demand gate. Per Lynx the mech should be immobile until the button is pushed
|
||||
again. ⚠ Worth checking what a crouched mech that IS driven does today, since the leg clip is
|
||||
parked -- a moving mech with a parked leg channel is the [skate] signature (#52).
|
||||
2. **No stance symbol.** No gauge element anywhere in `btl4gau*.cpp` draws a posture/stance icon,
|
||||
and the decomp has no crouch/squat/stance/duck graphic STRING (only substring false positives:
|
||||
"existance", "distance"). So it is likely an authored IMAGE element on the secondary/radar MFD
|
||||
rather than something findable by name -- next step is the secondary MFD's element list in the
|
||||
binary, not another string search.
|
||||
|
||||
Not fixed. Re-scoped, with the locomotion half cleared and the real target named.""")
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# #142 CROUCH in MULTIPLAYER -- solo works, so MP is the variable.
|
||||
#
|
||||
# crouch142.sh proved a madcat crouches and rises cleanly SOLO: both presses
|
||||
# reached DuckRequest, zero REQUEST DROPPED, SQUAT then RISE fired. The
|
||||
# reporters were in MP, and the wording matters:
|
||||
# Oracle: "the crouch button did not toggle to DISPLAY crouched ... no state
|
||||
# change ... the light next to it always flashes when pressed, but
|
||||
# state does not change. Remains in stand mode."
|
||||
#
|
||||
# Three things that could produce that with the mech itself working:
|
||||
# (a) MP-only refusal -- some gate differs on a networked master
|
||||
# (b) replication -- master squats, PEER never poses it
|
||||
# (c) indicator -- mech squats, but the button LAMP / state readout
|
||||
# never latches, so the pilot sees "stand"
|
||||
#
|
||||
# Same chassis as the passing solo run, so MP is the only changed variable.
|
||||
# A presses crouch twice; B observes. Read all three layers.
|
||||
# =========================================================================
|
||||
set -x
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
bt_assert_player_env
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
rm -f cm_a.log cm_b.log cm_relay.log
|
||||
bt_expert_egg MP.EGG CM.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" CM.EGG
|
||||
|
||||
( export BT_DUCK_LOG=1 BT_GAIT_LOG=1 BT_MP_LOG=1
|
||||
bt_launch cm_b.log CM.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
( export BT_BTNTEST=0x13,900,960 BT_BTNTEST2=0x13,1800,1860
|
||||
export BT_DUCK_LOG=1 BT_GAIT_LOG=1 BT_MP_LOG=1 BT_KEY_NOFOCUS=1
|
||||
bt_launch cm_a.log CM.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py CM.EGG 127.0.0.1:1501 127.0.0.1:1601 > cm_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep 240
|
||||
kill $RELAY 2>/dev/null
|
||||
sleep 3
|
||||
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
|
||||
|
||||
AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" cm_a.log | head -1 | cut -d= -f2)
|
||||
echo "=================== #142 CROUCH in MP (A=${AID:-?}) ==================="
|
||||
echo "--- (a) did A's press reach the handler, and was it dropped? ---"
|
||||
grep -a "btntest" cm_a.log | head -4
|
||||
echo -n "DuckRequests : "; grep -ac "DuckRequest" cm_a.log
|
||||
echo -n "REQUEST DROPPED: "; grep -ac "REQUEST DROPPED" cm_a.log
|
||||
grep -a "REQUEST DROPPED" cm_a.log | head -3
|
||||
echo
|
||||
echo "--- (b) did A's own mech actually change posture? ---"
|
||||
grep -aE "\[duck\] (SQUAT|RISE|squat clip)" cm_a.log | head -6
|
||||
echo -n "SQUAT/RISE on A: "; grep -acE "\[duck\] (SQUAT|RISE)" cm_a.log
|
||||
echo
|
||||
echo "--- (c) did the PEER pose the squat? (legState ships in the type-3 record) ---"
|
||||
echo -n "B duck lines for A: "; grep -ac "\[duck\]" cm_b.log
|
||||
grep -a "\[duck\]" cm_b.log | head -4
|
||||
echo -n "B legState-2/3 sightings: "; grep -aoE "legState=[23]" cm_b.log | wc -l
|
||||
echo
|
||||
echo "--- (d) the INDICATOR: does anything latch a crouched state? ---"
|
||||
grep -aiE "lamp.*0x13|stability|duckState" cm_a.log | head -6
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# #142 -- does CROUCH reset on respawn? (Oracle: "crouch wasn't resetting
|
||||
# on respawn ... i guess mechs always spawn standing")
|
||||
#
|
||||
# Mech::Reset (@0049fb74) stands the mech up in the binary:
|
||||
# *(this+0x398) = 0 duckState
|
||||
# legStateAlarm -> 0 (this+0x39c)
|
||||
# bodyStateAlarm -> 0 (this+0x714)
|
||||
# death + leg/body reset latches, idleStrideScale = 1.0
|
||||
# The port had dropped every one of those, so a pilot who died CROUCHED came
|
||||
# back crouched.
|
||||
#
|
||||
# THE TEST: A crouches, then is killed while crouched, then respawns.
|
||||
# PASS: after "[respawn] Mech::Reset", the leg alarm is 0 (standing) and
|
||||
# duckState is 0 -- and no squat clip is parked.
|
||||
# FAIL: legLvl stays 1 (parked in 'sqd') across the respawn.
|
||||
#
|
||||
# B force-damages A so the death lands while A is parked in the squat.
|
||||
# =========================================================================
|
||||
set -x
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
rm -f dr_a.log dr_b.log dr_relay.log
|
||||
bt_expert_egg MP.EGG DR.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" DR.EGG
|
||||
|
||||
( export BT_DEATH_LOG=1 BT_MP_LOG=1
|
||||
bt_launch dr_b.log DR.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
# A: crouch early and STAY down, so the kill lands on a crouched mech
|
||||
( export BT_BTNTEST=0x13,900,960 BT_SELF_DAMAGE=6
|
||||
export BT_DUCK_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_KEY_NOFOCUS=1
|
||||
bt_launch dr_a.log DR.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py DR.EGG 127.0.0.1:1501 127.0.0.1:1601 > dr_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep 260
|
||||
kill $RELAY 2>/dev/null
|
||||
sleep 3
|
||||
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
|
||||
|
||||
echo "=================== #142 CROUCH ACROSS RESPAWN ==================="
|
||||
echo "--- did A crouch, then die, then respawn? ---"
|
||||
grep -aE "\[duck\] (SQUAT|RISE)|death cycle START|Mech::Reset" dr_a.log | head -8
|
||||
echo
|
||||
echo "--- leg alarm around the respawn (1 = parked in 'sqd', 0 = standing) ---"
|
||||
grep -aE "probe legLvl|Mech::Reset" dr_a.log | tail -12
|
||||
echo
|
||||
echo "--- the strip's frame after respawn (must NOT be the up-arrow) ---"
|
||||
grep -a "probe legLvl" dr_a.log | tail -3
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# PANIC EJECT -> does the respawn replicate? (#108, night 13)
|
||||
#
|
||||
# Fourth attempt at the trigger. The first three drove the panic button
|
||||
# through BT_BTNTEST (EmitButton -> RIO queue). It never reached the
|
||||
# mapper: no [eject], no PUNCH-OUT, not even a lamp change -- and the same
|
||||
# seam failed to toggle the searchlight (0x14), so it was the SEAM, not the
|
||||
# button address. BT_EJECT_AT is the purpose-built hook (mech4.cpp:3310):
|
||||
# it synthesizes the identical press inside the input path -- "one message,
|
||||
# one dispatch", the same Mech::EjectPilotMessageID the key sends -- and
|
||||
# repeats every 300 frames, so one run yields many punch-outs.
|
||||
#
|
||||
# PASS : for every punch-out on A, B logs
|
||||
# "[respawn] replicant <A> un-wrecked + warp (mode 9->1)"
|
||||
# FAIL : punch-outs on A with no matching un-wreck <- the ghost
|
||||
#
|
||||
# Control already banked (ejectghost.sh): 9 force-kill deaths -> 8 peer
|
||||
# un-wrecks, 0 ghost lines. NORMAL death replication is good on this build,
|
||||
# so an eject-only shortfall here is the defect, not a broken rig.
|
||||
# =========================================================================
|
||||
set -x
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
rm -f ea_a.log ea_b.log ea_relay.log
|
||||
|
||||
bt_expert_egg MP.EGG EA.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" EA.EGG
|
||||
|
||||
( export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_FOG_LOG=1
|
||||
bt_launch ea_b.log EA.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
( export BT_AUTODRIVE=0.6 BT_EJECT_AT=1800
|
||||
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
|
||||
bt_launch ea_a.log EA.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py EA.EGG 127.0.0.1:1501 127.0.0.1:1601 > ea_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep 300
|
||||
kill $RELAY 2>/dev/null
|
||||
sleep 3
|
||||
bt_kill_ours
|
||||
sleep 2
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 3
|
||||
|
||||
AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" ea_a.log | head -1 | cut -d= -f2)
|
||||
echo "=================== PANIC-EJECT REPLICATION ==================="
|
||||
echo "A entity: ${AID:-UNKNOWN}"
|
||||
echo
|
||||
echo "--- 1. did punch-outs actually happen? (if 0 the run is VOID) ---"
|
||||
echo -n "DeathWithoutHonor notices : "; grep -ac "DeathWithoutHonor" ea_a.log
|
||||
echo -n "PUNCH-OUT lines : "; grep -ac "PUNCH-OUT" ea_a.log
|
||||
grep -a "PUNCH-OUT" ea_a.log | head -6
|
||||
echo
|
||||
echo "--- 2. A's own death/respawn cycles ---"
|
||||
echo -n "death cycle STARTs : "; grep -ac "death cycle START" ea_a.log
|
||||
echo -n "Mech::Reset : "; grep -ac "Mech::Reset ${AID:-@@@}" ea_a.log
|
||||
echo
|
||||
echo "--- 3. B: the peer's un-wrecks for A (THE NUMBER THAT MATTERS) ---"
|
||||
echo -n "un-wrecks seen : "; grep -a "respawn\] replicant" ea_b.log | grep -ac "${AID:-@@@}"
|
||||
grep -a "respawn\] replicant" ea_b.log | grep -a "${AID:-@@@}" | head -8
|
||||
echo
|
||||
echo "--- 4. B: ghost detector + searchlight cones ---"
|
||||
echo -n "ghost lines : "; grep -ac "\[ghost\]" ea_b.log
|
||||
grep -a "\[ghost\]" ea_b.log | head -4
|
||||
echo -n "cone SHOWN/HIDDEN : "; grep -ac "\[spot\] cone" ea_b.log
|
||||
echo
|
||||
echo "--- 5. VERDICT INPUTS ---"
|
||||
echo "punch-outs=$(grep -ac 'PUNCH-OUT' ea_a.log) A-respawns=$(grep -ac 'death cycle START' ea_a.log) B-unwrecks=$(grep -a 'respawn\] replicant' ea_b.log | grep -ac "${AID:-@@@}")"
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# EJECT-GHOST bench (#108 / night 13) -- does a PANIC EJECT replicate its
|
||||
# respawn to peers?
|
||||
#
|
||||
# FIELD REPORT: many ghost mechs tonight, all from panic-button self
|
||||
# destructs, none respawning properly -- and the operator has seen
|
||||
# eject-respawn work every time before tonight, so this is a REGRESSION
|
||||
# (night 12 = build 774, tonight = 817).
|
||||
#
|
||||
# FIELD EVIDENCE (staged logs): the ejecting mech's OWNER ran the whole
|
||||
# death path and respawned and kept driving in the same round, while BOTH
|
||||
# peers created his wreck and never processed the un-wreck. The same mech's
|
||||
# NORMAL deaths replicated their respawns 4-6 times in that same session.
|
||||
#
|
||||
# THE TEST: A punches out via the real click seam (button 0x3D, the panic
|
||||
# button -- the same addr the field log shows). B watches.
|
||||
# PASS : B logs "[respawn] replicant <A> un-wrecked + warp (mode 9->1)"
|
||||
# FAIL : B logs the wreck and never the un-wreck <- the ghost
|
||||
# A CONTROL kill comes first (B force-damages A), so the SAME run shows a
|
||||
# normal death replicating correctly -- otherwise a silent B proves nothing.
|
||||
#
|
||||
# Press polls: >=900 per the harness contract (round-start jitter eats
|
||||
# earlier presses). Panic twice, so one missed press does not read as a
|
||||
# pass.
|
||||
# =========================================================================
|
||||
set -x
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
rm -f eg_a.log eg_b.log eg_relay.log
|
||||
|
||||
bt_assert_player_env
|
||||
bt_expert_egg MP.EGG EG.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" EG.EGG
|
||||
|
||||
# ---- node B: OBSERVER + the control killer -------------------------------
|
||||
( export BT_MP_FORCE_DMG=1
|
||||
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
|
||||
bt_launch eg_b.log EG.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
# ---- node A: walks, gets killed (control), then PUNCHES OUT twice --------
|
||||
( export BT_AUTODRIVE=0.6
|
||||
export BT_BTNTEST=0x3d,900,960 # panic eject #1
|
||||
export BT_BTNTEST2=0x3d,2400,2460 # panic eject #2
|
||||
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
|
||||
bt_launch eg_a.log EG.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py EG.EGG 127.0.0.1:1501 127.0.0.1:1601 > eg_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep 300
|
||||
kill $RELAY 2>/dev/null
|
||||
sleep 3
|
||||
bt_kill_ours
|
||||
sleep 2
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 3
|
||||
|
||||
AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" eg_a.log | head -1 | cut -d= -f2)
|
||||
echo "=================== EJECT-GHOST RESULT ==================="
|
||||
echo "A's entity id: ${AID:-UNKNOWN}"
|
||||
echo
|
||||
echo "--- did the scripted panic actually press? ---"
|
||||
grep -a "btntest" eg_a.log
|
||||
echo
|
||||
echo "--- A: punch-outs + its own death/respawn cycle ---"
|
||||
grep -aE "\[eject\]|PUNCH-OUT|death cycle START|dz\] GRANTED|Mech::Reset" eg_a.log | head -20
|
||||
echo
|
||||
echo "--- B: the peer's view of A, in order (wreck / un-wreck) ---"
|
||||
grep -aE "wreck:|respawn\] replicant" eg_b.log | grep -a "${AID:-@@@}" | head -20
|
||||
echo
|
||||
echo "--- B: totals for A ---"
|
||||
echo -n "wrecks seen : "; grep -a "wreck:" eg_b.log | grep -ac "${AID:-@@@}"
|
||||
echo -n "respawns seen : "; grep -a "respawn\] replicant" eg_b.log | grep -ac "${AID:-@@@}"
|
||||
echo
|
||||
echo "--- B: ghost detector ---"
|
||||
grep -a "\[ghost\]" eg_b.log | head -5
|
||||
echo -n "ghost lines: "; grep -ac "\[ghost\]" eg_b.log
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# EJECT + SEARCHLIGHT (#108, night 13) -- the operator's observation was
|
||||
# that the ghost mechs all had their SEARCHLIGHTS ON. A plain panic eject
|
||||
# respawns fine (ejectghost.sh), so the searchlight is the variable.
|
||||
#
|
||||
# WHY IT IS PLAUSIBLE, mechanically: the searchlight is the only thing that
|
||||
# attaches EXTRA GEOMETRY into a peer's mech render tree -- btl4vid.cpp
|
||||
# builds a spot.bgf cone as a DPLStaticChildRenderable parented to the
|
||||
# lamp's mount-segment renderable. The death path then does a wreck swap
|
||||
# ('victim -> thrdbr.bgf + debris') and respawn does 'rebuilt intact model
|
||||
# (N segs restored, hulk dropped)'. A child renderable held across those
|
||||
# two rebuilds is a lifetime hazard, and it would only bite mechs whose
|
||||
# lamp was ON -- which is exactly the reported population.
|
||||
#
|
||||
# SEQUENCE on A: searchlight ON (0x14) -> confirm it lit -> panic eject
|
||||
# (0x3D) -> respawn. B watches for the un-wreck.
|
||||
# PASS : B logs "[respawn] replicant <A> un-wrecked + warp"
|
||||
# FAIL : wreck with no un-wreck <- the ghost, and the searchlight is it
|
||||
#
|
||||
# NB 0x14 is the searchlight per the shipped bindings comment (left column
|
||||
# 0x10-0x15 = map+/map-/IR/CROUCH/searchlight/display). The hardware name
|
||||
# table calls it "Secondary5" -- the FUNCTION comes from the streamed .CTL
|
||||
# rows, so the script VERIFIES the lamp actually lit before trusting the
|
||||
# result; a silent B with a lamp that never came on proves nothing.
|
||||
# =========================================================================
|
||||
#
|
||||
# MODE=light -- searchlight ON, then punch out
|
||||
# MODE=nolight -- punch out with the lamp OFF (the A/B control)
|
||||
#
|
||||
# NB no BT_MP_FORCE_DMG here. ejectghost.sh had it, and that is why its
|
||||
# panic presses did nothing: the "control killer" had A dead or mid-respawn
|
||||
# for most of the run, and a punch-out press on a dead mech is a no-op.
|
||||
# That run still produced its control result -- 8 force-kill deaths, 8 peer
|
||||
# un-wrecks, so NORMAL death replication is proven good on this build.
|
||||
set -x
|
||||
MODE="${1:-light}"
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
rm -f el_${MODE}_a.log el_${MODE}_b.log el_${MODE}_relay.log
|
||||
|
||||
bt_expert_egg MP.EGG EL.EGG
|
||||
# NIGHT map: the searchlight is a night system and the fog swap only has
|
||||
# meaning there; also the field population was night-map rounds.
|
||||
sed -i "s/^map=.*/map=cavern/; s/^time=.*/time=night/; s/^vehicle=.*/vehicle=madcat/" EL.EGG
|
||||
|
||||
( export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_SPLASH_LOG=1
|
||||
bt_launch el_${MODE}_b.log EL.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
( export BT_AUTODRIVE=0.6
|
||||
if [ "$MODE" = "light" ]; then
|
||||
export BT_BTNTEST=0x14,600,660 # SEARCHLIGHT on
|
||||
export BT_BTNTEST2=0x3d,1500,1560 # then punch out
|
||||
else
|
||||
export BT_BTNTEST=0x3d,1500,1560 # punch out, lamp OFF
|
||||
fi
|
||||
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
|
||||
bt_launch el_${MODE}_a.log EL.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py EL.EGG 127.0.0.1:1501 127.0.0.1:1601 > el_${MODE}_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep 300
|
||||
kill $RELAY 2>/dev/null
|
||||
sleep 3
|
||||
bt_kill_ours
|
||||
sleep 2
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 3
|
||||
|
||||
AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" el_${MODE}_a.log | head -1 | cut -d= -f2)
|
||||
echo "=================== EJECT+SEARCHLIGHT RESULT ==================="
|
||||
echo "A's entity id: ${AID:-UNKNOWN}"
|
||||
echo
|
||||
echo "--- 1. did the scripted presses land? ---"
|
||||
grep -a "btntest" el_${MODE}_a.log
|
||||
echo
|
||||
echo "--- 2. DID THE SEARCHLIGHT ACTUALLY COME ON? (if not, the run is void) ---"
|
||||
grep -aiE "searchlight|\[spot\]|lightState" el_${MODE}_a.log | head -10
|
||||
echo
|
||||
echo "--- 3. A: punch-out + own respawn ---"
|
||||
grep -aE "\[eject\]|PUNCH-OUT|dz\] GRANTED" el_${MODE}_a.log | head -10
|
||||
echo
|
||||
echo "--- 4. B: the peer's view of A (wreck / un-wreck, in order) ---"
|
||||
grep -aE "wreck:|respawn\] replicant" el_${MODE}_b.log | grep -a "${AID:-@@@}" | head -20
|
||||
echo -n "wrecks: "; grep -a "wreck:" el_${MODE}_b.log | grep -ac "${AID:-@@@}"
|
||||
echo -n "respawns: "; grep -a "respawn\] replicant" el_${MODE}_b.log | grep -ac "${AID:-@@@}"
|
||||
echo
|
||||
echo "--- 5. B: searchlight cones + ghost detector ---"
|
||||
grep -aE "\[spot\]" el_${MODE}_b.log | head -6
|
||||
echo -n "cone skipped: "; grep -ac "cone skipped" el_${MODE}_b.log
|
||||
echo -n "ghost lines : "; grep -ac "\[ghost\]" el_${MODE}_b.log
|
||||
grep -a "\[ghost\]" el_${MODE}_b.log | head -3
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# A REAL PANIC EJECT -- at last. (#108 ghost + the chart's "-1000 ejecting")
|
||||
#
|
||||
# WHY SIX RIGS FAILED. It was never the trigger. BT_EJECT_AT reaches the
|
||||
# dispatch every time (proved with an [ejecttest] receipt: "FIRING punch-out
|
||||
# at frame 600/900/1200"), and the HANDLER refuses it:
|
||||
#
|
||||
# [eject] 1:139 REFUSED (mech not crippled enough)
|
||||
#
|
||||
# Mech::EjectPilotMessageHandler gates on EvaluateEjectPermission() (@0x414) --
|
||||
# a healthy mech cannot punch out. Every bench so far ejected a pristine mech.
|
||||
# In the field players eject BECAUSE they are wrecked, which is why it works
|
||||
# for them and never for me. (The button seam was never broken either; that
|
||||
# earlier conclusion was wrong too.)
|
||||
#
|
||||
# So: CRIPPLE FIRST, then eject. BT_SELF_DAMAGE grinds A down; BT_EJECT_AT
|
||||
# retries every 300 frames, so the first retry after permission is granted
|
||||
# takes it.
|
||||
#
|
||||
# WHAT THIS SETTLES:
|
||||
# chart "-1000 ejecting" -- the eject total, currently arithmetic only
|
||||
# #108 eject-ghost -- does the peer un-wreck after an EJECT death?
|
||||
# (normal deaths replicate fine: 9 deaths -> 8
|
||||
# un-wrecks, benched)
|
||||
# =========================================================================
|
||||
set -x
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
bt_assert_player_env
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
rm -f er_a.log er_b.log er_relay.log matchlog_*.txt
|
||||
bt_expert_egg MP.EGG ER.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" ER.EGG
|
||||
|
||||
( export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
|
||||
bt_launch er_b.log ER.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
# A: grind itself down, then punch out once permission is granted
|
||||
# THE GATE, decoded from Mech::EvaluateEjectPermission (@0049fa1c):
|
||||
# permitted = liveWeapons < ejectMinWeapons || liveGenerators == 0
|
||||
# || coolantFrac < 0.05 || (leg-gimped && !simLive)
|
||||
# Armour damage satisfies NONE of them -- which is why grinding A down gave
|
||||
# 53 attempts and 785 refusals. Killing the GENERATORS is the direct lever,
|
||||
# and BT_KILL_SUBSYS's comma-list form was built for precisely this bench.
|
||||
# It fires at frame 900, so arm the eject after that.
|
||||
( export BT_KILL_SUBSYS=GeneratorA,GeneratorB,GeneratorC,GeneratorD BT_EJECT_AT=1200
|
||||
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_DMG_LOG=1
|
||||
bt_launch er_a.log ER.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py ER.EGG 127.0.0.1:1501 127.0.0.1:1601 > er_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep 300
|
||||
kill $RELAY 2>/dev/null
|
||||
sleep 3
|
||||
bt_kill_ours
|
||||
sleep 2
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 3
|
||||
|
||||
AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" er_a.log | head -1 | cut -d= -f2)
|
||||
echo "=================== REAL EJECT ==================="
|
||||
echo "A entity: ${AID:-UNKNOWN}"
|
||||
echo "--- 1. did a punch-out finally LAND? ---"
|
||||
echo -n "FIRING attempts : "; grep -ac "FIRING punch-out" er_a.log
|
||||
echo -n "REFUSED : "; grep -ac "REFUSED" er_a.log
|
||||
echo -n "PUNCH-OUT : "; grep -ac "PUNCH-OUT" er_a.log
|
||||
grep -aE "PUNCH-OUT|DeathWithoutHonor" er_a.log | head -3
|
||||
echo
|
||||
echo "--- 2. CHART '-1000 ejecting': the score trajectory around it ---"
|
||||
grep -ah "player=2:1 type=" matchlog_*.txt | tail -6 | cut -c1-115
|
||||
grep -a "\[deathcost\]" er_a.log | head -2 | cut -c1-140
|
||||
echo
|
||||
echo "--- 3. #108: does the peer UN-WRECK after an eject death? ---"
|
||||
echo -n "B wreck-enters for A : "; grep -a "entered wreck state" er_b.log | grep -ac "${AID:-@@@}"
|
||||
echo -n "B un-wrecks for A : "; grep -a "un-wrecked + warp" er_b.log | grep -ac "${AID:-@@@}"
|
||||
grep -aE "entered wreck state|un-wrecked \+ warp" er_b.log | tail -6 | cut -c1-120
|
||||
echo -n "B ghost lines : "; grep -ac "\[ghost\]" er_b.log
|
||||
@@ -0,0 +1,22 @@
|
||||
import sys, re
|
||||
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
|
||||
import gitea
|
||||
|
||||
rows = gitea.all_issues("all")
|
||||
rows = [r for r in rows if not r.get("pull_request")]
|
||||
print("TOTAL ISSUES: %d" % len(rows))
|
||||
terms = {
|
||||
"crouch": r"crouch",
|
||||
"missile-dir": r"missile.*(direction|facing|foot|feet|track)|emitter",
|
||||
"night-vis": r"night|darkness|visibilit|fog|thermal|infrared|\bIR\b|predator",
|
||||
"smoke-all": r"smoke",
|
||||
"layout-save": r"layout|glass_layout|\bsave\b",
|
||||
"eject-splash": r"eject|panic|suicide|splash",
|
||||
"torso-yaw": r"torso.*(twist|yaw)|feet.*fac",
|
||||
}
|
||||
for k, pat in terms.items():
|
||||
print("\n=== %s ===" % k)
|
||||
rx = re.compile(pat, re.I)
|
||||
for r in rows:
|
||||
if rx.search(r["title"]):
|
||||
print(" #%-4s %-7s %s" % (r["number"], r["state"], r["title"][:110]))
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Night 13: post the ghost/eject findings to #108 and settle #144. ASCII only."""
|
||||
import sys
|
||||
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
|
||||
import gitea
|
||||
|
||||
gitea.comment(108, """Night 13 (2026-08-06, build **4.11.817**) -- eject-ghosts, and why the logs barely saw them.
|
||||
|
||||
## Field finding
|
||||
|
||||
Testers reported MANY ghost mechs, all from panic-button self-destructs, none respawning
|
||||
properly -- and the operator has seen eject-respawn work every time before this night, so this is a
|
||||
REGRESSION, not a gap (night 12 = build 774).
|
||||
|
||||
The logs show it happening, on three independent witnesses. Dave punch-ejected; on HIS machine the
|
||||
whole death path ran (`DeathWithoutHonor`, `PUNCH-OUT`, death transition, wreck, explosion), he
|
||||
respawned, took a drop-zone slot, and drove another 193 logged frames **in the same round**, which
|
||||
then ended normally -- no crash, no disconnect. On BOTH peers his mech's history ends at the wreck
|
||||
and never resumes; host 2 vanishes from santo's world for the remaining ~4300 lines of that round.
|
||||
|
||||
The discriminator is clean: the SAME mech's NORMAL deaths replicated their respawns 4-6 times in
|
||||
that same session. Only the eject failed.
|
||||
|
||||
## Why the ghost detector said almost nothing
|
||||
|
||||
One `[ghost]` line all night, for an unrelated live mech. The gate is
|
||||
|
||||
&& !owner->WreckBuried() // "buried wrecks are expected-silent"
|
||||
WreckBuried() { return collisionVolumeCount == 0; }
|
||||
|
||||
An eject-ghost IS a wreck that stopped receiving records, so the detector files it under
|
||||
expected-silent. It can only ever catch a LIVE replicant going quiet -- structurally blind to this
|
||||
failure.
|
||||
|
||||
## Why the census said "one" when testers saw many
|
||||
|
||||
Counting ghosts meant pairing the un-wreck receipt against
|
||||
`[BTrender] wreck: 'thrdbr.bgf' missing -> gendbr.bgf fallback` -- a MISSING-ASSET warning, not a
|
||||
death, which only prints for chassis whose wreck model is absent. Fixed in `4642129`: every
|
||||
replicant entering the wreck state now emits an ungated
|
||||
|
||||
[wreck] replicant H:E entered wreck state (mode X->9) at (x,z)
|
||||
|
||||
symmetric with the existing un-wreck line, so the count is exactly (enters - un-wrecks) per entity.
|
||||
Verified 2-node: 5 enters / 5 exits, exactly paired -- **while the old marker printed ZERO times in
|
||||
the same run**. Five real deaths, invisible to what the census was reading. Treat the "one ghost on
|
||||
night 13" number as a floor, not a count.
|
||||
|
||||
Census tooling: `scratchpad/night13/ghostcensus.py` (NB: filter the log owner's OWN mech -- an
|
||||
owner's own wreck can never pair, since `un-wrecked` only logs for replicants; not doing so
|
||||
manufactures false positives in every log).
|
||||
|
||||
## NOT reproduced by bench -- five rigs failed to trigger a punch-out at all
|
||||
|
||||
`BT_BTNTEST` never reached the mapper for the panic button (0x3D) or the searchlight (0x14): no
|
||||
`[eject]`, no `PUNCH-OUT`, not even a lamp change -- so it is the SEAM, not the address.
|
||||
`BT_EJECT_AT` (mech4.cpp:3310, the purpose-built hook) did not fire either. Benches are staged
|
||||
(`ejectghost.sh`, `ejectlight.sh`, `ejectat.sh`) with their failure modes in the headers.
|
||||
|
||||
What the benches DID establish: **normal death replication is healthy on 817** -- 9 force-kill
|
||||
deaths, 8 peer un-wrecks, 0 ghost lines. So an eject-only shortfall is the defect, not a broken rig.
|
||||
|
||||
## Open lead
|
||||
|
||||
The operator observed that the ghost mechs all had their SEARCHLIGHTS ON. Untested -- there is
|
||||
currently no key or env that toggles the searchlight headlessly, so it needs a small bench hook.
|
||||
Mechanically plausible: the searchlight is the only thing that attaches extra geometry into a peer's
|
||||
mech render tree (btl4vid.cpp builds a `spot.bgf` cone as a `DPLStaticChildRenderable` parented to
|
||||
the lamp's mount-segment renderable), and the death path does a wreck swap while respawn does
|
||||
"rebuilt intact model (N segs restored, hulk dropped)" -- a child renderable held across those two
|
||||
rebuilds is a lifetime hazard, and it would only bite mechs whose lamp was ON.
|
||||
|
||||
## Regression window
|
||||
|
||||
Commits 775-817. Two touch the death path: `91bd286` (787) rewrote the VehicleDead dispatch --
|
||||
the message that drives the respawn cycle -- including the `killer == victim` eject case and the
|
||||
filter that had blocked the panic-eject path; and `297127d` (784, death blast). Unverified.""")
|
||||
|
||||
gitea.close(144, """**NOT A BUG -- authentic. Closing.**
|
||||
|
||||
The eject sets `suppressConsole` (+0x258, `BTPlayerEjectBookkeeping`), and the #89 death-blast gate
|
||||
reads it:
|
||||
|
||||
gates : owning player's advancedDamageOn (+0x264) AND
|
||||
NOT suppressConsole (+0x258 -- eject sets it: punch-outs never blast)
|
||||
[T1 @0x4a0aa8-0x4a0ad6]
|
||||
|
||||
So a mech that punches out does not splash its neighbourhood, by design, in the 1995 binary. SAURON's
|
||||
observation ("splash worked on mechs that died close, but no splash on a panic / eject / suicide
|
||||
death") is a correct reading of authentic behaviour.
|
||||
|
||||
Closing so nobody "fixes" it. Found while investigating the night-13 eject ghosts (#108).""")
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Night 13: census every 'wreck with no following un-wreck' across all logs.
|
||||
|
||||
A peer's view of a mech death is a pair:
|
||||
[BTrender] wreck: ... (entity=H:E) <- the wreck appears
|
||||
[respawn] replicant H:E un-wrecked + warp <- it comes back
|
||||
An UNPAIRED wreck -- one with no un-wreck before that log's session ends --
|
||||
is the ghost signature. Report per SESSION so 'was it just one game?' is
|
||||
answerable, and dump the lines immediately before the first one.
|
||||
"""
|
||||
import re, sys, os, io
|
||||
|
||||
LOGDIR = r"C:\git\bt411\scratchpad\night13"
|
||||
SESS = re.compile(r"===== BT411 SESSION.*?local=(\S+ \S+)")
|
||||
WRECK = re.compile(r"\[BTrender\] wreck:.*?entity=(\d+:\d+)")
|
||||
UNWRECK = re.compile(r"\[respawn\] replicant (\d+:\d+) un-wrecked")
|
||||
EJECT = re.compile(r"\[eject\]|PUNCH-OUT")
|
||||
|
||||
def scan(path):
|
||||
sessions = [] # (startline, stamp)
|
||||
events = [] # (line, kind, ent)
|
||||
with io.open(path, "r", encoding="latin-1", errors="replace") as f:
|
||||
for n, line in enumerate(f, 1):
|
||||
m = SESS.search(line)
|
||||
if m:
|
||||
sessions.append((n, m.group(1)))
|
||||
continue
|
||||
m = WRECK.search(line)
|
||||
if m:
|
||||
events.append((n, "wreck", m.group(1))); continue
|
||||
m = UNWRECK.search(line)
|
||||
if m:
|
||||
events.append((n, "unwreck", m.group(1))); continue
|
||||
if EJECT.search(line):
|
||||
events.append((n, "eject", "-"))
|
||||
return sessions, events
|
||||
|
||||
def session_of(sessions, line):
|
||||
idx, stamp = 0, "?"
|
||||
for i, (sl, st) in enumerate(sessions):
|
||||
if sl <= line:
|
||||
idx, stamp = i + 1, st
|
||||
else:
|
||||
break
|
||||
return idx, stamp
|
||||
|
||||
for fn in sorted(os.listdir(LOGDIR)):
|
||||
if not fn.endswith(".log") or fn.startswith("FAILURE"):
|
||||
continue
|
||||
path = os.path.join(LOGDIR, fn)
|
||||
sessions, events = scan(path)
|
||||
# pair wrecks to the next un-wreck of the same entity IN THE SAME SESSION
|
||||
pending = {} # ent -> (line, sessidx)
|
||||
unpaired = []
|
||||
for (n, kind, ent) in events:
|
||||
si = session_of(sessions, n)[0]
|
||||
if kind == "wreck":
|
||||
if ent in pending and pending[ent][1] == si:
|
||||
unpaired.append(pending[ent]) # wreck superseded by another wreck
|
||||
pending[ent] = (n, si)
|
||||
elif kind == "unwreck":
|
||||
if ent in pending and pending[ent][1] == si:
|
||||
del pending[ent]
|
||||
for ent, (n, si) in pending.items():
|
||||
unpaired.append((n, si, ent))
|
||||
norm = []
|
||||
for u in unpaired:
|
||||
norm.append(u if len(u) == 3 else (u[0], u[1], "?"))
|
||||
norm.sort()
|
||||
print("=" * 72)
|
||||
print("%s sessions=%d wrecks=%d unwrecks=%d ejects=%d"
|
||||
% (fn, len(sessions),
|
||||
sum(1 for e in events if e[1] == "wreck"),
|
||||
sum(1 for e in events if e[1] == "unwreck"),
|
||||
sum(1 for e in events if e[1] == "eject")))
|
||||
if not norm:
|
||||
print(" no unpaired wrecks")
|
||||
continue
|
||||
bysess = {}
|
||||
for (n, si, ent) in norm:
|
||||
bysess.setdefault(si, []).append((n, ent))
|
||||
for si in sorted(bysess):
|
||||
stamp = sessions[si - 1][1] if 0 < si <= len(sessions) else "?"
|
||||
print(" SESSION %d (%s): %d unpaired -> %s"
|
||||
% (si, stamp, len(bysess[si]),
|
||||
", ".join("%s@%d" % (e, n) for n, e in bysess[si][:6])))
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Night 13 (2026-08-06, build 4.11.817) tracker housekeeping. ASCII only."""
|
||||
import sys
|
||||
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
|
||||
import gitea
|
||||
|
||||
BUG, INV, WIP, HUD, AUDIO, NET, WORLD, AWAIT = 1, 2, 3, 4, 5, 6, 7, 8
|
||||
SRC = ("Source: playtest night 13 (2026-08-06, build **4.11.817 (6fcff95+)**), "
|
||||
"Discord #play-testing. Field logs staged in `scratchpad/night13/` "
|
||||
"(4 players + the pod cart).\n\n")
|
||||
new = {}
|
||||
|
||||
# ---------------------------------------------------------------- NEW ISSUES
|
||||
i = gitea.create(
|
||||
"REGRESSION (817): BT_GLASS_LAYOUT=save writes a layout file with the MFD and Secondary lines MISSING",
|
||||
SRC +
|
||||
"SAURON (Michael), repeatedly: \"the save command is no longer writing to the glass_layout.cfg "
|
||||
"file since this latest build\" ... \"got my glass panels set, saved and borders off again now\" "
|
||||
"-> \"and then relaunched and it reset again\" -> \"looks like it rewrote the glass_layout.cfg "
|
||||
"again\".\n\n"
|
||||
"**The diagnostic detail:** \"the glass_layout.cfg had all the MFDs and secondary lines missing, "
|
||||
"but plasma was still there\". So `save` is not failing to write -- it writes a file in which the "
|
||||
"MFD and Secondary/Radar entries are simply absent, and the Plasma entry survives. On the next "
|
||||
"launch there is nothing to restore, so every panel resets and the border/bare state is lost too.\n\n"
|
||||
"**Workaround (confirmed by SAURON):** set `BT_GLASS_LAYOUT=load` and restore a hand-kept backup "
|
||||
"copy of `glass_layout.cfg` -- \"set to load and used backup copy of CFG file and working aok so far\".\n\n"
|
||||
"Probably the same root cause: vwe_propwash the same night -- \"my screen order borked again so "
|
||||
"I'll need the trick to re-align them\".\n\n"
|
||||
"**Suspect [T4, unverified]:** today's pod-MFD work is the only thing that touched this area. "
|
||||
"Candidates, in order: (a) `e179c70` / `67a4f09` -- `BT_POD_RGB` and the bare-panel mode changed "
|
||||
"how panel surfaces are enumerated and added the new `monitor:<name|index>` and `,bare` line "
|
||||
"forms, so the WRITER may no longer emit a line for a surface it cannot express (or the panel "
|
||||
"list it walks is now populated differently); (b) `d213c98` -- panel create/destroy moved out of "
|
||||
"the PadRIO ctor into `LBE4ControlsManager`, which changes panel LIFETIME: if the save runs after "
|
||||
"`BTGlassPanels_Destroy()`, the windows are already gone and there is nothing to serialize, which "
|
||||
"would explain exactly this signature (Plasma is not a glass panel and is destroyed elsewhere, so "
|
||||
"it alone survives).\n\n"
|
||||
"Related: #76 (main cockpit + Plasma positions not restored) -- that is the older, much smaller "
|
||||
"version of this; fix this one first, #76 may be a subset. Desktop glass mode only; the pod cart "
|
||||
"is unaffected because it runs `BT_GLASS_LAYOUT=load` off a frozen master.",
|
||||
labels=[BUG, HUD])
|
||||
new['layoutsave'] = i['number']
|
||||
|
||||
i = gitea.create(
|
||||
"Missiles launch along the LEG/FOOT facing, then curve to the target -- peer POV only",
|
||||
SRC +
|
||||
"Oracle: \"the missile thing we observed where missiles are firing in the direction the mech feet "
|
||||
"are facing\" ... \"and then coming around to track the target\" ... \"**the emitter is following "
|
||||
"the foot facing**\".\n\n"
|
||||
"Confirmed by Oracle as **peer POV only** (epilectrik: \"only from peer pov though right\" -> "
|
||||
"\"yes correct\"): from your own cockpit the launch looks right; on a REPLICANT the launch vector "
|
||||
"comes off the leg/hip yaw instead of the torso/turret yaw. The homing itself works -- the missile "
|
||||
"curves onto the target after launch -- so this is the muzzle/emitter TRANSFORM on the replicated "
|
||||
"mech, not the guidance.\n\n"
|
||||
"Reads as the replicant's weapon hardpoint being attached to (or composed against) the wrong node "
|
||||
"in the segment hierarchy -- the leg/root segment rather than the twisted torso. Compare against "
|
||||
"the master-side emitter, which is correct.\n\n"
|
||||
"Related: #37 (MadCat torso is BACKWARDS), #70 (torso twist stops working after respawn) -- all "
|
||||
"three are torso-yaw composition on a replicated model, and may share a cause.",
|
||||
labels=[BUG, NET])
|
||||
new['missiledir'] = i['number']
|
||||
|
||||
i = gitea.create(
|
||||
"CROUCH does not toggle: the lamp flashes on every press but the mech stays STANDING",
|
||||
SRC +
|
||||
"Reported by Oracle and previously by SAURON. Oracle: \"the crouch button did not toggle to "
|
||||
"display crouched\" ... \"no state change\" ... \"button flickers sometimes on press, **the light "
|
||||
"next to it always flashes when pressed**, but state does not change. Remains in stand mode\".\n\n"
|
||||
"So the INPUT is arriving and the annunciator responds -- the press is seen all the way to the "
|
||||
"lamp -- but the crouch state itself never changes. That narrows it to the state transition / "
|
||||
"gait request rather than the button wiring.\n\n"
|
||||
"**Not to be confused with (and NOT a bug):** throttle-up does not stand you back up. Settled this "
|
||||
"night by primary source -- Lynx, quoting the original manual: \"Throttle up should not make you "
|
||||
"stand. Verified in manual. You must push the button again to stand. I agree that throttle up "
|
||||
"'should' do that, but it's not designed that way.\" Standing requires a second CROUCH press, "
|
||||
"which makes this bug worse than it looks: with no working toggle there is no designed way out of "
|
||||
"crouch. Locomotion/CROUCH background: `context/locomotion.md`.",
|
||||
labels=[BUG])
|
||||
new['crouch'] = i['number']
|
||||
|
||||
i = gitea.create(
|
||||
"One panel hit makes smoke erupt from MULTIPLE/ALL locations at once (seen on the sensor special panel)",
|
||||
SRC +
|
||||
"Oracle: \"the smoke sometimes emitting from multiple locations all at once when a location like "
|
||||
"the sensor special panel is hit during testing. I saw this several times. It's not just with the "
|
||||
"special panel, but it might have something to do with reaching a certain damage threshold which "
|
||||
"is fairly easy to do quickly with a special panel. So you hit the special with an alpha and "
|
||||
"suddenly the entire target mech emits smoke from all panels.\"\n\n"
|
||||
"**Open question from the reporter, and the key discriminator:** \"What I'm not sure about is if "
|
||||
"it's emitting only from previously damaged panels.\" If it is only previously-damaged panels, "
|
||||
"this is a threshold that re-triggers the effect on every already-damaged zone at once (a "
|
||||
"retrigger/latch bug). If it is genuinely ALL panels, the emitter is being attached per-mech "
|
||||
"instead of per-zone. Answer that first -- it picks the fix.\n\n"
|
||||
"Suspected trigger is a whole-mech damage THRESHOLD being crossed rather than the specific zone, "
|
||||
"which a special-panel alpha reaches quickly.\n\n"
|
||||
"Related: #90 (flames/smoke render less often and shorter than the original), #114 (missile impact "
|
||||
"smoke too thick), #129 (closed: respawned mech emitted the wreck plume) -- same damage-effect "
|
||||
"emitter family.",
|
||||
labels=[BUG, INV])
|
||||
new['smokeall'] = i['number']
|
||||
|
||||
i = gitea.create(
|
||||
"No SPLASH damage from a PANIC/EJECT/suicide death (splash from normal deaths now confirmed working)",
|
||||
SRC +
|
||||
"SAURON: \"Splash damage looked good to me, splash worked on mechs that died close, **but no "
|
||||
"splash on a panic / eject / suicide death**.\"\n\n"
|
||||
"Filed as the leftover sub-case of #89, which is otherwise verified working this night and closed.\n\n"
|
||||
"**Authenticity is NOT established** -- do not 'fix' this before deciding what the original did. A "
|
||||
"panic eject is plausibly a different death path (the pilot leaves; whether the chassis still "
|
||||
"detonates with an explosion payload is an open era question), so a self-destruct that does no "
|
||||
"splash could be correct. Decide from the binary's eject/death path first: does the eject route "
|
||||
"raise the same explosion object as a combat death, and does that object carry the splash payload?\n\n"
|
||||
"Related: #89, #106 (closed: splash burstCount dropped), #118/#109 (EJECT/PANIC wiring), #134 "
|
||||
"(closed: panic eject score penalty).",
|
||||
labels=[INV])
|
||||
new['ejectsplash'] = i['number']
|
||||
|
||||
i = gitea.create(
|
||||
"ThermalSight / IR ('predator vision'): the VISIBLE half is unimplemented -- and it was never a heat image",
|
||||
SRC +
|
||||
"epilectrik: \"IR we solved but I think haven't implemented yet\" -- decode exists, presentation "
|
||||
"does not, and there was no tracker item for the visible half (#61 covers the dead ToggleLamp "
|
||||
"handler; #123 is the same shape for the searchlight).\n\n"
|
||||
"**Primary-source description of what it should look like** -- Oracle, who played the original "
|
||||
"pods at Lazer Park, this night:\n"
|
||||
"- \"the IR was not what you would expect ... it was called predator vision, but it just looked "
|
||||
"more like a **random palette shift**\"\n"
|
||||
"- \"it was **not a heat image**\"\n"
|
||||
"- \"which is why we generally called it LSD vision in Lazer Park\"\n"
|
||||
"- \"IMHO it did nothing to improve visibility. All I found was that you had to look for some "
|
||||
"movement and go after that\"\n"
|
||||
"- epilectrik's recollection: it was a **test mode in the TH division hardware** that they "
|
||||
"switched on, so it was not a specifically designed effect. Oracle: \"hardware optimized\".\n\n"
|
||||
"**Why this matters for the port [T3]:** do not build a thermal/heat-gradient shader. The target "
|
||||
"is a palette/colour-table transform on the existing image -- most likely whatever the 1995 "
|
||||
"hardware path did when its test mode was enabled. Scope it from the decomp (and, if it really is "
|
||||
"a hardware mode, decide what the honest modern equivalent is) before writing anything.\n\n"
|
||||
"Related: #61 (ThermalSight ToggleLamp), #123 (searchlight visible half -- the same "
|
||||
"toggle-works/presentation-deferred split).",
|
||||
labels=[INV, WORLD])
|
||||
new['ir'] = i['number']
|
||||
|
||||
print()
|
||||
print("NEW:", new)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Night 13 housekeeping, part 2: comments + state changes on existing issues."""
|
||||
import sys
|
||||
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
|
||||
import gitea
|
||||
|
||||
N13 = "Playtest night 13 (2026-08-06, build **4.11.817 (6fcff95+)**). "
|
||||
|
||||
# ---- #89 SPLASH: verified working, close -----------------------------------
|
||||
gitea.close(89,
|
||||
N13 + "**VERIFIED WORKING -- closing.**\n\n"
|
||||
"SAURON: \"Splash damage looked good to me, **splash worked on mechs that died close**\". "
|
||||
"epilectrik: \"splash damage seems ok?\" -- no dissent from any tester.\n\n"
|
||||
"Oracle (original-pod player) also gave the era calibration that explains why splash reads as "
|
||||
"inconsistent rather than absent, and says our current behaviour matches it: \"sometimes you "
|
||||
"really catch it and sometimes not. It jogged my memory and I vividly recall that **being at "
|
||||
"different elevations on a hillside sometimes made a real hash of it** -- the effect could be much "
|
||||
"stronger or weaker\" ... \"it was **not** the mechwarrior 3/4 splash model\". SAURON: \"that seems "
|
||||
"correct to me\". So an unpredictable, elevation-sensitive splash is AUTHENTIC and should not be "
|
||||
"'fixed' into a clean radius falloff.\n\n"
|
||||
"One sub-case survives and is filed separately as **#144**: no splash on a panic/eject/suicide "
|
||||
"death (authenticity not yet established).")
|
||||
|
||||
# ---- #108 GHOST MECH: recurred ---------------------------------------------
|
||||
gitea.comment(108,
|
||||
N13 + "**Still present, and it was the headline problem of the night.** epilectrik: \"**ghost mech "
|
||||
"biggest surprise**\" when reviewing regressions.\n\n"
|
||||
"No new detail beyond the existing repro in this issue -- recording the recurrence on 817 so the "
|
||||
"issue is not read as stale. Field logs for all four players (3 desktop + the pod cart) are staged "
|
||||
"in `scratchpad/night13/`, including the pod's matchlogs, which is the first time this issue has "
|
||||
"log coverage from a hardware seat as well. Analysis: `docs/GHOST_MECH_ANALYSIS.md`.")
|
||||
|
||||
# ---- #76 layout restore: superseded by #140 --------------------------------
|
||||
gitea.comment(76,
|
||||
N13 + "**Overtaken by #140.** As of 817 the problem is no longer 'two windows fail to restore' -- "
|
||||
"`BT_GLASS_LAYOUT=save` now writes a `glass_layout.cfg` with the MFD and Secondary lines missing "
|
||||
"entirely (Plasma survives), so nothing restores at all. See #140 for the reports and the suspect "
|
||||
"commits.\n\n"
|
||||
"Fix #140 first; this issue may turn out to be a subset of it. Re-verify the two windows named "
|
||||
"here only once saving writes a complete file again.")
|
||||
|
||||
# ---- #123 searchlight / night visibility: era testimony --------------------
|
||||
gitea.comment(123,
|
||||
N13 + "**Primary-source testimony on night visibility** from Oracle, who played the original pods "
|
||||
"at Lazer Park. This is directly about the deferred half of this issue (the fog swap), and it "
|
||||
"raises the priority of the fog over the beam:\n\n"
|
||||
"- \"some maps were not very dark, some were very dark\"\n"
|
||||
"- \"**the FOG was what really killed visibility**\"\n"
|
||||
"- \"I do recall basically stumbling into each other point blank at times and **having to use "
|
||||
"radar to navigate**\"\n"
|
||||
"- \"it could be very difficult to make a shot in those conditions\"\n"
|
||||
"- \"it was 90's tech there\"\n\n"
|
||||
"Takeaway for the implementation: the authentic night experience is driven by FOG DENSITY, not by "
|
||||
"ambient darkness, and it was severe enough that radar navigation was the norm. The "
|
||||
"`searchlightfog=` / `nosearchlightfog=` environment swap described above is therefore the "
|
||||
"high-value half of this work, not the projected beam.\n\n"
|
||||
"Operator note the same night: general night-visibility tuning is **deferred** until the correct "
|
||||
"behaviour is known -- epilectrik: \"the night time visibility I might defer for now since we "
|
||||
"don't really know the correct behavior ... I'll try to retrieve it from the binary again but "
|
||||
"might be a hardware side thing\". This comment is that missing calibration.")
|
||||
|
||||
# ---- #61 ThermalSight: point at the new visible-half issue -----------------
|
||||
gitea.comment(61,
|
||||
N13 + "Cross-ref: the **visible** half of ThermalSight now has its own issue, **#145**, with "
|
||||
"primary-source description of what the IR mode actually looked like (a palette shift, explicitly "
|
||||
"NOT a heat image -- possibly a hardware test mode rather than a designed effect). Worth reading "
|
||||
"before anyone implements a presentation off the toggle this issue restored.")
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# #140 -- reach the teardown the way the FIELD does: a MISSION that ENDS.
|
||||
#
|
||||
# Three earlier attempts never exercised BTGlassPanels_Destroy at all:
|
||||
# bt_kill_ours uses `taskkill /F` (no dtors), and a graceful WM_CLOSE on a
|
||||
# solo -egg run did not reach it either. The path that matters is the one
|
||||
# testers hit constantly -- the controls manager is destroyed and rebuilt at
|
||||
# every ROUND boundary, which is exactly what the relay drives in MP.
|
||||
#
|
||||
# Panels ON for both nodes; the relay starts a round and the round ends.
|
||||
# The receipt to read is `[glasswin] destroy entry #N windows=M`:
|
||||
# #1 windows=7 then #2 windows=0 -> the double-destroy, confirmed
|
||||
# and then exactly ONE "saved N window position(s)" line, with the cfg intact.
|
||||
# =========================================================================
|
||||
set -x
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
rm -f glass_layout.cfg lr_a.log lr_b.log lr_relay.log
|
||||
|
||||
bt_expert_egg MP.EGG LR.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" LR.EGG
|
||||
|
||||
( export BT_GLASS_PANELS=1 BT_GLASS_LAYOUT=save BT_MP_LOG=1
|
||||
bt_launch lr_b.log LR.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
( export BT_GLASS_PANELS=1 BT_GLASS_LAYOUT=save BT_MP_LOG=1
|
||||
bt_launch lr_a.log LR.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py LR.EGG 127.0.0.1:1501 127.0.0.1:1601 > lr_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep 180
|
||||
kill $RELAY 2>/dev/null
|
||||
sleep 5
|
||||
bt_kill_ours
|
||||
sleep 2
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 3
|
||||
|
||||
echo "=================== #140 TEARDOWN RECEIPTS ==================="
|
||||
for n in a b; do
|
||||
echo "--- node $n: destroy entries ---"
|
||||
grep -a "destroy entry" lr_${n}.log
|
||||
echo "--- node $n: SaveLayout receipts ---"
|
||||
grep -a "window position" lr_${n}.log
|
||||
done
|
||||
echo
|
||||
echo "--- glass_layout.cfg entries ---"
|
||||
grep -aE "^[^#]+=" glass_layout.cfg 2>/dev/null || echo "(no file)"
|
||||
echo -n "entry count: "; grep -acE "^[^#]+=" glass_layout.cfg 2>/dev/null
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# #140 glass_layout.cfg save regression -- the FIELD composition.
|
||||
#
|
||||
# SAURON's report: "set my glass panels, saved and borders off ... relaunched
|
||||
# and it reset again ... the glass_layout.cfg had all the MFDs and secondary
|
||||
# lines missing, but plasma was still there".
|
||||
#
|
||||
# So the acceptance test is the full ROUND TRIP a tester does, not just a
|
||||
# single launch:
|
||||
# run 1 -- BT_GLASS_LAYOUT=save, panels come up, quit cleanly
|
||||
# check -- the cfg must list every MFD + the radar, not just plasma
|
||||
# run 2 -- relaunch; the panels must come back where they were
|
||||
#
|
||||
# The teardown is what mattered: BTGlassPanels_Destroy has TWO callers on the
|
||||
# desktop path (~PadRIO, then ~LBE4ControlsManager), and it ran SaveLayout
|
||||
# BEFORE checking whether any windows were left -- so the second pass rewrote
|
||||
# the file from an empty list.
|
||||
# =========================================================================
|
||||
set -x
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
rm -f glass_layout.cfg ls_run1.log ls_run2.log
|
||||
|
||||
# ---- run 1: create the layout ------------------------------------------
|
||||
( export BT_GLASS_PANELS=1 BT_GLASS_LAYOUT=save
|
||||
bt_launch ls_run1.log ARENA1.EGG 0x03 )
|
||||
sleep 55
|
||||
bt_kill_ours
|
||||
sleep 2
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 3
|
||||
|
||||
echo "############ RUN 1 SAVE RESULT ############"
|
||||
echo "--- every 'saved N window position(s)' line (the double-save shows here) ---"
|
||||
grep -a "window position" ls_run1.log
|
||||
echo
|
||||
echo "--- glass_layout.cfg AFTER a clean quit ---"
|
||||
if [ -f glass_layout.cfg ]; then cat glass_layout.cfg; else echo "!!! NO FILE WRITTEN"; fi
|
||||
echo
|
||||
echo "--- line census (comments excluded) ---"
|
||||
echo -n "total entries : "; grep -acE "^[^#]+=" glass_layout.cfg 2>/dev/null
|
||||
echo -n "MFD lines : "; grep -acE "^(Heat|Comm|Mfd|Eng|Weap|Sec)" glass_layout.cfg 2>/dev/null
|
||||
echo -n "plasma line : "; grep -ac "Plasma" glass_layout.cfg 2>/dev/null
|
||||
cp glass_layout.cfg /tmp/ls_after_run1.cfg 2>/dev/null
|
||||
|
||||
# ---- run 2: does it RESTORE? -------------------------------------------
|
||||
( export BT_GLASS_PANELS=1 BT_GLASS_LAYOUT=save
|
||||
bt_launch ls_run2.log ARENA1.EGG 0x03 )
|
||||
sleep 55
|
||||
bt_kill_ours
|
||||
sleep 2
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 3
|
||||
|
||||
echo "############ RUN 2 (RELAUNCH) ############"
|
||||
echo "--- restore receipts ---"
|
||||
grep -aE "restored|glass_layout|window position" ls_run2.log | head -12
|
||||
echo
|
||||
echo "--- cfg after run 2 -- must still hold every line ---"
|
||||
cat glass_layout.cfg 2>/dev/null
|
||||
echo
|
||||
echo "--- DIFF run1 -> run2 (empty = layout survived the round trip) ---"
|
||||
diff /tmp/ls_after_run1.cfg glass_layout.cfg && echo "IDENTICAL"
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# #140 -- exercise the TEARDOWN path, which is where the bug lives.
|
||||
#
|
||||
# WHY THIS EXISTS: bench_common's bt_kill_ours uses `taskkill /F`. That is a
|
||||
# HARD kill -- no destructors, so ~LBE4ControlsManager / ~PadRIO never run and
|
||||
# BTGlassPanels_Destroy (the function that saves, and the one that ran twice)
|
||||
# is never reached. Both of the first attempts died before teardown and the
|
||||
# result looked like a difference between builds when it was really a
|
||||
# difference in how far each run got. A graceful `taskkill` (NO /F) posts
|
||||
# WM_CLOSE and lets the dtor chain run.
|
||||
#
|
||||
# Pass a label; run it once on the pre-fix build and once on the fixed one.
|
||||
# =========================================================================
|
||||
LABEL="${1:-run}"
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
rm -f glass_layout.cfg lt_${LABEL}.log
|
||||
|
||||
( export BT_GLASS_PANELS=1 BT_GLASS_LAYOUT=save
|
||||
bt_launch lt_${LABEL}.log ARENA1.EGG 0x03 )
|
||||
|
||||
# wait for the panels to actually exist before asking for a shutdown
|
||||
for i in $(seq 1 60); do
|
||||
grep -aq "per-display cockpit up" lt_${LABEL}.log 2>/dev/null && break
|
||||
sleep 2
|
||||
done
|
||||
grep -aq "per-display cockpit up" lt_${LABEL}.log || { echo "PANELS NEVER CAME UP"; }
|
||||
sleep 20 # let it settle into the mission
|
||||
|
||||
PID=$(cat "$BT_PIDFILE" 2>/dev/null | head -1)
|
||||
echo "graceful close of pid $PID"
|
||||
taskkill //PID "$PID" > /dev/null 2>&1 # NO /F -- WM_CLOSE, dtors run
|
||||
for i in $(seq 1 30); do
|
||||
tasklist //FI "PID eq $PID" 2>/dev/null | grep -q btl4 || break
|
||||
sleep 1
|
||||
done
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1 # backstop
|
||||
rm -f "$BT_PIDFILE"
|
||||
sleep 2
|
||||
|
||||
echo "=============== $LABEL ==============="
|
||||
echo "--- every SaveLayout receipt (TWO lines = the double-save) ---"
|
||||
grep -a "window position" lt_${LABEL}.log
|
||||
echo
|
||||
echo "--- glass_layout.cfg entries after teardown ---"
|
||||
grep -aE "^[^#]+=" glass_layout.cfg 2>/dev/null || echo "(no file)"
|
||||
echo
|
||||
echo -n "entry count: "; grep -acE "^[^#]+=" glass_layout.cfg 2>/dev/null || echo 0
|
||||
@@ -0,0 +1,13 @@
|
||||
import sys
|
||||
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
|
||||
import gitea
|
||||
|
||||
labels = gitea.call("/labels")
|
||||
print("LABELS:", ", ".join("%s=%s" % (l["id"], l["name"]) for l in labels))
|
||||
print()
|
||||
rows = [r for r in gitea.all_issues("open") if "pull_request" not in r or not r.get("pull_request")]
|
||||
rows.sort(key=lambda r: r["number"])
|
||||
print("OPEN ISSUES: %d" % len(rows))
|
||||
for r in rows:
|
||||
lab = ",".join(l["name"] for l in r.get("labels", []))
|
||||
print("#%-4s [%-22s] %s" % (r["number"], lab, r["title"][:120]))
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# #141 -- "missiles launch along the LEG/FOOT facing, then curve to the
|
||||
# target -- PEER POV ONLY" (Oracle, night 13: "the emitter is following the
|
||||
# foot facing"; shooter's own view correct).
|
||||
#
|
||||
# WHAT THE CODE SAYS SO FAR. Both sides already pass the mount segment:
|
||||
# MissileLauncher::FireWeapon (mislanch.cpp:363) and the REPLICANT salvo
|
||||
# mirror (mislanch.cpp:478) each hand BTPushProjectile
|
||||
# `GetSegmentIndex() /*task #67 mount frame*/`. BTPushProjectile then
|
||||
# rotates the authored MuzzleVelocity through
|
||||
# `seg->GetSegmentToEntity() * localToWorld` -- so the launch direction IS
|
||||
# the segment's world frame on BOTH nodes. Task #67 fixed exactly this
|
||||
# symptom once already, master-side ("missiles fire out of his back").
|
||||
#
|
||||
# So if the peer report is real, the difference is NOT which frame is asked
|
||||
# for -- it is whether the replicant's SEGMENT actually carries the torso
|
||||
# twist. Torso pushes currentTwist into the skeleton on both paths
|
||||
# (TorsoSimulation and TorsoCopySimulation both call UpdateJoints), so this
|
||||
# has to be measured, not reasoned about.
|
||||
#
|
||||
# THE MEASUREMENT. New [launchframe] receipt (BT_PROJ_LOG) prints, on both
|
||||
# nodes, the yaw of the launch forward vs the BODY forward:
|
||||
# [launchframe] master seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
|
||||
# [launchframe] REPLICANT seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
|
||||
# twistDelta is the torso twist expressed in the launch direction.
|
||||
#
|
||||
# BUG CONFIRMED: A (master) shows |twistDelta| sweeping well away from 0
|
||||
# while B (replicant mirror) stays pinned near 0
|
||||
# -- or B shows segResolved=0 (fell back to the body basis).
|
||||
# NOT REPRODUCED: both sides show the same twistDelta spread.
|
||||
#
|
||||
# ⚠ READ THE PREFIX, NOT THE PERCENTAGE (learned the hard way, 2026-08-08).
|
||||
# BT_AUTOFIRE starts shooting IMMEDIATELY, while the app is still in
|
||||
# WaitingForLaunch/LaunchingMission -- and Entity::Execute (ENTITY.cpp:556,
|
||||
# real engine source) only calls PerformAndWatch when the state is
|
||||
# RunningMission/EndingMission or the entity IsPreRunnable(). A REPLICANT mech
|
||||
# is not pre-runnable (Entity::DefaultFlags has no PreRunFlag; only Player /
|
||||
# Director add it, and Mech::Reset sets it for a reset MASTER), so a peer mech
|
||||
# does not tick at all until the round actually starts. Every salvo fired
|
||||
# before that reads twistDelta=0 legitimately -- the peer has no twist yet.
|
||||
# That is a BENCH artifact (nobody can fire pre-round in a real match), not a
|
||||
# defect: it showed up as a clean leading run of zeros, e.g.
|
||||
# ZZZZ...(60)...ZZZZXXXX...(105)...XXXX
|
||||
# and the first X lands within a few lines of the RunningMission transition.
|
||||
# So: judge this bench by whether the failures are a PREFIX (fine) or
|
||||
# INTERLEAVED (real), never by the raw percentage.
|
||||
#
|
||||
# Only A fires and only A sweeps its torso, so every REPLICANT line in B's
|
||||
# log is a mirror of an A salvo and the comparison is unambiguous.
|
||||
# =========================================================================
|
||||
set -x
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
rm -f mf_a.log mf_b.log mf_relay.log
|
||||
bt_expert_egg MP.EGG MF.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MF.EGG
|
||||
|
||||
# B: the OBSERVER. Drives at A so it stays in range, but does NOT fire and
|
||||
# does NOT sweep -- so every [launchframe] REPLICANT line in mf_b.log is a
|
||||
# mirror of one of A's salvos.
|
||||
( export BT_GOTO=enemy BT_GOTO_STOP=150
|
||||
export BT_PROJ_LOG=1 BT_MP_LOG=1
|
||||
bt_launch mf_b.log MF.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
# A: the SHOOTER. Sweeps the torso hard so twistDelta is unmistakably
|
||||
# non-zero at fire time, and autofires missiles at the designated enemy.
|
||||
( export BT_GOTO=enemy BT_GOTO_STOP=150
|
||||
export BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=7
|
||||
export BT_LOCK_SWEEP=0.35
|
||||
export BT_PROJ_LOG=1 BT_TORSO_LOG=1 BT_MP_LOG=1
|
||||
bt_launch mf_a.log MF.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py MF.EGG 127.0.0.1:1501 127.0.0.1:1601 > mf_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep 300
|
||||
kill $RELAY 2>/dev/null
|
||||
sleep 3
|
||||
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
|
||||
|
||||
echo "=================== #141 MISSILE LAUNCH FRAME ==================="
|
||||
echo "--- did A fire, and did B mirror? ---"
|
||||
echo -n " A [launchframe] master lines ....... "; grep -ac "launchframe\] master" mf_a.log
|
||||
echo -n " B [launchframe] REPLICANT lines .... "; grep -ac "launchframe\] REPLICANT" mf_b.log
|
||||
echo
|
||||
echo "--- did the segment RESOLVE on each side? (segResolved=0 would be the bug) ---"
|
||||
echo -n " A segResolved=0 ... "; grep -a "launchframe\] master" mf_a.log | grep -ac "segResolved=0"
|
||||
echo -n " B segResolved=0 ... "; grep -a "launchframe\] REPLICANT" mf_b.log | grep -ac "segResolved=0"
|
||||
echo
|
||||
echo "--- THE COMPARISON: twistDelta spread on each side ---"
|
||||
python - <<'PY'
|
||||
import re, io
|
||||
def stats(path, tag):
|
||||
v = []
|
||||
try:
|
||||
for ln in io.open(path, encoding="latin-1", errors="replace"):
|
||||
if "[launchframe] " + tag in ln:
|
||||
m = re.search(r"twistDelta=([-\d.e+]+)", ln)
|
||||
if m:
|
||||
try: v.append(float(m.group(1)))
|
||||
except ValueError: pass
|
||||
except IOError:
|
||||
print(" %s: no log" % tag); return
|
||||
if not v:
|
||||
print(" %-9s no samples" % tag); return
|
||||
a = [abs(x) for x in v]
|
||||
big = sum(1 for x in a if x > 0.10) # ~5.7 deg -- clearly twisted
|
||||
print(" %-9s n=%-4d |twistDelta| max=%.4f mean=%.4f >0.10rad: %d (%.0f%%)"
|
||||
% (tag, len(v), max(a), sum(a)/len(a), big, 100.0*big/len(a)))
|
||||
stats(r"C:\git\bt411\content\mf_a.log", "master")
|
||||
stats(r"C:\git\bt411\content\mf_b.log", "REPLICANT")
|
||||
print()
|
||||
print(" VERDICT: master twisted + REPLICANT pinned near 0 => #141 CONFIRMED.")
|
||||
print(" both twisted alike => NOT reproduced.")
|
||||
PY
|
||||
echo
|
||||
echo "--- sample lines, both sides ---"
|
||||
grep -a "launchframe\] master" mf_a.log | head -4
|
||||
grep -a "launchframe\] REPLICANT" mf_b.log | head -4
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# #141 -- "missiles launch along the LEG/FOOT facing, then curve to the
|
||||
# target -- PEER POV ONLY" (Oracle, night 13: "the emitter is following the
|
||||
# foot facing"; shooter's own view correct).
|
||||
#
|
||||
# WHAT THE CODE SAYS SO FAR. Both sides already pass the mount segment:
|
||||
# MissileLauncher::FireWeapon (mislanch.cpp:363) and the REPLICANT salvo
|
||||
# mirror (mislanch.cpp:478) each hand BTPushProjectile
|
||||
# `GetSegmentIndex() /*task #67 mount frame*/`. BTPushProjectile then
|
||||
# rotates the authored MuzzleVelocity through
|
||||
# `seg->GetSegmentToEntity() * localToWorld` -- so the launch direction IS
|
||||
# the segment's world frame on BOTH nodes. Task #67 fixed exactly this
|
||||
# symptom once already, master-side ("missiles fire out of his back").
|
||||
#
|
||||
# So if the peer report is real, the difference is NOT which frame is asked
|
||||
# for -- it is whether the replicant's SEGMENT actually carries the torso
|
||||
# twist. Torso pushes currentTwist into the skeleton on both paths
|
||||
# (TorsoSimulation and TorsoCopySimulation both call UpdateJoints), so this
|
||||
# has to be measured, not reasoned about.
|
||||
#
|
||||
# THE MEASUREMENT. New [launchframe] receipt (BT_PROJ_LOG) prints, on both
|
||||
# nodes, the yaw of the launch forward vs the BODY forward:
|
||||
# [launchframe] master seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
|
||||
# [launchframe] REPLICANT seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
|
||||
# twistDelta is the torso twist expressed in the launch direction.
|
||||
#
|
||||
# BUG CONFIRMED: A (master) shows |twistDelta| sweeping well away from 0
|
||||
# while B (replicant mirror) stays pinned near 0
|
||||
# -- or B shows segResolved=0 (fell back to the body basis).
|
||||
# NOT REPRODUCED: both sides show the same twistDelta spread.
|
||||
#
|
||||
# Only A fires and only A sweeps its torso, so every REPLICANT line in B's
|
||||
# log is a mirror of an A salvo and the comparison is unambiguous.
|
||||
# =========================================================================
|
||||
set -x
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
rm -f mf_a.log mf_b.log mf_relay.log
|
||||
bt_expert_egg MP.EGG MF.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MF.EGG
|
||||
|
||||
# B: the OBSERVER. Drives at A so it stays in range, but does NOT fire and
|
||||
# does NOT sweep -- so every [launchframe] REPLICANT line in mf_b.log is a
|
||||
# mirror of one of A's salvos.
|
||||
( export BT_GOTO=enemy BT_GOTO_STOP=150
|
||||
export BT_PROJ_LOG=1 BT_MP_LOG=1 BT_TORSO_LOG=1
|
||||
bt_launch mf_b.log MF.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
# A: the SHOOTER. Sweeps the torso hard so twistDelta is unmistakably
|
||||
# non-zero at fire time, and autofires missiles at the designated enemy.
|
||||
( export BT_GOTO=enemy BT_GOTO_STOP=150
|
||||
export BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=7
|
||||
export BT_LOCK_SWEEP=0.35
|
||||
export BT_PROJ_LOG=1 BT_TORSO_LOG=1 BT_MP_LOG=1
|
||||
bt_launch mf_a.log MF.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py MF.EGG 127.0.0.1:1501 127.0.0.1:1601 > mf_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep 170
|
||||
kill $RELAY 2>/dev/null
|
||||
sleep 3
|
||||
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
|
||||
|
||||
echo "=================== #141 MISSILE LAUNCH FRAME ==================="
|
||||
echo "--- did A fire, and did B mirror? ---"
|
||||
echo -n " A [launchframe] master lines ....... "; grep -ac "launchframe\] master" mf_a.log
|
||||
echo -n " B [launchframe] REPLICANT lines .... "; grep -ac "launchframe\] REPLICANT" mf_b.log
|
||||
echo
|
||||
echo "--- did the segment RESOLVE on each side? (segResolved=0 would be the bug) ---"
|
||||
echo -n " A segResolved=0 ... "; grep -a "launchframe\] master" mf_a.log | grep -ac "segResolved=0"
|
||||
echo -n " B segResolved=0 ... "; grep -a "launchframe\] REPLICANT" mf_b.log | grep -ac "segResolved=0"
|
||||
echo
|
||||
echo "--- THE COMPARISON: twistDelta spread on each side ---"
|
||||
python - <<'PY'
|
||||
import re, io
|
||||
def stats(path, tag):
|
||||
v = []
|
||||
try:
|
||||
for ln in io.open(path, encoding="latin-1", errors="replace"):
|
||||
if "[launchframe] " + tag in ln:
|
||||
m = re.search(r"twistDelta=([-\d.e+]+)", ln)
|
||||
if m:
|
||||
try: v.append(float(m.group(1)))
|
||||
except ValueError: pass
|
||||
except IOError:
|
||||
print(" %s: no log" % tag); return
|
||||
if not v:
|
||||
print(" %-9s no samples" % tag); return
|
||||
a = [abs(x) for x in v]
|
||||
big = sum(1 for x in a if x > 0.10) # ~5.7 deg -- clearly twisted
|
||||
print(" %-9s n=%-4d |twistDelta| max=%.4f mean=%.4f >0.10rad: %d (%.0f%%)"
|
||||
% (tag, len(v), max(a), sum(a)/len(a), big, 100.0*big/len(a)))
|
||||
stats(r"C:\git\bt411\content\mf_a.log", "master")
|
||||
stats(r"C:\git\bt411\content\mf_b.log", "REPLICANT")
|
||||
print()
|
||||
print(" VERDICT: master twisted + REPLICANT pinned near 0 => #141 CONFIRMED.")
|
||||
print(" both twisted alike => NOT reproduced.")
|
||||
PY
|
||||
echo
|
||||
echo "--- sample lines, both sides ---"
|
||||
grep -a "launchframe\] master" mf_a.log | head -4
|
||||
grep -a "launchframe\] REPLICANT" mf_b.log | head -4
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# #141 -- "missiles launch along the LEG/FOOT facing, then curve to the
|
||||
# target -- PEER POV ONLY" (Oracle, night 13: "the emitter is following the
|
||||
# foot facing"; shooter's own view correct).
|
||||
#
|
||||
# WHAT THE CODE SAYS SO FAR. Both sides already pass the mount segment:
|
||||
# MissileLauncher::FireWeapon (mislanch.cpp:363) and the REPLICANT salvo
|
||||
# mirror (mislanch.cpp:478) each hand BTPushProjectile
|
||||
# `GetSegmentIndex() /*task #67 mount frame*/`. BTPushProjectile then
|
||||
# rotates the authored MuzzleVelocity through
|
||||
# `seg->GetSegmentToEntity() * localToWorld` -- so the launch direction IS
|
||||
# the segment's world frame on BOTH nodes. Task #67 fixed exactly this
|
||||
# symptom once already, master-side ("missiles fire out of his back").
|
||||
#
|
||||
# So if the peer report is real, the difference is NOT which frame is asked
|
||||
# for -- it is whether the replicant's SEGMENT actually carries the torso
|
||||
# twist. Torso pushes currentTwist into the skeleton on both paths
|
||||
# (TorsoSimulation and TorsoCopySimulation both call UpdateJoints), so this
|
||||
# has to be measured, not reasoned about.
|
||||
#
|
||||
# THE MEASUREMENT. New [launchframe] receipt (BT_PROJ_LOG) prints, on both
|
||||
# nodes, the yaw of the launch forward vs the BODY forward:
|
||||
# [launchframe] master seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
|
||||
# [launchframe] REPLICANT seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
|
||||
# twistDelta is the torso twist expressed in the launch direction.
|
||||
#
|
||||
# BUG CONFIRMED: A (master) shows |twistDelta| sweeping well away from 0
|
||||
# while B (replicant mirror) stays pinned near 0
|
||||
# -- or B shows segResolved=0 (fell back to the body basis).
|
||||
# NOT REPRODUCED: both sides show the same twistDelta spread.
|
||||
#
|
||||
# Only A fires and only A sweeps its torso, so every REPLICANT line in B's
|
||||
# log is a mirror of an A salvo and the comparison is unambiguous.
|
||||
# =========================================================================
|
||||
set -x
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
rm -f mf_a.log mf_b.log mf_relay.log
|
||||
bt_expert_egg MP.EGG MF.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MF.EGG
|
||||
|
||||
# B: the OBSERVER. Drives at A so it stays in range, but does NOT fire and
|
||||
# does NOT sweep -- so every [launchframe] REPLICANT line in mf_b.log is a
|
||||
# mirror of one of A's salvos.
|
||||
( export BT_GOTO=enemy BT_GOTO_STOP=150
|
||||
export BT_PROJ_LOG=1 BT_MP_LOG=1 BT_TORSO_LOG=1 BT_NET_TRACE=1
|
||||
bt_launch mf_b.log MF.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
# A: the SHOOTER. Sweeps the torso hard so twistDelta is unmistakably
|
||||
# non-zero at fire time, and autofires missiles at the designated enemy.
|
||||
( export BT_GOTO=enemy BT_GOTO_STOP=150
|
||||
export BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=7
|
||||
export BT_LOCK_SWEEP=0.35
|
||||
export BT_PROJ_LOG=1 BT_TORSO_LOG=1 BT_MP_LOG=1
|
||||
bt_launch mf_a.log MF.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py MF.EGG 127.0.0.1:1501 127.0.0.1:1601 > mf_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep 170
|
||||
kill $RELAY 2>/dev/null
|
||||
sleep 3
|
||||
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
|
||||
|
||||
echo "=================== #141 MISSILE LAUNCH FRAME ==================="
|
||||
echo "--- did A fire, and did B mirror? ---"
|
||||
echo -n " A [launchframe] master lines ....... "; grep -ac "launchframe\] master" mf_a.log
|
||||
echo -n " B [launchframe] REPLICANT lines .... "; grep -ac "launchframe\] REPLICANT" mf_b.log
|
||||
echo
|
||||
echo "--- did the segment RESOLVE on each side? (segResolved=0 would be the bug) ---"
|
||||
echo -n " A segResolved=0 ... "; grep -a "launchframe\] master" mf_a.log | grep -ac "segResolved=0"
|
||||
echo -n " B segResolved=0 ... "; grep -a "launchframe\] REPLICANT" mf_b.log | grep -ac "segResolved=0"
|
||||
echo
|
||||
echo "--- THE COMPARISON: twistDelta spread on each side ---"
|
||||
python - <<'PY'
|
||||
import re, io
|
||||
def stats(path, tag):
|
||||
v = []
|
||||
try:
|
||||
for ln in io.open(path, encoding="latin-1", errors="replace"):
|
||||
if "[launchframe] " + tag in ln:
|
||||
m = re.search(r"twistDelta=([-\d.e+]+)", ln)
|
||||
if m:
|
||||
try: v.append(float(m.group(1)))
|
||||
except ValueError: pass
|
||||
except IOError:
|
||||
print(" %s: no log" % tag); return
|
||||
if not v:
|
||||
print(" %-9s no samples" % tag); return
|
||||
a = [abs(x) for x in v]
|
||||
big = sum(1 for x in a if x > 0.10) # ~5.7 deg -- clearly twisted
|
||||
print(" %-9s n=%-4d |twistDelta| max=%.4f mean=%.4f >0.10rad: %d (%.0f%%)"
|
||||
% (tag, len(v), max(a), sum(a)/len(a), big, 100.0*big/len(a)))
|
||||
stats(r"C:\git\bt411\content\mf_a.log", "master")
|
||||
stats(r"C:\git\bt411\content\mf_b.log", "REPLICANT")
|
||||
print()
|
||||
print(" VERDICT: master twisted + REPLICANT pinned near 0 => #141 CONFIRMED.")
|
||||
print(" both twisted alike => NOT reproduced.")
|
||||
PY
|
||||
echo
|
||||
echo "--- sample lines, both sides ---"
|
||||
grep -a "launchframe\] master" mf_a.log | head -4
|
||||
grep -a "launchframe\] REPLICANT" mf_b.log | head -4
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# Sauron: "toggled through advanced controls from standard to advanced and
|
||||
# back to standard -- lost torso control."
|
||||
#
|
||||
# THE MECHANISM (@004afbe0, the binary's CycleControlModeMessageHandler):
|
||||
# the mode cycles 0 Basic -> 1 Standard -> 2 Veteran -> WRAPS TO BASIC. So
|
||||
# getting from Veteran/"advanced" back to Standard PASSES THROUGH BASIC, and
|
||||
# the Basic arm re-centres the torso.
|
||||
#
|
||||
# The port set that re-centre with CommandRecenter() -> centerCommand (@0x208).
|
||||
# That is the HELD-BUTTON cell: TorsoSimulation re-arms `recenterActive` from
|
||||
# it EVERY frame it is non-zero and only the input path clears it -- and a mode
|
||||
# switch has no button release to follow. One visit to Basic pinned it at 1
|
||||
# forever. Digital twist commands are processed BEFORE the centerCommand
|
||||
# block, so they were overridden as fast as they were applied = "lost torso
|
||||
# control". The binary writes recenterActive (@0x274) instead: a ONE-SHOT that
|
||||
# self-clears on settle and is cancelled by any twist input.
|
||||
#
|
||||
# THE MEASUREMENT. BT_MODECYCLE_EVERY=<n> cycles the control mode every n mapper ticks
|
||||
# frames from that frame. BT_TORSO_LOG's gate probe now prints the two cells:
|
||||
# [torso] ... ctrCmd=<centerCommand> recen=<recenterActive> vLim=(lo..hi)
|
||||
#
|
||||
# PASS: ctrCmd stays 0 across every cycle (the one-shot is used instead), and
|
||||
# vLim SWAPS between the Basic pair and the assisted pair as the mode
|
||||
# changes -- proving the elevation-limit swap (@0x228/@0x22C vs
|
||||
# @0x230/@0x234) that the port previously never implemented.
|
||||
# FAIL: ctrCmd latches to 1 after the first pass through Basic and never
|
||||
# returns to 0 -> the torso re-centres forever.
|
||||
#
|
||||
# Single node: this is entirely local control state, no peer needed.
|
||||
# =========================================================================
|
||||
set -x
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
rm -f mc_a.log
|
||||
bt_expert_egg MP.EGG MC.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MC.EGG
|
||||
|
||||
( export BT_MODECYCLE_EVERY=400
|
||||
export BT_TORSO_LOG=1 BT_KEY_NOFOCUS=1 BT_KEY_BRIDGE=0 BT_TWIST_PULSE=150 ${LEGACY:+BT_LEGACY_MODE_RECENTER=1}
|
||||
bt_launch mc_a.log MC.EGG 0x03 )
|
||||
sleep 150
|
||||
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
|
||||
|
||||
echo "=================== CONTROL-MODE TORSO STATE ==================="
|
||||
echo "--- the mode cycles that happened ---"
|
||||
grep -a "\[mode\] control mode" mc_a.log | head -10
|
||||
echo
|
||||
echo "--- centerCommand must NEVER latch (ctrCmd=1 with no button = the bug) ---"
|
||||
echo -n " samples with ctrCmd=1 : "; grep -ao "ctrCmd=[0-9]*" mc_a.log | grep -c "ctrCmd=1"
|
||||
echo -n " samples with ctrCmd=0 : "; grep -ao "ctrCmd=[0-9]*" mc_a.log | grep -c "ctrCmd=0"
|
||||
echo
|
||||
echo "--- the elevation-limit SWAP (should differ between Basic and assisted) ---"
|
||||
grep -ao "vLim=([^)]*)" mc_a.log | sort | uniq -c | sort -rn | head -5
|
||||
echo
|
||||
echo "--- torso state around each mode change ---"
|
||||
grep -aE "\[mode\] control mode|ctrCmd=" mc_a.log | grep -aA1 "\[mode\]" | head -12
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Night 13: rewrite #147's body (the inline attempt was mangled by the shell). ASCII only."""
|
||||
import sys
|
||||
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
|
||||
import gitea
|
||||
|
||||
BODY = """**Reported by Oracle (2026-08-06 session):** "no range finder on this drop" + a screenshot.
|
||||
Symptom confirmed by eye: **the ladder tick marks were present, the moving caret was not.**
|
||||
Intermittent -- one drop, not every drop. He was the only tester who hit it.
|
||||
|
||||
## What it is NOT
|
||||
|
||||
Ruled out from the four field logs:
|
||||
|
||||
* **Not a Steam-host bug.** Oracle *was* hosting (`[lobby] host: lobby up` / `GO with N member(s)`
|
||||
appears in his log and no other), but the host path is uninvolved: the range **computed** fine on
|
||||
his node (1806 nonzero `[target] range=` samples) and the reticle **built** fine on every drop
|
||||
(`[hud] reticle built: 7 weapon pip(s) registered` -- 6 drops, 6 builds).
|
||||
* **Not the Thor chassis.** He flew a Thor, which is why the report reads as chassis-specific -- but
|
||||
a second tester flew a Thor the same night *without* hosting and reported nothing, and the range
|
||||
ladder is drawn by the shared `HudSimulation` / `BTReticleRenderable`, not per-chassis cockpit
|
||||
content. No Thor asset-missing warnings in any log (only the known wreck-model fallbacks).
|
||||
* **Not his destroyed HUD.** His HUD subsystem *was* destroyed twice -- the only tester to reach
|
||||
`condition 0` all night -- but only for ~11 s and ~26 s, repaired by respawn each time. And a
|
||||
destroyed HUD costs you the fire-control LOCK (own host zone >= 0.75 damage, `_DAT_004b7ec4`),
|
||||
not the caret.
|
||||
|
||||
## The defect
|
||||
|
||||
`sShownRange` -- the value the caret ultimately binds to -- is a **function-level static** in
|
||||
mech4.cpp's targeting step. One cell for the whole process, shared by every mech, carried across
|
||||
drops, never re-seeded. Its update:
|
||||
|
||||
```c
|
||||
float step = trueRange - sShownRange;
|
||||
if (step > maxStep) step = maxStep;
|
||||
if (step < -maxStep) step = -maxStep;
|
||||
sShownRange += step;
|
||||
```
|
||||
|
||||
**NaN is absorbing here, and the clamps cannot catch it** -- `step > maxStep` and `step < -maxStep`
|
||||
are BOTH false for NaN. So a single poisoned frame makes `sShownRange` NaN and it stays NaN *for
|
||||
the life of the process*.
|
||||
|
||||
The consumer repeats the same mistake -- `BTReticleRenderable::Draw`:
|
||||
|
||||
```c
|
||||
Scalar range = (rangeAttr2 != 0) ? *rangeAttr2 : 0.0f;
|
||||
if (range < minRange) range = minRange; // false for NaN
|
||||
if (range > maxRange) range = maxRange; // false for NaN
|
||||
Scalar frac = (range - minRange) / (maxRange - minRange);
|
||||
```
|
||||
|
||||
NaN flows into `AddPoint` / `ConcatMatrix`, so the caret and its bar become **degenerate geometry
|
||||
and stop rendering** -- while every static reticle element, tick marks included, still draws.
|
||||
|
||||
That is exactly the reported symptom: ticks present, caret gone, sticky until relaunch.
|
||||
|
||||
## Why no log could confirm it
|
||||
|
||||
**The caret's actual input had no diagnostic anywhere.** `BT_RANGE_LOG` instruments the *pick*
|
||||
(#4), and the `range=` field in `[target]` is a separate, locally recomputed
|
||||
`Sqrt(ddx*ddx + ddy*ddy + ddz*ddz)` inside the weapon-range check -- it is neither `sShownRange`
|
||||
nor `gBTHudRangeStorage`. Grepping the field logs for NaN returns nothing because **the poisoned
|
||||
variable was never printed.** Absence of the signal was not evidence of absence.
|
||||
|
||||
## Fixed (unreleased)
|
||||
|
||||
1. **Re-seed on mech change** -- a new drop starts at the binary's 1200 default instead of
|
||||
inheriting the previous mission's slid value. Deliberately does NOT fire on respawn: that
|
||||
reuses the entity, and the binary does not reset the readout on respawn either.
|
||||
2. **NaN trap at the producer** -- re-seed to 1200 rather than propagate.
|
||||
3. **NaN-safe clamp at the consumer** -- test `x == x` first, and fall back to the authentic
|
||||
no-target peg (1200) instead of rendering nothing.
|
||||
4. **`BT_RANGE_LOG` now prints the caret's real input** -- `[range] caret input shown=... true=...
|
||||
lock=...` at 1 Hz, plus a `[range] NaN TRAPPED` receipt.
|
||||
|
||||
## Status [T3 -- honest]
|
||||
|
||||
The defect and the symptom match exactly, and the fix is correct on its own merits: a
|
||||
process-lifetime static feeding unguarded float geometry is a bug regardless of who reported what.
|
||||
|
||||
But **the causal link to Oracle's report is INFERENCE, not proof.** The NaN source is unidentified
|
||||
and the failure has not been reproduced. What would settle it: fly with `BT_RANGE_LOG=1` -- if the
|
||||
caret dies again, the log now names the frame it happened on.
|
||||
"""
|
||||
|
||||
gitea.call("/issues/147", method="PATCH", payload={"body": BODY})
|
||||
print("rewrote #147 body")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user