MP match forensics: per-peer matchlog + relay auto-upload + matchcheck

For the 8-player playtest.  Damage authority is VICTIM-side, so no single
peer sees the whole match: every -net instance now writes a compact
machine-parseable matchlog_<date>_<time>_<pid>.txt (auto-armed by -net;
BT_MATCHLOG=1/0 overrides; solo silent), and at mission end a relay-mode
pod dials the relay game port (the seat-request throwaway-dial pattern,
envelope route -9) and streams the file back -- the relay saves every
peer's copy under matchlogs/ on the operator's machine, so testers send
nothing by hand.  tools/matchcheck.py <dir> reconciles all of them:
damage claimed (FIRE/PROJ/SPLASH/RAM, shooter-side) vs applied (DMG,
victim-side authoritative), kill/death replication across observers,
PLAYER_DEAD counts, scores, version skew, replicant-application and
unattributed-damage anomalies, mid-mission disconnects.

Hooks at the authoritative sites: Mech::TakeDamageMessageHandler (DMG,
post-base, zone + post-application damageLevel), MechWeapon::
SendDamageMessage (FIRE), projectile impact/splash/collision dispatch
(PROJ/SPLASH/RAM), wreck entry (DEATH -- a ONE-SHOT latch at
MovementMode 9, NOT the transition: a replicant arrives at 9 straight
from the type-6 record header and never runs the transition branch),
BTPlayer vehicle/death/score handlers (VEHICLE/PLAYER_DEAD/SCORE), zone
crit cascade (CRIT), APP.cpp RunningMission (MISSION), L4NET host
handlers (PEER_UP/PEER_DOWN).  Every line t=/w=/st=-stamped + fflushed.

