mirror of
https://github.com/sampletext32/ParkanPlayground.git
synced 2026-08-15 02:57:49 +04:00
vibecoded inspector
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
using MaterialLib;
|
||||
using NResLib;
|
||||
using NResUI.Abstractions;
|
||||
using NResUI.Rendering.Viewport;
|
||||
using NResUI.Rendering.Viewport.OpenGL;
|
||||
using Silk.NET.OpenGL;
|
||||
using TexmLib;
|
||||
|
||||
namespace NResUI.Rendering.Materials;
|
||||
|
||||
/// <summary>Набор материалов, готовых для viewport. Ключ — material id из WEA/MSH batch.</summary>
|
||||
public sealed class ViewportMaterialSet
|
||||
{
|
||||
private readonly Dictionary<int, ViewportMaterial> _materialsById;
|
||||
|
||||
public ViewportMaterialSet(Dictionary<int, ViewportMaterial> materialsById)
|
||||
{
|
||||
_materialsById = materialsById;
|
||||
}
|
||||
|
||||
public static ViewportMaterialSet Empty { get; } = new([]);
|
||||
|
||||
public ViewportMaterial? FindMaterial(int id)
|
||||
{
|
||||
return _materialsById.GetValueOrDefault(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Связывает WEA -> Material.lib -> Textures.lib -> OpenGL texture.
|
||||
/// Этот слой намеренно остается в NResUI, потому что только UI знает GameBasePath и GL context.
|
||||
/// </summary>
|
||||
public static class ViewportMaterialResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Загружает материалы для MSH. Любая ошибка резолва не фатальна: модель должна остаться видимой без текстур.
|
||||
/// </summary>
|
||||
public static ViewportMaterialSet LoadForMsh(
|
||||
GL gl,
|
||||
string mshPath,
|
||||
IConfigProvider configProvider,
|
||||
ICollection<string>? warnings = null)
|
||||
{
|
||||
var weaMatch = WeaResourceResolver.TryLoadMatchingWeaForMsh(mshPath, warnings);
|
||||
if (weaMatch == null || weaMatch.File.Materials.Count == 0)
|
||||
return ViewportMaterialSet.Empty;
|
||||
|
||||
var materialLibPath = Path.Combine(configProvider.GetConfig().GameBasePath, "Material.lib");
|
||||
if (!File.Exists(materialLibPath))
|
||||
{
|
||||
warnings?.Add($"Material.lib was not found at '{materialLibPath}'.");
|
||||
return ViewportMaterialSet.Empty;
|
||||
}
|
||||
|
||||
using var materialFs = new FileStream(materialLibPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var materialArchiveResult = NResParser.ReadFile(materialFs);
|
||||
if (materialArchiveResult.Archive == null)
|
||||
{
|
||||
warnings?.Add($"Failed to parse Material.lib: {materialArchiveResult.Error}");
|
||||
return ViewportMaterialSet.Empty;
|
||||
}
|
||||
|
||||
var result = new Dictionary<int, ViewportMaterial>();
|
||||
foreach (var materialRef in weaMatch.File.Materials)
|
||||
{
|
||||
var texture = TryLoadMaterialTexture(
|
||||
gl,
|
||||
materialFs,
|
||||
materialArchiveResult.Archive,
|
||||
materialRef.Name,
|
||||
configProvider,
|
||||
warnings,
|
||||
out var texm);
|
||||
|
||||
result[materialRef.Id] = texture != null
|
||||
? new ViewportMaterial(materialRef.Name, texture.Value, texm)
|
||||
: new ViewportMaterial(materialRef.Name);
|
||||
}
|
||||
|
||||
return new ViewportMaterialSet(result);
|
||||
}
|
||||
|
||||
private static uint? TryLoadMaterialTexture(
|
||||
GL gl,
|
||||
FileStream materialFs,
|
||||
NResArchive materialArchive,
|
||||
string materialName,
|
||||
IConfigProvider configProvider,
|
||||
ICollection<string>? warnings,
|
||||
out TexmFile? texm)
|
||||
{
|
||||
texm = null;
|
||||
var materialEntry = FindEntry(materialArchive, materialName);
|
||||
if (materialEntry == null)
|
||||
{
|
||||
warnings?.Add($"Material '{materialName}' referenced by WEA was not found in Material.lib.");
|
||||
return null;
|
||||
}
|
||||
|
||||
materialFs.Seek(materialEntry.OffsetInFile, SeekOrigin.Begin);
|
||||
var materialData = new byte[materialEntry.FileLength];
|
||||
materialFs.ReadExactly(materialData, 0, materialData.Length);
|
||||
|
||||
using var ms = new MemoryStream(materialData, writable: false);
|
||||
var materialFile = MaterialParser.ReadFromStream(
|
||||
ms,
|
||||
materialName,
|
||||
materialEntry.ElementCount,
|
||||
materialEntry.Magic1);
|
||||
|
||||
var textureName = materialFile.Stages.FirstOrDefault()?.TextureName;
|
||||
if (string.IsNullOrWhiteSpace(textureName))
|
||||
return null;
|
||||
|
||||
// Текстуры живут в отдельной NRes-библиотеке, поэтому material parsing и texture parsing разделены.
|
||||
var textureLibPath = Path.Combine(configProvider.GetConfig().GameBasePath, "Textures.lib");
|
||||
if (!File.Exists(textureLibPath))
|
||||
{
|
||||
warnings?.Add($"Textures.lib was not found at '{textureLibPath}'.");
|
||||
return null;
|
||||
}
|
||||
|
||||
using var textureFs = new FileStream(textureLibPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var textureArchiveResult = NResParser.ReadFile(textureFs);
|
||||
if (textureArchiveResult.Archive == null)
|
||||
{
|
||||
warnings?.Add($"Failed to parse Textures.lib: {textureArchiveResult.Error}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var textureEntry = FindEntry(textureArchiveResult.Archive, textureName);
|
||||
if (textureEntry == null)
|
||||
{
|
||||
warnings?.Add($"Texture '{textureName}' referenced by material '{materialName}' was not found in Textures.lib.");
|
||||
return null;
|
||||
}
|
||||
|
||||
textureFs.Seek(textureEntry.OffsetInFile, SeekOrigin.Begin);
|
||||
var textureData = new byte[textureEntry.FileLength];
|
||||
textureFs.ReadExactly(textureData, 0, textureData.Length);
|
||||
|
||||
using var textureMs = new MemoryStream(textureData, writable: false);
|
||||
var texmResult = TexmParser.ReadFromStream(textureMs, textureEntry.FileName);
|
||||
if (texmResult.TexmFile == null)
|
||||
{
|
||||
warnings?.Add($"Failed to parse TEXM '{textureName}': {texmResult.Error}");
|
||||
return null;
|
||||
}
|
||||
|
||||
texm = texmResult.TexmFile;
|
||||
var rgba = texm.GetRgba32BytesFromMipmap(0, out var width, out var height);
|
||||
return ViewportTextureLoader.CreateRgbaTexture(gl, rgba, width, height);
|
||||
}
|
||||
|
||||
private static ListMetadataItem? FindEntry(NResArchive archive, string name)
|
||||
{
|
||||
static string Normalize(string value) => value.Trim().ToLowerInvariant();
|
||||
|
||||
var normalized = Normalize(name);
|
||||
return archive.Files.FirstOrDefault(x =>
|
||||
string.Equals(x.FileName, name, StringComparison.OrdinalIgnoreCase) ||
|
||||
Normalize(x.FileName) == normalized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using WeaLib;
|
||||
|
||||
namespace NResUI.Rendering.Materials;
|
||||
|
||||
/// <summary>Результат эвристического поиска WEA рядом с MSH.</summary>
|
||||
public sealed class WeaResourceMatch
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
public required string Reason { get; init; }
|
||||
public required WeaFile File { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UI-side resolver для связи MSH-файла с WEA-файлом.
|
||||
/// Это не часть WeaLib, потому что matching зависит от экспортированных имен и расположения ресурсов на диске.
|
||||
/// </summary>
|
||||
public static class WeaResourceResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Ищет WEA рядом с MSH и сразу парсит его.
|
||||
/// Если файл не найден или битый, viewport все равно может показать модель без материалов.
|
||||
/// </summary>
|
||||
public static WeaResourceMatch? TryLoadMatchingWeaForMsh(string mshPath, ICollection<string>? warnings = null)
|
||||
{
|
||||
var match = FindMatchingWeaPath(mshPath, out var reason);
|
||||
if (match == null)
|
||||
return null;
|
||||
|
||||
var parseResult = WeaParser.ReadFile(match);
|
||||
if (parseResult.WeaFile == null)
|
||||
{
|
||||
warnings?.Add($"Failed to parse WEA '{match}': {parseResult.Error}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return new WeaResourceMatch
|
||||
{
|
||||
Path = match,
|
||||
Reason = reason,
|
||||
File = parseResult.WeaFile
|
||||
};
|
||||
}
|
||||
|
||||
private static string? FindMatchingWeaPath(string mshPath, out string reason)
|
||||
{
|
||||
reason = "No matching WEA found.";
|
||||
var directory = Path.GetDirectoryName(mshPath);
|
||||
if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
|
||||
return null;
|
||||
|
||||
var mshResourceName = GetExportedResourceNameWithoutExtension(mshPath);
|
||||
var mshObjectStem = ExtractObjectStem(mshResourceName);
|
||||
|
||||
var candidates = Directory.EnumerateFiles(directory, "*.wea", SearchOption.TopDirectoryOnly)
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
ResourceName = GetExportedResourceNameWithoutExtension(path),
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Path,
|
||||
x.ResourceName,
|
||||
ObjectStem = ExtractObjectStem(x.ResourceName),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var exact = candidates.FirstOrDefault(x => string.Equals(x.ObjectStem, mshObjectStem,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Лучший случай: MESH_* и WEAR_* после нормализации указывают на один объект.
|
||||
if (exact != null)
|
||||
{
|
||||
reason = "Object stem matched exactly.";
|
||||
return exact.Path;
|
||||
}
|
||||
|
||||
var best = candidates
|
||||
.Select(x => new
|
||||
{
|
||||
x.Path,
|
||||
Score = CommonPrefixTokenCount(
|
||||
SplitResourceTokens(mshObjectStem),
|
||||
SplitResourceTokens(x.ObjectStem))
|
||||
})
|
||||
.OrderByDescending(x => x.Score)
|
||||
.FirstOrDefault();
|
||||
|
||||
// Fallback нужен для экспортов, где суффиксы MESH/WEAR отличаются, но начало имени объекта совпадает.
|
||||
if (best?.Score > 0)
|
||||
{
|
||||
reason = $"Shared first {best.Score} resource token(s).";
|
||||
return best.Path;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetExportedResourceNameWithoutExtension(string path)
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(path);
|
||||
var firstUnderscore = name.IndexOf('_');
|
||||
// Экспортер часто добавляет numeric prefix: 58_MESH_..., 81_WEAR_...
|
||||
if (firstUnderscore > 0 && name[..firstUnderscore].All(char.IsDigit))
|
||||
name = name[(firstUnderscore + 1)..];
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
private static string ExtractObjectStem(string resourceName)
|
||||
{
|
||||
var normalized = resourceName;
|
||||
// Тип ресурса не является частью имени объекта, поэтому MESH_o_x и WEAR_o_x должны совпасть.
|
||||
foreach (var prefix in new[] { "MESH_", "WEAR_", "TEXT_", "ANIM_" })
|
||||
{
|
||||
if (normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalized = normalized[prefix.Length..];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var tokens = normalized.Split('_', StringSplitOptions.RemoveEmptyEntries);
|
||||
return string.Join('_', tokens);
|
||||
}
|
||||
|
||||
private static string[] SplitResourceTokens(string value)
|
||||
{
|
||||
return value.Split('_', StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
private static int CommonPrefixTokenCount(string[] a, string[] b)
|
||||
{
|
||||
var count = Math.Min(a.Length, b.Length);
|
||||
var result = 0;
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
if (!string.Equals(a[i], b[i], StringComparison.OrdinalIgnoreCase))
|
||||
break;
|
||||
|
||||
result++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user