246 lines
10 KiB
C#
246 lines
10 KiB
C#
using System;
|
|
using Dalamud.Plugin;
|
|
using Dalamud.Plugin.Ipc;
|
|
using Dalamud.Plugin.Services;
|
|
using Newtonsoft.Json;
|
|
|
|
namespace HellionChat.Integrations;
|
|
|
|
// We pull Newtonsoft.Json into this single file for IPC compatibility:
|
|
// Honorific serialises its TitleData with Newtonsoft (see
|
|
// Honorific-master/IpcProvider.cs:9 and CustomTitle.cs:12). Using the
|
|
// same library guarantees identical handling of System.Numerics.Vector3?
|
|
// and the enum fields we ignore. Newtonsoft is a transitive dependency
|
|
// via Dalamud, so no new NuGet entry is needed. The rest of HellionChat
|
|
// keeps using System.Text.Json.
|
|
internal sealed class HonorificService : IDisposable
|
|
{
|
|
private const string IpcNamespace = "Honorific";
|
|
|
|
// Major version of the Honorific IPC contract HellionChat is built against.
|
|
// Used both by the runtime compatibility check and by the settings tab when
|
|
// it tells the user which major version we expected, so the literal lives
|
|
// in exactly one place.
|
|
internal const uint ExpectedApiMajor = 3;
|
|
|
|
// IPC gates we subscribe to. Keep them as fields so Dispose can
|
|
// unsubscribe the same instances we subscribed in the constructor.
|
|
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 IPluginLog _log;
|
|
private readonly IFramework _framework;
|
|
private bool _versionWarningLogged;
|
|
|
|
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, IPluginLog log, IFramework framework)
|
|
{
|
|
_framework = framework;
|
|
_log = log;
|
|
|
|
// Dalamud caches gate objects per-name for the lifetime of the
|
|
// plugin interface, so we can register subscribers even when
|
|
// Honorific isn't loaded yet — the gate just won't fire. Calling
|
|
// InvokeFunc before Honorific is up will throw, which is why the
|
|
// initial pull below is wrapped in try-catch.
|
|
//
|
|
// Thread-context: plugin constructors run on Dalamud's plugin-loader
|
|
// thread, NOT the framework thread. Honorific's IPC handlers read
|
|
// ObjectTable.LocalPlayer (Honorific IpcProvider.cs:61), which throws
|
|
// "Not on main thread!" outside the framework thread. If Honorific is
|
|
// already loaded when HellionChat starts, a synchronous InvokeFunc
|
|
// here would surface that exception, the broad catch below would
|
|
// mark IsAvailable=false, and OnTitleChanged's `if (!IsAvailable)`
|
|
// gate would block every subsequent title update. We therefore
|
|
// schedule the initial pull onto the framework thread via
|
|
// IFramework.RunOnFrameworkThread so the IPC call sees the right
|
|
// thread context.
|
|
_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()
|
|
{
|
|
// Honorific may already be gone by the time we dispose. Wrap each
|
|
// unsubscribe so a missing gate doesn't prevent the others from
|
|
// unsubscribing — leaking even one subscription leaves a callback
|
|
// alive that captures `this`, which keeps the whole service alive
|
|
// and breaks plugin reload.
|
|
TryUnsubscribe(() => _localCharacterTitleChanged.Unsubscribe(OnTitleChanged));
|
|
TryUnsubscribe(() => _ready.Unsubscribe(OnReady));
|
|
TryUnsubscribe(() => _disposing.Unsubscribe(OnDisposing));
|
|
}
|
|
|
|
private void TryInitialPull()
|
|
{
|
|
try
|
|
{
|
|
var version = _apiVersion.InvokeFunc();
|
|
DetectedApiVersion = version;
|
|
|
|
if (!IsApiVersionCompatible(version))
|
|
{
|
|
if (!_versionWarningLogged)
|
|
{
|
|
_log.Warning(
|
|
"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;
|
|
// Pull the current title once at startup; from here on we rely
|
|
// on LocalCharacterTitleChanged events.
|
|
var json = _getLocalCharacterTitle.InvokeFunc();
|
|
CurrentTitle = ParseTitleJson(json);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Honorific isn't installed or hasn't initialised yet. The Ready
|
|
// event will give us a second chance later. Log at Debug so
|
|
// users without Honorific don't see noise on every reload.
|
|
_log.Debug(ex, "Honorific not available at HellionChat startup; awaiting Ready.");
|
|
IsAvailable = false;
|
|
CurrentTitle = null;
|
|
}
|
|
}
|
|
|
|
// Honorific fires LocalCharacterTitleChanged through its nameplate hook
|
|
// (Honorific-master/Plugin.cs:665), which means we get title updates on
|
|
// character switches automatically as soon as the new character is
|
|
// rendered. While the user is in the character-select menu, HellionChat's
|
|
// window is hidden by default via HideWhenNotLoggedIn (Configuration.cs:152),
|
|
// so the stale-title window between logout and login isn't user-visible.
|
|
private void OnTitleChanged(string json)
|
|
{
|
|
// Don't update cached state when we've already decided we can't trust
|
|
// Honorific (e.g. version mismatch). Subscription stays live in case a
|
|
// compatible Honorific reloads, in which case Ready triggers TryInitialPull
|
|
// and sets IsAvailable back to true.
|
|
if (!IsAvailable) return;
|
|
CurrentTitle = ParseTitleJson(json);
|
|
}
|
|
|
|
private void OnReady()
|
|
{
|
|
// Honorific loaded after HellionChat; redo the version check and
|
|
// initial pull. Idempotent on purpose — Honorific can fire Ready
|
|
// more than once across reloads.
|
|
//
|
|
// Honorific's NotifyReady may dispatch from any thread, and
|
|
// TryInitialPull eventually calls IPC handlers that read
|
|
// ObjectTable.LocalPlayer — same "Not on main thread!" hazard as
|
|
// the constructor path. Schedule onto the framework thread.
|
|
_framework.RunOnFrameworkThread(TryInitialPull);
|
|
}
|
|
|
|
private void OnDisposing()
|
|
{
|
|
// Honorific is unloading. Drop our cached state so the header
|
|
// hides on the next frame; subscriptions stay registered because
|
|
// the gates may come back later (Honorific reload).
|
|
//
|
|
// Race-note: Honorific's NotifyDisposing calls ChangedLocalCharacterTitle(null)
|
|
// BEFORE SendMessage on the Disposing gate (IpcProvider.cs:109-111),
|
|
// so OnTitleChanged is expected to fire first and already null out
|
|
// CurrentTitle. We re-clear here as belt-and-braces; should the
|
|
// ordering ever flip, ShouldRenderSlot would still gate on IsAvailable.
|
|
CurrentTitle = null;
|
|
IsAvailable = false;
|
|
DetectedApiVersion = null;
|
|
}
|
|
|
|
private void TryUnsubscribe(Action unsubscribe)
|
|
{
|
|
try
|
|
{
|
|
unsubscribe();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.Debug(ex, "Honorific unsubscribe failed (likely already gone).");
|
|
}
|
|
}
|
|
|
|
// Threading note: Dalamud fires IPC events on the framework thread and
|
|
// ImGui renders on the framework thread, so OnTitleChanged and the
|
|
// render path that reads CurrentTitle never race — OnTitleChanged is
|
|
// safe to keep direct (no RunOnFrameworkThread wrap needed) because
|
|
// LocalCharacterTitleChanged delivery is framework-thread by Dalamud
|
|
// contract. If a future change moves either side onto a worker thread,
|
|
// switch to volatile/Interlocked for the CurrentTitle field.
|
|
//
|
|
// The constructor's initial pull and OnReady, on the other hand, are
|
|
// explicitly scheduled via IFramework.RunOnFrameworkThread because
|
|
// they run outside that contract: the constructor executes on the
|
|
// plugin-loader thread, and Honorific's NotifyReady can dispatch from
|
|
// any thread. Both call paths eventually invoke IPC handlers that read
|
|
// ObjectTable.LocalPlayer, which throws "Not on main thread!" off the
|
|
// framework thread — see the constructor comment block for context.
|
|
//
|
|
// Divergence from ChatTwo/Ipc/ExtraChat.cs: that file uses `volatile`
|
|
// on its state fields out of caution. We don't, because the framework-
|
|
// thread delivery is the documented Dalamud contract. If the two files
|
|
// ever need to share a threading audit, this is the place to revisit.
|
|
|
|
// --- Pure-logic helpers below; tested via HellionChat.Tests/Integrations. ---
|
|
|
|
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;
|
|
}
|
|
}
|