This repository contains libppuc, the host-side C++ library that configures
and communicates with PPUC IO Boards.
- Treat
src/,README.md,CMakeLists.txt, andplatforms/as the project. - This repo is the counterpart to
../io-boardsand the layer below../ppuc. libppucis not the protocol source of truth. The wire format lives in../io-boards/src/PPUCProtocolV2.h, whose headers are staged intothird-party/include/io-boards/byplatforms/*/*/external.sh, pinned byIO_BOARDS_SHAinplatforms/config.sh.
libppuc is a shared library that:
- loads and validates machine configuration from YAML
- configures attached IO boards over RS485
- derives dense
v2bitmap mappings from sparse logical device numbers - runs a background RS485 loop that pushes output snapshots and collects switch updates
- exposes a logical-number API so callers never deal with bitmaps
Main files:
src/PPUC.cpp(~1.9k lines): YAML load + schema validation, board and device registration, config frame generation, mapping derivation, virtual-board handling, public API.src/RS485Comm.cpp(~2k lines): serial transport vialibserialport,v2frame encode/decode, runtime output loop, token-ring switch polling, epoch session resync, timing constants.src/RS485Comm.h: transport state, runtime bitmaps, queueing, tuning macros.src/PPUC.h,src/PPUC_structs.h: the public API consumed by../ppuc.PPUC.halso carries the version macros parsed by CMake.
Host serial baud must come from the shared constant ppuc::v2::kBaudRate, not
from an independent host-only value.
platforms/macos/arm64/external.sh
cmake -DPLATFORM=macos -DARCH=arm64 -DCMAKE_BUILD_TYPE=Release -B build
cmake --build buildexternal.shstages theio-boardsheaders, buildslibserialport(sigrok) andyaml-cpp, and copies artifacts intothird-party/.- Supported platform/arch combinations live under
platforms/:macos/{arm64, x64},linux/{x64,aarch64},win/{x64,x86},win-mingw/x64. - CI is
.github/workflows/libppuc.yml. - There are no automated tests. CI only proves that the library compiles. Unit tests for YAML validation and mapping derivation are the cheapest stabilization win here and would not touch the timing-sensitive transport.
- On Linux, hardware RS485 mode is enabled automatically for
/dev/ttyAMA0and/dev/serial0, or forced withPPUC_RS485_HW=1. - Format with
.clang-formatbefore committing. - A change here only reaches a
../ppucbuild afterLIBPPUC_SHAin../ppuc/platforms/config.shis bumped, or when building withLIBPPUC_SOURCE_DIR=../libppuc.
- Keep host and firmware assumptions aligned when changing frame sizes, CRC
rules, field endianness, token-ring switch polling, or runtime bitmap sizing.
Validate
libppucand../io-boardstogether. - Preserve the compatibility model: the public API exposes legacy event-like
concepts (
SetSolenoidState,SetLampState, …) while the wire transport is bitmap-basedv2. Internal legacyEventobjects still exist; legacy RS485 event packets do not. - Prefer extending
v2over reviving legacy RS485 packets.
src/PPUC.cpp owns both parsing and schema validation. Top-level sections:
ppucVersion, rom, serialPort, platform, debug, boards,
dipSwitches, switches, switchMatrix, lampMatrix, switchGroups,
pwmOutput, ledStripes, mechs. Per-device effects blocks carry effect and trigger
configuration.
Optional metadata parsed and exposed here:
button: trueon switches →PPUCSwitch::button, so applications can treat cabinet/flipper controls differently from playfield/ball switches.ballSearch: trueon PWM outputs/coils →PPUCCoil::ballSearch.libppucdoes not fire ball-search coils itself;../ppucdecides.pollEvents: trueon boards → registers the board as switch-capable.holdWinding: trueon PWM outputs → the output is the hold winding of a pair the CPU drives separately (WPC Fliptronic flippers). It is wound to sit energised, so the coil validator accepts it with no bound at all. Not the same asdualWinding, which describes a single coil whose EOS contact transfers to its own hold winding mechanically.stopSwitches: [n, ...]on PWM outputs → up to two switches that cut the output the moment they close, sent asCONFIG_TOPIC_STOP_SWITCHandCONFIG_TOPIC_STOP_SWITCH_2. The opposite polarity tofastFlipSwitch, which runs an output while a switch is closed. A flipper's EOS, or the switch at each end of a motor's travel. Sent beforeCONFIG_TOPIC_TYPE, because the board registers the output when the type arrives and anything after it lands on the next output. Deliberately not counted as thermal protection: a stop switch can be unplugged or misadjusted, and WPC itself paired one with a timeout rather than trusting it alone.slowSwitches: trueon boards → the board carries nothing latency-critical (start button, coin door, tilt), so it is sorted to the front of the token ring and polled on every eighth chain instead of every chain. Skipping is lossless — boards queue a snapshot per transition — but slows how fast a burst of changes drains.debounce+debounceMode→CONFIG_TOPIC_DEBOUNCE_TIME/CONFIG_TOPIC_MODE.typeon boards → the hardware the board is, byppuc::v2::BoardTypeName()(IO_16_8_1,IO_16x8_matrix,Out_8x10,Opto_16). Optional, andIO_16_8_1when absent. Aportis a GPIO number and what a GPIO is depends on the board, soValidateBoardHardware()checks every switch, PWM output, LED string and matrix against the board's profile inio-boards/PPUCBoardTypes.h- staged from../io-boardslike the protocol header, and the same table the firmware refuses a bad config from. It also refuses two PWM outputs of one board that share an RP2040 PWM channel (GPIOs sixteen apart).GetConfiguredBoardType()exposes the type so an application can compare it with whatQueryBoardVersions()reports.switchMatrix→ on anIO_16_8_1the 4-column matrix on its inputs (4 or 8 rows); on anIO_16x8_matrixthe strobed matrix of 8 columns by 1-16 rows, which takes all sixteen inputs and all eight outputs of that board.portis the positioncolumn * rows + rowin both.lampMatrix→board,rows(1-10) andlamps(description,port,number), sent asCONFIG_TOPIC_LAMP_MATRIX. Only anOut_8x10drives one: 8 high-side columns by up to 10 low-side rows,portagaincolumn * rows + row. ApwmOutputof typelampon that board is a lamp wired to a single output and may not sit on a line the matrix uses; the board has no PWM, sopoweris ignored and no other output type is accepted.switchGroups→ named groups exposed to the Lua rules engine. The groupbuttonsis built in frombutton: trueswitches and cannot be overridden.
tools/check-schema-drift.py <exported-game.yml>... compares a real
config-tool export against the keys this validator names, in both directions. A
key an export contains and the validator does not know is silently ignored -
which is what a typo in a game YAML looks like today. CI runs it against the
exports in mkalkbrenner/ppuc_games. Pass --new-export when checking a single
freshly created game: the "validated but never exported" direction asks whether
any game uses a key, which one game cannot answer.
Schema validation must be kept in sync with every config feature. Whenever a
YAML section, field, accepted type, or optional key is added anywhere in the
stack, extend the validation pass so malformed files fail early with the
section path and YAML line/column — and check that ../config-tool exports the
same shape.
- UART baud
115200(ppuc::v2::kBaudRate); planned move to250000. - Sync
0xA5, 5-byte header (sync,typeAndFlags,nextBoard,sequence,epoch), CCITT-16 CRC over header + payload. - All multibyte values are written big-endian:
SetupFrame16-bit counts,MappingFrame16-bitindex/number,ConfigFrame32-bitvalue. That matches the current firmware parser. OutputStateFramecarries a dense coil bitmap, a dense lamp bitmap, and packed GI brightness for 5 fixed GI strings.
- Open the serial port through
RS485Comm::Connect(). - Flush and send
RestartFrame. - Wait briefly, then flush stale adapter bytes.
- Parse YAML and emit
ConfigFrames for platform, switches, switch matrix, PWM outputs, LEDs, effects, and trigger rules. - Require addressed boards to acknowledge config with
ConfigAck. - Register switch-capable boards and send
CONFIG_TOPIC_SWITCH_CHAINto define token handoff. - Derive dense coil/lamp/switch mappings from configured logical numbers.
- Send
SetupFrame, wait 100 ms. - Send all
MappingFrames, wait 1 s. - For non-WPC platforms, force GI string 1 to full brightness — older systems
such as System 6 do not provide useful GI updates through PinMAME. The same
reassertion happens in
StartUpdates(). - Start the
RS485Comm::Run()background loop.
If config acknowledgments are missing after RestartFrame, the host logs the
condition and fails the connection. A hard ResetFrame retry is not automatic;
it only happens when the caller forces hard reset
(SetForceHardReset() / ppuc-pinmame --hard-reset).
Shutdown: stop the runtime thread, send RestartFrame as a best-effort
output-off/session-clear, drain and flush the adapter, close the port.
- Coils, lamps, and switches are collected into sorted unique sets, which become dense index↔number mappings.
RuntimeConfigbit counts are the mapping sizes, clamped tokMax*Bits(kMaxCoilBits = 64,kMaxLampBits = 256,kMaxSwitchBits = 256).RS485Commkeeps both directions: index→number vectors and number→index hash maps.- Callers set lamps/coils by logical number;
QueueEvent()converts them into bitmap bits. Numbers absent from the derived mapping are silently ignored — a known sharp edge. - Dense wire mappings do not need to preserve the highest logical number. 64 coil slots are considered sufficient even when logical coil numbers are larger or sparse.
RS485Comm::Run() is the steady-state loop.
- Lamp, GI, and solenoid events become local bitmap changes, not discrete packets.
- One
OutputStateFrameper iteration carries the complete snapshot. Output state is not incremental. - Default cadence is 4 ms (
RS485_COMM_DEFAULT_OUTPUT_FRAME_INTERVAL_MS), configurable throughSetOutputFrameIntervalMs(). header.nextBoardis the first registered switch board, orkNoBoard.- After each output frame the host immediately reads the chained switch replies.
SwitchRefreshFrameis sent through the same chain when the application-configured idle timer expires.- Host-injected runtime events reach board-local effects as
kFrameTrigger.
GI is transmitted separately from lamps: 5 fixed strings, packed 4-bit values,
valid 0..8 with 0 = off. QueueEvent(EVENT_SOURCE_GI, string, brightness)
updates dedicated GI state, not the lamp bitmap. This matters because one
addressable LED string may mix lamps, GI, and flashers, and those domains must
stay independent in transport.
ReceiveSwitchStateChain() expects a SwitchStateFrame or
SwitchNoChangeFrame from the selected board; header.nextBoard in that reply
selects the next responder; the chain ends at kNoBoard.
- Sender identity is not verified beyond the chained
nextBoardvalue, because frames carry no sender ID. - CRC failure aborts the receive chain for that cycle.
- Switch changes are edge-diffed against
m_switchBitmapand exposed as queuedPPUCSwitchStateobjects. - Replies carry status flags (
kStatusInSync,kStatusNeedsSetup,kStatusMappingIncomplete,kStatusSequenceGap,kStatusParserResynced,kStatusSwitchOverflow) that feed the resync decision. - Session resync bumps
m_epochand re-sends setup/mapping instead of resetting boards.
RestartFrame: normal startup/shutdown path. Boards clear config/runtime state, turn outputs off, and stay alive on UART for immediate reconfiguration.ResetFrame: hard-reboot recovery path. Keep it for wedged boards and debugging.
Host switch-chain timing affects overall runtime quality, not only switch diagnostics. Confirmed on real hardware: increasing the switch-reply receive window, increasing the serial read timeout, flushing stale input after a missed chain, and delaying session resync until several consecutive misses made lamp attract-mode animation visibly correct again. Aggressive timeout/resync behavior degrades normal output animation even when lamps still look generally active.
Current tuning points in RS485Comm.h / RS485Comm.cpp:
RS485_COMM_SERIAL_READ_TIMEOUT(5),RS485_COMM_SERIAL_WRITE_TIMEOUT(20)RS485_COMM_SWITCH_REPLY_MISS_THRESHOLD(3)RS485_COMM_SWITCH_POLL_STARTUP_HOLD_MS(250)RS485_COMM_CONFIG_ACK_TIMEOUT_US(50000),RS485_COMM_CONFIG_ACK_RETRIES(3),RS485_COMM_INITIAL_CONFIG_ACK_MISS_THRESHOLD(10)RS485_COMM_DEFAULT_OUTPUT_FRAME_INTERVAL_MS(4)RS485_COMM_EFFECT_EVENT_SPACING_US(1000)- the switch-reply receive window in
ReceiveSwitchStateFrame() - stale-input flush after a missed chain, short-write retry behavior
Best known-good evidence: Time Warp attract mode ran 1h40m4s with no
communication errors. Treat these as per-cabinet transport tuning
parameters, not protocol constants — games with more switch boards will need
retuning. Do not casually retune them while working on unrelated features.
Protocol hardening must not cost throughput or latency: a full output snapshot plus the complete switch chain runs every 4 ms. Sequence/loss detection, heartbeats, and error reporting should ride in existing header/status fields or idle time rather than adding runtime frames or round trips. Measure before and after on hardware.
- Board presence is determined by firmware-backed
ConfigAckresponses during startup config transmission; everyConfigFramemust be acknowledged by the addressed board. - Configured boards that do not acknowledge become host-side virtual boards. Unacknowledged config frames are printed as startup errors with board/topic/index/key details.
- All switches owned by a virtual board are initialized to
open, and host-side switch injection is limited to those switches. - Runtime switch polling uses the full logical switch-board order including
missing boards. Real boards hand off to the next logical board even if that
board is missing; the host then emits a normal
SwitchStateorSwitchNoChangeframe on the bus on behalf of the virtual board. SetSkippedBoardsCsv()lets../ppucforce boards virtual immediately (--skip-boards) without waiting for presence detection.- This is the basis for bench setups where the cabinet board is absent, using one unmodified game config.
- The runtime loop always restarts token polling from the first registered switch board on every output frame. Changing fairness or latency requires coordinated firmware changes.
sequenceis transmitted but not validated for loss, duplicate detection, or synchronization.QueueEvent()silently ignores lamp/coil numbers absent from the derived mappings.- GI uses fixed runtime slots rather than the dynamic mapping tables used for coils, lamps, and switches.
- Reset/restart robustness remains the top unresolved transport problem; soft restart is the preferred path.
- Re-test board restart/reset behavior first.
- Verify mapping sizes equal the number of unique configured logical numbers, not raw port counts.
- Check that
pollEvents: trueboards also receiveCONFIG_TOPIC_SWITCH_CHAIN. - Run a single-board switch test first, then multi-board token chaining.
- On Linux, test with and without
PPUC_RS485_HW=1if direction control looks wrong. - When multi-board chaining mostly works but animation or switch latency looks wrong, tune timing before changing protocol semantics.