feat(bootstrap): wire DI host, hosted services, and plugin entry

The plugin is now loadable. Dalamud injects five services into the Plugin
constructor (Lightless pattern), the constructor builds the generic-host
container synchronously, and PluginLifecycle drives StartAsync from
LoadAsync. Module 02+ extends PluginHostFactory; this file set stays put.

- PluginHostDependencies (record): bundles the five Dalamud services
  v0.1.0 needs (IDalamudPluginInterface, IPluginLog, IDataManager,
  IFramework, ISelfTestRegistry).
- PluginHostFactory.Build: HostBuilder + AddDalamudLogging + the four
  service blocks (Dalamud services, Anvil singletons, ISelfTestStep
  collection, IHostedService init chain). Every registration uses an
  explicit factory lambda - the default activator only sees public
  ctors and Anvil follows the internal-sealed convention.
- PluginLifecycle (IAsyncDisposable): owns Host.StartAsync, marshals the
  Host.Dispose call onto the framework thread, idempotency guard via
  Interlocked, ExceptionDispatchInfo.Capture preserves the original
  load-throw stack when a failure cascades.
- Plugin (IAsyncDalamudPlugin): constructor injection of the five
  Dalamud services, builds the dependencies record, kicks off the host
  build, hands DisposeAsync to the lifecycle.
- Hosting/RecipeDataLoadHostedService: dispatches LuminaRecipeAdapter
  .LoadInternal onto the framework thread on StartAsync. Lumina sheet
  reads have no documented thread safety; conservative default.
- Hosting/SelfTestRegistrationHostedService: collects every
  ISelfTestStep registration from DI and hands them to
  ISelfTestRegistry.RegisterTestSteps once the host is up.
- SelfTest/RecipeDataAdapterLoadStep: nine pass criteria per spec §4.1
  (IsLoaded, RecipeCount > 0, ActionCount in 30..80, BuffsByKind.Count
  == 14, ConditionsByKind.Count == 11, Foods >= 30, Medicines >= 5,
  BasicSynthesis.RowIdByClassJob[8] == 100001, Cosmic-surface
  silent-degradation warning). Returns Waiting while the catalog is
  still loading.
- Infrastructure/Logging trio: DalamudLogger maps
  Microsoft.Extensions.Logging levels to IPluginLog, the provider
  emits an Anvil bootstrap banner with a Forge-Bronze fingerprint on
  ctor, the extension wires the provider into the ILoggingBuilder via
  TryAddEnumerable.
