diff --git a/context/combat-damage.md b/context/combat-damage.md index 10b7b85..d42d56c 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -606,10 +606,21 @@ btplayer.hpp, static_assert-locked; `scoreAward`=APPLIED tally in all three): the VICTIM role's `killBonus` (role+0x1c). senderMechID = the victim. Suicide (eject charge, self-damage) IS dispatched — the handler negates the award (@0x4c03ab `fchs`) and skips `killCount++`: **the #134 panic penalty, live** (bench: `type=2 award=-39.00 kills=0`). -- **B @0x4a05d9, not newly killed && tally≠0** → type 0 to the shooter. The ONLY registered 0x16 - receiver Verify-rejects type 0 → 1995 folded an UNINITIALIZED stack float into the shooter's - score on every non-lethal hit (real 1995 bug — @0x4c0200, the handler that accepts type 0, is - in NO table entry: dead code). Port sends it for wire fidelity, banks award 0. +- **B @0x4a05d9, not newly killed && tally≠0** → type 0 to the shooter: the **per-hit INFLICTED + credit**, and it is LIVE. ⚠ **CORRECTED 2026-08-07** — this entry previously read "the ONLY + registered 0x16 receiver Verify-rejects type 0 → 1995 folded an UNINITIALIZED stack float into + the shooter's score on every non-lethal hit (real 1995 bug — @0x4c0200 … is in NO table entry: + dead code)". **That was wrong**, and build 787 retired the port's per-hit crediting on the + strength of it (the scoring regression players reported on 4.11.817). `BTPlayer` overrides + **`Dispatch` — vtable `@00513300` slot 3 = `FUN_004bffa0`** — and splits type 0 off BEFORE base + dispatch: `if (id==0x16 && type==0) FUN_004c0200(...); else base;`. `@004c0200` names itself in + its own Verify string (`BTPlayer::ScoreInflictedMessageHandler`) and computes + `CalcInflicted(basis) → negate if target==self → × (targetTonnage/ownTonnage) → += +0x278`. + `ScoreMessageHandler`'s type-0 arm Verify-rejects precisely BECAUSE the interceptor guarantees + type 0 never reaches it. Independently corroborated by the original manual's SCORING CHART + (`reference/manual/scoring_chart.webp`, via Lynx): "+1 each damage point scored on opponent's armor" and "-1 each + self-inflicted point of armor damage" — the negate-if-self arm exactly. Interceptor restored; + benched 83 inflicted rows, awards 0.98–25.00, zero type-0 Verify rejections. [T1] - **C @0x4a06c0, tally>0 (kills included)** → type 1 DamageReceivedScore to the VICTIM's player. Basis = INTENDED damage (burstCount×amount). senderMechID = the INFLICTOR. Feeds the received penalty (`CalcDamageReceivedScore` returns the NEGATIVE) + the operator-console VTVDamaged line @@ -617,9 +628,31 @@ btplayer.hpp, static_assert-locked; `scoreAward`=APPLIED tally in all three): Reports carry the LOOP-ENTRY zone (msg+0x24, never rewritten mid-loop), the vital-wreck flag, and `inflictingSubsystemID` (msg+0x5c, engine T0 name). -**Score model consequence** [T1]: 1995 pod scoring = **kill awards + received-damage penalties + -death costs. No per-hit inflicted credit** (the port's old per-hit crediting — and the #95 salvo -fix on top of it — were inventions riding the dead @0x4c0200 channel; both retired). +**Score model consequence** [T1, ⚠ REWRITTEN 2026-08-07]: 1995 pod scoring = **per-hit inflicted +credit + kill awards + received-damage penalties + death costs**. The previous text here claimed +"No per-hit inflicted credit … inventions riding the dead @0x4c0200 channel; both retired" — that +followed from the dead-code misreading corrected in report B above, and retiring the credit IS the +scoring regression reported on 4.11.817. The **original manual's SCORING CHART** (`reference/manual/scoring_chart.webp`) +is the cross-check for every row and should be consulted before touching this path again: + +| Points | Event | +|---|---| +| +1000 | Starting the game | +| +1 | Each damage point scored on opponent's armor | +| +10..+30 | Destroying an opponent's internal system | +| +500 | Destroying an opponent's 'Mech | +| −1 | Each self-inflicted point of armor damage | +| −10..−30 | Knocking out one of your own internal systems | +| −500 | Destroying your own 'Mech by an ammo explosion | +| −1000 | Destroying your own 'Mech by ejecting | + +⚠ **Three chart rows are NOT yet reconciled with the reconstruction** — treat as open [T4]: +(a) a kill benches at `award=4.88`, two orders off the chart's flat **+500**; (b) **+1000 at +game start** has no known implementation; (c) **−1000 eject / −500 ammo** would live in +`ScenarioRole::specialCaseDeathPenalty` (role+0x20), which the port reads +(`GetSpecialCaseDeathPenalty`, the death-cost block) but which **shipped content authors +nowhere**, so it is 0 in the field — the #134 symptom by another route. Do not "fix" these by +hard-coding chart numbers; find where the binary sources them. Kill award = `(victimKillBonus + tally) × killerRole.damageInflictedModifier × (victimAvgZoneDamage@0x354 × damageBias + 1.0) × (victimTonnage/killerTonnage)`; same-team kill in a non-FFA game = `-friendlyFirePenalty` basis (inline strcmp of `teamName@0x20c`, gate diff --git a/game/reconstructed/btplayer.cpp b/game/reconstructed/btplayer.cpp index a81e074..06e9fb4 100644 --- a/game/reconstructed/btplayer.cpp +++ b/game/reconstructed/btplayer.cpp @@ -731,6 +731,47 @@ void suppressConsole = 0; // this+0x258 } +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// Dispatch (@004bffa0, vtable @00513300 slot 3) +// +// THE TYPE-0 INTERCEPTOR. Restored 2026-08-07 -- it was missing, and its +// absence silently deleted per-hit inflicted scoring: +// +// * the binary carries all three score reports under ONE id (0x16) and +// splits type 0 off here, BEFORE base dispatch, straight into +// ScoreInflictedMessageHandler (@004c0200 -- which names itself in its own +// Verify string, "BTPlayer::ScoreInflictedMessageHandler"); +// * ScoreMessageHandler's type-0 arm Verify-rejects ON PURPOSE, because this +// interceptor guarantees type 0 never gets that far; +// * the port had the handler, faithfully reconstructed, and NO interceptor. +// Block B sends its inflicted report under Player::ScoreMessageID, so every +// one of them landed in the rejecting arm and banked 0. +// +// The KB previously recorded @004c0200 as "in NO table entry: dead code" and +// concluded 1995 folded an uninitialised stack float into the shooter's score +// on every non-lethal hit -- and the port's per-hit crediting was retired as an +// "invention" on that basis (#45/#134, build 787, the build players report +// scoring regressed in). That reading was wrong: the handler is live through +// THIS vtable slot, and the original manual's SCORING CHART independently +// corroborates what it computes -- "+1 each damage point scored on opponent's +// armor" and "-1 each self-inflicted point of armor damage", which is exactly +// this handler's negate-if-target-is-self arm. combat-damage.md is corrected. +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +void + BTPlayer::Dispatch(Receiver::Message *what) +{ + if (what != 0 + && what->messageID == Player::ScoreMessageID + && ((BTPlayer::ScoreMessage *)what)->scoreType + == BTPlayer::ScoreMessage::DamageInflictedScore) + { + ScoreInflictedMessageHandler((BTPlayer::ScoreMessage *)what); + return; + } + Player::Dispatch(what); +} + + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ScoreInflictedMessageHandler // @@ -2351,9 +2392,14 @@ void BTMechPostCombatReports( else if (damage_tally != 0.0f && shooter_player != 0) { // - // Block B: the plain inflicted report. Wire fidelity only -- the 0x16 - // handler Verify-rejects type 0 and banks award 0 (1995 banked an - // uninitialized stack float; see the handler's type-0 arm note). + // Block B: the inflicted report -- "+1 each damage point scored on + // opponent's armor" (original manual scoring chart). Goes out under + // Player::ScoreMessageID with type 0, exactly as the binary does; the + // Dispatch override (@004bffa0) intercepts it into + // ScoreInflictedMessageHandler. It is NOT wire-fidelity-only -- the + // old note here claimed the handler banks 0 because @004c0200 was + // "dead code", which was a misreading of the vtable; corrected + // 2026-08-07 and the interceptor restored. // BTPlayer::ScoreMessage inflicted( Player::ScoreMessageID, diff --git a/game/reconstructed/btplayer.hpp b/game/reconstructed/btplayer.hpp index 1b97a90..1f56c9f 100644 --- a/game/reconstructed/btplayer.hpp +++ b/game/reconstructed/btplayer.hpp @@ -288,6 +288,23 @@ class DropZone__ReplyMessage; private: static const HandlerEntry MessageHandlerEntries[]; + public: + // + // @004bffa0 -- the DISPATCH OVERRIDE (vtable @00513300 slot 3). The + // binary uses ONE message id (0x16) for all three score reports and + // splits type 0 off HERE, before base dispatch: + // + // if (msg->id == 0x16 && msg->type == 0) ScoreInflicted(msg); + // else base dispatch; + // + // which is why ScoreMessageHandler's own type-0 arm can Verify-reject: + // the interceptor guarantees type 0 never reaches it. Without this + // override every inflicted report lands in the rejecting arm and scores + // nothing -- see the note in btplayer.cpp. + // + virtual void + Dispatch(Receiver::Message *what); // @004bffa0 + protected: static MessageHandlerSet& GetMessageHandlers(); diff --git a/reference/manual/scoring_chart.webp b/reference/manual/scoring_chart.webp new file mode 100644 index 0000000..a475a43 Binary files /dev/null and b/reference/manual/scoring_chart.webp differ diff --git a/scratchpad/night13/ghost108.py b/scratchpad/night13/ghost108.py new file mode 100644 index 0000000..27e456e --- /dev/null +++ b/scratchpad/night13/ghost108.py @@ -0,0 +1,91 @@ +"""Night 13: post the ghost/eject findings to #108 and settle #144. ASCII only.""" +import sys +sys.path.insert(0, r"C:\git\bt411\scratchpad\night7") +import gitea + +gitea.comment(108, """Night 13 (2026-08-06, build **4.11.817**) -- eject-ghosts, and why the logs barely saw them. + +## Field finding + +Testers reported MANY ghost mechs, all from panic-button self-destructs, none respawning +properly -- and the operator has seen eject-respawn work every time before this night, so this is a +REGRESSION, not a gap (night 12 = build 774). + +The logs show it happening, on three independent witnesses. Dave punch-ejected; on HIS machine the +whole death path ran (`DeathWithoutHonor`, `PUNCH-OUT`, death transition, wreck, explosion), he +respawned, took a drop-zone slot, and drove another 193 logged frames **in the same round**, which +then ended normally -- no crash, no disconnect. On BOTH peers his mech's history ends at the wreck +and never resumes; host 2 vanishes from santo's world for the remaining ~4300 lines of that round. + +The discriminator is clean: the SAME mech's NORMAL deaths replicated their respawns 4-6 times in +that same session. Only the eject failed. + +## Why the ghost detector said almost nothing + +One `[ghost]` line all night, for an unrelated live mech. The gate is + + && !owner->WreckBuried() // "buried wrecks are expected-silent" + WreckBuried() { return collisionVolumeCount == 0; } + +An eject-ghost IS a wreck that stopped receiving records, so the detector files it under +expected-silent. It can only ever catch a LIVE replicant going quiet -- structurally blind to this +failure. + +## Why the census said "one" when testers saw many + +Counting ghosts meant pairing the un-wreck receipt against +`[BTrender] wreck: 'thrdbr.bgf' missing -> gendbr.bgf fallback` -- a MISSING-ASSET warning, not a +death, which only prints for chassis whose wreck model is absent. Fixed in `4642129`: every +replicant entering the wreck state now emits an ungated + + [wreck] replicant H:E entered wreck state (mode X->9) at (x,z) + +symmetric with the existing un-wreck line, so the count is exactly (enters - un-wrecks) per entity. +Verified 2-node: 5 enters / 5 exits, exactly paired -- **while the old marker printed ZERO times in +the same run**. Five real deaths, invisible to what the census was reading. Treat the "one ghost on +night 13" number as a floor, not a count. + +Census tooling: `scratchpad/night13/ghostcensus.py` (NB: filter the log owner's OWN mech -- an +owner's own wreck can never pair, since `un-wrecked` only logs for replicants; not doing so +manufactures false positives in every log). + +## NOT reproduced by bench -- five rigs failed to trigger a punch-out at all + +`BT_BTNTEST` never reached the mapper for the panic button (0x3D) or the searchlight (0x14): no +`[eject]`, no `PUNCH-OUT`, not even a lamp change -- so it is the SEAM, not the address. +`BT_EJECT_AT` (mech4.cpp:3310, the purpose-built hook) did not fire either. Benches are staged +(`ejectghost.sh`, `ejectlight.sh`, `ejectat.sh`) with their failure modes in the headers. + +What the benches DID establish: **normal death replication is healthy on 817** -- 9 force-kill +deaths, 8 peer un-wrecks, 0 ghost lines. So an eject-only shortfall is the defect, not a broken rig. + +## Open lead + +The operator observed that the ghost mechs all had their SEARCHLIGHTS ON. Untested -- there is +currently no key or env that toggles the searchlight headlessly, so it needs a small bench hook. +Mechanically plausible: the searchlight is the only thing that attaches extra geometry into a peer's +mech render tree (btl4vid.cpp builds a `spot.bgf` cone as a `DPLStaticChildRenderable` parented to +the lamp's mount-segment renderable), and the death path does a wreck swap while respawn does +"rebuilt intact model (N segs restored, hulk dropped)" -- a child renderable held across those two +rebuilds is a lifetime hazard, and it would only bite mechs whose lamp was ON. + +## Regression window + +Commits 775-817. Two touch the death path: `91bd286` (787) rewrote the VehicleDead dispatch -- +the message that drives the respawn cycle -- including the `killer == victim` eject case and the +filter that had blocked the panic-eject path; and `297127d` (784, death blast). Unverified.""") + +gitea.close(144, """**NOT A BUG -- authentic. Closing.** + +The eject sets `suppressConsole` (+0x258, `BTPlayerEjectBookkeeping`), and the #89 death-blast gate +reads it: + + gates : owning player's advancedDamageOn (+0x264) AND + NOT suppressConsole (+0x258 -- eject sets it: punch-outs never blast) + [T1 @0x4a0aa8-0x4a0ad6] + +So a mech that punches out does not splash its neighbourhood, by design, in the 1995 binary. SAURON's +observation ("splash worked on mechs that died close, but no splash on a panic / eject / suicide +death") is a correct reading of authentic behaviour. + +Closing so nobody "fixes" it. Found while investigating the night-13 eject ghosts (#108).""") diff --git a/scratchpad/night13/scoreverify.sh b/scratchpad/night13/scoreverify.sh new file mode 100644 index 0000000..65e7161 --- /dev/null +++ b/scratchpad/night13/scoreverify.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# ========================================================================= +# SCORING verify -- the type-0 interceptor (@004bffa0) restored. +# +# Composition lifted from night12/scorekill.sh, which is known to produce a +# clean cross-node kill: A (madcat, shooter) zone-walk-hammers B (loki, +# spinner) at 90u until B dies. +# +# THE A/B IS READABLE IN ONE RUN, because the old behaviour left a receipt: +# BEFORE every inflicted report hit ScoreMessageHandler's type-0 arm and +# tripped Verify "ScoreMessageHandler should not be given +# DamageInflictedScoreMessages!" -- night12's bench listed those +# Verify prints as an expected PASS signal. +# AFTER the interceptor routes type 0 to ScoreInflictedMessageHandler, so +# those Verify prints must be GONE and matchlog SCORE type=0 rows +# with non-zero awards must appear instead. +# +# Chart cross-check (original manual, via Lynx): "+1 each damage point scored +# on opponent's armor". The handler scales by tonnage ratio and the role's +# damageInflictedModifier, so award != damage exactly -- but it must TRACK +# damage, not sit at zero, and must be NEGATIVE for self-damage. +# ========================================================================= +set -x +. /c/git/bt411/scratchpad/night6/bench_common.sh +cd /c/git/bt411/content || exit 1 +bt_assert_player_env +taskkill //F //IM btl4.exe > /dev/null 2>&1 +sleep 2 +rm -f sv_a.log sv_b.log sv_relay.log matchlog_*.txt +bt_expert_egg MP.EGG SV.EGG +sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/" SV.EGG +python - << 'EOF' +lines = open('SV.EGG').read().splitlines(True) +n = 0 +for i, l in enumerate(lines): + if l.startswith('vehicle='): + n += 1 + lines[i] = 'vehicle=madcat\n' if n == 1 else 'vehicle=loki\n' +open('SV.EGG', 'w').writelines(lines) +print('vehicles set:', n) +EOF + +( export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_SCORE_LOG=1 + bt_launch sv_b.log SV.EGG 0x0C -net 1601 ) +sleep 2 +( export BT_ZONE_WALK=8 BT_WALK_ZONES=dz_ldleg + export BT_GOTO=enemy BT_GOTO_STOP=90 BT_KEY_NOFOCUS=1 + export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_SCORE_LOG=1 + bt_launch sv_a.log SV.EGG 0x03 -net 1501 ) +sleep 5 +python ../tools/btconsole.py SV.EGG 127.0.0.1:1501 127.0.0.1:1601 > sv_relay.log 2>&1 & +RELAY=$! +sleep 260 +kill $RELAY 2>/dev/null +sleep 3 +bt_kill_ours +sleep 2 +taskkill //F //IM btl4.exe > /dev/null 2>&1 +sleep 3 + +echo "=================== SCORING VERIFY ===================" +echo "--- 0. did combat happen at all? (if 0 hits the run is VOID) ---" +echo -n "damage rows on victim B : "; grep -ac "dmghit\|DMG" sv_b.log +echo +echo "--- 1. THE OLD SYMPTOM: type-0 rejections (must be ZERO now) ---" +echo -n "'should not be given DamageInflictedScore' Verify prints: " +cat sv_a.log sv_b.log | grep -ac "should not be given DamageInflictedScore" +echo +echo "--- 2. THE FIX: inflicted score rows (matchlog SCORE type=0) ---" +echo -n "type=0 rows: "; cat matchlog_*.txt 2>/dev/null | grep -ac "type=0" +cat matchlog_*.txt 2>/dev/null | grep -a "type=0" | head -8 +echo +echo "--- 3. award vs damage: does the credit TRACK damage? ---" +python - << 'EOF' +import glob, re +aw = [] +for fn in glob.glob('matchlog_*.txt'): + for line in open(fn, errors='replace'): + m = re.search(r'type=0 award=(-?[\d.]+) total=(-?[\d.]+)', line) + if m: + aw.append((float(m.group(1)), float(m.group(2)))) +if not aw: + print(' NO type=0 rows -- interceptor did not fire') +else: + pos = [a for a, t in aw if a > 0] + neg = [a for a, t in aw if a < 0] + print(' rows=%d positive=%d negative(self)=%d' % (len(aw), len(pos), len(neg))) + print(' award range: %.2f .. %.2f running total ends at %.2f' + % (min(a for a, t in aw), max(a for a, t in aw), aw[-1][1])) +EOF +echo +echo "--- 4. kill path un-regressed (type=2) + respawn ---" +echo -n "type=2 kill rows: "; cat matchlog_*.txt 2>/dev/null | grep -ac "type=2" +cat matchlog_*.txt 2>/dev/null | grep -a "type=2" | head -3 +echo -n "victim death cycles: "; grep -ac "death cycle START" sv_b.log