diff --git a/context/open-questions.md b/context/open-questions.md index 5f38315..7e6eaf1 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -165,6 +165,27 @@ authentic path scoped. LODReuseHysteresis corrected 0.82→0.33 (a double); the MP beam-kill live-verify awaits a clear-sightline spawn (mechanism identical to solo; force-dmg cross-pod cycles verified). +- **HEAT (task #9, 2026-07-11): authentic 1e7-unit heat LIVE; conduction ROUTING defect + remains [T2/T3].** Landed + byte-anchored: emitters emit `heatCostToFire x 1e7 x + (charge/seekV)^2` (closed form; PPC = 1.1e8 -> +632 K on its 174000-mass sink); the missing + projectile/missile heat adds (raw pre-scaled resources); the bank's AMBIENT RADIATOR @4ae73c + (the system's only heat exit: relax toward 300 K, conductance x 0.1 x HeatSinkCount + [_DAT_004ae974 float80 = 0.1 -- the audit's "1.7375" was a misread]); the link-attach guard + corrected (skip = the 0xBBE bank, NOT Condenser -- the inversion had closed the system); + three coolant epsilon corrections (0.0025/0.003/1e-4 for the old single 1e-4) + + CoolantCapacityScale = 0.05 float80 (both prior readings wrong). Verified: heat flows, + exits (~2/3 of a 1e10 spam run drained), weapons overheat under max-rate autofire exactly + as the authored-constant simulation predicts (thermal spam is unsustainable BY DESIGN; + heatLoad -> effectiveRange 0 = the authentic overheat cutout). **REMAINING DEFECT: the + linked-sink ROUTING is scrambled** -- both PPCs' heat pools in Condenser1 (observed 6800 K) + while their AUTHORED condensers (PPC_1->C4, PPC_2->C6) sit at 72 K; heat.hpp:186 already + notes the heat-stream parse reading neighbouring fields for thermalMass/heatSinkIndex -- + audit the type-0x11/heat-family stream offsets + the "+2-biased" index resolve. Also + [T4]: who reads FailureHeat to BLOCK firing; the HeatWatcher/AmmoBin cook-off watchedLink; + the mech-level heatAlarm@0x450 producer. KB sweep pending: the "Heat MFD authentically + NEAR-STATIC" claim (gauges-hud.md) was computed at the degenerate scale -- at authentic + scale the MFD is dynamic. + ## Locomotion / combat polish (non-gating) - Authentic per-mech TURN-RATE constant (currently a bring-up constant rate). - Body-callback gimp handlers (states 16-19, targets 0x70b2/0x7161 undecoded → fall back to stand); diff --git a/game/reconstructed/emitter.cpp b/game/reconstructed/emitter.cpp index 9b43843..7427f2e 100644 --- a/game/reconstructed/emitter.cpp +++ b/game/reconstructed/emitter.cpp @@ -201,11 +201,19 @@ void damagePortion = (damageFraction * energyTotal * kDamageScale) * chargeRatio * chargeRatio; // 0x44c } - // Heat stays on the bring-up scale until the heat-calibration audit [T3]: - // the authentic heatPortion is heatCostToFire x 1e7 energy units (the - // missile path already feeds that scale raw); switching the emitters too - // is the heat audit's call, not the damage economy's. - heatPortion = energy - damageFraction * energy; // 0x450 + // THE AUTHENTIC HEAT (task #9; the calibration audit verified the whole + // HeatSink chain is 1e7-unit-native and formula-exact): the closed form + // mirroring the damage fix -- heatPortion = (1 - dF) x energyTotal x + // (charge/seekV)^2 = heatCostToFire x 1e7 at full charge (PPC = 1.1e8 -> + // +632 K on its own 174000-mass sink, conducting to the 1.39e6 bank; + // authored thresholds 77 start / 1000 degradation / 2000 failure). + { + Scalar vRecH = seekVoltage[seekVoltageRecommendedIndex]; + Scalar chargeRatioH = (vRecH > 0.0f) ? (currentLevel / vRecH) : 1.0f; + heatPortion = (1.0f - damageFraction) * energyTotal + * chargeRatioH * chargeRatioH; // 0x450 + } + (void)energy; // dump the heat portion into our INHERITED thermal accumulator if heatable/online. // E5: pendingHeat (HeatSink @0x1C8) is the field the heat sim absorbs each frame diff --git a/game/reconstructed/heat.cpp b/game/reconstructed/heat.cpp index b3a5195..b2d826e 100644 --- a/game/reconstructed/heat.cpp +++ b/game/reconstructed/heat.cpp @@ -62,13 +62,21 @@ // _DAT_004ad880 : 00 00 80 3f == 1.0f // _DAT_004ada90 : 00 00 80 3f == 1.0f (the "1 - exp(...)" unit term) // _DAT_004adbf4 : 17 b7 d1 38 == 1.0e-4f (heat-equalise threshold) -// _DAT_004adcfc : 17 b7 d1 38 == 1.0e-4f (coolant-draw threshold) +// _DAT_004adcfc : 17 b7 d1 38 == 1.0e-4f (DrawCoolant |amount| gate) +// _DAT_0050e3d8 : 0a d7 23 3b == 0.0025f (draw-zero floor + OFF hysteresis) +// _DAT_0050e3d4 : a6 9b 44 3b == 0.003f (coolantActive ON threshold) // static const Scalar HeatLoadScale = 0.002f; // _DAT_004ad870 (80-bit extended) static const Scalar HeatLoadMinimum = 0.0f; // _DAT_004ad87c static const Scalar HeatLoadMaximum = 1.0f; // _DAT_004ad880 static const Scalar HeatEqualizeEpsilon = 1.0e-4f; // _DAT_004adbf4 -static const Scalar CoolantDrawEpsilon = 1.0e-4f; // _DAT_004adcfc / 0050e3d8 / 0050e3d4 +// task #9 correction: the old single CoolantDrawEpsilon (1e-4) served THREE +// distinct byte-verified constants -- the draw floor / OFF hysteresis are +// 0.0025 and the ON threshold 0.003 (the 1e-4 reading made the leak floor + +// status-bit hysteresis fire ~25-30x too eagerly). +static const Scalar CoolantDrawGate = 1.0e-4f; // _DAT_004adcfc (DrawCoolant |amount|) +static const Scalar CoolantDrawFloor = 0.0025f; // _DAT_0050e3d8 (zero floor + OFF) +static const Scalar CoolantActiveOn = 0.003f; // _DAT_0050e3d4 (ON threshold) //########################################################################### @@ -466,9 +474,15 @@ HeatSink::HeatSink( // // Resolve and attach the linked heat sink referenced by heatSinkIndex. - // (Skipped for "Condenser"-class segments -- see @0041a1a4 guard.) + // task #9 GUARD CORRECTION: the binary's @0041a1a4 test is against GUID + // 0x50e590 = the AGGREGATE bank (0xBBE "HeatSinkBank"), NOT Condenser -- + // only the BANK skips the link-attach (its authored SinkIdx=5 is dead; + // it radiates to ambient instead). The old Condenser guard BLOCKED the + // condenser->bank conduction links, which (with the ambient radiator + // deferred) closed the system: heat could never leave -- the observed + // monotonic runaway once emitters went authentic. // - if (!IsDerivedFrom(*Condenser::GetClassDerivations())) // FUN_0041a1a4(*this[3], 0x50e590) + if (!IsDerivedFrom("HeatSinkBank")) // FUN_0041a1a4(*this[3], 0x50e590) { // ⚠ ROOT-CAUSE FIX (the BGF-load heap corruption): heatSinkIndex indexes the // owner's SUBSYSTEM ROSTER, NOT the skeleton segment table. Raw @004adda0 @@ -596,7 +610,7 @@ void && (Entity *)owner == application->GetViewpointEntity() && pendingHeat > 0.0f) { s_heatLog = 0.0f; - DEBUG_STREAM << "[heat] sink temp=" << currentTemperature + DEBUG_STREAM << "[heat] " << GetName() << " temp=" << currentTemperature << " heatEnergy=" << heatEnergy << " absorbed=" << pendingHeat << " heatLoad=" << heatLoad << "\n" << std::flush; } @@ -781,7 +795,7 @@ void ::DamageZone *ownZone = this->Subsystem::damageZone; // @0xE0 (word 0x38) Scalar zoneDamage = (ownZone != 0) ? ownZone->damageLevel : 0.0f; // +0x158 coolantDraw = zoneDamage * heatLoad; // *(this[0x38]+0x158) * this[0x48] - if (coolantDraw < CoolantDrawEpsilon) + if (coolantDraw < CoolantDrawFloor) // _DAT_0050e3d8 = 0.0025 { coolantDraw = 0.0f; } @@ -793,7 +807,7 @@ void } coolantLevel -= amount; - if (fabsf(amount) > CoolantDrawEpsilon) + if (fabsf(amount) > CoolantDrawGate) // _DAT_004adcfc = 1e-4 { coolantLevel += DrawCoolant(amount); // virtual: (**(*this+0x38))(this, amount) } @@ -801,11 +815,11 @@ void // // Draw state machine (this+0x138). // - if (coolantActive == 0 && coolantDraw > CoolantDrawEpsilon) + if (coolantActive == 0 && coolantDraw > CoolantActiveOn) // _DAT_0050e3d4 = 0.003 { coolantActive = 1; } - else if (coolantActive == 1 && coolantDraw < CoolantDrawEpsilon) + else if (coolantActive == 1 && coolantDraw < CoolantDrawFloor) // _DAT_0050e3d8 = 0.0025 { coolantActive = 0; } diff --git a/game/reconstructed/heatfamily_reslice.cpp b/game/reconstructed/heatfamily_reslice.cpp index 59ad9d7..640468e 100644 --- a/game/reconstructed/heatfamily_reslice.cpp +++ b/game/reconstructed/heatfamily_reslice.cpp @@ -45,7 +45,9 @@ // Recovered .data constants (best-effort; exact double bit-patterns folded to // decimal where known). TODO: confirm against section_dump.txt. // -static const Scalar CoolantCapacityScale = 1.738f; // _DAT_004af518 (~ master-sink capacity scale) +static const Scalar CoolantCapacityScale = 0.05f; // _DAT_004af518 (float80, byte-verified + // task #9; both prior readings -- 1.738 + // and the audit's 1.675 -- were misreads) // FUN_0049f788 -- defined near the foot of this file; forward-declared for MoveValve. void BTRecomputeCondenserValves(Entity *owner); @@ -720,10 +722,66 @@ AggregateHeatSink::AggregateHeatSink( Check_Pointer(subsystem_resource); heatSinkCount = subsystem_resource->heatSinkCount; // this[0x74] @0x1D0 = res +0xFC - ambientTemperature = 300.0f; // this[0x75] @0x1D4 (_DAT_004ae89c) FROZEN + ambientTemperature = 300.0f; // this[0x75] @0x1D4 (_DAT_004ae89c) + + // task #9 (the ambient radiator lands): the binary ctor @4ae8d0 scales the + // bank's conductance by 0.1 x HeatSinkCount (_DAT_004ae974 float80 = 0.1 + // byte-verified; madcat count 14 -> x1.4) and installs the RADIATOR + // Performance @4ae73c on masters -- the system's ONLY heat exit. + thermalConductance = 0.1f * (Scalar)heatSinkCount * thermalConductance; + if ((owner->simulationFlags & 0xC) == 0 + && (owner->simulationFlags & 0x100) != 0) + { + SetPerformance((HeatSink::Performance) + &AggregateHeatSink::RadiatorSimulation); // PTR @0050e5e8 = @4ae73c + } Check_Fpu(); } +// +// @4ae73c -- the bank's authentic Performance (task #9; was DEFERRED): its own +// absorb/temperature/load step, then the AMBIENT RADIATOR -- relax toward the +// 300 K setpoint (target = 300 - (300 - ambientTemperature) x 3.0; = 300 with +// the frozen setpoint) with rate k = conductance x (1 - ownZoneDamage) x +// (coolant/capacity) x flowScale / mass -- the only place heat LEAVES the +// mech. Tail: top the bank's coolant up from the linked reservoir +// (DrawCoolant, vtable +0x38) whenever below capacity. The old deferral note +// ("raw self+0xE0 -> [+0x158] does not map") was wrong -- that read is the +// engine-base DamageZone's damageLevel, the same named-member pattern +// UpdateCoolant already uses. [T1 constants: 300 / 3.0 / 1.0 / 0.0 / 1e-4] +// +void + AggregateHeatSink::RadiatorSimulation(Scalar time_slice) +{ + if (HeatModelActive()) // FUN_004ad7d4 + { + heatEnergy += pendingHeat; // [0x56] += [0x72] + currentTemperature = heatEnergy / thermalMass; // [0x45] = [0x56]/[0x55] + UpdateHeatLoad(); // FUN_004ad7f0 + pendingHeat = 0.0f; + + Scalar target = 300.0f + - (300.0f - ambientTemperature) * 3.0f; // _DAT_004ae89c/_DAT_004ae8a0 + ::DamageZone *ownZone = this->Subsystem::damageZone; // @0xE0 (word 0x38) + Scalar zoneDamage = (ownZone != 0) ? (Scalar)ownZone->damageLevel : 0.0f; + double ex = (double)(-(time_slice * thermalConductance + * (1.0f - zoneDamage) // _DAT_004ae8a4 - dmg + * (coolantLevel / thermalCapacity) * coolantFlowScale) + / thermalMass); + Scalar decay = 1.0f - (Scalar)exp(ex); // FUN_004dca38 + pendingHeat += -((currentTemperature * massScale - target) + * thermalMass * decay); + } + + // coolant top-up from the reservoir (the binary tail: deficit > 0 and + // past the 1e-4 gate -> DrawCoolant via vtable +0x38). + Scalar deficit = thermalCapacity - coolantLevel; // [0x4a] - [0x4b] + if (deficit > 0.0f && fabsf(deficit) > 1.0e-4f) // _DAT_004ae8a8/_DAT_004ae8ac + { + coolantLevel += DrawCoolant(deficit); // vcall +0x38 + } +} + AggregateHeatSink::~AggregateHeatSink() { Check(this); diff --git a/game/reconstructed/heatfamily_reslice.hpp b/game/reconstructed/heatfamily_reslice.hpp index 58d2be2..d9163cc 100644 --- a/game/reconstructed/heatfamily_reslice.hpp +++ b/game/reconstructed/heatfamily_reslice.hpp @@ -347,6 +347,10 @@ ); // @4ae8d0 ~AggregateHeatSink(); + // @4ae73c -- the authentic bank Performance (task #9): absorb + the + // AMBIENT RADIATOR (the system's only heat exit) + coolant top-up. + void RadiatorSimulation(Scalar time_slice); + static int CreateStreamedSubsystem( // @4ae9dc (offline content-build only) NotationFile *model_file, const char *model_name, diff --git a/game/reconstructed/mislanch.cpp b/game/reconstructed/mislanch.cpp index 1211d7a..956be96 100644 --- a/game/reconstructed/mislanch.cpp +++ b/game/reconstructed/mislanch.cpp @@ -218,6 +218,14 @@ void MissileLauncher::FireWeapon() { Check(this); + // THE FIRING HEAT (task #9): heatCostToFire RAW into the launcher's own + // pendingHeat (pre-scaled 1e7-unit resources: SRM6 = 5e7, LRM15 = 6.5e7) + // -- the binary's add @part_013.c:8744-8747, gated on the heatable check. + if (HeatModelActive()) // FUN_004ad7d4 (heat.hpp; [T3] permissive) + { + pendingHeat += heatCostToFire; // HeatSink @0x1C8, raw + } + // A salvo draws one ammo unit from the linked bin; a dry / not-ready bin // latches NoAmmo (ConsumeRound) and aborts the launch. if (!ConsumeRound()) // FUN_004bd4f4 (AmmoBin::FeedAmmo) diff --git a/game/reconstructed/projweap.cpp b/game/reconstructed/projweap.cpp index fc56b8c..c9a209c 100644 --- a/game/reconstructed/projweap.cpp +++ b/game/reconstructed/projweap.cpp @@ -598,11 +598,19 @@ void { Check(this); + // THE FIRING HEAT (task #9; was "a separate pre-existing gap"): the + // binary adds heatCostToFire RAW to the weapon's own pendingHeat -- + // projectile resources author PRE-scaled 1e7-unit heat (AFC100 = 6.5e7) + // -- gated on the heatable check (part_013.c:8744-8747 idiom). + if (HeatModelActive()) // FUN_004ad7d4 (heat.hpp; [T3] permissive) + { + pendingHeat += heatCostToFire; // HeatSink @0x1C8, raw + } + // Binary @4bc136-4bc19c (task #56): the firing RECOIL kick, BEFORE the ammo // pull (no early-out precedes it in the binary). Gate: per-shot damage > // 3.0f (@0x4bc3f4) && the owner mech has a gyro. Direction (0, 0.6, -1.5) // (0x3f19999a/0xbfc00000), magnitude = damageAmount / 16 (@0x4bc3f8). - // (The binary's preceding heat add is a separate pre-existing gap.) if (damageData.damageAmount > 3.0f && owner != 0) { extern void GyroApplyDamageImpulse(Subsystem *, Scalar, Scalar, Scalar, Scalar); diff --git a/scratchpad/scan_heatsub.py b/scratchpad/scan_heatsub.py new file mode 100644 index 0000000..adaf54b --- /dev/null +++ b/scratchpad/scan_heatsub.py @@ -0,0 +1,86 @@ +import struct +data = open(r'C:/git/bt411/content/BTL4.RES','rb').read() +n = len(data) + +def cstr(off, maxlen=32): + s = data[off:off+maxlen].split(b'\0')[0] + try: return s.decode('ascii') + except: return None + +def f32(off): return struct.unpack_from('= len(pad) - 2: + recs.append((i, name, cid, sz)) + i += 0x28 + continue + i += 1 + +# group into chains (consecutive: next record at p+sz) +chains = [] +cur = [] +for k,(p,name,cid,sz) in enumerate(recs): + if cur and cur[-1][0] + cur[-1][3] == p: + cur.append(recs[k]) + else: + if cur: chains.append(cur) + cur = [recs[k]] +if cur: chains.append(cur) + +mechnames = [b'madcat', b'bhk', b'vulture', b'thor', b'blackhawk', b'blh', b'owens', b'strider', b'sunder', b'cauldron', b'avatar', b'mdc'] +low = data.lower() +namepos = [] +for m in mechnames: + j = 0 + while True: + j = low.find(m, j) + if j < 0: break + namepos.append((j, m.decode())) + j += 1 +namepos.sort() + +def nearest_label(p): + best = None + for pos, name in namepos: + if pos < p: best = (pos, name) + else: break + return best + +HEAT = {0xBBD:'Condenser', 0xBBE:'AggregateHeatSink', 0xBC0:'Reservoir', 0xBBB:'HeatSink?', 0xBBC:'?'} +WEAP = {0xBC8:'Emitter', 0xBD4:'PPC', 0xBCD:'ProjWeapon', 0xBCE:'GaussRifle', 0xBD0:'MissileLauncher'} + +for ci, ch in enumerate(chains): + lbl = nearest_label(ch[0][0]) + names = [c[1] for c in ch] + print(f'--- chain {ci} @0x{ch[0][0]:x} n={len(ch)} nearest-name={lbl}') + for idx,(p,name,cid,sz) in enumerate(ch): + tag = HEAT.get(cid) or WEAP.get(cid) + if cid in HEAT: + st, dg, ft = f32(p+0xE4), f32(p+0xE8), f32(p+0xEC) + tc, tm = f32(p+0xF0), f32(p+0xF4) + hsi = i32(p+0xF8) + line = (f' [{idx:2d}] {name:20s} cid=0x{cid:X}({tag}) sz=0x{sz:X} ' + f'StartT={st:g} DegT={dg:g} FailT={ft:g} Conduct={tc:g} ThermMass={tm:g} HeatSinkIdx={hsi}') + if cid == 0xBBD: + line += f' RefrigFactor={f32(p+0xFC):g}' + elif cid == 0xBBE: + line += f' HeatSinkCount={i32(p+0xFC)}' + elif cid == 0xBC0: + line += f' CoolantCap={f32(p+0xFC):g} SquirtMass={f32(p+0x100):g}' + print(line) + elif cid in WEAP: + heat = f32(p+0x1A4) + print(f' [{idx:2d}] {name:20s} cid=0x{cid:X}({tag}) Heat={heat:g}') + else: + print(f' [{idx:2d}] {name:20s} cid=0x{cid:X} sz=0x{sz:X}')