Files
LightlessClient/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs
2025-12-16 06:31:29 +09:00

591 lines
23 KiB
C#

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 = 0x0C5EC1F1u;
private const uint IndexSamplerId = 0x565F8FD8u;
private const uint SpecularSamplerId = 0x2B99E025u;
private const uint DiffuseSamplerId = 0x115306BEu;
private const uint MaskSamplerId = 0x8A4E82B6u;
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
}
}