vibecoded inspector

This commit is contained in:
bird_egop
2026-06-09 01:46:57 +03:00
parent 70a5d0ef69
commit 325970374a
25 changed files with 1944 additions and 649 deletions
@@ -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;
}
}