Skip to content

gle: add physical outputs option (PWM-integrated lamps, GI, solenoids) - #41

Draft
dcbenji wants to merge 1 commit into
VisualPinball:masterfrom
dcbenji:feat/physical-outputs-gle
Draft

dcbenji wants to merge 1 commit into
VisualPinball:masterfrom
dcbenji:feat/physical-outputs-gle

Conversation

@dcbenji

@dcbenji dcbenji commented Oct 12, 2026 •

Copy link
Copy Markdown

AI disclosure: Ben was working on flasher domes for an Attack from Mars conversion in VPE and wondered why VPE doesn't get the pulse-modulated (PWM) incandescent fading that VPX has. While investigating, we (Ben + Claude) found that PinMAME's physical outputs are never turned on in VPE. We also found why turning on physical solenoids made every coil go silent. This PR was written by Claude (AI) at Ben's request; Ben will review it before it leaves draft. It was tested headless on Ben's Linux machine (Ubuntu 24.04, libpinmame 3.7.0) and compiled against Unity 6000.5 / VPE master, but this exact code has not been run in the Unity editor yet (see "How tested"). Please review it with that in mind.

What VPX does

With UseVPMModSol = 2, core.vbs sets Controller.SolMask(2) = 2. PinMAME then reports lamps, GI and solenoids as 0-255 levels from the game driver's device models (wpc.c: #44 bulbs on 18 V for the lamp matrix, #44 on 6.3 V AC for GI, #89 on 20 V for flashers, LEDs, and a two-state model for plain coils). Flashers and inserts ramp up in ~20 ms and glow down over ~150-250 ms like real bulbs, and ROM strobing and PWM come out right without any table-side fade code.

What VPE was missing

  • PinMameGamelogicEngine.cs:851: ApplyConfig() → PinmameSetConfig zeroes PinMAME's options, and nothing sets the physical output mask afterwards. Lamps stay 0/1, GI 0-8 and flashers on/off.
  • pinmame-dotnet 1.1.0 has no public SetSolenoidMask (see the binding PR below).
  • Why physical solenoids silenced every coil (an earlier test turned on all outputs from a table script: the ROM ran, but the trough never ejected and no coil event reached VPE). libpinmame integrates physical outputs on request only (core.c VIDEO_UPDATE(core_gen): "LibPinMAME: on request"). The GLE polls GetChangedLamps/GetChangedGIs every frame, and those calls integrate, so lamps and GI work. Solenoids only arrive through the callback (core.c "Report changed solenoids"), which compares the last integrated levels. Nothing integrates them, so their level never changes and no coil fires. Reproduced headless with AFM: 0 solenoid callbacks with physical solenoids, against ~1,050 in binary mode.
  • PinMameGamelogicEngine.cs:501: _changedGIs is never cleared, and pinmame-dotnet 1.1.0's GetChangedGIs(List) appends without clearing (its GetChangedLamps(List) does clear). So every GI change since game start is dispatched again on every frame. This already happens today with binary GI. With physical GI (0-255, changing every frame) the list would grow by hundreds of entries per second.

Change

  • New Physical Outputs flags field on the gamelogic engine (Startup section, packed): Lamps, GI, Solenoids. The default is none, so existing tables don't change.
  • Applied right after ApplyConfig() with PinmameSetSolenoidMask(2, mask) and read back. The inspector's "Everything" (-1) is masked to these three bits.
  • Lamps / GI levels: physical values are 0-255 (255 = rated brightness). For SingleFading mappings the GLE scales them into the mapping's range (value * FadingSteps / 255). The default PinMAME GI mapping (8 steps) therefore gets full 0-255 resolution, and tables don't need remapping. Inserts already set to 255 steps are unchanged. Other mapping types get 0-255 as is.
  • No double fading: PinMAME's bulb model already fades. For the session only (not saved), the GLE zeroes FadeSpeedUp/Down on the LightComponents mapped to a physical output, and logs how many it changed.
  • Physical solenoids (root-cause fix): SetTimeFence, which the simulation thread calls once per tick, now also calls PinmameGetChangedSolenoids while physical solenoids are on. This integrates them (latency below one tick), and the existing callback path then reports them unchanged. Its own result is dropped. A re-entrancy guard covers the stop paths, which also move the fence. In physical mode every solenoid value is treated as 0-255 (_solenoidsModulated), because a single 1 is a faint level, not "on".
  • Clear _changedGIs before GetChangedGIs (the bug above).
  • Binding: pinmame-dotnet 1.1.0 doesn't expose these calls, so the GLE uses a small guarded internal DllImport (PinmameSet/GetSolenoidMask, PinmameGetMaxSolenoids, PinmameGetChangedSolenoids). It uses the binding's own library names per platform, so it binds to the same loaded library. If the native lacks them, the GLE logs an error and keeps binary outputs. A TODO points to the binding PR, which adds SetPhysicalOutputs/GetChangedSolenoids.

