1
Fork 0
mirror of https://github.com/xivdev/EXDSchema.git synced 2025-06-05 23:57:46 +00:00

Commit first-pass of schemas and schema validator

This commit is contained in:
Liam 2023-10-09 00:45:22 -04:00
parent 4a605383ec
commit 7b6e016950
816 changed files with 13870 additions and 123 deletions

View file

@ -226,7 +226,7 @@ public class SchemaConverter
return (null, null);
}
var condition = new Condition();
condition.Switch = oldLink.Links[0].When.Key;
condition.Switch = Util.StripDefinitionName(oldLink.Links[0].When.Key);
condition.Cases = new Dictionary<int, List<string>>();
foreach (var oldLinkLink in oldLink.Links)
condition.Cases.Add(oldLinkLink.When.Value, oldLinkLink.LinkedSheet == null ? oldLinkLink.Sheets : new List<string> { oldLinkLink.LinkedSheet });

View file

@ -1,48 +1,53 @@
// using YamlDotNet.Core;
// using YamlDotNet.Core.Events;
// using YamlDotNet.Serialization;
// using YamlDotNet.Serialization.EventEmitters;
// using YamlDotNet.Serialization.NamingConventions;
//
// namespace SchemaConverter;
//
// public static class SerializeUtil
// {
// private static readonly ISerializer _serializer;
//
// static SerializeUtil()
// {
// _serializer = new SerializerBuilder()
// .WithIndentedSequences()
// .WithNamingConvention(CamelCaseNamingConvention.Instance)
// .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitDefaults)
// .DisableAliases()
// // .WithEventEmitter(nextEmitter => new FlowEverythingEmitter(nextEmitter))
// .Build();
// }
//
// public static string Serialize(object o)
// {
// return _serializer.Serialize(o);
// }
//
// public class FlowEverythingEmitter : ChainedEventEmitter
// {
// public FlowEverythingEmitter(IEventEmitter nextEmitter) : base(nextEmitter) { }
//
// public override void Emit(MappingStartEventInfo eventInfo, IEmitter emitter)
// {
// Console.WriteLine($"Type: {eventInfo.Source.Type} Style: {eventInfo.Source.StaticType} Value: {eventInfo.Source.Value}");
//
// eventInfo.Style = MappingStyle.Flow;
// base.Emit(eventInfo, emitter);
// }
//
// public override void Emit(SequenceStartEventInfo eventInfo, IEmitter emitter)
// {
// Console.WriteLine($"Type: {eventInfo.Source.Type} StaticType: {eventInfo.Source.StaticType} Value: {eventInfo.Source.Value}");
// eventInfo.Style = SequenceStyle.Flow;
// nextEmitter.Emit(eventInfo, emitter);
// }
// }
// }
using SharpYaml;
using SharpYaml.Events;
using SharpYaml.Serialization;
using SharpYaml.Serialization.Serializers;
namespace SchemaConverter;
public static class SerializeUtil
{
private static readonly Serializer _serializer;
static SerializeUtil()
{
var settings = new SerializerSettings
{
EmitAlias = false,
EmitDefaultValues = false,
NamingConvention = new CamelCaseNamingConvention(),
IgnoreNulls = true,
};
settings.RegisterSerializer(typeof(Dictionary<int, List<string>>), new CustomDictionarySerializer());
settings.RegisterSerializer(typeof(New.FieldType), new CustomFieldTypeSerializer());
_serializer = new Serializer(settings);
}
public static string Serialize(object o)
{
return _serializer.Serialize(o);
}
}
internal class CustomDictionarySerializer : DictionarySerializer
{
protected override void WriteDictionaryItem(ref ObjectContext objectContext, KeyValuePair<object, object?> keyValue, KeyValuePair<Type, Type> types)
{
objectContext.SerializerContext.WriteYaml(keyValue.Key, types.Key);
objectContext.SerializerContext.WriteYaml(keyValue.Value, types.Value, YamlStyle.Flow);
}
}
internal class CustomFieldTypeSerializer : ScalarSerializerBase
{
public override object? ConvertFrom(ref ObjectContext context, Scalar fromScalar)
{
return Enum.Parse<New.FieldType>(new PascalNamingConvention().Convert(fromScalar.Value));
}
public override string ConvertTo(ref ObjectContext objectContext)
{
return objectContext.Settings.NamingConvention.Convert(objectContext.Instance.ToString());
}
}

View file

@ -1,18 +0,0 @@
// using YamlMap;
//
// namespace SchemaConverter;
//
// public static class SerializeUtil2
// {
// private static readonly YamlWriter _serializer;
//
// static SerializeUtil2()
// {
// _serializer = new YamlWriter();
// }
//
// public static string Serialize(object o)
// {
// return _serializer.Write(o);
// }
// }

View file

@ -1,53 +0,0 @@
using SharpYaml;
using SharpYaml.Events;
using SharpYaml.Serialization;
using SharpYaml.Serialization.Serializers;
namespace SchemaConverter;
public static class SerializeUtil3
{
private static readonly Serializer _serializer;
static SerializeUtil3()
{
var settings = new SerializerSettings
{
EmitAlias = false,
EmitDefaultValues = false,
NamingConvention = new CamelCaseNamingConvention(),
IgnoreNulls = true,
};
settings.RegisterSerializer(typeof(Dictionary<int, List<string>>), new CustomDictionarySerializer());
settings.RegisterSerializer(typeof(New.FieldType), new CustomFieldTypeSerializer());
_serializer = new Serializer(settings);
}
public static string Serialize(object o)
{
return _serializer.Serialize(o);
}
}
internal class CustomDictionarySerializer : DictionarySerializer
{
protected override void WriteDictionaryItem(ref ObjectContext objectContext, KeyValuePair<object, object?> keyValue, KeyValuePair<Type, Type> types)
{
objectContext.SerializerContext.WriteYaml(keyValue.Key, types.Key);
objectContext.SerializerContext.WriteYaml(keyValue.Value, types.Value, YamlStyle.Flow);
}
}
internal class CustomFieldTypeSerializer : ScalarSerializerBase
{
public override object? ConvertFrom(ref ObjectContext context, Scalar fromScalar)
{
return Enum.Parse<New.FieldType>(new PascalNamingConvention().Convert(fromScalar.Value));
}
public override string ConvertTo(ref ObjectContext objectContext)
{
return objectContext.Settings.NamingConvention.Convert(objectContext.Instance.ToString());
}
}

View file

@ -0,0 +1,46 @@
using Lumina.Data.Structs.Excel;
using SchemaValidator.New;
namespace SchemaValidator;
public class DefinedColumn
{
public ExcelColumnDefinition Definition { get; set; }
public Field Field { get; set; }
private int? _bitOffset;
public int BitOffset {
get
{
if (_bitOffset == null)
_bitOffset = CalculateBitOffset(Definition.Offset, Definition.Type);
return _bitOffset.Value;
}
}
public override string ToString() => $"{Field} @ 0x{BitOffset / 8:X}&{BitOffset % 8}";
public static int CalculateBitOffset(ExcelColumnDefinition def)
{
return CalculateBitOffset(def.Offset, def.Type);
}
public static int CalculateBitOffset(int offset, ExcelColumnDataType type)
{
var bitOffset = offset * 8;
return type switch
{
ExcelColumnDataType.PackedBool0 => bitOffset + 0,
ExcelColumnDataType.PackedBool1 => bitOffset + 1,
ExcelColumnDataType.PackedBool2 => bitOffset + 2,
ExcelColumnDataType.PackedBool3 => bitOffset + 3,
ExcelColumnDataType.PackedBool4 => bitOffset + 4,
ExcelColumnDataType.PackedBool5 => bitOffset + 5,
ExcelColumnDataType.PackedBool6 => bitOffset + 6,
ExcelColumnDataType.PackedBool7 => bitOffset + 7,
_ => bitOffset,
};
}
}

View file

@ -0,0 +1,60 @@
// ReSharper disable UnusedMember.Global
// ReSharper disable InconsistentNaming
using System.ComponentModel;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace SchemaValidator.New;
public enum FieldType
{
Scalar,
Array,
Icon,
ModelId,
Color,
Link,
}
public class Sheet
{
public string Name { get; set; }
public string? DisplayField { get; set; }
public List<Field> Fields { get; set; }
}
public class Field
{
public string? Name { get; set; }
public int? Count { get; set; }
[DefaultValue(FieldType.Scalar)]
[JsonConverter(typeof(StringEnumConverter), true)]
public FieldType Type { get; set; }
public string? Comment { get; set; }
public List<Field>? Fields { get; set; }
public Condition? Condition { get; set; }
public List<string>? Targets { get; set; }
public override string ToString()
{
var arraySuffix = Count.HasValue ? $"[{Count}]" : "";
var name = Name != null ? $"{Name}{arraySuffix}" : "Unknown";
return $"{name} ({Type})";
}
public override bool Equals(object? obj)
{
if (obj is not Field other)
return false;
var fieldsEqual = (Fields == null && other.Fields == null) || (Fields != null && other.Fields != null && Fields.SequenceEqual(other.Fields));
var targetsEqual = (Targets == null && other.Targets == null) || (Targets != null && other.Targets != null && Targets.SequenceEqual(other.Targets));
return Name == other.Name && Count == other.Count && Type == other.Type && Comment == other.Comment && Condition == other.Condition && fieldsEqual && targetsEqual;
}
}
public class Condition
{
public string? Switch { get; set; }
public Dictionary<int, List<string>>? Cases { get; set; }
}

