From 054c3f2eee64be52e053bf55aa674c45164a1c97 Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Fri, 31 Jul 2026 02:10:04 -0500 Subject: [PATCH] #94: replicant un-wreck now rides the DEATH-STATE EXIT edge -- the zone falling-edge latch was blind to zone-less (collision) deaths The peer wreck lifecycle listened to two different signals: wreck ON came from the replicated death explosion (unconditional), wreck OFF from a damage-zone falling edge (wasWrecked latch, armed only when a zone crossed >= 1.0 on the observer). The #83 collision damage prices as internal rattle and never moves a zone, so a fresh mech killed by ramming could NEVER un-wreck on peers -- the live respawned mech drove around wearing the hulk (night-7 field report: SAURON's Loki, screenshotted by both observers at the exact minute the log analysis places the ram death). MechDeathHandler::Tick now tracks prevMode and fires the rebuild + warp when the replicated MovementMode leaves the death modes {2,9} -- only Mech::Reset ever does that, and the state rides every record header, so the trigger is cause-agnostic. The zone falling edge just refreshes the cache. Side effect fixed for free: the peer respawn warp vortex now plays for zone-less deaths too (same gate). Bench (scratchpad/night7/mp_zoneless.sh + the new BT_SELF_DAMAGE_TYPE=collision harness option, which dispatches type-0 through the real TakeDamage divert): 14/14 zone-less deaths un-wrecked (mode 9->1) with zero zone rises on the observer; explosive regression leg 12/12 with zone destruction active. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J --- context/multiplayer.md | 16 +++++++- context/translocation-warp.md | 4 +- game/reconstructed/mech4.cpp | 9 +++++ game/reconstructed/mechdmg.cpp | 67 +++++++++++++++++++------------- game/reconstructed/mechdmg.hpp | 17 +++++--- scratchpad/night7/mp_zoneless.sh | 55 ++++++++++++++++++++++++++ 6 files changed, 132 insertions(+), 36 deletions(-) create mode 100644 scratchpad/night7/mp_zoneless.sh diff --git a/context/multiplayer.md b/context/multiplayer.md index 76e5393..35f1a49 100644 --- a/context/multiplayer.md +++ b/context/multiplayer.md @@ -282,6 +282,18 @@ engine/port member. Wire sizes verified live (0x14/0x20/0x2c/0x78). Key mechan NO replicant VehicleDead) — the death SINK/burial gap (old item 3) is CLOSED. Respawn: `Force(0x1f)` burst snaps the replicant to the drop zone + un-wrecks it. Walking replicant un-regressed (run states 10→12, legCycle tracking, with type-3 records flowing). Solo clean. +- **The replicant UN-WRECK trigger is the DEATH-STATE EXIT edge, not a zone edge (#94 fix, + 2026-07-31) [T2]**: `MechDeathHandler::Tick` (mechdmg.cpp) tracks `prevMode` and fires + `BTRebuildMechModel` + `BTStartWarpEffect` when the replicated `MovementMode()` LEAVES the death + modes {2,9} (only `Mech::Reset`'s `SetMovementMode(0)` + `ForceUpdate(0x1f)` ever does; the state + rides every record header). The previous trigger — a damage-zone falling-edge latch + (`wasWrecked`, `prev>=1.0 -> <1.0`) — was BLIND to ZONE-LESS deaths: the #83 collision damage + prices as internal rattle and never moves a zone, so a fresh mech killed by ramming left the + latch unarmed and the peer's wreck could never un-wreck — the live respawned mech drove around + wearing the hulk (night-7 field report: SAURON's Loki after the full-speed ram; both peers + screenshotted it). Bench (`scratchpad/night7/mp_zoneless.sh`, `BT_SELF_DAMAGE_TYPE=collision` + dispatches type-0 through the real TakeDamage divert): 14/14 zone-less deaths un-wrecked + (`mode 9->1`), zero zone-damage rises on the observer; explosive regression leg clean. - Telemetry: `[mrec-tx]/[mrec-rx]` per non-pose record (BT_REPL_LOG). Note the type-1 rx line prints a bogus simState (the damage record's own layout at +0xC) — cosmetic. - New by-name members: `poseSyncLatch@0x77c` (dead-reckon re-base latch, consumed by @@ -802,7 +814,9 @@ transition, HUD all landed since P6): console egg → mesh → RunningMission on absolute_time` → unbounded → UV precision loss → radial "spokes"), fixed with `fmodf`; that also cleans the scrolling beam grit. See [[reconstruction-gotchas]] §13. PEER WARP IS WIRED + VISIBLE (`160b78e`): an observer sees a peer's un-wreck warp via the world-anchored `BTStartWarpEffect` - (mechdmg.cpp:1074, `ReplicantInstance`-gated; `simulationState` rides every record header). + (`MechDeathHandler::Tick`, `ReplicantInstance`-gated; since the #94 fix it fires on the + death-state EXIT edge of the replicated `MovementMode` — cause-agnostic, so zone-less + collision deaths warp too; `simulationState` rides every record header). REMAINING [T3, non-gating]: the peer sphere is anchored to the peer's WORLD position, not the peer's authentic `DropZoneLocation` (that attribute isn't replicated) — a fidelity refinement. Also confirm the COLLAPSE (death-side) warp fires on every MP death path (force-damage test logged diff --git a/context/translocation-warp.md b/context/translocation-warp.md index b10b055..648c7e3 100644 --- a/context/translocation-warp.md +++ b/context/translocation-warp.md @@ -120,7 +120,9 @@ Local player's own death → `BTStartWarpCollapsePOV()` (btplayer.cpp `VehicleDe `deathCount==-1`, guarded `this==GetMissionPlayer()`). Respawn `DropZoneReply` → `BTStartWarpExpandPOV()` (local-guarded). `BTWarpForceUnmask()` on mission-end / no-DropZones. **Peer warp IS wired + visible (`160b78e`):** an observer sees a peer's death/respawn warp — the replicant un-wreck fires the -world-anchored `BTStartWarpEffect(x,y,z)` at the peer's position (mechdmg.cpp:1074, gated to +world-anchored `BTStartWarpEffect(x,y,z)` at the peer's position (`MechDeathHandler::Tick`, +since the #94 fix triggered by the death-state EXIT edge of the replicated `MovementMode` — +cause-agnostic, covers zone-less collision deaths; gated to `ReplicantInstance`; the effect machine is a SINGLE global slot, and since 2026-07-30 the local pilot's own POV lifecycle OWNS it — `BTStartWarpEffect` self-skips while `gWarpPhase!=0 && gWarpPOV` (`[tloc] peer warp SKIPPED`), because a peer's un-wreck landing mid-collapse/wait/expand used to diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 4391e87..8a5bc9c 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -5786,6 +5786,15 @@ void ++s_sdTicks; Damage dmg; dmg.damageType = Damage::ExplosiveDamageType; + // BT_SELF_DAMAGE_TYPE=collision: dispatch as type-0 instead, so + // the TakeDamage hub's collision divert prices it as INTERNAL + // rattle (armor/zones never move) -- the deterministic bench for + // a ZONE-LESS death (#94: the field ram-kill, without the ram). + { + const char *ty = getenv("BT_SELF_DAMAGE_TYPE"); + if (ty != 0 && !stricmp(ty, "collision")) + dmg.damageType = Damage::CollisionDamageType; + } float want = (float)atof(getenv("BT_SELF_DAMAGE")); dmg.damageAmount = (want > 0.0f) ? want : 20.0f; dmg.burstCount = 1; diff --git a/game/reconstructed/mechdmg.cpp b/game/reconstructed/mechdmg.cpp index 8badf6f..42e8675 100644 --- a/game/reconstructed/mechdmg.cpp +++ b/game/reconstructed/mechdmg.cpp @@ -1149,7 +1149,7 @@ extern void BTSpawnDamageEffect(Mech *mech, int effect_resource, int segment_ind extern void BTRemakeMechModel(Entity *entity); MechDeathHandler::MechDeathHandler(Mech *mech) // @0042a984 - : owner(mech), wasWrecked(0) + : owner(mech), prevMode(0) { // per-zone last-damage cache, zeroed (binary this[0x10], size mech+0x11c). int count = (mech != 0 && mech->damageZoneCount > 0) ? mech->damageZoneCount : 0; @@ -1172,6 +1172,40 @@ void if (owner == 0) return; + // 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) + // + ForceUpdate(0x1f) reaches the peer in every record header). This is + // the cause-agnostic respawn signal: the previous trigger inferred the + // respawn from a damage-zone falling edge, which a COLLISION death never + // produces (type-0 damage prices as internal rattle, never armor -- the + // zones of a fresh mech killed by ramming sit at 0 for its whole life), + // so the peer's wreck could never un-wreck: the live respawned mech drove + // around wearing the hulk (night-7 field report, SAURON's Loki). Gated + // to ReplicantInstance: the master un-wrecks locally in Mech::Reset. + { + const int mode = (int)owner->MovementMode(); + const int oldMode = prevMode; + const bool nowDead = (mode == 2 || mode == 9); + const bool prevDead = (oldMode == 2 || oldMode == 9); + prevMode = mode; + if (prevDead && !nowDead + && owner->GetInstance() == Entity::ReplicantInstance) + { + extern void BTRebuildMechModel(Entity *entity); + extern void BTStartWarpEffect(float x, float y, float z); + 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; + } + } + int n = owner->damageZoneCount; for (int i = 0; i < n && i < (int)lastLevel.size(); ++i) { @@ -1183,35 +1217,13 @@ void Scalar prev = lastLevel[i]; // FALLING edge = the master's Mech::Reset healed + re-broadcast this zone - // (ForceUpdate(DamageZoneUpdateModelFlag)). The rising-damage swap below is - // one-way; on a REPLICANT observer, reverse it here -- drop the destroyed - // segment meshes (BTRebuildMechModel) and play the recovery warp -- so the - // peer's wreck un-wrecks + warps back on the observer's screen. Gated to - // ReplicantInstance: the master already un-wrecks + warps locally in - // Mech::Reset (mech4.cpp), so it must NOT double-fire here. The authentic - // engine drives this off the type-0 graphic-state transition in - // Mech::ReadUpdateRecord (mech.cpp case 0 -> ResetPose); our port has no - // ported RemakeEntity state, so we react to the replicated zone level. + // (ForceUpdate(DamageZoneUpdateModelFlag)). Just refresh the cache: the + // respawn un-wreck no longer rides this edge -- it fires from the + // death-state exit watcher above, which also covers deaths that never + // moved a zone (#94). if (level < prev) { lastLevel[i] = level; - if (wasWrecked && prev >= 1.0f && level < 1.0f) - { - wasWrecked = 0; // one un-wreck per death->respawn - if (owner->GetInstance() == Entity::ReplicantInstance) - { - extern void BTRebuildMechModel(Entity *entity); - extern void BTStartWarpEffect(float x, float y, float z); - 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 at (" - << owner->localOrigin.linearPosition.x << "," - << owner->localOrigin.linearPosition.z << ")\n" << std::flush; - } - } continue; } if (level <= prev) // unchanged -- no new damage @@ -1231,7 +1243,6 @@ void const Mech__DamageZone::DamageDescriptor *d = zone->DescriptorForLevel(level); if (level >= 1.0f && prev < 1.0f) // just destroyed -> the Destroyed descriptor { - wasWrecked = 1; // latch: a wreck the observer must later reverse const Mech__DamageZone::DamageDescriptor *dd = zone->DescriptorForGraphicState(DamageZone::DestroyedGraphicState); // enum 1 if (dd != 0) diff --git a/game/reconstructed/mechdmg.hpp b/game/reconstructed/mechdmg.hpp index 985b338..b36373f 100644 --- a/game/reconstructed/mechdmg.hpp +++ b/game/reconstructed/mechdmg.hpp @@ -367,12 +367,17 @@ class MechDeathHandler private: Mech *owner; // @0x14 std::vector lastLevel; // @0x10 per-zone last damageLevel cache - // PORT (respawn replication): latched when any zone is destroyed, so a - // REPLICANT observer can reverse the one-way wreck swap when the master's - // Mech::Reset heals + re-broadcasts the zones (falling damage edge). Not a - // binary field -- the authentic un-wreck rides the type-0 graphic-state hook - // (Mech::ReadUpdateRecord case 0), which our port drives via the zone levels. - Logical wasWrecked; + // PORT (respawn replication): last-tick MovementMode, so a REPLICANT + // observer can reverse the one-way wreck swap on the DEATH-STATE EXIT + // edge (9/2 -> 0/1, only Mech::Reset ever leaves the death modes). The + // state rides EVERY record header, so the edge fires for ANY death + // cause. Replaces a zone-level falling-edge latch (wasWrecked), which + // was blind to zone-less deaths: collision/internal-rattle kills + // (gitea #94) never move a damage zone, so the latch never armed and + // the peer's wreck could never un-wreck. Not a binary field -- the + // authentic un-wreck rides the type-0 graphic-state hook + // (Mech::ReadUpdateRecord case 0), which our port has not reconstructed. + int prevMode; }; #endif diff --git a/scratchpad/night7/mp_zoneless.sh b/scratchpad/night7/mp_zoneless.sh new file mode 100644 index 0000000..cdd3dcf --- /dev/null +++ b/scratchpad/night7/mp_zoneless.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# #94 bench: ZONE-LESS death un-wreck on the replicant. +# +# Node A (master) kills itself with TYPE-0 (collision) self-damage: the +# TakeDamage hub's collision divert prices it as internal rattle -- armor and +# damage zones never move -- reproducing the night-7 field ram-death without +# a ram. Node B observes with BT_DEATH_LOG: the fix must print +# [respawn] replicant un-wrecked + warp (mode 9->...) +# after each of A's respawns. Pre-fix, the un-wreck rode a zone falling edge +# that a zone-less death never produces -- B kept drawing A as the wreck. +# +# usage: mp_zoneless.sh [explosive] (arg switches A to the default weapon- +# type harness -- the REGRESSION leg: zone deaths must still un-wreck) +set -x +. /c/git/bt411/scratchpad/night6/bench_common.sh +cd /c/git/bt411/content || exit 1 +taskkill //F //IM btl4.exe > /dev/null 2>&1 +sleep 2 +sed "s/^map=.*/map=grass/; s/^time=.*/time=day/" MP.EGG > ZL.EGG + +TYPE="${1:-collision}" +OBS=zl_${TYPE}_obs.log +MST=zl_${TYPE}_master.log +rm -f "$OBS" "$MST" + +# observer first (B): death-log ON -- the un-wreck line is the verdict +BT_MP_LOG=1 BT_REPL_LOG=1 BT_DEATH_LOG=1 \ + bt_launch "$OBS" ZL.EGG 0x0C -net 1601 +sleep 2 + +# master (A): repeated self-kill of the requested damage type. Collision +# damage is priced down by ~5e-5 (the 100 km/h reference scale) so its dose +# is raw-kinetic-sized; the explosive regression leg uses the classic 60/s. +SD_TYPE=""; DOSE=60 +[ "$TYPE" = "collision" ] && { SD_TYPE=collision; DOSE=250000; } +BT_MP_LOG=1 BT_DMG_LOG=1 BT_DEATH_LOG=1 \ +BT_SELF_DAMAGE=$DOSE BT_SELF_DAMAGE_TYPE=$SD_TYPE \ +BT_SELF_DAMAGE_DELAY=12 BT_SELF_DAMAGE_REPEAT=1 \ + bt_launch "$MST" ZL.EGG 0x03 -net 1501 +sleep 4 + +python ../tools/btconsole.py ZL.EGG 127.0.0.1:1501 127.0.0.1:1601 & +PC=$! +sleep 150 +kill $PC 2>/dev/null +bt_kill_ours +rm -f ZL.EGG + +echo "=== MASTER ($MST): deaths + rattle ===" +grep -c "DESTROYED" "$MST" +grep -m3 "colldmg\|selfdmg" "$MST" +echo "=== OBSERVER ($OBS): wreck swaps vs un-wrecks ===" +grep -c "wreck swap" "$OBS" +grep "un-wrecked" "$OBS" +echo "=== DONE ($TYPE) ==="