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:
co-authored by
Claude Opus 4.8
parent
3097d269b4
commit
753540de96
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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));
|
||||
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:]))
|
||||
Reference in New Issue
Block a user