View file

@ -1,3 +0,0 @@
// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");

View file

@ -0,0 +1,213 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$ref": "#/definitions/Sheet",
"type": "object",
"additionalProperties": false,
"properties": {
"sheet": {
"$ref": "#/definitions/Sheet"
}
},
"definitions": {
"Sheet": {
"title": "Sheet",
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"description": "The name of the sheet.",
"type": "string"
},
"displayField": {
"description": "The name of the field to use for displaying a reference to this sheet in a cell. Useful only for UI-based consumption.",
"type": "string"
},
"fields": {
"description": "The fields of the sheet. Sheets must specify all fields present in the EXH file for that sheet, meaning they all must have at least one field.",
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/definitions/Field"
}
},
"comment": {
"type": "string"
}
},
"oneOf": [
{ "required": ["name", "fields"] }
]
},
"Field": {
"description": "A field in a sheet. Describes one or more columns.",
"title": "Field",
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"description": "The name of the field.",
"type": "string"
},
"type": {
"description": "Defines the type of the field. Scalar should be assumed by default, and has no meaning. The only other type that affects parsing is array.",
"type": "string",
"enum": ["scalar", "link", "array", "icon", "modelId", "color"]
},
"count": {
"description": "Only valid for array types. Defines the number of elements in the array.",
"type": "integer"
},
"targets": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"condition": {
"$ref": "#/definitions/Condition"
},
"fields": {
"description": "Only valid for array types. Defines the fields of the array. Fields are not available on non-array types because grouping non-array types is meaningless. They should be defined at the top-level.",
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/definitions/Field"
}
},
"comment": {
"type": "string"
}
},
"allOf": [
{
"description": "Arrays require a count.",
"if": {
"required": ["type"],
"properties": {
"type": {
"const": "array"
}
}
},
"then": {
"required": ["count"]
},
"else": {
"not": {
"required": ["count"]
}
}
},
{
"description": "Fields with a fields list must be an array.",
"if": {
"required": ["fields"]
},
"then": {
"required": ["type"],
"properties": {
"type": {
"const": "array"
}
}
}
},
{
"description": "Fields with a count must be an array.",
"if": {
"required": ["count"]
},
"then": {
"required": ["type"],
"properties": {
"type": {
"const": "array"
}
}
}
},
{
"description": "Fields can have only one of condition or targets.",
"allOf": [
{
"description": "Fields with targets cannot have a condition.",
"if": {
"required": ["targets"]
},
"then": {
"not": {
"required": ["condition"]
}
}
},
{
"description": "Fields with a condition cannot have targets.",
"if": {
"required": ["condition"]
},
"then": {
"not": {
"required": ["targets"]
}
}
},
{
"description": "Arrays can have neither condition or targets.",
"not": {
"required": ["condition", "targets"]
}
}
]
},
{
"description": "Fields with a link type must have a condition or targets.",
"if": {
"required": ["type"],
"properties": {
"type": {
"const": "link"
}
}
},
"then": {
"oneOf": [
{
"required": ["condition"],
"not": {
"required": ["targets"]
}
},
{
"required": ["targets"],
"not": {
"required": ["condition"]
}
}
]
}
}
]
},
"Condition": {
"title": "Condition",
"type": "object",
"additionalProperties": false,
"properties": {
"switch": {
"type": "string"
},
"cases": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"required": ["cases", "switch"]
}
}
}

View file

@ -0,0 +1,103 @@
using Lumina;
using Lumina.Data.Files.Excel;
using SchemaValidator.New;
using SchemaValidator.Util;
using SchemaValidator.Validation;
using SchemaValidator.Validation.Validators;
namespace SchemaValidator;
public class SchemaValidator
{
public static void Main(string[] args)
{
// we need 3 args
if (args.Length != 3)
{
Console.WriteLine("Usage: SchemaValidator.exe <game install directory> <json schema file> <schema directory>");
return;
}
var gameDir = args[0];
var schemaFile = args[1];
var schemaDir = args[2];
var gameData = new GameData(gameDir);
var schemaText = File.ReadAllText(schemaFile);
var testDict = new Dictionary<string, List<int>>()
{
{"all", new() {0}},
};
var validators = new List<Validator>
{
new SchemaFileValidator(gameData, schemaText),
new ColumnCountValidator(gameData),
new IconTypeValidator(gameData),
new NamedInnerNamedOuterValidator(gameData),
new ModelIdTypeValidator(gameData),
new ColorTypeValidator(gameData),
new IconPathExistsValidator(gameData),
new SingleLinkRefValidator(gameData, testDict),
new MultiLinkRefValidator(gameData, testDict),
new ConditionValidator(gameData),
new ConditionRefValidator(gameData, testDict),
};
// var exl = gameData.GetFile<ExcelListFile>("exd/root.exl");
var results = new ValidationResults();
foreach (var schemaPath in Directory.GetFiles(schemaDir, "*.yml"))
{
var sheetName = Path.GetFileNameWithoutExtension(schemaPath);
var exh = gameData.GetFile<ExcelHeaderFile>($"exd/{sheetName}.exh");
if (exh == null)
{
Console.Error.WriteLine($"Sheet {sheetName} does not exist!");
continue;
}
Sheet sheet;
try
{
sheet = SerializeUtil.Deserialize<Sheet>(File.ReadAllText(schemaPath));
}
catch (Exception e)
{
Console.Error.WriteLine($"Sheet {sheetName} encountered an exception when deserializing!");
Console.Error.WriteLine(e.Message);
Console.Error.WriteLine(e.StackTrace);
continue;
}
if (sheet == null)
{
Console.Error.WriteLine($"Sheet {sheetName} could not be deserialized!");
continue;
}
// SerializeUtil.EvaluateSchema(schemaPath);
foreach (var validator in validators)
results.Add(validator.Validate(exh, sheet));
}
foreach (var result in results.Results.Where(r => r.Status == ValidationStatus.Warning))
{
var msgfmt = result.Message == "" ? "" : $" - ";
Console.WriteLine($"{result.Status}: {result.SheetName} - {result.ValidatorName}{msgfmt}{result.Message}");
}
foreach (var result in results.Results.Where(r => r.Status == ValidationStatus.Error))
{
var msgfmt = result.Message == "" ? "" : $" - ";
Console.WriteLine($"{result.Status}: {result.SheetName} - {result.ValidatorName}{msgfmt}{result.Message}");
}
var successCount = results.Results.Count(r => r.Status == ValidationStatus.Success);
var warningCount = results.Results.Count(r => r.Status == ValidationStatus.Warning);
var errorCount = results.Results.Count(r => r.Status == ValidationStatus.Error);
Console.WriteLine($"{successCount} success, {warningCount} warnings, {errorCount} errors");
}
}

View file

@ -5,6 +5,17 @@
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="JsonSchema.Net" Version="5.2.5" />
<PackageReference Include="Lumina" Version="3.11.0" />
<PackageReference Include="NJsonSchema" Version="10.9.0" />
<PackageReference Include="SharpYaml" Version="2.1.0" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,120 @@
using Lumina.Data.Files.Excel;
using SchemaValidator.New;
namespace SchemaValidator.Util;
/// <summary>
/// Useful methods for working with the EXDSchema object model.
/// </summary>
public static class SchemaUtil
{
public static int GetColumnCount(Sheet sheet)
{
var total = 0;
foreach (var field in sheet.Fields)
total += GetFieldCount(field);
return total;
}
public static List<DefinedColumn> Flatten(ExcelHeaderFile exh, Sheet sheet)
{
var fields = new List<DefinedColumn>();
foreach (var field in sheet.Fields)
Emit(fields, field);
var exhDefList = exh.ColumnDefinitions.ToList();
exhDefList.Sort((c1, c2) => DefinedColumn.CalculateBitOffset(c1).CompareTo(DefinedColumn.CalculateBitOffset(c2)));
var min = Math.Min(exhDefList.Count, fields.Count);
for(int i = 0; i < min; i++)
{
var field = fields[i];
field.Definition = exhDefList[i];
}
return fields;
}
private static void Emit(List<DefinedColumn> list, Field field, string nameOverride = "")
{
if (field.Type != FieldType.Array)
{
// Single field
list.Add(new DefinedColumn { Field = CreateField(field, nameOverride) });
}
else if (field.Type == FieldType.Array)
{
// We can have an array without fields, it's just scalars
if (field.Fields == null)
{
for (int i = 0; i < field.Count.Value; i++)
{
list.Add(new DefinedColumn { Field = CreateField(field, "") });
}
}
else
{
for (int i = 0; i < field.Count.Value; i++)
{
foreach (var nestedField in field.Fields)
{
Emit(list, nestedField, field.Name);
}
}
}
}
}
private static Field CreateField(Field baseField, string nameOverride)
{
var addedField = new Field
{
Name = baseField.Name,
Comment = baseField.Comment,
Count = null,
Type = baseField.Type == FieldType.Array ? FieldType.Scalar : baseField.Type,
Fields = null,
Condition = baseField.Condition,
Targets = baseField.Targets,
};
// This is for unnamed inner fields of arrays such as arrays of links
// We don't want to override the name of unnamed scalars though
if (baseField.Name == null && baseField.Type != FieldType.Scalar && nameOverride != "")
addedField.Name = nameOverride;
return addedField;
}
private static int GetFieldCount(Field field)
{
if (field.Type == FieldType.Array)
{
var total = 0;
if (field.Fields != null)
{
foreach (var nestedField in field.Fields)
total += GetFieldCount(nestedField);
}
else
{
total = 1;
}
return total * field.Count.Value;
}
return 1;
}
public static Field GetFieldByIndex(Sheet schema, int index)
{
foreach (var field in schema.Fields)
{
}
return null;
}
public static Field GetFieldByIndex(Field field, int index, int baseIndex)
{
return null;
}
}

