Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/War3Net.Modeling/BinaryModelParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ public static class BinaryModelParser
private static readonly Dictionary<int, OptionalModelProperty> _ribbonEmitterProperties = GetRibbonEmitterProperties().ToDictionary(property => property.Tag);
private static readonly Dictionary<int, OptionalModelProperty> _cameraProperties = GetCameraProperties().ToDictionary(property => property.Tag);

// Format version of the model currently being parsed (from the VERS chunk), used to
// read version-specific fields (e.g. light shadow intensity in format version 1200+).
// ThreadStatic so concurrent parses don't interfere with each other.
[ThreadStatic]
private static int _parseVersion;

public static bool IsBinaryModel(Stream input)
{
if (input is null)
Expand Down Expand Up @@ -47,6 +53,7 @@ public static Model Parse(Stream input, bool leaveOpen)
}

var boxedModel = RuntimeHelpers.GetObjectValue(default(Model));
_parseVersion = 0;
while (reader.PeekChar() != -1)
{
var chunkTag = reader.ReadInt32();
Expand Down Expand Up @@ -81,6 +88,7 @@ private static ModelVersion ParseVersion(BinaryReader reader)

var modelVersion = new ModelVersion();
modelVersion.FormatVersion = (FormatVersion)reader.ReadUInt32();
_parseVersion = (int)modelVersion.FormatVersion;

return modelVersion;
}
Expand Down Expand Up @@ -614,6 +622,13 @@ private static Light ParseLight(BinaryReader reader)
light.AmbientColor = ParseVector3(reader);
light.AmbientIntensity = reader.ReadSingle();

// Format version 1200+ (Reforged 2.0) adds a shadow intensity field after the fixed
// light header. Skipping it would misread its value as an optional animation tag.
if (_parseVersion >= 1200)
{
light.ShadowIntensity = reader.ReadSingle();
}

