diff --git a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs
index da04bda..979d74a 100644
--- a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs
+++ b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs
@@ -8,6 +8,9 @@ using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.PlayerData.Handlers;
+///
+/// Game object handler for managing game object state and updates
+///
public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighPriorityMediatorSubscriber
{
private readonly DalamudUtilService _dalamudUtil;
diff --git a/LightlessSync/PlayerData/Handlers/OwnedObjectHandler.cs b/LightlessSync/PlayerData/Handlers/OwnedObjectHandler.cs
index afbcfdb..3ac87ba 100644
--- a/LightlessSync/PlayerData/Handlers/OwnedObjectHandler.cs
+++ b/LightlessSync/PlayerData/Handlers/OwnedObjectHandler.cs
@@ -13,6 +13,9 @@ using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.PlayerData.Handlers;
+///
+/// Owned object handler for applying changes to owned objects.
+///
internal sealed class OwnedObjectHandler
{
// Debug information for owned object resolution
diff --git a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs
index a2d89d0..1b5984f 100644
--- a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs
+++ b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs
@@ -1,4 +1,5 @@
using LightlessSync.API.Data;
+using LightlessSync.API.Data.Enum;
namespace LightlessSync.PlayerData.Pairs;
@@ -39,7 +40,9 @@ public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject
DateTime? VisibilityEvictionDueAtUtc { get; }
string? MinionAddressHex { get; }
+
ushort? MinionObjectIndex { get; }
+
DateTime? MinionResolvedAtUtc { get; }
string? MinionResolveStage { get; }
string? MinionResolveFailureReason { get; }
@@ -51,9 +54,14 @@ public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject
Guid OwnedPenumbraCollectionId { get; }
bool NeedsCollectionRebuildDebug { get; }
+ uint MinionOrMountCharacterId { get; }
+ uint PetCharacterId { get; }
+ uint CompanionCharacterId { get; }
+
void Initialize();
void ApplyData(CharacterData data);
void ApplyLastReceivedData(bool forced = false);
+ void HardReapplyLastData();
bool FetchPerformanceMetricsFromCache();
void LoadCachedCharacterData(CharacterData data);
void SetUploading(bool uploading);
diff --git a/LightlessSync/PlayerData/Pairs/Pair.cs b/LightlessSync/PlayerData/Pairs/Pair.cs
index ed1047d..b4e94c3 100644
--- a/LightlessSync/PlayerData/Pairs/Pair.cs
+++ b/LightlessSync/PlayerData/Pairs/Pair.cs
@@ -82,60 +82,114 @@ public class Pair
public void AddContextMenu(IMenuOpenedArgs args)
{
+
var handler = TryGetHandler();
if (handler is null)
+ return;
+
+ if (args.Target is not MenuTargetDefault target)
+ return;
+
+ var obj = target.TargetObject;
+ if (obj is null)
+ return;
+
+ var eid = obj.EntityId;
+
+ var isPlayerTarget = eid != 0 && eid != uint.MaxValue && eid == handler.PlayerCharacterId;
+
+ if (!(isPlayerTarget))
+ return;
+
+ if (isPlayerTarget)
{
+ if (!IsPaused)
+ {
+ UiSharedService.AddContextMenuItem(
+ args,
+ name: "Open Profile",
+ prefixChar: 'L',
+ colorMenuItem: _lightlessPrefixColor,
+ onClick: () =>
+ {
+ _mediator.Publish(new ProfileOpenStandaloneMessage(this));
+ return Task.CompletedTask;
+ });
+
+ UiSharedService.AddContextMenuItem(
+ args,
+ name: "(Soft) - Reapply last data",
+ prefixChar: 'L',
+ colorMenuItem: _lightlessPrefixColor,
+ onClick: () =>
+ {
+ ApplyLastReceivedData(forced: true);
+ return Task.CompletedTask;
+ });
+
+ UiSharedService.AddContextMenuItem(
+ args,
+ name: "(Hard) - Reapply last data",
+ prefixChar: 'L',
+ colorMenuItem: _lightlessPrefixColor,
+ onClick: () =>
+ {
+ HardApplyLastReceivedData();
+ return Task.CompletedTask;
+ });
+ }
+
+ UiSharedService.AddContextMenuItem(
+ args,
+ name: "Change Permissions",
+ prefixChar: 'L',
+ colorMenuItem: _lightlessPrefixColor,
+ onClick: () =>
+ {
+ _mediator.Publish(new OpenPermissionWindow(this));
+ return Task.CompletedTask;
+ });
+
+ if (IsPaused)
+ {
+ UiSharedService.AddContextMenuItem(
+ args,
+ name: "Toggle Unpause State",
+ prefixChar: 'L',
+ colorMenuItem: _lightlessPrefixColor,
+ onClick: () =>
+ {
+ _ = _apiController.Value.UnpauseAsync(UserData);
+ return Task.CompletedTask;
+ });
+ }
+ else
+ {
+ UiSharedService.AddContextMenuItem(
+ args,
+ name: "Toggle Pause State",
+ prefixChar: 'L',
+ colorMenuItem: _lightlessPrefixColor,
+ onClick: () =>
+ {
+ _ = _apiController.Value.PauseAsync(UserData);
+ return Task.CompletedTask;
+ });
+ }
+
+ UiSharedService.AddContextMenuItem(
+ args,
+ name: "Cycle Pause State",
+ prefixChar: 'L',
+ colorMenuItem: _lightlessPrefixColor,
+ onClick: () =>
+ {
+ TriggerCyclePause();
+ return Task.CompletedTask;
+ });
+
return;
}
-
- if (args.Target is not MenuTargetDefault target || target.TargetObjectId != handler.PlayerCharacterId)
- {
- return;
- }
-
- if (!IsPaused)
- {
- UiSharedService.AddContextMenuItem(args, name: "Open Profile", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
- {
- _mediator.Publish(new ProfileOpenStandaloneMessage(this));
- return Task.CompletedTask;
- });
-
- UiSharedService.AddContextMenuItem(args, name: "Reapply last data", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
- {
- ApplyLastReceivedData(forced: true);
- return Task.CompletedTask;
- });
- }
-
- UiSharedService.AddContextMenuItem(args, name: "Change Permissions", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
- {
- _mediator.Publish(new OpenPermissionWindow(this));
- return Task.CompletedTask;
- });
-
- if (IsPaused)
- {
- UiSharedService.AddContextMenuItem(args, name: "Toggle Unpause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
- {
- _ = _apiController.Value.UnpauseAsync(UserData);
- return Task.CompletedTask;
- });
- }
- else
- {
- UiSharedService.AddContextMenuItem(args, name: "Toggle Pause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
- {
- _ = _apiController.Value.PauseAsync(UserData);
- return Task.CompletedTask;
- });
- }
-
- UiSharedService.AddContextMenuItem(args, name: "Cycle Pause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
- {
- TriggerCyclePause();
- return Task.CompletedTask;
- });
}
public void ApplyData(OnlineUserCharaDataDto data)
@@ -160,6 +214,18 @@ public class Pair
handler.ApplyLastReceivedData(forced);
}
+ public void HardApplyLastReceivedData()
+ {
+ var handler = TryGetHandler();
+ if (handler is null)
+ {
+ _logger.LogTrace("ApplyLastReceivedData skipped for {Uid}: handler missing.", UserData.UID);
+ return;
+ }
+
+ handler.HardReapplyLastData();
+ }
+
public void CreateCachedPlayer(OnlineUserIdentDto? dto = null)
{
var handler = TryGetHandler();
diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs
index 549b883..970f3f6 100644
--- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs
+++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs
@@ -208,7 +208,9 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
public DateTime? MinionResolvedAtUtc => _ownedObjectHandler.MinionResolveDebug.ResolvedAtUtc;
public string? MinionResolveStage => string.IsNullOrEmpty(_ownedObjectHandler.MinionResolveDebug.Stage) ? null : _ownedObjectHandler.MinionResolveDebug.Stage;
public string? MinionResolveFailureReason => _ownedObjectHandler.MinionResolveDebug.FailureReason;
-
+ public uint MinionOrMountCharacterId { get; private set; } = uint.MaxValue;
+ public uint PetCharacterId { get; private set; } = uint.MaxValue;
+ public uint CompanionCharacterId { get; private set; } = uint.MaxValue;
public bool MinionPendingRetry
{
get
@@ -225,9 +227,9 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
lock (_ownedRetryGate)
{
if (_pendingOwnedChanges.TryGetValue(ObjectKind.MinionOrMount, out var set))
- return set.Select(s => s.ToString()).ToArray();
+ return [.. set.Select(static s => s.ToString())];
- return Array.Empty();
+ return [];
}
}
}
@@ -531,6 +533,44 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
}
}
+ private void RefreshOwnedTargetIds()
+ {
+ if (_charaHandler is null || _charaHandler.Address == nint.Zero)
+ {
+ MinionOrMountCharacterId = uint.MaxValue;
+ PetCharacterId = uint.MaxValue;
+ CompanionCharacterId = uint.MaxValue;
+ return;
+ }
+
+ var playerPtr = _charaHandler.Address;
+
+ _ = _dalamudUtil.RunOnFrameworkThread(() =>
+ {
+ try
+ {
+ var minPtr = _dalamudUtil.GetMinionOrMountPtr(playerPtr);
+ var petPtr = _dalamudUtil.GetPetPtr(playerPtr);
+ var compPtr = _dalamudUtil.GetCompanionPtr(playerPtr);
+
+ var minObj = _dalamudUtil.CreateGameObject(minPtr);
+ var petObj = _dalamudUtil.CreateGameObject(petPtr);
+ var compObj = _dalamudUtil.CreateGameObject(compPtr);
+
+ MinionOrMountCharacterId = minObj?.EntityId ?? uint.MaxValue;
+ PetCharacterId = petObj?.EntityId ?? uint.MaxValue;
+ CompanionCharacterId = compObj?.EntityId ?? uint.MaxValue;
+ }
+ catch
+ {
+ // don’t let this throw from framework thread
+ MinionOrMountCharacterId = uint.MaxValue;
+ PetCharacterId = uint.MaxValue;
+ CompanionCharacterId = uint.MaxValue;
+ }
+ });
+ }
+
private Guid EnsureOwnedPenumbraCollection()
{
if (!IsVisible)
@@ -800,6 +840,137 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
ApplyCharacterData(Guid.NewGuid(), sanitized, forceApplyCustomization, suppressForcedModRedraw);
}
+ public void HardReapplyLastData()
+ {
+ EnsureInitialized();
+
+ if (LastReceivedCharacterData is null && _cachedData is null)
+ return;
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ if (_charaHandler is null || _charaHandler.Address == nint.Zero)
+ return;
+
+ if (!_ipcManager.Penumbra.APIAvailable)
+ {
+ ApplyLastReceivedData(forced: true);
+ return;
+ }
+
+ _needsCollectionRebuild = true;
+ _lastAppliedModdedPaths = null;
+ _forceApplyMods = true;
+ _forceFullReapply = true;
+
+ var flushId = Guid.NewGuid();
+
+ var playerCollection = EnsurePenumbraCollection();
+ if (playerCollection != Guid.Empty)
+ {
+ var objIndex = await _dalamudUtil.RunOnFrameworkThread(() =>
+ _charaHandler.GetGameObject()?.ObjectIndex).ConfigureAwait(false);
+
+ if (objIndex.HasValue)
+ await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, playerCollection, objIndex.Value)
+ .ConfigureAwait(false);
+
+ await _ipcManager.Penumbra.SetTemporaryModsAsync(
+ Logger, flushId, playerCollection,
+ new Dictionary(StringComparer.Ordinal),
+ scope: "Player")
+ .ConfigureAwait(false);
+
+ await _ipcManager.Penumbra.SetManipulationDataAsync(
+ Logger, flushId, playerCollection, string.Empty)
+ .ConfigureAwait(false);
+
+ await _ipcManager.Penumbra.RedrawAsync(Logger, _charaHandler, flushId, CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+
+ var ownedCollection = EnsureOwnedPenumbraCollection();
+ if (ownedCollection != Guid.Empty)
+ {
+ await _ipcManager.Penumbra.SetTemporaryModsAsync(
+ Logger, flushId, ownedCollection,
+ new Dictionary(StringComparer.Ordinal),
+ scope: "Owned")
+ .ConfigureAwait(false);
+ }
+
+ ApplyLastReceivedData(forced: true);
+ await Task.Delay(900).ConfigureAwait(false);
+ ApplyLastReceivedData(forced: true);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogWarning(ex, "Hard reapply failed for {handler}", GetLogIdentifier());
+ }
+ });
+ }
+ private static readonly TimeSpan FileReadyTimeout = TimeSpan.FromSeconds(8);
+ private static readonly TimeSpan FileReadyPoll = TimeSpan.FromMilliseconds(75);
+
+ private static bool IsCriticalVisualPath(string gamePath)
+ => gamePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)
+ || gamePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)
+ || gamePath.EndsWith(".mtrl", StringComparison.OrdinalIgnoreCase);
+
+ private static async Task WaitForFilesReadyAsync(
+ ILogger logger,
+ Guid appId,
+ IEnumerable<(string GamePath, string FilePath)> entries,
+ CancellationToken token)
+ {
+ var list = entries
+ .Where(e => !string.IsNullOrEmpty(e.FilePath) && Path.IsPathRooted(e.FilePath))
+ .Select(e => (e.GamePath, e.FilePath))
+ .DistinctBy(e => e.FilePath, StringComparer.OrdinalIgnoreCase)
+ .Take(200)
+ .ToList();
+
+ foreach (var (gamePath, filePath) in list)
+ {
+ token.ThrowIfCancellationRequested();
+
+ var sw = Stopwatch.StartNew();
+ while (sw.Elapsed < FileReadyTimeout && !token.IsCancellationRequested)
+ {
+ try
+ {
+ using var fs = new FileStream(
+ filePath,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.ReadWrite | FileShare.Delete);
+
+ if (fs.Length > 0)
+ break;
+ }
+ catch (IOException)
+ {
+ // locked or being swapped
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // transient access issues, treat like locked
+ }
+
+ await Task.Delay(FileReadyPoll, token).ConfigureAwait(false);
+ }
+
+ if (sw.Elapsed >= FileReadyTimeout)
+ {
+ logger.LogDebug(
+ "[{appId}] File still not ready after {ms}ms: {gamePath} -> {filePath}",
+ appId, (int)sw.Elapsed.TotalMilliseconds, gamePath, filePath);
+ }
+ }
+ }
+
public bool FetchPerformanceMetricsFromCache()
{
EnsureInitialized();
@@ -2727,16 +2898,22 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
foreach (var kv in papOnly)
merged[kv.Key] = kv.Value;
- // Apply mods via IPC
+ await WaitForFilesReadyAsync(
+ Logger,
+ _applicationId,
+ merged.Select(kv => (kv.Key.GamePath, kv.Value))
+ .Where(x => IsCriticalVisualPath(x.GamePath)),
+ token)
+ .ConfigureAwait(false);
+
await _ipcManager.Penumbra.SetTemporaryModsAsync(
Logger, _applicationId, playerCollection,
merged.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal),
scope: "Player")
.ConfigureAwait(false);
- // Final redraw
await _ipcManager.Penumbra.RedrawAsync(Logger, handlerForApply, _applicationId, token).ConfigureAwait(false);
-
+
if (handlerForApply.Address != nint.Zero)
{
await _actorObjectService
@@ -2771,11 +2948,16 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
// Apply owned mods via IPC
if (ownedModded.Count > 0)
{
+ await WaitForFilesReadyAsync(
+ Logger,
+ _applicationId,
+ ownedModded.Select(kv => (kv.Key.GamePath, kv.Value)).Where(x => IsCriticalVisualPath(x.GamePath)),
+ token).ConfigureAwait(false);
+
await _ipcManager.Penumbra.SetTemporaryModsAsync(
Logger, _applicationId, ownedCollection,
ownedModded.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal),
- scope: "Owned")
- .ConfigureAwait(false);
+ scope: "Owned").ConfigureAwait(false);
}
}
@@ -2992,6 +3174,9 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
HandleVisibilityLoss(logChange: true);
}
+ if (_charaHandler?.Address != nint.Zero && IsVisible)
+ RefreshOwnedTargetIds();
+
TryApplyQueuedData();
}
@@ -3414,6 +3599,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
UpdateLastKnownActor(descriptor);
RefreshTrackedHandler(descriptor);
QueueActorInitialization(descriptor);
+
+ RefreshOwnedTargetIds();
return;
}