diff --git a/Anvil/Hosting/RecipeDataLoadHostedService.cs b/Anvil/Hosting/RecipeDataLoadHostedService.cs new file mode 100644 index 0000000..b75493c --- /dev/null +++ b/Anvil/Hosting/RecipeDataLoadHostedService.cs @@ -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 _logger; + + public RecipeDataLoadHostedService( + IFramework framework, + LuminaRecipeAdapter adapter, + ILogger 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; +} diff --git a/Anvil/Hosting/SelfTestRegistrationHostedService.cs b/Anvil/Hosting/SelfTestRegistrationHostedService.cs new file mode 100644 index 0000000..f328c64 --- /dev/null +++ b/Anvil/Hosting/SelfTestRegistrationHostedService.cs @@ -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. + +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 _steps; + private readonly ILogger _logger; + + public SelfTestRegistrationHostedService( + ISelfTestRegistry registry, + IEnumerable steps, + ILogger logger + ) + { + _registry = registry; + _steps = steps; + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + var stepList = new List(_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; +} diff --git a/Anvil/Infrastructure/Logging/DalamudLogger.cs b/Anvil/Infrastructure/Logging/DalamudLogger.cs new file mode 100644 index 0000000..f379796 --- /dev/null +++ b/Anvil/Infrastructure/Logging/DalamudLogger.cs @@ -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 state) => default!; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func 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()); + } +} diff --git a/Anvil/Infrastructure/Logging/DalamudLoggingProvider.cs b/Anvil/Infrastructure/Logging/DalamudLoggingProvider.cs new file mode 100644 index 0000000..161dda7 --- /dev/null +++ b/Anvil/Infrastructure/Logging/DalamudLoggingProvider.cs @@ -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 _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); + } +} diff --git a/Anvil/Infrastructure/Logging/DalamudLoggingProviderExtensions.cs b/Anvil/Infrastructure/Logging/DalamudLoggingProviderExtensions.cs new file mode 100644 index 0000000..65de549 --- /dev/null +++ b/Anvil/Infrastructure/Logging/DalamudLoggingProviderExtensions.cs @@ -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( + _ => new DalamudLoggingProvider(pluginLog) + ) + ); + return builder; + } +} diff --git a/Anvil/Plugin.cs b/Anvil/Plugin.cs new file mode 100644 index 0000000..8a29e43 --- /dev/null +++ b/Anvil/Plugin.cs @@ -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(); + _lifecycle.Host = host; + } + + public Task LoadAsync(CancellationToken cancellationToken) => + _lifecycle.LoadAsync(cancellationToken); + + public async ValueTask DisposeAsync() + { + await _lifecycle.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/Anvil/PluginHostDependencies.cs b/Anvil/PluginHostDependencies.cs new file mode 100644 index 0000000..a8c0924 --- /dev/null +++ b/Anvil/PluginHostDependencies.cs @@ -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 +); diff --git a/Anvil/PluginHostFactory.cs b/Anvil/PluginHostFactory.cs new file mode 100644 index 0000000..216d6e1 --- /dev/null +++ b/Anvil/PluginHostFactory.cs @@ -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() 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(sp => new PluginLifecycle( + sp.GetRequiredService(), + sp.GetRequiredService() + )); + + // ---- Block B: Anvil singletons ---- + services.AddSingleton(_ => new RecipeDataCatalog()); + services.AddSingleton(sp => new LuminaRecipeAdapter( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService() + )); + + // ---- Block C: SelfTest steps (collected as IEnumerable) ---- + services.AddSingleton( + sp => new RecipeDataAdapterLoadStep( + sp.GetRequiredService(), + sp.GetRequiredService>() + ) + ); + + // ---- 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( + sp => new RecipeDataLoadHostedService( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>() + ) + ); + services.AddHostedService( + sp => new SelfTestRegistrationHostedService( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService>() + ) + ); + } +} diff --git a/Anvil/PluginLifecycle.cs b/Anvil/PluginLifecycle.cs new file mode 100644 index 0000000..f3a2988 --- /dev/null +++ b/Anvil/PluginLifecycle.cs @@ -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 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 CaptureFailureAsync( + Exception? failure, + Func 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(); + } +} diff --git a/Anvil/SelfTest/RecipeDataAdapterLoadStep.cs b/Anvil/SelfTest/RecipeDataAdapterLoadStep.cs new file mode 100644 index 0000000..e2603c6 --- /dev/null +++ b/Anvil/SelfTest/RecipeDataAdapterLoadStep.cs @@ -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 _logger; + + public RecipeDataAdapterLoadStep( + RecipeDataCatalog catalog, + ILogger 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() { } +}