Verified 2-node: mesh run pairs A's 22 FIRE 1:1 with B's 22 DMG (same
amounts, cylinder-resolved zones, ~200ms latency); force-damage kill run
logs 103 DMG + 3 DEATH inst=M + 3 PLAYER_DEAD across three respawn
cycles victim-side and the kill SCOREs shooter-side; relay-mode run
auto-uploaded BOTH peers' files at the mission-clock stop and matchcheck
produced the full report with exactly one (correct) anomaly -- the force
hook's claim-less damage, the unattributed-damage detector working.
Relay gained a route-specific 8MB cap for the upload frame (game frames
stay capped at 1600).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-22 13:22:10 -05:00
co-authored by Claude Opus 4.8
parent 3097d269b4
commit 753540de96
17 changed files with 947 additions and 2 deletions
+2
View File
@@ -29,3 +29,5 @@ content/fe_last.ini
/join.bat
/join_lan.bat
/play_steam.bat
# MP match forensic logs (matchlog.cpp) -- runtime artifacts
matchlog_*.txt
+3
View File
@@ -286,6 +286,7 @@ add_library(bt410_l4 STATIC
"game/reconstructed/emitter.cpp"
"game/reconstructed/gnrator.cpp"
"game/reconstructed/gyro.cpp"
"game/reconstructed/matchlog.cpp"
"game/reconstructed/heat.cpp"
"game/reconstructed/heatfamily_reslice.cpp"
"game/reconstructed/hud.cpp"
@@ -326,6 +327,7 @@ add_library(bt410_l4 STATIC
"game/original/BT_L4/BTL4ARND.CPP"
)
target_include_directories(bt410_l4 BEFORE PRIVATE
"${CMAKE_BINARY_DIR}"
"${CMAKE_SOURCE_DIR}/engine/shim"
"${CMAKE_SOURCE_DIR}/game/reconstructed"
"${CMAKE_SOURCE_DIR}/game/original/BT"
@@ -347,6 +349,7 @@ add_custom_target(btversion
-P ${CMAKE_SOURCE_DIR}/tools/btversion.cmake
BYPRODUCTS ${CMAKE_BINARY_DIR}/btversion.h
COMMENT "Stamping btversion.h")
add_dependencies(bt410_l4 btversion) # matchlog.cpp stamps btversion.h into its HDR line
add_executable(btl4 WIN32 "${CMAKE_SOURCE_DIR}/game/btl4main.cpp")
add_dependencies(btl4 btversion)
target_include_directories(btl4 BEFORE PRIVATE
+47
View File
@@ -74,6 +74,53 @@ emulator** (⚠ `NotationFile::ReadText` expects NUL-SEPARATED lines). [T2]
INLINE (`char[N]` at the binary offsets), not a `const char*` pointer (garbage cross-pod). Check
EVERY MakeMessage for pointer payloads. [T2]
## MATCH FORENSICS (`BT_MATCHLOG`, permanent -- 2026-07-22) [T2]
Per-peer forensic combat log for MP playtests, built for the first 8-player session. Because
damage authority is VICTIM-side, no single peer sees the whole match: every instance writes
`matchlog_<date>_<time>_<pid>.txt` in its CWD (content\ for the player bats), testers send them
back, and **`tools/matchcheck.py <dir>`** reconciles all of them -- damage claimed (shooter-side
FIRE/PROJ/SPLASH/RAM) vs applied (victim-side DMG, the authoritative armor math), kill/death
replication across observers (DEATH inst=M authoritative vs inst=R sightings), PLAYER_DEAD
counts, SCORE totals, version skew, replicant-application anomalies, mid-mission PEER_DOWN.
- **Arming:** `BT_MATCHLOG=1` force-on / `=0` force-off; unset -> AUTO-ON when the command line
has `-net` (every MP path). Solo stays silent. Zero tester configuration.
- **Implementation:** `game/reconstructed/matchlog.{hpp,cpp}` (event catalogue in the hpp);
hooks at the authoritative sites: `Mech::TakeDamageMessageHandler` (DMG, post-base with the
zone's post-application `damageLevel`), `MechWeapon::SendDamageMessage` (FIRE),
`BTUpdateProjectiles` impact + `BTApplySplashDamage` + `BTDispatchCollisionDamage`
(PROJ/SPLASH/RAM), `Mech::UpdateDeathState` transition (DEATH, runs on every node --
replicated-death proof), `BTPlayer` CreatePlayerVehicle/VehicleDead/Score handlers
(VEHICLE/PLAYER_DEAD/SCORE), `Mech__DamageZone::SendSubsystemDamage` (CRIT), engine seams
APP.cpp `SetState(RunningMission)` (MISSION) + L4NET host handlers (PEER_UP/PEER_DOWN).
Every line: `<TAG> t=<tickms> w=<HH:MM:SS.mmm> st=<appstate> k=v...`, fflushed (crash-safe).
`EntityID::GetHostID()` is non-const -> the `BTMatchHostOf/BTMatchLocalOf` const-safe helpers.
- **DEATH is a ONE-SHOT at wreck entry (MovementMode 9), not at the transition**: a replicant's
MovementMode arrives as 9 straight from the type-6 record header and never runs the transition
branch, so the observer-side replicated-death line only fires from the mode==9 branch
(`matchlogDeathLogged` latch, self-re-arming while alive -- port bookkeeping member beside
`wreckSmokeTimer`). RESET-based respawns REUSE the mech, so respawns do NOT emit new VEHICLE
lines -- deaths are counted from PLAYER_DEAD.
- **AUTO-UPLOAD (relay mode)**: at mission end (`RunMissions` returns, btl4main) the pod dials the
relay game port -- the seat-request throwaway-dial pattern -- and sends envelope route **-9**
`{filename NUL + file bytes}`; the relay saves `matchlogs/<peer-ip>_<name>` in its CWD and
prints `[relay] matchlog saved:` (visible in the operator console). The resolved relay game
address is cached file-scope (`BTRelayRememberGameAddress`) because the upload runs after the
network teardown. Mesh/solo: no upload; the local file remains for manual send either way.
- **Verified 2-node**: (a) mesh console run -- A's 22 FIRE lines pair 1:1 with B's 22 victim-side
DMG applications (same amounts, cylinder-resolved zones, ~200ms wire latency); (b) force-damage
kill run -- B: 103 DMG + 3 DEATH inst=M + 3 PLAYER_DEAD over three respawn cycles, A: 3 kill
SCOREs; (c) relay-mode run -- BOTH peers auto-uploaded at the 60s mission-clock stop (relay
`matchlog saved` x2), observer DEATH inst=R x2 via the latch, and `matchcheck matchlogs/`
produced the full report: kill attribution symmetric, deaths seen 2/2 peers, and exactly ONE
anomaly -- the force hook's 912.0 received-with-no-claim damage, i.e. the unattributed-damage
detector firing correctly (real weapons always write a FIRE/PROJ/RAM claim). The teardown
PEER_DOWN is not flagged (matchcheck compares its st= against the MISSION line's run-state id).
- **Test-rig traps burned this session**: a Cygwin `kill` does not reliably reach a Windows
python child -- a zombie relay on the port silently serves the NEXT run with OLD code (scripts
now refuse to start over a bound port + finish the relay via the netstat-resolved Windows PID);
`taskkill //PID $!` is a PID-NAMESPACE bug ($! is a Cygwin pid); and `BT_SPAWN_XZ` in -net runs
left the mech with 6/30 executable subsystems (dev hook, unfit for MP tests -- unresolved).
## Debug tooling (`BT_NET_TRACE`, permanent)
`[net-tx]/[net-rx]` (L4NET), `[net-upd]` (update lookup), `[upd-repl]`, `[ent-exec]` (state ladder).
Per-instance: `BT_LOG=<file>` + `BT_AFFINITY=<mask>` (CPU pin). Update stream ≈ 60 Hz × 144-byte
+10
View File
@@ -1486,6 +1486,11 @@ void
#endif
Tell("Application::RunMissionMessageHandler - running mission\n");
{ // MP match forensics (game-side matchlog.cpp; single-exe link)
extern int BTMatchLogActive();
extern void BTMatchLog(const char *, const char *, ...);
if (BTMatchLogActive()) BTMatchLog("MISSION", "run");
}
applicationState.SetState(RunningMission);
gameStarted = Now();
break;
@@ -1613,6 +1618,11 @@ void
case ResumingMission:
Tell("Application::ResumeMissionMessageHandler - Running mission\n");
{ // MP match forensics (game-side matchlog.cpp; single-exe link)
extern int BTMatchLogActive();
extern void BTMatchLog(const char *, const char *, ...);
if (BTMatchLogActive()) BTMatchLog("MISSION", "run");
}
applicationState.SetState(RunningMission);
gameStarted = Now();
break;
+146 -1
View File
@@ -158,7 +158,9 @@ enum
RELAY_ROUTE_UDP_ACK = -5, // relay->pod: UDP HELLO acknowledged
RELAY_ROUTE_SEAT_REQUEST= -6, // pod->relay: assign me a free seat
RELAY_ROUTE_SEAT_ASSIGN = -7, // relay->pod: int32 hostID + NUL tag
RELAY_ROUTE_SEAT_FULL = -8 // relay->pod: roster full
RELAY_ROUTE_SEAT_FULL = -8, // relay->pod: roster full
RELAY_ROUTE_MATCHLOG = -9 // pod->relay: matchlog upload
// (payload: filename NUL + file bytes)
};
#define RELAY_HELLO_MAGIC 0x31525442 // 'BTR1' little-endian
// LAN auto-discovery (BT_RELAY=auto): probe broadcast on this UDP port; the
@@ -177,6 +179,7 @@ struct RelayUdpEnvelope
int fromHost;
unsigned int sequence;
};
void BTRelayRememberGameAddress(const SOCKADDR_IN &game_address); // fwd (defined below)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// BTRelayWaitStatus -- progress text into the join.bat window during the
@@ -558,6 +561,7 @@ L4NetworkManager::L4NetworkManager():
Str_Copy(relaySelf, self_env, sizeof(relaySelf));
}
relayMode = True;
BTRelayRememberGameAddress(relayGameAddress); // matchlog auto-upload
DEBUG_STREAM << "[relay] mode ON: relay "
<< inet_ntoa(relayConsoleAddress.sin_addr) << ":"
<< (int)ntohs(relayConsoleAddress.sin_port)
@@ -567,6 +571,129 @@ L4NetworkManager::L4NetworkManager():
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// MATCHLOG AUTO-UPLOAD (2026-07-22). At mission end the pod sends its match
// forensic log (game/reconstructed/matchlog.cpp) back to the relay so the
// operator collects every peer's file without the testers doing anything.
// Same shape as the seat-request throwaway dial: one short TCP connection to
// the relay game port, one MATCHLOG envelope {filename NUL + bytes}, close.
// Relay-mode only (the mesh has no central point -- manual send remains the
// fallback there, and the file always stays on disk regardless).
//
// The resolved relay game address is CACHED file-scope when relay mode arms,
// because the upload runs AFTER the application/network teardown when the
// L4NetworkManager (and its relayGameAddress member) is already gone.
//
static SOCKADDR_IN s_relayGameAddrCache;
static int s_relayGameAddrCached = 0;
void BTRelayRememberGameAddress(const SOCKADDR_IN &game_address)
{
s_relayGameAddrCache = game_address;
s_relayGameAddrCached = 1;
}
void BTRelayUploadMatchLog(void)
{
extern const char *BTMatchLogPath();
extern void BTMatchLogClose();
if (!s_relayGameAddrCached)
return; // mesh/solo: no relay
const char *path = BTMatchLogPath();
if (path == NULL || path[0] == '\0')
return; // matchlog never armed
BTMatchLogClose(); // complete file on disk
FILE *f = fopen(path, "rb");
if (f == NULL)
return;
fseek(f, 0, SEEK_END);
long size = ftell(f);
fseek(f, 0, SEEK_SET);
if (size <= 0 || size > 8L * 1024L * 1024L) // sanity cap
{
fclose(f);
return;
}
char *data = (char *)malloc((size_t)size);
if (data == NULL || fread(data, 1, (size_t)size, f) != (size_t)size)
{
free(data);
fclose(f);
return;
}
fclose(f);
// The app teardown may already have run WSACleanup; re-arm (ref-counted).
WSADATA wsa_data;
WSAStartup(MAKEWORD(2, 2), &wsa_data);
// Bounded non-blocking dial (a dead relay must not hang process exit).
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
int ok = 0;
if (sock != INVALID_SOCKET)
{
u_long non_blocking = 1;
ioctlsocket(sock, FIONBIO, &non_blocking);
connect(sock, (SOCKADDR *)&s_relayGameAddrCache,
sizeof(s_relayGameAddrCache));
fd_set write_set;
FD_ZERO(&write_set);
FD_SET(sock, &write_set);
timeval tv;
tv.tv_sec = 3;
tv.tv_usec = 0;
if (select(0, NULL, &write_set, NULL, &tv) == 1)
{
non_blocking = 0;
ioctlsocket(sock, FIONBIO, &non_blocking); // blocking for the send
int name_length = (int)strlen(path) + 1; // filename + NUL
RelayTcpEnvelope envelope;
envelope.route = RELAY_ROUTE_MATCHLOG;
envelope.length = (unsigned int)(name_length + size);
// partial-send-safe local loop (RelaySendAll is a manager method
// and the manager is gone by now)
struct Local
{
static int SendAll(SOCKET s, const char *p, int n)
{
int done = 0;
while (done < n)
{
int sent = send(s, p + done, n - done, 0);
if (sent <= 0)
return 0;
done += sent;
}
return 1;
}
};
ok = Local::SendAll(sock, (const char *)&envelope, sizeof(envelope))
&& Local::SendAll(sock, path, name_length)
&& Local::SendAll(sock, data, (int)size);
if (ok)
{
// Graceful close so the relay reads everything before the FIN.
shutdown(sock, SD_SEND);
char drain[64];
fd_set read_set;
FD_ZERO(&read_set);
FD_SET(sock, &read_set);
tv.tv_sec = 2;
tv.tv_usec = 0;
if (select(0, &read_set, NULL, NULL, &tv) == 1)
recv(sock, drain, sizeof(drain), 0);
}
}
closesocket(sock);
}
free(data);
DEBUG_STREAM << "[relay] matchlog upload "
<< (ok ? "SENT: " : "FAILED (file kept for manual send): ")
<< path << " (" << size << " bytes)\n" << std::flush;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// RelayDiscover -- BT_RELAY=auto: find the relay on the LAN by UDP broadcast.
// Sends the probe to 255.255.255.255 AND 127.0.0.1 (same-box sessions --
@@ -1334,6 +1461,15 @@ void
// counter of hosts online.
//
l4connected_host->SetConnectStatus(L4Host::OnLineConnectionStatus);
{ // MP match forensics (game-side matchlog.cpp; single-exe link)
extern int BTMatchLogActive();
extern void BTMatchLog(const char *, const char *, ...);
if (BTMatchLogActive())
BTMatchLog("PEER_UP", "host=%d type=%d",
(int)HostConnected->hostID,
(int)l4connected_host->GetHostType());
}
switch(l4connected_host->GetHostType())
{
case GameMachineHostType:
@@ -1413,6 +1549,15 @@ void L4NetworkManager::HostDisconnectedMessageHandler(HostDisconnectedMessage* H
// can announce it later, then close the connection.
//
l4connected_host->SetConnectStatus(L4Host::NoNetworkConnectionStatus);
{ // MP match forensics (game-side matchlog.cpp; single-exe link)
extern int BTMatchLogActive();
extern void BTMatchLog(const char *, const char *, ...);
if (BTMatchLogActive())
BTMatchLog("PEER_DOWN", "host=%d type=%d",
(int)HostDisconnected->hostID,
(int)l4connected_host->GetHostType());
}
SOCKADDR_IN temp_net_address = *l4connected_host->GetNetworkAddress();
CloseConnection(HostDisconnected->streamPointer);
//
+9
View File
@@ -739,6 +739,15 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
app_manager->RunMissions();
std::cout << "[boot] RunMissions returned (mission loop exited)." << std::endl << std::flush;
// MATCHLOG AUTO-UPLOAD (2026-07-22): in relay mode, send this peer's
// match forensic log back to the operator's relay (saved under the
// relay's matchlogs\ folder). No-op for mesh/solo or when the
// matchlog never armed; the local file stays either way.
{
extern void BTRelayUploadMatchLog(void);
BTRelayUploadMatchLog();
}
#ifdef BT_GLASS
//
// Front-end loop (glass step 3): a menu-launched mission returns
+40
View File
@@ -70,6 +70,7 @@
//
#include <bt.hpp>
#include <matchlog.hpp> // MP match forensics (VEHICLE/PLAYER_DEAD/SCORE)
#pragma hdrstop
#if !defined(BTPLAYER_HPP)
@@ -414,6 +415,12 @@ void
//
++deathCount; // this+0x200
message->deathCount = deathCount; // message+0x38
// MP MATCH FORENSICS (matchlog.hpp): the owning player's death
// notification (fires exactly once per death, deduped above).
if (BTMatchLogActive())
BTMatchLog("PLAYER_DEAD", "player=%d:%d deaths=%d",
BTMatchHostOf(GetEntityID()), (int)GetEntityID(), deathCount);
// TODO(bring-up): scenarioRole is the scoring role looked up from the mission's
// role registry (btplayer.cpp:815, currently deferred), so it is NULL for the
// minimal dev missions. Only debit a life when a role is present.
@@ -521,6 +528,14 @@ void
//
currentScore +=
(MECH_TONNAGE(target_mech) / MECH_TONNAGE(our_mech)) * award; // (+0x4bc / +0x4bc), this+0x278
// MP MATCH FORENSICS (matchlog.hpp): inflicted-damage score (type=0),
// logged with the post-accumulate total.
if (BTMatchLogActive())
BTMatchLog("SCORE",
"player=%d:%d type=0 award=%.2f total=%.2f kills=%d",
BTMatchHostOf(GetEntityID()), (int)GetEntityID(),
(float)award, (float)currentScore, killCount);
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -712,6 +727,17 @@ void
// straggler score lands the award without the vehicle response.
//
message->scoreAward = award; // msg+0x1c
// MP MATCH FORENSICS (matchlog.hpp): every folded score award. total is
// the score BEFORE this award lands (the base applies it right after) --
// matchcheck sums the awards; kills counts ride along.
if (BTMatchLogActive())
BTMatchLog("SCORE",
"player=%d:%d type=%d award=%.2f total=%.2f kills=%d",
BTMatchHostOf(GetEntityID()), (int)GetEntityID(),
(int)message->scoreType, (float)award, (float)currentScore,
killCount);
if (playerVehicle == 0)
{
currentScore += message->scoreAward; // the base's award apply
@@ -920,6 +946,20 @@ void
Register_Object(playerVehicle);
}
// MP MATCH FORENSICS (matchlog.hpp): the player has a vehicle (initial
// spawn AND every drop-zone respawn route here) -- the line that binds a
// player id to a mech id for the whole analysis.
if (BTMatchLogActive() && playerVehicle != 0)
BTMatchLog("VEHICLE",
"player=%d:%d mech=%d:%d deaths=%d pos=%.1f,%.1f,%.1f",
BTMatchHostOf(GetEntityID()), (int)GetEntityID(),
BTMatchHostOf(playerVehicle->GetEntityID()),
(int)playerVehicle->GetEntityID(),
deathCount,
(float)mech_location.linearPosition.x,
(float)mech_location.linearPosition.y,
(float)mech_location.linearPosition.z);
// --- BRING-UP: spawn a stationary target/enemy mech (gameplay scaffolding) ---
// The mission system is networked (one pilot per host) and BT's bot/AI code
// is absent, so a solo mission has nothing to fight. When BT_SPAWN_ENEMY is
+142
View File
@@ -0,0 +1,142 @@
//===========================================================================//
// matchlog.cpp -- per-peer MP forensic combat log (PORT tooling, 2026-07-22)//
//---------------------------------------------------------------------------//
// See matchlog.hpp for the contract + event catalogue. This TU only owns
// the file + the line writer; the event call sites live at the authoritative
// combat hooks (mech.cpp DMG, mechweap.cpp FIRE, mech4.cpp DEATH/PROJ/
// SPLASH/RAM, btplayer.cpp VEHICLE/PLAYER_DEAD/SCORE, mechdmg.cpp CRIT) and
// two engine seams (APP.cpp MISSION, L4NET.CPP PEER_UP/DOWN via extern).
//===========================================================================//
#include <bt.hpp>
#include <app.hpp> // Application/application (the st= line prefix)
#include <matchlog.hpp>
#include <btversion.h>
#include <stdio.h>
#include <stdarg.h>
int
BTMatchHostOf(const EntityID &id)
{
EntityID copy(id); // GetHostID() is non-const
return (int)copy.GetHostID();
}
int
BTMatchLocalOf(const EntityID &id)
{
return (int)id; // operator int() const == localID
}
static FILE *s_matchLog = 0;
static int s_matchState = -1; // -1 unresolved, 0 off, 1 armed
static char s_matchPath[160] = "";
const char *
BTMatchLogPath()
{
return s_matchPath;
}
void
BTMatchLogClose()
{
if (s_matchLog != 0)
{
fflush(s_matchLog);
fclose(s_matchLog);
s_matchLog = 0;
s_matchState = 0; // further events: no-op
}
}
int
BTMatchLogActive()
{
if (s_matchState >= 0)
return s_matchState;
//
// Resolve the gate once. BT_MATCHLOG overrides; the default arms for
// any "-net" launch (every MP path) and stays silent for solo.
//
int on;
const char *e = getenv("BT_MATCHLOG");
if (e != 0)
{
on = (atoi(e) != 0);
}
else
{
const char *cmd = GetCommandLineA();
on = (cmd != 0 && strstr(cmd, "-net") != 0);
}
if (on)
{
SYSTEMTIME now;
GetLocalTime(&now);
sprintf(s_matchPath, "matchlog_%04u%02u%02u_%02u%02u%02u_%lu.txt",
(unsigned)now.wYear, (unsigned)now.wMonth, (unsigned)now.wDay,
(unsigned)now.wHour, (unsigned)now.wMinute, (unsigned)now.wSecond,
(unsigned long)GetCurrentProcessId());
s_matchLog = fopen(s_matchPath, "w");
if (s_matchLog == 0)
s_matchPath[0] = '\0';
}
s_matchState = (s_matchLog != 0) ? 1 : 0;
if (s_matchState)
{
char machine[MAX_COMPUTERNAME_LENGTH + 1] = "?";
DWORD machine_length = sizeof(machine);
GetComputerNameA(machine, &machine_length);
const char *self = getenv("BT_SELF");
const char *relay = getenv("BT_RELAY");
SYSTEMTIME now;
GetLocalTime(&now);
BTMatchLog("HDR",
"ver=\"%s\" date=%04u-%02u-%02u pid=%lu machine=\"%s\" "
"self=\"%s\" relay=\"%s\" cmd=\"%s\"",
BT_VERSION_FULL,
(unsigned)now.wYear, (unsigned)now.wMonth, (unsigned)now.wDay,
(unsigned long)GetCurrentProcessId(), machine,
self != 0 ? self : "",
relay != 0 ? relay : "",
GetCommandLineA() != 0 ? GetCommandLineA() : "");
}
return s_matchState;
}
void
BTMatchLog(const char *tag, const char *format, ...)
{
if (!BTMatchLogActive())
return;
char body[640];
va_list args;
va_start(args, format);
_vsnprintf(body, sizeof(body) - 1, format, args);
body[sizeof(body) - 1] = 0;
va_end(args);
SYSTEMTIME now;
GetLocalTime(&now);
// The app state rides every line (WaitingForEgg .. RunningMission ..)
// so the tool can window events to the live mission without a separate
// state-transition hook.
int app_state = (application != 0)
? (int)application->GetApplicationState() : -1;
fprintf(s_matchLog, "%s t=%lu w=%02u:%02u:%02u.%03u st=%d %s\n",
tag, (unsigned long)GetTickCount(),
(unsigned)now.wHour, (unsigned)now.wMinute,
(unsigned)now.wSecond, (unsigned)now.wMilliseconds,
app_state, body);
fflush(s_matchLog);
}
+65
View File
@@ -0,0 +1,65 @@
//===========================================================================//
// matchlog.hpp -- per-peer MP forensic combat log (PORT tooling, 2026-07-22)//
//---------------------------------------------------------------------------//
// One compact machine-parseable text file per game instance recording the
// combat-economy events this peer AUTHORITATIVELY owns. MP damage authority
// is VICTIM-side (A shoots B -> zone resolution + armor math run on B's
// machine), so no single peer sees the whole match: every player's file is
// collected after a session and tools/matchcheck.py reconciles them --
// damage dealt vs received per pair, kill/death symmetry across observers,
// out-of-economy amounts, replicant anomalies, disconnects.
//
// ARMING: BT_MATCHLOG=1 forces on, =0 forces off; unset -> auto-on when the
// process command line carries "-net" (any networked/console launch, which
// is every MP path -- join/join_lan/steam/menu). Solo stays silent.
// FILE: matchlog_YYYYMMDD_HHMMSS_<pid>.txt in the working directory (the
// install root the player bats run from) -- "send us the matchlog_* file".
//
// LINE FORMAT (every event):
// <TAG> t=<GetTickCount ms> w=<HH:MM:SS.mmm local> st=<appstate> <fields>
// fflushed per line: a crash mid-match keeps everything up to the crash.
//
// EVENT TAGS:
// HDR once at open: version/pid/machine/self/relay/cmdline
// MISSION the app enters RunningMission (engine APP.cpp)
// PEER_UP a game host came online (engine L4NET.CPP)
// PEER_DOWN a game host dropped (engine L4NET.CPP)
// VEHICLE a player got a mech (initial spawn + every respawn)
// FIRE shooter-side beam-weapon damage submission (per shot)
// PROJ shooter-side projectile/missile impact delivery
// SPLASH shooter-side splash (cluster) delivery to a bystander
// RAM shooter-side collision damage dispatch
// DMG VICTIM-side applied TakeDamage (zone resolved, post level)
// CRIT a critical subsystem destroyed by zone-damage cascade
// DEATH a mech's death transition ran (fires on every observer too;
// inst=M in the victim's own log is the authoritative one)
// PLAYER_DEAD the owning player's death notification (deaths counter)
// SCORE a score award folded into a player's currentScore
//===========================================================================//
#ifndef MATCHLOG_HPP
#define MATCHLOG_HPP
class EntityID;
// Armed? Lazy one-time init (opens the file + writes HDR on first yes).
int BTMatchLogActive();
// EntityID::GetHostID() is non-const while GetEntityID() returns a const
// value -- const-safe host/local extractors for the event lines.
int BTMatchHostOf(const EntityID &id);
int BTMatchLocalOf(const EntityID &id);
// The open log's file name ("" when not armed) -- consumed by the relay
// auto-upload (L4NET BTRelayUploadMatchLog) at mission end.
const char *BTMatchLogPath();
// Flush + close the file (upload wants a complete file on disk). Further
// BTMatchLog calls become no-ops.
void BTMatchLogClose();
// One event line; printf-style body appended after the standard prefix.
// Safe no-op when not armed, but call sites on per-shot/per-hit paths should
// still gate on BTMatchLogActive() to skip the varargs work.
void BTMatchLog(const char *tag, const char *format, ...);
#endif
+31
View File
@@ -69,6 +69,9 @@
#if !defined(MECHTECH_HPP)
# include <mechtech.hpp>
#endif
#include <matchlog.hpp> // MP match forensics (DMG event line)
// AUTHENTIC GROUND MODEL ctor half (task #15): complete BoxedSolid type for the
// collisionTemplate/collisionVolume extent reads + the template bottom lift.
#include <BOXSOLID.hpp>
@@ -773,6 +776,33 @@ void
}
}
Entity::TakeDamageMessageHandler(message); // base: damageZones[zone]->TakeDamage
// MP MATCH FORENSICS (matchlog.hpp): the victim-side authoritative damage
// application -- one line per applied TakeDamage with the resolved zone
// and the zone's post-application level. tools/matchcheck.py reconciles
// these against every peer's shooter-side FIRE/PROJ/SPLASH/RAM records.
if (BTMatchLogActive())
{
int zone = message->damageZone;
Scalar level = -1.0f;
if (zone >= 0 && zone < damageZoneCount
&& damageZones != 0 && damageZones[zone] != 0)
level = damageZones[zone]->damageLevel;
BTMatchLog("DMG",
"victim=%d:%d inst=%c from=%d:%d type=%d amt=%.3f burst=%d "
"zone=%d lvl=%.4f pos=%.1f,%.1f,%.1f",
BTMatchHostOf(GetEntityID()), (int)GetEntityID(),
(GetInstance() == ReplicantInstance) ? 'R' : 'M',
BTMatchHostOf(message->inflictingEntity),
(int)message->inflictingEntity,
(int)message->damageData.damageType,
(float)message->damageData.damageAmount,
(int)message->damageData.burstCount,
zone, (float)level,
(float)message->damageData.impactPoint.x,
(float)message->damageData.impactPoint.y,
(float)message->damageData.impactPoint.z);
}
}
//
@@ -1758,6 +1788,7 @@ Mech::Mech(
critRes->Unlock();
}
wreckSmokeTimer = 0.0f;
matchlogDeathLogged = 0;
eyepointRotation = EulerAngles(Radian(0.0f), Radian(0.0f), Radian(0.0f));
//
+2
View File
@@ -765,6 +765,8 @@ protected:
// smoke plume (psfx 1, DDTHSMK) every 10s (its authored emission window)
// while the mech is a destroyed wreck, so a dead mech keeps smoking.
Scalar wreckSmokeTimer;
int matchlogDeathLogged; // matchlog DEATH one-shot latch
// (self-resets while alive)
// The cockpit eye-slew angles (the "EyepointRotation" attribute the
// binary's DPLEyeRenderable composes into the view every frame). Was
// bound to the shared junk attrPad -> the cockpit camera got rotated by
+71
View File
@@ -155,6 +155,7 @@
#include <messmgr.hpp> // SubsystemMessageManager (task #7 consolidated delivery)
#include <mechweap.hpp> // MechWeapon::GetExplosionResourceID (per-round detonation)
#include <mislanch.hpp> // MissileLauncher::ClassDerivations (splash-radius gate)
#include <matchlog.hpp> // MP match forensics (DEATH/PROJ/SPLASH/RAM event lines)
#include "btinput.hpp" // the CONTROLS.MAP + XInput binding engine
#include <string.h> // [aud-tail] strchr -- BT_FIRE_PULSE "on,off" parse (Gitea #5, instrumentation only)
#if !defined(PLAYER_HPP)
@@ -1116,6 +1117,18 @@ void
Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage),
shooter->GetEntityID(), -1 /*unaimed -> cylinder resolves*/, dmg);
e->Dispatch(&td);
// MP MATCH FORENSICS (matchlog.hpp): shooter-side splash delivery
// to a bystander (pairs with the victim's DMG burst>1 line).
if (BTMatchLogActive())
BTMatchLog("SPLASH",
"shooter=%d:%d victim=%d:%d vinst=%c dist=%.1f bursts=%d amt=%.3f",
BTMatchHostOf(shooter->GetEntityID()),
(int)shooter->GetEntityID(),
BTMatchHostOf(e->GetEntityID()), (int)e->GetEntityID(),
(e->GetInstance() == Entity::ReplicantInstance) ? 'R' : 'M',
(float)dist, bursts, (float)dmg.damageAmount);
if (s_log)
DEBUG_STREAM << "[splash] victim=" << e->GetEntityID() << " dist=" << dist
<< " radius=" << radius << " bursts=" << bursts
@@ -1490,6 +1503,26 @@ static void
// gauge scoring wave (Step 6): a projectile hit credits SCORE too
// (tgt == gEnemyMech here; local player is the viewpoint shooter).
BTPostDamageScore((Entity *)tgt, p.damage);
// MP MATCH FORENSICS (matchlog.hpp): shooter-side
// projectile impact delivery (pairs with the victim's
// DMG type=2 line).
if (BTMatchLogActive())
BTMatchLog("PROJ",
"shooter=%d:%d wpn=%d tgt=%d:%d tinst=%c "
"amt=%.3f pos=%.1f,%.1f,%.1f",
(p.shooter != 0)
? BTMatchHostOf(p.shooter->GetEntityID()) : -1,
(p.shooter != 0)
? (int)p.shooter->GetEntityID() : -1,
p.weaponSubsys,
BTMatchHostOf(tgt->GetEntityID()),
(int)tgt->GetEntityID(),
(tgt->GetInstance() == Entity::ReplicantInstance)
? 'R' : 'M',
(float)p.damage,
(float)p.pos.x, (float)p.pos.y, (float)p.pos.z);
DEBUG_STREAM << "[projectile] IMPACT damage=" << p.damage
<< " subsys=" << p.weaponSubsys
<< (mgr ? " (msgmgr bundled)" : " (direct)")
@@ -1769,9 +1802,33 @@ void
Mech::UpdateDeathState(Scalar dt)
{
if (!IsMechDestroyed()) // alive
{
matchlogDeathLogged = 0; // re-arm the DEATH one-shot (respawn)
return;
}
if (MovementMode() == 9) // already settled (disabled/frozen)
{
// MP MATCH FORENSICS (matchlog.hpp): one DEATH line at wreck entry.
// Placed here (not at the transition) so it fires for BOTH paths: the
// master's own transition->9 AND a replicant whose MovementMode came
// in as 9 straight from the type-6 record header (the observer-side
// replicated death, which never runs the transition branch). inst=M
// in the victim's own log is the authoritative kill; the inst=R
// sightings on the other peers prove the death REPLICATED.
if (!matchlogDeathLogged)
{
matchlogDeathLogged = 1;
if (BTMatchLogActive())
BTMatchLog("DEATH",
"victim=%d:%d inst=%c killer=%d:%d killdmg=%.3f pos=%.1f,%.1f,%.1f",
BTMatchHostOf(GetEntityID()), (int)GetEntityID(),
(GetInstance() == ReplicantInstance) ? 'R' : 'M',
BTMatchHostOf(lastInflictingID), (int)lastInflictingID,
(float)lastInflictingDamage,
(float)localOrigin.linearPosition.x,
(float)localOrigin.linearPosition.y,
(float)localOrigin.linearPosition.z);
}
// WRECK SMOKE (port addition, [T3]): keep the dead hulk smoking -- re-fire
// the death/rubble smoke plume (psfx 1 = DDTHSMK, "the mech death/rubble
// smoke plume") each time its authored 10-second emission window elapses.
@@ -1860,6 +1917,7 @@ void
// screen (this function runs for every mech, replicants included).
ForceUpdate(Simulation::DefaultUpdateModelFlag
| (1 << MechDeathUpdateModelBit));
SetMovementMode(9); // disabled: IsDisabled() true, locomotion frozen
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[death] mech " << GetEntityID()
@@ -6499,6 +6557,19 @@ static void
inflictor->GetEntityID(), -1 /*unaimed -> receiver's cylinder resolves*/, dmg);
victim->Dispatch(&take_damage);
// MP MATCH FORENSICS (matchlog.hpp): shooter-side ram dispatch (raw =
// the StaticBounce kinetic price, amt = the armor-point-normalized
// amount actually dispatched -- pairs with the victim's DMG type=0 line).
if (BTMatchLogActive())
BTMatchLog("RAM",
"inflictor=%d:%d victim=%d:%d raw=%.1f amt=%.3f pos=%.1f,%.1f,%.1f",
BTMatchHostOf(inflictor->GetEntityID()),
(int)inflictor->GetEntityID(),
BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(),
(float)resolved->damageAmount, (float)dmg.damageAmount,
(float)dmg.impactPoint.x, (float)dmg.impactPoint.y,
(float)dmg.impactPoint.z);
if (GroundLog())
DEBUG_STREAM << "[collide] dmg=" << resolved->damageAmount
<< " -> victim class=" << (int)victim->GetClassID()
+12
View File
@@ -57,6 +57,7 @@
// FUN_004dbb24/004db92c/004d9c38/004dbd4c DebugStream << / endl
//
#include <bt.hpp>
#include <matchlog.hpp> // MP match forensics (CRIT event line)
#pragma hdrstop
#if !defined(MECHDMG_HPP)
@@ -666,6 +667,17 @@ void
if (s->IsVitalSubsystem()) // +0xE4
((Mech *)GetOwningSimulation())->graphicAlarm.SetLevel(9); // mech kill
s->ForceCriticalFailure(); // status 1 + print + zone valve
// MP MATCH FORENSICS (matchlog.hpp): a critical subsystem died
// to the zone cascade (vital=1 == this kills the mech).
if (BTMatchLogActive())
{
Mech *owner = (Mech *)GetOwningSimulation();
BTMatchLog("CRIT", "mech=%d:%d zone=%d sub=%d vital=%d",
BTMatchHostOf(owner->GetEntityID()),
(int)owner->GetEntityID(),
damageZoneIndex, i, (int)s->IsVitalSubsystem());
}
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[deathfx] crit-subsys " << i
<< " DESTROYED (vital=" << s->IsVitalSubsystem() << ")\n" << std::flush;
+19
View File
@@ -58,6 +58,7 @@
//
#include <bt.hpp>
#include <matchlog.hpp> // MP match forensics (FIRE event line)
#include <messmgr.hpp> // SubsystemMessageManager (task #8 damage submission)
#include <mechmppr.hpp> // MechControlsMapper -- the config-session virtuals (task #6)
#pragma hdrstop
@@ -721,6 +722,24 @@ void
{
BTPostDamageScore(target, damageData.damageAmount);
}
// MP MATCH FORENSICS (matchlog.hpp): the shooter-side per-shot damage
// submission -- pairs with the victim's DMG application line. mech=1
// marks a registered-mech target (only those reconcile; a terrain/icon
// target ignores or maps the damage on its own rules).
if (BTMatchLogActive())
{
BTMatchLog("FIRE",
"shooter=%d:%d wpn=%d tgt=%d:%d tinst=%c mech=%d zone=%d "
"amt=%.3f type=%d",
BTMatchHostOf(mech->GetEntityID()), (int)mech->GetEntityID(),
(int)subsystemID,
BTMatchHostOf(target->GetEntityID()), (int)target->GetEntityID(),
(target->GetInstance() == Entity::ReplicantInstance) ? 'R' : 'M',
(int)BTIsRegisteredMech(target),
zone, (float)damageData.damageAmount,
(int)damageData.damageType);
}
}
+5
View File
@@ -17,6 +17,11 @@ SINGLE-PLAYER: double-click play_solo.bat -- opens the
Your seat and mech are assigned automatically by the operator.
If the game closes unexpectedly, send the operator content\join.log.
AFTER A MULTIPLAYER SESSION: the game writes a small match report,
content\matchlog_<date>_<time>_<number>.txt. Please send the operator
the newest one -- we combine everyone's files to verify damage, kills
and scores worked correctly across all players.
You start INSIDE the cockpit with the pod's MFD panels composited
around the view (one window). The red MFD buttons, radar rails and
joystick buttons are all MOUSE-CLICKABLE -- right-click a button to
+29 -1
View File
@@ -58,6 +58,7 @@ BT_SELF=<their [pilots] entry>.
inbound datagram and forwards verbatim; unknown target endpoint falls
back to wrapping the frame onto the target's game TCP connection.
"""
import os
import random
import selectors
import socket
@@ -218,6 +219,8 @@ ROUTE_UDP_ACK = -5
ROUTE_SEAT_REQUEST = -6 # client->relay: assign me a free roster seat
ROUTE_SEAT_ASSIGN = -7 # relay->client: int32 hostID + NUL-terminated tag
ROUTE_SEAT_FULL = -8 # relay->client: no free seats
ROUTE_MATCHLOG = -9 # client->relay: matchlog upload (filename NUL + bytes)
MATCHLOG_CAP = 8 * 1024 * 1024 # matchlog upload frame cap (client caps at 8MB)
SEAT_RESERVE_SECONDS = 60.0
HELLO_MAGIC = 0x31525442 # 'BTR1' little-endian
FRAME_CAP = 1600 # NETWORKMANAGER_BUFFER_SIZE (NETWORK.h:89)
@@ -599,7 +602,10 @@ class Relay:
conn.buf += data
while len(conn.buf) >= ENV_TCP_SIZE:
route, length = struct.unpack_from(ENV_FMT_TCP, conn.buf, 0)
if length > FRAME_CAP:
# game frames are small; the matchlog upload (route -9) is a whole
# file -- allow it its own generous cap (matches the client's 8MB)
cap = MATCHLOG_CAP if route == ROUTE_MATCHLOG else FRAME_CAP
if length > cap:
self._drop_game(conn, f"oversize frame ({length})")
return
if len(conn.buf) < ENV_TCP_SIZE + length:
@@ -612,6 +618,28 @@ class Relay:
return
def _handle_game_frame(self, conn, route, payload):
if conn.host_id is None and route == ROUTE_MATCHLOG:
# MATCHLOG AUTO-UPLOAD: a pod's post-mission throwaway dial hands
# us its match forensic log (payload: filename NUL + file bytes).
# Saved under matchlogs/ prefixed with the sender address so all
# peers' files land side by side for tools/matchcheck.py.
nul = payload.find(b"\0")
if nul <= 0 or nul > 150:
self._drop_game(conn, "malformed matchlog upload")
return
name = os.path.basename(payload[:nul].decode("ascii", "replace"))
if not (name.startswith("matchlog_") and name.endswith(".txt")):
self._drop_game(conn, f"suspicious matchlog name {name!r}")
return
data = payload[nul + 1:]
os.makedirs("matchlogs", exist_ok=True)
peer = conn.sock.getpeername()[0].replace(":", "_")
out_path = os.path.join("matchlogs", f"{peer}_{name}")
with open(out_path, "wb") as f:
f.write(data)
print(f"[relay] matchlog saved: {out_path} ({len(data)} bytes)",
flush=True)
return
if conn.host_id is None and route == ROUTE_SEAT_REQUEST:
# SERVER-ASSIGNED SEATS: hand out the lowest roster seat that is
# neither claimed (by_host) nor reserved; reserve it briefly so
+314
View File
@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""matchcheck.py -- cross-peer reconciliation of BT411 match forensic logs.
Each game instance in a networked session writes a matchlog_*.txt (see
game/reconstructed/matchlog.hpp for the event catalogue). MP damage
authority is VICTIM-side, so no single peer's log holds the whole match:
collect every player's file into one directory and run
python tools/matchcheck.py <dir-or-files...>
Report:
* roster -- one row per log: machine, version, self host id, mission window
* damage matrix -- authoritative received damage (DMG lines, victim-side)
cross-checked against the shooters' claimed FIRE/PROJ/SPLASH/RAM totals
* kills/deaths -- authoritative DEATH inst=M vs the replicated inst=R
sightings on every other peer, killer attribution, PLAYER_DEAD counts
* scores -- per-player award totals by type
* anomalies -- version skew, damage applied on a replicant, unresolved
zones, out-of-economy amounts, deaths that failed to replicate,
mid-mission disconnects, fire with no matching victim-side application
"""
import os
import re
import sys
from collections import defaultdict
LINE_RE = re.compile(
r"^(?P<tag>[A-Z_]+) t=(?P<t>\d+) w=(?P<w>[\d:.]+) st=(?P<st>-?\d+) ?(?P<body>.*)$")
KV_RE = re.compile(r'(\w+)=("[^"]*"|\S+)')
RUNNING_STATE = None # learned: the st= value on MISSION lines
# The armor-point economy observed live: weapons 3.4-11.8/hit, rams up to
# tens after normalization, splash <= weapon amounts. Amounts far outside
# are flagged, not judged.
AMT_MAX = 200.0
def parse_kv(body):
out = {}
for key, value in KV_RE.findall(body):
out[key] = value.strip('"')
return out
def parse_log(path):
events = []
bad = 0
with open(path, "r", errors="replace") as f:
for raw in f:
raw = raw.rstrip("\n")
if not raw:
continue
m = LINE_RE.match(raw)
if not m:
bad += 1
continue
ev = {
"tag": m.group("tag"),
"t": int(m.group("t")),
"w": m.group("w"),
"st": int(m.group("st")),
}
ev.update(parse_kv(m.group("body")))
events.append(ev)
return events, bad
def eid(value):
"""'3:22' -> (3, 22); tolerate missing."""
if value is None:
return None
parts = value.split(":")
if len(parts) != 2:
return None
try:
return (int(parts[0]), int(parts[1]))
except ValueError:
return None
class Log(object):
def __init__(self, path):
self.path = path
self.name = os.path.basename(path)
self.events, self.bad_lines = parse_log(path)
hdrs = [e for e in self.events if e["tag"] == "HDR"]
self.hdr = hdrs[0] if hdrs else {}
self.version = self.hdr.get("ver", "?")
self.machine = self.hdr.get("machine", "?")
# Self host id: the host part of this log's VEHICLE player ids
# (players are made locally), falling back to inst=M DMG victims.
self.self_host = None
for e in self.events:
if e["tag"] == "VEHICLE" and eid(e.get("player")):
self.self_host = eid(e["player"])[0]
break
if self.self_host is None:
for e in self.events:
if e["tag"] == "DMG" and e.get("inst") == "M" and eid(e.get("victim")):
self.self_host = eid(e["victim"])[0]
break
def mission_events(self):
run_states = set(e["st"] for e in self.events if e["tag"] == "MISSION")
if not run_states:
return self.events
# everything from the first MISSION line onward
for i, e in enumerate(self.events):
if e["tag"] == "MISSION":
return self.events[i:]
return self.events
def fmt_id(pair):
return "%d:%d" % pair if pair else "?"
def main(argv):
paths = []
for a in argv:
if os.path.isdir(a):
# accept both a pod's own file (matchlog_*.txt) and the relay's
# collected copy (<peer-ip>_matchlog_*.txt)
paths += [os.path.join(a, f) for f in sorted(os.listdir(a))
if "matchlog_" in f and f.endswith(".txt")]
else:
paths.append(a)
if not paths:
print(__doc__)
return 1
logs = [Log(p) for p in paths]
anomalies = []
# ---------------- roster ----------------
print("=" * 72)
print("ROSTER (%d logs)" % len(logs))
print("=" * 72)
versions = set()
for lg in logs:
versions.add(lg.version)
n_mission = sum(1 for e in lg.events if e["tag"] == "MISSION")
print(" %-38s host=%-4s machine=%-12s ver=%s%s" % (
lg.name, lg.self_host, lg.machine, lg.version,
"" if n_mission else " [never reached RunningMission]"))
if lg.bad_lines:
anomalies.append("%s: %d unparseable lines" % (lg.name, lg.bad_lines))
if len(versions) > 1:
anomalies.append("VERSION SKEW across peers: %s" % ", ".join(sorted(versions)))
host_log = {lg.self_host: lg for lg in logs if lg.self_host is not None}
# ---------------- players / vehicles ----------------
player_of_mech = {} # mech eid -> player eid
for lg in logs:
for e in lg.events:
if e["tag"] == "VEHICLE":
mech, player = eid(e.get("mech")), eid(e.get("player"))
if mech and player:
player_of_mech[mech] = player
# ---------------- damage reconciliation ----------------
# authoritative: DMG inst=M in the victim-host's own log
recv = defaultdict(float) # (from_host, victim_host) -> amount
recv_n = defaultdict(int)
dealt = defaultdict(float) # (shooter_host, tgt_host) -> claimed amount
dealt_n = defaultdict(int)
for lg in logs:
for e in lg.events:
if e["tag"] == "DMG":
victim, source = eid(e.get("victim")), eid(e.get("from"))
amt = float(e.get("amt", 0))
if e.get("inst") == "R":
anomalies.append("%s: DMG applied on a REPLICANT (%s) -- "
"authority violation" % (lg.name, e.get("victim")))
continue
if victim is None:
continue
key = (source[0] if source else -1, victim[0])
recv[key] += amt
recv_n[key] += 1
if int(e.get("zone", "-1")) < 0:
anomalies.append("%s: DMG with UNRESOLVED zone (victim %s t=%s)"
% (lg.name, e.get("victim"), e["t"]))
if amt <= 0 or amt > AMT_MAX:
anomalies.append("%s: out-of-economy DMG amount %.3f (victim %s)"
% (lg.name, amt, e.get("victim")))
elif e["tag"] in ("FIRE", "PROJ"):
if e["tag"] == "FIRE" and e.get("mech") == "0":
continue # scenery target: never reconciles
shooter, target = eid(e.get("shooter")), eid(e.get("tgt"))
if shooter and target:
key = (shooter[0], target[0])
dealt[key] += float(e.get("amt", 0))
dealt_n[key] += 1
elif e["tag"] == "SPLASH":
shooter, victim = eid(e.get("shooter")), eid(e.get("victim"))
if shooter and victim:
key = (shooter[0], victim[0])
dealt[key] += float(e.get("amt", 0))
dealt_n[key] += 1
elif e["tag"] == "RAM":
inf, victim = eid(e.get("inflictor")), eid(e.get("victim"))
if inf and victim:
key = (inf[0], victim[0])
dealt[key] += float(e.get("amt", 0))
dealt_n[key] += 1
print()
print("=" * 72)
print("DAMAGE (received = victim-side authoritative; claimed = shooter-side)")
print("=" * 72)
pairs = sorted(set(list(recv.keys()) + list(dealt.keys())))
print(" %-14s %14s %14s %s" % ("shooter->victim", "claimed", "received", "delta"))
for key in pairs:
c, r = dealt.get(key, 0.0), recv.get(key, 0.0)
# only cross-check when both hosts' logs are present
have_both = key[0] in host_log and key[1] in host_log
delta = ""
if have_both and (c or r):
diff = c - r
delta = "%+.1f" % diff
base = max(abs(c), abs(r), 1e-6)
if abs(diff) > 0.05 * base + 1.0:
delta += " <-- MISMATCH"
anomalies.append(
"damage pair %d->%d: claimed %.1f (%d ev) vs received %.1f (%d ev)"
% (key[0], key[1], c, dealt_n[key], r, recv_n[key]))
elif not have_both:
delta = "(missing a peer's log)"
print(" %4d -> %-6d %10.1f (%3d) %10.1f (%3d) %s" % (
key[0], key[1], c, dealt_n.get(key, 0), r, recv_n.get(key, 0), delta))
# ---------------- kills / deaths ----------------
print()
print("=" * 72)
print("KILLS / DEATHS")
print("=" * 72)
deaths_auth = [] # (victim eid, killer eid, log, t)
death_seen = defaultdict(set) # victim eid -> set(observer host)
for lg in logs:
for e in lg.events:
if e["tag"] == "DEATH":
victim = eid(e.get("victim"))
if victim is None:
continue
if e.get("inst") == "M":
deaths_auth.append((victim, eid(e.get("killer")), lg, e))
death_seen[victim].add(lg.self_host)
for victim, killer, lg, e in deaths_auth:
observers = death_seen.get(victim, set())
missing = [h for h in host_log if h not in observers and h is not None]
print(" %s killed by %s (w=%s, seen on %d/%d peers%s)" % (
fmt_id(victim), fmt_id(killer), e["w"],
len(observers), len(host_log),
"" if not missing else "; MISSING on hosts %s" % missing))
if missing:
anomalies.append("death of %s did not replicate to hosts %s"
% (fmt_id(victim), missing))
pd = defaultdict(int)
for lg in logs:
for e in lg.events:
if e["tag"] == "PLAYER_DEAD":
p = eid(e.get("player"))
pd[p] = max(pd[p], int(e.get("deaths", 0)))
for p, n in sorted(pd.items()):
print(" player %s: %d death(s)" % (fmt_id(p), n))
# ---------------- scores ----------------
print()
print("=" * 72)
print("SCORES (last reported total per player, per its own log)")
print("=" * 72)
final = {}
for lg in logs:
for e in lg.events:
if e["tag"] == "SCORE":
p = eid(e.get("player"))
if p and p[0] == lg.self_host:
final[p] = (float(e.get("total", 0)) + float(e.get("award", 0)),
int(e.get("kills", 0)))
for p, (total, kills) in sorted(final.items()):
print(" player %s: score ~%.1f kills=%d" % (fmt_id(p), total, kills))
# ---------------- connectivity ----------------
for lg in logs:
# The MISSION line's st= value IS the RunningMission state id; a
# PEER_DOWN in any LATER state (EndingMission teardown) is the normal
# end-of-mission exit, not a mid-match disconnect.
run_state = None
for e in lg.events:
if e["tag"] == "MISSION":
run_state = e["st"]
if (e["tag"] == "PEER_DOWN" and run_state is not None
and e["st"] == run_state):
anomalies.append("%s: PEER_DOWN host=%s MID-MISSION (w=%s)"
% (lg.name, e.get("host"), e["w"]))
# ---------------- anomalies ----------------
print()
print("=" * 72)
print("ANOMALIES (%d)" % len(anomalies))
print("=" * 72)
if not anomalies:
print(" none -- the match reconciles cleanly")
for a in anomalies:
print(" ! %s" % a)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))