View file

@ -0,0 +1,93 @@
using System.Text.Json.Nodes;
using Json.Schema;
using Newtonsoft.Json;
using SchemaValidator.New;
using SharpYaml;
using SharpYaml.Events;
using SharpYaml.Serialization;
using SharpYaml.Serialization.Serializers;
using JsonSchema = Json.Schema.JsonSchema;
namespace SchemaValidator.Util;
public static class SerializeUtil
{
private static readonly Serializer _serializer;
static SerializeUtil()
{
var settings = new SerializerSettings
{
EmitAlias = false,
EmitDefaultValues = false,
NamingConvention = new CamelCaseNamingConvention(),
IgnoreNulls = true,
};
settings.RegisterSerializer(typeof(Dictionary<int, List<string>>), new CustomDictionarySerializer());
settings.RegisterSerializer(typeof(FieldType), new CustomFieldTypeSerializer());
_serializer = new Serializer(settings);
}
public static string Serialize(object o)
{
return _serializer.Serialize(o);
}
public static T? Deserialize<T>(string s)
{
return _serializer.Deserialize<T>(s);
}
public static object? Deserialize(string s)
{
return _serializer.Deserialize(s);
}
public static EvaluationResults? EvaluateSchema(string filePath)
{
var yamlText = File.ReadAllText(filePath);
object? yamlObject;
try
{
yamlObject = _serializer.Deserialize(yamlText);
}
catch (Exception e)
{
Console.WriteLine(e);
return null;
}
if (yamlObject == null) return null;
var json = JsonConvert.SerializeObject(yamlObject);
// Console.WriteLine(json);
// File.WriteAllText(@"C:\Users\Liam\Documents\repos\EXDSchema\SchemaValidator\test.json", json);
var schemaText = File.ReadAllText(@"C:\Users\Liam\Documents\repos\EXDSchema\SchemaValidator\SchemaSchema.json");
var schema = JsonSchema.FromText(schemaText);
var node = JsonNode.Parse(json);
return schema.Evaluate(node);
}
}
internal class CustomDictionarySerializer : DictionarySerializer
{
protected override void WriteDictionaryItem(ref ObjectContext objectContext, KeyValuePair<object, object?> keyValue, KeyValuePair<Type, Type> types)
{
objectContext.SerializerContext.WriteYaml(keyValue.Key, types.Key);
objectContext.SerializerContext.WriteYaml(keyValue.Value, types.Value, YamlStyle.Flow);
}
}
internal class CustomFieldTypeSerializer : ScalarSerializerBase
{
public override object? ConvertFrom(ref ObjectContext context, Scalar fromScalar)
{
return Enum.Parse<FieldType>(new PascalNamingConvention().Convert(fromScalar.Value));
}
public override string ConvertTo(ref ObjectContext objectContext)
{
return objectContext.Settings.NamingConvention.Convert(objectContext.Instance.ToString());
}
}

View file

@ -0,0 +1,8 @@
using Newtonsoft.Json.Converters;
namespace SchemaValidator.Util;
public class StringToEnumCamelCaseConverter : StringEnumConverter
{
}

View file

@ -0,0 +1,92 @@
namespace SchemaValidator.Validation;
public enum ValidationStatus
{
Success,
Error,
Warning,
Failed,
Info,
}
public class ValidationResults
{
public List<ValidationResult> Results { get; set; } = new();
public ValidationResults() { }
public ValidationResults(ValidationResult result) => Results.Add(result);
public void Add(ValidationResult result) => Results.Add(result);
public void Add(ValidationResults results) => Results.AddRange(results.Results);
public static ValidationResults Success(string sheetName, string validatorName, string message = "") => new(ValidationResult.Success(sheetName, validatorName, message));
public static ValidationResults Error(string sheetName, string validatorName, string message = "") => new(ValidationResult.Error(sheetName, validatorName, message));
public static ValidationResults Warning(string sheetName, string validatorName, string message = "") => new(ValidationResult.Warning(sheetName, validatorName, message));
public static ValidationResults Failed(string sheetName, string validatorName, string message = "") => new(ValidationResult.Failed(sheetName, validatorName, message));
public static ValidationResults Info(string sheetName, string validatorName, string message = "") => new(ValidationResult.Info(sheetName, validatorName, message));
}
public class ValidationResult
{
public ValidationStatus Status { get; set; }
public string SheetName { get; set; }
public string ValidatorName { get; set; }
public string Message { get; set; }
private ValidationResult() {}
public static ValidationResult Success(string sheetName, string validatorName, string message = "")
{
return new ValidationResult
{
SheetName = sheetName,
ValidatorName = validatorName,
Status = ValidationStatus.Success,
Message = message,
};
}
public static ValidationResult Error(string sheetName, string validatorName, string message = "")
{
return new ValidationResult
{
SheetName = sheetName,
ValidatorName = validatorName,
Status = ValidationStatus.Error,
Message = message,
};
}
public static ValidationResult Warning(string sheetName, string validatorName, string message = "")
{
return new ValidationResult
{
SheetName = sheetName,
ValidatorName = validatorName,
Status = ValidationStatus.Warning,
Message = message,
};
}
public static ValidationResult Failed(string sheetName, string validatorName, string message = "")
{
return new ValidationResult
{
SheetName = sheetName,
ValidatorName = validatorName,
Status = ValidationStatus.Failed,
Message = message,
};
}
public static ValidationResult Info(string sheetName, string validatorName, string message = "")
{
return new ValidationResult
{
SheetName = sheetName,
ValidatorName = validatorName,
Status = ValidationStatus.Info,
Message = message,
};
}
}

View file

@ -0,0 +1,42 @@
using Lumina;
using Lumina.Data.Files.Excel;
using Lumina.Data.Structs.Excel;
using Lumina.Excel;
using SchemaValidator.New;
namespace SchemaValidator.Validation;
public abstract class Validator
{
protected GameData GameData;
public Validator(GameData gameData)
{
GameData = gameData;
}
public abstract string ValidatorName();
public abstract ValidationResults Validate(ExcelHeaderFile exh, Sheet sheet);
protected long? ReadColumnIntegerValue(RawExcelSheet sheet, RowParser parser, DefinedColumn column)
{
var offset = column.Definition.Offset;
var type = column.Definition.Type;
Int128? value = type switch
{
ExcelColumnDataType.Int8 => parser.ReadOffset<sbyte>(offset),
ExcelColumnDataType.UInt8 => parser.ReadOffset<byte>(offset),
ExcelColumnDataType.Int16 => parser.ReadOffset<short>(offset),
ExcelColumnDataType.UInt16 => parser.ReadOffset<ushort>(offset),
ExcelColumnDataType.Int32 => parser.ReadOffset<int>(offset),
ExcelColumnDataType.UInt32 => parser.ReadOffset<uint>(offset),
ExcelColumnDataType.Int64 => parser.ReadOffset<long>(offset),
ExcelColumnDataType.UInt64 => parser.ReadOffset<ulong>(offset),
_ => null,
};
if (value != null)
return (long)value;
return null;
}
}

View file

@ -0,0 +1,32 @@
using Lumina;
using Lumina.Data.Files.Excel;
using Lumina.Data.Structs.Excel;
using SchemaValidator.New;
using SchemaValidator.Util;
namespace SchemaValidator.Validation.Validators;
public class ColorTypeValidator : Validator
{
public override string ValidatorName() => "ColorTypeValidator";
public ColorTypeValidator(GameData gameData) : base(gameData) { }
public override ValidationResults Validate(ExcelHeaderFile exh, Sheet sheet)
{
var results = new ValidationResults();
var fields = SchemaUtil.Flatten(exh, sheet);
foreach (var field in fields)
{
if (field.Field.Type == FieldType.Color && field.Definition.Type != ExcelColumnDataType.UInt32)
{
var msg = $"Column {field.Field.Name}@0x{field.Definition.Offset:X} type {field.Definition.Type} is not valid for type 'color'.";
results.Results.Add(ValidationResult.Error(sheet.Name, ValidatorName(), msg));
}
}
if (results.Results.Count == 0)
return ValidationResults.Success(sheet.Name, ValidatorName());
return results;
}
}

View file

@ -0,0 +1,21 @@
using Lumina;
using Lumina.Data.Files.Excel;
using SchemaValidator.New;
using SchemaValidator.Util;
namespace SchemaValidator.Validation.Validators;
public class ColumnCountValidator : Validator
{
public override string ValidatorName() => "ColumnCountValidator";
public ColumnCountValidator(GameData gameData) : base(gameData) { }
public override ValidationResults Validate(ExcelHeaderFile exh, Sheet sheet)
{
var colCount = SchemaUtil.GetColumnCount(sheet);
if (colCount != exh.ColumnDefinitions.Length)
return ValidationResults.Error(sheet.Name, ValidatorName(), $"Column count mismatch! exh count {exh.ColumnDefinitions.Length} != schema count {colCount}");
return ValidationResults.Success(sheet.Name, ValidatorName());
}
}

View file

