# Game Statistics Export - Pure Standalone Implementation
## Complete DLL Plugin Without TOU Mira Modifications
**Document Version:** 1.0
**Date:** 2025-10-07
**Scenario:** Pure Standalone - Zero TOU Mira code changes
---
## Table of Contents
1. [Overview](#overview)
2. [Architecture](#architecture)
3. [Complete Implementation](#complete-implementation)
4. [Reflection Layer](#reflection-layer)
5. [Version Compatibility](#version-compatibility)
6. [Testing Strategy](#testing-strategy)
7. [Deployment Guide](#deployment-guide)
8. [Troubleshooting](#troubleshooting)
9. [Limitations](#limitations)
---
## Overview
### Concept
Ten dokument opisuje implementację **w 100% standalone** plugin DLL, który:
- ❌ **NIE modyfikuje** żadnego kodu TOU Mira
- ✅ Używa **refleksji** do dostępu do publicznych klas
- ✅ Używa **Harmony patches** do podpięcia się pod event flow
- ✅ Jest **całkowicie opcjonalny** dla użytkowników
- ✅ Może być **instalowany/usuwany** bez przebudowy TOU Mira
### Directory Structure
```
Among Us/
└── BepInEx/
└── plugins/
├── TownOfUsMira.dll # Oryginalny mod (NIEZMIENIONY)
├── MiraAPI.dll # Dependency
├── Reactor.dll # Dependency
└── TownOfUsStatsExporter.dll # ← NOWY plugin (standalone)
```
### User Installation
```
1. Download TownOfUsStatsExporter.dll
2. Copy to BepInEx/plugins/
3. Create ApiSet.ini with configuration
4. Done! Stats will be exported automatically
```
---
## Architecture
### High-Level Design
```
┌─────────────────────────────────────────────────────────────────┐
│ Among Us Game Process │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ TownOfUsMira.dll (UNTOUCHED) │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ EndGamePatches.BuildEndGameData() │ │ │
│ │ │ - Populates EndGameData.PlayerRecords │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ GameHistory (public static class) │ │ │
│ │ │ - RoleHistory │ │ │
│ │ │ - PlayerStats │ │ │
│ │ │ - KilledPlayers │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ▲ │
│ │ Reflection Access │
│ │ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ TownOfUsStatsExporter.dll (STANDALONE PLUGIN) │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ Harmony Patch on EndGameManager.Start │ │ │
│ │ │ - Lower priority (runs AFTER TOU Mira) │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ TouMiraReflectionBridge │ │ │
│ │ │ - GetEndGameData() │ │ │
│ │ │ - GetGameHistory() │ │ │
│ │ │ - GetPlayerStats() │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ StatsExporter │ │ │
│ │ │ - Transform data │ │ │
│ │ │ - Send to API │ │ │
│ │ │ - Save local backup │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Component Diagram
```
TownOfUsStatsExporter.dll
├── TownOfUsStatsPlugin.cs # BepInEx plugin entry point
├── Patches/
│ └── EndGameExportPatch.cs # Harmony patch (low priority)
├── Reflection/
│ ├── TouMiraReflectionBridge.cs # Main reflection interface
│ ├── ReflectionCache.cs # Cached reflection metadata
│ ├── VersionCompatibility.cs # Version checking
│ └── IL2CPPHelper.cs # IL2CPP type conversions
├── Export/
│ ├── StatsExporter.cs # Main export orchestrator
│ ├── DataTransformer.cs # Transform TOU data to export format
│ └── ApiClient.cs # HTTP client for API
├── Config/
│ ├── ApiConfigManager.cs # INI file reader
│ └── ApiConfig.cs # Config model
└── Models/
├── GameStatsData.cs # Export data models
└── ReflectedData.cs # DTOs for reflected data
```
---
## Complete Implementation
### 1. Plugin Entry Point
**File:** `TownOfUsStatsPlugin.cs`
```csharp
using BepInEx;
using BepInEx.Unity.IL2CPP;
using BepInEx.Logging;
using HarmonyLib;
using System;
using System.Reflection;
namespace TownOfUsStatsExporter;
[BepInPlugin(PluginGuid, PluginName, PluginVersion)]
[BepInDependency("auavengers.tou.mira", BepInDependency.DependencyFlags.HardDependency)]
[BepInDependency("gg.reactor.api", BepInDependency.DependencyFlags.HardDependency)]
[BepInDependency("me.mira.api", BepInDependency.DependencyFlags.HardDependency)]
public class TownOfUsStatsPlugin : BasePlugin
{
public const string PluginGuid = "com.townofus.stats.exporter";
public const string PluginName = "TownOfUs Stats Exporter";
public const string PluginVersion = "1.0.0";
internal static ManualLogSource Logger { get; private set; } = null!;
internal static Harmony Harmony { get; private set; } = null!;
private TouMiraReflectionBridge? reflectionBridge;
public override void Load()
{
Logger = Log;
Harmony = new Harmony(PluginGuid);
Logger.LogInfo("========================================");
Logger.LogInfo($"{PluginName} v{PluginVersion}");
Logger.LogInfo("========================================");
// Initialize reflection bridge
reflectionBridge = new TouMiraReflectionBridge();
if (!reflectionBridge.Initialize())
{
Logger.LogError("Failed to initialize TOU Mira reflection bridge!");
Logger.LogError("This plugin may not be compatible with your TOU Mira version.");
Logger.LogError("Plugin will be disabled.");
return;
}
Logger.LogInfo($"Successfully connected to TOU Mira v{reflectionBridge.TouMiraVersion}");
Logger.LogInfo($"Compatibility: {reflectionBridge.CompatibilityStatus}");
// Store bridge in static context for patches
ReflectionBridgeProvider.SetBridge(reflectionBridge);
// Apply Harmony patches
try
{
Harmony.PatchAll(Assembly.GetExecutingAssembly());
Logger.LogInfo("Harmony patches applied successfully");
}
catch (Exception ex)
{
Logger.LogError($"Failed to apply Harmony patches: {ex}");
return;
}
Logger.LogInfo($"{PluginName} loaded successfully!");
Logger.LogInfo("Stats will be exported at the end of each game.");
}
public override bool Unload()
{
Logger.LogInfo($"Unloading {PluginName}...");
Harmony?.UnpatchSelf();
return true;
}
}
///
/// Static provider for accessing reflection bridge from patches
///
internal static class ReflectionBridgeProvider
{
private static TouMiraReflectionBridge? bridge;
public static void SetBridge(TouMiraReflectionBridge b) => bridge = b;
public static TouMiraReflectionBridge GetBridge() => bridge ?? throw new InvalidOperationException("Bridge not initialized");
}
```
---
### 2. Harmony Patch
**File:** `Patches/EndGameExportPatch.cs`
```csharp
using HarmonyLib;
using System;
using System.Threading.Tasks;
namespace TownOfUsStatsExporter.Patches;
///
/// Patch on EndGameManager.Start to trigger stats export.
/// Uses Low priority to execute AFTER TOU Mira's patch.
///
[HarmonyPatch(typeof(EndGameManager), nameof(EndGameManager.Start))]
public static class EndGameExportPatch
{
///
/// Postfix patch - runs after original method and TOU Mira's patch
///
[HarmonyPostfix]
[HarmonyPriority(Priority.Low)] // Run AFTER TOU Mira (which uses normal priority)
public static void Postfix(EndGameManager __instance)
{
try
{
TownOfUsStatsPlugin.Logger.LogInfo("=== End Game Export Patch Triggered ===");
// Check if this is Hide & Seek mode (skip export)
if (GameOptionsManager.Instance?.CurrentGameOptions?.GameMode == GameModes.HideNSeek)
{
TownOfUsStatsPlugin.Logger.LogInfo("Hide & Seek mode detected - skipping export");
return;
}
// Fire-and-forget async export (don't block UI)
_ = Task.Run(async () =>
{
try
{
await StatsExporter.ExportGameStatsAsync();
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Unhandled error in stats export: {ex}");
}
});
TownOfUsStatsPlugin.Logger.LogInfo("Stats export task started in background");
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Error in EndGameExportPatch: {ex}");
}
}
}
```
---
### 3. Reflection Bridge (Core)
**File:** `Reflection/TouMiraReflectionBridge.cs`
```csharp
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using TownOfUsStatsExporter.Models;
namespace TownOfUsStatsExporter.Reflection;
///
/// Main bridge for accessing TOU Mira data through reflection.
/// Caches all reflection metadata for performance.
///
public class TouMiraReflectionBridge
{
private Assembly? touAssembly;
private ReflectionCache cache = new();
public string? TouMiraVersion { get; private set; }
public string CompatibilityStatus { get; private set; } = "Unknown";
///
/// Initialize the reflection bridge by finding TOU Mira and caching reflection metadata
///
public bool Initialize()
{
try
{
TownOfUsStatsPlugin.Logger.LogInfo("Initializing TOU Mira reflection bridge...");
// Find TOU Mira assembly
touAssembly = AppDomain.CurrentDomain.GetAssemblies()
.FirstOrDefault(a => a.GetName().Name == "TownOfUs");
if (touAssembly == null)
{
TownOfUsStatsPlugin.Logger.LogError("TOU Mira assembly not found!");
return false;
}
TouMiraVersion = touAssembly.GetName().Version?.ToString() ?? "Unknown";
TownOfUsStatsPlugin.Logger.LogInfo($"Found TOU Mira assembly v{TouMiraVersion}");
// Check version compatibility
CompatibilityStatus = VersionCompatibility.CheckVersion(TouMiraVersion);
if (CompatibilityStatus.StartsWith("Unsupported"))
{
TownOfUsStatsPlugin.Logger.LogWarning($"Version compatibility: {CompatibilityStatus}");
TownOfUsStatsPlugin.Logger.LogWarning("Plugin may not work correctly!");
}
// Cache reflection metadata
if (!CacheReflectionMetadata())
{
TownOfUsStatsPlugin.Logger.LogError("Failed to cache reflection metadata");
return false;
}
TownOfUsStatsPlugin.Logger.LogInfo("Reflection bridge initialized successfully");
return true;
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Failed to initialize reflection bridge: {ex}");
return false;
}
}
private bool CacheReflectionMetadata()
{
try
{
// Find and cache EndGamePatches type
cache.EndGamePatchesType = touAssembly!.GetType("TownOfUs.Patches.EndGamePatches");
if (cache.EndGamePatchesType == null)
{
TownOfUsStatsPlugin.Logger.LogError("Type not found: TownOfUs.Patches.EndGamePatches");
return false;
}
// Find and cache EndGameData nested type
cache.EndGameDataType = cache.EndGamePatchesType.GetNestedType("EndGameData", BindingFlags.Public);
if (cache.EndGameDataType == null)
{
TownOfUsStatsPlugin.Logger.LogError("Type not found: EndGameData");
return false;
}
// Find and cache PlayerRecord nested type
cache.PlayerRecordType = cache.EndGameDataType.GetNestedType("PlayerRecord", BindingFlags.Public);
if (cache.PlayerRecordType == null)
{
TownOfUsStatsPlugin.Logger.LogError("Type not found: PlayerRecord");
return false;
}
// Cache PlayerRecords property
cache.PlayerRecordsProperty = cache.EndGameDataType.GetProperty("PlayerRecords",
BindingFlags.Public | BindingFlags.Static);
if (cache.PlayerRecordsProperty == null)
{
TownOfUsStatsPlugin.Logger.LogError("Property not found: EndGameData.PlayerRecords");
return false;
}
// Find and cache GameHistory type
cache.GameHistoryType = touAssembly.GetType("TownOfUs.Modules.GameHistory");
if (cache.GameHistoryType == null)
{
TownOfUsStatsPlugin.Logger.LogError("Type not found: TownOfUs.Modules.GameHistory");
return false;
}
// Cache GameHistory properties
cache.PlayerStatsProperty = cache.GameHistoryType.GetProperty("PlayerStats",
BindingFlags.Public | BindingFlags.Static);
cache.RoleHistoryProperty = cache.GameHistoryType.GetProperty("RoleHistory",
BindingFlags.Public | BindingFlags.Static);
cache.KilledPlayersProperty = cache.GameHistoryType.GetProperty("KilledPlayers",
BindingFlags.Public | BindingFlags.Static);
cache.WinningFactionProperty = cache.GameHistoryType.GetProperty("WinningFaction",
BindingFlags.Public | BindingFlags.Static);
if (cache.PlayerStatsProperty == null || cache.RoleHistoryProperty == null)
{
TownOfUsStatsPlugin.Logger.LogError("Required GameHistory properties not found");
return false;
}
TownOfUsStatsPlugin.Logger.LogInfo("All required types and properties cached successfully");
return true;
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Error caching reflection metadata: {ex}");
return false;
}
}
///
/// Get player records from EndGameData
///
public List GetPlayerRecords()
{
try
{
var playerRecords = cache.PlayerRecordsProperty!.GetValue(null);
if (playerRecords == null)
{
TownOfUsStatsPlugin.Logger.LogWarning("PlayerRecords is null");
return new List();
}
// Handle IL2CPP list
var recordsList = IL2CPPHelper.ConvertToManagedList(playerRecords);
var result = new List();
foreach (var record in recordsList)
{
if (record == null) continue;
result.Add(new PlayerRecordData
{
PlayerName = GetPropertyValue(record, "PlayerName") ?? "Unknown",
RoleString = GetPropertyValue(record, "RoleString") ?? "",
Winner = GetPropertyValue(record, "Winner"),
PlayerId = GetPropertyValue(record, "PlayerId"),
TeamString = GetTeamString(record)
});
}
TownOfUsStatsPlugin.Logger.LogInfo($"Retrieved {result.Count} player records");
return result;
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Error getting player records: {ex}");
return new List();
}
}
///
/// Get player statistics from GameHistory
///
public Dictionary GetPlayerStats()
{
try
{
var playerStats = cache.PlayerStatsProperty!.GetValue(null);
if (playerStats == null)
{
TownOfUsStatsPlugin.Logger.LogWarning("PlayerStats is null");
return new Dictionary();
}
var statsDict = (IDictionary)playerStats;
var result = new Dictionary();
foreach (DictionaryEntry entry in statsDict)
{
var playerId = (byte)entry.Key;
var stats = entry.Value;
if (stats == null) continue;
result[playerId] = new PlayerStatsData
{
CorrectKills = GetPropertyValue(stats, "CorrectKills"),
IncorrectKills = GetPropertyValue(stats, "IncorrectKills"),
CorrectAssassinKills = GetPropertyValue(stats, "CorrectAssassinKills"),
IncorrectAssassinKills = GetPropertyValue(stats, "IncorrectAssassinKills")
};
}
TownOfUsStatsPlugin.Logger.LogInfo($"Retrieved stats for {result.Count} players");
return result;
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Error getting player stats: {ex}");
return new Dictionary();
}
}
///
/// Get role history from GameHistory
///
public Dictionary> GetRoleHistory()
{
try
{
var roleHistory = cache.RoleHistoryProperty!.GetValue(null);
if (roleHistory == null)
{
TownOfUsStatsPlugin.Logger.LogWarning("RoleHistory is null");
return new Dictionary>();
}
var historyList = IL2CPPHelper.ConvertToManagedList(roleHistory);
var result = new Dictionary>();
foreach (var entry in historyList)
{
if (entry == null) continue;
// Entry is KeyValuePair
var kvpType = entry.GetType();
var playerId = (byte)kvpType.GetProperty("Key")!.GetValue(entry)!;
var roleBehaviour = kvpType.GetProperty("Value")!.GetValue(entry);
if (roleBehaviour == null) continue;
// Get role name from RoleBehaviour.GetRoleName()
var getRoleNameMethod = roleBehaviour.GetType().GetMethod("GetRoleName");
if (getRoleNameMethod == null) continue;
var roleName = (string)getRoleNameMethod.Invoke(roleBehaviour, null)!;
// Skip ghost roles
if (roleName.Contains("Ghost")) continue;
// Strip color tags
roleName = StripColorTags(roleName);
if (!result.ContainsKey(playerId))
{
result[playerId] = new List();
}
result[playerId].Add(roleName);
}
TownOfUsStatsPlugin.Logger.LogInfo($"Retrieved role history for {result.Count} players");
return result;
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Error getting role history: {ex}");
return new Dictionary>();
}
}
///
/// Get killed players list
///
public List GetKilledPlayers()
{
try
{
var killedPlayers = cache.KilledPlayersProperty?.GetValue(null);
if (killedPlayers == null)
{
return new List();
}
var killedList = IL2CPPHelper.ConvertToManagedList(killedPlayers);
var result = new List();
foreach (var killed in killedList)
{
if (killed == null) continue;
result.Add(new KilledPlayerData
{
KillerId = GetPropertyValue(killed, "KillerId"),
VictimId = GetPropertyValue(killed, "VictimId")
});
}
return result;
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Error getting killed players: {ex}");
return new List();
}
}
///
/// Get winning faction string
///
public string GetWinningFaction()
{
try
{
if (cache.WinningFactionProperty == null)
return string.Empty;
var winningFaction = cache.WinningFactionProperty.GetValue(null);
return winningFaction as string ?? string.Empty;
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Error getting winning faction: {ex}");
return string.Empty;
}
}
///
/// Get modifiers for a player
///
public List GetPlayerModifiers(byte playerId)
{
try
{
// Find PlayerControl
var player = PlayerControl.AllPlayerControls.ToArray()
.FirstOrDefault(p => p.PlayerId == playerId);
if (player == null)
return new List();
// Get modifiers through reflection
// player.GetModifiers() but through reflection
var getModifiersMethod = player.GetType().GetMethods()
.FirstOrDefault(m => m.Name == "GetModifiers" && m.IsGenericMethod);
if (getModifiersMethod == null)
return new List();
// Find GameModifier type
var gameModifierType = touAssembly!.GetType("MiraAPI.Modifiers.GameModifier");
if (gameModifierType == null)
return new List();
var genericMethod = getModifiersMethod.MakeGenericMethod(gameModifierType);
var modifiers = genericMethod.Invoke(player, null);
if (modifiers == null)
return new List();
var modifiersList = IL2CPPHelper.ConvertToManagedList(modifiers);
var result = new List();
foreach (var modifier in modifiersList)
{
if (modifier == null) continue;
var modifierName = GetPropertyValue(modifier, "ModifierName");
if (!string.IsNullOrEmpty(modifierName))
{
result.Add(modifierName);
}
}
return result;
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Error getting modifiers for player {playerId}: {ex}");
return new List();
}
}
private string GetTeamString(object record)
{
try
{
// Get Team property (ModdedRoleTeams enum)
var teamProperty = record.GetType().GetProperty("Team");
if (teamProperty == null) return "Unknown";
var team = teamProperty.GetValue(record);
if (team == null) return "Unknown";
return team.ToString() ?? "Unknown";
}
catch
{
return "Unknown";
}
}
private T GetPropertyValue(object obj, string propertyName)
{
try
{
var property = obj?.GetType().GetProperty(propertyName);
if (property == null) return default!;
var value = property.GetValue(obj);
if (value == null) return default!;
return (T)value;
}
catch
{
return default!;
}
}
private string StripColorTags(string text)
{
if (string.IsNullOrEmpty(text)) return text;
text = Regex.Replace(text, @"", "");
text = text.Replace("", "");
text = text.Replace("", "").Replace("", "");
text = text.Replace("", "").Replace("", "");
return text.Trim();
}
}
```
---
### 4. Reflection Cache
**File:** `Reflection/ReflectionCache.cs`
```csharp
using System;
using System.Reflection;
namespace TownOfUsStatsExporter.Reflection;
///
/// Cache for reflection metadata to improve performance.
/// Reflection is ~100x slower than direct access, so caching is essential.
///
internal class ReflectionCache
{
// Types
public Type? EndGamePatchesType { get; set; }
public Type? EndGameDataType { get; set; }
public Type? PlayerRecordType { get; set; }
public Type? GameHistoryType { get; set; }
// Properties
public PropertyInfo? PlayerRecordsProperty { get; set; }
public PropertyInfo? PlayerStatsProperty { get; set; }
public PropertyInfo? RoleHistoryProperty { get; set; }
public PropertyInfo? KilledPlayersProperty { get; set; }
public PropertyInfo? WinningFactionProperty { get; set; }
// Methods (if needed)
public MethodInfo? GetRoleNameMethod { get; set; }
}
```
---
### 5. Version Compatibility
**File:** `Reflection/VersionCompatibility.cs`
```csharp
using System;
using System.Collections.Generic;
namespace TownOfUsStatsExporter.Reflection;
///
/// Manages version compatibility checks for TOU Mira
///
public static class VersionCompatibility
{
// Known compatible versions
private static readonly HashSet TestedVersions = new()
{
"1.2.1",
"1.2.0",
};
// Known incompatible versions
private static readonly HashSet IncompatibleVersions = new()
{
// Add any known incompatible versions here
};
public static string CheckVersion(string? version)
{
if (string.IsNullOrEmpty(version))
return "Unsupported: Version unknown";
// Parse version
if (!Version.TryParse(version, out var parsedVersion))
return $"Unsupported: Cannot parse version '{version}'";
// Check if explicitly incompatible
if (IncompatibleVersions.Contains(version))
return $"Unsupported: Version {version} is known to be incompatible";
// Check if tested
if (TestedVersions.Contains(version))
return $"Supported: Version {version} is tested and compatible";
// Check if it's a newer minor/patch version
foreach (var testedVersion in TestedVersions)
{
if (Version.TryParse(testedVersion, out var tested))
{
// Same major version = probably compatible
if (parsedVersion.Major == tested.Major)
{
return $"Probably Compatible: Version {version} (tested with {testedVersion})";
}
}
}
return $"Unsupported: Version {version} has not been tested";
}
public static void AddTestedVersion(string version)
{
TestedVersions.Add(version);
}
public static void AddIncompatibleVersion(string version)
{
IncompatibleVersions.Add(version);
}
}
```
---
### 6. IL2CPP Helper
**File:** `Reflection/IL2CPPHelper.cs`
```csharp
using System;
using System.Collections;
using System.Collections.Generic;
using Il2CppInterop.Runtime;
using Il2CppInterop.Runtime.InteropTypes.Arrays;
namespace TownOfUsStatsExporter.Reflection;
///
/// Helper for converting IL2CPP types to managed types
///
public static class IL2CPPHelper
{
///
/// Convert IL2CPP list/collection to managed List
///
public static List