From fe48accb6b59d94e9f71026e69d9a2a5a24f05c3 Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Mon, 3 Aug 2026 12:24:36 -0500 Subject: [PATCH] #87 root cause: the level-crossing zone-record send was missing -- observers never saw damage The binary's effect watcher (FUN_0042aa2c) raises ForceUpdate(DamageZoneUpdateModelFlag) when a zone's damageLevel CROSSES a band-descriptor threshold (FUN_0042a5f4, master-gated on entity+0x28 & 0xc) -- that send is how every other pod's replicant learns zone levels mid-fight. The port's band hub kept only the graphic-state branch, so an observer's copy sat at 0.0 until destruction: no enemy hull darkening, no doll movement, ever (the night-10 "no armour discoloration" report). Restored the level branch with the already-reconstructed DescriptorCrossed (@0042a5f4) + the master gate on both branches (mesh swap still runs on every instance). 2-node verified: A-side armour watcher pushed 114 level changes; every replicant peak matched the master's finals to 4 decimals (dtorso 0.9321 == 0.9321). Render path cleared separately (gotcha #23 discharged): BT_ARMOR_FORCE 0/1 A/B captures prove the tint renders (hull -> charcoal at 1.0). Solo perception is the authentic economy: 2-25 pt lasers vs 68-185 pt pools. Bench: zonewalk.sh node A now carries BT_ARMOR_LOG + BT_SHOT_EVERY (the standing zone-replication receipt) + digest_walk.py; armorpx.sh is the force-pair pixel rig. Co-Authored-By: Claude Fable 5 --- context/combat-damage.md | 42 +++++++++++++++++++++++ context/decomp-reference.md | 1 + context/multiplayer.md | 7 ++++ context/test-harness.md | 7 ++++ game/reconstructed/mechdmg.cpp | 21 +++++++++++- scratchpad/night10/armorpx.sh | 24 +++++++++++++ scratchpad/night10/digest_walk.py | 56 +++++++++++++++++++++++++++++++ scratchpad/night10/zonewalk.sh | 5 +++ 8 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 scratchpad/night10/armorpx.sh create mode 100644 scratchpad/night10/digest_walk.py diff --git a/context/combat-damage.md b/context/combat-damage.md index 3b3dd13..8935272 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -1009,6 +1009,48 @@ 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), 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 = single-digit % levels = a 2-10% darkening + a doll tick of a few color +indices. The cockpit doll maps all 28 zones (L4GAUGE.CFG:4788-4814 cmArmor +lines -- feet + lower legs included); a "leg shots never show" report is those +zones' levels peaking ~0.08-0.13, not a wiring gap. + ## The hit-location CYLINDER -- raw-bytes audit + chassis->table map (2026-08-02) [T1] Full independent verification of the type-29 DamageLookupTable streams, parsed diff --git a/context/decomp-reference.md b/context/decomp-reference.md index af6bb54..52a29d1 100644 --- a/context/decomp-reference.md +++ b/context/decomp-reference.md @@ -183,6 +183,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. --- diff --git a/context/multiplayer.md b/context/multiplayer.md index 35f1a49..717a219 100644 --- a/context/multiplayer.md +++ b/context/multiplayer.md @@ -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] diff --git a/context/test-harness.md b/context/test-harness.md index cc1b432..36c83d8 100644 --- a/context/test-harness.md +++ b/context/test-harness.md @@ -106,6 +106,13 @@ SERVOS the torso twist + aim elevation until the centered reticle's pick ray 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); diff --git a/game/reconstructed/mechdmg.cpp b/game/reconstructed/mechdmg.cpp index 0a296e8..513b480 100644 --- a/game/reconstructed/mechdmg.cpp +++ b/game/reconstructed/mechdmg.cpp @@ -1275,6 +1275,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 +1340,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")) diff --git a/scratchpad/night10/armorpx.sh b/scratchpad/night10/armorpx.sh new file mode 100644 index 0000000..f90e95a --- /dev/null +++ b/scratchpad/night10/armorpx.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# #87 pixel-verification A/B: identical solo scene, all zones' draw-op damage +# level FORCED 0.0 vs 1.0 (BT_ARMOR_FORCE pins at the watcher, bypassing the +# sim), numbered backbuffer captures -> pixel diff decides whether the damage +# tint RENDERS at all (gotcha #23: a colour change that logs perfectly and +# renders nothing). +set -e +cd /c/git/bt411/content +. /c/git/bt411/scratchpad/night6/bench_common.sh +taskkill //F //IM btl4.exe > /dev/null 2>&1 || true; sleep 2 +rm -f apxA_*.png apxB_*.png +sed "s/^map=.*/map=grass/; s/^time=.*/time=day/; 0,/^vehicle=.*/s//vehicle=madcat/" MP.EGG > APX.EGG +for F in 0.0 1.0; do + TAG=A; [ "$F" = "1.0" ] && TAG=B + rm -f "apx_$F.log" + BT_ARMOR_FORCE=$F BT_ARMOR_LOG=1 BT_SHOT_EVERY=120 BT_SHOT_PREFIX=apx$TAG \ + BT_SPAWN_ENEMY=1 BT_GOTO=enemy BT_GOTO_STOP=55 \ + bt_launch "apx_$F.log" APX.EGG 0x03 + sleep 75 + taskkill //F //IM btl4.exe > /dev/null 2>&1 || true; sleep 3 +done +echo "=== captures:"; ls apxA_*.png apxB_*.png 2>/dev/null +echo "=== [armor] lines force=1.0:"; grep -c "\[armor\]" apx_1.0.log || true +grep -m2 "\[armor\]" apx_1.0.log || true diff --git a/scratchpad/night10/digest_walk.py b/scratchpad/night10/digest_walk.py new file mode 100644 index 0000000..29cf6c3 --- /dev/null +++ b/scratchpad/night10/digest_walk.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# Digest the instrumented zone-walk: per-zone level progressions on B (master), +# replicant levels seen by A's armor watcher, and the per-hit damage economy. +import re, sys, collections + +def rd(p): + with open(p, 'rb') as f: + return f.read().decode('utf-8', 'replace') + +a = rd(r'C:\git\bt411\content\zw_a.log') +b = rd(r'C:\git\bt411\content\zw_b.log') + +# idx -> name from A's walk lines: zone N 'name' +names = {} +for m in re.finditer(r"zone (\d+) '([^']+)'", a): + names[int(m.group(1))] = m.group(2) + +# B-side consumption: [dmghit] mech=E zone=N ... amt=X ... lvl P->Q +zones = collections.defaultdict(lambda: {'hits': 0, 'amt': 0.0, 'first': None, 'last': 0.0}) +hitlines = re.findall(r"\[dmghit\] mech=(\S+) zone=(\d+) vital=(\d+) type=(\d+) amt=([\d.eE+-]+) burst=(\d+) lvl ([\d.eE+-]+)->([\d.eE+-]+)", b) +mechs = collections.Counter(m[0] for m in hitlines) +victim = mechs.most_common(1)[0][0] if mechs else None +for mech, zi, vital, typ, amt, burst, p, q in hitlines: + if mech != victim: + continue + z = zones[int(zi)] + z['hits'] += 1 + z['amt'] += float(amt) + if z['first'] is None: + z['first'] = float(p) + z['last'] = float(q) + +print(f"victim {victim}: {sum(z['hits'] for z in zones.values())} hits into {len(zones)} zones") +print(f"{'zone':>4} {'name':<12} {'hits':>4} {'amt/hit':>8} {'lvl first->last':>18}") +for zi in sorted(zones, key=lambda k: -zones[k]['last']): + z = zones[zi] + per = z['amt'] / z['hits'] if z['hits'] else 0 + print(f"{zi:>4} {names.get(zi, '?'):<12} {z['hits']:>4} {per:>8.3f} {z['first']:>8.4f} -> {z['last']:<8.4f}") + +# A-side replicant levels: [armor] '' zone N level P -> Q +arm = re.findall(r"\[armor\] '[^']*' zone (\d+) level ([\d.eE+-]+) -> ([\d.eE+-]+)", a) +if arm: + peak = collections.defaultdict(float) + for zi, p, q in arm: + peak[int(zi)] = max(peak[int(zi)], float(q)) + print(f"\nA-side watcher: {len(arm)} level-change pushes; replicant peaks:") + for zi in sorted(peak, key=lambda k: -peak[k])[:12]: + print(f" zone {zi:>3} {names.get(zi, '?'):<12} peak {peak[zi]:.4f}") +else: + print("\nA-side watcher: NO level-change pushes (replicant levels never moved)") + +# zone-repl confirmations on A +zr = re.findall(r"\[zone-repl\] zone (\d+) '([^']+)' lvl ([\d.eE+-]+)->([\d.eE+-]+)", a) +print(f"\n[zone-repl] on A: {len(zr)} lines") +for zi, nm, p, q in zr[:8]: + print(f" zone {zi} {nm} {p}->{q}") diff --git a/scratchpad/night10/zonewalk.sh b/scratchpad/night10/zonewalk.sh index ce86393..74baaa4 100644 --- a/scratchpad/night10/zonewalk.sh +++ b/scratchpad/night10/zonewalk.sh @@ -24,8 +24,13 @@ BT_SPIN_SELF=15 BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 \ bt_launch zw_b.log ZW.EGG 0x0C -net 1601 sleep 2 # SHOOTER second (foreground -- owns the pick focus): the walker. +# BT_ARMOR_LOG on A: the watcher logs the REPLICANT's zone levels as A renders +# B -- decides whether damage darkening ever reaches the observer node (#87 MP). +# BT_SHOT_EVERY: periodic captures of A's view of B's hull (real-fight pixels). +rm -f zwA_*.png BT_ZONE_WALK=6 BT_PICK_LOG=1 BT_GOTO=enemy BT_GOTO_STOP=90 BT_KEY_NOFOCUS=1 \ BT_DMG_LOG=1 BT_RANGE_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 \ +BT_ARMOR_LOG=1 BT_SHOT_EVERY=300 BT_SHOT_PREFIX=zwA \ bt_launch zw_a.log ZW.EGG 0x03 -net 1501 sleep 5 python ../tools/btconsole.py ZW.EGG 127.0.0.1:1501 127.0.0.1:1601 > zw_relay.log 2>&1 &