diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1e9bcd7..ebeb1c8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,58 +1,82 @@ -name: Build -on: [ push, pull_request ] - -defaults: - run: - shell: bash - -jobs: - build: - name: Build ${{ matrix.rid }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - os: windows-latest - rid: win-x64 - - os: windows-latest - rid: win-x86 - - os: macos-latest - rid: osx - - os: macos-latest - rid: ios-arm64 - - os: ubuntu-22.04 - rid: linux-x64 - - os: ubuntu-22.04 - rid: android-arm64-v8a - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '3.1.x' - - name: Build - run: | - cd VisualPinball.Engine.PinMAME - dotnet build -c Release -r ${{ matrix.rid }} -# - name: Test -# run: | -# cd VisualPinball.Engine.PinMAME.Test -# dotnet run -c Release -r ${{ matrix.rid }} - - run: | - mkdir tmp - cp -r VisualPinball.Engine.PinMAME.Unity/Plugins/${{ matrix.rid }} tmp - - uses: actions/upload-artifact@v4 - with: - name: Plugins-${{ matrix.rid }} - path: tmp - - dispatch: - runs-on: ubuntu-latest - needs: [ build ] - if: github.repository == 'VisualPinball/VisualPinball.Engine.PinMAME' && github.ref == 'refs/heads/master' && github.event_name == 'push' - steps: - - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ secrets.GH_PAT }} - event-type: build-complete - client-payload: '{"artifacts_run_id": "${{ github.run_id }}"}' +name: Build +on: [ push, pull_request ] + +defaults: + run: + shell: bash + +jobs: + build: + name: Build ${{ matrix.rid }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + rid: win-x64 + - os: windows-latest + rid: win-x86 + - os: macos-latest + rid: osx + - os: macos-latest + rid: ios-arm64 + - os: ubuntu-22.04 + rid: linux-x64 + - os: ubuntu-22.04 + rid: android-arm64-v8a + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + - name: Build + run: | + cd VisualPinball.Engine.PinMAME + dotnet build -c Release -r ${{ matrix.rid }} + - run: | + mkdir tmp + cp -r VisualPinball.Engine.PinMAME.Unity/Plugins/${{ matrix.rid }} tmp + - uses: actions/upload-artifact@v4 + with: + name: Plugins-${{ matrix.rid }} + path: tmp + + test: + name: Test definition conversion + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Check out the pinned machine-definition corpus + uses: actions/checkout@v4 + with: + repository: vpinball/pinmame-game-defs + ref: 4b3c12f776c4355f5dd27b942edf45d933b479b6 + path: pinmame-game-defs + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: pinmame-game-defs/site/package-lock.json + - name: Generate the published machine-definition data + working-directory: pinmame-game-defs/site + run: | + npm ci + npm run data + - run: dotnet test VisualPinball.Engine.PinMAME.Test/VisualPinball.Engine.PinMAME.Test.csproj --configuration Release + env: + PINMAME_GAME_DEFS_ROOT: ${{ github.workspace }}/pinmame-game-defs + + dispatch: + runs-on: ubuntu-latest + needs: [ build, test ] + if: github.repository == 'VisualPinball/VisualPinball.Engine.PinMAME' && github.ref == 'refs/heads/master' && github.event_name == 'push' + steps: + - uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.GH_PAT }} + event-type: build-complete + client-payload: '{"artifacts_run_id": "${{ github.run_id }}"}' diff --git a/README.md b/README.md index 278f32b..53ac582 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,15 @@ ## Structure -This project contains two root folders: +This project contains three root folders: -- `VisualPinball.Engine.PinMAME` wraps the [`pinmame-dotnet`](https://github.com/vpinball/pinmame-dotnet) - library and extends it with some meta data. It's also the project that provides - the Unity plugins. -- `VisualPinball.Engine.PinMAME.Unity` is the Unity UPM package that plugs into - VPE and implements the gamelogic engine. +- `VisualPinball.Engine.PinMAME` wraps the [`pinmame-dotnet`](https://github.com/vpinball/pinmame-dotnet) library and produces the managed Unity plugin. +- `VisualPinball.Engine.PinMAME.Unity` is the Unity UPM package that plugs into VPE and implements the gamelogic engine. +- `VisualPinball.Engine.PinMAME.Test` verifies the editor-side conversion from the public machine-definition schema to VPE hardware mappings. + +Machine definitions are not compiled into this package. In the Unity Editor, the PinMAME inspector loads the catalog from `https://games.visualpinball.org`, caches it under the project's `Library/PinMAME/GameDefinitions` directory, and populates the table's serialized hardware mappings. Runtime builds contain those mappings and the selected machine/ROM identifiers, but none of the fetched catalog metadata or networking code. CI converts a pinned revision of the complete curated corpus so incompatible definition changes fail before release. + +The published site endpoint is preferred to raw GitHub files because it exposes a stable joined index with all ROM aliases, supports CORS and conditional HTTP caching, and requires only one index request plus one detail request for the selected machine. The GitHub repository remains the canonical source from which the site is built. ### Unity Package @@ -57,6 +59,6 @@ git update-index --assume-unchanged VisualPinball.Engine.PinMAME.Unity/Plugins/w ## License -This plugin is licensed under the [MIT license](LICENSE). However +This plugin is licensed under the [MIT license](LICENSE). However since we link against PinMAME, the [MAME/BSD-3-Clause](https://github.com/vpinball/pinmame/blob/master/LICENSE) -must be honored as well. \ No newline at end of file +must be honored as well. diff --git a/VisualPinball.Engine.PinMAME.Test/PinMameGameDefinitionConverterTests.cs b/VisualPinball.Engine.PinMAME.Test/PinMameGameDefinitionConverterTests.cs new file mode 100644 index 0000000..4f92f57 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Test/PinMameGameDefinitionConverterTests.cs @@ -0,0 +1,558 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using VisualPinball.Engine.Common; +using VisualPinball.Engine.Game.Engines; +using VisualPinball.Engine.Math; + +namespace VisualPinball.Engine.PinMAME.Editor +{ + [TestFixture] + public class PinMameGameDefinitionConverterTests + { + [Test] + public void ConvertsTransportBindingsAndDriverOverrides() + { + const string json = @"{ + 'format': 'pinmame-machine-definition', + 'schema_version': 2, + 'machine': { 'id': 'test.machine.2026', 'name': 'Test Machine' }, + 'coverage': { 'status': 'partial', 'completion_score': 75 }, + 'drivers': [ + { 'id': 'test_1', 'description': 'Test 1' }, + { 'id': 'test_2', 'description': 'Test 2', 'overrides': [ + { 'target': 'switch.start', 'binding': { 'group': 'pinmame.input.switch', 'device': 12 } } + ] } + ], + 'catalogDrivers': [ + { 'id': 'test_3', 'description': 'Catalog-only test ROM' } + ], + 'inputs': [ + { 'id': 'switch.start', 'label': 'Start', 'kind': 'switch', 'binding': { 'group': 'pinmame.input.switch', 'device': 11 }, 'roles': ['cabinet.start'], 'pulse': true }, + { 'id': 'switch.tournament-start', 'label': 'Tournament start', 'kind': 'switch', 'binding': { 'group': 'pinmame.input.switch', 'device': 17 }, 'roles': ['cabinet.tournament-start'], 'pulse': true }, + { 'id': 'switch.tournament-start-generic', 'label': 'Tournament Start', 'kind': 'switch', 'binding': { 'group': 'pinmame.input.switch', 'device': 18 }, 'roles': ['cabinet.start'], 'pulse': true }, + { 'id': 'switch.pendulum-tilt', 'label': 'Pendulum Tilt', 'kind': 'switch', 'binding': { 'group': 'pinmame.input.switch', 'device': 19 }, 'roles': ['cabinet.tilt'] }, + { 'id': 'switch.slam-tilt-generic', 'label': 'Slam Tilt', 'kind': 'switch', 'binding': { 'group': 'pinmame.input.switch', 'device': 20 }, 'roles': ['cabinet.tilt'] }, + { 'id': 'switch.ball-roll-tilt', 'label': 'Ball Roll Tilt', 'kind': 'switch', 'binding': { 'group': 'pinmame.input.switch', 'device': 21 }, 'roles': ['cabinet.tilt'] }, + { 'id': 'switch.flipper', 'label': 'Left Main Flipper Button', 'kind': 'switch', 'binding': { 'group': 'pinmame.input.switch', 'device': 13 }, 'roles': ['cabinet.flipper'] }, + { 'id': 'switch.flipper-virtual', 'label': 'Virtual Left Flipper Compatibility State', 'kind': 'virtual', 'binding': { 'group': 'pinmame.input.switch', 'device': 22 }, 'roles': ['internal.compatibility'] }, + { 'id': 'switch.flipper-right', 'label': 'Right Flipper Button', 'kind': 'switch', 'binding': { 'group': 'pinmame.input.switch', 'device': 16 }, 'roles': ['flipper.lower.right.button'] }, + { 'id': 'switch.flipper-upper', 'label': 'Upper Left Flipper Button', 'kind': 'switch', 'binding': { 'group': 'pinmame.input.switch', 'device': 15 }, 'roles': ['flipper.upper.left'] }, + { 'id': 'switch.flipper-mini', 'label': 'Lower mini-playfield left flipper button', 'kind': 'switch', 'binding': { 'group': 'pinmame.input.switch', 'device': 88 }, 'roles': ['cabinet.flipper'] }, + { 'id': 'switch.eos', 'label': 'Left Flipper EOS', 'kind': 'switch', 'binding': { 'group': 'pinmame.input.switch', 'device': 14 }, 'roles': ['flipper.lower.left.button', 'cabinet.flipper'] }, + { 'id': 'switch.service-wpc', 'label': 'Service Credits / Escape', 'kind': 'switch', 'binding': { 'group': 'pinmame.input.switch', 'device': 5 }, 'roles': ['cabinet.service'] }, + { 'id': 'switch.service-sam', 'label': 'Coin-door Back button', 'kind': 'switch', 'binding': { 'group': 'pinmame.input.switch', 'device': -3 }, 'roles': ['service.button'] }, + { 'id': 'switch.grip', 'label': 'Grip Trigger', 'kind': 'switch', 'binding': { 'group': 'pinmame.input.switch', 'device': 34 }, 'roles': ['cabinet.grip-trigger'] }, + { 'id': 'switch.fire', 'label': 'Fire', 'kind': 'switch', 'binding': { 'group': 'pinmame.input.switch', 'device': 64 }, 'roles': ['cabinet.fire'] }, + { 'id': 'switch.buy-in', 'label': 'Buy-in', 'kind': 'switch', 'binding': { 'group': 'pinmame.input.switch', 'device': 10 }, 'roles': ['cabinet.buy-in'] }, + { 'id': 'switch.constant', 'label': 'Interlock', 'kind': 'constant', 'binding': { 'group': 'pinmame.input.switch', 'device': -1 }, 'constant_active': true }, + { 'id': 'switch.unused', 'label': 'Unused', 'kind': 'switch', 'binding': { 'group': 'pinmame.input.switch', 'device': 99 }, 'availability': 'unused' } + ], + 'outputs': [ + { 'id': 'flasher.one', 'label': 'Flasher', 'kind': 'flasher', 'binding': { 'group': 'pinmame.output.solenoid', 'device': 1 } }, + { 'id': 'device.left-flipper', 'label': 'Left Flipper', 'kind': 'coil', 'binding': { 'group': 'pinmame.output.solenoid', 'device': 6 } }, + { 'id': 'coil.flipper-upper-left', 'label': 'Upper Left Flipper', 'kind': 'coil', 'binding': { 'group': 'pinmame.output.solenoid', 'device': 7 } }, + { 'id': 'coil.right', 'label': 'Right Flipper', 'kind': 'coil', 'binding': { 'group': 'pinmame.output.solenoid', 'device': 8 }, 'roles': ['flipper.right.coil'] }, + { 'id': 'coil.lower-mini-playfield-left-flipper', 'label': 'Lower mini-playfield left flipper', 'kind': 'coil', 'binding': { 'group': 'pinmame.output.solenoid', 'device': 4 } }, + { 'id': 'lamp.one.red', 'label': 'Lamp (red)', 'kind': 'rgb_lamp', 'binding': { 'group': 'pinmame.output.lamp', 'device': 2 } }, + { 'id': 'gi.one', 'label': 'GI without explicit legacy range', 'kind': 'gi', 'binding': { 'group': 'pinmame.output.gi', 'device': 3 } }, + { 'id': 'gi.explicit', 'label': 'GI with explicit range', 'kind': 'gi', 'binding': { 'group': 'pinmame.output.gi', 'device': 4 }, 'range': { 'minimum': 0, 'maximum': 8, 'steps': 9 } }, + { 'id': 'lamp.degenerate-range', 'label': 'Degenerate fading range', 'kind': 'lamp', 'binding': { 'group': 'pinmame.output.lamp', 'device': 6 }, 'range': { 'minimum': 0, 'maximum': 0, 'steps': 3 } }, + { 'id': 'coil.lamp-transport', 'label': 'Lamp-driven coil', 'kind': 'coil', 'binding': { 'group': 'pinmame.output.lamp', 'device': 4 } }, + { 'id': 'state.virtual', 'label': 'Virtual state bit', 'kind': 'virtual', 'binding': { 'group': 'pinmame.output.solenoid', 'device': 5 } }, + { 'id': 'lamp.unused', 'label': 'Unused lamp', 'kind': 'lamp', 'binding': { 'group': 'pinmame.output.lamp', 'device': 5 }, 'availability': 'unused' } + ] + }"; + + var definition = PinMameGameDefinitionConverter.Convert(json, "test_2"); + + Assert.That(definition.MachineId, Is.EqualTo("test.machine.2026")); + Assert.That(definition.CompletionScore, Is.EqualTo(75)); + Assert.That(definition.Switches.Select(@switch => @switch.Id), Is.EqualTo(new[] { "-3", "-1", "5", "10", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "34", "64", "88" })); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "12").InputActionHint, Is.EqualTo(InputConstants.ActionStartGame)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "17").InputActionHint, Is.Empty); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "18").InputActionHint, Is.Empty); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "19").InputActionHint, Is.EqualTo("Tilt")); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "20").InputActionHint, Is.EqualTo(InputConstants.ActionSlamTilt)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "21").InputActionHint, Is.Empty); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "13").InputActionHint, Is.EqualTo(InputConstants.ActionLeftFlipper)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "14").InputActionHint, Is.Empty); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "88").InputActionHint, Is.Empty); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "5").InputActionHint, Is.EqualTo(InputConstants.ActionCoinDoorCancel)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "-3").InputActionHint, Is.EqualTo(InputConstants.ActionCoinDoorBack)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "34").InputActionHint, Is.EqualTo(InputConstants.ActionPlunger)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "64").InputActionHint, Is.EqualTo(InputConstants.ActionFire1)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "10").InputActionHint, Is.EqualTo(InputConstants.ActionFrontBuyIn)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "-1").ConstantHint, Is.EqualTo(SwitchConstantHint.AlwaysClosed)); + Assert.That(definition.Coils.Single(coil => coil.Id == "1").IsLamp, Is.True); + Assert.That(definition.Coils.Single(coil => coil.Id == "1").DeviceHint, Is.EqualTo("^F1?1$")); + Assert.That(definition.Coils.Single(coil => coil.Id == "6").DeviceHint, Does.Contain("LeftFlipper")); + Assert.That(definition.Coils.Single(coil => coil.Id == "6").DeviceHint, Does.Contain("FlipperLeft")); + Assert.That(definition.Coils, Has.None.Matches(coil => coil.Id == "5")); + Assert.That(definition.Wires, Has.Exactly(1).Matches(wire => wire.SourceId == "13" && wire.DestinationId == "6")); + Assert.That(definition.Wires, Has.Exactly(1).Matches(wire => wire.SourceId == "16" && wire.DestinationId == "8")); + Assert.That(definition.Wires, Has.Exactly(1).Matches(wire => wire.SourceId == "15" && wire.DestinationId == "7")); + Assert.That(definition.Wires, Has.None.Matches(wire => wire.SourceId == "88" || wire.DestinationId == "4")); + Assert.That(definition.Lamps.Single(lamp => lamp.Id == "2").Channel, Is.EqualTo(ColorChannel.Red)); + Assert.That(definition.Lamps.Single(lamp => lamp.Id == "3").Source, Is.EqualTo(LampSource.GI)); + Assert.That(definition.Lamps.Single(lamp => lamp.Id == "3").Type, Is.EqualTo(LampType.SingleFading)); + Assert.That(definition.Lamps.Single(lamp => lamp.Id == "3").FadingSteps, Is.EqualTo(8)); + Assert.That(definition.Lamps.Single(lamp => lamp.Id == "4").Type, Is.EqualTo(LampType.SingleFading)); + Assert.That(definition.Lamps.Single(lamp => lamp.Id == "4").FadingSteps, Is.EqualTo(8)); + Assert.That(definition.Lamps.Single(lamp => lamp.Id == "6").Type, Is.EqualTo(LampType.SingleFading)); + Assert.That(definition.Lamps.Single(lamp => lamp.Id == "6").FadingSteps, Is.EqualTo(2)); + Assert.That(definition.Diagnostics, Has.Exactly(1).Contains("Lamp-driven coil")); + + var catalogOnlyDefinition = PinMameGameDefinitionConverter.Convert(json, "test_3"); + Assert.That(catalogOnlyDefinition.Switches.Single(@switch => @switch.Description == "Start").Id, Is.EqualTo("11")); + Assert.That(catalogOnlyDefinition.Diagnostics, Has.Exactly(1).Contains("no curated driver override")); + } + + [Test] + public void SkipsAmbiguousOrDuplicateDevicesWithDiagnostics() + { + const string json = @"{ + 'format': 'pinmame-machine-definition', + 'schema_version': 2, + 'machine': { 'id': 'test.quirks.2026', 'name': 'Test Quirks' }, + 'coverage': { 'status': 'partial', 'completion_score': 50 }, + 'inputs': [ + { 'id': 'switch.left.1', 'label': 'Left Flipper Button', 'binding': { 'group': 'pinmame.input.switch', 'device': 1 }, 'roles': ['flipper.lower.left'] }, + { 'id': 'switch.left.2', 'label': 'Second Left Flipper Button', 'binding': { 'group': 'pinmame.input.switch', 'device': 2 }, 'roles': ['flipper.lower.left'] }, + { 'id': 'switch.duplicate', 'label': 'Duplicate Switch', 'binding': { 'group': 'pinmame.input.switch', 'device': 1 } } + ], + 'outputs': [ + { 'id': 'coil.left', 'label': 'Left Flipper', 'kind': 'coil', 'binding': { 'group': 'pinmame.output.solenoid', 'device': 1 }, 'roles': ['flipper.lower.left'] }, + { 'id': 'lamp.valid', 'label': 'Valid Lamp', 'kind': 'lamp', 'binding': { 'group': 'pinmame.output.lamp', 'device': 2 } }, + { 'id': 'lamp.duplicate', 'label': 'Duplicate Lamp', 'kind': 'lamp', 'binding': { 'group': 'pinmame.output.lamp', 'device': 2 } }, + { 'id': 'lamp.rgb', 'label': 'Unidentified RGB Lamp', 'kind': 'rgb_lamp', 'binding': { 'group': 'pinmame.output.lamp', 'device': 3 } } + ] + }"; + + var definition = PinMameGameDefinitionConverter.Convert(json); + + Assert.That(definition.Switches, Has.Length.EqualTo(2)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "1").InputActionHint, Is.EqualTo(InputConstants.ActionLeftFlipper)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "2").InputActionHint, Is.Empty); + Assert.That(definition.Lamps, Has.Length.EqualTo(1)); + Assert.That(definition.Wires, Is.Empty); + Assert.That(definition.Diagnostics, Has.Some.Contains("ambiguous Lower Left Flipper switch roles")); + Assert.That(definition.Diagnostics, Has.Some.Contains("Duplicate PinMAME switch address 1")); + Assert.That(definition.Diagnostics, Has.Some.Contains("Duplicate input action")); + Assert.That(definition.Diagnostics, Has.Some.Contains("Duplicate PinMAME output binding pinmame.output.lamp address 2")); + Assert.That(definition.Diagnostics, Has.Some.Contains("does not identify its red, green, or blue channel")); + } + + [Test] + public void LeavesSharedFlipperInputForManualMapping() + { + const string json = @"{ + 'format': 'pinmame-machine-definition', + 'schema_version': 2, + 'machine': { 'id': 'test.shared-flipper.2026', 'name': 'Test Shared Flipper' }, + 'coverage': { 'status': 'partial', 'completion_score': 50 }, + 'inputs': [ + { 'id': 'switch.flipper-buttons', 'label': 'Left & Right Flipper Button', 'binding': { 'group': 'pinmame.input.switch', 'device': 21 }, 'roles': ['flipper.lower.left.button', 'flipper.lower.right.button'] } + ], + 'outputs': [ + { 'id': 'device.flipper-lower-left', 'label': 'Lower left flipper', 'kind': 'coil', 'binding': { 'group': 'pinmame.output.solenoid', 'device': 48 } }, + { 'id': 'device.flipper-lower-right', 'label': 'Lower right flipper', 'kind': 'coil', 'binding': { 'group': 'pinmame.output.solenoid', 'device': 46 } } + ] + }"; + + var definition = PinMameGameDefinitionConverter.Convert(json); + + Assert.That(definition.Switches.Single(@switch => @switch.Id == "21").InputActionHint, Is.Empty); + Assert.That(definition.Wires, Is.Empty); + Assert.That(definition.Diagnostics, Has.Some.Contains("represents multiple flipper buttons")); + Assert.That(definition.Diagnostics, Has.Some.Contains("direct flipper wires were left for manual mapping")); + } + + [Test] + public void MapsGenericCoinRolesByPhysicalSlotLabel() + { + const string json = @"{ + 'format': 'pinmame-machine-definition', + 'schema_version': 2, + 'machine': { 'id': 'test.coins.2026', 'name': 'Test Coins' }, + 'coverage': { 'status': 'partial', 'completion_score': 50 }, + 'inputs': [ + { 'id': 'switch.coin-4', 'label': '4th Coin Slot', 'binding': { 'group': 'pinmame.input.switch', 'device': 2 }, 'roles': ['cabinet.coin'] }, + { 'id': 'switch.coin-6', 'label': '6th Coin Slot', 'binding': { 'group': 'pinmame.input.switch', 'device': 3 }, 'roles': ['cabinet.coin'] }, + { 'id': 'switch.coin-right', 'label': 'Right Coin Slot', 'binding': { 'group': 'pinmame.input.switch', 'device': 4 }, 'roles': ['cabinet.coin'] }, + { 'id': 'switch.coin-center', 'label': 'Center Coin Slot / DBA', 'binding': { 'group': 'pinmame.input.switch', 'device': 5 }, 'roles': ['cabinet.coin'] }, + { 'id': 'switch.coin-left', 'label': 'Left Coin Slot', 'binding': { 'group': 'pinmame.input.switch', 'device': 6 }, 'roles': ['cabinet.coin'] }, + { 'id': 'switch.coin-5', 'label': '5th Coin Slot', 'binding': { 'group': 'pinmame.input.switch', 'device': 7 }, 'roles': ['cabinet.coin'] } + ] + }"; + + var definition = PinMameGameDefinitionConverter.Convert(json); + + Assert.That(definition.Switches.Single(@switch => @switch.Id == "2").InputActionHint, Is.EqualTo(InputConstants.ActionInsertCoin4)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "3").InputActionHint, Is.Empty); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "4").InputActionHint, Is.EqualTo(InputConstants.ActionInsertCoin3)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "5").InputActionHint, Is.EqualTo(InputConstants.ActionInsertCoin2)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "6").InputActionHint, Is.EqualTo(InputConstants.ActionInsertCoin1)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "7").InputActionHint, Is.Empty); + } + + [Test] + public void UsesPinMameNumberingForGenericFlipperSwitches() + { + // labels and roles of 111-114 swapped, like the published Terminator 2 definition + const string json = @"{ + 'format': 'pinmame-machine-definition', + 'schema_version': 2, + 'machine': { 'id': 'test.flipper-numbering.2026', 'name': 'Test Flipper Numbering' }, + 'coverage': { 'status': 'partial', 'completion_score': 50 }, + 'inputs': [ + { 'id': 'switch.matrix-11', 'label': 'Right Flipper', 'binding': { 'group': 'pinmame.input.switch', 'device': 11 }, 'roles': ['flipper.lower.right.switch'] }, + { 'id': 'switch.generic-111', 'label': 'Lower Right Flipper Button', 'availability': 'optional', 'binding': { 'group': 'pinmame.input.switch', 'device': 111 }, 'roles': ['flipper.lower.right.button'] }, + { 'id': 'switch.generic-112', 'label': 'Lower Right Flipper EOS', 'availability': 'optional', 'normally_closed': true, 'binding': { 'group': 'pinmame.input.switch', 'device': 112 }, 'roles': ['internal.flipper.lower.right.eos'] }, + { 'id': 'switch.generic-113', 'label': 'Lower Left Flipper Button', 'availability': 'optional', 'binding': { 'group': 'pinmame.input.switch', 'device': 113 }, 'roles': ['flipper.lower.left.button'] }, + { 'id': 'switch.generic-114', 'label': 'Lower Left Flipper EOS', 'availability': 'optional', 'normally_closed': true, 'binding': { 'group': 'pinmame.input.switch', 'device': 114 }, 'roles': ['internal.flipper.lower.left.eos'] } + ], + 'outputs': [ + { 'id': 'device.flipper-lower-right', 'label': 'Lower right flipper', 'kind': 'coil', 'binding': { 'group': 'pinmame.output.solenoid', 'device': 46 } }, + { 'id': 'device.flipper-lower-left', 'label': 'Lower left flipper', 'kind': 'coil', 'binding': { 'group': 'pinmame.output.solenoid', 'device': 48 } } + ] + }"; + + var definition = PinMameGameDefinitionConverter.Convert(json); + + Assert.That(definition.Switches.Single(@switch => @switch.Id == "112").InputActionHint, Is.EqualTo(InputConstants.ActionRightFlipper)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "114").InputActionHint, Is.EqualTo(InputConstants.ActionLeftFlipper)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "111").InputActionHint, Is.Empty); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "113").InputActionHint, Is.Empty); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "11").InputActionHint, Is.Empty); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "112").NormallyClosed, Is.False); + Assert.That(definition.Wires.Select(wire => $"{wire.SourceId}->{wire.DestinationId}"), Is.EquivalentTo(new[] { "112->46", "114->48" })); + Assert.That(definition.Diagnostics.Count(diagnostic => diagnostic.Contains("PinMAME's numbering")), Is.EqualTo(4)); + } + + [Test] + public void PrefersGenericFlipperButtonsOverMatrixCopies() + { + // pre-Fliptronic WPC lists the matrix flipper switches as buttons too, like the published Bride of Pinbot + const string json = @"{ + 'format': 'pinmame-machine-definition', + 'schema_version': 2, + 'machine': { 'id': 'test.matrix-flipper-copies.2026', 'name': 'Test Matrix Flipper Copies' }, + 'coverage': { 'status': 'author_ready', 'completion_score': 100 }, + 'inputs': [ + { 'id': 'switch.matrix-11', 'label': 'Right Flipper', 'binding': { 'group': 'pinmame.input.switch', 'device': 11 }, 'roles': ['flipper.lower.right.button'] }, + { 'id': 'switch.matrix-12', 'label': 'Left Flipper', 'binding': { 'group': 'pinmame.input.switch', 'device': 12 }, 'roles': ['flipper.lower.left.button'] }, + { 'id': 'switch.generic-112', 'label': 'Generic Right Flipper Button State', 'binding': { 'group': 'pinmame.input.switch', 'device': 112 }, 'roles': ['flipper.lower.right.button'] }, + { 'id': 'switch.generic-114', 'label': 'Generic Left Flipper Button State', 'binding': { 'group': 'pinmame.input.switch', 'device': 114 }, 'roles': ['flipper.lower.left.button'] } + ], + 'outputs': [ + { 'id': 'device.flipper-lower-right', 'label': 'Lower right flipper', 'kind': 'coil', 'binding': { 'group': 'pinmame.output.solenoid', 'device': 46 } }, + { 'id': 'device.flipper-lower-left', 'label': 'Lower left flipper', 'kind': 'coil', 'binding': { 'group': 'pinmame.output.solenoid', 'device': 48 } } + ] + }"; + + var definition = PinMameGameDefinitionConverter.Convert(json); + + Assert.That(definition.Switches.Single(@switch => @switch.Id == "112").InputActionHint, Is.EqualTo(InputConstants.ActionRightFlipper)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "114").InputActionHint, Is.EqualTo(InputConstants.ActionLeftFlipper)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "11").InputActionHint, Is.Empty); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "12").InputActionHint, Is.Empty); + Assert.That(definition.Wires.Select(wire => $"{wire.SourceId}->{wire.DestinationId}"), Is.EquivalentTo(new[] { "112->46", "114->48" })); + Assert.That(definition.Diagnostics, Has.None.Contains("not imported")); + } + + [Test] + public void DefaultsToTheParentRomListedAfterItsClones() + { + const string json = @"{ + 'format': 'pinmame-machine-definition', + 'schema_version': 2, + 'machine': { 'id': 'test.parent-rom.2026', 'name': 'Test Parent ROM' }, + 'coverage': { 'status': 'partial', 'completion_score': 50 }, + 'drivers': [ + { 'id': 'tst_d2', 'clone_of': 'tst_l8' }, + { 'id': 'tst_l8', 'overrides': [ + { 'target': 'switch.start', 'binding': { 'group': 'pinmame.input.switch', 'device': 14 } } + ] }, + { 'id': 'tst_l6', 'clone_of': 'tst_l8', 'overrides': [ + { 'target': 'switch.start', 'binding': { 'group': 'pinmame.input.switch', 'device': 15 } } + ] } + ], + 'inputs': [ + { 'id': 'switch.start', 'label': 'Start Button', 'binding': { 'group': 'pinmame.input.switch', 'device': 13 } } + ] + }"; + + var definition = PinMameGameDefinitionConverter.Convert(json); + + Assert.That(definition.Roms.First().Id, Is.EqualTo("tst_d2"), "the list itself stays alphabetical"); + Assert.That(definition.DefaultRomId, Is.EqualTo("tst_l8")); + // without a ROM, the parent's driver overrides apply + Assert.That(definition.Switches.Select(@switch => @switch.Id), Is.EqualTo(new[] { "14" })); + } + + [Test] + public void DefaultsToTheMostReferencedParentWhenEveryDriverIsAClone() + { + const string json = @"{ + 'format': 'pinmame-machine-definition', + 'schema_version': 2, + 'machine': { 'id': 'test.clone-parent.2026', 'name': 'Test Clone Parent' }, + 'coverage': { 'status': 'partial', 'completion_score': 50 }, + 'drivers': [ + { 'id': 'tst_a1', 'clone_of': 'tst_b2' }, + { 'id': 'tst_a2', 'clone_of': 'tst_b2' }, + { 'id': 'tst_b2', 'clone_of': 'tst_z9' } + ], + 'inputs': [] + }"; + + var definition = PinMameGameDefinitionConverter.Convert(json); + + Assert.That(definition.DefaultRomId, Is.EqualTo("tst_b2")); + } + + [Test] + public void TakesTheRestStateOfCabinetInputsFromInitialActive() + { + const string json = @"{ + 'format': 'pinmame-machine-definition', + 'schema_version': 2, + 'machine': { 'id': 'test.initial-active.2026', 'name': 'Test Initial Active' }, + 'coverage': { 'status': 'partial', 'completion_score': 50 }, + 'inputs': [ + { 'id': 'switch.memory-protect', 'label': 'Coin Door Memory Protect', 'initial_active': true, 'binding': { 'group': 'pinmame.input.switch', 'device': -3 }, 'roles': ['cabinet.memory-protect'] }, + { 'id': 'switch.trough-4', 'label': 'Trough 4', 'initial_active': true, 'binding': { 'group': 'pinmame.input.switch', 'device': 18 }, 'roles': ['ball.position'] }, + { 'id': 'switch.coin-door', 'label': 'Coin Door Closed', 'normally_closed': true, 'binding': { 'group': 'pinmame.input.switch', 'device': 22 }, 'roles': ['cabinet.coin-door'] } + ] + }"; + + var definition = PinMameGameDefinitionConverter.Convert(json); + + // nothing operates the memory-protect switch, so it's constant at its rest state + Assert.That(definition.Switches.Single(@switch => @switch.Id == "-3").ConstantHint, Is.EqualTo(SwitchConstantHint.AlwaysClosed)); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "-3").NormallyClosed, Is.False); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "18").NormallyClosed, Is.False); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "22").NormallyClosed, Is.True, "coin door without initial_active"); + } + + [Test] + public void PassesNormallyClosedOnlyForTheCoinDoor() + { + const string json = @"{ + 'format': 'pinmame-machine-definition', + 'schema_version': 2, + 'machine': { 'id': 'test.normally-closed.2026', 'name': 'Test Normally Closed' }, + 'coverage': { 'status': 'partial', 'completion_score': 50 }, + 'inputs': [ + { 'id': 'switch.coin-door', 'label': 'Coin Door Closed', 'normally_closed': true, 'binding': { 'group': 'pinmame.input.switch', 'device': 22 }, 'roles': ['cabinet.coin-door'] }, + { 'id': 'switch.trough-eject', 'label': 'Trough Eject', 'normally_closed': true, 'binding': { 'group': 'pinmame.input.switch', 'device': 31 } }, + { 'id': 'switch.slam-tilt', 'label': 'Slam Tilt', 'normally_closed': true, 'binding': { 'group': 'pinmame.input.switch', 'device': 21 }, 'roles': ['cabinet.slam-tilt'] } + ] + }"; + + var definition = PinMameGameDefinitionConverter.Convert(json); + + Assert.That(definition.Switches.Single(@switch => @switch.Id == "22").NormallyClosed, Is.True); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "31").NormallyClosed, Is.False); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "21").NormallyClosed, Is.False); + } + + [Test] + public void KeepsVirtualHostDevicesAndDropsVirtualMirrors() + { + const string json = @"{ + 'format': 'pinmame-machine-definition', + 'schema_version': 2, + 'machine': { 'id': 'test.virtual.2026', 'name': 'Test Virtual' }, + 'coverage': { 'status': 'partial', 'completion_score': 50 }, + 'inputs': [ + { 'id': 'switch.flipper-right', 'label': 'Right Flipper Button', 'kind': 'virtual', 'availability': 'used', 'binding': { 'group': 'pinmame.input.switch', 'device': 82 }, 'roles': ['flipper.lower.right.button'] }, + { 'id': 'switch.host-left', 'label': 'Left Flipper Button Host Input', 'kind': 'virtual', 'availability': 'used', 'binding': { 'group': 'pinmame.input.switch', 'device': 84 } }, + { 'id': 'switch.compat', 'label': 'Flipper compatibility state', 'kind': 'virtual', 'availability': 'used', 'binding': { 'group': 'pinmame.input.switch', 'device': 85 }, 'roles': ['internal.compatibility'] } + ], + 'outputs': [ + { 'id': 'device.synthetic-flipper-right', 'label': 'Synthetic Lower Right Flipper', 'kind': 'virtual', 'availability': 'used', 'binding': { 'group': 'pinmame.output.solenoid', 'device': 46 }, 'roles': ['internal.synthetic-flipper', 'flipper.lower.right'] }, + { 'id': 'device.mirror', 'label': 'LED Transport Mirror Of 37', 'kind': 'virtual', 'availability': 'used', 'binding': { 'group': 'pinmame.output.solenoid', 'device': 41 } }, + { 'id': 'device.state', 'label': 'WPC State Channel 29', 'kind': 'virtual', 'availability': 'used', 'binding': { 'group': 'pinmame.output.solenoid', 'device': 29 }, 'roles': ['internal.wpc-state'] } + ] + }"; + + var definition = PinMameGameDefinitionConverter.Convert(json); + + Assert.That(definition.Switches.Select(@switch => @switch.Id), Is.EquivalentTo(new[] { "82", "84" })); + Assert.That(definition.Switches.Single(@switch => @switch.Id == "82").InputActionHint, Is.EqualTo(InputConstants.ActionRightFlipper)); + Assert.That(definition.Coils.Select(coil => coil.Id), Is.EquivalentTo(new[] { "46" })); + Assert.That(definition.Wires.Select(wire => $"{wire.SourceId}->{wire.DestinationId}"), Is.EquivalentTo(new[] { "82->46" })); + } + + [Test] + public void ConvertsEveryCuratedDefinitionWhenCorpusIsAvailable() + { + var root = Environment.GetEnvironmentVariable("PINMAME_GAME_DEFS_ROOT"); + if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(Path.Combine(root, "machines"))) { + Assert.Ignore("Set PINMAME_GAME_DEFS_ROOT to run the cross-repository corpus test."); + } + + var failures = new List(); + var files = new[] { "author-ready", "partial" } + .SelectMany(status => Directory.EnumerateFiles(Path.Combine(root, "machines", status), "*.json", SearchOption.AllDirectories)); + foreach (var file in files) { + var isAuthorReady = file.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Contains("author-ready", StringComparer.Ordinal); + var json = File.ReadAllText(file); + var drivers = (JObject.Parse(json)["drivers"] as JArray)?.OfType().Select(driver => driver.Value("id")).Where(id => id != null).ToArray() + ?? Array.Empty(); + foreach (var driver in drivers.DefaultIfEmpty(null)) { + try { + var definition = PinMameGameDefinitionConverter.Convert(json, driver); + var duplicateAction = definition.Switches + .Where(@switch => !string.IsNullOrEmpty(@switch.InputActionHint)) + .GroupBy(@switch => @switch.InputActionHint, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateAction != null) { + failures.Add($"{Path.GetFileName(file)} [{driver ?? "default"}]: duplicate input action {duplicateAction.Key}"); + } + if (isAuthorReady) { + var lossyDiagnostic = definition.Diagnostics.FirstOrDefault(diagnostic => + diagnostic.Contains("was not imported", StringComparison.Ordinal) + || diagnostic.Contains("left for manual mapping", StringComparison.Ordinal) + || diagnostic.Contains("single-channel lamp output", StringComparison.Ordinal) + || diagnostic.Contains("VPE has no coil destination", StringComparison.Ordinal)); + if (lossyDiagnostic != null) failures.Add($"{Path.GetFileName(file)} [{driver ?? "default"}]: {lossyDiagnostic}"); + } + } catch (Exception exception) { + failures.Add($"{Path.GetFileName(file)} [{driver ?? "default"}]: {exception.Message}"); + } + } + } + + Assert.That(failures, Is.Empty, string.Join(Environment.NewLine, failures)); + } + + [Test] + public void ConvertsEverySelectablePublishedDetailWhenSiteDataIsAvailable() + { + var root = Environment.GetEnvironmentVariable("PINMAME_GAME_DEFS_ROOT"); + if (string.IsNullOrWhiteSpace(root)) { + Assert.Ignore("Set PINMAME_GAME_DEFS_ROOT to run the published-detail test."); + } + var dataRoot = Path.Combine(root, "site", "public", "data"); + var indexPath = Path.Combine(dataRoot, "index.json"); + Assert.That(File.Exists(indexPath), Is.True, $"The pinned corpus is missing its generated site index: {indexPath}"); + + var catalog = PinMameGameDefinitionCatalogClient.ParseCatalog(File.ReadAllText(indexPath)); + var failures = new List(); + var testedDefinitions = 0; + foreach (var machine in catalog.Machines.Where(entry => entry.IsSelectable)) { + var expectedDetail = $"data/machines/{machine.Slug}.json"; + if (!string.Equals(machine.Detail, expectedDetail, StringComparison.Ordinal)) { + failures.Add($"{machine.Id}: unexpected detail path {machine.Detail}"); + continue; + } + var detailPath = Path.Combine(dataRoot, "machines", $"{machine.Slug}.json"); + if (!File.Exists(detailPath)) { + failures.Add($"{machine.Id}: missing {detailPath}"); + continue; + } + + var json = File.ReadAllText(detailPath); + foreach (var romId in (machine.Roms ?? Array.Empty()).DefaultIfEmpty(null)) { + try { + PinMameGameDefinitionConverter.Convert(json, romId); + } catch (Exception exception) { + failures.Add($"{machine.Id} [{romId ?? "default"}]: {exception.Message}"); + } + } + testedDefinitions++; + } + + Assert.That(testedDefinitions, Is.GreaterThan(0)); + Assert.That(failures, Is.Empty, string.Join(Environment.NewLine, failures)); + } + + [Test] + public void CatalogFiltersKnownNonPhysicalEntriesWithoutRejectingFutureKinds() + { + const string json = @"{ + 'format': 'pinmame-machine-reference-index', + 'version': 2, + 'machines': [ + { 'slug': 'physical', 'id': 'physical', 'name': 'Physical', 'status': 'partial', 'machineKind': 'physical_pinball', 'detail': 'data/machines/physical.json' }, + { 'slug': 'diagnostic', 'id': 'diagnostic', 'name': 'Diagnostic', 'status': 'partial', 'machineKind': 'diagnostic_software', 'detail': 'data/machines/diagnostic.json' }, + { 'slug': 'redemption', 'id': 'redemption', 'name': 'Redemption', 'status': 'partial', 'machineKind': 'redemption_game', 'detail': 'data/machines/redemption.json' }, + { 'slug': 'conversion', 'id': 'conversion', 'name': 'Conversion', 'status': 'partial', 'machineKind': 'physical_conversion', 'detail': 'data/machines/conversion.json' }, + { 'slug': 'future', 'id': 'future', 'name': 'Future', 'status': 'partial', 'machineKind': 'new_physical_kind', 'detail': 'data/machines/future.json' } + ] + }"; + + var catalog = PinMameGameDefinitionCatalogClient.ParseCatalog(json); + + Assert.That(catalog.Machines.Single(machine => machine.Id == "physical").IsSelectable, Is.True); + Assert.That(catalog.Machines.Single(machine => machine.Id == "diagnostic").IsSelectable, Is.False); + Assert.That(catalog.Machines.Single(machine => machine.Id == "redemption").IsSelectable, Is.False); + Assert.That(catalog.Machines.Single(machine => machine.Id == "conversion").IsSelectable, Is.False); + Assert.That(catalog.Machines.Single(machine => machine.Id == "future").IsSelectable, Is.True); + } + + [Test] + public void UpgradesVersionOneCatalogWithCompleteDriverList() + { + const string json = @"{ + 'format': 'pinmame-machine-reference-index', + 'version': 1, + 'machines': [ + { 'slug': 'physical', 'id': 'physical', 'name': 'Physical', 'status': 'partial', 'rootDrivers': ['root'], 'detail': 'data/machines/physical.json' }, + { 'slug': 'diagnostic', 'id': 'diagnostic.test', 'name': 'Diagnostic', 'status': 'partial', 'rootDrivers': ['diag'], 'detail': 'data/machines/diagnostic.json' } + ] + }"; + + var catalog = PinMameGameDefinitionCatalogClient.ParseCatalog(json); + PinMameGameDefinitionCatalogClient.UpgradeLegacyCatalog(catalog, new[] { + new PinMameCatalogDriver { Id = "root", MachineId = "physical" }, + new PinMameCatalogDriver { Id = "clone", MachineId = "physical" }, + }); + + var physical = catalog.Machines.Single(machine => machine.Id == "physical"); + Assert.That(physical.Roms, Is.EqualTo(new[] { "clone", "root" })); + Assert.That(physical.MachineKind, Is.EqualTo("unknown")); + Assert.That(physical.IsSelectable, Is.True); + var diagnostic = catalog.Machines.Single(machine => machine.Id == "diagnostic.test"); + Assert.That(diagnostic.MachineKind, Is.EqualTo("diagnostic_software")); + Assert.That(diagnostic.IsSelectable, Is.False); + } + + [Test] + public void RejectsUnknownFutureCatalogVersions() + { + const string json = @"{ + 'format': 'pinmame-machine-reference-index', + 'version': 3, + 'machines': [] + }"; + + Assert.That(() => PinMameGameDefinitionCatalogClient.ParseCatalog(json), Throws.TypeOf()); + } + + [TestCase("t2_l82", "s_flipper_lower_left", 114)] + [TestCase("rock", "s_coin_1", 17)] + [TestCase("trn_174h", "s_enter", 0)] + [TestCase("centaur", "s_start_button", 6)] + public void ResolvesLegacySwitchAliases(string romId, string alias, int expectedAddress) + { + Assert.That(PinMameLegacyAliasResolver.TryResolveSwitch(null, romId, null, alias, out var address), Is.True); + Assert.That(address, Is.EqualTo(expectedAddress)); + } + + [TestCase("t2_l82", "c_flipper_lower_left", 48)] + [TestCase("rock", "c_flipper_upper_right", 45)] + [TestCase("twd_160h", "c_flipper_lower_right", 16)] + public void ResolvesLegacyCoilAliases(string romId, string alias, int expectedAddress) + { + Assert.That(PinMameLegacyAliasResolver.TryResolveCoil(null, romId, null, alias, out var address), Is.True); + Assert.That(address, Is.EqualTo(expectedAddress)); + } + } +} diff --git a/VisualPinball.Engine.PinMAME.Test/PinMameSpatialMatcherTests.cs b/VisualPinball.Engine.PinMAME.Test/PinMameSpatialMatcherTests.cs new file mode 100644 index 0000000..5d81692 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Test/PinMameSpatialMatcherTests.cs @@ -0,0 +1,217 @@ +using System.Linq; +using NUnit.Framework; +using VisualPinball.Engine.Game.Engines; +using VisualPinball.Engine.Math; + +namespace VisualPinball.Engine.PinMAME.Editor +{ + [TestFixture] + public class PinMameSpatialMatcherTests + { + [Test] + public void ConvertsOnlyValidPlayfieldPlacementsForImportedHardware() + { + const string json = @"{ + 'format': 'pinmame-machine-definition', + 'schema_version': 2, + 'machine': { 'id': 'test.spatial.2026', 'name': 'Spatial Test' }, + 'coverage': { 'status': 'partial', 'completion_score': 50 }, + 'drivers': [{ 'id': 'spatial_1', 'overrides': [ + { 'target': 'switch.target', 'binding': { 'group': 'pinmame.input.switch', 'device': 12 } } + ] }], + 'inputs': [ + { 'id': 'switch.target', 'label': 'Target', 'kind': 'switch', + 'binding': { 'group': 'pinmame.input.switch', 'device': 11 }, + 'spatial': { 'placements': [ + { 'id': 'sensor', 'space': 'playfield', 'x': 0.25, 'y': 0.75 }, + { 'id': 'cabinet', 'space': 'cabinet', 'x': 0.1, 'y': 0.2 } + ] } } + ], + 'outputs': [ + { 'id': 'coil.kicker', 'label': 'Kicker', 'kind': 'coil', + 'binding': { 'group': 'pinmame.output.solenoid', 'device': 1 }, + 'spatial': { 'placements': [{ 'id': 'actuator', 'space': 'playfield', 'x': 0.3, 'y': 0.4 }] } }, + { 'id': 'flasher.pair', 'label': 'Flasher Pair', 'kind': 'flasher', + 'binding': { 'group': 'pinmame.output.solenoid', 'device': 2 }, + 'spatial': { 'placements': [ + { 'id': 'left', 'space': 'playfield', 'x': 0.2, 'y': 0.2 }, + { 'id': 'right', 'space': 'playfield', 'x': 0.8, 'y': 0.2 } + ] } }, + { 'id': 'gi.one', 'label': 'GI', 'kind': 'gi', + 'binding': { 'group': 'pinmame.output.gi', 'device': 3 }, + 'spatial': { 'placements': [ + { 'id': 'valid', 'space': 'playfield', 'x': 0.5, 'y': 0.5 }, + { 'id': 'invalid', 'space': 'playfield', 'x': 1.2, 'y': 0.5 } + ] } }, + { 'id': 'lamp.rgb-strip-blue', 'label': 'Strip blue', 'kind': 'lamp', + 'binding': { 'group': 'pinmame.output.lamp', 'device': 4 }, + 'spatial': { 'placements': [{ 'id': 'pixel', 'space': 'playfield', 'x': 0.6, 'y': 0.4 }] } } + ] + }"; + + var definition = PinMameGameDefinitionConverter.Convert(json, "spatial_1"); + + Assert.That(definition.SpatialSwitches, Has.Length.EqualTo(1)); + Assert.That(definition.SpatialSwitches[0].HardwareId, Is.EqualTo("12")); + Assert.That(definition.SpatialSwitches[0].X, Is.EqualTo(0.25f)); + Assert.That(definition.SpatialCoils, Has.Length.EqualTo(1)); + Assert.That(definition.SpatialCoils[0].HardwareId, Is.EqualTo("1")); + Assert.That(definition.SpatialLamps, Has.Length.EqualTo(4)); + Assert.That(definition.SpatialLamps.Count(item => item.HardwareId == "2" && item.IsCoil), Is.EqualTo(2)); + Assert.That(definition.SpatialLamps.Single(item => item.HardwareId == "3").Source, Is.EqualTo(LampSource.GI)); + Assert.That(definition.SpatialLamps.Single(item => item.HardwareId == "3").Channel, Is.EqualTo(ColorChannel.Alpha)); + Assert.That(definition.Lamps.Single(item => item.Id == "4").Channel, Is.EqualTo(ColorChannel.Blue)); + Assert.That(definition.Lamps.Single(item => item.Id == "4").Type, Is.EqualTo(LampType.RgbMulti)); + } + + [Test] + public void UsesTrustedLayoutRelationInsteadOfACloserWrongItem() + { + var targets = new[] { new PinMameSpatialMatchTarget("target", 0.5f, 0.5f) }; + var candidates = new[] { + new PinMameSpatialMatchCandidate("near-but-wrong", 0.5f, 0.45f), + new PinMameSpatialMatchCandidate("layout-match", 0.55f, 0.52f), + }; + var anchors = new[] { new PinMameSpatialMatchAnchor(0f, 0f, 0.05f, 0.02f) }; + + var result = PinMameSpatialMatcher.Match(targets, candidates, anchors); + + Assert.That(result.Decisions.Single().IsAccepted, Is.True); + Assert.That(result.Decisions.Single().Candidate.Key, Is.EqualTo("layout-match")); + } + + [Test] + public void RejectsAContestedItemInsteadOfForcingTheSecondMachineTarget() + { + var targets = new[] { + new PinMameSpatialMatchTarget("first", 0.1f, 0.1f), + new PinMameSpatialMatchTarget("second", 0.13f, 0.1f), + }; + var candidates = new[] { + new PinMameSpatialMatchCandidate("shared-best", 0.11f, 0.1f), + new PinMameSpatialMatchCandidate("weak-second", 0.16f, 0.1f), + }; + + var result = PinMameSpatialMatcher.Match(targets, candidates); + + Assert.That(result.Decisions.Count(decision => decision.IsAccepted), Is.EqualTo(1)); + Assert.That(result.Decisions.Single(decision => !decision.IsAccepted).Reason, Does.Contain("equally plausible")); + } + + [Test] + public void RejectsDifferentDeviceItemsAtTheSamePositionAsAmbiguous() + { + var targets = new[] { new PinMameSpatialMatchTarget("target", 0.4f, 0.6f) }; + var candidates = new[] { + new PinMameSpatialMatchCandidate("item-a", 0.4f, 0.6f), + new PinMameSpatialMatchCandidate("item-b", 0.4f, 0.6f), + }; + + var result = PinMameSpatialMatcher.Match(targets, candidates); + + Assert.That(result.Decisions.Single().IsAccepted, Is.False); + Assert.That(result.Decisions.Single().Reason, Does.Contain("equally plausible")); + } + + [Test] + public void DoesNotDiscountAPairWhenItsOnlyRelationAnchorIsItself() + { + var targets = new[] { + new PinMameSpatialMatchTarget("seed", 0.1f, 0.1f), + new PinMameSpatialMatchTarget("distant", 0.8f, 0.8f), + }; + var candidates = new[] { + new PinMameSpatialMatchCandidate("seed-candidate", 0.12f, 0.1f), + new PinMameSpatialMatchCandidate("distant-candidate", 0.95f, 0.95f), + }; + + var result = PinMameSpatialMatcher.Match(targets, candidates); + + var decision = result.Decisions.Single(item => item.Target.Key == "seed"); + Assert.That(decision.IsAccepted, Is.True); + Assert.That(decision.Score, Is.EqualTo(0.02).Within(0.0001)); + } + [Test] + public void SkipsPlacementsOfConflictedAndCabinetDevices() + { + const string json = @"{ + 'format': 'pinmame-machine-definition', + 'schema_version': 2, + 'machine': { 'id': 'test.spatial-filters.2026', 'name': 'Spatial Filters' }, + 'coverage': { 'status': 'partial', 'completion_score': 50 }, + 'inputs': [ + { 'id': 'switch.matrix-11', 'label': 'Right Flipper', 'binding': { 'group': 'pinmame.input.switch', 'device': 11 }, + 'roles': ['flipper.lower.right.switch'], + 'spatial': { 'placements': [{ 'id': 'sensor', 'space': 'playfield', 'x': 0.63, 'y': 0.85 }] } }, + { 'id': 'switch.start', 'label': 'Start Button', 'binding': { 'group': 'pinmame.input.switch', 'device': 13 }, + 'roles': ['cabinet.start'], + 'spatial': { 'placements': [{ 'id': 'sensor', 'space': 'playfield', 'x': 0.9, 'y': 0.95 }] } }, + { 'id': 'switch.outlane', 'label': 'Left Outlane', 'binding': { 'group': 'pinmame.input.switch', 'device': 25 }, + 'spatial': { 'placements': [{ 'id': 'sensor', 'space': 'playfield', 'x': 0.1, 'y': 0.7 }] } } + ], + 'outputs': [ + { 'id': 'gi.string-3', 'label': 'GI 3', 'kind': 'gi', 'binding': { 'group': 'pinmame.output.gi', 'device': 2 }, + 'spatial': { 'status': 'conflicted', 'placements': [{ 'id': 'bulb', 'space': 'playfield', 'x': 0.75, 'y': 0.81 }] } }, + { 'id': 'flasher.sling', 'label': 'Right Sling Flashlamps', 'kind': 'flasher', 'binding': { 'group': 'pinmame.output.solenoid', 'device': 18 }, + 'spatial': { 'status': 'validated', 'placements': [{ 'id': 'emitter', 'space': 'playfield', 'x': 0.756, 'y': 0.812 }] } } + ] + }"; + + var definition = PinMameGameDefinitionConverter.Convert(json); + + Assert.That(definition.SpatialSwitches.Select(item => item.HardwareId), Is.EqualTo(new[] { "25" })); + Assert.That(definition.SpatialLamps.Select(item => item.HardwareId), Is.EqualTo(new[] { "18" })); + } + + [Test] + public void AcceptsEachItemOfAnOffsetRow() + { + // the table's row is shifted by less than the row spacing, so each target also lies close + // to its neighbour's item, but each item clearly belongs to one target + var targets = Enumerable.Range(0, 4).Select(i => new PinMameSpatialMatchTarget($"t{i}", 0.2f + 0.025f * i, 0.5f)).ToArray(); + var candidates = Enumerable.Range(0, 4).Select(i => new PinMameSpatialMatchCandidate($"c{i}", 0.21f + 0.025f * i, 0.5f)).ToArray(); + + var result = PinMameSpatialMatcher.Match(targets, candidates); + + Assert.That(result.Decisions.All(decision => decision.IsAccepted), Is.True); + Assert.That(result.Decisions.Select(decision => decision.Candidate.Key), Is.EqualTo(new[] { "c0", "c1", "c2", "c3" })); + } + + [Test] + public void TellsItemsAtTheSamePositionApartByName() + { + var targets = new[] { + new PinMameSpatialMatchTarget("32", 0.9f, 0.48f, "Gun Mark"), + new PinMameSpatialMatchTarget("33", 0.9f, 0.48f, "Gun Home"), + }; + var candidates = new[] { + new PinMameSpatialMatchCandidate("home", 0.9f, 0.48f, "Gun Home"), + new PinMameSpatialMatchCandidate("mark", 0.9f, 0.48f, "gun mark"), + }; + + var result = PinMameSpatialMatcher.Match(targets, candidates); + + Assert.That(result.Decisions.Single(decision => decision.Target.Key == "32").Candidate?.Key, Is.EqualTo("mark")); + Assert.That(result.Decisions.Single(decision => decision.Target.Key == "33").Candidate?.Key, Is.EqualTo("home")); + } + + [Test] + public void ATargetMissingFromTheTableDoesNotPushAMatchAway() + { + // "eyes" has no item of its own on the table; the nearest one belongs to "drop" + var targets = new[] { + new PinMameSpatialMatchTarget("drop", 0.317f, 0.184f), + new PinMameSpatialMatchTarget("eyes", 0.302f, 0.138f), + }; + var candidates = new[] { + new PinMameSpatialMatchCandidate("drop-insert", 0.311f, 0.168f), + new PinMameSpatialMatchCandidate("gi-bulb", 0.266f, 0.216f), + }; + + var result = PinMameSpatialMatcher.Match(targets, candidates); + + Assert.That(result.Decisions.Single(decision => decision.Target.Key == "drop").Candidate?.Key, Is.EqualTo("drop-insert")); + Assert.That(result.Decisions.Single(decision => decision.Target.Key == "eyes").IsAccepted, Is.False); + } + } +} diff --git a/VisualPinball.Engine.PinMAME.Test/UnityEngineStub.cs b/VisualPinball.Engine.PinMAME.Test/UnityEngineStub.cs new file mode 100644 index 0000000..944009e --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Test/UnityEngineStub.cs @@ -0,0 +1,9 @@ +using System; + +namespace UnityEngine +{ + internal static class Application + { + internal static string dataPath => AppContext.BaseDirectory; + } +} diff --git a/VisualPinball.Engine.PinMAME.Test/VisualPinball.Engine.PinMAME.Test.csproj b/VisualPinball.Engine.PinMAME.Test/VisualPinball.Engine.PinMAME.Test.csproj new file mode 100644 index 0000000..024f3aa --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Test/VisualPinball.Engine.PinMAME.Test.csproj @@ -0,0 +1,21 @@ + + + net8.0 + false + 9.0 + + + + + + + + + + + + + + + + diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameEditorRomFolder.cs b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameEditorRomFolder.cs new file mode 100644 index 0000000..70f82d1 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameEditorRomFolder.cs @@ -0,0 +1,85 @@ +// 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 . + +// ReSharper disable CheckNamespace + +using PinMame; +using UnityEditor; +using VisualPinball.Unity; + +namespace VisualPinball.Engine.PinMAME.Editor +{ + /// + /// The ROM folder used when playing tables in the editor. It's a per-machine editor setting, + /// and without one, Visual PinMAME's ROM folder is used if it's installed, so authors with a + /// VPM setup don't need to configure anything. + /// + /// + /// It's the host's ROM folder (). A player app running + /// in the editor can still override it with its own setting for its play session. + /// + [InitializeOnLoad] + public static class PinMameEditorRomFolder + { + private const string PrefKey = "VisualPinball.PinMAME.RomFolder"; + + private static string _vpmRomFolder; + + static PinMameEditorRomFolder() + { + Apply(); + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + } + + // A player app's ROM folder only applies to its play session; editor actions like Create + // Displays use the editor's folder again. The domain isn't necessarily reloaded on exit. + private static void OnPlayModeStateChanged(PlayModeStateChange change) + { + if (change == PlayModeStateChange.EnteredEditMode) { + GamelogicPaths.RomFolderOverride = null; + } + } + + /// + /// The ROM folder set in the editor, or an empty string to use Visual PinMAME's. + /// + public static string Custom + { + get => EditorPrefs.GetString(PrefKey, string.Empty); + set { + EditorPrefs.SetString(PrefKey, GamelogicPaths.NormalizeFolderList(value) ?? string.Empty); + Apply(); + } + } + + /// + /// Visual PinMAME's ROM folder(s), or null if it's not installed. Read when the editor + /// loads scripts and when the setting changes. + /// + public static string VpmRomFolder => _vpmRomFolder; + + /// + /// The ROM folder(s) tables played in the editor use, or null if there's none. + /// + public static string Effective => string.IsNullOrWhiteSpace(Custom) ? VpmRomFolder : Custom; + + private static void Apply() + { + _vpmRomFolder = PinMame.PinMame.FindVpmPath(PinMameFileType.Roms); + GamelogicPaths.HostRomFolder = Effective; + } + } +} diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameEditorRomFolder.cs.meta b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameEditorRomFolder.cs.meta new file mode 100644 index 0000000..f254e57 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameEditorRomFolder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3c84f18fd68b036409cd522a506653ee \ No newline at end of file diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinition.cs b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinition.cs new file mode 100644 index 0000000..a56a630 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinition.cs @@ -0,0 +1,97 @@ +// 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. + +// ReSharper disable CheckNamespace + +using System; +using VisualPinball.Engine.Game.Engines; +using VisualPinball.Engine.Math; + +namespace VisualPinball.Engine.PinMAME.Editor +{ + public sealed class PinMameGameDefinition + { + public string MachineId { get; } + public string Name { get; } + public string CoverageStatus { get; } + public int CompletionScore { get; } + public PinMameRomDefinition[] Roms { get; } + /// + /// The ROM to select when none is given, usually the parent ROM. Null if there are no ROMs. + /// + public string DefaultRomId { get; } + public GamelogicEngineSwitch[] Switches { get; } + public GamelogicEngineCoil[] Coils { get; } + public GamelogicEngineLamp[] Lamps { get; } + public GamelogicEngineWire[] Wires { get; } + public PinMameSpatialPlacement[] SpatialSwitches { get; } + public PinMameSpatialPlacement[] SpatialCoils { get; } + public PinMameSpatialPlacement[] SpatialLamps { get; } + public string[] Diagnostics { get; } + + public PinMameGameDefinition(string machineId, string name, string coverageStatus, int completionScore, + PinMameRomDefinition[] roms, string defaultRomId, GamelogicEngineSwitch[] switches, GamelogicEngineCoil[] coils, + GamelogicEngineLamp[] lamps, GamelogicEngineWire[] wires, PinMameSpatialPlacement[] spatialSwitches, + PinMameSpatialPlacement[] spatialCoils, PinMameSpatialPlacement[] spatialLamps, string[] diagnostics) + { + MachineId = machineId; + Name = name; + CoverageStatus = coverageStatus; + CompletionScore = completionScore; + Roms = roms ?? Array.Empty(); + DefaultRomId = defaultRomId; + Switches = switches ?? Array.Empty(); + Coils = coils ?? Array.Empty(); + Lamps = lamps ?? Array.Empty(); + Wires = wires ?? Array.Empty(); + SpatialSwitches = spatialSwitches ?? Array.Empty(); + SpatialCoils = spatialCoils ?? Array.Empty(); + SpatialLamps = spatialLamps ?? Array.Empty(); + Diagnostics = diagnostics ?? Array.Empty(); + } + } + + public sealed class PinMameSpatialPlacement + { + public string HardwareId { get; } + public string PlacementId { get; } + public string Label { get; } + public float X { get; } + public float Y { get; } + public LampSource Source { get; } + public ColorChannel Channel { get; } + public bool IsCoil { get; } + + public PinMameSpatialPlacement(string hardwareId, string placementId, string label, float x, float y, + LampSource source = LampSource.Lamp, ColorChannel channel = ColorChannel.Alpha, bool isCoil = false) + { + HardwareId = hardwareId; + PlacementId = placementId; + Label = label; + X = x; + Y = y; + Source = source; + Channel = channel; + IsCoil = isCoil; + } + } + + public sealed class PinMameRomDefinition + { + public string Id { get; } + public string Description { get; } + + public PinMameRomDefinition(string id, string description) + { + Id = id; + Description = description; + } + + public override string ToString() => string.IsNullOrWhiteSpace(Description) ? Id : $"{Id} — {Description}"; + } +} diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinition.cs.meta b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinition.cs.meta new file mode 100644 index 0000000..305cce1 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 791a43dc0e9c463fbc50a140a3b39221 diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionCatalogClient.cs b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionCatalogClient.cs new file mode 100644 index 0000000..37025bd --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionCatalogClient.cs @@ -0,0 +1,340 @@ +// 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. + +// ReSharper disable CheckNamespace + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace VisualPinball.Engine.PinMAME.Editor +{ + internal static class PinMameGameDefinitionCatalogClient + { + internal const string BaseUrl = "https://games.visualpinball.org/"; + private static readonly TimeSpan FreshCacheAge = TimeSpan.FromMinutes(10); + private static readonly Regex SafeSlug = new("^[a-z0-9-]+$", RegexOptions.CultureInvariant); + private static readonly HttpClient HttpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; + private static readonly ConcurrentDictionary CacheLocks = new(StringComparer.OrdinalIgnoreCase); + private static readonly object CacheRootLock = new(); + private static string _cacheRoot; + + internal static async Task> GetCatalogAsync(bool forceRefresh, CancellationToken cancellationToken, bool cacheOnly = false) + { + var cachePath = CachePath("index.json"); + var result = await GetJsonAsync("data/index.json", cachePath, forceRefresh, cancellationToken, cacheOnly).ConfigureAwait(false); + PinMameCatalog catalog; + try { + catalog = ParseCatalog(result.Value); + } catch (Exception exception) when ((exception is JsonException || exception is InvalidDataException) && result.FromCache && !cacheOnly) { + result = await GetJsonAsync("data/index.json", cachePath, true, cancellationToken, cacheOnly, true).ConfigureAwait(false); + catalog = ParseCatalog(result.Value); + } + if (catalog.Version == 1) { + var driverResult = await GetJsonAsync("data/drivers.json", CachePath("drivers.json"), forceRefresh, cancellationToken, cacheOnly).ConfigureAwait(false); + PinMameCatalogDriver[] drivers; + try { + drivers = JsonConvert.DeserializeObject(driverResult.Value) ?? Array.Empty(); + } catch (JsonException) when (driverResult.FromCache && !cacheOnly) { + driverResult = await GetJsonAsync("data/drivers.json", CachePath("drivers.json"), true, cancellationToken, false, true).ConfigureAwait(false); + drivers = JsonConvert.DeserializeObject(driverResult.Value) ?? Array.Empty(); + } + UpgradeLegacyCatalog(catalog, drivers); + var warning = CombineWarnings("The machine-definition site is still serving catalog version 1; ROMs were joined from the driver index for compatibility.", CombineWarnings(result.Warning, driverResult.Warning)); + return new PinMameCatalogResult(catalog, result.FromCache && driverResult.FromCache, warning); + } + return new PinMameCatalogResult(catalog, result.FromCache, result.Warning); + } + + internal static async Task> GetDefinitionAsync(PinMameCatalogMachine machine, bool forceRefresh, CancellationToken cancellationToken, bool cacheOnly = false) + { + if (machine == null || string.IsNullOrWhiteSpace(machine.Detail)) { + throw new InvalidOperationException("The selected machine has no published detail definition."); + } + if (!SafeSlug.IsMatch(machine.Slug ?? string.Empty)) { + throw new InvalidDataException($"The catalog returned an unsafe machine slug: {machine.Slug}."); + } + var expectedDetail = $"data/machines/{machine.Slug}.json"; + if (!string.Equals(machine.Detail, expectedDetail, StringComparison.Ordinal)) { + throw new InvalidDataException($"The catalog returned an unexpected detail path for {machine.Name}: {machine.Detail}."); + } + + var cachePath = CachePath($"machines-{machine.Slug}.json"); + var result = await GetJsonAsync(expectedDetail, cachePath, forceRefresh, cancellationToken, cacheOnly).ConfigureAwait(false); + try { + ValidateDefinitionJson(result.Value, machine.Name); + } catch (Exception exception) when ((exception is JsonException || exception is InvalidDataException) && result.FromCache && !cacheOnly) { + result = await GetJsonAsync(expectedDetail, cachePath, true, cancellationToken, false, true).ConfigureAwait(false); + ValidateDefinitionJson(result.Value, machine.Name); + } + return result; + } + + internal static async Task ResolveMachineIdAsync(string romId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(romId)) { + return null; + } + var cachePath = CachePath("drivers.json"); + var result = await GetJsonAsync("data/drivers.json", cachePath, false, cancellationToken).ConfigureAwait(false); + PinMameCatalogDriver[] drivers; + try { + drivers = JsonConvert.DeserializeObject(result.Value) ?? Array.Empty(); + } catch (JsonException) when (result.FromCache) { + result = await GetJsonAsync("data/drivers.json", cachePath, true, cancellationToken, false, true).ConfigureAwait(false); + drivers = JsonConvert.DeserializeObject(result.Value) ?? Array.Empty(); + } + return drivers.FirstOrDefault(driver => string.Equals(driver.Id, romId, StringComparison.OrdinalIgnoreCase))?.MachineId; + } + + private static void ValidateDefinitionJson(string json, string machineName) + { + var root = JObject.Parse(json); + if (!string.Equals(root.Value("format"), "pinmame-machine-definition", StringComparison.Ordinal)) { + throw new InvalidDataException($"The detail endpoint for {machineName} did not return a PinMAME machine definition."); + } + } + + internal static PinMameCatalog ParseCatalog(string json) + { + var catalog = JsonConvert.DeserializeObject(json) ?? throw new InvalidDataException("The PinMAME machine catalog is empty."); + if (!string.Equals(catalog.Format, "pinmame-machine-reference-index", StringComparison.Ordinal)) { + throw new InvalidDataException($"The machine catalog has an unsupported format: {catalog.Format ?? "none"}."); + } + if (catalog.Version is < 1 or > 2) { + throw new InvalidDataException($"The machine catalog has an unsupported version: {catalog.Version}."); + } + catalog.Machines ??= Array.Empty(); + return catalog; + } + + internal static void UpgradeLegacyCatalog(PinMameCatalog catalog, IEnumerable drivers) + { + if (catalog == null) throw new ArgumentNullException(nameof(catalog)); + var romsByMachine = (drivers ?? Enumerable.Empty()) + .Where(driver => !string.IsNullOrWhiteSpace(driver.Id) && !string.IsNullOrWhiteSpace(driver.MachineId)) + .GroupBy(driver => driver.MachineId, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Select(driver => driver.Id).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(id => id, StringComparer.OrdinalIgnoreCase).ToArray(), StringComparer.Ordinal); + foreach (var machine in catalog.Machines ?? Array.Empty()) { + if (romsByMachine.TryGetValue(machine.Id ?? string.Empty, out var roms)) machine.Roms = roms; + else machine.Roms ??= machine.RootDrivers ?? Array.Empty(); + machine.MachineKind ??= (machine.Id ?? string.Empty).StartsWith("diagnostic.", StringComparison.Ordinal) + ? "diagnostic_software" + : "unknown"; + } + } + + private static async Task> GetJsonAsync(string relativeUrl, string cachePath, bool forceRefresh, + CancellationToken cancellationToken, bool cacheOnly = false, bool invalidateCache = false) + { + var cacheLock = CacheLocks.GetOrAdd(cachePath, _ => new SemaphoreSlim(1, 1)); + await cacheLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { + if (invalidateCache) { + DeleteCacheEntry(cachePath); + } + if (cacheOnly) { + if (!File.Exists(cachePath)) throw new FileNotFoundException($"No cached PinMAME definition is available at {cachePath}.", cachePath); + return new PinMameCatalogResult(File.ReadAllText(cachePath), true, null); + } + if (!forceRefresh && IsFresh(cachePath)) { + try { + return new PinMameCatalogResult(File.ReadAllText(cachePath), true, null); + } catch (IOException) { + // The cache changed between the freshness check and read; fetch a replacement. + } + } + + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(new Uri(BaseUrl), relativeUrl)); + var etagPath = cachePath + ".etag"; + if (File.Exists(cachePath) && File.Exists(etagPath)) { + var etag = File.ReadAllText(etagPath).Trim(); + if (EntityTagHeaderValue.TryParse(etag, out var entityTag)) { + request.Headers.IfNoneMatch.Add(entityTag); + } + } + if (forceRefresh) { + request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true }; + } + + using var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotModified && File.Exists(cachePath)) { + File.SetLastWriteTimeUtc(cachePath, DateTime.UtcNow); + return new PinMameCatalogResult(File.ReadAllText(cachePath), true, null); + } + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + JToken.Parse(json); + WriteCache(cachePath, json); + if (response.Headers.ETag != null) { + File.WriteAllText(etagPath, response.Headers.ETag.ToString()); + } else if (File.Exists(etagPath)) { + File.Delete(etagPath); + } + return new PinMameCatalogResult(json, false, null); + } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { + throw; + } catch (OperationCanceledException exception) when (File.Exists(cachePath)) { + return new PinMameCatalogResult(File.ReadAllText(cachePath), true, + $"The machine-definition request timed out; using the cached copy. {exception.Message}"); + } catch (OperationCanceledException exception) { + throw new TimeoutException("The machine-definition request timed out and no cached copy is available.", exception); + } catch (Exception exception) when (File.Exists(cachePath)) { + return new PinMameCatalogResult(File.ReadAllText(cachePath), true, + $"Could not refresh the machine definitions; using the cached copy. {exception.Message}"); + } finally { + cacheLock.Release(); + } + } + + private static bool IsFresh(string path) => File.Exists(path) && DateTime.UtcNow - File.GetLastWriteTimeUtc(path) < FreshCacheAge; + + internal static void EnsureCacheRoot() + { + if (_cacheRoot != null) return; + lock (CacheRootLock) { + if (_cacheRoot != null) return; + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + _cacheRoot = Path.Combine(projectRoot, "Library", "PinMAME", "GameDefinitions"); + Directory.CreateDirectory(_cacheRoot); + } + } + + private static string CachePath(string fileName) + { + EnsureCacheRoot(); + return Path.Combine(_cacheRoot, fileName); + } + + private static string CombineWarnings(string first, string second) + { + if (string.IsNullOrWhiteSpace(first)) return second; + if (string.IsNullOrWhiteSpace(second) || string.Equals(first, second, StringComparison.Ordinal)) return first; + return $"{first}\n{second}"; + } + + private static void WriteCache(string path, string content) + { + var temporaryPath = $"{path}.{Guid.NewGuid():N}.tmp"; + try { + File.WriteAllText(temporaryPath, content); + if (File.Exists(path)) { + File.Delete(path); + } + File.Move(temporaryPath, path); + } finally { + if (File.Exists(temporaryPath)) File.Delete(temporaryPath); + } + } + + private static void DeleteCacheEntry(string path) + { + if (File.Exists(path)) File.Delete(path); + var etagPath = path + ".etag"; + if (File.Exists(etagPath)) File.Delete(etagPath); + } + } + + internal sealed class PinMameCatalog + { + [JsonProperty("format")] + public string Format { get; set; } + + [JsonProperty("version")] + public int Version { get; set; } + + [JsonProperty("machines")] + public PinMameCatalogMachine[] Machines { get; set; } + } + + internal sealed class PinMameCatalogMachine + { + private static readonly HashSet NonPhysicalKinds = new(StringComparer.Ordinal) { + "physical_conversion", "redemption_game", "diagnostic_software", "system_software", + }; + + [JsonProperty("slug")] + public string Slug { get; set; } + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("manufacturer")] + public string Manufacturer { get; set; } + + [JsonProperty("year")] + public int? Year { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } + + [JsonProperty("completionScore")] + public int CompletionScore { get; set; } + + [JsonProperty("machineKind")] + public string MachineKind { get; set; } + + [JsonProperty("roms")] + public string[] Roms { get; set; } + + [JsonProperty("rootDrivers")] + public string[] RootDrivers { get; set; } + + [JsonProperty("detail")] + public string Detail { get; set; } + + public bool IsSelectable => !string.IsNullOrWhiteSpace(Detail) + && !string.Equals(Status, "stub", StringComparison.Ordinal) + && !NonPhysicalKinds.Contains(MachineKind ?? string.Empty); + + public string DisplayName { + get { + var identity = string.Join(" · ", new[] { Name, Manufacturer, Year?.ToString(CultureInfo.InvariantCulture) }.Where(value => !string.IsNullOrWhiteSpace(value))); + return string.Equals(Status, "partial", StringComparison.Ordinal) ? $"{identity} — partial {CompletionScore}%" : identity; + } + } + } + + internal sealed class PinMameCatalogDriver + { + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("machineId")] + public string MachineId { get; set; } + } + + internal readonly struct PinMameCatalogResult + { + public T Value { get; } + public bool FromCache { get; } + public string Warning { get; } + + public PinMameCatalogResult(T value, bool fromCache, string warning) + { + Value = value; + FromCache = fromCache; + Warning = warning; + } + } +} diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionCatalogClient.cs.meta b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionCatalogClient.cs.meta new file mode 100644 index 0000000..f035835 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionCatalogClient.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 361ed0c575204a0dbaf02a8566722b33 diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionConverter.cs b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionConverter.cs new file mode 100644 index 0000000..7e8a81d --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionConverter.cs @@ -0,0 +1,788 @@ +// 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. + +// ReSharper disable CheckNamespace + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using Newtonsoft.Json.Linq; +using VisualPinball.Engine.Common; +using VisualPinball.Engine.Game.Engines; +using VisualPinball.Engine.Math; + +namespace VisualPinball.Engine.PinMAME.Editor +{ + public static class PinMameGameDefinitionConverter + { + private const string ExpectedFormat = "pinmame-machine-definition"; + private static readonly FlipperWireRole[] FlipperWireRoles = { + new("Lower Left Flipper", new[] { "flipper.lower.left", "flipper.lower.left.button", "flipper.lower-left.button", "flipper.left.button", "cabinet.flipper.left-button" }, "flipper.lower.left"), + new("Lower Right Flipper", new[] { "flipper.lower.right", "flipper.lower.right.button", "flipper.lower-right.button", "flipper.right.button", "cabinet.flipper.right-button" }, "flipper.lower.right"), + new("Upper Left Flipper", new[] { "flipper.upper.left", "flipper.upper.left.button" }, "flipper.upper.left"), + new("Upper Right Flipper", new[] { "flipper.upper.right", "flipper.upper.right.button", "flipper.upper-right.button" }, "flipper.upper.right"), + }; + + // PinMAME's generic lower flipper switches (core.h, column CORE_FLIPPERSWCOL): 111/113 are + // the end-of-stroke switches, 112/114 the cabinet buttons. The upper channels (115-118) are + // left to the definition, since machines reuse them (second button optos, other switches). + private const int FirstCoreFlipperSwitch = 111; + private static readonly string[] CoreFlipperSwitchRoles = { "flipper.lower.right", "flipper.lower.left" }; + + public static PinMameGameDefinition Convert(string json, string selectedRomId = null) + { + if (string.IsNullOrWhiteSpace(json)) { + throw new InvalidDataException("The PinMAME machine definition is empty."); + } + + var root = JObject.Parse(json); + if (!string.Equals(root.Value("format"), ExpectedFormat, StringComparison.Ordinal)) { + throw new InvalidDataException($"Expected {ExpectedFormat}, got {root.Value("format") ?? "no format"}."); + } + + var schemaVersion = root.Value("schema_version"); + if (schemaVersion is not (1 or 2)) { + throw new InvalidDataException($"Unsupported machine-definition schema version {schemaVersion?.ToString() ?? "none"}."); + } + + var machine = RequiredObject(root, "machine"); + var machineId = RequiredString(machine, "id"); + var name = RequiredString(machine, "name"); + var coverage = RequiredObject(root, "coverage"); + var coverageStatus = RequiredString(coverage, "status"); + if (string.Equals(coverageStatus, "stub", StringComparison.Ordinal)) { + throw new InvalidDataException($"{name} is a stub and has no hardware definition to populate."); + } + + var completionScore = coverage.Value("completion_score") ?? (coverageStatus == "author_ready" ? 100 : 0); + var sourceDrivers = (root["drivers"] as JArray)?.OfType().ToArray() ?? Array.Empty(); + var roms = ReadRoms(root, sourceDrivers); + var defaultRomId = DefaultRomId(root, sourceDrivers, roms); + var selectedDriver = SelectDriver(sourceDrivers, string.IsNullOrWhiteSpace(selectedRomId) ? defaultRomId : selectedRomId); + var overrides = ReadOverrides(selectedDriver); + var diagnostics = new List(); + if (!string.IsNullOrWhiteSpace(selectedRomId) && selectedDriver == null + && roms.Any(rom => string.Equals(rom.Id, selectedRomId, StringComparison.OrdinalIgnoreCase))) { + diagnostics.Add($"ROM {selectedRomId} is listed by PinMAME but has no curated driver override; the machine's base bindings were used."); + } + + if (string.Equals(selectedDriver?.Value("physical_compatibility"), "different", StringComparison.Ordinal)) { + diagnostics.Add($"ROM {selectedRomId} is marked as physically different from this machine definition."); + } + + var spatialSwitches = new List(); + var spatialCoils = new List(); + var spatialLamps = new List(); + var switches = ConvertSwitches(root["inputs"] as JArray, overrides, spatialSwitches, diagnostics); + var coils = new List(); + var lamps = new List(); + ConvertOutputs(root["outputs"] as JArray, overrides, coils, lamps, spatialCoils, spatialLamps, diagnostics); + var wires = ConvertFlipperWires(root["inputs"] as JArray, root["outputs"] as JArray, overrides, diagnostics); + + return new PinMameGameDefinition(machineId, name, coverageStatus, completionScore, roms, defaultRomId, + switches.ToArray(), coils.ToArray(), lamps.ToArray(), wires, spatialSwitches.ToArray(), + spatialCoils.ToArray(), spatialLamps.ToArray(), diagnostics.ToArray()); + } + + private static GamelogicEngineWire[] ConvertFlipperWires(JArray inputs, JArray outputs, IReadOnlyDictionary overrides, + ICollection diagnostics) + { + if (inputs == null || outputs == null) { + return Array.Empty(); + } + + var inputDevices = inputs.OfType() + .Where(IsAvailable) + .Select(input => new { Device = input, Binding = EffectiveBinding(input, overrides) }) + .Where(item => string.Equals(item.Binding?.Value("group"), "pinmame.input.switch", StringComparison.Ordinal)) + .Where(item => item.Binding?["device"]?.Type == JTokenType.Integer) + .ToArray(); + var outputDevices = outputs.OfType() + .Where(IsAvailable) + .Select(output => new { Device = output, Binding = EffectiveBinding(output, overrides) }) + .Where(item => string.Equals(item.Binding?.Value("group"), "pinmame.output.solenoid", StringComparison.Ordinal)) + .Where(item => item.Binding?["device"]?.Type == JTokenType.Integer) + // synthetic flipper coils (e.g. pre-Fliptronic WPC) are virtual + .Where(item => item.Device.Value("kind") is "coil" or "virtual") + .ToArray(); + + var wires = new List(); + var ambiguousSourceIds = new HashSet(StringComparer.Ordinal); + foreach (var role in FlipperWireRoles) { + var sources = inputDevices + .Where(item => MatchesFlipperInputRole(item.Device, item.Binding.Value("device"), role)) + .ToArray(); + // PinMAME copies its generic flipper buttons (112/114) into the game's matrix flipper + // switches, so when both are listed, the generic one is what the host drives. + var coreSources = sources + .Where(item => TryGetCoreFlipperSwitch(item.Device, item.Binding.Value("device"), out _, out _)) + .ToArray(); + if (sources.Length > 1 && coreSources.Length == 1) { + sources = coreSources; + } + if (sources.Length > 1) { + diagnostics.Add($"The definition has ambiguous {role.Description} switch roles; its direct flipper wire was not imported."); + continue; + } + if (sources.Length == 0) { + continue; + } + var destinations = outputDevices + .Where(item => string.Equals(FlipperRoleForOutput(item.Device), role.DestinationRole, StringComparison.Ordinal)) + .Where(item => !IsHoldFlipperOutput(item.Device)) + .ToArray(); + if (destinations.Length > 1) { + diagnostics.Add($"The definition has ambiguous {role.Description} coil roles; its direct flipper wire was not imported."); + continue; + } + if (destinations.Length == 0) { + continue; + } + + var source = sources[0]; + var destination = destinations[0]; + var sourceId = source.Binding.Value("device").ToString(CultureInfo.InvariantCulture); + var destinationId = destination.Binding.Value("device").ToString(CultureInfo.InvariantCulture); + if (ambiguousSourceIds.Contains(sourceId)) { + continue; + } + if (wires.Any(wire => string.Equals(wire.SourceId, sourceId, StringComparison.Ordinal))) { + wires.RemoveAll(wire => string.Equals(wire.SourceId, sourceId, StringComparison.Ordinal)); + ambiguousSourceIds.Add(sourceId); + diagnostics.Add($"PinMAME switch {sourceId} represents multiple flipper buttons; its direct flipper wires were left for manual mapping."); + continue; + } + wires.Add(new GamelogicEngineWire(sourceId, destinationId, DestinationType.Coil, role.Description)); + } + return wires.ToArray(); + } + + private static List ConvertSwitches(JArray inputs, IReadOnlyDictionary overrides, + ICollection spatialSwitches, ICollection diagnostics) + { + var switches = new List(); + var addresses = new HashSet(); + var coreFlipperButtonIds = new HashSet(StringComparer.Ordinal); + if (inputs == null) { + return switches; + } + var genericCoinSlots = inputs.OfType() + .Where(IsAvailable) + .Where(input => (input["roles"] as JArray)?.Values().Contains("cabinet.coin", StringComparer.Ordinal) == true) + .Select(input => new { Input = input, Binding = EffectiveBinding(input, overrides) }) + .Where(item => string.Equals(item.Binding?.Value("group"), "pinmame.input.switch", StringComparison.Ordinal) && item.Binding?["device"]?.Type == JTokenType.Integer) + .Select(item => item.Binding.Value("device")) + .Distinct() + .OrderBy(address => address) + .Take(4) + .Select((address, index) => new { address, slot = index + 1 }) + .ToDictionary(item => item.address, item => item.slot); + + foreach (var input in inputs.OfType()) { + if (!IsAvailable(input)) { + continue; + } + + var binding = EffectiveBinding(input, overrides); + if (!string.Equals(binding?.Value("group"), "pinmame.input.switch", StringComparison.Ordinal)) { + continue; + } + + var address = RequiredDevice(binding, input); + if (!addresses.Add(address)) { + diagnostics.Add($"Duplicate PinMAME switch address {address}; {input.Value("id") ?? "the later switch"} was not imported."); + continue; + } + + var label = RequiredString(input, "label"); + var flipperRoleCount = FlipperWireRoles.Count(role => MatchesFlipperInputRole(input, address, role)); + var inputAction = flipperRoleCount > 1 + ? string.Empty + : InputAction(input, label, address, genericCoinSlots.TryGetValue(address, out var genericCoinSlot) ? genericCoinSlot : 0); + if (TryGetCoreFlipperSwitch(input, address, out _, out var isCoreEos) && isCoreEos != IsEosByDefinition(input)) { + diagnostics.Add($"PinMAME switch {address} ({label}) is a flipper {(isCoreEos ? "end-of-stroke switch" : "button")} in PinMAME's numbering; the definition says otherwise, PinMAME's numbering was used."); + } + if (flipperRoleCount > 1) { + diagnostics.Add($"PinMAME switch {address} ({label}) represents multiple flipper buttons; its input action was left for manual mapping."); + } + var constantActive = input.Value("constant_active"); + var normallyClosed = IsNormallyClosedForHost(input); + if (normallyClosed && string.IsNullOrEmpty(inputAction) && !constantActive.HasValue) { + // nothing operates it (e.g. a playfield glass or memory-protect switch), so it keeps its + // rest state; a normally closed mapping without a source would never be sent + constantActive = true; + normallyClosed = false; + } + var @switch = new GamelogicEngineSwitch(address) { + Description = label, + NormallyClosed = normallyClosed, + IsPulseSwitch = input.Value("pulse") ?? false, + InputActionHint = inputAction, + InputMapHint = string.IsNullOrEmpty(inputAction) ? string.Empty : InputConstants.MapCabinetSwitches, + ConstantHint = constantActive.HasValue + ? constantActive.Value ? SwitchConstantHint.AlwaysClosed : SwitchConstantHint.AlwaysOpen + : SwitchConstantHint.None, + }; + if (TryGetCoreFlipperSwitch(input, address, out _, out var isCoreFlipperEos) && !isCoreFlipperEos) { + coreFlipperButtonIds.Add(@switch.Id); + } + ApplySwitchDeviceHint(@switch, input); + switches.Add(@switch); + // cabinet inputs (buttons, and the matrix switches PinMAME mirrors them into) aren't + // playfield devices, even when the definition gives them a position + if (string.IsNullOrEmpty(inputAction) && !IsCabinetInput(input)) { + AddSpatialPlacements(spatialSwitches, input, @switch.Id, label); + } + } + + var orderedSwitches = switches.OrderBy(@switch => NumericId(@switch.Id)).ToList(); + var inputActions = new HashSet(StringComparer.Ordinal); + // generic flipper buttons first: the matrix flipper switches only mirror them + var actionClaimOrder = orderedSwitches + .Where(@switch => !string.IsNullOrEmpty(@switch.InputActionHint)) + .OrderBy(@switch => coreFlipperButtonIds.Contains(@switch.Id) ? 0 : 1) + .ToArray(); + foreach (var @switch in actionClaimOrder) { + if (inputActions.Add(@switch.InputActionHint)) { + continue; + } + + diagnostics.Add($"Duplicate input action {@switch.InputActionHint}; switch {@switch.Id} ({@switch.Description}) was left available for manual mapping."); + @switch.InputActionHint = string.Empty; + @switch.InputMapHint = string.Empty; + } + return orderedSwitches; + } + + private static void ConvertOutputs(JArray outputs, IReadOnlyDictionary overrides, + ICollection coils, ICollection lamps, + ICollection spatialCoils, ICollection spatialLamps, + ICollection diagnostics) + { + if (outputs == null) { + return; + } + + var addresses = new HashSet(StringComparer.Ordinal); + foreach (var output in outputs.OfType()) { + if (!IsAvailable(output)) { + continue; + } + var kind = RequiredString(output, "kind"); + + var binding = EffectiveBinding(output, overrides); + var group = binding?.Value("group"); + if (group is not ("pinmame.output.solenoid" or "pinmame.output.lamp" or "pinmame.output.gi")) { + continue; + } + + var address = RequiredDevice(binding, output); + var channel = binding.Value("channel") ?? -1; + if (!addresses.Add($"{group}:{address}:{channel}")) { + diagnostics.Add($"Duplicate PinMAME output binding {group} address {address}, channel {channel}; {output.Value("id") ?? "the later output"} was not imported."); + continue; + } + + var label = RequiredString(output, "label"); + if (group == "pinmame.output.solenoid") { + var coil = new GamelogicEngineCoil(address) { + Description = label, + IsLamp = kind is "flasher" or "lamp" or "rgb_lamp" or "gi", + }; + ApplyCoilDeviceHint(coil, output, kind, address); + coils.Add(coil); + if (coil.IsLamp) { + AddSpatialPlacements(spatialLamps, output, coil.Id, label, LampSource.Lamp, ColorChannel.Alpha, true); + } else { + AddSpatialPlacements(spatialCoils, output, coil.Id, label); + } + if (kind is "rgb_lamp" or "gi") { + diagnostics.Add($"{label} uses the solenoid callback as {kind}; VPE currently imports it as a single-channel lamp output."); + } + continue; + } + + if (kind is "coil" or "motor" or "servo" or "magnet" or "relay" or "control_signal") { + diagnostics.Add($"{label} is a {kind} carried by {group}; VPE has no coil destination driven by lamp callbacks, so it was not imported."); + continue; + } + + var isRgbChannel = TryColorChannelFor(output, out var colorChannel); + if (kind == "rgb_lamp" && !isRgbChannel) { + diagnostics.Add($"RGB output {output.Value("id") ?? label} does not identify its red, green, or blue channel and was not imported."); + continue; + } + if (!isRgbChannel) colorChannel = ColorChannel.Alpha; + // VPE needs the raw value at maximum intensity, while the catalog's `steps` is the + // inclusive count of distinct values. PinMAME GI defaults to native levels 0..8. + var range = output["range"] as JObject; + var maximum = range?.Value("maximum") ?? (group == "pinmame.output.gi" ? 8 : 0); + var steps = range?.Value("steps") ?? (group == "pinmame.output.gi" ? 9 : 0); + var fadingSteps = maximum > 0 ? maximum : steps > 1 ? steps - 1 : 0; + var lamp = new GamelogicEngineLamp(address) { + Description = label, + Channel = colorChannel, + FadingSteps = fadingSteps, + Source = group == "pinmame.output.gi" ? LampSource.GI : LampSource.Lamp, + Type = isRgbChannel ? LampType.RgbMulti : (fadingSteps > 1 ? LampType.SingleFading : LampType.SingleOnOff), + }; + lamps.Add(lamp); + AddSpatialPlacements(spatialLamps, output, lamp.Id, label, lamp.Source, lamp.Channel); + } + } + + private static void AddSpatialPlacements(ICollection destination, JObject device, + string hardwareId, string label, LampSource source = LampSource.Lamp, ColorChannel channel = ColorChannel.Alpha, + bool isCoil = false) + { + if (device["spatial"]?["placements"] is not JArray placements) { + return; + } + // the definition itself doesn't trust these positions + if (string.Equals(device["spatial"]?.Value("status"), "conflicted", StringComparison.Ordinal)) { + return; + } + + var deviceId = device.Value("id") ?? hardwareId; + var placementIndex = 0; + foreach (var placement in placements.OfType()) { + if (!string.Equals(placement.Value("space"), "playfield", StringComparison.Ordinal)) { + continue; + } + var x = placement.Value("x"); + var y = placement.Value("y"); + if (!x.HasValue || !y.HasValue || x.Value < 0f || x.Value > 1f || y.Value < 0f || y.Value > 1f) { + continue; + } + var placementId = placement.Value("id"); + if (string.IsNullOrWhiteSpace(placementId)) { + placementId = $"{deviceId}:{placementIndex}"; + } + destination.Add(new PinMameSpatialPlacement(hardwareId, placementId, label, x.Value, y.Value, source, channel, isCoil)); + placementIndex++; + } + } + + private static PinMameRomDefinition[] ReadRoms(JObject root, IEnumerable sourceDrivers) + { + var roms = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var driver in sourceDrivers) { + AddRom(roms, driver); + } + if (root["catalogDrivers"] is JArray catalogDrivers) { + foreach (var driver in catalogDrivers.OfType()) { + AddRom(roms, driver); + } + } + return roms.Values.OrderBy(rom => rom.Id, StringComparer.OrdinalIgnoreCase).ToArray(); + } + + /// + /// The parent ROM: the listed driver that isn't a clone, or else the one most clones point to. + /// The list itself is alphabetical, so its first entry is often a prototype or a homebrew fix. + /// + private static string DefaultRomId(JObject root, IEnumerable sourceDrivers, PinMameRomDefinition[] roms) + { + var drivers = sourceDrivers + .Concat((root["catalogDrivers"] as JArray)?.OfType() ?? Enumerable.Empty()) + .ToArray(); + string Listed(string id) => roms.FirstOrDefault(rom => string.Equals(rom.Id, id, StringComparison.OrdinalIgnoreCase))?.Id; + + var parent = drivers + .Where(driver => string.IsNullOrWhiteSpace(driver.Value("clone_of"))) + .Select(driver => Listed(driver.Value("id"))) + .FirstOrDefault(id => id != null); + parent ??= drivers + .Select(driver => Listed(driver.Value("root_driver") ?? driver.Value("clone_of"))) + .Where(id => id != null) + .GroupBy(id => id, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(group => group.Count()) + .Select(group => group.Key) + .FirstOrDefault(); + return parent ?? roms.FirstOrDefault()?.Id; + } + + private static void AddRom(IDictionary roms, JObject driver) + { + var id = driver.Value("id"); + if (string.IsNullOrWhiteSpace(id)) { + return; + } + var description = driver.Value("description") ?? string.Empty; + roms[id] = new PinMameRomDefinition(id, description); + } + + private static JObject SelectDriver(IEnumerable drivers, string selectedRomId) + { + var driverList = drivers.ToArray(); + if (!string.IsNullOrWhiteSpace(selectedRomId)) { + return driverList.FirstOrDefault(driver => string.Equals(driver.Value("id"), selectedRomId, StringComparison.OrdinalIgnoreCase)); + } + return driverList.FirstOrDefault(); + } + + private static IReadOnlyDictionary ReadOverrides(JObject driver) + { + var overrides = new Dictionary(StringComparer.Ordinal); + if (driver?["overrides"] is not JArray overrideArray) { + return overrides; + } + foreach (var item in overrideArray.OfType()) { + var target = item.Value("target"); + if (!string.IsNullOrWhiteSpace(target) && item["binding"] is JObject binding) { + overrides[target] = binding; + } + } + return overrides; + } + + private static JObject EffectiveBinding(JObject device, IReadOnlyDictionary overrides) + { + var id = device.Value("id"); + return id != null && overrides.TryGetValue(id, out var binding) ? binding : device["binding"] as JObject; + } + + private static bool IsAvailable(JObject device) + { + if (string.Equals(device.Value("availability"), "unused", StringComparison.Ordinal)) { + return false; + } + if (!string.Equals(device.Value("kind"), "virtual", StringComparison.Ordinal)) { + return true; + } + // "virtual" is independent of availability. A virtual item with a public role is still + // driven by or drives the host (e.g. a flipper button outside the switch matrix, or a + // synthetic flipper coil). Without one, virtual inputs are host inputs too, while + // virtual outputs are mirrors of other outputs or internal state. + var roles = (device["roles"] as JArray)?.Values().Where(role => role != null).ToArray() ?? Array.Empty(); + if (roles.Any(role => !role.StartsWith("internal.", StringComparison.Ordinal))) { + return true; + } + return roles.Length == 0 && (device["binding"] as JObject)?.Value("group")?.StartsWith("pinmame.input.", StringComparison.Ordinal) == true; + } + + /// + /// The definition's normally_closed is the physical contact (e.g. optos). PinMAME + /// already inverts those itself (per-game invSw), so passing it on would invert them + /// twice. What the host establishes at rest is initial_active; for a cabinet input + /// (coin door, memory protect, playfield glass) that's the state it keeps until operated. + /// Elsewhere it's only a startup state, e.g. balls resting in the trough. + /// + private static bool IsNormallyClosedForHost(JObject input) + { + var roles = (input["roles"] as JArray)?.Values() ?? Enumerable.Empty(); + if (input.Value("initial_active") == true && roles.Any(role => role != null && role.StartsWith("cabinet.", StringComparison.Ordinal))) { + return true; + } + // definitions that don't declare initial_active yet + return HasRole(input, "cabinet.coin-door") && (input.Value("normally_closed") ?? false); + } + + private static bool HasRole(JObject device, params string[] roles) + { + var deviceRoles = (device["roles"] as JArray)?.Values() ?? Enumerable.Empty(); + return deviceRoles.Intersect(roles, StringComparer.Ordinal).Any(); + } + + private static int RequiredDevice(JObject binding, JObject device) + { + if (binding?["device"]?.Type != JTokenType.Integer) { + throw new InvalidDataException($"{device.Value("id") ?? "A device"} has no integer PinMAME address."); + } + return binding.Value("device"); + } + + private static string InputAction(JObject input, string label, int address, int genericCoinSlot) + { + if (TryGetCoreFlipperSwitch(input, address, out var coreFlipperRole, out var isCoreEos)) { + return isCoreEos ? string.Empty : FlipperAction(coreFlipperRole); + } + var roles = input["roles"] as JArray; + if (roles == null) { + return string.Empty; + } + var values = new HashSet(roles.Values(), StringComparer.Ordinal); + if (values.Contains("cabinet.start-or-tournament")) return InputConstants.ActionStartGame; + if (values.Contains("cabinet.start") && !Contains(label, "tournament")) return InputConstants.ActionStartGame; + if (HasAny(values, "cabinet.launch", "cabinet.shooter")) return InputConstants.ActionPlunger; + if (HasAny(values, "cabinet.buy-in")) return InputConstants.ActionFrontBuyIn; + if (HasAny(values, "cabinet.grip-trigger")) return InputConstants.ActionPlunger; + if (HasAny(values, "cabinet.action", "cabinet.fire", "cabinet.fire-button")) return InputConstants.ActionFire1; + if (HasAny(values, "cabinet.coin.1", "cabinet.coin.left")) return InputConstants.ActionInsertCoin1; + if (HasAny(values, "cabinet.coin.2", "cabinet.coin.center", "cabinet.coin.middle")) return InputConstants.ActionInsertCoin2; + if (HasAny(values, "cabinet.coin.3", "cabinet.coin.right")) return InputConstants.ActionInsertCoin3; + if (HasAny(values, "cabinet.coin.4", "cabinet.coin.fourth")) return InputConstants.ActionInsertCoin4; + if (values.Contains("cabinet.coin")) { + var semanticCoinSlot = CoinSlotFromLabel(label); + var coinSlot = semanticCoinSlot > 0 ? semanticCoinSlot : semanticCoinSlot < 0 ? 0 : genericCoinSlot; + switch (coinSlot) { + case 1: return InputConstants.ActionInsertCoin1; + case 2: return InputConstants.ActionInsertCoin2; + case 3: return InputConstants.ActionInsertCoin3; + case 4: return InputConstants.ActionInsertCoin4; + } + } + if (HasAny(values, "cabinet.coin-door")) return InputConstants.ActionCoinDoorOpenClose; + if (HasAny(values, "cabinet.slam-tilt")) return InputConstants.ActionSlamTilt; + if (HasAny(values, "cabinet.tilt")) { + if (Contains(label, "slam")) return InputConstants.ActionSlamTilt; + if (Contains(label, "ball roll") || Contains(label, "playfield tilt")) return string.Empty; + // Keep compatibility with the VisualPinball.Engine preview used by the standalone + // wrapper build; that release predates InputConstants.ActionTilt. + return "Tilt"; + } + if (!IsEosFlipperInput(input)) { + if (HasAny(values, "flipper.lower.left", "flipper.lower.left.button", "flipper.lower-left.button", "flipper.left.button", "cabinet.flipper.left-button")) return InputConstants.ActionLeftFlipper; + if (HasAny(values, "flipper.lower.right", "flipper.lower.right.button", "flipper.lower-right.button", "flipper.right.button", "cabinet.flipper.right-button")) return InputConstants.ActionRightFlipper; + if (HasAny(values, "flipper.upper.left", "flipper.upper.left.button")) return InputConstants.ActionUpperLeftFlipper; + if (HasAny(values, "flipper.upper.right", "flipper.upper.right.button", "flipper.upper-right.button")) return InputConstants.ActionUpperRightFlipper; + if (HasAny(values, "cabinet.flipper", "cabinet.flipper.button")) { + return FlipperAction(FlipperRoleFromIdentity(input.Value("id")) ?? FlipperRoleFromIdentity(label)); + } + } + if (HasAny(values, "service.escape", "service.cancel")) return InputConstants.ActionCoinDoorCancel; + if (HasAny(values, "service.down")) return InputConstants.ActionCoinDoorDown; + if (HasAny(values, "service.up")) return InputConstants.ActionCoinDoorUp; + if (HasAny(values, "service.enter")) return InputConstants.ActionCoinDoorEnter; + if (HasAny(values, "service.back", "cabinet.service.back")) return InputConstants.ActionCoinDoorBack; + if (HasAny(values, "cabinet.service.minus")) return InputConstants.ActionCoinDoorMinus; + if (HasAny(values, "cabinet.service.plus")) return InputConstants.ActionCoinDoorPlus; + if (HasAny(values, "cabinet.service.select")) return InputConstants.ActionCoinDoorSelect; + if (HasAny(values, "service.advance", "service.adjust")) return InputConstants.ActionCoinDoorAdvance; + if (HasAny(values, "service.updown")) return InputConstants.ActionCoinDoorUpDown; + if (HasAny(values, "service.self-test", "service.test", "service.diagnostic")) return InputConstants.ActionSelfTest; + if (HasAny(values, "cabinet.service", "service.button")) return ServiceActionFromLabel(label); + return string.Empty; + } + + private static int CoinSlotFromLabel(string label) + { + var identity = NormalizeIdentity(label); + var words = new HashSet(identity.Split(new[] { ' ', '(', ')', '/', '&' }, StringSplitOptions.RemoveEmptyEntries), StringComparer.Ordinal); + if (words.Contains("left")) return 1; + if (words.Contains("center") || words.Contains("centre") || words.Contains("middle") || words.Contains("dba")) return 2; + if (words.Contains("right")) return 3; + if (words.Contains("fourth") || words.Contains("4th") || words.Contains("4") || words.Contains("iv")) return 4; + if (words.Contains("fifth") || words.Contains("5th") || words.Contains("5") || words.Contains("v") + || words.Contains("sixth") || words.Contains("6th") || words.Contains("6") || words.Contains("vi")) return -1; + if (words.Contains("first") || words.Contains("1st") || words.Contains("1") || words.Contains("i")) return 1; + if (words.Contains("second") || words.Contains("2nd") || words.Contains("2") || words.Contains("ii")) return 2; + if (words.Contains("third") || words.Contains("3rd") || words.Contains("3") || words.Contains("iii")) return 3; + return 0; + } + + private static string ServiceActionFromLabel(string label) + { + if (Contains(label, "escape") || Contains(label, "cancel")) return InputConstants.ActionCoinDoorCancel; + if (Contains(label, "down")) return InputConstants.ActionCoinDoorDown; + if (Contains(label, "up")) return InputConstants.ActionCoinDoorUp; + if (Contains(label, "enter")) return InputConstants.ActionCoinDoorEnter; + if (Contains(label, "back")) return InputConstants.ActionCoinDoorBack; + if (Contains(label, "minus")) return InputConstants.ActionCoinDoorMinus; + if (Contains(label, "plus")) return InputConstants.ActionCoinDoorPlus; + if (Contains(label, "select")) return InputConstants.ActionCoinDoorSelect; + return string.Empty; + } + + private static bool Contains(string value, string part) => value?.IndexOf(part, StringComparison.OrdinalIgnoreCase) >= 0; + + private static bool HasAny(ISet values, params string[] candidates) => candidates.Any(values.Contains); + + private static bool MatchesFlipperInputRole(JObject input, int address, FlipperWireRole role) + { + if (TryGetCoreFlipperSwitch(input, address, out var coreFlipperRole, out var isCoreEos)) { + return !isCoreEos && string.Equals(coreFlipperRole, role.DestinationRole, StringComparison.Ordinal); + } + if (IsEosFlipperInput(input)) return false; + if (HasRole(input, role.SourceRoles)) return true; + if (!HasRole(input, "cabinet.flipper", "cabinet.flipper.button")) return false; + var inferredRole = FlipperRoleFromIdentity(input.Value("id")) ?? FlipperRoleFromIdentity(input.Value("label")); + return string.Equals(inferredRole, role.DestinationRole, StringComparison.Ordinal); + } + + /// + /// Whether the input is one of PinMAME's generic lower flipper switches (111-114), and which + /// flipper it belongs to. Their meaning is fixed by PinMAME, whatever the definition's + /// label says. Only inputs the definition describes as flipper switches qualify. + /// + private static bool TryGetCoreFlipperSwitch(JObject input, int address, out string flipperRole, out bool isEos) + { + flipperRole = null; + isEos = false; + var offset = address - FirstCoreFlipperSwitch; + if (offset < 0 || offset >= CoreFlipperSwitchRoles.Length * 2) { + return false; + } + var roles = (input["roles"] as JArray)?.Values() ?? Enumerable.Empty(); + if (!roles.Any(role => Contains(role, "flipper")) && !Contains(input.Value("label"), "flipper")) { + return false; + } + flipperRole = CoreFlipperSwitchRoles[offset / 2]; + isEos = offset % 2 == 0; + return true; + } + + private static bool IsCabinetInput(JObject input) + { + var roles = (input["roles"] as JArray)?.Values() ?? Enumerable.Empty(); + return roles.Any(role => role != null && (role.StartsWith("cabinet.", StringComparison.Ordinal) + || role.StartsWith("flipper.", StringComparison.Ordinal) && !role.EndsWith(".eos", StringComparison.Ordinal))); + } + + private static bool IsEosByDefinition(JObject input) + { + var roles = (input["roles"] as JArray)?.Values() ?? Enumerable.Empty(); + return IsEosFlipperInput(input) || roles.Any(role => role != null && role.EndsWith(".eos", StringComparison.Ordinal)); + } + + private static bool IsEosFlipperInput(JObject input) + { + var identity = NormalizeIdentity($"{input.Value("id")} {input.Value("label")}"); + return identity.Contains("end of stroke") || identity.Contains("eos") || identity.Contains("e o s"); + } + + private static string FlipperAction(string role) + { + return role switch { + "flipper.lower.left" => InputConstants.ActionLeftFlipper, + "flipper.lower.right" => InputConstants.ActionRightFlipper, + "flipper.upper.left" => InputConstants.ActionUpperLeftFlipper, + "flipper.upper.right" => InputConstants.ActionUpperRightFlipper, + _ => string.Empty, + }; + } + + private static string FlipperRoleForOutput(JObject output) + { + if (HasRole(output, "flipper.left.coil")) return "flipper.lower.left"; + if (HasRole(output, "flipper.right.coil")) return "flipper.lower.right"; + foreach (var role in FlipperWireRoles) { + if (HasRole(output, role.DestinationRole)) { + return role.DestinationRole; + } + } + + return FlipperRoleFromIdentity(output.Value("id")) ?? FlipperRoleFromIdentity(output.Value("label")); + } + + private static string FlipperRoleFromIdentity(string value) + { + var identity = NormalizeIdentity(value); + if (!identity.Contains("flipper")) return null; + var nonCabinetFlipper = new[] { "backbox", "underplayfield", "spinning", "diverter", "relay", "mini", "top", "upf", "unused", "generic" }; + if (nonCabinetFlipper.Any(identity.Contains)) return null; + var level = identity.Contains("upper") ? "upper" : identity.Contains("lower") ? "lower" : null; + var side = identity.Contains("left") ? "left" : identity.Contains("right") ? "right" : null; + if (level == null && side != null) { + level = "lower"; + } + return level != null && side != null ? $"flipper.{level}.{side}" : null; + } + + private static string NormalizeIdentity(string value) => (value ?? string.Empty).ToLowerInvariant().Replace('.', ' ').Replace('-', ' ').Replace('_', ' '); + + private static bool IsHoldFlipperOutput(JObject output) + { + var identity = $"{output.Value("id")} {output.Value("label")}"; + return identity.IndexOf("hold", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static void ApplySwitchDeviceHint(GamelogicEngineSwitch @switch, JObject input) + { + var label = @switch.Description ?? string.Empty; + if (HasRole(input, "internal.trough")) { + @switch.DeviceHint = "^Trough\\s*\\d?$"; + if (string.Equals(label, "Trough Left", StringComparison.OrdinalIgnoreCase)) @switch.DeviceItemHint = "3"; + else if (string.Equals(label, "Trough Center", StringComparison.OrdinalIgnoreCase) || string.Equals(label, "Trough Middle", StringComparison.OrdinalIgnoreCase)) @switch.DeviceItemHint = "2"; + else if (string.Equals(label, "Trough Right", StringComparison.OrdinalIgnoreCase)) @switch.DeviceItemHint = "1"; + return; + } + + if (label.IndexOf("outhole", StringComparison.OrdinalIgnoreCase) >= 0 || label.IndexOf("drain", StringComparison.OrdinalIgnoreCase) >= 0) { + @switch.DeviceHint = "^Trough\\s*\\d?$"; + @switch.DeviceItemHint = "drain_switch"; + } + } + + private static void ApplyCoilDeviceHint(GamelogicEngineCoil coil, JObject output, string kind, int address) + { + if (kind == "flasher") { + coil.DeviceHint = $"^F1?{address}$"; + return; + } + + var semanticId = output.Value("id") ?? string.Empty; + if (string.Equals(semanticId, "device.outhole", StringComparison.Ordinal)) { + coil.DeviceHint = "^Trough\\s*\\d?$"; + coil.DeviceItemHint = "entry_coil"; + return; + } + if (string.Equals(semanticId, "device.trough", StringComparison.Ordinal)) { + coil.DeviceHint = "^Trough\\s*\\d?$"; + coil.DeviceItemHint = "eject_coil"; + return; + } + if (string.Equals(semanticId, "device.plunger", StringComparison.Ordinal)) { + coil.DeviceHint = "^Plunger\\d*$"; + return; + } + + var flipperRole = FlipperRoleForOutput(output); + if (flipperRole == null) return; + var isLeft = flipperRole.EndsWith(".left", StringComparison.Ordinal); + var isLower = flipperRole.Contains(".lower."); + coil.DeviceHint = isLeft + ? isLower ? "^((Lower)?LeftFlipper|LFlipper|FlipperLeft|FlipperL)\\d*$" : "^(UpperLeftFlipper|ULFlipper|FlipperUpperLeft)\\d*$" + : isLower ? "^((Lower)?RightFlipper|RFlipper|FlipperRight|FlipperR)\\d*$" : "^(UpperRightFlipper|URFlipper|FlipperUpperRight)\\d*$"; + coil.DeviceItemHint = IsHoldFlipperOutput(output) ? "hold_coil" : "main_coil"; + } + + private static bool TryColorChannelFor(JObject output, out ColorChannel channel) + { + var id = (output.Value("id") ?? string.Empty).ToLowerInvariant(); + var label = (output.Value("label") ?? string.Empty).ToLowerInvariant(); + if (id.EndsWith(".red", StringComparison.Ordinal) || id.EndsWith("-red", StringComparison.Ordinal) || label.Contains("(red)")) channel = ColorChannel.Red; + else if (id.EndsWith(".green", StringComparison.Ordinal) || id.EndsWith("-green", StringComparison.Ordinal) || label.Contains("(green)")) channel = ColorChannel.Green; + else if (id.EndsWith(".blue", StringComparison.Ordinal) || id.EndsWith("-blue", StringComparison.Ordinal) || label.Contains("(blue)")) channel = ColorChannel.Blue; + else { + channel = ColorChannel.Alpha; + return false; + } + return true; + } + + private static JObject RequiredObject(JObject parent, string property) + { + return parent[property] as JObject ?? throw new InvalidDataException($"The definition has no {property} object."); + } + + private static string RequiredString(JObject parent, string property) + { + var value = parent.Value(property); + return !string.IsNullOrWhiteSpace(value) ? value : throw new InvalidDataException($"The definition has no {property} value."); + } + + private static int NumericId(string id) => int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : int.MaxValue; + + private sealed class FlipperWireRole + { + internal string Description { get; } + internal string[] SourceRoles { get; } + internal string DestinationRole { get; } + + internal FlipperWireRole(string description, string[] sourceRoles, string destinationRole) + { + Description = description; + SourceRoles = sourceRoles; + DestinationRole = destinationRole; + } + } + } +} diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionConverter.cs.meta b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionConverter.cs.meta new file mode 100644 index 0000000..18b4f19 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionConverter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 254cc31b25484ce6b06e4955f0d5c0a2 diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionEditorService.cs b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionEditorService.cs new file mode 100644 index 0000000..2da8aac --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionEditorService.cs @@ -0,0 +1,120 @@ +// 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. + +// ReSharper disable CheckNamespace + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using UnityEditor; +using UnityEngine; +using VisualPinball.Engine.Game.Engines; +using VisualPinball.Unity; + +namespace VisualPinball.Engine.PinMAME.Editor +{ + /// + /// Editor-only entry point used by table importers that need to populate mappings without opening the inspector. + /// The raw fetched definition remains transient. Editor builds retain normalized manager candidates for authoring, + /// while player builds receive only the selected identifiers and populated table mappings. + /// + public static class PinMameGameDefinitionEditorService + { + public static PinMameGameDefinition PopulateHardware(PinMameGamelogicEngine gle, + TableComponent tableComponent, string machineId, string romId, CancellationToken cancellationToken = default) + { + ValidateArguments(gle, tableComponent, machineId); + var loaded = LoadDefinitionAsync(machineId, romId, cancellationToken, false).ConfigureAwait(false).GetAwaiter().GetResult(); + cancellationToken.ThrowIfCancellationRequested(); + if (!gle || !tableComponent) throw new OperationCanceledException("The target table was destroyed while its PinMAME definition was loading."); + return Apply(gle, tableComponent, machineId, romId, loaded); + } + + public static PinMameGameDefinition PopulateHardwareFromCache(PinMameGamelogicEngine gle, + TableComponent tableComponent, string machineId, string romId, CancellationToken cancellationToken = default) + { + ValidateArguments(gle, tableComponent, machineId); + var loaded = LoadDefinitionAsync(machineId, romId, cancellationToken, true).ConfigureAwait(false).GetAwaiter().GetResult(); + cancellationToken.ThrowIfCancellationRequested(); + if (!gle || !tableComponent) throw new OperationCanceledException("The target table was destroyed while its PinMAME definition was loading."); + return Apply(gle, tableComponent, machineId, romId, loaded); + } + + public static async Task PopulateHardwareAsync(PinMameGamelogicEngine gle, + TableComponent tableComponent, string machineId, string romId, CancellationToken cancellationToken = default) + { + ValidateArguments(gle, tableComponent, machineId); + var loaded = await LoadDefinitionAsync(machineId, romId, cancellationToken, false); + cancellationToken.ThrowIfCancellationRequested(); + if (!gle || !tableComponent) throw new OperationCanceledException("The target table was destroyed while its PinMAME definition was loading."); + return Apply(gle, tableComponent, machineId, romId, loaded); + } + + private static async Task LoadDefinitionAsync(string machineId, string romId, + CancellationToken cancellationToken, bool cacheOnly) + { + var catalogResult = await PinMameGameDefinitionCatalogClient.GetCatalogAsync(false, cancellationToken, cacheOnly).ConfigureAwait(false); + var machine = catalogResult.Value.Machines.FirstOrDefault(entry => string.Equals(entry.Id, machineId, StringComparison.Ordinal)); + if (machine == null) throw new InvalidDataException($"Machine {machineId} is not present in the published catalog."); + if (!machine.IsSelectable) throw new InvalidDataException($"Machine {machineId} has no selectable physical definition."); + + var definitionResult = await PinMameGameDefinitionCatalogClient.GetDefinitionAsync(machine, false, cancellationToken, cacheOnly).ConfigureAwait(false); + var definition = PinMameGameDefinitionConverter.Convert(definitionResult.Value, romId); + if (!string.Equals(definition.MachineId, machineId, StringComparison.Ordinal)) { + throw new InvalidDataException($"The catalog entry {machineId} returned definition {definition.MachineId}."); + } + if (!string.IsNullOrWhiteSpace(romId) && !definition.Roms.Any(rom => string.Equals(rom.Id, romId, StringComparison.OrdinalIgnoreCase))) { + throw new InvalidDataException($"ROM {romId} is not assigned to machine {machineId}."); + } + var warning = string.Join("\n", new[] { catalogResult.Warning, definitionResult.Warning }.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct()); + return new LoadedDefinition(definition, warning); + } + + private static PinMameGameDefinition Apply(PinMameGamelogicEngine gle, TableComponent tableComponent, + string machineId, string romId, LoadedDefinition loaded) + { + var definition = loaded.Definition; + Undo.RecordObject(gle, "Populate PinMAME hardware"); + Undo.RecordObject(tableComponent, "Populate PinMAME hardware"); + gle.SetMachineSelection(machineId, !string.IsNullOrWhiteSpace(romId) ? romId : definition.DefaultRomId); + gle.SetAvailableHardware(definition.Switches, definition.Coils, definition.Lamps, definition.Wires); + tableComponent.RepopulateHardware(gle); + gle.MarkMappingsPopulated(); + EditorUtility.SetDirty(gle); + EditorUtility.SetDirty(tableComponent); + PrefabUtility.RecordPrefabInstancePropertyModifications(gle); + PrefabUtility.RecordPrefabInstancePropertyModifications(tableComponent); + + if (!string.IsNullOrWhiteSpace(loaded.Warning)) Debug.LogWarning($"[PinMAME] {loaded.Warning}", gle); + foreach (var diagnostic in definition.Diagnostics) Debug.LogWarning($"[PinMAME] {diagnostic}", gle); + return definition; + } + + private static void ValidateArguments(PinMameGamelogicEngine gle, TableComponent tableComponent, string machineId) + { + if (!gle) throw new ArgumentNullException(nameof(gle)); + if (!tableComponent) throw new ArgumentNullException(nameof(tableComponent)); + if (string.IsNullOrWhiteSpace(machineId)) throw new ArgumentException("A machine ID is required.", nameof(machineId)); + PinMameGameDefinitionCatalogClient.EnsureCacheRoot(); + } + + private readonly struct LoadedDefinition + { + internal PinMameGameDefinition Definition { get; } + internal string Warning { get; } + + internal LoadedDefinition(PinMameGameDefinition definition, string warning) + { + Definition = definition; + Warning = warning; + } + } + } +} diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionEditorService.cs.meta b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionEditorService.cs.meta new file mode 100644 index 0000000..c00183b --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGameDefinitionEditorService.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 59841383c19b4d708835f92348c7f3a6 +timeCreated: 1786626000 diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGamelogicEngineInspector.cs b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGamelogicEngineInspector.cs index 1ca2933..d993b79 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGamelogicEngineInspector.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameGamelogicEngineInspector.cs @@ -1,4 +1,4 @@ -// Visual Pinball Engine +// Visual Pinball Engine // Copyright (C) 2021 freezy and VPE Team // // This program is free software: you can redistribute it and/or modify @@ -18,11 +18,13 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Threading; using PinMame; using UnityEditor; using UnityEngine; -using VisualPinball.Engine.PinMAME.Games; +using VisualPinball.Engine.Game.Engines; using VisualPinball.Unity; using VisualPinball.Unity.Editor; @@ -31,168 +33,325 @@ namespace VisualPinball.Engine.PinMAME.Editor [CustomEditor(typeof(PinMameGamelogicEngine))] public class PinMameGamelogicEngineInspector : UnityEditor.Editor { - private bool IsGameSet => _gle.Game != null; - private PinMameGamelogicEngine _gle; - private PinMameGame[] _games; - private string[] _gameNames; - private string[] _romNames = Array.Empty(); - + private PinMameCatalogMachine[] _machines = Array.Empty(); + private PinMameCatalogMachine _selectedMachine; + // the machine whose definition was last loaded successfully, which the table's mappings + // belong to when the component doesn't record its machine (older tables) + private string _loadedMachineId; + private PinMameGameDefinition _definition; + private string _definitionJson; + private PinMameRomDefinition[] _roms = Array.Empty(); private int _selectedGameIndex; private int _selectedRomIndex; - private TableComponent _tableComponent; - - private PinMameRom Rom => _gle.Game.Roms[_selectedRomIndex]; - private bool _toggleStartup = true; - private SerializedProperty _disableMechsProperty; private SerializedProperty _solenoidDelayProperty; - private bool _toggleDebug = true; - - int _debugSwitchId; - + private int _debugSwitchId; private SerializedProperty _disableAudioProperty; - - + private CancellationTokenSource _loadCancellation; + private bool _isLoading; + private string _loadError; + private string _loadWarning; + private string _spatialAssignmentMessage; + private MessageType _spatialAssignmentMessageType = MessageType.Info; + private string _romStatusKey; + private bool _romZipFound; private void OnEnable() { _gle = (PinMameGamelogicEngine) target; - - _games = new PinMameGame[] { - new AttackFromMars(), - new Centaur(), - new CreatureFromTheBlackLagoon(), - new FlashGordon(), - new MedievalMadness(), - new Rock(), - new RockEncore(), - new StarTrekEnterprise(), - new Terminator2(), - new TheWalkingDead(), - new TRONLegacy(), - }; - - _gameNames = new[] {"-- none --"} - .Concat(_games.Select(g => g.Name)) - .ToArray(); - if (_gle != null) { _tableComponent = _gle.gameObject.GetComponentInParent(); } + _solenoidDelayProperty = serializedObject.FindProperty(nameof(_gle.SolenoidDelay)); + _disableMechsProperty = serializedObject.FindProperty(nameof(_gle.DisableMechs)); + _disableAudioProperty = serializedObject.FindProperty(nameof(_gle.DisableAudio)); + Undo.undoRedoPerformed += OnUndoRedo; + LoadCatalog(false); + } - if (IsGameSet) { - for (var i = 0; i < _games.Length; i++) { - if (_games[i].Id == _gle.Game.Id) { - _selectedGameIndex = i + 1; - break; - } + private void OnDisable() + { + Undo.undoRedoPerformed -= OnUndoRedo; + _loadCancellation?.Cancel(); + _loadCancellation?.Dispose(); + _loadCancellation = null; + _isLoading = false; + } + + // An undone or redone machine or ROM change only restores the component; reload what the + // inspector derived from it, so it never works with the other machine's definition. + private void OnUndoRedo() + { + if (!_gle) return; + // no candidate fill here, or undoing a fill would be refilled right away + LoadCatalog(false, false); + Repaint(); + } + + private async void LoadCatalog(bool forceRefresh, bool fillCandidates = true) + { + var load = ResetCancellation(); + var cancellationToken = load.Token; + _isLoading = true; + _loadError = null; + _loadWarning = null; + try { + var result = await PinMameGameDefinitionCatalogClient.GetCatalogAsync(forceRefresh, cancellationToken); + EnsureCurrent(load); + var machines = result.Value.Machines + .Where(machine => machine.IsSelectable) + .OrderBy(machine => machine.Name, StringComparer.OrdinalIgnoreCase) + .ThenByDescending(machine => machine.Year) + .ToArray(); + _machines = machines; + _loadWarning = result.Warning; + + var selectedIndex = FindSelectedMachineIndex(); + if (selectedIndex < 0 && !string.IsNullOrWhiteSpace(_gle.romId)) { + var resolvedMachineId = await PinMameGameDefinitionCatalogClient.ResolveMachineIdAsync(_gle.romId, cancellationToken); + EnsureCurrent(load); + selectedIndex = Array.FindIndex(_machines, machine => string.Equals(machine.Id, resolvedMachineId, StringComparison.Ordinal)); } - _romNames = _gle.Game.Roms.Select(g => g.ToString()).ToArray(); - if (!string.IsNullOrEmpty(_gle.romId)) { - for (var i = 0; i < _gle.Game.Roms.Length; i++) { - if (_gle.Game.Roms[i].Id == _gle.romId) { - _selectedRomIndex = i; - break; - } - } + _selectedGameIndex = selectedIndex + 1; + if (_selectedGameIndex > 0) { + await SelectMachine(_selectedGameIndex, _gle.romId, forceRefresh, false, load, fillCandidates); + } else { + // nothing selected (e.g. after undoing a selection): drop what was derived from before + _selectedMachine = null; + _loadedMachineId = null; + _definition = null; + _definitionJson = null; + _roms = Array.Empty(); + _selectedRomIndex = 0; + _spatialAssignmentMessage = null; } + } catch (OperationCanceledException) { + return; + } catch (Exception exception) { + if (ReferenceEquals(_loadCancellation, load)) _loadError = exception.Message; + } finally { + CompleteLoad(load); } + } - _solenoidDelayProperty = serializedObject.FindProperty(nameof(_gle.SolenoidDelay)); - _disableMechsProperty = serializedObject.FindProperty(nameof(_gle.DisableMechs)); - _disableAudioProperty = serializedObject.FindProperty(nameof(_gle.DisableAudio)); + private int FindSelectedMachineIndex() + { + if (!string.IsNullOrWhiteSpace(_gle.machineId)) { + var byId = Array.FindIndex(_machines, machine => string.Equals(machine.Id, _gle.machineId, StringComparison.Ordinal)); + if (byId >= 0) { + return byId; + } + } + if (string.IsNullOrWhiteSpace(_gle.romId)) { + return -1; + } + return Array.FindIndex(_machines, machine => MachineRoms(machine).Contains(_gle.romId, StringComparer.OrdinalIgnoreCase)); } - public override void OnInspectorGUI() + private async void ChangeMachine(int selectedGameIndex) { - // game dropdown - //_gle.romId = EditorGUILayout.TextField("ROM ID", _gle.romId); + var load = ResetCancellation(); + _isLoading = true; + _loadError = null; + _loadWarning = null; + try { + await SelectMachine(selectedGameIndex, null, false, true, load); + } catch (OperationCanceledException) { + return; + } catch (Exception exception) { + if (ReferenceEquals(_loadCancellation, load)) _loadError = exception.Message; + } finally { + CompleteLoad(load); + } + } - EditorGUI.BeginChangeCheck(); - _selectedGameIndex = EditorGUILayout.Popup("Game", _selectedGameIndex, _gameNames); - if (EditorGUI.EndChangeCheck()) { - _selectedRomIndex = 0; - if (_selectedGameIndex > 0) { - _gle.Game = _games[_selectedGameIndex - 1]; - _gle.romId = Rom.Id; - _romNames = _gle.Game.Roms.Select(g => g.ToString()).ToArray(); + private async System.Threading.Tasks.Task SelectMachine(int selectedGameIndex, string preferredRomId, bool forceRefresh, + bool applySelection, CancellationTokenSource load, bool fillCandidates = false) + { + EnsureCurrent(load); + var cancellationToken = load.Token; + if (selectedGameIndex < 0 || selectedGameIndex > _machines.Length) { + throw new ArgumentOutOfRangeException(nameof(selectedGameIndex)); + } - } else { - _gle.Game = null; - _gle.romId = string.Empty; - _romNames = Array.Empty(); + var previousMachineId = _loadedMachineId; + _selectedGameIndex = selectedGameIndex; + _selectedRomIndex = 0; + _definition = null; + _definitionJson = null; + _roms = Array.Empty(); + _spatialAssignmentMessage = null; + + if (selectedGameIndex <= 0) { + _selectedMachine = null; + if (applySelection) { + Undo.RecordObject(_gle, "Select PinMAME machine"); + _gle.SetMachineSelection(null, null, previousMachineId); + EditorUtility.SetDirty(_gle); + _loadedMachineId = null; } + return; } - //rom dropdown - EditorGUI.BeginDisabledGroup(_gle.Game == null); - EditorGUI.BeginChangeCheck(); - _selectedRomIndex = EditorGUILayout.Popup("ROM", _selectedRomIndex, _romNames); - if (EditorGUI.EndChangeCheck()) { - _gle.romId = Rom.Id; + var selectedMachine = _machines[selectedGameIndex - 1]; + _selectedMachine = selectedMachine; + + var result = await PinMameGameDefinitionCatalogClient.GetDefinitionAsync(selectedMachine, forceRefresh, cancellationToken); + EnsureCurrent(load); + _definitionJson = result.Value; + _loadWarning = CombineWarnings(_loadWarning, result.Warning); + var definition = PinMameGameDefinitionConverter.Convert(_definitionJson, preferredRomId); + if (!string.Equals(definition.MachineId, selectedMachine.Id, StringComparison.Ordinal)) { + throw new InvalidDataException($"The catalog entry {selectedMachine.Id} returned definition {definition.MachineId}."); + } + _definition = definition; + _loadedMachineId = definition.MachineId; + var selectedRomId = SetRoms(_definition.Roms, preferredRomId, _definition.DefaultRomId); + if (applySelection) { + Undo.RecordObject(_gle, "Select PinMAME machine"); + _gle.SetMachineSelection(selectedMachine.Id, selectedRomId, previousMachineId); + // the converted definition used the default ROM's driver; convert again if that wasn't it + if (!string.Equals(selectedRomId, _definition.DefaultRomId, StringComparison.OrdinalIgnoreCase) && selectedRomId != null) { + _definition = ConvertSelectedRom(selectedRomId); + } + // candidates for the manager dropdowns; the table's mappings stay until Populate Hardware + _gle.SetAvailableHardware(_definition.Switches, _definition.Coils, _definition.Lamps, _definition.Wires); + EditorUtility.SetDirty(_gle); + } else if (!string.IsNullOrWhiteSpace(preferredRomId) && selectedRomId == null) { + _loadWarning = CombineWarnings(_loadWarning, $"The saved ROM '{preferredRomId}' is not listed for {selectedMachine.Name}; the saved component value was left unchanged."); + } else if (fillCandidates && selectedRomId != null && (forceRefresh || !_gle.HasAvailableHardware)) { + // The component's own machine and ROM: give the managers' dropdowns this definition's + // hardware when the component has none yet (older or unpacked tables) or on Refresh. + // The selection and the table's mappings stay as they are. + if (forceRefresh) Undo.RecordObject(_gle, "Refresh PinMAME hardware candidates"); + _gle.SetAvailableHardware(_definition.Switches, _definition.Coils, _definition.Lamps, _definition.Wires); + EditorUtility.SetDirty(_gle); + } + } + + private static string CombineWarnings(string first, string second) + { + if (string.IsNullOrWhiteSpace(first)) return second; + if (string.IsNullOrWhiteSpace(second) || string.Equals(first, second, StringComparison.Ordinal)) return first; + return $"{first}\n{second}"; + } + + private PinMameGameDefinition ConvertSelectedRom(string selectedRomId) + { + if (string.IsNullOrWhiteSpace(_definitionJson)) { + return null; + } + return PinMameGameDefinitionConverter.Convert(_definitionJson, selectedRomId); + } + + private string SetRoms(PinMameRomDefinition[] roms, string preferredRomId, string defaultRomId) + { + _roms = roms ?? Array.Empty(); + _selectedRomIndex = Array.FindIndex(_roms, rom => string.Equals(rom.Id, preferredRomId, StringComparison.OrdinalIgnoreCase)); + if (_selectedRomIndex < 0 && string.IsNullOrWhiteSpace(preferredRomId) && _roms.Length > 0) { + _selectedRomIndex = System.Math.Max(0, Array.FindIndex(_roms, rom => string.Equals(rom.Id, defaultRomId, StringComparison.OrdinalIgnoreCase))); + } + return _selectedRomIndex >= 0 ? _roms[_selectedRomIndex].Id : null; + } + + private static string[] MachineRoms(PinMameCatalogMachine machine) => machine.Roms?.Length > 0 ? machine.Roms : machine.RootDrivers ?? Array.Empty(); + + private CancellationTokenSource ResetCancellation() + { + _loadCancellation?.Cancel(); + _loadCancellation?.Dispose(); + _loadCancellation = new CancellationTokenSource(); + return _loadCancellation; + } + + private void CompleteLoad(CancellationTokenSource load) + { + if (!ReferenceEquals(_loadCancellation, load)) return; + _isLoading = false; + if (this && target) Repaint(); + } + + private void EnsureCurrent(CancellationTokenSource load) + { + load.Token.ThrowIfCancellationRequested(); + if (!ReferenceEquals(_loadCancellation, load)) throw new OperationCanceledException(load.Token); + } + + public override void OnInspectorGUI() + { + serializedObject.Update(); + if (_isLoading) { + EditorGUILayout.HelpBox("Loading PinMAME machine definitions…", MessageType.Info); + } + if (!string.IsNullOrWhiteSpace(_loadWarning)) { + EditorGUILayout.HelpBox(_loadWarning, MessageType.Warning); + } + if (!string.IsNullOrWhiteSpace(_loadError)) { + EditorGUILayout.HelpBox(_loadError, MessageType.Error); } - // info label - EditorGUILayout.LabelField("ROM ID", _gle.romId); + EditorGUI.BeginDisabledGroup(_machines.Length == 0 || Application.isPlaying || _isLoading); + DrawMachinePicker(); + EditorGUI.EndDisabledGroup(); + EditorGUI.BeginDisabledGroup(_roms.Length == 0 || Application.isPlaying || _isLoading); + DrawRomPicker(); EditorGUI.EndDisabledGroup(); + if (_selectedMachine != null || !string.IsNullOrWhiteSpace(_gle.machineId) || !string.IsNullOrWhiteSpace(_gle.romId)) { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + EditorGUILayout.LabelField(SelectedMachineSummary(), EditorStyles.miniLabel); + EditorGUILayout.LabelField(SelectedDefinitionIds(), EditorStyles.miniLabel); + EditorGUILayout.EndVertical(); + } + if (_definition != null) { + foreach (var diagnostic in _definition.Diagnostics) { + EditorGUILayout.HelpBox(diagnostic, MessageType.Warning); + } + } + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField(PinMameGameDefinitionCatalogClient.BaseUrl, EditorStyles.miniLabel); + if (GUILayout.Button("Refresh", EditorStyles.miniButton, GUILayout.Width(64f))) LoadCatalog(true); + EditorGUILayout.EndHorizontal(); + + DrawRomFolder(); + GUILayout.BeginVertical(); - if (_toggleStartup = EditorGUILayout.BeginFoldoutHeaderGroup(_toggleStartup, "Startup")) - { + if (_toggleStartup = EditorGUILayout.BeginFoldoutHeaderGroup(_toggleStartup, "Startup")) { EditorGUI.indentLevel++; - EditorGUI.BeginChangeCheck(); - EditorGUILayout.PropertyField(_disableMechsProperty, new GUIContent("Disable Mechs")); - - if (EditorGUI.EndChangeCheck()) - { + if (EditorGUI.EndChangeCheck()) { serializedObject.ApplyModifiedProperties(); } - EditorGUI.BeginChangeCheck(); - var cellRect = EditorGUILayout.GetControlRect(); - var labelRect = cellRect; labelRect.x += labelRect.width - 20; labelRect.width = 20; - var fieldRect = cellRect; fieldRect.width -= 25; - EditorGUI.PropertyField(fieldRect, _solenoidDelayProperty, new GUIContent("Solenoid Delay")); GUI.Label(labelRect, "ms"); - - if (EditorGUI.EndChangeCheck()) - { + if (EditorGUI.EndChangeCheck()) { serializedObject.ApplyModifiedProperties(); } - EditorGUI.indentLevel--; } - EditorGUILayout.EndFoldoutHeaderGroup(); - if (_toggleDebug = EditorGUILayout.BeginFoldoutHeaderGroup(_toggleDebug, "Debug")) - { + if (_toggleDebug = EditorGUILayout.BeginFoldoutHeaderGroup(_toggleDebug, "Debug")) { EditorGUI.indentLevel++; - EditorGUI.BeginChangeCheck(); - EditorGUILayout.PropertyField(_disableAudioProperty, new GUIContent("Disable Audio")); - - if (EditorGUI.EndChangeCheck()) - { + if (EditorGUI.EndChangeCheck()) { serializedObject.ApplyModifiedProperties(); } - EditorGUILayout.BeginHorizontal(); _debugSwitchId = EditorGUILayout.IntField("Switch", _debugSwitchId); if (GUILayout.Button("ON")) { @@ -201,19 +360,13 @@ public override void OnInspectorGUI() if (GUILayout.Button("OFF")) { _gle.Switch(_debugSwitchId.ToString(), false); } - EditorGUILayout.EndHorizontal(); - EditorGUI.indentLevel--; } - EditorGUILayout.EndFoldoutHeaderGroup(); - GUILayout.EndVertical(); - EditorGUILayout.Space(); EditorGUILayout.Separator(); - if (Application.isPlaying) { if (GUILayout.Button("Toggle Speed")) { _gle.ToggleSpeed(); @@ -222,15 +375,44 @@ public override void OnInspectorGUI() EditorGUILayout.Space(); EditorGUILayout.Separator(); } - - //EditorGUI.BeginDisabledGroup(!IsGameSet || Application.isPlaying); + + EditorGUI.BeginDisabledGroup(_definition == null || _selectedRomIndex < 0 || _tableComponent == null || Application.isPlaying || _isLoading); if (GUILayout.Button("Populate Hardware")) { - if (EditorUtility.DisplayDialog("PinMAME", "This will clear all linked switches, coils and lamps and re-populate them. You sure you want to do that?", "Yes", "No")) { + if (EditorUtility.DisplayDialog("PinMAME", "This will clear all linked switches, coils and lamps and re-populate them from the selected machine definition. Continue?", "Yes", "No")) { + Undo.RecordObject(_gle, "Populate PinMAME hardware"); + Undo.RecordObject(_tableComponent, "Populate PinMAME hardware"); + _gle.SetMachineSelection(_definition.MachineId, _roms[_selectedRomIndex].Id); + _gle.SetAvailableHardware(_definition.Switches, _definition.Coils, _definition.Lamps, _definition.Wires); _tableComponent.RepopulateHardware(_gle); + _gle.MarkMappingsPopulated(); + EditorUtility.SetDirty(_gle); + EditorUtility.SetDirty(_tableComponent); TableSelector.Instance.TableUpdated(); SceneView.RepaintAll(); } } + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + EditorGUILayout.LabelField("Spatial Assignment", EditorStyles.boldLabel); + EditorGUILayout.LabelField("Fills empty playfield mappings. Existing links are preserved as layout anchors; uncertain matches stay empty.", EditorStyles.wordWrappedMiniLabel); + var mappingsMatchMachine = _gle.MappingsMatchMachine && _definition != null + && (string.IsNullOrEmpty(_gle.machineId) || string.Equals(_definition.MachineId, _gle.machineId, StringComparison.Ordinal)); + if (!mappingsMatchMachine) { + EditorGUILayout.HelpBox("The table's mappings were made for another machine. Populate the hardware of this machine first.", MessageType.Info); + } + EditorGUI.BeginDisabledGroup(!mappingsMatchMachine); + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("Switches")) ApplySpatialAssignments("switch", PinMameSpatialAssignmentService.AssignSwitches); + if (GUILayout.Button("Coils")) ApplySpatialAssignments("coil", PinMameSpatialAssignmentService.AssignCoils); + if (GUILayout.Button("Lamps")) ApplySpatialAssignments("lamp", PinMameSpatialAssignmentService.AssignLamps); + EditorGUILayout.EndHorizontal(); + EditorGUI.EndDisabledGroup(); + if (!string.IsNullOrWhiteSpace(_spatialAssignmentMessage)) { + EditorGUILayout.HelpBox(_spatialAssignmentMessage, _spatialAssignmentMessageType); + } + EditorGUILayout.EndVertical(); + EditorGUI.EndDisabledGroup(); + + EditorGUI.BeginDisabledGroup(string.IsNullOrWhiteSpace(_gle.romId)); if (GUILayout.Button("Create Displays")) { var sceneDisplays = FindObjectsOfType(); if (sceneDisplays.Length > 0) { @@ -243,13 +425,202 @@ public override void OnInspectorGUI() SceneView.RepaintAll(); } } - //EditorGUI.EndDisabledGroup(); + EditorGUI.EndDisabledGroup(); + } + + private void ApplySpatialAssignments(string hardwareType, + Func assign) + { + var result = assign(_tableComponent, _definition, + () => Undo.RecordObject(_tableComponent, $"Assign PinMAME {hardwareType} mappings by position")); + _spatialAssignmentMessage = result.ToString(); + _spatialAssignmentMessageType = result.Changed ? MessageType.Info : MessageType.Warning; + if (!result.Changed) return; + EditorUtility.SetDirty(_tableComponent); + TableSelector.Instance.TableUpdated(); + SceneView.RepaintAll(); + } + + private void DrawMachinePicker() + { + EditorGUILayout.LabelField("Game", EditorStyles.miniLabel); + var fieldRect = EditorGUILayout.GetControlRect(); + var displayName = _selectedMachine?.Name + ?? (!string.IsNullOrWhiteSpace(_gle.machineId) ? _gle.machineId : _isLoading ? "Loading definitions…" : "Search games…"); + var content = new GUIContent(displayName, EditorGUIUtility.IconContent("Search Icon").image, "Search the published physical-machine catalog."); + if (!EditorGUI.DropdownButton(fieldRect, content, FocusType.Keyboard, EditorStyles.popup)) return; + + var picker = new PinMameSearchablePicker( + "Select PinMAME game", + "game", + _machines, + _selectedGameIndex - 1, + machine => machine.Name, + MachineMetadata, + MachineCoverage, + machine => $"{machine.Name} {machine.Manufacturer} {machine.Year} {machine.Id} {machine.Status} {machine.CompletionScore}", + index => { + _selectedGameIndex = index + 1; + ChangeMachine(_selectedGameIndex); + }, + true); + PopupWindow.Show(fieldRect, picker); + } + + private void DrawRomPicker() + { + EditorGUILayout.LabelField("ROM", EditorStyles.miniLabel); + var fieldRect = EditorGUILayout.GetControlRect(); + var displayName = _selectedRomIndex >= 0 && _selectedRomIndex < _roms.Length + ? CompactRomLabel(_roms[_selectedRomIndex]) + : !string.IsNullOrWhiteSpace(_gle.romId) ? _gle.romId : "Search ROMs…"; + var content = new GUIContent(displayName, EditorGUIUtility.IconContent("Search Icon").image, "Search ROM revisions for the selected game."); + if (!EditorGUI.DropdownButton(fieldRect, content, FocusType.Keyboard, EditorStyles.popup)) return; + + var picker = new PinMameSearchablePicker( + $"Select ROM for {_selectedMachine?.Name}", + "ROM", + _roms, + _selectedRomIndex, + rom => rom.Id, + CompactRomDescription, + null, + rom => $"{rom.Id} {rom.Description} {CompactRomDescription(rom)}", + ChangeRom); + PopupWindow.Show(fieldRect, picker); + } + + private void ChangeRom(int selectedRomIndex) + { + if (selectedRomIndex < 0 || selectedRomIndex >= _roms.Length) return; + var selectedRomId = _roms[selectedRomIndex].Id; + try { + var definition = ConvertSelectedRom(selectedRomId); + Undo.RecordObject(_gle, "Select PinMAME ROM"); + _definition = definition; + SetRoms(definition.Roms, selectedRomId, definition.DefaultRomId); + _gle.SetMachineSelection(definition.MachineId, selectedRomId); + // a ROM of the same machine: refresh the dropdown candidates, keep the mappings + _gle.SetAvailableHardware(definition.Switches, definition.Coils, definition.Lamps, definition.Wires); + _loadError = null; + _spatialAssignmentMessage = null; + EditorUtility.SetDirty(_gle); + } catch (Exception exception) { + _loadError = exception.Message; + SetRoms(_definition?.Roms, _gle.romId, _definition?.DefaultRomId); + } + Repaint(); + } + + private static string MachineMetadata(PinMameCatalogMachine machine) + { + return string.Join(" · ", new[] { + machine.Manufacturer, + machine.Year?.ToString(), + }.Where(value => !string.IsNullOrWhiteSpace(value))); + } + + private static string MachineCoverage(PinMameCatalogMachine machine) + { + return string.Equals(machine.Status, "author_ready", StringComparison.Ordinal) + ? "Author-ready" + : $"{machine.CompletionScore}% complete"; + } + + private string SelectedMachineSummary() + { + if (_selectedMachine == null) { + return _isLoading ? "Resolving saved selection…" : "Saved selection is not available in the current catalog"; + } + var coverage = _definition == null + ? MachineCoverage(_selectedMachine) + : string.Equals(_definition.CoverageStatus, "author_ready", StringComparison.Ordinal) + ? "Author-ready" + : $"{_definition.CompletionScore}% complete"; + return string.Join(" · ", new[] { + MachineMetadata(_selectedMachine), + coverage, + }.Where(value => !string.IsNullOrWhiteSpace(value))); + } + + private string SelectedDefinitionIds() + { + var machineId = string.IsNullOrWhiteSpace(_gle.machineId) ? _selectedMachine?.Id : _gle.machineId; + return string.Join(" · ", new[] { + string.IsNullOrWhiteSpace(machineId) ? null : $"Machine: {machineId}", + string.IsNullOrWhiteSpace(_gle.romId) ? null : $"ROM: {_gle.romId}", + }.Where(value => !string.IsNullOrWhiteSpace(value))); + } + + private string CompactRomLabel(PinMameRomDefinition rom) + { + var description = CompactRomDescription(rom); + return string.IsNullOrWhiteSpace(description) ? rom.Id : $"{rom.Id} · {description}"; + } + + private string CompactRomDescription(PinMameRomDefinition rom) + { + var description = rom?.Description?.Trim() ?? string.Empty; + foreach (var title in new[] { _selectedMachine?.Name, _definition?.Name }.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase)) { + if (!description.StartsWith(title, StringComparison.OrdinalIgnoreCase)) continue; + description = description.Substring(title.Length).TrimStart(' ', ':', '-', '—', '·'); + break; + } + if (description.Length >= 2 && description[0] == '(' && description.IndexOf(')') == description.Length - 1) { + description = description.Substring(1, description.Length - 2).Trim(); + } + return description; + } + + private void DrawRomFolder() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + EditorGUILayout.BeginHorizontal(); + EditorGUI.BeginChangeCheck(); + var custom = EditorGUILayout.DelayedTextField(new GUIContent("ROM Folder", + "Where PinMAME looks for ROMs when playing in the editor. Separate several folders with ';'. " + + "Leave empty to use Visual PinMAME's ROM folder. Saved on this machine, not in the project."), PinMameEditorRomFolder.Custom); + if (EditorGUI.EndChangeCheck()) { + PinMameEditorRomFolder.Custom = custom; + } + if (GUILayout.Button("...", EditorStyles.miniButton, GUILayout.Width(24f))) { + var current = PinMameEditorRomFolder.Effective?.Split(';')[0] ?? string.Empty; + var folder = EditorUtility.OpenFolderPanel("PinMAME ROM Folder", current, string.Empty); + if (!string.IsNullOrEmpty(folder)) { + PinMameEditorRomFolder.Custom = folder.Replace('/', Path.DirectorySeparatorChar); + } + GUIUtility.ExitGUI(); + } + EditorGUILayout.EndHorizontal(); + + var effective = PinMameEditorRomFolder.Effective; + if (string.IsNullOrWhiteSpace(PinMameEditorRomFolder.Custom)) { + EditorGUILayout.LabelField(effective != null + ? $"Using Visual PinMAME's ROM folder: {effective}" + : "Set the folder with your ROMs. Visual PinMAME's isn't available to fall back to.", EditorStyles.wordWrappedMiniLabel); + } + if (effective != null && !string.IsNullOrWhiteSpace(_gle.romId)) { + var key = effective + "|" + _gle.romId; + if (key != _romStatusKey) { + _romStatusKey = key; + _romZipFound = PinMameFolders.HasRomZip(effective, _gle.romId); + } + if (!_romZipFound) { + EditorGUILayout.HelpBox($"{_gle.romId}.zip isn't in the ROM folder.", MessageType.Warning); + } + } + EditorGUILayout.EndVertical(); } private void CreateDisplays(IEnumerable sceneDisplays) { // retrieve layouts from pinmame - var pinMame = PinMame.PinMame.Instance(PinMameAudioFormat.AudioFormatFloat, AudioSettings.outputSampleRate); + var pinMame = PinMame.PinMame.Instance(PinMameAudioFormat.AudioFormatFloat, AudioSettings.outputSampleRate, PinMameFolders.DataFolder); + if (!PinMame.PinMame.IsRunning) { + PinMameFolders.ApplyRomFolder(pinMame); + } + // probing runs the ROM, which saves NVRAM and config on exit, so bring over VPM's first + PinMameFolders.ImportFromVpm(_gle.romId); var displayLayouts = pinMame.GetAvailableDisplays(_gle.romId); // retrieve already existing displays from scene diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSearchablePicker.cs b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSearchablePicker.cs new file mode 100644 index 0000000..f1c8efb --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSearchablePicker.cs @@ -0,0 +1,226 @@ +// 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. + +// ReSharper disable CheckNamespace + +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEditor.IMGUI.Controls; +using UnityEngine; + +namespace VisualPinball.Engine.PinMAME.Editor +{ + internal sealed class PinMameSearchablePicker : PopupWindowContent + { + private const float RowHeight = 40f; + private const float HorizontalPadding = 8f; + private const string NoneSearchText = "None clear selection"; + private readonly string _title; + private readonly string _itemName; + private readonly IReadOnlyList _items; + private readonly int _selectedIndex; + private readonly Func _primaryText; + private readonly Func _secondaryText; + private readonly Func _statusText; + private readonly Func _searchText; + private readonly Action _onSelected; + private readonly bool _allowNone; + private readonly SearchField _searchField = new(); + private readonly GUIStyle _statusStyle; + private readonly GUIStyle _primaryStyle; + private readonly GUIStyle _secondaryStyle; + private List _filteredIndices; + private Vector2 _scrollPosition; + private string _query = string.Empty; + private int _highlightedRow; + + internal PinMameSearchablePicker(string title, string itemName, IReadOnlyList items, int selectedIndex, + Func primaryText, Func secondaryText, Func statusText, + Func searchText, Action onSelected, bool allowNone = false) + { + _title = title; + _itemName = itemName; + _items = items ?? Array.Empty(); + _selectedIndex = selectedIndex; + _primaryText = primaryText ?? throw new ArgumentNullException(nameof(primaryText)); + _secondaryText = secondaryText ?? (_ => string.Empty); + _statusText = statusText ?? (_ => string.Empty); + _searchText = searchText ?? primaryText; + _onSelected = onSelected ?? throw new ArgumentNullException(nameof(onSelected)); + _allowNone = allowNone; + _filteredIndices = Enumerable.Range(0, _items.Count).ToList(); + _statusStyle = new GUIStyle(EditorStyles.miniLabel) { alignment = TextAnchor.UpperRight }; + _primaryStyle = new GUIStyle(EditorStyles.label) { clipping = TextClipping.Clip, fontStyle = FontStyle.Bold }; + _secondaryStyle = new GUIStyle(EditorStyles.miniLabel) { clipping = TextClipping.Clip }; + } + + public override Vector2 GetWindowSize() => new(460f, 440f); + + public override void OnOpen() + { + editorWindow.wantsMouseMove = true; + _searchField.SetFocus(); + if (_selectedIndex >= 0) { + _highlightedRow = _filteredIndices.IndexOf(_selectedIndex) + (_allowNone ? 1 : 0); + _scrollPosition.y = System.Math.Max(0f, _highlightedRow * RowHeight - 120f); + } + } + + public override void OnGUI(Rect rect) + { + var noneMatches = NoneMatches(); + HandleKeyboard(noneMatches); + + GUILayout.Space(6f); + EditorGUILayout.LabelField(_title, EditorStyles.boldLabel); + var nextQuery = _searchField.OnGUI(_query); + if (!string.Equals(nextQuery, _query, StringComparison.Ordinal)) { + _query = nextQuery; + FilterItems(); + _scrollPosition = Vector2.zero; + noneMatches = NoneMatches(); + // a typed query highlights the first match, never "None" + _highlightedRow = noneMatches && _query.Trim().Length > 0 && _filteredIndices.Count > 0 ? 1 : 0; + } + + var resultCount = _filteredIndices.Count + (noneMatches ? 1 : 0); + EditorGUILayout.LabelField($"{resultCount} {(resultCount == 1 ? _itemName : _itemName + "s")}", EditorStyles.miniLabel); + + _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); + if (resultCount > 0) { + var listRect = GUILayoutUtility.GetRect(0f, resultCount * RowHeight, GUILayout.ExpandWidth(true)); + var firstVisibleRow = System.Math.Max(0, Mathf.FloorToInt(_scrollPosition.y / RowHeight)); + var lastVisibleRow = System.Math.Min(resultCount - 1, + firstVisibleRow + Mathf.CeilToInt(GetWindowSize().y / RowHeight) + 1); + for (var row = firstVisibleRow; row <= lastVisibleRow; row++) { + var rowRect = new Rect(listRect.x, listRect.y + row * RowHeight, listRect.width, RowHeight); + if (noneMatches && row == 0) { + DrawRow(rowRect, -1, "None", "Clear the selected machine and ROM", string.Empty, + _selectedIndex < 0, row == _highlightedRow); + continue; + } + var index = _filteredIndices[row - (noneMatches ? 1 : 0)]; + var item = _items[index]; + DrawRow(rowRect, index, _primaryText(item), _secondaryText(item), _statusText(item), + index == _selectedIndex, row == _highlightedRow); + } + } else { + GUILayout.Space(12f); + EditorGUILayout.LabelField("No matches", EditorStyles.centeredGreyMiniLabel); + } + EditorGUILayout.EndScrollView(); + + if (Event.current.type == EventType.MouseMove) editorWindow.Repaint(); + } + + private void DrawRow(Rect rowRect, int index, string primary, string secondary, string status, bool selected, + bool highlighted) + { + var hovered = rowRect.Contains(Event.current.mousePosition); + if (Event.current.type == EventType.Repaint) { + if (selected) { + EditorGUI.DrawRect(rowRect, EditorGUIUtility.isProSkin + ? new Color(0.18f, 0.36f, 0.58f, 0.75f) + : new Color(0.24f, 0.48f, 0.75f, 0.4f)); + } else if (hovered || highlighted) { + EditorGUI.DrawRect(rowRect, EditorGUIUtility.isProSkin + ? new Color(1f, 1f, 1f, 0.08f) + : new Color(0f, 0f, 0f, 0.06f)); + } + } + + var contentRect = new Rect(rowRect.x + HorizontalPadding, rowRect.y + 3f, + rowRect.width - HorizontalPadding * 2f, rowRect.height - 6f); + var statusWidth = string.IsNullOrWhiteSpace(status) ? 0f : System.Math.Min(105f, _statusStyle.CalcSize(new GUIContent(status)).x + 8f); + var primaryRect = new Rect(contentRect.x, contentRect.y, contentRect.width - statusWidth, 18f); + var statusRect = new Rect(contentRect.xMax - statusWidth, contentRect.y, statusWidth, 18f); + var secondaryRect = new Rect(contentRect.x, contentRect.y + 18f, contentRect.width, 16f); + GUI.Label(primaryRect, primary ?? string.Empty, _primaryStyle); + if (statusWidth > 0f) GUI.Label(statusRect, status, _statusStyle); + GUI.Label(secondaryRect, secondary ?? string.Empty, _secondaryStyle); + + if (Event.current.type == EventType.MouseDown && Event.current.button == 0 && hovered) { + Event.current.Use(); + Select(index); + } + } + + private void FilterItems() + { + _filteredIndices = Enumerable.Range(0, _items.Count) + .Where(index => MatchesQuery(_searchText(_items[index]))) + .ToList(); + } + + // Only offer "None" for an empty query or one that starts spelling it out, so a search such as + // "elect" (Electra) never puts it on top, where Enter would clear the selection. + private bool NoneMatches() + { + var query = _query.Trim(); + return _allowNone && (query.Length == 0 || NoneSearchText.Split(' ').Any(word => word.StartsWith(query, StringComparison.OrdinalIgnoreCase))); + } + + private bool MatchesQuery(string value) + { + var terms = _query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + return terms.All(term => (value ?? string.Empty).IndexOf(term, StringComparison.OrdinalIgnoreCase) >= 0); + } + + private void HandleKeyboard(bool noneMatches) + { + if (Event.current.type != EventType.KeyDown) return; + if (Event.current.keyCode == KeyCode.Escape) { + Event.current.Use(); + editorWindow.Close(); + GUIUtility.ExitGUI(); + } + var resultCount = _filteredIndices.Count + (noneMatches ? 1 : 0); + if (Event.current.keyCode == KeyCode.DownArrow && resultCount > 0) { + _highlightedRow = System.Math.Min(resultCount - 1, _highlightedRow + 1); + ScrollToHighlightedRow(); + editorWindow.Repaint(); + Event.current.Use(); + return; + } + if (Event.current.keyCode == KeyCode.UpArrow && resultCount > 0) { + _highlightedRow = System.Math.Max(0, _highlightedRow - 1); + ScrollToHighlightedRow(); + editorWindow.Repaint(); + Event.current.Use(); + return; + } + if (Event.current.keyCode != KeyCode.Return && Event.current.keyCode != KeyCode.KeypadEnter) return; + if (resultCount == 0) return; + Event.current.Use(); + if (noneMatches && _highlightedRow == 0) { + Select(-1); + } else { + Select(_filteredIndices[_highlightedRow - (noneMatches ? 1 : 0)]); + } + } + + private void ScrollToHighlightedRow() + { + var rowTop = _highlightedRow * RowHeight; + var viewportHeight = GetWindowSize().y - 80f; + if (rowTop < _scrollPosition.y) _scrollPosition.y = rowTop; + if (rowTop + RowHeight > _scrollPosition.y + viewportHeight) { + _scrollPosition.y = rowTop + RowHeight - viewportHeight; + } + } + + private void Select(int index) + { + _onSelected(index); + editorWindow.Close(); + GUIUtility.ExitGUI(); + } + } +} diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSearchablePicker.cs.meta b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSearchablePicker.cs.meta new file mode 100644 index 0000000..be6bb5d --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSearchablePicker.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dad3257b0d2c48a68d33a68e30ac988d diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSpatialAssignmentService.cs b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSpatialAssignmentService.cs new file mode 100644 index 0000000..efadef9 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSpatialAssignmentService.cs @@ -0,0 +1,465 @@ +// 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. + +// ReSharper disable CheckNamespace + +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; +using VisualPinball.Engine.Game.Engines; +using VisualPinball.Unity; + +namespace VisualPinball.Engine.PinMAME.Editor +{ + internal static class PinMameSpatialAssignmentService + { + private const double AnchorMaximumDistance = 0.15; + + internal static PinMameSpatialAssignmentResult AssignSwitches(TableComponent table, PinMameGameDefinition definition, + Action onBeforeMutate = null) + { + return Assign( + table, + definition.SpatialSwitches, + table.MappingConfig.Switches, + table.GetComponentsInChildren(true), + (mapping, placement) => mapping.Source == SwitchSource.Playfield && mapping.Id == placement.HardwareId, + mapping => mapping.Source == SwitchSource.Playfield, + mapping => mapping.Device, + mapping => mapping.DeviceItem, + (mapping, device, item) => { + mapping.Device = device; + mapping.DeviceItem = item; + }, + device => device.AvailableSwitches, + mapping => new SwitchMapping { + Id = mapping.Id, + IsNormallyClosed = mapping.IsNormallyClosed, + Description = mapping.Description, + Source = mapping.Source, + InputActionMap = mapping.InputActionMap, + InputAction = mapping.InputAction, + Constant = mapping.Constant, + PulseDelay = mapping.PulseDelay, + }, + _ => true, + _ => string.Empty, + _ => string.Empty, + onBeforeMutate); + } + + internal static PinMameSpatialAssignmentResult AssignCoils(TableComponent table, PinMameGameDefinition definition, + Action onBeforeMutate = null) + { + return Assign( + table, + definition.SpatialCoils, + table.MappingConfig.Coils, + table.GetComponentsInChildren(true), + (mapping, placement) => mapping.Destination == CoilDestination.Playfield && mapping.Id == placement.HardwareId, + mapping => mapping.Destination == CoilDestination.Playfield, + mapping => mapping.Device, + mapping => mapping.DeviceItem, + (mapping, device, item) => { + mapping.Device = device; + mapping.DeviceItem = item; + }, + device => device.AvailableCoils, + mapping => new CoilMapping { + Id = mapping.Id, + Description = mapping.Description, + Destination = mapping.Destination, + }, + _ => true, + _ => string.Empty, + _ => string.Empty, + onBeforeMutate); + } + + internal static PinMameSpatialAssignmentResult AssignLamps(TableComponent table, PinMameGameDefinition definition, + Action onBeforeMutate = null) + { + return Assign( + table, + definition.SpatialLamps, + table.MappingConfig.Lamps, + table.GetComponentsInChildren(true), + (mapping, placement) => mapping.Id == placement.HardwareId && mapping.Source == placement.Source + && mapping.Channel == placement.Channel && mapping.IsCoil == placement.IsCoil, + _ => true, + mapping => mapping.Device, + mapping => mapping.DeviceItem, + (mapping, device, item) => { + mapping.Device = device; + mapping.DeviceItem = item; + }, + device => device.AvailableLamps, + mapping => new LampMapping { + Id = mapping.Id, + Source = mapping.Source, + FadingSteps = mapping.FadingSteps, + IsCoil = mapping.IsCoil, + Description = mapping.Description, + Type = mapping.Type, + Channel = mapping.Channel, + }, + device => device is not LightGroupComponent, + placement => placement.Channel.ToString(), + mapping => mapping.Channel.ToString(), + onBeforeMutate); + } + + private static PinMameSpatialAssignmentResult Assign(TableComponent table, + IReadOnlyList placements, List mappings, IEnumerable devices, + Func mappingMatchesPlacement, Func isPlayfieldMapping, + Func getDevice, Func getDeviceItem, + Action setDevice, Func> getItems, + Func cloneMapping, Func mayAssign, + Func placementPartition, Func mappingPartition, + Action onBeforeMutate) + where TDevice : class, IMainComponent + where TItem : IGamelogicEngineDeviceItem + { + if (table == null) throw new ArgumentNullException(nameof(table)); + if (placements == null || placements.Count == 0) { + return PinMameSpatialAssignmentResult.NoSpatialData(); + } + if (mappings == null || mappings.Count == 0) { + return PinMameSpatialAssignmentResult.NoMappings(); + } + + var playfield = table.GetComponentInChildren(true); + if (playfield == null || playfield.Right <= playfield.Left || playfield.Bottom <= playfield.Top) { + return PinMameSpatialAssignmentResult.InvalidPlayfield(); + } + var deviceArray = devices.ToArray(); + var assigned = 0; + var anchored = 0; + var rejected = 0; + var unrepresented = 0; + var details = new List(); + var mutationStarted = false; + void BeginMutation() + { + if (mutationStarted) return; + mutationStarted = true; + onBeforeMutate?.Invoke(); + } + foreach (var partition in placements.GroupBy(placementPartition, StringComparer.Ordinal)) { + var partitionResult = AssignPartition(partition.ToArray(), mappings, deviceArray, + mappingMatchesPlacement, isPlayfieldMapping, getDevice, getDeviceItem, setDevice, getItems, + cloneMapping, mayAssign, playfield, + mapping => string.Equals(mappingPartition(mapping), partition.Key, StringComparison.Ordinal), BeginMutation); + assigned += partitionResult.AssignedCount; + anchored += partitionResult.AnchorCount; + rejected += partitionResult.RejectedCount; + unrepresented += partitionResult.UnrepresentedCount; + details.AddRange(partitionResult.Details); + } + return new PinMameSpatialAssignmentResult(placements.Count, assigned, anchored, rejected, unrepresented, + details.Take(3).ToArray(), null); + } + + private static PinMameSpatialAssignmentResult AssignPartition( + IReadOnlyList placements, List mappings, IEnumerable devices, + Func mappingMatchesPlacement, Func isPlayfieldMapping, + Func getDevice, Func getDeviceItem, + Action setDevice, Func> getItems, + Func cloneMapping, Func mayAssign, PlayfieldComponent playfield, + Func isInPartition, Action beginMutation) + where TDevice : class, IMainComponent + where TItem : IGamelogicEngineDeviceItem + { + var indexedPlacements = placements.Select((placement, index) => new IndexedPlacement(placement, + $"{placement.HardwareId}:{placement.PlacementId}:{index}")).ToArray(); + var allSlots = devices.SelectMany(device => getItems(device).Select(item => CreateSlot(device, item.Id, item.Description, playfield))) + .Where(slot => slot != null).ToArray(); + var usedSlotKeys = new HashSet(StringComparer.Ordinal); + var anchoredTargetKeys = new HashSet(StringComparer.Ordinal); + var anchors = new List(); + + foreach (var mapping in mappings.Where(mapping => isPlayfieldMapping(mapping) && isInPartition(mapping))) { + var device = getDevice(mapping); + if (!IsAlive(device)) continue; + var deviceItem = ResolveDeviceItem(getDeviceItem(mapping), getItems(device)); + var coveredDevices = CoveredDevices(device).ToArray(); + var deviceSlots = allSlots.Where(slot => coveredDevices.Any(covered => ReferenceEquals(slot.Device, covered))).ToArray(); + if (device is LightGroupComponent || string.IsNullOrEmpty(deviceItem)) { + foreach (var slot in deviceSlots) usedSlotKeys.Add(slot.Match.Key); + } else { + foreach (var slot in deviceSlots.Where(slot => slot.DeviceItem == deviceItem)) usedSlotKeys.Add(slot.Match.Key); + } + + var matchingTargets = indexedPlacements + .Where(item => !anchoredTargetKeys.Contains(item.Key) && mappingMatchesPlacement(mapping, item.Placement)) + .ToArray(); + var scenePoints = AnchorPositions(device, playfield).ToArray(); + if (device is LightGroupComponent && matchingTargets.Length == 1 && scenePoints.Length > 1) { + var groupPosition = DevicePosition(device, playfield); + if (groupPosition.HasValue) scenePoints = new[] { groupPosition.Value }; + } + var anchorPairs = matchingTargets + .SelectMany(target => scenePoints.Select((point, pointIndex) => new { + Target = target, + Point = point, + PointIndex = pointIndex, + Distance = Distance(target.Placement.X, target.Placement.Y, point.x, point.y), + })) + .OrderBy(pair => pair.Distance) + .ToArray(); + var usedPoints = new HashSet(); + foreach (var pair in anchorPairs) { + if (pair.Distance > AnchorMaximumDistance) break; + if (anchoredTargetKeys.Contains(pair.Target.Key) || !usedPoints.Add(pair.PointIndex)) continue; + anchoredTargetKeys.Add(pair.Target.Key); + anchors.Add(new PinMameSpatialMatchAnchor(pair.Target.Placement.X, pair.Target.Placement.Y, + pair.Point.x, pair.Point.y, pair.Target.Key)); + } + } + + var representedItems = indexedPlacements + .Where(item => !anchoredTargetKeys.Contains(item.Key)) + .Where(item => mappings.Any(mapping => isInPartition(mapping) && mappingMatchesPlacement(mapping, item.Placement))) + .ToArray(); + var unrepresented = indexedPlacements.Length - anchoredTargetKeys.Count - representedItems.Length; + + // Hardware that already has a device for each of its placements keeps its links, even when + // they couldn't serve as anchors (off the playfield, or far from the curated position). + // Otherwise a second device would be linked to it. + // one hardware number can be several outputs (e.g. matrix lamp 17 and solenoid flasher 17) + static string HardwareKey(PinMameSpatialPlacement placement) => + $"{placement.HardwareId}|{placement.Source}|{placement.Channel}|{placement.IsCoil}"; + var placementCounts = indexedPlacements + .GroupBy(item => HardwareKey(item.Placement), StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + var openPlacements = new Dictionary(StringComparer.Ordinal); + foreach (var item in representedItems) { + var hardwareId = HardwareKey(item.Placement); + if (openPlacements.ContainsKey(hardwareId)) continue; + var linkedCount = mappings.Count(mapping => isInPartition(mapping) && mappingMatchesPlacement(mapping, item.Placement) && IsAlive(getDevice(mapping))); + openPlacements[hardwareId] = System.Math.Max(0, placementCounts[hardwareId] - linkedCount); + } + var targetItems = representedItems + .Where(item => openPlacements[HardwareKey(item.Placement)] > 0) + .ToArray(); + var keptCount = representedItems.Length - targetItems.Length; + var candidateSlots = allSlots + .Where(slot => !usedSlotKeys.Contains(slot.Match.Key) && mayAssign(slot.Device)) + .ToArray(); + var result = PinMameSpatialMatcher.Match( + targetItems.Select(item => new PinMameSpatialMatchTarget(item.Key, item.Placement.X, item.Placement.Y, item.Placement.Label)).ToArray(), + candidateSlots.Select(slot => slot.Match).ToArray(), + anchors); + + var assigned = 0; + var keptInMatch = 0; + var rejectionReasons = new List(); + foreach (var decision in result.Decisions) { + var target = targetItems.First(item => item.Key == decision.Target.Key); + if (!decision.IsAccepted) { + rejectionReasons.Add($"{target.Placement.Label}: {decision.Reason}"); + continue; + } + if (openPlacements[HardwareKey(target.Placement)] <= 0) { + keptInMatch++; + continue; + } + var slot = candidateSlots.First(item => item.Match.Key == decision.Candidate.Key); + var mapping = mappings.FirstOrDefault(item => isInPartition(item) && mappingMatchesPlacement(item, target.Placement) + && !IsAlive(getDevice(item)) && isPlayfieldMapping(item)); + if (mapping == null) { + var template = mappings.FirstOrDefault(item => isInPartition(item) && mappingMatchesPlacement(item, target.Placement)); + if (template == null) continue; + beginMutation(); + mapping = cloneMapping(template); + mappings.Add(mapping); + } + beginMutation(); + setDevice(mapping, slot.Device, slot.DeviceItem); + openPlacements[HardwareKey(target.Placement)]--; + assigned++; + } + + // kept links count as preserved assignments, like anchors + return new PinMameSpatialAssignmentResult(placements.Count, assigned, anchors.Count + keptCount + keptInMatch, + result.Decisions.Length - assigned - keptInMatch, unrepresented, rejectionReasons.Take(3).ToArray(), null); + } + + private static SceneSlot CreateSlot(TDevice device, string deviceItem, string itemDescription, PlayfieldComponent playfield) + where TDevice : class, IMainComponent + { + var position = DevicePosition(device, playfield); + if (position == null) return null; + var component = device as MonoBehaviour; + var entityId = UnityObjectId.Get(component ? (UnityEngine.Object)component : device.gameObject); + var key = $"{entityId}:{deviceItem}"; + return new SceneSlot(device, deviceItem, new PinMameSpatialMatchCandidate(key, position.Value.x, position.Value.y, itemDescription)); + } + + private static Vector2? DevicePosition(TDevice device, PlayfieldComponent playfield) + where TDevice : class, IMainComponent + { + if (device is LightGroupComponent lightGroup) { + var lights = lightGroup.LightSources.Where(light => light).ToArray(); + if (lights.Length > 0) { + var sum = Vector3.zero; + foreach (var light in lights) sum += light.transform.position; + return Normalize(sum / lights.Length, playfield); + } + } + return Normalize(device.gameObject.transform.position, playfield); + } + + private static IEnumerable AnchorPositions(TDevice device, PlayfieldComponent playfield) + where TDevice : class, IMainComponent + { + if (device is LightGroupComponent lightGroup) { + var childPositions = LeafLamps(lightGroup) + .Select(lamp => Normalize(lamp.gameObject.transform.position, playfield)) + .Where(position => position.HasValue) + .Select(position => position.Value) + .ToArray(); + if (childPositions.Length > 0) return childPositions; + } + var position = DevicePosition(device, playfield); + return position.HasValue ? new[] { position.Value } : Array.Empty(); + } + + private static IEnumerable CoveredDevices(TDevice device) + where TDevice : class, IMainComponent + { + yield return device; + if (device is not LightGroupComponent lightGroup) yield break; + foreach (var lamp in LeafLamps(lightGroup).OfType()) yield return lamp; + } + + private static IEnumerable LeafLamps(LightGroupComponent root) + { + var leaves = new List(); + var visited = new HashSet(); + CollectLeafLamps(root, visited, leaves); + return leaves.Distinct(); + } + + private static void CollectLeafLamps(LightGroupComponent group, ISet visited, + ICollection leaves) + { + if (!visited.Add(group)) return; + foreach (var lamp in group.Lights) { + if (lamp is LightGroupComponent childGroup) CollectLeafLamps(childGroup, visited, leaves); + else leaves.Add(lamp); + } + } + + private static Vector2? Normalize(Vector3 worldPosition, PlayfieldComponent playfield) + { + var vpx = worldPosition.TranslateToVpx(playfield.transform); + var x = (vpx.x - playfield.Left) / (playfield.Right - playfield.Left); + var y = (vpx.y - playfield.Top) / (playfield.Bottom - playfield.Top); + if (float.IsNaN(x) || float.IsNaN(y) || x < -0.05f || x > 1.05f || y < -0.05f || y > 1.05f) return null; + return new Vector2(x, y); + } + + private static string ResolveDeviceItem(string configuredItem, IEnumerable items) + where TItem : IGamelogicEngineDeviceItem + { + var available = items.ToArray(); + if (!string.IsNullOrEmpty(configuredItem)) { + return available.Any(item => item.Id == configuredItem) ? configuredItem : null; + } + return available.Length == 1 ? available[0].Id : null; + } + + private static bool IsAlive(TDevice device) where TDevice : class + { + return device is MonoBehaviour behaviour ? behaviour : device != null; + } + + private static double Distance(float x1, float y1, float x2, float y2) + { + var x = x1 - x2; + var y = y1 - y2; + return System.Math.Sqrt(x * x + y * y); + } + + private sealed class IndexedPlacement + { + internal PinMameSpatialPlacement Placement { get; } + internal string Key { get; } + + internal IndexedPlacement(PinMameSpatialPlacement placement, string key) + { + Placement = placement; + Key = key; + } + } + + private sealed class SceneSlot + { + internal TDevice Device { get; } + internal string DeviceItem { get; } + internal PinMameSpatialMatchCandidate Match { get; } + + internal SceneSlot(TDevice device, string deviceItem, PinMameSpatialMatchCandidate match) + { + Device = device; + DeviceItem = deviceItem; + Match = match; + } + } + } + + internal sealed class PinMameSpatialAssignmentResult + { + private readonly string _message; + + internal int PlacementCount { get; } + internal int AssignedCount { get; } + internal int AnchorCount { get; } + internal int RejectedCount { get; } + internal int UnrepresentedCount { get; } + internal string[] Details { get; } + + internal bool Changed => AssignedCount > 0; + + internal PinMameSpatialAssignmentResult(int placementCount, int assignedCount, int anchorCount, int rejectedCount, + int unrepresentedCount, string[] details, string message) + { + PlacementCount = placementCount; + AssignedCount = assignedCount; + AnchorCount = anchorCount; + RejectedCount = rejectedCount; + UnrepresentedCount = unrepresentedCount; + Details = details ?? Array.Empty(); + _message = message; + } + + internal static PinMameSpatialAssignmentResult NoSpatialData() + => new(0, 0, 0, 0, 0, Array.Empty(), "This definition has no curated playfield positions for that hardware type."); + + internal static PinMameSpatialAssignmentResult NoMappings() + => new(0, 0, 0, 0, 0, Array.Empty(), "Populate Hardware before applying spatial assignments."); + + internal static PinMameSpatialAssignmentResult InvalidPlayfield() + => new(0, 0, 0, 0, 0, Array.Empty(), "The table has no valid playfield bounds."); + + public override string ToString() + { + if (!string.IsNullOrEmpty(_message)) return _message; + var parts = new List { + $"Assigned {AssignedCount} of {PlacementCount} curated positions", + }; + if (AnchorCount > 0) parts.Add($"preserved {AnchorCount} existing assignments as anchors"); + if (RejectedCount > 0) parts.Add($"left {RejectedCount} uncertain"); + if (UnrepresentedCount > 0) parts.Add($"skipped {UnrepresentedCount} without compatible mappings"); + var summary = string.Join("; ", parts) + "."; + return Details.Length == 0 ? summary : $"{summary}\n{string.Join("\n", Details)}"; + } + } +} diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSpatialAssignmentService.cs.meta b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSpatialAssignmentService.cs.meta new file mode 100644 index 0000000..f7b5a12 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSpatialAssignmentService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 83dbf61ebd1d4308b7f135626b8967f5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSpatialMatcher.cs b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSpatialMatcher.cs new file mode 100644 index 0000000..2c763e3 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSpatialMatcher.cs @@ -0,0 +1,340 @@ +// 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. + +// ReSharper disable CheckNamespace + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace VisualPinball.Engine.PinMAME.Editor +{ + internal static class PinMameSpatialMatcher + { + private const double MaximumDistance = 0.15; + private const double UnassignedCost = 0.18; + private const double MinimumMargin = 0.012; + private const double MaximumRelationError = 0.12; + private const double SeedMaximumDistance = 0.09; + private const double SeedMinimumMargin = 0.02; + private const double AbsoluteWeight = 0.65; + private const double RelationWeight = 0.35; + private const double ImpossibleCost = 1000.0; + // Bonus when a candidate's name equals the target's label, e.g. the "Gun Home" item of a mech + // whose items all sit at the same position. Larger than MinimumMargin, so it settles such ties. + private const double LabelMatchBonus = 0.02; + + internal static PinMameSpatialMatchResult Match(IReadOnlyList targets, + IReadOnlyList candidates, IReadOnlyList anchors = null) + { + if (targets == null) throw new ArgumentNullException(nameof(targets)); + if (candidates == null) throw new ArgumentNullException(nameof(candidates)); + if (targets.Count == 0) return new PinMameSpatialMatchResult(Array.Empty()); + + var relationAnchors = new List(anchors ?? Array.Empty()); + relationAnchors.AddRange(FindStableSeeds(targets, candidates)); + var columnCount = candidates.Count + targets.Count; + var costs = new double[targets.Count, columnCount]; + var distances = new double[targets.Count, candidates.Count]; + var relationErrors = new double[targets.Count, candidates.Count]; + var relationScratch = new double[relationAnchors.Count]; + + for (var targetIndex = 0; targetIndex < targets.Count; targetIndex++) { + for (var candidateIndex = 0; candidateIndex < candidates.Count; candidateIndex++) { + var distance = Distance(targets[targetIndex].X, targets[targetIndex].Y, + candidates[candidateIndex].X, candidates[candidateIndex].Y); + distances[targetIndex, candidateIndex] = distance; + if (distance > MaximumDistance) { + relationErrors[targetIndex, candidateIndex] = 0.0; + costs[targetIndex, candidateIndex] = ImpossibleCost; + continue; + } + var relationError = RelationError(targets[targetIndex], candidates[candidateIndex], relationAnchors, + relationScratch, out var usableAnchors); + relationErrors[targetIndex, candidateIndex] = relationError; + var cost = usableAnchors == 0 + ? distance + : AbsoluteWeight * distance + RelationWeight * relationError; + if (LabelsMatch(targets[targetIndex].Label, candidates[candidateIndex].Label)) { + cost -= LabelMatchBonus; + } + costs[targetIndex, candidateIndex] = cost; + } + for (var dummyIndex = candidates.Count; dummyIndex < columnCount; dummyIndex++) { + costs[targetIndex, dummyIndex] = UnassignedCost; + } + } + + var assignment = AssignStable(costs, candidates.Count); + var owners = Enumerable.Repeat(-1, candidates.Count).ToArray(); + for (var targetIndex = 0; targetIndex < targets.Count; targetIndex++) { + if (assignment[targetIndex] >= 0 && assignment[targetIndex] < candidates.Count) owners[assignment[targetIndex]] = targetIndex; + } + var decisions = new PinMameSpatialMatchDecision[targets.Count]; + for (var targetIndex = 0; targetIndex < targets.Count; targetIndex++) { + var candidateIndex = assignment[targetIndex]; + if (candidateIndex < 0 || candidateIndex >= candidates.Count) { + decisions[targetIndex] = PinMameSpatialMatchDecision.Rejected(targets[targetIndex], "No unassigned playfield item is close enough."); + continue; + } + + var score = costs[targetIndex, candidateIndex]; + var distance = distances[targetIndex, candidateIndex]; + var relationError = relationErrors[targetIndex, candidateIndex]; + if (distance > MaximumDistance || score >= UnassignedCost) { + decisions[targetIndex] = PinMameSpatialMatchDecision.Rejected(targets[targetIndex], "The closest playfield item is too far from the curated position.", distance, score, candidates[candidateIndex]); + continue; + } + + // Ambiguous when another item fits this target about as well, and it's either free or its + // owner would fit this target's item about as well too, so the two could swap. A neighbour + // that clearly fits its own item better (e.g. the next insert of a row) doesn't count. + // A target pushed off its own best item by another one isn't trusted either. + var ambiguous = UnassignedCost - score < MinimumMargin; + for (var otherCandidateIndex = 0; !ambiguous && otherCandidateIndex < candidates.Count; otherCandidateIndex++) { + if (otherCandidateIndex == candidateIndex || costs[targetIndex, otherCandidateIndex] >= ImpossibleCost) continue; + var otherCost = costs[targetIndex, otherCandidateIndex]; + if (otherCost < score) { + ambiguous = true; + break; + } + if (otherCost - score >= MinimumMargin) continue; + var owner = owners[otherCandidateIndex]; + ambiguous = owner < 0 || costs[owner, candidateIndex] - costs[owner, otherCandidateIndex] < MinimumMargin; + } + if (ambiguous) { + decisions[targetIndex] = PinMameSpatialMatchDecision.Rejected(targets[targetIndex], "Two playfield items are equally plausible.", distance, score, candidates[candidateIndex]); + continue; + } + + if (relationAnchors.Count > 0 && relationError > MaximumRelationError) { + decisions[targetIndex] = PinMameSpatialMatchDecision.Rejected(targets[targetIndex], "The item is nearby, but its position relative to trusted matches is inconsistent.", distance, score, candidates[candidateIndex]); + continue; + } + + // Contested when another target would rather have this item and fits it about as well or + // better. One that merely wants it (e.g. because it got nothing) but fits it clearly worse + // doesn't make this match doubtful. + var contested = false; + for (var otherTargetIndex = 0; otherTargetIndex < targets.Count; otherTargetIndex++) { + if (otherTargetIndex == targetIndex) continue; + var otherAssignedColumn = assignment[otherTargetIndex]; + var otherAssignedCost = otherAssignedColumn >= 0 ? costs[otherTargetIndex, otherAssignedColumn] : UnassignedCost; + var otherCost = costs[otherTargetIndex, candidateIndex]; + if (otherCost + MinimumMargin < otherAssignedCost && otherCost < score + MinimumMargin) { + contested = true; + break; + } + } + if (contested) { + decisions[targetIndex] = PinMameSpatialMatchDecision.Rejected(targets[targetIndex], "Another machine item is a better fit for this playfield item.", distance, score, candidates[candidateIndex]); + continue; + } + + decisions[targetIndex] = PinMameSpatialMatchDecision.Accepted(targets[targetIndex], candidates[candidateIndex], distance, score); + } + return new PinMameSpatialMatchResult(decisions); + } + + private static IEnumerable FindStableSeeds(IReadOnlyList targets, + IReadOnlyList candidates) + { + if (targets.Count < 2 || candidates.Count < 2) yield break; + for (var targetIndex = 0; targetIndex < targets.Count; targetIndex++) { + var nearestCandidate = -1; + var best = double.MaxValue; + var second = double.MaxValue; + for (var candidateIndex = 0; candidateIndex < candidates.Count; candidateIndex++) { + var distance = Distance(targets[targetIndex].X, targets[targetIndex].Y, candidates[candidateIndex].X, candidates[candidateIndex].Y); + if (distance < best) { + second = best; + best = distance; + nearestCandidate = candidateIndex; + } else if (distance < second) { + second = distance; + } + } + if (nearestCandidate < 0 || best > SeedMaximumDistance || second - best < SeedMinimumMargin) continue; + + var nearestTarget = -1; + var candidateBest = double.MaxValue; + var candidateSecond = double.MaxValue; + for (var otherTargetIndex = 0; otherTargetIndex < targets.Count; otherTargetIndex++) { + var distance = Distance(targets[otherTargetIndex].X, targets[otherTargetIndex].Y, + candidates[nearestCandidate].X, candidates[nearestCandidate].Y); + if (distance < candidateBest) { + candidateSecond = candidateBest; + candidateBest = distance; + nearestTarget = otherTargetIndex; + } else if (distance < candidateSecond) { + candidateSecond = distance; + } + } + if (nearestTarget != targetIndex || candidateSecond - candidateBest < SeedMinimumMargin) continue; + yield return new PinMameSpatialMatchAnchor(targets[targetIndex].X, targets[targetIndex].Y, + candidates[nearestCandidate].X, candidates[nearestCandidate].Y, targets[targetIndex].Key); + } + } + + private static bool LabelsMatch(string targetLabel, string candidateLabel) + { + if (string.IsNullOrWhiteSpace(targetLabel) || string.IsNullOrWhiteSpace(candidateLabel)) return false; + return string.Equals(NormalizeLabel(targetLabel), NormalizeLabel(candidateLabel), StringComparison.Ordinal); + } + + private static string NormalizeLabel(string label) => new string(label.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); + + private static double RelationError(PinMameSpatialMatchTarget target, PinMameSpatialMatchCandidate candidate, + IReadOnlyList anchors, double[] scratch, out int usableAnchors) + { + usableAnchors = 0; + if (anchors.Count == 0) return 0.0; + var count = 0; + for (var index = 0; index < anchors.Count; index++) { + var anchor = anchors[index]; + if (string.Equals(anchor.TargetKey, target.Key, StringComparison.Ordinal)) continue; + scratch[count++] = Distance(candidate.X - anchor.CandidateX, candidate.Y - anchor.CandidateY, + target.X - anchor.TargetX, target.Y - anchor.TargetY); + } + usableAnchors = count; + if (count == 0) return 0.0; + Array.Sort(scratch, 0, count); + return scratch[count / 2]; + } + + /// + /// Pairs targets and items in order of increasing cost, so each item goes to the target that + /// fits it best. Unlike a minimum-cost assignment, a target without a matching item on the + /// table (it isn't modelled) doesn't take a neighbour's item and push that one away. + /// + private static int[] AssignStable(double[,] costs, int candidateCount) + { + var targetCount = costs.GetLength(0); + var pairs = new List<(double Cost, int Target, int Candidate)>(); + for (var targetIndex = 0; targetIndex < targetCount; targetIndex++) { + for (var candidateIndex = 0; candidateIndex < candidateCount; candidateIndex++) { + if (costs[targetIndex, candidateIndex] < UnassignedCost) { + pairs.Add((costs[targetIndex, candidateIndex], targetIndex, candidateIndex)); + } + } + } + pairs.Sort((a, b) => a.Cost != b.Cost ? a.Cost.CompareTo(b.Cost) + : a.Target != b.Target ? a.Target.CompareTo(b.Target) : a.Candidate.CompareTo(b.Candidate)); + + var assignment = Enumerable.Repeat(-1, targetCount).ToArray(); + var taken = new bool[candidateCount]; + foreach (var (_, targetIndex, candidateIndex) in pairs) { + if (assignment[targetIndex] >= 0 || taken[candidateIndex]) continue; + assignment[targetIndex] = candidateIndex; + taken[candidateIndex] = true; + } + return assignment; + } + + private static double Distance(double x1, double y1, double x2, double y2) + { + var x = x1 - x2; + var y = y1 - y2; + return System.Math.Sqrt(x * x + y * y); + } + } + + internal sealed class PinMameSpatialMatchTarget + { + internal string Key { get; } + internal float X { get; } + internal float Y { get; } + internal string Label { get; } + + internal PinMameSpatialMatchTarget(string key, float x, float y, string label = null) + { + Key = key; + X = x; + Y = y; + Label = label; + } + } + + internal sealed class PinMameSpatialMatchCandidate + { + internal string Key { get; } + internal float X { get; } + internal float Y { get; } + internal string Label { get; } + + internal PinMameSpatialMatchCandidate(string key, float x, float y, string label = null) + { + Key = key; + X = x; + Y = y; + Label = label; + } + } + + internal sealed class PinMameSpatialMatchAnchor + { + internal float TargetX { get; } + internal float TargetY { get; } + internal float CandidateX { get; } + internal float CandidateY { get; } + internal string TargetKey { get; } + + internal PinMameSpatialMatchAnchor(float targetX, float targetY, float candidateX, float candidateY, + string targetKey = null) + { + TargetX = targetX; + TargetY = targetY; + CandidateX = candidateX; + CandidateY = candidateY; + TargetKey = targetKey; + } + } + + internal sealed class PinMameSpatialMatchDecision + { + internal PinMameSpatialMatchTarget Target { get; } + internal PinMameSpatialMatchCandidate Candidate { get; } + internal bool IsAccepted => Candidate != null; + internal string Reason { get; } + internal double Distance { get; } + internal double Score { get; } + /// + /// The item the target got in the best overall assignment, also when that wasn't trusted. + /// + internal PinMameSpatialMatchCandidate Assigned { get; } + + private PinMameSpatialMatchDecision(PinMameSpatialMatchTarget target, PinMameSpatialMatchCandidate candidate, + string reason, double distance, double score, PinMameSpatialMatchCandidate assigned) + { + Target = target; + Candidate = candidate; + Reason = reason; + Distance = distance; + Score = score; + Assigned = assigned; + } + + internal static PinMameSpatialMatchDecision Accepted(PinMameSpatialMatchTarget target, + PinMameSpatialMatchCandidate candidate, double distance, double score) + => new(target, candidate, null, distance, score, candidate); + + internal static PinMameSpatialMatchDecision Rejected(PinMameSpatialMatchTarget target, string reason, + double distance = double.NaN, double score = double.NaN, PinMameSpatialMatchCandidate assigned = null) + => new(target, null, reason, distance, score, assigned); + } + + internal sealed class PinMameSpatialMatchResult + { + internal PinMameSpatialMatchDecision[] Decisions { get; } + + internal PinMameSpatialMatchResult(PinMameSpatialMatchDecision[] decisions) + { + Decisions = decisions; + } + } +} diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSpatialMatcher.cs.meta b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSpatialMatcher.cs.meta new file mode 100644 index 0000000..09539fd --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/PinMameSpatialMatcher.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 12a14a5330204cfab80e58f4ad1bbf98 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.PinMAME.Unity/Editor/VisualPinball.Engine.PinMAME.Unity.Editor.asmdef b/VisualPinball.Engine.PinMAME.Unity/Editor/VisualPinball.Engine.PinMAME.Unity.Editor.asmdef index 256997e..b54b019 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Editor/VisualPinball.Engine.PinMAME.Unity.Editor.asmdef +++ b/VisualPinball.Engine.PinMAME.Unity/Editor/VisualPinball.Engine.PinMAME.Unity.Editor.asmdef @@ -5,7 +5,8 @@ "VisualPinball.Engine", "VisualPinball.Unity", "VisualPinball.Unity.Editor", - "VisualPinball.Engine.PinMAME.Unity" + "VisualPinball.Engine.PinMAME.Unity", + "Newtonsoft.Json" ], "includePlatforms": [ "Editor" ], "excludePlatforms": [], diff --git a/VisualPinball.Engine.PinMAME.Unity/README.md b/VisualPinball.Engine.PinMAME.Unity/README.md index da65e36..5cb046f 100644 --- a/VisualPinball.Engine.PinMAME.Unity/README.md +++ b/VisualPinball.Engine.PinMAME.Unity/README.md @@ -3,19 +3,21 @@ ## Setup -Add this to your Unity project via the package manager. When using a scoped +Add this to your Unity project via the package manager. When using a scoped registry, use `org.visualpinball.engine.pinmame`. ## Usage -This package adds a new Gamelogic Engine called *PinMAME*. -Just add the component to your table and wire up the switches, coils, lights -and DMD. +This package adds a new gamelogic engine called *PinMAME*. Add the component to a table, select a physical machine and ROM in its inspector, then click **Populate Hardware**. The editor downloads the selected definition from [games.visualpinball.org](https://games.visualpinball.org), caches it under `Library/PinMAME/GameDefinitions`, and fills the table's switch, coil, and lamp managers. + +The catalog and cache are editor-only. A player build receives the serialized hardware mappings and machine/ROM identifiers; it does not fetch or contain the source metadata. Display layouts continue to be queried directly from the selected ROM through PinMAME. + +Existing scenes and packages that used the retired class-based definitions remain compatible through a small runtime alias resolver. New and repopulated tables serialize numeric PinMAME IDs and populated flipper-wire mappings on the table. Candidate hardware snapshots stay in the editor scene and are omitted from packaged gamelogic data, so no machine-definition classes or catalog records are shipped with the player. ## Developer Setup This UPM package gets build and published via CI. If you want to use it locally, -you'll need to open and compile the VS solution first, which will copy the +you'll need to open and compile the VS solution first, which will copy the necessary binaries to the Unity package folder. ## License diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameFolders.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameFolders.cs new file mode 100644 index 0000000..3d0554e --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameFolders.cs @@ -0,0 +1,119 @@ +// 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 NLog; +using PinMame; +using VisualPinball.Unity; +using Logger = NLog.Logger; + +namespace VisualPinball.Engine.PinMAME +{ + /// + /// Where VPE's PinMAME keeps its files on desktop. VPE ships its own PinMAME and doesn't depend + /// on a Visual PinMAME (VPM) installation: NVRAM, config and high scores live in VPE's data + /// folder, shared by all VPE apps, and ROMs are searched in the folder the host set in + /// . The only thing taken from VPM is a game's NVRAM, + /// high scores and config, copied over the first time VPE runs that game. + /// + public static class PinMameFolders + { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + private static readonly string[] SubFolders = { "roms", "nvram", "cfg", "hi", "samples" }; + + private static readonly (PinMameFileType Type, string Folder, string Extension)[] VpmImports = { + (PinMameFileType.Nvram, "nvram", ".nv"), + (PinMameFileType.HighScore, "hi", ".hi"), + (PinMameFileType.Config, "cfg", ".cfg"), + }; + + /// + /// PinMAME's base folder, holding roms, nvram, cfg, hi and samples. Created on access. + /// + public static string DataFolder + { + get { + var folder = Path.Combine(GamelogicPaths.DataRoot, "PinMAME"); + foreach (var subFolder in SubFolders) { + Directory.CreateDirectory(Path.Combine(folder, subFolder)); + } + return folder; + } + } + + /// + /// Points PinMAME at the host's ROM folder, or at "roms" in the data folder if the host + /// didn't set one. Must not be called while a game is running. + /// + public static void ApplyRomFolder(PinMame.PinMame pinMame) + { + pinMame.RomPath = GamelogicPaths.RomFolder; + } + + /// + /// Whether a ROM's zip is in one of the given folders. A cheap check that doesn't need + /// PinMAME, for UI. + /// + public static bool HasRomZip(string romFolder, string romId) + { + if (string.IsNullOrWhiteSpace(romFolder) || string.IsNullOrWhiteSpace(romId)) { + return false; + } + return romFolder.Split(';') + .Select(folder => folder.Trim()) + .Any(folder => folder.Length > 0 && File.Exists(Path.Combine(folder, romId + ".zip"))); + } + + /// + /// Copies the game's NVRAM, high scores and config from VPM's folders, for each of them + /// that VPE doesn't have yet. Existing files are never overwritten, so after the first run + /// the game's state is VPE's own. + /// + public static void ImportFromVpm(string romId) + { + if (string.IsNullOrWhiteSpace(romId)) { + return; + } + var dataFolder = DataFolder; + foreach (var (type, folder, extension) in VpmImports) { + var target = Path.Combine(dataFolder, folder, romId + extension); + if (File.Exists(target)) { + continue; + } + var vpmFolders = PinMame.PinMame.FindVpmPath(type); + if (vpmFolders == null) { + continue; + } + foreach (var vpmFolder in vpmFolders.Split(';')) { + var source = Path.Combine(vpmFolder, romId + extension); + if (!File.Exists(source)) { + continue; + } + try { + File.Copy(source, target, false); + Logger.Info($"[PinMAME] Copied {source} from Visual PinMAME to {target}."); + } catch (Exception e) { + Logger.Warn($"[PinMAME] Could not copy {source} to {target}: {e.Message}"); + } + break; + } + } + } + } +} diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameFolders.cs.meta b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameFolders.cs.meta new file mode 100644 index 0000000..65aec0d --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameFolders.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3707101761991aa489cb11ba6e49bde3 \ No newline at end of file diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs index 8b8a090..75949f7 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs @@ -1,1570 +1,1868 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 . - -// ReSharper disable CheckNamespace -// ReSharper disable InconsistentNaming -// ReSharper disable PossibleNullReferenceException - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Reflection; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using NLog; -using PinMame; -using UnityEngine; -using UnityEngine.InputSystem; -using VisualPinball.Engine.Game.Engines; -using VisualPinball.Unity; -using VisualPinball.Unity.Simulation; -using Logger = NLog.Logger; - -namespace VisualPinball.Engine.PinMAME -{ - [Serializable] - [DisallowMultipleComponent] - [RequireComponent(typeof(AudioSource))] - [PackAs("PinMameGamelogicEngine")] - [AddComponentMenu("Pinball/Gamelogic Engine/PinMAME")] - public class PinMameGamelogicEngine : MonoBehaviour, IGamelogicEngine, IDisplayFrameFormatPreference, IRomNameProvider, IGamelogicInputThreading, IGamelogicTimeFence, IGamelogicCoilOutputFeed, IGamelogicSharedStateWriter, IGamelogicSharedStateApplier, IGamelogicPerformanceStats, IPackable - { - public string Name { get; } = "PinMAME Gamelogic Engine"; - public GamelogicInputDispatchMode SwitchDispatchMode => GamelogicInputDispatchMode.SimulationThread; - - public const string DmdPrefix = "dmd"; - public const string SegDispPrefix = "display"; - - public PinMameGame Game { get => _game; set => _game = value; } - - #region Configuration - - [HideInInspector] - public string romId = string.Empty; - - string IRomNameProvider.RomName => romId; - - [Tooltip("Disable built-in mechs")] - public bool DisableMechs = true; - - [Min(0f)] - [Tooltip("Delay after startup to listen for solenoid events.")] - public float SolenoidDelay; - - [Tooltip("Disable audio")] - public bool DisableAudio; - - #endregion - - #region Packaging - - public byte[] Pack() - { - return PackageApi.Packer.Pack(new PinMameGamelogicEnginePackable { - GameType = _game?.GetType().AssemblyQualifiedName, - GameId = _game?.Id, - RomId = romId, - DisableMechs = DisableMechs, - SolenoidDelay = SolenoidDelay, - DisableAudio = DisableAudio - }); - } - - public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) => Array.Empty(); - - public void Unpack(byte[] bytes) - { - var data = PackageApi.Packer.Unpack(bytes); - romId = data.RomId; - DisableMechs = data.DisableMechs; - SolenoidDelay = data.SolenoidDelay; - DisableAudio = data.DisableAudio; - _game = ResolveGame(data.GameType, data.GameId); - } - - public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) { } - - private static PinMameGame ResolveGame(string gameType, string gameId) - { - var gameFromType = CreateByTypeName(gameType); - if (gameFromType != null) { - return gameFromType; - } - - if (string.IsNullOrWhiteSpace(gameId)) { - return null; - } - - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { - Type[] types; - try { - types = assembly.GetTypes(); - } catch (ReflectionTypeLoadException e) { - types = e.Types.Where(t => t != null).ToArray(); - } - - foreach (var type in types) { - if (type == null || type.IsAbstract || !typeof(PinMameGame).IsAssignableFrom(type)) { - continue; - } - - if (Activator.CreateInstance(type) is PinMameGame game && - string.Equals(game.Id, gameId, StringComparison.OrdinalIgnoreCase)) { - return game; - } - } - } - - return null; - } - - private static PinMameGame CreateByTypeName(string gameType) - { - if (string.IsNullOrWhiteSpace(gameType)) { - return null; - } - - var type = Type.GetType(gameType); - if (type == null) { - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { - type = assembly.GetType(gameType, false); - if (type != null) { - break; - } - } - } - - if (type == null || type.IsAbstract || !typeof(PinMameGame).IsAssignableFrom(type)) { - return null; - } - - return Activator.CreateInstance(type) as PinMameGame; - } - - #endregion - - #region IGamelogicEngine - - public GamelogicEngineSwitch[] RequestedSwitches { - get { - UpdateCaches(); - return _game?.AvailableSwitches ?? Array.Empty(); - } - } - - public GamelogicEngineCoil[] RequestedCoils { - get { - UpdateCaches(); - return _coils.Values.ToArray(); - } - } - - public GamelogicEngineLamp[] RequestedLamps { - get { - UpdateCaches(); - return _lamps.Values.ToArray(); - } - } - - public GamelogicEngineWire[] AvailableWires => _game?.AvailableWires ?? Array.Empty(); - - public event EventHandler OnCoilChanged; - public event EventHandler OnSwitchChanged; - public event EventHandler OnLampChanged; - public event EventHandler OnLampsChanged; - public event EventHandler OnDisplaysRequested; - public event EventHandler OnDisplayClear; - public event EventHandler OnDisplayUpdateFrame; - public event EventHandler OnStarted; - private int _loggedChangedLampSamples; - private int _loggedUnmappedLampSamples; - private const int MaxLoggedChangedLampSamples = 24; - private const int MaxLoggedUnmappedLampSamples = 24; - - public void SetTimeFence(double timeInSeconds) - { - if (_pinMame == null) { - return; - } - - try { - PinMame.PinMame.SetTimeFence(timeInSeconds); - } - catch (Exception e) { - Logger.Warn(e, "[PinMAME] SetTimeFence call failed."); - } - } - - public bool TryDequeueCoilEvent(out CoilEventArgs coilEvent) - { - if (_simulationCoilDispatchQueue.TryDequeue(out coilEvent)) { - if (Interlocked.Decrement(ref _simulationCoilDispatchQueueSize) < 0) { - Interlocked.Exchange(ref _simulationCoilDispatchQueueSize, 0); - } - return true; - } - - return false; - } - - public bool TryGetPerformanceStats(out GamelogicPerformanceStats stats) - { - if (_pinMame == null) { - stats = default; - return false; - } - - stats = new GamelogicPerformanceStats(_isRunning, Volatile.Read(ref _pinMameCallbackRateHz), PinMame.PinMame.RunState); - return true; - } - - public void WriteSharedState(ref SimulationState.Snapshot snapshot) - { - lock (_outputStateLock) { - snapshot.CoilCount = CopyCoilStates(ref snapshot); - snapshot.LampCount = CopyLampStates(ref snapshot); - snapshot.GICount = CopyGiStates(ref snapshot); - } - } - - public void ApplySharedState(in SimulationState.Snapshot snapshot) - { - if (_player == null) { - return; - } - - _sharedLampPlaybackActive = true; - - for (var i = 0; i < snapshot.LampCount; i++) { - var lamp = snapshot.LampStates[i]; - if (!_pinMameIdToLampIdMapping.TryGetValue(lamp.Id, out var lampId)) { - continue; - } - if (_lastAppliedLampStates.TryGetValue(lamp.Id, out var currentValue) && currentValue == lamp.Value) { - continue; - } - - _lastAppliedLampStates[lamp.Id] = lamp.Value; - _player.SetLamp(lampId, lamp.Value, LampSource.Lamp); - } - - for (var i = 0; i < snapshot.GICount; i++) { - var gi = snapshot.GIStates[i]; - if (!_pinMameIdToLampIdMapping.TryGetValue(gi.Id, out var giId)) { - continue; - } - if (_lastAppliedGiStates.TryGetValue(gi.Id, out var currentValue) && currentValue == gi.Value) { - continue; - } - - _lastAppliedGiStates[gi.Id] = gi.Value; - _player.SetLamp(giId, gi.Value, LampSource.GI); - } - } - - #endregion - - #region Internals - - [NonSerialized] private Player _player; - [NonSerialized] private PinMame.PinMame _pinMame; - [NonSerialized] private BallManager _ballManager; - [NonSerialized] private PlayfieldComponent _playfieldComponent; - - [NonSerialized] private readonly List _changedLamps = new(); - [NonSerialized] private readonly List _changedGIs = new(); - - [SerializeReference] private PinMameGame _game; - - private Dictionary _switches = new(); - private Dictionary _pinMameIdToSwitchIdMappings = new(); - private Dictionary _switchIdToPinMameIdMappings = new(); - - private Dictionary _coils = new(); - private Dictionary _pinMameIdToCoilIdMapping = new(); - private Dictionary _coilIdToPinMameIdMapping = new(); - - private Dictionary _lamps = new(); - private Dictionary _pinMameIdToLampIdMapping = new(); - - private volatile bool _isRunning; - private int _numMechs; - private Dictionary _frameBuffer = new(); - private volatile DisplayFrameFormat _requestedDmdFrameFormat = DisplayFrameFormat.Dmd8; - - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - private static readonly Color Tint = new(1, 0.18f, 0); - private static readonly Color UnlitTint = new(Tint.r * 0.18f, Tint.g * 0.18f, Tint.b * 0.18f, 1f); - - private readonly Queue _dispatchQueue = new(); - private readonly List _pendingDispatchCallbacks = new(); - private readonly object _displayStateLock = new(); - private readonly object _outputStateLock = new(); - private readonly ConcurrentQueue _simulationCoilDispatchQueue = new(); - private int _simulationCoilDispatchQueueSize; - private bool _dispatchQueueWarningIssued; - private bool _simulationCoilQueueWarningIssued; - private bool _simulationCoilQueueDropWarningIssued; - private long _solenoidDelayStartTimestampUsec; - private const int MaxSimulationCoilDispatchQueueSize = 8192; - private const int DispatchQueueWarningThreshold = 512; - private const double PerfSampleWindowSeconds = 0.25; - private long _pinMamePerfWindowStartTicks = Stopwatch.GetTimestamp(); - private int _pinMameCallbacksInWindow; - private float _pinMameCallbackRateHz; - private readonly Dictionary _sharedCoilStates = new(); - // Solenoid callbacks report 0/1 for binary coils, but 0-255 for PWM-integrated - // (modulated) coils on WPC/SAM ROMs. We can't tell from a single value whether a - // "1" is "binary on" or "modulated 0.4% duty"; once any coil reports a value above - // 1 the game is modulating, and every coil value is then normalized by 255. - private volatile bool _solenoidsModulated; - private readonly Dictionary _sharedLampStates = new(); - private readonly Dictionary _sharedGiStates = new(); - private readonly Dictionary _lastAppliedLampStates = new(); - private readonly Dictionary _lastAppliedGiStates = new(); - private bool _sharedLampPlaybackActive; - private readonly Queue _audioQueue = new(); - - private int _audioFilterChannels; - private PinMameAudioInfo _audioInfo; - private float[] _lastAudioFrame = {}; - private int _lastAudioFrameOffset; - private const int _maximalQueueSize = 10; - - private double _audioInputStart; - private double _audioOutputStart; - private int _audioNumSamplesInput; - private int _audioNumSamplesOutput; - - public bool _solenoidsEnabled; - public long _solenoidDelayStart; - private Dictionary _registeredMechs = new(); - private Dictionary _registeredMechNames = new(); - private HashSet _mechSwitches = new(); - - private bool _toggleSpeed = false; - private Keyboard _keyboard; - - private static long TimestampUsec => (Stopwatch.GetTimestamp() * 1_000_000) / Stopwatch.Frequency; - - private static readonly SemaphoreSlim PinMameStartStopGate = new SemaphoreSlim(1, 1); - private int _onInitCalled; - private int _stopRequested; - private int _playModeExitHandled; - private const int StopTimeoutMs = 10000; - - #endregion - - #region Lifecycle - - private void Awake() - { - Logger.Info("Project audio sample rate: " + AudioSettings.outputSampleRate); - _keyboard = Keyboard.current; - } - - private void Start() - { - UpdateCaches(); - - _lastAudioFrame = Array.Empty(); - _lastAudioFrameOffset = 0; - } - - private void Update() - { - if (_pinMame == null || !_isRunning) { - return; - } - - _pendingDispatchCallbacks.Clear(); - - lock (_dispatchQueue) { - while (_dispatchQueue.Count > 0) { - _pendingDispatchCallbacks.Add(_dispatchQueue.Dequeue()); - } - } - - foreach (var callback in _pendingDispatchCallbacks) { - try { - callback.Invoke(); - } - catch (Exception e) { - Logger.Error(e, "[PinMAME] Exception while processing main-thread dispatch callback."); - } - } - - // lamps - _pinMame.GetChangedLamps(_changedLamps); - foreach (var changedLamp in _changedLamps) { - lock (_outputStateLock) { - _sharedLampStates[changedLamp.Id] = changedLamp.Value; - } - if (!_sharedLampPlaybackActive && _pinMameIdToLampIdMapping.ContainsKey(changedLamp.Id)) { - var vpeLampId = _lamps[_pinMameIdToLampIdMapping[changedLamp.Id]].Id; - LogChangedLampSample("lamp", changedLamp.Id, vpeLampId, changedLamp.Value, LampSource.Lamp); - OnLampChanged?.Invoke(this, new LampEventArgs(vpeLampId, changedLamp.Value)); - } else { - LogUnmappedLampSample("lamp", changedLamp.Id, changedLamp.Value, LampSource.Lamp); - } - } - - // gi - _pinMame.GetChangedGIs(_changedGIs); - foreach (var changedGi in _changedGIs) { - lock (_outputStateLock) { - _sharedGiStates[changedGi.Id] = changedGi.Value; - } - if (!_sharedLampPlaybackActive && _pinMameIdToLampIdMapping.ContainsKey(changedGi.Id)) { - var vpeLampId = _lamps[_pinMameIdToLampIdMapping[changedGi.Id]].Id; - LogChangedLampSample("gi", changedGi.Id, vpeLampId, changedGi.Value, LampSource.GI); - OnLampChanged?.Invoke(this, new LampEventArgs(vpeLampId, changedGi.Value, LampSource.GI)); - } else { - LogUnmappedLampSample("gi", changedGi.Id, changedGi.Value, LampSource.GI); - } - } - - // if (_keyboard != null && _keyboard.cKey.wasPressedThisFrame) - // { - // OnCoilChanged.Invoke(this, new CoilEventArgs("28", true)); - // OnCoilChanged.Invoke(this, new CoilEventArgs("28", false)); - // } - } - - private void EnqueueMainThreadDispatch(Action callback) - { - lock (_dispatchQueue) { - _dispatchQueue.Enqueue(callback); - if (!_dispatchQueueWarningIssued && _dispatchQueue.Count >= DispatchQueueWarningThreshold) { - _dispatchQueueWarningIssued = true; - Logger.Warn($"[PinMAME] Main-thread dispatch queue backlog reached {_dispatchQueue.Count} items."); - } - } - } - - private void LogChangedLampSample(string kind, int pinMameId, string vpeLampId, float value, LampSource source) - { - if (_loggedChangedLampSamples >= MaxLoggedChangedLampSamples) { - return; - } - - _loggedChangedLampSamples++; - Logger.Info( - $"[PinMAME] <= {kind} sample #{_loggedChangedLampSamples}: pinmameId={pinMameId}, " + - $"vpeId={vpeLampId}, source={source}, value={value}, sharedLampPlaybackActive={_sharedLampPlaybackActive}."); - } - - private void LogUnmappedLampSample(string kind, int pinMameId, float value, LampSource source) - { - if (_loggedUnmappedLampSamples >= MaxLoggedUnmappedLampSamples) { - return; - } - - _loggedUnmappedLampSamples++; - Logger.Warn( - $"[PinMAME] <= {kind} UNMAPPED sample #{_loggedUnmappedLampSamples}: pinmameId={pinMameId}, " + - $"source={source}, value={value}, sharedLampPlaybackActive={_sharedLampPlaybackActive}."); - } - - private int CopyCoilStates(ref SimulationState.Snapshot snapshot) - { - var index = 0; - foreach (var entry in _sharedCoilStates) { - if (index >= snapshot.CoilStates.Length) { - break; - } - - snapshot.CoilStates[index++] = new SimulationState.CoilState { - Id = entry.Key, - IsActive = entry.Value ? (byte)1 : (byte)0, - }; - } - - return index; - } - - private int CopyLampStates(ref SimulationState.Snapshot snapshot) - { - var index = 0; - foreach (var entry in _sharedLampStates) { - if (index >= snapshot.LampStates.Length) { - break; - } - - snapshot.LampStates[index++] = new SimulationState.LampState { - Id = entry.Key, - Value = entry.Value, - }; - } - - return index; - } - - private int CopyGiStates(ref SimulationState.Snapshot snapshot) - { - var index = 0; - foreach (var entry in _sharedGiStates) { - if (index >= snapshot.GIStates.Length) { - break; - } - - snapshot.GIStates[index++] = new SimulationState.GIState { - Id = entry.Key, - Value = entry.Value, - }; - } - - return index; - } - - private void OnDestroy() - { - #if UNITY_EDITOR - StopForPlayModeExit(); - #else - RequestStopGame("OnDestroy"); - #endif - if (_pinMame != null) { - _pinMame.OnGameStarted -= OnGameStarted; - _pinMame.OnGameEnded -= OnGameEnded; - _pinMame.OnDisplayAvailable -= OnDisplayRequested; - _pinMame.OnDisplayUpdated -= OnDisplayUpdated; - - if (!DisableAudio) - { - _pinMame.OnAudioAvailable -= OnAudioAvailable; - _pinMame.OnAudioUpdated -= OnAudioUpdated; - } - - _pinMame.OnMechAvailable -= OnMechAvailable; - _pinMame.OnMechUpdated -= OnMechUpdated; - _pinMame.OnSolenoidUpdated -= OnSolenoidUpdated; - _pinMame.IsKeyPressed -= IsKeyPressed; - } - _frameBuffer.Clear(); - lock (_outputStateLock) { - _sharedCoilStates.Clear(); - _solenoidsModulated = false; - _sharedLampStates.Clear(); - _sharedGiStates.Clear(); - } - _lastAppliedLampStates.Clear(); - _lastAppliedGiStates.Clear(); - _sharedLampPlaybackActive = false; - } - - private void OnDisable() - { - // In some Unity playmode/domain-reload configurations, OnDestroy isn't reliably called. - // Best-effort shutdown to avoid leaving a game running across sessions. - #if UNITY_EDITOR - StopForPlayModeExit(); - #else - RequestStopGame("OnDisable"); - #endif - } - - private void OnApplicationQuit() - { - #if UNITY_EDITOR - StopForPlayModeExit(); - #else - RequestStopGame("OnApplicationQuit"); - #endif - } - - public void StopForPlayModeExit() - { - if (Interlocked.Exchange(ref _playModeExitHandled, 1) != 0) { - return; - } - - // Editor-only: called from playmode lifecycle hook. - // Do not call into native Stop() here (it blocks); the editor hook will stop PinMAME - // after we unsubscribe managed callbacks. - Interlocked.Exchange(ref _stopRequested, 1); - SetTimeFence(0.0); - // Stop polling outputs immediately to avoid concurrent calls into pinmame.dll - // while a stop is in progress (pinmame APIs are not guaranteed thread-safe). - _isRunning = false; - Logger.Info($"[PinMAME] StopForPlayModeExit: unsubscribing callbacks ({name}#{UnityObjectId.Get(this)})"); - try { - if (_pinMame == null) { - return; - } - - _pinMame.OnGameStarted -= OnGameStarted; - _pinMame.OnGameEnded -= OnGameEnded; - _pinMame.OnDisplayAvailable -= OnDisplayRequested; - _pinMame.OnDisplayUpdated -= OnDisplayUpdated; - _pinMame.OnMechAvailable -= OnMechAvailable; - _pinMame.OnMechUpdated -= OnMechUpdated; - _pinMame.OnSolenoidUpdated -= OnSolenoidUpdated; - _pinMame.IsKeyPressed -= IsKeyPressed; - _pinMame.OnAudioAvailable -= OnAudioAvailable; - _pinMame.OnAudioUpdated -= OnAudioUpdated; - } - catch { } - } - - private void RequestStopGame(string reason) - { - #if UNITY_EDITOR - // In the editor, playmode shutdown is handled by StopForPlayModeExit + editor lifecycle hook. - // Calling native Stop() from here can race and destabilize the editor. - StopForPlayModeExit(); - return; - #endif - - try { - SetTimeFence(0.0); - - if (Interlocked.Exchange(ref _stopRequested, 1) != 0) { - return; - } - _isRunning = false; - - if (!PinMame.PinMame.IsRunning) { - return; - } - - } catch (Exception e) { - Logger.Warn(e, $"[PinMAME] StopGame ({reason}) failed."); - return; - } - - // Avoid racing StartGame on re-entering play mode. - if (!PinMameStartStopGate.Wait(0)) { - Logger.Warn($"[PinMAME] StopGame ({reason}) skipped: start/stop already in progress."); - return; - } - - // Never block the Unity main thread during playmode exit. - // PinMAME shutdown is best-effort here; OnInit() and the editor lifecycle hook - // will recover/ensure shutdown before starting a new ROM. - Logger.Warn($"[PinMAME] StopGame ({reason}) requested. RunningGame={_pinMame?.RunningGame}"); - - try { - if (_pinMame != null) { - // Unsubscribe first to keep the native thread from doing work in managed callbacks during shutdown. - _pinMame.OnGameStarted -= OnGameStarted; - _pinMame.OnGameEnded -= OnGameEnded; - _pinMame.OnDisplayAvailable -= OnDisplayRequested; - _pinMame.OnDisplayUpdated -= OnDisplayUpdated; - _pinMame.OnMechAvailable -= OnMechAvailable; - _pinMame.OnMechUpdated -= OnMechUpdated; - _pinMame.OnSolenoidUpdated -= OnSolenoidUpdated; - _pinMame.IsKeyPressed -= IsKeyPressed; - _pinMame.OnAudioAvailable -= OnAudioAvailable; - _pinMame.OnAudioUpdated -= OnAudioUpdated; - } - } - catch (Exception e) { - Logger.Warn(e, $"[PinMAME] Unsubscribe during shutdown ({reason}) failed."); - } - - // PinmameStop may block (joins the emulation thread). Never do that on the Unity main thread. - System.Threading.Tasks.Task.Run(() => { - try { - _pinMame?.StopGame(); - } catch (Exception e) { - Logger.Warn(e, $"[PinMAME] StopGame ({reason}) failed."); - } finally { - PinMameStartStopGate.Release(); - } - }); - } - - public async Task OnInit(Player player, TableApi tableApi, BallManager ballManager, CancellationToken ct) - { - if (Interlocked.Exchange(ref _onInitCalled, 1) != 0) { - Logger.Warn($"[PinMAME] OnInit called more than once on {name}, ignoring."); - return; - } - - string vpmPath = null; - _ballManager = ballManager; - _playfieldComponent = GetComponentInChildren(); - - #if (UNITY_IOS || UNITY_ANDROID) && !UNITY_EDITOR - vpmPath = Path.Combine(Application.persistentDataPath, "pinmame"); - Directory.CreateDirectory(Path.Combine(vpmPath, "roms")); - - byte[] data = null; - - #if UNITY_IOS - data = File.ReadAllBytes(Path.Combine(Application.streamingAssetsPath, $"{romId}.zip")); - #else - UnityWebRequest webRequest = new UnityWebRequest(Path.Combine(Application.streamingAssetsPath, $"{romId}.zip")); - - webRequest.downloadHandler = new DownloadHandlerBuffer(); - webRequest.SendWebRequest(); - - while (!webRequest.isDone) { }; - - data = webRequest.downloadHandler.data; - #endif - - File.WriteAllBytes(Path.Combine(vpmPath, "roms", $"{romId}.zip"), data); - #endif - - await PinMameStartStopGate.WaitAsync(ct); - try { - Logger.Info($"[PinMAME] OnInit {name}: romId={romId}, sampleRate={AudioSettings.outputSampleRate}"); - - #if UNITY_EDITOR - // With Domain Reload disabled, PinMAME (native + managed singleton) persists across play sessions. - // Even when RunState reports 0, the native game thread might still exist and need joining. - // Calling Stop here is a cheap no-op if nothing is running, and a critical cleanup if something is. - var preStopState = PinMame.PinMame.RunState; - var preStopSw = Stopwatch.StartNew(); - try { - PinMame.PinMame.StopRunningGame(); - } catch (Exception e) { - Logger.Warn(e, "[PinMAME] Pre-start StopRunningGame failed."); - } - // Wait for a clean stopped state before starting a new ROM. - var preStopWaitSw = Stopwatch.StartNew(); - while (PinMame.PinMame.RunState != 0 && preStopWaitSw.ElapsedMilliseconds < StopTimeoutMs) { - await Task.Delay(10, ct); - } - Logger.Debug($"[PinMAME] Editor pre-stop: initialRunState={preStopState}, stopCallMs={preStopSw.ElapsedMilliseconds}, waitMs={preStopWaitSw.ElapsedMilliseconds}, finalRunState={PinMame.PinMame.RunState}"); - if (PinMame.PinMame.RunState != 0) { - Logger.Error($"[PinMAME] Cannot start ROM; PinMAME did not reach stopped state (RunState={PinMame.PinMame.RunState})."); - return; - } - - // Force a fresh managed wrapper each init. This avoids cross-session managed state - // (callbacks, buffers) bleeding into the next play run when the host keeps assemblies loaded. - PinMame.PinMame.ResetInstance(); - #endif - - // If we exited play mode recently, a background stop may still be running. - // Starting a new ROM while the previous stop is cleaning up can lead to a "started" - // state with no progress/output on the next run. - var wasRunning = PinMame.PinMame.IsRunning; - var wasStopping = PinMame.PinMame.IsStopInProgress; - var swStop = Stopwatch.StartNew(); - while ((PinMame.PinMame.IsStopInProgress || PinMame.PinMame.IsRunning) && swStop.ElapsedMilliseconds < StopTimeoutMs) { - await Task.Delay(10, ct); - } - Logger.Debug($"[PinMAME] Pre-start wait: {swStop.ElapsedMilliseconds}ms (WasRunning={wasRunning}, WasStopping={wasStopping}, IsRunning={PinMame.PinMame.IsRunning}, RunState={PinMame.PinMame.RunState}, StopInProgress={PinMame.PinMame.IsStopInProgress})"); - - // ReSharper disable once ExpressionIsAlwaysNull - _pinMame = PinMame.PinMame.Instance(PinMameAudioFormat.AudioFormatFloat, AudioSettings.outputSampleRate, vpmPath); - // Re-apply config each init (important when Domain Reload is disabled). - _pinMame.ApplyConfig(); - - // Prevent duplicate subscriptions if OnInit is invoked more than once. - _pinMame.OnGameStarted -= OnGameStarted; - _pinMame.OnGameEnded -= OnGameEnded; - _pinMame.OnDisplayAvailable -= OnDisplayRequested; - _pinMame.OnDisplayUpdated -= OnDisplayUpdated; - _pinMame.OnMechAvailable -= OnMechAvailable; - _pinMame.OnMechUpdated -= OnMechUpdated; - _pinMame.OnSolenoidUpdated -= OnSolenoidUpdated; - _pinMame.IsKeyPressed -= IsKeyPressed; - _pinMame.OnAudioAvailable -= OnAudioAvailable; - _pinMame.OnAudioUpdated -= OnAudioUpdated; - - // If domain reload is disabled or a previous table didn't shut down cleanly, - // PinMAME can still be running here. - if (PinMame.PinMame.IsRunning) { - Logger.Warn($"[PinMAME] A game is already running (RunningGame={_pinMame.RunningGame}); stopping it before starting a new one."); - try { - _pinMame.StopGame(); - } - catch (Exception e) { - Logger.Warn(e, "[PinMAME] StopGame failed while recovering from already running state."); - } - - var sw = Stopwatch.StartNew(); - while (PinMame.PinMame.IsRunning && sw.ElapsedMilliseconds < StopTimeoutMs) { - await Task.Delay(10, ct); - } - if (PinMame.PinMame.IsRunning) { - Logger.Warn("[PinMAME] Failed to stop running game within timeout; attempting StartGame anyway."); - } - } - - _pinMame.SetHandleKeyboard(false); - _pinMame.SetHandleMechanics(DisableMechs ? 0 : 0xFF); - ApplyDmdModePreference(); - SetTimeFence(0.01); - - _pinMame.OnGameStarted += OnGameStarted; - _pinMame.OnGameEnded += OnGameEnded; - _pinMame.OnDisplayAvailable += OnDisplayRequested; - _pinMame.OnDisplayUpdated += OnDisplayUpdated; - - if (!DisableAudio) { - _pinMame.OnAudioAvailable += OnAudioAvailable; - _pinMame.OnAudioUpdated += OnAudioUpdated; - } - - _pinMame.OnMechAvailable += OnMechAvailable; - _pinMame.OnMechUpdated += OnMechUpdated; - _pinMame.OnSolenoidUpdated += OnSolenoidUpdated; - _pinMame.IsKeyPressed += IsKeyPressed; - - _player = player; - _solenoidsEnabled = SolenoidDelay == 0; - - try { - _pinMame.StartGame(romId); - Logger.Debug($"[PinMAME] StartGame returned. RunState={PinMame.PinMame.RunState}"); - } - catch (InvalidOperationException e) when (e.Message != null && e.Message.Contains("status=GAME_ALREADY_RUNNING")) - { - Logger.Warn(e, "[PinMAME] StartGame reported already running; stopping and retrying once."); - try { - _pinMame.StopGame(); - await Task.Delay(100, ct); - _pinMame.StartGame(romId); - Logger.Debug($"[PinMAME] StartGame retry returned. RunState={PinMame.PinMame.RunState}"); - } - catch (Exception retryEx) { - Logger.Error(retryEx); - } - } - catch (Exception e) { - Logger.Error(e); - } - - } finally { - PinMameStartStopGate.Release(); - } - } - - public void ToggleSpeed() - { - Logger.Info("[PinMAME] Toggle speed."); - - _pinMame.SetHandleKeyboard(true); - _toggleSpeed = true; - } - - private void OnGameStarted() - { - Logger.Info("[PinMAME] Game started."); - _isRunning = true; - - _solenoidDelayStartTimestampUsec = TimestampUsec; - _solenoidDelayStart = _solenoidDelayStartTimestampUsec / 1000; - - try { - SendInitialSwitches(); - SendMechs(); - } - - catch(Exception e) { - Logger.Error($"[PinMAME] OnGameStarted: {e.Message}"); - } - - EnqueueMainThreadDispatch(() => OnStarted?.Invoke(this, EventArgs.Empty)); - } - - private void OnGameEnded() - { - // Native can report ended more than once during teardown; keep it idempotent. - if (_isRunning) { - Logger.Info("[PinMAME] Game ended."); - } - _isRunning = false; - while (_simulationCoilDispatchQueue.TryDequeue(out _)) { - if (Interlocked.Decrement(ref _simulationCoilDispatchQueueSize) < 0) { - Interlocked.Exchange(ref _simulationCoilDispatchQueueSize, 0); - } - } - lock (_outputStateLock) { - _sharedCoilStates.Clear(); - _solenoidsModulated = false; - _sharedLampStates.Clear(); - _sharedGiStates.Clear(); - } - _lastAppliedLampStates.Clear(); - _lastAppliedGiStates.Clear(); - _sharedLampPlaybackActive = false; - } - - private void UpdateCaches() - { - if (_game == null) { - return; - } - - _lamps.Clear(); - _pinMameIdToLampIdMapping.Clear(); - - _coils.Clear(); - _pinMameIdToCoilIdMapping.Clear(); - - _switches.Clear(); - _pinMameIdToSwitchIdMappings.Clear(); - _switchIdToPinMameIdMappings.Clear(); - - // check aliases first (the switches/coils that aren't an integer) - foreach (var alias in _game.AvailableAliases) { - switch (alias.AliasType) { - case AliasType.Switch: - _pinMameIdToSwitchIdMappings[alias.Id] = alias.Name; - _switchIdToPinMameIdMappings[alias.Name] = alias.Id; - break; - - - case AliasType.Coil: - _pinMameIdToCoilIdMapping[alias.Id] = alias.Name; - _coilIdToPinMameIdMapping[alias.Name] = alias.Id; - break; - - case AliasType.Lamp: - _pinMameIdToLampIdMapping[alias.Id] = alias.Name; - break; - } - } - - // retrieve the game's switches - foreach (var @switch in _game.AvailableSwitches) { - _switches[@switch.Id] = @switch; - - if (int.TryParse(@switch.Id, out var pinMameId)) { - _pinMameIdToSwitchIdMappings[pinMameId] = @switch.Id; - _switchIdToPinMameIdMappings[@switch.Id] = pinMameId; - - // add mappings with prefixed 0. - if (pinMameId < 10) { - _switchIdToPinMameIdMappings["0" + @switch.Id] = pinMameId; - _switchIdToPinMameIdMappings["00" + @switch.Id] = pinMameId; - - _switches["0" + @switch.Id] = @switch; - _switches["00" + @switch.Id] = @switch; - } - if (pinMameId < 100) { - _switchIdToPinMameIdMappings["0" + @switch.Id] = pinMameId; - _switches["0" + @switch.Id] = @switch; - } - } - } - - // retrieve the game's coils - foreach (var coil in _game.AvailableCoils) { - _coils[coil.Id] = coil; - - if (int.TryParse(coil.Id, out int pinMameId)) { - _pinMameIdToCoilIdMapping[pinMameId] = coil.Id; - _coilIdToPinMameIdMapping[coil.Id] = pinMameId; - } - } - - // retrieve the game's lamps - foreach (var lamp in _game.AvailableLamps) { - _lamps[lamp.Id] = lamp; - - if (int.TryParse(lamp.Id, out int pinMameId)) { - _pinMameIdToLampIdMapping[pinMameId] = lamp.Id; - } - } - } - - #endregion - - #region Displays - - public void RequestDisplayFrameFormat(string displayId, DisplayFrameFormat format) - { - if (!string.IsNullOrWhiteSpace(displayId) && !displayId.StartsWith(DmdPrefix, StringComparison.OrdinalIgnoreCase)) { - return; - } - - if (format != DisplayFrameFormat.Dmd2 && format != DisplayFrameFormat.Dmd4 && format != DisplayFrameFormat.Dmd8) { - return; - } - - if (_requestedDmdFrameFormat == format) { - return; - } - - _requestedDmdFrameFormat = format; - ApplyDmdModePreference(); - } - - private void OnDisplayRequested(int index, int displayCount, PinMameDisplayLayout displayLayout) - { - if (displayLayout.IsDmd) { - EnqueueMainThreadDispatch(() => - OnDisplaysRequested?.Invoke(this, new RequestedDisplays( - new DisplayConfig($"{DmdPrefix}{index}", displayLayout.Width, displayLayout.Height, false, Tint, UnlitTint)))); - - lock (_displayStateLock) { - _frameBuffer[index] = new byte[displayLayout.Width * displayLayout.Height]; - } - - } else { - EnqueueMainThreadDispatch(() => - OnDisplaysRequested?.Invoke(this, new RequestedDisplays( - new DisplayConfig($"{SegDispPrefix}{index}", displayLayout.Length, 1)))); - - lock (_displayStateLock) { - _frameBuffer[index] = new byte[displayLayout.Length * 2]; - } - Logger.Info($"[PinMAME] Display {SegDispPrefix}{index} is of type {displayLayout.Type} at {displayLayout.Length} wide."); - } - } - - private void OnDisplayUpdated(int index, IntPtr framePtr, PinMameDisplayLayout displayLayout) - { - MarkPinMameCallbackActivity(); - - if (displayLayout.IsDmd) { - UpdateDmd(index, displayLayout, framePtr); - - } else { - UpdateSegDisp(index, displayLayout, framePtr); - } - } - - private void UpdateDmd(int index, PinMameDisplayLayout displayLayout, IntPtr framePtr) - { - if (framePtr == IntPtr.Zero) { - Logger.Warn($"[PinMAME] Dropping DMD frame for index {index} because frame pointer is null (layout: {displayLayout})."); - return; - } - - byte[] frameBuffer; - lock (_displayStateLock) { - if (!_frameBuffer.TryGetValue(index, out frameBuffer)) { - Logger.Warn($"[PinMAME] Dropping DMD frame for unknown index {index} (layout: {displayLayout})."); - return; - } - } - - // Snapshot into a fresh array: the native PinMAME thread can fire UpdateDmd again before - // the queued dispatch runs on the main thread, so the frame handed to subscribers must not - // be the reused _frameBuffer (otherwise it can be overwritten / torn mid-read). - var frame = new byte[frameBuffer.Length]; - Marshal.Copy(framePtr, frame, 0, frame.Length); - - EnqueueMainThreadDispatch(() => OnDisplayUpdateFrame?.Invoke(this, - new DisplayFrameData($"{DmdPrefix}{index}", GetRequestedDmdDisplayFrameFormat(displayLayout), frame))); - } - - private void UpdateSegDisp(int index, PinMameDisplayLayout displayLayout, IntPtr framePtr) - { - if (framePtr == IntPtr.Zero) { - Logger.Warn($"[PinMAME] Dropping segmented display frame for index {index} because frame pointer is null (layout: {displayLayout})."); - return; - } - - byte[] frameBuffer; - lock (_displayStateLock) { - if (!_frameBuffer.TryGetValue(index, out frameBuffer)) { - Logger.Warn($"[PinMAME] Dropping segmented display frame for unknown index {index} (layout: {displayLayout})."); - return; - } - } - - // Snapshot into a fresh array (see UpdateDmd) so a follow-up native callback can't - // overwrite a frame already queued for the main thread. - var frame = new byte[frameBuffer.Length]; - Marshal.Copy(framePtr, frame, 0, displayLayout.Length * 2); - - EnqueueMainThreadDispatch(() => OnDisplayUpdateFrame?.Invoke(this, - new DisplayFrameData($"{SegDispPrefix}{index}", GetDisplayFrameFormat(displayLayout), frame))); - } - - public static DisplayFrameFormat GetDisplayFrameFormat(PinMameDisplayLayout layout) - { - if (layout.IsDmd) { - return DisplayFrameFormat.Dmd8; - } - - switch (layout.Type) { - case PinMameDisplayType.Seg8: // 7 segments and comma - case PinMameDisplayType.Seg7SC: // 7 segments, small, with comma - case PinMameDisplayType.Seg8D: // 7 segments and period - case PinMameDisplayType.Seg7: // 7 segments - case PinMameDisplayType.Seg7S: // 7 segments, small - case PinMameDisplayType.Seg87: // 7 segments, comma every three - case PinMameDisplayType.Seg87F: // 7 segments, forced comma every three - case PinMameDisplayType.Seg10: // 9 segments and comma - case PinMameDisplayType.Seg9: // 9 segments - case PinMameDisplayType.Seg98: // 9 segments, comma every three - case PinMameDisplayType.Seg98F: // 9 segments, forced comma every three - case PinMameDisplayType.Seg16: // 16 segments - case PinMameDisplayType.Seg16R: // 16 segments with comma and period reversed - case PinMameDisplayType.Seg16N: // 16 segments without commas - case PinMameDisplayType.Seg16D: // 16 segments with periods only - case PinMameDisplayType.Seg16S: // 16 segments with split top and bottom line - case PinMameDisplayType.Seg8H: - case PinMameDisplayType.Seg7H: - case PinMameDisplayType.Seg87H: - case PinMameDisplayType.Seg87FH: - case PinMameDisplayType.Seg7SH: - case PinMameDisplayType.Seg7SCH: - case PinMameDisplayType.Seg7 | PinMameDisplayType.NoDisp: - return DisplayFrameFormat.Segment; - - case PinMameDisplayType.Video: - break; - - case PinMameDisplayType.SegAll: - case PinMameDisplayType.Import: - case PinMameDisplayType.SegMask: - case PinMameDisplayType.SegHiBit: - case PinMameDisplayType.SegRev: - case PinMameDisplayType.DmdNoAA: - case PinMameDisplayType.NoDisp: - throw new ArgumentOutOfRangeException(nameof(layout), layout, null); - - default: - throw new ArgumentOutOfRangeException(nameof(layout), layout, null); - } - - throw new NotImplementedException($"Still unsupported segmented display format: {layout}."); - } - - private DisplayFrameFormat GetRequestedDmdDisplayFrameFormat(PinMameDisplayLayout layout) - { - if (_requestedDmdFrameFormat == DisplayFrameFormat.Dmd8) { - return DisplayFrameFormat.Dmd8; - } - - return GetRawDmdDisplayFrameFormat(layout); - } - - private static DisplayFrameFormat GetRawDmdDisplayFrameFormat(PinMameDisplayLayout layout) - { - var levels = layout.Levels?.Count ?? 0; - if ((levels > 0 && levels <= 4) || (layout.Depth > 0 && layout.Depth <= 2)) { - return DisplayFrameFormat.Dmd2; - } - - if ((levels > 0 && levels <= 16) || (layout.Depth > 0 && layout.Depth <= 4)) { - return DisplayFrameFormat.Dmd4; - } - - return DisplayFrameFormat.Dmd8; - } - - private void ApplyDmdModePreference() - { - if (_pinMame == null) { - return; - } - - var rawMode = _requestedDmdFrameFormat == DisplayFrameFormat.Dmd2 || _requestedDmdFrameFormat == DisplayFrameFormat.Dmd4; - try { - _pinMame.SetDmdMode(rawMode ? PinMameDmdMode.Raw : PinMameDmdMode.Brightness); - Logger.Info($"[PinMAME] DMD frame mode set to {(rawMode ? "raw" : "brightness")} for {_requestedDmdFrameFormat} output."); - } catch (Exception exception) { - Logger.Warn(exception, "[PinMAME] Could not set DMD frame mode."); - } - } - - public void DisplayChanged(DisplayFrameData displayFrameData) - { - } - - #endregion - - #region Audio - - private int OnAudioAvailable(PinMameAudioInfo audioInfo) - { - Logger.Info("Game audio available: " + audioInfo); - - _audioInfo = audioInfo; - return _audioInfo.SamplesPerFrame; - } - - private int OnAudioUpdated(IntPtr framePtr, int frameSize) - { - if (_audioFilterChannels == 0) { - // don't know how many channels yet - return _audioInfo.SamplesPerFrame; - } - - _audioNumSamplesInput += frameSize; - if (_audioNumSamplesInput > 100000) { - // var delta = AudioSettings.dspTime - _audioInputStart; - // var queueMs = System.Math.Round(_audioQueue.Count * (double)_audioInfo.SamplesPerFrame / _audioInfo.SampleRate * 1000); - // Logger.Info($"INPUT: {System.Math.Round(_audioNumSamplesInput / delta)} - {_audioQueue.Count} in queue ({queueMs}ms)"); - _audioInputStart = AudioSettings.dspTime; - _audioNumSamplesInput = 0; - } - - float[] frame; - if (_audioFilterChannels == _audioInfo.Channels) { // n channels -> n channels - frame = new float[frameSize * 2]; - unsafe { - var src = (float*)framePtr; - for (var i = 0; i < frameSize * 2; i++) { - frame[i] = src[i]; - } - } - } else if (_audioFilterChannels > _audioInfo.Channels) { // 1 channel -> 2 channels - frame = new float[frameSize * 2]; - unsafe { - var src = (float*)framePtr; - for (var i = 0; i < frameSize; i++) { - frame[i * 2] = src[i]; - frame[i * 2 + 1] = frame[i * 2]; - } - } - } else { // 2 channels -> 1 channel - frame = new float[frameSize / 2]; - unsafe { - var src = (float*)framePtr; - for (var i = 0; i < frameSize; i += 2) { - frame[i] = src[i]; - } - } - } - - lock (_audioQueue) { - if (_audioQueue.Count >= _maximalQueueSize) { - _audioQueue.Clear(); - Logger.Error("Clearing full audio frame queue."); - } - _audioQueue.Enqueue(frame); - } - - return _audioInfo.SamplesPerFrame; - } - - private void OnAudioFilterRead(float[] data, int channels) - { - _audioNumSamplesOutput += data.Length / channels; - if (_audioNumSamplesOutput > 100000) { - //var delta = AudioSettings.dspTime - _audioOutputStart; - //Logger.Info($"OUTPUT: {System.Math.Round(_audioNumSamplesOutput / delta)}"); - _audioOutputStart = AudioSettings.dspTime; - _audioNumSamplesOutput = 0; - } - - if (_audioFilterChannels == 0) { - _audioFilterChannels = channels; - Logger.Info($"Creating audio on {channels} channels."); - return; - } - - if (_audioQueue.Count == 0) { - if (!DisableAudio) { - Logger.Info("Filtering audio but nothing to de-queue."); - } - return; - } - - - const int size = sizeof(float); - var dataOffset = 0; - var lastFrameSize = _lastAudioFrame.Length - _lastAudioFrameOffset; - if (data.Length >= lastFrameSize) { - if (lastFrameSize > 0) { - Buffer.BlockCopy(_lastAudioFrame, _lastAudioFrameOffset * size, data, 0, lastFrameSize * size); - dataOffset += lastFrameSize; - } - _lastAudioFrame = Array.Empty(); - _lastAudioFrameOffset = 0; - - lock (_audioQueue) { - while (dataOffset < data.Length && _audioQueue.Count > 0) { - var frame = _audioQueue.Dequeue(); - if (frame.Length <= data.Length - dataOffset) { - Buffer.BlockCopy(frame, 0, data, dataOffset * size, frame.Length * size); - dataOffset += frame.Length; - - } else { - Buffer.BlockCopy(frame, 0, data, dataOffset * size, (data.Length - dataOffset) * size); - _lastAudioFrame = frame; - _lastAudioFrameOffset = data.Length - dataOffset; - dataOffset = data.Length; - } - } - } - - } else { - Buffer.BlockCopy(_lastAudioFrame, _lastAudioFrameOffset * size, data, 0, data.Length * size); - _lastAudioFrameOffset += data.Length; - } - } - - #endregion - - #region Coils - - public void SetCoil(string n, bool value) - { - if (int.TryParse(n, out var coilId)) { - lock (_outputStateLock) { - _sharedCoilStates[coilId] = value; - } - } - OnCoilChanged?.Invoke(this, new CoilEventArgs(n, value)); - } - - public bool GetCoil(string id) - { - return _player != null && _player.CoilStatuses.ContainsKey(id) && _player.CoilStatuses[id]; - } - - private void OnSolenoidUpdated(int id, int value) - { - MarkPinMameCallbackActivity(); - - // value is 0/1 for binary coils, 0-255 for PWM-integrated (modulated) coils - if (value > 1) { - _solenoidsModulated = true; - } - var isActive = value > 0; - // normalized strength for the (main-thread) coil pipeline; the sim-thread path - // and the shared snapshot stay boolean on/off - var intensity = _solenoidsModulated ? System.Math.Min(value, 255) / 255f : (isActive ? 1f : 0f); - - if (_pinMameIdToCoilIdMapping.ContainsKey(id)) { - if (!_solenoidsEnabled) { - _solenoidsEnabled = TimestampUsec - _solenoidDelayStartTimestampUsec >= (long)(SolenoidDelay * 1000f); - - if (_solenoidsEnabled) { - Logger.Info($"Solenoids enabled, {SolenoidDelay}ms passed"); - } - } - - var coil = _coils[_pinMameIdToCoilIdMapping[id]]; - - if (_solenoidsEnabled) { - lock (_outputStateLock) { - _sharedCoilStates[id] = isActive; - } - - if (Logger.IsDebugEnabled) { - Logger.Debug($"[PinMAME] <= coil {coil.Id} : {value} | {coil.Description}"); - } - if (ShouldDispatchSimulationCoil(coil.Id)) { - _simulationCoilDispatchQueue.Enqueue(new CoilEventArgs(coil.Id, isActive)); - var simulationCoilQueueSize = Interlocked.Increment(ref _simulationCoilDispatchQueueSize); - if (!_simulationCoilQueueWarningIssued && simulationCoilQueueSize >= MaxSimulationCoilDispatchQueueSize / 2) { - _simulationCoilQueueWarningIssued = true; - Logger.Warn($"[PinMAME] Simulation coil dispatch backlog reached {simulationCoilQueueSize} items."); - } - if (simulationCoilQueueSize > MaxSimulationCoilDispatchQueueSize) { - if (_simulationCoilDispatchQueue.TryDequeue(out _)) { - if (Interlocked.Decrement(ref _simulationCoilDispatchQueueSize) < 0) { - Interlocked.Exchange(ref _simulationCoilDispatchQueueSize, 0); - } - if (!_simulationCoilQueueDropWarningIssued) { - _simulationCoilQueueDropWarningIssued = true; - Logger.Warn($"[PinMAME] Simulation coil dispatch queue exceeded {MaxSimulationCoilDispatchQueueSize} items. Dropping oldest coil callbacks."); - } - } - } - } - - EnqueueMainThreadDispatch(() => OnCoilChanged?.Invoke(this, new CoilEventArgs(coil.Id, intensity))); - } - else { - if (Logger.IsDebugEnabled) { - Logger.Debug($"[PinMAME] <= solenoids disabled, coil {coil.Id} : {value} | {coil.Description}"); - } - } - } - else { - Logger.Warn($"[PinMAME] <= coil UNMAPPED {id}: {isActive}"); - } - } - - #endregion - - #region Lamps - - public void SetLamp(string id, float value, bool isCoil = false, LampSource source = LampSource.Lamp) - { - if (int.TryParse(id, out var lampId)) { - lock (_outputStateLock) { - if (source == LampSource.GI) { - _sharedGiStates[lampId] = value; - } else { - _sharedLampStates[lampId] = value; - } - } - } - OnLampChanged?.Invoke(this, new LampEventArgs(id, value, isCoil, source)); - } - - public LampState GetLamp(string id) - { - return _player != null && _player.LampStatuses.ContainsKey(id) ? _player.LampStatuses[id] : LampState.Default; - } - - #endregion - - #region Switches - - public void SendInitialSwitches() - { - var switches = _player.SwitchStatuses; - Logger.Info("[PinMAME] Sending initial switch statuses..."); - foreach (var id in switches.Keys) { - var isClosed = switches[id].IsSwitchClosed; - // skip open switches - if (!isClosed) { - continue; - } - if (_switches.ContainsKey(id) && !_mechSwitches.Contains(_switchIdToPinMameIdMappings[_switches[id].Id])) { - Logger.Info($"[PinMAME] => sw {id} ({_switches[id].Id}): {true} | {_switches[id].Description}"); - - _pinMame.SetSwitch(_switchIdToPinMameIdMappings[_switches[id].Id], true); - } - } - } - - public void Switch(string id, bool isClosed) - { - if (_switches.TryGetValue(id, out var sw)) { - if (_switchIdToPinMameIdMappings.TryGetValue(sw.Id, out var pinMameId) && _mechSwitches.Contains(pinMameId)) { - // mech switches are triggered internally by pinmame. - return; - } - if (_pinMame != null && _isRunning) { - if (Logger.IsDebugEnabled) { - Logger.Debug($"[PinMAME] => sw {id}: {isClosed} | {sw.Description}"); - } - if (_switchIdToPinMameIdMappings.TryGetValue(sw.Id, out pinMameId)) { - _pinMame.SetSwitch(pinMameId, isClosed); - } - } - } else if (id == "s_spawn_ball") { - if (isClosed) { - _ballManager.CreateBall(new DebugBallCreator(630f, _playfieldComponent.Height / 2f)); - } - } else { - if (Logger.IsErrorEnabled) { - Logger.Error($"[PinMAME] Unknown switch \"{id}\"."); - } - } - - OnSwitchChanged?.Invoke(this, new SwitchEventArgs2(id, isClosed)); - } - - public bool GetSwitch(string id) - { - return _player != null && _player.SwitchStatuses.ContainsKey(id) && _player.SwitchStatuses[id].IsSwitchEnabled; - } - - #endregion - - #region Mechs - - public void RegisterMech(PinMameMechComponent mechComponent) - { - // PinMAME mech numbers are 1-based. - var id = ++_numMechs; - _registeredMechs[id] = mechComponent; - _registeredMechNames[id] = mechComponent ? mechComponent.name : ""; - } - - private void OnMechAvailable(int mechNo, PinMameMechInfo mechInfo) - { - Logger.Info($"[PinMAME] <= mech available: mechNo={mechNo}, mechInfo={mechInfo}"); - } - - private void OnMechUpdated(int mechNo, PinMameMechInfo mechInfo) - { - if (_registeredMechs.ContainsKey(mechNo)) { - EnqueueMainThreadDispatch(() => _registeredMechs[mechNo].UpdateMech(mechInfo)); - - } else { - Logger.Info($"[PinMAME] <= mech updated: mechNo={mechNo}, mechInfo={mechInfo}"); - } - } - - private void SendMechs() - { - var max = _pinMame.GetMaxMechs(); - foreach (var (id, mech) in _registeredMechs) { - var mechName = _registeredMechNames.TryGetValue(id, out var name) ? name : $"mech#{id}"; - // GetMaxMechs() returns the count of available slots and valid mech numbers are 1..max. - if (id > max) { - Logger.Error($"PinMAME supports {max} custom mech slot(s). Cannot register mech id={id} ({mechName})."); - continue; - } - var mechConfig = mech.Config(_player.SwitchMapping, _player.CoilMapping, _switchIdToPinMameIdMappings, _coilIdToPinMameIdMapping); - _pinMame.SetMech(id, mechConfig); - foreach (var c in mechConfig.SwitchList) { - _mechSwitches.Add(c.SwNo); - } - } - } - - #endregion - - private int IsKeyPressed(PinMameKeycode keycode) - { - if (keycode == PinMameKeycode.F10) { - if (_toggleSpeed) { - _toggleSpeed = false; - - _pinMame.SetHandleKeyboard(false); - - return 1; - } - } - - return 0; - } - - private bool ShouldDispatchSimulationCoil(string coilId) - { - return !string.IsNullOrEmpty(coilId) - && _player != null - && _player.SupportsSimulationThreadCoilDispatch(coilId); - } - - private void MarkPinMameCallbackActivity() - { - Interlocked.Increment(ref _pinMameCallbacksInWindow); - - var nowTicks = Stopwatch.GetTimestamp(); - var startTicks = Volatile.Read(ref _pinMamePerfWindowStartTicks); - var elapsedSeconds = (double)(nowTicks - startTicks) / Stopwatch.Frequency; - if (elapsedSeconds < PerfSampleWindowSeconds) { - return; - } - - if (Interlocked.CompareExchange(ref _pinMamePerfWindowStartTicks, nowTicks, startTicks) != startTicks) { - return; - } - - var callbacks = Interlocked.Exchange(ref _pinMameCallbacksInWindow, 0); - var rate = elapsedSeconds > 0.0 ? callbacks / elapsedSeconds : 0.0; - Volatile.Write(ref _pinMameCallbackRateHz, (float)rate); - } - } - - [Serializable] - public struct PinMameGamelogicEnginePackable - { - public string GameType; - public string GameId; - public string RomId; - public bool DisableMechs; - public float SolenoidDelay; - public bool DisableAudio; - } -} +// Visual Pinball Engine +// Copyright (C) 2021 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 . + +// ReSharper disable CheckNamespace +// ReSharper disable InconsistentNaming +// ReSharper disable PossibleNullReferenceException + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using NLog; +using PinMame; +using UnityEngine; +using UnityEngine.InputSystem; +using VisualPinball.Engine.Game.Engines; +using VisualPinball.Unity; +using VisualPinball.Unity.Simulation; +using ColorChannel = VisualPinball.Engine.Math.ColorChannel; +using Logger = NLog.Logger; + +namespace VisualPinball.Engine.PinMAME +{ + [Serializable] + [DisallowMultipleComponent] + [RequireComponent(typeof(AudioSource))] + [PackAs("PinMameGamelogicEngine")] + [AddComponentMenu("Pinball/Gamelogic Engine/PinMAME")] + public class PinMameGamelogicEngine : MonoBehaviour, IGamelogicEngine, IDisplayFrameFormatPreference, IRomNameProvider, IGamelogicInputThreading, IGamelogicTimeFence, IGamelogicCoilOutputFeed, IGamelogicSharedStateWriter, IGamelogicSharedStateApplier, IGamelogicPerformanceStats, IPackable + { + public string Name { get; } = "PinMAME Gamelogic Engine"; + public GamelogicInputDispatchMode SwitchDispatchMode => GamelogicInputDispatchMode.SimulationThread; + + public const string DmdPrefix = "dmd"; + public const string SegDispPrefix = "display"; + + #region Configuration + + [HideInInspector] + public string machineId = string.Empty; + + [HideInInspector] + public string romId = string.Empty; + + #if UNITY_EDITOR + [SerializeField, HideInInspector] + private PinMameSwitchDefinition[] _availableSwitches = Array.Empty(); + [SerializeField, HideInInspector] + private PinMameCoilDefinition[] _availableCoils = Array.Empty(); + [SerializeField, HideInInspector] + private PinMameLampDefinition[] _availableLamps = Array.Empty(); + [SerializeField, HideInInspector] + private bool _availableHardwareInitialized; + [SerializeField, HideInInspector] + private GamelogicEngineWire[] _availableWires = Array.Empty(); + [SerializeField, HideInInspector] + private bool _availableWiresInitialized; + // The machine the table's mappings were populated for, when it differs from machineId or is + // known. Empty for tables populated before this was tracked. + [SerializeField, HideInInspector] + private string _mappedMachineId; + #endif + + [SerializeField, HideInInspector] + private string _legacyGameId; + + string IRomNameProvider.RomName => romId; + + [Tooltip("Disable built-in mechs")] + public bool DisableMechs = true; + + [Min(0f)] + [Tooltip("Delay after startup to listen for solenoid events.")] + public float SolenoidDelay; + + [Tooltip("Disable audio")] + public bool DisableAudio; + + #endregion + + #region Packaging + + public byte[] Pack() + { + var data = new PinMameGamelogicEnginePackable { + MachineId = machineId, + GameId = _legacyGameId, + RomId = romId, + DisableMechs = DisableMechs, + SolenoidDelay = SolenoidDelay, + DisableAudio = DisableAudio + }; + return PackageApi.Packer.Pack(data); + } + + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) => Array.Empty(); + + public void Unpack(byte[] bytes) + { + var data = PackageApi.Packer.Unpack(bytes); + machineId = data.MachineId ?? string.Empty; + _legacyGameId = data.GameId; + romId = data.RomId; + DisableMechs = data.DisableMechs; + SolenoidDelay = data.SolenoidDelay; + DisableAudio = data.DisableAudio; + #if UNITY_EDITOR + ClearAvailableHardware(); + UpdateCaches(false); + if (!Application.isPlaying) { + UnityEditor.EditorApplication.delayCall += () => { + if (this) UpdateCaches(false); + }; + } + #endif + } + + public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) { } + + #endregion + + #region IGamelogicEngine + + public GamelogicEngineSwitch[] RequestedSwitches { + get { + #if UNITY_EDITOR + if (_availableHardwareInitialized && !_runtimeCachesBuilt) return (_availableSwitches ?? Array.Empty()).Select(item => item.ToEngineSwitch()).ToArray(); + #endif + return _requestedSwitches; + } + } + + public GamelogicEngineCoil[] RequestedCoils { + get { + #if UNITY_EDITOR + if (_availableHardwareInitialized && !_runtimeCachesBuilt) return (_availableCoils ?? Array.Empty()).Select(item => item.ToEngineCoil()).ToArray(); + #endif + return _requestedCoils; + } + } + + public GamelogicEngineLamp[] RequestedLamps { + get { + #if UNITY_EDITOR + if (_availableHardwareInitialized && !_runtimeCachesBuilt) return (_availableLamps ?? Array.Empty()).Select(item => item.ToEngineLamp()).ToArray(); + #endif + return _requestedLamps; + } + } + + public GamelogicEngineWire[] AvailableWires { + get { + #if UNITY_EDITOR + if (_availableWiresInitialized) return _availableWires ?? Array.Empty(); + return string.IsNullOrWhiteSpace(_legacyGameId) + ? Array.Empty() + : PinMameLegacyAliasResolver.WiresFor(machineId, romId, _legacyGameId); + #else + return Array.Empty(); + #endif + } + } + + #if UNITY_EDITOR + /// + /// Whether the table's mappings belong to the selected machine, so its positions and IDs can + /// be matched against them. + /// + public bool MappingsMatchMachine => string.IsNullOrEmpty(_mappedMachineId) || string.Equals(_mappedMachineId, machineId, StringComparison.Ordinal); + + /// + /// Whether the component holds hardware candidates from a machine definition. + /// + public bool HasAvailableHardware => _availableHardwareInitialized; + + /// + /// Records that the table's mappings were just populated from the selected machine. + /// + public void MarkMappingsPopulated() => _mappedMachineId = machineId; + + /// The machine to select. + /// The ROM to select. + /// The machine the editor resolved for the current + /// selection (e.g. from the ROM) when isn't set, like in older tables. + public void SetMachineSelection(string selectedMachineId, string selectedRomId, string resolvedCurrentMachineId = null) + { + selectedMachineId ??= string.Empty; + var currentMachineId = !string.IsNullOrEmpty(machineId) ? machineId : resolvedCurrentMachineId; + if (string.IsNullOrEmpty(_mappedMachineId) && !string.IsNullOrEmpty(currentMachineId) && !string.Equals(currentMachineId, selectedMachineId, StringComparison.Ordinal)) { + // the existing mappings were made for the machine selected until now + _mappedMachineId = currentMachineId; + } + machineId = selectedMachineId; + romId = selectedRomId ?? string.Empty; + _legacyGameId = null; + ClearAvailableHardware(); + } + + private void ClearAvailableHardware() + { + _runtimeCachesBuilt = false; + _availableSwitches = Array.Empty(); + _availableCoils = Array.Empty(); + _availableLamps = Array.Empty(); + _availableHardwareInitialized = false; + _availableWires = Array.Empty(); + _availableWiresInitialized = false; + } + + public void SetAvailableHardware(IEnumerable switches, IEnumerable coils, + IEnumerable lamps, IEnumerable wires) + { + _runtimeCachesBuilt = false; + _availableSwitches = switches?.Select(item => new PinMameSwitchDefinition(item)).ToArray() ?? Array.Empty(); + _availableCoils = coils?.Select(item => new PinMameCoilDefinition(item)).ToArray() ?? Array.Empty(); + _availableLamps = lamps?.Select(item => new PinMameLampDefinition(item)).ToArray() ?? Array.Empty(); + _availableHardwareInitialized = true; + _availableWires = wires?.Select(wire => new GamelogicEngineWire(wire.SourceId, wire.DestinationId, wire.DestinationType, wire.Description)).ToArray() + ?? Array.Empty(); + _availableWiresInitialized = true; + } + #endif + + public event EventHandler OnCoilChanged; + public event EventHandler OnSwitchChanged; + public event EventHandler OnLampChanged; + public event EventHandler OnLampsChanged; + public event EventHandler OnDisplaysRequested; + public event EventHandler OnDisplayClear; + public event EventHandler OnDisplayUpdateFrame; + public event EventHandler OnStarted; + private int _loggedChangedLampSamples; + private int _loggedUnmappedLampSamples; + private const int MaxLoggedChangedLampSamples = 24; + private const int MaxLoggedUnmappedLampSamples = 24; + + public void SetTimeFence(double timeInSeconds) + { + if (_pinMame == null) { + return; + } + + try { + PinMame.PinMame.SetTimeFence(timeInSeconds); + } + catch (Exception e) { + Logger.Warn(e, "[PinMAME] SetTimeFence call failed."); + } + } + + public bool TryDequeueCoilEvent(out CoilEventArgs coilEvent) + { + if (_simulationCoilDispatchQueue.TryDequeue(out coilEvent)) { + if (Interlocked.Decrement(ref _simulationCoilDispatchQueueSize) < 0) { + Interlocked.Exchange(ref _simulationCoilDispatchQueueSize, 0); + } + return true; + } + + return false; + } + + public bool TryGetPerformanceStats(out GamelogicPerformanceStats stats) + { + if (_pinMame == null) { + stats = default; + return false; + } + + stats = new GamelogicPerformanceStats(_isRunning, Volatile.Read(ref _pinMameCallbackRateHz), PinMame.PinMame.RunState); + return true; + } + + public void WriteSharedState(ref SimulationState.Snapshot snapshot) + { + lock (_outputStateLock) { + snapshot.CoilCount = CopyCoilStates(ref snapshot); + snapshot.LampCount = CopyLampStates(ref snapshot); + snapshot.GICount = CopyGiStates(ref snapshot); + } + } + + public void ApplySharedState(in SimulationState.Snapshot snapshot) + { + if (_player == null) { + return; + } + + _sharedLampPlaybackActive = true; + + for (var i = 0; i < snapshot.LampCount; i++) { + var lamp = snapshot.LampStates[i]; + if (!_pinMameIdToLampIdMapping.TryGetValue(lamp.Id, out var lampIds)) { + continue; + } + if (_lastAppliedLampStates.TryGetValue(lamp.Id, out var currentValue) && currentValue == lamp.Value) { + continue; + } + + _lastAppliedLampStates[lamp.Id] = lamp.Value; + foreach (var lampId in lampIds) { + _player.SetLamp(lampId, lamp.Value, LampSource.Lamp); + } + } + + for (var i = 0; i < snapshot.GICount; i++) { + var gi = snapshot.GIStates[i]; + if (!_pinMameIdToGiIdMapping.TryGetValue(gi.Id, out var giIds)) { + continue; + } + if (_lastAppliedGiStates.TryGetValue(gi.Id, out var currentValue) && currentValue == gi.Value) { + continue; + } + + _lastAppliedGiStates[gi.Id] = gi.Value; + foreach (var giId in giIds) { + _player.SetLamp(giId, gi.Value, LampSource.GI); + } + } + } + + #endregion + + #region Internals + + [NonSerialized] private Player _player; + [NonSerialized] private PinMame.PinMame _pinMame; + [NonSerialized] private BallManager _ballManager; + [NonSerialized] private PlayfieldComponent _playfieldComponent; + + [NonSerialized] private readonly List _changedLamps = new(); + [NonSerialized] private readonly List _changedGIs = new(); + + private Dictionary _switches = new(); + private volatile GamelogicEngineSwitch[] _requestedSwitches = Array.Empty(); + private Dictionary _pinMameIdToSwitchIdMappings = new(); + private Dictionary _switchIdToPinMameIdMappings = new(); + + private Dictionary _coils = new(); + private volatile GamelogicEngineCoil[] _requestedCoils = Array.Empty(); + // one PinMAME output can drive several mapping rows (e.g. "19" and the "c_game_on" alias) + private Dictionary> _pinMameIdToCoilIdMapping = new(); + private Dictionary _coilIdToPinMameIdMapping = new(); + + private volatile GamelogicEngineLamp[] _requestedLamps = Array.Empty(); + private Dictionary> _pinMameIdToLampIdMapping = new(); + private Dictionary> _pinMameIdToGiIdMapping = new(); + private volatile bool _runtimeCachesBuilt; + + private volatile bool _isRunning; + private int _numMechs; + private Dictionary _frameBuffer = new(); + private volatile DisplayFrameFormat _requestedDmdFrameFormat = DisplayFrameFormat.Dmd8; + + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + private static readonly Color Tint = new(1, 0.18f, 0); + private static readonly Color UnlitTint = new(Tint.r * 0.18f, Tint.g * 0.18f, Tint.b * 0.18f, 1f); + + private readonly Queue _dispatchQueue = new(); + private readonly List _pendingDispatchCallbacks = new(); + private readonly object _displayStateLock = new(); + private readonly object _outputStateLock = new(); + private readonly ConcurrentQueue _simulationCoilDispatchQueue = new(); + private int _simulationCoilDispatchQueueSize; + private bool _dispatchQueueWarningIssued; + private bool _simulationCoilQueueWarningIssued; + private bool _simulationCoilQueueDropWarningIssued; + private long _solenoidDelayStartTimestampUsec; + private const int MaxSimulationCoilDispatchQueueSize = 8192; + private const int DispatchQueueWarningThreshold = 512; + private const double PerfSampleWindowSeconds = 0.25; + private long _pinMamePerfWindowStartTicks = Stopwatch.GetTimestamp(); + private int _pinMameCallbacksInWindow; + private float _pinMameCallbackRateHz; + private readonly Dictionary _sharedCoilStates = new(); + // Solenoid callbacks report 0/1 for binary coils, but 0-255 for PWM-integrated + // (modulated) coils on WPC/SAM ROMs. We can't tell from a single value whether a + // "1" is "binary on" or "modulated 0.4% duty"; once any coil reports a value above + // 1 the game is modulating, and every coil value is then normalized by 255. + private volatile bool _solenoidsModulated; + private readonly Dictionary _sharedLampStates = new(); + private readonly Dictionary _sharedGiStates = new(); + private readonly Dictionary _lastAppliedLampStates = new(); + private readonly Dictionary _lastAppliedGiStates = new(); + private bool _sharedLampPlaybackActive; + private readonly Queue _audioQueue = new(); + private readonly HashSet _loggedUnresolvedMappingIds = new(StringComparer.Ordinal); + + private int _audioFilterChannels; + private PinMameAudioInfo _audioInfo; + private float[] _lastAudioFrame = {}; + private int _lastAudioFrameOffset; + private const int _maximalQueueSize = 10; + + private double _audioInputStart; + private double _audioOutputStart; + private int _audioNumSamplesInput; + private int _audioNumSamplesOutput; + + public bool _solenoidsEnabled; + public long _solenoidDelayStart; + private Dictionary _registeredMechs = new(); + private Dictionary _registeredMechNames = new(); + private HashSet _mechSwitches = new(); + + private bool _toggleSpeed = false; + private Keyboard _keyboard; + + private static long TimestampUsec => (Stopwatch.GetTimestamp() * 1_000_000) / Stopwatch.Frequency; + + private static readonly SemaphoreSlim PinMameStartStopGate = new SemaphoreSlim(1, 1); + private int _onInitCalled; + private int _stopRequested; + private int _playModeExitHandled; + private const int StopTimeoutMs = 10000; + + #endregion + + #region Lifecycle + + private void Awake() + { + // SimulationThreadComponent reads RequestedSwitches from Start(). Build these caches in Awake so + // their availability does not depend on Unity's undefined ordering between component Start calls. + UpdateCaches(true); + Logger.Info("Project audio sample rate: " + AudioSettings.outputSampleRate); + _keyboard = Keyboard.current; + } + + private void Start() + { + _lastAudioFrame = Array.Empty(); + _lastAudioFrameOffset = 0; + } + + #if UNITY_EDITOR + private void OnValidate() + { + if (!Application.isPlaying) UpdateCaches(false); + } + #endif + + private void Update() + { + if (_pinMame == null || !_isRunning) { + return; + } + + _pendingDispatchCallbacks.Clear(); + + lock (_dispatchQueue) { + while (_dispatchQueue.Count > 0) { + _pendingDispatchCallbacks.Add(_dispatchQueue.Dequeue()); + } + } + + foreach (var callback in _pendingDispatchCallbacks) { + try { + callback.Invoke(); + } + catch (Exception e) { + Logger.Error(e, "[PinMAME] Exception while processing main-thread dispatch callback."); + } + } + + // lamps + _pinMame.GetChangedLamps(_changedLamps); + foreach (var changedLamp in _changedLamps) { + lock (_outputStateLock) { + _sharedLampStates[changedLamp.Id] = changedLamp.Value; + } + if (!_sharedLampPlaybackActive && _pinMameIdToLampIdMapping.TryGetValue(changedLamp.Id, out var vpeLampIds)) { + foreach (var vpeLampId in vpeLampIds) { + LogChangedLampSample("lamp", changedLamp.Id, vpeLampId, changedLamp.Value, LampSource.Lamp); + OnLampChanged?.Invoke(this, new LampEventArgs(vpeLampId, changedLamp.Value)); + } + } else { + LogUnmappedLampSample("lamp", changedLamp.Id, changedLamp.Value, LampSource.Lamp); + } + } + + // gi + _pinMame.GetChangedGIs(_changedGIs); + foreach (var changedGi in _changedGIs) { + lock (_outputStateLock) { + _sharedGiStates[changedGi.Id] = changedGi.Value; + } + if (!_sharedLampPlaybackActive && _pinMameIdToGiIdMapping.TryGetValue(changedGi.Id, out var vpeGiIds)) { + foreach (var vpeLampId in vpeGiIds) { + LogChangedLampSample("gi", changedGi.Id, vpeLampId, changedGi.Value, LampSource.GI); + OnLampChanged?.Invoke(this, new LampEventArgs(vpeLampId, changedGi.Value, LampSource.GI)); + } + } else { + LogUnmappedLampSample("gi", changedGi.Id, changedGi.Value, LampSource.GI); + } + } + + // if (_keyboard != null && _keyboard.cKey.wasPressedThisFrame) + // { + // OnCoilChanged.Invoke(this, new CoilEventArgs("28", true)); + // OnCoilChanged.Invoke(this, new CoilEventArgs("28", false)); + // } + } + + private void EnqueueMainThreadDispatch(Action callback) + { + lock (_dispatchQueue) { + _dispatchQueue.Enqueue(callback); + if (!_dispatchQueueWarningIssued && _dispatchQueue.Count >= DispatchQueueWarningThreshold) { + _dispatchQueueWarningIssued = true; + Logger.Warn($"[PinMAME] Main-thread dispatch queue backlog reached {_dispatchQueue.Count} items."); + } + } + } + + private void LogChangedLampSample(string kind, int pinMameId, string vpeLampId, float value, LampSource source) + { + if (_loggedChangedLampSamples >= MaxLoggedChangedLampSamples) { + return; + } + + _loggedChangedLampSamples++; + Logger.Info( + $"[PinMAME] <= {kind} sample #{_loggedChangedLampSamples}: pinmameId={pinMameId}, " + + $"vpeId={vpeLampId}, source={source}, value={value}, sharedLampPlaybackActive={_sharedLampPlaybackActive}."); + } + + private void LogUnmappedLampSample(string kind, int pinMameId, float value, LampSource source) + { + if (_loggedUnmappedLampSamples >= MaxLoggedUnmappedLampSamples) { + return; + } + + _loggedUnmappedLampSamples++; + Logger.Warn( + $"[PinMAME] <= {kind} UNMAPPED sample #{_loggedUnmappedLampSamples}: pinmameId={pinMameId}, " + + $"source={source}, value={value}, sharedLampPlaybackActive={_sharedLampPlaybackActive}."); + } + + private int CopyCoilStates(ref SimulationState.Snapshot snapshot) + { + var index = 0; + foreach (var entry in _sharedCoilStates) { + if (index >= snapshot.CoilStates.Length) { + break; + } + + snapshot.CoilStates[index++] = new SimulationState.CoilState { + Id = entry.Key, + IsActive = entry.Value ? (byte)1 : (byte)0, + }; + } + + return index; + } + + private int CopyLampStates(ref SimulationState.Snapshot snapshot) + { + var index = 0; + foreach (var entry in _sharedLampStates) { + if (index >= snapshot.LampStates.Length) { + break; + } + + snapshot.LampStates[index++] = new SimulationState.LampState { + Id = entry.Key, + Value = entry.Value, + }; + } + + return index; + } + + private int CopyGiStates(ref SimulationState.Snapshot snapshot) + { + var index = 0; + foreach (var entry in _sharedGiStates) { + if (index >= snapshot.GIStates.Length) { + break; + } + + snapshot.GIStates[index++] = new SimulationState.GIState { + Id = entry.Key, + Value = entry.Value, + }; + } + + return index; + } + + private void OnDestroy() + { + #if UNITY_EDITOR + StopForPlayModeExit(); + #else + RequestStopGame("OnDestroy"); + #endif + if (_pinMame != null) { + _pinMame.OnGameStarted -= OnGameStarted; + _pinMame.OnGameEnded -= OnGameEnded; + _pinMame.OnDisplayAvailable -= OnDisplayRequested; + _pinMame.OnDisplayUpdated -= OnDisplayUpdated; + + if (!DisableAudio) + { + _pinMame.OnAudioAvailable -= OnAudioAvailable; + _pinMame.OnAudioUpdated -= OnAudioUpdated; + } + + _pinMame.OnMechAvailable -= OnMechAvailable; + _pinMame.OnMechUpdated -= OnMechUpdated; + _pinMame.OnSolenoidUpdated -= OnSolenoidUpdated; + _pinMame.IsKeyPressed -= IsKeyPressed; + } + _frameBuffer.Clear(); + lock (_outputStateLock) { + _sharedCoilStates.Clear(); + _solenoidsModulated = false; + _sharedLampStates.Clear(); + _sharedGiStates.Clear(); + } + _lastAppliedLampStates.Clear(); + _lastAppliedGiStates.Clear(); + _sharedLampPlaybackActive = false; + } + + private void OnDisable() + { + // In some Unity playmode/domain-reload configurations, OnDestroy isn't reliably called. + // Best-effort shutdown to avoid leaving a game running across sessions. + #if UNITY_EDITOR + StopForPlayModeExit(); + #else + RequestStopGame("OnDisable"); + #endif + } + + private void OnApplicationQuit() + { + #if UNITY_EDITOR + StopForPlayModeExit(); + #else + RequestStopGame("OnApplicationQuit"); + #endif + } + + public void StopForPlayModeExit() + { + #if UNITY_EDITOR + // With domain reload disabled this component survives the play session. Return editor callers to + // the complete catalog candidate snapshots instead of the runtime MappingConfig projection. + _runtimeCachesBuilt = false; + #endif + if (Interlocked.Exchange(ref _playModeExitHandled, 1) != 0) { + return; + } + + // Editor-only: called from playmode lifecycle hook. + // Do not call into native Stop() here (it blocks); the editor hook will stop PinMAME + // after we unsubscribe managed callbacks. + Interlocked.Exchange(ref _stopRequested, 1); + SetTimeFence(0.0); + // Stop polling outputs immediately to avoid concurrent calls into pinmame.dll + // while a stop is in progress (pinmame APIs are not guaranteed thread-safe). + _isRunning = false; + Logger.Info($"[PinMAME] StopForPlayModeExit: unsubscribing callbacks ({name}#{UnityObjectId.Get(this)})"); + try { + if (_pinMame == null) { + return; + } + + _pinMame.OnGameStarted -= OnGameStarted; + _pinMame.OnGameEnded -= OnGameEnded; + _pinMame.OnDisplayAvailable -= OnDisplayRequested; + _pinMame.OnDisplayUpdated -= OnDisplayUpdated; + _pinMame.OnMechAvailable -= OnMechAvailable; + _pinMame.OnMechUpdated -= OnMechUpdated; + _pinMame.OnSolenoidUpdated -= OnSolenoidUpdated; + _pinMame.IsKeyPressed -= IsKeyPressed; + _pinMame.OnAudioAvailable -= OnAudioAvailable; + _pinMame.OnAudioUpdated -= OnAudioUpdated; + } + catch { } + } + + private void RequestStopGame(string reason) + { + #if UNITY_EDITOR + // In the editor, playmode shutdown is handled by StopForPlayModeExit + editor lifecycle hook. + // Calling native Stop() from here can race and destabilize the editor. + StopForPlayModeExit(); + return; + #endif + + try { + SetTimeFence(0.0); + + if (Interlocked.Exchange(ref _stopRequested, 1) != 0) { + return; + } + _isRunning = false; + + if (!PinMame.PinMame.IsRunning) { + return; + } + + } catch (Exception e) { + Logger.Warn(e, $"[PinMAME] StopGame ({reason}) failed."); + return; + } + + // Avoid racing StartGame on re-entering play mode. + if (!PinMameStartStopGate.Wait(0)) { + Logger.Warn($"[PinMAME] StopGame ({reason}) skipped: start/stop already in progress."); + return; + } + + // Never block the Unity main thread during playmode exit. + // PinMAME shutdown is best-effort here; OnInit() and the editor lifecycle hook + // will recover/ensure shutdown before starting a new ROM. + Logger.Warn($"[PinMAME] StopGame ({reason}) requested. RunningGame={_pinMame?.RunningGame}"); + + try { + if (_pinMame != null) { + // Unsubscribe first to keep the native thread from doing work in managed callbacks during shutdown. + _pinMame.OnGameStarted -= OnGameStarted; + _pinMame.OnGameEnded -= OnGameEnded; + _pinMame.OnDisplayAvailable -= OnDisplayRequested; + _pinMame.OnDisplayUpdated -= OnDisplayUpdated; + _pinMame.OnMechAvailable -= OnMechAvailable; + _pinMame.OnMechUpdated -= OnMechUpdated; + _pinMame.OnSolenoidUpdated -= OnSolenoidUpdated; + _pinMame.IsKeyPressed -= IsKeyPressed; + _pinMame.OnAudioAvailable -= OnAudioAvailable; + _pinMame.OnAudioUpdated -= OnAudioUpdated; + } + } + catch (Exception e) { + Logger.Warn(e, $"[PinMAME] Unsubscribe during shutdown ({reason}) failed."); + } + + // PinmameStop may block (joins the emulation thread). Never do that on the Unity main thread. + System.Threading.Tasks.Task.Run(() => { + try { + _pinMame?.StopGame(); + } catch (Exception e) { + Logger.Warn(e, $"[PinMAME] StopGame ({reason}) failed."); + } finally { + PinMameStartStopGate.Release(); + } + }); + } + + public async Task OnInit(Player player, TableApi tableApi, BallManager ballManager, CancellationToken ct) + { + if (Interlocked.Exchange(ref _onInitCalled, 1) != 0) { + Logger.Warn($"[PinMAME] OnInit called more than once on {name}, ignoring."); + return; + } + + string vpmPath = null; + _ballManager = ballManager; + _playfieldComponent = GetComponentInChildren(); + + #if (UNITY_IOS || UNITY_ANDROID) && !UNITY_EDITOR + vpmPath = Path.Combine(Application.persistentDataPath, "pinmame"); + Directory.CreateDirectory(Path.Combine(vpmPath, "roms")); + + byte[] data = null; + + #if UNITY_IOS + data = File.ReadAllBytes(Path.Combine(Application.streamingAssetsPath, $"{romId}.zip")); + #else + UnityWebRequest webRequest = new UnityWebRequest(Path.Combine(Application.streamingAssetsPath, $"{romId}.zip")); + + webRequest.downloadHandler = new DownloadHandlerBuffer(); + webRequest.SendWebRequest(); + + while (!webRequest.isDone) { }; + + data = webRequest.downloadHandler.data; + #endif + + File.WriteAllBytes(Path.Combine(vpmPath, "roms", $"{romId}.zip"), data); + #else + vpmPath = PinMameFolders.DataFolder; + #endif + + await PinMameStartStopGate.WaitAsync(ct); + try { + Logger.Info($"[PinMAME] OnInit {name}: romId={romId}, sampleRate={AudioSettings.outputSampleRate}"); + + #if UNITY_EDITOR + // With Domain Reload disabled, PinMAME (native + managed singleton) persists across play sessions. + // Even when RunState reports 0, the native game thread might still exist and need joining. + // Calling Stop here is a cheap no-op if nothing is running, and a critical cleanup if something is. + var preStopState = PinMame.PinMame.RunState; + var preStopSw = Stopwatch.StartNew(); + try { + PinMame.PinMame.StopRunningGame(); + } catch (Exception e) { + Logger.Warn(e, "[PinMAME] Pre-start StopRunningGame failed."); + } + // Wait for a clean stopped state before starting a new ROM. + var preStopWaitSw = Stopwatch.StartNew(); + while (PinMame.PinMame.RunState != 0 && preStopWaitSw.ElapsedMilliseconds < StopTimeoutMs) { + await Task.Delay(10, ct); + } + Logger.Debug($"[PinMAME] Editor pre-stop: initialRunState={preStopState}, stopCallMs={preStopSw.ElapsedMilliseconds}, waitMs={preStopWaitSw.ElapsedMilliseconds}, finalRunState={PinMame.PinMame.RunState}"); + if (PinMame.PinMame.RunState != 0) { + Logger.Error($"[PinMAME] Cannot start ROM; PinMAME did not reach stopped state (RunState={PinMame.PinMame.RunState})."); + return; + } + + // Force a fresh managed wrapper each init. This avoids cross-session managed state + // (callbacks, buffers) bleeding into the next play run when the host keeps assemblies loaded. + PinMame.PinMame.ResetInstance(); + #endif + + // If we exited play mode recently, a background stop may still be running. + // Starting a new ROM while the previous stop is cleaning up can lead to a "started" + // state with no progress/output on the next run. + var wasRunning = PinMame.PinMame.IsRunning; + var wasStopping = PinMame.PinMame.IsStopInProgress; + var swStop = Stopwatch.StartNew(); + while ((PinMame.PinMame.IsStopInProgress || PinMame.PinMame.IsRunning) && swStop.ElapsedMilliseconds < StopTimeoutMs) { + await Task.Delay(10, ct); + } + Logger.Debug($"[PinMAME] Pre-start wait: {swStop.ElapsedMilliseconds}ms (WasRunning={wasRunning}, WasStopping={wasStopping}, IsRunning={PinMame.PinMame.IsRunning}, RunState={PinMame.PinMame.RunState}, StopInProgress={PinMame.PinMame.IsStopInProgress})"); + + _pinMame = PinMame.PinMame.Instance(PinMameAudioFormat.AudioFormatFloat, AudioSettings.outputSampleRate, vpmPath); + // Re-apply config each init (important when Domain Reload is disabled). + _pinMame.ApplyConfig(); + + // Prevent duplicate subscriptions if OnInit is invoked more than once. + _pinMame.OnGameStarted -= OnGameStarted; + _pinMame.OnGameEnded -= OnGameEnded; + _pinMame.OnDisplayAvailable -= OnDisplayRequested; + _pinMame.OnDisplayUpdated -= OnDisplayUpdated; + _pinMame.OnMechAvailable -= OnMechAvailable; + _pinMame.OnMechUpdated -= OnMechUpdated; + _pinMame.OnSolenoidUpdated -= OnSolenoidUpdated; + _pinMame.IsKeyPressed -= IsKeyPressed; + _pinMame.OnAudioAvailable -= OnAudioAvailable; + _pinMame.OnAudioUpdated -= OnAudioUpdated; + + // If domain reload is disabled or a previous table didn't shut down cleanly, + // PinMAME can still be running here. + if (PinMame.PinMame.IsRunning) { + Logger.Warn($"[PinMAME] A game is already running (RunningGame={_pinMame.RunningGame}); stopping it before starting a new one."); + try { + _pinMame.StopGame(); + } + catch (Exception e) { + Logger.Warn(e, "[PinMAME] StopGame failed while recovering from already running state."); + } + + var sw = Stopwatch.StartNew(); + while (PinMame.PinMame.IsRunning && sw.ElapsedMilliseconds < StopTimeoutMs) { + await Task.Delay(10, ct); + } + if (PinMame.PinMame.IsRunning) { + Logger.Warn("[PinMAME] Failed to stop running game within timeout; attempting StartGame anyway."); + } + } + + #if !((UNITY_IOS || UNITY_ANDROID) && !UNITY_EDITOR) + PrepareDesktopFolders(); + #endif + + _pinMame.SetHandleKeyboard(false); + _pinMame.SetHandleMechanics(DisableMechs ? 0 : 0xFF); + ApplyDmdModePreference(); + SetTimeFence(0.01); + + _pinMame.OnGameStarted += OnGameStarted; + _pinMame.OnGameEnded += OnGameEnded; + _pinMame.OnDisplayAvailable += OnDisplayRequested; + _pinMame.OnDisplayUpdated += OnDisplayUpdated; + + if (!DisableAudio) { + _pinMame.OnAudioAvailable += OnAudioAvailable; + _pinMame.OnAudioUpdated += OnAudioUpdated; + } + + _pinMame.OnMechAvailable += OnMechAvailable; + _pinMame.OnMechUpdated += OnMechUpdated; + _pinMame.OnSolenoidUpdated += OnSolenoidUpdated; + _pinMame.IsKeyPressed += IsKeyPressed; + + _player = player; + _solenoidsEnabled = SolenoidDelay == 0; + + try { + _pinMame.StartGame(romId); + Logger.Debug($"[PinMAME] StartGame returned. RunState={PinMame.PinMame.RunState}"); + } + catch (InvalidOperationException e) when (e.Message != null && e.Message.Contains("status=GAME_ALREADY_RUNNING")) + { + Logger.Warn(e, "[PinMAME] StartGame reported already running; stopping and retrying once."); + try { + _pinMame.StopGame(); + await Task.Delay(100, ct); + _pinMame.StartGame(romId); + Logger.Debug($"[PinMAME] StartGame retry returned. RunState={PinMame.PinMame.RunState}"); + } + catch (Exception retryEx) { + Logger.Error(retryEx); + } + } + catch (Exception e) { + Logger.Error(e); + } + + } finally { + PinMameStartStopGate.Release(); + } + } + + /// + /// Points PinMAME at the ROM folder and brings over Visual PinMAME's state for the game. + /// + private void PrepareDesktopFolders() + { + try { + PinMameFolders.ApplyRomFolder(_pinMame); + } catch (InvalidOperationException e) { + Logger.Error(e, "[PinMAME] Could not set the ROM folder."); + } + PinMameFolders.ImportFromVpm(romId); + + // Only a hint: PinMAME's audit reports clones whose files are all identical to their + // parent's as missing, although they run. StartGame reports a ROM that really is missing. + try { + if (!_pinMame.GetGame(romId).RomFound) { + Logger.Warn($"[PinMAME] ROM \"{romId}\" may be missing from {_pinMame.RomPath}. If the game doesn't start, set the ROM folder to where your ROMs are."); + } + } catch (InvalidOperationException) { + // unknown game: StartGame reports it + } + } + + public void ToggleSpeed() + { + Logger.Info("[PinMAME] Toggle speed."); + + _pinMame.SetHandleKeyboard(true); + _toggleSpeed = true; + } + + private void OnGameStarted() + { + Logger.Info("[PinMAME] Game started."); + _isRunning = true; + + _solenoidDelayStartTimestampUsec = TimestampUsec; + _solenoidDelayStart = _solenoidDelayStartTimestampUsec / 1000; + + try { + SendInitialSwitches(); + SendMechs(); + } + + catch(Exception e) { + Logger.Error($"[PinMAME] OnGameStarted: {e.Message}"); + } + + EnqueueMainThreadDispatch(() => OnStarted?.Invoke(this, EventArgs.Empty)); + } + + private void OnGameEnded() + { + // Native can report ended more than once during teardown; keep it idempotent. + if (_isRunning) { + Logger.Info("[PinMAME] Game ended."); + } + _isRunning = false; + while (_simulationCoilDispatchQueue.TryDequeue(out _)) { + if (Interlocked.Decrement(ref _simulationCoilDispatchQueueSize) < 0) { + Interlocked.Exchange(ref _simulationCoilDispatchQueueSize, 0); + } + } + lock (_outputStateLock) { + _sharedCoilStates.Clear(); + _solenoidsModulated = false; + _sharedLampStates.Clear(); + _sharedGiStates.Clear(); + } + _lastAppliedLampStates.Clear(); + _lastAppliedGiStates.Clear(); + _sharedLampPlaybackActive = false; + } + + private void UpdateCaches(bool runtime) + { + var requestedLamps = new List(); + var requestedCoils = new List(); + var requestedSwitches = new List(); + _requestedLamps = Array.Empty(); + _pinMameIdToLampIdMapping.Clear(); + _pinMameIdToGiIdMapping.Clear(); + + _coils.Clear(); + _requestedCoils = Array.Empty(); + _pinMameIdToCoilIdMapping.Clear(); + _coilIdToPinMameIdMapping.Clear(); + + _switches.Clear(); + _requestedSwitches = Array.Empty(); + _pinMameIdToSwitchIdMappings.Clear(); + _switchIdToPinMameIdMappings.Clear(); + + var mappingConfig = GetComponentInParent()?.MappingConfig; + if (mappingConfig == null) { + _runtimeCachesBuilt = runtime; + return; + } + + foreach (var mapping in mappingConfig.Switches) { + var @switch = new GamelogicEngineSwitch(mapping.Id) { + Description = mapping.Description, + NormallyClosed = mapping.IsNormallyClosed, + InputActionHint = mapping.Source == SwitchSource.InputSystem ? mapping.InputAction : string.Empty, + InputMapHint = mapping.Source == SwitchSource.InputSystem ? mapping.InputActionMap : string.Empty, + ConstantHint = mapping.Source != SwitchSource.Constant + ? SwitchConstantHint.None + : mapping.Constant == SwitchConstant.Open ? SwitchConstantHint.AlwaysOpen : SwitchConstantHint.AlwaysClosed, + }; + requestedSwitches.Add(@switch); + _switches[@switch.Id] = @switch; + + var isNumericMappingId = int.TryParse(@switch.Id, NumberStyles.Integer, CultureInfo.InvariantCulture, out _); + if (TryResolveSwitchId(@switch.Id, out var pinMameId)) { + _pinMameIdToSwitchIdMappings[pinMameId] = @switch.Id; + _switchIdToPinMameIdMappings[@switch.Id] = pinMameId; + + // add mappings with prefixed 0. + if (isNumericMappingId && pinMameId is >= 0 and < 10) { + _switchIdToPinMameIdMappings["0" + @switch.Id] = pinMameId; + _switchIdToPinMameIdMappings["00" + @switch.Id] = pinMameId; + + _switches["0" + @switch.Id] = @switch; + _switches["00" + @switch.Id] = @switch; + } + if (isNumericMappingId && pinMameId is >= 0 and < 100) { + _switchIdToPinMameIdMappings["0" + @switch.Id] = pinMameId; + _switches["0" + @switch.Id] = @switch; + } + } else { + if (runtime) LogUnresolvedMappingId("switch", @switch.Id); + } + } + + foreach (var mapping in mappingConfig.Coils) { + var coil = new GamelogicEngineCoil(mapping.Id) { + Description = mapping.Description, + IsLamp = mapping.Destination == CoilDestination.Lamp, + }; + _coils[coil.Id] = coil; + requestedCoils.Add(coil); + + if (TryResolveCoilId(coil.Id, out var pinMameId)) { + AddMappingTarget(_pinMameIdToCoilIdMapping, pinMameId, coil.Id); + _coilIdToPinMameIdMapping[coil.Id] = pinMameId; + } else { + if (runtime) LogUnresolvedMappingId("coil", coil.Id); + } + } + + foreach (var mapping in mappingConfig.Lamps.Where(mapping => !mapping.IsCoil)) { + var lamp = new GamelogicEngineLamp(mapping.Id) { + Description = mapping.Description, + Channel = mapping.Channel, + FadingSteps = mapping.FadingSteps, + Source = mapping.Source, + Type = mapping.Type, + }; + requestedLamps.Add(lamp); + + if (int.TryParse(lamp.Id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pinMameId)) { + AddMappingTarget(lamp.Source == LampSource.GI ? _pinMameIdToGiIdMapping : _pinMameIdToLampIdMapping, pinMameId, lamp.Id); + } + } + + CollapseNumericSpellings(_pinMameIdToCoilIdMapping); + CollapseNumericSpellings(_pinMameIdToLampIdMapping); + CollapseNumericSpellings(_pinMameIdToGiIdMapping); + + _requestedSwitches = requestedSwitches.GroupBy(item => item.Id, StringComparer.Ordinal).Select(group => group.Last()).ToArray(); + _requestedCoils = requestedCoils.GroupBy(item => item.Id, StringComparer.Ordinal).Select(group => group.Last()).ToArray(); + _requestedLamps = requestedLamps.GroupBy(item => new { item.Id, item.Source, item.Channel }).Select(group => group.Last()).ToArray(); + _runtimeCachesBuilt = runtime; + } + + private static void AddMappingTarget(Dictionary> map, int pinMameId, string mappingId) + { + if (!map.TryGetValue(pinMameId, out var mappingIds)) { + map[pinMameId] = mappingIds = new List(1); + } + if (!mappingIds.Contains(mappingId)) { + mappingIds.Add(mappingId); + } + } + + /// + /// Output events go out with each mapping's own ID, since the players key state and wires by + /// it. When one output has several numeric spellings ("1" and "01"), they're sent once as + /// PinMAME's number instead: the coil and lamp players register "01" under "1" too, so sending + /// both would actuate the "01" device twice. + /// + private static void CollapseNumericSpellings(Dictionary> map) + { + foreach (var (pinMameId, mappingIds) in map) { + var numericCount = mappingIds.Count(IsNumericId); + if (numericCount < 2) { + continue; + } + mappingIds.RemoveAll(IsNumericId); + mappingIds.Insert(0, pinMameId.ToString(CultureInfo.InvariantCulture)); + } + } + + private static bool IsNumericId(string id) => int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out _); + + private bool TryResolveSwitchId(string id, out int pinMameId) + { + return int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out pinMameId) + || PinMameLegacyAliasResolver.TryResolveSwitch(machineId, romId, _legacyGameId, id, out pinMameId); + } + + private bool TryResolveCoilId(string id, out int pinMameId) + { + return int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out pinMameId) + || PinMameLegacyAliasResolver.TryResolveCoil(machineId, romId, _legacyGameId, id, out pinMameId); + } + + private void LogUnresolvedMappingId(string kind, string id) + { + if (_loggedUnresolvedMappingIds.Add($"{kind}:{id}")) { + if (string.Equals(kind, "coil", StringComparison.Ordinal) && PinMameLegacyAliasResolver.IsFlipperCoilAlias(id)) { + Logger.Info($"[PinMAME] Legacy flipper coil mapping '{id}' has no direct PinMAME output for machine '{machineId}' and ROM '{romId}'; a table wire may drive it instead."); + } else { + Logger.Warn($"[PinMAME] {kind} '{id}' isn't a PinMAME {kind} of machine '{machineId}' (ROM '{romId}'), so PinMAME won't drive it."); + } + } + } + + #endregion + + #region Displays + + public void RequestDisplayFrameFormat(string displayId, DisplayFrameFormat format) + { + if (!string.IsNullOrWhiteSpace(displayId) && !displayId.StartsWith(DmdPrefix, StringComparison.OrdinalIgnoreCase)) { + return; + } + + if (format != DisplayFrameFormat.Dmd2 && format != DisplayFrameFormat.Dmd4 && format != DisplayFrameFormat.Dmd8) { + return; + } + + if (_requestedDmdFrameFormat == format) { + return; + } + + _requestedDmdFrameFormat = format; + ApplyDmdModePreference(); + } + + private void OnDisplayRequested(int index, int displayCount, PinMameDisplayLayout displayLayout) + { + if (displayLayout.IsDmd) { + EnqueueMainThreadDispatch(() => + OnDisplaysRequested?.Invoke(this, new RequestedDisplays( + new DisplayConfig($"{DmdPrefix}{index}", displayLayout.Width, displayLayout.Height, false, Tint, UnlitTint)))); + + lock (_displayStateLock) { + _frameBuffer[index] = new byte[displayLayout.Width * displayLayout.Height]; + } + + } else { + EnqueueMainThreadDispatch(() => + OnDisplaysRequested?.Invoke(this, new RequestedDisplays( + new DisplayConfig($"{SegDispPrefix}{index}", displayLayout.Length, 1)))); + + lock (_displayStateLock) { + _frameBuffer[index] = new byte[displayLayout.Length * 2]; + } + Logger.Info($"[PinMAME] Display {SegDispPrefix}{index} is of type {displayLayout.Type} at {displayLayout.Length} wide."); + } + } + + private void OnDisplayUpdated(int index, IntPtr framePtr, PinMameDisplayLayout displayLayout) + { + MarkPinMameCallbackActivity(); + + if (displayLayout.IsDmd) { + UpdateDmd(index, displayLayout, framePtr); + + } else { + UpdateSegDisp(index, displayLayout, framePtr); + } + } + + private void UpdateDmd(int index, PinMameDisplayLayout displayLayout, IntPtr framePtr) + { + if (framePtr == IntPtr.Zero) { + Logger.Warn($"[PinMAME] Dropping DMD frame for index {index} because frame pointer is null (layout: {displayLayout})."); + return; + } + + byte[] frameBuffer; + lock (_displayStateLock) { + if (!_frameBuffer.TryGetValue(index, out frameBuffer)) { + Logger.Warn($"[PinMAME] Dropping DMD frame for unknown index {index} (layout: {displayLayout})."); + return; + } + } + + // Snapshot into a fresh array: the native PinMAME thread can fire UpdateDmd again before + // the queued dispatch runs on the main thread, so the frame handed to subscribers must not + // be the reused _frameBuffer (otherwise it can be overwritten / torn mid-read). + var frame = new byte[frameBuffer.Length]; + Marshal.Copy(framePtr, frame, 0, frame.Length); + + EnqueueMainThreadDispatch(() => OnDisplayUpdateFrame?.Invoke(this, + new DisplayFrameData($"{DmdPrefix}{index}", GetRequestedDmdDisplayFrameFormat(displayLayout), frame))); + } + + private void UpdateSegDisp(int index, PinMameDisplayLayout displayLayout, IntPtr framePtr) + { + if (framePtr == IntPtr.Zero) { + Logger.Warn($"[PinMAME] Dropping segmented display frame for index {index} because frame pointer is null (layout: {displayLayout})."); + return; + } + + byte[] frameBuffer; + lock (_displayStateLock) { + if (!_frameBuffer.TryGetValue(index, out frameBuffer)) { + Logger.Warn($"[PinMAME] Dropping segmented display frame for unknown index {index} (layout: {displayLayout})."); + return; + } + } + + // Snapshot into a fresh array (see UpdateDmd) so a follow-up native callback can't + // overwrite a frame already queued for the main thread. + var frame = new byte[frameBuffer.Length]; + Marshal.Copy(framePtr, frame, 0, displayLayout.Length * 2); + + EnqueueMainThreadDispatch(() => OnDisplayUpdateFrame?.Invoke(this, + new DisplayFrameData($"{SegDispPrefix}{index}", GetDisplayFrameFormat(displayLayout), frame))); + } + + public static DisplayFrameFormat GetDisplayFrameFormat(PinMameDisplayLayout layout) + { + if (layout.IsDmd) { + return DisplayFrameFormat.Dmd8; + } + + switch (layout.Type) { + case PinMameDisplayType.Seg8: // 7 segments and comma + case PinMameDisplayType.Seg7SC: // 7 segments, small, with comma + case PinMameDisplayType.Seg8D: // 7 segments and period + case PinMameDisplayType.Seg7: // 7 segments + case PinMameDisplayType.Seg7S: // 7 segments, small + case PinMameDisplayType.Seg87: // 7 segments, comma every three + case PinMameDisplayType.Seg87F: // 7 segments, forced comma every three + case PinMameDisplayType.Seg10: // 9 segments and comma + case PinMameDisplayType.Seg9: // 9 segments + case PinMameDisplayType.Seg98: // 9 segments, comma every three + case PinMameDisplayType.Seg98F: // 9 segments, forced comma every three + case PinMameDisplayType.Seg16: // 16 segments + case PinMameDisplayType.Seg16R: // 16 segments with comma and period reversed + case PinMameDisplayType.Seg16N: // 16 segments without commas + case PinMameDisplayType.Seg16D: // 16 segments with periods only + case PinMameDisplayType.Seg16S: // 16 segments with split top and bottom line + case PinMameDisplayType.Seg8H: + case PinMameDisplayType.Seg7H: + case PinMameDisplayType.Seg87H: + case PinMameDisplayType.Seg87FH: + case PinMameDisplayType.Seg7SH: + case PinMameDisplayType.Seg7SCH: + case PinMameDisplayType.Seg7 | PinMameDisplayType.NoDisp: + return DisplayFrameFormat.Segment; + + case PinMameDisplayType.Video: + break; + + case PinMameDisplayType.SegAll: + case PinMameDisplayType.Import: + case PinMameDisplayType.SegMask: + case PinMameDisplayType.SegHiBit: + case PinMameDisplayType.SegRev: + case PinMameDisplayType.DmdNoAA: + case PinMameDisplayType.NoDisp: + throw new ArgumentOutOfRangeException(nameof(layout), layout, null); + + default: + throw new ArgumentOutOfRangeException(nameof(layout), layout, null); + } + + throw new NotImplementedException($"Still unsupported segmented display format: {layout}."); + } + + private DisplayFrameFormat GetRequestedDmdDisplayFrameFormat(PinMameDisplayLayout layout) + { + if (_requestedDmdFrameFormat == DisplayFrameFormat.Dmd8) { + return DisplayFrameFormat.Dmd8; + } + + return GetRawDmdDisplayFrameFormat(layout); + } + + private static DisplayFrameFormat GetRawDmdDisplayFrameFormat(PinMameDisplayLayout layout) + { + var levels = layout.Levels?.Count ?? 0; + if ((levels > 0 && levels <= 4) || (layout.Depth > 0 && layout.Depth <= 2)) { + return DisplayFrameFormat.Dmd2; + } + + if ((levels > 0 && levels <= 16) || (layout.Depth > 0 && layout.Depth <= 4)) { + return DisplayFrameFormat.Dmd4; + } + + return DisplayFrameFormat.Dmd8; + } + + private void ApplyDmdModePreference() + { + if (_pinMame == null) { + return; + } + + var rawMode = _requestedDmdFrameFormat == DisplayFrameFormat.Dmd2 || _requestedDmdFrameFormat == DisplayFrameFormat.Dmd4; + try { + _pinMame.SetDmdMode(rawMode ? PinMameDmdMode.Raw : PinMameDmdMode.Brightness); + Logger.Info($"[PinMAME] DMD frame mode set to {(rawMode ? "raw" : "brightness")} for {_requestedDmdFrameFormat} output."); + } catch (Exception exception) { + Logger.Warn(exception, "[PinMAME] Could not set DMD frame mode."); + } + } + + public void DisplayChanged(DisplayFrameData displayFrameData) + { + } + + #endregion + + #region Audio + + private int OnAudioAvailable(PinMameAudioInfo audioInfo) + { + Logger.Info("Game audio available: " + audioInfo); + + _audioInfo = audioInfo; + return _audioInfo.SamplesPerFrame; + } + + private int OnAudioUpdated(IntPtr framePtr, int frameSize) + { + if (_audioFilterChannels == 0) { + // don't know how many channels yet + return _audioInfo.SamplesPerFrame; + } + + _audioNumSamplesInput += frameSize; + if (_audioNumSamplesInput > 100000) { + // var delta = AudioSettings.dspTime - _audioInputStart; + // var queueMs = System.Math.Round(_audioQueue.Count * (double)_audioInfo.SamplesPerFrame / _audioInfo.SampleRate * 1000); + // Logger.Info($"INPUT: {System.Math.Round(_audioNumSamplesInput / delta)} - {_audioQueue.Count} in queue ({queueMs}ms)"); + _audioInputStart = AudioSettings.dspTime; + _audioNumSamplesInput = 0; + } + + float[] frame; + if (_audioFilterChannels == _audioInfo.Channels) { // n channels -> n channels + frame = new float[frameSize * 2]; + unsafe { + var src = (float*)framePtr; + for (var i = 0; i < frameSize * 2; i++) { + frame[i] = src[i]; + } + } + } else if (_audioFilterChannels > _audioInfo.Channels) { // 1 channel -> 2 channels + frame = new float[frameSize * 2]; + unsafe { + var src = (float*)framePtr; + for (var i = 0; i < frameSize; i++) { + frame[i * 2] = src[i]; + frame[i * 2 + 1] = frame[i * 2]; + } + } + } else { // 2 channels -> 1 channel + frame = new float[frameSize / 2]; + unsafe { + var src = (float*)framePtr; + for (var i = 0; i < frameSize; i += 2) { + frame[i] = src[i]; + } + } + } + + lock (_audioQueue) { + if (_audioQueue.Count >= _maximalQueueSize) { + _audioQueue.Clear(); + Logger.Error("Clearing full audio frame queue."); + } + _audioQueue.Enqueue(frame); + } + + return _audioInfo.SamplesPerFrame; + } + + private void OnAudioFilterRead(float[] data, int channels) + { + _audioNumSamplesOutput += data.Length / channels; + if (_audioNumSamplesOutput > 100000) { + //var delta = AudioSettings.dspTime - _audioOutputStart; + //Logger.Info($"OUTPUT: {System.Math.Round(_audioNumSamplesOutput / delta)}"); + _audioOutputStart = AudioSettings.dspTime; + _audioNumSamplesOutput = 0; + } + + if (_audioFilterChannels == 0) { + _audioFilterChannels = channels; + Logger.Info($"Creating audio on {channels} channels."); + return; + } + + if (_audioQueue.Count == 0) { + if (!DisableAudio) { + Logger.Info("Filtering audio but nothing to de-queue."); + } + return; + } + + + const int size = sizeof(float); + var dataOffset = 0; + var lastFrameSize = _lastAudioFrame.Length - _lastAudioFrameOffset; + if (data.Length >= lastFrameSize) { + if (lastFrameSize > 0) { + Buffer.BlockCopy(_lastAudioFrame, _lastAudioFrameOffset * size, data, 0, lastFrameSize * size); + dataOffset += lastFrameSize; + } + _lastAudioFrame = Array.Empty(); + _lastAudioFrameOffset = 0; + + lock (_audioQueue) { + while (dataOffset < data.Length && _audioQueue.Count > 0) { + var frame = _audioQueue.Dequeue(); + if (frame.Length <= data.Length - dataOffset) { + Buffer.BlockCopy(frame, 0, data, dataOffset * size, frame.Length * size); + dataOffset += frame.Length; + + } else { + Buffer.BlockCopy(frame, 0, data, dataOffset * size, (data.Length - dataOffset) * size); + _lastAudioFrame = frame; + _lastAudioFrameOffset = data.Length - dataOffset; + dataOffset = data.Length; + } + } + } + + } else { + Buffer.BlockCopy(_lastAudioFrame, _lastAudioFrameOffset * size, data, 0, data.Length * size); + _lastAudioFrameOffset += data.Length; + } + } + + #endregion + + #region Coils + + public void SetCoil(string n, bool value) + { + if (int.TryParse(n, out var coilId)) { + lock (_outputStateLock) { + _sharedCoilStates[coilId] = value; + } + } + OnCoilChanged?.Invoke(this, new CoilEventArgs(n, value)); + } + + public bool GetCoil(string id) + { + return _player != null && _player.CoilStatuses.ContainsKey(id) && _player.CoilStatuses[id]; + } + + private void OnSolenoidUpdated(int id, int value) + { + MarkPinMameCallbackActivity(); + + // value is 0/1 for binary coils, 0-255 for PWM-integrated (modulated) coils + if (value > 1) { + _solenoidsModulated = true; + } + var isActive = value > 0; + // normalized strength for the (main-thread) coil pipeline; the sim-thread path + // and the shared snapshot stay boolean on/off + var intensity = _solenoidsModulated ? System.Math.Min(value, 255) / 255f : (isActive ? 1f : 0f); + + if (_pinMameIdToCoilIdMapping.TryGetValue(id, out var coilIds)) { + if (!_solenoidsEnabled) { + _solenoidsEnabled = TimestampUsec - _solenoidDelayStartTimestampUsec >= (long)(SolenoidDelay * 1000f); + + if (_solenoidsEnabled) { + Logger.Info($"Solenoids enabled, {SolenoidDelay}ms passed"); + } + } + + if (_solenoidsEnabled) { + lock (_outputStateLock) { + _sharedCoilStates[id] = isActive; + } + + foreach (var coilId in coilIds) { + if (Logger.IsDebugEnabled) { + Logger.Debug($"[PinMAME] <= coil {coilId} : {value} | {(_coils.TryGetValue(coilId, out var coil) ? coil.Description : string.Empty)}"); + } + if (ShouldDispatchSimulationCoil(coilId)) { + _simulationCoilDispatchQueue.Enqueue(new CoilEventArgs(coilId, isActive)); + var simulationCoilQueueSize = Interlocked.Increment(ref _simulationCoilDispatchQueueSize); + if (!_simulationCoilQueueWarningIssued && simulationCoilQueueSize >= MaxSimulationCoilDispatchQueueSize / 2) { + _simulationCoilQueueWarningIssued = true; + Logger.Warn($"[PinMAME] Simulation coil dispatch backlog reached {simulationCoilQueueSize} items."); + } + if (simulationCoilQueueSize > MaxSimulationCoilDispatchQueueSize) { + if (_simulationCoilDispatchQueue.TryDequeue(out _)) { + if (Interlocked.Decrement(ref _simulationCoilDispatchQueueSize) < 0) { + Interlocked.Exchange(ref _simulationCoilDispatchQueueSize, 0); + } + if (!_simulationCoilQueueDropWarningIssued) { + _simulationCoilQueueDropWarningIssued = true; + Logger.Warn($"[PinMAME] Simulation coil dispatch queue exceeded {MaxSimulationCoilDispatchQueueSize} items. Dropping oldest coil callbacks."); + } + } + } + } + + EnqueueMainThreadDispatch(() => OnCoilChanged?.Invoke(this, new CoilEventArgs(coilId, intensity))); + } + } + else { + if (Logger.IsDebugEnabled) { + Logger.Debug($"[PinMAME] <= solenoids disabled, coil {id} : {value}"); + } + } + } + else { + Logger.Warn($"[PinMAME] <= coil UNMAPPED {id}: {isActive}"); + } + } + + #endregion + + #region Lamps + + public void SetLamp(string id, float value, bool isCoil = false, LampSource source = LampSource.Lamp) + { + if (int.TryParse(id, out var lampId)) { + lock (_outputStateLock) { + if (source == LampSource.GI) { + _sharedGiStates[lampId] = value; + } else { + _sharedLampStates[lampId] = value; + } + } + } + OnLampChanged?.Invoke(this, new LampEventArgs(id, value, isCoil, source)); + } + + public LampState GetLamp(string id) + { + return _player != null && _player.LampStatuses.ContainsKey(id) ? _player.LampStatuses[id] : LampState.Default; + } + + #endregion + + #region Switches + + public void SendInitialSwitches() + { + var switches = _player.SwitchStatuses; + Logger.Info("[PinMAME] Sending initial switch statuses..."); + foreach (var id in switches.Keys) { + var isClosed = switches[id].IsSwitchClosed; + // skip open switches + if (!isClosed) { + continue; + } + if (_switches.TryGetValue(id, out var @switch) + && _switchIdToPinMameIdMappings.TryGetValue(@switch.Id, out var pinMameId) + && !_mechSwitches.Contains(pinMameId)) { + Logger.Info($"[PinMAME] => sw {id} ({@switch.Id}): {true} | {@switch.Description}"); + + _pinMame.SetSwitch(pinMameId, true); + } + } + } + + public void Switch(string id, bool isClosed) + { + if (_switches.TryGetValue(id, out var sw)) { + if (_switchIdToPinMameIdMappings.TryGetValue(sw.Id, out var pinMameId) && _mechSwitches.Contains(pinMameId)) { + // mech switches are triggered internally by pinmame. + return; + } + if (_pinMame != null && _isRunning) { + if (Logger.IsDebugEnabled) { + Logger.Debug($"[PinMAME] => sw {id}: {isClosed} | {sw.Description}"); + } + if (_switchIdToPinMameIdMappings.TryGetValue(sw.Id, out pinMameId)) { + _pinMame.SetSwitch(pinMameId, isClosed); + } + } + } else if (id == "s_spawn_ball") { + if (isClosed) { + _ballManager.CreateBall(new DebugBallCreator(630f, _playfieldComponent.Height / 2f)); + } + } else { + if (Logger.IsErrorEnabled) { + Logger.Error($"[PinMAME] Unknown switch \"{id}\"."); + } + } + + OnSwitchChanged?.Invoke(this, new SwitchEventArgs2(id, isClosed)); + } + + public bool GetSwitch(string id) + { + return _player != null && _player.SwitchStatuses.ContainsKey(id) && _player.SwitchStatuses[id].IsSwitchEnabled; + } + + #endregion + + #region Mechs + + public void RegisterMech(PinMameMechComponent mechComponent) + { + // PinMAME mech numbers are 1-based. + var id = ++_numMechs; + _registeredMechs[id] = mechComponent; + _registeredMechNames[id] = mechComponent ? mechComponent.name : ""; + } + + private void OnMechAvailable(int mechNo, PinMameMechInfo mechInfo) + { + Logger.Info($"[PinMAME] <= mech available: mechNo={mechNo}, mechInfo={mechInfo}"); + } + + private void OnMechUpdated(int mechNo, PinMameMechInfo mechInfo) + { + if (_registeredMechs.ContainsKey(mechNo)) { + EnqueueMainThreadDispatch(() => _registeredMechs[mechNo].UpdateMech(mechInfo)); + + } else { + Logger.Info($"[PinMAME] <= mech updated: mechNo={mechNo}, mechInfo={mechInfo}"); + } + } + + private void SendMechs() + { + var max = _pinMame.GetMaxMechs(); + foreach (var (id, mech) in _registeredMechs) { + var mechName = _registeredMechNames.TryGetValue(id, out var name) ? name : $"mech#{id}"; + // GetMaxMechs() returns the count of available slots and valid mech numbers are 1..max. + if (id > max) { + Logger.Error($"PinMAME supports {max} custom mech slot(s). Cannot register mech id={id} ({mechName})."); + continue; + } + var mechConfig = mech.Config(_player.SwitchMapping, _player.CoilMapping, _switchIdToPinMameIdMappings, _coilIdToPinMameIdMapping); + _pinMame.SetMech(id, mechConfig); + foreach (var c in mechConfig.SwitchList) { + _mechSwitches.Add(c.SwNo); + } + } + } + + #endregion + + private int IsKeyPressed(PinMameKeycode keycode) + { + if (keycode == PinMameKeycode.F10) { + if (_toggleSpeed) { + _toggleSpeed = false; + + _pinMame.SetHandleKeyboard(false); + + return 1; + } + } + + return 0; + } + + private bool ShouldDispatchSimulationCoil(string coilId) + { + return !string.IsNullOrEmpty(coilId) + && _player != null + && _player.SupportsSimulationThreadCoilDispatch(coilId); + } + + private void MarkPinMameCallbackActivity() + { + Interlocked.Increment(ref _pinMameCallbacksInWindow); + + var nowTicks = Stopwatch.GetTimestamp(); + var startTicks = Volatile.Read(ref _pinMamePerfWindowStartTicks); + var elapsedSeconds = (double)(nowTicks - startTicks) / Stopwatch.Frequency; + if (elapsedSeconds < PerfSampleWindowSeconds) { + return; + } + + if (Interlocked.CompareExchange(ref _pinMamePerfWindowStartTicks, nowTicks, startTicks) != startTicks) { + return; + } + + var callbacks = Interlocked.Exchange(ref _pinMameCallbacksInWindow, 0); + var rate = elapsedSeconds > 0.0 ? callbacks / elapsedSeconds : 0.0; + Volatile.Write(ref _pinMameCallbackRateHz, (float)rate); + } + } + + [Serializable] + public struct PinMameGamelogicEnginePackable + { + public string MachineId; + // Kept so packages produced by older VPE versions still deserialize. + public string GameType; + public string GameId; + public string RomId; + public bool DisableMechs; + public float SolenoidDelay; + public bool DisableAudio; + } + + #if UNITY_EDITOR + [Serializable] + public struct PinMameSwitchDefinition + { + public string Id; + public string Description; + public bool NormallyClosed; + public bool IsPulseSwitch; + public string InputActionHint; + public string InputMapHint; + public string DeviceHint; + public string DeviceItemHint; + public int NumMatches; + public SwitchConstantHint ConstantHint; + + public PinMameSwitchDefinition(GamelogicEngineSwitch item) + { + Id = item.Id; + Description = item.Description; + NormallyClosed = item.NormallyClosed; + IsPulseSwitch = item.IsPulseSwitch; + InputActionHint = item.InputActionHint; + InputMapHint = item.InputMapHint; + DeviceHint = item.DeviceHint; + DeviceItemHint = item.DeviceItemHint; + NumMatches = item.NumMatches; + ConstantHint = item.ConstantHint; + } + + public GamelogicEngineSwitch ToEngineSwitch() => new(Id) { + Description = Description, + NormallyClosed = NormallyClosed, + IsPulseSwitch = IsPulseSwitch, + InputActionHint = InputActionHint, + InputMapHint = InputMapHint, + DeviceHint = DeviceHint, + DeviceItemHint = DeviceItemHint, + NumMatches = NumMatches, + ConstantHint = ConstantHint, + }; + } + + [Serializable] + public struct PinMameCoilDefinition + { + public string Id; + public string Description; + public string DeviceHint; + public string DeviceItemHint; + public int NumMatches; + public bool IsLamp; + public bool IsUnused; + + public PinMameCoilDefinition(GamelogicEngineCoil item) + { + Id = item.Id; + Description = item.Description; + DeviceHint = item.DeviceHint; + DeviceItemHint = item.DeviceItemHint; + NumMatches = item.NumMatches; + IsLamp = item.IsLamp; + IsUnused = item.IsUnused; + } + + public GamelogicEngineCoil ToEngineCoil() => new(Id) { + Description = Description, + DeviceHint = DeviceHint, + DeviceItemHint = DeviceItemHint, + NumMatches = NumMatches, + IsLamp = IsLamp, + IsUnused = IsUnused, + }; + } + + [Serializable] + public struct PinMameLampDefinition + { + public string Id; + public string Description; + public ColorChannel Channel; + public int FadingSteps; + public LampSource Source; + public LampType Type; + public string DeviceHint; + public string DeviceItemHint; + public int NumMatches; + + public PinMameLampDefinition(GamelogicEngineLamp item) + { + Id = item.Id; + Description = item.Description; + Channel = item.Channel; + FadingSteps = item.FadingSteps; + Source = item.Source; + Type = item.Type; + DeviceHint = item.DeviceHint; + DeviceItemHint = item.DeviceItemHint; + NumMatches = item.NumMatches; + } + + public GamelogicEngineLamp ToEngineLamp() => new(Id) { + Description = Description, + Channel = Channel, + FadingSteps = FadingSteps, + Source = Source, + Type = Type, + DeviceHint = DeviceHint, + DeviceItemHint = DeviceItemHint, + NumMatches = NumMatches, + }; + } + #endif +} diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameLegacyAliasResolver.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameLegacyAliasResolver.cs new file mode 100644 index 0000000..aad7538 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameLegacyAliasResolver.cs @@ -0,0 +1,169 @@ +// 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. + +// ReSharper disable CheckNamespace + +using System; +using System.Collections.Generic; +using VisualPinball.Engine.Game.Engines; + +namespace VisualPinball.Engine.PinMAME +{ + /// + /// Resolves symbolic mapping IDs serialized by the retired class-based definitions. + /// This is a compatibility shim for existing scenes and packages, not a machine-definition source. + /// + internal static class PinMameLegacyAliasResolver + { + private static readonly IReadOnlyDictionary WpcSwitches = new Dictionary(StringComparer.Ordinal) { + ["s_coin_1"] = 1, ["s_coin_2"] = 2, ["s_coin_3"] = 3, ["s_coin_4"] = 4, + ["s_cancel"] = 5, ["s_down"] = 6, ["s_up"] = 7, ["s_enter"] = 8, + ["s_flipper_lower_right"] = 112, ["s_flipper_lower_left"] = 114, + ["s_flipper_upper_right"] = 116, ["s_flipper_upper_left"] = 118, + }; + + private static readonly IReadOnlyDictionary BallySwitches = new Dictionary(StringComparer.Ordinal) { + ["s_self_test"] = -7, ["s_cpu_diagnose"] = -6, ["s_sound_diagnose"] = -5, + ["s_ball_roll_tilt"] = 2, ["s_start_button"] = 6, ["s_tilt"] = 7, ["s_slam_tilt"] = 16, + ["s_coin_1"] = 11, ["s_coin_2"] = 10, ["s_coin_3"] = 9, + ["s_flipper_lower_right"] = 82, ["s_flipper_lower_left"] = 84, + }; + + private static readonly IReadOnlyDictionary System80Switches = new Dictionary(StringComparer.Ordinal) { + ["s_self_test"] = 7, ["s_start_button"] = 47, ["s_tilt"] = 57, ["s_slam_tilt"] = -1, + ["s_coin_1"] = 17, ["s_coin_2"] = 27, ["s_coin_3"] = 37, + ["s_left_advance"] = 6, ["s_right_advance"] = 16, + ["s_flipper_lower_left"] = 114, ["s_flipper_lower_right"] = 112, + }; + + private static readonly IReadOnlyDictionary SamSwitches = new Dictionary(StringComparer.Ordinal) { + ["s_coin_1"] = 65, ["s_coin_2"] = 66, ["s_coin_3"] = 67, + ["s_cancel"] = -3, ["s_down"] = -2, ["s_up"] = -1, ["s_enter"] = 0, + ["s_start_button"] = 16, ["s_slam_tilt"] = -6, ["s_tilt"] = -7, + ["s_flipper_lower_left"] = 84, ["s_flipper_lower_right"] = 82, + }; + + internal static bool TryResolveSwitch(string machineId, string romId, string legacyGameId, string alias, out int address) + { + var platform = PlatformFor(machineId, romId, legacyGameId); + return SwitchesFor(platform).TryGetValue(alias ?? string.Empty, out address); + } + + internal static bool TryResolveCoil(string machineId, string romId, string legacyGameId, string alias, out int address) + { + address = 0; + var platform = PlatformFor(machineId, romId, legacyGameId); + if (platform == LegacyPlatform.Wpc && IsTerminator2(machineId, romId, legacyGameId)) { + if (TryResolveFlipperCoil(alias, 48, 46, 36, 34, out address)) return true; + } + if (platform == LegacyPlatform.System80 && IsRock(machineId, romId, legacyGameId)) { + if (TryResolveFlipperCoil(alias, 48, 46, 47, 45, out address)) return true; + } + if (platform == LegacyPlatform.Sam) { + if (string.Equals(alias, "c_flipper_lower_left", StringComparison.Ordinal)) { address = 15; return true; } + if (string.Equals(alias, "c_flipper_lower_right", StringComparison.Ordinal)) { address = 16; return true; } + } + if (string.Equals(alias, "c_game_on", StringComparison.Ordinal)) { + if (platform is LegacyPlatform.Wpc or LegacyPlatform.Bally) { address = 19; return true; } + if (platform == LegacyPlatform.System80) { address = 10; return true; } + } + return false; + } + + internal static bool IsFlipperCoilAlias(string alias) + { + return string.Equals(alias, "c_flipper_lower_left", StringComparison.Ordinal) + || string.Equals(alias, "c_flipper_lower_right", StringComparison.Ordinal) + || string.Equals(alias, "c_flipper_upper_left", StringComparison.Ordinal) + || string.Equals(alias, "c_flipper_upper_right", StringComparison.Ordinal); + } + + internal static GamelogicEngineWire[] WiresFor(string machineId, string romId, string legacyGameId) + { + if (PlatformFor(machineId, romId, legacyGameId) == LegacyPlatform.Unknown) return Array.Empty(); + return new[] { + new GamelogicEngineWire("s_flipper_lower_left", "c_flipper_lower_left", DestinationType.Coil, "Lower Left Flipper"), + new GamelogicEngineWire("s_flipper_lower_right", "c_flipper_lower_right", DestinationType.Coil, "Lower Right Flipper"), + new GamelogicEngineWire("s_flipper_upper_left", "c_flipper_upper_left", DestinationType.Coil, "Upper Left Flipper"), + new GamelogicEngineWire("s_flipper_upper_right", "c_flipper_upper_right", DestinationType.Coil, "Upper Right Flipper"), + }; + } + + private static bool TryResolveFlipperCoil(string alias, int lowerLeft, int lowerRight, int upperLeft, int upperRight, out int address) + { + address = 0; + if (string.Equals(alias, "c_flipper_lower_left", StringComparison.Ordinal)) address = lowerLeft; + else if (string.Equals(alias, "c_flipper_lower_right", StringComparison.Ordinal)) address = lowerRight; + else if (string.Equals(alias, "c_flipper_upper_left", StringComparison.Ordinal)) address = upperLeft; + else if (string.Equals(alias, "c_flipper_upper_right", StringComparison.Ordinal)) address = upperRight; + return address != 0; + } + + private static IReadOnlyDictionary SwitchesFor(LegacyPlatform platform) + { + return platform switch { + LegacyPlatform.Wpc => WpcSwitches, + LegacyPlatform.Bally => BallySwitches, + LegacyPlatform.System80 => System80Switches, + LegacyPlatform.Sam => SamSwitches, + _ => EmptySwitches, + }; + } + + private static LegacyPlatform PlatformFor(string machineId, string romId, string legacyGameId) + { + if (IsAny(machineId, legacyGameId, "bally.attack-from-mars.1995", "bally.creature-from-the-black-lagoon.1992", "williams.medieval-madness.1997", "williams.terminator-2-judgment-day.1991", "afm", "cftbl", "mm", "t2") + || StartsWithAny(romId, "afm_", "cftbl_", "mm_", "t2_")) return LegacyPlatform.Wpc; + if (IsAny(machineId, legacyGameId, "bally.centaur.1981", "bally.flash-gordon.1980", "centaur", "fg") + || StartsWithAny(romId, "centaur", "flashg")) return LegacyPlatform.Bally; + if (IsAny(machineId, legacyGameId, "premier.rock.1985", "premier.rock-encore.1986", "rock", "rock_enc") + || StartsWithAny(romId, "rock", "clash")) return LegacyPlatform.System80; + if (IsAny(machineId, legacyGameId, "stern.star-trek-pro.2013", "stern.star-trek-premium-limited-edition.2013", "stern.tron-legacy-pro.2011", "stern.tron-legacy-limited-edition.2011", "stern.the-walking-dead-pro.2014", "stern.the-walking-dead-premium-limited-edition.2014", "star-trek-stern", "trn", "twd") + || StartsWithAny(romId, "st_", "trn_", "twd_")) return LegacyPlatform.Sam; + return LegacyPlatform.Unknown; + } + + private static bool IsTerminator2(string machineId, string romId, string legacyGameId) + { + return IsAny(machineId, legacyGameId, "williams.terminator-2-judgment-day.1991", "t2") || StartsWithAny(romId, "t2_"); + } + + private static bool IsRock(string machineId, string romId, string legacyGameId) + { + return IsAny(machineId, legacyGameId, "premier.rock.1985", "premier.rock-encore.1986", "rock", "rock_enc") || StartsWithAny(romId, "rock", "clash"); + } + + private static bool IsAny(string machineId, string legacyGameId, params string[] identities) + { + foreach (var identity in identities) { + if (string.Equals(machineId, identity, StringComparison.OrdinalIgnoreCase) || string.Equals(legacyGameId, identity, StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + + private static bool StartsWithAny(string value, params string[] prefixes) + { + if (string.IsNullOrWhiteSpace(value)) return false; + foreach (var prefix in prefixes) { + if (value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + + private static readonly IReadOnlyDictionary EmptySwitches = new Dictionary(); + + private enum LegacyPlatform + { + Unknown, + Wpc, + Bally, + System80, + Sam, + } + } +} diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameLegacyAliasResolver.cs.meta b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameLegacyAliasResolver.cs.meta new file mode 100644 index 0000000..ab1d192 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameLegacyAliasResolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4df8d404fd3b4e2aa5faed3c5ff4fb31 diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameMechComponent.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameMechComponent.cs index d688a0f..3c6a9db 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameMechComponent.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameMechComponent.cs @@ -139,18 +139,22 @@ public PinMameMechConfig Config(List switchMappings, List", diff --git a/VisualPinball.Engine.PinMAME/Games/AttackFromMars.cs b/VisualPinball.Engine.PinMAME/Games/AttackFromMars.cs deleted file mode 100644 index 2162ac5..0000000 --- a/VisualPinball.Engine.PinMAME/Games/AttackFromMars.cs +++ /dev/null @@ -1,224 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 . - -// ReSharper disable StringLiteralTypo - -using System; -using VisualPinball.Engine.Common; -using VisualPinball.Engine.Game.Engines; -using VisualPinball.Engine.PinMAME.MPUs; - -namespace VisualPinball.Engine.PinMAME.Games -{ - [Serializable] - public class AttackFromMars : Wpc - { - public override string Name { get; } = "Attack From Mars"; - public override string Id { get; } = "afm"; - public override int Year { get; } = 1995; - public override string Manufacturer { get; } = "Bally"; - public override int IpdbId { get; } = 3781; - public override PinMameRom[] Roms { get; } = { - new PinMameRom("afm_113", "1.13"), - new PinMameRom("afm_10", "1.0"), - new PinMameRom("afm_11", "1.1"), - new PinMameRom("afm_11u", "1.1", "Ultrapin"), - new PinMameRom("afm_11pfx", "1.1", "Pinball FX"), - new PinMameRom("afm_113b", "1.13b"), - new PinMameRom("afm_f10", "0.10", "FreeWPC"), - new PinMameRom("afm_f20", "0.20", "FreeWPC"), - new PinMameRom("afm_f32", "0.32", "FreeWPC"), - }; - - protected override PinMameIdAlias[] Aliases { get; } = { - }; - - protected override GamelogicEngineSwitch[] Switches { get; } = { - new GamelogicEngineSwitch(11) { Description = "Launch Ball", InputActionHint = InputConstants.ActionPlunger }, - - new GamelogicEngineSwitch(13) { Description = "Start Button", InputActionHint = InputConstants.ActionStartGame }, - new GamelogicEngineSwitch(14) { Description = "Plumb Bolt Tilt" }, - - new GamelogicEngineSwitch(16) { Description = "Left Outline" }, - new GamelogicEngineSwitch(17) { Description = "Right Return Lane" }, - new GamelogicEngineSwitch(18) { Description = "Shooter Lane" }, - - new GamelogicEngineSwitch(21) { Description = "Slam Tilt", InputActionHint = InputConstants.ActionSlamTilt }, - new GamelogicEngineSwitch(22) { Description = "Coin Door Closed", NormallyClosed = true, InputActionHint = InputConstants.ActionCoinDoorOpenClose }, - - new GamelogicEngineSwitch(24) { Description = "Always Closed", ConstantHint = SwitchConstantHint.AlwaysClosed}, - - new GamelogicEngineSwitch(26) { Description = "Left Return Lane" }, - new GamelogicEngineSwitch(27) { Description = "Right Outlane" }, - - new GamelogicEngineSwitch(31) { Description = "Trough Eject (jam)", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "jam"}, - new GamelogicEngineSwitch(32) { Description = "Trough Ball 1", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "1" }, - new GamelogicEngineSwitch(33) { Description = "Trough Ball 2", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "2" }, - new GamelogicEngineSwitch(34) { Description = "Trough Ball 3", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "3" }, - new GamelogicEngineSwitch(35) { Description = "Trough Ball 4", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "4" }, - new GamelogicEngineSwitch(36) { Description = "Left Popper", ConstantHint = SwitchConstantHint.AlwaysOpen }, - new GamelogicEngineSwitch(37) { Description = "Right Popper", ConstantHint = SwitchConstantHint.AlwaysOpen }, - new GamelogicEngineSwitch(38) { Description = "Left Top Lane" }, - - new GamelogicEngineSwitch(41) { Description = "MARTI\"A\"N Target" }, - new GamelogicEngineSwitch(42) { Description = "MARTIA\"N\" Target" }, - new GamelogicEngineSwitch(43) { Description = "MAR\"T\"IAN Target" }, - new GamelogicEngineSwitch(44) { Description = "MART\"I\"AN Target" }, - new GamelogicEngineSwitch(45) { Description = "Left Motor Bank" }, - new GamelogicEngineSwitch(46) { Description = "Center Motor Bank" }, - new GamelogicEngineSwitch(47) { Description = "Right Motor Bank" }, - new GamelogicEngineSwitch(48) { Description = "Right Top Lane" }, - - new GamelogicEngineSwitch(51) { Description = "Left Slingshot (Kicker)" }, - new GamelogicEngineSwitch(52) { Description = "Right Slingshot (Kicker)" }, - new GamelogicEngineSwitch(53) { Description = "Left Jet Bumper" }, - new GamelogicEngineSwitch(54) { Description = "Bottom Jet Bumper" }, - new GamelogicEngineSwitch(55) { Description = "Right Jet Bumper" }, - new GamelogicEngineSwitch(56) { Description = "\"M\"ARTIAN Target" }, - new GamelogicEngineSwitch(57) { Description = "M\"A\"RTIAN Target" }, - new GamelogicEngineSwitch(58) { Description = "MA\"R\"TIAN Target" }, - - new GamelogicEngineSwitch(61) { Description = "Left Ramp Enter" }, - new GamelogicEngineSwitch(62) { Description = "Center Ramp Enter" }, - new GamelogicEngineSwitch(63) { Description = "Right Ramp Enter" }, - new GamelogicEngineSwitch(64) { Description = "Left Ramp Exit" }, - new GamelogicEngineSwitch(65) { Description = "Right Ramp Exit" }, - new GamelogicEngineSwitch(66) { Description = "Motor Bank Down" }, - new GamelogicEngineSwitch(67) { Description = "Motor Bank Up" }, - - new GamelogicEngineSwitch(71) { Description = "Right Bank High" }, - new GamelogicEngineSwitch(72) { Description = "Right Bank Low" }, - new GamelogicEngineSwitch(73) { Description = "Left Loop High" }, - new GamelogicEngineSwitch(74) { Description = "Left Loop Low" }, - new GamelogicEngineSwitch(75) { Description = "Left Saucer Target" }, - new GamelogicEngineSwitch(76) { Description = "Right Saucer Target" }, - new GamelogicEngineSwitch(77) { Description = "Drop Targets" }, - new GamelogicEngineSwitch(78) { Description = "Center Trough" }, - }; - - public override GamelogicEngineLamp[] AvailableLamps { get; } = { - new GamelogicEngineLamp(11) { Description = "Super Jets" }, - new GamelogicEngineLamp(12) { Description = "Super Jackpot" }, - new GamelogicEngineLamp(13) { Description = "Martian Attack Multi-ball" }, - new GamelogicEngineLamp(14) { Description = "Annihilation" }, - new GamelogicEngineLamp(15) { Description = "Return To Battle (2)" }, - new GamelogicEngineLamp(16) { Description = "Conquer Mars" }, - new GamelogicEngineLamp(17) { Description = "5-Way Combo" }, - new GamelogicEngineLamp(18) { Description = "Drop Target" }, - - new GamelogicEngineLamp(21) { Description = "Big-O-Beam 1" }, - new GamelogicEngineLamp(22) { Description = "Big-O-Beam 2" }, - new GamelogicEngineLamp(23) { Description = "Big-O-Beam 3" }, - new GamelogicEngineLamp(24) { Description = "Left Ramp Jackpot" }, - new GamelogicEngineLamp(25) { Description = "Left Ramp Arrow" }, - new GamelogicEngineLamp(26) { Description = "Lock 2" }, - new GamelogicEngineLamp(27) { Description = "Lock 3" }, - new GamelogicEngineLamp(28) { Description = "Center Ramp Jackpot" }, - - new GamelogicEngineLamp(31) { Description = "Tractor Beam 1" }, - new GamelogicEngineLamp(32) { Description = "Tractor Beam 2" }, - new GamelogicEngineLamp(33) { Description = "Tractor Beam 3" }, - new GamelogicEngineLamp(34) { Description = "Right Ramp Jackpot" }, - new GamelogicEngineLamp(35) { Description = "Right Ramp Arrow" }, - new GamelogicEngineLamp(36) { Description = "Martian Attack" }, - new GamelogicEngineLamp(37) { Description = "Rule Universe" }, - new GamelogicEngineLamp(38) { Description = "Stroke Of Luck" }, - - new GamelogicEngineLamp(41) { Description = "Right Loop Arrow" }, - new GamelogicEngineLamp(42) { Description = "Center Ramp Arrow" }, - new GamelogicEngineLamp(43) { Description = "Left Top Lane" }, - new GamelogicEngineLamp(44) { Description = "Right Top Lane" }, - new GamelogicEngineLamp(45) { Description = "Left Motor Bank" }, - new GamelogicEngineLamp(46) { Description = "Center Motor Bank" }, - new GamelogicEngineLamp(47) { Description = "Right Motor Bank" }, - new GamelogicEngineLamp(48) { Description = "MARTIAN Target" }, - - new GamelogicEngineLamp(51) { Description = "Attack Mars" }, - new GamelogicEngineLamp(52) { Description = "D.C., U.S.A." }, - new GamelogicEngineLamp(53) { Description = "London, England" }, - new GamelogicEngineLamp(54) { Description = "Light Lock" }, - new GamelogicEngineLamp(55) { Description = "Lock 1" }, - new GamelogicEngineLamp(56) { Description = "Pisa, Italy" }, - new GamelogicEngineLamp(57) { Description = "Berlin, Germany" }, - new GamelogicEngineLamp(58) { Description = "Paris, France" }, - - new GamelogicEngineLamp(61) { Description = "Francois d'Grimm" }, - new GamelogicEngineLamp(62) { Description = "King of Payne" }, - new GamelogicEngineLamp(63) { Description = "Earl of Ego" }, - new GamelogicEngineLamp(64) { Description = "Left Ramp Jackpot" }, - new GamelogicEngineLamp(65) { Description = "Revolting Peasants!" }, - new GamelogicEngineLamp(66) { Description = "Ugly Riot!" }, - new GamelogicEngineLamp(67) { Description = "Angry Mob!" }, - new GamelogicEngineLamp(68) { Description = "Rabble Rouser" }, - - new GamelogicEngineLamp(71) { Description = "Howard Hurtz" }, - new GamelogicEngineLamp(72) { Description = "Magic Shield" }, - new GamelogicEngineLamp(73) { Description = "Sir Psycho" }, - new GamelogicEngineLamp(74) { Description = "Duke of Bourbon" }, - new GamelogicEngineLamp(75) { Description = "Castle Lock 2" }, - new GamelogicEngineLamp(76) { Description = "Castle Lock 1" }, - new GamelogicEngineLamp(77) { Description = "Super Jackpot" }, - new GamelogicEngineLamp(78) { Description = "Super Jets (2)" }, - - new GamelogicEngineLamp(81) { Description = "Right Outlane" }, - new GamelogicEngineLamp(82) { Description = "Right Return" }, - new GamelogicEngineLamp(83) { Description = "Left Return" }, - new GamelogicEngineLamp(84) { Description = "Left Outlane" }, - new GamelogicEngineLamp(85) { Description = "Castle Lock 3" }, - new GamelogicEngineLamp(86) { Description = "Shoot Again" }, - new GamelogicEngineLamp(87) { Description = "Launch Button" }, - new GamelogicEngineLamp(88) { Description = "Start Button" } - }; - - protected override GamelogicEngineCoil[] GameCoils { get; } = { - new GamelogicEngineCoil(1) { Description = "Auto Plunger", DeviceHint = "Plunger", DeviceItemHint = "c_autofire" }, - new GamelogicEngineCoil(2) { Description = "Trough Eject", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "eject_coil" }, - new GamelogicEngineCoil(3) { Description = "Left Popper" }, - new GamelogicEngineCoil(4) { Description = "Right Popper" }, - new GamelogicEngineCoil(5) { Description = "Left Alien Low" }, - new GamelogicEngineCoil(6) { Description = "Left Alien High" }, - new GamelogicEngineCoil(7) { Description = "Knocker" }, - new GamelogicEngineCoil(8) { Description = "Right Alien High" }, - new GamelogicEngineCoil(9) { Description = "Left Slingshot" }, - new GamelogicEngineCoil(10) { Description = "Right Slingshot" }, - new GamelogicEngineCoil(11) { Description = "Right Slingshot" }, - new GamelogicEngineCoil(12) { Description = "Left Jet Bumper" }, - new GamelogicEngineCoil(13) { Description = "Bottom Jet Bumper" }, - new GamelogicEngineCoil(14) { Description = "Right Alien Low" }, - new GamelogicEngineCoil(15) { Description = "Saucer Shake" }, - new GamelogicEngineCoil(16) { Description = "Drop Target" }, - new GamelogicEngineCoil(17) { Description = "Right Ramp High Flashers", IsLamp = true }, - new GamelogicEngineCoil(18) { Description = "Right Ramp Low Flasher", IsLamp = true }, - new GamelogicEngineCoil(19) { Description = "Right Side High Flashers", IsLamp = true }, - new GamelogicEngineCoil(20) { Description = "Right Side Low Flasher", IsLamp = true }, - new GamelogicEngineCoil(21) { Description = "Center Arrow Flasher", IsLamp = true }, - new GamelogicEngineCoil(22) { Description = "Jets", IsLamp = true }, - new GamelogicEngineCoil(23) { Description = "Saucer Dome", IsLamp = true }, - new GamelogicEngineCoil(24) { Description = "Motor Bank", IsLamp = true }, - new GamelogicEngineCoil(25) { Description = "Left Ramp Left" }, - new GamelogicEngineCoil(26) { Description = "Left Ramp Right" }, - new GamelogicEngineCoil(27) { Description = "Left Side High" }, - new GamelogicEngineCoil(28) { Description = "Left Side Low" }, - new GamelogicEngineCoil(33) { Description = "Right Gate" }, - new GamelogicEngineCoil(34) { Description = "Left Gate" }, - new GamelogicEngineCoil(35) { Description = "Diverter Power" }, - new GamelogicEngineCoil(36) { Description = "Diverter Hold" }, - new GamelogicEngineCoil(37) { Description = "L.E.D. Clock", IsLamp = true }, - new GamelogicEngineCoil(38) { Description = "L.E.D. Data", IsLamp = true }, - new GamelogicEngineCoil(39) { Description = "Strobe Light", IsLamp = true }, - }; - } -} diff --git a/VisualPinball.Engine.PinMAME/Games/Centaur.cs b/VisualPinball.Engine.PinMAME/Games/Centaur.cs deleted file mode 100644 index e826dd2..0000000 --- a/VisualPinball.Engine.PinMAME/Games/Centaur.cs +++ /dev/null @@ -1,192 +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 . - -// ReSharper disable StringLiteralTypo - -using System; -using VisualPinball.Engine.Game.Engines; -using VisualPinball.Engine.PinMAME.MPUs; - -namespace VisualPinball.Engine.PinMAME.Games -{ - [Serializable] - public class Centaur : Bally - { - public override string Name => "Centaur"; - public override string Id => "centaur"; - public override int Year => 1981; - public override string Manufacturer => "Bally"; - public override int IpdbId => 476; - - public override PinMameRom[] Roms { get; } = { - new("centaur"), - new("centaura", description: "Free Play"), - new("centaurb", "Rev. 27", description: "Free Play"), - }; - - protected override PinMameIdAlias[] Aliases { get; } = { - }; - - protected override GamelogicEngineSwitch[] Switches { get; } = { - new(1) { Description = "4th Ball Through", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "4" }, - new(2) { Description = "5th Ball Through", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "5" }, - new(3) { Description = "Top Right Lane" }, - new(4) { Description = "Top Middle Lane" }, - new(5) { Description = "Top Left Lane" }, - new(6) { Description = "Credit Button" }, - new(7) { Description = "" }, - new(8) { Description = "Outhole", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "drain_switch" }, - new(9) { Description = "Coin III (Right)" }, - new(10) { Description = "Coin I (Left)" }, - new(11) { Description = "Coin II (Middle)" }, - new(12) { Description = "Top Left Lane R.O. Button" }, - new(13) { Description = "" }, - new(14) { Description = "" }, - new(15) { Description = "Tilt" }, - new(16) { Description = "Slam" }, - new(17) { Description = "1st Ball Through", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "1" }, - new(18) { Description = "Left Side R.O. Button" }, - new(19) { Description = "Orbs Right Lane Target" }, - new(20) { Description = "Inline Back Target" }, - new(21) { Description = "Left & Right Flipper Button" }, - new(22) { Description = "Reset 1-4 Targets" }, - new(23) { Description = "" }, - new(24) { Description = "Top Spot 1-4 Target" }, - new(25) { Description = "Right 4 Drop Target \"4\" (Bottom)" }, - new(26) { Description = "Right 4 Drop Target \"3\"" }, - new(27) { Description = "Right 4 Drop Target \"2\"" }, - new(28) { Description = "Right 4 Drop Target \"1\" (Top)" }, - new(29) { Description = "\"S\" Drop Target" }, - new(30) { Description = "\"B\" Drop Target" }, - new(31) { Description = "\"R\" Drop Target" }, - new(32) { Description = "\"O\" Drop Target" }, - new(33) { Description = "End of Trough" }, - new(34) { Description = "10 Point Rebound (5)" }, - new(35) { Description = "" }, - new(36) { Description = "" }, - new(37) { Description = "Right Slingshot" }, - new(38) { Description = "Left Slingshot" }, - new(39) { Description = "Right Thumper Bumper" }, - new(40) { Description = "Left Thumper Bumper" }, - new(41) { Description = "1st Inline Drop Target" }, - new(42) { Description = "2nd Inline Drop Target" }, - new(43) { Description = "3rd Inline Drop Target" }, - new(44) { Description = "4th Inline Drop Target" }, - new(45) { Description = "Right Outlane" }, - new(46) { Description = "Right Return Lane" }, - new(47) { Description = "Left Return Lane" }, - new(48) { Description = "Left Outlane" }, - }; - - public override GamelogicEngineLamp[] AvailableLamps { get; } = { - new(2) { Description = "Middle 1" }, - new(18) { Description = "Middle 2" }, - new(34) { Description = "Middle 3" }, - new(50) { Description = "Middle 4" }, - new(3) { Description = "Middle 5" }, - new(19) { Description = "Middle 6" }, - new(35) { Description = "Middle 7" }, - new(51) { Description = "Middle 8" }, - new(4) { Description = "Middle 9" }, - new(20) { Description = "Middle 10" }, - new(26) { Description = "Middle 20" }, - new(42) { Description = "Middle 40" }, - new(58) { Description = "Middle 60" }, - new(5) { Description = "Middle 2x" }, - new(21) { Description = "Middle 3x" }, - new(37) { Description = "Middle 4x" }, - new(53) { Description = "Middle 5x" }, - - new(12) { Description = "Bottom 1" }, - new(28) { Description = "Bottom 2" }, - new(44) { Description = "Bottom 3" }, - new(60) { Description = "Bottom 4" }, - - new(17) { Description = "When Lit" }, - - new(54) { Description = "5K 1 (left)" }, - new(38) { Description = "5K 2" }, - new(22) { Description = "5K 3" }, - new(6) { Description = "5K 4 (right)" }, - - new(9) { Description = "Left Lane 10" }, - new(25) { Description = "Left Lane 20" }, - new(41) { Description = "Left Lane 30" }, - new(57) { Description = "Left Lane 40" }, - new(10) { Description = "Left Lane 50" }, - - new(46) { Description = "Left Resets 1-4 Targets Arrow" }, - - new(56) { Description = "Center Drop \"O\"" }, - new(40) { Description = "Center Drop \"R\"" }, - new(24) { Description = "Center Drop \"B\"" }, - new(8) { Description = "Center Drop \"S\"" }, - - new(14) { Description = "Center SPL" }, - new(30) { Description = "Center 50" }, - - new(33) { Description = "Release (Right Lane)" }, - new(49) { Description = "Collect Bonus Arrow" }, - - new(15) { Description = "Bonus 2x" }, - new(31) { Description = "Bonus 3x" }, - new(47) { Description = "Bonus 4x" }, - new(63) { Description = "Bonus 5x" }, - - new(55) { Description = "Right Drop 10" }, - new(39) { Description = "Right Drop 20" }, - new(23) { Description = "Right Drop 40" }, - new(7) { Description = "Right Drop 80" }, - - new(84) { Description = "Queen's Chamber Upper" }, - new(68) { Description = "Queen's Chamber Lower" }, - - new(36) { Description = "Release (Top Left)" }, - new(52) { Description = "SPL (Top Left)" }, - - new(100) { Description = "Yellow Round Top Left" }, - new(62) { Description = "Green Round Top Right" }, - - new(97) { Description = "Rollover 1 (Left)" }, - new(81) { Description = "Rollover 2" }, - new(65) { Description = "Rollover 3 (Right)" }, - - new(114) { Description = "Left Thumper Bumper" }, - new(98) { Description = "Right Thumper Bumper" }, - - new(59) { Description = "Bottom Left" }, - }; - - protected override GamelogicEngineCoil[] GameCoils { get; } = { - new(7) { Description = "Outhole Kicker" }, // 01 - new(6) { Description = "Knocker" }, // 02 - new(8) { Description = "Inline Drop Target Reset" }, // 03 - new(9) { Description = "4 Right Drop Target Reset" }, // 04 - new(10) { Description = "Left Thumper Bumper" }, // 05 - new(11) { Description = "Right Thumper Bumper" }, // 06 - new(12) { Description = "Left Slingshot" }, // 07 - new(13) { Description = "Right Slingshot" }, // 08 - new(1) { Description = "Orbs Target Reset" }, // 09 - new(2) { Description = "Right 4 Drop Target \"1\" (top)" }, // 10 - new(3) { Description = "Right 4 Drop Target \"2\"" }, // 11 - new(5) { Description = "Right 4 Drop Target \"3\"" }, // 12 - new(5) { Description = "Right 4 Drop Target \"4\" (bottom)" }, // 13 - new(15) { Description = "Ball Release" }, // 14 - new(14) { Description = "Ball Kick to Playfield" }, // 15 - new(18) { Description = "Coin Lockout Door" }, // 16 - }; - } -} diff --git a/VisualPinball.Engine.PinMAME/Games/CreatureFromTheBlackLagoon.cs b/VisualPinball.Engine.PinMAME/Games/CreatureFromTheBlackLagoon.cs deleted file mode 100644 index 8a1cc90..0000000 --- a/VisualPinball.Engine.PinMAME/Games/CreatureFromTheBlackLagoon.cs +++ /dev/null @@ -1,202 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 . - -// ReSharper disable StringLiteralTypo - -using System; -using VisualPinball.Engine.Common; -using VisualPinball.Engine.Game.Engines; -using VisualPinball.Engine.PinMAME.MPUs; - -namespace VisualPinball.Engine.PinMAME.Games -{ - [Serializable] - public class CreatureFromTheBlackLagoon : Wpc - { - public override string Name { get; } = "Creature From The Black Lagoon"; - public override string Id { get; } = "cftbl"; - public override int Year { get; } = 1992; - public override string Manufacturer { get; } = "Bally"; - public override int IpdbId { get; } = 588; - public override PinMameRom[] Roms { get; } = { - new PinMameRom("cftbl_l4", "L-4"), - new PinMameRom("cftbl_p3", "P-3", "Prototype, SP-1"), - new PinMameRom("cftbl_l2", "L-2"), - new PinMameRom("cftbl_d2", "D-2", "LED Ghost Fix"), - new PinMameRom("cftbl_l3", "L-3"), - new PinMameRom("cftbl_d3", "D-3", "LED Ghost Fix"), - new PinMameRom("cftbl_d4", "D-4", "LED Ghost Fix"), - new PinMameRom("cftbl_l4c", "L-4C", "Competition + LED Ghost MOD"), - }; - - protected override PinMameIdAlias[] Aliases { get; } = { - }; - - protected override GamelogicEngineSwitch[] Switches { get; } = { - new GamelogicEngineSwitch(13) { Description = "Start Button", InputActionHint = InputConstants.ActionStartGame }, - new GamelogicEngineSwitch(14) { Description = "Plumb Bolt Tilt" }, - new GamelogicEngineSwitch(15) { Description = "Top Left Rollover" }, - new GamelogicEngineSwitch(16) { Description = "Left Subway" }, - new GamelogicEngineSwitch(17) { Description = "Center Subway" }, - new GamelogicEngineSwitch(18) { Description = "Center Shot" }, - - new GamelogicEngineSwitch(21) { Description = "Slam Tilt", InputActionHint = InputConstants.ActionSlamTilt }, - new GamelogicEngineSwitch(22) { Description = "Coin Door Closed", NormallyClosed = true, InputActionHint = InputConstants.ActionCoinDoorOpenClose }, - new GamelogicEngineSwitch(24) { Description = "Always Closed", ConstantHint = SwitchConstantHint.AlwaysClosed}, - new GamelogicEngineSwitch(25) { Description = "(P)-A-I-D"}, - new GamelogicEngineSwitch(26) { Description = "P-(A)-I-D" }, - new GamelogicEngineSwitch(27) { Description = "P-A-(I)-D" }, - new GamelogicEngineSwitch(28) { Description = "P-A-I-(D)" }, - - new GamelogicEngineSwitch(33) { Description = "Bottom Jet" }, - new GamelogicEngineSwitch(34) { Description = "Right Popper" }, - new GamelogicEngineSwitch(35) { Description = "Right Ramp Enter" }, - new GamelogicEngineSwitch(36) { Description = "Left Ramp Enter" }, - new GamelogicEngineSwitch(37) { Description = "Lower Right Popper" }, - new GamelogicEngineSwitch(38) { Description = "Ramp Up/Down" }, - - new GamelogicEngineSwitch(41) { Description = "Cola" }, - new GamelogicEngineSwitch(42) { Description = "Hot Dog" }, - new GamelogicEngineSwitch(43) { Description = "Popcorn" }, - new GamelogicEngineSwitch(44) { Description = "Ice Cream" }, - new GamelogicEngineSwitch(45) { Description = "Left Jet" }, - new GamelogicEngineSwitch(46) { Description = "Right Jet" }, - new GamelogicEngineSwitch(47) { Description = "Left Slingshot" }, - new GamelogicEngineSwitch(48) { Description = "Right Slingshot" }, - - new GamelogicEngineSwitch(51) { Description = "Left Out Lane" }, - new GamelogicEngineSwitch(52) { Description = "Left Return Lane" }, - new GamelogicEngineSwitch(53) { Description = "Start Combo" }, - new GamelogicEngineSwitch(54) { Description = "Right Out Lane" }, - new GamelogicEngineSwitch(55) { Description = "Outhole", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "drain_switch" }, - new GamelogicEngineSwitch(56) { Description = "Right Trough", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "1" }, - new GamelogicEngineSwitch(57) { Description = "Center Trough", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "2" }, - new GamelogicEngineSwitch(58) { Description = "Left Trough", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "3" }, - - new GamelogicEngineSwitch(61) { Description = "Right Ramp Exit" }, - new GamelogicEngineSwitch(62) { Description = "Left Ramp Exit" }, - new GamelogicEngineSwitch(63) { Description = "Center Lane Exit" }, - new GamelogicEngineSwitch(64) { Description = "Upper Ramp" }, - new GamelogicEngineSwitch(65) { Description = "Bowl" }, - new GamelogicEngineSwitch(66) { Description = "Shooter" }, - }; - - public override GamelogicEngineLamp[] AvailableLamps { get; } = { - new GamelogicEngineLamp(11) { Description = "(P)-A-I-D" }, - new GamelogicEngineLamp(12) { Description = "P-(A)-I-D" }, - new GamelogicEngineLamp(13) { Description = "P-A-(I)-D" }, - new GamelogicEngineLamp(14) { Description = "P-A-I-(D)" }, - new GamelogicEngineLamp(15) { Description = "Left Jet" }, - new GamelogicEngineLamp(16) { Description = "Right Jet" }, - new GamelogicEngineLamp(17) { Description = "Bottom Jet" }, - new GamelogicEngineLamp(18) { Description = "Admit One" }, - - new GamelogicEngineLamp(21) { Description = "(K)-I-S-S" }, - new GamelogicEngineLamp(22) { Description = "K-(I)-S-S" }, - new GamelogicEngineLamp(23) { Description = "K-I-(S)-S" }, - new GamelogicEngineLamp(24) { Description = "K-I-S-(S)" }, - new GamelogicEngineLamp(25) { Description = "10 Million" }, - new GamelogicEngineLamp(26) { Description = "20 Million" }, - new GamelogicEngineLamp(27) { Description = "30 Million" }, - new GamelogicEngineLamp(28) { Description = "Specials" }, - - new GamelogicEngineLamp(31) { Description = "Start Mega Menu" }, - new GamelogicEngineLamp(32) { Description = "Playground Award" }, - new GamelogicEngineLamp(33) { Description = "Lite Big Millions" }, - new GamelogicEngineLamp(34) { Description = "Slide" }, - new GamelogicEngineLamp(35) { Description = "Right Search" }, - new GamelogicEngineLamp(36) { Description = "Right Video" }, - new GamelogicEngineLamp(37) { Description = "Right Start Movie" }, - new GamelogicEngineLamp(38) { Description = "Mega Menu" }, - - new GamelogicEngineLamp(41) { Description = "Lips" }, - new GamelogicEngineLamp(42) { Description = "Left Search" }, - new GamelogicEngineLamp(43) { Description = "Left Video" }, - new GamelogicEngineLamp(44) { Description = "Left Start Movie" }, - new GamelogicEngineLamp(45) { Description = "Combo Award" }, - new GamelogicEngineLamp(46) { Description = "Parking O.K." }, - new GamelogicEngineLamp(47) { Description = "Move Your Car" }, - new GamelogicEngineLamp(48) { Description = "Extra Ball" }, - - new GamelogicEngineLamp(51) { Description = "Snack Bar" }, - new GamelogicEngineLamp(52) { Description = "Center Search" }, - new GamelogicEngineLamp(53) { Description = "Cola" }, - new GamelogicEngineLamp(54) { Description = "Hot Dog" }, - new GamelogicEngineLamp(55) { Description = "Super Jackpot" }, - new GamelogicEngineLamp(56) { Description = "Jackpot" }, - new GamelogicEngineLamp(57) { Description = "Rescue" }, - new GamelogicEngineLamp(58) { Description = "Multiball Restart" }, - - new GamelogicEngineLamp(61) { Description = "Free Pass" }, - new GamelogicEngineLamp(62) { Description = "Build Combo" }, - new GamelogicEngineLamp(63) { Description = "Unlimited Millions" }, - new GamelogicEngineLamp(64) { Description = "Creature Feature" }, - new GamelogicEngineLamp(65) { Description = "Extra Ball Countdown" }, - new GamelogicEngineLamp(66) { Description = "Big Millions" }, - new GamelogicEngineLamp(67) { Description = "Movie Madness" }, - new GamelogicEngineLamp(68) { Description = "Snack Attack" }, - - new GamelogicEngineLamp(71) { Description = "C" }, - new GamelogicEngineLamp(72) { Description = "R" }, - new GamelogicEngineLamp(73) { Description = "E" }, - new GamelogicEngineLamp(74) { Description = "A" }, - new GamelogicEngineLamp(75) { Description = "T" }, - new GamelogicEngineLamp(76) { Description = "U" }, - new GamelogicEngineLamp(77) { Description = "R" }, - new GamelogicEngineLamp(78) { Description = "E" }, - - new GamelogicEngineLamp(81) { Description = "(F)-I-L-M" }, - new GamelogicEngineLamp(82) { Description = "F-(I)-L-M" }, - new GamelogicEngineLamp(83) { Description = "F-I-(L)-M" }, - new GamelogicEngineLamp(84) { Description = "F-I-L-(M)" }, - new GamelogicEngineLamp(85) { Description = "Start Combo" }, - new GamelogicEngineLamp(86) { Description = "Popcorn" }, - new GamelogicEngineLamp(87) { Description = "Ice Cream" }, - new GamelogicEngineLamp(88) { Description = "Start Button" } - }; - - protected override GamelogicEngineCoil[] GameCoils { get; } = { - new GamelogicEngineCoil(1) { Description = "Top Right Popper" }, - new GamelogicEngineCoil(2) { Description = "Left Subway Enter Flasher", IsLamp = true }, - new GamelogicEngineCoil(3) { Description = "Lower Right Popper" }, - new GamelogicEngineCoil(4) { Description = "Trough Eject", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "eject_coil" }, - new GamelogicEngineCoil(5) { Description = "Right Slingshot" }, - new GamelogicEngineCoil(6) { Description = "Left Slingshot" }, - new GamelogicEngineCoil(7) { Description = "Knocker" }, - new GamelogicEngineCoil(8) { Description = "Bottom Right Flasher", IsLamp = true }, - new GamelogicEngineCoil(9) { Description = "Back Flashers", IsLamp = true }, - new GamelogicEngineCoil(10) { Description = "Bowl Flasher", IsLamp = true }, - new GamelogicEngineCoil(11) { Description = "Creature Flasher", IsLamp = true }, - new GamelogicEngineCoil(12) { Description = "Outhole", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "entry_coil" }, - new GamelogicEngineCoil(13) { Description = "Left Jet" }, - new GamelogicEngineCoil(14) { Description = "Right Jet" }, - new GamelogicEngineCoil(15) { Description = "Bottom Jet" }, - new GamelogicEngineCoil(16) { Description = "Right Popper Flasher", IsLamp = true }, - new GamelogicEngineCoil(17) { Description = "Bottom Left Flasher", IsLamp = true }, - new GamelogicEngineCoil(18) { Description = "Right Ramp Flasher", IsLamp = true }, - new GamelogicEngineCoil(19) { Description = "Left Ramp Flasher", IsLamp = true }, - new GamelogicEngineCoil(20) { Description = "Sequential GI #1", IsLamp = true }, - new GamelogicEngineCoil(21) { Description = "Hologram Push Motor (playfield)" }, - new GamelogicEngineCoil(22) { Description = "Center Hole Flasher", IsLamp = true }, - new GamelogicEngineCoil(23) { Description = "Up/Down Ramp (up)" }, - new GamelogicEngineCoil(24) { Description = "Sequential GI #2", IsLamp = true }, - new GamelogicEngineCoil(25) { Description = "Start Move Flashers", IsLamp = true }, - new GamelogicEngineCoil(26) { Description = "Up/Down Ramp (down)" }, - new GamelogicEngineCoil(27) { Description = "Creature Motor (mirror)" }, - new GamelogicEngineCoil(28) { Description = "Hologram Lamp (cabinet)", IsLamp = true }, - }; - } -} diff --git a/VisualPinball.Engine.PinMAME/Games/FlashGordon.cs b/VisualPinball.Engine.PinMAME/Games/FlashGordon.cs deleted file mode 100644 index 30bdffc..0000000 --- a/VisualPinball.Engine.PinMAME/Games/FlashGordon.cs +++ /dev/null @@ -1,112 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 . - -// ReSharper disable StringLiteralTypo - -using System; -using VisualPinball.Engine.Game.Engines; -using VisualPinball.Engine.PinMAME.MPUs; - -namespace VisualPinball.Engine.PinMAME.Games -{ - [Serializable] - public class FlashGordon : Bally - { - public override string Name => "Flash Gordon"; - public override string Id => "fg"; - public override int Year => 1980; - public override string Manufacturer => "Bally"; - public override int IpdbId => 874; - - public override PinMameRom[] Roms { get; } = { - new PinMameRom("flashgdn"), - new PinMameRom("flashgdf", language:PinMameRomLanguage.French), - new PinMameRom("flashgda", description: "Free Play"), - new PinMameRom("flashgfa", description: "Free Play", language: PinMameRomLanguage.French), - new PinMameRom("flashgdp", "Rev.1", "Prototype"), - new PinMameRom("flashgp2", "Rev.2", "Prototype"), - new PinMameRom("flashgdv", description:"Vocalizer Sound"), - new PinMameRom("flashgva", description: "Vocalizer Sound Free Play"), - }; - - protected override PinMameIdAlias[] Aliases { get; } = { - }; - - protected override GamelogicEngineSwitch[] Switches { get; } = { - new GamelogicEngineSwitch(1) { Description = "2 Left & Right R.O. Buttons" }, - new GamelogicEngineSwitch(2) { Description = "3 Shooter Lane R.O. Buttons" }, - new GamelogicEngineSwitch(3) { Description = "Top Single Drop Target" }, - new GamelogicEngineSwitch(4) { Description = "Shooter Lane Rollover" }, - new GamelogicEngineSwitch(5) { Description = "Drop Target 50 Point Reb. (2)" }, - new GamelogicEngineSwitch(6) { Description = "Credit Button" }, - new GamelogicEngineSwitch(7) { Description = "Tilt (3)" }, - new GamelogicEngineSwitch(8) { Description = "Outhole" }, - new GamelogicEngineSwitch(9) { Description = "Coin III (right)" }, - new GamelogicEngineSwitch(10) { Description = "Coin I (left)" }, - new GamelogicEngineSwitch(11) { Description = "Coin II (middle)" }, - new GamelogicEngineSwitch(12) { Description = "Lower Right Side Target" }, - new GamelogicEngineSwitch(13) { Description = "Flip Feed Lane (right)" }, - new GamelogicEngineSwitch(14) { Description = "Flip Feed Lane (left)" }, - new GamelogicEngineSwitch(15) { Description = "Upper Right Side Target" }, - new GamelogicEngineSwitch(16) { Description = "Slam (2)" }, - new GamelogicEngineSwitch(17) { Description = "4 Drop Target A (bottom)" }, - new GamelogicEngineSwitch(18) { Description = "4 Drop Target B" }, - new GamelogicEngineSwitch(19) { Description = "4 Drop Target C" }, - new GamelogicEngineSwitch(20) { Description = "4 Drop Target D (top)" }, - new GamelogicEngineSwitch(21) { Description = "1 Drop Target (top)" }, - new GamelogicEngineSwitch(22) { Description = "2 Drop Target (middle)" }, - new GamelogicEngineSwitch(23) { Description = "3 Drop Target (bottom)" }, - new GamelogicEngineSwitch(24) { Description = "Top Target" }, - new GamelogicEngineSwitch(25) { Description = "1st Inline Drop Target" }, - new GamelogicEngineSwitch(26) { Description = "2nd Inline Drop Target" }, - new GamelogicEngineSwitch(27) { Description = "3rd Inline Drop Target" }, - new GamelogicEngineSwitch(28) { Description = "Inline Back Target" }, - new GamelogicEngineSwitch(29) { Description = "10 Point Rebound (2)" }, - new GamelogicEngineSwitch(30) { Description = "Saucer" }, - new GamelogicEngineSwitch(31) { Description = "Right Outlane" }, - new GamelogicEngineSwitch(32) { Description = "Left Outlane" }, - new GamelogicEngineSwitch(33) { Description = "Right Spinner" }, - new GamelogicEngineSwitch(34) { Description = "Left Spinner" }, - new GamelogicEngineSwitch(35) { Description = "Right Slingshot" }, - new GamelogicEngineSwitch(36) { Description = "Left Slingshot" }, - new GamelogicEngineSwitch(37) { Description = "Top Thumper Bumper" }, - new GamelogicEngineSwitch(39) { Description = "Right Thumper Bumper" }, - new GamelogicEngineSwitch(40) { Description = "Left Thumper Bumper" }, - }; - - public override GamelogicEngineLamp[] AvailableLamps { get; } = { - }; - - protected override GamelogicEngineCoil[] GameCoils { get; } = { - new GamelogicEngineCoil(1) { Description = "Drop Target Reset" }, - new GamelogicEngineCoil(2) { Description = "Drop Target Reset" }, - new GamelogicEngineCoil(3) { Description = "In Line Drop Target" }, - new GamelogicEngineCoil(4) { Description = "Saucer Kick Down" }, - new GamelogicEngineCoil(6) { Description = "Knocker" }, - new GamelogicEngineCoil(7) { Description = "Out Hole" }, - new GamelogicEngineCoil(8) { Description = "Saucer Kick Up" }, - new GamelogicEngineCoil(9) { Description = "Single Drop Target Reset" }, - new GamelogicEngineCoil(10) { Description = "Left Bumper" }, - new GamelogicEngineCoil(11) { Description = "Right Bumper" }, - new GamelogicEngineCoil(12) { Description = "Single Drop Target Pull Down" }, - new GamelogicEngineCoil(13) { Description = "Top Bumper" }, - new GamelogicEngineCoil(14) { Description = "Left Slingshot" }, - new GamelogicEngineCoil(15) { Description = "Right Slingshot" }, - new GamelogicEngineCoil(18) { Description = "Coin Lockout Door" }, - new GamelogicEngineCoil(19) { Description = "KI Relay (Flipper enabled)" }, - }; - } -} diff --git a/VisualPinball.Engine.PinMAME/Games/MedievalMadness.cs b/VisualPinball.Engine.PinMAME/Games/MedievalMadness.cs deleted file mode 100644 index 5bfd85a..0000000 --- a/VisualPinball.Engine.PinMAME/Games/MedievalMadness.cs +++ /dev/null @@ -1,218 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 . - -// ReSharper disable StringLiteralTypo - -using System; -using VisualPinball.Engine.Common; -using VisualPinball.Engine.Game.Engines; -using VisualPinball.Engine.PinMAME.MPUs; - -namespace VisualPinball.Engine.PinMAME.Games -{ - [Serializable] - public class MedievalMadness : Wpc - { - public override string Name { get; } = "Medieval Madness"; - public override string Id { get; } = "mm"; - public override int Year { get; } = 1997; - public override string Manufacturer { get; } = "Williams"; - public override int IpdbId { get; } = 4032; - public override PinMameRom[] Roms { get; } = { - new PinMameRom("mm_05", "0.5"), - new PinMameRom("mm_10", "1.0"), - new PinMameRom("mm_10u", "1.0", "Ultrapin"), - new PinMameRom("mm_109", "1.09"), - new PinMameRom("mm_109b", "1.09b"), - new PinMameRom("mm_109c", "1.09c", "Profanity ROM"), - }; - - protected override PinMameIdAlias[] Aliases { get; } = { - }; - - protected override GamelogicEngineSwitch[] Switches { get; } = { - new GamelogicEngineSwitch(11) { Description = "Launch Ball", InputActionHint = InputConstants.ActionPlunger }, - new GamelogicEngineSwitch(12) { Description = "Catapult Target" }, - new GamelogicEngineSwitch(13) { Description = "Start Button", InputActionHint = InputConstants.ActionStartGame }, - new GamelogicEngineSwitch(14) { Description = "Plumb Bolt Tilt" }, - new GamelogicEngineSwitch(15) { Description = "Left Troll Target" }, - new GamelogicEngineSwitch(16) { Description = "Left Outline" }, - new GamelogicEngineSwitch(17) { Description = "Right Return Lane" }, - new GamelogicEngineSwitch(18) { Description = "Shooter Lane" }, - - new GamelogicEngineSwitch(21) { Description = "Slam Tilt", InputActionHint = InputConstants.ActionSlamTilt }, - new GamelogicEngineSwitch(22) { Description = "Coin Door Closed", NormallyClosed = true, InputActionHint = InputConstants.ActionCoinDoorOpenClose }, - new GamelogicEngineSwitch(24) { Description = "Always Closed", ConstantHint = SwitchConstantHint.AlwaysClosed}, - new GamelogicEngineSwitch(25) { Description = "Right Troll Target" }, - new GamelogicEngineSwitch(26) { Description = "Left Return Lane" }, - new GamelogicEngineSwitch(27) { Description = "Right Outlane" }, - new GamelogicEngineSwitch(28) { Description = "Right Eject" }, - - new GamelogicEngineSwitch(31) { Description = "Trough Eject (jam)", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "jam"}, - new GamelogicEngineSwitch(32) { Description = "Trough Ball 1", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "1" }, - new GamelogicEngineSwitch(33) { Description = "Trough Ball 2", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "2" }, - new GamelogicEngineSwitch(34) { Description = "Trough Ball 3", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "3" }, - new GamelogicEngineSwitch(35) { Description = "Trough Ball 4", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "4" }, - new GamelogicEngineSwitch(36) { Description = "Left Popper", ConstantHint = SwitchConstantHint.AlwaysOpen }, - new GamelogicEngineSwitch(37) { Description = "Castle Gate", ConstantHint = SwitchConstantHint.AlwaysOpen }, - new GamelogicEngineSwitch(38) { Description = "Catapult" }, - - new GamelogicEngineSwitch(41) { Description = "Moat Enter", ConstantHint = SwitchConstantHint.AlwaysOpen }, - new GamelogicEngineSwitch(44) { Description = "Castle Lock" }, - new GamelogicEngineSwitch(45) { Description = "Left Troll (under playfield)" }, - new GamelogicEngineSwitch(46) { Description = "Right Troll (under playfield)" }, - new GamelogicEngineSwitch(47) { Description = "Left Top Lane" }, - new GamelogicEngineSwitch(48) { Description = "Right Top Lane" }, - - new GamelogicEngineSwitch(51) { Description = "Left Slingshot" }, - new GamelogicEngineSwitch(52) { Description = "Right Slingshot" }, - new GamelogicEngineSwitch(53) { Description = "Left Jet Bumper" }, - new GamelogicEngineSwitch(54) { Description = "Bottom Jet Bumper" }, - new GamelogicEngineSwitch(55) { Description = "Right Jet Bumper" }, - new GamelogicEngineSwitch(56) { Description = "Draw-Bridge Up", ConstantHint = SwitchConstantHint.AlwaysClosed }, - new GamelogicEngineSwitch(57) { Description = "Draw-Bridge Down" }, - new GamelogicEngineSwitch(58) { Description = "Tower Exit" }, - - new GamelogicEngineSwitch(61) { Description = "Left Ramp Enter" }, - new GamelogicEngineSwitch(62) { Description = "Left Ramp Exit" }, - new GamelogicEngineSwitch(63) { Description = "Right Ramp Enter" }, - new GamelogicEngineSwitch(64) { Description = "Right Ramp Exit" }, - new GamelogicEngineSwitch(65) { Description = "Left Loop Low" }, - new GamelogicEngineSwitch(66) { Description = "Left Loop High" }, - new GamelogicEngineSwitch(67) { Description = "Right Loop Low" }, - new GamelogicEngineSwitch(68) { Description = "Right Loop High" }, - - new GamelogicEngineSwitch(71) { Description = "Right Bank Top" }, - new GamelogicEngineSwitch(72) { Description = "Right Bank Middle" }, - new GamelogicEngineSwitch(73) { Description = "Right Bank Bottom" }, - new GamelogicEngineSwitch(74) { Description = "Left Troll Up" }, - new GamelogicEngineSwitch(75) { Description = "Right Troll Up" }, - - // new GamelogicEngineSwitch(112) { Description = "Lower Right Flipper", InputActionHint = InputConstants.ActionRightFlipper }, - // new GamelogicEngineSwitch(114) { Description = "Lower Left Flipper", InputActionHint = InputConstants.ActionLeftFlipper }, - }; - - public override GamelogicEngineLamp[] AvailableLamps { get; } = { - new GamelogicEngineLamp(11) { Description = "Right Bank Top" }, - new GamelogicEngineLamp(12) { Description = "Right Bank Middle" }, - new GamelogicEngineLamp(13) { Description = "Right Bank Bottom" }, - new GamelogicEngineLamp(14) { Description = "Right Ramp Jackpot" }, - new GamelogicEngineLamp(15) { Description = "Save the Damsel! (2)" }, - new GamelogicEngineLamp(16) { Description = "Dragon Death" }, - new GamelogicEngineLamp(17) { Description = "Dragon Snack" }, - new GamelogicEngineLamp(18) { Description = "Dragon Breath" }, - - new GamelogicEngineLamp(21) { Description = "Right Loop Jackpot" }, - new GamelogicEngineLamp(22) { Description = "Right Joust Victory" }, - new GamelogicEngineLamp(23) { Description = "Right Clash!" }, - new GamelogicEngineLamp(24) { Description = "Right Charge!" }, - new GamelogicEngineLamp(25) { Description = "Patron of the Peasants" }, - new GamelogicEngineLamp(26) { Description = "Catapult Ace" }, - new GamelogicEngineLamp(27) { Description = "Joust Champion" }, - new GamelogicEngineLamp(28) { Description = "Castle Crusher" }, - - new GamelogicEngineLamp(31) { Description = "Trolls!" }, - new GamelogicEngineLamp(32) { Description = "Extra Ball" }, - new GamelogicEngineLamp(33) { Description = "Merlin's Magic" }, - new GamelogicEngineLamp(34) { Description = "Troll Madness" }, - new GamelogicEngineLamp(35) { Description = "Damsel Madness" }, - new GamelogicEngineLamp(36) { Description = "Peasant Madness" }, - new GamelogicEngineLamp(37) { Description = "Catapult Madness" }, - new GamelogicEngineLamp(38) { Description = "Joust Madness" }, - - new GamelogicEngineLamp(41) { Description = "Left Loop Jackpot" }, - new GamelogicEngineLamp(42) { Description = "Left Joust Victory" }, - new GamelogicEngineLamp(43) { Description = "Left Clash!" }, - new GamelogicEngineLamp(44) { Description = "Left Charge!" }, - new GamelogicEngineLamp(45) { Description = "Catapult Jackpot" }, - new GamelogicEngineLamp(46) { Description = "Catapult Slam!" }, - new GamelogicEngineLamp(47) { Description = "BAM!" }, - new GamelogicEngineLamp(48) { Description = "WAM!" }, - - new GamelogicEngineLamp(51) { Description = "Center Arrow" }, - new GamelogicEngineLamp(52) { Description = "Battle for the Kingdom" }, - new GamelogicEngineLamp(53) { Description = "Master of Trolls" }, - new GamelogicEngineLamp(54) { Description = "Defender of Damsels" }, - new GamelogicEngineLamp(55) { Description = "Left Top Lane" }, - new GamelogicEngineLamp(56) { Description = "Right Top Lane" }, - new GamelogicEngineLamp(57) { Description = "Left Troll Target" }, - new GamelogicEngineLamp(58) { Description = "Right Troll Target" }, - - new GamelogicEngineLamp(61) { Description = "Francois d'Grimm" }, - new GamelogicEngineLamp(62) { Description = "King of Payne" }, - new GamelogicEngineLamp(63) { Description = "Earl of Ego" }, - new GamelogicEngineLamp(64) { Description = "Left Ramp Jackpot" }, - new GamelogicEngineLamp(65) { Description = "Revolting Peasants!" }, - new GamelogicEngineLamp(66) { Description = "Ugly Riot!" }, - new GamelogicEngineLamp(67) { Description = "Angry Mob!" }, - new GamelogicEngineLamp(68) { Description = "Rabble Rouser" }, - - new GamelogicEngineLamp(71) { Description = "Howard Hurtz" }, - new GamelogicEngineLamp(72) { Description = "Magic Shield" }, - new GamelogicEngineLamp(73) { Description = "Sir Psycho" }, - new GamelogicEngineLamp(74) { Description = "Duke of Bourbon" }, - new GamelogicEngineLamp(75) { Description = "Castle Lock 2" }, - new GamelogicEngineLamp(76) { Description = "Castle Lock 1" }, - new GamelogicEngineLamp(77) { Description = "Super Jackpot" }, - new GamelogicEngineLamp(78) { Description = "Super Jets (2)" }, - - new GamelogicEngineLamp(81) { Description = "Right Outlane" }, - new GamelogicEngineLamp(82) { Description = "Right Return" }, - new GamelogicEngineLamp(83) { Description = "Left Return" }, - new GamelogicEngineLamp(84) { Description = "Left Outlane" }, - new GamelogicEngineLamp(85) { Description = "Castle Lock 3" }, - new GamelogicEngineLamp(86) { Description = "Shoot Again" }, - new GamelogicEngineLamp(87) { Description = "Launch Button" }, - new GamelogicEngineLamp(88) { Description = "Start Button" } - }; - - protected override GamelogicEngineCoil[] GameCoils { get; } = { - new GamelogicEngineCoil(1) { Description = "Auto Plunger", DeviceHint = "Plunger", DeviceItemHint = "c_autofire" }, - new GamelogicEngineCoil(2) { Description = "Trough Eject", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "eject_coil" }, - new GamelogicEngineCoil(3) { Description = "Left Popper" }, - new GamelogicEngineCoil(4) { Description = "Castle" }, - new GamelogicEngineCoil(5) { Description = "Castle Gate (Power)" }, - new GamelogicEngineCoil(6) { Description = "Castle Gate (Hold)" }, - new GamelogicEngineCoil(7) { Description = "Knocker" }, - new GamelogicEngineCoil(8) { Description = "Catapult" }, - new GamelogicEngineCoil(9) { Description = "Right Eject" }, - new GamelogicEngineCoil(10) { Description = "Left Slingshot" }, - new GamelogicEngineCoil(11) { Description = "Right Slingshot" }, - new GamelogicEngineCoil(12) { Description = "Left Jet Bumper" }, - new GamelogicEngineCoil(13) { Description = "Bottom Jet Bumper" }, - new GamelogicEngineCoil(14) { Description = "Right Jet Bumper" }, - new GamelogicEngineCoil(15) { Description = "Tower Diverter (Power)" }, - new GamelogicEngineCoil(16) { Description = "Tower Diverter (Hold)" }, - new GamelogicEngineCoil(17) { Description = "Left Side Low Flashers", IsLamp = true }, - new GamelogicEngineCoil(18) { Description = "Left Ramp Flashers", IsLamp = true }, - new GamelogicEngineCoil(19) { Description = "Left Side High Flashers", IsLamp = true }, - new GamelogicEngineCoil(20) { Description = "Right Side High Flashers", IsLamp = true }, - new GamelogicEngineCoil(21) { Description = "Right Ramp Flashers", IsLamp = true }, - new GamelogicEngineCoil(22) { Description = "Castle Right Side Flashers", IsLamp = true }, - new GamelogicEngineCoil(23) { Description = "Right Side Low Flashers", IsLamp = true }, - new GamelogicEngineCoil(24) { Description = "Moat Flashers", IsLamp = true }, - new GamelogicEngineCoil(25) { Description = "Castle Left Side Flashers", IsLamp = true }, - new GamelogicEngineCoil(26) { Description = "Tower Lock Post" }, - new GamelogicEngineCoil(27) { Description = "Right Gate" }, - new GamelogicEngineCoil(28) { Description = "Left Gate" }, - new GamelogicEngineCoil(33) { Description = "Left Troll (Power)" }, - new GamelogicEngineCoil(34) { Description = "Left Troll (Hold)" }, - new GamelogicEngineCoil(35) { Description = "Right Troll (Power)" }, - new GamelogicEngineCoil(36) { Description = "Right Troll (Hold)" }, - new GamelogicEngineCoil(37) { Description = "Drawbridge Motor" }, - }; - } -} diff --git a/VisualPinball.Engine.PinMAME/Games/Rock.cs b/VisualPinball.Engine.PinMAME/Games/Rock.cs deleted file mode 100644 index d74d384..0000000 --- a/VisualPinball.Engine.PinMAME/Games/Rock.cs +++ /dev/null @@ -1,144 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 . - -// ReSharper disable StringLiteralTypo - -using System; -using VisualPinball.Engine.Game.Engines; -using VisualPinball.Engine.PinMAME.MPUs; - -namespace VisualPinball.Engine.PinMAME.Games -{ - [Serializable] - public class Rock : System80 - { - public override string Name => "Rock"; - public override string Id => "rock"; - public override int Year => 1985; - public override string Manufacturer => "Premier"; - public override int IpdbId => 1978; - - public override PinMameRom[] Roms { get; } = { - new PinMameRom("rock"), - new PinMameRom("rockfp", description: "Free Play"), - new PinMameRom("rockg", language: PinMameRomLanguage.German), - new PinMameRom("rockgfp", description: "Free Play", language: PinMameRomLanguage.German), - }; - - protected override PinMameIdAlias[] Aliases { get; } = { - new PinMameIdAlias(45, CoilFlipperUpperRight, AliasType.Coil), - new PinMameIdAlias(46, CoilFlipperLowerRight, AliasType.Coil), - new PinMameIdAlias(47, CoilFlipperUpperLeft, AliasType.Coil), - new PinMameIdAlias(48, CoilFlipperLowerLeft, AliasType.Coil), - }; - - protected override GamelogicEngineSwitch[] Switches { get; } = { - new GamelogicEngineSwitch(40) { Description = "#1 Drop Target (Upper)" }, - new GamelogicEngineSwitch(41) { Description = "10 Point (2)", DeviceHint = "^(sw41[a-f]|(Left|Right)\\sSlingshot)$", NumMatches = 8 }, - new GamelogicEngineSwitch(42) { Description = "Right Flipper (Lower)", DeviceHint = "^LowerRightFlipper$" }, - new GamelogicEngineSwitch(43) { Description = "Rollunder" }, - new GamelogicEngineSwitch(44) { Description = "Right Outside Rollover" }, - new GamelogicEngineSwitch(45) { Description = "Right Spinner (with Bracket)" }, - new GamelogicEngineSwitch(46) { Description = "Right Top Rollover" }, - new GamelogicEngineSwitch(50) { Description = "#2 Drop Target (Upper)" }, - new GamelogicEngineSwitch(51) { Description = "#1 Drop Target (Lower)" }, - new GamelogicEngineSwitch(52) { Description = "Spot Target (with Bracket)" }, - new GamelogicEngineSwitch(53) { Description = "#1 Top Rollover" }, - new GamelogicEngineSwitch(54) { Description = "Right Return Rollover" }, - new GamelogicEngineSwitch(55) { Description = "Right Side Rollover" }, - new GamelogicEngineSwitch(57) { Description = "Tilt (with Bracket)" }, - new GamelogicEngineSwitch(60) { Description = "#3 Drop Targer (Upper)" }, - new GamelogicEngineSwitch(61) { Description = "#2 Drop Targer (Lower)" }, - new GamelogicEngineSwitch(62) { Description = "#4 Drop Targer (Lower)" }, - new GamelogicEngineSwitch(63) { Description = "#2 Top Rollover" }, - new GamelogicEngineSwitch(64) { Description = "Left Return Rollover" }, - new GamelogicEngineSwitch(65) { Description = "Left Side Rollover" }, - new GamelogicEngineSwitch(67) { Description = "Outhole (Assembly)", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "drain_switch" }, - new GamelogicEngineSwitch(70) { Description = "#4 Drop Targer (Upper)" }, - new GamelogicEngineSwitch(71) { Description = "#3 Drop Targer (Lower)" }, - new GamelogicEngineSwitch(72) { Description = "#5 Drop Targer (Lower)" }, - new GamelogicEngineSwitch(73) { Description = "#3 Top Rollover" }, - new GamelogicEngineSwitch(74) { Description = "Left Outside Rollover" }, - new GamelogicEngineSwitch(75) { Description = "Left Spinner (with Bracket)" }, - new GamelogicEngineSwitch(76) { Description = "Left Top Rollover" }, - }; - - public override GamelogicEngineLamp[] AvailableLamps { get; } = { - new GamelogicEngineLamp(1) { Description = "Lamp 1" }, - new GamelogicEngineLamp(3) { Description = "Shoot Again" }, - new GamelogicEngineLamp(5) { Description = "#1 Drop Target (Upper)" }, - new GamelogicEngineLamp(6) { Description = "#2 Drop Target (Upper)" }, - new GamelogicEngineLamp(7) { Description = "#3 Drop Target (Upper)" }, - new GamelogicEngineLamp(8) { Description = "#4 Drop Target (Upper)" }, - new GamelogicEngineLamp(9) { Description = "Level 1" }, - new GamelogicEngineLamp(10) { Description = "Level 2" }, - new GamelogicEngineLamp(11) { Description = "Level 3" }, - new GamelogicEngineLamp(12) { Description = "Lamp 12" }, - new GamelogicEngineLamp(13) { Description = "Lamp 13", DeviceHint = "^L13[a-b]$", NumMatches = 2 }, - new GamelogicEngineLamp(14) { Description = "#1 Drop Target (Lower)" }, - new GamelogicEngineLamp(15) { Description = "#2 Drop Target (Lower)" }, - new GamelogicEngineLamp(16) { Description = "#3 Drop Target (Lower)" }, - new GamelogicEngineLamp(17) { Description = "#4 Drop Target (Lower)" }, - new GamelogicEngineLamp(18) { Description = "#5 Drop Target (Lower)" }, - new GamelogicEngineLamp(19) { Description = "Left Side Rollover" }, - new GamelogicEngineLamp(20) { Description = "Left Return Rollover" }, - new GamelogicEngineLamp(21) { Description = "Right Return Rollover" }, - new GamelogicEngineLamp(22) { Description = "Right Side Rollover" }, - new GamelogicEngineLamp(23) { Description = "#1 Top Rollover (\"A\")" }, - new GamelogicEngineLamp(24) { Description = "#2 Top Rollover (\"B\")" }, - new GamelogicEngineLamp(25) { Description = "#3 Top Rollover (\"C\")" }, - new GamelogicEngineLamp(26) { Description = "\"R\"" }, - new GamelogicEngineLamp(27) { Description = "\"O\"" }, - new GamelogicEngineLamp(28) { Description = "\"C\"" }, - new GamelogicEngineLamp(29) { Description = "\"K\"" }, - new GamelogicEngineLamp(30) { Description = "\"AND\"" }, - new GamelogicEngineLamp(31) { Description = "\"R\"" }, - new GamelogicEngineLamp(32) { Description = "\"O\"" }, - new GamelogicEngineLamp(33) { Description = "\"L\"" }, - new GamelogicEngineLamp(34) { Description = "\"L\"" }, - new GamelogicEngineLamp(35) { Description = "\"L\"" }, - new GamelogicEngineLamp(36) { Description = "\"I\"" }, - new GamelogicEngineLamp(37) { Description = "\"V\"" }, - new GamelogicEngineLamp(38) { Description = "\"E\"" }, - new GamelogicEngineLamp(39) { Description = "\"S\"" }, - new GamelogicEngineLamp(40) { Description = "\"!\"" }, - new GamelogicEngineLamp(41) { Description = "Double Scoring" }, - new GamelogicEngineLamp(42) { Description = "2X" }, - new GamelogicEngineLamp(43) { Description = "3X" }, - new GamelogicEngineLamp(44) { Description = "4X" }, - new GamelogicEngineLamp(45) { Description = "5X" }, - new GamelogicEngineLamp(46) { Description = "Spot Target" }, - new GamelogicEngineLamp(47) { Description = "Left Outside Rollover / Right Spinner", DeviceHint = "^L47[a-b]$", NumMatches = 2 }, - new GamelogicEngineLamp(51) { Description = "Right Outside Rollover / Left Spinner", DeviceHint = "^L51[a-b]$", NumMatches = 2 }, - new GamelogicEngineLamp("l_auxiliary") { Description = "Auxiliary Lamps", DeviceHint = "^Auxiliary$" }, - new GamelogicEngineLamp("l_lower") { Description = "Lower", DeviceHint = "^Lower$" }, - new GamelogicEngineLamp("l_upper_left_1") { Description = "Upper Left 1", DeviceHint = "^UpperLeft1$" }, - new GamelogicEngineLamp("l_upper_left_2") { Description = "Upper Left 2", DeviceHint = "^UpperLeft2$" }, - new GamelogicEngineLamp("l_upper_right") { Description = "Upper Right", DeviceHint = "^UpperRight$" }, - }; - - protected override GamelogicEngineCoil[] GameCoils { get; } = { - new GamelogicEngineCoil(5) { Description = "Five Pos. Bank Reset", DeviceHint = "^5PosBank\\s*" }, - new GamelogicEngineCoil(6) { Description = "Four Pos. Bank Reset", DeviceHint = "^4PosBank\\s*" }, - new GamelogicEngineCoil(8) { Description = "Knocker Assembly" }, - new GamelogicEngineCoil(9) { Description = "Outhole", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "eject_coil" }, - new GamelogicEngineCoil(CoilFlipperUpperRight) { Description = "Upper Right Flipper", DeviceHint = "^UpperRightFlipper$" }, - new GamelogicEngineCoil(CoilFlipperLowerRight) { Description = "Lower Right Flipper", DeviceHint = "^LowerRightFlipper$" }, - new GamelogicEngineCoil(CoilFlipperUpperLeft) { Description = "Upper Left Flipper", DeviceHint = "^UpperLeftFlipper$" }, - new GamelogicEngineCoil(CoilFlipperLowerLeft) { Description = "Lower Left Flipper", DeviceHint = "^LowerLeftFlipper$" }, - }; - } -} diff --git a/VisualPinball.Engine.PinMAME/Games/RockEncore.cs b/VisualPinball.Engine.PinMAME/Games/RockEncore.cs deleted file mode 100644 index a023c0c..0000000 --- a/VisualPinball.Engine.PinMAME/Games/RockEncore.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 . - -// ReSharper disable StringLiteralTypo - -using System; - -namespace VisualPinball.Engine.PinMAME.Games -{ - [Serializable] - public class RockEncore : Rock - { - public override string Name => "Rock Encore"; - public override string Id => "rock_enc"; - public override int Year => 1986; - public override int IpdbId => 1979; - - public override PinMameRom[] Roms { get; } = { - new PinMameRom("rock_enc"), - new PinMameRom("rock_efp", description: "Free Play"), - new PinMameRom("rock_eg", language: PinMameRomLanguage.German), - new PinMameRom("rockegfp", description: "Free Play", language: PinMameRomLanguage.German), - new PinMameRom("clash", version: "Clash, The (Rock Encore unofficial MOD)"), - }; - } -} diff --git a/VisualPinball.Engine.PinMAME/Games/StarTrekEnterprise.cs b/VisualPinball.Engine.PinMAME/Games/StarTrekEnterprise.cs deleted file mode 100644 index 3671c0f..0000000 --- a/VisualPinball.Engine.PinMAME/Games/StarTrekEnterprise.cs +++ /dev/null @@ -1,423 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 . - -// ReSharper disable StringLiteralTypo - -using System; -using VisualPinball.Engine.Game.Engines; -using VisualPinball.Engine.Math; -using VisualPinball.Engine.PinMAME.MPUs; - -namespace VisualPinball.Engine.PinMAME.Games -{ - [Serializable] - public class StarTrekEnterprise : Sam - { - public override string Name => "Star Trek - Enterprise Limited Edition"; - public override string Id => "star-trek-stern"; - public override int Year => 2013; - public override string Manufacturer => "Stern"; - public override int IpdbId => 6046; - public override PinMameRom[] Roms { get; } = { - new PinMameRom("st_120", "1.20"), - new PinMameRom("st_130", "1.30"), - new PinMameRom("st_140", "1.40"), - new PinMameRom("st_140h", "1.40h"), - new PinMameRom("st_141h", "1.41h"), - new PinMameRom("st_142h", "1.42h"), - new PinMameRom("st_150", "1.50"), - new PinMameRom("st_150h", "1.50h"), - new PinMameRom("st_160", "1.60"), - new PinMameRom("st_160h", "1.60h"), - new PinMameRom("st_161", "1.61"), - new PinMameRom("st_161h", "1.61h"), - }; - - protected override PinMameIdAlias[] Aliases { get; } = { - }; - - protected override GamelogicEngineSwitch[] Switches { get; } = { - new GamelogicEngineSwitch(1) { Description = "(Beam) Me Up" }, - new GamelogicEngineSwitch(2) { Description = "Beam (Me) Up" }, - new GamelogicEngineSwitch(4) { Description = "Beam Me (Up)" }, - new GamelogicEngineSwitch(7) { Description = "Right 3-Bank Target (Top)" }, - new GamelogicEngineSwitch(8) { Description = "Right 3-Bank Target (Center)" }, - new GamelogicEngineSwitch(9) { Description = "Right 3-Bank Target (Bottom)" }, - new GamelogicEngineSwitch(10) { Description = "Left Eject" }, - new GamelogicEngineSwitch(11) { Description = "Center Drop Target" }, - new GamelogicEngineSwitch(12) { Description = "Spinner" }, - new GamelogicEngineSwitch(13) { Description = "Warp Ramp Enter" }, - new GamelogicEngineSwitch(14) { Description = "Left Ramp Enter" }, - new GamelogicEngineSwitch(15) { Description = "Tournament Start" }, - new GamelogicEngineSwitch(16) { Description = "Start" }, - new GamelogicEngineSwitch(18) { Description = "Trough #4 (Left)" }, - new GamelogicEngineSwitch(19) { Description = "Trough #3" }, - new GamelogicEngineSwitch(20) { Description = "Trough #2" }, - new GamelogicEngineSwitch(21) { Description = "Trough #1 (Right)" }, - new GamelogicEngineSwitch(22) { Description = "Trough Jam" }, - new GamelogicEngineSwitch(23) { Description = "Shooter Lane" }, - new GamelogicEngineSwitch(24) { Description = "(T)REK" }, - new GamelogicEngineSwitch(25) { Description = "T(R)EK" }, - new GamelogicEngineSwitch(26) { Description = "Left Slingshot" }, - new GamelogicEngineSwitch(27) { Description = "Right Slingshot" }, - new GamelogicEngineSwitch(28) { Description = "TR(E)K" }, - new GamelogicEngineSwitch(29) { Description = "TRE(K)" }, - new GamelogicEngineSwitch(30) { Description = "Left Pop Bumper" }, - new GamelogicEngineSwitch(31) { Description = "Right Pop Bumper" }, - new GamelogicEngineSwitch(32) { Description = "Bottom Pop Bumper" }, - new GamelogicEngineSwitch(33) { Description = "Center Lock (Bottom)" }, - new GamelogicEngineSwitch(34) { Description = "Center Lock (Top)" }, - new GamelogicEngineSwitch(35) { Description = "Left Ramp Exit" }, - new GamelogicEngineSwitch(36) { Description = "Warp Ramp Exit" }, - new GamelogicEngineSwitch(37) { Description = "Right Warp Entrance" }, - new GamelogicEngineSwitch(38) { Description = "Right Ramp Exit" }, - new GamelogicEngineSwitch(39) { Description = "Center 3-Bank Target (Top)" }, - new GamelogicEngineSwitch(40) { Description = "Center 3-Bank Target (Center)" }, - new GamelogicEngineSwitch(41) { Description = "Center 3-Bank Target (Bottom)" }, - new GamelogicEngineSwitch(42) { Description = "Left 2-Bank Target (Top)" }, - new GamelogicEngineSwitch(43) { Description = "Left 2-Bank Target (Bottom)" }, - new GamelogicEngineSwitch(44) { Description = "Behind Upper Flipper" }, - new GamelogicEngineSwitch(45) { Description = "Red Target 1" }, - new GamelogicEngineSwitch(46) { Description = "Red Target 2" }, - new GamelogicEngineSwitch(47) { Description = "Red Target 3" }, - new GamelogicEngineSwitch(48) { Description = "Big Red Target" }, - new GamelogicEngineSwitch(49) { Description = "Red Target 5" }, - new GamelogicEngineSwitch(50) { Description = "Red Target 6" }, - new GamelogicEngineSwitch(51) { Description = "Right Orbit" }, - new GamelogicEngineSwitch(52) { Description = "Left Orbit" }, - new GamelogicEngineSwitch(53) { Description = "Ship Crash" }, - }; - - public override GamelogicEngineLamp[] AvailableLamps { get; } = { - - new GamelogicEngineLamp(84) { Description = "Left Ramp Emblem - R", DeviceHint = "^L1$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(85) { Description = "Left Ramp Emblem - G", DeviceHint = "^L1$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(86) { Description = "Left Ramp Emblem - B", DeviceHint = "^L1$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(81) { Description = "Red Target 2 - R", DeviceHint = "^L2$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(87) { Description = "Red Target 2 - G", DeviceHint = "^L2$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(88) { Description = "Red Target 2 - B", DeviceHint = "^L2$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(83) { Description = "Left Ramp Enterprise Arrow - R", DeviceHint = "^L3$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(82) { Description = "Left Ramp Enterprise Arrow - G", DeviceHint = "^L3$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(89) { Description = "Left Ramp Enterprise Arrow - B", DeviceHint = "^L3$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(92) { Description = "Red Target 3 - R", DeviceHint = "^L4$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(91) { Description = "Red Target 3 - G", DeviceHint = "^L4$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(90) { Description = "Red Target 3 - B", DeviceHint = "^L4$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(93) { Description = "Center Lane Emblem - R", DeviceHint = "^L5$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(94) { Description = "Center Lane Emblem - G", DeviceHint = "^L5$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(95) { Description = "Center Lane Emblem - B", DeviceHint = "^L5$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(96) { Description = "Center Lane Enterprise Arrow - R", DeviceHint = "^L6$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(97) { Description = "Center Lane Enterprise Arrow - G", DeviceHint = "^L6$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(98) { Description = "Center Lane Enterprise Arrow - B", DeviceHint = "^L6$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(99) { Description = "Red Target 4 - R", DeviceHint = "^L7$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(100) { Description = "Red Target 4 - G", DeviceHint = "^L7$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(101) { Description = "Red Target 4 - B", DeviceHint = "^L7$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(102) { Description = "Black Hole Arrow - R", DeviceHint = "^L8$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(103) { Description = "Black Hole Arrow - G", DeviceHint = "^L8$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(104) { Description = "Black Hole Arrow - B", DeviceHint = "^L8$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(113) { Description = "Right Orbit Emblem - R", DeviceHint = "^L9$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(114) { Description = "Right Orbit Emblem - G", DeviceHint = "^L9$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(115) { Description = "Right Orbit Emblem - B", DeviceHint = "^L9$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(116) { Description = "Right Orbit Enterprise Arrow - R", DeviceHint = "^L10$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(117) { Description = "Right Orbit Enterprise Arrow - G", DeviceHint = "^L10$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(118) { Description = "Right Orbit Enterprise Arrow - B", DeviceHint = "^L10$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(119) { Description = "Red Target 6 - R", DeviceHint = "^L11$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(120) { Description = "Red Target 6 - G", DeviceHint = "^L11$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(121) { Description = "Red Target 6 - B", DeviceHint = "^L11$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(122) { Description = "Right Ramp Emblem - R", DeviceHint = "^L12$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(123) { Description = "Right Ramp Emblem - G", DeviceHint = "^L12$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(124) { Description = "Right Ramp Emblem - B", DeviceHint = "^L12$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(125) { Description = "Right Ramp Enterprise Arrow - R", DeviceHint = "^L13$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(126) { Description = "Right Ramp Enterprise Arrow - G", DeviceHint = "^L13$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(127) { Description = "Right Ramp Enterprise Arrow - B", DeviceHint = "^L13$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(128) { Description = "Red Target 5 - R", DeviceHint = "^L14$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(129) { Description = "Red Target 5 - G", DeviceHint = "^L14$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(130) { Description = "Red Target 5 - B", DeviceHint = "^L14$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(131) { Description = "Special - R", DeviceHint = "^L15$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(133) { Description = "Special - G", DeviceHint = "^L15$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(132) { Description = "Special - B", DeviceHint = "^L15$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(134) { Description = "Away Team - R", DeviceHint = "^L16$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(136) { Description = "Away Team - G", DeviceHint = "^L16$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(135) { Description = "Away Team - B", DeviceHint = "^L16$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(146) { Description = "Left Eject Lock - R", DeviceHint = "^L17$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(147) { Description = "Left Eject Lock - G", DeviceHint = "^L17$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(148) { Description = "Left Eject Lock - B", DeviceHint = "^L17$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(149) { Description = "Mission Start - R", DeviceHint = "^L18$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(150) { Description = "Mission Start - G", DeviceHint = "^L18$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(151) { Description = "Mission Start - B", DeviceHint = "^L18$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(152) { Description = "Left 2 Bank - Top - R", DeviceHint = "^L19$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(153) { Description = "Left 2 Bank - Top - G", DeviceHint = "^L19$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(154) { Description = "Left 2 Bank - Top - B", DeviceHint = "^L19$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(155) { Description = "Left Orbit Emblem - R", DeviceHint = "^L20$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(156) { Description = "Left Orbit Emblem - G", DeviceHint = "^L20$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(157) { Description = "Left Orbit Emblem - B", DeviceHint = "^L20$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(158) { Description = "Red Target 1 - R", DeviceHint = "^L21$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(159) { Description = "Red Target 1 - G", DeviceHint = "^L21$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(160) { Description = "Red Target 1 - B", DeviceHint = "^L21$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(161) { Description = "Left Orbit Enterprise Arrow - R", DeviceHint = "^L22$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(162) { Description = "Left Orbit Enterprise Arrow - G", DeviceHint = "^L22$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(163) { Description = "Left Orbit Enterprise Arrow - B", DeviceHint = "^L22$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(164) { Description = "Left 2 Bank (Bottom) - R", DeviceHint = "^L23$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(165) { Description = "Left 2 Bank (Bottom) - G", DeviceHint = "^L23$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(166) { Description = "Left 2 Bank (Bottom) - B", DeviceHint = "^L23$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(167) { Description = "Left Eject Emblem - R", DeviceHint = "^L24$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(168) { Description = "Left Eject Emblem - G", DeviceHint = "^L24$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(169) { Description = "Left Eject Emblem - B", DeviceHint = "^L24$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(170) { Description = "Left Eject Enterprise Arrow - R", DeviceHint = "^L25$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(171) { Description = "Left Eject Enterprise Arrow - G", DeviceHint = "^L25$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(172) { Description = "Left Eject Enterprise Arrow - B", DeviceHint = "^L25$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(179) { Description = "Kickback - R", DeviceHint = "^L26$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(177) { Description = "Kickback - G", DeviceHint = "^L26$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(178) { Description = "Kickback - B", DeviceHint = "^L26$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(182) { Description = "(T)REK - R", DeviceHint = "^L27$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(180) { Description = "(T)REK - G", DeviceHint = "^L27$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(181) { Description = "(T)REK - B", DeviceHint = "^L27$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(185) { Description = "T(R)EK - R", DeviceHint = "^L28$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(183) { Description = "T(R)EK - G", DeviceHint = "^L28$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(184) { Description = "T(R)EK - B", DeviceHint = "^L28$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(186) { Description = "Center 3 Bank (Bottom) - R", DeviceHint = "^L29$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(187) { Description = "Center 3 Bank (Bottom) - G", DeviceHint = "^L29$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(188) { Description = "Center 3 Bank (Bottom) - B", DeviceHint = "^L29$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(189) { Description = "Center 3 Bank (Center) - R", DeviceHint = "^L30$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(190) { Description = "Center 3 Bank (Center) - G", DeviceHint = "^L30$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(191) { Description = "Center 3 Bank (Center) - B", DeviceHint = "^L30$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(192) { Description = "Center 3 Bank (Top) - R", DeviceHint = "^L31$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(193) { Description = "Center 3 Bank (Top) - G", DeviceHint = "^L31$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(194) { Description = "Center 3 Bank (Top) - B", DeviceHint = "^L31$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(195) { Description = "Center Lane Lock - R", DeviceHint = "^L32$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(197) { Description = "Center Lane Lock - G", DeviceHint = "^L32$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(196) { Description = "Center Lane Lock - B", DeviceHint = "^L32$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(198) { Description = "Extra Ball - R", DeviceHint = "^L33$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(200) { Description = "Extra Ball - G", DeviceHint = "^L33$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(199) { Description = "Extra Ball - B", DeviceHint = "^L33$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(214) { Description = "Shoot Again - R", DeviceHint = "^L34$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(215) { Description = "Shoot Again - G", DeviceHint = "^L34$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(216) { Description = "Shoot Again - B", DeviceHint = "^L34$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(211) { Description = "The Captain's Chair - R", DeviceHint = "^L35$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(217) { Description = "The Captain's Chair - G", DeviceHint = "^L35$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(218) { Description = "The Captain's Chair - B", DeviceHint = "^L35$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(213) { Description = "Save the Enterprise - R", DeviceHint = "^L36$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(212) { Description = "Save the Enterprise - G", DeviceHint = "^L36$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(219) { Description = "Save the Enterprise - B", DeviceHint = "^L36$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(222) { Description = "Nero - R", DeviceHint = "^L37$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(221) { Description = "Nero - G", DeviceHint = "^L37$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(220) { Description = "Nero - B", DeviceHint = "^L37$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(223) { Description = "Destroy the Drill - R", DeviceHint = "^L38$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(224) { Description = "Destroy the Drill - G", DeviceHint = "^L38$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(225) { Description = "Destroy the Drill - B", DeviceHint = "^L38$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(226) { Description = "Space Jump - R", DeviceHint = "^L39$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(227) { Description = "Space Jump - G", DeviceHint = "^L39$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(228) { Description = "Space Jump - B", DeviceHint = "^L39$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(229) { Description = "Prime Directive - R", DeviceHint = "^L40$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(230) { Description = "Prime Directive - G", DeviceHint = "^L40$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(231) { Description = "Prime Directive - B", DeviceHint = "^L40$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(232) { Description = "Klingon Battle - R", DeviceHint = "^L41$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(233) { Description = "Klingon Battle - G", DeviceHint = "^L41$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(234) { Description = "Klingon Battle - B", DeviceHint = "^L41$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(235) { Description = "Status 1 (Bottom) - R", DeviceHint = "^L42$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(236) { Description = "Status 1 (Bottom) - G", DeviceHint = "^L42$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(237) { Description = "Status 1 (Bottom) - B", DeviceHint = "^L42$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(238) { Description = "Status 2 - R", DeviceHint = "^L43$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(239) { Description = "Status 2 - G", DeviceHint = "^L43$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(240) { Description = "Status 2 - B", DeviceHint = "^L43$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(241) { Description = "Status 3 - R", DeviceHint = "^L44$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(242) { Description = "Status 3 - G", DeviceHint = "^L44$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(243) { Description = "Status 3 - B", DeviceHint = "^L44$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(244) { Description = "Status 4 - R", DeviceHint = "^L45$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(245) { Description = "Status 4 - G", DeviceHint = "^L45$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(246) { Description = "Status 4 - B", DeviceHint = "^L45$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(247) { Description = "Status 5 - R", DeviceHint = "^L46$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(248) { Description = "Status 5 - G", DeviceHint = "^L46$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(249) { Description = "Status 5 - B", DeviceHint = "^L46$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(250) { Description = "Status 6 - R", DeviceHint = "^L47$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(251) { Description = "Status 6 - G", DeviceHint = "^L47$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(252) { Description = "Status 6 - B", DeviceHint = "^L47$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(253) { Description = "Status 7 - R", DeviceHint = "^L48$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(254) { Description = "Status 7 - G", DeviceHint = "^L48$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(255) { Description = "Status 7 - B", DeviceHint = "^L48$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(256) { Description = "Status 8 (Top) - R", DeviceHint = "^L49$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(257) { Description = "Status 8 (Top) - G", DeviceHint = "^L49$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(258) { Description = "Status 8 (Top) - B", DeviceHint = "^L49$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(276) { Description = "Warp Ramp Red", DeviceHint = "^L50$" }, - new GamelogicEngineLamp(277) { Description = "Enterprise", DeviceHint = "^L51$" }, - - new GamelogicEngineLamp(278) { Description = "Warp Ramp Emblem - R", DeviceHint = "^L52$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(279) { Description = "Warp Ramp Emblem - G", DeviceHint = "^L52$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(280) { Description = "Warp Ramp Emblem - B", DeviceHint = "^L52$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(281) { Description = "(Beam) Me Up - B", DeviceHint = "^L53$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(283) { Description = "(Beam) Me Up - B", DeviceHint = "^L53$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(282) { Description = "(Beam) Me Up - B", DeviceHint = "^L53$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(284) { Description = "Beam (Me) Up - R", DeviceHint = "^L54$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(286) { Description = "Beam (Me) Up - G", DeviceHint = "^L54$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(285) { Description = "Beam (Me) Up - B", DeviceHint = "^L54$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(287) { Description = "Beam Me (Up)", DeviceHint = "^L55$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(289) { Description = "Beam Me (Up) - G", DeviceHint = "^L55$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(288) { Description = "Beam Me (Up) - B", DeviceHint = "^L55$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(290) { Description = "Vengeance Saucer", DeviceHint = "^L56$" }, - new GamelogicEngineLamp(291) { Description = "Vengeance Nacelles (X2)", DeviceHint = "^L57$" }, - - new GamelogicEngineLamp(308) { Description = "Right 3 Bank (Top) - R", DeviceHint = "^L58$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(309) { Description = "Right 3 Bank (Top) - G", DeviceHint = "^L58$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(310) { Description = "Right 3 Bank (Top) - B", DeviceHint = "^L58$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(311) { Description = "Right 3 Bank (Center) - R", DeviceHint = "^L59$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(312) { Description = "Right 3 Bank (Center) - G", DeviceHint = "^L59$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(313) { Description = "Right 3 Bank (Center) - B", DeviceHint = "^L59$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(314) { Description = "Right 3 Bank (Bottom) - R", DeviceHint = "^L60$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(315) { Description = "Right 3 Bank (Bottom) - G", DeviceHint = "^L60$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(316) { Description = "Right 3 Bank (Bottom) - B", DeviceHint = "^L60$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(317) { Description = "TR(E)K - R", DeviceHint = "^L61$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(319) { Description = "TR(E)K - G", DeviceHint = "^L61$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(318) { Description = "TR(E)K - B", DeviceHint = "^L61$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(320) { Description = "TRE(K) - R", DeviceHint = "^L62$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(322) { Description = "TRE(K) - G", DeviceHint = "^L62$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(321) { Description = "TRE(K) - B", DeviceHint = "^L62$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(300) { Description = "Left Apron (X2) - R", DeviceHint = "^L63$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(302) { Description = "Left Apron (X2) - G", DeviceHint = "^L63$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(301) { Description = "Left Apron (X2) - B", DeviceHint = "^L63$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(303) { Description = "Right Apron (X2) - R", DeviceHint = "^L64$", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(305) { Description = "Right Apron (X2) - G", DeviceHint = "^L64$", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(304) { Description = "Right Apron (X2) - B", DeviceHint = "^L64$", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(80) { Description = "Start Button", DeviceHint = "^L65$" }, - new GamelogicEngineLamp(78) { Description = "Fire (Red)", DeviceHint = "^L66$" }, - new GamelogicEngineLamp(77) { Description = "Fire (Green)", DeviceHint = "^L67$" }, - new GamelogicEngineLamp(76) { Description = "Fire (Blue)", DeviceHint = "^L68$" }, - new GamelogicEngineLamp(79) { Description = "Tournament Start Button", DeviceHint = "^L69$" }, - new GamelogicEngineLamp(295) { Description = "Warp Chaser 1 (LE)", DeviceHint = "^L70$" }, - new GamelogicEngineLamp(292) { Description = "Warp Chaser 2 (LE)", DeviceHint = "^L71$" }, - new GamelogicEngineLamp(293) { Description = "Warp Chaser 3 (LE)", DeviceHint = "^L72$" }, - new GamelogicEngineLamp(294) { Description = "Warp Chaser 4 (LE)", DeviceHint = "^L73$" }, - new GamelogicEngineLamp(299) { Description = "Warp Chaser 5 (LE)", DeviceHint = "^L74$" }, - new GamelogicEngineLamp(296) { Description = "Warp Chaser 6 (LE)", DeviceHint = "^L75$" }, - new GamelogicEngineLamp(297) { Description = "Warp Chaser 7 (LE)", DeviceHint = "^L76$" }, - new GamelogicEngineLamp(298) { Description = "Warp Chaser 8 (LE)", DeviceHint = "^L77$" }, - new GamelogicEngineLamp(50) { Description = "Cabinet Side Enterprise (X2) (LE)", DeviceHint = "^L78$" }, - new GamelogicEngineLamp(51) { Description = "Cabinet Side Phaser 1 (X2) (Front) (LE)", DeviceHint = "^L79$" }, - new GamelogicEngineLamp(52) { Description = "Cabinet Side Phaser 2 (X2) (LE)", DeviceHint = "^L80$" }, - new GamelogicEngineLamp(53) { Description = "Cabinet Side Phaser 3 (X2) (LE)", DeviceHint = "^L81$" }, - new GamelogicEngineLamp(54) { Description = "Cabinet Side Phaser 4 (X2) (LE)", DeviceHint = "^L82$" }, - new GamelogicEngineLamp(55) { Description = "Cabinet Side Phaser 5 (X2) (LE)", DeviceHint = "^L83$" }, - new GamelogicEngineLamp(56) { Description = "Cabinet Side Phaser 6 (X2) (LE)", DeviceHint = "^L84$" }, - new GamelogicEngineLamp(57) { Description = "Cabinet Side Phaser 7 (X2) (LE)", DeviceHint = "^L85$" }, - new GamelogicEngineLamp(58) { Description = "Cabinet Side Phaser 8 (X2) (LE)", DeviceHint = "^L86$" }, - new GamelogicEngineLamp(59) { Description = "Cabinet Side Phaser 9 (X2) (LE)", DeviceHint = "^L87$" }, - new GamelogicEngineLamp(60) { Description = "Cabinet Side Phaser 10 (X2) (LE)", DeviceHint = "^L88$" }, - new GamelogicEngineLamp(61) { Description = "Cabinet Side Phaser 11 (X2) (LE)", DeviceHint = "^L89$" }, - new GamelogicEngineLamp(62) { Description = "Cabinet Side Phaser 12(X2) (LE)", DeviceHint = "^L90$" }, - new GamelogicEngineLamp(63) { Description = "Cabinet Side Phaser 13 (X2) (LE)", DeviceHint = "^L91$" }, - new GamelogicEngineLamp(64) { Description = "Cabinet Side Phaser 14 (X2) (LE)", DeviceHint = "^L92$" }, - new GamelogicEngineLamp(65) { Description = "Cabinet Side Phaser 15 (X2) (LE)", DeviceHint = "^L93$" }, - new GamelogicEngineLamp(66) { Description = "Cabinet Side Phaser 16 (X2) (LE)", DeviceHint = "^L94$" }, - new GamelogicEngineLamp(67) { Description = "Cabinet Side Phaser 17 (X2) (LE)", DeviceHint = "^L95$" }, - new GamelogicEngineLamp(68) { Description = "Cabinet Side Phaser 18 (X2) (LE)", DeviceHint = "^L96$" }, - new GamelogicEngineLamp(69) { Description = "Cabinet Side Phaser 19 (X2) (LE)", DeviceHint = "^L97$" }, - new GamelogicEngineLamp(70) { Description = "Cabinet Side Phaser 20 (X2) (LE)", DeviceHint = "^L98$" }, - new GamelogicEngineLamp(71) { Description = "Cabinet Side Phaser 21 (X2) (LE)", DeviceHint = "^L99$" }, - new GamelogicEngineLamp(72) { Description = "Cabinet Side Phaser 22 (Back) (X2) (LE)", DeviceHint = "^L100$" }, - }; - - protected override GamelogicEngineCoil[] GameCoils { get; } = { - - new GamelogicEngineCoil(1) { Description = "Trough Up-Kicker" }, - new GamelogicEngineCoil(2) { Description = "Auto Launch" }, - new GamelogicEngineCoil(3) { Description = "Magnet" }, - new GamelogicEngineCoil(4) { Description = "Center Drop Target Up" }, - new GamelogicEngineCoil(5) { Description = "Center Drop Target Down" }, - new GamelogicEngineCoil(6) { Description = "Left Eject" }, - new GamelogicEngineCoil(7) { Description = "Vengeance Kick Back" }, - new GamelogicEngineCoil(8) { Description = "Shaker Motor (optional)" }, - new GamelogicEngineCoil(9) { Description = "Left Pop Bumper" }, - new GamelogicEngineCoil(10) { Description = "Right Pop Bumper" }, - new GamelogicEngineCoil(11) { Description = "Bottom Pop Bumper" }, - new GamelogicEngineCoil(12) { Description = "Upper Right Flipper" }, - new GamelogicEngineCoil(13) { Description = "Left Slingshot" }, - new GamelogicEngineCoil(14) { Description = "Right Slingshot" }, - new GamelogicEngineCoil(17) { Description = "Flash: Asteroid (left)", IsLamp = true }, - new GamelogicEngineCoil(18) { Description = "Flash: Asteroid (right)", IsLamp = true }, - new GamelogicEngineCoil(19) { Description = "Flash: Left Ramp (top)", IsLamp = true }, - new GamelogicEngineCoil(20) { Description = "Flash: Right Ramp (top)", IsLamp = true }, - new GamelogicEngineCoil(21) { Description = "Flash: Kick Back", IsLamp = true }, - new GamelogicEngineCoil(22) { Description = "Laser Motor" }, - new GamelogicEngineCoil(23) { Description = "Flash: Ramp (left)", IsLamp = true }, - new GamelogicEngineCoil(25) { Description = "Flash: Pop Bumpers", IsLamp = true }, - new GamelogicEngineCoil(26) { Description = "Flash: Warp Ramp Entrance", IsLamp = true }, - new GamelogicEngineCoil(27) { Description = "Flash: Center Three Banks", IsLamp = true }, - new GamelogicEngineCoil(28) { Description = "Flash: Ramp (right)", IsLamp = true }, - new GamelogicEngineCoil(29) { Description = "Flash: Left Loop", IsLamp = true }, - new GamelogicEngineCoil(30) { Description = "Flash: Upper Right Flipper", IsLamp = true }, - new GamelogicEngineCoil(31) { Description = "Flash: Vengeance Ship", IsLamp = true }, - new GamelogicEngineCoil(32) { Description = "Flash: Bottom Spot (left)", IsLamp = true }, - }; - } -} diff --git a/VisualPinball.Engine.PinMAME/Games/TRONLegacy.cs b/VisualPinball.Engine.PinMAME/Games/TRONLegacy.cs deleted file mode 100644 index 8924e6b..0000000 --- a/VisualPinball.Engine.PinMAME/Games/TRONLegacy.cs +++ /dev/null @@ -1,200 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 . - -// ReSharper disable StringLiteralTypo - -using System; -using VisualPinball.Engine.Game.Engines; -using VisualPinball.Engine.PinMAME.MPUs; - -namespace VisualPinball.Engine.PinMAME.Games -{ - [Serializable] - public class TRONLegacy : Sam - { - public override string Name => "TRON Legacy - Limited Edition"; - public override string Id => "trn"; - public override int Year => 2011; - public override string Manufacturer => "Stern"; - public override int IpdbId => 5707; - public override PinMameRom[] Roms { get; } = { - new PinMameRom("trn_100h", "1.00h"), - new PinMameRom("trn_110h", "1.10h"), - new PinMameRom("trn_110", "1.10"), - new PinMameRom("trn_120", "1.20"), - new PinMameRom("trn_130h", "1.30h"), - new PinMameRom("trn_140", "1.4"), - new PinMameRom("trn_140h", "1.40h"), - new PinMameRom("trn_150", "1.50"), - new PinMameRom("trn_160", "1.6"), - new PinMameRom("trn_170", "1.7"), - new PinMameRom("trn_174", "1.74"), - new PinMameRom("trn_174h", "1.74h"), - new PinMameRom("trn_17402", "1.7402"), - new PinMameRom("trn_1741h", "1.741h"), - }; - - protected override PinMameIdAlias[] Aliases { get; } = { - }; - - protected override GamelogicEngineSwitch[] Switches { get; } = { - new GamelogicEngineSwitch(1) { Description = "TRO(N)" }, - new GamelogicEngineSwitch(2) { Description = "TR(O)N" }, - new GamelogicEngineSwitch(3) { Description = "T(R)ON" }, - new GamelogicEngineSwitch(4) { Description = "(T)RON" }, - new GamelogicEngineSwitch(7) { Description = "(Z)USE" }, - new GamelogicEngineSwitch(8) { Description = "Z(U)SE" }, - new GamelogicEngineSwitch(11) { Description = "Video Game Eject" }, - new GamelogicEngineSwitch(12) { Description = "Zen Rollover" }, - new GamelogicEngineSwitch(13) { Description = "ZUS(E)" }, - new GamelogicEngineSwitch(14) { Description = "C(L)U" }, - new GamelogicEngineSwitch(18) { Description = "Trough #4 Left" }, - new GamelogicEngineSwitch(19) { Description = "Trough #3" }, - new GamelogicEngineSwitch(20) { Description = "Trough #2" }, - new GamelogicEngineSwitch(21) { Description = "Trough #1 Right" }, - new GamelogicEngineSwitch(22) { Description = "Trough Jam" }, - new GamelogicEngineSwitch(23) { Description = "Shooter Lane" }, - new GamelogicEngineSwitch(24) { Description = "Left Outlane" }, - new GamelogicEngineSwitch(25) { Description = "(C)LU" }, - new GamelogicEngineSwitch(26) { Description = "Left Slingshot" }, - new GamelogicEngineSwitch(27) { Description = "Right Slingshot" }, - new GamelogicEngineSwitch(28) { Description = "CL(U)" }, - new GamelogicEngineSwitch(29) { Description = "Right Outlane" }, - new GamelogicEngineSwitch(30) { Description = "Left Bumper" }, - new GamelogicEngineSwitch(31) { Description = "Right Bumper" }, - new GamelogicEngineSwitch(32) { Description = "Bottom Bumper" }, - new GamelogicEngineSwitch(34) { Description = "Right Ramp Exit" }, - new GamelogicEngineSwitch(35) { Description = "Left Ramp Entrance" }, - new GamelogicEngineSwitch(36) { Description = "Right Orbit Spinner" }, - new GamelogicEngineSwitch(37) { Description = "Left Ramp Exit" }, - new GamelogicEngineSwitch(38) { Description = "Right Ramp Entrance" }, - new GamelogicEngineSwitch(39) { Description = "Right Inner Loop" }, - new GamelogicEngineSwitch(41) { Description = "Disc Opto" }, - new GamelogicEngineSwitch(43) { Description = "Left Orbit" }, - new GamelogicEngineSwitch(44) { Description = "Left Spinner" }, - new GamelogicEngineSwitch(46) { Description = "Right Orbit" }, - new GamelogicEngineSwitch(48) { Description = "ZU(S)E" }, - new GamelogicEngineSwitch(49) { Description = "Recognizer 3-Bank (Left)" }, - new GamelogicEngineSwitch(50) { Description = "Recognizer 3-Bank (Center)" }, - new GamelogicEngineSwitch(51) { Description = "Recognizer 3-Bank (Right)" }, - new GamelogicEngineSwitch(52) { Description = "3-Bank Motor (Down)" }, - new GamelogicEngineSwitch(53) { Description = "3-Bank Motor (Up)" }, - new GamelogicEngineSwitch(54) { Description = "Recognizer Motor Pos 1" }, - new GamelogicEngineSwitch(55) { Description = "Recognizer Motor Pos 2" }, - new GamelogicEngineSwitch(56) { Description = "Recognizer Motor Pos 3" } - }; - - public override GamelogicEngineLamp[] AvailableLamps { get; } = { - new GamelogicEngineLamp(1) { Description = "TRO(N)" }, - new GamelogicEngineLamp(2) { Description = "TR(O)N" }, - new GamelogicEngineLamp(3) { Description = "T(R)ON" }, - new GamelogicEngineLamp(4) { Description = "(T)RON" }, - new GamelogicEngineLamp(5) { Description = "Tron Double Scoring" }, - new GamelogicEngineLamp(6) { Description = "Bumpers" }, - new GamelogicEngineLamp(7) { Description = "Spinners" }, - new GamelogicEngineLamp(8) { Description = "Left Outlane" }, - new GamelogicEngineLamp(9) { Description = "(C)LU" }, - new GamelogicEngineLamp(10) { Description = "Z(U)SE" }, - new GamelogicEngineLamp(11) { Description = "Left Ramp (Light Cycle)" }, - new GamelogicEngineLamp(12) { Description = "(Z)USE" }, - new GamelogicEngineLamp(13) { Description = "Left Orbit (CLU)" }, - new GamelogicEngineLamp(14) { Description = "Left Orbit (Light Cycle)" }, - new GamelogicEngineLamp(15) { Description = "Left Orbit (Disc)" }, - new GamelogicEngineLamp(16) { Description = "Left Loop Arrow" }, - new GamelogicEngineLamp(17) { Description = "Center Portal" }, - new GamelogicEngineLamp(18) { Description = "Center TRON" }, - new GamelogicEngineLamp(19) { Description = "Center Recognizer" }, - new GamelogicEngineLamp(20) { Description = "Center Light Cycle" }, - new GamelogicEngineLamp(21) { Description = "Center Disc" }, - new GamelogicEngineLamp(22) { Description = "Center Quorra" }, - new GamelogicEngineLamp(23) { Description = "Center ZUSE" }, - new GamelogicEngineLamp(24) { Description = "Center CLU" }, - new GamelogicEngineLamp(25) { Description = "Center Gem" }, - new GamelogicEngineLamp(26) { Description = "Shoot Again" }, - new GamelogicEngineLamp(27) { Description = "Center Flynn" }, - new GamelogicEngineLamp(28) { Description = "Eject CLU" }, - new GamelogicEngineLamp(29) { Description = "ZUS(E)" }, - new GamelogicEngineLamp(30) { Description = "C(L)U" }, - new GamelogicEngineLamp(31) { Description = "CL(U)" }, - new GamelogicEngineLamp(32) { Description = "Right Outlane" }, - new GamelogicEngineLamp(33) { Description = "Right Loop Arrow" }, - new GamelogicEngineLamp(34) { Description = "Right Orbit (Disc)" }, - new GamelogicEngineLamp(35) { Description = "Right Orbit (Light Cycle)" }, - new GamelogicEngineLamp(36) { Description = "Right Orbit (CLU)" }, - new GamelogicEngineLamp(37) { Description = "Eject Extra Ball" }, - new GamelogicEngineLamp(38) { Description = "Eject Light Cylce" }, - new GamelogicEngineLamp(39) { Description = "Eject Quorra" }, - new GamelogicEngineLamp(40) { Description = "Eject Portal" }, - new GamelogicEngineLamp(42) { Description = "Right Ramp (Light Cycle)" }, - new GamelogicEngineLamp(43) { Description = "ZU(S)E" }, - new GamelogicEngineLamp(45) { Description = "Flynn's Arcade" }, - new GamelogicEngineLamp(46) { Description = "Left Bumper" }, - new GamelogicEngineLamp(47) { Description = "Right Bumper" }, - new GamelogicEngineLamp(48) { Description = "Bottom Bumper" }, - new GamelogicEngineLamp(49) { Description = "Left Ramp Arrow" }, - new GamelogicEngineLamp(50) { Description = "Left Ramp (Disc)" }, - new GamelogicEngineLamp(51) { Description = "Recognizer Right" }, - new GamelogicEngineLamp(52) { Description = "Recognizer Center" }, - new GamelogicEngineLamp(53) { Description = "Recognizer Left" }, - new GamelogicEngineLamp(54) { Description = "Recognizer 3-Bank" }, - new GamelogicEngineLamp(55) { Description = "Right Ramp (Disc)" }, - new GamelogicEngineLamp(56) { Description = "Right Ramp Arrow" }, - new GamelogicEngineLamp(57) { Description = "Right Inner Loop Arrow" }, - new GamelogicEngineLamp(58) { Description = "Right Inner Loop (Disc)" }, - new GamelogicEngineLamp(59) { Description = "Right Inner Loop (Light Cycle)" }, - new GamelogicEngineLamp(60) { Description = "Advance Quorra" }, - new GamelogicEngineLamp(61) { Description = "Left Inner Loop (CLU)" }, - new GamelogicEngineLamp(62) { Description = "Left Inner Loop (Light Cycle)" }, - new GamelogicEngineLamp(63) { Description = "Left Inner Loop (Disc)" }, - new GamelogicEngineLamp(64) { Description = "Left Inner Loop Arrow" }, - new GamelogicEngineLamp(65) { Description = "Start Button" }, - new GamelogicEngineLamp(66) { Description = "Tournament Start Button" }, - }; - - protected override GamelogicEngineCoil[] GameCoils { get; } = { - new GamelogicEngineCoil(1) { Description = "Trough Up-Kicker" }, - new GamelogicEngineCoil(2) { Description = "Auto Launch" }, - new GamelogicEngineCoil(3) { Description = "4-Bank Drop Target" }, - new GamelogicEngineCoil(4) { Description = "Video Game Eject" }, - new GamelogicEngineCoil(5) { Description = "Disc Motor Power" }, - new GamelogicEngineCoil(6) { Description = "Recognizer 3-Bank Motor / Relay" }, - new GamelogicEngineCoil(7) { Description = "Orbit Up / Down Post" }, - new GamelogicEngineCoil(8) { Description = "Shaker Motor (Optional)" }, - new GamelogicEngineCoil(9) { Description = "Left Pop Bumper" }, - new GamelogicEngineCoil(10) { Description = "Right Pop Bumper" }, - new GamelogicEngineCoil(11) { Description = "Bottom Pop Bumper" }, - new GamelogicEngineCoil(12) { Description = "Upper Left Flipper" }, - new GamelogicEngineCoil(13) { Description = "Left Slingshot" }, - new GamelogicEngineCoil(14) { Description = "Right Slingshot" }, - new GamelogicEngineCoil(17) { Description = "Zen Flasher", IsLamp = true }, - new GamelogicEngineCoil(18) { Description = "Flash: Video Game", IsLamp = true }, - new GamelogicEngineCoil(19) { Description = "Flash: Right Domes (X2)", IsLamp = true }, - new GamelogicEngineCoil(20) { Description = "Flash: Bottom Arch (Left)", IsLamp = true }, - new GamelogicEngineCoil(21) { Description = "Flash: Bottom Arch (Right)", IsLamp = true }, - new GamelogicEngineCoil(22) { Description = "Disc Direction Relay" }, - new GamelogicEngineCoil(23) { Description = "Recognizer Relay" }, - new GamelogicEngineCoil(24) { Description = "Optional (e.g. Coin Meter)" }, - new GamelogicEngineCoil(25) { Description = "Flash: Left Domes (X2)", IsLamp = true }, - new GamelogicEngineCoil(26) { Description = "Flash: Disc (Left)", IsLamp = true }, - new GamelogicEngineCoil(27) { Description = "Flash: Disc (Right)", IsLamp = true }, - new GamelogicEngineCoil(28) { Description = "Flash: Backpanel (X2)", IsLamp = true }, - new GamelogicEngineCoil(29) { Description = "Flash: Recognizer", IsLamp = true }, - new GamelogicEngineCoil(30) { Description = "Disc Motor Relay" }, - new GamelogicEngineCoil(31) { Description = "Flash: Red Disc (Left) (X2)", IsLamp = true }, - new GamelogicEngineCoil(32) { Description = "Flash: Red Disc (Right) (X2)", IsLamp = true }, - }; - } -} diff --git a/VisualPinball.Engine.PinMAME/Games/Terminator2.cs b/VisualPinball.Engine.PinMAME/Games/Terminator2.cs deleted file mode 100644 index c50afb6..0000000 --- a/VisualPinball.Engine.PinMAME/Games/Terminator2.cs +++ /dev/null @@ -1,230 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 . - -// ReSharper disable StringLiteralTypo - -using System; -using VisualPinball.Engine.Common; -using VisualPinball.Engine.Game.Engines; -using VisualPinball.Engine.PinMAME.MPUs; - -namespace VisualPinball.Engine.PinMAME.Games -{ - [Serializable] - public class Terminator2 : Wpc - { - public override string Name => "Terminator 2: Judgment Day"; - public override string Id => "t2"; - public override int Year => 1991; - public override string Manufacturer => "Williams"; - public override int IpdbId => 2524; - public override PinMameRom[] Roms { get; } = { - new PinMameRom("t2_l2", "L-2"), - new PinMameRom("t2_l3", "L-3"), - new PinMameRom("t2_l4", "L-4"), - new PinMameRom("t2_l6", "L-6"), - new PinMameRom("t2_l8", "L-8"), - new PinMameRom("t2_l81", "L-81"), - new PinMameRom("t2_l82", "L-82"), - new PinMameRom("t2_p2f", "P-2F", "Profanity ROM"), - new PinMameRom("t2_f19", "0.19", "FreeWPC"), - new PinMameRom("t2_f20", "0.20", "FreeWPC"), - new PinMameRom("t2_f32", "0.32", "FreeWPC"), - }; - - protected override PinMameIdAlias[] Aliases { get; } = { - new PinMameIdAlias(46, CoilFlipperLowerRight, AliasType.Coil), - new PinMameIdAlias(48, CoilFlipperLowerLeft, AliasType.Coil), - new PinMameIdAlias(34, CoilFlipperUpperRight, AliasType.Coil), - new PinMameIdAlias(36, CoilFlipperUpperLeft, AliasType.Coil), - }; - - protected override GamelogicEngineSwitch[] Switches { get; } = { - new GamelogicEngineSwitch(13) { Description = "Start Button", InputActionHint = InputConstants.ActionStartGame }, - new GamelogicEngineSwitch(14) { Description = "Plumb Bob Tilt" }, - new GamelogicEngineSwitch(15) { Description = "Trough Left", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "3" }, - new GamelogicEngineSwitch(16) { Description = "Trough Center", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "2" }, - new GamelogicEngineSwitch(17) { Description = "Trough Right", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "1" }, - new GamelogicEngineSwitch(18) { Description = "Outhole", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "drain_switch" }, - - //new GamelogicEngineSwitch(21) { Description = "Slam Tilt" }, - new GamelogicEngineSwitch(22) { Description = "Coin Door Closed", NormallyClosed = true, InputActionHint = InputConstants.ActionCoinDoorOpenClose }, - new GamelogicEngineSwitch(23) { Description = "Ticket Dispenser" }, - new GamelogicEngineSwitch(24) { Description = "Test Position, Always Closed", ConstantHint = SwitchConstantHint.AlwaysClosed}, - new GamelogicEngineSwitch(25) { Description = "Left Outlane" }, - new GamelogicEngineSwitch(26) { Description = "Left Return Lane" }, - new GamelogicEngineSwitch(27) { Description = "Right Return Lane" }, - new GamelogicEngineSwitch(28) { Description = "Right Outlane" }, - - new GamelogicEngineSwitch(31) { Description = "Gun Loaded" }, - new GamelogicEngineSwitch(32) { Description = "Gun Mark" }, - new GamelogicEngineSwitch(33) { Description = "Gun Home" }, - new GamelogicEngineSwitch(34) { Description = "Grip Trigger", InputActionHint = InputConstants.ActionPlunger }, - new GamelogicEngineSwitch(36) { Description = "Mid Left Stand-up Target" }, - new GamelogicEngineSwitch(37) { Description = "Mid Center Stand-up Target" }, - new GamelogicEngineSwitch(38) { Description = "Mid Right Stand-up Target" }, - - new GamelogicEngineSwitch(41) { Description = "Left Jet" }, - new GamelogicEngineSwitch(42) { Description = "Right Jet" }, - new GamelogicEngineSwitch(43) { Description = "Bottom Jet" }, - new GamelogicEngineSwitch(44) { Description = "Left Sling" }, - new GamelogicEngineSwitch(45) { Description = "Right Sling" }, - new GamelogicEngineSwitch(46) { Description = "Top Right Stand-up Target" }, - new GamelogicEngineSwitch(47) { Description = "Mid Right Stand-up Target" }, - new GamelogicEngineSwitch(48) { Description = "Bottom Right Stand-up Target" }, - - new GamelogicEngineSwitch(51) { Description = "Left Lock" }, - new GamelogicEngineSwitch(53) { Description = "Low Escape Route" }, - new GamelogicEngineSwitch(54) { Description = "High Escape Route" }, - new GamelogicEngineSwitch(55) { Description = "Top Lock" }, - new GamelogicEngineSwitch(56) { Description = "Top Lane Left" }, - new GamelogicEngineSwitch(57) { Description = "Top Lane Center" }, - new GamelogicEngineSwitch(58) { Description = "Top Lane Right" }, - - new GamelogicEngineSwitch(61) { Description = "Left Ramp Entry" }, - new GamelogicEngineSwitch(62) { Description = "Left Ramp Made" }, - new GamelogicEngineSwitch(63) { Description = "Right Ramp Entry" }, - new GamelogicEngineSwitch(64) { Description = "Right Ramp Made" }, - new GamelogicEngineSwitch(65) { Description = "Low Chase Loop" }, - new GamelogicEngineSwitch(66) { Description = "High Chase Loop" }, - - new GamelogicEngineSwitch(71) { Description = "Target 1 High" }, - new GamelogicEngineSwitch(72) { Description = "Target 2" }, - new GamelogicEngineSwitch(73) { Description = "Target 3" }, - new GamelogicEngineSwitch(74) { Description = "Target 4" }, - new GamelogicEngineSwitch(75) { Description = "Target 5 Low" }, - new GamelogicEngineSwitch(76) { Description = "Ball Popper" }, - new GamelogicEngineSwitch(77) { Description = "Drop Target" }, - new GamelogicEngineSwitch(78) { Description = "Shooter" }, - }; - - public override GamelogicEngineLamp[] AvailableLamps { get; } = { - new GamelogicEngineLamp(11) { Description = "Multiplier 2x" }, - new GamelogicEngineLamp(12) { Description = "Multiplier 4x" }, - new GamelogicEngineLamp(13) { Description = "Hold Bonus" }, - new GamelogicEngineLamp(14) { Description = "Multiplier 6x" }, - new GamelogicEngineLamp(15) { Description = "Multiplier 8x" }, - new GamelogicEngineLamp(16) { Description = "Shoot Again" }, - new GamelogicEngineLamp(17) { Description = "Mouth" }, - - new GamelogicEngineLamp(21) { Description = "Kickback" }, - new GamelogicEngineLamp(22) { Description = "Special" }, - new GamelogicEngineLamp(23) { Description = "Left Return Lane" }, - new GamelogicEngineLamp(24) { Description = "Right Return Lane" }, - new GamelogicEngineLamp(25) { Description = "Data Base" }, - new GamelogicEngineLamp(26) { Description = "Load Gun" }, - new GamelogicEngineLamp(27) { Description = "Extra Ball" }, - new GamelogicEngineLamp(28) { Description = "Load for Jackpot" }, - - new GamelogicEngineLamp(31) { Description = "Target 1 High" }, - new GamelogicEngineLamp(32) { Description = "Target 2" }, - new GamelogicEngineLamp(33) { Description = "Target 3" }, - new GamelogicEngineLamp(34) { Description = "Target 4" }, - new GamelogicEngineLamp(35) { Description = "Target 5 Low" }, - new GamelogicEngineLamp(36) { Description = "Middle Target Bank Left" }, - new GamelogicEngineLamp(37) { Description = "Middle Target Bank Center" }, - new GamelogicEngineLamp(38) { Description = "Middle Target Bank Right" }, - - new GamelogicEngineLamp(41) { Description = "Lock Two" }, - new GamelogicEngineLamp(42) { Description = "Data Base" }, - new GamelogicEngineLamp(43) { Description = "10 Million" }, - new GamelogicEngineLamp(44) { Description = "Extra Ball" }, - new GamelogicEngineLamp(45) { Description = "Multi-ball" }, - new GamelogicEngineLamp(46) { Description = "Light Hurry Up" }, - new GamelogicEngineLamp(47) { Description = "Hold Bonus" }, - new GamelogicEngineLamp(48) { Description = "Security Pass" }, - - new GamelogicEngineLamp(51) { Description = "Eyes Lower" }, - new GamelogicEngineLamp(52) { Description = "Eyes Upper" }, - new GamelogicEngineLamp(53) { Description = "5,000,000" }, - new GamelogicEngineLamp(54) { Description = "3,000,000" }, - new GamelogicEngineLamp(55) { Description = "1,000,000" }, - new GamelogicEngineLamp(56) { Description = "750,000" }, - new GamelogicEngineLamp(57) { Description = "500,000" }, - new GamelogicEngineLamp(58) { Description = "250,000" }, - - new GamelogicEngineLamp(61) { Description = "Left CPU Lit" }, - new GamelogicEngineLamp(62) { Description = "Left Vault Key" }, - new GamelogicEngineLamp(63) { Description = "Left Silent Alarm" }, - new GamelogicEngineLamp(64) { Description = "Left Passcode" }, - new GamelogicEngineLamp(65) { Description = "Left Checkpoint" }, - new GamelogicEngineLamp(66) { Description = "Lock 1" }, - new GamelogicEngineLamp(67) { Description = "Data Base 1" }, - new GamelogicEngineLamp(68) { Description = "Left Ramp" }, - - new GamelogicEngineLamp(71) { Description = "Right CPU Lit" }, - new GamelogicEngineLamp(72) { Description = "Right Vault Key" }, - new GamelogicEngineLamp(73) { Description = "Right Silent Alarm" }, - new GamelogicEngineLamp(74) { Description = "Right Passcode" }, - new GamelogicEngineLamp(75) { Description = "Right Checkpoint" }, - new GamelogicEngineLamp(76) { Description = "Right Bank Top" }, - new GamelogicEngineLamp(77) { Description = "Right Bank Middle" }, - new GamelogicEngineLamp(78) { Description = "Right Bank Bottom" }, - - new GamelogicEngineLamp(81) { Description = "Chase Values" }, - new GamelogicEngineLamp(82) { Description = "Right Ramp" }, - new GamelogicEngineLamp(83) { Description = "Hurry Up" }, - new GamelogicEngineLamp(84) { Description = "Start Button" }, - new GamelogicEngineLamp(85) { Description = "Drop Target" }, - new GamelogicEngineLamp(86) { Description = "Top Lane Left" }, - new GamelogicEngineLamp(87) { Description = "Top Lane Center" }, - new GamelogicEngineLamp(88) { Description = "Top Lane Right" }, - - new GamelogicEngineLamp(0) { Description = "GI: Top Insert", Type = LampType.SingleFading, Source = LampSource.GI, FadingSteps = 8 }, - new GamelogicEngineLamp(1) { Description = "GI: Bottom Insert", Type = LampType.SingleFading, Source = LampSource.GI, FadingSteps = 8 }, - new GamelogicEngineLamp(2) { Description = "GI: Right Playfield", Type = LampType.SingleFading, Source = LampSource.GI, FadingSteps = 8 }, - new GamelogicEngineLamp(3) { Description = "GI: CPU String", Type = LampType.SingleFading, Source = LampSource.GI, FadingSteps = 8 }, - new GamelogicEngineLamp(4) { Description = "GI: Left Playfield", Type = LampType.SingleFading, Source = LampSource.GI, FadingSteps = 8 }, - }; - - protected override GamelogicEngineCoil[] GameCoils { get; } = { - new GamelogicEngineCoil(1) { Description = "Ball Popper" }, - new GamelogicEngineCoil(2) { Description = "Gun Kicker" }, - new GamelogicEngineCoil(3) { Description = "Outhole", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "entry_coil" }, - new GamelogicEngineCoil(4) { Description = "Trough", DeviceHint = "^Trough\\s*\\d?", DeviceItemHint = "eject_coil" }, - new GamelogicEngineCoil(5) { Description = "Right Sling" }, - new GamelogicEngineCoil(6) { Description = "Left Sling" }, - new GamelogicEngineCoil(7) { Description = "Knocker" }, - new GamelogicEngineCoil(8) { Description = "Kickback" }, - new GamelogicEngineCoil(9) { Description = "Plunger", DeviceHint = "Plunger" }, - new GamelogicEngineCoil(10) { Description = "Top Lock" }, - new GamelogicEngineCoil(11) { Description = "Gun Motor" }, - new GamelogicEngineCoil(12) { Description = "Knock Down", DeviceHint = "^sw77$" }, - new GamelogicEngineCoil(13) { Description = "Left Jet" }, - new GamelogicEngineCoil(14) { Description = "Right Jet" }, - new GamelogicEngineCoil(15) { Description = "Bottom Jet" }, - new GamelogicEngineCoil(16) { Description = "Left Lock" }, - new GamelogicEngineCoil(17) { Description = "Hot Dog Flashlamps", IsLamp = true, DeviceHint = "^F1?17$"}, - new GamelogicEngineCoil(18) { Description = "Right Sling Flashlamps", IsLamp = true, DeviceHint = "^F1?18$" }, - new GamelogicEngineCoil(19) { Description = "Left Sling Flashlamps", IsLamp = true, DeviceHint = "^F1?19$" }, - new GamelogicEngineCoil(20) { Description = "Left Lock Flashlamps", IsLamp = true, DeviceHint = "^F1?20$" }, - new GamelogicEngineCoil(21) { Description = "Gun Flashlamps", IsLamp = true, DeviceHint = "^F1?21$" }, - new GamelogicEngineCoil(22) { Description = "Right Ramp Flashlamps", IsLamp = true, DeviceHint = "^F1?22$" }, - new GamelogicEngineCoil(23) { Description = "Left Ramp Flashlamps", IsLamp = true, DeviceHint = "^F1?23$" }, - new GamelogicEngineCoil(24) { Description = "Backglass Flashlamp", IsLamp = true, DeviceHint = "^F1?24$" }, - new GamelogicEngineCoil(25) { Description = "Targets Flashlamps", IsLamp = true, DeviceHint = "^F1?25$" }, - new GamelogicEngineCoil(26) { Description = "Left Popper Flashlamps", IsLamp = true, DeviceHint = "^F1?26$" }, - new GamelogicEngineCoil(27) { Description = "Right Popper Flashlamps", IsLamp = true, DeviceHint = "^F1?27$" }, - new GamelogicEngineCoil(28) { Description = "Flashlamps Drop Target", IsLamp = true, DeviceHint = "^F1?28$" }, - - new GamelogicEngineCoil(CoilFlipperLowerRight) { Description = "Lower Right Flipper", DeviceHint = "^RightFlipper$"}, - new GamelogicEngineCoil(CoilFlipperLowerLeft) { Description = "Lower Left Flipper", DeviceHint = "^LeftFlipper$"}, - - new GamelogicEngineCoil(CoilFlipperUpperRight) { IsUnused = true }, - new GamelogicEngineCoil(CoilFlipperUpperLeft) { IsUnused = true }, - }; - } -} diff --git a/VisualPinball.Engine.PinMAME/Games/TheWalkingDead.cs b/VisualPinball.Engine.PinMAME/Games/TheWalkingDead.cs deleted file mode 100644 index 0fc2c20..0000000 --- a/VisualPinball.Engine.PinMAME/Games/TheWalkingDead.cs +++ /dev/null @@ -1,249 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 . - -// ReSharper disable StringLiteralTypo - -using System; -using VisualPinball.Engine.Game.Engines; -using VisualPinball.Engine.Math; -using VisualPinball.Engine.PinMAME.MPUs; - -namespace VisualPinball.Engine.PinMAME.Games -{ - [Serializable] - public class TheWalkingDead : Sam - { - public override string Name => "The Walking Dead - Limited Edition"; - public override string Id => "twd"; - public override int Year => 2014; - public override string Manufacturer => "Stern"; - public override int IpdbId => 6156; - public override PinMameRom[] Roms { get; } = { - new PinMameRom("twd_105", "1.05"), - new PinMameRom("twd_111", "1.11"), - new PinMameRom("twd_111h", "1.11h"), - new PinMameRom("twd_119", "1.19"), - new PinMameRom("twd_119h", "1.19h"), - new PinMameRom("twd_124", "1.24"), - new PinMameRom("twd_124h", "1.24h"), - new PinMameRom("twd_125", "1.25"), - new PinMameRom("twd_125h", "1.25h"), - new PinMameRom("twd_128", "1.28"), - new PinMameRom("twd_128h", "1.28h"), - new PinMameRom("twd_141", "1.41"), - new PinMameRom("twd_141h", "1.41h"), - new PinMameRom("twd_153", "1.53"), - new PinMameRom("twd_153h", "1.53h"), - new PinMameRom("twd_156", "1.56"), - new PinMameRom("twd_156h", "1.56h"), - new PinMameRom("twd_160", "1.60.0"), - new PinMameRom("twd_160h", "1.60.0h"), - }; - - protected override PinMameIdAlias[] Aliases { get; } = { - }; - - protected override GamelogicEngineSwitch[] Switches { get; } = { - new GamelogicEngineSwitch(1) { Description = "Right Spinner" }, - new GamelogicEngineSwitch(2) { Description = "Well Walker" }, - new GamelogicEngineSwitch(3) { Description = "Prison Walker Hit" }, - new GamelogicEngineSwitch(4) { Description = "Prison Doors Closed" }, - new GamelogicEngineSwitch(9) { Description = "Left 3-Bank #1 (Bottom)" }, - new GamelogicEngineSwitch(10) { Description = "Left 3-Bank #2 (Middle)" }, - new GamelogicEngineSwitch(11) { Description = "Left 3-Bank #3 (Top)" }, - new GamelogicEngineSwitch(18) { Description = "Trough #4 Left" }, - new GamelogicEngineSwitch(19) { Description = "Trough #3" }, - new GamelogicEngineSwitch(20) { Description = "Trough #2" }, - new GamelogicEngineSwitch(21) { Description = "Trough #1 Right" }, - new GamelogicEngineSwitch(22) { Description = "Trough Jam" }, - new GamelogicEngineSwitch(23) { Description = "Shooter Lane" }, - new GamelogicEngineSwitch(24) { Description = "Left Outlane" }, - new GamelogicEngineSwitch(25) { Description = "Left Return Lane" }, - new GamelogicEngineSwitch(26) { Description = "Left Slingshot" }, - new GamelogicEngineSwitch(27) { Description = "Right Slingshot" }, - new GamelogicEngineSwitch(33) { Description = "Upper Shooter Lane" }, - new GamelogicEngineSwitch(34) { Description = "Right Ramp Enter" }, - new GamelogicEngineSwitch(35) { Description = "Left Ramp Exit" }, - new GamelogicEngineSwitch(36) { Description = "Left Top Lane" }, - new GamelogicEngineSwitch(37) { Description = "Right Top Lane" }, - new GamelogicEngineSwitch(38) { Description = "Tower Standup" }, - new GamelogicEngineSwitch(39) { Description = "Right Loop" }, - new GamelogicEngineSwitch(40) { Description = "Left Loop Spinner" }, - new GamelogicEngineSwitch(41) { Description = "Left Loop" }, - new GamelogicEngineSwitch(42) { Description = "Right Ramp Exit" }, - new GamelogicEngineSwitch(49) { Description = "Bicycle Girl Hit" }, - new GamelogicEngineSwitch(50) { Description = "Crossbow Home" }, - new GamelogicEngineSwitch(51) { Description = "Crossbow Mark" }, - new GamelogicEngineSwitch(52) { Description = "Crossbow Eject" }, - }; - - public override GamelogicEngineLamp[] AvailableLamps { get; } = { - new GamelogicEngineLamp(1) { Description = "Start Button" }, - new GamelogicEngineLamp(2) { Description = "Tournament Start Button" }, - new GamelogicEngineLamp(3) { Description = "12X Playfield values" }, - new GamelogicEngineLamp(4) { Description = "Shoot Again" }, - new GamelogicEngineLamp(5) { Description = "4 Walkers Killed" }, - new GamelogicEngineLamp(6) { Description = "3 Walkers Killed" }, - new GamelogicEngineLamp(7) { Description = "2 Walkers Killed" }, - new GamelogicEngineLamp(8) { Description = "1 Walker Killed" }, - new GamelogicEngineLamp(9) { Description = "40 Walkers Killed" }, - new GamelogicEngineLamp(10) { Description = "Last Man Standing" }, - new GamelogicEngineLamp(11) { Description = "5 Walkers Killed" }, - new GamelogicEngineLamp(12) { Description = "10 Walkers Killed" }, - new GamelogicEngineLamp(13) { Description = "20 Walkers Killed" }, - new GamelogicEngineLamp(14) { Description = "30 Walkers Killed" }, - new GamelogicEngineLamp(15) { Description = "Hammer Multi-Killed" }, - new GamelogicEngineLamp(16) { Description = "Sword Muiti-Kill" }, - new GamelogicEngineLamp(17) { Description = "Crossbow Muiti-Kill" }, - new GamelogicEngineLamp(18) { Description = "Gun Multi-Kill" }, - new GamelogicEngineLamp(19) { Description = "Knife Multi-Kill" }, - new GamelogicEngineLamp(20) { Description = "Axe Multi-Kill" }, - new GamelogicEngineLamp(21) { Description = "Horde" }, - new GamelogicEngineLamp(22) { Description = "Left Outlane" }, - new GamelogicEngineLamp(23) { Description = "Left Return Lane" }, - - new GamelogicEngineLamp(24) { Description = "Right Loop Arrow" }, - new GamelogicEngineLamp(168) { Description = "Right Loop Arrow Red", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(169) { Description = "Right Loop Arrow Green", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(170) { Description = "Right Loop Arrow Blue", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(25) { Description = "Blood Bath" }, - new GamelogicEngineLamp(26) { Description = "First Aid" }, - new GamelogicEngineLamp(27) { Description = "Weapons" }, - new GamelogicEngineLamp(28) { Description = "Food" }, - new GamelogicEngineLamp(29) { Description = "Left Ramp Walker Kill" }, - new GamelogicEngineLamp(30) { Description = "Left Loop Walker Kill" }, - new GamelogicEngineLamp(31) { Description = "Left Loop Multi-Kill" }, - new GamelogicEngineLamp(32) { Description = "Barn Mode" }, - - new GamelogicEngineLamp(33) { Description = "Left Loop Arrow" }, - new GamelogicEngineLamp(195) { Description = "Left Loop Arrow Red", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(196) { Description = "Left Loop Arrow Green", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(197) { Description = "Left Loop Arrow Blue", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(34) { Description = "Left Ramp Multi-Kill" }, - new GamelogicEngineLamp(35) { Description = "CDC Mode" }, - - new GamelogicEngineLamp(36) { Description = "Left Ramp Arrow" }, - new GamelogicEngineLamp(203) { Description = "Left Ramp Arrow Red", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(204) { Description = "Left Ramp Arrow Green", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(205) { Description = "Left Ramp Arrow Blue", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(37) { Description = "Right Outlane" }, - new GamelogicEngineLamp(38) { Description = "Right Return Lane" }, - new GamelogicEngineLamp(39) { Description = "Extra Ball" }, - new GamelogicEngineLamp(40) { Description = "Welcome To Woodbury" }, - - new GamelogicEngineLamp(41) { Description = "Right Ramp Arrow" }, - new GamelogicEngineLamp(152) { Description = "Right Ramp Arrow Red", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(153) { Description = "Right Ramp Arrow Green", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(154) { Description = "Right Ramp Arrow Blue", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(42) { Description = "Right Ramp Walker Kill" }, - new GamelogicEngineLamp(43) { Description = "Right Ramp Multi-Kill" }, - new GamelogicEngineLamp(44) { Description = "Arena Mode" }, - new GamelogicEngineLamp(45) { Description = "Well Walker Kill" }, - new GamelogicEngineLamp(46) { Description = "(W)ELL" }, - new GamelogicEngineLamp(47) { Description = "W(E)LL" }, - new GamelogicEngineLamp(48) { Description = "WE(L)L" }, - new GamelogicEngineLamp(49) { Description = "WEL(L)" }, - new GamelogicEngineLamp(50) { Description = "Well Walker" }, - new GamelogicEngineLamp(51) { Description = "Right Loop Walker Kill" }, - new GamelogicEngineLamp(52) { Description = "Right Loop Multi-Kill" }, - new GamelogicEngineLamp(53) { Description = "Tunnel Mode" }, - new GamelogicEngineLamp(54) { Description = "Siege" }, - new GamelogicEngineLamp(55) { Description = "Right Prison Standup" }, - new GamelogicEngineLamp(56) { Description = "Left Prison Standup" }, - - new GamelogicEngineLamp(57) { Description = "Center Lane Arrow" }, - new GamelogicEngineLamp(187) { Description = "Center Lane Arrow Red", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(188) { Description = "Center Lane Arrow Green", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(189) { Description = "Center Lane Arrow Blue", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(58) { Description = "Riot Mode" }, - new GamelogicEngineLamp(59) { Description = "Center Lane Multi-Kill" }, - new GamelogicEngineLamp(60) { Description = "Top Bumper" }, - new GamelogicEngineLamp(61) { Description = "Right Bumper" }, - new GamelogicEngineLamp(62) { Description = "Left Bumper" }, - new GamelogicEngineLamp(63) { Description = "Center Lane Walker" }, - new GamelogicEngineLamp(64) { Description = "(P)RISON" }, - new GamelogicEngineLamp(65) { Description = "P(R)ISON" }, - new GamelogicEngineLamp(66) { Description = "PR(I)SON" }, - new GamelogicEngineLamp(67) { Description = "PRI(S)ON" }, - new GamelogicEngineLamp(68) { Description = "PRIS(O)N" }, - new GamelogicEngineLamp(69) { Description = "PRISO(N)" }, - new GamelogicEngineLamp(70) { Description = "Crossbow" }, - new GamelogicEngineLamp(71) { Description = "Fish Tank" }, - new GamelogicEngineLamp(72) { Description = "Tower" }, - new GamelogicEngineLamp(73) { Description = "Fish Tank Head #1" }, - new GamelogicEngineLamp(74) { Description = "Fish Tank Head #2" }, - new GamelogicEngineLamp(75) { Description = "Fish Tank Head #3" }, - new GamelogicEngineLamp(76) { Description = "Left Top Lane" }, - new GamelogicEngineLamp(77) { Description = "Right Top Lane" }, - new GamelogicEngineLamp(78) { Description = "Bicycle Girl" }, - - new GamelogicEngineLamp(79) { Description = "Star Rollover (Bottom)" }, - new GamelogicEngineLamp(136) { Description = "Star Rollover (Bottom) Red", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(137) { Description = "Star Rollover (Bottom) Green", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(138) { Description = "Star Rollover (Bottom) Blue", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(80) { Description = "Star Rollover (Top)" }, - new GamelogicEngineLamp(133) { Description = "Star Rollover (Top) Red", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(134) { Description = "Star Rollover (Top) Green", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(135) { Description = "Star Rollover (Top) Blue", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(81) { Description = "Fire Button (Red)", Type = LampType.RgbMulti, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(81) { Description = "Fire Button (Green)", Type = LampType.RgbMulti, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(81) { Description = "Fire Button (Blue)", Type = LampType.RgbMulti, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(106) { Description = "GI (Red)", Type = LampType.RgbMulti, Source = LampSource.GI, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(107) { Description = "GI (Green)", Type = LampType.RgbMulti, Source = LampSource.GI, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(108) { Description = "GI (Blue)", Type = LampType.RgbMulti, Source = LampSource.GI, Channel = ColorChannel.Blue }, - - new GamelogicEngineLamp(109) { Description = "GI: Slings (Red)", Type = LampType.RgbMulti, Source = LampSource.GI, Channel = ColorChannel.Red }, - new GamelogicEngineLamp(114) { Description = "GI: Slings (Green)", Type = LampType.RgbMulti, Source = LampSource.GI, Channel = ColorChannel.Green }, - new GamelogicEngineLamp(149) { Description = "GI: Slings (Blue)", Type = LampType.RgbMulti, Source = LampSource.GI, Channel = ColorChannel.Blue }, - }; - - protected override GamelogicEngineCoil[] GameCoils { get; } = { - new GamelogicEngineCoil(1) { Description = "Trough Up-Kicker" }, - new GamelogicEngineCoil(2) { Description = "Auto Launch" }, - new GamelogicEngineCoil(3) { Description = "Prison Doors (Power)" }, - new GamelogicEngineCoil(4) { Description = "Prison Doors (Hold)" }, - new GamelogicEngineCoil(5) { Description = "Ramp Magnet Diverter" }, - new GamelogicEngineCoil(6) { Description = "Well Magnet" }, - new GamelogicEngineCoil(7) { Description = "Prison Magnet" }, - new GamelogicEngineCoil(8) { Description = "Shaker Motor (Optional)" }, - new GamelogicEngineCoil(9) { Description = "Left Pop Bumper" }, - new GamelogicEngineCoil(10) { Description = "Right Pop Bumper" }, - new GamelogicEngineCoil(11) { Description = "Top Pop Bumper" }, - new GamelogicEngineCoil(12) { Description = "Left 3-Bank Drop Target" }, - new GamelogicEngineCoil(13) { Description = "Left Slingshot" }, - new GamelogicEngineCoil(14) { Description = "Right Slingshot" }, - new GamelogicEngineCoil(19) { Description = "Flash: Well Walker", IsLamp = true }, - new GamelogicEngineCoil(20) { Description = "Flash: Right Spinner", IsLamp = true }, - new GamelogicEngineCoil(21) { Description = "Crossbow Motor" }, - new GamelogicEngineCoil(24) { Description = "Optional (e.g. Coin Meter)" }, - new GamelogicEngineCoil(25) { Description = "Flash: Pop Bumpers", IsLamp = true }, - new GamelogicEngineCoil(26) { Description = "Flash: Prison (Top)", IsLamp = true }, - new GamelogicEngineCoil(27) { Description = "Flash: Prison (Bottom) (X2)", IsLamp = true }, - new GamelogicEngineCoil(28) { Description = "Flash: Left Dome", IsLamp = true }, - new GamelogicEngineCoil(29) { Description = "Flash: Right Dome", IsLamp = true }, - new GamelogicEngineCoil(31) { Description = "Flash: Left Loop", IsLamp = true }, - new GamelogicEngineCoil(32) { Description = "Flash: Center Loop", IsLamp = true }, - }; - } -} diff --git a/VisualPinball.Engine.PinMAME/MPUs/Bally.cs b/VisualPinball.Engine.PinMAME/MPUs/Bally.cs deleted file mode 100644 index bdf2c90..0000000 --- a/VisualPinball.Engine.PinMAME/MPUs/Bally.cs +++ /dev/null @@ -1,71 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 VisualPinball.Engine.Common; -using VisualPinball.Engine.Game.Engines; - -namespace VisualPinball.Engine.PinMAME.MPUs -{ - public abstract class Bally : PinMameGame - { - public override PinMameIdAlias[] AvailableAliases => Aliases.Concat(_aliases).ToArray(); - protected abstract PinMameIdAlias[] Aliases { get; } - - protected override GamelogicEngineCoil[] Coils => GameCoils.Concat(_coils).ToArray(); - protected abstract GamelogicEngineCoil[] GameCoils { get; } - - public override GamelogicEngineSwitch[] AvailableSwitches => Switches.Concat(_switches).ToArray(); - protected abstract GamelogicEngineSwitch[] Switches { get; } - - private readonly PinMameIdAlias[] _aliases = - { - new PinMameIdAlias(-7, SwSelfTest, AliasType.Switch), - new PinMameIdAlias(-6, SwCpuDiagnose, AliasType.Switch), - new PinMameIdAlias(-5, SwSoundDiagnose, AliasType.Switch), - new PinMameIdAlias(2, SwBallRollTilt, AliasType.Switch), - new PinMameIdAlias(6, SwStartButton, AliasType.Switch), - new PinMameIdAlias(7, SwTilt, AliasType.Switch), - new PinMameIdAlias(16, SwSlamTilt, AliasType.Switch), - new PinMameIdAlias(11, SwCoin1, AliasType.Switch), - new PinMameIdAlias(10, SwCoin2, AliasType.Switch), - new PinMameIdAlias(9, SwCoin3, AliasType.Switch), - new PinMameIdAlias(82, SwFlipperLowerRight, AliasType.Switch), - new PinMameIdAlias(84, SwFlipperLowerLeft, AliasType.Switch), - - new PinMameIdAlias(19, CoilGameOn, AliasType.Coil), - }; - - private readonly GamelogicEngineSwitch[] _switches = { - new GamelogicEngineSwitch(SwSelfTest) { Description = "Self Test" }, - new GamelogicEngineSwitch(SwCpuDiagnose) { Description = "CPU Diagnose"}, - new GamelogicEngineSwitch(SwSoundDiagnose) { Description = "Sound Diagnose"}, - new GamelogicEngineSwitch(SwBallRollTilt) { Description = "Ball Roll Tilt"}, - new GamelogicEngineSwitch(SwStartButton) { Description = "Start Button", InputActionHint = InputConstants.ActionStartGame, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwTilt) { Description = "Tilt" }, - new GamelogicEngineSwitch(SwSlamTilt) { Description = "Slam Tilt", InputActionHint = InputConstants.ActionSlamTilt, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwCoin1) { Description = "Coin Button 1", InputActionHint = InputConstants.ActionInsertCoin1, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwCoin2) { Description = "Coin Button 2", InputActionHint = InputConstants.ActionInsertCoin2, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwCoin3) { Description = "Coin Button 3", InputActionHint = InputConstants.ActionInsertCoin3, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwFlipperLowerRight) { Description = "Lower Right Flipper", InputActionHint = InputConstants.ActionRightFlipper, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwFlipperLowerLeft) { Description = "Lower Left Flipper", InputActionHint = InputConstants.ActionLeftFlipper, InputMapHint = InputConstants.MapCabinetSwitches }, - }; - - private readonly GamelogicEngineCoil[] _coils = { - new GamelogicEngineCoil(CoilGameOn) { Description = "ROM Started" } - }; - } -} diff --git a/VisualPinball.Engine.PinMAME/MPUs/Sam.cs b/VisualPinball.Engine.PinMAME/MPUs/Sam.cs deleted file mode 100644 index f4baba0..0000000 --- a/VisualPinball.Engine.PinMAME/MPUs/Sam.cs +++ /dev/null @@ -1,108 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 VisualPinball.Engine.Common; -using VisualPinball.Engine.Game.Engines; - -namespace VisualPinball.Engine.PinMAME.MPUs -{ - public abstract class Sam : PinMameGame - { - public override PinMameIdAlias[] AvailableAliases => Aliases.Concat(_aliases).ToArray(); - protected abstract PinMameIdAlias[] Aliases { get; } - - public override GamelogicEngineSwitch[] AvailableSwitches => Concat(_switches, Switches); - - protected override GamelogicEngineCoil[] Coils => Concat(_coils, GameCoils); - protected abstract GamelogicEngineCoil[] GameCoils { get; } - - protected abstract GamelogicEngineSwitch[] Switches { get; } - - private readonly PinMameIdAlias[] _aliases = - { - new PinMameIdAlias(65, SwCoin1, AliasType.Switch), - new PinMameIdAlias(66, SwCoin2, AliasType.Switch), - new PinMameIdAlias(67, SwCoin3, AliasType.Switch), - new PinMameIdAlias(-3, SwCancel, AliasType.Switch), - new PinMameIdAlias(-2, SwDown, AliasType.Switch), - new PinMameIdAlias(-1, SwUp, AliasType.Switch), - new PinMameIdAlias(0, SwEnter, AliasType.Switch), - new PinMameIdAlias(16, SwStartButton, AliasType.Switch), - new PinMameIdAlias(-6, SwSlamTilt, AliasType.Switch), - new PinMameIdAlias(-7, SwTilt, AliasType.Switch), - new PinMameIdAlias(84, SwFlipperLowerLeft, AliasType.Switch), - new PinMameIdAlias(82, SwFlipperLowerRight, AliasType.Switch), - - new PinMameIdAlias(15, CoilFlipperLowerLeft, AliasType.Coil), - new PinMameIdAlias(16, CoilFlipperLowerRight, AliasType.Coil), - }; - - private readonly GamelogicEngineSwitch[] _switches = { - new GamelogicEngineSwitch(SwCoin1) { Description = "Coin Button 1", InputActionHint = InputConstants.ActionInsertCoin1, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwCoin2) { Description = "Coin Button 2", InputActionHint = InputConstants.ActionInsertCoin2, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwCoin3) { Description = "Coin Button 3", InputActionHint = InputConstants.ActionInsertCoin3, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwCancel) { Description = "Coin Door Back", InputActionHint = InputConstants.ActionCoinDoorBack, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwDown) { Description = "Coin Door -", InputActionHint = InputConstants.ActionCoinDoorMinus, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwUp) { Description = "Coin Door +", InputActionHint = InputConstants.ActionCoinDoorPlus, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwEnter) { Description = "Coin Door Select", InputActionHint = InputConstants.ActionCoinDoorSelect, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch("15") { Description = "Tourn Start" }, - new GamelogicEngineSwitch(SwStartButton) { Description = "Start", InputActionHint = InputConstants.ActionStartGame, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwSlamTilt) { Description = "Slam Tilt", InputActionHint = InputConstants.ActionSlamTilt, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwTilt) { Description = "Tilt" }, - new GamelogicEngineSwitch(SwFlipperLowerLeft) { Description = "Lower Left Flipper", InputActionHint = InputConstants.ActionLeftFlipper, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwFlipperLowerRight) { Description = "Lower Right Flipper", InputActionHint = InputConstants.ActionRightFlipper, InputMapHint = InputConstants.MapCabinetSwitches }, - - /* - - From manuals: - - new GamelogicEngineSwitch("D1") { Description = "Left Coin Slot" }, - new GamelogicEngineSwitch("D2") { Description = "Center Coin Slot" }, - new GamelogicEngineSwitch("D3") { Description = "Right Coin Slot" }, - new GamelogicEngineSwitch("D4") { Description = "Forth Coin Slot" }, - new GamelogicEngineSwitch("D5") { Description = "Fifth Coin Slot" }, - new GamelogicEngineSwitch("D6") { Description = "Star Rollover (Bottom)" }, - new GamelogicEngineSwitch("D7") { Description = "Fire Bottom" }, - new GamelogicEngineSwitch("D8") { Description = "Star Rollover (Top)" }, - new GamelogicEngineSwitch("D9") { Description = "Left Flipper Button" }, - new GamelogicEngineSwitch("D10") { Description = "Left Flipper EOS" }, - new GamelogicEngineSwitch("D17") { Description = "Tilt Pendulum" }, - new GamelogicEngineSwitch("D18") { Description = "Slam Tilt" }, - new GamelogicEngineSwitch("D19") { Description = "Ticket Notch" }, - new GamelogicEngineSwitch("D21") { Description = "Back (Green)" }, - new GamelogicEngineSwitch("D22") { Description = "Minus (Red)" }, - new GamelogicEngineSwitch("D23") { Description = "Plus (Red)" }, - new GamelogicEngineSwitch("D24") { Description = "Select (Black)" }, - new GamelogicEngineSwitch("D25") { Description = "Dip (1)2345678" }, - new GamelogicEngineSwitch("D26") { Description = "Dip 1(2)345678" }, - new GamelogicEngineSwitch("D27") { Description = "Dip 12(3)45678" }, - new GamelogicEngineSwitch("D28") { Description = "Dip 123(4)5678" }, - new GamelogicEngineSwitch("D29") { Description = "Dip 1234(5)678" }, - new GamelogicEngineSwitch("D30") { Description = "Dip 12345(6)78" }, - new GamelogicEngineSwitch("D31") { Description = "Dip 123456(7)8" }, - new GamelogicEngineSwitch("D32") { Description = "Dip 1234567(8)" }, - - */ - - }; - - private readonly GamelogicEngineCoil[] _coils = { - new GamelogicEngineCoil(CoilFlipperLowerLeft) { Description = "Lower Left Flipper", DeviceHint = "^(LeftFlipper|LFlipper|FlipperLeft|FlipperL)$"}, - new GamelogicEngineCoil(CoilFlipperLowerRight) { Description = "Lower Right Flipper", DeviceHint = "^(RightFlipper|RFlipper|FlipperRight|FlipperR)$"}, - }; - } -} diff --git a/VisualPinball.Engine.PinMAME/MPUs/System80.cs b/VisualPinball.Engine.PinMAME/MPUs/System80.cs deleted file mode 100644 index c4c1ad9..0000000 --- a/VisualPinball.Engine.PinMAME/MPUs/System80.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 VisualPinball.Engine.Common; -using VisualPinball.Engine.Game.Engines; - -namespace VisualPinball.Engine.PinMAME.MPUs -{ - public abstract class System80 : PinMameGame - { - public override PinMameIdAlias[] AvailableAliases => Aliases.Concat(_aliases).ToArray(); - protected abstract PinMameIdAlias[] Aliases { get; } - - protected override GamelogicEngineCoil[] Coils => GameCoils.Concat(_coils).ToArray(); - protected abstract GamelogicEngineCoil[] GameCoils { get; } - - public override GamelogicEngineSwitch[] AvailableSwitches => Switches.Concat(_switches).ToArray(); - - protected abstract GamelogicEngineSwitch[] Switches { get; } - - private readonly PinMameIdAlias[] _aliases = - { - new PinMameIdAlias(7, SwSelfTest, AliasType.Switch), - new PinMameIdAlias(47, SwStartButton, AliasType.Switch), - new PinMameIdAlias(57, SwTilt, AliasType.Switch), - new PinMameIdAlias(-1, SwSlamTilt, AliasType.Switch), - new PinMameIdAlias(17, SwCoin1, AliasType.Switch), - new PinMameIdAlias(27, SwCoin2, AliasType.Switch), - new PinMameIdAlias(37, SwCoin3, AliasType.Switch), - new PinMameIdAlias(6, SwLeftAdvance, AliasType.Switch), - new PinMameIdAlias(16, SwRightAdvance, AliasType.Switch), - new PinMameIdAlias(114, SwFlipperLowerLeft, AliasType.Switch), - new PinMameIdAlias(112, SwFlipperLowerRight, AliasType.Switch), - - new PinMameIdAlias(10, CoilGameOn, AliasType.Coil), - }; - - private readonly GamelogicEngineSwitch[] _switches = { - new GamelogicEngineSwitch(SwSelfTest) { Description = "Self Test", InputActionHint = InputConstants.ActionSelfTest }, - new GamelogicEngineSwitch(SwStartButton) { Description = "Start Button", InputActionHint = InputConstants.ActionStartGame, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwTilt) { Description = "Tilt" }, - new GamelogicEngineSwitch(SwSlamTilt) { Description = "Slam Tilt", InputActionHint = InputConstants.ActionSlamTilt, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwCoin1) { Description = "Coin Button 1", InputActionHint = InputConstants.ActionInsertCoin1, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwCoin2) { Description = "Coin Button 2", InputActionHint = InputConstants.ActionInsertCoin2, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwCoin3) { Description = "Coin Button 3", InputActionHint = InputConstants.ActionInsertCoin3, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwLeftAdvance) { Description = "Left Advance", InputActionHint = InputConstants.ActionLeftAdvance, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwRightAdvance) { Description = "Right Advance", InputActionHint = InputConstants.ActionRightAdvance, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwFlipperLowerLeft) { Description = "Lower Left Flipper", InputActionHint = InputConstants.ActionLeftFlipper, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwFlipperLowerRight) { Description = "Lower Right Flipper", InputActionHint = InputConstants.ActionRightFlipper, InputMapHint = InputConstants.MapCabinetSwitches }, - }; - - private readonly GamelogicEngineCoil[] _coils = { - new GamelogicEngineCoil(CoilGameOn) { Description = "ROM Started" } - }; - } -} diff --git a/VisualPinball.Engine.PinMAME/MPUs/Wpc.cs b/VisualPinball.Engine.PinMAME/MPUs/Wpc.cs deleted file mode 100644 index c633471..0000000 --- a/VisualPinball.Engine.PinMAME/MPUs/Wpc.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 VisualPinball.Engine.Common; -using VisualPinball.Engine.Game.Engines; - -namespace VisualPinball.Engine.PinMAME.MPUs -{ - public abstract class Wpc : PinMameGame - { - public override PinMameIdAlias[] AvailableAliases => Aliases.Concat(_aliases).ToArray(); - protected abstract PinMameIdAlias[] Aliases { get; } - - public override GamelogicEngineSwitch[] AvailableSwitches => Concat(_switches, Switches); - - protected override GamelogicEngineCoil[] Coils => Concat(_coils, GameCoils); - protected abstract GamelogicEngineCoil[] GameCoils { get; } - - protected abstract GamelogicEngineSwitch[] Switches { get; } - - private readonly PinMameIdAlias[] _aliases = - { - new PinMameIdAlias(1, SwCoin1, AliasType.Switch), - new PinMameIdAlias(2, SwCoin2, AliasType.Switch), - new PinMameIdAlias(3, SwCoin3, AliasType.Switch), - new PinMameIdAlias(4, SwCoin4, AliasType.Switch), - new PinMameIdAlias(5, SwCancel, AliasType.Switch), - new PinMameIdAlias(6, SwDown, AliasType.Switch), - new PinMameIdAlias(7, SwUp, AliasType.Switch), - new PinMameIdAlias(8, SwEnter, AliasType.Switch), - new PinMameIdAlias(112, SwFlipperLowerRight, AliasType.Switch), - new PinMameIdAlias(114, SwFlipperLowerLeft, AliasType.Switch), - new PinMameIdAlias(116, SwFlipperUpperRight, AliasType.Switch), - new PinMameIdAlias(118, SwFlipperUpperLeft, AliasType.Switch), - - new PinMameIdAlias(19, CoilGameOn, AliasType.Coil), - }; - - private readonly GamelogicEngineSwitch[] _switches = { - new GamelogicEngineSwitch(SwCoin1) { Description = "Coin Button 1", InputActionHint = InputConstants.ActionInsertCoin1, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwCoin2) { Description = "Coin Button 2", InputActionHint = InputConstants.ActionInsertCoin2, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwCoin3) { Description = "Coin Button 3", InputActionHint = InputConstants.ActionInsertCoin3, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwCoin4) { Description = "Coin Button 4", InputActionHint = InputConstants.ActionInsertCoin4, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwCancel) { Description = "Cancel", InputActionHint = InputConstants.ActionCoinDoorCancel, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwDown) { Description = "Down", InputActionHint = InputConstants.ActionCoinDoorDown, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwUp) { Description = "Up", InputActionHint = InputConstants.ActionCoinDoorUp, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwEnter) { Description = "Enter", InputActionHint = InputConstants.ActionCoinDoorEnter, InputMapHint = InputConstants.MapCabinetSwitches }, - - new GamelogicEngineSwitch(SwFlipperLowerRight) { Description = "Lower Right Flipper", InputActionHint = InputConstants.ActionRightFlipper, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwFlipperLowerLeft) { Description = "Lower Left Flipper", InputActionHint = InputConstants.ActionLeftFlipper, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwFlipperUpperRight) { Description = "Upper Right Flipper", InputActionHint = InputConstants.ActionUpperRightFlipper, InputMapHint = InputConstants.MapCabinetSwitches }, - new GamelogicEngineSwitch(SwFlipperUpperLeft) { Description = "Upper Left Flipper", InputActionHint = InputConstants.ActionUpperLeftFlipper, InputMapHint = InputConstants.MapCabinetSwitches }, - }; - - private readonly GamelogicEngineCoil[] _coils = { - new GamelogicEngineCoil(CoilGameOn) { Description = "ROM Started" } - }; - } -} diff --git a/VisualPinball.Engine.PinMAME/PinMameGame.cs b/VisualPinball.Engine.PinMAME/PinMameGame.cs deleted file mode 100644 index b5dd8d4..0000000 --- a/VisualPinball.Engine.PinMAME/PinMameGame.cs +++ /dev/null @@ -1,128 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 . - -// ReSharper disable InconsistentNaming - -using System; -using System.Collections.Generic; -using System.Linq; -using VisualPinball.Engine.Game.Engines; - -namespace VisualPinball.Engine.PinMAME -{ - [Serializable] - public abstract class PinMameGame - { - public const string SwCoin1 = "s_coin_1"; - public const string SwCoin2 = "s_coin_2"; - public const string SwCoin3 = "s_coin_3"; - public const string SwCoin4 = "s_coin_4"; - public const string SwCancel = "s_cancel"; - public const string SwDown = "s_down"; - public const string SwUp = "s_up"; - public const string SwEnter = "s_enter"; - public const string SwFlipperLowerRight = "s_flipper_lower_right"; - public const string SwFlipperLowerLeft = "s_flipper_lower_left"; - public const string SwFlipperUpperRight = "s_flipper_upper_right"; - public const string SwFlipperUpperLeft = "s_flipper_upper_left"; - public const string SwSelfTest = "s_self_test"; - public const string SwCpuDiagnose = "s_cpu_diagnose"; - public const string SwSoundDiagnose = "s_sound_diagnose"; - public const string SwTilt = "s_tilt"; - public const string SwBallRollTilt = "s_ball_roll_tilt"; - public const string SwSlamTilt = "s_slam_tilt"; - public const string SwStartButton = "s_start_button"; - public const string SwLeftAdvance = "s_left_advance"; - public const string SwRightAdvance = "s_right_advance"; - - public const string CoilGameOn = "c_game_on"; - public const string CoilFlipperLowerRight = "c_flipper_lower_right"; - public const string CoilFlipperLowerLeft = "c_flipper_lower_left"; - public const string CoilFlipperUpperRight = "c_flipper_upper_right"; - public const string CoilFlipperUpperLeft = "c_flipper_upper_left"; - - public abstract string Name { get; } - public abstract string Id { get; } - public abstract int Year { get; } - public abstract string Manufacturer { get; } - public abstract int IpdbId { get; } - - public abstract PinMameRom[] Roms { get; } - - /// - /// All available id aliases for that game - /// - public abstract PinMameIdAlias[] AvailableAliases { get; } - - /// - /// All available switches for that game - /// - public abstract GamelogicEngineSwitch[] AvailableSwitches { get; } - - /// - /// All available coils for that game - /// - public GamelogicEngineCoil[] AvailableCoils => Concat(_coils, Coils); - - /// - /// Coils specific to the MPU - /// - protected abstract GamelogicEngineCoil[] Coils { get; } - - public abstract GamelogicEngineLamp[] AvailableLamps { get; } - - public GamelogicEngineWire[] AvailableWires { get; } = { - new GamelogicEngineWire(SwFlipperLowerLeft, CoilFlipperLowerLeft, DestinationType.Coil, "Lower Left Flipper"), - new GamelogicEngineWire(SwFlipperLowerRight, CoilFlipperLowerRight, DestinationType.Coil, "Lower Right Flipper"), - new GamelogicEngineWire(SwFlipperUpperLeft, CoilFlipperUpperLeft, DestinationType.Coil, "Upper Left Flipper"), - new GamelogicEngineWire(SwFlipperUpperRight, CoilFlipperUpperRight, DestinationType.Coil, "Upper Right Flipper"), - }; - - /// - /// These coils are common to all games. - /// - private readonly GamelogicEngineCoil[] _coils = { - new GamelogicEngineCoil(CoilFlipperLowerRight) { Description = "Lower Right Flipper", DeviceHint = "^(RightFlipper|RFlipper|FlipperRight|FlipperR)$"}, - new GamelogicEngineCoil(CoilFlipperLowerLeft) { Description = "Lower Left Flipper", DeviceHint = "^(LeftFlipper|LFlipper|FlipperLeft|FlipperL)$"}, - new GamelogicEngineCoil(CoilFlipperUpperRight) { Description = "Upper Right Flipper"}, - new GamelogicEngineCoil(CoilFlipperUpperLeft) { Description = "Upper Left Flipper"}, - }; - - protected GamelogicEngineCoil[] Concat(IEnumerable parent, IEnumerable children) - { - var ids = parent.ToDictionary(s => s.Id, s => s); - foreach (var child in children) { - if (ids.ContainsKey(child.Id)) { - ids.Remove(child.Id); - } - } - var c = ids.Values.ToDictionary(s => s.Id, s => s); - foreach (var child in children) { - c[child.Id] = child; - } - return c.Values.ToArray(); - } - - protected GamelogicEngineSwitch[] Concat(IEnumerable parent, IEnumerable children) - { - var c = parent.ToDictionary(s => s.Id, s => s); - foreach (var child in children) { - c[child.Id] = child; - } - return c.Values.ToArray(); - } - } -} diff --git a/VisualPinball.Engine.PinMAME/PinMameIdAlias.cs b/VisualPinball.Engine.PinMAME/PinMameIdAlias.cs deleted file mode 100644 index 5393205..0000000 --- a/VisualPinball.Engine.PinMAME/PinMameIdAlias.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 . - -namespace VisualPinball.Engine.PinMAME -{ - public enum AliasType - { - Switch, Coil, Lamp - } - - public class PinMameIdAlias - { - public readonly int Id; - public readonly string Name; - public readonly AliasType AliasType; - - public PinMameIdAlias(int id, string name, AliasType aliasType) - { - Id = id; - Name = name; - AliasType = aliasType; - } - } -} diff --git a/VisualPinball.Engine.PinMAME/PinMameRom.cs b/VisualPinball.Engine.PinMAME/PinMameRom.cs deleted file mode 100644 index f2e4e62..0000000 --- a/VisualPinball.Engine.PinMAME/PinMameRom.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2021 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 . - -namespace VisualPinball.Engine.PinMAME -{ - public class PinMameRom - { - public readonly string Id; - private readonly string _version; - private readonly string _description; - private readonly PinMameRomLanguage _language; - - public PinMameRom(string id, string version = null, string description = null, PinMameRomLanguage language = PinMameRomLanguage.English) - { - Id = id; - _version = version; - _description = description; - _language = language; - } - - public override string ToString() - { - var id = string.IsNullOrEmpty(_version) ? Id : _version; - if (!string.IsNullOrEmpty(_description)) { - return _language != PinMameRomLanguage.English - ? $"{id} ({_description}) - {_language}" - : $"{id} ({_description})"; - } else { - return _language != PinMameRomLanguage.English - ? $"{id} - {_language}" - : $"{id}"; - } - } - } - - public enum PinMameRomLanguage { - English, French, German - } -} diff --git a/VisualPinball.Engine.PinMAME/VisualPinball.Engine.PinMAME.csproj b/VisualPinball.Engine.PinMAME/VisualPinball.Engine.PinMAME.csproj index c1c22b9..06e6961 100644 --- a/VisualPinball.Engine.PinMAME/VisualPinball.Engine.PinMAME.csproj +++ b/VisualPinball.Engine.PinMAME/VisualPinball.Engine.PinMAME.csproj @@ -5,8 +5,8 @@ false true 9.0 - 1.0.4 - 3.7.0-beta1 + 1.1.0 + 3.7.0-beta2 win-x64