This commit is contained in:
2025-11-25 07:14:59 +09:00
parent 9c794137c1
commit ef592032b3
111 changed files with 20622 additions and 3476 deletions

View File

@@ -2,12 +2,18 @@
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;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace LightlessSync.Interop.Ipc;
@@ -17,6 +23,7 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
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
@@ -33,6 +40,7 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
}
private readonly ConcurrentDictionary<IntPtr, bool> _penumbraRedrawRequests = new();
private readonly ConcurrentDictionary<IntPtr, byte> _trackedActors = new();
private readonly EventSubscriber _penumbraDispose;
private readonly EventSubscriber<nint, string, string> _penumbraGameObjectResourcePathResolved;
@@ -52,14 +60,19 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
private readonly GetModDirectory _penumbraResolveModDir;
private readonly ResolvePlayerPathsAsync _penumbraResolvePaths;
private readonly GetGameObjectResourcePaths _penumbraResourcePaths;
//private readonly GetPlayerResourcePaths _penumbraPlayerResourcePaths;
private readonly GetCollections _penumbraGetCollections;
private readonly ConcurrentDictionary<Guid, string> _activeTemporaryCollections = new();
private int _performedInitialCleanup;
public IpcCallerPenumbra(ILogger<IpcCallerPenumbra> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil,
LightlessMediator lightlessMediator, RedrawManager redrawManager) : base(logger, lightlessMediator)
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);
@@ -71,6 +84,7 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
_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) =>
@@ -80,6 +94,7 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
});
_penumbraConvertTextureFile = new ConvertTextureFile(pi);
_penumbraResourcePaths = new GetGameObjectResourcePaths(pi);
//_penumbraPlayerResourcePaths = new GetPlayerResourcePaths(pi);
_penumbraGameObjectResourcePathResolved = GameObjectResourcePathResolved.Subscriber(pi, ResourceLoaded);
@@ -92,6 +107,46 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
});
Mediator.Subscribe<DalamudLoginMessage>(this, (msg) => _shownPenumbraUnavailable = false);
Mediator.Subscribe<ActorTrackedMessage>(this, msg =>
{
if (msg.Descriptor.Address != nint.Zero)
{
_trackedActors[(IntPtr)msg.Descriptor.Address] = 0;
}
});
Mediator.Subscribe<ActorUntrackedMessage>(this, msg =>
{
if (msg.Descriptor.Address != nint.Zero)
{
_trackedActors.TryRemove((IntPtr)msg.Descriptor.Address, out _);
}
});
Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, msg =>
{
if (msg.GameObjectHandler.Address != nint.Zero)
{
_trackedActors[(IntPtr)msg.GameObjectHandler.Address] = 0;
}
});
Mediator.Subscribe<GameObjectHandlerDestroyedMessage>(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;
@@ -130,6 +185,11 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
NotificationType.Error));
}
}
if (APIAvailable)
{
ScheduleTemporaryCollectionCleanup();
}
}
public void CheckModDirectory()
@@ -144,6 +204,56 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
}
}
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);
@@ -169,58 +279,91 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
}).ConfigureAwait(false);
}
public async Task ConvertTextureFiles(ILogger logger, Dictionary<string, string[]> textures, IProgress<(string, int)> progress, CancellationToken token)
public async Task ConvertTextureFiles(ILogger logger, IReadOnlyList<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? progress, CancellationToken token)
{
if (!APIAvailable) return;
if (!APIAvailable || jobs.Count == 0)
{
return;
}
_lightlessMediator.Publish(new HaltScanMessage(nameof(ConvertTextureFiles)));
int currentTexture = 0;
foreach (var texture in textures)
var totalJobs = jobs.Count;
var completedJobs = 0;
try
{
if (token.IsCancellationRequested) break;
progress.Report((texture.Key, ++currentTexture));
logger.LogInformation("Converting Texture {path} to {type}", texture.Key, TextureType.Bc7Tex);
var convertTask = _penumbraConvertTextureFile.Invoke(texture.Key, texture.Key, TextureType.Bc7Tex, mipMaps: true);
await convertTask.ConfigureAwait(false);
if (convertTask.IsCompletedSuccessfully && texture.Value.Any())
foreach (var job in jobs)
{
foreach (var duplicatedTexture in texture.Value)
if (token.IsCancellationRequested)
{
logger.LogInformation("Migrating duplicate {dup}", duplicatedTexture);
try
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)
{
File.Copy(texture.Key, duplicatedTexture, overwrite: true);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to copy duplicate {dup}", duplicatedTexture);
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++;
}
}
_lightlessMediator.Publish(new ResumeScanMessage(nameof(ConvertTextureFiles)));
await _dalamudUtil.RunOnFrameworkThread(async () =>
finally
{
var gameObject = await _dalamudUtil.CreateGameObjectAsync(await _dalamudUtil.GetPlayerPointerAsync().ConfigureAwait(false)).ConfigureAwait(false);
_penumbraRedraw.Invoke(gameObject!.ObjectIndex, setting: RedrawType.Redraw);
}).ConfigureAwait(false);
_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<Guid> CreateTemporaryCollectionAsync(ILogger logger, string uid)
{
if (!APIAvailable) return Guid.Empty;
return await _dalamudUtil.RunOnFrameworkThread(() =>
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;
return (collId, collName);
}).ConfigureAwait(false);
if (collectionId != Guid.Empty)
{
_activeTemporaryCollections[collectionId] = collectionName;
}
return collectionId;
}
public async Task<Dictionary<string, HashSet<string>>?> GetCharacterData(ILogger logger, GameObjectHandler handler)
@@ -270,6 +413,10 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
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)
@@ -277,6 +424,31 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
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;
@@ -321,10 +493,26 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
private void ResourceLoaded(IntPtr ptr, string arg1, string arg2)
{
if (ptr != IntPtr.Zero && string.Compare(arg1, arg2, ignoreCase: true, System.Globalization.CultureInfo.InvariantCulture) != 0)
if (ptr == IntPtr.Zero)
return;
if (!_trackedActors.ContainsKey(ptr))
{
_lightlessMediator.Publish(new PenumbraResourceLoadMessage(ptr, arg1, arg2));
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()
@@ -338,6 +526,7 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
APIAvailable = true;
ModDirectory = _penumbraResolveModDir.Invoke();
_lightlessMediator.Publish(new PenumbraInitializedMessage());
ScheduleTemporaryCollectionCleanup();
_penumbraRedraw!.Invoke(0, setting: RedrawType.Redraw);
}
}

View File

@@ -0,0 +1,21 @@
using Penumbra.Api.Enums;
namespace LightlessSync.Interop.Ipc;
/// <summary>
/// Represents a single texture conversion request, including optional duplicate targets.
/// </summary>
public sealed record TextureConversionJob(
string InputFile,
string OutputFile,
TextureType TargetType,
bool IncludeMipMaps = true,
IReadOnlyList<string>? DuplicateTargets = null);
/// <summary>
/// Progress payload for a texture conversion batch.
/// </summary>
/// <param name="Completed">Number of completed conversions.</param>
/// <param name="Total">Total number of conversions scheduled.</param>
/// <param name="CurrentJob">The job currently being processed.</param>
public sealed record TextureConversionProgress(int Completed, int Total, TextureConversionJob CurrentJob);