init 2
This commit is contained in:
@@ -0,0 +1,549 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Dalamud.Plugin.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Penumbra.Api.Enums;
|
||||
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, "_normal"),
|
||||
(TextureMapKind.Normal, "_norm"),
|
||||
|
||||
(TextureMapKind.Mask, "_m"),
|
||||
(TextureMapKind.Mask, "_mask"),
|
||||
(TextureMapKind.Mask, "_msk"),
|
||||
|
||||
(TextureMapKind.Specular, "_s"),
|
||||
(TextureMapKind.Specular, "_spec"),
|
||||
|
||||
(TextureMapKind.Emissive, "_em"),
|
||||
(TextureMapKind.Emissive, "_glow"),
|
||||
|
||||
(TextureMapKind.Index, "_id"),
|
||||
(TextureMapKind.Index, "_idx"),
|
||||
(TextureMapKind.Index, "_index"),
|
||||
(TextureMapKind.Index, "_multi"),
|
||||
|
||||
(TextureMapKind.Diffuse, "_d"),
|
||||
(TextureMapKind.Diffuse, "_diff"),
|
||||
(TextureMapKind.Diffuse, "_b"),
|
||||
(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 bool TryGetRecommendationInfo(TextureCompressionTarget target, out (string Title, string Description) info)
|
||||
=> RecommendationCatalog.TryGetValue(target, out info);
|
||||
|
||||
public 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 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 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 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 fileName = Path.GetFileNameWithoutExtension(normalized);
|
||||
if (string.IsNullOrEmpty(fileName))
|
||||
return TextureMapKind.Unknown;
|
||||
|
||||
foreach (var (kind, token) in MapTokens)
|
||||
{
|
||||
if (fileName.Contains(token, StringComparison.OrdinalIgnoreCase))
|
||||
return kind;
|
||||
}
|
||||
|
||||
return TextureMapKind.Unknown;
|
||||
}
|
||||
|
||||
public bool TryMapFormatToTarget(string? format, out TextureCompressionTarget target)
|
||||
{
|
||||
var normalized = (format ?? string.Empty).ToUpperInvariant();
|
||||
if (normalized.Contains("BC1", StringComparison.Ordinal))
|
||||
{
|
||||
target = TextureCompressionTarget.BC1;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized.Contains("BC3", StringComparison.Ordinal))
|
||||
{
|
||||
target = TextureCompressionTarget.BC3;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized.Contains("BC4", StringComparison.Ordinal))
|
||||
{
|
||||
target = TextureCompressionTarget.BC4;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized.Contains("BC5", StringComparison.Ordinal))
|
||||
{
|
||||
target = TextureCompressionTarget.BC5;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized.Contains("BC7", StringComparison.Ordinal))
|
||||
{
|
||||
target = TextureCompressionTarget.BC7;
|
||||
return true;
|
||||
}
|
||||
|
||||
target = TextureCompressionTarget.BC7;
|
||||
return false;
|
||||
}
|
||||
|
||||
public (TextureCompressionTarget Target, string Reason)? GetSuggestedTarget(string? format, TextureMapKind mapKind)
|
||||
{
|
||||
TextureCompressionTarget? current = null;
|
||||
if (TryMapFormatToTarget(format, out var mapped))
|
||||
current = mapped;
|
||||
|
||||
var suggestion = mapKind switch
|
||||
{
|
||||
TextureMapKind.Normal => TextureCompressionTarget.BC7,
|
||||
TextureMapKind.Mask => TextureCompressionTarget.BC4,
|
||||
TextureMapKind.Index => TextureCompressionTarget.BC3,
|
||||
TextureMapKind.Specular => TextureCompressionTarget.BC4,
|
||||
TextureMapKind.Emissive => TextureCompressionTarget.BC3,
|
||||
TextureMapKind.Diffuse => TextureCompressionTarget.BC7,
|
||||
_ => TextureCompressionTarget.BC7
|
||||
};
|
||||
|
||||
if (mapKind == TextureMapKind.Diffuse && !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 (normalized.Contains("decal_face", StringComparison.OrdinalIgnoreCase))
|
||||
return "Face Paint";
|
||||
if (normalized.Contains("decal_equip", StringComparison.OrdinalIgnoreCase))
|
||||
return "Equipment Decal";
|
||||
|
||||
return "Customization";
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user