12 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
TownOfUs Stats Exporter is a standalone BepInEx plugin for Among Us that exports Town of Us Mira game statistics to a cloud API. The plugin operates completely independently of TOU Mira using reflection and Harmony patches - requiring zero modifications to the main mod.
Key Architecture Principles
Reflection-Based Approach
The plugin never directly references TOU Mira types. All access is via reflection through TouMiraReflectionBridge.cs which:
- Caches all Type and PropertyInfo objects at initialization for performance
- Handles IL2CPP interop for Unity collections
- Provides version compatibility checking
- Falls back gracefully if TOU Mira structures change
Harmony Patching Strategy
Uses [HarmonyPriority(Priority.Low)] on the AmongUsClient.OnGameEnd patch to ensure TOU Mira's BuildEndGameData() runs first and populates EndGameData.PlayerRecords before the export begins.
Game Duration Tracking
The plugin tracks actual gameplay duration (not lobby time) using GameStartTimePatch:
- Patches
ShipStatus.Beginto capture when the game round starts - Stores start time as
Time.timewhen gameplay begins - Calculates duration as difference between game end time and start time
- Resets on each game to ensure accurate per-game timing
Async Export Pattern
Export runs in a fire-and-forget Task.Run() to avoid blocking the game UI. The entire export process (reflection, data transformation, API call) happens asynchronously.
Build Commands
# Build the plugin
dotnet build -c Release
# Build in debug mode
dotnet build -c Debug
# Clean build
dotnet clean && dotnet build -c Release
The compiled DLL will be in bin/Release/TownOfUsStatsExporter.dll or bin/Debug/TownOfUsStatsExporter.dll.
If the AmongUs environment variable is set, the DLL auto-copies to $(AmongUs)/BepInEx/plugins/ after build.
Testing & Debugging
Local Testing
- Build in Debug mode
- Copy DLL to
Among Us/BepInEx/plugins/ - Launch Among Us with BepInEx console (F10)
- Check logs for "TownOfUs Stats Exporter" messages
- Complete a game and watch export logs
Log Locations
- BepInEx Console: Real-time logs (press F10 in game)
- BepInEx Log File:
Among Us/BepInEx/LogOutput.log - Local Backups:
Documents/TownOfUs/GameLogs/Game_YYYYMMDD_HHMMSS_<gameId>.json
Common Debug Scenarios
- "Failed to initialize reflection bridge": TOU Mira assembly not found or incompatible version
- "No player data available": Export triggered before TOU Mira populated EndGameData
- Empty role history: RoleString fallback parser activates - check RoleString format
Code Structure & Flow
Initialization Flow (Plugin Load)
TownOfUsStatsPlugin.Load()- BepInEx entry pointTouMiraReflectionBridge.Initialize()- Find TOU Mira assembly, cache reflection metadataVersionCompatibility.CheckVersion()- Verify TOU Mira version compatibilityHarmony.PatchAll()- Apply patches (EndGameExportPatch, GameStartTimePatch)
Game Start Flow
- ShipStatus.Begin fires (game round starts, gameplay begins)
- GameStartTimePatch.OnShipStatusBegin runs - records
Time.timeas game start time
Export Flow (Game End)
- AmongUsClient.OnGameEnd fires (game ends)
- GameStartTimePatch.OnGameEndPrefix runs - logs game duration
- TOU Mira's patch runs (High priority) - populates EndGameData.PlayerRecords
- EndGameExportPatch.Postfix runs (Low priority) - starts export
- Checks for Hide & Seek mode (skip if true)
- Fires Task.Run() with
StatsExporter.ExportGameStatsAsync()
- StatsExporter.ExportGameStatsAsync()
- Reads
ApiSet.iniconfig - Calls reflection bridge methods to collect data
- Transforms data via
DataTransformer.TransformToExportFormat()- Uses
GameStartTimePatch.GetGameDuration()for accurate game time
- Uses
- Saves local backup if enabled
- POSTs to API via
ApiClient.SendToApiAsync()
- Reads
- GameStartTimePatch.OnGameEndPostfix runs - clears game start time for next game
Key Reflection Patterns
Reading TOU Mira Data:
// Get static property value
var playerRecords = cache.PlayerRecordsProperty!.GetValue(null);
// Get instance property value
var value = obj.GetType().GetProperty("PropertyName")?.GetValue(obj);
// Handle IL2CPP lists
var managedList = IL2CPPHelper.ConvertToManagedList(il2cppList);
Adding New Reflected Data:
- Add to
ReflectionCache.cs(Type, PropertyInfo, FieldInfo) - Cache in
TouMiraReflectionBridge.CacheReflectionMetadata() - Add getter method in
TouMiraReflectionBridge - Add model to
Models/ReflectedData.cs - Use in
DataTransformer.TransformToExportFormat()
Important Constraints
What You Can Access
- Public classes, properties, fields, methods in TOU Mira
- Static data in
EndGamePatches.EndGameData - Static data in
GameHistory(PlayerStats, RoleHistory, KilledPlayers, WinningFaction) - PlayerControl instances via
PlayerControl.AllPlayerControls
What You Cannot Access
- Internal or private members (C# access modifiers enforced)
- Non-public nested types
- Direct type references (must use reflection strings)
Version Compatibility
- Tested: TOU Mira 1.2.0, 1.2.1
- Probably Compatible: Same major version (1.x.x)
- Incompatible: Different major version (2.x.x)
The plugin logs warnings for untested versions but attempts to run anyway.
Configuration System
The plugin searches for ApiSet.ini in priority order:
- Game directory (where DLL is located)
Documents/TownOfUs/ApiSet.ini
If not found, creates a default template at the Documents location.
Config Structure (INI format):
EnableApiExport=true
ApiToken=your_token_here
ApiEndpoint=https://api.example.com/endpoint
SaveLocalBackup=true
Secret=optional_secret
Config is read asynchronously each game - no restart required after editing.
Error Handling Philosophy
Fail-Safe Design
- Plugin failures never crash the game
- Export errors are logged but don't interrupt gameplay
- Individual player data errors skip that player, continue with others
- Missing optional data (modifiers, platform) defaults to empty/Unknown
Error Isolation Layers
- Top-level:
Task.Run()catches all exceptions - Export-level:
ExportGameStatsAsync()catches all exceptions - Component-level: Each transformer/client method has try-catch
- Player-level: Each player transform has try-catch
Data Models
Export Format (GameStatsData)
GameStatsData
├── Token (string) - API auth token
├── Secret (string?) - Optional additional secret
├── GameInfo
│ ├── GameId (guid)
│ ├── Timestamp (UTC)
│ ├── LobbyCode (e.g., "ABCDEF")
│ ├── GameMode (Normal/HideNSeek)
│ ├── Duration (seconds, actual gameplay time from intro end to game end)
│ └── Map (string)
├── Players[] (list)
│ ├── PlayerId (byte)
│ ├── PlayerName (string)
│ ├── PlayerTag (friend code)
│ ├── Platform (Steam/Epic/etc)
│ ├── Role (final role string)
│ ├── Roles[] (full role history, excludes ghost roles)
│ ├── Modifiers[] (e.g., ["Button Barry", "Flash"])
│ ├── IsWinner (bool)
│ └── Stats
│ ├── TotalTasks
│ ├── TasksCompleted
│ ├── Kills
│ ├── CorrectKills
│ ├── IncorrectKills
│ ├── CorrectAssassinKills
│ └── IncorrectAssassinKills
└── GameResult
└── WinningTeam (Crewmates/Impostors/Neutrals)
Internal Models (ReflectedData)
Intermediate models used between reflection bridge and transformer:
PlayerRecordData- Raw data from EndGameData.PlayerRecordsPlayerStatsData- Kill statistics from GameHistory.PlayerStatsKilledPlayerData- Kill events from GameHistory.KilledPlayers
Dependencies
- BepInEx.Unity.IL2CPP (6.0.0-be.735) - Plugin framework
- AmongUs.GameLibs.Steam (2025.9.9) - Game libraries
- HarmonyX (2.13.0) - Runtime patching
- AllOfUs.MiraAPI (0.3.0) - MiraAPI types (for PlayerControl, etc)
- Reactor (2.3.1) - Required by TOU Mira
- Il2CppInterop.Runtime (1.4.6-ci.426) - IL2CPP interop
All dependencies are NuGet packages referenced in TownOfUsStatsExporter.csproj.
Performance Considerations
- Reflection is ~100x slower than direct access, but total overhead is <100ms
- Reflection metadata is cached at plugin load for fast repeated access
- Export runs fully async - zero UI blocking time
- HTTP client is reused (static singleton) to avoid connection overhead
- JSON serialization uses System.Text.Json (fast, modern)
Modifier Detection System
The plugin uses three-tier modifier detection:
1. Symbol Extraction (Primary)
Player names can contain special symbols inside HTML tags that indicate modifiers.
Format: <b><size=60%>SYMBOL</size></b>
Supported symbols:
♥❤♡\u2665→ Lovers modifier#→ Underdog modifier★☆→ VIP modifier@→ Sleuth modifier†‡→ Bait modifier§→ Torch modifier⚡→ Flash modifier⚔🗡→ Assassin modifier
Important:
- Symbols are extracted only from
<size=...>SYMBOL</size>tags, not from entire player name - This prevents false positives (e.g.,
#in friend codes likeusername#1234) - Extraction happens before HTML tag stripping to preserve modifier information
Example:
Input: "<color=#EFBF04>boracik</color> <b><size=60%>♥</size></b>"
Step 1: ExtractModifiersFromPlayerName() finds "♥" in size tag → ["Lovers"]
Step 2: StripColorTags() removes HTML tags AND symbols → "boracik"
Output: playerName="boracik", modifiers=["Lovers"]
Note: The StripColorTags() function removes both HTML tags AND modifier symbols to ensure clean player names.
2. Reflection (Secondary)
Attempts to get modifiers via reflection on PlayerControl.GetModifiers<GameModifier>()
3. RoleString Parsing (Fallback)
Parses modifier names from parentheses in RoleString if both above methods fail.
Processing Order in DataTransformer.TransformPlayerData()
1. ExtractModifiersFromPlayerName(record.PlayerName) → Gets symbols
2. bridge.GetPlayerModifiers(playerId) → Gets via reflection
3. ParseModifiersFromRoleString(record.RoleString) → Parses string
4. StripColorTags(record.PlayerName) → Clean name for export
Adding New Features
To Add New Modifier Symbol
- Edit
ExtractModifiersFromPlayerName()inExport/DataTransformer.cs - Add symbol to
symbolToModifierdictionary - Rebuild and test
To Add New Exported Data
- Check if TOU Mira exposes it publicly (use dnSpy or ILSpy)
- Add reflection cache entry in
ReflectionCache.cs - Add caching logic in
TouMiraReflectionBridge.CacheReflectionMetadata() - Add getter method in
TouMiraReflectionBridge - Add property to export model in
Models/GameStatsData.cs - Add transformation in
DataTransformer.TransformToExportFormat()
To Add New Config Options
- Add property to
ApiConfig.cs - Add parsing in
ApiConfigManager.ParseIniFile() - Add to default template in
ApiConfigManager.CreateDefaultConfigAsync() - Use in appropriate exporter class
Data Cleaning & Normalization
All exported data is cleaned to ensure consistency:
Text Cleaning (StripColorTags)
Removes from all text fields (playerName, winningTeam, etc.):
- HTML/Unity tags:
<color=#XXX>,<b>,<size=XX%>, etc. - Modifier symbols:
♥,#,★,@, etc.
Role Name Normalization
- Removes suffixes:
"GrenadierRole"→"Grenadier" - Removes TOU suffix:
"EngineerTou"→"Engineer" - Skips generic types:
"RoleBehaviour"(not added to history)
Modifier Symbol Extraction
Symbols are extracted before text cleaning to preserve information:
- Extract from
<size=60%>SYMBOL</size>tags - Map to modifier names (e.g.,
♥→"Lovers") - Clean text (remove tags and symbols)
Known Limitations
- Cannot access internal/private TOU Mira members
- Role/modifier parsing falls back to string parsing if reflection fails
- Platform detection may fail on some Among Us versions (defaults to "Unknown")
- No compile-time type safety (all reflection is string-based)
- Breaking changes in TOU Mira require plugin updates
- Symbol encoding may vary by console/log viewer (e.g.,
♥may display asÔÖąin logs) - to memorize