Files
HellionChat/HellionChat/Integrations/HonorificService.cs
T

218 lines
7.8 KiB
C#

using System;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using Dalamud.Plugin.Services;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace HellionChat.Integrations;
// Newtonsoft.Json is used here for IPC compatibility with Honorific, which
// serialises TitleData with it. It's a transitive Dalamud dependency — no
// new NuGet entry needed. The rest of HellionChat uses System.Text.Json.
internal sealed class HonorificService : IDisposable
{
private const string IpcNamespace = "Honorific";
// Major version of the Honorific IPC contract we're built against.
internal const uint ExpectedApiMajor = 3;
// IPC gates — kept as fields so Dispose can unsubscribe the same instances.
private readonly ICallGateSubscriber<(uint, uint)> _apiVersion;
private readonly ICallGateSubscriber<string> _getLocalCharacterTitle;
private readonly ICallGateSubscriber<string, object> _localCharacterTitleChanged;
private readonly ICallGateSubscriber<object> _ready;
private readonly ICallGateSubscriber<object> _disposing;
private readonly ILogger<HonorificService> _logger;
private readonly IFramework _framework;
private bool _versionWarningLogged;
// Thread: framework only — IPC delivery + ImGui render both run there.
public HonorificTitleData? CurrentTitle { get; private set; }
public bool IsAvailable { get; private set; }
public (uint Major, uint Minor)? DetectedApiVersion { get; private set; }
public HonorificService(
IDalamudPluginInterface pluginInterface,
ILogger<HonorificService> logger,
IFramework framework
)
{
_framework = framework;
_logger = logger;
// Gate objects are cached per-name by Dalamud and safe to register
// before Honorific loads — they just won't fire until it does.
// Initial pull is scheduled on the framework thread because plugin
// constructors run on the loader thread, and Honorific's IPC handlers
// read ObjectTable.LocalPlayer which throws off the framework thread.
_apiVersion = pluginInterface.GetIpcSubscriber<(uint, uint)>($"{IpcNamespace}.ApiVersion");
_getLocalCharacterTitle = pluginInterface.GetIpcSubscriber<string>(
$"{IpcNamespace}.GetLocalCharacterTitle"
);
_localCharacterTitleChanged = pluginInterface.GetIpcSubscriber<string, object>(
$"{IpcNamespace}.LocalCharacterTitleChanged"
);
_ready = pluginInterface.GetIpcSubscriber<object>($"{IpcNamespace}.Ready");
_disposing = pluginInterface.GetIpcSubscriber<object>($"{IpcNamespace}.Disposing");
_localCharacterTitleChanged.Subscribe(OnTitleChanged);
_ready.Subscribe(OnReady);
_disposing.Subscribe(OnDisposing);
_framework.RunOnFrameworkThread(TryInitialPull);
}
public void Dispose()
{
// Wrap each unsubscribe — a missing gate must not block the others.
// Leaking a subscription keeps this service alive across plugin reloads.
TryUnsubscribe(() => _localCharacterTitleChanged.Unsubscribe(OnTitleChanged));
TryUnsubscribe(() => _ready.Unsubscribe(OnReady));
TryUnsubscribe(() => _disposing.Unsubscribe(OnDisposing));
}
// Thread: framework (scheduled from ctor and OnReady).
private void TryInitialPull()
{
try
{
var version = _apiVersion.InvokeFunc();
DetectedApiVersion = version;
if (!IsApiVersionCompatible(version))
{
if (!_versionWarningLogged)
{
_logger.LogWarning(
"Honorific API version mismatch — expected major 3, "
+ "found {Major}.{Minor}. Disabling Honorific integration.",
version.Item1,
version.Item2
);
_versionWarningLogged = true;
}
IsAvailable = false;
return;
}
IsAvailable = true;
_versionWarningLogged = false;
var json = _getLocalCharacterTitle.InvokeFunc();
CurrentTitle = ParseTitleJson(json);
}
catch (Exception ex)
{
// Honorific not installed or not yet initialised — Ready will retry.
_logger.LogDebug(ex, "Honorific not available at HellionChat startup; awaiting Ready.");
IsAvailable = false;
CurrentTitle = null;
}
}
// Thread: framework (Dalamud IPC delivery contract).
private void OnTitleChanged(string json)
{
// Skip updates on version mismatch; subscription stays live for reload.
if (!IsAvailable)
return;
CurrentTitle = ParseTitleJson(json);
}
// Thread: any (Honorific dispatches NotifyReady from its own thread).
private void OnReady()
{
_framework.RunOnFrameworkThread(TryInitialPull);
}
// Thread: framework (IPC delivery contract); idempotent — Disposing fires once.
private void OnDisposing()
{
// Honorific unloading — clear cached state so the header hides next frame.
// Subscriptions stay registered in case Honorific reloads.
// CurrentTitle is already nulled by OnTitleChanged before this fires,
// re-clearing here is belt-and-braces.
CurrentTitle = null;
IsAvailable = false;
DetectedApiVersion = null;
}
// Thread: framework (called from Dispose, which runs on the framework
// cleanup block in Plugin.DisposeAsync).
private void TryUnsubscribe(Action unsubscribe)
{
try
{
unsubscribe();
}
catch (Exception ex)
{
// Warning not Debug — a silent unsubscribe failure leaks a live
// subscription across plugin reloads.
_logger.LogWarning(
ex,
"Honorific unsubscribe failed (likely API break or gate already gone)."
);
}
}
internal static HonorificTitleData? ParseTitleJson(string json)
{
if (string.IsNullOrEmpty(json))
return null;
try
{
return JsonConvert.DeserializeObject<HonorificTitleData>(json);
}
catch (JsonException)
{
return null;
}
}
internal static bool IsApiVersionCompatible((uint Major, uint Minor) apiVersion)
{
return apiVersion.Major == ExpectedApiMajor;
}
internal static bool ShouldRenderSlot(
bool toggleEnabled,
bool isAvailable,
HonorificTitleData? title
)
{
if (!toggleEnabled)
return false;
if (!isAvailable)
return false;
if (title is null)
return false;
if (title.IsOriginal)
return false;
if (string.IsNullOrEmpty(title.Title))
return false;
return true;
}
// Test seam: the three status fields are private-set and IPC-driven, which a
// headless /xlperf run can't reach (Honorific is usually absent in tests).
// Callers MUST snapshot the prior values and restore them in CleanUp, and
// MUST drive Set -> Draw -> Assert within ONE synchronous RunStep (never
// Waiting between Set and Assert) — a between-frame OnReady/OnTitleChanged
// would otherwise clobber this state and a CleanUp restore can't un-corrupt a
// mid-flight assertion. (A FontsReady precondition gate returning Waiting
// BEFORE the snapshot/Set is fine — nothing is mutated yet.)
internal void TestOnly_SetState(
bool isAvailable,
(uint Major, uint Minor)? detectedApiVersion,
HonorificTitleData? title
)
{
IsAvailable = isAvailable;
DetectedApiVersion = detectedApiVersion;
CurrentTitle = title;
}
}