@ -0,0 +1,161 @@
using System.Diagnostics;
using Lumina;
using Lumina.Data.Files.Excel;
using Lumina.Excel;
using SchemaValidator.New;
using SchemaValidator.Util;
namespace SchemaValidator.Validation.Validators;
public class ConditionRefValidator : Validator
{
public override string ValidatorName() => "ConditionRefValidator";
private Dictionary<string, List<int>> _ignoredValues = new();
public ConditionRefValidator(GameData gameData) : base(gameData) { }
public ConditionRefValidator(GameData gameData, Dictionary<string, List<int>> ignoredValues) : base(gameData)
{
_ignoredValues = ignoredValues;
}
private class LinkTargetData
{
public string SourceSheet { get; set; }
public DefinedColumn Source { get; set; }
public string SwitchField { get; set; }
public int SwitchValue { get; set; }
public HashSet<string> TargetSheets { get; set; }
public HashSet<long> TargetKeys { get; set; }
public override bool Equals(object? obj)
{
if (obj is not LinkTargetData other)
return false;
return SourceSheet == other.SourceSheet && Source.Field.Equals(other.Source.Field) && TargetSheets == other.TargetSheets && TargetKeys.SetEquals(other.TargetKeys);
}
}
public override ValidationResults Validate(ExcelHeaderFile exh, Sheet sheet)
{
var results = new ValidationResults();
var fields = SchemaUtil.Flatten(exh, sheet);
var dataFile = GameData.Excel.GetSheetRaw($"{sheet.Name}");
if (dataFile == null)
{
var msg = $"Failed to obtain sheet {sheet.Name} from game data.";
results.Results.Add(ValidationResult.Failed(sheet.Name, ValidatorName(), msg));
return results;
}
// Get all link fields with condition
var linkFields = fields.Where(f => f.Field is { Type: FieldType.Link, Condition: not null, Targets: null }).ToList();
var linkVals = new List<LinkTargetData>();
foreach (var field in linkFields)
{
var offset = field.Definition.Offset;
var switchOn = field.Field.Condition.Switch;
var switchField = fields.First(f => f.Field.Name == switchOn);
// var definedSwitchColumnValues = field.Field.Condition.Cases.Keys.ToHashSet();
// store the column values for each switch value
var columnValues = new Dictionary<long, HashSet<long>>();
foreach (var row in dataFile.GetRowParsers())
{
var columnValue = ReadColumnIntegerValue(dataFile, row, field);
var switchColumnValue = ReadColumnIntegerValue(dataFile, row, switchField);
if (columnValue == null || switchColumnValue == null)
{
var msg = $"Column {field.Field.Name}@0x{offset:X} type {field.Definition.Type} is not valid for type 'link' condition switch, failed to read.";
results.Results.Add(ValidationResult.Failed(sheet.Name, ValidatorName(), msg));
break; // don't spam the same error
}
if (columnValues.TryGetValue(switchColumnValue.Value, out var values))
{
values.Add(columnValue.Value);
}
else
{
columnValues.Add(switchColumnValue.Value, new HashSet<long> { columnValue.Value });
}
}
foreach (var valueSet in columnValues)
{
// Skip if we don't have a defined target set for this value of the switch
if (!field.Field.Condition.Cases.TryGetValue((int)valueSet.Key, out var switchTargets))
continue;
var targetData = new LinkTargetData
{
SourceSheet = sheet.Name,
Source = field,
TargetSheets = switchTargets.ToHashSet(),
TargetKeys = valueSet.Value,
SwitchField = switchField.Field.Name,
SwitchValue = (int)valueSet.Key,
};
linkVals.Add(targetData);
}
}
foreach (var linkData in linkVals.Distinct())
{
var sheetNames = linkData.TargetSheets;
var sheetKeys = linkData.TargetKeys;
var dataFiles = new Dictionary<string, RawExcelSheet>();
foreach (var sheetName in sheetNames)
{
var tmpDataFile = GameData.Excel.GetSheetRaw(sheetName);
if (tmpDataFile == null)
{
var msg = $"Source {linkData.SourceSheet} field {linkData.Source.Field.Name}@0x{linkData.Source.Definition.Offset:X} references non-existent sheet {sheetName}.";
results.Add(ValidationResult.Error(sheet.Name, ValidatorName(), msg));
continue;
}
dataFiles.Add(sheetName, tmpDataFile);
}
foreach (var key in sheetKeys)
{
if (_ignoredValues.TryGetValue("all", out var ignoredKeys) && ignoredKeys.Contains((int)key))
{
sheetKeys.Remove(key);
continue;
}
foreach (var linkedDataFile in dataFiles)
{
if (_ignoredValues.TryGetValue(linkedDataFile.Key, out ignoredKeys) && ignoredKeys.Contains((int)key))
{
sheetKeys.Remove(key);
}
else if (linkedDataFile.Value.GetRow((uint)key) != null)
{
// Console.WriteLine($"removing {key} because of {linkedDataFile.Key}");
sheetKeys.Remove(key);
}
}
}
if (sheetKeys.Count > 0)
{
var contents = string.Join(", ", sheetKeys);
var display = $"{linkData.Source.Field.Name}@0x{linkData.Source.Definition.Offset:X}";
var msg = $"Source {linkData.SourceSheet} field {display} contains key references {contents} for case {linkData.SwitchField} = {linkData.SwitchValue} which do not exist in any linked sheet.";
results.Add(ValidationResult.Warning(sheet.Name, ValidatorName(), msg));
}
}
if (results.Results.Count == 0)
return ValidationResults.Success(sheet.Name, ValidatorName());
return results;
}
}

View file

@ -0,0 +1,67 @@
using System.Diagnostics;
using Lumina;
using Lumina.Data.Files.Excel;
using SchemaValidator.New;
using SchemaValidator.Util;
namespace SchemaValidator.Validation.Validators;
public class ConditionValidator : Validator
{
public override string ValidatorName() => "ConditionValidator";
public ConditionValidator(GameData gameData) : base(gameData) { }
public override ValidationResults Validate(ExcelHeaderFile exh, Sheet sheet)
{
var results = new ValidationResults();
var fields = SchemaUtil.Flatten(exh, sheet);
var dataFile = GameData.Excel.GetSheetRaw($"{sheet.Name}");
// Get all link fields with null targets (filters out target-based links)
var conditionFields = fields.Where(f => f.Field is { Type: FieldType.Link, Condition: not null, Targets: null }).ToList();
foreach (var field in conditionFields)
{
var offset = field.Definition.Offset;
var switchOn = field.Field.Condition.Switch;
var switchField = fields.First(f => f.Field.Name == switchOn);
var definedSwitchColumnValues = field.Field.Condition.Cases.Keys.ToHashSet();
var switchColumnValues = new HashSet<long>();
foreach (var row in dataFile.GetRowParsers())
{
var columnValue = ReadColumnIntegerValue(dataFile, row, switchField);
if (columnValue == null)
{
var msg = $"Column {field.Field.Name}@0x{offset:X} type {field.Definition.Type} is not valid for type 'link' condition switch, failed to read.";
results.Results.Add(ValidationResult.Failed(sheet.Name, ValidatorName(), msg));
break; // don't spam the same error
}
switchColumnValues.Add(columnValue.Value);
}
foreach (var switchColumnValue in switchColumnValues)
{
if (!definedSwitchColumnValues.Contains((int)switchColumnValue))
{
var msg = $"Column {field.Field.Name}@0x{offset:X} type {field.Definition.Type} is not valid for type 'link' condition switch, switch column {switchOn} value {switchColumnValue} is not defined in the schema.";
results.Add(ValidationResult.Warning(sheet.Name, ValidatorName(), msg));
}
}
foreach (var definedSwitchColumnValue in definedSwitchColumnValues)
{
if (!switchColumnValues.Contains(definedSwitchColumnValue))
{
var msg = $"Column {field.Field.Name}@0x{offset:X} type {field.Definition.Type} is not valid for type 'link' condition switch, switch column {switchOn} value {definedSwitchColumnValue} is not present in the column values.";
results.Add(ValidationResult.Warning(sheet.Name, ValidatorName(), msg));
}
}
}
if (results.Results.Count == 0)
return ValidationResults.Success(sheet.Name, ValidatorName());
return results;
}
}

View file

@ -0,0 +1,55 @@
using Lumina;
using Lumina.Data.Files.Excel;
using SchemaValidator.New;
using SchemaValidator.Util;
namespace SchemaValidator.Validation.Validators;
public class IconPathExistsValidator : Validator
{
public override string ValidatorName() => "IconPathExistsValidator";
public IconPathExistsValidator(GameData gameData) : base(gameData) { }
public override ValidationResults Validate(ExcelHeaderFile exh, Sheet sheet)
{
var results = new ValidationResults();
var fields = SchemaUtil.Flatten(exh, sheet);
var dataFile = GameData.Excel.GetSheetRaw($"{sheet.Name}");
foreach (var field in fields)
{
if (field.Field.Type == FieldType.Icon)
{
var offset = field.Definition.Offset;
foreach (var row in dataFile.GetRowParsers())
{
var columnValue = ReadColumnIntegerValue(dataFile, row, field);
if (columnValue == null)
{
var msg = $"Column {field.Field.Name}@0x{offset:X} type {field.Definition.Type} is not valid for type 'icon', failed to read.";
results.Results.Add(ValidationResult.Failed(sheet.Name, ValidatorName(), msg));
break; // don't spam the same error
}
if (!IconPathExists(columnValue.Value))
{
var msg = $"Column {field.Field.Name}@0x{offset:X} type {field.Definition.Type} at row {row.RowId} icon '{columnValue}' does not exist.";
results.Results.Add(ValidationResult.Warning(sheet.Name, ValidatorName(), msg));
break; // don't spam the same error
}
}
}
}
if (results.Results.Count == 0)
return ValidationResults.Success(sheet.Name, ValidatorName());
return results;
}
private bool IconPathExists(long iconId)
{
var path = $"ui/icon/{iconId / 1000 * 1000:000000}/{iconId:000000}.tex";
return GameData.FileExists(path);
}
}

