diff --git a/context/decomp-reference.md b/context/decomp-reference.md index 66f8830..6868919 100644 --- a/context/decomp-reference.md +++ b/context/decomp-reference.md @@ -289,6 +289,20 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a **Proximity fuse**: seeker rangeToTarget(+0x10C) < `_DAT_004bf5a4` = **4.0** → detonate on targetEntity without a geometry hit. Seeker drops a destroyed target (movementMode 2|9, FUN_0049fb54). +- **Missile FLIGHT MODEL (velocity/dt audit 2026-08-13) [T1]:** per frame: + `MissileThrusterSimulation` **@004be474** (the standalone Performance body — recovered; + misthrst.cpp's "folded" note was stale) slerps the missile toward the seeker aim + ≤ MaxThrusterRotationRate·dt and, while burning, adds `(0,0,−ThrusterAccel)` to the + frame-zeroed accumulator; @4bef78 adds quadratic drag `−COD·ρ·sign(v)·v²` per body axis + (burn AND coast; the dead plain-Projectile @4bddec uses power **1.0** via + ApplyAirResistanceAndGravity(1.0)), zeroes LATERAL local vel/accel while burning (velocity + slaved to the nose; slaving STOPS at burnout), applies gravity (env, default **6.5**) ONLY + when coasting, then integrates ×dt (@00421bac). Net: `dv/dt = T − COD·v²`. **Authored + CODs = 0.001 ALL axes, all four models** (BTL4.RES type-15 rid 315 srm / 319 lrm / + 323 strk / 327 nrk, raw floats); ctor @4bf5b4 (part_013.c:18416) precomputes **terminal + speed `sqrt(T/negCOD.z)` → missile+0x348** (the seeker lead-time base): LRM/Streak/NRK + **547.7 u/s**, SRM **774.6**. Port pool carries the same ODE since the 2026-08-13 drag + fix (mech4.cpp; drag was previously omitted as "negligible" — it is the speed governor). - **Performance split** (Projectile ctor @4be1bc / Missile ctor @4bf5b4): instance (flags&0xC)==ReplicantInstance(4) → FUN_004221c0 dead-reckoning smoother (no physics); master → 4bddec/4bef78. (CLASSMAP's old authoritative/ghost labels were swapped — @@ -603,9 +617,14 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a (`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 + per-frame term into PERSISTENT state (myomer kinetic @4b8d18's middle term) + fires at the FRAME cadence = the tick rate, 28 Hz nominal, sagging under load. + ⚠ The Mover gravity `-= **(mover+0x250)` @0x421e77 is NOT in that class + (corrected 2026-08-13, velocity/dt audit): `Mover::PerformAndWatch` = + **`FUN_00422360`** zeroes `localAcceleration` every frame (== T0 + MOVER.cpp:672), so gravity/drag/thrust adds define a fresh per-frame + ACCELERATION integrated ×dt by `ApplyWorldAccelerations` @00421bac — + frame-rate-independent, no 28 Hz rescale. 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 diff --git a/context/open-questions.md b/context/open-questions.md index bffe156..aecddea 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -194,7 +194,8 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR - **Environment gravity unwired — the myomer CLIMB-heat term is inert (found 2026-07-31, #85).** `Mover::localEnvironment` (MOVER.h:271) is declared and never populated anywhere in the port; `GetEnvironment()->gravityConstant` is the named analog of the original Mover's gravity POINTER at - `+0x250` (`FUN_00421e2c` does `vy -= **(+0x250)` per tick), which the myomer drive-heat + `+0x250` = `localEnvironment` (MOVER.h; `FUN_00421e2c` = ApplyAirResistanceAndGravity does + `worldAccel.y -= **(+0x250)` each frame onto the frame-zeroed accumulator), which the myomer drive-heat integrator uses for its `m·g·|vy|·dt` climb-work term. The bridge (`BTMechMyomerMotionSample`) null-guards it, so climbing currently generates no myomer heat (flat maps: moot; cavern slopes: a missing cost). EnvironmentZone resources (RES type 23) exist in BTL4.RES — wiring them (or at @@ -715,12 +716,19 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR 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] +- **✅ Engine Mover gravity needs NO 28Hz rescale — CLOSED 2026-08-13 (velocity/dt audit) [T1/T0].** + The worry was that the per-frame `worldAccel.y -= **(+0x250)` (@0x421e77, no dt) would scale + with our frame rate. It cannot: **`Mover::PerformAndWatch` = `FUN_00422360` ZEROES + `localAcceleration` at the top of every frame** (`FUN_0040a7f4(+0x1dc, &DAT_004e0fd4)` == + T0 MOVER.cpp:672 `localAcceleration = Motion::Identity`), so gravity/drag/thrust adds are + per-frame *acceleration definitions* on a fresh accumulator — a constant `a` integrated ×dt + by `ApplyWorldAccelerations` @00421bac (`v += a·dt`), correct at ANY frame rate. The port's + linked MOVER.cpp has the identical structure. (Distinct from the myomer per-tick accumulates, + which land in PERSISTENT state and did need the ×(dt·28) fix.) The separate gap — the port's + `GetEnvironment()->gravityConstant` sampling 0 (unwired Environment, entry above) — is still + open; note the target constant is **6.5** (LATTICE.cpp:413 default), not 9.8 (9.8 appears only + in the AC tracer fire-control math @0x4bc67c). Full audit: + `docs/WEAPONS_DRIFT_AUDIT.md` §VELOCITY/DT INTEGRATION. - **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 diff --git a/context/pod-hardware.md b/context/pod-hardware.md index 428a55a..bed2989 100644 --- a/context/pod-hardware.md +++ b/context/pod-hardware.md @@ -635,6 +635,14 @@ CONCLUSION: the coolant-loop reconstruction is faithful; every difference is 4.0 (loadout rework on Loki, small-laser redistribution elsewhere), NOT a bug. `BT_SPEC_LOG` (mech4) re-dumps on demand. +### Weapon-range cross-check RESULT (2026-08-13) [T1] — manual prints only 4 range figures +The manual has NO weapons table and prints NO per-weapon damage number anywhere; the per-mech +stat sheets carry no range/damage column. The only printed ranges are MFD screenshot mockups: +PPC 900M (p20) and AFC100 400M (p21) MATCH the shipped 4.10 content; SRM4/SRM6 450M (p22/p23) +vs the shipped 800 is the same 4.0→4.10 drift class as the Loki rework (and missile WeaponRange +is display-only — no physics change either way). Full table + the rangefinder-scale verdict: +`docs/WEAPONS_DRIFT_AUDIT.md` §RANGE AUTHENTICITY. + ## Key Relationships - Renders: [[gauges-hud]] (the MFD surfaces), [[rendering]] (the main 3D view). - Input: [[locomotion]] (the mapper). diff --git a/context/subsystems.md b/context/subsystems.md index 5666408..7a6508a 100644 --- a/context/subsystems.md +++ b/context/subsystems.md @@ -122,7 +122,10 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER (mech4.cpp): `+0x1C4` vec = `localVelocity.linearMotion`; `+0x82C` vec = `localAcceleration.linearMotion` (the authentic 15-ring smoothed AccelerationLastFrame); `+0x20C` = `moverMass` (the collision divert's cell — the old "motion gain" label was wrong); - `*(+0x250)` = the environment **gravity pointer** (`FUN_00421e2c` does `vy -= **(+0x250)`/tick). + `*(+0x250)` = the environment **gravity pointer** (= `Mover::localEnvironment`, MOVER.h; + `FUN_00421e2c` = ApplyAirResistanceAndGravity does `worldAccel.y -= **(+0x250)` each frame — + onto an accumulator `Mover::PerformAndWatch` @00422360 zeroes every frame, so it is a constant + acceleration integrated ×dt, NOT an accumulating per-tick term; velocity/dt audit 2026-08-13). Physics: `heat += ratio²·(1+dmg)·[0.005·½mv² + 0.2·m·g·|vy|·dt + 0.2·m|v||a|·dt]` — kinetic work + climb power + acceleration power. The binary `fabs`es v.y (the port's old draft didn't). **Authored tuning extracted from BTL4.RES** (all 18 mech variants share one record): diff --git a/docs/WEAPONS_DRIFT_AUDIT.md b/docs/WEAPONS_DRIFT_AUDIT.md index f13563e..437fc9c 100644 --- a/docs/WEAPONS_DRIFT_AUDIT.md +++ b/docs/WEAPONS_DRIFT_AUDIT.md @@ -382,3 +382,207 @@ tap-eject unjam, NoAmmo latch with ammo remaining (§9); gear-wrap brick (§2); incoming-missile warning (§10); generator switching mid-charge, out-of-range beam (§1); rack-name quirks, homing agility + Narc-did- nothing (§8). + +--- + +## RANGE AUTHENTICITY — printed manual cross-check + the rangefinder scale (2026-08-13) + +**Why:** playtesters report ranges "feel wrong" and hits landing "beyond +the maximum range on the range scale." Two legs: (1) diff every weapon +range/damage number the original player manual prints against the +authored content values (Appendix A of the field guide — this audit's +numbers table); (2) pin down how the HUD rangefinder's displayed maximum +relates to authored WeaponRange. + +### Leg 1 — the manual cross-check + +Source: `reference/manual/Tesla40_BT_manual.pdf` (34 pp). Finding about +the source itself [T1 — full-text sweep + page renders, this audit]: the +manual prints **no weapons table**. The per-mech stat sheets (pp. 25-29) +carry loadouts, coolant loops, tonnage/armor/reservoir/speeds — no +per-weapon range or damage column. The ONLY printed range figures are +MFD screenshot mockups (eng-data panels), four values total; **no +per-weapon damage number appears anywhere in the manual** (the p. 4 +scoring page says damage is delivered "the same way their BattleTech +boardgame counterparts do" — a fidelity claim with no numbers; the +authored 4.10 values do NOT numerically track boardgame values, e.g. +PPC 12 vs the boardgame's 10 — treat the sentence as blurb, not spec). + +| Weapon | MANUAL prints | CONTENT authored | Verdict | +|---|---|---|---| +| PPC | RANGE 900M (p. 20, PPC eng-data screen) | 900 | **MATCH** | +| AFC100 | RANGE 400M (p. 21, AFC eng-data screen) | 400 | **MATCH** | +| SRM4 | RANGE 450M (p. 22, Engineering MFD mockup) | 800 (display-only) | **MISMATCH** | +| SRM6 | RANGE 450M (p. 22 AND p. 23, two independent screens) | 800 (display-only) | **MISMATCH** | +| SLaser | — | 1.5 dmg / 150 | manual-silent | +| ERSLaser | — | 2 / 225 | manual-silent | +| MLaser | — | 2.5 / 350 | manual-silent | +| ERMLaser | — | 3.5 / 500 | manual-silent | +| LLaser | — | 5 / 600 | manual-silent | +| ERLLaser | — | 6 / 750 | manual-silent | +| ERPPC | — | 16 / 950 | manual-silent | +| AFC25 | — | 7 / 900 | manual-silent | +| AFC50 | — | 13 / 750 | manual-silent | +| GAUSS | — | 20 / 900 | manual-silent (pip-color note below) | +| LRM5/10/15/20, NRK5 | — | 6000 (display-only) | manual-silent | +| ALL weapons, damage | — (zero numbers printed) | Appendix A | manual-silent | + +All four manual figures verified by page RENDER, not OCR alone (the +450M reads twice on p. 22 and once on p. 23) [T1]. + +**The SRM mismatch read:** 450 (manual) vs 800 (shipped 4.10 content, +BLH live pip dump [T2] + field-guide Appendix A). Two mitigations: +(a) the manual's panels are mockups from the earlier software revision — +the same 4.0→4.10 drift already proven on the Loki loadout rework, +the small-laser loop redistribution, and supercharge +(`context/pod-hardware.md` §Manual); (b) for MISSILE racks, +WeaponRange is fire-control display ONLY (lane 10 [T1]) — real reach is +flight physics on both values, so the 450→800 change altered the pip +position and panel text, never a trajectory. Verdict: authoring drift +between revisions, NOT a port defect; the port displays the shipped +4.10 content values. + +**Pip-color side note:** manual p. 10 codes the readiness pips PPC=Blue, +Laser=Red, LRM=Green, SRM=Brown, AFC=Orange, **Gauss=Grey** — blue/red +match our dumps exactly, SRM "brown" is the authored amber (0.6,0.4,0), +but the shipped GAUSS record authors a YELLOW pip (lane 8 [T1]). Same +drift class as the SRM range; cosmetic. + +### Leg 2 — the rangefinder scale (what the ladder max actually is) + +The tester model behind the complaint — "the scale maxes at the selected +weapon's range" — is FALSE in both the binary and the port. What the +HUD right ladder actually is (full detail `context/gauges-hud.md` +§Cockpit HUD reticle): + +- **The ladder is a FIXED 0–1200 m scale** — the ctor's hardcoded + calibration (@004cc40c, ctor param 11 = 0x44960000) [T1]; port + identical (`btl4vid.cpp:2382` kRetMaxRange = 1200.0f). It is never + derived from any weapon's WeaponRange or live effectiveRange. +- **Each weapon marks its own reach as a PIP** at its authored + WeaponRange, CLAMPED into [0..1200] (`AddWeapon` @004cdac0; port + transcription `btl4vid.cpp:3139-3147`) [T1]. Caret below a pip = that + weapon reaches the target. An LRM/NRK rack authored 6000 therefore + pins its pip AT THE LADDER TOP — authentic on both sides. +- **The caret is the displayed target range**, sliding at 500 m/s toward + the true pick range (HudSimulation part_013.c:5652 [T1]; port + mech4.cpp targeting step [T2]); pegs at 1200 with no target. During + depth-discontinuity walks the displayed value legitimately lags true + range by hundreds of meters (the Gitea #4 measurement). +- **The ONE displayed-range divergence:** the binary subtracts + `_DAT_004b7ecc` = 100.0f from RangeToTarget (@0x1EC) every frame the + timed flag @0x22C is set (timer @0x21C to limit @0x1D8) — the port + does NOT implement this state (`hud.cpp:79-82` tracks it; + `context/open-questions.md` §HUD range-bias) [T1 read, unimplemented]. + Direction: the shipped machine UNDER-reads range by up to 100 m in + that state; the port never does. So when the state is active a + true-850 m missile hit against an 800 pip DISPLAYED as in-range on + the pod but displays as beyond-the-pip in the port — a bounded + (≤100 m) port-side contributor to "hit beyond the scale" reports. + Whether the state ever triggers in the field is unknown (what sets + @0x22C is the open question) [T4 for field prevalence]. + +**Rangefinder verdict:** the displayed scale is AUTHENTIC — no port +miscalibration. "Hits beyond the maximum range on the range scale" is +expected, authentic behavior with three drivers, largest first: +(1) missile reach is flight physics, not WeaponRange — SRMs genuinely +hit past their 800 pip and LRMs past the 1200 ladder top [T1]; +(2) energy/AC pips mark AUTHORED range while the live fire gate is +effectiveRange = (1 − zoneDamage) × authored — a damaged weapon's real +reach sits BELOW its pip, never above, so those lanes cannot produce +beyond-pip hits; (3) the 500 m/s caret slide lags true range during +fast picks. The single true divergence is the unimplemented −100 m +bias state (above) — worth reconstructing if Friday's reports cluster +in the 0–100 m band past a pip; anything farther past the pip is +missiles being missiles. + +**Guide action:** MISMATCHES exist (SRM display ranges + the Gauss pip +color) → per the audit rule the field guide was NOT touched; no +reassurance paragraph shipped. + +--- + +## VELOCITY/DT INTEGRATION — term-by-term audit (2026-08-13) + +**Scope:** the projectile velocity integration, binary vs port pool +(`mech4.cpp BTUpdateProjectiles` / `BTPushProjectile`), plus the AC tracer +launch-speed constants. Binary side read end-to-end this audit: +Missile::MoveAndCollide @004bef78, Seeker::FindTarget @004be9a0 / +LeadTarget @004beae4, **MissileThrusterSimulation @004be474 — the +standalone Performance body, RECOVERED** (misthrst.cpp's "folded into +MoveAndCollide, no distinct @ADDR survives" note predated the 2026-08-06 +re-export and is corrected), Missile ctor @004bf5b4, launch composer +@004bcc60, and the engine Mover lane, decomp↔T0-source matched +line-for-line: **FUN_00422360 = Mover::PerformAndWatch**, +FUN_00421bac = ApplyWorldAccelerations, FUN_00421e2c = +ApplyAirResistanceAndGravity(power), FUN_00421ca8 = CalculateDrag, +FUN_00421b2c/FUN_00421b6c = UpdateWorldMotion/UpdateLocalMotion; +mover+0x250 = localEnvironment, +0x254 = deadReckoner (MOVER.h/.cpp). + +**The dt model [T1/T0]:** every Performance slice is variable-step REAL +SECONDS (the #96 sim-time result), and `Mover::PerformAndWatch` +**ZEROES `localAcceleration` at the top of every frame** +(@00422360: `FUN_0040a7f4(+0x1dc, &DAT_004e0fd4)` == T0 MOVER.cpp:672 +`localAcceleration = Motion::Identity`). Thrust, drag and gravity are +re-added FRESH each frame and then integrated ×dt (@00421bac: +`p += v·dt + ½a·dt²; v += a·dt`). Net missile physics is the clean ODE +`dv/dt = ThrusterAccel − COD·ρ·v²` — **there is NO myomer-style 28 Hz +per-tick accumulate anywhere in this lane**, so the port's +`speed += accel·dt` needs no ×(dt·28) rescale. (The myomer idiom applies +to PERSISTENT accumulators; the Mover acceleration is not one.) + +**The headline: drag was wrongly waved off.** All four missile models +author linear drag CODs **0.001 on every axis** (BTL4.RES type-15 raw +floats read this audit: srm rid=315 / lrm 319 / strk 323 / nrk 327; +airDensity = 1.0, engine default). 0.001 multiplies **v²**: it is the +flight model's GOVERNOR, and the binary says so itself — the Missile +ctor @004bf5b4 (part_013.c:18416) **precomputes terminal speed +`sqrt(ThrusterAccel / negCOD.z)` into missile+0x348** and the seeker +uses it as the lead time base. Terminal speeds: LRM/Streak/NRK +**547.7 u/s**, SRM **774.6 u/s**. The port pool (per #84's "drag +~0.001 = negligible") flew undamped: an LRM reached **3030 u/s** by +burnout — 5.5× the binary cap. Simulated head-to-head (28 Hz binary +loop vs the port step): 800u arrival LRM 2.54 s @ 492 u/s (binary) vs +2.22 s @ 695 (undamped port); at t=5 s the undamped LRM had covered +3912u @ 1530 u/s vs the binary's 2108u @ 544. **Fixed this build**: +the pool integrates `speed += (accel_burn − 0.001·speed²)·dt` for +thruster rounds, burn AND coast (a burned-out round decelerates, +`v(t) = v0/(1+0.001·v0·t)` — Streak at t=5 s: 256 u/s, was 1005 +frozen). AC tracers (p.accel==0) untouched — the binary's visible AC +round is a renderer cosmetic with no Mover physics. + +| Term | Binary [T1] | Port (pre-audit) | Verdict | Player-visible consequence | +|---|---|---|---|---| +| dt model | variable slice, SECONDS; accel accumulator zeroed/frame (@00422360); integrate ×dt (@00421bac) | `speed += accel·dt`, `pos += vel·dt` | **FAITHFUL** (no per-tick accumulate here) | none | +| Launch velocity | authored MuzzleVelocity (z negated) + shooter velocity (@004bcc60 / FUN_004b9cbc) | same (#67) | **FAITHFUL** | none | +| Launch acceleration | ZERO in the make message (@004bcc60 authors the DAT_004e0fd4 zero Motion) | thrust applies from frame 1 (binary thruster adds the same frame) | **FAITHFUL** | none | +| Thrust | @004be474: while burning, `burnLeft −= dt`, `localAccel += (0,0,−ThrusterAccel)` onto the fresh accumulator ⇒ constant accel | `speed += accel·burn_dt` | **FAITHFUL** | none | +| **Drag** | `−COD·ρ·sign(v)·v²` per body axis, EVERY frame, burn + coast (@004bef78, power-2 inline; plain-Projectile dead class uses power 1.0 @004bddec); CODs 0.001 [T1 bytes]; terminal `sqrt(T/c)` precomputed @004bf5b4→+0x348 | **OMITTED** ("negligible") | **WAS DIVERGENT → FIXED-THIS-BUILD** | was: LRM 3030 u/s (5.5× cap), −13% time-to-800u, +40% impact speeds, map-crossing runaways past 2.5 s; now governed at 547.7/774.6 | +| Speed clamp | none — drag IS the clamp | none — drag now is | **FAITHFUL** (post-fix) | — | +| Velocity↔seeker slaving | burning: lateral local vel/accel zeroed each frame ⇒ velocity ≡ speed×nose; nose slerps ≤ MaxThrusterRotationRate·dt (@004be474); at burnout slaving STOPS (ballistic coast) | rotate-toward-aim at port gains 4/8·dt, speed-normalized; steers through coast | KNOWN-DIVERGENCE [T3] (lane 11) | turn dynamics shape; coast homing (real impacts are in-burn) | +| Gravity | none during burn; coast: `worldAccel.y −= 6.5` fresh each frame (env default) + y<−1 kill plane | absent (loft arc + ttl only) | DIVERGENT [T3 **minor**, unfixed] | post-burnout flights only (beyond ~1400u SRM / ~4800u LRM); missed rounds fly level instead of dropping | +| Position step | `p += v_old·dt + ½a·dt²` | `p += v_new·dt` (semi-implicit) | FAITHFUL-approx | ≤ ~12u lead over a full SRM burn @60fps; imperceptible | +| Expiry | burn+10 s, y<−1 (@004bef78 tail) | same (#168) | **FAITHFUL** | none | +| Seeker lead | `aim += targetVel × range/terminal(+0x348)`; loft climb-gated (thruster+0xF0 > _DAT_004bec1c) | live-position re-lead; loft constants exact | KNOWN-DIVERGENCE [T3] (lane 11) | small lead-shape difference | +| AC tracer speed | `launchVelocity.z = −effRange/sqrt(2.0·(1/9.8)·muzzleHeight)` @004bc3fc:16157-16164 (bytes @0x4bc678 = `00000040` 2.0f; @0x4bc67c = `8a6c379943c6fad0fb3f` 1/9.8 x87 EXTENDED) | 1.0f stand-ins, no muzzleHeight | **WAS DIVERGENT (cosmetic) → FIXED-THIS-BUILD** | tracer pace now the authentic fall-time scale (~1 s to effRange at a ~5u mount) | + +**Port changes landed (this audit, build clean):** +1. `mech4.cpp BTUpdateProjectiles` — quadratic drag `0.001·v²` on thruster + rounds, burn and coast; stale "drag negligible / range-capped" comment + replaced with the byte-cited model. +2. `projweap.cpp` — `_DAT_004bc678 = 2.0f`, `_DAT_004bc67c = 1/9.8` + (bytes above), muzzleHeight term restored via + `ResolveLaunchVelocity()` (deferred to first use — the port ctor runs + mid-roster-build; z==0 sentinel; complete-Mech-TU bridge + `BTWeaponMountHeight` in mech4.cpp). Stale "unresolved" constant + comments, the :990 "#168 flight-contact model" banner, and the header + "FireWeapon BODY NOT recovered" line all swept. +3. `misthrst.cpp` — banner corrected: @004be474 IS the standalone + MissileThrusterSimulation (steer + burn + thrust-add), recovered. + +**KB corollary (swept):** the "Mover gravity is dt-less per-frame → may +need 28 Hz rescale at our frame rate" open question is CLOSED-NO: the +per-frame gravity/drag adds land on an accumulator that PerformAndWatch +zeroes every frame — they are per-frame *acceleration definitions*, not +accumulating increments, and integrate ×dt correctly at ANY frame rate. +(The port's separate "environment gravity reads 0" gap stays open.) diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index a9144f4..0201782 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -817,6 +817,28 @@ void out = m->localOrigin.linearPosition; // safe non-garbage fallback (owner origin) } +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// BTWeaponMountHeight -- the @004bc3fc ctor's muzzle-height operand (velocity/ +// dt integration audit 2026-08-13): the ProjectileWeapon ctor computes +// launchVelocity.z = -effRange / sqrt((1/9.8) * 2.0 * muzzleTransform.y) +// (@004bc3fc part_013.c:16157-16164 -- FUN_004b9948 muzzle transform, then +// FUN_0040a968 extracts the translation and local_5c = its Y). The binary +// takes that Y at CTOR time = the bind-pose mount height; the port resolves +// lazily at first use (ctor runs mid-roster-build), so return the mount's +// height ABOVE THE MECH ORIGIN -- the same quantity for a standing mech, +// and terrain-independent. Complete-Mech-TU bridge per the databinding rule. +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Scalar + BTWeaponMountHeight(void *ownerMech, int segIndex) +{ + Mech *m = (Mech *)ownerMech; + if (m == 0) + return 0.0f; + Point3D p; + BTResolveWeaponMuzzle(ownerMech, segIndex, p); + return p.y - m->localOrigin.linearPosition.y; +} + // First vital damage-zone index (Mech__DamageZone::vitalDamageZone is protected; Mech has access). int Mech::FirstVitalZone() const @@ -1800,22 +1822,45 @@ static void } } - // #84 THRUSTER BURN: the binary Missile hosts a MissileThruster whose - // authored acceleration drives the round while BurnTime remains - // (MISTHRST: acceleration = (0,0,-thrusterAccel) in the missile frame, - // integrated by Missile::MoveAndCollide @4bef78). The pool flew at - // CONSTANT |MuzzleVelocity| -- the rack-eject speed (SRM 100, LRM 30 - // u/s) -- which is why field missiles crawled (Oracle/Rajel, #84). - // Authored (BTL4.RES type-15 missile models, +0x44/+0x48): SRM - // burn 2.5s @ 600 u/s^2, LRM 10s @ 300, Streak 3s @ 300. Drag is - // authored ~0.001 (negligible at combat ranges) -- integrate accel - // along the current heading, un-damped, range-capped as before. [T1 - // values / T2 integration] - if (p.burnLeft > 0.0f && p.accel > 0.0f) + // #84 THRUSTER BURN + QUADRATIC DRAG (velocity/dt integration audit + // 2026-08-13). Binary model [T1]: Mover::PerformAndWatch @00422360 + // ZEROES localAcceleration EVERY frame (`FUN_0040a7f4(+0x1dc, + // &DAT_004e0fd4)` = the T0 MOVER.cpp:672 `localAcceleration = + // Motion::Identity`), the MissileThruster's own Performance @004be474 + // (the standalone MissileThrusterSimulation body -- recovered by the + // 2026-08-06 re-export) re-adds (0,0,-ThrusterAccel) while burning, + // and Missile::MoveAndCollide @4bef78 adds the Mover quadratic drag + // -COD*airDensity*sign(v)*v^2 per body axis, then integrates with dt + // in SECONDS (@00421bac ApplyWorldAccelerations: v += a*dt). Net: + // dv/dt = ThrusterAccel - COD*v^2 (burn; lateral vel zeroed) + // -- a clean variable-step ODE. There is NO myomer-style 28Hz + // per-tick accumulate in this lane: `speed += accel*dt` is the + // faithful form and needs no (dt*28) rescale. + // AUTHORED [T1, raw floats read from BTL4.RES type-15 records + // 2026-08-13]: linear CODs = 0.001 on ALL axes of ALL four models + // (srm rid=315, lrm 319, strk 323, nrk 327); airDensity = 1.0 + // (engine default, LATTICE.cpp:413). Drag is NOT negligible -- it is + // the flight model's GOVERNOR: the Missile ctor @004bf5b4 precomputes + // terminal speed sqrt(ThrusterAccel/negCOD.z) into missile+0x348 for + // the seeker's lead time (part_013.c:18416). Terminal: LRM/Streak/ + // NRK 547.7 u/s, SRM 774.6. The old "drag ~0.001 negligible" note + // (#84) let an LRM reach 3030 u/s by burnout -- 5.5x the binary cap + // (sim: binary 492 u/s at 800u vs undamped 695; port t+15% to 800u). + // Drag runs during burn AND coast (a burned-out round DECELERATES, + // v(t)=v0/(1+COD*v0*t)); thruster rounds only -- the AC round is the + // 0xBCD renderer tracer, a cosmetic with no Mover physics. Still + // unported [T3 minor]: coast gravity (-6.5 world-Y, @4bef78 + // else-branch) and the binary's burnout end to velocity-slaving -- + // both live beyond practical impact ranges (see WEAPONS_DRIFT_AUDIT + // 'VELOCITY/DT INTEGRATION'). + if (p.accel > 0.0f) { Scalar burn_dt = (dt < p.burnLeft) ? dt : p.burnLeft; - p.burnLeft -= burn_dt; - Scalar ns = p.speed + p.accel * burn_dt; + if (burn_dt > 0.0f) + p.burnLeft -= burn_dt; + Scalar ns = p.speed + p.accel * burn_dt + - 0.001f * p.speed * p.speed * dt; // authored COD x airDensity 1.0 [T1] + if (ns < 1.0f) ns = 1.0f; // guard: drag alone never reverses flight if (p.speed > 0.01f) { Scalar k = ns / p.speed; diff --git a/game/reconstructed/misthrst.cpp b/game/reconstructed/misthrst.cpp index 0682f4c..f20bbdd 100644 --- a/game/reconstructed/misthrst.cpp +++ b/game/reconstructed/misthrst.cpp @@ -14,11 +14,21 @@ // // Coverage: // confident : ctor @004be7c4, dtor thunk @004be8bc, TestInstance @004be8e8, -// CreateStreamedSubsystem @004bf8ec -// best-effort: MissileThrusterSimulation -- the Performance method pointer is -// installed by the ctor (PTR_LAB_00512b20) but its standalone body -// is folded into the host Missile::MoveAndCollide @004bef78 (which -// samples the thruster acceleration buffer at missile+0x234/+0x250). +// CreateStreamedSubsystem @004bf8ec, +// MissileThrusterSimulation @004be474 -- the STANDALONE +// Performance body, RECOVERED by the 2026-08-06 re-export +// (the old "folded into Missile::MoveAndCollide, no distinct +// @ADDR survives" note here was stale). @004be474 does BOTH +// jobs each frame: (1) slerp the owner Missile's orientation +// toward the Seeker aim point (+0xE4), rate-capped at +// maxThrusterRotationRate*dt; (2) while ThrusterBurning: +// burnTimeRemaining -= dt and owner->localAcceleration += +// this->acceleration (0,0,-thrusterAccel) -- onto the +// accumulator Mover::PerformAndWatch @00422360 zeroes every +// frame, i.e. a constant acceleration, dt-correct. The +// simplified body below remains the dormant-TU stand-in; +// the LIVE port path is the mech4.cpp pool (see +// docs/WEAPONS_DRIFT_AUDIT.md 'VELOCITY/DT INTEGRATION'). // excluded : the 0x41xxxx engine vtable slots (pure Subsystem base behaviour; // vtable @00512bbc overrides only slot0 = destructor) // diff --git a/game/reconstructed/projweap.cpp b/game/reconstructed/projweap.cpp index f549192..8828116 100644 --- a/game/reconstructed/projweap.cpp +++ b/game/reconstructed/projweap.cpp @@ -28,11 +28,14 @@ // ProjectileWeaponSimulation @004bbd04 (Performance -- FULLY RECOVERED // by capstone disasm, Gitea #12 2026-07-19; see context/decomp-reference.md // s5 -- the old RivetGun-modeled body is retired). -// best-effort (vtable slot proven, function prologue present, BODY NOT -// recovered by the decompiler -- bodies below are reconstructed from context -// and clearly marked): -// FireWeapon @004bc104 (slot 18), GetStatusFlags @004bbf88 (slot 12), -// UpdateWeaponState @004bbc20 (slot 16). +// FireWeapon @004bc104 (slot 18) -- RECOVERED by the 2026-08-06 +// re-export (part_013.c:16023-16125; trigger-time hitscan, implemented +// in the #171 batch. The old "BODY NOT recovered" line here was the +// stale banner behind the #168 mis-fix -- see the FireWeapon block). +// best-effort (vtable slot proven, function prologue present, body still +// unrecovered -- reconstructed from context and clearly marked): +// GetStatusFlags @004bbf88 (slot 12), UpdateWeaponState @004bbc20 +// (slot 16). // excluded (belong to sibling/derived classes, NOT ProjectileWeapon): // Emitter family @004bb120/@004bb888/@004ba4d0-@004bb478 (energy weapons), // MissileLauncher @004bcff0/@004bd060/@004bd08c & FireWeapon @004bcc60, @@ -45,6 +48,9 @@ // _DAT_004bc068 = 0000803f = 1.0f (jam-chance clamp ceiling) // _DAT_004bb3b0 = 000080bf = -1.0f (resource "unset" sentinel) // _DAT_004bb3b4 = 0000003f = 0.5f +// _DAT_004bc678 = 00000040 = 2.0f (tracer fall-time factor; audit 2026-08-13) +// _DAT_004bc67c = 8a6c379943c6fad0fb3f = 1/9.8 as 80-bit x87 EXTENDED +// (tracer gravity term; audit 2026-08-13) // DAT_004e0f74 = {0,0,0} (zero vector) // DAT_004e0fd4 = {0,0,0} (zero point) // 0x40400000 = 3.0f (TotalTimeToEject default) @@ -145,8 +151,18 @@ namespace { static const Scalar _DAT_004bc100 = 1.0f; // time-of-flight bias static const Scalar _DAT_004bc064 = 0.41f; // heat -> jam-chance coefficient static const Scalar _DAT_004bc068 = 1.0f; // jam-chance clamp ceiling -static const Scalar _DAT_004bc678 = 1.0f; // muzzle-speed term (unresolved) -static const Scalar _DAT_004bc67c = 1.0f; // muzzle-speed term (unresolved) +// Tracer launch-speed constants -- BYTE-CONFIRMED by the 2026-08-13 weapons +// audit (WEAPONS_DRIFT_AUDIT.md entry 7 / evidence log 3); the old 1.0f +// "unresolved" stand-ins are RETIRED: +// @0x4bc678 = 40000000 = 2.0f (float32) +// @0x4bc67c = 8a6c379943c6fad0fb3f = 0.10204081... = 1/9.8, stored as an +// 80-bit x87 EXTENDED (not float32-decodable -- why it sat +// "unresolved"). Together with the ctor's muzzle-height term: +// launchVelocity.z = -effRange / sqrt((1/9.8) * 2.0 * muzzleHeight) +// i.e. the tracer crosses effectiveRange in the time a shell would fall +// from muzzle height under g=9.8 (~1s for a ~5u mount). +static const Scalar _DAT_004bc678 = 2.0f; // fall-time factor (2h/g) +static const Scalar _DAT_004bc67c = 0.10204081f; // 1/9.8 (x87 extended in the image) // FUN_00408050 -- uniform [0,1) random (jam roll). The old `return 0.0f` stub ALWAYS // jammed (0 < any positive jam chance), so a projectile weapon could never fire. A real @@ -311,11 +327,16 @@ ProjectileWeapon::ProjectileWeapon( minVoltagePercentToFire = subsystem_resource->minVoltagePercentToFire; // +0x1C8 -> 0x404 tracerCounter = 0; // 0x408 - // launchVelocity seed = (0,0,0) then z = -(effectiveRange / muzzleSpeed) + // launchVelocity seed = (0,0,0) then z = -(effectiveRange / muzzleSpeed), + // muzzleSpeed = sqrt((1/9.8) * 2.0 * muzzleHeight) -- @004bc3fc:16157-16164 + // resolves the muzzle transform (FUN_004b9948) INSIDE the ctor and uses its + // translation Y. The port DEFERS the z term to first use + // (ResolveLaunchVelocity below): our ctor runs mid-roster-build, where the + // segment resolve can fall back to the mech origin and would bake a garbage + // constant. z == 0 is the unresolved sentinel (the binary's z is always + // negative after its ctor). launchVelocity = Vector3D(0,0,0); // FUN_00408440(this+0x410, DAT_004e0f74) - Scalar muzzleSpeed = (Scalar)Sqrt(_DAT_004bc67c * _DAT_004bc678); // FUN_004dd138 tracerOrigin = Vector3D(0,0,0); // FUN_00408440(this+0x42c, DAT_004e0f74) - launchVelocity.z = -(effectiveRange / muzzleSpeed); // this[0x106] = -(this[0xca]/speed) totalTimeToEject = 3.0f; // 0x3F0 (0x40400000) timeToEject = totalTimeToEject; // 0x3F4 @@ -706,6 +727,30 @@ Logical return (p > UniformRandom()) ? True : False; // FUN_00408050 } +// +// The @004bc3fc ctor launch-speed expression, deferred to first use (see the +// ctor note). Byte-grounded constants (audit 2026-08-13): @0x4bc678 = 2.0f, +// @0x4bc67c = 1/9.8 (80-bit x87 extended `8a6c379943c6fad0fb3f`): +// launchVelocity.z = -effectiveRange / sqrt((1/9.8) * 2.0 * muzzleHeight) +// The binary computes it once at ctor time (bind pose, effectiveRange still == +// authored WeaponRange); the port computes it once at first use with the mount +// height above the mech origin -- the same quantity for a standing mech. +// +void + ProjectileWeapon::ResolveLaunchVelocity() +{ + if (launchVelocity.z != 0.0f) + return; // already resolved + extern Scalar BTWeaponMountHeight(void *ownerMech, int segIndex); + Scalar muzzleHeight = BTWeaponMountHeight(owner, GetSegmentIndex()); + if (muzzleHeight < 0.5f) + muzzleHeight = 0.5f; // port guard: unresolved segment (muzzle==origin + // fallback) -- the binary's bind-pose height is + // always positive; keeps sqrt/divide sane + Scalar muzzleSpeed = (Scalar)Sqrt(_DAT_004bc67c * _DAT_004bc678 * muzzleHeight); // FUN_004dd138 + launchVelocity.z = -(effectiveRange / muzzleSpeed); // this[0x106] = -(this[0xca]/speed) +} + // // @004bc06c -- lead-solution time-of-flight. Sample the current target // position, advance it by launchVelocity, and re-run targeting. With no target @@ -715,6 +760,7 @@ Logical Scalar ProjectileWeapon::ComputeTimeOfFlight() { + ResolveLaunchVelocity(); // deferred @004bc3fc z term GetTargetPosition(leadPosition); // FUN_004b9cbc(this, this+0x420) leadPosition.x += launchVelocity.x; // this+0x420 += this+0x410 leadPosition.y += launchVelocity.y; @@ -985,10 +1031,11 @@ void // single-panel Damage (amount = batchedCount x DamageAmount) to the LOCKED // target *(mech+0x388) via SendDamageMessage @004b9728 -- at fire time. // A locked AC inside range cannot miss; out of range the batch silently -// discards (tracer still flies). Reconstruction pending the #171 group -// decision (Oracle's HOLD) -- the current port body below still carries -// the #168 flight-contact model. MissileLauncher (@004bcc60) is the -// genuinely-flying family (Model B) and overrides this slot. +// discards (tracer still flies). The body below IMPLEMENTS that hitscan +// (landed in the #171 batch, bbb3b4e; the earlier "still carries the #168 +// flight-contact model" sentence here outlived the change -- the exact +// stale-banner genus this block documents). MissileLauncher (@004bcc60) +// is the genuinely-flying family (Model B) and overrides this slot. // // Gitea #12: the view/target gate, the ammo pull and the recoil set are // STRIPPED from this body -- they belong to the CALLER (the recovered @@ -1110,7 +1157,10 @@ void // The VISIBLE round: the binary's 0xBCD renderer tracer, cosmetic only // (the shell entity 0xBD1 is dead code -- nothing shipped ever spawns a // damage-carrying ballistic round). Fly the pool round as that visual: - // damage 0, straight at the pick, authentic muzzle speed. + // damage 0, straight at the pick, authentic muzzle speed + // (= effRange / sqrt(2*(1/9.8)*muzzleHeight), the recovered @004bc3fc + // constants -- see ResolveLaunchVelocity). + ResolveLaunchVelocity(); Scalar speed = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x + launchVelocity.y*launchVelocity.y + launchVelocity.z*launchVelocity.z); @@ -1178,6 +1228,7 @@ void // plus the DAFC muzzle flash. Point3D mz; GetMuzzlePoint(mz); // @004b9948 + ResolveLaunchVelocity(); // deferred @004bc3fc z term Scalar spd = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x + launchVelocity.y*launchVelocity.y + launchVelocity.z*launchVelocity.z); diff --git a/game/reconstructed/projweap.hpp b/game/reconstructed/projweap.hpp index e303ff5..3d6115c 100644 --- a/game/reconstructed/projweap.hpp +++ b/game/reconstructed/projweap.hpp @@ -283,6 +283,14 @@ class NotationFile; Scalar ComputeTimeOfFlight(); + // The @004bc3fc ctor launch-speed expression + // (launchVelocity.z = -effRange / sqrt((1/9.8) * 2.0 * muzzleHeight); + // constants byte-confirmed @0x4bc678/@0x4bc67c, audit 2026-08-13), + // deferred to first use because the port ctor runs mid-roster-build. + // z == 0 is the unresolved sentinel; no data member added (layout locked). + void + ResolveLaunchVelocity(); + // --- cross-family isolation helpers (owning Mech subsystem roster + // cockpit controls live in the mech/controls families). Declared // here so the recovered bodies compile; the mech family wires the