using Dalamud.Plugin; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; using Penumbra.Api.Enums; using Penumbra.Api.Helpers; using Penumbra.Api.IpcSubscribers; using System.Collections.Concurrent; namespace LightlessSync.Interop.Ipc; public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCaller { private readonly IDalamudPluginInterface _pi; private readonly DalamudUtilService _dalamudUtil; private readonly LightlessMediator _lightlessMediator; private readonly RedrawManager _redrawManager; private readonly ActorObjectService _actorObjectService; private bool _shownPenumbraUnavailable = false; private string? _penumbraModDirectory; public string? ModDirectory { get => _penumbraModDirectory; private set { if (!string.Equals(_penumbraModDirectory, value, StringComparison.Ordinal)) { _penumbraModDirectory = value; _lightlessMediator.Publish(new PenumbraDirectoryChangedMessage(_penumbraModDirectory)); } } } private readonly ConcurrentDictionary _penumbraRedrawRequests = new(); private readonly ConcurrentDictionary _trackedActors = new(); private readonly EventSubscriber _penumbraDispose; private readonly EventSubscriber _penumbraGameObjectResourcePathResolved; private readonly EventSubscriber _penumbraInit; private readonly EventSubscriber _penumbraModSettingChanged; private readonly EventSubscriber _penumbraObjectIsRedrawn; private readonly AddTemporaryMod _penumbraAddTemporaryMod; private readonly AssignTemporaryCollection _penumbraAssignTemporaryCollection; private readonly ConvertTextureFile _penumbraConvertTextureFile; private readonly CreateTemporaryCollection _penumbraCreateNamedTemporaryCollection; private readonly GetEnabledState _penumbraEnabled; private readonly GetPlayerMetaManipulations _penumbraGetMetaManipulations; private readonly RedrawObject _penumbraRedraw; private readonly DeleteTemporaryCollection _penumbraRemoveTemporaryCollection; private readonly RemoveTemporaryMod _penumbraRemoveTemporaryMod; private readonly GetModDirectory _penumbraResolveModDir; private readonly ResolvePlayerPathsAsync _penumbraResolvePaths; private readonly GetGameObjectResourcePaths _penumbraResourcePaths; //private readonly GetPlayerResourcePaths _penumbraPlayerResourcePaths; private readonly GetCollections _penumbraGetCollections; private readonly ConcurrentDictionary _activeTemporaryCollections = new(); private int _performedInitialCleanup; public IpcCallerPenumbra(ILogger logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, LightlessMediator lightlessMediator, RedrawManager redrawManager, ActorObjectService actorObjectService) : base(logger, lightlessMediator) { _pi = pi; _dalamudUtil = dalamudUtil; _lightlessMediator = lightlessMediator; _redrawManager = redrawManager; _actorObjectService = actorObjectService; _penumbraInit = Initialized.Subscriber(pi, PenumbraInit); _penumbraDispose = Disposed.Subscriber(pi, PenumbraDispose); _penumbraResolveModDir = new GetModDirectory(pi); _penumbraRedraw = new RedrawObject(pi); _penumbraObjectIsRedrawn = GameObjectRedrawn.Subscriber(pi, RedrawEvent); _penumbraGetMetaManipulations = new GetPlayerMetaManipulations(pi); _penumbraRemoveTemporaryMod = new RemoveTemporaryMod(pi); _penumbraAddTemporaryMod = new AddTemporaryMod(pi); _penumbraCreateNamedTemporaryCollection = new CreateTemporaryCollection(pi); _penumbraRemoveTemporaryCollection = new DeleteTemporaryCollection(pi); _penumbraAssignTemporaryCollection = new AssignTemporaryCollection(pi); _penumbraGetCollections = new GetCollections(pi); _penumbraResolvePaths = new ResolvePlayerPathsAsync(pi); _penumbraEnabled = new GetEnabledState(pi); _penumbraModSettingChanged = ModSettingChanged.Subscriber(pi, (change, arg1, arg, b) => { if (change == ModSettingChange.EnableState) _lightlessMediator.Publish(new PenumbraModSettingChangedMessage()); }); _penumbraConvertTextureFile = new ConvertTextureFile(pi); _penumbraResourcePaths = new GetGameObjectResourcePaths(pi); //_penumbraPlayerResourcePaths = new GetPlayerResourcePaths(pi); _penumbraGameObjectResourcePathResolved = GameObjectResourcePathResolved.Subscriber(pi, ResourceLoaded); CheckAPI(); CheckModDirectory(); Mediator.Subscribe(this, (msg) => { _penumbraRedraw.Invoke(msg.Character.ObjectIndex, RedrawType.AfterGPose); }); Mediator.Subscribe(this, (msg) => _shownPenumbraUnavailable = false); Mediator.Subscribe(this, msg => { if (msg.Descriptor.Address != nint.Zero) { _trackedActors[(IntPtr)msg.Descriptor.Address] = 0; } }); Mediator.Subscribe(this, msg => { if (msg.Descriptor.Address != nint.Zero) { _trackedActors.TryRemove((IntPtr)msg.Descriptor.Address, out _); } }); Mediator.Subscribe(this, msg => { if (msg.GameObjectHandler.Address != nint.Zero) { _trackedActors[(IntPtr)msg.GameObjectHandler.Address] = 0; } }); Mediator.Subscribe(this, msg => { if (msg.GameObjectHandler.Address != nint.Zero) { _trackedActors.TryRemove((IntPtr)msg.GameObjectHandler.Address, out _); } }); foreach (var descriptor in _actorObjectService.PlayerDescriptors) { if (descriptor.Address != nint.Zero) { _trackedActors[(IntPtr)descriptor.Address] = 0; } } } public bool APIAvailable { get; private set; } = false; public void CheckAPI() { bool penumbraAvailable = false; try { var penumbraVersion = (_pi.InstalledPlugins .FirstOrDefault(p => string.Equals(p.InternalName, "Penumbra", StringComparison.OrdinalIgnoreCase)) ?.Version ?? new Version(0, 0, 0, 0)); penumbraAvailable = penumbraVersion >= new Version(1, 2, 0, 22); try { penumbraAvailable &= _penumbraEnabled.Invoke(); } catch { penumbraAvailable = false; } _shownPenumbraUnavailable = _shownPenumbraUnavailable && !penumbraAvailable; APIAvailable = penumbraAvailable; } catch { APIAvailable = penumbraAvailable; } finally { if (!penumbraAvailable && !_shownPenumbraUnavailable) { _shownPenumbraUnavailable = true; _lightlessMediator.Publish(new NotificationMessage("Penumbra inactive", "Your Penumbra installation is not active or out of date. Update Penumbra and/or the Enable Mods setting in Penumbra to continue to use Lightless. If you just updated Penumbra, ignore this message.", NotificationType.Error)); } } if (APIAvailable) { ScheduleTemporaryCollectionCleanup(); } } public void CheckModDirectory() { if (!APIAvailable) { ModDirectory = string.Empty; } else { ModDirectory = _penumbraResolveModDir!.Invoke().ToLowerInvariant(); } } private void ScheduleTemporaryCollectionCleanup() { if (Interlocked.Exchange(ref _performedInitialCleanup, 1) != 0) return; _ = Task.Run(CleanupTemporaryCollectionsAsync); } private async Task CleanupTemporaryCollectionsAsync() { if (!APIAvailable) return; try { var collections = await _dalamudUtil.RunOnFrameworkThread(() => _penumbraGetCollections.Invoke()).ConfigureAwait(false); foreach (var (collectionId, name) in collections) { if (!IsLightlessCollectionName(name)) continue; if (_activeTemporaryCollections.ContainsKey(collectionId)) continue; Logger.LogDebug("Cleaning up stale temporary collection {CollectionName} ({CollectionId})", name, collectionId); var deleteResult = await _dalamudUtil.RunOnFrameworkThread(() => { var result = (PenumbraApiEc)_penumbraRemoveTemporaryCollection.Invoke(collectionId); Logger.LogTrace("Cleanup RemoveTemporaryCollection result for {CollectionName} ({CollectionId}): {Result}", name, collectionId, result); return result; }).ConfigureAwait(false); if (deleteResult == PenumbraApiEc.Success) { _activeTemporaryCollections.TryRemove(collectionId, out _); } else { Logger.LogDebug("Skipped removing temporary collection {CollectionName} ({CollectionId}). Result: {Result}", name, collectionId, deleteResult); } } } catch (Exception ex) { Logger.LogWarning(ex, "Failed to clean up Penumbra temporary collections"); } } private static bool IsLightlessCollectionName(string? name) => !string.IsNullOrEmpty(name) && name.StartsWith("Lightless_", StringComparison.Ordinal); protected override void Dispose(bool disposing) { base.Dispose(disposing); _redrawManager.Cancel(); _penumbraModSettingChanged.Dispose(); _penumbraGameObjectResourcePathResolved.Dispose(); _penumbraDispose.Dispose(); _penumbraInit.Dispose(); _penumbraObjectIsRedrawn.Dispose(); } public async Task AssignTemporaryCollectionAsync(ILogger logger, Guid collName, int idx) { if (!APIAvailable) return; await _dalamudUtil.RunOnFrameworkThread(() => { var retAssign = _penumbraAssignTemporaryCollection.Invoke(collName, idx, forceAssignment: true); logger.LogTrace("Assigning Temp Collection {collName} to index {idx}, Success: {ret}", collName, idx, retAssign); return collName; }).ConfigureAwait(false); } public async Task ConvertTextureFiles(ILogger logger, IReadOnlyList jobs, IProgress? progress, CancellationToken token) { if (!APIAvailable || jobs.Count == 0) { return; } _lightlessMediator.Publish(new HaltScanMessage(nameof(ConvertTextureFiles))); var totalJobs = jobs.Count; var completedJobs = 0; try { foreach (var job in jobs) { if (token.IsCancellationRequested) { break; } progress?.Report(new TextureConversionProgress(completedJobs, totalJobs, job)); logger.LogInformation("Converting texture {Input} -> {Output} ({Target})", job.InputFile, job.OutputFile, job.TargetType); var convertTask = _penumbraConvertTextureFile.Invoke(job.InputFile, job.OutputFile, job.TargetType, job.IncludeMipMaps); await convertTask.ConfigureAwait(false); if (convertTask.IsCompletedSuccessfully && job.DuplicateTargets is { Count: > 0 }) { foreach (var duplicate in job.DuplicateTargets) { logger.LogInformation("Synchronizing duplicate {Duplicate}", duplicate); try { File.Copy(job.OutputFile, duplicate, overwrite: true); } catch (Exception ex) { logger.LogError(ex, "Failed to copy duplicate {Duplicate}", duplicate); } } } completedJobs++; } } finally { _lightlessMediator.Publish(new ResumeScanMessage(nameof(ConvertTextureFiles))); } if (completedJobs > 0 && !token.IsCancellationRequested) { await _dalamudUtil.RunOnFrameworkThread(async () => { var player = await _dalamudUtil.GetPlayerPointerAsync().ConfigureAwait(false); if (player == null) { return; } var gameObject = await _dalamudUtil.CreateGameObjectAsync(player).ConfigureAwait(false); _penumbraRedraw.Invoke(gameObject!.ObjectIndex, setting: RedrawType.Redraw); }).ConfigureAwait(false); } } public async Task CreateTemporaryCollectionAsync(ILogger logger, string uid) { if (!APIAvailable) return Guid.Empty; var (collectionId, collectionName) = await _dalamudUtil.RunOnFrameworkThread(() => { var collName = "Lightless_" + uid; _penumbraCreateNamedTemporaryCollection.Invoke(collName, collName, out var collId); logger.LogTrace("Creating Temp Collection {collName}, GUID: {collId}", collName, collId); return (collId, collName); }).ConfigureAwait(false); if (collectionId != Guid.Empty) { _activeTemporaryCollections[collectionId] = collectionName; } return collectionId; } public async Task>?> GetCharacterData(ILogger logger, GameObjectHandler handler) { if (!APIAvailable) return null; return await _dalamudUtil.RunOnFrameworkThread(() => { logger.LogTrace("Calling On IPC: Penumbra.GetGameObjectResourcePaths"); var idx = handler.GetGameObject()?.ObjectIndex; if (idx == null) return null; return _penumbraResourcePaths.Invoke(idx.Value)[0]; }).ConfigureAwait(false); } public string GetMetaManipulations() { if (!APIAvailable) return string.Empty; return _penumbraGetMetaManipulations.Invoke(); } public async Task RedrawAsync(ILogger logger, GameObjectHandler handler, Guid applicationId, CancellationToken token) { if (!APIAvailable || _dalamudUtil.IsZoning) return; try { await _redrawManager.RedrawSemaphore.WaitAsync(token).ConfigureAwait(false); await _redrawManager.PenumbraRedrawInternalAsync(logger, handler, applicationId, (chara) => { logger.LogDebug("[{appid}] Calling on IPC: PenumbraRedraw", applicationId); _penumbraRedraw!.Invoke(chara.ObjectIndex, setting: RedrawType.Redraw); }, token).ConfigureAwait(false); } finally { _redrawManager.RedrawSemaphore.Release(); } } public async Task RemoveTemporaryCollectionAsync(ILogger logger, Guid applicationId, Guid collId) { if (!APIAvailable) return; await _dalamudUtil.RunOnFrameworkThread(() => { logger.LogTrace("[{applicationId}] Removing temp collection for {collId}", applicationId, collId); var ret2 = _penumbraRemoveTemporaryCollection.Invoke(collId); logger.LogTrace("[{applicationId}] RemoveTemporaryCollection: {ret2}", applicationId, ret2); }).ConfigureAwait(false); if (collId != Guid.Empty) { _activeTemporaryCollections.TryRemove(collId, out _); } } public async Task<(string[] forward, string[][] reverse)> ResolvePathsAsync(string[] forward, string[] reverse) { return await _penumbraResolvePaths.Invoke(forward, reverse).ConfigureAwait(false); } public async Task ConvertTextureFileDirectAsync(TextureConversionJob job, CancellationToken token) { if (!APIAvailable) return; token.ThrowIfCancellationRequested(); await _penumbraConvertTextureFile.Invoke(job.InputFile, job.OutputFile, job.TargetType, job.IncludeMipMaps) .ConfigureAwait(false); if (job.DuplicateTargets is { Count: > 0 }) { foreach (var duplicate in job.DuplicateTargets) { try { File.Copy(job.OutputFile, duplicate, overwrite: true); } catch (Exception ex) { Logger.LogDebug(ex, "Failed to copy duplicate {Duplicate} for texture conversion", duplicate); } } } } public async Task SetManipulationDataAsync(ILogger logger, Guid applicationId, Guid collId, string manipulationData) { if (!APIAvailable) return; await _dalamudUtil.RunOnFrameworkThread(() => { logger.LogTrace("[{applicationId}] Manip: {data}", applicationId, manipulationData); var retAdd = _penumbraAddTemporaryMod.Invoke("LightlessChara_Meta", collId, [], manipulationData, 0); logger.LogTrace("[{applicationId}] Setting temp meta mod for {collId}, Success: {ret}", applicationId, collId, retAdd); }).ConfigureAwait(false); } public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collId, Dictionary modPaths) { if (!APIAvailable) return; await _dalamudUtil.RunOnFrameworkThread(() => { foreach (var mod in modPaths) { logger.LogTrace("[{applicationId}] Change: {from} => {to}", applicationId, mod.Key, mod.Value); } var retRemove = _penumbraRemoveTemporaryMod.Invoke("LightlessChara_Files", collId, 0); logger.LogTrace("[{applicationId}] Removing temp files mod for {collId}, Success: {ret}", applicationId, collId, retRemove); var retAdd = _penumbraAddTemporaryMod.Invoke("LightlessChara_Files", collId, modPaths, string.Empty, 0); logger.LogTrace("[{applicationId}] Setting temp files mod for {collId}, Success: {ret}", applicationId, collId, retAdd); }).ConfigureAwait(false); } private void RedrawEvent(IntPtr objectAddress, int objectTableIndex) { bool wasRequested = false; if (_penumbraRedrawRequests.TryGetValue(objectAddress, out var redrawRequest) && redrawRequest) { _penumbraRedrawRequests[objectAddress] = false; } else { _lightlessMediator.Publish(new PenumbraRedrawMessage(objectAddress, objectTableIndex, wasRequested)); } } private void ResourceLoaded(IntPtr ptr, string arg1, string arg2) { if (ptr == IntPtr.Zero) return; if (!_trackedActors.ContainsKey(ptr)) { var descriptor = _actorObjectService.PlayerDescriptors.FirstOrDefault(d => d.Address == ptr); if (descriptor.Address != nint.Zero) { _trackedActors[ptr] = 0; } else { return; } } if (string.Compare(arg1, arg2, ignoreCase: true, System.Globalization.CultureInfo.InvariantCulture) == 0) return; _lightlessMediator.Publish(new PenumbraResourceLoadMessage(ptr, arg1, arg2)); } private void PenumbraDispose() { _redrawManager.Cancel(); _lightlessMediator.Publish(new PenumbraDisposedMessage()); } private void PenumbraInit() { APIAvailable = true; ModDirectory = _penumbraResolveModDir.Invoke(); _lightlessMediator.Publish(new PenumbraInitializedMessage()); ScheduleTemporaryCollectionCleanup(); _penumbraRedraw!.Invoke(0, setting: RedrawType.Redraw); } }