View file

@ -0,0 +1,39 @@
using Lumina;
using Lumina.Data.Files.Excel;
using Lumina.Data.Structs.Excel;
using SchemaValidator.New;
using SchemaValidator.Util;
namespace SchemaValidator.Validation.Validators;
public class IconTypeValidator : Validator
{
public override string ValidatorName() => "IconTypeValidator";
private readonly HashSet<ExcelColumnDataType> _validTypes = new()
{
ExcelColumnDataType.UInt32,
ExcelColumnDataType.Int32,
ExcelColumnDataType.UInt16,
};
public IconTypeValidator(GameData gameData) : base(gameData) { }
public override ValidationResults Validate(ExcelHeaderFile exh, Sheet sheet)
{
var results = new ValidationResults();
var fields = SchemaUtil.Flatten(exh, sheet);
foreach (var field in fields)
{
if (field.Field.Type == FieldType.Icon && !_validTypes.Contains(field.Definition.Type))
{
var msg = $"Column {field.Field.Name}@0x{field.Definition.Offset:X} type {field.Definition.Type} is not valid for type 'icon'.";
results.Results.Add(ValidationResult.Error(sheet.Name, ValidatorName(), msg));
}
}
if (results.Results.Count == 0)
return ValidationResults.Success(sheet.Name, ValidatorName());
return results;
}
}

View file

@ -0,0 +1,38 @@
using Lumina;
using Lumina.Data.Files.Excel;
using Lumina.Data.Structs.Excel;
using SchemaValidator.New;
using SchemaValidator.Util;
namespace SchemaValidator.Validation.Validators;
public class ModelIdTypeValidator : Validator
{
public override string ValidatorName() => "ModelIdTypeValidator";
private readonly HashSet<ExcelColumnDataType> _validTypes = new()
{
ExcelColumnDataType.UInt32,
ExcelColumnDataType.UInt64,
};
public ModelIdTypeValidator(GameData gameData) : base(gameData) { }
public override ValidationResults Validate(ExcelHeaderFile exh, Sheet sheet)
{
var results = new ValidationResults();
var fields = SchemaUtil.Flatten(exh, sheet);
foreach (var field in fields)
{
if (field.Field.Type == FieldType.ModelId && !_validTypes.Contains(field.Definition.Type))
{
var msg = $"Column {field.Field.Name}@0x{field.Definition.Offset:X} type {field.Definition.Type} is not valid for type 'modelId'.";
results.Results.Add(ValidationResult.Error(sheet.Name, ValidatorName(), msg));
}
}
if (results.Results.Count == 0)
return ValidationResults.Success(sheet.Name, ValidatorName());
return results;
}
}

View file

@ -0,0 +1,136 @@
using System.Diagnostics;
using Lumina;
using Lumina.Data.Files.Excel;
using Lumina.Excel;
using SchemaValidator.New;
using SchemaValidator.Util;
namespace SchemaValidator.Validation.Validators;
public class MultiLinkRefValidator : Validator
{
public override string ValidatorName() => "MultiLinkRefValidator";
private Dictionary<string, List<int>> _ignoredValues = new();
public MultiLinkRefValidator(GameData gameData) : base(gameData) { }
public MultiLinkRefValidator(GameData gameData, Dictionary<string, List<int>> ignoredValues) : base(gameData)
{
_ignoredValues = ignoredValues;
}
private class LinkTargetData
{
public string SourceSheet { get; set; }
public DefinedColumn Source { get; set; }
public HashSet<string> TargetSheets { get; set; }
public HashSet<long> TargetKeys { get; set; }
public override bool Equals(object? obj)
{
if (obj is not LinkTargetData other)
return false;
return SourceSheet == other.SourceSheet && Source.Field.Equals(other.Source.Field) && TargetSheets == other.TargetSheets && TargetKeys.SetEquals(other.TargetKeys);
}
}
public override ValidationResults Validate(ExcelHeaderFile exh, Sheet sheet)
{
var results = new ValidationResults();
var fields = SchemaUtil.Flatten(exh, sheet);
var dataFile = GameData.Excel.GetSheetRaw($"{sheet.Name}");
if (dataFile == null)
{
var msg = $"Failed to obtain sheet {sheet.Name} from game data.";
results.Results.Add(ValidationResult.Failed(sheet.Name, ValidatorName(), msg));
return results;
}
// Get all link fields with null condition, > 1 target
var linkFields = fields.Where(f => f.Field is { Type: FieldType.Link, Condition: null, Targets.Count: > 1 }).ToList();
var linkVals = new List<LinkTargetData>();
foreach (var field in linkFields)
{
var offset = field.Definition.Offset;
var columnValues = new HashSet<long>();
foreach (var row in dataFile.GetRowParsers())
{
var columnValue = ReadColumnIntegerValue(dataFile, row, field);
if (columnValue == null)
{
var msg = $"Row {row.RowId} column {field.Field.Name}@0x{offset:X} type {field.Definition.Type} failed to read properly.";
results.Results.Add(ValidationResult.Failed(sheet.Name, ValidatorName(), msg));
break; // don't spam the same error
}
columnValues.Add(columnValue.Value);
}
var targetData = new LinkTargetData
{
SourceSheet = sheet.Name,
Source = field,
TargetSheets = field.Field.Targets.ToHashSet(),
TargetKeys = columnValues
};
linkVals.Add(targetData);
}
foreach (var linkData in linkVals.Distinct())
{
var sheetNames = linkData.TargetSheets;
var sheetKeys = linkData.TargetKeys;
var dataFiles = new Dictionary<string, RawExcelSheet>();
foreach (var sheetName in sheetNames)
{
var tmpDataFile = GameData.Excel.GetSheetRaw(sheetName);
if (tmpDataFile == null)
{
var msg = $"Source {linkData.SourceSheet} field {linkData.Source.Field.Name}@0x{linkData.Source.Definition.Offset:X} references non-existent sheet {sheetName}.";
results.Add(ValidationResult.Error(sheet.Name, ValidatorName(), msg));
continue;
}
dataFiles.Add(sheetName, tmpDataFile);
}
foreach (var key in sheetKeys)
{
if (_ignoredValues.TryGetValue("all", out var ignoredKeys) && ignoredKeys.Contains((int)key))
{
sheetKeys.Remove(key);
continue;
}
foreach (var linkedDataFile in dataFiles)
{
if (_ignoredValues.TryGetValue(linkedDataFile.Key, out ignoredKeys) && ignoredKeys.Contains((int)key))
{
sheetKeys.Remove(key);
}
else if (linkedDataFile.Value.GetRow((uint)key) != null)
{
// Console.WriteLine($"removing {key} because of {linkedDataFile.Key}");
sheetKeys.Remove(key);
}
}
}
if (sheetKeys.Count > 0)
{
var contents = string.Join(", ", sheetKeys);
var display = $"{linkData.Source.Field.Name}@0x{linkData.Source.Definition.Offset:X}";
var msg = $"Source {linkData.SourceSheet} field {display} contains key references {contents} which do not exist in any linked sheet.";
results.Add(ValidationResult.Warning(sheet.Name, ValidatorName(), msg));
}
}
if (results.Results.Count == 0)
return ValidationResults.Success(sheet.Name, ValidatorName());
return results;
}
}

View file

@ -0,0 +1,56 @@
using Lumina;
using Lumina.Data.Files.Excel;
using SchemaValidator.New;
namespace SchemaValidator.Validation.Validators;
public class NamedInnerNamedOuterValidator : Validator
{
public override string ValidatorName() => "NamedInnerNamedOuterValidator";
private string _sheetName = "";
public override ValidationResults Validate(ExcelHeaderFile exh, Sheet sheet)
{
_sheetName = sheet.Name;
var results = new ValidationResults();
foreach (var field in sheet.Fields)
{
CheckNames(results, null, field);
}
if (results.Results.Count == 0)
return ValidationResults.Success(sheet.Name, ValidatorName());
return results;
}
public NamedInnerNamedOuterValidator(GameData gameData) : base(gameData) { }
private void CheckNames(ValidationResults results, Field? parentField, Field field)
{
if (parentField != null)
{
if (parentField.Fields.Count == 1 && !string.IsNullOrEmpty(parentField.Name) && !string.IsNullOrEmpty(field.Name) && field.Type == FieldType.Scalar)
{
var msg = $"Parent field {parentField.Name} has a single named field {field.Name}.";
results.Results.Add(ValidationResult.Warning(_sheetName, ValidatorName(), msg));
}
}
if (field.Type != FieldType.Array)
{
// Single field
return;
}
if (field.Type == FieldType.Array)
{
if (field.Fields != null)
{
foreach (var nestedField in field.Fields)
{
CheckNames(results, field, nestedField);
}
}
}
}
}

View file

