Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions Tests/Player/ColorizationCatalogTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
34 changes: 30 additions & 4 deletions VisualPinball.Engine.DMD.Unity/Runtime/DmdBridgePlayer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

private const float SettingsCheckInterval = 0.25f;

Expand Down Expand Up @@ -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;
Expand All @@ -401,7 +404,7 @@ private void EnsurePipeline(DisplayConfig display, bool force = false, bool? rgb
PublishDiagnostics();
}

private static string DestinationNames(List<IDestination> destinations)
private static string[] DestinationNames(List<IDestination> destinations)
{
var names = new List<string>(destinations.Count);
foreach (var destination in destinations) {
Expand All @@ -413,7 +416,7 @@ private static string DestinationNames(List<IDestination> destinations)
}
names.Add(string.IsNullOrEmpty(name) ? destination.GetType().Name : name);
}
return string.Join(", ", names);
return names.ToArray();
}

private void RequestSourceFrameFormat(DisplayFrameFormat format)
Expand Down Expand Up @@ -793,6 +796,18 @@ private string ResolveAltColorPath(string configuredAltColorPath)
/// </summary>
public IDiagnosticsSource DiagnosticsOwner { get; set; }

/// <summary>
/// The colorization the pipeline loaded: "Serum v2", "VNI/PAL" or "PAL", "disabled", or "none (why)"; "not resolved yet"
/// before the first pipeline.
/// </summary>
public string LoadedColorization => _colorization;

/// <summary>Whether the pipeline tried to load the colorization yet, so <see cref="LoadedColorization"/> is final.</summary>
public bool ColorizationResolved => _colorization != ColorizationNotResolved;

/// <summary>The names of the destinations the pipeline drives, the devices that were actually found among them.</summary>
public IReadOnlyList<string> Destinations => _destinations;

private void PublishDiagnostics()
{
DiagnosticsContext.Set(DiagnosticsOwner ?? this);
Expand Down Expand Up @@ -1077,6 +1092,17 @@ public static string ResolveEnvConfigPath()
return null;
}

/// <summary>One value of a DmdDevice.ini, or null when the file, section or key is missing. Doesn't log on success.</summary>
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<string, Dictionary<string, string>> Parse(string path)
{
var ini = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,22 +46,24 @@ internal static IReadOnlyList<ColorizationAsset> 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('\\', '/');
/// <param name="hasPacKey">Whether a PAC key is configured; PAC files only win automatic selection with one.</param>
/// <param name="hasPacKey">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.</param>
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);
Expand Down
118 changes: 111 additions & 7 deletions VisualPinball.Engine.DMD.Unity/Runtime/Plugins/DmdPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DmdPresentationDestination> _presentations = new List<DmdPresentationDestination>();
Expand All @@ -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<IUnityGameScene>(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);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
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));
Expand All @@ -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<IPluginDisplaySource>(selection));
_bridge.ConfigureManaged(settings, scene.Table, () => {
_application.Scheduler.VerifyOwner();
Expand All @@ -78,11 +87,106 @@ public override Task PrepareGameAsync(PluginGameContext game, CancellationToken
return Task.CompletedTask;
}

/// <summary>
/// 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.
/// </summary>
private PluginSettingsSnapshot WithDmdDeviceIni(PluginGameContext game)
{
var schema = DmdPluginFactory.SettingsSchema;
var saved = new HashSet<string>(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;
}

/// <summary>Why a game plays without colorization, in the pause menu's words. Reads the library, so only while preparing.</summary>
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<string>()).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);
Expand Down Expand Up @@ -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<DmdPresentationDestination>();
_gameSettings = null;
_gameSettings = null; _asset = null; _settings = null;
DiagnosticsContext.Remove(DiagnosticsSection);
}
}
Expand Down
Loading
Loading