UI/Library/LibretroDb.cs
using Emulotl.Gba;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Emulotl;
public sealed record GameInfo(
string Name,
string Region,
string Serial,
string Developer,
string Publisher,
string ReleaseYear,
string ReleaseMonth,
string Genre,
string Franchise,
string EsrbRating,
string MaxUsers
);
internal sealed class LibretroDb
{
private const string BaseUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/";
private const string CacheRoot = "database";
private const string CacheDirectory = "database/metadat";
private static readonly Dictionary<string, Task<FieldIndex>> FieldTasks = [];
private static readonly Dictionary<string, Task<GameInfo>> LookupTasks = [];
private static readonly Dictionary<string, Task<NoIntroIndex>> NoIntroTasks = [];
public static async Task WarmAsync()
{
EmulatorSystems.EnsureRegistered();
var tasks = new List<Task>();
foreach ( SystemProfile profile in SystemRegistry.All )
{
string platform = profile.LibretroPlatform;
if ( string.IsNullOrWhiteSpace( platform ) )
continue;
tasks.Add( GetNoIntroAsync( platform ) );
tasks.Add( GetFieldIndexAsync( platform, "developer", "developer", true ) );
tasks.Add( GetFieldIndexAsync( platform, "publisher", "publisher", true ) );
tasks.Add( GetFieldIndexAsync( platform, "releaseyear", "releaseyear", true ) );
tasks.Add( GetFieldIndexAsync( platform, "releasemonth", "releasemonth", true ) );
tasks.Add( GetFieldIndexAsync( platform, "genre", "genre", true ) );
tasks.Add( GetFieldIndexAsync( platform, "franchise", "franchise", true ) );
tasks.Add( GetFieldIndexAsync( platform, "esrb", "esrb_rating", true ) );
tasks.Add( GetFieldIndexAsync( platform, "maxusers", "users", false ) );
}
foreach ( var task in tasks )
await task;
}
private static string PlatformOf( GameEntry game )
=> SystemRegistry.ById( game?.SystemId )?.LibretroPlatform ?? GbaSystem.Profile.LibretroPlatform;
public static Task<GameInfo> FetchAsync( GameEntry game, GameHashes hashes = null )
{
if ( game is null )
return Task.FromResult<GameInfo>( null );
if ( LookupTasks.TryGetValue( game.Path, out var task ) )
return task;
task = FetchInternalAsync( game, hashes );
LookupTasks[game.Path] = task;
return task;
}
private static async Task<GameInfo> FetchInternalAsync( GameEntry game, GameHashes hashes )
{
string platform = PlatformOf( game );
var crc = NormalizeCrc( hashes?.Crc );
var noIntro = await GetNoIntroAsync( platform );
var noIntroEntry = FindNoIntroEntry( noIntro, game, crc );
var developerTask = GetValueAsync( platform, "developer", "developer", true, game, noIntroEntry, crc );
var publisherTask = GetValueAsync( platform, "publisher", "publisher", true, game, noIntroEntry, crc );
var releaseYearTask = GetValueAsync( platform, "releaseyear", "releaseyear", true, game, noIntroEntry, crc );
var releaseMonthTask = GetValueAsync( platform, "releasemonth", "releasemonth", true, game, noIntroEntry, crc );
var genreTask = GetValueAsync( platform, "genre", "genre", true, game, noIntroEntry, crc );
var franchiseTask = GetValueAsync( platform, "franchise", "franchise", true, game, noIntroEntry, crc );
var esrbTask = GetValueAsync( platform, "esrb", "esrb_rating", true, game, noIntroEntry, crc );
var maxUsersTask = GetValueAsync( platform, "maxusers", "users", false, game, noIntroEntry, crc );
await developerTask;
await publisherTask;
await releaseYearTask;
await releaseMonthTask;
await genreTask;
await franchiseTask;
await esrbTask;
await maxUsersTask;
return new GameInfo(
Display( noIntroEntry?.Name, game.NoIntroName, game.DisplayTitle ),
Display( noIntroEntry?.Region, game.Region ),
Display( noIntroEntry?.Serial, game.GameCode ),
developerTask.Result,
publisherTask.Result,
releaseYearTask.Result,
releaseMonthTask.Result,
genreTask.Result,
franchiseTask.Result,
esrbTask.Result,
maxUsersTask.Result
);
}
private static async Task<string> GetValueAsync( string platform, string folder, string propertyName, bool quoted, GameEntry game, NoIntroEntry noIntroEntry, string crc )
{
var index = await GetFieldIndexAsync( platform, folder, propertyName, quoted );
if ( !string.IsNullOrWhiteSpace( crc ) && index.ByCrc.TryGetValue( crc, out var value ) )
return value;
foreach ( var title in CandidateTitles( game, noIntroEntry ) )
{
var key = NormalizeTitle( title );
if ( !string.IsNullOrWhiteSpace( key ) && index.ByTitle.TryGetValue( key, out value ) )
return value;
}
return string.Empty;
}
private static Task<FieldIndex> GetFieldIndexAsync( string platform, string folder, string propertyName, bool quoted )
{
var key = $"{platform}:{folder}:{propertyName}";
if ( FieldTasks.TryGetValue( key, out var task ) )
return task;
task = LoadFieldIndexAsync( platform, folder, propertyName, quoted );
FieldTasks[key] = task;
return task;
}
private static async Task<FieldIndex> LoadFieldIndexAsync( string platform, string folder, string propertyName, bool quoted )
{
try
{
var text = await LoadDatAsync( folder, platform );
return ParseFieldIndex( text, propertyName, quoted );
}
catch ( Exception ex )
{
Log.Warning( $"[Emulotl] Libretro metadata {folder} failed: {ex.Message}" );
return new FieldIndex();
}
}
private static Task<NoIntroIndex> GetNoIntroAsync( string platform )
{
if ( NoIntroTasks.TryGetValue( platform, out var task ) )
return task;
task = LoadNoIntroAsync( platform );
NoIntroTasks[platform] = task;
return task;
}
private static async Task<NoIntroIndex> LoadNoIntroAsync( string platform )
{
try
{
var text = await LoadDatAsync( "no-intro", platform );
return ParseNoIntroIndex( text );
}
catch ( Exception ex )
{
Log.Warning( $"[Emulotl] Libretro no-intro metadata failed: {ex.Message}" );
return new NoIntroIndex();
}
}
private static async Task<string> LoadDatAsync( string folder, string platform )
{
var cacheFileName = platform + ".dat";
var urlFileName = Uri.EscapeDataString( platform ) + ".dat";
var cachePath = $"{CacheDirectory}/{folder}/{cacheFileName}";
try
{
if ( FileSystem.Data.FileExists( cachePath ) )
return FileSystem.Data.ReadAllText( cachePath );
}
catch { }
var text = await Http.RequestStringAsync( BaseUrl + folder + "/" + urlFileName );
try
{
if ( !FileSystem.Data.DirectoryExists( CacheRoot ) )
FileSystem.Data.CreateDirectory( CacheRoot );
if ( !FileSystem.Data.DirectoryExists( CacheDirectory ) )
FileSystem.Data.CreateDirectory( CacheDirectory );
var folderPath = $"{CacheDirectory}/{folder}";
if ( !FileSystem.Data.DirectoryExists( folderPath ) )
FileSystem.Data.CreateDirectory( folderPath );
FileSystem.Data.WriteAllText( cachePath, text );
}
catch ( Exception ex )
{
Log.Warning( $"[Emulotl] Failed to cache Libretro metadata {folder}: {ex.Message}" );
}
return text;
}
private static FieldIndex ParseFieldIndex( string text, string propertyName, bool quoted )
{
var index = new FieldIndex();
foreach ( var block in EnumerateGameBlocks( text ) )
{
var value = quoted ? ExtractQuotedValue( block, propertyName ) : ExtractBareValue( block, propertyName );
if ( string.IsNullOrWhiteSpace( value ) )
continue;
var crc = ExtractCrc( block );
if ( !string.IsNullOrWhiteSpace( crc ) )
index.ByCrc[crc] = value;
var title = Display( ExtractQuotedValue( block, "comment" ), ExtractQuotedValue( block, "name" ) );
var titleKey = NormalizeTitle( title );
if ( !string.IsNullOrWhiteSpace( titleKey ) && !index.ByTitle.ContainsKey( titleKey ) )
index.ByTitle[titleKey] = value;
}
return index;
}
private static NoIntroIndex ParseNoIntroIndex( string text )
{
var index = new NoIntroIndex();
foreach ( var block in EnumerateGameBlocks( text ) )
{
var name = ExtractQuotedValue( block, "name" );
if ( string.IsNullOrWhiteSpace( name ) )
continue;
var entry = new NoIntroEntry(
name,
ExtractQuotedValue( block, "region" ),
ExtractQuotedValue( block, "serial" )
);
var crc = ExtractCrc( block );
if ( !string.IsNullOrWhiteSpace( crc ) )
index.ByCrc[crc] = entry;
var titleKey = NormalizeTitle( name );
if ( !string.IsNullOrWhiteSpace( titleKey ) && !index.ByTitle.ContainsKey( titleKey ) )
index.ByTitle[titleKey] = entry;
}
return index;
}
private static NoIntroEntry FindNoIntroEntry( NoIntroIndex index, GameEntry game, string crc )
{
if ( !string.IsNullOrWhiteSpace( crc ) && index.ByCrc.TryGetValue( crc, out var entry ) )
return entry;
foreach ( var title in CandidateTitles( game, null ) )
{
var key = NormalizeTitle( title );
if ( !string.IsNullOrWhiteSpace( key ) && index.ByTitle.TryGetValue( key, out entry ) )
return entry;
}
return null;
}
private static IEnumerable<string> EnumerateGameBlocks( string text )
{
if ( string.IsNullOrWhiteSpace( text ) )
yield break;
var cursor = 0;
while ( cursor < text.Length )
{
var gameStart = text.IndexOf( "game", cursor, StringComparison.Ordinal );
if ( gameStart < 0 )
yield break;
var openParen = text.IndexOf( '(', gameStart );
if ( openParen < 0 )
yield break;
var depth = 0;
var inQuote = false;
for ( var scan = openParen; scan < text.Length; scan++ )
{
if ( text[scan] == '"' )
{
inQuote = !inQuote;
continue;
}
if ( inQuote )
continue;
if ( text[scan] == '(' ) depth++;
else if ( text[scan] == ')' ) depth--;
if ( depth == 0 )
{
yield return text.Substring( openParen + 1, scan - openParen - 1 );
cursor = scan + 1;
break;
}
}
if ( cursor <= openParen )
yield break;
}
}
private static string ExtractQuotedValue( string block, string propertyName )
{
var match = Regex.Match( block, $"\\b{Regex.Escape( propertyName )}\\s+\"([^\"]*)\"", RegexOptions.IgnoreCase );
return match.Success ? match.Groups[1].Value.Trim() : string.Empty;
}
private static string ExtractBareValue( string block, string propertyName )
{
var match = Regex.Match( block, $"\\b{Regex.Escape( propertyName )}\\s+([^\\s\\)]+)", RegexOptions.IgnoreCase );
return match.Success ? match.Groups[1].Value.Trim() : string.Empty;
}
private static string ExtractCrc( string block )
{
var match = Regex.Match( block, "\\bcrc\\s+([0-9A-Fa-f]{8})", RegexOptions.IgnoreCase );
return match.Success ? NormalizeCrc( match.Groups[1].Value ) : string.Empty;
}
private static IEnumerable<string> CandidateTitles( GameEntry game, NoIntroEntry noIntroEntry )
{
if ( !string.IsNullOrWhiteSpace( noIntroEntry?.Name ) ) yield return noIntroEntry.Name;
if ( !string.IsNullOrWhiteSpace( game?.NoIntroName ) ) yield return game.NoIntroName;
if ( !string.IsNullOrWhiteSpace( game?.DisplayTitle ) ) yield return game.DisplayTitle;
}
private static string NormalizeTitle( string value )
{
if ( string.IsNullOrWhiteSpace( value ) )
return string.Empty;
return Regex.Replace( value.Trim().ToLowerInvariant(), "\\s+", " " );
}
private static string NormalizeCrc( string value )
{
return string.IsNullOrWhiteSpace( value ) ? string.Empty : value.Trim().ToUpperInvariant();
}
private static string Display( params string[] values )
{
foreach ( var value in values )
{
if ( !string.IsNullOrWhiteSpace( value ) )
return value;
}
return string.Empty;
}
private sealed class FieldIndex
{
public Dictionary<string, string> ByCrc { get; } = new( StringComparer.OrdinalIgnoreCase );
public Dictionary<string, string> ByTitle { get; } = new( StringComparer.OrdinalIgnoreCase );
}
private sealed class NoIntroIndex
{
public Dictionary<string, NoIntroEntry> ByCrc { get; } = new( StringComparer.OrdinalIgnoreCase );
public Dictionary<string, NoIntroEntry> ByTitle { get; } = new( StringComparer.OrdinalIgnoreCase );
}
private sealed record NoIntroEntry( string Name, string Region, string Serial );
}