diff --git a/Sledge.Formats.Map.Tests/Formats/TestJackhammerFormat.cs b/Sledge.Formats.Map.Tests/Formats/TestJackhammerFormat.cs index 4c42540..a327dfa 100644 --- a/Sledge.Formats.Map.Tests/Formats/TestJackhammerFormat.cs +++ b/Sledge.Formats.Map.Tests/Formats/TestJackhammerFormat.cs @@ -1,40 +1,56 @@ using System; +using System.IO; using System.Linq; using System.Numerics; using Microsoft.VisualStudio.TestTools.UnitTesting; using Sledge.Formats.Map.Formats; using Sledge.Formats.Map.Objects; -namespace Sledge.Formats.Map.Tests.Formats +namespace Sledge.Formats.Map.Tests.Formats; + +[TestClass] +public class TestJackhammerFormat { - [TestClass] - public class TestJackhammerFormat + [TestMethod] + public void TestJmf121() + { + using var file = typeof(TestJackhammerFormat).Assembly.GetManifestResourceStream("Sledge.Formats.Map.Tests.Resources.jmf.default-room-121.jmf"); + var format = new JackhammerJmfFormat(); + var map = format.Read(file); + Assert.AreEqual("worldspawn", map.Worldspawn.ClassName); + } + + [TestMethod] + public void TestJmf122() { - [TestMethod] - public void TestJmf121() - { - using var file = typeof(TestJackhammerFormat).Assembly.GetManifestResourceStream("Sledge.Formats.Map.Tests.Resources.jmf.default-room-121.jmf"); - var format = new JackhammerJmfFormat(); - var map = format.Read(file); - Assert.AreEqual("worldspawn", map.Worldspawn.ClassName); - } + using var file = typeof(TestJackhammerFormat).Assembly.GetManifestResourceStream("Sledge.Formats.Map.Tests.Resources.jmf.default-room-122.jmf"); + var format = new JackhammerJmfFormat(); + var map = format.Read(file); + Assert.AreEqual("worldspawn", map.Worldspawn.ClassName); + Assert.AreEqual(3, map.BackgroundImages.Count); - [TestMethod] - public void TestJmf122() - { - using var file = typeof(TestJackhammerFormat).Assembly.GetManifestResourceStream("Sledge.Formats.Map.Tests.Resources.jmf.default-room-122.jmf"); - var format = new JackhammerJmfFormat(); - var map = format.Read(file); - Assert.AreEqual("worldspawn", map.Worldspawn.ClassName); - Assert.AreEqual(3, map.BackgroundImages.Count); + var front = map.BackgroundImages.Single(x => x.Viewport == ViewportType.OrthographicFront); + Assert.AreEqual("C:/Test/Viewport.png", front.Path); + Assert.IsTrue(Math.Abs(front.Scale - 2.5) < 0.0001); + Assert.AreEqual(175, front.Luminance); + Assert.AreEqual(FilterMode.Linear, front.Filter); + Assert.AreEqual(true, front.InvertColours); + Assert.AreEqual(new Vector2(6, -7), front.Offset); + } - var front = map.BackgroundImages.Single(x => x.Viewport == ViewportType.OrthographicFront); - Assert.AreEqual("C:/Test/Viewport.png", front.Path); - Assert.IsTrue(Math.Abs(front.Scale - 2.5) < 0.0001); - Assert.AreEqual(175, front.Luminance); - Assert.AreEqual(FilterMode.Linear, front.Filter); - Assert.AreEqual(true, front.InvertColours); - Assert.AreEqual(new Vector2(6, -7), front.Offset); - } + [DataTestMethod] + [DataRow("default-room-121", "121")] + [DataRow("default-room-121")] + [DataRow("default-room-122")] + public void TestRoundTrip(string filename, string styleHint = "122") + { + using var inputStream = typeof(TestJackhammerFormat).Assembly.GetManifestResourceStream($"Sledge.Formats.Map.Tests.Resources.jmf.{filename}.jmf"); + var format = new JackhammerJmfFormat(); + var inputMap = format.Read(inputStream); + var outputStream = new MemoryStream(); + format.Write(outputStream, inputMap, styleHint); + outputStream.Position = 0; + var outputMap = format.Read(outputStream); + Assert.IsTrue(TestUtils.AreEqualMap(inputMap, outputMap)); } } \ No newline at end of file diff --git a/Sledge.Formats.Map.Tests/TestUtils.cs b/Sledge.Formats.Map.Tests/TestUtils.cs new file mode 100644 index 0000000..bbd95a6 --- /dev/null +++ b/Sledge.Formats.Map.Tests/TestUtils.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Sledge.Formats.Map.Objects; + +namespace Sledge.Formats.Map.Tests; + +public static class TestUtils +{ + public static bool AreEqualMap(MapFile a, MapFile b) + { + if (!AreEqualMapObject(a.Worldspawn, b.Worldspawn)) return false; + // todo: visgroups, paths, cameras, additionalobjects, cordonbounds, backgroundimages + return true; + } + + public static bool AreEqualMapObject(MapObject a, MapObject b) + { + return a switch + { + Worldspawn aw => b is Worldspawn bw && AreEqualWorldspawn(aw, bw), + Entity ae => b is Entity be && AreEqualEntity(ae, be), + Group ag => b is Group bg && AreEqualGroup(ag, bg), + Solid al => b is Solid bl && AreEqualSolid(al, bl), + _ => false + }; + } + + public static bool AreEqualBase(MapObject a, MapObject b) + { + // color + if (!a.Color.Equals(b.Color)) return false; + + // visgroups + if (a.Visgroups.Count != b.Visgroups.Count) return false; + var diff = a.Visgroups.ToHashSet(); + diff.SymmetricExceptWith(b.Visgroups); + if (diff.Count > 0) return false; + + // children + if (a.Children.Count != b.Children.Count) return false; + var bChildren = b.Children.ToList(); + foreach (var ac in a.Children) + { + var matching = b.Children.FirstOrDefault(bc => AreEqualMapObject(ac, bc)); + if (matching == null) return false; + bChildren.Remove(matching); + } + + return bChildren.Count == 0; + } + + public static bool AreEqualWorldspawn(Worldspawn a, Worldspawn b) + { + return AreEqualEntity(a, b); + } + + public static bool AreEqualEntity(Entity a, Entity b) + { + if (!AreEqualBase(a, b)) return false; + if (a.ClassName != b.ClassName) return false; + if (a.SpawnFlags != b.SpawnFlags) return false; + if (a.Properties.Count != b.Properties.Count) return false; + var keys = b.Properties.Keys.ToList(); + foreach (var k in a.Properties.Keys) + { + var av = a.Properties.TryGetValue(k, out var avo) ? avo ?? "" : ""; + var bv = b.Properties.TryGetValue(k, out var bvo) ? bvo ?? "" : ""; + keys.Remove(k); + if (av != bv) return false; + } + + return keys.Count == 0; + } + + public static bool AreEqualGroup(Group a, Group b) + { + return AreEqualBase(a, b); + } + + public static bool AreEqualSolid(Solid a, Solid b) + { + if (!AreEqualBase(a, b)) return false; + + if (a.Faces.Count != b.Faces.Count) return false; + var bFaces = b.Faces.ToList(); + foreach (var af in a.Faces) + { + var matching = b.Faces.FirstOrDefault(bf => AreEqualFace(af, bf)); + if (matching == null) return false; + bFaces.Remove(matching); + } + + if (a.Meshes.Count != b.Meshes.Count) return false; + var bMeshes = b.Meshes.ToList(); + foreach (var am in a.Meshes) + { + var matching = b.Meshes.FirstOrDefault(bm => AreEqualMesh(am, bm)); + if (matching == null) return false; + bMeshes.Remove(matching); + } + + return bFaces.Count == 0 && bMeshes.Count == 0; + } + + public static bool AreEqualSurface(Surface a, Surface b) + { + return a.TextureName == b.TextureName + && a.UAxis == b.UAxis + && a.VAxis == b.VAxis + && Math.Abs(a.XScale - b.XScale) < float.Epsilon + && Math.Abs(a.YScale - b.YScale) < float.Epsilon + && Math.Abs(a.XShift - b.XShift) < float.Epsilon + && Math.Abs(a.YShift - b.YShift) < float.Epsilon + && Math.Abs(a.Rotation - b.Rotation) < float.Epsilon + && a.ContentFlags == b.ContentFlags + && a.SurfaceFlags == b.SurfaceFlags + && Math.Abs(a.Value - b.Value) < float.Epsilon + && Math.Abs(a.LightmapScale - b.LightmapScale) < float.Epsilon + && a.SmoothingGroups == b.SmoothingGroups; + } + + public static bool AreEqualFace(Face a, Face b) + { + if (!AreEqualSurface(a, b)) return false; + if (a.Plane != b.Plane) return false; + if (a.Vertices.Count != b.Vertices.Count) return false; + + var bIdx = b.Vertices.IndexOf(a.Vertices[0]); + if (bIdx < 0) return false; + for (var i = 0; i < a.Vertices.Count; i++) + { + var av = a.Vertices[i]; + var bv = b.Vertices[(i + bIdx) % a.Vertices.Count]; + if (av != bv) return false; + } + return true; + } + + public static bool AreEqualMesh(Mesh a, Mesh b) + { + if (!AreEqualSurface(a, b)) return false; + if (a.Width != b.Width) return false; + if (a.Height != b.Height) return false; + if (a.Points.Count != b.Points.Count) return false; + + for (var i = 0; i < a.Points.Count; i++) + { + var ap = a.Points[i]; + var bp = b.Points.FirstOrDefault(x => x.X == ap.X && x.Y == ap.Y); + if (bp == null) return false; + if (!AreEqualMeshPoint(ap, bp)) return false; + } + + return true; + } + + public static bool AreEqualMeshPoint(MeshPoint a, MeshPoint b) + { + return a.X == b.X + && a.Y == b.Y + && a.Position == b.Position + && a.Normal == b.Normal + && a.Texture == b.Texture; + } +} \ No newline at end of file diff --git a/Sledge.Formats.Map/Formats/JackhamerJmfFormat.cs b/Sledge.Formats.Map/Formats/JackhamerJmfFormat.cs index 83d48b2..7984fa8 100644 --- a/Sledge.Formats.Map/Formats/JackhamerJmfFormat.cs +++ b/Sledge.Formats.Map/Formats/JackhamerJmfFormat.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Drawing; +using System.Globalization; using System.IO; using System.Linq; using System.Numerics; @@ -18,7 +19,7 @@ public class JackhammerJmfFormat : IMapFormat public string ApplicationName => "JACK"; public string Extension => "jmf"; public string[] AdditionalExtensions => new[] { "jmx" }; - public string[] SupportedStyleHints => new[] { "" }; + public string[] SupportedStyleHints => new[] { "121", "122" }; public MapFile Read(Stream stream) { @@ -451,17 +452,24 @@ FilterMode ConvertFilterMode(int num) public void Write(Stream stream, MapFile map, string styleHint) { + if (!String.IsNullOrWhiteSpace(styleHint) && styleHint != "121" && styleHint != "122") + { + throw new NotImplementedException("Only saving v121 and v122 JMFs is supported."); + } + + var version = String.IsNullOrWhiteSpace(styleHint) || !int.TryParse(styleHint, out var v) ? 121 : v; + using (var bw = new BinaryWriter(stream, Encoding.ASCII, true)) { bw.WriteFixedLengthString(Encoding.ASCII, 4, "JHMF"); // Header - - bw.Write(121); // Version + bw.Write(version); // Version bw.Write(0); // Num export strings (we don't have this information) // No export strings here var groups = CreateGroups(map); + if (version >= 122) WriteBackgroundImages(map, bw); WriteGroups(groups.Values, bw); WriteVisgroups(map, bw); bw.WriteVector3(map.CordonBounds.min); @@ -470,7 +478,6 @@ public void Write(Stream stream, MapFile map, string styleHint) WritePaths(map, bw); WriteEntities(map, groups, bw); } - throw new NotImplementedException(); } private Dictionary CreateGroups(MapFile map) @@ -555,24 +562,269 @@ private void WritePaths(MapFile map, BinaryWriter bw) private void WritePath(Path path, BinaryWriter bw) { - throw new NotImplementedException(); + WriteString(path.Type, bw); + WriteString(path.Name, bw); + bw.Write((int) path.Direction); + bw.Write(path.Flags); + bw.WriteRGBAColour(path.Color); + + bw.Write(path.Nodes.Count); + foreach (var node in path.Nodes) + { + WriteString(node.Name, bw); + + var fire = node.Properties.TryGetValue("message", out var f) ? f ?? "" : ""; + WriteString(fire, bw); + + bw.WriteVector3(node.Position); + + var angleVector = Vector3.Zero; + var angles = node.Properties.TryGetValue("angles", out var a) ? a ?? "" : ""; + var angleSplit = angles.Split(' '); + if (angleSplit.Length == 3) + { + angleVector = NumericsExtensions.TryParse(angleSplit[0], angleSplit[1], angleSplit[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var av) + ? av + : Vector3.Zero; + } + bw.WriteVector3(angleVector); + + var spawnflags = node.Properties.TryGetValue("spawnflags", out f) ? f ?? "" : ""; + var spawnflagsInt = int.TryParse(spawnflags, out var sf) ? sf : 0; + bw.Write(spawnflagsInt); + + bw.WriteRGBAColour(node.Color); + + bw.Write(node.Properties.Count); + foreach (var p in node.Properties) + { + WriteString(p.Key, bw); + WriteString(p.Value ?? "", bw); + } + } + } + + private class EntityGroupRef + { + public Group RootGroup { get; set; } + public Group Group { get; set; } + public MapObject MapObject { get; set; } + + public EntityGroupRef(Group rootGroup, Group group, MapObject mapObject) + { + RootGroup = rootGroup; + Group = group; + MapObject = mapObject; + } } private void WriteEntities(MapFile map, Dictionary groups, BinaryWriter bw) { - throw new NotImplementedException(); + var list = new Queue(); + list.Enqueue(new EntityGroupRef(null, null, map.Worldspawn)); + while (list.Count > 0) + { + var egr = list.Dequeue(); + var rtt = egr.RootGroup; + var grp = egr.Group; + var obj = egr.MapObject; + if (obj is Entity ent) + { + var groupId = GetGroupID(groups, grp); + var rootGroupId = GetGroupID(groups, rtt); + + WriteString(ent.ClassName, bw); + bw.WriteVector3(ent.GetVectorProperty("origin", Vector3.Zero)); + bw.Write(0); // flags + bw.Write(groupId); + bw.Write(rootGroupId); + bw.WriteRGBAColour(ent.Color); + + // useless (?) list of 13 strings + for (var i = 0; i < 13; i++) WriteString("", bw); + + bw.Write(ent.SpawnFlags); + + // special properties + bw.WriteVector3(ent.GetVectorProperty("angles", Vector3.Zero)); + bw.Write((int) JmfRendering.Normal); // ? + bw.WriteRGBAColour(ent.GetColorProperty("rendercolor", Color.White)); + bw.Write(ent.GetIntProperty("rendermode", 0)); + bw.Write(ent.GetIntProperty("renderfx", 0)); + bw.Write((short) ent.GetIntProperty("body", 0)); + bw.Write((short) ent.GetIntProperty("skin", 0)); + bw.Write(ent.GetIntProperty("sequence", 0)); + bw.Write(ent.GetFloatProperty("framerate", 0)); + bw.Write(ent.GetFloatProperty("scale", 1)); + bw.Write(ent.GetFloatProperty("radius", 0)); + + bw.Write(new byte[28]); // unknown + + bw.Write(ent.SortedProperties.Count); + foreach (var p in ent.SortedProperties) + { + WriteString(p.Key, bw); + WriteString(p.Value, bw); + } + + bw.Write(ent.Visgroups.Count); + foreach (var visgroupId in ent.Visgroups) + { + bw.Write(visgroupId); + } + + var solids = ent.FindAll().OfType().ToList(); + bw.Write(solids.Count); + foreach (var solid in solids) + { + WriteSolid(solid, groupId, rootGroupId, bw); + } + } + + var newGrp = grp; + var newRtt = rtt; + if (obj is Group g && groups.ContainsKey(g)) + { + // if this is a group and it's in the collection (i.e. valid for this format), update the group + newGrp = g; + newRtt = newRtt ?? g; // only update the root group if the current root group is null + } + foreach (var ch in obj.Children) + { + list.Enqueue(new EntityGroupRef(newRtt, newGrp, ch)); + } + } + } + + private int GetGroupID(Dictionary groups, Group grp) + { + if (grp == null) return 0; + if (!groups.ContainsKey(grp)) return 0; + return groups[grp].ID; } - private void WriteSolid(Solid solid, Dictionary groups, BinaryWriter bw) + private void WriteSolid(Solid solid, int groupId, int rootGroupId, BinaryWriter bw) { + bw.Write(solid.Meshes.Count); + bw.Write(0); // flags + bw.Write(groupId); + bw.Write(rootGroupId); + bw.WriteRGBAColour(solid.Color); + bw.Write(solid.Visgroups.Count); + foreach (var visgroupId in solid.Visgroups) + { + bw.Write(visgroupId); + } + + bw.Write(solid.Faces.Count); + foreach (var face in solid.Faces) + { + WriteFace(face, bw); + } + + foreach (var mesh in solid.Meshes) + { + WritePatch(mesh, bw); + } } private void WriteFace(Face face, BinaryWriter bw) { + bw.Write(0); // render flags + + bw.Write(face.Vertices.Count); + WriteSurfaceProperties(face, bw); + + bw.WriteVector3(face.Plane.Normal); + bw.Write(-face.Plane.D); + + bw.Write(0); // something 2 + + foreach (var v in face.Vertices) + { + bw.WriteVector3(v); + bw.WriteVector3(Vector3.Zero); // texture coords + } + } + + private void WritePatch(Mesh mesh, BinaryWriter bw) + { + bw.Write(mesh.Width); + bw.Write(mesh.Height); + WriteSurfaceProperties(mesh, bw); + bw.Write(0); // something + for (var i = 0; i < 32; i++) + { + for (var j = 0; j < 32; j++) + { + var point = mesh.Points.FirstOrDefault(p => p.X == i && p.Y == j) + ?? new MeshPoint(); + bw.WriteVector3(point.Position); + bw.WriteVector3(point.Normal); + bw.WriteVector3(point.Texture); + } + } } + private void WriteSurfaceProperties(Surface surface, BinaryWriter bw) + { + bw.WriteVector3(surface.UAxis); + bw.Write(surface.XShift); + bw.WriteVector3(surface.VAxis); + bw.Write(surface.YShift); + bw.Write(surface.XScale); + bw.Write(surface.YScale); + bw.Write(surface.Rotation); + + bw.Write(0); // something 1 + bw.Write(0); // something 2 + bw.Write(0); // something 3 + bw.Write(0); // something 4 + + bw.Write(surface.SurfaceFlags); + bw.WriteFixedLengthString(Encoding.ASCII, 64, surface.TextureName); + } + + private static void WriteBackgroundImages(MapFile map, BinaryWriter bw) + { + var viewports = new[] + { + ViewportType.OrthographicFront, + ViewportType.OrthographicSide, + ViewportType.OrthographicTop + }; + foreach (var vp in viewports) + { + var bgi = map.BackgroundImages.FirstOrDefault(x => x.Viewport == vp) ?? new BackgroundImage + { + Viewport = vp, + Path = "", + Scale = 1, + Luminance = byte.MaxValue, + Filter = FilterMode.Linear, + Offset = Vector2.Zero, + InvertColours = false + }; + WriteString(bgi.Path, bw); + bw.Write(bgi.Scale); + bw.Write((int) bgi.Luminance); + bw.Write(ConvertFilterMode(bgi.Filter)); + bw.Write(bgi.InvertColours ? 1 : 0); + bw.Write((int) bgi.Offset.X); + bw.Write((int) bgi.Offset.Y); + bw.Write(new byte[4]); // padding + } + + return; + + int ConvertFilterMode(FilterMode mode) + { + if (mode == FilterMode.Nearest) return 0; + return 1; + } + } private static string ReadString(BinaryReader br) { diff --git a/Sledge.Formats.Map/Objects/Entity.cs b/Sledge.Formats.Map/Objects/Entity.cs index 37ad723..af03838 100644 --- a/Sledge.Formats.Map/Objects/Entity.cs +++ b/Sledge.Formats.Map/Objects/Entity.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Drawing; using System.Globalization; using System.Numerics; @@ -58,6 +59,12 @@ public Vector4 GetVector4Property(string key, Vector4 defaultValue) return new Vector4(x, y, z, w); } + public Color GetColorProperty(string key, Color defaultValue) + { + var vv = GetVector4Property(key, new Vector4(defaultValue.R, defaultValue.G, defaultValue.B, defaultValue.A)); + return Color.FromArgb((int)vv.W, (int)vv.X, (int)vv.Y, (int)vv.Z); + } + public T GetProperty(string key, T defaultValue) { if (!Properties.ContainsKey(key)) return defaultValue; diff --git a/Sledge.Formats.Map/Sledge.Formats.Map.csproj b/Sledge.Formats.Map/Sledge.Formats.Map.csproj index 89da24b..deb016d 100644 --- a/Sledge.Formats.Map/Sledge.Formats.Map.csproj +++ b/Sledge.Formats.Map/Sledge.Formats.Map.csproj @@ -11,8 +11,8 @@ https://github.com/LogicAndTrick/sledge-formats Git half-life quake valve hammer worldcraft jackhammer jack rmf vmf map jmf - Support RMF version 0.8, 0.9, 1.4, and support writing prefab libraries - 1.1.6 + Support for writing JMF files + 1.2.0 full true