Skip to content
Draft
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6561505
test(fpt): lock Future Pinball fixture corpus
freezy Jul 12, 2026
ac961aa
io(fpt): add safe Future Pinball readers
freezy Jul 12, 2026
314ddc6
io(fpt): extract lossless source bundles
freezy Jul 12, 2026
9df37d0
io(fpt): decode MilkShape model meshes
freezy Jul 12, 2026
3a2c02b
io(fpt): generate procedural table meshes
freezy Jul 12, 2026
2855ed9
io(fpt): map Future Pinball materials
freezy Jul 12, 2026
596e3f3
physics(fpt): convert model collision shapes
freezy Jul 12, 2026
9fd7319
editor(fpt): add lossless static table import
freezy Jul 12, 2026
50e9342
physics(fpt): use native VPE colliders
freezy Jul 12, 2026
0f431cd
editor(fpt): handle duplicate source names
freezy Jul 12, 2026
122b0cd
editor(fpt): disambiguate surface placement
freezy Jul 12, 2026
02e7769
editor(fpt): skip unresolved surface placement
freezy Jul 12, 2026
0dc17c9
editor(fpt): map native table elements
freezy Jul 14, 2026
2e321ab
editor(fpt): map spinning disks
freezy Jul 14, 2026
e05af86
editor(fpt): clarify spinning disk defaults
freezy Jul 14, 2026
33c547c
editor(fpt): skip unresolved procedural placement
freezy Jul 14, 2026
505fbc0
editor(fpt): stabilize native scene recreation
freezy Jul 25, 2026
0e054ef
io(fpt): index resource referrers in a single pass
freezy Jul 25, 2026
13e9875
io(fpt): cut redundant reads and copies from extraction
freezy Jul 25, 2026
3525f29
editor(fpt): extract from the already parsed table
freezy Jul 25, 2026
7bb43cf
editor(fpt): build model geometry once per model
freezy Jul 25, 2026
98aae80
editor(fpt): batch asset creation during scene construction
freezy Jul 25, 2026
6e96d71
physics(fpt): merge collider group meshes in one pass
freezy Jul 25, 2026
eff4d04
io(fpt): bound the missing stream index report
freezy Jul 25, 2026
56cff5f
io(fpt): decode element height from either encoding
freezy Jul 25, 2026
b010a41
io: write tables to disk instead of buffering them in memory
freezy Jul 25, 2026
360bd8d
editor(fpt): replace the source bundle when re-importing a table
freezy Jul 25, 2026
2026505
io(fpt): drop per-triangle allocations from the model reader
freezy Jul 25, 2026
2d87792
editor(fpt): trim repeated work from the scene converter
freezy Jul 25, 2026
f7e2c45
io(fpt): load Future Pinball libraries only when needed
freezy Jul 25, 2026
a35dae6
io(fpt): complete the native items meta file
freezy Jul 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions VisualPinball.Engine.Test/Fixtures~/FuturePinball/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Future Pinball integration fixtures

The Future Pinball integration tests use five historical Three Angels tables as an external corpus. The table files are intentionally not committed because of their size and redistribution status.

Set `VPE_FPT_FIXTURES` to the directory containing this layout before running the tests:

```text
fp-2008-1.666/3ANGELS.fpt
fp-2012-installed/3 Angels.fpt
fp-2012-ultra-release/Three Angels_ehanc_Slam.fpt
fp-2012-ultra-source/Three Angels.fpt
fp-2013-enhanced/Three Angels_ehanc_Slam.fpt
```

`FuturePinballFixtureCatalog` locks the source size and SHA-256, compound-directory entry and resource counts, element-type distribution, Table Data size, and compressed/decoded script boundaries and hashes. Tests skip this external corpus when the environment variable is absent; unit tests that synthesize malformed streams remain self-contained.

For the workspace research corpus:

```powershell
$env:VPE_FPT_FIXTURES='E:\_vpe-2025\_analysis\three-angels-fp'
dotnet test VisualPinball.Engine.Test/VisualPinball.Engine.Test.csproj --filter FuturePinball
```
8 changes: 8 additions & 0 deletions VisualPinball.Engine.Test/IO/FuturePinball.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Visual Pinball Engine
// Copyright (C) 2026 freezy and VPE Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

using System.Linq;

using NUnit.Framework;

using VisualPinball.Engine.IO.FuturePinball;
using VisualPinball.Engine.Math;
using VisualPinball.Engine.VPT;

