The Pinball Power-Up Controllers are a set of hard- and software designed to repair and enhance the capabilities of classic pinball machines of the 80s and 90s and to drive the hardware of home brew pinball machines. The project is in ongoing development. Visit the PPUC Page for further information. This directory contains the PinMAME related parts, mainly the building blocks to emulate a pinball CPU that drives PPUC I/O boards.
We want to enable people to be creative and to modernize old pinball machines using today's technology. Our goal is to establish an open and affordable platform for that. Ideally people will publish their game-specific PPUs so others could leverage and potentially improve them. We want to see a growing library of so-called Pinball Power-Ups (PPUs) and a vital homebrew pinball community.
The code in this directory and all sub-directories is licenced under GPLv3, except if a different license is mentioned in a file's header or in a sub-directory. Be aware of the fact that your own enhancements of ppuc need to be licenced under a compatible licence.
PPUC uses
- libppuc for host-side IO board communication
- libpinmame for ROM emulation
- libdmdutil, libsdldmd, SDL3, and SDL_image for DMD/backglass rendering
- VPX media plugins from vpinball, including PUP, AltSound, and B2S support
- libaltsound and FFmpeg libraries for media plugin audio/video support
- Lua for runtime rules
- cargs for command-line parsing
- yaml-cpp for IO board configuration
- flite and optionally eSpeak NG for speech output
- Optional SDL_mixer support for background music playback
These components are still in an early development stage and the documentation will grow.
The preferred runtime entry point is a game folder:
ppuc-pinmame --game /path/to/games/t2The game folder is the per-game runtime root. ppuc-pinmame derives the
standard files and media folders from that one path:
games/
t2/
io-boards.yaml
ppuc.ini
t2_l8.directb2s
translite-on.png
translite-off.png
rules/
00-base.lua
10-callouts.lua
20-lamps.lua
music/
01-theme.ogg
02-multiball.ogg
pup/
pupvideos/
t2_l8/
...
pinmame/
roms/
t2_l8.zip
nvram/
t2_l8.nv
cfg/
t2_l8.cfg
altsound/
t2_l8/
altsound.csv
*.ogg
*.wav
altcolor/
t2_l8/
t2_l8.cROMc
t2_l8.vni
t2_l8.pal
t2_l8.cRZ
With --game, defaults are:
- IO board config:
<game>/io-boards.yaml - runtime INI:
<game>/ppuc.ini, when present - Lua rules:
<game>/rules/*.lua, whenRuntime.Rules=true - background music: supported audio files in
<game>/music/, in filename order - in-game translite:
<game>/translite-on.*, falling back to<game>/translite.*, unless B2S is enabled - attract/off translite:
<game>/translite-off.*, falling back to<game>/translite-attract.*, unless B2S is enabled - PinMAME base:
<game>/pinmame - PUP root:
<game>/pup; packs are under<game>/pup/pupvideos/<rom> - AltSound:
<game>/pinmame/altsound/<rom> - AltColor:
<game>/pinmame/altcolor - B2S:
<game>/<rom>.directb2s, falling back to<game>/<folder-name>.directb2s
Dedicated command-line options override values from the game folder and INI.
- --game path
- path to a game folder containing
io-boards.yaml,ppuc.ini, rules, PUP, and PinMAME assets - preferred
- optional when using the legacy explicit path options
- path to a game folder containing
- -c path
- path to IO board config file
- optional when
--gameis used
- --ini-file path
- path to a ppuc runtime ini file
- optional
- values from dedicated command line options override values from the ini file at runtime
- -r rom name
- rom to use, overwrites rom setting in config file
- optional
- -s serial device
- serial device path to use, overwrites serialPort setting in config file
- optional
- -d
- enable debug mode, overwrites debug setting in config file
- optional
- --debug-effects
- enable effect trigger debug output
- optional
- -u
- enable legacy Serum/AltColor colorization
- optional
- --altcolor
- enable AltColor DMD colorization
- optional
- -t VALUE
- Serum timeout in milliseconds to ignore unknown frames
- optional
- -p VALUE
- Serum ignore number of unknown frames
- optional
- --rules path
- path to one Lua rules file or a directory containing Lua rule files
- explicit override for the default
<game>/rulesdirectory - optional
- --pup
- enable PUP media playback through the plugin host
- optional
- --b2s
- enable B2S backglass rendering through the plugin host
- optional
- --altsound
- enable AltSound through the plugin host
- optional
- --music-files csv
- comma-separated MP3 playlist for gameplay background music
- explicit override for the default
<game>/musicdirectory - plays only while the game is not in attract mode
- ducks while PinMAME or speech audio is active
- optional
- --switch-refresh-idle-ms VALUE
- re-read all IO board switches after this many milliseconds without non-button switch updates
- default:
15000 - always active; the value must be greater than zero
- --ball-search
- enable host-side ball search for coils marked
ballSearch: trueinio-boards.yaml - optional and disabled by default because newer ROMs often implement their own ball search
- enable host-side ball search for coils marked
- --ball-search-delay-ms VALUE
- first ball-search delay after no non-button switch activity while the game is running
- default:
15000
- --ball-search-round-delay-ms VALUE
- delay between complete ball-search rounds
- default:
5000
- --speech-backend value
- speech backend to use:
auto,flite,espeak-ng - optional
- speech backend to use:
- --speech-voice value
- speech voice name, mainly for
espeak-ng - optional
- speech voice name, mainly for
- --speech-rate value
- speech rate in words per minute, mainly for
espeak-ng - optional
- speech rate in words per minute, mainly for
- --speech-pitch value
- speech pitch
0-100, mainly forespeak-ng - optional
- speech pitch
- -i
- render display in console
- optional
- -h
- help
An example runtime ini file is available at examples/ppuc-pinmame.ini.
The game folder should normally contain a full ppuc.ini. It is not a minimal
replacement config; it is the normal runtime configuration file scoped to one
game.
The ROM can be declared in [Game]:
[Game]
Rom = t2_l8Common media options live in [Runtime]:
[Runtime]
Rules = true
PUP = true
B2S = true
B2SSegmentAngleDegrees = 9.0
B2SSegmentGlow = 1.4
B2SSegmentSmoothing = true
AltSound = true
AltColor = trueRules is a boolean. When it is true and --game is used, the rules loader
scans <game>/rules/*.lua in filename order. AltColor is the user-facing DMD
colorization enable switch. SerumTimeout and SerumSkipFrames remain
available for Serum-specific tuning only.
PUP packs live under <game>/pup/pupvideos/<rom>/. The pupvideos directory is
part of the required PUP folder structure.
Background music files live under <game>/music/ and are loaded in filename
order. Supported extensions are .mp3, .ogg, .wav, .flac, .opus, and
.m4a.
Translite images live at the game-folder root:
translite-on.png
translite-off.png
translite-on.* is shown during gameplay. translite-off.* is shown in
attract/off mode. The fallback names are translite.* and
translite-attract.*. When --b2s or Runtime.B2S=true is used, these
game-folder translite defaults are ignored so the B2S plugin owns the
backglass.
AltSound lives under <game>/pinmame/altsound/<rom>/.
AltColor files live under <game>/pinmame/altcolor/<rom>/, for example:
pinmame/altcolor/t2_l8/t2_l8.cROMc
pinmame/altcolor/t2_l8/t2_l8.vni
pinmame/altcolor/t2_l8/t2_l8.pal
pinmame/altcolor/t2_l8/t2_l8.cRZ
B2S files live at the game-folder root. --b2s loads the modern B2S plugin,
not the packaged B2SLegacy plugin. Lookup is case-insensitive and checks:
<game>/<rom>.directb2s
<game>/<folder-name>.directb2s
ppuc-pinmame always runs a switch-refresh safety net. If no non-button switch
update arrives for Runtime.SwitchRefreshIdleMs, the host sends a v2 switch
refresh command. IO boards re-read their switch inputs, restart their local
switch readers, and return full switch bitmaps through the normal switch chain.
The normal runtime output/switch-poll cadence is controlled by
Runtime.OutputFrameIntervalMs or --output-frame-interval-ms; the default is
4.
Switches can be marked as cabinet/player buttons in io-boards.yaml:
switches:
-
description: 'LEFT FLIPPER BUTTON'
number: 63
board: 1
port: 17
debounce: 3
debounceMode: fastFlip
button: trueButton switch activity does not postpone switch refresh or ball search. A held button does suppress ball search, so a player can hold a ball on a raised flipper without the host firing search coils.
Host-side ball search is separate from switch refresh and is disabled by default. Enable it only for older ROMs that do not perform their own ball search:
[Runtime]
OutputFrameIntervalMs = 4
BallSearch = true
BallSearchDelayMs = 15000
BallSearchRoundDelayMs = 5000Only coils marked ballSearch: true are fired. They are pulsed one after
another with the same short pulse style as coil test, then the host waits
BallSearchRoundDelayMs before starting the next round if the machine is still
quiet.
pwmOutput:
-
description: 'Outhole Kicker'
number: 7
board: 4
port: 3
type: solenoid
ballSearch: trueUse Runtime.Rules=true in a game-folder INI to run Lua rules from
<game>/rules. Directory loading is non-recursive, loads top-level *.lua
files in filename order, and fails on the first load or runtime error. The
legacy --rules <path> option is still available as an explicit override.
Rules are independent from --pup, and can also drive speech callouts,
board-local PPUC effects, and host-side interceptor behavior.
Rules define handlers on the ppuc namespace:
function ppuc.onSwitchChanged(number, state)
if number == 13 and state == 1 and ppuc.lampState(42) then
ppuc.pupTrigger("P", 100, 1)
end
if ppuc.stateActive("ballSave") and number == 9 and state == 1 then
ppuc.suppressSwitch(9)
ppuc.pulseCoil(7, 120)
end
end
function ppuc.onLampChanged(number, state)
if number == 23 and state == 1 and not ppuc.attractMode() then
ppuc.speech("New highscore!")
end
endWhen multiple rule files define the same handler, all handlers run in load
order. Rule files share one Lua state, so use local helper functions and
variables unless cross-file globals are intentional.
Supported handlers:
ppuc.onSwitchChanged(number, state)ppuc.onLampChanged(number, state)ppuc.onCoilChanged(number, state)ppuc.onBallChanged(ball)ppuc.onPlayerChanged(player)ppuc.onRulesUpdate()
State helpers and handler values:
ppuc.switchState(number),ppuc.lampState(number),ppuc.coilState(number)ppuc.currentBall(),ppuc.currentPlayer(),ppuc.attractMode()- Changed handlers receive
numberandstate; usestate == 1for active/closed/on andstate == 0for inactive/open/off.
Named states, history, and switch groups:
ppuc.setState(name)andppuc.setState(name, durationMs)ppuc.clearState(name)andppuc.stateActive(name)ppuc.triggerHistory(id)andppuc.triggerHistory(id, windowMs)ppuc.triggerSequence(windowMs, id1, id2, id3)ppuc.onlyOnceEvery(name, durationMs)returns true only once per named time windowppuc.switchGroupState(name),ppuc.switchGroupClosing(name),ppuc.switchGroupOpening(name)
Switch groups can be declared in io-boards.yaml:
switchGroups:
playfield:
switches: [10, 11, 12, 13]The group buttons is built in from switches marked button: true and cannot
be overridden in YAML.
Outputs and integrations:
ppuc.after(delayMs, function() ... end)schedules non-blocking delayed Lua workppuc.pupTrigger(source, id, value)ppuc.speech(text)ppuc.effectTrigger(id, value)orppuc.effectTrigger(name, value)ppuc.suppressSwitch(number)ppuc.sendSwitchToCpu(number, state)ppuc.pulseCoil(number, durationMs)ppuc.blinkLamp(number, onMs, offMs)andppuc.stopBlinkLamp(number)
ppuc.after(...) does not sleep inside the PinMAME loop. It stores the callback
and runs it from the normal rules update tick after the requested delay:
function ppuc.onSwitchChanged(number, state)
if number == 16 and state == 1 then
ppuc.after(500, function()
ppuc.speech("Test")
end)
end
endA ready-to-use sample file is available at examples/rules.lua.
Interceptor-specific behavior is documented in INTERCEPTOR.md.
Board effect trigger source:
F- board-local effect trigger
- forwarded to
libppucas a runtime event with sourceEVENT_SOURCE_EFFECT - use matching
trigger.source: Fplustrigger.nameortrigger.numberin theio-boards.yamleffect block
Speech callouts use the configured speech backend directly from Lua:
ppuc.speech("New highscore!")Speech backends:
auto- prefer
espeak-ngwhen available, otherwiseflite
- prefer
flite- lightweight default backend
espeak-ng- second backend with broader voice/language support when staged in
third-party - default voice is now a more distinct
en-us+f3
- second backend with broader voice/language support when staged in
Examples:
--speech-backend espeak-ng --speech-voice en-us+m3--speech-backend espeak-ng --speech-rate 210 --speech-pitch 35--speech-backend flite --speech-voice kal
Ready-to-use samples are available at:
examples/rules.lua
The platform build scripts stage dependencies into third-party. By default,
they use the pinned GitHub archive SHAs from platforms/config.sh, which keeps
normal user builds reproducible.
For local development across the PPUC repositories, set PPUC_DEPENDENCY_SOURCE
when running a build script from the ppuc repository root:
PPUC_DEPENDENCY_SOURCE=local platforms/macos/arm64/build.shlocal uses sibling source checkouts from the parent workspace for the managed
PPUC dependency tree, including ../libppuc, ../libsdldmd, ../io-boards,
../libdmdutil, ../libzedmd, ../libserum, ../libvni, ../libframeutil,
and ../vpinball. Override the workspace root with PPUC_LOCAL_SOURCE_ROOT if
those repositories live somewhere else:
PPUC_DEPENDENCY_SOURCE=local \
PPUC_LOCAL_SOURCE_ROOT=/path/to/workspace \
platforms/macos/arm64/build.shSet PPUC_DEPENDENCY_SOURCE=github or PPUC_DEPENDENCY_SOURCE=sha to force the
pinned archive path even when source directory variables are present. The default
mode is explicit, where only manually provided *_SOURCE_DIR variables are
used.
platforms/win/x64/build.shplatforms/win/x86/build.shplatforms/linux/x64/build.shsudo apt install git autoconf libtool libudev-dev libpipewire-0.3-dev libwayland-dev libdecor-0-dev liburing-dev libasound2-dev libpulse-dev libaudio-dev libjack-dev libsndio-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxtst-dev libxkbcommon-dev libdrm-dev libgbm-dev libgl1-mesa-dev libgles2-mesa-dev libegl1-mesa-dev libdbus-1-dev libibus-1.0-dev
git clone https://github.com/PPUC/ppuc.git
cd ppuc
platforms/linux/x64/build.sh
ppuc/ppuc-pinmame --game /path/to/games/t2 -n -ippuc-menu is an SDL3 selector that reads a plain-text menu file with repeated title, image, selected-image, and command fields. It uses the same speech backend options as ppuc-pinmame.
ppuc/ppuc-menu \
--menu-file examples/menu.txt \
--logo assets/ppuc-logo.png \
--slogan "Choose a machine and press Enter" \
--speech --greetingplatforms/linux/aarch64/build.shAfter building, install a per-user GNOME/XDG autostart entry with:
platforms/linux/install-gnome-autostart.sh -- --game /path/to/games/t2 -n -iThis writes ~/.config/autostart/ppuc-pinmame.desktop with absolute paths to the
generated launcher and starts ppuc-pinmame in a terminal window so the process
stays attached to the foreground session. Pass the same arguments you normally
use on the command line after --.
By default the installer prefers gnome-terminal, then kgx, then
x-terminal-emulator. Override that with:
platforms/linux/install-gnome-autostart.sh --terminal gnome-terminal -- --game /path/to/games/t2 -n -iThe installer also sets a GNOME autostart delay of 10 seconds by default so the terminal is launched after the session settles. Override that with:
platforms/linux/install-gnome-autostart.sh --delay 15 -- --game /path/to/games/t2 -n -iOn GNOME, the generated launcher also tries to dismiss the Activities overview a
couple of seconds after startup so the terminal and game are shown on the normal
workspace instead of remaining visible only in the workspace selector. Disable
that behavior with --no-dismiss-overview if needed.
Remove the autostart entry with:
platforms/linux/uninstall-gnome-autostart.shplatforms/macos/arm64/build.shplatforms/macos/x64/build.sh