Skip to content

Commit 298597e

Browse files
Merge pull request #43 from PassiveModding/feature/2025-08-update
Feature/2025 08 update
2 parents a6bc9e6 + f699b75 commit 298597e

25 files changed

+814
-913
lines changed

Meddle/Meddle.Plugin/Models/Composer/ArrayTextureUtil.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
using Meddle.Plugin.Utils;
2+
using Meddle.Utils;
23
using Meddle.Utils.Files;
34
using Meddle.Utils.Files.SqPack;
45
using Meddle.Utils.Helpers;
6+
using Microsoft.Extensions.Logging;
7+
using SkiaSharp;
58

69
namespace Meddle.Plugin.Models.Composer;
710

@@ -28,6 +31,8 @@ public static void SaveSphereTextures(SqPack pack, string cacheDir)
2831
var texture = img.ImageAsPng();
2932
File.WriteAllBytes(Path.Combine(catchlightOutDir, $"sphere_d_array.{i}.png"), texture.ToArray());
3033
}
34+
35+
SaveAsVerticalArrayTexture(catchLightTex, catchlightOutDir, "sphere_d_array", catchLightTex.Header.CalculatedArraySize);
3136
}
3237

3338
public static void SaveTileTextures(SqPack pack, string cacheDir)
@@ -56,6 +61,9 @@ public static void SaveTileTextures(SqPack pack, string cacheDir)
5661
var texture = img.ImageAsPng();
5762
File.WriteAllBytes(Path.Combine(tileOrbOutDir, $"tile_orb_array.{i}.png"), texture.ToArray());
5863
}
64+
65+
SaveAsVerticalArrayTexture(tileNormTex, tileNormOutDir, "tile_norm_array", tileNormTex.Header.CalculatedArraySize);
66+
SaveAsVerticalArrayTexture(tileOrbTex, tileOrbOutDir, "tile_orb_array", tileOrbTex.Header.CalculatedArraySize);
5967
}
6068

6169
public static void SaveBgSphereTextures(SqPack pack, string cacheDir)
@@ -73,6 +81,8 @@ public static void SaveBgSphereTextures(SqPack pack, string cacheDir)
7381
var texture = img.ImageAsPng();
7482
File.WriteAllBytes(Path.Combine(catchlightOutDir, $"sphere_d_array.{i}.png"), texture.ToArray());
7583
}
84+
85+
SaveAsVerticalArrayTexture(catchLightTex, catchlightOutDir, "sphere_d_array", catchLightTex.Header.CalculatedArraySize);
7686
}
7787

7888
public static void SaveBgDetailTextures(SqPack pack, string cacheDir)
@@ -103,5 +113,45 @@ public static void SaveBgDetailTextures(SqPack pack, string cacheDir)
103113
var texture = img.ImageAsPng();
104114
File.WriteAllBytes(Path.Combine(detailNOutDir, $"detail_n_array.{i}.png"), texture.ToArray());
105115
}
116+
117+
SaveAsVerticalArrayTexture(detailDTex, detailDOutDir, "detail_d_array", detailDTex.Header.CalculatedArraySize);
118+
SaveAsVerticalArrayTexture(detailNTex, detailNOutDir, "detail_n_array", detailNTex.Header.CalculatedArraySize);
119+
}
120+
121+
private static void SaveAsVerticalArrayTexture(
122+
TexFile texFile, string outDir, string fileName, int arraySize)
123+
{
124+
// Combines all images in the array into a single vertical array texture
125+
var width = texFile.Header.Width;
126+
var height = texFile.Header.Height;
127+
var totalHeight = height * arraySize;
128+
var combinedImage = new SkTexture(width, totalHeight);
129+
for (int i = 0; i < arraySize; i++)
130+
{
131+
var buf = ImageUtils.GetRawRgbaData(texFile, i, 0, 0);
132+
var startY = i * height;
133+
for (int y = 0; y < height; y++)
134+
{
135+
for (int x = 0; x < width; x++)
136+
{
137+
var index = (y * width + x) * 4;
138+
var r = buf[index];
139+
var g = buf[index + 1];
140+
var b = buf[index + 2];
141+
var a = buf[index + 3];
142+
// Set the pixel in the combined image
143+
// Adjust y position based on the array index
144+
combinedImage[x, startY + y] = new SKColor(r, g, b, a);
145+
}
146+
}
147+
}
148+
149+
// Save the combined image as a PNG
150+
var combinedImagePath = Path.Combine(outDir, $"{fileName}.{width}x{height}.vertical.png");
151+
using var memoryStream = new MemoryStream();
152+
combinedImage.Bitmap.Encode(memoryStream, SKEncodedImageFormat.Png, 100);
153+
var textureData = memoryStream.ToArray();
154+
File.WriteAllBytes(combinedImagePath, textureData);
155+
Plugin.Logger.LogInformation("Saved vertical array texture to {Path}", combinedImagePath);
106156
}
107157
}

