Compare commits
109
Commits
+12
@@ -49,3 +49,15 @@ operator_secret.txt
|
||||
scratchpad/night*/*.log
|
||||
scratchpad/night*/*.rar
|
||||
scratchpad/night*/*.zip
|
||||
# tester-authored notes / lastrun dumps carry player handles + machine names
|
||||
# (deliberate exceptions: git add -f)
|
||||
scratchpad/night*/*notes*.txt
|
||||
scratchpad/night*/lastrun*.txt
|
||||
|
||||
# Bench artifacts in content/ (screenshots, session logs, generated eggs, deployed exe)
|
||||
# -- receipts stay on disk, out of `git status`. A new AUTHORED egg: git add -f.
|
||||
content/*.png
|
||||
content/*.log
|
||||
content/matchlog_*.txt
|
||||
content/btl4.exe
|
||||
content/*.EGG
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
`BTL4OPT.EXE` binary on top of the working WinTesla engine.
|
||||
**Repo of record:** the top-level `CMakeLists.txt` + `README.md` build `btl4.exe`. Layout:
|
||||
`engine/ game/ content/ docs/ reference/ tools/ context/`.
|
||||
**Current front:** `btl4.exe` runs a full single-player loop; the gauge system is complete; the
|
||||
active work is reconstructing each subsystem's authentic behavior from the binary. Details +
|
||||
what's-next: `context/project-overview.md`, `context/open-questions.md`, recent git log.
|
||||
**Current front (2026-08-06):** core gameplay reconstruction is COMPLETE — SP+MP loop, combat/
|
||||
damage/scoring, locomotion (gait/gimp/CROUCH), night kit (searchlight), death/respawn, replication
|
||||
all authentic + benched (builds 4.11.774→801). The decomp export was REBUILT 2026-08-06 (#60
|
||||
closed: coverage 93.5%, dark code 41 KB — cite `@ADDR`, NOT `part_0NN.c:LINE`; the old export is
|
||||
archived). Active work: the POLISH list. Details + what's-next: `context/project-overview.md`,
|
||||
`context/open-questions.md`, recent git log.
|
||||
|
||||
---
|
||||
|
||||
@@ -59,6 +62,7 @@ precise than anything you can infer.
|
||||
|---|---|
|
||||
| What the project is, engine, platform, goal | `context/project-overview.md` |
|
||||
| Missing BT source, decompilation strategy | `context/source-completeness.md` |
|
||||
| **Export DARK REGIONS / "is X in the decomp?"** — the gap census | `reference/decomp/GAP_CENSUS.md` (tools: `tools/gapcensus.py`, `tools/ghidra_reexport.sh`, `tools/gapdiff.py`; log: `phases/phase-04-gap-census.md`) |
|
||||
| The 4.10 literal-source manifest (per-TU function/size/address map) | `reference/BT410_SOURCE_MANIFEST.md` (tool: `tools/manifest410.py`; log: `phases/phase-03-bt410-source-manifest.md`) |
|
||||
| The WinTesla Windows port (renderer/audio/HAL) | `context/wintesla-port.md` |
|
||||
| Build / run / debug / repo layout / env gates | `context/build-and-run.md` + `context/decomp-reference.md` §6 |
|
||||
@@ -150,7 +154,9 @@ bt411/
|
||||
│ └── <topic>.md # one concept each
|
||||
├── reference/
|
||||
│ ├── glossary.yaml # terms/acronyms
|
||||
│ ├── decomp/ # raw Ghidra pseudocode (the source-of-truth)
|
||||
│ ├── decomp/ # raw Ghidra pseudocode (the source-of-truth; REBUILT 2026-08-06,
|
||||
│ │ # 93.5% coverage. archive_2025export/ = the old export, kept
|
||||
│ │ # ONLY so old `part_0NN.c:LINE` citations resolve -- cite @ADDR)
|
||||
│ └── ghidra_scripts/ # the exporters
|
||||
├── docs/ # PROGRESS_LOG.md (the full old CLAUDE.md) + detailed running ledgers
|
||||
├── phases/ # restructuring / investigation logs
|
||||
|
||||
@@ -164,3 +164,31 @@ pad X action LookBehind
|
||||
pad Start action ModeCycle
|
||||
pad RightThumb action DisplayCycle
|
||||
pad Back action Valve
|
||||
|
||||
# COOLANT FLUSH -- HELD key dumps reservoir coolant into the loops
|
||||
# (Reservoir InjectCoolant, msg 4). The tank drains while held; with the
|
||||
# tank empty the loops burn coolant with no refill -- run hot long enough
|
||||
# and the bank drops below 5%, which ARMS the eject (Panic lights).
|
||||
key G action Flush
|
||||
|
||||
# PANIC / EJECT -- the punch-out (Mech msg 0x19). Only fires on a CRIPPLED
|
||||
# mech (no generators, coolant leaked below 5%, or leg-gimped novice) -- a
|
||||
# healthy mech refuses the button, so a stray press is a no-op.
|
||||
key Back action Eject
|
||||
pad LeftThumb action Eject
|
||||
|
||||
# PILOT KEYPAD (the pod's MFD program keypad, KeyboardPilot unit). While
|
||||
# PANIC mode is armed, ANY pilot-keypad key fires the eject (the binary's
|
||||
# keyboardGroup[KeyboardPilot].Add(0x200000, mech, 0x19) -- its ONLY armed
|
||||
# eject binding); un-armed, the keys feed program entry. Desktop numpad =
|
||||
# that keypad.
|
||||
key NumPad0 keypad pilot 0
|
||||
key NumPad1 keypad pilot 1
|
||||
key NumPad2 keypad pilot 2
|
||||
key NumPad3 keypad pilot 3
|
||||
key NumPad4 keypad pilot 4
|
||||
key NumPad5 keypad pilot 5
|
||||
key NumPad6 keypad pilot 6
|
||||
key NumPad7 keypad pilot 7
|
||||
key NumPad8 keypad pilot 8
|
||||
key NumPad9 keypad pilot 9
|
||||
|
||||
@@ -182,6 +182,18 @@ default (`-DBT_STEAM=ON` is the documented dev-checkout state). `BT_EXPIRE=ON` (
|
||||
14-day tester window; an expire-OFF zip is renamed `-noexpire` and warns. Verify by extracting the
|
||||
zip somewhere clean and booting it with no repo present — that is what catches a missing runtime DLL.
|
||||
|
||||
## Build ritual — the stale-link flake (bit 4+ times; MANDATORY for bench work)
|
||||
MSBuild often does NOT relink `btl4.exe` when only static-lib members changed — the bench then
|
||||
runs a STALE exe and the session burns hours on phantom results. Ritual for every code-change build:
|
||||
1. `rm -f build/Release/btl4.exe` FIRST (force the relink);
|
||||
2. build; a REAL relink prints the 20 known-benign `CreateStreamedSubsystem` LNK2019s (`/FORCE`
|
||||
baseline — see the top of this file); an up-to-date run prints none;
|
||||
3. STRING-VERIFY before benching: `python -c "d=open(r'build/Release/btl4.exe','rb').read();
|
||||
print(b'<A-NEW-LOG-MARKER>' in d)"` — pick a string your change added (NB: release strings
|
||||
carry LOG TEXT, not function names — check the .map for symbols);
|
||||
4. use ABSOLUTE paths in every bench command — `cd` leaks between compound commands (the cwd
|
||||
trap) and has broken builds/appends/copies repeatedly.
|
||||
|
||||
## Key Relationships
|
||||
- Base: [[wintesla-port]] (the engine build recipe).
|
||||
- Verify loop: [[reconstruction-method]]; env gates: [[decomp-reference]] §6.
|
||||
|
||||
+42
-2
@@ -17,8 +17,21 @@ Verified in-game on all 8 mechs (Thor vs gameplay footage). [T2]
|
||||
|
||||
- One per mech (12: AVX BLX FIX JAX LOX MAX OWX RAX SNX STX THX VUX; model→skeleton table in
|
||||
BTL4.RES @~3232850: `madcat=mad`, `blkhawk=blh`, … — the INSIDE skeleton is the X-variant, so
|
||||
Madcat = `MAX_COP`). It is the torso segment's SkeletonType_A mesh; the inside view loads
|
||||
EXACTLY ONE segment mesh (the `_cop`) — 25 others hidden. [T2]
|
||||
Madcat = `MAX_COP`). It is the torso segment's SkeletonType_A mesh. [T2]
|
||||
- **The inside view is a PER-MECH AUTHORED SET, not the cop alone** (#91 decode, 2026-08-04;
|
||||
the earlier "EXACTLY ONE segment mesh" claim was madcat-derived and over-generalized —
|
||||
corrected [T1, decomp-grounded: the binary's renderable build `FUN_004cef28`
|
||||
(part_014.c:5077) sets skeleton type 4 (=A) for the inside build and its per-segment loop
|
||||
calls `FUN_00424084(segment, type)` (:5570) for EVERY segment — NULL name = no mesh, no
|
||||
torso-only filter, no cop special-case (only `siteeyepoint` diverts to the eye ctor
|
||||
:5560-64); engine side: no type fallback in `EntitySegment::GetVideoObjectName`,
|
||||
SEGMENT.cpp:97 — so the binary drew every authored type-A mesh, own-body pieces included;
|
||||
T2: live `[view]` roster logs]). Authored type-A rosters: **madcat / vulture / bhk1 = cop only; sunder /
|
||||
loki / avatar = cop + `<pfx>_tor` torso piece; thor = cop + `thx_tor` + `thx_msl` (the
|
||||
shoulder LRM pod!); owens = cop + BOTH complete legs (`owx_{l,r}{ule,dle,bto,fto}`) +
|
||||
`own_tshd`** — the Owens pilot saw their own legs walking; the Thor pilot saw their
|
||||
missile pod edge bob at the lower frame. `BT_HIDE_INSIDE_SEG=<substr>` hides a non-cop
|
||||
type-A mesh by name (diag, btl4vid.cpp ApplyViewSkeleton).
|
||||
- Material: `<pfx>skin:blakskn_dz_{u,r,l}torso_mtl` — DIFFUSE/AMBIENT (0,0,0) + RAMP_REF
|
||||
`softer`, **NO texture** (BLXSKIN.BMF is 238 bytes total; byte-verified). The 56 nonzero-UV
|
||||
verts in BLX_COP sample nothing (planar-projection authoring leftover). [T1]
|
||||
@@ -230,6 +243,33 @@ the shell green), `BT_COP_DUMP` (per-batch punch/bbox), `BT_EYE_FWD=<f>` (eye po
|
||||
- Feeds: [[gauges-hud]] (the HUD overlays this view) · [[subsystems]] (gyro, task #56)
|
||||
- Gotchas: [[reconstruction-gotchas]] §14 (LookAt axis guess), §15 (per-patch edge counting)
|
||||
|
||||
## The #91 "black rectangle" (thor) — own-body inside meshes + the blakskn rule [T2]
|
||||
|
||||
Three testers (Oracle + RajelAran night 7; Ronin 2026-08-03: "moves diagonally 10→4 with the
|
||||
footsteps, at rest sits ON the lower frame bar, right side, half-in") reported a black
|
||||
rectangle in the Thor cockpit. Decoded 2026-08-04:
|
||||
- The rectangle is the **thor's own `thx_msl` missile-pod mount plate** — authored, authentic
|
||||
own-body geometry (see the roster above; "Summoner" = the `thr1` FE label, same THX canopy,
|
||||
so the cross-reference isolated nothing). It rides the walk-animated shoulder while the eye
|
||||
rides the gyro springs → the diagonal footstep-rhythm sway. Pixel-tracked across walk
|
||||
captures: ~29px wide, x/y oscillating together along the 10↔4 axis, bottom center-right,
|
||||
alternating with the gait [T2, scratchpad/night11/thorrect.sh].
|
||||
- **What WAS wrong:** the pilot-facing surfaces of these own-body meshes are authored with the
|
||||
same `<pfx>skin:blakskn_dz_*` interior-structure material as the canopy frame (texture-less,
|
||||
black diffuse, `softer` ramp lo 0.25 → hi 0.99), but the unlit frame-constant treatment was
|
||||
keyed on the `_cop` FILENAME — so the identical material rendered (0.13,0.12,0.15) on the
|
||||
canopy and PURE (0,0,0) on the pod/legs (`[matlog]`-verified: owx_cop blakskn vcol=FF211F26
|
||||
vs owx_lule blakskn vcol=FF000000). Fixed in bgfload.cpp: the constant now keys on
|
||||
`meshIsCop || material contains "skin:blakskn_dz_"`. `mechfx:blakskn_mtl` (tshd shadow
|
||||
quads) deliberately does NOT match. Verified: zero pure-black pixels in the lower view band
|
||||
post-fix, tab renders in the frame tone and blends with the bar at rest; owens legs read as
|
||||
coherent dark structure; canopy/terrain un-regressed. The textured pod/leg batches
|
||||
(THX/GEN/OWEN.BSL pages — bright, mean texel 0.55-0.74) face outboard and were never the
|
||||
visible surfaces.
|
||||
- Diags from the dig: `BT_MAT_LOG=<mesh-stem substr>` (bgfload per-batch material routing:
|
||||
resolved colour/texture/ramp/vcol), `BT_HIDE_INSIDE_SEG=<substr>`, `[view]` per-segment
|
||||
inside-roster log. The `softer` ramp resolves lo=(0.25,0.25,0.25) hi=(0.99,0.99,0.99) [T1].
|
||||
|
||||
## Torso-elevation aim -> EYE PITCH (fixed 2026-07-19) [T2, pixel-calibrated]
|
||||
User+tester report "pitch does not work": the Torso sim integrated R/F (stick-Y)
|
||||
into `currentElevation` (authored limits/rates) but NOTHING consumed it — the
|
||||
|
||||
+201
-9
@@ -102,6 +102,50 @@ the DCS = the struck SEGMENT → its `dzone` (the SKL segment→zone map, `GetSe
|
||||
`targetDamageZone`. The cylinder lottery (STEP 6) was only ever the UNAIMED path. The port's
|
||||
whole-mech box pick + "STEP-6 zone under the boresight" funnels AIMED fire through the unaimed
|
||||
lottery — which is exactly the night-6 report (#73): aim at the arm, get the spray.
|
||||
**COMPLETED 2026-08-03 (#124, the zone-walk matrix): the pick is TRIANGLE-ACCURATE** [T2 matrix].
|
||||
The sphere approximation measurably mis-picked (aim dead-on dtorso -> picked rgun/ruleg) —
|
||||
`MechSegmentPick` runs a sphere PRE-FILTER then Moller-Trumbore nearest-hit over the threaded
|
||||
segments' posed meshes (CPU triangle cache from each object's own BGF buffers, `BTGetPickMesh`).
|
||||
**⚠ CORRECTED 2026-08-04 (field pushback from era players — they were RIGHT): the 08-03 zone rule
|
||||
("the struck segment's SKL dzone, ALWAYS" -> every aimed torso hit = CENTER TORSO) was WRONG.**
|
||||
The authentic rule: **the pick's zone is the struck PATCH's authored `dz_*` tag** — the art
|
||||
zone-tags the hull PER PANEL (`MAD_TOR.BGF` carries `dz_utorso`×36 / `dz_ltorso`×18 /
|
||||
`dz_rtorso`×18 / `dz_dtorso`×16 + all four rear panels + `dz_searchlight` across its patches
|
||||
[T1 bytes]); the dpl hit result kept GEOGROUP granularity (`dplHitGeoGroup` in the renderer's
|
||||
result block [T0]); and the binary's segment->zone map `@49db20` has NO runtime caller (sole
|
||||
caller = `CreateStreamedDamageZone`, load-time — raw call-scan [T1]), so no segment-level
|
||||
collapse mechanism even exists. Aimed fire in the pod stripped the exact hull panel under the
|
||||
reticle. The segment-level model looked airtight because LIMBS agree at both granularities (one
|
||||
zone per limb mesh) — the collapse only bit the hull, and Oracle's night-10 audit ("only LCT gets
|
||||
hits") was the BUG's fingerprint, not the pod's design. Port: `MechSegmentPick` attributes the
|
||||
struck triangle to its draw op (index-range) and takes the op's `.DZM`-bound zone (the #87
|
||||
armour-darkening bindings — the same authored patch->zone mapping that already paints those
|
||||
panels), segment dzone as the untagged-patch fallback; `ZoneAimPoint` aims hull zones at their
|
||||
patch CENTROIDS so the zone walker can exercise per-panel. Bench: [[test-harness]] §zone-walk
|
||||
(madcat variant, scratchpad/night11/zonewalk_madcat.sh).
|
||||
**MISS-MEANS-MISS (#131, 2026-08-05) [T2 both-direction bench]:** the pick answers ONLY for
|
||||
drawn geometry -- the any-object SPHERE fallback and the caller's whole-mech AABB fallback are
|
||||
gone (sphere only for a mesh the reader cannot parse -- none exist, counters clean; AABB only as
|
||||
the pre-tree replicant grace, `BTMechSegmentPick` returns 1/0/-1). They were port stand-ins the
|
||||
1995 card never had, and their signature regime was a LEVEL boresight over a SHORT mech: the
|
||||
blackhawk's mesh tops out below eye-ray height, so rays cleared every triangle while threading
|
||||
the fat cull spheres -- lock ring lit with the reticle above its head (night-12 field report,
|
||||
operator-witnessed on the sweep bench). Instruments: `BT_LOCK_SWEEP` (torso-pan lock envelope,
|
||||
operator-watchable), `BT_LOCK_ENVELOPE` (synthetic unit sweep), `[picksrc]`/`[pickbox]` source
|
||||
telemetry + objs/invFail/noTri localization. Locking is now strictly TIGHTER than 716-774; if
|
||||
era testers report the pods were more forgiving, Draco's "slight lock linger" memory is the
|
||||
sourced follow-up (tracked on #131).
|
||||
**TWIST-SIGN VERIFIED (2026-08-04) [T2 live]:** the frame adapter's inferred angular flip
|
||||
(`SelectSlice: theta -= TorsoHeading()`, vs the binary's `+=` before the z-reflection) is
|
||||
CORRECT — bench: target torso pinned 0/+140°/−140° (`BT_FORCE_TWIST`, sim-level
|
||||
`analogTwistAxis` hold in torso.cpp — the input-level pin is DEAD, live input rides the
|
||||
CONTROLS.MAP device push and Basic mode auto-centers), LRM salvos (missiles = the authentic
|
||||
cylinder consumers; the binary DROPPED zone-−1 beam damage). Slice picks track the
|
||||
physically-facing flank in both twist directions (positive twist = `horizontalLimitLeft` =
|
||||
CCW), deterministic; the wrong sign is cleanly excluded (would pick slice 7 where slice 1 is
|
||||
observed). Diag: `[slice]` log (live twist, thetaIn/thetaAdj, wedge index) + zone NAMES on
|
||||
`[dmgresolve]` (both under `BT_DMGTABLE_LOG`); bench `scratchpad/night11/twistsign.sh`.
|
||||
|
||||
**IMPLEMENTED (2026-07-29/30) [T2 bench]: the aimed PER-PART pick.**
|
||||
`BTL4VideoRenderer::MechSegmentPick` (btl4vid.cpp): at tree build, each segment's draw object +
|
||||
`GetPrimaryDamageZone()` (SEGMENT.h — carried per segment in the skeleton STREAM, read by
|
||||
@@ -234,7 +278,7 @@ no-arc). The old hardwired lock, the ±30°-default cone, and the projectile pat
|
||||
fallback are all REMOVED. LMB fires lasers / RMB missiles (with SPACE/CTRL). ⚠ A view-selection
|
||||
bug was fixed en route: every
|
||||
renderable rebuild stomped `mCamera` back to the chase eye (btl4vid `mViewInside` now persists
|
||||
the chosen view — the aim camera feed and the V toggle both depend on it).
|
||||
the chosen view — the aim camera feed and the view toggle (backtick since task #68 took 'V' for rear-view) both depend on it).
|
||||
|
||||
**WORLD STRUCTURES ARE TARGETABLE via the STATIC COLLISION TREE (task #50, 2026-07-15) [T2].**
|
||||
The `BTGroundRayHit` "terrain" tier only samples the VISUAL heightfield, which on arena1 is a
|
||||
@@ -324,6 +368,34 @@ per ER-M laser; [[decomp-reference]] §5). Flying projectiles (LRM/autocannon) a
|
||||
reconstruction (`BTPushProjectile` — the 2007 Entity is too small for the binary's raw integrator
|
||||
offsets). [T2]
|
||||
|
||||
## "Mechs die with lightly damaged armor" — AUDITED, no port bug: it's AMMO COOK-OFF (2026-08-02) [T1 chain, T2 measured]
|
||||
Field complaint (night 9): mechs die while their panels read nearly pristine, so "the scaling
|
||||
must be off". A byte-level audit of the ENTIRE damage chain found **every link faithful**:
|
||||
zone stream reads (arcade base ctor `@0041df5c` == engine DAMAGE.cpp, incl. the JointedMover
|
||||
skip-5 branch); the BT ctor normalization loop (`@0049ce50` constants 1.0/1e-4/0.5 — and in
|
||||
SHIPPED CONTENT the raw cells are EXACTLY `1/armorPts` uniform across all 5 types, so the
|
||||
normalize is a no-op); `TakeDamage @0041e4e0` formula+clamps; the override `@0049c690`
|
||||
(redirect gate, vital/leg thresholds 1.0/0.5/0.0 `@0049c99c..a4`); `CriticalHit @0049ccc4`
|
||||
(fraction 0.5 `@0049ce48`, cap 1.0 `@0049ce4c`, the per-subsystem `applied<damagePercentage`
|
||||
cap); the cook-off chain (`stamp @004bc3fc` per-SALVO before the missile divide, `CookOff
|
||||
@004bd300` = ammoCount×amount, `DistributeCriticalHit @004ac274` = ÷(zones plugging the bin)
|
||||
then one DESIGNATED-zone TakeDamageMessage each).
|
||||
**The measured kill route** (death-kinetics bench, 7 deaths): 6 of 7 were **bay-fire
|
||||
detonations** — e.g. `AmmoBinLRM15 10 rounds × 50 = 500 (burst 1)` into the bin's ONE authored
|
||||
carrier zone (madcat bins are single-zone: LRM15_1=roster[26]→dz_ltorso, LRM15_2=[28]→dz_rtorso,
|
||||
AFC100=[21]→{larm,lgun}), which is VITAL (madcat authors **15 of 22 zones vital** — all torsos,
|
||||
legs, feet) → one application 0→1.0 → instant death, every other panel bright. The ÷1 is the
|
||||
CONTENT's authoring, not a port shortcut — the arithmetic is the binary's own. The one mech that
|
||||
died conventionally (300 applications) ended VISIBLY charred: 0.97/0.93/0.92/0.85/0.79 across 13
|
||||
zones — mid-fight darkening DOES accumulate when mechs live long enough.
|
||||
**Field logs confirm dominance**: Rajel's night-9 log alone has 12 bay-fire events (LRM10
|
||||
455-490 pts); ConnMan 10; the "salvo barely dented my armor" reporter was dying to their OWN
|
||||
cook-offs. **The real lever is HEAT** (bay fire arms at the bin heat-watcher's FAILURE level;
|
||||
the [[subsystems]] heat/#96 too-hot economy arms it constantly) plus player training: EJECT
|
||||
**hold** purges the burning bay (`DumpAmmo` → "bay fire EXTINGUISHED"); pod players were
|
||||
manual-trained to purge ([[pod-hardware]] §Manual), testers are not. Novice sim gates also
|
||||
suppress the whole path ([[experience-levels]]).
|
||||
|
||||
## The RAM economy — CLOSED (2026-07-12) [T1 evidence, T3 normalization]
|
||||
Three-layer story, measured live + decomp-verified:
|
||||
1. **The armor economy is POINTS**: every zone streams `damageScale[5]` — 5 cells indexed by
|
||||
@@ -497,7 +569,9 @@ percent tables, MadCat) — see #73 on the tracker.
|
||||
## layers are now fixed and live-verified
|
||||
The recovery: the un-exported gap held `Mech::TakeDamageMessageHandler @0x4a0230` (found via the
|
||||
Mech **message table @0x50bdf8** — rows `{id, name, handler}`: 0x12 TakeDamage, 0x14 PlayerLink,
|
||||
0x15 RealMaxSpeed, 0x16 BalanceCoolant, 0x17/0x18 Set/ClearBurningState, 0x19 EjectPilot @0x49f854,
|
||||
0x15 RealMaxSpeed, 0x16 BalanceCoolant, 0x17/0x18 Set/ClearBurningState (⚠ label/body mismatch:
|
||||
the 0x17 handler @0x49f674 randomizes position + graphicAlarm(2) + re-enables sim — not burning;
|
||||
possibly a shifted name row, cf. the factory ClassID mislabels — open-questions), 0x19 EjectPilot @0x49f854,
|
||||
0x1a DuckRequest @0x49fa00) and the **crit-chance roll `@0x4a0164`**:
|
||||
`p = clamp(0.7·damageLevel² + 0.01, 0..1)`, gated on the player's `simLive` flag (+0x25c — novice
|
||||
never crits), rolled per BURST on the current zone (skip if the zone is already burning). The
|
||||
@@ -510,12 +584,58 @@ entirely — multi-burst under-applied (burst-1)×) is superseded. `MechSubsyste
|
||||
@0x4ac0bc` is real (zone damage → destroyed alarms → **vital-subsystem kill**: owner
|
||||
`graphicAlarm` level 9, the #28 machinery — CLASSMAP's "HandleMessage@4ac0bc" was a mislabel).
|
||||
Live-verified: `[subarmor]` shows parsed scales at spawn; `[critroll] zone=3 -> Myomers subLvl=1`
|
||||
— a full chain crit destroying a subsystem. Still deferred [T4→documented]: the `damageType==0`
|
||||
COLLISION divert (@0x4a0368 → `0x49ffcc`, its own distribution) and the **id-0x16 damage/kill
|
||||
reports** to shooter+victim players (@0x4a04da-0x4a07b2 — the authentic stats plumbing, feeds #45:
|
||||
`{tally, zone, destroyed-flag, inflicting subsystem, victim name}`, kill-flagged variant on newly
|
||||
disabled, plus a killed-by block gated on movementMode 9/10). Diags: `BT_CRIT_LOG` (`[subarmor]` +
|
||||
`[critroll]`), `BT_DMG_LOG`.
|
||||
— a full chain crit destroying a subsystem. The `damageType==0` COLLISION divert (@0x4a0368 →
|
||||
`0x49ffcc`) and the id-0x16 report tail are BOTH reconstructed — see the next section. Diags:
|
||||
`BT_CRIT_LOG` (`[subarmor]` + `[critroll]`), `BT_DMG_LOG`.
|
||||
|
||||
## ✅ THE AUTHENTIC SCORE/DEATH REPORT TAIL (2026-08-05, #45/#134) [T1 raw disasm, T2 both benches]
|
||||
The rest of `Mech::TakeDamageMessageHandler` (@0x4a02f4-0x4a0890, dark-gap raw disasm) is now
|
||||
reconstructed: `BTMechPostCombatReports` + `BTMechPostVehicleDead` (btplayer.cpp), called from the
|
||||
handler at the binary's exact positions (mech.cpp). Retires `BTPostDamageScore`/`BTPostKillScore`.
|
||||
|
||||
**Entry sequence** [T1]: gyro bounce → death-edge latch `[ebp-0x10]` (was-destroyed at ENTRY —
|
||||
before the divert!) → `lastInflictingID` stamp (mech+0x43c ← msg inflictor) → shooter resolve
|
||||
(registry find → `shooter+0x190` = shooter's PLAYER; victim's = `mech+0x190`) → **collision divert
|
||||
JUMPS TO THE DEATH TAIL** (@0x4a0375 `jmp 0x4a07b5`, NOT a return — a wall/fall death still posts
|
||||
VehicleDead + blasts, but skips the score blocks: collision deaths credit no one). Our old early
|
||||
`return` here was a latent "wall-death strands the pilot" hazard, fixed with the move.
|
||||
|
||||
**The three id-0x16 reports** (all `Player::ScoreMessageID` 0x16, sizeof 0x3C — layout in
|
||||
btplayer.hpp, static_assert-locked; `scoreAward`=APPLIED tally in all three):
|
||||
- **A @0x4a04da, newly destroyed** → type 2 KillScore to the SHOOTER's player. Basis (+0x24) =
|
||||
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.
|
||||
- **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
|
||||
(gated `GetConsoleHost()` && !`suppressConsole` — which is why that field is named that).
|
||||
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).
|
||||
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
|
||||
`freeForAll@0x250==0`); self-kill negates the whole award.
|
||||
|
||||
**The death tail** (@0x4a07b5, shared by the divert): gate = was-alive-at-entry && movementMode
|
||||
9|10 → **`Player::VehicleDeadMessage` id 0x17 to the mech's OWN player** — the respawn trigger,
|
||||
in the BT 0x38-byte extension `{deathCount -1, dropZone Null, +0x2c killed-by PLAYER EntityID,
|
||||
+0x34 kill zone}` (bench: `killedBy=2:1 zone=3` cross-node) — then the death Explosion (id 3,
|
||||
0x5C, model 0x31; port fires the authored death list from the transition instead, #42) and the
|
||||
#89 SplashDamage (re-verified after the reorder: 72 bursts at ~9u). The old mech4
|
||||
death-transition dispatch site (which was flagged [T3 sender-undecoded]) is retired — this IS
|
||||
that sender. The #55 NULL-playerLink fallback + DEAD_NOTIFY forensics moved into the bridge.
|
||||
Benches: `scratchpad/night12/scorekill.sh` (cross-node kill: killer `kills=1 award=4.88`,
|
||||
victim respawns, death #1 single-cycle) + `scoreself.sh` (#134 negation). Collision-death tail
|
||||
fallthrough is inspection-tier [T3] — shares the benched tail code; field wall-deaths exercise it.
|
||||
|
||||
## (HISTORICAL — the gap as found 2026-07-29, superseded above) [T1]
|
||||
The authored crit machinery exists and is reconstructed — `Mech__DamageZone::CriticalHit @0049ccc4`
|
||||
@@ -833,7 +953,9 @@ stub let it fire and corrupt `graphicAlarm`. Now `mech->IsDisabled()` — the la
|
||||
defence-in-depth. Sibling fix: `mechdmg.cpp:451` read the phantom `stance` (perma-0) instead of
|
||||
`MovementMode()`, so the leg-shot-out → `graphicAlarm=9` fall/death branch was entirely DEAD; now live.
|
||||
|
||||
## Kill-score damage (task #60)
|
||||
## Kill-score damage (task #60) — ⚠ SUPERSEDED 2026-08-05 by §THE AUTHENTIC SCORE/DEATH REPORT
|
||||
## TAIL above (`BTPostKillScore` is RETIRED; the kill basis is now the applied damage TALLY +
|
||||
## the victim role's killBonus, posted by the victim's own TakeDamage handler). History below.
|
||||
`BTPostKillScore` (btplayer.cpp:1491) feeds the ScoreMessage `damageAmount` into the kill award
|
||||
(`@0x4c02e4` → `(damageAmount + scoreAward) × roleScalar × teamMult × tonnageRatio`, `@0x4c052c`).
|
||||
The port passed a flat `kShotDamage=12` (mech4.cpp:1551), so every kill scored identically regardless
|
||||
@@ -965,6 +1087,60 @@ returned NULL) -- the cascade "ran" and touched nothing, which is why blown-off
|
||||
arms left firing gun pods across every chassis (Conn Man's three-chassis audit).
|
||||
See reconstruction-gotchas #25.
|
||||
|
||||
## Zone-LEVEL replication -- the observer's picture (#87 root cause, 2026-08-03) [T1 decomp / T2 verified]
|
||||
|
||||
How an OBSERVER (replicant holder) learns a mech's zone damageLevels, end to end:
|
||||
|
||||
1. Damage messages do NOT echo locally: `Entity::Dispatch` on a REPLICANT
|
||||
forwards to the master's node and returns (ENTITY.cpp:244) -- there is no
|
||||
broadcast-bus application. The MASTER alone consumes `TakeDamage`.
|
||||
2. The master ships levels via zone UPDATE RECORDS
|
||||
(`DamageZone::Write/ReadUpdateRecord`, `DamageZoneUpdateModelBit`), gated by
|
||||
`ForceUpdate(DamageZoneUpdateModelFlag)`.
|
||||
3. **Who raises the flag (the binary's effect watcher `FUN_0042aa2c @0042aa2c`,
|
||||
the BT analog of the engine's `EntityEffectWatcher` -- which itself never
|
||||
runs for mechs: mech zones carry BAND DESCRIPTORS, the engine `ExplosionTable`
|
||||
stays NULL and `EntityEffectWatcher` is constructed nowhere in the image):**
|
||||
* level branch (zone changedFlags & 4): if the rise CROSSES a band-descriptor
|
||||
threshold (`FUN_0042a5f4` = `Mech__DamageZone::DescriptorCrossed`) AND the
|
||||
entity is a MASTER (`(entity+0x28 & 0xc) == 0`) -> `*(entity+0x18) |= 2`
|
||||
(= ForceUpdate(DamageZoneUpdateModelFlag)).
|
||||
* gstate branch (changedFlags & 8): ForceUpdate unconditionally (masters).
|
||||
So observers track a fight at **band-crossing granularity** -- not per hit.
|
||||
4. Consumption on the observer is passive: `ReadUpdateRecord` writes the level,
|
||||
the armour watcher (`TickArmourDamage`) re-reads zones every frame and pushes
|
||||
the draw-op tint; the doll gauges read `damageLevel*100` continuously.
|
||||
|
||||
**The port had dropped the level branch** -- mechdmg.cpp's band hub ForceUpdated
|
||||
only on `graphicState != Exists`, so every observer's copy sat at 0.0 until a
|
||||
zone DESTRUCTED: no enemy hull darkening, no observer-side doll movement, ever
|
||||
(the "no armour discoloration" night-10 report). Restored 2026-08-03
|
||||
(mechdmg.cpp band loop: `DescriptorCrossed(prev, level)` + master gate on both
|
||||
branches). Verified 2-node: A-side watcher pushed 114 level changes; every
|
||||
replicant peak matched the master's final level to 4 decimals (dtorso 0.9321
|
||||
both sides).
|
||||
|
||||
Render-path note (gotcha #23 discharged): the tint itself was pixel-proven with
|
||||
`BT_ARMOR_FORCE` A/B captures -- all-zones 1.0 renders the hull charcoal (0.1x
|
||||
floor), forced 0.4 reads visibly grey at close range on a light skin, so "no
|
||||
discoloration" was never the renderer. Solo/master-side perception is the
|
||||
authentic ECONOMY: 2-25 pt laser events vs 68-185 pt zone pools. The eye
|
||||
catches SNAPS, not creep: a single heavy hit (ERLG large laser / missile
|
||||
class, amt=25) jumps a 77-pt arm 0 -> 0.3247 in one frame (measured live) =
|
||||
the pod veterans' "a single missile leaves visible armor damage"; 2-pt ERS
|
||||
fire creeps ~1.6%/hit and stays visually silent for a dozen hits (the
|
||||
"legs never show" reports -- their levels peak ~0.08-0.13). The cockpit doll
|
||||
maps all 28 zones (L4GAUGE.CFG:4788-4814 cmArmor lines -- feet + lower legs
|
||||
included); nothing is unwired (orphan audit 2026-08-03: every drawn op with a
|
||||
material name is claimed by a zone).
|
||||
|
||||
Fidelity boundary [T1]: the HOST binary's response is LINEAR (watcher
|
||||
@004573e4/@00457784: 13 material floats lerped toward x0.1 by level, then
|
||||
"flush_material" to the division card). The CARD-side material->ramp->texel
|
||||
curve is i860 firmware absent from BTL4OPT.EXE -- see [[open-questions]]
|
||||
"i860 DIVISION-CARD firmware". If pod darkening was perceptually steeper at
|
||||
mid levels, that steepness lived there.
|
||||
|
||||
## The hit-location CYLINDER -- raw-bytes audit + chassis->table map (2026-08-02) [T1]
|
||||
|
||||
Full independent verification of the type-29 DamageLookupTable streams, parsed
|
||||
@@ -984,6 +1160,22 @@ code involved; grammar = the binary ctors @0x49ea48/@0x49e5e4). Results:
|
||||
"Right*", W1/W2 = "Front*" -- with theta = atan2(z,x) from +X toward +Z, the
|
||||
mech-local FRONT is the **+Z arc (theta 45-135 deg)** and dead-ahead is the
|
||||
W1|W2 SEAM. (Corrects the earlier #92 comment that put frontal hits in W6.)
|
||||
* **⚠ PORT FRAME ADAPTER (#124, fixed 2026-08-03; corrected same day by the
|
||||
MUZZLE-ANCHOR probe)**: that +Z-forward (and +X-right, from the W0/W7
|
||||
"Right*" names) is the **1995 resolver frame**. OUR engine frame, measured
|
||||
with the mech's own asymmetric geometry as the anchor (`BT_ASPECT_TEST`:
|
||||
four world-cardinal self-impacts + one at each weapon's physical muzzle):
|
||||
**RIGHT = +X, same as 1995; FORWARD = −Z, flipped** — the LEFT missile pod
|
||||
sits at raw local x=−2.32, the RIGHT at +2.32, and `WorldToLocal` is a clean
|
||||
R(yaw) with no hidden terms (verified at yaws −30°/150°/40°). The frames
|
||||
differ by a **Z-NEGATION ONLY — a reflection** (the first commit's π
|
||||
rotation CROSSED the pods: the left muzzle resolved rtorso). `ResolveHit`
|
||||
negates local z; and because a reflection reverses angular direction, the
|
||||
torso-twist term in `SelectSlice` enters with the OPPOSITE sign (twist was 0
|
||||
in every probe — the twisted-torso verify is the tracked follow-up).
|
||||
Anchor acceptance: left pod→ltorso, right pod→right wedges, left/right arm
|
||||
muzzles→larm/rarm, nose→front cells, tail→rear cells, at a third yaw. The
|
||||
separate "one band LOW" Y-signature is #16 (pick-ray parallax), not this.
|
||||
Frontal unaimed foot-band hits therefore straddle the RightFoot/LeftFoot
|
||||
cells by impact-x sign, and the authored cross-bleed (20% opposite foot,
|
||||
10+10% opposite leg) is a data-true source of "hit the opposite leg" reports.
|
||||
|
||||
+166
-3
@@ -176,6 +176,22 @@ rows until sanity fails (`python + struct`, see the session commits `cc2b109`/`2
|
||||
|
||||
---
|
||||
|
||||
### Master posture / crouch block (FUN_004a9b5c region, raw disasm 2026-08-05/06) [T1]
|
||||
- mech+0x3f8 `mapPosture` (0 none / 1 may-duck / 2 holding-squat) — selector @0x4a9f61-0x4aa007:
|
||||
gates = movementMode normal, simLive (player+0x25c, NOVICE lockout), leg alarm 0/4 → 1, leg
|
||||
alarm 1 → 2, myomer factor |x|>1e-4 (const @0x4ab16c).
|
||||
- mech+0x79c `myomerEffectiveness` — MAX over the myomer chain's speedEffect@0x31C, published by
|
||||
the mapper drive-scale block (mechmppr.cpp:990 == @0x4a9cf2-0x4a9da4); scales speedDemand,
|
||||
zeroes turnDemand at ~0 (the seek-4 freeze), and gates the crouch.
|
||||
- DuckRequest consumer @0x4aa011-0x4aa0af: duckState && squatCapable → SetLegAnimation(2 'sqd')
|
||||
/ (3 'squ'), ForceUpdate(8)+(1) (type-3 record ships legState → peers pose it), stability
|
||||
alarm 0/1, request consumed. Clips: animationClips[2]=sqd(i), [3]=squ(i) (BTL4.RES, all 8
|
||||
chassis; loader slots byte-verified vs @0x50d9c8 suffix pool).
|
||||
- Myomers cluster: Performance wrapper @004b8b9c → drive-heat integrator @004b8be3;
|
||||
AvailableOutput @004b8ac0 = gear clamp × QUADRATIC heat degrade (+0x114 vs +0x118/+0x11c band)
|
||||
× (1 − legZoneDamage); speedEffect = output / ownerBaseSpeed(+0x34C) — unclamped (gear-4
|
||||
supercharge ≈1.43).
|
||||
|
||||
## 4. Damage delivery
|
||||
|
||||
- `Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)` → `target->Dispatch(&msg)`.
|
||||
@@ -183,6 +199,7 @@ rows until sanity fails (`python + struct`, see the session commits `cc2b109`/`2
|
||||
- `class Damage { damageType(enum Collision/Ballistic/Explosive/Laser/Energy), damageAmount, damageForce, surfaceNormal, impactPoint, burstCount }`. [T1]
|
||||
- Weapon effect id: **"explode" = 13** (`Explosion::Make`). [T2]
|
||||
- `DestroyEntityMessage(id,size)` removes an entity — but a killed mech STAYS (a WRECK); death = a STATE transition (`SetGraphicState(DestroyedGraphicState)`), NOT removal. Issuing removal-on-death is the P5 teardown bug (do not). [T2]
|
||||
- **BT effect watcher (zone replication + band effects)** [T1, 2026-08-03]: ctor `FUN_0042a984 @0042a984` (hooks entity+0xbc, allocs oldLevel[damageZoneCount@+0x11c]); Execute `FUN_0042aa2c @0042aa2c` — per zone: flag&4 → `FUN_0042a664` DescriptorForLevel + `FUN_0042a5f4` DescriptorCrossed(old,new) → **crossed && master (`(entity+0x28 & 0xc)==0`) → `*(ushort*)(entity+0x18) |= 2`** (= `ForceUpdate(DamageZoneUpdateModelFlag)`, the zone-record send); flag&8 → `FUN_0042a6c4` DescriptorForGraphicState + the same master-gated ForceUpdate; descriptor → effect via `FUN_0043663c/FUN_004364e4` (renderer mgr @`DAT_004efc94+0x38`); changedFlags reset when (master && !pending-update) or replicant. Zone band table @ zone+0xd4; level @+0x158; gstate @+0x78. Engine `EntityEffectWatcher`/`ExplosionTable` are DEAD for mechs (table NULL, watcher never constructed — band descriptors replace them). Port: mechdmg.cpp band hub. See [[combat-damage]] §Zone-LEVEL replication.
|
||||
|
||||
---
|
||||
|
||||
@@ -246,9 +263,36 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
|
||||
### Binary message tables decoded 2026-07-20 (glass input audit) [T1]
|
||||
16-byte HandlerEntry {id, namePtr, fnPtr, 0} rows in section_dump. Per-receiver-class id spaces.
|
||||
- **Mech (entity)** @0x50BDF8..: 0x12 TakeDamage@004a0230, 0x14 PlayerLink@0049f624, 0x15
|
||||
RealMaxSpeed@0049f604, **0x16 BalanceCoolant@0049f728 (WIRED 2026-07-21, issue #20**: press-only, NO novice guard; sets every condenser valveState@0x1D0=1 then the shared redistribute @0049f788 == BTRecomputeCondenserValves; verify BT_BALTEST=1 + BT_VALVE_LOG=1**)**, 0x17 SetBurningState@0049f674, 0x18
|
||||
RealMaxSpeed@0049f604, **0x16 BalanceCoolant@0049f728 (WIRED 2026-07-21, issue #20**: press-only, NO novice guard; sets every condenser valveState@0x1D0=1 then the shared redistribute @0049f788 == BTRecomputeCondenserValves; verify BT_BALTEST=1 + BT_VALVE_LOG=1**)**, 0x17 SetBurningState@0049f674 (⚠ body mismatch — randomizes position, graphicAlarm 2, sim re-arm; see open-questions), 0x18
|
||||
ClearBurningState@0049f700, **0x19 EjectPilot@0049f854**, **0x1a DuckRequest@0049fa00** (the
|
||||
manual's CROUCH; streamed button 0x13 sends it — unreconstructed).
|
||||
- **BTPlayer** (byte-scanned 2026-08-05, file 0x112dxx; 20-byte rows {id, namePtr, fn, 0, 0} — 6
|
||||
entries EXACTLY, matching the T0 PLAYER.h enum): **0x15 DropZoneReply@0x4bffd0, 0x16
|
||||
Score@0x4c02e4** (the type-1/2 switch — BT shadows the engine id with its override), **0x17
|
||||
VehicleDead@0x4c05c4, 0x18 MissionStarting@0x4bfbe8, 0x19 MissionEnding@0x4bfc20, 0x1a
|
||||
ScoreUpdate@0x4c02a8** (= Player::NextMessageID). **@0x4c0200 ("ScoreInflicted", type-0-only,
|
||||
Verify line 0x18b) is in NO entry — dead code in 1995.** The 1995 type-0 path therefore lands in
|
||||
@0x4c02e4's Verify arm and folds an UNINITIALIZED [ebp-0xc] into currentScore (real bug; port
|
||||
banks 0). @0x4c02e4 internals [T1 raw]: `+0x40==4` MissionEnding gate; registry-find msg+0x34;
|
||||
case 1 = `(ownTonnage/senderTonnage) × role->CalcDamageReceivedScore(basis)` + the
|
||||
console VTVDamaged post (ConsoleHost && !suppressConsole@0x258; **arg 6 = Round(AWARD)** — an
|
||||
ST0-arg __ftol @0x4dcd94 the decompiler rendered argless; the old "Now()" read was wrong); case
|
||||
2 = kill: `CalcKillScore@0x4c052c` × (senderTonnage/ownTonnage), self-kill `fchs`, dual
|
||||
`killCount@0x27c++` (shooter + victim's player — the phantom partner increment, masked by
|
||||
replication), StatusMessage{type 0 Destroyed, victim player, 6.0s} from pool @0x512f6c with
|
||||
vtable @0x513344, clear-target @0x4b04d8 via `playerVehicle+0x128` roster head when the dead
|
||||
mech == `objectiveMech@0x284`. `CalcKillScore@0x4c052c` [T1]: same-team (strcmp
|
||||
`teamName@0x20c`, gate `freeForAll@0x250==0`) → `-friendlyFirePenalty(role+0x14)`; else
|
||||
`victimMech->avgZoneDamage@0x354 × damageBias(role+0x18) + 1.0` (const @0x4c05c0); result ×
|
||||
`(basis + tally) × damageInflictedModifier(role+0xC)`. **ScenarioRole offsets** (Node base):
|
||||
+0xC inflictedMod, +0x10 receivedMod, +0x14 ffPenalty, +0x18 damageBias, +0x1C killBonus,
|
||||
+0x20 specialCaseDeathPenalty. **mech+0x354** = average zone damageLevel (computed with the
|
||||
subsystem average @0x358 by the telemetry refresher, part_012.c:9515). **Wire layouts**
|
||||
(static_assert-locked, btplayer.hpp): ScoreMessage 0x3C {+0x1c scoreAward=tally, +0x20 type,
|
||||
+0x24 basis, +0x28 vitalHit, +0x2c zone, +0x30 subsysID(=TakeDamageMessage+0x5c,
|
||||
inflictingSubsystemID [T0]), +0x34 senderMechID}; BT VehicleDeadMessage 0x38 {engine 0x28 +
|
||||
0x28=0, +0x2c killed-by PLAYER EntityID, +0x34 kill zone}. Senders: the TakeDamage report tail
|
||||
— see [[combat-damage]].
|
||||
- **HeatableSubsystem** @0x50E41C: {3, "ToggleCooling"→@004ad6f8}. **Disassembled 2026-07-20**
|
||||
(`tools/disas2.py 0x4ad6f8`): a per-subsystem coolant on/off TOGGLE (NOT a multi-level "cycle
|
||||
priority" — that's the emergent effect, not the mechanism):
|
||||
@@ -319,6 +363,100 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
|
||||
Loaded ("full == the gear's seek voltage" is the arcade's own discharge algebra). Repro +
|
||||
verify: BT_SEEKTEST seek-abuse -- pre-fix deadlocks at pct=0/alarm=3; post-fix 38 rescues,
|
||||
ends Loaded/pct=1. `[seek]` log prints the per-gear table + rescue events.
|
||||
- **EJECT/PANIC cluster DECODED + WIRED 2026-08-02 [T1 raw disasm]:** `@0049f854`
|
||||
**Mech::EjectPilot** (table id 0x19; sat in the 0x49f854-0x49fa00 export gap) — gates
|
||||
{msg+0xc > 0, ejectPermitted@0x414, !IsDisabled@0049fb54}, console eject notice
|
||||
(`FUN_004c198c` ctor from ownerID@0x18c → network sender, code 5 — port tail),
|
||||
player `suppressConsole`@0x258 = 1 (the following death must not double-notify),
|
||||
`graphicAlarm → 10` (EJECT state; **kills via the ≥9 predicate** — IsMechDestroyed),
|
||||
self TakeDamageMessage {inflicting=SELF, zone −1, Explosive, amount =
|
||||
`ScenarioRole::killBonus` role+0x1c, burst 0}. Role layout byte-settled via the
|
||||
reader `@00429bec` dest offsets + ctor record copy: +0xc inflicted, +0x10 received,
|
||||
+0x14 ffPenalty, +0x18 damageBias, **+0x1c killBonus**, **+0x20
|
||||
SpecialCaseDeathPenalty (4.10-only key)**, +0x28 returnFromDeath. `KillBonus`
|
||||
authors NOWHERE in shipped content → charge 0, alarm does the killing — authentic.
|
||||
`@0049fa1c` **EvaluateEjectPermission** (writes @0x414; unexported caller): roster
|
||||
walk — 0xBBE bank coolant fraction (+0x12c/+0x128) < 0.05 (`@0049fb50`) | zero live
|
||||
Generators (0xBC1, +0x40 ≠ 1 && stateAlarm@0x210 ≠ 4) | live MechWeapons
|
||||
(0x511830; ProjectileWeapon family 0x5121a8 also needs weaponAlarm@0x364 ≠ 7 NoAmmo)
|
||||
< mech+0x448 (**no exported writer — zero, clause inert** [T3]) | leg-gimped
|
||||
(mode 3/4) novice (simLive 0). Port: mech.cpp handler+evaluator, bridges
|
||||
BTWeaponCountsForEject / BTGeneratorCountsForEject / BTHeatSinkBankCoolantFraction /
|
||||
BTPlayerEjectBookkeeping; input = binding-engine `Eject` action (Backspace /
|
||||
pad LeftThumb; BENCH `BT_EJECT_AT=<frame>`, `BT_KILL_SUBSYS` now comma-list).
|
||||
- **EJECT ARM CHAIN closed 2026-08-03 [T1 E8-scan / T2 lamp verified]:** the
|
||||
evaluator's "unexported caller" FOUND — one relative CALL from
|
||||
`0x004a9b6c` = **`FUN_004a9b5c` (the Mech MASTER PERFORMANCE) + 0x10: its
|
||||
FIRST act each frame is EvaluateEjectPermission** (masters only; the fn sits
|
||||
in the 004a977x-004ab188 export hole, located by E8 rel32 scan of the CODE
|
||||
section). Downstream: `@004d196c` L4MechControlsMapper::InterpretControls
|
||||
edge-detects @0x414 (cache @mapper+0x1a8) → Add/RemoveModeMask(**0x200000 =
|
||||
the PANIC-ARMED mode** — the old "missionReviewMode" reading of mech+0x414
|
||||
was a MISLABEL, swept 2026-08-03; the real review mode is the GLOBAL
|
||||
`DAT_004fd550`, btl4pb) → `MakeLinkedLamp(ButtonPanic 0x3d, 0x200000)`
|
||||
(`FUN_00476fc0`) lights the pod PANIC button; on glass the pad panel shades
|
||||
the miniconsole Panic button from the same PadRIO lamp state
|
||||
(L4PADPANEL:295, verified lit dark→bright on generator kill). NO gauge in
|
||||
the shipped L4GAUGE.CFG rides mode 0x200000 — the eject indication is the
|
||||
BUTTON LAMP, not an MFD element ("weapon-eng MFD eject lamp" reports = the
|
||||
weapon page's UNJAM/EJECT ammo-jam indicator, a different feature). Lamp is
|
||||
SOLID on arm (engine linked-lamp semantic); FLASH = the GaugeAlarm
|
||||
`SetAlertState` overlay (RIO::flashFast), not authored for panic. Port fix:
|
||||
per-frame `EvaluateEjectPermission()` at the top of Mech::PerformAndWatch
|
||||
(master-gated) + `GetEjectPermitted()` accessor; `BT_EJECT_LOG=1` logs the
|
||||
arm edges. Adjacent table fact: id 0x1a `DuckRequest@0049fa00` latches
|
||||
mech+0x398 = 1 — that latch has NO reader anywhere in the image (vestigial).
|
||||
- **THE TECHSTATUS / GAUGE-ALARM MODEL decoded 2026-08-04 [T1]:** the gauge-alarm
|
||||
"condition" = a **STATUS-FLAG BIT INDEX** (0..6, TechStatusTypeCount). MechTech's
|
||||
`TechnicalAssistance` edge-scans every monitored subsystem's `GetStatusFlags()`
|
||||
(vtable +0x30) and on a bit flip calls `ReportStatusSet/Cleared(sink, owner, sub,
|
||||
bitIndex, alarmModel)` → `GaugeAlarmManager::Activate(condition=bitIndex)` → the
|
||||
SHARED 'mechalrm' stream's items matching that condition flash their lamps
|
||||
(SetAlertState → RIO flashFast). The bit table, all byte-grounded overrides:
|
||||
**bit 0** structure ≥ StatusThreshold (@004ac18c) | **bit 1** structure >
|
||||
StatusFloor | **bit 2** coolantActive && HeatModelActive (CoolantLeaking) |
|
||||
**bit 3** heatAlarm ≠ 0 | **bit 4** linked AmmoBin cookOffArmed (**AmmoBurning**)
|
||||
| **bit 5** weaponAlarm == 5 (**Jammed**) | **bit 6** !HasVoltage (no usable
|
||||
voltage; `HasVoltage` = source stateAlarm==2 Ready && |outputVoltage| > ε,
|
||||
@004b0b5c). **Invite semantics:** conds 4/5 → gotoEngineering 0x80 + engEject
|
||||
0x85 = the AMMO purge/unjam invite (flashes the very key whose streamed function
|
||||
is EjectAmmo msg 0xB) — NEVER pilot eject; cond 6 → 0x80 + engBusMode 0x86 =
|
||||
the bus-switch invite; cond 2 → the coolant loop/placement lamps (#98). The
|
||||
PANIC LAMP (mode-linked, solid) is the SOLE pilot-eject indicator. **Electrical
|
||||
subtlety [T1, @0049c9a8 + FUN_004b1f7c]:** the crit distributor touches nothing
|
||||
electrical, and the generator sim has NO Ready-case recompute — a generator
|
||||
destroyed IN PLACE keeps stale Ready voltage (no bit-6 invite) until any
|
||||
transition (thermal trip → stateAlarm 4 GeneratorOut, short, switch-off)
|
||||
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.
|
||||
- **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
|
||||
`Player::ScoreMessageHandler` (`@0042da20` == PLAYER.cpp:138) a type-1 ScoreMessage
|
||||
with `scoreAward = −ScenarioRole::specialCaseDeathPenalty` (role+0x20, the 4.10-only
|
||||
key; restored to SCNROLE.h/.cpp — record slot [1] after KillBonus, fail-hard read).
|
||||
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:
|
||||
`@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):
|
||||
`slice = (till.ticks − this[4].ticks) / DAT_0052140c; this[4] = till;
|
||||
(*this[7])(this, slice)` then watchers (`FUN_0041c08c`) + WriteSimulationUpdate
|
||||
(`FUN_0041bd98`) — **VARIABLE-STEP, slice in real SECONDS**, line-for-line the
|
||||
WinTesla SIMULATE.cpp body. Corollaries: every Performance's `time_slice` is
|
||||
seconds in BOTH binary and port (no hidden tick/second unit gap); a dt-LESS
|
||||
per-frame term (myomer kinetic @4b8d18's middle term; the Mover gravity
|
||||
`-= **(mover+0x250)` @0x421e77) fires at the FRAME cadence = the tick rate,
|
||||
28 Hz nominal, sagging under load. Mover velocity is u/s (the replicant
|
||||
dead-reckoner @0x421f7c does `pos += vel × ticksΔ/DAT_0052140c`). The myomer
|
||||
heat term's ONE caller is the **unexported MyomersSimulation body
|
||||
@0x4b8b9a-0x4b8d0d** (E8-scan + raw disasm; the export gap hid it): calls
|
||||
@4b8d18 once per Perform, raw slice, gated `measuredVoltage > 0` only — plus
|
||||
a −0.011/Perform repair trickle at alarm 1 and `speedEffect(+0x31c) =
|
||||
AvailableOutput(min(measuredV, seek gear, cap)) / owner->runSpeedBase(+0x34c)`.
|
||||
- **AMMO COOK-OFF cluster DECODED 2026-07-25 (issue #46, raw disasm `scratchpad/disammo.py`) [T1]:**
|
||||
`@004bd394` AmmoBinSimulation (arm on heatAlarm FAILURE / detonate / cancel-on-Empty);
|
||||
`@004bdb94` HandleMessage(1) = crit-induced arm; the FUSE = `Now().ticks +
|
||||
@@ -409,6 +547,13 @@ default-ON (`'0'` disables).
|
||||
| Var | Effect |
|
||||
|---|---|
|
||||
| `BT_FORCE_THROTTLE` | auto-walk forward (no key) |
|
||||
| `BT_BTNTEST=addr,on,off` / `BT_BTNTEST2=...` | scripted screen-button press/release cycles at poll counts (the REAL RIO seam; 2nd cycle for e.g. squat→rise) |
|
||||
| `BT_DUCK_LOG` | crouch diagnostics: RIO press mode-mask, SetLegAnimation re-arm tracer (+ra), 1 Hz joint probe, posture/squat events |
|
||||
| `BT_TREE_LOG` | dump the built render-tree topology (segment/joint/parent, SITE rows) at mech build |
|
||||
| `BT_SPOT_SELF` | bench-only: build the searchlight cone on the OWN-cockpit tree so `BT_CAM=face` can eyeball it solo |
|
||||
| `BT_CAM=face` | chase camera in FRONT looking back (the old animation view); `BT_CAM_Y/Z` offsets |
|
||||
| `BT_FOG_LOG` | log SetFogStyle transitions + fog page resolution (searchlight swap forensics) |
|
||||
| `BT_GIMP_SPEED` | override the gimp-gait demand scale (1.0 = authentic) |
|
||||
| `BT_SPAWN_ENEMY` | spawn a target mech 120u ahead along the spawn facing |
|
||||
| `BT_AUTOFIRE=1` | hold the trigger (headless walk→fire→death harness; supersedes the dead `BT_FORCE_FIRE` — btl4main.cpp:310 set `fireForced` once at startup, but mech4.cpp:1567 unconditionally overwrites it every frame) |
|
||||
| `BT_ASSERT_TO_DEBUGGER` | route CRT asserts to the debugger, not a modal box |
|
||||
@@ -431,7 +576,7 @@ default-ON (`'0'` disables).
|
||||
| `BT_AIM="x y"` | pin the reticle crosshair (reticle coords) — headless aim harness |
|
||||
| `BT_AIM_LOG` | `[pick]` ray/box/hit diagnostics (Mech::PickRayHit) |
|
||||
| `BT_FIRE_ARC=<deg>` | OPT-IN external-camera fire-arc clamp (unset = authentic no-arc) |
|
||||
| `BT_START_INSIDE` | begin in the cockpit view (V toggles) |
|
||||
| `BT_START_INSIDE` | begin in the cockpit view (BACKTICK toggles; 'V' = the pod rear-view hold since #68 — the toggle skips bound keys, L4PADRIO.cpp:681) |
|
||||
| `BT_FORCE_MODEL=<name>` | force the player mech (`madcat`/`owens`/…; btl4mssn.cpp — per-mech cockpit bring-up) |
|
||||
| `BT_HIDE_COCKPIT` | hide the `*_cop` canopy shell (SHOWS by default — see [[cockpit-view]]) |
|
||||
| `BT_COP_*` / `BT_EYE_FWD=<f>` | cockpit canopy + eye diagnostics (`FRAME` (unlit frame colour override, default 0.13,0.12,0.15) / `SINGLE` (single-sided diag; double-sided is default) / `PLATES=1` (disable the punch stencil-cut kit) / `FLIP` / `DEBUG` / `DUMP`; eye forward-push probe) — [[cockpit-view]] |
|
||||
@@ -501,7 +646,9 @@ ex-"clipLoadGuard"). Byte tooling: `scratchpad/dis_4b2980.py` / `dis_range.py`
|
||||
`reference/decomp/section_dump.txt`.
|
||||
|
||||
**ARMOUR-DARKENING addresses** [T1] (#87; full story: [[rendering]]): `.DZM` res compiler
|
||||
`FUN_0041e4e0` (part_002.c:7341, reads the `material` key list per `[dz_*]` section); runtime
|
||||
address UNKNOWN — the old citation `FUN_0041e4e0` was WRONG (that function, part_002.c:7337,
|
||||
is `DamageZone::TakeDamage`: `level += amount×scale[type]`, byte-matches DAMAGE.cpp:379 —
|
||||
verified 2026-08-02, swept here + [[rendering]]); runtime
|
||||
consumer inside `BTL4VideoRenderer::MakeMechRenderables` **`FUN_004cef28`** (part_014.c:5190-5272);
|
||||
per-zone node ctor `FUN_00455c7c(...,0.04f)`; the per-material watcher
|
||||
**`FUN_004573e4(material, &zone->damageLevel, 0.1f)`** (part_007.c:9133, vtable `PTR_LAB_004f1730`,
|
||||
@@ -538,6 +685,22 @@ Respawn SIM path: `DropZoneReply` `FUN_004bffd0` → `Mech::Reset` @0x4009fb74;
|
||||
---
|
||||
|
||||
## 7. Tools
|
||||
- ⚠ **CITATION POLICY (since the 2026-08-06 re-export): cite `@ADDR`, never `part_0NN.c:LINE`.**
|
||||
Addresses are stable across exports; shard membership and line numbers are NOT. Pre-08-06
|
||||
part/line citations resolve only against `reference/decomp/archive_2025export/`.
|
||||
- ⚠ **Ghidra renders member access as int-ARRAY indices** (`param_1[0xfe]` == byte offset 0x3f8,
|
||||
`[0x1e7]` == 0x79c): grepping the export for an offset STRING misses every such site. Divide
|
||||
by 4 (or grep both forms) — this is a live instance of gotcha §20.
|
||||
- `tools/gapcensus.py [dir]` — the #60 dark-region census: index/export coverage, function-start
|
||||
discovery, TU attribution, repo-citation flags → `<dir>/GAP_CENSUS.md` + `gap_census.tsv`.
|
||||
Consult BEFORE any "absent from the export" claim (gotcha §20).
|
||||
- `tools/ghidra_reexport.sh [reprocess]` — headless re-export: Ghidra 12.1.2 + JDK 21 (installed
|
||||
in `%LOCALAPPDATA%\bt411-tools` beside DXSDK/cmake) run `reference/ghidra_scripts/ExportGaps.java`
|
||||
(ExportAll's output contract + the dark-region gap fill). ⚠ the runner MUST use 8.3 SHORT paths
|
||||
(`JOELAP~1`, `BT411-~1`): Ghidra's launcher .bat expands `%JAVA_HOME%` UNQUOTED and the user
|
||||
profile has a space. `reprocess` re-runs the script on the saved project (skips re-analysis).
|
||||
- `tools/gapdiff.py [old] [new]` — score two censused exports (coverage/dark deltas, which
|
||||
previously-dark addresses the KB already cites, celebrity check).
|
||||
|
||||
- `tools/disas2.py <VA> [len]` — capstone disassembly of BTL4OPT.EXE at a VA (recovers x87 math
|
||||
Ghidra drops; folds known call targets + float constants). THE tool for any `FUN_` the
|
||||
|
||||
@@ -42,7 +42,9 @@ then unconditionally `+0x264 = +0x268 = mission->advancedDamageOn(+0xf0)` (both
|
||||
|
||||
## What each flag gates (binary consumers, all reached via `mech+0x190` → BTPlayer) [T1]
|
||||
|
||||
- **+0x25c — "sim live", off only for novice.** Consumers: ballistic jam roll `CheckForJam`
|
||||
- **+0x25c — "sim live", off only for novice.** Consumers: the CROUCH posture selector
|
||||
(master-perf @0x4a9f70: novice → mapPosture 0 → squat/rise refused — 2026-08-06,
|
||||
[[locomotion]] §CROUCH); ballistic jam roll `CheckForJam`
|
||||
@4bbfcc early-returns NO-JAM when 0 (projweap.cpp calls this `LiveFireEnabled`); **ThermalSight**
|
||||
`ToggleLamp` @4b860c (thermalsight.hpp `ControlsAllowLights`) — ⚠ **CORRECTED 2026-07-25 (#61):
|
||||
this was listed as *Searchlight's* gate. It is not. Raw disassembly of Searchlight's real handler
|
||||
|
||||
+17
-1
@@ -11,7 +11,7 @@ open_questions:
|
||||
- "SeekVoltageGraph RECONSTRUCTED 2026-07-19 (Gitea #11, was #10 finding A): full widget landed (see §SeekVoltageGraph below) -- ghosts gone steady-state (BT_PRESET_HOLD verification); remaining polish: a 1-frame transition artifact when a BT_SHOT lands on the exact page-switch frame (label BecameActive vs the graph's next rated Execute -- same lag class as the binary; self-heals next frame)"
|
||||
- "Secondary-view cycling RESOLVED 2026-07-19 (Gitea #6): the selector is the DISPLAY mode (CycleDisplayMode -> vtbl+0x4C override @4d1ae4), NOT CycleControlMode; desktop 'N' / pad RightThumb wired; pixel-verified dama->crit->heat"
|
||||
- "Upper-MFD PRESET pages RESOLVED 2026-07-19 (Gitea #9): SetPresetMode table @0051dbf0 re-decoded (little-endian -> ModeMFD bits 0-14), per-MFD pod button banks identified from the .CTL dump, desktop J/K/L cycle wired"
|
||||
- "Always-active msg-4 records IDENTIFIED 2026-07-20 (glass input audit): 0x2C = Reservoir InjectCoolant (the flush button), 0x2F/0x2E/0x2D/0x2B/0x2A/0x29 = Condenser1-6 MoveValve, 0x1A-0x1D = GeneratorA-D ToggleGeneratorOnOff (@0050fb90, unreconstructed); plus 0x13 = Mech DuckRequest (crouch), 0x28 = Mech BalanceCoolant, 0x12/0x14 = ThermalSight/Searchlight toggles -- see pod-hardware.md + docs/GLASS_COCKPIT.md 2026-07-20"
|
||||
- "Always-active msg-4 records IDENTIFIED 2026-07-20 (glass input audit): 0x2C = Reservoir InjectCoolant (the flush button), 0x2F/0x2E/0x2D/0x2B/0x2A/0x29 = Condenser1-6 MoveValve, 0x1A-0x1D = GeneratorA-D ToggleGeneratorOnOff (@0050fb90; wired 2026-07-25, powersub.cpp); plus 0x13 = Mech DuckRequest (CROUCH -- COMPLETE 2026-08-06, [[locomotion]]), 0x28 = Mech BalanceCoolant, 0x12/0x14 = ThermalSight/Searchlight toggles (searchlight visuals done 2026-08-05) -- see pod-hardware.md + docs/GLASS_COCKPIT.md; statuses re-swept 2026-08-06"
|
||||
- "MP DEATHS resolved 2026-07-12 (observed-death tally + display clamp); remaining: verify multi-death tallies stay in sync across a long session (GAUGE_COMPOSITE.md)"
|
||||
---
|
||||
|
||||
@@ -783,6 +783,22 @@ pattern across entries [2^bits..255] (high-index art degrades to its
|
||||
index-mod-2^bits colour IN-PLANE, can never leak). The 1995 binary ships the
|
||||
SAME 64-entry fill and relied on 6-bit art discipline -- garbage is not a
|
||||
preservable behaviour, so the cycle-fill is a guarded PORT deviation.
|
||||
|
||||
**REFINED 2026-08-10 [T1] -- the cycle-fill itself made phantoms.** The
|
||||
in-plane wrap mapped art index 254 onto plane slot 62 = the LIVE
|
||||
`colorMapperMultiArmor` right-armor damage slot, and idx-254 art EXISTS:
|
||||
every SMODE.PCC frame fills the INACTIVE control-mode box interiors with 254
|
||||
(active border/text idx 9 yellow, inactive borders idx 5 orange-red), and
|
||||
BTSEC1.PCX carries a stray 52x13 idx-254 bar at port (199-250, 101-113) --
|
||||
between the heading dial and the armor rosette, geometrically a scratch
|
||||
duplicate of the rosette quadrant bars. Both lit up in the current
|
||||
right-armor colour (adpal ramp green/orange/red) on EVERY render path --
|
||||
playtester-reported as "red boxes around MID/ADV" + "a block between Armor
|
||||
and Heading". On the shipped machine those regions rendered BLACK (the
|
||||
garbage entry's low plane bits were 0 -> in-plane index 0), so the fill now
|
||||
maps [2^bits..255] to `translationTable[0]` (plane BACKGROUND) -- authentic
|
||||
on-screen result, same no-leak guarantee. NB the 2026-07-19 audit verified
|
||||
smode BEFORE the 07-25 cycle-fill landed, which is why the row said CORRECT.
|
||||
Post-fix: **0 leaks over 60s** on the same probe; sim3 3-pod regression clean.
|
||||
**The tripwires are DEFAULT-ON in every build** (BT_PLANE_AUDIT=0 opts out): the
|
||||
plane-leak trap, plus an out-of-bounds draw-start trap in `buildDestPointer`
|
||||
|
||||
@@ -73,6 +73,7 @@ that one answer. Precedence, highest first:
|
||||
| Env | Layout | Buttons live in |
|
||||
|---|---|---|
|
||||
| `BT_GLASS_PANELS`≠0 | per-display cockpit windows (the "exploded" view, `L4GLASSWIN`) | each display's own window |
|
||||
| `BT_POD_SURFACES`=1 | **POD MODE** (2026-08-06): crop each panel window to its SURFACE (MFD 640×480, radar 480×640), drop the on-screen button banks + the Flight Controls pad, frameless. For REAL pod glass, where the buttons are physical — see [[pod-hardware]] §MFD PANELS. Per-window: `,bare` in `glass_layout.cfg`. | 6 bare surfaces |
|
||||
| `BT_DEV_GAUGES_WINDOW` | the legacy separate MFD window | the single combined pad panel |
|
||||
| `BT_DEV_GAUGES_DOCK` | the docked bottom gauge strip | the single combined pad panel |
|
||||
| `BT_COCKPIT=0` | ...also the docked strip (the documented opt-out) | the single combined pad panel |
|
||||
@@ -155,6 +156,26 @@ want bare. From then on those windows are frameless **and pinned** — with no c
|
||||
nothing to drag them by, which is the point: the arrangement is finished. Delete the flag to get
|
||||
the frame back.
|
||||
|
||||
### `monitor:<name|index>` — bind a window to a DISPLAY, not to pixels (2026-08-06) [T2 live]
|
||||
|
||||
Instead of an `x,y,w,h` rect, a line may name a display device:
|
||||
|
||||
```
|
||||
Secondary / Radar=monitor:DISPLAY4,bare
|
||||
VGA Port A=monitor:DISPLAY2,bare
|
||||
```
|
||||
|
||||
The resolver (`ResolveMonitorSpec` + `MonScanProc`, `L4GLASSWIN`) matches the full device name OR
|
||||
its tail, so `monitor:DISPLAY4` works without typing `\\.\`; a bare integer is a 0-based index in
|
||||
enumeration order. The surface is CENTRED on that monitor. This is what makes a fixed rig
|
||||
survivable: **desktop rects move** when a display is re-detected, a USB graphics adapter
|
||||
re-enumerates or someone changes a resolution, and a pixel rect then silently puts a panel on the
|
||||
wrong glass — a device binding does not. Required for [[pod-hardware]] §ALPHA-MR, where a
|
||||
mis-bound window means a picture on the wrong CRT with no error anywhere.
|
||||
|
||||
`,bare` **implies frameless** (it used to need `,noframe` too, and a "bare" window came back
|
||||
656×519 WITH a caption — a real bug found on the cab).
|
||||
|
||||
Options are comma-separated after the numbers and **unknown ones are ignored**, so an older build
|
||||
reading a newer file loses the option but never the line — the `bindings.txt` grammar rule
|
||||
applied here.
|
||||
@@ -182,6 +203,40 @@ 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 (branch `glass-panel-perf`)
|
||||
after a playtester reported ~20 fps in panels mode vs ~130 fps in the surround:
|
||||
|
||||
1. **HALFTONE → COLORONCOLOR** (`f3d27f5`): 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`): 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** (2026-08-10): 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
|
||||
a rig with no marquee wired, leaving the variable unset is NOT enough; the profile fills it back
|
||||
in and a plasma window lands on the cab's glass. The `NONE`/`OFF`/`0` spelling nulls the string
|
||||
before the branch, so no external display is constructed at all. The boot banner reads
|
||||
`GLASS (PadRIO; plasma off [L4PLASMA])` when it takes, and the absence of the
|
||||
`[plasmawin] desktop plasma display up` line is the runtime proof.
|
||||
|
||||
**Verified 2026-07-26 [T2]:** `BT_RIOBANK_LOG=1` dumps every bank's rects;
|
||||
`scratchpad/checkbank.py` reports the per-bank census and proves no address is SHADOWED (has a
|
||||
point no earlier button covers); `scratchpad/clickbank.py` then posts a real click at every
|
||||
@@ -213,7 +268,10 @@ window.
|
||||
- **World aspect** under the letterbox is the view rect's OWN aspect — the client no longer
|
||||
enters into it.
|
||||
- **`-fit`** (alias `-windowed-fullscreen`): borderless `WS_POPUP` over the monitor, canvas
|
||||
letterboxed inside. Verified on a 3440×1440 ultrawide.
|
||||
letterboxed inside. Verified on a 3440×1440 ultrawide. **`BT_FIT=1` is the env spelling**
|
||||
(2026-08-06) — same flag, settable from `environ.ini`, so a fixed rig keeps its borderless main
|
||||
view however it is launched instead of depending on one launcher's command line. Targets
|
||||
`MONITOR_DEFAULTTOPRIMARY`, i.e. the PRIMARY display. [[pod-hardware]] §ALPHA-MR
|
||||
|
||||
⚠ **Ordering trap found live:** the letterbox flag was first set at device creation, but the
|
||||
first `WM_SIZE` arrives BEFORE the device exists — a `-fit` boot logged `aspect=3.14` (the
|
||||
|
||||
@@ -311,6 +311,48 @@ resolved with gait v5, no shadow code changed — though (a) may have contribute
|
||||
T4]; (c) genuine slope burial when the quad is flat/mis-tilted (the depth-bias margin is finite).
|
||||
Check `[shadowobj]` tag lines and `[sync]`/`BT_SYNC_LOG` before touching bias/tilt.
|
||||
|
||||
## CROUCH -- COMPLETE 2026-08-06 [T1 decode / T2 full-cycle + MP benched]
|
||||
The manual's CROUCH button (streamed 0x13; glass key F4), end to end: DuckRequest (mech id 0x1a
|
||||
@0049fa00) latches duckState@0x398 -> the master posture block (mech4, from the dark master-perf
|
||||
@0x4a9cf0-0x4aa0af): posture selector @0x3f8 (mode 9/10/2/3/4 block, NOVICE simLive lockout,
|
||||
myomer-alive gate) -> standing: SetLegAnimation(2) ('sqd', squat down; LegClipFinished case-2
|
||||
PARKS the pose + leg alarm 1) / ducked: SetLegAnimation(3) ('squ', rise; case-3 alarm 0);
|
||||
stability alarm flips; ForceUpdate(8)+(1) ships the type-3 state record -- peers pose the squat
|
||||
for FREE (screenshot receipt: the crouched crimson madcat from the observer's cockpit,
|
||||
duckmpA_031). Clip data verified from BTL4.RES (root joint drops -2.22, knees +-1.14; squ/sqd
|
||||
are exact mirrors; interior 'squi/sqdi' variants ship for all 8 chassis). Full solo cycle
|
||||
benched (BT_BTNTEST + BT_BTNTEST2): squat -> hold (joints steady for minutes) -> rise ->
|
||||
standing zeros. Myomer factor: LIVE, not deferred -- the 2026-08-05 "feeder unreconstructed" claim was an
|
||||
export-gap-blind grep (the members are NAMED: speedEffect@0x31C, myomers.cpp; the chain walk +
|
||||
speedDemand scale + turn freeze = mechmppr.cpp:990, the 2026-07-31 seek audit, same bytes).
|
||||
mechmppr now publishes the chain MAX into mech->myomerEffectiveness (the binary's +0x79C home)
|
||||
so the crouch gate reads the live value: dead/overheated myomers (the Oracle seek-4 freeze)
|
||||
refuse the squat AND the rise.
|
||||
The pilot's EYE drops too -- the task-#15 bob channel (gBTEyeBobY <- jointlocal.y each frame)
|
||||
carries the parked -2.22 into DPLEyeRenderable (log receipt bob=-2.2187 through the hold; frame
|
||||
receipt duckcp_030 = grass-level view). A false "eye does not drop" residual was briefly filed
|
||||
2026-08-06 -- measurement error (canopy-dominant diff crops; the canopy drops WITH the eye, so
|
||||
the interior looks unchanged and only the through-window ground shifts). Bench traps for reruns: MP button delivery can miss if pressed
|
||||
during round-start jitter (press >= poll 900 after GO); diags all BT_DUCK_LOG-gated.
|
||||
|
||||
### CROUCH addendum -- the #60 re-export pass (2026-08-06) [T1]
|
||||
The rebuilt export (93.5% coverage) produced pseudocode for the master-perf region this block
|
||||
was hand-disassembled from, and it **confirms the transcription field for field**:
|
||||
`param_1[0xfe]`=mapPosture(0x3f8), `[0xe6]`=duckState(0x398), `[0x161]`=squatCapable,
|
||||
`[0x1e7]`=myomerEffectiveness(0x79c), `[0x10]`=movementMode, `player+0x25c` novice gate,
|
||||
`FUN_004a7fc4(2|3)`=SetLegAnimation sqd/squ, `FUN_004a4c54(8)+(1)`=ForceUpdate, `+0x131`
|
||||
stability alarm, duckState cleared after. (Ghidra renders these as int-ARRAY indices --
|
||||
`[0xfe]` == byte 0x3f8 -- which is why an offset-string grep of the export misses them; see
|
||||
[[reconstruction-gotchas]] §20.)
|
||||
|
||||
**It also caught a branch the raw pass missed** (the hand disassembly stopped at `0x4aa0af`):
|
||||
an **AIRBORNE AUTO-RISE** immediately after the duck consumer --
|
||||
`if ((movementMode == 3 || movementMode == 4) && legState == 1) -> SetLegAnimation(3) +
|
||||
ForceUpdate(8)+(1) + stability 1`. A mech that leaves the ground (jump/knockback) while PARKED
|
||||
in the squat is forced upright; unconditional on duckState/squatCapable, i.e. a safety
|
||||
un-crouch rather than a request. Implemented in mech4.cpp the same day; crouch re-benched
|
||||
un-regressed (squat still holds on flat ground, no spurious rises).
|
||||
|
||||
## Key Relationships
|
||||
- Detail: `docs/P3_LOCOMOTION.md`. Uses: [[asset-formats]] (SKL/ANI), [[decomp-reference]] (offsets).
|
||||
- Feeds: [[combat-damage]] (collision→damage), [[rendering]] (shadow/visual-conform).
|
||||
|
||||
+14
-2
@@ -70,6 +70,13 @@ emulator** (⚠ `NotationFile::ReadText` expects NUL-SEPARATED lines). [T2]
|
||||
console LAUNCH). SIX bugs fixed to get here (dead-reckoner install, replicant-motion DeadReckon,
|
||||
master emission threshold, emission gated on RunningMission, the console-must-LAUNCH fact, replicant
|
||||
validity). [T2]
|
||||
- **Zone-level replication (#87, 2026-08-03):** an observer's copy of a mech learns zone damageLevels
|
||||
ONLY from zone update records, and the master sends those on BAND-THRESHOLD CROSSINGS (binary effect
|
||||
watcher `FUN_0042aa2c`: `DescriptorCrossed` + master gate → `ForceUpdate(DamageZoneUpdateModelFlag)`)
|
||||
plus every graphic-state change — never per hit. Damage messages do NOT echo locally (replicant
|
||||
`Dispatch` forwards to the master and returns). The port had only the gstate branch (observers saw
|
||||
0.0 until destruction); the level branch was restored + 2-node verified (replicant peaks == master
|
||||
finals to 4 decimals). Full mechanism: [[combat-damage]] §Zone-LEVEL replication. [T1/T2]
|
||||
- **Wire-format bug class found+fixed:** MakeMessages replicate RAW over TCP, so string payload must be
|
||||
INLINE (`char[N]` at the binary offsets), not a `const char*` pointer (garbage cross-pod). Check
|
||||
EVERY MakeMessage for pointer payloads. [T2]
|
||||
@@ -874,8 +881,13 @@ transition, HUD all landed since P6): console egg → mesh → RunningMission on
|
||||
REMAINING note is closed.
|
||||
5. ✅ **2-window driving — RESOLVED (tasks #48/#51).** Keyboard MP turning was restored at the root
|
||||
(the `pilotArray[1]` shrunk-span overrun fix, task #51); `BT_KEY_NOFOCUS` exists for automated
|
||||
harnesses. DEATHS scoring lights via the existing BTPostKillScore MP branch. Respawn is
|
||||
reconstructed (task #52 — see item 3b).
|
||||
harnesses. DEATHS scoring lights via the kill/death report flow (2026-08-05: now the AUTHENTIC
|
||||
report tail in the victim's TakeDamage handler — BTMechPostCombatReports, replacing
|
||||
BTPostKillScore; same cross-node reroute, see [[combat-damage]] §report tail). Respawn is
|
||||
reconstructed (task #52 — see item 3b; the VehicleDead sender moved to the same tail).
|
||||
CROUCH and the SEARCHLIGHT replicate with ZERO new wire code (2026-08-05/06): the squat rides
|
||||
the existing type-3 state record (legState) and the lamp rides subsystem record 0x14
|
||||
(lightState) — [[locomotion]] §CROUCH / [[rendering]] §SEARCHLIGHT.
|
||||
|
||||
## Key Relationships
|
||||
- Base: [[wintesla-port]] (L4NET). Depends on: [[locomotion]] (update writer), [[combat-damage]]
|
||||
|
||||
+102
-19
@@ -22,6 +22,20 @@ authentic path scoped.
|
||||
further assets exist; the runtime `BTL4.RES` with 8 maps + full anim set is the master we have).
|
||||
- Pod specifics for Phase 8: the 7-monitor driver setup, the RIO cockpit I/O protocol, current
|
||||
Win10+wrapper pod config. Status: OPEN.
|
||||
- **The i860 DIVISION-CARD firmware / DIV render docs (the material→pixel response curve).**
|
||||
Established 2026-08-03 (#87 dig): the HOST binary lerps 13 material colour floats linearly
|
||||
toward ×0.1 by zone damageLevel (watcher @004573e4/@00457784, 0.1 literal at the
|
||||
MakeMechRenderables call site) and then just **marshals the material to the card**
|
||||
(`FUN_0048d4d4` = "flush_material", dpl command opcode 100 — siblings flush_ramp/flush_texture).
|
||||
The card-side shading (how material colour selected/scaled the 256-row material ramps → final
|
||||
texels) is i860 firmware, NOT in BTL4OPT.EXE — so the PERCEPTUAL damage-darkening curve of the
|
||||
1995 pods cannot be recovered from this binary. Our port applies the host's linear factor at
|
||||
draw (TFACTOR modulate, L4D3D). Field testimony ("a single missile leaves visible armor
|
||||
damage") is arithmetically consistent with the linear curve — one 25-pt hit snaps a 68-90-pt
|
||||
zone by 0.28-0.37 (measured live: amt=25 → rarm 0→0.3247) — but if the card's ramp mapping was
|
||||
steeper than linear, mid-level creep damage read stronger on the pods than in the port.
|
||||
ASK: div-card firmware image, DIV/dpl render docs, or period screenshots showing a
|
||||
known-damage-level mech. Status: OPEN.
|
||||
- **Did the pod throttle quadrant have MECHANICAL detents** (how many / lever positions)? The
|
||||
software path is notch-free [T1, [[pod-hardware]]], but the gait SM's walk/run dead band
|
||||
([[locomotion]]) only makes sense if the hardware discouraged parking the lever mid-band —
|
||||
@@ -210,16 +224,22 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
||||
⚠ This entry was stale in BOTH directions for weeks (`docs/INPUT_PATH_AUDIT.md` flagged it) —
|
||||
most of the list had been reconstructed while the prose still called it dead. Check the code,
|
||||
not the census, before reconstructing anything here.
|
||||
- **`DuckState` has no CODE consumer, and that is authentic [T1].** `Mech::DuckRequest` sets
|
||||
`duckState`(attr 0x37, binary `mech+0x398`) to 1 and nothing else: the flag has exactly two
|
||||
writers in the whole binary (that handler and the mech reset) and ZERO readers. Its consumer is
|
||||
a DATABINDING — `content/GAUGE/L4GAUGE.CFG` drives a 3-frame `bduck.pcc` widget off `DuckState`
|
||||
on the map's legend column, verified live (the crouch icon lights grey→orange on a press). So
|
||||
the button is COMPLETE as a request flag + indicator. What is NOT known: whether the 1995 game
|
||||
ever consumed it for posture/collision (no SQUAT clip name survives in the decomp or in
|
||||
`content/`, only `DuckServo01.wav` in AUDIO1.RES). Do not invent a crouch pose to "finish" it.
|
||||
- **Searchlight-driven fog swap — STILL DEFERRED, but the "ORIGINAL 1995 LATENT BUG" premise is
|
||||
❌ RETRACTED (2026-07-25, #61).** The arcade swaps fog between `fog=` (lights on) and
|
||||
- **❌ RETRACTED (2026-08-06): "`DuckState` has no CODE consumer, and that is authentic [T1]".**
|
||||
That verdict — and its corollary "no SQUAT clip survives; do not invent a crouch pose" — was
|
||||
EXPORT-GAP BLINDNESS ([[reconstruction-gotchas]] §20, incident 2), and the [T1] tag was
|
||||
unearned: "ZERO readers" was true of the *export*, not the binary. The reader is the master-perf
|
||||
posture/duck block in the un-exported region (@0x4aa011-0x4aa0af, raw disasm), and the squat
|
||||
clips DID ship — `<pfx>squ/sqd` + interior `squi/sqdi` for all 8 chassis live in **BTL4.RES**
|
||||
(the old claim searched decomp strings + loose `content/` files, never the RES TOC;
|
||||
`DuckServo01.wav` was the tell). CROUCH is now fully reconstructed — request latch → posture
|
||||
arbiter (novice + myomer + mode gates) → squat/hold/rise, MP-replicated on the type-3 record
|
||||
([[locomotion]] §CROUCH). The `bduck.pcc` legend databinding observation stands and still works.
|
||||
- **✅ Searchlight-driven fog swap — DONE 2026-08-05 (412053d/b75bb4a; [[rendering]] §SEARCHLIGHT).**
|
||||
The port's `TickSearchlight` (btl4vid) is the `PullFogRenderable` equivalent: LightOn →
|
||||
`SetFogStyle(searchLightOn/OffFogStyle)` with the binary's inverted-cache seed (authentic DARK
|
||||
night start), plus the external spot.bgf beam cone (btfx `brighten` additive veil) on the
|
||||
searchlight site joint, MP-replicated. Historical attribution notes kept below — the
|
||||
"ORIGINAL 1995 LATENT BUG" premise was ❌ RETRACTED 2026-07-25 (#61).** The arcade swaps fog between `fog=` (lights on) and
|
||||
`nosearchlightfog=` (off) via `PullFogRenderable` watching the Searchlight's `lightState`.
|
||||
**The old entry claimed the 1995 binary itself could never light the lamp. That was wrong** — it
|
||||
compared Searchlight's Performance (@004b841c, reads `requestedOn`@0x1E0) against **ThermalSight's**
|
||||
@@ -229,15 +249,10 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
||||
un-pooled `"ToggleLamp"` strings adjacent to their own class names. Body: @004b838c sits in a Ghidra
|
||||
export gap (#60) but was **recovered by raw disasm** (`scratchpad/dis838c.py`) — it toggles 0x1E0 and
|
||||
carries no novice gate. All T1.]
|
||||
Searchlight's handler set is now WIRED and **verified live**: pad `0x14` → `requestedOn 0→1` →
|
||||
`lightState 0→1`. So the sim needs **no repair** and the previous "DECISION: faithful to the buggy
|
||||
original" is void — a working fog swap is now plain FAITHFUL reconstruction. Remaining work is a
|
||||
single item: construct `PullFogRenderable` at btl4vid.cpp `MakeMechRenderables` reticle-build/inside
|
||||
pass (== arcade part_014.c:5173, Dynamic, bound per Searchlight `lightState` via a new
|
||||
`LightStatePtr()` accessor). `ControlsAllowLights()` is WIRED since issue #2 to the `player+0x25c`
|
||||
not-novice experience flag via the BTPlayerExperienceSimLive bridge ([[experience-levels]]).
|
||||
See [[rendering]] fog section. (The pre-#61 "verified inert live: BT_FOG_LOG zero `SetFogStyle(2/3)`"
|
||||
observation still holds — reason (1), the un-constructed renderable, remains.)
|
||||
Searchlight's handler set is WIRED and **verified live**: pad `0x14` → `requestedOn 0→1` →
|
||||
`lightState 0→1`; the fog watcher + beam cone landed 2026-08-05 (see the header above), so no
|
||||
remaining work rides this entry. (NB the 1995 searchlight carries NO novice gate — that
|
||||
lockout is ThermalSight's; raw disasm @004b838c.)
|
||||
- **`HandleMessage` is vtable slot 8/9 in the binary but NON-virtual across the reconstruction --
|
||||
FILED AS GITEA #65
|
||||
(2026-07-25, found via #46).** Ten classes declare it (ammobin/heat×2/hud/mechsub/myomers/
|
||||
@@ -694,6 +709,22 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
||||
destroyed-subsystem dark-panel look.
|
||||
|
||||
## Locomotion / combat polish (non-gating)
|
||||
- **Myomer climb term is DEAD in the port: the gravity operand samples 0** (found in the #96
|
||||
cruise benches, `[myoheat] g=0` every line — `BTMechMyomerMotionSample` reads
|
||||
`GetEnvironment()->gravityConstant` and gets 0; the binary reads `**(mover+0x250)`, a POINTER
|
||||
the Mover holds to the live gravity cell). Harmless on flat ground (|vy|≈0 anyway) but the
|
||||
climb-work heat never accrues on slopes. Find where our Environment gravity actually lives
|
||||
and re-point the bridge. [T2 measured]
|
||||
- **Engine Mover gravity is dt-LESS per-frame in the binary** (`@0x421e77: vy -= **(+0x250)`,
|
||||
raw, once per Perform — same class as the myomer kinetic term). If our linked WinTesla
|
||||
MOVER.cpp kept that form, gravity acceleration in the port scales with OUR ~59Hz frame rate
|
||||
(≈2.1× the pod's 28) — jump arcs, falls, missile droop all stiffer than the pod. CHECK the
|
||||
WinTesla source's gravity line; if per-frame, it needs the same 28Hz reference-rate
|
||||
normalization the myomer kinetic term got. [T1 binary side / T4 port impact until read]
|
||||
- **Gait-noise phantom acceleration feeds the myomer accel term** (`[myoheat] a=4..33` at
|
||||
steady cruise — our per-frame velocity re-derivation jitters; the binary read the same
|
||||
`AccelerationLastFrame` cell off ITS OWN gait, noise level unknown). Bounded contributor
|
||||
(~25-40% of cruise heat-in); revisit only if the 28Hz-faithful feel is contested. [T3]
|
||||
- **✅ Interior vs exterior gait CLIP SET — DONE 2026-07-13 (task #59) [T2].** The local cockpit
|
||||
mech was leaning −8° into every walk because the authentic ctor clip-set gate
|
||||
(@part_012.c:10308-10320) was a no-op (`LoadLowDetailBody`/`LoadHighDetailBody` mislabeled the
|
||||
@@ -901,6 +932,28 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
|
||||
but is never CALLED, like the salvo mirror was thought to be, or a missing ForceUpdate). A
|
||||
LIVENESS audit (does a live call path reach each reconstructed fn?) would catch this class.
|
||||
|
||||
## Score/death report tail residuals (2026-08-05, #45/#134 reconstruction — the tail itself is DONE + benched, [[combat-damage]])
|
||||
- **Mech-table 0x17 "SetBurningState" label/body MISMATCH.** The recovered name row says
|
||||
Set/ClearBurningState for 0x17/0x18, but @0x49f674's body randomizes the mech's position from
|
||||
two scaled randoms, rebuilds localToWorld, sets graphicAlarm level 2, ForceUpdates and clears
|
||||
the not-simulated flag — nothing burns. Possibly a shifted name row (cf. the factory ClassID
|
||||
mislabels). Neither handler is reconstructed (nothing in the port sends mech-0x17/0x18);
|
||||
identify the real bodies before wiring. The DEATH-tail id-0x17 message that was once labeled
|
||||
"SetBurningState dispatch" is RESOLVED: it is Player::VehicleDeadMessage (player table, not mech).
|
||||
- **Does @0x4c05c4 (VehicleDead handler) CONSUME the BT extension fields** (+0x2c killed-by
|
||||
player, +0x34 kill zone)? The function is not in the export; our #52 reconstruction predates
|
||||
the fields. If it reads them, the likely consumer is a killed-by console post
|
||||
(ConsolePlayerMechDeathWithoutHonor / a killed-by line) + maybe an RP-style "DestroyedBy"
|
||||
status banner on the victim. The port SENDS them faithfully; raw-disasm the handler to close.
|
||||
- **Collision-death tail fallthrough is inspection-tier [T3]:** the divert now falls to the death
|
||||
tail (VehicleDead + blast) per @0x4a0375, but no live wall-death bench has run; field
|
||||
wall-deaths exercise it nightly (watch for DEAD_NOTIFY on collision deaths).
|
||||
- **The 1995 type-0 uninit-award bug** (documented in [[decomp-reference]] §BTPlayer table): the
|
||||
shipped binary folds an uninitialized stack float into the shooter's score on every non-lethal
|
||||
hit's type-0 report. Era scoreboards looked sane — worth understanding WHY (dispatch path may
|
||||
deterministically leave ~0 at [ebp-0xc]; or replicant-player reroute delivery differs). Port
|
||||
deviates safely (award=0). Curiosity, not a blocker.
|
||||
|
||||
## 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
|
||||
@@ -967,6 +1020,25 @@ stability problems or massive lag spikes"*. Relevant to the carried cross-fire-s
|
||||
silent-node-crash opens: neither reproduced at 5 players this session. Not evidence of a fix
|
||||
(neither was addressed), but it bounds how often they occur.
|
||||
|
||||
## Post-re-export leads (2026-08-06, #60 closed — [[source-completeness]], `phases/phase-04-gap-census.md`)
|
||||
- **`@0x4c0904` is the MASTER BTPlayer Performance — our `@0x4c083c` attribution needs a
|
||||
re-check.** Newly lit by the re-export: it resolves the player's Team by name out of the
|
||||
`Teams` registry (error string "Team ... does not exist"), calls `@0x4c083c` and the engine
|
||||
`Player::PlayerSimulation` (`@0x42e100`), then at mission-end (state 4, past a threshold,
|
||||
once) posts "Sending EndMission" to the console host, and on a timer posts the periodic
|
||||
console SCORE update (`Round(currentScore)` — corroborating our hand-built scoreboard
|
||||
heartbeat). btplayer.cpp currently documents `@0x4c083c` as PlayerSimulation; read both and
|
||||
fix the attribution + fold in the team resolution (we have no Team-by-name resolve today).
|
||||
- **`0x480a2a-0x487ca0` (~9 KB, l4splr|btmssn boundary) stayed dark THROUGH the re-export** —
|
||||
the gap fill reached it with neither a call nor a data reference, which is itself a finding:
|
||||
dead code, data misparsed as code, or reachable only through a jump table. The earlier
|
||||
spot-check showed a real `enter 0x3c` function driven by a global-index dispatch table
|
||||
(`[0x4fef88]` → table `0x501588`), so "dead code" is unlikely. Byte-scan for the table's
|
||||
writers to find the entry path.
|
||||
- **~41 KB is still dark overall**, mostly munga/munga_l4 (we compile the real engine source, so
|
||||
low value). Current map: `reference/decomp/GAP_CENSUS.md`; re-run the pipeline after any
|
||||
future re-export.
|
||||
|
||||
## Key Relationships
|
||||
- Feeds from: every subsystem/render topic (their deferral notes collect here).
|
||||
- Gating master: [[source-completeness]] (the missing BT source).
|
||||
@@ -1079,3 +1151,14 @@ tuning from taste into arithmetic.
|
||||
Conn Man saw heat build-up in Standard while Rajel expected none there. See
|
||||
Gitea #116 and `experience-levels.md`.
|
||||
|
||||
|
||||
## Searchlight residuals (2026-08-05 — feature LIVE, [[rendering]] §SEARCHLIGHT)
|
||||
- **Cone look pass pending**: spot.bgf hangs on the site joint with the site's rest transform;
|
||||
the binary's per-case offset (local_2f0 at @004cef28 case 0xbd8) is believed to be the same
|
||||
site offset [T3] — eyeball the size/aim on madcat/loki at night, then clear this.
|
||||
- **BTL4VideoRenderer::LoadObject (@00498448) is a btstubs no-op** — the MechMarker beacon case
|
||||
(MakeEntityRenderables 0xBBA) silently gets NULL and has never drawn. The searchlight went
|
||||
around it (d3d_OBJECT::LoadObject direct). Reconstruct the wrapper or migrate the marker.
|
||||
- **des_night-family pages have subtle fog deltas** (both sets near-black); arena/polar night
|
||||
pages carry the dramatic 5↔40 near-plane swing. If testers report "searchlight does nothing"
|
||||
on some maps, it's page authoring, not the mechanism.
|
||||
|
||||
+218
-6
@@ -54,9 +54,10 @@ button** (Reservoir InjectCoolant, hold-to-flush — works), **0x2F/0x2E/0x2D/0x
|
||||
the per-condenser VALVE buttons** (MoveValve, Cond1-6 — work), **0x1A-0x1D = Generator A-D
|
||||
ON/OFF** (`ToggleGeneratorOnOff` id 4, binary table @0050fb90 fn @004b1ed0 — ✅ **WIRED**,
|
||||
`powersub.cpp`). Newly decoded from the binary message tables: **0x13 → Mech `DuckRequest`
|
||||
(0x1a @0049fa00 — the manual's CROUCH button)** — ✅ **WIRED 2026-07-26**, the last handler in
|
||||
this census; press-only, sets `duckState` and the map legend's `bduck.pcc` widget lights (see
|
||||
[[open-questions]] for why it has no code consumer) —, **0x28 → Mech `BalanceCoolant` (0x16
|
||||
(0x1a @0049fa00 — the manual's CROUCH button)** — ✅ **COMPLETE 2026-08-06**: handler (07-26) +
|
||||
the master-perf posture CONSUMER + squat/rise clips + MP replication ([[locomotion]] §CROUCH;
|
||||
glass key F4); the map legend's `bduck.pcc` widget still lights. (The old "no code consumer"
|
||||
verdict was export-gap blindness — [[reconstruction-gotchas]] §20) —, **0x28 → Mech `BalanceCoolant` (0x16
|
||||
@0049f728)** ✅ **WIRED 2026-07-21 (#20)**, **0x12 → ThermalSight `ToggleLamp` (id 3, table @0x51120C fn @004b860c)** and **0x14 →
|
||||
Searchlight + Searchlight2 `ToggleLamp` (id 3, table @0x51117C fn @004b838c)** — ✅ **BOTH WIRED
|
||||
2026-07-25 (#61)**, previously default-constructed blackholes; verified live (0x14 →
|
||||
@@ -183,6 +184,212 @@ All cockpit surfaces are bit-plane MASKS over ONE shared `SVGA16` pixelBuffer: `
|
||||
byte; `Heat`=0x4000, `Mfd2`=0x0400, `Comm`=0x8000, `Mfd1`=0x0100, `Mfd3`=0x1000; `Eng1-3` =
|
||||
engineering-mode alt planes; `overlay`=0x00C0 (shares the sec surface). See [[gauges-hud]]. [T2]
|
||||
|
||||
## ⭐ THE RGB SPLIT — how ONE VGA port drives THREE mono MFDs (decoded 2026-08-06) [T0 engine source + T1 authentic pod config]
|
||||
The five monochrome MFDs are NOT five video outputs. Each VGA port's **R, G and B analog lines
|
||||
are split to three separate monochrome monitors**, and the software puts a different MFD in each
|
||||
colour channel of one shared palettized framebuffer. Mechanism, end to end:
|
||||
|
||||
1. **Every surface is a bit-plane + a CHANNEL.** `content/GAUGE/L4GAUGE.CFG` (the authentic 1996
|
||||
pod config) configures each port as `configure(idx, port, rotation, bitMask, clut, COLOUR, palette)`:
|
||||
| port | panel | mask | clut | channel |
|
||||
|---|---|---|---|---|
|
||||
| `Comm` | upper right | 0x8000 | clut2 | **red** |
|
||||
| `Mfd2` (Engineering) | upper centre | 0x0400 | clut2 | **green** |
|
||||
| `Heat` | upper left | 0x4000 | clut2 | **blue** |
|
||||
| `Mfd1` | lower left | 0x0100 | clut1 | **red** |
|
||||
| `Mfd3` | lower right | 0x1000 | clut1 | **green** |
|
||||
| `sec` (+`overlay` 0x00C0) | secondary/radar | 0x003F | clut0 | **rgb** (full colour, rotation 270 — the physically ROTATED portrait CRT) |
|
||||
`Eng1/2/3` are the engineering-page twins of Mfd1/2/3: same monitor, second bit-plane, switched
|
||||
by `reconfigure(...)` giving one plane the channel and the other `blank`.
|
||||
2. **The channel assignment is literally a palette write.** `L4GraphicsPort::BuildSecondaryColor`
|
||||
(L4VB16.cpp) walks the palette entries owned by the port's bit group (`BitWrangler(byteMask,8)`)
|
||||
and writes ONE component: `RedChannel -> triplet->Red`, `GreenChannel -> ->Green`,
|
||||
`BlueChannel -> ->Blue`, `AllChannels -> the whole triplet`. `BlankColor` blanks the group
|
||||
(`BlankPalette()`), which is how a page swap silences the plane it replaces. The
|
||||
`*TransparentZero` variants skip colour 0 so zero reads as transparent for that group.
|
||||
3. **So the DAC output carries three independent pictures**, one per analog line, and the splitter
|
||||
hands each line to its own mono monitor. Three MFDs per VGA port; the pod's two MFD ports are
|
||||
the **1280x480 "horizontally spanned" surface** (2 x 640x480 halves) the Displays section
|
||||
describes — clut2 = the upper row (Comm/Mfd2/Heat), clut1 = the lower row (Mfd1/Mfd3, blue
|
||||
spare). The radar rides its own port in real colour.
|
||||
|
||||
**Why this matters for the port [T2]:** our modern path renders each surface as its own
|
||||
mono-tinted window on its own Windows display (see §MFD PANELS ON REAL HARDWARE), which is right
|
||||
when every panel has its own output. **On splitter-wired glass it is wrong** — three monitors
|
||||
would share one Windows display and each would show only its channel's share of a single tinted
|
||||
image.
|
||||
|
||||
**`BT_POD_RGB=1` — the CHANNEL-COMPOSITE mode (built + field-verified 2026-08-06) [T2].** It
|
||||
collapses the six panel windows into **three**: two VGA-port windows plus the radar. Each port
|
||||
window composites its group's planes into ONE 640×480 BGRA image, each member ORed into its own
|
||||
channel mask (`0x00FF0000` / `0x0000FF00` / `0x000000FF`), so the analog splitter downstream hands
|
||||
each mono CRT exactly its picture. Grouping follows `L4GAUGE.CFG`: *VGA Port A* = Heat + Comm +
|
||||
Mfd2, *VGA Port B* = Mfd1 + Mfd3; the radar keeps real colour on its own port.
|
||||
`SVGA16::ExpandPlaneToBGRA` does the per-plane extraction, and `BT_POD_RGB` implies
|
||||
`BT_POD_SURFACES`. Two companion gates came out of the bring-up: **`BT_POD_CHANMAP=<Port>=<colour>,…`**
|
||||
remaps a surface's colour line when a cab's harness disagrees with the 1995 config (it did — see
|
||||
§ALPHA-MR), and **`BT_POD_IDENT`** / **`BT_POD_CHANTEST`** paint identifying/cycling test patterns
|
||||
so you can tell which physical CRT is which without trusting the desktop layout.
|
||||
**SETTLED:** Nick's cart IS splitter-wired — its three 640×480 "displays" are VGA outputs, each
|
||||
feeding a three-monitor splitter, and the composite is what lit the glass correctly.
|
||||
|
||||
## MFD PANELS ON REAL HARDWARE — the bring-up path (2026-08-06) [T2 local / T4 on-pod]
|
||||
Nick's crash cart (pod hardware + Chrome Remote Desktop on a burner account) is the first chance
|
||||
to drive the real panels. **The 1995 display path is NOT the way in.** That rig spanned the five
|
||||
MFDs as ONE 1280×480 surface via **NVIDIA Horizontal Span, which every driver after XP dropped**;
|
||||
`SVGA16::BuildWindows` also wants an exclusive-fullscreen D3D device per adapter, which is
|
||||
fragile on modern drivers and over a remote session. Both are still in-tree and still the
|
||||
authentic reference — they are just not the bring-up route.
|
||||
|
||||
**The route is the glass per-display windows** ([[glass-cockpit]]): one window per surface, which
|
||||
maps 1:1 onto one physical panel per MFD, needs no special driver, and is already field-proven on
|
||||
desktops. Two pieces were added for the cab:
|
||||
- **`BT_POD_SURFACES=1` (pod surface mode, L4GLASSWIN)** — crops every window to its SURFACE
|
||||
(MFDs exactly 640×480, radar 480×640 portrait), drops the on-screen RIO button banks (the cab's
|
||||
buttons are PHYSICAL — drawing fake ones over a real panel is precisely wrong), goes frameless,
|
||||
and does not create the Flight Controls pad at all (6 windows, not 7). Per-window equivalent:
|
||||
append `,bare` to a line in `glass_layout.cfg` (mixed rigs).
|
||||
- **Placement receipts** — every window logs `[glasswin] '<title>' surface=<port> at X,Y WxH
|
||||
bare -> monitor \.\DISPLAYn (origin WxH, PRIMARY)`. On a cab nobody can see seven surfaces at
|
||||
once, and over CRD you cannot see the panels at all: the log IS the confirmation that a picture
|
||||
landed on the right glass.
|
||||
|
||||
**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
|
||||
assigns the six surfaces to the non-primary monitors top-to-bottom/left-to-right, centred.
|
||||
2. Drop the cfg in the game's working directory; run with `BT_GLASS_PANELS=1
|
||||
BT_POD_SURFACES=1 BT_GLASS_LAYOUT=load` (`=save` to persist drags instead).
|
||||
3. Read the receipts; re-assign titles to monitors in the cfg until each picture is on its panel.
|
||||
Surface→panel roles: Heat MFD = upper left (coolant), Engineering = upper centre, Comm MFD =
|
||||
upper right (hot box), Left/Right Weapons = lower left/right, Secondary/Radar = the secondary
|
||||
screen (portrait). Main 3D view stays the game's own window on the main-view monitor.
|
||||
|
||||
**Known constraints / open on-pod questions:** whether the panels are attached to this PC at all
|
||||
(the probe answers it); whether Windows offers a 640×480 mode on them (if not, the surface renders
|
||||
at native size CENTRED, not scaled — a scale-to-fit option is the obvious follow-up); whether
|
||||
Chrome Remote Desktop holds the CONSOLE session (it should — an RDP session would get a virtual
|
||||
display and light nothing); and the RIO serial input, which is a separate task from the displays.
|
||||
|
||||
## ALPHA-MR — the verified cart config, FROZEN (2026-08-06) [T2, field-verified by eye]
|
||||
The bring-up above landed: real pictures on real pod glass, then user-confirmed correct
|
||||
("ok this is correct"). What that took, and where it now lives permanently.
|
||||
|
||||
**The rig.** Win10 IoT Enterprise LTSC on the cart; Intel HD630 + a "Trigger 6 External Graphics"
|
||||
USB adapter. Four console displays: **DISPLAY3 800×600 PRIMARY at 0,0** (main view), plus
|
||||
**DISPLAY4 @800,122**, **DISPLAY2 @1440,123**, **DISPLAY1 @2080,124** (each 640×480). Physical
|
||||
cab: five mono MFD CRTs (two left stacked, two right stacked, one centre loose on the desk) plus
|
||||
the colour radar LCD in the yellow-button frame.
|
||||
|
||||
**The mapping** (`glass_layout.cfg`, `monitor:<name>` binding — see [[glass-cockpit]]):
|
||||
```
|
||||
Secondary / Radar = monitor:DISPLAY4,bare # colour LCD, yellow-button frame
|
||||
VGA Port A = monitor:DISPLAY2,bare # RGB triple: Heat + Comm + Mfd2
|
||||
VGA Port B = monitor:DISPLAY1,bare # RGB triple: Mfd1 + Mfd3
|
||||
```
|
||||
Three windows, not six — under `BT_POD_RGB` each VGA-port window carries three MFDs on its three
|
||||
colour planes (§THE RGB SPLIT). Desktop-coordinate order says NOTHING about physical cab position:
|
||||
the first assignment, made by sorting monitors left-to-right, was wrong. Only eyes on the glass
|
||||
settle it.
|
||||
|
||||
**Two deviations from the 1995 config, both real, both this cab's wiring:**
|
||||
- **`BT_POD_CHANMAP=Comm=blue,Heat=red`** — kills/deaths and coolant loops came up on each
|
||||
other's panel. `L4GAUGE.CFG` has Comm on red and Heat on blue; THIS cab's splitter harness
|
||||
wires them the other way. The env override remaps a port's colour channel without touching the
|
||||
authentic config file. [T2]
|
||||
- **`BT_GAUGE_SEC_ROT=0`** — the radar here is a LANDSCAPE LCD, so the 640×480 source needs no
|
||||
rotation. The 1995 pod's 270° rotation existed because its secondary CRT was mounted PORTRAIT;
|
||||
it is a mounting fact, not a format fact. (En route: 180° looked plausible and was upside down.)
|
||||
|
||||
**Frozen in `content/environ.ini`**, not in a launcher — `scratchpad/pod/podprofile.ini` is the
|
||||
block, `scratchpad/pod/mergeprofile.ps1` merges it idempotently between markers. environ.ini is
|
||||
read before anything touches the environment ([[build-and-run]]), and the real environment still
|
||||
WINS, so a .bat can override any of it for a one-off. The cart's `runpod.bat` now sets only
|
||||
`BT_LOG`/`BT_GLASS_LOG` and the `-egg`; `runpod_env.bat` keeps the all-inline version as the
|
||||
fallback. Two gates were added for this:
|
||||
- **`BT_FIT=1`** — the env spelling of `-fit`, so the borderless main view survives however the
|
||||
game is started (shortcut, scheduled task, autostart) instead of riding on one launcher's
|
||||
command line. It fits `MONITOR_DEFAULTTOPRIMARY` = DISPLAY3, which is the main-view panel.
|
||||
- **`L4PLASMA=NONE`** (also `OFF`/`0`) — no marquee at all. Needed because the GLASS profile
|
||||
force-defaults `L4PLASMA=SCREEN`, so simply leaving it unset still puts a desktop plasma window
|
||||
on the pod's glass. The boot banner reports `plasma off [L4PLASMA]` when it takes.
|
||||
Verified on build 4.11.813 with the launcher carrying none of it: `[boot] environ.ini: 9
|
||||
setting(s) applied`, `[cockpit] -fit: borderless 800x600`, `[glasswin] radar rotation 0 (none)`,
|
||||
and all three surfaces on their intended `\\.\DISPLAYn`.
|
||||
|
||||
**Surviving a NEW BUILD, not just a reboot.** Both frozen files live INSIDE the versioned install
|
||||
(`content\environ.ini`, `content\glass_layout.cfg`) and NEITHER ships in the zip — environ.ini is
|
||||
generated on first run. So extracting a new build gives a cab that comes up wrong with no error
|
||||
anywhere. The masters therefore live at the stable `C:\bt411\` (`podprofile.ini`,
|
||||
`glass_layout.cfg`, `podkit.ps1`), and **`setup_pod.bat` pushes them into the newest `BT411_*`
|
||||
folder** — run it once after each extract. `runpod.bat` resolves the newest install and applies
|
||||
the kit itself before launching, so the remote path needs no hand-editing per build. Re-tuning the
|
||||
cab means editing the MASTER at `C:\bt411\`, not the install's copy: the apply overwrites it, on
|
||||
purpose, so there is one place to look when a panel moves. Repo copies: `scratchpad/pod/`.
|
||||
|
||||
**The tester launchers work on the cab unchanged (verified 2026-08-06) [T2].** `play_solo.bat`,
|
||||
`join.bat`, `join_lan.bat`, `play_steam.bat` and `joyconfig.bat` set `BT_PLATFORM=glass`,
|
||||
`BT_START_INSIDE=1`, `BT_DEV_GAUGES=1` plus their own mode flag — and **not one pod key** between
|
||||
them. So each inherits the whole rig from environ.ini without knowing the pod exists. This is the
|
||||
argument against a separate pod-only settings file: a second file would need every launcher to opt
|
||||
in, which is the shape of the "competing keymaps" bug that made glass the default profile.
|
||||
Observed: `play_solo.bat` on the cart logs `environ.ini: 7 setting(s) applied` — 7 not 9, because
|
||||
the bat sets `BT_PLATFORM` and `BT_START_INSIDE` itself and the real environment wins.
|
||||
Steam MP needs the **client installed and logged in** (AppID 480/Spacewar, so any account, no
|
||||
purchase); installed on the cart 2026-08-06. ⚠ **#68: a failed JOIN/HOST exits the process
|
||||
SILENTLY** — "Steam not running" and a real crash look identical to a player.
|
||||
|
||||
### The HARDWARE RIO is live on the cart (2026-08-06) [T2]
|
||||
`L4CONTROLS=RIO:COM1,KEYBOARD` in the frozen profile — the real cockpit board instead of PadRIO.
|
||||
The whole stock path above the seam (mapper, lamps, streamed `.CTL` mappings) runs unchanged; the
|
||||
109-mapping `L4` control table installs exactly as it does on a desktop. **This is independent of
|
||||
the platform profile** — the cab keeps the GLASS display stack (panels + RGB split) and only the
|
||||
input device changes, which is what the glass/PadRIO seam was built for. Do NOT reach for
|
||||
`BT_PLATFORM=pod` to get it.
|
||||
|
||||
- **Wiring:** `RIO:COMn` → `\\.\COMn` at **9600 8N1** (`L4SERIAL`); bare `RIO` defaults to COM1.
|
||||
Reads are non-blocking (`ReadIntervalTimeout=MAXDWORD`, totals 0), so an absent or dead board
|
||||
cannot hang the game — it just goes quiet.
|
||||
- **RIO and PAD are MUTUALLY EXCLUSIVE** — both assign `rioPointer` in the `L4CONTROLS` parser, so
|
||||
the last token wins. Selecting the hardware RIO turns the XInput pad OFF on that machine.
|
||||
`KEYBOARD` is safe alongside: it only sets `flags.keyboardExists` and never touches
|
||||
`primaryControlType`.
|
||||
- **The board's own self-test is the diagnostic.** `RIO::RIO` runs it (`perform_tests = True` by
|
||||
default) and writes `content\FAILURE.LOG` pages `RIOBoardErrors` / `RIODeadLamps` / `RIOErrors`,
|
||||
deleting them first so the file is always current. On the cart, reproducibly: **4 missing boards**
|
||||
(`Slot=3:Address=0`, `3:2`, `4:0`, `5:0`) and **16 dead lamps** (`AuxUpperRight1-8`,
|
||||
`AuxUpperCenter1`, `TeslaRelay1/3`, `PanicButton`, `IcomAmpEnableRelay`, `IcomIncRelay`,
|
||||
`FloorEntry`, `undefined_0x17`) — the crash cart is a PARTIAL cab, and those are the boards it
|
||||
does not have. **A specific 4-of-many inventory is itself the proof the link is good**: a dead
|
||||
serial line reports the whole address space missing, not four slots.
|
||||
- **Input reaching the mech** was seen live as `[ctrlmap] push stick x=0.0595238 y=0` with nobody
|
||||
at the cab — the physical stick sitting just outside the 5% deadband. NB when the stick is
|
||||
centred this reads `x=0`, which is indistinguishable from no data: a centred-stick zero proves
|
||||
nothing either way. ⚠ **Not yet checked by hand:** that stick/throttle/pedal travel and the
|
||||
physical buttons drive the mech correctly, and whether the cab's pots match the `Ranger`
|
||||
calibration baked into `RIO::RIO` (`JoystickX -96..96`, `JoystickY -97..108`, `Throttle 0..800`,
|
||||
pedals `0..470`). A mismatch shows up as drift or short travel, not as an error.
|
||||
- The boot banner names the resolved device — `GLASS (hardware RIO; plasma off [L4PLASMA])`. It
|
||||
used to hardcode "PadRIO", which is a lie on a wired cab and exactly the line you read to check.
|
||||
- ⚠ **THE COUPLING BUG this exposed (fixed 2026-08-06) [T2].** `BTGlassPanels_Create()` was called
|
||||
ONLY from the end of the **PadRIO constructor** — the panels began life as the frames around the
|
||||
on-screen RIO button banks, so "the buttons ride the device". Selecting the hardware RIO means
|
||||
PadRIO is never constructed, so **every MFD window silently failed to appear**: the mission came
|
||||
up on the main view with all the pod glass dark, and not one `[glasswin]` line in the log. The
|
||||
MFD panels are a DISPLAY concern, so creation moved to `LBE4ControlsManager` after the
|
||||
`L4CONTROLS` parse, where every device branch converges — plus a symmetric `BTGlassPanels_Destroy()`
|
||||
in its destructor, because `~PadRIO` tore them down and `~RIO` knows nothing about them (windows
|
||||
outliving the surfaces they blit would crash on the next mission cycle). Both calls are
|
||||
idempotent (`Create` returns on `gWinCount != 0`), so the PadRIO path is unchanged and simply
|
||||
arrives first.
|
||||
|
||||
**Working on the cart, remotely.** SSH over Tailscale lands in **session 0**, which has a dummy
|
||||
"WinDisc" display and CANNOT see or enumerate session 1's windows — GUI work must go through
|
||||
`schtasks /run /tn BT411Run` (task registered `/IT`), and window enumeration over SSH silently
|
||||
returns nothing rather than failing. Kill `btl4.exe` BEFORE scp'ing a new exe or the file is
|
||||
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 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:
|
||||
@@ -231,9 +438,14 @@ every checked control behavior:
|
||||
"Top Speed Gimped 40" also confirms the limp-gait speed cap as an authored spec. The port
|
||||
follows the BINARY (house rule); resurrecting manual-4.0 supercharge would be an opt-in
|
||||
deviation for the operator to decide.
|
||||
NEW LEADS (manual describes, port lacks input/UI): **CROUCH** (button by the secondary screen;
|
||||
`Mech::duckState` attr 0x37 + SQUAT clips exist, nothing drives them), **EJECT** (sounds exist:
|
||||
EjectButton01_z*.wav), hot-box viewscreen framing (the deferred PNAME marker chain). Per-mech
|
||||
NEW LEADS (manual describes, port lacks input/UI): ~~CROUCH~~ ✅ **CROUCH COMPLETE 2026-08-06**
|
||||
([[locomotion]] §CROUCH), ~~EJECT~~ **EJECT WIRED
|
||||
2026-08-02** (core: `Mech::EjectPilotMessageHandler` id 0x19 @0049f854 + the crippled-mech
|
||||
permission evaluator @0049fa1c; input = binding-engine "Eject" action, Backspace / pad
|
||||
LeftThumb; the punch-out kills via graphicAlarm 10 ≥ 9 — KillBonus authors 0 in ALL shipped
|
||||
content, so the zero charge is authentic; tails: console relay notice, RIO 0x38 panic control,
|
||||
alarm-10 audio/canopy presentation, `SpecialCaseDeathPenalty` role+0x20 consumer), hot-box
|
||||
viewscreen framing (the deferred PNAME marker chain). Per-mech
|
||||
stat tables = a systematic cross-check source for our streamed subsystem resources.
|
||||
|
||||
### Coolant-loop cross-check RESULT (2026-07-18) [T1] -- STRUCTURE strongly faithful
|
||||
|
||||
+18
-13
@@ -39,19 +39,24 @@ sections in `docs/PROGRESS_LOG.md` cite old `C:/git/nick-games/...` paths — tr
|
||||
they map into this repo (reconstructed BT → `game/reconstructed/`, engine → `engine/MUNGA{,_L4}/`,
|
||||
content → `content/`, raw decomp → `reference/decomp/`). [T2]
|
||||
|
||||
## Current state (2026-07)
|
||||
`btl4.exe` boots, renders the world + a skinned mech, and runs a full **drive → animate → target →
|
||||
fire → damage → destroy → respawn** single-player loop (task #52). **2-node MP is verified
|
||||
end-to-end** — replication, cross-pod targeting/damage/kill, beam visuals, replicant gait (tasks
|
||||
#46-#51, [[multiplayer]]). All 8 cockpit canopies are authentic + the horizontal-FOV fix (task #55,
|
||||
[[cockpit-view]]); the Gyroscope is live byte-exact with hit-bounce (task #56); the **gauge system
|
||||
is complete** ([[gauges-hud]]). The engine/renderer/HAL are done (WinTesla); AUDIO backend is now REAL
|
||||
(the repo's OpenAL32.dll + libsndfile-1.dll were both no-op STUBS — replaced with real OpenAL Soft +
|
||||
an in-tree WAV loader, 2026-07-15) and the 241-sample soundbank is cracked from AUDIO1/2.RES + loading;
|
||||
the ONLY remaining audio gap is game-triggering (no AudioEntities fire PlayNote yet), so gameplay is
|
||||
still silent apart from a proof-of-life hook (see [[wintesla-port]] Audio). The active work
|
||||
is reconstructing each BT subsystem's authentic behavior from the binary. Remaining: pod-LAN
|
||||
config, Mech-level update records, per-subsystem waves. [T2]
|
||||
## Current state (2026-08-06) — core gameplay reconstruction COMPLETE; polish phase
|
||||
Field-tested nightly at 4-8 pods over Steam (builds 4.11.622→801). Authentic + benched: the full
|
||||
SP+MP loop; per-panel mesh-true targeting/damage (+ crits, cylinder lottery, armour darkening);
|
||||
the 1995 SCORING model (kill awards + received penalties + panic cost — the id-0x16 report tail);
|
||||
death (blast/splash, wreck, burial) + respawn (full re-arm audit); locomotion (two-channel gait,
|
||||
gimp limp, knockdowns, CROUCH F4); the night kit (fog-swap SEARCHLIGHT F5 + beam cone); heat/
|
||||
power/myomers (incl. the seek-4 freeze); replication (masters/replicants, kill/death columns,
|
||||
ghost+skate field detectors); the Steam wire seam + build gate; the operator console/relay
|
||||
lifecycle; gauges/HUD; audio (real OpenAL backend + the AUDIO_FIDELITY trigger waves — footsteps/
|
||||
gait/alarms/impacts live in the field). [T2]
|
||||
**Infra ✅ 2026-08-06:** the #60 export gap census AND re-export shipped — coverage 87.3% →
|
||||
93.5%, dark code 90 → 41 KB, +205 functions, and the re-export independently confirmed the
|
||||
week's raw-disasm reconstructions ([[source-completeness]], `phases/phase-04-gap-census.md`).
|
||||
**Remaining (polish):** VehicleDead killed-by consumption (operator "X killed by Y" — the sender
|
||||
already ships the fields); burning-wreck handler (mech-0x17 label mismatch); `@0x4c0904` master
|
||||
BTPlayer Performance vs our `@0x4c083c` attribution; the id-0x16 type-0 curiosity; MechRIOMapper
|
||||
Keypress @004d2514; marker beacon (LoadObject wrapper stub); DIV firmware intersection routine
|
||||
(deep-cut); deferred ledger items in [[open-questions]]. [T2]
|
||||
|
||||
## Key Relationships
|
||||
- Full detail: `docs/PROGRESS_LOG.md`.
|
||||
|
||||
@@ -278,6 +278,12 @@ Suspect ANY reconstructed per-frame code with narrow equality/window tests or `x
|
||||
state transitions: charge/seek loops, snap comparisons, timers compared with `==`.
|
||||
|
||||
## 13. Verification gotchas (don't fool yourself)
|
||||
- **Capture the viewpoint that can SEE the change.** The crouch "pose does not hold" and the
|
||||
false "eye does not drop" residual (2026-08-05/06) were BOTH capture errors: cockpit-view
|
||||
screenshots cannot show your own legs, and canopy-dominant diff crops mask eye-relative motion
|
||||
(the canopy drops WITH the eye — only the through-window ground shifts). Anchor pixel-diff
|
||||
crops on a region the effect MUST change, pair them with a state probe (joint values), and
|
||||
READ one frame with your own eyes before declaring a visual regression.
|
||||
|
||||
- **Lazy gauge build:** `GaugeRenderer::BuildConfigurationFile` runs LAZILY. A too-early process
|
||||
kill shows `[gskip]=0` / "not built" even though the widget is fine — **wait for the gauge
|
||||
@@ -543,6 +549,34 @@ segment tables) — the same class of latent overflow.
|
||||
0xFEEEFEEE=freed).
|
||||
5. For exhaustive multi-function analysis: a read-only Workflow (understand), then implement hands-on.
|
||||
|
||||
## 20. Export-gap blindness — absence in the EXPORT is not absence in the BINARY (4 incidents)
|
||||
The Ghidra export (`reference/decomp/`) has coverage gaps (#60), and BTL4.RES content never
|
||||
appears in it at all — so "no readers", "no caller", "no such clip/asset", and "unreconstructed"
|
||||
claims made by grepping the export or the port ALONE are structurally unsound. This class has
|
||||
produced four wrong conclusions, two of them [T1]-tagged at the time:
|
||||
1. **2026-07-25 searchlight "1995 latent bug"** — compared Searchlight's Performance against
|
||||
THERMALSIGHT's handler (wrong class) and invented a missing bridge; retracted (#61).
|
||||
2. **2026-07-2x `DuckState` "has no CODE consumer, authentic [T1]" + "no SQUAT clip survives"** —
|
||||
the consumer was the un-exported master-perf posture block (@0x4aa011), and the clips lived in
|
||||
BTL4.RES (`squ/sqd/squi/sqdi` × 8 chassis); the search covered decomp strings + loose files,
|
||||
never the RES TOC. Disproven by the CROUCH reconstruction (2026-08-06).
|
||||
3. **2026-07-31 morning: the myomer drive-scale DELETION** ("the binary has NO dynamic
|
||||
myomer→speed coupling") — the consumer was un-exported; restored same day by raw capstone.
|
||||
4. **2026-08-05 "the myomer factor FEEDER (@004b8be3) is unreconstructed"** — it had been fully
|
||||
reconstructed since 07-31 under NAMED members (`speedEffect`, `AvailableOutput`); the grep
|
||||
searched raw offsets. Cost: a duplicate multiply + a dead crouch gate until 08-06.
|
||||
**The rule — before claiming "X does not exist / is not reconstructed":**
|
||||
- byte-scan `content/BTL4OPT.EXE` for the offset/immediate (disp32 patterns), never just the export;
|
||||
- grep the port for NAMED members (check the .hpp for the offset's name) — offsets rot after promotion;
|
||||
- for content claims, walk the **BTL4.RES TOC** (`tools/resscan.py` pattern), not the loose tree;
|
||||
- check `reference/BT410_SOURCE_MANIFEST.md` + `game/reconstructed/CLASSMAP.md`;
|
||||
- and tag the claim's tier by the WEAKEST source consulted — an export-only sweep caps at [T4].
|
||||
The #60 gap census + RE-EXPORT is the systemic fix, and BOTH shipped 2026-08-06: the export
|
||||
now covers 93.5% of .text (dark code 90 KB → 41 KB) and `reference/decomp/GAP_CENSUS.md` maps
|
||||
what is still dark. **So the rule is cheap now: look the address up in GAP_CENSUS.md first.**
|
||||
If it is NOT in a dark region, the export is authoritative and "absent" means absent. If it IS,
|
||||
byte-scan before claiming anything. (Toolchain + method: `phases/phase-04-gap-census.md`.)
|
||||
|
||||
## Key Relationships
|
||||
- Applies to: every topic that reconstructs a class ([[subsystems]], [[combat-damage]], [[gauges-hud]], [[locomotion]]).
|
||||
- Uses: [[decomp-reference]] (offsets/ClassIDs), [[reconstruction-method]] (the loop).
|
||||
@@ -795,3 +829,37 @@ grep its helper types for `return 0` bodies FIRST; (b) a bring-up shim must
|
||||
either Fail() loudly or log its own emptiness, never silently iterate nothing;
|
||||
(c) before shimming an engine-side structure, check whether the engine already
|
||||
has it -- the offsets in the decomp comment ARE the lookup key.
|
||||
|
||||
## 27. Drawing where the design says DON'T: under-surface buttons, shared planes, and inherited D3D state (#118, 2026-08-03)
|
||||
|
||||
Three MFD-corruption incidents in one evening, all mine, all the same class:
|
||||
adding a DRAW to a compositor whose layout invariants I had not read.
|
||||
|
||||
1. **The eject-wipe gauge** (BitMapInverseWipeScalar) drew into a weapon's ENG
|
||||
graphics port -- but the Eng1-3 planes SHARE composite dest cells with the
|
||||
weapon pages (`ckSlotOf` in L4VB16), so its mis-geometry scribbled striped
|
||||
garbage OVER the weapon MFDs whenever an eject cycle redrew it (= at
|
||||
overheat-jam onset, which the operator correctly correlated with "when the
|
||||
coolant warning starts").
|
||||
2. **The "flash overlay"** re-drew flashing buttons' full faces ON TOP of the
|
||||
surfaces. But the side buttons are BIG rects deliberately tucked UNDER the
|
||||
displays -- the protruding edge IS the lamp (the pod's backlit keys sat
|
||||
beside the CRT; there is no in-display face). A full-face redraw is wrong
|
||||
BY DESIGN: it covers gauge imagery with button faces.
|
||||
3. **The same overlay, drawn after `DrawDevSurface`, inherited its bound
|
||||
texture** -- an un-reset `CkFill` renders a TEXTURED quad sampling the
|
||||
gauge atlas: green diagonal-striped rectangles. (With the state reset the
|
||||
bug became honest: solid flashing faces covering the MFD -- which is how
|
||||
the operator's "the panel buttons took over the MFD" report finally
|
||||
identified the author.)
|
||||
|
||||
Rules: (a) before ADDING a draw to an existing render pass, read the pass's
|
||||
layering comments -- "UNDER the surfaces", "masks the part inside", "shares
|
||||
its sibling's dest cell" are INVARIANTS, not descriptions; (b) any D3D draw
|
||||
inserted after another subsystem's draws must re-assert its own texture/color
|
||||
state (CkColorState-equivalent) -- the previous draw's bindings are live;
|
||||
(c) when a visual corruption tracks an EVENT ("starts with the coolant
|
||||
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.
|
||||
|
||||
+47
-1
@@ -335,7 +335,9 @@ the limb swap ("the actual enemy mech in external view is not showing darkened a
|
||||
- **`.DZM`** (`VIDEO\<mech>SKIN.DZM`, 40 shipped) lists, per damage zone, the materials that zone
|
||||
paints: `[dz_ltorso] material=avaskin:avat2_dz_ltorso_mtl`. One file per SKIN VARIANT — the
|
||||
`dzm`/`dzms`/`dzmt`/`dzmo`/`dzma`/`dzmb`/`dzmc`/`dzmd` key family (same suffix set as the
|
||||
`destroyed`…`destroyedd` video variants); the res compiler is `FUN_0041e4e0`.
|
||||
`destroyed`…`destroyedd` video variants); the res-compiler address is UNKNOWN (the old
|
||||
`FUN_0041e4e0` citation was WRONG — that is `DamageZone::TakeDamage`; corrected 2026-08-02,
|
||||
swept with [[decomp-reference]]).
|
||||
- Compiled into BTL4.RES and parsed by **MUNGA's own `DamageZone` stream ctor** into
|
||||
`materialTable`, keyed by `EntitySegment::SkeletonType` (`engine/MUNGA/DAMAGE.cpp:311-336`,
|
||||
`GetMaterialList(skl_type)`) — real engine source [T0]. **This data was loaded and unused in the
|
||||
@@ -377,6 +379,50 @@ mech, brightness tracking `1 - 0.9*level` exactly, hull 0.35x vs terrain control
|
||||
The `damcolor_`/`gen*gry_` arm+gun names go unmatched until the DESTROYED limb variant loads —
|
||||
correct: those materials live on the `*D*.BGF` damaged meshes.
|
||||
|
||||
## SEARCHLIGHT -- the pod's night kit, RECONSTRUCTED 2026-08-05 [T1 decode, T2 both benches]
|
||||
The subsystem (searchlight.cpp: sim @4b841c, ToggleLamp @4b838c toggles requestedOn@0x1E0 --
|
||||
NO novice gate, that's ThermalSight's; lightState = requested && powered(4) && heat<2 && alive,
|
||||
published as the "LightOn" attribute + replicated as subsystem record 0x14) was already complete;
|
||||
the VISUALS were the gap, decoded from MakeMechRenderables @004cef28 `case 0xbd8` [T1]:
|
||||
- **Cockpit = a FOG SWAP, not a light.** The @00456778/@00456814 watcher polls up to two LightOn
|
||||
attrs; either-on -> `SetFogStyle(searchLightOnFogStyle)` (the authored `fog=` set), off ->
|
||||
`searchLightOffFogStyle` (the `nosearchlightfog=` set). Both sets are authored per
|
||||
map/time/weather page in BTDPL.INI (arena_night 40/400 lit vs 5/400 dark; des_night 100/1100
|
||||
vs 30/700; arnitclear 90/1100 vs 5/1000). The 2007 engine kept the whole system under its real
|
||||
names (searchLightFog*/noSearchLightFog*, the FogStyle enum); the port completed the stubbed
|
||||
plane application (currentFogNear/Far now swap with the style) and transcribed the watcher into
|
||||
`TickSearchlight` -- including the ctor's INVERTED cache seed, whose first-tick "change"
|
||||
normalizes a night start onto the DARK set. Fidelity consequence: every prior build rendered
|
||||
the lit fog permanently (nothing called SetFogStyle(3)); authentic night starts darker.
|
||||
Glass key: F5 -> streamed button 0x14.
|
||||
- **PERCEIVED STRENGTH [T2 field eyeball 2026-08-05]: authentically SUBTLE.** With LINEAR fog the
|
||||
near-plane move barely changes close-range haze (at 30u the arena pair differs ~5%); the swap
|
||||
reads as a modest push-back of the night murk at mid/long range, NOT a flashlight. Geometry
|
||||
brightness comes from the page's ambient= + ramps and never changes with the lamp -- a close
|
||||
wall is identical lit/unlit. First eyeball matched exactly ("kinda sorta illuminates the
|
||||
area... didn't illuminate the wall" -- that IS the 1995 behavior). No illuminating dynamic
|
||||
light exists anywhere in BT's render path: ChildLightRenderable has ZERO instantiations
|
||||
engine-wide, and the FUN_0048d060 cluster (@4568f8/@456a0c/@456b4c/@4586d0) is light-POINT
|
||||
sprites (beacons/nav strobes, palette-tinted), not geometry lights. Set player expectations
|
||||
in the release notes accordingly.
|
||||
- **External = the spot.bgf beam-cone mesh** (VIDEO/GEO + per-time variants), hung as a child of
|
||||
the searchlight SITE joint (mount = subsystem `mountSegment`@0x1DC == resource segmentIndex;
|
||||
madcat 20, loki 21), shown/hidden per frame from the replicated LightOn (@0045612c watcher ->
|
||||
the SetDrawObj swap). Site segments now build geometry-less DCS children (posed + parentable --
|
||||
the 1995 graph gave every site a DCS node; our tree used to skip sites entirely; this also
|
||||
enables future site-hung effects). Wrecks go dark through the authentic gate (host shutdown
|
||||
forces lightState 0). Cross-node verified: B presses, A logs `[spot] cone SHOWN (seg 20)`.
|
||||
- Benches: `scratchpad/night12/searchfog.sh` (solo cockpit chain + red-fog end-to-end probe),
|
||||
`spotcone.sh` (2-node cone + replication). BEAM MATERIAL decoded 2026-08-06 (b75bb4a) [T1]:
|
||||
SPOT.BGF is a 7-vert cone ~50u forward and ~35° DOWN (a ground-pool lamp, not an air beam),
|
||||
verts tinted cyan-white; its material class `btfx:brighten.25` smuggles the ADDITIVE factor
|
||||
in DIFFUSE.r (0.25) with a warm emissive on the night page — the loader draws brighten*
|
||||
batches as an additive veil (dest += vertexRGB × factor, blend pass, unlit); tint compose
|
||||
(vertex cyan vs night emissive warm) [T3], flagged in the L4D3D draw branch. Look pass
|
||||
ACCEPTED by field eyeball 2026-08-06 (translucent beam reads correctly; solo rig
|
||||
BT_SPOT_SELF=1 + BT_CAM=face). Still open ([[open-questions]]): the stubbed LoadObject
|
||||
wrapper + dormant marker case, the subtle authored deltas.
|
||||
|
||||
## Key Relationships
|
||||
- Geometry/LOD: [[bgf-format]]. Base: [[wintesla-port]] (L4D3D). Shadow/visual-conform: [[locomotion]].
|
||||
- Renders on: [[pod-hardware]] (main 3D view).
|
||||
|
||||
@@ -94,6 +94,32 @@ its recorded line 400 exactly. [T1/T3 per the sidecar]
|
||||
Plus `BTL4OPT.EXE` as a behavioral **oracle** (run under cdb / read the decomp). Result =
|
||||
behavior-equivalent **reconstruction**, NOT original source. [T2]
|
||||
|
||||
## THE GAP CENSUS + RE-EXPORT (2026-08-06, #60) — BOTH HALVES DONE [T1]
|
||||
**The export was REBUILT 2026-08-06** (Ghidra 12.1.2 headless + `ExportGaps.java`'s gap-fill
|
||||
pass): index coverage **87.3% → 93.5%**, dark real code **90.4 KB → 40.8 KB (54.8% recovered)**,
|
||||
game-side dark 53.1 → 21.1 KB, functions 6,267 → **6,472** (+205, zero decompile failures).
|
||||
Every historically-bitten dark function now has pseudocode — including `@0x4c05c4` VehicleDead,
|
||||
the absence that opened #60. The re-export CONFIRMED the crouch reconstruction field-for-field
|
||||
and revealed one branch the raw pass missed (airborne auto-rise, fixed same day).
|
||||
⚠ **The old export is archived at `reference/decomp/archive_2025export/`** — addresses are
|
||||
stable across both, but `part_0NN.c:LINE` citations only resolve against the archive. Cite
|
||||
`@ADDR`, not part/line. Pipeline: `tools/ghidra_reexport.sh` → `tools/gapcensus.py` →
|
||||
`tools/gapdiff.py`. Toolchain (JDK 21 + Ghidra 12.1.2) lives in `%LOCALAPPDATA%\bt411-tools`;
|
||||
the runner MUST use 8.3 short paths (Ghidra's .bat expands %JAVA_HOME% unquoted and the user
|
||||
profile has a space). Full log: `phases/phase-04-gap-census.md`.
|
||||
|
||||
### The original inventory (part 1, same day) [T1]
|
||||
`tools/gapcensus.py` → `reference/decomp/GAP_CENSUS.md` + `gap_census.tsv` (regenerate after
|
||||
any re-export; it censuses whatever dir you point it at). As found BEFORE the re-export: .text
|
||||
892 KB, index 87.3%, **428 dark regions = 90 KB of real code** (indexed-but-unexported = 0 —
|
||||
the gap class is purely "not indexed"), game-side dark ≈ 54 KB with 159 regions never touched.
|
||||
Current numbers are in the header above. Validation: all six historically-bitten dark
|
||||
addresses (VehicleDead, ToggleLamp, death tail, master-perf, myomer integrator, duck consumer)
|
||||
land inside census regions. Top uncharted leads + method: `phases/phase-04-gap-census.md`.
|
||||
**RULE (gotcha §20):** consult the census BEFORE any "absent from the export/binary" claim —
|
||||
if the address falls in a dark region, the claim needs byte-scan evidence, not export silence.
|
||||
(The second half — the re-export — landed the same day; see the header.)
|
||||
|
||||
## Key Relationships
|
||||
- Feeds: [[reconstruction-method]] (the loop that fills the gap), [[decomp-reference]].
|
||||
- Route A closed 2026-07-19 (Nick holds nothing further); gated now only on reconstruction labor + the engine-closure backfill.
|
||||
|
||||
@@ -41,6 +41,9 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
|
||||
- **WAVE 3** — power bus (Generator/PoweredSubsystem) + Emitter/PPC fire-path (end-to-end fire; heat
|
||||
conducts to the central sink via the linked-sink roster).
|
||||
- **WAVE 4** — standalone readouts: Sensor/Searchlight/ThermalSight/AmmoBin (de-shim, gate fixes).
|
||||
- ✅ **Searchlight VISUALS complete 2026-08-05** — cockpit fog swap + external spot.bgf beam
|
||||
cone, both MP-replicated; mountSegment@0x1DC identified (= resource segmentIndex, the cone's
|
||||
mount joint — was "commandedOn, role unidentified"). Full story: [[rendering]] §SEARCHLIGHT.
|
||||
- ✅ **Searchlight + ThermalSight buttons WIRED 2026-07-25 (#61)** — both classes' handler sets were
|
||||
default-constructed blackholes (systemic cause #1, `docs/INPUT_PATH_AUDIT.md`). Each now chains
|
||||
`PowerWatcher::GetMessageHandlers()` with its own id-3 `ToggleLamp`. **Verified live**: pad `0x14`
|
||||
|
||||
@@ -84,6 +84,56 @@ The vehicle sed must replace the WHOLE line (`s/^vehicle=.*/`), or you mint
|
||||
`vehicle=thr1bhk1` and the spawn FATALs. Chassis codes: ava1 bhk1 lok1/2 mad1/2
|
||||
own1 snd1 thr1 vul1 (own1 has NO arm zones — rack zones instead).
|
||||
|
||||
**Weapon/combat benches need `map=grass time=day`.** `MP.EGG` authors
|
||||
`map=cavern time=night`; a bench that copies it without the map sed (e.g. via
|
||||
`bt_expert_egg` alone — mp_double.sh has this gap) parks the GOTO-driven mechs
|
||||
against cavern rock and they shoot terrain for the whole window. Always sed the
|
||||
map/time on the COPY, single-node and two-node alike.
|
||||
|
||||
### THE ZONE-WALK MATRIX (`scratchpad/night10/zonewalk.sh`) — systematic
|
||||
### per-panel hit-location testing, operator-watchable (built for #124)
|
||||
The precision-targeting rig: **two nodes**. Node B (launched first, back
|
||||
window) is the TARGET — `BT_SPIN_SELF=15` rotates its own viewpoint mech in
|
||||
place (deg/s; the BT_SPAWN_AT write pattern, applied per frame) so every
|
||||
aspect passes the shooter's boresight; its window shows its own paper doll
|
||||
taking the hits (`BT_DMG_LOG=1` prints every `[dmghit]` with zone + level).
|
||||
Node A (foreground — it owns the pick focus) runs `BT_ZONE_WALK=<secs/zone>`:
|
||||
walks the target's damage zones in index order, resolves each zone's CARRIER
|
||||
SEGMENT's live world position (`BTResolveSegmentWorld` — the same resolver the
|
||||
attached smoke rides; zone→segment via the `BTMechZoneSegAndName` bridge),
|
||||
SERVOS the torso twist + aim elevation until the centered reticle's pick ray
|
||||
(`BTGetAimRay(0,0)`) points at the segment, then pulses the laser trigger 3×
|
||||
and advances. `[walk] ZONE/FIRE/HOLD` lines on A pair with `[dmghit]` on B.
|
||||
Launch: `bash scratchpad/night10/zonewalk.sh` (relay included; NO kill timer —
|
||||
the session stays up for observation; teardown = taskkill btl4).
|
||||
Node A also carries `BT_ARMOR_LOG=1` + `BT_SHOT_EVERY=300 BT_SHOT_PREFIX=zwA`
|
||||
(added for #87): the armour watcher's `[armor] ... level P -> Q` lines on A are
|
||||
the OBSERVER-side receipt that B's zone levels replicated (the walk is the
|
||||
standing zone-replication bench — A-side peaks must match B's `[dmghit]`
|
||||
finals), and the numbered captures are the pixel record of the enemy hull
|
||||
darkening. Digest: `python scratchpad/night10/digest_walk.py` (per-zone hits,
|
||||
amt/hit, level first→last on B; replicant peaks seen by A; zone-repl lines).
|
||||
Companion scalpels: `BT_ASPECT_TEST=1` (the zero-premise frame probe — 4
|
||||
world-cardinal self-impacts + one at each weapon's physical MUZZLE, the
|
||||
left/right anchors that caught the #124 reflection-vs-rotation error);
|
||||
`BT_AIM_SWEEP=<period>` (blind elevation sweep — superseded by the walker).
|
||||
Hard-won constraints baked into the walker (do not relearn these):
|
||||
- **Engage gate**: servo only when the aim ray is live AND range < 150 AND
|
||||
|bearing| < 1.1 rad; otherwise RELAX the twist toward 0 and hold the zone
|
||||
clock. Without it the servo winds the twist to its limit while the goto
|
||||
marches (limit-clamp fight = the mech visibly SHAKES) — and the gate must
|
||||
sit well above the goto's resting bearing error (~0.55 rad at STOP=90) or
|
||||
it deadlocks in HOLD.
|
||||
- **Yaw polarity**: the twist cell's angular sense is OPPOSITE atan2(x,−z)
|
||||
world yaw (operator-observed live: `+=` turned the torso AWAY — the second
|
||||
witness for the #124 SelectSlice twist-sign flip). The servo starts at −1
|
||||
and carries a growth WATCHDOG that self-flips if the error diverges.
|
||||
- **Damped servo**: gain 0.40, step cap 0.025 rad/frame, deadband — the aim
|
||||
ray lags the twist write by a frame; full-error correction oscillates.
|
||||
- **Approach port**: dropzones spawn cross-map; the walker one-shot teleports
|
||||
A to 100 u off the target (BT_SPAWN_AT pattern incl. the +500 y ground-snap
|
||||
lift) instead of minutes of marching.
|
||||
|
||||
### Two-node bench skeleton (the MP pattern)
|
||||
```bash
|
||||
bt_assert_player_env
|
||||
@@ -139,6 +189,21 @@ sleep <duration>; kill $relay; bt_kill_ours
|
||||
(one line, ungated) answer questions retroactively; per-frame traces stay
|
||||
gated.
|
||||
|
||||
## Bench-script gotchas (each has burned a session)
|
||||
- **Write benches CLEAN, never sed-derive a chain** — sed-derived copies silently dropped envs
|
||||
twice (the [replgimp] silence); export envs INSIDE the per-node subshell.
|
||||
- **Absolute paths everywhere; `cd /c/git/bt411` first** — the cwd trap (heredoc python + `cd
|
||||
scratchpad/nightN`) has broken later builds/appends with `fatal: pathspec`/`cannot stat`.
|
||||
- **No apostrophes/backticks through bash heredocs** — write a `.py` file and run it (quoting has
|
||||
mangled posted tracker comments and killed scripts mid-parse).
|
||||
- **Drain stale watchdogs before relaunching** — a leftover waiter's `taskkill` has killed a live
|
||||
bench mid-run.
|
||||
- **Scripted input goes through the REAL seam**: `BT_BTNTEST`/`BT_BTNTEST2` (RIO queue → mode-mask
|
||||
drain). In 2-node runs press at poll ≥900 — round-start jitter can eat earlier presses.
|
||||
- **Capture the viewpoint that can see the change** (gotcha #13 bullet): cockpit view can't show
|
||||
your own legs; pair screenshots with a state probe and READ a frame before concluding.
|
||||
- **Build ritual first** — see [[build-and-run]] §Build ritual (stale exe = phantom results).
|
||||
|
||||
## Key Relationships
|
||||
- Uses: [[build-and-run]] (parity, env gates, BT_SHOT capture) · [[experience-levels]] (expert vs novice gating)
|
||||
- Informs: [[reconstruction-method]] (step 4 "verify honestly" — this file is the how)
|
||||
|
||||
@@ -696,7 +696,7 @@ reconfigure/externalConfigure); parse-skip list EMPTY ([gskip]=0), all 50 attr b
|
||||
| 30 | sec: schematic CRITICAL view (cmCrit) | subsystem simulationState/damage | LIVE this audit: N-cycle shows the full subsystem list (GEN A-D, LOOP 1-6, HUD, SENSORS, GYRO, TORSO, weapons) | T2 | CORRECT |
|
||||
| 31 | sec: schematic HEAT view (cmHeat) | subsystem currentTemperature tint | #6 pixel-verified; re-cycled this audit (mask 0x450421→0x490421→0x510421) | T2 | CORRECT |
|
||||
| 32 | sec: view cycling (N / pod 0x15) | CycleDisplayMode → vtbl+0x4C @4d1ae4 | #6 resolution re-verified live ([mode] display notify 0/1/2) | T2 | CORRECT |
|
||||
| 33 | sec: CONTROL MODE lamp (BAS/MID/ADV) | ControlsMapper/DisplayMode oneOfSeveralPixInt | attr wave incr.5; M-cycle verified #6 | T2 | CORRECT |
|
||||
| 33 | sec: CONTROL MODE lamp (BAS/MID/ADV) | ControlsMapper/**ControlMode** oneOfSeveralPixInt (row previously mislabeled DisplayMode — that is the sibling sdspmod) | attr wave incr.5; M-cycle verified #6. **2026-08-10:** this row's verification PREDATED the 07-25 #48 cycle-fill, which then lit the inactive box interiors (art idx 254 → live armor slot 62) — playtester-reported, re-fixed by mapping out-of-range indices to plane background (see context/gauges-hud.md §#48 REFINED); both render paths re-verified against the reference | T2 | CORRECT |
|
||||
| 34 | sec: duck / searchlight button lamps | duckState (ctor-zeroed only) / Searchlight LightOn | duckState writer missing (P3 leftover) | T3 | DEFERRED-FEED (duck); CORRECT (light attr published) |
|
||||
| 35 | sec: messageBoard ticker | StatusMessagePool (NULL stub) + kill ticker strip 0 | 7fc4acb; kill ticker live 2026-07-12, other strips unsurveyed | T2/T3 | DEFERRED-FEED (partial) |
|
||||
| 36 | MFD preset paging (J/K/L, pod RIO banks) | SetPresetMode table @0051dbf0 (little-endian re-decode) | #9; re-verified live this audit: cycles visit EXACTLY the populated set (MFD1: 1,2,4; MFD2: 1-4; MFD3: 1,2) | T2 | CORRECT |
|
||||
|
||||
@@ -74,7 +74,7 @@ Ranked by impact. **Reverse thrust `0x3F` (key LALT / pad B) is FIXED** and conf
|
||||
| 3 | **pad LT / RT (turn pedals)** — and every pad-only session | Steer the legs | Not a wiring gap: the pedals are live (`L4PADRIO.cpp:710-715` → `L4CTRL.cpp:1438-1446` → `btl4mppr.cpp:1337-1340` → `:1399 pedalsPosition`) but only *Standard/Veteran* read them (`mechmppr.cpp:1034`, `:1061`); the mapper boots in **Basic** (`mechmppr.cpp:361`) — and **the pad has no binding for `0x18` CycleControlMode**. Verified: `content/bindings.txt:74-89` binds only `0x40 0x3F 0x47 0x42 0x44 0x43 0x41`; no `0x18`, no `0x15` | **S** | `BT_MPPR_TRACE=1`, squeeze LT on a pad-only launch: `pedals=` moves, `turn=0`. Add `pad BACK button 0x18`, press it → `[mode] control mode -> 1`, then LT yaws |
|
||||
| 4 | **Fire dropout: releasing one alias of a held fire button** (`1`+`SPACE` on `0x40`; `3`+`LCTRL`+`RCTRL` on `0x47`; pad A/X on the same) | Two keys on one address = two wires to one button | Edge state is per **binding**, not per **address**: `previousKeyHeld[k]` is indexed by binding index and `EmitButton(addr, held)` fires on every per-binding edge (`engine/MUNGA_L4/L4PADRIO.cpp:580-595`; same shape in the pad loop `:665-685`). The release writes `-(addr+1)` (`L4CTRL.cpp:2635-2647`) and `ControlsUpdateManager::Update` then suppresses the redundant press, so fire stays **off** until the held key is released and re-pressed | **M** (shared plumbing — **defer**) | `BT_FIRE_LOG=1`: hold LCTRL (missiles firing), tap `3`, release `3` → fire stops with LCTRL still down |
|
||||
| 5 | **`0x1A`-`0x1D`** Generator A-D ON/OFF (4 radar-rail buttons) | Take a generator off line (heat/power management) | `Generator : HeatSink` (`powersub.hpp:320-322`) and **no `Generator::GetMessageHandlers()` exists** — `powersub.cpp:1014` resolves to HeatSink's set `{id 3 ToggleCooling}`, so streamed `msg 4` (`ctrlmap.log:44-47`) finds nothing. `ToggleGeneratorOnOff` @`004b1ed0` is unreconstructed (0 hits in `game/`) | **M** | Click `0x1A`: today zero log. After: generator output voltage → 0 and its ENG-page bar follows |
|
||||
| 6 | **`0x13`** CROUCH (manual p8, a full section) | Duck to present a smaller target | Streamed `subsys -1 msg 0x1a` (`ctrlmap.log:40`) resolves to the Mech (`ENTITY.cpp:608-615`), but `Mech::MessageHandlerEntries` has exactly three entries — `TakeDamage`, `PlayerLink`, `BalanceCoolant` (`mech.cpp:463-467`). No id `0x1a`. Assets exist (`Mech::duckState` attr 0x37 `mech.cpp:861`, SQUAT clips, `DuckServo01.wav`) | **M** (reconstruct @`0049fa00`) | Click `0x13` on a stationary mech: expect the squat clip + `duckState 0→1` |
|
||||
| 6 | **`0x13`** CROUCH (manual p8, a full section) | Duck to present a smaller target | Streamed `subsys -1 msg 0x1a` (`ctrlmap.log:40`) resolves to the Mech (`ENTITY.cpp:608-615`), but `Mech::MessageHandlerEntries` has exactly three entries — `TakeDamage`, `PlayerLink`, `BalanceCoolant` (`mech.cpp:463-467`). No id `0x1a`. Assets exist (`Mech::duckState` attr 0x37 `mech.cpp:861`, SQUAT clips, `DuckServo01.wav`) | ~~M~~ ✅ **COMPLETE** (handler 07-26; consumer + clips + MP 2026-08-06 — [[locomotion]] §CROUCH) | Verified: squat clip plays, holds, rises; peers replicate it |
|
||||
| 7 | **Torso recenter from the keyboard** (`0x42` is pad/mouse only) | Center a twisted torso — the manual's anti-disorientation control (p9) | `0x42` itself is **LIVE** (streamed `subsys 17 attr 14 -> +0x208` = `Torso::centerCommand`, consumed `torso.cpp:636-640`) but `content/bindings.txt:83,86` bind it only on `pad Y`/`DPAD_UP`. The port's keyboard path (`gBTTorsoRecenter`, `mech4.cpp:2875`) is fed from the zeroed `gBTInput` and its consumer sits inside the stood-down bridge (`mechmppr.cpp:854-863`, gate `:655-660`) | **S** | Twist with `E`, press `X`: `currentTwist` does not return to 0. Add `key X button 0x42` (two rows on one VK both fire — `L4PADRIO.cpp:580-629` iterates all bindings) |
|
||||
| 8 | **Any clickable button latches if the mouse-up is lost** (worst on `0x3F` reverse, `0x40` trigger) | A momentary click always releases | Neither WndProc handles `WM_CAPTURECHANGED` or `WM_KILLFOCUS` — grep over `engine/` + `game/` returns **zero** hits. Alt-tab or a system dialog between press and release leaves `gCkPressed` set and `SetScreenButton(a,0)` never runs | **M** — trap: `btl4main.cpp:137` calls `ReleaseCapture()` itself, which re-enters `WM_CAPTURECHANGED`; guard on `gCkPressed != -1` (it is cleared before `ReleaseCapture`) | Click-hold `0x3F`, alt-tab, release outside the window: `BT_PAD_LOG=1` shows no release line and `rev` stays 1 |
|
||||
| 9 | **Every HOTAS / flight stick / rudder** (whole `joydev/joyaxis/joybutton/joyhat` grammar) | Drive the pod channels from a DirectInput device | `content/bindings.txt` is 89 lines and has **zero** joy rows; the joy poll is gated on `joyAxisBindingCount>0 \|\| …` (`L4PADRIO.cpp:775-777`) so `BTJoyInit/BTJoyPoll` are never called. `PadBindingProfile::Load` writes the default only when the file is **absent** (`L4PADBINDINGS.cpp:708-713`), and the file is gitignored (`.gitignore:23`) — so the zip ships **this machine's stale file** | **S** now (ship without it / refresh it) · **M** later (version marker + additive merge) | `content/jstest.log`: `bindings loaded: 40 keys, 10 pad buttons, 5 pad axes, 0 joy axes…`; no `[joy]` line exists in any log in `content/` |
|
||||
|
||||
@@ -404,3 +404,21 @@ design change for its own pass. `killCount`/`deathTally` are still zeroed only i
|
||||
column should read, `+0x280` vs `deathCount`), §Headline-5 (last-hitter-takes-all and
|
||||
ram kills), and §Headline-6's fidelity question (the wrong-column slip is still
|
||||
reproduced; it is merely no longer visible).
|
||||
|
||||
## Addendum 2026-08-05 — the AUTHENTIC report tail lands (#45/#134)
|
||||
The kill/damage report sender is no longer the BTPostDamageScore/BTPostKillScore stand-in pair:
|
||||
`Mech::TakeDamageMessageHandler`'s dark-gap tail (@0x4a02f4-0x4a0890) is reconstructed as
|
||||
`BTMechPostCombatReports` + `BTMechPostVehicleDead` (btplayer.cpp), dispatching the binary's three
|
||||
id-0x16 reports (kill to shooter / type-0 wire-fidelity / received to victim) and the BT-extended
|
||||
0x38 VehicleDead {killed-by player, kill zone} from the victim's handler at the binary's exact
|
||||
positions. Consequences for this plan:
|
||||
- KILLS credit path unchanged in shape (victim node -> replicant-player Dispatch reroute ->
|
||||
killer's master ++killCount) — re-benched cross-node (scorekill.sh: kills=1, award=4.88).
|
||||
- Suicides now dispatch and the handler negates the award (#134 panic penalty live;
|
||||
scoreself.sh: type=2 award=-39.00 kills=0).
|
||||
- Per-hit INFLICTED credit is REMOVED — it never existed in 1995 (@0x4c0200 is bound in no
|
||||
handler-table entry; scoring = kill awards + received penalties + death costs). SCORE column
|
||||
behavior changes accordingly; KILLS/DEATHS columns unaffected.
|
||||
- §Headline-6's wrong-column slip (dual +0x27c increment) remains faithfully reproduced and
|
||||
replication-masked. The type-1 received report also fixes the console VTVDamaged
|
||||
points_transfered field (was a Now() misread; it is Round(award), raw disasm @0x4c04a7).
|
||||
|
||||
@@ -254,4 +254,27 @@ Keep all three unconditional (matchlog only arms on `-net`, `matchlog.cpp:66-76`
|
||||
7. **Do not remove the entity.** Nothing in this plan may issue `DestroyEntityMessage` on death — the wreck stays (P5 teardown crash).
|
||||
8. **Don't reconstruct dtor glue** while touching the heat/power chain terminus; the trailing base-dtor calls run the chain twice.
|
||||
9. **`AmmoBin +0x228`** is written to 0 by `@004bd26c` alongside `feedTimer`/`cookOffArmed` and is the object's last dword (`sizeof == 0x22C`); our `ammobin.hpp:270` calls it `reserved // unused`. It is a live per-feed latch — identify it from `AmmoBinSimulation @004bd394` / `FeedAmmo @004bd4f4` before shipping the refill move, or copy the write blind and flag it [T3].
|
||||
10. **Roster-order sensitivity is a non-risk.** `PoweredSubsystem::RTIS @004b0e6c` gates the electrical restore on the source Generator's `stateAlarm == 2`, but `Generator @004b215c` sets level 2 **unconditionally** (`0x4b2194`) and the death sweep already ran `DeathReset(0)` over the whole roster with no index skip — so no authored segment order can produce a partial re-arm. Do not add ordering logic to `Mech::Reset`.
|
||||
10. **Roster-order sensitivity is a non-risk.** `PoweredSubsystem::RTIS @004b0e6c` gates the electrical restore on the source Generator's `stateAlarm == 2`, but `Generator @004b215c` sets level 2 **unconditionally** (`0x4b2194`) and the death sweep already ran `DeathReset(0)` over the whole roster with no index skip — so no authored segment order can produce a partial re-arm. Do not add ordering logic to `Mech::Reset`.
|
||||
---
|
||||
|
||||
# ADDENDUM 2026-08-04 — the full-reset audit (operator request): valves, gensel, MFD modes, spawn smoke
|
||||
|
||||
Steps 0-5 above all landed via #55 and are byte-consistent (re-audited today).
|
||||
Four operator/field reports were then grounded and dispositioned:
|
||||
|
||||
| report | binary ground truth | disposition |
|
||||
|---|---|---|
|
||||
| coolant valves survive respawn | `@004ae534` (MISSING from the Ghidra export -- raw disasm): chains **HeatSink** `0x4ad760` (coolant refill RUNS), then arg-gated `valveState=1` + `massScale=refrigerationFactor`; `Mech::Reset` tail (`call 0x49f788`) re-runs RecomputeCondenserValves | **PORT GAP -> FIXED** (heat.cpp authentic body + mech4.cpp Reset tail call). Bench: detent 5 -> death -> `[respawn] Condenser1 valve detent 5 -> 1` |
|
||||
| generator switching survives | `@004b215c` sets `generatorOn=1` (landed, #55); `@004b0e6c` only RESOLVES the source link -- **the weapon->generator taps are never rewritten** | gens re-light = already correct; **taps persisting = AUTHENTIC** |
|
||||
| MFD display mode / control mode survive | mapper vtables `0050f45c` + `0051e440` slots 8-11 read from the shipped exe = plain root bodies -- **displayMode/controlMode have NO reset anywhere** | **AUTHENTIC persist** -- do not "fix" |
|
||||
| #129 fresh spawns smoke briefly | the pod's respawn ran the per-entity effect cleanup `@004d0c14` | **PORT GAP -> FIXED**: only the master's Mech::Reset ran the cleanup; the REPLICANT un-wreck edge (mechdmg.cpp MechDeathHandler::Tick) rebuilt without it, so the observer's last 10s plume window rode the teleport (peers-only symptom). BTStopEntityPfx now runs on the un-wreck edge |
|
||||
|
||||
Bench: `scratchpad/night11/respawnreset.sh` (BT_VALVE_TEST press -> BT_MP_FORCE_DMG
|
||||
kill -> read `[valve]`/`[respawn]` on A, plume/un-wreck ordering on B).
|
||||
|
||||
## Addendum 2026-08-05 (#45 report tail): the VehicleDead SENDER moved
|
||||
The death-transition dispatch site (mech4) is retired: the decoded sender is the TakeDamage
|
||||
handler's death tail (@0x4a07d4-0x4a0890), now BTMechPostVehicleDead (btplayer.cpp) with the
|
||||
BT 0x38-byte extension {killed-by player, kill zone}. The #55 NULL-playerLink fallback + the
|
||||
DEAD_NOTIFY forensics moved into it intact; the #81 single-dispatch rule is unchanged. The
|
||||
flow diagram above predates this -- read BTPostKillScore rows as historical (retired 08-05).
|
||||
|
||||
@@ -48,6 +48,7 @@ ScenarioRole::ScenarioRole(const CString &role_name, const CString &model_file)
|
||||
resourceAddress;
|
||||
|
||||
killBonus = player_data->killBonus;
|
||||
specialCaseDeathPenalty = player_data->specialCaseDeathPenalty; // BT 4.10 (restored)
|
||||
returnFromDeath = player_data->returnFromDeath;
|
||||
damageReceivedModifier = player_data->damageReceivedModifier;
|
||||
damageInflictedModifier = player_data->damageInflictedModifier;
|
||||
@@ -64,6 +65,7 @@ ScenarioRole::ScenarioRole(const CString &role_name, const CString &model_file)
|
||||
damageBias = 0.0f;
|
||||
friendlyFirePenalty = 0.0f;
|
||||
killBonus = 0.0f;
|
||||
specialCaseDeathPenalty = 0.0f; // BT 4.10 (restored)
|
||||
returnFromDeath = 0;
|
||||
}
|
||||
}
|
||||
@@ -151,6 +153,20 @@ Dump_And_Die:
|
||||
return -1;
|
||||
}
|
||||
|
||||
// BT 4.10: read right after KillBonus, fail-hard like every other key
|
||||
// (binary role reader @00429bec, dest = record slot [1]).
|
||||
if(
|
||||
!model_file->GetEntry(
|
||||
"gamedata",
|
||||
"SpecialCaseDeathPenalty",
|
||||
&local_model->specialCaseDeathPenalty
|
||||
)
|
||||
)
|
||||
{
|
||||
std::cerr << model_name << "Missing SpecialCaseDeathPenalty" << std::endl;
|
||||
goto Dump_And_Die;
|
||||
}
|
||||
|
||||
if(
|
||||
!model_file->GetEntry(
|
||||
"gamedata",
|
||||
|
||||
+13
-1
@@ -9,7 +9,12 @@ class NotationFile;
|
||||
|
||||
struct ScenarioRole__ModelResource
|
||||
{
|
||||
Scalar killBonus, damageReceivedModifier, damageInflictedModifier, damageBias, friendlyFirePenalty;
|
||||
// BT 4.10 carries specialCaseDeathPenalty as record slot [1], right after
|
||||
// killBonus (role reader @00429bec dest offsets; the 2007 WinTesla source
|
||||
// dropped the BT-only field -- restored 2026-08-02, the DefaultRendererRate
|
||||
// pattern). Consumed by the BT death handler tail (@004c07cd): every death
|
||||
// with advancedDamageOn costs -specialCaseDeathPenalty score.
|
||||
Scalar killBonus, specialCaseDeathPenalty, damageReceivedModifier, damageInflictedModifier, damageBias, friendlyFirePenalty;
|
||||
int returnFromDeath;
|
||||
};
|
||||
|
||||
@@ -20,6 +25,7 @@ public:
|
||||
|
||||
protected:
|
||||
Scalar damageInflictedModifier, damageReceivedModifier, friendlyFirePenalty, damageBias, killBonus;
|
||||
Scalar specialCaseDeathPenalty; // BT 4.10 role+0x20 (restored 2026-08-02)
|
||||
CString roleName;
|
||||
|
||||
int returnFromDeath;
|
||||
@@ -61,6 +67,12 @@ public:
|
||||
return killBonus;
|
||||
}
|
||||
|
||||
Scalar GetSpecialCaseDeathPenalty() const // BT 4.10 role+0x20
|
||||
{
|
||||
Check(this);
|
||||
return specialCaseDeathPenalty;
|
||||
}
|
||||
|
||||
CString GetRoleName() const
|
||||
{
|
||||
Check(this);
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
#include "dxutils.h"
|
||||
#ifdef BT_GLASS
|
||||
#include "l4padrio.h"
|
||||
#ifdef BT_GLASS
|
||||
#include "l4glasswin.h" // the per-display MFD windows -- created below for
|
||||
// EITHER RIO (they used to ride the PadRIO ctor)
|
||||
#endif
|
||||
#endif
|
||||
|
||||
//
|
||||
@@ -865,6 +869,29 @@ LBE4ControlsManager::LBE4ControlsManager():
|
||||
while (temp[0] != '\0');
|
||||
}
|
||||
|
||||
//--------------------------------------------------
|
||||
// The per-display MFD windows (2026-08-06)
|
||||
//--------------------------------------------------
|
||||
// These used to be created ONLY at the end of the PadRIO constructor,
|
||||
// because they began life as the frames around the on-screen RIO button
|
||||
// banks -- "the buttons ride the device". On a real cab that coupling is
|
||||
// backwards: selecting the HARDWARE RIO means PadRIO is never built, so
|
||||
// the MFD panels never appeared at all. Found on the crash cart, where
|
||||
// the mission came up on the main view with every MFD dark.
|
||||
//
|
||||
// The windows are a DISPLAY concern, so create them here -- after the
|
||||
// L4CONTROLS parse, where every device branch converges -- and let them
|
||||
// exist whichever RIO won. In pod surface mode there are no on-screen
|
||||
// buttons on them at all. Create() is idempotent (`gWinCount != 0`
|
||||
// returns), so the PadRIO call site stays valid and simply arrives first.
|
||||
//
|
||||
#ifdef BT_GLASS
|
||||
if (rioPointer != NULL && BTGlassPanelsActive())
|
||||
{
|
||||
BTGlassPanels_Create();
|
||||
}
|
||||
#endif
|
||||
|
||||
//--------------------------------------------------
|
||||
// Clear all lamps (and reset analog I/O)
|
||||
//--------------------------------------------------
|
||||
@@ -1089,6 +1116,17 @@ LBE4ControlsManager::~LBE4ControlsManager()
|
||||
rioPointer = NULL;
|
||||
}
|
||||
|
||||
#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.
|
||||
//
|
||||
BTGlassPanels_Destroy();
|
||||
#endif
|
||||
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
@@ -2624,12 +2662,28 @@ void
|
||||
Verify(rio_event.Data.Unit < ButtonCount);
|
||||
temp = rio_event.Data.Unit + 1;
|
||||
|
||||
// DIAG (BT_DUCK_LOG): the mode mask a press dispatches under -- the
|
||||
// crouch-button (0x13) MP-delivery hunt (mode-gated streamed rows).
|
||||
if (getenv("BT_DUCK_LOG"))
|
||||
DEBUG_STREAM << "[duck] RIO press unit=0x" << std::hex
|
||||
<< (int)rio_event.Data.Unit << " modeMask=0x"
|
||||
<< (unsigned)mode_mask << std::dec << "\n" << std::flush;
|
||||
|
||||
Check(&buttonGroup[rio_event.Data.Unit]);
|
||||
buttonGroup[rio_event.Data.Unit].Update(&temp, mode_mask);
|
||||
//----------------------------------------
|
||||
// Save 'pressed' mode mask for 'release'
|
||||
//----------------------------------------
|
||||
buttonActivateModeMask[rio_event.Data.Unit] = mode_mask;
|
||||
|
||||
// (NO eng-page eject-key hook: REMOVED 2026-08-03 for strict binary
|
||||
// fidelity. The soft keys 0x0B/0x23/0x03 never pilot-eject in the
|
||||
// binary -- every page routes them to authored functions (weapon eng
|
||||
// pages: EjectAmmo 0xB -- a hook here HIJACKED the unjam while armed,
|
||||
// self-destructing a pilot trying to clear a jam). The flashing
|
||||
// engEject lamp is the INVITE; the authored press is the pilot KEYPAD
|
||||
// bank / PANIC key, both live on the desktop.)
|
||||
|
||||
break;
|
||||
|
||||
case RIO::ButtonReleasedEvent:
|
||||
@@ -2670,6 +2724,16 @@ void
|
||||
);
|
||||
|
||||
Check(&keyboardGroup[rio_event.Data.Keyboard.Unit]);
|
||||
{
|
||||
static int s_klog = -1;
|
||||
if (s_klog < 0) s_klog = (getenv("BT_PAD_LOG") != 0) ? 1 : 0;
|
||||
if (s_klog)
|
||||
DEBUG_STREAM << "[lbe4key] unit "
|
||||
<< (int)rio_event.Data.Keyboard.Unit
|
||||
<< " key " << (int)new_key
|
||||
<< " mode 0x" << std::hex << (unsigned)mode_mask
|
||||
<< std::dec << "\n" << std::flush;
|
||||
}
|
||||
keyboardGroup[rio_event.Data.Keyboard.Unit].
|
||||
ForceUpdate(&new_key, mode_mask);
|
||||
|
||||
|
||||
@@ -443,8 +443,13 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName)
|
||||
// drawAsSky) and vanishes -- DECLOUDS carries vertex alpha but must
|
||||
// stay in the sky pass.
|
||||
const bool vtxAlphaOp = data.batches[i].vertexAlpha && !isSkyObj;
|
||||
object->mDrawOps[i].alphaTest = (object->mIsShadow != 0) || vtxAlphaOp;
|
||||
// BTFX brighten veil (spot cone): route to the blend pass like the
|
||||
// vertex-alpha cards; the draw-state branch in DrawMesh adds
|
||||
// dest += emissive x factor instead of SRCALPHA/INVSRCALPHA.
|
||||
const bool brightenOp = data.batches[i].brightenFactor > 0.0f && !isSkyObj;
|
||||
object->mDrawOps[i].alphaTest = (object->mIsShadow != 0) || vtxAlphaOp || brightenOp;
|
||||
object->mDrawOps[i].vertexAlphaBlend = vtxAlphaOp;
|
||||
object->mDrawOps[i].brightenAlpha = brightenOp ? data.batches[i].brightenFactor : 0.0f;
|
||||
object->mDrawOps[i].drawAsDecal = false;
|
||||
object->mDrawOps[i].drawAsSky = isSkyObj; // sky dome -> PASS_SKY (fullbright)
|
||||
// PUNCH (dpl_Punchize; BT_PUNCH=0 disables): cutout batch -- black texels
|
||||
@@ -1279,7 +1284,9 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
|
||||
// (SRCALPHA/INVSRCALPHA). Runs only in the blend pass (the op filter
|
||||
// above routed it there; ALPHABLEND is on and z-write off pass-wide).
|
||||
DWORD sVLight = 0, sVSrc = 0, sVDst = 0, sVCop = 0, sVCa1 = 0, sVCa2 = 0, sVAop = 0, sVAa2 = 0;
|
||||
const bool vtxAlphaCard = (drawOp->vertexAlphaBlend && pass == PASS_ALPHABLEND);
|
||||
const bool brightenCard = (drawOp->brightenAlpha > 0.0f && pass == PASS_ALPHABLEND);
|
||||
const bool vtxAlphaCard =
|
||||
((drawOp->vertexAlphaBlend && pass == PASS_ALPHABLEND) || brightenCard);
|
||||
if (vtxAlphaCard)
|
||||
{
|
||||
mDevice->GetRenderState(D3DRS_LIGHTING, &sVLight);
|
||||
@@ -1292,13 +1299,35 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
|
||||
mDevice->GetTextureStageState(0, D3DTSS_ALPHAARG2, &sVAa2);
|
||||
mDevice->SetRenderState(D3DRS_LIGHTING, FALSE);
|
||||
mDevice->SetRenderState(D3DRS_SRCBLEND, D3DBLEND_SRCALPHA);
|
||||
mDevice->SetRenderState(D3DRS_DESTBLEND, D3DBLEND_INVSRCALPHA);
|
||||
mDevice->SetRenderState(D3DRS_DESTBLEND,
|
||||
brightenCard ? D3DBLEND_ONE : D3DBLEND_INVSRCALPHA);
|
||||
if (brightenCard)
|
||||
{
|
||||
// BTFX brighten veil: dest += vertexRGB x factor. SPOT.BGF's
|
||||
// verts author the beam tint (cyan-white 0.12/0.98/1.0, alpha
|
||||
// 1.0 -- no fade gradient); the factor rides TFACTOR alpha.
|
||||
// [T3 tint compose]: the night material ALSO authors emissive
|
||||
// (0.9,0.4,0.2 warm) -- whether the DIV board tinted the veil
|
||||
// by vertex, emissive, or their product is unconfirmed; vertex
|
||||
// (a white-blue searchlight) matches the authored geometry
|
||||
// gradient model. Flip here if era-look evidence says warm.
|
||||
mDevice->SetRenderState(D3DRS_TEXTUREFACTOR,
|
||||
D3DCOLOR_COLORVALUE(1.0f, 1.0f, 1.0f, drawOp->brightenAlpha));
|
||||
mDevice->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_SELECTARG1);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_DIFFUSE);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_TFACTOR);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG2);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_TFACTOR);
|
||||
}
|
||||
else
|
||||
{
|
||||
mDevice->SetTextureStageState(0, D3DTSS_COLOROP,
|
||||
drawOp->texture.texture != NULL ? D3DTOP_MODULATE : D3DTOP_SELECTARG2);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_DIFFUSE);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG2);
|
||||
mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_DIFFUSE);
|
||||
}
|
||||
}
|
||||
|
||||
const bool copCut = (drawOp->copRole == 2 && iOp > 0
|
||||
|
||||
+719
-19
@@ -115,6 +115,24 @@ struct GWin
|
||||
int wantX, wantY;
|
||||
int restored; // wantX/wantY came from glass_layout.cfg -> don't re-snap
|
||||
int noFrame; // ",noframe" in the cfg -> WS_POPUP, drag by the surface
|
||||
|
||||
// RGB CHANNEL GROUP (BT_POD_RGB): on splitter-wired pod glass this window
|
||||
// is one VGA PORT feeding up to three mono monitors through R/G/B. These
|
||||
// are the OTHER surfaces sharing the port; each is composited into its own
|
||||
// channel by BlitSurface. groupCount 0 = ordinary single-surface window.
|
||||
const char *groupPort[3];
|
||||
const char *groupAlt[3]; // the Eng<n> twin, or NULL
|
||||
int groupCount;
|
||||
|
||||
char monitorName[40]; // the PHYSICAL monitor this window landed on
|
||||
// (\.\DISPLAYn), stamped at creation; shown
|
||||
// by BT_POD_IDENT so the cab can be mapped.
|
||||
|
||||
// Dirty-skip (2026-08-09): the last change token the pump blitted for this
|
||||
// window (plane checksum + lamp render). Zero-init (static storage); the pump
|
||||
// re-blits only when the token differs, so unchanged panels cost nothing.
|
||||
unsigned long lastToken;
|
||||
int haveToken;
|
||||
};
|
||||
|
||||
static GWin gWins[8];
|
||||
@@ -124,7 +142,8 @@ static int pressedAddress = -1;
|
||||
static unsigned char latched[128];
|
||||
static int gRadarRot = 3; // BT_GAUGE_SEC_ROT (default CW, upright)
|
||||
static bool sRepositionedToMain = false;
|
||||
static unsigned long *gStage = NULL; // 640*480 BGRA staging (shared, main-thread)
|
||||
static unsigned long *gStage = NULL; // 640*480 BGRA staging (shared, main-thread)
|
||||
static unsigned long *gStage2 = NULL; // RGB composite scratch (BT_POD_RGB)
|
||||
|
||||
static HFONT titleFont = NULL;
|
||||
static HFONT buttonFont = NULL;
|
||||
@@ -244,17 +263,26 @@ static void
|
||||
// (0x15 sits on the left column -- Cyd listed 0x10-0x14; move it if wanted.)
|
||||
static const int bottomAddrs[4] = { 0x16, 0x17, 0x1F, 0x1E };
|
||||
|
||||
// The SURFACE SIZE follows the rotation: a quarter turn (1 or 3) transposes
|
||||
// the 640x480 source to 480x640 portrait -- the pod's rotated CRT -- while
|
||||
// 0 (none) and 2 (180) keep it LANDSCAPE. Sizing this independently of the
|
||||
// rotation squashed the picture into the wrong aspect on a landscape
|
||||
// replacement panel (crash cart, 2026-08-06).
|
||||
const int quarterTurn = (gRadarRot == 1 || gRadarRot == 3);
|
||||
const int surfW = quarterTurn ? RadarSurfW : RadarSurfH; // 480 : 640
|
||||
const int surfH = quarterTurn ? RadarSurfH : RadarSurfW; // 640 : 480
|
||||
|
||||
BTRioBankMetrics metrics;
|
||||
BTRioBankMetricsFor(RadarSurfW, RadarSurfH, &metrics);
|
||||
BTRioBankMetricsFor(surfW, surfH, &metrics);
|
||||
|
||||
BTRioBank bank;
|
||||
BTRioBankLayout(BTRioBankRadar, 0, 0, RadarSurfW, RadarSurfH,
|
||||
BTRioBankLayout(BTRioBankRadar, 0, 0, surfW, surfH,
|
||||
0x10, 0x18, &metrics, bottomAddrs, &bank);
|
||||
|
||||
BTRioBankDump("Secondary / Radar", &bank);
|
||||
int dx = -bank.boundsX, dy = -bank.boundsY;
|
||||
AdoptBank(w, bank, ClrYellow, dx, dy);
|
||||
SetRect(&w.surfaceRect, dx, dy, dx + RadarSurfW, dy + RadarSurfH);
|
||||
SetRect(&w.surfaceRect, dx, dy, dx + surfW, dy + surfH);
|
||||
w.clientW = bank.boundsW;
|
||||
w.clientH = bank.boundsH;
|
||||
}
|
||||
@@ -407,6 +435,179 @@ enum { LayoutOff = 0, LayoutLoad = 1, LayoutSave = 2 };
|
||||
|
||||
static const char *layoutFileName = "glass_layout.cfg";
|
||||
|
||||
// POD SURFACE MODE (BT_POD_SURFACES=1, 2026-08-06) -- the REAL pod bring-up
|
||||
// shape. On the actual cab each MFD is its own 640x480 panel with PHYSICAL
|
||||
// buttons and lamps around it, so the desktop chrome the glass windows draw
|
||||
// (the RIO button bank + the frame) is exactly wrong there: it would paint
|
||||
// fake buttons onto a panel that has real ones behind glass. In pod mode a
|
||||
// window is cropped to its SURFACE, frameless, and the bank is dropped:
|
||||
// one clean picture per physical panel, positioned by glass_layout.cfg.
|
||||
// The Flight Controls window (no surface at all) is not created.
|
||||
// BT_POD_SURFACES=1 -> every display window goes bare
|
||||
// glass_layout.cfg "<title>=x,y,bare" -> just that one (mixed rigs)
|
||||
// BT_POD_CHANMAP -- per-cart channel override, e.g.
|
||||
// BT_POD_CHANMAP=Comm=blue,Heat=red
|
||||
// The 1995 config authors which colour line each surface rides (L4GAUGE.CFG),
|
||||
// but a rebuilt cab can have its RGB splitter wired to different panels than
|
||||
// the original -- on Nick's cart the coolant (Heat) and hot-box (Comm) screens
|
||||
// came out swapped, which is a WIRING difference, not a software bug. Rather
|
||||
// than edit authentic config, override the channel here, per rig.
|
||||
// Returns 0/1/2 (red/green/blue) for a named port, or -1 if unmapped.
|
||||
static int
|
||||
PodChannelOverride(const char *portName)
|
||||
{
|
||||
static int loaded = 0;
|
||||
static char names[8][20];
|
||||
static int chans[8];
|
||||
static int count = 0;
|
||||
if (!loaded)
|
||||
{
|
||||
loaded = 1;
|
||||
const char *e = getenv("BT_POD_CHANMAP");
|
||||
while (e != NULL && *e && count < 8)
|
||||
{
|
||||
while (*e == ' ' || *e == ',') ++e;
|
||||
const char *eq = strchr(e, '=');
|
||||
if (eq == NULL) break;
|
||||
int n = 0;
|
||||
for (const char *p = e; p < eq && n < 19; ++p) names[count][n++] = *p;
|
||||
names[count][n] = 0;
|
||||
const char *v = eq + 1;
|
||||
int ch = -1;
|
||||
if (_strnicmp(v, "red", 3) == 0) ch = L4GraphicsPort::RedChannel;
|
||||
else if (_strnicmp(v, "green", 5) == 0) ch = L4GraphicsPort::GreenChannel;
|
||||
else if (_strnicmp(v, "blue", 4) == 0) ch = L4GraphicsPort::BlueChannel;
|
||||
if (ch >= 0)
|
||||
{
|
||||
chans[count] = ch;
|
||||
DEBUG_STREAM << "[glasswin] channel override: " << names[count]
|
||||
<< " -> " << (ch == L4GraphicsPort::RedChannel ? "red"
|
||||
: ch == L4GraphicsPort::GreenChannel ? "green" : "blue")
|
||||
<< std::endl << std::flush;
|
||||
++count;
|
||||
}
|
||||
const char *nx = strchr(v, ',');
|
||||
if (nx == NULL) break;
|
||||
e = nx + 1;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < count; ++i)
|
||||
if (portName != NULL && _stricmp(names[i], portName) == 0)
|
||||
return chans[i];
|
||||
return -1;
|
||||
}
|
||||
|
||||
// The channel a surface actually rides on THIS rig: the override if given,
|
||||
// else what the pod config authored.
|
||||
static int
|
||||
ResolvedChannelOf(const char *portName, L4GraphicsPort *port)
|
||||
{
|
||||
int ov = PodChannelOverride(portName);
|
||||
if (ov >= 0)
|
||||
return ov;
|
||||
return (port != NULL) ? (port->GetEnableID() & 0x7F) : -1;
|
||||
}
|
||||
|
||||
// BT_POD_IDENT=1 -- draw each surface's identity ON the glass (title, monitor,
|
||||
// and in RGB mode the channel->surface map). The only reliable way to learn
|
||||
// which physical cab panel a Windows display drives.
|
||||
static int
|
||||
PodIdentMode()
|
||||
{
|
||||
static int m = -1;
|
||||
if (m < 0)
|
||||
{
|
||||
const char *e = getenv("BT_POD_IDENT");
|
||||
m = (e != NULL && e[0] != 0 && e[0] != '0') ? 1 : 0;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// BT_POD_CHANTEST=<seconds> -- CHANNEL WALK, the instrument for mapping
|
||||
// splitter-wired glass. On a cab, three mono CRTs share one VGA port through
|
||||
// its R/G/B lines, so "which panel is which" cannot be read off Windows: the
|
||||
// desktop sees ONE display for three monitors. This drives ONE channel at a
|
||||
// time -- all-red, then all-green, then all-blue, N seconds each, with the
|
||||
// channel name drawn huge -- so the answer is simply: watch which panel lights
|
||||
// up, and when. Readable even over a rough livestream.
|
||||
// Returns the dwell in seconds (0 = off).
|
||||
static int
|
||||
PodChanTestSeconds()
|
||||
{
|
||||
static int s = -1;
|
||||
if (s < 0)
|
||||
{
|
||||
const char *e = getenv("BT_POD_CHANTEST");
|
||||
s = (e != NULL && e[0] != 0) ? atoi(e) : 0;
|
||||
if (s < 0) s = 0;
|
||||
if (e != NULL && e[0] != 0 && s == 0) s = 3; // "1"/"on" -> 3s default
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Which channel is lit right now (0=red 1=green 2=blue), from the wall clock.
|
||||
static int
|
||||
PodChanTestPhase()
|
||||
{
|
||||
int dwell = PodChanTestSeconds();
|
||||
if (dwell <= 0)
|
||||
return -1;
|
||||
return (int)((GetTickCount() / (DWORD)(dwell * 1000)) % 3);
|
||||
}
|
||||
|
||||
// BT_POD_RGB=1 -- the pod's REAL video wiring (see pod-hardware.md §THE RGB
|
||||
// SPLIT): one VGA port per window, its R/G/B analog lines split to three
|
||||
// monochrome MFD monitors. Implies pod surface mode.
|
||||
static int
|
||||
PodRgbSplitMode()
|
||||
{
|
||||
static int m = -1;
|
||||
if (m < 0)
|
||||
{
|
||||
const char *e = getenv("BT_POD_RGB");
|
||||
m = (e != NULL && e[0] != 0 && e[0] != '0') ? 1 : 0;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
static int
|
||||
PodSurfaceMode()
|
||||
{
|
||||
static int m = -1;
|
||||
if (m < 0)
|
||||
{
|
||||
const char *e = getenv("BT_POD_SURFACES");
|
||||
m = (e != NULL && *e != '\0' && *e != '0') ? 1 : 0;
|
||||
if (!m && PodRgbSplitMode())
|
||||
m = 1; // the RGB split is pod glass -- always bare
|
||||
if (m)
|
||||
DEBUG_STREAM << "[glasswin] POD SURFACE MODE: bare surfaces, no "
|
||||
"button banks (physical pod buttons assumed)\n" << std::flush;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// Crop a built window to its surface: drop the bank, move the picture to the
|
||||
// client origin, go frameless. Called at the end of each Build* when pod mode
|
||||
// is on (and from LoadLayout for a per-window ",bare").
|
||||
static void
|
||||
MakeBareSurface(GWin &w)
|
||||
{
|
||||
if (w.portPrimary == NULL) // Flight Controls -- nothing to show
|
||||
{
|
||||
w.clientW = w.clientH = 0;
|
||||
w.buttonCount = 0;
|
||||
return;
|
||||
}
|
||||
const int sw = w.surfaceRect.right - w.surfaceRect.left;
|
||||
const int sh = w.surfaceRect.bottom - w.surfaceRect.top;
|
||||
w.buttonCount = 0; // the pod's buttons are real
|
||||
SetRect(&w.surfaceRect, 0, 0, sw, sh);
|
||||
w.clientW = sw;
|
||||
w.clientH = sh;
|
||||
w.noFrame = 1;
|
||||
}
|
||||
|
||||
// A framed window is a normal tool window; ",noframe" makes it a bare
|
||||
// WS_POPUP -- no caption, no border, nothing but the display and its buttons.
|
||||
static DWORD
|
||||
@@ -537,6 +738,73 @@ void
|
||||
}
|
||||
|
||||
// Restore saved positions over the just-computed pod-faithful defaults. Called
|
||||
//---------------------------------------------------------------------------
|
||||
// MONITOR BINDING (2026-08-06) -- "monitor:<name-or-index>" instead of x,y.
|
||||
//
|
||||
// Absolute desktop coordinates do not survive a reboot on a pod: Windows
|
||||
// renumbers the virtual desktop whenever adapters enumerate differently (a USB
|
||||
// display adapter is especially prone), so a layout pinned to 800,122 can land
|
||||
// on the wrong glass -- or off-screen -- after a power cycle. Binding to the
|
||||
// MONITOR instead and resolving its rect at startup is stable across boots.
|
||||
// Heat MFD=monitor:\.\DISPLAY4,bare (device name -- what the receipts print)
|
||||
// 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; };
|
||||
|
||||
static BOOL CALLBACK
|
||||
MonScanProc(HMONITOR mon, HDC, LPRECT, LPARAM param)
|
||||
{
|
||||
MonScan *sc = (MonScan *)param;
|
||||
MONITORINFOEXA mi;
|
||||
memset(&mi, 0, sizeof(mi));
|
||||
mi.cbSize = sizeof(mi);
|
||||
if (GetMonitorInfoA(mon, (MONITORINFO *)&mi))
|
||||
{
|
||||
int hit = 0;
|
||||
if (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
|
||||
// (backslashes in a config file are a foot-gun -- every shell,
|
||||
// editor and copy-paste path mangles them differently).
|
||||
size_t dl = strlen(mi.szDevice), wl = strlen(sc->want);
|
||||
if (_stricmp(mi.szDevice, sc->want) == 0)
|
||||
hit = 1;
|
||||
else if (wl > 0 && wl <= dl
|
||||
&& _stricmp(mi.szDevice + (dl - wl), sc->want) == 0)
|
||||
hit = 1;
|
||||
}
|
||||
if (!hit && sc->wantIndex >= 0 && sc->index == sc->wantIndex)
|
||||
hit = 1;
|
||||
if (hit && !sc->found)
|
||||
{
|
||||
sc->rect = mi.rcMonitor;
|
||||
sc->found = 1;
|
||||
}
|
||||
}
|
||||
++sc->index;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Resolve "monitor:<spec>" to a rect. Returns 1 on success.
|
||||
static int
|
||||
ResolveMonitorSpec(const char *spec, RECT *out)
|
||||
{
|
||||
while (*spec == ' ' || *spec == ' ') ++spec;
|
||||
MonScan sc;
|
||||
memset(&sc, 0, sizeof(sc));
|
||||
sc.wantIndex = -1;
|
||||
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;
|
||||
return sc.found;
|
||||
}
|
||||
|
||||
// from Create AFTER ComputeLayout, so any window not named in the file keeps its
|
||||
// computed spot. Restored windows are flagged so the WM_TIMER re-snap (which
|
||||
// re-runs ComputeLayout once the main window appears) leaves them alone.
|
||||
@@ -574,30 +842,77 @@ static void
|
||||
*end = '\0';
|
||||
|
||||
int x = 0, y = 0, w = 0, h = 0;
|
||||
if (sscanf(eq + 1, "%d,%d,%d,%d", &x, &y, &w, &h) < 2)
|
||||
int haveXY = (sscanf(eq + 1, "%d,%d,%d,%d", &x, &y, &w, &h) >= 2);
|
||||
int viaMonitor = 0;
|
||||
if (!haveXY)
|
||||
{
|
||||
// "monitor:<name|index>" -- boot-stable binding (see the resolver).
|
||||
const char *v = eq + 1;
|
||||
while (*v == ' ' || *v == '\t') ++v;
|
||||
if (_strnicmp(v, "monitor:", 8) == 0 || _strnicmp(v, "mon:", 4) == 0)
|
||||
{
|
||||
char spec[64];
|
||||
const char *p2 = strchr(v, ':') + 1;
|
||||
int n2 = 0;
|
||||
while (*p2 && *p2 != ',' && n2 < (int)sizeof(spec) - 1) spec[n2++] = *p2++;
|
||||
spec[n2] = 0;
|
||||
RECT mr;
|
||||
if (ResolveMonitorSpec(spec, &mr))
|
||||
{
|
||||
x = mr.left; y = mr.top;
|
||||
w = mr.right - mr.left; h = mr.bottom - mr.top;
|
||||
haveXY = 1;
|
||||
viaMonitor = 1;
|
||||
}
|
||||
else
|
||||
DEBUG_STREAM << "[glasswin] monitor '" << spec
|
||||
<< "' not found -- leaving computed placement\n" << std::flush;
|
||||
}
|
||||
}
|
||||
if (!haveXY)
|
||||
continue;
|
||||
|
||||
// Trailing options after the numbers, comma-separated. Unknown ones
|
||||
// are ignored (so an older build reading a newer file just loses the
|
||||
// option, never the line) -- the bindings.txt grammar rule.
|
||||
int noframe = 0;
|
||||
int noframe = 0, bare = 0;
|
||||
for (const char *opt = strchr(eq + 1, ','); opt != NULL; opt = strchr(opt + 1, ','))
|
||||
{
|
||||
const char *t = opt + 1;
|
||||
while (*t == ' ' || *t == '\t') ++t;
|
||||
if (_strnicmp(t, "noframe", 7) == 0)
|
||||
{
|
||||
noframe = 1;
|
||||
break;
|
||||
}
|
||||
else if (_strnicmp(t, "bare", 4) == 0)
|
||||
bare = 1; // pod panel: surface only (see PodSurfaceMode)
|
||||
}
|
||||
|
||||
GWin *gw = FindGWinByTitle(s);
|
||||
if (gw == NULL)
|
||||
continue;
|
||||
gw->wantX = x;
|
||||
gw->wantY = y;
|
||||
gw->noFrame = noframe;
|
||||
// "bare" is a pod panel: it always implies frameless. (Without this a
|
||||
// cfg line saying only ",bare" silently re-framed a window that pod
|
||||
// surface mode had already cropped -- caught on the bench 2026-08-06.)
|
||||
gw->noFrame = (noframe || bare) ? 1 : 0;
|
||||
if (bare && gw->buttonCount > 0)
|
||||
MakeBareSurface(*gw); // per-window pod panel (mixed rigs)
|
||||
if (viaMonitor)
|
||||
{
|
||||
// x,y,w,h is the MONITOR rect -- centre this surface on it (an
|
||||
// exact-size pod panel fills it edge to edge).
|
||||
int sw = gw->clientW > 0 ? gw->clientW : (gw->surfaceRect.right - gw->surfaceRect.left);
|
||||
int sh = gw->clientH > 0 ? gw->clientH : (gw->surfaceRect.bottom - gw->surfaceRect.top);
|
||||
gw->wantX = x + ((w - sw) / 2);
|
||||
gw->wantY = y + ((h - sh) / 2);
|
||||
DEBUG_STREAM << "[glasswin] '" << gw->title
|
||||
<< "' bound to monitor " << x << "," << y << " " << w << "x" << h
|
||||
<< " -> window at " << gw->wantX << "," << gw->wantY
|
||||
<< std::endl << std::flush;
|
||||
}
|
||||
else
|
||||
{
|
||||
gw->wantX = x;
|
||||
gw->wantY = y;
|
||||
}
|
||||
gw->restored = 1;
|
||||
++restored;
|
||||
}
|
||||
@@ -768,8 +1083,86 @@ static void
|
||||
mask = 0xFF; // index the low (palette) byte
|
||||
palId = SVGA16::SecondaryPalette;
|
||||
}
|
||||
// RGB mode: the base surface rides a CHANNEL, so its tint is that pure
|
||||
// primary -- resolved live (override first), never the build-time guess.
|
||||
if (w->groupCount > 0 && tint >= 0)
|
||||
{
|
||||
switch (ResolvedChannelOf(w->portPrimary, port))
|
||||
{
|
||||
case L4GraphicsPort::RedChannel: tint = (int)0x00FF0000; break;
|
||||
case L4GraphicsPort::GreenChannel: tint = (int)0x0000FF00; break;
|
||||
case L4GraphicsPort::BlueChannel: tint = (int)0x000000FF; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
int ow = 0, oh = 0;
|
||||
svga->ExpandPlaneToBGRA(mask, palId, tint, w->rotate, gStage, &ow, &oh);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// RGB CHANNEL COMPOSITE (BT_POD_RGB, 2026-08-06) -- the AUTHENTIC pod
|
||||
// output. The cab does not give each mono MFD its own video port: one
|
||||
// VGA port's R/G/B analog lines are SPLIT to three monochrome monitors,
|
||||
// and the game puts a different surface in each colour channel of one
|
||||
// palettized framebuffer (L4GAUGE.CFG: Comm=red, Mfd2=green, Heat=blue
|
||||
// on clut2; Mfd1=red, Mfd3=green on clut1 -- and
|
||||
// L4GraphicsPort::BuildSecondaryColor writes exactly ONE component per
|
||||
// port, which IS the split). So on splitter-wired glass this window is
|
||||
// not one MFD: it is one VGA PORT, carrying up to three. Composite the
|
||||
// group's planes into this frame, each in its own channel, and let the
|
||||
// hardware separate them again.
|
||||
//
|
||||
// Channel comes from the port itself (GetEnableID), so the authored
|
||||
// config -- including a page swap that hands the channel to an Eng
|
||||
// plane -- decides, never a hardcoded table here.
|
||||
// ---------------------------------------------------------------------
|
||||
if (w->groupCount > 0 && ow > 0 && oh > 0)
|
||||
{
|
||||
const int n = ow * oh;
|
||||
for (int gi = 0; gi < w->groupCount; ++gi)
|
||||
{
|
||||
L4GraphicsPort *gp =
|
||||
static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(w->groupPort[gi]));
|
||||
if (gp == NULL)
|
||||
continue;
|
||||
// honour the live Mfd<n>/Eng<n> page swap: take whichever twin
|
||||
// currently owns a channel
|
||||
if (gp->GetEnableID() == L4GraphicsPort::BlankColor
|
||||
&& w->groupAlt[gi] != NULL)
|
||||
{
|
||||
L4GraphicsPort *ga =
|
||||
static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(w->groupAlt[gi]));
|
||||
if (ga != NULL && ga->GetEnableID() != L4GraphicsPort::BlankColor)
|
||||
gp = ga;
|
||||
}
|
||||
const int ch = ResolvedChannelOf(w->groupPort[gi], gp);
|
||||
unsigned long chMask;
|
||||
switch (ch)
|
||||
{
|
||||
case L4GraphicsPort::RedChannel: chMask = 0x00FF0000ul; break;
|
||||
case L4GraphicsPort::GreenChannel: chMask = 0x0000FF00ul; break;
|
||||
case L4GraphicsPort::BlueChannel: chMask = 0x000000FFul; break;
|
||||
default: continue; // blank / direct-colour -> contributes nothing
|
||||
}
|
||||
int gw = 0, gh = 0;
|
||||
// -1 tint would palette-expand; pass white so the plane comes back
|
||||
// as full-intensity mono, then keep only this port's channel.
|
||||
svga->ExpandPlaneToBGRA(gp->GetBitMask(), gp->paletteID,
|
||||
(int)0x00FFFFFF, w->rotate, gStage2, &gw, &gh);
|
||||
if (gw != ow || gh != oh)
|
||||
continue;
|
||||
for (int i = 0; i < n; ++i)
|
||||
gStage[i] |= (gStage2[i] & chMask);
|
||||
}
|
||||
if (log)
|
||||
{
|
||||
DEBUG_STREAM << "[glass] '" << w->title << "' RGB COMPOSITE of "
|
||||
<< w->groupCount << " plane(s):";
|
||||
for (int gi = 0; gi < w->groupCount; ++gi)
|
||||
DEBUG_STREAM << " " << w->groupPort[gi];
|
||||
DEBUG_STREAM << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
if (log)
|
||||
{
|
||||
int nz = 0, n = ow * oh;
|
||||
@@ -793,7 +1186,16 @@ static void
|
||||
info.bmiHeader.biCompression = BI_RGB;
|
||||
|
||||
const RECT &r = w->surfaceRect;
|
||||
SetStretchBltMode(dc, HALFTONE);
|
||||
// STRETCH MODE (perf, 2026-08-09): default COLORONCOLOR. HALFTONE runs a
|
||||
// per-output-pixel resample filter; done synchronously for all 7 glass windows
|
||||
// every ~16 Hz repaint (BTGlassPanels_Tick), it was the per-display-mode perf
|
||||
// sink -- playtesters saw ~20 fps in the exploded panels vs ~130 fps in the
|
||||
// cockpit surround (same scene, same machine). The MFDs are low-res pixel
|
||||
// content, so nearest-neighbour reads crisp (and closer to the pod CRT).
|
||||
// BT_GLASS_SMOOTH=1 restores HALFTONE for anyone who prefers smoothing to speed.
|
||||
static int sSmooth = -1;
|
||||
if (sSmooth < 0) sSmooth = getenv("BT_GLASS_SMOOTH") ? 1 : 0;
|
||||
SetStretchBltMode(dc, sSmooth ? HALFTONE : COLORONCOLOR);
|
||||
SetBrushOrgEx(dc, 0, 0, NULL);
|
||||
StretchDIBits(dc,
|
||||
r.left, r.top, r.right - r.left, r.bottom - r.top,
|
||||
@@ -927,6 +1329,117 @@ static void
|
||||
SelectObject(dc, oldBrush);
|
||||
SelectObject(dc, oldPen);
|
||||
DeleteObject(pen);
|
||||
|
||||
// (NO full-face flash overlay here either -- same wrongness as the
|
||||
// L4VB16 one, removed 2026-08-03: the buttons are big rects tucked
|
||||
// under the surface by design, and the protruding edge is the lamp.)
|
||||
|
||||
// ---- CHANNEL WALK (BT_POD_CHANTEST) ---------------------------------
|
||||
// Overwrite the whole surface with ONE channel at full intensity, so
|
||||
// exactly one monitor of a splitter-fed trio lights. Cycles R->G->B.
|
||||
{
|
||||
const int phase = PodChanTestPhase();
|
||||
if (phase >= 0)
|
||||
{
|
||||
static const COLORREF chColor[3] =
|
||||
{ RGB(255, 0, 0), RGB(0, 255, 0), RGB(0, 0, 255) };
|
||||
static const char *chName[3] = { "RED", "GREEN", "BLUE" };
|
||||
HBRUSH fill = CreateSolidBrush(chColor[phase]);
|
||||
FillRect(dc, &w->surfaceRect, fill);
|
||||
DeleteObject(fill);
|
||||
|
||||
// Name the channel AND what the pod authors onto it here, so a
|
||||
// lit panel identifies itself completely: "GREEN = Mfd2".
|
||||
const char *who = "-";
|
||||
GaugeRenderer *cgr = BTResolveGaugeRenderer();
|
||||
if (cgr != NULL)
|
||||
{
|
||||
const char *cand[4]; int nc = 0;
|
||||
cand[nc++] = w->portPrimary;
|
||||
for (int gi = 0; gi < w->groupCount && nc < 4; ++gi)
|
||||
cand[nc++] = w->groupPort[gi];
|
||||
for (int i = 0; i < nc; ++i)
|
||||
{
|
||||
L4GraphicsPort *pp =
|
||||
static_cast<L4GraphicsPort*>(cgr->GetGraphicsPort(cand[i]));
|
||||
if (pp != NULL && (pp->GetEnableID() & 0x7F) == phase)
|
||||
{ who = cand[i]; break; }
|
||||
}
|
||||
}
|
||||
char big[96];
|
||||
sprintf(big, "%s %s = %s", w->monitorName, chName[phase], who);
|
||||
RECT br = w->surfaceRect;
|
||||
br.top += (br.bottom - br.top) / 3;
|
||||
SetBkMode(dc, TRANSPARENT);
|
||||
DrawLabelText(dc, big, titleFont, &br, RGB(0, 0, 0),
|
||||
DT_CENTER | DT_SINGLELINE | DT_VCENTER);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- PANEL IDENTIFY (BT_POD_IDENT=1) --------------------------------
|
||||
// Pod bring-up: nobody can tell which PHYSICAL panel a Windows display
|
||||
// is, and desktop coordinate order says nothing about where a panel
|
||||
// sits in the cab -- guessing that mapping is exactly how it came out
|
||||
// wrong. So label each surface ON ITS OWN GLASS with what it is, which
|
||||
// monitor it came from, and (in RGB mode) the channel->surface map.
|
||||
// Walk the pod, read the panels, write the layout from what you SAW.
|
||||
if (PodIdentMode())
|
||||
{
|
||||
char line1[96], line2[160];
|
||||
sprintf(line1, "%s", w->title);
|
||||
if (w->groupCount > 0)
|
||||
{
|
||||
// name the channels so a splitter-fed trio is self-describing
|
||||
char parts[128];
|
||||
parts[0] = 0;
|
||||
GaugeRenderer *igr = BTResolveGaugeRenderer();
|
||||
const char *names[4]; int chans[4]; int nn = 0;
|
||||
names[nn] = w->portPrimary; chans[nn] = -1; ++nn;
|
||||
for (int gi = 0; gi < w->groupCount && nn < 4; ++gi)
|
||||
{ names[nn] = w->groupPort[gi]; chans[nn] = -1; ++nn; }
|
||||
for (int i = 0; i < nn; ++i)
|
||||
{
|
||||
const char *chName = "?";
|
||||
if (igr != NULL)
|
||||
{
|
||||
L4GraphicsPort *pp =
|
||||
static_cast<L4GraphicsPort*>(igr->GetGraphicsPort(names[i]));
|
||||
if (pp != NULL)
|
||||
switch (pp->GetEnableID() & 0x7F)
|
||||
{
|
||||
case L4GraphicsPort::RedChannel: chName = "RED"; break;
|
||||
case L4GraphicsPort::GreenChannel: chName = "GREEN"; break;
|
||||
case L4GraphicsPort::BlueChannel: chName = "BLUE"; break;
|
||||
case L4GraphicsPort::AllChannels: chName = "RGB"; break;
|
||||
default: chName = "blank"; break;
|
||||
}
|
||||
}
|
||||
char one[48];
|
||||
sprintf(one, "%s%s=%s", (i ? " " : ""), chName, names[i]);
|
||||
if (strlen(parts) + strlen(one) < sizeof(parts) - 1)
|
||||
strcat(parts, one);
|
||||
}
|
||||
sprintf(line2, "%s %s", w->monitorName, parts);
|
||||
}
|
||||
else
|
||||
{
|
||||
sprintf(line2, "%s surface=%s", w->monitorName,
|
||||
w->portPrimary ? w->portPrimary : "-");
|
||||
}
|
||||
|
||||
RECT lr = w->surfaceRect;
|
||||
lr.bottom = lr.top + 64;
|
||||
HBRUSH back = CreateSolidBrush(RGB(0, 0, 0));
|
||||
FillRect(dc, &lr, back);
|
||||
DeleteObject(back);
|
||||
SetBkMode(dc, TRANSPARENT);
|
||||
RECT t1 = lr; t1.top += 4; t1.bottom = t1.top + 30;
|
||||
DrawLabelText(dc, line1, titleFont, &t1, RGB(255, 255, 0),
|
||||
DT_LEFT | DT_SINGLELINE | DT_VCENTER);
|
||||
RECT t2 = lr; t2.top += 32; t2.bottom = t2.top + 28;
|
||||
DrawLabelText(dc, line2, buttonFont, &t2, RGB(0, 255, 255),
|
||||
DT_LEFT | DT_SINGLELINE | DT_VCENTER);
|
||||
}
|
||||
}
|
||||
|
||||
BitBlt(winDC, 0, 0, client.right - client.left, client.bottom - client.top,
|
||||
@@ -1063,11 +1576,23 @@ void
|
||||
if (gWinCount != 0)
|
||||
return; // already up
|
||||
|
||||
// BT_GAUGE_SEC_ROT: the portrait-CRT unrotation direction (shared with the
|
||||
// D3D dev path; default 3 = CW = user-verified upright).
|
||||
// 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).
|
||||
// 0 and 2 keep the picture LANDSCAPE, which a modern replacement panel
|
||||
// needs. (Until 2026-08-06 this read only '1' and forced everything else
|
||||
// to 3, so BT_GAUGE_SEC_ROT=2 was silently ignored -- caught on the cart
|
||||
// when a 180 request kept coming back portrait.)
|
||||
{
|
||||
const char *rv = getenv("BT_GAUGE_SEC_ROT");
|
||||
gRadarRot = (rv != NULL && rv[0] == '1') ? 1 : 3;
|
||||
if (rv != NULL && rv[0] >= '0' && rv[0] <= '3')
|
||||
gRadarRot = rv[0] - '0';
|
||||
else
|
||||
gRadarRot = 3;
|
||||
DEBUG_STREAM << "[glasswin] radar rotation " << gRadarRot
|
||||
<< (gRadarRot == 3 ? " (90 CW)" : gRadarRot == 1 ? " (90 CCW)"
|
||||
: gRadarRot == 2 ? " (180)" : " (none)")
|
||||
<< std::endl << std::flush;
|
||||
}
|
||||
|
||||
memset(latched, 0, sizeof(latched));
|
||||
@@ -1075,6 +1600,8 @@ void
|
||||
sRepositionedToMain = false;
|
||||
if (gStage == NULL)
|
||||
gStage = new unsigned long[640 * 480];
|
||||
if (gStage2 == NULL)
|
||||
gStage2 = new unsigned long[640 * 480];
|
||||
|
||||
// Build the seven windows. Order fixes the ComputeLayout roles (0..6).
|
||||
BuildMfd (gWins[0], "Heat MFD", "Heat", NULL, 0x2F); // UL 0x28-0x2F
|
||||
@@ -1086,6 +1613,52 @@ void
|
||||
BuildFlight(gWins[6]); // 0x38-0x47
|
||||
gWinCount = 7;
|
||||
|
||||
// POD SURFACE MODE: crop every display to its picture and drop the Flight
|
||||
// Controls pad (it is pure desktop chrome -- the cab has a real stick).
|
||||
if (PodSurfaceMode())
|
||||
{
|
||||
for (int i = 0; i < gWinCount; ++i)
|
||||
MakeBareSurface(gWins[i]);
|
||||
if (gWins[6].portPrimary == NULL)
|
||||
gWinCount = 6; // drop Flight Controls
|
||||
}
|
||||
|
||||
// RGB SPLIT MODE (BT_POD_RGB): the cab's real wiring -- ONE VGA port per
|
||||
// window, its R/G/B lines split to three mono monitors. Collapse the five
|
||||
// separate MFD windows into the TWO ports the pod actually drives
|
||||
// (L4GAUGE.CFG clut2 = Comm/Mfd2/Heat, clut1 = Mfd1/Mfd3), each window
|
||||
// compositing its group into the colour channels; the radar keeps its own
|
||||
// full-colour port. Channels are read live from each port, so a page swap
|
||||
// to an Eng plane follows automatically.
|
||||
if (PodRgbSplitMode())
|
||||
{
|
||||
GWin port2 = gWins[0]; // reuse the Heat window's shape
|
||||
port2.title = "VGA Port A"; // Comm=red, Mfd2=green, Heat=blue
|
||||
port2.portPrimary = "Heat"; // base plane (blue); group adds the rest
|
||||
port2.portAlt = NULL;
|
||||
port2.groupCount = 2;
|
||||
port2.groupPort[0] = "Comm"; port2.groupAlt[0] = NULL;
|
||||
port2.groupPort[1] = "Mfd2"; port2.groupAlt[1] = "Eng2";
|
||||
port2.monoTint = (int)0x000000FF; // Heat rides BLUE
|
||||
|
||||
GWin port1 = gWins[3]; // the Mfd1 window's shape
|
||||
port1.title = "VGA Port B"; // Mfd1=red, Mfd3=green (blue spare)
|
||||
port1.portPrimary = "Mfd1";
|
||||
port1.portAlt = "Eng1";
|
||||
port1.groupCount = 1;
|
||||
port1.groupPort[0] = "Mfd3"; port1.groupAlt[0] = "Eng3";
|
||||
port1.monoTint = (int)0x00FF0000; // Mfd1 rides RED
|
||||
|
||||
GWin radar = gWins[5]; // unchanged: its own colour port
|
||||
|
||||
gWins[0] = port2;
|
||||
gWins[1] = port1;
|
||||
gWins[2] = radar;
|
||||
gWinCount = 3;
|
||||
DEBUG_STREAM << "[glasswin] RGB SPLIT MODE: 2 VGA ports + radar "
|
||||
"(each port's R/G/B feeds three mono panels)\n" << std::flush;
|
||||
}
|
||||
|
||||
titleFont = CreateFontW(-11, 0, 0, 0, FW_BOLD, 0, 0, 0,
|
||||
DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, 0, L"Segoe UI");
|
||||
buttonFont = CreateFontW(-10, 0, 0, 0, FW_NORMAL, 0, 0, 0,
|
||||
@@ -1141,6 +1714,39 @@ void
|
||||
SetLayeredWindowAttributes(w.hwnd, 0, 255, LWA_ALPHA);
|
||||
SetTimer(w.hwnd, RepaintTimerId, RepaintMilliseconds, NULL);
|
||||
ShowWindow(w.hwnd, SW_SHOWNOACTIVATE);
|
||||
|
||||
// PLACEMENT RECEIPT (pod bring-up, 2026-08-06): which PHYSICAL monitor
|
||||
// each display actually landed on. On a real cab -- or any remote
|
||||
// session -- nobody can see all seven surfaces at once, so the log is
|
||||
// the only way to confirm the map without walking around the pod.
|
||||
{
|
||||
RECT wr = { 0, 0, 0, 0 };
|
||||
GetWindowRect(w.hwnd, &wr);
|
||||
HMONITOR mon = MonitorFromWindow(w.hwnd, MONITOR_DEFAULTTONEAREST);
|
||||
MONITORINFOEXA mi;
|
||||
memset(&mi, 0, sizeof(mi));
|
||||
mi.cbSize = sizeof(mi);
|
||||
const char *devName = "?";
|
||||
int mx = 0, my = 0, mw = 0, mh = 0, primary = 0;
|
||||
if (mon != NULL && GetMonitorInfoA(mon, (MONITORINFO *)&mi))
|
||||
{
|
||||
devName = mi.szDevice;
|
||||
strncpy(w.monitorName, mi.szDevice, sizeof(w.monitorName) - 1);
|
||||
w.monitorName[sizeof(w.monitorName) - 1] = 0;
|
||||
mx = mi.rcMonitor.left; my = mi.rcMonitor.top;
|
||||
mw = mi.rcMonitor.right - mi.rcMonitor.left;
|
||||
mh = mi.rcMonitor.bottom - mi.rcMonitor.top;
|
||||
primary = (mi.dwFlags & MONITORINFOF_PRIMARY) ? 1 : 0;
|
||||
}
|
||||
DEBUG_STREAM << "[glasswin] '" << w.title << "' surface="
|
||||
<< (w.portPrimary ? w.portPrimary : "-")
|
||||
<< " at " << wr.left << "," << wr.top
|
||||
<< " " << (wr.right - wr.left) << "x" << (wr.bottom - wr.top)
|
||||
<< (w.noFrame ? " bare" : " framed")
|
||||
<< " -> monitor " << devName
|
||||
<< " (" << mx << "," << my << " " << mw << "x" << mh
|
||||
<< (primary ? ", PRIMARY)" : ")") << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
DEBUG_STREAM << "[glasswin] per-display cockpit up (" << gWinCount
|
||||
@@ -1187,6 +1793,74 @@ void
|
||||
// one-shot re-snap); a focused window just repaints from whichever fires first.
|
||||
//###########################################################################
|
||||
|
||||
//
|
||||
// GLASS DIRTY-SKIP (2026-08-09): re-blit only the windows whose content changed.
|
||||
// Each window is a plane of the ONE shared gauge pixelBuffer, so its change token is
|
||||
// the masked plane checksum (SVGA16::PlaneChecksum over every port that can feed the
|
||||
// window) combined with each button's RENDERED lamp brightness + held/latched state.
|
||||
// Folding the flash BRIGHTNESS in (not the raw lamp state) means a flashing lamp
|
||||
// repaints exactly when it toggles, and a static panel -- or a fully idle cockpit --
|
||||
// skips its expand + StretchDIBits entirely. The plane checksum is the only added
|
||||
// cost (once per window per ~16 Hz pump) and is far cheaper than the paint it saves.
|
||||
//
|
||||
static unsigned long
|
||||
GlassWindowToken(GaugeRenderer *gr, GWin *w, unsigned long tick)
|
||||
{
|
||||
unsigned long token = 2166136261UL;
|
||||
|
||||
if (w->portPrimary != NULL && gr != NULL)
|
||||
{
|
||||
// Every plane that could feed the window (primary + Eng twin + RGB group +
|
||||
// their twins) -- OR their masks so a change to ANY is caught; checksum once.
|
||||
const char *ports[8];
|
||||
int np = 0;
|
||||
ports[np++] = w->portPrimary;
|
||||
if (w->portAlt != NULL) ports[np++] = w->portAlt;
|
||||
for (int gi = 0; gi < w->groupCount && np < 7; ++gi)
|
||||
{
|
||||
ports[np++] = w->groupPort[gi];
|
||||
if (w->groupAlt[gi] != NULL && np < 8) ports[np++] = w->groupAlt[gi];
|
||||
}
|
||||
int combined = 0;
|
||||
SVGA16 *svga = NULL;
|
||||
for (int i = 0; i < np; ++i)
|
||||
{
|
||||
L4GraphicsPort *p =
|
||||
static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(ports[i]));
|
||||
if (p == NULL) continue;
|
||||
combined |= p->GetBitMask();
|
||||
if (svga == NULL) svga = static_cast<SVGA16*>(p->graphicsDisplay);
|
||||
}
|
||||
if (svga != NULL && combined != 0)
|
||||
{
|
||||
token ^= svga->PlaneChecksum(combined);
|
||||
// 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
|
||||
// press repaints exactly the window it lives on.
|
||||
for (int j = 0; j < w->buttonCount; ++j)
|
||||
{
|
||||
int addr = w->buttons[j].address;
|
||||
unsigned long shade =
|
||||
(unsigned long)LampBrightnessOf(PadRIO::GetLampState(addr), tick);
|
||||
int held = (addr == pressedAddress) || latched[addr & 0x7F];
|
||||
token = (token ^ ((unsigned long)(addr & 0xFF) << 4)
|
||||
^ (shade << 1) ^ (unsigned long)held) * 16777619UL;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
void
|
||||
BTGlassPanels_Tick()
|
||||
{
|
||||
@@ -1199,12 +1873,38 @@ void
|
||||
return;
|
||||
sLastPaint = now;
|
||||
|
||||
GaugeRenderer *gr = BTResolveGaugeRenderer();
|
||||
int repainted = 0;
|
||||
for (int i = 0; i < gWinCount; ++i)
|
||||
{
|
||||
if (gWins[i].hwnd != NULL)
|
||||
GWin &w = gWins[i];
|
||||
if (w.hwnd == NULL)
|
||||
continue;
|
||||
unsigned long token = GlassWindowToken(gr, &w, now);
|
||||
if (w.haveToken && token == w.lastToken)
|
||||
continue; // gauges + lamps unchanged -> skip this window
|
||||
w.lastToken = token;
|
||||
w.haveToken = 1;
|
||||
++repainted;
|
||||
InvalidateRect(w.hwnd, NULL, FALSE);
|
||||
UpdateWindow(w.hwnd); // synchronous paint, not the throttled queue
|
||||
}
|
||||
|
||||
// BT_GLASS_DIRTY: report how many window-repaints the dirty-skip let through vs
|
||||
// the old fixed gWinCount-per-pump, so the saving is visible.
|
||||
static int sDirtyLog = -1;
|
||||
if (sDirtyLog < 0) sDirtyLog = getenv("BT_GLASS_DIRTY") ? 1 : 0;
|
||||
if (sDirtyLog)
|
||||
{
|
||||
static unsigned long sWin = 0;
|
||||
static int sPumps = 0, sPaints = 0;
|
||||
++sPumps; sPaints += repainted;
|
||||
if (now - sWin >= 2000)
|
||||
{
|
||||
InvalidateRect(gWins[i].hwnd, NULL, FALSE);
|
||||
UpdateWindow(gWins[i].hwnd); // synchronous paint, not the throttled queue
|
||||
DEBUG_STREAM << "[glass-dirty] " << sPumps << " pumps -> " << sPaints
|
||||
<< " window-repaints (was " << (sPumps * gWinCount) << " always-on)\n"
|
||||
<< std::flush;
|
||||
sWin = now; sPumps = 0; sPaints = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,23 @@ L4GaugeRenderer::L4GaugeRenderer(bool windowed, int *secondaryIndex, int *aux1In
|
||||
char
|
||||
*plasma_string = getenv("L4PLASMA");
|
||||
|
||||
//
|
||||
// L4PLASMA=NONE / OFF / 0 -- no plasma marquee at all (2026-08-06).
|
||||
// A real cab drives the 128x32 marquee over serial; a rebuilt cart may
|
||||
// not have one wired, and the GLASS profile force-defaults L4PLASMA to
|
||||
// SCREEN, which puts a desktop plasma window on the pod's glass where
|
||||
// nothing should be. Leaving the variable UNSET is not an option
|
||||
// there (the profile fills it in), so give "off" an explicit spelling.
|
||||
//
|
||||
if (plasma_string != NULL
|
||||
&& (_stricmp(plasma_string, "NONE") == 0
|
||||
|| _stricmp(plasma_string, "OFF") == 0
|
||||
|| strcmp(plasma_string, "0") == 0))
|
||||
{
|
||||
Tell("Plasma display disabled (L4PLASMA=none)\n");
|
||||
plasma_string = NULL;
|
||||
}
|
||||
|
||||
if (plasma_string != NULL)
|
||||
{
|
||||
//Win32 Serial support: ADB 06/30/07
|
||||
|
||||
@@ -466,6 +466,21 @@ void
|
||||
<< (pressed ? "HELD" : "released") << "\n" << std::flush;
|
||||
}
|
||||
|
||||
// PANIC (0x3D) -> pilot-keypad key. The binary's ONLY pilot-eject input
|
||||
// is keyboardGroup[KeyboardPilot].Add(mode 0x200000, mech, msg 0x19)
|
||||
// (FUN_004d266c; exhaustive scan: no other 0x200000 consumer). The
|
||||
// guarded PANIC key has no button-space consumer anywhere in the image,
|
||||
// so on the pod it reported through the pilot keypad matrix -- this is
|
||||
// that wire.
|
||||
if (address == 0x3D && pressed)
|
||||
EmitKeypad(LBE4ControlsManager::KeyboardPilot, 0);
|
||||
|
||||
// (The ENG-PAGE EJECT KEY intent-completion lives in the game-thread RIO
|
||||
// drain -- LBE4ControlsManager::ProcessRIOEvent, ButtonPressedEvent --
|
||||
// where mode_mask is safely in hand. A first cut queried the mode
|
||||
// manager HERE, on the window-click thread, and crashed on the unbound
|
||||
// TU global (field-caught 2026-08-03).)
|
||||
|
||||
RIOEvent event;
|
||||
event.Type = pressed ? ButtonPressedEvent : ButtonReleasedEvent;
|
||||
event.Data.Unit = address;
|
||||
@@ -479,6 +494,9 @@ void
|
||||
event.Type = KeyEvent;
|
||||
event.Data.Keyboard.Unit = unit;
|
||||
event.Data.Keyboard.Key = key;
|
||||
if (getenv("BT_PAD_LOG"))
|
||||
DEBUG_STREAM << "[padkey] unit " << unit << " key " << key
|
||||
<< " queued\n" << std::flush;
|
||||
PushEvent(event);
|
||||
}
|
||||
|
||||
@@ -524,6 +542,33 @@ void
|
||||
DEBUG_STREAM << "[btntest] RELEASE 0x" << std::hex << s_btnAddr
|
||||
<< std::dec << " at poll " << s_poll << "\n" << std::flush;
|
||||
}
|
||||
// BT_BTNTEST2="addr,pressPoll,releasePoll": an optional SECOND
|
||||
// scripted cycle (e.g. crouch then rise) on the same poll clock.
|
||||
static int s_bt2Addr = -2, s_bt2On = 0, s_bt2Off = 0, s_bt2State = 0;
|
||||
if (s_bt2Addr == -2)
|
||||
{
|
||||
s_bt2Addr = -1;
|
||||
const char *e2 = getenv("BT_BTNTEST2");
|
||||
if (e2 != NULL)
|
||||
sscanf(e2, "%i,%i,%i", &s_bt2Addr, &s_bt2On, &s_bt2Off);
|
||||
}
|
||||
if (s_bt2Addr >= 0)
|
||||
{
|
||||
if (s_bt2State == 0 && s_poll >= s_bt2On)
|
||||
{
|
||||
s_bt2State = 1;
|
||||
EmitButton(s_bt2Addr, 1);
|
||||
DEBUG_STREAM << "[btntest] PRESS2 0x" << std::hex << s_bt2Addr
|
||||
<< std::dec << " at poll " << s_poll << "\n" << std::flush;
|
||||
}
|
||||
else if (s_bt2State == 1 && s_poll >= s_bt2Off)
|
||||
{
|
||||
s_bt2State = 2;
|
||||
EmitButton(s_bt2Addr, 0);
|
||||
DEBUG_STREAM << "[btntest] RELEASE2 0x" << std::hex << s_bt2Addr
|
||||
<< std::dec << " at poll " << s_poll << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+100
-7
@@ -692,6 +692,28 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa
|
||||
device->DrawPrimitiveUP(D3DPT_TRIANGLEFAN, 2, quad, sizeof(InsetVert));
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// GLASS dirty-skip: FNV-1a over the shared pixelBuffer masked to `mask` -- the bits
|
||||
// one glass window can show. The glass repaint pump compares this per window and
|
||||
// re-blits only the ones whose plane changed (L4GLASSWIN BTGlassPanels_Tick). Full
|
||||
// pass (no stride) so a single-word gauge change is never missed; ~640*480 cheap
|
||||
// integer ops, run at most once per window per ~16 Hz pump.
|
||||
//===========================================================================//
|
||||
unsigned long SVGA16::PlaneChecksum(int mask) const
|
||||
{
|
||||
int w = pixelBuffer.Data.Size.x;
|
||||
int h = pixelBuffer.Data.Size.y;
|
||||
const Word *p = pixelBuffer.Data.MapPointer;
|
||||
if (p == NULL || w <= 0 || h <= 0)
|
||||
return 0;
|
||||
unsigned long sum = 2166136261UL; // FNV-1a offset basis
|
||||
Word m = (Word)mask;
|
||||
int n = w * h;
|
||||
for (int i = 0; i < n; ++i)
|
||||
sum = (sum ^ (unsigned long)(p[i] & m)) * 16777619UL;
|
||||
return sum;
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// GLASS per-display windows -- the CPU (no-D3D) analog of DrawDevSurface: expand
|
||||
// one bit-plane of the shared gauge pixelBuffer into a 32-bit BGRA image that the
|
||||
@@ -739,6 +761,34 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
|
||||
return;
|
||||
}
|
||||
|
||||
// 180 degrees (rotate 2): dimensions UNCHANGED -- the case a landscape
|
||||
// replacement panel needs when the 1995 glass was a portrait CRT mounted
|
||||
// the other way up. Added 2026-08-06 from the crash cart, whose radar is a
|
||||
// landscape LCD: 90 CW left the picture portrait and overhanging its
|
||||
// monitor, and a further 90 CW is this.
|
||||
if (rotateQuadrant == 2)
|
||||
{
|
||||
for (int oy = 0; oy < h; oy++)
|
||||
{
|
||||
unsigned long *d = dst + oy * w;
|
||||
for (int ox = 0; ox < w; ox++)
|
||||
{
|
||||
Word s = base[(h - 1 - oy) * w + (w - 1 - ox)];
|
||||
if (monoTint < 0)
|
||||
{
|
||||
PaletteTriplet *pe = &(pal->paletteData.Color[s & mask]);
|
||||
d[ox] = ((unsigned long)pe->Red << 16) |
|
||||
((unsigned long)pe->Green << 8) | (unsigned long)pe->Blue;
|
||||
}
|
||||
else
|
||||
d[ox] = (s & mask) ? (unsigned long)monoTint : 0UL;
|
||||
}
|
||||
}
|
||||
if (outW) *outW = w;
|
||||
if (outH) *outH = h;
|
||||
return;
|
||||
}
|
||||
|
||||
// 90-degree rotation: output is transposed (ow = h, oh = w). rotate 3 = CW,
|
||||
// rotate 1 = CCW (the DrawDevSurface convention; BT_GAUGE_SEC_ROT picks it).
|
||||
int ow = h, oh = w;
|
||||
@@ -1180,6 +1230,15 @@ void BTDrawCockpitPanels(LPDIRECT3DDEVICE9 device)
|
||||
}
|
||||
}
|
||||
|
||||
// (NO full-face "flash overlay" here -- twice field-broken 2026-08-03.
|
||||
// The side buttons are BIG rects deliberately tucked UNDER the surfaces;
|
||||
// step 2 already animates their lamp shades, and the protruding edge IS
|
||||
// the port's lamp light (the pod's backlit keys sat beside the CRT --
|
||||
// there is no in-display face to light). An on-top redraw either covers
|
||||
// the MFD imagery with flashing faces, or -- drawn without resetting the
|
||||
// texture state DrawDevSurface leaves bound -- smears the gauge atlas as
|
||||
// striped garbage (operator captures: green stripes, then red faces).)
|
||||
|
||||
// 4) Flight-block labels (on top; the blue faces aren't covered by surfaces).
|
||||
for (int i = 0; i < L.buttonCount; i++)
|
||||
{
|
||||
@@ -1293,7 +1352,7 @@ void BTCockpitMouseUp(void)
|
||||
// that turn fires only ONCE PER FULL GAUGE CYCLE (foreground -> background -> copy).
|
||||
// The cycle only advances to the next foreground turn after the THROTTLED background
|
||||
// gauge sweep drains the whole active-instrument list -- and that background task
|
||||
// starves under load (issue #45: "every cockpit instrument freezes at its last paint
|
||||
// starves under load (field reports: "every cockpit instrument freezes at its last paint
|
||||
// while the underlying values AND fps stay perfectly healthy"). So on a busy MP
|
||||
// mission the lamp sweep ran ~1x/second: the lit buttons froze / flashes stalled while
|
||||
// the 3D view (a separate per-frame foreground render) stayed smooth -- exactly the
|
||||
@@ -5386,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");
|
||||
@@ -7131,6 +7191,7 @@ void
|
||||
|
||||
svga_palette->paletteData.Valid = True;
|
||||
svga_palette->modified = True;
|
||||
((SVGA16 *) graphicsDisplay)->paletteGeneration++; // glass dirty-skip (full rebuild)
|
||||
|
||||
Check_Fpu();
|
||||
}
|
||||
@@ -7175,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
|
||||
@@ -7216,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;
|
||||
}
|
||||
@@ -7251,6 +7329,8 @@ void
|
||||
|
||||
svga_palette->paletteData.Valid = True;
|
||||
svga_palette->modified = True;
|
||||
if (palette_changed)
|
||||
((SVGA16 *) graphicsDisplay)->paletteGeneration++; // glass dirty-skip
|
||||
|
||||
Check_Fpu();
|
||||
}
|
||||
@@ -7354,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 " <<
|
||||
@@ -7464,6 +7545,7 @@ void
|
||||
|
||||
svga_palette->paletteData.Valid = True;
|
||||
svga_palette->modified = True;
|
||||
((SVGA16 *) graphicsDisplay)->paletteGeneration++; // glass dirty-skip (full rebuild)
|
||||
|
||||
Check_Fpu();
|
||||
}
|
||||
@@ -7498,17 +7580,28 @@ void
|
||||
// LEFT AS HEAP GARBAGE and any pixmap pixel >= that count wrote the
|
||||
// garbage's high bits into other displays' planes (the #48 stray
|
||||
// blocks; convicted live by BT_PLANE_AUDIT -- the 480x640 radar
|
||||
// background carries index 217). Cycle the in-plane pattern across
|
||||
// the remainder: high-index art degrades to its (index mod 2^bits)
|
||||
// colour IN-PLANE, and can never leak. (The 1995 binary shipped the
|
||||
// same 64-entry fill and relied on art discipline; garbage is not a
|
||||
// preservable behaviour, so this is a guarded PORT deviation.)
|
||||
// background carries index 217). Map the remainder to the plane
|
||||
// BACKGROUND (translationTable[0]): high-index art renders as the
|
||||
// port's background colour in-plane, and can never leak.
|
||||
//
|
||||
// REFINED 2026-08-10 (the sec-surface phantoms): the first #48 fill
|
||||
// cycled the remainder IN-PLANE (index mod 2^bits), which mapped art
|
||||
// index 254 onto plane slot 62 -- a LIVE colorMapperMultiArmor damage
|
||||
// slot. SMODE.PCC's inactive control-mode box interiors and a stray
|
||||
// 52x13 idx-254 bar baked into BTSEC1.PCX (between the heading dial
|
||||
// and the armor rosette) lit up in the current right-armor damage
|
||||
// colour on every render path. On the shipped machine those regions
|
||||
// rendered BLACK (the heap garbage's low plane bits were 0 ->
|
||||
// in-plane index 0), so background IS the authentic on-screen result.
|
||||
// (The 1995 binary shipped the same 64-entry fill and relied on art
|
||||
// discipline; garbage is not a preservable behaviour, so this stays a
|
||||
// guarded PORT deviation.)
|
||||
{
|
||||
int filled = 1;
|
||||
for (int b = bitMask & 0xFF; b != 0; b &= (b - 1))
|
||||
filled <<= 1;
|
||||
for (int i = filled; i < 256; ++i)
|
||||
translationTable[i] = translationTable[i & (filled - 1)];
|
||||
translationTable[i] = translationTable[0];
|
||||
}
|
||||
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;
|
||||
@@ -419,6 +428,17 @@ public:
|
||||
// dwords; the image is written TOP-DOWN. *outW/*outH receive the produced size.
|
||||
void ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotateQuadrant,
|
||||
unsigned long *dst, int *outW, int *outH);
|
||||
|
||||
// GLASS dirty-skip (L4GLASSWIN, 2026-08-09): FNV-1a checksum of the shared
|
||||
// gauge pixelBuffer, masked to the bits a given window can show. Lets the
|
||||
// glass repaint pump re-blit ONLY the windows whose plane actually changed
|
||||
// (idle MFDs / static panels skip; the sweeping radar keeps updating).
|
||||
unsigned long PlaneChecksum(int mask) const;
|
||||
|
||||
// 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; }
|
||||
};
|
||||
|
||||
//########################################################################
|
||||
|
||||
@@ -4361,6 +4361,12 @@ void
|
||||
fogFar = searchLightFogFar;
|
||||
if(fogUpdating)
|
||||
{
|
||||
// BT (searchlight, 2026-08-05): the stubbed dpl_SetViewFog below also
|
||||
// carried the PLANES; the per-frame FOGSTART/FOGEND application reads
|
||||
// currentFogNear/Far, so the swap must land there too (the color line
|
||||
// alone left the fog DISTANCES stuck at load values).
|
||||
currentFogNear = fogNear;
|
||||
currentFogFar = fogFar;
|
||||
mDevice->SetRenderState(D3DRS_FOGCOLOR, D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen), (int)(255 * fogBlue)));
|
||||
// dpl_SetViewFog(
|
||||
// dplMainView,
|
||||
@@ -4381,6 +4387,8 @@ void
|
||||
fogFar = noSearchLightFogFar;
|
||||
if(fogUpdating)
|
||||
{
|
||||
currentFogNear = fogNear; // see the On case
|
||||
currentFogFar = fogFar;
|
||||
mDevice->SetRenderState(D3DRS_FOGCOLOR, D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen), (int)(255 * fogBlue)));
|
||||
// dpl_SetViewFog(
|
||||
// dplMainView,
|
||||
|
||||
@@ -350,6 +350,12 @@ struct MatInfo {
|
||||
// EMISSIVE (tag 0x26): pure-emissive materials (diffuse black) render as an
|
||||
// unlit glow -- tex x emissive (btpolar:pintBIceEmit_mtl, the polar ice).
|
||||
bool hasEmissive = false;
|
||||
// BTFX "brighten" class (SPOT.BGF searchlight beam cone): the material
|
||||
// smuggles its ADDITIVE factor in DIFFUSE.r (brighten.25 authors diffuse
|
||||
// {0.25,0,0}) and its glow colour in EMISSIVE ({0.9,0.4,0.2} on the night
|
||||
// page). 0 = not a brighten material; >0 = draw as an additive veil,
|
||||
// dest += emissive x factor (the 1995 DIV brighten semantics).
|
||||
float brightenFactor = 0.0f;
|
||||
float emissive[3] = {0,0,0};
|
||||
};
|
||||
|
||||
@@ -495,12 +501,14 @@ struct MaterialResolver {
|
||||
std::string name, texName, rampName;
|
||||
MatInfo info;
|
||||
bool haveColor = false;
|
||||
float rawDiffuseR = -1.0f; // brighten-class factor carrier
|
||||
for (const Chunk& ch : c.children) {
|
||||
if (ch.id == TAG_NAME) name = chunkStr(ch);
|
||||
else if (ch.id == TAG_MATERIAL_TEXTURE && ch.len > 1) texName = chunkStr(ch, 1);
|
||||
else if (ch.id == TAG_RAMP_REF) rampName = chunkStr(ch);
|
||||
else if (ch.id == TAG_DIFFUSE && ch.len >= 12) {
|
||||
info.color = packColor(rdF32(ch.data), rdF32(ch.data + 4), rdF32(ch.data + 8));
|
||||
rawDiffuseR = rdF32(ch.data);
|
||||
haveColor = true;
|
||||
info.hasDiffuse = true;
|
||||
} else if (ch.id == TAG_AMBIENT && ch.len >= 12 && !haveColor
|
||||
@@ -520,6 +528,12 @@ struct MaterialResolver {
|
||||
info.hasEmissive = (info.emissive[0] + info.emissive[1] + info.emissive[2]) > 0.001f;
|
||||
}
|
||||
}
|
||||
// BTFX brighten class: name-gated so ordinary materials keep
|
||||
// their diffuse semantics untouched (the factor rides only in
|
||||
// "brighten*" records -- brighten.25/.5/.75 in btfx.bmf).
|
||||
if (name.compare(0, 8, "brighten") == 0 && rawDiffuseR > 0.0f)
|
||||
info.brightenFactor = rawDiffuseR;
|
||||
|
||||
// Resolve the ramp reference to its low/high colours: this
|
||||
// file's own definitions first, then the cross-library
|
||||
// registry (mech skins reference 'softer' defined elsewhere).
|
||||
@@ -640,10 +654,12 @@ struct Builder {
|
||||
bool currentTSphere = false; // material is tsphere_mtl (translocation warp): ramp it despite normals
|
||||
bool meshIsTSphere = false; // this OBJECT is the translocation warp -> smooth-tessellate the cone
|
||||
bool meshIsCop = false; // this OBJECT is a *_cop cockpit canopy shell (task #55)
|
||||
std::string meshStem; // lowercased load stem (BT_MAT_LOG diag)
|
||||
std::map<const void*, int> copRoleMap; // punch-kit roles per PMESH chunk: 1=mask 2=hull 3=skip (see TAG_PATCH)
|
||||
int currentCopRole = 0; // role of the pmesh being built (consumed by buildPmesh)
|
||||
bool currentHasEmissive = false;
|
||||
float currentEmissive[3] = {0,0,0};
|
||||
float currentBrighten = 0.0f; // btfx brighten-class additive factor
|
||||
|
||||
// per-vertex scratch (normals accumulated from forward triangles only)
|
||||
std::vector<float> px, py, pz, nx, ny, nz, uu, vv;
|
||||
@@ -778,7 +794,19 @@ struct Builder {
|
||||
// value is not recoverable from the code (texture-less ramp = no texel
|
||||
// luminance to index); default (0.13,0.12,0.15) [T3] matches the dark
|
||||
// near-black frame in pod gameplay footage. BT_COP_FRAME="r g b" overrides.
|
||||
if (useRamp && currentTex.empty() && meshIsCop) {
|
||||
// The same interior-structure treatment applies to the blakskn SKIN
|
||||
// family on ANY mesh, not just *_cop shells: the inside skeletons
|
||||
// author pilot-facing surfaces of the OWN-BODY meshes (thor thx_msl
|
||||
// missile pod, owens owx_* legs -- issue #91) with the identical
|
||||
// "<pfx>skin:blakskn_dz_*" material the canopy frame uses, and the
|
||||
// board shaded them all the same way. Keying on the filename alone
|
||||
// left those surfaces vcol=pure-black -- the reported "black
|
||||
// rectangle" was the thor pod's blakskn mount plate at RGB(0,0,0)
|
||||
// instead of the frame constant. mechfx:blakskn_mtl (the tshd
|
||||
// shadow quads) must NOT match -- key on the skin-lib "_dz_" form.
|
||||
const bool blaksknSkin =
|
||||
currentMatName.find("skin:blakskn_dz_") != std::string::npos;
|
||||
if (useRamp && currentTex.empty() && (meshIsCop || blaksknSkin)) {
|
||||
auto CB = [](float ff){ int v=(int)(ff*255.0f+0.5f); return (uint32_t)(v<0?0:v>255?255:v); };
|
||||
vcol = 0xFF000000u | (CB(copFrameRGB(0))<<16) | (CB(copFrameRGB(1))<<8) | CB(copFrameRGB(2));
|
||||
}
|
||||
@@ -913,6 +941,7 @@ struct Builder {
|
||||
batch.shadowMat = currentShadowMat;
|
||||
batch.hasEmissive = currentHasEmissive;
|
||||
for (int i = 0; i < 3; ++i) batch.emissive[i] = currentEmissive[i];
|
||||
batch.brightenFactor = currentBrighten; // btfx additive veil (spot cone)
|
||||
// SUBMISSION-ORDER DEPTH BIAS (additive objects): the content layers
|
||||
// EXACTLY-COPLANAR shells (AR02 LOD2: solid concrete + a coplanar
|
||||
// PUNCH-cutout overlay + an inner shell, plane separations
|
||||
@@ -939,6 +968,28 @@ struct Builder {
|
||||
else
|
||||
batch.lodBias = 0.0f;
|
||||
mesh->batches.push_back(batch);
|
||||
// #91 diag: BT_MAT_LOG=<substr> dumps each batch of a matching mesh
|
||||
// stem -- the material routing decision (ramp vs lit vs emissive,
|
||||
// resolved colour, texture) for the inside-view black-mesh hunt.
|
||||
{
|
||||
static const char* s_matLog = getenv("BT_MAT_LOG");
|
||||
if (s_matLog && *s_matLog
|
||||
&& meshStem.find(s_matLog) != std::string::npos)
|
||||
fprintf(stderr, "[matlog] %s mat=%s color=%08X hasDiff=%d "
|
||||
"tex='%s' ch=%d hasRamp=%d useRamp=%d norms=%d "
|
||||
"emis=%d vcol=%08X tris=%u\n",
|
||||
meshStem.c_str(), currentMatName.c_str(), currentColor,
|
||||
(int)currentHasDiffuse, currentTex.c_str(),
|
||||
currentTexChannel, (int)currentHasRamp, (int)useRamp,
|
||||
(int)hasNormals(vtag), (int)pureEmissive, vcol,
|
||||
idxCount / 3);
|
||||
if (s_matLog && *s_matLog
|
||||
&& meshStem.find(s_matLog) != std::string::npos)
|
||||
fprintf(stderr, "[matlog] ramp lo=(%.3f,%.3f,%.3f) "
|
||||
"hi=(%.3f,%.3f,%.3f)\n",
|
||||
currentRampLo[0], currentRampLo[1], currentRampLo[2],
|
||||
currentRampHi[0], currentRampHi[1], currentRampHi[2]);
|
||||
}
|
||||
if (getenv("BT_COP_DUMP") && currentHasRamp && currentTex.empty()) {
|
||||
float lo[3]={1e9f,1e9f,1e9f}, hi[3]={-1e9f,-1e9f,-1e9f}, cen[3]={0,0,0}; int nv=0;
|
||||
for (uint32_t i = idxStart; i < idxStart + idxCount; ++i) {
|
||||
@@ -1216,8 +1267,9 @@ struct Builder {
|
||||
for (int i = 0; i < 3; ++i) { currentRampLo[i] = info.rampLo[i]; currentRampHi[i] = info.rampHi[i]; }
|
||||
currentHasEmissive = info.hasEmissive;
|
||||
for (int i = 0; i < 3; ++i) currentEmissive[i] = info.emissive[i];
|
||||
currentBrighten = info.brightenFactor;
|
||||
}
|
||||
else { currentColor = colorForMaterial(full); currentTex.clear(); currentHasDiffuse = false; currentTexChannel = 0; currentTexScroll = false; currentTexScrollU = currentTexScrollV = 0.0f; }
|
||||
else { currentColor = colorForMaterial(full); currentTex.clear(); currentHasDiffuse = false; currentTexChannel = 0; currentTexScroll = false; currentTexScrollU = currentTexScrollV = 0.0f; currentBrighten = 0.0f; }
|
||||
// TRANSLOCATION WARP: override the material's coarse "sky" ramp with the
|
||||
// NARROW LAVENDER ramp that matched the original (capture.png). The swirl
|
||||
// is the bintA cloud coloured lo->hi by luminance; a wide sky ramp
|
||||
@@ -1386,6 +1438,7 @@ bool LoadBgfFile(const std::string& name, BgfData& out) {
|
||||
// single-siding + dark-frame ramp reconstruction in emitTri/buildPmesh applies to
|
||||
// these meshes only.
|
||||
b.meshIsCop = stemLower(name).find("_cop") != std::string::npos;
|
||||
b.meshStem = stemLower(name);
|
||||
b.res = &res;
|
||||
for (const Chunk& c : roots) b.collect(c);
|
||||
b.finish();
|
||||
|
||||
@@ -44,6 +44,12 @@ struct BgfDrawBatch {
|
||||
// alpha). The verts keep their authored RGBA gradient and the draw routes
|
||||
// to the alpha-blend pass, unlit, colour = texture x vertex gradient.
|
||||
bool vertexAlpha = false;
|
||||
// BTFX "brighten" ADDITIVE VEIL (the searchlight SPOT.BGF beam cone): the
|
||||
// material class authors its factor in DIFFUSE.r and its glow colour in
|
||||
// EMISSIVE; the batch draws in the blend pass as dest += emissive x factor
|
||||
// (1995 DIV brighten semantics; the record also authors fog IMMUNE +
|
||||
// DRAWLAST, both satisfied by the blend pass). 0 = not a brighten batch.
|
||||
float brightenFactor = 0.0f;
|
||||
// TEXTURE ALPHA CUTOUT (issue #3, the wreck scorch splat): an RGBA4444 BSL
|
||||
// slice (texChannel >= 8) carries an AUTHORED alpha channel -- in the shipped
|
||||
// content it is a BINARY 0/240 cutout mask (bexp9 = the scorch splat
|
||||
|
||||
@@ -109,6 +109,11 @@ struct L4DRAWOP
|
||||
// UNLIT, colour = texture x the authored per-vertex gradient, alpha = the
|
||||
// per-vertex fade (SRCALPHA/INVSRCALPHA).
|
||||
bool vertexAlphaBlend;
|
||||
// BTFX BRIGHTEN VEIL (the searchlight SPOT.BGF cone): additive brighten of
|
||||
// the framebuffer, dest += material.Emissive x this factor (the class
|
||||
// authors the factor in DIFFUSE.r, the glow colour in EMISSIVE). Drawn in
|
||||
// the alpha-blend pass, unlit, fog-immune by authoring. 0 = off.
|
||||
float brightenAlpha;
|
||||
// COCKPIT PUNCH STENCIL-CUT (task #55, i860-firmware-decoded): 0=normal,
|
||||
// 1=aperture MASK (drawn stencil-only, paired with the following hull op),
|
||||
// 2=HULL (drawn stencil-rejected under the mask = window cutouts).
|
||||
|
||||
+42
-1
@@ -407,6 +407,32 @@ static const char *gBTCwdFixNote = NULL;
|
||||
//
|
||||
int gBTFitDisplay = 0;
|
||||
|
||||
//
|
||||
// L4PLASMA=NONE/OFF/0 means "no marquee at all" (L4GREND.cpp reads the same
|
||||
// spellings). Only used to keep the boot banner honest.
|
||||
//
|
||||
static bool BTPlasmaDisabled(void)
|
||||
{
|
||||
const char *p = getenv("L4PLASMA");
|
||||
return p != NULL && (_stricmp(p, "NONE") == 0 ||
|
||||
_stricmp(p, "OFF") == 0 ||
|
||||
strcmp(p, "0") == 0);
|
||||
}
|
||||
|
||||
//
|
||||
// Which control device the GLASS profile actually resolved to. The banner
|
||||
// used to hardcode "PadRIO", which is a lie on a cab wired to the real board
|
||||
// -- and precisely the log line someone reads to check that (2026-08-06).
|
||||
//
|
||||
static const char *BTControlsSummary(void)
|
||||
{
|
||||
const char *c = getenv("L4CONTROLS");
|
||||
if (c == NULL) return "PadRIO";
|
||||
if (_strnicmp(c, "RIO", 3) == 0) return "hardware RIO";
|
||||
if (_strnicmp(c, "PAD", 3) == 0) return "PadRIO";
|
||||
return c;
|
||||
}
|
||||
|
||||
static void BTEnsureContentDirectory(void)
|
||||
{
|
||||
if (GetFileAttributesA("BTL4.RES") != INVALID_FILE_ATTRIBUTES)
|
||||
@@ -848,6 +874,18 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
|
||||
{
|
||||
gBTFitDisplay = 1;
|
||||
}
|
||||
//
|
||||
// BT_FIT=1 -- the env spelling of -fit, so a POD can freeze it in
|
||||
// content\environ.ini alongside the rest of its rig config instead of
|
||||
// depending on every launcher passing the flag (2026-08-06, crash cart:
|
||||
// the main view has to fill its 800x600 panel on every boot, however the
|
||||
// game gets started -- shortcut, autostart or scheduled task).
|
||||
//
|
||||
{
|
||||
const char *fitEnv = getenv("BT_FIT");
|
||||
if (fitEnv != NULL && fitEnv[0] != '\0' && fitEnv[0] != '0')
|
||||
gBTFitDisplay = 1;
|
||||
}
|
||||
|
||||
// BT_JOYCONFIG=1: the generic-joystick capture wizard (flight sticks /
|
||||
// HOTAS / pedals -- L4JOY.h). Console prompts detect which device/axis
|
||||
@@ -1145,8 +1183,11 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
|
||||
std::cout << "[boot] platform profile: "
|
||||
<< (fe_menu_mode ? "MENU (front end -- no profile applied)"
|
||||
: gBTPlatformPod ? "POD (RIO cockpit input; multi-surface gauges/MFDs via pod hardware or explicit L4GAUGE)"
|
||||
: gBTPlatformGlass ? "GLASS (PadRIO + plasma window)"
|
||||
: gBTPlatformGlass ? "GLASS"
|
||||
: "DEV (single window + keyboard)");
|
||||
if (gBTPlatformGlass && !fe_menu_mode)
|
||||
std::cout << " (" << BTControlsSummary()
|
||||
<< (BTPlasmaDisabled() ? "; plasma off [L4PLASMA])" : " + plasma window)");
|
||||
if (!fe_menu_mode)
|
||||
std::cout << " [secondary displays: " << kGlassLayoutName[glassLayout] << "]";
|
||||
std::cout << std::endl << std::flush;
|
||||
|
||||
+149
-5
@@ -13,6 +13,7 @@
|
||||
#pragma pack(push, 8)
|
||||
#include "steam/steam_api.h"
|
||||
#include "steam/isteammatchmaking.h"
|
||||
#include <btversion.h> // BUILD GATE (#108 confound): exact-build lobbies
|
||||
#pragma pack(pop)
|
||||
|
||||
//
|
||||
@@ -48,6 +49,50 @@ static void
|
||||
}
|
||||
#include <stdarg.h>
|
||||
|
||||
//
|
||||
// A rejection the PLAYER must see goes through LobbyNotice: the same text
|
||||
// lands in the day log (newlines flattened so the line stays greppable)
|
||||
// AND in a blocking message box. These paths are synchronous FE flows --
|
||||
// a bare return here QUITS the exe, and #68 taught us a player bounced
|
||||
// with only a log line reports "the game just closed".
|
||||
//
|
||||
static void
|
||||
LobbyNotice(const char *format, ...)
|
||||
{
|
||||
char text[512];
|
||||
va_list arguments;
|
||||
va_start(arguments, format);
|
||||
_vsnprintf(text, sizeof(text) - 1, format, arguments);
|
||||
text[sizeof(text) - 1] = 0;
|
||||
va_end(arguments);
|
||||
{
|
||||
char flat[512];
|
||||
int n = 0;
|
||||
for (const char *s = text; *s && n < 510; ++s)
|
||||
{
|
||||
if (*s == '\n')
|
||||
{
|
||||
if (n > 0 && flat[n - 1] == ' ')
|
||||
continue;
|
||||
flat[n++] = ' ';
|
||||
}
|
||||
else
|
||||
{
|
||||
flat[n++] = *s;
|
||||
}
|
||||
}
|
||||
flat[n] = 0;
|
||||
LobbyLog("NOTICE: %s", flat);
|
||||
}
|
||||
WCHAR wide[512];
|
||||
int n = 0;
|
||||
for (const char *s = text; *s && n < 511; ++s)
|
||||
wide[n++] = (WCHAR)*s;
|
||||
wide[n] = 0;
|
||||
MessageBoxW(NULL, wide, L"BATTLETECH -- STEAM LOBBY",
|
||||
MB_OK | MB_ICONWARNING | MB_SETFOREGROUND);
|
||||
}
|
||||
|
||||
//###########################################################################
|
||||
// Synchronous Steam call-result helper (manual polling -- no callback
|
||||
// template machinery in this C-style TU).
|
||||
@@ -95,6 +140,11 @@ static void
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "vh", vehicle);
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "cl", color);
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "xp", experience);
|
||||
// BUILD GATE, member half: publish our exact build so the HOST can
|
||||
// reject mismatches at GO time. This is what catches OLD exes -- they
|
||||
// predate the joiner-side lobby filter, but they can't fake a "bv" key
|
||||
// they never set.
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "bv", BT_VERSION_STRING);
|
||||
}
|
||||
|
||||
static int
|
||||
@@ -186,10 +236,17 @@ static void
|
||||
int n = 0;
|
||||
for (const char *s = member.name; *s && n < 60; ++s) name[n++] = (WCHAR)*s;
|
||||
name[n] = 0;
|
||||
wsprintfW(line, L" %d. %-16s %hs, %hs%s", i + 1, name,
|
||||
// BUILD GATE: flag mismatched members in the roster so the host
|
||||
// sees WHO won't launch before pressing GO (the log alone left
|
||||
// the operator guessing on field nights).
|
||||
const char *bv = matchmaking->GetLobbyMemberData(currentLobby,
|
||||
matchmaking->GetLobbyMemberByIndex(currentLobby, i), "bv");
|
||||
int build_ok = (bv != NULL && strcmp(bv, BT_VERSION_STRING) == 0);
|
||||
wsprintfW(line, L" %d. %-16s %hs, %hs%s%s", i + 1, name,
|
||||
member.vehicle[0] ? member.vehicle : "mech",
|
||||
member.color[0] ? member.color : "-",
|
||||
member.isSelf ? L" (you)" : L"");
|
||||
member.isSelf ? L" (you)" : L"",
|
||||
build_ok ? L"" : L" [WRONG BUILD -- WILL NOT LAUNCH]");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -346,6 +403,12 @@ int
|
||||
}
|
||||
currentLobby = created.m_ulSteamIDLobby;
|
||||
SteamMatchmaking()->SetLobbyData(currentLobby, "btl4", "1");
|
||||
// BUILD GATE: stamp the host's exact build on the lobby. Joiners filter
|
||||
// on equality (below) and verify after entry -- a mixed-build lobby
|
||||
// silently corrupts raw-struct replication (the night-9 ghost confound:
|
||||
// one stale-zip player desyncs one connection's stream). Same build or
|
||||
// no entry.
|
||||
SteamMatchmaking()->SetLobbyData(currentLobby, "btl4ver", BT_VERSION_STRING);
|
||||
PublishSelf(pilot_name, vehicle, color, experience);
|
||||
LobbyLog("host: lobby up (%llu)", (unsigned long long)created.m_ulSteamIDLobby);
|
||||
|
||||
@@ -371,6 +434,25 @@ int
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!member.isSelf)
|
||||
{
|
||||
// BUILD GATE, host half: a member on a different zip (or an
|
||||
// exe old enough to have no "bv" key at all) gets NO token.
|
||||
// Omitted from btl4map, their client -- old builds included,
|
||||
// the code predates the gate -- hits its own "the host's map
|
||||
// is missing us" path and fails the join cleanly instead of
|
||||
// desyncing the session (#108's stale-zip confound).
|
||||
const char *bv = matchmaking->GetLobbyMemberData(
|
||||
currentLobby, CSteamID(member.steamID), "bv");
|
||||
if (bv == NULL || strcmp(bv, BT_VERSION_STRING) != 0)
|
||||
{
|
||||
LobbyLog("host: REJECT %s at GO -- their build [%s], "
|
||||
"ours %s (no token minted; their join will fail)",
|
||||
member.name, (bv && *bv) ? bv : "pre-gate/unknown",
|
||||
BT_VERSION_STRING);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((pass == 0) == (member.isSelf != 0))
|
||||
{
|
||||
roster_out->members[roster_out->memberCount++] = member;
|
||||
@@ -420,13 +502,20 @@ int
|
||||
{
|
||||
if (BTSteamNet_Install() != 0 || !BTSteamNet_Active())
|
||||
{
|
||||
LobbyLog("join: Steam transport unavailable");
|
||||
LobbyNotice("STEAM UNAVAILABLE\n\n"
|
||||
"Could not reach Steam.\n"
|
||||
"Is Steam running and logged in?");
|
||||
return -1;
|
||||
}
|
||||
|
||||
ISteamMatchmaking *matchmaking = SteamMatchmaking();
|
||||
matchmaking->AddRequestLobbyListStringFilter(
|
||||
"btl4", "1", k_ELobbyComparisonEqual);
|
||||
// BUILD GATE: only lobbies stamped with OUR exact build are visible. A
|
||||
// stale-zip player finds nothing -- and the log names the build so the
|
||||
// "no lobby found" report is self-diagnosing (#68's silent-exit lesson).
|
||||
matchmaking->AddRequestLobbyListStringFilter(
|
||||
"btl4ver", BT_VERSION_STRING, k_ELobbyComparisonEqual);
|
||||
SteamAPICall_t call = matchmaking->RequestLobbyList();
|
||||
LobbyMatchList_t match_list;
|
||||
memset(&match_list, 0, sizeof(match_list));
|
||||
@@ -434,7 +523,36 @@ int
|
||||
LobbyMatchList_t::k_iCallback, 15000) != 0 ||
|
||||
match_list.m_nLobbiesMatching == 0)
|
||||
{
|
||||
LobbyLog("join: no btl4 lobby found");
|
||||
//
|
||||
// Nothing on OUR build. Probe once WITHOUT the version filter so
|
||||
// the rejection is specific: "the host is on a different zip"
|
||||
// beats "no lobby" when one is in fact up. Lobby data rides back
|
||||
// with the list result, readable without joining. (Filters only
|
||||
// apply to the next request, so the probe re-adds the game key.)
|
||||
//
|
||||
matchmaking->AddRequestLobbyListStringFilter(
|
||||
"btl4", "1", k_ELobbyComparisonEqual);
|
||||
call = matchmaking->RequestLobbyList();
|
||||
memset(&match_list, 0, sizeof(match_list));
|
||||
if (WaitApiCall(call, &match_list, sizeof(match_list),
|
||||
LobbyMatchList_t::k_iCallback, 8000) == 0 &&
|
||||
match_list.m_nLobbiesMatching > 0)
|
||||
{
|
||||
const char *host_ver = matchmaking->GetLobbyData(
|
||||
matchmaking->GetLobbyByIndex(0), "btl4ver");
|
||||
LobbyNotice("BUILD MISMATCH\n\n"
|
||||
"A lobby is up, but the host runs build %s\n"
|
||||
"and this machine runs build %s.\n\n"
|
||||
"Everyone must run the same zip to play together.",
|
||||
(host_ver && *host_ver) ? host_ver : "(an older build)",
|
||||
BT_VERSION_STRING);
|
||||
}
|
||||
else
|
||||
{
|
||||
LobbyNotice("NO LOBBY FOUND\n\n"
|
||||
"No BattleTech lobby is up right now.\n"
|
||||
"(This machine runs build %s.)", BT_VERSION_STRING);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -450,6 +568,24 @@ int
|
||||
return -1;
|
||||
}
|
||||
currentLobby = lobby;
|
||||
// BUILD GATE belt-and-suspenders: the list filter covers discovery, but
|
||||
// verify the entered lobby too (covers invite/direct joins and any
|
||||
// filter drift). Mismatch = leave loudly, never play version-skewed.
|
||||
{
|
||||
const char *host_ver = matchmaking->GetLobbyData(currentLobby, "btl4ver");
|
||||
if (host_ver == NULL || strcmp(host_ver, BT_VERSION_STRING) != 0)
|
||||
{
|
||||
matchmaking->LeaveLobby(currentLobby);
|
||||
currentLobby = CSteamID();
|
||||
LobbyNotice("BUILD MISMATCH\n\n"
|
||||
"The host runs build %s\n"
|
||||
"and this machine runs build %s.\n\n"
|
||||
"Everyone must run the same zip to play together.",
|
||||
(host_ver && *host_ver) ? host_ver : "(an older build)",
|
||||
BT_VERSION_STRING);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
PublishSelf(pilot_name, vehicle, color, experience);
|
||||
LobbyLog("join: in lobby, waiting for GO");
|
||||
|
||||
@@ -487,7 +623,15 @@ int
|
||||
LobbyLog("join: my token [%s], map [%s]", my_token_out, steam_map_out);
|
||||
if (my_token_out[0] == 0)
|
||||
{
|
||||
result = -1; // the host's map is missing us
|
||||
// The host launched without us -- no seat in the mission map.
|
||||
// (Same-build clients can only hit this via a full lobby or a
|
||||
// publish race now; version skew is gated before entry.)
|
||||
LobbyNotice("LEFT BEHIND\n\n"
|
||||
"The host launched the mission without this machine\n"
|
||||
"(no seat in the mission map).\n\n"
|
||||
"Rejoin on the next launch. If this repeats, compare\n"
|
||||
"builds: this machine runs %s.", BT_VERSION_STRING);
|
||||
result = -1;
|
||||
}
|
||||
}
|
||||
SteamMatchmaking()->LeaveLobby(currentLobby);
|
||||
|
||||
@@ -82,6 +82,7 @@ enum BTActionID
|
||||
BTActMfd1Cycle, // Gitea #9: cycle the lower-left MFD preset page
|
||||
BTActMfd2Cycle, // Gitea #9: cycle the upper-center MFD preset page
|
||||
BTActMfd3Cycle, // Gitea #9: cycle the lower-right MFD preset page
|
||||
BTActEject, // PANIC/EJECT punch-out -> Mech msg 0x19 (@0049f854)
|
||||
BTActCount
|
||||
};
|
||||
|
||||
@@ -204,6 +205,7 @@ static const BTName sActionNames[] =
|
||||
{"Reconnect", BTActReconnect},
|
||||
{"Mfd1Cycle", BTActMfd1Cycle}, {"Mfd2Cycle", BTActMfd2Cycle},
|
||||
{"Mfd3Cycle", BTActMfd3Cycle},
|
||||
{"Eject", BTActEject},
|
||||
{0, 0}
|
||||
};
|
||||
|
||||
@@ -288,6 +290,8 @@ static const char *sDefaultProfile =
|
||||
"key L action Mfd3Cycle\n"
|
||||
"key V action ViewToggle\n"
|
||||
"key B action LookBehind\n"
|
||||
"key Back action Eject\n"
|
||||
"pad LeftThumb action Eject\n"
|
||||
"key F5 action Generator1\n"
|
||||
"key F6 action Generator2\n"
|
||||
"key F7 action Generator3\n"
|
||||
@@ -991,6 +995,7 @@ void
|
||||
case BTActMfd1Cycle: next.mfdCycle[0] = 1; break;
|
||||
case BTActMfd2Cycle: next.mfdCycle[1] = 1; break;
|
||||
case BTActMfd3Cycle: next.mfdCycle[2] = 1; break;
|
||||
case BTActEject: next.eject = 1; break;
|
||||
case BTActGenerator1: next.genSel = 4; break;
|
||||
case BTActGenerator2: next.genSel = 5; break;
|
||||
case BTActGenerator3: next.genSel = 6; break;
|
||||
|
||||
@@ -55,6 +55,7 @@ struct BTInputState
|
||||
int valve;
|
||||
int flush; // Gitea #7: coolant flush HELD (InjectCoolant)
|
||||
int configHold;
|
||||
int eject; // PANIC/EJECT punch-out (level; mech4 edge-dispatches Mech 0x19)
|
||||
int genSel; // 0 = none, 4..7 = Generator A..D, 8 = reconnect
|
||||
// Gitea #9: per-MFD preset-page cycle (desktop senders for SetPresetMode;
|
||||
// index 0/1/2 = Mfd1 lower-left / Mfd2 upper-center / Mfd3 lower-right)
|
||||
|
||||
@@ -276,6 +276,14 @@ void
|
||||
mem_stream->ReadBytes(&item_condition, sizeof(item_condition)); // (*stream+0x1c)
|
||||
mem_stream->ReadBytes(&lamp_code, sizeof(lamp_code));
|
||||
|
||||
if (BTLampLog())
|
||||
DEBUG_STREAM << "[galm-item] sub='"
|
||||
<< (the_subsystem ? (const char *)the_subsystem->GetName() : "?")
|
||||
<< "' cond=" << (int)the_condition
|
||||
<< " itemCond=" << item_condition
|
||||
<< " lampCode=0x" << std::hex << lamp_code << std::dec
|
||||
<< "\n" << std::flush;
|
||||
|
||||
if ((int)the_condition != item_condition)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -2020,11 +2020,23 @@ BallisticWeaponCluster::BallisticWeaponCluster(
|
||||
destroyedLamp = new TwoState(ChildRate(), eng_mode, renderer_in, owner_ID,
|
||||
engPort, 0xc2, 0x85, "edestryd.pcc", 0, 0xff, (int *)&failedState, "TwoState"); // the destroyed X: bright when FAILED
|
||||
|
||||
// ejectWipe (BitMapInverseWipeScalar @004c61c8, eject-timer wipe reading
|
||||
// subsys+0x3f8). BRING-UP: the BitMapInverseWipeScalar class is not yet
|
||||
// declared/reconstructed in btl4gaug (only the non-Scalar BitMapInverseWipe);
|
||||
// tracked NULL until it lands.
|
||||
ejectWipe = NULL; // @0x44
|
||||
// ejectWipe (BitMapInverseWipeScalar @004c61c8): the round-EJECT progress
|
||||
// sweep -- bteejtm.pcc as the weapon's PercentOfEject (@0x3F8) runs 0..1.
|
||||
// Binary call (part_014.c:2312): FUN_004c61c8(., rate, eng_mode,
|
||||
// renderer, ownerID, engPort, 0xF, 0, "bteejtm.pcc", 0, 0xFF,
|
||||
// weapon+0x3f8, name). RE-WIRED 2026-08-03 after the draw op was
|
||||
// properly settled: vtbl+0x58 = DrawBitMapOpaque, NOT DrawBitMap -- the
|
||||
// mis-guess was the striped-MFD corruption (gotcha #27; the wipe's
|
||||
// Execute in btl4gaug now carries the byte-exact two-span transcription).
|
||||
{
|
||||
extern Scalar *BTWeaponPercentOfEjectPtr(void *weapon);
|
||||
Scalar *ejectPct = BTWeaponPercentOfEjectPtr(subsystem_in);
|
||||
ejectWipe = (ejectPct != NULL)
|
||||
? new BitMapInverseWipeScalar(ChildRate(), eng_mode, renderer_in,
|
||||
owner_ID, engPort, 0xF, 0, "bteejtm.pcc", 0, 0xFF,
|
||||
ejectPct, "BitMapInverseWipeScalar")
|
||||
: NULL; // @0x44
|
||||
}
|
||||
}
|
||||
|
||||
BallisticWeaponCluster::~BallisticWeaponCluster()
|
||||
|
||||
@@ -2152,6 +2152,114 @@ void BitMapInverseWipe::Execute()
|
||||
}
|
||||
|
||||
|
||||
//###########################################################################
|
||||
// BitMapInverseWipeScalar @004c61c8 ctor / @004c5fb8 Execute
|
||||
//###########################################################################
|
||||
//
|
||||
// The weapon-eng round-EJECT progress sweep (bteejtm.pcc <- PercentOfEject).
|
||||
// Base ctor @004c5e84 interns the image and captures colours + the bitmap
|
||||
// geometry; the @004c61c8 tail adds the Scalar value-watcher (FUN_00474855
|
||||
// into this+0xA8) -- our GaugeConnectionDirectOf<Scalar> is that watcher.
|
||||
//
|
||||
BitMapInverseWipeScalar::BitMapInverseWipeScalar(
|
||||
GaugeRate rate, ModeMask mode_mask, L4GaugeRenderer *renderer_in,
|
||||
int owner_ID, int graphics_port_number, int x, int y, const char *image,
|
||||
int color_a, int color_b, Scalar *value_pointer,
|
||||
const char *identification_string
|
||||
):
|
||||
GraphicGauge(rate, mode_mask, renderer_in, owner_ID, graphics_port_number,
|
||||
identification_string)
|
||||
{
|
||||
localView.SetOrigin(x, y);
|
||||
imageName = new char[strlen(image) + 1];
|
||||
strcpy(imageName, image);
|
||||
colorA = color_a;
|
||||
colorB = color_b;
|
||||
value = 0.0f;
|
||||
previousLevel = -1;
|
||||
L4Warehouse *warehouse = (L4Warehouse *)renderer_in->warehousePointer;
|
||||
BitMap *bmp = warehouse->bitMapBin.Get(imageName);
|
||||
if (bmp == NULL)
|
||||
{
|
||||
width = 0;
|
||||
height = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
width = bmp->Data.Size.x;
|
||||
height = bmp->Data.Size.y;
|
||||
}
|
||||
AddConnection(new GaugeConnectionDirectOf<Scalar>(0, &value, value_pointer));
|
||||
}
|
||||
|
||||
BitMapInverseWipeScalar::~BitMapInverseWipeScalar()
|
||||
{
|
||||
L4Warehouse *warehouse = (L4Warehouse *)renderer->warehousePointer;
|
||||
warehouse->bitMapBin.Release(imageName);
|
||||
delete[] imageName;
|
||||
imageName = NULL;
|
||||
}
|
||||
|
||||
Logical BitMapInverseWipeScalar::TestInstance() const { return GraphicGauge::TestInstance(); }
|
||||
|
||||
void BitMapInverseWipeScalar::BecameActive() // @004c5fa4
|
||||
{
|
||||
previousLevel = -1;
|
||||
}
|
||||
|
||||
//
|
||||
// @004c5fb8 -- Execute. level = round(value x width) clamped to [0, width]
|
||||
// (the x87 form Ghidra drops -- fild width / fmul value, gotcha #19), and on
|
||||
// a change draw the two source-column spans OPAQUE with INVERTED colour
|
||||
// pairs -- the literal "inverse wipe".
|
||||
//
|
||||
// SETTLED 2026-08-03 (the MFD-corruption arc): the binary's draw call is
|
||||
// vtbl+0x58 = GraphicsView::DrawBitMapOpaque(background, rotation, bitmap,
|
||||
// sx1, sy1, sx2, sy2) [T0 GRAPH2D.h slot 22 -- the NEXT virtual after
|
||||
// DrawBitMap at +0x54; verified by counting the declaration order:
|
||||
// +0x18 SetColor, +0x24 MoveToAbsolute both land exactly]. The earlier
|
||||
// reconstruction guessed DrawBitMap and fed it a colour as "rotation" --
|
||||
// the striped-garbage smear the operator captured. Byte-exact spans:
|
||||
// level != 0: MoveTo(0,0); SetColor(colorA);
|
||||
// DrawBitMapOpaque(colorB, 0, bmp, 0, 0, level, height);
|
||||
// ++level; // inclusive-bounds step
|
||||
// level < w: MoveTo(level,0); SetColor(colorB);
|
||||
// DrawBitMapOpaque(colorA, 0, bmp, level, 0, width, height);
|
||||
//
|
||||
void BitMapInverseWipeScalar::Execute()
|
||||
{
|
||||
Scalar scaled = value * (Scalar)width;
|
||||
int level = (int)(scaled + (scaled < 0.0f ? -0.5f : 0.5f)); // FUN_004dcd94 round
|
||||
if (level < 0) level = 0;
|
||||
if (level > width) level = width;
|
||||
|
||||
if (level != previousLevel)
|
||||
{
|
||||
previousLevel = level;
|
||||
L4Warehouse *warehouse = (L4Warehouse *)renderer->warehousePointer;
|
||||
BitMap *bmp = warehouse->bitMapBin.Get(imageName);
|
||||
if (bmp != NULL)
|
||||
{
|
||||
int split = level;
|
||||
if (split != 0)
|
||||
{
|
||||
localView.MoveToAbsolute(0, 0);
|
||||
localView.SetColor(colorA);
|
||||
localView.DrawBitMapOpaque(colorB, 0, bmp, 0, 0, split, height);
|
||||
++split; // the binary's iVar2++
|
||||
}
|
||||
if (split < width)
|
||||
{
|
||||
localView.MoveToAbsolute(split, 0);
|
||||
localView.SetColor(colorB);
|
||||
localView.DrawBitMapOpaque(colorA, 0, bmp, split, 0, width, height);
|
||||
}
|
||||
warehouse->bitMapBin.Release(imageName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
// HeadingPointer @004c554c Make / @004c562c ctor
|
||||
|
||||
@@ -541,6 +541,39 @@
|
||||
Scalar value; // @0xB4 this[0x2D] (connection)
|
||||
};
|
||||
|
||||
//#######################################################################
|
||||
// BitMapInverseWipeScalar @004c61c8 (vtable 0x518a14; base ctor @004c5e84,
|
||||
// Execute @004c5fb8, BecameActive @004c5fa4). A bitmap COLUMN SWEEP whose
|
||||
// level tracks a live Scalar 0..1 (the ctor's trailing Scalar* rides a
|
||||
// value-watcher into this+0xA8): columns [0..level] draw fg colorA over
|
||||
// bg colorB, the remainder draws the INVERSE -- the weapon-engineering
|
||||
// panel's round-EJECT progress graphic (bteejtm.pcc, watching the
|
||||
// weapon's PercentOfEject @0x3F8). Reconstructed 2026-08-03 (#118: the
|
||||
// operator saw the eject slot flash with NO graphic -- this was the
|
||||
// tracked-NULL bring-up stub in BallisticWeaponCluster).
|
||||
//#######################################################################
|
||||
class BitMapInverseWipeScalar :
|
||||
public GraphicGauge
|
||||
{
|
||||
public:
|
||||
BitMapInverseWipeScalar( // @004c61c8
|
||||
GaugeRate, ModeMask, L4GaugeRenderer *, int owner_ID,
|
||||
int graphics_port_number, int x, int y, const char *image,
|
||||
int color_a, int color_b, Scalar *value_pointer, const char *);
|
||||
~BitMapInverseWipeScalar();
|
||||
Logical TestInstance() const;
|
||||
void BecameActive(); // @004c5fa4 (previousLevel = -1)
|
||||
void Execute(); // @004c5fb8 (two-span inverse sweep)
|
||||
protected:
|
||||
char *imageName; // @0x90
|
||||
int colorA; // @0x94
|
||||
int colorB; // @0x98
|
||||
int width; // @0x9C bitmap width (columns)
|
||||
int height; // @0xA0 bitmap height
|
||||
int previousLevel; // @0xA4
|
||||
Scalar value; // @0xA8 (connection: the 0..1 progress)
|
||||
};
|
||||
|
||||
//#######################################################################
|
||||
// SegmentArc gauges -- needle / dial over a 270-degree arc. Derived from
|
||||
// the MUNGA L4 arc primitives (FUN_004745e0 / FUN_00473f44).
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
// Function -> method map:
|
||||
// L4MechControlsMapper ----------------------------- vtable @0051e440
|
||||
// @004d17ac ctor @004d1814 dtor
|
||||
// @004d196c InterpretControls (target-range ramp + review-mode watch)
|
||||
// @004d196c InterpretControls (target-range ramp + panic-arm watch)
|
||||
// @004d1b64 ZoomTargetRangeIn @004d1b9c ZoomTargetRangeOut
|
||||
// @004d1acc NotifyOfControlModeChange (+0x48; forwards to base no-op @004b048c)
|
||||
// @004d1ae4 NotifyOfDisplayModeChange (+0x4C; the secondary-view mask swap
|
||||
@@ -91,7 +91,7 @@
|
||||
// DAT_0051dcd0[8] = {0x37,0x36,0x35,0x34,0x33,0x32,0x31,0x30} hotbox buttons
|
||||
//
|
||||
// ModeManager (app+0x50): +0x4 currentMode mask, +0x8 savedMode mask.
|
||||
// Mech (mapper owner @this+0xd0): +0x404 targetRange, +0x414 missionReviewMode.
|
||||
// Mech (mapper owner @this+0xd0): +0x404 targetRange, +0x414 ejectPermitted.
|
||||
//
|
||||
|
||||
#include <bt.hpp>
|
||||
@@ -329,7 +329,7 @@ L4MechControlsMapper::MessageHandlerSet&
|
||||
//
|
||||
// Chains to MechControlsMapper (FUN_004b02f0); stamps vtable &0051e440 and
|
||||
// initialises the target-range zoom (exponent 2.0 == 250*2^2 == 1000m) and the
|
||||
// review-mode watcher.
|
||||
// panic-arm watcher (ejectPermitted edge).
|
||||
//
|
||||
L4MechControlsMapper::L4MechControlsMapper(
|
||||
Mech *owner,
|
||||
@@ -349,7 +349,7 @@ L4MechControlsMapper::MessageHandlerSet&
|
||||
)
|
||||
{
|
||||
(void)class_ID;
|
||||
previousMissionReviewMode = 0; // this[0x6a] @0x1a8
|
||||
previousEjectPermitted = 0; // this[0x6a] @0x1a8
|
||||
targetRangeExponentDemand = 2.0f; // this[0x68] @0x1a0
|
||||
targetRangeExponent = 2.0f; // this[0x69] @0x1a4
|
||||
Check_Fpu();
|
||||
@@ -374,8 +374,14 @@ L4MechControlsMapper::MessageHandlerSet&
|
||||
// The L4-layer performance, run every frame ahead of the in-Mech mapper:
|
||||
// 1. snap the throttle to a full-throttle detent,
|
||||
// 2. (re)build the pilot roster,
|
||||
// 3. watch the Mech's mission-review flag and toggle the corresponding mode
|
||||
// bit (0x200000) on the application mode manager,
|
||||
// 3. watch the Mech's EJECT-PERMISSION flag (ejectPermitted @0x414 --
|
||||
// refreshed each frame by the master performance, FUN_004a9b5c+0x10 ->
|
||||
// @0049fa1c) and toggle the PANIC-ARMED mode bit (0x200000) on the
|
||||
// application mode manager. That mode bit is what lights the pod's
|
||||
// physical PANIC button (MakeLinkedLamp, btl4mppr RIO ctor) and any
|
||||
// gauge elements carrying the mode in their ModeMask. [Corrected
|
||||
// 2026-08-03: this cell was mislabeled "mission-review mode" -- the
|
||||
// real review mode is the GLOBAL DAT_004fd550 (btl4pb), not mech+0x414.]
|
||||
// 4. slew the smoothed target-range exponent toward the panel demand and
|
||||
// push the resulting range (250 * 2^exponent) onto the Mech,
|
||||
// 5. delegate to MechControlsMapper::InterpretControls for the actual
|
||||
@@ -405,19 +411,19 @@ L4MechControlsMapper::MessageHandlerSet&
|
||||
BuildPilotArray(); // FUN_004b0600
|
||||
|
||||
//
|
||||
// (3) Mission-review mode change watcher.
|
||||
// (3) Panic-arm watcher: edge-detect ejectPermitted -> mode 0x200000.
|
||||
//
|
||||
Mech *mech = GetMech();
|
||||
int review_mode = mech->GetMissionReviewMode(); // (mech)+0x414
|
||||
if (review_mode != previousMissionReviewMode) // @0x1a8
|
||||
int eject_armed = mech->GetEjectPermitted(); // (mech)+0x414
|
||||
if (eject_armed != previousEjectPermitted) // @0x1a8
|
||||
{
|
||||
previousMissionReviewMode = review_mode;
|
||||
previousEjectPermitted = eject_armed;
|
||||
|
||||
BTL4ModeManager *mode_manager =
|
||||
(BTL4ModeManager*)application->GetModeManager(); // DAT_004efc94+0x50
|
||||
// ModeManager API: Add/RemoveModeMask save the old mask into
|
||||
// previousModeMask (the recovered "savedMode = currentMode" step).
|
||||
if (review_mode == 0)
|
||||
if (eject_armed == 0)
|
||||
{
|
||||
mode_manager->RemoveModeMask(0x200000);
|
||||
}
|
||||
@@ -425,6 +431,10 @@ L4MechControlsMapper::MessageHandlerSet&
|
||||
{
|
||||
mode_manager->AddModeMask(0x200000);
|
||||
}
|
||||
if (getenv("BT_EJECT_LOG"))
|
||||
DEBUG_STREAM << "[eject] panic-arm mode "
|
||||
<< (eject_armed ? "ON" : "off") << " ("
|
||||
<< mech->GetEntityID() << ")\n" << std::flush;
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -74,8 +74,17 @@
|
||||
//
|
||||
public:
|
||||
enum {
|
||||
KeypressMessageID = MechControlsMapper::NextMessageID,
|
||||
StringMatchMessageID,
|
||||
// PINNED to the binary literals (FUN_004d266c passes 0x19/0x1a
|
||||
// straight into the group Adds; corrected 2026-08-03). The chain
|
||||
// arithmetic (MechControlsMapper::NextMessageID) lands on 0x17 --
|
||||
// TWO mapper ids between 0x16 ToggleVoiceAssist and 0x19 are
|
||||
// unreconstructed -- and the mis-numbered Keypress send was
|
||||
// SILENTLY swallowed by the Mech (no 0x17 handler), which is why
|
||||
// the armed pilot-keypad press never ejected. Mech::EjectPilot is
|
||||
// id 0x19 on the MECH's receiver: the panic-mode keypad binding
|
||||
// sends THIS id to the OWNER, and the collision is the design.
|
||||
KeypressMessageID = 0x19,
|
||||
StringMatchMessageID, // 0x1a (unregistered; stringManager route)
|
||||
NextMessageID
|
||||
};
|
||||
|
||||
@@ -203,7 +212,9 @@
|
||||
targetRangeExponentDemand, // @0x1a0 panel "zoom" demand (init 2.0, range 0..5)
|
||||
targetRangeExponent; // @0x1a4 smoothed value -> mech target range (2^x)
|
||||
int
|
||||
previousMissionReviewMode; // @0x1a8 last-seen mech mission-review flag
|
||||
previousEjectPermitted; // @0x1a8 last-seen mech ejectPermitted (@0x414) --
|
||||
// the panic-arm edge detector (was mislabeled
|
||||
// "mission-review flag"; corrected 2026-08-03)
|
||||
|
||||
//
|
||||
// Per-platform input staging slots (filled by the derived ctor's
|
||||
@@ -305,8 +316,12 @@
|
||||
// was previously self-consistent at the wrong value (0x2a), so it
|
||||
// worked by accident -- now it matches the binary.
|
||||
// (Faithful-fidelity note: the binary RIO table also carries its OWN
|
||||
// Keypress id 0x19 -> @004d2514; our ctor still registers the shared
|
||||
// L4 Keypress (0x17, @004d1bf0) -- see the Gitea backlog issue.)
|
||||
// Keypress id: FIXED 2026-08-03 -- KeypressMessageID is now pinned to
|
||||
// the binary's 0x19 (the old chain arithmetic landed on 0x17, and the
|
||||
// panic-mode pilot-keypad send was silently swallowed by the Mech --
|
||||
// no 0x17 handler -- so the armed eject press never fired). Remaining
|
||||
// refinement: the ctor registers the shared L4 Keypress BODY
|
||||
// (@004d1bf0); the RIO-specific body is @004d2514.)
|
||||
//
|
||||
enum {
|
||||
Aux1QuadMessageID = MechControlsMapper::Aux1QuadMessageID, // 3
|
||||
|
||||
@@ -369,8 +369,13 @@ BTL4PlaybackApplication::SharedData
|
||||
|
||||
//
|
||||
// Controls only matter in interactive ("scrub") review mode.
|
||||
// The binary reads the GLOBAL review-mode cell DAT_004fd550 here --
|
||||
// NOT mech+0x414 (that cell is ejectPermitted; the old
|
||||
// GetMissionReviewMode() call was a mislabel, corrected 2026-08-03).
|
||||
// The port never enters scrub mode, so the global stays 0.
|
||||
//
|
||||
if (GetMissionReviewMode() == 2) // DAT_004fd550
|
||||
extern int gMissionReviewMode; // DAT_004fd550 (btstubs)
|
||||
if (gMissionReviewMode == 2)
|
||||
{
|
||||
Check(controlsManager);
|
||||
controlsManager->Execute(); // vtbl+0x18
|
||||
|
||||
+615
-16
@@ -170,6 +170,104 @@ void
|
||||
// localToWorld in its ctor -- no explicit AddDynamicRenderable here
|
||||
// (unlike the 1996 VideoComponent path).
|
||||
(void)mech_root;
|
||||
|
||||
//
|
||||
// SEARCHLIGHT pass (2026-08-05) -- the roster walk's `case 0xbd8`
|
||||
// (@004cef28, raw pseudocode part_014). The binary, per searchlight
|
||||
// subsystem: resolve the "LightOn" attribute (error print
|
||||
// BTL4VID.CPP:0x941 when absent); COCKPIT build (view 1) -> stash up
|
||||
// to TWO attrs for the fog-swap watcher (@00456778); EXTERNAL build
|
||||
// -> load "spot.bgf" (warn if missing), hang it as a child of the
|
||||
// mount joint and bind visibility to the attribute (@0045612c). Our
|
||||
// tree builder does not walk the roster, so the probe bridge
|
||||
// (searchlight.cpp) supplies {attr, mount segment} and the two
|
||||
// watcher Execute bodies live in TickSearchlight.
|
||||
//
|
||||
{
|
||||
extern int BTMechSearchlightProbe(void *, int **, int *);
|
||||
int *attrs[2];
|
||||
int segs[2];
|
||||
int count = BTMechSearchlightProbe((void *)entity, attrs, segs);
|
||||
std::map<Entity*, MechRenderTree>::iterator ti =
|
||||
mMechRenderTrees.find(entity);
|
||||
if (count > 0 && ti != mMechRenderTrees.end())
|
||||
{
|
||||
MechRenderTree &tree = ti->second;
|
||||
tree.searchLightCount = 0;
|
||||
tree.searchIsCockpit = (view_type == 1);
|
||||
for (int sl = 0; sl < count; ++sl)
|
||||
{
|
||||
MechRenderTree::SearchLight &light =
|
||||
tree.searchLight[tree.searchLightCount];
|
||||
light.cone = NULL;
|
||||
light.coneObj = NULL;
|
||||
light.lightOn = attrs[sl];
|
||||
light.shown = 0;
|
||||
light.mountSeg = segs[sl];
|
||||
// the binary's inverted seed (@00456778: [9]/[10] =
|
||||
// (*attr == 0)) -- guarantees the FIRST tick "changes"
|
||||
// and normalizes the fog to the real lamp state, which
|
||||
// is how a night mission starts on nosearchlightfog.
|
||||
tree.searchFogCache[tree.searchLightCount] =
|
||||
(*attrs[sl] == 0);
|
||||
// BT_SPOT_SELF=1 (bench-only): build the cone on the
|
||||
// OWN-cockpit tree too, so the V-chase camera can
|
||||
// eyeball it solo. Authentic builds never cone the
|
||||
// cockpit tree (the 1995 view-1 branch stashes attrs
|
||||
// only) -- keep this env off outside look benches.
|
||||
if (!tree.searchIsCockpit || getenv("BT_SPOT_SELF"))
|
||||
{
|
||||
// NB: the renderer-level LoadObject wrapper (@00498448) is
|
||||
// still a btstubs no-op; load through the d3d route the
|
||||
// tree builder itself uses (extension REQUIRED there).
|
||||
d3d_OBJECT *spot = d3d_OBJECT::LoadObject(
|
||||
GetDevice(), (char *)"spot.bgf"); // @0051d6c0
|
||||
if (spot == NULL)
|
||||
{
|
||||
DEBUG_STREAM << "[spot] Couldn't locate spot.bgf "
|
||||
"for mech\n" << std::flush; // @0051d6c9
|
||||
}
|
||||
else
|
||||
{
|
||||
std::map<int, HierarchicalDrawComponent*>::iterator si =
|
||||
tree.segRenderable.find(segs[sl]);
|
||||
HierarchicalDrawComponent *parent =
|
||||
(si != tree.segRenderable.end()) ? si->second : NULL;
|
||||
if (parent == NULL)
|
||||
{
|
||||
// slot-space mismatch would land here -- keep the
|
||||
// receipt loud (see the bench).
|
||||
DEBUG_STREAM << "[spot] mount segment " << segs[sl]
|
||||
<< " has no joint renderable -- cone skipped; slots:";
|
||||
for (std::map<int, HierarchicalDrawComponent*>::iterator di =
|
||||
tree.segRenderable.begin();
|
||||
di != tree.segRenderable.end(); ++di)
|
||||
DEBUG_STREAM << " " << di->first;
|
||||
DEBUG_STREAM << "\n" << std::flush;
|
||||
}
|
||||
else
|
||||
{
|
||||
LinearMatrix identity(True);
|
||||
dpl_ISECT_MODE gSpotIsectMode; // stub type (binary: mode 1)
|
||||
light.cone = new DPLStaticChildRenderable(
|
||||
entity, false /* main zone */, spot,
|
||||
gSpotIsectMode, 0 /* mask 0: never pickable */,
|
||||
identity, parent);
|
||||
light.cone->SetDrawObj(NULL); // hidden until LightOn
|
||||
light.coneObj = spot;
|
||||
}
|
||||
}
|
||||
}
|
||||
++tree.searchLightCount;
|
||||
}
|
||||
DEBUG_STREAM << "[spot] searchlight pass: " << count
|
||||
<< " light(s), view=" << (int)view_type
|
||||
<< " mountSeg=" << segs[0]
|
||||
<< (tree.searchIsCockpit ? " (cockpit: fog watcher)"
|
||||
: " (external: spot cone)")
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -401,7 +499,8 @@ HierarchicalDrawComponent*
|
||||
// bring-up diagnostics (counts geometry actually loaded vs. requested)
|
||||
int dbg_obj_requested = 0, dbg_obj_loaded = 0, dbg_eye = 0;
|
||||
DEBUG_STREAM << "[BTrender] MakeMechRenderables: " << segment_count
|
||||
<< " segments, view=" << (int)type << "\n" << std::flush;
|
||||
<< " segments, view=" << (int)type
|
||||
<< " entity=" << entity->GetEntityID() << "\n" << std::flush;
|
||||
|
||||
JointSubsystem *joint_subsystem = jointed_mover->GetJointSubsystem(); // [0x31c]
|
||||
|
||||
@@ -459,6 +558,11 @@ HierarchicalDrawComponent*
|
||||
//
|
||||
if (segment->IsSiteSegment() /* [0x10] */ != 0)
|
||||
{
|
||||
if (getenv("BT_TREE_LOG"))
|
||||
DEBUG_STREAM << "[tree] SITE seg=" << segment->GetIndex()
|
||||
<< " name=" << (segment->GetName() ? (const char *)segment->GetName() : "?")
|
||||
<< " parent=" << (segment->GetParent() ? segment->GetParentIndex() : -1)
|
||||
<< "\n" << std::flush;
|
||||
// The authentic COCKPIT EYEPOINT. Built for the true inside view
|
||||
// AND for the player's chase build (buildDebugChaseCamera), so the
|
||||
// V-key toggle can switch to it -- the pod's only view was this
|
||||
@@ -491,6 +595,22 @@ HierarchicalDrawComponent*
|
||||
mCamera = mEyeCockpit;
|
||||
dbg_eye = 1;
|
||||
}
|
||||
//
|
||||
// SEARCHLIGHT (2026-08-05): sites are ATTACHMENT joints, and the
|
||||
// 1995 render graph gave every one a DCS node -- that node is what
|
||||
// the subsystem-visual walk parents to (the spot cone hangs on the
|
||||
// searchlight site, @004cef28 case 0xbd8). Build the geometry-less
|
||||
// child so site slots are posed + parentable; nothing draws for the
|
||||
// site itself.
|
||||
//
|
||||
{
|
||||
dpl_ISECT_MODE site_isect;
|
||||
HierarchicalDrawComponent *site_child = new DPLStaticChildRenderable(
|
||||
entity, inDeathZone, NULL /* no geometry */,
|
||||
site_isect, 0, offset_matrix, parent_DCS);
|
||||
dcs_array[segment->GetIndex()] = site_child;
|
||||
render_tree.segRenderable[segment->GetIndex()] = site_child;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -563,6 +683,14 @@ HierarchicalDrawComponent*
|
||||
// the JointSubsystem's joint table.
|
||||
//
|
||||
int segment_slot = segment->GetIndex() /* [0xcc] */;
|
||||
// DIAG (BT_TREE_LOG): the built joint topology -- segment slot, name,
|
||||
// joint index/type, parent -- the crouch root-drop chain hunt.
|
||||
if (getenv("BT_TREE_LOG"))
|
||||
DEBUG_STREAM << "[tree] seg=" << segment_slot
|
||||
<< " name=" << (segment->GetName() ? (const char *)segment->GetName() : "?")
|
||||
<< " joint=" << segment->GetJointIndex()
|
||||
<< " parent=" << (segment->GetParent() ? segment->GetParentIndex() : -1)
|
||||
<< "\n" << std::flush;
|
||||
Joint *this_joint = NULL;
|
||||
Joint::JointType joint_type;
|
||||
if (segment->GetJointIndex() /* [0xc0] */ == -1)
|
||||
@@ -884,7 +1012,8 @@ void
|
||||
if (swapped != 0 || getenv("BT_DEATH_LOG"))
|
||||
DEBUG_STREAM << "[BTrender] RemakeEntity: " << swapped
|
||||
<< " mesh(es) swapped (" << mapped << " body segs mapped of "
|
||||
<< checked << " checked)\n" << std::flush;
|
||||
<< checked << " checked) entity="
|
||||
<< entity->GetEntityID() << "\n" << std::flush;
|
||||
}
|
||||
|
||||
|
||||
@@ -1171,13 +1300,15 @@ void
|
||||
if (hulk != NULL && hulk->GetVertCount() == 0)
|
||||
{
|
||||
DEBUG_STREAM << "[BTrender] wreck: '" << hulk_name
|
||||
<< "' is an EMPTY placeholder -> gendbr.bgf fallback\n" << std::flush;
|
||||
<< "' is an EMPTY placeholder -> gendbr.bgf fallback (entity="
|
||||
<< victim->GetEntityID() << ")\n" << std::flush;
|
||||
hulk = NULL;
|
||||
}
|
||||
if (hulk == NULL)
|
||||
{
|
||||
DEBUG_STREAM << "[BTrender] wreck: '" << hulk_name
|
||||
<< "' missing -> gendbr.bgf fallback\n" << std::flush;
|
||||
<< "' missing -> gendbr.bgf fallback (entity="
|
||||
<< victim->GetEntityID() << ")\n" << std::flush;
|
||||
hulk = d3d_OBJECT::LoadObject(GetDevice(), "gendbr.bgf");
|
||||
}
|
||||
|
||||
@@ -1451,9 +1582,36 @@ void
|
||||
}
|
||||
|
||||
if (log)
|
||||
{
|
||||
DEBUG_STREAM << "[armor] entity " << (void *)entity << " skel="
|
||||
<< tree.skeletonType << ": " << tree.dmgBinds.size()
|
||||
<< " material binding(s)\n" << std::flush;
|
||||
// ORPHAN inventory: drawn ops NO zone claimed. These panels never
|
||||
// darken -- if the big hull materials land here (a .DZM name the mesh
|
||||
// doesn't carry, or per-zone material instancing collapsed at load),
|
||||
// the mech reads "no darkening" live even while every bound op logs
|
||||
// perfectly (the #87 visibility gap).
|
||||
for (std::map<int, MechRenderTree::SegPick>::iterator si = tree.segPick.begin();
|
||||
si != tree.segPick.end(); ++si)
|
||||
{
|
||||
d3d_OBJECT *obj = si->second.obj;
|
||||
if (obj == NULL)
|
||||
continue;
|
||||
for (int op = 0; op < obj->GetDrawOpCount(); ++op)
|
||||
{
|
||||
L4DRAWOP *draw_op = obj->GetDrawOp(op);
|
||||
if (draw_op->dzMatName[0] == '\0')
|
||||
continue;
|
||||
int claimed = 0;
|
||||
for (size_t b = 0; b < tree.dmgBinds.size() && !claimed; ++b)
|
||||
if (tree.dmgBinds[b].obj == obj && tree.dmgBinds[b].op == op)
|
||||
claimed = 1;
|
||||
if (!claimed)
|
||||
DEBUG_STREAM << "[armor] ORPHAN op seg=" << si->first
|
||||
<< " '" << draw_op->dzMatName << "'\n" << std::flush;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1514,6 +1672,63 @@ void
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
//#############################################################################
|
||||
// TickSearchlight -- the two 1995 searchlight watchers, transcribed
|
||||
//#############################################################################
|
||||
//
|
||||
// External cone (@0045612c, Execute @004561d8): poll the subsystem's LightOn
|
||||
// attribute; on change, set the spot instance visible iff the value matches
|
||||
// (match value 1) and flush. Our equivalent of SetInstanceOn/Flush is the
|
||||
// SetDrawObj object swap (the wreck-reveal mechanism).
|
||||
//
|
||||
// Cockpit fog (@00456778, Execute @00456814): poll BOTH stashed attrs; when
|
||||
// either changed, either-on -> SetFogStyle(searchLightOnFogStyle) else
|
||||
// SetFogStyle(searchLightOffFogStyle). The ctor's INVERTED caches make the
|
||||
// first poll always fire, normalizing a night start onto nosearchlightfog --
|
||||
// the authentic dark (fog= is the LIT set; see BTDPL.INI per-page authoring).
|
||||
//
|
||||
void
|
||||
BTL4VideoRenderer::TickSearchlight(Entity *mech)
|
||||
{
|
||||
std::map<Entity*, MechRenderTree>::iterator ti = mMechRenderTrees.find(mech);
|
||||
if (ti == mMechRenderTrees.end() || ti->second.searchLightCount <= 0)
|
||||
return;
|
||||
MechRenderTree &tree = ti->second;
|
||||
|
||||
int any_on = 0, changed = 0;
|
||||
for (int sl = 0; sl < tree.searchLightCount; ++sl)
|
||||
{
|
||||
MechRenderTree::SearchLight &light = tree.searchLight[sl];
|
||||
if (light.lightOn == NULL)
|
||||
continue;
|
||||
const int cur = (*light.lightOn != 0);
|
||||
|
||||
if (light.cone != NULL && cur != light.shown) // @004561d8
|
||||
{
|
||||
light.cone->SetDrawObj(cur ? light.coneObj : NULL);
|
||||
light.shown = cur;
|
||||
if (getenv("BT_FIRE_LOG") || getenv("BT_FOG_LOG"))
|
||||
DEBUG_STREAM << "[spot] cone " << (cur ? "SHOWN" : "HIDDEN")
|
||||
<< " (seg " << light.mountSeg << ")\n" << std::flush;
|
||||
}
|
||||
|
||||
if (cur != tree.searchFogCache[sl]) // @00456814
|
||||
changed = 1;
|
||||
tree.searchFogCache[sl] = cur;
|
||||
any_on |= cur;
|
||||
}
|
||||
|
||||
if (tree.searchIsCockpit && changed)
|
||||
{
|
||||
SetFogStyle(any_on ? searchLightOnFogStyle : searchLightOffFogStyle);
|
||||
if (getenv("BT_FIRE_LOG") || getenv("BT_FOG_LOG"))
|
||||
DEBUG_STREAM << "[spot] cockpit fog -> "
|
||||
<< (any_on ? "searchLightOn" : "searchLightOff") << "\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Sim-side bridge (per-mech, every frame, from Mech::PerformAndWatch).
|
||||
//
|
||||
@@ -1526,6 +1741,7 @@ void BTArmourDamageTick(Entity *mech)
|
||||
if (renderer == NULL)
|
||||
return;
|
||||
renderer->TickArmourDamage(mech);
|
||||
renderer->TickSearchlight(mech); // the searchlight watchers ride the same beat
|
||||
}
|
||||
|
||||
|
||||
@@ -1545,10 +1761,106 @@ int BTWreckSinkTick(Entity *victim, float dt)
|
||||
|
||||
|
||||
//
|
||||
// #73 -- the aimed PER-PART pick (see the header note). Ray-vs-sphere over
|
||||
// the per-segment draw objects recorded at tree build; world centers come
|
||||
// through the draw-cached mLocalToWorld (updated every drawn frame -- the
|
||||
// target being aimed at is on screen, so at most one frame stale).
|
||||
// #124 -- the pick's TRIANGLE cache. The 1995 pick was a dpl scene
|
||||
// intersection against the DRAWN GEOMETRY (the division card cast from the
|
||||
// view); the port's sphere approximation measurably mis-picked (the zone-walk
|
||||
// matrix: aim dead-on dtorso -> picked rgun/ruleg -- gun/limb spheres thread
|
||||
// the ray before the torso from most angles, and its own comments admitted "a
|
||||
// foot can be unhittable behind its own knee"). Restore the authentic
|
||||
// semantic: nearest RAY-TRIANGLE hit across the candidate segments' posed
|
||||
// meshes. Positions are read ONCE per d3d_OBJECT from its own BGF buffers
|
||||
// (managed pool, lockable) and cached CPU-side; the per-frame cost is a
|
||||
// sphere pre-filter + Moller-Trumbore over the few threaded segments.
|
||||
//
|
||||
struct BTPickMesh
|
||||
{
|
||||
std::vector<float> pos; // xyz per vertex
|
||||
std::vector<unsigned int> idx; // triangle list
|
||||
int ok;
|
||||
};
|
||||
static std::map<d3d_OBJECT *, BTPickMesh> gBTPickMeshes;
|
||||
|
||||
static BTPickMesh *
|
||||
BTGetPickMesh(d3d_OBJECT *obj)
|
||||
{
|
||||
std::map<d3d_OBJECT *, BTPickMesh>::iterator mi = gBTPickMeshes.find(obj);
|
||||
if (mi != gBTPickMeshes.end())
|
||||
return mi->second.ok ? &mi->second : 0;
|
||||
|
||||
BTPickMesh &pm = gBTPickMeshes[obj];
|
||||
pm.ok = 0;
|
||||
if (obj->mBgfVB == 0 || obj->mBgfIB == 0 || obj->mBgfStride < 12)
|
||||
return 0;
|
||||
|
||||
D3DINDEXBUFFER_DESC ibd;
|
||||
if (FAILED(obj->mBgfIB->GetDesc(&ibd)))
|
||||
return 0;
|
||||
int idx32 = (ibd.Format == D3DFMT_INDEX32);
|
||||
unsigned int nIdx = ibd.Size / (idx32 ? 4 : 2);
|
||||
|
||||
void *vp = 0, *ip = 0;
|
||||
if (FAILED(obj->mBgfVB->Lock(0, 0, &vp, D3DLOCK_READONLY)))
|
||||
return 0;
|
||||
if (FAILED(obj->mBgfIB->Lock(0, 0, &ip, D3DLOCK_READONLY)))
|
||||
{
|
||||
obj->mBgfVB->Unlock();
|
||||
return 0;
|
||||
}
|
||||
pm.pos.resize((size_t)obj->mBgfNumVerts * 3);
|
||||
const unsigned char *vb = (const unsigned char *)vp;
|
||||
for (UINT v = 0; v < obj->mBgfNumVerts; ++v)
|
||||
{
|
||||
const float *p = (const float *)(vb + (size_t)v * obj->mBgfStride);
|
||||
pm.pos[v*3+0] = p[0]; // position-first vertex layout (the BGF
|
||||
pm.pos[v*3+1] = p[1]; // loader's own decl; the cull sphere was
|
||||
pm.pos[v*3+2] = p[2]; // computed from these same floats at load)
|
||||
}
|
||||
pm.idx.resize(nIdx);
|
||||
if (idx32)
|
||||
{
|
||||
const unsigned int *s = (const unsigned int *)ip;
|
||||
for (unsigned int k = 0; k < nIdx; ++k) pm.idx[k] = s[k];
|
||||
}
|
||||
else
|
||||
{
|
||||
const unsigned short *s = (const unsigned short *)ip;
|
||||
for (unsigned int k = 0; k < nIdx; ++k) pm.idx[k] = s[k];
|
||||
}
|
||||
obj->mBgfIB->Unlock();
|
||||
obj->mBgfVB->Unlock();
|
||||
pm.ok = (pm.idx.size() >= 3 && pm.pos.size() >= 9);
|
||||
return pm.ok ? &pm : 0;
|
||||
}
|
||||
|
||||
// Moller-Trumbore, both-sided (the pod's dpl geometry has no consistent
|
||||
// winding guarantee across ported BGF pieces). Returns t >= 0 or -1.
|
||||
static float
|
||||
BTRayTri(const float o[3], const float d[3],
|
||||
const float *a, const float *b, const float *c)
|
||||
{
|
||||
float e1[3] = { b[0]-a[0], b[1]-a[1], b[2]-a[2] };
|
||||
float e2[3] = { c[0]-a[0], c[1]-a[1], c[2]-a[2] };
|
||||
float pv[3] = { d[1]*e2[2]-d[2]*e2[1], d[2]*e2[0]-d[0]*e2[2], d[0]*e2[1]-d[1]*e2[0] };
|
||||
float det = e1[0]*pv[0] + e1[1]*pv[1] + e1[2]*pv[2];
|
||||
if (det > -1e-8f && det < 1e-8f) return -1.0f;
|
||||
float inv = 1.0f / det;
|
||||
float tv[3] = { o[0]-a[0], o[1]-a[1], o[2]-a[2] };
|
||||
float u = (tv[0]*pv[0] + tv[1]*pv[1] + tv[2]*pv[2]) * inv;
|
||||
if (u < 0.0f || u > 1.0f) return -1.0f;
|
||||
float qv[3] = { tv[1]*e1[2]-tv[2]*e1[1], tv[2]*e1[0]-tv[0]*e1[2], tv[0]*e1[1]-tv[1]*e1[0] };
|
||||
float v = (d[0]*qv[0] + d[1]*qv[1] + d[2]*qv[2]) * inv;
|
||||
if (v < 0.0f || u + v > 1.0f) return -1.0f;
|
||||
float t = (e2[0]*qv[0] + e2[1]*qv[1] + e2[2]*qv[2]) * inv;
|
||||
return (t >= 0.0f) ? t : -1.0f;
|
||||
}
|
||||
|
||||
//
|
||||
// #73 -- the aimed PER-PART pick (see the header note). #124: now a true
|
||||
// DRAWN-GEOMETRY intersection -- sphere pre-filter, then nearest ray-triangle
|
||||
// hit across the threaded segments' posed meshes (the 1995 division-card
|
||||
// semantic). The old smallest-sphere selection survives only as the fallback
|
||||
// when no triangle anywhere is struck (grazing edge shots). World transforms
|
||||
// come through the draw-cached mLocalToWorld (at most one frame stale).
|
||||
//
|
||||
int
|
||||
BTL4VideoRenderer::MechSegmentPick(
|
||||
@@ -1561,7 +1873,8 @@ int
|
||||
{
|
||||
std::map<Entity*, MechRenderTree>::iterator it = mMechRenderTrees.find(mech);
|
||||
if (it == mMechRenderTrees.end() || it->second.wrecked)
|
||||
return 0;
|
||||
return -1; // #131: NO TREE (unbuilt replicant / wreck) -- the caller may
|
||||
// box-test; distinct from 0 = a true drawn-geometry MISS.
|
||||
|
||||
// Selection is SPECIFICITY-FIRST: among the spheres the ray pierces, the
|
||||
// SMALLEST radius wins (normalized-distance tie-break). Neither nearest-
|
||||
@@ -1573,12 +1886,35 @@ int
|
||||
// envelope; smallest-pierced picks the most specific part on the aim line,
|
||||
// and the torso wins only when no limb is threaded -- which is the per-part
|
||||
// semantic the 1995 mesh intersection produced.
|
||||
float bestR = 1e30f; // primary key: sphere radius (ascending)
|
||||
float bestScore = 1.0f; // tie-break: normalized perpendicular d2/r2
|
||||
float bestR = 1e30f; // sphere-FALLBACK key: radius (ascending)
|
||||
float bestScore = 1.0f; // sphere tie-break: normalized d2/r2
|
||||
float bestT = max_range;
|
||||
int bestZone = -1;
|
||||
int hitAny = 0;
|
||||
|
||||
// triangle-accurate primary: nearest surface hit across all segments
|
||||
float triBestT = max_range;
|
||||
int triBestZone = -1;
|
||||
int triBestSeg = -1;
|
||||
int triHit = 0;
|
||||
d3d_OBJECT *triBestObj = 0; // #124-PATCH: winning object + tri
|
||||
size_t triBestK = 0; // (triangle ordinal in its IB)
|
||||
int dbgTriObjs = 0, dbgInvFail = 0, dbgNoTri = 0; // #131 telemetry
|
||||
|
||||
// #124 CORRECTED (2026-08-04, field pushback vindicated): the pick's zone
|
||||
// is the struck PATCH's authored dz_* tag, with the segment's SKL dzone as
|
||||
// the fallback for untagged patches. The earlier "struck segment's dzone,
|
||||
// ALWAYS" model collapsed the hull to dtorso -- but the art zone-tags the
|
||||
// hull PER PANEL (MAD_TOR carries dz_utorso/ltorso/rtorso/dtorso + all four
|
||||
// rear panels + searchlight across its patches), the dpl hit result kept
|
||||
// GEOGROUP granularity (dplHitInstance/DCS/GeoGroup/Geometry), and the
|
||||
// binary's segment->zone map (@49db20) has NO runtime caller (sole caller =
|
||||
// CreateStreamedDamageZone, load-time) -- there was nothing to do the
|
||||
// segment-level collapse with. Era players' "I stripped the left torso in
|
||||
// the pod" stands. The triangle -> patch attribution below reuses the .DZM
|
||||
// zone->material bindings (#87's armour darkening -- the same authored
|
||||
// mapping that already paints those panels per-zone).
|
||||
|
||||
std::map<int, MechRenderTree::SegPick>::iterator sp;
|
||||
for (sp = it->second.segPick.begin(); sp != it->second.segPick.end(); ++sp)
|
||||
{
|
||||
@@ -1609,6 +1945,77 @@ int
|
||||
if (t < 0.0f || t >= max_range)
|
||||
continue;
|
||||
|
||||
// ---- TRIANGLE TEST (#124): the sphere only nominates ----
|
||||
BTPickMesh *pm = BTGetPickMesh(obj);
|
||||
if (pm == 0 && getenv("BT_PICK_LOG"))
|
||||
{
|
||||
static int s_nt = 0;
|
||||
if (s_nt++ < 40)
|
||||
DEBUG_STREAM << "[picktri] seg=" << sp->first << " zone="
|
||||
<< sp->second.zone << " NO TRIS (vb=" << (void *)obj->mBgfVB
|
||||
<< " ib=" << (void *)obj->mBgfIB
|
||||
<< " stride=" << obj->mBgfStride << ")\n" << std::flush;
|
||||
}
|
||||
if (pm == 0)
|
||||
++dbgNoTri;
|
||||
if (pm != 0)
|
||||
{
|
||||
++dbgTriObjs;
|
||||
// ray into object-local space (affine inverse; segment poses are
|
||||
// rigid, so local t == world t after direction normalization is
|
||||
// preserved by construction below)
|
||||
D3DXMATRIX w2l;
|
||||
if (D3DXMatrixInverse(&w2l, 0, &l2w) == 0)
|
||||
++dbgInvFail;
|
||||
if (D3DXMatrixInverse(&w2l, 0, &l2w) != 0)
|
||||
{
|
||||
D3DXVECTOR3 lo, ld;
|
||||
D3DXVECTOR3 wo(ray_start[0], ray_start[1], ray_start[2]);
|
||||
D3DXVECTOR3 wd(ray_dir[0], ray_dir[1], ray_dir[2]);
|
||||
D3DXVec3TransformCoord(&lo, &wo, &w2l);
|
||||
D3DXVec3TransformNormal(&ld, &wd, &w2l);
|
||||
float o3[3] = { lo.x, lo.y, lo.z };
|
||||
float d3[3] = { ld.x, ld.y, ld.z };
|
||||
const float *P = &pm->pos[0];
|
||||
size_t nv = pm->pos.size() / 3;
|
||||
for (size_t k = 0; k + 2 < pm->idx.size(); k += 3)
|
||||
{
|
||||
unsigned int i0 = pm->idx[k], i1 = pm->idx[k+1], i2 = pm->idx[k+2];
|
||||
if (i0 >= nv || i1 >= nv || i2 >= nv)
|
||||
continue;
|
||||
float tt = BTRayTri(o3, d3, P + i0*3, P + i1*3, P + i2*3);
|
||||
if (tt >= 0.0f && tt < triBestT)
|
||||
{
|
||||
// world-space t of the local hit (handles any scale)
|
||||
D3DXVECTOR3 lh(o3[0]+d3[0]*tt, o3[1]+d3[1]*tt, o3[2]+d3[2]*tt);
|
||||
D3DXVECTOR3 wh;
|
||||
D3DXVec3TransformCoord(&wh, &lh, &l2w);
|
||||
float wt = (wh.x - ray_start[0]) * ray_dir[0]
|
||||
+ (wh.y - ray_start[1]) * ray_dir[1]
|
||||
+ (wh.z - ray_start[2]) * ray_dir[2];
|
||||
if (wt >= 0.0f && wt < triBestT)
|
||||
{
|
||||
triBestT = wt;
|
||||
triBestZone = sp->second.zone; // segment dzone (fallback)
|
||||
triBestSeg = sp->first;
|
||||
triBestObj = obj;
|
||||
triBestK = k; // first index of the tri
|
||||
triHit = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- sphere FALLBACK bookkeeping -- #131 MISS-MEANS-MISS: only an
|
||||
// object whose drawn mesh is UNREADABLE (pm == 0) may answer by
|
||||
// sphere. A readable mesh the ray misses is a MISS. The old
|
||||
// any-object sphere halo produced lock-without-mech: level rays over
|
||||
// the squat blackhawk read tri=0 / sphereFB=168 on the sweep bench,
|
||||
// and the operator watched the reticle pass over its head with the
|
||||
// ring lit. The 1995 card cast against the DRAWN geometry -- no halo.
|
||||
if (pm != 0)
|
||||
continue;
|
||||
float score = d2 / r2; // 0 = dead-center thread
|
||||
if (r > bestR
|
||||
|| (r == bestR && score >= bestScore))
|
||||
@@ -1621,6 +2028,58 @@ int
|
||||
hitAny = 1;
|
||||
}
|
||||
|
||||
// #131 telemetry: how did this pick answer? (TRI = drawn-geometry hit,
|
||||
// the authentic semantic; SPHERE = the fallback answered with no triangle
|
||||
// struck -- the false-lock candidate.) Throttled under BT_PICK_LOG.
|
||||
// objs/inv/noTri localize a dead triangle pass: objs = sphere-nominated
|
||||
// objects entering the tri test, inv = SILENT skips from a failed
|
||||
// world-matrix inverse (degenerate l2w), noTri = pick-mesh read failures.
|
||||
if (getenv("BT_PICK_LOG"))
|
||||
{
|
||||
static int s_src[3] = {0,0,0}; // [0]=tri [1]=sphere [2]=miss
|
||||
static int s_objs = 0, s_inv = 0, s_noTri = 0;
|
||||
static int s_rep = 0;
|
||||
s_objs += dbgTriObjs; s_inv += dbgInvFail; s_noTri += dbgNoTri;
|
||||
++s_src[triHit ? 0 : (hitAny ? 1 : 2)];
|
||||
if ((++s_rep % 240) == 0)
|
||||
DEBUG_STREAM << "[picksrc] tri=" << s_src[0]
|
||||
<< " sphereFB=" << s_src[1] << " miss=" << s_src[2]
|
||||
<< " objs=" << s_objs << " invFail=" << s_inv
|
||||
<< " noTri=" << s_noTri
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
|
||||
if (triHit)
|
||||
{
|
||||
bestT = triBestT;
|
||||
bestZone = triBestZone; // segment dzone unless a patch tags it
|
||||
hitAny = 1;
|
||||
// #124-PATCH: attribute the struck triangle to its DRAW OP (each op is
|
||||
// an index RANGE in the same IB the pick mesh copied) and take the
|
||||
// op's .DZM-bound zone -- the authored dz_* panel. dmgBinds is the
|
||||
// #87 armour mapping: {obj, op, zone} for every material a zone paints.
|
||||
if (triBestObj != 0)
|
||||
{
|
||||
const std::vector<MechRenderTree::DmgBind> &binds = it->second.dmgBinds;
|
||||
for (size_t b = 0; b < binds.size(); ++b)
|
||||
{
|
||||
if (binds[b].obj != triBestObj)
|
||||
continue;
|
||||
L4DRAWOP *op = triBestObj->GetDrawOp(binds[b].op);
|
||||
if (op == 0 || op->bgfPrimCount <= 0)
|
||||
continue;
|
||||
size_t first = (size_t)op->bgfStartIndex;
|
||||
size_t count = (size_t)op->bgfPrimCount * 3;
|
||||
if (triBestK >= first && triBestK < first + count)
|
||||
{
|
||||
bestZone = binds[b].zone; // the struck PANEL's zone
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
(void)triBestSeg;
|
||||
}
|
||||
|
||||
// #92 probe: which spheres did the ray actually THREAD, and which won?
|
||||
// "smallest radius wins" means a big sphere can never beat a small one that
|
||||
// the ray also grazes -- so a foot can be unhittable behind its own knee.
|
||||
@@ -1681,7 +2140,9 @@ int
|
||||
static int s_pl = 0;
|
||||
if ((s_pl++ % 60) == 0)
|
||||
DEBUG_STREAM << "[pickwin] zone=" << bestZone
|
||||
<< " score=" << bestScore << " t=" << bestT << "\n" << std::flush;
|
||||
<< " score=" << bestScore << " t=" << bestT
|
||||
<< " tri=" << triHit << " triSeg=" << triBestSeg
|
||||
<< " triZone=" << triBestZone << "\n" << std::flush;
|
||||
}
|
||||
|
||||
hit_out[0] = ray_start[0] + bestT * ray_dir[0];
|
||||
@@ -1692,11 +2153,102 @@ int
|
||||
}
|
||||
|
||||
//
|
||||
// Game-side bridge (mech4.cpp's per-frame target pick; same access pattern as
|
||||
// the wreck swap below).
|
||||
// #124 zone walker: the zone's visual aim anchor (see the hpp note).
|
||||
//
|
||||
int BTMechSegmentPick(void *mech, const float ray_start[3], const float ray_dir[3],
|
||||
float max_range, float hit_out[3], int *zone_out)
|
||||
int
|
||||
BTL4VideoRenderer::ZoneAimPoint(Entity *mech, int zone, float out3[3])
|
||||
{
|
||||
std::map<Entity*, MechRenderTree>::iterator it = mMechRenderTrees.find(mech);
|
||||
if (it == mMechRenderTrees.end() || it->second.wrecked)
|
||||
return 0;
|
||||
|
||||
// #124-PATCH: a zone with .DZM-bound panel patches aims at the PATCH
|
||||
// CENTROID (the authored dz_* panel -- upper/left/right/rear torso are
|
||||
// distinct patch clusters on ONE torso object, so the old cull-center
|
||||
// answer aimed every hull zone at the same chest point). Centroid =
|
||||
// average of the bound ops' triangle vertices, world-transformed.
|
||||
{
|
||||
double acc[3] = { 0, 0, 0 };
|
||||
long nAcc = 0;
|
||||
const std::vector<MechRenderTree::DmgBind> &binds = it->second.dmgBinds;
|
||||
for (size_t b = 0; b < binds.size(); ++b)
|
||||
{
|
||||
if (binds[b].zone != zone || binds[b].obj == NULL)
|
||||
continue;
|
||||
d3d_OBJECT *obj = binds[b].obj;
|
||||
L4DRAWOP *op = obj->GetDrawOp(binds[b].op);
|
||||
if (op == 0 || op->bgfPrimCount <= 0)
|
||||
continue;
|
||||
BTPickMesh *pm = BTGetPickMesh(obj);
|
||||
if (pm == 0)
|
||||
continue;
|
||||
D3DXMATRIX l2w = obj->GetLocalToWorld();
|
||||
size_t first = (size_t)op->bgfStartIndex;
|
||||
size_t last = first + (size_t)op->bgfPrimCount * 3;
|
||||
if (last > pm->idx.size())
|
||||
continue;
|
||||
for (size_t k = first; k < last; ++k)
|
||||
{
|
||||
unsigned int vi = pm->idx[k];
|
||||
if ((size_t)vi * 3 + 2 >= pm->pos.size())
|
||||
continue;
|
||||
D3DXVECTOR3 lp(pm->pos[vi*3], pm->pos[vi*3+1], pm->pos[vi*3+2]);
|
||||
D3DXVECTOR3 wp;
|
||||
D3DXVec3TransformCoord(&wp, &lp, &l2w);
|
||||
acc[0] += wp.x; acc[1] += wp.y; acc[2] += wp.z;
|
||||
++nAcc;
|
||||
}
|
||||
}
|
||||
if (nAcc > 0)
|
||||
{
|
||||
out3[0] = (float)(acc[0] / nAcc);
|
||||
out3[1] = (float)(acc[1] / nAcc);
|
||||
out3[2] = (float)(acc[2] / nAcc);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
d3d_OBJECT *best = 0;
|
||||
std::map<int, MechRenderTree::SegPick>::iterator sp;
|
||||
for (sp = it->second.segPick.begin(); sp != it->second.segPick.end(); ++sp)
|
||||
{
|
||||
if (sp->second.zone != zone || sp->second.obj == NULL)
|
||||
continue;
|
||||
if (best == 0 || sp->second.obj->mCullRadius > best->mCullRadius)
|
||||
best = sp->second.obj;
|
||||
}
|
||||
if (best == 0 || best->mCullRadius <= 0.0f)
|
||||
return 0;
|
||||
D3DXMATRIX l2w = best->GetLocalToWorld();
|
||||
D3DXVECTOR3 cw;
|
||||
D3DXVec3TransformCoord(&cw, &best->mCullCenter, &l2w);
|
||||
out3[0] = cw.x; out3[1] = cw.y; out3[2] = cw.z;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// The carrier-SEGMENT variant: anchor at segPick[seg]'s cull center (the
|
||||
// piece's visual middle). For hull-family zones no object claims the zone,
|
||||
// and the zone table's carrier is the hull joint whose ORIGIN sits in the
|
||||
// crotch gap -- rays aimed there sail between the legs to the terrain
|
||||
// sentinel. The cull center is the chest.
|
||||
int
|
||||
BTL4VideoRenderer::SegAimPoint(Entity *mech, int seg, float out3[3])
|
||||
{
|
||||
std::map<Entity*, MechRenderTree>::iterator it = mMechRenderTrees.find(mech);
|
||||
if (it == mMechRenderTrees.end() || it->second.wrecked)
|
||||
return 0;
|
||||
std::map<int, MechRenderTree::SegPick>::iterator sp = it->second.segPick.find(seg);
|
||||
if (sp == it->second.segPick.end() || sp->second.obj == NULL
|
||||
|| sp->second.obj->mCullRadius <= 0.0f)
|
||||
return 0;
|
||||
D3DXMATRIX l2w = sp->second.obj->GetLocalToWorld();
|
||||
D3DXVECTOR3 cw;
|
||||
D3DXVec3TransformCoord(&cw, &sp->second.obj->mCullCenter, &l2w);
|
||||
out3[0] = cw.x; out3[1] = cw.y; out3[2] = cw.z;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int BTMechSegAimPoint(void *mech, int seg, float out3[3])
|
||||
{
|
||||
if (mech == NULL || application == NULL)
|
||||
return 0;
|
||||
@@ -1704,6 +2256,35 @@ int BTMechSegmentPick(void *mech, const float ray_start[3], const float ray_dir[
|
||||
(BTL4VideoRenderer *)application->GetVideoRenderer();
|
||||
if (renderer == NULL)
|
||||
return 0;
|
||||
return renderer->SegAimPoint((Entity *)mech, seg, out3);
|
||||
}
|
||||
|
||||
int BTMechZoneAimPoint(void *mech, int zone, float out3[3])
|
||||
{
|
||||
if (mech == NULL || application == NULL)
|
||||
return 0;
|
||||
BTL4VideoRenderer *renderer =
|
||||
(BTL4VideoRenderer *)application->GetVideoRenderer();
|
||||
if (renderer == NULL)
|
||||
return 0;
|
||||
return renderer->ZoneAimPoint((Entity *)mech, zone, out3);
|
||||
}
|
||||
|
||||
//
|
||||
// Game-side bridge (mech4.cpp's per-frame target pick; same access pattern as
|
||||
// the wreck swap below).
|
||||
//
|
||||
int BTMechSegmentPick(void *mech, const float ray_start[3], const float ray_dir[3],
|
||||
float max_range, float hit_out[3], int *zone_out)
|
||||
{
|
||||
// #131 contract: 1 = drawn-geometry hit, 0 = TRUE MISS (mesh tested),
|
||||
// -1 = no renderer/tree (caller may box-test as pre-tree grace).
|
||||
if (mech == NULL || application == NULL)
|
||||
return -1;
|
||||
BTL4VideoRenderer *renderer =
|
||||
(BTL4VideoRenderer *)application->GetVideoRenderer();
|
||||
if (renderer == NULL)
|
||||
return -1;
|
||||
return renderer->MechSegmentPick((Entity *)mech, ray_start, ray_dir,
|
||||
max_range, hit_out, zone_out);
|
||||
}
|
||||
@@ -2916,6 +3497,16 @@ int
|
||||
if (inside && nm != NULL && strstr((const char *)*nm, "_cop") != NULL
|
||||
&& getenv("BT_HIDE_COCKPIT"))
|
||||
nm = NULL;
|
||||
// #91 attribution diag: BT_HIDE_INSIDE_SEG=<substr> hides any OTHER
|
||||
// inside-view (type-A) mesh whose name contains the substring -- the
|
||||
// thor authors THREE type-A meshes (cop + thx_tor + thx_msl) and one
|
||||
// of the extras is the reported black rectangle.
|
||||
{
|
||||
const char *hide = getenv("BT_HIDE_INSIDE_SEG");
|
||||
if (inside && nm != NULL && hide && *hide
|
||||
&& strstr((const char *)*nm, hide) != NULL)
|
||||
nm = NULL;
|
||||
}
|
||||
d3d_OBJECT *obj = NULL;
|
||||
if (nm != NULL)
|
||||
{
|
||||
@@ -2947,6 +3538,14 @@ int
|
||||
else
|
||||
render_tree.segPick.erase(slot);
|
||||
if (obj) ++shown; else ++hidden;
|
||||
// Inside view: NAME what renders. The KB says the inside view is the
|
||||
// _cop alone; any OTHER type-A mesh here is a black-material stowaway
|
||||
// (the #91 rectangle investigation) -- keep the roster visible.
|
||||
if (inside && obj != NULL)
|
||||
DEBUG_STREAM << "[view] shown seg " << slot << ": "
|
||||
<< (const char *)segment->GetName()
|
||||
<< " mesh " << (nm ? (const char *)*nm : "?")
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
// This reloaded every segment mesh, so every armour-damage binding is stale.
|
||||
BindArmourDamage(viewpoint, render_tree);
|
||||
|
||||
@@ -721,6 +721,24 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
|
||||
struct DmgBind { d3d_OBJECT *obj; int op; int zone; };
|
||||
std::vector<DmgBind> dmgBinds;
|
||||
std::map<int, int> segGState; // slot -> last applied graphic state
|
||||
// SEARCHLIGHT (2026-08-05, MakeMechRenderables case 0xbd8 @004cef28):
|
||||
// external view = a spot.bgf cone child on the mount joint, shown/
|
||||
// hidden from the subsystem's "LightOn" attribute (the @0045612c
|
||||
// watcher, transcribed into TickSearchlight); cockpit view = the fog
|
||||
// swap (@00456778/@00456814 -> SetFogStyle searchLightOn/Off).
|
||||
struct SearchLight
|
||||
{
|
||||
DPLStaticChildRenderable *cone; // external only; NULL on cockpit builds
|
||||
d3d_OBJECT *coneObj; // spot.bgf (held while hidden)
|
||||
int *lightOn; // the subsystem's LightOn attribute
|
||||
int shown; // cone visibility last applied
|
||||
int mountSeg; // resource segmentIndex (subsys +0x1DC)
|
||||
};
|
||||
SearchLight searchLight[2]; // the binary keeps 2 slots
|
||||
int searchLightCount;
|
||||
int searchIsCockpit; // built with view_type 1
|
||||
int searchFogCache[2]; // seeded INVERTED (@00456778
|
||||
// [9]/[10]) -> first tick syncs
|
||||
char paintSerno; // the %serno% this mech was BUILT with
|
||||
// (0 = none) -- ApplyViewSkeleton re-parses
|
||||
// segment BGFs, so the paint substitutions
|
||||
@@ -740,6 +758,15 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
|
||||
int
|
||||
TickWreck(Entity *victim, float dt);
|
||||
|
||||
//
|
||||
// SEARCHLIGHT per-frame drive (2026-08-05): the transcription of the two
|
||||
// 1995 watcher renderables -- cone visibility from "LightOn" (@0045612c
|
||||
// Execute @004561d8) and the cockpit fog swap (@00456778 Execute
|
||||
// @00456814). Rides the same sim bridge as TickArmourDamage.
|
||||
//
|
||||
void
|
||||
TickSearchlight(Entity *mech);
|
||||
|
||||
//
|
||||
// ARMOUR DARKENING (issue #87). BindArmourDamage resolves the mech's
|
||||
// .DZM zone->material lists (DamageZone::GetMaterialList, already parsed
|
||||
@@ -797,6 +824,15 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
|
||||
const float ray_dir[3], float max_range,
|
||||
float hit_out[3], int *zone_out);
|
||||
|
||||
// #124 zone walker: a zone's VISUAL aim point -- its largest pick
|
||||
// object's cull-center in world (what a player aims at; the segment
|
||||
// ORIGIN sits at the joint and makes lower segments strike the part
|
||||
// above). 0 when the zone has no pick geometry.
|
||||
int
|
||||
ZoneAimPoint(Entity *mech, int zone, float out3[3]);
|
||||
int
|
||||
SegAimPoint(Entity *mech, int seg, float out3[3]);
|
||||
|
||||
protected:
|
||||
//
|
||||
// Renderer-manager overrides
|
||||
|
||||
+373
-125
@@ -203,6 +203,35 @@ static_assert(offsetof(BTPlayer::MakeMessage, roleName) == 0x90,
|
||||
static_assert(sizeof(BTPlayer::MakeMessage) == 0xD0,
|
||||
"BTPlayer::MakeMessage wire size must be 0xD0");
|
||||
|
||||
// Wire-format lock (2026-08-05): the combat score reports and the extended
|
||||
// VehicleDead cross nodes raw (replicant-player Dispatch reroute), and the
|
||||
// sender/receiver field map was transcribed from the binary's builds
|
||||
// (@0x4a04da/@0x4a05d9/@0x4a06c0 and @0x4a07d4) -- lock every offset.
|
||||
static_assert(offsetof(BTPlayer::ScoreMessage, scoreAward) == 0x1c,
|
||||
"ScoreMessage::scoreAward must be at wire offset 0x1c");
|
||||
static_assert(offsetof(BTPlayer::ScoreMessage, scoreType) == 0x20,
|
||||
"ScoreMessage::scoreType must be at wire offset 0x20");
|
||||
static_assert(offsetof(BTPlayer::ScoreMessage, damageAmount) == 0x24,
|
||||
"ScoreMessage::damageAmount must be at wire offset 0x24");
|
||||
static_assert(offsetof(BTPlayer::ScoreMessage, vitalHit) == 0x28,
|
||||
"ScoreMessage::vitalHit must be at wire offset 0x28");
|
||||
static_assert(offsetof(BTPlayer::ScoreMessage, zoneIndex) == 0x2c,
|
||||
"ScoreMessage::zoneIndex must be at wire offset 0x2c");
|
||||
static_assert(offsetof(BTPlayer::ScoreMessage, subsysID) == 0x30,
|
||||
"ScoreMessage::subsysID must be at wire offset 0x30");
|
||||
static_assert(offsetof(BTPlayer::ScoreMessage, senderMechID) == 0x34,
|
||||
"ScoreMessage::senderMechID must be at wire offset 0x34");
|
||||
static_assert(sizeof(BTPlayer::ScoreMessage) == 0x3c,
|
||||
"ScoreMessage wire size must be 0x3c (binary build @0x4a052b)");
|
||||
static_assert(offsetof(BTPlayer::VehicleDeadMessage, reserved28) == 0x28,
|
||||
"VehicleDeadMessage::reserved28 must be at wire offset 0x28");
|
||||
static_assert(offsetof(BTPlayer::VehicleDeadMessage, killedByPlayerID) == 0x2c,
|
||||
"VehicleDeadMessage::killedByPlayerID must be at wire offset 0x2c");
|
||||
static_assert(offsetof(BTPlayer::VehicleDeadMessage, killZone) == 0x34,
|
||||
"VehicleDeadMessage::killZone must be at wire offset 0x34");
|
||||
static_assert(sizeof(BTPlayer::VehicleDeadMessage) == 0x38,
|
||||
"VehicleDeadMessage wire size must be 0x38 (binary build @0x4a07f6)");
|
||||
|
||||
//
|
||||
// LAYOUT LOCK (Gitea #48/#57). Our compiled BTPlayer is NOT the binary's -- the
|
||||
// Entity base differs -- so the 1995 offsets must never be used as raw byte
|
||||
@@ -641,6 +670,32 @@ void
|
||||
}
|
||||
// else: out of lives -> the +10s mission-review post (id 0x18). Deferred.
|
||||
|
||||
//
|
||||
// THE DEATH SCORE COST (@004c07cd-0x4c0828, raw disasm 2026-08-02 -- this
|
||||
// tail was missing from the #52 reconstruction). Gated on advancedDamageOn
|
||||
// (+0x264) alone in the binary: self-award -SpecialCaseDeathPenalty
|
||||
// (role+0x20, the 4.10-only role field restored to SCNROLE.h) as a type-1
|
||||
// score message handed DIRECTLY to the engine base handler (@0042da20 ==
|
||||
// PLAYER.cpp:138 `currentScore += scoreAward` + the vehicle
|
||||
// RespondToScoreMessage no-op hook) -- NOT dispatched, so it bypasses the
|
||||
// BT ScoreInflicted Verify. Shipped content authors the key nowhere, so
|
||||
// the cost is 0 in the field today; a mission that authors it gets it.
|
||||
// BINARY NUANCE [T4]: the 1995 engine cell (+0x1c8) is read by no BT-side
|
||||
// scoreboard (they read +0x278), so the pod's penalty may never have been
|
||||
// visible; our port has ONE currentScore, so it shows.
|
||||
//
|
||||
if (advancedDamageOn && scenarioRole != 0) // this+0x264 (binary derefs role unguarded)
|
||||
{
|
||||
BTPlayer::ScoreMessage death_cost(
|
||||
BTPlayer::ScoreInflictedMessageID, // 0x16
|
||||
sizeof(BTPlayer::ScoreMessage),
|
||||
BTPlayer::ScoreMessage::DamageReceivedScore, // type 1
|
||||
-scenarioRole->GetSpecialCaseDeathPenalty(), // scoreAward @+0x1c, NEGATED
|
||||
0.0f,
|
||||
EntityID::Null); // senderMechID (binary copies @0x522524)
|
||||
Player::ScoreMessageHandler(&death_cost); // the DIRECT base call
|
||||
}
|
||||
|
||||
//
|
||||
// RELEASE THE DEATH LATCH (#81, the GHOST MECH fix -- 2026-07-30).
|
||||
//
|
||||
@@ -825,30 +880,45 @@ void
|
||||
{
|
||||
//
|
||||
// Points lost for damage we took (unless we hit ourselves).
|
||||
// sender null guard is PORT SAFETY: the binary derefs the registry
|
||||
// lookup unguarded (@0x4c0453) -- an inflictor that died between
|
||||
// shot and impact would have crashed the 1995 pod.
|
||||
//
|
||||
if (sender_mech != our_mech && scenarioRole != 0) // scoring wave: guard the NULL bring-up role
|
||||
if (sender_mech != 0 && our_mech != 0 // port guards (dead-window straggler)
|
||||
&& sender_mech != our_mech && scenarioRole != 0) // scoring wave: guard the NULL bring-up role
|
||||
{
|
||||
Scalar received =
|
||||
scenarioRole->CalcDamageReceivedScore(message->damageAmount); // FUN_00429b94
|
||||
award = (MECH_TONNAGE(our_mech) / MECH_TONNAGE(sender_mech)) * received;
|
||||
}
|
||||
// BINARY NUANCE [T1 raw disasm @0x4c0433]: on SELF-inflicted damage
|
||||
// (sender == own vehicle) the 1995 arm skips the math and the common
|
||||
// tail folds its UNINITIALIZED award local into currentScore --
|
||||
// stack garbage, every fall/ram self-hit. The port keeps award=0.0,
|
||||
// a deliberate safe deviation.
|
||||
|
||||
//
|
||||
// Tell the console (if any, and not suppressed) that our VTV
|
||||
// was damaged.
|
||||
// was damaged. points_transfered CORRECTED 2026-08-05: the binary
|
||||
// rounds THE COMPUTED AWARD here (raw disasm @0x4c04a7: fld
|
||||
// [ebp-0xc]; call 0x4dcd94 -- an ST0-arg __ftol the decompiler
|
||||
// rendered argless, which an earlier pass misread as Now()).
|
||||
//
|
||||
Host *console_host =
|
||||
application->GetHostManager()->GetConsoleHost(); // FUN_00429078
|
||||
if (console_host && suppressConsole == 0) // this+0x258
|
||||
BTPlayer *damager_player =
|
||||
(sender_mech != 0) ? MECH_OWNING_PLAYER(sender_mech) : 0; // port guard (dummy shooter)
|
||||
if (console_host && suppressConsole == 0 // this+0x258
|
||||
&& damager_player != 0)
|
||||
{
|
||||
ConsolePlayerVTVDamagedMessage damaged_message(
|
||||
ownerID, // this+0x18c
|
||||
MECH_OWNING_PLAYER(sender_mech)->ownerID, // *(sender+0x190)+0x18c
|
||||
damager_player->ownerID, // *(sender+0x190)+0x18c
|
||||
Round(message->damageAmount), // FUN_004078fc
|
||||
message->pointSenderLo, // msg+0x2c
|
||||
message->pointSenderHi, // msg+0x28
|
||||
(int)Now().ticks, // FUN_004dcd94
|
||||
message->auxID // msg+0x30
|
||||
message->zoneIndex, // msg+0x2c
|
||||
message->vitalHit, // msg+0x28
|
||||
Round(award), // FUN_004dcd94(ST0)
|
||||
message->subsysID // msg+0x30
|
||||
);
|
||||
|
||||
application->SendMessage( // app+0x20, slot+0x18
|
||||
@@ -862,6 +932,13 @@ void
|
||||
|
||||
case BTPlayer::ScoreMessage::KillScore: // 2
|
||||
{
|
||||
// PORT SAFETY: an unresolvable victim mech (removed wreck racing the
|
||||
// cross-node dispatch) or a severed own vehicle skips the arm; the
|
||||
// binary derefs both unguarded (@0x4c037e).
|
||||
if (sender_mech == 0 || our_mech == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
// gauge scoring wave: the attacking mech's owner (NULL for the ownerless
|
||||
// BT_SPAWN_ENEMY dummy). Resolved once, guarded everywhere below.
|
||||
BTPlayer *sender_owner = MECH_OWNING_PLAYER(sender_mech); // *(sender+0x190), NULL for the dummy
|
||||
@@ -1837,6 +1914,14 @@ int BTPlayerAdvancedDamageOn(void *player_v)
|
||||
return (player_v != 0) ? ((BTPlayer *)player_v)->advancedDamageOn2 : 0;
|
||||
}
|
||||
|
||||
// #89 death blast, second gate: suppressConsole (+0x258) is SET by the eject
|
||||
// bookkeeping -- an EJECTED pilot's abandoned mech never blast-splashes
|
||||
// (raw disasm @0x4a0ac8-0x4a0ad6).
|
||||
int BTPlayerConsoleSuppressed(void *player_v)
|
||||
{
|
||||
return (player_v != 0) ? (int)((BTPlayer *)player_v)->suppressConsole : 0;
|
||||
}
|
||||
|
||||
int BTPilotKills(void *pilot)
|
||||
{
|
||||
int k = pilot ? ((BTPlayer *)pilot)->GetKillCount() : 0;
|
||||
@@ -2157,138 +2242,247 @@ Logical BTResolveMessageBoard(Entity * /*tracked_mech*/, int *messageId, BitMap
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// gauge scoring wave: PRODUCERS -- posted by the combat path (mech4.cpp) to feed
|
||||
// the scoreboard. The inflictor is always the viewpoint mech in bring-up, so the
|
||||
// local (crediting) player is application->GetMissionPlayer(). Bridges (not inline
|
||||
// in mech4.cpp) so the message construction lives in this complete-BTPlayer TU.
|
||||
// THE AUTHENTIC COMBAT REPORT SENDER (#45/#134 -- 2026-08-05)
|
||||
//
|
||||
void BTPostDamageScore(Entity *victim, Scalar damage) // Step 6: per-hit SCORE
|
||||
// Reconstructed from the raw disasm of the Mech::TakeDamageMessageHandler
|
||||
// report tail @0x4a02f4-0x4a0890 (dark-gap region, not in the export). This
|
||||
// RETIRES the bring-up producers BTPostDamageScore / BTPostKillScore (see the
|
||||
// tombstone below). The binary's flow, transcribed:
|
||||
//
|
||||
// resolve: shooterEntity = registry find(msg->inflictingEntity) @0x4a033c
|
||||
// shooterPlayer = shooterEntity+0x190 @0x4a034f
|
||||
// victimPlayer = mech+0x190 @0x4a0358
|
||||
// block A @0x4a04da (was ALIVE at entry, destroyed now):
|
||||
// ScoreMessage{id 0x16, type 2 KillScore,
|
||||
// scoreAward = the APPLIED damage tally,
|
||||
// damageAmount = victim role's killBonus (role+0x1c),
|
||||
// vitalHit, zone, subsysID,
|
||||
// senderMechID = the VICTIM} -> the SHOOTER's player
|
||||
// block B @0x4a05d9 (not newly killed, tally != 0):
|
||||
// same shape, type 0 -- which the only registered 0x16 receiver
|
||||
// (@0x4c02e4) VERIFY-rejects; 1995 then folded an UNINITIALIZED
|
||||
// award. Sent for wire fidelity; our handler banks 0.
|
||||
// block C @0x4a06c0 (tally > 0; runs after A too -- kills included):
|
||||
// ScoreMessage{type 1 DamageReceivedScore,
|
||||
// scoreAward = tally,
|
||||
// damageAmount = INTENDED (burstCount x amount),
|
||||
// senderMechID = the INFLICTOR} -> the victim's player
|
||||
//
|
||||
// Score-model consequence [T1]: shooters are credited ONLY for kills; victims
|
||||
// are PENALIZED for damage received (CalcDamageReceivedScore returns the
|
||||
// negative). Per-hit inflicted credit never existed in the 1995 pod.
|
||||
//
|
||||
// Port accommodations, all guarded: registry/player lookups null-checked (the
|
||||
// binary derefs them raw); killBonus basis 0 for the ownerless dummy victim;
|
||||
// a skipped kill credit keeps the #45 NOCREDIT matchlog forensics.
|
||||
//
|
||||
void BTMechPostCombatReports(
|
||||
void *mech_v, // the VICTIM mech (the handler's this)
|
||||
void *msg_v, // the TakeDamageMessage being handled
|
||||
float damage_tally, // loop local_24: applied damage + crit bonuses
|
||||
int vital_hit, // loop local_2c: a zone entered BurningState
|
||||
int report_zone, // msg->damageZone at loop ENTRY (the binary
|
||||
// reads msg+0x24, never rewritten mid-loop)
|
||||
int newly_destroyed) // alive at entry && IsMechDestroyed() now
|
||||
{
|
||||
if (application == 0 || victim == 0)
|
||||
Mech *victim = (Mech *)mech_v;
|
||||
Entity::TakeDamageMessage *msg = (Entity::TakeDamageMessage *)msg_v;
|
||||
if (application == 0 || victim == 0 || msg == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
BTPlayer *local_player = (BTPlayer *)application->GetMissionPlayer();
|
||||
if (local_player == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// ScoreInflicted (scoreType 0): senderMechID = the mech that TOOK the damage.
|
||||
// -> ScoreInflictedMessageHandler: currentScore += tonnageRatio*CalcInflictedScore.
|
||||
BTPlayer::ScoreMessage message(
|
||||
BTPlayer::ScoreInflictedMessageID, // 0x16
|
||||
sizeof(BTPlayer::ScoreMessage),
|
||||
BTPlayer::ScoreMessage::DamageInflictedScore, // 0
|
||||
0.0f, // scoreAward (unused for inflicted)
|
||||
damage, // damageAmount
|
||||
victim->GetEntityID()); // senderMechID = victim
|
||||
local_player->Dispatch(&message);
|
||||
if (getenv("BT_SCORE_LOG"))
|
||||
DEBUG_STREAM << "[score] +damage " << damage << " -> currentScore="
|
||||
<< (Scalar)local_player->GetScore() << "\n" << std::flush;
|
||||
}
|
||||
|
||||
void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP death)
|
||||
{
|
||||
if (application == 0 || victim == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// KILL credit. Runs on the VICTIM's node -- the only node whose mech carries a
|
||||
// populated `lastInflictingID` (damage is applied master-side only: 0 of 8800
|
||||
// corpus DMG rows target a replicant, which is why every `DEATH inst=R` row reads
|
||||
// `killer=0:0`). The credit is dispatched to the killer's Player, which HERE is a
|
||||
// REPLICANT, so `Entity::Dispatch` reroutes it to the owning host
|
||||
// (ENTITY.cpp:244-251) and `++killCount` lands on the killer's OWN machine.
|
||||
//
|
||||
// CORRECTED 2026-07-25 (Gitea #45). The previous banner claimed "every node
|
||||
// maintains LOCAL score copies ... each node witnesses the death transition and
|
||||
// tallies its own copies self-consistently". That was FALSE and it is what hid
|
||||
// this bug: the reroute means exactly ONE node increments, no other node can
|
||||
// observe the killer at all, and nothing carried the value back out -- so every
|
||||
// remote pilot's KILLS read 0 on every other pod, all mission (corpus invariant:
|
||||
// 0 of 18818 DMG rows are inst=R, so no other node can even attribute the kill;
|
||||
// every DEATH inst=R row reads killer=0:0). The counters are
|
||||
// now replicated owner->replicant instead; see Read/WriteUpdateRecord above.
|
||||
BTPlayer *killer_player = 0;
|
||||
BTPlayer *shooter_player = 0;
|
||||
Entity *shooter = 0;
|
||||
extern int BTIsRegisteredMech(Entity *e);
|
||||
if (application->GetHostManager() != 0)
|
||||
{
|
||||
Entity *killer = application->GetHostManager()->GetEntityPointer(
|
||||
((Mech *)victim)->lastInflictingID);
|
||||
extern int BTIsRegisteredMech(Entity *e);
|
||||
if (killer != 0 && killer != victim && BTIsRegisteredMech(killer))
|
||||
killer_player = (BTPlayer *)((Mech *)killer)->GetPlayerLink();
|
||||
shooter = application->GetHostManager()->GetEntityPointer(
|
||||
msg->inflictingEntity);
|
||||
if (shooter != 0 && BTIsRegisteredMech(shooter))
|
||||
shooter_player = (BTPlayer *)((Mech *)shooter)->GetPlayerLink();
|
||||
}
|
||||
if (killer_player != 0)
|
||||
BTPlayer *victim_player = (BTPlayer *)victim->GetPlayerLink();
|
||||
|
||||
if (newly_destroyed)
|
||||
{
|
||||
// KillScore (scoreType 2): senderMechID MUST be the VICTIM (!= the
|
||||
// receiver's mech) so the handler credits `killCount++` -- a suicide
|
||||
// (killer == victim) never reaches here.
|
||||
BTPlayer::ScoreMessage kill(
|
||||
Player::ScoreMessageID, // 0x12
|
||||
//
|
||||
// Block A: the KILL report, to the SHOOTER's player. On the victim's
|
||||
// node that player is a replicant, so Entity::Dispatch reroutes to the
|
||||
// owning host (ENTITY.cpp:244) and the credit lands on the killer's own
|
||||
// machine -- the #45 flow, unchanged. killer == victim (the eject
|
||||
// charge) IS dispatched: the handler negates the award. That is the
|
||||
// #134 panic-penalty path the old BTPostKillScore filtered out.
|
||||
//
|
||||
Scalar kill_bonus = 0.0f; // basis: victim role+0x1c
|
||||
if (victim_player != 0 && victim_player->GetScenarioRole() != 0)
|
||||
kill_bonus = victim_player->GetScenarioRole()->GetKillBonus();
|
||||
if (shooter_player != 0)
|
||||
{
|
||||
BTPlayer::ScoreMessage kill(
|
||||
Player::ScoreMessageID, // 0x16
|
||||
sizeof(BTPlayer::ScoreMessage), // 0x3c
|
||||
BTPlayer::ScoreMessage::KillScore, // 2
|
||||
damage_tally, // +0x1c
|
||||
kill_bonus, // +0x24
|
||||
victim->GetEntityID(), // +0x34 the VICTIM
|
||||
vital_hit, // +0x28
|
||||
report_zone, // +0x2c
|
||||
msg->inflictingSubsystemID); // +0x30
|
||||
shooter_player->Dispatch(&kill);
|
||||
if (getenv("BT_SCORE_LOG"))
|
||||
DEBUG_STREAM << "[score] *** KILL report *** -> shooterPlayer="
|
||||
<< (void *)shooter_player << " tally=" << damage_tally
|
||||
<< " bonus=" << kill_bonus << "\n" << std::flush;
|
||||
}
|
||||
else if (BTMatchLogActive())
|
||||
{
|
||||
// #45 forensics, carried from BTPostKillScore: WHY the credit was
|
||||
// skipped -- missing killer entity, unregistered, or NULL link.
|
||||
BTMatchLog("NOCREDIT",
|
||||
"victim=%d:%d killerID=%d:%d found=%d registered=%d link=%d",
|
||||
BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(),
|
||||
BTMatchHostOf(msg->inflictingEntity), (int)msg->inflictingEntity,
|
||||
(int)(shooter != 0),
|
||||
(int)(shooter != 0 && BTIsRegisteredMech(shooter)),
|
||||
(int)(shooter != 0 && ((Mech *)shooter)->GetPlayerLink() != 0));
|
||||
}
|
||||
}
|
||||
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).
|
||||
//
|
||||
BTPlayer::ScoreMessage inflicted(
|
||||
Player::ScoreMessageID,
|
||||
sizeof(BTPlayer::ScoreMessage),
|
||||
BTPlayer::ScoreMessage::KillScore, // 2
|
||||
0.0f, // scoreAward (killBonus; 0 for bring-up)
|
||||
damage, // damageAmount (killing-blow)
|
||||
victim->GetEntityID()); // senderMechID = victim
|
||||
killer_player->Dispatch(&kill);
|
||||
if (getenv("BT_SCORE_LOG"))
|
||||
DEBUG_STREAM << "[score] *** KILL *** killerPlayer=" << (void *)killer_player
|
||||
<< " killCount=" << killer_player->GetKillCount()
|
||||
<< " deaths=" << killer_player->GetDeaths()
|
||||
<< " score=" << (Scalar)killer_player->GetScore() << std::endl;
|
||||
}
|
||||
else if (BTMatchLogActive())
|
||||
{
|
||||
//
|
||||
// #45 observability: the credit was SKIPPED. This used to be completely
|
||||
// silent, which is why the bug survived so long -- the counter simply never
|
||||
// moved and no log said why. Record which link in the chain broke so a
|
||||
// single matchlog convicts it: a missing killer entity, an unregistered
|
||||
// mech, a self-kill, or the NULL playerLink hazard.
|
||||
//
|
||||
Entity *k = (application->GetHostManager() != 0)
|
||||
? application->GetHostManager()->GetEntityPointer(
|
||||
((Mech *)victim)->lastInflictingID)
|
||||
: 0;
|
||||
extern int BTIsRegisteredMech(Entity *e);
|
||||
BTMatchLog("NOCREDIT",
|
||||
"victim=%d:%d killerID=%d:%d found=%d self=%d registered=%d link=%d",
|
||||
BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(),
|
||||
BTMatchHostOf(((Mech *)victim)->lastInflictingID),
|
||||
(int)((Mech *)victim)->lastInflictingID,
|
||||
(int)(k != 0), (int)(k == victim),
|
||||
(int)(k != 0 && BTIsRegisteredMech(k)),
|
||||
(int)(k != 0 && ((Mech *)k)->GetPlayerLink() != 0));
|
||||
BTPlayer::ScoreMessage::DamageInflictedScore, // 0
|
||||
damage_tally, // +0x1c
|
||||
damage_tally, // +0x24 basis = tally
|
||||
victim->GetEntityID(),
|
||||
vital_hit, report_zone, msg->inflictingSubsystemID);
|
||||
shooter_player->Dispatch(&inflicted);
|
||||
}
|
||||
|
||||
// MP DEATH: the victim's death tally.
|
||||
//
|
||||
// REMOVED 2026-07-30 (#81): this used to dispatch a second
|
||||
// `Player::VehicleDeadMessage` to the victim's own player "to credit a
|
||||
// death". That message is the RESPAWN-CYCLE TRIGGER, not a scoreboard
|
||||
// increment, and the victim already receives one from the mech's death
|
||||
// transition (mech4.cpp:2110) -- which is the hardened, authentic notify
|
||||
// (it carries the #55 NULL-playerLink fallback). Both fire inside the SAME
|
||||
// death transition (BTPostKillScore is called at mech4.cpp:2006, the notify
|
||||
// at :2110), so they were always paired: every death dispatched the message
|
||||
// TWICE. The tally itself is credited by the handler's `++deathTally`
|
||||
// (:538) on the first one, so this dispatch never added anything.
|
||||
//
|
||||
// It was invisible because the `deathPending` latch silently deduped it --
|
||||
// that is what EVERY "death ... SWALLOWED" warning in the field logs
|
||||
// actually was (8 of 8 deaths, a 100% base rate, which is exactly why it
|
||||
// correlated with nothing). Once the latch was released to match the
|
||||
// binary (see the death handler's tail), the duplicate stopped being
|
||||
// masked and started a SECOND death cycle: deathCount double-incremented,
|
||||
// the first cycle's re-post went stale and tripped the drop-zone
|
||||
// `*** MISMATCH ***`, and one respawn burned two cycles. Caught on the
|
||||
// first solo bench of the latch fix.
|
||||
//
|
||||
// The kill credit above is unaffected -- it dispatches a ScoreMessage, the
|
||||
// correct message for a scoreboard change.
|
||||
if (damage_tally > 0.0f && victim_player != 0)
|
||||
{
|
||||
//
|
||||
// Block C: the RECEIVED report, to the victim's own player -- kills
|
||||
// included (the binary runs this after block A as well). Carries the
|
||||
// INTENDED damage (burstCount x amount, @0x4a06d2 fild/fmul) as the
|
||||
// penalty basis and the INFLICTOR as senderMechID; feeds the score
|
||||
// penalty and the operator-console VTVDamaged line.
|
||||
//
|
||||
int bursts = msg->damageData.burstCount;
|
||||
if (bursts < 1)
|
||||
bursts = 1; // port guard (mirrors the loop)
|
||||
BTPlayer::ScoreMessage received(
|
||||
Player::ScoreMessageID,
|
||||
sizeof(BTPlayer::ScoreMessage),
|
||||
BTPlayer::ScoreMessage::DamageReceivedScore, // 1
|
||||
damage_tally, // +0x1c
|
||||
(Scalar)bursts * msg->damageData.damageAmount, // +0x24 intended
|
||||
msg->inflictingEntity, // +0x34 the INFLICTOR
|
||||
vital_hit, report_zone, msg->inflictingSubsystemID);
|
||||
victim_player->Dispatch(&received);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// The authentic VehicleDead sender (@0x4a07d4-0x4a0890): the respawn-cycle
|
||||
// trigger, dispatched from the death tail of the SAME TakeDamage that killed
|
||||
// the mech, to the mech's own player -- now carrying the killed-by player and
|
||||
// the killing zone (the BT 0x38-byte message extension). Replaces the mech4
|
||||
// death-transition dispatch site, which carried a [T3 -- the binary's exact
|
||||
// sender is undecoded] flag; this IS that sender, decoded. The #55
|
||||
// NULL-playerLink fallback and the DEAD_NOTIFY forensics move here intact.
|
||||
//
|
||||
void BTMechPostVehicleDead(void *mech_v, void *msg_v, int report_zone)
|
||||
{
|
||||
Mech *victim = (Mech *)mech_v;
|
||||
Entity::TakeDamageMessage *msg = (Entity::TakeDamageMessage *)msg_v;
|
||||
if (application == 0 || victim == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Player *owner = victim->GetPlayerLink();
|
||||
// #55 (the David fix), moved intact: resolve the SAME object by the
|
||||
// reverse link the binary's own respawn branch uses when the once-written
|
||||
// playerLink was lost.
|
||||
if (owner == 0)
|
||||
{
|
||||
Player *mission_player = (Player *)application->GetMissionPlayer();
|
||||
if (mission_player != 0
|
||||
&& mission_player->GetPlayerVehicle() == (Entity *)victim)
|
||||
{
|
||||
owner = mission_player;
|
||||
}
|
||||
}
|
||||
BTMatchLog("DEAD_NOTIFY", "mech=%d:%d link=%p",
|
||||
BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(),
|
||||
(void *)owner);
|
||||
if (owner == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// killed-by: the shooter PLAYER's own EntityID (@0x4a07d4 copies
|
||||
// shooterPlayer+0x184); Null when the inflictor or its player is gone.
|
||||
EntityID killed_by = EntityID::Null;
|
||||
if (msg != 0 && application->GetHostManager() != 0)
|
||||
{
|
||||
Entity *shooter = application->GetHostManager()->GetEntityPointer(
|
||||
msg->inflictingEntity);
|
||||
extern int BTIsRegisteredMech(Entity *e);
|
||||
if (shooter != 0 && BTIsRegisteredMech(shooter))
|
||||
{
|
||||
Player *shooter_player = ((Mech *)shooter)->GetPlayerLink();
|
||||
if (shooter_player != 0)
|
||||
killed_by = shooter_player->GetEntityID();
|
||||
}
|
||||
}
|
||||
|
||||
BTPlayer::VehicleDeadMessage vehicle_dead(
|
||||
Player::VehicleDeadMessageID, // 0x17
|
||||
sizeof(BTPlayer::VehicleDeadMessage), // 0x38
|
||||
killed_by,
|
||||
report_zone);
|
||||
owner->Dispatch(&vehicle_dead);
|
||||
if (getenv("BT_DEATH_LOG"))
|
||||
DEBUG_STREAM << "[death] VehicleDead(-1) dispatched to the owning player"
|
||||
<< " (killedBy=" << BTMatchHostOf(killed_by) << ":" << (int)killed_by
|
||||
<< " zone=" << report_zone << ")\n" << std::flush;
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// TOMBSTONE (2026-08-05): BTPostDamageScore / BTPostKillScore -- the gauge
|
||||
// scoring wave's bring-up producers -- are RETIRED, replaced by the authentic
|
||||
// report tail above (BTMechPostCombatReports / BTMechPostVehicleDead, called
|
||||
// from Mech::TakeDamageMessageHandler at the binary's exact positions).
|
||||
// What changed for the scoreboard:
|
||||
// - per-hit INFLICTED credit is GONE: it was a port invention riding the
|
||||
// never-registered @0x4c0200 handler (the binary's BTPlayer table, byte-
|
||||
// scanned at file 0x112dxx, has 6 entries and none binds it). 1995
|
||||
// scoring = kill awards + received-damage penalties, nothing per-hit.
|
||||
// This also retires the #95 salvo-credit fix that rode it.
|
||||
// - suicides now DISPATCH the kill report and the handler NEGATES the
|
||||
// award -- the #134 panic-eject penalty path the old filter blocked.
|
||||
// - kill credit resolves the shooter from msg->inflictingEntity of the
|
||||
// killing TakeDamage itself; lastInflictingID (stamped from the same
|
||||
// field at handler entry) stays for the LOD router + effect orientation.
|
||||
// - the cross-node reroute story is unchanged: the kill report still
|
||||
// Dispatches to a replicant player and lands on the owner host
|
||||
// (ENTITY.cpp:244-251); KILLS/DEATHS still replicate owner->replicant
|
||||
// (Read/WriteUpdateRecord + the scoreboard heartbeat).
|
||||
// - the #81 single-VehicleDead rule is unchanged: exactly one dispatch per
|
||||
// death, now from the authentic tail instead of the death transition.
|
||||
// The ScoreInflictedMessageID channel and its handler remain registered but
|
||||
// idle (port infra; the binary's analog was dead code in the table).
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
||||
//#############################################################################
|
||||
// BTPlayerRoleLocksAdvanced -- complete-type bridge (task #12)
|
||||
@@ -2377,3 +2571,57 @@ Entity *BTPlayerObjectiveMechOf(void *player)
|
||||
return 0;
|
||||
return (Entity *)((BTPlayer *)player)->GetObjectiveMech(); // @0x284
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
// BTPlayerEjectBookkeeping -- complete-type bridge for Mech::EjectPilot
|
||||
// (@0049f854, the PANIC/EJECT punch-out). The handler's two player-side
|
||||
// writes, verbatim from the raw disasm:
|
||||
// * suppressConsole (+0x258) = 1 -- the eject already notified the console
|
||||
// (its own message, the tracked relay tail), so the DEATH that the
|
||||
// returned charge causes must not double-notify;
|
||||
// * the self-destruct amount = ScenarioRole::killBonus (role+0x1c -- the
|
||||
// record map is byte-verified: reader @00429bec dest offsets + the role
|
||||
// ctor's record copy [7]=rec[0]).
|
||||
// Returns the charge; 0 with no player/role (binary derefs unguarded).
|
||||
//#############################################################################
|
||||
Scalar BTPlayerEjectBookkeeping(void *player_v)
|
||||
{
|
||||
BTPlayer *player = (BTPlayer *)player_v;
|
||||
if (player == 0)
|
||||
{
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
// The console EJECT NOTICE (@0049f88e-0x49f8cf): FUN_004c198c is the
|
||||
// ConsolePlayerMechDeathWithoutHonorMessage ctor -- "death without honor"
|
||||
// IS the punch-out record (4.10 wire id 0xF; our catalogue re-tags it 6,
|
||||
// not load-bearing) -- sent to the console host through the same
|
||||
// ConsoleClientID path as the VTVDamaged notify. The suppressConsole
|
||||
// latch below is its other half: the eject notice REPLACES the death
|
||||
// notify for this death.
|
||||
Host *console_host =
|
||||
application->GetHostManager()->GetConsoleHost(); // FUN_00429078
|
||||
if (console_host != 0)
|
||||
{
|
||||
ConsolePlayerMechDeathWithoutHonorMessage eject_notice(
|
||||
player->ownerID); // this+0x18c
|
||||
application->SendMessage(
|
||||
console_host->GetHostID(), // console_host+0xc
|
||||
NetworkClient::ConsoleClientID, // 5
|
||||
&eject_notice);
|
||||
// Rare + forensic -- always log (the DumpAmmo precedent).
|
||||
DEBUG_STREAM << "[eject] DeathWithoutHonor notice -> console host "
|
||||
<< (int)console_host->GetHostID() << "\n" << std::flush;
|
||||
}
|
||||
else
|
||||
{
|
||||
DEBUG_STREAM << "[eject] no console host -- notice skipped\n" << std::flush;
|
||||
}
|
||||
player->suppressConsole = 1; // +0x258
|
||||
const ScenarioRole *role = player->GetScenarioRole(); // +0x208
|
||||
if (role == 0)
|
||||
{
|
||||
return 0.0f;
|
||||
}
|
||||
return role->GetKillBonus(); // role+0x1c
|
||||
}
|
||||
|
||||
@@ -57,14 +57,26 @@ class DropZone__ReplyMessage;
|
||||
// generated the points. Parallels RPPlayer__ScoreMessage, but the BT
|
||||
// score types describe mech combat instead of score zones.
|
||||
//
|
||||
// Observed message layout (param_2 in the handlers):
|
||||
// +0x1c scoreAward (Scalar, base Player::ScoreMessage)
|
||||
// Message layout, sizeof 0x3C (field meanings CORRECTED 2026-08-05 from the
|
||||
// raw disasm of the three sender builds in the Mech::TakeDamageMessageHandler
|
||||
// report tail @0x4a04da/@0x4a05d9/@0x4a06c0 -- the old "pointSender" names
|
||||
// were guesses from RP's smaller message, which stops at +0x28):
|
||||
// +0x1c scoreAward (Scalar, base) -- the sender puts the APPLIED
|
||||
// damage tally here; the receiving handler
|
||||
// overwrites it with the computed award before
|
||||
// delegating to Player::ScoreMessageHandler
|
||||
// +0x20 scoreType (int)
|
||||
// +0x24 damageAmount (Scalar) raw damage / point quantity
|
||||
// +0x28 pointSenderHi (EntityID word)
|
||||
// +0x2c pointSenderLo (EntityID word)
|
||||
// +0x30 auxID (EntityID / host word)
|
||||
// +0x34 senderMechID (EntityID -- resolved to the inflicting Mech)
|
||||
// +0x24 damageAmount (Scalar) the scoring BASIS: type 0 = tally,
|
||||
// type 1 = intended (burstCount x amount),
|
||||
// type 2 = the VICTIM role's killBonus (role+0x1c)
|
||||
// +0x28 vitalHit (int) a zone reached BurningState during
|
||||
// this message's applications (loop local_2c)
|
||||
// +0x2c zoneIndex (int) the struck damage zone (msg+0x24,
|
||||
// the INITIAL resolve -- not the last burst's)
|
||||
// +0x30 subsysID (int) inflictingSubsystemID passed through
|
||||
// from the TakeDamageMessage (+0x5c)
|
||||
// +0x34 senderMechID (EntityID) types 0/2: the VICTIM mech;
|
||||
// type 1: the INFLICTING mech
|
||||
//
|
||||
class BTPlayer__ScoreMessage:
|
||||
public Player::ScoreMessage
|
||||
@@ -72,11 +84,16 @@ class DropZone__ReplyMessage;
|
||||
public:
|
||||
//
|
||||
// Kind of scoring event. Recovered from the branch selector at
|
||||
// @004c02e4 (this->scoreType, message+0x20) and the dedicated
|
||||
// inflicted-damage handler at @004c0200.
|
||||
// @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.
|
||||
//
|
||||
enum ScoreType {
|
||||
DamageInflictedScore = 0, // to ScoreInflictedMessageHandler
|
||||
DamageInflictedScore = 0, // sent, but scores nothing (see above)
|
||||
DamageReceivedScore = 1, // I took damage
|
||||
KillScore = 2 // I destroyed / was destroyed
|
||||
};
|
||||
@@ -85,21 +102,19 @@ class DropZone__ReplyMessage;
|
||||
scoreType; // +0x20
|
||||
|
||||
Scalar
|
||||
damageAmount; // +0x24
|
||||
damageAmount; // +0x24 scoring basis (see layout table)
|
||||
|
||||
//
|
||||
// Point-source / auxiliary handles carried alongside the damage record
|
||||
// (read by ScoreMessageHandler when building the console feed message).
|
||||
//
|
||||
int
|
||||
pointSenderHi; // +0x28
|
||||
vitalHit; // +0x28 zone entered BurningState this message
|
||||
|
||||
int
|
||||
pointSenderLo; // +0x2c
|
||||
zoneIndex; // +0x2c struck damage zone
|
||||
|
||||
int
|
||||
auxID; // +0x30
|
||||
subsysID; // +0x30 inflicting weapon subsystem
|
||||
|
||||
EntityID
|
||||
senderMechID; // +0x34 inflicting mech (point sender)
|
||||
senderMechID; // +0x34 victim (types 0/2) / inflictor (type 1)
|
||||
|
||||
BTPlayer__ScoreMessage(
|
||||
Receiver::MessageID message_ID,
|
||||
@@ -107,15 +122,63 @@ class DropZone__ReplyMessage;
|
||||
int score_type,
|
||||
Scalar score_award,
|
||||
Scalar damage_amount,
|
||||
const EntityID &sender_mech_ID
|
||||
const EntityID &sender_mech_ID,
|
||||
int vital_hit = 0,
|
||||
int zone_index = 0,
|
||||
int subsys_ID = 0
|
||||
):
|
||||
Player::ScoreMessage(message_ID, length, score_award),
|
||||
scoreType(score_type),
|
||||
damageAmount(damage_amount),
|
||||
vitalHit(vital_hit),
|
||||
zoneIndex(zone_index),
|
||||
subsysID(subsys_ID),
|
||||
senderMechID(sender_mech_ID)
|
||||
{}
|
||||
};
|
||||
|
||||
//###########################################################################
|
||||
//################# BTPlayer::VehicleDeadMessage ######################
|
||||
//###########################################################################
|
||||
//
|
||||
// BT extension of the engine Player__VehicleDeadMessage (PLAYER.h:
|
||||
// deathCount @0x1c, dropZoneID @0x20). Decoded 2026-08-05 from the death
|
||||
// tail of Mech::TakeDamageMessageHandler (@0x4a07d4-0x4a0890, raw disasm):
|
||||
// the binary builds a 0x38-byte message {size 0x38, id 0x17 ==
|
||||
// Player::VehicleDeadMessageID, pri 1, deathCount -1 (the engine ctor
|
||||
// default), dropZoneID Null, +0x28 = 0, +0x2c = the KILLER PLAYER's own
|
||||
// EntityID (shooterPlayer+0x184), +0x34 = the killing zone (msg+0x24)} and
|
||||
// dispatches it to the dying mech's own player -- the respawn-cycle
|
||||
// trigger, now carrying the killed-by attribution. Whether the @004c05c4
|
||||
// handler consumes the two extension fields is still undecoded (the
|
||||
// function is not in the export); the sender carries them faithfully.
|
||||
//
|
||||
class BTPlayer__VehicleDeadMessage:
|
||||
public Player::VehicleDeadMessage
|
||||
{
|
||||
public:
|
||||
int
|
||||
reserved28; // +0x28 zeroed by the binary (@0x4a0856)
|
||||
|
||||
EntityID
|
||||
killedByPlayerID; // +0x2c the killer PLAYER's EntityID
|
||||
|
||||
int
|
||||
killZone; // +0x34 zone the killing damage struck
|
||||
|
||||
BTPlayer__VehicleDeadMessage(
|
||||
Receiver::MessageID message_ID,
|
||||
size_t length,
|
||||
const EntityID &killed_by_player_ID,
|
||||
int kill_zone
|
||||
):
|
||||
Player::VehicleDeadMessage(message_ID, length), // deathCount -1, dropZone Null
|
||||
reserved28(0),
|
||||
killedByPlayerID(killed_by_player_ID),
|
||||
killZone(kill_zone)
|
||||
{}
|
||||
};
|
||||
|
||||
//###########################################################################
|
||||
//##################### BTPlayer::MakeMessage #########################
|
||||
//###########################################################################
|
||||
@@ -202,8 +265,9 @@ class DropZone__ReplyMessage;
|
||||
// Player::ScoreMessage.)
|
||||
//
|
||||
public:
|
||||
typedef BTPlayer__ScoreMessage ScoreMessage;
|
||||
typedef BTPlayer__MakeMessage MakeMessage;
|
||||
typedef BTPlayer__ScoreMessage ScoreMessage;
|
||||
typedef BTPlayer__MakeMessage MakeMessage;
|
||||
typedef BTPlayer__VehicleDeadMessage VehicleDeadMessage; // BT 0x38 ext (killed-by)
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// Message Support
|
||||
@@ -437,6 +501,8 @@ class DropZone__ReplyMessage;
|
||||
friend int BTPlayerExperienceSimLive(void *); // +0x25c reader (issue #2): jams / lights / powersub short path
|
||||
friend int BTPlayerExperienceHeatModelOn(void *); // +0x260 reader (issue #2): FUN_004ad7d4 heat-model gate
|
||||
friend int BTPlayerAdvancedDamageOn(void *); // +0x268 reader (#83): FUN_0049ffcc collision-rattle gate
|
||||
friend Scalar BTPlayerEjectBookkeeping(void *); // +0x258 latch + role killBonus (Mech::EjectPilot @0049f854)
|
||||
friend int BTPlayerConsoleSuppressed(void *); // +0x258 reader (#89): death-blast eject gate @0x4a0ace
|
||||
// TARGET DESIGNATION (Gitea #48/#57): the mapper used to write the target
|
||||
// RAW at `pilot + 0x284` -- the BINARY's objectiveMech offset, which on our
|
||||
// compiled object is `deathPending` (the respawn latch). These bridges
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// body recovered from BTL4OPT.EXE (the binary oracle) / the RP analogue. //
|
||||
// //
|
||||
// == RUNTIME BRING-UP WORKLIST (replace these) == //
|
||||
// Mech::GetMissionReviewMode / IsAirborne / SetTargetRange / //
|
||||
// Mech::IsAirborne / SetTargetRange / //
|
||||
// SetMappingSubsystem / RaiseStatusAlarm //
|
||||
// Mech__DamageZone::LoadCriticalSubsystems //
|
||||
// MechSubsystem::TakeDamage / OnAlarmChanged //
|
||||
@@ -97,11 +97,11 @@ void Notify_Objective_Reached(int * /*objective_subsystem*/, Mech * /*mech*/)
|
||||
// Mech method stubs.
|
||||
//===========================================================================//
|
||||
|
||||
// TODO(bring-up): reads mech+0x414 (mission-review playback flag).
|
||||
int Mech::GetMissionReviewMode()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
// (GetMissionReviewMode stub RETIRED 2026-08-03: it was a mislabel of
|
||||
// mech+0x414 = ejectPermitted, now served by the inline GetEjectPermitted in
|
||||
// mech.hpp.) The REAL mission-review mode is this GLOBAL (DAT_004fd550);
|
||||
// the port never enters scrub/review, so it stays 0.
|
||||
int gMissionReviewMode = 0;
|
||||
|
||||
// TODO(bring-up): true while the mech is off the ground (jump-jet / fall state).
|
||||
int Mech::IsAirborne()
|
||||
|
||||
@@ -175,9 +175,14 @@ DamageZonePercentTable *
|
||||
{
|
||||
if (sliceCount <= 0) return 0;
|
||||
|
||||
Scalar thetaIn = theta;
|
||||
if (rotateWithTorso) // binary this[3] != 0
|
||||
{
|
||||
theta += owner->TorsoHeading(); // torso+0x1d8
|
||||
// #124 frame adapter, angular half: theta arrives in the 1995 frame
|
||||
// (ResolveHit's z-reflection), so the twist -- measured in OUR frame
|
||||
// -- enters with the OPPOSITE sign (a reflection reverses angular
|
||||
// direction). Binary form: theta += torso+0x1d8.
|
||||
theta -= owner->TorsoHeading(); // torso+0x1d8 (sign: see adapter)
|
||||
if (theta >= TwoPi) theta -= TwoPi;
|
||||
if (theta < 0.0f) theta += TwoPi;
|
||||
}
|
||||
@@ -185,6 +190,19 @@ DamageZonePercentTable *
|
||||
int index = (int)floorf(theta * (Scalar)sliceCount * (1.0f / TwoPi));
|
||||
if (index < 0) index = 0;
|
||||
if (index > sliceCount - 1) index = sliceCount - 1;
|
||||
|
||||
// #124 twist-sign bench: expose the twist term itself -- the leaf is a
|
||||
// weighted roll, so the SLICE choice (not the rolled zone) is the signal.
|
||||
if (getenv("BT_DMGTABLE_LOG"))
|
||||
{
|
||||
static int s_sl = 0;
|
||||
if (s_sl++ < 400)
|
||||
DEBUG_STREAM << "[slice] rot=" << (int)rotateWithTorso
|
||||
<< " twist=" << (float)owner->TorsoHeading()
|
||||
<< " thetaIn=" << (float)thetaIn
|
||||
<< " thetaAdj=" << (float)theta
|
||||
<< " -> slice " << index << "/" << sliceCount << std::endl;
|
||||
}
|
||||
return slices[index];
|
||||
}
|
||||
|
||||
@@ -277,6 +295,24 @@ int
|
||||
Scalar heightRef = owner->CylinderReferenceHeight(); // owner+0x2ec[+0xc]
|
||||
if (heightRef <= 0.0f) return -1;
|
||||
|
||||
// PORT FRAME ADAPTER (#124, corrected 2026-08-03 by the MUZZLE-ANCHOR
|
||||
// probe). The 1995 resolver frame: FORWARD = +Z (the authored ring puts
|
||||
// Front* cells in the +Z arc) and RIGHT = +X (W0/W7 = "Right*") [T1
|
||||
// slice-name data]. Our engine frame, measured with the mech's own
|
||||
// asymmetric geometry as the anchor (BT_ASPECT_TEST muzzle probes -- the
|
||||
// LEFT missile pod LRM15_1 sits at raw local x=-2.32, the RIGHT pod
|
||||
// LRM15_2 at x=+2.32): RIGHT = +X (SAME as 1995) but FORWARD = -Z
|
||||
// (drive-code convention, "forward = -Z at heading 0"). The frames
|
||||
// therefore differ by a Z-NEGATION ONLY -- a REFLECTION, not the pi
|
||||
// rotation first committed (that crossed the pods: left muzzle resolved
|
||||
// rtorso). y (the band axis) untouched.
|
||||
//
|
||||
// A reflection reverses the angular walk direction, so the OTHER angular
|
||||
// input -- the torso-twist term SelectSlice adds -- must flip sign with
|
||||
// it (applied there, same adapter). Twist was 0 in every probe; the
|
||||
// twisted-torso verify is the follow-up on #124.
|
||||
local.z = -local.z;
|
||||
|
||||
// --- height -> layer (penetration depth) ---
|
||||
Scalar depth = local.y; // binary local_2c
|
||||
int layerIndex = (int)floorf((Scalar)layerCount * (depth / heightRef));
|
||||
@@ -305,12 +341,22 @@ int
|
||||
{
|
||||
static int s_rh = 0;
|
||||
if (s_rh++ < 400)
|
||||
DEBUG_STREAM << "[dmgresolve] localY=" << local.y
|
||||
{
|
||||
extern int BTMechZoneSegAndName(void *mech_v, int zone_idx,
|
||||
int *seg_out, const char **name_out);
|
||||
int seg = -1; const char *zname = 0;
|
||||
if (resolved >= 0)
|
||||
BTMechZoneSegAndName((void *)owner, resolved, &seg, &zname);
|
||||
DEBUG_STREAM << "[dmgresolve] impact=(" << impact.x << ","
|
||||
<< impact.y << "," << impact.z << ")"
|
||||
<< " local=(" << local.x << "," << local.y << "," << local.z << ")"
|
||||
<< " heightRef=" << heightRef
|
||||
<< " frac=" << (depth / heightRef)
|
||||
<< " layer=" << layerIndex << "/" << layerCount
|
||||
<< " theta=" << theta
|
||||
<< " -> zone " << resolved << std::endl;
|
||||
<< " -> zone " << resolved
|
||||
<< " '" << (zname ? zname : "?") << "'" << std::endl;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
+87
-13
@@ -359,22 +359,40 @@ Condenser::~Condenser()
|
||||
#endif
|
||||
|
||||
//###########################################################################
|
||||
// ResetToInitialState -- Condenser (HEAT.TCP)
|
||||
// ResetToInitialState -- Condenser
|
||||
//
|
||||
// RE-TRANSCRIBED FROM THE BINARY (respawn-reset audit 2026-08-04; the function
|
||||
// is MISSING from the Ghidra export -- disassembled raw, tools/disas2.py
|
||||
// 0x4ae534 0x60). The old body chained HeatableSubsystem (temp+heatLoad only,
|
||||
// per the stale HEAT.TCP shard) -- the binary chains HEATSINK (call 0x4ad760),
|
||||
// so the coolant refill DOES run for a condenser; then, RESPAWN-side only:
|
||||
//
|
||||
// test esi, esi ; je ... -> if (powered) {
|
||||
// [this+0x1d0] = 1 -> valveState = 1 (default detent --
|
||||
// the pod RESETS every coolant valve)
|
||||
// eax = [this+0x1d8]; [0x160] = eax -> massScale = refrigerationFactor
|
||||
// (restore the refrigeration output) }
|
||||
//
|
||||
// Mech::Reset's tail then re-runs BTRecomputeCondenserValves (@0049f788), so
|
||||
// the flow fractions rebuild from the reset detents (mech4.cpp). This is the
|
||||
// operator-reported "coolant valves survive a respawn" gap [was T3, now T1].
|
||||
//
|
||||
void
|
||||
Condenser::ResetToInitialState(Logical /*powered*/)
|
||||
Condenser::ResetToInitialState(Logical powered)
|
||||
{
|
||||
HeatableSubsystem::ResetToInitialState(True);
|
||||
HeatSink::ResetToInitialState(powered); // call 0x4ad760
|
||||
if (powered)
|
||||
{
|
||||
if (getenv("BT_DEATH_LOG") && valveState != 1)
|
||||
DEBUG_STREAM << "[respawn] " << GetName() << " valve detent "
|
||||
<< valveState << " -> 1 (authentic @004ae534 restore)\n" << std::flush;
|
||||
valveState = 1; // @0x1D0 = 1
|
||||
massScale = refrigerationFactor; // @0x160 = @0x1D8
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// DeathReset (Gitea #55): the respawn sweep's entry for a Condenser -- a coolant
|
||||
// loop's valve. NOTE the body above chains HeatableSubsystem's (temperature +
|
||||
// heatLoad) and so does NOT run HeatSink's coolant refill, even though Condenser
|
||||
// derives from HeatSink. That mirrors the existing reconstruction; whether the
|
||||
// binary's Condenser reset also restores the VALVE SETTING is not established from
|
||||
// the decomp yet, so valve detents may still persist across a respawn.
|
||||
// [T3 -- do NOT claim valves are fixed; tracked on the issue.]
|
||||
// DeathReset (Gitea #55): the respawn sweep's entry for a Condenser.
|
||||
//
|
||||
void
|
||||
Condenser::DeathReset(int reset_command)
|
||||
@@ -777,7 +795,7 @@ void
|
||||
{
|
||||
heatEnergy += pendingHeat;
|
||||
currentTemperature = heatEnergy / thermalMass;
|
||||
UpdateHeatLoad();
|
||||
UpdateHeatLoad(time_slice); // 28 Hz filter cadence (#119)
|
||||
|
||||
// DIAG census (BT_HEAT_LOG, viewpoint mech): PER-INSTANCE 5-s timers --
|
||||
// the old shared static timer aliased to whichever instance crossed the
|
||||
@@ -850,8 +868,22 @@ void
|
||||
// @004ad7f0 -- recompute the radiated/instantaneous heat and feed it through
|
||||
// the running-average filter to produce the smoothed heatLoad reading.
|
||||
//
|
||||
// POD-CADENCE SAMPLING (#119, 2026-08-02). The binary adds ONE sample per
|
||||
// Perform -- a dt-less per-frame term (the myomer-kinetic class, FUN_0041c018
|
||||
// proves Performs ride the frame rate). 15 samples at the pod's 28 Hz = a
|
||||
// 0.536 s smoothing window; sampled at the port's ~59 fps the window halves
|
||||
// and heatLoad turns 2x as twitchy -- which doubles the cadence of the
|
||||
// ReportLeak relaxation oscillation at a drained tank (draw = damage x
|
||||
// heatLoad hunting across the authored 0.0025/0.003 hysteresis band), and
|
||||
// THAT is the rapidly-clipping "warning coo-- war--" leak voice Oracle
|
||||
// reproduced twice in 716. Sim calls pass their slice and samples accrue at
|
||||
// 28 Hz on any machine; ctor/reset prime calls (negative dt) sample
|
||||
// unconditionally. Per-instance clock lives in a static map (the census
|
||||
// precedent above) because the heat-family layouts are factory-size-locked
|
||||
// (sizeof(Myomers) == 0x358 exact) -- no new members.
|
||||
//
|
||||
void
|
||||
HeatSink::UpdateHeatLoad()
|
||||
HeatSink::UpdateHeatLoad(Scalar time_slice)
|
||||
{
|
||||
radiatedHeat = currentTemperature * coolantLevel;
|
||||
|
||||
@@ -865,7 +897,31 @@ void
|
||||
sample = HeatLoadMaximum;
|
||||
}
|
||||
|
||||
heatFilter.AddSample(sample); // FUN_0043ade4
|
||||
if (time_slice >= 0.0f)
|
||||
{
|
||||
static std::map<const void *, Scalar> s_filterClock;
|
||||
Scalar &clock = s_filterClock[this];
|
||||
clock += time_slice;
|
||||
const Scalar kPodTick = 1.0f / 28.0f; // [T1] pod Perform cadence
|
||||
if (clock < kPodTick)
|
||||
{
|
||||
return; // heatLoad holds the last average
|
||||
}
|
||||
int catchUp = 0;
|
||||
while (clock >= kPodTick && ++catchUp <= 15) // >15 samples saturate the window
|
||||
{
|
||||
clock -= kPodTick;
|
||||
heatFilter.AddSample(sample); // FUN_0043ade4
|
||||
}
|
||||
if (catchUp > 15)
|
||||
{
|
||||
clock = 0.0f; // hitch: window already saturated
|
||||
}
|
||||
heatLoad = heatFilter.Average(); // FUN_0043ae0b
|
||||
return;
|
||||
}
|
||||
|
||||
heatFilter.AddSample(sample); // FUN_0043ade4 (prime call)
|
||||
heatLoad = heatFilter.Average(); // FUN_0043ae0b
|
||||
}
|
||||
|
||||
@@ -1372,3 +1428,21 @@ Derivation *
|
||||
{
|
||||
return HeatSink::GetClassDerivations();
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// BTHeatSinkBankCoolantFraction -- complete-type bridge for the EJECT-
|
||||
// permission evaluator (Mech::EvaluateEjectPermission @0049fa1c). The binary
|
||||
// clause: classID 0xBBE (the AGGREGATE heat-sink bank -- the factory case
|
||||
// carries the known "Sensor" mislabel; ctor-address truth per CLASSMAP rule),
|
||||
// fraction = coolantLevel(@0x12C) / thermalCapacity(@0x128). Eject unlocks
|
||||
// below 0.05 -- i.e. the mech has LEAKED nearly dry. Returns 0 = not the
|
||||
// bank, else 1 with *out filled.
|
||||
//===========================================================================//
|
||||
int BTHeatSinkBankCoolantFraction(Subsystem *sub, Scalar *out)
|
||||
{
|
||||
if (sub == 0 || (int)sub->GetClassID() != 0xBBE)
|
||||
return 0;
|
||||
HeatSink *bank = (HeatSink *)sub;
|
||||
*out = bank->CoolantFractionOf();
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -512,8 +512,11 @@ inline int
|
||||
// Internal model helpers
|
||||
//
|
||||
public:
|
||||
// time_slice >= 0: sample the filter at the POD's 28 Hz cadence (#119
|
||||
// -- the per-Perform filter is frame-rate-dependent, the myomer-kinetic
|
||||
// class); negative (the ctor/reset prime calls) samples unconditionally.
|
||||
void
|
||||
UpdateHeatLoad(); // @004ad7f0
|
||||
UpdateHeatLoad(Scalar time_slice = -1.0f); // @004ad7f0
|
||||
void
|
||||
ClearHeatFilter(); // @004ad884
|
||||
void
|
||||
@@ -567,6 +570,11 @@ inline int
|
||||
Scalar filterDecay; // @0x150 init 0.15f (@004b8fec) -- ALSO the
|
||||
// leak gauge's full-scale divisor (#97)
|
||||
Scalar LeakGaugeFullScale() const { return filterDecay; }
|
||||
// Eject evaluator (@0049fa1c): the bank's remaining-coolant fraction
|
||||
// (@0x12C / @0x128). Capacity is authored nonzero (ctor init 1.0);
|
||||
// the guard only shields a malformed stream from a NaN.
|
||||
Scalar CoolantFractionOf() const
|
||||
{ return (thermalCapacity > 0.0f) ? (coolantLevel / thermalCapacity) : 0.0f; }
|
||||
Scalar thermalMass; // @0x154 resource +0xF4
|
||||
Scalar heatEnergy; // @0x158 init = thermalMass * startingTemperature
|
||||
Scalar coolantFlowScale; // @0x15C init 1.0f (== "word57")
|
||||
|
||||
@@ -1233,7 +1233,7 @@ void
|
||||
}
|
||||
heatEnergy += pendingHeat; // [0x56] += [0x72]
|
||||
currentTemperature = heatEnergy / thermalMass; // [0x45] = [0x56]/[0x55]
|
||||
UpdateHeatLoad(); // FUN_004ad7f0
|
||||
UpdateHeatLoad(time_slice); // FUN_004ad7f0 (28 Hz cadence, #119)
|
||||
pendingHeat = 0.0f;
|
||||
|
||||
Scalar target = 300.0f
|
||||
|
||||
+228
-19
@@ -466,6 +466,7 @@ const Receiver::HandlerEntry
|
||||
MESSAGE_ENTRY(Mech, PlayerLink),
|
||||
MESSAGE_ENTRY(Mech, BalanceCoolant), // id 0x16 @0049f728 (issue #20)
|
||||
MESSAGE_ENTRY(Mech, DuckRequest), // id 0x1a @0049fa00 (CROUCH, 2026-07-26)
|
||||
MESSAGE_ENTRY(Mech, EjectPilot), // id 0x19 @0049f854 (PANIC/EJECT, 2026-08-02)
|
||||
};
|
||||
|
||||
//
|
||||
@@ -513,6 +514,137 @@ void
|
||||
DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1" << std::endl << std::flush;
|
||||
}
|
||||
|
||||
//
|
||||
// @0049fa1c -- the EJECT-PERMISSION evaluator (writes ejectPermitted @0x414).
|
||||
// Raw decomp, transcribed clause for clause: walk the roster from index 2 --
|
||||
// classID 0xBBE (HeatSinkBank): coolant fraction = coolantLevel/thermalCapacity
|
||||
// classID 0xBC1 (Generator): count live (not destroyed && state != 4)
|
||||
// MechWeapon-derived: count live (not destroyed; ammo-fed weapons
|
||||
// additionally need weaponAlarm != 7 = NoAmmo)
|
||||
// then permitted = weapons < ejectMinWeapons || generators == 0
|
||||
// || coolant < 0.05 (@0049fb50)
|
||||
// || (MovementMode 3/4 [leg-gimped] && player simLive == 0).
|
||||
// The binary refreshes this per frame from an UNEXPORTED caller; the port
|
||||
// evaluates on demand (the handler + any future panic-lamp consumer).
|
||||
//
|
||||
int
|
||||
Mech::EvaluateEjectPermission()
|
||||
{
|
||||
extern int BTWeaponCountsForEject(Subsystem *sub); // projweap.cpp (-1 = not a weapon)
|
||||
extern int BTGeneratorCountsForEject(Subsystem *sub); // powersub.cpp (-1 = not a generator)
|
||||
extern int BTHeatSinkBankCoolantFraction(Subsystem *sub, Scalar *out); // heat.cpp
|
||||
|
||||
int liveWeapons = 0;
|
||||
int liveGenerators = 0;
|
||||
Scalar coolantFrac = 0.0f; // local_10 (0 when no bank streams)
|
||||
|
||||
for (int i = 2; i < subsystemCount; ++i) // binary: from index 2
|
||||
{
|
||||
Subsystem *s = (Subsystem *)subsystemArray[i];
|
||||
if (s == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Scalar frac;
|
||||
if (BTHeatSinkBankCoolantFraction(s, &frac))
|
||||
{
|
||||
coolantFrac = frac;
|
||||
}
|
||||
else
|
||||
{
|
||||
int g = BTGeneratorCountsForEject(s);
|
||||
if (g >= 0)
|
||||
{
|
||||
liveGenerators += g;
|
||||
}
|
||||
else
|
||||
{
|
||||
int w = BTWeaponCountsForEject(s);
|
||||
if (w >= 0)
|
||||
{
|
||||
liveWeapons += w;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int gimped = (MovementMode() == 3 || MovementMode() == 4); // mech+0x40 - 3U < 2
|
||||
extern int BTPlayerExperienceSimLive(void *owner_mech); // btplayer.cpp (+0x25c;
|
||||
int noviceSim = (BTPlayerExperienceSimLive(this) == 0); // NULL-player reads LIVE)
|
||||
|
||||
// (A short-lived coolant-clause HYSTERESIS was tried and REMOVED
|
||||
// 2026-08-03, same night, for strict fidelity: the binary's clause is
|
||||
// this plain `< 0.05` compare, evaluated per frame by the master
|
||||
// performance (FUN_004a9b5c+0x10) -- the ARCADE ran exactly this, so a
|
||||
// bank fraction hovering at the threshold flapped the panic-arm mode at
|
||||
// frame rate there too. The flap is cosmetic churn (mode mask + lamp;
|
||||
// hundreds of transitions ran crash-free on the bench) and was NOT the
|
||||
// operator's audio tick -- that was the autofire scalpel spamming the
|
||||
// per-pull jam click, no game bug. Keep the authentic compare.)
|
||||
ejectPermitted =
|
||||
(liveWeapons < ejectMinWeapons) // @0x448 floor (inert at 0)
|
||||
|| (liveGenerators == 0)
|
||||
|| (coolantFrac < 0.05f) // _DAT_0049fb50
|
||||
|| (gimped && noviceSim);
|
||||
return ejectPermitted;
|
||||
}
|
||||
|
||||
//
|
||||
// @0049f854 -- EjectPilot (id 0x19): the cockpit PANIC/EJECT punch-out.
|
||||
// See the header comment (mech.hpp) for the byte-level story.
|
||||
//
|
||||
void
|
||||
Mech::EjectPilotMessageHandler(ReceiverDataMessageOf<int> *message)
|
||||
{
|
||||
if (message->dataContents <= 0) // press only (msg+0xc)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (EvaluateEjectPermission() == 0) // @0x414 gate: healthy mechs refuse
|
||||
{
|
||||
DEBUG_STREAM << "[eject] " << GetEntityID()
|
||||
<< " REFUSED (mech not crippled enough)" << std::endl << std::flush;
|
||||
return;
|
||||
}
|
||||
if (IsDisabled()) // @0049fb54 -- no ejecting from a wreck
|
||||
{
|
||||
DEBUG_STREAM << "[eject] " << GetEntityID()
|
||||
<< " REFUSED (disabled: movementMode "
|
||||
<< MovementMode() << ")" << std::endl << std::flush;
|
||||
return;
|
||||
}
|
||||
|
||||
// Binary @0049f88e-0x49f8cf: build the console eject notice (FUN_004c198c
|
||||
// from ownerID @0x18c) and send it to the console host. CORE: forensic
|
||||
// log; the relay wire is the tracked tail. The suppressConsole latch set
|
||||
// below is the authentic other half (the following DEATH must not also
|
||||
// notify the console).
|
||||
extern Scalar BTPlayerEjectBookkeeping(void *player); // btplayer.cpp:
|
||||
void *player = GetPlayerLink(); // suppressConsole=1,
|
||||
Scalar charge = (player != 0) // returns role killBonus
|
||||
? BTPlayerEjectBookkeeping(player) : 0.0f;
|
||||
|
||||
DEBUG_STREAM << "[eject] " << GetEntityID()
|
||||
<< " PUNCH-OUT: charge=" << charge
|
||||
<< " (role killBonus)" << std::endl << std::flush;
|
||||
|
||||
graphicAlarm.SetLevel(10); // FUN_0041bbd8(this+0x2C, 0xA) -- EJECT state
|
||||
|
||||
Damage dmg; // FUN_0041db7c (Damage::Damage)
|
||||
dmg.damageType = (Enumeration)2; // Explosive
|
||||
dmg.damageAmount = charge; // role+0x1c (killBonus)
|
||||
dmg.impactPoint = localOrigin.linearPosition; // binary copies this+0x100
|
||||
dmg.burstCount = 1; // binary writes 0; our victim-side
|
||||
// guard clamps 0->1 -- same outcome
|
||||
Entity::TakeDamageMessage take_damage(
|
||||
Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage),
|
||||
GetEntityID(), // inflicting = SELF (this+0x184)
|
||||
-1, // unaimed -> cylinder resolves
|
||||
dmg,
|
||||
-1); // inflictingSubsystemID = -1
|
||||
Dispatch(&take_damage); // vtbl+0xC self-dispatch
|
||||
}
|
||||
|
||||
Receiver::MessageHandlerSet
|
||||
Mech::MessageHandlers(
|
||||
ELEMENTS(Mech::MessageHandlerEntries),
|
||||
@@ -931,24 +1063,40 @@ void
|
||||
(float)(message->damageData.impactPoint.z - localOrigin.linearPosition.z));
|
||||
}
|
||||
|
||||
//
|
||||
// Death-edge latch, MOVED 2026-08-05 to the authentic position (binary
|
||||
// @0x4a0303: captured at handler ENTRY, before the collision divert). It
|
||||
// arms the whole death tail -- kill report, VehicleDead, death blast.
|
||||
// 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
|
||||
//
|
||||
// The zone the reports + VehicleDead carry: msg+0x24 as of loop entry.
|
||||
// The binary never rewrites msg+0x24 after the initial cylinder resolve;
|
||||
// our loop-exit matchlog write (damageZone = LAST burst's zone) must not
|
||||
// leak into them.
|
||||
//
|
||||
int reportZone = message->damageZone;
|
||||
DamageLookupTable *table = (DamageLookupTable *)damageLookupTable; // named member (Wword absorbs!)
|
||||
|
||||
//
|
||||
// @0x4a0368 -- the COLLISION DIVERT (#83, closing the #82 chain). Damage
|
||||
// type 0 == the mover's collision damage (what the crash response policy
|
||||
// forwards). It NEVER reaches the zone/armor loop below: it is priced +
|
||||
// distributed as INTERNAL RATTLE by DistributeCollisionDamage (@0049ffcc)
|
||||
// and the handler is done. This was the missing half of the crash
|
||||
// self-damage reconstruction -- without the divert, the raw kinetic-energy
|
||||
// amounts (a 60-ton mech prices ~1000+ per wall tap) fell through into the
|
||||
// WEAPON loop and a single bump killed the mech (run-14 field report).
|
||||
// distributed as INTERNAL RATTLE by DistributeCollisionDamage (@0049ffcc).
|
||||
// CORRECTED 2026-08-05: the binary does NOT return here -- it JUMPS TO THE
|
||||
// DEATH TAIL (raw disasm @0x4a0375: jmp 0x4a07b5), so a mech that dies of
|
||||
// the rattle still posts VehicleDead (the respawn trigger) and still
|
||||
// blasts. The early `return` this block shipped with was a latent
|
||||
// "wall-death strands the pilot" hazard. No score reports on this path --
|
||||
// the jump bypasses them; a collision death credits no one. Authentic.
|
||||
//
|
||||
if (message->damageData.damageType == 0)
|
||||
{
|
||||
DistributeCollisionDamage(&message->damageData);
|
||||
Check_Fpu();
|
||||
return;
|
||||
goto death_tail;
|
||||
}
|
||||
|
||||
DamageLookupTable *table = (DamageLookupTable *)damageLookupTable; // named member (Wword absorbs!)
|
||||
// #92: when this gate fails the zone keeps whatever the message carried --
|
||||
// which for an unaimed weapon hit is 0, i.e. EVERY shot lands on zone 0.
|
||||
if (getenv("BT_DMGTABLE_LOG"))
|
||||
@@ -973,6 +1121,7 @@ void
|
||||
<< message->damageData.impactPoint.z << ")\n" << std::flush;
|
||||
}
|
||||
}
|
||||
reportZone = message->damageZone; // post-resolve (@0x4a0396 write)
|
||||
//
|
||||
// #80 -- the faithful application loop (binary @0x4a0423-0x4a04d8), which
|
||||
// REPLACES the engine-base single application. Three things the base
|
||||
@@ -1044,14 +1193,20 @@ void
|
||||
}
|
||||
message->damageZone = zoneIndex; // matchlog sees the LAST zone
|
||||
|
||||
// Binary tail (@0x4a04da-0x4a07b2, decoded + deferred): builds id-0x16
|
||||
// damage/kill REPORT messages -- {tally, zone, zoneDestroyed flag,
|
||||
// inflicting subsystem, victim name} -- to the shooter's player (with a
|
||||
// kill-flagged variant when this damage NEWLY disabled the mech) and to
|
||||
// the victim's player. That is the authentic stats plumbing (#45); the
|
||||
// port's matchlog + BTPostDamageScore cover the bookkeeping today [T3].
|
||||
(void)zoneDestroyed;
|
||||
(void)damageTally;
|
||||
//
|
||||
// THE REPORT TAIL (@0x4a04da-0x4a07b2), reconstructed 2026-08-05 [T1]:
|
||||
// the kill / inflicted / received score reports to the shooter's and
|
||||
// victim's players. Sender lives in btplayer.cpp (complete-BTPlayer
|
||||
// TU, per the databinding rule); it receives the APPLIED tally, the
|
||||
// vital-wreck flag, and the loop-ENTRY zone. This retires
|
||||
// BTPostDamageScore/BTPostKillScore -- see the btplayer.cpp tombstone.
|
||||
//
|
||||
{
|
||||
extern void BTMechPostCombatReports(void *, void *, float, int, int, int);
|
||||
BTMechPostCombatReports((void *)this, (void *)message,
|
||||
(float)damageTally, zoneDestroyed, reportZone,
|
||||
deathBlastArmed && IsMechDestroyed());
|
||||
}
|
||||
}
|
||||
|
||||
// MP MATCH FORENSICS (matchlog.hpp): the victim-side authoritative damage
|
||||
@@ -1080,6 +1235,45 @@ void
|
||||
(float)message->damageData.impactPoint.y,
|
||||
(float)message->damageData.impactPoint.z);
|
||||
}
|
||||
|
||||
// THE DEATH TAIL (@0x4a07b5-0x4a0bda, raw disasm -- the un-exported tail
|
||||
// of THIS handler). Gate [T1]: mech was ALIVE at entry ([ebp-0x10]) and
|
||||
// is dead(9)/eject(10) now. Sequence:
|
||||
// 1. Player::VehicleDeadMessage (id 0x17; the BT 0x38-byte extension
|
||||
// carrying killed-by player + kill zone) -> the mech's OWN player:
|
||||
// the respawn-cycle trigger. CORRECTED 2026-08-05: the #89 banner
|
||||
// previously read this build as "SetBurningState (mech id 0x17)" --
|
||||
// it is a PLAYER-table id: the binary's BTPlayer handler table (file
|
||||
// 0x112dxx) binds 0x17 -> @0x4c05c4 VehicleDeadMessageHandler; the
|
||||
// mech-table 0x17 (@0x49f674) is unrelated. Dispatched from HERE --
|
||||
// this is the decoded sender the mech4 transition site's
|
||||
// [T3 sender-undecoded] flag was waiting for.
|
||||
// 2. the death Explosion (id 3 MakeMessage, 0x5C bytes, model 0x31 at
|
||||
// the mech origin, @0x4a08bd) -- the port fires the authored per-mech
|
||||
// death list from the death transition instead (#42 history): same
|
||||
// once-per-death edge, not double-spawned.
|
||||
// 3. SplashDamage (#89), gated advancedDamageOn (+0x264) AND NOT
|
||||
// suppressConsole (+0x258 -- ejected pilots' mechs never blast):
|
||||
// Damage{type=2 Explosive, amount=deathSplashDamage@0x520,
|
||||
// impact=mech origin,
|
||||
// burstCount=round(0.001 * moverMass@0x20c * 15.0)}
|
||||
// radius=deathSplashRadius@0x524, excluded=the dying mech;
|
||||
// falloff bursts/dist^1.25 per victim inside the shared core.
|
||||
// 4. ForceUpdate(1).
|
||||
// The gate predicate here is IsMechDestroyed() (graphicAlarm >= 9, the
|
||||
// structural flag @0x49fb54) -- the binary tests movementMode 9|10, but
|
||||
// the death transition sets mode 9 synchronously with the structural flag
|
||||
// on every path through here, so the edges coincide; this exact predicate
|
||||
// is the one the #89 blast benches verified both ways.
|
||||
death_tail:
|
||||
if (deathBlastArmed && IsMechDestroyed())
|
||||
{
|
||||
extern void BTMechPostVehicleDead(void *, void *, int);
|
||||
BTMechPostVehicleDead((void *)this, (void *)message, reportZone);
|
||||
|
||||
extern void BTApplyDeathSplash(void *mech_v);
|
||||
BTApplyDeathSplash((void *)this);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
@@ -1249,6 +1443,8 @@ Mech::Mech(
|
||||
mechNameFilter.Initialize(); // FUN_00435a7c(this+0xdb)
|
||||
masterAlarm = AlarmIndicator(0x21); // FUN_0041b9ec(this+0xe7,0x21)
|
||||
rearFiring = 0; // (task #68) ORed from the weapons below
|
||||
ejectPermitted = 0; // @0x414 (refreshed by EvaluateEjectPermission)
|
||||
ejectMinWeapons = 0; // @0x448 (no exported writer -- zero, clause inert [T3])
|
||||
// (F7 correction) the binary's 0x400 = FLT_MAX init is DistanceToMissile's
|
||||
// "no missile" far default (attr id 56), NOT a maxSpeed -- the old member
|
||||
// is retired; distanceToMissile (init below) owns the slot's meaning.
|
||||
@@ -1764,8 +1960,11 @@ Mech::Mech(
|
||||
|
||||
Wword(0x68) = model->cameraOffset; // FUN_00408440(this+0x1a0, rec+0xA8)
|
||||
Wword(0x130) = model->deathEffectResourceID; // rec+0x74 -> mech+0x4c0
|
||||
Wword(0x148) = model->deathSplashDamage; // rec+0x78 -> mech+0x520
|
||||
Wword(0x149) = model->deathSplashRadius; // rec+0x7C -> mech+0x524
|
||||
deathSplashDamage = model->deathSplashDamage; // rec+0x78 -> mech+0x520 (#89:
|
||||
deathSplashRadius = model->deathSplashRadius; // rec+0x7C -> mech+0x524 named
|
||||
// members -- the Wword bank is
|
||||
// GLOBAL and clobbered per-mech
|
||||
// values chassis-to-chassis)
|
||||
Wword(0x195) = Wword(0x196) = 0;
|
||||
Wword(0xfd) = 0;
|
||||
masterAlarm.SetLevel(0); // FUN_0041bbd8(this+0xe7,0)
|
||||
@@ -1919,6 +2118,9 @@ Mech::Mech(
|
||||
ramLastVictim = 0; // ram contact-edge state
|
||||
ramContactLinger = 0.0f;
|
||||
lastInflictingDamage = 0.0f; // task #60: killing-blow magnitude (set on hit)
|
||||
mapPosture = 0; // binary @0x3f8 (the ctor zero-block @0x4b3d18 zeroes the band)
|
||||
myomerEffectiveness = 1.0f; // binary @0x79c: 0-init + per-frame MAX; the port holds the
|
||||
// healthy 1.0 until the Myomers Performance feeder lands [T3]
|
||||
if (GroundReal() && GetCollisionVolumeCount() > 0
|
||||
&& collisionTemplate != 0 && collisionVolume != 0)
|
||||
{
|
||||
@@ -2290,6 +2492,13 @@ Logical
|
||||
void
|
||||
Mech::ReadUpdateRecord(Simulation::UpdateRecord *message)
|
||||
{
|
||||
// #108 ghost forensics: stamp the staleness detector on every applied
|
||||
// record (replicants only -- the detector lives on the death handler).
|
||||
if (GetInstance() == ReplicantInstance)
|
||||
{
|
||||
extern void BTMechNoteUpdateApplied(void *mech_v);
|
||||
BTMechNoteUpdateApplied((void *)this);
|
||||
}
|
||||
// RECORD-CADENCE probe (BT_RXJIT): quantify how EVENLY records arrive (wall-clock
|
||||
// ms between arrivals) -- the jitter that makes the peer's corrections random.
|
||||
if (getenv("BT_RXJIT") && GetInstance() == ReplicantInstance)
|
||||
|
||||
@@ -357,7 +357,11 @@ struct ShotDescriptor
|
||||
enum ResetMode { MissionReviewReset = 0 }; // reset-mode selector (Reset arg, value 0)
|
||||
|
||||
void SetMappingSubsystem(Subsystem *mapper); // btl4app.cpp:567
|
||||
Logical GetMissionReviewMode(); // reads this+0x414 (btl4mppr.cpp:366)
|
||||
// ejectPermitted (@0x414) accessor -- the panic-arm watcher's read
|
||||
// (btl4mppr InterpretControls @004d196c). [The old GetMissionReviewMode
|
||||
// here was a mislabel of the same cell; the real review mode is the
|
||||
// GLOBAL DAT_004fd550 (btl4pb.cpp). Corrected 2026-08-03.]
|
||||
int GetEjectPermitted() const { return ejectPermitted; }
|
||||
void SetTargetRange(Scalar range); // writes this+0x404 (btl4mppr.cpp:407)
|
||||
void Reset(const Origin &origin, int mode); // btl4pb.cpp:555 (FUN_0049fb74)
|
||||
|
||||
@@ -650,6 +654,12 @@ public:
|
||||
// binary part_012.c:9938-9940 + 9974-9975). By-name access only; declared
|
||||
// after the layout-locked fields so nothing shifts.
|
||||
Scalar standingTemplateMaxY; // binary @0x518 collisionTemplate->maxY at ctor
|
||||
// #89 DEATH BLAST (raw disasm @0x4a07b8-0x4a0bda): the authored death-
|
||||
// explosion pair, model "gamedata" keys -> ctor. PROMOTED from the Wword
|
||||
// scratch bank (the bank is one GLOBAL array -- per-mech authored values
|
||||
// were silently clobbered to the last-loaded chassis).
|
||||
Scalar deathSplashDamage; // binary @0x520 <- model rec+0x78 "DeathSplashDamage"
|
||||
Scalar deathSplashRadius; // binary @0x524 <- model rec+0x7C "DeathSplashRadius"
|
||||
Scalar duckedTemplateMaxY; // binary @0x51c 0.6 x standing (duck preset)
|
||||
Scalar templateBottomLift; // binary @0x4b8 0.05 x (volume maxX-minX)
|
||||
|
||||
@@ -739,6 +749,15 @@ protected:
|
||||
// `stateFlags`): OR of every mounted weapon's rearFiring flag -- "this
|
||||
// mech carries a rear arsenal". The look-back view arms those weapons.
|
||||
int rearFiring; // @0x410 this[0x104]
|
||||
int ejectPermitted; // @0x414 EvaluateEjectPermission (@0049fa1c) result:
|
||||
// 1 = crippled enough that the PANIC/EJECT
|
||||
// punch-out is allowed (binary refreshes per
|
||||
// frame from an unexported caller; the port
|
||||
// evaluates on demand in the 0x19 handler)
|
||||
int ejectMinWeapons; // @0x448 live-weapon floor for the evaluator's first
|
||||
// clause. NO writer in the exported decomp --
|
||||
// zero-filled in the binary, so the clause is
|
||||
// inert there too [T3]; init 0 to match.
|
||||
// (task #68) the authored look-view angles (model record +0x50..0x5c,
|
||||
// ctor converts degrees->radians; binary @0x564..0x570 -- previously
|
||||
// parked in the Wword scratch bank). Consumed by the look-state
|
||||
@@ -1013,6 +1032,17 @@ protected:
|
||||
Scalar gimpStrideLength; // @0x350
|
||||
AlarmIndicator legStateAlarm; // @0x39c
|
||||
int legAnimationState; // @0x3b0
|
||||
int mapPosture; // binary @0x3f8 -- the map-legend/duck posture arbiter
|
||||
// (0 none / 1 may-duck / 2 holding-squat), computed per
|
||||
// master frame (FUN_004a9b5c @0x4a9f61-0x4aa007; the
|
||||
// CROUCH reconstruction, 2026-08-05)
|
||||
Scalar myomerEffectiveness; // binary @0x79c -- the master-perf myomer chain MAX
|
||||
// (speedEffect@0x31C over the myomer subsystems),
|
||||
// published LIVE each mapper update by mechmppr.cpp's
|
||||
// drive-scale block (the 2026-07-31 seek-audit recon of
|
||||
// the same @0x4a9cf2-0x4a9da4 bytes). Consumed by the
|
||||
// crouch posture gate (mech4). Ctor 1.0 = pre-first-
|
||||
// update default only.
|
||||
Scalar gimpSpeedMax; // @0x52c
|
||||
Scalar standSpeed; // @0x530
|
||||
Scalar walkStrideLength; // @0x534
|
||||
@@ -1106,6 +1136,9 @@ protected:
|
||||
// from its exported consumers + the RP VTV::DeathShutdown analog) ---
|
||||
Logical IsMechDestroyed(); // damage-side death flag: graphicAlarm level >= 9
|
||||
void UpdateDeathState(Scalar dt); // death freeze + subsystem DeathShutdown + wreck smoke
|
||||
// --- #108 ghost-forensics accessors (PORT; the fields are protected) ---
|
||||
void *DeathHandlerPtr() { return (void *)deathHandler; }
|
||||
int WreckBuried() const { return collisionVolumeCount == 0; }
|
||||
|
||||
// --- clip loaders (mech3) -------------------------------------------
|
||||
ResourceDescription::ResourceID *
|
||||
@@ -1237,6 +1270,29 @@ protected:
|
||||
enum { DuckRequestMessageID = 0x1a };
|
||||
void DuckRequestMessageHandler(ReceiverDataMessageOf<int> *message);
|
||||
|
||||
// @0049f854 -- "EjectPilot" (Mech table id 0x19): the cockpit PANIC/
|
||||
// EJECT punch-out (raw disasm 2026-08-02 -- the handler sat in the
|
||||
// 0x49f854-0x49fa00 export gap). Press-only (msg+0xc > 0), gated on
|
||||
// ejectPermitted (@0x414) and !IsDisabled(). Body: notify the operator
|
||||
// console of the eject (FUN_004c198c ctor -> network sender; port core
|
||||
// LOGS it, the relay wire is the tail) and latch the player's
|
||||
// suppressConsole (+0x258) so the death that follows doesn't double-
|
||||
// notify; graphicAlarm -> 10 (the EJECT state, one past death's 9);
|
||||
// then SELF-dispatch a TakeDamageMessage {inflicting = SELF, zone -1,
|
||||
// Explosive, amount = ScenarioRole::killBonus (role+0x1c -- field map
|
||||
// byte-verified via the role reader @00429bec dest offsets + the ctor
|
||||
// record copy), burst 0 (binary; our victim guard clamps to 1 --
|
||||
// identical outcome: the charge is lethal)}. The punch-out IS a
|
||||
// death: wreck, respawn cycle and replication all ride the normal
|
||||
// damage chain.
|
||||
enum { EjectPilotMessageID = 0x19 };
|
||||
void EjectPilotMessageHandler(ReceiverDataMessageOf<int> *message);
|
||||
// @0049fa1c -- the eject-permission evaluator (ejectPermitted @0x414):
|
||||
// roster walk -- live weapons < ejectMinWeapons(@0x448) | zero live
|
||||
// generators | HeatSinkBank coolant fraction < 0.05 (@0049fb50) |
|
||||
// (leg-gimped state 3/4 AND novice, player simLive == 0).
|
||||
int EvaluateEjectPermission();
|
||||
|
||||
// --- damage-routing support (mechdmg / mech4) -----------------------
|
||||
// Typed access to the inherited Entity::damageZones[] (engine stores DamageZone*;
|
||||
// our entries are Mech__DamageZone, populated by the Mech ctor). Defined in
|
||||
|
||||
@@ -220,6 +220,16 @@ enum MechAnimationState
|
||||
void
|
||||
Mech::SetLegAnimation(int state)
|
||||
{
|
||||
// DIAG (BT_DUCK_LOG): every leg re-arm with the caller's return address --
|
||||
// the crouch-clobber hunt (who re-arms after the squat parks?).
|
||||
if (getenv("BT_DUCK_LOG"))
|
||||
{
|
||||
char dbuf[96];
|
||||
sprintf(dbuf, "[duck] SetLegAnimation(%d) ra=btl4+0x%lx", state,
|
||||
(unsigned long)_ReturnAddress()
|
||||
- (unsigned long)GetModuleHandleA(0));
|
||||
DEBUG_STREAM << dbuf << std::endl << std::flush;
|
||||
}
|
||||
legAnimation.SelectSequence( // FUN_004277a8(this+0x65c, ...)
|
||||
animationClips[state], // *(this+0x5cc + state*4)
|
||||
// The real leg finished-callback PTR_LAB_0050d6f0 == FUN_004a6928
|
||||
@@ -520,6 +530,8 @@ Scalar
|
||||
return 0.0f;
|
||||
// slot10 state 2 (0x4a6b37): SetLevel(1).
|
||||
case 2:
|
||||
if (getenv("BT_DUCK_LOG"))
|
||||
DEBUG_STREAM << "[duck] squat clip parked (case 2 -> leg alarm 1)\n" << std::flush;
|
||||
m->legStateAlarm.SetLevel(1); return 0.0f;
|
||||
// slot2 (0x4a6b21) + slot1 state 32 (0x4a6b4d) + slot9 state 4 (0x4a6d6e): SetLevel(0).
|
||||
case 3: case 4: case 8: case 9: case 20: case 21:
|
||||
|
||||
+885
-76
File diff suppressed because it is too large
Load Diff
@@ -1193,8 +1193,21 @@ extern void BTSpawnDamageEffect(Mech *mech, int effect_resource, int segment_ind
|
||||
// unported "RemakeEntity" render state). Entity* param -> Mech* binds fine.
|
||||
extern void BTRemakeMechModel(Entity *entity);
|
||||
|
||||
//
|
||||
// #108 bridge: Mech::ReadUpdateRecord (mech.cpp, incomplete-type TU for the
|
||||
// handler) stamps the ghost detector through this.
|
||||
//
|
||||
void
|
||||
BTMechNoteUpdateApplied(void *mech_v)
|
||||
{
|
||||
Mech *m = (Mech *)mech_v;
|
||||
if (m != 0 && m->DeathHandlerPtr() != 0)
|
||||
((MechDeathHandler *)m->DeathHandlerPtr())->NoteUpdateApplied();
|
||||
}
|
||||
|
||||
MechDeathHandler::MechDeathHandler(Mech *mech) // @0042a984
|
||||
: owner(mech), prevMode(0)
|
||||
: owner(mech), prevMode(0), ghostFrames(0), ghostLogged(0),
|
||||
skatePrevX(0.0f), skatePrevZ(0.0f), skateFrames(0), skateLogged(0)
|
||||
{
|
||||
// per-zone last-damage cache, zeroed (binary this[0x10], size mech+0x11c).
|
||||
int count = (mech != 0 && mech->damageZoneCount > 0) ? mech->damageZoneCount : 0;
|
||||
@@ -1217,6 +1230,93 @@ void
|
||||
if (owner == 0)
|
||||
return;
|
||||
|
||||
// #52 SKATE DETECTOR (PORT, ungated -- the night-12 forensic): a REPLICANT
|
||||
// whose position advances while its LEG CHANNEL is not cycling is the
|
||||
// field "skating" signature. One line per episode, carrying the three
|
||||
// inputs that split the failure: bodyTargetSpeed (the replicated commanded
|
||||
// speed the leg SM feeds from -- ~0 means the RECORD side starved the
|
||||
// gait), legCycleSpeed (~0 with a live demand means the SM is STUCK), and
|
||||
// IsMechDestroyed (1 means the gait feed block was SKIPPED -- the stale
|
||||
// post-respawn flag suspect). ~90 frames of sustained skate to trip; the
|
||||
// threshold 0.08 u/frame ~ 2.4 u/s at 30 fps, well above reckoner jitter.
|
||||
if (owner->GetInstance() == Entity::ReplicantInstance)
|
||||
{
|
||||
const float px = (float)owner->localOrigin.linearPosition.x;
|
||||
const float pz = (float)owner->localOrigin.linearPosition.z;
|
||||
const float dx = px - skatePrevX, dz = pz - skatePrevZ;
|
||||
const float step = sqrtf(dx * dx + dz * dz);
|
||||
skatePrevX = px; skatePrevZ = pz;
|
||||
// CHANNEL-AGNOSTIC (corrected same-day): the peer's joints are posed by
|
||||
// the BODY channel in the current authentic architecture (mech4
|
||||
// s_peerLegCh=0 -> AdvanceBodyAnimation mj=1), so legCycleSpeed==0 is
|
||||
// NORMAL there -- the first detector build false-fired on every healthy
|
||||
// movement phase. Skating = moving with BOTH channels idle.
|
||||
const float cyc = fabsf((float)owner->legCycleSpeed)
|
||||
+ fabsf((float)owner->bodyCycleSpeed);
|
||||
const int movingNoLegs = (step > 0.08f && step < 5.0f // 5+: teleport/warp
|
||||
&& cyc < 0.05f) ? 1 : 0;
|
||||
if (movingNoLegs)
|
||||
{
|
||||
if (++skateFrames > 90 && !skateLogged)
|
||||
{
|
||||
skateLogged = 1;
|
||||
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
|
||||
<< " 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",
|
||||
BTMatchHostOf(owner->GetEntityID()), (int)owner->GetEntityID(),
|
||||
skateFrames, step, cyc,
|
||||
(float)owner->bodyTargetSpeed,
|
||||
(int)owner->IsMechDestroyed(), (int)owner->MovementMode(),
|
||||
px, pz);
|
||||
}
|
||||
}
|
||||
else if (skateFrames > 0)
|
||||
{
|
||||
if (skateLogged)
|
||||
DEBUG_STREAM << "[skate] replicant " << owner->GetEntityID()
|
||||
<< " recovered after " << skateFrames << " frames\n" << std::flush;
|
||||
skateFrames = 0; skateLogged = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// #108 GHOST DETECTOR (PORT, ungated -- the night-9 forensic): a visible
|
||||
// REPLICANT that stops receiving update records is exactly what "everyone
|
||||
// shoots thin air" looks like from the other side. ReadUpdateRecord
|
||||
// stamps arrivals (NoteUpdateApplied); ~20s of silence on an unburied
|
||||
// replicant logs ONE [ghost] line per episode with the identity + last
|
||||
// position, then re-arms when records resume. Frame-counted (Tick has no
|
||||
// dt); 600 frames ~ 10-20s at field rates.
|
||||
if (owner->GetInstance() == Entity::ReplicantInstance)
|
||||
{
|
||||
++ghostFrames;
|
||||
if (ghostFrames > 600 && !ghostLogged
|
||||
&& !owner->WreckBuried()) // buried wrecks are expected-silent
|
||||
{
|
||||
ghostLogged = 1;
|
||||
DEBUG_STREAM << "[ghost] replicant " << owner->GetEntityID()
|
||||
<< " has received NO update records for " << ghostFrames
|
||||
<< " frames (mode " << (int)owner->MovementMode()
|
||||
<< ") last pos (" << owner->localOrigin.linearPosition.x
|
||||
<< "," << owner->localOrigin.linearPosition.z
|
||||
<< ") -- stale-replicant / ghost signature (#108)\n" << std::flush;
|
||||
if (BTMatchLogActive())
|
||||
BTMatchLog("GHOST", "mech=%d:%d frames=%d mode=%d x=%.1f z=%.1f",
|
||||
BTMatchHostOf(owner->GetEntityID()), (int)owner->GetEntityID(),
|
||||
ghostFrames, (int)owner->MovementMode(),
|
||||
(float)owner->localOrigin.linearPosition.x,
|
||||
(float)owner->localOrigin.linearPosition.z);
|
||||
}
|
||||
}
|
||||
|
||||
// REPLICANT UN-WRECK (#94): fire on the DEATH-STATE EXIT edge of the
|
||||
// replicated SimulationState dial (MovementMode 9/2 are the death modes;
|
||||
// they never revert except through Mech::Reset, whose SetMovementMode(0)
|
||||
@@ -1239,15 +1339,25 @@ void
|
||||
{
|
||||
extern void BTRebuildMechModel(Entity *entity);
|
||||
extern void BTStartWarpEffect(float x, float y, float z);
|
||||
// #129 (fresh spawns smoking, peers' screens only): the wreck plume
|
||||
// is re-armed LOCALLY on every node (entity-attached, 10s windows),
|
||||
// and only the master's Mech::Reset ran the @004d0c14 effect
|
||||
// cleanup -- the observer's last window rode the teleport and kept
|
||||
// puffing on the fresh mech for up to ~16s. Kill the entity's
|
||||
// effects on the un-wreck edge, same as the master path.
|
||||
extern void BTStopEntityPfx(void *entity_v);
|
||||
BTStopEntityPfx((void *)owner);
|
||||
BTRebuildMechModel((Entity *)owner);
|
||||
BTStartWarpEffect((float)owner->localOrigin.linearPosition.x,
|
||||
(float)owner->localOrigin.linearPosition.y,
|
||||
(float)owner->localOrigin.linearPosition.z);
|
||||
if (getenv("BT_DEATH_LOG"))
|
||||
DEBUG_STREAM << "[respawn] replicant un-wrecked + warp (mode "
|
||||
<< oldMode << "->" << mode << ") at ("
|
||||
<< owner->localOrigin.linearPosition.x << ","
|
||||
<< owner->localOrigin.linearPosition.z << ")\n" << std::flush;
|
||||
// #108 forensics: UNGATED -- one line per peer respawn; a field
|
||||
// night's ghost triage needs these without asking players for envs.
|
||||
DEBUG_STREAM << "[respawn] replicant " << owner->GetEntityID()
|
||||
<< " un-wrecked + warp (mode "
|
||||
<< oldMode << "->" << mode << ") at ("
|
||||
<< owner->localOrigin.linearPosition.x << ","
|
||||
<< owner->localOrigin.linearPosition.z << ")\n" << std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1275,6 +1385,20 @@ void
|
||||
continue;
|
||||
lastLevel[i] = level;
|
||||
|
||||
// AUTHENTIC (FUN_0042aa2c @0042aa2c, the binary's effect-watcher level
|
||||
// branch): when the rise CROSSES a band-descriptor threshold
|
||||
// (FUN_0042a5f4) and this node owns the master, raise the entity's
|
||||
// damage-zone update flag (*(entity+0x18) |= 2, master-gated on
|
||||
// (entity+0x28 & 0xc) == 0) -- the zone update records ship this
|
||||
// zone's damageLevel to every observer, so replicant dolls/hull tint
|
||||
// track the fight at band granularity. This send was MISSING from
|
||||
// the port (only the graphic-state branch below raised it): masters
|
||||
// consumed damage correctly while every observer's copy sat at 0.0
|
||||
// until destruction (the "no discoloration in MP" report, #87).
|
||||
if (zone->DescriptorCrossed(prev, level)
|
||||
&& owner->GetInstance() == Entity::MasterInstance)
|
||||
owner->ForceUpdate(Entity::DamageZoneUpdateModelFlag);
|
||||
|
||||
// AUTHENTIC GATE (decomp re-verified 2026-07-12, workflow): the binary
|
||||
// fires the CURRENT band descriptor's effect whenever the zone's
|
||||
// damage-CHANGED flag is set (+0xb8 & 4, set by DamageZone::TakeDamage,
|
||||
@@ -1326,7 +1450,12 @@ void
|
||||
// The single-threaded frame loop makes the immediate swap safe.
|
||||
if (d->graphicState != DamageZone::ExistsGraphicState)
|
||||
{
|
||||
owner->ForceUpdate(Entity::DamageZoneUpdateModelFlag);
|
||||
// Master-gated like the binary's gstate branch (@0042aa2c:
|
||||
// (entity+0x28 & 0xc) == 0 guards the |= 2); the mesh swap
|
||||
// runs on EVERY instance -- an observer node executes this
|
||||
// same path when the replicated level/gstate lands.
|
||||
if (owner->GetInstance() == Entity::MasterInstance)
|
||||
owner->ForceUpdate(Entity::DamageZoneUpdateModelFlag);
|
||||
BTRemakeMechModel(owner); // RemakeEntity: swap in the destroyed mesh
|
||||
}
|
||||
if (getenv("BT_DEATH_LOG"))
|
||||
@@ -1438,3 +1567,22 @@ void BTAmmoExplosionFanOut(
|
||||
((Mech *)owner_mech)->AmmoExplosionFanOut(
|
||||
(::Subsystem *)inflicting_subsystem, inflicting_subsystem_id, total_damage);
|
||||
}
|
||||
|
||||
//
|
||||
// #124 ZONE WALKER bridge: a zone's carrier SEGMENT index + name, complete-
|
||||
// type TU (Mech__DamageZone). Replicant-safe -- the zone table streams on
|
||||
// every instance, so the SHOOTER can enumerate its target's zones locally.
|
||||
// Returns 0 past the end (the walker wraps on that).
|
||||
//
|
||||
int BTMechZoneSegAndName(void *mech_v, int zone_idx, int *seg_out, const char **name_out)
|
||||
{
|
||||
Mech *m = (Mech *)mech_v;
|
||||
if (m == 0 || zone_idx < 0 || zone_idx >= m->damageZoneCount)
|
||||
return 0;
|
||||
Mech__DamageZone *dz = m->Zone(zone_idx);
|
||||
if (dz == 0)
|
||||
return 0;
|
||||
*seg_out = dz->EffectSegmentIndex(); // public accessor (@0x194)
|
||||
*name_out = (const char *)dz->damageZoneName; // engine base, public
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -363,6 +363,11 @@ class MechDeathHandler
|
||||
MechDeathHandler(Mech *mech); // @0042a984
|
||||
~MechDeathHandler(); // @0042a9f4
|
||||
void Tick(); // @0042aa2c (the Performance)
|
||||
// #108 forensics (PORT, ungated): the replicant staleness detector.
|
||||
// Mech::ReadUpdateRecord stamps every applied record; Tick counts
|
||||
// frames since -- a visible replicant starved of records IS the ghost
|
||||
// signature, logged once per episode with id + age + last position.
|
||||
void NoteUpdateApplied() { ghostFrames = 0; ghostLogged = 0; }
|
||||
|
||||
private:
|
||||
Mech *owner; // @0x14
|
||||
@@ -378,6 +383,14 @@ class MechDeathHandler
|
||||
// authentic un-wreck rides the type-0 graphic-state hook
|
||||
// (Mech::ReadUpdateRecord case 0), which our port has not reconstructed.
|
||||
int prevMode;
|
||||
// #108 ghost detector state (PORT)
|
||||
int ghostFrames;
|
||||
int ghostLogged;
|
||||
// #52 skate detector state (PORT): position advancing while the leg
|
||||
// channel is not cycling = the field "skating" signature.
|
||||
float skatePrevX, skatePrevZ;
|
||||
int skateFrames;
|
||||
int skateLogged;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
@@ -840,6 +840,15 @@ void
|
||||
extern float gBTElevAxis;
|
||||
stickPosition.y = gBTElevAxis;
|
||||
}
|
||||
// #124 twist-sign bench probe: is the axis routed, and in what mode?
|
||||
if (getenv("BT_TORSO_LOG"))
|
||||
{
|
||||
static int s_mp = 0;
|
||||
if ((s_mp++ % 60) == 0 && s_mp < 1200)
|
||||
DEBUG_STREAM << "[mppr] mode=" << (int)controlMode
|
||||
<< " twistAxis=" << gBTTwistAxis
|
||||
<< " keyTurn=" << key_turn << "\n" << std::flush;
|
||||
}
|
||||
if (controlMode == BasicMode)
|
||||
{
|
||||
stickPosition.x = key_turn;
|
||||
@@ -1009,6 +1018,11 @@ void
|
||||
if (myomers > 0)
|
||||
{
|
||||
speedDemand *= drive; // @0x4a9d63: in-place demand scale
|
||||
// The binary keeps the chain MAX in mech+0x79C (@0x4a9d00-0x4a9d39)
|
||||
// -- publish it so the CROUCH gate (mech4 posture block, 2026-08-06)
|
||||
// reads the same live factor (dead/overheated myomers cannot squat
|
||||
// or rise; the posture selector tests |factor| <= 1e-4).
|
||||
mech->myomerEffectiveness = drive;
|
||||
if (fabsf(drive) <= 1.0e-4f) // @0x4a9d89 vs _DAT_004ab16c
|
||||
turnDemand = 0.0f; // @0x4a9d9e: mapper+0x12C -- the FREEZE
|
||||
}
|
||||
|
||||
@@ -287,6 +287,14 @@ void MechSubsystem::ForceCriticalFailure()
|
||||
{
|
||||
((DamageZone *)damageZone)->SetDamageZoneState(1); // zone+0x10
|
||||
}
|
||||
// (A MarkGeneratorOut call lived here 2026-08-03 for a few hours --
|
||||
// REMOVED same night when the alarm dig completed: the gauge-alarm
|
||||
// conditions are STATUS-FLAG BITS scanned by MechTech, not stateAlarm
|
||||
// levels. Generator death raises bit 6 (!HasVoltage) AUTHENTICALLY ->
|
||||
// the gotoEngineering + engBusMode invite; conditions 4/5 (engEject)
|
||||
// are the WEAPON's AmmoBurning/Jammed bits -- the ammo-purge invite,
|
||||
// never a pilot-eject one. stateAlarm 4's real producer remains
|
||||
// unfound (export holes) and nothing observable needs it.)
|
||||
}
|
||||
|
||||
// issue #22 (PORT): the fresh-mech respawn repair -- the exact inverse of the
|
||||
|
||||
@@ -341,6 +341,13 @@ class Damage;
|
||||
public:
|
||||
static Logical
|
||||
LookupStatusType(const char *name, int *out_value);
|
||||
|
||||
// EJECT-evaluator support (@0049fa1c bridges): the both-cells
|
||||
// destroyed test (gotcha #22 -- the binary's ONE cell @0x40 is split
|
||||
// into statusAlarm + simulationState here, so every cross-family
|
||||
// destroyed check must read BOTH).
|
||||
int SimulationStateCell() const { return simulationState; }
|
||||
int StatusAlarmLevel() { return statusAlarm.GetLevel(); }
|
||||
protected:
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -197,6 +197,15 @@ MechTech::MechTech(
|
||||
wooHooMinimumDuration = model->wooHooMinimumDuration; // res +0x34 -> this+0xF4
|
||||
wooHooDurationRange = model->wooHooDurationRange; // res +0x38 -> this+0xF8
|
||||
wooHooChance = model->wooHooChance; // res +0x3C -> this+0xFC
|
||||
// DIAG (#119 leak-voice dig): the AUTHORED anti-spam latch tuning.
|
||||
// No armer for the wooHoo latch exists anywhere in the flat export --
|
||||
// if the authored chance is 0 the latch was dormant in 4.10 too and
|
||||
// the repeating warning voice under multiple leaks is AUTHENTIC.
|
||||
if (getenv("BT_LAMP_LOG"))
|
||||
DEBUG_STREAM << "[techstat] wooHoo authored: minDur="
|
||||
<< wooHooMinimumDuration << " range=" << wooHooDurationRange
|
||||
<< " chance=" << wooHooChance
|
||||
<< " alarmModel=" << alarmModel << "\n" << std::flush;
|
||||
|
||||
//
|
||||
// Walk the owner's subsystem roster and attach a monitor to every
|
||||
|
||||
@@ -740,15 +740,12 @@ void
|
||||
target->Dispatch(&message); // no manager: direct (bring-up)
|
||||
}
|
||||
|
||||
// PORT bookkeeping (score lived in the retired mech4 fire block): credit
|
||||
// the delivered amount to the shooter-side score exactly as before.
|
||||
extern void BTPostDamageScore(Entity *victim, Scalar damage);
|
||||
extern Logical BTIsRegisteredMech(Entity *e);
|
||||
if (BTIsRegisteredMech(target)
|
||||
&& !((Mech *)target)->IsMechDestroyed())
|
||||
{
|
||||
BTPostDamageScore(target, damageData.damageAmount);
|
||||
}
|
||||
// SCORE: handled by the victim's own TakeDamage handler now -- the
|
||||
// dispatch above lands in the authentic report tail (#45), which posts
|
||||
// the kill/received reports itself. The shooter-side BTPostDamageScore
|
||||
// call that lived here was RETIRED 2026-08-05 (per-hit inflicted credit
|
||||
// never existed in the 1995 pod; kills + received-damage penalties only).
|
||||
extern Logical BTIsRegisteredMech(Entity *e); // still read by the matchlog below
|
||||
|
||||
// MP MATCH FORENSICS (matchlog.hpp): the shooter-side per-shot damage
|
||||
// submission -- pairs with the victim's DMG application line. mech=1
|
||||
@@ -994,3 +991,13 @@ void BTWeaponSetViewFireEnable(Subsystem *sub, int enable)
|
||||
if (sub != 0 && sub->IsDerivedFrom(MechWeapon::ClassDerivations))
|
||||
((MechWeapon *)sub)->SetViewFireEnable(enable);
|
||||
}
|
||||
|
||||
// #124 aspect probe: a weapon's physical muzzle WORLD position (the pod's own
|
||||
// asymmetric geometry as a left/right anchor). -1-family returns 0.
|
||||
int BTWeaponMuzzleWorld(Subsystem *sub, Point3D *out)
|
||||
{
|
||||
if (sub == 0 || out == 0 || !sub->IsDerivedFrom(MechWeapon::ClassDerivations))
|
||||
return 0;
|
||||
((MechWeapon *)sub)->MuzzlePoint(*out); // the public beam-render alias
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -360,6 +360,7 @@ class CockpitHud;
|
||||
int PipExtendedRange() const { return pipExtendedRange; }
|
||||
void *WithinRangePtr() { return &targetWithinRange; }
|
||||
void *WeaponAlarmPtr() { return &weaponAlarm; }
|
||||
int WeaponAlarmLevel() { return weaponAlarm.GetLevel(); } // @0x364 (eject evaluator: 7 = NoAmmo)
|
||||
void *SimulationStatePtr() { return &simulationState; } // attr 1 ("SimulationState" -- damage state; 1 = Destroyed)
|
||||
Scalar *RechargeLevelPtr() { return &rechargeLevel; } // the recharge-dial source (1.0 == fully charged); LIVE on BOTH families
|
||||
// (projectile weapons write it via slot 17 @004b9c9c — Gitea #12)
|
||||
|
||||
@@ -762,35 +762,36 @@ void Myomers::MyomersDriveHeat(Scalar time_slice)
|
||||
// exists because the binary's per-tick value depends on the pod's frame
|
||||
// rate, which our port does not run at.
|
||||
//
|
||||
// THE CLOCK ORIGIN, traced 2026-08-02 (was the open question here):
|
||||
// the DOS binary keeps its engine frame rate in the global DAT_0052140c,
|
||||
// set once at startup --
|
||||
// 0x401ace: mov [0x52140c], 0x41E00000 = 28.0f (nominal)
|
||||
// 0x401ada: mov [0x52140c], 0x4191A6E0 = 18.2065f (BIOS-tick fallback)
|
||||
// -- and consumed by ~90 fmul/fdiv sites across the image as THE
|
||||
// per-frame<->per-second conversion. The DOS main passes it straight into
|
||||
// the ApplicationManager ctor (0x401189: push [0x52140c]). So the pod's
|
||||
// nominal Performance cadence was 28 Hz, byte-proven. [T1]
|
||||
// THE CLOCK -- CLOSED 2026-08-02, all [T1] from bytes:
|
||||
// * DAT_0052140c = SystemClock ticksPerSecond, 28.0 nominal (0x401ace) /
|
||||
// 18.2065 BIOS fallback (0x401ada).
|
||||
// * The arcade Simulation::PerformAndWatch is FUN_0041c018:
|
||||
// slice = (till.ticks - lastPerformance.ticks) / DAT_0052140c;
|
||||
// lastPerformance = till; (*performance)(this, slice);
|
||||
// -- VARIABLE-STEP, slice in real SECONDS, line-for-line the WinTesla
|
||||
// SIMULATE.cpp body. Every dt-carrying term is frame-rate independent
|
||||
// in BOTH the binary and the port; no unit conversion is missing.
|
||||
// * This term's ONE caller is the unexported MyomersSimulation body
|
||||
// (0x4b8b9a-0x4b8d0d, raw disasm 2026-08-02 -- the export gap hid it):
|
||||
// it calls 0x4b8d18 once per Perform with the RAW slice, gated only on
|
||||
// measuredVoltage > 0. So the kinetic energy pulse fires once per
|
||||
// FRAME, and the pod's frame cadence is its tick rate: 28 Hz when
|
||||
// holding the deadline, sagging under load (fewer pulses/s -- an
|
||||
// EMERGENT hardware mercy, not a constant in the code).
|
||||
//
|
||||
// CALIBRATION FACTOR 0.5 -- the pod's EFFECTIVE rate under load, now
|
||||
// corroborated from three directions (2026-08-02):
|
||||
// * Oracle (pod veteran), asked if the frame rate was rock solid:
|
||||
// "I think I could do as well with a flipbook"; heavy fights "would
|
||||
// turn to a slide show".
|
||||
// * Lynx (original-era): "RP had a target frame rate of 30FPS. It
|
||||
// wasn't hard coded or locked. BT never had a locked frame rate in
|
||||
// 1st release testing... coming from 3.0, where 20FPS was a miracle."
|
||||
// * The engine's own pacer DESIGN: end_of_frame = Now() + frameDuration
|
||||
// with background fill and NO overrun catch-up (APPMGR.cpp, T0) --
|
||||
// "target 28 but allowed to slip" is the construction, and the
|
||||
// 18.2065 BIOS fallback anticipated degraded timing.
|
||||
// Corollary: a choking 486 generated LESS heat/s exactly during the
|
||||
// biggest fights -- veteran memory of seek-4 endurance encodes the LOADED
|
||||
// rate, so bracketing by their consensus is the correct instrument.
|
||||
// Effective default 28 * 0.5 = 14 Hz [T2 multi-witness]; BT_MYO_HZ=<hz>
|
||||
// overrides absolutely for bracketing.
|
||||
// The faithful reference is therefore the binary's own nominal cadence,
|
||||
// 28 Hz, full stop. The old 0.5 "loaded-rate" calibration [T3 witness
|
||||
// bracket] is RETIRED: it modeled the sag as a constant, which is wrong in
|
||||
// exactly the reported scenario (open-field cruising is a LIGHT scene --
|
||||
// the pod held ~28 there). Measured at 28 Hz (cruise benches 2026-08-02):
|
||||
// recommended gear equilibrates ~1450 (soft 20% output governor inside the
|
||||
// authored degradation band, weapons unaffected, zero jams); supercharge
|
||||
// ~1770 (deep governor, the manual's own warning). That IS the authored
|
||||
// system: degradeT=1000 is a governor onset, failT=2000 the cliff, and the
|
||||
// pod's coolant-loop controls (flush/balance, Myomers on loop 5) are the
|
||||
// player's lever. BT_MYO_HZ=<hz> remains as the bracketing override.
|
||||
static const Scalar kPodFrameHz = 28.0f; // byte-proven nominal [T1]
|
||||
static const Scalar kSeekHeatCalib = 0.5f; // Oracle bracket 2026-08-02 [T3]
|
||||
static const Scalar kSeekHeatCalib = 1.0f; // faithful (0.5 bracket retired 2026-08-02)
|
||||
static Scalar s_hz = -1.0f;
|
||||
if (s_hz < 0.0f)
|
||||
{
|
||||
@@ -800,7 +801,7 @@ void Myomers::MyomersDriveHeat(Scalar time_slice)
|
||||
if (getenv("BT_MYO_LOG"))
|
||||
DEBUG_STREAM << "[myoheat] kinetic-term reference rate: " << s_hz
|
||||
<< " Hz (pod nominal 28 [T1] x calib " << kSeekHeatCalib
|
||||
<< " [T3]" << (hv ? ", ENV OVERRIDE" : "") << ")" << std::endl;
|
||||
<< (hv ? ", ENV OVERRIDE" : "") << ")" << std::endl;
|
||||
}
|
||||
|
||||
Scalar termClimb = velComplement * vy * mass * gravity * time_slice;
|
||||
|
||||
@@ -1305,10 +1305,10 @@ void
|
||||
{
|
||||
// The THERMAL BREAKER: output collapses to 0 until the generator cools
|
||||
// past NormalHeat and re-spins (task #11 diag below shows the duty cycle).
|
||||
if (stateAlarm.GetLevel() != GeneratorRecovered && getenv("BT_HEAT_LOG"))
|
||||
if (stateAlarm.GetLevel() != GeneratorOut && getenv("BT_HEAT_LOG"))
|
||||
DEBUG_STREAM << "[gen] " << GetName() << " TRIPPED (T="
|
||||
<< currentTemperature << ")" << std::endl;
|
||||
stateAlarm.SetLevel(GeneratorRecovered); // SetLevel 4
|
||||
stateAlarm.SetLevel(GeneratorOut); // SetLevel 4
|
||||
}
|
||||
|
||||
switch (stateAlarm.GetLevel()) // this[0x84] @0x210
|
||||
@@ -1337,7 +1337,7 @@ void
|
||||
}
|
||||
break;
|
||||
|
||||
case GeneratorRecovered: // 4
|
||||
case GeneratorOut: // 4
|
||||
outputVoltage = 0.0f;
|
||||
if (heatAlarm.GetLevel() == HeatSink::NormalHeat) // this+0x184 == 0
|
||||
{
|
||||
@@ -1778,3 +1778,30 @@ int BTSubsystemIsGenerator(::Subsystem *sub)
|
||||
{
|
||||
return (sub != 0 && sub->IsDerivedFrom(*Generator::GetClassDerivations())) ? 1 : 0;
|
||||
}
|
||||
|
||||
// Destruction -> OUT display state (see Generator::MarkGeneratorOut, the [T3
|
||||
// inferred link]): a destroyed generator must reach stateAlarm 4 or the
|
||||
// authored gotoEngineering/engEject FLASH invite (alarm conditions 4/5) never
|
||||
// fires and the eject evaluator's state clause never sees it. Called from
|
||||
// MechSubsystem::ForceCriticalFailure (the one crit-death chokepoint).
|
||||
void BTGeneratorMarkOut(::Subsystem *sub)
|
||||
{
|
||||
if (sub != 0 && sub->IsDerivedFrom(*Generator::GetClassDerivations()))
|
||||
((Generator *)sub)->MarkGeneratorOut();
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// BTGeneratorCountsForEject -- complete-type bridge for the EJECT-permission
|
||||
// evaluator (Mech::EvaluateEjectPermission @0049fa1c). Binary clause: classID
|
||||
// 0xBC1, not destroyed (+0x40 != 1; both-cells here per gotcha #22), and
|
||||
// stateAlarm level (@0x210) != 4. Returns -1 = not a generator, else 0/1.
|
||||
//===========================================================================//
|
||||
int BTGeneratorCountsForEject(Subsystem *sub)
|
||||
{
|
||||
if (sub == 0 || sub->GetClassID() != RegisteredClass::GeneratorClassID) // +4 == 0xbc1
|
||||
return -1;
|
||||
Generator *g = (Generator *)sub;
|
||||
if (g->SimulationStateCell() == 1 || g->StatusAlarmLevel() == 1)
|
||||
return 0; // destroyed
|
||||
return (g->GeneratorStateOf() != 4) ? 1 : 0; // stateAlarm @0x210 != 4
|
||||
}
|
||||
|
||||
@@ -379,7 +379,12 @@ class Generator;
|
||||
GeneratorIdle = 1, // off / no output
|
||||
GeneratorReady = 2, // producing RatedVoltage
|
||||
GeneratorShorted = 3, // shorted; shortTimer -> shortRecoveryTime
|
||||
GeneratorRecovered = 4 // TODO: confirm 4th/5th level names
|
||||
GeneratorOut = 4 // generator DEAD/OUT: the eject evaluator counts
|
||||
// stateAlarm==4 as not-live [T1 @0049fa1c], and the
|
||||
// authored gauge-alarm streams flash gotoEngineering +
|
||||
// engEject on conditions 4/5 [T1 content dump
|
||||
// 2026-08-03] -- the "punch out now" invite.
|
||||
// (Old name "GeneratorRecovered" was a placeholder.)
|
||||
};
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
@@ -462,6 +467,20 @@ class Generator;
|
||||
// Source-state queries used by PoweredSubsystem / PowerWatcher.
|
||||
int
|
||||
GeneratorStateOf() { return stateAlarm.GetLevel(); } // source+0x210
|
||||
|
||||
// UNUSED -- and now PROVEN unnecessary (2026-08-04 dig): stateAlarm 4
|
||||
// (GeneratorOut) is the THERMAL BREAKER state, produced by
|
||||
// GeneratorSimulation itself (heatAlarm==FailureHeat -> SetLevel 4,
|
||||
// byte-matched vs FUN_004b1f7c) -- never by destruction. The gauge
|
||||
// alarms scan STATUS-FLAG BITS via MechTech (bit 6 = !HasVoltage);
|
||||
// a generator destroyed IN PLACE keeps its stale Ready voltage in
|
||||
// the BINARY TOO (the crit distributor @0049c9a8 touches nothing
|
||||
// electrical) until any transition recomputes output through the
|
||||
// (1 - damage) formula. A short-lived ForceCriticalFailure bridge
|
||||
// to this method was removed as unfounded; keep the method only as
|
||||
// documentation of the state's meaning.
|
||||
void
|
||||
MarkGeneratorOut() { stateAlarm.SetLevel(GeneratorOut); }
|
||||
Scalar
|
||||
MeasuredVoltage() { return outputVoltage; } // source+0x1dc
|
||||
Scalar
|
||||
|
||||
@@ -268,6 +268,17 @@ void *
|
||||
? (void *)((ProjectileWeapon *)weapon)->ammoBinLink.Resolve() : 0;
|
||||
}
|
||||
|
||||
// Eject-wipe gauge bridge (#118): the BallisticWeaponCluster's
|
||||
// BitMapInverseWipeScalar watches the weapon's PercentOfEject (@0x3F8, the
|
||||
// binary passes param_8+0x3f8 straight into the @004c61c8 ctor). Complete-
|
||||
// type TU per the databinding rule -- never raw-offset a compiled object.
|
||||
Scalar *
|
||||
BTWeaponPercentOfEjectPtr(void *weapon)
|
||||
{
|
||||
return (weapon != 0)
|
||||
? &((ProjectileWeapon *)weapon)->percentOfEject : 0;
|
||||
}
|
||||
|
||||
|
||||
//#############################################################################
|
||||
// Construction / Destruction
|
||||
@@ -1242,3 +1253,25 @@ Subsystem *CreateProjectileWeaponSubsystem(Mech *owner, int id, void *seg)
|
||||
return (Subsystem *) new (Memory::Allocate(0x448))
|
||||
ProjectileWeapon(owner, id, (ProjectileWeapon::SubsystemResource *)seg, ProjectileWeapon::DefaultData);
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// BTWeaponCountsForEject -- complete-type bridge for the EJECT-permission
|
||||
// evaluator (Mech::EvaluateEjectPermission, @0049fa1c; issue: eject/panic).
|
||||
// The binary's weapon clause: MechWeapon-derived (0x511830), not destroyed,
|
||||
// and -- for the ProjectileWeapon family only (0x5121a8) -- weaponAlarm
|
||||
// (@0x364) != 7 (NoAmmo): a dry ballistic rack is not a fighting weapon.
|
||||
// Destroyed is the BOTH-CELLS test (gotcha #22: statusAlarm level + the
|
||||
// port's split simulationState). Returns -1 = not a weapon, else 0/1.
|
||||
//===========================================================================//
|
||||
int BTWeaponCountsForEject(Subsystem *sub)
|
||||
{
|
||||
if (sub == 0 || !sub->IsDerivedFrom(MechWeapon::ClassDerivations))
|
||||
return -1;
|
||||
MechWeapon *w = (MechWeapon *)sub;
|
||||
if (w->SimulationStateCell() == 1 || w->StatusAlarmLevel() == 1)
|
||||
return 0; // destroyed (both cells)
|
||||
if (sub->IsDerivedFrom(ProjectileWeapon::ClassDerivations)
|
||||
&& ((ProjectileWeapon *)sub)->WeaponAlarmLevel() == 7)
|
||||
return 0; // NoAmmo (@0x364 == 7)
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -360,6 +360,7 @@ class NotationFile;
|
||||
AmmoBinConnection
|
||||
ammoBinLink; // @0x43C embedded 0xC connection to the AmmoBin subsystem
|
||||
friend void *BTWeaponAmmoBin(void *weapon); // panel ammo-bin bridge (AFC100-counter fix)
|
||||
friend Scalar *BTWeaponPercentOfEjectPtr(void *weapon); // eject-wipe gauge bridge (#118)
|
||||
// (ctor @004bcbb0, dtor @004bcbcf, link vtable
|
||||
// @00512424; resolved via the resource AmmoBin
|
||||
// index at +0x1C0) -- LAST field, object ends 0x448
|
||||
|
||||
@@ -82,7 +82,7 @@ Searchlight::SharedData
|
||||
//
|
||||
// Chains to the PowerWatcher ctor (FUN_004b18a4) with &DAT_00511198, installs
|
||||
// the Searchlight vtable (PTR_FUN_005114d4), builds the 2-level state
|
||||
// AlarmIndicator (@0x1E4) and seeds commandedOn from the segment field
|
||||
// AlarmIndicator (@0x1E4) and seeds mountSegment from the segment field
|
||||
// (+0x28). When this is a live (non-copy) segment with the has-performance
|
||||
// flag set it registers SearchlightSimulation (this[7..9] <- PTR_FUN_00511200);
|
||||
// otherwise it marks the instance "no per-frame performance" (flag bit 2).
|
||||
@@ -103,7 +103,7 @@ Searchlight::Searchlight(
|
||||
lightState = 0; // @0x1D8
|
||||
requestedOn = 0; // @0x1E0
|
||||
stateAlarm.Initialize(2); // FUN_0041b9ec(this+0x1E4, 2)
|
||||
commandedOn = subsystem_resource->segmentIndex; // @0x1DC <- inherited resource +0x28
|
||||
mountSegment = subsystem_resource->segmentIndex; // @0x1DC <- inherited resource +0x28 (the spot-cone mount joint)
|
||||
|
||||
// GATE FIX (the functional bug the de-shim unblocks): the binary reads OWNER
|
||||
// simulationFlags (param_2+0x28, raw part_013.c:6012), NOT the old shadow
|
||||
@@ -311,8 +311,8 @@ void
|
||||
if (!announced)
|
||||
{
|
||||
announced = 1;
|
||||
DEBUG_STREAM << "[light] SearchlightSimulation RUNNING (commandedOn "
|
||||
<< commandedOn << ", requestedOn " << requestedOn
|
||||
DEBUG_STREAM << "[light] SearchlightSimulation RUNNING (mountSegment "
|
||||
<< mountSegment << ", requestedOn " << requestedOn
|
||||
<< ", lightState " << lightState << ")" << std::endl;
|
||||
}
|
||||
}
|
||||
@@ -323,7 +323,7 @@ void
|
||||
|
||||
if (getenv("BT_FIRE_LOG") || getenv("BT_HEAT_LOG"))
|
||||
DEBUG_STREAM << "[light] reported lightState " << previous << " -> " << lightState
|
||||
<< " (commandedOn " << commandedOn << ", requestedOn " << requestedOn << ")"
|
||||
<< " (mountSegment " << mountSegment << ", requestedOn " << requestedOn << ")"
|
||||
<< std::endl;
|
||||
}
|
||||
|
||||
@@ -405,7 +405,7 @@ int
|
||||
struct SearchlightLayoutCheck
|
||||
{
|
||||
static_assert(offsetof(Searchlight, lightState) == 0x1D8, "Searchlight lightState @0x1D8 (PowerWatcher ends 0x1D8)");
|
||||
static_assert(offsetof(Searchlight, commandedOn) == 0x1DC, "Searchlight commandedOn @0x1DC (res+0x28)");
|
||||
static_assert(offsetof(Searchlight, mountSegment) == 0x1DC, "Searchlight mountSegment @0x1DC (res+0x28 segmentIndex)");
|
||||
static_assert(offsetof(Searchlight, requestedOn) == 0x1E0, "Searchlight requestedOn @0x1E0");
|
||||
static_assert(offsetof(Searchlight, stateAlarm) == 0x1E4, "Searchlight stateAlarm @0x1E4 (proves base ends 0x1D8)");
|
||||
static_assert(sizeof(Searchlight) <= 0x238, "sizeof(Searchlight) must fit the factory Memory::Allocate(0x238)");
|
||||
@@ -423,3 +423,39 @@ Subsystem *CreateSearchlightSubsystem(Mech *owner, int id, void *seg)
|
||||
return (Subsystem *) new (Memory::Allocate(0x238))
|
||||
Searchlight(owner, id, (Searchlight::SubsystemResource *)seg, Searchlight::DefaultData);
|
||||
}
|
||||
|
||||
//===========================================================================//
|
||||
// Video-layer probe (2026-08-05, the spot-cone/fog reconstruction). The 1995
|
||||
// MakeMechRenderables walk (@004cef28) hits `case 0xbd8` per roster subsystem
|
||||
// and pulls the "LightOn" attribute + the mount joint; our port's tree builder
|
||||
// does not walk the roster, so btl4vid calls this bridge instead (complete-
|
||||
// Searchlight TU, per the databinding rule). Returns the number of
|
||||
// searchlights found (0..2 reported -- the binary's cockpit branch keeps TWO
|
||||
// attr slots, @004cef28 local_118/local_11c; more than 2 exist on no chassis).
|
||||
//===========================================================================//
|
||||
int BTMechSearchlightProbe(
|
||||
void *mech_v,
|
||||
int **light_on_out, // [2] "LightOn" attribute pointers (int*)
|
||||
int *mount_seg_out) // [2] mount segment slots (resource segmentIndex)
|
||||
{
|
||||
Mech *mech = (Mech *)mech_v;
|
||||
if (mech == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
int found = 0;
|
||||
for (int i = 0; i < mech->GetSubsystemCount() && found < 2; ++i)
|
||||
{
|
||||
Subsystem *sub = mech->GetSubsystem(i);
|
||||
if (sub == 0
|
||||
|| (int)sub->GetClassID() != (int)RegisteredClass::SearchlightClassID) // 0xBD8
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Searchlight *light = (Searchlight *)sub;
|
||||
light_on_out[found] = light->LightOnPointer(); // == GetAttributePointer("LightOn")
|
||||
mount_seg_out[found] = light->MountSegmentIndex(); // @0x1DC (res+0x28 segmentIndex)
|
||||
++found;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
@@ -67,13 +67,13 @@
|
||||
//
|
||||
// Empty extension of the PowerWatcher resource (CreateStreamedSubsystem
|
||||
// @004b85a8 reads no light-specific fields; it only stamps classID/model
|
||||
// size). The ctor seeds commandedOn from the inherited segment field (+0x28).
|
||||
// size). The ctor seeds mountSegment from the inherited segment field (+0x28).
|
||||
//
|
||||
struct Searchlight__SubsystemResource:
|
||||
public PowerWatcher::SubsystemResource
|
||||
{
|
||||
// RESOURCE_AUDIT.md: Searchlight adds NO resource field. The ctor seeds
|
||||
// commandedOn from the INHERITED segmentIndex (resource +0x28); the old
|
||||
// mountSegment from the INHERITED segmentIndex (resource +0x28); the old
|
||||
// `int segmentDefault;` was appended after the base (compiled at ~0xF4, OOB)
|
||||
// and read garbage. Empty extension = correct 0xF4 model size.
|
||||
};
|
||||
@@ -121,13 +121,20 @@
|
||||
//
|
||||
protected:
|
||||
int lightState; // @0x1D8 reported on/off (gated by power + heat each frame); "LightState"
|
||||
int commandedOn; // @0x1DC ctor-seeded from the subsystem resource +0x28 (@004b84dc).
|
||||
// NOT the toggle target and NOT read by @004b841c; it measured
|
||||
// 21 live, so it is not a boolean -- role still unidentified.
|
||||
int mountSegment; // @0x1DC ctor-seeded from the subsystem resource +0x28 (@004b84dc).
|
||||
// IDENTIFIED 2026-08-05 (was "commandedOn -- role still
|
||||
// unidentified"): resource+0x28 IS segmentIndex, and the live
|
||||
// value 21 is the searchlight's mount SEGMENT slot -- the joint
|
||||
// the spot-cone renderable hangs from (MakeMechRenderables
|
||||
// @004cef28 case 0xbd8). Not a boolean, not the toggle target.
|
||||
int requestedOn; // @0x1E0 init 0; THE on/off input consumed by SearchlightSimulation
|
||||
// (@004b841c reads +0x1E0) and toggled by ToggleLamp @004b838c
|
||||
BtAlarm stateAlarm; // @0x1E4 2-level light indicator (size 0x54)
|
||||
|
||||
public:
|
||||
int MountSegmentIndex() const { return mountSegment; } // @0x1DC (see above)
|
||||
int *LightOnPointer() { return &lightState; } // the "LightOn" attribute target
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// BT segment / base-state compatibility shims (CROSS-FAMILY).
|
||||
//
|
||||
|
||||
@@ -587,6 +587,48 @@ void
|
||||
static int s_sweep = 0;
|
||||
analogTwistAxis = ((++s_sweep / 90) & 1) ? -1.0f : 1.0f; // +/- every ~90 frames
|
||||
}
|
||||
// #124 twist-sign bench (BT_FORCE_TWIST=<-1..1>): HOLD the analog axis at a
|
||||
// constant (the sweep above alternates) -- the sim integrates currentTwist
|
||||
// to the per-mech limit and stays, giving the cylinder-table bench a known
|
||||
// twist state at the SIM level, independent of the input plumbing.
|
||||
{
|
||||
static float s_forceTwist = -2.0f;
|
||||
if (s_forceTwist < -1.5f)
|
||||
{
|
||||
const char *ft = getenv("BT_FORCE_TWIST");
|
||||
s_forceTwist = ft ? (float)atof(ft) : -3.0f;
|
||||
}
|
||||
if (s_forceTwist >= -1.0f)
|
||||
{
|
||||
effectiveTwistRate = baseTwistRate;
|
||||
analogTwistAxis = s_forceTwist;
|
||||
}
|
||||
}
|
||||
// #131 LOCK-SWEEP (BT_LOCK_SWEEP=<axis 0..1>, default 0.12): pan the torso
|
||||
// in a slow triangle wave between the twist limits, so the REAL boresight
|
||||
// sweeps across a stand-off target and past both edges -- the operator-
|
||||
// visible lock-envelope instrument (watch the ring light/die; the
|
||||
// [locksweep] log in mech4 records the twist at each lock transition).
|
||||
{
|
||||
static float s_lockSweep = -1.0f;
|
||||
if (s_lockSweep < 0.0f)
|
||||
{
|
||||
const char *lsw = getenv("BT_LOCK_SWEEP");
|
||||
s_lockSweep = lsw ? (float)atof(lsw) : 0.0f;
|
||||
if (lsw != 0 && s_lockSweep <= 0.0f) s_lockSweep = 0.12f;
|
||||
if (s_lockSweep > 1.0f) s_lockSweep = 1.0f;
|
||||
}
|
||||
if (s_lockSweep > 0.0f)
|
||||
{
|
||||
effectiveTwistRate = baseTwistRate;
|
||||
// flip direction at the limits (small margin so we visibly cross
|
||||
// past the target and dwell OFF it at the extremes)
|
||||
static float s_dir = 1.0f;
|
||||
if (currentTwist >= horizontalLimitLeft - 0.05f) s_dir = -1.0f;
|
||||
if (currentTwist <= horizontalLimitRight + 0.05f) s_dir = 1.0f;
|
||||
analogTwistAxis = s_dir * s_lockSweep;
|
||||
}
|
||||
}
|
||||
|
||||
// TORSO GATE PROBE (BT_TORSO_LOG): why is the twist rate zero?
|
||||
if (getenv("BT_TORSO_LOG"))
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Phase 04 — The Export Gap Census (#60)
|
||||
|
||||
**Date:** 2026-08-06 · **Trigger:** export-gap blindness caused 4 wrong conclusions
|
||||
(reconstruction-gotchas §20), 2 falsely [T1]. **Goal:** turn "we trip over gaps when we
|
||||
arrive at them" into a known inventory.
|
||||
|
||||
## Method
|
||||
`tools/gapcensus.py` (deterministic; re-run after any re-export):
|
||||
1. **Index coverage** — `functions_index.tsv` (6,267 functions with sizes) interval-merged
|
||||
against the PE `.text` extent → raw dark regions (bytes belonging to NO indexed function).
|
||||
2. **Export coverage** — `/* @ADDR */` headers in `all/part_*.c` vs the index.
|
||||
3. **Discovery** — function starts inside dark regions from E8 call targets + data-section
|
||||
code pointers (vtables/handler tables/Performance pointers), with caller counts.
|
||||
4. **Annotation** — pad-byte exclusion (CC/90/00), nearest TU attribution from `file=` tags,
|
||||
and `visited` flags (address cited anywhere in game/ context/ docs/).
|
||||
|
||||
## Headline results
|
||||
- `.text` = 892 KB. Index covers **87.3%**. Dark: **428 regions, 105 KB raw / 90 KB real code**.
|
||||
- **Indexed-but-unexported = 0** — the entire gap class is "Ghidra never made it a function"
|
||||
(consistent with #60's original find: VehicleDead was missing from the *index*, not filtered
|
||||
from the export).
|
||||
- 332 function starts discovered inside dark regions (call-graph + data-pointer evidence).
|
||||
- **By TU family (real code):** munga/ 28.5 KB (engine — source in repo, LOW priority),
|
||||
**bt/ 24.0 KB + bt_l4/ 14.9 KB + boundary 15.6 KB = ~54 KB of GAME-side dark**, munga_l4/ 9.5 KB.
|
||||
- **Game-side dark regions: 66 already visited by past digs, 159 never touched.**
|
||||
|
||||
## Validation
|
||||
All six known dark celebrities land inside census regions: the VehicleDead handler
|
||||
(`0x4c05c4`), Searchlight ToggleLamp (`0x4b838c`), the TakeDamage death tail (`0x4a07b8`,
|
||||
inside the famous `0x49ffc8-0x4a1232` stretch — 89 repo citations), the master-perf region
|
||||
(`0x4a9b5a-0x4ab188` — 69 citations; crouch/myomers/posture all came from here), the myomer
|
||||
integrator (`0x4b8be3`), the duck consumer (`0x4aa011`). The two most-cited dark regions are
|
||||
exactly the two that produced the most reconstructions — the census finds the right things.
|
||||
|
||||
## Top uncharted leads (zero citations, real code, spot-checked)
|
||||
1. **`0x480a2a-0x487ca0` cluster (~9 KB across 6 regions), l4splr|btmssn boundary** — head
|
||||
spot-check @0x486467: real `enter 0x3c` function driven by a global-index dispatch table
|
||||
(`[0x4fef88]` → table `0x501588`) — mission-state-machine flavored. Likely the un-exported
|
||||
heart of **BTMission** (round lifecycle / mode carousel).
|
||||
2. **`0x4c08fc-0x4c0bc8` (613 B), btplayer, immediately before the BTPlayer ctor** — real
|
||||
function reading `player+0x24c`; prime suspect for the deferred **mission-review post
|
||||
(id 0x18)** sender and/or death-cycle siblings.
|
||||
3. **`0x4d41ae-0x4ddc7c` btl4app regions (~2.6 KB)** — app/session plumbing (attract/config?).
|
||||
4. **`0x4af691-0x4af9cf` (707 B), heat|mechmppr boundary** — heat-family tail or mapper head.
|
||||
5. **`0x4bdd7b-0x4be078` (673 B), mechweap|btplayer boundary** — weapon-family virtuals.
|
||||
|
||||
## PART 2 — THE RE-EXPORT (same day, 2026-08-06): the dark map, cut in half
|
||||
Installed (no admin, project tool convention `%LOCALAPPDATA%t411-tools`, beside DXSDK/cmake):
|
||||
**JDK 21.0.12** + **Ghidra 12.1.2**. ⚠ Ghidra's launcher `.bat` expands `%JAVA_HOME%`
|
||||
UNQUOTED, so the space in the user profile ("Joe Laptop") kills it — the runner uses 8.3 SHORT
|
||||
paths (`JOELAP~1`, `BT411-~1`). Zips deleted after extract.
|
||||
|
||||
New tooling (committed, repeatable):
|
||||
- `reference/ghidra_scripts/ExportGaps.java` — keeps ExportAll's EXACT output contract (shard
|
||||
format, index columns) so every tool/grep/`@ADDR` citation still works, and adds the gap-fill
|
||||
pass first: force disassembly + `createFunction` at (a) E8 call targets outside any function,
|
||||
(b) data→code pointers at a plausible prologue, (c) the census's discovered starts — iterated
|
||||
to a fixpoint. Logs what it created to `gapfill_report.tsv`.
|
||||
- `tools/ghidra_reexport.sh` — headless runner (`reprocess` re-runs the script on the saved
|
||||
project without re-analysis). `tools/gapdiff.py` — scores any two censused exports.
|
||||
|
||||
**Results (`tools/gapdiff.py`):**
|
||||
| Metric | Before | After |
|
||||
|---|---|---|
|
||||
| Index coverage of .text | 87.3% | **93.5%** |
|
||||
| Dark REAL code | 90.4 KB | **40.8 KB** (−49.5 KB, **54.8% recovered**) |
|
||||
| Game-side dark (bt/ + bt_l4/) | 53.1 KB | **21.1 KB** (−32.0 KB) |
|
||||
| Dark regions | 428 | 321 |
|
||||
| Functions / pseudocode | 6,267 | **6,472** (+205, **0 decompile failures**) |
|
||||
Gap fill converged in 2 rounds (205 created: 195 from census starts, 6 call targets, 4 data
|
||||
pointers) covering 56.1 KB. Ghidra 12 auto-analysis alone also found 42 more than the old export.
|
||||
|
||||
**Every historically-bitten dark function now has pseudocode**, including `@0x4c05c4`
|
||||
VehicleDead — the function whose absence OPENED this issue — plus ToggleLamp `@0x4b838c`, the
|
||||
TakeDamage death tail, the master-perf region (crouch/myomers/posture), the myomer integrator,
|
||||
and the `btplayer` hole `@0x4c0904`.
|
||||
|
||||
### Validation: the re-export CONFIRMS this week's hand reconstructions
|
||||
The master-perf pseudocode matches the crouch transcription field-for-field —
|
||||
`param_1[0xfe]`=mapPosture(0x3f8), `[0xe6]`=duckState(0x398), `[0x161]`=squatCapable,
|
||||
`[0x1e7]`=myomerEffectiveness(0x79c), `[0x10]`=movementMode, the `player+0x25c` novice gate,
|
||||
`FUN_004a7fc4(2/3)`=SetLegAnimation sqd/squ, `FUN_004a4c54(8)+(1)`=ForceUpdate, the
|
||||
`+0x131` stability alarm, and the duckState clear. Independent confirmation of a raw-disasm
|
||||
reconstruction, and of the census method that found it.
|
||||
|
||||
### …and it caught one thing the raw-disasm pass MISSED (fixed same day)
|
||||
The hand pass stopped at `0x4aa0af`; the pseudocode tail carries an **airborne auto-rise**:
|
||||
`if ((mode == 3 || mode == 4) && legState == 1) → SetLegAnimation(3) + ForceUpdate(8)+(1) +
|
||||
stability 1` — a mech that leaves the ground while parked in the squat is forced up,
|
||||
unconditional on duckState/squatCapable. Now in `mech4.cpp`; crouch re-benched un-regressed.
|
||||
|
||||
## Promotion + archive
|
||||
The re-export is now the CANONICAL `reference/decomp/` (index, shards, vtables, census). The
|
||||
previous export is preserved verbatim at **`reference/decomp/archive_2025export/`** so older
|
||||
KB citations of the form `part_0NN.c:LINE` stay resolvable — ⚠ **line numbers and shard
|
||||
membership shifted** in the new export (addresses did not). Cite `@ADDR` (stable), not
|
||||
part/line, going forward.
|
||||
|
||||
## Remaining dark (40.8 KB) — the next leads
|
||||
- `0x480a2a-0x487ca0` (~9 KB, l4splr|btmssn boundary) — still dark even after the fill: no call
|
||||
or data reference reaches it, which itself is informative (dead code? data misparsed as code?
|
||||
a jump-table-only region?). Worth a targeted look with the census's byte-scan.
|
||||
- New from the re-export: **`@0x4c0904` is the MASTER BTPlayer Performance** — team resolution
|
||||
by name ("Team ... does not exist"), the mission-end "Sending EndMission" console post, and
|
||||
the periodic console score heartbeat (corroborating our hand-built one). Our port attributes
|
||||
`@0x4c083c` as PlayerSimulation; that attribution needs a re-check.
|
||||
- The remaining munga/munga_l4 dark (~19 KB) is low priority — we compile the real engine source.
|
||||
|
||||
## Systemic fix status — BOTH HALVES DONE
|
||||
Inventory + re-export shipped. Rule (gotcha §20) stands and is now cheap to obey: check
|
||||
`GAP_CENSUS.md` before any "absent from the export" claim; if still dark, byte-scan.
|
||||
Re-run after any future re-export: `tools/ghidra_reexport.sh` → `tools/gapcensus.py` →
|
||||
`tools/gapdiff.py`.
|
||||
@@ -27,6 +27,16 @@ rem Clear any INHERITED BT_LOG: with it set the exe uses that path verbatim
|
||||
rem and, with no BT_LOG_APPEND, TRUNCATES it -- the exact evidence loss this
|
||||
rem change removes. Empty forces the per-day self-named file + append.
|
||||
set BT_LOG=
|
||||
rem #108 FORENSICS (night-11): every field session records the match ledger
|
||||
rem (matchlog also auto-arms on -net; forcing covers menu-relaunch paths) and
|
||||
rem the score/death chain lines -- the ghost/K-D triage needs them from every
|
||||
rem player, not just the ones we can reach for env setup.
|
||||
set BT_MATCHLOG=1
|
||||
set BT_SCORE_LOG=1
|
||||
set BT_DEATH_LOG=1
|
||||
rem #135: lamp/annunciator edges (techstat) -- one line per state change,
|
||||
rem near-zero noise; would have answered the leak-no-flash report in one grep.
|
||||
set BT_LAMP_LOG=1
|
||||
cd content
|
||||
rem JOIN-GAME menu (2026-07-22): pick your CALLSIGN + MECH, click JOIN --
|
||||
rem your choice is sent to the operator and lands in the mission roster.
|
||||
|
||||
@@ -30,6 +30,16 @@ rem Clear any INHERITED BT_LOG: with it set the exe uses that path verbatim
|
||||
rem and, with no BT_LOG_APPEND, TRUNCATES it -- the exact evidence loss this
|
||||
rem change removes. Empty forces the per-day self-named file + append.
|
||||
set BT_LOG=
|
||||
rem #108 FORENSICS (night-11): every field session records the match ledger
|
||||
rem (matchlog also auto-arms on -net; forcing covers menu-relaunch paths) and
|
||||
rem the score/death chain lines -- the ghost/K-D triage needs them from every
|
||||
rem player, not just the ones we can reach for env setup.
|
||||
set BT_MATCHLOG=1
|
||||
set BT_SCORE_LOG=1
|
||||
set BT_DEATH_LOG=1
|
||||
rem #135: lamp/annunciator edges (techstat) -- one line per state change,
|
||||
rem near-zero noise; would have answered the leak-no-flash report in one grep.
|
||||
set BT_LAMP_LOG=1
|
||||
cd content
|
||||
rem JOIN-GAME menu (2026-07-22): pick your CALLSIGN + MECH, click JOIN --
|
||||
rem your choice is sent to the operator and lands in the mission roster.
|
||||
|
||||
@@ -29,6 +29,16 @@ rem Clear any INHERITED BT_LOG: with it set the exe uses that path verbatim
|
||||
rem and, with no BT_LOG_APPEND, TRUNCATES it -- the exact evidence loss this
|
||||
rem change removes. Empty forces the per-day self-named file + append.
|
||||
set BT_LOG=
|
||||
rem #108 FORENSICS (night-11): every field session records the match ledger
|
||||
rem (matchlog also auto-arms on -net; forcing covers menu-relaunch paths) and
|
||||
rem the score/death chain lines -- the ghost/K-D triage needs them from every
|
||||
rem player, not just the ones we can reach for env setup.
|
||||
set BT_MATCHLOG=1
|
||||
set BT_SCORE_LOG=1
|
||||
set BT_DEATH_LOG=1
|
||||
rem #135: lamp/annunciator edges (techstat) -- one line per state change,
|
||||
rem near-zero noise; would have answered the leak-no-flash report in one grep.
|
||||
set BT_LAMP_LOG=1
|
||||
cd content
|
||||
rem ------------------------------------------------------------------
|
||||
rem OUR GENERATION ONLY (2026-07-27). The wait loop below used to hold
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# GAP CENSUS -- the export dark-region inventory (#60)
|
||||
|
||||
Generated by `tools/gapcensus.py` (deterministic; re-run after any re-export).
|
||||
Machine-readable twin: `gap_census.tsv`.
|
||||
|
||||
## Headline
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Code section (.text) | `0x401000`-`0x4e0000` (892 KB) |
|
||||
| Indexed functions (functions_index.tsv) | 6472, covering 833 KB (93.5%) |
|
||||
| **RAW DARK (no indexed function at all)** | **321 regions >= 16B, 49 KB (5.5%) -- 40 KB REAL CODE after padding** |
|
||||
| Exported pseudocode functions (part_*.c) | 6472 |
|
||||
| **Indexed but NOT exported** (Ghidra knew it; no pseudocode) | **0 functions, 0 KB** |
|
||||
| Function starts discovered inside dark regions | 135 (call-graph + data-pointer evidence) |
|
||||
|
||||
Reading the tables: `visited` = the address is already cited somewhere in
|
||||
game/ context/ docs/ (a prior dig reached it); everything else is UNCHARTED.
|
||||
`tu` = nearest file-tagged export neighbors (`before|after` when they disagree).
|
||||
|
||||
## Dark code by suspected TU family
|
||||
|
||||
| Family | Regions | Real code bytes |
|
||||
|---|---|---|
|
||||
| munga/ (engine core -- source in repo) | 117 | 14544 |
|
||||
| BOUNDARY/mixed | 53 | 10346 |
|
||||
| bt/ (GAME -- reconstruction target) | 60 | 5848 |
|
||||
| munga_l4/ (engine L4 -- source in repo) | 34 | 5563 |
|
||||
| bt_l4/ (GAME video/glue -- reconstruction target) | 57 | 5492 |
|
||||
|
||||
## Top 40 dark regions by REAL CODE bytes (padding excluded)
|
||||
|
||||
| # | Range | Code bytes | Fn starts | Call-ins | Visited | Suspected TU |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | `0x4596ab`-`0x459eb8` | 1795 | 0 | 0 | 1: `4599ae` | munga_l4/l4vidrnd.cpp|munga_l4/l4video.cpp |
|
||||
| 2 | `0x480a2a`-`0x48116d` | 1521 | 0 | 0 | - | munga_l4/l4splr.cpp|bt/btmssn.cpp |
|
||||
| 3 | `0x40c82a`-`0x40cdb4` | 1322 | 0 | 0 | - | munga/scalar.cpp|munga/boxsolid.cpp |
|
||||
| 4 | `0x40b68c`-`0x40bbe8` | 1293 | 0 | 0 | - | munga/scalar.cpp|munga/boxsolid.cpp |
|
||||
| 5 | `0x481f77`-`0x48255c` | 1267 | 0 | 0 | - | munga_l4/l4splr.cpp|bt/btmssn.cpp |
|
||||
| 6 | `0x486467`-`0x486a0e` | 1252 | 0 | 0 | - | munga_l4/l4splr.cpp|bt/btmssn.cpp |
|
||||
| 7 | `0x47ae1b`-`0x47b2ec` | 1060 | 0 | 0 | - | munga_l4/l4ctrl.cpp|munga_l4/l4app.cpp |
|
||||
| 8 | `0x405b1e`-`0x405f44` | 972 | 0 | 0 | - | munga/filestrm.hpp|munga/scalar.cpp |
|
||||
| 9 | `0x427fd0`-`0x4283b8` | 931 | 0 | 0 | - | munga/network.cpp|munga/watcher.cpp |
|
||||
| 10 | `0x4d41ae`-`0x4d45b0` | 765 | 0 | 0 | - | bt_l4/btl4app.cpp|? |
|
||||
| 11 | `0x4af691`-`0x4af9cf` | 707 | 0 | 0 | - | bt/heat.cpp|bt/mechmppr.cpp |
|
||||
| 12 | `0x4829ab`-`0x482c9f` | 687 | 0 | 0 | - | munga_l4/l4splr.cpp|bt/btmssn.cpp |
|
||||
| 13 | `0x487a38`-`0x487ca0` | 615 | 0 | 0 | - | munga_l4/l4splr.cpp|bt/btmssn.cpp |
|
||||
| 14 | `0x483b9b`-`0x483df0` | 520 | 0 | 0 | - | munga_l4/l4splr.cpp|bt/btmssn.cpp |
|
||||
| 15 | `0x4d95b6`-`0x4d9800` | 487 | 0 | 0 | - | bt_l4/btl4app.cpp|? |
|
||||
| 16 | `0x423202`-`0x423414` | 460 | 0 | 0 | - | munga/network.cpp|munga/watcher.cpp |
|
||||
| 17 | `0x45472a`-`0x454924` | 451 | 0 | 0 | - | munga_l4/l4vidrnd.cpp |
|
||||
| 18 | `0x4b8837`-`0x4b8a48` | 448 | 2 | 0 | 1: `4b8837` | bt/powersub.cpp|bt/mechweap.cpp |
|
||||
| 19 | `0x474671`-`0x474855` | 421 | 0 | 0 | - | munga_l4/l4gauge.cpp|munga_l4/l4ctrl.cpp |
|
||||
| 20 | `0x4d2813`-`0x4d29fc` | 414 | 1 | 0 | - | bt_l4/btl4vid.cpp|bt_l4/btl4mssn.cpp |
|
||||
| 21 | `0x4816b2`-`0x48186d` | 404 | 0 | 0 | - | munga_l4/l4splr.cpp|bt/btmssn.cpp |
|
||||
| 22 | `0x42f80b`-`0x42f9bc` | 394 | 0 | 0 | - | munga/player.cpp|munga/explode.cpp |
|
||||
| 23 | `0x443cf5`-`0x443e94` | 380 | 0 | 0 | - | munga/watcher.hpp|munga/gauge.cpp |
|
||||
| 24 | `0x4b2c11`-`0x4b2d8c` | 324 | 0 | 0 | 11: `4b2c2d` `4b2c75` `4b2d00` `4b2d23`... | bt/powersub.cpp|bt/mechweap.cpp |
|
||||
| 25 | `0x4d993f`-`0x4d9c38` | 321 | 12 | 0 | - | bt_l4/btl4app.cpp|? |
|
||||
| 26 | `0x4deec5`-`0x4df048` | 321 | 0 | 0 | - | bt_l4/btl4app.cpp|? |
|
||||
| 27 | `0x4c651c`-`0x4c668d` | 318 | 0 | 0 | - | bt_l4/btl4gaug.cpp|bt_l4/btl4vid.cpp |
|
||||
| 28 | `0x409c41`-`0x409d9c` | 312 | 0 | 0 | - | munga/scalar.cpp|munga/boxsolid.cpp |
|
||||
| 29 | `0x484ed6`-`0x485020` | 307 | 0 | 0 | - | munga_l4/l4splr.cpp|bt/btmssn.cpp |
|
||||
| 30 | `0x43223f`-`0x4323a8` | 306 | 0 | 0 | - | munga/explode.cpp|munga/registry.cpp |
|
||||
| 31 | `0x40f6a7`-`0x40f7d8` | 283 | 0 | 0 | - | munga/boxsolid.cpp|munga/iterator.cpp |
|
||||
| 32 | `0x4b1a84`-`0x4b1bb1` | 251 | 0 | 0 | - | bt/powersub.cpp|bt/mechweap.cpp |
|
||||
| 33 | `0x481565`-`0x481675` | 247 | 0 | 0 | - | munga_l4/l4splr.cpp|bt/btmssn.cpp |
|
||||
| 34 | `0x45888e`-`0x4589a0` | 241 | 0 | 0 | - | munga_l4/l4vidrnd.cpp |
|
||||
| 35 | `0x4d2016`-`0x4d2150` | 241 | 0 | 0 | - | bt_l4/btl4vid.cpp|bt_l4/btl4mssn.cpp |
|
||||
| 36 | `0x466ac2`-`0x466bc4` | 235 | 0 | 0 | - | munga_l4/l4audwtr.cpp|munga_l4/l4audres.cpp |
|
||||
| 37 | `0x4310c3`-`0x4311dc` | 234 | 2 | 0 | - | munga/explode.cpp|munga/registry.cpp |
|
||||
| 38 | `0x466702`-`0x466804` | 234 | 0 | 0 | - | munga_l4/l4audwtr.cpp|munga_l4/l4audres.cpp |
|
||||
| 39 | `0x484076`-`0x48416d` | 229 | 0 | 0 | - | munga_l4/l4splr.cpp|bt/btmssn.cpp |
|
||||
| 40 | `0x480899`-`0x4809ba` | 222 | 0 | 0 | - | munga_l4/l4splr.cpp|bt/btmssn.cpp |
|
||||
|
||||
## Indexed-but-unexported functions (first 60 by address)
|
||||
|
||||
These have index rows (address + size) but no pseudocode in `all/part_*.c` --
|
||||
decompile them individually (raw disasm or a targeted re-export) when a dig arrives.
|
||||
|
||||
| Addr | Size | Index name | Cited in repo |
|
||||
|---|---|---|---|
|
||||
@@ -49,6 +49,24 @@ void __cdecl __GetExceptDLLinfo(undefined4 *param_1)
|
||||
|
||||
|
||||
|
||||
/* @00401074 file=? name=FUN_00401074 */
|
||||
|
||||
int FUN_00401074(void)
|
||||
|
||||
{
|
||||
char *pcVar1;
|
||||
int iVar2;
|
||||
|
||||
pcVar1 = (char *)FUN_004dee74(s_HEAPSIZE_004e0373);
|
||||
if (pcVar1 == (char *)0x0) {
|
||||
return 0x600000;
|
||||
}
|
||||
iVar2 = FUN_004dd1e0(pcVar1);
|
||||
return iVar2;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* @0040109c file=? name=FUN_0040109c */
|
||||
|
||||
undefined4 __cdecl FUN_0040109c(int param_1,undefined4 param_2)
|
||||
@@ -9601,6 +9619,43 @@ float * __cdecl FUN_0040a4d8(float *param_1,float *param_2,float *param_3,float
|
||||
|
||||
|
||||
|
||||
/* @0040a67c file=? name=FUN_0040a67c */
|
||||
|
||||
void __cdecl FUN_0040a67c(int *param_1,float *param_2)
|
||||
|
||||
{
|
||||
float fVar1;
|
||||
float fVar2;
|
||||
float fVar3;
|
||||
float fVar4;
|
||||
int *piVar5;
|
||||
byte bVar6;
|
||||
byte bVar7;
|
||||
byte bVar8;
|
||||
byte bVar9;
|
||||
|
||||
bVar9 = 0x3e;
|
||||
fVar1 = param_2[3];
|
||||
bVar8 = 0x2c;
|
||||
fVar2 = param_2[2];
|
||||
bVar7 = 0x2c;
|
||||
fVar3 = param_2[1];
|
||||
bVar6 = 0x2c;
|
||||
fVar4 = *param_2;
|
||||
piVar5 = FUN_004db92c(param_1,0x3c);
|
||||
piVar5 = FUN_004db634(piVar5,(float10)fVar4);
|
||||
piVar5 = FUN_004db92c(piVar5,bVar6);
|
||||
piVar5 = FUN_004db634(piVar5,(float10)fVar3);
|
||||
piVar5 = FUN_004db92c(piVar5,bVar7);
|
||||
piVar5 = FUN_004db634(piVar5,(float10)fVar2);
|
||||
piVar5 = FUN_004db92c(piVar5,bVar8);
|
||||
piVar5 = FUN_004db634(piVar5,(float10)fVar1);
|
||||
FUN_004db92c(piVar5,bVar9);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* @0040a738 file=? name=FUN_0040a738 */
|
||||
|
||||
undefined4 FUN_0040a738(void)
|
||||
@@ -9897,86 +9952,3 @@ float * __cdecl FUN_0040ac6c(float *param_1,float *param_2)
|
||||
|
||||
|
||||
|
||||
/* @0040ad2c file=? name=FUN_0040ad2c */
|
||||
|
||||
float * __cdecl FUN_0040ad2c(float *param_1,float *param_2)
|
||||
|
||||
{
|
||||
float local_1c;
|
||||
float local_18;
|
||||
float local_14;
|
||||
float local_10;
|
||||
float local_c;
|
||||
float local_8;
|
||||
|
||||
FUN_00408328(&local_c,param_2 + 1);
|
||||
FUN_00408328(&local_14,param_2);
|
||||
FUN_00408328(&local_1c,param_2 + 2);
|
||||
*param_1 = local_c * local_14 * local_1c + local_10 * local_18;
|
||||
param_1[4] = local_8 * local_1c;
|
||||
param_1[8] = local_c * local_10 * local_1c - local_14 * local_18;
|
||||
param_1[1] = local_c * local_14 * local_18 - local_10 * local_1c;
|
||||
param_1[5] = local_8 * local_18;
|
||||
param_1[9] = local_c * local_10 * local_18 + local_14 * local_1c;
|
||||
param_1[2] = local_8 * local_14;
|
||||
param_1[6] = -local_c;
|
||||
param_1[10] = local_8 * local_10;
|
||||
return param_1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* @0040adec file=? name=FUN_0040adec */
|
||||
|
||||
/* WARNING: Globals starting with '_' overlap smaller symbols at the same address */
|
||||
|
||||
float * __cdecl FUN_0040adec(float *param_1,float *param_2)
|
||||
|
||||
{
|
||||
float fVar1;
|
||||
float fVar2;
|
||||
float fVar3;
|
||||
float fVar4;
|
||||
float fVar5;
|
||||
float fVar6;
|
||||
float fVar7;
|
||||
float fVar8;
|
||||
float fVar9;
|
||||
float fVar10;
|
||||
float fVar11;
|
||||
float fVar12;
|
||||
float fVar13;
|
||||
float fVar14;
|
||||
float fVar15;
|
||||
float fVar16;
|
||||
|
||||
fVar1 = *param_2;
|
||||
fVar2 = param_2[1];
|
||||
fVar3 = param_2[1];
|
||||
fVar4 = param_2[2];
|
||||
fVar5 = param_2[2];
|
||||
fVar6 = *param_2;
|
||||
fVar7 = param_2[3];
|
||||
fVar8 = *param_2;
|
||||
fVar9 = param_2[3];
|
||||
fVar10 = param_2[1];
|
||||
fVar11 = param_2[3];
|
||||
fVar12 = param_2[2];
|
||||
fVar13 = param_2[3] * param_2[3];
|
||||
fVar14 = *param_2 * *param_2;
|
||||
fVar15 = param_2[1] * param_2[1];
|
||||
fVar16 = param_2[2] * param_2[2];
|
||||
*param_1 = ((fVar13 + fVar14) - fVar15) - fVar16;
|
||||
param_1[1] = (fVar1 * fVar2 - fVar11 * fVar12) * _DAT_0040aedc;
|
||||
param_1[2] = (fVar5 * fVar6 + fVar9 * fVar10) * _DAT_0040aedc;
|
||||
param_1[4] = (fVar11 * fVar12 + fVar1 * fVar2) * _DAT_0040aedc;
|
||||
param_1[5] = ((fVar13 - fVar14) + fVar15) - fVar16;
|
||||
param_1[6] = (fVar3 * fVar4 - fVar7 * fVar8) * _DAT_0040aedc;
|
||||
param_1[8] = (fVar5 * fVar6 - fVar9 * fVar10) * _DAT_0040aedc;
|
||||
param_1[9] = (fVar3 * fVar4 + fVar7 * fVar8) * _DAT_0040aedc;
|
||||
param_1[10] = ((fVar13 - fVar14) - fVar15) + fVar16;
|
||||
return param_1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
+1717
-631
File diff suppressed because it is too large
Load Diff
+641
-1405
File diff suppressed because it is too large
Load Diff
+1600
-1058
File diff suppressed because it is too large
Load Diff
+1446
-1272
File diff suppressed because it is too large
Load Diff
+1385
-1204
File diff suppressed because it is too large
Load Diff
+1270
-1443
File diff suppressed because it is too large
Load Diff
+1585
-1870
File diff suppressed because it is too large
Load Diff
+2342
-4161
File diff suppressed because it is too large
Load Diff
+4239
-2305
File diff suppressed because it is too large
Load Diff
+3006
-1927
File diff suppressed because it is too large
Load Diff
+1927
-4586
File diff suppressed because it is too large
Load Diff
+5553
-5835
File diff suppressed because it is too large
Load Diff
+8703
-3999
File diff suppressed because it is too large
Load Diff
+4880
-6204
File diff suppressed because it is too large
Load Diff
+7241
-1515
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
# GAP CENSUS -- the export dark-region inventory (#60)
|
||||
|
||||
Generated by `tools/gapcensus.py` (deterministic; re-run after any re-export).
|
||||
Machine-readable twin: `gap_census.tsv`.
|
||||
|
||||
## Headline
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Code section (.text) | `0x401000`-`0x4e0000` (892 KB) |
|
||||
| Indexed functions (functions_index.tsv) | 6267, covering 778 KB (87.3%) |
|
||||
| **RAW DARK (no indexed function at all)** | **428 regions >= 16B, 105 KB (11.9%) -- 90 KB REAL CODE after padding** |
|
||||
| Exported pseudocode functions (part_*.c) | 6267 |
|
||||
| **Indexed but NOT exported** (Ghidra knew it; no pseudocode) | **0 functions, 0 KB** |
|
||||
| Function starts discovered inside dark regions | 332 (call-graph + data-pointer evidence) |
|
||||
|
||||
Reading the tables: `visited` = the address is already cited somewhere in
|
||||
game/ context/ docs/ (a prior dig reached it); everything else is UNCHARTED.
|
||||
`tu` = nearest file-tagged export neighbors (`before|after` when they disagree).
|
||||
|
||||
## Dark code by suspected TU family
|
||||
|
||||
| Family | Regions | Real code bytes |
|
||||
|---|---|---|
|
||||
| munga/ (engine core -- source in repo) | 150 | 28540 |
|
||||
| bt/ (GAME -- reconstruction target) | 87 | 23954 |
|
||||
| BOUNDARY/mixed | 59 | 15581 |
|
||||
| bt_l4/ (GAME video/glue -- reconstruction target) | 80 | 14916 |
|
||||
| munga_l4/ (engine L4 -- source in repo) | 52 | 9529 |
|
||||
|
||||
## Top 40 dark regions by REAL CODE bytes (padding excluded)
|
||||
|
||||
| # | Range | Code bytes | Fn starts | Call-ins | Visited | Suspected TU |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | `0x412e41`-`0x4146dc` | 5936 | 6 | 0 | - | munga/boxsolid.cpp|munga/iterator.cpp |
|
||||
| 2 | `0x4a9b5a`-`0x4ab188` | 4957 | 1 | 0 | 69: `4a9b5a` `4a9b5c` `4a9b6c` `4a9bff`... | bt/mech2.cpp|bt/heat.cpp |
|
||||
| 3 | `0x486467`-`0x487788` | 4265 | 0 | 0 | - | munga_l4/l4splr.cpp|bt/btmssn.cpp |
|
||||
| 4 | `0x49ffc8`-`0x4a1232` | 4116 | 5 | 2 | 89: `49ffc8` `49ffcc` `49ffde` `49ffff`... | bt/btmssn.cpp|bt/mech.cpp |
|
||||
| 5 | `0x4cdce5`-`0x4ceecc` | 3831 | 1 | 0 | 18: `4cdcf0` `4cdd75` `4cdd9d` `4cddc1`... | bt_l4/btl4vid.cpp |
|
||||
| 6 | `0x4a68ce`-`0x4a71f4` | 1822 | 2 | 0 | 44: `4a6928` `4a6989` `4a69aa` `4a69d6`... | bt/mech2.cpp |
|
||||
| 7 | `0x4596ab`-`0x459eb8` | 1795 | 0 | 0 | 1: `4599ae` | munga_l4/l4vidrnd.cpp|munga_l4/l4video.cpp |
|
||||
| 8 | `0x480a2a`-`0x48116d` | 1521 | 0 | 0 | - | munga_l4/l4splr.cpp|bt/btmssn.cpp |
|
||||
| 9 | `0x42f3fe`-`0x42f9bc` | 1341 | 1 | 0 | - | munga/player.cpp|munga/explode.cpp |
|
||||
| 10 | `0x40c82a`-`0x40cdb4` | 1322 | 0 | 0 | - | munga/scalar.cpp|munga/boxsolid.cpp |
|
||||
| 11 | `0x464bf6`-`0x4651ac` | 1312 | 2 | 0 | - | munga_l4/l4audio.cpp|munga_l4/l4audlvl.cpp |
|
||||
| 12 | `0x40b68c`-`0x40bbe8` | 1293 | 0 | 0 | - | munga/scalar.cpp|munga/boxsolid.cpp |
|
||||
| 13 | `0x481f77`-`0x48255c` | 1267 | 0 | 0 | - | munga_l4/l4splr.cpp|bt/btmssn.cpp |
|
||||
| 14 | `0x47ae1b`-`0x47b2ec` | 1060 | 0 | 0 | - | munga_l4/l4ctrl.cpp|munga_l4/l4app.cpp |
|
||||
| 15 | `0x4dd80a`-`0x4ddc7c` | 1030 | 0 | 0 | - | bt_l4/btl4app.cpp|? |
|
||||
| 16 | `0x405b1e`-`0x405f44` | 972 | 0 | 0 | - | munga/filestrm.hpp|munga/scalar.cpp |
|
||||
| 17 | `0x427fd0`-`0x4283b8` | 931 | 0 | 0 | - | munga/network.cpp|munga/watcher.cpp |
|
||||
| 18 | `0x4cba73`-`0x4cbea0` | 904 | 1 | 0 | 1: `4cbaf0` | bt_l4/btl4gaug.cpp|bt_l4/btl4vid.cpp |
|
||||
| 19 | `0x4be3cf`-`0x4be7c4` | 897 | 1 | 0 | 1: `4be474` | bt/mechweap.hpp|bt/btplayer.cpp |
|
||||
| 20 | `0x4b297b`-`0x4b2d8c` | 894 | 1 | 1 | 30: `4b2980` `4b298c` `4b299e` `4b29bd`... | bt/powersub.cpp|bt/mechweap.cpp |
|
||||
| 21 | `0x4da08f`-`0x4da44c` | 866 | 4 | 0 | - | bt_l4/btl4app.cpp|? |
|
||||
| 22 | `0x4147f9`-`0x414b60` | 789 | 4 | 0 | - | munga/boxsolid.cpp|munga/iterator.cpp |
|
||||
| 23 | `0x4d41ae`-`0x4d45b0` | 765 | 0 | 0 | - | bt_l4/btl4app.cpp|? |
|
||||
| 24 | `0x412940`-`0x412c78` | 760 | 4 | 0 | - | munga/boxsolid.cpp|munga/iterator.cpp |
|
||||
| 25 | `0x4af691`-`0x4af9cf` | 707 | 0 | 0 | - | bt/heat.cpp|bt/mechmppr.cpp |
|
||||
| 26 | `0x4bc0fd`-`0x4bc3fc` | 693 | 1 | 0 | 6: `4bc100` `4bc104` `4bc136` `4bc194`... | bt/mechweap.hpp|bt/btplayer.cpp |
|
||||
| 27 | `0x4829ab`-`0x482c9f` | 687 | 0 | 0 | - | munga_l4/l4splr.cpp|bt/btmssn.cpp |
|
||||
| 28 | `0x4bdd7b`-`0x4be078` | 673 | 4 | 0 | - | bt/mechweap.hpp|bt/btplayer.cpp |
|
||||
| 29 | `0x4105ff`-`0x4108b4` | 635 | 4 | 0 | - | munga/boxsolid.cpp|munga/iterator.cpp |
|
||||
| 30 | `0x487a27`-`0x487ca0` | 632 | 0 | 0 | - | munga_l4/l4splr.cpp|bt/btmssn.cpp |
|
||||
| 31 | `0x4221b9`-`0x42249c` | 631 | 2 | 0 | - | munga/network.cpp|munga/watcher.cpp |
|
||||
| 32 | `0x4c08fc`-`0x4c0bc8` | 613 | 1 | 0 | - | bt/btplayer.cpp|bt_l4/btl4rdr.cpp |
|
||||
| 33 | `0x4bbcfd`-`0x4bbfcc` | 607 | 2 | 0 | 41: `4bbd04` `4bbd12` `4bbd1b` `4bbd28`... | bt/mechweap.hpp|bt/btplayer.cpp |
|
||||
| 34 | `0x4c05be`-`0x4c083c` | 552 | 1 | 0 | 22: `4c05c0` `4c05c4` `4c05df` `4c0611`... | bt/btplayer.cpp|bt_l4/btl4rdr.cpp |
|
||||
| 35 | `0x4bfca1`-`0x4bfee0` | 535 | 1 | 0 | 1: `4bfcac` | bt/mechweap.hpp|bt/btplayer.cpp |
|
||||
| 36 | `0x483b9b`-`0x483df0` | 520 | 0 | 0 | - | munga_l4/l4splr.cpp|bt/btmssn.cpp |
|
||||
| 37 | `0x4b8837`-`0x4b8a8c` | 509 | 3 | 0 | 6: `4b8837` `4b8a48` `4b8a50` `4b8a5b`... | bt/powersub.cpp|bt/mechweap.cpp |
|
||||
| 38 | `0x4c11b3`-`0x4c1408` | 499 | 4 | 0 | 3: `4c11b3` `4c1230` `4c13d4` | bt/btplayer.cpp|bt_l4/btl4rdr.cpp |
|
||||
| 39 | `0x47b8e2`-`0x47bb20` | 488 | 1 | 0 | - | munga_l4/l4app.cpp|munga_l4/l4splr.cpp |
|
||||
| 40 | `0x4d95b6`-`0x4d9800` | 487 | 0 | 0 | - | bt_l4/btl4app.cpp|? |
|
||||
|
||||
## Indexed-but-unexported functions (first 60 by address)
|
||||
|
||||
These have index rows (address + size) but no pseudocode in `all/part_*.c` --
|
||||
decompile them individually (raw disasm or a targeted re-export) when a dig arrives.
|
||||
|
||||
| Addr | Size | Index name | Cited in repo |
|
||||
|---|---|---|---|
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user