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
501 lines
17 KiB
C#
501 lines
17 KiB
C#
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
|
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
|
using LightlessSync.Services;
|
|
using LightlessSync.Services.Mediator;
|
|
using LightlessSync.Utils;
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Runtime.CompilerServices;
|
|
using static FFXIVClientStructs.FFXIV.Client.Game.Character.DrawDataContainer;
|
|
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
|
using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags;
|
|
|
|
namespace LightlessSync.PlayerData.Handlers;
|
|
|
|
public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighPriorityMediatorSubscriber
|
|
{
|
|
private readonly DalamudUtilService _dalamudUtil;
|
|
private readonly Func<IntPtr> _getAddress;
|
|
private readonly bool _isOwnedObject;
|
|
private readonly PerformanceCollectorService _performanceCollector;
|
|
private readonly object _frameworkUpdateGate = new();
|
|
private bool _frameworkUpdateSubscribed;
|
|
private byte _classJob = 0;
|
|
private Task? _delayedZoningTask;
|
|
private bool _haltProcessing = false;
|
|
private CancellationTokenSource _zoningCts = new();
|
|
|
|
public GameObjectHandler(ILogger<GameObjectHandler> logger, PerformanceCollectorService performanceCollector,
|
|
LightlessMediator mediator, DalamudUtilService dalamudUtil, ObjectKind objectKind, Func<IntPtr> getAddress, bool ownedObject = true) : base(logger, mediator)
|
|
{
|
|
_performanceCollector = performanceCollector;
|
|
ObjectKind = objectKind;
|
|
_dalamudUtil = dalamudUtil;
|
|
_getAddress = () =>
|
|
{
|
|
_dalamudUtil.EnsureIsOnFramework();
|
|
return getAddress.Invoke();
|
|
};
|
|
_isOwnedObject = ownedObject;
|
|
Name = string.Empty;
|
|
|
|
if (ownedObject)
|
|
{
|
|
Mediator.Subscribe<TransientResourceChangedMessage>(this, (msg) =>
|
|
{
|
|
if (_delayedZoningTask?.IsCompleted ?? true)
|
|
{
|
|
if (msg.Address != Address) return;
|
|
Mediator.Publish(new CreateCacheForObjectMessage(this));
|
|
}
|
|
});
|
|
}
|
|
|
|
if (_isOwnedObject)
|
|
{
|
|
EnableFrameworkUpdates();
|
|
}
|
|
|
|
Mediator.Subscribe<ZoneSwitchEndMessage>(this, (_) => ZoneSwitchEnd());
|
|
Mediator.Subscribe<ZoneSwitchStartMessage>(this, (_) => ZoneSwitchStart());
|
|
|
|
Mediator.Subscribe<CutsceneStartMessage>(this, (_) =>
|
|
{
|
|
_haltProcessing = true;
|
|
});
|
|
Mediator.Subscribe<CutsceneEndMessage>(this, (_) =>
|
|
{
|
|
_haltProcessing = false;
|
|
ZoneSwitchEnd();
|
|
});
|
|
Mediator.Subscribe<PenumbraStartRedrawMessage>(this, (msg) =>
|
|
{
|
|
if (msg.Address == Address)
|
|
{
|
|
_haltProcessing = true;
|
|
}
|
|
});
|
|
Mediator.Subscribe<PenumbraEndRedrawMessage>(this, (msg) =>
|
|
{
|
|
if (msg.Address == Address)
|
|
{
|
|
_haltProcessing = false;
|
|
}
|
|
});
|
|
|
|
Mediator.Publish(new GameObjectHandlerCreatedMessage(this, _isOwnedObject));
|
|
|
|
_dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult();
|
|
}
|
|
|
|
public enum DrawCondition
|
|
{
|
|
None,
|
|
ObjectZero,
|
|
DrawObjectZero,
|
|
RenderFlags,
|
|
ModelInSlotLoaded,
|
|
ModelFilesInSlotLoaded
|
|
}
|
|
|
|
public IntPtr Address { get; private set; }
|
|
public DrawCondition CurrentDrawCondition { get; set; } = DrawCondition.None;
|
|
public byte Gender { get; private set; }
|
|
public string Name { get; private set; }
|
|
public uint EntityId { get; private set; } = uint.MaxValue;
|
|
public ObjectKind ObjectKind { get; }
|
|
public byte RaceId { get; private set; }
|
|
public byte TribeId { get; private set; }
|
|
private byte[] CustomizeData { get; set; } = new byte[26];
|
|
private IntPtr DrawObjectAddress { get; set; }
|
|
private byte[] EquipSlotData { get; set; } = new byte[40];
|
|
private ushort[] MainHandData { get; set; } = new ushort[3];
|
|
private ushort[] OffHandData { get; set; } = new ushort[3];
|
|
|
|
public async Task ActOnFrameworkAfterEnsureNoDrawAsync(Action<Dalamud.Game.ClientState.Objects.Types.ICharacter> act, CancellationToken token)
|
|
{
|
|
while (await _dalamudUtil.RunOnFrameworkThread(() =>
|
|
{
|
|
EnsureLatestObjectState();
|
|
if (CurrentDrawCondition != DrawCondition.None) return true;
|
|
var gameObj = _dalamudUtil.CreateGameObject(Address);
|
|
if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter chara)
|
|
{
|
|
act.Invoke(chara);
|
|
}
|
|
return false;
|
|
}).ConfigureAwait(false))
|
|
{
|
|
await Task.Delay(250, token).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
public void CompareNameAndThrow(string name)
|
|
{
|
|
if (!string.Equals(Name, name, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new InvalidOperationException("Player name not equal to requested name, pointer invalid");
|
|
}
|
|
if (Address == IntPtr.Zero)
|
|
{
|
|
throw new InvalidOperationException("Player pointer is zero, pointer invalid");
|
|
}
|
|
}
|
|
|
|
public Dalamud.Game.ClientState.Objects.Types.IGameObject? GetGameObject()
|
|
{
|
|
return _dalamudUtil.CreateGameObject(Address);
|
|
}
|
|
|
|
public void Invalidate()
|
|
{
|
|
Address = IntPtr.Zero;
|
|
DrawObjectAddress = IntPtr.Zero;
|
|
EntityId = uint.MaxValue;
|
|
_haltProcessing = false;
|
|
}
|
|
|
|
public void Refresh()
|
|
{
|
|
_dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult();
|
|
}
|
|
|
|
public async Task<bool> IsBeingDrawnRunOnFrameworkAsync()
|
|
{
|
|
return await _dalamudUtil.RunOnFrameworkThread(IsBeingDrawn).ConfigureAwait(false);
|
|
}
|
|
|
|
public override string ToString()
|
|
{
|
|
var owned = _isOwnedObject ? "Self" : "Other";
|
|
return $"{owned}/{ObjectKind}:{Name} ({Address:X},{DrawObjectAddress:X})";
|
|
}
|
|
|
|
private void CheckAndUpdateObject() => CheckAndUpdateObject(allowPublish: true);
|
|
|
|
private unsafe void CheckAndUpdateObject(bool allowPublish)
|
|
{
|
|
var prevAddr = Address;
|
|
var prevDrawObj = DrawObjectAddress;
|
|
string? nameString = null;
|
|
|
|
var nextAddr = _getAddress();
|
|
|
|
if (nextAddr != IntPtr.Zero && !PtrGuard.LooksLikePtr(nextAddr))
|
|
{
|
|
nextAddr = IntPtr.Zero;
|
|
}
|
|
|
|
if (nextAddr != IntPtr.Zero &&
|
|
!PtrGuard.IsReadable(nextAddr, (nuint)sizeof(FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject)))
|
|
{
|
|
nextAddr = IntPtr.Zero;
|
|
}
|
|
|
|
Address = nextAddr;
|
|
|
|
if (Address != IntPtr.Zero)
|
|
{
|
|
var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address;
|
|
|
|
var draw = (nint)gameObject->DrawObject;
|
|
|
|
if (!PtrGuard.LooksLikePtr(draw) || !PtrGuard.IsReadable(draw, (nuint)sizeof(DrawObject)))
|
|
draw = 0;
|
|
|
|
DrawObjectAddress = draw;
|
|
EntityId = gameObject->EntityId;
|
|
|
|
if (PtrGuard.IsReadable(Address, (nuint)sizeof(Character)))
|
|
{
|
|
var chara = (Character*)Address;
|
|
nameString = chara->GameObject.NameString;
|
|
|
|
if (!string.IsNullOrEmpty(nameString) && !string.Equals(nameString, Name, StringComparison.Ordinal))
|
|
Name = nameString;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
DrawObjectAddress = IntPtr.Zero;
|
|
EntityId = uint.MaxValue;
|
|
}
|
|
|
|
CurrentDrawCondition = (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero)
|
|
? IsBeingDrawnUnsafe()
|
|
: DrawCondition.DrawObjectZero;
|
|
|
|
if (_haltProcessing || !allowPublish) return;
|
|
|
|
bool drawObjDiff = DrawObjectAddress != prevDrawObj;
|
|
bool addrDiff = Address != prevAddr;
|
|
|
|
if (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero
|
|
&& PtrGuard.IsReadable(Address, (nuint)sizeof(Character))
|
|
&& PtrGuard.IsReadable(DrawObjectAddress, (nuint)sizeof(DrawObject)))
|
|
{
|
|
var chara = (Character*)Address;
|
|
var drawObj = (DrawObject*)DrawObjectAddress;
|
|
|
|
var objType = drawObj->Object.GetObjectType();
|
|
var isHuman = objType == ObjectType.CharacterBase
|
|
&& ((CharacterBase*)drawObj)->GetModelType() == CharacterBase.ModelType.Human;
|
|
|
|
nameString ??= chara->GameObject.NameString;
|
|
var nameChange = !string.Equals(nameString, Name, StringComparison.Ordinal);
|
|
if (nameChange) Name = nameString;
|
|
|
|
bool equipDiff = false;
|
|
|
|
if (isHuman)
|
|
{
|
|
if (PtrGuard.IsReadable(DrawObjectAddress, (nuint)sizeof(Human)))
|
|
{
|
|
var classJob = chara->CharacterData.ClassJob;
|
|
if (classJob != _classJob)
|
|
{
|
|
Logger.LogTrace("[{this}] classjob changed from {old} to {new}", this, _classJob, classJob);
|
|
_classJob = classJob;
|
|
Mediator.Publish(new ClassJobChangedMessage(this));
|
|
}
|
|
|
|
equipDiff = CompareAndUpdateEquipByteData((byte*)&((Human*)drawObj)->Head);
|
|
|
|
ref var mh = ref chara->DrawData.Weapon(WeaponSlot.MainHand);
|
|
ref var oh = ref chara->DrawData.Weapon(WeaponSlot.OffHand);
|
|
|
|
equipDiff |= CompareAndUpdateMainHand((Weapon*)mh.DrawObject);
|
|
equipDiff |= CompareAndUpdateOffHand((Weapon*)oh.DrawObject);
|
|
}
|
|
else
|
|
{
|
|
isHuman = false;
|
|
}
|
|
}
|
|
|
|
if (!isHuman)
|
|
{
|
|
equipDiff = CompareAndUpdateEquipByteData((byte*)Unsafe.AsPointer(ref chara->DrawData.EquipmentModelIds[0]));
|
|
}
|
|
|
|
if (equipDiff && !_isOwnedObject)
|
|
{
|
|
Logger.LogTrace("[{this}] Changed", this);
|
|
return;
|
|
}
|
|
|
|
bool customizeDiff = false;
|
|
|
|
if (isHuman && PtrGuard.IsReadable(DrawObjectAddress, (nuint)sizeof(Human)))
|
|
{
|
|
var human = (Human*)drawObj;
|
|
|
|
var gender = human->Customize.Sex;
|
|
var raceId = human->Customize.Race;
|
|
var tribeId = human->Customize.Tribe;
|
|
|
|
if (_isOwnedObject && ObjectKind == ObjectKind.Player
|
|
&& (gender != Gender || raceId != RaceId || tribeId != TribeId))
|
|
{
|
|
Mediator.Publish(new CensusUpdateMessage(gender, raceId, tribeId));
|
|
Gender = gender;
|
|
RaceId = raceId;
|
|
TribeId = tribeId;
|
|
}
|
|
|
|
customizeDiff = CompareAndUpdateCustomizeData(human->Customize.Data);
|
|
}
|
|
else
|
|
{
|
|
customizeDiff = CompareAndUpdateCustomizeData(chara->DrawData.CustomizeData.Data);
|
|
}
|
|
|
|
if ((addrDiff || drawObjDiff || equipDiff || customizeDiff || nameChange) && _isOwnedObject)
|
|
{
|
|
Logger.LogDebug("[{this}] Changed, Sending CreateCacheObjectMessage", this);
|
|
Mediator.Publish(new CreateCacheForObjectMessage(this));
|
|
}
|
|
}
|
|
else if (addrDiff || drawObjDiff)
|
|
{
|
|
CurrentDrawCondition = DrawCondition.DrawObjectZero;
|
|
Logger.LogTrace("[{this}] Changed", this);
|
|
if (_isOwnedObject && ObjectKind != ObjectKind.Player)
|
|
Mediator.Publish(new ClearCacheForObjectMessage(this));
|
|
}
|
|
}
|
|
|
|
|
|
private unsafe bool CompareAndUpdateCustomizeData(Span<byte> customizeData)
|
|
{
|
|
bool hasChanges = false;
|
|
|
|
for (int i = 0; i < customizeData.Length; i++)
|
|
{
|
|
var data = customizeData[i];
|
|
if (CustomizeData[i] != data)
|
|
{
|
|
CustomizeData[i] = data;
|
|
hasChanges = true;
|
|
}
|
|
}
|
|
|
|
return hasChanges;
|
|
}
|
|
|
|
private unsafe bool CompareAndUpdateEquipByteData(byte* equipSlotData)
|
|
{
|
|
bool hasChanges = false;
|
|
for (int i = 0; i < EquipSlotData.Length; i++)
|
|
{
|
|
var data = equipSlotData[i];
|
|
if (EquipSlotData[i] != data)
|
|
{
|
|
EquipSlotData[i] = data;
|
|
hasChanges = true;
|
|
}
|
|
}
|
|
|
|
return hasChanges;
|
|
}
|
|
|
|
private unsafe bool CompareAndUpdateMainHand(Weapon* weapon)
|
|
{
|
|
var p = (nint)weapon;
|
|
if (!PtrGuard.LooksLikePtr(p) || !PtrGuard.IsReadable(p, (nuint)sizeof(Weapon)))
|
|
return false;
|
|
|
|
bool hasChanges = false;
|
|
hasChanges |= weapon->ModelSetId != MainHandData[0];
|
|
MainHandData[0] = weapon->ModelSetId;
|
|
hasChanges |= weapon->Variant != MainHandData[1];
|
|
MainHandData[1] = weapon->Variant;
|
|
hasChanges |= weapon->SecondaryId != MainHandData[2];
|
|
MainHandData[2] = weapon->SecondaryId;
|
|
return hasChanges;
|
|
}
|
|
|
|
private unsafe bool CompareAndUpdateOffHand(Weapon* weapon)
|
|
{
|
|
var p = (nint)weapon;
|
|
if (!PtrGuard.LooksLikePtr(p) || !PtrGuard.IsReadable(p, (nuint)sizeof(Weapon)))
|
|
return false;
|
|
|
|
bool hasChanges = false;
|
|
hasChanges |= weapon->ModelSetId != OffHandData[0];
|
|
OffHandData[0] = weapon->ModelSetId;
|
|
hasChanges |= weapon->Variant != OffHandData[1];
|
|
OffHandData[1] = weapon->Variant;
|
|
hasChanges |= weapon->SecondaryId != OffHandData[2];
|
|
OffHandData[2] = weapon->SecondaryId;
|
|
return hasChanges;
|
|
}
|
|
|
|
private void FrameworkUpdate()
|
|
{
|
|
try
|
|
{
|
|
var zoningDelayActive = !(_delayedZoningTask?.IsCompleted ?? true);
|
|
_performanceCollector.LogPerformance(this, $"CheckAndUpdateObject>{(_isOwnedObject ? "Self" : "Other")}+{ObjectKind}/{(string.IsNullOrEmpty(Name) ? "Unk" : Name)}", () => CheckAndUpdateObject(allowPublish: !zoningDelayActive));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarning(ex, "Error during FrameworkUpdate of {this}", this);
|
|
}
|
|
}
|
|
|
|
private bool IsBeingDrawn()
|
|
{
|
|
EnsureLatestObjectState();
|
|
|
|
if (_dalamudUtil.IsAnythingDrawing)
|
|
{
|
|
Logger.LogTrace("[{this}] IsBeingDrawn, Global draw block", this);
|
|
return true;
|
|
}
|
|
|
|
Logger.LogTrace("[{this}] IsBeingDrawn, Condition: {cond}", this, CurrentDrawCondition);
|
|
return CurrentDrawCondition != DrawCondition.None;
|
|
}
|
|
|
|
private void EnsureLatestObjectState()
|
|
{
|
|
if (_haltProcessing || !_frameworkUpdateSubscribed)
|
|
{
|
|
CheckAndUpdateObject();
|
|
}
|
|
}
|
|
|
|
private void EnableFrameworkUpdates()
|
|
{
|
|
lock (_frameworkUpdateGate)
|
|
{
|
|
if (_frameworkUpdateSubscribed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Mediator.Subscribe<FrameworkUpdateMessage>(this, _ => FrameworkUpdate());
|
|
_frameworkUpdateSubscribed = true;
|
|
}
|
|
}
|
|
|
|
private unsafe DrawCondition IsBeingDrawnUnsafe()
|
|
{
|
|
if (Address == IntPtr.Zero) return DrawCondition.ObjectZero;
|
|
if (DrawObjectAddress == IntPtr.Zero) return DrawCondition.DrawObjectZero;
|
|
var visibilityFlags = ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address)->RenderFlags;
|
|
if (visibilityFlags != VisibilityFlags.None) return DrawCondition.RenderFlags;
|
|
|
|
if (ObjectKind == ObjectKind.Player)
|
|
{
|
|
var modelInSlotLoaded = (((CharacterBase*)DrawObjectAddress)->HasModelInSlotLoaded != 0);
|
|
if (modelInSlotLoaded) return DrawCondition.ModelInSlotLoaded;
|
|
var modelFilesInSlotLoaded = (((CharacterBase*)DrawObjectAddress)->HasModelFilesInSlotLoaded != 0);
|
|
if (modelFilesInSlotLoaded) return DrawCondition.ModelFilesInSlotLoaded;
|
|
}
|
|
|
|
return DrawCondition.None;
|
|
}
|
|
|
|
private void ZoneSwitchEnd()
|
|
{
|
|
if (!_isOwnedObject) return;
|
|
|
|
try
|
|
{
|
|
_zoningCts?.CancelAfter(2500);
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
// ignore
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarning(ex, "Zoning CTS cancel issue");
|
|
}
|
|
}
|
|
|
|
private void ZoneSwitchStart()
|
|
{
|
|
if (!_isOwnedObject) return;
|
|
|
|
_zoningCts = new();
|
|
Logger.LogDebug("[{obj}] Starting Delay After Zoning", this);
|
|
_delayedZoningTask = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
await Task.Delay(TimeSpan.FromSeconds(120), _zoningCts.Token).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
// ignore cancelled
|
|
}
|
|
finally
|
|
{
|
|
Logger.LogDebug("[{this}] Delay after zoning complete", this);
|
|
_zoningCts.Dispose();
|
|
}
|
|
}, _zoningCts.Token);
|
|
}
|
|
} |