2.1.0 (#123)
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m9s

# Patchnotes 2.1.0
The changes in this update are more than just "patches". With a new UI, a new feature, and a bunch of bug fixes, improvements and a new member on the dev team, we thought this was more of a minor update.

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

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

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

[1]

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

[2]

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

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

[3]

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

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

[4]

Co-authored-by: defnotken <itsdefnotken@gmail.com>
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: cake <admin@cakeandbanana.nl>
Co-authored-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
Co-authored-by: choco <choco@patat.nl>
Co-authored-by: celine <aaa@aaa.aaa>
Co-authored-by: celine <celine@noreply.git.lightless-sync.org>
Co-authored-by: Tsubasahane <wozaiha@gmail.com>
Co-authored-by: cake <cake@noreply.git.lightless-sync.org>
Reviewed-on: #123
This commit was merged in pull request #123.
This commit is contained in:
2026-01-20 19:43:00 +00:00
parent ed7932ab83
commit 72a62b7449
178 changed files with 33356 additions and 4568 deletions

View File

@@ -2,6 +2,7 @@ using LightlessSync.Interop.Ipc;
using LightlessSync.FileCache;
using Microsoft.Extensions.Logging;
using Penumbra.Api.Enums;
using System.Globalization;
namespace LightlessSync.Services.TextureCompression;
@@ -27,7 +28,9 @@ public sealed class TextureCompressionService
public async Task ConvertTexturesAsync(
IReadOnlyList<TextureCompressionRequest> requests,
IProgress<TextureConversionProgress>? progress,
CancellationToken token)
CancellationToken token,
bool requestRedraw = true,
bool includeMipMaps = true)
{
if (requests.Count == 0)
{
@@ -48,7 +51,7 @@ public sealed class TextureCompressionService
continue;
}
await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token).ConfigureAwait(false);
await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token, requestRedraw, includeMipMaps).ConfigureAwait(false);
completed++;
}
@@ -65,14 +68,16 @@ public sealed class TextureCompressionService
int total,
int completedBefore,
IProgress<TextureConversionProgress>? progress,
CancellationToken token)
CancellationToken token,
bool requestRedraw,
bool includeMipMaps)
{
var primaryPath = request.PrimaryFilePath;
var displayJob = new TextureConversionJob(
primaryPath,
primaryPath,
targetType,
IncludeMipMaps: true,
IncludeMipMaps: includeMipMaps,
request.DuplicateFilePaths);
var backupPath = CreateBackupCopy(primaryPath);
@@ -83,7 +88,7 @@ public sealed class TextureCompressionService
try
{
WaitForAccess(primaryPath);
await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { conversionJob }, null, token).ConfigureAwait(false);
await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { conversionJob }, null, token, requestRedraw).ConfigureAwait(false);
if (!IsValidConversionResult(displayJob.OutputFile))
{
@@ -128,19 +133,46 @@ public sealed class TextureCompressionService
var cacheEntries = _fileCacheManager.GetFileCachesByPaths(paths.ToArray());
foreach (var path in paths)
{
var hasExpectedHash = TryGetExpectedHashFromPath(path, out var expectedHash);
if (!cacheEntries.TryGetValue(path, out var entry) || entry is null)
{
entry = _fileCacheManager.CreateFileEntry(path);
if (hasExpectedHash)
{
entry = _fileCacheManager.CreateCacheEntryWithKnownHash(path, expectedHash);
}
entry ??= _fileCacheManager.CreateFileEntry(path);
if (entry is null)
{
_logger.LogWarning("Unable to locate cache entry for {Path}; skipping hash refresh", path);
continue;
}
}
else if (hasExpectedHash && entry.IsCacheEntry && !string.Equals(entry.Hash, expectedHash, StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("Fixing cache hash mismatch for {Path}: {Current} -> {Expected}", path, entry.Hash, expectedHash);
_fileCacheManager.RemoveHashedFile(entry.Hash, entry.PrefixedFilePath, removeDerivedFiles: false);
var corrected = _fileCacheManager.CreateCacheEntryWithKnownHash(path, expectedHash);
if (corrected is not null)
{
entry = corrected;
}
}
try
{
_fileCacheManager.UpdateHashedFile(entry);
if (entry.IsCacheEntry)
{
var info = new FileInfo(path);
entry.Size = info.Length;
entry.CompressedSize = null;
entry.LastModifiedDateTicks = info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture);
_fileCacheManager.UpdateHashedFile(entry, computeProperties: false);
}
else
{
_fileCacheManager.UpdateHashedFile(entry);
}
}
catch (Exception ex)
{
@@ -149,6 +181,35 @@ public sealed class TextureCompressionService
}
}
private static bool TryGetExpectedHashFromPath(string path, out string hash)
{
hash = Path.GetFileNameWithoutExtension(path);
if (string.IsNullOrWhiteSpace(hash))
{
return false;
}
if (hash.Length is not (40 or 64))
{
return false;
}
for (var i = 0; i < hash.Length; i++)
{
var c = hash[i];
var isHex = (c >= '0' && c <= '9')
|| (c >= 'a' && c <= 'f')
|| (c >= 'A' && c <= 'F');
if (!isHex)
{
return false;
}
}
hash = hash.ToUpperInvariant();
return true;
}
private static readonly string WorkingDirectory =
Path.Combine(Path.GetTempPath(), "LightlessSync.TextureCompression");

View File

@@ -4,9 +4,12 @@ using System.Buffers.Binary;
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using OtterTex;
using OtterImage = OtterTex.Image;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services;
using LightlessSync.Utils;
using LightlessSync.FileCache;
using Microsoft.Extensions.Logging;
using Lumina.Data.Files;
@@ -30,10 +33,13 @@ public sealed class TextureDownscaleService
private readonly LightlessConfigService _configService;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly FileCacheManager _fileCacheManager;
private readonly TextureCompressionService _textureCompressionService;
private readonly TextureProcessingQueue _processingQueue;
private readonly ConcurrentDictionary<string, Task> _activeJobs = new(StringComparer.OrdinalIgnoreCase);
private readonly TaskRegistry<string> _downscaleDeduplicator = new();
private readonly ConcurrentDictionary<string, string> _downscaledPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _downscaleSemaphore = new(4);
private readonly SemaphoreSlim _compressionSemaphore = new(1);
private static readonly IReadOnlyDictionary<int, TextureCompressionTarget> BlockCompressedFormatMap =
new Dictionary<int, TextureCompressionTarget>
{
@@ -68,23 +74,52 @@ public sealed class TextureDownscaleService
ILogger<TextureDownscaleService> logger,
LightlessConfigService configService,
PlayerPerformanceConfigService playerPerformanceConfigService,
FileCacheManager fileCacheManager)
FileCacheManager fileCacheManager,
TextureCompressionService textureCompressionService,
TextureProcessingQueue processingQueue)
{
_logger = logger;
_configService = configService;
_playerPerformanceConfigService = playerPerformanceConfigService;
_fileCacheManager = fileCacheManager;
_textureCompressionService = textureCompressionService;
_processingQueue = processingQueue;
}
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;
if (_downscaleDeduplicator.TryGetExisting(hash, out _)) return;
_activeJobs[hash] = Task.Run(async () =>
_downscaleDeduplicator.GetOrStart(hash, () => _processingQueue.Enqueue(async token =>
{
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);
}, CancellationToken.None));
}
public bool ShouldScheduleDownscale(string filePath)
{
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))
return false;
var performanceConfig = _playerPerformanceConfigService.Current;
return performanceConfig.EnableNonIndexTextureMipTrim
|| performanceConfig.EnableIndexTextureDownscale
|| performanceConfig.EnableUncompressedTextureCompression;
}
public string GetPreferredPath(string hash, string originalPath)
@@ -121,7 +156,7 @@ public sealed class TextureDownscaleService
continue;
}
if (_activeJobs.TryGetValue(hash, out var job))
if (_downscaleDeduplicator.TryGetExisting(hash, out var job))
{
pending.Add(job);
}
@@ -159,10 +194,18 @@ public sealed class TextureDownscaleService
targetMaxDimension = ResolveTargetMaxDimension();
onlyDownscaleUncompressed = performanceConfig.OnlyDownscaleUncompressedTextures;
if (onlyDownscaleUncompressed && !headerInfo.HasValue)
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for texture {Hash}; format unknown and only-uncompressed enabled.", hash);
return;
}
destination = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex");
if (File.Exists(destination))
{
RegisterDownscaledTexture(hash, sourcePath, destination);
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
return;
}
@@ -173,6 +216,7 @@ public sealed class TextureDownscaleService
if (performanceConfig.EnableNonIndexTextureMipTrim
&& await TryDropTopMipAsync(hash, sourcePath, destination, targetMaxDimension, onlyDownscaleUncompressed, headerInfo).ConfigureAwait(false))
{
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
return;
}
@@ -183,6 +227,7 @@ public sealed class TextureDownscaleService
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for non-index texture {Hash}; no mip reduction required.", hash);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
@@ -190,6 +235,7 @@ public sealed class TextureDownscaleService
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; feature disabled.", hash);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
@@ -199,6 +245,7 @@ public sealed class TextureDownscaleService
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; header dimensions {Width}x{Height} within target.", hash, headerValue.Width, headerValue.Height);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
@@ -206,10 +253,12 @@ public sealed class TextureDownscaleService
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; block compressed format {Format}.", hash, headerInfo.Value.Format);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
using var sourceScratch = TexFileHelper.Load(sourcePath);
var sourceFormat = sourceScratch.Meta.Format;
using var rgbaScratch = sourceScratch.GetRGBA(out var rgbaInfo).ThrowIfError(rgbaInfo);
var bytesPerPixel = rgbaInfo.Meta.Format.BitsPerPixel() / 8;
@@ -225,16 +274,39 @@ public sealed class TextureDownscaleService
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; already within bounds.", hash);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
using var resized = IndexDownscaler.Downscale(originalImage, targetSize.width, targetSize.height, BlockMultiple);
var canReencodeWithPenumbra = TryResolveCompressionTarget(headerInfo, sourceFormat, out var compressionTarget);
using var resizedScratch = CreateScratchImage(resized, targetSize.width, targetSize.height);
using var finalScratch = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm);
if (!TryConvertForSave(resizedScratch, sourceFormat, out var finalScratch, canReencodeWithPenumbra))
{
if (canReencodeWithPenumbra
&& await TryReencodeWithPenumbraAsync(hash, sourcePath, destination, resizedScratch, compressionTarget).ConfigureAwait(false))
{
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
return;
}
TexFileHelper.Save(destination, finalScratch);
RegisterDownscaledTexture(hash, sourcePath, destination);
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace(
"Skipping downscale for index texture {Hash}; failed to re-encode to {Format}.",
hash,
sourceFormat);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
using (finalScratch)
{
TexFileHelper.Save(destination, finalScratch);
RegisterDownscaledTexture(hash, sourcePath, destination);
}
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -254,7 +326,6 @@ public sealed class TextureDownscaleService
finally
{
_downscaleSemaphore.Release();
_activeJobs.TryRemove(hash, out _);
}
}
@@ -307,6 +378,164 @@ public sealed class TextureDownscaleService
}
}
private bool TryConvertForSave(
ScratchImage source,
DXGIFormat sourceFormat,
out ScratchImage result,
bool attemptPenumbraFallback)
{
var isCompressed = sourceFormat.IsCompressed();
var targetFormat = isCompressed ? sourceFormat : DXGIFormat.B8G8R8A8UNorm;
_logger.LogDebug(
"Downscale convert target {TargetFormat} (source {SourceFormat}, compressed {IsCompressed}, penumbraFallback {PenumbraFallback})",
targetFormat,
sourceFormat,
isCompressed,
attemptPenumbraFallback);
try
{
result = source.Convert(targetFormat);
return true;
}
catch (Exception ex)
{
var compressedFallback = attemptPenumbraFallback
? " Attempting Penumbra re-encode."
: " Skipping downscale.";
_logger.LogWarning(
ex,
"Failed to convert downscaled texture to {Format}.{Fallback}",
targetFormat,
isCompressed ? compressedFallback : " Falling back to B8G8R8A8.");
if (isCompressed)
{
result = default!;
return false;
}
result = source.Convert(DXGIFormat.B8G8R8A8UNorm);
return true;
}
}
private bool TryResolveCompressionTarget(TexHeaderInfo? headerInfo, DXGIFormat sourceFormat, out TextureCompressionTarget target)
{
if (headerInfo is { } info && TryGetCompressionTarget(info.Format, out target))
{
return _textureCompressionService.IsTargetSelectable(target);
}
if (sourceFormat.IsCompressed() && BlockCompressedFormatMap.TryGetValue((int)sourceFormat, out target))
{
return _textureCompressionService.IsTargetSelectable(target);
}
target = default;
return false;
}
private async Task<bool> TryReencodeWithPenumbraAsync(
string hash,
string sourcePath,
string destination,
ScratchImage resizedScratch,
TextureCompressionTarget target)
{
try
{
_logger.LogDebug("Downscale Penumbra re-encode target {Target} for {Hash}.", target, hash);
using var uncompressed = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm);
TexFileHelper.Save(destination, uncompressed);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save uncompressed downscaled texture for {Hash}. Skipping downscale.", hash);
TryDelete(destination);
return false;
}
await _compressionSemaphore.WaitAsync().ConfigureAwait(false);
try
{
var request = new TextureCompressionRequest(destination, Array.Empty<string>(), target);
await _textureCompressionService
.ConvertTexturesAsync(new[] { request }, null, CancellationToken.None, requestRedraw: false)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to re-encode downscaled texture {Hash} to {Target}. Skipping downscale.", hash, target);
TryDelete(destination);
return false;
}
finally
{
_compressionSemaphore.Release();
}
RegisterDownscaledTexture(hash, sourcePath, destination);
_logger.LogDebug("Downscaled texture {Hash} -> {Path} (re-encoded via Penumbra).", hash, destination);
return true;
}
private async Task TryAutoCompressAsync(string hash, string texturePath, TextureMapKind mapKind, TexHeaderInfo? headerInfo)
{
var performanceConfig = _playerPerformanceConfigService.Current;
if (!performanceConfig.EnableUncompressedTextureCompression)
{
return;
}
if (string.IsNullOrEmpty(texturePath) || !File.Exists(texturePath))
{
return;
}
var info = headerInfo ?? (TryReadTexHeader(texturePath, out var header) ? header : (TexHeaderInfo?)null);
if (!info.HasValue)
{
_logger.LogTrace("Skipping auto-compress for texture {Hash}; unable to read header.", hash);
return;
}
if (IsBlockCompressedFormat(info.Value.Format))
{
_logger.LogTrace("Skipping auto-compress for texture {Hash}; already block-compressed.", hash);
return;
}
var suggestion = TextureMetadataHelper.GetSuggestedTarget(info.Value.Format.ToString(), mapKind, texturePath);
if (suggestion is null)
{
return;
}
var target = _textureCompressionService.NormalizeTarget(suggestion.Value.Target);
if (!_textureCompressionService.IsTargetSelectable(target))
{
_logger.LogTrace("Skipping auto-compress for texture {Hash}; target {Target} not supported.", hash, target);
return;
}
await _compressionSemaphore.WaitAsync().ConfigureAwait(false);
try
{
var includeMipMaps = !performanceConfig.SkipUncompressedTextureCompressionMipMaps;
var request = new TextureCompressionRequest(texturePath, Array.Empty<string>(), target);
await _textureCompressionService
.ConvertTexturesAsync(new[] { request }, null, CancellationToken.None, requestRedraw: false, includeMipMaps: includeMipMaps)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Auto-compress failed for texture {Hash} ({Path})", hash, texturePath);
}
finally
{
_compressionSemaphore.Release();
}
}
private static bool IsIndexMap(TextureMapKind kind)
=> kind is TextureMapKind.Mask
or TextureMapKind.Index;
@@ -655,7 +884,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);