This commit is contained in:
2026-05-27 21:58:38 +02:00
parent 401ebc9495
commit 90803bcd3c
10 changed files with 733 additions and 0 deletions
@@ -0,0 +1,49 @@
// IHostedService adapter that runs the LuminaRecipeAdapter on the framework
// thread when the host starts. The dispatch is conservative - Lumina sheet
// reads have no documented thread-safety guarantee, and the CPU-bound work
// finishes well under a second, so framework-thread blocking is fine.
using System.Threading;
using System.Threading.Tasks;
using Anvil.RecipeData.Internal;
using Dalamud.Plugin.Services;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Anvil.Hosting;
internal sealed class RecipeDataLoadHostedService : IHostedService
{
private readonly IFramework _framework;
private readonly LuminaRecipeAdapter _adapter;
private readonly ILogger<RecipeDataLoadHostedService> _logger;
public RecipeDataLoadHostedService(
IFramework framework,
LuminaRecipeAdapter adapter,
ILogger<RecipeDataLoadHostedService> logger
)
{
_framework = framework;
_adapter = adapter;
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
return _framework.RunOnFrameworkThread(() =>
{
try
{
_adapter.LoadInternal(cancellationToken);
}
catch (System.Exception ex)
{
_logger.LogError(ex, "Anvil RecipeData load failed");
throw;
}
});
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -0,0 +1,43 @@
// IHostedService adapter that hands every registered ISelfTestStep to
// Dalamud's ISelfTestRegistry once the host is up. Resolving the steps
// through DI keeps the plugin entry point free of static Plugin.X
// lookups - the DI container collects each step that was registered as
// IEnumerable<ISelfTestStep>.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.Plugin.SelfTest;
using Dalamud.Plugin.Services;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Anvil.Hosting;
internal sealed class SelfTestRegistrationHostedService : IHostedService
{
private readonly ISelfTestRegistry _registry;
private readonly IEnumerable<ISelfTestStep> _steps;
private readonly ILogger<SelfTestRegistrationHostedService> _logger;
public SelfTestRegistrationHostedService(
ISelfTestRegistry registry,
IEnumerable<ISelfTestStep> steps,
ILogger<SelfTestRegistrationHostedService> logger
)
{
_registry = registry;
_steps = steps;
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
var stepList = new List<ISelfTestStep>(_steps);
_registry.RegisterTestSteps(stepList);
_logger.LogInformation("Anvil registered {Count} self-test step(s)", stepList.Count);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -0,0 +1,65 @@
// Bridge from Microsoft.Extensions.Logging.ILogger to Dalamud's IPluginLog.
// One DalamudLogger instance per category, cached in DalamudLoggingProvider.
// Filtering is delegated to /xllog - every level falls through to the
// matching IPluginLog call, the plugin side stays stateless.
using System;
using System.Text;
using Dalamud.Plugin.Services;
using Microsoft.Extensions.Logging;
namespace Anvil.Infrastructure.Logging;
internal sealed class DalamudLogger : ILogger
{
private readonly string _name;
private readonly IPluginLog _pluginLog;
public DalamudLogger(string name, IPluginLog pluginLog)
{
_name = name;
_pluginLog = pluginLog;
}
IDisposable? ILogger.BeginScope<TState>(TState state) => default!;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter
)
{
if (!IsEnabled(logLevel))
return;
if ((int)logLevel <= (int)LogLevel.Information)
{
_pluginLog.Information($"[{_name}] {{{(int)logLevel}}} {state}");
return;
}
var sb = new StringBuilder();
sb.Append($"[{_name}] {{{(int)logLevel}}} {state} {exception?.Message}");
if (!string.IsNullOrWhiteSpace(exception?.StackTrace))
sb.AppendLine(exception.StackTrace);
var inner = exception?.InnerException;
while (inner != null)
{
sb.AppendLine($"InnerException {inner}: {inner.Message}");
sb.AppendLine(inner.StackTrace);
inner = inner.InnerException;
}
if (logLevel == LogLevel.Warning)
_pluginLog.Warning(sb.ToString());
else if (logLevel == LogLevel.Error)
_pluginLog.Error(sb.ToString());
else
_pluginLog.Fatal(sb.ToString());
}
}
@@ -0,0 +1,75 @@
// ILoggerProvider that hands out DalamudLogger instances and emits the
// Anvil bootstrap banner once per plugin load. Category names are
// normalised (15-char left-pad or ellipsis trim) so /xllog columns stay
// aligned across services.
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using Dalamud.Plugin.Services;
using Microsoft.Extensions.Logging;
namespace Anvil.Infrastructure.Logging;
[ProviderAlias("Dalamud")]
internal sealed class DalamudLoggingProvider : ILoggerProvider
{
// Hellion Forge Bronze (#C2410C) mixed into the bootstrap fingerprint
// so a casual fork of the file still announces its origin in /xllog.
private const string AnvilMarker = "AnvilForgeBronzeC2410C";
private readonly ConcurrentDictionary<string, DalamudLogger> _loggers = new(
StringComparer.OrdinalIgnoreCase
);
private readonly IPluginLog _pluginLog;
public DalamudLoggingProvider(IPluginLog pluginLog)
{
_pluginLog = pluginLog;
EmitBootstrapBanner();
}
private void EmitBootstrapBanner()
{
var version =
typeof(DalamudLoggingProvider).Assembly.GetName().Version?.ToString() ?? "0.0.0";
var fingerprint = ComputeFingerprint(version);
_pluginLog.Information(
$"Anvil DI-Logger bootstrap v{version} fingerprint={fingerprint} (Hellion Forge)"
);
}
private static string ComputeFingerprint(string version)
{
var seed = Encoding.UTF8.GetBytes($"{AnvilMarker}-{version}");
var hash = SHA256.HashData(seed);
var sb = new StringBuilder(8);
for (var i = 0; i < 4; i++)
sb.Append(hash[i].ToString("x2"));
return sb.ToString();
}
public ILogger CreateLogger(string categoryName)
{
var catName = categoryName.Split(".", StringSplitOptions.RemoveEmptyEntries).Last();
if (catName.Length > 15)
catName = string.Concat(
catName.AsSpan(0, 6),
"...",
catName.AsSpan(catName.Length - 6, 6)
);
else
catName = catName.PadLeft(15);
return _loggers.GetOrAdd(catName, name => new DalamudLogger(name, _pluginLog));
}
public void Dispose()
{
_loggers.Clear();
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,28 @@
// One-liner extension that wires DalamudLoggingProvider into the
// ILoggingBuilder. ClearProviders kills the default console output (no
// Dalamud plugin runs a console anyway); TryAddEnumerable keeps the
// registration idempotent if a caller adds it twice.
using Dalamud.Plugin.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
namespace Anvil.Infrastructure.Logging;
internal static class DalamudLoggingProviderExtensions
{
public static ILoggingBuilder AddDalamudLogging(
this ILoggingBuilder builder,
IPluginLog pluginLog
)
{
builder.ClearProviders();
builder.Services.TryAddEnumerable(
ServiceDescriptor.Singleton<ILoggerProvider, DalamudLoggingProvider>(
_ => new DalamudLoggingProvider(pluginLog)
)
);
return builder;
}
}
+51
View File
@@ -0,0 +1,51 @@
// Anvil plugin entry. Implements IAsyncDalamudPlugin so Dalamud awaits
// LoadAsync before considering the plugin loaded. The constructor wires
// the generic-host DI container synchronously - Dalamud's IoC fills the
// five Dalamud services as ctor args (Lightless pattern), the bundle is
// handed to PluginHostFactory, and PluginLifecycle takes it from there.
//
// v0.1.0 is intentionally thin. Module 02+ extends PluginHostFactory's
// services collection; this file does not need to change.
using System;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Anvil;
public sealed class Plugin : IAsyncDalamudPlugin
{
private readonly PluginLifecycle _lifecycle;
public Plugin(
IDalamudPluginInterface pluginInterface,
IPluginLog pluginLog,
IDataManager dataManager,
IFramework framework,
ISelfTestRegistry selfTestRegistry
)
{
var dependencies = new PluginHostDependencies(
pluginInterface,
pluginLog,
dataManager,
framework,
selfTestRegistry
);
var host = PluginHostFactory.Build(this, dependencies);
_lifecycle = host.Services.GetRequiredService<PluginLifecycle>();
_lifecycle.Host = host;
}
public Task LoadAsync(CancellationToken cancellationToken) =>
_lifecycle.LoadAsync(cancellationToken);
public async ValueTask DisposeAsync()
{
await _lifecycle.DisposeAsync().ConfigureAwait(false);
}
}
+17
View File
@@ -0,0 +1,17 @@
// Bundle of every Dalamud service Anvil v0.1.0 needs. The Plugin entry
// constructs one of these from its [PluginService] properties and hands
// it to PluginHostFactory.Build. Keeping the bundle as a record means
// adding a service for module 02+ is a one-line schema change.
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
namespace Anvil;
internal sealed record PluginHostDependencies(
IDalamudPluginInterface PluginInterface,
IPluginLog PluginLog,
IDataManager DataManager,
IFramework Framework,
ISelfTestRegistry SelfTestRegistry
);
+101
View File
@@ -0,0 +1,101 @@
// Builds the generic-host DI container for Anvil. v0.1.0 is intentionally
// thin - module 01 (RecipeData) plus the bootstrap glue. Module 02+ will
// extend the Block B / Block C sections, not replace them.
//
// Every service registration goes through a factory lambda. The default
// AddSingleton<T>() overload reflects public constructors, and Anvil
// follows the Goatcorp / Dalamud convention of internal-sealed types -
// the activator would throw on the first resolve otherwise (HellionChat
// v1.5.0 C0 smoke documented this). The lambda compiles inside Anvil's
// namespace where internal access works.
using System.Collections.Generic;
using Anvil.Hosting;
using Anvil.Infrastructure.Logging;
using Anvil.RecipeData;
using Anvil.RecipeData.Internal;
using Anvil.SelfTest;
using Dalamud.Plugin;
using Dalamud.Plugin.SelfTest;
using Dalamud.Plugin.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Anvil;
internal static class PluginHostFactory
{
public static IHost Build(Plugin plugin, PluginHostDependencies dependencies)
{
return new HostBuilder()
.UseContentRoot(dependencies.PluginInterface.ConfigDirectory.FullName)
.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.AddDalamudLogging(dependencies.PluginLog);
logging.SetMinimumLevel(LogLevel.Trace);
})
.ConfigureServices(services => ConfigureServices(services, plugin, dependencies))
.Build();
}
private static void ConfigureServices(
IServiceCollection services,
Plugin plugin,
PluginHostDependencies dependencies
)
{
// ---- Block A: Dalamud services ----
services.AddSingleton(dependencies);
services.AddSingleton(dependencies.PluginInterface);
services.AddSingleton(dependencies.PluginLog);
services.AddSingleton(dependencies.DataManager);
services.AddSingleton(dependencies.Framework);
services.AddSingleton(dependencies.SelfTestRegistry);
// Self-reference. Lets services that genuinely need the plugin
// back-ref (later modules; v0.1.0 has none) reach it without a
// Plugin.Instance static.
services.AddSingleton(plugin);
services.AddSingleton<PluginLifecycle>(sp => new PluginLifecycle(
sp.GetRequiredService<IFramework>(),
sp.GetRequiredService<Plugin>()
));
// ---- Block B: Anvil singletons ----
services.AddSingleton<RecipeDataCatalog>(_ => new RecipeDataCatalog());
services.AddSingleton<LuminaRecipeAdapter>(sp => new LuminaRecipeAdapter(
sp.GetRequiredService<IDataManager>(),
sp.GetRequiredService<ILogger<LuminaRecipeAdapter>>(),
sp.GetRequiredService<RecipeDataCatalog>()
));
// ---- Block C: SelfTest steps (collected as IEnumerable<ISelfTestStep>) ----
services.AddSingleton<ISelfTestStep, RecipeDataAdapterLoadStep>(
sp => new RecipeDataAdapterLoadStep(
sp.GetRequiredService<RecipeDataCatalog>(),
sp.GetRequiredService<ILogger<RecipeDataAdapterLoadStep>>()
)
);
// ---- Block D: Hosted services (init order is registration order) ----
// RecipeData adapter runs first so the catalog is populated before
// the self-test registry sees its steps.
services.AddHostedService<RecipeDataLoadHostedService>(
sp => new RecipeDataLoadHostedService(
sp.GetRequiredService<IFramework>(),
sp.GetRequiredService<LuminaRecipeAdapter>(),
sp.GetRequiredService<ILogger<RecipeDataLoadHostedService>>()
)
);
services.AddHostedService<SelfTestRegistrationHostedService>(
sp => new SelfTestRegistrationHostedService(
sp.GetRequiredService<ISelfTestRegistry>(),
sp.GetRequiredService<IEnumerable<ISelfTestStep>>(),
sp.GetRequiredService<ILogger<SelfTestRegistrationHostedService>>()
)
);
}
}
+133
View File
@@ -0,0 +1,133 @@
// Owns the generic-host startup and the framework-thread shutdown for
// Anvil. Plugin.ctor builds the host synchronously and hands it to this
// class via the Host property so LoadAsync only deals with start-up
// orchestration. DisposeAsync trusts the container for service disposal
// (Container disposes singletons in reverse registration order on its own
// thread); we only marshal the Host.Dispose call to the framework thread
// because some services touch game state in their Dispose paths.
using System;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.Plugin.Services;
using Microsoft.Extensions.Hosting;
namespace Anvil;
internal sealed class PluginLifecycle : IAsyncDisposable
{
private readonly IFramework _framework;
private readonly Plugin _plugin;
private int _disposeStarted;
private bool _hostStartRequested;
public PluginLifecycle(IFramework framework, Plugin plugin)
{
_framework = framework;
_plugin = plugin;
}
// Filled by Plugin.ctor immediately after PluginHostFactory.Build,
// before LoadAsync runs. Nullable to keep the property uninitialised
// when the DI container resolves PluginLifecycle.
public IHost? Host { get; set; }
public async Task LoadAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
_hostStartRequested = true;
await Host!.StartAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
try
{
await DisposeAsync().ConfigureAwait(false);
}
catch
{
// Swallow secondary dispose failure - the original load throw wins.
}
throw;
}
}
public async ValueTask DisposeAsync()
{
// Idempotency guard - Dalamud may fire DisposeAsync twice in a reload race.
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
return;
Exception? failure = null;
if (_hostStartRequested && Host is not null)
failure = await CaptureFailureAsync(failure, () => Host.StopAsync())
.ConfigureAwait(false);
failure = await DisposeHostOnFrameworkThreadAsync(failure).ConfigureAwait(false);
ThrowIfFailed(failure);
}
private async Task<Exception?> DisposeHostOnFrameworkThreadAsync(Exception? failure)
{
try
{
await _framework
.RunOnFrameworkThread(() =>
{
failure = CaptureFailure(failure, () => Host?.Dispose());
})
.ConfigureAwait(false);
}
catch (Exception ex)
{
failure ??= ex;
}
return failure;
}
private static Exception? CaptureFailure(Exception? failure, Action action)
{
try
{
action();
}
catch (Exception ex)
{
failure ??= ex;
}
return failure;
}
private static async ValueTask<Exception?> CaptureFailureAsync(
Exception? failure,
Func<Task> action
)
{
try
{
await action().ConfigureAwait(false);
}
catch (Exception ex)
{
failure ??= ex;
}
return failure;
}
private static void ThrowIfFailed(Exception? failure)
{
if (failure is not null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
}
+171
View File
@@ -0,0 +1,171 @@
// /xlperf verification that the LuminaRecipeAdapter populated the catalog.
// All nine pass criteria come from spec §4.1. The step is patient: while
// the adapter is still loading (IsLoaded == false) the step returns
// Waiting; once the catalog is populated, every criterion is checked in
// a single frame.
using System.Linq;
using Anvil.RecipeData;
using Dalamud.Plugin.SelfTest;
using Microsoft.Extensions.Logging;
namespace Anvil.SelfTest;
internal sealed class RecipeDataAdapterLoadStep : ISelfTestStep
{
// Spec §4.1 criterion #8: BasicSynthesis CRP variant Lumina RowId.
// Cross-verified against ffxiv-datamining CraftAction.csv row 100001.
private const uint BasicSynthesisCrpRowId = 100001;
private const uint CarpenterClassJobId = 8;
private readonly RecipeDataCatalog _catalog;
private readonly ILogger<RecipeDataAdapterLoadStep> _logger;
public RecipeDataAdapterLoadStep(
RecipeDataCatalog catalog,
ILogger<RecipeDataAdapterLoadStep> logger
)
{
_catalog = catalog;
_logger = logger;
}
public string Name =>
Localization.AnvilStrings.SelfTest_RecipeDataAdapterLoad_Name
?? "Anvil: RecipeData adapter load";
public SelfTestStepResult RunStep()
{
if (!_catalog.IsLoaded)
return SelfTestStepResult.Waiting;
var failures = 0;
// #1: IsLoaded must be true. Implied by the early-return above; the
// check stays explicit so the criterion shows up in the log on a
// load that flipped the flag without populating anything.
if (!_catalog.IsLoaded)
{
_logger.LogError("RecipeDataCatalog.IsLoaded is false after StartAsync.");
failures++;
}
// #2: Recipe count sanity floor.
if (_catalog.RecipeCount <= 0)
{
_logger.LogError(
"RecipeDataCatalog.RecipeCount is {Count}; expected > 0.",
_catalog.RecipeCount
);
failures++;
}
// #3: Action count in the catalog. Spec says 30-80 as a generous
// range; tightening will follow once /xldev pins the exact patch
// value.
if (_catalog.ActionCount is < 30 or > 80)
{
_logger.LogError(
"RecipeDataCatalog.ActionCount is {Count}; expected 30 <= n <= 80.",
_catalog.ActionCount
);
failures++;
}
// #4: Buff catalog count exact.
if (_catalog.BuffsByKind.Count != 14)
{
_logger.LogError(
"RecipeDataCatalog.BuffsByKind.Count is {Count}; expected 14.",
_catalog.BuffsByKind.Count
);
failures++;
}
// #5: Condition catalog count exact.
if (_catalog.ConditionsByKind.Count != 11)
{
_logger.LogError(
"RecipeDataCatalog.ConditionsByKind.Count is {Count}; expected 11.",
_catalog.ConditionsByKind.Count
);
failures++;
}
// #6: Food sanity floor.
if (_catalog.Foods.Count < 30)
{
_logger.LogError(
"RecipeDataCatalog.Foods.Count is {Count}; expected >= 30.",
_catalog.Foods.Count
);
failures++;
}
// #7: Medicine sanity floor.
if (_catalog.Medicines.Count < 5)
{
_logger.LogError(
"RecipeDataCatalog.Medicines.Count is {Count}; expected >= 5.",
_catalog.Medicines.Count
);
failures++;
}
// #8: BasicSynthesis CRP RowId sanity probe.
if (!_catalog.ActionsByKind.TryGetValue(AnvilActionKind.BasicSynthesis, out var basic))
{
_logger.LogError("ActionsByKind[BasicSynthesis] is missing.");
failures++;
}
else
{
if (basic.RowIdByClassJob.Count != 8)
{
_logger.LogError(
"BasicSynthesis.RowIdByClassJob.Count is {Count}; expected 8.",
basic.RowIdByClassJob.Count
);
failures++;
}
if (
!basic.RowIdByClassJob.TryGetValue(CarpenterClassJobId, out var crpRowId)
|| crpRowId != BasicSynthesisCrpRowId
)
{
_logger.LogError(
"BasicSynthesis.RowIdByClassJob[8] is {Actual}; expected {Expected}.",
basic.RowIdByClassJob.TryGetValue(CarpenterClassJobId, out var probe)
? probe
: 0,
BasicSynthesisCrpRowId
);
failures++;
}
}
// #9: Cosmic-surface sanity. v0.1.0 ships with SH-15 option B (all
// MissionHas* flags forced false), so the WARN is expected whenever
// any Cosmic recipe is in the catalog. The step still returns Pass
// because the catalog itself is not broken - the Cosmic sub-surface
// is intentionally disabled.
var hasCosmicRecipe = _catalog.RecipesById.Values.Any(r => r.IsCosmic);
if (hasCosmicRecipe)
{
var anyMissionFlag = _catalog
.RecipesById.Values.Where(r => r.IsCosmic)
.Any(r => r.MissionHasMaterialMiracle || r.MissionHasSteadyHand);
if (!anyMissionFlag)
{
_logger.LogWarning(
"Cosmic recipes loaded, but no MissionHas* flags resolved - "
+ "Cosmic-Surface silent-degraded. Expected under SH-15 option B (v0.1.0)."
);
}
}
return failures == 0 ? SelfTestStepResult.Pass : SelfTestStepResult.Fail;
}
public void CleanUp() { }
}