Compare commits

...

92 Commits

Author SHA1 Message Date
cake
699535b68b Last commit for this. will stop 2026-01-17 22:31:50 +01:00
cake
828705cbfb Added documentation, support of log out and log on added. 2026-01-16 17:16:29 +01:00
cake
d8b4122ec3 Removed unsafe handling of game object and owned object 2026-01-16 15:54:38 +01:00
cake
f6a5c85c2d Added debug information regarding minions 2026-01-15 06:11:07 +01:00
cake
9fcbd68ca2 Removel warm reload start 2026-01-14 01:09:08 +01:00
cake
73dee6d9a5 Added seperate collections for other states, moved clean up of penumbra collection out of config. Safe read of ptr on process, fixed notfications on popup and notifications with flags. 2026-01-13 17:45:32 +01:00
Tsubasahane
4502cadaeb Fix lumina offset for WorldSheet 2026-01-12 13:58:23 +08:00
7f33b6a4ce Fixed missing symbol
"@" was missing for the LightlessClient.zip
2026-01-09 06:11:08 +00:00
61f584f059 Merge pull request 'ci-cd-rework' (#137) from ci-cd-rework into 2.0.3
Reviewed-on: #137
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2026-01-09 05:58:21 +00:00
95d286f990 Yeet 2026-01-08 20:35:15 +00:00
choco
42d6a19db1 syncshell list cleanup 2026-01-07 01:08:28 +01:00
choco
05f7d256d7 syncshell join modal to overlay, which doesnt prevent game interaction 2026-01-07 00:54:50 +01:00
cake
058ba504cb Updated text of options 2026-01-06 15:10:15 +01:00
cake
19966f3828 Default to unsafe 2026-01-06 15:06:21 +01:00
cake
d8b9e9cf19 Splitting havok tasks. 2026-01-06 14:27:01 +01:00
cake
ad34d88336 Merged I18N and latest 2.0.3 changes 2026-01-06 13:54:04 +01:00
9167bb1afd i18n init (#135)
shouldnt break anything?

Co-authored-by: Tsubasahane <wozaiha@gmail.com>
Reviewed-on: #135
Co-authored-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
Co-committed-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
2026-01-06 12:51:29 +00:00
cake
5161c6bad3 Attempt fix on crash. 2026-01-06 13:50:58 +01:00
defnotken
ce28799db3 More checks for animations and bones. 2026-01-05 20:46:14 -06:00
defnotken
9b9010ab8e Defenses? 2026-01-05 18:57:18 -06:00
cake
032201ed9e Changed logging, last change of gameobject 2026-01-06 00:31:08 +01:00
cake
775b128cf3 Removal of parameter 2026-01-06 00:23:24 +01:00
cake
4bb8db8c03 Game object handler changes. 2026-01-06 00:22:22 +01:00
defnotken
f307c65c66 check nulls remove redundant catches. 2026-01-05 17:19:31 -06:00
defnotken
4eec363cd2 yeet some comments 2026-01-05 15:40:32 -06:00
defnotken
d00df84ed6 even more violation checks.... 2026-01-05 15:39:18 -06:00
defnotken
9048b3bd87 more checks on drawing 2026-01-05 15:07:48 -06:00
defnotken
a2ed9f8d2b Adding memory violations catches and null checks to NameString and GameObj 2026-01-05 14:48:14 -06:00
8e08da7471 Chat changes for 2.0.3 (#134)
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #134
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2026-01-05 19:58:10 +00:00
defnotken
3205e6e0c3 Adding AccessViolationException catch to return true for NullDrawObject 2026-01-05 10:40:31 -06:00
cake
d16e46200d Added clear of block of pap files. 2026-01-05 16:41:30 +01:00
cake
5fc13647ae Fixed name getting, cast fix on compact ui 2026-01-05 14:24:07 +01:00
cake
39d5d9d7c1 Another few fixes. 2026-01-05 01:54:19 +01:00
cake
c19db58ead Fix build error from conflict 2026-01-05 01:49:00 +01:00
30717ba200 Merged Cake and Abel branched into 2.0.3 (#131)
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: cake <admin@cakeandbanana.nl>
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #131
2026-01-05 00:45:14 +00:00
e0b8070aa8 Merge pull request 'Lifestream IPC witrh Debug Example' (#124) from lifestream-ipc into 2.0.3
Reviewed-on: #124
2026-01-04 14:50:00 +00:00
3241b9222b Merge pull request 'Changes of admin ui for banning users.' (#128) from ban-admin-changes into 2.0.3
Reviewed-on: #128
2026-01-04 14:49:47 +00:00
80b082240f Merge branch '2.0.3' into ban-admin-changes 2026-01-04 14:49:38 +00:00
b8c8f3dffd Merge pull request 'Lightless Lightfinder redesign + stuff' (#127) from 2.0.0-crashing-bugfixes into 2.0.3
Reviewed-on: #127
2026-01-04 14:21:27 +00:00
543ea6c865 Merge branch '2.0.3' into 2.0.0-crashing-bugfixes 2026-01-04 14:19:23 +00:00
defnotken
de9c9955ef add more functionality for future features. 2026-01-04 00:54:40 -06:00
cake
2eb0c463e3 Fixed refreshing of ban list 2026-01-04 05:40:34 +01:00
cake
cd510f93af Changed banning into syncshell 2026-01-04 05:08:08 +01:00
cake
3bbda69699 Revert "Added another try on fetching download status"
This reverts commit deb7f67e59.
2026-01-03 23:22:18 +01:00
cake
deb7f67e59 Added another try on fetching download status 2026-01-03 23:12:18 +01:00
choco
9ba45670c5 top menu cleanup, removed duplicate old code 2026-01-03 02:08:28 +01:00
cake
f7bb73bcd1 Updated api 2026-01-02 18:34:07 +01:00
choco
4c07162ee3 Merge remote-tracking branch 'origin/2.0.3' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessAPI
2026-01-02 09:26:21 +01:00
choco
a4d62af73d lightfinder user text 2026-01-02 09:23:23 +01:00
choco
5fba3c01e7 lightfinder nearby badge alignment 2026-01-02 09:19:39 +01:00
defnotken
df33a0f0a2 Move buttons to debug 2026-01-01 17:27:12 -06:00
c439d1c822 Merge branch '2.0.3' into lifestream-ipc 2026-01-01 23:21:46 +00:00
choco
906dda3885 lightfinder nearby badge icon 2026-01-01 22:32:45 +01:00
choco
f812b6d09e own syncshell sometimes not showing in list bug 2026-01-01 22:32:34 +01:00
7e61954541 Location Sharing 2.0 (#125)
Need: Lightless-Sync/LightlessServer#49
Authored-by: Tsubasahane <wozaiha@gmail.com>
Reviewed-on: #125
Reviewed-by: cake <cake@noreply.git.lightless-sync.org>
Co-authored-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
Co-committed-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
2025-12-31 17:31:31 +00:00
choco
89f59a98f5 Merge remote-tracking branch 'origin/2.0.3' into 2.0.0-crashing-bugfixes 2025-12-31 09:02:55 +01:00
defnotken
fb58d8657d Lifestream IPC witrh Debug Example 2025-12-30 23:43:22 -06:00
bbb3375661 2.0.3 staaato 2025-12-31 02:44:31 +00:00
choco
e95a2c3352 Merge remote-tracking branch 'refs/remotes/origin/2.0.2' into 2.0.0-crashing-bugfixes 2025-12-30 19:32:42 +01:00
choco
a8340c3279 Merge remote-tracking branch 'origin/2.0.2-Location' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessSync/Services/DalamudUtilService.cs
2025-12-30 14:55:42 +01:00
Tsubasahane
e25979e089 fix Icon direction 2025-12-30 18:04:54 +08:00
Tsubasahane
ca7375b9c3 dont check location when target is offline 2025-12-30 14:42:02 +08:00
Tsubasahane
f8752fcb4d changed kanmoji to show correctly 2025-12-30 14:37:13 +08:00
Tsubasahane
d1c955c74f Reuse WorldData and make context menu work for non-Global uses 2025-12-30 14:23:37 +08:00
Tsubasahane
91e60694ad triggers update when map changes 2025-12-30 11:20:12 +08:00
Tsubasahane
f37fdefddd show icon correctly 2025-12-29 16:43:12 +08:00
Tsubasahane
18fa0a47b1 Locationshare fix 2025-12-29 15:42:55 +08:00
Tsubasahane
9f5cc9e0d1 Merge branch '2.0.2' into 2.0.2-Location 2025-12-29 14:48:07 +08:00
choco
b02db4c1e1 Merge remote-tracking branch 'origin/2.0.0-crashing-bugfixes' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessSync/Services/DalamudUtilService.cs
#	LightlessSync/UI/DtrEntry.cs
2025-12-28 16:56:06 +01:00
cake
d6b31ed5b9 Fixed finder again. 2025-12-28 16:55:01 +01:00
cake
9e600bfae0 Fixed merge conflicts. 2025-12-28 16:48:51 +01:00
cake
1a73d5a4d9 2.0.2 merged again 2025-12-28 16:40:47 +01:00
Tsubasahane
a933330418 Share location 2025-12-28 23:07:45 +08:00
Tsubasahane
ea34b18f40 Merge branch '2.0.2' into 2.0.2-Location 2025-12-28 13:10:17 +08:00
defnotken
67dc215e83 Merge branch '2.0.2-Location' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 2.0.2-Location 2025-12-27 21:17:32 -06:00
defnotken
baf3869cec Merge conf 2025-12-27 21:17:26 -06:00
Tsubasahane
eeda5aeb66 Revert "Location Sharing"
This reverts commit 70745613e1.
2025-12-28 10:54:01 +08:00
choco
754df95071 Merge remote-tracking branch 'origin/2.0.2-Location' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessSync/UI/DtrEntry.cs
2025-12-27 23:13:20 +01:00
choco
24fca31606 join syncshell draw modal 2025-12-27 23:09:29 +01:00
choco
a99c1c01b0 Merge remote-tracking branch 'origin/2.0.2' into 2.0.0-crashing-bugfixes 2025-12-27 23:08:03 +01:00
choco
85999fab8f Merge remote-tracking branch 'origin/2.0.2' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs
#	LightlessSync/UI/SyncshellFinderUI.cs
#	LightlessSync/UI/TopTabMenu.cs
#	LightlessSync/WebAPI/Files/FileDownloadManager.cs
2025-12-27 20:49:20 +01:00
Tsubasahane
70745613e1 Location Sharing 2025-12-27 19:57:21 +08:00
Tsubasahane
5c8e239a7b implement playerState
- use IPlayerState for DalamudUtilService and make things less asynced
- make LocationInfo work with ContentFinderData
2025-12-27 17:04:39 +08:00
choco
5eed65149a nearby lightfinder users window, wiht pair func 2025-12-27 02:38:56 +01:00
cake
1ab4e2f94b Added color options for header 2025-12-26 22:26:29 +01:00
choco
f792bc1954 compact ui design refactor with lightfinder redesign 2025-12-26 00:00:13 +01:00
choco
ced72ab9eb icon centering changes 2025-12-24 16:59:46 +01:00
choco
6c1cc77aaa settings animated header 2025-12-23 17:36:36 +01:00
choco
5b81caf5a8 compact menu redesign with new animated particle header, enable particles toggle added in UI settings 2025-12-23 17:16:51 +01:00
choco
4e03b381dc animated header main menu redesign test 2025-12-23 00:48:47 +01:00
choco
3222133aa0 Merge branch '2.0.1' into 2.0.0-crashing-bugfixes 2025-12-23 00:36:56 +01:00
choco
0ec423e65c potential resolve disposal crashes and race conditions 2025-12-21 22:34:39 +01:00
120 changed files with 20765 additions and 3881 deletions

View File

@@ -9,6 +9,7 @@ env:
DOTNET_VERSION: |
10.x.x
9.x.x
DOTNET_CLI_TELEMETRY_OPTOUT: true
jobs:
tag-and-release:
@@ -32,16 +33,14 @@ jobs:
- name: Download Dalamud
run: |
cd /
mkdir -p root/.xlcore/dalamud/Hooks/dev
mkdir -p ~/.xlcore/dalamud/Hooks/dev
curl -O https://goatcorp.github.io/dalamud-distrib/stg/latest.zip
unzip latest.zip -d /root/.xlcore/dalamud/Hooks/dev
unzip latest.zip -d ~/.xlcore/dalamud/Hooks/dev
- name: Lets Build Lightless!
run: |
dotnet restore
dotnet build --configuration Release --no-restore
dotnet publish --configuration Release --no-build
dotnet publish --configuration Release
mv LightlessSync/bin/x64/Release/LightlessSync/latest.zip LightlessClient.zip
- name: Get version
id: package_version
@@ -53,19 +52,6 @@ jobs:
run: |
echo "Version: ${{ steps.package_version.outputs.version }}"
- name: Prepare Lightless Client
run: |
PUBLISH_PATH="/workspace/Lightless-Sync/LightlessClient/LightlessSync/bin/x64/Release/publish/"
if [ -d "$PUBLISH_PATH" ]; then
rm -rf "$PUBLISH_PATH"
echo "Removed $PUBLISH_PATH"
else
echo "$PUBLISH_PATH does not exist, nothing to remove."
fi
mkdir -p output
(cd /workspace/Lightless-Sync/LightlessClient/LightlessSync/bin/x64/Release/ && zip -r $OLDPWD/output/LightlessClient.zip *)
- name: Create Git tag if not exists (master)
if: github.ref == 'refs/heads/master'
run: |
@@ -163,13 +149,6 @@ jobs:
echo "release_id=$release_id" >> $GITHUB_OUTPUT || echo "::set-output name=release_id::$release_id"
echo "RELEASE_ID=$release_id" >> $GITHUB_ENV
- name: Check asset exists
run: |
if [ ! -f output/LightlessClient.zip ]; then
echo "output/LightlessClient.zip does not exist!"
exit 1
fi
- name: Upload Assets to release
env:
RELEASE_ID: ${{ env.RELEASE_ID }}
@@ -177,7 +156,7 @@ jobs:
echo "Uploading to release ID: $RELEASE_ID"
curl --fail-with-body -s -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@output/LightlessClient.zip" \
-F "attachment=@LightlessClient.zip" \
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets"
- name: Clone plugin hosting repo
@@ -186,7 +165,7 @@ jobs:
cd LightlessSyncRepo
git clone https://git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessSync.git
env:
GIT_TERMINAL_PROMPT: 0
GIT_TERMINAL_PROMPT: 0
- name: Update plogonmaster.json with version (master)
if: github.ref == 'refs/heads/master'
@@ -282,8 +261,8 @@ jobs:
- name: Commit and push to LightlessSync
run: |
cd LightlessSyncRepo/LightlessSync
git config user.name "github-actions"
git config user.email "github-actions@github.com"
git config user.name "Gitea-Automation"
git config user.email "aaa@aaaaaaa.aaa"
git add .
git diff-index --quiet HEAD || git commit -m "Update ${{ env.PLUGIN_NAME }} to ${{ steps.package_version.outputs.version }}"
git push https://x-access-token:${{ secrets.AUTOMATION_TOKEN }}@git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessSync.git HEAD:main

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@ public sealed class FileCacheManager : IHostedService
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, FileCacheEntity>> _fileCaches = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, FileCacheEntity> _fileCachesByPrefixedPath = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _getCachesByPathsSemaphore = new(1, 1);
private readonly SemaphoreSlim _evictSemaphore = new(1, 1);
private readonly Lock _fileWriteLock = new();
private readonly IpcManager _ipcManager;
private readonly ILogger<FileCacheManager> _logger;
@@ -226,13 +227,23 @@ public sealed class FileCacheManager : IHostedService
var compressed = LZ4Wrapper.WrapHC(raw, 0, raw.Length);
var tmpPath = compressedPath + ".tmp";
await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false);
File.Move(tmpPath, compressedPath, overwrite: true);
try
{
await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false);
File.Move(tmpPath, compressedPath, overwrite: true);
}
finally
{
try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { /* ignore */ }
}
var compressedSize = compressed.LongLength;
var compressedSize = new FileInfo(compressedPath).Length;
SetSizeInfo(hash, originalSize, compressedSize);
UpdateEntitiesSizes(hash, originalSize, compressedSize);
var maxBytes = GiBToBytes(_configService.Current.MaxLocalCacheInGiB);
await EnforceCacheLimitAsync(maxBytes, token).ConfigureAwait(false);
return compressed;
}
finally
@@ -280,6 +291,26 @@ public sealed class FileCacheManager : IHostedService
return CreateFileEntity(cacheFolder, CachePrefix, fi);
}
public FileCacheEntity? CreateCacheEntryWithKnownHash(string path, string hash)
{
if (string.IsNullOrWhiteSpace(hash))
{
return CreateCacheEntry(path);
}
FileInfo fi = new(path);
if (!fi.Exists) return null;
_logger.LogTrace("Creating cache entry for {path} using provided hash", path);
var cacheFolder = _configService.Current.CacheFolder;
if (string.IsNullOrEmpty(cacheFolder)) return null;
if (!TryBuildPrefixedPath(fi.FullName, cacheFolder, CachePrefix, out var prefixedPath, out _))
{
return null;
}
return CreateFileCacheEntity(fi, prefixedPath, hash);
}
public FileCacheEntity? CreateFileEntry(string path)
{
FileInfo fi = new(path);
@@ -562,9 +593,10 @@ public sealed class FileCacheManager : IHostedService
}
}
public void RemoveHashedFile(string hash, string prefixedFilePath)
public void RemoveHashedFile(string hash, string prefixedFilePath, bool removeDerivedFiles = true)
{
var normalizedPath = NormalizePrefixedPathKey(prefixedFilePath);
var removedHash = false;
if (_fileCaches.TryGetValue(hash, out var caches))
{
@@ -577,11 +609,16 @@ public sealed class FileCacheManager : IHostedService
if (caches.IsEmpty)
{
_fileCaches.TryRemove(hash, out _);
removedHash = _fileCaches.TryRemove(hash, out _);
}
}
_fileCachesByPrefixedPath.TryRemove(normalizedPath, out _);
if (removeDerivedFiles && removedHash)
{
RemoveDerivedCacheFiles(hash);
}
}
public void UpdateHashedFile(FileCacheEntity fileCache, bool computeProperties = true)
@@ -597,7 +634,8 @@ public sealed class FileCacheManager : IHostedService
fileCache.Hash = Crypto.ComputeFileHash(fileCache.ResolvedFilepath, Crypto.HashAlgo.Sha1);
fileCache.LastModifiedDateTicks = fi.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture);
}
RemoveHashedFile(oldHash, prefixedPath);
var removeDerivedFiles = !string.Equals(oldHash, fileCache.Hash, StringComparison.OrdinalIgnoreCase);
RemoveHashedFile(oldHash, prefixedPath, removeDerivedFiles);
AddHashedFile(fileCache);
}
@@ -747,7 +785,7 @@ public sealed class FileCacheManager : IHostedService
{
try
{
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath, removeDerivedFiles: false);
var extensionPath = fileCache.ResolvedFilepath.ToUpper(CultureInfo.InvariantCulture) + "." + ext;
File.Move(fileCache.ResolvedFilepath, extensionPath, overwrite: true);
var newHashedEntity = new FileCacheEntity(fileCache.Hash, fileCache.PrefixedFilePath + "." + ext, DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture));
@@ -764,6 +802,33 @@ public sealed class FileCacheManager : IHostedService
}
}
private void RemoveDerivedCacheFiles(string hash)
{
var cacheFolder = _configService.Current.CacheFolder;
if (string.IsNullOrWhiteSpace(cacheFolder))
{
return;
}
TryDeleteDerivedCacheFile(Path.Combine(cacheFolder, "downscaled", $"{hash}.tex"));
TryDeleteDerivedCacheFile(Path.Combine(cacheFolder, "decimated", $"{hash}.mdl"));
}
private void TryDeleteDerivedCacheFile(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to delete derived cache file {path}", path);
}
}
private void AddHashedFile(FileCacheEntity fileCache)
{
var normalizedPath = NormalizePrefixedPathKey(fileCache.PrefixedFilePath);
@@ -877,6 +942,83 @@ public sealed class FileCacheManager : IHostedService
}, token).ConfigureAwait(false);
}
private async Task EnforceCacheLimitAsync(long maxBytes, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(CacheFolder) || maxBytes <= 0) return;
await _evictSemaphore.WaitAsync(token).ConfigureAwait(false);
try
{
Directory.CreateDirectory(CacheFolder);
foreach (var tmp in Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension + ".tmp"))
{
try { File.Delete(tmp); } catch { /* ignore */ }
}
var files = Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension, SearchOption.TopDirectoryOnly)
.Select(p => new FileInfo(p))
.Where(fi => fi.Exists)
.OrderBy(fi => fi.LastWriteTimeUtc)
.ToList();
long total = files.Sum(f => f.Length);
if (total <= maxBytes) return;
foreach (var fi in files)
{
token.ThrowIfCancellationRequested();
if (total <= maxBytes) break;
var hash = Path.GetFileNameWithoutExtension(fi.Name);
try
{
var len = fi.Length;
fi.Delete();
total -= len;
_sizeCache.TryRemove(hash, out _);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to evict cache file {file}", fi.FullName);
}
}
}
finally
{
_evictSemaphore.Release();
}
}
private static long GiBToBytes(double gib)
{
if (double.IsNaN(gib) || double.IsInfinity(gib) || gib <= 0)
return 0;
var bytes = gib * 1024d * 1024d * 1024d;
if (bytes >= long.MaxValue) return long.MaxValue;
return (long)Math.Round(bytes, MidpointRounding.AwayFromZero);
}
private void CleanupOrphanCompressedCache()
{
if (string.IsNullOrWhiteSpace(CacheFolder) || !Directory.Exists(CacheFolder))
return;
foreach (var path in Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension))
{
var hash = Path.GetFileNameWithoutExtension(path);
if (!_fileCaches.ContainsKey(hash))
{
try { File.Delete(path); }
catch (Exception ex) { _logger.LogWarning(ex, "Failed deleting orphan {file}", path); }
}
}
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting FileCacheManager");
@@ -1060,6 +1202,8 @@ public sealed class FileCacheManager : IHostedService
{
await WriteOutFullCsvAsync(cancellationToken).ConfigureAwait(false);
}
CleanupOrphanCompressedCache();
}
_logger.LogInformation("Started FileCacheManager");

View File

@@ -213,7 +213,7 @@ public sealed partial class FileCompactor : IDisposable
/// <param name="bytes">Bytes that have to be written</param>
/// <param name="token">Cancellation Token for interupts</param>
/// <returns>Writing Task</returns>
public async Task WriteAllBytesAsync(string filePath, byte[] bytes, CancellationToken token)
public async Task WriteAllBytesAsync(string filePath, byte[] bytes, CancellationToken token, bool enqueueCompaction = true)
{
var dir = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
@@ -221,6 +221,12 @@ public sealed partial class FileCompactor : IDisposable
await File.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false);
if (enqueueCompaction && _lightlessConfigService.Current.UseCompactor)
EnqueueCompaction(filePath);
}
public void RequestCompaction(string filePath)
{
if (_lightlessConfigService.Current.UseCompactor)
EnqueueCompaction(filePath);
}

View File

@@ -297,7 +297,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
private void DalamudUtil_FrameworkUpdate()
{
RefreshPlayerRelatedAddressMap();
_ = Task.Run(() => RefreshPlayerRelatedAddressMap());
lock (_cacheAdditionLock)
{
@@ -306,20 +306,64 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
if (_lastClassJobId != _dalamudUtil.ClassJobId)
{
_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);
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 = 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 _))
@@ -349,26 +393,6 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
_semiTransientResources = null;
}
private void RefreshPlayerRelatedAddressMap()
{
_playerRelatedByAddress.Clear();
var updatedFrameAddresses = new ConcurrentDictionary<nint, ObjectKind>();
lock (_playerRelatedLock)
{
foreach (var handler in _playerRelatedPointers)
{
var address = (nint)handler.Address;
if (address != nint.Zero)
{
_playerRelatedByAddress[address] = handler;
updatedFrameAddresses[address] = handler.ObjectKind;
}
}
}
_cachedFrameAddresses = updatedFrameAddresses;
}
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
{
if (descriptor.IsInGpose)

View File

@@ -1,4 +1,5 @@
using Dalamud.Plugin.Services;
using Dalamud.Game.ClientState.Objects.SubKinds;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.UI.Info;
using Microsoft.Extensions.Logging;
@@ -11,24 +12,35 @@ public unsafe class BlockedCharacterHandler
private readonly Dictionary<CharaData, bool> _blockedCharacterCache = new();
private readonly ILogger<BlockedCharacterHandler> _logger;
private readonly IObjectTable _objectTable;
public BlockedCharacterHandler(ILogger<BlockedCharacterHandler> logger, IGameInteropProvider gameInteropProvider)
public BlockedCharacterHandler(ILogger<BlockedCharacterHandler> logger, IGameInteropProvider gameInteropProvider, IObjectTable objectTable)
{
gameInteropProvider.InitializeFromAttributes(this);
_logger = logger;
_objectTable = objectTable;
}
private static CharaData GetIdsFromPlayerPointer(nint ptr)
private CharaData? TryGetIdsFromPlayerPointer(nint ptr, ushort objectIndex)
{
if (ptr == nint.Zero) return new(0, 0);
var castChar = ((BattleChara*)ptr);
if (ptr == nint.Zero || objectIndex >= 200)
return null;
var obj = _objectTable[objectIndex];
if (obj is not IPlayerCharacter player || player.Address != ptr)
return null;
var castChar = (BattleChara*)player.Address;
return new(castChar->Character.AccountId, castChar->Character.ContentId);
}
public bool IsCharacterBlocked(nint ptr, out bool firstTime)
public bool IsCharacterBlocked(nint ptr, ushort objectIndex, out bool firstTime)
{
firstTime = false;
var combined = GetIdsFromPlayerPointer(ptr);
var combined = TryGetIdsFromPlayerPointer(ptr, objectIndex);
if (combined == null)
return false;
if (_blockedCharacterCache.TryGetValue(combined, out var isBlocked))
return isBlocked;

View File

@@ -0,0 +1,11 @@
namespace Lifestream.Enums;
public enum ResidentialAetheryteKind
{
None = -1,
Uldah = 9,
Gridania = 2,
Limsa = 8,
Foundation = 70,
Kugane = 111,
}

View File

@@ -0,0 +1 @@
global using AddressBookEntryTuple = (string Name, int World, int City, int Ward, int PropertyType, int Plot, int Apartment, bool ApartmentSubdivision, bool AliasEnabled, string Alias);

View File

@@ -0,0 +1,129 @@
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using Lifestream.Enums;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Interop.Ipc;
public sealed class IpcCallerLifestream : IpcServiceBase
{
private static readonly IpcServiceDescriptor LifestreamDescriptor = new("Lifestream", "Lifestream", new Version(0, 0, 0, 0));
private readonly ICallGateSubscriber<string, object> _executeLifestreamCommand;
private readonly ICallGateSubscriber<AddressBookEntryTuple, bool> _isHere;
private readonly ICallGateSubscriber<AddressBookEntryTuple, object> _goToHousingAddress;
private readonly ICallGateSubscriber<bool> _isBusy;
private readonly ICallGateSubscriber<object> _abort;
private readonly ICallGateSubscriber<string, bool> _changeWorld;
private readonly ICallGateSubscriber<uint, bool> _changeWorldById;
private readonly ICallGateSubscriber<string, bool> _aetheryteTeleport;
private readonly ICallGateSubscriber<uint, bool> _aetheryteTeleportById;
private readonly ICallGateSubscriber<bool> _canChangeInstance;
private readonly ICallGateSubscriber<int> _getCurrentInstance;
private readonly ICallGateSubscriber<int> _getNumberOfInstances;
private readonly ICallGateSubscriber<int, object> _changeInstance;
private readonly ICallGateSubscriber<(ResidentialAetheryteKind, int, int)> _getCurrentPlotInfo;
public IpcCallerLifestream(IDalamudPluginInterface pi, LightlessMediator lightlessMediator, ILogger<IpcCallerLifestream> logger)
: base(logger, lightlessMediator, pi, LifestreamDescriptor)
{
_executeLifestreamCommand = pi.GetIpcSubscriber<string, object>("Lifestream.ExecuteCommand");
_isHere = pi.GetIpcSubscriber<AddressBookEntryTuple, bool>("Lifestream.IsHere");
_goToHousingAddress = pi.GetIpcSubscriber<AddressBookEntryTuple, object>("Lifestream.GoToHousingAddress");
_isBusy = pi.GetIpcSubscriber<bool>("Lifestream.IsBusy");
_abort = pi.GetIpcSubscriber<object>("Lifestream.Abort");
_changeWorld = pi.GetIpcSubscriber<string, bool>("Lifestream.ChangeWorld");
_changeWorldById = pi.GetIpcSubscriber<uint, bool>("Lifestream.ChangeWorldById");
_aetheryteTeleport = pi.GetIpcSubscriber<string, bool>("Lifestream.AetheryteTeleport");
_aetheryteTeleportById = pi.GetIpcSubscriber<uint, bool>("Lifestream.AetheryteTeleportById");
_canChangeInstance = pi.GetIpcSubscriber<bool>("Lifestream.CanChangeInstance");
_getCurrentInstance = pi.GetIpcSubscriber<int>("Lifestream.GetCurrentInstance");
_getNumberOfInstances = pi.GetIpcSubscriber<int>("Lifestream.GetNumberOfInstances");
_changeInstance = pi.GetIpcSubscriber<int, object>("Lifestream.ChangeInstance");
_getCurrentPlotInfo = pi.GetIpcSubscriber<(ResidentialAetheryteKind, int, int)>("Lifestream.GetCurrentPlotInfo");
CheckAPI();
}
public void ExecuteLifestreamCommand(string command)
{
if (!APIAvailable) return;
_executeLifestreamCommand.InvokeAction(command);
}
public bool IsHere(AddressBookEntryTuple entry)
{
if (!APIAvailable) return false;
return _isHere.InvokeFunc(entry);
}
public void GoToHousingAddress(AddressBookEntryTuple entry)
{
if (!APIAvailable) return;
_goToHousingAddress.InvokeAction(entry);
}
public bool IsBusy()
{
if (!APIAvailable) return false;
return _isBusy.InvokeFunc();
}
public void Abort()
{
if (!APIAvailable) return;
_abort.InvokeAction();
}
public bool ChangeWorld(string worldName)
{
if (!APIAvailable) return false;
return _changeWorld.InvokeFunc(worldName);
}
public bool AetheryteTeleport(string aetheryteName)
{
if (!APIAvailable) return false;
return _aetheryteTeleport.InvokeFunc(aetheryteName);
}
public bool ChangeWorldById(uint worldId)
{
if (!APIAvailable) return false;
return _changeWorldById.InvokeFunc(worldId);
}
public bool AetheryteTeleportById(uint aetheryteId)
{
if (!APIAvailable) return false;
return _aetheryteTeleportById.InvokeFunc(aetheryteId);
}
public bool CanChangeInstance()
{
if (!APIAvailable) return false;
return _canChangeInstance.InvokeFunc();
}
public int GetCurrentInstance()
{
if (!APIAvailable) return -1;
return _getCurrentInstance.InvokeFunc();
}
public int GetNumberOfInstances()
{
if (!APIAvailable) return -1;
return _getNumberOfInstances.InvokeFunc();
}
public void ChangeInstance(int instanceNumber)
{
if (!APIAvailable) return;
_changeInstance.InvokeAction(instanceNumber);
}
public (ResidentialAetheryteKind, int, int)? GetCurrentPlotInfo()
{
if (!APIAvailable) return (ResidentialAetheryteKind.None, -1, -1);
return _getCurrentPlotInfo.InvokeFunc();
}
}

View File

@@ -81,7 +81,10 @@ public sealed class IpcCallerPenumbra : IpcServiceBase
=> _collections.RemoveTemporaryCollectionAsync(logger, applicationId, collectionId);
public Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths)
=> _collections.SetTemporaryModsAsync(logger, applicationId, collectionId, modPaths);
=> _collections.SetTemporaryModsAsync(logger, applicationId, collectionId, modPaths, "Player");
public Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths, string scope)
=> _collections.SetTemporaryModsAsync(logger, applicationId, collectionId, modPaths, scope);
public Task SetManipulationDataAsync(ILogger logger, Guid applicationId, Guid collectionId, string manipulationData)
=> _collections.SetManipulationDataAsync(logger, applicationId, collectionId, manipulationData);

View File

@@ -5,9 +5,12 @@ namespace LightlessSync.Interop.Ipc;
public sealed partial class IpcManager : DisposableMediatorSubscriberBase
{
private bool _wasInitialized;
public IpcManager(ILogger<IpcManager> logger, LightlessMediator mediator,
IpcCallerPenumbra penumbraIpc, IpcCallerGlamourer glamourerIpc, IpcCallerCustomize customizeIpc, IpcCallerHeels heelsIpc,
IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio) : base(logger, mediator)
IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio,
IpcCallerLifestream ipcCallerLifestream) : base(logger, mediator)
{
CustomizePlus = customizeIpc;
Heels = heelsIpc;
@@ -17,8 +20,10 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
Moodles = moodlesIpc;
PetNames = ipcCallerPetNames;
Brio = ipcCallerBrio;
Lifestream = ipcCallerLifestream;
if (Initialized)
_wasInitialized = Initialized;
if (_wasInitialized)
{
Mediator.Publish(new PenumbraInitializedMessage());
}
@@ -44,8 +49,8 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
public IpcCallerPenumbra Penumbra { get; }
public IpcCallerMoodles Moodles { get; }
public IpcCallerPetNames PetNames { get; }
public IpcCallerBrio Brio { get; }
public IpcCallerLifestream Lifestream { get; }
private void PeriodicApiStateCheck()
{
@@ -58,5 +63,14 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
Moodles.CheckAPI();
PetNames.CheckAPI();
Brio.CheckAPI();
var initialized = Initialized;
if (initialized && !_wasInitialized)
{
Mediator.Publish(new PenumbraInitializedMessage());
}
_wasInitialized = initialized;
Lifestream.CheckAPI();
}
}

View File

@@ -92,25 +92,43 @@ public sealed class PenumbraCollections : PenumbraBase
_activeTemporaryCollections.TryRemove(collectionId, out _);
}
public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths)
public async Task SetTemporaryModsAsync(
ILogger logger,
Guid applicationId,
Guid collectionId,
Dictionary<string, string> modPaths,
string scope)
{
if (!IsAvailable || collectionId == Guid.Empty)
{
return;
var modName = $"LightlessChara_Files_{applicationId:N}_{scope}";
var normalized = new Dictionary<string, string>(modPaths.Count, StringComparer.OrdinalIgnoreCase);
foreach (var kvp in modPaths)
{
if (string.IsNullOrWhiteSpace(kvp.Key) || string.IsNullOrWhiteSpace(kvp.Value))
continue;
var gamePath = kvp.Key.Replace('\\', '/').ToLowerInvariant();
normalized[gamePath] = kvp.Value;
}
await DalamudUtil.RunOnFrameworkThread(() =>
{
foreach (var mod in modPaths)
{
logger.LogTrace("[{ApplicationId}] Change: {From} => {To}", applicationId, mod.Key, mod.Value);
}
foreach (var mod in normalized)
logger.LogTrace("[{ApplicationId}] {ModName}: {From} => {To}", applicationId, modName, mod.Key, mod.Value);
var removeResult = _removeTemporaryMod.Invoke("LightlessChara_Files", collectionId, 0);
logger.LogTrace("[{ApplicationId}] Removing temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, removeResult);
var removeResult = _removeTemporaryMod.Invoke(modName, collectionId, 0);
logger.LogTrace("[{ApplicationId}] Removing temp mod {ModName} for {CollectionId}, Success: {Result}",
applicationId, modName, collectionId, removeResult);
var addResult = _addTemporaryMod.Invoke("LightlessChara_Files", collectionId, modPaths, string.Empty, 0);
logger.LogTrace("[{ApplicationId}] Setting temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, addResult);
if (normalized.Count == 0)
return;
var addResult = _addTemporaryMod.Invoke(modName, collectionId, normalized, string.Empty, 0);
logger.LogTrace("[{ApplicationId}] Setting temp mod {ModName} for {CollectionId}, Success: {Result}",
applicationId, modName, collectionId, addResult);
}).ConfigureAwait(false);
}
@@ -171,7 +189,7 @@ public sealed class PenumbraCollections : PenumbraBase
Logger.LogDebug("Cleaning up stale temporary collection {CollectionName} ({CollectionId})", name, collectionId);
var deleteResult = await DalamudUtil.RunOnFrameworkThread(() =>
{
var result = (PenumbraApiEc)_removeTemporaryCollection.Invoke(collectionId);
var result = _removeTemporaryCollection.Invoke(collectionId);
Logger.LogTrace("Cleanup RemoveTemporaryCollection result for {CollectionName} ({CollectionId}): {Result}", name, collectionId, result);
return result;
}).ConfigureAwait(false);

View File

@@ -11,6 +11,7 @@ public sealed class ChatConfig : ILightlessConfiguration
public bool ShowRulesOverlayOnOpen { get; set; } = true;
public bool ShowMessageTimestamps { get; set; } = true;
public bool ShowNotesInSyncshellChat { get; set; } = true;
public bool EnableAnimatedEmotes { get; set; } = true;
public float ChatWindowOpacity { get; set; } = .97f;
public bool FadeWhenUnfocused { get; set; } = false;
public float UnfocusedWindowOpacity { get; set; } = 0.6f;

View File

@@ -4,6 +4,7 @@ using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.UI;
using LightlessSync.UI.Models;
using Microsoft.Extensions.Logging;
using LightlessSync.PlayerData.Factories;
namespace LightlessSync.LightlessConfiguration.Configurations;
@@ -51,6 +52,7 @@ public class LightlessConfig : ILightlessConfiguration
public bool PreferNotesOverNamesForVisible { get; set; } = false;
public VisiblePairSortMode VisiblePairSortMode { get; set; } = VisiblePairSortMode.Alphabetical;
public OnlinePairSortMode OnlinePairSortMode { get; set; } = OnlinePairSortMode.Alphabetical;
public TextureFormatSortMode TextureFormatSortMode { get; set; } = TextureFormatSortMode.None;
public float ProfileDelay { get; set; } = 1.5f;
public bool ProfilePopoutRight { get; set; } = false;
public bool ProfilesAllowNsfw { get; set; } = false;
@@ -155,5 +157,9 @@ public class LightlessConfig : ILightlessConfiguration
public bool SyncshellFinderEnabled { get; set; } = false;
public string? SelectedFinderSyncshell { get; set; } = null;
public string LastSeenVersion { get; set; } = string.Empty;
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
public bool EnableParticleEffects { get; set; } = true;
public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Unsafe;
public bool AnimationAllowOneBasedShift { get; set; } = false;
public bool AnimationAllowNeighborIndexTolerance { get; set; } = false;
}

View File

@@ -0,0 +1,8 @@
namespace LightlessSync.LightlessConfiguration.Configurations;
public class PenumbraJanitorConfig : ILightlessConfiguration
{
public int Version { get; set; } = 0;
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
}

View File

@@ -22,4 +22,15 @@ public class PlayerPerformanceConfig : ILightlessConfiguration
public int TextureDownscaleMaxDimension { get; set; } = 2048;
public bool OnlyDownscaleUncompressedTextures { get; set; } = true;
public bool KeepOriginalTextureFiles { get; set; } = false;
public bool SkipTextureDownscaleForPreferredPairs { get; set; } = true;
public bool EnableModelDecimation { get; set; } = false;
public int ModelDecimationTriangleThreshold { get; set; } = 20_000;
public double ModelDecimationTargetRatio { get; set; } = 0.8;
public bool KeepOriginalModelFiles { get; set; } = true;
public bool SkipModelDecimationForPreferredPairs { get; set; } = true;
public bool ModelDecimationAllowBody { get; set; } = false;
public bool ModelDecimationAllowFaceHead { get; set; } = false;
public bool ModelDecimationAllowTail { get; set; } = false;
public bool ModelDecimationAllowClothing { get; set; } = true;
public bool ModelDecimationAllowAccessories { get; set; } = true;
}

View File

@@ -5,6 +5,7 @@ namespace LightlessSync.LightlessConfiguration.Configurations;
public class XivDataStorageConfig : ILightlessConfiguration
{
public ConcurrentDictionary<string, long> TriangleDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, long> EffectiveTriangleDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, Dictionary<string, List<ushort>>> BonesDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public int Version { get; set; } = 0;
}

View File

@@ -0,0 +1,14 @@
using LightlessSync.LightlessConfiguration.Configurations;
namespace LightlessSync.LightlessConfiguration;
public class PenumbraJanitorConfigService : ConfigurationServiceBase<PenumbraJanitorConfig>
{
public const string ConfigName = "penumbra-collections.json";
public PenumbraJanitorConfigService(string configDir) : base(configDir)
{
}
public override string ConfigurationName => ConfigName;
}

View File

@@ -74,6 +74,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
private readonly DalamudUtilService _dalamudUtil;
private readonly LightlessConfigService _lightlessConfigService;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly PairHandlerRegistry _pairHandlerRegistry;
private readonly IServiceScopeFactory _serviceScopeFactory;
private IServiceScope? _runtimeServiceScope;
private Task? _launchTask = null;
@@ -81,11 +82,13 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
public LightlessPlugin(ILogger<LightlessPlugin> logger, LightlessConfigService lightlessConfigService,
ServerConfigurationManager serverConfigurationManager,
DalamudUtilService dalamudUtil,
PairHandlerRegistry pairHandlerRegistry,
IServiceScopeFactory serviceScopeFactory, LightlessMediator mediator) : base(logger, mediator)
{
_lightlessConfigService = lightlessConfigService;
_serverConfigurationManager = serverConfigurationManager;
_dalamudUtil = dalamudUtil;
_pairHandlerRegistry = pairHandlerRegistry;
_serviceScopeFactory = serviceScopeFactory;
}
@@ -108,12 +111,20 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
public Task StopAsync(CancellationToken cancellationToken)
{
Logger.LogDebug("Halting LightlessPlugin");
try
{
_pairHandlerRegistry.ResetAllHandlers();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to reset pair handlers on shutdown");
}
UnsubscribeAll();
DalamudUtilOnLogOut();
Logger.LogDebug("Halting LightlessPlugin");
return Task.CompletedTask;
}

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<Authors></Authors>
<Company></Company>
<Version>2.0.2</Version>
<Version>2.0.3</Version>
<Description></Description>
<Copyright></Copyright>
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>
@@ -24,6 +24,15 @@
<Compile Remove="PlayerData\Export\**" />
<EmbeddedResource Remove="PlayerData\Export\**" />
<None Remove="PlayerData\Export\**" />
<EmbeddedResource Update="Resources\Resources.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
<Compile Update="Resources\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
@@ -37,6 +46,7 @@
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
<PackageReference Include="Glamourer.Api" Version="2.8.0" />
<PackageReference Include="NReco.Logging.File" Version="1.3.1" />
@@ -67,8 +77,6 @@
</None>
<EmbeddedResource Include="Changelog\changelog.yaml" />
<EmbeddedResource Include="Changelog\credits.yaml" />
<EmbeddedResource Include="Localization\de.json" />
<EmbeddedResource Include="Localization\fr.json" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,3 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeEditing/Localization/Localizable/@EntryValue">Yes</s:String>
<s:String x:Key="/Default/CodeEditing/Localization/LocalizableInspector/@EntryValue">Pessimistic</s:String></wpf:ResourceDictionary>

View File

@@ -1,44 +0,0 @@
using CheapLoc;
namespace LightlessSync.Localization;
public static class Strings
{
public static ToSStrings ToS { get; set; } = new();
public class ToSStrings
{
public readonly string AgreeLabel = Loc.Localize("AgreeLabel", "I agree");
public readonly string AgreementLabel = Loc.Localize("AgreementLabel", "Agreement of Usage of Service");
public readonly string ButtonWillBeAvailableIn = Loc.Localize("ButtonWillBeAvailableIn", "'I agree' button will be available in");
public readonly string LanguageLabel = Loc.Localize("LanguageLabel", "Language");
public readonly string Paragraph1 = Loc.Localize("Paragraph1",
"All of the mod files currently active on your character as well as your current character state will be uploaded to the service you registered yourself at automatically. " +
"The plugin will exclusively upload the necessary mod files and not the whole mod.");
public readonly string Paragraph2 = Loc.Localize("Paragraph2",
"If you are on a data capped internet connection, higher fees due to data usage depending on the amount of downloaded and uploaded mod files might occur. " +
"Mod files will be compressed on up- and download to save on bandwidth usage. Due to varying up- and download speeds, changes in characters might not be visible immediately. " +
"Files present on the service that already represent your active mod files will not be uploaded again.");
public readonly string Paragraph3 = Loc.Localize("Paragraph3",
"The mod files you are uploading are confidential and will not be distributed to parties other than the ones who are requesting the exact same mod files. " +
"Please think about who you are going to pair since it is unavoidable that they will receive and locally cache the necessary mod files that you have currently in use. " +
"Locally cached mod files will have arbitrary file names to discourage attempts at replicating the original mod.");
public readonly string Paragraph4 = Loc.Localize("Paragraph4",
"The plugin creator tried their best to keep you secure. However, there is no guarantee for 100% security. Do not blindly pair your client with everyone.");
public readonly string Paragraph5 = Loc.Localize("Paragraph5",
"Mod files that are saved on the service will remain on the service as long as there are requests for the files from clients. " +
"After a period of not being used, the mod files will be automatically deleted. " +
"You will also be able to wipe all the files you have personally uploaded on request. " +
"The service holds no information about which mod files belong to which mod.");
public readonly string Paragraph6 = Loc.Localize("Paragraph6",
"This service is provided as-is. In case of abuse join the Lightless Sync Discord.");
public readonly string ReadLabel = Loc.Localize("ReadLabel", "READ THIS CAREFULLY");
}
}

View File

@@ -1,46 +0,0 @@
{
"LanguageLabel": {
"message": "Language",
"description": "ToSStrings..ctor"
},
"AgreementLabel": {
"message": "Nutzungsbedingungen",
"description": "ToSStrings..ctor"
},
"ReadLabel": {
"message": "BITTE LIES DIES SORGFÄLTIG",
"description": "ToSStrings..ctor"
},
"Paragraph1": {
"message": "Alle Moddateien, die aktuell auf deinem Charakter aktiv sind und dein Charakterzustand werden automatisch zu dem Service, an dem du dich registriert hast, hochgeladen. Das Plugin wird ausschließlich die nötigen Moddateien hochladen und nicht die gesamte Modifikation.",
"description": "ToSStrings..ctor"
},
"Paragraph2": {
"message": "Falls du mit einer getakteten Internetverbindung verbunden bist, können durch den Datentransfer von Hoch- und Runtergeladenen Moddateien höhere Kosten entstehen. Moddateien werden beim Hoch- und Runterladen komprimiert um Bandbreite zu sparen. Durch unterschiedliche Hoch- und Runterladgeschwindigkeiten ist es möglich, dass Änderungen an Charakteren nicht sofort sichtbar sind. Dateien die bereits auf dem Service existieren, werden nicht nochmals hochgeladen.",
"description": "ToSStrings..ctor"
},
"Paragraph3": {
"message": "Die Moddateien die du hochlädst sind vertraulich und werden nicht mit anderen Nutzern geteilt, die nicht die exakt selben Dateien anfordern. Bitte überlege dir sorgfältig mit wem du deinen Identifikationscode teilst, da es unvermeidlich ist, dass die andere Person deine Moddateien erhält und lokal zwischenspeichert. Lokal zwischengespeicherte Dateien haben willkürrliche Namen um vor Versuchen abzuschrecken die originalen Moddateien aus diesen wiederherzustellen.",
"description": "ToSStrings..ctor"
},
"Paragraph4": {
"message": "Der Ersteller des Plugins hat sein Bestes getan, um deine Sicherheit zu gewährleisten. Es gibt jedoch keine Garantie für 100%ige Sicherheit. Teile deinen Identifikationscode nicht blind mit jedem.",
"description": "ToSStrings..ctor"
},
"Paragraph5": {
"message": "Moddateien, die auf dem Service gespeichert sind, verbleiben auf dem Service, solange es Anforderungen für diese Dateien gibt. Nach einer Zeitspanne in der die Dateien nicht verwendet wurden, werden diese automatisch gelöscht. Du hast auch die Möglichkeit manuell alle Dateien auf dem Service zu löschen. Der Service hat keine Informationen welche Moddateien zu welcher Modifikation gehören.",
"description": "ToSStrings..ctor"
},
"Paragraph6": {
"message": "Dieser Dienst wird ohne Gewähr angeboten. Im Falle eines Missbrauchs tretet dem Lightless Sync Discord bei.",
"description": "ToSStrings..ctor"
},
"AgreeLabel": {
"message": "Ich Stimme zu",
"description": "ToSStrings..ctor"
},
"ButtonWillBeAvailableIn": {
"message": "\"Ich stimme zu\" Knopf verfügbar in",
"description": "ToSStrings..ctor"
}
}

View File

@@ -1,46 +0,0 @@
{
"LanguageLabel": {
"message": "Language",
"description": "ToSStrings..ctor"
},
"AgreementLabel": {
"message": "Conditions d'Utilisation",
"description": "ToSStrings..ctor"
},
"ReadLabel": {
"message": "LISEZ CES INFORMATIONS ATTENTIVEMENT",
"description": "ToSStrings..ctor"
},
"Paragraph1": {
"message": "Tous les fichiers moddés actuellement en cours d'utilisation ainsi que le statut actuel de votre personnage vont être mix en ligne via le service sur lequel vous vous êtes automatiquement enregistré. Seuls les fichiers nécessaires seront téléversés par le plugin et non pas le mod en entier.",
"description": "ToSStrings..ctor"
},
"Paragraph2": {
"message": "Si le débit de votre connexion internet est limité, le téléchargement et téléversement d'un grand nombre de fichiers peut entraîner des coûts supplémentaires. Les fichiers seront compressés au chargement et versement pour réduire l'impact sur votre bande passants. Selon la rapidité de vos téléchargements et téléversements, les changements ne seront peut-être pas visibles instantanément sur les personnages. Les fichiers déja présents sur le service qui correspondent à ceux de vos mods en cours d'utilisation ne seront pas remis en ligne.",
"description": "ToSStrings..ctor"
},
"Paragraph3": {
"message": "Les fichiers que vous allez partager sont confidentiels et ne seront envoyés qu'aux utilisateurs qui feront une requête exacte de ceux-çi. Nous vous demandons de (re)considérer qui sera synchronisé avec vous, puisqu'ils recevront et stockeront inévitablement en local les fichiers nécéssaires utilisés à cet instant. Les noms des fichiers stockés localement sont changés de manière arbitraire afin de décourager toute tentative de réplication des originaux.",
"description": "ToSStrings..ctor"
},
"Paragraph4": {
"message": "Le créateur de ce plugin a tenté de sécuriser l'application du mieux possible. Cependant, il ne peut pas garantir une protection 100% infaillible. Pour votre sécurité, ne vous synchronisez pas aveuglément et avec n'importe qui.",
"description": "ToSStrings..ctor"
},
"Paragraph5": {
"message": "Les fichiers sauvegardés sur le service resteront en ligne tant que des utilisateurs en feront usage. Ils seront effacés automatiquement après une certaine période d'inactivité. Vous pouvez également demander l'effacement de tous les fichiers que vous avez mis en ligne vous-même. Le service en soi ne contient aucune information pouvant identifier quel fichier appartient à quel mod.",
"description": "ToSStrings..ctor"
},
"Paragraph6": {
"message": "Ce service et ses composants vous sont fournis en l'état. En cas d'abus rejoindre le serveur Discord Lightless Sync.",
"description": "ToSStrings..ctor"
},
"AgreeLabel": {
"message": "J'accept",
"description": "ToSStrings..ctor"
},
"ButtonWillBeAvailableIn": {
"message": "Bouton \"J'accept\" disposible dans",
"description": "ToSStrings..ctor"
}
}

View File

@@ -0,0 +1,9 @@
namespace LightlessSync.PlayerData.Factories
{
public enum AnimationValidationMode
{
Unsafe = 0,
Safe = 1,
Safest = 2,
}
}

View File

@@ -1,6 +1,7 @@
using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ModelDecimation;
using LightlessSync.Services.TextureCompression;
using LightlessSync.WebAPI.Files;
using Microsoft.Extensions.Logging;
@@ -16,6 +17,7 @@ public class FileDownloadManagerFactory
private readonly FileCompactor _fileCompactor;
private readonly LightlessConfigService _configService;
private readonly TextureDownscaleService _textureDownscaleService;
private readonly ModelDecimationService _modelDecimationService;
private readonly TextureMetadataHelper _textureMetadataHelper;
public FileDownloadManagerFactory(
@@ -26,6 +28,7 @@ public class FileDownloadManagerFactory
FileCompactor fileCompactor,
LightlessConfigService configService,
TextureDownscaleService textureDownscaleService,
ModelDecimationService modelDecimationService,
TextureMetadataHelper textureMetadataHelper)
{
_loggerFactory = loggerFactory;
@@ -35,6 +38,7 @@ public class FileDownloadManagerFactory
_fileCompactor = fileCompactor;
_configService = configService;
_textureDownscaleService = textureDownscaleService;
_modelDecimationService = modelDecimationService;
_textureMetadataHelper = textureMetadataHelper;
}
@@ -48,6 +52,7 @@ public class FileDownloadManagerFactory
_fileCompactor,
_configService,
_textureDownscaleService,
_modelDecimationService,
_textureMetadataHelper);
}
}

View File

@@ -1,4 +1,5 @@
using LightlessSync.API.Data.Enum;
using Dalamud.Plugin.Services;
using LightlessSync.API.Data.Enum;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
@@ -11,6 +12,7 @@ public class GameObjectHandlerFactory
{
private readonly IServiceProvider _serviceProvider;
private readonly ILoggerFactory _loggerFactory;
private readonly IObjectTable _objectTable;
private readonly LightlessMediator _lightlessMediator;
private readonly PerformanceCollectorService _performanceCollectorService;
@@ -18,12 +20,14 @@ public class GameObjectHandlerFactory
ILoggerFactory loggerFactory,
PerformanceCollectorService performanceCollectorService,
LightlessMediator lightlessMediator,
IServiceProvider serviceProvider)
IServiceProvider serviceProvider,
IObjectTable objectTable)
{
_loggerFactory = loggerFactory;
_performanceCollectorService = performanceCollectorService;
_lightlessMediator = lightlessMediator;
_serviceProvider = serviceProvider;
_objectTable = objectTable;
}
public async Task<GameObjectHandler> Create(ObjectKind objectKind, Func<nint> getAddressFunc, bool isWatched = false)
@@ -36,6 +40,7 @@ public class GameObjectHandlerFactory
dalamudUtilService,
objectKind,
getAddressFunc,
_objectTable,
isWatched)).ConfigureAwait(false);
}
}

View File

@@ -1,4 +1,4 @@
using LightlessSync.API.Data.Enum;
 using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto.User;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;

View File

@@ -1,13 +1,20 @@
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using LightlessSync.API.Data.Enum;
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Data;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.InteropServices;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.PlayerData.Factories;
@@ -18,13 +25,34 @@ public class PlayerDataFactory
private readonly IpcManager _ipcManager;
private readonly ILogger<PlayerDataFactory> _logger;
private readonly PerformanceCollectorService _performanceCollector;
private readonly LightlessConfigService _configService;
private readonly XivDataAnalyzer _modelAnalyzer;
private readonly LightlessMediator _lightlessMediator;
private readonly TransientResourceManager _transientResourceManager;
private static readonly SemaphoreSlim _papParseLimiter = new(1, 1);
public PlayerDataFactory(ILogger<PlayerDataFactory> logger, DalamudUtilService dalamudUtil, IpcManager ipcManager,
TransientResourceManager transientResourceManager, FileCacheManager fileReplacementFactory,
PerformanceCollectorService performanceCollector, XivDataAnalyzer modelAnalyzer, LightlessMediator lightlessMediator)
// Transient resolved entries threshold
private const int _maxTransientResolvedEntries = 1000;
// Character build caches
private readonly ConcurrentDictionary<nint, Task<CharacterDataFragment>> _characterBuildInflight = new();
private readonly ConcurrentDictionary<nint, CacheEntry> _characterBuildCache = new();
// Time out thresholds
private static readonly TimeSpan _characterCacheTtl = TimeSpan.FromMilliseconds(750);
private static readonly TimeSpan _softReturnIfBusyAfter = TimeSpan.FromMilliseconds(250);
private static readonly TimeSpan _hardBuildTimeout = TimeSpan.FromSeconds(30);
public PlayerDataFactory(
ILogger<PlayerDataFactory> logger,
DalamudUtilService dalamudUtil,
IpcManager ipcManager,
TransientResourceManager transientResourceManager,
FileCacheManager fileReplacementFactory,
PerformanceCollectorService performanceCollector,
XivDataAnalyzer modelAnalyzer,
LightlessMediator lightlessMediator,
LightlessConfigService configService)
{
_logger = logger;
_dalamudUtil = dalamudUtil;
@@ -34,15 +62,15 @@ public class PlayerDataFactory
_performanceCollector = performanceCollector;
_modelAnalyzer = modelAnalyzer;
_lightlessMediator = lightlessMediator;
_configService = configService;
_logger.LogTrace("Creating {this}", nameof(PlayerDataFactory));
}
private sealed record CacheEntry(CharacterDataFragment Fragment, DateTime CreatedUtc);
public async Task<CharacterDataFragment?> BuildCharacterData(GameObjectHandler playerRelatedObject, CancellationToken token)
{
if (!_ipcManager.Initialized)
{
throw new InvalidOperationException("Penumbra or Glamourer is not connected");
}
if (playerRelatedObject == null) return null;
@@ -67,16 +95,17 @@ public class PlayerDataFactory
if (pointerIsZero)
{
_logger.LogTrace("Pointer was zero for {objectKind}", playerRelatedObject.ObjectKind);
_logger.LogTrace("Pointer was zero for {objectKind}; couldn't build character", playerRelatedObject.ObjectKind);
return null;
}
try
{
return await _performanceCollector.LogPerformance(this, $"CreateCharacterData>{playerRelatedObject.ObjectKind}", async () =>
{
return await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false);
}).ConfigureAwait(true);
return await _performanceCollector.LogPerformance(
this,
$"CreateCharacterData>{playerRelatedObject.ObjectKind}",
async () => await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false)
).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -91,115 +120,192 @@ public class PlayerDataFactory
return null;
}
private static readonly int _characterGameObjectOffset =
(int)Marshal.OffsetOf<Character>(nameof(Character.GameObject));
private static readonly int _gameObjectDrawObjectOffset =
(int)Marshal.OffsetOf<GameObject>(nameof(GameObject.DrawObject));
private async Task<bool> CheckForNullDrawObject(IntPtr playerPointer)
=> await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectSafe(playerPointer))
.ConfigureAwait(false);
private static bool CheckForNullDrawObjectSafe(nint playerPointer)
{
return await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
if (playerPointer == nint.Zero)
return true;
var drawObjPtrAddress = playerPointer + _characterGameObjectOffset + _gameObjectDrawObjectOffset;
// Read the DrawObject pointer from memory
if (!MemoryProcessProbe.TryReadIntPtr(drawObjPtrAddress, out var drawObj))
return true;
return drawObj == nint.Zero;
}
private unsafe bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
private static bool IsCacheFresh(CacheEntry entry)
=> (DateTime.UtcNow - entry.CreatedUtc) <= _characterCacheTtl;
private Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
=> CreateCharacterDataCoalesced(playerRelatedObject, ct);
private async Task<CharacterDataFragment> CreateCharacterDataCoalesced(GameObjectHandler obj, CancellationToken ct)
{
if (playerPointer == IntPtr.Zero)
return true;
var key = obj.Address;
var character = (Character*)playerPointer;
if (_characterBuildCache.TryGetValue(key, out var cached) && IsCacheFresh(cached) && !_characterBuildInflight.ContainsKey(key))
return cached.Fragment;
if (character == null)
return true;
var buildTask = _characterBuildInflight.GetOrAdd(key, valueFactory: k => BuildAndCacheAsync(obj, k));
var gameObject = &character->GameObject;
if (gameObject == null)
return true;
if (_characterBuildCache.TryGetValue(key, out cached))
{
var completed = await Task.WhenAny(buildTask, Task.Delay(_softReturnIfBusyAfter, ct)).ConfigureAwait(false);
if (completed != buildTask && (DateTime.UtcNow - cached.CreatedUtc) <= TimeSpan.FromSeconds(5))
{
return cached.Fragment;
}
}
return gameObject->DrawObject == null;
return await WithCancellation(buildTask, ct).ConfigureAwait(false);
}
private async Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
private async Task<CharacterDataFragment> BuildAndCacheAsync(GameObjectHandler obj, nint key)
{
try
{
using var cts = new CancellationTokenSource(_hardBuildTimeout);
var fragment = await CreateCharacterDataInternal(obj, cts.Token).ConfigureAwait(false);
_characterBuildCache[key] = new CacheEntry(fragment, DateTime.UtcNow);
PruneCharacterCacheIfNeeded();
return fragment;
}
finally
{
_characterBuildInflight.TryRemove(key, out _);
}
}
private void PruneCharacterCacheIfNeeded()
{
if (_characterBuildCache.Count < 2048) return;
var cutoff = DateTime.UtcNow - TimeSpan.FromSeconds(10);
foreach (var kv in _characterBuildCache)
{
if (kv.Value.CreatedUtc < cutoff)
_characterBuildCache.TryRemove(kv.Key, out _);
}
}
private static async Task<T> WithCancellation<T>(Task<T> task, CancellationToken ct)
=> await task.WaitAsync(ct).ConfigureAwait(false);
private async Task<CharacterDataFragment> CreateCharacterDataInternal(GameObjectHandler playerRelatedObject, CancellationToken ct)
{
var objectKind = playerRelatedObject.ObjectKind;
CharacterDataFragment fragment = objectKind == ObjectKind.Player ? new CharacterDataFragmentPlayer() : new();
_logger.LogDebug("Building character data for {obj}", playerRelatedObject);
var logDebug = _logger.IsEnabled(LogLevel.Debug);
var sw = Stopwatch.StartNew();
// wait until chara is not drawing and present so nothing spontaneously explodes
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct).ConfigureAwait(false);
int totalWaitTime = 10000;
while (!await _dalamudUtil.IsObjectPresentAsync(await _dalamudUtil.CreateGameObjectAsync(playerRelatedObject.Address).ConfigureAwait(false)).ConfigureAwait(false) && totalWaitTime > 0)
_logger.LogDebug("Building character data for {obj}", playerRelatedObject);
await EnsureObjectPresentAsync(playerRelatedObject, ct).ConfigureAwait(false);
ct.ThrowIfCancellationRequested();
var waitRecordingTask = _transientResourceManager.WaitForRecording(ct);
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct)
.ConfigureAwait(false);
// get all remaining paths and resolve them
ct.ThrowIfCancellationRequested();
if (await CheckForNullDrawObject(playerRelatedObject.Address).ConfigureAwait(false))
throw new InvalidOperationException("DrawObject became null during build (actor despawned)");
Task<string> getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address);
Task<string?> getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address);
Task<string?>? getMoodlesData = null;
Task<string>? getHeelsOffset = null;
Task<string>? getHonorificTitle = null;
if (objectKind == ObjectKind.Player)
{
_logger.LogTrace("Character is null but it shouldn't be, waiting");
await Task.Delay(50, ct).ConfigureAwait(false);
totalWaitTime -= 50;
getHeelsOffset = _ipcManager.Heels.GetOffsetAsync();
getHonorificTitle = _ipcManager.Honorific.GetTitle();
getMoodlesData = _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address);
}
var resolvedPaths = await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false) ?? throw new InvalidOperationException("Penumbra returned null data; couldn't proceed with character");
ct.ThrowIfCancellationRequested();
DateTime start = DateTime.UtcNow;
var staticBuildTask = Task.Run(() => BuildStaticReplacements(resolvedPaths), ct);
// penumbra call, it's currently broken
Dictionary<string, HashSet<string>>? resolvedPaths;
resolvedPaths = (await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false));
if (resolvedPaths == null) throw new InvalidOperationException("Penumbra returned null data");
ct.ThrowIfCancellationRequested();
fragment.FileReplacements =
new HashSet<FileReplacement>(resolvedPaths.Select(c => new FileReplacement([.. c.Value], c.Key)), FileReplacementComparer.Instance)
.Where(p => p.HasFileReplacement).ToHashSet();
fragment.FileReplacements.RemoveWhere(c => c.GamePaths.Any(g => !CacheMonitor.AllowedFileExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase))));
ct.ThrowIfCancellationRequested();
fragment.FileReplacements = await staticBuildTask.ConfigureAwait(false);
if (logDebug)
{
_logger.LogDebug("== Static Replacements ==");
foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement).OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
foreach (var replacement in fragment.FileReplacements
.Where(i => i.HasFileReplacement)
.OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
{
_logger.LogDebug("=> {repl}", replacement);
ct.ThrowIfCancellationRequested();
}
}
else
var staticReplacements = new HashSet<FileReplacement>(fragment.FileReplacements, FileReplacementComparer.Instance);
var transientTask = ResolveTransientReplacementsAsync(
playerRelatedObject,
objectKind,
staticReplacements,
waitRecordingTask,
ct);
fragment.GlamourerString = await getGlamourerData.ConfigureAwait(false);
_logger.LogDebug("Glamourer is now: {data}", fragment.GlamourerString);
var customizeScale = await getCustomizeData.ConfigureAwait(false);
fragment.CustomizePlusScale = customizeScale ?? string.Empty;
_logger.LogDebug("Customize is now: {data}", fragment.CustomizePlusScale);
if (objectKind == ObjectKind.Player)
{
foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement))
{
ct.ThrowIfCancellationRequested();
}
}
CharacterDataFragmentPlayer? playerFragment = fragment as CharacterDataFragmentPlayer ?? throw new InvalidOperationException("Failed to cast CharacterDataFragment to Player variant");
await _transientResourceManager.WaitForRecording(ct).ConfigureAwait(false);
playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations();
playerFragment.HonorificData = await getHonorificTitle!.ConfigureAwait(false);
_logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData);
// if it's pet then it's summoner, if it's summoner we actually want to keep all filereplacements alive at all times
// or we get into redraw city for every change and nothing works properly
if (objectKind == ObjectKind.Pet)
{
foreach (var item in fragment.FileReplacements.Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths))
{
if (_transientResourceManager.AddTransientResource(objectKind, item))
{
_logger.LogDebug("Marking static {item} for Pet as transient", item);
}
}
playerFragment.PetNamesData = _ipcManager.PetNames.GetLocalNames();
_logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);
_logger.LogTrace("Clearing {count} Static Replacements for Pet", fragment.FileReplacements.Count);
fragment.FileReplacements.Clear();
playerFragment.HeelsData = await getHeelsOffset!.ConfigureAwait(false);
_logger.LogDebug("Heels is now: {heels}", playerFragment!.HeelsData);
playerFragment.MoodlesData = (await getMoodlesData!.ConfigureAwait(false)) ?? string.Empty;
_logger.LogDebug("Moodles is now: {moodles}", playerFragment!.MoodlesData);
}
ct.ThrowIfCancellationRequested();
_logger.LogDebug("Handling transient update for {obj}", playerRelatedObject);
// remove all potentially gathered paths from the transient resource manager that are resolved through static resolving
_transientResourceManager.ClearTransientPaths(objectKind, fragment.FileReplacements.SelectMany(c => c.GamePaths).ToList());
// get all remaining paths and resolve them
var transientPaths = ManageSemiTransientData(objectKind);
var resolvedTransientPaths = await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
var (resolvedTransientPaths, clearedForPet) = await transientTask.ConfigureAwait(false);
if (clearedForPet != null)
fragment.FileReplacements.Clear();
if (logDebug)
{
_logger.LogDebug("== Transient Replacements ==");
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)).OrderBy(f => f.ResolvedPath, StringComparer.Ordinal))
foreach (var replacement in resolvedTransientPaths
.Select(c => new FileReplacement([.. c.Value], c.Key))
.OrderBy(f => f.ResolvedPath, StringComparer.Ordinal))
{
_logger.LogDebug("=> {repl}", replacement);
fragment.FileReplacements.Add(replacement);
@@ -208,85 +314,64 @@ public class PlayerDataFactory
else
{
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)))
{
fragment.FileReplacements.Add(replacement);
}
}
// clean up all semi transient resources that don't have any file replacement (aka null resolve)
_transientResourceManager.CleanUpSemiTransientResources(objectKind, [.. fragment.FileReplacements]);
ct.ThrowIfCancellationRequested();
// make sure we only return data that actually has file replacements
fragment.FileReplacements = new HashSet<FileReplacement>(fragment.FileReplacements.Where(v => v.HasFileReplacement).OrderBy(v => v.ResolvedPath, StringComparer.Ordinal), FileReplacementComparer.Instance);
// gather up data from ipc
Task<string> getHeelsOffset = _ipcManager.Heels.GetOffsetAsync();
Task<string> getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address);
Task<string?> getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address);
Task<string> getHonorificTitle = _ipcManager.Honorific.GetTitle();
fragment.GlamourerString = await getGlamourerData.ConfigureAwait(false);
_logger.LogDebug("Glamourer is now: {data}", fragment.GlamourerString);
var customizeScale = await getCustomizeData.ConfigureAwait(false);
fragment.CustomizePlusScale = customizeScale ?? string.Empty;
_logger.LogDebug("Customize is now: {data}", fragment.CustomizePlusScale);
if (objectKind == ObjectKind.Player)
{
var playerFragment = (fragment as CharacterDataFragmentPlayer)!;
playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations();
playerFragment!.HonorificData = await getHonorificTitle.ConfigureAwait(false);
_logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData);
playerFragment!.HeelsData = await getHeelsOffset.ConfigureAwait(false);
_logger.LogDebug("Heels is now: {heels}", playerFragment!.HeelsData);
playerFragment!.MoodlesData = await _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address).ConfigureAwait(false) ?? string.Empty;
_logger.LogDebug("Moodles is now: {moodles}", playerFragment!.MoodlesData);
playerFragment!.PetNamesData = _ipcManager.PetNames.GetLocalNames();
_logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);
}
fragment.FileReplacements = new HashSet<FileReplacement>(
fragment.FileReplacements
.Where(v => v.HasFileReplacement)
.OrderBy(v => v.ResolvedPath, StringComparer.Ordinal),
FileReplacementComparer.Instance);
ct.ThrowIfCancellationRequested();
var toCompute = fragment.FileReplacements.Where(f => !f.IsFileSwap).ToArray();
_logger.LogDebug("Getting Hashes for {amount} Files", toCompute.Length);
var computedPaths = _fileCacheManager.GetFileCachesByPaths(toCompute.Select(c => c.ResolvedPath).ToArray());
foreach (var file in toCompute)
await Task.Run(() =>
{
ct.ThrowIfCancellationRequested();
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty;
}
var computedPaths = _fileCacheManager.GetFileCachesByPaths([.. toCompute.Select(c => c.ResolvedPath)]);
foreach (var file in toCompute)
{
ct.ThrowIfCancellationRequested();
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty;
}
}, ct).ConfigureAwait(false);
var removed = fragment.FileReplacements.RemoveWhere(f => !f.IsFileSwap && string.IsNullOrEmpty(f.Hash));
if (removed > 0)
{
_logger.LogDebug("Removed {amount} of invalid files", removed);
}
ct.ThrowIfCancellationRequested();
Dictionary<string, List<ushort>>? boneIndices = null;
var hasPapFiles = false;
if (objectKind == ObjectKind.Player)
{
hasPapFiles = fragment.FileReplacements.Any(f =>
!f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase));
if (hasPapFiles)
{
boneIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject)).ConfigureAwait(false);
}
}
if (objectKind == ObjectKind.Player)
{
hasPapFiles = fragment.FileReplacements.Any(f =>
!f.IsFileSwap && f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
if (hasPapFiles)
{
boneIndices = await _dalamudUtil
.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject))
.ConfigureAwait(false);
}
try
{
#if DEBUG
if (hasPapFiles && boneIndices != null)
_modelAnalyzer.DumpLocalSkeletonIndices(playerRelatedObject);
#endif
if (hasPapFiles)
{
await VerifyPlayerAnimationBones(boneIndices, (fragment as CharacterDataFragmentPlayer)!, ct).ConfigureAwait(false);
await VerifyPlayerAnimationBones(boneIndices, (CharacterDataFragmentPlayer)fragment, ct)
.ConfigureAwait(false);
}
}
catch (OperationCanceledException e)
@@ -300,105 +385,313 @@ public class PlayerDataFactory
}
}
_logger.LogInformation("Building character data for {obj} took {time}ms", objectKind, TimeSpan.FromTicks(DateTime.UtcNow.Ticks - start.Ticks).TotalMilliseconds);
_logger.LogInformation("Building character data for {obj} took {time}ms",
objectKind, sw.Elapsed.TotalMilliseconds);
return fragment;
}
private async Task VerifyPlayerAnimationBones(Dictionary<string, List<ushort>>? boneIndices, CharacterDataFragmentPlayer fragment, CancellationToken ct)
private async Task EnsureObjectPresentAsync(GameObjectHandler handler, CancellationToken ct)
{
if (boneIndices == null) return;
if (_logger.IsEnabled(LogLevel.Debug))
{
foreach (var kvp in boneIndices)
{
_logger.LogDebug("Found {skellyname} ({idx} bone indices) on player: {bones}", kvp.Key, kvp.Value.Any() ? kvp.Value.Max() : 0, string.Join(',', kvp.Value));
}
}
var maxPlayerBoneIndex = boneIndices.SelectMany(kvp => kvp.Value).DefaultIfEmpty().Max();
if (maxPlayerBoneIndex <= 0) return;
int noValidationFailed = 0;
foreach (var file in fragment.FileReplacements.Where(f => !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)).ToList())
var remaining = 10000;
while (remaining > 0)
{
ct.ThrowIfCancellationRequested();
var skeletonIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetBoneIndicesFromPap(file.Hash)).ConfigureAwait(false);
bool validationFailed = false;
if (skeletonIndices != null)
var obj = await _dalamudUtil.CreateGameObjectAsync(handler.Address).ConfigureAwait(false);
if (await _dalamudUtil.IsObjectPresentAsync(obj).ConfigureAwait(false))
return;
_logger.LogTrace("Character is null but it shouldn't be, waiting");
await Task.Delay(50, ct).ConfigureAwait(false);
remaining -= 50;
}
}
private static HashSet<FileReplacement> BuildStaticReplacements(Dictionary<string, HashSet<string>> resolvedPaths)
{
var set = new HashSet<FileReplacement>(FileReplacementComparer.Instance);
foreach (var kvp in resolvedPaths)
{
var fr = new FileReplacement([.. kvp.Value], kvp.Key);
if (!fr.HasFileReplacement) continue;
var allAllowed = fr.GamePaths.All(g =>
CacheMonitor.AllowedFileExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase)));
if (!allAllowed) continue;
set.Add(fr);
}
return set;
}
private async Task<(IReadOnlyDictionary<string, string[]> ResolvedPaths, HashSet<FileReplacement>? ClearedReplacements)>
ResolveTransientReplacementsAsync(
GameObjectHandler obj,
ObjectKind objectKind,
HashSet<FileReplacement> staticReplacements,
Task waitRecordingTask,
CancellationToken ct)
{
await waitRecordingTask.ConfigureAwait(false);
HashSet<FileReplacement>? clearedReplacements = null;
if (objectKind == ObjectKind.Pet)
{
foreach (var item in staticReplacements.Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths))
{
// 105 is the maximum vanilla skellington spoopy bone index
if (skeletonIndices.All(k => k.Value.Max() <= 105))
{
_logger.LogTrace("All indices of {path} are <= 105, ignoring", file.ResolvedPath);
continue;
}
if (_transientResourceManager.AddTransientResource(objectKind, item))
_logger.LogDebug("Marking static {item} for Pet as transient", item);
}
_logger.LogDebug("Verifying bone indices for {path}, found {x} skeletons", file.ResolvedPath, skeletonIndices.Count);
_logger.LogTrace("Clearing {count} Static Replacements for Pet", staticReplacements.Count);
clearedReplacements = staticReplacements;
}
foreach (var boneCount in skeletonIndices)
ct.ThrowIfCancellationRequested();
_transientResourceManager.ClearTransientPaths(objectKind, [.. staticReplacements.SelectMany(c => c.GamePaths)]);
var transientPaths = ManageSemiTransientData(objectKind);
if (transientPaths.Count == 0)
return (new Dictionary<string, string[]>(StringComparer.Ordinal), clearedReplacements);
var resolved = await GetFileReplacementsFromPaths(obj, transientPaths, new HashSet<string>(StringComparer.Ordinal))
.ConfigureAwait(false);
if (_maxTransientResolvedEntries > 0 && resolved.Count > _maxTransientResolvedEntries)
{
_logger.LogWarning("Transient entries ({resolved}) are above the threshold {max}; Please consider disable some mods (VFX have heavy load) to reduce transient load",
resolved.Count,
_maxTransientResolvedEntries);
}
return (resolved, clearedReplacements);
}
private async Task VerifyPlayerAnimationBones(
Dictionary<string, List<ushort>>? playerBoneIndices,
CharacterDataFragmentPlayer fragment,
CancellationToken ct)
{
var mode = _configService.Current.AnimationValidationMode;
var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift;
var allownNightIndex = _configService.Current.AnimationAllowNeighborIndexTolerance;
if (mode == AnimationValidationMode.Unsafe)
return;
if (playerBoneIndices == null || playerBoneIndices.Count == 0)
return;
var localBoneSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
foreach (var (rawLocalKey, indices) in playerBoneIndices)
{
if (indices is not { Count: > 0 })
continue;
var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawLocalKey);
if (string.IsNullOrEmpty(key))
continue;
if (!localBoneSets.TryGetValue(key, out var set))
localBoneSets[key] = set = [];
foreach (var idx in indices)
set.Add(idx);
}
if (localBoneSets.Count == 0)
return;
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("SEND local buckets: {b}",
string.Join(", ", localBoneSets.Keys.Order(StringComparer.Ordinal)));
foreach (var kvp in localBoneSets.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
{
var min = kvp.Value.Count > 0 ? kvp.Value.Min() : 0;
var max = kvp.Value.Count > 0 ? kvp.Value.Max() : 0;
_logger.LogDebug("Local bucket {bucket}: count={count} min={min} max={max}",
kvp.Key, kvp.Value.Count, min, max);
}
}
var papGroups = fragment.FileReplacements
.Where(f => !f.IsFileSwap
&& !string.IsNullOrEmpty(f.Hash)
&& f.GamePaths is { Count: > 0 }
&& f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
.GroupBy(f => f.Hash!, StringComparer.OrdinalIgnoreCase)
.ToList();
int noValidationFailed = 0;
foreach (var g in papGroups)
{
ct.ThrowIfCancellationRequested();
var hash = g.Key;
var resolvedPath = g.Select(f => f.ResolvedPath).Distinct(StringComparer.OrdinalIgnoreCase);
var papPathSummary = string.Join(", ", resolvedPath);
if (papPathSummary.IsNullOrEmpty())
papPathSummary = "<unknown pap path>";
Dictionary<string, List<ushort>>? papIndices = null;
await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
try
{
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
var papPath = cacheEntity?.ResolvedFilepath;
if (!string.IsNullOrEmpty(papPath) && File.Exists(papPath))
{
var maxAnimationIndex = boneCount.Value.DefaultIfEmpty().Max();
if (maxAnimationIndex > maxPlayerBoneIndex)
var havokBytes = await Task.Run(() => XivDataAnalyzer.ReadHavokBytesFromPap(papPath), ct)
.ConfigureAwait(false);
if (havokBytes is { Length: > 8 })
{
_logger.LogWarning("Found more bone indices on the animation {path} skeleton {skl} (max indice {idx}) than on any player related skeleton (max indice {idx2})",
file.ResolvedPath, boneCount.Key, maxAnimationIndex, maxPlayerBoneIndex);
validationFailed = true;
break;
papIndices = await _dalamudUtil.RunOnFrameworkThread(
() => _modelAnalyzer.ParseHavokBytesOnFrameworkThread(havokBytes, hash, persistToConfig: false))
.ConfigureAwait(false);
}
}
}
if (validationFailed)
finally
{
noValidationFailed++;
_logger.LogDebug("Removing {file} from sent file replacements and transient data", file.ResolvedPath);
fragment.FileReplacements.Remove(file);
foreach (var gamePath in file.GamePaths)
_papParseLimiter.Release();
}
if (papIndices == null || papIndices.Count == 0)
continue;
if (_logger.IsEnabled(LogLevel.Debug))
{
try
{
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gamePath);
var papBuckets = papIndices
.Where(kvp => kvp.Value is { Count: > 0 })
.Select(kvp => new
{
Raw = kvp.Key,
Key = XivDataAnalyzer.CanonicalizeSkeletonKey(kvp.Key),
Indices = kvp.Value
})
.Where(x => x.Indices is { Count: > 0 })
.GroupBy(x => string.IsNullOrEmpty(x.Key) ? x.Raw : x.Key!, StringComparer.OrdinalIgnoreCase)
.Select(grp =>
{
var all = grp.SelectMany(v => v.Indices).ToList();
var min = all.Count > 0 ? all.Min() : 0;
var max = all.Count > 0 ? all.Max() : 0;
var raws = string.Join(',', grp.Select(v => v.Raw).Distinct(StringComparer.OrdinalIgnoreCase));
return $"{grp.Key}(min={min},max={max},raw=[{raws}])";
})
.ToList();
_logger.LogDebug("SEND pap buckets for hash={hash}: {b}",
hash,
string.Join(" | ", papBuckets));
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error logging PAP bucket details for hash={hash}", hash);
}
}
bool isCompatible = false;
string reason = string.Empty;
try
{
isCompatible = XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out reason);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error checking PAP compatibility for hash={hash}, path={path}. Treating as incompatible.", hash, papPathSummary);
reason = $"Exception during compatibility check: {ex.Message}";
isCompatible = false;
}
if (isCompatible)
continue;
noValidationFailed++;
_logger.LogWarning(
"Animation PAP is not compatible with local skeletons; dropping mappings for {papPath}. Reason: {reason}",
papPathSummary,
reason);
var removedGamePaths = fragment.FileReplacements
.Where(fr => !fr.IsFileSwap
&& string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
&& fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
.SelectMany(fr => fr.GamePaths.Where(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
fragment.FileReplacements.RemoveWhere(fr =>
!fr.IsFileSwap
&& string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
&& fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
foreach (var gp in removedGamePaths)
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gp);
}
if (noValidationFailed > 0)
{
_lightlessMediator.Publish(new NotificationMessage("Invalid Skeleton Setup",
$"Your client is attempting to send {noValidationFailed} animation files with invalid bone data. Those animation files have been removed from your sent data. " +
$"Verify that you are using the correct skeleton for those animation files (Check /xllog for more information).",
NotificationType.Warning, TimeSpan.FromSeconds(10)));
_lightlessMediator.Publish(new NotificationMessage(
"Invalid Skeleton Setup",
$"Your client is attempting to send {noValidationFailed} animation files that don't match your current skeleton validation mode ({mode}). " +
"Please adjust your skeleton/mods or change the validation mode if this is unexpected. " +
"Those animation files have been removed from your sent (player) data. (Check /xllog for details).",
NotificationType.Warning,
TimeSpan.FromSeconds(10)));
}
}
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(GameObjectHandler handler, HashSet<string> forwardResolve, HashSet<string> reverseResolve)
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(
GameObjectHandler handler,
HashSet<string> forwardResolve,
HashSet<string> reverseResolve)
{
var forwardPaths = forwardResolve.ToArray();
var reversePaths = reverseResolve.ToArray();
Dictionary<string, List<string>> resolvedPaths = new(StringComparer.Ordinal);
if (forwardPaths.Length == 0 && reversePaths.Length == 0)
{
return new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase).AsReadOnly();
}
var forwardPathsLower = forwardPaths.Length == 0 ? [] : forwardPaths.Select(p => p.ToLowerInvariant()).ToArray();
var reversePathsLower = reversePaths.Length == 0 ? [] : reversePaths.Select(p => p.ToLowerInvariant()).ToArray();
Dictionary<string, List<string>> resolvedPaths = new(forwardPaths.Length + reversePaths.Length, StringComparer.Ordinal);
if (handler.ObjectKind != ObjectKind.Player)
{
var (objectIndex, forwardResolved, reverseResolved) = await _dalamudUtil.RunOnFrameworkThread(() =>
{
var idx = handler.GetGameObject()?.ObjectIndex;
if (!idx.HasValue)
{
return ((int?)null, Array.Empty<string>(), Array.Empty<string[]>());
}
var resolvedForward = new string[forwardPaths.Length];
for (int i = 0; i < forwardPaths.Length; i++)
{
resolvedForward[i] = _ipcManager.Penumbra.ResolveGameObjectPath(forwardPaths[i], idx.Value);
}
var resolvedReverse = new string[reversePaths.Length][];
for (int i = 0; i < reversePaths.Length; i++)
{
resolvedReverse[i] = _ipcManager.Penumbra.ReverseResolveGameObjectPath(reversePaths[i], idx.Value);
}
return (idx, resolvedForward, resolvedReverse);
}).ConfigureAwait(false);
@@ -409,31 +702,28 @@ public class PlayerDataFactory
{
var filePath = forwardResolved[i]?.ToLowerInvariant();
if (string.IsNullOrEmpty(filePath))
{
continue;
}
if (resolvedPaths.TryGetValue(filePath, out var list))
{
list.Add(forwardPaths[i].ToLowerInvariant());
}
else
{
resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
resolvedPaths[filePath] = [forwardPathsLower[i]];
}
}
for (int i = 0; i < reversePaths.Length; i++)
{
var filePath = reversePaths[i].ToLowerInvariant();
var filePath = reversePathsLower[i];
var reverseResolvedLower = new string[reverseResolved[i].Length];
for (var j = 0; j < reverseResolvedLower.Length; j++)
{
reverseResolvedLower[j] = reverseResolved[i][j].ToLowerInvariant();
}
if (resolvedPaths.TryGetValue(filePath, out var list))
{
list.AddRange(reverseResolved[i].Select(c => c.ToLowerInvariant()));
}
else
{
resolvedPaths[filePath] = new List<string>(reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList());
}
resolvedPaths[filePath] = [.. reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList()];
}
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
@@ -441,30 +731,28 @@ public class PlayerDataFactory
}
var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false);
for (int i = 0; i < forwardPaths.Length; i++)
{
var filePath = forward[i].ToLowerInvariant();
if (resolvedPaths.TryGetValue(filePath, out var list))
{
list.Add(forwardPaths[i].ToLowerInvariant());
}
else
{
resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
}
}
for (int i = 0; i < reversePaths.Length; i++)
{
var filePath = reversePaths[i].ToLowerInvariant();
var filePath = reversePathsLower[i];
var reverseResolvedLower = new string[reverse[i].Length];
for (var j = 0; j < reverseResolvedLower.Length; j++)
{
reverseResolvedLower[j] = reverse[i][j].ToLowerInvariant();
}
if (resolvedPaths.TryGetValue(filePath, out var list))
{
list.AddRange(reverse[i].Select(c => c.ToLowerInvariant()));
}
else
{
resolvedPaths[filePath] = new List<string>(reverse[i].Select(c => c.ToLowerInvariant()).ToList());
}
resolvedPaths[filePath] = [.. reverse[i].Select(c => c.ToLowerInvariant()).ToList()];
}
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
@@ -475,11 +763,29 @@ public class PlayerDataFactory
_transientResourceManager.PersistTransientResources(objectKind);
HashSet<string> pathsToResolve = new(StringComparer.Ordinal);
foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind).Where(path => !string.IsNullOrEmpty(path)))
int scanned = 0, skippedEmpty = 0, skippedVfx = 0;
foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind))
{
scanned++;
if (string.IsNullOrEmpty(path))
{
skippedEmpty++;
continue;
}
pathsToResolve.Add(path);
}
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug(
"ManageSemiTransientData({kind}): scanned={scanned}, added={added}, skippedEmpty={skippedEmpty}, skippedVfx={skippedVfx}",
objectKind, scanned, pathsToResolve.Count, skippedEmpty, skippedVfx);
}
return pathsToResolve;
}
}

View File

@@ -1,45 +1,68 @@
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin.Services;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Runtime.CompilerServices;
using static FFXIVClientStructs.FFXIV.Client.Game.Character.DrawDataContainer;
using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.PlayerData.Handlers;
/// <summary>
/// Game object handler for managing game object state and updates
/// </summary>
public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighPriorityMediatorSubscriber
{
private readonly DalamudUtilService _dalamudUtil;
private readonly IObjectTable _objectTable;
private readonly Func<IntPtr> _getAddress;
private readonly bool _isOwnedObject;
private readonly PerformanceCollectorService _performanceCollector;
private readonly object _frameworkUpdateGate = new();
private readonly Lock _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)
/// <summary>
/// Constructor for GameObjectHandler
/// </summary>
/// <param name="logger">Logger</param>
/// <param name="performanceCollector">Performance Collector</param>
/// <param name="mediator">Lightless Mediator</param>
/// <param name="dalamudUtil">Dalamud Utilties Service</param>
/// <param name="objectKind">Object kind of Object</param>
/// <param name="getAddress">Get Adress</param>
/// <param name="objectTable">Object table of Dalamud</param>
/// <param name="ownedObject">Object is owned by user</param>
public GameObjectHandler(
ILogger<GameObjectHandler> logger,
PerformanceCollectorService performanceCollector,
LightlessMediator mediator,
DalamudUtilService dalamudUtil,
ObjectKind objectKind,
Func<IntPtr> getAddress,
IObjectTable objectTable,
bool ownedObject = true) : base(logger, mediator)
{
_performanceCollector = performanceCollector;
ObjectKind = objectKind;
_dalamudUtil = dalamudUtil;
_objectTable = objectTable;
_getAddress = () =>
{
_dalamudUtil.EnsureIsOnFramework();
return getAddress.Invoke();
};
_isOwnedObject = ownedObject;
Name = string.Empty;
if (ownedObject)
{
Mediator.Subscribe<TransientResourceChangedMessage>(this, (msg) =>
Mediator.Subscribe<TransientResourceChangedMessage>(this, msg =>
{
if (_delayedZoningTask?.IsCompleted ?? true)
{
@@ -49,43 +72,36 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
});
}
if (_isOwnedObject)
{
EnableFrameworkUpdates();
}
EnableFrameworkUpdates();
Mediator.Subscribe<ZoneSwitchEndMessage>(this, (_) => ZoneSwitchEnd());
Mediator.Subscribe<ZoneSwitchStartMessage>(this, (_) => ZoneSwitchStart());
Mediator.Subscribe<ZoneSwitchEndMessage>(this, _ => ZoneSwitchEnd());
Mediator.Subscribe<ZoneSwitchStartMessage>(this, _ => ZoneSwitchStart());
Mediator.Subscribe<CutsceneStartMessage>(this, (_) =>
{
_haltProcessing = true;
});
Mediator.Subscribe<CutsceneEndMessage>(this, (_) =>
Mediator.Subscribe<CutsceneStartMessage>(this, _ => _haltProcessing = true);
Mediator.Subscribe<CutsceneEndMessage>(this, _ =>
{
_haltProcessing = false;
ZoneSwitchEnd();
});
Mediator.Subscribe<PenumbraStartRedrawMessage>(this, (msg) =>
Mediator.Subscribe<PenumbraStartRedrawMessage>(this, msg =>
{
if (msg.Address == Address)
{
_haltProcessing = true;
}
if (msg.Address == Address) _haltProcessing = true;
});
Mediator.Subscribe<PenumbraEndRedrawMessage>(this, (msg) =>
Mediator.Subscribe<PenumbraEndRedrawMessage>(this, msg =>
{
if (msg.Address == Address)
{
_haltProcessing = false;
}
if (msg.Address == Address) _haltProcessing = false;
});
Mediator.Publish(new GameObjectHandlerCreatedMessage(this, _isOwnedObject));
_dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult();
_dalamudUtil.EnsureIsOnFramework();
CheckAndUpdateObject(allowPublish: true);
}
/// <summary>
/// Draw Condition Enum
/// </summary>
public enum DrawCondition
{
None,
@@ -96,6 +112,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
ModelFilesInSlotLoaded
}
// Properties
public IntPtr Address { get; private set; }
public DrawCondition CurrentDrawCondition { get; set; } = DrawCondition.None;
public byte Gender { get; private set; }
@@ -106,28 +123,36 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
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)
/// <summary>
/// Act on framework thread after ensuring no draw condition
/// </summary>
/// <param name="act">Action of Character</param>
/// <param name="token">Cancellation Token</param>
/// <returns>Task Completion</returns>
public async Task ActOnFrameworkAfterEnsureNoDrawAsync(Action<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))
{
EnsureLatestObjectState();
if (CurrentDrawCondition != DrawCondition.None) return true;
var gameObj = _dalamudUtil.CreateGameObject(Address);
if (gameObj is ICharacter chara)
{
act.Invoke(chara);
}
return false;
}).ConfigureAwait(false))
{
await Task.Delay(250, token).ConfigureAwait(false);
}
}
/// <summary>
/// Compare Name And Throw if not equal
/// </summary>
/// <param name="name">Name that will be compared to Object Handler.</param>
/// <exception cref="InvalidOperationException">Not equal if thrown</exception>
public void CompareNameAndThrow(string name)
{
if (!string.Equals(Name, name, StringComparison.OrdinalIgnoreCase))
@@ -140,11 +165,18 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
}
}
public Dalamud.Game.ClientState.Objects.Types.IGameObject? GetGameObject()
/// <summary>
/// Gets the game object from the address
/// </summary>
/// <returns>Gane object</returns>
public IGameObject? GetGameObject()
{
return _dalamudUtil.CreateGameObject(Address);
}
/// <summary>
/// Invalidate the object handler
/// </summary>
public void Invalidate()
{
Address = IntPtr.Zero;
@@ -153,153 +185,203 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
_haltProcessing = false;
}
/// <summary>
/// Refresh the object handler state
/// </summary>
public void Refresh()
{
_dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult();
}
/// <summary>
/// Is Being Drawn Run On Framework Asyncronously
/// </summary>
/// <returns>Object is being run in framework</returns>
public async Task<bool> IsBeingDrawnRunOnFrameworkAsync()
{
return await _dalamudUtil.RunOnFrameworkThread(IsBeingDrawn).ConfigureAwait(false);
}
/// <summary>
/// Override ToString method for GameObjectHandler
/// </summary>
/// <returns>String</returns>
public override string ToString()
{
var owned = _isOwnedObject ? "Self" : "Other";
return $"{owned}/{ObjectKind}:{Name} ({Address:X},{DrawObjectAddress:X})";
}
protected override void Dispose(bool disposing)
/// <summary>
/// Try Get Object By Address from Object Table
/// </summary>
/// <param name="address">Object address</param>
/// <returns>Game Object of adress</returns>
private IGameObject? TryGetObjectByAddress(nint address)
{
base.Dispose(disposing);
if (address == nint.Zero) return null;
Mediator.Publish(new GameObjectHandlerDestroyedMessage(this, _isOwnedObject));
// Search object table
foreach (var obj in _objectTable)
{
if (obj is null) continue;
if (obj.Address == address)
return obj;
}
return null;
}
private unsafe void CheckAndUpdateObject()
/// <summary>
/// Checks and updates the object state
/// </summary>
private void CheckAndUpdateObject() => CheckAndUpdateObject(allowPublish: true);
/// <summary>
/// Checks and updates the object state with option to allow publish
/// </summary>
/// <param name="allowPublish">Allows to publish the object</param>
private void CheckAndUpdateObject(bool allowPublish)
{
var prevAddr = Address;
var prevDrawObj = DrawObjectAddress;
string? nameString = null;
Address = _getAddress();
if (Address != IntPtr.Zero)
IGameObject? obj = null;
ICharacter? chara = null;
if (Address != nint.Zero)
{
var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address;
var drawObjAddr = (IntPtr)gameObject->DrawObject;
DrawObjectAddress = drawObjAddr;
EntityId = gameObject->EntityId;
CurrentDrawCondition = DrawCondition.None;
}
else
{
DrawObjectAddress = IntPtr.Zero;
EntityId = uint.MaxValue;
CurrentDrawCondition = DrawCondition.DrawObjectZero;
}
// Try get object
obj = TryGetObjectByAddress(Address);
CurrentDrawCondition = IsBeingDrawnUnsafe();
if (_haltProcessing) return;
bool drawObjDiff = DrawObjectAddress != prevDrawObj;
bool addrDiff = Address != prevAddr;
if (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero)
{
var chara = (Character*)Address;
var name = chara->GameObject.NameString;
bool nameChange = !string.Equals(name, Name, StringComparison.Ordinal);
if (nameChange)
if (obj is not null)
{
Name = name;
}
bool equipDiff = false;
EntityId = obj.EntityId;
if (((DrawObject*)DrawObjectAddress)->Object.GetObjectType() == ObjectType.CharacterBase
&& ((CharacterBase*)DrawObjectAddress)->GetModelType() == CharacterBase.ModelType.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));
}
DrawObjectAddress = Address;
equipDiff = CompareAndUpdateEquipByteData((byte*)&((Human*)DrawObjectAddress)->Head);
// Name update
nameString = obj.Name.TextValue ?? string.Empty;
if (!string.IsNullOrEmpty(nameString) && !string.Equals(nameString, Name, StringComparison.Ordinal))
Name = nameString;
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);
if (equipDiff)
Logger.LogTrace("Checking [{this}] equip data as human from draw obj, result: {diff}", this, equipDiff);
chara = obj as ICharacter;
}
else
{
equipDiff = CompareAndUpdateEquipByteData((byte*)Unsafe.AsPointer(ref chara->DrawData.EquipmentModelIds[0]));
if (equipDiff)
Logger.LogTrace("Checking [{this}] equip data from game obj, result: {diff}", this, equipDiff);
DrawObjectAddress = nint.Zero;
EntityId = uint.MaxValue;
}
}
else
{
DrawObjectAddress = nint.Zero;
EntityId = uint.MaxValue;
}
// Update draw condition
CurrentDrawCondition = IsBeingDrawnSafe(obj, chara);
if (_haltProcessing || !allowPublish) return;
// Determine differences
bool drawObjDiff = DrawObjectAddress != prevDrawObj;
bool addrDiff = Address != prevAddr;
// Name change check
bool nameChange = false;
if (nameString is not null)
{
nameChange = !string.Equals(nameString, Name, StringComparison.Ordinal);
if (nameChange) Name = nameString;
}
// Customize data change check
bool customizeDiff = false;
if (chara is not null)
{
// Class job change check
var classJob = chara.ClassJob.RowId;
if (classJob != _classJob)
{
Logger.LogTrace("[{this}] classjob changed from {old} to {new}", this, _classJob, classJob);
_classJob = (byte)classJob;
Mediator.Publish(new ClassJobChangedMessage(this));
}
if (equipDiff && !_isOwnedObject) // send the message out immediately and cancel out, no reason to continue if not self
// Customize data comparison
customizeDiff = CompareAndUpdateCustomizeData(chara.Customize);
// Census update publish
if (_isOwnedObject && ObjectKind == ObjectKind.Player && chara.Customize.Length > (int)CustomizeIndex.Tribe)
{
Logger.LogTrace("[{this}] Changed", this);
return;
}
var gender = chara.Customize[(int)CustomizeIndex.Gender];
var raceId = chara.Customize[(int)CustomizeIndex.Race];
var tribeId = chara.Customize[(int)CustomizeIndex.Tribe];
bool customizeDiff = false;
if (((DrawObject*)DrawObjectAddress)->Object.GetObjectType() == ObjectType.CharacterBase
&& ((CharacterBase*)DrawObjectAddress)->GetModelType() == CharacterBase.ModelType.Human)
{
var gender = ((Human*)DrawObjectAddress)->Customize.Sex;
var raceId = ((Human*)DrawObjectAddress)->Customize.Race;
var tribeId = ((Human*)DrawObjectAddress)->Customize.Tribe;
if (_isOwnedObject && ObjectKind == ObjectKind.Player
&& (gender != Gender || raceId != RaceId || tribeId != TribeId))
if (gender != Gender || raceId != RaceId || tribeId != TribeId)
{
Mediator.Publish(new CensusUpdateMessage(gender, raceId, tribeId));
Gender = gender;
RaceId = raceId;
TribeId = tribeId;
}
}
}
customizeDiff = CompareAndUpdateCustomizeData(((Human*)DrawObjectAddress)->Customize.Data);
if (customizeDiff)
Logger.LogTrace("Checking [{this}] customize data as human from draw obj, result: {diff}", this, customizeDiff);
}
else
{
customizeDiff = CompareAndUpdateCustomizeData(chara->DrawData.CustomizeData.Data);
if (customizeDiff)
Logger.LogTrace("Checking [{this}] customize data from game obj, result: {diff}", this, equipDiff);
}
if ((addrDiff || drawObjDiff || equipDiff || customizeDiff || nameChange) && _isOwnedObject)
{
Logger.LogDebug("[{this}] Changed, Sending CreateCacheObjectMessage", this);
Mediator.Publish(new CreateCacheForObjectMessage(this));
}
if ((addrDiff || drawObjDiff || customizeDiff || nameChange) && _isOwnedObject)
{
Logger.LogDebug("[{this}] Changed, Sending CreateCacheObjectMessage", this);
Mediator.Publish(new CreateCacheForObjectMessage(this));
}
else if (addrDiff || drawObjDiff)
{
CurrentDrawCondition = DrawCondition.DrawObjectZero;
if (Address == nint.Zero)
CurrentDrawCondition = DrawCondition.ObjectZero;
else if (DrawObjectAddress == nint.Zero)
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)
/// <summary>
/// Is object being drawn safe check
/// </summary>
/// <param name="obj">Object thats being checked</param>
/// <param name="chara">Character of the object</param>
/// <returns>Draw Condition of character</returns>
private DrawCondition IsBeingDrawnSafe(IGameObject? obj, ICharacter? chara)
{
// Object zero check
if (Address == nint.Zero) return DrawCondition.ObjectZero;
if (obj is null) return DrawCondition.DrawObjectZero;
// Draw Object check
if (chara is not null && (chara.Customize is null || chara.Customize.Length == 0))
return DrawCondition.DrawObjectZero;
return DrawCondition.None;
}
/// <summary>
/// Compare and update customize data of character
/// </summary>
/// <param name="customizeData">Customize+ data of object</param>
/// <returns>Successfully applied or not</returns>
private bool CompareAndUpdateCustomizeData(ReadOnlySpan<byte> customizeData)
{
bool hasChanges = false;
for (int i = 0; i < customizeData.Length; i++)
// Resize if needed
var len = Math.Min(customizeData.Length, CustomizeData.Length);
for (int i = 0; i < len; i++)
{
var data = customizeData[i];
if (CustomizeData[i] != data)
@@ -312,56 +394,15 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
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)
{
if ((nint)weapon == nint.Zero) 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)
{
if ((nint)weapon == nint.Zero) 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;
}
/// <summary>
/// Framework update method
/// </summary>
private void FrameworkUpdate()
{
if (!_delayedZoningTask?.IsCompleted ?? false) return;
try
{
_performanceCollector.LogPerformance(this, $"CheckAndUpdateObject>{(_isOwnedObject ? "Self" : "Other")}+{ObjectKind}/{(string.IsNullOrEmpty(Name) ? "Unk" : Name)}"
+ $"+{Address.ToString("X")}", CheckAndUpdateObject);
var zoningDelayActive = !(_delayedZoningTask?.IsCompleted ?? true);
_performanceCollector.LogPerformance(this, $"CheckAndUpdateObject>{(_isOwnedObject ? "Self" : "Other")}+{ObjectKind}/{(string.IsNullOrEmpty(Name) ? "Unk" : Name)}", () => CheckAndUpdateObject(allowPublish: !zoningDelayActive));
}
catch (Exception ex)
{
@@ -369,6 +410,10 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
}
}
/// <summary>
/// Is object being drawn check
/// </summary>
/// <returns>Is being drawn</returns>
private bool IsBeingDrawn()
{
EnsureLatestObjectState();
@@ -383,6 +428,9 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
return CurrentDrawCondition != DrawCondition.None;
}
/// <summary>
/// Ensures the latest object state
/// </summary>
private void EnsureLatestObjectState()
{
if (_haltProcessing || !_frameworkUpdateSubscribed)
@@ -391,6 +439,9 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
}
}
/// <summary>
/// Enables framework updates for the object handler
/// </summary>
private void EnableFrameworkUpdates()
{
lock (_frameworkUpdateGate)
@@ -405,24 +456,9 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
}
}
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;
}
/// <summary>
/// Zone switch end handling
/// </summary>
private void ZoneSwitchEnd()
{
if (!_isOwnedObject) return;
@@ -433,7 +469,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
}
catch (ObjectDisposedException)
{
// ignore
// ignore canelled after disposed
}
catch (Exception ex)
{
@@ -441,6 +477,9 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
}
}
/// <summary>
/// Zone switch start handling
/// </summary>
private void ZoneSwitchStart()
{
if (!_isOwnedObject) return;
@@ -462,6 +501,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
Logger.LogDebug("[{this}] Delay after zoning complete", this);
_zoningCts.Dispose();
}
});
}, _zoningCts.Token);
}
}

View File

@@ -0,0 +1,461 @@
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin.Services;
using LightlessSync.API.Data;
using LightlessSync.Interop.Ipc;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using Microsoft.Extensions.Logging;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.PlayerData.Handlers;
/// <summary>
/// Owned object handler for applying changes to owned objects.
/// </summary>
internal sealed class OwnedObjectHandler
{
// Debug information for owned object resolution
internal readonly record struct OwnedResolveDebug(
DateTime? ResolvedAtUtc,
nint Address,
ushort? ObjectIndex,
string Stage,
string? FailureReason)
{
public string? AddressHex => Address == nint.Zero ? null : $"0x{Address:X}";
public static OwnedResolveDebug Empty => new(null, nint.Zero, null, string.Empty, null);
}
private OwnedResolveDebug _minionResolveDebug = OwnedResolveDebug.Empty;
public OwnedResolveDebug MinionResolveDebug => _minionResolveDebug;
// Dependencies
private readonly ILogger _logger;
private readonly DalamudUtilService _dalamudUtil;
private readonly GameObjectHandlerFactory _handlerFactory;
private readonly IpcManager _ipc;
private readonly ActorObjectService _actorObjectService;
private readonly IObjectTable _objectTable;
// Timeouts for fully loaded checks
private const int _fullyLoadedTimeoutMsPlayer = 30000;
private const int _fullyLoadedTimeoutMsOther = 5000;
public OwnedObjectHandler(
ILogger logger,
DalamudUtilService dalamudUtil,
GameObjectHandlerFactory handlerFactory,
IpcManager ipc,
ActorObjectService actorObjectService,
IObjectTable objectTable)
{
_logger = logger;
_dalamudUtil = dalamudUtil;
_handlerFactory = handlerFactory;
_ipc = ipc;
_actorObjectService = actorObjectService;
_objectTable = objectTable;
}
/// <summary>
/// Applies the specified changes to the owned object of the given kind.
/// </summary>
/// <param name="applicationId">Application ID of the Character Object</param>
/// <param name="kind">Object Kind of the given object</param>
/// <param name="changes">Changes of the object</param>
/// <param name="data">Data of the object</param>
/// <param name="playerHandler">Owner of the object</param>
/// <param name="penumbraCollection">Collection if needed</param>
/// <param name="customizeIds">Customizing identications for the object</param>
/// <param name="token">Cancellation Token</param>
/// <returns>Successfully applied or not</returns>
public async Task<bool> ApplyAsync(
Guid applicationId,
ObjectKind kind,
HashSet<PlayerChanges> changes,
CharacterData data,
GameObjectHandler playerHandler,
Guid penumbraCollection,
Dictionary<ObjectKind, Guid?> customizeIds,
CancellationToken token)
{
// Validate player handler
if (playerHandler.Address == nint.Zero)
return false;
// Create handler for owned object
var handler = await CreateHandlerAsync(kind, playerHandler, token).ConfigureAwait(false);
if (handler is null || handler.Address == nint.Zero)
return false;
try
{
token.ThrowIfCancellationRequested();
// Determine if we have file replacements for this kind
bool hasFileReplacements =
kind != ObjectKind.Player
&& data.FileReplacements.TryGetValue(kind, out var repls)
&& repls is { Count: > 0 };
// Determine if we should assign a Penumbra collection
bool shouldAssignCollection =
kind != ObjectKind.Player
&& hasFileReplacements
&& penumbraCollection != Guid.Empty
&& _ipc.Penumbra.APIAvailable;
// Determine if only IPC-only changes are being made for player
bool isPlayerIpcOnly =
kind == ObjectKind.Player
&& changes.Count > 0
&& changes.All(c => c is PlayerChanges.Honorific
or PlayerChanges.Moodles
or PlayerChanges.PetNames
or PlayerChanges.Heels);
// Wait for drawing to complete
await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false);
// Determine timeouts
var drawTimeoutMs = handler.ObjectKind == ObjectKind.Player ? 30000 : 5000;
var fullyLoadedTimeoutMs = handler.ObjectKind == ObjectKind.Player ? _fullyLoadedTimeoutMsPlayer : _fullyLoadedTimeoutMsOther;
// Wait for drawing to complete
await _dalamudUtil
.WaitWhileCharacterIsDrawing(_logger, handler, applicationId, drawTimeoutMs, token)
.ConfigureAwait(false);
if (handler.Address != nint.Zero)
{
// Wait for fully loaded
var loaded = await _actorObjectService
.WaitForFullyLoadedAsync(handler.Address, token, fullyLoadedTimeoutMs)
.ConfigureAwait(false);
if (!loaded)
{
_logger.LogTrace("[{appId}] {kind}: not fully loaded in time, skipping for now", applicationId, kind);
return false;
}
}
token.ThrowIfCancellationRequested();
// Assign Penumbra collection if needed
if (shouldAssignCollection)
{
// Get object index
var objIndex = await _dalamudUtil
.RunOnFrameworkThread(() => handler.GetGameObject()?.ObjectIndex)
.ConfigureAwait(false);
if (!objIndex.HasValue)
{
_logger.LogTrace("[{appId}] {kind}: ObjectIndex not available yet, cannot assign collection", applicationId, kind);
return false;
}
// Assign collection
await _ipc.Penumbra
.AssignTemporaryCollectionAsync(_logger, penumbraCollection, objIndex.Value)
.ConfigureAwait(false);
}
var tasks = new List<Task>();
// Apply each change
foreach (var change in changes.OrderBy(c => (int)c))
{
token.ThrowIfCancellationRequested();
// Handle each change type
switch (change)
{
case PlayerChanges.Customize:
if (data.CustomizePlusData.TryGetValue(kind, out var customizeData) && !string.IsNullOrEmpty(customizeData))
tasks.Add(ApplyCustomizeAsync(handler.Address, customizeData, kind, customizeIds));
else if (customizeIds.TryGetValue(kind, out var existingId))
tasks.Add(RevertCustomizeAsync(existingId, kind, customizeIds));
break;
case PlayerChanges.Glamourer:
if (data.GlamourerData.TryGetValue(kind, out var glamourerData) && !string.IsNullOrEmpty(glamourerData))
tasks.Add(_ipc.Glamourer.ApplyAllAsync(_logger, handler, glamourerData, applicationId, token));
break;
case PlayerChanges.Heels:
if (kind == ObjectKind.Player && !string.IsNullOrEmpty(data.HeelsData))
tasks.Add(_ipc.Heels.SetOffsetForPlayerAsync(handler.Address, data.HeelsData));
break;
case PlayerChanges.Honorific:
if (kind == ObjectKind.Player && !string.IsNullOrEmpty(data.HonorificData))
tasks.Add(_ipc.Honorific.SetTitleAsync(handler.Address, data.HonorificData));
break;
case PlayerChanges.Moodles:
if (kind == ObjectKind.Player && !string.IsNullOrEmpty(data.MoodlesData))
tasks.Add(_ipc.Moodles.SetStatusAsync(handler.Address, data.MoodlesData));
break;
case PlayerChanges.PetNames:
if (kind == ObjectKind.Player && !string.IsNullOrEmpty(data.PetNamesData))
tasks.Add(_ipc.PetNames.SetPlayerData(handler.Address, data.PetNamesData));
break;
case PlayerChanges.ModFiles:
case PlayerChanges.ModManip:
case PlayerChanges.ForcedRedraw:
default:
break;
}
}
// Await all tasks for change applications
if (tasks.Count > 0)
await Task.WhenAll(tasks).ConfigureAwait(false);
token.ThrowIfCancellationRequested();
// Determine if redraw is needed
bool needsRedraw =
_ipc.Penumbra.APIAvailable
&& (
shouldAssignCollection
|| changes.Contains(PlayerChanges.ForcedRedraw)
|| changes.Contains(PlayerChanges.ModFiles)
|| changes.Contains(PlayerChanges.ModManip)
|| changes.Contains(PlayerChanges.Glamourer)
|| changes.Contains(PlayerChanges.Customize)
);
// Skip redraw for player if only IPC-only changes were made
if (isPlayerIpcOnly)
needsRedraw = false;
// Perform redraw if needed
if (needsRedraw && _ipc.Penumbra.APIAvailable)
{
_logger.LogWarning(
"[{appId}] {kind}: Redrawing ownedTarget={isOwned} (needsRedraw={needsRedraw})",
applicationId, kind, kind != ObjectKind.Player, needsRedraw);
await _ipc.Penumbra
.RedrawAsync(_logger, handler, applicationId, token)
.ConfigureAwait(false);
}
return true;
}
finally
{
if (!ReferenceEquals(handler, playerHandler))
handler.Dispose();
}
}
/// <summary>
/// Creates a GameObjectHandler for the owned object of the specified kind.
/// </summary>
/// <param name="kind">Object kind of the handler</param>
/// <param name="playerHandler">Owner of the given object</param>
/// <param name="token">Cancellation Token</param>
/// <returns>Handler for the GameObject with the handler</returns>
private async Task<GameObjectHandler?> CreateHandlerAsync(ObjectKind kind, GameObjectHandler playerHandler, CancellationToken token)
{
token.ThrowIfCancellationRequested();
// Debug info setter
void SetMinionDebug(string stage, string? failure, nint addr = default, ushort? objIndex = null)
{
if (kind != ObjectKind.MinionOrMount)
return;
_minionResolveDebug = new OwnedResolveDebug(
DateTime.UtcNow,
addr,
objIndex,
stage,
failure);
}
// Direct return for player
if (kind == ObjectKind.Player)
return playerHandler;
// First, try direct retrieval via Dalamud API
var playerPtr = playerHandler.Address;
if (playerPtr == nint.Zero)
{
SetMinionDebug("player_ptr_zero", "playerHandler.Address == 0");
return null;
}
// Try direct retrieval
nint ownedPtr = kind switch
{
ObjectKind.Companion => await _dalamudUtil.GetCompanionAsync(playerPtr).ConfigureAwait(false),
ObjectKind.MinionOrMount => await _dalamudUtil.GetMinionOrMountAsync(playerPtr).ConfigureAwait(false),
ObjectKind.Pet => await _dalamudUtil.GetPetAsync(playerPtr).ConfigureAwait(false),
_ => nint.Zero
};
// If that fails, scan the object table for owned objects
var stage = ownedPtr != nint.Zero ? "direct" : "direct_miss";
// Owner ID based scan
if (ownedPtr == nint.Zero)
{
token.ThrowIfCancellationRequested();
// Get owner entity ID
var ownerEntityId = playerHandler.EntityId;
if (ownerEntityId == 0 || ownerEntityId == uint.MaxValue)
{
// Read unsafe
ownerEntityId = await _dalamudUtil
.RunOnFrameworkThread(() => ReadEntityIdSafe(playerHandler))
.ConfigureAwait(false);
}
if (ownerEntityId != 0 && ownerEntityId != uint.MaxValue)
{
// Scan for owned object
ownedPtr = await _dalamudUtil
.RunOnFrameworkThread(() => FindOwnedByOwnerIdSafe(kind, ownerEntityId))
.ConfigureAwait(false);
stage = ownedPtr != nint.Zero ? "owner_scan" : "owner_scan_miss";
}
else
{
stage = "owner_id_unavailable";
}
}
if (ownedPtr == nint.Zero)
{
SetMinionDebug(stage, "ownedPtr == 0");
return null;
}
token.ThrowIfCancellationRequested();
// Create handler
var handler = await _handlerFactory.Create(kind, () => ownedPtr, isWatched: false).ConfigureAwait(false);
if (handler is null || handler.Address == nint.Zero)
{
SetMinionDebug(stage, "handlerFactory returned null/zero", ownedPtr);
return null;
}
// Get object index for debug
ushort? objIndex = await _dalamudUtil.RunOnFrameworkThread(() => handler.GetGameObject()?.ObjectIndex)
.ConfigureAwait(false);
SetMinionDebug(stage, failure: null, handler.Address, objIndex);
return handler;
}
/// <summary>
/// Entity ID reader with safety checks.
/// </summary>
/// <param name="playerHandler">Handler of the Object</param>
/// <returns>Entity Id</returns>
private static uint ReadEntityIdSafe(GameObjectHandler playerHandler) => playerHandler.GetGameObject()?.EntityId ?? 0;
/// <summary>
/// Finds an owned object by scanning the object table for the specified owner entity ID.
/// </summary>
/// <param name="kind">Object kind to find of owned object</param>
/// <param name="ownerEntityId">Owner Id</param>
/// <returns>Object Id</returns>
private nint FindOwnedByOwnerIdSafe(ObjectKind kind, uint ownerEntityId)
{
// Validate owner ID
if (ownerEntityId == 0 || ownerEntityId == uint.MaxValue)
return nint.Zero;
// Scan object table
foreach (var obj in _objectTable)
{
// Validate object
if (obj is null || obj.Address == nint.Zero)
continue;
// Check owner ID match
if (obj.OwnerId != ownerEntityId)
continue;
// Check kind match
if (!IsOwnedKindMatch(obj, kind))
continue;
return obj.Address;
}
return nint.Zero;
}
/// <summary>
/// Determines if the given object matches the specified owned kind.
/// </summary>
/// <param name="obj">Game Object</param>
/// <param name="kind">Object Kind</param>
/// <returns></returns>
private static bool IsOwnedKindMatch(IGameObject obj, ObjectKind kind) => kind switch
{
// Match minion or mount
ObjectKind.MinionOrMount =>
obj.ObjectKind is DalamudObjectKind.MountType
or DalamudObjectKind.Companion,
// Match pet
ObjectKind.Pet =>
obj.ObjectKind == DalamudObjectKind.BattleNpc
&& obj is IBattleNpc bnPet
&& bnPet.BattleNpcKind == BattleNpcSubKind.Pet,
// Match companion
ObjectKind.Companion =>
obj.ObjectKind == DalamudObjectKind.BattleNpc
&& obj is IBattleNpc bnBuddy
&& bnBuddy.BattleNpcKind == BattleNpcSubKind.Chocobo,
_ => false
};
/// <summary>
/// Applies Customize Plus data to the specified object.
/// </summary>
/// <param name="address">Object Address</param>
/// <param name="customizeData">Data of the Customize+ that has to be applied</param>
/// <param name="kind">Object Kind</param>
/// <param name="customizeIds">Customize+ Ids</param>
/// <returns>Task</returns>
private async Task ApplyCustomizeAsync(nint address, string customizeData, ObjectKind kind, Dictionary<ObjectKind, Guid?> customizeIds)
{
customizeIds[kind] = await _ipc.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false);
}
/// <summary>
/// Reverts Customize Plus changes for the specified object.
/// </summary>
/// <param name="customizeId">Customize+ Id</param>
/// <param name="kind">Object Id</param>
/// <param name="customizeIds">List of Customize+ ids</param>
/// <returns></returns>
private async Task RevertCustomizeAsync(Guid? customizeId, ObjectKind kind, Dictionary<ObjectKind, Guid?> customizeIds)
{
if (!customizeId.HasValue)
return;
await _ipc.CustomizePlus.RevertByIdAsync(customizeId.Value).ConfigureAwait(false);
customizeIds.Remove(kind);
}
}

View File

@@ -1,43 +1,69 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
namespace LightlessSync.PlayerData.Pairs;
namespace LightlessSync.PlayerData.Pairs;
/// <summary>
/// orchestrates the lifecycle of a paired character
/// </summary>
public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject
{
new string Ident { get; }
bool Initialized { get; }
bool IsVisible { get; }
bool ScheduledForDeletion { get; set; }
CharacterData? LastReceivedCharacterData { get; }
long LastAppliedDataBytes { get; }
new string? PlayerName { get; }
string PlayerNameHash { get; }
uint PlayerCharacterId { get; }
DateTime? LastDataReceivedAt { get; }
DateTime? LastApplyAttemptAt { get; }
DateTime? LastSuccessfulApplyAt { get; }
string? LastFailureReason { get; }
IReadOnlyList<string> LastBlockingConditions { get; }
bool IsApplying { get; }
bool IsDownloading { get; }
int PendingDownloadCount { get; }
int ForbiddenDownloadCount { get; }
bool PendingModReapply { get; }
bool ModApplyDeferred { get; }
int MissingCriticalMods { get; }
int MissingNonCriticalMods { get; }
int MissingForbiddenMods { get; }
DateTime? InvisibleSinceUtc { get; }
DateTime? VisibilityEvictionDueAtUtc { get; }
/// <summary>
/// orchestrates the lifecycle of a paired character
/// </summary>
public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject
{
new string Ident { get; }
bool Initialized { get; }
bool IsVisible { get; }
bool ScheduledForDeletion { get; set; }
CharacterData? LastReceivedCharacterData { get; }
long LastAppliedDataBytes { get; }
new string? PlayerName { get; }
string PlayerNameHash { get; }
uint PlayerCharacterId { get; }
DateTime? LastDataReceivedAt { get; }
DateTime? LastApplyAttemptAt { get; }
DateTime? LastSuccessfulApplyAt { get; }
string? LastFailureReason { get; }
IReadOnlyList<string> LastBlockingConditions { get; }
bool IsApplying { get; }
bool IsDownloading { get; }
int PendingDownloadCount { get; }
int ForbiddenDownloadCount { get; }
bool PendingModReapply { get; }
bool ModApplyDeferred { get; }
int MissingCriticalMods { get; }
int MissingNonCriticalMods { get; }
int MissingForbiddenMods { get; }
DateTime? InvisibleSinceUtc { get; }
DateTime? VisibilityEvictionDueAtUtc { get; }
string? MinionAddressHex { get; }
ushort? MinionObjectIndex { get; }
DateTime? MinionResolvedAtUtc { get; }
string? MinionResolveStage { get; }
string? MinionResolveFailureReason { get; }
bool MinionPendingRetry { get; }
IReadOnlyList<string> MinionPendingRetryChanges { get; }
bool MinionHasAppearanceData { get; }
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);
bool FetchPerformanceMetricsFromCache();
void LoadCachedCharacterData(CharacterData data);
void SetUploading(bool uploading);
void SetPaused(bool paused);
}
void ApplyData(CharacterData data);
void ApplyLastReceivedData(bool forced = false);
void HardReapplyLastData();
bool FetchPerformanceMetricsFromCache();
void LoadCachedCharacterData(CharacterData data);
void SetUploading(bool uploading);
void SetPaused(bool paused);
}

View File

@@ -16,4 +16,5 @@ public interface IPairPerformanceSubject
long LastAppliedApproximateVRAMBytes { get; set; }
long LastAppliedApproximateEffectiveVRAMBytes { get; set; }
long LastAppliedDataTris { get; set; }
long LastAppliedApproximateEffectiveTris { get; set; }
}

View File

@@ -69,6 +69,7 @@ public class Pair
public string? PlayerName => TryGetHandler()?.PlayerName ?? UserPair.User.AliasOrUID;
public long LastAppliedDataBytes => TryGetHandler()?.LastAppliedDataBytes ?? -1;
public long LastAppliedDataTris => TryGetHandler()?.LastAppliedDataTris ?? -1;
public long LastAppliedApproximateEffectiveTris => TryGetHandler()?.LastAppliedApproximateEffectiveTris ?? -1;
public long LastAppliedApproximateVRAMBytes => TryGetHandler()?.LastAppliedApproximateVRAMBytes ?? -1;
public long LastAppliedApproximateEffectiveVRAMBytes => TryGetHandler()?.LastAppliedApproximateEffectiveVRAMBytes ?? -1;
public string Ident => TryGetHandler()?.Ident ?? TryGetConnection()?.Ident ?? string.Empty;
@@ -81,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)
@@ -159,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();
@@ -243,6 +310,17 @@ public class Pair
handler.ModApplyDeferred,
handler.MissingCriticalMods,
handler.MissingNonCriticalMods,
handler.MissingForbiddenMods);
handler.MissingForbiddenMods,
handler.MinionAddressHex,
handler.MinionObjectIndex,
handler.MinionResolvedAtUtc,
handler.MinionResolveStage,
handler.MinionResolveFailureReason,
handler.MinionPendingRetry,
handler.MinionPendingRetryChanges,
handler.MinionHasAppearanceData,
handler.OwnedPenumbraCollectionId,
handler.NeedsCollectionRebuildDebug);
}
}

View File

@@ -125,6 +125,7 @@ public sealed partial class PairCoordinator
}
}
_mediator.Publish(new PairOnlineMessage(new PairUniqueIdentifier(dto.User.UID)));
PublishPairDataChanged();
}

View File

@@ -21,28 +21,50 @@ public sealed record PairDebugInfo(
bool ModApplyDeferred,
int MissingCriticalMods,
int MissingNonCriticalMods,
int MissingForbiddenMods)
int MissingForbiddenMods,
string? MinionAddressHex,
ushort? MinionObjectIndex,
DateTime? MinionResolvedAtUtc,
string? MinionResolveStage,
string? MinionResolveFailureReason,
bool MinionPendingRetry,
IReadOnlyList<string> MinionPendingRetryChanges,
bool MinionHasAppearanceData,
Guid OwnedPenumbraCollectionId,
bool NeedsCollectionRebuild)
{
public static PairDebugInfo Empty { get; } = new(
false,
false,
false,
false,
null,
null,
null,
null,
null,
null,
null,
Array.Empty<string>(),
false,
false,
0,
0,
false,
false,
0,
0,
0);
HasHandler: false,
HandlerInitialized: false,
HandlerVisible: false,
HandlerScheduledForDeletion: false,
LastDataReceivedAt: null,
LastApplyAttemptAt: null,
LastSuccessfulApplyAt: null,
InvisibleSinceUtc: null,
VisibilityEvictionDueAtUtc: null,
VisibilityEvictionRemainingSeconds: null,
LastFailureReason: null,
BlockingConditions: [],
IsApplying: false,
IsDownloading: false,
PendingDownloadCount: 0,
ForbiddenDownloadCount: 0,
PendingModReapply: false,
ModApplyDeferred: false,
MissingCriticalMods: 0,
MissingNonCriticalMods: 0,
MissingForbiddenMods: 0,
MinionAddressHex: null,
MinionObjectIndex: null,
MinionResolvedAtUtc: null,
MinionResolveStage: null,
MinionResolveFailureReason: null,
MinionPendingRetry: false,
MinionPendingRetryChanges: [],
MinionHasAppearanceData: false,
OwnedPenumbraCollectionId: Guid.Empty,
NeedsCollectionRebuild: false);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,15 @@
using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Factories;
using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ModelDecimation;
using LightlessSync.Services.PairProcessing;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Services.TextureCompression;
using Dalamud.Plugin.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -25,13 +28,19 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
private readonly IServiceProvider _serviceProvider;
private readonly IHostApplicationLifetime _lifetime;
private readonly FileCacheManager _fileCacheManager;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly PlayerPerformanceService _playerPerformanceService;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ServerConfigurationManager _serverConfigManager;
private readonly TextureDownscaleService _textureDownscaleService;
private readonly ModelDecimationService _modelDecimationService;
private readonly PairStateCache _pairStateCache;
private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache;
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
private readonly LightlessConfigService _configService;
private readonly XivDataAnalyzer _modelAnalyzer;
private readonly IFramework _framework;
private readonly IObjectTable _objectTable;
public PairHandlerAdapterFactory(
ILoggerFactory loggerFactory,
@@ -42,15 +51,21 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
FileDownloadManagerFactory fileDownloadManagerFactory,
PluginWarningNotificationService pluginWarningNotificationManager,
IServiceProvider serviceProvider,
IFramework framework,
IHostApplicationLifetime lifetime,
FileCacheManager fileCacheManager,
PlayerPerformanceConfigService playerPerformanceConfigService,
PlayerPerformanceService playerPerformanceService,
PairProcessingLimiter pairProcessingLimiter,
ServerConfigurationManager serverConfigManager,
TextureDownscaleService textureDownscaleService,
ModelDecimationService modelDecimationService,
PairStateCache pairStateCache,
PairPerformanceMetricsCache pairPerformanceMetricsCache,
PenumbraTempCollectionJanitor tempCollectionJanitor)
PenumbraTempCollectionJanitor tempCollectionJanitor,
XivDataAnalyzer modelAnalyzer,
LightlessConfigService configService,
IObjectTable objectTable)
{
_loggerFactory = loggerFactory;
_mediator = mediator;
@@ -60,15 +75,21 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
_fileDownloadManagerFactory = fileDownloadManagerFactory;
_pluginWarningNotificationManager = pluginWarningNotificationManager;
_serviceProvider = serviceProvider;
_framework = framework;
_lifetime = lifetime;
_fileCacheManager = fileCacheManager;
_playerPerformanceConfigService = playerPerformanceConfigService;
_playerPerformanceService = playerPerformanceService;
_pairProcessingLimiter = pairProcessingLimiter;
_serverConfigManager = serverConfigManager;
_textureDownscaleService = textureDownscaleService;
_modelDecimationService = modelDecimationService;
_pairStateCache = pairStateCache;
_pairPerformanceMetricsCache = pairPerformanceMetricsCache;
_tempCollectionJanitor = tempCollectionJanitor;
_modelAnalyzer = modelAnalyzer;
_configService = configService;
_objectTable = objectTable;
}
public IPairHandlerAdapter Create(string ident)
@@ -86,15 +107,21 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
downloadManager,
_pluginWarningNotificationManager,
dalamudUtilService,
_framework,
_objectTable,
actorObjectService,
_lifetime,
_fileCacheManager,
_playerPerformanceConfigService,
_playerPerformanceService,
_pairProcessingLimiter,
_serverConfigManager,
_textureDownscaleService,
_modelDecimationService,
_pairStateCache,
_pairPerformanceMetricsCache,
_tempCollectionJanitor);
_tempCollectionJanitor,
_modelAnalyzer,
_configService);
}
}

View File

@@ -89,7 +89,7 @@ public sealed class PairHandlerRegistry : IDisposable
}
if (handler.LastReceivedCharacterData is not null &&
(handler.LastAppliedApproximateVRAMBytes < 0 || handler.LastAppliedDataTris < 0))
(handler.LastAppliedApproximateVRAMBytes < 0 || handler.LastAppliedDataTris < 0 || handler.LastAppliedApproximateEffectiveTris < 0))
{
handler.ApplyLastReceivedData(forced: true);
}

View File

@@ -258,7 +258,8 @@ public sealed class PairLedger : DisposableMediatorSubscriberBase
if (handler.LastAppliedApproximateVRAMBytes >= 0
&& handler.LastAppliedDataTris >= 0
&& handler.LastAppliedApproximateEffectiveVRAMBytes >= 0)
&& handler.LastAppliedApproximateEffectiveVRAMBytes >= 0
&& handler.LastAppliedApproximateEffectiveTris >= 0)
{
continue;
}

View File

@@ -5,7 +5,8 @@ namespace LightlessSync.PlayerData.Pairs;
public readonly record struct PairPerformanceMetrics(
long TriangleCount,
long ApproximateVramBytes,
long ApproximateEffectiveVramBytes);
long ApproximateEffectiveVramBytes,
long ApproximateEffectiveTris);
/// <summary>
/// caches performance metrics keyed by pair ident

View File

@@ -50,6 +50,7 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
});
Mediator.Subscribe<ConnectedMessage>(this, (_) => PushToAllVisibleUsers());
Mediator.Subscribe<PairOnlineMessage>(this, (msg) => HandlePairOnline(msg.PairIdent));
Mediator.Subscribe<DisconnectedMessage>(this, (_) =>
{
_fileTransferManager.CancelUpload();
@@ -111,6 +112,20 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
_ = PushCharacterDataAsync(forced);
}
private void HandlePairOnline(PairUniqueIdentifier pairIdent)
{
if (!_apiController.IsConnected || !_pairLedger.IsPairVisible(pairIdent))
{
return;
}
if (_pairLedger.GetHandler(pairIdent)?.UserData is { } user)
{
_usersToPushDataTo.Add(user);
PushCharacterData(forced: true);
}
}
private async Task PushCharacterDataAsync(bool forced = false)
{
await _pushLock.WaitAsync(_runtimeCts.Token).ConfigureAwait(false);
@@ -152,5 +167,6 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
}
}
private List<UserData> GetVisibleUsers() => [.. _pairLedger.GetVisiblePairs().Select(connection => connection.User)];
private List<UserData> GetVisibleUsers()
=> [.. _pairLedger.GetVisiblePairs().Where(connection => connection.IsOnline).Select(connection => connection.User)];
}

View File

@@ -40,6 +40,7 @@ using System.Reflection;
using OtterTex;
using LightlessSync.Services.LightFinder;
using LightlessSync.Services.PairProcessing;
using LightlessSync.Services.ModelDecimation;
using LightlessSync.UI.Models;
namespace LightlessSync;
@@ -105,9 +106,11 @@ public sealed class Plugin : IDalamudPlugin
services.AddSingleton(new WindowSystem("LightlessSync"));
services.AddSingleton<FileDialogManager>();
services.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", string.Empty, useEmbedded: true));
services.AddSingleton(framework);
services.AddSingleton(gameGui);
services.AddSingleton(gameInteropProvider);
services.AddSingleton(addonLifecycle);
services.AddSingleton(objectTable);
services.AddSingleton<IUiBuilder>(pluginInterface.UiBuilder);
// Core singletons
@@ -125,6 +128,7 @@ public sealed class Plugin : IDalamudPlugin
services.AddSingleton<LightlessProfileManager>();
services.AddSingleton<TextureCompressionService>();
services.AddSingleton<TextureDownscaleService>();
services.AddSingleton<ModelDecimationService>();
services.AddSingleton<GameObjectHandlerFactory>();
services.AddSingleton<FileDownloadManagerFactory>();
services.AddSingleton<PairProcessingLimiter>();
@@ -140,6 +144,7 @@ public sealed class Plugin : IDalamudPlugin
services.AddSingleton<IdDisplayHandler>();
services.AddSingleton<PlayerPerformanceService>();
services.AddSingleton<PenumbraTempCollectionJanitor>();
services.AddSingleton<LocationShareService>();
services.AddSingleton<TextureMetadataHelper>(sp =>
new TextureMetadataHelper(sp.GetRequiredService<ILogger<TextureMetadataHelper>>(), gameData));
@@ -176,7 +181,8 @@ public sealed class Plugin : IDalamudPlugin
services.AddSingleton(sp => new BlockedCharacterHandler(
sp.GetRequiredService<ILogger<BlockedCharacterHandler>>(),
gameInteropProvider));
gameInteropProvider,
objectTable));
services.AddSingleton(sp => new IpcProvider(
sp.GetRequiredService<ILogger<IpcProvider>>(),
@@ -372,6 +378,11 @@ public sealed class Plugin : IDalamudPlugin
sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessMediator>()));
services.AddSingleton(sp => new IpcCallerLifestream(
pluginInterface,
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<ILogger<IpcCallerLifestream>>()));
services.AddSingleton(sp => new IpcManager(
sp.GetRequiredService<ILogger<IpcManager>>(),
sp.GetRequiredService<LightlessMediator>(),
@@ -382,7 +393,9 @@ public sealed class Plugin : IDalamudPlugin
sp.GetRequiredService<IpcCallerHonorific>(),
sp.GetRequiredService<IpcCallerMoodles>(),
sp.GetRequiredService<IpcCallerPetNames>(),
sp.GetRequiredService<IpcCallerBrio>()));
sp.GetRequiredService<IpcCallerBrio>(),
sp.GetRequiredService<IpcCallerLifestream>()
));
// Notifications / HTTP
services.AddSingleton(sp => new NotificationService(
@@ -416,6 +429,7 @@ public sealed class Plugin : IDalamudPlugin
return cfg;
});
services.AddSingleton(sp => new ServerConfigService(configDir));
services.AddSingleton(sp => new PenumbraJanitorConfigService(configDir));
services.AddSingleton(sp => new NotesConfigService(configDir));
services.AddSingleton(sp => new PairTagConfigService(configDir));
services.AddSingleton(sp => new SyncshellTagConfigService(configDir));
@@ -429,6 +443,7 @@ public sealed class Plugin : IDalamudPlugin
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<UiThemeConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ChatConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ServerConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<PenumbraJanitorConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<NotesConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<PairTagConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<SyncshellTagConfigService>());
@@ -480,19 +495,11 @@ public sealed class Plugin : IDalamudPlugin
sp.GetRequiredService<UiSharedService>(),
sp.GetRequiredService<ApiController>(),
sp.GetRequiredService<LightFinderScannerService>(),
sp.GetRequiredService<LightFinderPlateHandler>()));
services.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>(sp => new SyncshellFinderUI(
sp.GetRequiredService<ILogger<SyncshellFinderUI>>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<PerformanceCollectorService>(),
sp.GetRequiredService<LightFinderService>(),
sp.GetRequiredService<UiSharedService>(),
sp.GetRequiredService<ApiController>(),
sp.GetRequiredService<LightFinderScannerService>(),
sp.GetRequiredService<PairUiService>(),
sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessProfileManager>()));
sp.GetRequiredService<LightlessProfileManager>(),
sp.GetRequiredService<ActorObjectService>(),
sp.GetRequiredService<LightFinderPlateHandler>()));
services.AddScoped<IPopupHandler, BanUserPopupHandler>();
services.AddScoped<IPopupHandler, CensusPopupHandler>();
@@ -578,7 +585,6 @@ public sealed class Plugin : IDalamudPlugin
public void Dispose()
{
_host.StopAsync().GetAwaiter().GetResult();
_host.Dispose();
_host.StopAsync().ContinueWith(_ => _host.Dispose()).Wait(TimeSpan.FromSeconds(5));
}
}

View File

@@ -0,0 +1,9 @@
namespace LightlessSync.Resources;
public static class LocalizationExtensions
{
public static string F(this string mask, params object[] args)
{
return string.Format(mask, args);
}
}

View File

@@ -0,0 +1,171 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace LightlessSync.Resources {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("LightlessSync.Resources.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to I agree.
/// </summary>
public static string ToSStrings_AgreeLabel {
get {
return ResourceManager.GetString("ToSStrings_AgreeLabel", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Agreement of Usage of Service.
/// </summary>
public static string ToSStrings_AgreementLabel {
get {
return ResourceManager.GetString("ToSStrings_AgreementLabel", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to &apos;I agree&apos; button will be available in.
/// </summary>
public static string ToSStrings_ButtonWillBeAvailableIn {
get {
return ResourceManager.GetString("ToSStrings_ButtonWillBeAvailableIn", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Language.
/// </summary>
public static string ToSStrings_LanguageLabel {
get {
return ResourceManager.GetString("ToSStrings_LanguageLabel", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to All of the mod files currently active on your character as well as your current character state will be uploaded to the service you registered yourself at automatically. The plugin will exclusively upload the necessary mod files and not the whole mod..
/// </summary>
public static string ToSStrings_Paragraph1 {
get {
return ResourceManager.GetString("ToSStrings_Paragraph1", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to If you are on a data capped internet connection, higher fees due to data usage depending on the amount of downloaded and uploaded mod files might occur. Mod files will be compressed on up- and download to save on bandwidth usage. Due to varying up- and download speeds, changes in characters might not be visible immediately. Files present on the service that already represent your active mod files will not be uploaded again..
/// </summary>
public static string ToSStrings_Paragraph2 {
get {
return ResourceManager.GetString("ToSStrings_Paragraph2", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The mod files you are uploading are confidential and will not be distributed to parties other than the ones who are requesting the exact same mod files. Please think about who you are going to pair since it is unavoidable that they will receive and locally cache the necessary mod files that you have currently in use. Locally cached mod files will have arbitrary file names to discourage attempts at replicating the original mod..
/// </summary>
public static string ToSStrings_Paragraph3 {
get {
return ResourceManager.GetString("ToSStrings_Paragraph3", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The plugin creator tried their best to keep you secure. However, there is no guarantee for 100% security. Do not blindly pair your client with everyone..
/// </summary>
public static string ToSStrings_Paragraph4 {
get {
return ResourceManager.GetString("ToSStrings_Paragraph4", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Mod files that are saved on the service will remain on the service as long as there are requests for the files from clients. After a period of not being used, the mod files will be automatically deleted. You will also be able to wipe all the files you have personally uploaded on request. The service holds no information about which mod files belong to which mod..
/// </summary>
public static string ToSStrings_Paragraph5 {
get {
return ResourceManager.GetString("ToSStrings_Paragraph5", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to This service is provided as-is. In case of abuse join the Lightless Sync Discord..
/// </summary>
public static string ToSStrings_Paragraph6 {
get {
return ResourceManager.GetString("ToSStrings_Paragraph6", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to READ THIS CAREFULLY.
/// </summary>
public static string ToSStrings_ReadLabel {
get {
return ResourceManager.GetString("ToSStrings_ReadLabel", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Users Online.
/// </summary>
public static string Users_Online {
get {
return ResourceManager.GetString("Users_Online", resourceCulture);
}
}
}
}

View File

@@ -0,0 +1,47 @@
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ToSStrings_LanguageLabel" xml:space="preserve">
<value>Language</value>
</data>
<data name="ToSStrings_AgreementLabel" xml:space="preserve">
<value>Nutzungsbedingungen</value>
</data>
<data name="ToSStrings_ReadLabel" xml:space="preserve">
<value>BITTE LIES DIES SORGFÄLTIG</value>
</data>
<data name="ToSStrings_Paragraph1" xml:space="preserve">
<value>Alle Moddateien, die aktuell auf deinem Charakter aktiv sind und dein Charakterzustand werden automatisch zu dem Service, an dem du dich registriert hast, hochgeladen. Das Plugin wird ausschließlich die nötigen Moddateien hochladen und nicht die gesamte Modifikation.</value>
</data>
<data name="ToSStrings_Paragraph2" xml:space="preserve">
<value>Falls du mit einer getakteten Internetverbindung verbunden bist, können durch den Datentransfer von Hoch- und Runtergeladenen Moddateien höhere Kosten entstehen. Moddateien werden beim Hoch- und Runterladen komprimiert um Bandbreite zu sparen. Durch unterschiedliche Hoch- und Runterladgeschwindigkeiten ist es möglich, dass Änderungen an Charakteren nicht sofort sichtbar sind. Dateien die bereits auf dem Service existieren, werden nicht nochmals hochgeladen.</value>
</data>
<data name="ToSStrings_Paragraph3" xml:space="preserve">
<value>Die Moddateien die du hochlädst sind vertraulich und werden nicht mit anderen Nutzern geteilt, die nicht die exakt selben Dateien anfordern. Bitte überlege dir sorgfältig mit wem du deinen Identifikationscode teilst, da es unvermeidlich ist, dass die andere Person deine Moddateien erhält und lokal zwischenspeichert. Lokal zwischengespeicherte Dateien haben willkürrliche Namen um vor Versuchen abzuschrecken die originalen Moddateien aus diesen wiederherzustellen.</value>
</data>
<data name="ToSStrings_Paragraph4" xml:space="preserve">
<value>Der Ersteller des Plugins hat sein Bestes getan, um deine Sicherheit zu gewährleisten. Es gibt jedoch keine Garantie für 100%ige Sicherheit. Teile deinen Identifikationscode nicht blind mit jedem.</value>
</data>
<data name="ToSStrings_Paragraph5" xml:space="preserve">
<value>Moddateien, die auf dem Service gespeichert sind, verbleiben auf dem Service, solange es Anforderungen für diese Dateien gibt. Nach einer Zeitspanne in der die Dateien nicht verwendet wurden, werden diese automatisch gelöscht. Du hast auch die Möglichkeit manuell alle Dateien auf dem Service zu löschen. Der Service hat keine Informationen welche Moddateien zu welcher Modifikation gehören.</value>
</data>
<data name="ToSStrings_Paragraph6" xml:space="preserve">
<value>Dieser Dienst wird ohne Gewähr angeboten. Im Falle eines Missbrauchs tretet dem Lightless Sync Discord bei.</value>
</data>
<data name="ToSStrings_AgreeLabel" xml:space="preserve">
<value>Ich Stimme zu</value>
</data>
<data name="ToSStrings_ButtonWillBeAvailableIn" xml:space="preserve">
<value>"Ich stimme zu" Knopf verfügbar in</value>
</data>
</root>

View File

@@ -0,0 +1,47 @@
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ToSStrings_LanguageLabel" xml:space="preserve">
<value>Language</value>
</data>
<data name="ToSStrings_AgreementLabel" xml:space="preserve">
<value>Conditions d'Utilisation</value>
</data>
<data name="ToSStrings_ReadLabel" xml:space="preserve">
<value>LISEZ CES INFORMATIONS ATTENTIVEMENT</value>
</data>
<data name="ToSStrings_Paragraph1" xml:space="preserve">
<value>Tous les fichiers moddés actuellement en cours d'utilisation ainsi que le statut actuel de votre personnage vont être mix en ligne via le service sur lequel vous vous êtes automatiquement enregistré. Seuls les fichiers nécessaires seront téléversés par le plugin et non pas le mod en entier.</value>
</data>
<data name="ToSStrings_Paragraph2" xml:space="preserve">
<value>Si le débit de votre connexion internet est limité, le téléchargement et téléversement d'un grand nombre de fichiers peut entraîner des coûts supplémentaires. Les fichiers seront compressés au chargement et versement pour réduire l'impact sur votre bande passants. Selon la rapidité de vos téléchargements et téléversements, les changements ne seront peut-être pas visibles instantanément sur les personnages. Les fichiers déja présents sur le service qui correspondent à ceux de vos mods en cours d'utilisation ne seront pas remis en ligne.</value>
</data>
<data name="ToSStrings_Paragraph3" xml:space="preserve">
<value>Les fichiers que vous allez partager sont confidentiels et ne seront envoyés qu'aux utilisateurs qui feront une requête exacte de ceux-çi. Nous vous demandons de (re)considérer qui sera synchronisé avec vous, puisqu'ils recevront et stockeront inévitablement en local les fichiers nécéssaires utilisés à cet instant. Les noms des fichiers stockés localement sont changés de manière arbitraire afin de décourager toute tentative de réplication des originaux.</value>
</data>
<data name="ToSStrings_Paragraph4" xml:space="preserve">
<value>Le créateur de ce plugin a tenté de sécuriser l'application du mieux possible. Cependant, il ne peut pas garantir une protection 100% infaillible. Pour votre sécurité, ne vous synchronisez pas aveuglément et avec n'importe qui.</value>
</data>
<data name="ToSStrings_Paragraph5" xml:space="preserve">
<value>Les fichiers sauvegardés sur le service resteront en ligne tant que des utilisateurs en feront usage. Ils seront effacés automatiquement après une certaine période d'inactivité. Vous pouvez également demander l'effacement de tous les fichiers que vous avez mis en ligne vous-même. Le service en soi ne contient aucune information pouvant identifier quel fichier appartient à quel mod.</value>
</data>
<data name="ToSStrings_Paragraph6" xml:space="preserve">
<value>Ce service et ses composants vous sont fournis en l'état. En cas d'abus rejoindre le serveur Discord Lightless Sync.</value>
</data>
<data name="ToSStrings_AgreeLabel" xml:space="preserve">
<value>J'accept</value>
</data>
<data name="ToSStrings_ButtonWillBeAvailableIn" xml:space="preserve">
<value>Bouton "J'accept" disposible dans</value>
</data>
</root>

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ToSStrings_AgreeLabel" xml:space="preserve">
<value>I agree</value>
</data>
<data name="ToSStrings_AgreementLabel" xml:space="preserve">
<value>Agreement of Usage of Service</value>
</data>
<data name="ToSStrings_ButtonWillBeAvailableIn" xml:space="preserve">
<value>'I agree' button will be available in</value>
</data>
<data name="ToSStrings_Paragraph1" xml:space="preserve">
<value>All of the mod files currently active on your character as well as your current character state will be uploaded to the service you registered yourself at automatically. The plugin will exclusively upload the necessary mod files and not the whole mod.</value>
</data>
<data name="ToSStrings_Paragraph2" xml:space="preserve">
<value>If you are on a data capped internet connection, higher fees due to data usage depending on the amount of downloaded and uploaded mod files might occur. Mod files will be compressed on up- and download to save on bandwidth usage. Due to varying up- and download speeds, changes in characters might not be visible immediately. Files present on the service that already represent your active mod files will not be uploaded again.</value>
</data>
<data name="ToSStrings_Paragraph3" xml:space="preserve">
<value>The mod files you are uploading are confidential and will not be distributed to parties other than the ones who are requesting the exact same mod files. Please think about who you are going to pair since it is unavoidable that they will receive and locally cache the necessary mod files that you have currently in use. Locally cached mod files will have arbitrary file names to discourage attempts at replicating the original mod.</value>
</data>
<data name="ToSStrings_Paragraph4" xml:space="preserve">
<value>The plugin creator tried their best to keep you secure. However, there is no guarantee for 100% security. Do not blindly pair your client with everyone.</value>
</data>
<data name="ToSStrings_Paragraph5" xml:space="preserve">
<value>Mod files that are saved on the service will remain on the service as long as there are requests for the files from clients. After a period of not being used, the mod files will be automatically deleted. You will also be able to wipe all the files you have personally uploaded on request. The service holds no information about which mod files belong to which mod.</value>
</data>
<data name="ToSStrings_Paragraph6" xml:space="preserve">
<value>This service is provided as-is. In case of abuse join the Lightless Sync Discord.</value>
</data>
<data name="ToSStrings_ReadLabel" xml:space="preserve">
<value>READ THIS CAREFULLY</value>
</data>
<data name="ToSStrings_LanguageLabel" xml:space="preserve">
<value>Language</value>
</data>
<data name="Users_Online" xml:space="preserve">
<value>Users Online</value>
</data>
</root>

View File

@@ -0,0 +1,20 @@
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ToSStrings_LanguageLabel" xml:space="preserve">
<value>语言</value>
</data>
<data name="Users_Online" xml:space="preserve">
<value>用户在线</value>
</data>
</root>

View File

@@ -6,6 +6,7 @@ using FFXIVClientStructs.Interop;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -13,10 +14,11 @@ using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSu
using IPlayerCharacter = Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
using System.Runtime.InteropServices;
namespace LightlessSync.Services.ActorTracking;
public sealed class ActorObjectService : IHostedService, IDisposable
public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorSubscriber
{
public readonly record struct ActorDescriptor(
string Name,
@@ -36,6 +38,8 @@ public sealed class ActorObjectService : IHostedService, IDisposable
private readonly IClientState _clientState;
private readonly ICondition _condition;
private readonly LightlessMediator _mediator;
private readonly object _playerRelatedHandlerLock = new();
private readonly HashSet<GameObjectHandler> _playerRelatedHandlers = [];
private readonly ConcurrentDictionary<nint, ActorDescriptor> _activePlayers = new();
private readonly ConcurrentDictionary<nint, ActorDescriptor> _gposePlayers = new();
@@ -54,6 +58,8 @@ public sealed class ActorObjectService : IHostedService, IDisposable
private bool _hooksActive;
private static readonly TimeSpan SnapshotRefreshInterval = TimeSpan.FromSeconds(1);
private DateTime _nextRefreshAllowed = DateTime.MinValue;
private int _warmStartQueued;
private int _warmStartRan;
public ActorObjectService(
ILogger<ActorObjectService> logger,
@@ -71,10 +77,27 @@ public sealed class ActorObjectService : IHostedService, IDisposable
_clientState = clientState;
_condition = condition;
_mediator = mediator;
_mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
{
if (!msg.OwnedObject) return;
lock (_playerRelatedHandlerLock)
{
_playerRelatedHandlers.Add(msg.GameObjectHandler);
}
RefreshTrackedActors(force: true);
});
_mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, (msg) =>
{
if (!msg.OwnedObject) return;
lock (_playerRelatedHandlerLock)
{
_playerRelatedHandlers.Remove(msg.GameObjectHandler);
}
RefreshTrackedActors(force: true);
});
}
private bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51];
private ActorSnapshot Snapshot => Volatile.Read(ref _snapshot);
private GposeSnapshot CurrentGposeSnapshot => Volatile.Read(ref _gposeSnapshot);
@@ -84,6 +107,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable
public IReadOnlyList<ActorDescriptor> PlayerDescriptors => Snapshot.PlayerDescriptors;
public IReadOnlyList<ActorDescriptor> OwnedDescriptors => Snapshot.OwnedDescriptors;
public IReadOnlyList<ActorDescriptor> GposeDescriptors => CurrentGposeSnapshot.GposeDescriptors;
public LightlessMediator Mediator => _mediator;
public bool TryGetActorByHash(string hash, out ActorDescriptor descriptor) => _actorsByHash.TryGetValue(hash, out descriptor);
public bool TryGetValidatedActorByHash(string hash, out ActorDescriptor descriptor)
@@ -213,18 +237,25 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return false;
}
public async Task WaitForFullyLoadedAsync(nint address, CancellationToken cancellationToken = default)
public async Task<bool> WaitForFullyLoadedAsync(nint address, CancellationToken cancellationToken = default, int timeOutMs = 30000)
{
if (address == nint.Zero)
throw new ArgumentException("Address cannot be zero.", nameof(address));
var timeoutAt = timeOutMs > 0 ? Environment.TickCount64 + timeOutMs : long.MaxValue;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var isLoaded = await _framework.RunOnFrameworkThread(() => IsObjectFullyLoaded(address)).ConfigureAwait(false);
if (!IsZoning && isLoaded)
return;
var loadState = await _framework.RunOnFrameworkThread(() => GetObjectLoadState(address)).ConfigureAwait(false);
if (!loadState.IsValid)
return false;
if (!IsZoning && loadState.IsLoaded)
return true;
if (Environment.TickCount64 >= timeoutAt)
return false;
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
@@ -311,12 +342,19 @@ public sealed class ActorObjectService : IHostedService, IDisposable
public Task StopAsync(CancellationToken cancellationToken)
{
_warmStartRan = 0;
DisposeHooks();
_activePlayers.Clear();
_gposePlayers.Clear();
_actorsByHash.Clear();
_actorsByName.Clear();
_pendingHashResolutions.Clear();
_mediator.UnsubscribeAll(this);
lock (_playerRelatedHandlerLock)
{
_playerRelatedHandlers.Clear();
}
Volatile.Write(ref _snapshot, ActorSnapshot.Empty);
Volatile.Write(ref _gposeSnapshot, GposeSnapshot.Empty);
return Task.CompletedTask;
@@ -470,7 +508,10 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return new ActorDescriptor(name, hashedCid, address, objectIndex, isLocal, isInGpose, objectKind, ownedKind, ownerEntityId);
}
private unsafe (LightlessObjectKind? OwnedKind, uint OwnerEntityId) DetermineOwnedKind(GameObject* gameObject, DalamudObjectKind objectKind, bool isLocalPlayer)
private unsafe (LightlessObjectKind? OwnedKind, uint OwnerEntityId) DetermineOwnedKind(
GameObject* gameObject,
DalamudObjectKind objectKind,
bool isLocalPlayer)
{
if (gameObject == null)
return (null, 0);
@@ -482,6 +523,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable
}
var ownerId = ResolveOwnerId(gameObject);
var localPlayerAddress = _objectTable.LocalPlayer?.Address ?? nint.Zero;
if (localPlayerAddress == nint.Zero)
return (null, ownerId);
@@ -503,40 +545,61 @@ public sealed class ActorObjectService : IHostedService, IDisposable
if (objectKind != DalamudObjectKind.BattleNpc)
return (null, ownerId);
if (ownerId != localEntityId)
if (ownerId != 0 && ownerId != localEntityId)
return (null, ownerId);
var expectedPet = GetPetAddress(localPlayerAddress, localEntityId);
if (expectedPet != nint.Zero && (nint)gameObject == expectedPet)
return (LightlessObjectKind.Pet, ownerId);
return (LightlessObjectKind.Pet, ownerId != 0 ? ownerId : localEntityId);
var expectedCompanion = GetCompanionAddress(localPlayerAddress, localEntityId);
if (expectedCompanion != nint.Zero && (nint)gameObject == expectedCompanion)
return (LightlessObjectKind.Companion, ownerId);
return (LightlessObjectKind.Companion, ownerId != 0 ? ownerId : localEntityId);
return (null, ownerId);
}
private bool IsPlayerRelatedOwnedAddress(nint address, LightlessObjectKind expectedKind)
{
if (address == nint.Zero)
return false;
lock (_playerRelatedHandlerLock)
{
foreach (var handler in _playerRelatedHandlers)
{
if (handler.Address == address && handler.ObjectKind == expectedKind)
return true;
}
}
return false;
}
private unsafe nint GetMinionOrMountAddress(nint localPlayerAddress, uint ownerEntityId)
{
if (localPlayerAddress == nint.Zero)
return nint.Zero;
var playerObject = (GameObject*)localPlayerAddress;
var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1);
if (candidateAddress != nint.Zero)
{
var candidate = (GameObject*)candidateAddress;
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
{
if (ownerEntityId == 0 || ResolveOwnerId(candidate) == ownerEntityId)
var resolvedOwner = ResolveOwnerId(candidate);
if (resolvedOwner == ownerEntityId || resolvedOwner == 0)
return candidateAddress;
}
}
if (ownerEntityId == 0)
return candidateAddress;
return nint.Zero;
foreach (var obj in _objectTable)
{
@@ -551,7 +614,91 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return obj.Address;
}
return candidateAddress;
return nint.Zero;
}
public unsafe bool TryFindOwnedObject(uint ownerEntityId, LightlessObjectKind kind, out nint address)
{
address = nint.Zero;
if (ownerEntityId == 0) return false;
foreach (var addr in EnumerateActiveCharacterAddresses())
{
if (addr == nint.Zero) continue;
var go = (GameObject*)addr;
var ok = (DalamudObjectKind)go->ObjectKind;
switch (kind)
{
case LightlessObjectKind.MinionOrMount:
if (ok is DalamudObjectKind.MountType or DalamudObjectKind.Companion
&& ResolveOwnerId(go) == ownerEntityId)
{
address = addr;
return true;
}
break;
case LightlessObjectKind.Pet:
if (ok == DalamudObjectKind.BattleNpc
&& go->BattleNpcSubKind == BattleNpcSubKind.Pet
&& ResolveOwnerId(go) == ownerEntityId)
{
address = addr;
return true;
}
break;
case LightlessObjectKind.Companion:
if (ok == DalamudObjectKind.BattleNpc
&& go->BattleNpcSubKind == BattleNpcSubKind.Buddy
&& ResolveOwnerId(go) == ownerEntityId)
{
address = addr;
return true;
}
break;
}
}
return false;
}
public unsafe IReadOnlyList<nint> GetMinionOrMountCandidates(uint ownerEntityId, ushort preferredPlayerIndex)
{
var results = new List<(nint Ptr, int Score)>(4);
var manager = GameObjectManager.Instance();
if (manager == null || ownerEntityId == 0)
return Array.Empty<nint>();
const int objectLimit = 200;
for (var i = 0; i < objectLimit; i++)
{
var obj = manager->Objects.IndexSorted[i].Value;
if (obj == null)
continue;
var kind = (DalamudObjectKind)obj->ObjectKind;
if (kind is not (DalamudObjectKind.MountType or DalamudObjectKind.Companion))
continue;
var owner = ResolveOwnerId(obj);
if (owner != ownerEntityId)
continue;
var idx = obj->ObjectIndex;
var score = Math.Abs(idx - (preferredPlayerIndex + 1));
if (obj->DrawObject == null) score += 50;
results.Add(((nint)obj, score));
}
return results
.OrderBy(r => r.Score)
.Select(r => r.Ptr)
.ToArray();
}
private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId)
@@ -1022,6 +1169,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable
public void Dispose()
{
DisposeHooks();
_mediator.UnsubscribeAll(this);
GC.SuppressFinalize(this);
}
@@ -1143,23 +1291,33 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return results;
}
private static unsafe bool IsObjectFullyLoaded(nint address)
private LoadState GetObjectLoadState(nint address)
{
if (address == nint.Zero)
return false;
return LoadState.Invalid;
var obj = _objectTable.CreateObjectReference(address);
if (obj is null || obj.Address != address)
return LoadState.Invalid;
return new LoadState(true, IsObjectFullyLoaded(address));
}
private static unsafe bool IsObjectFullyLoaded(nint address)
{
if (address == nint.Zero) return false;
var gameObject = (GameObject*)address;
if (gameObject == null)
return false;
if (gameObject == null) return false;
var drawObject = gameObject->DrawObject;
if (drawObject == null)
return false;
if (drawObject == null) return false;
if ((ulong)gameObject->RenderFlags == 2048)
return false;
var characterBase = (CharacterBase*)drawObject;
if (characterBase->HasModelInSlotLoaded != 0)
return false;
@@ -1169,6 +1327,12 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return true;
}
[StructLayout(LayoutKind.Auto)]
private readonly record struct LoadState(bool IsValid, bool IsLoaded)
{
public static LoadState Invalid => new(false, false);
}
private sealed record OwnedObjectSnapshot(
IReadOnlyList<nint> RenderedPlayers,
IReadOnlyList<nint> RenderedCompanions,

View File

@@ -28,7 +28,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
{
_baseAnalysisCts = _baseAnalysisCts.CancelRecreate();
var token = _baseAnalysisCts.Token;
_ = BaseAnalysis(msg.CharacterData, token);
_ = Task.Run(async () => await BaseAnalysis(msg.CharacterData, token).ConfigureAwait(false), token);
});
_fileCacheManager = fileCacheManager;
_xivDataAnalyzer = modelAnalyzer;

View File

@@ -1,29 +1,41 @@
using Dalamud.Interface.Textures.TextureWraps;
using LightlessSync.LightlessConfiguration;
using LightlessSync.UI;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text.Json;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Gif;
using SixLabors.ImageSharp.Formats.Webp;
using SixLabors.ImageSharp.Metadata;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace LightlessSync.Services.Chat;
public sealed class ChatEmoteService : IDisposable
{
private const string GlobalEmoteSetUrl = "https://7tv.io/v3/emote-sets/global";
private const int DefaultFrameDelayMs = 100;
private const int MinFrameDelayMs = 20;
private readonly ILogger<ChatEmoteService> _logger;
private readonly HttpClient _httpClient;
private readonly UiSharedService _uiSharedService;
private readonly ChatConfigService _chatConfigService;
private readonly ConcurrentDictionary<string, EmoteEntry> _emotes = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _downloadGate = new(3, 3);
private readonly object _loadLock = new();
private Task? _loadTask;
public ChatEmoteService(ILogger<ChatEmoteService> logger, HttpClient httpClient, UiSharedService uiSharedService)
public ChatEmoteService(ILogger<ChatEmoteService> logger, HttpClient httpClient, UiSharedService uiSharedService, ChatConfigService chatConfigService)
{
_logger = logger;
_httpClient = httpClient;
_uiSharedService = uiSharedService;
_chatConfigService = chatConfigService;
}
public void EnsureGlobalEmotesLoaded()
@@ -62,13 +74,17 @@ public sealed class ChatEmoteService : IDisposable
return false;
}
if (entry.Texture is not null)
var allowAnimation = _chatConfigService.Current.EnableAnimatedEmotes;
if (entry.TryGetTexture(allowAnimation, out texture))
{
texture = entry.Texture;
if (allowAnimation && entry.NeedsAnimationLoad && !entry.HasAttemptedAnimation)
{
entry.EnsureLoading(allowAnimation, QueueEmoteDownload, allowWhenStaticLoaded: true);
}
return true;
}
entry.EnsureLoading(QueueEmoteDownload);
entry.EnsureLoading(allowAnimation, QueueEmoteDownload);
return true;
}
@@ -76,7 +92,7 @@ public sealed class ChatEmoteService : IDisposable
{
foreach (var entry in _emotes.Values)
{
entry.Texture?.Dispose();
entry.Dispose();
}
_downloadGate.Dispose();
@@ -108,13 +124,13 @@ public sealed class ChatEmoteService : IDisposable
continue;
}
var url = TryBuildEmoteUrl(emoteElement);
if (string.IsNullOrWhiteSpace(url))
var source = TryBuildEmoteSource(emoteElement);
if (source is null || (!source.Value.HasStatic && !source.Value.HasAnimation))
{
continue;
}
_emotes.TryAdd(name, new EmoteEntry(url));
_emotes.TryAdd(name, new EmoteEntry(name, source.Value));
}
}
catch (Exception ex)
@@ -123,7 +139,7 @@ public sealed class ChatEmoteService : IDisposable
}
}
private static string? TryBuildEmoteUrl(JsonElement emoteElement)
private static EmoteSource? TryBuildEmoteSource(JsonElement emoteElement)
{
if (!emoteElement.TryGetProperty("data", out var dataElement))
{
@@ -156,29 +172,38 @@ public sealed class ChatEmoteService : IDisposable
return null;
}
var fileName = PickBestStaticFile(filesElement);
if (string.IsNullOrWhiteSpace(fileName))
var files = ReadEmoteFiles(filesElement);
if (files.Count == 0)
{
return null;
}
return baseUrl.TrimEnd('/') + "/" + fileName;
var animatedFile = PickBestAnimatedFile(files);
var animatedUrl = animatedFile is null ? null : BuildEmoteUrl(baseUrl, animatedFile.Value.Name);
var staticName = animatedFile?.StaticName;
if (string.IsNullOrWhiteSpace(staticName))
{
staticName = PickBestStaticFileName(files);
}
var staticUrl = string.IsNullOrWhiteSpace(staticName) ? null : BuildEmoteUrl(baseUrl, staticName);
if (string.IsNullOrWhiteSpace(animatedUrl) && string.IsNullOrWhiteSpace(staticUrl))
{
return null;
}
return new EmoteSource(staticUrl, animatedUrl);
}
private static string? PickBestStaticFile(JsonElement filesElement)
{
string? png1x = null;
string? webp1x = null;
string? pngFallback = null;
string? webpFallback = null;
private static string BuildEmoteUrl(string baseUrl, string fileName)
=> baseUrl.TrimEnd('/') + "/" + fileName;
private static List<EmoteFile> ReadEmoteFiles(JsonElement filesElement)
{
var files = new List<EmoteFile>();
foreach (var file in filesElement.EnumerateArray())
{
if (file.TryGetProperty("static", out var staticElement) && staticElement.ValueKind == JsonValueKind.False)
{
continue;
}
if (!file.TryGetProperty("name", out var nameElement))
{
continue;
@@ -190,6 +215,88 @@ public sealed class ChatEmoteService : IDisposable
continue;
}
string? staticName = null;
if (file.TryGetProperty("static_name", out var staticNameElement) && staticNameElement.ValueKind == JsonValueKind.String)
{
staticName = staticNameElement.GetString();
}
var frameCount = 1;
if (file.TryGetProperty("frame_count", out var frameCountElement) && frameCountElement.ValueKind == JsonValueKind.Number)
{
frameCountElement.TryGetInt32(out frameCount);
frameCount = Math.Max(frameCount, 1);
}
string? format = null;
if (file.TryGetProperty("format", out var formatElement) && formatElement.ValueKind == JsonValueKind.String)
{
format = formatElement.GetString();
}
files.Add(new EmoteFile(name, staticName, frameCount, format));
}
return files;
}
private static EmoteFile? PickBestAnimatedFile(IReadOnlyList<EmoteFile> files)
{
EmoteFile? webp1x = null;
EmoteFile? gif1x = null;
EmoteFile? webpFallback = null;
EmoteFile? gifFallback = null;
foreach (var file in files)
{
if (file.FrameCount <= 1 || !IsAnimatedFormatSupported(file))
{
continue;
}
if (file.Name.Equals("1x.webp", StringComparison.OrdinalIgnoreCase))
{
webp1x = file;
}
else if (file.Name.Equals("1x.gif", StringComparison.OrdinalIgnoreCase))
{
gif1x = file;
}
else if (file.Name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) && webpFallback is null)
{
webpFallback = file;
}
else if (file.Name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) && gifFallback is null)
{
gifFallback = file;
}
}
return webp1x ?? gif1x ?? webpFallback ?? gifFallback;
}
private static string? PickBestStaticFileName(IReadOnlyList<EmoteFile> files)
{
string? png1x = null;
string? webp1x = null;
string? gif1x = null;
string? pngFallback = null;
string? webpFallback = null;
string? gifFallback = null;
foreach (var file in files)
{
if (file.FrameCount > 1)
{
continue;
}
var name = file.StaticName ?? file.Name;
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
if (name.Equals("1x.png", StringComparison.OrdinalIgnoreCase))
{
png1x = name;
@@ -198,6 +305,10 @@ public sealed class ChatEmoteService : IDisposable
{
webp1x = name;
}
else if (name.Equals("1x.gif", StringComparison.OrdinalIgnoreCase))
{
gif1x = name;
}
else if (name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && pngFallback is null)
{
pngFallback = name;
@@ -206,25 +317,80 @@ public sealed class ChatEmoteService : IDisposable
{
webpFallback = name;
}
else if (name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) && gifFallback is null)
{
gifFallback = name;
}
}
return png1x ?? webp1x ?? pngFallback ?? webpFallback;
return png1x ?? webp1x ?? gif1x ?? pngFallback ?? webpFallback ?? gifFallback;
}
private void QueueEmoteDownload(EmoteEntry entry)
private static bool IsAnimatedFormatSupported(EmoteFile file)
{
if (!string.IsNullOrWhiteSpace(file.Format))
{
return file.Format.Equals("WEBP", StringComparison.OrdinalIgnoreCase)
|| file.Format.Equals("GIF", StringComparison.OrdinalIgnoreCase);
}
return file.Name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase)
|| file.Name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase);
}
private readonly record struct EmoteSource(string? StaticUrl, string? AnimatedUrl)
{
public bool HasStatic => !string.IsNullOrWhiteSpace(StaticUrl);
public bool HasAnimation => !string.IsNullOrWhiteSpace(AnimatedUrl);
}
private readonly record struct EmoteFile(string Name, string? StaticName, int FrameCount, string? Format);
private void QueueEmoteDownload(EmoteEntry entry, bool allowAnimation)
{
_ = Task.Run(async () =>
{
await _downloadGate.WaitAsync().ConfigureAwait(false);
try
{
var data = await _httpClient.GetByteArrayAsync(entry.Url).ConfigureAwait(false);
var texture = _uiSharedService.LoadImage(data);
entry.SetTexture(texture);
if (allowAnimation)
{
if (entry.HasAnimatedSource)
{
entry.MarkAnimationAttempted();
if (await TryLoadAnimatedEmoteAsync(entry).ConfigureAwait(false))
{
return;
}
}
if (entry.HasStaticSource && !entry.HasStaticTexture && await TryLoadStaticEmoteAsync(entry).ConfigureAwait(false))
{
return;
}
}
else
{
if (entry.HasStaticSource && await TryLoadStaticEmoteAsync(entry).ConfigureAwait(false))
{
return;
}
if (entry.HasAnimatedSource)
{
entry.MarkAnimationAttempted();
if (await TryLoadAnimatedEmoteAsync(entry).ConfigureAwait(false))
{
return;
}
}
}
entry.MarkFailed();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to load 7TV emote {Url}", entry.Url);
_logger.LogDebug(ex, "Failed to load 7TV emote {Emote}", entry.Code);
entry.MarkFailed();
}
finally
@@ -234,21 +400,334 @@ public sealed class ChatEmoteService : IDisposable
});
}
private sealed class EmoteEntry
private async Task<bool> TryLoadAnimatedEmoteAsync(EmoteEntry entry)
{
private int _loadingState;
public EmoteEntry(string url)
if (string.IsNullOrWhiteSpace(entry.AnimatedUrl))
{
Url = url;
return false;
}
public string Url { get; }
public IDalamudTextureWrap? Texture { get; private set; }
public void EnsureLoading(Action<EmoteEntry> queueDownload)
try
{
if (Texture is not null)
var data = await _httpClient.GetByteArrayAsync(entry.AnimatedUrl).ConfigureAwait(false);
var isWebp = entry.AnimatedUrl.EndsWith(".webp", StringComparison.OrdinalIgnoreCase);
if (!TryDecodeAnimation(data, isWebp, out var animation))
{
return false;
}
entry.SetAnimation(animation);
return true;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to decode animated 7TV emote {Emote}", entry.Code);
return false;
}
}
private async Task<bool> TryLoadStaticEmoteAsync(EmoteEntry entry)
{
if (string.IsNullOrWhiteSpace(entry.StaticUrl))
{
return false;
}
try
{
var data = await _httpClient.GetByteArrayAsync(entry.StaticUrl).ConfigureAwait(false);
var texture = _uiSharedService.LoadImage(data);
entry.SetStaticTexture(texture);
return true;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to decode static 7TV emote {Emote}", entry.Code);
return false;
}
}
private bool TryDecodeAnimation(byte[] data, bool isWebp, out EmoteAnimation? animation)
{
animation = null;
List<EmoteFrame>? frames = null;
try
{
Image<Rgba32> image;
if (isWebp)
{
using var stream = new MemoryStream(data);
image = WebpDecoder.Instance.Decode<Rgba32>(
new WebpDecoderOptions { BackgroundColorHandling = BackgroundColorHandling.Ignore },
stream);
}
else
{
image = Image.Load<Rgba32>(data);
}
using (image)
{
if (image.Frames.Count <= 1)
{
return false;
}
using var composite = new Image<Rgba32>(image.Width, image.Height, Color.Transparent);
Image<Rgba32>? restoreCanvas = null;
GifDisposalMethod? pendingGifDisposal = null;
WebpDisposalMethod? pendingWebpDisposal = null;
frames = new List<EmoteFrame>(image.Frames.Count);
for (var i = 0; i < image.Frames.Count; i++)
{
var frameMetadata = image.Frames[i].Metadata;
var delayMs = GetFrameDelayMs(frameMetadata);
ApplyDisposal(composite, ref restoreCanvas, pendingGifDisposal, pendingWebpDisposal);
GifDisposalMethod? currentGifDisposal = null;
WebpDisposalMethod? currentWebpDisposal = null;
var blendMethod = WebpBlendMethod.Over;
if (isWebp)
{
if (frameMetadata.TryGetWebpFrameMetadata(out var webpMetadata))
{
currentWebpDisposal = webpMetadata.DisposalMethod;
blendMethod = webpMetadata.BlendMethod;
}
}
else if (frameMetadata.TryGetGifMetadata(out var gifMetadata))
{
currentGifDisposal = gifMetadata.DisposalMethod;
}
if (currentGifDisposal == GifDisposalMethod.RestoreToPrevious)
{
restoreCanvas?.Dispose();
restoreCanvas = composite.Clone();
}
using var frameImage = image.Frames.CloneFrame(i);
var alphaMode = blendMethod == WebpBlendMethod.Source
? PixelAlphaCompositionMode.Src
: PixelAlphaCompositionMode.SrcOver;
composite.Mutate(ctx => ctx.DrawImage(frameImage, PixelColorBlendingMode.Normal, alphaMode, 1f));
using var renderedFrame = composite.Clone();
using var ms = new MemoryStream();
renderedFrame.SaveAsPng(ms);
var texture = _uiSharedService.LoadImage(ms.ToArray());
frames.Add(new EmoteFrame(texture, delayMs));
pendingGifDisposal = currentGifDisposal;
pendingWebpDisposal = currentWebpDisposal;
}
restoreCanvas?.Dispose();
animation = new EmoteAnimation(frames);
return true;
}
}
catch
{
if (frames is not null)
{
foreach (var frame in frames)
{
frame.Texture.Dispose();
}
}
return false;
}
}
private static int GetFrameDelayMs(ImageFrameMetadata metadata)
{
if (metadata.TryGetGifMetadata(out var gifMetadata))
{
var delayMs = (long)gifMetadata.FrameDelay * 10L;
return NormalizeFrameDelayMs(delayMs);
}
if (metadata.TryGetWebpFrameMetadata(out var webpMetadata))
{
return NormalizeFrameDelayMs(webpMetadata.FrameDelay);
}
return DefaultFrameDelayMs;
}
private static int NormalizeFrameDelayMs(long delayMs)
{
if (delayMs <= 0)
{
return DefaultFrameDelayMs;
}
var clamped = delayMs > int.MaxValue ? int.MaxValue : (int)delayMs;
return Math.Max(clamped, MinFrameDelayMs);
}
private static void ApplyDisposal(
Image<Rgba32> composite,
ref Image<Rgba32>? restoreCanvas,
GifDisposalMethod? gifDisposal,
WebpDisposalMethod? webpDisposal)
{
if (gifDisposal is not null)
{
switch (gifDisposal)
{
case GifDisposalMethod.RestoreToBackground:
composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent));
break;
case GifDisposalMethod.RestoreToPrevious:
if (restoreCanvas is not null)
{
composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent));
var restoreSnapshot = restoreCanvas;
composite.Mutate(ctx => ctx.DrawImage(restoreSnapshot, PixelColorBlendingMode.Normal, PixelAlphaCompositionMode.Src, 1f));
restoreCanvas.Dispose();
restoreCanvas = null;
}
break;
}
}
else if (webpDisposal == WebpDisposalMethod.RestoreToBackground)
{
composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent));
}
}
private sealed class EmoteAnimation : IDisposable
{
private readonly EmoteFrame[] _frames;
private readonly int _durationMs;
private readonly long _startTimestamp;
public EmoteAnimation(IReadOnlyList<EmoteFrame> frames)
{
_frames = frames.ToArray();
_durationMs = Math.Max(1, frames.Sum(frame => frame.DurationMs));
_startTimestamp = Stopwatch.GetTimestamp();
}
public IDalamudTextureWrap? GetCurrentFrame()
{
if (_frames.Length == 0)
{
return null;
}
if (_frames.Length == 1)
{
return _frames[0].Texture;
}
var elapsedTicks = Stopwatch.GetTimestamp() - _startTimestamp;
var elapsedMs = (elapsedTicks * 1000L) / Stopwatch.Frequency;
var targetMs = (int)(elapsedMs % _durationMs);
var accumulated = 0;
foreach (var frame in _frames)
{
accumulated += frame.DurationMs;
if (targetMs < accumulated)
{
return frame.Texture;
}
}
return _frames[^1].Texture;
}
public IDalamudTextureWrap? GetStaticFrame()
{
if (_frames.Length == 0)
{
return null;
}
return _frames[0].Texture;
}
public void Dispose()
{
foreach (var frame in _frames)
{
frame.Texture.Dispose();
}
}
}
private readonly record struct EmoteFrame(IDalamudTextureWrap Texture, int DurationMs);
private sealed class EmoteEntry : IDisposable
{
private int _loadingState;
private int _animationAttempted;
private IDalamudTextureWrap? _staticTexture;
private EmoteAnimation? _animation;
public EmoteEntry(string code, EmoteSource source)
{
Code = code;
StaticUrl = source.StaticUrl;
AnimatedUrl = source.AnimatedUrl;
}
public string Code { get; }
public string? StaticUrl { get; }
public string? AnimatedUrl { get; }
public bool HasStaticSource => !string.IsNullOrWhiteSpace(StaticUrl);
public bool HasAnimatedSource => !string.IsNullOrWhiteSpace(AnimatedUrl);
public bool HasStaticTexture => _staticTexture is not null;
public bool HasAttemptedAnimation => Interlocked.CompareExchange(ref _animationAttempted, 0, 0) != 0;
public bool NeedsAnimationLoad => _animation is null && HasAnimatedSource;
public void MarkAnimationAttempted()
{
Interlocked.Exchange(ref _animationAttempted, 1);
}
public bool TryGetTexture(bool allowAnimation, out IDalamudTextureWrap? texture)
{
if (allowAnimation && _animation is not null)
{
texture = _animation.GetCurrentFrame();
return true;
}
if (_staticTexture is not null)
{
texture = _staticTexture;
return true;
}
if (!allowAnimation && _animation is not null)
{
texture = _animation.GetStaticFrame();
return true;
}
texture = null;
return false;
}
public void EnsureLoading(bool allowAnimation, Action<EmoteEntry, bool> queueDownload, bool allowWhenStaticLoaded = false)
{
if (_animation is not null)
{
return;
}
if (!allowWhenStaticLoaded && _staticTexture is not null)
{
return;
}
@@ -258,12 +737,22 @@ public sealed class ChatEmoteService : IDisposable
return;
}
queueDownload(this);
queueDownload(this, allowAnimation);
}
public void SetTexture(IDalamudTextureWrap texture)
public void SetAnimation(EmoteAnimation animation)
{
Texture = texture;
_staticTexture?.Dispose();
_staticTexture = null;
_animation?.Dispose();
_animation = animation;
Interlocked.Exchange(ref _loadingState, 0);
}
public void SetStaticTexture(IDalamudTextureWrap texture)
{
_staticTexture?.Dispose();
_staticTexture = texture;
Interlocked.Exchange(ref _loadingState, 0);
}
@@ -271,5 +760,11 @@ public sealed class ChatEmoteService : IDisposable
{
Interlocked.Exchange(ref _loadingState, 0);
}
public void Dispose()
{
_animation?.Dispose();
_staticTexture?.Dispose();
}
}
}

View File

@@ -10,7 +10,6 @@ using LightlessSync.UI;
using LightlessSync.UI.Services;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using Lumina.Excel.Sheets;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -173,8 +172,7 @@ internal class ContextMenuService : IHostedService
return;
}
var world = GetWorld(target.TargetHomeWorld.RowId);
if (!IsWorldValid(world))
if (!IsWorldValid(target.TargetHomeWorld.RowId))
{
_logger.LogTrace("Target player {TargetName}@{World} is on an invalid world.", target.TargetName, target.TargetHomeWorld.RowId);
return;
@@ -227,8 +225,7 @@ internal class ContextMenuService : IHostedService
if (args.Target is not MenuTargetDefault target)
return;
var world = GetWorld(target.TargetHomeWorld.RowId);
if (!IsWorldValid(world))
if (!target.TargetHomeWorld.IsValid || !IsWorldValid(target.TargetHomeWorld.RowId))
return;
try
@@ -237,7 +234,7 @@ internal class ContextMenuService : IHostedService
if (targetData == null || targetData.Address == nint.Zero)
{
_logger.LogWarning("Target player {TargetName}@{World} not found in object table.", target.TargetName, world.Name);
_logger.LogWarning("Target player {TargetName}@{World} not found in object table.", target.TargetName, target.TargetHomeWorld.Value.Name);
return;
}
@@ -252,7 +249,7 @@ internal class ContextMenuService : IHostedService
}
// Notify in chat when NotificationService is disabled
NotifyInChat($"Pair request sent to {target.TargetName}@{world.Name}.", NotificationType.Info);
NotifyInChat($"Pair request sent to {target.TargetName}@{target.TargetHomeWorld.Value.Name}.", NotificationType.Info);
}
catch (Exception ex)
{
@@ -312,37 +309,8 @@ internal class ContextMenuService : IHostedService
p.HomeWorld.RowId == target.TargetHomeWorld.RowId);
}
private World GetWorld(uint worldId)
private bool IsWorldValid(uint worldId)
{
var sheet = _gameData.GetExcelSheet<World>()!;
var luminaWorlds = sheet.Where(x =>
{
var dc = x.DataCenter.ValueNullable;
var name = x.Name.ExtractText();
var internalName = x.InternalName.ExtractText();
if (dc == null || dc.Value.Region == 0 || string.IsNullOrWhiteSpace(dc.Value.Name.ExtractText()))
return false;
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(internalName))
return false;
if (name.Contains('-', StringComparison.Ordinal) || name.Contains('_', StringComparison.Ordinal))
return false;
return x.DataCenter.Value.Region != 5 || x.RowId > 3001 && x.RowId != 1200 && IsChineseJapaneseKoreanString(name);
});
return luminaWorlds.FirstOrDefault(x => x.RowId == worldId);
}
private static bool IsChineseJapaneseKoreanString(string text) => text.All(IsChineseJapaneseKoreanCharacter);
private static bool IsChineseJapaneseKoreanCharacter(char c) => c >= 0x4E00 && c <= 0x9FFF;
public static bool IsWorldValid(World world)
{
var name = world.Name.ToString();
return !string.IsNullOrWhiteSpace(name) && char.IsUpper(name[0]);
return _dalamudUtil.WorldData.Value.ContainsKey((ushort)worldId);
}
}

View File

@@ -1,11 +1,13 @@
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Game.Text;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Game.Control;
using FFXIVClientStructs.FFXIV.Client.Game.UI;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using LightlessSync.API.Dto.CharaData;
@@ -20,12 +22,15 @@ using LightlessSync.Utils;
using Lumina.Excel.Sheets;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
using Map = Lumina.Excel.Sheets.Map;
using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags;
namespace LightlessSync.Services;
@@ -57,6 +62,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private string _lastGlobalBlockReason = string.Empty;
private ushort _lastZone = 0;
private ushort _lastWorldId = 0;
private uint _lastMapId = 0;
private bool _sentBetweenAreas = false;
private Lazy<ulong> _cid;
@@ -86,7 +92,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
WorldData = new(() =>
{
return gameData.GetExcelSheet<Lumina.Excel.Sheets.World>(clientLanguage)!
.Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0])))
.Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0])
|| w is { RowId: > 1000, UserType: 101 or 201 }))
.ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString());
});
JobData = new(() =>
@@ -659,7 +666,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
var location = new LocationInfo();
location.ServerId = _playerState.CurrentWorld.RowId;
//location.InstanceId = UIState.Instance()->PublicInstance.InstanceId; //TODO:Need API update first
location.InstanceId = UIState.Instance()->PublicInstance.InstanceId;
location.TerritoryId = _clientState.TerritoryType;
location.MapId = _clientState.MapId;
if (houseMan != null)
@@ -685,7 +692,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
var outside = houseMan->OutdoorTerritory;
var house = outside->HouseId;
location.WardId = house.WardIndex + 1u;
location.HouseId = (uint)houseMan->GetCurrentPlot() + 1;
//location.HouseId = (uint)houseMan->GetCurrentPlot() + 1;
location.DivisionId = houseMan->GetCurrentDivision();
}
//_logger.LogWarning(LocationToString(location));
@@ -698,7 +705,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
if (location.ServerId is 0 || location.TerritoryId is 0) return String.Empty;
var str = WorldData.Value[(ushort)location.ServerId];
if (ContentFinderData.Value.TryGetValue(location.TerritoryId , out var dutyName))
if (ContentFinderData.Value.TryGetValue(location.TerritoryId, out var dutyName))
{
str += $" - [In Duty]{dutyName}";
}
@@ -713,10 +720,10 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
str += $" - {MapData.Value[(ushort)location.MapId].MapName}";
}
// if (location.InstanceId is not 0)
// {
// str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString();
// }
if (location.InstanceId is not 0)
{
str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString();
}
if (location.WardId is not 0)
{
@@ -809,9 +816,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
{
_logger.LogInformation("Starting DalamudUtilService");
_framework.Update += FrameworkOnUpdate;
if (IsLoggedIn)
_clientState.Login += OnClientLogin;
_clientState.Logout += OnClientLogout;
if (_clientState.IsLoggedIn)
{
_classJobId = _objectTable.LocalPlayer!.ClassJob.RowId;
OnClientLogin();
}
_logger.LogInformation("Started DalamudUtilService");
@@ -824,6 +834,9 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
Mediator.UnsubscribeAll(this);
_framework.Update -= FrameworkOnUpdate;
_clientState.Login -= OnClientLogin;
_clientState.Logout -= OnClientLogout;
if (_FocusPairIdent.HasValue)
{
if (_framework.IsInFrameworkUpdateThread)
@@ -838,33 +851,78 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return Task.CompletedTask;
}
public async Task WaitWhileCharacterIsDrawing(ILogger logger, GameObjectHandler handler, Guid redrawId, int timeOut = 5000, CancellationToken? ct = null)
private void OnClientLogin()
{
if (IsLoggedIn)
return;
_ = RunOnFrameworkThread(() =>
{
if (IsLoggedIn)
return;
var localPlayer = _objectTable.LocalPlayer;
IsLoggedIn = true;
_lastZone = _clientState.TerritoryType;
if (localPlayer != null)
{
_lastWorldId = (ushort)localPlayer.CurrentWorld.RowId;
_classJobId = localPlayer.ClassJob.RowId;
}
_cid = RebuildCID();
Mediator.Publish(new DalamudLoginMessage());
});
}
private void OnClientLogout(int type, int code)
{
if (!IsLoggedIn)
return;
_ = RunOnFrameworkThread(() =>
{
if (!IsLoggedIn)
return;
IsLoggedIn = false;
_lastWorldId = 0;
Mediator.Publish(new DalamudLogoutMessage());
});
}
public async Task WaitWhileCharacterIsDrawing(
ILogger logger,
GameObjectHandler handler,
Guid redrawId,
int timeOut = 5000,
CancellationToken? ct = null)
{
if (!_clientState.IsLoggedIn) return;
if (ct == null)
ct = CancellationToken.None;
var token = ct ?? CancellationToken.None;
const int tick = 250;
int curWaitTime = 0;
const int initialSettle = 50;
var sw = Stopwatch.StartNew();
try
{
logger.LogTrace("[{redrawId}] Starting wait for {handler} to draw", redrawId, handler);
await Task.Delay(tick, ct.Value).ConfigureAwait(true);
curWaitTime += tick;
while ((!ct.Value.IsCancellationRequested)
&& curWaitTime < timeOut
&& await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false)) // 0b100000000000 is "still rendering" or something
await Task.Delay(initialSettle, token).ConfigureAwait(false);
while (!token.IsCancellationRequested
&& sw.ElapsedMilliseconds < timeOut
&& await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false))
{
logger.LogTrace("[{redrawId}] Waiting for {handler} to finish drawing", redrawId, handler);
curWaitTime += tick;
await Task.Delay(tick, ct.Value).ConfigureAwait(true);
await Task.Delay(tick, token).ConfigureAwait(false);
}
logger.LogTrace("[{redrawId}] Finished drawing after {curWaitTime}ms", redrawId, curWaitTime);
logger.LogTrace("[{redrawId}] Finished drawing after {ms}ms", redrawId, sw.ElapsedMilliseconds);
}
catch (AccessViolationException ex)
catch (OperationCanceledException)
{
// ignore
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler);
}
@@ -914,37 +972,109 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return WorldData.Value.TryGetValue(worldId, out var worldName) ? worldName : null;
}
public void TargetPlayerByAddress(nint address)
{
if (address == nint.Zero) return;
if (_clientState.IsPvP) return;
_ = RunOnFrameworkThread(() =>
{
var gameObject = CreateGameObject(address);
if (gameObject is null) return;
var useFocusTarget = _configService.Current.UseFocusTarget;
if (useFocusTarget)
{
_targetManager.FocusTarget = gameObject;
}
else
{
_targetManager.Target = gameObject;
}
});
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool IsBadReadPtr(IntPtr ptr, UIntPtr size);
private static bool IsValidPointer(nint ptr, int size = 8)
{
if (ptr == nint.Zero)
return false;
try
{
if (!Util.IsWine())
{
return !IsBadReadPtr(ptr, (UIntPtr)size);
}
return ptr != nint.Zero && (ptr % IntPtr.Size) == 0;
}
catch
{
return false;
}
}
private unsafe void CheckCharacterForDrawing(nint address, string characterName)
{
if (address == nint.Zero)
return;
if (!IsValidPointer(address))
{
_logger.LogDebug("Invalid pointer for character {name} at {addr}", characterName, address.ToString("X"));
return;
}
var gameObj = (GameObject*)address;
if (gameObj == null)
return;
if (!_objectTable.Any(o => o?.Address == address))
{
_logger.LogDebug("Character {name} at {addr} no longer in object table", characterName, address.ToString("X"));
return;
}
if (gameObj->ObjectKind == 0)
return;
var drawObj = gameObj->DrawObject;
bool isDrawing = false;
bool isDrawingChanged = false;
if ((nint)drawObj != IntPtr.Zero)
if ((nint)drawObj != IntPtr.Zero && IsValidPointer((nint)drawObj))
{
isDrawing = gameObj->RenderFlags == (VisibilityFlags)0b100000000000;
if (!isDrawing)
{
isDrawing = ((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0;
if (!isDrawing)
var charBase = (CharacterBase*)drawObj;
if (charBase != null && IsValidPointer((nint)charBase))
{
isDrawing = ((CharacterBase*)drawObj)->HasModelFilesInSlotLoaded != 0;
if (isDrawing && !string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal)
&& !string.Equals(_lastGlobalBlockReason, "HasModelFilesInSlotLoaded", StringComparison.Ordinal))
isDrawing = charBase->HasModelInSlotLoaded != 0;
if (!isDrawing)
{
_lastGlobalBlockPlayer = characterName;
_lastGlobalBlockReason = "HasModelFilesInSlotLoaded";
isDrawingChanged = true;
isDrawing = charBase->HasModelFilesInSlotLoaded != 0;
if (isDrawing && !string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal)
&& !string.Equals(_lastGlobalBlockReason, "HasModelFilesInSlotLoaded", StringComparison.Ordinal))
{
_lastGlobalBlockPlayer = characterName;
_lastGlobalBlockReason = "HasModelFilesInSlotLoaded";
isDrawingChanged = true;
}
}
}
else
{
if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal)
&& !string.Equals(_lastGlobalBlockReason, "HasModelInSlotLoaded", StringComparison.Ordinal))
else
{
_lastGlobalBlockPlayer = characterName;
_lastGlobalBlockReason = "HasModelInSlotLoaded";
isDrawingChanged = true;
if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal)
&& !string.Equals(_lastGlobalBlockReason, "HasModelInSlotLoaded", StringComparison.Ordinal))
{
_lastGlobalBlockPlayer = characterName;
_lastGlobalBlockReason = "HasModelInSlotLoaded";
isDrawingChanged = true;
}
}
}
}
@@ -975,6 +1105,11 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private unsafe void FrameworkOnUpdateInternal()
{
if (!_clientState.IsLoggedIn || _objectTable.LocalPlayer == null)
{
return;
}
if ((_objectTable.LocalPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty])
{
return;
@@ -994,18 +1129,23 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
}
var playerDescriptors = _actorObjectService.PlayerDescriptors;
for (var i = 0; i < playerDescriptors.Count; i++)
var descriptorCount = playerDescriptors.Count;
for (var i = 0; i < descriptorCount; i++)
{
if (i >= playerDescriptors.Count)
break;
var actor = playerDescriptors[i];
var playerAddress = actor.Address;
if (playerAddress == nint.Zero)
if (playerAddress == nint.Zero || !IsValidPointer(playerAddress))
continue;
if (actor.ObjectIndex >= 200)
continue;
if (_blockedCharacterHandler.IsCharacterBlocked(playerAddress, out bool firstTime) && firstTime)
if (_blockedCharacterHandler.IsCharacterBlocked(playerAddress, actor.ObjectIndex, out bool firstTime) && firstTime)
{
_logger.LogTrace("Skipping character {addr}, blocked/muted", playerAddress.ToString("X"));
continue;
@@ -1013,17 +1153,16 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
if (!IsAnythingDrawing)
{
var gameObj = (GameObject*)playerAddress;
var currentName = gameObj != null ? gameObj->NameString ?? string.Empty : string.Empty;
var charaName = string.IsNullOrEmpty(currentName) ? actor.Name : currentName;
CheckCharacterForDrawing(playerAddress, charaName);
if (!_objectTable.Any(o => o?.Address == playerAddress))
{
continue;
}
CheckCharacterForDrawing(playerAddress, actor.Name);
if (IsAnythingDrawing)
break;
}
else
{
break;
}
}
});
@@ -1092,7 +1231,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
});
// Cutscene
HandleStateTransition(() => IsInCutscene,v => IsInCutscene = v, shouldBeInCutscene, "Cutscene",
HandleStateTransition(() => IsInCutscene, v => IsInCutscene = v, shouldBeInCutscene, "Cutscene",
onEnter: () =>
{
Mediator.Publish(new CutsceneStartMessage());
@@ -1136,6 +1275,18 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
Mediator.Publish(new ResumeScanMessage(nameof(ConditionFlag.BetweenAreas)));
}
//Map
if (!_sentBetweenAreas)
{
var mapid = _clientState.MapId;
if (mapid != _lastMapId)
{
_lastMapId = mapid;
Mediator.Publish(new MapChangedMessage(mapid));
}
}
var localPlayer = _objectTable.LocalPlayer;
if (localPlayer != null)
{
@@ -1162,23 +1313,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
if (isNormalFrameworkUpdate)
return;
if (localPlayer != null && !IsLoggedIn)
{
_logger.LogDebug("Logged in");
IsLoggedIn = true;
_lastZone = _clientState.TerritoryType;
_lastWorldId = (ushort)localPlayer.CurrentWorld.RowId;
_cid = RebuildCID();
Mediator.Publish(new DalamudLoginMessage());
}
else if (localPlayer == null && IsLoggedIn)
{
_logger.LogDebug("Logged out");
IsLoggedIn = false;
_lastWorldId = 0;
Mediator.Publish(new DalamudLogoutMessage());
}
if (_gameConfig != null
&& _gameConfig.TryGet(Dalamud.Game.Config.SystemConfigOption.LodType_DX11, out bool lodEnabled))
{

View File

@@ -68,6 +68,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
ImGuiWindowFlags.NoMove |
ImGuiWindowFlags.NoSavedSettings |
ImGuiWindowFlags.NoNav |
ImGuiWindowFlags.NoFocusOnAppearing |
ImGuiWindowFlags.NoInputs;
private readonly List<RectF> _uiRects = new(128);

View File

@@ -1,4 +1,4 @@
using Dalamud.Plugin.Services;
using Dalamud.Plugin.Services;
using LightlessSync.API.Dto.User;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
@@ -23,6 +23,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
private readonly HashSet<string> _syncshellCids = [];
private volatile bool _pendingLocalBroadcast;
private TimeSpan? _pendingLocalTtl;
private string? _pendingLocalGid;
private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4);
private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1);
@@ -36,6 +37,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
private const int _maxQueueSize = 100;
private volatile bool _batchRunning = false;
private volatile bool _disposed = false;
public IReadOnlyDictionary<string, BroadcastEntry> BroadcastCache => _broadcastCache;
public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID);
@@ -68,6 +70,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
public void Update()
{
if (_disposed)
return;
_frameCounter++;
var lookupsThisFrame = 0;
@@ -78,12 +83,12 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
var now = DateTime.UtcNow;
foreach (var address in _actorTracker.PlayerAddresses)
foreach (var descriptor in _actorTracker.PlayerDescriptors)
{
if (address == nint.Zero)
if (string.IsNullOrEmpty(descriptor.HashedContentId))
continue;
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address);
var cid = descriptor.HashedContentId;
var isStale = !_broadcastCache.TryGetValue(cid, out var entry) || entry.ExpiryTime <= now;
if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < _maxQueueSize)
@@ -111,7 +116,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
private async Task BatchUpdateBroadcastCacheAsync(List<string> cids)
{
if (_disposed)
return;
var results = await _broadcastService.AreUsersBroadcastingAsync(cids).ConfigureAwait(false);
if (_disposed)
return;
var now = DateTime.UtcNow;
foreach (var (cid, info) in results)
@@ -130,6 +142,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
(_, old) => new BroadcastEntry(info.IsBroadcasting, expiry, info.GID));
}
if (_disposed)
return;
var activeCids = _broadcastCache
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now)
.Select(e => e.Key)
@@ -142,6 +157,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg)
{
if (_disposed)
return;
if (!msg.Enabled)
{
_broadcastCache.Clear();
@@ -158,6 +176,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
_pendingLocalBroadcast = true;
_pendingLocalTtl = msg.Ttl;
_pendingLocalGid = msg.Gid;
TryPrimeLocalBroadcastCache();
}
@@ -173,11 +192,12 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
var expiry = DateTime.UtcNow + ttl;
_broadcastCache.AddOrUpdate(localCid,
new BroadcastEntry(true, expiry, null),
(_, old) => new BroadcastEntry(true, expiry, old.GID));
new BroadcastEntry(true, expiry, _pendingLocalGid),
(_, old) => new BroadcastEntry(true, expiry, _pendingLocalGid ?? old.GID));
_pendingLocalBroadcast = false;
_pendingLocalTtl = null;
_pendingLocalGid = null;
var now = DateTime.UtcNow;
var activeCids = _broadcastCache
@@ -187,10 +207,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
_lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids);
UpdateSyncshellBroadcasts();
}
private void UpdateSyncshellBroadcasts()
{
if (_disposed)
return;
var now = DateTime.UtcNow;
var nearbyCids = GetNearbyHashedCids(out _);
var newSet = nearbyCids.Count == 0
@@ -324,17 +348,35 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
protected override void Dispose(bool disposing)
{
_disposed = true;
base.Dispose(disposing);
_framework.Update -= OnFrameworkUpdate;
if (_cleanupTask != null)
try
{
_cleanupTask?.Wait(100, _cleanupCts.Token);
_cleanupCts.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed, can be ignored :)
}
_cleanupCts.Cancel();
_cleanupCts.Dispose();
try
{
_cleanupTask?.Wait(100);
}
catch (Exception)
{
// Task may have already completed or been cancelled?
}
_cleanupTask?.Wait(100);
_cleanupCts.Dispose();
try
{
_cleanupCts.Dispose();
}
catch (ObjectDisposedException)
{
// Already disposed, ignore
}
}
}

View File

@@ -1,4 +1,4 @@
using Dalamud.Interface;
using Dalamud.Interface;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
using LightlessSync.LightlessConfiguration;
@@ -121,7 +121,10 @@ public class LightFinderService : IHostedService, IMediatorSubscriber
_waitingForTtlFetch = false;
if (!wasEnabled || previousRemaining != validTtl)
_mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl));
{
var gid = _config.Current.SyncshellFinderEnabled ? _config.Current.SelectedFinderSyncshell : null;
_mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl, gid));
}
_logger.LogInformation("Lightfinder broadcast enabled ({Context}), TTL: {TTL}", context, validTtl);
return true;

View File

@@ -0,0 +1,137 @@
using LightlessSync.API.Data;
using LightlessSync.API.Dto.CharaData;
using LightlessSync.API.Dto.User;
using LightlessSync.Services.Mediator;
using LightlessSync.WebAPI;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
namespace LightlessSync.Services
{
public class LocationShareService : DisposableMediatorSubscriberBase
{
private readonly DalamudUtilService _dalamudUtilService;
private readonly ApiController _apiController;
private IMemoryCache _locations = new MemoryCache(new MemoryCacheOptions());
private IMemoryCache _sharingStatus = new MemoryCache(new MemoryCacheOptions());
private CancellationTokenSource _resetToken = new CancellationTokenSource();
public LocationShareService(ILogger<LocationShareService> logger, LightlessMediator mediator, DalamudUtilService dalamudUtilService, ApiController apiController) : base(logger, mediator)
{
_dalamudUtilService = dalamudUtilService;
_apiController = apiController;
Mediator.Subscribe<DisconnectedMessage>(this, (msg) =>
{
_resetToken.Cancel();
_resetToken.Dispose();
_resetToken = new CancellationTokenSource();
});
Mediator.Subscribe<ConnectedMessage>(this, (msg) =>
{
_ = _apiController.UpdateLocation(new LocationDto(new UserData(_apiController.UID, apiController.DisplayName), _dalamudUtilService.GetMapData()));
_ = RequestAllLocation();
} );
Mediator.Subscribe<LocationSharingMessage>(this, UpdateLocationList);
Mediator.Subscribe<MapChangedMessage>(this,
msg => _ = _apiController.UpdateLocation(new LocationDto(new UserData(_apiController.UID, _apiController.DisplayName), _dalamudUtilService.GetMapData())));
}
private void UpdateLocationList(LocationSharingMessage msg)
{
if (_locations.TryGetValue(msg.User.UID, out _) && msg.LocationInfo.ServerId is 0)
{
_locations.Remove(msg.User.UID);
return;
}
if ( msg.LocationInfo.ServerId is not 0 && msg.ExpireAt > DateTime.UtcNow)
{
AddLocationInfo(msg.User.UID, msg.LocationInfo, msg.ExpireAt);
}
}
private void AddLocationInfo(string uid, LocationInfo location, DateTimeOffset expireAt)
{
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(expireAt)
.AddExpirationToken(new CancellationChangeToken(_resetToken.Token));
_locations.Set(uid, location, options);
}
private async Task RequestAllLocation()
{
try
{
var (data, status) = await _apiController.RequestAllLocationInfo().ConfigureAwait(false);
foreach (var dto in data)
{
AddLocationInfo(dto.LocationDto.User.UID, dto.LocationDto.Location, dto.ExpireAt);
}
foreach (var dto in status)
{
AddStatus(dto.User.UID, dto.ExpireAt);
}
}
catch (Exception e)
{
Logger.LogError(e,"RequestAllLocation error : ");
throw;
}
}
private void AddStatus(string uid, DateTimeOffset expireAt)
{
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(expireAt)
.AddExpirationToken(new CancellationChangeToken(_resetToken.Token));
_sharingStatus.Set(uid, expireAt, options);
}
public string GetUserLocation(string uid)
{
try
{
if (_locations.TryGetValue<LocationInfo>(uid, out var location))
{
return _dalamudUtilService.LocationToString(location);
}
return String.Empty;
}
catch (Exception e)
{
Logger.LogError(e,"GetUserLocation error : ");
throw;
}
}
public DateTimeOffset GetSharingStatus(string uid)
{
try
{
if (_sharingStatus.TryGetValue<DateTimeOffset>(uid, out var expireAt))
{
return expireAt;
}
return DateTimeOffset.MinValue;
}
catch (Exception e)
{
Logger.LogError(e,"GetSharingStatus error : ");
throw;
}
}
public void UpdateSharingStatus(List<string> users, DateTimeOffset expireAt)
{
foreach (var user in users)
{
AddStatus(user, expireAt);
}
}
}
}

View File

@@ -63,23 +63,31 @@ public sealed class LightlessMediator : IHostedService
_ = Task.Run(async () =>
{
while (!_loopCts.Token.IsCancellationRequested)
try
{
while (!_processQueue)
while (!_loopCts.Token.IsCancellationRequested)
{
while (!_processQueue)
{
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
}
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
HashSet<MessageBase> processedMessages = [];
while (_messageQueue.TryDequeue(out var message))
{
if (processedMessages.Contains(message)) { continue; }
processedMessages.Add(message);
ExecuteMessage(message);
}
}
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
HashSet<MessageBase> processedMessages = [];
while (_messageQueue.TryDequeue(out var message))
{
if (processedMessages.Contains(message)) { continue; }
processedMessages.Add(message);
ExecuteMessage(message);
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("LightlessMediator stopped");
}
});

View File

@@ -73,7 +73,7 @@ public record HubClosedMessage(Exception? Exception) : SameThreadMessage;
public record ResumeScanMessage(string Source) : MessageBase;
public record FileCacheInitializedMessage : MessageBase;
public record DownloadReadyMessage(Guid RequestId) : MessageBase;
public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase;
public record DownloadStartedMessage(GameObjectHandler DownloadId, IReadOnlyDictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase;
public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase;
public record UiToggleMessage(Type UiType) : MessageBase;
public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : MessageBase;
@@ -104,6 +104,7 @@ public record PairUiUpdatedMessage(PairUiSnapshot Snapshot) : MessageBase;
public record CensusUpdateMessage(byte Gender, byte RaceId, byte TribeId) : MessageBase;
public record TargetPairMessage(Pair Pair) : MessageBase;
public record PairFocusCharacterMessage(Pair Pair) : SameThreadMessage;
public record PairOnlineMessage(PairUniqueIdentifier PairIdent) : MessageBase;
public record CombatStartMessage : MessageBase;
public record CombatEndMessage : MessageBase;
public record PerformanceStartMessage : MessageBase;
@@ -123,7 +124,7 @@ public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) :
public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase;
public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase;
public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase;
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : MessageBase;
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl, string? Gid = null) : MessageBase;
public record UserLeftSyncshell(string gid) : MessageBase;
public record UserJoinedSyncshell(string gid) : MessageBase;
public record SyncshellBroadcastsUpdatedMessage : MessageBase;
@@ -135,5 +136,8 @@ public record ChatChannelsUpdated : MessageBase;
public record ChatChannelMessageAdded(string ChannelKey, ChatMessageEntry Message) : MessageBase;
public record GroupCollectionChangedMessage : MessageBase;
public record OpenUserProfileMessage(UserData User) : MessageBase;
public record LocationSharingMessage(UserData User, LocationInfo LocationInfo, DateTimeOffset ExpireAt) : MessageBase;
public record MapChangedMessage(uint MapId) : MessageBase;
public record PenumbraTempCollectionsCleanedMessage : MessageBase;
#pragma warning restore S2094
#pragma warning restore MA0048 // File name must match type name

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,381 @@
using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Globalization;
namespace LightlessSync.Services.ModelDecimation;
public sealed class ModelDecimationService
{
private const int MaxConcurrentJobs = 1;
private const double MinTargetRatio = 0.01;
private const double MaxTargetRatio = 0.99;
private readonly ILogger<ModelDecimationService> _logger;
private readonly LightlessConfigService _configService;
private readonly FileCacheManager _fileCacheManager;
private readonly PlayerPerformanceConfigService _performanceConfigService;
private readonly XivDataStorageService _xivDataStorageService;
private readonly SemaphoreSlim _decimationSemaphore = new(MaxConcurrentJobs);
private readonly ConcurrentDictionary<string, Task> _activeJobs = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _decimatedPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, byte> _failedHashes = new(StringComparer.OrdinalIgnoreCase);
public ModelDecimationService(
ILogger<ModelDecimationService> logger,
LightlessConfigService configService,
FileCacheManager fileCacheManager,
PlayerPerformanceConfigService performanceConfigService,
XivDataStorageService xivDataStorageService)
{
_logger = logger;
_configService = configService;
_fileCacheManager = fileCacheManager;
_performanceConfigService = performanceConfigService;
_xivDataStorageService = xivDataStorageService;
}
public void ScheduleDecimation(string hash, string filePath, string? gamePath = null)
{
if (!ShouldScheduleDecimation(hash, filePath, gamePath))
{
return;
}
if (_decimatedPaths.ContainsKey(hash) || _failedHashes.ContainsKey(hash) || _activeJobs.ContainsKey(hash))
{
return;
}
_logger.LogInformation("Queued model decimation for {Hash}", hash);
_activeJobs[hash] = Task.Run(async () =>
{
await _decimationSemaphore.WaitAsync().ConfigureAwait(false);
try
{
await DecimateInternalAsync(hash, filePath).ConfigureAwait(false);
}
catch (Exception ex)
{
_failedHashes[hash] = 1;
_logger.LogWarning(ex, "Model decimation failed for {Hash}", hash);
}
finally
{
_decimationSemaphore.Release();
_activeJobs.TryRemove(hash, out _);
}
}, CancellationToken.None);
}
public bool ShouldScheduleDecimation(string hash, string filePath, string? gamePath = null)
=> IsDecimationEnabled()
&& filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)
&& IsDecimationAllowed(gamePath)
&& !ShouldSkipByTriangleCache(hash);
public string GetPreferredPath(string hash, string originalPath)
{
if (!IsDecimationEnabled())
{
return originalPath;
}
if (_decimatedPaths.TryGetValue(hash, out var existing) && File.Exists(existing))
{
return existing;
}
var resolved = GetExistingDecimatedPath(hash);
if (!string.IsNullOrEmpty(resolved))
{
_decimatedPaths[hash] = resolved;
return resolved;
}
return originalPath;
}
public Task WaitForPendingJobsAsync(IEnumerable<string>? hashes, CancellationToken token)
{
if (hashes is null)
{
return Task.CompletedTask;
}
var pending = new List<Task>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var hash in hashes)
{
if (string.IsNullOrEmpty(hash) || !seen.Add(hash))
{
continue;
}
if (_activeJobs.TryGetValue(hash, out var job))
{
pending.Add(job);
}
}
if (pending.Count == 0)
{
return Task.CompletedTask;
}
return Task.WhenAll(pending).WaitAsync(token);
}
private Task DecimateInternalAsync(string hash, string sourcePath)
{
if (!File.Exists(sourcePath))
{
_failedHashes[hash] = 1;
_logger.LogWarning("Cannot decimate model {Hash}; source path missing: {Path}", hash, sourcePath);
return Task.CompletedTask;
}
if (!TryGetDecimationSettings(out var triangleThreshold, out var targetRatio))
{
_logger.LogInformation("Model decimation disabled or invalid settings for {Hash}", hash);
return Task.CompletedTask;
}
_logger.LogInformation("Starting model decimation for {Hash} (threshold {Threshold}, ratio {Ratio:0.##})", hash, triangleThreshold, targetRatio);
var destination = Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl");
if (File.Exists(destination))
{
RegisterDecimatedModel(hash, sourcePath, destination);
return Task.CompletedTask;
}
if (!MdlDecimator.TryDecimate(sourcePath, destination, triangleThreshold, targetRatio, _logger))
{
_failedHashes[hash] = 1;
_logger.LogInformation("Model decimation skipped for {Hash}", hash);
return Task.CompletedTask;
}
RegisterDecimatedModel(hash, sourcePath, destination);
_logger.LogInformation("Decimated model {Hash} -> {Path}", hash, destination);
return Task.CompletedTask;
}
private void RegisterDecimatedModel(string hash, string sourcePath, string destination)
{
_decimatedPaths[hash] = destination;
var performanceConfig = _performanceConfigService.Current;
if (performanceConfig.KeepOriginalModelFiles)
{
return;
}
if (string.Equals(sourcePath, destination, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (!TryReplaceCacheEntryWithDecimated(hash, sourcePath, destination))
{
return;
}
TryDelete(sourcePath);
}
private bool TryReplaceCacheEntryWithDecimated(string hash, string sourcePath, string destination)
{
try
{
var cacheEntry = _fileCacheManager.GetFileCacheByHash(hash);
if (cacheEntry is null || !cacheEntry.IsCacheEntry)
{
return File.Exists(sourcePath) ? false : true;
}
var cacheFolder = _configService.Current.CacheFolder;
if (string.IsNullOrEmpty(cacheFolder))
{
return false;
}
if (!destination.StartsWith(cacheFolder, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var info = new FileInfo(destination);
if (!info.Exists)
{
return false;
}
var relative = Path.GetRelativePath(cacheFolder, destination)
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
var sanitizedRelative = relative.TrimStart(Path.DirectorySeparatorChar);
var prefixed = Path.Combine(FileCacheManager.CachePrefix, sanitizedRelative);
var replacement = new FileCacheEntity(
hash,
prefixed,
info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture),
info.Length,
cacheEntry.CompressedSize);
replacement.SetResolvedFilePath(destination);
if (!string.Equals(cacheEntry.PrefixedFilePath, prefixed, StringComparison.OrdinalIgnoreCase))
{
_fileCacheManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath, removeDerivedFiles: false);
}
_fileCacheManager.UpdateHashedFile(replacement, computeProperties: false);
_fileCacheManager.WriteOutFullCsv();
_logger.LogTrace("Replaced cache entry for model {Hash} to decimated path {Path}", hash, destination);
return true;
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to replace cache entry for model {Hash}", hash);
return false;
}
}
private bool IsDecimationEnabled()
=> _performanceConfigService.Current.EnableModelDecimation;
private bool ShouldSkipByTriangleCache(string hash)
{
if (string.IsNullOrEmpty(hash))
{
return false;
}
if (!_xivDataStorageService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris) || cachedTris <= 0)
{
return false;
}
var threshold = Math.Max(0, _performanceConfigService.Current.ModelDecimationTriangleThreshold);
return threshold > 0 && cachedTris < threshold;
}
private bool IsDecimationAllowed(string? gamePath)
{
if (string.IsNullOrWhiteSpace(gamePath))
{
return true;
}
var normalized = NormalizeGamePath(gamePath);
if (normalized.Contains("/hair/", StringComparison.Ordinal))
{
return false;
}
if (normalized.Contains("/chara/equipment/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowClothing;
}
if (normalized.Contains("/chara/accessory/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowAccessories;
}
if (normalized.Contains("/chara/human/", StringComparison.Ordinal))
{
if (normalized.Contains("/body/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowBody;
}
if (normalized.Contains("/face/", StringComparison.Ordinal) || normalized.Contains("/head/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowFaceHead;
}
if (normalized.Contains("/tail/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowTail;
}
}
return true;
}
private static string NormalizeGamePath(string path)
=> path.Replace('\\', '/').ToLowerInvariant();
private bool TryGetDecimationSettings(out int triangleThreshold, out double targetRatio)
{
triangleThreshold = 15_000;
targetRatio = 0.8;
var config = _performanceConfigService.Current;
if (!config.EnableModelDecimation)
{
return false;
}
triangleThreshold = Math.Max(0, config.ModelDecimationTriangleThreshold);
targetRatio = config.ModelDecimationTargetRatio;
if (double.IsNaN(targetRatio) || double.IsInfinity(targetRatio))
{
return false;
}
targetRatio = Math.Clamp(targetRatio, MinTargetRatio, MaxTargetRatio);
return true;
}
private string? GetExistingDecimatedPath(string hash)
{
var candidate = Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl");
return File.Exists(candidate) ? candidate : null;
}
private string GetDecimatedDirectory()
{
var directory = Path.Combine(_configService.Current.CacheFolder, "decimated");
if (!Directory.Exists(directory))
{
try
{
Directory.CreateDirectory(directory);
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to create decimated directory {Directory}", directory);
}
}
return directory;
}
private static void TryDelete(string? path)
{
if (string.IsNullOrEmpty(path))
{
return;
}
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// ignored
}
}
}

View File

@@ -8,14 +8,14 @@ namespace LightlessSync.Services;
public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriberBase
{
private readonly IpcManager _ipc;
private readonly LightlessConfigService _config;
private readonly PenumbraJanitorConfigService _config;
private int _ran;
public PenumbraTempCollectionJanitor(
ILogger<PenumbraTempCollectionJanitor> logger,
LightlessMediator mediator,
IpcManager ipc,
LightlessConfigService config) : base(logger, mediator)
PenumbraJanitorConfigService config) : base(logger, mediator)
{
_ipc = ipc;
_config = config;
@@ -67,5 +67,8 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber
_config.Current.OrphanableTempCollections.Clear();
_config.Save();
// Notify cleanup complete
Mediator.Publish(new PenumbraTempCollectionsCleanedMessage());
}
}

View File

@@ -4,6 +4,7 @@ using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Events;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ModelDecimation;
using LightlessSync.Services.TextureCompression;
using LightlessSync.UI;
using LightlessSync.WebAPI.Files.Models;
@@ -18,12 +19,14 @@ public class PlayerPerformanceService
private readonly ILogger<PlayerPerformanceService> _logger;
private readonly LightlessMediator _mediator;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly ModelDecimationService _modelDecimationService;
private readonly TextureDownscaleService _textureDownscaleService;
private readonly Dictionary<string, bool> _warnedForPlayers = new(StringComparer.Ordinal);
public PlayerPerformanceService(ILogger<PlayerPerformanceService> logger, LightlessMediator mediator,
PlayerPerformanceConfigService playerPerformanceConfigService, FileCacheManager fileCacheManager,
XivDataAnalyzer xivDataAnalyzer, TextureDownscaleService textureDownscaleService)
XivDataAnalyzer xivDataAnalyzer, TextureDownscaleService textureDownscaleService,
ModelDecimationService modelDecimationService)
{
_logger = logger;
_mediator = mediator;
@@ -31,6 +34,7 @@ public class PlayerPerformanceService
_fileCacheManager = fileCacheManager;
_xivDataAnalyzer = xivDataAnalyzer;
_textureDownscaleService = textureDownscaleService;
_modelDecimationService = modelDecimationService;
}
public async Task<bool> CheckBothThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData)
@@ -111,10 +115,12 @@ public class PlayerPerformanceService
var config = _playerPerformanceConfigService.Current;
long triUsage = 0;
long effectiveTriUsage = 0;
if (!charaData.FileReplacements.TryGetValue(API.Data.Enum.ObjectKind.Player, out List<FileReplacementData>? playerReplacements))
{
pairHandler.LastAppliedDataTris = 0;
pairHandler.LastAppliedApproximateEffectiveTris = 0;
return true;
}
@@ -123,14 +129,40 @@ public class PlayerPerformanceService
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var skipDecimation = config.SkipModelDecimationForPreferredPairs && pairHandler.IsDirectlyPaired && pairHandler.HasStickyPermissions;
foreach (var hash in moddedModelHashes)
{
triUsage += await _xivDataAnalyzer.GetTrianglesByHash(hash).ConfigureAwait(false);
var tris = await _xivDataAnalyzer.GetTrianglesByHash(hash).ConfigureAwait(false);
triUsage += tris;
long effectiveTris = tris;
var fileEntry = _fileCacheManager.GetFileCacheByHash(hash);
if (fileEntry != null)
{
var preferredPath = fileEntry.ResolvedFilepath;
if (!skipDecimation)
{
preferredPath = _modelDecimationService.GetPreferredPath(hash, fileEntry.ResolvedFilepath);
}
if (!string.Equals(preferredPath, fileEntry.ResolvedFilepath, StringComparison.OrdinalIgnoreCase))
{
var decimatedTris = await _xivDataAnalyzer.GetEffectiveTrianglesByHash(hash, preferredPath).ConfigureAwait(false);
if (decimatedTris > 0)
{
effectiveTris = decimatedTris;
}
}
}
effectiveTriUsage += effectiveTris;
}
pairHandler.LastAppliedDataTris = triUsage;
pairHandler.LastAppliedApproximateEffectiveTris = effectiveTriUsage;
_logger.LogDebug("Calculated VRAM usage for {p}", pairHandler);
_logger.LogDebug("Calculated triangle usage for {p}", pairHandler);
// no warning of any kind on ignored pairs
if (config.UIDsToIgnore
@@ -167,7 +199,9 @@ public class PlayerPerformanceService
public bool ComputeAndAutoPauseOnVRAMUsageThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData, List<DownloadFileTransfer> toDownloadFiles)
{
var config = _playerPerformanceConfigService.Current;
bool skipDownscale = pairHandler.IsDirectlyPaired && pairHandler.HasStickyPermissions;
bool skipDownscale = config.SkipTextureDownscaleForPreferredPairs
&& pairHandler.IsDirectlyPaired
&& pairHandler.HasStickyPermissions;
long vramUsage = 0;
long effectiveVramUsage = 0;

View File

@@ -77,16 +77,39 @@ public sealed class TextureDownscaleService
}
public void ScheduleDownscale(string hash, string filePath, TextureMapKind mapKind)
=> ScheduleDownscale(hash, filePath, () => mapKind);
public void ScheduleDownscale(string hash, string filePath, Func<TextureMapKind> mapKindFactory)
{
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return;
if (_activeJobs.ContainsKey(hash)) return;
_activeJobs[hash] = Task.Run(async () =>
{
TextureMapKind mapKind;
try
{
mapKind = mapKindFactory();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to determine texture map kind for {Hash}; skipping downscale", hash);
return;
}
await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false);
}, CancellationToken.None);
}
public bool ShouldScheduleDownscale(string filePath)
{
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))
return false;
var performanceConfig = _playerPerformanceConfigService.Current;
return performanceConfig.EnableNonIndexTextureMipTrim || performanceConfig.EnableIndexTextureDownscale;
}
public string GetPreferredPath(string hash, string originalPath)
{
if (_downscaledPaths.TryGetValue(hash, out var existing) && File.Exists(existing))
@@ -655,7 +678,7 @@ public sealed class TextureDownscaleService
if (!string.Equals(cacheEntry.PrefixedFilePath, prefixed, StringComparison.OrdinalIgnoreCase))
{
_fileCacheManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath);
_fileCacheManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath, removeDerivedFiles: false);
}
_fileCacheManager.UpdateHashedFile(replacement, computeProperties: false);

View File

@@ -8,6 +8,7 @@ using LightlessSync.UI.Tags;
using LightlessSync.WebAPI;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Logging;
using LightlessSync.PlayerData.Factories;
namespace LightlessSync.Services;
@@ -23,6 +24,7 @@ public class UiFactory
private readonly PerformanceCollectorService _performanceCollectorService;
private readonly ProfileTagService _profileTagService;
private readonly DalamudUtilService _dalamudUtilService;
private readonly PairFactory _pairFactory;
public UiFactory(
ILoggerFactory loggerFactory,
@@ -34,7 +36,8 @@ public class UiFactory
LightlessProfileManager lightlessProfileManager,
PerformanceCollectorService performanceCollectorService,
ProfileTagService profileTagService,
DalamudUtilService dalamudUtilService)
DalamudUtilService dalamudUtilService,
PairFactory pairFactory)
{
_loggerFactory = loggerFactory;
_lightlessMediator = lightlessMediator;
@@ -46,6 +49,7 @@ public class UiFactory
_performanceCollectorService = performanceCollectorService;
_profileTagService = profileTagService;
_dalamudUtilService = dalamudUtilService;
_pairFactory = pairFactory;
}
public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto)
@@ -58,7 +62,8 @@ public class UiFactory
_pairUiService,
dto,
_performanceCollectorService,
_lightlessProfileManager);
_lightlessProfileManager,
_pairFactory);
}
public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair)

View File

@@ -1,23 +1,30 @@
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using FFXIVClientStructs.Havok.Common.Serialize.Resource;
using FFXIVClientStructs.Havok.Animation;
using FFXIVClientStructs.Havok.Common.Base.Types;
using FFXIVClientStructs.Havok.Common.Serialize.Resource;
using FFXIVClientStructs.Havok.Common.Serialize.Util;
using LightlessSync.FileCache;
using LightlessSync.Interop.GameModel;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers;
using Microsoft.Extensions.Logging;
using OtterGui.Text.EndObjects;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
namespace LightlessSync.Services;
public sealed class XivDataAnalyzer
public sealed partial class XivDataAnalyzer
{
private readonly ILogger<XivDataAnalyzer> _logger;
private readonly FileCacheManager _fileCacheManager;
private readonly XivDataStorageService _configService;
private readonly List<string> _failedCalculatedTris = [];
private readonly List<string> _failedCalculatedEffectiveTris = [];
public XivDataAnalyzer(ILogger<XivDataAnalyzer> logger, FileCacheManager fileCacheManager,
XivDataStorageService configService)
@@ -29,127 +36,435 @@ public sealed class XivDataAnalyzer
public unsafe Dictionary<string, List<ushort>>? GetSkeletonBoneIndices(GameObjectHandler handler)
{
if (handler.Address == nint.Zero) return null;
var chara = (CharacterBase*)(((Character*)handler.Address)->GameObject.DrawObject);
if (chara->GetModelType() != CharacterBase.ModelType.Human) return null;
var resHandles = chara->Skeleton->SkeletonResourceHandles;
Dictionary<string, List<ushort>> outputIndices = [];
if (handler is null || handler.Address == nint.Zero)
return null;
Dictionary<string, HashSet<ushort>> sets = new(StringComparer.OrdinalIgnoreCase);
try
{
for (int i = 0; i < chara->Skeleton->PartialSkeletonCount; i++)
var drawObject = ((Character*)handler.Address)->GameObject.DrawObject;
if (drawObject == null)
return null;
var chara = (CharacterBase*)drawObject;
if (chara->GetModelType() != CharacterBase.ModelType.Human)
return null;
var skeleton = chara->Skeleton;
if (skeleton == null)
return null;
var resHandles = skeleton->SkeletonResourceHandles;
var partialCount = skeleton->PartialSkeletonCount;
if (partialCount <= 0)
return null;
for (int i = 0; i < partialCount; i++)
{
var handle = *(resHandles + i);
_logger.LogTrace("Iterating over SkeletonResourceHandle #{i}:{x}", i, ((nint)handle).ToString("X"));
if ((nint)handle == nint.Zero) continue;
var curBones = handle->BoneCount;
// this is unrealistic, the filename shouldn't ever be that long
if (handle->FileName.Length > 1024) continue;
var skeletonName = handle->FileName.ToString();
if (string.IsNullOrEmpty(skeletonName)) continue;
outputIndices[skeletonName] = [];
for (ushort boneIdx = 0; boneIdx < curBones; boneIdx++)
if ((nint)handle == nint.Zero)
continue;
if (handle->FileName.Length > 1024)
continue;
var rawName = handle->FileName.ToString();
if (string.IsNullOrWhiteSpace(rawName))
continue;
var skeletonKey = CanonicalizeSkeletonKey(rawName);
if (string.IsNullOrEmpty(skeletonKey))
continue;
var boneCount = handle->BoneCount;
if (boneCount == 0)
continue;
var havokSkel = handle->HavokSkeleton;
if ((nint)havokSkel == nint.Zero)
continue;
if (!sets.TryGetValue(skeletonKey, out var set))
{
var boneName = handle->HavokSkeleton->Bones[boneIdx].Name.String;
if (boneName == null) continue;
outputIndices[skeletonName].Add((ushort)(boneIdx + 1));
set = [];
sets[skeletonKey] = set;
}
uint maxExclusive = boneCount;
uint ushortExclusive = (uint)ushort.MaxValue + 1u;
if (maxExclusive > ushortExclusive)
maxExclusive = ushortExclusive;
for (uint boneIdx = 0; boneIdx < maxExclusive; boneIdx++)
{
var name = havokSkel->Bones[boneIdx].Name.String;
if (name == null)
continue;
set.Add((ushort)boneIdx);
}
_logger.LogTrace("Local skeleton raw file='{raw}', key='{key}', boneCount={count}",
rawName, skeletonKey, boneCount);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not process skeleton data");
return null;
}
return (outputIndices.Count != 0 && outputIndices.Values.All(u => u.Count > 0)) ? outputIndices : null;
if (sets.Count == 0)
return null;
var output = new Dictionary<string, List<ushort>>(sets.Count, StringComparer.OrdinalIgnoreCase);
foreach (var (key, set) in sets)
{
if (set.Count == 0)
continue;
var list = set.ToList();
list.Sort();
output[key] = list;
}
return (output.Count != 0 && output.Values.All(v => v.Count > 0)) ? output : null;
}
public unsafe Dictionary<string, List<ushort>>? GetBoneIndicesFromPap(string hash)
public static byte[]? ReadHavokBytesFromPap(string papPath)
{
if (_configService.Current.BonesDictionary.TryGetValue(hash, out var bones)) return bones;
using var fs = File.Open(papPath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new BinaryReader(fs);
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
if (cacheEntity == null) return null;
_ = reader.ReadInt32();
_ = reader.ReadInt32();
_ = reader.ReadInt16();
_ = reader.ReadInt16();
using BinaryReader reader = new(File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read));
var type = reader.ReadByte();
if (type != 0) return null;
// most of this shit is from vfxeditor, surely nothing will change in the pap format :copium:
reader.ReadInt32(); // ignore
reader.ReadInt32(); // ignore
reader.ReadInt16(); // read 2 (num animations)
reader.ReadInt16(); // read 2 (modelid)
var type = reader.ReadByte();// read 1 (type)
if (type != 0) return null; // it's not human, just ignore it, whatever
_ = reader.ReadByte();
_ = reader.ReadInt32();
reader.ReadByte(); // read 1 (variant)
reader.ReadInt32(); // ignore
var havokPosition = reader.ReadInt32();
var footerPosition = reader.ReadInt32();
var havokDataSize = footerPosition - havokPosition;
reader.BaseStream.Position = havokPosition;
var havokData = reader.ReadBytes(havokDataSize);
if (havokData.Length <= 8) return null; // no havok data
var output = new Dictionary<string, List<ushort>>(StringComparer.OrdinalIgnoreCase);
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) + ".hkx";
var tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath);
if (havokPosition <= 0 || footerPosition <= havokPosition || footerPosition > fs.Length)
return null;
var sizeLong = (long)footerPosition - havokPosition;
if (sizeLong <= 8 || sizeLong > int.MaxValue)
return null;
var size = (int)sizeLong;
fs.Position = havokPosition;
var bytes = reader.ReadBytes(size);
return bytes.Length > 8 ? bytes : null;
}
public unsafe Dictionary<string, List<ushort>>? ParseHavokBytesOnFrameworkThread(
byte[] havokData,
string hash,
bool persistToConfig)
{
var tempSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
var tempHkxPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx");
IntPtr pathAnsi = IntPtr.Zero;
try
{
File.WriteAllBytes(tempHavokDataPath, havokData);
File.WriteAllBytes(tempHkxPath, havokData);
var loadoptions = stackalloc hkSerializeUtil.LoadOptions[1];
loadoptions->TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry();
loadoptions->ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry();
loadoptions->Flags = new hkFlags<hkSerializeUtil.LoadOptionBits, int>
pathAnsi = Marshal.StringToHGlobalAnsi(tempHkxPath);
hkSerializeUtil.LoadOptions loadOptions = default;
loadOptions.TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry();
loadOptions.ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry();
loadOptions.Flags = new hkFlags<hkSerializeUtil.LoadOptionBits, int>
{
Storage = (int)(hkSerializeUtil.LoadOptionBits.Default)
Storage = (int)hkSerializeUtil.LoadOptionBits.Default
};
var resource = hkSerializeUtil.LoadFromFile((byte*)tempHavokDataPathAnsi, null, loadoptions);
hkSerializeUtil.LoadOptions* pOpts = &loadOptions;
var resource = hkSerializeUtil.LoadFromFile((byte*)pathAnsi, errorResult: null, pOpts);
if (resource == null)
{
throw new InvalidOperationException("Resource was null after loading");
}
return null;
var rootLevelName = @"hkRootLevelContainer"u8;
fixed (byte* n1 = rootLevelName)
{
var container = (hkRootLevelContainer*)resource->GetContentsPointer(n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry());
var container = (hkRootLevelContainer*)resource->GetContentsPointer(
n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry());
if (container == null) return null;
var animationName = @"hkaAnimationContainer"u8;
fixed (byte* n2 = animationName)
{
var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null);
if (animContainer == null) return null;
for (int i = 0; i < animContainer->Bindings.Length; i++)
{
var binding = animContainer->Bindings[i].ptr;
if (binding == null) continue;
var rawSkel = binding->OriginalSkeletonName.String;
var skeletonKey = CanonicalizeSkeletonKey(rawSkel);
if (string.IsNullOrEmpty(skeletonKey)) continue;
var boneTransform = binding->TransformTrackToBoneIndices;
string name = binding->OriginalSkeletonName.String! + "_" + i;
output[name] = [];
if (boneTransform.Length <= 0) continue;
if (!tempSets.TryGetValue(skeletonKey, out var set))
tempSets[skeletonKey] = set = [];
for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++)
{
output[name].Add((ushort)boneTransform[boneIdx]);
var v = boneTransform[boneIdx];
if (v < 0) continue;
set.Add((ushort)v);
}
output[name].Sort();
}
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not load havok file in {path}", tempHavokDataPath);
}
finally
{
Marshal.FreeHGlobal(tempHavokDataPathAnsi);
File.Delete(tempHavokDataPath);
if (pathAnsi != IntPtr.Zero)
Marshal.FreeHGlobal(pathAnsi);
try { if (File.Exists(tempHkxPath)) File.Delete(tempHkxPath); }
catch { /* ignore */ }
}
if (tempSets.Count == 0) return null;
var output = new Dictionary<string, List<ushort>>(tempSets.Count, StringComparer.OrdinalIgnoreCase);
foreach (var (key, set) in tempSets)
{
if (set.Count == 0) continue;
var list = set.ToList();
list.Sort();
output[key] = list;
}
if (output.Count == 0) return null;
_configService.Current.BonesDictionary[hash] = output;
_configService.Save();
if (persistToConfig) _configService.Save();
return output;
}
public static string CanonicalizeSkeletonKey(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return string.Empty;
var s = raw.Replace('\\', '/').Trim();
var underscore = s.LastIndexOf('_');
if (underscore > 0 && underscore + 1 < s.Length && char.IsDigit(s[underscore + 1]))
s = s[..underscore];
if (s.StartsWith("skeleton", StringComparison.OrdinalIgnoreCase))
return "skeleton";
var m = _bucketPathRegex.Match(s);
if (m.Success)
return m.Groups["bucket"].Value.ToLowerInvariant();
m = _bucketSklRegex.Match(s);
if (m.Success)
return m.Groups["bucket"].Value.ToLowerInvariant();
m = _bucketLooseRegex.Match(s);
if (m.Success)
return m.Groups["bucket"].Value.ToLowerInvariant();
return string.Empty;
}
public static bool ContainsIndexCompat(
HashSet<ushort> available,
ushort idx,
bool papLikelyOneBased,
bool allowOneBasedShift,
bool allowNeighborTolerance)
{
Span<ushort> candidates = stackalloc ushort[2];
int count = 0;
candidates[count++] = idx;
if (allowOneBasedShift && papLikelyOneBased && idx > 0)
candidates[count++] = (ushort)(idx - 1);
for (int i = 0; i < count; i++)
{
var c = candidates[i];
if (available.Contains(c))
return true;
if (allowNeighborTolerance)
{
if (c > 0 && available.Contains((ushort)(c - 1)))
return true;
if (c < ushort.MaxValue && available.Contains((ushort)(c + 1)))
return true;
}
}
return false;
}
public static bool IsPapCompatible(
IReadOnlyDictionary<string, HashSet<ushort>> localBoneSets,
IReadOnlyDictionary<string, List<ushort>> papBoneIndices,
AnimationValidationMode mode,
bool allowOneBasedShift,
bool allowNeighborTolerance,
out string reason)
{
reason = string.Empty;
if (mode == AnimationValidationMode.Unsafe)
return true;
var papByBucket = new Dictionary<string, List<ushort>>(StringComparer.OrdinalIgnoreCase);
foreach (var (rawKey, list) in papBoneIndices)
{
var key = CanonicalizeSkeletonKey(rawKey);
if (string.IsNullOrEmpty(key))
continue;
if (string.Equals(key, "skeleton", StringComparison.OrdinalIgnoreCase))
key = "__any__";
if (!papByBucket.TryGetValue(key, out var acc))
papByBucket[key] = acc = [];
if (list is { Count: > 0 })
acc.AddRange(list);
}
foreach (var k in papByBucket.Keys.ToList())
papByBucket[k] = papByBucket[k].Distinct().ToList();
if (papByBucket.Count == 0)
{
reason = "No skeleton bucket bindings found in the PAP";
return false;
}
static bool AllIndicesOk(
HashSet<ushort> available,
List<ushort> indices,
bool papLikelyOneBased,
bool allowOneBasedShift,
bool allowNeighborTolerance,
out ushort missing)
{
foreach (var idx in indices)
{
if (!ContainsIndexCompat(available, idx, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance))
{
missing = idx;
return false;
}
}
missing = 0;
return true;
}
foreach (var (bucket, indices) in papByBucket)
{
if (indices.Count == 0)
continue;
bool has0 = false, has1 = false;
ushort min = ushort.MaxValue;
foreach (var v in indices)
{
if (v == 0) has0 = true;
if (v == 1) has1 = true;
if (v < min) min = v;
}
bool papLikelyOneBased = allowOneBasedShift && (min == 1) && has1 && !has0;
if (string.Equals(bucket, "__any__", StringComparison.OrdinalIgnoreCase))
{
foreach (var (lk, ls) in localBoneSets)
{
if (AllIndicesOk(ls, indices, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance, out _))
goto nextBucket;
}
reason = $"No compatible local skeleton bucket for generic PAP skeleton '{bucket}'. Local buckets: {string.Join(", ", localBoneSets.Keys)}";
return false;
}
if (!localBoneSets.TryGetValue(bucket, out var available))
{
reason = $"Missing skeleton bucket '{bucket}' on local actor.";
return false;
}
if (!AllIndicesOk(available, indices, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance, out var missing))
{
reason = $"No compatible local skeleton for PAP '{bucket}': missing bone index {missing}.";
return false;
}
nextBucket:
;
}
return true;
}
public void DumpLocalSkeletonIndices(GameObjectHandler handler, string? filter = null)
{
var skels = GetSkeletonBoneIndices(handler);
if (skels == null)
{
_logger.LogTrace("DumpLocalSkeletonIndices: local skeleton indices are null or not found");
return;
}
var keys = skels.Keys
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();
_logger.LogTrace("Local skeleton indices found ({count}): {keys}",
keys.Length,
string.Join(", ", keys));
if (!string.IsNullOrWhiteSpace(filter))
{
var hits = keys.Where(k =>
k.Equals(filter, StringComparison.OrdinalIgnoreCase) ||
k.StartsWith(filter + "_", StringComparison.OrdinalIgnoreCase) ||
filter.StartsWith(k + "_", StringComparison.OrdinalIgnoreCase) ||
k.Contains(filter, StringComparison.OrdinalIgnoreCase))
.ToArray();
_logger.LogTrace("Matches found for '{filter}': {hits}",
filter,
hits.Length == 0 ? "<none>" : string.Join(", ", hits));
}
}
public async Task<long> GetTrianglesByHash(string hash)
{
if (_configService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris) && cachedTris > 0)
@@ -162,16 +477,41 @@ public sealed class XivDataAnalyzer
if (path == null || !path.ResolvedFilepath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase))
return 0;
var filePath = path.ResolvedFilepath;
return CalculateTrianglesFromPath(hash, path.ResolvedFilepath, _configService.Current.TriangleDictionary, _failedCalculatedTris);
}
public async Task<long> GetEffectiveTrianglesByHash(string hash, string filePath)
{
if (_configService.Current.EffectiveTriangleDictionary.TryGetValue(hash, out var cachedTris) && cachedTris > 0)
return cachedTris;
if (_failedCalculatedEffectiveTris.Contains(hash, StringComparer.Ordinal))
return 0;
if (string.IsNullOrEmpty(filePath)
|| !filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)
|| !File.Exists(filePath))
{
return 0;
}
return CalculateTrianglesFromPath(hash, filePath, _configService.Current.EffectiveTriangleDictionary, _failedCalculatedEffectiveTris);
}
private long CalculateTrianglesFromPath(
string hash,
string filePath,
ConcurrentDictionary<string, long> cache,
List<string> failedList)
{
try
{
_logger.LogDebug("Detected Model File {path}, calculating Tris", filePath);
var file = new MdlFile(filePath);
if (file.LodCount <= 0)
{
_failedCalculatedTris.Add(hash);
_configService.Current.TriangleDictionary[hash] = 0;
failedList.Add(hash);
cache[hash] = 0;
_configService.Save();
return 0;
}
@@ -195,7 +535,7 @@ public sealed class XivDataAnalyzer
if (tris > 0)
{
_logger.LogDebug("TriAnalysis: {filePath} => {tris} triangles", filePath, tris);
_configService.Current.TriangleDictionary[hash] = tris;
cache[hash] = tris;
_configService.Save();
break;
}
@@ -205,11 +545,30 @@ public sealed class XivDataAnalyzer
}
catch (Exception e)
{
_failedCalculatedTris.Add(hash);
_configService.Current.TriangleDictionary[hash] = 0;
failedList.Add(hash);
cache[hash] = 0;
_configService.Save();
_logger.LogWarning(e, "Could not parse file {file}", filePath);
return 0;
}
}
// Regexes for canonicalizing skeleton keys
private static readonly Regex _bucketPathRegex =
BucketRegex();
private static readonly Regex _bucketSklRegex =
SklRegex();
private static readonly Regex _bucketLooseRegex =
LooseBucketRegex();
[GeneratedRegex(@"(?i)(?:^|/)(?<bucket>c\d{4})(?:/|$)", RegexOptions.Compiled, "en-NL")]
private static partial Regex BucketRegex();
[GeneratedRegex(@"(?i)\bskl_(?<bucket>c\d{4})[a-z]\d{4}\b", RegexOptions.Compiled, "en-NL")]
private static partial Regex SklRegex();
[GeneratedRegex(@"(?i)(?<![a-z0-9])(?<bucket>c\d{4})(?!\d)", RegexOptions.Compiled, "en-NL")]
private static partial Regex LooseBucketRegex();
}

View File

@@ -0,0 +1,169 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using Microsoft.Extensions.Logging;
namespace MeshDecimator.Algorithms
{
/// <summary>
/// A decimation algorithm.
/// </summary>
public abstract class DecimationAlgorithm
{
#region Delegates
/// <summary>
/// A callback for decimation status reports.
/// </summary>
/// <param name="iteration">The current iteration, starting at zero.</param>
/// <param name="originalTris">The original count of triangles.</param>
/// <param name="currentTris">The current count of triangles.</param>
/// <param name="targetTris">The target count of triangles.</param>
public delegate void StatusReportCallback(int iteration, int originalTris, int currentTris, int targetTris);
#endregion
#region Fields
private bool preserveBorders = false;
private int maxVertexCount = 0;
private bool verbose = false;
private StatusReportCallback statusReportInvoker = null;
#endregion
#region Properties
/// <summary>
/// Gets or sets if borders should be kept.
/// Default value: false
/// </summary>
[Obsolete("Use the 'DecimationAlgorithm.PreserveBorders' property instead.", false)]
public bool KeepBorders
{
get { return preserveBorders; }
set { preserveBorders = value; }
}
/// <summary>
/// Gets or sets if borders should be preserved.
/// Default value: false
/// </summary>
public bool PreserveBorders
{
get { return preserveBorders; }
set { preserveBorders = value; }
}
/// <summary>
/// Gets or sets if linked vertices should be kept.
/// Default value: false
/// </summary>
[Obsolete("This feature has been removed, for more details why please read the readme.", true)]
public bool KeepLinkedVertices
{
get { return false; }
set { }
}
/// <summary>
/// Gets or sets the maximum vertex count. Set to zero for no limitation.
/// Default value: 0 (no limitation)
/// </summary>
public int MaxVertexCount
{
get { return maxVertexCount; }
set { maxVertexCount = Math.MathHelper.Max(value, 0); }
}
/// <summary>
/// Gets or sets if verbose information should be printed in the console.
/// Default value: false
/// </summary>
public bool Verbose
{
get { return verbose; }
set { verbose = value; }
}
/// <summary>
/// Gets or sets the logger used for diagnostics.
/// </summary>
public ILogger? Logger { get; set; }
#endregion
#region Events
/// <summary>
/// An event for status reports for this algorithm.
/// </summary>
public event StatusReportCallback StatusReport
{
add { statusReportInvoker += value; }
remove { statusReportInvoker -= value; }
}
#endregion
#region Protected Methods
/// <summary>
/// Reports the current status of the decimation.
/// </summary>
/// <param name="iteration">The current iteration, starting at zero.</param>
/// <param name="originalTris">The original count of triangles.</param>
/// <param name="currentTris">The current count of triangles.</param>
/// <param name="targetTris">The target count of triangles.</param>
protected void ReportStatus(int iteration, int originalTris, int currentTris, int targetTris)
{
var statusReportInvoker = this.statusReportInvoker;
if (statusReportInvoker != null)
{
statusReportInvoker.Invoke(iteration, originalTris, currentTris, targetTris);
}
}
#endregion
#region Public Methods
/// <summary>
/// Initializes the algorithm with the original mesh.
/// </summary>
/// <param name="mesh">The mesh.</param>
public abstract void Initialize(Mesh mesh);
/// <summary>
/// Decimates the mesh.
/// </summary>
/// <param name="targetTrisCount">The target triangle count.</param>
public abstract void DecimateMesh(int targetTrisCount);
/// <summary>
/// Decimates the mesh without losing any quality.
/// </summary>
public abstract void DecimateMeshLossless();
/// <summary>
/// Returns the resulting mesh.
/// </summary>
/// <returns>The resulting mesh.</returns>
public abstract Mesh ToMesh();
#endregion
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,249 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using MeshDecimator.Math;
namespace MeshDecimator
{
/// <summary>
/// A bone weight.
/// </summary>
public struct BoneWeight : IEquatable<BoneWeight>
{
#region Fields
/// <summary>
/// The first bone index.
/// </summary>
public int boneIndex0;
/// <summary>
/// The second bone index.
/// </summary>
public int boneIndex1;
/// <summary>
/// The third bone index.
/// </summary>
public int boneIndex2;
/// <summary>
/// The fourth bone index.
/// </summary>
public int boneIndex3;
/// <summary>
/// The first bone weight.
/// </summary>
public float boneWeight0;
/// <summary>
/// The second bone weight.
/// </summary>
public float boneWeight1;
/// <summary>
/// The third bone weight.
/// </summary>
public float boneWeight2;
/// <summary>
/// The fourth bone weight.
/// </summary>
public float boneWeight3;
#endregion
#region Constructor
/// <summary>
/// Creates a new bone weight.
/// </summary>
/// <param name="boneIndex0">The first bone index.</param>
/// <param name="boneIndex1">The second bone index.</param>
/// <param name="boneIndex2">The third bone index.</param>
/// <param name="boneIndex3">The fourth bone index.</param>
/// <param name="boneWeight0">The first bone weight.</param>
/// <param name="boneWeight1">The second bone weight.</param>
/// <param name="boneWeight2">The third bone weight.</param>
/// <param name="boneWeight3">The fourth bone weight.</param>
public BoneWeight(int boneIndex0, int boneIndex1, int boneIndex2, int boneIndex3, float boneWeight0, float boneWeight1, float boneWeight2, float boneWeight3)
{
this.boneIndex0 = boneIndex0;
this.boneIndex1 = boneIndex1;
this.boneIndex2 = boneIndex2;
this.boneIndex3 = boneIndex3;
this.boneWeight0 = boneWeight0;
this.boneWeight1 = boneWeight1;
this.boneWeight2 = boneWeight2;
this.boneWeight3 = boneWeight3;
}
#endregion
#region Operators
/// <summary>
/// Returns if two bone weights equals eachother.
/// </summary>
/// <param name="lhs">The left hand side bone weight.</param>
/// <param name="rhs">The right hand side bone weight.</param>
/// <returns>If equals.</returns>
public static bool operator ==(BoneWeight lhs, BoneWeight rhs)
{
return (lhs.boneIndex0 == rhs.boneIndex0 && lhs.boneIndex1 == rhs.boneIndex1 && lhs.boneIndex2 == rhs.boneIndex2 && lhs.boneIndex3 == rhs.boneIndex3 &&
new Vector4(lhs.boneWeight0, lhs.boneWeight1, lhs.boneWeight2, lhs.boneWeight3) == new Vector4(rhs.boneWeight0, rhs.boneWeight1, rhs.boneWeight2, rhs.boneWeight3));
}
/// <summary>
/// Returns if two bone weights don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side bone weight.</param>
/// <param name="rhs">The right hand side bone weight.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(BoneWeight lhs, BoneWeight rhs)
{
return !(lhs == rhs);
}
#endregion
#region Private Methods
private void MergeBoneWeight(int boneIndex, float weight)
{
if (boneIndex == boneIndex0)
{
boneWeight0 = (boneWeight0 + weight) * 0.5f;
}
else if (boneIndex == boneIndex1)
{
boneWeight1 = (boneWeight1 + weight) * 0.5f;
}
else if (boneIndex == boneIndex2)
{
boneWeight2 = (boneWeight2 + weight) * 0.5f;
}
else if (boneIndex == boneIndex3)
{
boneWeight3 = (boneWeight3 + weight) * 0.5f;
}
else if(boneWeight0 == 0f)
{
boneIndex0 = boneIndex;
boneWeight0 = weight;
}
else if (boneWeight1 == 0f)
{
boneIndex1 = boneIndex;
boneWeight1 = weight;
}
else if (boneWeight2 == 0f)
{
boneIndex2 = boneIndex;
boneWeight2 = weight;
}
else if (boneWeight3 == 0f)
{
boneIndex3 = boneIndex;
boneWeight3 = weight;
}
Normalize();
}
private void Normalize()
{
float mag = (float)System.Math.Sqrt(boneWeight0 * boneWeight0 + boneWeight1 * boneWeight1 + boneWeight2 * boneWeight2 + boneWeight3 * boneWeight3);
if (mag > float.Epsilon)
{
boneWeight0 /= mag;
boneWeight1 /= mag;
boneWeight2 /= mag;
boneWeight3 /= mag;
}
else
{
boneWeight0 = boneWeight1 = boneWeight2 = boneWeight3 = 0f;
}
}
#endregion
#region Public Methods
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return boneIndex0.GetHashCode() ^ boneIndex1.GetHashCode() << 2 ^ boneIndex2.GetHashCode() >> 2 ^ boneIndex3.GetHashCode() >>
1 ^ boneWeight0.GetHashCode() << 5 ^ boneWeight1.GetHashCode() << 4 ^ boneWeight2.GetHashCode() >> 4 ^ boneWeight3.GetHashCode() >> 3;
}
/// <summary>
/// Returns if this bone weight is equal to another object.
/// </summary>
/// <param name="obj">The other object to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object obj)
{
if (!(obj is BoneWeight))
{
return false;
}
BoneWeight other = (BoneWeight)obj;
return (boneIndex0 == other.boneIndex0 && boneIndex1 == other.boneIndex1 && boneIndex2 == other.boneIndex2 && boneIndex3 == other.boneIndex3 &&
boneWeight0 == other.boneWeight0 && boneWeight1 == other.boneWeight1 && boneWeight2 == other.boneWeight2 && boneWeight3 == other.boneWeight3);
}
/// <summary>
/// Returns if this bone weight is equal to another one.
/// </summary>
/// <param name="other">The other bone weight to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(BoneWeight other)
{
return (boneIndex0 == other.boneIndex0 && boneIndex1 == other.boneIndex1 && boneIndex2 == other.boneIndex2 && boneIndex3 == other.boneIndex3 &&
boneWeight0 == other.boneWeight0 && boneWeight1 == other.boneWeight1 && boneWeight2 == other.boneWeight2 && boneWeight3 == other.boneWeight3);
}
/// <summary>
/// Returns a nicely formatted string for this bone weight.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}:{4:F1}, {1}:{5:F1}, {2}:{6:F1}, {3}:{7:F1})",
boneIndex0, boneIndex1, boneIndex2, boneIndex3, boneWeight0, boneWeight1, boneWeight2, boneWeight3);
}
#endregion
#region Static
/// <summary>
/// Merges two bone weights and stores the merged result in the first parameter.
/// </summary>
/// <param name="a">The first bone weight, also stores result.</param>
/// <param name="b">The second bone weight.</param>
public static void Merge(ref BoneWeight a, ref BoneWeight b)
{
if (b.boneWeight0 > 0f) a.MergeBoneWeight(b.boneIndex0, b.boneWeight0);
if (b.boneWeight1 > 0f) a.MergeBoneWeight(b.boneIndex1, b.boneWeight1);
if (b.boneWeight2 > 0f) a.MergeBoneWeight(b.boneIndex2, b.boneWeight2);
if (b.boneWeight3 > 0f) a.MergeBoneWeight(b.boneIndex3, b.boneWeight3);
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,179 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
namespace MeshDecimator.Collections
{
/// <summary>
/// A resizable array.
/// </summary>
/// <typeparam name="T">The item type.</typeparam>
internal sealed class ResizableArray<T>
{
#region Fields
private T[] items = null;
private int length = 0;
private static T[] emptyArr = new T[0];
#endregion
#region Properties
/// <summary>
/// Gets the length of this array.
/// </summary>
public int Length
{
get { return length; }
}
/// <summary>
/// Gets the internal data buffer for this array.
/// </summary>
public T[] Data
{
get { return items; }
}
/// <summary>
/// Gets or sets the element value at a specific index.
/// </summary>
/// <param name="index">The element index.</param>
/// <returns>The element value.</returns>
public T this[int index]
{
get { return items[index]; }
set { items[index] = value; }
}
#endregion
#region Constructor
/// <summary>
/// Creates a new resizable array.
/// </summary>
/// <param name="capacity">The initial array capacity.</param>
public ResizableArray(int capacity)
: this(capacity, 0)
{
}
/// <summary>
/// Creates a new resizable array.
/// </summary>
/// <param name="capacity">The initial array capacity.</param>
/// <param name="length">The initial length of the array.</param>
public ResizableArray(int capacity, int length)
{
if (capacity < 0)
throw new ArgumentOutOfRangeException("capacity");
else if (length < 0 || length > capacity)
throw new ArgumentOutOfRangeException("length");
if (capacity > 0)
items = new T[capacity];
else
items = emptyArr;
this.length = length;
}
#endregion
#region Private Methods
private void IncreaseCapacity(int capacity)
{
T[] newItems = new T[capacity];
Array.Copy(items, 0, newItems, 0, System.Math.Min(length, capacity));
items = newItems;
}
#endregion
#region Public Methods
/// <summary>
/// Clears this array.
/// </summary>
public void Clear()
{
Array.Clear(items, 0, length);
length = 0;
}
/// <summary>
/// Resizes this array.
/// </summary>
/// <param name="length">The new length.</param>
/// <param name="trimExess">If exess memory should be trimmed.</param>
public void Resize(int length, bool trimExess = false)
{
if (length < 0)
throw new ArgumentOutOfRangeException("capacity");
if (length > items.Length)
{
IncreaseCapacity(length);
}
else if (length < this.length)
{
//Array.Clear(items, capacity, length - capacity);
}
this.length = length;
if (trimExess)
{
TrimExcess();
}
}
/// <summary>
/// Trims any excess memory for this array.
/// </summary>
public void TrimExcess()
{
if (items.Length == length) // Nothing to do
return;
T[] newItems = new T[length];
Array.Copy(items, 0, newItems, 0, length);
items = newItems;
}
/// <summary>
/// Adds a new item to the end of this array.
/// </summary>
/// <param name="item">The new item.</param>
public void Add(T item)
{
if (length >= items.Length)
{
IncreaseCapacity(items.Length << 1);
}
items[length++] = item;
}
#endregion
}
}

View File

@@ -0,0 +1,79 @@
using System;
namespace MeshDecimator.Collections
{
/// <summary>
/// A collection of UV channels.
/// </summary>
/// <typeparam name="TVec">The UV vector type.</typeparam>
internal sealed class UVChannels<TVec>
{
#region Fields
private ResizableArray<TVec>[] channels = null;
private TVec[][] channelsData = null;
#endregion
#region Properties
/// <summary>
/// Gets the channel collection data.
/// </summary>
public TVec[][] Data
{
get
{
for (int i = 0; i < Mesh.UVChannelCount; i++)
{
if (channels[i] != null)
{
channelsData[i] = channels[i].Data;
}
else
{
channelsData[i] = null;
}
}
return channelsData;
}
}
/// <summary>
/// Gets or sets a specific channel by index.
/// </summary>
/// <param name="index">The channel index.</param>
public ResizableArray<TVec> this[int index]
{
get { return channels[index]; }
set { channels[index] = value; }
}
#endregion
#region Constructor
/// <summary>
/// Creates a new collection of UV channels.
/// </summary>
public UVChannels()
{
channels = new ResizableArray<TVec>[Mesh.UVChannelCount];
channelsData = new TVec[Mesh.UVChannelCount][];
}
#endregion
#region Public Methods
/// <summary>
/// Resizes all channels at once.
/// </summary>
/// <param name="capacity">The new capacity.</param>
/// <param name="trimExess">If exess memory should be trimmed.</param>
public void Resize(int capacity, bool trimExess = false)
{
for (int i = 0; i < Mesh.UVChannelCount; i++)
{
if (channels[i] != null)
{
channels[i].Resize(capacity, trimExess);
}
}
}
#endregion
}
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,286 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
namespace MeshDecimator.Math
{
/// <summary>
/// Math helpers.
/// </summary>
public static class MathHelper
{
#region Consts
/// <summary>
/// The Pi constant.
/// </summary>
public const float PI = 3.14159274f;
/// <summary>
/// The Pi constant.
/// </summary>
public const double PId = 3.1415926535897932384626433832795;
/// <summary>
/// Degrees to radian constant.
/// </summary>
public const float Deg2Rad = PI / 180f;
/// <summary>
/// Degrees to radian constant.
/// </summary>
public const double Deg2Radd = PId / 180.0;
/// <summary>
/// Radians to degrees constant.
/// </summary>
public const float Rad2Deg = 180f / PI;
/// <summary>
/// Radians to degrees constant.
/// </summary>
public const double Rad2Degd = 180.0 / PId;
#endregion
#region Min
/// <summary>
/// Returns the minimum of two values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <returns>The minimum value.</returns>
public static int Min(int val1, int val2)
{
return (val1 < val2 ? val1 : val2);
}
/// <summary>
/// Returns the minimum of three values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <param name="val3">The third value.</param>
/// <returns>The minimum value.</returns>
public static int Min(int val1, int val2, int val3)
{
return (val1 < val2 ? (val1 < val3 ? val1 : val3) : (val2 < val3 ? val2 : val3));
}
/// <summary>
/// Returns the minimum of two values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <returns>The minimum value.</returns>
public static float Min(float val1, float val2)
{
return (val1 < val2 ? val1 : val2);
}
/// <summary>
/// Returns the minimum of three values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <param name="val3">The third value.</param>
/// <returns>The minimum value.</returns>
public static float Min(float val1, float val2, float val3)
{
return (val1 < val2 ? (val1 < val3 ? val1 : val3) : (val2 < val3 ? val2 : val3));
}
/// <summary>
/// Returns the minimum of two values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <returns>The minimum value.</returns>
public static double Min(double val1, double val2)
{
return (val1 < val2 ? val1 : val2);
}
/// <summary>
/// Returns the minimum of three values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <param name="val3">The third value.</param>
/// <returns>The minimum value.</returns>
public static double Min(double val1, double val2, double val3)
{
return (val1 < val2 ? (val1 < val3 ? val1 : val3) : (val2 < val3 ? val2 : val3));
}
#endregion
#region Max
/// <summary>
/// Returns the maximum of two values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <returns>The maximum value.</returns>
public static int Max(int val1, int val2)
{
return (val1 > val2 ? val1 : val2);
}
/// <summary>
/// Returns the maximum of three values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <param name="val3">The third value.</param>
/// <returns>The maximum value.</returns>
public static int Max(int val1, int val2, int val3)
{
return (val1 > val2 ? (val1 > val3 ? val1 : val3) : (val2 > val3 ? val2 : val3));
}
/// <summary>
/// Returns the maximum of two values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <returns>The maximum value.</returns>
public static float Max(float val1, float val2)
{
return (val1 > val2 ? val1 : val2);
}
/// <summary>
/// Returns the maximum of three values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <param name="val3">The third value.</param>
/// <returns>The maximum value.</returns>
public static float Max(float val1, float val2, float val3)
{
return (val1 > val2 ? (val1 > val3 ? val1 : val3) : (val2 > val3 ? val2 : val3));
}
/// <summary>
/// Returns the maximum of two values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <returns>The maximum value.</returns>
public static double Max(double val1, double val2)
{
return (val1 > val2 ? val1 : val2);
}
/// <summary>
/// Returns the maximum of three values.
/// </summary>
/// <param name="val1">The first value.</param>
/// <param name="val2">The second value.</param>
/// <param name="val3">The third value.</param>
/// <returns>The maximum value.</returns>
public static double Max(double val1, double val2, double val3)
{
return (val1 > val2 ? (val1 > val3 ? val1 : val3) : (val2 > val3 ? val2 : val3));
}
#endregion
#region Clamping
/// <summary>
/// Clamps a value between a minimum and a maximum value.
/// </summary>
/// <param name="value">The value to clamp.</param>
/// <param name="min">The minimum value.</param>
/// <param name="max">The maximum value.</param>
/// <returns>The clamped value.</returns>
public static float Clamp(float value, float min, float max)
{
return (value >= min ? (value <= max ? value : max) : min);
}
/// <summary>
/// Clamps a value between a minimum and a maximum value.
/// </summary>
/// <param name="value">The value to clamp.</param>
/// <param name="min">The minimum value.</param>
/// <param name="max">The maximum value.</param>
/// <returns>The clamped value.</returns>
public static double Clamp(double value, double min, double max)
{
return (value >= min ? (value <= max ? value : max) : min);
}
/// <summary>
/// Clamps the value between 0 and 1.
/// </summary>
/// <param name="value">The value to clamp.</param>
/// <returns>The clamped value.</returns>
public static float Clamp01(float value)
{
return (value > 0f ? (value < 1f ? value : 1f) : 0f);
}
/// <summary>
/// Clamps the value between 0 and 1.
/// </summary>
/// <param name="value">The value to clamp.</param>
/// <returns>The clamped value.</returns>
public static double Clamp01(double value)
{
return (value > 0.0 ? (value < 1.0 ? value : 1.0) : 0.0);
}
#endregion
#region Triangle Area
/// <summary>
/// Calculates the area of a triangle.
/// </summary>
/// <param name="p0">The first point.</param>
/// <param name="p1">The second point.</param>
/// <param name="p2">The third point.</param>
/// <returns>The triangle area.</returns>
public static float TriangleArea(ref Vector3 p0, ref Vector3 p1, ref Vector3 p2)
{
var dx = p1 - p0;
var dy = p2 - p0;
return dx.Magnitude * ((float)System.Math.Sin(Vector3.Angle(ref dx, ref dy) * Deg2Rad) * dy.Magnitude) * 0.5f;
}
/// <summary>
/// Calculates the area of a triangle.
/// </summary>
/// <param name="p0">The first point.</param>
/// <param name="p1">The second point.</param>
/// <param name="p2">The third point.</param>
/// <returns>The triangle area.</returns>
public static double TriangleArea(ref Vector3d p0, ref Vector3d p1, ref Vector3d p2)
{
var dx = p1 - p0;
var dy = p2 - p0;
return dx.Magnitude * (System.Math.Sin(Vector3d.Angle(ref dx, ref dy) * Deg2Radd) * dy.Magnitude) * 0.5f;
}
#endregion
}
}

View File

@@ -0,0 +1,303 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
namespace MeshDecimator.Math
{
/// <summary>
/// A symmetric matrix.
/// </summary>
public struct SymmetricMatrix
{
#region Fields
/// <summary>
/// The m11 component.
/// </summary>
public double m0;
/// <summary>
/// The m12 component.
/// </summary>
public double m1;
/// <summary>
/// The m13 component.
/// </summary>
public double m2;
/// <summary>
/// The m14 component.
/// </summary>
public double m3;
/// <summary>
/// The m22 component.
/// </summary>
public double m4;
/// <summary>
/// The m23 component.
/// </summary>
public double m5;
/// <summary>
/// The m24 component.
/// </summary>
public double m6;
/// <summary>
/// The m33 component.
/// </summary>
public double m7;
/// <summary>
/// The m34 component.
/// </summary>
public double m8;
/// <summary>
/// The m44 component.
/// </summary>
public double m9;
#endregion
#region Properties
/// <summary>
/// Gets the component value with a specific index.
/// </summary>
/// <param name="index">The component index.</param>
/// <returns>The value.</returns>
public double this[int index]
{
get
{
switch (index)
{
case 0:
return m0;
case 1:
return m1;
case 2:
return m2;
case 3:
return m3;
case 4:
return m4;
case 5:
return m5;
case 6:
return m6;
case 7:
return m7;
case 8:
return m8;
case 9:
return m9;
default:
throw new IndexOutOfRangeException();
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a symmetric matrix with a value in each component.
/// </summary>
/// <param name="c">The component value.</param>
public SymmetricMatrix(double c)
{
this.m0 = c;
this.m1 = c;
this.m2 = c;
this.m3 = c;
this.m4 = c;
this.m5 = c;
this.m6 = c;
this.m7 = c;
this.m8 = c;
this.m9 = c;
}
/// <summary>
/// Creates a symmetric matrix.
/// </summary>
/// <param name="m0">The m11 component.</param>
/// <param name="m1">The m12 component.</param>
/// <param name="m2">The m13 component.</param>
/// <param name="m3">The m14 component.</param>
/// <param name="m4">The m22 component.</param>
/// <param name="m5">The m23 component.</param>
/// <param name="m6">The m24 component.</param>
/// <param name="m7">The m33 component.</param>
/// <param name="m8">The m34 component.</param>
/// <param name="m9">The m44 component.</param>
public SymmetricMatrix(double m0, double m1, double m2, double m3,
double m4, double m5, double m6, double m7, double m8, double m9)
{
this.m0 = m0;
this.m1 = m1;
this.m2 = m2;
this.m3 = m3;
this.m4 = m4;
this.m5 = m5;
this.m6 = m6;
this.m7 = m7;
this.m8 = m8;
this.m9 = m9;
}
/// <summary>
/// Creates a symmetric matrix from a plane.
/// </summary>
/// <param name="a">The plane x-component.</param>
/// <param name="b">The plane y-component</param>
/// <param name="c">The plane z-component</param>
/// <param name="d">The plane w-component</param>
public SymmetricMatrix(double a, double b, double c, double d)
{
this.m0 = a * a;
this.m1 = a * b;
this.m2 = a * c;
this.m3 = a * d;
this.m4 = b * b;
this.m5 = b * c;
this.m6 = b * d;
this.m7 = c * c;
this.m8 = c * d;
this.m9 = d * d;
}
#endregion
#region Operators
/// <summary>
/// Adds two matrixes together.
/// </summary>
/// <param name="a">The left hand side.</param>
/// <param name="b">The right hand side.</param>
/// <returns>The resulting matrix.</returns>
public static SymmetricMatrix operator +(SymmetricMatrix a, SymmetricMatrix b)
{
return new SymmetricMatrix(
a.m0 + b.m0, a.m1 + b.m1, a.m2 + b.m2, a.m3 + b.m3,
a.m4 + b.m4, a.m5 + b.m5, a.m6 + b.m6,
a.m7 + b.m7, a.m8 + b.m8,
a.m9 + b.m9
);
}
#endregion
#region Internal Methods
/// <summary>
/// Determinant(0, 1, 2, 1, 4, 5, 2, 5, 7)
/// </summary>
/// <returns></returns>
internal double Determinant1()
{
double det =
m0 * m4 * m7 +
m2 * m1 * m5 +
m1 * m5 * m2 -
m2 * m4 * m2 -
m0 * m5 * m5 -
m1 * m1 * m7;
return det;
}
/// <summary>
/// Determinant(1, 2, 3, 4, 5, 6, 5, 7, 8)
/// </summary>
/// <returns></returns>
internal double Determinant2()
{
double det =
m1 * m5 * m8 +
m3 * m4 * m7 +
m2 * m6 * m5 -
m3 * m5 * m5 -
m1 * m6 * m7 -
m2 * m4 * m8;
return det;
}
/// <summary>
/// Determinant(0, 2, 3, 1, 5, 6, 2, 7, 8)
/// </summary>
/// <returns></returns>
internal double Determinant3()
{
double det =
m0 * m5 * m8 +
m3 * m1 * m7 +
m2 * m6 * m2 -
m3 * m5 * m2 -
m0 * m6 * m7 -
m2 * m1 * m8;
return det;
}
/// <summary>
/// Determinant(0, 1, 3, 1, 4, 6, 2, 5, 8)
/// </summary>
/// <returns></returns>
internal double Determinant4()
{
double det =
m0 * m4 * m8 +
m3 * m1 * m5 +
m1 * m6 * m2 -
m3 * m4 * m2 -
m0 * m6 * m5 -
m1 * m1 * m8;
return det;
}
#endregion
#region Public Methods
/// <summary>
/// Computes the determinant of this matrix.
/// </summary>
/// <param name="a11">The a11 index.</param>
/// <param name="a12">The a12 index.</param>
/// <param name="a13">The a13 index.</param>
/// <param name="a21">The a21 index.</param>
/// <param name="a22">The a22 index.</param>
/// <param name="a23">The a23 index.</param>
/// <param name="a31">The a31 index.</param>
/// <param name="a32">The a32 index.</param>
/// <param name="a33">The a33 index.</param>
/// <returns>The determinant value.</returns>
public double Determinant(int a11, int a12, int a13,
int a21, int a22, int a23,
int a31, int a32, int a33)
{
double det =
this[a11] * this[a22] * this[a33] +
this[a13] * this[a21] * this[a32] +
this[a12] * this[a23] * this[a31] -
this[a13] * this[a22] * this[a31] -
this[a11] * this[a23] * this[a32] -
this[a12] * this[a21] * this[a33];
return det;
}
#endregion
}
}

View File

@@ -0,0 +1,425 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A single precision 2D vector.
/// </summary>
public struct Vector2 : IEquatable<Vector2>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector2 zero = new Vector2(0, 0);
#endregion
#region Consts
/// <summary>
/// The vector epsilon.
/// </summary>
public const float Epsilon = 9.99999944E-11f;
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public float x;
/// <summary>
/// The y component.
/// </summary>
public float y;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public float Magnitude
{
get { return (float)System.Math.Sqrt(x * x + y * y); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public float MagnitudeSqr
{
get { return (x * x + y * y); }
}
/// <summary>
/// Gets a normalized vector from this vector.
/// </summary>
public Vector2 Normalized
{
get
{
Vector2 result;
Normalize(ref this, out result);
return result;
}
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public float this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
default:
throw new IndexOutOfRangeException("Invalid Vector2 index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector2 index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector2(float value)
{
this.x = value;
this.y = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
public Vector2(float x, float y)
{
this.x = x;
this.y = y;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2 operator +(Vector2 a, Vector2 b)
{
return new Vector2(a.x + b.x, a.y + b.y);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2 operator -(Vector2 a, Vector2 b)
{
return new Vector2(a.x - b.x, a.y - b.y);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector2 operator *(Vector2 a, float d)
{
return new Vector2(a.x * d, a.y * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2 operator *(float d, Vector2 a)
{
return new Vector2(a.x * d, a.y * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector2 operator /(Vector2 a, float d)
{
return new Vector2(a.x / d, a.y / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2 operator -(Vector2 a)
{
return new Vector2(-a.x, -a.y);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector2 lhs, Vector2 rhs)
{
return (lhs - rhs).MagnitudeSqr < Epsilon;
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector2 lhs, Vector2 rhs)
{
return (lhs - rhs).MagnitudeSqr >= Epsilon;
}
/// <summary>
/// Explicitly converts from a double-precision vector into a single-precision vector.
/// </summary>
/// <param name="v">The double-precision vector.</param>
public static explicit operator Vector2(Vector2d v)
{
return new Vector2((float)v.x, (float)v.y);
}
/// <summary>
/// Implicitly converts from an integer vector into a single-precision vector.
/// </summary>
/// <param name="v">The integer vector.</param>
public static implicit operator Vector2(Vector2i v)
{
return new Vector2(v.x, v.y);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x and y components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
public void Set(float x, float y)
{
this.x = x;
this.y = y;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector2 scale)
{
x *= scale.x;
y *= scale.y;
}
/// <summary>
/// Normalizes this vector.
/// </summary>
public void Normalize()
{
float mag = this.Magnitude;
if (mag > Epsilon)
{
x /= mag;
y /= mag;
}
else
{
x = y = 0;
}
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(float min, float max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector2))
{
return false;
}
Vector2 vector = (Vector2)other;
return (x == vector.x && y == vector.y);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector2 other)
{
return (x == other.x && y == other.y);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1})",
x.ToString("F1", CultureInfo.InvariantCulture),
y.ToString("F1", CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The float format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Dot Product of two vectors.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
public static float Dot(ref Vector2 lhs, ref Vector2 rhs)
{
return lhs.x * rhs.x + lhs.y * rhs.y;
}
/// <summary>
/// Performs a linear interpolation between two vectors.
/// </summary>
/// <param name="a">The vector to interpolate from.</param>
/// <param name="b">The vector to interpolate to.</param>
/// <param name="t">The time fraction.</param>
/// <param name="result">The resulting vector.</param>
public static void Lerp(ref Vector2 a, ref Vector2 b, float t, out Vector2 result)
{
result = new Vector2(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
}
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector2 a, ref Vector2 b, out Vector2 result)
{
result = new Vector2(a.x * b.x, a.y * b.y);
}
/// <summary>
/// Normalizes a vector.
/// </summary>
/// <param name="value">The vector to normalize.</param>
/// <param name="result">The resulting normalized vector.</param>
public static void Normalize(ref Vector2 value, out Vector2 result)
{
float mag = value.Magnitude;
if (mag > Epsilon)
{
result = new Vector2(value.x / mag, value.y / mag);
}
else
{
result = Vector2.zero;
}
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,425 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A double precision 2D vector.
/// </summary>
public struct Vector2d : IEquatable<Vector2d>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector2d zero = new Vector2d(0, 0);
#endregion
#region Consts
/// <summary>
/// The vector epsilon.
/// </summary>
public const double Epsilon = double.Epsilon;
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public double x;
/// <summary>
/// The y component.
/// </summary>
public double y;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public double Magnitude
{
get { return System.Math.Sqrt(x * x + y * y); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public double MagnitudeSqr
{
get { return (x * x + y * y); }
}
/// <summary>
/// Gets a normalized vector from this vector.
/// </summary>
public Vector2d Normalized
{
get
{
Vector2d result;
Normalize(ref this, out result);
return result;
}
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public double this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
default:
throw new IndexOutOfRangeException("Invalid Vector2d index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector2d index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector2d(double value)
{
this.x = value;
this.y = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
public Vector2d(double x, double y)
{
this.x = x;
this.y = y;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2d operator +(Vector2d a, Vector2d b)
{
return new Vector2d(a.x + b.x, a.y + b.y);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2d operator -(Vector2d a, Vector2d b)
{
return new Vector2d(a.x - b.x, a.y - b.y);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector2d operator *(Vector2d a, double d)
{
return new Vector2d(a.x * d, a.y * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2d operator *(double d, Vector2d a)
{
return new Vector2d(a.x * d, a.y * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector2d operator /(Vector2d a, double d)
{
return new Vector2d(a.x / d, a.y / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2d operator -(Vector2d a)
{
return new Vector2d(-a.x, -a.y);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector2d lhs, Vector2d rhs)
{
return (lhs - rhs).MagnitudeSqr < Epsilon;
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector2d lhs, Vector2d rhs)
{
return (lhs - rhs).MagnitudeSqr >= Epsilon;
}
/// <summary>
/// Implicitly converts from a single-precision vector into a double-precision vector.
/// </summary>
/// <param name="v">The single-precision vector.</param>
public static implicit operator Vector2d(Vector2 v)
{
return new Vector2d(v.x, v.y);
}
/// <summary>
/// Implicitly converts from an integer vector into a double-precision vector.
/// </summary>
/// <param name="v">The integer vector.</param>
public static implicit operator Vector2d(Vector2i v)
{
return new Vector2d(v.x, v.y);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x and y components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
public void Set(double x, double y)
{
this.x = x;
this.y = y;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector2d scale)
{
x *= scale.x;
y *= scale.y;
}
/// <summary>
/// Normalizes this vector.
/// </summary>
public void Normalize()
{
double mag = this.Magnitude;
if (mag > Epsilon)
{
x /= mag;
y /= mag;
}
else
{
x = y = 0;
}
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(double min, double max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector2d))
{
return false;
}
Vector2d vector = (Vector2d)other;
return (x == vector.x && y == vector.y);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector2d other)
{
return (x == other.x && y == other.y);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1})",
x.ToString("F1", CultureInfo.InvariantCulture),
y.ToString("F1", CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The float format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Dot Product of two vectors.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
public static double Dot(ref Vector2d lhs, ref Vector2d rhs)
{
return lhs.x * rhs.x + lhs.y * rhs.y;
}
/// <summary>
/// Performs a linear interpolation between two vectors.
/// </summary>
/// <param name="a">The vector to interpolate from.</param>
/// <param name="b">The vector to interpolate to.</param>
/// <param name="t">The time fraction.</param>
/// <param name="result">The resulting vector.</param>
public static void Lerp(ref Vector2d a, ref Vector2d b, double t, out Vector2d result)
{
result = new Vector2d(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
}
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector2d a, ref Vector2d b, out Vector2d result)
{
result = new Vector2d(a.x * b.x, a.y * b.y);
}
/// <summary>
/// Normalizes a vector.
/// </summary>
/// <param name="value">The vector to normalize.</param>
/// <param name="result">The resulting normalized vector.</param>
public static void Normalize(ref Vector2d value, out Vector2d result)
{
double mag = value.Magnitude;
if (mag > Epsilon)
{
result = new Vector2d(value.x / mag, value.y / mag);
}
else
{
result = Vector2d.zero;
}
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,348 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A 2D integer vector.
/// </summary>
public struct Vector2i : IEquatable<Vector2i>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector2i zero = new Vector2i(0, 0);
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public int x;
/// <summary>
/// The y component.
/// </summary>
public int y;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public int Magnitude
{
get { return (int)System.Math.Sqrt(x * x + y * y); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public int MagnitudeSqr
{
get { return (x * x + y * y); }
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public int this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
default:
throw new IndexOutOfRangeException("Invalid Vector2i index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector2i index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector2i(int value)
{
this.x = value;
this.y = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
public Vector2i(int x, int y)
{
this.x = x;
this.y = y;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2i operator +(Vector2i a, Vector2i b)
{
return new Vector2i(a.x + b.x, a.y + b.y);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2i operator -(Vector2i a, Vector2i b)
{
return new Vector2i(a.x - b.x, a.y - b.y);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector2i operator *(Vector2i a, int d)
{
return new Vector2i(a.x * d, a.y * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2i operator *(int d, Vector2i a)
{
return new Vector2i(a.x * d, a.y * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector2i operator /(Vector2i a, int d)
{
return new Vector2i(a.x / d, a.y / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector2i operator -(Vector2i a)
{
return new Vector2i(-a.x, -a.y);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector2i lhs, Vector2i rhs)
{
return (lhs.x == rhs.x && lhs.y == rhs.y);
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector2i lhs, Vector2i rhs)
{
return (lhs.x != rhs.x || lhs.y != rhs.y);
}
/// <summary>
/// Explicitly converts from a single-precision vector into an integer vector.
/// </summary>
/// <param name="v">The single-precision vector.</param>
public static explicit operator Vector2i(Vector2 v)
{
return new Vector2i((int)v.x, (int)v.y);
}
/// <summary>
/// Explicitly converts from a double-precision vector into an integer vector.
/// </summary>
/// <param name="v">The double-precision vector.</param>
public static explicit operator Vector2i(Vector2d v)
{
return new Vector2i((int)v.x, (int)v.y);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x and y components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
public void Set(int x, int y)
{
this.x = x;
this.y = y;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector2i scale)
{
x *= scale.x;
y *= scale.y;
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(int min, int max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector2i))
{
return false;
}
Vector2i vector = (Vector2i)other;
return (x == vector.x && y == vector.y);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector2i other)
{
return (x == other.x && y == other.y);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1})",
x.ToString(CultureInfo.InvariantCulture),
y.ToString(CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The integer format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector2i a, ref Vector2i b, out Vector2i result)
{
result = new Vector2i(a.x * b.x, a.y * b.y);
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,494 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A single precision 3D vector.
/// </summary>
public struct Vector3 : IEquatable<Vector3>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector3 zero = new Vector3(0, 0, 0);
#endregion
#region Consts
/// <summary>
/// The vector epsilon.
/// </summary>
public const float Epsilon = 9.99999944E-11f;
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public float x;
/// <summary>
/// The y component.
/// </summary>
public float y;
/// <summary>
/// The z component.
/// </summary>
public float z;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public float Magnitude
{
get { return (float)System.Math.Sqrt(x * x + y * y + z * z); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public float MagnitudeSqr
{
get { return (x * x + y * y + z * z); }
}
/// <summary>
/// Gets a normalized vector from this vector.
/// </summary>
public Vector3 Normalized
{
get
{
Vector3 result;
Normalize(ref this, out result);
return result;
}
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public float this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
case 2:
return z;
default:
throw new IndexOutOfRangeException("Invalid Vector3 index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
case 2:
z = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector3 index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector3(float value)
{
this.x = value;
this.y = value;
this.z = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
public Vector3(float x, float y, float z)
{
this.x = x;
this.y = y;
this.z = z;
}
/// <summary>
/// Creates a new vector from a double precision vector.
/// </summary>
/// <param name="vector">The double precision vector.</param>
public Vector3(Vector3d vector)
{
this.x = (float)vector.x;
this.y = (float)vector.y;
this.z = (float)vector.z;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3 operator +(Vector3 a, Vector3 b)
{
return new Vector3(a.x + b.x, a.y + b.y, a.z + b.z);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3 operator -(Vector3 a, Vector3 b)
{
return new Vector3(a.x - b.x, a.y - b.y, a.z - b.z);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector3 operator *(Vector3 a, float d)
{
return new Vector3(a.x * d, a.y * d, a.z * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3 operator *(float d, Vector3 a)
{
return new Vector3(a.x * d, a.y * d, a.z * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector3 operator /(Vector3 a, float d)
{
return new Vector3(a.x / d, a.y / d, a.z / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3 operator -(Vector3 a)
{
return new Vector3(-a.x, -a.y, -a.z);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector3 lhs, Vector3 rhs)
{
return (lhs - rhs).MagnitudeSqr < Epsilon;
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector3 lhs, Vector3 rhs)
{
return (lhs - rhs).MagnitudeSqr >= Epsilon;
}
/// <summary>
/// Explicitly converts from a double-precision vector into a single-precision vector.
/// </summary>
/// <param name="v">The double-precision vector.</param>
public static explicit operator Vector3(Vector3d v)
{
return new Vector3((float)v.x, (float)v.y, (float)v.z);
}
/// <summary>
/// Implicitly converts from an integer vector into a single-precision vector.
/// </summary>
/// <param name="v">The integer vector.</param>
public static implicit operator Vector3(Vector3i v)
{
return new Vector3(v.x, v.y, v.z);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x, y and z components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
public void Set(float x, float y, float z)
{
this.x = x;
this.y = y;
this.z = z;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector3 scale)
{
x *= scale.x;
y *= scale.y;
z *= scale.z;
}
/// <summary>
/// Normalizes this vector.
/// </summary>
public void Normalize()
{
float mag = this.Magnitude;
if (mag > Epsilon)
{
x /= mag;
y /= mag;
z /= mag;
}
else
{
x = y = z = 0;
}
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(float min, float max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
if (z < min) z = min;
else if (z > max) z = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector3))
{
return false;
}
Vector3 vector = (Vector3)other;
return (x == vector.x && y == vector.y && z == vector.z);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector3 other)
{
return (x == other.x && y == other.y && z == other.z);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1}, {2})",
x.ToString("F1", CultureInfo.InvariantCulture),
y.ToString("F1", CultureInfo.InvariantCulture),
z.ToString("F1", CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The float format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1}, {2})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture),
z.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Dot Product of two vectors.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
public static float Dot(ref Vector3 lhs, ref Vector3 rhs)
{
return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z;
}
/// <summary>
/// Cross Product of two vectors.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Cross(ref Vector3 lhs, ref Vector3 rhs, out Vector3 result)
{
result = new Vector3(lhs.y * rhs.z - lhs.z * rhs.y, lhs.z * rhs.x - lhs.x * rhs.z, lhs.x * rhs.y - lhs.y * rhs.x);
}
/// <summary>
/// Calculates the angle between two vectors.
/// </summary>
/// <param name="from">The from vector.</param>
/// <param name="to">The to vector.</param>
/// <returns>The angle.</returns>
public static float Angle(ref Vector3 from, ref Vector3 to)
{
Vector3 fromNormalized = from.Normalized;
Vector3 toNormalized = to.Normalized;
return (float)System.Math.Acos(MathHelper.Clamp(Vector3.Dot(ref fromNormalized, ref toNormalized), -1f, 1f)) * MathHelper.Rad2Deg;
}
/// <summary>
/// Performs a linear interpolation between two vectors.
/// </summary>
/// <param name="a">The vector to interpolate from.</param>
/// <param name="b">The vector to interpolate to.</param>
/// <param name="t">The time fraction.</param>
/// <param name="result">The resulting vector.</param>
public static void Lerp(ref Vector3 a, ref Vector3 b, float t, out Vector3 result)
{
result = new Vector3(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t);
}
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector3 a, ref Vector3 b, out Vector3 result)
{
result = new Vector3(a.x * b.x, a.y * b.y, a.z * b.z);
}
/// <summary>
/// Normalizes a vector.
/// </summary>
/// <param name="value">The vector to normalize.</param>
/// <param name="result">The resulting normalized vector.</param>
public static void Normalize(ref Vector3 value, out Vector3 result)
{
float mag = value.Magnitude;
if (mag > Epsilon)
{
result = new Vector3(value.x / mag, value.y / mag, value.z / mag);
}
else
{
result = Vector3.zero;
}
}
/// <summary>
/// Normalizes both vectors and makes them orthogonal to each other.
/// </summary>
/// <param name="normal">The normal vector.</param>
/// <param name="tangent">The tangent.</param>
public static void OrthoNormalize(ref Vector3 normal, ref Vector3 tangent)
{
normal.Normalize();
Vector3 proj = normal * Vector3.Dot(ref tangent, ref normal);
tangent -= proj;
tangent.Normalize();
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,481 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A double precision 3D vector.
/// </summary>
public struct Vector3d : IEquatable<Vector3d>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector3d zero = new Vector3d(0, 0, 0);
#endregion
#region Consts
/// <summary>
/// The vector epsilon.
/// </summary>
public const double Epsilon = double.Epsilon;
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public double x;
/// <summary>
/// The y component.
/// </summary>
public double y;
/// <summary>
/// The z component.
/// </summary>
public double z;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public double Magnitude
{
get { return System.Math.Sqrt(x * x + y * y + z * z); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public double MagnitudeSqr
{
get { return (x * x + y * y + z * z); }
}
/// <summary>
/// Gets a normalized vector from this vector.
/// </summary>
public Vector3d Normalized
{
get
{
Vector3d result;
Normalize(ref this, out result);
return result;
}
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public double this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
case 2:
return z;
default:
throw new IndexOutOfRangeException("Invalid Vector3d index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
case 2:
z = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector3d index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector3d(double value)
{
this.x = value;
this.y = value;
this.z = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
public Vector3d(double x, double y, double z)
{
this.x = x;
this.y = y;
this.z = z;
}
/// <summary>
/// Creates a new vector from a single precision vector.
/// </summary>
/// <param name="vector">The single precision vector.</param>
public Vector3d(Vector3 vector)
{
this.x = vector.x;
this.y = vector.y;
this.z = vector.z;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3d operator +(Vector3d a, Vector3d b)
{
return new Vector3d(a.x + b.x, a.y + b.y, a.z + b.z);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3d operator -(Vector3d a, Vector3d b)
{
return new Vector3d(a.x - b.x, a.y - b.y, a.z - b.z);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector3d operator *(Vector3d a, double d)
{
return new Vector3d(a.x * d, a.y * d, a.z * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3d operator *(double d, Vector3d a)
{
return new Vector3d(a.x * d, a.y * d, a.z * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector3d operator /(Vector3d a, double d)
{
return new Vector3d(a.x / d, a.y / d, a.z / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3d operator -(Vector3d a)
{
return new Vector3d(-a.x, -a.y, -a.z);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector3d lhs, Vector3d rhs)
{
return (lhs - rhs).MagnitudeSqr < Epsilon;
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector3d lhs, Vector3d rhs)
{
return (lhs - rhs).MagnitudeSqr >= Epsilon;
}
/// <summary>
/// Implicitly converts from a single-precision vector into a double-precision vector.
/// </summary>
/// <param name="v">The single-precision vector.</param>
public static implicit operator Vector3d(Vector3 v)
{
return new Vector3d(v.x, v.y, v.z);
}
/// <summary>
/// Implicitly converts from an integer vector into a double-precision vector.
/// </summary>
/// <param name="v">The integer vector.</param>
public static implicit operator Vector3d(Vector3i v)
{
return new Vector3d(v.x, v.y, v.z);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x, y and z components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
public void Set(double x, double y, double z)
{
this.x = x;
this.y = y;
this.z = z;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector3d scale)
{
x *= scale.x;
y *= scale.y;
z *= scale.z;
}
/// <summary>
/// Normalizes this vector.
/// </summary>
public void Normalize()
{
double mag = this.Magnitude;
if (mag > Epsilon)
{
x /= mag;
y /= mag;
z /= mag;
}
else
{
x = y = z = 0;
}
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(double min, double max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
if (z < min) z = min;
else if (z > max) z = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector3d))
{
return false;
}
Vector3d vector = (Vector3d)other;
return (x == vector.x && y == vector.y && z == vector.z);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector3d other)
{
return (x == other.x && y == other.y && z == other.z);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1}, {2})",
x.ToString("F1", CultureInfo.InvariantCulture),
y.ToString("F1", CultureInfo.InvariantCulture),
z.ToString("F1", CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The float format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1}, {2})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture),
z.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Dot Product of two vectors.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
public static double Dot(ref Vector3d lhs, ref Vector3d rhs)
{
return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z;
}
/// <summary>
/// Cross Product of two vectors.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Cross(ref Vector3d lhs, ref Vector3d rhs, out Vector3d result)
{
result = new Vector3d(lhs.y * rhs.z - lhs.z * rhs.y, lhs.z * rhs.x - lhs.x * rhs.z, lhs.x * rhs.y - lhs.y * rhs.x);
}
/// <summary>
/// Calculates the angle between two vectors.
/// </summary>
/// <param name="from">The from vector.</param>
/// <param name="to">The to vector.</param>
/// <returns>The angle.</returns>
public static double Angle(ref Vector3d from, ref Vector3d to)
{
Vector3d fromNormalized = from.Normalized;
Vector3d toNormalized = to.Normalized;
return System.Math.Acos(MathHelper.Clamp(Vector3d.Dot(ref fromNormalized, ref toNormalized), -1.0, 1.0)) * MathHelper.Rad2Degd;
}
/// <summary>
/// Performs a linear interpolation between two vectors.
/// </summary>
/// <param name="a">The vector to interpolate from.</param>
/// <param name="b">The vector to interpolate to.</param>
/// <param name="t">The time fraction.</param>
/// <param name="result">The resulting vector.</param>
public static void Lerp(ref Vector3d a, ref Vector3d b, double t, out Vector3d result)
{
result = new Vector3d(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t);
}
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector3d a, ref Vector3d b, out Vector3d result)
{
result = new Vector3d(a.x * b.x, a.y * b.y, a.z * b.z);
}
/// <summary>
/// Normalizes a vector.
/// </summary>
/// <param name="value">The vector to normalize.</param>
/// <param name="result">The resulting normalized vector.</param>
public static void Normalize(ref Vector3d value, out Vector3d result)
{
double mag = value.Magnitude;
if (mag > Epsilon)
{
result = new Vector3d(value.x / mag, value.y / mag, value.z / mag);
}
else
{
result = Vector3d.zero;
}
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,368 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A 3D integer vector.
/// </summary>
public struct Vector3i : IEquatable<Vector3i>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector3i zero = new Vector3i(0, 0, 0);
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public int x;
/// <summary>
/// The y component.
/// </summary>
public int y;
/// <summary>
/// The z component.
/// </summary>
public int z;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public int Magnitude
{
get { return (int)System.Math.Sqrt(x * x + y * y + z * z); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public int MagnitudeSqr
{
get { return (x * x + y * y + z * z); }
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public int this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
case 2:
return z;
default:
throw new IndexOutOfRangeException("Invalid Vector3i index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
case 2:
z = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector3i index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector3i(int value)
{
this.x = value;
this.y = value;
this.z = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
public Vector3i(int x, int y, int z)
{
this.x = x;
this.y = y;
this.z = z;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3i operator +(Vector3i a, Vector3i b)
{
return new Vector3i(a.x + b.x, a.y + b.y, a.z + b.z);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3i operator -(Vector3i a, Vector3i b)
{
return new Vector3i(a.x - b.x, a.y - b.y, a.z - b.z);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector3i operator *(Vector3i a, int d)
{
return new Vector3i(a.x * d, a.y * d, a.z * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3i operator *(int d, Vector3i a)
{
return new Vector3i(a.x * d, a.y * d, a.z * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector3i operator /(Vector3i a, int d)
{
return new Vector3i(a.x / d, a.y / d, a.z / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector3i operator -(Vector3i a)
{
return new Vector3i(-a.x, -a.y, -a.z);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector3i lhs, Vector3i rhs)
{
return (lhs.x == rhs.x && lhs.y == rhs.y && lhs.z == rhs.z);
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector3i lhs, Vector3i rhs)
{
return (lhs.x != rhs.x || lhs.y != rhs.y || lhs.z != rhs.z);
}
/// <summary>
/// Explicitly converts from a single-precision vector into an integer vector.
/// </summary>
/// <param name="v">The single-precision vector.</param>
public static implicit operator Vector3i(Vector3 v)
{
return new Vector3i((int)v.x, (int)v.y, (int)v.z);
}
/// <summary>
/// Explicitly converts from a double-precision vector into an integer vector.
/// </summary>
/// <param name="v">The double-precision vector.</param>
public static explicit operator Vector3i(Vector3d v)
{
return new Vector3i((int)v.x, (int)v.y, (int)v.z);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x, y and z components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
public void Set(int x, int y, int z)
{
this.x = x;
this.y = y;
this.z = z;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector3i scale)
{
x *= scale.x;
y *= scale.y;
z *= scale.z;
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(int min, int max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
if (z < min) z = min;
else if (z > max) z = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector3i))
{
return false;
}
Vector3i vector = (Vector3i)other;
return (x == vector.x && y == vector.y && z == vector.z);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector3i other)
{
return (x == other.x && y == other.y && z == other.z);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1}, {2})",
x.ToString(CultureInfo.InvariantCulture),
y.ToString(CultureInfo.InvariantCulture),
z.ToString(CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The integer format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1}, {2})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture),
z.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector3i a, ref Vector3i b, out Vector3i result)
{
result = new Vector3i(a.x * b.x, a.y * b.y, a.z * b.z);
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,467 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A single precision 4D vector.
/// </summary>
public struct Vector4 : IEquatable<Vector4>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector4 zero = new Vector4(0, 0, 0, 0);
#endregion
#region Consts
/// <summary>
/// The vector epsilon.
/// </summary>
public const float Epsilon = 9.99999944E-11f;
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public float x;
/// <summary>
/// The y component.
/// </summary>
public float y;
/// <summary>
/// The z component.
/// </summary>
public float z;
/// <summary>
/// The w component.
/// </summary>
public float w;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public float Magnitude
{
get { return (float)System.Math.Sqrt(x * x + y * y + z * z + w * w); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public float MagnitudeSqr
{
get { return (x * x + y * y + z * z + w * w); }
}
/// <summary>
/// Gets a normalized vector from this vector.
/// </summary>
public Vector4 Normalized
{
get
{
Vector4 result;
Normalize(ref this, out result);
return result;
}
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public float this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
case 2:
return z;
case 3:
return w;
default:
throw new IndexOutOfRangeException("Invalid Vector4 index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
case 2:
z = value;
break;
case 3:
w = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector4 index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector4(float value)
{
this.x = value;
this.y = value;
this.z = value;
this.w = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
/// <param name="w">The w value.</param>
public Vector4(float x, float y, float z, float w)
{
this.x = x;
this.y = y;
this.z = z;
this.w = w;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4 operator +(Vector4 a, Vector4 b)
{
return new Vector4(a.x + b.x, a.y + b.y, a.z + b.z, a.w + b.w);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4 operator -(Vector4 a, Vector4 b)
{
return new Vector4(a.x - b.x, a.y - b.y, a.z - b.z, a.w - b.w);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector4 operator *(Vector4 a, float d)
{
return new Vector4(a.x * d, a.y * d, a.z * d, a.w * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4 operator *(float d, Vector4 a)
{
return new Vector4(a.x * d, a.y * d, a.z * d, a.w * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector4 operator /(Vector4 a, float d)
{
return new Vector4(a.x / d, a.y / d, a.z / d, a.w / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4 operator -(Vector4 a)
{
return new Vector4(-a.x, -a.y, -a.z, -a.w);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector4 lhs, Vector4 rhs)
{
return (lhs - rhs).MagnitudeSqr < Epsilon;
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector4 lhs, Vector4 rhs)
{
return (lhs - rhs).MagnitudeSqr >= Epsilon;
}
/// <summary>
/// Explicitly converts from a double-precision vector into a single-precision vector.
/// </summary>
/// <param name="v">The double-precision vector.</param>
public static explicit operator Vector4(Vector4d v)
{
return new Vector4((float)v.x, (float)v.y, (float)v.z, (float)v.w);
}
/// <summary>
/// Implicitly converts from an integer vector into a single-precision vector.
/// </summary>
/// <param name="v">The integer vector.</param>
public static implicit operator Vector4(Vector4i v)
{
return new Vector4(v.x, v.y, v.z, v.w);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x, y and z components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
/// <param name="w">The w value.</param>
public void Set(float x, float y, float z, float w)
{
this.x = x;
this.y = y;
this.z = z;
this.w = w;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector4 scale)
{
x *= scale.x;
y *= scale.y;
z *= scale.z;
w *= scale.w;
}
/// <summary>
/// Normalizes this vector.
/// </summary>
public void Normalize()
{
float mag = this.Magnitude;
if (mag > Epsilon)
{
x /= mag;
y /= mag;
z /= mag;
w /= mag;
}
else
{
x = y = z = w = 0;
}
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(float min, float max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
if (z < min) z = min;
else if (z > max) z = max;
if (w < min) w = min;
else if (w > max) w = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2 ^ w.GetHashCode() >> 1;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector4))
{
return false;
}
Vector4 vector = (Vector4)other;
return (x == vector.x && y == vector.y && z == vector.z && w == vector.w);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector4 other)
{
return (x == other.x && y == other.y && z == other.z && w == other.w);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1}, {2}, {3})",
x.ToString("F1", CultureInfo.InvariantCulture),
y.ToString("F1", CultureInfo.InvariantCulture),
z.ToString("F1", CultureInfo.InvariantCulture),
w.ToString("F1", CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The float format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1}, {2}, {3})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture),
z.ToString(format, CultureInfo.InvariantCulture),
w.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Dot Product of two vectors.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
public static float Dot(ref Vector4 lhs, ref Vector4 rhs)
{
return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z + lhs.w * rhs.w;
}
/// <summary>
/// Performs a linear interpolation between two vectors.
/// </summary>
/// <param name="a">The vector to interpolate from.</param>
/// <param name="b">The vector to interpolate to.</param>
/// <param name="t">The time fraction.</param>
/// <param name="result">The resulting vector.</param>
public static void Lerp(ref Vector4 a, ref Vector4 b, float t, out Vector4 result)
{
result = new Vector4(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t, a.w + (b.w - a.w) * t);
}
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector4 a, ref Vector4 b, out Vector4 result)
{
result = new Vector4(a.x * b.x, a.y * b.y, a.z * b.z, a.w * b.w);
}
/// <summary>
/// Normalizes a vector.
/// </summary>
/// <param name="value">The vector to normalize.</param>
/// <param name="result">The resulting normalized vector.</param>
public static void Normalize(ref Vector4 value, out Vector4 result)
{
float mag = value.Magnitude;
if (mag > Epsilon)
{
result = new Vector4(value.x / mag, value.y / mag, value.z / mag, value.w / mag);
}
else
{
result = Vector4.zero;
}
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,467 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A double precision 4D vector.
/// </summary>
public struct Vector4d : IEquatable<Vector4d>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector4d zero = new Vector4d(0, 0, 0, 0);
#endregion
#region Consts
/// <summary>
/// The vector epsilon.
/// </summary>
public const double Epsilon = double.Epsilon;
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public double x;
/// <summary>
/// The y component.
/// </summary>
public double y;
/// <summary>
/// The z component.
/// </summary>
public double z;
/// <summary>
/// The w component.
/// </summary>
public double w;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public double Magnitude
{
get { return System.Math.Sqrt(x * x + y * y + z * z + w * w); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public double MagnitudeSqr
{
get { return (x * x + y * y + z * z + w * w); }
}
/// <summary>
/// Gets a normalized vector from this vector.
/// </summary>
public Vector4d Normalized
{
get
{
Vector4d result;
Normalize(ref this, out result);
return result;
}
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public double this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
case 2:
return z;
case 3:
return w;
default:
throw new IndexOutOfRangeException("Invalid Vector4d index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
case 2:
z = value;
break;
case 3:
w = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector4d index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector4d(double value)
{
this.x = value;
this.y = value;
this.z = value;
this.w = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
/// <param name="w">The w value.</param>
public Vector4d(double x, double y, double z, double w)
{
this.x = x;
this.y = y;
this.z = z;
this.w = w;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4d operator +(Vector4d a, Vector4d b)
{
return new Vector4d(a.x + b.x, a.y + b.y, a.z + b.z, a.w + b.w);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4d operator -(Vector4d a, Vector4d b)
{
return new Vector4d(a.x - b.x, a.y - b.y, a.z - b.z, a.w - b.w);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector4d operator *(Vector4d a, double d)
{
return new Vector4d(a.x * d, a.y * d, a.z * d, a.w * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4d operator *(double d, Vector4d a)
{
return new Vector4d(a.x * d, a.y * d, a.z * d, a.w * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector4d operator /(Vector4d a, double d)
{
return new Vector4d(a.x / d, a.y / d, a.z / d, a.w / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4d operator -(Vector4d a)
{
return new Vector4d(-a.x, -a.y, -a.z, -a.w);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector4d lhs, Vector4d rhs)
{
return (lhs - rhs).MagnitudeSqr < Epsilon;
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector4d lhs, Vector4d rhs)
{
return (lhs - rhs).MagnitudeSqr >= Epsilon;
}
/// <summary>
/// Implicitly converts from a single-precision vector into a double-precision vector.
/// </summary>
/// <param name="v">The single-precision vector.</param>
public static implicit operator Vector4d(Vector4 v)
{
return new Vector4d(v.x, v.y, v.z, v.w);
}
/// <summary>
/// Implicitly converts from an integer vector into a double-precision vector.
/// </summary>
/// <param name="v">The integer vector.</param>
public static implicit operator Vector4d(Vector4i v)
{
return new Vector4d(v.x, v.y, v.z, v.w);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x, y and z components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
/// <param name="w">The w value.</param>
public void Set(double x, double y, double z, double w)
{
this.x = x;
this.y = y;
this.z = z;
this.w = w;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector4d scale)
{
x *= scale.x;
y *= scale.y;
z *= scale.z;
w *= scale.w;
}
/// <summary>
/// Normalizes this vector.
/// </summary>
public void Normalize()
{
double mag = this.Magnitude;
if (mag > Epsilon)
{
x /= mag;
y /= mag;
z /= mag;
w /= mag;
}
else
{
x = y = z = w = 0;
}
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(double min, double max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
if (z < min) z = min;
else if (z > max) z = max;
if (w < min) w = min;
else if (w > max) w = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2 ^ w.GetHashCode() >> 1;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector4d))
{
return false;
}
Vector4d vector = (Vector4d)other;
return (x == vector.x && y == vector.y && z == vector.z && w == vector.w);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector4d other)
{
return (x == other.x && y == other.y && z == other.z && w == other.w);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1}, {2}, {3})",
x.ToString("F1", CultureInfo.InvariantCulture),
y.ToString("F1", CultureInfo.InvariantCulture),
z.ToString("F1", CultureInfo.InvariantCulture),
w.ToString("F1", CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The float format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1}, {2}, {3})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture),
z.ToString(format, CultureInfo.InvariantCulture),
w.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Dot Product of two vectors.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
public static double Dot(ref Vector4d lhs, ref Vector4d rhs)
{
return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z + lhs.w * rhs.w;
}
/// <summary>
/// Performs a linear interpolation between two vectors.
/// </summary>
/// <param name="a">The vector to interpolate from.</param>
/// <param name="b">The vector to interpolate to.</param>
/// <param name="t">The time fraction.</param>
/// <param name="result">The resulting vector.</param>
public static void Lerp(ref Vector4d a, ref Vector4d b, double t, out Vector4d result)
{
result = new Vector4d(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t, a.w + (b.w - a.w) * t);
}
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector4d a, ref Vector4d b, out Vector4d result)
{
result = new Vector4d(a.x * b.x, a.y * b.y, a.z * b.z, a.w * b.w);
}
/// <summary>
/// Normalizes a vector.
/// </summary>
/// <param name="value">The vector to normalize.</param>
/// <param name="result">The resulting normalized vector.</param>
public static void Normalize(ref Vector4d value, out Vector4d result)
{
double mag = value.Magnitude;
if (mag > Epsilon)
{
result = new Vector4d(value.x / mag, value.y / mag, value.z / mag, value.w / mag);
}
else
{
result = Vector4d.zero;
}
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,388 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Globalization;
namespace MeshDecimator.Math
{
/// <summary>
/// A 4D integer vector.
/// </summary>
public struct Vector4i : IEquatable<Vector4i>
{
#region Static Read-Only
/// <summary>
/// The zero vector.
/// </summary>
public static readonly Vector4i zero = new Vector4i(0, 0, 0, 0);
#endregion
#region Fields
/// <summary>
/// The x component.
/// </summary>
public int x;
/// <summary>
/// The y component.
/// </summary>
public int y;
/// <summary>
/// The z component.
/// </summary>
public int z;
/// <summary>
/// The w component.
/// </summary>
public int w;
#endregion
#region Properties
/// <summary>
/// Gets the magnitude of this vector.
/// </summary>
public int Magnitude
{
get { return (int)System.Math.Sqrt(x * x + y * y + z * z + w * w); }
}
/// <summary>
/// Gets the squared magnitude of this vector.
/// </summary>
public int MagnitudeSqr
{
get { return (x * x + y * y + z * z + w * w); }
}
/// <summary>
/// Gets or sets a specific component by index in this vector.
/// </summary>
/// <param name="index">The component index.</param>
public int this[int index]
{
get
{
switch (index)
{
case 0:
return x;
case 1:
return y;
case 2:
return z;
case 3:
return w;
default:
throw new IndexOutOfRangeException("Invalid Vector4i index!");
}
}
set
{
switch (index)
{
case 0:
x = value;
break;
case 1:
y = value;
break;
case 2:
z = value;
break;
case 3:
w = value;
break;
default:
throw new IndexOutOfRangeException("Invalid Vector4i index!");
}
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new vector with one value for all components.
/// </summary>
/// <param name="value">The value.</param>
public Vector4i(int value)
{
this.x = value;
this.y = value;
this.z = value;
this.w = value;
}
/// <summary>
/// Creates a new vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
/// <param name="w">The w value.</param>
public Vector4i(int x, int y, int z, int w)
{
this.x = x;
this.y = y;
this.z = z;
this.w = w;
}
#endregion
#region Operators
/// <summary>
/// Adds two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4i operator +(Vector4i a, Vector4i b)
{
return new Vector4i(a.x + b.x, a.y + b.y, a.z + b.z, a.w + b.w);
}
/// <summary>
/// Subtracts two vectors.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4i operator -(Vector4i a, Vector4i b)
{
return new Vector4i(a.x - b.x, a.y - b.y, a.z - b.z, a.w - b.w);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The scaling value.</param>
/// <returns>The resulting vector.</returns>
public static Vector4i operator *(Vector4i a, int d)
{
return new Vector4i(a.x * d, a.y * d, a.z * d, a.w * d);
}
/// <summary>
/// Scales the vector uniformly.
/// </summary>
/// <param name="d">The scaling value.</param>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4i operator *(int d, Vector4i a)
{
return new Vector4i(a.x * d, a.y * d, a.z * d, a.w * d);
}
/// <summary>
/// Divides the vector with a float.
/// </summary>
/// <param name="a">The vector.</param>
/// <param name="d">The dividing float value.</param>
/// <returns>The resulting vector.</returns>
public static Vector4i operator /(Vector4i a, int d)
{
return new Vector4i(a.x / d, a.y / d, a.z / d, a.w / d);
}
/// <summary>
/// Subtracts the vector from a zero vector.
/// </summary>
/// <param name="a">The vector.</param>
/// <returns>The resulting vector.</returns>
public static Vector4i operator -(Vector4i a)
{
return new Vector4i(-a.x, -a.y, -a.z, -a.w);
}
/// <summary>
/// Returns if two vectors equals eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If equals.</returns>
public static bool operator ==(Vector4i lhs, Vector4i rhs)
{
return (lhs.x == rhs.x && lhs.y == rhs.y && lhs.z == rhs.z && lhs.w == rhs.w);
}
/// <summary>
/// Returns if two vectors don't equal eachother.
/// </summary>
/// <param name="lhs">The left hand side vector.</param>
/// <param name="rhs">The right hand side vector.</param>
/// <returns>If not equals.</returns>
public static bool operator !=(Vector4i lhs, Vector4i rhs)
{
return (lhs.x != rhs.x || lhs.y != rhs.y || lhs.z != rhs.z || lhs.w != rhs.w);
}
/// <summary>
/// Explicitly converts from a single-precision vector into an integer vector.
/// </summary>
/// <param name="v">The single-precision vector.</param>
public static explicit operator Vector4i(Vector4 v)
{
return new Vector4i((int)v.x, (int)v.y, (int)v.z, (int)v.w);
}
/// <summary>
/// Explicitly converts from a double-precision vector into an integer vector.
/// </summary>
/// <param name="v">The double-precision vector.</param>
public static explicit operator Vector4i(Vector4d v)
{
return new Vector4i((int)v.x, (int)v.y, (int)v.z, (int)v.w);
}
#endregion
#region Public Methods
#region Instance
/// <summary>
/// Set x, y and z components of an existing vector.
/// </summary>
/// <param name="x">The x value.</param>
/// <param name="y">The y value.</param>
/// <param name="z">The z value.</param>
/// <param name="w">The w value.</param>
public void Set(int x, int y, int z, int w)
{
this.x = x;
this.y = y;
this.z = z;
this.w = w;
}
/// <summary>
/// Multiplies with another vector component-wise.
/// </summary>
/// <param name="scale">The vector to multiply with.</param>
public void Scale(ref Vector4i scale)
{
x *= scale.x;
y *= scale.y;
z *= scale.z;
w *= scale.w;
}
/// <summary>
/// Clamps this vector between a specific range.
/// </summary>
/// <param name="min">The minimum component value.</param>
/// <param name="max">The maximum component value.</param>
public void Clamp(int min, int max)
{
if (x < min) x = min;
else if (x > max) x = max;
if (y < min) y = min;
else if (y > max) y = max;
if (z < min) z = min;
else if (z > max) z = max;
if (w < min) w = min;
else if (w > max) w = max;
}
#endregion
#region Object
/// <summary>
/// Returns a hash code for this vector.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2 ^ w.GetHashCode() >> 1;
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public override bool Equals(object other)
{
if (!(other is Vector4i))
{
return false;
}
Vector4i vector = (Vector4i)other;
return (x == vector.x && y == vector.y && z == vector.z && w == vector.w);
}
/// <summary>
/// Returns if this vector is equal to another one.
/// </summary>
/// <param name="other">The other vector to compare to.</param>
/// <returns>If equals.</returns>
public bool Equals(Vector4i other)
{
return (x == other.x && y == other.y && z == other.z && w == other.w);
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <returns>The string.</returns>
public override string ToString()
{
return string.Format("({0}, {1}, {2}, {3})",
x.ToString(CultureInfo.InvariantCulture),
y.ToString(CultureInfo.InvariantCulture),
z.ToString(CultureInfo.InvariantCulture),
w.ToString(CultureInfo.InvariantCulture));
}
/// <summary>
/// Returns a nicely formatted string for this vector.
/// </summary>
/// <param name="format">The integer format.</param>
/// <returns>The string.</returns>
public string ToString(string format)
{
return string.Format("({0}, {1}, {2}, {3})",
x.ToString(format, CultureInfo.InvariantCulture),
y.ToString(format, CultureInfo.InvariantCulture),
z.ToString(format, CultureInfo.InvariantCulture),
w.ToString(format, CultureInfo.InvariantCulture));
}
#endregion
#region Static
/// <summary>
/// Multiplies two vectors component-wise.
/// </summary>
/// <param name="a">The first vector.</param>
/// <param name="b">The second vector.</param>
/// <param name="result">The resulting vector.</param>
public static void Scale(ref Vector4i a, ref Vector4i b, out Vector4i result)
{
result = new Vector4i(a.x * b.x, a.y * b.y, a.z * b.z, a.w * b.w);
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,955 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using System.Collections.Generic;
using MeshDecimator.Math;
namespace MeshDecimator
{
/// <summary>
/// A mesh.
/// </summary>
public sealed class Mesh
{
#region Consts
/// <summary>
/// The count of supported UV channels.
/// </summary>
public const int UVChannelCount = 4;
#endregion
#region Fields
private Vector3d[] vertices = null;
private int[][] indices = null;
private Vector3[] normals = null;
private Vector4[] tangents = null;
private Vector2[][] uvs2D = null;
private Vector3[][] uvs3D = null;
private Vector4[][] uvs4D = null;
private Vector4[] colors = null;
private BoneWeight[] boneWeights = null;
private static readonly int[] emptyIndices = new int[0];
#endregion
#region Properties
/// <summary>
/// Gets the count of vertices of this mesh.
/// </summary>
public int VertexCount
{
get { return vertices.Length; }
}
/// <summary>
/// Gets or sets the count of submeshes in this mesh.
/// </summary>
public int SubMeshCount
{
get { return indices.Length; }
set
{
if (value <= 0)
throw new ArgumentOutOfRangeException("value");
int[][] newIndices = new int[value][];
Array.Copy(indices, 0, newIndices, 0, MathHelper.Min(indices.Length, newIndices.Length));
indices = newIndices;
}
}
/// <summary>
/// Gets the total count of triangles in this mesh.
/// </summary>
public int TriangleCount
{
get
{
int triangleCount = 0;
for (int i = 0; i < indices.Length; i++)
{
if (indices[i] != null)
{
triangleCount += indices[i].Length / 3;
}
}
return triangleCount;
}
}
/// <summary>
/// Gets or sets the vertices for this mesh. Note that this resets all other vertex attributes.
/// </summary>
public Vector3d[] Vertices
{
get { return vertices; }
set
{
if (value == null)
throw new ArgumentNullException("value");
vertices = value;
ClearVertexAttributes();
}
}
/// <summary>
/// Gets or sets the combined indices for this mesh. Once set, the sub-mesh count gets set to 1.
/// </summary>
public int[] Indices
{
get
{
if (indices.Length == 1)
{
return indices[0] ?? emptyIndices;
}
else
{
List<int> indexList = new List<int>(TriangleCount * 3);
for (int i = 0; i < indices.Length; i++)
{
if (indices[i] != null)
{
indexList.AddRange(indices[i]);
}
}
return indexList.ToArray();
}
}
set
{
if (value == null)
throw new ArgumentNullException("value");
else if ((value.Length % 3) != 0)
throw new ArgumentException("The index count must be multiple by 3.", "value");
SubMeshCount = 1;
SetIndices(0, value);
}
}
/// <summary>
/// Gets or sets the normals for this mesh.
/// </summary>
public Vector3[] Normals
{
get { return normals; }
set
{
if (value != null && value.Length != vertices.Length)
throw new ArgumentException(string.Format("The vertex normals must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length));
normals = value;
}
}
/// <summary>
/// Gets or sets the tangents for this mesh.
/// </summary>
public Vector4[] Tangents
{
get { return tangents; }
set
{
if (value != null && value.Length != vertices.Length)
throw new ArgumentException(string.Format("The vertex tangents must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length));
tangents = value;
}
}
/// <summary>
/// Gets or sets the first UV set for this mesh.
/// </summary>
public Vector2[] UV1
{
get { return GetUVs2D(0); }
set { SetUVs(0, value); }
}
/// <summary>
/// Gets or sets the second UV set for this mesh.
/// </summary>
public Vector2[] UV2
{
get { return GetUVs2D(1); }
set { SetUVs(1, value); }
}
/// <summary>
/// Gets or sets the third UV set for this mesh.
/// </summary>
public Vector2[] UV3
{
get { return GetUVs2D(2); }
set { SetUVs(2, value); }
}
/// <summary>
/// Gets or sets the fourth UV set for this mesh.
/// </summary>
public Vector2[] UV4
{
get { return GetUVs2D(3); }
set { SetUVs(3, value); }
}
/// <summary>
/// Gets or sets the vertex colors for this mesh.
/// </summary>
public Vector4[] Colors
{
get { return colors; }
set
{
if (value != null && value.Length != vertices.Length)
throw new ArgumentException(string.Format("The vertex colors must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length));
colors = value;
}
}
/// <summary>
/// Gets or sets the vertex bone weights for this mesh.
/// </summary>
public BoneWeight[] BoneWeights
{
get { return boneWeights; }
set
{
if (value != null && value.Length != vertices.Length)
throw new ArgumentException(string.Format("The vertex bone weights must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length));
boneWeights = value;
}
}
#endregion
#region Constructor
/// <summary>
/// Creates a new mesh.
/// </summary>
/// <param name="vertices">The mesh vertices.</param>
/// <param name="indices">The mesh indices.</param>
public Mesh(Vector3d[] vertices, int[] indices)
{
if (vertices == null)
throw new ArgumentNullException("vertices");
else if (indices == null)
throw new ArgumentNullException("indices");
else if ((indices.Length % 3) != 0)
throw new ArgumentException("The index count must be multiple by 3.", "indices");
this.vertices = vertices;
this.indices = new int[1][];
this.indices[0] = indices;
}
/// <summary>
/// Creates a new mesh.
/// </summary>
/// <param name="vertices">The mesh vertices.</param>
/// <param name="indices">The mesh indices.</param>
public Mesh(Vector3d[] vertices, int[][] indices)
{
if (vertices == null)
throw new ArgumentNullException("vertices");
else if (indices == null)
throw new ArgumentNullException("indices");
for (int i = 0; i < indices.Length; i++)
{
if (indices[i] != null && (indices[i].Length % 3) != 0)
throw new ArgumentException(string.Format("The index count must be multiple by 3 at sub-mesh index {0}.", i), "indices");
}
this.vertices = vertices;
this.indices = indices;
}
#endregion
#region Private Methods
private void ClearVertexAttributes()
{
normals = null;
tangents = null;
uvs2D = null;
uvs3D = null;
uvs4D = null;
colors = null;
boneWeights = null;
}
#endregion
#region Public Methods
#region Recalculate Normals
/// <summary>
/// Recalculates the normals for this mesh smoothly.
/// </summary>
public void RecalculateNormals()
{
int vertexCount = vertices.Length;
Vector3[] normals = new Vector3[vertexCount];
int subMeshCount = this.indices.Length;
for (int subMeshIndex = 0; subMeshIndex < subMeshCount; subMeshIndex++)
{
int[] indices = this.indices[subMeshIndex];
if (indices == null)
continue;
int indexCount = indices.Length;
for (int i = 0; i < indexCount; i += 3)
{
int i0 = indices[i];
int i1 = indices[i + 1];
int i2 = indices[i + 2];
var v0 = (Vector3)vertices[i0];
var v1 = (Vector3)vertices[i1];
var v2 = (Vector3)vertices[i2];
var nx = v1 - v0;
var ny = v2 - v0;
Vector3 normal;
Vector3.Cross(ref nx, ref ny, out normal);
normal.Normalize();
normals[i0] += normal;
normals[i1] += normal;
normals[i2] += normal;
}
}
for (int i = 0; i < vertexCount; i++)
{
normals[i].Normalize();
}
this.normals = normals;
}
#endregion
#region Recalculate Tangents
/// <summary>
/// Recalculates the tangents for this mesh.
/// </summary>
public void RecalculateTangents()
{
// Make sure we have the normals first
if (normals == null)
return;
// Also make sure that we have the first UV set
bool uvIs2D = (uvs2D != null && uvs2D[0] != null);
bool uvIs3D = (uvs3D != null && uvs3D[0] != null);
bool uvIs4D = (uvs4D != null && uvs4D[0] != null);
if (!uvIs2D && !uvIs3D && !uvIs4D)
return;
int vertexCount = vertices.Length;
var tangents = new Vector4[vertexCount];
var tan1 = new Vector3[vertexCount];
var tan2 = new Vector3[vertexCount];
Vector2[] uv2D = (uvIs2D ? uvs2D[0] : null);
Vector3[] uv3D = (uvIs3D ? uvs3D[0] : null);
Vector4[] uv4D = (uvIs4D ? uvs4D[0] : null);
int subMeshCount = this.indices.Length;
for (int subMeshIndex = 0; subMeshIndex < subMeshCount; subMeshIndex++)
{
int[] indices = this.indices[subMeshIndex];
if (indices == null)
continue;
int indexCount = indices.Length;
for (int i = 0; i < indexCount; i += 3)
{
int i0 = indices[i];
int i1 = indices[i + 1];
int i2 = indices[i + 2];
var v0 = vertices[i0];
var v1 = vertices[i1];
var v2 = vertices[i2];
float s1, s2, t1, t2;
if (uvIs2D)
{
var w0 = uv2D[i0];
var w1 = uv2D[i1];
var w2 = uv2D[i2];
s1 = w1.x - w0.x;
s2 = w2.x - w0.x;
t1 = w1.y - w0.y;
t2 = w2.y - w0.y;
}
else if (uvIs3D)
{
var w0 = uv3D[i0];
var w1 = uv3D[i1];
var w2 = uv3D[i2];
s1 = w1.x - w0.x;
s2 = w2.x - w0.x;
t1 = w1.y - w0.y;
t2 = w2.y - w0.y;
}
else
{
var w0 = uv4D[i0];
var w1 = uv4D[i1];
var w2 = uv4D[i2];
s1 = w1.x - w0.x;
s2 = w2.x - w0.x;
t1 = w1.y - w0.y;
t2 = w2.y - w0.y;
}
float x1 = (float)(v1.x - v0.x);
float x2 = (float)(v2.x - v0.x);
float y1 = (float)(v1.y - v0.y);
float y2 = (float)(v2.y - v0.y);
float z1 = (float)(v1.z - v0.z);
float z2 = (float)(v2.z - v0.z);
float r = 1f / (s1 * t2 - s2 * t1);
var sdir = new Vector3((t2 * x1 - t1 * x2) * r, (t2 * y1 - t1 * y2) * r, (t2 * z1 - t1 * z2) * r);
var tdir = new Vector3((s1 * x2 - s2 * x1) * r, (s1 * y2 - s2 * y1) * r, (s1 * z2 - s2 * z1) * r);
tan1[i0] += sdir;
tan1[i1] += sdir;
tan1[i2] += sdir;
tan2[i0] += tdir;
tan2[i1] += tdir;
tan2[i2] += tdir;
}
}
for (int i = 0; i < vertexCount; i++)
{
var n = normals[i];
var t = tan1[i];
var tmp = (t - n * Vector3.Dot(ref n, ref t));
tmp.Normalize();
Vector3 c;
Vector3.Cross(ref n, ref t, out c);
float dot = Vector3.Dot(ref c, ref tan2[i]);
float w = (dot < 0f ? -1f : 1f);
tangents[i] = new Vector4(tmp.x, tmp.y, tmp.z, w);
}
this.tangents = tangents;
}
#endregion
#region Triangles
/// <summary>
/// Returns the count of triangles for a specific sub-mesh in this mesh.
/// </summary>
/// <param name="subMeshIndex">The sub-mesh index.</param>
/// <returns>The triangle count.</returns>
public int GetTriangleCount(int subMeshIndex)
{
if (subMeshIndex < 0 || subMeshIndex >= indices.Length)
throw new IndexOutOfRangeException();
return indices[subMeshIndex].Length / 3;
}
/// <summary>
/// Returns the triangle indices of a specific sub-mesh in this mesh.
/// </summary>
/// <param name="subMeshIndex">The sub-mesh index.</param>
/// <returns>The triangle indices.</returns>
public int[] GetIndices(int subMeshIndex)
{
if (subMeshIndex < 0 || subMeshIndex >= indices.Length)
throw new IndexOutOfRangeException();
return indices[subMeshIndex] ?? emptyIndices;
}
/// <summary>
/// Returns the triangle indices for all sub-meshes in this mesh.
/// </summary>
/// <returns>The sub-mesh triangle indices.</returns>
public int[][] GetSubMeshIndices()
{
var subMeshIndices = new int[indices.Length][];
for (int subMeshIndex = 0; subMeshIndex < indices.Length; subMeshIndex++)
{
subMeshIndices[subMeshIndex] = indices[subMeshIndex] ?? emptyIndices;
}
return subMeshIndices;
}
/// <summary>
/// Sets the triangle indices of a specific sub-mesh in this mesh.
/// </summary>
/// <param name="subMeshIndex">The sub-mesh index.</param>
/// <param name="indices">The triangle indices.</param>
public void SetIndices(int subMeshIndex, int[] indices)
{
if (subMeshIndex < 0 || subMeshIndex >= this.indices.Length)
throw new IndexOutOfRangeException();
else if (indices == null)
throw new ArgumentNullException("indices");
else if ((indices.Length % 3) != 0)
throw new ArgumentException("The index count must be multiple by 3.", "indices");
this.indices[subMeshIndex] = indices;
}
#endregion
#region UV Sets
#region Getting
/// <summary>
/// Returns the UV dimension for a specific channel.
/// </summary>
/// <param name="channel"></param>
/// <returns>The UV dimension count.</returns>
public int GetUVDimension(int channel)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs2D != null && uvs2D[channel] != null)
{
return 2;
}
else if (uvs3D != null && uvs3D[channel] != null)
{
return 3;
}
else if (uvs4D != null && uvs4D[channel] != null)
{
return 4;
}
else
{
return 0;
}
}
/// <summary>
/// Returns the UVs (2D) from a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <returns>The UVs.</returns>
public Vector2[] GetUVs2D(int channel)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs2D != null && uvs2D[channel] != null)
{
return uvs2D[channel];
}
else
{
return null;
}
}
/// <summary>
/// Returns the UVs (3D) from a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <returns>The UVs.</returns>
public Vector3[] GetUVs3D(int channel)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs3D != null && uvs3D[channel] != null)
{
return uvs3D[channel];
}
else
{
return null;
}
}
/// <summary>
/// Returns the UVs (4D) from a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <returns>The UVs.</returns>
public Vector4[] GetUVs4D(int channel)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs4D != null && uvs4D[channel] != null)
{
return uvs4D[channel];
}
else
{
return null;
}
}
/// <summary>
/// Returns the UVs (2D) from a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void GetUVs(int channel, List<Vector2> uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
else if (uvs == null)
throw new ArgumentNullException("uvs");
uvs.Clear();
if (uvs2D != null && uvs2D[channel] != null)
{
var uvData = uvs2D[channel];
if (uvData != null)
{
uvs.AddRange(uvData);
}
}
}
/// <summary>
/// Returns the UVs (3D) from a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void GetUVs(int channel, List<Vector3> uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
else if (uvs == null)
throw new ArgumentNullException("uvs");
uvs.Clear();
if (uvs3D != null && uvs3D[channel] != null)
{
var uvData = uvs3D[channel];
if (uvData != null)
{
uvs.AddRange(uvData);
}
}
}
/// <summary>
/// Returns the UVs (4D) from a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void GetUVs(int channel, List<Vector4> uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
else if (uvs == null)
throw new ArgumentNullException("uvs");
uvs.Clear();
if (uvs4D != null && uvs4D[channel] != null)
{
var uvData = uvs4D[channel];
if (uvData != null)
{
uvs.AddRange(uvData);
}
}
}
#endregion
#region Setting
/// <summary>
/// Sets the UVs (2D) for a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void SetUVs(int channel, Vector2[] uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs != null && uvs.Length > 0)
{
if (uvs.Length != vertices.Length)
throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvs.Length, vertices.Length));
if (uvs2D == null)
uvs2D = new Vector2[UVChannelCount][];
int uvCount = uvs.Length;
var uvSet = new Vector2[uvCount];
uvs2D[channel] = uvSet;
uvs.CopyTo(uvSet, 0);
}
else
{
if (uvs2D != null)
{
uvs2D[channel] = null;
}
}
if (uvs3D != null)
{
uvs3D[channel] = null;
}
if (uvs4D != null)
{
uvs4D[channel] = null;
}
}
/// <summary>
/// Sets the UVs (3D) for a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void SetUVs(int channel, Vector3[] uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs != null && uvs.Length > 0)
{
int uvCount = uvs.Length;
if (uvCount != vertices.Length)
throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs");
if (uvs3D == null)
uvs3D = new Vector3[UVChannelCount][];
var uvSet = new Vector3[uvCount];
uvs3D[channel] = uvSet;
uvs.CopyTo(uvSet, 0);
}
else
{
if (uvs3D != null)
{
uvs3D[channel] = null;
}
}
if (uvs2D != null)
{
uvs2D[channel] = null;
}
if (uvs4D != null)
{
uvs4D[channel] = null;
}
}
/// <summary>
/// Sets the UVs (4D) for a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void SetUVs(int channel, Vector4[] uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs != null && uvs.Length > 0)
{
int uvCount = uvs.Length;
if (uvCount != vertices.Length)
throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs");
if (uvs4D == null)
uvs4D = new Vector4[UVChannelCount][];
var uvSet = new Vector4[uvCount];
uvs4D[channel] = uvSet;
uvs.CopyTo(uvSet, 0);
}
else
{
if (uvs4D != null)
{
uvs4D[channel] = null;
}
}
if (uvs2D != null)
{
uvs2D[channel] = null;
}
if (uvs3D != null)
{
uvs3D[channel] = null;
}
}
/// <summary>
/// Sets the UVs (2D) for a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void SetUVs(int channel, List<Vector2> uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs != null && uvs.Count > 0)
{
int uvCount = uvs.Count;
if (uvCount != vertices.Length)
throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs");
if (uvs2D == null)
uvs2D = new Vector2[UVChannelCount][];
var uvSet = new Vector2[uvCount];
uvs2D[channel] = uvSet;
uvs.CopyTo(uvSet, 0);
}
else
{
if (uvs2D != null)
{
uvs2D[channel] = null;
}
}
if (uvs3D != null)
{
uvs3D[channel] = null;
}
if (uvs4D != null)
{
uvs4D[channel] = null;
}
}
/// <summary>
/// Sets the UVs (3D) for a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void SetUVs(int channel, List<Vector3> uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs != null && uvs.Count > 0)
{
int uvCount = uvs.Count;
if (uvCount != vertices.Length)
throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs");
if (uvs3D == null)
uvs3D = new Vector3[UVChannelCount][];
var uvSet = new Vector3[uvCount];
uvs3D[channel] = uvSet;
uvs.CopyTo(uvSet, 0);
}
else
{
if (uvs3D != null)
{
uvs3D[channel] = null;
}
}
if (uvs2D != null)
{
uvs2D[channel] = null;
}
if (uvs4D != null)
{
uvs4D[channel] = null;
}
}
/// <summary>
/// Sets the UVs (4D) for a specific channel.
/// </summary>
/// <param name="channel">The channel index.</param>
/// <param name="uvs">The UVs.</param>
public void SetUVs(int channel, List<Vector4> uvs)
{
if (channel < 0 || channel >= UVChannelCount)
throw new ArgumentOutOfRangeException("channel");
if (uvs != null && uvs.Count > 0)
{
int uvCount = uvs.Count;
if (uvCount != vertices.Length)
throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs");
if (uvs4D == null)
uvs4D = new Vector4[UVChannelCount][];
var uvSet = new Vector4[uvCount];
uvs4D[channel] = uvSet;
uvs.CopyTo(uvSet, 0);
}
else
{
if (uvs4D != null)
{
uvs4D[channel] = null;
}
}
if (uvs2D != null)
{
uvs2D[channel] = null;
}
if (uvs3D != null)
{
uvs3D[channel] = null;
}
}
#endregion
#endregion
#region To String
/// <summary>
/// Returns the text-representation of this mesh.
/// </summary>
/// <returns>The text-representation.</returns>
public override string ToString()
{
return string.Format("Vertices: {0}", vertices.Length);
}
#endregion
#endregion
}
}

View File

@@ -0,0 +1,180 @@
#region License
/*
MIT License
Copyright(c) 2017-2018 Mattias Edlund
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#endregion
using System;
using MeshDecimator.Algorithms;
namespace MeshDecimator
{
#region Algorithm
/// <summary>
/// The decimation algorithms.
/// </summary>
public enum Algorithm
{
/// <summary>
/// The default algorithm.
/// </summary>
Default,
/// <summary>
/// The fast quadric mesh simplification algorithm.
/// </summary>
FastQuadricMesh
}
#endregion
/// <summary>
/// The mesh decimation API.
/// </summary>
public static class MeshDecimation
{
#region Public Methods
#region Create Algorithm
/// <summary>
/// Creates a specific decimation algorithm.
/// </summary>
/// <param name="algorithm">The desired algorithm.</param>
/// <returns>The decimation algorithm.</returns>
public static DecimationAlgorithm CreateAlgorithm(Algorithm algorithm)
{
DecimationAlgorithm alg = null;
switch (algorithm)
{
case Algorithm.Default:
case Algorithm.FastQuadricMesh:
alg = new FastQuadricMeshSimplification();
break;
default:
throw new ArgumentException("The specified algorithm is not supported.", "algorithm");
}
return alg;
}
#endregion
#region Decimate Mesh
/// <summary>
/// Decimates a mesh.
/// </summary>
/// <param name="mesh">The mesh to decimate.</param>
/// <param name="targetTriangleCount">The target triangle count.</param>
/// <returns>The decimated mesh.</returns>
public static Mesh DecimateMesh(Mesh mesh, int targetTriangleCount)
{
return DecimateMesh(Algorithm.Default, mesh, targetTriangleCount);
}
/// <summary>
/// Decimates a mesh.
/// </summary>
/// <param name="algorithm">The desired algorithm.</param>
/// <param name="mesh">The mesh to decimate.</param>
/// <param name="targetTriangleCount">The target triangle count.</param>
/// <returns>The decimated mesh.</returns>
public static Mesh DecimateMesh(Algorithm algorithm, Mesh mesh, int targetTriangleCount)
{
if (mesh == null)
throw new ArgumentNullException("mesh");
var decimationAlgorithm = CreateAlgorithm(algorithm);
return DecimateMesh(decimationAlgorithm, mesh, targetTriangleCount);
}
/// <summary>
/// Decimates a mesh.
/// </summary>
/// <param name="algorithm">The decimation algorithm.</param>
/// <param name="mesh">The mesh to decimate.</param>
/// <param name="targetTriangleCount">The target triangle count.</param>
/// <returns>The decimated mesh.</returns>
public static Mesh DecimateMesh(DecimationAlgorithm algorithm, Mesh mesh, int targetTriangleCount)
{
if (algorithm == null)
throw new ArgumentNullException("algorithm");
else if (mesh == null)
throw new ArgumentNullException("mesh");
int currentTriangleCount = mesh.TriangleCount;
if (targetTriangleCount > currentTriangleCount)
targetTriangleCount = currentTriangleCount;
else if (targetTriangleCount < 0)
targetTriangleCount = 0;
algorithm.Initialize(mesh);
algorithm.DecimateMesh(targetTriangleCount);
return algorithm.ToMesh();
}
#endregion
#region Decimate Mesh Lossless
/// <summary>
/// Decimates a mesh without losing any quality.
/// </summary>
/// <param name="mesh">The mesh to decimate.</param>
/// <returns>The decimated mesh.</returns>
public static Mesh DecimateMeshLossless(Mesh mesh)
{
return DecimateMeshLossless(Algorithm.Default, mesh);
}
/// <summary>
/// Decimates a mesh without losing any quality.
/// </summary>
/// <param name="algorithm">The desired algorithm.</param>
/// <param name="mesh">The mesh to decimate.</param>
/// <returns>The decimated mesh.</returns>
public static Mesh DecimateMeshLossless(Algorithm algorithm, Mesh mesh)
{
if (mesh == null)
throw new ArgumentNullException("mesh");
var decimationAlgorithm = CreateAlgorithm(algorithm);
return DecimateMeshLossless(decimationAlgorithm, mesh);
}
/// <summary>
/// Decimates a mesh without losing any quality.
/// </summary>
/// <param name="algorithm">The decimation algorithm.</param>
/// <param name="mesh">The mesh to decimate.</param>
/// <returns>The decimated mesh.</returns>
public static Mesh DecimateMeshLossless(DecimationAlgorithm algorithm, Mesh mesh)
{
if (algorithm == null)
throw new ArgumentNullException("algorithm");
else if (mesh == null)
throw new ArgumentNullException("mesh");
int currentTriangleCount = mesh.TriangleCount;
algorithm.Initialize(mesh);
algorithm.DecimateMeshLossless();
return algorithm.ToMesh();
}
#endregion
#endregion
}
}

View File

@@ -34,44 +34,65 @@ namespace LightlessSync.UI;
public class CompactUi : WindowMediatorSubscriberBase
{
private readonly CharacterAnalyzer _characterAnalyzer;
#region Constants
private const float ConnectButtonHighlightThickness = 14f;
#endregion
#region Services
private readonly ApiController _apiController;
private readonly CharacterAnalyzer _characterAnalyzer;
private readonly DalamudUtilService _dalamudUtilService;
private readonly DrawEntityFactory _drawEntityFactory;
private readonly FileUploadManager _fileTransferManager;
private readonly IpcManager _ipcManager;
private readonly LightFinderService _broadcastService;
private readonly LightlessConfigService _configService;
private readonly LightlessMediator _lightlessMediator;
private readonly PairLedger _pairLedger;
private readonly ConcurrentDictionary<GameObjectHandler, Dictionary<string, FileDownloadStatus>> _currentDownloads = new();
private readonly DrawEntityFactory _drawEntityFactory;
private readonly FileUploadManager _fileTransferManager;
private readonly PlayerPerformanceConfigService _playerPerformanceConfig;
private readonly PairUiService _pairUiService;
private readonly SelectTagForPairUi _selectTagForPairUi;
private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi;
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
private readonly RenameSyncshellTagUi _renameSyncshellTagUi;
private readonly SelectPairForTagUi _selectPairsForGroupUi;
private readonly RenamePairTagUi _renamePairTagUi;
private readonly IpcManager _ipcManager;
private readonly ServerConfigurationManager _serverManager;
private readonly TopTabMenu _tabMenu;
private readonly TagHandler _tagHandler;
private readonly UiSharedService _uiSharedService;
private readonly LightFinderService _broadcastService;
private readonly DalamudUtilService _dalamudUtilService;
#endregion
#region UI Components
private readonly AnimatedHeader _animatedHeader = new();
private readonly RenamePairTagUi _renamePairTagUi;
private readonly RenameSyncshellTagUi _renameSyncshellTagUi;
private readonly SelectPairForTagUi _selectPairsForGroupUi;
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
private readonly SelectTagForPairUi _selectTagForPairUi;
private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi;
private readonly SeluneBrush _seluneBrush = new();
private readonly TopTabMenu _tabMenu;
#endregion
#region State
private readonly ConcurrentDictionary<GameObjectHandler, Dictionary<string, FileDownloadStatus>> _currentDownloads = new();
private List<IDrawFolder> _drawFolders;
private Pair? _focusedPair;
private Pair? _lastAddedUser;
private string _lastAddedUserComment = string.Empty;
private Vector2 _lastPosition = Vector2.One;
private Vector2 _lastSize = Vector2.One;
private int _pendingFocusFrame = -1;
private Pair? _pendingFocusPair;
private bool _showModalForUserAddition;
private float _transferPartHeight;
private bool _wasOpen;
private float _windowContentWidth;
private readonly SeluneBrush _seluneBrush = new();
private const float _connectButtonHighlightThickness = 14f;
private Pair? _focusedPair;
private Pair? _pendingFocusPair;
private int _pendingFocusFrame = -1;
#endregion
#region Constructor
public CompactUi(
ILogger<CompactUi> logger,
@@ -128,6 +149,11 @@ public class CompactUi : WindowMediatorSubscriberBase
_drawFolders = [.. DrawFolders];
_animatedHeader.Height = 120f;
_animatedHeader.EnableBottomGradient = true;
_animatedHeader.GradientHeight = 250f;
_animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects;
#if DEBUG
string dev = "Dev Build";
var ver = Assembly.GetExecutingAssembly().GetName().Version!;
@@ -141,18 +167,26 @@ public class CompactUi : WindowMediatorSubscriberBase
Mediator.Subscribe<SwitchToIntroUiMessage>(this, (_) => IsOpen = false);
Mediator.Subscribe<CutsceneStartMessage>(this, (_) => UiSharedService_GposeStart());
Mediator.Subscribe<CutsceneEndMessage>(this, (_) => UiSharedService_GposeEnd());
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus);
Mediator.Subscribe<DownloadStartedMessage>(this, msg =>
{
_currentDownloads[msg.DownloadId] = new Dictionary<string, FileDownloadStatus>(msg.DownloadStatus, StringComparer.Ordinal);
});
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) => _currentDownloads.TryRemove(msg.DownloadId, out _));
Mediator.Subscribe<RefreshUiMessage>(this, (msg) => _drawFolders = DrawFolders.ToList());
Mediator.Subscribe<RefreshUiMessage>(this, (msg) => _drawFolders = [.. DrawFolders]);
_characterAnalyzer = characterAnalyzer;
_playerPerformanceConfig = playerPerformanceConfig;
_lightlessMediator = mediator;
}
#endregion
#region Lifecycle
public override void OnClose()
{
ForceReleaseFocus();
_animatedHeader.ClearParticles();
base.OnClose();
}
@@ -164,6 +198,13 @@ public class CompactUi : WindowMediatorSubscriberBase
using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize);
_windowContentWidth = UiSharedService.GetWindowContentRegionWidth();
// Draw animated header background (just the gradient/particles, content drawn by existing methods)
var startCursorY = ImGui.GetCursorPosY();
_animatedHeader.Draw(_windowContentWidth, (_, _) => { });
// Reset cursor to draw content on top of the header background
ImGui.SetCursorPosY(startCursorY);
if (!_apiController.IsCurrentVersion)
{
var ver = _apiController.CurrentClientVersion;
@@ -209,17 +250,11 @@ public class CompactUi : WindowMediatorSubscriberBase
}
using (ImRaii.PushId("header")) DrawUIDHeader();
_uiSharedService.RoundedSeparator(UIColors.Get("LightlessPurple"), 2.5f, 1f, 12f);
using (ImRaii.PushId("serverstatus"))
{
DrawServerStatus();
}
selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
var style = ImGui.GetStyle();
var contentMinY = windowPos.Y + ImGui.GetWindowContentRegionMin().Y;
var gradientInset = 4f * ImGuiHelpers.GlobalScale;
var gradientTop = MathF.Max(contentMinY, ImGui.GetCursorScreenPos().Y - style.ItemSpacing.Y + gradientInset);
ImGui.Separator();
if (_apiController.ServerState is ServerState.Connected)
{
@@ -227,7 +262,6 @@ public class CompactUi : WindowMediatorSubscriberBase
using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw(pairSnapshot);
using (ImRaii.PushId("pairlist")) DrawPairs();
ImGui.Separator();
var transfersTop = ImGui.GetCursorScreenPos().Y;
var gradientBottom = MathF.Max(gradientTop, transfersTop - style.ItemSpacing.Y - gradientInset);
selune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime);
@@ -290,6 +324,10 @@ public class CompactUi : WindowMediatorSubscriberBase
}
}
#endregion
#region Content Drawing
private void DrawPairs()
{
float ySize = Math.Abs(_transferPartHeight) < 0.0001f
@@ -308,95 +346,6 @@ public class CompactUi : WindowMediatorSubscriberBase
ImGui.EndChild();
}
private void DrawServerStatus()
{
var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Link);
var userCount = _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture);
var userSize = ImGui.CalcTextSize(userCount);
var textSize = ImGui.CalcTextSize("Users Online");
#if DEBUG
string shardConnection = $"Shard: {_apiController.ServerInfo.ShardName}";
#else
string shardConnection = string.Equals(_apiController.ServerInfo.ShardName, "Main", StringComparison.OrdinalIgnoreCase) ? string.Empty : $"Shard: {_apiController.ServerInfo.ShardName}";
#endif
var shardTextSize = ImGui.CalcTextSize(shardConnection);
var printShard = !string.IsNullOrEmpty(_apiController.ServerInfo.ShardName) && shardConnection != string.Empty;
if (_apiController.ServerState is ServerState.Connected)
{
ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth()) / 2 - (userSize.X + textSize.X) / 2 - ImGui.GetStyle().ItemSpacing.X / 2);
if (!printShard) ImGui.AlignTextToFramePadding();
ImGui.TextColored(UIColors.Get("LightlessPurple"), userCount);
ImGui.SameLine();
if (!printShard) ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Users Online");
}
else
{
ImGui.AlignTextToFramePadding();
ImGui.TextColored(UIColors.Get("DimRed"), "Not connected to any server");
}
if (printShard)
{
ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ImGui.GetStyle().ItemSpacing.Y);
ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth()) / 2 - shardTextSize.X / 2);
ImGui.TextUnformatted(shardConnection);
}
ImGui.SameLine();
if (printShard)
{
ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ((userSize.Y + textSize.Y) / 2 + shardTextSize.Y) / 2 - ImGui.GetStyle().ItemSpacing.Y + buttonSize.Y / 2);
}
bool isConnectingOrConnected = _apiController.ServerState is ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting;
var color = UiSharedService.GetBoolColor(!isConnectingOrConnected);
var connectedIcon = isConnectingOrConnected ? FontAwesomeIcon.Unlink : FontAwesomeIcon.Link;
ImGui.SameLine(ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() - buttonSize.X);
if (printShard)
{
ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ((userSize.Y + textSize.Y) / 2 + shardTextSize.Y) / 2 - ImGui.GetStyle().ItemSpacing.Y + buttonSize.Y / 2);
}
if (_apiController.ServerState is not (ServerState.Reconnecting or ServerState.Disconnecting))
{
using (ImRaii.PushColor(ImGuiCol.Text, color))
{
if (_uiSharedService.IconButton(connectedIcon))
{
if (isConnectingOrConnected && !_serverManager.CurrentServer.FullPause)
{
_serverManager.CurrentServer.FullPause = true;
_serverManager.Save();
}
else if (!isConnectingOrConnected && _serverManager.CurrentServer.FullPause)
{
_serverManager.CurrentServer.FullPause = false;
_serverManager.Save();
}
_ = _apiController.CreateConnectionsAsync();
}
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
{
Selune.RegisterHighlight(
ImGui.GetItemRectMin(),
ImGui.GetItemRectMax(),
SeluneHighlightMode.Both,
borderOnly: true,
borderThicknessOverride: _connectButtonHighlightThickness,
exactSize: true,
clipToElement: true,
roundingOverride: ImGui.GetStyle().FrameRounding);
}
UiSharedService.AttachToolTip(isConnectingOrConnected ? "Disconnect from " + _serverManager.CurrentServer.ServerName : "Connect to " + _serverManager.CurrentServer.ServerName);
}
}
private void DrawTransfers()
{
var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot();
@@ -492,11 +441,9 @@ public class CompactUi : WindowMediatorSubscriberBase
return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes);
}
[StructLayout(LayoutKind.Auto)]
private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes)
{
public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0;
}
#endregion
#region Header Drawing
private void DrawUIDHeader()
{
@@ -532,21 +479,52 @@ public class CompactUi : WindowMediatorSubscriberBase
using (_uiSharedService.IconFont.Push())
iconSize = ImGui.CalcTextSize(FontAwesomeIcon.PersonCirclePlus.ToIconString());
float contentWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X;
float uidStartX = (contentWidth - uidTextSize.X) / 2f;
float uidStartX = 25f;
float cursorY = ImGui.GetCursorPosY();
ImGui.SetCursorPosY(cursorY);
ImGui.SetCursorPosX(uidStartX);
bool headerItemClicked;
using (_uiSharedService.UidFont.Push())
{
if (useVanityColors)
{
var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor);
var cursorPos = ImGui.GetCursorScreenPos();
var targetFontSize = ImGui.GetFontSize();
var font = ImGui.GetFont();
SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, targetFontSize ,font , "uid-header");
}
else
{
ImGui.TextColored(uidColor, uidText);
}
}
// Get the actual rendered text rect for proper icon alignment
var uidTextRect = ImGui.GetItemRectMax() - ImGui.GetItemRectMin();
var uidTextRectMin = ImGui.GetItemRectMin();
var uidTextHovered = ImGui.IsItemHovered();
headerItemClicked = ImGui.IsItemClicked();
// Track position for icons next to UID text
// Use uidTextSize.Y (actual font height) for vertical centering, not hitbox height
float nextIconX = uidTextRectMin.X + uidTextRect.X + 10f;
float iconYOffset = (uidTextSize.Y - iconSize.Y) * 0.5f;
float textVerticalOffset = (uidTextRect.Y - uidTextSize.Y) * 0.5f;
var buttonSize = new Vector2(iconSize.X, uidTextSize.Y);
if (_configService.Current.BroadcastEnabled && _apiController.IsConnected)
{
float iconYOffset = (uidTextSize.Y - iconSize.Y) * 0.5f;
var buttonSize = new Vector2(iconSize.X, uidTextSize.Y);
ImGui.SetCursorScreenPos(new Vector2(nextIconX, uidTextRectMin.Y + textVerticalOffset));
ImGui.SetCursorPos(new Vector2(ImGui.GetStyle().ItemSpacing.X + 5f, cursorY));
ImGui.InvisibleButton("BroadcastIcon", buttonSize);
var iconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset);
using (_uiSharedService.IconFont.Push())
ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.PersonCirclePlus.ToIconString());
ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.Wifi.ToIconString());
nextIconX = ImGui.GetItemRectMax().X + 6f;
if (ImGui.IsItemHovered())
@@ -619,49 +597,7 @@ public class CompactUi : WindowMediatorSubscriberBase
_lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
}
ImGui.SetCursorPosY(cursorY);
ImGui.SetCursorPosX(uidStartX);
bool headerItemClicked;
using (_uiSharedService.UidFont.Push())
{
if (useVanityColors)
{
var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor);
var cursorPos = ImGui.GetCursorScreenPos();
var targetFontSize = ImGui.GetFontSize();
var font = ImGui.GetFont();
SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, targetFontSize ,font , "uid-header");
}
else
{
ImGui.TextColored(uidColor, uidText);
}
}
if (ImGui.IsItemHovered())
{
var padding = new Vector2(35f * ImGuiHelpers.GlobalScale);
Selune.RegisterHighlight(
ImGui.GetItemRectMin() - padding,
ImGui.GetItemRectMax() + padding,
SeluneHighlightMode.Point,
exactSize: true,
clipToElement: true,
clipPadding: padding,
highlightColorOverride: vanityGlowColor,
highlightAlphaOverride: 0.05f);
}
headerItemClicked = ImGui.IsItemClicked();
if (headerItemClicked)
{
ImGui.SetClipboardText(uidText);
}
UiSharedService.AttachToolTip("Click to copy");
// Warning threshold icon (next to lightfinder or UID text)
if (_apiController.ServerState is ServerState.Connected && analysisSummary.HasData)
{
var objectSummary = analysisSummary.Objects.Values.FirstOrDefault(summary => summary.HasEntries);
@@ -675,24 +611,30 @@ public class CompactUi : WindowMediatorSubscriberBase
if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds)
{
ImGui.SameLine();
ImGui.SetCursorPosY(cursorY + 15f);
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow"));
ImGui.SetCursorScreenPos(new Vector2(nextIconX, uidTextRectMin.Y + textVerticalOffset));
string warningMessage = "";
if (isOverTriHold)
{
warningMessage += $"You exceed your own triangles threshold by " +
$"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles.";
warningMessage += Environment.NewLine;
ImGui.InvisibleButton("WarningThresholdIcon", buttonSize);
var warningIconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset);
using (_uiSharedService.IconFont.Push())
ImGui.GetWindowDrawList().AddText(warningIconPos, ImGui.GetColorU32(UIColors.Get("LightlessYellow")), FontAwesomeIcon.ExclamationTriangle.ToIconString());
}
if (isOverVRAMUsage)
if (ImGui.IsItemHovered())
{
warningMessage += $"You exceed your own VRAM threshold by " +
$"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}.";
string warningMessage = "";
if (isOverTriHold)
{
warningMessage += $"You exceed your own triangles threshold by " +
$"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles.";
warningMessage += Environment.NewLine;
}
if (isOverVRAMUsage)
{
warningMessage += $"You exceed your own VRAM threshold by " +
$"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}.";
}
UiSharedService.AttachToolTip(warningMessage);
}
UiSharedService.AttachToolTip(warningMessage);
if (ImGui.IsItemClicked())
{
_lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi)));
@@ -701,6 +643,34 @@ public class CompactUi : WindowMediatorSubscriberBase
}
}
if (uidTextHovered)
{
var padding = new Vector2(35f * ImGuiHelpers.GlobalScale);
Selune.RegisterHighlight(
uidTextRectMin - padding,
uidTextRectMin + uidTextRect + padding,
SeluneHighlightMode.Point,
exactSize: true,
clipToElement: true,
clipPadding: padding,
highlightColorOverride: vanityGlowColor,
highlightAlphaOverride: 0.05f);
ImGui.SetTooltip("Click to copy");
}
if (headerItemClicked)
{
ImGui.SetClipboardText(uidText);
}
// Connect/Disconnect button next to big UID (use screen pos to avoid affecting layout)
DrawConnectButton(uidTextRectMin.Y + textVerticalOffset, uidTextSize.Y);
// Add spacing below the big UID
ImGuiHelpers.ScaledDummy(5f);
if (_apiController.ServerState is ServerState.Connected)
{
if (headerItemClicked)
@@ -708,10 +678,12 @@ public class CompactUi : WindowMediatorSubscriberBase
ImGui.SetClipboardText(_apiController.DisplayName);
}
if (!string.Equals(_apiController.DisplayName, _apiController.UID, StringComparison.Ordinal))
// Only show smaller UID line if DisplayName differs from UID (custom vanity name)
bool hasCustomName = !string.Equals(_apiController.DisplayName, _apiController.UID, StringComparison.OrdinalIgnoreCase);
if (hasCustomName)
{
var origTextSize = ImGui.CalcTextSize(_apiController.UID);
ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X) / 2 - (origTextSize.X / 2));
ImGui.SetCursorPosX(uidStartX);
if (useVanityColors)
{
@@ -746,14 +718,88 @@ public class CompactUi : WindowMediatorSubscriberBase
{
ImGui.SetClipboardText(_apiController.UID);
}
// Users Online on same line as smaller UID (with separator)
ImGui.SameLine();
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("|");
ImGui.SameLine();
ImGui.TextColored(UIColors.Get("LightlessGreen"), _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture));
ImGui.SameLine();
ImGui.TextUnformatted("Users Online");
}
else
{
// No custom name - just show Users Online aligned to uidStartX
ImGui.SetCursorPosX(uidStartX);
ImGui.TextColored(UIColors.Get("LightlessGreen"), _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture));
ImGui.SameLine();
ImGui.TextUnformatted("Users Online");
}
}
else
{
ImGui.SetCursorPosX(uidStartX);
UiSharedService.ColorTextWrapped(_apiController.ServerState.GetServerError(_apiController.AuthFailureMessage), uidColor);
}
}
private void DrawConnectButton(float screenY, float textHeight)
{
var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Link);
bool isConnectingOrConnected = _apiController.ServerState is ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting;
var color = UiSharedService.GetBoolColor(!isConnectingOrConnected);
var connectedIcon = isConnectingOrConnected ? FontAwesomeIcon.Unlink : FontAwesomeIcon.Link;
// Position on right side, vertically centered with text
if (_apiController.ServerState is not (ServerState.Reconnecting or ServerState.Disconnecting))
{
var windowPos = ImGui.GetWindowPos();
var screenX = windowPos.X + UiSharedService.GetWindowContentRegionWidth() - buttonSize.X - 13f;
var yOffset = (textHeight - buttonSize.Y) * 0.5f;
ImGui.SetCursorScreenPos(new Vector2(screenX, screenY + yOffset));
using (ImRaii.PushColor(ImGuiCol.Text, color))
using (ImRaii.PushColor(ImGuiCol.Button, ImGui.ColorConvertFloat4ToU32(new(0, 0, 0, 0))))
{
if (_uiSharedService.IconButton(connectedIcon, buttonSize.Y))
{
if (isConnectingOrConnected && !_serverManager.CurrentServer.FullPause)
{
_serverManager.CurrentServer.FullPause = true;
_serverManager.Save();
}
else if (!isConnectingOrConnected && _serverManager.CurrentServer.FullPause)
{
_serverManager.CurrentServer.FullPause = false;
_serverManager.Save();
}
_ = _apiController.CreateConnectionsAsync();
}
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
{
Selune.RegisterHighlight(
ImGui.GetItemRectMin(),
ImGui.GetItemRectMax(),
SeluneHighlightMode.Both,
borderOnly: true,
borderThicknessOverride: ConnectButtonHighlightThickness,
exactSize: true,
clipToElement: true,
roundingOverride: ImGui.GetStyle().FrameRounding);
}
UiSharedService.AttachToolTip(isConnectingOrConnected ? "Disconnect from " + _serverManager.CurrentServer.ServerName : "Connect to " + _serverManager.CurrentServer.ServerName);
}
}
#endregion
#region Folder Building
private IEnumerable<IDrawFolder> DrawFolders
{
get
@@ -889,6 +935,10 @@ public class CompactUi : WindowMediatorSubscriberBase
}
}
#endregion
#region Filtering & Sorting
private static bool PassesFilter(PairUiEntry entry, string filter)
{
if (string.IsNullOrEmpty(filter)) return true;
@@ -944,6 +994,7 @@ public class CompactUi : WindowMediatorSubscriberBase
VisiblePairSortMode.VramUsage => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateVramBytes),
VisiblePairSortMode.EffectiveVramUsage => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateEffectiveVramBytes),
VisiblePairSortMode.TriangleCount => SortVisibleByMetric(entryList, e => e.LastAppliedDataTris),
VisiblePairSortMode.EffectiveTriangleCount => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateEffectiveTris),
VisiblePairSortMode.Alphabetical => [.. entryList.OrderBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase)],
VisiblePairSortMode.PreferredDirectPairs => SortVisibleByPreferred(entryList),
_ => SortEntries(entryList),
@@ -1032,10 +1083,11 @@ public class CompactUi : WindowMediatorSubscriberBase
return SortGroupEntries(entries, group);
}
private void UiSharedService_GposeEnd()
{
IsOpen = _wasOpen;
}
#endregion
#region GPose Handlers
private void UiSharedService_GposeEnd() => IsOpen = _wasOpen;
private void UiSharedService_GposeStart()
{
@@ -1043,6 +1095,10 @@ public class CompactUi : WindowMediatorSubscriberBase
IsOpen = false;
}
#endregion
#region Focus Tracking
private void RegisterFocusCharacter(Pair pair)
{
_pendingFocusPair = pair;
@@ -1088,4 +1144,16 @@ public class CompactUi : WindowMediatorSubscriberBase
_pendingFocusPair = null;
_pendingFocusFrame = -1;
}
#endregion
#region Helper Types
[StructLayout(LayoutKind.Auto)]
private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes)
{
public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0;
}
#endregion
}

View File

@@ -326,6 +326,7 @@ public class DrawFolderTag : DrawFolderBase
VisiblePairSortMode.VramUsage => "VRAM usage (descending)",
VisiblePairSortMode.EffectiveVramUsage => "Effective VRAM usage (descending)",
VisiblePairSortMode.TriangleCount => "Triangle count (descending)",
VisiblePairSortMode.EffectiveTriangleCount => "Effective triangle count (descending)",
VisiblePairSortMode.PreferredDirectPairs => "Preferred permissions & Direct pairs",
_ => "Default",
};

View File

@@ -37,6 +37,7 @@ public class DrawUserPair
private readonly UiSharedService _uiSharedService;
private readonly PlayerPerformanceConfigService _performanceConfigService;
private readonly LightlessConfigService _configService;
private readonly LocationShareService _locationShareService;
private readonly CharaDataManager _charaDataManager;
private readonly PairLedger _pairLedger;
private float _menuWidth = -1;
@@ -57,6 +58,7 @@ public class DrawUserPair
UiSharedService uiSharedService,
PlayerPerformanceConfigService performanceConfigService,
LightlessConfigService configService,
LocationShareService locationShareService,
CharaDataManager charaDataManager,
PairLedger pairLedger)
{
@@ -74,6 +76,7 @@ public class DrawUserPair
_uiSharedService = uiSharedService;
_performanceConfigService = performanceConfigService;
_configService = configService;
_locationShareService = locationShareService;
_charaDataManager = charaDataManager;
_pairLedger = pairLedger;
}
@@ -216,6 +219,48 @@ public class DrawUserPair
_ = _apiController.UserSetPairPermissions(new UserPermissionsDto(_pair.UserData, permissions));
}
UiSharedService.AttachToolTip("Changes VFX sync permissions with this user." + (individual ? individualText : string.Empty));
ImGui.SetCursorPosX(10f);
_uiSharedService.IconText(FontAwesomeIcon.Globe);
ImGui.SameLine();
if (ImGui.BeginMenu("Toggle Location sharing"))
{
if (ImGui.MenuItem("Share for 30 Mins"))
{
_ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.UtcNow.AddMinutes(30));
}
if (ImGui.MenuItem("Share for 1 Hour"))
{
_ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.UtcNow.AddHours(1));
}
if (ImGui.MenuItem("Share for 3 Hours"))
{
_ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.UtcNow.AddHours(3));
}
if (ImGui.MenuItem("Share until manually stop"))
{
_ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.MaxValue);
}
ImGui.Separator();
if (ImGui.MenuItem("Stop Sharing"))
{
_ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.MinValue);
}
ImGui.EndMenu();
}
}
private async Task ToggleLocationSharing(List<string> users, DateTimeOffset expireAt)
{
var updated = await _apiController.ToggleLocationSharing(new LocationSharingToggleDto(users, expireAt)).ConfigureAwait(false);
if (updated)
{
_locationShareService.UpdateSharingStatus(users, expireAt);
}
}
private void DrawIndividualMenu()
@@ -384,6 +429,7 @@ public class DrawUserPair
_pair.LastAppliedApproximateVRAMBytes,
_pair.LastAppliedApproximateEffectiveVRAMBytes,
_pair.LastAppliedDataTris,
_pair.LastAppliedApproximateEffectiveTris,
_pair.IsPaired,
groupDisplays is null ? ImmutableArray<string>.Empty : ImmutableArray.CreateRange(groupDisplays));
@@ -399,6 +445,8 @@ public class DrawUserPair
private static string BuildTooltip(in TooltipSnapshot snapshot)
{
var builder = new StringBuilder(256);
static string FormatTriangles(long count) =>
count > 1000 ? (count / 1000d).ToString("0.0'k'") : count.ToString();
if (snapshot.IsPaused)
{
@@ -465,9 +513,13 @@ public class DrawUserPair
{
builder.Append(Environment.NewLine);
builder.Append("Approx. Triangle Count (excl. Vanilla): ");
builder.Append(snapshot.LastAppliedDataTris > 1000
? (snapshot.LastAppliedDataTris / 1000d).ToString("0.0'k'")
: snapshot.LastAppliedDataTris);
builder.Append(FormatTriangles(snapshot.LastAppliedDataTris));
if (snapshot.LastAppliedApproximateEffectiveTris >= 0)
{
builder.Append(" (Effective: ");
builder.Append(FormatTriangles(snapshot.LastAppliedApproximateEffectiveTris));
builder.Append(')');
}
}
}
@@ -499,11 +551,12 @@ public class DrawUserPair
long LastAppliedApproximateVRAMBytes,
long LastAppliedApproximateEffectiveVRAMBytes,
long LastAppliedDataTris,
long LastAppliedApproximateEffectiveTris,
bool IsPaired,
ImmutableArray<string> GroupDisplays)
{
public static TooltipSnapshot Empty { get; } =
new(false, false, false, IndividualPairStatus.None, string.Empty, string.Empty, -1, -1, -1, -1, false, ImmutableArray<string>.Empty);
new(false, false, false, IndividualPairStatus.None, string.Empty, string.Empty, -1, -1, -1, -1, -1, false, ImmutableArray<string>.Empty);
}
private void DrawPairedClientMenu()
@@ -575,6 +628,71 @@ public class DrawUserPair
var individualIsSticky = _pair.UserPair!.OwnPermissions.IsSticky();
var individualIcon = individualIsSticky ? FontAwesomeIcon.ArrowCircleUp : FontAwesomeIcon.InfoCircle;
var shareLocationIcon = FontAwesomeIcon.Globe;
var location = _locationShareService.GetUserLocation(_pair.UserPair!.User.UID);
var shareLocation = !string.IsNullOrEmpty(location);
var expireAt = _locationShareService.GetSharingStatus(_pair.UserPair!.User.UID);
var shareLocationToOther = expireAt > DateTimeOffset.UtcNow;
var shareColor = shareLocation switch
{
true when shareLocationToOther => UIColors.Get("LightlessGreen"),
true when !shareLocationToOther => UIColors.Get("LightlessBlue"),
_ => UIColors.Get("LightlessYellow"),
};
if (shareLocation || shareLocationToOther)
{
currentRightSide -= (_uiSharedService.GetIconSize(shareLocationIcon).X + spacingX);
ImGui.SameLine(currentRightSide);
using (ImRaii.PushColor(ImGuiCol.Text, shareColor, shareLocation || shareLocationToOther))
_uiSharedService.IconText(shareLocationIcon);
if (ImGui.IsItemHovered())
{
ImGui.BeginTooltip();
if (_pair.IsOnline)
{
if (shareLocation)
{
if (!string.IsNullOrEmpty(location))
{
_uiSharedService.IconText(FontAwesomeIcon.LocationArrow);
ImGui.SameLine();
ImGui.TextUnformatted(location);
}
else
{
ImGui.TextUnformatted("Location info not updated, reconnect or wait for update.");
}
}
else
{
ImGui.TextUnformatted("NOT Sharing location with you. o(TヘTo)");
}
}
else
{
ImGui.TextUnformatted("User not online. (´・ω・`)?");
}
ImGui.Separator();
if (shareLocationToOther)
{
ImGui.TextUnformatted("Sharing your location. ヾ(•ω•`)o");
if (expireAt != DateTimeOffset.MaxValue)
{
ImGui.TextUnformatted("Expires at " + expireAt.ToLocalTime().ToString("g"));
}
}
else
{
ImGui.TextUnformatted("NOT sharing your location.  ̄へ ̄");
}
ImGui.EndTooltip();
}
}
if (individualAnimDisabled || individualSoundsDisabled || individualVFXDisabled || individualIsSticky)
{
currentRightSide -= (_uiSharedService.GetIconSize(individualIcon).X + spacingX);

View File

@@ -11,6 +11,7 @@ using LightlessSync.LightlessConfiguration;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.TextureCompression;
using LightlessSync.UI.Models;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using OtterTex;
@@ -34,12 +35,15 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
private const float TextureDetailSplitterWidth = 12f;
private const float TextureDetailSplitterCollapsedWidth = 18f;
private const float SelectedFilePanelLogicalHeight = 90f;
private const float TextureHoverPreviewDelaySeconds = 1.75f;
private const float TextureHoverPreviewSize = 350f;
private static readonly Vector4 SelectedTextureRowTextColor = new(0f, 0f, 0f, 1f);
private readonly CharacterAnalyzer _characterAnalyzer;
private readonly Progress<TextureConversionProgress> _conversionProgress = new();
private readonly IpcManager _ipcManager;
private readonly UiSharedService _uiSharedService;
private readonly LightlessConfigService _configService;
private readonly PlayerPerformanceConfigService _playerPerformanceConfig;
private readonly TransientResourceManager _transientResourceManager;
private readonly TransientConfigService _transientConfigService;
@@ -77,6 +81,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
private string _selectedJobEntry = string.Empty;
private string _filterGamePath = string.Empty;
private string _filterFilePath = string.Empty;
private string _textureHoverKey = string.Empty;
private int _conversionCurrentFileProgress = 0;
private int _conversionTotalJobs;
@@ -87,6 +92,11 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
private bool _textureRowsDirty = true;
private bool _textureDetailCollapsed = false;
private bool _conversionFailed;
private double _textureHoverStartTime = 0;
#if DEBUG
private bool _debugCompressionModalOpen = false;
private TextureConversionProgress? _debugConversionProgress;
#endif
private bool _showAlreadyAddedTransients = false;
private bool _acknowledgeReview = false;
@@ -98,10 +108,12 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
private TextureUsageCategory? _textureCategoryFilter = null;
private TextureMapKind? _textureMapFilter = null;
private TextureCompressionTarget? _textureTargetFilter = null;
private TextureFormatSortMode _textureFormatSortMode = TextureFormatSortMode.None;
public DataAnalysisUi(ILogger<DataAnalysisUi> logger, LightlessMediator mediator,
CharacterAnalyzer characterAnalyzer, IpcManager ipcManager,
PerformanceCollectorService performanceCollectorService, UiSharedService uiSharedService,
LightlessConfigService configService,
PlayerPerformanceConfigService playerPerformanceConfig, TransientResourceManager transientResourceManager,
TransientConfigService transientConfigService, TextureCompressionService textureCompressionService,
TextureMetadataHelper textureMetadataHelper)
@@ -110,6 +122,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
_characterAnalyzer = characterAnalyzer;
_ipcManager = ipcManager;
_uiSharedService = uiSharedService;
_configService = configService;
_playerPerformanceConfig = playerPerformanceConfig;
_transientResourceManager = transientResourceManager;
_transientConfigService = transientConfigService;
@@ -135,21 +148,30 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
private void HandleConversionModal()
{
if (_conversionTask == null)
bool hasConversion = _conversionTask != null;
#if DEBUG
bool showDebug = _debugCompressionModalOpen && !hasConversion;
#else
const bool showDebug = false;
#endif
if (!hasConversion && !showDebug)
{
return;
}
if (_conversionTask.IsCompleted)
if (hasConversion && _conversionTask!.IsCompleted)
{
ResetConversionModalState();
return;
if (!showDebug)
{
return;
}
}
_showModal = true;
if (ImGui.BeginPopupModal("Texture Compression in Progress", ImGuiWindowFlags.AlwaysAutoResize))
if (ImGui.BeginPopupModal("Texture Compression in Progress", UiSharedService.PopupWindowFlags))
{
DrawConversionModalContent();
DrawConversionModalContent(showDebug);
ImGui.EndPopup();
}
else
@@ -164,31 +186,190 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
}
}
private void DrawConversionModalContent()
private void DrawConversionModalContent(bool isDebugPreview)
{
var progress = _lastConversionProgress;
var scale = ImGuiHelpers.GlobalScale;
TextureConversionProgress? progress;
#if DEBUG
progress = isDebugPreview ? _debugConversionProgress : _lastConversionProgress;
#else
progress = _lastConversionProgress;
#endif
var total = progress?.Total ?? Math.Max(_conversionTotalJobs, 1);
var completed = progress != null
? Math.Min(progress.Completed + 1, total)
: _conversionCurrentFileProgress;
var currentLabel = !string.IsNullOrEmpty(_conversionCurrentFileName)
? _conversionCurrentFileName
: "Preparing...";
? Math.Clamp(progress.Completed + 1, 0, total)
: Math.Clamp(_conversionCurrentFileProgress, 0, total);
var percent = total > 0 ? Math.Clamp(completed / (float)total, 0f, 1f) : 0f;
ImGui.TextUnformatted($"Compressing textures ({completed}/{total})");
UiSharedService.TextWrapped("Current file: " + currentLabel);
var job = progress?.CurrentJob;
var inputPath = job?.InputFile ?? string.Empty;
var targetLabel = job != null ? job.TargetType.ToString() : "Unknown";
var currentLabel = !string.IsNullOrEmpty(inputPath)
? Path.GetFileName(inputPath)
: !string.IsNullOrEmpty(_conversionCurrentFileName) ? _conversionCurrentFileName : "Preparing...";
var mapKind = !string.IsNullOrEmpty(inputPath)
? _textureMetadataHelper.DetermineMapKind(inputPath)
: TextureMapKind.Unknown;
if (_conversionFailed)
var accent = UIColors.Get("LightlessPurple");
var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.18f);
var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.4f);
var headerHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * 2.6f, 46f * scale);
using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f * scale))
using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize)))
using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(12f * scale, 6f * scale)))
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(12f * scale, 2f * scale)))
using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(accentBg)))
using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(accentBorder)))
using (var header = ImRaii.Child("compressionHeader", new Vector2(-1f, headerHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse))
{
UiSharedService.ColorText("Conversion encountered errors. Please review the log for details.", ImGuiColors.DalamudRed);
if (header)
{
if (ImGui.BeginTable("compressionHeaderTable", 2,
ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.NoHostExtendX))
{
ImGui.TableNextRow();
ImGui.TableNextColumn();
DrawCompressionTitle(accent, scale);
var statusText = isDebugPreview ? "Preview mode" : "Working...";
var statusColor = isDebugPreview ? UIColors.Get("LightlessYellow") : ImGuiColors.DalamudGrey;
UiSharedService.ColorText(statusText, statusColor);
ImGui.TableNextColumn();
var progressText = $"{completed}/{total}";
var percentText = $"{percent * 100f:0}%";
var summaryText = $"{progressText} ({percentText})";
var summaryWidth = ImGui.CalcTextSize(summaryText).X;
ImGui.SetCursorPosX(ImGui.GetCursorPosX() + MathF.Max(0f, ImGui.GetColumnWidth() - summaryWidth));
UiSharedService.ColorText(summaryText, ImGuiColors.DalamudGrey);
ImGui.EndTable();
}
}
}
if (_uiSharedService.IconTextButton(FontAwesomeIcon.StopCircle, "Cancel conversion"))
ImGuiHelpers.ScaledDummy(6);
using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 4f * scale))
using (ImRaii.PushStyle(ImGuiStyleVar.FramePadding, new Vector2(0f, 4f * scale)))
using (ImRaii.PushColor(ImGuiCol.FrameBg, UiSharedService.Color(new Vector4(0.15f, 0.15f, 0.18f, 1f))))
using (ImRaii.PushColor(ImGuiCol.PlotHistogram, UiSharedService.Color(accent)))
{
_conversionCancellationTokenSource.Cancel();
ImGui.ProgressBar(percent, new Vector2(-1f, 0f), $"{percent * 100f:0}%");
}
UiSharedService.SetScaledWindowSize(520);
ImGuiHelpers.ScaledDummy(6);
var infoAccent = UIColors.Get("LightlessBlue");
var infoBg = new Vector4(infoAccent.X, infoAccent.Y, infoAccent.Z, 0.12f);
var infoBorder = new Vector4(infoAccent.X, infoAccent.Y, infoAccent.Z, 0.32f);
const int detailRows = 3;
var detailHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * (detailRows + 1.2f), 72f * scale);
using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 5f * scale))
using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize)))
using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(10f * scale, 6f * scale)))
using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(infoBg)))
using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(infoBorder)))
using (var details = ImRaii.Child("compressionDetail", new Vector2(-1f, detailHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse))
{
if (details)
{
if (ImGui.BeginTable("compressionDetailTable", 2,
ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.PadOuterX))
{
DrawDetailRow("Current file", currentLabel, inputPath);
DrawDetailRow("Target format", targetLabel, null);
DrawDetailRow("Map type", mapKind.ToString(), null);
ImGui.EndTable();
}
}
}
if (_conversionFailed && !isDebugPreview)
{
ImGuiHelpers.ScaledDummy(4);
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, ImGuiColors.DalamudRed);
ImGui.SameLine(0f, 6f * scale);
UiSharedService.TextWrapped("Conversion encountered errors. Please review the log for details.", color: ImGuiColors.DalamudRed);
}
ImGuiHelpers.ScaledDummy(6);
if (!isDebugPreview)
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.StopCircle, "Cancel conversion"))
{
_conversionCancellationTokenSource.Cancel();
}
}
else
{
#if DEBUG
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Times, "Close preview"))
{
CloseDebugCompressionModal();
}
#endif
}
UiSharedService.SetScaledWindowSize(600);
void DrawDetailRow(string label, string value, string? tooltip)
{
ImGui.TableNextRow();
ImGui.TableNextColumn();
using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudGrey))
{
ImGui.TextUnformatted(label);
}
ImGui.TableNextColumn();
ImGui.TextUnformatted(value);
if (!string.IsNullOrEmpty(tooltip))
{
UiSharedService.AttachToolTip(tooltip);
}
}
void DrawCompressionTitle(Vector4 iconColor, float localScale)
{
const string title = "Texture Compression";
var spacing = 6f * localScale;
var iconText = FontAwesomeIcon.CompressArrowsAlt.ToIconString();
Vector2 iconSize;
using (_uiSharedService.IconFont.Push())
{
iconSize = ImGui.CalcTextSize(iconText);
}
Vector2 titleSize;
using (_uiSharedService.MediumFont.Push())
{
titleSize = ImGui.CalcTextSize(title);
}
var lineHeight = MathF.Max(iconSize.Y, titleSize.Y);
var iconOffsetY = (lineHeight - iconSize.Y) / 2f;
var textOffsetY = (lineHeight - titleSize.Y) / 2f;
var start = ImGui.GetCursorScreenPos();
var drawList = ImGui.GetWindowDrawList();
using (_uiSharedService.IconFont.Push())
{
drawList.AddText(new Vector2(start.X, start.Y + iconOffsetY), UiSharedService.Color(iconColor), iconText);
}
using (_uiSharedService.MediumFont.Push())
{
var textPos = new Vector2(start.X + iconSize.X + spacing, start.Y + textOffsetY);
drawList.AddText(textPos, ImGui.GetColorU32(ImGuiCol.Text), title);
}
ImGui.Dummy(new Vector2(iconSize.X + spacing + titleSize.X, lineHeight));
}
}
private void ResetConversionModalState()
@@ -202,6 +383,41 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
_conversionTotalJobs = 0;
}
#if DEBUG
private void OpenCompressionDebugModal()
{
if (_conversionTask != null && !_conversionTask.IsCompleted)
{
return;
}
_debugCompressionModalOpen = true;
_debugConversionProgress = new TextureConversionProgress(
Completed: 3,
Total: 10,
CurrentJob: new TextureConversionJob(
@"C:\Lightless\Mods\Textures\example_diffuse.tex",
@"C:\Lightless\Mods\Textures\example_diffuse_bc7.tex",
Penumbra.Api.Enums.TextureType.Bc7Tex));
_showModal = true;
_modalOpen = false;
}
private void ResetDebugCompressionModalState()
{
_debugCompressionModalOpen = false;
_debugConversionProgress = null;
}
private void CloseDebugCompressionModal()
{
ResetDebugCompressionModalState();
_showModal = false;
_modalOpen = false;
ImGui.CloseCurrentPopup();
}
#endif
private void RefreshAnalysisCache()
{
if (!_hasUpdate)
@@ -757,6 +973,16 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
ResetTextureFilters();
InvalidateTextureRows();
_conversionFailed = false;
#if DEBUG
ResetDebugCompressionModalState();
#endif
var savedFormatSort = _configService.Current.TextureFormatSortMode;
if (!Enum.IsDefined(typeof(TextureFormatSortMode), savedFormatSort))
{
savedFormatSort = TextureFormatSortMode.None;
}
SetTextureFormatSortMode(savedFormatSort, persist: false);
}
protected override void Dispose(bool disposing)
@@ -1955,6 +2181,17 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
{
InvalidateTextureRows();
}
#if DEBUG
ImGui.SameLine();
using (ImRaii.Disabled(conversionRunning || !UiSharedService.CtrlPressed()))
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Cog, "Preview popup (debug)", 200f * scale))
{
OpenCompressionDebugModal();
}
}
UiSharedService.AttachToolTip("Hold CTRL to open the compression popup preview.");
#endif
TextureRow? lastSelected = null;
using (var table = ImRaii.Table("textureDataTable", 9,
@@ -1973,26 +2210,56 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
ImGui.TableSetupColumn("Original", ImGuiTableColumnFlags.PreferSortDescending);
ImGui.TableSetupColumn("Compressed", ImGuiTableColumnFlags.PreferSortDescending);
ImGui.TableSetupScrollFreeze(0, 1);
ImGui.TableHeadersRow();
DrawTextureTableHeaderRow();
var targets = _textureCompressionService.SelectableTargets;
IEnumerable<TextureRow> orderedRows = rows;
var sortSpecs = ImGui.TableGetSortSpecs();
var sizeSortColumn = -1;
var sizeSortDirection = ImGuiSortDirection.Ascending;
if (sortSpecs.SpecsCount > 0)
{
var spec = sortSpecs.Specs[0];
orderedRows = spec.ColumnIndex switch
if (spec.ColumnIndex is 7 or 8)
{
7 => spec.SortDirection == ImGuiSortDirection.Ascending
? rows.OrderBy(r => r.OriginalSize)
: rows.OrderByDescending(r => r.OriginalSize),
8 => spec.SortDirection == ImGuiSortDirection.Ascending
? rows.OrderBy(r => r.CompressedSize)
: rows.OrderByDescending(r => r.CompressedSize),
_ => rows
};
sizeSortColumn = spec.ColumnIndex;
sizeSortDirection = spec.SortDirection;
}
}
var hasSizeSort = sizeSortColumn != -1;
var indexedRows = rows.Select((row, idx) => (row, idx));
if (_textureFormatSortMode != TextureFormatSortMode.None)
{
bool compressedFirst = _textureFormatSortMode == TextureFormatSortMode.CompressedFirst;
int GroupKey(TextureRow row) => row.IsAlreadyCompressed == compressedFirst ? 0 : 1;
long SizeKey(TextureRow row) => sizeSortColumn == 7 ? row.OriginalSize : row.CompressedSize;
var ordered = indexedRows.OrderBy(pair => GroupKey(pair.row));
if (hasSizeSort)
{
ordered = sizeSortDirection == ImGuiSortDirection.Ascending
? ordered.ThenBy(pair => SizeKey(pair.row))
: ordered.ThenByDescending(pair => SizeKey(pair.row));
}
orderedRows = ordered
.ThenBy(pair => pair.idx)
.Select(pair => pair.row);
}
else if (hasSizeSort)
{
long SizeKey(TextureRow row) => sizeSortColumn == 7 ? row.OriginalSize : row.CompressedSize;
orderedRows = sizeSortDirection == ImGuiSortDirection.Ascending
? indexedRows.OrderBy(pair => SizeKey(pair.row)).ThenBy(pair => pair.idx).Select(pair => pair.row)
: indexedRows.OrderByDescending(pair => SizeKey(pair.row)).ThenBy(pair => pair.idx).Select(pair => pair.row);
}
if (sortSpecs.SpecsCount > 0)
{
sortSpecs.SpecsDirty = false;
}
@@ -2034,6 +2301,79 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
}
}
}
private void DrawTextureTableHeaderRow()
{
ImGui.TableNextRow(ImGuiTableRowFlags.Headers);
DrawHeaderCell(0, "##select");
DrawHeaderCell(1, "Texture");
DrawHeaderCell(2, "Slot");
DrawHeaderCell(3, "Map");
DrawFormatHeaderCell();
DrawHeaderCell(5, "Recommended");
DrawHeaderCell(6, "Target");
DrawHeaderCell(7, "Original");
DrawHeaderCell(8, "Compressed");
}
private static void DrawHeaderCell(int columnIndex, string label)
{
ImGui.TableSetColumnIndex(columnIndex);
ImGui.TableHeader(label);
}
private void DrawFormatHeaderCell()
{
ImGui.TableSetColumnIndex(4);
ImGui.TableHeader(GetFormatHeaderLabel());
if (ImGui.IsItemClicked(ImGuiMouseButton.Left))
{
CycleTextureFormatSortMode();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Click to cycle sort: normal, compressed first, uncompressed first.");
}
}
private string GetFormatHeaderLabel()
=> _textureFormatSortMode switch
{
TextureFormatSortMode.CompressedFirst => "Format (C)##formatHeader",
TextureFormatSortMode.UncompressedFirst => "Format (U)##formatHeader",
_ => "Format##formatHeader"
};
private void SetTextureFormatSortMode(TextureFormatSortMode mode, bool persist = true)
{
if (_textureFormatSortMode == mode)
{
return;
}
_textureFormatSortMode = mode;
if (persist)
{
_configService.Current.TextureFormatSortMode = mode;
_configService.Save();
}
}
private void CycleTextureFormatSortMode()
{
var nextMode = _textureFormatSortMode switch
{
TextureFormatSortMode.None => TextureFormatSortMode.CompressedFirst,
TextureFormatSortMode.CompressedFirst => TextureFormatSortMode.UncompressedFirst,
_ => TextureFormatSortMode.None
};
SetTextureFormatSortMode(nextMode);
}
private void StartTextureConversion()
{
if (_conversionTask != null && !_conversionTask.IsCompleted)
@@ -2183,7 +2523,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
bool toggleClicked = false;
if (showToggle)
{
var icon = isCollapsed ? FontAwesomeIcon.ChevronRight : FontAwesomeIcon.ChevronLeft;
var icon = !isCollapsed ? FontAwesomeIcon.ChevronRight : FontAwesomeIcon.ChevronLeft;
Vector2 iconSize;
using (_uiSharedService.IconFont.Push())
{
@@ -2335,11 +2675,30 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
{
if (_texturePreviews.TryGetValue(key, out var state))
{
var loadTask = state.LoadTask;
if (loadTask is { IsCompleted: false })
{
_ = loadTask.ContinueWith(_ =>
{
state.Texture?.Dispose();
}, TaskScheduler.Default);
}
state.Texture?.Dispose();
_texturePreviews.Remove(key);
}
}
private void ClearHoverPreview(TextureRow row)
{
if (string.Equals(_selectedTextureKey, row.Key, StringComparison.Ordinal))
{
return;
}
ResetPreview(row.Key);
}
private TextureResolutionInfo? GetTextureResolution(TextureRow row)
{
if (_textureResolutionCache.TryGetValue(row.Key, out var cached))
@@ -2440,7 +2799,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
UiSharedService.AttachToolTip("Already stored in a compressed format; additional compression is disabled.");
}
DrawSelectableColumn(isSelected, () =>
var nameHovered = DrawSelectableColumn(isSelected, () =>
{
var selectableLabel = $"{row.DisplayName}##texName{index}";
if (ImGui.Selectable(selectableLabel, isSelected))
@@ -2448,20 +2807,20 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
_selectedTextureKey = isSelected ? string.Empty : key;
}
return () => UiSharedService.AttachToolTip($"{row.PrimaryFilePath}{UiSharedService.TooltipSeparator}{string.Join(Environment.NewLine, row.GamePaths)}");
return null;
});
DrawSelectableColumn(isSelected, () =>
_ = DrawSelectableColumn(isSelected, () =>
{
ImGui.TextUnformatted(row.Slot);
return null;
});
DrawSelectableColumn(isSelected, () =>
_ = DrawSelectableColumn(isSelected, () =>
{
ImGui.TextUnformatted(row.MapKind.ToString());
return null;
});
DrawSelectableColumn(isSelected, () =>
_ = DrawSelectableColumn(isSelected, () =>
{
Action? tooltipAction = null;
ImGui.TextUnformatted(row.Format);
@@ -2475,7 +2834,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
return tooltipAction;
});
DrawSelectableColumn(isSelected, () =>
_ = DrawSelectableColumn(isSelected, () =>
{
if (row.SuggestedTarget.HasValue)
{
@@ -2537,19 +2896,21 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
UiSharedService.AttachToolTip("This texture is already compressed and cannot be processed again.");
}
DrawSelectableColumn(isSelected, () =>
_ = DrawSelectableColumn(isSelected, () =>
{
ImGui.TextUnformatted(UiSharedService.ByteToString(row.OriginalSize));
return null;
});
DrawSelectableColumn(isSelected, () =>
_ = DrawSelectableColumn(isSelected, () =>
{
ImGui.TextUnformatted(UiSharedService.ByteToString(row.CompressedSize));
return null;
});
DrawTextureRowHoverTooltip(row, nameHovered);
}
private static void DrawSelectableColumn(bool isSelected, Func<Action?> draw)
private static bool DrawSelectableColumn(bool isSelected, Func<Action?> draw)
{
ImGui.TableNextColumn();
if (isSelected)
@@ -2558,6 +2919,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
}
var after = draw();
var hovered = ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled | ImGuiHoveredFlags.AllowWhenBlockedByActiveItem);
if (isSelected)
{
@@ -2565,6 +2927,127 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
}
after?.Invoke();
return hovered;
}
private void DrawTextureRowHoverTooltip(TextureRow row, bool isHovered)
{
if (!isHovered)
{
if (string.Equals(_textureHoverKey, row.Key, StringComparison.Ordinal))
{
_textureHoverKey = string.Empty;
_textureHoverStartTime = 0;
ClearHoverPreview(row);
}
return;
}
var now = ImGui.GetTime();
if (!string.Equals(_textureHoverKey, row.Key, StringComparison.Ordinal))
{
_textureHoverKey = row.Key;
_textureHoverStartTime = now;
}
var elapsed = now - _textureHoverStartTime;
if (elapsed < TextureHoverPreviewDelaySeconds)
{
var progress = (float)Math.Clamp(elapsed / TextureHoverPreviewDelaySeconds, 0f, 1f);
DrawTextureRowTextTooltip(row, progress);
return;
}
DrawTextureRowPreviewTooltip(row);
}
private void DrawTextureRowTextTooltip(TextureRow row, float progress)
{
ImGui.BeginTooltip();
ImGui.SetWindowFontScale(1f);
DrawTextureRowTooltipBody(row);
ImGuiHelpers.ScaledDummy(4);
DrawTextureHoverProgressBar(progress, GetTooltipContentWidth());
ImGui.EndTooltip();
}
private void DrawTextureRowPreviewTooltip(TextureRow row)
{
ImGui.BeginTooltip();
ImGui.SetWindowFontScale(1f);
DrawTextureRowTooltipBody(row);
ImGuiHelpers.ScaledDummy(4);
var previewSize = new Vector2(TextureHoverPreviewSize * ImGuiHelpers.GlobalScale);
var (previewTexture, previewLoading, previewError) = GetTexturePreview(row);
if (previewTexture != null)
{
ImGui.Image(previewTexture.Handle, previewSize);
}
else
{
using (ImRaii.Child("textureHoverPreview", previewSize, true))
{
UiSharedService.TextWrapped(previewLoading ? "Loading preview..." : previewError ?? "Preview unavailable.");
}
}
ImGui.EndTooltip();
}
private static void DrawTextureRowTooltipBody(TextureRow row)
{
var text = row.GamePaths.Count > 0
? $"{row.PrimaryFilePath}{UiSharedService.TooltipSeparator}{string.Join(Environment.NewLine, row.GamePaths)}"
: row.PrimaryFilePath;
var wrapWidth = GetTextureHoverTooltipWidth();
ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + wrapWidth);
if (text.Contains(UiSharedService.TooltipSeparator, StringComparison.Ordinal))
{
var splitText = text.Split(UiSharedService.TooltipSeparator, StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < splitText.Length; i++)
{
ImGui.TextUnformatted(splitText[i]);
if (i != splitText.Length - 1)
{
ImGui.Separator();
}
}
}
else
{
ImGui.TextUnformatted(text);
}
ImGui.PopTextWrapPos();
}
private static void DrawTextureHoverProgressBar(float progress, float width)
{
var scale = ImGuiHelpers.GlobalScale;
var barHeight = 4f * scale;
var barWidth = width > 0f ? width : -1f;
using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 3f * scale))
using (ImRaii.PushStyle(ImGuiStyleVar.FramePadding, Vector2.Zero))
using (ImRaii.PushColor(ImGuiCol.PlotHistogram, UiSharedService.Color(UIColors.Get("LightlessPurple"))))
{
ImGui.ProgressBar(progress, new Vector2(barWidth, barHeight), string.Empty);
}
}
private static float GetTextureHoverTooltipWidth()
=> ImGui.GetFontSize() * 35f;
private static float GetTooltipContentWidth()
{
var min = ImGui.GetWindowContentRegionMin();
var max = ImGui.GetWindowContentRegionMax();
var width = max.X - min.X;
if (width <= 0f)
{
width = ImGui.GetContentRegionAvail().X;
}
return width;
}
private static void ApplyTextureRowBackground(TextureRow row, bool isSelected)

View File

@@ -17,7 +17,7 @@ namespace LightlessSync.UI;
public class DownloadUi : WindowMediatorSubscriberBase
{
private readonly LightlessConfigService _configService;
private readonly ConcurrentDictionary<GameObjectHandler, Dictionary<string, FileDownloadStatus>> _currentDownloads = new();
private readonly ConcurrentDictionary<GameObjectHandler, IReadOnlyDictionary<string, FileDownloadStatus>> _currentDownloads = new();
private readonly DalamudUtilService _dalamudUtilService;
private readonly FileUploadManager _fileTransferManager;
private readonly UiSharedService _uiShared;
@@ -25,6 +25,8 @@ public class DownloadUi : WindowMediatorSubscriberBase
private readonly ConcurrentDictionary<GameObjectHandler, bool> _uploadingPlayers = new();
private readonly Dictionary<GameObjectHandler, Vector2> _smoothed = [];
private readonly Dictionary<GameObjectHandler, DownloadSpeedTracker> _downloadSpeeds = [];
private readonly Dictionary<GameObjectHandler, (int TotalFiles, long TotalBytes)> _downloadInitialTotals = [];
private byte _transferBoxTransparency = 100;
private bool _notificationDismissed = true;
@@ -63,9 +65,15 @@ public class DownloadUi : WindowMediatorSubscriberBase
IsOpen = true;
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) =>
Mediator.Subscribe<DownloadStartedMessage>(this, msg =>
{
_currentDownloads[msg.DownloadId] = msg.DownloadStatus;
var snap = msg.DownloadStatus.ToArray();
var totalFiles = snap.Sum(kv => kv.Value?.TotalFiles ?? 0);
var totalBytes = snap.Sum(kv => kv.Value?.TotalBytes ?? 0);
_downloadInitialTotals[msg.DownloadId] = (totalFiles, totalBytes);
_notificationDismissed = false;
});
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) =>
@@ -73,7 +81,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
_currentDownloads.TryRemove(msg.DownloadId, out _);
// Dismiss notification if all downloads are complete
if (!_currentDownloads.Any() && !_notificationDismissed)
if (_currentDownloads.IsEmpty && !_notificationDismissed)
{
Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress"));
_notificationDismissed = true;
@@ -164,10 +172,10 @@ public class DownloadUi : WindowMediatorSubscriberBase
const float rounding = 6f;
var shadowOffset = new Vector2(2, 2);
List<KeyValuePair<GameObjectHandler, Dictionary<string, FileDownloadStatus>>> transfers;
List<KeyValuePair<GameObjectHandler, IReadOnlyDictionary<string, FileDownloadStatus>>> transfers;
try
{
transfers = _currentDownloads.ToList();
transfers = [.. _currentDownloads];
}
catch (ArgumentException)
{
@@ -206,12 +214,16 @@ public class DownloadUi : WindowMediatorSubscriberBase
var dlQueue = 0;
var dlProg = 0;
var dlDecomp = 0;
var dlComplete = 0;
foreach (var entry in transfer.Value)
{
var fileStatus = entry.Value;
switch (fileStatus.DownloadStatus)
{
case DownloadStatus.Initializing:
dlQueue++;
break;
case DownloadStatus.WaitingForSlot:
dlSlot++;
break;
@@ -224,15 +236,20 @@ public class DownloadUi : WindowMediatorSubscriberBase
case DownloadStatus.Decompressing:
dlDecomp++;
break;
case DownloadStatus.Completed:
dlComplete++;
break;
}
}
var isAllComplete = dlComplete > 0 && dlProg == 0 && dlDecomp == 0 && dlQueue == 0 && dlSlot == 0;
string statusText;
if (dlProg > 0)
{
statusText = "Downloading";
}
else if (dlDecomp > 0 || (totalBytes > 0 && transferredBytes >= totalBytes))
else if (dlDecomp > 0)
{
statusText = "Decompressing";
}
@@ -244,6 +261,10 @@ public class DownloadUi : WindowMediatorSubscriberBase
{
statusText = "Waiting for slot";
}
else if (isAllComplete)
{
statusText = "Completed";
}
else
{
statusText = "Waiting";
@@ -309,7 +330,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
fillPercent = transferredBytes / (double)totalBytes;
showFill = true;
}
else if (dlDecomp > 0 || transferredBytes >= totalBytes)
else if (dlDecomp > 0 || dlComplete > 0 || transferredBytes >= totalBytes)
{
fillPercent = 1.0;
showFill = true;
@@ -341,10 +362,14 @@ public class DownloadUi : WindowMediatorSubscriberBase
downloadText =
$"{statusText} {UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}";
}
else if ((dlDecomp > 0 || transferredBytes >= totalBytes) && hasValidSize)
else if (dlDecomp > 0)
{
downloadText = "Decompressing";
}
else if (isAllComplete)
{
downloadText = "Completed";
}
else
{
// Waiting states
@@ -417,6 +442,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
var totalDlQueue = 0;
var totalDlProg = 0;
var totalDlDecomp = 0;
var totalDlComplete = 0;
var perPlayer = new List<(
string Name,
@@ -428,16 +454,21 @@ public class DownloadUi : WindowMediatorSubscriberBase
int DlSlot,
int DlQueue,
int DlProg,
int DlDecomp)>();
int DlDecomp,
int DlComplete)>();
foreach (var transfer in _currentDownloads)
{
var handler = transfer.Key;
var statuses = transfer.Value.Values;
var playerTotalFiles = statuses.Sum(s => s.TotalFiles);
var playerTransferredFiles = statuses.Sum(s => s.TransferredFiles);
var playerTotalBytes = statuses.Sum(s => s.TotalBytes);
var (playerTotalFiles, playerTotalBytes) = _downloadInitialTotals.TryGetValue(handler, out var totals)
? totals
: (statuses.Sum(s => s.TotalFiles), statuses.Sum(s => s.TotalBytes));
var playerTransferredFiles = statuses.Count(s =>
s.DownloadStatus == DownloadStatus.Decompressing ||
s.TransferredBytes >= s.TotalBytes);
var playerTransferredBytes = statuses.Sum(s => s.TransferredBytes);
totalFiles += playerTotalFiles;
@@ -445,25 +476,27 @@ public class DownloadUi : WindowMediatorSubscriberBase
totalBytes += playerTotalBytes;
transferredBytes += playerTransferredBytes;
// per-player W/Q/P/D
// per-player W/Q/P/D/C
var playerDlSlot = 0;
var playerDlQueue = 0;
var playerDlProg = 0;
var playerDlDecomp = 0;
var playerDlComplete = 0;
foreach (var entry in transfer.Value)
{
var fileStatus = entry.Value;
switch (fileStatus.DownloadStatus)
{
case DownloadStatus.WaitingForSlot:
playerDlSlot++;
totalDlSlot++;
break;
case DownloadStatus.Initializing:
case DownloadStatus.WaitingForQueue:
playerDlQueue++;
totalDlQueue++;
break;
case DownloadStatus.WaitingForSlot:
playerDlSlot++;
totalDlSlot++;
break;
case DownloadStatus.Downloading:
playerDlProg++;
totalDlProg++;
@@ -472,6 +505,10 @@ public class DownloadUi : WindowMediatorSubscriberBase
playerDlDecomp++;
totalDlDecomp++;
break;
case DownloadStatus.Completed:
playerDlComplete++;
totalDlComplete++;
break;
}
}
@@ -497,7 +534,8 @@ public class DownloadUi : WindowMediatorSubscriberBase
playerDlSlot,
playerDlQueue,
playerDlProg,
playerDlDecomp
playerDlDecomp,
playerDlComplete
));
}
@@ -511,17 +549,12 @@ public class DownloadUi : WindowMediatorSubscriberBase
if (totalFiles == 0 || totalBytes == 0)
return;
// max speed for per-player bar scale (clamped)
double maxSpeed = perPlayer.Count > 0 ? perPlayer.Max(p => p.SpeedBytesPerSecond) : 0;
if (maxSpeed <= 0)
maxSpeed = 1;
var drawList = ImGui.GetBackgroundDrawList();
var windowPos = ImGui.GetWindowPos();
// Overall texts
var headerText =
$"Downloading {transferredFiles}/{totalFiles} files [W:{totalDlSlot}/Q:{totalDlQueue}/P:{totalDlProg}/D:{totalDlDecomp}]";
$"Downloading {transferredFiles}/{totalFiles} files [W:{totalDlSlot}/Q:{totalDlQueue}/P:{totalDlProg}/D:{totalDlDecomp}/C:{totalDlComplete}]";
var bytesText =
$"{UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}";
@@ -544,7 +577,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
foreach (var p in perPlayer)
{
var line =
$"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}] {p.TransferredFiles}/{p.TotalFiles}";
$"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}/C:{p.DlComplete}] {p.TransferredFiles}/{p.TotalFiles}";
var lineSize = ImGui.CalcTextSize(line);
if (lineSize.X > contentWidth)
@@ -662,7 +695,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
&& p.TransferredBytes > 0;
var labelLine =
$"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}] {p.TransferredFiles}/{p.TotalFiles}";
$"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}/C:{p.DlComplete}] {p.TransferredFiles}/{p.TotalFiles}";
if (!showBar)
{
@@ -721,13 +754,18 @@ public class DownloadUi : WindowMediatorSubscriberBase
// Text inside bar: downloading vs decompressing
string barText;
var isDecompressing = p.DlDecomp > 0 && p.TransferredBytes >= p.TotalBytes && p.TotalBytes > 0;
var isDecompressing = p.DlDecomp > 0;
var isAllComplete = p.DlComplete > 0 && p.DlProg == 0 && p.DlDecomp == 0 && p.DlQueue == 0 && p.DlSlot == 0;
if (isDecompressing)
{
// Keep bar full, static text showing decompressing
barText = "Decompressing...";
}
else if (isAllComplete)
{
barText = "Completed";
}
else
{
var bytesInside =
@@ -808,6 +846,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
var dlQueue = 0;
var dlProg = 0;
var dlDecomp = 0;
var dlComplete = 0;
long totalBytes = 0;
long transferredBytes = 0;
@@ -817,22 +856,29 @@ public class DownloadUi : WindowMediatorSubscriberBase
var fileStatus = entry.Value;
switch (fileStatus.DownloadStatus)
{
case DownloadStatus.Initializing: dlQueue++; break;
case DownloadStatus.WaitingForSlot: dlSlot++; break;
case DownloadStatus.WaitingForQueue: dlQueue++; break;
case DownloadStatus.Downloading: dlProg++; break;
case DownloadStatus.Decompressing: dlDecomp++; break;
case DownloadStatus.Completed: dlComplete++; break;
}
totalBytes += fileStatus.TotalBytes;
transferredBytes += fileStatus.TransferredBytes;
}
var progress = totalBytes > 0 ? (float)transferredBytes / totalBytes : 0f;
if (dlComplete > 0 && dlProg == 0 && dlDecomp == 0 && dlQueue == 0 && dlSlot == 0)
{
progress = 1f;
}
string status;
if (dlDecomp > 0) status = "decompressing";
else if (dlProg > 0) status = "downloading";
else if (dlQueue > 0) status = "queued";
else if (dlSlot > 0) status = "waiting";
else if (dlComplete > 0) status = "completed";
else status = "completed";
downloadStatus.Add((item.Key.Name, progress, status));

View File

@@ -29,6 +29,7 @@ public class DrawEntityFactory
private readonly LightlessConfigService _configService;
private readonly UiSharedService _uiSharedService;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly LocationShareService _locationShareService;
private readonly CharaDataManager _charaDataManager;
private readonly SelectTagForPairUi _selectTagForPairUi;
private readonly RenamePairTagUi _renamePairTagUi;
@@ -53,6 +54,7 @@ public class DrawEntityFactory
LightlessConfigService configService,
UiSharedService uiSharedService,
PlayerPerformanceConfigService playerPerformanceConfigService,
LocationShareService locationShareService,
CharaDataManager charaDataManager,
SelectTagForSyncshellUi selectTagForSyncshellUi,
RenameSyncshellTagUi renameSyncshellTagUi,
@@ -72,6 +74,7 @@ public class DrawEntityFactory
_configService = configService;
_uiSharedService = uiSharedService;
_playerPerformanceConfigService = playerPerformanceConfigService;
_locationShareService = locationShareService;
_charaDataManager = charaDataManager;
_selectTagForSyncshellUi = selectTagForSyncshellUi;
_renameSyncshellTagUi = renameSyncshellTagUi;
@@ -162,6 +165,7 @@ public class DrawEntityFactory
_uiSharedService,
_playerPerformanceConfigService,
_configService,
_locationShareService,
_charaDataManager,
_pairLedger);
}
@@ -213,6 +217,7 @@ public class DrawEntityFactory
entry.PairStatus,
handler?.LastAppliedDataBytes ?? -1,
handler?.LastAppliedDataTris ?? -1,
handler?.LastAppliedApproximateEffectiveTris ?? -1,
handler?.LastAppliedApproximateVRAMBytes ?? -1,
handler?.LastAppliedApproximateEffectiveVRAMBytes ?? -1,
handler);

View File

@@ -104,9 +104,18 @@ public sealed class DtrEntry : IDisposable, IHostedService
public async Task StopAsync(CancellationToken cancellationToken)
{
await _cancellationTokenSource.CancelAsync().ConfigureAwait(false);
if (_dalamudUtilService.IsOnFrameworkThread)
{
_logger.LogDebug("Skipping Lightfinder DTR wait on framework thread during shutdown.");
_cancellationTokenSource.Dispose();
return;
}
try
{
await _runTask!.ConfigureAwait(false);
if (_runTask != null)
await _runTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{

View File

@@ -415,7 +415,9 @@ public class IdDisplayHandler
var vramBytes = pair.LastAppliedApproximateEffectiveVRAMBytes >= 0
? pair.LastAppliedApproximateEffectiveVRAMBytes
: pair.LastAppliedApproximateVRAMBytes;
var triangleCount = pair.LastAppliedDataTris;
var triangleCount = pair.LastAppliedApproximateEffectiveTris >= 0
? pair.LastAppliedApproximateEffectiveTris
: pair.LastAppliedDataTris;
if (vramBytes < 0 && triangleCount < 0)
{
return null;

View File

@@ -6,12 +6,12 @@ using Dalamud.Utility;
using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.Localization;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using System.Globalization;
using System.Numerics;
using System.Text.RegularExpressions;
@@ -21,7 +21,7 @@ public partial class IntroUi : WindowMediatorSubscriberBase
{
private readonly LightlessConfigService _configService;
private readonly CacheMonitor _cacheMonitor;
private readonly Dictionary<string, string> _languages = new(StringComparer.Ordinal) { { "English", "en" }, { "Deutsch", "de" }, { "Français", "fr" } };
private readonly Dictionary<string, string> _languages = new(StringComparer.Ordinal) { { "English", "en" }, { "Deutsch", "de" }, { "Français", "fr" }, { "中文", "zh"} };
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly DalamudUtilService _dalamudUtilService;
private readonly UiSharedService _uiShared;
@@ -31,7 +31,6 @@ public partial class IntroUi : WindowMediatorSubscriberBase
private string _secretKey = string.Empty;
private string _timeoutLabel = string.Empty;
private Task? _timeoutTask;
private string[]? _tosParagraphs;
private bool _useLegacyLogin = false;
public IntroUi(ILogger<IntroUi> logger, UiSharedService uiShared, LightlessConfigService configService,
@@ -51,7 +50,6 @@ public partial class IntroUi : WindowMediatorSubscriberBase
.SetSizeConstraints(new Vector2(600, 400), new Vector2(600, 2000))
.Apply();
GetToSLocalization();
Mediator.Subscribe<SwitchToMainUiMessage>(this, (_) => IsOpen = false);
Mediator.Subscribe<SwitchToIntroUiMessage>(this, (_) =>
@@ -88,7 +86,7 @@ public partial class IntroUi : WindowMediatorSubscriberBase
{
for (int i = 60; i > 0; i--)
{
_timeoutLabel = $"{Strings.ToS.ButtonWillBeAvailableIn} {i}s";
_timeoutLabel = $"{Resources.Resources.ToSStrings_ButtonWillBeAvailableIn} {i}s";
await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
}
});
@@ -102,44 +100,46 @@ public partial class IntroUi : WindowMediatorSubscriberBase
Vector2 textSize;
using (_uiShared.UidFont.Push())
{
textSize = ImGui.CalcTextSize(Strings.ToS.LanguageLabel);
ImGui.TextUnformatted(Strings.ToS.AgreementLabel);
textSize = ImGui.CalcTextSize(Resources.Resources.ToSStrings_LanguageLabel);
ImGui.TextUnformatted(Resources.Resources.ToSStrings_AgreementLabel);
}
ImGui.SameLine();
var languageSize = ImGui.CalcTextSize(Strings.ToS.LanguageLabel);
var languageSize = ImGui.CalcTextSize(Resources.Resources.ToSStrings_LanguageLabel);
ImGui.SetCursorPosX(ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X - languageSize.X - 80);
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + textSize.Y / 2 - languageSize.Y / 2);
ImGui.TextUnformatted(Strings.ToS.LanguageLabel);
ImGui.TextUnformatted(Resources.Resources.ToSStrings_LanguageLabel);
ImGui.SameLine();
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + textSize.Y / 2 - (languageSize.Y + ImGui.GetStyle().FramePadding.Y) / 2);
ImGui.SetNextItemWidth(80);
if (ImGui.Combo("", ref _currentLanguage, _languages.Keys.ToArray(), _languages.Count))
{
GetToSLocalization(_currentLanguage);
var culture = new CultureInfo(_languages.Values.ToArray()[_currentLanguage]);
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
}
ImGui.Separator();
ImGui.SetWindowFontScale(1.5f);
string readThis = Strings.ToS.ReadLabel;
string readThis = Resources.Resources.ToSStrings_ReadLabel;
textSize = ImGui.CalcTextSize(readThis);
ImGui.SetCursorPosX(ImGui.GetWindowSize().X / 2 - textSize.X / 2);
UiSharedService.ColorText(readThis, ImGuiColors.DalamudRed);
ImGui.SetWindowFontScale(1.0f);
ImGui.Separator();
UiSharedService.TextWrapped(_tosParagraphs![0]);
UiSharedService.TextWrapped(_tosParagraphs![1]);
UiSharedService.TextWrapped(_tosParagraphs![2]);
UiSharedService.TextWrapped(_tosParagraphs![3]);
UiSharedService.TextWrapped(_tosParagraphs![4]);
UiSharedService.TextWrapped(_tosParagraphs![5]);
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph1);
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph2);
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph3);
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph4);
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph5);
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph6);
ImGui.Separator();
if (_timeoutTask?.IsCompleted ?? true)
{
if (ImGui.Button(Strings.ToS.AgreeLabel + "##toSetup"))
if (ImGui.Button(Resources.Resources.ToSStrings_AgreeLabel + "##toSetup"))
{
_configService.Current.AcceptedAgreement = true;
_configService.Save();
@@ -349,16 +349,6 @@ public partial class IntroUi : WindowMediatorSubscriberBase
}
}
private void GetToSLocalization(int changeLanguageTo = -1)
{
if (changeLanguageTo != -1)
{
_uiShared.LoadLocalization(_languages.ElementAt(changeLanguageTo).Value);
}
_tosParagraphs = [Strings.ToS.Paragraph1, Strings.ToS.Paragraph2, Strings.ToS.Paragraph3, Strings.ToS.Paragraph4, Strings.ToS.Paragraph5, Strings.ToS.Paragraph6];
}
[GeneratedRegex("^[A-F0-9]{64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant)]
private static partial Regex SecretRegex();
}

File diff suppressed because it is too large Load Diff

View File

@@ -47,6 +47,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase
ImGuiWindowFlags.NoCollapse |
ImGuiWindowFlags.NoTitleBar |
ImGuiWindowFlags.NoScrollbar |
ImGuiWindowFlags.NoFocusOnAppearing |
ImGuiWindowFlags.AlwaysAutoResize;
PositionCondition = ImGuiCond.Always;

View File

@@ -21,6 +21,7 @@ public sealed record PairUiEntry(
IndividualPairStatus? PairStatus,
long LastAppliedDataBytes,
long LastAppliedDataTris,
long LastAppliedApproximateEffectiveTris,
long LastAppliedApproximateVramBytes,
long LastAppliedApproximateEffectiveVramBytes,
IPairHandlerAdapter? Handler)

View File

@@ -0,0 +1,8 @@
namespace LightlessSync.UI.Models;
public enum TextureFormatSortMode
{
None = 0,
CompressedFirst = 1,
UncompressedFirst = 2
}

Some files were not shown because too many files have changed in this diff Show More