How tested

  • Compile: the Runtime and Editor assemblies of this branch build against Unity 6000.5.0f1's managed DLLs and VPE master's compiled assemblies, with editor, Linux player and Windows player defines (out-of-Unity dotnet build, 0 errors, same as master). VisualPinball.Engine.PinMAME.Test: 30 passed, 2 skipped, as on master.
  • Root cause and fix, headless: a C harness on the libpinmame 3.7.0 VPE ships, running Attack from Mars (afm_113b):
    • mask 12 (lamps + GI): lamp 11 reports physical levels (e.g. 176), and coils are unaffected.
    • mask 2 / all, no integration: 0 solenoid callbacks, including after game start.
    • mask 2 + PinmameGetChangedSolenoids every 1 ms (what this PR does per tick): the callbacks are back, on the same coils as binary mode. Plain coils report 0/255. In the flasher test, sol 17 (#89) goes 9 → 156 → 244 → 255 in ~50 ms and then 200, 117, 69, 43, 27, 17, 11, 8, 5, 3, 2, 1, 0 over ~240 ms. Binary mode reports 1/0 for the same pulses.
  • In Unity (AFM conversion, before this PR): a table-side script did what this PR does for lamps + GI: its own DllImport of PinmameSetSolenoidMask(2, 12), GI mappings set to 255 steps, and light fades zeroed. Result: the game played normally (ball launched, 16 flasher coil events), the ROM read back the types sol 17 = 301 (#89), sol 39 = 400 (LED strobe), lamp 11 = 201, GI = 100, and GI brightness varied (lower-playfield mean 59-93 over a short clip). Flashers there still used a Unity-side #89 approximation, because physical solenoids were dead until this fix.
  • Not done: running this branch's GLE in the editor. Another session had the only Unity project with this setup open. Please try it on a table before merging.

Known limitations

  • Plain coils with physical solenoids: PinMAME's two-state model (CORE_MODOUT_SOL_2_STATE) reports a coil off only 60 ms after its last pulse (core.c, switchDownLatency = 0.060f). Coils driven by the ROM, flippers included, release later than in binary mode. VPX tables with UseVPMModSol = 2 get the same PinMAME behaviour. That is why Solenoids is a separate flag. Lamps + GI alone are safe.
  • Flashers only show their levels with the Engine PR below. Without it a lamp-mapped coil is On for as long as the bulb glows (~250 ms after a 40 ms pulse) and then Off.
  • Custom solenoid aliases above the driver's range only come through under their native numbers in physical mode (e.g. AFM's gates/diverter 51-53 come through as 33-36, core_getAllPhysicSols).
  • Levels saturate at 255 (rated brightness). An over-driven #89 (~1.4x after 40 ms at 20 V) is clipped. The float message API would avoid that, but that's a bigger change.
  • Lamp/GI levels are still polled once per rendered frame, as before. Solenoids are per simulation tick, but PinMAME reports WPC solenoid changes at its 60 Hz interface update.
  • SingleOnOff lamp mappings switch on at any glow, including the cool-down tail. Use SingleFading to see the fade.
  • One unexplained segfault in a headless stress test: 1 of 22 runs (~45 s each) with the pump crashed. The following 21 pump runs and 10 runs without it were clean, and there is no backtrace. That run polled PinmameGetChangedSolenoids at 1 kHz from one thread and lamps/GI at 60 Hz from another, which is the threading this PR introduces in the editor. libpinmame guards these calls with a shared lock, and the outputs touched are disjoint.

Dependencies

🤖 Generated with Claude Code

PinMAME can run lamps, GI and solenoids through the game driver's device
models (#44 lamp bulbs, #89 flasher bulbs, LEDs, two-state solenoids) and
report 0-255 levels, which VPX tables get with UseVPMModSol = 2. VPE never
turned it on, so lamps were 0/1, GI 0-8 and flashers on/off.

Add a "Physical Outputs" flags field (Lamps, GI, Solenoids; default none)
to the PinMAME gamelogic engine:

- Applied right after ApplyConfig (which resets PinMAME's options) with
  PinmameSetSolenoidMask(2, mask) and read back. pinmame-dotnet 1.1.0 has
  no wrapper, so a guarded internal DllImport with the binding's library
  names is used until it ships one (TODO in the code).
- Physical lamp/GI levels are scaled into the range of SingleFading
  mappings (value * FadingSteps / 255), so the default GI mapping (8
  steps) gets the full 0-255 resolution without remapping the table.
- The fades of lights driven by physical outputs are turned off for the
  session, since PinMAME's bulb model already fades them.
- Physical solenoids: libpinmame integrates physical outputs on request
  only (core.c VIDEO_UPDATE(core_gen), "LibPinMAME: on request"). Lamps
  and GI are integrated by the per-frame GetChangedLamps/GIs poll, but
  solenoids only arrive through the callback, which compares the last
  integrated levels, so no coil ever fired. Call PinmameGetChangedSolenoids
  once per simulation tick (in SetTimeFence) to integrate them, and treat
  every solenoid value as 0-255 in physical mode.

Also clear the GI list before GetChangedGIs: PinMame 1.1.0 appends
without clearing, so every GI change since the game started was
dispatched again on every frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Medium impact] Adds physical output mode option to the pinball simulation engine.

Fix the native fallback before merging so a missing polling export cannot leave physical coils enabled without updates.

Findings

  1. P1 Fallback leaves physical coils enabled ▶
  2. P2 Light fades outlive the session ▶

Summary

Adds optional physical output levels for lamps, GI, and solenoids.

  • Tables can choose which PinMAME outputs use physical levels.
  • Physical solenoid levels now reach the engine’s coil events.
  • GI polling no longer replays earlier changes.

Acknowledgments from dcbenji: delayed plain-coil release, flasher support requiring the companion Engine change, custom solenoid aliases changing in physical mode, brightness clipping, frame-rate lamp/GI polling, and on/off mappings staying lit through the glow tail are stated limitations. The unexplained stress-run crash and missing Unity-editor test are disclosed; neither alone is treated as a finding.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Startup settings] --> B[Apply native configuration]
  B --> C[Set physical output mask]
  C --> D[Scale lamp and GI levels]
  C --> E[Disable extra light fades]
  C --> F[Poll solenoids each simulation tick]
  F --> G[Existing coil callbacks]
  F --> H[Poll fails]
  H --> I[Managed flag cleared]
  I --> J[Native mask remains enabled]
Loading

Reviews (1) · Last reviewed commit: "gle: add physical outputs option (PWM-in..." · Reviewed by Greptile

Comment on lines +297 to +299
catch (Exception e) {
Logger.Error(e, "[PinMAME] Updating physical solenoids failed, turning them off.");
_physicalOutputs &= ~PinMamePhysicalOutputFlags.Solenoids;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Fallback leaves physical coils enabled

If the native library lacks PinmameGetChangedSolenoids, startup can still enable physical solenoids because it checks only the other exports. The first poll then clears _physicalOutputs without resetting the native mask. Later ticks skip the poll that physical coils need, so coil events can stop instead of falling back to binary outputs.

Check the required exports before enabling the mask, and restore the native mask on failure. The startup catch needs the same rollback if an earlier native call already succeeded.

Comment on lines +1048 to +1049
light.FadeSpeedUp = 0f;
light.FadeSpeedDown = 0f;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Light fades outlive the session

DisableLightFades overwrites FadeSpeedUp and FadeSpeedDown without saving or restoring their original values. If Unity keeps scene objects between play sessions, a later run with physical outputs disabled can inherit zero fades.

Save the original values and restore them at shutdown so the promised session-only change does not depend on scene reload.

Knowledge Base Used: Unity editor workflows

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant