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 _logger; private readonly IDataManager _dataManager; private static readonly Dictionary 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 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(); 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 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 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; if (normalized.Contains("/eye/eyelids_shadow.tex", StringComparison.Ordinal)) return TextureMapKind.Normal; if (normalized.Contains("/ui/map/", StringComparison.Ordinal) && !string.IsNullOrEmpty(fileNameWithoutExtension)) { if (fileNameWithoutExtension.EndsWith("m_m", StringComparison.Ordinal) || fileNameWithoutExtension.EndsWith("m_s", StringComparison.Ordinal)) return TextureMapKind.Mask; if (fileNameWithoutExtension.EndsWith("_m", StringComparison.Ordinal) || fileNameWithoutExtension.EndsWith("_s", StringComparison.Ordinal) || fileNameWithoutExtension.EndsWith("d", StringComparison.Ordinal)) return TextureMapKind.Diffuse; } 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("A1", StringComparison.Ordinal) || normalized.Contains("A4", StringComparison.Ordinal) || normalized.Contains("A16", StringComparison.Ordinal) || normalized.Contains("A32", StringComparison.Ordinal) || normalized.Contains("ARGB", StringComparison.Ordinal) || normalized.Contains("RGBA", StringComparison.Ordinal) || normalized.Contains("BGRA", StringComparison.Ordinal) || normalized.Contains("DXT3", StringComparison.Ordinal) || normalized.Contains("DXT5", StringComparison.Ordinal) || normalized.Contains("BC2", 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 } }