AlternativeFuta: load DirectSkin custom hair on the futa skin Based on RunRudolf.AlternativeFuta.14 (AlternativeFuta.cs "Version 2026-09-15"). Apply from the VaM root: git apply AlternativeFuta-DirectSkinHair.patch or: patch -p1 --binary < AlternativeFuta-DirectSkinHair.patch (The source uses CRLF line endings; the patch keeps them, so share it as a file rather than pasting it into a web form that may strip carriage returns.) Problem ------- Custom hair saved from Hair Creator with the DirectSkinFemale/DirectSkinMale scalp stores one strand slot per base vertex of the skin it was made on: 23008 for the stock female skin, 24333 for the AltFuta skin. On an AltFuta character: - Hair made on the futa skin fails with Exception while loading Custom/Hair/.../X.vab System.Exception: Binary hair data not compatible with chosen scalp mesh and then, on every later load of that item, System.IndexOutOfRangeException: Array index is out of range. at GPUTools.Hair.Scripts.Geometry.Create.RuntimeHairGeometryCreator.LoadFromBinaryReader - Hair made on the stock female skin (including published DirectSkin hair, e.g. body fur and tails) always fails with "not compatible". Cause ----- RuntimeHairGeometryCreator sizes its strand table when its ScalpProvider object is assigned. The DirectSkin providers are PreCalcMeshProviderHolders around the atom's current skin; DAZHairGroup.Connect() repoints the holder at a new skin without resizing the table. AddMaleGenitalia() selects the new character before the male graft is merged (WaitForLoad finishes it later), so hair that connects in that window, or that a scene/preset restores before AltFuta is ready, sizes its table for 23008 vertices. Once the skin reaches 24333, the count check in LoadFromBinaryReader passes and strands[j] runs past the end of the table. The futa skin's base vertices are the stock female ones in the same order, followed by the 1325 base vertices of male_gen_dazmesh: DAZMergedMesh places graft 2 at startGraft2VertIndex, after the target mesh and graft 1. Stock hair is therefore valid on the futa skin once its table is padded with empty slots for the graft. Fix --- When WaitForLoad sets status "ready", WatchDirectSkinHair() checks the atom's active saved DirectSkin hair once a second for as long as that futa skin is up (after any scene load has finished), so hair added later is covered too. A hair item needs help when its loaded mask and strand table differ in size (LoadFromBinaryReader reads the mask before failing), or the table fits neither the futa nor the stock vertex count. - Hair whose .vab matches the futa skin: point the creator at another DirectSkin holder and call DAZDynamic.Load(). Load assigns the .vab's provider back, which rebuilds the table from the finished skin. - Hair whose .vab matches the stock skin: write a copy of its .vam/.vaj and a .vab with the mask and strand table padded by 1325 empty, masked slots to Custom/PluginData/RunRudolf/AlternativeFuta/DirectSkinHair/ (a plugin path that needs no confirmation), point the item's DAZDynamic at that folder for one native Load(), then restore its original names so scenes and presets keep referring to the original item. Plugins can't use System.IO, so the copy is written with FileManagerSecure and loaded by VaM's own loader rather than fed to LoadFromBinaryReader directly. - Hair made for any other skin is left alone, as is the Hair Creator item. Non-default values of the item's own storables (sim, colors, scalp materials) are captured before Load() and restored afterwards, because Load() re-applies the item's .vaj values. An item is not retried until VaM loads it again, so hair that still can't load doesn't produce repeated errors. The first "not compatible" error from VaM's own load attempt still appears once. --- a/Custom/Scripts/RunRudolf/AlternativeFuta/AlternativeFuta.cs +++ b/Custom/Scripts/RunRudolf/AlternativeFuta/AlternativeFuta.cs @@ -14,6 +14,8 @@ using Battlehub.RTCommon; using Leap.Unity; using UnityEngine.EventSystems; +using PreCalcMeshProviderHolder = GPUTools.Skinner.Scripts.Providers.PreCalcMeshProviderHolder; +using RuntimeHairGeometryCreator = GPUTools.Hair.Scripts.Geometry.Create.RuntimeHairGeometryCreator; namespace Stopper { @@ -1232,6 +1234,8 @@ skinLoaded = true; status.val = "ready"; + StartCoroutine(WatchDirectSkinHair(skin)); + // Pelvis if (showMaterial29 != null) { @@ -1292,6 +1296,383 @@ isLoaded = true; } + + // DirectSkin hair + // + // Custom hair made with the DirectSkinFemale/DirectSkinMale scalp keeps one + // strand slot per base vertex of the skin it was made on: 23008 for the stock + // female skin, 24333 for the futa skin. The futa skin's base vertices are the + // stock female ones followed by the male graft (DAZMergedMesh places graft 2 + // at startGraft2VertIndex, after the target mesh and graft 1), so hair made on + // the stock skin is valid on the futa skin once its table is padded with empty + // slots for the graft. + // + // VaM sizes that table when the hair's scalp provider is assigned, but the + // DirectSkin providers are holders around the atom's current skin and VaM + // does not resize the table when the skin behind the holder changes. + // AddMaleGenitalia selects the new character before the graft is merged, so + // hair that connects in that window sizes its table for the stock skin. + // Without help: + // - hair made on the futa skin fails with "Binary hair data not compatible + // with chosen scalp mesh", then IndexOutOfRangeException on every later + // load, and + // - hair made on the stock skin always fails with "not compatible" here. + // + // While the futa skin is up, check the atom's DirectSkin hair once a second. + // Futa hair is reloaded so VaM rebuilds its table from the finished skin. + // Stock hair is loaded from a copy whose table is padded for the graft, + // written under Custom/PluginData. + + private const float HairCheckInterval = 1f; + private const string HairGeometryMarker = "RuntimeHairGeometryCreator"; + private const string PaddedHairPath = "Custom/PluginData/RunRudolf/AlternativeFuta/DirectSkinHair"; + // DAZDynamic resolves its files under Custom/Hair////; + // these climb back to Custom/ to reach the padded copies. + private const string PaddedHairCreatorName = ".."; + private const string PaddedHairFolderName = "../PluginData/RunRudolf/AlternativeFuta/DirectSkinHair"; + + private class HairStorableState + { + public JSONStorable storable; + public JSONClass json; + } + + // Byte offsets of the parts of a .vab hair geometry block that depend on the + // scalp vertex count. + private class VabGeometryLayout + { + public int scalpVertexCount; + public int maskCountPos; // Int32 mask size, then one bool per scalp vertex + public int strandCountPos; // Int32 strand count, then the strands + public int strandsEnd; + } + + private IEnumerator WatchDirectSkinHair(DAZMergedSkinV2 futaSkin) + { + // Loaded masks already handled, so hair that still can't load isn't + // retried until VaM loads it again. + List handledMasks = new List(); + + // Ends when ReloadSkin/UnloadSkin replaces this skin or the plugin is disabled. + while (skin == futaSkin && skinLoaded && status.val == "ready") + { + // Hair restored by a scene or preset load arrives after the load ends. + if (!SuperController.singleton.isLoading) + { + CheckDirectSkinHair(futaSkin, handledMasks); + } + yield return new WaitForSecondsRealtime(HairCheckInterval); + } + } + + private void CheckDirectSkinHair(DAZMergedSkinV2 futaSkin, List handledMasks) + { + DAZHairGroup[] items = characterSelector.hairItems; + if (items == null) return; + + for (int i = 0; i < items.Length; i++) + { + DAZHairGroup item = items[i]; + // Saved custom hair only; the Hair Creator item is left alone. + if (item == null || !item.active || !item.isDynamicRuntimeLoaded) continue; + + DAZDynamic dynamic = item.GetComponentInChildren(true); + if (dynamic == null) continue; + RuntimeHairGeometryCreator creator = dynamic.GetComponent(); + if (creator == null || creator.strandsMask == null) continue; + PreCalcMeshProviderHolder holder = creator.ScalpProvider as PreCalcMeshProviderHolder; + // Not DirectSkin hair, or not connected to the futa skin yet. + if (holder == null || holder.provider != futaSkin) continue; + + Mesh mesh = holder.useBaseMesh ? holder.BaseMesh : holder.Mesh; + if (mesh == null) continue; + int skinVertexCount = mesh.vertexCount; + int stockVertexCount = GetStockVertexCount(futaSkin, holder, skinVertexCount); + + // LoadFromBinaryReader reads the mask from the file before checking the + // strand table, so a failed load leaves the two at different sizes. + bool[] mask = creator.strandsMask.vertices; + int strandCount = creator.strands == null ? -1 : creator.strands.Length; + if (mask == null || handledMasks.Contains(mask)) continue; + if (mask.Length == strandCount && (strandCount == skinVertexCount || strandCount == stockVertexCount)) continue; + + LoadDirectSkinHair(item, dynamic, creator, holder, skinVertexCount, stockVertexCount); + + handledMasks.Add(mask); + if (creator.strandsMask != null && creator.strandsMask.vertices != null) + { + handledMasks.Add(creator.strandsMask.vertices); + } + } + } + + // Base vertex count of the stock skin at the start of the futa skin, when the + // holder uses base vertices and the male graft is last; otherwise -1. + private static int GetStockVertexCount(DAZMergedSkinV2 futaSkin, PreCalcMeshProviderHolder holder, int skinVertexCount) + { + DAZMergedMesh mergedMesh = futaSkin.dazMesh as DAZMergedMesh; + if (!holder.useBaseMesh || mergedMesh == null || mergedMesh.numGraft2BaseVertices <= 0) return -1; + if (mergedMesh.startGraft2VertIndex + mergedMesh.numGraft2BaseVertices != skinVertexCount) return -1; + return mergedMesh.startGraft2VertIndex; + } + + private void LoadDirectSkinHair(DAZHairGroup item, DAZDynamic dynamic, RuntimeHairGeometryCreator creator, + PreCalcMeshProviderHolder holder, int skinVertexCount, int stockVertexCount) + { + string sourceBase = dynamic.GetStoreFolderPath() + dynamic.storeName; + byte[] vab = ReadFileBytes(sourceBase + ".vab"); + VabGeometryLayout layout = vab == null ? null : ParseVabGeometry(vab); + if (layout == null) return; + + if (layout.scalpVertexCount == skinVertexCount) + { + // Made on the futa skin; a reload rebuilds the stale table. + ReloadHair(item, dynamic, creator, holder, null); + } + else if (stockVertexCount > 0 && layout.scalpVertexCount == stockVertexCount) + { + // Made on the stock skin; load a copy padded for the male graft. + string paddedStoreName = WritePaddedHairCopy(dynamic, sourceBase, vab, layout, skinVertexCount); + if (paddedStoreName != null) + { + ReloadHair(item, dynamic, creator, holder, paddedStoreName); + } + } + // Hair made for any other skin can't load here; VaM's error stands. + } + + private void ReloadHair(DAZHairGroup item, DAZDynamic dynamic, RuntimeHairGeometryCreator creator, + PreCalcMeshProviderHolder holder, string paddedStoreName) + { + PreCalcMeshProviderHolder otherHolder = null; + Transform[] providers = creator.ScalpProviders; + for (int i = 0; providers != null && i < providers.Length; i++) + { + if (providers[i] == null) continue; + PreCalcMeshProviderHolder candidate = providers[i].GetComponent(); + if (candidate != null && candidate != holder) + { + otherHolder = candidate; + break; + } + } + if (otherHolder == null) return; + + // Load() re-applies the item's own .vaj values, so keep what the scene + // or preset changed and restore it afterwards. + List overrides = CaptureHairOverrides(item); + + // The table is only rebuilt when the provider object changes. Point the + // creator at another DirectSkin holder so that Load(), which assigns the + // provider named in the .vab, rebuilds it from the current skin. + creator.ScalpProvider = otherHolder; + + bool loaded; + if (paddedStoreName == null) + { + loaded = dynamic.Load(); + } + else + { + // Load the padded copy once, then point the item back at its own files + // so scenes and presets keep referring to the original. + string package = dynamic.package; + string creatorName = dynamic.creatorName; + string storeFolderName = dynamic.storeFolderName; + string storeName = dynamic.storeName; + try + { + dynamic.package = string.Empty; + dynamic.creatorName = PaddedHairCreatorName; + dynamic.storeFolderName = PaddedHairFolderName; + dynamic.storeName = paddedStoreName; + loaded = dynamic.Load(); + } + finally + { + dynamic.package = package; + dynamic.creatorName = creatorName; + dynamic.storeFolderName = storeFolderName; + dynamic.storeName = storeName; + } + } + + for (int i = 0; i < overrides.Count; i++) + { + overrides[i].storable.RestoreFromJSON(overrides[i].json, true, true, null, false); + } + for (int i = 0; i < overrides.Count; i++) + { + overrides[i].storable.LateRestoreFromJSON(overrides[i].json, true, true, false); + } + + if (!loaded) + { + SuperController.LogError($"AlternativeFuta: Could not load DirectSkin hair {item.uid} on the futa skin."); + } + } + + // Non-default values of the hair item's own storables (sim, item control, + // scalp materials), whose ids all start with the item uid. + private static List CaptureHairOverrides(DAZHairGroup item) + { + List result = new List(); + string prefix = item.uid; + if (string.IsNullOrEmpty(prefix)) return result; + + JSONStorable[] storables = item.GetComponentsInChildren(true); + for (int i = 0; i < storables.Length; i++) + { + JSONStorable storable = storables[i]; + if (storable == null) continue; + string id = storable.storeId; + if (id == null || id == prefix || !id.StartsWith(prefix)) continue; + result.Add(new HairStorableState { storable = storable, json = storable.GetJSON(true, true, false) }); + } + return result; + } + + // Writes the item's .vam/.vaj and a .vab whose mask and strand table are + // padded to the futa skin, and returns the copy's store name. + private static string WritePaddedHairCopy(DAZDynamic dynamic, string sourceBase, byte[] vab, + VabGeometryLayout layout, int skinVertexCount) + { + try + { + int stockCount = layout.scalpVertexCount; + int extra = skinVertexCount - stockCount; + byte[] padded = new byte[vab.Length + extra + extra * 8]; + int pos = CopyBytes(vab, 0, layout.maskCountPos, padded, 0); + pos = WriteInt32(padded, pos, skinVertexCount); + pos = CopyBytes(vab, layout.maskCountPos + 4, stockCount, padded, pos); + for (int i = 0; i < extra; i++) + { + padded[pos++] = 1; // Masked: no hair grows on the graft + } + pos = WriteInt32(padded, pos, skinVertexCount); + pos = CopyBytes(vab, layout.strandCountPos + 4, layout.strandsEnd - layout.strandCountPos - 4, padded, pos); + for (int i = stockCount; i < skinVertexCount; i++) + { + pos = WriteInt32(padded, pos, i); // Scalp vertex index + pos = WriteInt32(padded, pos, 0); // No strand vertices + } + CopyBytes(vab, layout.strandsEnd, vab.Length - layout.strandsEnd, padded, pos); + + string storeName = dynamic.storeName + " " + StableHash(sourceBase); + string target = PaddedHairPath + "/" + storeName; + FileManagerSecure.CreateDirectory(PaddedHairPath); + FileManagerSecure.WriteAllText(target + ".vam", FileManagerSecure.ReadAllText(sourceBase + ".vam")); + FileManagerSecure.WriteAllText(target + ".vaj", FileManagerSecure.ReadAllText(sourceBase + ".vaj")); + FileManagerSecure.WriteAllBytes(target + ".vab", padded); + return storeName; + } + catch (Exception e) + { + SuperController.LogError($"AlternativeFuta: Could not prepare {sourceBase}.vab for the futa skin. {e.Message}"); + return null; + } + } + + private static byte[] ReadFileBytes(string path) + { + try + { + return FileManagerSecure.FileExists(path) ? FileManagerSecure.ReadAllBytes(path) : null; + } + catch (Exception) + { + return null; + } + } + + // The geometry block is a length-prefixed "RuntimeHairGeometryCreator", + // schema version, scalp provider name, segments (Int32), segment length + // (Single), mask name, mask, strand count, then per strand its scalp index, + // vertex count and vertices. + private static VabGeometryLayout ParseVabGeometry(byte[] data) + { + try + { + int pos = IndexOfAscii(data, HairGeometryMarker); + if (pos < 0) return null; + pos += HairGeometryMarker.Length; + SkipBinaryString(data, ref pos); // Schema version + SkipBinaryString(data, ref pos); // Scalp provider name + pos += 8; // Segments, segment length + SkipBinaryString(data, ref pos); // Mask name + + VabGeometryLayout layout = new VabGeometryLayout(); + layout.maskCountPos = pos; + layout.scalpVertexCount = BitConverter.ToInt32(data, pos); + pos += 4 + layout.scalpVertexCount; + layout.strandCountPos = pos; + if (BitConverter.ToInt32(data, pos) != layout.scalpVertexCount) return null; + pos += 4; + for (int i = 0; i < layout.scalpVertexCount; i++) + { + pos += 8 + BitConverter.ToInt32(data, pos + 4) * 12; + } + if (pos > data.Length) return null; + layout.strandsEnd = pos; + return layout; + } + catch (Exception) + { + return null; + } + } + + private static int IndexOfAscii(byte[] data, string text) + { + for (int i = 0; i + text.Length <= data.Length; i++) + { + int j = 0; + while (j < text.Length && data[i + j] == (byte)text[j]) j++; + if (j == text.Length) return i; + } + return -1; + } + + // BinaryWriter string: 7-bit encoded length followed by UTF-8 bytes. + private static void SkipBinaryString(byte[] data, ref int pos) + { + int length = 0; + int shift = 0; + byte b; + do + { + b = data[pos++]; + length |= (b & 0x7F) << shift; + shift += 7; + } + while ((b & 0x80) != 0); + pos += length; + } + + private static int CopyBytes(byte[] source, int sourceIndex, int length, byte[] target, int targetIndex) + { + Array.Copy(source, sourceIndex, target, targetIndex, length); + return targetIndex + length; + } + + private static int WriteInt32(byte[] target, int pos, int value) + { + byte[] bytes = BitConverter.GetBytes(value); + Array.Copy(bytes, 0, target, pos, 4); + return pos + 4; + } + + // FNV-1a of the source path, so same-named hair from different folders or + // packages gets separate padded copies. + private static string StableHash(string text) + { + uint hash = 2166136261; + for (int i = 0; i < text.Length; i++) + { + hash = (hash ^ (uint)text[i]) * 16777619u; + } + return hash.ToString("x8"); + } private void InitMainUI() {