Files
LightlessClient/LightlessSync/FileCache/TransientResourceManager.cs
defnotken 72a62b7449
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m9s
2.1.0 (#123)
# Patchnotes 2.1.0
The changes in this update are more than just "patches". With a new UI, a new feature, and a bunch of bug fixes, improvements and a new member on the dev team, we thought this was more of a minor update.

We would like to introduce @tsubasahane of MareCN to the team! We’re happy to work with them to bring Lightless and its features to the CN client as well as having another talented dev bring features and ideas to us. Speaking of which:

# Location Sharing (Big shout out to @tsubasahane for bringing this feature)

- Are you TIRED of scrambling to find the address of the venue you're in to share with your friends? We are introducing Location Sharing! An optional feature where you can share your location with direct pairs temporarily [30 minutes, 1 hour, 3 hours] minutes or until you turn it off for them. That's up to you! [#125](<#125>)  [#49](<Lightless-Sync/LightlessServer#49>)
- To share your location with a pair, click the three dots beside the pair and choose a duration to share with them. [#125](<#125>)  [#49](<Lightless-Sync/LightlessServer#49>)
- To view the location of someone who's shared with you, simply hover over the globe icon! [#125](<#125>)  [#49](<Lightless-Sync/LightlessServer#49>)

[1]

# Model Optimization (Mesh Decimating)
 - This new option can automatically “simplify” incoming character meshes to help performance by reducing triangle counts. You choose how strong the reduction is (default/recommended is 80%). [#131](<#131>)
 - Decimation only kicks in when a mesh is above a certain triangle threshold, and only for the items that qualify for it and you selected for. [#131](<#131>)
 - Hair meshes is always excluded, since simplifying hair meshes is very prone to breaking.
 - You can find everything under Settings → Performance → Model Optimization. [#131](<#131>)
+ ** IF YOU HAVE USED DECIMATION IN TESTING, PLEASE CLEAR YOUR CACHE  **

[2]

# Animation (PAP) Validation (Safer animations)
 - Lightless now checks your currently animations to see if they work with your local skeleton/bone mod. If an animation matches, it’s included in what gets sent to other players. If it doesn’t, Lightless will skip it and write a warning to your log showing how many were skipped due to skeleton changes. Its defaulted to Unsafe (off). turn it on if you experience crashes from others users. [#131](<#131>)
 - Lightless also does the same kind of check for incoming animation files, to make sure they match the body/skeleton they were sent with. [#131](<#131>)
 - Because these checks can sometimes be a little picky, you can adjust how strict they are in Settings -> General -> Animation & Bones to reduce false positives. [#131](<#131>)

# UI Changes (Thanks to @kyuwu for UI Changes)
- The top part of the main screen has gotten a makeover. You can adjust the colors of the gradiant in the Color settings of Lightless. [#127](<#127>)

[3]

- Settings have gotten some changes as well to make this change more universal, and will use the same color settings. [#127](<#127>)
- The particle effects of the gradient are toggleable in 'Settings -> UI -> Behavior' [#127](<#127>)
- Instead of showing download/upload on bottom of Main UI, it will show VRAM usage and triangles with their optimization options next to it [#138](<#138>)

# LightFinder / ShellFinder
- UI Changes that follow our new design follow the color codes for the Gradient top as the main screen does.  [#127](<#127>)

[4]

Co-authored-by: defnotken <itsdefnotken@gmail.com>
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: cake <admin@cakeandbanana.nl>
Co-authored-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
Co-authored-by: choco <choco@patat.nl>
Co-authored-by: celine <aaa@aaa.aaa>
Co-authored-by: celine <celine@noreply.git.lightless-sync.org>
Co-authored-by: Tsubasahane <wozaiha@gmail.com>
Co-authored-by: cake <cake@noreply.git.lightless-sync.org>
Reviewed-on: #123
2026-01-20 19:43:00 +00:00

707 lines
25 KiB
C#

using LightlessSync.API.Data.Enum;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Configurations;
using LightlessSync.PlayerData.Data;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Factories;
using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
namespace LightlessSync.FileCache;
public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
{
private readonly object _cacheAdditionLock = new();
private readonly HashSet<string> _cachedHandledPaths = new(StringComparer.Ordinal);
private readonly TransientConfigService _configurationService;
private readonly DalamudUtilService _dalamudUtil;
private readonly ActorObjectService _actorObjectService;
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
private readonly object _ownedHandlerLock = new();
private readonly string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk", "kdb"];
private readonly string[] _handledRecordingFileTypes = ["tex", "mdl", "mtrl"];
private readonly HashSet<GameObjectHandler> _playerRelatedPointers = [];
private readonly object _playerRelatedLock = new();
private readonly ConcurrentDictionary<nint, GameObjectHandler> _playerRelatedByAddress = new();
private readonly Dictionary<nint, GameObjectHandler> _ownedHandlers = new();
private ConcurrentDictionary<nint, ObjectKind> _cachedFrameAddresses = new();
private ConcurrentDictionary<ObjectKind, HashSet<string>>? _semiTransientResources = null;
private uint _lastClassJobId = uint.MaxValue;
public bool IsTransientRecording { get; private set; } = false;
public TransientResourceManager(ILogger<TransientResourceManager> logger, TransientConfigService configurationService,
DalamudUtilService dalamudUtil, LightlessMediator mediator, ActorObjectService actorObjectService, GameObjectHandlerFactory gameObjectHandlerFactory) : base(logger, mediator)
{
_configurationService = configurationService;
_dalamudUtil = dalamudUtil;
_actorObjectService = actorObjectService;
_gameObjectHandlerFactory = gameObjectHandlerFactory;
Mediator.Subscribe<PenumbraResourceLoadMessage>(this, Manager_PenumbraResourceLoadEvent);
Mediator.Subscribe<ActorTrackedMessage>(this, msg => HandleActorTracked(msg.Descriptor));
Mediator.Subscribe<ActorUntrackedMessage>(this, msg => HandleActorUntracked(msg.Descriptor));
Mediator.Subscribe<PenumbraModSettingChangedMessage>(this, (_) => Manager_PenumbraModSettingChanged());
Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, (_) => DalamudUtil_FrameworkUpdate());
Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
{
if (!msg.OwnedObject) return;
lock (_playerRelatedLock)
{
_playerRelatedPointers.Add(msg.GameObjectHandler);
}
});
Mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, (msg) =>
{
if (!msg.OwnedObject) return;
lock (_playerRelatedLock)
{
_playerRelatedPointers.Remove(msg.GameObjectHandler);
}
});
foreach (var descriptor in _actorObjectService.ObjectDescriptors)
{
HandleActorTracked(descriptor);
}
}
private TransientConfig.TransientPlayerConfig PlayerConfig
{
get
{
if (!_configurationService.Current.TransientConfigs.TryGetValue(PlayerPersistentDataKey, out var transientConfig))
{
_configurationService.Current.TransientConfigs[PlayerPersistentDataKey] = transientConfig = new();
}
return transientConfig;
}
}
private string PlayerPersistentDataKey => _dalamudUtil.GetPlayerName() + "_" + _dalamudUtil.GetHomeWorldId();
private ConcurrentDictionary<ObjectKind, HashSet<string>> SemiTransientResources
{
get
{
if (_semiTransientResources == null)
{
_semiTransientResources = new();
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
_semiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? [])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
_semiTransientResources[ObjectKind.Pet] = new HashSet<string>(
petSpecificData ?? [],
StringComparer.OrdinalIgnoreCase);
}
return _semiTransientResources;
}
}
private ConcurrentDictionary<ObjectKind, HashSet<string>> TransientResources { get; } = new();
public void CleanUpSemiTransientResources(ObjectKind objectKind, List<FileReplacement>? fileReplacement = null)
{
if (!SemiTransientResources.TryGetValue(objectKind, out HashSet<string>? value))
return;
if (fileReplacement == null)
{
value.Clear();
return;
}
int removedPaths = 0;
foreach (var replacement in fileReplacement.Where(p => !p.HasFileReplacement).SelectMany(p => p.GamePaths).ToList())
{
removedPaths += PlayerConfig.RemovePath(replacement, objectKind);
value.Remove(replacement);
}
if (removedPaths > 0)
{
Logger.LogTrace("Removed {amount} of SemiTransient paths during CleanUp, Saving from {name}", removedPaths, nameof(CleanUpSemiTransientResources));
// force reload semi transient resources
_configurationService.Save();
}
}
public HashSet<string> GetSemiTransientResources(ObjectKind objectKind)
{
SemiTransientResources.TryGetValue(objectKind, out var result);
return result ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
public void PersistTransientResources(ObjectKind objectKind)
{
if (!SemiTransientResources.TryGetValue(objectKind, out HashSet<string>? semiTransientResources))
{
SemiTransientResources[objectKind] = semiTransientResources = new(StringComparer.OrdinalIgnoreCase);
}
if (!TransientResources.TryGetValue(objectKind, out var resources))
{
return;
}
List<string> transientResources;
lock (resources)
{
transientResources = resources.ToList();
}
Logger.LogDebug("Persisting {count} transient resources", transientResources.Count);
List<string> newlyAddedGamePaths;
lock (semiTransientResources)
{
newlyAddedGamePaths = transientResources.Except(semiTransientResources, StringComparer.OrdinalIgnoreCase).ToList();
foreach (var gamePath in transientResources)
{
semiTransientResources.Add(gamePath);
}
}
bool saveConfig = false;
if (objectKind == ObjectKind.Player && newlyAddedGamePaths.Count != 0)
{
saveConfig = true;
foreach (var item in newlyAddedGamePaths.Where(f => !string.IsNullOrEmpty(f)))
{
PlayerConfig.AddOrElevate(_dalamudUtil.ClassJobId, item);
}
}
else if (objectKind == ObjectKind.Pet && newlyAddedGamePaths.Count != 0)
{
saveConfig = true;
if (!PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petPerma))
{
PlayerConfig.JobSpecificPetCache[_dalamudUtil.ClassJobId] = petPerma = [];
}
foreach (var item in newlyAddedGamePaths.Where(f => !string.IsNullOrEmpty(f)))
{
petPerma.Add(item);
}
}
if (saveConfig)
{
Logger.LogTrace("Saving transient.json from {method}", nameof(PersistTransientResources));
_configurationService.Save();
}
lock (resources)
{
resources.Clear();
}
}
public void RemoveTransientResource(ObjectKind objectKind, string path)
{
var normalizedPath = NormalizeGamePath(path);
if (SemiTransientResources.TryGetValue(objectKind, out var resources))
{
resources.Remove(normalizedPath);
if (objectKind == ObjectKind.Player)
{
PlayerConfig.RemovePath(normalizedPath, objectKind);
Logger.LogTrace("Saving transient.json from {method}", nameof(RemoveTransientResource));
_configurationService.Save();
}
}
}
internal bool AddTransientResource(ObjectKind objectKind, string item)
{
var normalizedItem = NormalizeGamePath(item);
if (SemiTransientResources.TryGetValue(objectKind, out var semiTransient) && semiTransient != null && semiTransient.Contains(normalizedItem))
return false;
if (!TransientResources.TryGetValue(objectKind, out HashSet<string>? transientResource))
{
transientResource = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
TransientResources[objectKind] = transientResource;
}
return transientResource.Add(normalizedItem);
}
internal void ClearTransientPaths(ObjectKind objectKind, List<string> list)
{
// ignore all recording only datatypes
int recordingOnlyRemoved = list.RemoveAll(entry => _handledRecordingFileTypes.Any(ext => entry.EndsWith(ext, StringComparison.OrdinalIgnoreCase)));
if (recordingOnlyRemoved > 0)
{
Logger.LogTrace("Ignored {0} game paths when clearing transients", recordingOnlyRemoved);
}
if (TransientResources.TryGetValue(objectKind, out var set))
{
foreach (var file in set.Where(p => list.Contains(p, StringComparer.OrdinalIgnoreCase)))
{
Logger.LogTrace("Removing From Transient: {file}", file);
}
int removed = set.RemoveWhere(p => list.Contains(p, StringComparer.OrdinalIgnoreCase));
Logger.LogDebug("Removed {removed} previously existing transient paths", removed);
}
bool reloadSemiTransient = false;
if (objectKind == ObjectKind.Player && SemiTransientResources.TryGetValue(objectKind, out var semiset))
{
foreach (var file in semiset.Where(p => list.Contains(p, StringComparer.OrdinalIgnoreCase)))
{
Logger.LogTrace("Removing From SemiTransient: {file}", file);
PlayerConfig.RemovePath(file, objectKind);
}
int removed = semiset.RemoveWhere(p => list.Contains(p, StringComparer.OrdinalIgnoreCase));
Logger.LogDebug("Removed {removed} previously existing semi transient paths", removed);
if (removed > 0)
{
reloadSemiTransient = true;
Logger.LogTrace("Saving transient.json from {method}", nameof(ClearTransientPaths));
_configurationService.Save();
}
}
if (reloadSemiTransient)
_semiTransientResources = null;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
TransientResources.Clear();
SemiTransientResources.Clear();
lock (_ownedHandlerLock)
{
foreach (var handler in _ownedHandlers.Values)
{
handler.Dispose();
}
_ownedHandlers.Clear();
}
}
private void DalamudUtil_FrameworkUpdate()
{
_ = Task.Run(() => RefreshPlayerRelatedAddressMap());
lock (_cacheAdditionLock)
{
_cachedHandledPaths.Clear();
}
if (_lastClassJobId != _dalamudUtil.ClassJobId)
{
UpdateClassJobCache();
}
CleanupAbsentObjects();
}
private void RefreshPlayerRelatedAddressMap()
{
var tempMap = new ConcurrentDictionary<nint, GameObjectHandler>();
var updatedFrameAddresses = new ConcurrentDictionary<nint, ObjectKind>();
lock (_playerRelatedLock)
{
foreach (var handler in _playerRelatedPointers)
{
var address = (nint)handler.Address;
if (address != nint.Zero)
{
tempMap[address] = handler;
updatedFrameAddresses[address] = handler.ObjectKind;
}
}
}
_playerRelatedByAddress.Clear();
foreach (var kvp in tempMap)
{
_playerRelatedByAddress[kvp.Key] = kvp.Value;
}
_cachedFrameAddresses.Clear();
foreach (var kvp in updatedFrameAddresses)
{
_cachedFrameAddresses[kvp.Key] = kvp.Value;
}
}
private void UpdateClassJobCache()
{
_lastClassJobId = _dalamudUtil.ClassJobId;
if (SemiTransientResources.TryGetValue(ObjectKind.Pet, out HashSet<string>? value))
{
value?.Clear();
}
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache
.Concat(jobSpecificData ?? [])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
SemiTransientResources[ObjectKind.Pet] = new HashSet<string>(
petSpecificData ?? [],
StringComparer.OrdinalIgnoreCase);
}
private void CleanupAbsentObjects()
{
foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast<ObjectKind>())
{
if (!_cachedFrameAddresses.Any(k => k.Value == kind) && TransientResources.Remove(kind, out _))
{
Logger.LogDebug("Object not present anymore: {kind}", kind.ToString());
}
}
}
private void Manager_PenumbraModSettingChanged()
{
_ = Task.Run(() =>
{
Logger.LogDebug("Penumbra Mod Settings changed, verifying SemiTransientResources");
lock (_playerRelatedLock)
{
foreach (var item in _playerRelatedPointers)
{
Mediator.Publish(new TransientResourceChangedMessage(item.Address));
}
}
});
}
public void RebuildSemiTransientResources()
{
_semiTransientResources = null;
}
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
{
if (descriptor.IsInGpose)
return;
if (descriptor.OwnedKind is not ObjectKind ownedKind)
return;
if (Logger.IsEnabled(LogLevel.Debug))
{
Logger.LogDebug("ActorObject tracked: {kind} addr={address:X} name={name}", ownedKind, descriptor.Address, descriptor.Name);
}
_cachedFrameAddresses[descriptor.Address] = ownedKind;
lock (_ownedHandlerLock)
{
if (_ownedHandlers.ContainsKey(descriptor.Address))
return;
_ = CreateOwnedHandlerAsync(descriptor, ownedKind);
}
}
private void HandleActorUntracked(ActorObjectService.ActorDescriptor descriptor)
{
if (Logger.IsEnabled(LogLevel.Debug))
{
var kindLabel = descriptor.OwnedKind?.ToString()
?? (descriptor.ObjectKind == DalamudObjectKind.Player ? ObjectKind.Player.ToString() : "<none>");
Logger.LogDebug("ActorObject untracked: addr={address:X} name={name} kind={kind}", descriptor.Address, descriptor.Name, kindLabel);
}
_cachedFrameAddresses.TryRemove(descriptor.Address, out _);
if (descriptor.OwnedKind is not ObjectKind)
return;
lock (_ownedHandlerLock)
{
if (_ownedHandlers.Remove(descriptor.Address, out var handler))
{
handler.Dispose();
}
}
}
private async Task CreateOwnedHandlerAsync(ActorObjectService.ActorDescriptor descriptor, ObjectKind kind)
{
try
{
var handler = await _gameObjectHandlerFactory.Create(
kind,
() =>
{
if (!string.IsNullOrEmpty(descriptor.HashedContentId) &&
_actorObjectService.TryGetValidatedActorByHash(descriptor.HashedContentId, out var current) &&
current.OwnedKind == kind)
{
return current.Address;
}
return descriptor.Address;
},
true).ConfigureAwait(false);
if (handler.Address == IntPtr.Zero)
{
handler.Dispose();
return;
}
lock (_ownedHandlerLock)
{
if (!_cachedFrameAddresses.ContainsKey(descriptor.Address))
{
Logger.LogDebug("ActorObject handler discarded (stale): addr={address:X}", descriptor.Address);
handler.Dispose();
return;
}
_ownedHandlers[descriptor.Address] = handler;
}
Logger.LogDebug("ActorObject handler created: {kind} addr={address:X}", kind, descriptor.Address);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to create owned handler for {kind} at {address:X}", kind, descriptor.Address);
}
}
private static string NormalizeGamePath(string path)
{
if (string.IsNullOrEmpty(path))
return string.Empty;
return path.Replace("\\", "/", StringComparison.Ordinal).ToLowerInvariant();
}
private static string NormalizeFilePath(string path)
{
if (string.IsNullOrEmpty(path))
return string.Empty;
if (path.StartsWith("|", StringComparison.Ordinal))
{
var lastPipe = path.LastIndexOf('|');
if (lastPipe >= 0 && lastPipe + 1 < path.Length)
{
path = path[(lastPipe + 1)..];
}
}
return NormalizeGamePath(path);
}
private static bool HasHandledFileType(string gamePath, string[] handledTypes)
{
for (var i = 0; i < handledTypes.Length; i++)
{
if (gamePath.EndsWith(handledTypes[i], StringComparison.Ordinal))
return true;
}
return false;
}
private void Manager_PenumbraResourceLoadEvent(PenumbraResourceLoadMessage msg)
{
var gamePath = msg.GamePath.ToLowerInvariant();
var gameObjectAddress = msg.GameObject;
var filePath = msg.FilePath;
// ignore files already processed this frame
if (_cachedHandledPaths.Contains(gamePath)) return;
lock (_cacheAdditionLock)
{
_cachedHandledPaths.Add(gamePath);
}
// replace individual mtrl stuff
if (filePath.StartsWith("|", StringComparison.OrdinalIgnoreCase))
{
filePath = filePath.Split("|")[2];
}
// replace filepath
filePath = filePath.ToLowerInvariant().Replace("\\", "/", StringComparison.OrdinalIgnoreCase);
// ignore files that are the same
var replacedGamePath = gamePath.ToLowerInvariant().Replace("\\", "/", StringComparison.OrdinalIgnoreCase);
if (string.Equals(filePath, replacedGamePath, StringComparison.OrdinalIgnoreCase))
{
return;
}
// ignore files to not handle
var handledTypes = IsTransientRecording ? _handledRecordingFileTypes.Concat(_handledFileTypes) : _handledFileTypes;
if (!handledTypes.Any(type => gamePath.EndsWith(type, StringComparison.OrdinalIgnoreCase)))
{
lock (_cacheAdditionLock)
{
_cachedHandledPaths.Add(gamePath);
}
return;
}
// ignore files not belonging to anything player related
if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind))
{
lock (_cacheAdditionLock)
{
_cachedHandledPaths.Add(gamePath);
}
return;
}
// ^ all of the code above is just to sanitize the data
if (!TransientResources.TryGetValue(objectKind, out HashSet<string>? transientResources))
{
transientResources = new(StringComparer.OrdinalIgnoreCase);
TransientResources[objectKind] = transientResources;
}
_playerRelatedByAddress.TryGetValue(gameObjectAddress, out var owner);
bool alreadyTransient = false;
bool transientContains = transientResources.Contains(replacedGamePath);
bool semiTransientContains = SemiTransientResources.SelectMany(k => k.Value)
.Any(f => string.Equals(f, gamePath, StringComparison.OrdinalIgnoreCase));
if (transientContains || semiTransientContains)
{
if (!IsTransientRecording)
Logger.LogTrace("Not adding {replacedPath} => {filePath}, Reason: Transient: {contains}, SemiTransient: {contains2}", replacedGamePath, filePath,
transientContains, semiTransientContains);
alreadyTransient = true;
}
else
{
if (!IsTransientRecording)
{
bool isAdded = transientResources.Add(replacedGamePath);
if (isAdded)
{
Logger.LogDebug("Adding {replacedGamePath} for {gameObject} ({filePath})", replacedGamePath, owner?.ToString() ?? gameObjectAddress.ToString("X"), filePath);
SendTransients(gameObjectAddress, objectKind);
}
}
}
if (owner != null && IsTransientRecording)
{
_recordedTransients.Add(new TransientRecord(owner, replacedGamePath, filePath, alreadyTransient) { AddTransient = !alreadyTransient });
}
}
private void SendTransients(nint gameObject, ObjectKind objectKind)
{
_sendTransientCts.Cancel();
_sendTransientCts = new();
var token = _sendTransientCts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false);
if (TransientResources.TryGetValue(objectKind, out var values) && values.Any())
{
Logger.LogTrace("Sending Transients for {kind}", objectKind);
Mediator.Publish(new TransientResourceChangedMessage(gameObject));
}
}
catch (TaskCanceledException)
{
}
catch (System.OperationCanceledException)
{
}
});
}
public void StartRecording(CancellationToken token)
{
if (IsTransientRecording) return;
_recordedTransients.Clear();
IsTransientRecording = true;
RecordTimeRemaining.Value = TimeSpan.FromSeconds(150);
_ = Task.Run(async () =>
{
try
{
while (RecordTimeRemaining.Value > TimeSpan.Zero && !token.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(1), token).ConfigureAwait(false);
RecordTimeRemaining.Value = RecordTimeRemaining.Value.Subtract(TimeSpan.FromSeconds(1));
}
}
finally
{
IsTransientRecording = false;
}
});
}
public async Task WaitForRecording(CancellationToken token)
{
while (IsTransientRecording)
{
await Task.Delay(TimeSpan.FromSeconds(1), token).ConfigureAwait(false);
}
}
internal void SaveRecording()
{
HashSet<nint> addedTransients = [];
foreach (var item in _recordedTransients)
{
if (!item.AddTransient || item.AlreadyTransient) continue;
if (!TransientResources.TryGetValue(item.Owner.ObjectKind, out var transient))
{
TransientResources[item.Owner.ObjectKind] = transient = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
Logger.LogTrace("Adding recorded: {gamePath} => {filePath}", item.GamePath, item.FilePath);
transient.Add(item.GamePath);
addedTransients.Add(item.Owner.Address);
}
_recordedTransients.Clear();
foreach (var item in addedTransients)
{
Mediator.Publish(new TransientResourceChangedMessage(item));
}
}
private readonly HashSet<TransientRecord> _recordedTransients = [];
public IReadOnlySet<TransientRecord> RecordedTransients => _recordedTransients;
public ValueProgress<TimeSpan> RecordTimeRemaining { get; } = new();
private CancellationTokenSource _sendTransientCts = new();
public record TransientRecord(GameObjectHandler Owner, string GamePath, string FilePath, bool AlreadyTransient)
{
public bool AddTransient { get; set; }
}
}