while (reader.BaseStream.Position < lightEnd)
{
var optionalTag = reader.ReadInt32();
Expand Down
5 changes: 5 additions & 0 deletions src/War3Net.Modeling/DataStructures/Light.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public Light(string name)

public float AmbientIntensity { get; set; }

/// <summary>
/// Gets or sets the light's shadow intensity (format version 1200+ only).
/// </summary>
public float ShadowIntensity { get; set; }

public AnimationChannel<uint>? AttenuationStarts { get; set; }

public AnimationChannel<uint>? AttenuationEnds { get; set; }
Expand Down
1 change: 1 addition & 0 deletions src/War3Net.Modeling/Enums/FormatVersion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ public enum FormatVersion
Classic = 800,
ReforgedBeta = 900,
Reforged = 1000,
Reforged2 = 1200,
}
}
170 changes: 170 additions & 0 deletions tests/War3Net.Modeling.Tests/BinaryModelParserTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
namespace War3Net.Modeling.Tests
{
[TestClass]
public sealed class BinaryModelParserTests
{
// Minimal MDX with a VERS chunk (format version 1200) and a LITE chunk that includes
// the shadow intensity field introduced in format version 1200 (Reforged 2.0).
// Layout follows HiveWE mdx_reader.cpp read_LITE:
// LITE size (includes its own 4 bytes)
// node size (includes its own 4 bytes) + name(80) + objectId + parentId + flags
// type + attenuationStart + attenuationEnd + color(3) + intensity
// + ambientColor(3) + ambientIntensity + shadowIntensity(v1200+)
[TestMethod]
public void ParseModelWithV1200Light()
{
const float shadowIntensity = 0.3f;
var bytes = CreateModelWithLight(1200, shadowIntensity);

using var stream = new MemoryStream(bytes);
var model = BinaryModelParser.Parse(stream, false);

Assert.IsNotNull(model.Version);
Assert.AreEqual(FormatVersion.Reforged2, model.Version.Value.FormatVersion);
Assert.IsNotNull(model.Lights);
Assert.AreEqual(1, model.Lights.Length);
Assert.AreEqual("Omni01", model.Lights[0].Name);
Assert.AreEqual(shadowIntensity, model.Lights[0].ShadowIntensity);
}

// Format versions before 1200 don't have the shadow intensity field; parsing must not
// consume the following optional animation tag as if it were a shadow intensity value.
[TestMethod]
public void ParseModelWithV800Light()
{
var bytes = CreateModelWithLightAndVisibilityAnimation(800);

using var stream = new MemoryStream(bytes);
var model = BinaryModelParser.Parse(stream, false);

Assert.IsNotNull(model.Lights);
Assert.AreEqual(1, model.Lights.Length);
Assert.AreEqual(0f, model.Lights[0].ShadowIntensity);
}

private static byte[] CreateModelWithLightAndVisibilityAnimation(int formatVersion)
{
using var stream = new MemoryStream();
using (var writer = new BinaryWriter(stream, Encoding.UTF8, true))
{
WriteModelHeader(writer, formatVersion);

// LITE chunk
writer.Write("LITE".FromRawcode());
var liteSizePos = stream.Position;
writer.Write(0); // patched below

var lightSizePos = stream.Position;
writer.Write(0); // light inclusive size, patched below
WriteLightHeader(writer, "Omni01");

// Optional animation tag after the fixed light header. For format versions < 1200
// this tag immediately follows ambient intensity; a buggy parser that reads a
// shadow intensity field here would consume the tag and fail.
writer.Write("KLAV".FromRawcode());
writer.Write(1); // key count
writer.Write(0); // interpolation type
writer.Write(uint.MaxValue); // global sequence id
writer.Write(0); // frame
writer.Write(1f); // value

var lightEnd = stream.Position;
PatchInt32(stream, lightSizePos, (int)(lightEnd - lightSizePos));

var liteEnd = stream.Position;
PatchInt32(stream, liteSizePos, (int)(liteEnd - liteSizePos - 4));
}

return stream.ToArray();
}

private static byte[] CreateModelWithLight(int formatVersion, float shadowIntensity)
{
using var stream = new MemoryStream();
using (var writer = new BinaryWriter(stream, Encoding.UTF8, true))
{
WriteModelHeader(writer, formatVersion);

// LITE chunk
writer.Write("LITE".FromRawcode());
var liteSizePos = stream.Position;
writer.Write(0); // patched below

var lightSizePos = stream.Position;
writer.Write(0); // light inclusive size, patched below
WriteLightHeader(writer, "Omni01");

if (formatVersion >= 1200)
{
writer.Write(shadowIntensity);
}

var lightEnd = stream.Position;
PatchInt32(stream, lightSizePos, (int)(lightEnd - lightSizePos));

var liteEnd = stream.Position;
PatchInt32(stream, liteSizePos, (int)(liteEnd - liteSizePos - 4));
}

return stream.ToArray();
}

private static void WriteModelHeader(BinaryWriter writer, int formatVersion)
{
writer.Write("MDLX".FromRawcode());

// VERS chunk
writer.Write("VERS".FromRawcode());
writer.Write(4);
writer.Write(formatVersion);
}

private static void WriteLightHeader(BinaryWriter writer, string name)
{
// Node
var nodeSizePos = writer.BaseStream.Position;
writer.Write(0); // node size, patched below
WriteFixedString(writer, name, 80);
writer.Write(0); // objectId
writer.Write(-1); // parentId
writer.Write(0x200); // flags: HiveWE Node::Flags::light
var nodeEnd = writer.BaseStream.Position;
PatchInt32(writer.BaseStream, nodeSizePos, (int)(nodeEnd - nodeSizePos));

// Light fixed fields
writer.Write(0); // type: Omni
writer.Write(80f); // attenuationStart
writer.Write(200f); // attenuationEnd
writer.Write(1f); // color.x
writer.Write(1f); // color.y
writer.Write(1f); // color.z
writer.Write(4f); // intensity
writer.Write(1f); // ambientColor.x
writer.Write(1f); // ambientColor.y
writer.Write(1f); // ambientColor.z
writer.Write(0f); // ambientIntensity
}

private static void WriteFixedString(BinaryWriter writer, string value, int length)
{
var bytes = Encoding.UTF8.GetBytes(value);
writer.Write(bytes);
for (var i = bytes.Length; i < length; i++)
{
writer.Write((byte)0);
}
}

private static void PatchInt32(MemoryStream stream, long position, int value)
{
var oldPosition = stream.Position;
stream.Position = position;
using (var writer = new BinaryWriter(stream, Encoding.UTF8, true))
{
writer.Write(value);
}

stream.Position = oldPosition;
}
}
}
9 changes: 9 additions & 0 deletions tests/War3Net.Modeling.Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;
global using System.Text;
global using Microsoft.VisualStudio.TestTools.UnitTesting;
global using War3Net.Common.Extensions;
global using War3Net.Modeling.DataStructures;
global using War3Net.Modeling.Enums;