2.0.0 (#92)
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m27s
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:
312
LightlessSync/Services/TextureCompression/IndexDownscaler.cs
Normal file
312
LightlessSync/Services/TextureCompression/IndexDownscaler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
280
LightlessSync/Services/TextureCompression/TexFileHelper.cs
Normal file
280
LightlessSync/Services/TextureCompression/TexFileHelper.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
namespace LightlessSync.Services.TextureCompression;
|
||||
|
||||
public sealed record TextureCompressionRequest(
|
||||
string PrimaryFilePath,
|
||||
IReadOnlyList<string> DuplicateFilePaths,
|
||||
TextureCompressionTarget Target);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace LightlessSync.Services.TextureCompression;
|
||||
|
||||
public enum TextureCompressionTarget
|
||||
{
|
||||
BC1,
|
||||
BC3,
|
||||
BC4,
|
||||
BC5,
|
||||
BC7
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
11
LightlessSync/Services/TextureCompression/TextureMapKind.cs
Normal file
11
LightlessSync/Services/TextureCompression/TextureMapKind.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace LightlessSync.Services.TextureCompression;
|
||||
|
||||
public enum TextureMapKind
|
||||
{
|
||||
Diffuse,
|
||||
Normal,
|
||||
Specular,
|
||||
Mask,
|
||||
Index,
|
||||
Unknown
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace LightlessSync.Services.TextureCompression;
|
||||
|
||||
public enum TextureUsageCategory
|
||||
{
|
||||
Gear,
|
||||
Weapon,
|
||||
Accessory,
|
||||
Customization,
|
||||
MountOrMinion,
|
||||
Companion,
|
||||
Monster,
|
||||
Housing,
|
||||
UI,
|
||||
VisualEffect,
|
||||
Unknown
|
||||
}
|
||||
Reference in New Issue
Block a user