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