2019-01-24 08:43:03 +00: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.
2018-04-13 09:19:50 +00:00
using System ;
using System.Collections.Generic ;
2019-06-30 14:52:39 +00:00
using System.IO ;
2018-04-13 09:19:50 +00:00
using System.Linq ;
using System.Linq.Expressions ;
2021-05-10 13:43:48 +00:00
using System.Text ;
2019-05-28 09:59:21 +00:00
using System.Threading ;
using System.Threading.Tasks ;
2018-04-13 09:19:50 +00:00
using Microsoft.EntityFrameworkCore ;
2021-05-10 13:43:48 +00:00
using Newtonsoft.Json ;
2018-04-13 09:19:50 +00:00
using osu.Framework.Audio ;
using osu.Framework.Audio.Sample ;
2019-02-21 10:04:31 +00:00
using osu.Framework.Bindables ;
2018-04-13 09:19:50 +00:00
using osu.Framework.Graphics ;
2020-07-17 07:54:30 +00:00
using osu.Framework.Graphics.OpenGL.Textures ;
2018-04-13 09:19:50 +00:00
using osu.Framework.Graphics.Textures ;
2019-08-29 07:38:39 +00:00
using osu.Framework.IO.Stores ;
2021-10-20 08:33:36 +00:00
using osu.Framework.Logging ;
2018-04-13 09:19:50 +00:00
using osu.Framework.Platform ;
2020-10-16 05:39:02 +00:00
using osu.Framework.Testing ;
2020-11-11 04:05:03 +00:00
using osu.Framework.Utils ;
2019-08-23 11:32:43 +00:00
using osu.Game.Audio ;
2018-04-13 09:19:50 +00:00
using osu.Game.Database ;
2021-05-12 20:42:26 +00:00
using osu.Game.Extensions ;
2020-12-21 06:14:32 +00:00
using osu.Game.IO ;
2018-04-13 09:19:50 +00:00
using osu.Game.IO.Archives ;
namespace osu.Game.Skinning
{
2021-06-22 00:06:39 +00:00
/// <summary>
/// Handles the storage and retrieval of <see cref="Skin"/>s.
/// </summary>
/// <remarks>
2021-06-22 09:05:17 +00:00
/// This is also exposed and cached as <see cref="ISkinSource"/> to allow for any component to potentially have skinning support.
/// For gameplay components, see <see cref="RulesetSkinProvidingContainer"/> which adds extra legacy and toggle logic that may affect the lookup process.
2021-06-22 00:06:39 +00:00
/// </remarks>
2020-10-16 05:39:02 +00:00
[ExcludeFromDynamicCompile]
2020-12-21 06:14:32 +00:00
public class SkinManager : ArchiveModelManager < SkinInfo , SkinFileInfo > , ISkinSource , IStorageResourceProvider
2018-04-13 09:19:50 +00:00
{
private readonly AudioManager audio ;
2020-12-21 06:14:32 +00:00
private readonly GameHost host ;
2021-05-31 09:37:32 +00:00
private readonly IResourceStore < byte [ ] > resources ;
2019-08-29 07:38:39 +00:00
2021-05-27 02:48:48 +00:00
public readonly Bindable < Skin > CurrentSkin = new Bindable < Skin > ( ) ;
2018-04-13 09:19:50 +00:00
public readonly Bindable < SkinInfo > CurrentSkinInfo = new Bindable < SkinInfo > ( SkinInfo . Default ) { Default = SkinInfo . Default } ;
2020-10-02 07:17:10 +00:00
public override IEnumerable < string > HandledExtensions = > new [ ] { ".osk" } ;
2018-04-13 09:19:50 +00:00
2021-10-20 05:05:32 +00:00
protected override string [ ] HashableFileTypes = > new [ ] { ".ini" , ".json" } ;
2018-11-28 10:16:05 +00:00
2018-08-31 09:28:53 +00:00
protected override string ImportFromStablePath = > "Skins" ;
2021-06-10 10:04:34 +00:00
/// <summary>
2021-06-11 05:50:21 +00:00
/// The default skin.
2021-06-10 10:04:34 +00:00
/// </summary>
2021-06-11 05:49:35 +00:00
public Skin DefaultSkin { get ; }
2021-06-07 15:42:50 +00:00
2021-06-10 10:04:34 +00:00
/// <summary>
2021-06-11 05:50:21 +00:00
/// The default legacy skin.
2021-06-10 10:04:34 +00:00
/// </summary>
2021-06-11 05:49:35 +00:00
public Skin DefaultLegacySkin { get ; }
2021-06-09 09:51:34 +00:00
2021-05-31 09:37:32 +00:00
public SkinManager ( Storage storage , DatabaseContextFactory contextFactory , GameHost host , IResourceStore < byte [ ] > resources , AudioManager audio )
2020-12-21 06:14:32 +00:00
: base ( storage , contextFactory , new SkinStore ( contextFactory , storage ) , host )
2018-11-28 10:01:22 +00:00
{
this . audio = audio ;
2020-12-21 06:14:32 +00:00
this . host = host ;
2021-05-31 09:37:32 +00:00
this . resources = resources ;
2018-11-28 10:01:22 +00:00
2021-06-11 05:49:35 +00:00
DefaultLegacySkin = new DefaultLegacySkin ( this ) ;
DefaultSkin = new DefaultSkin ( this ) ;
2021-06-07 15:42:50 +00:00
2019-07-26 10:29:06 +00:00
CurrentSkinInfo . ValueChanged + = skin = > CurrentSkin . Value = GetSkin ( skin . NewValue ) ;
2021-05-27 05:50:42 +00:00
2021-06-11 05:49:35 +00:00
CurrentSkin . Value = DefaultSkin ;
2018-11-28 10:01:22 +00:00
CurrentSkin . ValueChanged + = skin = >
{
2019-02-22 08:51:39 +00:00
if ( skin . NewValue . SkinInfo ! = CurrentSkinInfo . Value )
2018-11-28 10:01:22 +00:00
throw new InvalidOperationException ( $"Setting {nameof(CurrentSkin)}'s value directly is not supported. Use {nameof(CurrentSkinInfo)} instead." ) ;
SourceChanged ? . Invoke ( ) ;
} ;
2021-10-20 08:33:36 +00:00
// can be removed 20220420.
populateMissingHashes ( ) ;
}
private void populateMissingHashes ( )
{
var skinsWithoutHashes = ModelStore . ConsumableItems . Where ( i = > i . Hash = = null ) . ToArray ( ) ;
foreach ( SkinInfo skin in skinsWithoutHashes )
{
try
{
Update ( skin ) ;
}
catch ( Exception e )
{
Delete ( skin ) ;
Logger . Error ( e , $"Existing skin {skin} has been deleted during hash recomputation due to being invalid" ) ;
}
}
2018-11-28 10:01:22 +00:00
}
2021-11-01 05:09:17 +00:00
protected override bool ShouldDeleteArchive ( string path ) = > Path . GetExtension ( path ) ? . ToLowerInvariant ( ) = = @".osk" ;
2019-06-27 12:41:11 +00:00
2018-04-13 09:19:50 +00:00
/// <summary>
2018-08-31 09:28:53 +00:00
/// Returns a list of all usable <see cref="SkinInfo"/>s. Includes the special default skin plus all skins from <see cref="GetAllUserSkins"/>.
2018-04-13 09:19:50 +00:00
/// </summary>
2018-11-28 11:36:21 +00:00
/// <returns>A newly allocated list of available <see cref="SkinInfo"/>.</returns>
2018-04-13 09:19:50 +00:00
public List < SkinInfo > GetAllUsableSkins ( )
{
2021-08-17 21:00:10 +00:00
var userSkins = GetAllUserSkins ( ) ;
2021-06-11 05:49:35 +00:00
userSkins . Insert ( 0 , DefaultSkin . SkinInfo ) ;
userSkins . Insert ( 1 , DefaultLegacySkin . SkinInfo ) ;
2018-04-13 09:19:50 +00:00
return userSkins ;
}
2018-08-31 09:28:53 +00:00
/// <summary>
/// Returns a list of all usable <see cref="SkinInfo"/>s that have been loaded by the user.
/// </summary>
2018-11-28 11:36:21 +00:00
/// <returns>A newly allocated list of available <see cref="SkinInfo"/>.</returns>
2021-08-17 10:21:22 +00:00
public List < SkinInfo > GetAllUserSkins ( bool includeFiles = false )
{
if ( includeFiles )
return ModelStore . ConsumableItems . Where ( s = > ! s . DeletePending ) . ToList ( ) ;
return ModelStore . Items . Where ( s = > ! s . DeletePending ) . ToList ( ) ;
}
2018-08-31 09:28:53 +00:00
2020-11-11 04:05:03 +00:00
public void SelectRandomSkin ( )
{
// choose from only user skins, removing the current selection to ensure a new one is chosen.
2021-08-16 15:23:30 +00:00
var randomChoices = ModelStore . Items . Where ( s = > ! s . DeletePending & & s . ID ! = CurrentSkinInfo . Value . ID ) . ToArray ( ) ;
2020-11-11 04:05:03 +00:00
if ( randomChoices . Length = = 0 )
{
CurrentSkinInfo . Value = SkinInfo . Default ;
return ;
}
2021-08-16 15:23:30 +00:00
var chosen = randomChoices . ElementAt ( RNG . Next ( 0 , randomChoices . Length ) ) ;
CurrentSkinInfo . Value = ModelStore . ConsumableItems . Single ( i = > i . ID = = chosen . ID ) ;
2020-11-11 04:05:03 +00:00
}
2021-11-01 05:09:17 +00:00
protected override SkinInfo CreateModel ( ArchiveReader archive ) = > new SkinInfo { Name = archive . Name ? ? @"No name" } ;
2018-04-13 09:19:50 +00:00
2021-11-01 05:09:17 +00:00
private const string unknown_creator_string = @"Unknown" ;
2020-09-11 07:29:14 +00:00
2021-06-27 07:35:13 +00:00
protected override bool HasCustomHashFunction = > true ;
2021-10-24 14:48:46 +00:00
protected override string ComputeHash ( SkinInfo item )
2020-09-11 06:06:10 +00:00
{
2021-08-23 11:24:00 +00:00
var instance = GetSkin ( item ) ;
2020-09-14 14:31:03 +00:00
2021-10-20 06:22:47 +00:00
// This function can be run on fresh import or save. The logic here ensures a skin.ini file is in a good state for both operations.
2021-10-24 10:47:17 +00:00
// `Skin` will parse the skin.ini and populate `Skin.Configuration` during construction above.
2021-10-20 07:49:58 +00:00
string skinIniSourcedName = instance . Configuration . SkinInfo . Name ;
2021-10-21 04:36:04 +00:00
string skinIniSourcedCreator = instance . Configuration . SkinInfo . Creator ;
2021-11-01 05:09:17 +00:00
string archiveName = item . Name . Replace ( @".osk" , string . Empty , StringComparison . OrdinalIgnoreCase ) ;
2020-09-11 06:06:10 +00:00
2021-10-22 05:08:12 +00:00
bool isImport = item . ID = = 0 ;
2021-05-11 08:00:24 +00:00
2021-10-20 06:22:47 +00:00
if ( isImport )
{
2021-10-20 07:49:58 +00:00
item . Name = ! string . IsNullOrEmpty ( skinIniSourcedName ) ? skinIniSourcedName : archiveName ;
2021-10-21 04:36:04 +00:00
item . Creator = ! string . IsNullOrEmpty ( skinIniSourcedCreator ) ? skinIniSourcedCreator : unknown_creator_string ;
2021-10-20 07:49:58 +00:00
2021-10-20 06:22:47 +00:00
// For imports, we want to use the archive or folder name as part of the metadata, in addition to any existing skin.ini metadata.
// In an ideal world, skin.ini would be the only source of metadata, but a lot of skin creators and users don't update it when making modifications.
// In both of these cases, the expectation from the user is that the filename or folder name is displayed somewhere to identify the skin.
if ( archiveName ! = item . Name )
2021-11-01 05:09:17 +00:00
item . Name = @ $"{item.Name} [{archiveName}]" ;
2021-10-20 06:22:47 +00:00
}
// By this point, the metadata in SkinInfo will be correct.
// Regardless of whether this is an import or not, let's write the skin.ini if non-existing or non-matching.
// This is (weirdly) done inside ComputeHash to avoid adding a new method to handle this case. After switching to realm it can be moved into another place.
2021-10-22 05:41:59 +00:00
if ( skinIniSourcedName ! = item . Name )
2021-10-20 07:49:58 +00:00
updateSkinIniMetadata ( item ) ;
2021-10-20 06:22:47 +00:00
2021-10-24 14:48:46 +00:00
return base . ComputeHash ( item ) ;
2020-09-14 14:31:03 +00:00
}
2021-10-20 07:49:58 +00:00
private void updateSkinIniMetadata ( SkinInfo item )
2020-09-14 14:31:03 +00:00
{
2021-11-01 05:09:17 +00:00
string nameLine = @ $"Name: {item.Name}" ;
string authorLine = @ $"Author: {item.Creator}" ;
2021-10-20 06:22:47 +00:00
2021-11-01 04:55:34 +00:00
var existingFile = item . Files . SingleOrDefault ( f = > f . Filename . Equals ( @"skin.ini" , StringComparison . OrdinalIgnoreCase ) ) ;
2021-10-20 07:49:58 +00:00
if ( existingFile ! = null )
2018-04-13 09:19:50 +00:00
{
2021-10-20 06:22:47 +00:00
List < string > outputLines = new List < string > ( ) ;
bool addedName = false ;
bool addedAuthor = false ;
using ( var stream = Files . Storage . GetStream ( existingFile . FileInfo . StoragePath ) )
using ( var sr = new StreamReader ( stream ) )
{
string line ;
while ( ( line = sr . ReadLine ( ) ) ! = null )
{
2021-11-01 05:09:17 +00:00
if ( line . StartsWith ( @"Name:" , StringComparison . Ordinal ) )
2021-10-20 06:22:47 +00:00
{
outputLines . Add ( nameLine ) ;
addedName = true ;
}
2021-11-01 05:09:17 +00:00
else if ( line . StartsWith ( @"Author:" , StringComparison . Ordinal ) )
2021-10-20 06:22:47 +00:00
{
outputLines . Add ( authorLine ) ;
addedAuthor = true ;
}
else
outputLines . Add ( line ) ;
}
}
if ( ! addedName | | ! addedAuthor )
{
outputLines . AddRange ( new [ ]
{
2021-11-01 05:09:17 +00:00
@"[General]" ,
2021-10-20 06:22:47 +00:00
nameLine ,
authorLine ,
} ) ;
}
using ( Stream stream = new MemoryStream ( ) )
{
2021-10-20 07:49:58 +00:00
using ( var sw = new StreamWriter ( stream , Encoding . UTF8 , 1024 , true ) )
{
foreach ( string line in outputLines )
sw . WriteLine ( line ) ;
}
2021-10-20 06:22:47 +00:00
ReplaceFile ( item , existingFile , stream ) ;
}
2018-04-13 09:19:50 +00:00
}
else
{
2021-10-20 06:22:47 +00:00
using ( Stream stream = new MemoryStream ( ) )
{
2021-10-20 07:49:58 +00:00
using ( var sw = new StreamWriter ( stream , Encoding . UTF8 , 1024 , true ) )
{
2021-11-01 05:09:17 +00:00
sw . WriteLine ( @"[General]" ) ;
2021-10-20 07:49:58 +00:00
sw . WriteLine ( nameLine ) ;
sw . WriteLine ( authorLine ) ;
2021-11-01 05:09:17 +00:00
sw . WriteLine ( @"Version: latest" ) ;
2021-10-20 07:49:58 +00:00
}
2021-10-20 06:22:47 +00:00
2021-11-01 05:09:17 +00:00
AddFile ( item , stream , @"skin.ini" ) ;
2021-10-20 06:22:47 +00:00
}
2018-04-13 09:19:50 +00:00
}
2021-10-20 06:22:47 +00:00
}
2021-08-23 11:24:00 +00:00
2021-10-20 06:22:47 +00:00
protected override Task Populate ( SkinInfo model , ArchiveReader archive , CancellationToken cancellationToken = default )
{
var instance = GetSkin ( model ) ;
2021-08-23 11:24:00 +00:00
2021-10-20 06:22:47 +00:00
model . InstantiationInfo ? ? = instance . GetType ( ) . GetInvariantInstantiationInfo ( ) ;
model . Name = instance . Configuration . SkinInfo . Name ;
model . Creator = instance . Configuration . SkinInfo . Creator ;
return Task . CompletedTask ;
2018-04-13 09:19:50 +00:00
}
/// <summary>
/// Retrieve a <see cref="Skin"/> instance for the provided <see cref="SkinInfo"/>
/// </summary>
/// <param name="skinInfo">The skin to lookup.</param>
/// <returns>A <see cref="Skin"/> instance correlating to the provided <see cref="SkinInfo"/>.</returns>
2021-05-31 09:58:40 +00:00
public Skin GetSkin ( SkinInfo skinInfo ) = > skinInfo . CreateInstance ( this ) ;
2021-05-11 08:00:56 +00:00
/// <summary>
/// Ensure that the current skin is in a state it can accept user modifications.
/// This will create a copy of any internal skin and being tracking in the database if not already.
/// </summary>
public void EnsureMutableSkin ( )
2018-04-13 09:19:50 +00:00
{
2021-05-11 08:00:56 +00:00
if ( CurrentSkinInfo . Value . ID > = 1 ) return ;
2018-04-13 09:19:50 +00:00
2021-05-11 08:00:56 +00:00
var skin = CurrentSkin . Value ;
2019-08-29 07:38:39 +00:00
2021-05-11 08:00:56 +00:00
// if the user is attempting to save one of the default skin implementations, create a copy first.
CurrentSkinInfo . Value = Import ( new SkinInfo
{
2021-11-01 05:09:17 +00:00
Name = skin . SkinInfo . Name + @" (modified)" ,
2021-05-11 08:00:56 +00:00
Creator = skin . SkinInfo . Creator ,
InstantiationInfo = skin . SkinInfo . InstantiationInfo ,
2021-09-30 10:33:12 +00:00
} ) . Result . Value ;
2018-04-13 09:19:50 +00:00
}
2021-05-10 13:43:48 +00:00
public void Save ( Skin skin )
{
2021-05-11 08:00:56 +00:00
if ( skin . SkinInfo . ID < = 0 )
throw new InvalidOperationException ( $"Attempting to save a skin which is not yet tracked. Call {nameof(EnsureMutableSkin)} first." ) ;
2021-05-10 13:43:48 +00:00
foreach ( var drawableInfo in skin . DrawableComponentInfo )
{
string json = JsonConvert . SerializeObject ( drawableInfo . Value , new JsonSerializerSettings { Formatting = Formatting . Indented } ) ;
using ( var streamContent = new MemoryStream ( Encoding . UTF8 . GetBytes ( json ) ) )
{
2021-11-01 05:09:17 +00:00
string filename = @ $"{drawableInfo.Key}.json" ;
2021-05-10 13:43:48 +00:00
var oldFile = skin . SkinInfo . Files . FirstOrDefault ( f = > f . Filename = = filename ) ;
if ( oldFile ! = null )
ReplaceFile ( skin . SkinInfo , oldFile , streamContent , oldFile . Filename ) ;
else
AddFile ( skin . SkinInfo , streamContent , filename ) ;
}
}
}
2018-04-13 09:19:50 +00:00
/// <summary>
/// Perform a lookup query on available <see cref="SkinInfo"/>s.
/// </summary>
/// <param name="query">The query.</param>
/// <returns>The first result for the provided query, or null if no results were found.</returns>
public SkinInfo Query ( Expression < Func < SkinInfo , bool > > query ) = > ModelStore . ConsumableItems . AsNoTracking ( ) . FirstOrDefault ( query ) ;
public event Action SourceChanged ;
2021-06-06 03:17:55 +00:00
public Drawable GetDrawableComponent ( ISkinComponent component ) = > lookupWithFallback ( s = > s . GetDrawableComponent ( component ) ) ;
2018-04-13 09:19:50 +00:00
2021-06-06 03:17:55 +00:00
public Texture GetTexture ( string componentName , WrapMode wrapModeS , WrapMode wrapModeT ) = > lookupWithFallback ( s = > s . GetTexture ( componentName , wrapModeS , wrapModeT ) ) ;
2018-04-13 09:19:50 +00:00
2021-06-06 03:17:55 +00:00
public ISample GetSample ( ISampleInfo sampleInfo ) = > lookupWithFallback ( s = > s . GetSample ( sampleInfo ) ) ;
2018-04-13 09:19:50 +00:00
2021-06-06 03:17:55 +00:00
public IBindable < TValue > GetConfig < TLookup , TValue > ( TLookup lookup ) = > lookupWithFallback ( s = > s . GetConfig < TLookup , TValue > ( lookup ) ) ;
2020-12-21 06:14:32 +00:00
2021-06-07 15:42:50 +00:00
public ISkin FindProvider ( Func < ISkin , bool > lookupFunction )
{
2021-06-22 07:49:21 +00:00
foreach ( var source in AllSources )
{
if ( lookupFunction ( source ) )
return source ;
}
2021-06-09 09:51:34 +00:00
2021-06-07 15:42:50 +00:00
return null ;
}
2021-06-06 03:17:55 +00:00
2021-06-22 07:19:55 +00:00
public IEnumerable < ISkin > AllSources
{
get
{
yield return CurrentSkin . Value ;
2021-06-22 07:49:37 +00:00
if ( CurrentSkin . Value is LegacySkin & & CurrentSkin . Value ! = DefaultLegacySkin )
2021-06-22 07:19:55 +00:00
yield return DefaultLegacySkin ;
2021-06-22 07:49:37 +00:00
if ( CurrentSkin . Value ! = DefaultSkin )
yield return DefaultSkin ;
2021-06-22 07:19:55 +00:00
}
}
2021-06-09 09:51:34 +00:00
private T lookupWithFallback < T > ( Func < ISkin , T > lookupFunction )
2021-06-06 03:17:55 +00:00
where T : class
{
2021-06-22 07:19:55 +00:00
foreach ( var source in AllSources )
{
if ( lookupFunction ( source ) is T skinSourced )
return skinSourced ;
}
2021-06-06 03:17:55 +00:00
2021-06-22 07:19:55 +00:00
return null ;
2021-06-06 03:17:55 +00:00
}
2021-05-31 08:25:21 +00:00
2020-12-22 03:01:09 +00:00
#region IResourceStorageProvider
2020-12-21 06:14:32 +00:00
AudioManager IStorageResourceProvider . AudioManager = > audio ;
2021-05-31 09:37:32 +00:00
IResourceStore < byte [ ] > IStorageResourceProvider . Resources = > resources ;
2020-12-21 06:14:32 +00:00
IResourceStore < byte [ ] > IStorageResourceProvider . Files = > Files . Store ;
IResourceStore < TextureUpload > IStorageResourceProvider . CreateTextureLoaderStore ( IResourceStore < byte [ ] > underlyingStore ) = > host . CreateTextureLoaderStore ( underlyingStore ) ;
2020-12-22 03:01:09 +00:00
#endregion
2018-04-13 09:19:50 +00:00
}
}