From 8d884d46b9637f7a0d6e5796817a28fa7273cb9d Mon Sep 17 00:00:00 2001 From: memoryoff <463570478@qq.com> Date: Thu, 13 Aug 2026 23:00:55 +0800 Subject: [PATCH] Parse light shadow intensity in MDX format version 1200+ --- src/War3Net.Modeling/BinaryModelParser.cs | 15 ++ src/War3Net.Modeling/DataStructures/Light.cs | 5 + src/War3Net.Modeling/Enums/FormatVersion.cs | 1 + .../BinaryModelParserTests.cs | 170 ++++++++++++++++++ tests/War3Net.Modeling.Tests/GlobalUsings.cs | 9 + 5 files changed, 200 insertions(+) create mode 100644 tests/War3Net.Modeling.Tests/BinaryModelParserTests.cs create mode 100644 tests/War3Net.Modeling.Tests/GlobalUsings.cs diff --git a/src/War3Net.Modeling/BinaryModelParser.cs b/src/War3Net.Modeling/BinaryModelParser.cs index 15b76020..0aa308e1 100644 --- a/src/War3Net.Modeling/BinaryModelParser.cs +++ b/src/War3Net.Modeling/BinaryModelParser.cs @@ -14,6 +14,12 @@ public static class BinaryModelParser private static readonly Dictionary _ribbonEmitterProperties = GetRibbonEmitterProperties().ToDictionary(property => property.Tag); private static readonly Dictionary _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) @@ -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(); @@ -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; } @@ -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(); diff --git a/src/War3Net.Modeling/DataStructures/Light.cs b/src/War3Net.Modeling/DataStructures/Light.cs index 73914371..6c539f55 100644 --- a/src/War3Net.Modeling/DataStructures/Light.cs +++ b/src/War3Net.Modeling/DataStructures/Light.cs @@ -22,6 +22,11 @@ public Light(string name) public float AmbientIntensity { get; set; } + /// + /// Gets or sets the light's shadow intensity (format version 1200+ only). + /// + public float ShadowIntensity { get; set; } + public AnimationChannel? AttenuationStarts { get; set; } public AnimationChannel? AttenuationEnds { get; set; } diff --git a/src/War3Net.Modeling/Enums/FormatVersion.cs b/src/War3Net.Modeling/Enums/FormatVersion.cs index cf277bd2..16755aec 100644 --- a/src/War3Net.Modeling/Enums/FormatVersion.cs +++ b/src/War3Net.Modeling/Enums/FormatVersion.cs @@ -5,5 +5,6 @@ public enum FormatVersion Classic = 800, ReforgedBeta = 900, Reforged = 1000, + Reforged2 = 1200, } } \ No newline at end of file diff --git a/tests/War3Net.Modeling.Tests/BinaryModelParserTests.cs b/tests/War3Net.Modeling.Tests/BinaryModelParserTests.cs new file mode 100644 index 00000000..73fbf27a --- /dev/null +++ b/tests/War3Net.Modeling.Tests/BinaryModelParserTests.cs @@ -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; + } + } +} diff --git a/tests/War3Net.Modeling.Tests/GlobalUsings.cs b/tests/War3Net.Modeling.Tests/GlobalUsings.cs new file mode 100644 index 00000000..3c92b86a --- /dev/null +++ b/tests/War3Net.Modeling.Tests/GlobalUsings.cs @@ -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;