From 699535b68bd5fc82929a06766f0fde0f85f03cfe Mon Sep 17 00:00:00 2001 From: cake Date: Sat, 17 Jan 2026 22:31:50 +0100 Subject: [PATCH] Last commit for this. will stop --- .../PlayerData/Handlers/GameObjectHandler.cs | 3 + .../PlayerData/Handlers/OwnedObjectHandler.cs | 3 + .../PlayerData/Pairs/IPairHandlerAdapter.cs | 8 + LightlessSync/PlayerData/Pairs/Pair.cs | 164 +++++++++----- .../PlayerData/Pairs/PairHandlerAdapter.cs | 203 +++++++++++++++++- 5 files changed, 324 insertions(+), 57 deletions(-) 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; }