2.0.0 (#92)
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m27s

2.0.0 Changes:

- Reworked shell finder UI with compact or list view with profile tags showing with the listing, allowing moderators to broadcast the syncshell as well to have it be used more.
- Reworked user list in syncshell admin screen to have filter visible and moved away from table to its own thing, allowing to copy uid/note/alias when clicking on the name.
- Reworked download bars and download box to make it look more modern, removed the jitter around, so it shouldn't vibrate around much.
- Chat has been added to the top menu, working in Zone or in Syncshells to be used there.
- Paired system has been revamped to make pausing and unpausing faster, and loading people should be faster as well.
- Moved to the internal object table to have faster load times for users; people should load in faster
- Compactor is running on a multi-threaded level instead of single-threaded; this should increase the speed of compacting files
- Nameplate Service has been reworked so it wouldn't use the nameplate handler anymore.
- Files can be resized when downloading to reduce load on users if they aren't compressed. (can be toggled to resize all).
- Penumbra Collections are now only made when people are visible, reducing the load on boot-up when having many syncshells in your list.
- Lightfinder plates have been moved away from using Nameplates, but will use an overlay.
- Main UI has been changed a bit with a gradient, and on hover will glow up now.
- Reworked Profile UI for Syncshell and Users to be more user-facing with more customizable items.
- Reworked Settings UI to look more modern.
- Performance should be better due to new systems that would dispose of the collections and better caching of items.

Co-authored-by: defnotken <itsdefnotken@gmail.com>
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: choco <choco@patat.nl>
Co-authored-by: cake <admin@cakeandbanana.nl>
Co-authored-by: Minmoose <KennethBohr@outlook.com>
Reviewed-on: #92
This commit was merged in pull request #92.
This commit is contained in:
2025-12-21 17:19:34 +00:00
parent 906f401940
commit 835a0a637d
191 changed files with 32636 additions and 8841 deletions

View File

@@ -0,0 +1,312 @@
using System.Numerics;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
/*
* Index upscaler code (converted/reversed for downscaling purposes) provided by Ny
* thank you!!
*/
namespace LightlessSync.Services.TextureCompression;
internal static class IndexDownscaler
{
private static readonly Vector2[] SampleOffsets =
{
new(0.25f, 0.25f),
new(0.75f, 0.25f),
new(0.25f, 0.75f),
new(0.75f, 0.75f),
};
public static Image<Rgba32> Downscale(Image<Rgba32> source, int targetWidth, int targetHeight, int blockMultiple)
{
var current = source.Clone();
while (current.Width > targetWidth || current.Height > targetHeight)
{
var nextWidth = Math.Max(targetWidth, Math.Max(blockMultiple, current.Width / 2));
var nextHeight = Math.Max(targetHeight, Math.Max(blockMultiple, current.Height / 2));
var next = new Image<Rgba32>(nextWidth, nextHeight);
for (var y = 0; y < nextHeight; y++)
{
var srcY = Math.Min(current.Height - 1, y * 2);
for (var x = 0; x < nextWidth; x++)
{
var srcX = Math.Min(current.Width - 1, x * 2);
var topLeft = current[srcX, srcY];
var topRight = current[Math.Min(current.Width - 1, srcX + 1), srcY];
var bottomLeft = current[srcX, Math.Min(current.Height - 1, srcY + 1)];
var bottomRight = current[Math.Min(current.Width - 1, srcX + 1), Math.Min(current.Height - 1, srcY + 1)];
next[x, y] = DownscaleIndexBlock(topLeft, topRight, bottomLeft, bottomRight);
}
}
current.Dispose();
current = next;
}
return current;
}
private static Rgba32 DownscaleIndexBlock(in Rgba32 topLeft, in Rgba32 topRight, in Rgba32 bottomLeft, in Rgba32 bottomRight)
{
Span<Rgba32> ordered = stackalloc Rgba32[4]
{
bottomLeft,
bottomRight,
topRight,
topLeft
};
Span<float> weights = stackalloc float[4];
var hasContribution = false;
foreach (var sample in SampleOffsets)
{
if (TryAccumulateSampleWeights(ordered, sample, weights))
{
hasContribution = true;
}
}
if (hasContribution)
{
var bestIndex = IndexOfMax(weights);
if (bestIndex >= 0 && weights[bestIndex] > 0f)
{
return ordered[bestIndex];
}
}
Span<Rgba32> fallback = stackalloc Rgba32[4] { topLeft, topRight, bottomLeft, bottomRight };
return PickMajorityColor(fallback);
}
private static bool TryAccumulateSampleWeights(ReadOnlySpan<Rgba32> colors, in Vector2 sampleUv, Span<float> weights)
{
var red = new Vector4(
colors[0].R / 255f,
colors[1].R / 255f,
colors[2].R / 255f,
colors[3].R / 255f);
var symbols = QuantizeSymbols(red);
var cellUv = ComputeShiftedUv(sampleUv);
Span<int> order = stackalloc int[4];
order[0] = 0;
order[1] = 1;
order[2] = 2;
order[3] = 3;
ApplySymmetry(ref symbols, ref cellUv, order);
var equality = BuildEquality(symbols, symbols.W);
var selector = BuildSelector(equality, symbols, cellUv);
const uint lut = 0x00000C07u;
if (((lut >> (int)selector) & 1u) != 0u)
{
weights[order[3]] += 1f;
return true;
}
if (selector == 3u)
{
equality = BuildEquality(symbols, symbols.Z);
}
var weight = ComputeWeight(equality, cellUv);
if (weight <= 1e-6f)
{
return false;
}
var factor = 1f / weight;
var wW = equality.W * (1f - cellUv.X) * (1f - cellUv.Y) * factor;
var wX = equality.X * (1f - cellUv.X) * cellUv.Y * factor;
var wZ = equality.Z * cellUv.X * (1f - cellUv.Y) * factor;
var wY = equality.Y * cellUv.X * cellUv.Y * factor;
var contributed = false;
if (wW > 0f)
{
weights[order[3]] += wW;
contributed = true;
}
if (wX > 0f)
{
weights[order[0]] += wX;
contributed = true;
}
if (wZ > 0f)
{
weights[order[2]] += wZ;
contributed = true;
}
if (wY > 0f)
{
weights[order[1]] += wY;
contributed = true;
}
return contributed;
}
private static Vector4 QuantizeSymbols(in Vector4 channel)
=> new(
Quantize(channel.X),
Quantize(channel.Y),
Quantize(channel.Z),
Quantize(channel.W));
private static float Quantize(float value)
{
var clamped = Math.Clamp(value, 0f, 1f);
return (MathF.Round(clamped * 16f) + 0.5f) / 16f;
}
private static void ApplySymmetry(ref Vector4 symbols, ref Vector2 cellUv, Span<int> order)
{
if (cellUv.X >= 0.5f)
{
symbols = SwapYxwz(symbols, order);
cellUv.X = 1f - cellUv.X;
}
if (cellUv.Y >= 0.5f)
{
symbols = SwapWzyx(symbols, order);
cellUv.Y = 1f - cellUv.Y;
}
}
private static Vector4 BuildEquality(in Vector4 symbols, float reference)
=> new(
AreEqual(symbols.X, reference) ? 1f : 0f,
AreEqual(symbols.Y, reference) ? 1f : 0f,
AreEqual(symbols.Z, reference) ? 1f : 0f,
AreEqual(symbols.W, reference) ? 1f : 0f);
private static uint BuildSelector(in Vector4 equality, in Vector4 symbols, in Vector2 cellUv)
{
uint selector = 0;
if (equality.X > 0.5f) selector |= 4u;
if (equality.Y > 0.5f) selector |= 8u;
if (equality.Z > 0.5f) selector |= 16u;
if (AreEqual(symbols.X, symbols.Z)) selector |= 2u;
if (cellUv.X + cellUv.Y >= 0.5f) selector |= 1u;
return selector;
}
private static float ComputeWeight(in Vector4 equality, in Vector2 cellUv)
=> equality.W * (1f - cellUv.X) * (1f - cellUv.Y)
+ equality.X * (1f - cellUv.X) * cellUv.Y
+ equality.Z * cellUv.X * (1f - cellUv.Y)
+ equality.Y * cellUv.X * cellUv.Y;
private static Vector2 ComputeShiftedUv(in Vector2 uv)
{
var shifted = new Vector2(
uv.X - MathF.Floor(uv.X),
uv.Y - MathF.Floor(uv.Y));
shifted.X -= 0.5f;
if (shifted.X < 0f)
{
shifted.X += 1f;
}
shifted.Y -= 0.5f;
if (shifted.Y < 0f)
{
shifted.Y += 1f;
}
return shifted;
}
private static Vector4 SwapYxwz(in Vector4 v, Span<int> order)
{
var o0 = order[0];
var o1 = order[1];
var o2 = order[2];
var o3 = order[3];
order[0] = o1;
order[1] = o0;
order[2] = o3;
order[3] = o2;
return new Vector4(v.Y, v.X, v.W, v.Z);
}
private static Vector4 SwapWzyx(in Vector4 v, Span<int> order)
{
var o0 = order[0];
var o1 = order[1];
var o2 = order[2];
var o3 = order[3];
order[0] = o3;
order[1] = o2;
order[2] = o1;
order[3] = o0;
return new Vector4(v.W, v.Z, v.Y, v.X);
}
private static int IndexOfMax(ReadOnlySpan<float> values)
{
var bestIndex = -1;
var bestValue = 0f;
for (var i = 0; i < values.Length; i++)
{
if (values[i] > bestValue)
{
bestValue = values[i];
bestIndex = i;
}
}
return bestIndex;
}
private static bool AreEqual(float a, float b) => MathF.Abs(a - b) <= 1e-5f;
private static Rgba32 PickMajorityColor(ReadOnlySpan<Rgba32> colors)
{
var counts = new Dictionary<Rgba32, int>(colors.Length);
foreach (var color in colors)
{
if (counts.TryGetValue(color, out var count))
{
counts[color] = count + 1;
}
else
{
counts[color] = 1;
}
}
return counts
.OrderByDescending(kvp => kvp.Value)
.ThenByDescending(kvp => kvp.Key.A)
.ThenByDescending(kvp => kvp.Key.R)
.ThenByDescending(kvp => kvp.Key.G)
.ThenByDescending(kvp => kvp.Key.B)
.First().Key;
}
}

View File

@@ -0,0 +1,280 @@
using System.Runtime.InteropServices;
using Lumina.Data.Files;
using OtterTex;
namespace LightlessSync.Services.TextureCompression;
// base taken from penumbra mostly
internal static class TexFileHelper
{
private const int HeaderSize = 80;
private const int MaxMipLevels = 13;
public static ScratchImage Load(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
return Load(stream);
}
public static ScratchImage Load(Stream stream)
{
using var reader = new BinaryReader(stream, System.Text.Encoding.UTF8, leaveOpen: true);
var header = ReadHeader(reader);
var meta = CreateMeta(header);
meta.MipLevels = ComputeMipCount(stream.Length, header, meta);
if (meta.MipLevels == 0)
{
throw new InvalidOperationException("TEX file does not contain a valid mip chain.");
}
var scratch = ScratchImage.Initialize(meta);
ReadPixelData(reader, scratch);
return scratch;
}
public static void Save(string path, ScratchImage image)
{
var header = BuildHeader(image);
if (header.Format == TexFile.TextureFormat.Unknown)
{
throw new InvalidOperationException($"Unable to export TEX file with unsupported format {image.Meta.Format}.");
}
var mode = File.Exists(path) ? FileMode.Truncate : FileMode.CreateNew;
using var stream = new FileStream(path, mode, FileAccess.Write, FileShare.Read);
using var writer = new BinaryWriter(stream);
WriteHeader(writer, header);
writer.Write(image.Pixels);
GC.KeepAlive(image);
}
private static TexFile.TexHeader ReadHeader(BinaryReader reader)
{
Span<byte> buffer = stackalloc byte[HeaderSize];
var read = reader.Read(buffer);
if (read != HeaderSize)
{
throw new EndOfStreamException($"Incomplete TEX header: expected {HeaderSize} bytes, read {read} bytes.");
}
return MemoryMarshal.Read<TexFile.TexHeader>(buffer);
}
private static TexMeta CreateMeta(in TexFile.TexHeader header)
{
var meta = new TexMeta
{
Width = header.Width,
Height = header.Height,
Depth = Math.Max(header.Depth, (ushort)1),
ArraySize = 1,
MipLevels = header.MipCount,
Format = header.Format.ToDxgi(),
Dimension = header.Type.ToDimension(),
MiscFlags = header.Type.HasFlag(TexFile.Attribute.TextureTypeCube) ? D3DResourceMiscFlags.TextureCube : 0,
MiscFlags2 = 0,
};
if (meta.Format == DXGIFormat.Unknown)
{
throw new InvalidOperationException($"TEX format {header.Format} cannot be mapped to DXGI.");
}
if (meta.Dimension == TexDimension.Unknown)
{
throw new InvalidOperationException($"Unrecognised TEX dimension attribute {header.Type}.");
}
return meta;
}
private static unsafe int ComputeMipCount(long totalLength, in TexFile.TexHeader header, in TexMeta meta)
{
var width = Math.Max(meta.Width, 1);
var height = Math.Max(meta.Height, 1);
var minSide = meta.Format.IsCompressed() ? 4 : 1;
var bitsPerPixel = meta.Format.BitsPerPixel();
var expectedOffset = HeaderSize;
var remaining = totalLength - HeaderSize;
for (var level = 0; level < MaxMipLevels; level++)
{
var declaredOffset = header.OffsetToSurface[level];
if (declaredOffset == 0)
{
return level;
}
if (declaredOffset != expectedOffset || remaining <= 0)
{
return level;
}
var mipSize = (int)((long)width * height * bitsPerPixel / 8);
if (mipSize > remaining)
{
return level;
}
expectedOffset += mipSize;
remaining -= mipSize;
if (width <= minSide && height <= minSide)
{
return level + 1;
}
width = Math.Max(width / 2, minSide);
height = Math.Max(height / 2, minSide);
}
return MaxMipLevels;
}
private static unsafe void ReadPixelData(BinaryReader reader, ScratchImage image)
{
fixed (byte* destination = image.Pixels)
{
var span = new Span<byte>(destination, image.Pixels.Length);
var read = reader.Read(span);
if (read < span.Length)
{
throw new InvalidDataException($"TEX pixel buffer is truncated (read {read} of {span.Length} bytes).");
}
}
}
private static TexFile.TexHeader BuildHeader(ScratchImage image)
{
var meta = image.Meta;
var header = new TexFile.TexHeader
{
Width = (ushort)meta.Width,
Height = (ushort)meta.Height,
Depth = (ushort)Math.Max(meta.Depth, 1),
MipCount = (byte)Math.Min(meta.MipLevels, MaxMipLevels),
Format = meta.Format.ToTex(),
Type = meta.Dimension switch
{
_ when meta.IsCubeMap => TexFile.Attribute.TextureTypeCube,
TexDimension.Tex1D => TexFile.Attribute.TextureType1D,
TexDimension.Tex2D => TexFile.Attribute.TextureType2D,
TexDimension.Tex3D => TexFile.Attribute.TextureType3D,
_ => 0,
},
};
PopulateOffsets(ref header, image);
return header;
}
private static unsafe void PopulateOffsets(ref TexFile.TexHeader header, ScratchImage image)
{
var index = 0;
fixed (byte* basePtr = image.Pixels)
{
foreach (var mip in image.Images)
{
if (index >= MaxMipLevels)
{
break;
}
var byteOffset = (byte*)mip.Pixels - basePtr;
header.OffsetToSurface[index++] = HeaderSize + (uint)byteOffset;
}
}
while (index < MaxMipLevels)
{
header.OffsetToSurface[index++] = 0;
}
header.LodOffset[0] = 0;
header.LodOffset[1] = (byte)Math.Min(header.MipCount - 1, 1);
header.LodOffset[2] = (byte)Math.Min(header.MipCount - 1, 2);
}
private static unsafe void WriteHeader(BinaryWriter writer, in TexFile.TexHeader header)
{
writer.Write((uint)header.Type);
writer.Write((uint)header.Format);
writer.Write(header.Width);
writer.Write(header.Height);
writer.Write(header.Depth);
writer.Write((byte)(header.MipCount | (header.MipUnknownFlag ? 0x80 : 0)));
writer.Write(header.ArraySize);
writer.Write(header.LodOffset[0]);
writer.Write(header.LodOffset[1]);
writer.Write(header.LodOffset[2]);
for (var i = 0; i < MaxMipLevels; i++)
{
writer.Write(header.OffsetToSurface[i]);
}
}
private static TexDimension ToDimension(this TexFile.Attribute attribute)
=> (attribute & TexFile.Attribute.TextureTypeMask) switch
{
TexFile.Attribute.TextureType1D => TexDimension.Tex1D,
TexFile.Attribute.TextureType2D => TexDimension.Tex2D,
TexFile.Attribute.TextureType3D => TexDimension.Tex3D,
_ => TexDimension.Unknown,
};
private static DXGIFormat ToDxgi(this TexFile.TextureFormat format)
=> format switch
{
TexFile.TextureFormat.L8 => DXGIFormat.R8UNorm,
TexFile.TextureFormat.A8 => DXGIFormat.A8UNorm,
TexFile.TextureFormat.B4G4R4A4 => DXGIFormat.B4G4R4A4UNorm,
TexFile.TextureFormat.B5G5R5A1 => DXGIFormat.B5G5R5A1UNorm,
TexFile.TextureFormat.B8G8R8A8 => DXGIFormat.B8G8R8A8UNorm,
TexFile.TextureFormat.B8G8R8X8 => DXGIFormat.B8G8R8X8UNorm,
TexFile.TextureFormat.R32F => DXGIFormat.R32Float,
TexFile.TextureFormat.R16G16F => DXGIFormat.R16G16Float,
TexFile.TextureFormat.R32G32F => DXGIFormat.R32G32Float,
TexFile.TextureFormat.R16G16B16A16F => DXGIFormat.R16G16B16A16Float,
TexFile.TextureFormat.R32G32B32A32F => DXGIFormat.R32G32B32A32Float,
TexFile.TextureFormat.BC1 => DXGIFormat.BC1UNorm,
TexFile.TextureFormat.BC2 => DXGIFormat.BC2UNorm,
TexFile.TextureFormat.BC3 => DXGIFormat.BC3UNorm,
(TexFile.TextureFormat)0x6120 => DXGIFormat.BC4UNorm,
TexFile.TextureFormat.BC5 => DXGIFormat.BC5UNorm,
(TexFile.TextureFormat)0x6330 => DXGIFormat.BC6HSF16,
TexFile.TextureFormat.BC7 => DXGIFormat.BC7UNorm,
TexFile.TextureFormat.D16 => DXGIFormat.R16G16B16A16Typeless,
TexFile.TextureFormat.D24S8 => DXGIFormat.R24G8Typeless,
TexFile.TextureFormat.Shadow16 => DXGIFormat.R16Typeless,
TexFile.TextureFormat.Shadow24 => DXGIFormat.R24G8Typeless,
_ => DXGIFormat.Unknown,
};
private static TexFile.TextureFormat ToTex(this DXGIFormat format)
=> format switch
{
DXGIFormat.R8UNorm => TexFile.TextureFormat.L8,
DXGIFormat.A8UNorm => TexFile.TextureFormat.A8,
DXGIFormat.B4G4R4A4UNorm => TexFile.TextureFormat.B4G4R4A4,
DXGIFormat.B5G5R5A1UNorm => TexFile.TextureFormat.B5G5R5A1,
DXGIFormat.B8G8R8A8UNorm => TexFile.TextureFormat.B8G8R8A8,
DXGIFormat.B8G8R8X8UNorm => TexFile.TextureFormat.B8G8R8X8,
DXGIFormat.R32Float => TexFile.TextureFormat.R32F,
DXGIFormat.R16G16Float => TexFile.TextureFormat.R16G16F,
DXGIFormat.R32G32Float => TexFile.TextureFormat.R32G32F,
DXGIFormat.R16G16B16A16Float => TexFile.TextureFormat.R16G16B16A16F,
DXGIFormat.R32G32B32A32Float => TexFile.TextureFormat.R32G32B32A32F,
DXGIFormat.BC1UNorm => TexFile.TextureFormat.BC1,
DXGIFormat.BC2UNorm => TexFile.TextureFormat.BC2,
DXGIFormat.BC3UNorm => TexFile.TextureFormat.BC3,
DXGIFormat.BC4UNorm => (TexFile.TextureFormat)0x6120,
DXGIFormat.BC5UNorm => TexFile.TextureFormat.BC5,
DXGIFormat.BC6HSF16 => (TexFile.TextureFormat)0x6330,
DXGIFormat.BC7UNorm => TexFile.TextureFormat.BC7,
DXGIFormat.R16G16B16A16Typeless => TexFile.TextureFormat.D16,
DXGIFormat.R24G8Typeless => TexFile.TextureFormat.D24S8,
DXGIFormat.R16Typeless => TexFile.TextureFormat.Shadow16,
_ => TexFile.TextureFormat.Unknown,
};
}

View File

@@ -0,0 +1,61 @@
using System.Collections.Immutable;
using Penumbra.Api.Enums;
namespace LightlessSync.Services.TextureCompression;
internal static class TextureCompressionCapabilities
{
private static readonly ImmutableDictionary<TextureCompressionTarget, TextureType> TexTargets =
new Dictionary<TextureCompressionTarget, TextureType>
{
[TextureCompressionTarget.BC1] = TextureType.Bc1Tex,
[TextureCompressionTarget.BC3] = TextureType.Bc3Tex,
[TextureCompressionTarget.BC4] = TextureType.Bc4Tex,
[TextureCompressionTarget.BC5] = TextureType.Bc5Tex,
[TextureCompressionTarget.BC7] = TextureType.Bc7Tex,
}.ToImmutableDictionary();
private static readonly ImmutableDictionary<TextureCompressionTarget, TextureType> DdsTargets =
new Dictionary<TextureCompressionTarget, TextureType>
{
[TextureCompressionTarget.BC1] = TextureType.Bc1Dds,
[TextureCompressionTarget.BC3] = TextureType.Bc3Dds,
[TextureCompressionTarget.BC4] = TextureType.Bc4Dds,
[TextureCompressionTarget.BC5] = TextureType.Bc5Dds,
[TextureCompressionTarget.BC7] = TextureType.Bc7Dds,
}.ToImmutableDictionary();
private static readonly TextureCompressionTarget[] SelectableTargetsCache = TexTargets
.Select(kvp => kvp.Key)
.OrderBy(t => t)
.ToArray();
private static readonly HashSet<TextureCompressionTarget> SelectableTargetSet = SelectableTargetsCache.ToHashSet();
public static IReadOnlyList<TextureCompressionTarget> SelectableTargets => SelectableTargetsCache;
public static TextureCompressionTarget DefaultTarget => TextureCompressionTarget.BC7;
public static bool IsSelectable(TextureCompressionTarget target) => SelectableTargetSet.Contains(target);
public static TextureCompressionTarget Normalize(TextureCompressionTarget? desired)
{
if (desired.HasValue && IsSelectable(desired.Value))
{
return desired.Value;
}
return DefaultTarget;
}
public static bool TryGetPenumbraTarget(TextureCompressionTarget target, string? outputPath, out TextureType textureType)
{
if (!string.IsNullOrWhiteSpace(outputPath) &&
string.Equals(Path.GetExtension(outputPath), ".dds", StringComparison.OrdinalIgnoreCase))
{
return DdsTargets.TryGetValue(target, out textureType);
}
return TexTargets.TryGetValue(target, out textureType);
}
}

View File

@@ -0,0 +1,7 @@
namespace LightlessSync.Services.TextureCompression;
public sealed record TextureCompressionRequest(
string PrimaryFilePath,
IReadOnlyList<string> DuplicateFilePaths,
TextureCompressionTarget Target);

View File

@@ -0,0 +1,325 @@
using LightlessSync.Interop.Ipc;
using LightlessSync.FileCache;
using Microsoft.Extensions.Logging;
using Penumbra.Api.Enums;
namespace LightlessSync.Services.TextureCompression;
public sealed class TextureCompressionService
{
private readonly ILogger<TextureCompressionService> _logger;
private readonly IpcManager _ipcManager;
private readonly FileCacheManager _fileCacheManager;
public IReadOnlyList<TextureCompressionTarget> SelectableTargets => TextureCompressionCapabilities.SelectableTargets;
public TextureCompressionTarget DefaultTarget => TextureCompressionCapabilities.DefaultTarget;
public TextureCompressionService(
ILogger<TextureCompressionService> logger,
IpcManager ipcManager,
FileCacheManager fileCacheManager)
{
_logger = logger;
_ipcManager = ipcManager;
_fileCacheManager = fileCacheManager;
}
public async Task ConvertTexturesAsync(
IReadOnlyList<TextureCompressionRequest> requests,
IProgress<TextureConversionProgress>? progress,
CancellationToken token)
{
if (requests.Count == 0)
{
return;
}
var total = requests.Count;
var completed = 0;
foreach (var request in requests)
{
token.ThrowIfCancellationRequested();
if (!TextureCompressionCapabilities.TryGetPenumbraTarget(request.Target, request.PrimaryFilePath, out var textureType))
{
_logger.LogWarning("Unsupported compression target {Target} requested.", request.Target);
completed++;
continue;
}
await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token).ConfigureAwait(false);
completed++;
}
}
public bool IsTargetSelectable(TextureCompressionTarget target) => TextureCompressionCapabilities.IsSelectable(target);
public TextureCompressionTarget NormalizeTarget(TextureCompressionTarget? desired) =>
TextureCompressionCapabilities.Normalize(desired);
private async Task RunPenumbraConversionAsync(
TextureCompressionRequest request,
TextureType targetType,
int total,
int completedBefore,
IProgress<TextureConversionProgress>? progress,
CancellationToken token)
{
var primaryPath = request.PrimaryFilePath;
var displayJob = new TextureConversionJob(
primaryPath,
primaryPath,
targetType,
IncludeMipMaps: true,
request.DuplicateFilePaths);
var backupPath = CreateBackupCopy(primaryPath);
var conversionJob = displayJob with { InputFile = backupPath };
progress?.Report(new TextureConversionProgress(completedBefore, total, displayJob));
try
{
WaitForAccess(primaryPath);
await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { conversionJob }, null, token).ConfigureAwait(false);
if (!IsValidConversionResult(displayJob.OutputFile))
{
throw new InvalidOperationException($"Penumbra conversion produced no output for {displayJob.OutputFile}.");
}
UpdateFileCache(displayJob);
progress?.Report(new TextureConversionProgress(completedBefore + 1, total, displayJob));
}
catch (Exception ex)
{
RestoreFromBackup(backupPath, displayJob.OutputFile, displayJob.DuplicateTargets, ex);
throw;
}
finally
{
CleanupBackup(backupPath);
}
}
private void UpdateFileCache(TextureConversionJob job)
{
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
job.OutputFile
};
if (job.DuplicateTargets is { Count: > 0 })
{
foreach (var duplicate in job.DuplicateTargets)
{
paths.Add(duplicate);
}
}
if (paths.Count == 0)
{
return;
}
var cacheEntries = _fileCacheManager.GetFileCachesByPaths(paths.ToArray());
foreach (var path in paths)
{
if (!cacheEntries.TryGetValue(path, out var entry) || entry is null)
{
entry = _fileCacheManager.CreateFileEntry(path);
if (entry is null)
{
_logger.LogWarning("Unable to locate cache entry for {Path}; skipping hash refresh", path);
continue;
}
}
try
{
_fileCacheManager.UpdateHashedFile(entry);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to refresh file cache entry for {Path}", path);
}
}
}
private static readonly string WorkingDirectory =
Path.Combine(Path.GetTempPath(), "LightlessSync.TextureCompression");
private static string CreateBackupCopy(string filePath)
{
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"Cannot back up missing texture file {filePath}.", filePath);
}
Directory.CreateDirectory(WorkingDirectory);
var extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension))
{
extension = ".tmp";
}
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(filePath);
var backupName = $"{fileNameWithoutExtension}.backup.{Guid.NewGuid():N}{extension}";
var backupPath = Path.Combine(WorkingDirectory, backupName);
WaitForAccess(filePath);
File.Copy(filePath, backupPath, overwrite: false);
return backupPath;
}
private const int MaxAccessRetries = 10;
private static readonly TimeSpan AccessRetryDelay = TimeSpan.FromMilliseconds(200);
private static void WaitForAccess(string filePath)
{
if (!File.Exists(filePath))
{
return;
}
try
{
File.SetAttributes(filePath, FileAttributes.Normal);
}
catch
{
// ignore attribute changes here
}
Exception? lastException = null;
for (var attempt = 0; attempt < MaxAccessRetries; attempt++)
{
try
{
using var stream = new FileStream(
filePath,
FileMode.Open,
FileAccess.Read,
FileShare.None);
return;
}
catch (IOException ex) when (IsSharingViolation(ex))
{
lastException = ex;
}
Thread.Sleep(AccessRetryDelay);
}
if (lastException != null)
{
throw lastException;
}
}
private static bool IsSharingViolation(IOException ex) =>
ex.HResult == unchecked((int)0x80070020);
private void RestoreFromBackup(
string backupPath,
string destinationPath,
IReadOnlyList<string>? duplicateTargets,
Exception reason)
{
if (string.IsNullOrEmpty(backupPath))
{
_logger.LogWarning(reason, "Conversion failed for {File}, but no backup was available to restore.", destinationPath);
return;
}
if (!File.Exists(backupPath))
{
_logger.LogWarning(reason, "Conversion failed for {File}, but backup path {Backup} no longer exists.", destinationPath, backupPath);
return;
}
try
{
TryReplaceFile(backupPath, destinationPath);
}
catch (Exception restoreEx)
{
_logger.LogError(restoreEx, "Failed to restore texture {File} after conversion failure.", destinationPath);
return;
}
if (duplicateTargets is { Count: > 0 })
{
foreach (var duplicate in duplicateTargets)
{
if (string.Equals(destinationPath, duplicate, StringComparison.OrdinalIgnoreCase))
{
continue;
}
try
{
File.Copy(destinationPath, duplicate, overwrite: true);
}
catch (Exception duplicateEx)
{
_logger.LogDebug(duplicateEx, "Failed to restore duplicate {Duplicate} after conversion failure.", duplicate);
}
}
}
_logger.LogWarning(reason, "Restored original texture {File} after conversion failure.", destinationPath);
}
private static void TryReplaceFile(string sourcePath, string destinationPath)
{
WaitForAccess(destinationPath);
var destinationDirectory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrEmpty(destinationDirectory))
{
Directory.CreateDirectory(destinationDirectory);
}
File.Copy(sourcePath, destinationPath, overwrite: true);
}
private static void CleanupBackup(string backupPath)
{
if (string.IsNullOrEmpty(backupPath))
{
return;
}
try
{
if (File.Exists(backupPath))
{
File.Delete(backupPath);
}
}
catch
{
// avoid killing successful conversions on cleanup failure
}
}
private static bool IsValidConversionResult(string path)
{
try
{
var fileInfo = new FileInfo(path);
return fileInfo.Exists && fileInfo.Length > 0;
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,10 @@
namespace LightlessSync.Services.TextureCompression;
public enum TextureCompressionTarget
{
BC1,
BC3,
BC4,
BC5,
BC7
}

View File

@@ -0,0 +1,714 @@
using System.Collections.Concurrent;
using System.Buffers;
using System.Buffers.Binary;
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using OtterTex;
using OtterImage = OtterTex.Image;
using LightlessSync.LightlessConfiguration;
using LightlessSync.FileCache;
using Microsoft.Extensions.Logging;
using Lumina.Data.Files;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
/*
* OtterTex made by Ottermandias
* thank you!!
*/
namespace LightlessSync.Services.TextureCompression;
public sealed class TextureDownscaleService
{
private const int DefaultTargetMaxDimension = 2048;
private const int MaxSupportedTargetDimension = 8192;
private const int BlockMultiple = 4;
private readonly ILogger<TextureDownscaleService> _logger;
private readonly LightlessConfigService _configService;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly FileCacheManager _fileCacheManager;
private readonly ConcurrentDictionary<string, Task> _activeJobs = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _downscaledPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _downscaleSemaphore = new(4);
private static readonly IReadOnlyDictionary<int, TextureCompressionTarget> BlockCompressedFormatMap =
new Dictionary<int, TextureCompressionTarget>
{
[70] = TextureCompressionTarget.BC1, // DXGI_FORMAT_BC1_TYPELESS
[71] = TextureCompressionTarget.BC1, // DXGI_FORMAT_BC1_UNORM
[72] = TextureCompressionTarget.BC1, // DXGI_FORMAT_BC1_UNORM_SRGB
[73] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC2_TYPELESS
[74] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC2_UNORM
[75] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC2_UNORM_SRGB
[76] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC3_TYPELESS
[77] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC3_UNORM
[78] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC3_UNORM_SRGB
[79] = TextureCompressionTarget.BC4, // DXGI_FORMAT_BC4_TYPELESS
[80] = TextureCompressionTarget.BC4, // DXGI_FORMAT_BC4_UNORM
[81] = TextureCompressionTarget.BC4, // DXGI_FORMAT_BC4_SNORM
[82] = TextureCompressionTarget.BC5, // DXGI_FORMAT_BC5_TYPELESS
[83] = TextureCompressionTarget.BC5, // DXGI_FORMAT_BC5_UNORM
[84] = TextureCompressionTarget.BC5, // DXGI_FORMAT_BC5_SNORM
[94] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC6H_TYPELESS (treated as BC7 for block detection)
[95] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC6H_UF16
[96] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC6H_SF16
[97] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC7_TYPELESS
[98] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC7_UNORM
[99] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC7_UNORM_SRGB
};
public TextureDownscaleService(
ILogger<TextureDownscaleService> logger,
LightlessConfigService configService,
PlayerPerformanceConfigService playerPerformanceConfigService,
FileCacheManager fileCacheManager)
{
_logger = logger;
_configService = configService;
_playerPerformanceConfigService = playerPerformanceConfigService;
_fileCacheManager = fileCacheManager;
}
public void ScheduleDownscale(string hash, string filePath, TextureMapKind mapKind)
{
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return;
if (_activeJobs.ContainsKey(hash)) return;
_activeJobs[hash] = Task.Run(async () =>
{
await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false);
}, CancellationToken.None);
}
public string GetPreferredPath(string hash, string originalPath)
{
if (_downscaledPaths.TryGetValue(hash, out var existing) && File.Exists(existing))
{
return existing;
}
var resolved = GetExistingDownscaledPath(hash);
if (!string.IsNullOrEmpty(resolved))
{
_downscaledPaths[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 async Task DownscaleInternalAsync(string hash, string sourcePath, TextureMapKind mapKind)
{
TexHeaderInfo? headerInfo = null;
string? destination = null;
int targetMaxDimension = 0;
bool onlyDownscaleUncompressed = false;
bool? isIndexTexture = null;
await _downscaleSemaphore.WaitAsync().ConfigureAwait(false);
try
{
if (!File.Exists(sourcePath))
{
_logger.LogWarning("Cannot downscale texture {Hash}; source path missing: {Path}", hash, sourcePath);
return;
}
headerInfo = TryReadTexHeader(sourcePath, out var header)
? header
: (TexHeaderInfo?)null;
var performanceConfig = _playerPerformanceConfigService.Current;
targetMaxDimension = ResolveTargetMaxDimension();
onlyDownscaleUncompressed = performanceConfig.OnlyDownscaleUncompressedTextures;
destination = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex");
if (File.Exists(destination))
{
RegisterDownscaledTexture(hash, sourcePath, destination);
return;
}
var indexTexture = IsIndexMap(mapKind);
isIndexTexture = indexTexture;
if (!indexTexture)
{
if (performanceConfig.EnableNonIndexTextureMipTrim
&& await TryDropTopMipAsync(hash, sourcePath, destination, targetMaxDimension, onlyDownscaleUncompressed, headerInfo).ConfigureAwait(false))
{
return;
}
if (!performanceConfig.EnableNonIndexTextureMipTrim)
{
_logger.LogTrace("Skipping mip trim for non-index texture {Hash}; feature disabled.", hash);
}
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for non-index texture {Hash}; no mip reduction required.", hash);
return;
}
if (!performanceConfig.EnableIndexTextureDownscale)
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; feature disabled.", hash);
return;
}
if (headerInfo is { } headerValue &&
headerValue.Width <= targetMaxDimension &&
headerValue.Height <= targetMaxDimension)
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; header dimensions {Width}x{Height} within target.", hash, headerValue.Width, headerValue.Height);
return;
}
if (onlyDownscaleUncompressed && headerInfo.HasValue && IsBlockCompressedFormat(headerInfo.Value.Format))
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; block compressed format {Format}.", hash, headerInfo.Value.Format);
return;
}
using var sourceScratch = TexFileHelper.Load(sourcePath);
using var rgbaScratch = sourceScratch.GetRGBA(out var rgbaInfo).ThrowIfError(rgbaInfo);
var bytesPerPixel = rgbaInfo.Meta.Format.BitsPerPixel() / 8;
var width = rgbaInfo.Meta.Width;
var height = rgbaInfo.Meta.Height;
var requiredLength = width * height * bytesPerPixel;
var rgbaPixels = rgbaScratch.Pixels.Slice(0, requiredLength);
using var originalImage = SixLabors.ImageSharp.Image.LoadPixelData<Rgba32>(rgbaPixels, width, height);
var targetSize = CalculateTargetSize(originalImage.Width, originalImage.Height, targetMaxDimension);
if (targetSize.width == originalImage.Width && targetSize.height == originalImage.Height)
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; already within bounds.", hash);
return;
}
using var resized = IndexDownscaler.Downscale(originalImage, targetSize.width, targetSize.height, BlockMultiple);
using var resizedScratch = CreateScratchImage(resized, targetSize.width, targetSize.height);
using var finalScratch = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm);
TexFileHelper.Save(destination, finalScratch);
RegisterDownscaledTexture(hash, sourcePath, destination);
}
catch (Exception ex)
{
TryDelete(destination);
_logger.LogWarning(
ex,
"Texture downscale failed for {Hash} ({MapKind}) from {SourcePath} -> {Destination}. TargetMax={TargetMaxDimension}, OnlyUncompressed={OnlyDownscaleUncompressed}, IsIndex={IsIndexTexture}, HeaderFormat={HeaderFormat}",
hash,
mapKind,
sourcePath,
destination ?? "<unresolved>",
targetMaxDimension,
onlyDownscaleUncompressed,
isIndexTexture,
headerInfo?.Format);
}
finally
{
_downscaleSemaphore.Release();
_activeJobs.TryRemove(hash, out _);
}
}
private static (int width, int height) CalculateTargetSize(int width, int height, int targetMaxDimension)
{
var resultWidth = width;
var resultHeight = height;
while (Math.Max(resultWidth, resultHeight) > targetMaxDimension)
{
resultWidth = Math.Max(BlockMultiple, resultWidth / 2);
resultHeight = Math.Max(BlockMultiple, resultHeight / 2);
}
return (resultWidth, resultHeight);
}
private static ScratchImage CreateScratchImage(Image<Rgba32> image, int width, int height)
{
const int BytesPerPixel = 4;
var requiredLength = width * height * BytesPerPixel;
static ScratchImage Create(ReadOnlySpan<byte> pixels, int width, int height)
{
var scratchResult = ScratchImage.FromRGBA(pixels, width, height, out var creationInfo);
return scratchResult.ThrowIfError(creationInfo);
}
if (image.DangerousTryGetSinglePixelMemory(out var pixelMemory))
{
var byteSpan = MemoryMarshal.AsBytes(pixelMemory.Span);
if (byteSpan.Length < requiredLength)
{
throw new InvalidOperationException($"Image buffer shorter than expected ({byteSpan.Length} < {requiredLength}).");
}
return Create(byteSpan.Slice(0, requiredLength), width, height);
}
var rented = ArrayPool<byte>.Shared.Rent(requiredLength);
try
{
var rentedSpan = rented.AsSpan(0, requiredLength);
image.CopyPixelDataTo(rentedSpan);
return Create(rentedSpan, width, height);
}
finally
{
ArrayPool<byte>.Shared.Return(rented);
}
}
private static bool IsIndexMap(TextureMapKind kind)
=> kind is TextureMapKind.Mask
or TextureMapKind.Index;
private Task<bool> TryDropTopMipAsync(
string hash,
string sourcePath,
string destination,
int targetMaxDimension,
bool onlyDownscaleUncompressed,
TexHeaderInfo? headerInfo = null)
{
TexHeaderInfo? header = headerInfo;
int dropCount = -1;
int originalWidth = 0;
int originalHeight = 0;
int originalMipLevels = 0;
try
{
if (!File.Exists(sourcePath))
{
_logger.LogWarning("Cannot trim mip levels for texture {Hash}; source path missing: {Path}", hash, sourcePath);
return Task.FromResult(false);
}
if (header is null && TryReadTexHeader(sourcePath, out var discoveredHeader))
{
header = discoveredHeader;
}
if (header is TexHeaderInfo info)
{
if (onlyDownscaleUncompressed && IsBlockCompressedFormat(info.Format))
{
_logger.LogTrace("Skipping mip trim for texture {Hash}; block compressed format {Format}.", hash, info.Format);
return Task.FromResult(false);
}
if (info.MipLevels <= 1)
{
return Task.FromResult(false);
}
var headerDepth = info.Depth == 0 ? 1 : info.Depth;
if (!ShouldTrimDimensions(info.Width, info.Height, headerDepth, targetMaxDimension))
{
return Task.FromResult(false);
}
}
using var original = TexFileHelper.Load(sourcePath);
var meta = original.Meta;
originalWidth = meta.Width;
originalHeight = meta.Height;
originalMipLevels = meta.MipLevels;
if (meta.MipLevels <= 1)
{
return Task.FromResult(false);
}
if (!ShouldTrim(meta, targetMaxDimension))
{
return Task.FromResult(false);
}
var targetSize = CalculateTargetSize(meta.Width, meta.Height, targetMaxDimension);
dropCount = CalculateDropCount(meta, targetSize.width, targetSize.height);
if (dropCount <= 0)
{
return Task.FromResult(false);
}
using var trimmed = TrimMipChain(original, dropCount);
TexFileHelper.Save(destination, trimmed);
RegisterDownscaledTexture(hash, sourcePath, destination);
_logger.LogDebug("Trimmed {DropCount} top mip level(s) for texture {Hash} -> {Path}", dropCount, hash, destination);
return Task.FromResult(true);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to trim mips for texture {Hash} from {SourcePath} -> {Destination}. TargetMax={TargetMaxDimension}, OnlyUncompressed={OnlyDownscaleUncompressed}, HeaderFormat={HeaderFormat}, OriginalSize={OriginalWidth}x{OriginalHeight}, OriginalMipLevels={OriginalMipLevels}, DropAttempt={DropCount}",
hash,
sourcePath,
destination,
targetMaxDimension,
onlyDownscaleUncompressed,
header?.Format,
originalWidth,
originalHeight,
originalMipLevels,
dropCount);
TryDelete(destination);
return Task.FromResult(false);
}
}
private static int CalculateDropCount(in TexMeta meta, int targetWidth, int targetHeight)
{
var drop = 0;
var width = meta.Width;
var height = meta.Height;
while ((width > targetWidth || height > targetHeight) && drop + 1 < meta.MipLevels)
{
drop++;
width = ReduceDimension(width);
height = ReduceDimension(height);
}
return drop;
}
private static ScratchImage TrimMipChain(ScratchImage source, int dropCount)
{
var meta = source.Meta;
var newMeta = meta;
newMeta.MipLevels = meta.MipLevels - dropCount;
newMeta.Width = ReduceDimension(meta.Width, dropCount);
newMeta.Height = ReduceDimension(meta.Height, dropCount);
if (meta.Dimension == TexDimension.Tex3D)
{
newMeta.Depth = ReduceDimension(meta.Depth, dropCount);
}
var result = ScratchImage.Initialize(newMeta);
CopyMipChainData(source, result, dropCount, meta);
return result;
}
private static unsafe void CopyMipChainData(ScratchImage source, ScratchImage destination, int dropCount, in TexMeta sourceMeta)
{
var destinationMeta = destination.Meta;
var arraySize = Math.Max(1, sourceMeta.ArraySize);
var isCube = sourceMeta.IsCubeMap;
var isVolume = sourceMeta.Dimension == TexDimension.Tex3D;
for (var item = 0; item < arraySize; item++)
{
for (var mip = 0; mip < destinationMeta.MipLevels; mip++)
{
var sourceMip = mip + dropCount;
var sliceCount = GetSliceCount(sourceMeta, sourceMip, isCube, isVolume);
for (var slice = 0; slice < sliceCount; slice++)
{
var srcImage = source.GetImage(sourceMip, item, slice);
var dstImage = destination.GetImage(mip, item, slice);
CopyImage(srcImage, dstImage);
}
}
}
}
private static int GetSliceCount(in TexMeta meta, int mip, bool isCube, bool isVolume)
{
if (isCube)
{
return 6;
}
if (isVolume)
{
return Math.Max(1, meta.Depth >> mip);
}
return 1;
}
private static unsafe void CopyImage(in OtterImage source, in OtterImage destination)
{
var srcPtr = (byte*)source.Pixels;
var dstPtr = (byte*)destination.Pixels;
var bytesToCopy = Math.Min(source.SlicePitch, destination.SlicePitch);
Buffer.MemoryCopy(srcPtr, dstPtr, destination.SlicePitch, bytesToCopy);
}
private static int ReduceDimension(int value, int iterations)
{
var result = value;
for (var i = 0; i < iterations; i++)
{
result = ReduceDimension(result);
}
return result;
}
private static int ReduceDimension(int value)
=> value <= 1 ? 1 : Math.Max(1, value / 2);
private static bool ShouldTrim(in TexMeta meta, int targetMaxDimension)
{
var depth = meta.Dimension == TexDimension.Tex3D ? Math.Max(1, meta.Depth) : 1;
return ShouldTrimDimensions(meta.Width, meta.Height, depth, targetMaxDimension);
}
private static bool ShouldTrimDimensions(int width, int height, int depth, int targetMaxDimension)
{
if (width <= targetMaxDimension && height <= targetMaxDimension && depth <= targetMaxDimension)
{
return false;
}
return true;
}
private int ResolveTargetMaxDimension()
{
var configured = _playerPerformanceConfigService.Current.TextureDownscaleMaxDimension;
if (configured <= 0)
{
return DefaultTargetMaxDimension;
}
return Math.Clamp(configured, BlockMultiple, MaxSupportedTargetDimension);
}
private readonly struct TexHeaderInfo
{
public TexHeaderInfo(ushort width, ushort height, ushort depth, ushort mipLevels, TexFile.TextureFormat format)
{
Width = width;
Height = height;
Depth = depth;
MipLevels = mipLevels;
Format = format;
}
public ushort Width { get; }
public ushort Height { get; }
public ushort Depth { get; }
public ushort MipLevels { get; }
public TexFile.TextureFormat Format { get; }
}
private static bool TryReadTexHeader(string path, out TexHeaderInfo header)
{
header = default;
try
{
Span<byte> buffer = stackalloc byte[16];
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
var read = stream.Read(buffer);
if (read < buffer.Length)
{
return false;
}
var formatValue = BinaryPrimitives.ReadInt32LittleEndian(buffer[4..8]);
var format = (TexFile.TextureFormat)formatValue;
var width = BinaryPrimitives.ReadUInt16LittleEndian(buffer[8..10]);
var height = BinaryPrimitives.ReadUInt16LittleEndian(buffer[10..12]);
var depth = BinaryPrimitives.ReadUInt16LittleEndian(buffer[12..14]);
var mipLevels = BinaryPrimitives.ReadUInt16LittleEndian(buffer[14..16]);
header = new TexHeaderInfo(width, height, depth, mipLevels, format);
return true;
}
catch
{
return false;
}
}
private static bool IsBlockCompressedFormat(TexFile.TextureFormat format)
=> TryGetCompressionTarget(format, out _);
private static bool TryGetCompressionTarget(TexFile.TextureFormat format, out TextureCompressionTarget target)
{
if (BlockCompressedFormatMap.TryGetValue(unchecked((int)format), out var mapped))
{
target = mapped;
return true;
}
target = default;
return false;
}
private void RegisterDownscaledTexture(string hash, string sourcePath, string destination)
{
_downscaledPaths[hash] = destination;
_logger.LogDebug("Downscaled texture {Hash} -> {Path}", hash, destination);
var performanceConfig = _playerPerformanceConfigService.Current;
if (performanceConfig.KeepOriginalTextureFiles)
{
return;
}
if (string.Equals(sourcePath, destination, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (!TryReplaceCacheEntryWithDownscaled(hash, sourcePath, destination))
{
return;
}
TryDelete(sourcePath);
}
private bool TryReplaceCacheEntryWithDownscaled(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);
}
_fileCacheManager.UpdateHashedFile(replacement, computeProperties: false);
_fileCacheManager.WriteOutFullCsv();
_logger.LogTrace("Replaced cache entry for texture {Hash} to downscaled path {Path}", hash, destination);
return true;
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to replace cache entry for texture {Hash}", hash);
return false;
}
}
private string? GetExistingDownscaledPath(string hash)
{
var candidate = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex");
return File.Exists(candidate) ? candidate : null;
}
private string GetDownscaledDirectory()
{
var directory = Path.Combine(_configService.Current.CacheFolder, "downscaled");
if (!Directory.Exists(directory))
{
try
{
Directory.CreateDirectory(directory);
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to create downscaled 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

@@ -0,0 +1,11 @@
namespace LightlessSync.Services.TextureCompression;
public enum TextureMapKind
{
Diffuse,
Normal,
Specular,
Mask,
Index,
Unknown
}

View File

@@ -0,0 +1,590 @@
using Dalamud.Plugin.Services;
using Microsoft.Extensions.Logging;
using Penumbra.GameData.Files;
namespace LightlessSync.Services.TextureCompression;
// ima lie, this isn't garbage
public sealed class TextureMetadataHelper
{
private readonly ILogger<TextureMetadataHelper> _logger;
private readonly IDataManager _dataManager;
private static readonly Dictionary<TextureCompressionTarget, (string Title, string Description)> RecommendationCatalog = new()
{
[TextureCompressionTarget.BC1] = (
"BC1 (Simple Compression for Opaque RGB)",
"This offers a 8:1 compression ratio and is quick with acceptable quality, but only supports RGB, without Alpha.\n\nCan be used for diffuse maps and equipment textures to save extra space."),
[TextureCompressionTarget.BC3] = (
"BC3 (Simple Compression for RGBA)",
"This offers a 4:1 compression ratio and is quick with acceptable quality, and fully supports RGBA.\n\nGeneric format that can be used for most textures."),
[TextureCompressionTarget.BC4] = (
"BC4 (Simple Compression for Opaque Grayscale)",
"This offers a 8:1 compression ratio and has almost indistinguishable quality, but only supports Grayscale, without Alpha.\n\nCan be used for face paints and legacy marks."),
[TextureCompressionTarget.BC5] = (
"BC5 (Simple Compression for Opaque RG)",
"This offers a 4:1 compression ratio and has almost indistinguishable quality, but only supports RG, without B or Alpha.\n\nRecommended for index maps, unrecommended for normal maps."),
[TextureCompressionTarget.BC7] = (
"BC7 (Complex Compression for RGBA)",
"This offers a 4:1 compression ratio and has almost indistinguishable quality, but may take a while.\n\nGeneric format that can be used for most textures.")
};
private static readonly (TextureUsageCategory Category, string Token)[] CategoryTokens =
{
(TextureUsageCategory.UI, "/ui/"),
(TextureUsageCategory.UI, "/uld/"),
(TextureUsageCategory.UI, "/icon/"),
(TextureUsageCategory.VisualEffect, "/vfx/"),
(TextureUsageCategory.Customization, "/chara/human/"),
(TextureUsageCategory.Customization, "/chara/common/"),
(TextureUsageCategory.Customization, "/chara/bibo"),
(TextureUsageCategory.Weapon, "/chara/weapon/"),
(TextureUsageCategory.Accessory, "/chara/accessory/"),
(TextureUsageCategory.Gear, "/chara/equipment/"),
(TextureUsageCategory.Monster, "/chara/monster/"),
(TextureUsageCategory.Monster, "/chara/demihuman/"),
(TextureUsageCategory.MountOrMinion, "/chara/mount/"),
(TextureUsageCategory.MountOrMinion, "/chara/battlepet/"),
(TextureUsageCategory.Companion, "/chara/companion/"),
(TextureUsageCategory.Housing, "/hou/"),
(TextureUsageCategory.Housing, "/housing/"),
(TextureUsageCategory.Housing, "/bg/"),
(TextureUsageCategory.Housing, "/bgcommon/")
};
private static readonly (TextureUsageCategory Category, string SlotToken, string SlotName)[] SlotTokens =
{
(TextureUsageCategory.Gear, "_met", "Head"),
(TextureUsageCategory.Gear, "_top", "Body"),
(TextureUsageCategory.Gear, "_glv", "Hands"),
(TextureUsageCategory.Gear, "_dwn", "Legs"),
(TextureUsageCategory.Gear, "_sho", "Feet"),
(TextureUsageCategory.Accessory, "_ear", "Ears"),
(TextureUsageCategory.Accessory, "_nek", "Neck"),
(TextureUsageCategory.Accessory, "_wrs", "Wrists"),
(TextureUsageCategory.Accessory, "_rir", "Ring"),
(TextureUsageCategory.Weapon, "_w", "Weapon"), // sussy
(TextureUsageCategory.Weapon, "weapon", "Weapon"),
};
private static readonly (TextureMapKind Kind, string Token)[] MapTokens =
{
(TextureMapKind.Normal, "_n."),
(TextureMapKind.Normal, "_n_"),
(TextureMapKind.Normal, "_normal"),
(TextureMapKind.Normal, "normal_"),
(TextureMapKind.Normal, "_norm"),
(TextureMapKind.Normal, "norm_"),
(TextureMapKind.Mask, "_m."),
(TextureMapKind.Mask, "_m_"),
(TextureMapKind.Mask, "_mask"),
(TextureMapKind.Mask, "mask_"),
(TextureMapKind.Mask, "_msk"),
(TextureMapKind.Specular, "_s."),
(TextureMapKind.Specular, "_s_"),
(TextureMapKind.Specular, "_spec"),
(TextureMapKind.Specular, "_specular"),
(TextureMapKind.Specular, "specular_"),
(TextureMapKind.Index, "_id."),
(TextureMapKind.Index, "_id_"),
(TextureMapKind.Index, "_idx"),
(TextureMapKind.Index, "_index"),
(TextureMapKind.Index, "index_"),
(TextureMapKind.Index, "_multi"),
(TextureMapKind.Diffuse, "_d."),
(TextureMapKind.Diffuse, "_d_"),
(TextureMapKind.Diffuse, "_diff"),
(TextureMapKind.Diffuse, "_b."),
(TextureMapKind.Diffuse, "_b_"),
(TextureMapKind.Diffuse, "_base"),
(TextureMapKind.Diffuse, "base_")
};
private const string TextureSegment = "/texture/";
private const string MaterialSegment = "/material/";
private const uint NormalSamplerId = ShpkFile.NormalSamplerId;
private const uint IndexSamplerId = ShpkFile.IndexSamplerId;
private const uint SpecularSamplerId = ShpkFile.SpecularSamplerId;
private const uint DiffuseSamplerId = ShpkFile.DiffuseSamplerId;
private const uint MaskSamplerId = ShpkFile.MaskSamplerId;
public TextureMetadataHelper(ILogger<TextureMetadataHelper> logger, IDataManager dataManager)
{
_logger = logger;
_dataManager = dataManager;
}
public static bool TryGetRecommendationInfo(TextureCompressionTarget target, out (string Title, string Description) info)
=> RecommendationCatalog.TryGetValue(target, out info);
public static TextureUsageCategory DetermineCategory(string? gamePath)
{
var normalized = Normalize(gamePath);
if (string.IsNullOrEmpty(normalized))
return TextureUsageCategory.Unknown;
var fileName = Path.GetFileName(normalized);
if (!string.IsNullOrEmpty(fileName))
{
if (fileName.StartsWith("bibo", StringComparison.OrdinalIgnoreCase)
|| fileName.Contains("gen3", StringComparison.OrdinalIgnoreCase)
|| fileName.Contains("tfgen3", StringComparison.OrdinalIgnoreCase))
{
return TextureUsageCategory.Customization;
}
}
if (normalized.Contains("bibo", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("skin", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("gen3", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("tfgen3", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("body", StringComparison.OrdinalIgnoreCase))
{
return TextureUsageCategory.Customization;
}
foreach (var (category, token) in CategoryTokens)
{
if (normalized.Contains(token, StringComparison.OrdinalIgnoreCase))
return category;
}
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length >= 2 && string.Equals(segments[0], "chara", StringComparison.OrdinalIgnoreCase))
{
return segments[1] switch
{
"equipment" => TextureUsageCategory.Gear,
"accessory" => TextureUsageCategory.Accessory,
"weapon" => TextureUsageCategory.Weapon,
"human" or "common" => TextureUsageCategory.Customization,
"monster" or "demihuman" => TextureUsageCategory.Monster,
"mount" or "battlepet" => TextureUsageCategory.MountOrMinion,
"companion" => TextureUsageCategory.Companion,
_ => TextureUsageCategory.Unknown
};
}
if (normalized.StartsWith("chara/", StringComparison.OrdinalIgnoreCase)
&& (normalized.Contains("bibo", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("skin", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("body", StringComparison.OrdinalIgnoreCase)))
return TextureUsageCategory.Customization;
return TextureUsageCategory.Unknown;
}
public static string DetermineSlot(TextureUsageCategory category, string? gamePath)
{
if (category == TextureUsageCategory.Customization)
return GuessCustomizationSlot(gamePath);
var normalized = Normalize(gamePath);
var fileName = Path.GetFileNameWithoutExtension(normalized);
var searchSource = $"{normalized} {fileName}".ToLowerInvariant();
foreach (var (candidateCategory, token, slot) in SlotTokens)
{
if (candidateCategory == category && searchSource.Contains(token, StringComparison.Ordinal))
return slot;
}
return category switch
{
TextureUsageCategory.Gear => "Gear",
TextureUsageCategory.Accessory => "Accessory",
TextureUsageCategory.Weapon => "Weapon",
TextureUsageCategory.Monster => "Monster",
TextureUsageCategory.MountOrMinion => "Mount / Minion",
TextureUsageCategory.Companion => "Companion",
TextureUsageCategory.VisualEffect => "VFX",
TextureUsageCategory.Housing => "Housing",
TextureUsageCategory.UI => "UI",
_ => "General"
};
}
public TextureMapKind DetermineMapKind(string path)
=> DetermineMapKind(path, null);
public TextureMapKind DetermineMapKind(string? gamePath, string? localTexturePath)
{
if (TryDetermineFromMaterials(gamePath, localTexturePath, out var kind))
return kind;
return GuessMapFromFileName(gamePath ?? localTexturePath ?? string.Empty);
}
private bool TryDetermineFromMaterials(string? gamePath, string? localTexturePath, out TextureMapKind kind)
{
kind = TextureMapKind.Unknown;
var candidates = new List<MaterialCandidate>();
AddGameMaterialCandidates(gamePath, candidates);
AddLocalMaterialCandidates(localTexturePath, candidates);
if (candidates.Count == 0)
return false;
var normalizedGamePath = Normalize(gamePath);
var normalizedFileName = Path.GetFileName(normalizedGamePath);
foreach (var candidate in candidates)
{
if (!TryLoadMaterial(candidate, out var material))
continue;
if (TryInferKindFromMaterial(material, normalizedGamePath, normalizedFileName, out kind))
return true;
}
return false;
}
private static void AddGameMaterialCandidates(string? gamePath, IList<MaterialCandidate> candidates)
{
var normalized = Normalize(gamePath);
if (string.IsNullOrEmpty(normalized))
return;
var textureIndex = normalized.IndexOf(TextureSegment, StringComparison.Ordinal);
if (textureIndex < 0)
return;
var prefix = normalized[..textureIndex];
var suffix = normalized[(textureIndex + TextureSegment.Length)..];
var baseName = Path.GetFileNameWithoutExtension(suffix);
if (string.IsNullOrEmpty(baseName))
return;
var directory = $"{prefix}{MaterialSegment}{Path.GetDirectoryName(suffix)?.Replace('\\', '/') ?? string.Empty}".TrimEnd('/');
candidates.Add(MaterialCandidate.Game($"{directory}/mt_{baseName}.mtrl"));
if (baseName.StartsWith('v') && baseName.IndexOf('_') is > 0 and var idx)
{
var trimmed = baseName[(idx + 1)..];
candidates.Add(MaterialCandidate.Game($"{directory}/mt_{trimmed}.mtrl"));
}
}
private static void AddLocalMaterialCandidates(string? localTexturePath, IList<MaterialCandidate> candidates)
{
if (string.IsNullOrEmpty(localTexturePath))
return;
var normalized = localTexturePath.Replace('\\', '/');
var textureIndex = normalized.IndexOf(TextureSegment, StringComparison.OrdinalIgnoreCase);
if (textureIndex >= 0)
{
var prefix = normalized[..textureIndex];
var suffix = normalized[(textureIndex + TextureSegment.Length)..];
var folder = Path.GetDirectoryName(suffix)?.Replace('\\', '/') ?? string.Empty;
var baseName = Path.GetFileNameWithoutExtension(suffix);
if (!string.IsNullOrEmpty(baseName))
{
var materialDir = $"{prefix}{MaterialSegment}{folder}".TrimEnd('/');
candidates.Add(MaterialCandidate.Local(Path.Combine(materialDir.Replace('/', Path.DirectorySeparatorChar), $"mt_{baseName}.mtrl")));
if (baseName.StartsWith('v') && baseName.IndexOf('_') is > 0 and var idx)
{
var trimmed = baseName[(idx + 1)..];
candidates.Add(MaterialCandidate.Local(Path.Combine(materialDir.Replace('/', Path.DirectorySeparatorChar), $"mt_{trimmed}.mtrl")));
}
}
}
var textureDirectory = Path.GetDirectoryName(localTexturePath);
if (!string.IsNullOrEmpty(textureDirectory) && Directory.Exists(textureDirectory))
{
foreach (var candidate in Directory.EnumerateFiles(textureDirectory, "*.mtrl", SearchOption.TopDirectoryOnly))
candidates.Add(MaterialCandidate.Local(candidate));
}
}
private bool TryLoadMaterial(MaterialCandidate candidate, out MtrlFile material)
{
material = null!;
try
{
switch (candidate.Source)
{
case MaterialSource.Game:
var gameFile = _dataManager.GetFile(candidate.Path);
if (gameFile?.Data.Length > 0)
{
material = new MtrlFile(gameFile.Data);
return material.Valid;
}
break;
case MaterialSource.Local when File.Exists(candidate.Path):
material = new MtrlFile(File.ReadAllBytes(candidate.Path));
return material.Valid;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to load material {Path}", candidate.Path);
}
return false;
}
private static bool TryInferKindFromMaterial(MtrlFile material, string normalizedGamePath, string? fileName, out TextureMapKind kind)
{
kind = TextureMapKind.Unknown;
var targetName = fileName ?? string.Empty;
foreach (var sampler in material.ShaderPackage.Samplers)
{
if (!TryMapSamplerId(sampler.SamplerId, out var candidateKind))
continue;
if (sampler.TextureIndex < 0 || sampler.TextureIndex >= material.Textures.Length)
continue;
var texturePath = Normalize(material.Textures[sampler.TextureIndex].Path);
if (!string.IsNullOrEmpty(normalizedGamePath) && string.Equals(texturePath, normalizedGamePath, StringComparison.OrdinalIgnoreCase))
{
kind = candidateKind;
return true;
}
if (!string.IsNullOrEmpty(targetName) && string.Equals(Path.GetFileName(texturePath), targetName, StringComparison.OrdinalIgnoreCase))
{
kind = candidateKind;
return true;
}
}
return false;
}
private static TextureMapKind GuessMapFromFileName(string path)
{
var normalized = Normalize(path);
var fileNameWithExtension = Path.GetFileName(normalized);
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(normalized);
if (string.IsNullOrEmpty(fileNameWithExtension) && string.IsNullOrEmpty(fileNameWithoutExtension))
return TextureMapKind.Unknown;
foreach (var (kind, token) in MapTokens)
{
if (!string.IsNullOrEmpty(fileNameWithExtension) &&
fileNameWithExtension.Contains(token, StringComparison.OrdinalIgnoreCase))
return kind;
if (!string.IsNullOrEmpty(fileNameWithoutExtension) &&
fileNameWithoutExtension.Contains(token, StringComparison.OrdinalIgnoreCase))
return kind;
}
return TextureMapKind.Unknown;
}
private static readonly (string Token, TextureCompressionTarget Target)[] FormatTargetTokens =
{
("BC1", TextureCompressionTarget.BC1),
("DXT1", TextureCompressionTarget.BC1),
("BC3", TextureCompressionTarget.BC3),
("DXT3", TextureCompressionTarget.BC3),
("DXT5", TextureCompressionTarget.BC3),
("BC4", TextureCompressionTarget.BC4),
("ATI1", TextureCompressionTarget.BC4),
("BC5", TextureCompressionTarget.BC5),
("ATI2", TextureCompressionTarget.BC5),
("3DC", TextureCompressionTarget.BC5),
("BC7", TextureCompressionTarget.BC7),
("BPTC", TextureCompressionTarget.BC7)
}; // idk man
public static bool TryMapFormatToTarget(string? format, out TextureCompressionTarget target)
{
var normalized = (format ?? string.Empty).ToUpperInvariant();
foreach (var (token, mappedTarget) in FormatTargetTokens)
{
if (normalized.Contains(token, StringComparison.Ordinal))
{
target = mappedTarget;
return true;
}
}
target = TextureCompressionTarget.BC7;
return false;
}
public static (TextureCompressionTarget Target, string Reason)? GetSuggestedTarget(
string? format,
TextureMapKind mapKind,
string? texturePath = null)
{
TextureCompressionTarget? current = null;
if (TryMapFormatToTarget(format, out var mapped))
current = mapped;
var prefersBc4 = IsFacePaintOrMarkTexture(texturePath);
var suggestion = mapKind switch
{
TextureMapKind.Normal => TextureCompressionTarget.BC7,
TextureMapKind.Mask => TextureCompressionTarget.BC7,
TextureMapKind.Index => TextureCompressionTarget.BC5,
TextureMapKind.Specular => TextureCompressionTarget.BC3,
TextureMapKind.Diffuse => TextureCompressionTarget.BC7,
_ => TextureCompressionTarget.BC7
};
if (prefersBc4)
{
suggestion = TextureCompressionTarget.BC4;
}
else if (mapKind == TextureMapKind.Diffuse && current is null && !HasAlphaHint(format))
suggestion = TextureCompressionTarget.BC1;
if (current == suggestion)
return null;
return (suggestion, RecommendationCatalog.TryGetValue(suggestion, out var info)
? info.Description
: "Suggested to balance visual quality and file size.");
}
private static bool TryMapSamplerId(uint id, out TextureMapKind kind)
{
kind = id switch
{
NormalSamplerId => TextureMapKind.Normal,
IndexSamplerId => TextureMapKind.Index,
SpecularSamplerId => TextureMapKind.Specular,
DiffuseSamplerId => TextureMapKind.Diffuse,
MaskSamplerId => TextureMapKind.Mask,
_ => TextureMapKind.Unknown
};
return kind != TextureMapKind.Unknown;
}
private static string GuessCustomizationSlot(string? gamePath)
{
var normalized = Normalize(gamePath);
var fileName = Path.GetFileName(normalized);
if (!string.IsNullOrEmpty(fileName))
{
if (fileName.StartsWith("bibo", StringComparison.OrdinalIgnoreCase)
|| fileName.Contains("gen3", StringComparison.OrdinalIgnoreCase)
|| fileName.Contains("tfgen3", StringComparison.OrdinalIgnoreCase)
|| fileName.Contains("skin", StringComparison.OrdinalIgnoreCase))
{
return "Skin";
}
}
if (normalized.Contains("hair", StringComparison.OrdinalIgnoreCase))
return "Hair";
if (normalized.Contains("face", StringComparison.OrdinalIgnoreCase))
return "Face";
if (normalized.Contains("tail", StringComparison.OrdinalIgnoreCase))
return "Tail";
if (normalized.Contains("zear", StringComparison.OrdinalIgnoreCase))
return "Ear";
if (normalized.Contains("eye", StringComparison.OrdinalIgnoreCase))
return "Eye";
if (normalized.Contains("body", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("skin", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("bibo", StringComparison.OrdinalIgnoreCase))
return "Skin";
if (IsFacePaintPath(normalized))
return "Face Paint";
if (IsLegacyMarkPath(normalized))
return "Legacy Mark";
if (normalized.Contains("decal_equip", StringComparison.OrdinalIgnoreCase))
return "Equipment Decal";
return "Customization";
}
private static bool IsFacePaintOrMarkTexture(string? texturePath)
{
var normalized = Normalize(texturePath);
return IsFacePaintPath(normalized) || IsLegacyMarkPath(normalized);
}
private static bool IsFacePaintPath(string? normalizedPath)
{
if (string.IsNullOrEmpty(normalizedPath))
return false;
return normalizedPath.Contains("decal_face", StringComparison.Ordinal)
|| normalizedPath.Contains("facepaint", StringComparison.Ordinal)
|| normalizedPath.Contains("_decal_", StringComparison.Ordinal);
}
private static bool IsLegacyMarkPath(string? normalizedPath)
{
if (string.IsNullOrEmpty(normalizedPath))
return false;
return normalizedPath.Contains("transparent", StringComparison.Ordinal)
|| normalizedPath.Contains("transparent.tex", StringComparison.Ordinal);
}
private static bool HasAlphaHint(string? format)
{
if (string.IsNullOrEmpty(format))
return false;
var normalized = format.ToUpperInvariant();
return normalized.Contains("A8", StringComparison.Ordinal)
|| normalized.Contains("ARGB", StringComparison.Ordinal)
|| normalized.Contains("BC3", StringComparison.Ordinal)
|| normalized.Contains("BC7", StringComparison.Ordinal);
}
private static string Normalize(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return string.Empty;
return path.Replace('\\', '/').ToLowerInvariant();
}
private readonly record struct MaterialCandidate(string Path, MaterialSource Source)
{
public static MaterialCandidate Game(string path) => new(path, MaterialSource.Game);
public static MaterialCandidate Local(string path) => new(path, MaterialSource.Local);
}
private enum MaterialSource
{
Game,
Local
}
}

View File

@@ -0,0 +1,16 @@
namespace LightlessSync.Services.TextureCompression;
public enum TextureUsageCategory
{
Gear,
Weapon,
Accessory,
Customization,
MountOrMinion,
Companion,
Monster,
Housing,
UI,
VisualEffect,
Unknown
}