From 17836b160150591bbacc6871447807b985cdc7ea Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Wed, 24 Jun 2026 15:24:42 +1200 Subject: [PATCH] feat(audience): auto-collect Epic account ID and detect Epic platform When the PlayEveryWare EOS Unity plugin is present, the SDK now: - Calls Identify() with the logged-in EpicAccountId automatically at Init - Sets distribution_platform = "epic" in game_launch properties when the game was launched from the Epic Games Store Detection uses C# reflection so there is no hard compile-time dependency; games without EOS are completely unaffected. Both EOS Unity plugin install methods are supported: - UPM package (com.playeveryware.eos / com.Epic.OnlineServices assembly) - Legacy DLL install (EOSSDK / PlayEveryWare.EpicOnlineServices assembly) EGS detection is based on launcher-injected CLI args (-EpicPortal, -epicapp=, -epicenv=), not EOS initialisation, so Steam-only games that use EOS for cross-play are not misattributed. link.xml: type-level preserve="all" entries for AuthInterface and EpicAccountId prevent IL2CPP High stripping from removing the method metadata that reflection depends on. Assembly-level preserve="all" only protects type declarations, not method bodies or their metadata. examples/audience/Assets/link.xml mirrors the same rules because Unity's linker skips package link.xml when the package is a file: path (dev mode). Co-Authored-By: Claude Sonnet 4.6 --- examples/audience/Assets/link.xml | 26 ++++ .../Audience/Runtime/ImmutableAudience.cs | 112 ++++++++++++++++++ src/Packages/Audience/Runtime/Utility/Log.cs | 13 ++ src/Packages/Audience/link.xml | 28 +++++ 4 files changed, 179 insertions(+) diff --git a/examples/audience/Assets/link.xml b/examples/audience/Assets/link.xml index c90feed3..a2a3e288 100644 --- a/examples/audience/Assets/link.xml +++ b/examples/audience/Assets/link.xml @@ -34,4 +34,30 @@ hooks fire under IL2CPP with stripping High. + + + + + + + + + + + + + + + + + diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 14c1ba4c..50f6dd01 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -257,6 +257,7 @@ public static void Init(AudienceConfig config) FireGameLaunch(config, consentAtInit, skanRegistered, attributionContext); TryIdentifySteamUser(); + TryIdentifyEpicUser(); CheckAndFireAttStatusChanged(config, consentAtInit); @@ -1214,6 +1215,116 @@ private static bool TryGetFacepunchId(out string? id) return true; } + // Resolves PlayEveryWare.EpicOnlineServices.EOSManager across install methods. + // Returns null when the EOS Unity plugin is not present. + private static System.Type? ResolveEosManagerType() => + System.Type.GetType("PlayEveryWare.EpicOnlineServices.EOSManager, PlayEveryWare.EpicOnlineServices") + ?? System.Type.GetType("PlayEveryWare.EpicOnlineServices.EOSManager, com.playeveryware.eos.core") + ?? System.Type.GetType("PlayEveryWare.EpicOnlineServices.EOSManager, Assembly-CSharp"); + + // Gets the initialised PlatformInterface handle from EOSManager.Instance. + // Returns null when the EOS plugin is absent or EOS has not been initialised. + private static object? GetEosPlatformInterface() + { + var managerType = ResolveEosManagerType(); + if (managerType == null) return null; + // Use the compiled getter name (get_Instance) for IL2CPP compatibility; + // property metadata can be stripped even when the method body survives. + var instance = managerType.GetMethod("get_Instance")?.Invoke(null, null) + ?? managerType.GetProperty("Instance")?.GetValue(null); + if (instance == null) return null; + return instance.GetType().GetMethod("GetEOSPlatformInterface")?.Invoke(instance, null); + } + + // Sets distribution_platform = "epic" when the game was launched from the Epic + // Games Store launcher. Uses command-line args injected by the EGS launcher + // (-epicenv=, -epicapp=) rather than EOS-being-initialised, which would + // misattribute Steam games that use EOS purely for cross-play. + // Config override wins afterward. + private static void TryDetectEpicPlatform(Dictionary properties) + { + try + { + if (IsLaunchedFromEpicGamesStore()) + properties["distribution_platform"] = DistributionPlatforms.Epic; + } + catch (Exception ex) + { + Log.Warn(AudienceLogs.EpicPlatformDetectionFailed(ex)); + } + } + + // EGS launcher injects these args into every game it launches, regardless of + // whether the game integrates EOS. Absent when EOS is used for cross-play only. + // -EpicPortal is the strongest signal (bare flag, no value); -epicapp= and + // -epicenv= are the environment/artifact identifiers also always present. + private static bool IsLaunchedFromEpicGamesStore() + { + var args = Environment.GetCommandLineArgs(); + foreach (var arg in args) + { + if (arg.Equals("-EpicPortal", StringComparison.OrdinalIgnoreCase) || + arg.StartsWith("-epicenv=", StringComparison.OrdinalIgnoreCase) || + arg.StartsWith("-epicapp=", StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + // Calls Identify with the logged-in EOS ProductUserId. + // No-op if EOS is not present, not initialised, no user is logged in, + // or consent is below Full. + private static void TryIdentifyEpicUser() + { + try + { + if (!TryGetEpicAccountId(out var id)) + return; + Log.Debug(AudienceLogs.EpicAutoIdentified(id!)); + Identify(id!, IdentityType.Epic); + } + catch (Exception ex) + { + Log.Warn(AudienceLogs.EpicIdentityCollectionFailed(ex)); + } + } + + // Reads the EOS EpicAccountId via AuthInterface.GetLoggedInAccountByIndex(0). + // EpicAccountId is the player's Epic Games Account — consistent across all products. + // Requires the game to have already initialised EOS via EOSManager. + private static bool TryGetEpicAccountId(out string? id) + { + id = null; + + // Guard: EOS C# bindings must be present (assembly name varies by install method). + if (System.Type.GetType("Epic.OnlineServices.Auth.AuthInterface, com.Epic.OnlineServices") == null + && System.Type.GetType("Epic.OnlineServices.Auth.AuthInterface, EOSSDK") == null) + return false; + + var platformInterface = GetEosPlatformInterface(); + if (platformInterface == null) return false; + + var authInterface = platformInterface.GetType() + .GetMethod("GetAuthInterface")?.Invoke(platformInterface, null); + if (authInterface == null) return false; + + // Skip if no accounts are logged in. + var countResult = authInterface.GetType() + .GetMethod("GetLoggedInAccountsCount")?.Invoke(authInterface, null); + if (!(countResult is int count && count > 0)) return false; + + // C# binding takes a plain int index, not an options struct. + var epicAccountId = authInterface.GetType() + .GetMethod("GetLoggedInAccountByIndex")?.Invoke(authInterface, new object[] { 0 }); + if (epicAccountId == null) return false; + + if (epicAccountId.GetType().GetMethod("IsValid")?.Invoke(epicAccountId, null) as bool? != true) + return false; + + id = epicAccountId.ToString(); + return !string.IsNullOrEmpty(id); + } + // consentAtInit only gates the launch; Track still checks live _state via CanTrack. private static void FireGameLaunch( AudienceConfig config, @@ -1246,6 +1357,7 @@ private static void FireGameLaunch( // Auto-detect distribution platform via reflection. Config override wins below. TryDetectSteamPlatform(properties); + TryDetectEpicPlatform(properties); // Config-supplied distributionPlatform overrides the auto-detected value. if (config.DistributionPlatform != null) diff --git a/src/Packages/Audience/Runtime/Utility/Log.cs b/src/Packages/Audience/Runtime/Utility/Log.cs index 8fa5eb43..7285c5e4 100644 --- a/src/Packages/Audience/Runtime/Utility/Log.cs +++ b/src/Packages/Audience/Runtime/Utility/Log.cs @@ -171,5 +171,18 @@ internal static string SteamAutoIdentified(string steamId) => internal static string SteamIdentityCollectionFailed(Exception ex) => $"Steam identity collection threw {ex.GetType().Name}: {ex.Message}. " + "Steam user ID will not be auto-collected."; + + // ---- Epic auto-detection ---- + + internal static string EpicPlatformDetectionFailed(Exception ex) => + $"Epic platform detection threw {ex.GetType().Name}: {ex.Message}. " + + "distribution_platform will not be auto-set."; + + internal static string EpicAutoIdentified(string epicId) => + $"auto-identified epic user: {epicId}"; + + internal static string EpicIdentityCollectionFailed(Exception ex) => + $"Epic identity collection threw {ex.GetType().Name}: {ex.Message}. " + + "Epic user ID will not be auto-collected."; } } diff --git a/src/Packages/Audience/link.xml b/src/Packages/Audience/link.xml index 38cd8398..22646dc7 100644 --- a/src/Packages/Audience/link.xml +++ b/src/Packages/Audience/link.xml @@ -41,4 +41,32 @@ framework dependency. + + + + + + + + + + + + + + + + +