@ -0,0 +1,43 @@
using System.Text.Json.Nodes;
using Json.Schema;
using Lumina;
using Lumina.Data.Files.Excel;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using SchemaValidator.New;
namespace SchemaValidator.Validation.Validators;
public class SchemaFileValidator : Validator
{
public override string ValidatorName() => "SchemaFileValidator";
private readonly JsonSerializerSettings _settings = new()
{
NullValueHandling = NullValueHandling.Ignore,
DefaultValueHandling = DefaultValueHandling.Ignore,
ContractResolver = new CamelCasePropertyNamesContractResolver(),
};
private readonly JsonSchema _schema;
public SchemaFileValidator(GameData gameData, string schemaText) : base(gameData)
{
_schema = JsonSchema.FromText(schemaText);
}
public override ValidationResults Validate(ExcelHeaderFile exh, Sheet sheet)
{
// Re-serialize the yml sheet into json
var json = JsonConvert.SerializeObject(sheet, _settings);
if (json == null) return ValidationResults.Failed(sheet.Name, ValidatorName(), "Json serialization returned null.");
var node = JsonNode.Parse(json);
var schemaResult = _schema.Evaluate(node);
if (schemaResult == null) return ValidationResults.Failed(sheet.Name, ValidatorName(), "Schema validation returned null.");
if (schemaResult.IsValid) return ValidationResults.Success(sheet.Name, ValidatorName());
return ValidationResults.Error(sheet.Name, ValidatorName());
}
}

View file

@ -0,0 +1,114 @@
using System.Diagnostics;
using Lumina;
using Lumina.Data.Files.Excel;
using SchemaValidator.New;
using SchemaValidator.Util;
namespace SchemaValidator.Validation.Validators;
public class SingleLinkRefValidator : Validator
{
public override string ValidatorName() => "SingleLinkRefValidator";
private Dictionary<string, List<int>> _ignoredValues = new();
public SingleLinkRefValidator(GameData gameData) : base(gameData) { }
public SingleLinkRefValidator(GameData gameData, Dictionary<string, List<int>> ignoredValues) : base(gameData)
{
_ignoredValues = ignoredValues;
}
private class LinkTargetData
{
public string SourceSheet { get; set; }
public DefinedColumn Source { get; set; }
public string TargetSheet { get; set; }
public HashSet<long> TargetKeys { get; set; }
public override bool Equals(object? obj)
{
if (obj is not LinkTargetData other)
return false;
return SourceSheet == other.SourceSheet && Source.Field.Equals(other.Source.Field) && TargetSheet == other.TargetSheet && TargetKeys.SetEquals(other.TargetKeys);
}
}
public override ValidationResults Validate(ExcelHeaderFile exh, Sheet sheet)
{
var results = new ValidationResults();
var fields = SchemaUtil.Flatten(exh, sheet);
var dataFile = GameData.Excel.GetSheetRaw($"{sheet.Name}");
if (dataFile == null)
{
var msg = $"Failed to obtain sheet {sheet.Name} from game data.";
results.Results.Add(ValidationResult.Failed(sheet.Name, ValidatorName(), msg));
return results;
}
// Get all link fields with null condition, 1 target
var linkFields = fields.Where(f => f.Field is { Type: FieldType.Link, Condition: null, Targets.Count: 1 }).ToList();
var linkVals = new List<LinkTargetData>();
foreach (var field in linkFields)
{
var offset = field.Definition.Offset;
var columnValues = new HashSet<long>();
foreach (var row in dataFile.GetRowParsers())
{
var columnValue = ReadColumnIntegerValue(dataFile, row, field);
if (columnValue == null)
{
var msg = $"Row {row.RowId} column {field.Field.Name}@0x{offset:X} type {field.Definition.Type} failed to read properly.";
results.Results.Add(ValidationResult.Failed(sheet.Name, ValidatorName(), msg));
break; // don't spam the same error
}
columnValues.Add(columnValue.Value);
}
// our filtering ensures we have exactly one target
var targetData = new LinkTargetData
{
SourceSheet = sheet.Name,
Source = field,
TargetSheet = field.Field.Targets[0],
TargetKeys = columnValues,
};
linkVals.Add(targetData);
}
foreach (var linkData in linkVals.Distinct())
{
var sheetName = linkData.TargetSheet;
var sheetKeys = linkData.TargetKeys;
var linkedDataFile = GameData.Excel.GetSheetRaw(sheetName);
if (linkedDataFile == null)
{
var msg = $"Source {linkData.SourceSheet} field {linkData.Source.Field.Name}@0x{linkData.Source.Definition.Offset:X} references non-existent sheet {sheetName}.";
results.Add(ValidationResult.Error(sheet.Name, ValidatorName(), msg));
continue;
}
foreach (var key in sheetKeys)
{
if (_ignoredValues.TryGetValue(sheetName, out var ignoredKeys) && ignoredKeys.Contains((int)key)) continue;
if (_ignoredValues.TryGetValue("all", out ignoredKeys) && ignoredKeys.Contains((int)key)) continue;
if (linkedDataFile.GetRow((uint)key) == null)
{
var display = $"{linkData.Source.Field.Name}@0x{linkData.Source.Definition.Offset:X}";
var msg = $"Source {linkData.SourceSheet} field {display} references {sheetName} key '{key}' which does not exist.";
results.Add(ValidationResult.Warning(sheet.Name, ValidatorName(), msg));
}
}
}
if (results.Results.Count == 0)
return ValidationResults.Success(sheet.Name, ValidatorName());
return results;
}
}

View file

View file

@ -0,0 +1,6 @@
name: AOZArrangement
fields:
- name: AOZContentBriefingBNpc
type: link
targets: [AOZContentBriefingBNpc]
- name: Position

7
Schemas/AOZBoss.yml Normal file
View file

@ -0,0 +1,7 @@
name: AOZBoss
displayField: Boss
fields:
- name: Boss
type: link
targets: [AOZContentBriefingBNpc]
- name: Position

41
Schemas/AOZContent.yml Normal file
View file

@ -0,0 +1,41 @@
name: AOZContent
fields:
- name: GilReward
- name: AlliedSealsReward
- name: TomestonesReward
- name: ContentEntry
type: link
targets: [ContentEntry]
- name: StandardFinishTime
- name: IdealFinishTime
- name: Act1
type: link
condition:
switch: Act1FightType
cases:
1: [AOZArrangement]
2: [AOZBoss]
- name: Act2
type: link
condition:
switch: Act2FightType
cases:
1: [AOZArrangement]
2: [AOZBoss]
- name: Act3
type: link
condition:
switch: Act3FightType
cases:
1: [AOZArrangement]
2: [AOZBoss]
- name: Unknown5
- name: Unknown9
- name: Unknown13
- name: Act1FightType
- name: Act2FightType
- name: Act3FightType
- name: ArenaType1
- name: ArenaType2
- name: ArenaType3
- name: Order

View file

@ -0,0 +1,32 @@
name: AOZContentBriefingBNpc
displayField: BNpcName
fields:
- name: BNpcName
type: link
targets: [BNpcName]
- name: TargetSmall
type: icon
- name: TargetLarge
type: icon
- name: Endurance
- name: Fire
- name: Ice
- name: Wind
- name: Earth
- name: Thunder
- name: Water
- name: Slashing
- name: Piercing
- name: Blunt
- name: Magic
- name: HideStats
- name: SlowVuln
- name: PetrificationVuln
- name: ParalysisVuln
- name: InterruptionVuln
- name: BlindVuln
- name: StunVuln
- name: SleepVuln
- name: BindVuln
- name: HeavyVuln
- name: FlatOrDeathVuln

View file

@ -0,0 +1,5 @@
name: AOZContentBriefingObject
fields:
- name: Icon
type: icon
- name: Unknown1

7
Schemas/AOZReport.yml Normal file
View file

@ -0,0 +1,7 @@
name: AOZReport
fields:
- name: Unknown0
- name: Reward
type: link
targets: [AOZReportReward]
- name: Order

7
Schemas/AOZScore.yml Normal file
View file

@ -0,0 +1,7 @@
name: AOZScore
displayField: Name
fields:
- name: Name
- name: Description
- name: Score
- name: IsHidden

62
Schemas/Achievement.yml Normal file
View file

@ -0,0 +1,62 @@
name: Achievement
displayField: Name
fields:
- name: Name
- name: Description
- name: Item
type: link
targets: [Item]
- name: Key
type: link
condition:
switch: Type
cases:
2: [Achievement]
3: [ClassJob]
6: [Quest]
7: [ClassJob]
8: [Map]
9: [Quest]
11: [GrandCompany]
14: [InstanceContent]
15: [BeastTribe]
18: [GrandCompany]
20: [AetherCurrentCompFlgSet]
24: [Quest]
- name: Data
type: array
count: 8
fields:
- type: link
condition:
switch: Type
cases:
2: [Achievement]
6: [Quest]
9: [Quest]
15: [BeastReputationRank]
20: [AetherCurrentCompFlgSet]
24: [ClassJob, Quest]
- name: Title
type: link
targets: [Title]
- name: Icon
type: icon
- name: Order
- name: AchievementCategory
type: link
targets: [AchievementCategory]
- name: AchievementTarget
type: link
targets: [AchievementTarget]
- name: Unknown4
- name: Points
- name: Unknown8
- name: Unknown9
- name: Unknown10
- name: Unknown12
- name: Type
- name: Unknown24
- name: AchievementHideCondition
type: link
targets: [AchievementHideCondition]

View file

