diff --git a/Penumbra/Import/Models/Export/MaterialExporter.cs b/Penumbra/Import/Models/Export/MaterialExporter.cs
index 61609bb5..307e9d2b 100644
--- a/Penumbra/Import/Models/Export/MaterialExporter.cs
+++ b/Penumbra/Import/Models/Export/MaterialExporter.cs
@@ -22,6 +22,12 @@ public class MaterialExporter
// variant?
}
+ /// Dependency-less material configuration, for use when no material data can be resolved.
+ public static readonly MaterialBuilder Unknown = new MaterialBuilder("UNKNOWN")
+ .WithMetallicRoughnessShader()
+ .WithDoubleSide(true)
+ .WithBaseColor(Vector4.One);
+
/// Build a glTF material from a hydrated XIV model, with the provided name.
public static MaterialBuilder Export(Material material, string name, IoNotifier notifier)
{
diff --git a/Penumbra/Import/Models/Export/ModelExporter.cs b/Penumbra/Import/Models/Export/ModelExporter.cs
index 550aaf11..9bc33697 100644
--- a/Penumbra/Import/Models/Export/ModelExporter.cs
+++ b/Penumbra/Import/Models/Export/ModelExporter.cs
@@ -55,9 +55,14 @@ public class ModelExporter
/// Build materials for each of the material slots in the .mdl.
private static MaterialBuilder[] ConvertMaterials(MdlFile mdl, Dictionary rawMaterials, IoNotifier notifier)
=> mdl.Materials
- // TODO: material generation should be fallible, which means this lookup should be a tryget, with a fallback.
- // fallback can likely be a static on the material exporter.
- .Select(name => MaterialExporter.Export(rawMaterials[name], name, notifier.WithContext($"Material {name}")))
+ .Select(name =>
+ {
+ if (rawMaterials.TryGetValue(name, out var rawMaterial))
+ return MaterialExporter.Export(rawMaterial, name, notifier.WithContext($"Material {name}"));
+
+ notifier.Warning($"Material \"{name}\" missing, using blank fallback.");
+ return MaterialExporter.Unknown;
+ })
.ToArray();
/// Convert XIV skeleton data into a glTF-compatible node tree, with mappings.
diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs
index c41f28e5..bfd55281 100644
--- a/Penumbra/Import/Models/ModelManager.cs
+++ b/Penumbra/Import/Models/ModelManager.cs
@@ -37,7 +37,7 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
_tasks.Clear();
}
- public Task ExportToGltf(MdlFile mdl, IEnumerable sklbPaths, Func read, string outputPath)
+ public Task ExportToGltf(MdlFile mdl, IEnumerable sklbPaths, Func read, string outputPath)
=> EnqueueWithResult(
new ExportToGltfAction(this, mdl, sklbPaths, read, outputPath),
action => action.Notifier
@@ -106,7 +106,7 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
}
/// Try to resolve the absolute path to a .mtrl from the potentially-partial path provided by a model.
- private string ResolveMtrlPath(string rawPath)
+ private string? ResolveMtrlPath(string rawPath, IoNotifier notifier)
{
// TODO: this should probably be chosen in the export settings
var variantId = 1;
@@ -119,13 +119,18 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
? rawPath
: '/' + Path.GetFileName(rawPath);
- // TODO: this should be a recoverable warning
if (absolutePath == null)
- throw new Exception("Failed to resolve material path.");
+ {
+ notifier.Warning($"Material path \"{rawPath}\" could not be resolved.");
+ return null;
+ }
var info = parser.GetFileInfo(absolutePath);
if (info.FileType is not FileType.Material)
- throw new Exception($"Material path {rawPath} does not conform to material conventions.");
+ {
+ notifier.Warning($"Material path {rawPath} does not conform to material conventions.");
+ return null;
+ }
var resolvedPath = info.ObjectType switch
{
@@ -179,7 +184,7 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
ModelManager manager,
MdlFile mdl,
IEnumerable sklbPaths,
- Func read,
+ Func read,
string outputPath)
: IAction
{
@@ -193,10 +198,10 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
var xivSkeletons = BuildSkeletons(cancel);
Penumbra.Log.Debug("[GLTF Export] Reading materials...");
- var materials = mdl.Materials.ToDictionary(
- path => path,
- path => BuildMaterial(path, cancel)
- );
+ var materials = mdl.Materials
+ .Select(path => (path, material: BuildMaterial(path, Notifier, cancel)))
+ .Where(pair => pair.material != null)
+ .ToDictionary(pair => pair.path, pair => pair.material!.Value);
Penumbra.Log.Debug("[GLTF Export] Converting model...");
var model = ModelExporter.Export(mdl, xivSkeletons, materials, Notifier);
@@ -215,7 +220,9 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
private IEnumerable BuildSkeletons(CancellationToken cancel)
{
var havokTasks = sklbPaths
- .Select(path => new SklbFile(read(path)))
+ .Select(path => read(path) ?? throw new Exception(
+ $"Resolved skeleton \"{path}\" could not be read. Ensure EST metadata is configured, and/or relevant mods are enabled in the current collection."))
+ .Select(bytes => new SklbFile(bytes))
.WithIndex()
.Select(CreateHavokTask)
.ToArray();
@@ -234,10 +241,15 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
}
/// Read a .mtrl and populate its textures.
- private MaterialExporter.Material BuildMaterial(string relativePath, CancellationToken cancel)
+ private MaterialExporter.Material? BuildMaterial(string relativePath, IoNotifier notifier, CancellationToken cancel)
{
- var path = manager.ResolveMtrlPath(relativePath);
- var mtrl = new MtrlFile(read(path));
+ var path = manager.ResolveMtrlPath(relativePath, notifier);
+ if (path == null)
+ return null;
+ var bytes = read(path);
+ if (bytes == null)
+ return null;
+ var mtrl = new MtrlFile(bytes);
return new MaterialExporter.Material
{
@@ -254,12 +266,23 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
{
// Work out the texture's path - the DX11 material flag controls a file name prefix.
GamePaths.Tex.HandleDx11Path(texture, out var texturePath);
- using var textureData = new MemoryStream(read(texturePath));
+ var bytes = read(texturePath);
+ if (bytes == null)
+ return CreateDummyImage();
+
+ using var textureData = new MemoryStream(bytes);
var image = TexFileParser.Parse(textureData);
var pngImage = TextureManager.ConvertToPng(image, cancel).AsPng;
return pngImage ?? throw new Exception("Failed to convert texture to png.");
}
+ private Image CreateDummyImage()
+ {
+ var image = new Image(1, 1);
+ image[0, 0] = Color.White;
+ return image;
+ }
+
public bool Equals(IAction? other)
{
if (other is not ExportToGltfAction rhs)
diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs
index 6decd344..cb8e662f 100644
--- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs
+++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs
@@ -260,7 +260,7 @@ public partial class ModEditWindow
/// Read a file from the active collection or game.
/// Game path to the file to load.
// TODO: Also look up files within the current mod regardless of mod state?
- private byte[] ReadFile(string path)
+ private byte[]? ReadFile(string path)
{
// TODO: if cross-collection lookups are turned off, this conversion can be skipped
if (!Utf8GamePath.FromString(path, out var utf8Path, true))
@@ -269,13 +269,9 @@ public partial class ModEditWindow
var resolvedPath = _edit._activeCollections.Current.ResolvePath(utf8Path) ?? new FullPath(utf8Path);
// TODO: is it worth trying to use streams for these instead? I'll need to do this for mtrl/tex too, so might be a good idea. that said, the mtrl reader doesn't accept streams, so...
- var bytes = resolvedPath.IsRooted
+ return resolvedPath.IsRooted
? File.ReadAllBytes(resolvedPath.FullName)
: _edit._gameData.GetFile(resolvedPath.InternalName.ToString())?.Data;
-
- // TODO: some callers may not care about failures - handle exceptions separately?
- return bytes ?? throw new Exception(
- $"Resolved path {path} could not be found. If modded, is it enabled in the current collection?");
}
/// Remove the material given by the index.