Meddle/Meddle.Plugin/Models/Composer/CharacterComposer.cs

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public record SkinningContext(List<BoneNodeBuilder> Bones, BoneNodeBuilder? Root
2020
private readonly ComposerCache composerCache;
2121
private readonly Configuration.ExportConfiguration exportConfig;
2222
private readonly CancellationToken cancellationToken;
23+
private readonly Dictionary<ParsedCharacterInfo, Dictionary<string, MaterialBuilder>> materialCache = new();
2324

2425
public CharacterComposer(ComposerCache composerCache, Configuration.ExportConfiguration exportConfig, CancellationToken cancellationToken)
2526
{
@@ -42,12 +43,12 @@ private void HandleModel(ParsedCharacterInfo characterInfo, ParsedModelInfo m, S
4243
{
4344
if (m.Path.GamePath.Contains("b0003_top"))
4445
{
45-
Plugin.Logger?.LogDebug("Skipping model {ModelPath}", m.Path.GamePath);
46+
Plugin.Logger.LogDebug("Skipping model {ModelPath}", m.Path.GamePath);
4647
return;
4748
}
4849

4950
var mdlFile = composerCache.GetMdlFile(m.Path.FullPath);
50-
Plugin.Logger?.LogInformation("Loaded model {modelPath}", m.Path.FullPath);
51+
Plugin.Logger.LogInformation("Loaded model {modelPath}", m.Path.FullPath);
5152
var materialBuilders = new MaterialBuilder[m.Materials.Length];
5253
for (int i = 0; i < m.Materials.Length; i++)
5354
{
@@ -60,14 +61,26 @@ private void HandleModel(ParsedCharacterInfo characterInfo, ParsedModelInfo m, S
6061

6162
try
6263
{
63-
materialBuilders[i] = composerCache.ComposeMaterial(materialInfo.Path.FullPath,
64-
materialInfo: materialInfo,
65-
characterInfo: characterInfo,
66-
colorTableSet: materialInfo.ColorTable);
64+
if (!materialCache.TryGetValue(characterInfo, out var characterCache))
65+
{
66+
characterCache = new Dictionary<string, MaterialBuilder>();
67+
materialCache[characterInfo] = characterCache;
68+
}
69+
70+
if (characterCache.TryGetValue(materialInfo.GetHash(), out var cachedBuilder))
71+
{
72+
materialBuilders[i] = cachedBuilder;
73+
}
74+
else
75+
{
76+
materialBuilders[i] = composerCache.ComposeMaterial(materialInfo.Path.FullPath,
77+
materialInfo: materialInfo,
78+
characterInfo: characterInfo);
79+
}
6780
}
6881
catch (Exception e)
6982
{
70-
Plugin.Logger?.LogError(e, "Failed to load material\n{Message}\n{MaterialInfo}", e.Message,
83+
Plugin.Logger.LogError(e, "Failed to load material\n{Message}\n{MaterialInfo}", e.Message,
7184
JsonSerializer.Serialize(m.Materials[i],
7285
MaterialComposer.JsonOptions));
7386
materialBuilders[i] = new MaterialBuilder("error");
@@ -91,7 +104,7 @@ private void HandleModel(ParsedCharacterInfo characterInfo, ParsedModelInfo m, S
91104
deform = ((GenderRace)m.Deformer.Value.DeformerId,
92105
(GenderRace)m.Deformer.Value.RaceSexId,
93106
new RaceDeformer(pbdFile, skinningContext.Bones));
94-
Plugin.Logger?.LogDebug("Using deformer pbd {Path}", m.Deformer.Value.PbdPath);
107+
Plugin.Logger.LogDebug("Using deformer pbd {Path}", m.Deformer.Value.PbdPath);
95108
}
96109
else
97110
{
@@ -197,7 +210,7 @@ private bool HandleAttach((ParsedCharacterInfo Owner, List<BoneNodeBuilder> Owne
197210
if (rootBone == null) throw new InvalidOperationException("Root bone not found");
198211
var attachName = attachData.Owner.Skeleton.PartialSkeletons[attach.PartialSkeletonIdx]
199212
.HkSkeleton!.BoneNames[(int)attach.BoneIdx];
200-
Plugin.Logger?.LogInformation("Attaching {AttachName} to {RootBone}", attachName, rootBone.BoneName);
213+
Plugin.Logger.LogInformation("Attaching {AttachName} to {RootBone}", attachName, rootBone.BoneName);
201214
lock (attachLock)
202215
{
203216
Interlocked.Increment(ref attachSuffix);
@@ -252,11 +265,11 @@ private bool HandleRootAttach(
252265
var rootAttach = characterInfo.Attaches.FirstOrDefault(x => x.Attach.ExecuteType == 0);
253266
if (rootAttach == null)
254267
{
255-
Plugin.Logger?.LogWarning("Root attach not found");
268+
Plugin.Logger.LogWarning("Root attach not found");
256269
}
257270
else
258271
{
259-
Plugin.Logger?.LogWarning("Root attach found");
272+
Plugin.Logger.LogWarning("Root attach found");
260273
// handle root first, then attach this to the root
261274
var rootAttachProgress = new ExportProgress(rootAttach.Models.Length, "Root attach");
262275
rootProgress.Children.Add(rootAttachProgress);
@@ -343,13 +356,13 @@ private bool HandleRootAttach(
343356
bones = SkeletonUtils.GetBoneMap(characterInfo.Skeleton, exportConfig.PoseMode, out rootBone);
344357
if (rootBone == null)
345358
{
346-
Plugin.Logger?.LogWarning("Root bone not found");
359+
Plugin.Logger.LogWarning("Root bone not found");
347360
return null;
348361
}
349362
}
350363
catch (Exception e)
351364
{
352-
Plugin.Logger?.LogError(e, "Failed to get bone map");
365+
Plugin.Logger.LogError(e, "Failed to get bone map");
353366
return null;
354367
}
355368

@@ -366,7 +379,7 @@ private bool HandleRootAttach(
366379
}
367380
catch (Exception e)
368381
{
369-
Plugin.Logger?.LogError(e, "Failed to handle attach {AttachData}", JsonSerializer.Serialize(new
382+
Plugin.Logger.LogError(e, "Failed to handle attach {AttachData}", JsonSerializer.Serialize(new
370383
{
371384
AttachData = attachData.Value.Attach,
372385
Owner = attachData.Value.Owner
@@ -384,7 +397,7 @@ private bool HandleRootAttach(
384397
}
385398
catch (Exception e)
386399
{
387-
Plugin.Logger?.LogError(e, "Failed to handle root attach {CharacterInfo}", JsonSerializer.Serialize(characterInfo, MaterialComposer.JsonOptions));
400+
Plugin.Logger.LogError(e, "Failed to handle root attach {CharacterInfo}", JsonSerializer.Serialize(characterInfo, MaterialComposer.JsonOptions));
388401
}
389402
}
390403

@@ -397,7 +410,7 @@ private bool HandleRootAttach(
397410
{
398411
if (cancellationToken.IsCancellationRequested)
399412
{
400-
Plugin.Logger?.LogInformation("Export cancelled, stopping model processing");
413+
Plugin.Logger.LogInformation("Export cancelled, stopping model processing");
401414
break;
402415
}
403416

@@ -409,7 +422,7 @@ private bool HandleRootAttach(
409422
}
410423
catch (Exception e)
411424
{
412-
Plugin.Logger?.LogError(e, "Failed to handle model\n{Message}\n{ModelInfo}", e.Message, JsonSerializer.Serialize(t, MaterialComposer.JsonOptions));
425+
Plugin.Logger.LogError(e, "Failed to handle model\n{Message}\n{ModelInfo}", e.Message, JsonSerializer.Serialize(t, MaterialComposer.JsonOptions));
413426
}
414427

415428
rootProgress.IncrementProgress();
@@ -419,7 +432,7 @@ private bool HandleRootAttach(
419432
{
420433
if (cancellationToken.IsCancellationRequested)
421434
{
422-
Plugin.Logger?.LogInformation("Export cancelled, stopping attach processing");
435+
Plugin.Logger.LogInformation("Export cancelled, stopping attach processing");
423436
break;
424437
}
425438

@@ -433,7 +446,7 @@ private bool HandleRootAttach(
433446
}
434447
catch (Exception e)
435448
{
436-
Plugin.Logger?.LogError(e, "Failed to handle attach {Attach}", JsonSerializer.Serialize(t, MaterialComposer.JsonOptions));
449+
Plugin.Logger.LogError(e, "Failed to handle attach {Attach}", JsonSerializer.Serialize(t, MaterialComposer.JsonOptions));
437450
}
438451
finally
439452
{
@@ -470,7 +483,7 @@ public static void EnsureBonesExist(Model model, List<BoneNodeBuilder> bones, Bo
470483
var parent = FindLogicalParent(model, bone, bones) ?? root;
471484
parent.AddNode(bone);
472485

473-
Plugin.Logger?.LogWarning("Added bone {BoneName} to {ParentBone}\n" +
486+
Plugin.Logger.LogWarning("Added bone {BoneName} to {ParentBone}\n" +
474487
"NOTE: This may break posing in some cases, you may find better results " +
475488
"deleting the bone after importing into editing software", boneName, parent.BoneName);
476489
bones.Add(bone);

Meddle/Meddle.Plugin/Models/Composer/ComposerCache.cs

Lines changed: 47 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Collections.Concurrent;
22
using Meddle.Plugin.Models.Layout;
33
using Meddle.Plugin.Utils;
4+
using Meddle.Utils;
45
using Meddle.Utils.Export;
56
using Meddle.Utils.Files;
67
using Meddle.Utils.Files.SqPack;
@@ -20,7 +21,6 @@ public class ComposerCache
2021
private readonly ConcurrentDictionary<string, string> mtrlPathCache = new();
2122
private readonly ConcurrentDictionary<string, PbdFile> pbdCache = new();
2223
private readonly ConcurrentDictionary<string, RefCounter<MdlFile>> mdlCache = new();
23-
private readonly ConcurrentDictionary<string, MaterialBuilder> mtrlBuilderCache = new();
2424

2525
private sealed class RefCounter<T>(T obj)
2626
{
@@ -94,7 +94,7 @@ public MdlFile GetMdlFile(string path)
9494
{
9595
var toRemove = mdlCache.OrderBy(x => x.Value.LastAccess).First();
9696
mdlCache.TryRemove(toRemove.Key, out _);
97-
Plugin.Logger?.LogDebug($"Evicting model file: {toRemove.Key}");
97+
Plugin.Logger.LogDebug($"Evicting model file: {toRemove.Key}");
9898
}
9999

100100
return new RefCounter<MdlFile>(mdlFile);
@@ -123,7 +123,7 @@ public MtrlFile GetMtrlFile(string path, out string? cachePath)
123123
// evict least recently accessed
124124
var toRemove = mtrlCache.OrderBy(x => x.Value.LastAccess).First();
125125
mtrlCache.TryRemove(toRemove.Key, out _);
126-
Plugin.Logger?.LogDebug("Evicting material file: {toRemove}", toRemove.Key);
126+
Plugin.Logger.LogDebug("Evicting material file: {toRemove}", toRemove.Key);
127127
}
128128

129129
return new RefCounter<MtrlFile>(mtrlFile);
@@ -178,7 +178,7 @@ private string GetCacheFilePath(string fullPath)
178178
var fileName = parts[^1];
179179

180180
var trimmed = $"{dirHash}/{fileName}";
181-
Plugin.Logger?.LogDebug("Cache path too long ({len} > {available}), using hash: {trimmed} for {fullPath}", len, charactersAvailable, trimmed, fullPath);
181+
Plugin.Logger.LogDebug("Cache path too long ({len} > {available}), using hash: {trimmed} for {fullPath}", len, charactersAvailable, trimmed, fullPath);
182182
cleanPath = trimmed;
183183
}
184184

@@ -230,28 +230,8 @@ public string CacheTexture(string fullPath)
230230
public MaterialBuilder ComposeMaterial(string mtrlPath,
231231
ParsedMaterialInfo? materialInfo = null,
232232
IStainableInstance? stainInstance = null,
233-
ParsedCharacterInfo? characterInfo = null,
234-
IColorTableSet? colorTableSet = null)
233+
ParsedCharacterInfo? characterInfo = null)
235234
{
236-
// bool canCacheBuilder = materialInfo == null
237-
// && characterInfo == null
238-
// && colorTableSet == null;
239-
// var cacheKey = $"{mtrlPath}_{materialInfo?.Shpk ?? "default"}";
240-
// if (canCacheBuilder)
241-
// {
242-
// if (stainInstance != null)
243-
// {
244-
// var stainHash = stainInstance.GetStainingHash();
245-
// var stainHashStr = System.Text.Encoding.UTF8.GetString(stainHash);
246-
// cacheKey += $"_{stainHashStr}";
247-
// }
248-
// if (mtrlBuilderCache.TryGetValue(cacheKey, out var cachedBuilder))
249-
// {
250-
// Plugin.Logger?.LogDebug("Using cached material builder for {cacheKey}", cacheKey);
251-
// return cachedBuilder;
252-
// }
253-
// }
254-
255235
var mtrlFile = GetMtrlFile(mtrlPath, out var mtrlCachePath);
256236
var shaderPackage = GetShaderPackage(mtrlFile.GetShaderPackageName());
257237
var material = new MaterialComposer(mtrlFile, mtrlPath, shaderPackage);
@@ -269,15 +249,51 @@ public MaterialBuilder ComposeMaterial(string mtrlPath,
269249
{
270250
material.SetPropertiesFromCharacterInfo(characterInfo);
271251
}
272-
273-
if (colorTableSet != null)
274-
{
275-
material.SetPropertiesFromColorTable(colorTableSet);
276-
}
277252

278253
if (materialInfo != null)
279254
{
280255
material.SetPropertiesFromMaterialInfo(materialInfo);
256+
if (materialInfo.ColorTable != null)
257+
{
258+
material.SetPropertiesFromColorTable(materialInfo.ColorTable);
259+
// since colortables are purely in-memory, they dont have a path.
260+
// going to store them in the 'ColorTables' directory in the cache with a unique name based on the hash.
261+
if (materialInfo.ColorTable is ColorTableSet colorTableSet)
262+
{
263+
var tex = colorTableSet.ColorTable.ToTexture();
264+
var colorTablePath = SaveColorTableTex(tex);
265+
material.SetProperty("ColorTable_PngCachePath", Path.GetRelativePath(cacheDir, colorTablePath));
266+
}
267+
else if (materialInfo.ColorTable is LegacyColorTableSet legacyColorTableSet)
268+
{
269+
var tex = legacyColorTableSet.ColorTable.ToTexture();
270+
var colorTablePath = SaveColorTableTex(tex);
271+
material.SetProperty("LegacyColorTable_PngCachePath", Path.GetRelativePath(cacheDir, colorTablePath));
272+
}
273+
}
274+
275+
string SaveColorTableTex(SkTexture tex)
276+
{
277+
var colorTableCacheDir = Path.Combine(cacheDir, "color_tables");
278+
Directory.CreateDirectory(colorTableCacheDir);
279+
var buf = tex.Bitmap.Bytes;
280+
var hash = System.Security.Cryptography.SHA256.HashData(buf);
281+
var hashStr = Convert.ToHexStringLower(hash);
282+
// truncate the hash to 8 characters for the filename.
283+
if (hashStr.Length > 8)
284+
{
285+
hashStr = hashStr[..8];
286+
}
287+
var mtrlPathWithoutExtension = Path.GetFileNameWithoutExtension(mtrlPath);
288+
var colorTablePath = Path.Combine(colorTableCacheDir, $"{mtrlPathWithoutExtension}_{materialInfo.Shpk}_{hashStr}.png");
289+
if (!File.Exists(colorTablePath))
290+
{
291+
using var fileStream = new FileStream(colorTablePath, FileMode.Create, FileAccess.Write);
292+
using var skiaStream = new SKManagedWStream(fileStream);
293+
tex.Bitmap.Encode(skiaStream, SKEncodedImageFormat.Png, 100);
294+
}
295+
return colorTablePath;
296+
}
281297
}
282298

283299
string materialName;
@@ -326,11 +342,7 @@ public MaterialBuilder ComposeMaterial(string mtrlPath,
326342
}
327343

328344
materialBuilder.Extras = material.ExtrasNode;
329-
330-
// if (canCacheBuilder)
331-
// {
332-
// mtrlBuilderCache.TryAdd(cacheKey, materialBuilder);
333-
// }
345+
334346
return materialBuilder;
335347
}
336348
}

0 commit comments

Comments
 (0)