@ -0,0 +1,10 @@
name: AchievementCategory
displayField: Name
fields:
- name: Name
- name: AchievementKind
type: link
targets: [AchievementKind]
- name: Order
- name: ShowComplete
- name: HideCategory

View file

@ -0,0 +1,5 @@
name: AchievementHideCondition
fields:
- name: HideAchievement
- name: HideName
- name: HideConditions

View file

@ -0,0 +1,5 @@
name: AchievementKind
displayField: Name
fields:
- name: Name
- name: Order

View file

@ -0,0 +1,5 @@
name: AchievementTarget
displayField: Value
fields:
- name: Value
- name: Type

105
Schemas/Action.yml Normal file
View file

@ -0,0 +1,105 @@
name: Action
displayField: Name
fields:
- name: Name
- name: UnlockLink
type: link
targets: [ChocoboTaxiStand, CraftLeve, CustomTalk, DefaultTalk, FccShop, GCShop, GilShop, GuildleveAssignment, GuildOrderGuide, GuildOrderOfficer, Quest, SpecialShop, Story, SwitchTalk, TopicSelect, TripleTriad, Warp]
- name: Icon
type: icon
- name: VFX
type: link
targets: [ActionCastVFX]
- name: ActionTimelineHit
type: link
targets: [ActionTimeline]
- name: PrimaryCostValue
- name: SecondaryCostValue
type: link
condition:
switch: SecondaryCostType
cases:
32: [Status]
35: [Status]
46: [Status]
- name: ActionCombo
type: link
targets: [Action]
- name: Cast100ms
- name: Recast100ms
- name: StatusGainSelf
type: link
targets: [Status]
- name: Omen
type: link
targets: [Omen]
- name: Unknown54
- name: AnimationEnd
type: link
targets: [ActionTimeline]
- name: ActionCategory
type: link
targets: [ActionCategory]
- name: Unknown4
- name: AnimationStart
type: link
targets: [ActionCastTimeline]
- name: Unknown9
- name: BehaviourType
- name: ClassJobLevel
- name: CastType
- name: EffectRange
- name: XAxisModifier
- name: PrimaryCostType
- name: SecondaryCostType
- name: Unknown38
- name: CooldownGroup
- name: AdditionalCooldownGroup
- name: MaxCharges
- name: Aspect
- name: ActionProcStatus
type: link
targets: [ActionProcStatus]
- name: Unknown46
- name: ClassJobCategory
type: link
targets: [ClassJobCategory]
- name: Unknown50
- name: Unknown64
- name: ClassJob
type: link
targets: [ClassJob]
- name: Range
- name: Unknown24
- name: AttackType
type: link
targets: [AttackType]
- name: Unknown1
- name: IsRoleAction
- name: CanTargetSelf
- name: CanTargetParty
- name: CanTargetFriendly
- name: CanTargetHostile
- name: Unknown19
- name: Unknown20
- name: TargetArea
- name: Unknown22
- name: Unknown23
- name: CanTargetDead
- name: Unknown26
- name: Unknown30
- name: PreservesCombo
- name: Unknown51
- name: AffectsPosition
- name: IsPvP
- name: Unknown56
- name: Unknown57
- name: Unknown58
- name: Unknown59
- name: Unknown60
- name: Unknown61
- name: Unknown62
- name: Unknown63
- name: Unknown65
- name: Unknown66
- name: IsPlayerAction

View file

@ -0,0 +1,9 @@
name: ActionCastTimeline
displayField: Name
fields:
- name: Name
type: link
targets: [ActionTimeline]
- name: VFX
type: link
targets: [VFX]

View file

@ -0,0 +1,6 @@
name: ActionCastVFX
displayField: VFX
fields:
- name: VFX
type: link
targets: [VFX]

View file

@ -0,0 +1,4 @@
name: ActionCategory
displayField: Name
fields:
- name: Name

View file

@ -0,0 +1,15 @@
name: ActionComboRoute
displayField: Name
fields:
- name: Name
- name: Action
type: array
count: 4
fields:
- type: link
targets: [Action]
- name: Unknown6
- name: Unknown7
- name: Unknown8
- name: Unknown1
- name: Unknown9

View file

@ -0,0 +1,12 @@
name: ActionIndirection
displayField: Name
fields:
- name: Name
type: link
targets: [Action]
- name: PreviousComboAction
type: link
targets: [Action]
- name: ClassJob
type: link
targets: [ClassJob]

5
Schemas/ActionParam.yml Normal file
View file

@ -0,0 +1,5 @@
name: ActionParam
displayField: Name
fields:
- name: Name
- name: Unknown1

View file

@ -0,0 +1,6 @@
name: ActionProcStatus
displayField: Status
fields:
- name: Status
type: link
targets: [Status]

View file

@ -0,0 +1,27 @@
name: ActionTimeline
displayField: Key
fields:
- name: Key
- name: KillUpper
- name: Type
- name: Priority
- name: Stance
- name: Slot
- name: LookAtMode
- name: ActionTimelineIDMode
- name: WeaponTimeline
type: link
targets: [WeaponTimeline]
- name: LoadType
- name: StartAttach
- name: ResidentPap
- name: IsLoop
- name: Unknown20
- name: Unknown21
- name: Pause
- name: Resident
- name: IsMotionCanceledByMoving
- name: Unknown15
- name: Unknown17
- name: Unknown18
- name: Unknown19

View file

@ -0,0 +1,8 @@
name: ActionTimelineMove
fields:
- name: Unknown4
- name: Unknown0
- name: Unknown1
- name: Unknown2
- name: Unknown3
- name: Unknown5

View file

@ -0,0 +1,8 @@
name: ActionTimelineReplace
fields:
- name: Old
type: link
targets: [ActionTimeline]
- name: New
type: link
targets: [ActionTimeline]

View file

@ -0,0 +1,4 @@
name: ActionTransient
displayField: Description
fields:
- name: Description

View file

@ -0,0 +1,7 @@
name: ActivityFeedButtons
fields:
- name: BannerURL
- name: Description
- name: Language
- name: PictureURL
- name: Unknown0

View file

@ -0,0 +1,6 @@
name: ActivityFeedCaptions
fields:
- name: JA
- name: EN
- name: DE
- name: FR

View file

@ -0,0 +1,6 @@
name: ActivityFeedGroupCaptions
fields:
- name: JA
- name: EN
- name: DE
- name: FR

View file

@ -0,0 +1,7 @@
name: ActivityFeedImages
fields:
- name: ExpansionImage
- name: ActivityFeedJA
- name: ActivityFeedEN
- name: ActivityFeedDE
- name: ActivityFeedFR

4
Schemas/Addon.yml Normal file
View file

@ -0,0 +1,4 @@
name: Addon
displayField: Text
fields:
- name: Text

26
Schemas/Adventure.yml Normal file
View file

@ -0,0 +1,26 @@
name: Adventure
displayField: Name
fields:
- name: Name
- name: Impression
- name: Description
- name: Level
type: link
targets: [Level]
- name: MinLevel
- name: PlaceName
type: link
targets: [PlaceName]
- name: IconList
type: icon
- name: IconDiscovered
type: icon
- name: IconUndiscovered
type: icon
- name: Emote
type: link
targets: [Emote]
- name: MinTime
- name: MaxTime
- name: MaxLevel
- name: IsInitial

View file

@ -0,0 +1,15 @@
name: AdventureExPhase
fields:
- name: Quest
type: link
targets: [Quest]
- name: AdventureBegin
type: link
targets: [Adventure]
- name: AdventureEnd
type: link
targets: [Adventure]
- name: Unknown4
- name: Expansion
type: link
targets: [ExVersion]

View file

@ -0,0 +1,6 @@
name: AetherCurrent
displayField: Quest
fields:
- name: Quest
type: link
targets: [Quest]

View file

@ -0,0 +1,11 @@
name: AetherCurrentCompFlgSet
fields:
- name: Territory
type: link
targets: [TerritoryType]
- name: AetherCurrents
type: array
count: 15
fields:
- type: link
targets: [AetherCurrent]

View file

@ -0,0 +1,10 @@
name: AetherialWheel
fields:
- name: ItemUnprimed
type: link
targets: [Item]
- name: ItemPrimed
type: link
targets: [Item]
- name: Grade
- name: HoursRequired

39
Schemas/Aetheryte.yml Normal file
View file

@ -0,0 +1,39 @@
name: Aetheryte
displayField: PlaceName
fields:
- name: Singular
- name: Plural
- name: Adjective
- name: PossessivePronoun
- name: StartsWithVowel
- name: Unknown5
- name: Pronoun
- name: Article
- name: Unknown16
- name: Level
type: array
count: 4
fields:
- type: link
targets: [Level]
- name: RequiredQuest
type: link
targets: [Quest]
- name: PlaceName
type: link
targets: [PlaceName]
- name: AethernetName
type: link
targets: [PlaceName]
- name: Territory
type: link
targets: [TerritoryType]
- name: Map
type: link
targets: [Map]
- name: AetherstreamX
- name: AetherstreamY
- name: AethernetGroup
- name: Order
- name: IsAetheryte
- name: Invisible

View file

@ -0,0 +1,5 @@
name: AetheryteSystemDefine
displayField: Text
fields:
- name: Text
- name: DefineValue

View file

@ -0,0 +1,3 @@
name: AetheryteTransient
fields:
- name: Unknown0

View file

@ -0,0 +1,4 @@
name: AirshipExplorationLevel
fields:
- name: ExpToNext
- name: Capacity