namespace VisualPinball.Engine.Test.IO.FuturePinball
{
[TestFixture]
public class FuturePinballColliderTests
{
[Test]
public void ConvertsDocumentedAnalyticShapesToModelWorldSpace()
{
var colliders = FuturePinballColliderBuilder.FromShapes(new[] {
new FuturePinballCollisionShape(1, true, 10f, 20f, 30f, 5f, 12f),
new FuturePinballCollisionShape(2, true, 0f, 0f, 0f, 8f),
new FuturePinballCollisionShape(3, true, 0f, 4f, 10f, 6f, 3f, 40f, 2f),
new FuturePinballCollisionShape(5, true, 0f, 0f, 0f, 2f, 3f, 4f),
new FuturePinballCollisionShape(7, true, 0f, 0f, 0f, 5f, 12f, 6f)
});

Assert.That(colliders.Select(collider => collider.Kind), Is.EqualTo(new[] {
FuturePinballColliderKind.VerticalCylinder,
FuturePinballColliderKind.Sphere,
FuturePinballColliderKind.TaperedCapsule,
FuturePinballColliderKind.Box,
FuturePinballColliderKind.HorizontalCylinder
}));
Assert.That(colliders.All(collider => collider.Status == FuturePinballColliderStatus.Generated), Is.True);
Assert.That(colliders[0].Center.X, Is.EqualTo(0.01f).Within(0.000001f));
Assert.That(colliders[0].Center.Y, Is.EqualTo(0.02f).Within(0.000001f));
Assert.That(colliders[0].Center.Z, Is.EqualTo(-0.03f).Within(0.000001f));
Assert.That(colliders[0].Radius, Is.EqualTo(0.005f).Within(0.000001f));
Assert.That(colliders[1].Radius, Is.EqualTo(0.008f).Within(0.000001f));
Assert.That(colliders[2].Center.Z, Is.EqualTo(0.01f).Within(0.000001f));
Assert.That(colliders[2].SecondaryRadius, Is.EqualTo(0.003f).Within(0.000001f));
Assert.That(colliders[3].Size.X, Is.EqualTo(0.004f).Within(0.000001f));
Assert.That(colliders[3].Size.Y, Is.EqualTo(0.006f).Within(0.000001f));
Assert.That(colliders[3].Size.Z, Is.EqualTo(0.008f).Within(0.000001f));
Assert.That(colliders[4].SecondaryRadius, Is.EqualTo(0.006f).Within(0.000001f));
}

[Test]
public void ReportsNonPhysicalUnknownAndInvalidShapes()
{
var colliders = FuturePinballColliderBuilder.FromShapes(new[] {
new FuturePinballCollisionShape(1, false, 0f, 0f, 0f, 1f, 1f),
new FuturePinballCollisionShape(6, true, 0f, 0f, 0f, 1f),
new FuturePinballCollisionShape(2, true, 0f, 0f, 0f, float.NaN)
});

Assert.That(colliders[0].Status, Is.EqualTo(FuturePinballColliderStatus.Skipped));
Assert.That(colliders[1].Status, Is.EqualTo(FuturePinballColliderStatus.Unsupported));
Assert.That(colliders[2].Status, Is.EqualTo(FuturePinballColliderStatus.Invalid));
}

[Test]
public void BuildsPerPolygonMeshAndRejectsDegenerateTriangles()
{
var mesh = new Mesh(new[] {
new Vertex3DNoTex2(0f, 0f, 0f),
new Vertex3DNoTex2(100f, 0f, 0f),
new Vertex3DNoTex2(0f, 0f, 100f),
new Vertex3DNoTex2(5f, 5f, 5f)
}, new[] { 0, 1, 2, 3, 3, 3 });

var collider = FuturePinballColliderBuilder.FromMesh(new[] { mesh });

Assert.That(collider.Status, Is.EqualTo(FuturePinballColliderStatus.Generated));
Assert.That(collider.Mesh.Indices, Has.Length.EqualTo(3));
Assert.That(collider.Mesh.Indices, Is.EqualTo(new[] { 0, 2, 1 }));
Assert.That(collider.Mesh.Vertices[2].Z, Is.EqualTo(-0.1f));
}

[Test]
public void TessellatesGeneratedShapesForVpePrimitiveColliders()
{
var descriptions = FuturePinballColliderBuilder.FromShapes(new[] {
new FuturePinballCollisionShape(1, true, 0f, 0f, 0f, 5f, 12f),
new FuturePinballCollisionShape(2, true, 0f, 0f, 0f, 8f),
new FuturePinballCollisionShape(3, true, 0f, 0f, 40f, 6f, 3f, 40f, 2f),
new FuturePinballCollisionShape(5, true, 0f, 0f, 0f, 2f, 3f, 4f),
new FuturePinballCollisionShape(7, true, 0f, 0f, 0f, 5f, 12f, 6f)
});

var meshes = descriptions.Select(description => FuturePinballColliderMeshBuilder.Build(description)).ToArray();

Assert.That(meshes.All(mesh => mesh?.IsSet == true && mesh.Indices.Length >= 12), Is.True);
Assert.That(meshes.All(mesh => mesh.Indices.Length % 3 == 0), Is.True);
foreach (var mesh in meshes) AssertClosedMeshFacesOutward(mesh);
AssertBounds(meshes[0], -0.005f, 0.005f, -0.012f, 0.012f, -0.005f, 0.005f);
AssertBounds(meshes[1], -0.008f, 0.008f, -0.008f, 0.008f, -0.008f, 0.008f);
AssertBounds(meshes[2], -0.006f, 0.006f, -0.002f, 0.002f, -0.026f, 0.023f);
AssertBounds(meshes[3], -0.002f, 0.002f, -0.003f, 0.003f, -0.004f, 0.004f);
AssertBounds(meshes[4], -0.005f, 0.005f, -0.006f, 0.006f, -0.012f, 0.012f);
}

[Test]
public void DoesNotCreateVpeMeshForSkippedOrUnsupportedShapes()
{
var descriptions = FuturePinballColliderBuilder.FromShapes(new[] {
new FuturePinballCollisionShape(1, false, 0f, 0f, 0f, 1f, 1f),
new FuturePinballCollisionShape(6, true, 0f, 0f, 0f, 1f)
});

Assert.That(FuturePinballColliderMeshBuilder.Build(descriptions[0]), Is.Null);
Assert.That(FuturePinballColliderMeshBuilder.Build(descriptions[1]), Is.Null);
}

private static void AssertBounds(Mesh mesh, float minX, float maxX, float minY, float maxY, float minZ, float maxZ)
{
Assert.That(mesh.Vertices.Min(vertex => vertex.X), Is.EqualTo(minX).Within(0.000001f));
Assert.That(mesh.Vertices.Max(vertex => vertex.X), Is.EqualTo(maxX).Within(0.000001f));
Assert.That(mesh.Vertices.Min(vertex => vertex.Y), Is.EqualTo(minY).Within(0.000001f));
Assert.That(mesh.Vertices.Max(vertex => vertex.Y), Is.EqualTo(maxY).Within(0.000001f));
Assert.That(mesh.Vertices.Min(vertex => vertex.Z), Is.EqualTo(minZ).Within(0.000001f));
Assert.That(mesh.Vertices.Max(vertex => vertex.Z), Is.EqualTo(maxZ).Within(0.000001f));
}

private static void AssertClosedMeshFacesOutward(Mesh mesh)
{
var centerX = mesh.Vertices.Average(vertex => vertex.X);
var centerY = mesh.Vertices.Average(vertex => vertex.Y);
var centerZ = mesh.Vertices.Average(vertex => vertex.Z);
for (var i = 0; i < mesh.Indices.Length; i += 3) {
Assert.That(mesh.Indices[i], Is.InRange(0, mesh.Vertices.Length - 1));
Assert.That(mesh.Indices[i + 1], Is.InRange(0, mesh.Vertices.Length - 1));
Assert.That(mesh.Indices[i + 2], Is.InRange(0, mesh.Vertices.Length - 1));
var a = mesh.Vertices[mesh.Indices[i]];
var b = mesh.Vertices[mesh.Indices[i + 1]];
var c = mesh.Vertices[mesh.Indices[i + 2]];
var abX = b.X - a.X;
var abY = b.Y - a.Y;
var abZ = b.Z - a.Z;
var acX = c.X - a.X;
var acY = c.Y - a.Y;
var acZ = c.Z - a.Z;
var normalX = abY * acZ - abZ * acY;
var normalY = abZ * acX - abX * acZ;
var normalZ = abX * acY - abY * acX;
var areaSquared = normalX * normalX + normalY * normalY + normalZ * normalZ;
Assert.That(areaSquared, Is.GreaterThan(1e-20f));
var faceX = (a.X + b.X + c.X) / 3f - centerX;
var faceY = (a.Y + b.Y + c.Y) / 3f - centerY;
var faceZ = (a.Z + b.Z + c.Z) / 3f - centerZ;
Assert.That(normalX * faceX + normalY * faceY + normalZ * faceZ, Is.GreaterThan(0f),
$"{mesh.Name} triangle {i / 3} faces inward");
}
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading