591 lines
23 KiB
C#
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 = 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
|
|
}
|
|
}
|