View file

@ -0,0 +1,4 @@
name: AirshipExplorationLog
displayField: Text
fields:
- name: Text

View file

@ -0,0 +1,4 @@
name: AirshipExplorationParamType
displayField: Name
fields:
- name: Name

View file

@ -0,0 +1,12 @@
name: AirshipExplorationPart
fields:
- name: Class
- name: Surveillance
- name: Retrieval
- name: Speed
- name: Range
- name: Favor
- name: Slot
- name: Rank
- name: Components
- name: RepairMaterials

View file

@ -0,0 +1,17 @@
name: AirshipExplorationPoint
displayField: Name
fields:
- name: Name
- name: NameShort
- name: ExpReward
- name: CeruleumTankReq
- name: SurveyDurationmin
- name: SurveyDistance
- name: X
- name: Y
- name: RankReq
- name: Unknown9
- name: Unknown11
- name: SurveillanceReq
- name: Unknown12
- name: Passengers

11
Schemas/AkatsukiNote.yml Normal file
View file

@ -0,0 +1,11 @@
name: AkatsukiNote
fields:
- name: Unknown0
- name: Unknown1
- name: Unknown2
- name: Unknown3
- name: Unknown4
- name: Unknown5
- name: Unknown6
- name: Unknown7
- name: Unknown8

View file

@ -0,0 +1,3 @@
name: AkatsukiNoteString
fields:
- name: Unknown0

13
Schemas/AnimaWeapon5.yml Normal file
View file

@ -0,0 +1,13 @@
name: AnimaWeapon5
fields:
- name: Item
type: link
targets: [Item]
- name: Unknown1
- name: SecondaryStatTotal
- name: Parameter
type: array
count: 5
fields:
- type: link
targets: [AnimaWeapon5Param]

View file

@ -0,0 +1,7 @@
name: AnimaWeapon5Param
displayField: BaseParam
fields:
- name: Name
- name: BaseParam
type: link
targets: [BaseParam]

View file

@ -0,0 +1,4 @@
name: AnimaWeapon5PatternGroup
displayField: Name
fields:
- name: Name

View file

@ -0,0 +1,6 @@
name: AnimaWeapon5SpiritTalk
displayField: Dialogue
fields:
- name: Dialogue
type: link
targets: [AnimaWeapon5SpiritTalkParam]

View file

@ -0,0 +1,5 @@
name: AnimaWeapon5SpiritTalkParam
displayField: Prologue
fields:
- name: Prologue
- name: Epilogue

View file

@ -0,0 +1,22 @@
name: AnimaWeapon5TradeItem
fields:
- name: CrystalSand
type: link
targets: [Item]
- name: Item
type: array
count: 8
fields:
- type: link
targets: [ Item ]
- name: Order
- name: ReceiveQuantity
- name: Quantity
type: array
count: 8
- name: Category
type: link
targets: [AnimaWeapon5PatternGroup]
- name: IsHQ
type: array
count: 8

View file

@ -0,0 +1,6 @@
name: AnimaWeaponFUITalk
displayField: Dialogue
fields:
- name: Dialogue
type: link
targets: [AnimaWeaponFUITalkParam]

View file

@ -0,0 +1,5 @@
name: AnimaWeaponFUITalkParam
displayField: Prologue
fields:
- name: Prologue
- name: Epilogue

View file

@ -0,0 +1,12 @@
name: AnimaWeaponIcon
fields:
- name: Hyperconductive
type: icon
- name: Reborn
type: icon
- name: Sharpened
type: icon
- name: Zodiac
type: icon
- name: ZodiacLux
type: icon

View file

@ -0,0 +1,8 @@
name: AnimaWeaponItem
fields:
- name: Item
type: array
count: 14
fields:
- type: link
targets: [Item]

8
Schemas/AnimationLOD.yml Normal file
View file

@ -0,0 +1,8 @@
name: AnimationLOD
fields:
- name: CameraDistance
- name: SampleInterval
- name: BoneLOD
- name: AnimationEnable
type: array
count: 8

7
Schemas/AozAction.yml Normal file
View file

@ -0,0 +1,7 @@
name: AozAction
displayField: Action
fields:
- name: Action
type: link
targets: [Action]
- name: Rank

View file

@ -0,0 +1,34 @@
name: AozActionTransient
displayField: Action
fields:
- name: Stats
- name: Description
- name: Icon
type: icon
- name: RequiredForQuest
type: link
targets: [Quest]
- name: PreviousQuest
type: link
targets: [Quest]
- name: Location
type: link
condition:
switch: LocationKey
cases:
1: [PlaceName]
4: [ContentFinderCondition]
- name: Number
- name: LocationKey
- name: TargetsEnemy
- name: TargetsSelfOrAlly
- name: CauseSlow
- name: CausePetrify
- name: CauseParalysis
- name: CauseInterrupt
- name: CauseBlind
- name: CauseStun
- name: CauseSleep
- name: CauseBind
- name: CauseHeavy
- name: CauseDeath

11
Schemas/AquariumFish.yml Normal file
View file

@ -0,0 +1,11 @@
name: AquariumFish
displayField: Item
fields:
- name: Item
type: link
targets: [Item]
- name: Unknown3
- name: AquariumWater
type: link
targets: [AquariumWater]
- name: Size

View file

@ -0,0 +1,5 @@
name: AquariumWater
displayField: Name
fields:
- name: Name
- name: Unknown0

5
Schemas/ArchiveItem.yml Normal file
View file

@ -0,0 +1,5 @@
name: ArchiveItem
fields:
- name: Unknown0
- name: Unknown1
- name: Unknown2

View file

@ -0,0 +1,8 @@
name: ArrayEventHandler
fields:
- name: Data
type: array
count: 16
fields:
- type: link
targets: [InstanceContentGuide, Story, Opening, CustomTalk, DefaultTalk, GilShop, Warp, Quest]

4
Schemas/AttackType.yml Normal file
View file

@ -0,0 +1,4 @@
name: AttackType
displayField: Name
fields:
- name: Name

7
Schemas/Attract.yml Normal file
View file

@ -0,0 +1,7 @@
name: Attract
fields:
- name: MaxDistance
- name: Speed
- name: MinRemainingDistance
- name: Direction
- name: UseDistanceBetweenHitboxes

10
Schemas/BGM.yml Normal file
View file

@ -0,0 +1,10 @@
name: BGM
displayField: File
fields:
- name: File
- name: DisableRestartResetTime
- name: Priority
- name: SpecialMode
- name: DisableRestartTimeOut
- name: DisableRestart
- name: PassEnd

7
Schemas/BGMFade.yml Normal file
View file

@ -0,0 +1,7 @@
name: BGMFade
fields:
- name: SceneOut
- name: SceneIn
- name: BGMFadeType
type: link
targets: [BGMFadeType]

6
Schemas/BGMFadeType.yml Normal file
View file

@ -0,0 +1,6 @@
name: BGMFadeType
fields:
- name: FadeOutTime
- name: FadeInTime
- name: FadeInStartTime
- name: ResumeFadeInTime

7
Schemas/BGMScene.yml Normal file
View file

@ -0,0 +1,7 @@
name: BGMScene
fields:
- name: EnableDisableRestart
- name: Resume
- name: EnablePassEnd
- name: ForceAutoReset
- name: IgnoreBattle

17
Schemas/BGMSituation.yml Normal file
View file

@ -0,0 +1,17 @@
name: BGMSituation
fields:
- name: DaytimeID
type: link
targets: [BGM]
- name: NightID
type: link
targets: [BGM]
- name: BattleID
type: link
targets: [BGM]
- name: DaybreakID
type: link
targets: [BGM]
- name: TwilightID
type: link
targets: [BGM]

12
Schemas/BGMSwitch.yml Normal file
View file

@ -0,0 +1,12 @@
name: BGMSwitch
fields:
- name: Quest
type: link
targets: [Quest]
- name: BGM
type: link
targets: [BGM, BGMSituation]
- name: BGMSystemDefine
type: link
targets: [BGMSystemDefine]
- name: Unknown2

View file

@ -0,0 +1,4 @@
name: BGMSystemDefine
displayField: Define
fields:
- name: Define

View file

@ -0,0 +1,5 @@
name: BNpcAnnounceIcon
displayField: Icon
fields:
- name: Icon
type: icon

40
Schemas/BNpcBase.yml Normal file
View file

@ -0,0 +1,40 @@
name: BNpcBase
fields:
- name: Scale
- name: ArrayEventHandler
type: link
targets: [ArrayEventHandler]
- name: Behavior
type: link
targets: [Behavior]
- name: ModelChara
type: link
targets: [ModelChara]
- name: BNpcCustomize
type: link
targets: [BNpcCustomize]
- name: NpcEquip
type: link
targets: [NpcEquip]
- name: Special
- name: Battalion
type: link
targets: [Battalion]
- name: LinkRace
type: link
targets: [LinkRace]
- name: Rank
- name: SEPack
- name: Unknown12
- name: BNpcParts
type: link
targets: [BNpcParts]
- name: Unknown14
- name: Unknown19
- name: Unknown20
- name: Unknown21
- name: Unknown10
- name: Unknown15
- name: IsTargetLine
- name: IsDisplayLevel
- name: Unknown18

View file

@ -0,0 +1,3 @@
name: BNpcBasePopVfx
fields:
- name: Unknown0

Some files were not shown because too many files have changed in this diff Show more