diff --git a/CLAUDE.md b/CLAUDE.md index 0541bb3..e8aee98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1220,15 +1220,32 @@ muzzleVelocity — a DEDICATED bridge, not CreateEmitter, because its FireWeapon per the binary). All three `static_assert`-locked (offsetof/sizeof). Verified: Mad Cat (LRM/ballistic) + BLH construct the real weapon classes + tick authentic charge/ammo/heat/recoil, DESTROYED-in-8, 0 crashes, heapcheck-clean through construction. Factory now **18 of 20** (remaining: SubsystemMessageManager 0xBD3 -[WAVE 8], Gyroscope 0xBC4 [deferred]). **⚠ Phase A safe-stubs FireWeapon (no spawn):** ProjectileWeapon/ -MissileLauncher FireWeapon consume a round + recoil but do NOT spawn the projectile/missile — the reconstructed -**Projectile (@4be1bc, 0x340) + Missile (@4bf5b4, 0x368) ENTITY classes are NOT spawn-ready** (they carry -Entity-BASE phantom members that overflow their `New()` alloc → heap corruption; `New()` takes `(int)this` -instead of the 0xD4 descriptor MakeMessage; the Entity base size is unconfirmed 2007-vs-1995, the Mech -0x638-vs-0x854 problem). **Phase B = the entity byte-exactness + descriptor build + `New(MakeMessage*)` + -engine-integration** so a fired shot becomes a flying entity that damages — a distinct follow-up (the workflow's -adversarial verify flagged the live spawn as a heap-overflow hazard and recommended exactly this phasing). The -energy weapons (Emitter/PPC) already damage via the mech4 beam path, so combat is unaffected. +[WAVE 8], Gyroscope 0xBC4 [deferred]). +**✅✅ WAVE 7 Phase B DONE — the Mad Cat's LRMs now LAUNCH flying missiles that fly + damage (autocannon rounds +too).** The byte-exact WORLD-ENTITY reconstruction (Projectile @4be1bc 0x340 / Missile @4bf5b4 0x368) is +**infeasible on the engine**: measured `sizeof(2007 engine Entity)=0x1BC` vs the 1995 binary's 0x300, so the +reconstruction's raw base-offset reads (velocity@0x1dc, roster@0x124, motion@0x250) read GARBAGE on the engine +(the Mech 0x638-vs-0x854 gap, but the entity INTEGRATOR depends on those offsets). So — like the mech drive / +beam renderer — flying projectiles are a **PORT reconstruction** (`BTPushProjectile`/`BTUpdateProjectiles` in +mech4.cpp): seeded from the launcher's fire with the decomp's real muzzle (`GetMuzzlePoint`) / launch speed +(`|launchVelocity|`) / per-shot damage (`damageData`, split across `missileCount`), they fly to the target +(tracer via `BTPushBeam`) and deliver the weapon's damage on impact through the SAME `Entity::TakeDamage` path as +the beam (aim `Mech::FirstVitalZone()`). **THREE bring-up fixes were needed to make a projectile weapon fire at +all** (all found by tracing, marked): (1) **the trigger** — `fireImpulse` was only driven for the Emitter; +`ProjectileWeaponSimulation` now sets `fireImpulse = gBTWeaponTrigger` too (else `CheckFireEdge` never sees an +edge); (2) **the ammo bin** — `OwnerSubsystemCount/OwnerSubsystem` were stubbed `->0` so `ammoBinLink` never +resolved and `ConsumeRound` always failed; redirected to the real roster (`owner->GetSubsystemCount()/ +GetSubsystem(i)` — the AmmoBin 0xBCB constructs before the weapons 0xBCD/0xBD0); (3) **the jam roll** — +`UniformRandom()` was stubbed `return 0.0f`, so `CheckForJam`'s `0 < jamChance` ALWAYS jammed (a projectile +weapon could NEVER fire); replaced with a real LCG `[0,1)` (fires ~1-jamChance of the time — authentic occasional +jams). Also: the mech's own target slot (`owner+0x388`) isn't populated in bring-up (the visible fire targets the +`gEnemyMech` global), so `BTPushProjectile` **falls back to `gEnemyMech`**. Verified: Mad Cat PUSH=62 / IMPACT=31 +(LRM missiles 3.33 dmg each split across the salvo + AFC100 autocannon 25 dmg), TARGET DESTROYED, 0 crashes; BLH +un-regressed (also fires its ballistic weapon now). Diagnostics `BT_PROJ_LOG` (`[projectile] PUSH/IMPACT` + +per-weapon fire trace). **The energy weapons (Emitter/PPC) still damage via the mech4 beam path.** REMAINING +(deferred): the byte-exact world-entity Missile (`Projectile : Mover`, MP-replicable via `Registry::MakeEntity`) +— the port projectile is master-local only (no MP replication); the real per-weapon fire-rate/heat wiring off +the subsystem sim (the mech4 beam path is still the bring-up harness). **✅ HEAT-FLOW LINKAGE CONNECTED — weapon heat now conducts to the central sink** (verified: `[heat] conduct self.E=.. -> other.E`, sustained fire `FIRED #1..#21+`, combat intact, no crash). THREE fixes, each faithful to the decomp (no stand-ins): diff --git a/content/LAST.EGG b/content/LAST.EGG index 61255bd..d6f71cb 100644 --- a/content/LAST.EGG +++ b/content/LAST.EGG @@ -168,7 +168,7 @@ badge=VGL patch=Yellow role=Role::Default dropzone=one -vehicle=bhk1 +vehicle=madcat vehicleValue=1000 color=White [largebitmap] diff --git a/game/reconstructed/mech.hpp b/game/reconstructed/mech.hpp index 1228ff8..1d87409 100644 --- a/game/reconstructed/mech.hpp +++ b/game/reconstructed/mech.hpp @@ -741,6 +741,9 @@ protected: // our entries are Mech__DamageZone, populated by the Mech ctor). Defined in // mech.cpp where Mech__DamageZone is a complete type. Mech__DamageZone *Zone(int i) const; + // First VITAL damage zone index (0 if none) -- for concentrated fire that kills. + // Defined in mech4.cpp (Mech__DamageZone::vitalDamageZone is protected; Mech has access). + int FirstVitalZone() const; int flags; // entity flags word (this+0x28 region) int stance; // posture state int ammoState; // @0x44c 0 none / 1 leaking / 2 dry diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index e92d21f..9b1f1b4 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -678,6 +678,125 @@ void out = m->localOrigin.linearPosition; // safe non-garbage fallback (owner origin) } +// First vital damage-zone index (Mech__DamageZone::vitalDamageZone is protected; Mech has access). +int + Mech::FirstVitalZone() const +{ + for (int k = 0; k < damageZoneCount; ++k) + if (Zone(k)->vitalDamageZone) + return k; + return 0; +} + +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// Port-side tracked-projectile service (WAVE 7 Phase B -- flying missiles/rounds). +// The 1995 Projectile/Missile WORLD-ENTITY classes cannot be revived byte-exact: +// the 2007 engine Entity base is 0x1BC bytes vs the 1995 binary's 0x300, so the +// reconstruction's raw base-offset reads (velocity@0x1dc, roster@0x124, motion@ +// 0x250) read garbage on the engine (the same 0x638-vs-0x854 gap the Mech has, +// but the entity integrator depends on those offsets). So a fired projectile is +// a PORT reconstruction (like the beam renderer): seeded from the launcher's fire +// with the decomp's real muzzle / launch velocity / per-shot damage, it flies +// toward the target (tracer via BTPushBeam) and delivers the weapon's damage on +// impact through the SAME Entity::TakeDamage path as the beam. The byte-exact +// world-entity Missile (Projectile : Mover, MP-replicable) is the deeper follow-up. +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +struct BTProjectile { + Point3D pos; + Vector3D dir; + Scalar speed; + Scalar traveled; + Scalar range; + Entity *target; + Point3D targetPos; + Scalar damage; + int active; +}; +static BTProjectile gProjectiles[64]; + +extern void BTPushBeam(float,float,float, float,float,float, unsigned, float, float); + +// Called from ProjectileWeapon / MissileLauncher::FireWeapon (via the extern below) +// with the live muzzle, the owner's locked target entity + point, the launch speed +// (|muzzleVelocity|), and the weapon's per-shot damage. +void + BTPushProjectile(const Point3D &muzzle, void *target, const Point3D &targetPos, + Scalar speed, Scalar damage) +{ + // Bring-up: the mech's own target slot (owner+0x388) isn't populated (the visible + // fire path targets the gEnemyMech global directly), so fall back to it here. + Point3D tpos = targetPos; + if ((target == 0 || tpos.x == 0.0f) && gEnemyMech != 0) + { + target = gEnemyMech; + tpos = ((Mech *)gEnemyMech)->localOrigin.linearPosition; + } + Vector3D d; + d.x = tpos.x - muzzle.x; d.y = tpos.y - muzzle.y; d.z = tpos.z - muzzle.z; + Scalar len = (Scalar)sqrtf(d.x*d.x + d.y*d.y + d.z*d.z); + if (getenv("BT_PROJ_LOG")) + DEBUG_STREAM << "[projectile] PUSH target=" << (void*)target << " enemy=" << (void*)gEnemyMech + << " len=" << len << " speed=" << speed << " dmg=" << damage << "\n" << std::flush; + if (len < 0.001f) return; + for (int i = 0; i < 64; ++i) + { + if (gProjectiles[i].active) continue; + BTProjectile &p = gProjectiles[i]; + p.pos = muzzle; + p.dir.x = d.x/len; p.dir.y = d.y/len; p.dir.z = d.z/len; + p.speed = (speed > 1.0f) ? speed : 120.0f; // |launchVelocity|; sane fallback + p.traveled = 0.0f; + p.range = len + 40.0f; // impact by the target, else expire just past it + p.target = (Entity *)target; + p.targetPos = tpos; // resolved target position (fallback-aware) + p.damage = damage; + p.active = 1; + return; + } +} + +// Per-frame (viewpoint mech): advance each projectile, draw its tracer, deliver damage on impact. +static void + BTUpdateProjectiles(Scalar dt) +{ + for (int i = 0; i < 64; ++i) + { + BTProjectile &p = gProjectiles[i]; + if (!p.active) continue; + Point3D prev = p.pos; + Scalar step = p.speed * dt; + p.pos.x += p.dir.x*step; p.pos.y += p.dir.y*step; p.pos.z += p.dir.z*step; + p.traveled += step; + BTPushBeam(prev.x, prev.y, prev.z, p.pos.x, p.pos.y, p.pos.z, 0x00FFB030u, 0.18f, 1.4f); // warm tracer + + Scalar dx = p.targetPos.x - p.pos.x, dy = p.targetPos.y - p.pos.y, dz = p.targetPos.z - p.pos.z; + if (dx*dx + dy*dy + dz*dz < (10.0f*10.0f) || p.traveled >= p.range) + { + Entity *tgt = p.target; + // Deliver only to the known-valid enemy (bring-up), same zone/damage path as the beam. + if (tgt != 0 && tgt == gEnemyMech && p.damage > 0.0f) + { + Mech *m = (Mech *)tgt; + if (m->damageZoneCount > 0) + { + int zone = m->FirstVitalZone(); // concentrated fire -> a kill + Damage dmg; + dmg.damageType = Damage::ExplosiveDamageType; + dmg.damageAmount = p.damage; + dmg.burstCount = 1; + dmg.impactPoint = p.pos; + Entity::TakeDamageMessage take_damage( + Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage), + 0 /*inflictor id: bring-up*/, zone, dmg); + tgt->Dispatch(&take_damage); + DEBUG_STREAM << "[projectile] IMPACT damage=" << p.damage << " zone=" << zone << "\n" << std::flush; + } + } + p.active = 0; + } + } +} + void Mech::PerformAndWatch(const Time& till, MemoryStream *update_stream) { @@ -694,6 +813,10 @@ void const Logical isPlayerMech = (application != 0 && (Entity *)this == application->GetViewpointEntity()); + // WAVE 7 Phase B: advance/render/impact the flying projectiles once per frame (viewpoint). + if (isPlayerMech && dt > 0.0001f) + BTUpdateProjectiles(dt); + // 1-Hz non-player mech telemetry (multiplayer diagnosis): every mech that is // NOT the local viewpoint -- whatever its instance claims -- with position. if (!isPlayerMech && getenv("BT_REPL_LOG") && dt > 0.0001f) diff --git a/game/reconstructed/mislanch.cpp b/game/reconstructed/mislanch.cpp index 7e3cf67..9c0e8b7 100644 --- a/game/reconstructed/mislanch.cpp +++ b/game/reconstructed/mislanch.cpp @@ -63,6 +63,13 @@ #if !defined(TESTBT_HPP) # include #endif +#include + +// WAVE 7 Phase B -- the port-side flying-projectile service (defined in mech4.cpp, a +// complete-Mech TU with the render + damage path). A fired missile becomes a tracked +// projectile that flies to the target + delivers this weapon's damage on impact. +extern void BTPushProjectile(const Point3D &muzzle, void *target, const Point3D &targetPos, + Scalar speed, Scalar damage); //########################################################################### //########################################################################### @@ -219,21 +226,27 @@ void MissileLauncher::FireWeapon() // Begin the per-shot recharge cooldown (recoil bleeds to 0 before ReadyToFire). recoil = rechargeRate; - // Resolve the muzzle / lead geometry (used to seed each missile's descriptor). + // Resolve the muzzle / lead geometry (seeds each flying missile). Point3D muzzle; GetMuzzlePoint(muzzle); // @004b9948 damageData.burstCount = missileCount; // @0x3d4 (was shadow "salvoCount") - // ⚠ SPAWN DEFERRED (WAVE 7 Phase B): the shipped code loops missileCount times - // building a 0xD4-byte Missile MakeMessage (muzzle transform + lead + the 12-word - // Damage descriptor + launchVelocity + owner/target) and calls Missile::New - // (@004bf8bc -> ctor @004bf5b4, allocs 0x368). The Missile ENTITY class is not - // spawn-ready (Entity-base phantom members overflow the 0x368 alloc; New() takes - // (int)this instead of the descriptor) -> a live spawn would corrupt the heap. The - // launcher SUBSYSTEM ticks authentically (charge/ammo/heat/recoil, damage split - // across missileCount) but does NOT launch until the entity work lands. See - // scratchpad/wave7_maps.txt (Missile map) + docs/SUBSYS_PLAN.md. - // for (int i = 0; i < missileCount; ++i) Missile::New(&descriptor); // <-- Phase B + // WAVE 7 Phase B: launch `missileCount` flying projectiles toward the owner's target. + // (Port reconstruction -- the byte-exact world-entity Missile is blocked by the 2007 + // engine Entity base mismatch; see BTPushProjectile / mech4.cpp.) Speed = |launchVelocity| + // (resource MuzzleVelocity); per-missile damage = damageData.damageAmount (already + // divided by missileCount in the ctor). + char *o = (char *)owner; // inherited MechSubsystem::owner (the Mech) + void *target = (o != 0) ? *(void **)(o + 0x388) : 0; // owner's locked target entity (null in bring-up) + Point3D targetPos = (o != 0) ? *(Point3D *)(o + 0x37c) : muzzle; // owner target point + Scalar speed = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x + + launchVelocity.y*launchVelocity.y + + launchVelocity.z*launchVelocity.z); + // Always launch -- BTPushProjectile falls back to gEnemyMech when the owner's target + // slot isn't populated (bring-up); missileCount missiles per salvo. + int nmiss = (missileCount > 0 && missileCount < 40) ? missileCount : 1; + for (int i = 0; i < nmiss; ++i) + BTPushProjectile(muzzle, target, targetPos, speed, damageData.damageAmount); simulationFlags |= 0x1; // replication-dirty Check_Fpu(); diff --git a/game/reconstructed/projweap.cpp b/game/reconstructed/projweap.cpp index 2ce5515..b2a48f5 100644 --- a/game/reconstructed/projweap.cpp +++ b/game/reconstructed/projweap.cpp @@ -85,6 +85,11 @@ #if !defined(TESTBT_HPP) # include #endif +#include + +// WAVE 7 Phase B -- the port-side flying-projectile service (defined in mech4.cpp). +extern void BTPushProjectile(const Point3D &muzzle, void *target, const Point3D &targetPos, + Scalar speed, Scalar damage); //############################################################################# @@ -96,7 +101,15 @@ 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) -static Scalar UniformRandom() { return 0.0f; } // FUN_00408050 (deterministic stand-in) +// 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 +// pseudo-random (LCG) restores authentic behavior: it fires ~(1-jamChance) of the time. +static Scalar UniformRandom() +{ + static unsigned s = 0x12345678u; + s = s * 1103515245u + 12345u; + return (Scalar)((s >> 16) & 0x7fff) / 32768.0f; +} static int ResolveTracerModel(void*) { return 0; } // explosion/tracer model handle static int ResolveSubsystemName(const ResourceDirectories*, const char*) { return -1; } // FUN_004215b0 @@ -145,15 +158,19 @@ Logical // bodies compile and are wired up there. // int - ProjectileWeapon::OwnerSubsystemCount(Mech * /*owner*/) const + ProjectileWeapon::OwnerSubsystemCount(Mech *owner) const { - return 0; + // Real roster (binary reads owner->subsystemCount @0x124). The AmmoBin (0xBCB) + // constructs before the projectile weapons (0xBCD/0xBD0) in the factory loop, so + // its roster slot is populated by the time this weapon's ctor resolves it. + return (owner != 0) ? owner->GetSubsystemCount() : 0; } Subsystem * - ProjectileWeapon::OwnerSubsystem(Mech * /*owner*/, int /*index*/) const + ProjectileWeapon::OwnerSubsystem(Mech *owner, int index) const { - return 0; + // Real roster lookup (binary: subsystemArray[index] @0x128). + return (owner != 0) ? owner->GetSubsystem(index) : 0; } Logical @@ -227,7 +244,8 @@ ProjectileWeapon::ProjectileWeapon( // NOT the resource flags -- the same authoritative-simulation gate the AmmoBin // uses (owner&0xC==0 && owner&0x100). This is what arms the per-frame tick on // the bring-up's authoritative mech. - if (((owner->simulationFlags & 0xC) == 0) && ((owner->simulationFlags & 0x100) != 0)) + Logical installPerf = (((owner->simulationFlags & 0xC) == 0) && ((owner->simulationFlags & 0x100) != 0)); + if (installPerf) { SetPerformance(&ProjectileWeapon::ProjectileWeaponSimulation); // member-ptr {0x4bbd04,..} } @@ -235,6 +253,9 @@ ProjectileWeapon::ProjectileWeapon( { simulationFlags |= 0x2; // this[10] |= 2 (mark "not simulated") } + if (getenv("BT_PROJ_LOG")) + DEBUG_STREAM << "[projweap] CTOR name=" << GetName() << " ownerFlags=" + << owner->simulationFlags << " installPerf=" << (int)installPerf << "\n" << std::flush; // Link to the AmmoBin subsystem referenced by the resource and copy the // initial display fields out of it. (The Mech subsystem-roster lookup lives @@ -502,8 +523,19 @@ void UpdateEject(time_slice); // @004bbb50 -- idle unless ejectState==0 // 2. Rising-edge trigger from the owning Mech's discharge signal. + // Bring-up: the controls mapper is bypassed, so drive fireImpulse from the + // per-frame gBTWeaponTrigger (same as EmitterSimulation) -- else CheckFireEdge + // never sees an edge and a projectile weapon never fires. + extern int gBTWeaponTrigger; + fireImpulse = (Scalar)gBTWeaponTrigger; // this+0x31C Logical trigger = CheckFireEdge(); // MechWeapon @004b9608 + static int s_pwtick = 0; + if (getenv("BT_PROJ_LOG") && (((++s_pwtick) % 240) == 0 || trigger)) + DEBUG_STREAM << "[projweap] tick " << GetName() << " trig=" << (int)trigger + << " gTrig=" << gBTWeaponTrigger << " ready=" << (int)ReadyToFire() + << " state=" << weaponAlarm.GetState() << " recoil=" << recoil << " x\n" << std::flush; + // 3. Firing state machine (weaponAlarm @0x350). Modelled on the RP analog // RivetGun::RivetGunSimulation (WEAPSYS.cpp): the Jammed / NoAmmo latches // persist and only escalate on a trigger pull; the default (ready) branch @@ -559,26 +591,30 @@ void // Pull a round; a dry / not-ready bin latches NoAmmo and aborts the shot. if (!ConsumeRound()) // FUN_004bd4f4 (AmmoBin::FeedAmmo) + { return; + } // Begin the per-shot recharge cooldown (recoil bleeds back down to 0 in // DrawFiringCharge before the weapon is ReadyToFire again). recoil = rechargeRate; // this[0xfa] = this[0xf7] - // Resolve the live muzzle (used to seed the projectile descriptor). + // Resolve the live muzzle. Point3D muzzle; GetMuzzlePoint(muzzle); // MechWeapon @004b9948 - // ⚠ SPAWN DEFERRED (WAVE 7 Phase B): the shipped code builds a ~0xD4-byte projectile - // MakeMessage here (muzzle transform + lead point + the 12-word Damage descriptor + - // owner/target) and hands it to Projectile::New (@004be384). The reconstructed - // Projectile/Missile ENTITY classes are NOT spawn-ready yet: they carry Entity-base - // phantom members that overflow their New() alloc (0x340/0x368), and New() currently - // takes (int)this instead of the descriptor -> a live spawn would corrupt the heap. - // So the weapon SUBSYSTEM ticks authentically (charge/ammo/heat/recoil) but does NOT - // spawn until the entity byte-exact base + descriptor build land. See scratchpad/ - // wave7_maps.txt (Projectile/Missile maps) + docs/SUBSYS_PLAN.md. - // Projectile::New((int)this); // <-- Phase B + // WAVE 7 Phase B: launch one flying ballistic round toward the owner's target (port + // reconstruction; the byte-exact world-entity Projectile is blocked by the 2007 engine + // Entity base mismatch -- see BTPushProjectile / mech4.cpp). Speed = |launchVelocity|. + char *o = (char *)owner; // inherited MechSubsystem::owner (the Mech) + void *target = (o != 0) ? *(void **)(o + 0x388) : 0; // owner's locked target entity (null in bring-up) + Point3D targetPos = (o != 0) ? *(Point3D *)(o + 0x37c) : muzzle; // owner target point + Scalar speed = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x + + launchVelocity.y*launchVelocity.y + + launchVelocity.z*launchVelocity.z); + // Always launch -- BTPushProjectile falls back to gEnemyMech when the owner's target + // slot isn't populated (bring-up). + BTPushProjectile(muzzle, target, targetPos, speed, damageData.damageAmount); simulationFlags |= 0x1; // replication-dirty Check_Fpu();