Compare commits
53
Commits
@@ -606,10 +606,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 +628,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
|
||||
|
||||
@@ -430,6 +430,31 @@ 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.
|
||||
- **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 +464,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):
|
||||
|
||||
@@ -414,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
|
||||
@@ -549,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
|
||||
|
||||
@@ -203,6 +203,32 @@ appears in the cfg like any panel and honours `,noframe`. Verified: a drag wrote
|
||||
at 321,222. NB the plasma window blits directly every frame (`GetDC`+`StretchDIBits`), so unlike
|
||||
the panels it has no `WM_TIMER` focus-throttle to worry about.
|
||||
|
||||
## Glass-panel repaint pump — perf + dirty-skip (2026-08-09/10) [T2 measured]
|
||||
|
||||
The exploded per-display windows are pure CPU/GDI (`ExpandPlaneToBGRA` +
|
||||
`StretchDIBits`), repainted synchronously on the MAIN render thread by
|
||||
`BTGlassPanels_Tick` (~16 Hz). Three layered changes after a playtester reported
|
||||
~20 fps in panels mode vs ~130 fps in the surround:
|
||||
|
||||
1. **HALFTONE → COLORONCOLOR** (`f3d27f5`, in master): the `HALFTONE` stretch
|
||||
(GDI's per-output-pixel resample) × 7 windows per pump was the primary sink;
|
||||
nearest is crisper for the low-res MFD pixels anyway. `BT_GLASS_SMOOTH=1` restores.
|
||||
2. **Per-window dirty-skip** (`c9e25e5`, in master): each window carries a change
|
||||
token = FNV over `SVGA16::PlaneChecksum(mask)` (the shared pixelBuffer masked to
|
||||
every port that can feed the window) + each button's RENDERED lamp brightness +
|
||||
held/latch. The pump re-blits only windows whose token moved: ~31 pumps/2s →
|
||||
4-15 window repaints vs 217-224 always-on. `BT_GLASS_DIRTY=1` logs the tally.
|
||||
3. **Palette generation in the token** (branch `glass-palette-token`): the
|
||||
ColorMapper family (armor rosette tints, adpal/adpal2 damage flash) animates by
|
||||
CLUT writes with ZERO pixel churn — invisible to a pixel checksum.
|
||||
`SVGA16::paletteGeneration` is bumped by the palette writers
|
||||
(`BuildSecondaryColor` only on a REAL entry change, since the flash alternates
|
||||
palettes every Execute writing identical RGB at zero damage; full rebuilds
|
||||
unconditionally) and folded into the token for palette-expanding windows
|
||||
(`monoTint < 0`, or all under `BT_GLASS_MFD_PAL`). Without it the glass radar
|
||||
held stale armor tints between pixel repaints while the D3D surround (which
|
||||
re-expands every frame) tracked live.
|
||||
|
||||
**Turning it OFF: `L4PLASMA=NONE` (also `OFF`/`0`, 2026-08-06) [T2].** `L4GREND` creates a
|
||||
marquee whenever `L4PLASMA` is set at all (`SCREEN` → the desktop window, anything else → a real
|
||||
`PlasmaDisplay` on that serial port), and the GLASS profile force-defaults it to `SCREEN` — so on
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -254,6 +254,40 @@ 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:` (2026-08-08, staged, pod-untested)
|
||||
**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 +424,65 @@ 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`). The Dell's code is unique. The cab's shipped
|
||||
`glass_layout.cfg` still uses the fragile `monitor:DISPLAY4` device-name form.
|
||||
|
||||
## 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,137 @@ 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.
|
||||
|
||||
@@ -299,3 +299,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.
|
||||
|
||||
+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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
+337
-15
@@ -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;
|
||||
|
||||
@@ -750,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)
|
||||
@@ -762,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
|
||||
@@ -787,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)
|
||||
@@ -795,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;
|
||||
}
|
||||
|
||||
@@ -922,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()
|
||||
{
|
||||
@@ -951,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];
|
||||
@@ -959,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
|
||||
@@ -988,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
|
||||
@@ -1474,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:
|
||||
@@ -1576,6 +1827,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).
|
||||
@@ -1756,8 +2012,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)
|
||||
{
|
||||
@@ -1832,7 +2104,19 @@ static unsigned long
|
||||
if (svga == NULL) svga = static_cast<SVGA16*>(p->graphicsDisplay);
|
||||
}
|
||||
if (svga != NULL && combined != 0)
|
||||
{
|
||||
token ^= svga->PlaneChecksum(combined);
|
||||
// PALETTE-ANIMATED content (2026-08-10): a palette-expanding window
|
||||
// (the radar; MFDs under BT_GLASS_MFD_PAL) changes colour with ZERO
|
||||
// pixel writes -- the ColorMapper family (armor rosette tints, the
|
||||
// adpal/adpal2 damage flash) writes CLUT entries. Fold the palette
|
||||
// write generation in so those changes repaint; mono-tint windows
|
||||
// don't read the palette and keep their pixel-only token.
|
||||
static int sPalTok = -1;
|
||||
if (sPalTok < 0) sPalTok = getenv("BT_GLASS_MFD_PAL") ? 1 : 0;
|
||||
if (w->monoTint < 0 || sPalTok)
|
||||
token = (token ^ svga->PaletteGeneration()) * 16777619UL;
|
||||
}
|
||||
}
|
||||
|
||||
// Lamps: each button's RENDERED brightness + held/latched, so a flash toggle or a
|
||||
@@ -1861,6 +2145,15 @@ void
|
||||
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)
|
||||
@@ -1869,7 +2162,7 @@ void
|
||||
if (w.hwnd == NULL)
|
||||
continue;
|
||||
unsigned long token = GlassWindowToken(gr, &w, now);
|
||||
if (w.haveToken && token == w.lastToken)
|
||||
if (!sLegacySweep && w.haveToken && token == w.lastToken)
|
||||
continue; // gauges + lamps unchanged -> skip this window
|
||||
w.lastToken = token;
|
||||
w.haveToken = 1;
|
||||
@@ -1895,4 +2188,33 @@ void
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5445,6 +5445,7 @@ 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;
|
||||
paletteGeneration = 0; // GLASS dirty-skip palette tracking
|
||||
//STUBBED: VIDEO RB 1/15/07
|
||||
# if defined(DEBUG)
|
||||
Tell("SVGA16::SVGA16()\n");
|
||||
@@ -7190,6 +7191,7 @@ void
|
||||
|
||||
svga_palette->paletteData.Valid = True;
|
||||
svga_palette->modified = True;
|
||||
((SVGA16 *) graphicsDisplay)->paletteGeneration++; // glass dirty-skip (full rebuild)
|
||||
|
||||
Check_Fpu();
|
||||
}
|
||||
@@ -7234,6 +7236,13 @@ void
|
||||
SVGA16Palette
|
||||
*svga_palette(&((SVGA16 *) graphicsDisplay)->palette[paletteID]);
|
||||
|
||||
// GLASS dirty-skip: track whether this write CHANGES anything -- the
|
||||
// ColorMapper family calls this every Execute (the adpal/adpal2 flash
|
||||
// alternates palettes even at zero damage, usually writing identical RGB),
|
||||
// so bump the generation only on a real change or every glass window that
|
||||
// palette-expands would repaint every pump for nothing.
|
||||
int palette_changed = 0;
|
||||
|
||||
//-------------------------------------------
|
||||
// If any of the ...TransparentZero modes are used,
|
||||
// leave color zero undefined for this bit group by
|
||||
@@ -7275,21 +7284,31 @@ void
|
||||
{
|
||||
case RedChannel:
|
||||
case RedChannelTransparentZero:
|
||||
if (destination_triplet->Red != source_triplet->Red)
|
||||
palette_changed = 1;
|
||||
destination_triplet->Red = source_triplet->Red;
|
||||
break;
|
||||
|
||||
case GreenChannel:
|
||||
case GreenChannelTransparentZero:
|
||||
if (destination_triplet->Green != source_triplet->Green)
|
||||
palette_changed = 1;
|
||||
destination_triplet->Green = source_triplet->Green;
|
||||
break;
|
||||
|
||||
case BlueChannel:
|
||||
case BlueChannelTransparentZero:
|
||||
if (destination_triplet->Blue != source_triplet->Blue)
|
||||
palette_changed = 1;
|
||||
destination_triplet->Blue = source_triplet->Blue;
|
||||
break;
|
||||
|
||||
case AllChannels:
|
||||
case AllChannelsTransparentZero:
|
||||
if (destination_triplet->Red != source_triplet->Red
|
||||
|| destination_triplet->Green != source_triplet->Green
|
||||
|| destination_triplet->Blue != source_triplet->Blue)
|
||||
palette_changed = 1;
|
||||
*destination_triplet = *source_triplet;
|
||||
break;
|
||||
}
|
||||
@@ -7310,6 +7329,8 @@ void
|
||||
|
||||
svga_palette->paletteData.Valid = True;
|
||||
svga_palette->modified = True;
|
||||
if (palette_changed)
|
||||
((SVGA16 *) graphicsDisplay)->paletteGeneration++; // glass dirty-skip
|
||||
|
||||
Check_Fpu();
|
||||
}
|
||||
@@ -7413,6 +7434,7 @@ void
|
||||
|
||||
svga_palette->paletteData.Valid = True;
|
||||
svga_palette->modified = True;
|
||||
((SVGA16 *) graphicsDisplay)->paletteGeneration++; // glass dirty-skip (full rebuild)
|
||||
|
||||
# if defined(TESTPALETTE)
|
||||
std::cout << "L4GraphicsPort::BuildAuxiliaryPalette for port " <<
|
||||
@@ -7523,6 +7545,7 @@ void
|
||||
|
||||
svga_palette->paletteData.Valid = True;
|
||||
svga_palette->modified = True;
|
||||
((SVGA16 *) graphicsDisplay)->paletteGeneration++; // glass dirty-skip (full rebuild)
|
||||
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
@@ -382,6 +382,15 @@ protected:
|
||||
SVGA16Palette
|
||||
palette[PaletteCount];
|
||||
|
||||
// GLASS dirty-skip (2026-08-10): bumped by the palette writers
|
||||
// (L4GraphicsPort::BuildSecondaryColor on a REAL entry change; the full
|
||||
// palette rebuilds unconditionally) so the glass repaint token can see
|
||||
// palette-only animation -- the ColorMapper family (armor rosette tints,
|
||||
// damage flash) changes COLORS with zero pixel writes, which the pixel
|
||||
// checksum alone can never catch.
|
||||
unsigned long
|
||||
paletteGeneration;
|
||||
|
||||
private:
|
||||
int NUMGAUGEWINDOWS;
|
||||
HWND *gaugeWindows;
|
||||
@@ -425,6 +434,11 @@ public:
|
||||
// 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;
|
||||
|
||||
// GLASS dirty-skip: the palette write generation (see paletteGeneration).
|
||||
// Folded into the repaint token of palette-expanding windows so ColorMapper
|
||||
// palette animation (armor tints / damage flash) repaints without pixel churn.
|
||||
unsigned long PaletteGeneration() const { return paletteGeneration; }
|
||||
};
|
||||
|
||||
//########################################################################
|
||||
|
||||
@@ -9034,6 +9034,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
|
||||
|
||||
@@ -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);
|
||||
|
||||
+328
-28
@@ -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(
|
||||
@@ -731,6 +770,187 @@ void
|
||||
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 +1085,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 +1381,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 +1431,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1778,15 +2037,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 +2646,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
|
||||
|
||||
@@ -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
|
||||
@@ -1446,3 +1466,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);
|
||||
|
||||
|
||||
@@ -510,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;
|
||||
}
|
||||
|
||||
//
|
||||
@@ -1070,7 +1087,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;
|
||||
@@ -1544,6 +1585,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
|
||||
|
||||
+505
-24
@@ -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
|
||||
@@ -1044,8 +1063,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;
|
||||
}
|
||||
@@ -1493,8 +1517,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 +1580,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;
|
||||
@@ -2038,8 +2160,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 +2271,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 +2330,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 +2407,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 +2631,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 +2754,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 +3574,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 +4683,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 +4745,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 +4754,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 +6481,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 +6552,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 +6579,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):
|
||||
@@ -7581,7 +8021,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
|
||||
@@ -8561,9 +9027,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
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Audit Mech::Reset (@0049fb74) -- every field the binary clears vs ours.
|
||||
|
||||
Motivated by finding EIGHT dropped posture clears (duckState, both state alarms,
|
||||
the death/reset latches, idleStrideScale) in the port's Reset, and by #137
|
||||
"respawn came back with MYOMERS heat MAXED" + Sauron's overheating generator D:
|
||||
if the reconstruction dropped the posture clears it may have dropped others.
|
||||
"""
|
||||
import io, re
|
||||
|
||||
DECOMP = r"C:\git\bt411\reference\decomp\all\part_012.c"
|
||||
HPP = r"C:\git\bt411\game\reconstructed\mech.hpp"
|
||||
CPP = r"C:\git\bt411\game\reconstructed\mech4.cpp"
|
||||
|
||||
# --- 1. every offset the BINARY's Reset touches ---------------------------
|
||||
lines = io.open(DECOMP, encoding="latin-1", errors="replace").readlines()
|
||||
body = "".join(lines[14191:14400]) # FUN_0049fb74
|
||||
bin_off = sorted({int(m, 16) for m in re.findall(r"param_1 \+ (0x[0-9a-f]+)", body)})
|
||||
|
||||
# --- 2. offset -> our field name, from mech.hpp's offset comments ---------
|
||||
name_of = {}
|
||||
for ln in io.open(HPP, encoding="latin-1", errors="replace"):
|
||||
m = re.search(r"^\s*(?:[A-Za-z_][\w:<>* ]*?)\s+(\w+)\s*(?:\[\d+\])?\s*;.*?(?:@|//\s*)0x([0-9a-fA-F]+)", ln)
|
||||
if m:
|
||||
name_of.setdefault(int(m.group(2), 16), m.group(1))
|
||||
|
||||
# --- 3. what OUR Reset assigns -------------------------------------------
|
||||
cpp = io.open(CPP, encoding="latin-1", errors="replace").read()
|
||||
i = cpp.find("Mech::Reset(const Origin &origin, int mode)")
|
||||
ours_body = cpp[i:i + 9000] if i != -1 else ""
|
||||
ours = set(re.findall(r"^\s*(\w+)\s*(?:\.\w+\([^)]*\)|=)", ours_body, re.M))
|
||||
|
||||
print("Mech::Reset @0049fb74 -- %d distinct fields touched by the binary\n" % len(bin_off))
|
||||
missing, covered, unknown = [], [], []
|
||||
for off in bin_off:
|
||||
nm = name_of.get(off)
|
||||
if nm is None:
|
||||
unknown.append(off)
|
||||
elif nm in ours:
|
||||
covered.append((off, nm))
|
||||
else:
|
||||
missing.append((off, nm))
|
||||
|
||||
print("COVERED by our Reset (%d):" % len(covered))
|
||||
print(" " + ", ".join("%s@0x%x" % (n, o) for o, n in covered) + "\n")
|
||||
print("*** NOT CLEARED by our Reset (%d) -- named fields the binary resets ***" % len(missing))
|
||||
for o, n in missing:
|
||||
print(" 0x%-5x %s" % (o, n))
|
||||
print("\nUNMAPPED offsets (%d) -- no named field at that offset in mech.hpp:" % len(unknown))
|
||||
print(" " + " ".join("0x%x" % o for o in unknown))
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# SELF-INFLICTED scoring + DEATHS counter (night 13).
|
||||
#
|
||||
# Two chart rows and one field complaint are still unverified:
|
||||
# "-1 each self-inflicted point of armor damage" -- code-verified only
|
||||
# (ScoreInflictedMessageHandler's negate-if-target-is-self arm); every
|
||||
# previous run reported negative(self)=0 because nobody self-damaged.
|
||||
# "-1000 destroying your own 'Mech by ejecting" -- arithmetic only
|
||||
# (-500 death cost + a self-kill negating its own ~500 award). A
|
||||
# SELF-DESTRUCT reaches the same two paths without needing the panic
|
||||
# button, which five rigs failed to trigger.
|
||||
# "kills/deaths counts were screwy" -- kills=1 is verified, deathTally is not.
|
||||
#
|
||||
# A self-destructs (BT_SELF_DAMAGE, inflictor = SELF); B stands off.
|
||||
# Composition from night12/scoreself.sh, which was built for #134.
|
||||
# =========================================================================
|
||||
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 s2_a.log s2_b.log s2_relay.log matchlog_*.txt
|
||||
bt_expert_egg MP.EGG S2.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" S2.EGG
|
||||
|
||||
( export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
|
||||
bt_launch s2_b.log S2.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
( export BT_SELF_DAMAGE=40
|
||||
export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_SCORE_LOG=1
|
||||
bt_launch s2_a.log S2.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py S2.EGG 127.0.0.1:1501 127.0.0.1:1601 > s2_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep 280
|
||||
kill $RELAY 2>/dev/null
|
||||
sleep 3
|
||||
bt_kill_ours
|
||||
sleep 2
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 3
|
||||
|
||||
echo "=================== SELF-SCORE + DEATHS ==================="
|
||||
echo "--- 0. role bound? (if NULL every number below is meaningless) ---"
|
||||
grep -a "\[role\] player" s2_a.log | head -3
|
||||
echo
|
||||
echo "--- 1. CHART '-1 each self-inflicted point': type=0 with award < 0 ---"
|
||||
grep -ah "type=0 award=-" matchlog_*.txt | head -6 | cut -c1-115
|
||||
echo -n "negative type=0 rows: "; grep -ahc "type=0 award=-" matchlog_*.txt | paste -sd+ | bc 2>/dev/null || grep -ah "type=0 award=-" matchlog_*.txt | wc -l
|
||||
echo
|
||||
echo "--- 2. CHART '-1000 ejecting' components: self-kill negation + death cost ---"
|
||||
echo "self-kill (type=2, award must be NEGATIVE, kills must NOT increment):"
|
||||
grep -ah "SCORE.*type=2" matchlog_*.txt | head -4 | cut -c1-115
|
||||
echo "death cost (-specialCaseDeathPenalty, expect -500):"
|
||||
grep -ah "type=1 award=-5" matchlog_*.txt | head -3 | cut -c1-115
|
||||
echo
|
||||
echo "--- 3. DEATHS counter (the other half of the field report) ---"
|
||||
grep -ah "PLAYER_DEAD" matchlog_*.txt | head -5 | cut -c1-115
|
||||
echo -n "death cycles on A: "; grep -ac "death cycle START" s2_a.log
|
||||
echo
|
||||
echo "--- 4. net score trajectory for the self-destructor ---"
|
||||
grep -ah "player=2:1 type=" matchlog_*.txt | tail -4 | cut -c1-115
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# SCORING verify -- the type-0 interceptor (@004bffa0) restored.
|
||||
#
|
||||
# Composition lifted from night12/scorekill.sh, which is known to produce a
|
||||
# clean cross-node kill: A (madcat, shooter) zone-walk-hammers B (loki,
|
||||
# spinner) at 90u until B dies.
|
||||
#
|
||||
# THE A/B IS READABLE IN ONE RUN, because the old behaviour left a receipt:
|
||||
# BEFORE every inflicted report hit ScoreMessageHandler's type-0 arm and
|
||||
# tripped Verify "ScoreMessageHandler should not be given
|
||||
# DamageInflictedScoreMessages!" -- night12's bench listed those
|
||||
# Verify prints as an expected PASS signal.
|
||||
# AFTER the interceptor routes type 0 to ScoreInflictedMessageHandler, so
|
||||
# those Verify prints must be GONE and matchlog SCORE type=0 rows
|
||||
# with non-zero awards must appear instead.
|
||||
#
|
||||
# Chart cross-check (original manual, via Lynx): "+1 each damage point scored
|
||||
# on opponent's armor". The handler scales by tonnage ratio and the role's
|
||||
# damageInflictedModifier, so award != damage exactly -- but it must TRACK
|
||||
# damage, not sit at zero, and must be NEGATIVE for self-damage.
|
||||
# =========================================================================
|
||||
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 sv_a.log sv_b.log sv_relay.log matchlog_*.txt
|
||||
bt_expert_egg MP.EGG SV.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/" SV.EGG
|
||||
python - << 'EOF'
|
||||
lines = open('SV.EGG').read().splitlines(True)
|
||||
n = 0
|
||||
for i, l in enumerate(lines):
|
||||
if l.startswith('vehicle='):
|
||||
n += 1
|
||||
lines[i] = 'vehicle=madcat\n' if n == 1 else 'vehicle=loki\n'
|
||||
open('SV.EGG', 'w').writelines(lines)
|
||||
print('vehicles set:', n)
|
||||
EOF
|
||||
|
||||
( export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_SCORE_LOG=1
|
||||
bt_launch sv_b.log SV.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
( export BT_ZONE_WALK=8 BT_WALK_ZONES=dz_ldleg
|
||||
export BT_GOTO=enemy BT_GOTO_STOP=90 BT_KEY_NOFOCUS=1
|
||||
export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_SCORE_LOG=1
|
||||
bt_launch sv_a.log SV.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py SV.EGG 127.0.0.1:1501 127.0.0.1:1601 > sv_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep 400
|
||||
kill $RELAY 2>/dev/null
|
||||
sleep 3
|
||||
bt_kill_ours
|
||||
sleep 2
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 3
|
||||
|
||||
echo "=================== SCORING VERIFY ==================="
|
||||
echo "--- 0. did combat happen at all? (if 0 hits the run is VOID) ---"
|
||||
echo -n "damage rows on victim B : "; grep -ac "dmghit\|DMG" sv_b.log
|
||||
echo
|
||||
echo "--- 1. THE OLD SYMPTOM: type-0 rejections (must be ZERO now) ---"
|
||||
echo -n "'should not be given DamageInflictedScore' Verify prints: "
|
||||
cat sv_a.log sv_b.log | grep -ac "should not be given DamageInflictedScore"
|
||||
echo
|
||||
echo "--- 2. THE FIX: inflicted score rows (matchlog SCORE type=0) ---"
|
||||
echo -n "type=0 rows: "; cat matchlog_*.txt 2>/dev/null | grep -ac "type=0"
|
||||
cat matchlog_*.txt 2>/dev/null | grep -a "type=0" | head -8
|
||||
echo
|
||||
echo "--- 3. award vs damage: does the credit TRACK damage? ---"
|
||||
python - << 'EOF'
|
||||
import glob, re
|
||||
aw = []
|
||||
for fn in glob.glob('matchlog_*.txt'):
|
||||
for line in open(fn, errors='replace'):
|
||||
m = re.search(r'type=0 award=(-?[\d.]+) total=(-?[\d.]+)', line)
|
||||
if m:
|
||||
aw.append((float(m.group(1)), float(m.group(2))))
|
||||
if not aw:
|
||||
print(' NO type=0 rows -- interceptor did not fire')
|
||||
else:
|
||||
pos = [a for a, t in aw if a > 0]
|
||||
neg = [a for a, t in aw if a < 0]
|
||||
print(' rows=%d positive=%d negative(self)=%d' % (len(aw), len(pos), len(neg)))
|
||||
print(' award range: %.2f .. %.2f running total ends at %.2f'
|
||||
% (min(a for a, t in aw), max(a for a, t in aw), aw[-1][1]))
|
||||
EOF
|
||||
echo
|
||||
echo "--- 4. kill path un-regressed (type=2) + respawn ---"
|
||||
echo -n "type=2 kill rows: "; cat matchlog_*.txt 2>/dev/null | grep -ac "type=2"
|
||||
cat matchlog_*.txt 2>/dev/null | grep -a "type=2" | head -3
|
||||
echo -n "victim death cycles: "; grep -ac "death cycle START" sv_b.log
|
||||
@@ -0,0 +1,11 @@
|
||||
import sys
|
||||
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
|
||||
import gitea
|
||||
for n in sys.argv[1:]:
|
||||
i = gitea.call("/issues/%s" % n)
|
||||
print("=" * 78)
|
||||
print("#%s [%s] %s" % (i["number"], i["state"], i["title"]))
|
||||
print("labels:", ",".join(l["name"] for l in i.get("labels", [])))
|
||||
print("-" * 78)
|
||||
print((i.get("body") or "")[:1800])
|
||||
print()
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# #52 STANDING-LOCK bench -- the night-13 field composition.
|
||||
#
|
||||
# FIELD COMPOSITION (night 13, 4.11.817): a healthy PEER that pivots at
|
||||
# stand and then accelerates. The peer's turn-arm block parks its body
|
||||
# channel at Standing on the walk-demand yield (mech4.cpp:2853, "case 0
|
||||
# walk-begins next tick"); the claim under test is that case 0 CANNOT
|
||||
# walk-begin on a replicant because the port's inserted turn block resets
|
||||
# the state case 0 just armed, using the SAME expression.
|
||||
#
|
||||
# PROVOCATION: B chases A and holds at BT_GOTO_STOP. Every time A walks
|
||||
# back out of that radius B re-acquires -- goto turns hard at 0.2 throttle
|
||||
# (mech4.cpp:4108-4113) = TURNING AT SUB-WALK SPEED, which arms the peer's
|
||||
# body state 4 -- then the heading aligns, throttle goes to 1.0, and the
|
||||
# walk-demand yield fires. That is the trap, once per re-acquisition.
|
||||
#
|
||||
# READ IT ON A: B's replicant lives on A, so A's log carries [bodySM] and
|
||||
# [skate] for B. Both nodes carry both gates anyway.
|
||||
#
|
||||
# MODE=legacy -> BT_NO_BODY_FALLTHRU=1 (pre-fix path; expect the lock)
|
||||
# MODE=fixed -> default (expect no lock)
|
||||
# =========================================================================
|
||||
set -x
|
||||
MODE="${1:-fixed}"
|
||||
DUR="${2:-230}"
|
||||
. /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 sl_${MODE}_a.log sl_${MODE}_b.log sl_${MODE}_relay.log
|
||||
|
||||
bt_assert_player_env
|
||||
bt_expert_egg MP.EGG SL.EGG
|
||||
# map=CAVERN on purpose (not the usual grass/day combat default): the lock needs
|
||||
# a mech holding a STEADY walk demand without its gait changing, and the
|
||||
# reliable way to get that is a mech pushing into geometry -- throttle up, leg
|
||||
# SM parked, no gait edges, so no type-3 record ever refreshes the peer. On
|
||||
# open grass whether the autodriver finds a wall is luck: the first run locked
|
||||
# for 336 consecutive seconds, the second for 1. Cavern guarantees it, and its
|
||||
# walls also let the jammed mech SLIDE, which is what turns a lock into a
|
||||
# visible skate. time=day only so the windows are watchable.
|
||||
sed -i "s/^map=.*/map=cavern/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" SL.EGG
|
||||
|
||||
LEGACY=""
|
||||
[ "$MODE" = "legacy" ] && LEGACY=1
|
||||
|
||||
# ROLES (v2, after the pass-1 miss): the mech we need MOVING WHILE LOCKED is
|
||||
# the one being observed. v1 made the observer autodrive and it walked into
|
||||
# the arena wall -- 336 locked seconds but ~zero translation, so the lock
|
||||
# reproduced and the SKATE (which needs 90 sustained MOVING frames) did not.
|
||||
# v2 puts the chaser on A: BT_GOTO with a tight stop radius keeps A walking at
|
||||
# a target that keeps moving, so A gets a stop/turn/walk cycle (the lock entry)
|
||||
# AND continuous travel (the symptom). B observes; B's own wall-bumping is
|
||||
# irrelevant because we read A's replicant on B's log.
|
||||
# v3 = back to the v1 roles, which is the rig that ACTUALLY reproduces.
|
||||
# v2 (chaser observed) gave zero locks, and that is itself the finding: a mech
|
||||
# whose gait keeps changing keeps emitting type-3 records, and each one sets the
|
||||
# peer's body state directly (ReadUpdateRecord), so the peer never has to
|
||||
# self-arm and never meets case 0. The lock needs the opposite -- a peer PARKED
|
||||
# at Standing while the master holds a STEADY demand, so no refreshing record
|
||||
# ever comes. A wall-jammed autodriver is exactly that, which is why v1 locked
|
||||
# for 336 consecutive seconds. Keep it.
|
||||
# ---- node B (back window): the OBSERVER -- chases, so it stays engaged ------
|
||||
(
|
||||
export BT_GOTO=enemy BT_GOTO_STOP=150 BT_GOTO_LOG=1
|
||||
export BT_BODY_SM_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
|
||||
[ -n "$LEGACY" ] && export BT_NO_BODY_FALLTHRU=1
|
||||
bt_launch sl_${MODE}_b.log SL.EGG 0x0C -net 1601
|
||||
)
|
||||
sleep 2
|
||||
# ---- node A (front window): the OBSERVED mech -- steady demand, held up -----
|
||||
(
|
||||
export BT_AUTODRIVE=0.7
|
||||
export BT_BODY_SM_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
|
||||
[ -n "$LEGACY" ] && export BT_NO_BODY_FALLTHRU=1
|
||||
bt_launch sl_${MODE}_a.log SL.EGG 0x03 -net 1501
|
||||
)
|
||||
sleep 5
|
||||
python ../tools/btconsole.py SL.EGG 127.0.0.1:1501 127.0.0.1:1601 > sl_${MODE}_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep "$DUR"
|
||||
kill $RELAY 2>/dev/null
|
||||
bt_kill_ours
|
||||
sleep 2
|
||||
# bt_launch's winpid poll can MISS (documented) -- pass 1 orphaned a node that
|
||||
# then sat holding a -net port. Passes run strictly one at a time here, so a
|
||||
# blanket sweep is safe and is the only thing that guarantees a clean slate.
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 3
|
||||
|
||||
echo "=================== MODE=$MODE ==================="
|
||||
echo "--- THE LOCK: [bodySM] arm->reset pairs (1 Hz throttled) ---"
|
||||
echo -n "on B (observing A, the chaser): "; grep -ac "STANDING-LOCK" sl_${MODE}_b.log
|
||||
echo -n "on A (observing B): "; grep -ac "STANDING-LOCK" sl_${MODE}_a.log
|
||||
grep -a "STANDING-LOCK" sl_${MODE}_b.log | sort -u | head -4
|
||||
echo
|
||||
echo "--- THE PEER GAIT: what a MOVING replicant's body channel is doing ---"
|
||||
echo -n "samples on B: "; grep -ac "peergait" sl_${MODE}_b.log
|
||||
echo "body states seen while moving (B's view of A):"
|
||||
grep -a "\[peergait\]" sl_${MODE}_b.log | grep -oaE "bstate=[0-9-]+" | sort | uniq -c | sort -rn | head -10
|
||||
echo "idle-channel samples (the skate condition):"
|
||||
grep -ac "IDLE CHANNELS" sl_${MODE}_b.log
|
||||
echo
|
||||
echo "--- THE SYMPTOM: [skate] episodes ---"
|
||||
echo -n "on B: "; grep -ac "SKATING" sl_${MODE}_b.log
|
||||
grep -a "\[skate\]" sl_${MODE}_b.log | head -6
|
||||
echo -n "on A: "; grep -ac "SKATING" sl_${MODE}_a.log
|
||||
echo
|
||||
echo "--- A's drive cycle (arrive/re-acquire churn = lock entries) ---"
|
||||
grep -a "\[goto\]" sl_${MODE}_a.log | grep -oaE "arr=[01]" | uniq -c | wc -l
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# #52 STANDING-LOCK -- A/B on the rig that is KNOWN to reproduce.
|
||||
#
|
||||
# Composition lifted verbatim from scratchpad/night12/skatebench.sh, which
|
||||
# produced 6 skate episodes in one 260s run (sk_run.out) and 4 in another.
|
||||
# A walks continuously (autodrive) and is killed every ~40s by B, so it
|
||||
# respawns WHILE MOVING -- and that is the field correlation both nights:
|
||||
# night 12's skating clustered in the deaths-heavy final drop, and all four
|
||||
# of night 13's episodes were in the last, deaths-heavy session.
|
||||
#
|
||||
# My own rigs (skatelock.sh v1/v2/v3) each failed to reproduce the SYMPTOM:
|
||||
# a wall-jammed mech locks but does not translate, and a freely-walking mech
|
||||
# transitions gait constantly, so records keep rescuing its peer copy. The
|
||||
# kill cycle gives both halves at once -- a steady post-respawn walk demand
|
||||
# with no gait edges, on a mech that is actually moving.
|
||||
#
|
||||
# B is the OBSERVER: A's replicant lives on B, so B's log carries [skate],
|
||||
# [bodySM] and [peergait] for A.
|
||||
#
|
||||
# MODE=legacy -> BT_NO_BODY_FALLTHRU=1 (pre-fix; expect episodes)
|
||||
# MODE=fixed -> default (expect none)
|
||||
# =========================================================================
|
||||
set -x
|
||||
MODE="${1:-fixed}"
|
||||
DUR="${2:-260}"
|
||||
. /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 s2_${MODE}_a.log s2_${MODE}_b.log s2_${MODE}_relay.log
|
||||
|
||||
bt_assert_player_env
|
||||
bt_expert_egg MP.EGG S2.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" S2.EGG
|
||||
|
||||
LEGACY=""
|
||||
[ "$MODE" = "legacy" ] && LEGACY=1
|
||||
|
||||
# ---- node B: OBSERVER + killer (back window) ------------------------------
|
||||
(
|
||||
export BT_MP_FORCE_DMG=1
|
||||
export BT_BODY_SM_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
|
||||
[ -n "$LEGACY" ] && export BT_NO_BODY_FALLTHRU=1
|
||||
bt_launch s2_${MODE}_b.log S2.EGG 0x0C -net 1601
|
||||
)
|
||||
sleep 2
|
||||
# ---- node A: the walking victim; autodrive persists across respawns -------
|
||||
(
|
||||
export BT_AUTODRIVE=0.8
|
||||
export BT_BODY_SM_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
|
||||
[ -n "$LEGACY" ] && export BT_NO_BODY_FALLTHRU=1
|
||||
bt_launch s2_${MODE}_a.log S2.EGG 0x03 -net 1501
|
||||
)
|
||||
sleep 5
|
||||
python ../tools/btconsole.py S2.EGG 127.0.0.1:1501 127.0.0.1:1601 > s2_${MODE}_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep "$DUR"
|
||||
kill $RELAY 2>/dev/null
|
||||
bt_kill_ours
|
||||
sleep 2
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 3
|
||||
|
||||
echo "=================== MODE=$MODE ==================="
|
||||
echo -n "death cycles on A (the provocation actually ran): "
|
||||
grep -acE "death cycle START" s2_${MODE}_a.log
|
||||
echo
|
||||
echo "--- THE SYMPTOM: [skate] episodes on B (A's replicant) ---"
|
||||
echo -n "SKATING lines: "; grep -ac "SKATING" s2_${MODE}_b.log
|
||||
grep -a "\[skate\]" s2_${MODE}_b.log | head -14
|
||||
echo
|
||||
echo "--- THE MECHANISM: [bodySM] arm->reset pairs (1 Hz throttled) ---"
|
||||
echo -n "STANDING-LOCK on B: "; grep -ac "STANDING-LOCK" s2_${MODE}_b.log
|
||||
grep -a "STANDING-LOCK" s2_${MODE}_b.log | grep -a REPLICANT | sort -u | head -3
|
||||
echo
|
||||
echo "--- THE POSITIVE: moving replicant's body state, 1 Hz ---"
|
||||
grep -a "\[peergait\]" s2_${MODE}_b.log | grep -oaE "bstate=[0-9-]+" | sort | uniq -c | sort -rn | head -8
|
||||
echo -n "samples flagged IDLE CHANNELS: "; grep -ac "IDLE CHANNELS" s2_${MODE}_b.log
|
||||
echo
|
||||
echo "--- control: skate on A (B stands still -- expect 0) ---"
|
||||
grep -ac "SKATING" s2_${MODE}_a.log
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# #146 -- desktop virtual throttle must be RELEASED on respawn.
|
||||
#
|
||||
# The pod's throttle is a physical lever and Mech::Reset deliberately leaves
|
||||
# it alone (subsystem loop starts at index 2; ControlsMapper is index 0) --
|
||||
# authentic, kept. The desktop bridge only EMULATES that lever with the
|
||||
# static ramp accumulator sLever, so a pad/keyboard pilot respawned at speed
|
||||
# while holding nothing. Fix: queue the existing all-stop at Mech::Reset,
|
||||
# LOCAL VIEWPOINT MECH ONLY.
|
||||
#
|
||||
# WHAT THIS BENCH ACTUALLY TESTS. It does NOT test "does zeroing sLever
|
||||
# stop the mech" -- that path is the X all-stop button, proven in the field
|
||||
# every day, and BT_AUTODRIVE cannot exercise it anyway (forced mode reads
|
||||
# gBTDrive.forcedThrottle, never sLever -- mechmppr.cpp:677).
|
||||
#
|
||||
# It tests the one thing the fix could genuinely get WRONG: the viewpoint
|
||||
# gate. Mech::Reset also runs for REPLICANTS, so an ungated write to the
|
||||
# global gBTDrive would all-stop the local player every time a REMOTE mech
|
||||
# respawned -- a far worse bug than the one being fixed.
|
||||
#
|
||||
# PASS: A (who dies) logs "[respawn] desktop throttle released" once per
|
||||
# Mech::Reset, and B logs it ZERO times while A is respawning.
|
||||
# FAIL: any occurrence in B's log -> the gate leaks and remote respawns
|
||||
# stop the local pilot.
|
||||
#
|
||||
# A self-damages to death on a timer; B just flies and watches.
|
||||
# =========================================================================
|
||||
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 tr_a.log tr_b.log tr_relay.log
|
||||
bt_expert_egg MP.EGG TR.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" TR.EGG
|
||||
|
||||
# B: the OBSERVER. Also driving, so a leaked all-stop would be doubly visible.
|
||||
( export BT_AUTODRIVE=0.6
|
||||
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_KEY_LOG=1
|
||||
bt_launch tr_b.log TR.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
# A: drives, dies repeatedly, respawns.
|
||||
( export BT_AUTODRIVE=0.8 BT_SELF_DAMAGE=8
|
||||
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_KEY_LOG=1
|
||||
bt_launch tr_a.log TR.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py TR.EGG 127.0.0.1:1501 127.0.0.1:1601 > tr_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep 280
|
||||
kill $RELAY 2>/dev/null
|
||||
sleep 3
|
||||
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
|
||||
|
||||
echo "=================== #146 THROTTLE RELEASE ON RESPAWN ==================="
|
||||
echo "--- A: how many respawns, how many releases? (must be 1:1) ---"
|
||||
echo -n " A Mech::Reset .......... "; grep -ac "Mech::Reset" tr_a.log
|
||||
echo -n " A throttle released .... "; grep -ac "desktop throttle released" tr_a.log
|
||||
echo
|
||||
echo "--- B: the VIEWPOINT GATE. Must be 0 releases despite seeing A respawn. ---"
|
||||
echo -n " B Mech::Reset (incl. replicant) .... "; grep -ac "Mech::Reset" tr_b.log
|
||||
echo -n " B throttle released (MUST BE 0) .... "; grep -ac "desktop throttle released" tr_b.log
|
||||
echo
|
||||
echo "--- A: the release must sit INSIDE the reset block ---"
|
||||
grep -aE "Mech::Reset|desktop throttle released" tr_a.log | head -8
|
||||
echo
|
||||
echo "--- B: same window, to show B saw the respawn and still did not release ---"
|
||||
grep -aE "Mech::Reset|desktop throttle released" tr_b.log | head -8
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# TURN-IN-PLACE regression check for the #52 fallthrough fix.
|
||||
#
|
||||
# The fix only changes ONE path: case 0 no longer falls into the port's
|
||||
# turn block. Entry INTO state 4 is unaffected -- masters arm it via the
|
||||
# leg twin's lockstep (mech2.cpp:1014) and peers via the #82 block
|
||||
# (mech4.cpp:2851), and both set the state so the NEXT frame dispatches
|
||||
# straight to case 4. This bench exercises that claim instead of asserting
|
||||
# it: force sustained PIVOTING and confirm state 4 is still entered, still
|
||||
# advances, and still exits -- on both the master and the peer.
|
||||
#
|
||||
# PROVOCATION: BT_GOTO with a tiny throttle and a stop radius it can never
|
||||
# reach -- the mech steers at the enemy forever while creeping below
|
||||
# standSpeed, which is exactly the trn entry gate (turning + speed in
|
||||
# [0, standSpeed] + turnCapable).
|
||||
# =========================================================================
|
||||
set -x
|
||||
MODE="${1:-fixed}"
|
||||
DUR="${2:-150}"
|
||||
. /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 tr_${MODE}_a.log tr_${MODE}_b.log tr_${MODE}_relay.log
|
||||
|
||||
bt_expert_egg MP.EGG TR.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" TR.EGG
|
||||
|
||||
LEGACY=""
|
||||
[ "$MODE" = "legacy" ] && LEGACY=1
|
||||
|
||||
# v2: MUTUAL goto converged instantly -- both mechs faced each other, err~0,
|
||||
# nothing ever turned (146 samples parked at Standing). A pivot needs a target
|
||||
# that keeps MOVING, so B autodrives away and A creeps-and-steers after it:
|
||||
# heading error stays live while A's speed stays under standSpeed, which is the
|
||||
# trn entry gate.
|
||||
( export BT_AUTODRIVE=0.6 # B: the moving target
|
||||
export BT_BODY_SM_LOG=1 BT_MP_LOG=1
|
||||
[ -n "$LEGACY" ] && export BT_NO_BODY_FALLTHRU=1
|
||||
bt_launch tr_${MODE}_b.log TR.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
( export BT_GOTO=enemy BT_GOTO_STOP=5 BT_GOTO_THR=0.02 BT_GOTO_LOG=1
|
||||
export BT_BODY_SM_LOG=1 BT_MP_LOG=1 # A: the pivoter
|
||||
[ -n "$LEGACY" ] && export BT_NO_BODY_FALLTHRU=1
|
||||
bt_launch tr_${MODE}_a.log TR.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py TR.EGG 127.0.0.1:1501 127.0.0.1:1601 > tr_${MODE}_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep "$DUR"
|
||||
kill $RELAY 2>/dev/null
|
||||
bt_kill_ours
|
||||
sleep 2
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 3
|
||||
|
||||
echo "=================== TURN REGRESSION MODE=$MODE ==================="
|
||||
echo "--- MASTER: does the turn-in-place clip still get entered + advanced? ---"
|
||||
for n in a b; do
|
||||
echo "node $n body state=4 samples : $(grep -a '\[gaitSM\] adv=' tr_${MODE}_${n}.log | grep -caE ' state=4 ')"
|
||||
echo "node $n leg state=4 samples : $(grep -a '\[gaitSM\] adv=' tr_${MODE}_${n}.log | grep -caE ' legState=4')"
|
||||
echo "node $n full body distribution:"
|
||||
grep -a "\[gaitSM\] adv=" tr_${MODE}_${n}.log | grep -oaE "state=[0-9]+" | sort | uniq -c | sort -rn | head -6
|
||||
done
|
||||
echo
|
||||
echo "--- PEER: does a replicant still pivot (body state 4 while moving)? ---"
|
||||
grep -a "\[peergait\]" tr_${MODE}_b.log | grep -oaE "bstate=[0-9-]+" | sort | uniq -c | sort -rn | head -8
|
||||
echo
|
||||
echo "--- no new lock / no skate ---"
|
||||
echo -n "STANDING-LOCK a/b: "; echo "$(grep -ac 'STANDING-LOCK' tr_${MODE}_a.log) / $(grep -ac 'STANDING-LOCK' tr_${MODE}_b.log)"
|
||||
echo -n "SKATING a/b: "; echo "$(grep -ac 'SKATING' tr_${MODE}_a.log) / $(grep -ac 'SKATING' tr_${MODE}_b.log)"
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Night 13: correct #148's framing with the measured findings. ASCII only."""
|
||||
import sys
|
||||
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
|
||||
import gitea
|
||||
|
||||
BODY = """**Investigated, narrowed a long way, NOT fixed. Correcting this ticket's own framing first.**
|
||||
|
||||
## The title/premise was WRONG -- the cadence is authentic
|
||||
|
||||
I filed this as "only 13 update records across a 5-minute run". That is not a defect. Reading the
|
||||
payloads with line numbers:
|
||||
|
||||
```
|
||||
205 RECORD atUpd=0.0436769 rate=0.305433
|
||||
502 RECORD atUpd=2.3558 rate=-0.305433
|
||||
1150 RECORD atUpd=-2.39277 rate=0.305433
|
||||
1698 RECORD atUpd=2.38543 rate=-0.305431
|
||||
2044 RECORD atUpd=-2.39124 rate=0.305427
|
||||
...
|
||||
```
|
||||
|
||||
Those `atUpd` values are the sweep's EXTREMES and `rate` flips sign at each one. **The master sends
|
||||
a torso record on RATE CHANGE**, and the peer dead-reckons `atUpd + rate * elapsed` in between
|
||||
(`ComputeTargetTwist`). 12 records for 12 direction reversals is exactly right -- this is
|
||||
extrapolation-based replication working as designed, not a starved channel.
|
||||
|
||||
Also ruled out: the `ComputeTargetTwist` clamp. The copy's limits load correctly
|
||||
(`limL=2.44346 limR=-2.44346 enab=1 copy=1`), so `Min/Max` is not pinning `targetTwist` to zero.
|
||||
And the extrapolator itself is exact -- the copy reports `cur == target` on every sample.
|
||||
|
||||
## The real defect
|
||||
|
||||
**The peer's copy torso Performance does not run at all until log line ~1006, while its first
|
||||
record arrived at line 205.** ~800 lines of correctly-replicated twist data integrated by nobody.
|
||||
|
||||
This is now directly readable because `[torso-copy]` logs on call #0 (`s_cl++ % 120`), so its first
|
||||
line IS the first `TorsoCopySimulation` call. (That also means my earlier "first copy currentTwist
|
||||
!= 0 at line 1016" was a SAMPLING artifact -- first *sample*, not first non-zero.)
|
||||
|
||||
The first tick coincides with the replicant's MODEL bring-up, not with record arrival:
|
||||
|
||||
```
|
||||
[loadclips] end: fScale=0.8 ... hasGimpClips=1
|
||||
[clipfix] mech 05769358 -> EXTERIOR (lean)
|
||||
[torso] PushTwist COPY node=057A3C68 type=1 twist=-1.52319
|
||||
```
|
||||
|
||||
So the gate is **above the subsystem level**, somewhere in replicant model/clip initialisation.
|
||||
Not yet found.
|
||||
|
||||
## Tried and rejected
|
||||
|
||||
`Entity::Perform` (ENTITY.cpp:733-793, real engine source) picks the executable predicate by
|
||||
instance -- `IsNonReplicantExecutable()` for masters, `IsReplicantExecutable()` for replicants --
|
||||
and they differ exactly on `|| lastUpdate >= lastPerformance`, which is what makes an
|
||||
`ExecuteOnUpdate` subsystem run when a record arrives. Mech's reconstructed tick loop used the
|
||||
NonReplicant predicate for every mech, dropping the branch.
|
||||
|
||||
That is a genuine fidelity gap and it is now **fixed** (`f36f013`) -- but it does **not** move this
|
||||
bug (first copy tick 1014 -> 1006, noise). The torso's own flag was not the gate.
|
||||
|
||||
## Consequence, and what it is NOT
|
||||
|
||||
This is the tail of #141: the peer's missiles launch along the body facing until its torso starts
|
||||
ticking. #141's own fix is complete and verified separately -- every zero-`twistDelta` peer launch
|
||||
now reads `liveTwist=0` on the same log line, and the first launch with `liveTwist=-1.84061` reads
|
||||
`twistDelta=-1.83813`. The launch frame tracks the twist perfectly; there is simply no twist to
|
||||
track until the copy torso wakes up.
|
||||
|
||||
Likely also relevant to **#37** (MadCat torso BACKWARDS) and **#70** (twist stops after respawn) --
|
||||
a peer torso that does not tick would sit at its bind pose, and a respawn re-runs model bring-up.
|
||||
Re-test both once this is found.
|
||||
|
||||
## Diagnostics now in place
|
||||
|
||||
`BT_TORSO_LOG`: `[torso-rec-rx]` (receive + raw payload), `[torso-copy]` (cur/target/atUpd/rate plus
|
||||
`limL/limR/enab`), `[torso] PushTwist master|COPY` (per instance-kind).
|
||||
`BT_PROJ_LOG`: `[launchframe]` now carries the shooter's live torso twist, so `twistDelta` and its
|
||||
driver appear on the same line."""
|
||||
|
||||
gitea.call("/issues/148", method="PATCH", payload={
|
||||
"title": "Peer copy TORSO does not tick until replicant model bring-up -- ~800 lines of replicated twist integrated by nobody"})
|
||||
gitea.comment(148, BODY)
|
||||
print("updated #148")
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# #152: is the AUTHENTIC torso-CENTER button (0x42) alive on the RIO/glass path
|
||||
# (key bridge OFF)? Cycle to Standard, deflect/release the twist axis, and
|
||||
# hold 0x42 during release windows. ctrCmd=1 while held + twist slewing to 0
|
||||
# = route works (ticket becomes player education). ctrCmd stuck 0 = the
|
||||
# streamed 0x42 route is dead on glass and needs the forward implemented.
|
||||
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 c42_a.log
|
||||
bt_expert_egg MP.EGG C42.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" C42.EGG
|
||||
( export BT_MODECYCLE_EVERY=400 BT_TWIST_PULSE=150
|
||||
export BT_BTNTEST=0x42,900,1150 BT_BTNTEST2=0x42,1500,1750
|
||||
export BT_TORSO_LOG=1 BT_KEY_NOFOCUS=1 BT_KEY_BRIDGE=0
|
||||
bt_launch c42_a.log C42.EGG 0x03 )
|
||||
sleep 130
|
||||
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
|
||||
echo "============ RESULT ============"
|
||||
grep -a "\[mode\] control mode" c42_a.log | head -4
|
||||
echo "--- ctrCmd seen (want some =1 during the 0x42 holds) ---"
|
||||
grep -ao "ctrCmd=[01]" c42_a.log | sort | uniq -c
|
||||
echo "--- twist trajectory around the first hold ---"
|
||||
grep -aE "twistpulse|ctrCmd=1" c42_a.log | head -8
|
||||
@@ -0,0 +1,69 @@
|
||||
"""#137: post the fix write-up. ASCII only."""
|
||||
import sys
|
||||
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
|
||||
import gitea
|
||||
|
||||
BODY = """**FIXED** (`0d6ed40`) -- and my previous two analyses of this ticket were both wrong, so here is
|
||||
the final, measured story.
|
||||
|
||||
## The fix is ONE LINE, and it is the binary's own
|
||||
|
||||
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 port reconstructed the ring
|
||||
faithfully but its Reset never got that line. Restored as:
|
||||
|
||||
```cpp
|
||||
accelPrevPos = origin.linearPosition; // binary +0x58c re-seed
|
||||
```
|
||||
|
||||
## What actually happened at a respawn
|
||||
|
||||
1. The respawn TELEPORTS the mech; `accelPrevPos` still held the death position.
|
||||
2. First post-respawn sample: `|newPos - prevPos| / dt` = **teleport distance / dt ~ 1e5** enters
|
||||
the velocity ring; the ring-mean derivative spikes `AccelerationLastFrame` (pure forward, with
|
||||
an opposite-sign echo ~15 frames later as the sample rotates out of the 15-ring).
|
||||
3. The myomer drive-heat integrator `@004b8d18`:
|
||||
`termAccel = (1-accEff) * |v| * |a| * m * dt = 0.2 * 40 * 1.04e5 * 75000 * 0.044 = 2.75e9`
|
||||
-- one tick deposits ~3e9 into `pendingHeat -> heatEnergy`.
|
||||
4. The freshly-reset myomers snaps 77 -> ~9000 against `failT=2000` -> `speedEffect 0` ->
|
||||
`speedDemand *= 0` -> **"respawned with heat maxed, unable to move until it cools"**. The
|
||||
excess then sheds into Condenser5 and GeneratorD -- Oracle's "loop 5 and generator D heating
|
||||
up" clause, literally.
|
||||
|
||||
**Why ~8% and why it eluded everyone:** the deposit needs `|v|` in the same 1-2 frames, so only
|
||||
pilots whose throttle was still forward at the respawn (physical lever / HOTAS -- exactly who
|
||||
reported it) got the freeze. Idle-throttle respawns deposit nothing. That is also why frozen
|
||||
respawns were a strict subset of died-hot ones: died hot = was running hard = lever still forward.
|
||||
|
||||
## Measured (same abusive bench: 0.95 throttle, continuous autofire)
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| deposits >1e7 near resets | up to **3.35e9**, every one 3-4 lines after a reset | **0** |
|
||||
| frozen respawns | 4-6 of 7 | **0 of 7** |
|
||||
| post-reset myomers T | 7,700-12,100 | 77-178 (degradeT is 1000) |
|
||||
|
||||
## For the record: what it was NOT (each killed by operand data)
|
||||
|
||||
Not the reset (T=77 at every reset, roster-wide). Not stale pendingHeat (1-frame bound, +1.2K).
|
||||
Not death-window accumulation (consumers tick the wreck). Not conduction (roster snapshot: all
|
||||
partners at 77; flow trap: zero e6 flows into the myomers). Not drag or impulses (both trapped:
|
||||
never fired). And not my earlier "players accelerate to top speed" close -- arithmetically
|
||||
impossible (input ceiling ~60 deg/s vs the observed 1,100-21,000 deg/s snap); the KB paragraph
|
||||
carrying that claim is corrected.
|
||||
|
||||
The in-life governor is untouched and authentic: sustained top-speed running still derates the
|
||||
myomers (constants byte-verified). What is gone is the respawn injecting a teleport-sized heat
|
||||
slug.
|
||||
|
||||
Credit where due: the operator called the shape of this from the start -- *"maybe the math gets
|
||||
screwy in respawning while some systems are ticking while values are being reset."*
|
||||
|
||||
**Unreleased; needs a build cut. Field-verify:** respawn while holding the throttle forward on a
|
||||
HOTAS -- the mech should drive off cleanly with a cold heat bar every time. Gotcha #30 records the
|
||||
class so the next dropped re-seed gets caught in review."""
|
||||
|
||||
gitea.comment(137, BODY)
|
||||
gitea.call("/issues/137", method="PATCH", payload={"state": "closed"})
|
||||
print("posted + closed #137")
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Night 14 (build 4.11.857) housekeeping: close what the field confirmed, reopen
|
||||
#137 with evidence, and file the new reports. ASCII only."""
|
||||
import sys
|
||||
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
|
||||
import gitea
|
||||
|
||||
FIELD = ("Field-verified on **4.11.857** (playtest 2026-08-08, 4 testers: Oracle/SCREECH, "
|
||||
"Sauron/XIAOLONG, santo/MS-FIREFLY, RajelAran/GAMERSLAB).")
|
||||
|
||||
# ---------------------------------------------------------------- CLOSE ----
|
||||
CLOSE = {
|
||||
52: "**CONFIRMED FIXED.** " + FIELD + "\n\nOracle: *\"no skating observed\"*.\n\n"
|
||||
"Cross-check: the `[skate]` detector fires zero times across all four logs.\n\n"
|
||||
"**#130 (Vulture skating) should be re-tested against this build** -- it was always "
|
||||
"suspected to be the same defect.",
|
||||
108: "**CONFIRMED FIXED.** " + FIELD + "\n\nOracle: *\"Lynx panic ejected and didn't ghost\"*.\n\n"
|
||||
"The eject path now runs the full death tail, so the respawn trigger fires.",
|
||||
142: "**CONFIRMED FIXED.** " + FIELD + "\n\nOracle: *\"Fixed, button indicating state and mech "
|
||||
"is standing after respawn\"* -- both halves: the indicator animates, and the "
|
||||
"`Mech::Reset` posture clear brings you back standing.",
|
||||
140: "**CONFIRMED FIXED.** " + FIELD + "\n\nOracle: *\"It is not overwriting the saved settings\"*.",
|
||||
141: "**CONFIRMED FIXED.** " + FIELD + "\n\nOracle: *\"Fixed\"*. VGL Lynx, independently and "
|
||||
"before seeing the notes: *\"Missiles seem to be launching from weapon ports\"* -- which is "
|
||||
"the symptom stated positively.\n\n"
|
||||
"Worth noting the same stale-segment-cache defect was fixed on the **beam muzzle** path in "
|
||||
"the same sweep; nobody had reported that one, and a peer's beams would have had the "
|
||||
"identical wrong origin whenever the shooter was twisted.",
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- COMMENT ----
|
||||
COMMENT = {
|
||||
147: "**Not reproduced on 4.11.857** -- Oracle: *\"I have been unable to recreate the range caret "
|
||||
"issue today ... but only tried in Solo\"*, and later *\"Not observed\"*.\n\n"
|
||||
"Cross-check: zero `NaN TRAPPED` receipts in any of the four field logs, so the trap never "
|
||||
"had to fire either.\n\n"
|
||||
"**Keeping this OPEN.** One clean night is not proof for a defect whose whole signature is "
|
||||
"'rare, and sticky once it happens' -- and solo exercises far less of the targeting path "
|
||||
"than a 4-way drop. If the caret dies again, `BT_RANGE_LOG=1` now names the frame.",
|
||||
146: "**Working on 4.11.857, with a nuance worth recording.** Oracle: *\"Initial spawn/respawn "
|
||||
"throttle is idle and then begins to respond to match my throttle position on my X56 "
|
||||
"without moving it.\"* That is the fix behaving correctly on a PHYSICAL throttle: the "
|
||||
"virtual lever is released at respawn, then re-syncs to where the hardware lever actually "
|
||||
"sits.\n\n"
|
||||
"**But see the new report about being unable to move after respawn until the throttle is "
|
||||
"cycled.** A HOTAS user who respawns with the lever already forward now gets no motion "
|
||||
"until the axis next changes -- that may be this fix's side effect rather than a separate "
|
||||
"bug, and the two need separating before either is 'fixed'.",
|
||||
135: "**Related field report on 4.11.857** (Oracle): *\"buttons for gens are not flashing when "
|
||||
"they leak\"* -- observed in GLASS mode this time, where this ticket was originally about "
|
||||
"the normal (non-glass) screen path. Same annunciator chain, so treat them together: the "
|
||||
"coolant-leak condition (cond 2) is supposed to light the per-generator lamps.",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ NEW ----
|
||||
NEW = [
|
||||
("REOPEN #137 -- placeholder", None), # handled separately below
|
||||
]
|
||||
|
||||
def main():
|
||||
for num, body in CLOSE.items():
|
||||
gitea.close(num, body)
|
||||
for num, body in COMMENT.items():
|
||||
gitea.comment(num, body)
|
||||
|
||||
# ---- #137: reopen. I closed this wrongly; the field disproved my analysis.
|
||||
gitea.comment(137,
|
||||
"**REOPENING -- I closed this incorrectly, and the field data disproves my analysis.**\n\n"
|
||||
"I closed it as 'not a bug: the mech respawns still under throttle and earns the heat "
|
||||
"honestly'. Oracle pushed back: *\"not sure if the bug report was understood ... the mech "
|
||||
"is initially unable to move until it cools off sufficiently to start moving. This is "
|
||||
"abnormal.\"* He is right, and 'unable to move' is the detail that kills my explanation -- "
|
||||
"a mech that cannot move cannot be earning heat by running.\n\n"
|
||||
"## Measured on 4.11.857, all four field logs\n\n"
|
||||
"Scanning every respawn for the freeze signature (throttle up, speedDemand pinned at 0):\n\n"
|
||||
"| player | respawns | FROZE |\n|---|---|---|\n"
|
||||
"| Oracle / SCREECH | 16 | **2** |\n| Sauron / XIAOLONG | 19 | **2** |\n"
|
||||
"| RajelAran / GAMERSLAB | 16 | **1** |\n| santo / MS-FIREFLY | 10 | 0 |\n\n"
|
||||
"**5 of 61 respawns (~8%)**, across three independent machines.\n\n"
|
||||
"The sequence at one of them (Oracle, log line 48584) is unambiguous:\n\n"
|
||||
"```\n"
|
||||
"[mppr] thr=0.847 -> speedDemand=44.87 <- moving normally, pre-death\n"
|
||||
"[respawn] Mech::Reset 2:21 healed+moved\n"
|
||||
"[techstat] Myomers condition 1 CLEARED <- Damaged cleared OK\n"
|
||||
"[techstat] Myomers condition 2 CLEARED <- Leaking cleared OK\n"
|
||||
"[techstat] Myomers condition 3 SET <- OVERHEATING, AT the reset\n"
|
||||
"[mppr] thr=0.762 -> speedDemand=0 <- FROZEN\n"
|
||||
"```\n\n"
|
||||
"The reset clears the damage and leak flags correctly and then the myomers come up "
|
||||
"**already overheating**, in the same breath as the reset -- before any running could have "
|
||||
"happened. The myomer heat-freeze (`speedEffect` -> `speedDemand *= it`) then pins speed "
|
||||
"at zero, which is exactly the reported 'cannot move until it cools'.\n\n"
|
||||
"## Why my bench missed it\n\n"
|
||||
"It is **intermittent (~8%)**. My synthetic bench reset cleanly every single time and "
|
||||
"reported `T=77 start=77` for every heat-bearing subsystem including all six Condensers -- "
|
||||
"so I concluded the reset path was correct and closed the ticket. A bench that never "
|
||||
"reproduces the failure is not evidence the failure does not exist; I treated it as such.\n\n"
|
||||
"## Next\n\n"
|
||||
"The temperature is NOT logged in the field (`BT_HEAT_LOG` is off), so the next step is a "
|
||||
"bench that reproduces the freeze rather than the happy path -- repeated death/respawn "
|
||||
"under load until a reset lands hot -- and then reads `[heat-reset]` on the failing one. "
|
||||
"The ~8% rate suggests a race or an ordering dependency rather than a plain missing reset, "
|
||||
"since the plain path demonstrably works.")
|
||||
gitea.call("/issues/137", method="PATCH", payload={"state": "open"})
|
||||
print("REOPENED #137")
|
||||
|
||||
made = []
|
||||
def new(title, body, ):
|
||||
i = gitea.create(title, body)
|
||||
made.append(i["number"])
|
||||
|
||||
new("PERFORMANCE REGRESSION 817 -> 857: hard stalls at mission load (glass)",
|
||||
"Oracle: *\"frame rate very poor with glass cockpit starting with this build. Only "
|
||||
"observed by Oracle, Sauron using glass had no issue. Oracle had no performance issues in "
|
||||
"past with glass.\"*\n\n"
|
||||
"**Confirmed, and it is a real regression -- but it is STUTTER, not low framerate.**\n\n"
|
||||
"## Same machine, previous build vs this one (Oracle / SCREECH)\n\n"
|
||||
"| build | median frame | stalls >50ms | worst |\n|---|---|---|---|\n"
|
||||
"| 4.11.817 (Aug 6) | 5.79 ms | **0** (0.00%) | 15.4 ms |\n"
|
||||
"| 4.11.857 (Aug 8) | 5.87 ms | **97** (6.70%) | 103.7 ms |\n\n"
|
||||
"His steady state is **identical** (5.79 -> 5.87 ms). 817 had literally zero stalls; 857 "
|
||||
"has 97, and the worst frame went 15 ms -> 104 ms.\n\n"
|
||||
"## Why only Oracle noticed\n\n"
|
||||
"| player | median | stalls >50ms |\n|---|---|---|\n"
|
||||
"| Oracle | 5.87 ms (170 fps) | 97 |\n| santo | 3.55 ms | 0 |\n"
|
||||
"| Sauron | 12.53 ms (80 fps) | 0 |\n| RajelAran | 6.70 ms | 0 |\n\n"
|
||||
"Oracle has the **best median of the group** and Sauron -- who reported no problem -- has "
|
||||
"the worst. Smooth-but-slower reads as fine; fast-with-hitches reads as broken. The "
|
||||
"distribution is bimodal: **all 97 of Oracle's slow blocks are >50 ms**, nothing in "
|
||||
"between.\n\n"
|
||||
"## Where\n\n"
|
||||
"All 97 stalls are one contiguous window (log lines 30307-32979, 30-32% through the "
|
||||
"session), and it begins exactly at **mission load**, as peer mechs stream in:\n\n"
|
||||
"```\n"
|
||||
"[clipfix] mech 15A1FF38 -> EXTERIOR (lean)\n"
|
||||
"[loadclips] end: fScale=0.8 ...\n"
|
||||
"[spike] dt=0.356 turn=0 thr=0\n"
|
||||
"[rstat] frames=10 avg=103.713ms maxDraw=518.497 maxPresent=7.046 batches=547 culled=226\n"
|
||||
"```\n\n"
|
||||
"`maxDraw=518ms` against `maxPresent=7ms` -- it is **draw time**, not vsync/present.\n\n"
|
||||
"## NOT the instrumentation\n\n"
|
||||
"The standing theory was that accumulated logging is slowing things down. The data says "
|
||||
"no: the **median frame time is unchanged between builds** (5.79 -> 5.87 ms) and log "
|
||||
"volumes are comparable across players (6.1-6.9 MB). Logging would raise the floor "
|
||||
"everywhere, not add isolated 100 ms draw spikes in one window.\n\n"
|
||||
"## Prime suspect\n\n"
|
||||
"The #141 stale-segment-cache fix in this same build. It routes muzzle/beam queries "
|
||||
"through `JointedMover::GetSegmentToWorld`, which -- when joints are modified, i.e. every "
|
||||
"animating frame -- walks the whole segment table and marks every segment dirty, forcing "
|
||||
"the hierarchy to re-derive. That is now on per-frame paths (`BTResolveWeaponMuzzle`, and "
|
||||
"the energy-beam gun-port loop which runs per weapon per mech per frame). With several "
|
||||
"mechs newly loaded it multiplies out. Faithful to the binary, but the binary was not "
|
||||
"calling it this often.\n\n"
|
||||
"**Next:** time `GetSegmentToWorld` per frame and count calls per mech; if it is the "
|
||||
"cause, cache the resolved frame per (mech, segment) for the duration of a frame rather "
|
||||
"than re-deriving per weapon.")
|
||||
|
||||
new("Double KILL credit: one kill counted twice",
|
||||
"Two independent reports on 4.11.857:\n\n"
|
||||
"* RajelAran: *\"in match beginning 20:33, I got a double kill on Oracle. One kill, "
|
||||
"counted twice\"*\n"
|
||||
"* Oracle: *\"12:12AM ET - Oracle killed Conn Man and got 2 kills credited. Rajel also "
|
||||
"the same when killing me.\"*\n\n"
|
||||
"Log cross-check (Oracle/SCREECH): the scoreboard `kills=` reading jumps **2 -> 4**, "
|
||||
"skipping 3.\n\n"
|
||||
"## Suspect (identified 2026-08-07, never fixed)\n\n"
|
||||
"In the type-2 (Kill) arm of the score handler:\n\n"
|
||||
"```c\n"
|
||||
"++killCount; // this player\n"
|
||||
"if (sender_owner) ++sender_owner->killCount; // the mech named by senderMechID\n"
|
||||
"```\n\n"
|
||||
"For type 2, `senderMechID` is the **victim**. The second increment is justified in the "
|
||||
"comment by the SOLO case, where the dummy target has no owning player -- but in MP the "
|
||||
"victim *does* have an owner, so the credit lands twice. In a 1v1 exchange both "
|
||||
"increments can even land on the same scoreboard row, which is what 'one kill counted "
|
||||
"twice' looks like.\n\n"
|
||||
"Needs a 2-node bench confirming the victim's `killCount` moves on a kill.")
|
||||
|
||||
new("PANIC eject scores -499 instead of 0 (double-charged)",
|
||||
"Oracle, 4.11.857, **confirmed twice**: *\"Had 1000 points, leaked out all coolant, hit "
|
||||
"'panic' and ended up with -499 points. Should it have been zero points because "
|
||||
"1000 - 1000 = 0 for ejecting? Why the extra -499 points?\"*\n\n"
|
||||
"The shipped `Role::Default` carries `deathPenalty=500`, and the death tail applies "
|
||||
"`-specialCaseDeathPenalty`. 1000 - 500 = 500, so -499 is roughly **1000 - 500 - 999**, "
|
||||
"i.e. the penalty looks like it is being applied more than once, or the eject path "
|
||||
"charges both the normal death cost and a separate panic cost.\n\n"
|
||||
"Note this is the tail of **#134** (panic eject carried NO score penalty) -- that fix "
|
||||
"landed and now over-charges. The two should be read together.\n\n"
|
||||
"Also from the same report, lower confidence / subjective: *\"Missiles seem to be scoring "
|
||||
"a little low (twin LRM15s scored ~30-40 points)\"* -- worth a damage-vs-score comparison "
|
||||
"rather than a code change on its own.")
|
||||
|
||||
new("REGRESSION: torso no longer auto-recentres in ANY control mode",
|
||||
"Oracle, 4.11.857: *\"Torso is no longer recentering on its own in any mode "
|
||||
"Bas/Mid/Adv.\"*\n\n"
|
||||
"**This is a regression from the control-mode fix in this build.** That change replaced "
|
||||
"the Basic-mode re-centre write from `centerCommand` (@0x208, the HELD-button cell that "
|
||||
"nothing released -- the bug where the centering *fought* the pilot) with "
|
||||
"`recenterActive` (@0x274), a ONE-SHOT that self-clears on settle, matching @004afbe0.\n\n"
|
||||
"The one-shot is what the binary does on the mode CHANGE. What it does not do is provide "
|
||||
"the CONTINUOUS auto-centring that Basic mode is supposed to have per the 1995 manual "
|
||||
"(BAS = joystick turns the mech, **no torso twist**). So the fix removed a behaviour that "
|
||||
"was previously coming -- incorrectly, but coming -- from the stuck cell.\n\n"
|
||||
"Both halves need to be true at once: Basic must hold the torso centred, without the "
|
||||
"held-cell latch that made it fight the stick in Standard/Veteran.\n\n"
|
||||
"The original complaint (Sauron: cycling to advanced and back made the centering fight "
|
||||
"the controls) is fixed and should stay fixed -- verify any change against both.")
|
||||
|
||||
new("Basic-mode elevation limit does not clamp the CURRENT view",
|
||||
"Oracle, 4.11.857: *\"Basic mode does limit downward view to 'half', but only after "
|
||||
"cycling off 'Basic' and then back to 'Basic'. If in Mid or Advanced and move view past "
|
||||
"'half' down and then switch to Basic, nothing happens until I move the control stick in "
|
||||
"the pitch axis and then the view immediately snaps up to 'half' limit.\"*\n\n"
|
||||
"The elevation-limit swap (Basic gets @0x228/@0x22C = full top, HALF bottom; "
|
||||
"Standard/Veteran get @0x230/@0x234) is new in this build and previously was never "
|
||||
"implemented at all. It writes the LIMITS but does not re-clamp `currentElevation`, so an "
|
||||
"already-out-of-range view stays until the next pitch input clamps it.\n\n"
|
||||
"Small fix: clamp current elevation into the new pair at the moment of the swap. Worth "
|
||||
"checking against the binary whether it clamps on switch or leaves it to the sim -- the "
|
||||
"snap-on-next-input behaviour may be authentic.\n\n"
|
||||
"Also: Oracle asked what 'settles the HUD horizon' meant in the release note -- that was "
|
||||
"my wording for Basic raising the HUD's `flickerActive` (@0x2A0) so the horizon "
|
||||
"re-settles with the re-centred torso. Needs a plainer description for testers.")
|
||||
|
||||
new("Overheated generator never came back online; no generator-out alarm on thermal trip",
|
||||
"Oracle, 4.11.857: *\"Noticed a generator that overheated never came back online even "
|
||||
"after cooling off completely. Also noticed the generator out message doesn't sound after "
|
||||
"a generator fails due to overheat. The second and third time I overheated a generator "
|
||||
"for the test it did come back online after cooling halfway, and I heard the generator "
|
||||
"out warning upon the generator coming back online.\"*\n\n"
|
||||
"Two defects, and the intermittency is the interesting part -- the same test gave a stuck "
|
||||
"generator once and a recovering one twice.\n\n"
|
||||
"1. **Stuck offline after thermal trip.** The thermal breaker in `GeneratorSimulation` "
|
||||
"produces `stateAlarm 4` (GeneratorOut); recovery should re-arm once temperature falls. "
|
||||
"Related known nuance (context/decomp-reference.md): the generator sim has NO Ready-case "
|
||||
"recompute, so a generator can hold stale state until *some* transition recomputes it.\n"
|
||||
"2. **Alarm timing.** The generator-out warning did not sound on the trip, but DID sound "
|
||||
"later on the way back online -- so the annunciator appears tied to the wrong edge.\n\n"
|
||||
"Cross-check available: `[techstat] GeneratorX condition 3` (Overheating) transitions are "
|
||||
"in the field logs and are balanced overall, so this is about the stateAlarm/voltage path "
|
||||
"rather than the heat flag.")
|
||||
|
||||
new("PANIC lamp lit: rapid flicker, and engineering + weapon MFD buttons stop responding",
|
||||
"Oracle, 4.11.857: *\"When the panic button lights it flickers very rapidly and the "
|
||||
"engineering and weapon MFDs buttons stop responding. Other MFDs continue to respond to "
|
||||
"button presses (using mouse). I also have mapped controls on my HOTAS for some of those "
|
||||
"MFD buttons and they also stop working when the panic button is lit. Radar, Scoring and "
|
||||
"coolant MFDs keep working.\"*\n\n"
|
||||
"Two symptoms, probably one cause. Arming eject raises the panic-armed mode "
|
||||
"**0x200000**, which drives the physical PANIC lamp (`MakeLinkedLamp`), the eject-mode "
|
||||
"gauge elements, **and keypad routing** -- so eng/weapon buttons going dead while radar/"
|
||||
"scoring/coolant keep working is consistent with the eject mode capturing exactly those "
|
||||
"banks. That part may well be AUTHENTIC (the pod wants your hand on the eject decision, "
|
||||
"not the weapon page).\n\n"
|
||||
"The **rapid flicker is not** authentic: per the decode, the panic lamp is SOLID on arm "
|
||||
"(engine linked-lamp semantic); FLASH is the GaugeAlarm `SetAlertState` overlay, which is "
|
||||
"not authored for panic. A flickering panic lamp suggests the arm state is oscillating -- "
|
||||
"and if it is oscillating, the keypad routing is being torn down and rebuilt repeatedly, "
|
||||
"which would also explain buttons that 'stop responding' rather than cleanly switching.\n\n"
|
||||
"Check `EvaluateEjectPermission` for a condition that chatters at the threshold "
|
||||
"(coolantFrac < 0.05 is a likely candidate while coolant is draining).")
|
||||
|
||||
new("Fade-to-black at mission end is not happening",
|
||||
"Oracle, 4.11.857: *\"Fade to black effect when mission ends is not happening\"*.\n\n"
|
||||
"No log signature to cross-check against -- filing on the report. Worth confirming "
|
||||
"whether this ever worked in a recent build or has been absent for a while.")
|
||||
|
||||
new("Crash at drop end (~23:59 ET, MS-FIREFLY / santo)",
|
||||
"Conn Man / santo, 4.11.857: *\"Did not exit cleanly\"*, and Oracle: *\"Conn Man crashed "
|
||||
"when drop ended ~11:59PM ET\"*.\n\n"
|
||||
"`lastrun_steam.txt` is staged with the field logs. The launch bracketing shows the "
|
||||
"process reaching WinMain normally, so this is a crash at teardown rather than a blocked "
|
||||
"launch.\n\n"
|
||||
"Teardown crashes have bitten this project before (the P5 teardown path). Needs the log "
|
||||
"tail read against the mission-end sequence; the field log for that machine is staged as "
|
||||
"`scratchpad/night14/steam_20260808_b_santo_MSFIREFLY.log`.\n\n"
|
||||
"Also reported for the same player: *\"Conn Man was lagging sometimes during drops and "
|
||||
"that caused the effect of missiles or direct fire exploding/hitting empty space\"* -- "
|
||||
"likely a separate networking symptom, noted here only so the two are not conflated.")
|
||||
|
||||
new("Cannot move after respawn until the throttle is cycled (Abandoned Arena)",
|
||||
"Sauron, 4.11.857: *\"respawned several times in Abandoned Arena and couldn't move until "
|
||||
"throttle down/up or reversing out. Was near map edge and structure.\"*\n\n"
|
||||
"**Two candidate causes, and they must be separated before either is called fixed:**\n\n"
|
||||
"1. **A side effect of the #146 throttle fix in this build.** The virtual lever is now "
|
||||
"released at respawn; a pilot whose PHYSICAL throttle is already forward gets no motion "
|
||||
"until the axis next changes -- i.e. exactly 'couldn't move until throttle down/up'. "
|
||||
"Oracle described the same mechanic positively (*\"throttle is idle and then begins to "
|
||||
"respond to match my throttle position\"*), so the behaviour is real; the question is "
|
||||
"whether it should re-sync immediately instead of on next change.\n"
|
||||
"2. **Geometry.** 'Near map edge and structure' suggests spawning into or against "
|
||||
"collision, which would also read as being stuck.\n\n"
|
||||
"Note this is NOT the same as #137: there the mech is frozen by myomer heat with the "
|
||||
"throttle up, and cycling the throttle does not help. Here cycling the throttle DOES "
|
||||
"free it. The two will look identical to a player, so keep the distinguishing test in "
|
||||
"mind: does throttle-cycling free it (this) or not (#137)?")
|
||||
|
||||
print("created:", made)
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# #137 -- "respawns with the myomers overheated ... initially unable to move
|
||||
# until it cools off" (Oracle, across several builds).
|
||||
#
|
||||
# WHY THE OLD BENCH PROVED NOTHING. heatrespawn.sh sampled AT the reset and
|
||||
# found every heat-bearing subsystem at T == startingTemperature, so I closed
|
||||
# #137 as not-a-bug. The field then measured the freeze at 5 of 61 respawns
|
||||
# (~8%) across THREE machines -- throttle up, speedDemand pinned at 0. A
|
||||
# bench that never reproduces the failure is not evidence the failure does not
|
||||
# exist, which is the mistake that closed the ticket.
|
||||
#
|
||||
# WHAT THE DECOMP SAYS (re-read 2026-08-09):
|
||||
# Mech::Reset @0049fb74 walks the roster from index 2 calling vtable +0x28
|
||||
# (slot 10 = ResetToInitialState) on each subsystem, then @0049f788.
|
||||
# Myomers::RTIS @004b8aa4 -> PoweredSubsystem::RTIS @004b0e6c -> ALWAYS
|
||||
# HeatSink::RTIS @004ad760, which does `param_1[0x45] = param_1[0x4f]`
|
||||
# i.e. currentTemperature(@0x114) = startingTemperature(@0x13C).
|
||||
# The freeze itself is the derating curve @004b8ac0:
|
||||
# temp >= degradation(@0x118) -> falls off; temp >= FAILURE(@0x11C) -> 0.0
|
||||
# and 0.0 reaches the mover as the chain MAX -> speedDemand *= 0.
|
||||
# CRUCIALLY: nothing in that reset chain touches Myomers::speedEffect
|
||||
# (@0x31C). It keeps its pre-death value until the myomers next ticks.
|
||||
#
|
||||
# So three explanations survive, and only data separates them:
|
||||
# (a) RESET DIDN'T TAKE -> post-reset T is high (>= fail)
|
||||
# (b) STALE CACHE -> T is at start but speedEffect is still 0
|
||||
# ("<<<< STALE (cold but zero)" in the receipt)
|
||||
# (c) INSTANT RE-HEAT -> T starts at start and climbs back immediately
|
||||
#
|
||||
# THE MEASUREMENT. Mech::Reset now arms a ~4 s post-reset trace sampled where
|
||||
# the mover's multiplier is actually formed:
|
||||
# [myofreeze] at-reset Myomers T=.. deg=.. fail=.. speedEffect=..
|
||||
# [myofreeze] post-reset Myomers T=.. deg=.. fail=.. speedEffect=..
|
||||
# [myofreeze] post-reset CHAIN MAX=0 <<<< FROZEN
|
||||
#
|
||||
# A drives hard (heat) and self-damages to death repeatedly, so deaths land on
|
||||
# a HOT mech -- the field composition. Long run: at ~8% we need many respawns.
|
||||
# =========================================================================
|
||||
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 mf137_a.log mf137_b.log mf137_relay.log
|
||||
bt_expert_egg MP.EGG MF137.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MF137.EGG
|
||||
|
||||
( export BT_DEATH_LOG=1 BT_MP_LOG=1
|
||||
bt_launch mf137_b.log MF137.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
# A: run hot (autodrive) + fire continuously (weapon heat) + die often.
|
||||
( export BT_AUTODRIVE=0.95 BT_SELF_DAMAGE=7
|
||||
export BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=4
|
||||
export BT_HEAT_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1
|
||||
bt_launch mf137_a.log MF137.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py MF137.EGG 127.0.0.1:1501 127.0.0.1:1601 > mf137_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep 540
|
||||
kill $RELAY 2>/dev/null
|
||||
sleep 3
|
||||
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
|
||||
|
||||
echo "=================== #137 MYOMER FREEZE ==================="
|
||||
echo -n "respawns: "; grep -ac "Mech::Reset" mf137_a.log
|
||||
echo -n "post-reset FROZEN samples: "; grep -ac "FROZEN" mf137_a.log
|
||||
echo -n "STALE (cold but zero) samples: "; grep -ac "STALE (cold but zero)" mf137_a.log
|
||||
echo
|
||||
echo "--- any reset where the myomers came back AT or OVER the failure temp? ---"
|
||||
python - <<'PY'
|
||||
import re, io
|
||||
bad = 0
|
||||
for ln in io.open(r"C:\git\bt411\content\mf137_a.log", encoding="latin-1", errors="replace"):
|
||||
m = re.search(r"\[myofreeze\] (\S+)\s+(\S+)\s+T=([-\d.e+]+) deg=([-\d.e+]+) fail=([-\d.e+]+)\s+speedEffect=([-\d.e+]+)", ln)
|
||||
if not m:
|
||||
continue
|
||||
when, name, t, deg, fail, se = m.group(1), m.group(2), float(m.group(3)), float(m.group(4)), float(m.group(5)), float(m.group(6))
|
||||
if se <= 1e-4:
|
||||
bad += 1
|
||||
if bad <= 12:
|
||||
why = "TEMP >= fail (reset did not take / re-heated)" if t >= fail else "STALE CACHE (temp fine, effect 0)"
|
||||
print(" %-10s %-12s T=%8.1f fail=%8.1f effect=%.4f -> %s" % (when, name, t, fail, se, why))
|
||||
print(" zero-effect samples: %d" % bad)
|
||||
PY
|
||||
echo
|
||||
echo "--- the first frozen episode in context ---"
|
||||
grep -aE "Mech::Reset|myofreeze" mf137_a.log | grep -aB2 -A6 "FROZEN" | head -20
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# #137 -- "respawns with the myomers overheated ... initially unable to move
|
||||
# until it cools off" (Oracle, across several builds).
|
||||
#
|
||||
# WHY THE OLD BENCH PROVED NOTHING. heatrespawn.sh sampled AT the reset and
|
||||
# found every heat-bearing subsystem at T == startingTemperature, so I closed
|
||||
# #137 as not-a-bug. The field then measured the freeze at 5 of 61 respawns
|
||||
# (~8%) across THREE machines -- throttle up, speedDemand pinned at 0. A
|
||||
# bench that never reproduces the failure is not evidence the failure does not
|
||||
# exist, which is the mistake that closed the ticket.
|
||||
#
|
||||
# WHAT THE DECOMP SAYS (re-read 2026-08-09):
|
||||
# Mech::Reset @0049fb74 walks the roster from index 2 calling vtable +0x28
|
||||
# (slot 10 = ResetToInitialState) on each subsystem, then @0049f788.
|
||||
# Myomers::RTIS @004b8aa4 -> PoweredSubsystem::RTIS @004b0e6c -> ALWAYS
|
||||
# HeatSink::RTIS @004ad760, which does `param_1[0x45] = param_1[0x4f]`
|
||||
# i.e. currentTemperature(@0x114) = startingTemperature(@0x13C).
|
||||
# The freeze itself is the derating curve @004b8ac0:
|
||||
# temp >= degradation(@0x118) -> falls off; temp >= FAILURE(@0x11C) -> 0.0
|
||||
# and 0.0 reaches the mover as the chain MAX -> speedDemand *= 0.
|
||||
# CRUCIALLY: nothing in that reset chain touches Myomers::speedEffect
|
||||
# (@0x31C). It keeps its pre-death value until the myomers next ticks.
|
||||
#
|
||||
# So three explanations survive, and only data separates them:
|
||||
# (a) RESET DIDN'T TAKE -> post-reset T is high (>= fail)
|
||||
# (b) STALE CACHE -> T is at start but speedEffect is still 0
|
||||
# ("<<<< STALE (cold but zero)" in the receipt)
|
||||
# (c) INSTANT RE-HEAT -> T starts at start and climbs back immediately
|
||||
#
|
||||
# THE MEASUREMENT. Mech::Reset now arms a ~4 s post-reset trace sampled where
|
||||
# the mover's multiplier is actually formed:
|
||||
# [myofreeze] at-reset Myomers T=.. deg=.. fail=.. speedEffect=..
|
||||
# [myofreeze] post-reset Myomers T=.. deg=.. fail=.. speedEffect=..
|
||||
# [myofreeze] post-reset CHAIN MAX=0 <<<< FROZEN
|
||||
#
|
||||
# A drives hard (heat) and self-damages to death repeatedly, so deaths land on
|
||||
# a HOT mech -- the field composition. Long run: at ~8% we need many respawns.
|
||||
# =========================================================================
|
||||
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 mf137_a.log mf137_b.log mf137_relay.log
|
||||
bt_expert_egg MP.EGG MF137.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MF137.EGG
|
||||
|
||||
( export BT_DEATH_LOG=1 BT_MP_LOG=1
|
||||
bt_launch mf137_b.log MF137.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
# A: run hot (autodrive) + fire continuously (weapon heat) + die often.
|
||||
( export BT_AUTODRIVE=0.5 BT_SELF_DAMAGE=7
|
||||
|
||||
export BT_HEAT_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1
|
||||
bt_launch mf137_a.log MF137.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py MF137.EGG 127.0.0.1:1501 127.0.0.1:1601 > mf137_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep 330
|
||||
kill $RELAY 2>/dev/null
|
||||
sleep 3
|
||||
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
|
||||
|
||||
echo "=================== #137 MYOMER FREEZE ==================="
|
||||
echo -n "respawns: "; grep -ac "Mech::Reset" mf137_a.log
|
||||
echo -n "post-reset FROZEN samples: "; grep -ac "FROZEN" mf137_a.log
|
||||
echo -n "STALE (cold but zero) samples: "; grep -ac "STALE (cold but zero)" mf137_a.log
|
||||
echo
|
||||
echo "--- any reset where the myomers came back AT or OVER the failure temp? ---"
|
||||
python - <<'PY'
|
||||
import re, io
|
||||
bad = 0
|
||||
for ln in io.open(r"C:\git\bt411\content\mf137_a.log", encoding="latin-1", errors="replace"):
|
||||
m = re.search(r"\[myofreeze\] (\S+)\s+(\S+)\s+T=([-\d.e+]+) deg=([-\d.e+]+) fail=([-\d.e+]+)\s+speedEffect=([-\d.e+]+)", ln)
|
||||
if not m:
|
||||
continue
|
||||
when, name, t, deg, fail, se = m.group(1), m.group(2), float(m.group(3)), float(m.group(4)), float(m.group(5)), float(m.group(6))
|
||||
if se <= 1e-4:
|
||||
bad += 1
|
||||
if bad <= 12:
|
||||
why = "TEMP >= fail (reset did not take / re-heated)" if t >= fail else "STALE CACHE (temp fine, effect 0)"
|
||||
print(" %-10s %-12s T=%8.1f fail=%8.1f effect=%.4f -> %s" % (when, name, t, fail, se, why))
|
||||
print(" zero-effect samples: %d" % bad)
|
||||
PY
|
||||
echo
|
||||
echo "--- the first frozen episode in context ---"
|
||||
grep -aE "Mech::Reset|myofreeze" mf137_a.log | grep -aB2 -A6 "FROZEN" | head -20
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# #137 -- "respawns with the myomers overheated ... initially unable to move
|
||||
# until it cools off" (Oracle, across several builds).
|
||||
#
|
||||
# WHY THE OLD BENCH PROVED NOTHING. heatrespawn.sh sampled AT the reset and
|
||||
# found every heat-bearing subsystem at T == startingTemperature, so I closed
|
||||
# #137 as not-a-bug. The field then measured the freeze at 5 of 61 respawns
|
||||
# (~8%) across THREE machines -- throttle up, speedDemand pinned at 0. A
|
||||
# bench that never reproduces the failure is not evidence the failure does not
|
||||
# exist, which is the mistake that closed the ticket.
|
||||
#
|
||||
# WHAT THE DECOMP SAYS (re-read 2026-08-09):
|
||||
# Mech::Reset @0049fb74 walks the roster from index 2 calling vtable +0x28
|
||||
# (slot 10 = ResetToInitialState) on each subsystem, then @0049f788.
|
||||
# Myomers::RTIS @004b8aa4 -> PoweredSubsystem::RTIS @004b0e6c -> ALWAYS
|
||||
# HeatSink::RTIS @004ad760, which does `param_1[0x45] = param_1[0x4f]`
|
||||
# i.e. currentTemperature(@0x114) = startingTemperature(@0x13C).
|
||||
# The freeze itself is the derating curve @004b8ac0:
|
||||
# temp >= degradation(@0x118) -> falls off; temp >= FAILURE(@0x11C) -> 0.0
|
||||
# and 0.0 reaches the mover as the chain MAX -> speedDemand *= 0.
|
||||
# CRUCIALLY: nothing in that reset chain touches Myomers::speedEffect
|
||||
# (@0x31C). It keeps its pre-death value until the myomers next ticks.
|
||||
#
|
||||
# So three explanations survive, and only data separates them:
|
||||
# (a) RESET DIDN'T TAKE -> post-reset T is high (>= fail)
|
||||
# (b) STALE CACHE -> T is at start but speedEffect is still 0
|
||||
# ("<<<< STALE (cold but zero)" in the receipt)
|
||||
# (c) INSTANT RE-HEAT -> T starts at start and climbs back immediately
|
||||
#
|
||||
# THE MEASUREMENT. Mech::Reset now arms a ~4 s post-reset trace sampled where
|
||||
# the mover's multiplier is actually formed:
|
||||
# [myofreeze] at-reset Myomers T=.. deg=.. fail=.. speedEffect=..
|
||||
# [myofreeze] post-reset Myomers T=.. deg=.. fail=.. speedEffect=..
|
||||
# [myofreeze] post-reset CHAIN MAX=0 <<<< FROZEN
|
||||
#
|
||||
# A drives hard (heat) and self-damages to death repeatedly, so deaths land on
|
||||
# a HOT mech -- the field composition. Long run: at ~8% we need many respawns.
|
||||
# =========================================================================
|
||||
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 mf137_a.log mf137_b.log mf137_relay.log
|
||||
bt_expert_egg MP.EGG MF137.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MF137.EGG
|
||||
|
||||
( export BT_DEATH_LOG=1 BT_MP_LOG=1
|
||||
bt_launch mf137_b.log MF137.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
# A: run hot (autodrive) + fire continuously (weapon heat) + die often.
|
||||
( export BT_AUTODRIVE=0.95 BT_SELF_DAMAGE=7
|
||||
export BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=4
|
||||
export BT_HEAT_LOG=1 BT_MYO_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1
|
||||
bt_launch mf137_a.log MF137.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py MF137.EGG 127.0.0.1:1501 127.0.0.1:1601 > mf137_relay.log 2>&1 &
|
||||
RELAY=$!
|
||||
sleep 540
|
||||
kill $RELAY 2>/dev/null
|
||||
sleep 3
|
||||
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
|
||||
|
||||
echo "=================== #137 MYOMER FREEZE ==================="
|
||||
echo -n "respawns: "; grep -ac "Mech::Reset" mf137_a.log
|
||||
echo -n "post-reset FROZEN samples: "; grep -ac "FROZEN" mf137_a.log
|
||||
echo -n "STALE (cold but zero) samples: "; grep -ac "STALE (cold but zero)" mf137_a.log
|
||||
echo
|
||||
echo "--- any reset where the myomers came back AT or OVER the failure temp? ---"
|
||||
python - <<'PY'
|
||||
import re, io
|
||||
bad = 0
|
||||
for ln in io.open(r"C:\git\bt411\content\mf137_a.log", encoding="latin-1", errors="replace"):
|
||||
m = re.search(r"\[myofreeze\] (\S+)\s+(\S+)\s+T=([-\d.e+]+) deg=([-\d.e+]+) fail=([-\d.e+]+)\s+speedEffect=([-\d.e+]+)", ln)
|
||||
if not m:
|
||||
continue
|
||||
when, name, t, deg, fail, se = m.group(1), m.group(2), float(m.group(3)), float(m.group(4)), float(m.group(5)), float(m.group(6))
|
||||
if se <= 1e-4:
|
||||
bad += 1
|
||||
if bad <= 12:
|
||||
why = "TEMP >= fail (reset did not take / re-heated)" if t >= fail else "STALE CACHE (temp fine, effect 0)"
|
||||
print(" %-10s %-12s T=%8.1f fail=%8.1f effect=%.4f -> %s" % (when, name, t, fail, se, why))
|
||||
print(" zero-effect samples: %d" % bad)
|
||||
PY
|
||||
echo
|
||||
echo "--- the first frozen episode in context ---"
|
||||
grep -aE "Mech::Reset|myofreeze" mf137_a.log | grep -aB2 -A6 "FROZEN" | head -20
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# =========================================================================
|
||||
# #149 -- 817 -> 857 stall regression (Oracle: 97 blocks >50ms, all in the
|
||||
# mission-load window, maxDraw up to 518ms; zero such blocks on 817, same
|
||||
# machine). My filed suspect was the #141 segment-cache sweep -- but that is
|
||||
# a HYPOTHESIS, and #137 just taught us what plausible-but-unmeasured
|
||||
# theories are worth. So: measure first.
|
||||
#
|
||||
# INSTRUMENTATION (this build):
|
||||
# [segperf] calls=<GetSegmentToWorld entries> dirty=<mark-every-segment
|
||||
# invalidation passes> ms=<time inside the accessor> -- per rstat window,
|
||||
# BT_PERF_LOG-gated. The DIRTY count is the tell: if the beam path
|
||||
# multiplies invalidations, dirty >> mechs-per-frame and ms tracks maxDraw.
|
||||
#
|
||||
# A/B: identical 2-node runs, beams firing continuously.
|
||||
# run A: default (swept behaviour: beam muzzle via GetSegmentToWorld)
|
||||
# run B: BT_BEAM_SEGFRESH=0 (pre-sweep plain compose at the BEAM site only)
|
||||
# If A's stalls/dirty collapse in B, the beam-site sweep is the regression.
|
||||
# If A == B and both are clean, the suspect is EXONERATED and the real 857
|
||||
# load-stall cause is elsewhere (next: diff the load path).
|
||||
# =========================================================================
|
||||
set -x
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
|
||||
run_one () { # $1 = tag, $2 = extra env value for BT_BEAM_SEGFRESH
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
rm -f sp_${1}_a.log sp_${1}_b.log sp_${1}_relay.log
|
||||
bt_expert_egg MP.EGG SP.EGG
|
||||
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" SP.EGG
|
||||
( export BT_GOTO=enemy BT_GOTO_STOP=120 BT_AUTOFIRE=1 BT_AF_PERIOD=3
|
||||
export BT_MP_LOG=1
|
||||
bt_launch sp_${1}_b.log SP.EGG 0x0C -net 1601 )
|
||||
sleep 2
|
||||
( export BT_GOTO=enemy BT_GOTO_STOP=120 BT_AUTOFIRE=1 BT_AF_PERIOD=3
|
||||
export BT_PERF_LOG=1 BT_MP_LOG=1
|
||||
export BT_BEAM_SEGFRESH=${2}
|
||||
bt_launch sp_${1}_a.log SP.EGG 0x03 -net 1501 )
|
||||
sleep 5
|
||||
python ../tools/btconsole.py SP.EGG 127.0.0.1:1501 127.0.0.1:1601 > sp_${1}_relay.log 2>&1 &
|
||||
local 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
|
||||
}
|
||||
|
||||
run_one fresh 1
|
||||
run_one legacy 0
|
||||
|
||||
echo "=================== #149 SEGPERF A/B ==================="
|
||||
for t in fresh legacy; do
|
||||
echo "--- $t ---"
|
||||
python - <<PY
|
||||
import re, io
|
||||
v=[]; seg=[]
|
||||
for ln in io.open(r"C:\git\bt411\content\sp_${t}_a.log", encoding="latin-1", errors="replace"):
|
||||
m=re.search(r"\[rstat\] frames=\d+ avg=([\d.]+)ms maxDraw=([\d.]+)", ln)
|
||||
if m: v.append((float(m.group(1)), float(m.group(2))))
|
||||
m=re.search(r"\[segperf\] calls=(\d+) dirty=(\d+) ms=([\d.]+)", ln)
|
||||
if m: seg.append((int(m.group(1)), int(m.group(2)), float(m.group(3))))
|
||||
if v:
|
||||
import statistics
|
||||
avgs=[a for a,_ in v]; draws=[d for _,d in v]
|
||||
stall=sum(1 for a,_ in v if a>50)
|
||||
print(" rstat blocks=%d avg-med=%.2fms maxDraw-med=%.2f maxDraw-worst=%.1f blocks>50ms=%d"
|
||||
% (len(v), statistics.median(avgs), statistics.median(draws), max(draws), stall))
|
||||
if seg:
|
||||
c=[x[0] for x in seg]; d=[x[1] for x in seg]; ms=[x[2] for x in seg]
|
||||
print(" segperf windows=%d calls-med=%d dirty-med=%d ms-med=%.2f ms-worst=%.2f"
|
||||
% (len(seg), sorted(c)[len(c)//2], sorted(d)[len(d)//2], sorted(ms)[len(ms)//2], max(ms)))
|
||||
PY
|
||||
done
|
||||
@@ -0,0 +1,58 @@
|
||||
"""#137: post the clean mechanism write-up (the inline attempt was shell-mangled)."""
|
||||
import sys
|
||||
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
|
||||
import gitea
|
||||
|
||||
BODY = """*(Reposting -- the previous comment was mangled in transit. This is the readable version.)*
|
||||
|
||||
**MECHANISM SOLVED -- and it is not the respawn.** Controlled A/B on one bench, only the load differs:
|
||||
|
||||
| load | respawns | post-reset runaway |
|
||||
|---|---|---|
|
||||
| 0.95 throttle + continuous missile fire | 4 | **17 frozen samples, T climbs 77 -> ~11,600** |
|
||||
| 0.50 throttle, no weapons | 2 | **0** |
|
||||
|
||||
Measured, not argued:
|
||||
|
||||
* **The reset works.** `T == startingTemperature` (77) at every single reset, in both runs.
|
||||
`HeatSink::RTIS @004ad760` does `param_1[0x45] = param_1[0x4f]`, and our port matches --
|
||||
additionally resetting `heatEnergy`, which it must, since the sim derives
|
||||
`currentTemperature = heatEnergy / thermalMass`.
|
||||
* **The stale cache is real but harmless.** Nothing in the reset chain writes
|
||||
`Myomers::speedEffect` (@0x31C) -- `Myomers::RTIS @004b8aa4` only chains to the
|
||||
PoweredSubsystem one -- so it survives the reset reading 0. It self-heals on the very next
|
||||
tick (`T=77.13` -> `speedEffect=1`). Not the bug.
|
||||
* **With ordinary load the myomers never approach `failT=2000` after a respawn.**
|
||||
|
||||
## What players are actually experiencing
|
||||
|
||||
Run hard -> myomers pass `failT=2000` -> the derating curve `@004b8ac0` returns 0.0 -> chain
|
||||
MAX 0 -> `speedDemand *= 0` -> bogged down -> die, often *because* bogged down. The respawn
|
||||
correctly resets to 77. Resume high throttle and firing and it climbs back over the cliff within
|
||||
seconds -- which reads as "respawned with the heat bar maxed".
|
||||
|
||||
That also explains the intermittency (5 of 61 field respawns, ~8%): it tracks how hard you were
|
||||
driving into and out of the respawn, not the respawn itself.
|
||||
|
||||
Oracle's "loop 5 and generator D heating up as all the excess heat goes into the loop" is
|
||||
confirmed literally -- the myomers link to **Condenser5** (`mass=250000 k=190000`).
|
||||
|
||||
## The remaining defect: the climb RATE
|
||||
|
||||
Overshooting roughly 6x past a cliff the design treats as coolant-managed (`degradeT=1000`
|
||||
governor onset, `failT=2000`) is not a lever a player can work with. That is the real ticket now.
|
||||
|
||||
Suspect under review, **not yet proven**: the kinetic term. The binary `@004b8d18` applies **no**
|
||||
`time_slice` to it (`fVar5 * fVar1`), while the climb and accel terms both carry `param_2` -- i.e.
|
||||
it is a per-frame energy add at the pod's fixed ~28 Hz. Our port rate-normalises it
|
||||
(`work * (time_slice * 28)`), which agrees per-second at any frame rate, so this is not yet a
|
||||
demonstrated discrepancy. Next step is a term-by-term dump (`BT_MYO_LOG`) against the authored
|
||||
tuning: VelocityEfficiency 0.995, AccelerationEfficiency 0.8, thermalMass 2.5e5.
|
||||
|
||||
**Retitling suggestion:** this should stop being "respawn comes back hot" and become
|
||||
"myomer heat rate under sustained load overshoots the failure cliff"."""
|
||||
|
||||
gitea.comment(137, BODY)
|
||||
gitea.call("/issues/137", method="PATCH", payload={
|
||||
"title": "Myomer heat rate under sustained load overshoots the failure cliff (was: respawn came back with MYOMERS heat MAXED)"})
|
||||
print("posted + retitled #137")
|
||||
@@ -0,0 +1,58 @@
|
||||
# Apply the ALPHA-MR pod kit to a BT411 install's content directory.
|
||||
#
|
||||
# WHY THIS EXISTS: the frozen rig config lives in content\environ.ini and
|
||||
# glass_layout.cfg -- both INSIDE the versioned install folder, and neither is
|
||||
# shipped in the zip (environ.ini is generated on first run). So extracting a
|
||||
# new build gives you a cab that comes up wrong with no error anywhere. The
|
||||
# MASTERS live beside this script at a stable path; this pushes them into
|
||||
# whichever install you point it at. Idempotent -- safe to re-run.
|
||||
param([Parameter(Mandatory=$true)][string]$Content)
|
||||
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$prof = Join-Path $here 'podprofile.ini'
|
||||
$lay = Join-Path $here 'glass_layout.cfg'
|
||||
$ini = Join-Path $Content 'environ.ini'
|
||||
|
||||
if (-not (Test-Path $Content)) { Write-Output "NO SUCH CONTENT DIR: $Content"; exit 1 }
|
||||
if (-not (Test-Path $prof)) { Write-Output "MISSING MASTER: $prof"; exit 1 }
|
||||
|
||||
# --- the env block: replace anything between the markers, keep the rest ------
|
||||
$block = Get-Content $prof
|
||||
$keep = @()
|
||||
if (Test-Path $ini) {
|
||||
$inBlock = $false
|
||||
foreach ($line in (Get-Content $ini)) {
|
||||
if ($line -match '^# ==== BT411 POD PROFILE') { $inBlock = $true; continue }
|
||||
if ($line -match '^# ==== END BT411 POD PROFILE') { $inBlock = $false; continue }
|
||||
if (-not $inBlock) { $keep += $line }
|
||||
}
|
||||
# ⚠ `-gt 1`, NOT `-gt 0` (fixed 2026-08-08). With `-gt 0`, once $keep trims
|
||||
# down to a SINGLE blank line, $keep.Count-2 is -1 and PowerShell's
|
||||
# $keep[0..-1] returns TWO elements (index 0 and index -1 = the last) instead
|
||||
# of shrinking -- so the array grows and this loops FOREVER, 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 config forward from the previous install. Symptom on the cab: a
|
||||
# blank cmd console, BT411Run stuck "Running", no btl4.exe and no podrun.log,
|
||||
# and each hung run holds environ.ini so every later attempt blocks too.
|
||||
while ($keep.Count -gt 1 -and $keep[-1].Trim() -eq '') { $keep = $keep[0..($keep.Count-2)] }
|
||||
}
|
||||
($keep + @('') + $block) | Set-Content $ini -Encoding ascii
|
||||
|
||||
# --- the layout: the master always wins -------------------------------------
|
||||
# Re-tuning the cab means editing the MASTER beside this script, not the copy
|
||||
# in the install -- a BT_GLASS_LAYOUT=save drag inside an install is overwritten
|
||||
# on the next apply, on purpose. One place to look when a panel moves.
|
||||
if (Test-Path $lay) { Copy-Item $lay (Join-Path $Content 'glass_layout.cfg') -Force }
|
||||
|
||||
# --- the pod test mission ----------------------------------------------------
|
||||
# PODTEST.EGG is NOT in the repo -- it was made on the cart, so a fresh extract
|
||||
# has no mission for runpod.bat to launch (found the first time a new build was
|
||||
# dropped: the launcher ran and nothing came up). Carry it in the kit.
|
||||
$egg = Join-Path $here 'PODTEST.EGG'
|
||||
if (Test-Path $egg) { Copy-Item $egg (Join-Path $Content 'PODTEST.EGG') -Force }
|
||||
|
||||
Write-Output "pod kit applied to $Content"
|
||||
Get-Content $ini | Select-String '^(BT_|L4)' | ForEach-Object { " " + $_.Line }
|
||||
if (Test-Path $lay) { Get-Content (Join-Path $Content 'glass_layout.cfg') | Select-String '^[A-Z]' | ForEach-Object { " " + $_.Line } }
|
||||
|
||||
@@ -82,6 +82,94 @@ try {
|
||||
} catch { W " (EDID query failed -- normal over some remote sessions: $_)" }
|
||||
W ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BOOT-STABLE panel identity. Windows renumbers \\.\DISPLAYn and reorders the
|
||||
# enumeration when a panel is power-cycled or re-cabled, so binding the pod by
|
||||
# index or device name is fragile (Nick, 2026-08-07: "the order changed ... even
|
||||
# if the visual desktop tool looks the same"). Bind by the panel's own EDID
|
||||
# identity instead: glass_layout.cfg accepts `monitor:id:<fragment>`.
|
||||
#
|
||||
# This deliberately calls the SAME Win32 API the game does (EnumDisplayDevices
|
||||
# on the display's MONITOR child) rather than the WmiMonitorID above -- if the
|
||||
# probe and the engine read different sources, the fragments printed here might
|
||||
# not be what the engine actually matches against.
|
||||
# ---------------------------------------------------------------------------
|
||||
W "---- BOOT-STABLE panel identity (paste these into glass_layout.cfg) ----"
|
||||
try {
|
||||
if (-not ("BTDisp" -as [type])) {
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public class BTDisp {
|
||||
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Ansi)]
|
||||
public struct DISPLAY_DEVICE {
|
||||
public int cb;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst=32)] public string DeviceName;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceString;
|
||||
public int StateFlags;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceID;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceKey;
|
||||
}
|
||||
[DllImport("user32.dll", CharSet=CharSet.Ansi)]
|
||||
public static extern bool EnumDisplayDevicesA(string dev, uint num, ref DISPLAY_DEVICE dd, uint flags);
|
||||
public static string MonitorId(string display) {
|
||||
DISPLAY_DEVICE dd = new DISPLAY_DEVICE();
|
||||
dd.cb = Marshal.SizeOf(typeof(DISPLAY_DEVICE));
|
||||
// 0x1 = EDD_GET_DEVICE_INTERFACE_NAME (richer path, includes connector UID)
|
||||
if (!EnumDisplayDevicesA(display, 0, ref dd, 0x1)) {
|
||||
dd = new DISPLAY_DEVICE();
|
||||
dd.cb = Marshal.SizeOf(typeof(DISPLAY_DEVICE));
|
||||
if (!EnumDisplayDevicesA(display, 0, ref dd, 0)) return "";
|
||||
}
|
||||
return dd.DeviceID;
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
$codes = @{}
|
||||
$rows = @()
|
||||
foreach ($s in [System.Windows.Forms.Screen]::AllScreens) {
|
||||
$sid = [BTDisp]::MonitorId($s.DeviceName)
|
||||
# EDID PnP code = 3 letters + 4 hex digits (AUO10ED, DEL4231)
|
||||
$code = ""
|
||||
if ($sid -match '[\\#\?]([A-Za-z]{3}[0-9A-Fa-f]{4})[\\#\?]') { $code = $Matches[1] }
|
||||
elseif ($sid -match '([A-Za-z]{3}[0-9A-Fa-f]{4})') { $code = $Matches[1] }
|
||||
$rows += [pscustomobject]@{ Dev=$s.DeviceName; Prim=$s.Primary; Sid=$sid; Code=$code
|
||||
X=$s.Bounds.X; Y=$s.Bounds.Y; W=$s.Bounds.Width; H=$s.Bounds.Height }
|
||||
if ($code -ne "") { $codes[$code] = 1 + $(if ($codes.ContainsKey($code)) { $codes[$code] } else { 0 }) }
|
||||
}
|
||||
foreach ($r in $rows) {
|
||||
W (" {0}{1} {2},{3} {4}x{5}" -f $r.Dev, $(if ($r.Prim) { " *PRIMARY*" } else { "" }), $r.X, $r.Y, $r.W, $r.H)
|
||||
W (" stable-id : {0}" -f $(if ($r.Sid) { $r.Sid } else { "(unavailable)" }))
|
||||
if ($r.Code -ne "" -and $codes[$r.Code] -gt 1) {
|
||||
# Same model on more than one output: the EDID code alone is ambiguous.
|
||||
# The connector UID in the tail is what separates them.
|
||||
$uid = ""
|
||||
if ($r.Sid -match '(UID[0-9]+)') { $uid = $Matches[1] }
|
||||
if ($uid -ne "") {
|
||||
W (" cfg form : monitor:id:{0} <-- code '{1}' is on {2} panels, so use the UID" -f $uid, $r.Code, $codes[$r.Code])
|
||||
} else {
|
||||
W (" cfg form : (AMBIGUOUS -- '{0}' appears on {1} panels and no UID found;" -f $r.Code, $codes[$r.Code])
|
||||
W " use a longer unique substring of stable-id above)"
|
||||
}
|
||||
} elseif ($r.Code -ne "") {
|
||||
W (" cfg form : monitor:id:{0}" -f $r.Code)
|
||||
} else {
|
||||
W " cfg form : (no EDID code parsed -- use a substring of stable-id)"
|
||||
}
|
||||
}
|
||||
$dupes = ($codes.GetEnumerator() | Where-Object { $_.Value -gt 1 } | Measure-Object).Count
|
||||
if ($dupes -gt 0) {
|
||||
W ""
|
||||
W (" NOTE: {0} EDID code(s) appear on more than one panel (identical models)." -f $dupes)
|
||||
W " Those lines use the connector UID instead, which is per-output."
|
||||
}
|
||||
W ""
|
||||
W " Run this again AFTER a reboot or a panel power-cycle: the device names"
|
||||
W " and ordering above may move, but stable-id / cfg form must NOT."
|
||||
} catch { W " (stable-identity probe failed: $_)" }
|
||||
W ""
|
||||
|
||||
W "---- SERIAL PORTS (the RIO board lives on one of these) ----"
|
||||
try {
|
||||
$sp = Get-CimInstance Win32_SerialPort
|
||||
|
||||
Reference in New Issue
Block a user