diff --git a/VisualPinball.Engine.Test/Fixtures~/FuturePinball/README.md b/VisualPinball.Engine.Test/Fixtures~/FuturePinball/README.md new file mode 100644 index 000000000..8f17920bb --- /dev/null +++ b/VisualPinball.Engine.Test/Fixtures~/FuturePinball/README.md @@ -0,0 +1,22 @@ +# Future Pinball integration fixtures + +The Future Pinball integration tests use five historical Three Angels tables as an external corpus. The table files are intentionally not committed because of their size and redistribution status. + +Set `VPE_FPT_FIXTURES` to the directory containing this layout before running the tests: + +```text +fp-2008-1.666/3ANGELS.fpt +fp-2012-installed/3 Angels.fpt +fp-2012-ultra-release/Three Angels_ehanc_Slam.fpt +fp-2012-ultra-source/Three Angels.fpt +fp-2013-enhanced/Three Angels_ehanc_Slam.fpt +``` + +`FuturePinballFixtureCatalog` locks the source size and SHA-256, compound-directory entry and resource counts, element-type distribution, Table Data size, and compressed/decoded script boundaries and hashes. Tests skip this external corpus when the environment variable is absent; unit tests that synthesize malformed streams remain self-contained. + +For the workspace research corpus: + +```powershell +$env:VPE_FPT_FIXTURES='E:\_vpe-2025\_analysis\three-angels-fp' +dotnet test VisualPinball.Engine.Test/VisualPinball.Engine.Test.csproj --filter FuturePinball +``` diff --git a/VisualPinball.Engine.Test/IO/FuturePinball.meta b/VisualPinball.Engine.Test/IO/FuturePinball.meta new file mode 100644 index 000000000..6873efc40 --- /dev/null +++ b/VisualPinball.Engine.Test/IO/FuturePinball.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0f6a36cbf22a4d4683754a62dce12056 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballColliderTests.cs b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballColliderTests.cs new file mode 100644 index 000000000..488fa7340 --- /dev/null +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballColliderTests.cs @@ -0,0 +1,170 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System.Linq; + +using NUnit.Framework; + +using VisualPinball.Engine.IO.FuturePinball; +using VisualPinball.Engine.Math; +using VisualPinball.Engine.VPT; + +namespace VisualPinball.Engine.Test.IO.FuturePinball +{ + [TestFixture] + public class FuturePinballColliderTests + { + [Test] + public void ConvertsDocumentedAnalyticShapesToModelWorldSpace() + { + var colliders = FuturePinballColliderBuilder.FromShapes(new[] { + new FuturePinballCollisionShape(1, true, 10f, 20f, 30f, 5f, 12f), + new FuturePinballCollisionShape(2, true, 0f, 0f, 0f, 8f), + new FuturePinballCollisionShape(3, true, 0f, 4f, 10f, 6f, 3f, 40f, 2f), + new FuturePinballCollisionShape(5, true, 0f, 0f, 0f, 2f, 3f, 4f), + new FuturePinballCollisionShape(7, true, 0f, 0f, 0f, 5f, 12f, 6f) + }); + + Assert.That(colliders.Select(collider => collider.Kind), Is.EqualTo(new[] { + FuturePinballColliderKind.VerticalCylinder, + FuturePinballColliderKind.Sphere, + FuturePinballColliderKind.TaperedCapsule, + FuturePinballColliderKind.Box, + FuturePinballColliderKind.HorizontalCylinder + })); + Assert.That(colliders.All(collider => collider.Status == FuturePinballColliderStatus.Generated), Is.True); + Assert.That(colliders[0].Center.X, Is.EqualTo(0.01f).Within(0.000001f)); + Assert.That(colliders[0].Center.Y, Is.EqualTo(0.02f).Within(0.000001f)); + Assert.That(colliders[0].Center.Z, Is.EqualTo(-0.03f).Within(0.000001f)); + Assert.That(colliders[0].Radius, Is.EqualTo(0.005f).Within(0.000001f)); + Assert.That(colliders[1].Radius, Is.EqualTo(0.008f).Within(0.000001f)); + Assert.That(colliders[2].Center.Z, Is.EqualTo(0.01f).Within(0.000001f)); + Assert.That(colliders[2].SecondaryRadius, Is.EqualTo(0.003f).Within(0.000001f)); + Assert.That(colliders[3].Size.X, Is.EqualTo(0.004f).Within(0.000001f)); + Assert.That(colliders[3].Size.Y, Is.EqualTo(0.006f).Within(0.000001f)); + Assert.That(colliders[3].Size.Z, Is.EqualTo(0.008f).Within(0.000001f)); + Assert.That(colliders[4].SecondaryRadius, Is.EqualTo(0.006f).Within(0.000001f)); + } + + [Test] + public void ReportsNonPhysicalUnknownAndInvalidShapes() + { + var colliders = FuturePinballColliderBuilder.FromShapes(new[] { + new FuturePinballCollisionShape(1, false, 0f, 0f, 0f, 1f, 1f), + new FuturePinballCollisionShape(6, true, 0f, 0f, 0f, 1f), + new FuturePinballCollisionShape(2, true, 0f, 0f, 0f, float.NaN) + }); + + Assert.That(colliders[0].Status, Is.EqualTo(FuturePinballColliderStatus.Skipped)); + Assert.That(colliders[1].Status, Is.EqualTo(FuturePinballColliderStatus.Unsupported)); + Assert.That(colliders[2].Status, Is.EqualTo(FuturePinballColliderStatus.Invalid)); + } + + [Test] + public void BuildsPerPolygonMeshAndRejectsDegenerateTriangles() + { + var mesh = new Mesh(new[] { + new Vertex3DNoTex2(0f, 0f, 0f), + new Vertex3DNoTex2(100f, 0f, 0f), + new Vertex3DNoTex2(0f, 0f, 100f), + new Vertex3DNoTex2(5f, 5f, 5f) + }, new[] { 0, 1, 2, 3, 3, 3 }); + + var collider = FuturePinballColliderBuilder.FromMesh(new[] { mesh }); + + Assert.That(collider.Status, Is.EqualTo(FuturePinballColliderStatus.Generated)); + Assert.That(collider.Mesh.Indices, Has.Length.EqualTo(3)); + Assert.That(collider.Mesh.Indices, Is.EqualTo(new[] { 0, 2, 1 })); + Assert.That(collider.Mesh.Vertices[2].Z, Is.EqualTo(-0.1f)); + } + + [Test] + public void TessellatesGeneratedShapesForVpePrimitiveColliders() + { + var descriptions = FuturePinballColliderBuilder.FromShapes(new[] { + new FuturePinballCollisionShape(1, true, 0f, 0f, 0f, 5f, 12f), + new FuturePinballCollisionShape(2, true, 0f, 0f, 0f, 8f), + new FuturePinballCollisionShape(3, true, 0f, 0f, 40f, 6f, 3f, 40f, 2f), + new FuturePinballCollisionShape(5, true, 0f, 0f, 0f, 2f, 3f, 4f), + new FuturePinballCollisionShape(7, true, 0f, 0f, 0f, 5f, 12f, 6f) + }); + + var meshes = descriptions.Select(description => FuturePinballColliderMeshBuilder.Build(description)).ToArray(); + + Assert.That(meshes.All(mesh => mesh?.IsSet == true && mesh.Indices.Length >= 12), Is.True); + Assert.That(meshes.All(mesh => mesh.Indices.Length % 3 == 0), Is.True); + foreach (var mesh in meshes) AssertClosedMeshFacesOutward(mesh); + AssertBounds(meshes[0], -0.005f, 0.005f, -0.012f, 0.012f, -0.005f, 0.005f); + AssertBounds(meshes[1], -0.008f, 0.008f, -0.008f, 0.008f, -0.008f, 0.008f); + AssertBounds(meshes[2], -0.006f, 0.006f, -0.002f, 0.002f, -0.026f, 0.023f); + AssertBounds(meshes[3], -0.002f, 0.002f, -0.003f, 0.003f, -0.004f, 0.004f); + AssertBounds(meshes[4], -0.005f, 0.005f, -0.006f, 0.006f, -0.012f, 0.012f); + } + + [Test] + public void DoesNotCreateVpeMeshForSkippedOrUnsupportedShapes() + { + var descriptions = FuturePinballColliderBuilder.FromShapes(new[] { + new FuturePinballCollisionShape(1, false, 0f, 0f, 0f, 1f, 1f), + new FuturePinballCollisionShape(6, true, 0f, 0f, 0f, 1f) + }); + + Assert.That(FuturePinballColliderMeshBuilder.Build(descriptions[0]), Is.Null); + Assert.That(FuturePinballColliderMeshBuilder.Build(descriptions[1]), Is.Null); + } + + private static void AssertBounds(Mesh mesh, float minX, float maxX, float minY, float maxY, float minZ, float maxZ) + { + Assert.That(mesh.Vertices.Min(vertex => vertex.X), Is.EqualTo(minX).Within(0.000001f)); + Assert.That(mesh.Vertices.Max(vertex => vertex.X), Is.EqualTo(maxX).Within(0.000001f)); + Assert.That(mesh.Vertices.Min(vertex => vertex.Y), Is.EqualTo(minY).Within(0.000001f)); + Assert.That(mesh.Vertices.Max(vertex => vertex.Y), Is.EqualTo(maxY).Within(0.000001f)); + Assert.That(mesh.Vertices.Min(vertex => vertex.Z), Is.EqualTo(minZ).Within(0.000001f)); + Assert.That(mesh.Vertices.Max(vertex => vertex.Z), Is.EqualTo(maxZ).Within(0.000001f)); + } + + private static void AssertClosedMeshFacesOutward(Mesh mesh) + { + var centerX = mesh.Vertices.Average(vertex => vertex.X); + var centerY = mesh.Vertices.Average(vertex => vertex.Y); + var centerZ = mesh.Vertices.Average(vertex => vertex.Z); + for (var i = 0; i < mesh.Indices.Length; i += 3) { + Assert.That(mesh.Indices[i], Is.InRange(0, mesh.Vertices.Length - 1)); + Assert.That(mesh.Indices[i + 1], Is.InRange(0, mesh.Vertices.Length - 1)); + Assert.That(mesh.Indices[i + 2], Is.InRange(0, mesh.Vertices.Length - 1)); + var a = mesh.Vertices[mesh.Indices[i]]; + var b = mesh.Vertices[mesh.Indices[i + 1]]; + var c = mesh.Vertices[mesh.Indices[i + 2]]; + var abX = b.X - a.X; + var abY = b.Y - a.Y; + var abZ = b.Z - a.Z; + var acX = c.X - a.X; + var acY = c.Y - a.Y; + var acZ = c.Z - a.Z; + var normalX = abY * acZ - abZ * acY; + var normalY = abZ * acX - abX * acZ; + var normalZ = abX * acY - abY * acX; + var areaSquared = normalX * normalX + normalY * normalY + normalZ * normalZ; + Assert.That(areaSquared, Is.GreaterThan(1e-20f)); + var faceX = (a.X + b.X + c.X) / 3f - centerX; + var faceY = (a.Y + b.Y + c.Y) / 3f - centerY; + var faceZ = (a.Z + b.Z + c.Z) / 3f - centerZ; + Assert.That(normalX * faceX + normalY * faceY + normalZ * faceZ, Is.GreaterThan(0f), + $"{mesh.Name} triangle {i / 3} faces inward"); + } + } + } +} diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballColliderTests.cs.meta b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballColliderTests.cs.meta new file mode 100644 index 000000000..20edb7c3d --- /dev/null +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballColliderTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3ff261059f984be09ffcfa54588fb6cb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballFixtureCatalog.cs b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballFixtureCatalog.cs new file mode 100644 index 000000000..3d42ef501 --- /dev/null +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballFixtureCatalog.cs @@ -0,0 +1,214 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; + +using NUnit.Framework; + +namespace VisualPinball.Engine.Test.IO.FuturePinball +{ + public sealed class FuturePinballFixtureExpectation + { + public string Name { get; } + public string RelativePath { get; } + public long SourceBytes { get; } + public string SourceSha256 { get; } + public int CompoundEntryCount { get; } + public int TableDataBytes { get; } + public int ElementCount { get; } + public int ImageCount { get; } + public int SoundCount { get; } + public int MusicCount { get; } + public int PinModelCount { get; } + public int DmdFontCount { get; } + public int ImageListCount { get; } + public int LightListCount { get; } + public string ElementTypeCounts { get; } + public int ScriptRecordOffset { get; } + public int ScriptRecordLength { get; } + public int ScriptCompressedBytes { get; } + public string ScriptCompressedSha256 { get; } + public int ScriptDecodedBytes { get; } + public string ScriptDecodedSha256 { get; } + + public int ScriptTagOffset => ScriptRecordOffset + sizeof(uint); + + public FuturePinballFixtureExpectation( + string name, + string relativePath, + long sourceBytes, + string sourceSha256, + int compoundEntryCount, + int tableDataBytes, + int elementCount, + int imageCount, + int soundCount, + int musicCount, + int pinModelCount, + int dmdFontCount, + int imageListCount, + int lightListCount, + string elementTypeCounts, + int scriptRecordOffset, + int scriptRecordLength, + int scriptCompressedBytes, + string scriptCompressedSha256, + int scriptDecodedBytes, + string scriptDecodedSha256) + { + Name = name; + RelativePath = relativePath; + SourceBytes = sourceBytes; + SourceSha256 = sourceSha256; + CompoundEntryCount = compoundEntryCount; + TableDataBytes = tableDataBytes; + ElementCount = elementCount; + ImageCount = imageCount; + SoundCount = soundCount; + MusicCount = musicCount; + PinModelCount = pinModelCount; + DmdFontCount = dmdFontCount; + ImageListCount = imageListCount; + LightListCount = lightListCount; + ElementTypeCounts = elementTypeCounts; + ScriptRecordOffset = scriptRecordOffset; + ScriptRecordLength = scriptRecordLength; + ScriptCompressedBytes = scriptCompressedBytes; + ScriptCompressedSha256 = scriptCompressedSha256; + ScriptDecodedBytes = scriptDecodedBytes; + ScriptDecodedSha256 = scriptDecodedSha256; + } + + public override string ToString() + { + return Name; + } + } + + public static class FuturePinballFixtureCatalog + { + public const string FixtureRootEnvironmentVariable = "VPE_FPT_FIXTURES"; + + private const string OriginalElementTypes = "2:98,3:3,4:84,6:34,7:4,8:2,10:5,11:37,12:1,13:11,14:9,15:188,16:21,17:51,19:23,20:4,21:1,22:22,23:9,24:50,27:2,29:53,30:5,31:2,34:2,37:2,38:2,43:7,46:2,50:13,53:10,56:2,57:39,61:2"; + private const string UltraElementTypes = "2:100,3:3,4:84,6:34,7:4,8:2,10:5,11:37,12:1,13:11,14:9,15:168,16:21,17:51,19:23,20:4,21:1,22:38,23:9,24:50,27:2,29:55,30:5,31:2,34:2,37:2,38:2,43:7,46:2,50:13,53:10,56:2,57:39,61:2"; + + public static readonly IReadOnlyList All = new[] { + Original( + "Three Angels 2008 v1.666", + "fp-2008-1.666/3ANGELS.fpt", + 1669120, + "dd2b5701dcfebe78bca9e717ba6c17fd7e79143275d359e46c7be1168f03f72f" + ), + Original( + "Three Angels 2012 installed", + "fp-2012-installed/3 Angels.fpt", + 1676800, + "e5be5c004d1ab2caf43d46c3416759082a2c6403b3e30e86ff7142048f32dcdb" + ), + Ultra( + "Three Angels 2012 Ultra release", + "fp-2012-ultra-release/Three Angels_ehanc_Slam.fpt", + "c62e9cfe1936722b721a1ac797c3956ee0007ad5764eb126b40c3d6898202052", + 559084, + 557539, + 557531, + "0d6cb0a69079c3fca656e124210fbaff25679e13a2b3a5be362115af58747994", + 2552611, + "d3b7da77414f9d351cca3d4dd811f7d54b9f949afc4fc00604bfe51a2479a1bb" + ), + Ultra( + "Three Angels 2012 Ultra source", + "fp-2012-ultra-source/Three Angels.fpt", + "c62e9cfe1936722b721a1ac797c3956ee0007ad5764eb126b40c3d6898202052", + 559084, + 557539, + 557531, + "0d6cb0a69079c3fca656e124210fbaff25679e13a2b3a5be362115af58747994", + 2552611, + "d3b7da77414f9d351cca3d4dd811f7d54b9f949afc4fc00604bfe51a2479a1bb" + ), + Ultra( + "Three Angels 2013 enhanced", + "fp-2013-enhanced/Three Angels_ehanc_Slam.fpt", + "d5a8280c466ef1d3bd23a8b2080c06a289f601254b05fecc494c624a1b7b16fc", + 559088, + 557543, + 557535, + "723ee7235b291423603df98a6d9a598637d4167297dd0f9110040107d7fe96e3", + 2552609, + "a494cba86affeaf8551c99575366b213cbfc376fd394d110ea41b2bb9d78fa44" + ) + }; + + private static FuturePinballFixtureExpectation Original(string name, string relativePath, long bytes, string hash) + { + return new FuturePinballFixtureExpectation( + name, relativePath, bytes, hash, 2246, 559078, 800, 263, 840, 48, 259, 0, 30, 2, + OriginalElementTypes, 1594, 557376, 557368, + "3a9379c2ea632b75a09d6c1fc2f92d6017e2bf2222342ce182d46688a4d1bceb", + 2550062, "b8141312683b075b4bccc59cb9b098207cda622207f2c9a8f20805c3d8a6ea4f" + ); + } + + private static FuturePinballFixtureExpectation Ultra( + string name, + string relativePath, + string hash, + int tableDataBytes, + int scriptRecordLength, + int scriptCompressedBytes, + string scriptCompressedHash, + int scriptDecodedBytes, + string scriptDecodedHash) + { + return new FuturePinballFixtureExpectation( + name, relativePath, 8980992, hash, 2059, tableDataBytes, 800, 255, 869, 48, 51, 0, 30, 2, + UltraElementTypes, 1437, scriptRecordLength, scriptCompressedBytes, + scriptCompressedHash, scriptDecodedBytes, scriptDecodedHash + ); + } + } + + [TestFixture] + public class FuturePinballFixtureIntegrityTests + { + [TestCaseSource(typeof(FuturePinballFixtureCatalog), nameof(FuturePinballFixtureCatalog.All))] + public void FixtureMatchesLockedSource(FuturePinballFixtureExpectation fixture) + { + var fixtureRoot = Environment.GetEnvironmentVariable(FuturePinballFixtureCatalog.FixtureRootEnvironmentVariable); + if (string.IsNullOrWhiteSpace(fixtureRoot)) { + Assert.Ignore($"Set {FuturePinballFixtureCatalog.FixtureRootEnvironmentVariable} to run Future Pinball corpus tests."); + } + + var relativePath = fixture.RelativePath.Replace('/', Path.DirectorySeparatorChar); + var fixturePath = Path.GetFullPath(Path.Combine(fixtureRoot, relativePath)); + Assert.That(File.Exists(fixturePath), Is.True, $"Missing locked fixture {fixture.RelativePath}"); + Assert.That(new FileInfo(fixturePath).Length, Is.EqualTo(fixture.SourceBytes)); + Assert.That(Sha256(fixturePath), Is.EqualTo(fixture.SourceSha256)); + } + + private static string Sha256(string path) + { + using (var input = File.OpenRead(path)) + using (var sha = SHA256.Create()) { + return BitConverter.ToString(sha.ComputeHash(input)).Replace("-", string.Empty).ToLowerInvariant(); + } + } + } +} diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballFixtureCatalog.cs.meta b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballFixtureCatalog.cs.meta new file mode 100644 index 000000000..f706f0fbc --- /dev/null +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballFixtureCatalog.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5b84578d2d324172ae79cb6ae25aa56f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs new file mode 100644 index 000000000..605190aed --- /dev/null +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs @@ -0,0 +1,199 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.IO; +using System.Linq; +using System.Text; + +using NUnit.Framework; +using OpenMcdf; + +using VisualPinball.Engine.IO.FuturePinball; +using VisualPinball.Engine.Math; +using VisualPinball.Engine.VPT; + +namespace VisualPinball.Engine.Test.IO.FuturePinball +{ + [TestFixture] + public class FuturePinballGeometryTests + { + [Test] + public void NativeAndDirectScalePathsProduceSameWorldPosition() + { + var fp = new Vertex3D(125f, 250f, 30f); + var vpx = FuturePinballCoordinateConverter.ToVpx(fp.X, fp.Y, fp.Z); + var direct = FuturePinballCoordinateConverter.ToWorld(fp.X, fp.Y, fp.Z); + + Assert.That(vpx.X / 1852.71f, Is.EqualTo(direct.X).Within(0.000001f)); + Assert.That(vpx.Z / 1852.71f, Is.EqualTo(direct.Y).Within(0.000001f)); + Assert.That(-vpx.Y / 1852.71f, Is.EqualTo(direct.Z).Within(0.000001f)); + } + + [Test] + public void ConvertsModelAxesScaleNormalsAndTextureV() + { + var source = new Mesh(new[] { + new Vertex3DNoTex2(100f, 200f, 300f, 0f, 1f, 0f, 0.25f, 0.2f), + new Vertex3DNoTex2(0f, 0f, 0f), + new Vertex3DNoTex2(1f, 0f, 0f) + }, new[] { 0, 1, 2 }); + + var converted = FuturePinballCoordinateConverter.ModelMeshToWorld(source); + + Assert.That(converted.Vertices[0].X, Is.EqualTo(0.1f)); + Assert.That(converted.Vertices[0].Y, Is.EqualTo(0.2f)); + Assert.That(converted.Vertices[0].Z, Is.EqualTo(-0.3f)); + Assert.That(converted.Vertices[0].Nx, Is.Zero); + Assert.That(converted.Vertices[0].Ny, Is.EqualTo(1f)); + Assert.That(converted.Vertices[0].Nz, Is.Zero); + Assert.That(converted.Vertices[0].Tu, Is.EqualTo(0.25f)); + Assert.That(converted.Vertices[0].Tv, Is.EqualTo(0.8f)); + Assert.That(converted.Indices, Is.EqualTo(new[] { 0, 2, 1 })); + Assert.That(source.Vertices[0].X, Is.EqualTo(100f)); + Assert.That(source.Indices, Is.EqualTo(new[] { 0, 1, 2 })); + } + + [Test] + public void BuildsSurfaceFromPointRecords() + { + var path = Path.Combine(Path.GetTempPath(), $"vpe-fp-geometry-{Guid.NewGuid():N}.fpt"); + try { + CreateSurfaceTable(path); + var table = FuturePinballTableReader.Load(path); + var points = FuturePinballElementGeometry.Points(table.Elements.Single()); + var generated = FuturePinballProceduralMeshBuilder.Build(table).Single(); + + Assert.That(points, Has.Count.EqualTo(4)); + Assert.That(points[1].Position.X, Is.EqualTo(100f)); + Assert.That(points[1].Smooth, Is.True); + Assert.That(FuturePinballElementGeometry.ContainsPoint(table.Elements.Single(), new FuturePinballVector2(50f, 25f)), Is.True); + Assert.That(FuturePinballElementGeometry.ContainsPoint(table.Elements.Single(), new FuturePinballVector2(100f, 25f)), Is.True); + Assert.That(FuturePinballElementGeometry.ContainsPoint(table.Elements.Single(), new FuturePinballVector2(101f, 25f)), Is.False); + Assert.That(generated.Name, Is.EqualTo("Playfield Wall")); + Assert.That(generated.IsCollidable, Is.True); + Assert.That(generated.Texture, Is.EqualTo("wood")); + Assert.That(generated.Meshes, Has.Count.EqualTo(2)); + Assert.That(generated.Meshes.SelectMany(mesh => mesh.Vertices).Any(vertex => + System.Math.Abs(vertex.X - FuturePinballCoordinateConverter.ToVpx(100f)) < 0.001f), Is.True); + Assert.That(generated.Meshes.SelectMany(mesh => mesh.Vertices).Max(vertex => vertex.Z), + Is.EqualTo(FuturePinballCoordinateConverter.ToVpx(20f)).Within(0.001f)); + } finally { + if (File.Exists(path)) File.Delete(path); + } + } + + [Test] + public void BuildsGuideWallFromElementSpecificFloatHeight() + { + var path = Path.Combine(Path.GetTempPath(), $"vpe-fp-guide-wall-{Guid.NewGuid():N}.fpt"); + try { + CreateSurfaceTable(path, FuturePinballElementType.GuideWall); + var generated = FuturePinballProceduralMeshBuilder.Build(FuturePinballTableReader.Load(path)).Single(); + + Assert.That(generated.Type, Is.EqualTo(FuturePinballElementType.GuideWall)); + Assert.That(generated.Meshes.SelectMany(mesh => mesh.Vertices).Max(vertex => vertex.Z), + Is.EqualTo(FuturePinballCoordinateConverter.ToVpx(20f)).Within(0.001f)); + } finally { + if (File.Exists(path)) File.Delete(path); + } + } + + [TestCaseSource(typeof(FuturePinballFixtureCatalog), nameof(FuturePinballFixtureCatalog.All))] + public void BuildsFiniteProceduralMeshesFromLockedTable(FuturePinballFixtureExpectation fixture) + { + var fixtureRoot = Environment.GetEnvironmentVariable(FuturePinballFixtureCatalog.FixtureRootEnvironmentVariable); + if (string.IsNullOrWhiteSpace(fixtureRoot)) Assert.Ignore("Future Pinball fixture root is not configured."); + var path = Path.Combine(fixtureRoot, fixture.RelativePath.Replace('/', Path.DirectorySeparatorChar)); + var generated = FuturePinballProceduralMeshBuilder.Build(FuturePinballTableReader.Load(path)); + + Assert.That(generated, Is.Not.Empty); + Assert.That(generated.SelectMany(element => element.Meshes), Is.Not.Empty); + foreach (var vertex in generated.SelectMany(element => element.Meshes).SelectMany(mesh => mesh.Vertices)) { + Assert.That(float.IsNaN(vertex.X) || float.IsInfinity(vertex.X), Is.False); + Assert.That(float.IsNaN(vertex.Y) || float.IsInfinity(vertex.Y), Is.False); + Assert.That(float.IsNaN(vertex.Z) || float.IsInfinity(vertex.Z), Is.False); + } + } + + private static void CreateSurfaceTable(string path, FuturePinballElementType type = FuturePinballElementType.Surface) + { + using (var table = RootStorage.Create(path, OpenMcdf.Version.V3, StorageModeFlags.None)) { + var storage = table.CreateStorage("Future Pinball"); + Write(storage.CreateStream("File Version"), UInt32(1)); + Write(storage.CreateStream("Table Data"), Join( + IntegerRecord(0xA5F8BBD1, 500), IntegerRecord(0x9BFCC6D1, 1000), + IntegerRecord(0x95FDCDD2, 1), IntegerRecord(0xA2F4C9D2, 0), + IntegerRecord(0xA5F3BFD2, 0), IntegerRecord(0x96ECC5D2, 0), + IntegerRecord(0xA5F2C5D2, 0), IntegerRecord(0x95F5C9D2, 0), + IntegerRecord(0x95F5C6D2, 0), IntegerRecord(0x9BFBCED2, 0), + Record(0xA7FDC4E0, Array.Empty()) + )); + Write(storage.CreateStream("Table MAC"), new byte[16]); + Write(storage.CreateStream("Table Element 1"), Join( + UInt32((uint)type), + WideStringRecord(0xA4F4D1D7, "Playfield Wall"), + type == FuturePinballElementType.GuideWall + ? FloatRecord(0xA2F8CDDD, 20f) + : Join(FloatRecord(0x99F2BEDD, 20f), FloatRecord(0x95F2D0DD, 0f)), + StringRecord(0xA2F4C9D1, "wood"), IntegerRecord(0x9DF5C3E2, 1), + Point(0f, 0f, false), Point(100f, 0f, true), Point(100f, 50f, false), Point(0f, 50f, false), + Record(0xA7FDC4E0, Array.Empty()) + )); + table.Flush(true); + } + } + + private static byte[] Point(float x, float y, bool smooth) + { + return Join( + Record(FuturePinballElementGeometry.PointTag, Array.Empty()), + Record(FuturePinballElementGeometry.PositionTag, Join(Single(x), Single(y))), + IntegerRecord(FuturePinballElementGeometry.SmoothTag, smooth ? 1 : 0), + Record(0xA7FDC4E0, Array.Empty()) + ); + } + + private static byte[] IntegerRecord(uint tag, int value) => Record(tag, UInt32((uint)value)); + private static byte[] FloatRecord(uint tag, float value) => Record(tag, Single(value)); + private static byte[] StringRecord(uint tag, string value) => Record(tag, StringBytes(value, Encoding.ASCII)); + private static byte[] WideStringRecord(uint tag, string value) => Record(tag, StringBytes(value, Encoding.Unicode)); + private static byte[] StringBytes(string value, Encoding encoding) + { + var bytes = encoding.GetBytes(value); + return Join(UInt32((uint)bytes.Length), bytes); + } + private static byte[] Record(uint tag, byte[] payload) => Join(UInt32((uint)(payload.Length + 4)), UInt32(tag), payload); + private static byte[] Single(float value) => BitConverter.GetBytes(value); + private static byte[] UInt32(uint value) => new[] { (byte)value, (byte)(value >> 8), (byte)(value >> 16), (byte)(value >> 24) }; + private static byte[] Join(params byte[][] parts) + { + var result = new byte[parts.Sum(part => part.Length)]; + var offset = 0; + foreach (var part in parts) { + Buffer.BlockCopy(part, 0, result, offset, part.Length); + offset += part.Length; + } + return result; + } + private static void Write(CfbStream stream, byte[] data) + { + stream.SetLength(data.Length); + stream.Write(data, 0, data.Length); + stream.Flush(); + } + } +} diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs.meta b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs.meta new file mode 100644 index 000000000..25b299a72 --- /dev/null +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a23b20678d764cd1880afb0ccda57295 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballMaterialTests.cs b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballMaterialTests.cs new file mode 100644 index 000000000..765345a87 --- /dev/null +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballMaterialTests.cs @@ -0,0 +1,79 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; + +using VisualPinball.Engine.IO.FuturePinball; + +namespace VisualPinball.Engine.Test.IO.FuturePinball +{ + [TestFixture] + public class FuturePinballMaterialTests + { + [Test] + public void ConvertsMetalPresetToVpeMaterial() + { + var source = FuturePinballMaterialConverter.FromValues( + "metal", 0, 0xff332211, 7, sphereMapped: true, twoSided: true, texture: "metal.jpg" + ); + var material = source.ToVpeMaterial(); + + Assert.That(source.Category, Is.EqualTo(FuturePinballMaterialCategory.Metal)); + Assert.That(source.IsSphereMapped, Is.True); + Assert.That(source.IsTwoSided, Is.True); + Assert.That(source.Texture, Is.EqualTo("metal.jpg")); + Assert.That(material.IsMetal, Is.True); + Assert.That(material.BaseColor.Red, Is.EqualTo(0x11)); + Assert.That(material.BaseColor.Green, Is.EqualTo(0x22)); + Assert.That(material.BaseColor.Blue, Is.EqualTo(0x33)); + Assert.That(material.Opacity, Is.EqualTo(0.7f).Within(0.0001f)); + Assert.That(material.IsOpacityActive, Is.True); + } + + [Test] + public void AppliesCrystalFallbackOpacityWithoutLosingSourceValue() + { + var source = FuturePinballMaterialConverter.FromValues("crystal", 2, 0xffffffff, 10, crystal: true); + + Assert.That(source.IsCrystal, Is.True); + Assert.That(source.SourceTransparency, Is.EqualTo(10)); + Assert.That(source.Opacity, Is.EqualTo(0.35f)); + Assert.That(source.ToVpeMaterial().Thickness, Is.EqualTo(0.15f)); + } + + [Test] + public void ConvertsMilkShapeDiffuseTransparencyAndShininess() + { + var source = FuturePinballMaterialConverter.FromMilkShape( + "ms3d", new[] { 1f, 0.5f, 0.25f, 0.5f }, 128f, 0.8f, "diffuse.png" + ); + + Assert.That(source.SourceColor, Is.EqualTo(0xff4080ff)); + Assert.That(source.Opacity, Is.EqualTo(0.4f).Within(0.0001f)); + Assert.That(source.Roughness, Is.Zero); + Assert.That(source.Texture, Is.EqualTo("diffuse.png")); + } + + [Test] + public void PreservesUnknownMaterialCategory() + { + var source = FuturePinballMaterialConverter.FromValues("future", 42, 0xffffffff); + + Assert.That(source.Category, Is.EqualTo(FuturePinballMaterialCategory.Unknown)); + Assert.That(source.SourceMaterialType, Is.EqualTo(42)); + } + } +} diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballMaterialTests.cs.meta b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballMaterialTests.cs.meta new file mode 100644 index 000000000..08c5b68b8 --- /dev/null +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballMaterialTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bfd2875f66d84ed59ea3d3b5a485c65a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballNativeItemTests.cs b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballNativeItemTests.cs new file mode 100644 index 000000000..c39519035 --- /dev/null +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballNativeItemTests.cs @@ -0,0 +1,284 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.IO; +using System.Linq; +using System.Text; + +using NUnit.Framework; +using OpenMcdf; + +using VisualPinball.Engine.IO.FuturePinball; +using VisualPinball.Engine.VPT; +using VisualPinball.Engine.VPT.Bumper; +using VisualPinball.Engine.VPT.Flipper; +using VisualPinball.Engine.VPT.Gate; +using VisualPinball.Engine.VPT.Kicker; +using VisualPinball.Engine.VPT.Light; +using VisualPinball.Engine.VPT.Plunger; +using VisualPinball.Engine.VPT.Rubber; +using VisualPinball.Engine.VPT.Surface; +using VisualPinball.Engine.VPT.Table; +using VisualPinball.Engine.VPT.Trigger; + +namespace VisualPinball.Engine.Test.IO.FuturePinball +{ + [TestFixture] + public class FuturePinballNativeItemTests + { + private const uint NameTag = 0xA4F4D1D7; + private const uint PositionTag = 0x9BFCCFCF; + private const uint SurfaceTag = 0xA3EFBDD2; + private const uint GlowCenterTag = 0x9BFCCFDD; + + [Test] + public void MapsKnownValuesAndRetainsDefaultsForIncompatibleScales() + { + WithTable(table => + { + var element = table.Elements.Single(item => item.ElementType == FuturePinballElementType.Flipper); + + Assert.That(FuturePinballNativeItemConverter.TryConvert(element, out var converted), Is.True); + var data = ((Flipper)converted.Item).Data; + Assert.That(data.Center.X, Is.EqualTo(FuturePinballCoordinateConverter.ToVpx(100f))); + Assert.That(data.Center.Y, Is.EqualTo(FuturePinballCoordinateConverter.ToVpx(200f))); + Assert.That(data.Surface, Is.EqualTo("Upper PF")); + Assert.That(data.StartAngle, Is.EqualTo(122f)); + Assert.That(data.EndAngle, Is.EqualTo(70f)); + Assert.That(data.Strength, Is.EqualTo(2200f), "FP's discrete strength scale must not overwrite VPE's physical units."); + Assert.That(data.Elasticity, Is.EqualTo(0.8f), "FP's material enum must not be treated as a coefficient."); + Assert.That(converted.DefaultedParameters, Does.Contain("strength scale")); + }); + } + + [Test] + public void MapsAutoPlungerOptoRubberGateAndLightSemantics() + { + WithTable(table => + { + var autoPlunger = Convert(table, FuturePinballElementType.AutoPlunger).Data; + Assert.That(autoPlunger.AutoPlunger, Is.True); + Assert.That(autoPlunger.IsMechPlunger, Is.False); + + var opto = Convert(table, FuturePinballElementType.OptoTrigger).Data; + Assert.That(opto.Shape, Is.EqualTo(TriggerShape.TriggerNone)); + Assert.That(opto.IsVisible, Is.False); + Assert.That(opto.Radius, Is.EqualTo(FuturePinballCoordinateConverter.ToVpx(30f))); + + var rubber = Convert(table, FuturePinballElementType.ShapeableRubber).Data; + Assert.That(rubber.DragPoints, Has.Length.EqualTo(3)); + Assert.That(rubber.Height, Is.EqualTo(FuturePinballCoordinateConverter.ToVpx(14f))); + + var gate = Convert(table, FuturePinballElementType.Gate).Data; + Assert.That(gate.Rotation, Is.EqualTo(270f)); + Assert.That(gate.TwoWay, Is.False); + + var light = Convert(table, FuturePinballElementType.RoundLight).Data; + Assert.That(light.State, Is.EqualTo(LightStatus.LightStateBlinking)); + Assert.That(light.BlinkInterval, Is.EqualTo(175)); + Assert.That(light.BlinkPattern, Is.EqualTo("1010")); + Assert.That(light.MeshRadius, Is.EqualTo(FuturePinballCoordinateConverter.ToVpx(10f))); + Assert.That(light.DragPoints, Has.Length.EqualTo(8)); + Assert.That(light.Color.Red, Is.EqualTo(0x33)); + Assert.That(light.Color.Green, Is.EqualTo(0x22)); + Assert.That(light.Color.Blue, Is.EqualTo(0x11)); + }); + } + + [Test] + public void DoesNotClaimUnsupportedFunctionalToys() + { + WithTable(table => + { + var diverter = table.Elements.Single(item => item.ElementType == FuturePinballElementType.Diverter); + Assert.That(FuturePinballNativeItemConverter.TryConvert(diverter, out _), Is.False); + }); + } + + [Test] + public void MapsSurfaceEventsAndRetainsPassiveMechanismSemantics() + { + WithTable(table => + { + var surface = Convert(table, FuturePinballElementType.Surface).Data; + Assert.That(surface.HitEvent, Is.True); + + var bumper = Convert(table, FuturePinballElementType.Bumper).Data; + Assert.That(bumper.Force, Is.Zero, "A bumper without an FP trigger skirt must not kick the ball."); + + var trigger = Convert(table, FuturePinballElementType.Trigger).Data; + Assert.That(trigger.IsVisible, Is.False); + }); + } + + [Test] + public void MapsShapeableLightGeometryAndDefaultsUnknownStateToOff() + { + WithTable(table => + { + var light = Convert(table, FuturePinballElementType.ShapeableLight).Data; + Assert.That(light.Center.X, Is.EqualTo(FuturePinballCoordinateConverter.ToVpx(210f))); + Assert.That(light.Center.Y, Is.EqualTo(FuturePinballCoordinateConverter.ToVpx(310f))); + Assert.That(light.DragPoints, Has.Length.EqualTo(3)); + Assert.That(light.DragPoints[0].Center.X, Is.EqualTo(FuturePinballCoordinateConverter.ToVpx(180f))); + Assert.That(light.DragPoints[0].Center.Y, Is.EqualTo(FuturePinballCoordinateConverter.ToVpx(280f))); + Assert.That(light.State, Is.EqualTo(LightStatus.LightStateOff)); + Assert.That(light.OffImage, Is.EqualTo(FuturePinballNativeItemConverter.PlayfieldImage)); + + var tableContainer = new FileTableContainer(); + tableContainer.Table.Data.Image = FuturePinballNativeItemConverter.PlayfieldImage; + Assert.That(new Light(light).IsInsertLight(tableContainer.Table), Is.True, + "Imported FP playfield lights must select VPE's insert-light prefab."); + }); + } + + [Test] + public void UsesShapePointForSurfacePlacementAndDefaultsMissingKickerType() + { + WithTable(table => + { + var rubber = table.Elements.Single(item => item.ElementType == FuturePinballElementType.ShapeableRubber); + var probe = FuturePinballElementGeometry.SurfaceProbePosition(rubber); + Assert.That(probe.X, Is.EqualTo(125f)); + Assert.That(probe.Y, Is.EqualTo(225f)); + + var defaultKicker = table.Elements.Single(item => item.Name == "Table Element 11"); + Assert.That(((Kicker)ConvertItem(defaultKicker).Item).Data.FallThrough, Is.False, + "A missing FP kicker type must retain the VPE default rather than becoming a gobble hole."); + + var gobbleHole = table.Elements.Single(item => item.Name == "Table Element 12"); + Assert.That(((Kicker)ConvertItem(gobbleHole).Item).Data.FallThrough, Is.True); + }); + } + + private static T Convert(FuturePinballTable table, FuturePinballElementType type) where T : class, IItem + { + var element = table.Elements.Single(item => item.ElementType == type); + Assert.That(FuturePinballNativeItemConverter.TryConvert(element, out var converted), Is.True); + return converted.Item as T; + } + + private static FuturePinballNativeItem ConvertItem(FuturePinballSourceStream element) + { + Assert.That(FuturePinballNativeItemConverter.TryConvert(element, out var converted), Is.True); + return converted; + } + + private static void WithTable(Action assertion) + { + var path = Path.Combine(Path.GetTempPath(), $"vpe-fp-native-{Guid.NewGuid():N}.fpt"); + try { + CreateTable(path); + assertion(FuturePinballTableReader.Load(path)); + } finally { + if (File.Exists(path)) File.Delete(path); + } + } + + private static void CreateTable(string path) + { + using (var table = RootStorage.Create(path, OpenMcdf.Version.V3, StorageModeFlags.None)) { + var storage = table.CreateStorage("Future Pinball"); + void WriteTableElement(int index, FuturePinballElementType type, params byte[][] records) + { + Write(storage.CreateStream($"Table Element {index}"), Join(new[] { UInt32((uint)type) }.Concat(records).Concat(new[] { End() }).ToArray())); + } + Write(storage.CreateStream("File Version"), UInt32(1)); + Write(storage.CreateStream("Table Data"), Join( + IntegerRecord(0x95FDCDD2, 13), IntegerRecord(0xA2F4C9D2, 0), + IntegerRecord(0xA5F3BFD2, 0), IntegerRecord(0x96ECC5D2, 0), + IntegerRecord(0xA5F2C5D2, 0), IntegerRecord(0x95F5C9D2, 0), + IntegerRecord(0x95F5C6D2, 0), IntegerRecord(0x9BFBCED2, 0), End() + )); + Write(storage.CreateStream("Table MAC"), new byte[16]); + WriteTableElement(1, FuturePinballElementType.Flipper, + Common("Left Flipper", 100f, 200f, "Upper PF"), + IntegerRecord(0xA900BED2, 122), IntegerRecord(0xA2EABFE4, -52), + IntegerRecord(0xA1FABED2, 6), IntegerRecord(0x9700C6E0, 0)); + WriteTableElement(2, FuturePinballElementType.AutoPlunger, + Common("Auto Plunger", 480f, 950f, "Playfield")); + WriteTableElement(3, FuturePinballElementType.OptoTrigger, + Common("Opto", 300f, 400f, "Playfield"), IntegerRecord(0x95FDC9CE, 60)); + WriteTableElement(4, FuturePinballElementType.ShapeableRubber, + WideStringRecord(NameTag, "Rubber"), StringRecord(SurfaceTag, "Playfield"), + IntegerRecord(0x96FBCCD6, 14), Point(125f, 225f), Point(175f, 225f), Point(150f, 265f)); + WriteTableElement(5, FuturePinballElementType.Gate, + Common("Gate", 200f, 300f, "Playfield"), IntegerRecord(0xA8EDC3D3, 270), IntegerRecord(0x9100BBD6, 1)); + WriteTableElement(6, FuturePinballElementType.RoundLight, + Common("Lamp", 220f, 320f, "Playfield"), IntegerRecord(0x9D00C9E1, 20), + IntegerRecord(0x9600BED2, 2), IntegerRecord(0x95F3C9E3, 175), + StringRecord(0x9600C2E3, "1010"), IntegerRecord(0x9DF2CFD9, 0x00112233)); + WriteTableElement(7, FuturePinballElementType.Diverter, + Common("Diverter", 250f, 350f, "Playfield"), IntegerRecord(0xA900BED2, 25), IntegerRecord(0xA2EABFE4, 83)); + WriteTableElement(8, FuturePinballElementType.Surface, + WideStringRecord(NameTag, "Surface"), FloatRecord(0x99F2BEDD, 25f), FloatRecord(0x95F2D0DD, 5f), + IntegerRecord(0x95EBCDDD, 1), Point(0f, 0f), Point(100f, 0f), Point(100f, 100f)); + WriteTableElement(9, FuturePinballElementType.Bumper, + Common("No Skirt Bumper", 280f, 380f, "Playfield"), IntegerRecord(0xA0EED1D5, 0), IntegerRecord(0x9EEED1DD, 0)); + WriteTableElement(10, FuturePinballElementType.Trigger, + Common("Invisible Trigger", 320f, 420f, "Playfield"), IntegerRecord(0xA5F2C5D3, 0)); + WriteTableElement(11, FuturePinballElementType.Kicker, + Common("Default Kicker", 340f, 440f, "Playfield")); + WriteTableElement(12, FuturePinballElementType.Kicker, + Common("Gobble Hole", 360f, 460f, "Playfield"), IntegerRecord(0x99E8BEDA, 1)); + WriteTableElement(13, FuturePinballElementType.ShapeableLight, + WideStringRecord(NameTag, "Shaped Lamp"), StringRecord(SurfaceTag, "Playfield"), + VectorRecord(GlowCenterTag, 210f, 310f), IntegerRecord(0x9600BED2, 99), + Point(180f, 280f), Point(240f, 280f), Point(210f, 340f)); + table.Flush(true); + } + } + + private static byte[] Common(string name, float x, float y, string surface) + { + return Join(WideStringRecord(NameTag, name), VectorRecord(PositionTag, x, y), StringRecord(SurfaceTag, surface)); + } + + private static byte[] Point(float x, float y) + { + return Join(Record(FuturePinballElementGeometry.PointTag, Array.Empty()), + VectorRecord(PositionTag, x, y), IntegerRecord(FuturePinballElementGeometry.SmoothTag, 1), End()); + } + + private static byte[] End() => Record(0xA7FDC4E0, Array.Empty()); + private static byte[] VectorRecord(uint tag, float x, float y) => Record(tag, Join(Single(x), Single(y))); + private static byte[] IntegerRecord(uint tag, int value) => Record(tag, UInt32(unchecked((uint)value))); + private static byte[] FloatRecord(uint tag, float value) => Record(tag, Single(value)); + private static byte[] StringRecord(uint tag, string value) => Record(tag, StringBytes(value, Encoding.ASCII)); + private static byte[] WideStringRecord(uint tag, string value) => Record(tag, StringBytes(value, Encoding.Unicode)); + private static byte[] StringBytes(string value, Encoding encoding) => Join(UInt32((uint)encoding.GetByteCount(value)), encoding.GetBytes(value)); + private static byte[] Record(uint tag, byte[] payload) => Join(UInt32((uint)(payload.Length + 4)), UInt32(tag), payload); + private static byte[] Single(float value) => BitConverter.GetBytes(value); + private static byte[] UInt32(uint value) => new[] { (byte)value, (byte)(value >> 8), (byte)(value >> 16), (byte)(value >> 24) }; + private static byte[] Join(params byte[][] parts) + { + var result = new byte[parts.Sum(part => part.Length)]; + var offset = 0; + foreach (var part in parts) { + Buffer.BlockCopy(part, 0, result, offset, part.Length); + offset += part.Length; + } + return result; + } + private static void Write(CfbStream stream, byte[] data) + { + stream.SetLength(data.Length); + stream.Write(data, 0, data.Length); + stream.Flush(); + } + } +} diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballReaderTests.cs b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballReaderTests.cs new file mode 100644 index 000000000..bc04c0256 --- /dev/null +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballReaderTests.cs @@ -0,0 +1,417 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text.Json; + +using NUnit.Framework; +using OpenMcdf; + +using VisualPinball.Engine.IO.FuturePinball; +using VisualPinball.Engine.VPT.Table; + +namespace VisualPinball.Engine.Test.IO.FuturePinball +{ + [TestFixture] + public class FuturePinballReaderTests + { + private const uint ScriptTag = 0x4F5A4C7A; + + [TestCaseSource(typeof(FuturePinballFixtureCatalog), nameof(FuturePinballFixtureCatalog.All))] + public void ParsesLockedTableWithoutLosingStreams(FuturePinballFixtureExpectation fixture) + { + var fixturePath = FixturePath(fixture); + var table = FuturePinballTableReader.Load(fixturePath); + + Assert.That(table.CompoundEntryCount, Is.EqualTo(fixture.CompoundEntryCount)); + Assert.That(table.Streams.Count, Is.EqualTo(fixture.CompoundEntryCount - 1)); + Assert.That(table.TableData.RawData.Length, Is.EqualTo(fixture.TableDataBytes)); + Assert.That(table.Elements.Count, Is.EqualTo(fixture.ElementCount)); + Assert.That(table.Images.Count, Is.EqualTo(fixture.ImageCount)); + Assert.That(table.Sounds.Count, Is.EqualTo(fixture.SoundCount)); + Assert.That(table.Music.Count, Is.EqualTo(fixture.MusicCount)); + Assert.That(table.PinModels.Count, Is.EqualTo(fixture.PinModelCount)); + Assert.That(table.DmdFonts.Count, Is.EqualTo(fixture.DmdFontCount)); + Assert.That(table.ImageLists.Count, Is.EqualTo(fixture.ImageListCount)); + Assert.That(table.LightLists.Count, Is.EqualTo(fixture.LightListCount)); + Assert.That(ElementTypeCounts(table.Elements), Is.EqualTo(fixture.ElementTypeCounts)); + Assert.That(table.Issues, Is.Empty); + } + + [TestCaseSource(typeof(FuturePinballFixtureCatalog), nameof(FuturePinballFixtureCatalog.All))] + public void DecodesLockedTableScriptAtExactRecordBoundary(FuturePinballFixtureExpectation fixture) + { + var table = FuturePinballTableReader.Load(FixturePath(fixture)); + var scriptRecord = table.TableData.FirstRecord(ScriptTag); + Assert.That(scriptRecord, Is.Not.Null); + Assert.That(scriptRecord.Offset, Is.EqualTo(fixture.ScriptRecordOffset)); + Assert.That(scriptRecord.Offset + sizeof(uint), Is.EqualTo(fixture.ScriptTagOffset)); + Assert.That(scriptRecord.StoredLength, Is.EqualTo(fixture.ScriptRecordLength)); + Assert.That(scriptRecord.ConsumedLength, Is.EqualTo(fixture.ScriptRecordLength + sizeof(uint))); + + var script = (FuturePinballCompressedData)scriptRecord.Value; + Assert.That(script.IsCompressed, Is.True); + Assert.That(script.RawBytes.Length, Is.EqualTo(fixture.ScriptRecordLength)); + Assert.That(script.RawBytes.Span.Slice(0, 4).ToArray(), Is.EqualTo(new byte[] { 0x7a, 0x4c, 0x5a, 0x4f })); + Assert.That(script.RawBytes.Length - 8, Is.EqualTo(fixture.ScriptCompressedBytes)); + Assert.That(Sha256(script.RawBytes.Slice(8).ToArray()), Is.EqualTo(fixture.ScriptCompressedSha256)); + Assert.That(script.DecodedBytes.Length, Is.EqualTo(fixture.ScriptDecodedBytes)); + Assert.That(Sha256(script.DecodedBytes), Is.EqualTo(fixture.ScriptDecodedSha256)); + + var following = table.TableData.Records.SkipWhile(record => record != scriptRecord).Skip(1).ToArray(); + Assert.That(following.Length, Is.EqualTo(9)); + Assert.That(following.Last().Offset + following.Last().ConsumedLength, Is.EqualTo(table.TableData.RawData.Length)); + } + + [Test] + public void RejectsLzoOutputBeyondConfiguredLimit() + { + var data = new byte[] { (byte)'z', (byte)'L', (byte)'Z', (byte)'O', 0x00, 0x10, 0x00, 0x00 }; + var action = new TestDelegate(() => FuturePinballCompression.Decode(data, 1024, "bomb")); + Assert.That(action, Throws.TypeOf() + .With.Message.Contains("exceeds the configured limit")); + } + + [Test] + public void RejectsTruncatedLzoHeader() + { + var data = new byte[] { (byte)'z', (byte)'L', (byte)'Z', (byte)'O', 0x01 }; + var action = new TestDelegate(() => FuturePinballCompression.Decode(data)); + Assert.That(action, Throws.TypeOf() + .With.Message.Contains("Truncated zLZO header")); + } + + [Test] + public void ParsesNumericElementOrderBareTypesAndBrokenListLength() + { + var path = TemporaryPath("fpt"); + try { + using (var root = RootStorage.Create(path, OpenMcdf.Version.V3, StorageModeFlags.None)) { + var storage = root.CreateStorage("Future Pinball"); + Write(storage.CreateStream("File Version"), UInt32(0x00020000)); + Write(storage.CreateStream("Table Data"), Join( + IntegerRecord(0x95FDCDD2, 2), + IntegerRecord(0xA2F4C9D2, 0), + IntegerRecord(0xA5F3BFD2, 0), + IntegerRecord(0x96ECC5D2, 0), + IntegerRecord(0xA5F2C5D2, 0), + IntegerRecord(0x95F5C9D2, 1), + IntegerRecord(0x95F5C6D2, 0), + IntegerRecord(0x9BFBCED2, 0), + Record(0xA7FDC4E0, Array.Empty()) + )); + Write(storage.CreateStream("Table MAC"), new byte[16]); + Write(storage.CreateStream("Table Element 10"), Join( + UInt32((uint)FuturePinballElementType.Flipper), + Record(0xDEADBEEF, new byte[] { 1, 2, 3 }), + Record(0xA7FDC4E0, Array.Empty()) + )); + Write(storage.CreateStream("Table Element 2"), Join( + UInt32((uint)FuturePinballElementType.Surface), + Record(0xA7FDC4E0, Array.Empty()) + )); + var listPayload = Join(UInt32(2), AsciiString("first"), AsciiString("second")); + Write(storage.CreateStream("ImageList 1"), Join( + StringRecord(0xA4F4D1D7, "frames"), + Record(0xA8EDD1E1, listPayload, sizeof(uint)), + Record(0xA7FDC4E0, Array.Empty()) + )); + root.Flush(true); + } + + var table = FuturePinballTableReader.Load(path); + Assert.That(table.Elements.Select(element => element.SourceIndex), Is.EqualTo(new int?[] { 2, 10 })); + Assert.That(table.Elements.Select(element => element.ElementType), Is.EqualTo(new[] { + FuturePinballElementType.Surface, + FuturePinballElementType.Flipper + })); + var unknown = table.Elements[1].Records.First(); + Assert.That(unknown.ValueKind, Is.EqualTo(FuturePinballValueKind.Opaque)); + Assert.That(unknown.Payload.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 })); + var items = (IReadOnlyList)table.ImageLists.Single().Records[1].Value; + Assert.That(items, Is.EqualTo(new[] { "first", "second" })); + Assert.That(table.ImageLists.Single().Records[1].StoredLength, Is.EqualTo(sizeof(uint))); + Assert.That(table.Issues, Has.Count.EqualTo(7)); + Assert.That(table.Issues, Has.All.Contains("TableElement stream index")); + } finally { + File.Delete(path); + } + } + + [Test] + public void ReadsLibraryEntriesWithoutTrustingOriginalPath() + { + var path = TemporaryPath("fpl"); + try { + using (var root = RootStorage.Create(path, OpenMcdf.Version.V3, StorageModeFlags.None)) { + var item = root.CreateStorage("Playfield"); + Write(item.CreateStream("FTYP"), UInt32((uint)FuturePinballResourceKind.Image)); + Write(item.CreateStream("FPAT"), System.Text.Encoding.ASCII.GetBytes("C:\\unsafe\\playfield.bmp\0")); + Write(item.CreateStream("FLAD"), new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }); + Write(item.CreateStream("FDAT"), new byte[] { (byte)'B', (byte)'M', 1, 2, 3 }); + root.Flush(true); + } + + var library = FuturePinballLibraryReader.Load(path); + var entry = library.Entries.Single(); + Assert.That(entry.Kind, Is.EqualTo(FuturePinballResourceKind.Image)); + Assert.That(entry.OriginalPath, Is.EqualTo("C:\\unsafe\\playfield.bmp")); + Assert.That(entry.Data.IsCompressed, Is.False); + Assert.That(entry.Data.DecodedBytes, Is.EqualTo(new byte[] { (byte)'B', (byte)'M', 1, 2, 3 })); + Assert.That(entry.Streams.Keys, Is.EquivalentTo(new[] { "FTYP", "FPAT", "FLAD", "FDAT" })); + } finally { + File.Delete(path); + } + } + + [Test] + public void ReadsStandaloneModelRecords() + { + var path = TemporaryPath("fpm"); + try { + using (var root = RootStorage.Create(path, OpenMcdf.Version.V3, StorageModeFlags.None)) { + var modelStorage = root.CreateStorage("PinModel"); + Write(modelStorage.CreateStream("ModelData"), Join( + StringRecord(0xA4F4D1D7, "Test Model"), + IntegerRecord(0xA4F1B9D1, 8), + Record(0xA7FDC4E0, Array.Empty()) + )); + root.Flush(true); + } + + var model = FuturePinballModelReader.Load(path); + Assert.That(model.ModelData.Text(0xA4F4D1D7), Is.EqualTo("Test Model")); + Assert.That(model.ModelData.Integer(0xA4F1B9D1), Is.EqualTo(8)); + } finally { + File.Delete(path); + } + } + + [Test] + public void OpenMcdfUpgradeRoundTripsVpxTables() + { + var path = TemporaryPath("vpx"); + try { + var source = new TableBuilder().AddBumper("Bumper1").Build(); + source.Table.Data.NumGameItems = source.ItemDatas.Count(); + source.Export(path); + using (var root = RootStorage.OpenRead(path, StorageModeFlags.None)) { + var storage = root.OpenStorage("GameStg"); + var item = storage.OpenStream("GameItem0"); + Assert.That(item.Length, Is.GreaterThan(4)); + var type = new byte[4]; + Assert.That(item.Read(type, 0, type.Length), Is.EqualTo(type.Length)); + Assert.That(BitConverter.ToInt32(type, 0), Is.EqualTo((int)VisualPinball.Engine.VPT.ItemType.Bumper)); + } + var loaded = FileTableContainer.Load(path); + Assert.That(loaded.FileVersion, Is.EqualTo(1060)); + Assert.That(loaded.NumGameItems, Is.EqualTo(source.NumGameItems)); + Assert.That(loaded.ItemDatas.Count(), Is.EqualTo(source.ItemDatas.Count())); + Assert.That(loaded.Bumper("Bumper1"), Is.Not.Null); + Assert.That(loaded.FileHash, Has.Length.EqualTo(16)); + } finally { + File.Delete(path); + } + } + + [Test] + public void ExtractsEveryLockedStreamAndReimportsDeterministically() + { + var fixture = FuturePinballFixtureCatalog.All[0]; + var output = TemporaryDirectory(); + try { + var manifest = FuturePinballExtractor.Extract(FixturePath(fixture), output); + Assert.That(manifest.Streams.Count, Is.EqualTo(fixture.CompoundEntryCount - 1)); + Assert.That(manifest.Streams.All(stream => File.Exists(BundlePath(output, stream.RawFile))), Is.True); + Assert.That(manifest.Counts.Elements, Is.EqualTo(fixture.ElementCount)); + Assert.That(manifest.Counts.Images, Is.EqualTo(fixture.ImageCount)); + Assert.That(manifest.Counts.Sounds, Is.EqualTo(fixture.SoundCount)); + Assert.That(manifest.Counts.Music, Is.EqualTo(fixture.MusicCount)); + Assert.That(manifest.Counts.PinModels, Is.EqualTo(fixture.PinModelCount)); + Assert.That(manifest.Counts.OpaqueRecords, Is.GreaterThan(0)); + Assert.That(File.Exists(BundlePath(output, manifest.OriginalFile)), Is.True); + Assert.That(File.Exists(BundlePath(output, "table/table-data.json")), Is.True); + Assert.That(File.Exists(BundlePath(output, "table/elements.json")), Is.True); + Assert.That(Sha256(File.ReadAllBytes(BundlePath(output, "scripts/table.vbs"))), Is.EqualTo(fixture.ScriptDecodedSha256)); + Assert.That(Sha256(File.ReadAllBytes(BundlePath(output, "scripts/table.zlzo")).Skip(8).ToArray()), Is.EqualTo(fixture.ScriptCompressedSha256)); + + var manifestPath = BundlePath(output, "manifest.json"); + var before = File.ReadAllBytes(manifestPath); + using (var json = JsonDocument.Parse(before)) { + Assert.That(json.RootElement.GetProperty("streams").GetArrayLength(), Is.EqualTo(fixture.CompoundEntryCount - 1)); + Assert.That(json.RootElement.GetProperty("resources").GetArrayLength(), Is.EqualTo( + fixture.ImageCount + fixture.SoundCount + fixture.MusicCount + fixture.PinModelCount + + fixture.DmdFontCount + fixture.ImageListCount + fixture.LightListCount + 1 + )); + } + + FuturePinballExtractor.Extract(FixturePath(fixture), output); + Assert.That(File.ReadAllBytes(manifestPath), Is.EqualTo(before)); + } finally { + if (Directory.Exists(output)) Directory.Delete(output, true); + } + } + + [Test] + public void ResolvesLinkedLibraryMediaWithoutUsingSourcePathForOutput() + { + var root = TemporaryDirectory(); + var tablePath = Path.Combine(root, "linked.fpt"); + var libraryPath = Path.Combine(root, "media.fpl"); + var output = Path.Combine(root, "bundle"); + try { + Directory.CreateDirectory(root); + using (var table = RootStorage.Create(tablePath, OpenMcdf.Version.V3, StorageModeFlags.None)) { + var storage = table.CreateStorage("Future Pinball"); + Write(storage.CreateStream("File Version"), UInt32(1)); + Write(storage.CreateStream("Table Data"), Join( + IntegerRecord(0x95FDCDD2, 0), IntegerRecord(0xA2F4C9D2, 1), + IntegerRecord(0xA5F3BFD2, 0), IntegerRecord(0x96ECC5D2, 0), + IntegerRecord(0xA5F2C5D2, 0), IntegerRecord(0x95F5C9D2, 0), + IntegerRecord(0x95F5C6D2, 0), IntegerRecord(0x9BFBCED2, 0), + Record(0xA7FDC4E0, Array.Empty()) + )); + Write(storage.CreateStream("Table MAC"), new byte[16]); + Write(storage.CreateStream("Image 1"), Join( + IntegerRecord(0xA4F1B9D1, 1), + StringRecord(0xA4F4D1D7, "Linked Image"), + StringRecord(0xA1EDD1D5, "C:\\Future Pinball\\Libraries\\unsafe.bmp"), + IntegerRecord(0x9EF3C6D9, 1), + Record(0xA7FDC4E0, Array.Empty()) + )); + table.Flush(true); + } + + var bitmap = new byte[] { (byte)'B', (byte)'M', 1, 2, 3, 4 }; + using (var library = RootStorage.Create(libraryPath, OpenMcdf.Version.V3, StorageModeFlags.None)) { + var item = library.CreateStorage("Linked Image"); + Write(item.CreateStream("FTYP"), UInt32((uint)FuturePinballResourceKind.Image)); + Write(item.CreateStream("FPAT"), System.Text.Encoding.ASCII.GetBytes("C:\\unsafe\\unsafe.bmp\0")); + Write(item.CreateStream("FDAT"), bitmap); + library.Flush(true); + } + + var manifest = FuturePinballExtractor.Extract(tablePath, output); + var resource = manifest.Resources.Single(item => item.Category == "images"); + Assert.That(resource.ResolutionStatus, Is.EqualTo("resolved")); + Assert.That(resource.ResolvedLibrary, Is.EqualTo("media.fpl")); + Assert.That(resource.ResolvedLibraryEntry, Is.EqualTo("Linked Image")); + Assert.That(resource.DetectedFormat, Is.EqualTo("bmp")); + var original = resource.Files.Single(file => file.Role == "original"); + Assert.That(original.Path, Does.Not.Contain("unsafe")); + Assert.That(File.ReadAllBytes(BundlePath(output, original.Path)), Is.EqualTo(bitmap)); + Assert.That(resource.Files.Any(file => file.Role == "library-FDAT"), Is.True); + Assert.That(Path.GetFullPath(BundlePath(output, original.Path)), Does.StartWith(Path.GetFullPath(output))); + } finally { + if (Directory.Exists(root)) Directory.Delete(root, true); + } + } + + private static string FixturePath(FuturePinballFixtureExpectation fixture) + { + var fixtureRoot = Environment.GetEnvironmentVariable(FuturePinballFixtureCatalog.FixtureRootEnvironmentVariable); + if (string.IsNullOrWhiteSpace(fixtureRoot)) { + Assert.Ignore($"Set {FuturePinballFixtureCatalog.FixtureRootEnvironmentVariable} to run Future Pinball corpus tests."); + } + return Path.GetFullPath(Path.Combine(fixtureRoot, fixture.RelativePath.Replace('/', Path.DirectorySeparatorChar))); + } + + private static string ElementTypeCounts(IEnumerable elements) + { + return string.Join(",", elements + .GroupBy(element => element.ElementTypeId.Value) + .OrderBy(group => group.Key) + .Select(group => $"{group.Key}:{group.Count()}")); + } + + private static string Sha256(byte[] data) + { + using (var sha = SHA256.Create()) { + return BitConverter.ToString(sha.ComputeHash(data)).Replace("-", string.Empty).ToLowerInvariant(); + } + } + + private static string TemporaryPath(string extension) + { + return Path.Combine(Path.GetTempPath(), $"vpe-fp-{Guid.NewGuid():N}.{extension}"); + } + + private static string TemporaryDirectory() + { + return Path.Combine(Path.GetTempPath(), $"vpe-fp-{Guid.NewGuid():N}"); + } + + private static string BundlePath(string root, string relativePath) + { + return Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + } + + private static void Write(CfbStream stream, byte[] data) + { + stream.SetLength(data.Length); + stream.Position = 0; + stream.Write(data, 0, data.Length); + stream.Flush(); + } + + private static byte[] IntegerRecord(uint tag, int value) + { + return Record(tag, UInt32((uint)value)); + } + + private static byte[] StringRecord(uint tag, string value) + { + return Record(tag, AsciiString(value)); + } + + private static byte[] AsciiString(string value) + { + var data = System.Text.Encoding.ASCII.GetBytes(value); + return Join(UInt32((uint)data.Length), data); + } + + private static byte[] Record(uint tag, byte[] payload, int? storedLength = null) + { + return Join(UInt32((uint)(storedLength ?? payload.Length + sizeof(uint))), UInt32(tag), payload); + } + + private static byte[] UInt32(uint value) + { + return new[] { + (byte)value, + (byte)(value >> 8), + (byte)(value >> 16), + (byte)(value >> 24) + }; + } + + private static byte[] Join(params byte[][] parts) + { + var length = parts.Sum(part => part.Length); + var result = new byte[length]; + var offset = 0; + foreach (var part in parts) { + Buffer.BlockCopy(part, 0, result, offset, part.Length); + offset += part.Length; + } + return result; + } + } +} diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballReaderTests.cs.meta b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballReaderTests.cs.meta new file mode 100644 index 000000000..3e50f38a6 --- /dev/null +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballReaderTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6cd6f986039a42a2848ed7a1f287905c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/MilkShapeModelReaderTests.cs b/VisualPinball.Engine.Test/IO/FuturePinball/MilkShapeModelReaderTests.cs new file mode 100644 index 000000000..a038d6143 --- /dev/null +++ b/VisualPinball.Engine.Test/IO/FuturePinball/MilkShapeModelReaderTests.cs @@ -0,0 +1,155 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System.IO; +using System.Text; + +using NUnit.Framework; + +using VisualPinball.Engine.IO.FuturePinball; + +namespace VisualPinball.Engine.Test.IO.FuturePinball +{ + [TestFixture] + public class MilkShapeModelReaderTests + { + [Test] + public void ParsesModelAndCreatesGroupMesh() + { + var model = MilkShapeModelReader.Parse(CreateModel(), "fixture.ms3d"); + + Assert.That(model.Version, Is.EqualTo(4)); + Assert.That(model.Vertices, Has.Count.EqualTo(3)); + Assert.That(model.Triangles, Has.Count.EqualTo(1)); + Assert.That(model.Groups, Has.Count.EqualTo(1)); + Assert.That(model.Materials, Has.Count.EqualTo(1)); + Assert.That(model.Materials[0].Texture, Is.EqualTo("texture.png")); + Assert.That(model.TrailingData, Is.EqualTo(new byte[] { 0xaa, 0xbb, 0xcc })); + + var meshes = model.CreateMeshes(); + Assert.That(meshes, Has.Count.EqualTo(1)); + Assert.That(meshes[0].Name, Is.EqualTo("triangle")); + Assert.That(meshes[0].MaterialIndex, Is.Zero); + Assert.That(meshes[0].Mesh.Vertices, Has.Length.EqualTo(3)); + Assert.That(meshes[0].Mesh.Indices, Is.EqualTo(new[] { 0, 1, 2 })); + Assert.That(meshes[0].Mesh.Vertices[1].X, Is.EqualTo(1f)); + Assert.That(meshes[0].Mesh.Vertices[2].Y, Is.EqualTo(1f)); + Assert.That(meshes[0].Mesh.Vertices[0].Nz, Is.EqualTo(1f)); + Assert.That(meshes[0].Mesh.Vertices[1].Tu, Is.EqualTo(1f)); + Assert.That(meshes[0].Mesh.Vertices[2].Tv, Is.EqualTo(1f)); + } + + [Test] + public void ReusesModelsByContentHash() + { + var data = CreateModel(); + var cache = new MilkShapeModelCache(); + + var first = cache.Parse(data, "first.ms3d"); + var second = cache.Parse((byte[])data.Clone(), "second.ms3d"); + + Assert.That(second, Is.SameAs(first)); + } + + [Test] + public void RejectsTriangleVertexOutsideModel() + { + var action = new TestDelegate(() => MilkShapeModelReader.Parse(CreateModel(3), "bad.ms3d")); + + Assert.That(action, Throws.TypeOf() + .With.Message.Contains("outside 0..2") + .And.Message.Contains("bad.ms3d")); + } + + private static byte[] CreateModel(ushort firstTriangleVertex = 0) + { + using (var stream = new MemoryStream()) + using (var writer = new BinaryWriter(stream, Encoding.ASCII)) { + writer.Write(Encoding.ASCII.GetBytes("MS3D000000")); + writer.Write(4); + writer.Write((ushort)3); + WriteVertex(writer, 0f, 0f, 0f); + WriteVertex(writer, 1f, 0f, 0f); + WriteVertex(writer, 0f, 1f, 0f); + + writer.Write((ushort)1); + writer.Write((ushort)0); + writer.Write(firstTriangleVertex); + writer.Write((ushort)1); + writer.Write((ushort)2); + for (var corner = 0; corner < 3; corner++) { + writer.Write(0f); + writer.Write(0f); + writer.Write(1f); + } + writer.Write(0f); + writer.Write(1f); + writer.Write(0f); + writer.Write(0f); + writer.Write(0f); + writer.Write(1f); + writer.Write((byte)1); + writer.Write((byte)0); + + writer.Write((ushort)1); + writer.Write((byte)0); + WriteFixedString(writer, "triangle", 32); + writer.Write((ushort)1); + writer.Write((ushort)0); + writer.Write((sbyte)0); + + writer.Write((ushort)1); + WriteFixedString(writer, "material", 32); + WriteVector4(writer, 0.2f, 0.2f, 0.2f, 1f); + WriteVector4(writer, 0.8f, 0.7f, 0.6f, 1f); + WriteVector4(writer, 1f, 1f, 1f, 1f); + WriteVector4(writer, 0f, 0f, 0f, 1f); + writer.Write(16f); + writer.Write(0.75f); + writer.Write((byte)0); + WriteFixedString(writer, "texture.png", 128); + WriteFixedString(writer, "alpha.png", 128); + writer.Write(new byte[] { 0xaa, 0xbb, 0xcc }); + return stream.ToArray(); + } + } + + private static void WriteVertex(BinaryWriter writer, float x, float y, float z) + { + writer.Write((byte)0); + writer.Write(x); + writer.Write(y); + writer.Write(z); + writer.Write((sbyte)-1); + writer.Write((byte)0); + } + + private static void WriteVector4(BinaryWriter writer, float x, float y, float z, float w) + { + writer.Write(x); + writer.Write(y); + writer.Write(z); + writer.Write(w); + } + + private static void WriteFixedString(BinaryWriter writer, string value, int length) + { + var bytes = Encoding.ASCII.GetBytes(value); + writer.Write(bytes); + writer.Write(new byte[length - bytes.Length]); + } + } +} diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/MilkShapeModelReaderTests.cs.meta b/VisualPinball.Engine.Test/IO/FuturePinball/MilkShapeModelReaderTests.cs.meta new file mode 100644 index 000000000..1ddf69b83 --- /dev/null +++ b/VisualPinball.Engine.Test/IO/FuturePinball/MilkShapeModelReaderTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7873a0e915e947e3a6444c73e6ef374e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine/IO/BiffData.cs b/VisualPinball.Engine/IO/BiffData.cs index df9282fdd..000b4b215 100644 --- a/VisualPinball.Engine/IO/BiffData.cs +++ b/VisualPinball.Engine/IO/BiffData.cs @@ -75,10 +75,10 @@ protected BiffData(string storageName) public abstract void Write(BinaryWriter writer, HashWriter hashWriter); - public void WriteData(CFStorage gameStorage, HashWriter hashWriter = null) + public void WriteData(Storage gameStorage, HashWriter hashWriter = null) { - var itemData = gameStorage.AddStream(StorageName); - itemData.SetData(GetBytes(hashWriter)); + var itemData = gameStorage.CreateStream(StorageName); + itemData.WriteAll(GetBytes(hashWriter)); } private byte[] GetBytes(HashWriter hashWriter) diff --git a/VisualPinball.Engine/IO/CompoundStorageExtensions.cs b/VisualPinball.Engine/IO/CompoundStorageExtensions.cs new file mode 100644 index 000000000..7033f337c --- /dev/null +++ b/VisualPinball.Engine/IO/CompoundStorageExtensions.cs @@ -0,0 +1,56 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.IO; + +using OpenMcdf; + +namespace VisualPinball.Engine.IO +{ + internal static class CompoundStorageExtensions + { + public static byte[] ReadAll(this CfbStream stream) + { + if (stream.Length > int.MaxValue) { + throw new InvalidDataException($"Compound stream is too large to load ({stream.Length} bytes)."); + } + + stream.Position = 0; + var data = new byte[(int)stream.Length]; + var offset = 0; + while (offset < data.Length) { + var read = stream.Read(data, offset, data.Length - offset); + if (read == 0) { + throw new EndOfStreamException($"Compound stream ended after {offset} of {data.Length} bytes."); + } + offset += read; + } + return data; + } + + public static void WriteAll(this CfbStream stream, byte[] data) + { + if (data == null) { + throw new ArgumentNullException(nameof(data)); + } + stream.SetLength(data.Length); + stream.Position = 0; + stream.Write(data, 0, data.Length); + stream.Flush(); + } + } +} diff --git a/VisualPinball.Engine/IO/CompoundStorageExtensions.cs.meta b/VisualPinball.Engine/IO/CompoundStorageExtensions.cs.meta new file mode 100644 index 000000000..31c2706ee --- /dev/null +++ b/VisualPinball.Engine/IO/CompoundStorageExtensions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c35ac0c6741b4a20af3c32ba284f9ca4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine/IO/FuturePinball.meta b/VisualPinball.Engine/IO/FuturePinball.meta new file mode 100644 index 000000000..0e5053bfc --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e946fb695f1e4aa894741df6c73639d0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs new file mode 100644 index 000000000..ba62b2187 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs @@ -0,0 +1,434 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; + +using VisualPinball.Engine.Math; +using VisualPinball.Engine.VPT; + +namespace VisualPinball.Engine.IO.FuturePinball +{ + public enum FuturePinballColliderKind + { + None, + VerticalCylinder, + Sphere, + TaperedCapsule, + Box, + HorizontalCylinder, + Mesh + } + + public enum FuturePinballColliderStatus + { + Generated, + Skipped, + Unsupported, + Invalid + } + + public sealed class FuturePinballColliderDescription + { + public uint SourceType { get; internal set; } + public FuturePinballColliderKind Kind { get; internal set; } + public FuturePinballColliderStatus Status { get; internal set; } + public FuturePinballWorldPoint Center { get; internal set; } + public float Radius { get; internal set; } + public float SecondaryRadius { get; internal set; } + public float HalfLength { get; internal set; } + public float HalfHeight { get; internal set; } + public FuturePinballWorldPoint Size { get; internal set; } + public bool GenerateHitEvent { get; internal set; } + public uint EventId { get; internal set; } + public string Reason { get; internal set; } + public Mesh Mesh { get; internal set; } + } + + public sealed class FuturePinballColliderOptions + { + public bool EnableAnalyticShapes { get; set; } = true; + public bool EnablePerPolygonCollision { get; set; } = true; + public bool GenerateRenderMeshFallback { get; set; } + } + + public static class FuturePinballColliderBuilder + { + private const float Scale = FuturePinballCoordinateConverter.WorldUnitsPerMillimeter; + + public static IReadOnlyList FromModel( + FuturePinballSourceStream pinModel, + MilkShapeModel primaryModel = null, + FuturePinballColliderOptions options = null) + { + if (pinModel == null) throw new ArgumentNullException(nameof(pinModel)); + options ??= new FuturePinballColliderOptions(); + var result = new List(); + var shapesEnabled = RecordInteger(pinModel, "collision_shapes_enabled", 1) != 0; + var shapes = pinModel.Records.FirstOrDefault(record => record.Name == "collision_shapes")?.Value + as IReadOnlyList; + if (shapes != null) { + if (options.EnableAnalyticShapes && shapesEnabled) result.AddRange(FromShapes(shapes)); + else result.AddRange(shapes.Select(shape => Skipped(shape, "Analytic collision shapes are disabled"))); + } + + var perPolygon = RecordInteger(pinModel, "per_polygon_collision") != 0; + if (perPolygon && primaryModel != null) { + if (options.EnablePerPolygonCollision) result.Add(FromMesh(primaryModel.CreateMeshes().Select(mesh => mesh.Mesh))); + else result.Add(new FuturePinballColliderDescription { + Kind = FuturePinballColliderKind.Mesh, + Status = FuturePinballColliderStatus.Skipped, + Reason = "Per-polygon collision is disabled" + }); + } else if (!perPolygon && options.GenerateRenderMeshFallback && primaryModel != null) { + result.Add(FromMesh(primaryModel.CreateMeshes().Select(mesh => mesh.Mesh))); + } + return result; + } + + public static IReadOnlyList FromShapes( + IEnumerable shapes) + { + if (shapes == null) throw new ArgumentNullException(nameof(shapes)); + return shapes.Select(FromShape).ToArray(); + } + + public static FuturePinballColliderDescription FromShape(FuturePinballCollisionShape shape) + { + if (shape == null) throw new ArgumentNullException(nameof(shape)); + if (!shape.AffectsBall) return Skipped(shape, "The source shape does not affect the ball"); + if (!Finite(shape.X, shape.Y, shape.Z, shape.Value1, shape.Value2, shape.Value3, shape.Value4)) { + return Invalid(shape, "Collision shape contains a non-finite value"); + } + + var collider = Base(shape); + switch (shape.Type) { + case 1: + if (!Positive(shape.Value1, shape.Value2)) return Invalid(shape, "Cylinder radius and half-length must be positive"); + collider.Kind = FuturePinballColliderKind.VerticalCylinder; + collider.Radius = shape.Value1 * Scale; + collider.HalfLength = shape.Value2 * Scale; + break; + case 2: + if (!Positive(shape.Value1)) return Invalid(shape, "Sphere radius must be positive"); + collider.Kind = FuturePinballColliderKind.Sphere; + collider.Radius = shape.Value1 * Scale; + break; + case 3: + if (!Positive(shape.Value1, shape.Value2, shape.Value3, shape.Value4)) return Invalid(shape, "Flipper dimensions must be positive"); + collider.Kind = FuturePinballColliderKind.TaperedCapsule; + collider.Radius = shape.Value1 * Scale; + collider.SecondaryRadius = shape.Value2 * Scale; + collider.HalfLength = shape.Value3 * Scale * 0.5f; + collider.HalfHeight = shape.Value4 * Scale; + collider.Center = FuturePinballCoordinateConverter.ModelToWorld( + shape.X, shape.Y, shape.Z - shape.Value3 * 0.5f + ); + break; + case 5: + if (!Positive(shape.Value1, shape.Value2, shape.Value3)) return Invalid(shape, "Box half-extents must be positive"); + collider.Kind = FuturePinballColliderKind.Box; + collider.Size = new FuturePinballWorldPoint(shape.Value1 * Scale * 2f, shape.Value2 * Scale * 2f, shape.Value3 * Scale * 2f); + break; + case 7: + if (!Positive(shape.Value1, shape.Value2)) return Invalid(shape, "Cylinder radius and half-length must be positive"); + collider.Kind = FuturePinballColliderKind.HorizontalCylinder; + collider.Radius = shape.Value1 * Scale; + collider.SecondaryRadius = shape.Value3 * Scale; + collider.HalfLength = shape.Value2 * Scale; + break; + default: + collider.Kind = FuturePinballColliderKind.None; + collider.Status = FuturePinballColliderStatus.Unsupported; + collider.Reason = $"Unsupported Future Pinball collision shape type {shape.Type}"; + break; + } + return collider; + } + + public static FuturePinballColliderDescription FromMesh(IEnumerable meshes) + { + if (meshes == null) throw new ArgumentNullException(nameof(meshes)); + var merged = new Mesh(); + foreach (var source in meshes.Where(mesh => mesh?.IsSet == true)) { + var world = FuturePinballCoordinateConverter.ModelMeshToWorld(source); + var validIndices = new List(); + for (var i = 0; i + 2 < world.Indices.Length; i += 3) { + if (world.Indices[i] < 0 || world.Indices[i] >= world.Vertices.Length + || world.Indices[i + 1] < 0 || world.Indices[i + 1] >= world.Vertices.Length + || world.Indices[i + 2] < 0 || world.Indices[i + 2] >= world.Vertices.Length) continue; + var a = world.Vertices[world.Indices[i]]; + var b = world.Vertices[world.Indices[i + 1]]; + var c = world.Vertices[world.Indices[i + 2]]; + if (TriangleAreaSquared(a, b, c) > 1e-16f) { + validIndices.Add(world.Indices[i]); + validIndices.Add(world.Indices[i + 1]); + validIndices.Add(world.Indices[i + 2]); + } + } + world.Indices = validIndices.ToArray(); + if (world.Indices.Length > 0) merged.Merge(world); + } + return new FuturePinballColliderDescription { + Kind = FuturePinballColliderKind.Mesh, + Status = merged.IsSet && merged.Indices.Length > 0 ? FuturePinballColliderStatus.Generated : FuturePinballColliderStatus.Invalid, + Reason = merged.IsSet && merged.Indices.Length > 0 ? null : "No non-degenerate model triangles are available", + Mesh = merged.IsSet ? merged : null + }; + } + + private static FuturePinballColliderDescription Base(FuturePinballCollisionShape shape) + { + return new FuturePinballColliderDescription { + SourceType = shape.Type, + Status = FuturePinballColliderStatus.Generated, + Center = FuturePinballCoordinateConverter.ModelToWorld(shape.X, shape.Y, shape.Z), + GenerateHitEvent = shape.GenerateHitEvent, + EventId = shape.EventId + }; + } + + private static FuturePinballColliderDescription Skipped(FuturePinballCollisionShape shape, string reason) + { + var result = Base(shape); + result.Status = FuturePinballColliderStatus.Skipped; + result.Reason = reason; + return result; + } + + private static FuturePinballColliderDescription Invalid(FuturePinballCollisionShape shape, string reason) + { + var result = Base(shape); + result.Status = FuturePinballColliderStatus.Invalid; + result.Reason = reason; + return result; + } + + private static int RecordInteger(FuturePinballSourceStream stream, string name, int fallback = 0) + { + var value = stream.Records.FirstOrDefault(record => record.Name == name)?.Value; + return value is int integer ? integer : fallback; + } + + private static bool Positive(params float[] values) => values.All(value => value > 0f); + + private static bool Finite(params float[] values) => values.All(value => !float.IsNaN(value) && !float.IsInfinity(value)); + + private static float TriangleAreaSquared(Vertex3DNoTex2 a, Vertex3DNoTex2 b, Vertex3DNoTex2 c) + { + var abX = b.X - a.X; + var abY = b.Y - a.Y; + var abZ = b.Z - a.Z; + var acX = c.X - a.X; + var acY = c.Y - a.Y; + var acZ = c.Z - a.Z; + var x = abY * acZ - abZ * acY; + var y = abZ * acX - abX * acZ; + var z = abX * acY - abY * acX; + return x * x + y * y + z * z; + } + } + + public static class FuturePinballColliderMeshBuilder + { + public static Mesh Build(FuturePinballColliderDescription collider, int radialSegments = 24, int sphereStacks = 12) + { + if (collider == null) throw new ArgumentNullException(nameof(collider)); + if (collider.Status != FuturePinballColliderStatus.Generated) return null; + radialSegments = System.Math.Max(8, (radialSegments + 3) / 4 * 4); + sphereStacks = System.Math.Max(4, sphereStacks + sphereStacks % 2); + switch (collider.Kind) { + case FuturePinballColliderKind.Mesh: + return collider.Mesh?.Clone("Future Pinball collider mesh"); + case FuturePinballColliderKind.Box: + return Box(collider.Size); + case FuturePinballColliderKind.VerticalCylinder: + return Cylinder(collider.Radius, collider.Radius, collider.HalfLength, radialSegments, true); + case FuturePinballColliderKind.HorizontalCylinder: + return Cylinder(collider.Radius, collider.SecondaryRadius > 0f ? collider.SecondaryRadius : collider.Radius, + collider.HalfLength, radialSegments, false); + case FuturePinballColliderKind.Sphere: + return Sphere(collider.Radius, radialSegments, sphereStacks); + case FuturePinballColliderKind.TaperedCapsule: + return TaperedCapsule(collider.Radius, collider.SecondaryRadius, collider.HalfLength, + collider.HalfHeight, radialSegments); + default: + return null; + } + } + + public static bool IsTessellatedApproximation(FuturePinballColliderKind kind) + { + return kind == FuturePinballColliderKind.VerticalCylinder + || kind == FuturePinballColliderKind.HorizontalCylinder + || kind == FuturePinballColliderKind.Sphere + || kind == FuturePinballColliderKind.TaperedCapsule; + } + + private static Mesh Box(FuturePinballWorldPoint size) + { + var x = size.X * 0.5f; + var y = size.Y * 0.5f; + var z = size.Z * 0.5f; + return NamedMesh("Future Pinball box collider", new[] { + Vertex(-x, -y, -z), Vertex(x, -y, -z), Vertex(x, y, -z), Vertex(-x, y, -z), + Vertex(-x, -y, z), Vertex(x, -y, z), Vertex(x, y, z), Vertex(-x, y, z) + }, new[] { + 0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, + 0, 1, 5, 0, 5, 4, 3, 7, 6, 3, 6, 2, + 0, 4, 7, 0, 7, 3, 1, 2, 6, 1, 6, 5 + }); + } + + private static Mesh Cylinder(float radiusX, float radiusY, float halfLength, int segments, bool vertical) + { + var vertices = new List(segments * 2 + 2); + var indices = new List(segments * 12); + for (var i = 0; i < segments; i++) { + var angle = (float)(System.Math.PI * 2.0 * i / segments); + var x = (float)System.Math.Cos(angle) * radiusX; + var radial = (float)System.Math.Sin(angle) * radiusY; + vertices.Add(vertical ? Vertex(x, -halfLength, radial) : Vertex(x, radial, -halfLength)); + vertices.Add(vertical ? Vertex(x, halfLength, radial) : Vertex(x, radial, halfLength)); + } + var lowCenter = vertices.Count; + vertices.Add(vertical ? Vertex(0f, -halfLength, 0f) : Vertex(0f, 0f, -halfLength)); + var highCenter = vertices.Count; + vertices.Add(vertical ? Vertex(0f, halfLength, 0f) : Vertex(0f, 0f, halfLength)); + for (var i = 0; i < segments; i++) { + var next = (i + 1) % segments; + var low = i * 2; + var high = low + 1; + var nextLow = next * 2; + var nextHigh = nextLow + 1; + if (vertical) { + indices.Add(low); indices.Add(high); indices.Add(nextHigh); + indices.Add(low); indices.Add(nextHigh); indices.Add(nextLow); + indices.Add(lowCenter); indices.Add(low); indices.Add(nextLow); + indices.Add(highCenter); indices.Add(nextHigh); indices.Add(high); + } else { + indices.Add(low); indices.Add(nextHigh); indices.Add(high); + indices.Add(low); indices.Add(nextLow); indices.Add(nextHigh); + indices.Add(lowCenter); indices.Add(nextLow); indices.Add(low); + indices.Add(highCenter); indices.Add(high); indices.Add(nextHigh); + } + } + return NamedMesh(vertical ? "Future Pinball vertical cylinder collider" : "Future Pinball horizontal cylinder collider", + vertices.ToArray(), indices.ToArray()); + } + + private static Mesh Sphere(float radius, int segments, int stacks) + { + var vertices = new List((stacks - 1) * segments + 2) { Vertex(0f, -radius, 0f) }; + for (var stack = 1; stack < stacks; stack++) { + var latitude = -System.Math.PI * 0.5 + System.Math.PI * stack / stacks; + var y = (float)System.Math.Sin(latitude) * radius; + var ringRadius = (float)System.Math.Cos(latitude) * radius; + for (var segment = 0; segment < segments; segment++) { + var longitude = System.Math.PI * 2.0 * segment / segments; + vertices.Add(Vertex((float)System.Math.Cos(longitude) * ringRadius, y, + (float)System.Math.Sin(longitude) * ringRadius)); + } + } + var top = vertices.Count; + vertices.Add(Vertex(0f, radius, 0f)); + var indices = new List(segments * stacks * 6); + for (var segment = 0; segment < segments; segment++) { + var next = (segment + 1) % segments; + indices.Add(0); indices.Add(1 + segment); indices.Add(1 + next); + for (var stack = 0; stack < stacks - 2; stack++) { + var ring = 1 + stack * segments; + var nextRing = ring + segments; + indices.Add(ring + segment); indices.Add(nextRing + segment); indices.Add(nextRing + next); + indices.Add(ring + segment); indices.Add(nextRing + next); indices.Add(ring + next); + } + var lastRing = 1 + (stacks - 2) * segments; + indices.Add(top); indices.Add(lastRing + next); indices.Add(lastRing + segment); + } + return NamedMesh("Future Pinball sphere collider", vertices.ToArray(), indices.ToArray()); + } + + private static Mesh TaperedCapsule(float startRadius, float endRadius, float halfLength, float halfHeight, int segments) + { + var samples = new List(segments * 2); + for (var i = 0; i < segments; i++) { + var angle = System.Math.PI * 2.0 * i / segments; + var x = (float)System.Math.Cos(angle); + var z = (float)System.Math.Sin(angle); + samples.Add(new Point2(x * startRadius, z * startRadius - halfLength)); + samples.Add(new Point2(x * endRadius, z * endRadius + halfLength)); + } + var outline = ConvexHull(samples); + var vertices = new List(outline.Count * 2); + foreach (var point in outline) vertices.Add(Vertex(point.X, -halfHeight, point.Z)); + foreach (var point in outline) vertices.Add(Vertex(point.X, halfHeight, point.Z)); + var indices = new List(outline.Count * 12); + for (var i = 1; i + 1 < outline.Count; i++) { + indices.Add(0); indices.Add(i); indices.Add(i + 1); + indices.Add(outline.Count); indices.Add(outline.Count + i + 1); indices.Add(outline.Count + i); + } + for (var i = 0; i < outline.Count; i++) { + var next = (i + 1) % outline.Count; + indices.Add(i); indices.Add(outline.Count + i); indices.Add(outline.Count + next); + indices.Add(i); indices.Add(outline.Count + next); indices.Add(next); + } + return NamedMesh("Future Pinball tapered capsule collider", vertices.ToArray(), indices.ToArray()); + } + + private static List ConvexHull(IEnumerable points) + { + var sorted = points.OrderBy(point => point.X).ThenBy(point => point.Z).ToArray(); + var hull = new List(sorted.Length * 2); + foreach (var point in sorted) { + while (hull.Count >= 2 && Cross(hull[hull.Count - 2], hull[hull.Count - 1], point) <= 0f) hull.RemoveAt(hull.Count - 1); + hull.Add(point); + } + var lowerCount = hull.Count; + for (var i = sorted.Length - 2; i >= 0; i--) { + var point = sorted[i]; + while (hull.Count > lowerCount && Cross(hull[hull.Count - 2], hull[hull.Count - 1], point) <= 0f) hull.RemoveAt(hull.Count - 1); + hull.Add(point); + } + if (hull.Count > 1) hull.RemoveAt(hull.Count - 1); + return hull; + } + + private static float Cross(Point2 origin, Point2 a, Point2 b) + { + return (a.X - origin.X) * (b.Z - origin.Z) - (a.Z - origin.Z) * (b.X - origin.X); + } + + private static Vertex3DNoTex2 Vertex(float x, float y, float z) => new Vertex3DNoTex2(x, y, z); + + private static Mesh NamedMesh(string name, Vertex3DNoTex2[] vertices, int[] indices) + { + return new Mesh(vertices, indices) { Name = name }; + } + + private readonly struct Point2 + { + public float X { get; } + public float Z { get; } + + public Point2(float x, float z) + { + X = x; + Z = z; + } + } + } +} diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs.meta b/VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs.meta new file mode 100644 index 000000000..3e5bc14b7 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6bf5ea98fc37455bb7ad5b4bedcf8637 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballCompression.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballCompression.cs new file mode 100644 index 000000000..447431498 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballCompression.cs @@ -0,0 +1,230 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; + +namespace VisualPinball.Engine.IO.FuturePinball +{ + /// + /// Bounded decoder for the raw LZO1X streams used by Future Pinball's zLZO wrapper. + /// The algorithm follows minilzo's lzo1x_decompress_safe state machine. + /// + public static class FuturePinballCompression + { + private static readonly byte[] Signature = { (byte)'z', (byte)'L', (byte)'Z', (byte)'O' }; + + public static FuturePinballCompressedData Decode( + ReadOnlyMemory raw, + int maximumOutputBytes = FuturePinballReaderOptions.DefaultMaximumDecompressedBytes, + string sourceName = null, + long sourceOffset = -1) + { + if (!HasSignature(raw.Span)) { + return new FuturePinballCompressedData { + RawBytes = raw, + DecodedBytes = raw.ToArray(), + DeclaredUncompressedLength = raw.Length, + CompressedBytesConsumed = raw.Length + }; + } + + if (raw.Length < 8) { + throw new FuturePinballFormatException("Truncated zLZO header", sourceName, sourceOffset); + } + + var declaredLength = ReadInt32(raw.Span, 4); + if (declaredLength < 0 || declaredLength > maximumOutputBytes) { + throw new FuturePinballFormatException( + $"zLZO output length {declaredLength} exceeds the configured limit {maximumOutputBytes}", + sourceName, + sourceOffset + 4 + ); + } + + var input = raw.Slice(8).Span; + var output = new byte[declaredLength]; + try { + var consumed = DecodeLzo1X(input, output); + return new FuturePinballCompressedData { + RawBytes = raw, + IsCompressed = true, + DeclaredUncompressedLength = declaredLength, + CompressedBytesConsumed = consumed, + DecodedBytes = output + }; + } catch (FuturePinballFormatException) { + throw; + } catch (Exception exception) { + throw new FuturePinballFormatException("Invalid LZO1X stream", sourceName, sourceOffset + 8, exception); + } + } + + private static bool HasSignature(ReadOnlySpan data) + { + return data.Length >= Signature.Length + && data[0] == Signature[0] + && data[1] == Signature[1] + && data[2] == Signature[2] + && data[3] == Signature[3]; + } + + private static int ReadInt32(ReadOnlySpan data, int offset) + { + return data[offset] + | data[offset + 1] << 8 + | data[offset + 2] << 16 + | data[offset + 3] << 24; + } + + private static int DecodeLzo1X(ReadOnlySpan source, Span destination) + { + var input = 0; + var output = 0; + var value = ReadByte(source, ref input); + + if (value > 17) { + value -= 17; + CopyLiteral(source, ref input, destination, ref output, value); + value = ReadByte(source, ref input); + if (value < 16) { + throw new FuturePinballFormatException("Invalid initial LZO1X literal run"); + } + } + + input--; + while (true) { + value = ReadByte(source, ref input); + if (value < 16) { + if (value == 0) { + while (PeekByte(source, input) == 0) { + value += 255; + input++; + } + value += 15 + ReadByte(source, ref input); + } + + value += 3; + CopyLiteral(source, ref input, destination, ref output, value); + value = ReadByte(source, ref input); + if (value < 16) { + var match = output - 0x801 - (value >> 2) - (ReadByte(source, ref input) << 2); + CopyMatch(destination, ref output, match, 3); + value = source[input - 2] & 3; + if (value == 0) { + continue; + } + CopyLiteral(source, ref input, destination, ref output, value); + value = ReadByte(source, ref input); + } + } + + input--; + while (true) { + value = ReadByte(source, ref input); + int match; + if (value >= 64) { + match = output - 1 - ((value >> 2) & 7) - (ReadByte(source, ref input) << 3); + value = (value >> 5) - 1; + } else if (value >= 32) { + value &= 31; + if (value == 0) { + while (PeekByte(source, input) == 0) { + value += 255; + input++; + } + value += 31 + ReadByte(source, ref input); + } + match = output - 1 - (ReadByte(source, ref input) >> 2); + match -= ReadByte(source, ref input) << 6; + } else if (value >= 16) { + match = output - ((value & 8) << 11); + value &= 7; + if (value == 0) { + while (PeekByte(source, input) == 0) { + value += 255; + input++; + } + value += 7 + ReadByte(source, ref input); + } + match -= ReadByte(source, ref input) >> 2; + match -= ReadByte(source, ref input) << 6; + if (match == output) { + if (value != 1 || output != destination.Length) { + throw new FuturePinballFormatException( + $"LZO1X terminator produced {output} of {destination.Length} expected bytes" + ); + } + return input; + } + match -= 0x4000; + } else { + match = output - 1 - (value >> 2) - (ReadByte(source, ref input) << 2); + value = 0; + } + + CopyMatch(destination, ref output, match, value + 2); + value = source[input - 2] & 3; + if (value == 0) { + break; + } + CopyLiteral(source, ref input, destination, ref output, value); + } + } + } + + private static int ReadByte(ReadOnlySpan source, ref int offset) + { + if ((uint)offset >= (uint)source.Length) { + throw new FuturePinballFormatException("LZO1X input overrun"); + } + return source[offset++]; + } + + private static int PeekByte(ReadOnlySpan source, int offset) + { + if ((uint)offset >= (uint)source.Length) { + throw new FuturePinballFormatException("LZO1X input overrun"); + } + return source[offset]; + } + + private static void CopyLiteral(ReadOnlySpan source, ref int input, Span destination, ref int output, int count) + { + if (count < 0 || input > source.Length - count) { + throw new FuturePinballFormatException("LZO1X literal input overrun"); + } + if (output > destination.Length - count) { + throw new FuturePinballFormatException("LZO1X output overrun"); + } + source.Slice(input, count).CopyTo(destination.Slice(output, count)); + input += count; + output += count; + } + + private static void CopyMatch(Span destination, ref int output, int match, int count) + { + if (count < 0 || match < 0 || match >= output) { + throw new FuturePinballFormatException("LZO1X look-behind overrun"); + } + if (output > destination.Length - count) { + throw new FuturePinballFormatException("LZO1X output overrun"); + } + for (var i = 0; i < count; i++) { + destination[output++] = destination[match++]; + } + } + } +} diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballCompression.cs.meta b/VisualPinball.Engine/IO/FuturePinball/FuturePinballCompression.cs.meta new file mode 100644 index 000000000..593b7e0f8 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballCompression.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1e66239e7a9f4e0bb7df6cc5b01357e3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractionManifest.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractionManifest.cs new file mode 100644 index 000000000..5bc0fc820 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractionManifest.cs @@ -0,0 +1,325 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace VisualPinball.Engine.IO.FuturePinball +{ + public sealed class FuturePinballExtractionManifest + { + public int SchemaVersion { get; internal set; } = 1; + public string SourceFileName { get; internal set; } + public long SourceBytes { get; internal set; } + public string SourceSha256 { get; internal set; } + public string OriginalFile { get; internal set; } + public uint? FileVersion { get; internal set; } + public int CompoundEntryCount { get; internal set; } + public FuturePinballManifestCounts Counts { get; internal set; } + public IReadOnlyList Streams { get; internal set; } = Array.Empty(); + public IReadOnlyList Resources { get; internal set; } = Array.Empty(); + public IReadOnlyList Issues { get; internal set; } = Array.Empty(); + } + + public sealed class FuturePinballManifestCounts + { + public int Elements { get; internal set; } + public int Images { get; internal set; } + public int Sounds { get; internal set; } + public int Music { get; internal set; } + public int PinModels { get; internal set; } + public int DmdFonts { get; internal set; } + public int ImageLists { get; internal set; } + public int LightLists { get; internal set; } + public int OpaqueRecords { get; internal set; } + public int UnresolvedLinkedResources { get; internal set; } + } + + public sealed class FuturePinballManifestStream + { + public string Name { get; internal set; } + public string Kind { get; internal set; } + public int? SourceIndex { get; internal set; } + public uint? ElementTypeId { get; internal set; } + public string ElementType { get; internal set; } + public int Bytes { get; internal set; } + public string Sha256 { get; internal set; } + public string RawFile { get; internal set; } + public IReadOnlyList Records { get; internal set; } = Array.Empty(); + } + + public sealed class FuturePinballManifestRecord + { + public int Offset { get; internal set; } + public uint StoredLength { get; internal set; } + public int ConsumedLength { get; internal set; } + public string OriginalTag { get; internal set; } + public string CanonicalTag { get; internal set; } + public string Name { get; internal set; } + public string ValueKind { get; internal set; } + public string Value { get; internal set; } + public int PayloadBytes { get; internal set; } + public string PayloadSha256 { get; internal set; } + public string OpaqueFile { get; internal set; } + } + + public sealed class FuturePinballManifestResource + { + public string Category { get; internal set; } + public int? SourceIndex { get; internal set; } + public string SourceStream { get; internal set; } + public string LogicalName { get; internal set; } + public int? DeclaredType { get; internal set; } + public bool Linked { get; internal set; } + public string LinkedPath { get; internal set; } + public string ResolutionStatus { get; internal set; } + public string ResolvedLibrary { get; internal set; } + public string ResolvedLibraryEntry { get; internal set; } + public string DetectedFormat { get; internal set; } + public IReadOnlyList Files { get; internal set; } = Array.Empty(); + public IReadOnlyList Items { get; internal set; } = Array.Empty(); + public IReadOnlyList Referrers { get; internal set; } = Array.Empty(); + } + + public sealed class FuturePinballManifestFile + { + public string Role { get; internal set; } + public string Path { get; internal set; } + public int Bytes { get; internal set; } + public string Sha256 { get; internal set; } + } + + internal static class FuturePinballManifestJson + { + public static string Serialize(FuturePinballExtractionManifest manifest) + { + var json = new StringBuilder(1024 * 32); + json.Append("{\n"); + Property(json, 1, "schemaVersion", manifest.SchemaVersion, true); + Property(json, 1, "sourceFileName", manifest.SourceFileName, true); + Property(json, 1, "sourceBytes", manifest.SourceBytes, true); + Property(json, 1, "sourceSha256", manifest.SourceSha256, true); + Property(json, 1, "originalFile", manifest.OriginalFile, true); + NullableProperty(json, 1, "fileVersion", manifest.FileVersion, true); + Property(json, 1, "compoundEntryCount", manifest.CompoundEntryCount, true); + Indent(json, 1).Append("\"counts\": "); + WriteCounts(json, manifest.Counts); + json.Append(",\n"); + Indent(json, 1).Append("\"streams\": [\n"); + for (var i = 0; i < manifest.Streams.Count; i++) { + WriteStream(json, manifest.Streams[i], 2); + json.Append(i + 1 == manifest.Streams.Count ? "\n" : ",\n"); + } + Indent(json, 1).Append("],\n"); + Indent(json, 1).Append("\"resources\": [\n"); + for (var i = 0; i < manifest.Resources.Count; i++) { + WriteResource(json, manifest.Resources[i], 2); + json.Append(i + 1 == manifest.Resources.Count ? "\n" : ",\n"); + } + Indent(json, 1).Append("],\n"); + Indent(json, 1).Append("\"issues\": "); + WriteStrings(json, manifest.Issues); + json.Append("\n}\n"); + return json.ToString(); + } + + public static string SerializeTableData(FuturePinballManifestStream tableData) + { + var json = new StringBuilder(); + WriteStream(json, tableData, 0); + return json.Append('\n').ToString(); + } + + public static string SerializeElements(IReadOnlyList elements) + { + var json = new StringBuilder("[\n"); + for (var i = 0; i < elements.Count; i++) { + WriteStream(json, elements[i], 1); + json.Append(i + 1 == elements.Count ? "\n" : ",\n"); + } + return json.Append("]\n").ToString(); + } + + private static void WriteCounts(StringBuilder json, FuturePinballManifestCounts counts) + { + json.Append("{ "); + InlineProperty(json, "elements", counts.Elements, true); + InlineProperty(json, "images", counts.Images, true); + InlineProperty(json, "sounds", counts.Sounds, true); + InlineProperty(json, "music", counts.Music, true); + InlineProperty(json, "pinModels", counts.PinModels, true); + InlineProperty(json, "dmdFonts", counts.DmdFonts, true); + InlineProperty(json, "imageLists", counts.ImageLists, true); + InlineProperty(json, "lightLists", counts.LightLists, true); + InlineProperty(json, "opaqueRecords", counts.OpaqueRecords, true); + InlineProperty(json, "unresolvedLinkedResources", counts.UnresolvedLinkedResources, false); + json.Append(" }"); + } + + private static void WriteStream(StringBuilder json, FuturePinballManifestStream stream, int indent) + { + Indent(json, indent).Append("{\n"); + Property(json, indent + 1, "name", stream.Name, true); + Property(json, indent + 1, "kind", stream.Kind, true); + NullableProperty(json, indent + 1, "sourceIndex", stream.SourceIndex, true); + NullableProperty(json, indent + 1, "elementTypeId", stream.ElementTypeId, true); + Property(json, indent + 1, "elementType", stream.ElementType, true); + Property(json, indent + 1, "bytes", stream.Bytes, true); + Property(json, indent + 1, "sha256", stream.Sha256, true); + Property(json, indent + 1, "rawFile", stream.RawFile, true); + Indent(json, indent + 1).Append("\"records\": [\n"); + for (var i = 0; i < stream.Records.Count; i++) { + WriteRecord(json, stream.Records[i], indent + 2); + json.Append(i + 1 == stream.Records.Count ? "\n" : ",\n"); + } + Indent(json, indent + 1).Append("]\n"); + Indent(json, indent).Append('}'); + } + + private static void WriteRecord(StringBuilder json, FuturePinballManifestRecord record, int indent) + { + Indent(json, indent).Append("{ "); + InlineProperty(json, "offset", record.Offset, true); + InlineProperty(json, "storedLength", record.StoredLength, true); + InlineProperty(json, "consumedLength", record.ConsumedLength, true); + InlineProperty(json, "originalTag", record.OriginalTag, true); + InlineProperty(json, "canonicalTag", record.CanonicalTag, true); + InlineProperty(json, "name", record.Name, true); + InlineProperty(json, "valueKind", record.ValueKind, true); + InlineProperty(json, "value", record.Value, true); + InlineProperty(json, "payloadBytes", record.PayloadBytes, true); + InlineProperty(json, "payloadSha256", record.PayloadSha256, true); + InlineProperty(json, "opaqueFile", record.OpaqueFile, false); + json.Append(" }"); + } + + private static void WriteResource(StringBuilder json, FuturePinballManifestResource resource, int indent) + { + Indent(json, indent).Append("{\n"); + Property(json, indent + 1, "category", resource.Category, true); + NullableProperty(json, indent + 1, "sourceIndex", resource.SourceIndex, true); + Property(json, indent + 1, "sourceStream", resource.SourceStream, true); + Property(json, indent + 1, "logicalName", resource.LogicalName, true); + NullableProperty(json, indent + 1, "declaredType", resource.DeclaredType, true); + Property(json, indent + 1, "linked", resource.Linked, true); + Property(json, indent + 1, "linkedPath", resource.LinkedPath, true); + Property(json, indent + 1, "resolutionStatus", resource.ResolutionStatus, true); + Property(json, indent + 1, "resolvedLibrary", resource.ResolvedLibrary, true); + Property(json, indent + 1, "resolvedLibraryEntry", resource.ResolvedLibraryEntry, true); + Property(json, indent + 1, "detectedFormat", resource.DetectedFormat, true); + Indent(json, indent + 1).Append("\"files\": [\n"); + for (var i = 0; i < resource.Files.Count; i++) { + var file = resource.Files[i]; + Indent(json, indent + 2).Append("{ "); + InlineProperty(json, "role", file.Role, true); + InlineProperty(json, "path", file.Path, true); + InlineProperty(json, "bytes", file.Bytes, true); + InlineProperty(json, "sha256", file.Sha256, false); + json.Append(i + 1 == resource.Files.Count ? " }\n" : " },\n"); + } + Indent(json, indent + 1).Append("],\n"); + Indent(json, indent + 1).Append("\"items\": "); + WriteStrings(json, resource.Items); + json.Append(",\n"); + Indent(json, indent + 1).Append("\"referrers\": "); + WriteStrings(json, resource.Referrers); + json.Append('\n'); + Indent(json, indent).Append('}'); + } + + private static void WriteStrings(StringBuilder json, IReadOnlyList values) + { + json.Append('['); + for (var i = 0; i < values.Count; i++) { + if (i > 0) json.Append(", "); + String(json, values[i]); + } + json.Append(']'); + } + + private static void Property(StringBuilder json, int indent, string name, string value, bool comma) + { + Indent(json, indent); + String(json, name).Append(": "); + String(json, value); + json.Append(comma ? ",\n" : "\n"); + } + + private static void Property(StringBuilder json, int indent, string name, long value, bool comma) + { + Indent(json, indent); + String(json, name).Append(": ").Append(value.ToString(CultureInfo.InvariantCulture)); + json.Append(comma ? ",\n" : "\n"); + } + + private static void Property(StringBuilder json, int indent, string name, bool value, bool comma) + { + Indent(json, indent); + String(json, name).Append(": ").Append(value ? "true" : "false"); + json.Append(comma ? ",\n" : "\n"); + } + + private static void NullableProperty(StringBuilder json, int indent, string name, T? value, bool comma) where T : struct + { + Indent(json, indent); + String(json, name).Append(": "); + json.Append(value.HasValue ? Convert.ToString(value.Value, CultureInfo.InvariantCulture) : "null"); + json.Append(comma ? ",\n" : "\n"); + } + + private static void InlineProperty(StringBuilder json, string name, string value, bool comma) + { + String(json, name).Append(": "); + String(json, value); + if (comma) json.Append(", "); + } + + private static void InlineProperty(StringBuilder json, string name, long value, bool comma) + { + String(json, name).Append(": ").Append(value.ToString(CultureInfo.InvariantCulture)); + if (comma) json.Append(", "); + } + + private static StringBuilder String(StringBuilder json, string value) + { + if (value == null) return json.Append("null"); + json.Append('"'); + foreach (var character in value) { + switch (character) { + case '"': json.Append("\\\""); break; + case '\\': json.Append("\\\\"); break; + case '\b': json.Append("\\b"); break; + case '\f': json.Append("\\f"); break; + case '\n': json.Append("\\n"); break; + case '\r': json.Append("\\r"); break; + case '\t': json.Append("\\t"); break; + default: + if (character < 0x20) json.Append("\\u").Append(((int)character).ToString("x4")); + else json.Append(character); + break; + } + } + return json.Append('"'); + } + + private static StringBuilder Indent(StringBuilder json, int indent) + { + return json.Append(' ', indent * 2); + } + } +} diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractionManifest.cs.meta b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractionManifest.cs.meta new file mode 100644 index 000000000..f8bfe13f9 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractionManifest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e1e25b663b63421ca3d2d0c065bebf6e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs new file mode 100644 index 000000000..ac6801498 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs @@ -0,0 +1,552 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +namespace VisualPinball.Engine.IO.FuturePinball +{ + public sealed class FuturePinballExtractionOptions + { + public bool CopyOriginalTable { get; set; } = true; + public bool OverwriteChangedFiles { get; set; } + public bool SearchAdjacentLibraries { get; set; } = true; + public bool ExtractOpaqueRecords { get; set; } = true; + public IReadOnlyList LibrarySearchRoots { get; set; } = Array.Empty(); + public FuturePinballReaderOptions ReaderOptions { get; set; } = new FuturePinballReaderOptions(); + } + + public static class FuturePinballExtractor + { + private const uint NameTag = 0xA4F4D1D7; + private const uint TypeTag = 0xA4F1B9D1; + private const uint LinkedTag = 0x9EF3C6D9; + private const uint LinkedPathTag = 0xA1EDD1D5; + private const uint DataTag = 0xA8EDD1E1; + private const uint ScriptTag = 0x4F5A4C7A; + private const uint ListItemsTag = 0xA8EDD1E1; + + private static readonly UTF8Encoding Utf8 = new UTF8Encoding(false); + private static readonly HashSet InvalidFileNameCharacters = new HashSet("<>:\"/\\|?*"); + + public static FuturePinballExtractionManifest Extract( + string tablePath, + string outputDirectory, + FuturePinballExtractionOptions options = null) + { + if (tablePath == null) throw new ArgumentNullException(nameof(tablePath)); + if (outputDirectory == null) throw new ArgumentNullException(nameof(outputDirectory)); + options ??= new FuturePinballExtractionOptions(); + var sourcePath = Path.GetFullPath(tablePath); + var outputRoot = Path.GetFullPath(outputDirectory); + Directory.CreateDirectory(outputRoot); + + var table = FuturePinballTableReader.Load(sourcePath, options.ReaderOptions); + var issues = new List(table.Issues); + var manifestStreams = ExtractStreams(outputRoot, table.Streams, options); + var libraries = LoadLibraries(sourcePath, options, issues); + var resources = new List(); + resources.AddRange(ExtractResources(outputRoot, table.Images, "images", libraries, options, issues)); + resources.AddRange(ExtractResources(outputRoot, table.Sounds, "sounds", libraries, options, issues)); + resources.AddRange(ExtractResources(outputRoot, table.Music, "music", libraries, options, issues)); + resources.AddRange(ExtractPinModels(outputRoot, table.PinModels, libraries, options, issues)); + resources.AddRange(ExtractDmdFonts(outputRoot, table.DmdFonts, options)); + resources.AddRange(ExtractLists(table.ImageLists, "image-lists")); + resources.AddRange(ExtractLists(table.LightLists, "light-lists")); + ExtractScript(outputRoot, table, resources, options); + AddReferrers(table.Streams, resources); + + var originalFile = options.CopyOriginalTable + ? $"original/{SafeName(Path.GetFileName(sourcePath), "table.fpt")}" : null; + if (originalFile != null) { + WriteFile(outputRoot, originalFile, File.ReadAllBytes(sourcePath), options); + } + + var manifest = new FuturePinballExtractionManifest { + SourceFileName = Path.GetFileName(sourcePath), + SourceBytes = new FileInfo(sourcePath).Length, + SourceSha256 = Sha256(sourcePath), + OriginalFile = originalFile, + FileVersion = table.FileVersion, + CompoundEntryCount = table.CompoundEntryCount, + Counts = new FuturePinballManifestCounts { + Elements = table.Elements.Count, + Images = table.Images.Count, + Sounds = table.Sounds.Count, + Music = table.Music.Count, + PinModels = table.PinModels.Count, + DmdFonts = table.DmdFonts.Count, + ImageLists = table.ImageLists.Count, + LightLists = table.LightLists.Count, + OpaqueRecords = manifestStreams.Sum(stream => stream.Records.Count(record => record.OpaqueFile != null)), + UnresolvedLinkedResources = resources.Count(resource => resource.Linked && resource.ResolutionStatus == "unresolved") + }, + Streams = manifestStreams, + Resources = resources, + Issues = issues + }; + + var tableData = manifestStreams.FirstOrDefault(stream => stream.Kind == FuturePinballStreamKind.TableData.ToString()); + if (tableData != null) { + WriteText(outputRoot, "table/table-data.json", FuturePinballManifestJson.SerializeTableData(tableData), options); + } + var elements = manifestStreams.Where(stream => stream.Kind == FuturePinballStreamKind.TableElement.ToString()).ToArray(); + WriteText(outputRoot, "table/elements.json", FuturePinballManifestJson.SerializeElements(elements), options); + WriteText(outputRoot, "manifest.json", FuturePinballManifestJson.Serialize(manifest), options); + return manifest; + } + + private static List ExtractStreams( + string outputRoot, + IReadOnlyList streams, + FuturePinballExtractionOptions options) + { + var result = new List(streams.Count); + foreach (var stream in streams) { + var index = stream.SourceIndex.HasValue ? stream.SourceIndex.Value.ToString("D5") + "-" : string.Empty; + var category = stream.Kind.ToString().ToLowerInvariant(); + var rawPath = $"streams/{category}/{index}{SafeName(stream.Name, "stream")}.bin"; + WriteFile(outputRoot, rawPath, stream.RawData, options); + + var records = new List(); + for (var i = 0; i < stream.Records.Count; i++) { + var record = stream.Records[i]; + string opaquePath = null; + if (options.ExtractOpaqueRecords && record.ValueKind == FuturePinballValueKind.Opaque) { + opaquePath = $"opaque/{category}/{index}{SafeName(stream.Name, "stream")}-{i:D4}-{record.OriginalTag:X8}.bin"; + WriteFile(outputRoot, opaquePath, record.Payload.ToArray(), options); + } + records.Add(new FuturePinballManifestRecord { + Offset = record.Offset, + StoredLength = record.StoredLength, + ConsumedLength = record.ConsumedLength, + OriginalTag = $"0x{record.OriginalTag:X8}", + CanonicalTag = $"0x{record.CanonicalTag:X8}", + Name = record.Name, + ValueKind = record.ValueKind.ToString(), + Value = DisplayValue(record.Value), + PayloadBytes = record.Payload.Length, + PayloadSha256 = Sha256(record.Payload.Span), + OpaqueFile = opaquePath + }); + } + result.Add(new FuturePinballManifestStream { + Name = stream.Name, + Kind = stream.Kind.ToString(), + SourceIndex = stream.SourceIndex, + ElementTypeId = stream.ElementTypeId, + ElementType = stream.ElementType?.ToString(), + Bytes = stream.RawData.Length, + Sha256 = Sha256(stream.RawData), + RawFile = rawPath, + Records = records + }); + } + return result; + } + + private static IEnumerable ExtractResources( + string outputRoot, + IEnumerable streams, + string category, + IReadOnlyList libraries, + FuturePinballExtractionOptions options, + ICollection issues) + { + foreach (var stream in streams) { + var logicalName = stream.Text(NameTag) ?? $"{category}-{stream.SourceIndex}"; + var linked = (stream.Integer(LinkedTag) ?? 0) != 0; + var linkedPath = stream.Text(LinkedPathTag); + var data = (stream.FirstRecord(DataTag)?.Value as FuturePinballCompressedData)?.DecodedBytes; + FuturePinballLibrary library = null; + FuturePinballLibraryEntry entry = null; + if (data == null && linked) { + FindLibraryResource(libraries, logicalName, linkedPath, out library, out entry); + data = entry?.Data?.DecodedBytes; + } + + var files = new List(); + var format = data == null ? null : DetectFormat(data, stream.Integer(TypeTag), linkedPath); + if (data != null) { + var path = $"media/{category}/{stream.SourceIndex:D5}-{SafeName(logicalName, category)}.{Extension(format)}"; + WriteFile(outputRoot, path, data, options); + files.Add(ManifestFile("original", path, data)); + } + if (entry != null) ExtractLibraryStreams(outputRoot, library, entry, files, options); + var status = data != null ? (entry == null ? "embedded" : "resolved") : (linked ? "unresolved" : "missing-data"); + if (status == "unresolved") { + issues.Add($"Could not resolve linked {category} resource '{logicalName}' ({linkedPath ?? "no linked path"})."); + } + yield return new FuturePinballManifestResource { + Category = category, + SourceIndex = stream.SourceIndex, + SourceStream = stream.Name, + LogicalName = logicalName, + DeclaredType = stream.Integer(TypeTag), + Linked = linked, + LinkedPath = linkedPath, + ResolutionStatus = status, + ResolvedLibrary = library == null ? null : Path.GetFileName(library.SourcePath), + ResolvedLibraryEntry = entry?.Name, + DetectedFormat = format, + Files = files + }; + } + } + + private static IEnumerable ExtractPinModels( + string outputRoot, + IEnumerable streams, + IReadOnlyList libraries, + FuturePinballExtractionOptions options, + ICollection issues) + { + foreach (var stream in streams) { + var logicalName = stream.Text(NameTag) ?? $"model-{stream.SourceIndex}"; + var linked = (stream.Integer(LinkedTag) ?? 0) != 0; + var linkedPath = stream.Text(LinkedPathTag); + var files = new List(); + foreach (var record in stream.Records.Where(record => record.Value is FuturePinballCompressedData)) { + var data = ((FuturePinballCompressedData)record.Value).DecodedBytes; + if (data == null || data.Length == 0) continue; + var format = DetectFormat(data, null, null); + var path = $"models/{stream.SourceIndex:D5}-{SafeName(logicalName, "model")}/{SafeName(record.Name, "data")}.{Extension(format)}"; + WriteFile(outputRoot, path, data, options); + files.Add(ManifestFile(record.Name ?? "data", path, data)); + } + + FuturePinballLibrary library = null; + FuturePinballLibraryEntry entry = null; + string detectedFormat = files.Count == 0 ? null : Path.GetExtension(files[0].Path).TrimStart('.'); + if (files.Count == 0 && linked) { + FindLibraryResource(libraries, logicalName, linkedPath, out library, out entry); + var data = entry?.Data?.DecodedBytes; + if (data != null) { + var format = DetectFormat(data, null, linkedPath); + detectedFormat = format; + var path = $"models/{stream.SourceIndex:D5}-{SafeName(logicalName, "model")}/linked.{Extension(format)}"; + WriteFile(outputRoot, path, data, options); + files.Add(ManifestFile("linked-model", path, data)); + } + } + if (entry != null) ExtractLibraryStreams(outputRoot, library, entry, files, options); + + var status = files.Count > 0 ? (entry == null ? "embedded" : "resolved") : (linked ? "unresolved" : "missing-data"); + if (status == "unresolved") { + issues.Add($"Could not resolve linked model resource '{logicalName}' ({linkedPath ?? "no linked path"})."); + } + yield return new FuturePinballManifestResource { + Category = "models", + SourceIndex = stream.SourceIndex, + SourceStream = stream.Name, + LogicalName = logicalName, + DeclaredType = stream.Integer(TypeTag), + Linked = linked, + LinkedPath = linkedPath, + ResolutionStatus = status, + ResolvedLibrary = library == null ? null : Path.GetFileName(library.SourcePath), + ResolvedLibraryEntry = entry?.Name, + DetectedFormat = detectedFormat, + Files = files + }; + } + } + + private static IEnumerable ExtractDmdFonts( + string outputRoot, + IEnumerable streams, + FuturePinballExtractionOptions options) + { + foreach (var stream in streams) { + var logicalName = $"dmd-font-{stream.SourceIndex}"; + var path = $"media/dmd-fonts/{stream.SourceIndex:D5}-{logicalName}.dmdf"; + WriteFile(outputRoot, path, stream.RawData, options); + yield return new FuturePinballManifestResource { + Category = "dmd-fonts", + SourceIndex = stream.SourceIndex, + SourceStream = stream.Name, + LogicalName = logicalName, + ResolutionStatus = "embedded", + DetectedFormat = "dmdf", + Files = new[] { ManifestFile("original", path, stream.RawData) } + }; + } + } + + private static IEnumerable ExtractLists( + IEnumerable streams, + string category) + { + foreach (var stream in streams) { + var logicalName = stream.Text(NameTag) ?? $"{category}-{stream.SourceIndex}"; + var items = stream.FirstRecord(ListItemsTag)?.Value as IReadOnlyList ?? Array.Empty(); + yield return new FuturePinballManifestResource { + Category = category, + SourceIndex = stream.SourceIndex, + SourceStream = stream.Name, + LogicalName = logicalName, + ResolutionStatus = "embedded", + Items = items + }; + } + } + + private static void ExtractScript( + string outputRoot, + FuturePinballTable table, + ICollection resources, + FuturePinballExtractionOptions options) + { + var record = table.TableData?.FirstRecord(ScriptTag); + var script = record?.Value as FuturePinballCompressedData; + if (script == null) return; + const string rawPath = "scripts/table.zlzo"; + const string decodedPath = "scripts/table.vbs"; + WriteFile(outputRoot, rawPath, script.RawBytes.ToArray(), options); + WriteFile(outputRoot, decodedPath, script.DecodedBytes, options); + resources.Add(new FuturePinballManifestResource { + Category = "scripts", + SourceStream = table.TableData.Name, + LogicalName = "table", + ResolutionStatus = "embedded", + DetectedFormat = "vbs", + Files = new[] { + ManifestFile("compressed", rawPath, script.RawBytes.ToArray()), + ManifestFile("decoded", decodedPath, script.DecodedBytes) + } + }); + } + + private static void AddReferrers( + IReadOnlyList streams, + IEnumerable resources) + { + foreach (var resource in resources) { + if (string.IsNullOrEmpty(resource.LogicalName)) continue; + resource.Referrers = streams.Where(stream => stream.Records.Any(record => References(record.Value, resource.LogicalName))) + .Select(stream => stream.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + } + + private static bool References(object value, string logicalName) + { + if (value is string text) return text.Equals(logicalName, StringComparison.OrdinalIgnoreCase); + if (value is IReadOnlyList list) return list.Any(item => item.Equals(logicalName, StringComparison.OrdinalIgnoreCase)); + return false; + } + + private static IReadOnlyList LoadLibraries( + string tablePath, + FuturePinballExtractionOptions options, + ICollection issues) + { + var roots = new List(); + if (options.SearchAdjacentLibraries) roots.Add(Path.GetDirectoryName(tablePath)); + if (options.LibrarySearchRoots != null) roots.AddRange(options.LibrarySearchRoots); + var files = new List(); + foreach (var root in roots.Where(root => !string.IsNullOrWhiteSpace(root)).Distinct(StringComparer.OrdinalIgnoreCase)) { + try { + if (Directory.Exists(root)) files.AddRange(Directory.EnumerateFiles(root, "*.fpl", SearchOption.TopDirectoryOnly)); + } catch (IOException exception) { + issues.Add($"Could not enumerate library root '{root}': {exception.Message}"); + } catch (UnauthorizedAccessException exception) { + issues.Add($"Could not enumerate library root '{root}': {exception.Message}"); + } + } + var libraries = new List(); + foreach (var file in files.Select(Path.GetFullPath).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(path => path, StringComparer.OrdinalIgnoreCase)) { + try { + libraries.Add(FuturePinballLibraryReader.Load(file, options.ReaderOptions)); + } catch (IOException exception) { + issues.Add($"Could not read library '{Path.GetFileName(file)}': {exception.Message}"); + } catch (UnauthorizedAccessException exception) { + issues.Add($"Could not read library '{Path.GetFileName(file)}': {exception.Message}"); + } catch (OpenMcdf.FileFormatException exception) { + issues.Add($"Could not read library '{Path.GetFileName(file)}': {exception.Message}"); + } + } + return libraries; + } + + private static void FindLibraryResource( + IReadOnlyList libraries, + string logicalName, + string linkedPath, + out FuturePinballLibrary library, + out FuturePinballLibraryEntry entry) + { + foreach (var candidate in libraries) { + var match = candidate.Entries.FirstOrDefault(item => item.Name.Equals(logicalName, StringComparison.OrdinalIgnoreCase)); + if (match == null && linkedPath != null) { + var linkedFileName = FileNameOnly(linkedPath); + match = candidate.Entries.FirstOrDefault(item => + FileNameOnly(item.OriginalPath).Equals(linkedFileName, StringComparison.OrdinalIgnoreCase)); + } + if (match != null) { + library = candidate; + entry = match; + return; + } + } + library = null; + entry = null; + } + + private static string FileNameOnly(string sourcePath) + { + if (sourcePath == null) return string.Empty; + var normalized = sourcePath.Replace('\\', '/'); + return normalized.Substring(normalized.LastIndexOf('/') + 1); + } + + private static FuturePinballManifestFile ManifestFile(string role, string path, byte[] data) + { + return new FuturePinballManifestFile { Role = role, Path = path, Bytes = data.Length, Sha256 = Sha256(data) }; + } + + private static void ExtractLibraryStreams( + string outputRoot, + FuturePinballLibrary library, + FuturePinballLibraryEntry entry, + ICollection files, + FuturePinballExtractionOptions options) + { + var libraryName = SafeName(Path.GetFileNameWithoutExtension(library.SourcePath), "library"); + var entryName = SafeName(entry.Name, "entry"); + foreach (var stream in entry.Streams.OrderBy(item => item.Key, StringComparer.OrdinalIgnoreCase)) { + var path = $"libraries/{libraryName}/{entryName}/{SafeName(stream.Key, "stream")}.bin"; + WriteFile(outputRoot, path, stream.Value, options); + files.Add(ManifestFile($"library-{stream.Key}", path, stream.Value)); + } + } + + private static string DisplayValue(object value) + { + switch (value) { + case null: return null; + case string text: return text; + case int integer: return integer.ToString(CultureInfo.InvariantCulture); + case uint color: return $"0x{color:X8}"; + case float number: return number.ToString("R", CultureInfo.InvariantCulture); + case FuturePinballVector2 vector: return $"{vector.X.ToString("R", CultureInfo.InvariantCulture)},{vector.Y.ToString("R", CultureInfo.InvariantCulture)}"; + case IReadOnlyList strings: return string.Join("|", strings); + case IReadOnlyList shapes: return $"{shapes.Count} collision shapes"; + case FuturePinballCompressedData compressed: return $"{compressed.DecodedBytes?.Length ?? 0} decoded bytes"; + default: return null; + } + } + + private static string DetectFormat(byte[] data, int? declaredType, string originalPath) + { + if (data.Length >= 2 && data[0] == 'B' && data[1] == 'M') return "bmp"; + if (data.Length >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff) return "jpg"; + if (StartsWith(data, "OggS")) return "ogg"; + if (data.Length >= 12 && StartsWith(data, "RIFF") && Encoding.ASCII.GetString(data, 8, 4) == "WAVE") return "wav"; + if (StartsWith(data, "ID3") || data.Length >= 2 && data[0] == 0xff && (data[1] & 0xe0) == 0xe0) return "mp3"; + if (StartsWith(data, "MS3D000000")) return "ms3d"; + if (data.Length >= 8 && data[0] == 0xd0 && data[1] == 0xcf && data[2] == 0x11 && data[3] == 0xe0 + && data[4] == 0xa1 && data[5] == 0xb1 && data[6] == 0x1a && data[7] == 0xe1) return "fpm"; + if (data.Length >= 18 && Encoding.ASCII.GetString(data, data.Length - 18, 18) == "TRUEVISION-XFILE.\0") return "tga"; + if (declaredType == 15) return "dmdf"; + if (declaredType == 4 && LooksLikeTga(data)) return "tga"; + return "unknown"; + } + + private static bool LooksLikeTga(byte[] data) + { + return data.Length >= 18 && (data[2] == 1 || data[2] == 2 || data[2] == 3 || data[2] == 9 || data[2] == 10 || data[2] == 11); + } + + private static bool StartsWith(byte[] data, string value) + { + if (data.Length < value.Length) return false; + for (var i = 0; i < value.Length; i++) if (data[i] != value[i]) return false; + return true; + } + + private static string Extension(string format) + { + return string.IsNullOrEmpty(format) || format == "unknown" ? "bin" : format; + } + + private static string SafeName(string value, string fallback) + { + if (string.IsNullOrWhiteSpace(value)) value = fallback; + var result = new StringBuilder(value.Length); + foreach (var character in value) { + result.Append(character < 0x20 || InvalidFileNameCharacters.Contains(character) ? '_' : character); + } + var safe = result.ToString().Trim().TrimEnd('.', ' '); + if (safe.Length == 0) safe = fallback; + if (safe.Length > 100) safe = safe.Substring(0, 100); + var stem = Path.GetFileNameWithoutExtension(safe); + if (new[] { "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" } + .Contains(stem, StringComparer.OrdinalIgnoreCase)) safe = "_" + safe; + return safe; + } + + private static void WriteText(string outputRoot, string relativePath, string content, FuturePinballExtractionOptions options) + { + WriteFile(outputRoot, relativePath, Utf8.GetBytes(content), options); + } + + private static void WriteFile(string outputRoot, string relativePath, byte[] data, FuturePinballExtractionOptions options) + { + var normalized = relativePath.Replace('/', Path.DirectorySeparatorChar); + if (Path.IsPathRooted(normalized)) throw new InvalidOperationException($"Extraction path must be relative: {relativePath}"); + var path = Path.GetFullPath(Path.Combine(outputRoot, normalized)); + var rootPrefix = outputRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + var comparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (!path.StartsWith(rootPrefix, comparison)) throw new InvalidOperationException($"Extraction path escapes the output root: {relativePath}"); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + if (File.Exists(path)) { + var existing = File.ReadAllBytes(path); + if (existing.SequenceEqual(data)) return; + if (!options.OverwriteChangedFiles) throw new IOException($"Extraction target already exists with different content: {path}"); + } + File.WriteAllBytes(path, data); + } + + private static string Sha256(string path) + { + using (var stream = File.OpenRead(path)) + using (var sha = SHA256.Create()) return Hex(sha.ComputeHash(stream)); + } + + private static string Sha256(byte[] data) + { + using (var sha = SHA256.Create()) return Hex(sha.ComputeHash(data)); + } + + private static string Sha256(ReadOnlySpan data) + { + return Sha256(data.ToArray()); + } + + private static string Hex(byte[] data) + { + return BitConverter.ToString(data).Replace("-", string.Empty).ToLowerInvariant(); + } + } +} diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs.meta b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs.meta new file mode 100644 index 000000000..6f8359c30 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c9c3286065584e07a3ee693ef9c7f845 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballFormat.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballFormat.cs new file mode 100644 index 000000000..45493d560 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballFormat.cs @@ -0,0 +1,311 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace VisualPinball.Engine.IO.FuturePinball +{ + public enum FuturePinballFileKind + { + Table, + Library, + Model + } + + public enum FuturePinballStreamKind + { + Unknown, + FileVersion, + TableData, + TableMac, + TableElement, + Image, + Sound, + Music, + PinModel, + DmdFont, + ImageList, + LightList, + ModelData + } + + public enum FuturePinballResourceKind + { + Unknown = 0, + Image = 1, + Sound = 2, + Music = 3, + Model = 4, + DmdFont = 5, + Script = 6, + ImageList = 7, + LightList = 8 + } + + public enum FuturePinballValueKind + { + Opaque, + Integer, + Float, + Color, + Vector2, + String, + WideString, + StringList, + CollisionData, + CompressedData, + End + } + + public enum FuturePinballElementType : uint + { + Surface = 2, + RoundLight = 3, + ShapeableLight = 4, + Peg = 6, + Flipper = 7, + Bumper = 8, + LeafTarget = 10, + DropTarget = 11, + Plunger = 12, + RoundRubber = 13, + ShapeableRubber = 14, + Ornament = 15, + GuideWall = 16, + Timer = 17, + Decal = 18, + Kicker = 19, + LaneGuide = 20, + ModelRubber = 21, + Trigger = 22, + Flasher = 23, + WireGuide = 24, + DispReel = 25, + HudReel = 26, + Overlay = 27, + Bulb = 29, + Gate = 30, + Spinner = 31, + CustomToy = 33, + LightSequencer = 34, + Segment = 37, + HudSegment = 38, + Dmd = 39, + HudDmd = 40, + Diverter = 43, + Sta = 44, + AutoPlunger = 46, + RotoTarget = 49, + Popup = 50, + RampModel = 51, + WireRamp = 53, + SwingTarget = 54, + Ramp = 55, + SpinningDisk = 56, + LightImage = 57, + EmKicker = 58, + HudLightImage = 60, + OptoTrigger = 61, + VariTarget = 62, + Hologram = 64 + } + + public sealed class FuturePinballReaderOptions + { + public const int DefaultMaximumStreamBytes = 512 * 1024 * 1024; + public const int DefaultMaximumDecompressedBytes = 512 * 1024 * 1024; + + public int MaximumStreamBytes { get; set; } = DefaultMaximumStreamBytes; + public int MaximumDecompressedBytes { get; set; } = DefaultMaximumDecompressedBytes; + public int MaximumRecordCount { get; set; } = 1_000_000; + public int MaximumStringBytes { get; set; } = 16 * 1024 * 1024; + public bool DecodeCompressedData { get; set; } = true; + } + + public sealed class FuturePinballFormatException : IOException + { + public string SourceName { get; } + public long SourceOffset { get; } + + public FuturePinballFormatException(string message, string sourceName = null, long sourceOffset = -1, Exception innerException = null) + : base(FormatMessage(message, sourceName, sourceOffset), innerException) + { + SourceName = sourceName; + SourceOffset = sourceOffset; + } + + private static string FormatMessage(string message, string sourceName, long sourceOffset) + { + var location = sourceName == null ? string.Empty : $" in {sourceName}"; + if (sourceOffset >= 0) { + location += $" at 0x{sourceOffset:X}"; + } + return message + location; + } + } + + public readonly struct FuturePinballVector2 + { + public float X { get; } + public float Y { get; } + + public FuturePinballVector2(float x, float y) + { + X = x; + Y = y; + } + } + + public sealed class FuturePinballCollisionShape + { + public uint Type { get; internal set; } + public bool GenerateHitEvent { get; internal set; } + public bool AffectsBall { get; internal set; } + public uint EventId { get; internal set; } + public float X { get; internal set; } + public float Y { get; internal set; } + public float Z { get; internal set; } + public float Value1 { get; internal set; } + public float Value2 { get; internal set; } + public float Value3 { get; internal set; } + public float Value4 { get; internal set; } + + internal FuturePinballCollisionShape() + { + } + + public FuturePinballCollisionShape( + uint type, + bool affectsBall, + float x, + float y, + float z, + float value1, + float value2 = 0f, + float value3 = 0f, + float value4 = 0f, + bool generateHitEvent = false, + uint eventId = 0) + { + Type = type; + AffectsBall = affectsBall; + X = x; + Y = y; + Z = z; + Value1 = value1; + Value2 = value2; + Value3 = value3; + Value4 = value4; + GenerateHitEvent = generateHitEvent; + EventId = eventId; + } + } + + public sealed class FuturePinballCompressedData + { + public ReadOnlyMemory RawBytes { get; internal set; } + public bool IsCompressed { get; internal set; } + public int DeclaredUncompressedLength { get; internal set; } + public int CompressedBytesConsumed { get; internal set; } + public byte[] DecodedBytes { get; internal set; } + } + + public sealed class FuturePinballRecord + { + public int Offset { get; internal set; } + public uint StoredLength { get; internal set; } + public uint OriginalTag { get; internal set; } + public uint CanonicalTag { get; internal set; } + public int ConsumedLength { get; internal set; } + public string Name { get; internal set; } + public FuturePinballValueKind ValueKind { get; internal set; } + public object Value { get; internal set; } + public ReadOnlyMemory RawRecord { get; internal set; } + public ReadOnlyMemory Payload { get; internal set; } + public bool UsesLegacyTag => OriginalTag != CanonicalTag; + } + + public sealed class FuturePinballSourceStream + { + public string Name { get; internal set; } + public int? SourceIndex { get; internal set; } + public FuturePinballStreamKind Kind { get; internal set; } + public byte[] RawData { get; internal set; } + public uint? ElementTypeId { get; internal set; } + public FuturePinballElementType? ElementType { get; internal set; } + public IReadOnlyList Records { get; internal set; } = Array.Empty(); + + public FuturePinballRecord FirstRecord(uint canonicalTag) + { + return Records.FirstOrDefault(record => record.CanonicalTag == canonicalTag); + } + + public int? Integer(uint canonicalTag) + { + return FirstRecord(canonicalTag)?.Value as int?; + } + + public string Text(uint canonicalTag) + { + return FirstRecord(canonicalTag)?.Value as string; + } + } + + public sealed class FuturePinballTable + { + public string SourcePath { get; internal set; } + public uint? FileVersion { get; internal set; } + public int CompoundEntryCount { get; internal set; } + public IReadOnlyList Streams { get; internal set; } = Array.Empty(); + public FuturePinballSourceStream TableData { get; internal set; } + public FuturePinballSourceStream TableMac { get; internal set; } + public IReadOnlyList Elements { get; internal set; } = Array.Empty(); + public IReadOnlyList Images { get; internal set; } = Array.Empty(); + public IReadOnlyList Sounds { get; internal set; } = Array.Empty(); + public IReadOnlyList Music { get; internal set; } = Array.Empty(); + public IReadOnlyList PinModels { get; internal set; } = Array.Empty(); + public IReadOnlyList DmdFonts { get; internal set; } = Array.Empty(); + public IReadOnlyList ImageLists { get; internal set; } = Array.Empty(); + public IReadOnlyList LightLists { get; internal set; } = Array.Empty(); + public IReadOnlyList Issues { get; internal set; } = Array.Empty(); + } + + public sealed class FuturePinballLibraryEntry + { + public string Name { get; internal set; } + public FuturePinballResourceKind Kind { get; internal set; } + public uint TypeId { get; internal set; } + public string OriginalPath { get; internal set; } + public byte[] Flad { get; internal set; } + public FuturePinballCompressedData Data { get; internal set; } + public IReadOnlyDictionary Streams { get; internal set; } + } + + public sealed class FuturePinballLibrary + { + public string SourcePath { get; internal set; } + public IReadOnlyList Entries { get; internal set; } = Array.Empty(); + } + + public sealed class FuturePinballModel + { + public string SourcePath { get; internal set; } + public FuturePinballSourceStream ModelData { get; internal set; } + } +} diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballFormat.cs.meta b/VisualPinball.Engine/IO/FuturePinball/FuturePinballFormat.cs.meta new file mode 100644 index 000000000..359f6ea4d --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballFormat.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a684826b9268479f9b07d8e205943edc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs new file mode 100644 index 000000000..f7460fec8 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs @@ -0,0 +1,368 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; + +using VisualPinball.Engine.Math; +using VisualPinball.Engine.VPT; +using VisualPinball.Engine.VPT.Ramp; +using VisualPinball.Engine.VPT.Surface; + +namespace VisualPinball.Engine.IO.FuturePinball +{ + public readonly struct FuturePinballWorldPoint + { + public float X { get; } + public float Y { get; } + public float Z { get; } + + public FuturePinballWorldPoint(float x, float y, float z) + { + X = x; + Y = y; + Z = z; + } + } + + public sealed class FuturePinballShapePoint + { + public FuturePinballVector2 Position { get; internal set; } + public bool Smooth { get; internal set; } + public bool IsRampEndPoint { get; internal set; } + public bool LeftGuide { get; internal set; } + public bool LeftUpperGuide { get; internal set; } + public bool RightGuide { get; internal set; } + public bool RightUpperGuide { get; internal set; } + public bool TopWire { get; internal set; } + public int RingType { get; internal set; } + } + + public sealed class FuturePinballGeneratedElement + { + public int SourceIndex { get; internal set; } + public string Name { get; internal set; } + public FuturePinballElementType Type { get; internal set; } + public bool IsCollidable { get; internal set; } + public string Texture { get; internal set; } + public uint Color { get; internal set; } + public FuturePinballMaterialDescription Material { get; internal set; } + public IReadOnlyList Meshes { get; internal set; } = Array.Empty(); + } + + public static class FuturePinballCoordinateConverter + { + // VPE uses 1852.71 VPX units per metre. Future Pinball dimensions are millimetres. + public const float VpxUnitsPerMillimeter = 1.85271f; + public const float WorldUnitsPerMillimeter = 0.001f; + + public static float ToVpx(float millimeters) => millimeters * VpxUnitsPerMillimeter; + + public static Vertex3D ToVpx(float x, float y, float z) + { + return new Vertex3D(ToVpx(x), ToVpx(y), ToVpx(z)); + } + + public static FuturePinballWorldPoint ToWorld(float x, float y, float z) + { + return new FuturePinballWorldPoint( + x * WorldUnitsPerMillimeter, + z * WorldUnitsPerMillimeter, + -y * WorldUnitsPerMillimeter + ); + } + + public static FuturePinballWorldPoint ModelToWorld(float x, float y, float z) + { + return new FuturePinballWorldPoint( + x * WorldUnitsPerMillimeter, + y * WorldUnitsPerMillimeter, + -z * WorldUnitsPerMillimeter + ); + } + + public static Mesh ModelMeshToWorld(Mesh source, bool flipTextureV = true) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + var result = source.Clone(); + for (var i = 0; i < (result.Vertices?.Length ?? 0); i++) { + var vertex = result.Vertices[i]; + var position = ModelToWorld(vertex.X, vertex.Y, vertex.Z); + var normal = ModelToWorld(vertex.Nx, vertex.Ny, vertex.Nz); + vertex.X = position.X; + vertex.Y = position.Y; + vertex.Z = position.Z; + vertex.Nx = normal.X / WorldUnitsPerMillimeter; + vertex.Ny = normal.Y / WorldUnitsPerMillimeter; + vertex.Nz = normal.Z / WorldUnitsPerMillimeter; + if (flipTextureV) vertex.Tv = 1f - vertex.Tv; + result.Vertices[i] = vertex; + } + for (var i = 0; i + 2 < (result.Indices?.Length ?? 0); i += 3) { + var index = result.Indices[i + 1]; + result.Indices[i + 1] = result.Indices[i + 2]; + result.Indices[i + 2] = index; + } + return result; + } + } + + public static class FuturePinballElementGeometry + { + public const uint PointTag = 0x95F3C2D2; + public const uint PositionTag = 0x9BFCCFCF; + public const uint SmoothTag = 0xA1EDC5D2; + public const uint RampEndPointTag = 0x9AF1CAE0; + public const uint LeftGuideTag = 0xA4F5C9D8; + public const uint LeftUpperGuideTag = 0xA4F5C2D0; + public const uint RightGuideTag = 0xA0EFC9D8; + public const uint RightUpperGuideTag = 0xA0EFC2D0; + public const uint TopWireTag = 0x95ECC3D1; + public const uint RingTypeTag = 0xA2F3C9D3; + + private const uint EndTag = 0xA7FDC4E0; + private const uint LegacyTagOffset = 0x15BDECDB; + + public static IReadOnlyList Points(FuturePinballSourceStream element) + { + if (element == null) throw new ArgumentNullException(nameof(element)); + var points = new List(); + for (var i = 0; i < element.Records.Count; i++) { + if (!Matches(element.Records[i], PointTag)) continue; + var point = new FuturePinballShapePoint(); + for (i++; i < element.Records.Count && !Matches(element.Records[i], EndTag); i++) { + var record = element.Records[i]; + if (Matches(record, PositionTag)) point.Position = Vector2(record); + else if (Matches(record, SmoothTag)) point.Smooth = Integer(record) != 0; + else if (Matches(record, RampEndPointTag)) point.IsRampEndPoint = Integer(record) != 0; + else if (Matches(record, LeftGuideTag)) point.LeftGuide = Integer(record) != 0; + else if (Matches(record, LeftUpperGuideTag)) point.LeftUpperGuide = Integer(record) != 0; + else if (Matches(record, RightGuideTag)) point.RightGuide = Integer(record) != 0; + else if (Matches(record, RightUpperGuideTag)) point.RightUpperGuide = Integer(record) != 0; + else if (Matches(record, TopWireTag)) point.TopWire = Integer(record) != 0; + else if (Matches(record, RingTypeTag)) point.RingType = Integer(record); + } + points.Add(point); + } + return points; + } + + public static FuturePinballVector2 Position(FuturePinballSourceStream element, uint tag = PositionTag) + { + var record = Find(element, tag); + return record == null ? new FuturePinballVector2() : Vector2(record); + } + + /// + /// Returns a representative table position for resolving a shape against its named support surface. + /// Shape-based FP elements generally have no top-level position, so their first control point is the + /// only stable point guaranteed to lie on the element itself. + /// + public static FuturePinballVector2 SurfaceProbePosition(FuturePinballSourceStream element) + { + if (element == null) throw new ArgumentNullException(nameof(element)); + var points = Points(element); + return points.Count > 0 ? points[0].Position : Position(element); + } + + public static bool ContainsPoint(FuturePinballSourceStream element, FuturePinballVector2 point, float edgeTolerance = 0.001f) + { + var polygon = Points(element).Select(item => item.Position).ToArray(); + if (polygon.Length < 3) return false; + var inside = false; + for (var i = 0; i < polygon.Length; i++) { + var j = i == 0 ? polygon.Length - 1 : i - 1; + if (PointOnSegment(point, polygon[j], polygon[i], edgeTolerance)) return true; + if ((polygon[i].Y > point.Y) != (polygon[j].Y > point.Y) + && point.X < (polygon[j].X - polygon[i].X) * (point.Y - polygon[i].Y) + / (polygon[j].Y - polygon[i].Y) + polygon[i].X) inside = !inside; + } + return inside; + } + + public static string Text(FuturePinballSourceStream stream, uint tag, string fallback = "") + { + var record = Find(stream, tag); + return record?.Value as string ?? fallback; + } + + public static int Integer(FuturePinballSourceStream stream, uint tag, int fallback = 0) + { + var record = Find(stream, tag); + return record == null ? fallback : Integer(record); + } + + public static float Float(FuturePinballSourceStream stream, uint tag, float fallback = 0f) + { + var record = Find(stream, tag); + if (record?.Value is float value) return value; + // Some FP tags change representation by element type and remain opaque in the generic reader. + return record?.Payload.Length >= 4 ? ReadSingle(record.Payload.Span) : fallback; + } + + public static uint Color(FuturePinballSourceStream stream, uint tag, uint fallback = 0xffffffff) + { + var record = Find(stream, tag); + if (record?.Value is uint value) return value; + return record?.Payload.Length >= 4 ? ReadUInt32(record.Payload.Span) : fallback; + } + + public static bool HasTag(FuturePinballSourceStream stream, uint tag) + { + return Find(stream, tag) != null; + } + + private static FuturePinballRecord Find(FuturePinballSourceStream stream, uint tag) + { + return stream?.Records.FirstOrDefault(record => Matches(record, tag)); + } + + private static int Integer(FuturePinballRecord record) + { + if (record.Value is int value) return value; + return record.Payload.Length >= 4 ? ReadInt32(record.Payload.Span) : 0; + } + + private static FuturePinballVector2 Vector2(FuturePinballRecord record) + { + if (record.Value is FuturePinballVector2 value) return value; + return record.Payload.Length >= 8 + ? new FuturePinballVector2(ReadSingle(record.Payload.Span), ReadSingle(record.Payload.Span.Slice(4))) + : new FuturePinballVector2(); + } + + private static bool PointOnSegment(FuturePinballVector2 point, FuturePinballVector2 a, FuturePinballVector2 b, float tolerance) + { + var segmentX = b.X - a.X; + var segmentY = b.Y - a.Y; + var lengthSquared = segmentX * segmentX + segmentY * segmentY; + if (lengthSquared <= tolerance * tolerance) { + var pointX = point.X - a.X; + var pointY = point.Y - a.Y; + return pointX * pointX + pointY * pointY <= tolerance * tolerance; + } + var cross = (point.Y - a.Y) * segmentX - (point.X - a.X) * segmentY; + if (System.Math.Abs(cross) > tolerance * System.Math.Sqrt(lengthSquared)) return false; + var dot = (point.X - a.X) * segmentX + (point.Y - a.Y) * segmentY; + if (dot < 0f) return false; + return dot <= lengthSquared; + } + + private static bool Matches(FuturePinballRecord record, uint tag) + { + return record.CanonicalTag == tag || record.OriginalTag == tag || record.OriginalTag - LegacyTagOffset == tag; + } + + private static int ReadInt32(ReadOnlySpan data) => unchecked((int)ReadUInt32(data)); + + private static uint ReadUInt32(ReadOnlySpan data) + { + return (uint)(data[0] | data[1] << 8 | data[2] << 16 | data[3] << 24); + } + + private static float ReadSingle(ReadOnlySpan data) + { + return BitConverter.ToSingle(data.Slice(0, 4).ToArray(), 0); + } + } + + public static class FuturePinballProceduralMeshBuilder + { + private const uint NameTag = 0xA4F4D1D7; + private const uint CollidableTag = 0x9DF5C3E2; + private const uint RenderObjectTag = 0x97FDC4D3; + private const uint TopTextureTag = 0xA2F4C9D1; + private const uint TextureTag = 0xA4FAC5DC; + private const uint TopColorTag = 0x9DF2CFD1; + private const uint ColorTag = 0x97F5C3E2; + + public static IReadOnlyList Build(FuturePinballTable table) + { + if (table == null) throw new ArgumentNullException(nameof(table)); + var tableWidth = FuturePinballCoordinateConverter.ToVpx(table.TableData.Integer(0xA5F8BBD1) ?? 0); + var tableLength = FuturePinballCoordinateConverter.ToVpx(table.TableData.Integer(0x9BFCC6D1) ?? 0); + var result = new List(); + foreach (var element in table.Elements) { + FuturePinballGeneratedElement generated = null; + switch (element.ElementType) { + case FuturePinballElementType.Surface: + case FuturePinballElementType.GuideWall: + generated = Surface(element, tableWidth, tableLength); + break; + case FuturePinballElementType.Ramp: + case FuturePinballElementType.WireRamp: + generated = Ramp(element, tableWidth, tableLength); + break; + } + if (generated != null) result.Add(generated); + } + return result; + } + + private static FuturePinballGeneratedElement Surface(FuturePinballSourceStream element, float tableWidth, float tableLength) + { + if (!FuturePinballNativeItemConverter.TryConvert(element, out var converted) + || !(converted.Item is Surface surface)) return null; + var data = surface.Data; + var generator = new SurfaceMeshGenerator(data, new Vertex3D(0f, 0f, 0f)); + var meshes = new[] { + generator.GetMesh(SurfaceMeshGenerator.Top, tableWidth, tableLength, 0f, false), + generator.GetMesh(SurfaceMeshGenerator.Side, tableWidth, tableLength, 0f, false) + }.Where(mesh => mesh?.IsSet == true).ToArray(); + return Generated(element, meshes, TopTextureTag, TopColorTag); + } + + private static FuturePinballGeneratedElement Ramp(FuturePinballSourceStream element, float tableWidth, float tableLength) + { + var isWire = element.ElementType == FuturePinballElementType.WireRamp; + if (!FuturePinballNativeItemConverter.TryConvert(element, out var converted) + || !(converted.Item is Ramp ramp)) return null; + var data = ramp.Data; + var generator = new RampMeshGenerator(data, new Vertex3D(0f, 0f, 0f)); + var ids = isWire + ? new[] { RampMeshGenerator.Wires } + : new[] { RampMeshGenerator.Floor, RampMeshGenerator.Wall }; + var meshes = ids.Select(id => generator.GetMesh(tableWidth, tableLength, 0f, id)) + .Where(mesh => mesh?.IsSet == true && mesh.Vertices.Length > 0).ToArray(); + return Generated(element, meshes, TextureTag, ColorTag); + } + + private static FuturePinballGeneratedElement Generated( + FuturePinballSourceStream element, + IReadOnlyList meshes, + uint textureTag, + uint colorTag) + { + var material = FuturePinballMaterialConverter.FromElement(element, textureTag, colorTag); + return new FuturePinballGeneratedElement { + SourceIndex = element.SourceIndex ?? -1, + Name = Name(element), + Type = element.ElementType.Value, + IsCollidable = FuturePinballElementGeometry.Integer(element, CollidableTag, 1) != 0, + Texture = FuturePinballElementGeometry.Text(element, textureTag), + Color = FuturePinballElementGeometry.Color(element, colorTag), + Material = material, + Meshes = FuturePinballElementGeometry.Integer(element, RenderObjectTag, 1) == 0 ? Array.Empty() : meshes + }; + } + + private static string Name(FuturePinballSourceStream element) + { + return FuturePinballElementGeometry.Text(element, NameTag, element.Name); + } + } +} diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs.meta b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs.meta new file mode 100644 index 000000000..6fdcb9241 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 40bf90c4d1a64c78bcb48913e0194e7f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballLibraryReader.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballLibraryReader.cs new file mode 100644 index 000000000..40b811445 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballLibraryReader.cs @@ -0,0 +1,135 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +using OpenMcdf; + +namespace VisualPinball.Engine.IO.FuturePinball +{ + public static class FuturePinballLibraryReader + { + public static FuturePinballLibrary Load(string fileName, FuturePinballReaderOptions options = null) + { + if (fileName == null) { + throw new ArgumentNullException(nameof(fileName)); + } + options ??= new FuturePinballReaderOptions(); + using (var root = RootStorage.OpenRead(fileName, StorageModeFlags.None)) { + var entries = new List(); + foreach (var item in root.EnumerateEntries() + .Where(item => item.Type == EntryType.Storage) + .OrderBy(item => item.Name, StringComparer.OrdinalIgnoreCase)) { + var storage = root.OpenStorage(item.Name); + var streams = ReadStreams(storage, options, item.Name); + streams.TryGetValue("FTYP", out var typeBytes); + streams.TryGetValue("FPAT", out var pathBytes); + streams.TryGetValue("FLAD", out var flad); + streams.TryGetValue("FDAT", out var dataBytes); + var typeId = typeBytes != null && typeBytes.Length >= 4 ? ReadUInt32(typeBytes, 0) : 0; + entries.Add(new FuturePinballLibraryEntry { + Name = item.Name, + TypeId = typeId, + Kind = Enum.IsDefined(typeof(FuturePinballResourceKind), (int)typeId) + ? (FuturePinballResourceKind)typeId + : FuturePinballResourceKind.Unknown, + OriginalPath = pathBytes == null ? null : Encoding.ASCII.GetString(pathBytes).TrimEnd('\0'), + Flad = flad, + Data = dataBytes == null + ? null + : FuturePinballCompression.Decode(dataBytes, options.MaximumDecompressedBytes, item.Name), + Streams = streams + }); + } + return new FuturePinballLibrary { + SourcePath = Path.GetFullPath(fileName), + Entries = entries + }; + } + } + + private static Dictionary ReadStreams( + Storage storage, + FuturePinballReaderOptions options, + string sourceName) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in storage.EnumerateEntries().Where(item => item.Type == EntryType.Stream)) { + if (item.Length > options.MaximumStreamBytes) { + throw new FuturePinballFormatException( + $"Stream length {item.Length} exceeds the configured limit {options.MaximumStreamBytes}", + $"{sourceName}/{item.Name}" + ); + } + result[item.Name] = storage.OpenStream(item.Name).ReadAll(); + } + return result; + } + + private static uint ReadUInt32(byte[] data, int offset) + { + return (uint)(data[offset] + | data[offset + 1] << 8 + | data[offset + 2] << 16 + | data[offset + 3] << 24); + } + } + + public static class FuturePinballModelReader + { + public static FuturePinballModel Load(string fileName, FuturePinballReaderOptions options = null) + { + if (fileName == null) { + throw new ArgumentNullException(nameof(fileName)); + } + options ??= new FuturePinballReaderOptions(); + using (var root = RootStorage.OpenRead(fileName, StorageModeFlags.None)) { + var pinModelEntry = root.EnumerateEntries().FirstOrDefault(item => + item.Type == EntryType.Storage && item.Name.Equals("PinModel", StringComparison.OrdinalIgnoreCase)); + if (pinModelEntry == null) { + throw new FuturePinballFormatException("Required PinModel storage is missing", fileName); + } + var pinModel = root.OpenStorage(pinModelEntry.Name); + var modelEntry = pinModel.EnumerateEntries().FirstOrDefault(item => + item.Type == EntryType.Stream && item.Name.Equals("ModelData", StringComparison.OrdinalIgnoreCase)); + if (modelEntry == null) { + throw new FuturePinballFormatException("Required ModelData stream is missing", fileName); + } + if (modelEntry.Length > options.MaximumStreamBytes) { + throw new FuturePinballFormatException( + $"ModelData length {modelEntry.Length} exceeds the configured limit {options.MaximumStreamBytes}", fileName + ); + } + var data = pinModel.OpenStream(modelEntry.Name).ReadAll(); + return new FuturePinballModel { + SourcePath = Path.GetFullPath(fileName), + ModelData = new FuturePinballSourceStream { + Name = modelEntry.Name, + Kind = FuturePinballStreamKind.ModelData, + RawData = data, + Records = FuturePinballRecordReader.Read( + data, 0, FuturePinballRecordContext.PinModel, options, modelEntry.Name + ) + } + }; + } + } + } +} diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballLibraryReader.cs.meta b/VisualPinball.Engine/IO/FuturePinball/FuturePinballLibraryReader.cs.meta new file mode 100644 index 000000000..b6d321151 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballLibraryReader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 745d97363a9343a1a0a0703dcaee4f20 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballMaterial.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballMaterial.cs new file mode 100644 index 000000000..6c04bd99d --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballMaterial.cs @@ -0,0 +1,213 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; + +using VisualPinball.Engine.Math; +using VisualPinball.Engine.VPT; + +namespace VisualPinball.Engine.IO.FuturePinball +{ + public enum FuturePinballMaterialCategory + { + Metal = 0, + Wood = 1, + Plastic = 2, + Rubber = 3, + Unknown = 255 + } + + public sealed class FuturePinballMaterialDescription + { + public string Name { get; internal set; } + public FuturePinballMaterialCategory Category { get; internal set; } + public uint SourceColor { get; internal set; } + public float Opacity { get; internal set; } + public float Roughness { get; internal set; } + public bool IsMetal { get; internal set; } + public bool IsCrystal { get; internal set; } + public bool IsSphereMapped { get; internal set; } + public bool IsTwoSided { get; internal set; } + public bool IsEmissive { get; internal set; } + public string Texture { get; internal set; } + public int SourceMaterialType { get; internal set; } + public int SourceTransparency { get; internal set; } + + public Material ToVpeMaterial(string name = null) + { + var color = new Color(SourceColor, ColorFormat.Bgr).WithAlpha(255); + var material = new Material(name ?? Name ?? "Future Pinball") { + BaseColor = color, + Roughness = Roughness, + GlossyImageLerp = IsSphereMapped ? 1f : 0f, + Thickness = IsCrystal ? 0.15f : 0.05f, + Edge = IsSphereMapped || IsCrystal ? 1f : 0.25f, + Opacity = Opacity, + IsMetal = IsMetal, + IsOpacityActive = Opacity < 0.999f + }; + material.UpdateData(); + return material; + } + } + + public static class FuturePinballMaterialConverter + { + private const uint NameTag = 0xA4F4D1D7; + private const uint MaterialTypeTag = 0x99E8BED8; + private const uint TransparencyTag = 0x9C00C0D1; + private const uint SphereMappingTag = 0x99F4C2D2; + private const uint CrystalTag = 0x96E8C0E2; + private const uint DisableCullingTag = 0x9DECCFE1; + + public static FuturePinballMaterialDescription FromElement( + FuturePinballSourceStream element, + uint textureTag, + uint colorTag) + { + if (element == null) throw new ArgumentNullException(nameof(element)); + var name = FuturePinballElementGeometry.Text(element, NameTag, element.Name); + var category = FuturePinballElementGeometry.Integer(element, MaterialTypeTag, 2); + var color = FuturePinballElementGeometry.Color(element, colorTag); + var transparency = FuturePinballElementGeometry.Integer(element, TransparencyTag, 10); + return FromValues( + name, + category, + color, + transparency, + FuturePinballElementGeometry.Integer(element, CrystalTag) != 0, + FuturePinballElementGeometry.Integer(element, SphereMappingTag) != 0, + FuturePinballElementGeometry.Integer(element, DisableCullingTag) != 0, + IsEmissive(element.ElementType), + FuturePinballElementGeometry.Text(element, textureTag) + ); + } + + public static FuturePinballMaterialDescription FromValues( + string name, + int materialType, + uint color, + int transparency = 10, + bool crystal = false, + bool sphereMapped = false, + bool twoSided = false, + bool emissive = false, + string texture = "") + { + var category = Category(materialType); + var opacity = Clamp01(transparency / 10f); + if (crystal && opacity >= 0.999f) opacity = 0.35f; + return new FuturePinballMaterialDescription { + Name = name, + Category = category, + SourceColor = color, + Opacity = opacity, + Roughness = Roughness(category), + IsMetal = category == FuturePinballMaterialCategory.Metal, + IsCrystal = crystal, + IsSphereMapped = sphereMapped, + IsTwoSided = twoSided, + IsEmissive = emissive, + Texture = texture ?? string.Empty, + SourceMaterialType = materialType, + SourceTransparency = transparency + }; + } + + public static FuturePinballMaterialDescription FromMilkShape(MilkShapeMaterial material) + { + if (material == null) throw new ArgumentNullException(nameof(material)); + return FromMilkShape(material.Name, material.Diffuse, material.Shininess, material.Transparency, material.Texture); + } + + public static FuturePinballMaterialDescription FromMilkShape( + string name, + float[] diffuse, + float shininess, + float transparency, + string texture = "") + { + diffuse ??= Array.Empty(); + var red = Channel(diffuse, 0, 1f); + var green = Channel(diffuse, 1, 1f); + var blue = Channel(diffuse, 2, 1f); + var alpha = Channel(diffuse, 3, 1f); + var opacity = Clamp01(transparency * alpha); + var normalizedShininess = Clamp01(shininess / 128f); + return new FuturePinballMaterialDescription { + Name = name, + Category = FuturePinballMaterialCategory.Plastic, + SourceColor = Pack(red, green, blue), + Opacity = opacity, + Roughness = Clamp01(1f - (float)System.Math.Sqrt(normalizedShininess)), + IsMetal = false, + Texture = texture ?? string.Empty, + SourceMaterialType = -1, + SourceTransparency = (int)System.Math.Round(opacity * 10f) + }; + } + + private static FuturePinballMaterialCategory Category(int value) + { + return value >= 0 && value <= 3 + ? (FuturePinballMaterialCategory)value + : FuturePinballMaterialCategory.Unknown; + } + + private static float Roughness(FuturePinballMaterialCategory category) + { + switch (category) { + case FuturePinballMaterialCategory.Metal: return 0.22f; + case FuturePinballMaterialCategory.Wood: return 0.55f; + case FuturePinballMaterialCategory.Rubber: return 0.82f; + default: return 0.38f; + } + } + + private static bool IsEmissive(FuturePinballElementType? type) + { + switch (type) { + case FuturePinballElementType.RoundLight: + case FuturePinballElementType.ShapeableLight: + case FuturePinballElementType.Flasher: + case FuturePinballElementType.Bulb: + case FuturePinballElementType.LightImage: + case FuturePinballElementType.HudLightImage: + return true; + default: + return false; + } + } + + private static float Channel(float[] values, int index, float fallback) + { + return index < values.Length ? Clamp01(values[index]) : fallback; + } + + private static uint Pack(float red, float green, float blue) + { + return (uint)(System.Math.Round(Clamp01(red) * 255f) + + ((uint)System.Math.Round(Clamp01(green) * 255f) << 8) + + ((uint)System.Math.Round(Clamp01(blue) * 255f) << 16) + + (0xffu << 24)); + } + + private static float Clamp01(float value) + { + return value < 0f ? 0f : value > 1f ? 1f : value; + } + } +} diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballMaterial.cs.meta b/VisualPinball.Engine/IO/FuturePinball/FuturePinballMaterial.cs.meta new file mode 100644 index 000000000..a7cd87182 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballMaterial.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a3eef2a3aa794f1e8ed036d46be6705c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs new file mode 100644 index 000000000..f195ea1cb --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs @@ -0,0 +1,416 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; + +using VisualPinball.Engine.Math; +using VisualPinball.Engine.VPT; +using VisualPinball.Engine.VPT.Bumper; +using VisualPinball.Engine.VPT.Flipper; +using VisualPinball.Engine.VPT.Gate; +using VisualPinball.Engine.VPT.HitTarget; +using VisualPinball.Engine.VPT.Kicker; +using VisualPinball.Engine.VPT.Light; +using VisualPinball.Engine.VPT.MetalWireGuide; +using VisualPinball.Engine.VPT.Plunger; +using VisualPinball.Engine.VPT.Ramp; +using VisualPinball.Engine.VPT.Rubber; +using VisualPinball.Engine.VPT.Spinner; +using VisualPinball.Engine.VPT.Surface; +using VisualPinball.Engine.VPT.Trigger; + +namespace VisualPinball.Engine.IO.FuturePinball +{ + public sealed class FuturePinballNativeItem + { + public IItem Item { get; internal set; } + public IReadOnlyList DefaultedParameters { get; internal set; } = Array.Empty(); + } + + /// + /// Converts Future Pinball elements whose behavior has a direct VPE counterpart. + /// Source values are copied only when their meaning and units are known; all other + /// values intentionally retain the VPE item's defaults. + /// + public static class FuturePinballNativeItemConverter + { + /// + /// Synthetic playfield image name shared by imported insert lights and their table. + /// VPE uses this equality to select and retain the insert-light mesh. + /// + public const string PlayfieldImage = "Future Pinball Playfield"; + + private const uint NameTag = 0xA4F4D1D7; + private const uint SurfaceTag = 0xA3EFBDD2; + private const uint RotationTag = 0xA8EDC3D3; + private const uint StartAngleTag = 0xA900BED2; + private const uint SwingTag = 0xA2EABFE4; + private const uint ReflectsTag = 0x9DFBCDD3; + private const uint RenderModelTag = 0xA5F2C5D3; + private const uint KickerTypeTag = 0x99E8BEDA; + private const uint PassiveTag = 0xA0EED1D5; + private const uint TriggerSkirtTag = 0x9EEED1DD; + private const uint OneWayTag = 0x9100BBD6; + private const uint OffsetTag = 0x96FBCCD6; + private const uint HeightTag = 0xA2F8CDDD; + private const uint WidthTag = 0x95FDC9CE; + private const uint CollidableTag = 0x9DF5C3E2; + private const uint RenderObjectTag = 0x97FDC4D3; + private const uint TopHeightTag = 0x99F2BEDD; + private const uint BottomHeightTag = 0x95F2D0DD; + private const uint StartHeightTag = 0xA2F8CAD2; + private const uint EndHeightTag = 0xA2F8CAE0; + private const uint StartWidthTag = 0xA5F8BBD2; + private const uint EndWidthTag = 0xA5F8BBE0; + private const uint LeftSideHeightTag = 0xA2F8CAD9; + private const uint RightSideHeightTag = 0xA2F8CAD3; + private const uint StateTag = 0x9600BED2; + private const uint BlinkIntervalTag = 0x95F3C9E3; + private const uint BlinkPatternTag = 0x9600C2E3; + private const uint LitColorTag = 0x9DF2CFD9; + private const uint UnlitColorTag = 0x9DF2CFD0; + private const uint GlowCenterTag = 0x9BFCCFDD; + private const uint DiameterTag = 0x9D00C9E1; + private const uint GlowRadiusTag = 0x96FDD1D3; + private const uint GenerateHitEventTag = 0x95EBCDDD; + + public static bool TryConvert(FuturePinballSourceStream element, out FuturePinballNativeItem converted) + { + if (element == null) throw new ArgumentNullException(nameof(element)); + converted = null; + if (!element.ElementType.HasValue) return false; + + switch (element.ElementType.Value) { + case FuturePinballElementType.Surface: + case FuturePinballElementType.GuideWall: + return Result(Surface(element), new[] { "physics response" }, out converted); + case FuturePinballElementType.Ramp: + case FuturePinballElementType.WireRamp: + return Result(Ramp(element), new[] { "physics response" }, out converted); + case FuturePinballElementType.ShapeableRubber: + return Result(ShapeableRubber(element), new[] { "thickness", "physics response", "slingshot and leaf-trigger point behavior" }, out converted); + case FuturePinballElementType.WireGuide: + return Result(WireGuide(element), new[] { "bend radius", "physics response" }, out converted); + case FuturePinballElementType.Flipper: + return Result(Flipper(element), new[] { "dimensions", "strength scale", "elasticity scale", "return and damping" }, out converted); + case FuturePinballElementType.Bumper: + return Result(Bumper(element), new[] { "radius", "strength scale when active", "lamp and skirt-switch scripting", "animation timing" }, out converted); + case FuturePinballElementType.LeafTarget: + return Result(Target(element, false), new[] { "dimensions", "physics response" }, out converted); + case FuturePinballElementType.DropTarget: + return Result(Target(element, true), new[] { "dimensions", "multi-target bank expansion and grouping", "animation timing", "physics response" }, out converted); + case FuturePinballElementType.Plunger: + return Result(Plunger(element, false), new[] { "rotation", "dimensions", "stroke", "strength scale", "pull and fire speed" }, out converted); + case FuturePinballElementType.AutoPlunger: + return Result(Plunger(element, true), new[] { "rotation", "dimensions", "stroke", "strength scale", "fire speed" }, out converted); + case FuturePinballElementType.Kicker: + return Result(Kicker(element), new[] { "radius", "strength scale", "scatter and hit accuracy" }, out converted); + case FuturePinballElementType.EmKicker: + return Result(Kicker(element), new[] { "radius", "strength scale", "scatter and hit accuracy" }, out converted); + case FuturePinballElementType.Trigger: + return Result(Trigger(element, false), new[] { "trigger footprint", "animation speed" }, out converted); + case FuturePinballElementType.OptoTrigger: + return Result(Trigger(element, true), new[] { "beam length", "animation speed" }, out converted); + case FuturePinballElementType.Gate: + return Result(Gate(element), new[] { "dimensions", "travel angles", "damping and physics response" }, out converted); + case FuturePinballElementType.Spinner: + return Result(Spinner(element), new[] { "dimensions", "damping scale", "physics response" }, out converted); + case FuturePinballElementType.RoundLight: + case FuturePinballElementType.ShapeableLight: + case FuturePinballElementType.Bulb: + return Result(Light(element), new[] { "lens and halo geometry", "intensity", "fade speeds" }, out converted); + default: + return false; + } + } + + private static bool Result(IItem item, IReadOnlyList defaults, out FuturePinballNativeItem converted) + { + if (item == null) { + converted = null; + return false; + } + converted = new FuturePinballNativeItem { Item = item, DefaultedParameters = defaults }; + return true; + } + + private static Surface Surface(FuturePinballSourceStream element) + { + var points = DragPoints(element); + if (points.Length < 3) return null; + var isGuideWall = element.ElementType == FuturePinballElementType.GuideWall; + return new Surface(new SurfaceData(Name(element), points) { + HeightTop = ToVpx(FuturePinballElementGeometry.Float(element, isGuideWall ? HeightTag : TopHeightTag)), + HeightBottom = isGuideWall ? 0f : ToVpx(FuturePinballElementGeometry.Float(element, BottomHeightTag)), + IsCollidable = FuturePinballElementGeometry.Integer(element, CollidableTag, 1) != 0, + IsTopBottomVisible = FuturePinballElementGeometry.Integer(element, RenderObjectTag, 1) != 0, + IsSideVisible = FuturePinballElementGeometry.Integer(element, RenderObjectTag, 1) != 0, + IsReflectionEnabled = FuturePinballElementGeometry.Integer(element, ReflectsTag, 1) != 0, + HitEvent = FuturePinballElementGeometry.Integer(element, GenerateHitEventTag) != 0 + }); + } + + private static Ramp Ramp(FuturePinballSourceStream element) + { + var points = DragPoints(element); + if (points.Length < 2) return null; + var isWire = element.ElementType == FuturePinballElementType.WireRamp; + return new Ramp(new RampData(Name(element), points) { + HeightBottom = ToVpx(FuturePinballElementGeometry.Integer(element, StartHeightTag)), + HeightTop = ToVpx(FuturePinballElementGeometry.Integer(element, EndHeightTag)), + WidthBottom = ToVpx(FuturePinballElementGeometry.Integer(element, StartWidthTag, 40)), + WidthTop = ToVpx(FuturePinballElementGeometry.Integer(element, EndWidthTag, 40)), + LeftWallHeight = ToVpx(FuturePinballElementGeometry.Integer(element, LeftSideHeightTag)), + RightWallHeight = ToVpx(FuturePinballElementGeometry.Integer(element, RightSideHeightTag)), + LeftWallHeightVisible = ToVpx(FuturePinballElementGeometry.Integer(element, LeftSideHeightTag)), + RightWallHeightVisible = ToVpx(FuturePinballElementGeometry.Integer(element, RightSideHeightTag)), + RampType = isWire ? VPT.RampType.RampType2Wire : VPT.RampType.RampTypeFlat, + WireDiameter = ToVpx(6f), + WireDistanceX = ToVpx(30f), + IsCollidable = FuturePinballElementGeometry.Integer(element, CollidableTag, 1) != 0, + IsVisible = FuturePinballElementGeometry.Integer(element, RenderObjectTag, 1) != 0, + IsReflectionEnabled = FuturePinballElementGeometry.Integer(element, ReflectsTag, 1) != 0 + }); + } + + private static Rubber ShapeableRubber(FuturePinballSourceStream element) + { + var points = DragPoints(element); + if (points.Length < 2) return null; + var offset = ToVpx(FuturePinballElementGeometry.Integer(element, OffsetTag)); + return new Rubber(new RubberData(Name(element)) { + DragPoints = points, + Height = offset, + HitHeight = offset, + IsReflectionEnabled = FuturePinballElementGeometry.Integer(element, ReflectsTag, 1) != 0 + }); + } + + private static MetalWireGuide WireGuide(FuturePinballSourceStream element) + { + var points = DragPoints(element); + if (points.Length < 2) return null; + var height = ToVpx(FuturePinballElementGeometry.Integer(element, HeightTag, 25)); + return new MetalWireGuide(new MetalWireGuideData(Name(element)) { + DragPoints = points, + Height = height, + HitHeight = height, + Thickness = ToVpx(FuturePinballElementGeometry.Integer(element, WidthTag, 3)), + IsReflectionEnabled = FuturePinballElementGeometry.Integer(element, ReflectsTag, 1) != 0 + }); + } + + private static Flipper Flipper(FuturePinballSourceStream element) + { + var position = Position(element); + var data = new FlipperData(Name(element), position.X, position.Y) { + Surface = SurfaceName(element), + IsReflectionEnabled = FuturePinballElementGeometry.Integer(element, ReflectsTag, 1) != 0 + }; + if (FuturePinballElementGeometry.HasTag(element, StartAngleTag)) { + data.StartAngle = FuturePinballElementGeometry.Integer(element, StartAngleTag); + if (FuturePinballElementGeometry.HasTag(element, SwingTag)) { + data.EndAngle = data.StartAngle + FuturePinballElementGeometry.Integer(element, SwingTag); + } + } + return new Flipper(data); + } + + private static Bumper Bumper(FuturePinballSourceStream element) + { + var position = Position(element); + var data = new BumperData(Name(element), position.X, position.Y) { + Surface = SurfaceName(element), + IsReflectionEnabled = FuturePinballElementGeometry.Integer(element, ReflectsTag, 1) != 0 + }; + // An FP bumper without its trigger skirt cannot react to a ball hit, even when Passive is clear. + if (FuturePinballElementGeometry.Integer(element, PassiveTag) != 0 + || FuturePinballElementGeometry.Integer(element, TriggerSkirtTag, 1) == 0) data.Force = 0f; + return new Bumper(data); + } + + private static HitTarget Target(FuturePinballSourceStream element, bool drop) + { + var position = Position(element); + return new HitTarget(new HitTargetData(Name(element), position.X, position.Y) { + Position = new Vertex3D(position.X, position.Y, 0f), + RotZ = FuturePinballElementGeometry.Integer(element, RotationTag), + TargetType = drop ? VPT.TargetType.DropTargetSimple : VPT.TargetType.HitTargetRectangle, + IsReflectionEnabled = FuturePinballElementGeometry.Integer(element, ReflectsTag, 1) != 0 + }); + } + + private static Plunger Plunger(FuturePinballSourceStream element, bool automatic) + { + var position = Position(element); + return new Plunger(new PlungerData(Name(element), position.X, position.Y) { + Surface = SurfaceName(element), + AutoPlunger = automatic, + IsMechPlunger = !automatic, + IsReflectionEnabled = FuturePinballElementGeometry.Integer(element, ReflectsTag, 1) != 0 + }); + } + + private static Kicker Kicker(FuturePinballSourceStream element) + { + var position = Position(element); + var hasSourceType = FuturePinballElementGeometry.HasTag(element, KickerTypeTag); + var sourceType = FuturePinballElementGeometry.Integer(element, KickerTypeTag); + var renderModel = FuturePinballElementGeometry.Integer(element, RenderModelTag, 1) != 0; + var type = renderModel ? VPT.KickerType.KickerHole : VPT.KickerType.KickerInvisible; + return new Kicker(new KickerData(Name(element), position.X, position.Y) { + Surface = SurfaceName(element), + Orientation = FuturePinballElementGeometry.Integer(element, RotationTag), + KickerType = type, + FallThrough = hasSourceType && sourceType == 1 + }); + } + + private static Trigger Trigger(FuturePinballSourceStream element, bool opto) + { + var position = Position(element); + var data = new TriggerData(Name(element), position.X, position.Y) { + Surface = SurfaceName(element), + Rotation = FuturePinballElementGeometry.Integer(element, RotationTag), + Shape = opto ? VPT.TriggerShape.TriggerNone : VPT.TriggerShape.TriggerWireA, + IsVisible = !opto && FuturePinballElementGeometry.Integer(element, RenderModelTag, 1) != 0 + }; + if (opto && FuturePinballElementGeometry.HasTag(element, WidthTag)) { + data.Radius = ToVpx(FuturePinballElementGeometry.Integer(element, WidthTag)) / 2f; + } + return new Trigger(data); + } + + private static Gate Gate(FuturePinballSourceStream element) + { + var position = Position(element); + return new Gate(new GateData(Name(element), position.X, position.Y) { + Surface = SurfaceName(element), + Rotation = FuturePinballElementGeometry.Integer(element, RotationTag), + TwoWay = FuturePinballElementGeometry.Integer(element, OneWayTag, 1) == 0, + IsReflectionEnabled = FuturePinballElementGeometry.Integer(element, ReflectsTag, 1) != 0 + }); + } + + private static Spinner Spinner(FuturePinballSourceStream element) + { + var position = Position(element); + return new Spinner(new SpinnerData(Name(element), position.X, position.Y) { + Surface = SurfaceName(element), + Rotation = FuturePinballElementGeometry.Integer(element, RotationTag) + }); + } + + private static Light Light(FuturePinballSourceStream element) + { + var type = element.ElementType.Value; + var isRound = type == FuturePinballElementType.RoundLight; + var isShapeable = type == FuturePinballElementType.ShapeableLight; + var isBulb = type == FuturePinballElementType.Bulb; + var position = isShapeable ? ShapeCenter(element) : Position(element); + if (FuturePinballElementGeometry.HasTag(element, GlowCenterTag)) { + position = Position(element, GlowCenterTag); + } + var data = new LightData(Name(element), position.X, position.Y) { + Surface = SurfaceName(element), + State = LightState(element), + BlinkInterval = FuturePinballElementGeometry.Integer(element, BlinkIntervalTag, 125), + BlinkPattern = FuturePinballElementGeometry.Text(element, BlinkPatternTag, "10"), + Color = SourceColor(element, LitColorTag, 0x0000ffff), + Color2 = SourceColor(element, UnlitColorTag, 0x00ffffff), + OffImage = isBulb ? string.Empty : PlayfieldImage, + IsRoundLight = isRound, + IsBulbLight = isBulb, + ShowBulbMesh = isBulb && FuturePinballElementGeometry.Integer(element, RenderModelTag, 1) != 0, + DragPoints = isShapeable ? DragPoints(element) : null + }; + var diameter = FuturePinballElementGeometry.Integer(element, DiameterTag); + var glowRadius = FuturePinballElementGeometry.Integer(element, GlowRadiusTag); + if (diameter > 0) data.MeshRadius = ToVpx(diameter) / 2f; + if (isRound) data.DragPoints = RoundLightPoints(Position(element), data.MeshRadius); + if (glowRadius > 0) data.Falloff = ToVpx(glowRadius); + else if (diameter > 0) data.Falloff = ToVpx(diameter) / 2f; + return new Light(data); + } + + private static int LightState(FuturePinballSourceStream element) + { + // FP persists its documented BulbOff/BulbOn/BulbBlink states as 0/1/2. Unknown or + // missing values deliberately retain VPE's safe Off default. + switch (FuturePinballElementGeometry.Integer(element, StateTag, VPT.LightStatus.LightStateOff)) { + case 1: return VPT.LightStatus.LightStateOn; + case 2: return VPT.LightStatus.LightStateBlinking; + default: return VPT.LightStatus.LightStateOff; + } + } + + private static Vertex2D ShapeCenter(FuturePinballSourceStream element) + { + var points = FuturePinballElementGeometry.Points(element); + if (points.Count == 0) return Position(element); + return new Vertex2D( + ToVpx(points.Average(point => point.Position.X)), + ToVpx(points.Average(point => point.Position.Y)) + ); + } + + private static DragPointData[] RoundLightPoints(Vertex2D center, float radius) + { + const int pointCount = 8; + var points = new DragPointData[pointCount]; + for (var i = 0; i < pointCount; i++) { + var angle = -System.Math.PI / 2d + i * 2d * System.Math.PI / pointCount; + points[i] = new DragPointData( + center.X + radius * (float)System.Math.Cos(angle), + center.Y + radius * (float)System.Math.Sin(angle) + ) { IsSmooth = true }; + } + return points; + } + + private static DragPointData[] DragPoints(FuturePinballSourceStream element) + { + return FuturePinballElementGeometry.Points(element).Select(point => new DragPointData( + FuturePinballCoordinateConverter.ToVpx(point.Position.X, point.Position.Y, 0f) + ) { IsSmooth = point.Smooth }).ToArray(); + } + + private static Vertex2D Position(FuturePinballSourceStream element, uint tag = FuturePinballElementGeometry.PositionTag) + { + var position = FuturePinballElementGeometry.Position(element, tag); + return new Vertex2D(ToVpx(position.X), ToVpx(position.Y)); + } + + private static string Name(FuturePinballSourceStream element) + { + return FuturePinballElementGeometry.Text(element, NameTag, element.Name); + } + + private static string SurfaceName(FuturePinballSourceStream element) + { + return FuturePinballElementGeometry.Text(element, SurfaceTag); + } + + private static Color SourceColor(FuturePinballSourceStream element, uint tag, uint fallback) + { + return new Color(FuturePinballElementGeometry.Color(element, tag, fallback), ColorFormat.Bgr).WithAlpha(255); + } + + private static float ToVpx(float value) => FuturePinballCoordinateConverter.ToVpx(value); + } +} diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs.meta b/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs.meta new file mode 100644 index 000000000..686aa2108 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e104fbf26238406b9ca62553f9a94594 diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballRecordReader.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballRecordReader.cs new file mode 100644 index 000000000..a326d46bd --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballRecordReader.cs @@ -0,0 +1,511 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.Text; + +namespace VisualPinball.Engine.IO.FuturePinball +{ + internal enum FuturePinballRecordContext + { + TableData, + TableElement, + Resource, + List, + PinModel + } + + internal sealed class FuturePinballChunkDescriptor + { + public string Name { get; } + public FuturePinballValueKind Kind { get; } + + public FuturePinballChunkDescriptor(string name, FuturePinballValueKind kind) + { + Name = name; + Kind = kind; + } + } + + internal static class FuturePinballRecordReader + { + internal const uint LegacyTagCorrection = 0x15BDECDB; + internal const uint EndTag = 0xA7FDC4E0; + internal const uint ScriptTag = 0x4F5A4C7A; + + private static readonly IReadOnlyDictionary TableDataDescriptors = CreateTableDataDescriptors(); + private static readonly IReadOnlyDictionary ResourceDescriptors = CreateResourceDescriptors(); + private static readonly IReadOnlyDictionary ListDescriptors = CreateListDescriptors(); + private static readonly IReadOnlyDictionary PinModelDescriptors = CreatePinModelDescriptors(); + private static readonly IReadOnlyDictionary ElementDescriptors = CreateElementDescriptors(); + + public static IReadOnlyList Read( + byte[] data, + int startOffset, + FuturePinballRecordContext context, + FuturePinballReaderOptions options, + string sourceName) + { + var records = new List(); + var offset = startOffset; + while (offset < data.Length) { + if (records.Count >= options.MaximumRecordCount) { + throw new FuturePinballFormatException( + $"Record count exceeds the configured limit {options.MaximumRecordCount}", sourceName, offset + ); + } + if (data.Length - offset < 8) { + throw new FuturePinballFormatException("Truncated record header", sourceName, offset); + } + + var storedLength = ReadUInt32(data, offset); + if (storedLength < sizeof(uint)) { + throw new FuturePinballFormatException($"Invalid record length {storedLength}", sourceName, offset); + } + if (storedLength > int.MaxValue) { + throw new FuturePinballFormatException($"Record length {storedLength} is too large", sourceName, offset); + } + + var originalTag = ReadUInt32(data, offset + 4); + var descriptor = FindDescriptor(context, originalTag, out var canonicalTag); + var payloadOffset = offset + 8; + var storedPayloadLength = (int)storedLength - sizeof(uint); + var payloadLength = descriptor?.Kind == FuturePinballValueKind.StringList + ? MeasureStringList(data, payloadOffset, options, sourceName) + : storedPayloadLength; + + if (payloadLength < 0 || payloadOffset > data.Length - payloadLength) { + throw new FuturePinballFormatException( + $"Record payload length {payloadLength} crosses the stream boundary", sourceName, offset + ); + } + + var consumedLength = 8 + payloadLength; + var record = new FuturePinballRecord { + Offset = offset, + StoredLength = storedLength, + OriginalTag = originalTag, + CanonicalTag = canonicalTag, + ConsumedLength = consumedLength, + Name = descriptor?.Name, + ValueKind = descriptor?.Kind ?? FuturePinballValueKind.Opaque, + RawRecord = new ReadOnlyMemory(data, offset, consumedLength), + Payload = new ReadOnlyMemory(data, payloadOffset, payloadLength) + }; + record.Value = DecodeValue(record, options, sourceName); + records.Add(record); + offset += consumedLength; + } + + if (offset != data.Length) { + throw new FuturePinballFormatException("Record parsing did not align to the stream boundary", sourceName, offset); + } + return records; + } + + private static object DecodeValue(FuturePinballRecord record, FuturePinballReaderOptions options, string sourceName) + { + var payload = record.Payload.Span; + switch (record.ValueKind) { + case FuturePinballValueKind.Integer: + Require(payload, 4, record, sourceName); + return ReadInt32(payload, 0); + case FuturePinballValueKind.Float: + Require(payload, 4, record, sourceName); + return BitConverter.Int32BitsToSingle(ReadInt32(payload, 0)); + case FuturePinballValueKind.Color: + Require(payload, 4, record, sourceName); + return ReadUInt32(payload, 0); + case FuturePinballValueKind.Vector2: + Require(payload, 8, record, sourceName); + return new FuturePinballVector2( + BitConverter.Int32BitsToSingle(ReadInt32(payload, 0)), + BitConverter.Int32BitsToSingle(ReadInt32(payload, 4)) + ); + case FuturePinballValueKind.String: + return ReadString(payload, Encoding.ASCII, options, record, sourceName); + case FuturePinballValueKind.WideString: + return ReadString(payload, Encoding.Unicode, options, record, sourceName); + case FuturePinballValueKind.StringList: + return ReadStringList(payload, options, record, sourceName); + case FuturePinballValueKind.CollisionData: + return ReadCollisionData(payload, record, sourceName); + case FuturePinballValueKind.CompressedData: + if (!options.DecodeCompressedData) { + return new FuturePinballCompressedData { RawBytes = record.Payload }; + } + var compressedBytes = record.CanonicalTag == ScriptTag + ? record.RawRecord.Slice(sizeof(uint), (int)record.StoredLength) + : record.Payload; + return FuturePinballCompression.Decode( + compressedBytes, options.MaximumDecompressedBytes, sourceName, record.Offset + 4 + ); + case FuturePinballValueKind.End: + return null; + default: + return record.Payload; + } + } + + private static void Require(ReadOnlySpan payload, int length, FuturePinballRecord record, string sourceName) + { + if (payload.Length < length) { + throw new FuturePinballFormatException( + $"{record.Name ?? "Known"} record needs {length} payload bytes but has {payload.Length}", + sourceName, + record.Offset + ); + } + } + + private static string ReadString( + ReadOnlySpan payload, + Encoding encoding, + FuturePinballReaderOptions options, + FuturePinballRecord record, + string sourceName) + { + Require(payload, 4, record, sourceName); + var byteLength = ReadInt32(payload, 0); + if (byteLength < 0 || byteLength > options.MaximumStringBytes || byteLength > payload.Length - 4) { + throw new FuturePinballFormatException($"Invalid string length {byteLength}", sourceName, record.Offset + 8); + } + return encoding.GetString(payload.Slice(4, byteLength).ToArray()).TrimEnd('\0'); + } + + private static IReadOnlyList ReadStringList( + ReadOnlySpan payload, + FuturePinballReaderOptions options, + FuturePinballRecord record, + string sourceName) + { + Require(payload, 4, record, sourceName); + var count = ReadInt32(payload, 0); + if (count < 0 || count > options.MaximumRecordCount) { + throw new FuturePinballFormatException($"Invalid string-list count {count}", sourceName, record.Offset + 8); + } + var result = new List(count); + var offset = 4; + for (var i = 0; i < count; i++) { + if (offset > payload.Length - 4) { + throw new FuturePinballFormatException("Truncated string-list length", sourceName, record.Offset + 8 + offset); + } + var length = ReadInt32(payload, offset); + offset += 4; + if (length < 0 || length > options.MaximumStringBytes || offset > payload.Length - length) { + throw new FuturePinballFormatException($"Invalid string-list item length {length}", sourceName, record.Offset + 8 + offset); + } + result.Add(Encoding.ASCII.GetString(payload.Slice(offset, length).ToArray()).TrimEnd('\0')); + offset += length; + } + return result; + } + + private static int MeasureStringList(byte[] data, int payloadOffset, FuturePinballReaderOptions options, string sourceName) + { + if (payloadOffset > data.Length - 4) { + throw new FuturePinballFormatException("Truncated string-list count", sourceName, payloadOffset); + } + var count = ReadInt32(data, payloadOffset); + if (count < 0 || count > options.MaximumRecordCount) { + throw new FuturePinballFormatException($"Invalid string-list count {count}", sourceName, payloadOffset); + } + var offset = payloadOffset + 4; + for (var i = 0; i < count; i++) { + if (offset > data.Length - 4) { + throw new FuturePinballFormatException("Truncated string-list length", sourceName, offset); + } + var length = ReadInt32(data, offset); + offset += 4; + if (length < 0 || length > options.MaximumStringBytes || offset > data.Length - length) { + throw new FuturePinballFormatException($"Invalid string-list item length {length}", sourceName, offset); + } + offset += length; + } + return offset - payloadOffset; + } + + private static IReadOnlyList ReadCollisionData( + ReadOnlySpan payload, + FuturePinballRecord record, + string sourceName) + { + const int shapeBytes = 44; + if (payload.Length % shapeBytes != 0) { + throw new FuturePinballFormatException( + $"Collision payload length {payload.Length} is not a multiple of {shapeBytes}", sourceName, record.Offset + ); + } + var shapes = new List(payload.Length / shapeBytes); + for (var offset = 0; offset < payload.Length; offset += shapeBytes) { + shapes.Add(new FuturePinballCollisionShape { + Type = ReadUInt32(payload, offset), + GenerateHitEvent = ReadUInt32(payload, offset + 4) != 0, + AffectsBall = ReadUInt32(payload, offset + 8) != 0, + EventId = ReadUInt32(payload, offset + 12), + X = ReadSingle(payload, offset + 16), + Y = ReadSingle(payload, offset + 20), + Z = ReadSingle(payload, offset + 24), + Value1 = ReadSingle(payload, offset + 28), + Value2 = ReadSingle(payload, offset + 32), + Value3 = ReadSingle(payload, offset + 36), + Value4 = ReadSingle(payload, offset + 40) + }); + } + return shapes; + } + + private static FuturePinballChunkDescriptor FindDescriptor( + FuturePinballRecordContext context, + uint originalTag, + out uint canonicalTag) + { + var descriptors = Descriptors(context); + if (descriptors.TryGetValue(originalTag, out var descriptor)) { + canonicalTag = originalTag; + return descriptor; + } + if (originalTag >= LegacyTagCorrection) { + var legacyTag = originalTag - LegacyTagCorrection; + if (descriptors.TryGetValue(legacyTag, out descriptor)) { + canonicalTag = legacyTag; + return descriptor; + } + } + canonicalTag = originalTag; + return null; + } + + private static IReadOnlyDictionary Descriptors(FuturePinballRecordContext context) + { + switch (context) { + case FuturePinballRecordContext.TableData: return TableDataDescriptors; + case FuturePinballRecordContext.TableElement: return ElementDescriptors; + case FuturePinballRecordContext.Resource: return ResourceDescriptors; + case FuturePinballRecordContext.List: return ListDescriptors; + case FuturePinballRecordContext.PinModel: return PinModelDescriptors; + default: throw new ArgumentOutOfRangeException(nameof(context), context, null); + } + } + + private static Dictionary CreateTableDataDescriptors() + { + var result = CommonEnd(); + Add(result, 0xA4F4D1D7, "name", FuturePinballValueKind.WideString); + Add(result, 0xA5F8BBD1, "width", FuturePinballValueKind.Integer); + Add(result, 0x9BFCC6D1, "length", FuturePinballValueKind.Integer); + Add(result, 0xA1FACCD1, "front_glass_height", FuturePinballValueKind.Integer); + Add(result, 0xA1FAC0D1, "rear_glass_height", FuturePinballValueKind.Integer); + Add(result, 0x9AF5BFD1, "slope", FuturePinballValueKind.Float); + Add(result, 0x9DF2CFD5, "playfield_color", FuturePinballValueKind.Color); + Add(result, 0xA2F4C9D5, "playfield_texture", FuturePinballValueKind.String); + Add(result, 0x9AFECBE3, "translite_color", FuturePinballValueKind.Color); + Add(result, 0xA2F4C9E3, "translite_image", FuturePinballValueKind.String); + Add(result, 0xA0EACBE3, "translite_width", FuturePinballValueKind.Integer); + Add(result, 0xA4F9CBE3, "translite_height", FuturePinballValueKind.Integer); + Add(result, 0x99E8BED8, "machine_type", FuturePinballValueKind.Integer); + Add(result, 0xA2F4C9E2, "cabinet_texture", FuturePinballValueKind.String); + Add(result, 0x9BEDC9D1, "table_name", FuturePinballValueKind.String); + Add(result, 0xA4EBC9D1, "version", FuturePinballValueKind.String); + Add(result, 0x9500C9D1, "authors", FuturePinballValueKind.String); + Add(result, 0xA5EFC9D1, "release_date", FuturePinballValueKind.String); + Add(result, 0x9CFCC9D1, "email", FuturePinballValueKind.String); + Add(result, 0x96EAC9D1, "web_page", FuturePinballValueKind.String); + Add(result, 0xA4FDC9D1, "description", FuturePinballValueKind.String); + Add(result, 0x96EFC9D1, "rules_length", FuturePinballValueKind.Integer); + Add(result, 0x99F5C9D1, "loading_picture", FuturePinballValueKind.String); + Add(result, 0x95FDCDD2, "element_count", FuturePinballValueKind.Integer); + Add(result, 0xA2F4C9D2, "image_count", FuturePinballValueKind.Integer); + Add(result, 0xA5F3BFD2, "sound_count", FuturePinballValueKind.Integer); + Add(result, 0x96ECC5D2, "music_count", FuturePinballValueKind.Integer); + Add(result, 0xA5F2C5D2, "pin_model_count", FuturePinballValueKind.Integer); + Add(result, 0x95F5C9D2, "image_list_count", FuturePinballValueKind.Integer); + Add(result, 0x95F5C6D2, "light_list_count", FuturePinballValueKind.Integer); + Add(result, 0x9BFBCED2, "dmd_font_count", FuturePinballValueKind.Integer); + Add(result, ScriptTag, "script", FuturePinballValueKind.CompressedData); + return result; + } + + private static Dictionary CreateResourceDescriptors() + { + var result = CommonEnd(); + Add(result, 0xA4F1B9D1, "type", FuturePinballValueKind.Integer); + Add(result, 0xA4F4D1D7, "name", FuturePinballValueKind.String); + Add(result, 0xA4F4C4DC, "id", FuturePinballValueKind.String); + Add(result, 0xA1EDD1D5, "linked_path", FuturePinballValueKind.String); + Add(result, 0x9EF3C6D9, "linked", FuturePinballValueKind.Integer); + Add(result, 0xA6E9BEE4, "compression", FuturePinballValueKind.Integer); + Add(result, 0x95F5CCE1, "disable_filtering", FuturePinballValueKind.Integer); + Add(result, 0x96F3C0D1, "transparent_color", FuturePinballValueKind.Color); + Add(result, 0xA4E7C9D2, "data_length", FuturePinballValueKind.Integer); + Add(result, 0xA8EDD1E1, "data", FuturePinballValueKind.CompressedData); + return result; + } + + private static Dictionary CreateListDescriptors() + { + var result = CommonEnd(); + Add(result, 0xA4F4D1D7, "name", FuturePinballValueKind.String); + Add(result, 0xA8EDD1E1, "items", FuturePinballValueKind.StringList); + return result; + } + + private static Dictionary CreatePinModelDescriptors() + { + var result = CommonEnd(); + Add(result, 0xA4F4D1D7, "name", FuturePinballValueKind.String); + Add(result, 0xA4F4C4DC, "id", FuturePinballValueKind.String); + Add(result, 0x9EF3C6D9, "linked", FuturePinballValueKind.Integer); + Add(result, 0xA4F1B9D1, "type", FuturePinballValueKind.Integer); + Add(result, 0x99E8BED8, "material_type", FuturePinballValueKind.Integer); + Add(result, 0x9D00C4DC, "preview_path", FuturePinballValueKind.String); + Add(result, 0x8FF8BFDC, "preview_data_length", FuturePinballValueKind.Integer); + Add(result, 0x9600CEDC, "preview_data", FuturePinballValueKind.CompressedData); + Add(result, 0x9AFEC2D5, "per_polygon_collision", FuturePinballValueKind.Integer); + Add(result, 0xA8EFCBD3, "special_value", FuturePinballValueKind.Float); + Add(result, 0x9D00C4D5, "primary_model_path", FuturePinballValueKind.String); + Add(result, 0x8FF8BFD5, "primary_model_data_length", FuturePinballValueKind.Integer); + Add(result, 0x9600CED5, "primary_model_data", FuturePinballValueKind.CompressedData); + Add(result, 0x9D00C4D2, "secondary_model_path", FuturePinballValueKind.String); + Add(result, 0x8FF8BFD2, "secondary_model_data_length", FuturePinballValueKind.Integer); + Add(result, 0x9600CED2, "secondary_model_data", FuturePinballValueKind.CompressedData); + Add(result, 0x9D00C4D1, "mask_model_path", FuturePinballValueKind.String); + Add(result, 0x8FF8BFD1, "mask_model_data_length", FuturePinballValueKind.Integer); + Add(result, 0x9600CED1, "mask_model_data", FuturePinballValueKind.CompressedData); + Add(result, 0x9D00C4D3, "reflection_model_path", FuturePinballValueKind.String); + Add(result, 0x8FF8BFD3, "reflection_model_data_length", FuturePinballValueKind.Integer); + Add(result, 0x9600CED3, "reflection_model_data", FuturePinballValueKind.CompressedData); + Add(result, 0x8FEEC3E2, "collision_shape_count", FuturePinballValueKind.Integer); + Add(result, 0x93FBC3E2, "collision_shapes_enabled", FuturePinballValueKind.Integer); + Add(result, 0x9DFCC3E2, "collision_shapes", FuturePinballValueKind.CollisionData); + Add(result, 0xA1EDD1D5, "linked_path", FuturePinballValueKind.String); + return result; + } + + private static Dictionary CreateElementDescriptors() + { + var result = CommonEnd(); + Add(result, 0xA4F4D1D7, "name", FuturePinballValueKind.WideString); + Add(result, 0x9BFCCFCF, "position", FuturePinballValueKind.Vector2); + Add(result, 0x9BFCCFDD, "glow_center", FuturePinballValueKind.Vector2); + Add(result, 0x9DFDC3D8, "model", FuturePinballValueKind.String); + Add(result, 0xA3EFBDD2, "surface", FuturePinballValueKind.String); + Add(result, 0xA300C5DC, "texture", FuturePinballValueKind.String); + Add(result, 0x97F5C3E2, "color", FuturePinballValueKind.Color); + Add(result, 0xA8EDC3D3, "rotation", FuturePinballValueKind.Integer); + Add(result, 0xA900BED2, "start_angle", FuturePinballValueKind.Integer); + Add(result, 0xA2EABFE4, "swing", FuturePinballValueKind.Integer); + Add(result, 0xA1FABED2, "strength", FuturePinballValueKind.Integer); + Add(result, 0x9700C6E0, "elasticity", FuturePinballValueKind.Integer); + Add(result, 0x9DFBCDD3, "reflects_off_playfield", FuturePinballValueKind.Integer); + Add(result, 0xA0EED1D5, "passive", FuturePinballValueKind.Integer); + Add(result, 0x9EEED1DD, "trigger_skirt", FuturePinballValueKind.Integer); + Add(result, 0x9100BBD6, "one_way", FuturePinballValueKind.Integer); + Add(result, 0x99F4D1E1, "damping", FuturePinballValueKind.Integer); + Add(result, 0x96FBCCD6, "offset", FuturePinballValueKind.Integer); + Add(result, 0xA5F2C5D3, "render_model", FuturePinballValueKind.Integer); + Add(result, 0x99E8BEDA, "kicker_type", FuturePinballValueKind.Integer); + Add(result, 0x9600BED2, "state", FuturePinballValueKind.Integer); + Add(result, 0x95F3C9E3, "blink_interval", FuturePinballValueKind.Integer); + Add(result, 0x9600C2E3, "blink_pattern", FuturePinballValueKind.String); + Add(result, 0x9DF2CFD9, "lit_color", FuturePinballValueKind.Color); + Add(result, 0x9DF2CFD0, "unlit_color", FuturePinballValueKind.Color); + Add(result, 0x9D00C9E1, "diameter", FuturePinballValueKind.Integer); + Add(result, 0x96FDD1D3, "glow_radius", FuturePinballValueKind.Integer); + Add(result, 0x95EBCDDD, "generate_hit_event", FuturePinballValueKind.Integer); + // Element-dependent: GuideWall stores a float while several display and guide types store an integer. + Add(result, 0xA2F8CDDD, "height", FuturePinballValueKind.Opaque); + Add(result, 0x95FDC9CE, "width", FuturePinballValueKind.Integer); + Add(result, 0x95F3C2D2, "point", FuturePinballValueKind.Opaque); + Add(result, 0xA1EDC5D2, "smooth", FuturePinballValueKind.Integer); + Add(result, 0x99F2BEDD, "top_height", FuturePinballValueKind.Float); + Add(result, 0x95F2D0DD, "bottom_height", FuturePinballValueKind.Float); + Add(result, 0x9DF5C3E2, "collidable", FuturePinballValueKind.Integer); + Add(result, 0x97FDC4D3, "render_object", FuturePinballValueKind.Integer); + Add(result, 0x9DF2CFD1, "top_color", FuturePinballValueKind.Color); + Add(result, 0xA2F4C9D1, "top_texture", FuturePinballValueKind.String); + Add(result, 0x9DF2CFD2, "side_color", FuturePinballValueKind.Color); + Add(result, 0xA2F4C9D2, "side_texture", FuturePinballValueKind.String); + Add(result, 0x9C00C0D1, "transparency", FuturePinballValueKind.Integer); + Add(result, 0x99E8BED8, "material_type", FuturePinballValueKind.Integer); + Add(result, 0xA2F8CAD2, "start_height", FuturePinballValueKind.Integer); + Add(result, 0xA2F8CAE0, "end_height", FuturePinballValueKind.Integer); + Add(result, 0xA5F8BBD2, "start_width", FuturePinballValueKind.Integer); + Add(result, 0xA5F8BBE0, "end_width", FuturePinballValueKind.Integer); + Add(result, 0xA2F8CAD9, "left_side_height", FuturePinballValueKind.Integer); + Add(result, 0xA2F8CAD3, "right_side_height", FuturePinballValueKind.Integer); + Add(result, 0xA3F2C0D5, "ramp_profile", FuturePinballValueKind.Integer); + Add(result, 0x9AF1CAE0, "ramp_end_point", FuturePinballValueKind.Integer); + Add(result, 0xA4F5C9D8, "left_guide", FuturePinballValueKind.Integer); + Add(result, 0xA4F5C2D0, "left_upper_guide", FuturePinballValueKind.Integer); + Add(result, 0xA0EFC9D8, "right_guide", FuturePinballValueKind.Integer); + Add(result, 0xA0EFC2D0, "right_upper_guide", FuturePinballValueKind.Integer); + Add(result, 0x95ECC3D1, "top_wire", FuturePinballValueKind.Integer); + Add(result, 0xA2F3C9D3, "ring_type", FuturePinballValueKind.Integer); + Add(result, 0x9EFEC3D9, "locked", FuturePinballValueKind.Integer); + Add(result, 0x9100C6E4, "layer", FuturePinballValueKind.Integer); + return result; + } + + private static Dictionary CommonEnd() + { + return new Dictionary { + { EndTag, new FuturePinballChunkDescriptor("end", FuturePinballValueKind.End) } + }; + } + + private static void Add( + IDictionary descriptors, + uint tag, + string name, + FuturePinballValueKind kind) + { + descriptors[tag] = new FuturePinballChunkDescriptor(name, kind); + } + + private static uint ReadUInt32(byte[] data, int offset) + { + return ReadUInt32(new ReadOnlySpan(data), offset); + } + + private static int ReadInt32(byte[] data, int offset) + { + return ReadInt32(new ReadOnlySpan(data), offset); + } + + private static uint ReadUInt32(ReadOnlySpan data, int offset) + { + return (uint)(data[offset] + | data[offset + 1] << 8 + | data[offset + 2] << 16 + | data[offset + 3] << 24); + } + + private static int ReadInt32(ReadOnlySpan data, int offset) + { + return data[offset] + | data[offset + 1] << 8 + | data[offset + 2] << 16 + | data[offset + 3] << 24; + } + + private static float ReadSingle(ReadOnlySpan data, int offset) + { + return BitConverter.Int32BitsToSingle(ReadInt32(data, offset)); + } + } +} diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballRecordReader.cs.meta b/VisualPinball.Engine/IO/FuturePinball/FuturePinballRecordReader.cs.meta new file mode 100644 index 000000000..f166bfa99 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballRecordReader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 51d8d457a4854c2780e8e1e307573be5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballTableReader.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballTableReader.cs new file mode 100644 index 000000000..96b4729b2 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballTableReader.cs @@ -0,0 +1,270 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using OpenMcdf; + +namespace VisualPinball.Engine.IO.FuturePinball +{ + public static class FuturePinballTableReader + { + private const uint ElementCountTag = 0x95FDCDD2; + private const uint ImageCountTag = 0xA2F4C9D2; + private const uint SoundCountTag = 0xA5F3BFD2; + private const uint MusicCountTag = 0x96ECC5D2; + private const uint PinModelCountTag = 0xA5F2C5D2; + private const uint ImageListCountTag = 0x95F5C9D2; + private const uint LightListCountTag = 0x95F5C6D2; + private const uint DmdFontCountTag = 0x9BFBCED2; + + public static FuturePinballTable Load(string fileName, FuturePinballReaderOptions options = null) + { + if (fileName == null) { + throw new ArgumentNullException(nameof(fileName)); + } + options ??= new FuturePinballReaderOptions(); + using (var root = RootStorage.OpenRead(fileName, StorageModeFlags.None)) { + var storage = OpenStorage(root, "Future Pinball", fileName); + var entries = storage.EnumerateEntries().ToArray(); + var streams = new List(); + foreach (var entry in entries.Where(entry => entry.Type == EntryType.Stream)) { + if (entry.Length > options.MaximumStreamBytes) { + throw new FuturePinballFormatException( + $"Stream length {entry.Length} exceeds the configured limit {options.MaximumStreamBytes}", + entry.Name + ); + } + var data = storage.OpenStream(entry.Name).ReadAll(); + streams.Add(ParseStream(entry.Name, data, options)); + } + + streams.Sort(CompareStreams); + var issues = new List(); + ReportIndexProblems(streams, issues); + var table = BuildTable(fileName, entries.Length + 1, streams, issues); + ValidateDeclaredCounts(table, issues); + table.Issues = issues; + return table; + } + } + + private static FuturePinballSourceStream ParseStream( + string name, + byte[] data, + FuturePinballReaderOptions options) + { + var kind = Classify(name, out var sourceIndex); + var stream = new FuturePinballSourceStream { + Name = name, + SourceIndex = sourceIndex, + Kind = kind, + RawData = data + }; + + switch (kind) { + case FuturePinballStreamKind.FileVersion: + case FuturePinballStreamKind.TableMac: + case FuturePinballStreamKind.DmdFont: + case FuturePinballStreamKind.Unknown: + return stream; + case FuturePinballStreamKind.TableElement: + if (data.Length < sizeof(uint)) { + throw new FuturePinballFormatException("Table element has no type prefix", name, 0); + } + stream.ElementTypeId = ReadUInt32(data, 0); + if (Enum.IsDefined(typeof(FuturePinballElementType), stream.ElementTypeId.Value)) { + stream.ElementType = (FuturePinballElementType)stream.ElementTypeId.Value; + } + stream.Records = FuturePinballRecordReader.Read( + data, sizeof(uint), FuturePinballRecordContext.TableElement, options, name + ); + return stream; + case FuturePinballStreamKind.TableData: + stream.Records = FuturePinballRecordReader.Read( + data, 0, FuturePinballRecordContext.TableData, options, name + ); + return stream; + case FuturePinballStreamKind.ImageList: + case FuturePinballStreamKind.LightList: + stream.Records = FuturePinballRecordReader.Read(data, 0, FuturePinballRecordContext.List, options, name); + return stream; + case FuturePinballStreamKind.PinModel: + stream.Records = FuturePinballRecordReader.Read(data, 0, FuturePinballRecordContext.PinModel, options, name); + return stream; + default: + stream.Records = FuturePinballRecordReader.Read(data, 0, FuturePinballRecordContext.Resource, options, name); + return stream; + } + } + + private static FuturePinballTable BuildTable( + string fileName, + int compoundEntryCount, + IReadOnlyList streams, + ICollection issues) + { + var fileVersion = streams.FirstOrDefault(stream => stream.Kind == FuturePinballStreamKind.FileVersion); + uint? version = null; + if (fileVersion != null) { + if (fileVersion.RawData.Length >= sizeof(uint)) { + version = ReadUInt32(fileVersion.RawData, 0); + } else { + issues.Add("File Version stream is shorter than four bytes."); + } + } + + return new FuturePinballTable { + SourcePath = Path.GetFullPath(fileName), + FileVersion = version, + CompoundEntryCount = compoundEntryCount, + Streams = streams, + TableData = streams.FirstOrDefault(stream => stream.Kind == FuturePinballStreamKind.TableData), + TableMac = streams.FirstOrDefault(stream => stream.Kind == FuturePinballStreamKind.TableMac), + Elements = OfKind(streams, FuturePinballStreamKind.TableElement), + Images = OfKind(streams, FuturePinballStreamKind.Image), + Sounds = OfKind(streams, FuturePinballStreamKind.Sound), + Music = OfKind(streams, FuturePinballStreamKind.Music), + PinModels = OfKind(streams, FuturePinballStreamKind.PinModel), + DmdFonts = OfKind(streams, FuturePinballStreamKind.DmdFont), + ImageLists = OfKind(streams, FuturePinballStreamKind.ImageList), + LightLists = OfKind(streams, FuturePinballStreamKind.LightList) + }; + } + + private static FuturePinballSourceStream[] OfKind( + IEnumerable streams, + FuturePinballStreamKind kind) + { + return streams.Where(stream => stream.Kind == kind).ToArray(); + } + + private static void ValidateDeclaredCounts(FuturePinballTable table, ICollection issues) + { + if (table.TableData == null) { + issues.Add("Table Data stream is missing."); + return; + } + + CompareCount(table.TableData, ElementCountTag, table.Elements.Count, "table elements", issues); + CompareCount(table.TableData, ImageCountTag, table.Images.Count, "images", issues); + CompareCount(table.TableData, SoundCountTag, table.Sounds.Count, "sounds", issues); + CompareCount(table.TableData, MusicCountTag, table.Music.Count, "music", issues); + CompareCount(table.TableData, PinModelCountTag, table.PinModels.Count, "pin models", issues); + CompareCount(table.TableData, ImageListCountTag, table.ImageLists.Count, "image lists", issues); + CompareCount(table.TableData, LightListCountTag, table.LightLists.Count, "light lists", issues); + CompareCount(table.TableData, DmdFontCountTag, table.DmdFonts.Count, "DMD fonts", issues); + } + + private static void CompareCount( + FuturePinballSourceStream tableData, + uint tag, + int actual, + string category, + ICollection issues) + { + var declared = tableData.Integer(tag); + if (declared.HasValue && declared.Value != actual) { + issues.Add($"Table Data declares {declared.Value} {category}, but the compound file contains {actual}."); + } + } + + private static void ReportIndexProblems( + IEnumerable streams, + ICollection issues) + { + foreach (var group in streams.Where(stream => stream.SourceIndex.HasValue).GroupBy(stream => stream.Kind)) { + var indices = group.Select(stream => stream.SourceIndex.Value).OrderBy(index => index).ToArray(); + var duplicates = indices.GroupBy(index => index).Where(item => item.Count() > 1).Select(item => item.Key).ToArray(); + if (duplicates.Length > 0) { + issues.Add($"{group.Key} streams contain duplicate source indices: {string.Join(", ", duplicates)}."); + } + if (indices.Length > 0) { + var expected = indices[0]; + foreach (var index in indices.Distinct()) { + while (expected < index) { + issues.Add($"{group.Key} stream index {expected} is missing."); + expected++; + } + expected = index + 1; + } + } + } + } + + private static int CompareStreams(FuturePinballSourceStream left, FuturePinballSourceStream right) + { + var kind = left.Kind.CompareTo(right.Kind); + if (kind != 0) { + return kind; + } + if (left.SourceIndex.HasValue && right.SourceIndex.HasValue) { + return left.SourceIndex.Value.CompareTo(right.SourceIndex.Value); + } + return string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase); + } + + private static FuturePinballStreamKind Classify(string name, out int? sourceIndex) + { + sourceIndex = null; + if (name.Equals("File Version", StringComparison.OrdinalIgnoreCase)) return FuturePinballStreamKind.FileVersion; + if (name.Equals("Table Data", StringComparison.OrdinalIgnoreCase)) return FuturePinballStreamKind.TableData; + if (name.Equals("Table MAC", StringComparison.OrdinalIgnoreCase)) return FuturePinballStreamKind.TableMac; + if (TryIndex(name, "Table Element ", out sourceIndex)) return FuturePinballStreamKind.TableElement; + if (TryIndex(name, "Image ", out sourceIndex)) return FuturePinballStreamKind.Image; + if (TryIndex(name, "Sound ", out sourceIndex)) return FuturePinballStreamKind.Sound; + if (TryIndex(name, "Music ", out sourceIndex)) return FuturePinballStreamKind.Music; + if (TryIndex(name, "PinModel ", out sourceIndex)) return FuturePinballStreamKind.PinModel; + if (TryIndex(name, "DmdFont ", out sourceIndex)) return FuturePinballStreamKind.DmdFont; + if (TryIndex(name, "ImageList ", out sourceIndex)) return FuturePinballStreamKind.ImageList; + if (TryIndex(name, "LightList ", out sourceIndex)) return FuturePinballStreamKind.LightList; + return FuturePinballStreamKind.Unknown; + } + + private static bool TryIndex(string name, string prefix, out int? index) + { + index = null; + if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { + return false; + } + if (int.TryParse(name.Substring(prefix.Length), out var value) && value >= 0) { + index = value; + } + return true; + } + + private static Storage OpenStorage(Storage parent, string name, string sourceName) + { + var entry = parent.EnumerateEntries().FirstOrDefault(item => + item.Type == EntryType.Storage && item.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (entry == null) { + throw new FuturePinballFormatException($"Required storage '{name}' is missing", sourceName); + } + return parent.OpenStorage(entry.Name); + } + + private static uint ReadUInt32(byte[] data, int offset) + { + return (uint)(data[offset] + | data[offset + 1] << 8 + | data[offset + 2] << 16 + | data[offset + 3] << 24); + } + } +} diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballTableReader.cs.meta b/VisualPinball.Engine/IO/FuturePinball/FuturePinballTableReader.cs.meta new file mode 100644 index 000000000..a91289b1d --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballTableReader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: be4ce70e28014392a793842245128aa7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine/IO/FuturePinball/MilkShapeModelReader.cs b/VisualPinball.Engine/IO/FuturePinball/MilkShapeModelReader.cs new file mode 100644 index 000000000..953b901b5 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/MilkShapeModelReader.cs @@ -0,0 +1,358 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +using VisualPinball.Engine.Math; +using VisualPinball.Engine.VPT; + +namespace VisualPinball.Engine.IO.FuturePinball +{ + public sealed class MilkShapeVertex + { + public byte Flags { get; internal set; } + public float X { get; internal set; } + public float Y { get; internal set; } + public float Z { get; internal set; } + public sbyte BoneId { get; internal set; } + public byte ReferenceCount { get; internal set; } + } + + public sealed class MilkShapeTriangle + { + public ushort Flags { get; internal set; } + public ushort[] VertexIndices { get; internal set; } + public float[,] Normals { get; internal set; } + public float[] U { get; internal set; } + public float[] V { get; internal set; } + public byte SmoothingGroup { get; internal set; } + public byte GroupIndex { get; internal set; } + } + + public sealed class MilkShapeGroup + { + public byte Flags { get; internal set; } + public string Name { get; internal set; } + public ushort[] TriangleIndices { get; internal set; } + public sbyte MaterialIndex { get; internal set; } + } + + public sealed class MilkShapeMaterial + { + public string Name { get; internal set; } + public float[] Ambient { get; internal set; } + public float[] Diffuse { get; internal set; } + public float[] Specular { get; internal set; } + public float[] Emissive { get; internal set; } + public float Shininess { get; internal set; } + public float Transparency { get; internal set; } + public byte Mode { get; internal set; } + public string Texture { get; internal set; } + public string AlphaMap { get; internal set; } + } + + public sealed class MilkShapeGroupMesh + { + public string Name { get; internal set; } + public int MaterialIndex { get; internal set; } + public Mesh Mesh { get; internal set; } + } + + public sealed class MilkShapeModel + { + public int Version { get; internal set; } + public string SourceSha256 { get; internal set; } + public IReadOnlyList Vertices { get; internal set; } + public IReadOnlyList Triangles { get; internal set; } + public IReadOnlyList Groups { get; internal set; } + public IReadOnlyList Materials { get; internal set; } + public byte[] TrailingData { get; internal set; } + + public IReadOnlyList CreateMeshes() + { + var groups = Groups.Count == 0 + ? new[] { new MilkShapeGroup { Name = "mesh", MaterialIndex = -1, TriangleIndices = Enumerable.Range(0, Triangles.Count).Select(index => (ushort)index).ToArray() } } + : Groups; + var meshes = new List(groups.Count); + foreach (var group in groups) { + var vertices = new List(group.TriangleIndices.Length * 3); + var indices = new List(group.TriangleIndices.Length * 3); + foreach (var triangleIndex in group.TriangleIndices) { + var triangle = Triangles[triangleIndex]; + for (var corner = 0; corner < 3; corner++) { + var position = Vertices[triangle.VertexIndices[corner]]; + indices.Add(vertices.Count); + vertices.Add(new Vertex3DNoTex2( + position.X, position.Y, position.Z, + triangle.Normals[corner, 0], triangle.Normals[corner, 1], triangle.Normals[corner, 2], + triangle.U[corner], triangle.V[corner] + )); + } + } + var name = string.IsNullOrWhiteSpace(group.Name) ? "mesh" : group.Name; + meshes.Add(new MilkShapeGroupMesh { + Name = name, + MaterialIndex = group.MaterialIndex, + Mesh = new Mesh(vertices.ToArray(), indices.ToArray()) { Name = name } + }); + } + return meshes; + } + } + + public sealed class FuturePinballModelVariant + { + public string Role { get; internal set; } + public MilkShapeModel Model { get; internal set; } + } + + public sealed class FuturePinballModelAssets + { + public IReadOnlyList Variants { get; internal set; } = Array.Empty(); + public ReadOnlyMemory Preview { get; internal set; } + } + + public sealed class MilkShapeModelCache + { + private readonly Dictionary _models = new Dictionary(); + + public MilkShapeModel Parse(byte[] data, string sourceName = null) + { + var hash = Sha256(data); + if (_models.TryGetValue(hash, out var model)) return model; + model = MilkShapeModelReader.Parse(data, sourceName); + _models[hash] = model; + return model; + } + + private static string Sha256(byte[] data) + { + using (var sha = SHA256.Create()) return BitConverter.ToString(sha.ComputeHash(data)).Replace("-", string.Empty).ToLowerInvariant(); + } + } + + public static class FuturePinballModelAssetReader + { + public static FuturePinballModelAssets ReadEmbedded( + FuturePinballSourceStream pinModel, + MilkShapeModelCache cache = null) + { + if (pinModel == null) throw new ArgumentNullException(nameof(pinModel)); + cache ??= new MilkShapeModelCache(); + var variants = new List(); + ReadVariant(pinModel, "primary_model_data", variants, cache); + ReadVariant(pinModel, "secondary_model_data", variants, cache); + ReadVariant(pinModel, "mask_model_data", variants, cache); + ReadVariant(pinModel, "reflection_model_data", variants, cache); + var preview = pinModel.Records.FirstOrDefault(record => record.Name == "preview_data")?.Value as FuturePinballCompressedData; + return new FuturePinballModelAssets { Variants = variants, Preview = preview?.DecodedBytes ?? Array.Empty() }; + } + + private static void ReadVariant( + FuturePinballSourceStream pinModel, + string role, + ICollection variants, + MilkShapeModelCache cache) + { + var data = pinModel.Records.FirstOrDefault(record => record.Name == role)?.Value as FuturePinballCompressedData; + if (data?.DecodedBytes == null || data.DecodedBytes.Length == 0) return; + variants.Add(new FuturePinballModelVariant { Role = role, Model = cache.Parse(data.DecodedBytes, pinModel.Name + "/" + role) }); + } + } + + public static class MilkShapeModelReader + { + private const string Signature = "MS3D000000"; + + public static MilkShapeModel Parse(byte[] data, string sourceName = null) + { + if (data == null) throw new ArgumentNullException(nameof(data)); + try { + using (var stream = new MemoryStream(data, false)) + using (var reader = new BinaryReader(stream, Encoding.ASCII)) { + if (ReadFixedString(reader, 10) != Signature) throw Error("Invalid MilkShape signature", sourceName, stream.Position - 10); + var version = reader.ReadInt32(); + if (version < 3 || version > 4) throw Error($"Unsupported MilkShape version {version}", sourceName, stream.Position - 4); + + var vertices = ReadVertices(reader, sourceName); + var triangles = ReadTriangles(reader, vertices.Count, sourceName); + var groups = ReadGroups(reader, triangles.Count, sourceName); + var materials = ReadMaterials(reader, sourceName); + foreach (var group in groups) { + if (group.MaterialIndex < -1 || group.MaterialIndex >= materials.Count) { + throw Error($"Group '{group.Name}' references material {group.MaterialIndex} of {materials.Count}", sourceName, stream.Position); + } + } + var trailing = reader.ReadBytes((int)(stream.Length - stream.Position)); + return new MilkShapeModel { + Version = version, + SourceSha256 = Sha256(data), + Vertices = vertices, + Triangles = triangles, + Groups = groups, + Materials = materials, + TrailingData = trailing + }; + } + } catch (EndOfStreamException exception) { + throw Error("Truncated MilkShape model", sourceName, -1, exception); + } + } + + private static List ReadVertices(BinaryReader reader, string sourceName) + { + var count = reader.ReadUInt16(); + RequireRemaining(reader, count * 15L, sourceName, "vertices"); + var result = new List(count); + for (var i = 0; i < count; i++) { + var vertex = new MilkShapeVertex { + Flags = reader.ReadByte(), + X = ReadFinite(reader, sourceName, "vertex x"), + Y = ReadFinite(reader, sourceName, "vertex y"), + Z = ReadFinite(reader, sourceName, "vertex z"), + BoneId = reader.ReadSByte(), + ReferenceCount = reader.ReadByte() + }; + result.Add(vertex); + } + return result; + } + + private static List ReadTriangles(BinaryReader reader, int vertexCount, string sourceName) + { + var count = reader.ReadUInt16(); + RequireRemaining(reader, count * 70L, sourceName, "triangles"); + var result = new List(count); + for (var i = 0; i < count; i++) { + var triangle = new MilkShapeTriangle { + Flags = reader.ReadUInt16(), + VertexIndices = new[] { reader.ReadUInt16(), reader.ReadUInt16(), reader.ReadUInt16() }, + Normals = new float[3, 3], + U = new float[3], + V = new float[3] + }; + if (triangle.VertexIndices.Any(index => index >= vertexCount)) { + throw Error($"Triangle {i} references a vertex outside 0..{vertexCount - 1}", sourceName, reader.BaseStream.Position - 6); + } + for (var corner = 0; corner < 3; corner++) + for (var component = 0; component < 3; component++) + triangle.Normals[corner, component] = ReadFinite(reader, sourceName, "normal"); + for (var corner = 0; corner < 3; corner++) triangle.U[corner] = ReadFinite(reader, sourceName, "texture u"); + for (var corner = 0; corner < 3; corner++) triangle.V[corner] = ReadFinite(reader, sourceName, "texture v"); + triangle.SmoothingGroup = reader.ReadByte(); + triangle.GroupIndex = reader.ReadByte(); + result.Add(triangle); + } + return result; + } + + private static List ReadGroups(BinaryReader reader, int triangleCount, string sourceName) + { + var count = reader.ReadUInt16(); + var result = new List(count); + for (var i = 0; i < count; i++) { + RequireRemaining(reader, 36, sourceName, "group header"); + var flags = reader.ReadByte(); + var name = ReadFixedString(reader, 32); + var triangleIndexCount = reader.ReadUInt16(); + RequireRemaining(reader, triangleIndexCount * 2L + 1, sourceName, "group triangles"); + var triangleIndices = new ushort[triangleIndexCount]; + for (var j = 0; j < triangleIndices.Length; j++) { + triangleIndices[j] = reader.ReadUInt16(); + if (triangleIndices[j] >= triangleCount) { + throw Error($"Group '{name}' references triangle {triangleIndices[j]} of {triangleCount}", sourceName, reader.BaseStream.Position - 2); + } + } + result.Add(new MilkShapeGroup { + Flags = flags, + Name = name, + TriangleIndices = triangleIndices, + MaterialIndex = reader.ReadSByte() + }); + } + return result; + } + + private static List ReadMaterials(BinaryReader reader, string sourceName) + { + var count = reader.ReadUInt16(); + RequireRemaining(reader, count * 361L, sourceName, "materials"); + var result = new List(count); + for (var i = 0; i < count; i++) { + result.Add(new MilkShapeMaterial { + Name = ReadFixedString(reader, 32), + Ambient = ReadVector4(reader, sourceName), + Diffuse = ReadVector4(reader, sourceName), + Specular = ReadVector4(reader, sourceName), + Emissive = ReadVector4(reader, sourceName), + Shininess = ReadFinite(reader, sourceName, "shininess"), + Transparency = ReadFinite(reader, sourceName, "transparency"), + Mode = reader.ReadByte(), + Texture = ReadFixedString(reader, 128), + AlphaMap = ReadFixedString(reader, 128) + }); + } + return result; + } + + private static float[] ReadVector4(BinaryReader reader, string sourceName) + { + return new[] { + ReadFinite(reader, sourceName, "material component"), ReadFinite(reader, sourceName, "material component"), + ReadFinite(reader, sourceName, "material component"), ReadFinite(reader, sourceName, "material component") + }; + } + + private static string ReadFixedString(BinaryReader reader, int length) + { + var data = reader.ReadBytes(length); + if (data.Length != length) throw new EndOfStreamException(); + var terminator = Array.IndexOf(data, (byte)0); + return Encoding.ASCII.GetString(data, 0, terminator < 0 ? data.Length : terminator); + } + + private static float ReadFinite(BinaryReader reader, string sourceName, string valueName) + { + var value = reader.ReadSingle(); + if (float.IsNaN(value) || float.IsInfinity(value)) { + throw Error($"MilkShape {valueName} is not finite", sourceName, reader.BaseStream.Position - 4); + } + return value; + } + + private static void RequireRemaining(BinaryReader reader, long bytes, string sourceName, string section) + { + if (bytes < 0 || reader.BaseStream.Position > reader.BaseStream.Length - bytes) { + throw Error($"Truncated MilkShape {section}", sourceName, reader.BaseStream.Position); + } + } + + private static FuturePinballFormatException Error(string message, string sourceName, long offset, Exception inner = null) + { + return new FuturePinballFormatException(message, sourceName, offset, inner); + } + + private static string Sha256(byte[] data) + { + using (var sha = SHA256.Create()) return BitConverter.ToString(sha.ComputeHash(data)).Replace("-", string.Empty).ToLowerInvariant(); + } + } +} diff --git a/VisualPinball.Engine/IO/FuturePinball/MilkShapeModelReader.cs.meta b/VisualPinball.Engine/IO/FuturePinball/MilkShapeModelReader.cs.meta new file mode 100644 index 000000000..2c7f7ce7f --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/MilkShapeModelReader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 651d2afc4f36472cb279fbef220da9fb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine/VPT/Table/TableLoader.cs b/VisualPinball.Engine/VPT/Table/TableLoader.cs index 5fd55964c..ac41e6008 100644 --- a/VisualPinball.Engine/VPT/Table/TableLoader.cs +++ b/VisualPinball.Engine/VPT/Table/TableLoader.cs @@ -33,17 +33,16 @@ public static class TableLoader public static FileTableContainer Load(string filename, bool loadGameItems = true) { - var cf = new CompoundFile(filename); - try { - var gameStorage = cf.RootStorage.GetStorage("GameStg"); - var gameData = gameStorage.GetStream("GameData"); + using (var cf = RootStorage.OpenRead(filename, StorageModeFlags.None)) { + var gameStorage = cf.OpenStorage("GameStg"); + var gameData = gameStorage.OpenStream("GameData"); - var fileVersion = BitConverter.ToInt32(gameStorage.GetStream("Version").GetData(), 0); - using (var stream = new MemoryStream(gameData.GetData())) + var fileVersion = BitConverter.ToInt32(gameStorage.OpenStream("Version").ReadAll(), 0); + using (var stream = new MemoryStream(gameData.ReadAll())) using (var reader = new BinaryReader(stream)) { var tableContainer = new FileTableContainer(reader); - var tableInfoStorage = LoadTableInfo(tableContainer, cf.RootStorage, gameStorage); + var tableInfoStorage = LoadTableInfo(tableContainer, cf, gameStorage); if (loadGameItems) { LoadGameItems(tableContainer, gameStorage, tableContainer.NumGameItems, "GameItem"); LoadGameItems(tableContainer, gameStorage, tableContainer.NumVpeGameItems, "VpeGameItem"); @@ -55,25 +54,19 @@ public static FileTableContainer Load(string filename, bool loadGameItems = true return tableContainer; } - - } finally { - cf.Close(); } } public static IEnumerable ReadGameItems(string fileName, int numGameItems, string storagePrefix) { var gameItemData = new byte[numGameItems][]; - var cf = new CompoundFile(fileName); - try { - var storage = cf.RootStorage.GetStorage("GameStg"); + using (var cf = RootStorage.OpenRead(fileName, StorageModeFlags.None)) { + var storage = cf.OpenStorage("GameStg"); for (var i = 0; i < numGameItems; i++) { var itemName = $"{storagePrefix}{i}"; - var itemStream = storage.GetStream(itemName); - gameItemData[i] = itemStream.GetData(); + var itemStream = storage.OpenStream(itemName); + gameItemData[i] = itemStream.ReadAll(); } - } finally { - cf.Close(); } return gameItemData; } @@ -121,16 +114,16 @@ public static void LoadGameItem(byte[] itemData, int storageIndex, out ItemType } } - private static void LoadGameItems(FileTableContainer tableContainer, CFStorage storage, int count, string storagePrefix) + private static void LoadGameItems(FileTableContainer tableContainer, Storage storage, int count, string storagePrefix) { for (var i = 0; i < count; i++) { var itemName = $"{storagePrefix}{i}"; - storage.TryGetStream(itemName, out var itemStream); + storage.TryOpenStream(itemName, out var itemStream); if (itemStream == null) { Logger.Warn("Could not find stream {0}, skipping.", itemName); continue; } - var itemData = itemStream.GetData(); + var itemData = itemStream.ReadAll(); if (itemData.Length < 4) { Logger.Warn("Skipping {itemName} because it has size of {itemDataLength}.", itemName, itemData.Length); continue; @@ -256,16 +249,16 @@ private static void LoadGameItems(FileTableContainer tableContainer, CFStorage s } } - private static void LoadTextures(FileTableContainer tableContainer, CFStorage storage, CFStorage tableInfoStorage) + private static void LoadTextures(FileTableContainer tableContainer, Storage storage, Storage tableInfoStorage) { for (var i = 0; i < tableContainer.NumTextures; i++) { var textureName = $"Image{i}"; - storage.TryGetStream(textureName, out var textureStream); + storage.TryOpenStream(textureName, out var textureStream); if (textureStream == null) { Logger.Warn("Could not find stream {0}, skipping.", textureName); continue; } - var textureData = textureStream.GetData(); + var textureData = textureStream.ReadAll(); if (textureData.Length < 4) { Logger.Warn("Skipping {itemName} because it has size of {itemDataLength}.", textureName, textureData.Length); continue; @@ -279,32 +272,32 @@ private static void LoadTextures(FileTableContainer tableContainer, CFStorage st } } - private static void LoadCollections(FileTableContainer tableContainer, CFStorage storage) + private static void LoadCollections(FileTableContainer tableContainer, Storage storage) { for (var i = 0; i < tableContainer.NumCollections; i++) { var collectionName = $"Collection{i}"; - storage.TryGetStream(collectionName, out var collectionStream); + storage.TryOpenStream(collectionName, out var collectionStream); if (collectionStream == null) { Logger.Warn("Could not find stream {0}, skipping.", collectionName); continue; } - using (var stream = new MemoryStream(collectionStream.GetData())) + using (var stream = new MemoryStream(collectionStream.ReadAll())) using (var reader = new BinaryReader(stream)) { tableContainer.Collections.Add(new CollectionData(reader, collectionName)); } } } - private static void LoadSounds(FileTableContainer tableContainer, CFStorage storage, int fileVersion) + private static void LoadSounds(FileTableContainer tableContainer, Storage storage, int fileVersion) { for (var i = 0; i < tableContainer.NumSounds; i++) { var soundName = $"Sound{i}"; - storage.TryGetStream(soundName, out var soundStream); + storage.TryOpenStream(soundName, out var soundStream); if (soundStream == null) { Logger.Warn("Could not find stream {0}, skipping.", soundName); continue; } - var soundData = soundStream.GetData(); + var soundData = soundStream.ReadAll(); using (var stream = new MemoryStream(soundData)) using (var reader = new BinaryReader(stream)) { var sound = new Sound.Sound(reader, soundName, fileVersion); @@ -313,54 +306,52 @@ private static void LoadSounds(FileTableContainer tableContainer, CFStorage stor } } - private static CFStorage LoadTableInfo(FileTableContainer tableContainer, CFStorage rootStorage, CFStorage gameStorage) + private static Storage LoadTableInfo(FileTableContainer tableContainer, Storage rootStorage, Storage gameStorage) { // first, although we can loop through entries, get them from the game storage, so we // know their order, which is important when writing back (because you know, hashing). - gameStorage.TryGetStream("CustomInfoTags", out var citStream); + gameStorage.TryOpenStream("CustomInfoTags", out var citStream); if (citStream != null) { - using (var stream = new MemoryStream(citStream.GetData())) + using (var stream = new MemoryStream(citStream.ReadAll())) using (var reader = new BinaryReader(stream)) { tableContainer.CustomInfoTags.Load(reader); } } // now actually read them in - rootStorage.TryGetStorage("TableInfo", out var tableInfoStorage); + rootStorage.TryOpenStorage("TableInfo", out var tableInfoStorage); if (tableInfoStorage == null) { Logger.Info("TableInfo storage not found, skipping."); return null; } - tableInfoStorage.VisitEntries(item => { + foreach (var item in tableInfoStorage.EnumerateEntries()) { if (item.Name == "Screenshot") { // skip those - return; + continue; } - if (item.IsStream) { - var itemStream = item as CFStream; - if (itemStream != null) { - tableContainer.TableInfo[item.Name] = BiffUtil.ParseWideString(itemStream.GetData()); - } + if (item.Type == EntryType.Stream) { + var itemStream = tableInfoStorage.OpenStream(item.Name); + tableContainer.TableInfo[item.Name] = BiffUtil.ParseWideString(itemStream.ReadAll()); } - }, false); + } return tableInfoStorage; } - private static void LoadTableMeta(FileTableContainer tableContainer, CFStorage gameStorage) + private static void LoadTableMeta(FileTableContainer tableContainer, Storage gameStorage) { // version - gameStorage.TryGetStream("Version", out var versionBytes); + gameStorage.TryOpenStream("Version", out var versionBytes); if (versionBytes != null) { - tableContainer.FileVersion = BitConverter.ToInt32(versionBytes.GetData(), 0); + tableContainer.FileVersion = BitConverter.ToInt32(versionBytes.ReadAll(), 0); } else { Logger.Info("No Version under GameStg found, skipping."); } // hash - gameStorage.TryGetStream("Version", out var hashBytes); + gameStorage.TryOpenStream("MAC", out var hashBytes); if (hashBytes != null) { - tableContainer.FileHash = hashBytes.GetData(); + tableContainer.FileHash = hashBytes.ReadAll(); } else { Logger.Info("No MAC under GameStg found, skipping."); } diff --git a/VisualPinball.Engine/VPT/Table/TableWriter.cs b/VisualPinball.Engine/VPT/Table/TableWriter.cs index 1464757be..10b37227e 100644 --- a/VisualPinball.Engine/VPT/Table/TableWriter.cs +++ b/VisualPinball.Engine/VPT/Table/TableWriter.cs @@ -16,6 +16,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Reflection; using OpenMcdf; @@ -29,8 +30,8 @@ public class TableWriter private readonly TableContainer _tableContainer; - private CompoundFile _cf; - private CFStorage _gameStorage; + private RootStorage _cf; + private Storage _gameStorage; public TableWriter(TableContainer tableContainer) { @@ -39,10 +40,10 @@ public TableWriter(TableContainer tableContainer) public void WriteTable(string fileName) { - using (var hashWriter = new HashWriter()) { - - _cf = new CompoundFile(); - _gameStorage = _cf.RootStorage.AddStorage("GameStg"); + using (var output = new MemoryStream()) + using (var hashWriter = new HashWriter()) + using (_cf = RootStorage.Create(output, OpenMcdf.Version.V3, StorageModeFlags.LeaveOpen)) { + _gameStorage = _cf.CreateStorage("GameStg"); // 1. version WriteStream(_gameStorage, "Version", BitConverter.GetBytes(VpFileFormatVersion), hashWriter); @@ -60,14 +61,14 @@ public void WriteTable(string fileName) // finally write hash WriteStream(_gameStorage, "MAC", hashWriter.Hash()); - _cf.SaveAs(fileName); - _cf.Close(); + _cf.Flush(true); + File.WriteAllBytes(fileName, output.ToArray()); } } private void WriteTableInfo(HashWriter hashWriter) { - var tableInfo = _cf.RootStorage.AddStorage("TableInfo"); + var tableInfo = _cf.CreateStorage("TableInfo"); // order for the hashing is important here. var knownTags = new[] { @@ -89,7 +90,7 @@ private void WriteTableInfo(HashWriter hashWriter) } } - private void WriteInfoTag(CFStorage tableInfo, string tag, HashWriter hashWriter) + private void WriteInfoTag(Storage tableInfo, string tag, HashWriter hashWriter) { if (!_tableContainer.TableInfo.ContainsKey(tag)) { return; @@ -139,9 +140,9 @@ private void WriteSounds() } } - private static void WriteStream(CFStorage storage, string streamName, byte[] data, HashWriter hashWriter = null) + private static void WriteStream(Storage storage, string streamName, byte[] data, HashWriter hashWriter = null) { - storage.AddStream(streamName).SetData(data); + storage.CreateStream(streamName).WriteAll(data); hashWriter?.Write(data); } diff --git a/VisualPinball.Engine/VPT/Texture.cs b/VisualPinball.Engine/VPT/Texture.cs index 96c2abe2b..31729a24e 100644 --- a/VisualPinball.Engine/VPT/Texture.cs +++ b/VisualPinball.Engine/VPT/Texture.cs @@ -20,6 +20,7 @@ using NetVips; using NLog; using OpenMcdf; +using VisualPinball.Engine.IO; using VisualPinball.Engine.Resources; namespace VisualPinball.Engine.VPT @@ -91,7 +92,7 @@ public Texture(string name) : base(new TextureData(name)) Name = name; } - public Texture(TextureData data, CFStorage tableInfoStorage) : base(data) + public Texture(TextureData data, Storage tableInfoStorage) : base(data) { if (data.Binary == null && data.Bitmap == null) { if (data.LinkId == 1) { @@ -100,14 +101,16 @@ public Texture(TextureData data, CFStorage tableInfoStorage) : base(data) return; } // load screenshot - _screenshot = tableInfoStorage.GetStream("Screenshot")?.GetData(); + _screenshot = tableInfoStorage.TryOpenStream("Screenshot", out var screenshot) + ? screenshot.ReadAll() + : null; } else { Logger.Warn($"Could not load texture {Name} from storage. No binaries and link is {data.LinkId}."); } } } - public Texture(BinaryReader reader, string itemName, CFStorage tableInfoStorage) : this(new TextureData(reader, itemName), tableInfoStorage) { } + public Texture(BinaryReader reader, string itemName, Storage tableInfoStorage) : this(new TextureData(reader, itemName), tableInfoStorage) { } private Texture(Resource res) : this(new TextureData(res), null) { } diff --git a/VisualPinball.Engine/VisualPinball.Engine.csproj b/VisualPinball.Engine/VisualPinball.Engine.csproj index 8caa6df91..437167742 100644 --- a/VisualPinball.Engine/VisualPinball.Engine.csproj +++ b/VisualPinball.Engine/VisualPinball.Engine.csproj @@ -15,7 +15,7 @@ https://visualpinball.org icon.png LICENSE - 0.0.5 + 0.0.5 @@ -31,8 +31,7 @@ - - + @@ -67,7 +66,7 @@ - + diff --git a/VisualPinball.Unity/Plugins/android-arm64-v8a/OpenMcdf.Extensions.dll.meta b/VisualPinball.Unity/Plugins/android-arm64-v8a/Microsoft.Bcl.HashCode.dll.meta similarity index 59% rename from VisualPinball.Unity/Plugins/android-arm64-v8a/OpenMcdf.Extensions.dll.meta rename to VisualPinball.Unity/Plugins/android-arm64-v8a/Microsoft.Bcl.HashCode.dll.meta index b6c1e4b3a..2eb38d6cc 100644 --- a/VisualPinball.Unity/Plugins/android-arm64-v8a/OpenMcdf.Extensions.dll.meta +++ b/VisualPinball.Unity/Plugins/android-arm64-v8a/Microsoft.Bcl.HashCode.dll.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: d4ab20a8f0e341cea6fb88d694d866b2 +guid: 0ae33a73935b448a977dd9a3329b61c0 PluginImporter: externalObjects: {} serializedVersion: 2 @@ -29,7 +29,7 @@ PluginImporter: enabled: 1 settings: {} - first: - Any: + Any: second: enabled: 0 settings: {} @@ -39,30 +39,6 @@ PluginImporter: enabled: 0 settings: DefaultValueInitialized: true - - first: - Standalone: Linux64 - second: - enabled: 0 - settings: - CPU: None - - first: - Standalone: Win - second: - enabled: 0 - settings: - CPU: None - - first: - Standalone: Win64 - second: - enabled: 0 - settings: - CPU: None - - first: - Windows Store Apps: WindowsStoreApps - second: - enabled: 0 - settings: - CPU: AnyCPU - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Plugins/ios-arm64/OpenMcdf.Extensions.dll.meta b/VisualPinball.Unity/Plugins/ios-arm64/Microsoft.Bcl.HashCode.dll.meta similarity index 52% rename from VisualPinball.Unity/Plugins/ios-arm64/OpenMcdf.Extensions.dll.meta rename to VisualPinball.Unity/Plugins/ios-arm64/Microsoft.Bcl.HashCode.dll.meta index 96608c35e..77b195d18 100644 --- a/VisualPinball.Unity/Plugins/ios-arm64/OpenMcdf.Extensions.dll.meta +++ b/VisualPinball.Unity/Plugins/ios-arm64/Microsoft.Bcl.HashCode.dll.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: a1a577bc5d844620aa2897ec85ee1478 +guid: f5fc1b0193564d02a279a460fd230d89 PluginImporter: externalObjects: {} serializedVersion: 2 @@ -23,7 +23,6 @@ PluginImporter: Exclude Win: 1 Exclude Win64: 1 Exclude iOS: 0 - Exclude tvOS: 1 - first: Editor: Editor second: @@ -32,36 +31,6 @@ PluginImporter: CPU: ARM64 DefaultValueInitialized: true OS: OSX - - first: - Standalone: Linux64 - second: - enabled: 0 - settings: - CPU: None - - first: - Standalone: OSXUniversal - second: - enabled: 0 - settings: - CPU: ARM64 - - first: - Standalone: Win - second: - enabled: 0 - settings: - CPU: None - - first: - Standalone: Win64 - second: - enabled: 0 - settings: - CPU: None - - first: - Windows Store Apps: WindowsStoreApps - second: - enabled: 0 - settings: - CPU: AnyCPU - first: iPhone: iOS second: @@ -69,8 +38,6 @@ PluginImporter: settings: AddToEmbeddedBinaries: false CPU: AnyCPU - CompileFlags: - FrameworkDependencies: - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Plugins/linux-x64/OpenMcdf.Extensions.dll.meta b/VisualPinball.Unity/Plugins/linux-x64/Microsoft.Bcl.HashCode.dll.meta similarity index 86% rename from VisualPinball.Unity/Plugins/linux-x64/OpenMcdf.Extensions.dll.meta rename to VisualPinball.Unity/Plugins/linux-x64/Microsoft.Bcl.HashCode.dll.meta index 037c053a6..e4eafb2ec 100644 --- a/VisualPinball.Unity/Plugins/linux-x64/OpenMcdf.Extensions.dll.meta +++ b/VisualPinball.Unity/Plugins/linux-x64/Microsoft.Bcl.HashCode.dll.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 07bdc33e373e49209337c4dbd74684ca +guid: 6ba9baf1276f4caa8663243b83850cb8 PluginImporter: externalObjects: {} serializedVersion: 2 @@ -23,7 +23,6 @@ PluginImporter: Exclude Win: 1 Exclude Win64: 1 Exclude iOS: 1 - Exclude tvOS: 1 - first: Editor: Editor second: @@ -38,6 +37,6 @@ PluginImporter: enabled: 1 settings: CPU: x86_64 - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Plugins/osx/OpenMcdf.Extensions.dll.meta b/VisualPinball.Unity/Plugins/osx/Microsoft.Bcl.HashCode.dll.meta similarity index 58% rename from VisualPinball.Unity/Plugins/osx/OpenMcdf.Extensions.dll.meta rename to VisualPinball.Unity/Plugins/osx/Microsoft.Bcl.HashCode.dll.meta index ce31cc3be..185549847 100644 --- a/VisualPinball.Unity/Plugins/osx/OpenMcdf.Extensions.dll.meta +++ b/VisualPinball.Unity/Plugins/osx/Microsoft.Bcl.HashCode.dll.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 7f75ee827a76428b939f672377772f95 +guid: f01a6c1fa25b4c9d8fb0d24549bc670e PluginImporter: externalObjects: {} serializedVersion: 2 @@ -23,7 +23,6 @@ PluginImporter: Exclude Win: 1 Exclude Win64: 1 Exclude iOS: 1 - Exclude tvOS: 1 - first: Editor: Editor second: @@ -32,36 +31,12 @@ PluginImporter: CPU: AnyCPU DefaultValueInitialized: true OS: OSX - - first: - Standalone: Linux64 - second: - enabled: 0 - settings: - CPU: AnyCPU - first: Standalone: OSXUniversal second: enabled: 1 settings: CPU: AnyCPU - - first: - Standalone: Win - second: - enabled: 0 - settings: - CPU: x86 - - first: - Standalone: Win64 - second: - enabled: 0 - settings: - CPU: x86_64 - - first: - Windows Store Apps: WindowsStoreApps - second: - enabled: 0 - settings: - CPU: AnyCPU - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Plugins/win-x64/Microsoft.Bcl.HashCode.dll.meta b/VisualPinball.Unity/Plugins/win-x64/Microsoft.Bcl.HashCode.dll.meta new file mode 100644 index 000000000..1f4f6d471 --- /dev/null +++ b/VisualPinball.Unity/Plugins/win-x64/Microsoft.Bcl.HashCode.dll.meta @@ -0,0 +1,42 @@ +fileFormatVersion: 2 +guid: 2c11539fc0a64272bc07ee42159ae526 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Editor: 0 + Exclude Android: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 0 + Exclude iOS: 1 + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: x86_64 + DefaultValueInitialized: true + OS: Windows + - first: + Standalone: Win64 + second: + enabled: 1 + settings: + CPU: x86_64 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Plugins/win-x64/OpenMcdf.Extensions.dll.meta b/VisualPinball.Unity/Plugins/win-x64/OpenMcdf.Extensions.dll.meta deleted file mode 100644 index 05ef11bc2..000000000 --- a/VisualPinball.Unity/Plugins/win-x64/OpenMcdf.Extensions.dll.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 647b48e562a5d5b4c815e7da4b830fe3 \ No newline at end of file diff --git a/VisualPinball.Unity/Plugins/win-x86/OpenMcdf.Extensions.dll.meta b/VisualPinball.Unity/Plugins/win-x86/Microsoft.Bcl.HashCode.dll.meta similarity index 82% rename from VisualPinball.Unity/Plugins/win-x86/OpenMcdf.Extensions.dll.meta rename to VisualPinball.Unity/Plugins/win-x86/Microsoft.Bcl.HashCode.dll.meta index 119b2a0d1..cf0d0ba47 100644 --- a/VisualPinball.Unity/Plugins/win-x86/OpenMcdf.Extensions.dll.meta +++ b/VisualPinball.Unity/Plugins/win-x86/Microsoft.Bcl.HashCode.dll.meta @@ -1,43 +1,42 @@ -fileFormatVersion: 2 -guid: 403cc3e472d84e568192c369376e46e6 -PluginImporter: - externalObjects: {} - serializedVersion: 2 - iconMap: {} - executionOrder: {} - defineConstraints: [] - isPreloaded: 0 - isOverridable: 1 - isExplicitlyReferenced: 0 - validateReferences: 1 - platformData: - - first: - : Any - second: - enabled: 0 - settings: - Exclude Editor: 0 - Exclude Android: 1 - Exclude Linux64: 1 - Exclude OSXUniversal: 1 - Exclude Win: 0 - Exclude Win64: 1 - Exclude iOS: 1 - Exclude tvOS: 1 - - first: - Editor: Editor - second: - enabled: 1 - settings: - CPU: x86 - DefaultValueInitialized: true - OS: Windows - - first: - Standalone: Win - second: - enabled: 1 - settings: - CPU: x86 - userData: - assetBundleName: - assetBundleVariant: +fileFormatVersion: 2 +guid: 057b6206892a46feb4ab619939537a5b +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Editor: 0 + Exclude Android: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 0 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: x86 + DefaultValueInitialized: true + OS: Windows + - first: + Standalone: Win + second: + enabled: 1 + settings: + CPU: x86 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Common/AssemblyInfo.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Common/AssemblyInfo.cs new file mode 100644 index 000000000..52d673487 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Common/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("VisualPinball.Unity.Test")] diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Common/AssemblyInfo.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Common/AssemblyInfo.cs.meta new file mode 100644 index 000000000..4cc42eb6b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Common/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7ddf112cc9cc431799d3b67b65ba29ed diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportEngine.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportEngine.cs new file mode 100644 index 000000000..8b799905e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportEngine.cs @@ -0,0 +1,55 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.IO; + +using NLog; +using UnityEditor; +using UnityEngine; + +using Logger = NLog.Logger; + +namespace VisualPinball.Unity.Editor +{ + public static class FptImportEngine + { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + public static FptImportResult ImportIntoScene( + string path, + string tableName = null, + GameObject parent = null, + FptImportOptions options = null) + { + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("FPT path is required.", nameof(path)); + if (!File.Exists(path)) throw new FileNotFoundException("Future Pinball table not found.", path); + try { + EditorUtility.DisplayProgressBar("Import Future Pinball Table", "Extracting source data and building static scene...", 0.35f); + var converter = new FptSceneConverter(path, tableName, options ?? new FptImportOptions()); + var result = converter.Convert(); + if (parent != null) GameObjectUtility.SetParentAndAlign(result.Root, parent); + Undo.RegisterCreatedObjectUndo(result.Root, "Import Future Pinball table"); + Selection.activeGameObject = result.Root; + Logger.Info("Imported Future Pinball table {0}: {1} meshes, {2} colliders, {3} placeholders. Bundle: {4}", + Path.GetFileName(path), result.Report.MeshAssets, result.Report.Colliders, result.Report.Placeholders, result.BundleAssetPath); + return result; + } finally { + EditorUtility.ClearProgressBar(); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportEngine.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportEngine.cs.meta new file mode 100644 index 000000000..cb903618c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportEngine.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f6a79a358b754ab1a1792a682bdce82b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportOptions.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportOptions.cs new file mode 100644 index 000000000..959134ec5 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportOptions.cs @@ -0,0 +1,35 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; + +namespace VisualPinball.Unity.Editor +{ + [Serializable] + public sealed class FptImportOptions + { + public string AssetRoot = "Assets/Tables"; + public string[] LibrarySearchRoots = Array.Empty(); + public bool CopyOriginalTable = true; + public bool OverwriteChangedSourceFiles; + public bool ReuseGeneratedAssets = true; + public bool ReplaceExistingSceneRoot = true; + public bool ImportPrimaryModels = true; + public bool GenerateColliders = true; + public bool EnablePerPolygonCollision = true; + public bool GenerateRenderMeshFallbackColliders; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportOptions.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportOptions.cs.meta new file mode 100644 index 000000000..ae83bc544 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportOptions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b26c737a454343cb974d6524af58ffbf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs new file mode 100644 index 000000000..2562970d1 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs @@ -0,0 +1,178 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +using VisualPinball.Engine.IO.FuturePinball; + +namespace VisualPinball.Unity.Editor +{ + public sealed class FptImportReport + { + public string SourceFile { get; internal set; } + public string SourceSha256 { get; internal set; } + public uint? FileVersion { get; internal set; } + public int Elements { get; internal set; } + public int ProceduralElements { get; internal set; } + public int NativeElements { get; internal set; } + public int ModelInstances { get; internal set; } + public int Placeholders { get; internal set; } + public int SkippedUnresolvedSupport { get; internal set; } + public int MeshAssets { get; internal set; } + public int MaterialAssets { get; internal set; } + public int Colliders { get; internal set; } + public int ReusedAssets { get; internal set; } + public int UnresolvedResources { get; internal set; } + public long ElapsedMilliseconds { get; internal set; } + public List Warnings { get; } = new List(); + public List Backlog { get; } = new List(); + + internal void Write(string outputDirectory, FuturePinballExtractionManifest manifest) + { + Directory.CreateDirectory(outputDirectory); + File.WriteAllText(Path.Combine(outputDirectory, "import-report.json"), Json(manifest), new UTF8Encoding(false)); + File.WriteAllText(Path.Combine(outputDirectory, "import-report.md"), Markdown(manifest), new UTF8Encoding(false)); + File.WriteAllText(Path.Combine(outputDirectory, "recreation-backlog.json"), BacklogJson(), new UTF8Encoding(false)); + File.WriteAllText(Path.Combine(outputDirectory, "recreation-backlog.md"), BacklogMarkdown(), new UTF8Encoding(false)); + } + + private string Json(FuturePinballExtractionManifest manifest) + { + var json = new StringBuilder(); + json.AppendLine("{"); + Property(json, "sourceFile", SourceFile, true); + Property(json, "sourceSha256", SourceSha256, true); + Property(json, "fileVersion", FileVersion?.ToString(CultureInfo.InvariantCulture) ?? "null", true, false); + Property(json, "manifestSchemaVersion", manifest.SchemaVersion.ToString(CultureInfo.InvariantCulture), true, false); + Property(json, "elements", Elements.ToString(CultureInfo.InvariantCulture), true, false); + Property(json, "proceduralElements", ProceduralElements.ToString(CultureInfo.InvariantCulture), true, false); + Property(json, "nativeElements", NativeElements.ToString(CultureInfo.InvariantCulture), true, false); + Property(json, "modelInstances", ModelInstances.ToString(CultureInfo.InvariantCulture), true, false); + Property(json, "placeholders", Placeholders.ToString(CultureInfo.InvariantCulture), true, false); + Property(json, "skippedUnresolvedSupport", SkippedUnresolvedSupport.ToString(CultureInfo.InvariantCulture), true, false); + Property(json, "meshAssets", MeshAssets.ToString(CultureInfo.InvariantCulture), true, false); + Property(json, "materialAssets", MaterialAssets.ToString(CultureInfo.InvariantCulture), true, false); + Property(json, "colliders", Colliders.ToString(CultureInfo.InvariantCulture), true, false); + Property(json, "reusedAssets", ReusedAssets.ToString(CultureInfo.InvariantCulture), true, false); + Property(json, "unresolvedResources", UnresolvedResources.ToString(CultureInfo.InvariantCulture), true, false); + Property(json, "elapsedMilliseconds", ElapsedMilliseconds.ToString(CultureInfo.InvariantCulture), true, false); + json.Append(" \"warnings\": ["); + json.Append(string.Join(", ", Warnings.Select(warning => $"\"{Escape(warning)}\""))); + json.AppendLine("]"); + json.AppendLine("}"); + return json.ToString(); + } + + private string Markdown(FuturePinballExtractionManifest manifest) + { + var text = new StringBuilder(); + text.AppendLine("# Future Pinball Import Report").AppendLine(); + text.AppendLine($"- Source: `{SourceFile}`"); + text.AppendLine($"- SHA-256: `{SourceSha256}`"); + text.AppendLine($"- FPT version: `{FileVersion?.ToString() ?? "unknown"}`"); + text.AppendLine($"- Manifest schema: `{manifest.SchemaVersion}`"); + text.AppendLine($"- Elements: {Elements}"); + text.AppendLine($"- Recreation passes (overlapping): {NativeElements} native VPE counterparts, {ProceduralElements} procedural visuals, {ModelInstances} model instances, {Placeholders} placeholders"); + text.AppendLine($"- Skipped unresolved-support elements: {SkippedUnresolvedSupport}"); + text.AppendLine($"- Assets: {MeshAssets} meshes, {MaterialAssets} materials, {ReusedAssets} reused"); + text.AppendLine($"- VPE colliders: {Colliders}"); + text.AppendLine($"- Unresolved resources: {UnresolvedResources}"); + text.AppendLine($"- Elapsed: {ElapsedMilliseconds} ms"); + text.AppendLine().AppendLine("## Extracted media").AppendLine(); + foreach (var group in manifest.Resources.GroupBy(resource => resource.Category).OrderBy(group => group.Key)) { + text.AppendLine($"- {group.Key}: {group.Count()} resource(s), {group.Sum(resource => resource.Files.Sum(file => (long)file.Bytes))} bytes"); + } + if (Warnings.Count > 0) { + text.AppendLine().AppendLine("## Warnings").AppendLine(); + foreach (var warning in Warnings) text.AppendLine($"- {warning}"); + } + return text.ToString(); + } + + private string BacklogJson() + { + var json = new StringBuilder(); + json.AppendLine("{").AppendLine(" \"schemaVersion\": 1,").AppendLine(" \"items\": ["); + for (var i = 0; i < Backlog.Count; i++) { + var item = Backlog[i]; + json.AppendLine(" {"); + json.AppendLine($" \"sourceIndex\": {item.SourceIndex},"); + json.AppendLine($" \"name\": \"{Escape(item.Name)}\","); + json.AppendLine($" \"elementType\": \"{Escape(item.ElementType)}\","); + json.AppendLine($" \"currentOutcome\": \"{Escape(item.CurrentOutcome)}\","); + json.AppendLine($" \"suggestedCapability\": \"{Escape(item.SuggestedCapability)}\","); + json.AppendLine($" \"sourceStream\": \"{Escape(item.SourceStream)}\""); + json.Append(" }").AppendLine(i + 1 < Backlog.Count ? "," : string.Empty); + } + json.AppendLine(" ]").AppendLine("}"); + return json.ToString(); + } + + private string BacklogMarkdown() + { + var text = new StringBuilder(); + text.AppendLine("# Future Pinball Recreation Backlog").AppendLine(); + text.AppendLine("Every item below remains traceable to the lossless source bundle and embedded table script.").AppendLine(); + foreach (var item in Backlog.OrderBy(item => item.SourceIndex)) { + text.AppendLine($"- **{item.Name}** (`{item.ElementType}`, `{item.SourceStream}`): {item.CurrentOutcome}. Suggested: {item.SuggestedCapability}."); + } + return text.ToString(); + } + + private static void Property(StringBuilder json, string name, string value, bool comma, bool quote = true) + { + json.Append(" \"").Append(Escape(name)).Append("\": "); + json.Append(quote ? $"\"{Escape(value)}\"" : value); + json.AppendLine(comma ? "," : string.Empty); + } + + private static string Escape(string value) + { + var escaped = new StringBuilder(); + foreach (var character in value ?? string.Empty) { + switch (character) { + case '"': escaped.Append("\\\""); break; + case '\\': escaped.Append("\\\\"); break; + case '\b': escaped.Append("\\b"); break; + case '\f': escaped.Append("\\f"); break; + case '\n': escaped.Append("\\n"); break; + case '\r': escaped.Append("\\r"); break; + case '\t': escaped.Append("\\t"); break; + default: + if (character < 0x20) escaped.Append("\\u").Append(((int)character).ToString("x4")); + else escaped.Append(character); + break; + } + } + return escaped.ToString(); + } + } + + public sealed class FptRecreationBacklogItem + { + public int SourceIndex { get; internal set; } + public string Name { get; internal set; } + public string ElementType { get; internal set; } + public string CurrentOutcome { get; internal set; } + public string SuggestedCapability { get; internal set; } + public string SourceStream { get; internal set; } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs.meta new file mode 100644 index 000000000..eab741084 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 496fc05d78fb4397b11ce95010bd3858 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportWizard.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportWizard.cs new file mode 100644 index 000000000..deb37af30 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportWizard.cs @@ -0,0 +1,104 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.IO; +using System.Linq; + +using UnityEditor; +using UnityEngine; + +namespace VisualPinball.Unity.Editor +{ + public sealed class FptImportWizard : EditorWindow + { + private string _path; + private string _tableName; + private string _libraryRoots = string.Empty; + private Vector2 _scroll; + private readonly FptImportOptions _options = new FptImportOptions(); + + public static void Open(string path = null) + { + var window = GetWindow(true, "Future Pinball Import", true); + window.minSize = new Vector2(520f, 520f); + if (!string.IsNullOrWhiteSpace(path)) { + window._path = path; + window._tableName = Path.GetFileNameWithoutExtension(path); + } + window.Show(); + } + + private void OnGUI() + { + _scroll = EditorGUILayout.BeginScrollView(_scroll); + EditorGUILayout.LabelField("Lossless Future Pinball Import", EditorStyles.boldLabel); + EditorGUILayout.HelpBox("The original FPT and every media/model/script payload are preserved. Scene conversion creates static meshes, materials, VPE-native primitive colliders, and placeholders; it does not execute Future Pinball scripts.", MessageType.Info); + + EditorGUILayout.BeginHorizontal(); + _path = EditorGUILayout.TextField("FPT File", _path); + if (GUILayout.Button("Browse", GUILayout.Width(80f))) { + var selected = EditorUtility.OpenFilePanel("Future Pinball Table", Path.GetDirectoryName(_path), "fpt"); + if (!string.IsNullOrEmpty(selected)) { + _path = selected; + if (string.IsNullOrWhiteSpace(_tableName)) _tableName = Path.GetFileNameWithoutExtension(selected); + } + } + EditorGUILayout.EndHorizontal(); + _tableName = EditorGUILayout.TextField("Table Name", _tableName); + _options.AssetRoot = EditorGUILayout.TextField("Asset Root", _options.AssetRoot); + _libraryRoots = EditorGUILayout.TextField("FPL Search Roots", _libraryRoots); + EditorGUILayout.HelpBox("Separate multiple library roots with semicolons. Adjacent FPL files are searched first.", MessageType.None); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Source bundle", EditorStyles.boldLabel); + _options.CopyOriginalTable = EditorGUILayout.Toggle("Copy Original FPT", _options.CopyOriginalTable); + _options.OverwriteChangedSourceFiles = EditorGUILayout.Toggle("Overwrite Changed Files", _options.OverwriteChangedSourceFiles); + _options.ReuseGeneratedAssets = EditorGUILayout.Toggle("Reuse Generated Assets", _options.ReuseGeneratedAssets); + _options.ReplaceExistingSceneRoot = EditorGUILayout.Toggle("Replace Existing Root", _options.ReplaceExistingSceneRoot); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Recreation", EditorStyles.boldLabel); + _options.ImportPrimaryModels = EditorGUILayout.Toggle("Import Primary Models", _options.ImportPrimaryModels); + _options.GenerateColliders = EditorGUILayout.Toggle("Generate VPE Colliders", _options.GenerateColliders); + using (new EditorGUI.DisabledScope(!_options.GenerateColliders)) { + _options.EnablePerPolygonCollision = EditorGUILayout.Toggle("Per-Polygon Collision", _options.EnablePerPolygonCollision); + _options.GenerateRenderMeshFallbackColliders = EditorGUILayout.Toggle("Render-Mesh Fallback", _options.GenerateRenderMeshFallbackColliders); + } + + GUILayout.FlexibleSpace(); + using (new EditorGUI.DisabledScope(string.IsNullOrWhiteSpace(_path) || !File.Exists(_path))) { + if (GUILayout.Button("Import", GUILayout.Height(34f))) Import(); + } + EditorGUILayout.EndScrollView(); + } + + private void Import() + { + _options.LibrarySearchRoots = (_libraryRoots ?? string.Empty).Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(root => root.Trim()).Where(root => root.Length > 0).ToArray(); + try { + var result = FptImportEngine.ImportIntoScene(_path, _tableName, options: _options); + EditorUtility.DisplayDialog("Future Pinball Import", + $"Imported {result.Report.Elements} elements.\nMeshes: {result.Report.MeshAssets}\nColliders: {result.Report.Colliders}\nPlaceholders: {result.Report.Placeholders}\n\nSource bundle and reports:\n{result.BundleAssetPath}", "OK"); + Close(); + } catch (Exception exception) { + UnityEngine.Debug.LogException(exception); + EditorUtility.DisplayDialog("Future Pinball Import Failed", exception.Message, "OK"); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportWizard.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportWizard.cs.meta new file mode 100644 index 000000000..1e137af39 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportWizard.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 864b5fa55f7d4369ac07313a95f3a749 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs new file mode 100644 index 000000000..db3afab22 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs @@ -0,0 +1,1022 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +using UnityEditor; +using UnityEngine; + +using VisualPinball.Engine.IO.FuturePinball; +using VisualPinball.Engine.VPT; +using VisualPinball.Engine.VPT.Bumper; +using VisualPinball.Engine.VPT.Collection; +using VisualPinball.Engine.VPT.Flipper; +using VisualPinball.Engine.VPT.Gate; +using VisualPinball.Engine.VPT.HitTarget; +using VisualPinball.Engine.VPT.Kicker; +using VisualPinball.Engine.VPT.MetalWireGuide; +using VisualPinball.Engine.VPT.Plunger; +using VisualPinball.Engine.VPT.Ramp; +using VisualPinball.Engine.VPT.Rubber; +using VisualPinball.Engine.VPT.Spinner; +using VisualPinball.Engine.VPT.Surface; +using VisualPinball.Engine.VPT.Table; +using VisualPinball.Engine.VPT.Trigger; +using VisualPinball.Unity.Simulation; + +using EngineMesh = VisualPinball.Engine.VPT.Mesh; +using EngineMaterial = VisualPinball.Engine.VPT.Material; +using EngineSound = VisualPinball.Engine.VPT.Sound.Sound; +using EngineTexture = VisualPinball.Engine.VPT.Texture; +using EngineLight = VisualPinball.Engine.VPT.Light.Light; +using UnityMaterial = UnityEngine.Material; +using UnityMesh = UnityEngine.Mesh; + +namespace VisualPinball.Unity.Editor +{ + internal sealed class FptSceneConverter : IMaterialProvider, ITextureProvider + { + private const uint NameTag = 0xA4F4D1D7; + private const uint ModelTag = 0x9DFDC3D8; + private const uint SurfaceTag = 0xA3EFBDD2; + private const uint TextureTag = 0xA300C5DC; + private const uint ColorTag = 0x97F5C3E2; + private const uint RotationTag = 0xA8EDC3D3; + private const uint HeightTag = 0xA2F8CDDD; + private const uint SurfaceTopHeightTag = 0x99F2BEDD; + private const uint TableWidthTag = 0xA5F8BBD1; + private const uint TableLengthTag = 0x9BFCC6D1; + private const uint FrontGlassHeightTag = 0xA1FACCD1; + private const uint RearGlassHeightTag = 0xA1FAC0D1; + private const uint SlopeTag = 0x9AF5BFD1; + + private readonly string _sourcePath; + private readonly string _tableName; + private readonly FptImportOptions _options; + private readonly Dictionary _textures = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _textureAliases = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _textureAliasSources = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _ambiguousTextureAliases = new HashSet(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _meshes = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _materials = new Dictionary(); + private readonly Dictionary _nativeMaterials = new Dictionary(StringComparer.Ordinal); + private readonly HashSet _reportedTessellatedColliderKinds = new HashSet(); + private readonly HashSet _reportedNativeDefaults = new HashSet(); + private readonly HashSet _reportedSurfacePlacementIssues = new HashSet(StringComparer.Ordinal); + private readonly HashSet _skippedUnresolvedSupportElements = new HashSet(); + private readonly Dictionary _nativeObjects = new Dictionary(); + private readonly Dictionary _nativePrefabs = new Dictionary(); + private readonly Dictionary _spinningDisks = new Dictionary(); + private readonly FptImportReport _report = new FptImportReport(); + private string _tableAssetRoot; + private string _bundleAssetRoot; + private string _meshAssetRoot; + private string _materialAssetRoot; + private FuturePinballTable _table; + private FuturePinballExtractionManifest _manifest; + private Table _nativeTable; + private Dictionary _surfacesByName; + + public FptSceneConverter(string sourcePath, string tableName, FptImportOptions options) + { + _sourcePath = Path.GetFullPath(sourcePath ?? throw new ArgumentNullException(nameof(sourcePath))); + _tableName = string.IsNullOrWhiteSpace(tableName) ? Path.GetFileNameWithoutExtension(sourcePath) : tableName; + _options = options ?? new FptImportOptions(); + } + + public FptImportResult Convert() + { + var timer = Stopwatch.StartNew(); + GameObject root = null; + ValidateAssetRoot(); + _tableAssetRoot = $"{_options.AssetRoot.TrimEnd('/', '\\')}/{SafeName(_tableName)}"; + _bundleAssetRoot = $"{_tableAssetRoot}/Source/FuturePinball"; + _meshAssetRoot = $"{_tableAssetRoot}/Meshes/FuturePinball"; + _materialAssetRoot = $"{_tableAssetRoot}/Materials/FuturePinball"; + EnsureAssetFolder(_bundleAssetRoot); + EnsureAssetFolder(_meshAssetRoot); + EnsureAssetFolder(_materialAssetRoot); + + _table = FuturePinballTableReader.Load(_sourcePath); + _manifest = FuturePinballExtractor.Extract(_sourcePath, PhysicalPath(_bundleAssetRoot), new FuturePinballExtractionOptions { + CopyOriginalTable = _options.CopyOriginalTable, + OverwriteChangedFiles = _options.OverwriteChangedSourceFiles, + LibrarySearchRoots = _options.LibrarySearchRoots ?? Array.Empty() + }); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + LoadTextures(); + + var rootName = _tableName + " (Future Pinball)"; + var existing = _options.ReplaceExistingSceneRoot ? GameObject.Find(rootName) : null; + try { + root = new GameObject(rootName); + var playfield = CreateVpeHierarchy(root); + var rootSource = root.AddComponent(); + rootSource.SourceFile = Path.GetFileName(_sourcePath); + rootSource.SourceHash = _manifest.SourceSha256; + rootSource.ImportOutcome = "lossless-source-bundle-and-vpe-native-scene"; + + var proceduralRoot = Child(playfield, "Procedural Geometry"); + var nativeRoot = Child(playfield, "Native Elements"); + var modelRoot = Child(playfield, "Model Instances"); + var placeholderRoot = Child(playfield, "Preserved Placeholders"); + var handled = new HashSet(); + CreateNativeElements(nativeRoot, handled); + CreateProceduralElements(proceduralRoot, handled); + CreateModelInstances(modelRoot, handled); + CreatePlaceholders(placeholderRoot, handled); + PersistNativeElements(); + + _report.SourceFile = Path.GetFileName(_sourcePath); + _report.SourceSha256 = _manifest.SourceSha256; + _report.FileVersion = _manifest.FileVersion; + _report.Elements = _table.Elements.Count; + _report.UnresolvedResources = _manifest.Counts.UnresolvedLinkedResources; + _report.Warnings.AddRange(_manifest.Issues); + if (_options.GenerateColliders) { + _report.Warnings.Add("Generated collision uses native VPE collider components; no Unity/PhysX colliders are created."); + } + _report.ElapsedMilliseconds = timer.ElapsedMilliseconds; + AssetDatabase.SaveAssets(); + _report.Write(PhysicalPath(_bundleAssetRoot), _manifest); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + if (existing != null) Undo.DestroyObjectImmediate(existing); + return new FptImportResult(root, _manifest, _report, _bundleAssetRoot); + } catch { + if (root != null) UnityEngine.Object.DestroyImmediate(root); + throw; + } + } + + private void CreateNativeElements(GameObject parent, ISet handled) + { + foreach (var element in _table.Elements) { + if (!FuturePinballNativeItemConverter.TryConvert(element, out var converted)) { + if (element.ElementType == FuturePinballElementType.SpinningDisk) CreateSpinningDisk(parent, element, handled); + continue; + } + var sourcePosition = FuturePinballElementGeometry.SurfaceProbePosition(element); + if (!TryResolveSurfaceHeight(element, sourcePosition, out var supportHeight)) { + continue; + } + var prefab = InstantiateNativePrefab(converted.Item); + prefab.GameObject.transform.SetParent(parent.transform, false); + prefab.SetData(); + SetReferencedData(prefab); + if (supportHeight != 0f) { + prefab.GameObject.transform.localPosition += Vector3.up * FuturePinballCoordinateConverter.ToWorld(0f, 0f, supportHeight).Y; + } + var nativeColliders = prefab.GameObject.GetComponentsInChildren(true) + .Where(component => component is ICollidableComponent).ToArray(); + foreach (var nativeCollider in nativeColliders) { + if (!_options.GenerateColliders) nativeCollider.enabled = false; + else if (nativeCollider.enabled) _report.Colliders++; + } + AddSource(prefab.GameObject, element, "native-vpe-counterpart"); + _nativeObjects[element] = prefab.GameObject; + _nativePrefabs[element] = prefab; + handled.Add(element); + _report.NativeElements++; + if (converted.DefaultedParameters.Count > 0 && element.ElementType.HasValue + && _reportedNativeDefaults.Add(element.ElementType.Value)) { + _report.Warnings.Add($"{element.ElementType.Value} maps to its native VPE counterpart; {string.Join(", ", converted.DefaultedParameters)} retain VPE defaults or remain unrecreated because FP units or behavior are not equivalent."); + } + } + } + + private void CreateSpinningDisk(GameObject parent, FuturePinballSourceStream element, ISet handled) + { + if (!TryGetPlacement(element, out var position, out var rotation)) return; + var go = Child(parent, ElementName(element)); + go.transform.localPosition = position; + go.transform.localRotation = rotation; + var component = go.AddComponent(); + AddSource(go, element, "native-vpe-counterpart"); + _spinningDisks[element] = component; + handled.Add(element); + _report.NativeElements++; + if (_reportedNativeDefaults.Add(FuturePinballElementType.SpinningDisk)) { + _report.Warnings.Add("SpinningDisk maps to VPE's turntable; motor power has no equivalent VPE scale, and the Loose/Normal/Tight damping presets need calibration, so both retain VPE defaults."); + } + } + + private void SetReferencedData(IVpxPrefab prefab) + { + var previousSkipSurfaceParenting = ImportContext.SkipSurfaceParenting; + try { + // FP support heights are resolved explicitly, including duplicate names and nested guide walls. + ImportContext.SkipSurfaceParenting = true; + prefab.SetReferencedData(_nativeTable, this, this, null); + } finally { + ImportContext.SkipSurfaceParenting = previousSkipSurfaceParenting; + } + } + + private void PersistNativeElements() + { + foreach (var prefab in _nativePrefabs.Values) { + prefab.PersistData(); + foreach (var transform in prefab.GameObject.GetComponentsInChildren(true)) { + EditorUtility.SetDirty(transform.gameObject); + PrefabUtility.RecordPrefabInstancePropertyModifications(transform.gameObject); + foreach (var component in transform.GetComponents()) { + if (!component) continue; + EditorUtility.SetDirty(component); + PrefabUtility.RecordPrefabInstancePropertyModifications(component); + } + } + prefab.FreeBinaryData(); + } + } + + private IVpxPrefab InstantiateNativePrefab(IItem item) + { + switch (item) { + case Bumper bumper: return bumper.InstantiatePrefab(); + case Flipper flipper: return flipper.InstantiatePrefab(); + case Gate gate: return gate.InstantiatePrefab(); + case HitTarget target: return target.InstantiatePrefab(); + case Kicker kicker: return kicker.InstantiatePrefab(); + case EngineLight light: return light.InstantiatePrefab(_nativeTable); + case MetalWireGuide wireGuide: return wireGuide.InstantiatePrefab(); + case Plunger plunger: return plunger.InstantiatePrefab(); + case Ramp ramp: return ramp.InstantiatePrefab(); + case Rubber rubber: return rubber.InstantiatePrefab(); + case Spinner spinner: return spinner.InstantiatePrefab(); + case Surface surface: return surface.InstantiatePrefab(); + case Trigger trigger: return trigger.InstantiatePrefab(); + default: throw new InvalidOperationException($"No VPE prefab is registered for {item.GetType().Name}."); + } + } + + private void CreateProceduralElements(GameObject parent, ISet handled) + { + var sources = _table.Elements.Where(item => item.SourceIndex.HasValue) + .GroupBy(item => item.SourceIndex.Value) + .ToDictionary(group => group.Key, group => group.First()); + foreach (var element in FuturePinballProceduralMeshBuilder.Build(_table)) { + if (!sources.TryGetValue(element.SourceIndex, out var source)) { + _report.Warnings.Add($"Procedural element '{element.Name}' has no matching source stream and was skipped."); + continue; + } + var sourcePosition = FuturePinballElementGeometry.SurfaceProbePosition(source); + if (!TryResolveSurfaceHeight(source, sourcePosition, out var supportHeight)) { + continue; + } + var go = Child(parent, element.Name); + if (supportHeight != 0f) { + go.transform.localPosition += Vector3.up * FuturePinballCoordinateConverter.ToWorld(0f, 0f, supportHeight).Y; + } + var hasNativeCounterpart = _nativeObjects.TryGetValue(source, out var nativeObject); + AddSource(go, source, hasNativeCounterpart ? "native-procedural-visual" : "native-procedural-static"); + if (hasNativeCounterpart) DisableRenderers(nativeObject); + for (var i = 0; i < element.Meshes.Count; i++) { + var worldMesh = element.Meshes[i].Clone().TransformToWorld(); + CreateRenderer(go, source, worldMesh, element.Material, i, true, !hasNativeCounterpart && element.IsCollidable); + } + handled.Add(source); + _report.ProceduralElements++; + } + } + + private void CreateModelInstances(GameObject parent, ISet handled) + { + var configuredSpinningDisks = new HashSet(); + if (_options.ImportPrimaryModels) { + var referenced = new HashSet(_table.Elements.Select(element => FuturePinballElementGeometry.Text(element, ModelTag)) + .Where(name => !string.IsNullOrWhiteSpace(name)), StringComparer.OrdinalIgnoreCase); + var models = LoadModels(referenced); + foreach (var element in _table.Elements) { + var modelName = FuturePinballElementGeometry.Text(element, ModelTag); + if (string.IsNullOrWhiteSpace(modelName) || !models.TryGetValue(modelName, out var model)) continue; + if (!TryGetPlacement(element, out var position, out var rotation)) { + handled.Add(element); + continue; + } + var name = ElementName(element); + var hasNativeCounterpart = _nativeObjects.ContainsKey(element); + var isSpinningDisk = _spinningDisks.TryGetValue(element, out var turntable); + var go = Child(parent, hasNativeCounterpart || isSpinningDisk ? name + " (Source Model)" : name); + AddSource(go, element, isSpinningDisk + ? "source-model-drives-native-turntable-visual" + : hasNativeCounterpart ? "source-model-preserved-for-native-counterpart" : "static-model"); + GameObject visualRoot; + if (isSpinningDisk) { + visualRoot = CreateSpinningDiskVisualRoot(turntable, go); + } else { + go.transform.localPosition = position; + go.transform.localRotation = rotation; + visualRoot = go; + } + var elementMaterial = FuturePinballMaterialConverter.FromElement(element, TextureTag, ColorTag); + var groups = model.Primary.CreateMeshes(); + for (var i = 0; i < groups.Count; i++) { + var worldMesh = FuturePinballCoordinateConverter.ModelMeshToWorld(groups[i].Mesh); + var material = groups[i].MaterialIndex >= 0 && groups[i].MaterialIndex < model.Primary.Materials.Count + ? FuturePinballMaterialConverter.FromMilkShape(model.Primary.Materials[groups[i].MaterialIndex]) + : elementMaterial; + CreateRenderer(visualRoot, element, worldMesh, material, i, false, false, $"model-{model.Primary.SourceSha256}-{i}"); + } + if (isSpinningDisk && ConfigureSpinningDiskVisual(turntable, visualRoot)) configuredSpinningDisks.Add(element); + if (_options.GenerateColliders && !hasNativeCounterpart) CreateModelColliders(go, element, model); + if (hasNativeCounterpart) go.SetActive(false); + handled.Add(element); + _report.ModelInstances++; + } + } + foreach (var element in _spinningDisks.Keys.Where(element => !configuredSpinningDisks.Contains(element))) { + AddSpinningDiskBacklog(element); + } + } + + internal static GameObject CreateSpinningDiskVisualRoot(TurntableComponent component, GameObject modelRoot) + { + modelRoot.transform.SetParent(component.transform, false); + modelRoot.transform.localPosition = Vector3.zero; + modelRoot.transform.localRotation = Quaternion.identity; + return Child(modelRoot, "Rotating Visual"); + } + + internal static bool ConfigureSpinningDiskVisual(TurntableComponent component, GameObject visual) + { + component.RotationTarget = visual.transform; + var radiusSquared = 0f; + // FP spinning-disk primary models are treated as the playable disc; auxiliary geometry can overestimate this radius. + foreach (var filter in visual.GetComponentsInChildren(true)) { + if (filter.sharedMesh == null) continue; + foreach (var vertex in filter.sharedMesh.vertices) { + var local = visual.transform.InverseTransformPoint(filter.transform.TransformPoint(vertex)); + radiusSquared = Mathf.Max(radiusSquared, local.x * local.x + local.z * local.z); + } + } + var radius = Mathf.Sqrt(radiusSquared) * 1000f; + var validRadius = !float.IsNaN(radius) && !float.IsInfinity(radius) && radius > 0f; + if (validRadius) component.Radius = radius; + EditorUtility.SetDirty(component); + return validRadius; + } + + private void CreatePlaceholders(GameObject parent, ISet handled) + { + foreach (var element in _table.Elements.Where(element => !handled.Contains(element))) { + if (!TryGetPlacement(element, out var position, out var rotation)) continue; + var go = Child(parent, ElementName(element)); + AddSource(go, element, "preserved-placeholder"); + go.transform.localPosition = position; + go.transform.localRotation = rotation; + _report.Placeholders++; + _report.Backlog.Add(new FptRecreationBacklogItem { + SourceIndex = element.SourceIndex ?? -1, + Name = ElementName(element), + ElementType = element.ElementType?.ToString() ?? $"Unknown({element.ElementTypeId})", + CurrentOutcome = "Source data preserved; no recreated visual behavior", + SuggestedCapability = SuggestedCapability(element.ElementType), + SourceStream = element.Name + }); + } + } + + private Dictionary LoadModels(ISet referenced) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var cache = new MilkShapeModelCache(); + foreach (var stream in _table.PinModels) { + var name = stream.Text(NameTag) ?? stream.Name; + if (!referenced.Contains(name)) continue; + try { + var source = stream; + var assets = FuturePinballModelAssetReader.ReadEmbedded(source, cache); + if (assets.Variants.Count == 0) { + var resource = _manifest.Resources.FirstOrDefault(item => item.Category == "models" && item.SourceIndex == stream.SourceIndex); + var linked = resource?.Files.FirstOrDefault(file => file.Role == "linked-model"); + if (linked != null) { + var fpm = FuturePinballModelReader.Load(PhysicalPath($"{_bundleAssetRoot}/{linked.Path}")); + source = fpm.ModelData; + assets = FuturePinballModelAssetReader.ReadEmbedded(source, cache); + } + } + var primary = assets.Variants.FirstOrDefault(variant => variant.Role == "primary_model_data")?.Model; + if (primary != null) result[name] = new LoadedModel(primary, source); + else _report.Warnings.Add($"Model '{name}' has no decoded primary mesh."); + } catch (Exception exception) { + _report.Warnings.Add($"Could not decode model '{name}': {exception.Message}"); + } + } + return result; + } + + private void CreateModelColliders(GameObject parent, FuturePinballSourceStream element, LoadedModel model) + { + var descriptions = FuturePinballColliderBuilder.FromModel(model.Source, model.Primary, new FuturePinballColliderOptions { + EnableAnalyticShapes = true, + EnablePerPolygonCollision = _options.EnablePerPolygonCollision, + GenerateRenderMeshFallback = _options.GenerateRenderMeshFallbackColliders + }); + var index = 0; + foreach (var description in descriptions) { + if (description.Status != FuturePinballColliderStatus.Generated) { + AddColliderBacklog(element, description.Reason ?? $"{description.Kind} collider was not generated"); + continue; + } + var colliderIndex = index++; + var colliderMesh = FuturePinballColliderMeshBuilder.Build(description); + if (colliderMesh?.IsSet != true || colliderMesh.Indices.Length == 0) { + AddColliderBacklog(element, $"{description.Kind} has no VPE-compatible triangle mesh"); + continue; + } + var go = Child(parent, $"VPE Collider {colliderIndex} ({description.Kind})"); + go.transform.localPosition = new Vector3(description.Center.X, description.Center.Y, description.Center.Z); + var unityMesh = GetOrCreateMesh(colliderMesh, + $"vpe-collider-{model.Primary.SourceSha256}-{colliderIndex}-{description.Kind}"); + AddVpePrimitiveCollider(go, unityMesh, description.GenerateHitEvent, false); + if (FuturePinballColliderMeshBuilder.IsTessellatedApproximation(description.Kind) + && _reportedTessellatedColliderKinds.Add(description.Kind)) { + _report.Warnings.Add($"{description.Kind} collision is tessellated into a VPE primitive mesh; curved-source geometry is approximate."); + } + if (description.GenerateHitEvent && description.EventId != 0) { + _report.Warnings.Add($"{ElementName(element)} collider event {description.EventId} maps to a generic VPE primitive hit event; Future Pinball script dispatch remains unimplemented."); + } + _report.Colliders++; + } + } + + private void CreateRenderer( + GameObject parent, + FuturePinballSourceStream source, + EngineMesh mesh, + FuturePinballMaterialDescription material, + int part, + bool procedural, + bool collidable, + string meshAssetKey = null) + { + if (mesh?.IsSet != true || mesh.Vertices.Length == 0 || mesh.Indices.Length == 0) return; + var go = Child(parent, $"{SafeName(mesh.Name ?? "Mesh")} {part}"); + var unityMesh = GetOrCreateMesh(mesh, meshAssetKey ?? $"{source.SourceIndex:D5}-{SafeName(ElementName(source))}-{part}"); + go.AddComponent().sharedMesh = unityMesh; + go.AddComponent().sharedMaterial = GetOrCreateMaterial(material); + if (_options.GenerateColliders && procedural && collidable) { + AddVpePrimitiveCollider(go, unityMesh, false, true); + _report.Colliders++; + } + } + + private static void AddVpePrimitiveCollider(GameObject go, UnityMesh mesh, bool hitEvent, bool visible) + { + var meshFilter = go.GetComponent() ?? go.AddComponent(); + meshFilter.sharedMesh = mesh; + go.AddComponent(); + var meshComponent = go.AddComponent(); + meshComponent.UseLegacyMesh = false; + meshComponent.enabled = visible; + var collider = go.AddComponent(); + collider.HitEvent = hitEvent; + collider.enabled = true; + } + + private UnityMesh GetOrCreateMesh(EngineMesh source, string assetName) + { + var path = $"{_meshAssetRoot}/{SafeName(assetName)}-{MeshHash(source).Substring(0, 16)}.asset"; + if (_meshes.TryGetValue(path, out var cached)) return cached; + var existing = AssetDatabase.LoadAssetAtPath(path); + if (existing != null && _options.ReuseGeneratedAssets) { + _report.ReusedAssets++; + return _meshes[path] = existing; + } + if (existing != null) AssetDatabase.DeleteAsset(path); + var mesh = new UnityMesh { + name = assetName, + indexFormat = source.Vertices.Length > ushort.MaxValue + ? UnityEngine.Rendering.IndexFormat.UInt32 + : UnityEngine.Rendering.IndexFormat.UInt16 + }; + source.ApplyToUnityMesh(mesh); + AssetDatabase.CreateAsset(mesh, path); + _report.MeshAssets++; + return _meshes[path] = mesh; + } + + private UnityMaterial GetOrCreateMaterial(FuturePinballMaterialDescription source) + { + source ??= FuturePinballMaterialConverter.FromValues("default", 2, 0xffffffff); + var key = $"{source.Category}|{source.SourceColor}|{source.Opacity:R}|{source.Roughness:R}|{source.IsCrystal}|{source.IsSphereMapped}|{source.IsTwoSided}|{source.IsEmissive}|{source.Texture}"; + var hash = Sha256(key).Substring(0, 16); + if (_materials.TryGetValue(hash, out var cached)) return cached; + var path = $"{_materialAssetRoot}/{SafeName(source.Name)}-{hash}.mat"; + var existing = AssetDatabase.LoadAssetAtPath(path); + if (existing != null && _options.ReuseGeneratedAssets) { + _report.ReusedAssets++; + return _materials[hash] = existing; + } + if (existing != null) AssetDatabase.DeleteAsset(path); + + var pbr = new PbrMaterial(source.ToVpeMaterial(), id: hash); + var material = RenderPipeline.Current?.MaterialConverter?.CreateMaterial(pbr, null) + ?? new UnityMaterial(Shader.Find("Standard")); + material.name = source.Name; + if (!string.IsNullOrWhiteSpace(source.Texture) && TryGetTexture(source.Texture, out var texture)) { + SetTexture(material, texture); + } + if (source.IsTwoSided) { + SetFloat(material, "_DoubleSidedEnable", 1f); + SetFloat(material, "_CullMode", 0f); + SetFloat(material, "_Cull", 0f); + } + if (source.IsEmissive) { + var color = source.ToVpeMaterial().BaseColor.ToUnityColor(); + if (material.HasProperty("_EmissiveColor")) material.SetColor("_EmissiveColor", color); + if (material.HasProperty("_EmissionColor")) material.SetColor("_EmissionColor", color); + material.EnableKeyword("_EMISSION"); + } + AssetDatabase.CreateAsset(material, path); + _report.MaterialAssets++; + return _materials[hash] = material; + } + + private GameObject CreateVpeHierarchy(GameObject root) + { + var tableData = CreateTableData(); + _nativeTable = new NativeTableContainer(tableData).Table; + var tableComponent = root.AddComponent(); + tableComponent.SetData(tableData); + root.AddComponent(); + root.AddComponent(); + + var playfield = Child(root, "Playfield"); + var physicsEngine = playfield.AddComponent(); + SimulationThreadComponent.EnsureFor(physicsEngine); + var playfieldComponent = playfield.AddComponent(); + if (_options.GenerateColliders) { + playfield.AddComponent(); + _report.Colliders++; + } + playfieldComponent.SetData(tableData); + playfieldComponent.RenderSlope = tableData.AngleTiltMin; + return playfield; + } + + private TableData CreateTableData() + { + const int defaultWidthMillimeters = 514; + const int defaultLengthMillimeters = 1168; + const int defaultGlassHeightMillimeters = 216; + var width = FuturePinballElementGeometry.Integer(_table.TableData, TableWidthTag, defaultWidthMillimeters); + var length = FuturePinballElementGeometry.Integer(_table.TableData, TableLengthTag, defaultLengthMillimeters); + var frontGlass = FuturePinballElementGeometry.Integer(_table.TableData, FrontGlassHeightTag, defaultGlassHeightMillimeters); + var rearGlass = FuturePinballElementGeometry.Integer(_table.TableData, RearGlassHeightTag, defaultGlassHeightMillimeters); + var slope = FuturePinballElementGeometry.Float(_table.TableData, SlopeTag, 6f); + if (width <= 0) width = defaultWidthMillimeters; + if (length <= 0) length = defaultLengthMillimeters; + if (frontGlass <= 0) frontGlass = defaultGlassHeightMillimeters; + if (rearGlass <= 0) rearGlass = defaultGlassHeightMillimeters; + if (float.IsNaN(slope) || float.IsInfinity(slope) || slope < 0f || slope > 20f) slope = 6f; + return new TableData { + Name = _tableName, + Image = FuturePinballNativeItemConverter.PlayfieldImage, + Left = 0f, + Right = FuturePinballCoordinateConverter.ToVpx(width), + Top = 0f, + Bottom = FuturePinballCoordinateConverter.ToVpx(length), + GlassHeight = FuturePinballCoordinateConverter.ToVpx(System.Math.Max(frontGlass, rearGlass)), + AngleTiltMin = slope, + AngleTiltMax = slope + }; + } + + private void AddColliderBacklog(FuturePinballSourceStream element, string reason) + { + if (reason == "The source shape does not affect the ball") return; + var message = $"{ElementName(element)} collider: {reason}"; + _report.Warnings.Add(message); + _report.Backlog.Add(new FptRecreationBacklogItem { + SourceIndex = element.SourceIndex ?? -1, + Name = ElementName(element), + ElementType = element.ElementType?.ToString() ?? $"Unknown({element.ElementTypeId})", + CurrentOutcome = $"Collision source preserved but not converted: {reason}", + SuggestedCapability = "map the preserved collision record to a supported VPE collider", + SourceStream = element.Name + }); + } + + private void AddSpinningDiskBacklog(FuturePinballSourceStream element) + { + var message = $"{ElementName(element)} turntable radius could not be derived because its source model was not imported or had no usable mesh; the VPE default radius is retained."; + _report.Warnings.Add(message); + _report.Backlog.Add(new FptRecreationBacklogItem { + SourceIndex = element.SourceIndex ?? -1, + Name = ElementName(element), + ElementType = FuturePinballElementType.SpinningDisk.ToString(), + CurrentOutcome = "Native VPE turntable created, but its influence radius retains the VPE default and its source visual may be unavailable", + SuggestedCapability = "import or map a usable spinning-disk source model to recover the visual and influence radius", + SourceStream = element.Name + }); + } + + private void LoadTextures() + { + foreach (var resource in _manifest.Resources.Where(resource => resource.Category == "images")) { + var file = resource.Files.FirstOrDefault(item => item.Role == "original"); + if (file == null) continue; + var texture = AssetDatabase.LoadAssetAtPath($"{_bundleAssetRoot}/{file.Path}"); + if (texture == null) continue; + var logicalName = resource.LogicalName; + if (string.IsNullOrWhiteSpace(logicalName)) continue; + if (_textures.ContainsKey(logicalName)) { + _report.Warnings.Add($"Duplicate image name '{logicalName}'; the later source stream is used for exact-name references."); + } + _textures[logicalName] = texture; + AddTextureAlias(Path.GetFileName(logicalName), logicalName, texture); + AddTextureAlias(Path.GetFileNameWithoutExtension(logicalName), logicalName, texture); + } + } + + private void AddTextureAlias(string alias, string logicalName, Texture2D texture) + { + if (string.IsNullOrWhiteSpace(alias) || alias.Equals(logicalName, StringComparison.OrdinalIgnoreCase) + || _ambiguousTextureAliases.Contains(alias)) return; + if (_textureAliasSources.TryGetValue(alias, out var existingSource) + && !existingSource.Equals(logicalName, StringComparison.OrdinalIgnoreCase)) { + _textureAliases.Remove(alias); + _textureAliasSources.Remove(alias); + _ambiguousTextureAliases.Add(alias); + _report.Warnings.Add($"Image alias '{alias}' is ambiguous between '{existingSource}' and '{logicalName}' and will not be used."); + return; + } + _textureAliases[alias] = texture; + _textureAliasSources[alias] = logicalName; + } + + private bool TryGetTexture(string name, out Texture2D texture) + { + if (_textures.TryGetValue(name, out texture)) return true; + if (_ambiguousTextureAliases.Contains(name)) { + texture = null; + return false; + } + return _textureAliases.TryGetValue(name, out texture); + } + + private bool TryResolveSurfaceHeight(FuturePinballSourceStream element, FuturePinballVector2 position, out float height) + { + var surfaceName = FuturePinballElementGeometry.Text(element, SurfaceTag); + if (string.IsNullOrWhiteSpace(surfaceName)) { + height = 0f; + return true; + } + return TryResolveSurfaceHeight(surfaceName, position, element, + new HashSet(StringComparer.OrdinalIgnoreCase), out height); + } + + private bool TryResolveSurfaceHeight( + string surfaceName, + FuturePinballVector2 position, + FuturePinballSourceStream placedElement, + ISet resolving, + out float height) + { + height = 0f; + if (!resolving.Add(surfaceName)) { + AddSurfacePlacementBacklog(placedElement, surfaceName, "surface reference cycle"); + return false; + } + try { + var candidates = SurfacesByName().TryGetValue(surfaceName, out var named) ? named : Array.Empty(); + if (candidates.Length == 0) { + AddSurfacePlacementBacklog(placedElement, surfaceName, "referenced surface was not found"); + return false; + } + if (candidates.Length == 1) { + return TryCandidateSurfaceHeight(candidates[0], position, placedElement, resolving, out height); + } + var containing = candidates.Where(candidate => FuturePinballElementGeometry.ContainsPoint(candidate, position)).ToArray(); + var selected = containing.Length > 0 ? containing : candidates; + var heights = new float[selected.Length]; + for (var i = 0; i < selected.Length; i++) { + if (!TryCandidateSurfaceHeight(selected[i], position, placedElement, resolving, out heights[i])) return false; + } + if (containing.Length == 1 || heights.All(candidateHeight => System.Math.Abs(candidateHeight - heights[0]) < 0.0001f)) { + height = heights[0]; + return true; + } + AddSurfacePlacementBacklog(placedElement, surfaceName, + containing.Length == 0 ? "duplicate surfaces could not be disambiguated by element position" : "element position lies on multiple surfaces with different heights"); + return false; + } finally { + resolving.Remove(surfaceName); + } + } + + private bool TryCandidateSurfaceHeight( + FuturePinballSourceStream candidate, + FuturePinballVector2 position, + FuturePinballSourceStream placedElement, + ISet resolving, + out float height) + { + if (candidate.ElementType == FuturePinballElementType.Surface) { + height = FuturePinballElementGeometry.Float(candidate, SurfaceTopHeightTag); + return true; + } + height = FuturePinballElementGeometry.Float(candidate, HeightTag); + var parentSurface = FuturePinballElementGeometry.Text(candidate, SurfaceTag); + if (string.IsNullOrWhiteSpace(parentSurface)) return true; + if (!TryResolveSurfaceHeight(parentSurface, position, placedElement, resolving, out var parentHeight)) { + height = 0f; + return false; + } + height += parentHeight; + return true; + } + + private Dictionary SurfacesByName() + { + return _surfacesByName ??= _table.Elements + .Where(element => element.ElementType == FuturePinballElementType.Surface + || element.ElementType == FuturePinballElementType.GuideWall) + .GroupBy(ElementName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.OrdinalIgnoreCase); + } + + private void AddSurfacePlacementBacklog(FuturePinballSourceStream element, string surfaceName, string reason) + { + if (_skippedUnresolvedSupportElements.Add(element)) _report.SkippedUnresolvedSupport++; + var issueKey = $"{element.SourceIndex}:{element.Name}:{surfaceName}:{reason}"; + if (!_reportedSurfacePlacementIssues.Add(issueKey)) return; + _report.Warnings.Add($"{ElementName(element)} surface '{surfaceName}': {reason}; its named support offset is unresolved."); + _report.Backlog.Add(new FptRecreationBacklogItem { + SourceIndex = element.SourceIndex ?? -1, + Name = ElementName(element), + ElementType = element.ElementType?.ToString() ?? $"Unknown({element.ElementTypeId})", + CurrentOutcome = $"Scene placement skipped; named support offset is unresolved: {reason}", + SuggestedCapability = "resolve the preserved surface reference manually", + SourceStream = element.Name + }); + } + + private bool TryGetPlacement(FuturePinballSourceStream element, out Vector3 worldPosition, out Quaternion worldRotation) + { + var position = FuturePinballElementGeometry.Position(element); + if (!TryResolveSurfaceHeight(element, position, out var height)) { + worldPosition = default; + worldRotation = default; + return false; + } + height += FuturePinballElementGeometry.Integer(element, HeightTag); + var world = FuturePinballCoordinateConverter.ToWorld(position.X, position.Y, height); + worldPosition = new Vector3(world.X, world.Y, world.Z); + // FPM meshes use model Y as up, unlike table records where Z is height. This basis correction is + // model-only; native VPE item data intentionally keeps the FP table yaw unchanged. + worldRotation = Quaternion.Euler(0f, -90f - FuturePinballElementGeometry.Integer(element, RotationTag), 0f); + return true; + } + + private void AddSource(GameObject go, FuturePinballSourceStream source, string outcome) + { + var component = go.AddComponent(); + component.SourceFile = Path.GetFileName(_sourcePath); + component.SourceHash = _manifest.SourceSha256; + component.SourceStream = source.Name; + component.SourceIndex = source.SourceIndex ?? -1; + component.ElementType = source.ElementType?.ToString() ?? $"Unknown({source.ElementTypeId})"; + component.ImportOutcome = outcome; + } + + private static string SuggestedCapability(FuturePinballElementType? type) + { + switch (type) { + case FuturePinballElementType.RoundLight: + case FuturePinballElementType.ShapeableLight: + case FuturePinballElementType.Flasher: + case FuturePinballElementType.Bulb: + return "recreate lamp state and light-list behavior"; + case FuturePinballElementType.Dmd: + case FuturePinballElementType.HudDmd: + case FuturePinballElementType.DispReel: + case FuturePinballElementType.HudReel: + return "recreate script-driven display output"; + case FuturePinballElementType.Timer: + case FuturePinballElementType.LightSequencer: + return "translate runtime behavior from the preserved table script"; + default: + return "map to a native VPE component or static fallback"; + } + } + + private static string ElementName(FuturePinballSourceStream element) + { + return FuturePinballElementGeometry.Text(element, NameTag, element.Name); + } + + private void ValidateAssetRoot() + { + _options.AssetRoot = (_options.AssetRoot ?? string.Empty).Replace('\\', '/').TrimEnd('/'); + if (_options.AssetRoot != "Assets" && !_options.AssetRoot.StartsWith("Assets/", StringComparison.Ordinal)) { + throw new ArgumentException("Future Pinball import asset root must be inside Assets."); + } + if (_options.AssetRoot.Split('/').Any(part => part.Length == 0 || part == "." || part == "..")) { + throw new ArgumentException("Future Pinball import asset root contains an invalid path segment."); + } + } + + private static GameObject Child(GameObject parent, string name) + { + var child = new GameObject(name); + child.transform.SetParent(parent.transform, false); + return child; + } + + private static void DisableRenderers(GameObject root) + { + foreach (var renderer in root.GetComponentsInChildren(true)) renderer.enabled = false; + } + + public UnityEngine.Texture GetTexture(string name) + { + return !string.IsNullOrWhiteSpace(name) && TryGetTexture(name, out var texture) ? texture : null; + } + + public bool HasMaterial(PbrMaterial material) + { + var key = NativeMaterialKey(material); + if (_nativeMaterials.ContainsKey(key)) return true; + if (!_options.ReuseGeneratedAssets) return false; + var existing = AssetDatabase.LoadAssetAtPath(NativeMaterialPath(key)); + if (existing == null) return false; + _nativeMaterials[key] = existing; + _report.ReusedAssets++; + return true; + } + + public void SaveMaterial(PbrMaterial source, UnityMaterial material) + { + var key = NativeMaterialKey(source); + var path = NativeMaterialPath(key); + var existing = AssetDatabase.LoadAssetAtPath(path); + if (existing != null && _options.ReuseGeneratedAssets) { + _nativeMaterials[key] = existing; + if (material != null && !AssetDatabase.Contains(material)) UnityEngine.Object.DestroyImmediate(material); + _report.ReusedAssets++; + return; + } + if (existing != null) AssetDatabase.DeleteAsset(path); + material ??= new UnityMaterial(Shader.Find("Standard")); + material.name = $"Future Pinball Native {key.Substring(0, 8)}"; + AssetDatabase.CreateAsset(material, path); + _nativeMaterials[key] = material; + _report.MaterialAssets++; + } + + public UnityMaterial GetMaterial(PbrMaterial material) + { + if (HasMaterial(material)) return _nativeMaterials[NativeMaterialKey(material)]; + var unityMaterial = RenderPipeline.Current?.MaterialConverter?.CreateMaterial(material ?? new PbrMaterial(), this) + ?? new UnityMaterial(Shader.Find("Standard")); + SaveMaterial(material, unityMaterial); + return _nativeMaterials[NativeMaterialKey(material)]; + } + + public PhysicsMaterialAsset GetPhysicsMaterial(string name) => null; + + public UnityMaterial MergeMaterials(string vpxMaterial, UnityMaterial textureMaterial) + { + if (textureMaterial == null) return GetMaterial(new PbrMaterial(id: vpxMaterial)); + var source = new PbrMaterial(id: string.IsNullOrWhiteSpace(vpxMaterial) ? PbrMaterial.NameNoMaterial : vpxMaterial); + if (HasMaterial(source)) return _nativeMaterials[NativeMaterialKey(source)]; + var merged = RenderPipeline.Current?.MaterialConverter?.MergeMaterials(source, textureMaterial) + ?? new UnityMaterial(textureMaterial); + SaveMaterial(source, merged); + return _nativeMaterials[NativeMaterialKey(source)]; + } + + private static string NativeMaterialKey(PbrMaterial material) + { + return Sha256(material?.Id ?? PbrMaterial.NameNoMaterial); + } + + private string NativeMaterialPath(string key) => $"{_materialAssetRoot}/native-{key.Substring(0, 16)}.mat"; + + private static void SetTexture(UnityMaterial material, UnityEngine.Texture texture) + { + foreach (var property in new[] { "_BaseColorMap", "_BaseMap", "_MainTex" }) { + if (material.HasProperty(property)) material.SetTexture(property, texture); + } + } + + private static void SetFloat(UnityMaterial material, string property, float value) + { + if (material.HasProperty(property)) material.SetFloat(property, value); + } + + private static void EnsureAssetFolder(string assetPath) + { + var parts = assetPath.Replace('\\', '/').Split('/'); + var current = parts[0]; + for (var i = 1; i < parts.Length; i++) { + var next = current + "/" + parts[i]; + if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(current, parts[i]); + current = next; + } + } + + private static string PhysicalPath(string assetPath) + { + var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? throw new InvalidOperationException("Unity project root not found."); + return Path.GetFullPath(Path.Combine(projectRoot, assetPath.Replace('/', Path.DirectorySeparatorChar))); + } + + private static string SafeName(string value) + { + if (string.IsNullOrWhiteSpace(value)) return "unnamed"; + var invalid = new HashSet("<>:\"/\\|?*"); + var result = new string(value.Select(character => character < 0x20 || invalid.Contains(character) ? '_' : character).ToArray()).Trim().TrimEnd('.', ' '); + return string.IsNullOrEmpty(result) ? "unnamed" : result.Length > 80 ? result.Substring(0, 80) : result; + } + + private static string Sha256(string value) + { + using (var sha = SHA256.Create()) return BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(value))).Replace("-", string.Empty).ToLowerInvariant(); + } + + private static string MeshHash(EngineMesh mesh) + { + using (var sha = SHA256.Create()) + using (var output = new CryptoStream(Stream.Null, sha, CryptoStreamMode.Write)) + using (var writer = new BinaryWriter(output)) { + foreach (var vertex in mesh.Vertices) { + writer.Write(vertex.X); writer.Write(vertex.Y); writer.Write(vertex.Z); + writer.Write(vertex.Nx); writer.Write(vertex.Ny); writer.Write(vertex.Nz); + writer.Write(vertex.Tu); writer.Write(vertex.Tv); + } + foreach (var index in mesh.Indices) writer.Write(index); + writer.Flush(); + output.FlushFinalBlock(); + return BitConverter.ToString(sha.Hash).Replace("-", string.Empty).ToLowerInvariant(); + } + } + + private sealed class LoadedModel + { + public MilkShapeModel Primary { get; } + public FuturePinballSourceStream Source { get; } + + public LoadedModel(MilkShapeModel primary, FuturePinballSourceStream source) + { + Primary = primary; + Source = source; + } + } + + private sealed class NativeTableContainer : TableContainer + { + public override Table Table { get; } + public override Dictionary TableInfo { get; } = new Dictionary(); + public override List Collections { get; } = new List(); + public override CustomInfoTags CustomInfoTags { get; } = new CustomInfoTags(); + public override IEnumerable Textures => Array.Empty(); + public override IEnumerable Sounds => Array.Empty(); + + public NativeTableContainer(TableData data) + { + Table = new Table(this, data); + } + + public override EngineMaterial GetMaterial(string name) => null; + public override EngineTexture GetTexture(string name) => null; + } + } + + public sealed class FptImportResult + { + public GameObject Root { get; } + public FuturePinballExtractionManifest Manifest { get; } + public FptImportReport Report { get; } + public string BundleAssetPath { get; } + + internal FptImportResult(GameObject root, FuturePinballExtractionManifest manifest, FptImportReport report, string bundleAssetPath) + { + Root = root; + Manifest = manifest; + Report = report; + BundleAssetPath = bundleAssetPath; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs.meta new file mode 100644 index 000000000..a3ea54b5b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a184c8cf376c4fb096246ed343e52b67 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/MenuImporter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/MenuImporter.cs index 802df0692..4f76a252e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/MenuImporter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/MenuImporter.cs @@ -36,7 +36,7 @@ public static async void ImportVpeIntoScene(MenuCommand menuCommand) } // open file dialog - var path = EditorUtility.OpenFilePanelWithFilters("Import", null, new[] { "Pinball Tables", "vpe,vpx" }); + var path = EditorUtility.OpenFilePanelWithFilters("Import", null, new[] { "Pinball Tables", "vpe,vpx,fpt" }); if (path.Length == 0) { return; } @@ -52,8 +52,12 @@ public static async void ImportVpeIntoScene(MenuCommand menuCommand) await importer.ImportIntoScene(Path.GetFileNameWithoutExtension(path)); break; } + case ".fpt": { + FptImportWizard.Open(path); + break; + } default: - EditorUtility.DisplayDialog("Import", "Unsupported file format. Only .vpe and .vpx files are currently supported.", "OK"); + EditorUtility.DisplayDialog("Import", "Unsupported file format. Supported formats are .vpe, .vpx, and .fpt.", "OK"); break; } } @@ -70,13 +74,21 @@ public static void ImportVpxWithFilters(MenuCommand menuCommand) return; } - VpxImportWizardSettings.VpxPath = path; - VpxImportWizardSettings.TableName = Path.GetFileNameWithoutExtension(path); - VpxImportWizardSettings.ObjectImportFilter = VpxObjectImportFilter.CollidableOnly; - VpxImportWizardSettings.ImportTextures = false; - VpxImportWizardSettings.ImportSounds = false; - VpxImportWizard.Init(); - } + VpxImportWizardSettings.VpxPath = path; + VpxImportWizardSettings.TableName = Path.GetFileNameWithoutExtension(path); + VpxImportWizardSettings.ObjectImportFilter = VpxObjectImportFilter.CollidableOnly; + VpxImportWizardSettings.ImportTextures = false; + VpxImportWizardSettings.ImportSounds = false; + VpxImportWizard.Init(); + } + + [MenuItem("Pinball/Import Future Pinball Table", false, 3)] + public static void ImportFuturePinballTable() + { + if (!EnsureUntitledSceneHasBeenSaved()) return; + var path = EditorUtility.OpenFilePanel("Import Future Pinball Table", null, "fpt"); + if (!string.IsNullOrEmpty(path)) FptImportWizard.Open(path); + } private static bool EnsureUntitledSceneHasBeenSaved() { @@ -98,4 +110,4 @@ private static bool EnsureUntitledSceneHasBeenSaved() return true; } } -} +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs new file mode 100644 index 000000000..9fe5da014 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs @@ -0,0 +1,79 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; + +using UnityEngine; + +using VisualPinball.Unity.Editor; + +namespace VisualPinball.Unity.Test +{ + public class FptSceneConverterTests + { + [Test] + public void ShouldAttachAndConfigureSpinningDiskFromSourceModel() + { + var modelInstances = new GameObject("Model Instances"); + var mechanism = new GameObject("Spinning Disk"); + var sourceModel = new GameObject("Source Model"); + sourceModel.transform.SetParent(modelInstances.transform, false); + sourceModel.transform.localPosition = Vector3.one; + sourceModel.transform.localRotation = Quaternion.Euler(10f, 20f, 30f); + var visual = FptSceneConverter.CreateSpinningDiskVisualRoot(mechanism.AddComponent(), sourceModel); + var meshObject = new GameObject("Mesh"); + var mesh = new Mesh { vertices = new[] { new Vector3(0.03f, 0f, 0.04f) } }; + meshObject.transform.SetParent(visual.transform, false); + meshObject.AddComponent().sharedMesh = mesh; + var component = mechanism.GetComponent(); + + try { + var configured = FptSceneConverter.ConfigureSpinningDiskVisual(component, visual); + + Assert.That(configured, Is.True); + Assert.That(sourceModel.transform.parent, Is.EqualTo(mechanism.transform)); + Assert.That(sourceModel.transform.localPosition, Is.EqualTo(Vector3.zero)); + Assert.That(sourceModel.transform.localRotation, Is.EqualTo(Quaternion.identity)); + Assert.That(visual.transform.parent, Is.EqualTo(sourceModel.transform)); + Assert.That(component.RotationTarget, Is.EqualTo(visual.transform)); + Assert.That(component.Radius, Is.EqualTo(50f).Within(0.001f)); + } finally { + Object.DestroyImmediate(mechanism); + Object.DestroyImmediate(modelInstances); + Object.DestroyImmediate(mesh); + } + } + + [Test] + public void ShouldRetainDefaultRadiusForSpinningDiskWithoutUsableMesh() + { + var mechanism = new GameObject("Spinning Disk"); + var visual = new GameObject("Rotating Visual"); + var component = mechanism.AddComponent(); + + try { + var configured = FptSceneConverter.ConfigureSpinningDiskVisual(component, visual); + + Assert.That(configured, Is.False); + Assert.That(component.RotationTarget, Is.EqualTo(visual.transform)); + Assert.That(component.Radius, Is.EqualTo(60f)); + } finally { + Object.DestroyImmediate(mechanism); + Object.DestroyImmediate(visual); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs.meta new file mode 100644 index 000000000..5d1df9f46 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8682ce0f9b924811af50cbd28a68a8f3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/LightTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/LightTests.cs index 9168c3b7a..e4130dd05 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/LightTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/LightTests.cs @@ -17,11 +17,15 @@ using System.IO; using NUnit.Framework; using UnityEngine; +using VisualPinball.Engine.IO.FuturePinball; using VisualPinball.Engine.Test.Test; using VisualPinball.Engine.Test.VPT.Light; +using VisualPinball.Engine.VPT.Light; using VisualPinball.Engine.VPT.Table; using VisualPinball.Unity.Editor; +using VpeLight = VisualPinball.Engine.VPT.Light.Light; + namespace VisualPinball.Unity.Test { public class LightTests @@ -41,5 +45,23 @@ public void ShouldWriteImportedLightData() File.Delete(tmpFileName); Object.DestroyImmediate(go); } + + [Test] + public void ShouldInstantiateFuturePinballInsertLightPrefab() + { + var table = new FileTableContainer(); + table.Table.Data.Image = FuturePinballNativeItemConverter.PlayfieldImage; + var light = new VpeLight(new LightData("Future Pinball Insert", 100f, 100f) { + OffImage = FuturePinballNativeItemConverter.PlayfieldImage, + ShowBulbMesh = false + }); + + var prefab = light.InstantiatePrefab(table.Table); + try { + Assert.That(prefab.GameObject.GetComponentInChildren(true), Is.Not.Null); + } finally { + Object.DestroyImmediate(prefab.GameObject); + } + } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Import/FuturePinballSourceComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Import/FuturePinballSourceComponent.cs new file mode 100644 index 000000000..ab332bbf2 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Import/FuturePinballSourceComponent.cs @@ -0,0 +1,31 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using UnityEngine; + +namespace VisualPinball.Unity +{ + [AddComponentMenu("Pinball/Import/Future Pinball Source")] + public class FuturePinballSourceComponent : MonoBehaviour + { + public string SourceFile; + public string SourceHash; + public string SourceStream; + public int SourceIndex = -1; + public string ElementType; + public string ImportOutcome; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Import/FuturePinballSourceComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Import/FuturePinballSourceComponent.cs.meta new file mode 100644 index 000000000..7c9592418 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Import/FuturePinballSourceComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 081c267ff13a405c8d9000f30c8e5755 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/OpenMcdfStorage.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/OpenMcdfStorage.cs deleted file mode 100644 index 2784df705..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/OpenMcdfStorage.cs +++ /dev/null @@ -1,161 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2023 freezy and VPE Team -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -using System; -using System.IO; -using OpenMcdf; -using OpenMcdf.Extensions; - -namespace VisualPinball.Unity.Editor.Packaging -{ - /// - /// Historical alternative container (OLE compound file, the VPX container format). Kept for - /// reference only — the shipping .vpe container is zip (see ) - /// and there is no plan to switch back. Do not use for new code. - /// - [Obsolete("The .vpe container is zip (SharpZipStorageManager). This implementation is kept for historical reference only.")] - public class OpenMcdfStorageManager : IStorageManager - { - public IPackageStorage CreateStorage(string path) - { - var s = new OpenMcdfStorage(); - s.Path = path; - return s; - } - - public IPackageStorage OpenStorage(string path) - { - return new OpenMcdfStorage(path); - } - } - - - [Obsolete("The .vpe container is zip (SharpZipStorageManager). This implementation is kept for historical reference only.")] - public class OpenMcdfStorage : IPackageStorage - { - private readonly CompoundFile _cf; - public string Path; - - public OpenMcdfStorage() - { - _cf = new CompoundFile(); - } - - public OpenMcdfStorage(string path) - { - _cf = new CompoundFile(path); - } - - public IPackageFolder AddFolder(string name) => new OpenMcdfFolder(_cf.RootStorage.AddStorage(name)); - - public IPackageFolder GetFolder(string name) => new OpenMcdfFolder(_cf.RootStorage.GetStorage(name)); - - public IPackageFile AddFile(string name, string ext = null) => new OpenMcdfFile(_cf.RootStorage.AddStream(name + (ext ?? string.Empty))); - - public bool TryGetFile(string name, out IPackageFile file, string ext = null) - { - if (_cf.RootStorage.TryGetStream(name + (ext ?? string.Empty), out var cfStream)) { - file = new OpenMcdfFile(cfStream); - return true; - } - file = null; - return false; - } - - public void Close() - { - _cf.SaveAs(Path); - _cf.Close(); - } - - public void Dispose() - { - _cf.Close(); - _cf.Dispose(); - } - } - - public class OpenMcdfFolder : IPackageFolder - { - private readonly CFStorage _storage; - - public OpenMcdfFolder(CFStorage storage) - { - _storage = storage; - _storage.ModifyDate = DateTime.Now; - _storage.CreationDate = DateTime.Now; - } - - public string Name => _storage.Name; - public IPackageFile AddFile(string name, string ext = null) => new OpenMcdfFile(_storage.AddStream(name + (ext ?? string.Empty))); - public IPackageFolder AddFolder(string name) => new OpenMcdfFolder(_storage.AddStorage(name)); - public bool TryGetFolder(string name, out IPackageFolder folder) - { - if (_storage.TryGetStorage(name, out var cfStorage)) { - folder = new OpenMcdfFolder(cfStorage); - return true; - } - folder = null; - return false; - } - - public bool TryGetFile(string name, out IPackageFile file, string ext = null) - { - if (_storage.TryGetStream(name + (ext ?? string.Empty), out var cfStream)) { - file = new OpenMcdfFile(cfStream); - return true; - } - file = null; - return false; - } - - public IPackageFile GetFile(string name, string ext = null) => new OpenMcdfFile(_storage.GetStream(name + (ext ?? string.Empty))); - public IPackageFolder GetFolder(string name) => new OpenMcdfFolder(_storage.GetStorage(name)); - public void VisitFiles(Action action) - { - _storage.VisitEntries(entry => { - if (entry is CFStream stream) { - action(new OpenMcdfFile(stream)); - } - }, false); - } - - public void VisitFolders(Action action) - { - _storage.VisitEntries(entry => { - if (entry is CFStorage storage) { - action(new OpenMcdfFolder(storage)); - } - }, false); - } - } - - public class OpenMcdfFile : IPackageFile - { - private readonly CFStream _stream; - - public OpenMcdfFile(CFStream stream) - { - _stream = stream; - } - - public string Name => _stream.Name; - public Stream AsStream() => _stream.AsIOStream(); - // Compound files have no per-stream compression; the hint is ignored. - public void SetData(byte[] data, PackageCompression compression = PackageCompression.Default) => _stream.Append(data); - public byte[] GetData() => _stream.GetData(); - } -} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/OpenMcdfStorage.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Packaging/OpenMcdfStorage.cs.meta deleted file mode 100644 index 931f8236b..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/OpenMcdfStorage.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 79a7bb615add4e7e8761aa1baf98aae2 -timeCreated: 1738618743 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs index eaf383642..bc661da19 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs @@ -65,7 +65,6 @@ public static class PackageApi public const float LightIntensityFactor = 100f; public static readonly IStorageManager StorageManager = new SharpZipStorageManager(); - // public static IStorageManager StorageManager => new OpenMcdfStorageManager(); public static readonly IDataPacker Packer = new JsonPacker(); }