From 6561505d48d11948a21c7fcbec087b22deeb5808 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 09:48:18 +0200 Subject: [PATCH 01/31] test(fpt): lock Future Pinball fixture corpus --- .../Fixtures~/FuturePinball/README.md | 22 ++ .../IO/FuturePinball.meta | 8 + .../FuturePinballFixtureCatalog.cs | 214 ++++++++++++++++++ .../FuturePinballFixtureCatalog.cs.meta | 11 + 4 files changed, 255 insertions(+) create mode 100644 VisualPinball.Engine.Test/Fixtures~/FuturePinball/README.md create mode 100644 VisualPinball.Engine.Test/IO/FuturePinball.meta create mode 100644 VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballFixtureCatalog.cs create mode 100644 VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballFixtureCatalog.cs.meta diff --git a/VisualPinball.Engine.Test/Fixtures~/FuturePinball/README.md b/VisualPinball.Engine.Test/Fixtures~/FuturePinball/README.md new file mode 100644 index 000000000..ce8f37b36 --- /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-stream 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/FuturePinballFixtureCatalog.cs b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballFixtureCatalog.cs new file mode 100644 index 000000000..c602a6712 --- /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 StreamCount { 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 streamCount, + 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; + StreamCount = streamCount; + 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: From ac961aacc18d3b51a7d822042a568dbbd5e965fb Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 10:19:03 +0200 Subject: [PATCH 02/31] io(fpt): add safe Future Pinball readers --- .../Fixtures~/FuturePinball/README.md | 2 +- .../FuturePinballFixtureCatalog.cs | 6 +- .../FuturePinball/FuturePinballReaderTests.cs | 313 ++++++++++++ .../FuturePinballReaderTests.cs.meta | 11 + VisualPinball.Engine/IO/BiffData.cs | 6 +- .../IO/CompoundStorageExtensions.cs | 56 +++ .../IO/CompoundStorageExtensions.cs.meta | 11 + VisualPinball.Engine/IO/FuturePinball.meta | 8 + .../FuturePinball/FuturePinballCompression.cs | 230 +++++++++ .../FuturePinballCompression.cs.meta | 11 + .../IO/FuturePinball/FuturePinballFormat.cs | 281 +++++++++++ .../FuturePinball/FuturePinballFormat.cs.meta | 11 + .../FuturePinballLibraryReader.cs | 135 +++++ .../FuturePinballLibraryReader.cs.meta | 11 + .../FuturePinballRecordReader.cs | 464 ++++++++++++++++++ .../FuturePinballRecordReader.cs.meta | 11 + .../FuturePinball/FuturePinballTableReader.cs | 270 ++++++++++ .../FuturePinballTableReader.cs.meta | 11 + VisualPinball.Engine/VPT/Table/TableLoader.cs | 83 ++-- VisualPinball.Engine/VPT/Table/TableWriter.cs | 25 +- VisualPinball.Engine/VPT/Texture.cs | 9 +- .../VisualPinball.Engine.csproj | 7 +- ...l.meta => Microsoft.Bcl.HashCode.dll.meta} | 34 +- ...l.meta => Microsoft.Bcl.HashCode.dll.meta} | 41 +- ...l.meta => Microsoft.Bcl.HashCode.dll.meta} | 9 +- ...l.meta => Microsoft.Bcl.HashCode.dll.meta} | 33 +- .../win-x64/Microsoft.Bcl.HashCode.dll.meta | 42 ++ .../win-x64/OpenMcdf.Extensions.dll.meta | 2 - ...l.meta => Microsoft.Bcl.HashCode.dll.meta} | 85 ++-- .../Packaging/OpenMcdfStorage.cs | 161 ------ .../Packaging/OpenMcdfStorage.cs.meta | 3 - .../Packaging/PackageApi.cs | 1 - 32 files changed, 2001 insertions(+), 382 deletions(-) create mode 100644 VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballReaderTests.cs create mode 100644 VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballReaderTests.cs.meta create mode 100644 VisualPinball.Engine/IO/CompoundStorageExtensions.cs create mode 100644 VisualPinball.Engine/IO/CompoundStorageExtensions.cs.meta create mode 100644 VisualPinball.Engine/IO/FuturePinball.meta create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballCompression.cs create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballCompression.cs.meta create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballFormat.cs create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballFormat.cs.meta create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballLibraryReader.cs create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballLibraryReader.cs.meta create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballRecordReader.cs create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballRecordReader.cs.meta create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballTableReader.cs create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballTableReader.cs.meta rename VisualPinball.Unity/Plugins/android-arm64-v8a/{OpenMcdf.Extensions.dll.meta => Microsoft.Bcl.HashCode.dll.meta} (59%) rename VisualPinball.Unity/Plugins/ios-arm64/{OpenMcdf.Extensions.dll.meta => Microsoft.Bcl.HashCode.dll.meta} (52%) rename VisualPinball.Unity/Plugins/linux-x64/{OpenMcdf.Extensions.dll.meta => Microsoft.Bcl.HashCode.dll.meta} (86%) rename VisualPinball.Unity/Plugins/osx/{OpenMcdf.Extensions.dll.meta => Microsoft.Bcl.HashCode.dll.meta} (58%) create mode 100644 VisualPinball.Unity/Plugins/win-x64/Microsoft.Bcl.HashCode.dll.meta delete mode 100644 VisualPinball.Unity/Plugins/win-x64/OpenMcdf.Extensions.dll.meta rename VisualPinball.Unity/Plugins/win-x86/{OpenMcdf.Extensions.dll.meta => Microsoft.Bcl.HashCode.dll.meta} (82%) delete mode 100644 VisualPinball.Unity/VisualPinball.Unity/Packaging/OpenMcdfStorage.cs delete mode 100644 VisualPinball.Unity/VisualPinball.Unity/Packaging/OpenMcdfStorage.cs.meta diff --git a/VisualPinball.Engine.Test/Fixtures~/FuturePinball/README.md b/VisualPinball.Engine.Test/Fixtures~/FuturePinball/README.md index ce8f37b36..8f17920bb 100644 --- a/VisualPinball.Engine.Test/Fixtures~/FuturePinball/README.md +++ b/VisualPinball.Engine.Test/Fixtures~/FuturePinball/README.md @@ -12,7 +12,7 @@ fp-2012-ultra-source/Three Angels.fpt fp-2013-enhanced/Three Angels_ehanc_Slam.fpt ``` -`FuturePinballFixtureCatalog` locks the source size and SHA-256, compound-stream 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. +`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: diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballFixtureCatalog.cs b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballFixtureCatalog.cs index c602a6712..3d42ef501 100644 --- a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballFixtureCatalog.cs +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballFixtureCatalog.cs @@ -29,7 +29,7 @@ public sealed class FuturePinballFixtureExpectation public string RelativePath { get; } public long SourceBytes { get; } public string SourceSha256 { get; } - public int StreamCount { get; } + public int CompoundEntryCount { get; } public int TableDataBytes { get; } public int ElementCount { get; } public int ImageCount { get; } @@ -54,7 +54,7 @@ public FuturePinballFixtureExpectation( string relativePath, long sourceBytes, string sourceSha256, - int streamCount, + int compoundEntryCount, int tableDataBytes, int elementCount, int imageCount, @@ -76,7 +76,7 @@ public FuturePinballFixtureExpectation( RelativePath = relativePath; SourceBytes = sourceBytes; SourceSha256 = sourceSha256; - StreamCount = streamCount; + CompoundEntryCount = compoundEntryCount; TableDataBytes = tableDataBytes; ElementCount = elementCount; ImageCount = imageCount; diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballReaderTests.cs b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballReaderTests.cs new file mode 100644 index 000000000..651786333 --- /dev/null +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballReaderTests.cs @@ -0,0 +1,313 @@ +// 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 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); + } + } + + 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 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/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/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/FuturePinballFormat.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballFormat.cs new file mode 100644 index 000000000..04cc3dfa2 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballFormat.cs @@ -0,0 +1,281 @@ +// 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; } + } + + 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/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/FuturePinballRecordReader.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballRecordReader.cs new file mode 100644 index 000000000..a047dc21d --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballRecordReader.cs @@ -0,0 +1,464 @@ +// 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, 0xA2F8CDDD, "height", FuturePinballValueKind.Integer); + Add(result, 0x95FDC9CE, "width", 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/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/Packaging/OpenMcdfStorage.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/OpenMcdfStorage.cs deleted file mode 100644 index 2784df705..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/OpenMcdfStorage.cs +++ /dev/null @@ -1,161 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2023 freezy and VPE Team -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -using System; -using System.IO; -using OpenMcdf; -using OpenMcdf.Extensions; - -namespace VisualPinball.Unity.Editor.Packaging -{ - /// - /// Historical alternative container (OLE compound file, the VPX container format). Kept for - /// reference only — the shipping .vpe container is zip (see ) - /// and there is no plan to switch back. Do not use for new code. - /// - [Obsolete("The .vpe container is zip (SharpZipStorageManager). This implementation is kept for historical reference only.")] - public class OpenMcdfStorageManager : IStorageManager - { - public IPackageStorage CreateStorage(string path) - { - var s = new OpenMcdfStorage(); - s.Path = path; - return s; - } - - public IPackageStorage OpenStorage(string path) - { - return new OpenMcdfStorage(path); - } - } - - - [Obsolete("The .vpe container is zip (SharpZipStorageManager). This implementation is kept for historical reference only.")] - public class OpenMcdfStorage : IPackageStorage - { - private readonly CompoundFile _cf; - public string Path; - - public OpenMcdfStorage() - { - _cf = new CompoundFile(); - } - - public OpenMcdfStorage(string path) - { - _cf = new CompoundFile(path); - } - - public IPackageFolder AddFolder(string name) => new OpenMcdfFolder(_cf.RootStorage.AddStorage(name)); - - public IPackageFolder GetFolder(string name) => new OpenMcdfFolder(_cf.RootStorage.GetStorage(name)); - - public IPackageFile AddFile(string name, string ext = null) => new OpenMcdfFile(_cf.RootStorage.AddStream(name + (ext ?? string.Empty))); - - public bool TryGetFile(string name, out IPackageFile file, string ext = null) - { - if (_cf.RootStorage.TryGetStream(name + (ext ?? string.Empty), out var cfStream)) { - file = new OpenMcdfFile(cfStream); - return true; - } - file = null; - return false; - } - - public void Close() - { - _cf.SaveAs(Path); - _cf.Close(); - } - - public void Dispose() - { - _cf.Close(); - _cf.Dispose(); - } - } - - public class OpenMcdfFolder : IPackageFolder - { - private readonly CFStorage _storage; - - public OpenMcdfFolder(CFStorage storage) - { - _storage = storage; - _storage.ModifyDate = DateTime.Now; - _storage.CreationDate = DateTime.Now; - } - - public string Name => _storage.Name; - public IPackageFile AddFile(string name, string ext = null) => new OpenMcdfFile(_storage.AddStream(name + (ext ?? string.Empty))); - public IPackageFolder AddFolder(string name) => new OpenMcdfFolder(_storage.AddStorage(name)); - public bool TryGetFolder(string name, out IPackageFolder folder) - { - if (_storage.TryGetStorage(name, out var cfStorage)) { - folder = new OpenMcdfFolder(cfStorage); - return true; - } - folder = null; - return false; - } - - public bool TryGetFile(string name, out IPackageFile file, string ext = null) - { - if (_storage.TryGetStream(name + (ext ?? string.Empty), out var cfStream)) { - file = new OpenMcdfFile(cfStream); - return true; - } - file = null; - return false; - } - - public IPackageFile GetFile(string name, string ext = null) => new OpenMcdfFile(_storage.GetStream(name + (ext ?? string.Empty))); - public IPackageFolder GetFolder(string name) => new OpenMcdfFolder(_storage.GetStorage(name)); - public void VisitFiles(Action action) - { - _storage.VisitEntries(entry => { - if (entry is CFStream stream) { - action(new OpenMcdfFile(stream)); - } - }, false); - } - - public void VisitFolders(Action action) - { - _storage.VisitEntries(entry => { - if (entry is CFStorage storage) { - action(new OpenMcdfFolder(storage)); - } - }, false); - } - } - - public class OpenMcdfFile : IPackageFile - { - private readonly CFStream _stream; - - public OpenMcdfFile(CFStream stream) - { - _stream = stream; - } - - public string Name => _stream.Name; - public Stream AsStream() => _stream.AsIOStream(); - // Compound files have no per-stream compression; the hint is ignored. - public void SetData(byte[] data, PackageCompression compression = PackageCompression.Default) => _stream.Append(data); - public byte[] GetData() => _stream.GetData(); - } -} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/OpenMcdfStorage.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Packaging/OpenMcdfStorage.cs.meta deleted file mode 100644 index 931f8236b..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/OpenMcdfStorage.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 79a7bb615add4e7e8761aa1baf98aae2 -timeCreated: 1738618743 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs index eaf383642..bc661da19 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs @@ -65,7 +65,6 @@ public static class PackageApi public const float LightIntensityFactor = 100f; public static readonly IStorageManager StorageManager = new SharpZipStorageManager(); - // public static IStorageManager StorageManager => new OpenMcdfStorageManager(); public static readonly IDataPacker Packer = new JsonPacker(); } From 314ddc6f4731b79330120a6e3bed3c9d6b43acb2 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 10:32:44 +0200 Subject: [PATCH 03/31] io(fpt): extract lossless source bundles --- .../FuturePinball/FuturePinballReaderTests.cs | 104 ++++ .../FuturePinballExtractionManifest.cs | 325 +++++++++++ .../FuturePinballExtractionManifest.cs.meta | 11 + .../FuturePinball/FuturePinballExtractor.cs | 552 ++++++++++++++++++ .../FuturePinballExtractor.cs.meta | 11 + 5 files changed, 1003 insertions(+) create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractionManifest.cs create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractionManifest.cs.meta create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs.meta diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballReaderTests.cs b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballReaderTests.cs index 651786333..bc04c0256 100644 --- a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballReaderTests.cs +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballReaderTests.cs @@ -19,6 +19,7 @@ using System.IO; using System.Linq; using System.Security.Cryptography; +using System.Text.Json; using NUnit.Framework; using OpenMcdf; @@ -230,6 +231,99 @@ public void OpenMcdfUpgradeRoundTripsVpxTables() } } + [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); @@ -259,6 +353,16 @@ 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); 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: From 9df37d011b2b4f3f04408ad0bdc854fc2e3ac113 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 10:45:42 +0200 Subject: [PATCH 04/31] io(fpt): decode MilkShape model meshes --- .../MilkShapeModelReaderTests.cs | 155 ++++++++ .../MilkShapeModelReaderTests.cs.meta | 11 + .../IO/FuturePinball/MilkShapeModelReader.cs | 358 ++++++++++++++++++ .../MilkShapeModelReader.cs.meta | 11 + 4 files changed, 535 insertions(+) create mode 100644 VisualPinball.Engine.Test/IO/FuturePinball/MilkShapeModelReaderTests.cs create mode 100644 VisualPinball.Engine.Test/IO/FuturePinball/MilkShapeModelReaderTests.cs.meta create mode 100644 VisualPinball.Engine/IO/FuturePinball/MilkShapeModelReader.cs create mode 100644 VisualPinball.Engine/IO/FuturePinball/MilkShapeModelReader.cs.meta 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/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: From 3a2c02b69d567f0141579ca2364fbe914ca907bd Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 11:06:50 +0200 Subject: [PATCH 05/31] io(fpt): generate procedural table meshes --- .../FuturePinballGeometryTests.cs | 192 ++++++++++ .../FuturePinballGeometryTests.cs.meta | 11 + .../IO/FuturePinball/FuturePinballGeometry.cs | 332 ++++++++++++++++++ .../FuturePinballGeometry.cs.meta | 11 + .../FuturePinballRecordReader.cs | 29 +- 5 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs create mode 100644 VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs.meta create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs.meta diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs new file mode 100644 index 000000000..f77ddb207 --- /dev/null +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs @@ -0,0 +1,192 @@ +// 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[] { 0 }); + + var converted = FuturePinballCoordinateConverter.ModelMeshToWorld(source); + + Assert.That(converted.Vertices[0].X, Is.EqualTo(0.1f)); + Assert.That(converted.Vertices[0].Y, Is.EqualTo(0.3f)); + Assert.That(converted.Vertices[0].Z, Is.EqualTo(-0.2f)); + Assert.That(converted.Vertices[0].Nx, Is.Zero); + Assert.That(converted.Vertices[0].Ny, Is.Zero); + Assert.That(converted.Vertices[0].Nz, Is.EqualTo(-1f)); + Assert.That(converted.Vertices[0].Tu, Is.EqualTo(0.25f)); + Assert.That(converted.Vertices[0].Tv, Is.EqualTo(0.8f)); + Assert.That(source.Vertices[0].X, Is.EqualTo(100f)); + } + + [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(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/IO/FuturePinball/FuturePinballGeometry.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs new file mode 100644 index 000000000..4a0e1cbb3 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs @@ -0,0 +1,332 @@ +// 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 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 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 = ToWorld(vertex.X, vertex.Y, vertex.Z); + var normal = ToWorld(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; + } + 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 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; + } + + 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 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 TopHeightTag = 0x99F2BEDD; + private const uint BottomHeightTag = 0x95F2D0DD; + private const uint HeightTag = 0xA2F8CDDD; + 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; + 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; + + 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) + { + var points = DragPoints(element); + if (points.Length < 3) return null; + var isGuideWall = element.ElementType == FuturePinballElementType.GuideWall; + var data = new SurfaceData(Name(element), points) { + HeightTop = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Float( + element, isGuideWall ? HeightTag : TopHeightTag + )), + HeightBottom = isGuideWall + ? 0f + : FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Float(element, BottomHeightTag)) + }; + 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 points = DragPoints(element); + if (points.Length < 2) return null; + var isWire = element.ElementType == FuturePinballElementType.WireRamp; + var data = new RampData(Name(element), points) { + HeightBottom = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Integer(element, StartHeightTag)), + HeightTop = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Integer(element, EndHeightTag)), + WidthBottom = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Integer(element, StartWidthTag, 40)), + WidthTop = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Integer(element, EndWidthTag, 40)), + LeftWallHeight = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Integer(element, LeftSideHeightTag)), + RightWallHeight = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Integer(element, RightSideHeightTag)), + LeftWallHeightVisible = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Integer(element, LeftSideHeightTag)), + RightWallHeightVisible = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Integer(element, RightSideHeightTag)), + RampType = isWire ? RampType.RampType2Wire : RampType.RampTypeFlat, + WireDiameter = FuturePinballCoordinateConverter.ToVpx(6f), + WireDistanceX = FuturePinballCoordinateConverter.ToVpx(30f) + }; + 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 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 FuturePinballGeneratedElement Generated( + FuturePinballSourceStream element, + IReadOnlyList meshes, + uint textureTag, + uint 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), + 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/FuturePinballRecordReader.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballRecordReader.cs index a047dc21d..fed4ea6a0 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballRecordReader.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballRecordReader.cs @@ -407,8 +407,35 @@ private static Dictionary CreateElementDescr Add(result, 0xA300C5DC, "texture", FuturePinballValueKind.String); Add(result, 0x97F5C3E2, "color", FuturePinballValueKind.Color); Add(result, 0xA8EDC3D3, "rotation", FuturePinballValueKind.Integer); - Add(result, 0xA2F8CDDD, "height", 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; From 2855ed94fa6990e0626647d9d605442ac3c41cd7 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 11:13:54 +0200 Subject: [PATCH 06/31] io(fpt): map Future Pinball materials --- .../FuturePinballMaterialTests.cs | 79 +++++++ .../FuturePinballMaterialTests.cs.meta | 11 + .../IO/FuturePinball/FuturePinballGeometry.cs | 3 + .../IO/FuturePinball/FuturePinballMaterial.cs | 213 ++++++++++++++++++ .../FuturePinballMaterial.cs.meta | 11 + 5 files changed, 317 insertions(+) create mode 100644 VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballMaterialTests.cs create mode 100644 VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballMaterialTests.cs.meta create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballMaterial.cs create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballMaterial.cs.meta 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/IO/FuturePinball/FuturePinballGeometry.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs index 4a0e1cbb3..c2bc4a353 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs @@ -60,6 +60,7 @@ public sealed class FuturePinballGeneratedElement 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(); } @@ -313,6 +314,7 @@ private static FuturePinballGeneratedElement Generated( uint textureTag, uint colorTag) { + var material = FuturePinballMaterialConverter.FromElement(element, textureTag, colorTag); return new FuturePinballGeneratedElement { SourceIndex = element.SourceIndex ?? -1, Name = Name(element), @@ -320,6 +322,7 @@ private static FuturePinballGeneratedElement Generated( 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 }; } 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: From 596e3f34e6d3fe7d7cc2203c1fd6864e4de3a87b Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 11:24:07 +0200 Subject: [PATCH 07/31] physics(fpt): convert model collision shapes --- .../FuturePinballColliderTests.cs | 94 +++++++ .../FuturePinballColliderTests.cs.meta | 11 + .../FuturePinballGeometryTests.cs | 16 +- .../IO/FuturePinball/FuturePinballCollider.cs | 245 ++++++++++++++++++ .../FuturePinballCollider.cs.meta | 11 + .../IO/FuturePinball/FuturePinballFormat.cs | 30 +++ .../IO/FuturePinball/FuturePinballGeometry.cs | 18 +- 7 files changed, 417 insertions(+), 8 deletions(-) create mode 100644 VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballColliderTests.cs create mode 100644 VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballColliderTests.cs.meta create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs.meta diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballColliderTests.cs b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballColliderTests.cs new file mode 100644 index 000000000..2221e90fe --- /dev/null +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballColliderTests.cs @@ -0,0 +1,94 @@ +// 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)); + } + } +} 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/FuturePinballGeometryTests.cs b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs index f77ddb207..221a242f3 100644 --- a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs @@ -47,20 +47,24 @@ public void NativeAndDirectScalePathsProduceSameWorldPosition() public void ConvertsModelAxesScaleNormalsAndTextureV() { var source = new Mesh(new[] { - new Vertex3DNoTex2(100f, 200f, 300f, 0f, 1f, 0f, 0.25f, 0.2f) - }, new[] { 0 }); + 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.3f)); - Assert.That(converted.Vertices[0].Z, Is.EqualTo(-0.2f)); + 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.Zero); - Assert.That(converted.Vertices[0].Nz, Is.EqualTo(-1f)); + 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] diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs new file mode 100644 index 000000000..94ede0de7 --- /dev/null +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs @@ -0,0 +1,245 @@ +// 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; + } + } +} 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/FuturePinballFormat.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballFormat.cs index 04cc3dfa2..45493d560 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballFormat.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballFormat.cs @@ -185,6 +185,36 @@ public sealed class FuturePinballCollisionShape 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 diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs index c2bc4a353..a6d2437cd 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs @@ -86,14 +86,23 @@ public static FuturePinballWorldPoint ToWorld(float x, float y, float z) ); } + 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 = ToWorld(vertex.X, vertex.Y, vertex.Z); - var normal = ToWorld(vertex.Nx, vertex.Ny, vertex.Nz); + 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; @@ -103,6 +112,11 @@ public static Mesh ModelMeshToWorld(Mesh source, bool flipTextureV = true) 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; } } From 9fd7319147ec8c10dd7ca321891f5cbdee5cbc74 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 12:08:45 +0200 Subject: [PATCH 08/31] editor(fpt): add lossless static table import --- .../IO/FuturePinball/FuturePinballGeometry.cs | 6 + .../Import/FptImportEngine.cs | 55 ++ .../Import/FptImportEngine.cs.meta | 11 + .../Import/FptImportOptions.cs | 35 ++ .../Import/FptImportOptions.cs.meta | 11 + .../Import/FptImportReport.cs | 172 ++++++ .../Import/FptImportReport.cs.meta | 11 + .../Import/FptImportWizard.cs | 104 ++++ .../Import/FptImportWizard.cs.meta | 11 + .../Import/FptSceneConverter.cs | 532 ++++++++++++++++++ .../Import/FptSceneConverter.cs.meta | 11 + .../Import/MenuImporter.cs | 32 +- .../Import/FuturePinballSourceComponent.cs | 31 + .../FuturePinballSourceComponent.cs.meta | 11 + 14 files changed, 1023 insertions(+), 10 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportEngine.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportEngine.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportOptions.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportOptions.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportWizard.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportWizard.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Import/FuturePinballSourceComponent.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Import/FuturePinballSourceComponent.cs.meta diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs index a6d2437cd..98be4f577 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs @@ -161,6 +161,12 @@ public static IReadOnlyList Points(FuturePinballSourceS return points; } + public static FuturePinballVector2 Position(FuturePinballSourceStream element, uint tag = PositionTag) + { + var record = Find(element, tag); + return record == null ? new FuturePinballVector2() : Vector2(record); + } + public static string Text(FuturePinballSourceStream stream, uint tag, string fallback = "") { var record = Find(stream, tag); 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..f42b34485 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs @@ -0,0 +1,172 @@ +// 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 ModelInstances { get; internal set; } + public int Placeholders { 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, "modelInstances", ModelInstances.ToString(CultureInfo.InvariantCulture), true, false); + Property(json, "placeholders", Placeholders.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} ({ProceduralElements} procedural, {ModelInstances} model instances, {Placeholders} placeholders)"); + text.AppendLine($"- Assets: {MeshAssets} meshes, {MaterialAssets} materials, {ReusedAssets} reused"); + text.AppendLine($"- 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..9b2872ff4 --- /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 currently creates static meshes, materials, 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 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..cdc653b32 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs @@ -0,0 +1,532 @@ +// 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 EngineMesh = VisualPinball.Engine.VPT.Mesh; +using UnityMaterial = UnityEngine.Material; +using UnityMesh = UnityEngine.Mesh; + +namespace VisualPinball.Unity.Editor +{ + internal sealed class FptSceneConverter + { + 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 readonly string _sourcePath; + private readonly string _tableName; + private readonly FptImportOptions _options; + private readonly Dictionary _textures = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _meshes = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _materials = new Dictionary(); + private readonly FptImportReport _report = new FptImportReport(); + private string _tableAssetRoot; + private string _bundleAssetRoot; + private string _meshAssetRoot; + private string _materialAssetRoot; + private FuturePinballTable _table; + private FuturePinballExtractionManifest _manifest; + + public FptSceneConverter(string sourcePath, string tableName, FptImportOptions options) + { + _sourcePath = Path.GetFullPath(sourcePath ?? throw new ArgumentNullException(nameof(sourcePath))); + _tableName = string.IsNullOrWhiteSpace(tableName) ? Path.GetFileNameWithoutExtension(sourcePath) : tableName; + _options = options ?? new FptImportOptions(); + } + + public FptImportResult Convert() + { + var timer = Stopwatch.StartNew(); + GameObject root = null; + ValidateAssetRoot(); + _tableAssetRoot = $"{_options.AssetRoot.TrimEnd('/', '\\')}/{SafeName(_tableName)}"; + _bundleAssetRoot = $"{_tableAssetRoot}/Source/FuturePinball"; + _meshAssetRoot = $"{_tableAssetRoot}/Meshes/FuturePinball"; + _materialAssetRoot = $"{_tableAssetRoot}/Materials/FuturePinball"; + EnsureAssetFolder(_bundleAssetRoot); + EnsureAssetFolder(_meshAssetRoot); + EnsureAssetFolder(_materialAssetRoot); + + _table = FuturePinballTableReader.Load(_sourcePath); + _manifest = FuturePinballExtractor.Extract(_sourcePath, PhysicalPath(_bundleAssetRoot), new FuturePinballExtractionOptions { + CopyOriginalTable = _options.CopyOriginalTable, + OverwriteChangedFiles = _options.OverwriteChangedSourceFiles, + LibrarySearchRoots = _options.LibrarySearchRoots ?? Array.Empty() + }); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + LoadTextures(); + + var rootName = _tableName + " (Future Pinball)"; + var existing = _options.ReplaceExistingSceneRoot ? GameObject.Find(rootName) : null; + try { + root = new GameObject(rootName); + var rootSource = root.AddComponent(); + rootSource.SourceFile = Path.GetFileName(_sourcePath); + rootSource.SourceHash = _manifest.SourceSha256; + rootSource.ImportOutcome = "lossless-source-bundle-and-static-scene"; + + var proceduralRoot = Child(root, "Procedural Geometry"); + var modelRoot = Child(root, "Model Instances"); + var placeholderRoot = Child(root, "Preserved Placeholders"); + var handled = new HashSet(); + CreateProceduralElements(proceduralRoot, handled); + CreateModelInstances(modelRoot, handled); + CreatePlaceholders(placeholderRoot, handled); + + _report.SourceFile = Path.GetFileName(_sourcePath); + _report.SourceSha256 = _manifest.SourceSha256; + _report.FileVersion = _manifest.FileVersion; + _report.Elements = _table.Elements.Count; + _report.UnresolvedResources = _manifest.Counts.UnresolvedLinkedResources; + _report.Warnings.AddRange(_manifest.Issues); + if (_options.GenerateColliders) { + _report.Warnings.Add("Generated colliders are static Unity authoring colliders; native VPE physics behavior remains to be recreated."); + } + _report.ElapsedMilliseconds = timer.ElapsedMilliseconds; + AssetDatabase.SaveAssets(); + _report.Write(PhysicalPath(_bundleAssetRoot), _manifest); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + if (existing != null) Undo.DestroyObjectImmediate(existing); + return new FptImportResult(root, _manifest, _report, _bundleAssetRoot); + } catch { + if (root != null) UnityEngine.Object.DestroyImmediate(root); + throw; + } + } + + private void CreateProceduralElements(GameObject parent, ISet handled) + { + var sources = _table.Elements.Where(item => item.SourceIndex.HasValue) + .GroupBy(item => item.SourceIndex.Value) + .ToDictionary(group => group.Key, group => group.First()); + foreach (var element in FuturePinballProceduralMeshBuilder.Build(_table)) { + if (!sources.TryGetValue(element.SourceIndex, out var source)) { + _report.Warnings.Add($"Procedural element '{element.Name}' has no matching source stream and was skipped."); + continue; + } + var go = Child(parent, element.Name); + AddSource(go, source, "native-procedural-static"); + for (var i = 0; i < element.Meshes.Count; i++) { + var worldMesh = element.Meshes[i].Clone().TransformToWorld(); + CreateRenderer(go, source, worldMesh, element.Material, i, true, element.IsCollidable); + } + handled.Add(element.SourceIndex); + _report.ProceduralElements++; + } + } + + private void CreateModelInstances(GameObject parent, ISet handled) + { + if (!_options.ImportPrimaryModels) return; + var referenced = new HashSet(_table.Elements.Select(element => FuturePinballElementGeometry.Text(element, ModelTag)) + .Where(name => !string.IsNullOrWhiteSpace(name)), StringComparer.OrdinalIgnoreCase); + var models = LoadModels(referenced); + var surfaceHeights = SurfaceHeights(); + foreach (var element in _table.Elements) { + var modelName = FuturePinballElementGeometry.Text(element, ModelTag); + if (string.IsNullOrWhiteSpace(modelName) || !models.TryGetValue(modelName, out var model)) continue; + var name = ElementName(element); + var go = Child(parent, name); + AddSource(go, element, "static-model"); + Place(go.transform, element, surfaceHeights); + var elementMaterial = FuturePinballMaterialConverter.FromElement(element, TextureTag, ColorTag); + var groups = model.Primary.CreateMeshes(); + for (var i = 0; i < groups.Count; i++) { + var worldMesh = FuturePinballCoordinateConverter.ModelMeshToWorld(groups[i].Mesh); + var material = groups[i].MaterialIndex >= 0 && groups[i].MaterialIndex < model.Primary.Materials.Count + ? FuturePinballMaterialConverter.FromMilkShape(model.Primary.Materials[groups[i].MaterialIndex]) + : elementMaterial; + CreateRenderer(go, element, worldMesh, material, i, false, false, $"model-{model.Primary.SourceSha256}-{i}"); + } + if (_options.GenerateColliders) CreateModelColliders(go, element, model); + handled.Add(element.SourceIndex ?? -1); + _report.ModelInstances++; + } + } + + private void CreatePlaceholders(GameObject parent, ISet handled) + { + var surfaceHeights = SurfaceHeights(); + foreach (var element in _table.Elements.Where(element => !handled.Contains(element.SourceIndex ?? -1))) { + var go = Child(parent, ElementName(element)); + AddSource(go, element, "preserved-placeholder"); + Place(go.transform, element, surfaceHeights); + _report.Placeholders++; + _report.Backlog.Add(new FptRecreationBacklogItem { + SourceIndex = element.SourceIndex ?? -1, + Name = ElementName(element), + ElementType = element.ElementType?.ToString() ?? $"Unknown({element.ElementTypeId})", + CurrentOutcome = "Source data preserved; no recreated visual behavior", + SuggestedCapability = SuggestedCapability(element.ElementType), + SourceStream = element.Name + }); + } + } + + private Dictionary LoadModels(ISet referenced) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var cache = new MilkShapeModelCache(); + foreach (var stream in _table.PinModels) { + var name = stream.Text(NameTag) ?? stream.Name; + if (!referenced.Contains(name)) continue; + try { + var source = stream; + var assets = FuturePinballModelAssetReader.ReadEmbedded(source, cache); + if (assets.Variants.Count == 0) { + var resource = _manifest.Resources.FirstOrDefault(item => item.Category == "models" && item.SourceIndex == stream.SourceIndex); + var linked = resource?.Files.FirstOrDefault(file => file.Role == "linked-model"); + if (linked != null) { + var fpm = FuturePinballModelReader.Load(PhysicalPath($"{_bundleAssetRoot}/{linked.Path}")); + source = fpm.ModelData; + assets = FuturePinballModelAssetReader.ReadEmbedded(source, cache); + } + } + var primary = assets.Variants.FirstOrDefault(variant => variant.Role == "primary_model_data")?.Model; + if (primary != null) result[name] = new LoadedModel(primary, source); + else _report.Warnings.Add($"Model '{name}' has no decoded primary mesh."); + } catch (Exception exception) { + _report.Warnings.Add($"Could not decode model '{name}': {exception.Message}"); + } + } + return result; + } + + private void CreateModelColliders(GameObject parent, FuturePinballSourceStream element, LoadedModel model) + { + var descriptions = FuturePinballColliderBuilder.FromModel(model.Source, model.Primary, new FuturePinballColliderOptions { + EnableAnalyticShapes = true, + EnablePerPolygonCollision = _options.EnablePerPolygonCollision, + GenerateRenderMeshFallback = _options.GenerateRenderMeshFallbackColliders + }); + var index = 0; + foreach (var description in descriptions) { + if (description.Status != FuturePinballColliderStatus.Generated) { + _report.Warnings.Add($"{ElementName(element)} collider: {description.Reason}"); + continue; + } + var go = Child(parent, $"Collider {index++} ({description.Kind})"); + go.transform.localPosition = new Vector3(description.Center.X, description.Center.Y, description.Center.Z); + switch (description.Kind) { + case FuturePinballColliderKind.Sphere: { + var collider = go.AddComponent(); + collider.radius = description.Radius; + break; + } + case FuturePinballColliderKind.Box: { + var collider = go.AddComponent(); + collider.size = new Vector3(description.Size.X, description.Size.Y, description.Size.Z); + break; + } + case FuturePinballColliderKind.Mesh: { + var collider = go.AddComponent(); + collider.sharedMesh = GetOrCreateMesh(description.Mesh, $"collider-{model.Primary.SourceSha256}-{index}"); + break; + } + default: { + var collider = go.AddComponent(); + collider.direction = description.Kind == FuturePinballColliderKind.VerticalCylinder ? 1 : 2; + collider.radius = Mathf.Max(description.Radius, description.SecondaryRadius); + var sourceLength = description.HalfLength * 2f; + collider.height = description.Kind == FuturePinballColliderKind.TaperedCapsule + ? sourceLength + collider.radius * 2f + : Mathf.Max(collider.radius * 2f, sourceLength); + _report.Warnings.Add($"{ElementName(element)} {description.Kind} uses a capsule approximation in Unity."); + break; + } + } + _report.Colliders++; + } + } + + private void CreateRenderer( + GameObject parent, + FuturePinballSourceStream source, + EngineMesh mesh, + FuturePinballMaterialDescription material, + int part, + bool procedural, + bool collidable, + string meshAssetKey = null) + { + if (mesh?.IsSet != true || mesh.Vertices.Length == 0 || mesh.Indices.Length == 0) return; + var go = Child(parent, $"{SafeName(mesh.Name ?? "Mesh")} {part}"); + var unityMesh = GetOrCreateMesh(mesh, meshAssetKey ?? $"{source.SourceIndex:D5}-{SafeName(ElementName(source))}-{part}"); + go.AddComponent().sharedMesh = unityMesh; + go.AddComponent().sharedMaterial = GetOrCreateMaterial(material); + if (_options.GenerateColliders && procedural && collidable) { + go.AddComponent().sharedMesh = unityMesh; + _report.Colliders++; + } + } + + private UnityMesh GetOrCreateMesh(EngineMesh source, string assetName) + { + var path = $"{_meshAssetRoot}/{SafeName(assetName)}-{MeshHash(source).Substring(0, 16)}.asset"; + if (_meshes.TryGetValue(path, out var cached)) return cached; + var existing = AssetDatabase.LoadAssetAtPath(path); + if (existing != null && _options.ReuseGeneratedAssets) { + _report.ReusedAssets++; + return _meshes[path] = existing; + } + if (existing != null) AssetDatabase.DeleteAsset(path); + var mesh = new UnityMesh { + name = assetName, + indexFormat = source.Vertices.Length > ushort.MaxValue + ? UnityEngine.Rendering.IndexFormat.UInt32 + : UnityEngine.Rendering.IndexFormat.UInt16 + }; + source.ApplyToUnityMesh(mesh); + AssetDatabase.CreateAsset(mesh, path); + _report.MeshAssets++; + return _meshes[path] = mesh; + } + + private UnityMaterial GetOrCreateMaterial(FuturePinballMaterialDescription source) + { + source ??= FuturePinballMaterialConverter.FromValues("default", 2, 0xffffffff); + var key = $"{source.Category}|{source.SourceColor}|{source.Opacity:R}|{source.Roughness:R}|{source.IsCrystal}|{source.IsSphereMapped}|{source.IsTwoSided}|{source.IsEmissive}|{source.Texture}"; + var hash = Sha256(key).Substring(0, 16); + if (_materials.TryGetValue(hash, out var cached)) return cached; + var path = $"{_materialAssetRoot}/{SafeName(source.Name)}-{hash}.mat"; + var existing = AssetDatabase.LoadAssetAtPath(path); + if (existing != null && _options.ReuseGeneratedAssets) { + _report.ReusedAssets++; + return _materials[hash] = existing; + } + if (existing != null) AssetDatabase.DeleteAsset(path); + + var pbr = new PbrMaterial(source.ToVpeMaterial(), id: hash); + var material = RenderPipeline.Current?.MaterialConverter?.CreateMaterial(pbr, null) + ?? new UnityMaterial(Shader.Find("Standard")); + material.name = source.Name; + if (!string.IsNullOrWhiteSpace(source.Texture) && _textures.TryGetValue(source.Texture, out var texture)) { + SetTexture(material, texture); + } + if (source.IsTwoSided) { + SetFloat(material, "_DoubleSidedEnable", 1f); + SetFloat(material, "_CullMode", 0f); + SetFloat(material, "_Cull", 0f); + } + if (source.IsEmissive) { + var color = source.ToVpeMaterial().BaseColor.ToUnityColor(); + if (material.HasProperty("_EmissiveColor")) material.SetColor("_EmissiveColor", color); + if (material.HasProperty("_EmissionColor")) material.SetColor("_EmissionColor", color); + material.EnableKeyword("_EMISSION"); + } + AssetDatabase.CreateAsset(material, path); + _report.MaterialAssets++; + return _materials[hash] = material; + } + + private void LoadTextures() + { + foreach (var resource in _manifest.Resources.Where(resource => resource.Category == "images")) { + var file = resource.Files.FirstOrDefault(item => item.Role == "original"); + if (file == null) continue; + var texture = AssetDatabase.LoadAssetAtPath($"{_bundleAssetRoot}/{file.Path}"); + if (texture == null) continue; + AddTextureAlias(resource.LogicalName, texture); + AddTextureAlias(Path.GetFileName(resource.LogicalName), texture); + AddTextureAlias(Path.GetFileNameWithoutExtension(resource.LogicalName), texture); + } + } + + private void AddTextureAlias(string name, Texture2D texture) + { + if (!string.IsNullOrWhiteSpace(name)) _textures[name] = texture; + } + + private Dictionary SurfaceHeights() + { + return _table.Elements.Where(element => element.ElementType == FuturePinballElementType.Surface) + .GroupBy(ElementName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => FuturePinballElementGeometry.Float(group.First(), SurfaceTopHeightTag), StringComparer.OrdinalIgnoreCase); + } + + private static void Place(Transform transform, FuturePinballSourceStream element, IReadOnlyDictionary surfaceHeights) + { + var position = FuturePinballElementGeometry.Position(element); + var surface = FuturePinballElementGeometry.Text(element, SurfaceTag); + var height = !string.IsNullOrWhiteSpace(surface) && surfaceHeights.TryGetValue(surface, out var surfaceHeight) ? surfaceHeight : 0f; + height += FuturePinballElementGeometry.Integer(element, HeightTag); + var world = FuturePinballCoordinateConverter.ToWorld(position.X, position.Y, height); + transform.localPosition = new Vector3(world.X, world.Y, world.Z); + transform.localRotation = Quaternion.Euler(0f, -90f - FuturePinballElementGeometry.Integer(element, RotationTag), 0f); + } + + private void AddSource(GameObject go, FuturePinballSourceStream source, string outcome) + { + var component = go.AddComponent(); + component.SourceFile = Path.GetFileName(_sourcePath); + component.SourceHash = _manifest.SourceSha256; + component.SourceStream = source.Name; + component.SourceIndex = source.SourceIndex ?? -1; + component.ElementType = source.ElementType?.ToString() ?? $"Unknown({source.ElementTypeId})"; + component.ImportOutcome = outcome; + } + + private static string SuggestedCapability(FuturePinballElementType? type) + { + switch (type) { + case FuturePinballElementType.RoundLight: + case FuturePinballElementType.ShapeableLight: + case FuturePinballElementType.Flasher: + case FuturePinballElementType.Bulb: + return "recreate lamp state and light-list behavior"; + case FuturePinballElementType.Dmd: + case FuturePinballElementType.HudDmd: + case FuturePinballElementType.DispReel: + case FuturePinballElementType.HudReel: + return "recreate script-driven display output"; + case FuturePinballElementType.Timer: + case FuturePinballElementType.LightSequencer: + return "translate runtime behavior from the preserved table script"; + default: + return "map to a native VPE component or static fallback"; + } + } + + private static string ElementName(FuturePinballSourceStream element) + { + return FuturePinballElementGeometry.Text(element, NameTag, element.Name); + } + + private void ValidateAssetRoot() + { + _options.AssetRoot = (_options.AssetRoot ?? string.Empty).Replace('\\', '/').TrimEnd('/'); + if (_options.AssetRoot != "Assets" && !_options.AssetRoot.StartsWith("Assets/", StringComparison.Ordinal)) { + throw new ArgumentException("Future Pinball import asset root must be inside Assets."); + } + if (_options.AssetRoot.Split('/').Any(part => part.Length == 0 || part == "." || part == "..")) { + throw new ArgumentException("Future Pinball import asset root contains an invalid path segment."); + } + } + + private static GameObject Child(GameObject parent, string name) + { + var child = new GameObject(name); + child.transform.SetParent(parent.transform, false); + return child; + } + + private static void SetTexture(UnityMaterial material, UnityEngine.Texture texture) + { + foreach (var property in new[] { "_BaseColorMap", "_BaseMap", "_MainTex" }) { + if (material.HasProperty(property)) material.SetTexture(property, texture); + } + } + + private static void SetFloat(UnityMaterial material, string property, float value) + { + if (material.HasProperty(property)) material.SetFloat(property, value); + } + + private static void EnsureAssetFolder(string assetPath) + { + var parts = assetPath.Replace('\\', '/').Split('/'); + var current = parts[0]; + for (var i = 1; i < parts.Length; i++) { + var next = current + "/" + parts[i]; + if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(current, parts[i]); + current = next; + } + } + + private static string PhysicalPath(string assetPath) + { + var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? throw new InvalidOperationException("Unity project root not found."); + return Path.GetFullPath(Path.Combine(projectRoot, assetPath.Replace('/', Path.DirectorySeparatorChar))); + } + + private static string SafeName(string value) + { + if (string.IsNullOrWhiteSpace(value)) return "unnamed"; + var invalid = new HashSet("<>:\"/\\|?*"); + var result = new string(value.Select(character => character < 0x20 || invalid.Contains(character) ? '_' : character).ToArray()).Trim().TrimEnd('.', ' '); + return string.IsNullOrEmpty(result) ? "unnamed" : result.Length > 80 ? result.Substring(0, 80) : result; + } + + private static string Sha256(string value) + { + using (var sha = SHA256.Create()) return BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(value))).Replace("-", string.Empty).ToLowerInvariant(); + } + + private static string MeshHash(EngineMesh mesh) + { + using (var sha = SHA256.Create()) + using (var output = new CryptoStream(Stream.Null, sha, CryptoStreamMode.Write)) + using (var writer = new BinaryWriter(output)) { + foreach (var vertex in mesh.Vertices) { + writer.Write(vertex.X); writer.Write(vertex.Y); writer.Write(vertex.Z); + writer.Write(vertex.Nx); writer.Write(vertex.Ny); writer.Write(vertex.Nz); + writer.Write(vertex.Tu); writer.Write(vertex.Tv); + } + foreach (var index in mesh.Indices) writer.Write(index); + writer.Flush(); + output.FlushFinalBlock(); + return BitConverter.ToString(sha.Hash).Replace("-", string.Empty).ToLowerInvariant(); + } + } + + private sealed class LoadedModel + { + public MilkShapeModel Primary { get; } + public FuturePinballSourceStream Source { get; } + + public LoadedModel(MilkShapeModel primary, FuturePinballSourceStream source) + { + Primary = primary; + Source = source; + } + } + } + + public sealed class FptImportResult + { + public GameObject Root { get; } + public FuturePinballExtractionManifest Manifest { get; } + public FptImportReport Report { get; } + public string BundleAssetPath { get; } + + internal FptImportResult(GameObject root, FuturePinballExtractionManifest manifest, FptImportReport report, string bundleAssetPath) + { + Root = root; + Manifest = manifest; + Report = report; + BundleAssetPath = bundleAssetPath; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs.meta new file mode 100644 index 000000000..a3ea54b5b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a184c8cf376c4fb096246ed343e52b67 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/MenuImporter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/MenuImporter.cs index 802df0692..4f76a252e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/MenuImporter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/MenuImporter.cs @@ -36,7 +36,7 @@ public static async void ImportVpeIntoScene(MenuCommand menuCommand) } // open file dialog - var path = EditorUtility.OpenFilePanelWithFilters("Import", null, new[] { "Pinball Tables", "vpe,vpx" }); + var path = EditorUtility.OpenFilePanelWithFilters("Import", null, new[] { "Pinball Tables", "vpe,vpx,fpt" }); if (path.Length == 0) { return; } @@ -52,8 +52,12 @@ public static async void ImportVpeIntoScene(MenuCommand menuCommand) await importer.ImportIntoScene(Path.GetFileNameWithoutExtension(path)); break; } + case ".fpt": { + FptImportWizard.Open(path); + break; + } default: - EditorUtility.DisplayDialog("Import", "Unsupported file format. Only .vpe and .vpx files are currently supported.", "OK"); + EditorUtility.DisplayDialog("Import", "Unsupported file format. Supported formats are .vpe, .vpx, and .fpt.", "OK"); break; } } @@ -70,13 +74,21 @@ public static void ImportVpxWithFilters(MenuCommand menuCommand) return; } - VpxImportWizardSettings.VpxPath = path; - VpxImportWizardSettings.TableName = Path.GetFileNameWithoutExtension(path); - VpxImportWizardSettings.ObjectImportFilter = VpxObjectImportFilter.CollidableOnly; - VpxImportWizardSettings.ImportTextures = false; - VpxImportWizardSettings.ImportSounds = false; - VpxImportWizard.Init(); - } + VpxImportWizardSettings.VpxPath = path; + VpxImportWizardSettings.TableName = Path.GetFileNameWithoutExtension(path); + VpxImportWizardSettings.ObjectImportFilter = VpxObjectImportFilter.CollidableOnly; + VpxImportWizardSettings.ImportTextures = false; + VpxImportWizardSettings.ImportSounds = false; + VpxImportWizard.Init(); + } + + [MenuItem("Pinball/Import Future Pinball Table", false, 3)] + public static void ImportFuturePinballTable() + { + if (!EnsureUntitledSceneHasBeenSaved()) return; + var path = EditorUtility.OpenFilePanel("Import Future Pinball Table", null, "fpt"); + if (!string.IsNullOrEmpty(path)) FptImportWizard.Open(path); + } private static bool EnsureUntitledSceneHasBeenSaved() { @@ -98,4 +110,4 @@ private static bool EnsureUntitledSceneHasBeenSaved() return true; } } -} +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Import/FuturePinballSourceComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Import/FuturePinballSourceComponent.cs new file mode 100644 index 000000000..ab332bbf2 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Import/FuturePinballSourceComponent.cs @@ -0,0 +1,31 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using UnityEngine; + +namespace VisualPinball.Unity +{ + [AddComponentMenu("Pinball/Import/Future Pinball Source")] + public class FuturePinballSourceComponent : MonoBehaviour + { + public string SourceFile; + public string SourceHash; + public string SourceStream; + public int SourceIndex = -1; + public string ElementType; + public string ImportOutcome; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Import/FuturePinballSourceComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Import/FuturePinballSourceComponent.cs.meta new file mode 100644 index 000000000..7c9592418 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Import/FuturePinballSourceComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 081c267ff13a405c8d9000f30c8e5755 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 50e934228b464c82c4d49a78d34234c9e982c4c4 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 13:27:24 +0200 Subject: [PATCH 09/31] physics(fpt): use native VPE colliders --- .../FuturePinballColliderTests.cs | 76 +++++++ .../IO/FuturePinball/FuturePinballCollider.cs | 189 ++++++++++++++++++ .../Import/FptImportReport.cs | 2 +- .../Import/FptImportWizard.cs | 4 +- .../Import/FptSceneConverter.cs | 143 +++++++++---- 5 files changed, 376 insertions(+), 38 deletions(-) diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballColliderTests.cs b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballColliderTests.cs index 2221e90fe..488fa7340 100644 --- a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballColliderTests.cs +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballColliderTests.cs @@ -90,5 +90,81 @@ public void BuildsPerPolygonMeshAndRejectsDegenerateTriangles() 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/IO/FuturePinball/FuturePinballCollider.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs index 94ede0de7..ba62b2187 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs @@ -242,4 +242,193 @@ private static float TriangleAreaSquared(Vertex3DNoTex2 a, Vertex3DNoTex2 b, Ver 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.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs index f42b34485..32ef54792 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs @@ -87,7 +87,7 @@ private string Markdown(FuturePinballExtractionManifest manifest) text.AppendLine($"- Manifest schema: `{manifest.SchemaVersion}`"); text.AppendLine($"- Elements: {Elements} ({ProceduralElements} procedural, {ModelInstances} model instances, {Placeholders} placeholders)"); text.AppendLine($"- Assets: {MeshAssets} meshes, {MaterialAssets} materials, {ReusedAssets} reused"); - text.AppendLine($"- Colliders: {Colliders}"); + text.AppendLine($"- VPE colliders: {Colliders}"); text.AppendLine($"- Unresolved resources: {UnresolvedResources}"); text.AppendLine($"- Elapsed: {ElapsedMilliseconds} ms"); text.AppendLine().AppendLine("## Extracted media").AppendLine(); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportWizard.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportWizard.cs index 9b2872ff4..deb37af30 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportWizard.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportWizard.cs @@ -46,7 +46,7 @@ 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 currently creates static meshes, materials, colliders, and placeholders; it does not execute Future Pinball scripts.", MessageType.Info); + 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); @@ -73,7 +73,7 @@ private void OnGUI() EditorGUILayout.Space(); EditorGUILayout.LabelField("Recreation", EditorStyles.boldLabel); _options.ImportPrimaryModels = EditorGUILayout.Toggle("Import Primary Models", _options.ImportPrimaryModels); - _options.GenerateColliders = EditorGUILayout.Toggle("Generate Colliders", _options.GenerateColliders); + _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); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs index cdc653b32..cbc4a4a71 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs @@ -27,6 +27,8 @@ using VisualPinball.Engine.IO.FuturePinball; using VisualPinball.Engine.VPT; +using VisualPinball.Engine.VPT.Table; +using VisualPinball.Unity.Simulation; using EngineMesh = VisualPinball.Engine.VPT.Mesh; using UnityMaterial = UnityEngine.Material; @@ -44,6 +46,11 @@ internal sealed class FptSceneConverter 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; @@ -51,6 +58,7 @@ internal sealed class FptSceneConverter private readonly Dictionary _textures = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _meshes = new Dictionary(StringComparer.Ordinal); private readonly Dictionary _materials = new Dictionary(); + private readonly HashSet _reportedTessellatedColliderKinds = new HashSet(); private readonly FptImportReport _report = new FptImportReport(); private string _tableAssetRoot; private string _bundleAssetRoot; @@ -92,14 +100,15 @@ public FptImportResult Convert() var existing = _options.ReplaceExistingSceneRoot ? GameObject.Find(rootName) : null; try { root = new GameObject(rootName); + var playfield = CreateVpeHierarchy(root); var rootSource = root.AddComponent(); rootSource.SourceFile = Path.GetFileName(_sourcePath); rootSource.SourceHash = _manifest.SourceSha256; - rootSource.ImportOutcome = "lossless-source-bundle-and-static-scene"; + rootSource.ImportOutcome = "lossless-source-bundle-and-vpe-static-scene"; - var proceduralRoot = Child(root, "Procedural Geometry"); - var modelRoot = Child(root, "Model Instances"); - var placeholderRoot = Child(root, "Preserved Placeholders"); + var proceduralRoot = Child(playfield, "Procedural Geometry"); + var modelRoot = Child(playfield, "Model Instances"); + var placeholderRoot = Child(playfield, "Preserved Placeholders"); var handled = new HashSet(); CreateProceduralElements(proceduralRoot, handled); CreateModelInstances(modelRoot, handled); @@ -112,7 +121,7 @@ public FptImportResult Convert() _report.UnresolvedResources = _manifest.Counts.UnresolvedLinkedResources; _report.Warnings.AddRange(_manifest.Issues); if (_options.GenerateColliders) { - _report.Warnings.Add("Generated colliders are static Unity authoring colliders; native VPE physics behavior remains to be recreated."); + _report.Warnings.Add("Generated collision uses VPE PlayfieldColliderComponent and PrimitiveColliderComponent; no Unity/PhysX colliders are created."); } _report.ElapsedMilliseconds = timer.ElapsedMilliseconds; AssetDatabase.SaveAssets(); @@ -234,38 +243,26 @@ private void CreateModelColliders(GameObject parent, FuturePinballSourceStream e var index = 0; foreach (var description in descriptions) { if (description.Status != FuturePinballColliderStatus.Generated) { - _report.Warnings.Add($"{ElementName(element)} collider: {description.Reason}"); + AddColliderBacklog(element, description.Reason ?? $"{description.Kind} collider was not generated"); continue; } - var go = Child(parent, $"Collider {index++} ({description.Kind})"); + var colliderIndex = index++; + var colliderMesh = FuturePinballColliderMeshBuilder.Build(description); + if (colliderMesh?.IsSet != true || colliderMesh.Indices.Length == 0) { + AddColliderBacklog(element, $"{description.Kind} has no VPE-compatible triangle mesh"); + continue; + } + var go = Child(parent, $"VPE Collider {colliderIndex} ({description.Kind})"); go.transform.localPosition = new Vector3(description.Center.X, description.Center.Y, description.Center.Z); - switch (description.Kind) { - case FuturePinballColliderKind.Sphere: { - var collider = go.AddComponent(); - collider.radius = description.Radius; - break; - } - case FuturePinballColliderKind.Box: { - var collider = go.AddComponent(); - collider.size = new Vector3(description.Size.X, description.Size.Y, description.Size.Z); - break; - } - case FuturePinballColliderKind.Mesh: { - var collider = go.AddComponent(); - collider.sharedMesh = GetOrCreateMesh(description.Mesh, $"collider-{model.Primary.SourceSha256}-{index}"); - break; - } - default: { - var collider = go.AddComponent(); - collider.direction = description.Kind == FuturePinballColliderKind.VerticalCylinder ? 1 : 2; - collider.radius = Mathf.Max(description.Radius, description.SecondaryRadius); - var sourceLength = description.HalfLength * 2f; - collider.height = description.Kind == FuturePinballColliderKind.TaperedCapsule - ? sourceLength + collider.radius * 2f - : Mathf.Max(collider.radius * 2f, sourceLength); - _report.Warnings.Add($"{ElementName(element)} {description.Kind} uses a capsule approximation in Unity."); - break; - } + var unityMesh = GetOrCreateMesh(colliderMesh, + $"vpe-collider-{model.Primary.SourceSha256}-{colliderIndex}-{description.Kind}"); + AddVpePrimitiveCollider(go, unityMesh, description.GenerateHitEvent, false); + if (FuturePinballColliderMeshBuilder.IsTessellatedApproximation(description.Kind) + && _reportedTessellatedColliderKinds.Add(description.Kind)) { + _report.Warnings.Add($"{description.Kind} collision is tessellated into a VPE primitive mesh; curved-source geometry is approximate."); + } + if (description.GenerateHitEvent && description.EventId != 0) { + _report.Warnings.Add($"{ElementName(element)} collider event {description.EventId} maps to a generic VPE primitive hit event; Future Pinball script dispatch remains unimplemented."); } _report.Colliders++; } @@ -287,11 +284,24 @@ private void CreateRenderer( go.AddComponent().sharedMesh = unityMesh; go.AddComponent().sharedMaterial = GetOrCreateMaterial(material); if (_options.GenerateColliders && procedural && collidable) { - go.AddComponent().sharedMesh = unityMesh; + AddVpePrimitiveCollider(go, unityMesh, false, true); _report.Colliders++; } } + private static void AddVpePrimitiveCollider(GameObject go, UnityMesh mesh, bool hitEvent, bool visible) + { + var meshFilter = go.GetComponent() ?? go.AddComponent(); + meshFilter.sharedMesh = mesh; + go.AddComponent(); + var meshComponent = go.AddComponent(); + meshComponent.UseLegacyMesh = false; + meshComponent.enabled = visible; + var collider = go.AddComponent(); + collider.HitEvent = hitEvent; + collider.enabled = true; + } + private UnityMesh GetOrCreateMesh(EngineMesh source, string assetName) { var path = $"{_meshAssetRoot}/{SafeName(assetName)}-{MeshHash(source).Substring(0, 16)}.asset"; @@ -351,6 +361,69 @@ private UnityMaterial GetOrCreateMaterial(FuturePinballMaterialDescription sourc return _materials[hash] = material; } + private GameObject CreateVpeHierarchy(GameObject root) + { + var tableData = CreateTableData(); + var tableComponent = root.AddComponent(); + tableComponent.SetData(tableData); + root.AddComponent(); + root.AddComponent(); + + var playfield = Child(root, "Playfield"); + var physicsEngine = playfield.AddComponent(); + SimulationThreadComponent.EnsureFor(physicsEngine); + var playfieldComponent = playfield.AddComponent(); + if (_options.GenerateColliders) { + playfield.AddComponent(); + _report.Colliders++; + } + playfieldComponent.SetData(tableData); + playfieldComponent.RenderSlope = tableData.AngleTiltMin; + return playfield; + } + + private TableData CreateTableData() + { + const int defaultWidthMillimeters = 514; + const int defaultLengthMillimeters = 1168; + const int defaultGlassHeightMillimeters = 216; + var width = FuturePinballElementGeometry.Integer(_table.TableData, TableWidthTag, defaultWidthMillimeters); + var length = FuturePinballElementGeometry.Integer(_table.TableData, TableLengthTag, defaultLengthMillimeters); + var frontGlass = FuturePinballElementGeometry.Integer(_table.TableData, FrontGlassHeightTag, defaultGlassHeightMillimeters); + var rearGlass = FuturePinballElementGeometry.Integer(_table.TableData, RearGlassHeightTag, defaultGlassHeightMillimeters); + var slope = FuturePinballElementGeometry.Float(_table.TableData, SlopeTag, 6f); + if (width <= 0) width = defaultWidthMillimeters; + if (length <= 0) length = defaultLengthMillimeters; + if (frontGlass <= 0) frontGlass = defaultGlassHeightMillimeters; + if (rearGlass <= 0) rearGlass = defaultGlassHeightMillimeters; + if (float.IsNaN(slope) || float.IsInfinity(slope) || slope < 0f || slope > 20f) slope = 6f; + return new TableData { + Name = _tableName, + Left = 0f, + Right = FuturePinballCoordinateConverter.ToVpx(width), + Top = 0f, + Bottom = FuturePinballCoordinateConverter.ToVpx(length), + GlassHeight = FuturePinballCoordinateConverter.ToVpx(System.Math.Max(frontGlass, rearGlass)), + AngleTiltMin = slope, + AngleTiltMax = slope + }; + } + + private void AddColliderBacklog(FuturePinballSourceStream element, string reason) + { + if (reason == "The source shape does not affect the ball") return; + var message = $"{ElementName(element)} collider: {reason}"; + _report.Warnings.Add(message); + _report.Backlog.Add(new FptRecreationBacklogItem { + SourceIndex = element.SourceIndex ?? -1, + Name = ElementName(element), + ElementType = element.ElementType?.ToString() ?? $"Unknown({element.ElementTypeId})", + CurrentOutcome = $"Collision source preserved but not converted: {reason}", + SuggestedCapability = "map the preserved collision record to a supported VPE collider", + SourceStream = element.Name + }); + } + private void LoadTextures() { foreach (var resource in _manifest.Resources.Where(resource => resource.Category == "images")) { From 0f431cd6460d9a2cc8daba6d5aad6d0a5c9c7a0c Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 13:40:48 +0200 Subject: [PATCH 10/31] editor(fpt): handle duplicate source names --- .../Import/FptSceneConverter.cs | 58 ++++++++++++++++--- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs index cbc4a4a71..027f992a9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs @@ -56,6 +56,9 @@ internal sealed class FptSceneConverter 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 HashSet _reportedTessellatedColliderKinds = new HashSet(); @@ -66,6 +69,7 @@ internal sealed class FptSceneConverter private string _materialAssetRoot; private FuturePinballTable _table; private FuturePinballExtractionManifest _manifest; + private Dictionary _surfaceHeights; public FptSceneConverter(string sourcePath, string tableName, FptImportOptions options) { @@ -342,7 +346,7 @@ private UnityMaterial GetOrCreateMaterial(FuturePinballMaterialDescription sourc var material = RenderPipeline.Current?.MaterialConverter?.CreateMaterial(pbr, null) ?? new UnityMaterial(Shader.Find("Standard")); material.name = source.Name; - if (!string.IsNullOrWhiteSpace(source.Texture) && _textures.TryGetValue(source.Texture, out var texture)) { + if (!string.IsNullOrWhiteSpace(source.Texture) && TryGetTexture(source.Texture, out var texture)) { SetTexture(material, texture); } if (source.IsTwoSided) { @@ -431,22 +435,58 @@ private void LoadTextures() if (file == null) continue; var texture = AssetDatabase.LoadAssetAtPath($"{_bundleAssetRoot}/{file.Path}"); if (texture == null) continue; - AddTextureAlias(resource.LogicalName, texture); - AddTextureAlias(Path.GetFileName(resource.LogicalName), texture); - AddTextureAlias(Path.GetFileNameWithoutExtension(resource.LogicalName), texture); + var logicalName = resource.LogicalName; + if (string.IsNullOrWhiteSpace(logicalName)) continue; + if (_textures.ContainsKey(logicalName)) { + _report.Warnings.Add($"Duplicate image name '{logicalName}'; the later source stream is used for exact-name references."); + } + _textures[logicalName] = texture; + AddTextureAlias(Path.GetFileName(logicalName), logicalName, texture); + AddTextureAlias(Path.GetFileNameWithoutExtension(logicalName), logicalName, texture); + } + } + + private void AddTextureAlias(string alias, string logicalName, Texture2D texture) + { + if (string.IsNullOrWhiteSpace(alias) || alias.Equals(logicalName, StringComparison.OrdinalIgnoreCase) + || _ambiguousTextureAliases.Contains(alias)) return; + if (_textureAliasSources.TryGetValue(alias, out var existingSource) + && !existingSource.Equals(logicalName, StringComparison.OrdinalIgnoreCase)) { + _textureAliases.Remove(alias); + _textureAliasSources.Remove(alias); + _ambiguousTextureAliases.Add(alias); + _report.Warnings.Add($"Image alias '{alias}' is ambiguous between '{existingSource}' and '{logicalName}' and will not be used."); + return; } + _textureAliases[alias] = texture; + _textureAliasSources[alias] = logicalName; } - private void AddTextureAlias(string name, Texture2D texture) + private bool TryGetTexture(string name, out Texture2D texture) { - if (!string.IsNullOrWhiteSpace(name)) _textures[name] = texture; + if (_textures.TryGetValue(name, out texture)) return true; + if (_ambiguousTextureAliases.Contains(name)) { + texture = null; + return false; + } + return _textureAliases.TryGetValue(name, out texture); } private Dictionary SurfaceHeights() { - return _table.Elements.Where(element => element.ElementType == FuturePinballElementType.Surface) - .GroupBy(ElementName, StringComparer.OrdinalIgnoreCase) - .ToDictionary(group => group.Key, group => FuturePinballElementGeometry.Float(group.First(), SurfaceTopHeightTag), StringComparer.OrdinalIgnoreCase); + if (_surfaceHeights != null) return _surfaceHeights; + _surfaceHeights = new Dictionary(StringComparer.OrdinalIgnoreCase); + var groups = _table.Elements.Where(element => element.ElementType == FuturePinballElementType.Surface) + .GroupBy(ElementName, StringComparer.OrdinalIgnoreCase); + foreach (var group in groups) { + var surfaces = group.ToArray(); + var heights = surfaces.Select(surface => FuturePinballElementGeometry.Float(surface, SurfaceTopHeightTag)).ToArray(); + if (heights.Distinct().Skip(1).Any()) { + _report.Warnings.Add($"Duplicate surface name '{group.Key}' has different top heights; the later source stream is used for name-based placement."); + } + _surfaceHeights[group.Key] = heights[heights.Length - 1]; + } + return _surfaceHeights; } private static void Place(Transform transform, FuturePinballSourceStream element, IReadOnlyDictionary surfaceHeights) From 122b0cd7fccfceb717f2e1135c629acd39148763 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 13:54:45 +0200 Subject: [PATCH 11/31] editor(fpt): disambiguate surface placement --- .../FuturePinballGeometryTests.cs | 3 + .../IO/FuturePinball/FuturePinballGeometry.cs | 32 ++++++ .../Import/FptSceneConverter.cs | 100 ++++++++++++++---- 3 files changed, 115 insertions(+), 20 deletions(-) diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs index 221a242f3..605190aed 100644 --- a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs @@ -80,6 +80,9 @@ public void BuildsSurfaceFromPointRecords() 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")); diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs index 98be4f577..d0abb6dd5 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs @@ -167,6 +167,21 @@ public static FuturePinballVector2 Position(FuturePinballSourceStream element, u return record == null ? new FuturePinballVector2() : Vector2(record); } + 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); @@ -213,6 +228,23 @@ private static FuturePinballVector2 Vector2(FuturePinballRecord record) : 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; diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs index 027f992a9..7b451d2fc 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs @@ -62,6 +62,7 @@ internal sealed class FptSceneConverter private readonly Dictionary _meshes = new Dictionary(StringComparer.Ordinal); private readonly Dictionary _materials = new Dictionary(); private readonly HashSet _reportedTessellatedColliderKinds = new HashSet(); + private readonly HashSet _reportedSurfacePlacementIssues = new HashSet(StringComparer.Ordinal); private readonly FptImportReport _report = new FptImportReport(); private string _tableAssetRoot; private string _bundleAssetRoot; @@ -69,7 +70,7 @@ internal sealed class FptSceneConverter private string _materialAssetRoot; private FuturePinballTable _table; private FuturePinballExtractionManifest _manifest; - private Dictionary _surfaceHeights; + private Dictionary _surfacesByName; public FptSceneConverter(string sourcePath, string tableName, FptImportOptions options) { @@ -166,14 +167,13 @@ private void CreateModelInstances(GameObject parent, ISet handled) var referenced = new HashSet(_table.Elements.Select(element => FuturePinballElementGeometry.Text(element, ModelTag)) .Where(name => !string.IsNullOrWhiteSpace(name)), StringComparer.OrdinalIgnoreCase); var models = LoadModels(referenced); - var surfaceHeights = SurfaceHeights(); foreach (var element in _table.Elements) { var modelName = FuturePinballElementGeometry.Text(element, ModelTag); if (string.IsNullOrWhiteSpace(modelName) || !models.TryGetValue(modelName, out var model)) continue; var name = ElementName(element); var go = Child(parent, name); AddSource(go, element, "static-model"); - Place(go.transform, element, surfaceHeights); + Place(go.transform, element); var elementMaterial = FuturePinballMaterialConverter.FromElement(element, TextureTag, ColorTag); var groups = model.Primary.CreateMeshes(); for (var i = 0; i < groups.Count; i++) { @@ -191,11 +191,10 @@ private void CreateModelInstances(GameObject parent, ISet handled) private void CreatePlaceholders(GameObject parent, ISet handled) { - var surfaceHeights = SurfaceHeights(); foreach (var element in _table.Elements.Where(element => !handled.Contains(element.SourceIndex ?? -1))) { var go = Child(parent, ElementName(element)); AddSource(go, element, "preserved-placeholder"); - Place(go.transform, element, surfaceHeights); + Place(go.transform, element); _report.Placeholders++; _report.Backlog.Add(new FptRecreationBacklogItem { SourceIndex = element.SourceIndex ?? -1, @@ -472,28 +471,89 @@ private bool TryGetTexture(string name, out Texture2D texture) return _textureAliases.TryGetValue(name, out texture); } - private Dictionary SurfaceHeights() + private float ResolveSurfaceHeight(FuturePinballSourceStream element, FuturePinballVector2 position) { - if (_surfaceHeights != null) return _surfaceHeights; - _surfaceHeights = new Dictionary(StringComparer.OrdinalIgnoreCase); - var groups = _table.Elements.Where(element => element.ElementType == FuturePinballElementType.Surface) - .GroupBy(ElementName, StringComparer.OrdinalIgnoreCase); - foreach (var group in groups) { - var surfaces = group.ToArray(); - var heights = surfaces.Select(surface => FuturePinballElementGeometry.Float(surface, SurfaceTopHeightTag)).ToArray(); - if (heights.Distinct().Skip(1).Any()) { - _report.Warnings.Add($"Duplicate surface name '{group.Key}' has different top heights; the later source stream is used for name-based placement."); + var surfaceName = FuturePinballElementGeometry.Text(element, SurfaceTag); + if (string.IsNullOrWhiteSpace(surfaceName)) return 0f; + return ResolveSurfaceHeight(surfaceName, position, element, new HashSet(StringComparer.OrdinalIgnoreCase)); + } + + private float ResolveSurfaceHeight( + string surfaceName, + FuturePinballVector2 position, + FuturePinballSourceStream placedElement, + ISet resolving) + { + if (!resolving.Add(surfaceName)) { + AddSurfacePlacementBacklog(placedElement, surfaceName, "surface reference cycle"); + return 0f; + } + try { + var candidates = SurfacesByName().TryGetValue(surfaceName, out var named) ? named : Array.Empty(); + if (candidates.Length == 0) { + AddSurfacePlacementBacklog(placedElement, surfaceName, "referenced surface was not found"); + return 0f; + } + var heights = candidates.Select(candidate => CandidateSurfaceHeight(candidate, position, placedElement, resolving)).ToArray(); + if (heights.All(height => System.Math.Abs(height - heights[0]) < 0.0001f)) return heights[0]; + var containing = candidates.Select((candidate, index) => new { Candidate = candidate, Height = heights[index] }) + .Where(item => FuturePinballElementGeometry.ContainsPoint(item.Candidate, position)).ToArray(); + if (containing.Length == 1) return containing[0].Height; + if (containing.Length > 1 && containing.All(item => System.Math.Abs(item.Height - containing[0].Height) < 0.0001f)) { + return containing[0].Height; } - _surfaceHeights[group.Key] = heights[heights.Length - 1]; + AddSurfacePlacementBacklog(placedElement, surfaceName, + containing.Length == 0 ? "duplicate surfaces could not be disambiguated by element position" : "element position lies on multiple surfaces with different heights"); + return 0f; + } finally { + resolving.Remove(surfaceName); } - return _surfaceHeights; } - private static void Place(Transform transform, FuturePinballSourceStream element, IReadOnlyDictionary surfaceHeights) + private float CandidateSurfaceHeight( + FuturePinballSourceStream candidate, + FuturePinballVector2 position, + FuturePinballSourceStream placedElement, + ISet resolving) + { + if (candidate.ElementType == FuturePinballElementType.Surface) { + return FuturePinballElementGeometry.Float(candidate, SurfaceTopHeightTag); + } + var height = FuturePinballElementGeometry.Float(candidate, HeightTag); + var parentSurface = FuturePinballElementGeometry.Text(candidate, SurfaceTag); + return string.IsNullOrWhiteSpace(parentSurface) + ? height + : height + ResolveSurfaceHeight(parentSurface, position, placedElement, resolving); + } + + private Dictionary SurfacesByName() + { + return _surfacesByName ??= _table.Elements + .Where(element => element.ElementType == FuturePinballElementType.Surface + || element.ElementType == FuturePinballElementType.GuideWall) + .GroupBy(ElementName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.OrdinalIgnoreCase); + } + + private void AddSurfacePlacementBacklog(FuturePinballSourceStream element, string surfaceName, string reason) + { + var issueKey = $"{element.SourceIndex}:{element.Name}:{surfaceName}:{reason}"; + if (!_reportedSurfacePlacementIssues.Add(issueKey)) return; + _report.Warnings.Add($"{ElementName(element)} surface '{surfaceName}': {reason}; the unresolved height contribution was treated as zero."); + _report.Backlog.Add(new FptRecreationBacklogItem { + SourceIndex = element.SourceIndex ?? -1, + Name = ElementName(element), + ElementType = element.ElementType?.ToString() ?? $"Unknown({element.ElementTypeId})", + CurrentOutcome = $"An unresolved surface-height contribution was treated as zero: {reason}", + SuggestedCapability = "resolve the preserved surface reference manually", + SourceStream = element.Name + }); + } + + private void Place(Transform transform, FuturePinballSourceStream element) { var position = FuturePinballElementGeometry.Position(element); - var surface = FuturePinballElementGeometry.Text(element, SurfaceTag); - var height = !string.IsNullOrWhiteSpace(surface) && surfaceHeights.TryGetValue(surface, out var surfaceHeight) ? surfaceHeight : 0f; + var height = ResolveSurfaceHeight(element, position); height += FuturePinballElementGeometry.Integer(element, HeightTag); var world = FuturePinballCoordinateConverter.ToWorld(position.X, position.Y, height); transform.localPosition = new Vector3(world.X, world.Y, world.Z); From 02e776923e0a1a3cdfd1989898e941a317030097 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Jul 2026 14:10:33 +0200 Subject: [PATCH 12/31] editor(fpt): skip unresolved surface placement --- .../Import/FptSceneConverter.cs | 103 +++++++++++------- 1 file changed, 66 insertions(+), 37 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs index 7b451d2fc..3cc948df5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs @@ -114,7 +114,7 @@ public FptImportResult Convert() var proceduralRoot = Child(playfield, "Procedural Geometry"); var modelRoot = Child(playfield, "Model Instances"); var placeholderRoot = Child(playfield, "Preserved Placeholders"); - var handled = new HashSet(); + var handled = new HashSet(); CreateProceduralElements(proceduralRoot, handled); CreateModelInstances(modelRoot, handled); CreatePlaceholders(placeholderRoot, handled); @@ -140,7 +140,7 @@ public FptImportResult Convert() } } - private void CreateProceduralElements(GameObject parent, ISet handled) + private void CreateProceduralElements(GameObject parent, ISet handled) { var sources = _table.Elements.Where(item => item.SourceIndex.HasValue) .GroupBy(item => item.SourceIndex.Value) @@ -156,12 +156,12 @@ private void CreateProceduralElements(GameObject parent, ISet handled) var worldMesh = element.Meshes[i].Clone().TransformToWorld(); CreateRenderer(go, source, worldMesh, element.Material, i, true, element.IsCollidable); } - handled.Add(element.SourceIndex); + handled.Add(source); _report.ProceduralElements++; } } - private void CreateModelInstances(GameObject parent, ISet handled) + private void CreateModelInstances(GameObject parent, ISet handled) { if (!_options.ImportPrimaryModels) return; var referenced = new HashSet(_table.Elements.Select(element => FuturePinballElementGeometry.Text(element, ModelTag)) @@ -170,10 +170,15 @@ private void CreateModelInstances(GameObject parent, ISet handled) foreach (var element in _table.Elements) { var modelName = FuturePinballElementGeometry.Text(element, ModelTag); if (string.IsNullOrWhiteSpace(modelName) || !models.TryGetValue(modelName, out var model)) continue; + if (!TryGetPlacement(element, out var position, out var rotation)) { + handled.Add(element); + continue; + } var name = ElementName(element); var go = Child(parent, name); AddSource(go, element, "static-model"); - Place(go.transform, element); + go.transform.localPosition = position; + go.transform.localRotation = rotation; var elementMaterial = FuturePinballMaterialConverter.FromElement(element, TextureTag, ColorTag); var groups = model.Primary.CreateMeshes(); for (var i = 0; i < groups.Count; i++) { @@ -184,17 +189,19 @@ private void CreateModelInstances(GameObject parent, ISet handled) CreateRenderer(go, element, worldMesh, material, i, false, false, $"model-{model.Primary.SourceSha256}-{i}"); } if (_options.GenerateColliders) CreateModelColliders(go, element, model); - handled.Add(element.SourceIndex ?? -1); + handled.Add(element); _report.ModelInstances++; } } - private void CreatePlaceholders(GameObject parent, ISet handled) + private void CreatePlaceholders(GameObject parent, ISet handled) { - foreach (var element in _table.Elements.Where(element => !handled.Contains(element.SourceIndex ?? -1))) { + foreach (var element in _table.Elements.Where(element => !handled.Contains(element))) { + if (!TryGetPlacement(element, out var position, out var rotation)) continue; var go = Child(parent, ElementName(element)); AddSource(go, element, "preserved-placeholder"); - Place(go.transform, element); + go.transform.localPosition = position; + go.transform.localRotation = rotation; _report.Placeholders++; _report.Backlog.Add(new FptRecreationBacklogItem { SourceIndex = element.SourceIndex ?? -1, @@ -471,59 +478,76 @@ private bool TryGetTexture(string name, out Texture2D texture) return _textureAliases.TryGetValue(name, out texture); } - private float ResolveSurfaceHeight(FuturePinballSourceStream element, FuturePinballVector2 position) + private bool TryResolveSurfaceHeight(FuturePinballSourceStream element, FuturePinballVector2 position, out float height) { var surfaceName = FuturePinballElementGeometry.Text(element, SurfaceTag); - if (string.IsNullOrWhiteSpace(surfaceName)) return 0f; - return ResolveSurfaceHeight(surfaceName, position, element, new HashSet(StringComparer.OrdinalIgnoreCase)); + if (string.IsNullOrWhiteSpace(surfaceName)) { + height = 0f; + return true; + } + return TryResolveSurfaceHeight(surfaceName, position, element, + new HashSet(StringComparer.OrdinalIgnoreCase), out height); } - private float ResolveSurfaceHeight( + private bool TryResolveSurfaceHeight( string surfaceName, FuturePinballVector2 position, FuturePinballSourceStream placedElement, - ISet resolving) + ISet resolving, + out float height) { + height = 0f; if (!resolving.Add(surfaceName)) { AddSurfacePlacementBacklog(placedElement, surfaceName, "surface reference cycle"); - return 0f; + return false; } try { var candidates = SurfacesByName().TryGetValue(surfaceName, out var named) ? named : Array.Empty(); if (candidates.Length == 0) { AddSurfacePlacementBacklog(placedElement, surfaceName, "referenced surface was not found"); - return 0f; + return false; + } + if (candidates.Length == 1) { + return TryCandidateSurfaceHeight(candidates[0], position, placedElement, resolving, out height); + } + var containing = candidates.Where(candidate => FuturePinballElementGeometry.ContainsPoint(candidate, position)).ToArray(); + var selected = containing.Length > 0 ? containing : candidates; + var heights = new float[selected.Length]; + for (var i = 0; i < selected.Length; i++) { + if (!TryCandidateSurfaceHeight(selected[i], position, placedElement, resolving, out heights[i])) return false; } - var heights = candidates.Select(candidate => CandidateSurfaceHeight(candidate, position, placedElement, resolving)).ToArray(); - if (heights.All(height => System.Math.Abs(height - heights[0]) < 0.0001f)) return heights[0]; - var containing = candidates.Select((candidate, index) => new { Candidate = candidate, Height = heights[index] }) - .Where(item => FuturePinballElementGeometry.ContainsPoint(item.Candidate, position)).ToArray(); - if (containing.Length == 1) return containing[0].Height; - if (containing.Length > 1 && containing.All(item => System.Math.Abs(item.Height - containing[0].Height) < 0.0001f)) { - return containing[0].Height; + if (containing.Length == 1 || heights.All(candidateHeight => System.Math.Abs(candidateHeight - heights[0]) < 0.0001f)) { + height = heights[0]; + return true; } AddSurfacePlacementBacklog(placedElement, surfaceName, containing.Length == 0 ? "duplicate surfaces could not be disambiguated by element position" : "element position lies on multiple surfaces with different heights"); - return 0f; + return false; } finally { resolving.Remove(surfaceName); } } - private float CandidateSurfaceHeight( + private bool TryCandidateSurfaceHeight( FuturePinballSourceStream candidate, FuturePinballVector2 position, FuturePinballSourceStream placedElement, - ISet resolving) + ISet resolving, + out float height) { if (candidate.ElementType == FuturePinballElementType.Surface) { - return FuturePinballElementGeometry.Float(candidate, SurfaceTopHeightTag); + height = FuturePinballElementGeometry.Float(candidate, SurfaceTopHeightTag); + return true; } - var height = FuturePinballElementGeometry.Float(candidate, HeightTag); + height = FuturePinballElementGeometry.Float(candidate, HeightTag); var parentSurface = FuturePinballElementGeometry.Text(candidate, SurfaceTag); - return string.IsNullOrWhiteSpace(parentSurface) - ? height - : height + ResolveSurfaceHeight(parentSurface, position, placedElement, resolving); + if (string.IsNullOrWhiteSpace(parentSurface)) return true; + if (!TryResolveSurfaceHeight(parentSurface, position, placedElement, resolving, out var parentHeight)) { + height = 0f; + return false; + } + height += parentHeight; + return true; } private Dictionary SurfacesByName() @@ -539,25 +563,30 @@ private void AddSurfacePlacementBacklog(FuturePinballSourceStream element, strin { var issueKey = $"{element.SourceIndex}:{element.Name}:{surfaceName}:{reason}"; if (!_reportedSurfacePlacementIssues.Add(issueKey)) return; - _report.Warnings.Add($"{ElementName(element)} surface '{surfaceName}': {reason}; the unresolved height contribution was treated as zero."); + _report.Warnings.Add($"{ElementName(element)} surface '{surfaceName}': {reason}; scene placement was skipped."); _report.Backlog.Add(new FptRecreationBacklogItem { SourceIndex = element.SourceIndex ?? -1, Name = ElementName(element), ElementType = element.ElementType?.ToString() ?? $"Unknown({element.ElementTypeId})", - CurrentOutcome = $"An unresolved surface-height contribution was treated as zero: {reason}", + CurrentOutcome = $"Scene placement was skipped because the surface height is unresolved: {reason}", SuggestedCapability = "resolve the preserved surface reference manually", SourceStream = element.Name }); } - private void Place(Transform transform, FuturePinballSourceStream element) + private bool TryGetPlacement(FuturePinballSourceStream element, out Vector3 worldPosition, out Quaternion worldRotation) { var position = FuturePinballElementGeometry.Position(element); - var height = ResolveSurfaceHeight(element, position); + if (!TryResolveSurfaceHeight(element, position, out var height)) { + worldPosition = default; + worldRotation = default; + return false; + } height += FuturePinballElementGeometry.Integer(element, HeightTag); var world = FuturePinballCoordinateConverter.ToWorld(position.X, position.Y, height); - transform.localPosition = new Vector3(world.X, world.Y, world.Z); - transform.localRotation = Quaternion.Euler(0f, -90f - FuturePinballElementGeometry.Integer(element, RotationTag), 0f); + worldPosition = new Vector3(world.X, world.Y, world.Z); + worldRotation = Quaternion.Euler(0f, -90f - FuturePinballElementGeometry.Integer(element, RotationTag), 0f); + return true; } private void AddSource(GameObject go, FuturePinballSourceStream source, string outcome) From 0dc17c97213b0e6236844d28813f29e2e7a83a84 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 14 Jul 2026 07:21:03 +0200 Subject: [PATCH 13/31] editor(fpt): map native table elements --- .../FuturePinballNativeItemTests.cs | 284 ++++++++++++ .../IO/FuturePinball/FuturePinballGeometry.cs | 65 +-- .../FuturePinball/FuturePinballNativeItems.cs | 416 ++++++++++++++++++ .../FuturePinballNativeItems.cs.meta | 2 + .../FuturePinballRecordReader.cs | 20 + .../Common/AssemblyInfo.cs | 3 + .../Common/AssemblyInfo.cs.meta | 2 + .../Import/FptImportReport.cs | 5 +- .../Import/FptSceneConverter.cs | 231 +++++++++- .../VPT/LightTests.cs | 22 + 10 files changed, 997 insertions(+), 53 deletions(-) create mode 100644 VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballNativeItemTests.cs create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs create mode 100644 VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Common/AssemblyInfo.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Common/AssemblyInfo.cs.meta 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/IO/FuturePinball/FuturePinballGeometry.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs index d0abb6dd5..f7460fec8 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs @@ -167,6 +167,18 @@ public static FuturePinballVector2 Position(FuturePinballSourceStream element, u 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(); @@ -209,6 +221,11 @@ public static uint Color(FuturePinballSourceStream stream, uint tag, uint fallba 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)); @@ -266,21 +283,12 @@ private static float ReadSingle(ReadOnlySpan data) public static class FuturePinballProceduralMeshBuilder { private const uint NameTag = 0xA4F4D1D7; - private const uint TopHeightTag = 0x99F2BEDD; - private const uint BottomHeightTag = 0x95F2D0DD; - private const uint HeightTag = 0xA2F8CDDD; 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; - 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; public static IReadOnlyList Build(FuturePinballTable table) { @@ -307,17 +315,9 @@ public static IReadOnlyList Build(FuturePinballTa private static FuturePinballGeneratedElement Surface(FuturePinballSourceStream element, float tableWidth, float tableLength) { - var points = DragPoints(element); - if (points.Length < 3) return null; - var isGuideWall = element.ElementType == FuturePinballElementType.GuideWall; - var data = new SurfaceData(Name(element), points) { - HeightTop = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Float( - element, isGuideWall ? HeightTag : TopHeightTag - )), - HeightBottom = isGuideWall - ? 0f - : FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Float(element, BottomHeightTag)) - }; + 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), @@ -328,22 +328,10 @@ private static FuturePinballGeneratedElement Surface(FuturePinballSourceStream e private static FuturePinballGeneratedElement Ramp(FuturePinballSourceStream element, float tableWidth, float tableLength) { - var points = DragPoints(element); - if (points.Length < 2) return null; var isWire = element.ElementType == FuturePinballElementType.WireRamp; - var data = new RampData(Name(element), points) { - HeightBottom = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Integer(element, StartHeightTag)), - HeightTop = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Integer(element, EndHeightTag)), - WidthBottom = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Integer(element, StartWidthTag, 40)), - WidthTop = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Integer(element, EndWidthTag, 40)), - LeftWallHeight = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Integer(element, LeftSideHeightTag)), - RightWallHeight = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Integer(element, RightSideHeightTag)), - LeftWallHeightVisible = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Integer(element, LeftSideHeightTag)), - RightWallHeightVisible = FuturePinballCoordinateConverter.ToVpx(FuturePinballElementGeometry.Integer(element, RightSideHeightTag)), - RampType = isWire ? RampType.RampType2Wire : RampType.RampTypeFlat, - WireDiameter = FuturePinballCoordinateConverter.ToVpx(6f), - WireDistanceX = FuturePinballCoordinateConverter.ToVpx(30f) - }; + 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 } @@ -353,13 +341,6 @@ private static FuturePinballGeneratedElement Ramp(FuturePinballSourceStream elem return Generated(element, meshes, TextureTag, ColorTag); } - 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 FuturePinballGeneratedElement Generated( FuturePinballSourceStream element, IReadOnlyList meshes, 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 index fed4ea6a0..a326d46bd 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballRecordReader.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballRecordReader.cs @@ -407,6 +407,26 @@ private static Dictionary CreateElementDescr 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); 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/FptImportReport.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs index 32ef54792..ed38dd691 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs @@ -32,6 +32,7 @@ public sealed class FptImportReport 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 MeshAssets { get; internal set; } @@ -62,6 +63,7 @@ private string Json(FuturePinballExtractionManifest manifest) 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, "meshAssets", MeshAssets.ToString(CultureInfo.InvariantCulture), true, false); @@ -85,7 +87,8 @@ private string Markdown(FuturePinballExtractionManifest manifest) text.AppendLine($"- SHA-256: `{SourceSha256}`"); text.AppendLine($"- FPT version: `{FileVersion?.ToString() ?? "unknown"}`"); text.AppendLine($"- Manifest schema: `{manifest.SchemaVersion}`"); - text.AppendLine($"- Elements: {Elements} ({ProceduralElements} procedural, {ModelInstances} model instances, {Placeholders} placeholders)"); + text.AppendLine($"- Elements: {Elements}"); + text.AppendLine($"- Recreation passes (overlapping): {NativeElements} native VPE counterparts, {ProceduralElements} procedural visuals, {ModelInstances} model instances, {Placeholders} placeholders"); text.AppendLine($"- Assets: {MeshAssets} meshes, {MaterialAssets} materials, {ReusedAssets} reused"); text.AppendLine($"- VPE colliders: {Colliders}"); text.AppendLine($"- Unresolved resources: {UnresolvedResources}"); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs index 3cc948df5..be7f5724a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs @@ -27,16 +27,33 @@ 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 + internal sealed class FptSceneConverter : IMaterialProvider, ITextureProvider { private const uint NameTag = 0xA4F4D1D7; private const uint ModelTag = 0x9DFDC3D8; @@ -61,8 +78,12 @@ internal sealed class FptSceneConverter 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 Dictionary _nativeObjects = new Dictionary(); + private readonly Dictionary _nativePrefabs = new Dictionary(); private readonly FptImportReport _report = new FptImportReport(); private string _tableAssetRoot; private string _bundleAssetRoot; @@ -70,6 +91,7 @@ internal sealed class FptSceneConverter private string _materialAssetRoot; private FuturePinballTable _table; private FuturePinballExtractionManifest _manifest; + private Table _nativeTable; private Dictionary _surfacesByName; public FptSceneConverter(string sourcePath, string tableName, FptImportOptions options) @@ -109,15 +131,18 @@ public FptImportResult Convert() var rootSource = root.AddComponent(); rootSource.SourceFile = Path.GetFileName(_sourcePath); rootSource.SourceHash = _manifest.SourceSha256; - rootSource.ImportOutcome = "lossless-source-bundle-and-vpe-static-scene"; + rootSource.ImportOutcome = "lossless-source-bundle-and-vpe-native-scene"; var proceduralRoot = Child(playfield, "Procedural Geometry"); + var nativeRoot = Child(playfield, "Native Elements"); var modelRoot = Child(playfield, "Model Instances"); var placeholderRoot = Child(playfield, "Preserved Placeholders"); var handled = new HashSet(); + CreateNativeElements(nativeRoot, handled); CreateProceduralElements(proceduralRoot, handled); CreateModelInstances(modelRoot, handled); CreatePlaceholders(placeholderRoot, handled); + PersistNativeElements(); _report.SourceFile = Path.GetFileName(_sourcePath); _report.SourceSha256 = _manifest.SourceSha256; @@ -126,7 +151,7 @@ public FptImportResult Convert() _report.UnresolvedResources = _manifest.Counts.UnresolvedLinkedResources; _report.Warnings.AddRange(_manifest.Issues); if (_options.GenerateColliders) { - _report.Warnings.Add("Generated collision uses VPE PlayfieldColliderComponent and PrimitiveColliderComponent; no Unity/PhysX colliders are created."); + _report.Warnings.Add("Generated collision uses native VPE collider components; no Unity/PhysX colliders are created."); } _report.ElapsedMilliseconds = timer.ElapsedMilliseconds; AssetDatabase.SaveAssets(); @@ -140,6 +165,88 @@ public FptImportResult Convert() } } + private void CreateNativeElements(GameObject parent, ISet handled) + { + foreach (var element in _table.Elements) { + if (!FuturePinballNativeItemConverter.TryConvert(element, out var converted)) continue; + var sourcePosition = FuturePinballElementGeometry.SurfaceProbePosition(element); + if (!TryResolveSurfaceHeight(element, sourcePosition, out var supportHeight)) { + continue; + } + var prefab = InstantiateNativePrefab(converted.Item); + prefab.GameObject.transform.SetParent(parent.transform, false); + prefab.SetData(); + SetReferencedData(prefab); + if (supportHeight != 0f) { + prefab.GameObject.transform.localPosition += Vector3.up * FuturePinballCoordinateConverter.ToWorld(0f, 0f, supportHeight).Y; + } + var nativeColliders = prefab.GameObject.GetComponentsInChildren(true) + .Where(component => component is ICollidableComponent).ToArray(); + foreach (var nativeCollider in nativeColliders) { + if (!_options.GenerateColliders) nativeCollider.enabled = false; + else if (nativeCollider.enabled) _report.Colliders++; + } + AddSource(prefab.GameObject, element, "native-vpe-counterpart"); + _nativeObjects[element] = prefab.GameObject; + _nativePrefabs[element] = prefab; + handled.Add(element); + _report.NativeElements++; + if (converted.DefaultedParameters.Count > 0 && element.ElementType.HasValue + && _reportedNativeDefaults.Add(element.ElementType.Value)) { + _report.Warnings.Add($"{element.ElementType.Value} maps to its native VPE counterpart; {string.Join(", ", converted.DefaultedParameters)} retain VPE defaults or remain unrecreated because FP units or behavior are not equivalent."); + } + } + } + + private void SetReferencedData(IVpxPrefab prefab) + { + var previousSkipSurfaceParenting = ImportContext.SkipSurfaceParenting; + try { + // FP support heights are resolved explicitly, including duplicate names and nested guide walls. + ImportContext.SkipSurfaceParenting = true; + prefab.SetReferencedData(_nativeTable, this, this, null); + } finally { + ImportContext.SkipSurfaceParenting = previousSkipSurfaceParenting; + } + } + + private void PersistNativeElements() + { + foreach (var prefab in _nativePrefabs.Values) { + prefab.PersistData(); + foreach (var transform in prefab.GameObject.GetComponentsInChildren(true)) { + EditorUtility.SetDirty(transform.gameObject); + PrefabUtility.RecordPrefabInstancePropertyModifications(transform.gameObject); + foreach (var component in transform.GetComponents()) { + if (!component) continue; + EditorUtility.SetDirty(component); + PrefabUtility.RecordPrefabInstancePropertyModifications(component); + } + } + prefab.FreeBinaryData(); + } + } + + private IVpxPrefab InstantiateNativePrefab(IItem item) + { + switch (item) { + case Bumper bumper: return bumper.InstantiatePrefab(); + case Flipper flipper: return flipper.InstantiatePrefab(); + case Gate gate: return gate.InstantiatePrefab(); + case HitTarget target: return target.InstantiatePrefab(); + case Kicker kicker: return kicker.InstantiatePrefab(); + case EngineLight light: return light.InstantiatePrefab(_nativeTable); + case MetalWireGuide wireGuide: return wireGuide.InstantiatePrefab(); + case Plunger plunger: return plunger.InstantiatePrefab(); + case Ramp ramp: return ramp.InstantiatePrefab(); + case Rubber rubber: return rubber.InstantiatePrefab(); + case Spinner spinner: return spinner.InstantiatePrefab(); + case Surface surface: return surface.InstantiatePrefab(); + case Trigger trigger: return trigger.InstantiatePrefab(); + default: throw new InvalidOperationException($"No VPE prefab is registered for {item.GetType().Name}."); + } + } + private void CreateProceduralElements(GameObject parent, ISet handled) { var sources = _table.Elements.Where(item => item.SourceIndex.HasValue) @@ -150,11 +257,21 @@ private void CreateProceduralElements(GameObject parent, ISet(); tableComponent.SetData(tableData); root.AddComponent(); @@ -409,6 +529,7 @@ private TableData CreateTableData() if (float.IsNaN(slope) || float.IsInfinity(slope) || slope < 0f || slope > 20f) slope = 6f; return new TableData { Name = _tableName, + Image = FuturePinballNativeItemConverter.PlayfieldImage, Left = 0f, Right = FuturePinballCoordinateConverter.ToVpx(width), Top = 0f, @@ -563,12 +684,12 @@ private void AddSurfacePlacementBacklog(FuturePinballSourceStream element, strin { var issueKey = $"{element.SourceIndex}:{element.Name}:{surfaceName}:{reason}"; if (!_reportedSurfacePlacementIssues.Add(issueKey)) return; - _report.Warnings.Add($"{ElementName(element)} surface '{surfaceName}': {reason}; scene placement was skipped."); + _report.Warnings.Add($"{ElementName(element)} surface '{surfaceName}': {reason}; its named support offset is unresolved."); _report.Backlog.Add(new FptRecreationBacklogItem { SourceIndex = element.SourceIndex ?? -1, Name = ElementName(element), ElementType = element.ElementType?.ToString() ?? $"Unknown({element.ElementTypeId})", - CurrentOutcome = $"Scene placement was skipped because the surface height is unresolved: {reason}", + CurrentOutcome = $"Named support offset is unresolved: {reason}", SuggestedCapability = "resolve the preserved surface reference manually", SourceStream = element.Name }); @@ -585,6 +706,8 @@ private bool TryGetPlacement(FuturePinballSourceStream element, out Vector3 worl height += FuturePinballElementGeometry.Integer(element, HeightTag); var world = FuturePinballCoordinateConverter.ToWorld(position.X, position.Y, height); worldPosition = new Vector3(world.X, world.Y, world.Z); + // FPM meshes use model Y as up, unlike table records where Z is height. This basis correction is + // model-only; native VPE item data intentionally keeps the FP table yaw unchanged. worldRotation = Quaternion.Euler(0f, -90f - FuturePinballElementGeometry.Integer(element, RotationTag), 0f); return true; } @@ -644,6 +767,76 @@ private static GameObject Child(GameObject parent, string name) return child; } + private static void DisableRenderers(GameObject root) + { + foreach (var renderer in root.GetComponentsInChildren(true)) renderer.enabled = false; + } + + public UnityEngine.Texture GetTexture(string name) + { + return !string.IsNullOrWhiteSpace(name) && TryGetTexture(name, out var texture) ? texture : null; + } + + public bool HasMaterial(PbrMaterial material) + { + var key = NativeMaterialKey(material); + if (_nativeMaterials.ContainsKey(key)) return true; + if (!_options.ReuseGeneratedAssets) return false; + var existing = AssetDatabase.LoadAssetAtPath(NativeMaterialPath(key)); + if (existing == null) return false; + _nativeMaterials[key] = existing; + _report.ReusedAssets++; + return true; + } + + public void SaveMaterial(PbrMaterial source, UnityMaterial material) + { + var key = NativeMaterialKey(source); + var path = NativeMaterialPath(key); + var existing = AssetDatabase.LoadAssetAtPath(path); + if (existing != null && _options.ReuseGeneratedAssets) { + _nativeMaterials[key] = existing; + if (material != null && !AssetDatabase.Contains(material)) UnityEngine.Object.DestroyImmediate(material); + _report.ReusedAssets++; + return; + } + if (existing != null) AssetDatabase.DeleteAsset(path); + material ??= new UnityMaterial(Shader.Find("Standard")); + material.name = $"Future Pinball Native {key.Substring(0, 8)}"; + AssetDatabase.CreateAsset(material, path); + _nativeMaterials[key] = material; + _report.MaterialAssets++; + } + + public UnityMaterial GetMaterial(PbrMaterial material) + { + if (HasMaterial(material)) return _nativeMaterials[NativeMaterialKey(material)]; + var unityMaterial = RenderPipeline.Current?.MaterialConverter?.CreateMaterial(material ?? new PbrMaterial(), this) + ?? new UnityMaterial(Shader.Find("Standard")); + SaveMaterial(material, unityMaterial); + return _nativeMaterials[NativeMaterialKey(material)]; + } + + public PhysicsMaterialAsset GetPhysicsMaterial(string name) => null; + + public UnityMaterial MergeMaterials(string vpxMaterial, UnityMaterial textureMaterial) + { + if (textureMaterial == null) return GetMaterial(new PbrMaterial(id: vpxMaterial)); + var source = new PbrMaterial(id: string.IsNullOrWhiteSpace(vpxMaterial) ? PbrMaterial.NameNoMaterial : vpxMaterial); + if (HasMaterial(source)) return _nativeMaterials[NativeMaterialKey(source)]; + var merged = RenderPipeline.Current?.MaterialConverter?.MergeMaterials(source, textureMaterial) + ?? new UnityMaterial(textureMaterial); + SaveMaterial(source, merged); + return _nativeMaterials[NativeMaterialKey(source)]; + } + + private static string NativeMaterialKey(PbrMaterial material) + { + return Sha256(material?.Id ?? PbrMaterial.NameNoMaterial); + } + + private string NativeMaterialPath(string key) => $"{_materialAssetRoot}/native-{key.Substring(0, 16)}.mat"; + private static void SetTexture(UnityMaterial material, UnityEngine.Texture texture) { foreach (var property in new[] { "_BaseColorMap", "_BaseMap", "_MainTex" }) { @@ -714,6 +907,24 @@ public LoadedModel(MilkShapeModel primary, FuturePinballSourceStream source) Source = source; } } + + private sealed class NativeTableContainer : TableContainer + { + public override Table Table { get; } + public override Dictionary TableInfo { get; } = new Dictionary(); + public override List Collections { get; } = new List(); + public override CustomInfoTags CustomInfoTags { get; } = new CustomInfoTags(); + public override IEnumerable Textures => Array.Empty(); + public override IEnumerable Sounds => Array.Empty(); + + public NativeTableContainer(TableData data) + { + Table = new Table(this, data); + } + + public override EngineMaterial GetMaterial(string name) => null; + public override EngineTexture GetTexture(string name) => null; + } } public sealed class FptImportResult diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/LightTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/LightTests.cs index 9168c3b7a..e4130dd05 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/LightTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/LightTests.cs @@ -17,11 +17,15 @@ using System.IO; using NUnit.Framework; using UnityEngine; +using VisualPinball.Engine.IO.FuturePinball; using VisualPinball.Engine.Test.Test; using VisualPinball.Engine.Test.VPT.Light; +using VisualPinball.Engine.VPT.Light; using VisualPinball.Engine.VPT.Table; using VisualPinball.Unity.Editor; +using VpeLight = VisualPinball.Engine.VPT.Light.Light; + namespace VisualPinball.Unity.Test { public class LightTests @@ -41,5 +45,23 @@ public void ShouldWriteImportedLightData() File.Delete(tmpFileName); Object.DestroyImmediate(go); } + + [Test] + public void ShouldInstantiateFuturePinballInsertLightPrefab() + { + var table = new FileTableContainer(); + table.Table.Data.Image = FuturePinballNativeItemConverter.PlayfieldImage; + var light = new VpeLight(new LightData("Future Pinball Insert", 100f, 100f) { + OffImage = FuturePinballNativeItemConverter.PlayfieldImage, + ShowBulbMesh = false + }); + + var prefab = light.InstantiatePrefab(table.Table); + try { + Assert.That(prefab.GameObject.GetComponentInChildren(true), Is.Not.Null); + } finally { + Object.DestroyImmediate(prefab.GameObject); + } + } } } From 2e321ab1153086bbdaad6910ef28b1a72757442b Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 14 Jul 2026 12:23:12 +0200 Subject: [PATCH 14/31] editor(fpt): map spinning disks --- .../Import/FptSceneConverter.cs | 132 ++++++++++++++---- .../FptSceneConverterTests.cs | 79 +++++++++++ .../FptSceneConverterTests.cs.meta | 11 ++ 3 files changed, 194 insertions(+), 28 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs index be7f5724a..d378472d5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs @@ -84,6 +84,7 @@ internal sealed class FptSceneConverter : IMaterialProvider, ITextureProvider private readonly HashSet _reportedSurfacePlacementIssues = new HashSet(StringComparer.Ordinal); private readonly Dictionary _nativeObjects = new Dictionary(); private readonly Dictionary _nativePrefabs = new Dictionary(); + private readonly Dictionary _spinningDisks = new Dictionary(); private readonly FptImportReport _report = new FptImportReport(); private string _tableAssetRoot; private string _bundleAssetRoot; @@ -168,7 +169,10 @@ public FptImportResult Convert() private void CreateNativeElements(GameObject parent, ISet handled) { foreach (var element in _table.Elements) { - if (!FuturePinballNativeItemConverter.TryConvert(element, out var converted)) continue; + if (!FuturePinballNativeItemConverter.TryConvert(element, out var converted)) { + if (element.ElementType == FuturePinballElementType.SpinningDisk) CreateSpinningDisk(parent, element, handled); + continue; + } var sourcePosition = FuturePinballElementGeometry.SurfaceProbePosition(element); if (!TryResolveSurfaceHeight(element, sourcePosition, out var supportHeight)) { continue; @@ -198,6 +202,22 @@ private void CreateNativeElements(GameObject parent, ISet handled) + { + if (!TryGetPlacement(element, out var position, out var rotation)) return; + var go = Child(parent, ElementName(element)); + go.transform.localPosition = position; + go.transform.localRotation = rotation; + var component = go.AddComponent(); + AddSource(go, element, "native-vpe-counterpart"); + _spinningDisks[element] = component; + handled.Add(element); + _report.NativeElements++; + if (_reportedNativeDefaults.Add(FuturePinballElementType.SpinningDisk)) { + _report.Warnings.Add("SpinningDisk maps to VPE's turntable; motor power and damping retain VPE defaults because the Future Pinball scales are not equivalent."); + } + } + private void SetReferencedData(IVpxPrefab prefab) { var previousSkipSurfaceParenting = ImportContext.SkipSurfaceParenting; @@ -280,37 +300,79 @@ private void CreateProceduralElements(GameObject parent, ISet handled) { - if (!_options.ImportPrimaryModels) return; - var referenced = new HashSet(_table.Elements.Select(element => FuturePinballElementGeometry.Text(element, ModelTag)) - .Where(name => !string.IsNullOrWhiteSpace(name)), StringComparer.OrdinalIgnoreCase); - var models = LoadModels(referenced); - foreach (var element in _table.Elements) { - var modelName = FuturePinballElementGeometry.Text(element, ModelTag); - if (string.IsNullOrWhiteSpace(modelName) || !models.TryGetValue(modelName, out var model)) continue; - if (!TryGetPlacement(element, out var position, out var rotation)) { + var configuredSpinningDisks = new HashSet(); + if (_options.ImportPrimaryModels) { + var referenced = new HashSet(_table.Elements.Select(element => FuturePinballElementGeometry.Text(element, ModelTag)) + .Where(name => !string.IsNullOrWhiteSpace(name)), StringComparer.OrdinalIgnoreCase); + var models = LoadModels(referenced); + foreach (var element in _table.Elements) { + var modelName = FuturePinballElementGeometry.Text(element, ModelTag); + if (string.IsNullOrWhiteSpace(modelName) || !models.TryGetValue(modelName, out var model)) continue; + if (!TryGetPlacement(element, out var position, out var rotation)) { + handled.Add(element); + continue; + } + var name = ElementName(element); + var hasNativeCounterpart = _nativeObjects.ContainsKey(element); + var isSpinningDisk = _spinningDisks.TryGetValue(element, out var turntable); + var go = Child(parent, hasNativeCounterpart || isSpinningDisk ? name + " (Source Model)" : name); + AddSource(go, element, isSpinningDisk + ? "source-model-drives-native-turntable-visual" + : hasNativeCounterpart ? "source-model-preserved-for-native-counterpart" : "static-model"); + GameObject visualRoot; + if (isSpinningDisk) { + visualRoot = CreateSpinningDiskVisualRoot(turntable, go); + } else { + go.transform.localPosition = position; + go.transform.localRotation = rotation; + visualRoot = go; + } + var elementMaterial = FuturePinballMaterialConverter.FromElement(element, TextureTag, ColorTag); + var groups = model.Primary.CreateMeshes(); + for (var i = 0; i < groups.Count; i++) { + var worldMesh = FuturePinballCoordinateConverter.ModelMeshToWorld(groups[i].Mesh); + var material = groups[i].MaterialIndex >= 0 && groups[i].MaterialIndex < model.Primary.Materials.Count + ? FuturePinballMaterialConverter.FromMilkShape(model.Primary.Materials[groups[i].MaterialIndex]) + : elementMaterial; + CreateRenderer(visualRoot, element, worldMesh, material, i, false, false, $"model-{model.Primary.SourceSha256}-{i}"); + } + if (isSpinningDisk && ConfigureSpinningDiskVisual(turntable, visualRoot)) configuredSpinningDisks.Add(element); + if (_options.GenerateColliders && !hasNativeCounterpart) CreateModelColliders(go, element, model); + if (hasNativeCounterpart) go.SetActive(false); handled.Add(element); - continue; + _report.ModelInstances++; } - var name = ElementName(element); - var hasNativeCounterpart = _nativeObjects.ContainsKey(element); - var go = Child(parent, hasNativeCounterpart ? name + " (Source Model)" : name); - AddSource(go, element, hasNativeCounterpart ? "source-model-preserved-for-native-counterpart" : "static-model"); - go.transform.localPosition = position; - go.transform.localRotation = rotation; - var elementMaterial = FuturePinballMaterialConverter.FromElement(element, TextureTag, ColorTag); - var groups = model.Primary.CreateMeshes(); - for (var i = 0; i < groups.Count; i++) { - var worldMesh = FuturePinballCoordinateConverter.ModelMeshToWorld(groups[i].Mesh); - var material = groups[i].MaterialIndex >= 0 && groups[i].MaterialIndex < model.Primary.Materials.Count - ? FuturePinballMaterialConverter.FromMilkShape(model.Primary.Materials[groups[i].MaterialIndex]) - : elementMaterial; - CreateRenderer(go, element, worldMesh, material, i, false, false, $"model-{model.Primary.SourceSha256}-{i}"); + } + foreach (var element in _spinningDisks.Keys.Where(element => !configuredSpinningDisks.Contains(element))) { + AddSpinningDiskBacklog(element); + } + } + + internal static GameObject CreateSpinningDiskVisualRoot(TurntableComponent component, GameObject modelRoot) + { + modelRoot.transform.SetParent(component.transform, false); + modelRoot.transform.localPosition = Vector3.zero; + modelRoot.transform.localRotation = Quaternion.identity; + return Child(modelRoot, "Rotating Visual"); + } + + internal static bool ConfigureSpinningDiskVisual(TurntableComponent component, GameObject visual) + { + component.RotationTarget = visual.transform; + var radiusSquared = 0f; + // FP spinning-disk primary models are treated as the playable disc; auxiliary geometry can overestimate this radius. + foreach (var filter in visual.GetComponentsInChildren(true)) { + if (filter.sharedMesh == null) continue; + foreach (var vertex in filter.sharedMesh.vertices) { + var local = visual.transform.InverseTransformPoint(filter.transform.TransformPoint(vertex)); + radiusSquared = Mathf.Max(radiusSquared, local.x * local.x + local.z * local.z); } - if (_options.GenerateColliders && !hasNativeCounterpart) CreateModelColliders(go, element, model); - if (hasNativeCounterpart) go.SetActive(false); - handled.Add(element); - _report.ModelInstances++; } + var radius = Mathf.Sqrt(radiusSquared) * 1000f; + var validRadius = !float.IsNaN(radius) && !float.IsInfinity(radius) && radius > 0f; + if (validRadius) component.Radius = radius; + EditorUtility.SetDirty(component); + return validRadius; } private void CreatePlaceholders(GameObject parent, ISet handled) @@ -555,6 +617,20 @@ private void AddColliderBacklog(FuturePinballSourceStream element, string reason }); } + private void AddSpinningDiskBacklog(FuturePinballSourceStream element) + { + var message = $"{ElementName(element)} turntable radius could not be derived because its source model was not imported or had no usable mesh; the VPE default radius is retained."; + _report.Warnings.Add(message); + _report.Backlog.Add(new FptRecreationBacklogItem { + SourceIndex = element.SourceIndex ?? -1, + Name = ElementName(element), + ElementType = FuturePinballElementType.SpinningDisk.ToString(), + CurrentOutcome = "Native VPE turntable created, but its influence radius retains the VPE default and its source visual may be unavailable", + SuggestedCapability = "import or map a usable spinning-disk source model to recover the visual and influence radius", + SourceStream = element.Name + }); + } + private void LoadTextures() { foreach (var resource in _manifest.Resources.Where(resource => resource.Category == "images")) { diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs new file mode 100644 index 000000000..9fe5da014 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs @@ -0,0 +1,79 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; + +using UnityEngine; + +using VisualPinball.Unity.Editor; + +namespace VisualPinball.Unity.Test +{ + public class FptSceneConverterTests + { + [Test] + public void ShouldAttachAndConfigureSpinningDiskFromSourceModel() + { + var modelInstances = new GameObject("Model Instances"); + var mechanism = new GameObject("Spinning Disk"); + var sourceModel = new GameObject("Source Model"); + sourceModel.transform.SetParent(modelInstances.transform, false); + sourceModel.transform.localPosition = Vector3.one; + sourceModel.transform.localRotation = Quaternion.Euler(10f, 20f, 30f); + var visual = FptSceneConverter.CreateSpinningDiskVisualRoot(mechanism.AddComponent(), sourceModel); + var meshObject = new GameObject("Mesh"); + var mesh = new Mesh { vertices = new[] { new Vector3(0.03f, 0f, 0.04f) } }; + meshObject.transform.SetParent(visual.transform, false); + meshObject.AddComponent().sharedMesh = mesh; + var component = mechanism.GetComponent(); + + try { + var configured = FptSceneConverter.ConfigureSpinningDiskVisual(component, visual); + + Assert.That(configured, Is.True); + Assert.That(sourceModel.transform.parent, Is.EqualTo(mechanism.transform)); + Assert.That(sourceModel.transform.localPosition, Is.EqualTo(Vector3.zero)); + Assert.That(sourceModel.transform.localRotation, Is.EqualTo(Quaternion.identity)); + Assert.That(visual.transform.parent, Is.EqualTo(sourceModel.transform)); + Assert.That(component.RotationTarget, Is.EqualTo(visual.transform)); + Assert.That(component.Radius, Is.EqualTo(50f).Within(0.001f)); + } finally { + Object.DestroyImmediate(mechanism); + Object.DestroyImmediate(modelInstances); + Object.DestroyImmediate(mesh); + } + } + + [Test] + public void ShouldRetainDefaultRadiusForSpinningDiskWithoutUsableMesh() + { + var mechanism = new GameObject("Spinning Disk"); + var visual = new GameObject("Rotating Visual"); + var component = mechanism.AddComponent(); + + try { + var configured = FptSceneConverter.ConfigureSpinningDiskVisual(component, visual); + + Assert.That(configured, Is.False); + Assert.That(component.RotationTarget, Is.EqualTo(visual.transform)); + Assert.That(component.Radius, Is.EqualTo(60f)); + } finally { + Object.DestroyImmediate(mechanism); + Object.DestroyImmediate(visual); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs.meta new file mode 100644 index 000000000..5d1df9f46 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8682ce0f9b924811af50cbd28a68a8f3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From e05af8655d900c100f7cf749e2dfd702456fc65a Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 14 Jul 2026 12:39:40 +0200 Subject: [PATCH 15/31] editor(fpt): clarify spinning disk defaults --- .../VisualPinball.Unity.Editor/Import/FptSceneConverter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs index d378472d5..15394c61a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs @@ -214,7 +214,7 @@ private void CreateSpinningDisk(GameObject parent, FuturePinballSourceStream ele handled.Add(element); _report.NativeElements++; if (_reportedNativeDefaults.Add(FuturePinballElementType.SpinningDisk)) { - _report.Warnings.Add("SpinningDisk maps to VPE's turntable; motor power and damping retain VPE defaults because the Future Pinball scales are not equivalent."); + _report.Warnings.Add("SpinningDisk maps to VPE's turntable; motor power has no equivalent VPE scale, and the Loose/Normal/Tight damping presets need calibration, so both retain VPE defaults."); } } From 33c547ccbc8d2ad8cb3e6140a1ecc6b01fd2f9e4 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 14 Jul 2026 12:48:55 +0200 Subject: [PATCH 16/31] editor(fpt): skip unresolved procedural placement --- .../VisualPinball.Unity.Editor/Import/FptImportReport.cs | 3 +++ .../VisualPinball.Unity.Editor/Import/FptSceneConverter.cs | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs index ed38dd691..2562970d1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportReport.cs @@ -35,6 +35,7 @@ public sealed class FptImportReport 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; } @@ -66,6 +67,7 @@ private string Json(FuturePinballExtractionManifest manifest) 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); @@ -89,6 +91,7 @@ private string Markdown(FuturePinballExtractionManifest manifest) 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}"); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs index 15394c61a..db3afab22 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs @@ -82,6 +82,7 @@ internal sealed class FptSceneConverter : IMaterialProvider, ITextureProvider private readonly HashSet _reportedTessellatedColliderKinds = new HashSet(); private readonly HashSet _reportedNativeDefaults = new HashSet(); private readonly HashSet _reportedSurfacePlacementIssues = new HashSet(StringComparer.Ordinal); + private readonly HashSet _skippedUnresolvedSupportElements = new HashSet(); private readonly Dictionary _nativeObjects = new Dictionary(); private readonly Dictionary _nativePrefabs = new Dictionary(); private readonly Dictionary _spinningDisks = new Dictionary(); @@ -279,8 +280,7 @@ private void CreateProceduralElements(GameObject parent, ISet SurfacesByName() private void AddSurfacePlacementBacklog(FuturePinballSourceStream element, string surfaceName, string reason) { + if (_skippedUnresolvedSupportElements.Add(element)) _report.SkippedUnresolvedSupport++; var issueKey = $"{element.SourceIndex}:{element.Name}:{surfaceName}:{reason}"; if (!_reportedSurfacePlacementIssues.Add(issueKey)) return; _report.Warnings.Add($"{ElementName(element)} surface '{surfaceName}': {reason}; its named support offset is unresolved."); @@ -765,7 +766,7 @@ private void AddSurfacePlacementBacklog(FuturePinballSourceStream element, strin SourceIndex = element.SourceIndex ?? -1, Name = ElementName(element), ElementType = element.ElementType?.ToString() ?? $"Unknown({element.ElementTypeId})", - CurrentOutcome = $"Named support offset is unresolved: {reason}", + CurrentOutcome = $"Scene placement skipped; named support offset is unresolved: {reason}", SuggestedCapability = "resolve the preserved surface reference manually", SourceStream = element.Name }); From 505fbc0657813d213a7f54b768afcd5e9b990bb3 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 25 Jul 2026 22:55:24 +0200 Subject: [PATCH 17/31] editor(fpt): stabilize native scene recreation --- .../FuturePinballNativeItemTests.cs | 14 +- .../FuturePinball/FuturePinballNativeItems.cs | 20 +- .../Import/FptSceneConverter.cs | 138 +++++++++++-- .../FptSceneConverterTests.cs | 192 +++++++++++++++++- 4 files changed, 341 insertions(+), 23 deletions(-) diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballNativeItemTests.cs b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballNativeItemTests.cs index c39519035..bfbf184d6 100644 --- a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballNativeItemTests.cs +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballNativeItemTests.cs @@ -43,6 +43,7 @@ public class FuturePinballNativeItemTests private const uint NameTag = 0xA4F4D1D7; private const uint PositionTag = 0x9BFCCFCF; private const uint SurfaceTag = 0xA3EFBDD2; + private const uint TextureTag = 0xA300C5DC; private const uint GlowCenterTag = 0x9BFCCFDD; [Test] @@ -57,6 +58,7 @@ public void MapsKnownValuesAndRetainsDefaultsForIncompatibleScales() 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.Image, Is.EqualTo("flipper-white-black")); 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."); @@ -75,9 +77,10 @@ public void MapsAutoPlungerOptoRubberGateAndLightSemantics() Assert.That(autoPlunger.IsMechPlunger, Is.False); var opto = Convert(table, FuturePinballElementType.OptoTrigger).Data; - Assert.That(opto.Shape, Is.EqualTo(TriggerShape.TriggerNone)); + Assert.That(opto.Shape, Is.EqualTo(TriggerShape.TriggerButton)); Assert.That(opto.IsVisible, Is.False); Assert.That(opto.Radius, Is.EqualTo(FuturePinballCoordinateConverter.ToVpx(30f))); + Assert.That(opto.DragPoints, Has.Length.EqualTo(4)); var rubber = Convert(table, FuturePinballElementType.ShapeableRubber).Data; Assert.That(rubber.DragPoints, Has.Length.EqualTo(3)); @@ -93,6 +96,11 @@ public void MapsAutoPlungerOptoRubberGateAndLightSemantics() Assert.That(light.BlinkPattern, Is.EqualTo("1010")); Assert.That(light.MeshRadius, Is.EqualTo(FuturePinballCoordinateConverter.ToVpx(10f))); Assert.That(light.DragPoints, Has.Length.EqualTo(8)); + var insertMesh = new SurfaceMeshGenerator( + new LightInsertData(light.DragPoints, 20f), + new VisualPinball.Engine.Math.Vertex3D() + ).GetMesh(SurfaceMeshGenerator.Top, 1000f, 2000f, 0f, false); + Assert.That(insertMesh, Is.Not.Null, "Generated round-light contours must use VPE's triangulatable winding."); Assert.That(light.Color.Red, Is.EqualTo(0x33)); Assert.That(light.Color.Green, Is.EqualTo(0x22)); Assert.That(light.Color.Blue, Is.EqualTo(0x11)); @@ -122,6 +130,9 @@ public void MapsSurfaceEventsAndRetainsPassiveMechanismSemantics() var trigger = Convert(table, FuturePinballElementType.Trigger).Data; Assert.That(trigger.IsVisible, Is.False); + Assert.That(trigger.DragPoints, Has.Length.EqualTo(4)); + Assert.That(trigger.DragPoints[0].Center.X, Is.EqualTo(trigger.Center.X - 50f)); + Assert.That(trigger.DragPoints[0].Center.Y, Is.EqualTo(trigger.Center.Y - 50f)); }); } @@ -207,6 +218,7 @@ void WriteTableElement(int index, FuturePinballElementType type, params byte[][] Write(storage.CreateStream("Table MAC"), new byte[16]); WriteTableElement(1, FuturePinballElementType.Flipper, Common("Left Flipper", 100f, 200f, "Upper PF"), + StringRecord(TextureTag, "flipper-white-black"), IntegerRecord(0xA900BED2, 122), IntegerRecord(0xA2EABFE4, -52), IntegerRecord(0xA1FABED2, 6), IntegerRecord(0x9700C6E0, 0)); WriteTableElement(2, FuturePinballElementType.AutoPlunger, diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs index f195ea1cb..670b0218e 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs @@ -57,6 +57,7 @@ public static class FuturePinballNativeItemConverter private const uint NameTag = 0xA4F4D1D7; private const uint SurfaceTag = 0xA3EFBDD2; + private const uint TextureTag = 0xA300C5DC; private const uint RotationTag = 0xA8EDC3D3; private const uint StartAngleTag = 0xA900BED2; private const uint SwingTag = 0xA2EABFE4; @@ -88,6 +89,7 @@ public static class FuturePinballNativeItemConverter private const uint DiameterTag = 0x9D00C9E1; private const uint GlowRadiusTag = 0x96FDD1D3; private const uint GenerateHitEventTag = 0x95EBCDDD; + private const float DefaultTriggerHalfSize = 50f; public static bool TryConvert(FuturePinballSourceStream element, out FuturePinballNativeItem converted) { @@ -220,6 +222,7 @@ private static Flipper Flipper(FuturePinballSourceStream element) var position = Position(element); var data = new FlipperData(Name(element), position.X, position.Y) { Surface = SurfaceName(element), + Image = FuturePinballElementGeometry.Text(element, TextureTag), IsReflectionEnabled = FuturePinballElementGeometry.Integer(element, ReflectsTag, 1) != 0 }; if (FuturePinballElementGeometry.HasTag(element, StartAngleTag)) { @@ -287,8 +290,9 @@ private static Trigger Trigger(FuturePinballSourceStream element, bool opto) 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 + Shape = opto ? VPT.TriggerShape.TriggerButton : VPT.TriggerShape.TriggerWireA, + IsVisible = !opto && FuturePinballElementGeometry.Integer(element, RenderModelTag, 1) != 0, + DragPoints = DefaultTriggerPoints(position) }; if (opto && FuturePinballElementGeometry.HasTag(element, WidthTag)) { data.Radius = ToVpx(FuturePinballElementGeometry.Integer(element, WidthTag)) / 2f; @@ -296,6 +300,16 @@ private static Trigger Trigger(FuturePinballSourceStream element, bool opto) return new Trigger(data); } + private static DragPointData[] DefaultTriggerPoints(Vertex2D center) + { + return new[] { + new DragPointData(center.X - DefaultTriggerHalfSize, center.Y - DefaultTriggerHalfSize), + new DragPointData(center.X - DefaultTriggerHalfSize, center.Y + DefaultTriggerHalfSize), + new DragPointData(center.X + DefaultTriggerHalfSize, center.Y + DefaultTriggerHalfSize), + new DragPointData(center.X + DefaultTriggerHalfSize, center.Y - DefaultTriggerHalfSize) + }; + } + private static Gate Gate(FuturePinballSourceStream element) { var position = Position(element); @@ -374,7 +388,7 @@ 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; + 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) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs index db3afab22..733ebcdec 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs @@ -41,6 +41,7 @@ using VisualPinball.Engine.VPT.Surface; using VisualPinball.Engine.VPT.Table; using VisualPinball.Engine.VPT.Trigger; +using VisualPinball.Unity.Playfield; using VisualPinball.Unity.Simulation; using EngineMesh = VisualPinball.Engine.VPT.Mesh; @@ -68,6 +69,7 @@ internal sealed class FptSceneConverter : IMaterialProvider, ITextureProvider private const uint FrontGlassHeightTag = 0xA1FACCD1; private const uint RearGlassHeightTag = 0xA1FAC0D1; private const uint SlopeTag = 0x9AF5BFD1; + private const uint PlayfieldTextureTag = 0xA2F4C9D5; private readonly string _sourcePath; private readonly string _tableName; @@ -313,32 +315,50 @@ private void CreateModelInstances(GameObject parent, ISet() + : null; + var usesNativeFlipperVisual = nativeFlipper != null; var isSpinningDisk = _spinningDisks.TryGetValue(element, out var turntable); + var groups = model.Primary.CreateMeshes(); + var worldMeshes = groups.Select(group => FuturePinballCoordinateConverter.ModelMeshToWorld(group.Mesh)).ToArray(); + var modelSize = Vector3.zero; + var isEnvironmentScale = element.ElementType == FuturePinballElementType.Ornament + && IsEnvironmentScaleOrnamentModel(worldMeshes, _nativeTable.Data, out modelSize); var go = Child(parent, hasNativeCounterpart || isSpinningDisk ? name + " (Source Model)" : name); - AddSource(go, element, isSpinningDisk + AddSource(go, element, isEnvironmentScale + ? "environment-scale-model-preserved-disabled" + : isSpinningDisk ? "source-model-drives-native-turntable-visual" + : usesNativeFlipperVisual + ? "source-model-drives-native-flipper-visual" : hasNativeCounterpart ? "source-model-preserved-for-native-counterpart" : "static-model"); GameObject visualRoot; if (isSpinningDisk) { visualRoot = CreateSpinningDiskVisualRoot(turntable, go); + } else if (usesNativeFlipperVisual) { + visualRoot = CreateFlipperVisualRoot(nativeFlipper, go, FlipperSourceRotation(nativeFlipper.StartAngle)); } else { go.transform.localPosition = position; go.transform.localRotation = rotation; visualRoot = go; } var elementMaterial = FuturePinballMaterialConverter.FromElement(element, TextureTag, ColorTag); - var groups = model.Primary.CreateMeshes(); for (var i = 0; i < groups.Count; i++) { - var worldMesh = FuturePinballCoordinateConverter.ModelMeshToWorld(groups[i].Mesh); var material = groups[i].MaterialIndex >= 0 && groups[i].MaterialIndex < model.Primary.Materials.Count ? FuturePinballMaterialConverter.FromMilkShape(model.Primary.Materials[groups[i].MaterialIndex]) : elementMaterial; - CreateRenderer(visualRoot, element, worldMesh, material, i, false, false, $"model-{model.Primary.SourceSha256}-{i}"); + CreateRenderer(visualRoot, element, worldMeshes[i], material, i, false, false, $"model-{model.Primary.SourceSha256}-{i}"); } if (isSpinningDisk && ConfigureSpinningDiskVisual(turntable, visualRoot)) configuredSpinningDisks.Add(element); - if (_options.GenerateColliders && !hasNativeCounterpart) CreateModelColliders(go, element, model); - if (hasNativeCounterpart) go.SetActive(false); + if (isEnvironmentScale) { + go.SetActive(false); + AddEnvironmentModelBacklog(element, modelName, modelSize); + } else if (_options.GenerateColliders && !hasNativeCounterpart) { + CreateModelColliders(go, element, model); + } + if (hasNativeCounterpart && !usesNativeFlipperVisual) go.SetActive(false); handled.Add(element); _report.ModelInstances++; } @@ -356,6 +376,25 @@ internal static GameObject CreateSpinningDiskVisualRoot(TurntableComponent compo return Child(modelRoot, "Rotating Visual"); } + internal static Quaternion FlipperSourceRotation(float startAngle) + { + // FPT flippers have no independent placement rotation. Their FPM model rests at -90° minus start_angle. + return Quaternion.Euler(0f, -90f - startAngle, 0f); + } + + internal static GameObject CreateFlipperVisualRoot(FlipperComponent component, GameObject modelRoot, Quaternion sourceRotation) + { + modelRoot.transform.localRotation = sourceRotation; + var sourceWorldRotation = modelRoot.transform.rotation; + modelRoot.transform.SetParent(component.transform, false); + modelRoot.transform.localPosition = Vector3.zero; + modelRoot.transform.rotation = sourceWorldRotation; + component.InstantiateAsPrefab = true; + // This must run before the source-model renderers are added, or they would be disabled as well. + DisableRenderers(component.gameObject); + return modelRoot; + } + internal static bool ConfigureSpinningDiskVisual(TurntableComponent component, GameObject visual) { component.RotationTarget = visual.transform; @@ -375,6 +414,32 @@ internal static bool ConfigureSpinningDiskVisual(TurntableComponent component, G return validRadius; } + internal static bool IsEnvironmentScaleOrnamentModel(IEnumerable worldMeshes, TableData tableData, out Vector3 size) + { + size = Vector3.zero; + if (worldMeshes == null || tableData == null) return false; + var min = new Vector3(float.PositiveInfinity, float.PositiveInfinity, float.PositiveInfinity); + var max = new Vector3(float.NegativeInfinity, float.NegativeInfinity, float.NegativeInfinity); + var hasVertex = false; + foreach (var mesh in worldMeshes.Where(mesh => mesh?.Vertices != null)) { + foreach (var vertex in mesh.Vertices) { + var point = new Vector3(vertex.X, vertex.Y, vertex.Z); + min = Vector3.Min(min, point); + max = Vector3.Max(max, point); + hasVertex = true; + } + } + if (!hasVertex) return false; + size = max - min; + var tableWidth = Mathf.Abs(tableData.Right - tableData.Left) + / FuturePinballCoordinateConverter.VpxUnitsPerMillimeter * FuturePinballCoordinateConverter.WorldUnitsPerMillimeter; + var tableLength = Mathf.Abs(tableData.Bottom - tableData.Top) + / FuturePinballCoordinateConverter.VpxUnitsPerMillimeter * FuturePinballCoordinateConverter.WorldUnitsPerMillimeter; + var longestTableSide = Mathf.Max(tableWidth, tableLength); + var longestHorizontalExtent = Mathf.Max(size.x, size.z); + return longestTableSide > 0f && longestHorizontalExtent > longestTableSide * 2f; + } + private void CreatePlaceholders(GameObject parent, ISet handled) { foreach (var element in _table.Elements.Where(element => !handled.Contains(element))) { @@ -480,9 +545,10 @@ private void CreateRenderer( } } - private static void AddVpePrimitiveCollider(GameObject go, UnityMesh mesh, bool hitEvent, bool visible) + internal static void AddVpePrimitiveCollider(GameObject go, UnityMesh mesh, bool hitEvent, bool visible) { - var meshFilter = go.GetComponent() ?? go.AddComponent(); + var meshFilter = go.GetComponent(); + if (!meshFilter) meshFilter = go.AddComponent(); meshFilter.sharedMesh = mesh; go.AddComponent(); var meshComponent = go.AddComponent(); @@ -555,7 +621,7 @@ private UnityMaterial GetOrCreateMaterial(FuturePinballMaterialDescription sourc private GameObject CreateVpeHierarchy(GameObject root) { var tableData = CreateTableData(); - _nativeTable = new NativeTableContainer(tableData).Table; + _nativeTable = CreateNativeTable(tableData, _textures.Keys); var tableComponent = root.AddComponent(); tableComponent.SetData(tableData); root.AddComponent(); @@ -564,16 +630,26 @@ private GameObject CreateVpeHierarchy(GameObject root) var playfield = Child(root, "Playfield"); var physicsEngine = playfield.AddComponent(); SimulationThreadComponent.EnsureFor(physicsEngine); + var playfieldComponent = AddPlayfieldComponents(playfield, tableData, _options.GenerateColliders); + if (_options.GenerateColliders) _report.Colliders++; + playfieldComponent.SetReferencedData(tableData, _nativeTable, this, this, new Dictionary()); + return playfield; + } + + internal static PlayfieldComponent AddPlayfieldComponents(GameObject playfield, TableData tableData, bool generateCollider) + { var playfieldComponent = playfield.AddComponent(); - if (_options.GenerateColliders) { - playfield.AddComponent(); - _report.Colliders++; - } + if (generateCollider) playfield.AddComponent(); + playfield.AddComponent(); + playfield.AddComponent(); playfieldComponent.SetData(tableData); playfieldComponent.RenderSlope = tableData.AngleTiltMin; - return playfield; + return playfieldComponent; } + internal static Table CreateNativeTable(TableData tableData, IEnumerable textureNames = null) + => new NativeTableContainer(tableData, textureNames).Table; + private TableData CreateTableData() { const int defaultWidthMillimeters = 514; @@ -631,6 +707,20 @@ private void AddSpinningDiskBacklog(FuturePinballSourceStream element) }); } + private void AddEnvironmentModelBacklog(FuturePinballSourceStream element, string modelName, Vector3 size) + { + var message = $"{ElementName(element)} model '{modelName}' spans {size.x:F3} x {size.y:F3} x {size.z:F3} world units and appears to be room-scale environment geometry; its scene object is preserved but disabled."; + _report.Warnings.Add(message); + _report.Backlog.Add(new FptRecreationBacklogItem { + SourceIndex = element.SourceIndex ?? -1, + Name = ElementName(element), + ElementType = element.ElementType?.ToString() ?? $"Unknown({element.ElementTypeId})", + CurrentOutcome = $"The extracted '{modelName}' model is preserved on an inactive scene object because it is much larger than the table", + SuggestedCapability = "enable or reposition the source model manually if the surrounding Future Pinball room should be recreated", + SourceStream = element.Name + }); + } + private void LoadTextures() { foreach (var resource in _manifest.Resources.Where(resource => resource.Category == "images")) { @@ -647,6 +737,12 @@ private void LoadTextures() AddTextureAlias(Path.GetFileName(logicalName), logicalName, texture); AddTextureAlias(Path.GetFileNameWithoutExtension(logicalName), logicalName, texture); } + var playfieldTextureName = FuturePinballElementGeometry.Text(_table.TableData, PlayfieldTextureTag); + if (!string.IsNullOrWhiteSpace(playfieldTextureName) && TryGetTexture(playfieldTextureName, out var playfieldTexture)) { + AddTextureAlias(FuturePinballNativeItemConverter.PlayfieldImage, playfieldTextureName, playfieldTexture); + } else if (!string.IsNullOrWhiteSpace(playfieldTextureName)) { + _report.Warnings.Add($"Playfield image '{playfieldTextureName}' was declared by the table but could not be resolved."); + } } private void AddTextureAlias(string alias, string logicalName, Texture2D texture) @@ -991,16 +1087,22 @@ private sealed class NativeTableContainer : TableContainer public override Dictionary TableInfo { get; } = new Dictionary(); public override List Collections { get; } = new List(); public override CustomInfoTags CustomInfoTags { get; } = new CustomInfoTags(); - public override IEnumerable Textures => Array.Empty(); + public override IEnumerable Textures => _textures.Values; public override IEnumerable Sounds => Array.Empty(); + private readonly Dictionary _textures; - public NativeTableContainer(TableData data) + public NativeTableContainer(TableData data, IEnumerable textureNames) { + _textures = (textureNames ?? Array.Empty()) + .Append(data.Image) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToDictionary(name => name, name => new EngineTexture(name), StringComparer.OrdinalIgnoreCase); Table = new Table(this, data); } public override EngineMaterial GetMaterial(string name) => null; - public override EngineTexture GetTexture(string name) => null; + public override EngineTexture GetTexture(string name) => name != null && _textures.TryGetValue(name, out var texture) ? texture : null; } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs index 9fe5da014..86f6566d5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/FptSceneConverterTests.cs @@ -14,11 +14,22 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +using System.Collections.Generic; + using NUnit.Framework; using UnityEngine; +using VisualPinball.Engine.IO.FuturePinball; +using VisualPinball.Engine.VPT; +using VisualPinball.Engine.VPT.Table; using VisualPinball.Unity.Editor; +using VisualPinball.Unity.Playfield; + +using EngineMesh = VisualPinball.Engine.VPT.Mesh; +using UnityMaterial = UnityEngine.Material; +using UnityMesh = UnityEngine.Mesh; +using UnityTexture = UnityEngine.Texture; namespace VisualPinball.Unity.Test { @@ -35,7 +46,7 @@ public void ShouldAttachAndConfigureSpinningDiskFromSourceModel() sourceModel.transform.localRotation = Quaternion.Euler(10f, 20f, 30f); var visual = FptSceneConverter.CreateSpinningDiskVisualRoot(mechanism.AddComponent(), sourceModel); var meshObject = new GameObject("Mesh"); - var mesh = new Mesh { vertices = new[] { new Vector3(0.03f, 0f, 0.04f) } }; + var mesh = new UnityMesh { vertices = new[] { new Vector3(0.03f, 0f, 0.04f) } }; meshObject.transform.SetParent(visual.transform, false); meshObject.AddComponent().sharedMesh = mesh; var component = mechanism.GetComponent(); @@ -75,5 +86,184 @@ public void ShouldRetainDefaultRadiusForSpinningDiskWithoutUsableMesh() Object.DestroyImmediate(visual); } } + + [Test] + public void ShouldDriveNativeFlipperWithSourceVisual() + { + var modelInstances = new GameObject("Model Instances"); + modelInstances.transform.rotation = Quaternion.Euler(5f, 17f, -9f); + var nativeParent = new GameObject("Native Parent"); + nativeParent.transform.rotation = Quaternion.Euler(11f, -31f, 7f); + var nativeFlipper = new GameObject("Native Flipper"); + nativeFlipper.transform.SetParent(nativeParent.transform, false); + var proceduralVisual = new GameObject("Procedural Visual"); + proceduralVisual.transform.SetParent(nativeFlipper.transform, false); + var renderer = proceduralVisual.AddComponent(); + var component = nativeFlipper.AddComponent(); + component.StartAngle = 122f; + var sourceModel = new GameObject("Source Model"); + sourceModel.transform.SetParent(modelInstances.transform, false); + var sourceRotation = FptSceneConverter.FlipperSourceRotation(component.StartAngle); + var sourceWorldRotation = modelInstances.transform.rotation * sourceRotation; + var relativeRotation = Quaternion.Inverse(nativeFlipper.transform.rotation) * sourceWorldRotation; + + try { + var visual = FptSceneConverter.CreateFlipperVisualRoot(component, sourceModel, sourceRotation); + + Assert.That(visual, Is.SameAs(sourceModel)); + Assert.That(sourceModel.transform.parent, Is.EqualTo(nativeFlipper.transform)); + Assert.That(sourceModel.transform.localPosition, Is.EqualTo(Vector3.zero)); + Assert.That(Quaternion.Angle(sourceModel.transform.rotation, sourceWorldRotation), Is.LessThan(0.001f)); + Assert.That(renderer.enabled, Is.False); + Assert.That(component.InstantiateAsPrefab, Is.True); + + component.StartAngle = 70f; + var expectedRotation = nativeFlipper.transform.rotation * relativeRotation; + Assert.That(Quaternion.Angle(sourceModel.transform.rotation, expectedRotation), Is.LessThan(0.001f)); + } finally { + Object.DestroyImmediate(nativeParent); + Object.DestroyImmediate(modelInstances); + } + } + + [TestCase(122f, -212f)] + [TestCase(-122f, -328f)] + public void ShouldOrientFlipperSourceModelFromStartAngle(float startAngle, float expectedYaw) + { + var rotation = FptSceneConverter.FlipperSourceRotation(startAngle); + + Assert.That(Quaternion.Angle(rotation, Quaternion.Euler(0f, expectedYaw, 0f)), Is.LessThan(0.001f)); + } + + [Test] + public void ShouldAttachHiddenVpePrimitiveCollider() + { + var gameObject = new GameObject("VPE Collider"); + var mesh = new UnityMesh(); + + try { + FptSceneConverter.AddVpePrimitiveCollider(gameObject, mesh, true, false); + + Assert.That(gameObject.GetComponent().sharedMesh, Is.SameAs(mesh)); + Assert.That(gameObject.GetComponent(), Is.Not.Null); + Assert.That(gameObject.GetComponent().enabled, Is.False); + Assert.That(gameObject.GetComponent().HitEvent, Is.True); + Assert.That(gameObject.GetComponent().enabled, Is.True); + } finally { + Object.DestroyImmediate(gameObject); + Object.DestroyImmediate(mesh); + } + } + + [Test] + public void ShouldAttachRenderablePlayfieldComponents() + { + var gameObject = new GameObject("Playfield"); + var texture = new Texture2D(2, 2) { name = FuturePinballNativeItemConverter.PlayfieldImage }; + var data = new TableData { + Name = "Playfield", + Image = FuturePinballNativeItemConverter.PlayfieldImage, + Right = FuturePinballCoordinateConverter.ToVpx(514f), + Bottom = FuturePinballCoordinateConverter.ToVpx(1168f), + AngleTiltMin = 6.5f + }; + var provider = new TestMaterialProvider(texture); + UnityMesh generatedMesh = null; + UnityMaterial generatedMaterial = null; + + try { + var component = FptSceneConverter.AddPlayfieldComponents(gameObject, data, true); + component.SetReferencedData( + data, + FptSceneConverter.CreateNativeTable(data), + provider, + provider, + new Dictionary() + ); + + Assert.That(component.RenderSlope, Is.EqualTo(6.5f)); + Assert.That(gameObject.GetComponent(), Is.Not.Null); + Assert.That(gameObject.GetComponent(), Is.Not.Null); + var meshFilter = gameObject.GetComponent(); + var meshRenderer = gameObject.GetComponent(); + Assert.That(meshFilter, Is.Not.Null); + Assert.That(meshFilter.sharedMesh, Is.Not.Null); + Assert.That(meshFilter.sharedMesh.vertexCount, Is.EqualTo(4)); + Assert.That(meshRenderer, Is.Not.Null); + Assert.That(meshRenderer.sharedMaterial, Is.Not.Null); + Assert.That(provider.RequestedTextureName, Is.EqualTo(FuturePinballNativeItemConverter.PlayfieldImage)); + Assert.That(new[] { "_BaseColorMap", "_BaseMap", "_MainTex" }, Has.Some.Matches( + property => meshRenderer.sharedMaterial.HasProperty(property) + && meshRenderer.sharedMaterial.GetTexture(property) == texture + )); + generatedMesh = meshFilter.sharedMesh; + generatedMaterial = meshRenderer.sharedMaterial; + } finally { + Object.DestroyImmediate(gameObject); + if (generatedMesh) Object.DestroyImmediate(generatedMesh); + if (generatedMaterial) Object.DestroyImmediate(generatedMaterial); + Object.DestroyImmediate(texture); + } + } + + [Test] + public void ShouldDisableOnlyRoomScaleOrnamentModels() + { + var table = new TableData { + Right = FuturePinballCoordinateConverter.ToVpx(514f), + Bottom = FuturePinballCoordinateConverter.ToVpx(1168f) + }; + var cabinetRail = new EngineMesh(new[] { + new[] { -0.012f, -0.053f, -0.598f }, + new[] { 0.012f, 0.053f, 0.598f } + }, new[] { 0, 1 }); + var arcadeWall = new EngineMesh(new[] { + new[] { -3.042f, -1.939f, -1.590f }, + new[] { 3.042f, 0.930f, 0.524f } + }, new[] { 0, 1 }); + + Assert.That(FptSceneConverter.IsEnvironmentScaleOrnamentModel(new[] { cabinetRail }, table, out _), Is.False); + Assert.That(FptSceneConverter.IsEnvironmentScaleOrnamentModel(new[] { arcadeWall }, table, out var size), Is.True); + Assert.That(size.x, Is.EqualTo(6.084f).Within(0.001f)); + } + + [Test] + public void ShouldExposeImportedTexturesToNativeMeshes() + { + var data = new TableData { Image = FuturePinballNativeItemConverter.PlayfieldImage }; + var table = FptSceneConverter.CreateNativeTable(data, new[] { "flipper-white-black" }); + + Assert.That(table.GetTexture(FuturePinballNativeItemConverter.PlayfieldImage), Is.Not.Null); + Assert.That(table.GetTexture("FLIPPER-WHITE-BLACK"), Is.Not.Null); + Assert.That(table.GetTexture("missing"), Is.Null); + } + + private sealed class TestMaterialProvider : IMaterialProvider, ITextureProvider + { + private readonly UnityTexture _texture; + + public string RequestedTextureName { get; private set; } + + public TestMaterialProvider(UnityTexture texture) + { + _texture = texture; + } + + public bool HasMaterial(PbrMaterial material) => false; + + public void SaveMaterial(PbrMaterial vpxMaterial, UnityMaterial material) { } + + public UnityMaterial GetMaterial(PbrMaterial material) => null; + + public PhysicsMaterialAsset GetPhysicsMaterial(string name) => null; + + public UnityMaterial MergeMaterials(string vpxMaterial, UnityMaterial textureMaterial) => textureMaterial; + + public UnityTexture GetTexture(string name) + { + RequestedTextureName = name; + return _texture; + } + } } } From 0e054ef752b8e11f284adad7738f6598073e0493 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 25 Jul 2026 23:26:39 +0200 Subject: [PATCH 18/31] io(fpt): index resource referrers in a single pass Resolving referrers scanned every stream record once per resource, which is quadratic and dominated extraction: ~3.5s for 2044 names on an 8.7MB table. Build the name-to-stream index once instead, which runs in ~1ms and produces the same case-insensitive, name-ordered result. --- .../FuturePinball/FuturePinballExtractor.cs | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs index ac6801498..d23b6c81b 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs @@ -339,21 +339,37 @@ private static void AddReferrers( IReadOnlyList streams, IEnumerable resources) { + // Indexing every referenced name once keeps this linear in records; scanning all streams + // per resource is quadratic and dominates extraction on tables with many resources. + var referrers = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var stream in streams) { + foreach (var record in stream.Records) { + switch (record.Value) { + case string text: + Track(referrers, text, stream.Name); + break; + case IReadOnlyList list: + foreach (var item in list) Track(referrers, item, stream.Name); + break; + } + } + } + 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(); + resource.Referrers = referrers.TryGetValue(resource.LogicalName, out var names) + ? names.OrderBy(name => name, StringComparer.OrdinalIgnoreCase).ToArray() + : Array.Empty(); } } - private static bool References(object value, string logicalName) + private static void Track(IDictionary> referrers, string value, string streamName) { - 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; + if (string.IsNullOrEmpty(value)) return; + if (!referrers.TryGetValue(value, out var names)) { + referrers[value] = names = new HashSet(StringComparer.OrdinalIgnoreCase); + } + names.Add(streamName); } private static IReadOnlyList LoadLibraries( From 13e9875dcc9fc6b21280b96ea852914f4f5fecd2 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 25 Jul 2026 23:30:11 +0200 Subject: [PATCH 19/31] io(fpt): cut redundant reads and copies from extraction WriteFile read every existing file back in full and compared it with LINQ's enumerator-based SequenceEqual. Compare the length first and use a vectorized span comparison. Hash record payloads without copying them, and build hex strings and the reserved-name lookup without per-call allocations. Cold extraction of an 8.7MB table drops from 7.4s to 4.8s, re-extraction from 4.8s to 1.6s. --- .../FuturePinball/FuturePinballExtractor.cs | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs index d23b6c81b..116d8d301 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs @@ -46,6 +46,14 @@ public static class FuturePinballExtractor private static readonly UTF8Encoding Utf8 = new UTF8Encoding(false); private static readonly HashSet InvalidFileNameCharacters = new HashSet("<>:\"/\\|?*"); + private static readonly HashSet ReservedFileNames = new HashSet( + new[] { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + }, + StringComparer.OrdinalIgnoreCase + ); public static FuturePinballExtractionManifest Extract( string tablePath, @@ -517,8 +525,7 @@ private static string SafeName(string value, string fallback) 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; + if (ReservedFileNames.Contains(stem)) safe = "_" + safe; return safe; } @@ -536,9 +543,12 @@ private static void WriteFile(string outputRoot, string relativePath, byte[] dat 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; + var existing = new FileInfo(path); + if (existing.Exists) { + // Comparing the length first skips reading back media that cannot match, and the span + // comparison is vectorized, unlike the enumerator-based LINQ SequenceEqual. + if (existing.Length == data.Length + && new ReadOnlySpan(File.ReadAllBytes(path)).SequenceEqual(data)) return; if (!options.OverwriteChangedFiles) throw new IOException($"Extraction target already exists with different content: {path}"); } File.WriteAllBytes(path, data); @@ -557,12 +567,26 @@ private static string Sha256(byte[] data) private static string Sha256(ReadOnlySpan data) { - return Sha256(data.ToArray()); + // Record payloads are hashed for every record of every stream, so the copy that + // ComputeHash(byte[]) would need is avoided here. + Span hash = stackalloc byte[32]; + using (var sha = SHA256.Create()) { + if (!sha.TryComputeHash(data, hash, out var written) || written != hash.Length) { + return Sha256(data.ToArray()); + } + } + return Hex(hash); } - private static string Hex(byte[] data) + private static string Hex(ReadOnlySpan data) { - return BitConverter.ToString(data).Replace("-", string.Empty).ToLowerInvariant(); + const string digits = "0123456789abcdef"; + var result = new char[data.Length * 2]; + for (var i = 0; i < data.Length; i++) { + result[i * 2] = digits[data[i] >> 4]; + result[i * 2 + 1] = digits[data[i] & 0xf]; + } + return new string(result); } } } From 3525f29e9a66c1729944aaa6a7bf3ca3cd4df681 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 25 Jul 2026 23:33:32 +0200 Subject: [PATCH 20/31] editor(fpt): extract from the already parsed table The importer read and decompressed the table once for the scene converter and a second time inside the extractor. Add an Extract overload that takes a parsed table so the converter can hand over the one it already has, saving a full parse pass (250-500ms per table) per import. --- .../IO/FuturePinball/FuturePinballExtractor.cs | 18 ++++++++++++++++-- .../Import/FptSceneConverter.cs | 7 ++++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs index 116d8d301..9198908e0 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs @@ -63,11 +63,25 @@ public static FuturePinballExtractionManifest Extract( if (tablePath == null) throw new ArgumentNullException(nameof(tablePath)); if (outputDirectory == null) throw new ArgumentNullException(nameof(outputDirectory)); options ??= new FuturePinballExtractionOptions(); - var sourcePath = Path.GetFullPath(tablePath); + return Extract(FuturePinballTableReader.Load(Path.GetFullPath(tablePath), options.ReaderOptions), outputDirectory, options); + } + + /// + /// Extracts a table that has already been read, so callers that need both the parsed table + /// and the source bundle do not pay for a second parse and decompression pass. + /// + public static FuturePinballExtractionManifest Extract( + FuturePinballTable table, + string outputDirectory, + FuturePinballExtractionOptions options = null) + { + if (table == null) throw new ArgumentNullException(nameof(table)); + if (outputDirectory == null) throw new ArgumentNullException(nameof(outputDirectory)); + options ??= new FuturePinballExtractionOptions(); + var sourcePath = table.SourcePath; 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); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs index 733ebcdec..b0c4669cf 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs @@ -118,12 +118,13 @@ public FptImportResult Convert() EnsureAssetFolder(_meshAssetRoot); EnsureAssetFolder(_materialAssetRoot); - _table = FuturePinballTableReader.Load(_sourcePath); - _manifest = FuturePinballExtractor.Extract(_sourcePath, PhysicalPath(_bundleAssetRoot), new FuturePinballExtractionOptions { + var extractionOptions = new FuturePinballExtractionOptions { CopyOriginalTable = _options.CopyOriginalTable, OverwriteChangedFiles = _options.OverwriteChangedSourceFiles, LibrarySearchRoots = _options.LibrarySearchRoots ?? Array.Empty() - }); + }; + _table = FuturePinballTableReader.Load(_sourcePath, extractionOptions.ReaderOptions); + _manifest = FuturePinballExtractor.Extract(_table, PhysicalPath(_bundleAssetRoot), extractionOptions); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); LoadTextures(); From 7bb43cff5fb24539a56a15e36a52e8693b799e00 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 25 Jul 2026 23:41:13 +0200 Subject: [PATCH 21/31] editor(fpt): build model geometry once per model Model meshes were rebuilt and re-transformed for every element referencing a model, collider descriptions were re-derived per element, and the mesh hash that forms the asset path was recomputed on every lookup, so the mesh cache could never avoid it. Cache group and world meshes on the loaded model, share collider descriptions across its instances, and let callers holding a content-identifying key skip hashing. The collider key is the model resource rather than the render-mesh hash, because collision shapes come from the pin-model stream and two resources can share render geometry while colliding differently. --- .../Import/FptSceneConverter.cs | 79 ++++++++++++++++--- 1 file changed, 66 insertions(+), 13 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs index b0c4669cf..ad71d8f58 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs @@ -79,6 +79,9 @@ internal sealed class FptSceneConverter : IMaterialProvider, ITextureProvider 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 _meshesByContentKey = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary> _modelColliders = + new Dictionary>(); private readonly Dictionary _materials = new Dictionary(); private readonly Dictionary _nativeMaterials = new Dictionary(StringComparer.Ordinal); private readonly HashSet _reportedTessellatedColliderKinds = new HashSet(); @@ -322,8 +325,8 @@ private void CreateModelInstances(GameObject parent, ISet FuturePinballCoordinateConverter.ModelMeshToWorld(group.Mesh)).ToArray(); + var groups = model.Groups; + var worldMeshes = model.WorldMeshes; var modelSize = Vector3.zero; var isEnvironmentScale = element.ElementType == FuturePinballElementType.Ornament && IsEnvironmentScaleOrnamentModel(worldMeshes, _nativeTable.Data, out modelSize); @@ -481,7 +484,7 @@ private Dictionary LoadModels(ISet referenced) } } var primary = assets.Variants.FirstOrDefault(variant => variant.Role == "primary_model_data")?.Model; - if (primary != null) result[name] = new LoadedModel(primary, source); + if (primary != null) result[name] = new LoadedModel(name, primary, source); else _report.Warnings.Add($"Model '{name}' has no decoded primary mesh."); } catch (Exception exception) { _report.Warnings.Add($"Could not decode model '{name}': {exception.Message}"); @@ -492,11 +495,16 @@ private Dictionary LoadModels(ISet referenced) private void CreateModelColliders(GameObject parent, FuturePinballSourceStream element, LoadedModel model) { - var descriptions = FuturePinballColliderBuilder.FromModel(model.Source, model.Primary, new FuturePinballColliderOptions { - EnableAnalyticShapes = true, - EnablePerPolygonCollision = _options.EnablePerPolygonCollision, - GenerateRenderMeshFallback = _options.GenerateRenderMeshFallbackColliders - }); + // Descriptions depend only on the model and the import options, and building them + // re-tessellates the model's meshes, so they are shared by every element using the model. + if (!_modelColliders.TryGetValue(model, out var descriptions)) { + descriptions = FuturePinballColliderBuilder.FromModel(model.Source, model.Primary, new FuturePinballColliderOptions { + EnableAnalyticShapes = true, + EnablePerPolygonCollision = _options.EnablePerPolygonCollision, + GenerateRenderMeshFallback = _options.GenerateRenderMeshFallbackColliders + }); + _modelColliders[model] = descriptions; + } var index = 0; foreach (var description in descriptions) { if (description.Status != FuturePinballColliderStatus.Generated) { @@ -511,8 +519,14 @@ private void CreateModelColliders(GameObject parent, FuturePinballSourceStream e } var go = Child(parent, $"VPE Collider {colliderIndex} ({description.Kind})"); go.transform.localPosition = new Vector3(description.Center.X, description.Center.Y, description.Center.Z); - var unityMesh = GetOrCreateMesh(colliderMesh, - $"vpe-collider-{model.Primary.SourceSha256}-{colliderIndex}-{description.Kind}"); + // Keyed on the model resource, not on its render-mesh hash: collider shapes come from + // the pin-model stream, so two resources can share render geometry yet collide + // differently. + var unityMesh = GetOrCreateMesh( + colliderMesh, + $"vpe-collider-{model.Primary.SourceSha256}-{colliderIndex}-{description.Kind}", + $"vpe-collider-{model.Name}-{colliderIndex}-{description.Kind}" + ); AddVpePrimitiveCollider(go, unityMesh, description.GenerateHitEvent, false); if (FuturePinballColliderMeshBuilder.IsTessellatedApproximation(description.Kind) && _reportedTessellatedColliderKinds.Add(description.Kind)) { @@ -537,7 +551,11 @@ private void CreateRenderer( { if (mesh?.IsSet != true || mesh.Vertices.Length == 0 || mesh.Indices.Length == 0) return; var go = Child(parent, $"{SafeName(mesh.Name ?? "Mesh")} {part}"); - var unityMesh = GetOrCreateMesh(mesh, meshAssetKey ?? $"{source.SourceIndex:D5}-{SafeName(ElementName(source))}-{part}"); + var unityMesh = GetOrCreateMesh( + mesh, + meshAssetKey ?? $"{source.SourceIndex:D5}-{SafeName(ElementName(source))}-{part}", + meshAssetKey + ); go.AddComponent().sharedMesh = unityMesh; go.AddComponent().sharedMaterial = GetOrCreateMaterial(material); if (_options.GenerateColliders && procedural && collidable) { @@ -560,7 +578,21 @@ internal static void AddVpePrimitiveCollider(GameObject go, UnityMesh mesh, bool collider.enabled = true; } - private UnityMesh GetOrCreateMesh(EngineMesh source, string assetName) + /// + /// Hashing a mesh costs roughly 250 ms per 200k vertices, and the hash is part of the asset + /// path, so it cannot be skipped by the path cache alone. Callers that already hold a key + /// identifying the content - model groups and the colliders derived from them, which repeat + /// across every element referencing the same model - pass it as . + /// + private UnityMesh GetOrCreateMesh(EngineMesh source, string assetName, string contentKey = null) + { + if (contentKey != null && _meshesByContentKey.TryGetValue(contentKey, out var known)) return known; + var mesh = GetOrCreateMeshAsset(source, assetName); + if (contentKey != null) _meshesByContentKey[contentKey] = mesh; + return mesh; + } + + private UnityMesh GetOrCreateMeshAsset(EngineMesh source, string assetName) { var path = $"{_meshAssetRoot}/{SafeName(assetName)}-{MeshHash(source).Substring(0, 16)}.asset"; if (_meshes.TryGetValue(path, out var cached)) return cached; @@ -1072,11 +1104,32 @@ private static string MeshHash(EngineMesh mesh) private sealed class LoadedModel { + /// + /// Logical model name, unique per imported model resource. Identifies the collision + /// source, which does not. + /// + public string Name { get; } + public MilkShapeModel Primary { get; } public FuturePinballSourceStream Source { get; } - public LoadedModel(MilkShapeModel primary, FuturePinballSourceStream source) + /// + /// Built once per model rather than once per element: a table typically instantiates the + /// same model many times, and both the group meshes and their world transform are + /// deterministic. Consumers only read them. + /// + public IReadOnlyList Groups => _groups ??= Primary.CreateMeshes(); + + public EngineMesh[] WorldMeshes => _worldMeshes ??= Groups + .Select(group => FuturePinballCoordinateConverter.ModelMeshToWorld(group.Mesh)) + .ToArray(); + + private IReadOnlyList _groups; + private EngineMesh[] _worldMeshes; + + public LoadedModel(string name, MilkShapeModel primary, FuturePinballSourceStream source) { + Name = name; Primary = primary; Source = source; } From 98aae8057d75aabd9a6240c873d2ea50a2a9a49d Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 25 Jul 2026 23:44:05 +0200 Subject: [PATCH 22/31] editor(fpt): batch asset creation during scene construction Mesh and material assets were created one at a time, so the editor imported each of them individually. Wrap scene construction in StartAssetEditing / StopAssetEditing, matching how VpxSceneConverter already batches its own asset creation. --- .../Import/FptSceneConverter.cs | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs index ad71d8f58..4845a42ce 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs @@ -135,22 +135,32 @@ public FptImportResult Convert() var existing = _options.ReplaceExistingSceneRoot ? GameObject.Find(rootName) : null; try { root = new GameObject(rootName); - var playfield = CreateVpeHierarchy(root); - var rootSource = root.AddComponent(); - rootSource.SourceFile = Path.GetFileName(_sourcePath); - rootSource.SourceHash = _manifest.SourceSha256; - rootSource.ImportOutcome = "lossless-source-bundle-and-vpe-native-scene"; - - var proceduralRoot = Child(playfield, "Procedural Geometry"); - var nativeRoot = Child(playfield, "Native Elements"); - var modelRoot = Child(playfield, "Model Instances"); - var placeholderRoot = Child(playfield, "Preserved Placeholders"); - var handled = new HashSet(); - CreateNativeElements(nativeRoot, handled); - CreateProceduralElements(proceduralRoot, handled); - CreateModelInstances(modelRoot, handled); - CreatePlaceholders(placeholderRoot, handled); - PersistNativeElements(); + try { + // Scene construction creates one mesh and material asset at a time; importing + // each of them separately is far more expensive than importing them as a batch. + AssetDatabase.StartAssetEditing(); + + var playfield = CreateVpeHierarchy(root); + var rootSource = root.AddComponent(); + rootSource.SourceFile = Path.GetFileName(_sourcePath); + rootSource.SourceHash = _manifest.SourceSha256; + rootSource.ImportOutcome = "lossless-source-bundle-and-vpe-native-scene"; + + var proceduralRoot = Child(playfield, "Procedural Geometry"); + var nativeRoot = Child(playfield, "Native Elements"); + var modelRoot = Child(playfield, "Model Instances"); + var placeholderRoot = Child(playfield, "Preserved Placeholders"); + var handled = new HashSet(); + CreateNativeElements(nativeRoot, handled); + CreateProceduralElements(proceduralRoot, handled); + CreateModelInstances(modelRoot, handled); + CreatePlaceholders(placeholderRoot, handled); + PersistNativeElements(); + + } finally { + AssetDatabase.StopAssetEditing(); + AssetDatabase.Refresh(); + } _report.SourceFile = Path.GetFileName(_sourcePath); _report.SourceSha256 = _manifest.SourceSha256; From 6e96d7106c86edb6738c7c491ca8c85c38ba0ed6 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 25 Jul 2026 23:46:15 +0200 Subject: [PATCH 23/31] physics(fpt): merge collider group meshes in one pass Mesh.Merge concatenates into fresh arrays, so merging one group at a time copies everything merged so far and is quadratic in total vertex count. Accumulate the surviving triangles and build the merged mesh once. --- .../IO/FuturePinball/FuturePinballCollider.cs | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs index ba62b2187..a8381f6bb 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballCollider.cs @@ -163,10 +163,14 @@ public static FuturePinballColliderDescription FromShape(FuturePinballCollisionS public static FuturePinballColliderDescription FromMesh(IEnumerable meshes) { if (meshes == null) throw new ArgumentNullException(nameof(meshes)); - var merged = new Mesh(); + // Mesh.Merge reallocates and copies everything merged so far, so merging group by group + // is quadratic in total vertex count. Accumulate first and build the mesh once. + var vertices = new List(); + var indices = new List(); foreach (var source in meshes.Where(mesh => mesh?.IsSet == true)) { var world = FuturePinballCoordinateConverter.ModelMeshToWorld(source); - var validIndices = new List(); + var offset = vertices.Count; + var indexCount = indices.Count; 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 @@ -175,13 +179,19 @@ public static FuturePinballColliderDescription FromMesh(IEnumerable meshes 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]); + indices.Add(offset + world.Indices[i]); + indices.Add(offset + world.Indices[i + 1]); + indices.Add(offset + world.Indices[i + 2]); } } - world.Indices = validIndices.ToArray(); - if (world.Indices.Length > 0) merged.Merge(world); + // Merge appended every vertex of a contributing group, referenced or not. + if (indices.Count > indexCount) vertices.AddRange(world.Vertices); + } + + var merged = new Mesh(); + if (vertices.Count > 0) { + merged.Vertices = vertices.ToArray(); + merged.Indices = indices.ToArray(); } return new FuturePinballColliderDescription { Kind = FuturePinballColliderKind.Mesh, From eff4d040a87eed08b388f2d87b909caf161941b2 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 25 Jul 2026 23:48:25 +0200 Subject: [PATCH 24/31] io(fpt): bound the missing stream index report Stream indices come from the stream name, so a malformed file could declare an arbitrarily sparse range and make the reader emit one issue per missing index until it ran out of memory. Report at most 32 gaps plus a total, derive the gap arithmetically instead of iterating it, and widen the counter so an index of int.MaxValue no longer overflows. --- .../FuturePinball/FuturePinballTableReader.cs | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballTableReader.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballTableReader.cs index 96b4729b2..460b34eaf 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballTableReader.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballTableReader.cs @@ -34,6 +34,8 @@ public static class FuturePinballTableReader private const uint LightListCountTag = 0x95F5C6D2; private const uint DmdFontCountTag = 0x9BFBCED2; + private const int MaximumReportedIndexGaps = 32; + public static FuturePinballTable Load(string fileName, FuturePinballReaderOptions options = null) { if (fileName == null) { @@ -196,13 +198,26 @@ private static void ReportIndexProblems( issues.Add($"{group.Key} streams contain duplicate source indices: {string.Join(", ", duplicates)}."); } if (indices.Length > 0) { - var expected = indices[0]; + // A stream carries its own index in its name, so a malformed file can declare an + // arbitrarily sparse range. Report a bounded sample and a total rather than one + // issue per missing index, which would otherwise exhaust memory. + long expected = indices[0]; + var reported = 0; + var missing = 0L; foreach (var index in indices.Distinct()) { - while (expected < index) { - issues.Add($"{group.Key} stream index {expected} is missing."); - expected++; + var gap = index - expected; + if (gap > 0) { + var sample = (int)System.Math.Min(gap, MaximumReportedIndexGaps - reported); + for (var i = 0; i < sample; i++) { + issues.Add($"{group.Key} stream index {expected + i} is missing."); + } + reported += sample; + missing += gap; } - expected = index + 1; + expected = (long)index + 1; + } + if (missing > reported) { + issues.Add($"{group.Key} streams are missing {missing} indices in total; the first {reported} are listed above."); } } } From 56cff5f8141fcf7b26d09e4375c0484572c38bbe Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 25 Jul 2026 23:52:45 +0200 Subject: [PATCH 25/31] io(fpt): decode element height from either encoding The height tag is stored as a float by guide walls and as an integer by several other element types, so the reader keeps it opaque and the four read sites each guessed. Guessing wrong one way is severe: a float height read as an integer becomes ~1.1e9, placing the element about a thousand kilometres above the playfield, which a guide wall with too few drag points already hits by falling through to the placeholder path. Decode from the bit pattern instead, since the two encodings are orders of magnitude apart for real millimetre values, and route every read site through it. --- .../FuturePinballGeometryTests.cs | 41 +++++++++++++++++++ .../IO/FuturePinball/FuturePinballGeometry.cs | 34 +++++++++++++++ .../FuturePinball/FuturePinballNativeItems.cs | 6 ++- .../Import/FptSceneConverter.cs | 4 +- 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs index 605190aed..b47846dda 100644 --- a/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs +++ b/VisualPinball.Engine.Test/IO/FuturePinball/FuturePinballGeometryTests.cs @@ -129,6 +129,47 @@ public void BuildsFiniteProceduralMeshesFromLockedTable(FuturePinballFixtureExpe } } + [Test] + public void ReadsAmbiguousHeightInEitherEncoding() + { + var path = Path.Combine(Path.GetTempPath(), $"vpe-fp-height-{Guid.NewGuid():N}.fpt"); + try { + CreateAmbiguousHeightTable(path); + var elements = FuturePinballTableReader.Load(path).Elements; + + // The tag is element-dependent, so the reader keeps it opaque and both encodings + // reach the same accessor. Reading a float as an integer would yield ~1.1e9. + Assert.That(FuturePinballElementGeometry.Millimeters(elements[0], 0xA2F8CDDD), Is.EqualTo(20f)); + Assert.That(FuturePinballElementGeometry.Millimeters(elements[1], 0xA2F8CDDD), Is.EqualTo(25f)); + Assert.That(FuturePinballElementGeometry.Millimeters(elements[0], 0x11223344, 7f), Is.EqualTo(7f)); + } finally { + if (File.Exists(path)) File.Delete(path); + } + } + + private static void CreateAmbiguousHeightTable(string path) + { + 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(0x95FDCDD2, 2), + Record(0xA7FDC4E0, Array.Empty()) + )); + Write(storage.CreateStream("Table Element 1"), Join( + UInt32((uint)FuturePinballElementType.GuideWall), + FloatRecord(0xA2F8CDDD, 20f), + Record(0xA7FDC4E0, Array.Empty()) + )); + Write(storage.CreateStream("Table Element 2"), Join( + UInt32((uint)FuturePinballElementType.WireGuide), + IntegerRecord(0xA2F8CDDD, 25), + Record(0xA7FDC4E0, Array.Empty()) + )); + table.Flush(true); + } + } + private static void CreateSurfaceTable(string path, FuturePinballElementType type = FuturePinballElementType.Surface) { using (var table = RootStorage.Create(path, OpenMcdf.Version.V3, StorageModeFlags.None)) { diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs index f7460fec8..d8c4dd1d3 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs @@ -226,6 +226,40 @@ public static bool HasTag(FuturePinballSourceStream stream, uint tag) return Find(stream, tag) != null; } + /// + /// Reads a millimetre value from a tag that some element types store as an integer and + /// others as a float, and which the generic reader therefore keeps opaque. Reading such a + /// payload with the wrong type is not a small error: a float height read as an integer + /// lands around 1e9, which would place the element roughly a thousand kilometres off the + /// table. The two encodings are far apart, so the plausible reading is picked instead: a + /// height stored as an integer reads back as a denormal float, and a height stored as a + /// float reads back as a huge integer. + /// + public static float Millimeters(FuturePinballSourceStream stream, uint tag, float fallback = 0f) + { + var record = Find(stream, tag); + if (record == null) return fallback; + if (record.Value is int knownInteger) return knownInteger; + if (record.Value is float knownFloat) return IsPlausibleMillimeters(knownFloat) ? knownFloat : fallback; + if (record.Payload.Length < 4) return fallback; + + var bits = ReadInt32(record.Payload.Span); + var asFloat = BitConverter.Int32BitsToSingle(bits); + if (IsPlausibleMillimeters(asFloat)) return asFloat; + return IsPlausibleMillimeters(bits) ? bits : fallback; + } + + private static bool IsPlausibleMillimeters(float value) + { + // Future Pinball tables are roughly 500 x 1200 mm, so anything beyond a hundred metres + // or below a micron is a misread rather than a real dimension. + const float maximum = 100000f; + const float minimum = 1e-3f; + if (float.IsNaN(value) || float.IsInfinity(value)) return false; + var magnitude = System.Math.Abs(value); + return magnitude == 0f || magnitude >= minimum && magnitude <= maximum; + } + private static FuturePinballRecord Find(FuturePinballSourceStream stream, uint tag) { return stream?.Records.FirstOrDefault(record => Matches(record, tag)); diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs index 670b0218e..9445b37bc 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs @@ -157,7 +157,9 @@ private static Surface Surface(FuturePinballSourceStream 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)), + HeightTop = ToVpx(isGuideWall + ? FuturePinballElementGeometry.Millimeters(element, HeightTag) + : FuturePinballElementGeometry.Float(element, TopHeightTag)), HeightBottom = isGuideWall ? 0f : ToVpx(FuturePinballElementGeometry.Float(element, BottomHeightTag)), IsCollidable = FuturePinballElementGeometry.Integer(element, CollidableTag, 1) != 0, IsTopBottomVisible = FuturePinballElementGeometry.Integer(element, RenderObjectTag, 1) != 0, @@ -207,7 +209,7 @@ private static MetalWireGuide WireGuide(FuturePinballSourceStream element) { var points = DragPoints(element); if (points.Length < 2) return null; - var height = ToVpx(FuturePinballElementGeometry.Integer(element, HeightTag, 25)); + var height = ToVpx(FuturePinballElementGeometry.Millimeters(element, HeightTag, 25f)); return new MetalWireGuide(new MetalWireGuideData(Name(element)) { DragPoints = points, Height = height, diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs index 4845a42ce..2005a13d5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs @@ -875,7 +875,7 @@ private bool TryCandidateSurfaceHeight( height = FuturePinballElementGeometry.Float(candidate, SurfaceTopHeightTag); return true; } - height = FuturePinballElementGeometry.Float(candidate, HeightTag); + height = FuturePinballElementGeometry.Millimeters(candidate, HeightTag); var parentSurface = FuturePinballElementGeometry.Text(candidate, SurfaceTag); if (string.IsNullOrWhiteSpace(parentSurface)) return true; if (!TryResolveSurfaceHeight(parentSurface, position, placedElement, resolving, out var parentHeight)) { @@ -919,7 +919,7 @@ private bool TryGetPlacement(FuturePinballSourceStream element, out Vector3 worl worldRotation = default; return false; } - height += FuturePinballElementGeometry.Integer(element, HeightTag); + height += FuturePinballElementGeometry.Millimeters(element, HeightTag); var world = FuturePinballCoordinateConverter.ToWorld(position.X, position.Y, height); worldPosition = new Vector3(world.X, world.Y, world.Z); // FPM meshes use model Y as up, unlike table records where Z is height. This basis correction is From b010a414fe3d673e3b204b46d3d2e2c298d55afd Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 25 Jul 2026 23:59:09 +0200 Subject: [PATCH 26/31] io: write tables to disk instead of buffering them in memory The OpenMcdf 3 migration replaced SaveAs with a MemoryStream plus File.WriteAllBytes(ToArray()), so saving needed twice the table size in managed memory and one contiguous large-object allocation. Tables reach hundreds of megabytes. Write to a sibling temporary file and replace the destination on success, so peak memory no longer scales with the table and a failure part-way through still leaves the existing table intact. --- VisualPinball.Engine/VPT/Table/TableWriter.cs | 67 ++++++++++++------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/VisualPinball.Engine/VPT/Table/TableWriter.cs b/VisualPinball.Engine/VPT/Table/TableWriter.cs index 10b37227e..92ae69f21 100644 --- a/VisualPinball.Engine/VPT/Table/TableWriter.cs +++ b/VisualPinball.Engine/VPT/Table/TableWriter.cs @@ -40,29 +40,50 @@ public TableWriter(TableContainer tableContainer) public void WriteTable(string fileName) { - 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); - - // 2. table info - WriteTableInfo(hashWriter); - - // 3. game items - WriteGameItems(hashWriter); - - // 4. the rest, which isn't hashed. - WriteTextures(); - WriteSounds(); - - // finally write hash - WriteStream(_gameStorage, "MAC", hashWriter.Hash()); - - _cf.Flush(true); - File.WriteAllBytes(fileName, output.ToArray()); + // Written to a sibling temporary file rather than buffered in memory: tables run into + // hundreds of megabytes, and buffering needs the whole thing twice over, once in the + // stream and once more to copy it out. Moving it into place only on success keeps the + // destination intact if anything throws part-way through. + var temporaryFile = Path.Combine( + Path.GetDirectoryName(Path.GetFullPath(fileName)) ?? string.Empty, + $"{Path.GetFileName(fileName)}.{Guid.NewGuid():N}.tmp" + ); + try { + using (var hashWriter = new HashWriter()) + using (_cf = RootStorage.Create(temporaryFile, OpenMcdf.Version.V3, StorageModeFlags.None)) { + _gameStorage = _cf.CreateStorage("GameStg"); + + // 1. version + WriteStream(_gameStorage, "Version", BitConverter.GetBytes(VpFileFormatVersion), hashWriter); + + // 2. table info + WriteTableInfo(hashWriter); + + // 3. game items + WriteGameItems(hashWriter); + + // 4. the rest, which isn't hashed. + WriteTextures(); + WriteSounds(); + + // finally write hash + WriteStream(_gameStorage, "MAC", hashWriter.Hash()); + + _cf.Flush(true); + } + + // Replace rather than delete-then-move: a failure between the two would destroy the + // existing table. + if (File.Exists(fileName)) { + File.Replace(temporaryFile, fileName, null); + } else { + File.Move(temporaryFile, fileName); + } + + } finally { + if (File.Exists(temporaryFile)) { + File.Delete(temporaryFile); + } } } From 360bd8d039f2a8f3d054b342f155f407f7578e84 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 26 Jul 2026 00:01:21 +0200 Subject: [PATCH 27/31] editor(fpt): replace the source bundle when re-importing a table Re-importing an edited table under the same name aborted as soon as one extracted file differed, after thousands of files had already been written, leaving the bundle half updated and reporting a raw absolute path. The bundle mirrors the table it came from, so replacing it is what re-import means. Default to overwriting, and say what to do when it is turned off. --- .../IO/FuturePinball/FuturePinballExtractor.cs | 7 ++++++- .../VisualPinball.Unity.Editor/Import/FptImportOptions.cs | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs index 9198908e0..45ed8bedb 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs @@ -563,7 +563,12 @@ private static void WriteFile(string outputRoot, string relativePath, byte[] dat // comparison is vectorized, unlike the enumerator-based LINQ SequenceEqual. if (existing.Length == data.Length && new ReadOnlySpan(File.ReadAllBytes(path)).SequenceEqual(data)) return; - if (!options.OverwriteChangedFiles) throw new IOException($"Extraction target already exists with different content: {path}"); + if (!options.OverwriteChangedFiles) { + throw new IOException( + $"'{relativePath}' already exists in the source bundle with different content. " + + "Enable overwriting of changed source files to re-extract this table, or extract it to another directory." + ); + } } File.WriteAllBytes(path, data); } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportOptions.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportOptions.cs index 959134ec5..9555350ce 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportOptions.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptImportOptions.cs @@ -24,7 +24,13 @@ public sealed class FptImportOptions public string AssetRoot = "Assets/Tables"; public string[] LibrarySearchRoots = Array.Empty(); public bool CopyOriginalTable = true; - public bool OverwriteChangedSourceFiles; + + /// + /// The source bundle mirrors the table it was extracted from, so re-importing an edited + /// table is expected to replace it. Turning this off makes any differing file abort the + /// import part-way through, leaving the bundle half updated. + /// + public bool OverwriteChangedSourceFiles = true; public bool ReuseGeneratedAssets = true; public bool ReplaceExistingSceneRoot = true; public bool ImportPrimaryModels = true; From 2026505c16d53ce0a2aa5ad5b25a5d8f3d7cf678 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 26 Jul 2026 00:04:39 +0200 Subject: [PATCH 28/31] io(fpt): drop per-triangle allocations from the model reader The model cache hashed the model bytes for its key and the reader hashed the same bytes again for SourceSha256, the triangle range check allocated a closure per triangle, and mesh building filled lists before copying them to arrays even though every triangle contributes exactly three unshared vertices. Reading a float also copied four bytes into a fresh array. --- .../IO/FuturePinball/FuturePinballGeometry.cs | 2 +- .../IO/FuturePinball/MilkShapeModelReader.cs | 32 +++++++++++++------ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs index d8c4dd1d3..b262f1302 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballGeometry.cs @@ -310,7 +310,7 @@ private static uint ReadUInt32(ReadOnlySpan data) private static float ReadSingle(ReadOnlySpan data) { - return BitConverter.ToSingle(data.Slice(0, 4).ToArray(), 0); + return BitConverter.Int32BitsToSingle(ReadInt32(data)); } } diff --git a/VisualPinball.Engine/IO/FuturePinball/MilkShapeModelReader.cs b/VisualPinball.Engine/IO/FuturePinball/MilkShapeModelReader.cs index 953b901b5..73a6b4143 100644 --- a/VisualPinball.Engine/IO/FuturePinball/MilkShapeModelReader.cs +++ b/VisualPinball.Engine/IO/FuturePinball/MilkShapeModelReader.cs @@ -93,25 +93,30 @@ public IReadOnlyList CreateMeshes() : 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); + // Every triangle contributes exactly three unshared vertices, so the size is known + // up front and the arrays can be filled directly. + var count = group.TriangleIndices.Length * 3; + var vertices = new Vertex3DNoTex2[count]; + var indices = new int[count]; + var next = 0; 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( + indices[next] = next; + vertices[next] = 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] - )); + ); + next++; } } 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 } + Mesh = new Mesh(vertices, indices) { Name = name } }); } return meshes; @@ -138,7 +143,8 @@ 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); + // Handed to the reader so the same bytes are not hashed twice on a cache miss. + model = MilkShapeModelReader.Parse(data, sourceName, hash); _models[hash] = model; return model; } @@ -182,7 +188,10 @@ public static class MilkShapeModelReader { private const string Signature = "MS3D000000"; - public static MilkShapeModel Parse(byte[] data, string sourceName = null) + /// + /// Precomputed hash of , if the caller already has one. + /// + public static MilkShapeModel Parse(byte[] data, string sourceName = null, string sourceSha256 = null) { if (data == null) throw new ArgumentNullException(nameof(data)); try { @@ -204,7 +213,7 @@ public static MilkShapeModel Parse(byte[] data, string sourceName = null) var trailing = reader.ReadBytes((int)(stream.Length - stream.Position)); return new MilkShapeModel { Version = version, - SourceSha256 = Sha256(data), + SourceSha256 = sourceSha256 ?? Sha256(data), Vertices = vertices, Triangles = triangles, Groups = groups, @@ -249,7 +258,10 @@ private static List ReadTriangles(BinaryReader reader, int ve U = new float[3], V = new float[3] }; - if (triangle.VertexIndices.Any(index => index >= vertexCount)) { + // Checked without a predicate: the closure would be allocated once per triangle. + if (triangle.VertexIndices[0] >= vertexCount + || triangle.VertexIndices[1] >= vertexCount + || triangle.VertexIndices[2] >= vertexCount) { throw Error($"Triangle {i} references a vertex outside 0..{vertexCount - 1}", sourceName, reader.BaseStream.Position - 6); } for (var corner = 0; corner < 3; corner++) From 2d87792347fe31fca6da1b2d5cb35b3ccbae2d23 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 26 Jul 2026 00:08:46 +0200 Subject: [PATCH 29/31] editor(fpt): trim repeated work from the scene converter Collider discovery materialised every MonoBehaviour in a prefab before filtering, the native material key was hashed up to three times per lookup, the turntable radius resolved both transforms for every vertex instead of composing them once per mesh, and the file-name and texture-property tables were rebuilt on every call. --- .../Import/FptSceneConverter.cs | 63 ++++++++++++------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs index 2005a13d5..8b987c439 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/FptSceneConverter.cs @@ -71,6 +71,10 @@ internal sealed class FptSceneConverter : IMaterialProvider, ITextureProvider private const uint SlopeTag = 0x9AF5BFD1; private const uint PlayfieldTextureTag = 0xA2F4C9D5; + // Both are consulted once per generated asset, so neither is rebuilt per call. + private static readonly string[] BaseTextureProperties = { "_BaseColorMap", "_BaseMap", "_MainTex" }; + private static readonly HashSet InvalidFileNameCharacters = new HashSet("<>:\"/\\|?*"); + private readonly string _sourcePath; private readonly string _tableName; private readonly FptImportOptions _options; @@ -201,11 +205,10 @@ private void CreateNativeElements(GameObject parent, ISet(true) - .Where(component => component is ICollidableComponent).ToArray(); - foreach (var nativeCollider in nativeColliders) { - if (!_options.GenerateColliders) nativeCollider.enabled = false; - else if (nativeCollider.enabled) _report.Colliders++; + foreach (var nativeCollider in prefab.GameObject.GetComponentsInChildren(true)) { + if (!(nativeCollider is MonoBehaviour behaviour)) continue; + if (!_options.GenerateColliders) behaviour.enabled = false; + else if (behaviour.enabled) _report.Colliders++; } AddSource(prefab.GameObject, element, "native-vpe-counterpart"); _nativeObjects[element] = prefab.GameObject; @@ -414,10 +417,14 @@ internal static bool ConfigureSpinningDiskVisual(TurntableComponent component, G component.RotationTarget = visual.transform; var radiusSquared = 0f; // FP spinning-disk primary models are treated as the playable disc; auxiliary geometry can overestimate this radius. + var worldToVisual = visual.transform.worldToLocalMatrix; foreach (var filter in visual.GetComponentsInChildren(true)) { - if (filter.sharedMesh == null) continue; - foreach (var vertex in filter.sharedMesh.vertices) { - var local = visual.transform.InverseTransformPoint(filter.transform.TransformPoint(vertex)); + var mesh = filter.sharedMesh; + if (mesh == null) continue; + // Composed once per mesh rather than resolving both transforms for every vertex. + var meshToVisual = worldToVisual * filter.transform.localToWorldMatrix; + foreach (var vertex in mesh.vertices) { + var local = meshToVisual.MultiplyPoint3x4(vertex); radiusSquared = Mathf.Max(radiusSquared, local.x * local.x + local.z * local.z); } } @@ -638,7 +645,8 @@ private UnityMaterial GetOrCreateMaterial(FuturePinballMaterialDescription sourc } if (existing != null) AssetDatabase.DeleteAsset(path); - var pbr = new PbrMaterial(source.ToVpeMaterial(), id: hash); + var vpeMaterial = source.ToVpeMaterial(); + var pbr = new PbrMaterial(vpeMaterial, id: hash); var material = RenderPipeline.Current?.MaterialConverter?.CreateMaterial(pbr, null) ?? new UnityMaterial(Shader.Find("Standard")); material.name = source.Name; @@ -651,7 +659,7 @@ private UnityMaterial GetOrCreateMaterial(FuturePinballMaterialDescription sourc SetFloat(material, "_Cull", 0f); } if (source.IsEmissive) { - var color = source.ToVpeMaterial().BaseColor.ToUnityColor(); + var color = vpeMaterial.BaseColor.ToUnityColor(); if (material.HasProperty("_EmissiveColor")) material.SetColor("_EmissiveColor", color); if (material.HasProperty("_EmissionColor")) material.SetColor("_EmissionColor", color); material.EnableKeyword("_EMISSION"); @@ -993,9 +1001,14 @@ public UnityEngine.Texture GetTexture(string name) return !string.IsNullOrWhiteSpace(name) && TryGetTexture(name, out var texture) ? texture : null; } - public bool HasMaterial(PbrMaterial material) + // The public provider methods derive the key once and hand it to the private overloads; + // deriving it is a SHA-256, and GetMaterial used to recompute it up to three times a call. + public bool HasMaterial(PbrMaterial material) => HasNativeMaterial(NativeMaterialKey(material)); + + public void SaveMaterial(PbrMaterial source, UnityMaterial material) => SaveMaterial(NativeMaterialKey(source), material); + + private bool HasNativeMaterial(string key) { - var key = NativeMaterialKey(material); if (_nativeMaterials.ContainsKey(key)) return true; if (!_options.ReuseGeneratedAssets) return false; var existing = AssetDatabase.LoadAssetAtPath(NativeMaterialPath(key)); @@ -1005,9 +1018,8 @@ public bool HasMaterial(PbrMaterial material) return true; } - public void SaveMaterial(PbrMaterial source, UnityMaterial material) + private void SaveMaterial(string key, UnityMaterial material) { - var key = NativeMaterialKey(source); var path = NativeMaterialPath(key); var existing = AssetDatabase.LoadAssetAtPath(path); if (existing != null && _options.ReuseGeneratedAssets) { @@ -1026,11 +1038,12 @@ public void SaveMaterial(PbrMaterial source, UnityMaterial material) public UnityMaterial GetMaterial(PbrMaterial material) { - if (HasMaterial(material)) return _nativeMaterials[NativeMaterialKey(material)]; + var key = NativeMaterialKey(material); + if (HasNativeMaterial(key)) return _nativeMaterials[key]; var unityMaterial = RenderPipeline.Current?.MaterialConverter?.CreateMaterial(material ?? new PbrMaterial(), this) ?? new UnityMaterial(Shader.Find("Standard")); - SaveMaterial(material, unityMaterial); - return _nativeMaterials[NativeMaterialKey(material)]; + SaveMaterial(key, unityMaterial); + return _nativeMaterials[key]; } public PhysicsMaterialAsset GetPhysicsMaterial(string name) => null; @@ -1039,11 +1052,12 @@ public UnityMaterial MergeMaterials(string vpxMaterial, UnityMaterial textureMat { if (textureMaterial == null) return GetMaterial(new PbrMaterial(id: vpxMaterial)); var source = new PbrMaterial(id: string.IsNullOrWhiteSpace(vpxMaterial) ? PbrMaterial.NameNoMaterial : vpxMaterial); - if (HasMaterial(source)) return _nativeMaterials[NativeMaterialKey(source)]; + var key = NativeMaterialKey(source); + if (HasNativeMaterial(key)) return _nativeMaterials[key]; var merged = RenderPipeline.Current?.MaterialConverter?.MergeMaterials(source, textureMaterial) ?? new UnityMaterial(textureMaterial); - SaveMaterial(source, merged); - return _nativeMaterials[NativeMaterialKey(source)]; + SaveMaterial(key, merged); + return _nativeMaterials[key]; } private static string NativeMaterialKey(PbrMaterial material) @@ -1055,7 +1069,7 @@ private static string NativeMaterialKey(PbrMaterial material) private static void SetTexture(UnityMaterial material, UnityEngine.Texture texture) { - foreach (var property in new[] { "_BaseColorMap", "_BaseMap", "_MainTex" }) { + foreach (var property in BaseTextureProperties) { if (material.HasProperty(property)) material.SetTexture(property, texture); } } @@ -1085,8 +1099,11 @@ private static string PhysicalPath(string assetPath) private static string SafeName(string value) { if (string.IsNullOrWhiteSpace(value)) return "unnamed"; - var invalid = new HashSet("<>:\"/\\|?*"); - var result = new string(value.Select(character => character < 0x20 || invalid.Contains(character) ? '_' : character).ToArray()).Trim().TrimEnd('.', ' '); + var characters = new char[value.Length]; + for (var i = 0; i < value.Length; i++) { + characters[i] = value[i] < 0x20 || InvalidFileNameCharacters.Contains(value[i]) ? '_' : value[i]; + } + var result = new string(characters).Trim().TrimEnd('.', ' '); return string.IsNullOrEmpty(result) ? "unnamed" : result.Length > 80 ? result.Substring(0, 80) : result; } From f7e2c4558b408a5afce00385789d36876287ecf2 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 26 Jul 2026 00:11:23 +0200 Subject: [PATCH 30/31] io(fpt): load Future Pinball libraries only when needed Extraction read every adjacent .fpl and decompressed every entry in it before looking at a single resource, even though libraries are consulted only to resolve a linked resource that has no embedded data. Next to a real library folder that is hundreds of megabytes of wasted decompression. --- .../IO/FuturePinball/FuturePinballExtractor.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs index 45ed8bedb..d17931164 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballExtractor.cs @@ -1,4 +1,4 @@ -// Visual Pinball Engine +// Visual Pinball Engine // Copyright (C) 2026 freezy and VPE Team // // This program is free software: you can redistribute it and/or modify @@ -84,7 +84,9 @@ public static FuturePinballExtractionManifest Extract( var issues = new List(table.Issues); var manifestStreams = ExtractStreams(outputRoot, table.Streams, options); - var libraries = LoadLibraries(sourcePath, options, issues); + // Loaded on first use: every adjacent .fpl is read and fully decompressed, which is + // wasted work for the common case of a table with no unresolved linked resource. + var libraries = new Lazy>(() => 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)); @@ -189,7 +191,7 @@ private static IEnumerable ExtractResources( string outputRoot, IEnumerable streams, string category, - IReadOnlyList libraries, + Lazy> libraries, FuturePinballExtractionOptions options, ICollection issues) { @@ -201,7 +203,7 @@ private static IEnumerable ExtractResources( FuturePinballLibrary library = null; FuturePinballLibraryEntry entry = null; if (data == null && linked) { - FindLibraryResource(libraries, logicalName, linkedPath, out library, out entry); + FindLibraryResource(libraries.Value, logicalName, linkedPath, out library, out entry); data = entry?.Data?.DecodedBytes; } @@ -237,7 +239,7 @@ private static IEnumerable ExtractResources( private static IEnumerable ExtractPinModels( string outputRoot, IEnumerable streams, - IReadOnlyList libraries, + Lazy> libraries, FuturePinballExtractionOptions options, ICollection issues) { @@ -259,7 +261,7 @@ private static IEnumerable ExtractPinModels( 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); + FindLibraryResource(libraries.Value, logicalName, linkedPath, out library, out entry); var data = entry?.Data?.DecodedBytes; if (data != null) { var format = DetectFormat(data, null, linkedPath); From a35dae619ed9a570c75e815ed3cc979446375cad Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 26 Jul 2026 00:12:47 +0200 Subject: [PATCH 31/31] io(fpt): complete the native items meta file It was committed with only the format version and guid, so Unity would regenerate the importer block and produce a spurious diff. --- .../IO/FuturePinball/FuturePinballNativeItems.cs.meta | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs.meta b/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs.meta index 686aa2108..90d4c9701 100644 --- a/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs.meta +++ b/VisualPinball.Engine/IO/FuturePinball/FuturePinballNativeItems.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 guid: e104fbf26238406b9ca62553f9a94594 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: