diff --git a/Tests/Player/ColorizationCatalogTests.cs b/Tests/Player/ColorizationCatalogTests.cs index 55c5a49..eef0eeb 100644 --- a/Tests/Player/ColorizationCatalogTests.cs +++ b/Tests/Player/ColorizationCatalogTests.cs @@ -43,17 +43,26 @@ public void AmbiguousAutomaticAndMissingManualChoicesNeverFallBack() [Test] public void AmbiguousAutomaticPrefersTheFilesDmdDeviceLoads() { - Asset("game/pin2dmd.pal"); Asset("game/pin2dmd.vni"); Asset("game/old/pin2dmd.pal"); Asset("game/old/pin2dmd.vni"); Asset("game/update.pac"); Asset("game/pin2dmd - Copy.pal"); Asset("game/old/._pin2dmd.pal"); + Asset("game/pin2dmd.pal"); Asset("game/pin2dmd.vni"); Asset("game/old/pin2dmd.pal"); Asset("game/old/pin2dmd.vni"); Asset("game/pin2dmd - Copy.pal"); Asset("game/old/._pin2dmd.pal"); Assert.That(ColorizationCatalog.Select(_root, "game", "auto").Id, Is.EqualTo("game/pin2dmd.pal")); Assert.That(ColorizationCatalog.Read(_root, "game").Any(asset => asset.Id.Contains("._")), Is.False, "macOS resource forks are not colorizations"); + Asset("game/update.pac"); File.SetCreationTimeUtc(Path.Combine(_root, "game/update.pac"), new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(ColorizationCatalog.Select(_root, "game", "auto"), Is.Null, "DmdDevice fails on a PAC without key and doesn't fall back to pin2dmd.pal"); + Assert.That(ColorizationCatalog.Select(_root, "game", "auto", hasPacKey: true).Id, Is.EqualTo("game/update.pac"), "DmdDevice loads any PAC in the game folder"); Asset("game/pin2dmd.pac"); - Assert.That(ColorizationCatalog.Select(_root, "game", "auto").Id, Is.EqualTo("game/pin2dmd.pal"), "a PAC without key can't be decrypted"); - Assert.That(ColorizationCatalog.Select(_root, "game", "auto", hasPacKey: true).Id, Is.EqualTo("game/pin2dmd.pac")); + Assert.That(ColorizationCatalog.Select(_root, "game", "auto", hasPacKey: true).Id, Is.EqualTo("game/pin2dmd.pac"), "the newest PAC wins"); Asset("game/old/other.cRZ"); - Assert.That(ColorizationCatalog.Select(_root, "game", "auto").Id, Is.EqualTo("game/pin2dmd.pal"), "Serum only counts in the game folder itself"); + Assert.That(ColorizationCatalog.Select(_root, "game", "auto"), Is.Null, "Serum only counts in the game folder itself"); Asset("game/older.cROM"); File.SetCreationTimeUtc(Path.Combine(_root, "game/older.cROM"), new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)); Asset("game/newer.cROMc"); - Assert.That(ColorizationCatalog.Select(_root, "game", "auto").Id, Is.EqualTo("game/newer.cROMc"), "the newest Serum file wins, like the bridge"); + Assert.That(ColorizationCatalog.Select(_root, "game", "auto").Id, Is.EqualTo("game/newer.cROMc"), "the newest Serum file wins, like the bridge, also over a PAC"); + } + [Test] + public void AutomaticSkipsASolePacWithoutKey() + { + Asset("game/update.pac"); + Assert.That(ColorizationCatalog.Select(_root, "game", "auto"), Is.Null); + Assert.That(ColorizationCatalog.Select(_root, "game", "auto", hasPacKey: true).Id, Is.EqualTo("game/update.pac")); } [Test] public void StagingContainsOnlySelectedVariantAndReleasesOwnedCopies() diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs index b88b660..c23c3f3 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs @@ -128,9 +128,11 @@ public class DmdBridgePlayer : MonoBehaviour, IDiagnosticsSource private float _nextSettingsCheckTime; // kept for diagnostics - private string _colorization = "not resolved yet"; + private const string ColorizationNotResolved = "not resolved yet"; + private string _colorization = ColorizationNotResolved; private string _serumLibraryVersion; private string _destinationNames = ""; + private string[] _destinations = Array.Empty(); private const float SettingsCheckInterval = 0.25f; @@ -384,7 +386,8 @@ private void EnsurePipeline(DisplayConfig display, bool force = false, bool? rgb _pipeline?.Dispose(); CaptureAppliedSettings(); var destinations = CreateDestinations(display); - _destinationNames = DestinationNames(destinations); + _destinations = DestinationNames(destinations); + _destinationNames = string.Join(", ", _destinations); if (destinations.Count == 0) { Logger.Warn("[DMD] No DMD destinations are available, the bridge will ignore frames."); _pipeline = null; @@ -401,7 +404,7 @@ private void EnsurePipeline(DisplayConfig display, bool force = false, bool? rgb PublishDiagnostics(); } - private static string DestinationNames(List destinations) + private static string[] DestinationNames(List destinations) { var names = new List(destinations.Count); foreach (var destination in destinations) { @@ -413,7 +416,7 @@ private static string DestinationNames(List destinations) } names.Add(string.IsNullOrEmpty(name) ? destination.GetType().Name : name); } - return string.Join(", ", names); + return names.ToArray(); } private void RequestSourceFrameFormat(DisplayFrameFormat format) @@ -793,6 +796,18 @@ private string ResolveAltColorPath(string configuredAltColorPath) /// public IDiagnosticsSource DiagnosticsOwner { get; set; } + /// + /// The colorization the pipeline loaded: "Serum v2", "VNI/PAL" or "PAL", "disabled", or "none (why)"; "not resolved yet" + /// before the first pipeline. + /// + public string LoadedColorization => _colorization; + + /// Whether the pipeline tried to load the colorization yet, so is final. + public bool ColorizationResolved => _colorization != ColorizationNotResolved; + + /// The names of the destinations the pipeline drives, the devices that were actually found among them. + public IReadOnlyList Destinations => _destinations; + private void PublishDiagnostics() { DiagnosticsContext.Set(DiagnosticsOwner ?? this); @@ -1077,6 +1092,17 @@ public static string ResolveEnvConfigPath() return null; } + /// One value of a DmdDevice.ini, or null when the file, section or key is missing. Doesn't log on success. + public static string ReadValue(string path, string section, string key) + { + try { + return File.Exists(path) && Parse(path).TryGetValue(section, out var values) ? GetString(values, key, null) : null; + } catch (Exception exception) { + Logger.Debug(exception, "[DMD] Could not read {Key} from {Path:l}.", key, path); + return null; + } + } + private static Dictionary> Parse(string path) { var ini = new Dictionary>(StringComparer.OrdinalIgnoreCase); diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs index 2674b11..b80e06b 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/ColorizationCatalog.cs @@ -46,22 +46,24 @@ internal static IReadOnlyList Read(string root, string game) private static bool InGameFolder(ColorizationAsset asset, string game) => asset.Id.StartsWith(game + "/", StringComparison.OrdinalIgnoreCase) && asset.Id.IndexOf('/', game.Length + 1) < 0; private static bool IsSerum(string file) => Path.GetExtension(file).ToLowerInvariant() is ".crz" or ".crom" or ".cromc"; private static string Relative(string root, string file) => Path.GetRelativePath(root, file).Replace('\\', '/'); - /// Whether a PAC key is configured; PAC files only win automatic selection with one. + /// Whether a PAC key is configured. Without one, automatic selection skips PAC files and doesn't fall + /// back to pin2dmd.pal next to a PAC, since DmdDevice fails to load such a folder and shows the uncolorized frames. internal static ColorizationAsset Select(string root, string game, string selection, bool hasPacKey = false) { if (selection == "none") return null; var assets = Read(root, game); if (selection == "auto") { - if (assets.Count <= 1) return assets.FirstOrDefault(); + if (assets.Count <= 1) return assets.FirstOrDefault(asset => hasPacKey || !asset.RequiresKey); // Several: what DmdDevice itself loads from the game folder, like the standalone bridge did. Serum first (the newest - // .cRZ, .cROM or .cROMc), then VNI, which prefers pin2dmd.pac over pin2dmd.pal when it can decrypt it. + // .cRZ, .cROM or .cROMc), then VNI, which loads the newest PAC whatever its name, and pin2dmd.pal only without a PAC. var serum = assets.Where(asset => InGameFolder(asset, game) && IsSerum(asset.Files[0])) .OrderByDescending(asset => File.GetCreationTimeUtc(asset.Files[0])).FirstOrDefault(); if (serum != null) return serum; - foreach (string standard in hasPacKey ? new[] { "pin2dmd.pac", "pin2dmd.pal" } : new[] { "pin2dmd.pal", "pin2dmd.pac" }) { - var match = assets.FirstOrDefault(asset => string.Equals(asset.Id, game + "/" + standard, StringComparison.OrdinalIgnoreCase)); - if (match != null) return match; - } + var pac = assets.Where(asset => InGameFolder(asset, game) && asset.RequiresKey) + .OrderByDescending(asset => File.GetCreationTimeUtc(asset.Files[0])).FirstOrDefault(); + if (pac != null) return hasPacKey ? pac : null; + var pal = assets.FirstOrDefault(asset => string.Equals(asset.Id, game + "/pin2dmd.pal", StringComparison.OrdinalIgnoreCase)); + if (pal != null) return pal; throw new InvalidOperationException("Several colorizations are available. Select one for this game."); } return assets.FirstOrDefault(asset => asset.Id == selection) ?? throw new FileNotFoundException("Selected colorization is missing: " + selection); diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs index 0f06494..e436cef 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs @@ -19,7 +19,7 @@ namespace VisualPinball.Engine.DMD.Player { - internal sealed class DmdPlugin : PlayerPlugin, IPluginTick, IPluginEmergencyStop, IPluginDeferredCleanup, IDiagnosticsSource + internal sealed class DmdPlugin : PlayerPlugin, IPluginTick, IPluginEmergencyStop, IPluginDeferredCleanup, IDiagnosticsSource, IPluginStatusSource { private readonly PluginApplicationContext _application; private readonly List _presentations = new List(); @@ -35,16 +35,25 @@ internal sealed class DmdPlugin : PlayerPlugin, IPluginTick, IPluginEmergencySto private string _colorizationLibrary; private string _colorizationAsset; private string _stagedColorization; + private string _dmdDeviceIni; + // kept for the pause menu + private ColorizationAsset _asset; + private string _noColorizationReason; + private int _iniImported; + private DmdBridgeSettings _settings; internal DmdPlugin(PluginApplicationContext application) { _application = application; } public override Task PrepareGameAsync(PluginGameContext game, CancellationToken cancellation) { var scene = game.Capabilities.Require(UnityPluginCapability.GameScene); - var settings = DmdPluginFactory.Settings(game.Settings); + var effective = WithDmdDeviceIni(game); + var settings = DmdPluginFactory.Settings(effective); settings.RomName = game.Identity.Key; - _gameSettings = game.Settings; - _colorizationLibrary = DmdPluginFactory.ColorizationLibrary(game.Settings.String("AltColorRoot")); - var asset = ColorizationCatalog.Select(_colorizationLibrary, game.Identity.Key, game.Settings.String("Colorization", "auto"), game.Settings.String("VniKey").Length > 0); + _gameSettings = effective; + _colorizationLibrary = DmdPluginFactory.Library(effective, game.Identity.Key); + var asset = ColorizationCatalog.Select(_colorizationLibrary, game.Identity.Key, effective.String("Colorization", "auto"), effective.String("VniKey").Length > 0); settings.EnableColorization = asset != null; + _asset = asset; _settings = settings; + _noColorizationReason = asset != null ? null : NoColorizationReason(game.Identity.Key, effective); _colorizationAsset = asset != null ? asset.Id + " (" + asset.Label + ")" : "none"; if (asset != null) { _nativeResources.Own(ColorizationCatalog.Stage(asset, Path.Combine(_application.Configuration.DirectoryFor(_application.Plugin), "cache", "colorizations"), game.Identity.Key, out string staged)); @@ -61,7 +70,7 @@ public override Task PrepareGameAsync(PluginGameContext game, CancellationToken var selection = new PluginResourceSelection(ResourceKind.Display) { Controller = game.ControllerId, Session = game.Session, Role = "dmd", Intent = asset == null ? ResourceView.Original : ResourceView.Identification, Accepts = asset == null ? DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8 | DisplayEncoding.Rgb24 : DisplayEncoding.Gray2 | DisplayEncoding.Gray4 }; - string source = game.Settings.String("Source", "auto"); if (source != "auto") selection.ResourceId = PluginResourceChoices.Resolve(game.ControllerId, source); + string source = effective.String("Source", "auto"); if (source != "auto") selection.ResourceId = PluginResourceChoices.Resolve(game.ControllerId, source); _binding = game.Lifetime.Own(game.Resources.Bind(selection)); _bridge.ConfigureManaged(settings, scene.Table, () => { _application.Scheduler.VerifyOwner(); @@ -78,11 +87,106 @@ public override Task PrepareGameAsync(PluginGameContext game, CancellationToken return Task.CompletedTask; } + /// + /// The game's settings with DmdDevice.ini underneath what the user saved: values in the global settings or the game's profile + /// win, every other one comes from the ini when it has it. + /// + private PluginSettingsSnapshot WithDmdDeviceIni(PluginGameContext game) + { + var schema = DmdPluginFactory.SettingsSchema; + var saved = new HashSet(StringComparer.Ordinal); + try { + foreach (var value in _application.Configuration.Read(_application.Plugin, schema).Values.Properties()) + if (schema.Fields.FirstOrDefault(field => field.Id == value.Name)?.Scope != ConfigurationScope.GameOnly) saved.Add(value.Name); + foreach (var value in _application.Configuration.Read(_application.Plugin, schema, game.Identity).Values.Properties()) + if (schema.Fields.FirstOrDefault(field => field.Id == value.Name)?.Scope != ConfigurationScope.GlobalOnly) saved.Add(value.Name); + } catch (Exception error) { + _application.Logger.Warn($"Not applying DmdDevice.ini, the saved DMD settings couldn't be read: {error.Message}"); + return game.Settings; + } + var effective = DmdPluginFactory.WithDmdDeviceIni(game.Settings, saved, game.Identity.Key, out _dmdDeviceIni, out int imported); + _iniImported = imported; + _application.Logger.Info(_dmdDeviceIni != null + ? $"DmdDevice.ini at {_dmdDeviceIni} applied: {imported} setting(s) from the ini, {saved.Count} saved in VPE." + : "No DmdDevice.ini found (DMDDEVICE_CONFIG, VPM_HOME, VPinMAME's COM registration, next to the ROM folder)."); + return effective; + } + + /// Why a game plays without colorization, in the pause menu's words. Reads the library, so only while preparing. + private string NoColorizationReason(string game, PluginSettingsSnapshot settings) + { + if (settings.String("Colorization", "auto") == "none") return "off"; + if (_colorizationLibrary.Length == 0) return "off, no altcolor folder found"; + try { + var found = ColorizationCatalog.Read(_colorizationLibrary, game); + if (found.Count == 0) return "off, none for this game"; + var pac = found.FirstOrDefault(asset => asset.RequiresKey); + if (pac != null && settings.String("VniKey").Length == 0) return "off, " + Path.GetFileName(pac.Files[0]) + " found, no PAC key"; + } catch (Exception) { + return "off"; + } + return "off"; + } + + public void DescribeStatus(PluginStatusWriter writer) + { + if (_gameSettings == null) return; + bool resolved = _bridge != null && _bridge.ColorizationResolved; + string loaded = resolved ? _bridge.LoadedColorization : ""; + bool failed = _asset != null && (loaded.StartsWith("none", StringComparison.Ordinal) || loaded == "disabled"); + if (_asset == null) writer.Add("Colorization", _noColorizationReason ?? "off"); + else if (!resolved) writer.Add("Colorization", AssetKind(_asset) + ", " + AssetFiles(_asset) + ", waiting for the DMD"); + else writer.Add("Colorization", failed ? "off, " + AssetFiles(_asset) + " didn't load" : AssetKind(_asset) + ", " + AssetFiles(_asset)); + if (_asset == null || failed) { + var tint = LibDmd.RenderGraph.DefaultColor; + writer.Add("Tint", $"#{tint.R:X2}{tint.G:X2}{tint.B:X2}"); + } + if (_dmdDeviceIni != null) { + string folder = Path.GetFileName(Path.GetDirectoryName(_dmdDeviceIni) ?? ""); + writer.Add("DmdDevice.ini", (folder.Length > 0 ? "from " + folder + ", " : "") + (_iniImported == 0 ? "nothing applied" : _iniImported == 1 ? "1 setting applied" : _iniImported + " settings applied")); + } + var outputs = (_bridge != null ? _bridge.Destinations : Array.Empty()).Select(Output).Where(name => name != null).ToList(); + if (outputs.Count > 0) writer.Add("Outputs", string.Join(", ", outputs)); + } + + private static string AssetKind(ColorizationAsset asset) + { + switch (Path.GetExtension(asset.Files[0]).ToLowerInvariant()) { + case ".crz": case ".crom": case ".cromc": return "Serum"; + case ".pac": return "PAC"; + default: return asset.Files.Length > 1 ? "PIN2DMD" : "PIN2DMD palette"; + } + } + + // the files as the library has them, relative to the game folder: "pin2dmd.pal + pin2dmd.vni", "old/pin2dmd.pal" + private static string AssetFiles(ColorizationAsset asset) + { + string inGame = asset.Id.Substring(asset.Id.IndexOf('/') + 1); + string subfolder = inGame.Contains('/') ? inGame.Substring(0, inGame.LastIndexOf('/') + 1) : ""; + return string.Join(" + ", asset.Files.Select(file => subfolder + Path.GetFileName(file))); + } + + // the cabinet DMDs and windows by what the player calls them; the table's own DMD and the presentation surfaces aren't outputs + private string Output(string destination) + { + switch (destination) { + case "VPE In-Scene DMD": case "VPE Presentation Resource": return null; + case "Native DMD Window": return "DMD window"; + case "ZeDMD": return WithPort("ZeDMD", _settings?.ZeDmdPort); + case "PinDMD v3": return WithPort("PinDMD v3", _settings?.PinDmd3Port); + case "Pixelcade": return WithPort("Pixelcade", _settings?.PixelcadePort); + default: return destination; + } + } + + private static string WithPort(string device, string port) => string.IsNullOrWhiteSpace(port) ? device : device + " (" + port.Trim() + ")"; + public string DiagnosticsSection => "DMD"; public void Describe(DiagnosticsWriter writer) { writer.Add("plugin", DmdPluginFactory.Id); + writer.Add("dmddevice.ini", _dmdDeviceIni ?? "none"); writer.Add("colorization.library", string.IsNullOrEmpty(_colorizationLibrary) ? "not found" : _colorizationLibrary); writer.Add("colorization.asset", _colorizationAsset ?? "none"); if (_stagedColorization != null) writer.Add("colorization.staged", _stagedColorization); @@ -127,7 +231,7 @@ public override async Task StopGameAsync(CancellationToken cancellation) await _application.Scheduler.Switch(); if (_bridge != null) Quiescence = _bridge.ShutdownCompletion; ReleaseWhenStopped(); _presentations.Clear(); _dispatch = Array.Empty(); - _gameSettings = null; + _gameSettings = null; _asset = null; _settings = null; DiagnosticsContext.Remove(DiagnosticsSection); } } diff --git a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs index cae8f87..9301e15 100644 --- a/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs +++ b/VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPluginFactory.cs @@ -8,8 +8,12 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; using System.Threading; using System.Threading.Tasks; +using System.Globalization; using LibDmd.Converter; using LibDmd.Common; using LibDmd.Output.Pixelcade; @@ -28,7 +32,8 @@ internal sealed class DmdPluginFactory : IPlayerPluginFactory, IPluginSettingsPr internal const string Id = "org.visualpinball.dmd"; private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); public PluginDescriptor Descriptor { get; } = new PluginDescriptor(Id, "DMD", "Colorizations, your in-scene DMD and native displays, with optional cabinet hardware.") { EnabledByDefault = true, Category = "Displays", Order = 20 }; - public PluginSettingsSchema Schema { get; } = BuildSchema(); + internal static readonly PluginSettingsSchema SettingsSchema = BuildSchema(); + public PluginSettingsSchema Schema => SettingsSchema; public IPluginSettingsProvider SettingsProvider => this; public IPlayerPlugin Create(PluginApplicationContext context) => new DmdPlugin(context); [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] @@ -48,7 +53,7 @@ private static PluginField Coordinate(string id, string label, string row, int v new PluginField("AltColorRoot", "Colorization library", SettingKind.Folder) { Default = "", Scope = ConfigurationScope.GlobalAndGame, FullWidth = true, Description = "The altcolor folder containing one folder per ROM. Leave empty to use the altcolor folder next to DmdDevice.ini or VPinMAME's. Games can override the global library." }, Field("Colorization", "Colorization", SettingKind.Choice, "auto", "colorizations"), Field("ColorizationScalerMode", "Scaler", SettingKind.Choice, "None", "scalers"), - new PluginField("VniKey", "PAC key", SettingKind.Text) { Default = "", FullWidth = true }), + new PluginField("VniKey", "PAC key", SettingKind.Text) { Default = "", FullWidth = true, Description = "Leave empty to use vni.key from DmdDevice.ini." }), new PluginSettingsGroup("Display outputs", "Native placement uses desktop pixels and allows negative coordinates on any monitor.", new PluginField("Source", "DMD source", SettingKind.Choice) { Default = "auto", Scope = ConfigurationScope.GameOnly, Provider = "sources" }, Field("EnableInSceneDisplay", "Use the table's DMD", SettingKind.Boolean, true), @@ -66,7 +71,7 @@ private static PluginField Coordinate(string id, string label, string row, int v Field("DotSize", "Dot size", SettingKind.Number, 0.92, minimum: 0.05, maximum: 1.5), Field("DotRounding", "Rounding", SettingKind.Number, 1, minimum: 0, maximum: 1), Field("DotSharpness", "Sharpness", SettingKind.Number, 0.8, minimum: 0, maximum: 1), Field("Brightness", "Brightness", SettingKind.Number, 0.95, minimum: 0, maximum: 4), Field("DotGlow", "Dot glow", SettingKind.Number, 0, minimum: 0, maximum: 4), Field("BackGlow", "Background glow", SettingKind.Number, 0, minimum: 0, maximum: 4), Field("Gamma", "Gamma", SettingKind.Number, 1, minimum: 0.1, maximum: 4), Field("UnlitDot", "Unlit dot color", SettingKind.Color, "#000000"), Field("GlassColor", "Glass color", SettingKind.Color, "#000000"), Field("GlassLighting", "Glass lighting", SettingKind.Number, 0, minimum: 0, maximum: 4)), - new PluginSettingsGroup("Import existing settings", "Import once into the draft, review it, then Apply. The INI is never rewritten.", + new PluginSettingsGroup("Import existing settings", "DmdDevice.ini from VPinMAME's folder applies automatically to everything not set here. Import copies an INI into the draft to review and Apply. The INI is never rewritten.", new PluginField("ImportPath", "DmdDevice.ini", SettingKind.File) { Default = "", FullWidth = true }, new PluginField("Import", "Import INI", SettingKind.Action) { Provider = "import-ini" })); internal static DmdBridgeSettings Settings(PluginSettingsSnapshot snapshot) { @@ -85,14 +90,31 @@ private PluginChoiceCatalog Discover(string provider, PluginChoiceRequest reques var choices = new List(); switch (provider) { case "colorizations": - choices.Add(new PluginChoice("auto", "Automatic", "Uses the sole available colorization, or what DmdDevice loads from the game folder: Serum first, then pin2dmd.pac with a PAC key, then pin2dmd.pal and .vni. Otherwise select one.")); choices.Add(new PluginChoice("none", "No colorization")); - foreach (var asset in ColorizationCatalog.Read(DmdPluginFactory.ColorizationLibrary(request.Settings.String("AltColorRoot")), request.Game?.Key ?? "")) choices.Add(new PluginChoice(asset.Id, asset.Label, available: !asset.RequiresKey || request.Settings.String("VniKey").Length > 0, unavailableReason: "A PAC key is required.")); + choices.Add(new PluginChoice("auto", "Automatic", "Uses the sole available colorization, or what DmdDevice loads from the game folder: Serum first, then the newest .pac, which needs a PAC key (without one, no colorization), then pin2dmd.pal and .vni. Otherwise select one.")); choices.Add(new PluginChoice("none", "No colorization")); + foreach (var asset in ColorizationCatalog.Read(Library(request.Settings, request.Game?.Key), request.Game?.Key ?? "")) choices.Add(new PluginChoice(asset.Id, asset.Label, available: !asset.RequiresKey || PacKey(request.Settings).Length > 0, unavailableReason: "A PAC key is required.")); break; case "scalers": foreach (string name in Enum.GetNames(typeof(ScalerMode))) choices.Add(new PluginChoice(name, name)); break; case "matrices": foreach (string name in Enum.GetNames(typeof(ColorMatrix))) choices.Add(new PluginChoice(name, name)); break; } return new PluginChoiceCatalog(string.Join("|", choices.Select(choice => choice.Id)), choices); } + /// + /// The colorization library a game uses: the configured folder, else the altcolor folder DmdDevice.ini sets for the game, else + /// the first default altcolor folder that exists. The settings screen and the game resolve it the same way. + /// + internal static string Library(PluginSettingsSnapshot settings, string game) + { + string configured = settings.String("AltColorRoot").Trim(); + if (configured.Length == 0 && !string.IsNullOrWhiteSpace(game)) { + string ini = DmdDeviceIni(); + string altColor = ini == null ? null : DmdBridgeConfig.ReadValue(ini, game, "altcolor"); + if (!string.IsNullOrWhiteSpace(altColor)) { + try { configured = Path.IsPathRooted(altColor) ? altColor : Path.GetFullPath(Path.Combine(Path.GetDirectoryName(ini) ?? "", altColor)); } catch (Exception error) { Logger.Debug(error, "[DMD] Skipping altcolor {Path:l} from DmdDevice.ini.", altColor); } + } + } + return ColorizationLibrary(configured); + } + /// The colorization library to use: the configured folder, or the first default altcolor folder that exists. internal static string ColorizationLibrary(string configured) { @@ -103,22 +125,15 @@ internal static string ColorizationLibrary(string configured) return ""; } /// - /// Where DmdDevice and the standalone DMD bridge look: next to DmdDevice.ini, in VPinMAME's folder (VPM_HOME, or next to a ROM - /// folder, since VPinMAME keeps roms and altcolor side by side), then where LibDmd finds it. + /// Where DmdDevice and the standalone DMD bridge look: next to DmdDevice.ini, in VPinMAME's folder (VPM_HOME, its COM registration, + /// or next to a ROM folder, since VPinMAME keeps roms and altcolor side by side), then where LibDmd finds it. /// private static IEnumerable DefaultColorizationLibraries() { string config = null; try { config = DmdBridgeConfig.ResolveEnvConfigPath(); } catch (Exception error) { Logger.Debug(error, "[DMD] Could not resolve DMDDEVICE_CONFIG."); } if (!string.IsNullOrWhiteSpace(config)) yield return Path.Combine(Path.GetDirectoryName(config) ?? "", "altcolor"); - string home = Environment.GetEnvironmentVariable("VPM_HOME"); - if (!string.IsNullOrWhiteSpace(home)) yield return Path.Combine(home.Trim().Trim('"'), "altcolor"); - foreach (string roms in (VisualPinball.Unity.GamelogicPaths.RomFolder ?? "").Split(';')) { - if (string.IsNullOrWhiteSpace(roms)) continue; - string parent = null; - try { parent = Path.GetDirectoryName(Path.GetFullPath(roms.Trim()).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); } catch (Exception error) { Logger.Debug(error, "[DMD] Skipping ROM folder {Path:l}.", roms); } - if (parent != null) yield return Path.Combine(parent, "altcolor"); - } + foreach (string folder in VpinMameFolders()) yield return Path.Combine(folder, "altcolor"); string libDmd = null; try { libDmd = PathUtil.GetVpmFolder("altcolor", "[DMD]"); } catch (Exception error) { Logger.Debug(error, "[DMD] Could not locate VPinMAME's altcolor folder."); } yield return libDmd; @@ -128,8 +143,9 @@ public IReadOnlyList Validate(PluginChoiceRequest request var issues = new List(); foreach (string field in new[] { "UnlitDot", "GlassColor" }) if (!ColorUtility.TryParseHtmlString(request.Settings.String(field), out _)) issues.Add(new PluginValidationIssue(field, "Enter a color such as #FFAA00.")); if (request.Game.HasValue) try { - var asset = ColorizationCatalog.Select(DmdPluginFactory.ColorizationLibrary(request.Settings.String("AltColorRoot")), request.Game.Value.Key, request.Settings.String("Colorization", "auto"), request.Settings.String("VniKey").Length > 0); - if (asset?.RequiresKey == true && request.Settings.String("VniKey").Length == 0) issues.Add(new PluginValidationIssue("VniKey", "A PAC key is required.")); + bool hasKey = PacKey(request.Settings).Length > 0; + var asset = ColorizationCatalog.Select(Library(request.Settings, request.Game.Value.Key), request.Game.Value.Key, request.Settings.String("Colorization", "auto"), hasKey); + if (asset?.RequiresKey == true && !hasKey) issues.Add(new PluginValidationIssue("VniKey", "A PAC key is required.")); } catch (Exception error) { issues.Add(new PluginValidationIssue("Colorization", error.Message)); } return issues; } @@ -138,13 +154,127 @@ public Task RunActionAsync(string action, PluginChoiceReques if (action != "import-ini") return Task.FromResult(new PluginActionResult(false, "Unknown DMD action.")); string path = request.Settings.String("ImportPath"); if (!File.Exists(path)) return Task.FromResult(new PluginActionResult(false, "Choose an existing DmdDevice.ini.")); - var fallback = Settings(Schema.Defaults()); - fallback.RomName = request.Game?.Key; + // only what the ini carries, over the current values, so the source and library the user chose stay + var values = IniValues(path, request.Settings, request.Game?.Key); + return Task.FromResult(new PluginActionResult(true, "Imported into the draft. Review the values and Apply to save.", new PluginSettingsSnapshot(1, values))); + } + + /// + /// The settings a DmdDevice.ini carries, as field values: the ini applied over , with DmdDevice's + /// own default (colorization on) where the ini says nothing. Only schema fields; the import and source fields are left out. + /// + internal static JObject IniValues(string path, PluginSettingsSnapshot current, string romName) + { + var fallback = Settings(current); fallback.RomName = romName; fallback.EnableColorization = true; fallback.AltColorPath = null; var settings = DmdBridgeConfig.Load(path, fallback, true); - var values = JObject.FromObject(settings); values["UnlitDot"] = "#" + ColorUtility.ToHtmlStringRGB(settings.UnlitDot); values["GlassColor"] = "#" + ColorUtility.ToHtmlStringRGB(settings.GlassColor); + var values = new JObject(); + foreach (var field in SettingsSchema.Fields) { + if (field.Id == "ImportPath" || field.Id == "Import" || field.Id == "Source") continue; + var member = typeof(DmdBridgeSettings).GetField(field.Id, BindingFlags.Public | BindingFlags.Instance); + if (member == null) continue; + object value = member.GetValue(settings); + values[field.Id] = value switch { + Color color => "#" + ColorUtility.ToHtmlStringRGB(color), + Enum choice => choice.ToString(), + // floats as their shortest decimal, so 0.92 stays 0.92 rather than 0.9200000166893005 + float number => double.Parse(number.ToString("R", CultureInfo.InvariantCulture), CultureInfo.InvariantCulture), + null => "", + _ => JToken.FromObject(value), + }; + } if (!string.IsNullOrWhiteSpace(settings.AltColorPath)) values["AltColorRoot"] = settings.AltColorPath; - values["Colorization"] = settings.EnableColorization ? "auto" : "none"; - return Task.FromResult(new PluginActionResult(true, "Imported into the draft. Review the values and Apply to save.", new PluginSettingsSnapshot(1, values))); + string selected = current.String("Colorization", "auto"); + values["Colorization"] = !settings.EnableColorization ? "none" : selected == "none" ? "auto" : selected; + return values; + } + + /// + /// The settings a game runs with: DmdDevice.ini, when there is one, replaces every value the user hasn't saved in the global + /// settings or the game's profile, so the ini sits between the defaults and the user's own settings. + /// + /// The resolved settings the host passed. + /// The fields stored in the global or the game layer, which keep their values. + internal static PluginSettingsSnapshot WithDmdDeviceIni(PluginSettingsSnapshot effective, ISet saved, string romName, out string iniPath, out int imported) + { + iniPath = DmdDeviceIni(); imported = 0; + if (iniPath == null) return effective; + var result = effective.Copy(); + foreach (var value in IniValues(iniPath, effective, romName).Properties()) { + // an empty PAC key or library means "use the ini's", as the fields say, also when saved empty + bool keptByUser = saved.Contains(value.Name) && !((value.Name == "VniKey" || value.Name == "AltColorRoot") && effective.String(value.Name).Trim().Length == 0); + if (keptByUser || Same(result.Values[value.Name], value.Value)) continue; + result.Values[value.Name] = value.Value; imported++; + } + return result; + } + + // 1 and 1.0 are the same setting, though not the same token + private static bool Same(JToken current, JToken imported) => JToken.DeepEquals(current, imported) + || current is JValue a && imported is JValue b && (a.Type == JTokenType.Integer || a.Type == JTokenType.Float) && (b.Type == JTokenType.Integer || b.Type == JTokenType.Float) + && a.Value() == b.Value(); + + /// The PAC key: the configured one, or vni.key from DmdDevice.ini. + internal static string PacKey(PluginSettingsSnapshot settings) + { + string key = settings.String("VniKey").Trim(); + if (key.Length > 0) return key; + string ini = DmdDeviceIni(); + return ini == null ? "" : (DmdBridgeConfig.ReadValue(ini, "global", "vni.key") ?? "").Trim(); + } + + /// + /// The DmdDevice.ini that DmdDevice would use: DMDDEVICE_CONFIG, then the one in VPinMAME's folder, where DmdDevice.dll and its + /// ini live (see ). Null if there is none. + /// + internal static string DmdDeviceIni() + { + string config = null; + try { config = DmdBridgeConfig.ResolveEnvConfigPath(); } catch (Exception error) { Logger.Debug(error, "[DMD] Could not resolve DMDDEVICE_CONFIG."); } + if (!string.IsNullOrWhiteSpace(config)) return config; + foreach (string folder in VpinMameFolders()) { + try { string ini = Path.Combine(folder, "DmdDevice.ini"); if (File.Exists(ini)) return ini; } catch (Exception error) { Logger.Debug(error, "[DMD] Skipping DmdDevice.ini candidate in {Path:l}.", folder); } + } + return null; } + + /// VPinMAME's folder candidates: VPM_HOME, the folder of VPinMAME's COM registration, and the parent of each ROM folder. + private static IEnumerable VpinMameFolders() + { + string home = Environment.GetEnvironmentVariable("VPM_HOME"); + if (!string.IsNullOrWhiteSpace(home)) yield return home.Trim().Trim('"'); + string registered = null; + try { registered = RegisteredVpinMameFolder(); } catch (Exception error) { Logger.Debug(error, "[DMD] Could not read VPinMAME's COM registration."); } + if (!string.IsNullOrWhiteSpace(registered)) yield return registered; + foreach (string roms in (VisualPinball.Unity.GamelogicPaths.RomFolder ?? "").Split(';')) { + if (string.IsNullOrWhiteSpace(roms)) continue; + string parent = null; + try { parent = Path.GetDirectoryName(Path.GetFullPath(roms.Trim()).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); } catch (Exception error) { Logger.Debug(error, "[DMD] Skipping ROM folder {Path:l}.", roms); } + if (parent != null) yield return parent; + } + } + + /// The folder of the VPinMAME DLL registered for VPinMAME.Controller, where LibDmd finds it on Windows. + private static string RegisteredVpinMameFolder() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return null; + string clsid = ReadClassesRoot(@"VPinMAME.Controller\CLSID"); + if (string.IsNullOrWhiteSpace(clsid)) return null; + // the ProgID is shared by both registry views, the CLSID isn't: a 32-bit-only VPinMAME is under WOW6432Node + string dll = ReadClassesRoot(@"CLSID\" + clsid.Trim() + @"\InprocServer32") ?? ReadClassesRoot(@"WOW6432Node\CLSID\" + clsid.Trim() + @"\InprocServer32"); + return string.IsNullOrWhiteSpace(dll) ? null : Path.GetDirectoryName(dll.Trim().Trim('"')); + } + + private static string ReadClassesRoot(string subKey) + { + var classesRoot = new IntPtr(unchecked((int)0x80000000)); + uint size = 0; + if (RegGetValueW(classesRoot, subKey, null, RrfRtRegSz, out _, null, ref size) != 0 || size == 0) return null; + var buffer = new StringBuilder((int)(size / 2) + 1); + return RegGetValueW(classesRoot, subKey, null, RrfRtRegSz, out _, buffer, ref size) == 0 ? buffer.ToString() : null; + } + + private const uint RrfRtRegSz = 0x00000002; + [DllImport("advapi32.dll", CharSet = CharSet.Unicode)] + private static extern int RegGetValueW(IntPtr key, string subKey, string value, uint flags, out uint type, StringBuilder data, ref uint size); } }