218 lines
7.8 KiB
C#
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;
|
|
}
|
|
}
|