Keep the game off EA's online services

Skate.exe contacts EA on every launch, offline mode included: error and
session reports (collector.errors.ea.com), SDK telemetry
(freeform-river.data.ea.com) and remote configuration (gcs.ea.com).

- Block the game's lookups of ea.com and tnt-ea.com names inside the
  process (getaddrinfo and the other Winsock resolvers). Installed at
  bootstrap, and fails closed like the EA app launch block
- Turn EA's error reporting (EadpErrorsData) off in game, next to the
  existing DingoTelemetry settings; the game ignores them on its
  command line
- Leave EA's image CDN (dingo-dev-assets.akamaized.net) reachable for
  the fast-travel artwork, which only downloads images
- Add a hostname test, contrib/ea-watch.ps1 to check the traffic, and a
  short note in the README's privacy section

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VebjorhkandClaude Opus 5.5 committed 2026-10-06 21:58:11 +02:00
1 parent 2cdc5ee8df
commit e3ec512878
9 files changed
+821 -2

No files matched your search

+3
View File
@@ -172,6 +172,9 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32)
add_test(NAME multiplayer_settings_lock COMMAND dingosdk_settings_lock_tests)
add_executable(dingosdk_held_input_tests Extension/UI/Overlay/Test/held_input_tests.cpp)
add_test(NAME overlay_held_input COMMAND dingosdk_held_input_tests)
add_executable(dingosdk_ea_service_block_tests Extension/Boot/Test/ea_service_block_tests.cpp)
target_include_directories(dingosdk_ea_service_block_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME ea_service_block COMMAND dingosdk_ea_service_block_tests)
endif()
# The route and logging tests live in the local, untracked tests/ folder.
option(DINGOSDK_BUILD_ROUTE_TESTS "Build native route cycle regression tests" OFF)
@@ -0,0 +1,34 @@
#include "Extension/Boot/ea_service_block.h"
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>
using namespace dingosdk;
void check(bool value, const std::string& message) { if (!value) throw std::runtime_error(message); }
bool blocked(std::string_view host) { return ea_service_host(host); }
bool blocked(std::wstring_view host) { return ea_service_host(host); }
static_assert(ea_service_host(std::string_view("collector.errors.ea.com")), "The match is usable at compile time");
int main() {
try {
// Every EA service the game contacted in a recorded session, and a sample of the others it names.
for (const auto* host : {"collector.errors.ea.com", "a-collector.errors.ea.com", "freeform-river.data.ea.com",
"gcs.ea.com", "pin-river-grpc.data.ea.com", "pin-em.data.ea.com", "spring25.client.blazeredirector.ea.com",
"spring18.gosredirector.ea.com", "accounts.ea.com", "gateway.grpc.ea.com", "experimentation-grpc.data.ea.com",
"adtech-runtime-rest-internal.data.ea.com", "river-grpc-lt.eadpdata.ddns.ea.com", "pn.tnt-ea.com",
"rtm.tnt-ea.com", "ea.com", "tnt-ea.com"})
check(blocked(host), std::string("EA service must be blocked: ") + host);
check(blocked("COLLECTOR.Errors.EA.com"), "Host names compare without case");
check(blocked("gcs.ea.com."), "A trailing root dot is the same name");
check(blocked(L"freeform-river.data.ea.com"), "Wide lookups are blocked too");
check(blocked(L"GCS.EA.COM."), "Wide lookups compare without case and root dot");
// What ReSkate and the game still need, and names that only look like EA's.
for (const auto* host : {"dingo-dev-assets.akamaized.net", "api.reskate.dev", "api.github.com",
"raw.githubusercontent.com", "thunderstore.io", "api.steampowered.com", "idea.com", "notea.com",
"xtnt-ea.com", "ea.com.example.net", "ea.co", "eacom", "com", ".", "", "159.153.1.1"})
check(!blocked(host), std::string("Host must stay reachable: ") + host);
check(!blocked(L"dingo-dev-assets.akamaized.net"), "The fast-travel artwork CDN stays reachable");
check(!blocked(L"gcs.ea.cöm"), "Characters outside ASCII never match a domain letter");
check(!blocked("gcs.ea.com.."), "Only one root dot is stripped");
std::cout << "EA service block checks passed.\n";
} catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; }
}
+154
View File
@@ -0,0 +1,154 @@
#include <winsock2.h>
#include <ws2tcpip.h>
#include <Windows.h>
#include "ea_service_block.h"
#include "Engine/Core/Hooks/hooks.h"
#include "Engine/Core/Log/logging.h"
#include <atomic>
#include <mutex>
#include <set>
#include <stdexcept>
#include <string>
#include <string_view>
namespace dingosdk {
namespace {
using GetAddrInfoAFn = INT (WSAAPI*)(PCSTR, PCSTR, const ADDRINFOA*, PADDRINFOA*);
using GetAddrInfoWFn = INT (WSAAPI*)(PCWSTR, PCWSTR, const ADDRINFOW*, PADDRINFOW*);
using GetAddrInfoExAFn = INT (WSAAPI*)(PCSTR, PCSTR, DWORD, LPGUID, const ADDRINFOEXA*, PADDRINFOEXA*, timeval*,
LPOVERLAPPED, LPLOOKUPSERVICE_COMPLETION_ROUTINE, LPHANDLE);
using GetAddrInfoExWFn = INT (WSAAPI*)(PCWSTR, PCWSTR, DWORD, LPGUID, const ADDRINFOEXW*, PADDRINFOEXW*, timeval*,
LPOVERLAPPED, LPLOOKUPSERVICE_COMPLETION_ROUTINE, LPHANDLE);
using GetHostByNameFn = hostent* (WSAAPI*)(const char*);
std::atomic<GetAddrInfoAFn> original_get_addr_info_a{};
std::atomic<GetAddrInfoWFn> original_get_addr_info_w{};
std::atomic<GetAddrInfoExAFn> original_get_addr_info_ex_a{};
std::atomic<GetAddrInfoExWFn> original_get_addr_info_ex_w{};
std::atomic<GetHostByNameFn> original_get_host_by_name{};
// A refused name is logged once at info level; a service that cannot connect keeps retrying, so
// repeats are logged at debug level only. Never destroyed, because lookups can still arrive while
// the process exits.
struct RefusedNames {
std::mutex mutex;
std::set<std::wstring> names;
};
RefusedNames& refused_names() {
static auto* value = new RefusedNames;
return *value;
}
template<class Char>
bool refuse(const Char* name) noexcept {
if (!name) return false;
const std::basic_string_view<Char> host(name);
if (!ea_service_host(host)) return false;
try {
std::wstring wide;
wide.reserve(host.size());
for (const auto ch : host) wide.push_back(static_cast<wchar_t>(static_cast<std::make_unsigned_t<Char>>(ch)));
bool first{};
{
auto& refused = refused_names();
const std::lock_guard lock(refused.mutex);
first = refused.names.insert(wide).second;
}
if (first)
logging::write(logging::Level::info, logging::Channel::runtime,
L"Blocked the game's lookup of " + wide + L": ReSkate keeps it off EA's online services");
else if (logging::enabled(logging::Level::debug))
logging::write(logging::Level::debug, logging::Channel::runtime, L"Blocked the game's lookup of " + wide + L" again");
} catch (...) {}
return true;
}
// What the resolver itself answers for a name that does not exist.
template<class Result>
INT not_found(Result* result) noexcept {
if (result) *result = nullptr;
WSASetLastError(WSAHOST_NOT_FOUND);
return WSAHOST_NOT_FOUND;
}
INT WSAAPI get_addr_info_a(PCSTR name, PCSTR service, const ADDRINFOA* hints, PADDRINFOA* result) {
if (refuse(name)) return not_found(result);
return original_get_addr_info_a.load()(name, service, hints, result);
}
INT WSAAPI get_addr_info_w(PCWSTR name, PCWSTR service, const ADDRINFOW* hints, PADDRINFOW* result) {
if (refuse(name)) return not_found(result);
return original_get_addr_info_w.load()(name, service, hints, result);
}
// Failing before the lookup starts is a valid answer for an overlapped call too: the completion
// routine and event are only used once the function has returned WSA_IO_PENDING.
INT WSAAPI get_addr_info_ex_a(PCSTR name, PCSTR service, DWORD name_space, LPGUID provider, const ADDRINFOEXA* hints,
PADDRINFOEXA* result, timeval* timeout, LPOVERLAPPED overlapped,
LPLOOKUPSERVICE_COMPLETION_ROUTINE completion, LPHANDLE handle) {
if (refuse(name)) {
if (handle) *handle = nullptr;
return not_found(result);
}
return original_get_addr_info_ex_a.load()(name, service, name_space, provider, hints, result, timeout, overlapped,
completion, handle);
}
INT WSAAPI get_addr_info_ex_w(PCWSTR name, PCWSTR service, DWORD name_space, LPGUID provider, const ADDRINFOEXW* hints,
PADDRINFOEXW* result, timeval* timeout, LPOVERLAPPED overlapped,
LPLOOKUPSERVICE_COMPLETION_ROUTINE completion, LPHANDLE handle) {
if (refuse(name)) {
if (handle) *handle = nullptr;
return not_found(result);
}
return original_get_addr_info_ex_w.load()(name, service, name_space, provider, hints, result, timeout, overlapped,
completion, handle);
}
hostent* WSAAPI get_host_by_name(const char* name) {
if (refuse(name)) {
WSASetLastError(WSAHOST_NOT_FOUND);
return nullptr;
}
return original_get_host_by_name.load()(name);
}
template<class Fn>
void hook(const char* name, void* replacement, std::atomic<Fn>& original) {
const auto module = LoadLibraryExW(L"ws2_32.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32);
const auto target = module ? reinterpret_cast<void*>(GetProcAddress(module, name)) : nullptr;
if (!target) throw std::runtime_error(std::string("Missing ") + name);
void* relay{};
const auto create = hook_prepare(target, replacement, &relay);
if (create != HookOk) throw std::runtime_error(std::string("Cannot hook ") + name + ": " + hook_status_string(create));
original.store(reinterpret_cast<Fn>(relay));
const auto enable = hook_enable(target);
if (enable != HookOk) throw std::runtime_error(std::string("Cannot enable ") + name + ": " + hook_status_string(enable));
}
} // namespace
bool start_ea_service_block(std::string& error) noexcept {
try {
// Skate.exe resolves names through getaddrinfo alone; the other entry points close the
// same door for anything else loaded into the process.
#pragma warning(push)
#pragma warning(disable: 4191)
hook("getaddrinfo", reinterpret_cast<void*>(&get_addr_info_a), original_get_addr_info_a);
hook("GetAddrInfoW", reinterpret_cast<void*>(&get_addr_info_w), original_get_addr_info_w);
hook("GetAddrInfoExA", reinterpret_cast<void*>(&get_addr_info_ex_a), original_get_addr_info_ex_a);
hook("GetAddrInfoExW", reinterpret_cast<void*>(&get_addr_info_ex_w), original_get_addr_info_ex_w);
hook("gethostbyname", reinterpret_cast<void*>(&get_host_by_name), original_get_host_by_name);
#pragma warning(pop)
error.clear();
return true;
} catch (const std::exception& exception) {
error = exception.what();
return false;
}
}
} // namespace dingosdk
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <array>
#include <cstddef>
#include <string>
#include <string_view>
#include <type_traits>
namespace dingosdk {
// EA's online services, as Skate.exe names them. ea.com holds the Blaze redirectors, accounts and
// gateway, telemetry (pin-*, freeform-river), error reports (collector.errors), remote
// configuration (gcs), experimentation, leaderboards, social and ad tech; tnt-ea.com holds push
// notifications and real-time messaging. Not listed on purpose: dingo-dev-assets.akamaized.net,
// the CDN the fast-travel artwork comes from (Engine/Game/World/location_travel.h). It only serves
// images, and the travel menu would lose its icons without it.
inline constexpr std::array<std::string_view, 2> ea_service_domains{"ea.com", "tnt-ea.com"};
// Whether a host name is one of ea_service_domains or a name under one, ignoring ASCII case and a
// trailing root dot. Address literals never match: Skate.exe reaches EA by name only.
template<class Char>
constexpr bool ea_service_host(std::basic_string_view<Char> host) noexcept {
using Unit = std::make_unsigned_t<Char>;
if (!host.empty() && host.back() == Char('.')) host.remove_suffix(1);
for (const auto domain : ea_service_domains) {
if (host.size() < domain.size()) continue;
const auto start = host.size() - domain.size();
if (start != 0 && host[start - 1] != Char('.')) continue;
bool same = true;
for (std::size_t i = 0; same && i < domain.size(); ++i) {
auto unit = static_cast<Unit>(host[start + i]);
if (unit >= Unit('A') && unit <= Unit('Z')) unit = static_cast<Unit>(unit + Unit('a' - 'A'));
same = unit == static_cast<Unit>(domain[i]);
}
if (same) return true;
}
return false;
}
// Makes every name lookup inside Skate.exe for an EA service fail as if the name did not exist, so
// the game cannot reach EA's servers: no error reports, telemetry or remote configuration. Always on.
bool start_ea_service_block(std::string& error) noexcept;
} // namespace dingosdk
+5
View File
@@ -238,6 +238,11 @@ login name.
To turn it off, untick **Send crash reports** in the launcher's Settings (ADVANCED), or set the
environment variable `RESKATE_CRASH_REPORTING=0`.
ReSkate also keeps the game off EA's online services: the game's own crash reports, telemetry and
remote configuration requests are turned off and blocked. Fast-travel artwork still comes from EA's
image CDN (`dingo-dev-assets.akamaized.net`), which only downloads images.
[contrib/ea-watch.ps1](contrib/ea-watch.ps1) shows what the game connects to, if you want to check.
## Third-party code
ReSkate uses Dear ImGui, Microsoft Detours, RapidJSON, spdlog, SQLite, LZ4, Zstandard, miniz, bcdec and
+3
View File
@@ -6,6 +6,7 @@
#include "Extension/Boot/steam_restart_guard.h"
#include "Extension/Boot/user_data_redirect.h"
#include "Extension/Boot/ea_app_block.h"
#include "Extension/Boot/ea_service_block.h"
#include "Extension/Assets/native_patch_support.h"
#include "Extension/Assets/mod_layers.h"
#include "Extension/Assets/loose_files.h"
@@ -61,6 +62,8 @@ bool initialize_bootstrap(std::uintptr_t base) {
if (!stage(Channel::runtime, "Separate game user data", ready, error)) return false;
error.clear(); ready = start_ea_app_block(error);
if (!stage(Channel::runtime, "EA app launch block", ready, error)) return false;
error.clear(); ready = start_ea_service_block(error);
if (!stage(Channel::runtime, "EA online services block", ready, error)) return false;
error.clear(); ready = start_steam_restart_guard(error);
return stage(Channel::runtime, launcher::offline_mode() ? "Offline Steam" : "Steam restart guard", ready, error);
}
+7 -1
View File
@@ -93,6 +93,9 @@ void refresh_interactive_model(std::uintptr_t client, DWORD state, DWORD game_ty
// Engine settings that cost simulation time for nothing here, set once through the named
// setter (retried until the settings registry answers):
// - telemetry and the performance tracker only feed EA's backend, absent offline;
// - EA's error reporting (EadpErrorsData) only builds reports for EA's servers, which
// ea_service_block keeps unreachable. Set here because the same values given on the game's
// command line do not take;
// - the world-transform update, which grows with every remote player's entities, is split
// over more jobs than Game.cfg's 2.
void apply_performance_settings() {
@@ -104,11 +107,14 @@ void apply_performance_settings() {
next_attempt = now + 500;
try {
const auto jobs = std::to_string(std::clamp(std::thread::hardware_concurrency() / 2, 2u, 8u));
const std::array<std::pair<const char*, std::string>, 7> settings{{
const std::array<std::pair<const char*, std::string>, 10> settings{{
{"DingoTelemetry.Enable", "0"},
{"DingoTelemetry.EnablePlayerTickEvents", "0"},
{"DingoTelemetry.EnablePerformanceEvents", "0"},
{"DingoTelemetry.EnableCPUBenchmark", "0"},
{"EadpErrorsData.DisableAllEventsReporting", "1"},
{"EadpErrorsData.EnableReportCrashes", "0"},
{"EadpErrorsData.AutoSendEventFromPersistence", "0"},
{"PerformanceTracker.Enabled", "0"},
{"PerformanceTracker.JuiceLogPerformance", "0"},
{"EcsWorldTransform.ParallelWorldTransformUpdateJobCount", jobs},
+2 -1
View File
@@ -173,7 +173,8 @@ add_library(dingosdk_runtime SHARED
Extension/Progression/fixed_stop_entitlement_observation.cpp
Extension/Progression/neighborhood_unlock_override.cpp
Extension/Boot/user_data_redirect.cpp
Extension/Boot/ea_app_block.cpp)
Extension/Boot/ea_app_block.cpp
Extension/Boot/ea_service_block.cpp)
target_link_libraries(dingosdk_runtime PRIVATE dingosdk_logging dingosdk_profiler dingosdk_supported_build dingosdk_overlay dingosdk_console_core dingosdk_steam_restart_guard
dingosdk_startup_interventions dingosdk_fast_travel_unlock dingosdk_local_profile dingosdk_initfs dingosdk_mods dingosdk_startup_window dingosdk_custom_scripts dingosdk_world_layer_scan bcrypt
+569
View File
@@ -0,0 +1,569 @@
<#
.SYNOPSIS
Records which programs on this PC look up and talk to EA's servers, so you can check what
skate. and ReSkate send to EA.
.DESCRIPTION
Uses two event-tracing providers built into Windows; nothing is installed and the game is not
touched:
Microsoft-Windows-DNS-Client every DNS lookup, with the program that made it
Microsoft-Windows-Kernel-Network every TCP/UDP connect, send and receive, with the program,
the remote address and the size in bytes
The traffic itself is encrypted; the report shows which EA hosts were looked up, by which
program, and how many bytes went to and came from them.
The game keeps the fast-travel artwork it downloads in its own cache and only fetches it from
EA's image CDN when it is missing. To see those downloads, close the game and rename
%LOCALAPPDATA%\ReSkate\Game\Skate\data\cache\http before recording; the game makes a new one.
The report also warns about hosts-file entries and Windows Firewall rules that would hide or
change what the game sends.
Each recording gets its own folder, %LOCALAPPDATA%\ReSkate\ea-watch\<date>-<time>, holding the
trace (ea-watch.etl), the report (ea-watch-report.txt) and CSV files with every lookup and
connection. These list your IP addresses and the programs you ran, so read them before sharing.
Recording needs an administrator PowerShell, because Windows only lets administrators start an
event trace. Analysing a recording again (-Analyze) does not.
Run it with Windows PowerShell (powershell.exe), which every Windows 10 and 11 PC has.
-ExecutionPolicy Bypass in the examples applies to that one command only.
.PARAMETER Analyze
Reads a recording again instead of making a new one: the newest one, or the folder in -OutDir.
.PARAMETER OutDir
The folder to record into or analyse. Defaults to a new folder per recording (see above).
.PARAMETER MaxMinutes
Stops the recording by itself after this many minutes (default 30).
.PARAMETER ReverseDns
Names addresses no recorded lookup explains through reverse DNS. Each unanswered query waits
for a timeout, so with many such addresses this takes minutes.
.PARAMETER MaxEvents
Reads only this many trace events (0, the default, reads all); for timing a large trace.
.EXAMPLE
# 1. Open Windows PowerShell as administrator (Start, type PowerShell, Run as administrator).
# 2. Start recording, from the folder this script is in:
powershell -NoProfile -ExecutionPolicy Bypass -File .\ea-watch.ps1
# 3. Start ReSkate from its launcher, play as usual, and quit with Quit Game.
# 4. Press Enter in the recording window. The report is printed and saved.
.EXAMPLE
# Read the newest recording again (no administrator rights needed):
powershell -NoProfile -ExecutionPolicy Bypass -File .\ea-watch.ps1 -Analyze
#>
#Requires -PSEdition Desktop
param(
[switch]$Analyze,
[string]$OutDir,
[int]$MaxMinutes = 30,
[switch]$ReverseDns,
[long]$MaxEvents = 0
)
$ErrorActionPreference = 'Stop'
$runsRoot = Join-Path $env:LOCALAPPDATA 'ReSkate\ea-watch'
if (-not $OutDir) {
if ($Analyze) {
$latest = Get-ChildItem -LiteralPath $runsRoot -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'ea-watch.etl') } |
Sort-Object Name -Descending | Select-Object -First 1
if (-not $latest) { throw "No recording found under $runsRoot. Record one first, or pass -OutDir." }
$OutDir = $latest.FullName
} else {
$OutDir = Join-Path $runsRoot (Get-Date -Format 'yyyyMMdd-HHmmss')
}
}
$session = 'ReSkateEaWatch'
$etlPath = Join-Path $OutDir 'ea-watch.etl'
$stopPath = Join-Path $OutDir 'ea-watch.stop'
$procPath = Join-Path $OutDir 'ea-watch-processes.csv'
$statsPath = Join-Path $OutDir 'ea-watch-session.txt'
$envPath = Join-Path $OutDir 'ea-watch-environment.txt'
$reportPath = Join-Path $OutDir 'ea-watch-report.txt'
$dnsCsvPath = Join-Path $OutDir 'ea-watch-dns.csv'
$flowCsvPath = Join-Path $OutDir 'ea-watch-flows.csv'
# Programs whose whole network activity is listed, EA or not.
$focusProcesses = @('Skate', 'ReSkateLauncher')
# 'service' for EA's online services, 'cdn' for the image CDN the fast-travel artwork comes from
# (ReSkate still uses it, for downloads only), nothing for other hosts.
function Get-EaKind([string]$Name) {
if (-not $Name) { return $null }
$Name = $Name.TrimEnd('.').ToLowerInvariant()
if ($Name -match '(^|\.)(ea\.com|ea\.net|eadp\.com|origin\.com|tnt-ea\.com|easports\.com|eaplay\.com)$') { return 'service' }
if ($Name -match '^dingo-[a-z0-9-]*\.akamaized\.net$') { return 'cdn' }
return $null
}
# 159.153.0.0/16 is Electronic Arts' own address block (AS3402). EA services hosted on AWS or a
# CDN are only recognisable by the names they were looked up under.
function Test-EaAddress([string]$Address) {
return $Address -match '^159\.153\.'
}
# Private, link-local, multicast and broadcast addresses: traffic that never leaves the local network.
function Test-LocalNetworkAddress([string]$Address) {
return $Address -match '^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|22[4-9]\.|23\d\.|255\.255\.255\.255$|fe[89ab][0-9a-f]:|ff[0-9a-f]{2}:|f[cd][0-9a-f]{2}:)'
}
# Reading the trace is done in compiled code: PowerShell takes minutes over ~100k events.
Add-Type -ReferencedAssemblies 'System.Core' -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Eventing.Reader;
using System.Net;
using System.Text.RegularExpressions;
public sealed class TraceFlow
{
public int ProcessId;
public string Process;
public string Protocol;
public string Address;
public int Port;
public int Connects;
public long SentBytes;
public int Sends;
public long ReceivedBytes;
public int Receives;
public DateTime First;
public DateTime Last;
}
public sealed class TraceLookup
{
public DateTime Time;
public int Event;
public int ProcessId;
public string Process;
public string Name;
public string Type;
public string Status;
public string Results;
}
public sealed class TraceResult
{
public readonly List<TraceFlow> Flows = new List<TraceFlow>();
public readonly List<TraceLookup> Lookups = new List<TraceLookup>();
public readonly Dictionary<string, HashSet<string>> NamesByAddress =
new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
public long Events, NetworkEvents, LoopbackEvents, DnsEvents;
public DateTime First, Last;
public double Seconds;
}
public static class TraceAnalysis
{
static readonly Guid KernelNetwork = new Guid("7DD42A49-5329-4832-8DFD-43D979153A88");
static readonly Guid DnsClient = new Guid("1C95126E-7EEA-49A9-A3FE-A378B03DDB4D");
static readonly Regex DataPattern = new Regex("<Data Name=['\"](\\w+)['\"]>([^<]*)</Data>", RegexOptions.Compiled);
static readonly Regex AliasPattern = new Regex(@"^type:\s*\d+\s+(\S+)$", RegexOptions.Compiled);
// Kernel-Network event ids: 10/26 TCP sent, 11/27 TCP received, 12/28 TCP connect attempted,
// 42/58 UDP sent, 43/59 UDP received (IPv4/IPv6). Their payload starts with
// PID, size, daddr (the remote end), saddr, dport, sport.
static int Kind(int id)
{
switch (id)
{
case 10: case 26: case 42: case 58: return 1;
case 11: case 27: case 43: case 59: return 2;
case 12: case 28: return 3;
default: return 0;
}
}
// IPv4 arrives as the four address bytes read as one little-endian number
// (4211081440 = E0 00 00 FB backwards = 224.0.0.251); IPv6 as 16 bytes.
static IPAddress Address(object value)
{
if (value is uint) return new IPAddress((long)(uint)value);
if (value is int) return new IPAddress((long)(uint)(int)value);
var bytes = value as byte[];
if (bytes != null && (bytes.Length == 4 || bytes.Length == 16)) return new IPAddress(bytes);
var text = value == null ? "" : value.ToString().Trim();
uint number;
if (uint.TryParse(text, out number)) return new IPAddress((long)number);
if (text.Length == 32)
{
var parsed = new byte[16];
for (int i = 0; i < 16; i++) parsed[i] = Convert.ToByte(text.Substring(i * 2, 2), 16);
return new IPAddress(parsed);
}
IPAddress address;
return IPAddress.TryParse(text, out address) ? address : null;
}
// Ports arrive in network byte order: 47873 is 0xBB01, port 0x01BB = 443.
static int Port(object value)
{
int raw = Convert.ToInt32(value);
return ((raw & 0xFF) << 8) | ((raw >> 8) & 0xFF);
}
static string Text(Dictionary<string, string> data, string name)
{
string value;
return data.TryGetValue(name, out value) ? value : null;
}
public static TraceResult Run(string path, long maxEvents)
{
var watch = Stopwatch.StartNew();
var result = new TraceResult();
var flows = new Dictionary<string, TraceFlow>();
using (var reader = new EventLogReader(new EventLogQuery(path, PathType.FilePath)))
{
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
{
using (record)
{
if (maxEvents > 0 && result.Events >= maxEvents) break;
result.Events++;
var time = record.TimeCreated ?? DateTime.MinValue;
if (result.Events == 1) result.First = time;
result.Last = time;
var provider = record.ProviderId ?? Guid.Empty;
if (provider == KernelNetwork)
{
int kind = Kind(record.Id);
if (kind == 0) continue;
var properties = record.Properties;
if (properties.Count < 6) continue;
var address = Address(properties[2].Value);
if (address == null) continue;
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
if (IPAddress.IsLoopback(address)) { result.LoopbackEvents++; continue; }
result.NetworkEvents++;
int pid = Convert.ToInt32(properties[0].Value);
long size = Convert.ToInt64(properties[1].Value);
int port = Port(properties[4].Value);
string protocol = record.Id < 40 ? "TCP" : "UDP";
string text = address.ToString();
string key = pid + "|" + protocol + "|" + text + "|" + port;
TraceFlow flow;
if (!flows.TryGetValue(key, out flow))
{
flow = new TraceFlow { ProcessId = pid, Protocol = protocol, Address = text, Port = port, First = time };
flows[key] = flow;
result.Flows.Add(flow);
}
flow.Last = time;
if (kind == 1) { flow.SentBytes += size; flow.Sends++; }
else if (kind == 2) { flow.ReceivedBytes += size; flow.Receives++; }
else flow.Connects++;
}
else if (provider == DnsClient && (record.Id == 3006 || record.Id == 3008))
{
result.DnsEvents++;
var data = new Dictionary<string, string>();
foreach (Match m in DataPattern.Matches(record.ToXml())) data[m.Groups[1].Value] = m.Groups[2].Value;
var name = Text(data, "QueryName");
if (string.IsNullOrEmpty(name)) continue;
var results = Text(data, "QueryResults");
result.Lookups.Add(new TraceLookup {
Time = time, Event = record.Id, ProcessId = record.ProcessId ?? 0, Name = name,
Type = Text(data, "QueryType"), Status = Text(data, "QueryStatus"), Results = results });
// "type: 5 alias.example;::ffff:1.2.3.4;" - CNAME targets and addresses, ';'-separated.
if (record.Id != 3008 || string.IsNullOrEmpty(results)) continue;
var aliases = new List<string> { name };
foreach (var raw in results.Split(';'))
{
var token = raw.Trim();
if (token.Length == 0) continue;
var alias = AliasPattern.Match(token);
if (alias.Success) { aliases.Add(alias.Groups[1].Value); continue; }
IPAddress answer;
if (!IPAddress.TryParse(token, out answer)) continue;
if (answer.IsIPv4MappedToIPv6) answer = answer.MapToIPv4();
HashSet<string> names;
if (!result.NamesByAddress.TryGetValue(answer.ToString(), out names))
{
names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
result.NamesByAddress[answer.ToString()] = names;
}
foreach (var n in aliases) names.Add(n);
}
}
}
}
}
result.Seconds = watch.Elapsed.TotalSeconds;
return result;
}
}
'@
# logman reports its own failures through the exit code; its output is only shown on failure.
function Invoke-Logman([string[]]$Arguments) {
$previous = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try { $output = & logman @Arguments 2>&1 | Out-String } finally { $ErrorActionPreference = $previous }
return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output.Trim() }
}
function Start-Recording {
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Recording needs an administrator PowerShell (Start, type PowerShell, Run as administrator).'
}
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
[void](Invoke-Logman @('stop', $session, '-ets'))
Remove-Item -LiteralPath $etlPath, $stopPath -ErrorAction SilentlyContinue
# What would make the report misleading: a hosts-file entry answers a lookup before DNS sees
# it, and a firewall rule stops a connection before it opens (blocked UDP still shows as sent).
$environment = New-Object System.Collections.Generic.List[string]
$hostsPath = Join-Path $env:SystemRoot 'System32\drivers\etc\hosts'
foreach ($line in Get-Content -LiteralPath $hostsPath -ErrorAction SilentlyContinue) {
$entry = ($line -replace '#.*$', '').Trim()
if ($entry -and @($entry -split '\s+' | Select-Object -Skip 1 | Where-Object { Get-EaKind $_ }).Count) {
$environment.Add("The hosts file has '$entry', so lookups of that name are answered on this PC and its traffic does not show as it would without it.")
}
}
try {
foreach ($filter in Get-NetFirewallApplicationFilter -ErrorAction Stop | Where-Object { $_.Program -match '\\(Skate|ReSkateLauncher)\.exe$' }) {
foreach ($rule in $filter | Get-NetFirewallRule | Where-Object { "$($_.Enabled)" -eq 'True' -and "$($_.Action)" -eq 'Block' }) {
$environment.Add("Windows Firewall rule '$($rule.DisplayName)' blocks $("$($rule.Direction)".ToLowerInvariant()) traffic of $($filter.Program), so connections it stops do not show and what shows as sent may not have left this PC.")
}
}
} catch {
$environment.Add("Windows Firewall rules could not be read ($($_.Exception.Message)), so rules that block the game were not checked.")
}
$environment | Set-Content -LiteralPath $envPath -Encoding utf8
# Lookups answered from the cache are still logged, but flushing makes every
# name the game needs go through a fresh, visible lookup.
Clear-DnsClientCache
# Flags and levels are quoted: unquoted, PowerShell would turn 0xffffffffffffffff into -1.
$start = Invoke-Logman @('start', $session, '-p', 'Microsoft-Windows-Kernel-Network', '0x30', '5',
'-o', $etlPath, '-bs', '1024', '-nb', '64', '512', '-ets')
if ($start.ExitCode -ne 0) { throw "logman could not start the trace session: $($start.Output)" }
$update = Invoke-Logman @('update', 'trace', $session, '-p', 'Microsoft-Windows-DNS-Client', '0xffffffffffffffff', '5', '-ets')
if ($update.ExitCode -ne 0) {
[void](Invoke-Logman @('stop', $session, '-ets'))
throw "logman could not add the DNS client provider: $($update.Output)"
}
$processes = @{}
$started = Get-Date
$deadline = $started.AddMinutes($MaxMinutes)
Write-Host ''
Write-Host "Recording since $($started.ToString('HH:mm:ss')) into $OutDir" -ForegroundColor Green
Write-Host 'Start ReSkate from its launcher now, play, and quit with Quit Game.' -ForegroundColor Green
Write-Host "Then press Enter here. It stops by itself after $MaxMinutes minutes." -ForegroundColor Green
$keysReadable = $true
try {
while ((Get-Date) -lt $deadline -and -not (Test-Path -LiteralPath $stopPath)) {
# Kernel-Network only gives process ids, so names are collected while processes live.
foreach ($p in Get-Process) {
$existing = $processes[$p.Id]
if (-not $existing) { $processes[$p.Id] = $p.ProcessName }
elseif (($existing -split '/') -notcontains $p.ProcessName) { $processes[$p.Id] = "$existing/$($p.ProcessName)" }
}
$enter = $false
if ($keysReadable) {
try {
while ($Host.UI.RawUI.KeyAvailable) {
$key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown,IncludeKeyUp')
if ($key.KeyDown -and $key.VirtualKeyCode -eq 13) { $enter = $true }
}
} catch {
# Hosts without a console (PowerShell ISE, redirected input) cannot report key presses.
$keysReadable = $false
Write-Host "This window cannot detect key presses ($($_.Exception.Message))." -ForegroundColor Yellow
Write-Host "To stop, create the file $stopPath" -ForegroundColor Yellow
}
}
if ($enter) { break }
Start-Sleep -Milliseconds 300
}
} finally {
(Invoke-Logman @('query', $session, '-ets')).Output | Set-Content -LiteralPath $statsPath -Encoding utf8
$stop = Invoke-Logman @('stop', $session, '-ets')
if ($stop.ExitCode -ne 0) { Write-Warning "logman could not stop the trace session: $($stop.Output)" }
$processes.GetEnumerator() | ForEach-Object { [pscustomobject]@{ Id = $_.Key; Name = $_.Value } } |
Export-Csv -LiteralPath $procPath -NoTypeInformation -Encoding utf8
Remove-Item -LiteralPath $stopPath -ErrorAction SilentlyContinue
}
Write-Host "Recording stopped at $((Get-Date).ToString('HH:mm:ss')). Analysing..." -ForegroundColor Green
}
function Invoke-Analysis {
if (-not (Test-Path -LiteralPath $etlPath)) { throw "No trace at $etlPath." }
$processes = @{}
if (Test-Path -LiteralPath $procPath) {
Import-Csv -LiteralPath $procPath | ForEach-Object { $processes[[int]$_.Id] = $_.Name }
}
function Get-ProcessLabel([int]$Id) {
$name = $processes[$Id]
if ($name) { return "$name($Id)" }
return "pid $Id (name not captured)"
}
$result = [TraceAnalysis]::Run($etlPath, $MaxEvents)
foreach ($f in $result.Flows) { $f.Process = Get-ProcessLabel $f.ProcessId }
foreach ($l in $result.Lookups) { $l.Process = Get-ProcessLabel $l.ProcessId }
$namesByAddress = $result.NamesByAddress
function Get-Names([string]$Address) {
if ($Address -and $namesByAddress.ContainsKey($Address)) { return @($namesByAddress[$Address]) }
return @()
}
# A flow counts as an EA service when any name its address was looked up under is one.
function Get-FlowKind($Flow) {
if (Test-EaAddress $Flow.Address) { return 'service' }
$kinds = @(Get-Names $Flow.Address | ForEach-Object { Get-EaKind $_ })
if ($kinds -contains 'service') { return 'service' }
if ($kinds -contains 'cdn') { return 'cdn' }
return $null
}
function Format-Flow($Flow) {
$names = (Get-Names $Flow.Address) -join ', '
if (-not $names) { $names = 'no lookup recorded' }
'{0,-28} {1} {2}:{3} [{4}] connects {5}, sent {6:N0} B in {7}, received {8:N0} B in {9}, {10:HH:mm:ss.fff}-{11:HH:mm:ss.fff}' -f
$Flow.Process, $Flow.Protocol, $Flow.Address, $Flow.Port, $names, $Flow.Connects,
$Flow.SentBytes, $Flow.Sends, $Flow.ReceivedBytes, $Flow.Receives, $Flow.First, $Flow.Last
}
function Format-Targets($Flows) {
($Flows | ForEach-Object {
$n = (Get-Names $_.Address) -join '/'
if (-not $n) { $n = $_.Address }
"$n ($($_.Address):$($_.Port))"
} | Select-Object -Unique) -join ', '
}
$allFlows = @($result.Flows | Sort-Object First)
# Windows itself (System, pid 4) sends NetBIOS name queries (UDP 137) to addresses this PC has
# just connected to; they are not the game's traffic.
$isNetBios = { $_.ProcessId -eq 4 -and $_.Protocol -eq 'UDP' -and $_.Port -eq 137 }
$eaFlows = @($allFlows | Where-Object { Get-FlowKind $_ })
$netBiosQueries = @($eaFlows | Where-Object $isNetBios).Count
$eaFlows = @($eaFlows | Where-Object { -not (& $isNetBios) })
$serviceFlows = @($eaFlows | Where-Object { (Get-FlowKind $_) -eq 'service' })
$cdnFlows = @($eaFlows | Where-Object { (Get-FlowKind $_) -eq 'cdn' })
# Tools that read the DNS cache through WMI (Get-DnsClientCache) show up as lookups by WmiPrvSE;
# those are cache reads, not a program contacting the host.
$isEaLookup = { $_.Event -eq 3006 -and (Get-EaKind $_.Name) }
$cacheReaders = @($result.Lookups | Where-Object $isEaLookup | Where-Object { $_.Process -like 'WmiPrvSE(*' })
$eaLookups = @($result.Lookups | Where-Object $isEaLookup | Where-Object { $_.Process -notlike 'WmiPrvSE(*' } | Sort-Object Time)
$eaAnswers = @($result.Lookups | Where-Object { $_.Event -eq 3008 -and $_.Status -eq '0' -and (Get-EaKind $_.Name) })
$focusIds = @($processes.GetEnumerator() | Where-Object { ($_.Value -split '/') | Where-Object { $focusProcesses -contains $_ } } | ForEach-Object { [int]$_.Key })
$focusFlows = @($allFlows | Where-Object { $focusIds -contains $_.ProcessId })
$focusListed = @($focusFlows | Where-Object { $_.Protocol -eq 'TCP' -or -not (Test-LocalNetworkAddress $_.Address) })
$focusLocal = $focusFlows.Count - $focusListed.Count
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('ReSkate EA traffic watch')
$lines.Add(('Recorded {0:yyyy-MM-dd HH:mm:ss} to {1:HH:mm:ss}; {2:N0} events read in {3:N1} s ({4:N0} network, {5:N0} loopback network left out, {6:N0} DNS).' -f
$result.First, $result.Last, $result.Events, $result.Seconds, $result.NetworkEvents, $result.LoopbackEvents, $result.DnsEvents))
if (Test-Path -LiteralPath $statsPath) {
$lost = Get-Content -LiteralPath $statsPath | Where-Object { $_ -match 'Lost' }
if ($lost) { $lines.Add('Trace session: ' + (($lost | ForEach-Object { $_.Trim() }) -join '; ')) }
}
if ($MaxEvents -gt 0) { $lines.Add("NOTE: only the first $MaxEvents events were read (-MaxEvents).") }
if ($result.NetworkEvents -eq 0) { $lines.Add('WARNING: no network events were recorded, so this capture cannot show traffic.') }
if ($result.DnsEvents -eq 0) { $lines.Add('WARNING: no DNS events were recorded, so this capture cannot show lookups.') }
if ($focusIds.Count -eq 0) { $lines.Add('WARNING: Skate.exe and ReSkateLauncher.exe were not running during the recording.') }
if (Test-Path -LiteralPath $envPath) {
Get-Content -LiteralPath $envPath | Where-Object { $_ } | ForEach-Object { $lines.Add("WARNING: $_") }
}
if ($netBiosQueries -gt 0) {
$lines.Add("NOTE: $netBiosQueries NetBIOS name queries (UDP 137) that Windows itself sent to EA addresses are left out; they are not the game's traffic.")
}
$lines.Add('')
$lines.Add('== 1. EA hostnames looked up, and by which program ==')
if ($cacheReaders.Count -gt 0) { $lines.Add("($($cacheReaders.Count) DNS cache reads through WMI (WmiPrvSE) left out.)") }
if ($eaLookups.Count -eq 0) { $lines.Add('(none)') }
foreach ($l in $eaLookups) {
$answer = $eaAnswers | Where-Object { $_.Name -eq $l.Name -and $_.ProcessId -eq $l.ProcessId -and $_.Time -ge $l.Time } |
Sort-Object Time | Select-Object -First 1
$answerText = if ($answer) { $answer.Results } else { 'no answer recorded' }
$lines.Add(('{0:HH:mm:ss.fff} {1,-28} {2} -> {3}' -f $l.Time, $l.Process, $l.Name, $answerText))
}
$lines.Add('')
$lines.Add('== 2. Traffic to or from EA services ==')
if ($serviceFlows.Count -eq 0) { $lines.Add('(none)') }
foreach ($f in $serviceFlows) { $lines.Add((Format-Flow $f)) }
$lines.Add('')
$lines.Add("== 3. Traffic to or from EA's image CDN (fast-travel artwork downloads) ==")
if ($cdnFlows.Count -eq 0) {
$lines.Add('(none) The game only downloads artwork missing from its cache. To record these downloads, close the game')
$lines.Add('and rename %LOCALAPPDATA%\ReSkate\Game\Skate\data\cache\http before recording.')
}
foreach ($f in $cdnFlows) { $lines.Add((Format-Flow $f)) }
$lines.Add('')
$lines.Add('== 4. Verdict ==')
$serviceSent = @($serviceFlows | Where-Object { $_.SentBytes -gt 0 })
if ($serviceSent.Count -gt 0) {
$lines.Add('DATA WAS SENT TO EA SERVICES:')
foreach ($group in $serviceSent | Group-Object Process) {
$bytes = ($group.Group | Measure-Object SentBytes -Sum).Sum
$lines.Add((' {0} sent {1:N0} bytes to {2}' -f $group.Name, $bytes, (Format-Targets $group.Group)))
}
} elseif (@($eaLookups | Where-Object { (Get-EaKind $_.Name) -eq 'service' }).Count -gt 0) {
$lines.Add('EA service hostnames were looked up, but no data was sent to their addresses.')
} else {
$lines.Add('No EA service was looked up or contacted.')
}
foreach ($group in $cdnFlows | Group-Object Process) {
$sent = ($group.Group | Measure-Object SentBytes -Sum).Sum
$received = ($group.Group | Measure-Object ReceivedBytes -Sum).Sum
$lines.Add(('CDN downloads: {0} sent {1:N0} bytes of requests and received {2:N0} bytes from {3}' -f
$group.Name, $sent, $received, (Format-Targets $group.Group)))
}
$lines.Add('')
$lines.Add("== 5. Traffic of $($focusProcesses -join ' / '): all TCP, and UDP beyond the local network ==")
if ($focusLocal -gt 0) { $lines.Add("($focusLocal UDP flows to local-network, broadcast or multicast addresses left out.)") }
if ($focusListed.Count -eq 0) { $lines.Add('(none)') }
$ptrTried = @{}
foreach ($f in $focusListed) {
if ($ReverseDns -and (Get-Names $f.Address).Count -eq 0 -and -not $ptrTried.ContainsKey($f.Address)) {
$ptrTried[$f.Address] = $true
$ptr = try { (Resolve-DnsName -Type PTR $f.Address -QuickTimeout -ErrorAction Stop | Select-Object -First 1).NameHost } catch { $null }
if ($ptr) {
$set = New-Object 'System.Collections.Generic.HashSet[string]'
[void]$set.Add("reverse DNS: $ptr")
$namesByAddress[$f.Address] = $set
}
}
$lines.Add((Format-Flow $f))
}
$lines | Set-Content -LiteralPath $reportPath -Encoding utf8
$result.Lookups | Sort-Object Time | Select-Object Time, Event, Process, ProcessId, Name, Type, Status, Results |
Export-Csv -LiteralPath $dnsCsvPath -NoTypeInformation -Encoding utf8
$allFlows | Select-Object Process, ProcessId, Protocol, Address, Port,
@{ n = 'Names'; e = { (Get-Names $_.Address) -join ', ' } }, @{ n = 'EA'; e = { Get-FlowKind $_ } },
Connects, SentBytes, Sends, ReceivedBytes, Receives, First, Last |
Export-Csv -LiteralPath $flowCsvPath -NoTypeInformation -Encoding utf8
$lines | ForEach-Object { Write-Host $_ }
Write-Host ''
Write-Host "Report: $reportPath" -ForegroundColor Green
}
if (-not $Analyze) { Start-Recording }
Invoke-Analysis