All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m9s
# 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
526 lines
16 KiB
C#
526 lines
16 KiB
C#
using LightlessSync.API.Data.Extensions;
|
|
using LightlessSync.API.Dto.User;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace LightlessSync.PlayerData.Pairs;
|
|
|
|
/// <summary>
|
|
/// creates, tracks, and removes pair handlers
|
|
/// </summary>
|
|
public sealed class PairHandlerRegistry : IDisposable
|
|
{
|
|
private readonly object _gate = new();
|
|
private readonly object _pendingGate = new();
|
|
private readonly object _visibilityGate = new();
|
|
private readonly Dictionary<string, PairHandlerEntry> _entriesByIdent = new(StringComparer.Ordinal);
|
|
private readonly Dictionary<string, CancellationTokenSource> _pendingInvisibleEvictions = new(StringComparer.Ordinal);
|
|
private readonly Dictionary<IPairHandlerAdapter, PairHandlerEntry> _entriesByHandler = new(ReferenceEqualityComparer.Instance);
|
|
|
|
private readonly IPairHandlerAdapterFactory _handlerFactory;
|
|
private readonly PairManager _pairManager;
|
|
private readonly PairStateCache _pairStateCache;
|
|
private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache;
|
|
private readonly ILogger<PairHandlerRegistry> _logger;
|
|
|
|
private readonly TimeSpan _deletionGracePeriod = TimeSpan.FromMinutes(5);
|
|
private static readonly TimeSpan _handlerReadyTimeout = TimeSpan.FromMinutes(3);
|
|
private const int _handlerReadyPollDelayMs = 500;
|
|
private readonly Dictionary<string, CancellationTokenSource> _pendingCharacterData = new(StringComparer.Ordinal);
|
|
|
|
public PairHandlerRegistry(
|
|
IPairHandlerAdapterFactory handlerFactory,
|
|
PairManager pairManager,
|
|
PairStateCache pairStateCache,
|
|
PairPerformanceMetricsCache pairPerformanceMetricsCache,
|
|
ILogger<PairHandlerRegistry> logger)
|
|
{
|
|
_handlerFactory = handlerFactory;
|
|
_pairManager = pairManager;
|
|
_pairStateCache = pairStateCache;
|
|
_pairPerformanceMetricsCache = pairPerformanceMetricsCache;
|
|
_logger = logger;
|
|
}
|
|
|
|
public int GetVisibleUsersCount()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
return _entriesByHandler.Keys.Count(handler => handler.IsVisible);
|
|
}
|
|
}
|
|
|
|
public bool IsIdentVisible(string ident)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
return _entriesByIdent.TryGetValue(ident, out var entry) && entry.Handler.IsVisible;
|
|
}
|
|
}
|
|
|
|
public PairOperationResult<PairUniqueIdentifier> RegisterOnlinePair(PairRegistration registration)
|
|
{
|
|
if (registration.CharacterIdent is null)
|
|
{
|
|
return PairOperationResult<PairUniqueIdentifier>.Fail($"Registration for {registration.PairIdent.UserId} missing ident.");
|
|
}
|
|
|
|
IPairHandlerAdapter handler;
|
|
lock (_gate)
|
|
{
|
|
var entry = GetOrCreateEntry(registration.CharacterIdent);
|
|
handler = entry.Handler;
|
|
handler.ScheduledForDeletion = false;
|
|
entry.AddPair(registration.PairIdent);
|
|
if (!handler.Initialized)
|
|
{
|
|
handler.Initialize();
|
|
}
|
|
}
|
|
|
|
ApplyPauseStateForHandler(handler);
|
|
|
|
if (handler.LastReceivedCharacterData is null)
|
|
{
|
|
var cachedData = _pairStateCache.TryLoad(registration.CharacterIdent);
|
|
if (cachedData is not null)
|
|
{
|
|
handler.LoadCachedCharacterData(cachedData);
|
|
}
|
|
}
|
|
|
|
if (handler.LastReceivedCharacterData is not null &&
|
|
(handler.LastAppliedApproximateVRAMBytes < 0 || handler.LastAppliedDataTris < 0 || handler.LastAppliedApproximateEffectiveTris < 0))
|
|
{
|
|
handler.ApplyLastReceivedData(forced: true);
|
|
}
|
|
|
|
return PairOperationResult<PairUniqueIdentifier>.Ok(registration.PairIdent);
|
|
}
|
|
|
|
public PairOperationResult<PairUniqueIdentifier> DeregisterOfflinePair(PairRegistration registration, bool forceDisposal = false)
|
|
{
|
|
if (registration.CharacterIdent is null)
|
|
{
|
|
return PairOperationResult<PairUniqueIdentifier>.Fail($"Deregister for {registration.PairIdent.UserId} missing ident.");
|
|
}
|
|
|
|
IPairHandlerAdapter? handler = null;
|
|
bool shouldScheduleRemoval = false;
|
|
bool shouldDisposeImmediately = false;
|
|
|
|
lock (_gate)
|
|
{
|
|
if (!_entriesByIdent.TryGetValue(registration.CharacterIdent, out var entry))
|
|
{
|
|
return PairOperationResult<PairUniqueIdentifier>.Fail($"Ident {registration.CharacterIdent} not registered.");
|
|
}
|
|
|
|
handler = entry.Handler;
|
|
entry.RemovePair(registration.PairIdent);
|
|
if (entry.PairCount == 0)
|
|
{
|
|
if (forceDisposal)
|
|
{
|
|
shouldDisposeImmediately = true;
|
|
}
|
|
else
|
|
{
|
|
shouldScheduleRemoval = true;
|
|
handler.ScheduledForDeletion = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (shouldDisposeImmediately && handler is not null)
|
|
{
|
|
if (TryFinalizeHandlerRemoval(handler))
|
|
{
|
|
handler.Dispose();
|
|
_pairStateCache.Clear(registration.CharacterIdent);
|
|
}
|
|
}
|
|
else if (shouldScheduleRemoval && handler is not null)
|
|
{
|
|
_ = RemoveAfterGracePeriodAsync(handler);
|
|
}
|
|
|
|
return PairOperationResult<PairUniqueIdentifier>.Ok(registration.PairIdent);
|
|
}
|
|
|
|
private PairOperationResult CancelAllInvisibleEvictions()
|
|
{
|
|
List<CancellationTokenSource> snapshot;
|
|
lock (_visibilityGate)
|
|
{
|
|
snapshot = [.. _pendingInvisibleEvictions.Values];
|
|
_pendingInvisibleEvictions.Clear();
|
|
}
|
|
|
|
List<string>? errors = null;
|
|
|
|
foreach (var cts in snapshot)
|
|
{
|
|
try { cts.Cancel(); }
|
|
catch (Exception ex)
|
|
{
|
|
(errors ??= new List<string>()).Add($"Cancel: {ex.Message}");
|
|
}
|
|
|
|
try { cts.Dispose(); }
|
|
catch (Exception ex)
|
|
{
|
|
(errors ??= new List<string>()).Add($"Dispose: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
return errors is null
|
|
? PairOperationResult.Ok()
|
|
: PairOperationResult.Fail($"CancelAllInvisibleEvictions had error(s): {string.Join(" | ", errors)}");
|
|
}
|
|
|
|
public PairOperationResult ApplyCharacterData(PairRegistration registration, OnlineUserCharaDataDto dto)
|
|
{
|
|
if (registration.CharacterIdent is null)
|
|
{
|
|
return PairOperationResult.Fail($"Character data received without ident for {registration.PairIdent.UserId}.");
|
|
}
|
|
|
|
if (!TryGetHandler(registration.CharacterIdent, out var handler) || handler is null)
|
|
{
|
|
var registerResult = RegisterOnlinePair(registration);
|
|
if (!registerResult.Success)
|
|
{
|
|
return PairOperationResult.Fail(registerResult.Error);
|
|
}
|
|
|
|
if (!TryGetHandler(registration.CharacterIdent, out handler) || handler is null)
|
|
{
|
|
QueuePendingCharacterData(registration, dto);
|
|
return PairOperationResult.Ok();
|
|
}
|
|
}
|
|
|
|
if (!handler.Initialized)
|
|
{
|
|
handler.Initialize();
|
|
QueuePendingCharacterData(registration, dto);
|
|
return PairOperationResult.Ok();
|
|
}
|
|
|
|
handler.ApplyData(dto.CharaData);
|
|
return PairOperationResult.Ok();
|
|
}
|
|
|
|
public PairOperationResult ApplyLastReceivedData(PairUniqueIdentifier pairIdent, string ident, bool forced = false)
|
|
{
|
|
if (!TryGetHandler(ident, out var handler) || handler is null)
|
|
{
|
|
return PairOperationResult.Fail($"Cannot reapply data: handler for {pairIdent.UserId} not found.");
|
|
}
|
|
|
|
handler.ApplyLastReceivedData(forced);
|
|
return PairOperationResult.Ok();
|
|
}
|
|
|
|
public PairOperationResult SetUploading(PairUniqueIdentifier pairIdent, string ident, bool uploading)
|
|
{
|
|
if (!TryGetHandler(ident, out var handler) || handler is null)
|
|
{
|
|
return PairOperationResult.Fail($"Cannot set uploading for {pairIdent.UserId}: handler not found.");
|
|
}
|
|
|
|
handler.SetUploading(uploading);
|
|
return PairOperationResult.Ok();
|
|
}
|
|
|
|
public PairOperationResult SetPausedState(PairUniqueIdentifier pairIdent, string ident, bool paused)
|
|
{
|
|
if (!TryGetHandler(ident, out var handler) || handler is null)
|
|
{
|
|
return PairOperationResult.Fail($"Cannot update pause state for {pairIdent.UserId}: handler not found.");
|
|
}
|
|
|
|
_ = paused; // value reflected in pair manager already
|
|
ApplyPauseStateForHandler(handler);
|
|
return PairOperationResult.Ok();
|
|
}
|
|
|
|
public PairOperationResult<IReadOnlyList<(PairUniqueIdentifier Ident, PairConnection Pair)>> GetPairConnections(string ident)
|
|
{
|
|
PairHandlerEntry? entry;
|
|
lock (_gate)
|
|
{
|
|
_entriesByIdent.TryGetValue(ident, out entry);
|
|
}
|
|
|
|
if (entry is null)
|
|
{
|
|
return PairOperationResult<IReadOnlyList<(PairUniqueIdentifier Ident, PairConnection Pair)>>.Fail($"No handler registered for {ident}.");
|
|
}
|
|
|
|
var list = new List<(PairUniqueIdentifier, PairConnection)>();
|
|
foreach (var pairIdent in entry.SnapshotPairs())
|
|
{
|
|
var result = _pairManager.GetPair(pairIdent.UserId);
|
|
if (result.Success)
|
|
{
|
|
list.Add((pairIdent, result.Value));
|
|
}
|
|
}
|
|
|
|
return PairOperationResult<IReadOnlyList<(PairUniqueIdentifier Ident, PairConnection Pair)>>.Ok(list);
|
|
}
|
|
|
|
private void ApplyPauseStateForHandler(IPairHandlerAdapter handler)
|
|
{
|
|
var pairs = _pairManager.GetPairsByIdent(handler.Ident);
|
|
bool paused = pairs.Any(p => p.SelfToOtherPermissions.IsPaused() || p.OtherToSelfPermissions.IsPaused());
|
|
handler.SetPaused(paused);
|
|
}
|
|
|
|
internal bool TryGetHandler(string ident, out IPairHandlerAdapter? handler)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
var success = _entriesByIdent.TryGetValue(ident, out var entry);
|
|
handler = entry?.Handler;
|
|
return success;
|
|
}
|
|
}
|
|
|
|
internal IReadOnlyList<IPairHandlerAdapter> GetHandlerSnapshot()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
return _entriesByHandler.Keys.ToList();
|
|
}
|
|
}
|
|
|
|
internal IReadOnlyCollection<PairUniqueIdentifier> GetRegisteredPairs(IPairHandlerAdapter handler)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_entriesByHandler.TryGetValue(handler, out var entry))
|
|
{
|
|
return entry.SnapshotPairs();
|
|
}
|
|
}
|
|
|
|
return Array.Empty<PairUniqueIdentifier>();
|
|
}
|
|
|
|
internal void ReapplyAll(bool forced = false)
|
|
{
|
|
var handlers = GetHandlerSnapshot();
|
|
foreach (var handler in handlers)
|
|
{
|
|
try
|
|
{
|
|
handler.ApplyLastReceivedData(forced);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (_logger.IsEnabled(LogLevel.Debug))
|
|
{
|
|
_logger.LogDebug(ex, "Failed to reapply cached data for {Ident}", handler.Ident);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
internal void ResetAllHandlers()
|
|
{
|
|
List<IPairHandlerAdapter> handlers;
|
|
lock (_gate)
|
|
{
|
|
handlers = _entriesByHandler.Keys.ToList();
|
|
CancelAllInvisibleEvictions();
|
|
_entriesByIdent.Clear();
|
|
_entriesByHandler.Clear();
|
|
}
|
|
|
|
CancelAllPendingCharacterData();
|
|
|
|
foreach (var handler in handlers)
|
|
{
|
|
try
|
|
{
|
|
handler.Dispose();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (_logger.IsEnabled(LogLevel.Debug))
|
|
{
|
|
_logger.LogDebug(ex, "Failed to dispose handler for {Ident}", handler.Ident);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_pairPerformanceMetricsCache.Clear(handler.Ident);
|
|
_pairStateCache.Clear(handler.Ident);
|
|
}
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
List<IPairHandlerAdapter> handlers;
|
|
lock (_gate)
|
|
{
|
|
handlers = _entriesByHandler.Keys.ToList();
|
|
CancelAllInvisibleEvictions();
|
|
_entriesByIdent.Clear();
|
|
_entriesByHandler.Clear();
|
|
}
|
|
|
|
CancelAllPendingCharacterData();
|
|
|
|
foreach (var handler in handlers)
|
|
{
|
|
handler.Dispose();
|
|
_pairPerformanceMetricsCache.Clear(handler.Ident);
|
|
_pairStateCache.Clear(handler.Ident);
|
|
}
|
|
}
|
|
|
|
private PairHandlerEntry GetOrCreateEntry(string ident)
|
|
{
|
|
if (_entriesByIdent.TryGetValue(ident, out var entry))
|
|
{
|
|
return entry;
|
|
}
|
|
|
|
var handler = _handlerFactory.Create(ident);
|
|
entry = new PairHandlerEntry(ident, handler);
|
|
_entriesByIdent[ident] = entry;
|
|
_entriesByHandler[handler] = entry;
|
|
return entry;
|
|
}
|
|
|
|
private async Task RemoveAfterGracePeriodAsync(IPairHandlerAdapter handler)
|
|
{
|
|
await Task.Delay(_deletionGracePeriod).ConfigureAwait(false);
|
|
|
|
if (TryFinalizeHandlerRemoval(handler))
|
|
{
|
|
handler.Dispose();
|
|
_pairStateCache.Clear(handler.Ident);
|
|
}
|
|
}
|
|
|
|
private bool TryFinalizeHandlerRemoval(IPairHandlerAdapter handler)
|
|
{
|
|
string? ident = null;
|
|
lock (_gate)
|
|
{
|
|
if (!_entriesByHandler.TryGetValue(handler, out var entry) || entry.HasPairs)
|
|
{
|
|
handler.ScheduledForDeletion = false;
|
|
return false;
|
|
}
|
|
|
|
ident = entry.Ident;
|
|
_entriesByHandler.Remove(handler);
|
|
_entriesByIdent.Remove(entry.Ident);
|
|
}
|
|
|
|
if (ident is not null)
|
|
{
|
|
_pairPerformanceMetricsCache.Clear(ident);
|
|
CancelPendingCharacterData(ident);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private void QueuePendingCharacterData(PairRegistration registration, OnlineUserCharaDataDto dto)
|
|
{
|
|
if (registration.CharacterIdent is null) return;
|
|
|
|
CancellationTokenSource? previous;
|
|
CancellationTokenSource cts;
|
|
|
|
lock (_pendingGate)
|
|
{
|
|
_pendingCharacterData.TryGetValue(registration.CharacterIdent, out previous);
|
|
previous?.Cancel();
|
|
|
|
cts = new CancellationTokenSource();
|
|
_pendingCharacterData[registration.CharacterIdent] = cts;
|
|
}
|
|
|
|
cts.CancelAfter(_handlerReadyTimeout);
|
|
_ = Task.Run(() => WaitThenApplyPendingCharacterDataAsync(registration, dto, cts.Token, cts));
|
|
}
|
|
|
|
private void CancelPendingCharacterData(string ident)
|
|
{
|
|
CancellationTokenSource? cts = null;
|
|
lock (_pendingGate)
|
|
{
|
|
if (_pendingCharacterData.TryGetValue(ident, out cts))
|
|
_pendingCharacterData.Remove(ident);
|
|
}
|
|
|
|
cts?.Cancel();
|
|
}
|
|
|
|
private void CancelAllPendingCharacterData()
|
|
{
|
|
List<CancellationTokenSource>? snapshot = null;
|
|
lock (_pendingGate)
|
|
{
|
|
if (_pendingCharacterData.Count > 0)
|
|
{
|
|
snapshot = [.. _pendingCharacterData.Values];
|
|
_pendingCharacterData.Clear();
|
|
}
|
|
}
|
|
|
|
if (snapshot is null) return;
|
|
foreach (var cts in snapshot) cts.Cancel();
|
|
}
|
|
|
|
private async Task WaitThenApplyPendingCharacterDataAsync(
|
|
PairRegistration registration,
|
|
OnlineUserCharaDataDto dto,
|
|
CancellationToken token,
|
|
CancellationTokenSource source)
|
|
{
|
|
if (registration.CharacterIdent is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
while (!token.IsCancellationRequested)
|
|
{
|
|
if (TryGetHandler(registration.CharacterIdent, out var handler) && handler is not null && handler.Initialized)
|
|
{
|
|
handler.ApplyData(dto.CharaData);
|
|
break;
|
|
}
|
|
|
|
await Task.Delay(_handlerReadyPollDelayMs, token).ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// expected
|
|
}
|
|
finally
|
|
{
|
|
lock (_pendingGate)
|
|
{
|
|
if (_pendingCharacterData.TryGetValue(registration.CharacterIdent, out var current) && ReferenceEquals(current, source))
|
|
{
|
|
_pendingCharacterData.Remove(registration.CharacterIdent);
|
|
}
|
|
}
|
|
|
|
source.Dispose();
|
|
}
|
|
}
|
|
}
|