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
@@ -9,6 +9,10 @@ namespace NResUI.Rendering.Viewport.Msh;
public static class MshRestPoseBuilder
{
/// <summary>
/// Собирает bind/rest pose для pieces из fallback keyframes 0x08.
/// Циклы и битые parent-ссылки не должны ломать viewport, поэтому такие узлы остаются с identity transform.
/// </summary>
public static IReadOnlyList<MshPieceRestPose> BuildRestPose(Msh0x01.Msh0x01Component nodesComponent, List<Msh0x08.AnimationDescriptor> animationDescriptors)
{
var nodeList = nodesComponent.Nodes;
@@ -54,15 +58,12 @@ public static class MshRestPoseBuilder
else
{
localTransform = Matrix4x4.Identity;
Console.WriteLine($"Node {nodeIndex} has no fallback");
}
if (parentIndex == -1)
{
// Root nodes describe object placement in game space; the viewer keeps the model centered.
localTransform.Translation = Vector3.Zero;
Console.WriteLine($"Node {nodeIndex} has no parent");
}
var meshSpaceTransform = localTransform;
@@ -86,15 +87,7 @@ public static class MshRestPoseBuilder
private static int GetParentIndex(Msh0x01.Node node)
{
try
{
return Convert.ToInt32(node.ParentIndexOrLink);
}
catch
{
var rawParent = Convert.ToUInt16(node.ParentIndexOrLink);
return rawParent == ushort.MaxValue ? -1 : rawParent;
}
return node.ParentIndexOrLink == ushort.MaxValue ? -1 : node.ParentIndexOrLink;
}
private static int GetFallbackKeyframeIndex(Msh0x01.Node node)
@@ -1,31 +0,0 @@
namespace NResUI.Rendering.Viewport.Msh;
public sealed class MshViewportLoadResult
{
public bool IsSuccess { get; }
public string? Error { get; }
public string? SourcePath { get; }
public IReadOnlyList<ViewportPiece> Pieces { get; }
private MshViewportLoadResult(
bool isSuccess,
string? error,
string? sourcePath,
IReadOnlyList<ViewportPiece> pieces)
{
IsSuccess = isSuccess;
Error = error;
SourcePath = sourcePath;
Pieces = pieces;
}
public static MshViewportLoadResult Success(string sourcePath, IReadOnlyList<ViewportPiece> pieces)
{
return new MshViewportLoadResult(true, null, sourcePath, pieces);
}
public static MshViewportLoadResult Failure(string error, string? sourcePath = null)
{
return new MshViewportLoadResult(false, error, sourcePath, Array.Empty<ViewportPiece>());
}
}
@@ -1,294 +0,0 @@
using System.Numerics;
using MshLib;
using NResLib;
using NResUI.Abstractions;
using NResUI.Rendering.Viewport.Meshes;
using Silk.NET.OpenGL;
namespace NResUI.Rendering.Viewport.Msh;
public static class MshViewportLoader
{
private const int DefaultModelState = 0;
private const int DefaultLod = 0;
public static MshViewportLoadResult LoadFromFile(GL gl, string path, IConfigProvider configProvider)
{
try
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var parseResult = NResParser.ReadFile(fs);
if (parseResult.Archive == null)
return MshViewportLoadResult.Failure(parseResult.Error ?? "Failed to parse MSH/NRes file.", path);
var archive = parseResult.Archive;
var meshType = MshConverter.DetectMeshType(archive);
if (meshType != MshType.Model)
{
return MshViewportLoadResult.Failure(
$"Only normal MSH models are supported for this viewport pass. Detected: {meshType}.",
path);
}
fs.Seek(0, SeekOrigin.Begin);
var pieces = LoadModelPieces(gl, fs, archive, path, configProvider);
if (pieces.Count == 0)
return MshViewportLoadResult.Failure("MSH was parsed, but no renderable geometry pieces were found.", path);
return MshViewportLoadResult.Success(path, pieces);
}
catch (Exception ex)
{
return MshViewportLoadResult.Failure(ex.Message, path);
}
}
private static List<ViewportPiece> LoadModelPieces(
GL gl, FileStream fs, NResArchive archive, string path, IConfigProvider configProvider
)
{
var nodes = Msh0x01.ReadComponent(fs, archive);
var geometry = Msh0x02.ReadComponent(fs, archive);
var positions = Msh0x03.ReadComponent(fs, archive);
var uvs = TryReadUvs(fs, archive);
var indices = Msh0x06.ReadComponent(fs, archive);
var batches = Msh0x0D.ReadComponent(fs, archive);
var animationDescriptors = Msh0x08.ReadComponent(fs, archive);
var restPoses = MshRestPoseBuilder.BuildRestPose(nodes, animationDescriptors);
var mshToViewportTransform = Matrix4x4.CreateRotationX(-MathF.PI * 0.5f);
var names = TryReadNames(fs, archive);
var materialLibrary = WeaMaterialLibrary.TryLoadForMsh(gl, path, configProvider);
var pieces = new List<ViewportPiece>();
for (var nodeIndex = 0; nodeIndex < nodes.Nodes.Count; nodeIndex++)
{
var node = nodes.Nodes[nodeIndex];
var restPose = restPoses[nodeIndex];
var slotIndex = node.ResolveSlotIndex(DefaultModelState, DefaultLod);
if (slotIndex == ushort.MaxValue)
continue;
if (slotIndex >= geometry.Slots.Count)
continue;
var slot = geometry.Slots[slotIndex];
if (!slot.HasBatches)
continue;
var meshBuildResult = BuildPieceMeshes(gl, nodeIndex, slot, positions, uvs, indices, batches, materialLibrary);
if (meshBuildResult == null)
continue;
var name = ResolvePieceName(names, nodeIndex);
pieces.Add(new ViewportPiece(
id: nodeIndex,
name: name,
meshes: meshBuildResult.Meshes,
localTransform: restPose.MeshSpaceTransform * mshToViewportTransform,
boundsMin: meshBuildResult.BoundsMin,
boundsMax: meshBuildResult.BoundsMax,
debugInfo: new ViewportPieceDebugInfo
{
SourceKind = "MSH 0x01 piece",
SourcePieceIndex = nodeIndex,
SourceParentIndex = restPose.ParentIndex,
GeometrySlotIndex = slotIndex,
Msh01Flags = (uint)node.Flags,
BatchCount = meshBuildResult.BatchCount,
TriangleCount = meshBuildResult.TriangleCount,
FallbackKeyframeIndex = restPose.FallbackKeyframeIndex,
HasRestPose = restPose.HasFallbackPose
}));
}
return pieces;
}
private static PieceMeshBuildResult? BuildPieceMeshes(
GL gl,
int nodeIndex,
Msh0x02.GeometrySlot slot,
IReadOnlyList<Common.Vector3> positions,
IReadOnlyList<Msh05Uv> uvs,
IReadOnlyList<ushort> indices,
IReadOnlyList<Msh0x0D.Batch> batches,
WeaMaterialLibrary materialLibrary)
{
var meshes = new List<GpuMesh>();
var debugColor = PickDebugColor(nodeIndex);
var batchCount = 0;
var triangleCount = 0;
var boundsMin = new Vector3(float.PositiveInfinity, float.PositiveInfinity, float.PositiveInfinity);
var boundsMax = new Vector3(float.NegativeInfinity, float.NegativeInfinity, float.NegativeInfinity);
for (var batchIndex = slot.BatchStart0x0D; batchIndex < slot.BatchEndExclusive0x0D; batchIndex++)
{
if (batchIndex < 0 || batchIndex >= batches.Count)
continue;
var batch = batches[batchIndex];
if (batch.IndexStart0x06 + batch.IndexCount0x06 > indices.Count)
continue;
var vertices = new List<float>();
var outIndices = new List<uint>();
var batchTriangleCount = 0;
var material = materialLibrary.FindMaterial(batch.MaterialIndexLo) ?? ViewportMaterial.Untextured;
var vertexColor = material.HasTexture ? Vector3.One : debugColor;
for (var i = 0; i + 2 < batch.IndexCount0x06; i += 3)
{
var indexBase = (int)batch.IndexStart0x06 + i;
var vertexIndex0 = checked((int)batch.BaseVertex0x03 + indices[indexBase + 0]);
var vertexIndex1 = checked((int)batch.BaseVertex0x03 + indices[indexBase + 1]);
var vertexIndex2 = checked((int)batch.BaseVertex0x03 + indices[indexBase + 2]);
if (!IsValidTriangle(positions.Count, vertexIndex0, vertexIndex1, vertexIndex2))
continue;
var p0 = ToNumericsVector3(positions[vertexIndex0]);
var p1 = ToNumericsVector3(positions[vertexIndex1]);
var p2 = ToNumericsVector3(positions[vertexIndex2]);
var normal = Vector3.Cross(p1 - p0, p2 - p0);
if (normal.LengthSquared() < 1e-8f)
normal = Vector3.UnitY;
else
normal = Vector3.Normalize(normal);
AddTriangleVertex(p0, vertexColor, normal, GetUv(uvs, vertexIndex0), vertices, outIndices, ref boundsMin, ref boundsMax);
AddTriangleVertex(p1, vertexColor, normal, GetUv(uvs, vertexIndex1), vertices, outIndices, ref boundsMin, ref boundsMax);
AddTriangleVertex(p2, vertexColor, normal, GetUv(uvs, vertexIndex2), vertices, outIndices, ref boundsMin, ref boundsMax);
batchTriangleCount++;
}
if (batchTriangleCount == 0)
continue;
batchCount++;
triangleCount += batchTriangleCount;
meshes.Add(PrimitiveMeshes.CreateColoredIndexedMesh(gl, vertices, outIndices, PrimitiveType.Triangles, material));
}
if (triangleCount == 0 || meshes.Count == 0)
return null;
return new PieceMeshBuildResult(meshes, boundsMin, boundsMax, batchCount, triangleCount);
}
private static Vector2 GetUv(IReadOnlyList<Msh05Uv> uvs, int vertexIndex)
{
if (vertexIndex < 0 || vertexIndex >= uvs.Count)
return Vector2.Zero;
var uv = uvs[vertexIndex];
return new Vector2(uv.U / 1024.0f, uv.V / 1024.0f);
}
private static Vector3 ToNumericsVector3(Common.Vector3 position)
{
return new Vector3(position.X, position.Y, position.Z);
}
private static void AddTriangleVertex(
Vector3 position,
Vector3 color,
Vector3 normal,
Vector2 uv,
List<float> vertices,
List<uint> indices,
ref Vector3 boundsMin,
ref Vector3 boundsMax)
{
var vertexIndex = (uint)(vertices.Count / PrimitiveMeshes.FloatsPerVertex);
vertices.Add(position.X);
vertices.Add(position.Y);
vertices.Add(position.Z);
vertices.Add(color.X);
vertices.Add(color.Y);
vertices.Add(color.Z);
vertices.Add(normal.X);
vertices.Add(normal.Y);
vertices.Add(normal.Z);
vertices.Add(uv.X);
vertices.Add(uv.Y);
indices.Add(vertexIndex);
boundsMin = Vector3.Min(boundsMin, position);
boundsMax = Vector3.Max(boundsMax, position);
}
private static bool IsValidTriangle(int vertexCount, int vertexIndex0, int vertexIndex1, int vertexIndex2)
{
return IsValidVertexIndex(vertexCount, vertexIndex0) &&
IsValidVertexIndex(vertexCount, vertexIndex1) &&
IsValidVertexIndex(vertexCount, vertexIndex2);
}
private static bool IsValidVertexIndex(int vertexCount, int vertexIndex)
{
return vertexIndex >= 0 && vertexIndex < vertexCount;
}
private static List<string> TryReadNames(FileStream fs, NResArchive archive)
{
try
{
return Msh0x0A.ReadComponent(fs, archive);
}
catch
{
return new List<string>();
}
}
private static List<Msh05Uv> TryReadUvs(FileStream fs, NResArchive archive)
{
try
{
return Msh0x05.ReadComponent(fs, archive);
}
catch
{
return new List<Msh05Uv>();
}
}
private static string ResolvePieceName(IReadOnlyList<string> names, int nodeIndex)
{
if (nodeIndex >= 0 && nodeIndex < names.Count && !string.IsNullOrWhiteSpace(names[nodeIndex]))
return $"{nodeIndex}: {names[nodeIndex]}";
return $"{nodeIndex}: piece_{nodeIndex:D3}";
}
private static Vector3 PickDebugColor(int index)
{
ReadOnlySpan<Vector3> palette =
[
new Vector3(0.90f, 0.36f, 0.30f),
new Vector3(0.30f, 0.68f, 0.95f),
new Vector3(0.42f, 0.82f, 0.42f),
new Vector3(0.95f, 0.78f, 0.32f),
new Vector3(0.78f, 0.48f, 0.95f),
new Vector3(0.36f, 0.86f, 0.78f),
new Vector3(0.95f, 0.55f, 0.78f),
new Vector3(0.72f, 0.72f, 0.72f),
];
return palette[index % palette.Length];
}
private sealed record PieceMeshBuildResult(
IReadOnlyList<GpuMesh> Meshes,
Vector3 BoundsMin,
Vector3 BoundsMax,
int BatchCount,
int TriangleCount);
}
@@ -1,284 +0,0 @@
using MaterialLib;
using NResLib;
using NResUI.Abstractions;
using NResUI.Rendering.Viewport.OpenGL;
using Silk.NET.OpenGL;
using TexmLib;
namespace NResUI.Rendering.Viewport.Msh;
public sealed class WeaMaterialLibrary
{
private readonly Dictionary<int, ViewportMaterial> _materialsById;
private WeaMaterialLibrary(Dictionary<int, ViewportMaterial> materialsById)
{
_materialsById = materialsById;
}
public static WeaMaterialLibrary Empty { get; } = new(new Dictionary<int, ViewportMaterial>());
public ViewportMaterial? FindMaterial(int id)
{
return _materialsById.TryGetValue(id, out var material)
? material
: null;
}
public static WeaMaterialLibrary TryLoadForMsh(GL gl, string mshPath, IConfigProvider configProvider)
{
var weaPath = FindMatchingWeaPath(mshPath);
if (!File.Exists(weaPath))
return Empty;
var materialRefs = ParseMaterialRefs(weaPath);
if (materialRefs.Count == 0)
return Empty;
var materialLibFs = Path.Combine(configProvider.GetConfig().GameBasePath, "Material.lib");
if (!File.Exists(materialLibFs))
{
return new WeaMaterialLibrary([]);
}
using var materialFs = new FileStream(materialLibFs, FileMode.Open, FileAccess.Read, FileShare.Read);
var parseResult = NResParser.ReadFile(materialFs);
if (parseResult.Archive == null)
return Empty;
var result = new Dictionary<int, ViewportMaterial>();
foreach (var materialRef in materialRefs)
{
var texture = TryLoadMaterialTexture(gl, materialFs, parseResult.Archive, materialRef.Name, configProvider, out var texm);
result[materialRef.Id] = texture != null
? new ViewportMaterial(materialRef.Name, texture.Value, texm)
: new ViewportMaterial(materialRef.Name);
}
return new WeaMaterialLibrary(result);
}
private static string? FindMatchingWeaPath(string mshPath)
{
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();
// Best case: exact resource stem match after type prefix normalization.
var exact = candidates.FirstOrDefault(x => string.Equals(x.ObjectStem, mshObjectStem,
StringComparison.OrdinalIgnoreCase));
if (exact != null)
return exact.Path;
// Fallback: choose a WEA whose resource name shares the longest token prefix.
var best = candidates
.Select(x => new
{
x.Path,
Score = CommonPrefixTokenCount(
SplitResourceTokens(mshObjectStem),
SplitResourceTokens(x.ObjectStem))
})
.OrderByDescending(x => x.Score)
.FirstOrDefault();
return best?.Score > 0 ? best.Path : null;
}
private static string GetExportedResourceNameWithoutExtension(string path)
{
var name = Path.GetFileNameWithoutExtension(path);
// Exported files are usually like:
// 58_MESH_o_tur_ba_06
// 81_WEAR_o_tur_ba_02
//
// Strip numeric export prefix.
var firstUnderscore = name.IndexOf('_');
if (firstUnderscore > 0 &&
name[..firstUnderscore].All(char.IsDigit))
{
name = name[(firstUnderscore + 1)..];
}
return name;
}
private static string ExtractObjectStem(string resourceName)
{
// Normalize known resource kind prefixes.
// MESH_o_tur_ba_06 -> o_tur_ba
// WEAR_o_tur_ba_02 -> o_tur_ba
var normalized = resourceName;
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;
}
private static IReadOnlyList<WeaMaterialRef> ParseMaterialRefs(string weaPath)
{
var lines = File.ReadAllLines(weaPath)
.Select(x => x.Trim())
.Where(x => x.Length != 0)
.ToList();
if (lines.Count == 0 || !int.TryParse(lines[0], out var materialCount) || materialCount <= 0)
return [];
var result = new List<WeaMaterialRef>();
for (var i = 0; i < materialCount && i + 1 < lines.Count; i++)
{
var line = lines[i + 1];
if (line.Equals("LIGHTMAPS", StringComparison.OrdinalIgnoreCase))
break;
var parts = line.Split((char[]?)null, 2,
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2)
continue;
if (!int.TryParse(parts[0], out var id))
continue;
result.Add(new WeaMaterialRef(id, parts[1]));
}
return result;
}
private static uint? TryLoadMaterialTexture(
GL gl, FileStream materialFs, NResArchive materialArchive, string materialName, IConfigProvider configProvider,
out TexmFile? texm
)
{
texm = null;
var entry = FindMaterialEntry(materialArchive, materialName);
if (entry == null)
return null;
materialFs.Seek(entry.OffsetInFile, SeekOrigin.Begin);
var materialData = new byte[entry.FileLength];
materialFs.ReadExactly(materialData, 0, materialData.Length);
using var ms = new MemoryStream(materialData, writable: false);
var materialFile = MaterialParser.ReadFromStream(ms, materialName, entry.ElementCount, entry.Magic1);
var texture = materialFile.Stages[0].TextureName;
var textureLibFs = Path.Combine(configProvider.GetConfig().GameBasePath, "Textures.lib");
if (!File.Exists(textureLibFs))
{
return null;
}
using var textureFs = new FileStream(textureLibFs, FileMode.Open, FileAccess.Read, FileShare.Read);
var textureResult = NResParser.ReadFile(textureFs);
if (textureResult.Archive == null)
return 0;
var textureEntry = FindTextureEntry(textureResult.Archive, texture);
if (textureEntry is null)
{
Console.WriteLine($"Didnt find texture {texture}");
return 0;
}
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, entry.FileName);
if (texmResult.TexmFile == null)
return null;
texm = texmResult.TexmFile;
var rgba = texmResult.TexmFile.GetRgba32BytesFromMipmap(0, out var width, out var height);
return ViewportTextureLoader.CreateRgbaTexture(gl, rgba, width, height);
}
private static ListMetadataItem? FindMaterialEntry(NResArchive materialArchive, string materialName)
{
static string Normalize(string value)
{
return value.Trim().ToLowerInvariant();
}
var normalized = Normalize(materialName);
return materialArchive.Files.FirstOrDefault(x =>
string.Equals(x.FileName, materialName, StringComparison.OrdinalIgnoreCase) ||
Normalize(x.FileName) == normalized);
}
private static ListMetadataItem? FindTextureEntry(NResArchive archive, string name)
{
static string Normalize(string value)
{
return value.Trim().ToLowerInvariant();
}
var normalized = Normalize(name);
return archive.Files.FirstOrDefault(x =>
string.Equals(x.FileName, name, StringComparison.OrdinalIgnoreCase) ||
Normalize(x.FileName) == normalized);
}
private readonly record struct WeaMaterialRef(int Id, string Name);
}
+7 -3
View File
@@ -17,6 +17,7 @@ public sealed class ViewportPiece
public Vector3 BoundsMax { get; }
public ViewportPieceDebugInfo? DebugInfo { get; }
public IReadOnlyList<int> SourceBatchIndices { get; }
public ViewportPiece(
int id,
@@ -25,8 +26,9 @@ public sealed class ViewportPiece
Matrix4x4 localTransform,
Vector3 boundsMin,
Vector3 boundsMax,
ViewportPieceDebugInfo? debugInfo = null)
: this(id, name, new[] { mesh }, localTransform, boundsMin, boundsMax, debugInfo)
ViewportPieceDebugInfo? debugInfo = null,
IReadOnlyList<int>? sourceBatchIndices = null)
: this(id, name, new[] { mesh }, localTransform, boundsMin, boundsMax, debugInfo, sourceBatchIndices)
{
}
@@ -37,7 +39,8 @@ public sealed class ViewportPiece
Matrix4x4 localTransform,
Vector3 boundsMin,
Vector3 boundsMax,
ViewportPieceDebugInfo? debugInfo = null)
ViewportPieceDebugInfo? debugInfo = null,
IReadOnlyList<int>? sourceBatchIndices = null)
{
if (meshes.Count == 0)
throw new ArgumentException("A viewport piece must contain at least one mesh.", nameof(meshes));
@@ -49,6 +52,7 @@ public sealed class ViewportPiece
BoundsMin = boundsMin;
BoundsMax = boundsMax;
DebugInfo = debugInfo;
SourceBatchIndices = sourceBatchIndices ?? [];
}
public static ViewportPiece CreateUnitCube(int id, string name, GpuMesh mesh)