diff --git a/Core.SourceGen/ContentSerializerGenerator.cs b/Core.SourceGen/ContentSerializerGenerator.cs index a63b434..bf4ed78 100644 --- a/Core.SourceGen/ContentSerializerGenerator.cs +++ b/Core.SourceGen/ContentSerializerGenerator.cs @@ -28,6 +28,7 @@ private struct XnbPropertyInfo public string TypeFullName; public bool IsNullable; public bool IsReferenceType; + public bool IsRequiredReferenceType; public int Order; public bool UseConverter; public bool Optional; @@ -106,7 +107,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) Name = member.Name, TypeFullName = underlyingPropertyType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), IsNullable = propertyNullable, - IsReferenceType = !propertyNullable && member.Type.IsReferenceType, + IsReferenceType = member.Type.IsReferenceType, + IsRequiredReferenceType = member.Type.IsReferenceType && member.NullableAnnotation == NullableAnnotation.NotAnnotated, Order = order, UseConverter = useConverter, Optional = optional, @@ -261,6 +263,16 @@ private static void EmitSerialize(CodeStringBuilder cb, XnbTypeInfo model) { cb.AppendLine($"var content = ({model.TypeFullName})data;"); + foreach (var prop in model.Properties) + { + EmitRequiredPropertyValidation(cb, model, prop); + } + + if (model.Properties.Any(prop => !prop.Optional && prop.IsRequiredReferenceType)) + { + cb.AppendLine(); + } + foreach (var prop in model.Properties) { EmitPropertySerialize(cb, prop); @@ -269,6 +281,20 @@ private static void EmitSerialize(CodeStringBuilder cb, XnbTypeInfo model) cb.EndCodeBlock(); } + private static void EmitRequiredPropertyValidation(CodeStringBuilder cb, XnbTypeInfo model, XnbPropertyInfo prop) + { + if (prop is { Optional: false, IsRequiredReferenceType: true }) + { + cb.AppendLine($"if (content.{prop.Name} is null)"); + cb.BeginCodeBlock(); + { + cb.AppendLine( + $"throw new global::System.InvalidOperationException(\"Cannot serialize required XNB property {model.TypeName}.{prop.Name} because it is null.\");"); + } + cb.EndCodeBlock(); + } + } + private static void EmitPropertySerialize(CodeStringBuilder cb, XnbPropertyInfo prop) { var valueExpression = $"content.{prop.Name}"; diff --git a/Core/Definitions/Game/ArtObject/ArtObject.cs b/Core/Definitions/Game/ArtObject/ArtObject.cs index d9b78d2..ed364c4 100644 --- a/Core/Definitions/Game/ArtObject/ArtObject.cs +++ b/Core/Definitions/Game/ArtObject/ArtObject.cs @@ -16,14 +16,14 @@ public class ArtObject [JsonIgnore] [XnbProperty(UseConverter = true)] - public Texture2D Cubemap { get; set; } = new(); + public Texture2D? Cubemap { get; set; } [XnbProperty] public Vector3 Size { get; set; } [JsonIgnore] [XnbProperty(UseConverter = true)] - public IndexedPrimitives Geometry { get; set; } = new(); + public IndexedPrimitives? Geometry { get; set; } [XnbProperty(UseConverter = true)] public ActorType ActorType { get; set; } diff --git a/Core/Definitions/Game/Graphics/AnimatedTexture.cs b/Core/Definitions/Game/Graphics/AnimatedTexture.cs index 0f2a5fb..1df333d 100644 --- a/Core/Definitions/Game/Graphics/AnimatedTexture.cs +++ b/Core/Definitions/Game/Graphics/AnimatedTexture.cs @@ -27,6 +27,6 @@ public class AnimatedTexture public byte[] TextureData { get; set; } = { }; [XnbProperty(UseConverter = true)] - public List Frames { get; set; } = new(); + public List? Frames { get; set; } = new(); } } diff --git a/Core/Definitions/Game/Graphics/IndexedPrimitives.cs b/Core/Definitions/Game/Graphics/IndexedPrimitives.cs index 2b7ca53..58cb255 100644 --- a/Core/Definitions/Game/Graphics/IndexedPrimitives.cs +++ b/Core/Definitions/Game/Graphics/IndexedPrimitives.cs @@ -11,15 +11,9 @@ public class IndexedPrimitives public PrimitiveType PrimitiveType { get; set; } [XnbProperty(UseConverter = true)] - public TemplateType[] Vertices { get; set; } + public TemplateType[]? Vertices { get; set; } [XnbProperty(UseConverter = true)] - public ushort[] Indices { get; set; } - - public IndexedPrimitives() - { - Vertices = new TemplateType[0]; - Indices = new ushort[0]; - } + public ushort[]? Indices { get; set; } } } diff --git a/Core/Definitions/Game/Level/AmbienceTrack.cs b/Core/Definitions/Game/Level/AmbienceTrack.cs index 0995ceb..6812e8c 100644 --- a/Core/Definitions/Game/Level/AmbienceTrack.cs +++ b/Core/Definitions/Game/Level/AmbienceTrack.cs @@ -5,7 +5,7 @@ public class AmbienceTrack { [XnbProperty(UseConverter = true)] - public string Name { get; set; } = ""; + public string? Name { get; set; } [XnbProperty] public bool Day { get; set; } diff --git a/Core/Definitions/Game/Level/ArtObjectActorSettings.cs b/Core/Definitions/Game/Level/ArtObjectActorSettings.cs index f1dc68b..1bd0453 100644 --- a/Core/Definitions/Game/Level/ArtObjectActorSettings.cs +++ b/Core/Definitions/Game/Level/ArtObjectActorSettings.cs @@ -50,7 +50,7 @@ public class ArtObjectActorSettings public string? TreasureMapName { get; set; } [XnbProperty(UseConverter = true)] - public FaceOrientation[] InvisibleSides { get; set; } = { }; + public FaceOrientation[]? InvisibleSides { get; set; } = { }; [XnbProperty] public float TimeswitchWindBackSpeed { get; set; } diff --git a/Core/Definitions/Game/Level/ArtObjectInstance.cs b/Core/Definitions/Game/Level/ArtObjectInstance.cs index 7734533..9b0c0f7 100644 --- a/Core/Definitions/Game/Level/ArtObjectInstance.cs +++ b/Core/Definitions/Game/Level/ArtObjectInstance.cs @@ -13,12 +13,12 @@ public class ArtObjectInstance public Vector3 Position { get; set; } [XnbProperty] - public Quaternion Rotation { get; set; } + public Quaternion Rotation { get; set; } = Quaternion.Identity; [XnbProperty] - public Vector3 Scale { get; set; } + public Vector3 Scale { get; set; } = Vector3.One; [XnbProperty(UseConverter = true)] - public ArtObjectActorSettings? ActorSettings { get; set; } + public ArtObjectActorSettings? ActorSettings { get; set; } = new(); } } diff --git a/Core/Definitions/Game/Level/DotDialogueLine.cs b/Core/Definitions/Game/Level/DotDialogueLine.cs index 34a0812..be6b782 100644 --- a/Core/Definitions/Game/Level/DotDialogueLine.cs +++ b/Core/Definitions/Game/Level/DotDialogueLine.cs @@ -5,7 +5,7 @@ public class DotDialogueLine { [XnbProperty(UseConverter = true)] - public string ResourceText { get; set; } = ""; + public string? ResourceText { get; set; } [XnbProperty] public bool Grouped { get; set; } diff --git a/Core/Definitions/Game/Level/Level.cs b/Core/Definitions/Game/Level/Level.cs index 2ba7048..1d5d4cb 100644 --- a/Core/Definitions/Game/Level/Level.cs +++ b/Core/Definitions/Game/Level/Level.cs @@ -10,7 +10,7 @@ namespace FEZRepacker.Core.Definitions.Game.Level public class Level { [XnbProperty(UseConverter = true)] - public string Name { get; set; } = ""; + public string? Name { get; set; } [XnbProperty] public Vector3 Size { get; set; } @@ -37,7 +37,7 @@ public class Level public string? GomezHaloName { get; set; } [XnbProperty] - public bool HaloFiltering { get; set; } + public bool HaloFiltering { get; set; } = true; [XnbProperty] public bool BlinkingAlpha { get; set; } @@ -55,13 +55,13 @@ public class Level public string SkyName { get; set; } = ""; [XnbProperty(UseConverter = true)] - public string TrileSetName { get; set; } = ""; + public string? TrileSetName { get; set; } [XnbProperty(UseConverter = true)] - public IDictionary Volumes { get; set; } = new OrderedDictionary(); + public IDictionary? Volumes { get; set; } = new OrderedDictionary(); [XnbProperty(UseConverter = true)] - public IDictionary Scripts { get; set; } = new OrderedDictionary(); + public IDictionary? Scripts { get; set; } = new OrderedDictionary(); [XnbProperty(UseConverter = true)] public string? SongName { get; set; } @@ -73,22 +73,22 @@ public class Level public int FAPFadeOutLength { get; set; } [XnbProperty(UseConverter = true)] - public IDictionary Triles { get; set; } = new OrderedDictionary(); + public IDictionary? Triles { get; set; } = new OrderedDictionary(); [XnbProperty(UseConverter = true)] - public IDictionary ArtObjects { get; set; } = new OrderedDictionary(); + public IDictionary? ArtObjects { get; set; } = new OrderedDictionary(); [XnbProperty(UseConverter = true)] - public IDictionary BackgroundPlanes { get; set; } = new OrderedDictionary(); + public IDictionary? BackgroundPlanes { get; set; } = new OrderedDictionary(); [XnbProperty(UseConverter = true)] - public IDictionary Groups { get; set; } = new OrderedDictionary(); + public IDictionary? Groups { get; set; } = new OrderedDictionary(); [XnbProperty(UseConverter = true)] - public IDictionary NonPlayerCharacters { get; set; } = new OrderedDictionary(); + public IDictionary? NonPlayerCharacters { get; set; } = new OrderedDictionary(); [XnbProperty(UseConverter = true)] - public IDictionary Paths { get; set; } = new OrderedDictionary(); + public IDictionary? Paths { get; set; } = new OrderedDictionary(); [XnbProperty] public bool Descending { get; set; } @@ -100,10 +100,10 @@ public class Level public bool LowPass { get; set; } [XnbProperty(UseConverter = true)] - public List MutedLoops { get; set; } = new(); + public List? MutedLoops { get; set; } = new(); [XnbProperty(UseConverter = true)] - public List AmbienceTracks { get; set; } = new(); + public List? AmbienceTracks { get; set; } = new(); [XnbProperty(UseConverter = true)] public LevelNodeType NodeType { get; set; } diff --git a/Core/Definitions/Game/Level/MovementPath.cs b/Core/Definitions/Game/Level/MovementPath.cs index fdd4388..9cd0e06 100644 --- a/Core/Definitions/Game/Level/MovementPath.cs +++ b/Core/Definitions/Game/Level/MovementPath.cs @@ -5,7 +5,7 @@ public class MovementPath { [XnbProperty(UseConverter = true)] - public List Segments { get; set; } = new(); + public List? Segments { get; set; } = new(); [XnbProperty] public bool NeedsTrigger { get; set; } @@ -25,4 +25,4 @@ public class MovementPath [XnbProperty] public bool SaveTrigger { get; set; } } -} \ No newline at end of file +} diff --git a/Core/Definitions/Game/Level/NpcActionContent.cs b/Core/Definitions/Game/Level/NpcActionContent.cs index 37e4005..4673eff 100644 --- a/Core/Definitions/Game/Level/NpcActionContent.cs +++ b/Core/Definitions/Game/Level/NpcActionContent.cs @@ -5,9 +5,9 @@ public class NpcActionContent { [XnbProperty(UseConverter = true)] - public string AnimationName { get; set; } = ""; + public string? AnimationName { get; set; } [XnbProperty(UseConverter = true)] - public string SoundName { get; set; } = ""; + public string? SoundName { get; set; } } } diff --git a/Core/Definitions/Game/Level/NpcInstance.cs b/Core/Definitions/Game/Level/NpcInstance.cs index 0202b60..82b21ac 100644 --- a/Core/Definitions/Game/Level/NpcInstance.cs +++ b/Core/Definitions/Game/Level/NpcInstance.cs @@ -17,7 +17,7 @@ public class NpcInstance public Vector3 DestinationOffset { get; set; } [XnbProperty] - public float WalkSpeed { get; set; } + public float WalkSpeed { get; set; } = 1.5f; [XnbProperty] public bool RandomizeSpeech { get; set; } @@ -32,9 +32,9 @@ public class NpcInstance public ActorType ActorType { get; set; } [XnbProperty(UseConverter = true)] - public List Speech { get; set; } = null!; + public List? Speech { get; set; } = new(); [XnbProperty(UseConverter = true)] - public IDictionary Actions { get; set; } = null!; + public IDictionary? Actions { get; set; } = new FEZRepacker.Core.Helpers.OrderedDictionary(); } -} \ No newline at end of file +} diff --git a/Core/Definitions/Game/Level/Scripting/Script.cs b/Core/Definitions/Game/Level/Scripting/Script.cs index bc44d50..7f2afc0 100644 --- a/Core/Definitions/Game/Level/Scripting/Script.cs +++ b/Core/Definitions/Game/Level/Scripting/Script.cs @@ -11,13 +11,13 @@ public class Script public TimeSpan? Timeout { get; set; } [XnbProperty(UseConverter = true)] - public List? Triggers { get; set; } + public List? Triggers { get; set; } = new(); [XnbProperty(UseConverter = true)] public List? Conditions { get; set; } [XnbProperty(UseConverter = true)] - public List? Actions { get; set; } + public List? Actions { get; set; } = new(); [XnbProperty] public bool OneTime { get; set; } diff --git a/Core/Definitions/Game/Level/Scripting/ScriptAction.cs b/Core/Definitions/Game/Level/Scripting/ScriptAction.cs index aa0b064..dca7f9c 100644 --- a/Core/Definitions/Game/Level/Scripting/ScriptAction.cs +++ b/Core/Definitions/Game/Level/Scripting/ScriptAction.cs @@ -5,13 +5,13 @@ public class ScriptAction { [XnbProperty(UseConverter = true)] - public Entity Object { get; set; } = new(); + public Entity? Object { get; set; } = new(); [XnbProperty] public string Operation { get; set; } = ""; [XnbProperty(UseConverter = true)] - public string[] Arguments { get; set; } = { }; + public string[]? Arguments { get; set; } = { }; [XnbProperty] public bool Killswitch { get; set; } // whether action should kill this script diff --git a/Core/Definitions/Game/Level/Scripting/ScriptCondition.cs b/Core/Definitions/Game/Level/Scripting/ScriptCondition.cs index b138e36..336c7fe 100644 --- a/Core/Definitions/Game/Level/Scripting/ScriptCondition.cs +++ b/Core/Definitions/Game/Level/Scripting/ScriptCondition.cs @@ -5,7 +5,7 @@ public class ScriptCondition { [XnbProperty(UseConverter = true)] - public Entity Object { get; set; } = new(); + public Entity? Object { get; set; } = new(); [XnbProperty(UseConverter = true)] public ComparisonOperator Operator { get; set; } = ComparisonOperator.None; diff --git a/Core/Definitions/Game/Level/Scripting/ScriptTrigger.cs b/Core/Definitions/Game/Level/Scripting/ScriptTrigger.cs index 3cfd45d..39786d5 100644 --- a/Core/Definitions/Game/Level/Scripting/ScriptTrigger.cs +++ b/Core/Definitions/Game/Level/Scripting/ScriptTrigger.cs @@ -5,7 +5,7 @@ public class ScriptTrigger { [XnbProperty(UseConverter = true)] - public Entity Object { get; set; } = new(); + public Entity? Object { get; set; } = new(); [XnbProperty] public string Event { get; set; } = ""; diff --git a/Core/Definitions/Game/Level/SpeechLine.cs b/Core/Definitions/Game/Level/SpeechLine.cs index 5fe6013..db03e86 100644 --- a/Core/Definitions/Game/Level/SpeechLine.cs +++ b/Core/Definitions/Game/Level/SpeechLine.cs @@ -5,9 +5,9 @@ public class SpeechLine { [XnbProperty(UseConverter = true)] - public string Text { get; set; } = ""; + public string? Text { get; set; } [XnbProperty(UseConverter = true)] public NpcActionContent? OverrideContent { get; set; } } -} \ No newline at end of file +} diff --git a/Core/Definitions/Game/Level/TrileGroup.cs b/Core/Definitions/Game/Level/TrileGroup.cs index fa7d4d6..b5e520a 100644 --- a/Core/Definitions/Game/Level/TrileGroup.cs +++ b/Core/Definitions/Game/Level/TrileGroup.cs @@ -8,7 +8,7 @@ namespace FEZRepacker.Core.Definitions.Game.Level public class TrileGroup { [XnbProperty(UseConverter = true)] - public List Triles { get; set; } = new(); + public List? Triles { get; set; } = new(); [XnbProperty(UseConverter = true)] public MovementPath? Path { get; set; } = null; @@ -55,4 +55,4 @@ public class TrileGroup [XnbProperty(UseConverter = true)] public string? AssociatedSound { get; set; } } -} \ No newline at end of file +} diff --git a/Core/Definitions/Game/Level/Volume.cs b/Core/Definitions/Game/Level/Volume.cs index 6f0e1b6..89b0978 100644 --- a/Core/Definitions/Game/Level/Volume.cs +++ b/Core/Definitions/Game/Level/Volume.cs @@ -8,7 +8,7 @@ namespace FEZRepacker.Core.Definitions.Game.Level public class Volume { [XnbProperty(UseConverter = true)] - public FaceOrientation[] Orientations { get; set; } = { }; + public FaceOrientation[]? Orientations { get; set; } = { }; [XnbProperty] public Vector3 From { get; set; } diff --git a/Core/Definitions/Game/Level/VolumeActorSettings.cs b/Core/Definitions/Game/Level/VolumeActorSettings.cs index 8b434af..267d769 100644 --- a/Core/Definitions/Game/Level/VolumeActorSettings.cs +++ b/Core/Definitions/Game/Level/VolumeActorSettings.cs @@ -13,7 +13,7 @@ public class VolumeActorSettings public bool IsPointOfInterest { get; set; } [XnbProperty(UseConverter = true)] - public List DotDialogue { get; set; } = new(); + public List? DotDialogue { get; set; } = new(); [XnbProperty] public bool WaterLocked { get; set; } diff --git a/Core/Definitions/Game/MapTree/MapNode.cs b/Core/Definitions/Game/MapTree/MapNode.cs index 0891238..04e5c62 100644 --- a/Core/Definitions/Game/MapTree/MapNode.cs +++ b/Core/Definitions/Game/MapTree/MapNode.cs @@ -10,13 +10,13 @@ public class MapNode public string LevelName { get; set; } = ""; [XnbProperty(UseConverter = true)] - public List Connections { get; set; } = new(); + public List? Connections { get; set; } = new(); [XnbProperty(UseConverter = true)] public LevelNodeType NodeType { get; set; } [XnbProperty(UseConverter = true)] - public WinConditions Conditions { get; set; } = new(); + public WinConditions? Conditions { get; set; } = new(); [XnbProperty] public bool HasLesserGate { get; set; } diff --git a/Core/Definitions/Game/MapTree/MapNodeConnection.cs b/Core/Definitions/Game/MapTree/MapNodeConnection.cs index 52aa3f3..4b184cc 100644 --- a/Core/Definitions/Game/MapTree/MapNodeConnection.cs +++ b/Core/Definitions/Game/MapTree/MapNodeConnection.cs @@ -10,7 +10,7 @@ public class MapNodeConnection public FaceOrientation Face { get; set; } [XnbProperty(UseConverter = true)] - public MapNode Node { get; set; } = new(); + public MapNode? Node { get; set; } = new(); [XnbProperty] public float BranchOversize { get;set; } diff --git a/Core/Definitions/Game/MapTree/MapTree.cs b/Core/Definitions/Game/MapTree/MapTree.cs index da60a45..16909d4 100644 --- a/Core/Definitions/Game/MapTree/MapTree.cs +++ b/Core/Definitions/Game/MapTree/MapTree.cs @@ -5,6 +5,6 @@ public class MapTree { [XnbProperty(UseConverter = true)] - public MapNode Root { get; set; } = new(); + public MapNode? Root { get; set; } } } diff --git a/Core/Definitions/Game/MapTree/WinConditions.cs b/Core/Definitions/Game/MapTree/WinConditions.cs index d6bc8f8..002ba01 100644 --- a/Core/Definitions/Game/MapTree/WinConditions.cs +++ b/Core/Definitions/Game/MapTree/WinConditions.cs @@ -14,7 +14,7 @@ public class WinConditions public int UnlockedDoorCount { get; set; } [XnbProperty(UseConverter = true)] - public List ScriptIds { get; set; } = new(); + public List? ScriptIds { get; set; } = new(); [XnbProperty] public int CubeShardCount { get; set; } diff --git a/Core/Definitions/Game/NpcMetadata/NpcMetadata.cs b/Core/Definitions/Game/NpcMetadata/NpcMetadata.cs index 3826a03..387c852 100644 --- a/Core/Definitions/Game/NpcMetadata/NpcMetadata.cs +++ b/Core/Definitions/Game/NpcMetadata/NpcMetadata.cs @@ -7,7 +7,7 @@ namespace FEZRepacker.Core.Definitions.Game.NpcMetadata public class NpcMetadata { [XnbProperty] - public float WalkSpeed { get; set; } + public float WalkSpeed { get; set; } = 1.5f; [XnbProperty] public bool AvoidsGomez { get;set; } @@ -16,6 +16,6 @@ public class NpcMetadata public string? SoundPath { get; set; } [XnbProperty(UseConverter = true)] - public List SoundActions { get; set; } = new(); + public List? SoundActions { get; set; } = new(); } } diff --git a/Core/Definitions/Game/Sky/Sky.cs b/Core/Definitions/Game/Sky/Sky.cs index 74cc505..ba2e238 100644 --- a/Core/Definitions/Game/Sky/Sky.cs +++ b/Core/Definitions/Game/Sky/Sky.cs @@ -5,25 +5,25 @@ public class Sky { [XnbProperty] - public string Name { get; set; } = ""; + public string Name { get; set; } = "Default"; [XnbProperty] - public string Background { get; set; } = ""; + public string Background { get; set; } = "SkyBack"; [XnbProperty] - public float WindSpeed { get; set; } + public float WindSpeed { get; set; } = 1f; [XnbProperty] - public float Density { get; set; } + public float Density { get; set; } = 1f; [XnbProperty] - public float FogDensity { get; set; } + public float FogDensity { get; set; } = 0.02f; [XnbProperty(UseConverter = true)] - public List Layers { get; set; } = new(); + public List? Layers { get; set; } = new(); [XnbProperty(UseConverter = true)] - public List Clouds { get; set; } = new(); + public List? Clouds { get; set; } = new(); [XnbProperty(UseConverter = true)] public string? Shadows { get; set; } @@ -41,7 +41,7 @@ public class Sky public bool HorizontalScrolling { get; set; } [XnbProperty] - public float LayerBaseHeight { get; set; } + public float LayerBaseHeight { get; set; } = 0.5f; [XnbProperty] public float InterLayerVerticalDistance { get; set; } @@ -65,10 +65,10 @@ public class Sky public float WindDistance { get; set; } [XnbProperty] - public float CloudsParallax { get; set; } + public float CloudsParallax { get; set; } = 1f; [XnbProperty] - public float ShadowOpacity { get; set; } + public float ShadowOpacity { get; set; } = 0.7f; [XnbProperty] public bool FoliageShadows { get; set; } diff --git a/Core/Definitions/Game/Sky/SkyLayer.cs b/Core/Definitions/Game/Sky/SkyLayer.cs index f6b7e85..aef13f2 100644 --- a/Core/Definitions/Game/Sky/SkyLayer.cs +++ b/Core/Definitions/Game/Sky/SkyLayer.cs @@ -11,7 +11,7 @@ public class SkyLayer public bool InFront { get; set; } [XnbProperty] - public float Opacity { get; set; } + public float Opacity { get; set; } = 1f; [XnbProperty] public float FogTint { get; set; } diff --git a/Core/Definitions/Game/TrackedSong/Loop.cs b/Core/Definitions/Game/TrackedSong/Loop.cs index 8f13b21..2b1023d 100644 --- a/Core/Definitions/Game/TrackedSong/Loop.cs +++ b/Core/Definitions/Game/TrackedSong/Loop.cs @@ -5,13 +5,13 @@ public class Loop { [XnbProperty] - public int Duration { get; set; } + public int Duration { get; set; } = 1; [XnbProperty] - public int LoopTimesFrom { get; set; } + public int LoopTimesFrom { get; set; } = 1; [XnbProperty] - public int LoopTimesTo { get; set; } + public int LoopTimesTo { get; set; } = 1; [XnbProperty] public string Name { get; set; } = ""; @@ -26,16 +26,16 @@ public class Loop public int Delay { get; set; } [XnbProperty] - public bool Night { get; set; } + public bool Night { get; set; } = true; [XnbProperty] - public bool Day { get; set; } + public bool Day { get; set; } = true; [XnbProperty] - public bool Dusk { get; set; } + public bool Dusk { get; set; } = true; [XnbProperty] - public bool Dawn { get; set; } + public bool Dawn { get; set; } = true; [XnbProperty] public bool FractionalTime { get; set; } diff --git a/Core/Definitions/Game/TrackedSong/TrackedSong.cs b/Core/Definitions/Game/TrackedSong/TrackedSong.cs index 5e1f50a..cdd9cdd 100644 --- a/Core/Definitions/Game/TrackedSong/TrackedSong.cs +++ b/Core/Definitions/Game/TrackedSong/TrackedSong.cs @@ -5,19 +5,29 @@ public class TrackedSong { [XnbProperty(UseConverter = true)] - public List Loops { get; set; } = new(); + public List? Loops { get; set; } = new(); [XnbProperty] - public string Name { get; set; } = ""; + public string Name { get; set; } = "Untitled"; [XnbProperty] - public int Tempo { get; set; } + public int Tempo { get; set; } = 60; [XnbProperty] - public int TimeSignature { get; set; } + public int TimeSignature { get; set; } = 4; [XnbProperty(UseConverter = true)] - public ShardNotes[] Notes { get; set; } = { }; + public ShardNotes[]? Notes { get; set; } = new[] + { + ShardNotes.C2, + ShardNotes.D2, + ShardNotes.E2, + ShardNotes.F2, + ShardNotes.G2, + ShardNotes.A2, + ShardNotes.B2, + ShardNotes.C3 + }; [XnbProperty(UseConverter = true)] public AssembleChords AssembleChord { get; set; } diff --git a/Core/Definitions/Game/TrileSet/Trile.cs b/Core/Definitions/Game/TrileSet/Trile.cs index 8765d37..914b10f 100644 --- a/Core/Definitions/Game/TrileSet/Trile.cs +++ b/Core/Definitions/Game/TrileSet/Trile.cs @@ -4,6 +4,7 @@ using FEZRepacker.Core.Definitions.Game.Common; using FEZRepacker.Core.Definitions.Game.Graphics; using FEZRepacker.Core.Definitions.Game.XNA; +using FEZRepacker.Core.Helpers; namespace FEZRepacker.Core.Definitions.Game.TrileSet { @@ -12,13 +13,13 @@ namespace FEZRepacker.Core.Definitions.Game.TrileSet public class Trile { [XnbProperty] - public string Name { get; set; } = ""; + public string Name { get; set; } = "Untitled"; [XnbProperty] public string CubemapPath { get; set; } = ""; [XnbProperty] - public Vector3 Size { get; set; } + public Vector3 Size { get; set; } = Vector3.One; [XnbProperty] public Vector3 Offset { get; set; } @@ -36,11 +37,11 @@ public class Trile public bool ForceHugging { get; set; } [XnbProperty(UseConverter = true)] - public IDictionary Faces { get; set; } = null!; + public IDictionary? Faces { get; set; } = new OrderedDictionary(); [JsonIgnore] [XnbProperty(UseConverter = true)] - public IndexedPrimitives Geometry { get; set; } = null!; + public IndexedPrimitives? Geometry { get; set; } [XnbProperty(UseConverter = true)] public ActorType Type { get; set; } diff --git a/Core/Definitions/Game/TrileSet/TrileSet.cs b/Core/Definitions/Game/TrileSet/TrileSet.cs index 0673a8f..83108e8 100644 --- a/Core/Definitions/Game/TrileSet/TrileSet.cs +++ b/Core/Definitions/Game/TrileSet/TrileSet.cs @@ -13,10 +13,10 @@ public class TrileSet public string Name { get; set; } = ""; [XnbProperty(UseConverter = true)] - public IDictionary Triles { get; set; } = new OrderedDictionary(); + public IDictionary? Triles { get; set; } = new OrderedDictionary(); [JsonIgnore] [XnbProperty(UseConverter = true)] - public Texture2D TextureAtlas { get; set; } = new(); + public Texture2D? TextureAtlas { get; set; } } } diff --git a/Core/Definitions/Json/MapTreeJsonModel.cs b/Core/Definitions/Json/MapTreeJsonModel.cs index 8d05763..1dc786a 100644 --- a/Core/Definitions/Json/MapTreeJsonModel.cs +++ b/Core/Definitions/Json/MapTreeJsonModel.cs @@ -7,7 +7,10 @@ public class MapTreeJsonModel : Dictionary, JsonModel() { diff --git a/Tests/Dependencies/FezEngine.dll b/Tests/Dependencies/FezEngine.dll new file mode 100644 index 0000000..333432a Binary files /dev/null and b/Tests/Dependencies/FezEngine.dll differ diff --git a/Tests/Dependencies/README.md b/Tests/Dependencies/README.md new file mode 100644 index 0000000..cd1613f --- /dev/null +++ b/Tests/Dependencies/README.md @@ -0,0 +1,5 @@ +This directory contains the stripped binary of `FezEngine.dll`, generated by [FEZStripGen](https://github.com/FEZModding/FEZStripGen/) with `--include-readers` flag. + +# DO NOT PUSH THE ORIGINAL VERSION OF THE STRIPPED BINARIES + +However, you're more than free to replace them **locally**. \ No newline at end of file diff --git a/Tests/FEZRepacker.Tests.csproj b/Tests/FEZRepacker.Tests.csproj index f47bf3e..8c2777f 100644 --- a/Tests/FEZRepacker.Tests.csproj +++ b/Tests/FEZRepacker.Tests.csproj @@ -14,6 +14,7 @@ + @@ -21,4 +22,8 @@ + + + + diff --git a/Tests/TestPacking.cs b/Tests/TestPacking.cs index af3a88a..b08153d 100644 --- a/Tests/TestPacking.cs +++ b/Tests/TestPacking.cs @@ -3,13 +3,21 @@ namespace FEZRepacker.Tests { + // Verifies package data is byte-identical after unpacking and repacking [TestClass] public class TestPacking { + // Rebuilds each configured FEZ package and compares its complete payload [TestMethod] [DynamicData(nameof(TestUtils.PackagePathsTestData), typeof(TestUtils))] - public void RepackAndComparePackage(string packagePath) + public void RepackAndComparePackage(string? packagePath) { + if (packagePath == null) + { + Assert.Inconclusive("Set FEZContentDirPath in a runsettings file to run package integration tests."); + return; + } + var pakData = File.ReadAllBytes(packagePath); using var pakStream = new MemoryStream(pakData); @@ -29,4 +37,4 @@ public void RepackAndComparePackage(string packagePath) Assert.IsTrue(repackData.SequenceEqual(pakData)); } } -} \ No newline at end of file +} diff --git a/Tests/TestUtils.cs b/Tests/TestUtils.cs index 4631472..03a585b 100644 --- a/Tests/TestUtils.cs +++ b/Tests/TestUtils.cs @@ -2,32 +2,47 @@ namespace FEZRepacker.Tests { + // Supplies optional FEZ asset paths to package integration tests [TestClass] public class TestUtils { - public static TestContext Context; + private static TestContext Context { get; set; } = null!; + // Captures the MSTest context without requiring optional integration-test data [AssemblyInitialize] public static void SetupTestContext(TestContext testContext) { Context = testContext; - Assert.IsTrue( - Directory.Exists(GetGameAssetsDirectory()), - "You forgot to put FEZ's Content path into .runsettings file" - ); } - public static IEnumerable PackagePathsTestData => - GetPathsToPackages().Select(name => new object[] { name }); + public static IEnumerable PackagePathsTestData + { + get + { + var assetsDirectory = GetGameAssetsDirectory(); + if (assetsDirectory == null) + { + yield return new object?[] { null }; + yield break; + } + + foreach (var packagePath in GetPathsToPackages(assetsDirectory)) + { + yield return new object?[] { packagePath }; + } + } + } - public static string GetGameAssetsDirectory() + private static string? GetGameAssetsDirectory() { - return Context.Properties["FEZContentDirPath"].ToString(); + var configuredPath = Context.Properties["FEZContentDirPath"]?.ToString(); + return string.IsNullOrWhiteSpace(configuredPath) ? null : + Directory.Exists(configuredPath) ? configuredPath : null; } - public static IEnumerable GetPathsToPackages() + private static IEnumerable GetPathsToPackages(string assetsDirectory) { - return Directory.EnumerateFiles(GetGameAssetsDirectory(), "*.pak", SearchOption.AllDirectories); + return Directory.EnumerateFiles(assetsDirectory, "*.pak", SearchOption.AllDirectories); } } } diff --git a/Tests/TypeContractDefaultsTests.cs b/Tests/TypeContractDefaultsTests.cs new file mode 100644 index 0000000..2e357c2 --- /dev/null +++ b/Tests/TypeContractDefaultsTests.cs @@ -0,0 +1,124 @@ +using FEZRepacker.Core.Definitions.Game.Level; +using FEZRepacker.Core.Definitions.Game.NpcMetadata; +using FEZRepacker.Core.Definitions.Game.Sky; +using FEZRepacker.Core.Definitions.Game.TrackedSong; +using FEZRepacker.Core.Definitions.Game.TrileSet; +using FEZRepacker.Core.Definitions.Game.XNA; +using FEZRepacker.Core.Definitions.Json; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace FEZRepacker.Tests +{ + // Verifies newly constructed repacker models use the same defaults as FEZ + [TestClass] + public class TypeContractDefaultsTests + { + // Constructs a level and checks the FEZ filtering default + [TestMethod] + public void LevelUsesFezDefaults() + { + var level = new Level(); + + Assert.IsTrue(level.HaloFiltering); + } + + // Constructs an art-object instance and checks its transform and settings defaults + [TestMethod] + public void ArtObjectInstanceUsesFezDefaults() + { + var instance = new ArtObjectInstance(); + + Assert.AreEqual(Quaternion.Identity, instance.Rotation); + Assert.AreEqual(Vector3.One, instance.Scale); + Assert.IsNotNull(instance.ActorSettings); + } + + // Constructs NPC models and checks their movement and collection defaults + [TestMethod] + public void NpcModelsUseFezDefaults() + { + var instance = new NpcInstance(); + var metadata = new NpcMetadata(); + + Assert.AreEqual(1.5f, instance.WalkSpeed); + Assert.IsNotNull(instance.Speech); + Assert.AreEqual(0, instance.Speech.Count); + Assert.IsNotNull(instance.Actions); + Assert.AreEqual(0, instance.Actions.Count); + Assert.AreEqual(1.5f, metadata.WalkSpeed); + Assert.IsNotNull(metadata.SoundActions); + Assert.AreEqual(0, metadata.SoundActions.Count); + } + + // Constructs song models and checks FEZ timing, note, and loop defaults + [TestMethod] + public void TrackedSongModelsUseFezDefaults() + { + var song = new TrackedSong(); + var loop = new Loop(); + var expectedNotes = new[] + { + ShardNotes.C2, ShardNotes.D2, ShardNotes.E2, ShardNotes.F2, ShardNotes.G2, ShardNotes.A2, + ShardNotes.B2, ShardNotes.C3 + }; + + Assert.AreEqual("Untitled", song.Name); + Assert.AreEqual(60, song.Tempo); + Assert.AreEqual(4, song.TimeSignature); + CollectionAssert.AreEqual(expectedNotes, song.Notes); + Assert.IsNotNull(song.Loops); + Assert.AreEqual(0, song.Loops.Count); + Assert.AreEqual(1, loop.Duration); + Assert.AreEqual(1, loop.LoopTimesFrom); + Assert.AreEqual(1, loop.LoopTimesTo); + Assert.IsTrue(loop.Day); + Assert.IsTrue(loop.Night); + Assert.IsTrue(loop.Dawn); + Assert.IsTrue(loop.Dusk); + } + + // Constructs sky models and checks their rendering defaults + [TestMethod] + public void SkyModelsUseFezDefaults() + { + var sky = new Sky(); + var layer = new SkyLayer(); + + Assert.AreEqual("Default", sky.Name); + Assert.AreEqual("SkyBack", sky.Background); + Assert.AreEqual(1f, sky.WindSpeed); + Assert.AreEqual(1f, sky.Density); + Assert.AreEqual(0.02f, sky.FogDensity); + Assert.AreEqual(0.5f, sky.LayerBaseHeight); + Assert.AreEqual(1f, sky.CloudsParallax); + Assert.AreEqual(0.7f, sky.ShadowOpacity); + Assert.IsNotNull(sky.Layers); + Assert.IsNotNull(sky.Clouds); + Assert.AreEqual(1f, layer.Opacity); + } + + // Constructs a trile and checks its resource, size, and face defaults + [TestMethod] + public void TrileUsesFezDefaults() + { + var trile = new Trile(); + + Assert.AreEqual("Untitled", trile.Name); + Assert.AreEqual(Vector3.One, trile.Size); + Assert.IsNotNull(trile.Faces); + Assert.AreEqual(0, trile.Faces.Count); + Assert.IsNull(trile.Geometry); + } + + // Deconverts a map model and verifies the converter creates its required root + [TestMethod] + public void MapTreeJsonModelCreatesRoot() + { + var model = new MapTreeJsonModel { [0] = new MapNodeJsonModel() }; + var mapTree = model.Deserialize(); + + Assert.IsNotNull(mapTree.Root); + } + } +} \ No newline at end of file diff --git a/Tests/TypeContractReaderTests.cs b/Tests/TypeContractReaderTests.cs new file mode 100644 index 0000000..0ff60f5 --- /dev/null +++ b/Tests/TypeContractReaderTests.cs @@ -0,0 +1,214 @@ +using System.Reflection; + +using FEZRepacker.Core.Definitions.Game.Level; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace FEZRepacker.Tests +{ + // Verifies model nullability against direct assignments in preserved FEZ reader IL + [TestClass] + public class TypeContractReaderTests + { + private const string FixturePath = "Dependencies/FezEngine.dll"; + + private const string ReadObjectMethodName = "ReadObject"; + + private const string ReadStringMethodName = "ReadString"; + + private const string FezReaderNamespacePrefix = "FezEngine.Readers."; + + private const string ReaderMethodName = "Read"; + + private const string XnbPropertyAttributeName = "FEZRepacker.Core.Definitions.Game.XnbPropertyAttribute"; + + private const string XnbReaderTypeAttributeName = "FEZRepacker.Core.Definitions.Game.XnbReaderTypeAttribute"; + + private enum ReaderValueContract + { + Required, + NullableObject + } + + // // These reader expressions transform, flatten, or conditionally assign their source values + private static readonly IReadOnlyDictionary ReaderOverrides = + new Dictionary(StringComparer.Ordinal) + { + ["FezEngine.Readers.TrileSetReader.TextureAtlas"] = ReaderValueContract.NullableObject, + ["FezEngine.Readers.ArtObjectActorSettingsReader.InvisibleSides"] = ReaderValueContract.NullableObject, + ["FezEngine.Readers.ArtObjectInstanceReader.Name"] = ReaderValueContract.Required, + ["FezEngine.Readers.BackgroundPlaneReader.Filter"] = ReaderValueContract.Required, + ["FezEngine.Readers.LevelReader.StartingFace"] = ReaderValueContract.NullableObject, + ["FezEngine.Readers.TrileFaceReader.Id"] = ReaderValueContract.Required, + ["FezEngine.Readers.VolumeReader.Orientations"] = ReaderValueContract.NullableObject, + ["FezEngine.Readers.AnimatedTextureReader.TextureData"] = ReaderValueContract.Required, + ["FezEngine.Readers.AnimatedTextureReader.Frames"] = ReaderValueContract.NullableObject, + ["FezEngine.Readers.FrameReader.Rectangle"] = ReaderValueContract.Required, + ["FezEngine.Readers.ShaderInstancedIndexedPrimitivesReader`2.Indices"] = ReaderValueContract.NullableObject, + ["FezEngine.Readers.ArtObjectReader.Cubemap"] = ReaderValueContract.NullableObject + }; + + // Maps every reader-backed reference property and checks its nullable write metadata + [TestMethod] + public void DirectReaderAssignmentsMatchModelNullability() + { + var fixturePath = Path.Combine(AppContext.BaseDirectory, FixturePath); + var failures = new List(); + + using var module = ModuleDefinition.ReadModule(fixturePath); + + foreach (var repackerType in typeof(Level).Assembly.GetTypes()) + { + var readerName = GetReaderName(repackerType); + if (readerName is null) + { + continue; + } + + if (!readerName.StartsWith(FezReaderNamespacePrefix, StringComparison.Ordinal)) + { + continue; + } + + var reader = GetReader(module, readerName, repackerType); + if (reader is null) + { + failures.Add($"Reader {readerName} does not exist."); + continue; + } + + var readMethod = reader.Methods.SingleOrDefault(method => method.Name == ReaderMethodName && method.HasBody); + if (readMethod is null) + { + failures.Add($"Reader {readerName} does not have a readable Read method."); + continue; + } + + foreach (var property in GetReferenceXnbProperties(repackerType)) + { + var key = $"{reader.FullName}.{property.Name}"; + var expected = GetDirectContract(readMethod, property.Name); + + if (expected is not null) + { + AssertNullability(property, expected.Value, key, failures); + continue; + } + + if (!ReaderOverrides.TryGetValue(key, out var overrideContract)) + { + failures.Add($"{key} has no direct reader contract or explicit override."); + continue; + } + + AssertNullability(property, overrideContract, key, failures); + } + } + + if (failures.Any()) + { + Assert.Fail("Reader-derived nullability contract failures:\n" + string.Join(Environment.NewLine, failures)); + } + } + + private static void AssertNullability( + PropertyInfo property, + ReaderValueContract contract, + string key, + ICollection failures) + { + var actual = new NullabilityInfoContext().Create(property).WriteState; + var expectedNullability = contract == ReaderValueContract.NullableObject + ? NullabilityState.Nullable + : NullabilityState.NotNull; + + if (actual != expectedNullability) + { + failures.Add($"{key} must be {expectedNullability} but is {actual}."); + } + } + + private static IEnumerable GetReferenceXnbProperties(Type type) + { + return type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => !property.PropertyType.IsValueType) + .Where(property => property.CustomAttributes.Any(attribute => + attribute.AttributeType.FullName == XnbPropertyAttributeName)); + } + + private static string? GetReaderName(Type repackerType) + { + var readerAttribute = repackerType.CustomAttributes.SingleOrDefault(attribute => + attribute.AttributeType.FullName == XnbReaderTypeAttributeName); + + return readerAttribute?.ConstructorArguments.Single().Value is string qualifier + ? qualifier.Split(',')[0].Replace('+', '/') + : null; + } + + private static TypeDefinition? GetReader(ModuleDefinition module, string readerName, Type repackerType) + { + var reader = module.GetType(readerName); + if (reader is not null || !repackerType.IsGenericType) + { + return reader; + } + + return module.GetType($"{readerName}{'`'}{repackerType.GetGenericArguments().Length}"); + } + + private static ReaderValueContract? GetDirectContract(MethodDefinition readMethod, string propertyName) + { + var instructions = readMethod.Body.Instructions; + + for (var index = 1; index < instructions.Count; index++) + { + if (!IsPropertyAssignment(instructions[index], propertyName)) + { + continue; + } + + return GetReadContract(instructions[index - 1]); + } + + return null; + } + + private static bool IsPropertyAssignment(Instruction instruction, string propertyName) + { + return instruction.Operand switch + { + MethodReference { Name: var name } when (instruction.OpCode.Code is Code.Call or Code.Callvirt) && + name == $"set_{propertyName}" => true, + FieldReference { Name: var name } when instruction.OpCode.Code == Code.Stfld && + name == propertyName => true, + _ => false + }; + } + + private static ReaderValueContract? GetReadContract(Instruction instruction) + { + if (instruction.Operand is not MethodReference method) + { + return null; + } + + if (method.Name == ReadStringMethodName) + { + return ReaderValueContract.Required; + } + + if (method.Name != ReadObjectMethodName || method is not GenericInstanceMethod genericMethod) + { + return null; + } + + return genericMethod.GenericArguments[0].IsValueType + ? null + : ReaderValueContract.NullableObject; + } + } +} diff --git a/Tests/TypeContractSemanticNullabilityTests.cs b/Tests/TypeContractSemanticNullabilityTests.cs new file mode 100644 index 0000000..8a0d914 --- /dev/null +++ b/Tests/TypeContractSemanticNullabilityTests.cs @@ -0,0 +1,72 @@ +using System.Reflection; +using System.Text.Json; + +using FEZRepacker.Core.Definitions.Game.ArtObject; +using FEZRepacker.Core.Definitions.Game.Level; +using FEZRepacker.Core.Definitions.Game.Level.Scripting; +using FEZRepacker.Core.Definitions.Game.Sky; +using FEZRepacker.Core.Definitions.Game.TrackedSong; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace FEZRepacker.Tests +{ + // Verifies nullable aliases that are not represented by FEZ type metadata + [TestClass] + public class TypeContractSemanticNullabilityTests + { + // Checks repacker-only entity aliases retain their nullable write contract + [TestMethod] + public void EntityAliasesAreNullable() + { + AssertNullable(nameof(ScriptAction.Object)); + AssertNullable(nameof(ScriptTrigger.Object)); + AssertNullable(nameof(ScriptCondition.Object)); + } + + // Deserializes and serializes representative nulls to verify JSON preservation + [TestMethod] + public void ExplicitJsonNullsArePreserved() + { + AssertJsonNullRoundTrip(nameof(NpcActionContent.AnimationName)); + AssertJsonNullRoundTrip(nameof(NpcActionContent.SoundName)); + AssertJsonNullRoundTrip(nameof(NpcInstance.Speech)); + AssertJsonNullRoundTrip(nameof(NpcInstance.Actions)); + AssertJsonNullRoundTrip