Major improvements to data cleaning, modifier extraction, and role name handling: ## Data Cleaning & Normalization - Enhanced StripColorTags() to remove ALL HTML/Unity tags using universal regex - Added removal of modifier symbols (♥, #, ★, etc.) from player names - Fixed winningTeam containing HTML tags by applying StripColorTags() - Result: Clean player names without tags or symbols in exported JSON ## Modifier Detection System - Implemented three-tier modifier detection: 1. Symbol extraction from <size=60%>SYMBOL</size> tags (primary) 2. Reflection-based GetModifiers<GameModifier>() (secondary) 3. RoleString parsing fallback (tertiary) - Fixed false positive Underdog detection from friend codes (username#1234) - Symbols now extracted ONLY from size tags, not entire player name - Fixed Dictionary duplicate key error (removed \u2665 duplicate of ♥) - Supported symbols: ♥❤♡ (Lovers), # (Underdog), ★☆ (VIP), @ (Sleuth), etc. ## Role Name Handling - Added fallback for roles without GetRoleName() method - Extracts role names from type names: "GrenadierRole" → "Grenadier" - Removes "Tou" suffix: "EngineerTou" → "Engineer" - Skips generic "RoleBehaviour" type (not a real role) - Result: Full role history now populated correctly ## Code Quality - Added comprehensive logging for debugging modifier/role extraction - Enhanced error handling with per-player try-catch blocks - Improved code documentation with inline comments ## Documentation - Added CLAUDE.md with complete architecture guide - Documented modifier detection system - Added data cleaning & normalization section - Included troubleshooting guide and development workflows Fixes: #1 (HTML tags in export), #2 (missing role history), #3 (false modifier detection) 🎮 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
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.
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)
Export Flow (Game End)
- AmongUsClient.OnGameEnd fires (game ends)
- 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() - Saves local backup if enabled
- POSTs to API via
ApiClient.SendToApiAsync()
- Reads
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)
│ └── 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)