CharacterKit/SerializedModelMorphs.cs

Reads morph targets for a fitted hair mesh from a native resource atlas. It opens a companion resource file, parses a table to find a morph block, decodes a texture atlas and reconstructs per-vertex position and normal deltas into Target records.

File Access
using System;
using System.IO;
using System.Text;

namespace CharacterEditor;

/// <summary>Retains native expression targets when extending an authored hair mesh.
/// SaveToVmdl omits MRPH, so read the resource's own morph atlas once per fitted model.</summary>
internal static class SerializedModelMorphs
{
	internal sealed record Target(string Name, Vector3[] Positions, Vector3[] Normals);
	static Dictionary<string, object> Object(object value) => (Dictionary<string, object>)value;
	static List<object> Array(object value) => (List<object>)value;
	static int Int(object value) => Convert.ToInt32(value);
	static float Float(object value) => Convert.ToSingle(value);
	static Vector3 Vector(object value)
	{
		var a = Array(value);
		return new(Float(a[0]), Float(a[1]), Float(a[2]));
	}

	internal static Target[] Read(Model source, int mesh, int vertexCount, bool identityOnly = false)
	{
		if (source.MorphCount == 0) return System.Array.Empty<Target>();
		var bytes = FileSystem.Mounted.ReadAllBytes(source.ResourcePath + "_c").ToArray();
		int table = 8 + BitConverter.ToInt32(bytes, 8), count = BitConverter.ToInt32(bytes, 12);
		byte[] Block(int i)
		{
			int entry = table + 12 * i;
			int start = entry + 4 + BitConverter.ToInt32(bytes, entry + 4), size = BitConverter.ToInt32(bytes, entry + 8);
			return bytes.AsSpan(start, size).ToArray();
		}
		int control = Enumerable.Range(0, count).Single(i => Encoding.ASCII.GetString(bytes, table + 12 * i, 4) == "CTRL");
		var root = SerializedMeshBones.ReadObject(Block(control));
		var embedded = Array(root["embedded_meshes"]).Select(Object).Single(x => Int(x["mesh_index"]) == mesh);
		int index = Int(embedded["morph_block"]);
		if (index < 0) return System.Array.Empty<Target>();
		var data = SerializedMeshBones.ReadObject(Block(index));
		if ((string)data["m_nLookupType"] != "LOOKUP_TYPE_VERTEX_ID" || (string)data["m_nEncodingType"] != "ENCODING_TYPE_OBJECT_SPACE")
			throw new InvalidDataException("Unsupported hair morph encoding: " + source.Name);
		var texture = Texture.Load((string)data["m_pTextureAtlas"]);
		if (texture is null || texture.IsError) throw new InvalidDataException("Missing native hair expression atlas: " + source.Name);
		texture.MarkUsed(Math.Max(texture.Width, texture.Height));
		var pixels = texture.GetPixels();
		int width = Int(data["m_nWidth"]), height = Int(data["m_nHeight"]);
		if (vertexCount > width * height) throw new InvalidDataException("Hair morph vertex grid is smaller than its mesh.");
		var bundles = Array(data["m_bundleTypes"]).Cast<string>().ToArray();
		var result = new List<Target>();
		foreach (var morph in Array(data["m_morphDatas"]).Select(Object))
		{
			var name = (string)morph["m_name"];
			if (string.IsNullOrWhiteSpace(name)) continue;
			if (identityOnly && !name.StartsWith("ce_", StringComparison.Ordinal)) continue;
			var positions = new Vector3[vertexCount];
			var normals = new Vector3[vertexCount];
			foreach (var rect in Array(morph["m_morphRectDatas"]).Select(Object))
			{
				int dx = Int(rect["m_nXLeftDst"]), dy = Int(rect["m_nYTopDst"]);
				int w = (int)MathF.Round(Float(rect["m_flUWidthSrc"]) * texture.Width), h = (int)MathF.Round(Float(rect["m_flVHeightSrc"]) * texture.Height);
				var parts = Array(rect["m_bundleDatas"]);
				for (int bundle = 0; bundle < parts.Count; bundle++)
				{
					bool normal = bundles[bundle] == "MORPH_BUNDLE_TYPE_NORMAL_WRINKLE";
					if (!normal && bundles[bundle] != "MORPH_BUNDLE_TYPE_POSITION_SPEED") continue;
					var part = Object(parts[bundle]);
					int sx = (int)MathF.Round(Float(part["m_flULeftSrc"]) * texture.Width), sy = (int)MathF.Round(Float(part["m_flVTopSrc"]) * texture.Height);
					var range = Vector(part["m_ranges"]); var offset = Vector(part["m_offsets"]);
					float Snap(float v, float r) => MathF.Abs(v) < MathF.Abs(r) / 510 ? 0 : v;
					for (int y = 0; y < h; y++) for (int x = 0; x < w; x++)
					{
						int id = (dy + y) * width + dx + x;
						if (dx + x >= width || id < 0 || id >= vertexCount || sx + x >= texture.Width || sy + y >= texture.Height) continue;
						var pixel = pixels[(sy + y) * texture.Width + sx + x];
						var value = new Vector3(pixel.r, pixel.g, pixel.b) / 255 * range + offset;
						value = new(Snap(value.x, range.x), Snap(value.y, range.y), Snap(value.z, range.z));
						(normal ? normals : positions)[id] = value;
					}
				}
			}
			result.Add(new(name, positions, normals));
		}
		return result.ToArray();
	}
}