2021-10-11 14:45:06 +08:00
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System ;
using System.Collections.Generic ;
using System.IO ;
using System.Linq ;
using System.Reflection ;
using osu.Framework ;
2021-10-15 15:15:34 +08:00
using osu.Framework.Extensions.ObjectExtensions ;
2021-10-11 14:45:06 +08:00
using osu.Framework.Logging ;
using osu.Framework.Platform ;
using osu.Game.Database ;
using osu.Game.Models ;
using osu.Game.Rulesets ;
#nullable enable
namespace osu.Game.Stores
{
public class RealmRulesetStore : IDisposable
{
private readonly RealmContextFactory realmFactory ;
2021-10-14 13:16:39 +08:00
private const string ruleset_library_prefix = @"osu.Game.Rulesets" ;
2021-10-11 14:45:06 +08:00
private readonly Dictionary < Assembly , Type > loadedAssemblies = new Dictionary < Assembly , Type > ( ) ;
/// <summary>
/// All available rulesets.
/// </summary>
public IEnumerable < IRulesetInfo > AvailableRulesets = > availableRulesets ;
private readonly List < IRulesetInfo > availableRulesets = new List < IRulesetInfo > ( ) ;
public RealmRulesetStore ( RealmContextFactory realmFactory , Storage ? storage = null )
{
this . realmFactory = realmFactory ;
// On android in release configuration assemblies are loaded from the apk directly into memory.
// We cannot read assemblies from cwd, so should check loaded assemblies instead.
loadFromAppDomain ( ) ;
// This null check prevents Android from attempting to load the rulesets from disk,
// as the underlying path "AppContext.BaseDirectory", despite being non-nullable, it returns null on android.
// See https://github.com/xamarin/xamarin-android/issues/3489.
if ( RuntimeInfo . StartupDirectory ! = null )
loadFromDisk ( ) ;
// the event handler contains code for resolving dependency on the game assembly for rulesets located outside the base game directory.
// It needs to be attached to the assembly lookup event before the actual call to loadUserRulesets() else rulesets located out of the base game directory will fail
// to load as unable to locate the game core assembly.
AppDomain . CurrentDomain . AssemblyResolve + = resolveRulesetDependencyAssembly ;
2021-10-14 13:16:39 +08:00
var rulesetStorage = storage ? . GetStorageForDirectory ( @"rulesets" ) ;
2021-10-11 14:45:06 +08:00
if ( rulesetStorage ! = null )
loadUserRulesets ( rulesetStorage ) ;
addMissingRulesets ( ) ;
}
/// <summary>
/// Retrieve a ruleset using a known ID.
/// </summary>
/// <param name="id">The ruleset's internal ID.</param>
/// <returns>A ruleset, if available, else null.</returns>
public IRulesetInfo ? GetRuleset ( int id ) = > AvailableRulesets . FirstOrDefault ( r = > r . OnlineID = = id ) ;
/// <summary>
/// Retrieve a ruleset using a known short name.
/// </summary>
/// <param name="shortName">The ruleset's short name.</param>
/// <returns>A ruleset, if available, else null.</returns>
public IRulesetInfo ? GetRuleset ( string shortName ) = > AvailableRulesets . FirstOrDefault ( r = > r . ShortName = = shortName ) ;
private Assembly ? resolveRulesetDependencyAssembly ( object? sender , ResolveEventArgs args )
{
var asm = new AssemblyName ( args . Name ) ;
// the requesting assembly may be located out of the executable's base directory, thus requiring manual resolving of its dependencies.
// this attempts resolving the ruleset dependencies on game core and framework assemblies by returning assemblies with the same assembly name
// already loaded in the AppDomain.
var domainAssembly = AppDomain . CurrentDomain . GetAssemblies ( )
// Given name is always going to be equally-or-more qualified than the assembly name.
. Where ( a = >
{
string? name = a . GetName ( ) . Name ;
if ( name = = null )
return false ;
return args . Name . Contains ( name , StringComparison . Ordinal ) ;
} )
// Pick the greatest assembly version.
. OrderByDescending ( a = > a . GetName ( ) . Version )
. FirstOrDefault ( ) ;
if ( domainAssembly ! = null )
return domainAssembly ;
return loadedAssemblies . Keys . FirstOrDefault ( a = > a . FullName = = asm . FullName ) ;
}
private void addMissingRulesets ( )
{
realmFactory . Context . Write ( realm = >
{
var rulesets = realm . All < RealmRuleset > ( ) ;
2021-10-14 13:20:34 +08:00
List < Ruleset > instances = loadedAssemblies . Values
2021-10-15 15:14:57 +08:00
. Select ( r = > Activator . CreateInstance ( r ) as Ruleset )
. Where ( r = > r ! = null )
2021-10-15 15:15:34 +08:00
. Select ( r = > r . AsNonNull ( ) )
2021-10-14 13:20:34 +08:00
. ToList ( ) ;
2021-10-11 14:45:06 +08:00
// add all legacy rulesets first to ensure they have exclusive choice of primary key.
foreach ( var r in instances . Where ( r = > r is ILegacyRuleset ) )
{
if ( realm . All < RealmRuleset > ( ) . FirstOrDefault ( rr = > rr . OnlineID = = r . RulesetInfo . ID ) = = null )
realm . Add ( new RealmRuleset ( r . RulesetInfo . ShortName , r . RulesetInfo . Name , r . RulesetInfo . InstantiationInfo , r . RulesetInfo . ID ) ) ;
}
// add any other rulesets which have assemblies present but are not yet in the database.
foreach ( var r in instances . Where ( r = > ! ( r is ILegacyRuleset ) ) )
{
if ( rulesets . FirstOrDefault ( ri = > ri . InstantiationInfo . Equals ( r . RulesetInfo . InstantiationInfo , StringComparison . Ordinal ) ) = = null )
{
var existingSameShortName = rulesets . FirstOrDefault ( ri = > ri . ShortName = = r . RulesetInfo . ShortName ) ;
if ( existingSameShortName ! = null )
{
// even if a matching InstantiationInfo was not found, there may be an existing ruleset with the same ShortName.
// this generally means the user or ruleset provider has renamed their dll but the underlying ruleset is *likely* the same one.
// in such cases, update the instantiation info of the existing entry to point to the new one.
existingSameShortName . InstantiationInfo = r . RulesetInfo . InstantiationInfo ;
}
else
realm . Add ( new RealmRuleset ( r . RulesetInfo . ShortName , r . RulesetInfo . Name , r . RulesetInfo . InstantiationInfo , r . RulesetInfo . ID ) ) ;
}
}
List < RealmRuleset > detachedRulesets = new List < RealmRuleset > ( ) ;
// perform a consistency check and detach final rulesets from realm for cross-thread runtime usage.
foreach ( var r in rulesets )
{
try
{
var type = Type . GetType ( r . InstantiationInfo ) ;
if ( type = = null )
2021-10-14 13:24:36 +08:00
throw new InvalidOperationException ( @"Type resolution failure." ) ;
2021-10-11 14:45:06 +08:00
var rInstance = ( Activator . CreateInstance ( type ) as Ruleset ) ? . RulesetInfo ;
if ( rInstance = = null )
2021-10-14 13:24:36 +08:00
throw new InvalidOperationException ( @"Instantiation failure." ) ;
2021-10-11 14:45:06 +08:00
r . Name = rInstance . Name ;
r . ShortName = rInstance . ShortName ;
r . InstantiationInfo = rInstance . InstantiationInfo ;
r . Available = true ;
detachedRulesets . Add ( r . Clone ( ) ) ;
}
2021-10-14 13:24:36 +08:00
catch ( Exception ex )
2021-10-11 14:45:06 +08:00
{
r . Available = false ;
2021-10-14 13:24:36 +08:00
Logger . Log ( $"Could not load ruleset {r}: {ex.Message}" ) ;
2021-10-11 14:45:06 +08:00
}
}
availableRulesets . AddRange ( detachedRulesets ) ;
} ) ;
}
private void loadFromAppDomain ( )
{
foreach ( var ruleset in AppDomain . CurrentDomain . GetAssemblies ( ) )
{
string? rulesetName = ruleset . GetName ( ) . Name ;
if ( rulesetName = = null )
continue ;
2021-10-14 13:16:39 +08:00
if ( ! rulesetName . StartsWith ( ruleset_library_prefix , StringComparison . InvariantCultureIgnoreCase ) | | rulesetName . Contains ( @"Tests" ) )
2021-10-11 14:45:06 +08:00
continue ;
addRuleset ( ruleset ) ;
}
}
private void loadUserRulesets ( Storage rulesetStorage )
{
2021-10-14 13:16:39 +08:00
var rulesets = rulesetStorage . GetFiles ( @"." , @ $"{ruleset_library_prefix}.*.dll" ) ;
2021-10-11 14:45:06 +08:00
2021-10-27 12:04:41 +08:00
foreach ( string? ruleset in rulesets . Where ( f = > ! f . Contains ( @"Tests" ) ) )
2021-10-11 14:45:06 +08:00
loadRulesetFromFile ( rulesetStorage . GetFullPath ( ruleset ) ) ;
}
private void loadFromDisk ( )
{
try
{
2021-10-27 12:09:30 +08:00
string [ ] files = Directory . GetFiles ( RuntimeInfo . StartupDirectory , @ $"{ruleset_library_prefix}.*.dll" ) ;
2021-10-11 14:45:06 +08:00
foreach ( string file in files . Where ( f = > ! Path . GetFileName ( f ) . Contains ( "Tests" ) ) )
loadRulesetFromFile ( file ) ;
}
catch ( Exception e )
{
Logger . Error ( e , $"Could not load rulesets from directory {RuntimeInfo.StartupDirectory}" ) ;
}
}
private void loadRulesetFromFile ( string file )
{
2021-10-27 12:04:41 +08:00
string? filename = Path . GetFileNameWithoutExtension ( file ) ;
2021-10-11 14:45:06 +08:00
if ( loadedAssemblies . Values . Any ( t = > Path . GetFileNameWithoutExtension ( t . Assembly . Location ) = = filename ) )
return ;
try
{
addRuleset ( Assembly . LoadFrom ( file ) ) ;
}
catch ( Exception e )
{
Logger . Error ( e , $"Failed to load ruleset {filename}" ) ;
}
}
private void addRuleset ( Assembly assembly )
{
if ( loadedAssemblies . ContainsKey ( assembly ) )
return ;
// the same assembly may be loaded twice in the same AppDomain (currently a thing in certain Rider versions https://youtrack.jetbrains.com/issue/RIDER-48799).
// as a failsafe, also compare by FullName.
if ( loadedAssemblies . Any ( a = > a . Key . FullName = = assembly . FullName ) )
return ;
try
{
loadedAssemblies [ assembly ] = assembly . GetTypes ( ) . First ( t = > t . IsPublic & & t . IsSubclassOf ( typeof ( Ruleset ) ) ) ;
}
catch ( Exception e )
{
Logger . Error ( e , $"Failed to add ruleset {assembly}" ) ;
}
}
public void Dispose ( )
{
Dispose ( true ) ;
GC . SuppressFinalize ( this ) ;
}
protected virtual void Dispose ( bool disposing )
{
AppDomain . CurrentDomain . AssemblyResolve - = resolveRulesetDependencyAssembly ;
}
}
}