From 5ace22a966b2cf65ff0fd634f461a5f23a047739 Mon Sep 17 00:00:00 2001 From: Markus Kalkbrenner Date: Sun, 21 Jun 2026 12:48:02 +0200 Subject: [PATCH 1/7] WIP: interceptor --- README.md | 34 +++ RULES_AND_EFFECTS.md | 52 ++++ examples/pup-triggers.rules | 18 +- src/PUPTriggerEngine.cpp | 535 +++++++++++++++++++++++++++++++----- src/PUPTriggerEngine.h | 65 ++++- src/ppuc.cpp | 153 ++++++++++- 6 files changed, 778 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 3d9da2a..989cdc9 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,36 @@ Rule options: * wait before firing after the expression matches * for state-based expressions, the condition must still be true when the delay expires * for edge expressions like `switch_rising(...)`, the matching edge arms the delayed trigger once +* `set_state=` with optional `state_ms=` + * set a named transient state that can be tested with `state()` + * omit `state_ms` for a state that remains set until `clear_state=` +* `clear_state=` + * clear a named state when the rule matches +* `suppress_switch=` + * prevent a matching physical switch close from being reported to PinMAME + * the matching switch open is also suppressed so PinMAME sees no edge +* `pulse_coil=` with optional `pulse_ms=` + * pulse a coil from the host; default pulse is 120 ms +* `blink_lamp=` with optional `blink_on_ms=` and `blink_off_ms=` + * blink a lamp while the rule expression remains true; defaults are 250/250 ms + +Additional expression functions: +* `state()` +* `switch_group()` +* `switch_rising_group()` +* `switch_falling_group()` +* `switch_rising(, , ...)` and `switch_falling(, , ...)` + +Switch groups can be declared in the game YAML: + +```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. Example: @@ -206,6 +236,10 @@ P 101 1 cooldown=500 : switch_rising(13) && attract P 102 1 delay=750 : switch(13) && attract O 60010 1 : lamp_rising(23) && !attract F cabinet-attract 1 : lamp_rising(5) && attract +S ball-save-ready set_state=ball_save_ready : switch_rising(15) +S ball-save-active set_state=ball_save state_ms=5000 clear_state=ball_save_ready : state(ball_save_ready) && switch_rising_group(playfield) +S shoot-again-blink blink_lamp=8 : state(ball_save_ready) || state(ball_save) +S outhole-save suppress_switch=9 pulse_coil=7 pulse_ms=120 : state(ball_save) && switch_rising(9) ``` A ready-to-use sample file is available at `examples/pup-triggers.rules`. diff --git a/RULES_AND_EFFECTS.md b/RULES_AND_EFFECTS.md index cbb5bf3..c760f9c 100644 --- a/RULES_AND_EFFECTS.md +++ b/RULES_AND_EFFECTS.md @@ -143,10 +143,13 @@ The conditions are written with readable words: - `player(...)` - `history(...)` - `sequence(...)` +- `state(...)` - `switch(...)` +- `switch_group(...)` - `lamp(...)` - `coil(...)` - `switch_rising(...)` +- `switch_rising_group(...)` - `lamp_rising(...)` - `coil_rising(...)` - `attract` @@ -157,6 +160,55 @@ Current limitation: Trigger history is retained per player for a rolling time window and is cleared when the table returns to attract mode. Rules can also use `clear_player_history=` to drop stored history for a specific player before the new trigger is recorded. +### Interceptor Rules + +Interceptor behavior is part of the same rules file. Rules still observe +physical switch state, even when a switch event is suppressed before it reaches +PinMAME. + +Additional rule options: + +- `set_state=` with optional `state_ms=` +- `clear_state=` +- `suppress_switch=` +- `pulse_coil=` with optional `pulse_ms=`; default is 120 ms +- `blink_lamp=` with optional `blink_on_ms=` and + `blink_off_ms=`; defaults are 250/250 ms + +Additional expression functions: + +- `state()` +- `switch_group()` +- `switch_rising_group()` +- `switch_falling_group()` +- `switch_rising(, , ...)` +- `switch_falling(, , ...)` + +`suppress_switch=` blocks a matching switch close from being forwarded +to PinMAME and also blocks the matching switch open. Host-side coil pulses and +lamp blinking temporarily override normal PinMAME output for that output number; +when the override ends, the last PinMAME output state is restored. + +Switch groups can be declared in game YAML: + +```yaml +switchGroups: + playfield: + switches: [10, 11, 12, 13] +``` + +The group `buttons` is built in from switches marked `button: true` and cannot +be overridden. + +Example: + +```text +S ball-save-ready set_state=ball_save_ready : switch_rising(15) +S ball-save-active set_state=ball_save state_ms=5000 clear_state=ball_save_ready : state(ball_save_ready) && switch_rising_group(playfield) +S shoot-again-blink blink_lamp=8 : state(ball_save_ready) || state(ball_save) +S outhole-save suppress_switch=9 pulse_coil=7 pulse_ms=120 : state(ball_save) && switch_rising(9) +``` + So the intended reading is: - rule header: target/output channel plus trigger ID diff --git a/examples/pup-triggers.rules b/examples/pup-triggers.rules index b0ff00b..bd20cfa 100644 --- a/examples/pup-triggers.rules +++ b/examples/pup-triggers.rules @@ -1,6 +1,9 @@ # Format: # [value] [cooldown=] [delay=] [set_player=] -# [clear_player_history=] : +# [clear_player_history=] [set_state=] [state_ms=] +# [clear_state=] [suppress_switch=] [pulse_coil=] +# [pulse_ms=] [blink_lamp=] [blink_on_ms=] +# [blink_off_ms=] : # Special source: # - S silent rule: apply state/history side effects, but emit no external trigger # @@ -10,12 +13,16 @@ # - history() true if this trigger id fired before for the current player # - history(, ) same, limited to the last milliseconds # - sequence(, , , ...) true if those trigger ids fired in order within +# - state() true while a named state is active # - switch() current switch state (true when closed) +# - switch_group() true if any switch in a configured group is closed # - lamp() current lamp state (true when lit) # - coil() current coil state (true when active) # - attract / attract() true if table is in attract mode (no game running) -# - switch_rising() switch transitioned 0 -> 1 in this event +# - switch_rising([, ...]) switch transitioned 0 -> 1 in this event # - switch_falling() +# - switch_rising_group() +# - switch_falling_group() # - lamp_rising() # - lamp_falling() # - coil_rising() @@ -58,3 +65,10 @@ P 104 1 cooldown=500 : switch_rising(13) && attract # Trigger 750ms after switch 13 closes in attract mode. # If switch 13 opens again before 750ms, this delayed trigger is cancelled. P 105 1 delay=750 : switch(13) && attract + +# Interceptor example: host-side ball save. +# Requires a YAML switchGroups.playfield list, plus real switch/lamp/coil numbers. +# S ball-save-ready set_state=ball_save_ready : switch_rising(15) +# S ball-save-active set_state=ball_save state_ms=5000 clear_state=ball_save_ready : state(ball_save_ready) && switch_rising_group(playfield) +# S shoot-again-blink blink_lamp=8 : state(ball_save_ready) || state(ball_save) +# S outhole-save suppress_switch=9 pulse_coil=7 pulse_ms=120 : state(ball_save) && switch_rising(9) diff --git a/src/PUPTriggerEngine.cpp b/src/PUPTriggerEngine.cpp index c8964b0..4792dc3 100644 --- a/src/PUPTriggerEngine.cpp +++ b/src/PUPTriggerEngine.cpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -138,6 +139,24 @@ bool ParseUInt32Token(const std::string& token, uint32_t& out) return true; } +bool IsNameToken(const std::string& token) +{ + if (token.empty()) + { + return false; + } + + for (char c : token) + { + const unsigned char uc = static_cast(c); + if (!(::isalnum(uc) || c == '_' || c == '-' || c == '.')) + { + return false; + } + } + return true; +} + class TriggerExpressionParser { public: @@ -327,10 +346,18 @@ class TriggerExpressionParser { return ParseSequenceFunction(); } + else if (identifier == "state") + { + return ParseNamedStateFunction(); + } else if (identifier == "switch") { functionType = PUPTriggerEngine::ExprNodeType::SwitchState; } + else if (identifier == "switch_group") + { + return ParseGroupFunction(PUPTriggerEngine::ExprNodeType::SwitchGroupState, identifier); + } else if (identifier == "lamp") { functionType = PUPTriggerEngine::ExprNodeType::LampState; @@ -341,11 +368,19 @@ class TriggerExpressionParser } else if (identifier == "switch_rising") { - functionType = PUPTriggerEngine::ExprNodeType::SwitchRising; + return ParseSwitchNumberListFunction(PUPTriggerEngine::ExprNodeType::SwitchRising, identifier); } else if (identifier == "switch_falling") { - functionType = PUPTriggerEngine::ExprNodeType::SwitchFalling; + return ParseSwitchNumberListFunction(PUPTriggerEngine::ExprNodeType::SwitchFalling, identifier); + } + else if (identifier == "switch_rising_group") + { + return ParseGroupFunction(PUPTriggerEngine::ExprNodeType::SwitchRisingGroup, identifier); + } + else if (identifier == "switch_falling_group") + { + return ParseGroupFunction(PUPTriggerEngine::ExprNodeType::SwitchFallingGroup, identifier); } else if (identifier == "lamp_rising") { @@ -393,6 +428,108 @@ class TriggerExpressionParser auto node = std::make_unique(); node->type = functionType; node->number = number; + node->numbers.push_back(number); + return node; + } + + std::unique_ptr ParseNamedStateFunction() + { + SkipWhitespace(); + if (!Match("(")) + { + m_error = "missing ( after function 'state'"; + return nullptr; + } + + const std::string name = ParseArgumentToken(); + if (name.empty()) + { + m_error = "expected state name in function 'state'"; + return nullptr; + } + + SkipWhitespace(); + if (!Match(")")) + { + m_error = "missing ) in function 'state'"; + return nullptr; + } + + auto node = std::make_unique(); + node->type = PUPTriggerEngine::ExprNodeType::NamedState; + node->name = name; + return node; + } + + std::unique_ptr ParseGroupFunction( + PUPTriggerEngine::ExprNodeType type, const std::string& identifier) + { + SkipWhitespace(); + if (!Match("(")) + { + m_error = "missing ( after function '" + identifier + "'"; + return nullptr; + } + + const std::string name = ParseArgumentToken(); + if (name.empty()) + { + m_error = "expected group name in function '" + identifier + "'"; + return nullptr; + } + + SkipWhitespace(); + if (!Match(")")) + { + m_error = "missing ) in function '" + identifier + "'"; + return nullptr; + } + + auto node = std::make_unique(); + node->type = type; + node->name = name; + return node; + } + + std::unique_ptr ParseSwitchNumberListFunction( + PUPTriggerEngine::ExprNodeType type, const std::string& identifier) + { + SkipWhitespace(); + if (!Match("(")) + { + m_error = "missing ( after function '" + identifier + "'"; + return nullptr; + } + + std::vector numbers; + while (true) + { + int number = 0; + if (!ParseUnsignedNumber(number)) + { + m_error = "expected numeric argument in function '" + identifier + "'"; + return nullptr; + } + numbers.push_back(number); + + SkipWhitespace(); + if (!Match(",")) + { + break; + } + SkipWhitespace(); + } + + if (!Match(")")) + { + m_error = "missing ) in function '" + identifier + "'"; + return nullptr; + } + + auto node = std::make_unique(); + node->type = type; + node->number = numbers.empty() ? 0 : numbers.front(); + node->numbers = std::move(numbers); return node; } @@ -725,6 +862,106 @@ bool PUPTriggerEngine::LoadRules(const char* path, std::string& error) continue; } + if (option.rfind("set_state=", 0) == 0) + { + const std::string name = option.substr(10); + if (!IsNameToken(name)) + { + error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": set_state must be a name token."; + return false; + } + rule.setState = name; + continue; + } + + if (option.rfind("clear_state=", 0) == 0) + { + const std::string name = option.substr(12); + if (!IsNameToken(name)) + { + error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": clear_state must be a name token."; + return false; + } + rule.clearState = name; + continue; + } + + if (option.rfind("state_ms=", 0) == 0) + { + if (!ParseUInt32Token(option.substr(9), rule.stateMs)) + { + error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": state_ms must be uint32 milliseconds."; + return false; + } + continue; + } + + if (option.rfind("suppress_switch=", 0) == 0) + { + uint16_t number = 0; + if (!ParseUInt16Token(option.substr(16), number)) + { + error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": suppress_switch must be uint16."; + return false; + } + rule.suppressSwitch = static_cast(number); + continue; + } + + if (option.rfind("pulse_coil=", 0) == 0) + { + uint16_t number = 0; + if (!ParseUInt16Token(option.substr(11), number)) + { + error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": pulse_coil must be uint16."; + return false; + } + rule.pulseCoil = static_cast(number); + continue; + } + + if (option.rfind("pulse_ms=", 0) == 0) + { + if (!ParseUInt32Token(option.substr(9), rule.pulseMs)) + { + error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": pulse_ms must be uint32 milliseconds."; + return false; + } + continue; + } + + if (option.rfind("blink_lamp=", 0) == 0) + { + uint16_t number = 0; + if (!ParseUInt16Token(option.substr(11), number)) + { + error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": blink_lamp must be uint16."; + return false; + } + rule.blinkLamp = static_cast(number); + continue; + } + + if (option.rfind("blink_on_ms=", 0) == 0) + { + if (!ParseUInt32Token(option.substr(12), rule.blinkOnMs) || rule.blinkOnMs == 0) + { + error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": blink_on_ms must be a positive uint32."; + return false; + } + continue; + } + + if (option.rfind("blink_off_ms=", 0) == 0) + { + if (!ParseUInt32Token(option.substr(13), rule.blinkOffMs) || rule.blinkOffMs == 0) + { + error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": blink_off_ms must be a positive uint32."; + return false; + } + continue; + } + if (!hasValue) { if (!ParseUInt8Token(option, rule.value)) @@ -758,6 +995,8 @@ bool PUPTriggerEngine::LoadRules(const char* path, std::string& error) m_switchStates.clear(); m_lampStates.clear(); m_coilStates.clear(); + m_namedStates.clear(); + m_suppressedSwitchOpen.clear(); m_history.clear(); m_currentBall = 0; m_currentPlayer = 0; @@ -776,6 +1015,18 @@ void PUPTriggerEngine::SetTriggerCallback(TriggerCallback callback) m_triggerCallback = std::move(callback); } +void PUPTriggerEngine::SetActionCallback(ActionCallback callback) +{ + std::lock_guard lock(m_mutex); + m_actionCallback = std::move(callback); +} + +void PUPTriggerEngine::SetSwitchGroups(const std::unordered_map>& switchGroups) +{ + std::lock_guard lock(m_mutex); + m_switchGroups = switchGroups; +} + void PUPTriggerEngine::SetAttractMode(bool attractMode) { std::lock_guard lock(m_mutex); @@ -798,30 +1049,24 @@ void PUPTriggerEngine::Update() { const uint64_t nowMs = GetNowMs(); std::vector matched; + std::vector actions; TriggerCallback triggerCallback; + ActionCallback actionCallback; bool debug = false; { std::lock_guard lock(m_mutex); PruneHistoryLocked(nowMs); - CollectDueTriggers(nowMs, matched); + PruneNamedStatesLocked(nowMs); + const TriggerEvent timerEvent = {EventType::Timer, 0, 0, 0}; + CollectBlinkActions(nowMs, timerEvent, actions); + CollectDueTriggers(nowMs, matched, actions); RecordMatchedTriggers(matched, nowMs); triggerCallback = m_triggerCallback; + actionCallback = m_actionCallback; debug = m_debug; } - for (const auto& match : matched) - { - if (debug) - { - printf("PUP trigger matched (line=%zu player=%u ball=%u): source=%c id=%u value=%u\n", match.line, - match.player, match.ball, match.source, match.id, match.value); - } - - if (match.source != kSilentTriggerSource && triggerCallback) - { - triggerCallback(match.source, match.id, match.value); - } - } + DispatchMatchesAndActions(matched, actions, triggerCallback, actionCallback, debug); } uint8_t PUPTriggerEngine::GetState(const std::unordered_map& states, int number) const @@ -858,17 +1103,42 @@ bool PUPTriggerEngine::EvaluateExpression(const ExprNode* node, const TriggerEve case ExprNodeType::SequenceState: return SequenceOccurred(node->triggerIds, node->windowMs, GetNowMs()); case ExprNodeType::SwitchState: + if (!node->numbers.empty()) + { + for (int number : node->numbers) + { + if (GetState(m_switchStates, number) != 0) + { + return true; + } + } + return false; + } return GetState(m_switchStates, node->number) != 0; case ExprNodeType::LampState: return GetState(m_lampStates, node->number) != 0; case ExprNodeType::CoilState: return GetState(m_coilStates, node->number) != 0; + case ExprNodeType::NamedState: + return NamedStateActive(node->name, GetNowMs()); + case ExprNodeType::SwitchGroupState: + return SwitchGroupStateActive(node->name); case ExprNodeType::SwitchRising: - return event.type == EventType::Switch && event.number == node->number && event.oldValue == 0 && - event.newValue != 0; + return event.type == EventType::Switch && + (node->numbers.empty() || + std::find(node->numbers.begin(), node->numbers.end(), event.number) != node->numbers.end()) && + event.oldValue == 0 && event.newValue != 0; case ExprNodeType::SwitchFalling: - return event.type == EventType::Switch && event.number == node->number && event.oldValue != 0 && - event.newValue == 0; + return event.type == EventType::Switch && + (node->numbers.empty() || + std::find(node->numbers.begin(), node->numbers.end(), event.number) != node->numbers.end()) && + event.oldValue != 0 && event.newValue == 0; + case ExprNodeType::SwitchRisingGroup: + return event.type == EventType::Switch && event.oldValue == 0 && event.newValue != 0 && + SwitchGroupContainsEvent(node->name, event); + case ExprNodeType::SwitchFallingGroup: + return event.type == EventType::Switch && event.oldValue != 0 && event.newValue == 0 && + SwitchGroupContainsEvent(node->name, event); case ExprNodeType::LampRising: return event.type == EventType::Lamp && event.number == node->number && event.oldValue == 0 && event.newValue != 0; @@ -941,6 +1211,45 @@ bool PUPTriggerEngine::SequenceOccurred(const std::vector& triggerIds, return false; } +bool PUPTriggerEngine::NamedStateActive(const std::string& name, uint64_t nowMs) const +{ + const auto it = m_namedStates.find(name); + if (it == m_namedStates.end()) + { + return false; + } + return it->second == 0 || nowMs < it->second; +} + +bool PUPTriggerEngine::SwitchGroupStateActive(const std::string& name) const +{ + const auto it = m_switchGroups.find(name); + if (it == m_switchGroups.end()) + { + return false; + } + + for (const uint16_t number : it->second) + { + if (GetState(m_switchStates, static_cast(number)) != 0) + { + return true; + } + } + return false; +} + +bool PUPTriggerEngine::SwitchGroupContainsEvent(const std::string& name, const TriggerEvent& event) const +{ + const auto it = m_switchGroups.find(name); + if (it == m_switchGroups.end()) + { + return false; + } + + return std::find(it->second.begin(), it->second.end(), static_cast(event.number)) != it->second.end(); +} + bool PUPTriggerEngine::UsesEventEdges(const ExprNode* node) const { if (!node) @@ -952,6 +1261,8 @@ bool PUPTriggerEngine::UsesEventEdges(const ExprNode* node) const { case ExprNodeType::SwitchRising: case ExprNodeType::SwitchFalling: + case ExprNodeType::SwitchRisingGroup: + case ExprNodeType::SwitchFallingGroup: case ExprNodeType::LampRising: case ExprNodeType::LampFalling: case ExprNodeType::CoilRising: @@ -974,7 +1285,8 @@ bool PUPTriggerEngine::CanTriggerNow(const Rule& rule, uint64_t nowMs) const return rule.cooldownMs == 0 || nowMs >= (rule.lastTriggeredMs + static_cast(rule.cooldownMs)); } -void PUPTriggerEngine::CollectDueTriggers(uint64_t nowMs, std::vector& matched) +void PUPTriggerEngine::CollectDueTriggers(uint64_t nowMs, std::vector& matched, + std::vector& actions) { for (auto& rule : m_rules) { @@ -991,11 +1303,35 @@ void PUPTriggerEngine::CollectDueTriggers(uint64_t nowMs, std::vector& actions) +{ + for (auto& rule : m_rules) + { + if (rule.blinkLamp < 0) + { + continue; + } + + const bool active = EvaluateExpression(rule.expression.get(), event); + if (active && !rule.blinkActive) + { + rule.blinkActive = true; + actions.push_back({ActionType::StartBlinkLamp, rule.blinkLamp, 0, rule.blinkOnMs, rule.blinkOffMs}); + } + else if (!active && rule.blinkActive) + { + rule.blinkActive = false; + actions.push_back({ActionType::StopBlinkLamp, rule.blinkLamp, 0, 0, 0}); + } + } +} + void PUPTriggerEngine::RecordMatchedTriggers(const std::vector& matched, const uint64_t nowMs) { for (const auto& match : matched) @@ -1008,6 +1344,25 @@ void PUPTriggerEngine::RecordMatchedTriggers(const std::vector& } } +void PUPTriggerEngine::ApplyRuleSideEffects(Rule& rule, uint64_t nowMs, std::vector& actions) +{ + if (!rule.clearState.empty()) + { + m_namedStates.erase(rule.clearState); + } + + if (!rule.setState.empty()) + { + const uint64_t expiresAt = rule.stateMs == 0 ? 0 : nowMs + static_cast(rule.stateMs); + m_namedStates[rule.setState] = expiresAt; + } + + if (rule.pulseCoil >= 0) + { + actions.push_back({ActionType::PulseCoil, rule.pulseCoil, rule.pulseMs, 0, 0}); + } +} + void PUPTriggerEngine::PruneHistoryLocked(const uint64_t nowMs) { while (!m_history.empty() && nowMs > m_history.front().timestampMs && @@ -1017,6 +1372,22 @@ void PUPTriggerEngine::PruneHistoryLocked(const uint64_t nowMs) } } +void PUPTriggerEngine::PruneNamedStatesLocked(const uint64_t nowMs) +{ + auto it = m_namedStates.begin(); + while (it != m_namedStates.end()) + { + if (it->second != 0 && nowMs >= it->second) + { + it = m_namedStates.erase(it); + } + else + { + ++it; + } + } +} + void PUPTriggerEngine::ClearPlayerHistoryLocked(const uint8_t player) { if (player == 0) @@ -1038,15 +1409,49 @@ void PUPTriggerEngine::ClearPlayerHistoryLocked(const uint8_t player) } } -void PUPTriggerEngine::HandleStateChange(EventType type, int number, uint8_t state, std::unordered_map& states) +void PUPTriggerEngine::DispatchMatchesAndActions(const std::vector& matched, + const std::vector& actions, + TriggerCallback triggerCallback, + ActionCallback actionCallback, + bool debug) +{ + for (const auto& match : matched) + { + if (debug) + { + printf("PUP trigger matched (line=%zu player=%u ball=%u): source=%c id=%u value=%u\n", match.line, + match.player, match.ball, match.source, match.id, match.value); + } + + if (match.source != kSilentTriggerSource && triggerCallback) + { + triggerCallback(match.source, match.id, match.value); + } + } + + if (actionCallback) + { + for (const auto& action : actions) + { + actionCallback(action); + } + } +} + +PUPTriggerEngine::SwitchProcessResult PUPTriggerEngine::HandleStateChange(EventType type, int number, uint8_t state, + std::unordered_map& states) { const uint64_t nowMs = GetNowMs(); std::vector matched; + std::vector actions; TriggerCallback triggerCallback; + ActionCallback actionCallback; bool debug = false; + SwitchProcessResult result; { std::lock_guard lock(m_mutex); PruneHistoryLocked(nowMs); + PruneNamedStatesLocked(nowMs); const uint8_t oldState = GetState(states, number); states[number] = state; @@ -1101,45 +1506,51 @@ void PUPTriggerEngine::HandleStateChange(EventType type, int number, uint8_t sta } rule.lastTriggeredMs = nowMs; + ApplyRuleSideEffects(rule, nowMs, actions); const uint8_t matchedPlayer = rule.setPlayer != 0 ? rule.setPlayer : m_currentPlayer; if (rule.clearPlayerHistory != 0) { ClearPlayerHistoryLocked(rule.clearPlayerHistory == 255 ? matchedPlayer : rule.clearPlayerHistory); } + if (type == EventType::Switch && rule.suppressSwitch == number && state != 0) + { + result.forwardToCpu = false; + m_suppressedSwitchOpen.insert(number); + } matched.push_back({rule.source, rule.id, rule.value, rule.line, matchedPlayer, m_currentBall}); } - CollectDueTriggers(nowMs, matched); + if (type == EventType::Switch && state == 0 && m_suppressedSwitchOpen.find(number) != m_suppressedSwitchOpen.end()) + { + result.forwardToCpu = false; + m_suppressedSwitchOpen.erase(number); + } + + CollectBlinkActions(nowMs, event, actions); + CollectDueTriggers(nowMs, matched, actions); RecordMatchedTriggers(matched, nowMs); triggerCallback = m_triggerCallback; + actionCallback = m_actionCallback; debug = m_debug; } - for (const auto& match : matched) - { - if (debug) - { - printf("PUP trigger matched (line=%zu player=%u ball=%u): source=%c id=%u value=%u\n", match.line, - match.player, match.ball, match.source, match.id, match.value); - } - - if (match.source != kSilentTriggerSource && triggerCallback) - { - triggerCallback(match.source, match.id, match.value); - } - } + DispatchMatchesAndActions(matched, actions, triggerCallback, actionCallback, debug); + return result; } void PUPTriggerEngine::HandleBallChange(uint8_t currentBall) { const uint64_t nowMs = GetNowMs(); std::vector matched; + std::vector actions; TriggerCallback triggerCallback; + ActionCallback actionCallback; bool debug = false; { std::lock_guard lock(m_mutex); PruneHistoryLocked(nowMs); + PruneNamedStatesLocked(nowMs); const uint8_t oldBall = m_currentBall; if (oldBall == currentBall) @@ -1199,6 +1610,7 @@ void PUPTriggerEngine::HandleBallChange(uint8_t currentBall) } rule.lastTriggeredMs = nowMs; + ApplyRuleSideEffects(rule, nowMs, actions); const uint8_t matchedPlayer = rule.setPlayer != 0 ? rule.setPlayer : m_currentPlayer; if (rule.clearPlayerHistory != 0) { @@ -1207,37 +1619,30 @@ void PUPTriggerEngine::HandleBallChange(uint8_t currentBall) matched.push_back({rule.source, rule.id, rule.value, rule.line, matchedPlayer, m_currentBall}); } - CollectDueTriggers(nowMs, matched); + CollectBlinkActions(nowMs, event, actions); + CollectDueTriggers(nowMs, matched, actions); RecordMatchedTriggers(matched, nowMs); triggerCallback = m_triggerCallback; + actionCallback = m_actionCallback; debug = m_debug; } - for (const auto& match : matched) - { - if (debug) - { - printf("PUP trigger matched (line=%zu player=%u ball=%u): source=%c id=%u value=%u\n", match.line, - match.player, match.ball, match.source, match.id, match.value); - } - - if (match.source != kSilentTriggerSource && triggerCallback) - { - triggerCallback(match.source, match.id, match.value); - } - } + DispatchMatchesAndActions(matched, actions, triggerCallback, actionCallback, debug); } void PUPTriggerEngine::HandlePlayerChange(uint8_t currentPlayer) { const uint64_t nowMs = GetNowMs(); std::vector matched; + std::vector actions; TriggerCallback triggerCallback; + ActionCallback actionCallback; bool debug = false; { std::lock_guard lock(m_mutex); PruneHistoryLocked(nowMs); + PruneNamedStatesLocked(nowMs); const uint8_t oldPlayer = m_currentPlayer; if (oldPlayer == currentPlayer) @@ -1297,6 +1702,7 @@ void PUPTriggerEngine::HandlePlayerChange(uint8_t currentPlayer) } rule.lastTriggeredMs = nowMs; + ApplyRuleSideEffects(rule, nowMs, actions); const uint8_t matchedPlayer = rule.setPlayer != 0 ? rule.setPlayer : m_currentPlayer; if (rule.clearPlayerHistory != 0) { @@ -1305,41 +1711,36 @@ void PUPTriggerEngine::HandlePlayerChange(uint8_t currentPlayer) matched.push_back({rule.source, rule.id, rule.value, rule.line, matchedPlayer, m_currentBall}); } - CollectDueTriggers(nowMs, matched); + CollectBlinkActions(nowMs, event, actions); + CollectDueTriggers(nowMs, matched, actions); RecordMatchedTriggers(matched, nowMs); triggerCallback = m_triggerCallback; + actionCallback = m_actionCallback; debug = m_debug; } - for (const auto& match : matched) - { - if (debug) - { - printf("PUP trigger matched (line=%zu player=%u ball=%u): source=%c id=%u value=%u\n", match.line, - match.player, match.ball, match.source, match.id, match.value); - } - - if (match.source != kSilentTriggerSource && triggerCallback) - { - triggerCallback(match.source, match.id, match.value); - } - } + DispatchMatchesAndActions(matched, actions, triggerCallback, actionCallback, debug); } void PUPTriggerEngine::OnSwitchState(int number, uint8_t state) { - HandleStateChange(EventType::Switch, number, state == 0 ? 0 : 1, m_switchStates); + ProcessSwitchState(number, state); +} + +PUPTriggerEngine::SwitchProcessResult PUPTriggerEngine::ProcessSwitchState(int number, uint8_t state) +{ + return HandleStateChange(EventType::Switch, number, state == 0 ? 0 : 1, m_switchStates); } void PUPTriggerEngine::OnLampState(int number, uint8_t state) { - HandleStateChange(EventType::Lamp, number, state == 0 ? 0 : 1, m_lampStates); + (void)HandleStateChange(EventType::Lamp, number, state == 0 ? 0 : 1, m_lampStates); } void PUPTriggerEngine::OnCoilState(int number, uint8_t state) { - HandleStateChange(EventType::Coil, number, state == 0 ? 0 : 1, m_coilStates); + (void)HandleStateChange(EventType::Coil, number, state == 0 ? 0 : 1, m_coilStates); } void PUPTriggerEngine::SetCurrentBall(uint8_t currentBall) diff --git a/src/PUPTriggerEngine.h b/src/PUPTriggerEngine.h index b0cebc7..8a3db21 100644 --- a/src/PUPTriggerEngine.h +++ b/src/PUPTriggerEngine.h @@ -8,6 +8,7 @@ #include #include #include +#include #include class PUPTriggerEngine @@ -15,12 +16,38 @@ class PUPTriggerEngine public: using TriggerCallback = std::function; + enum class ActionType + { + PulseCoil, + StartBlinkLamp, + StopBlinkLamp + }; + + struct RuleAction + { + ActionType type; + int number = 0; + uint32_t durationMs = 0; + uint32_t onMs = 0; + uint32_t offMs = 0; + }; + + struct SwitchProcessResult + { + bool forwardToCpu = true; + }; + + using ActionCallback = std::function; + void SetDebug(bool debug); void SetTriggerCallback(TriggerCallback callback); + void SetActionCallback(ActionCallback callback); + void SetSwitchGroups(const std::unordered_map>& switchGroups); bool LoadRules(const char* path, std::string& error); size_t GetRuleCount() const; void Update(); + SwitchProcessResult ProcessSwitchState(int number, uint8_t state); void OnSwitchState(int number, uint8_t state); void OnLampState(int number, uint8_t state); void OnCoilState(int number, uint8_t state); @@ -35,7 +62,8 @@ class PUPTriggerEngine Lamp, Coil, Ball, - Player + Player, + Timer }; struct TriggerEvent @@ -60,8 +88,12 @@ class PUPTriggerEngine SwitchState, LampState, CoilState, + NamedState, + SwitchGroupState, SwitchRising, SwitchFalling, + SwitchRisingGroup, + SwitchFallingGroup, LampRising, LampFalling, CoilRising, @@ -74,6 +106,8 @@ class PUPTriggerEngine int number = 0; bool literal = false; uint32_t windowMs = 0; + std::string name; + std::vector numbers; std::vector triggerIds; std::unique_ptr left; std::unique_ptr right; @@ -94,6 +128,16 @@ class PUPTriggerEngine bool eventTriggered = false; uint8_t setPlayer = 0; uint8_t clearPlayerHistory = 0; + std::string setState; + std::string clearState; + uint32_t stateMs = 0; + int suppressSwitch = -1; + int pulseCoil = -1; + uint32_t pulseMs = 120; + int blinkLamp = -1; + uint32_t blinkOnMs = 250; + uint32_t blinkOffMs = 250; + bool blinkActive = false; std::unique_ptr expression; }; @@ -122,22 +166,37 @@ class PUPTriggerEngine bool UsesEventEdges(const ExprNode* node) const; bool HistoryContains(uint16_t triggerId, uint32_t windowMs, uint64_t nowMs) const; bool SequenceOccurred(const std::vector& triggerIds, uint32_t windowMs, uint64_t nowMs) const; + bool NamedStateActive(const std::string& name, uint64_t nowMs) const; + bool SwitchGroupStateActive(const std::string& name) const; + bool SwitchGroupContainsEvent(const std::string& name, const TriggerEvent& event) const; uint64_t GetNowMs() const; bool CanTriggerNow(const Rule& rule, uint64_t nowMs) const; - void CollectDueTriggers(uint64_t nowMs, std::vector& matched); + void CollectDueTriggers(uint64_t nowMs, std::vector& matched, std::vector& actions); + void CollectBlinkActions(uint64_t nowMs, const TriggerEvent& event, std::vector& actions); void RecordMatchedTriggers(const std::vector& matched, uint64_t nowMs); + void ApplyRuleSideEffects(Rule& rule, uint64_t nowMs, std::vector& actions); void PruneHistoryLocked(uint64_t nowMs); + void PruneNamedStatesLocked(uint64_t nowMs); void ClearPlayerHistoryLocked(uint8_t player); - void HandleStateChange(EventType type, int number, uint8_t state, std::unordered_map& states); + SwitchProcessResult HandleStateChange(EventType type, int number, uint8_t state, std::unordered_map& states); void HandleBallChange(uint8_t currentBall); void HandlePlayerChange(uint8_t currentPlayer); + void DispatchMatchesAndActions(const std::vector& matched, + const std::vector& actions, + TriggerCallback triggerCallback, + ActionCallback actionCallback, + bool debug); std::unordered_map m_switchStates; std::unordered_map m_lampStates; std::unordered_map m_coilStates; + std::unordered_map m_namedStates; + std::unordered_map> m_switchGroups; + std::unordered_set m_suppressedSwitchOpen; std::vector m_rules; std::deque m_history; TriggerCallback m_triggerCallback; + ActionCallback m_actionCallback; uint8_t m_currentBall = 0; uint8_t m_currentPlayer = 0; bool m_attractMode = true; diff --git a/src/ppuc.cpp b/src/ppuc.cpp index b4a123c..b652f8c 100644 --- a/src/ppuc.cpp +++ b/src/ppuc.cpp @@ -384,6 +384,131 @@ static void PrintFlushedLogLine(const char* prefix, const char* message) fflush(stdout); } +struct InterceptorOutputOverrides +{ + struct CoilPulse + { + std::chrono::steady_clock::time_point until{}; + }; + + struct LampBlink + { + uint32_t onMs = 250; + uint32_t offMs = 250; + bool outputOn = false; + std::chrono::steady_clock::time_point nextToggle{}; + }; + + std::unordered_map pinmameCoils; + std::unordered_map pinmameLamps; + std::unordered_map coilPulses; + std::unordered_map lampBlinks; + + void ApplyPinmameCoil(PPUC* controller, int number, uint8_t state) + { + pinmameCoils[number] = state == 0 ? 0 : 1; + const auto now = std::chrono::steady_clock::now(); + const auto pulseIt = coilPulses.find(number); + if (pulseIt != coilPulses.end() && now < pulseIt->second.until) + { + controller->SetSolenoidState(number, 1); + return; + } + controller->SetSolenoidState(number, state == 0 ? 0 : 1); + } + + void ApplyPinmameLamp(PPUC* controller, int number, uint8_t state) + { + pinmameLamps[number] = state == 0 ? 0 : 1; + if (lampBlinks.find(number) != lampBlinks.end()) + { + return; + } + controller->SetLampState(number, state == 0 ? 0 : 1); + } + + void PulseCoil(PPUC* controller, int number, uint32_t durationMs) + { + const auto now = std::chrono::steady_clock::now(); + auto& pulse = coilPulses[number]; + const auto until = now + std::chrono::milliseconds(durationMs == 0 ? 1 : durationMs); + if (until > pulse.until) + { + pulse.until = until; + } + controller->SetSolenoidState(number, 1); + } + + void StartBlinkLamp(PPUC* controller, int number, uint32_t onMs, uint32_t offMs) + { + auto& blink = lampBlinks[number]; + blink.onMs = onMs == 0 ? 250 : onMs; + blink.offMs = offMs == 0 ? 250 : offMs; + blink.outputOn = true; + blink.nextToggle = std::chrono::steady_clock::now() + std::chrono::milliseconds(blink.onMs); + controller->SetLampState(number, 1); + } + + void StopBlinkLamp(PPUC* controller, int number) + { + lampBlinks.erase(number); + const uint8_t restore = pinmameLamps.count(number) == 0 ? 0 : pinmameLamps[number]; + controller->SetLampState(number, restore); + } + + void Service(PPUC* controller) + { + const auto now = std::chrono::steady_clock::now(); + + auto coilIt = coilPulses.begin(); + while (coilIt != coilPulses.end()) + { + if (now >= coilIt->second.until) + { + const int number = coilIt->first; + coilIt = coilPulses.erase(coilIt); + const uint8_t restore = pinmameCoils.count(number) == 0 ? 0 : pinmameCoils[number]; + controller->SetSolenoidState(number, restore); + } + else + { + ++coilIt; + } + } + + for (auto& entry : lampBlinks) + { + LampBlink& blink = entry.second; + if (now < blink.nextToggle) + { + continue; + } + + blink.outputOn = !blink.outputOn; + blink.nextToggle = now + std::chrono::milliseconds(blink.outputOn ? blink.onMs : blink.offMs); + controller->SetLampState(entry.first, blink.outputOn ? 1 : 0); + } + } + + void HandleAction(PPUC* controller, const PUPTriggerEngine::RuleAction& action) + { + switch (action.type) + { + case PUPTriggerEngine::ActionType::PulseCoil: + PulseCoil(controller, action.number, action.durationMs); + break; + case PUPTriggerEngine::ActionType::StartBlinkLamp: + StartBlinkLamp(controller, action.number, action.onMs, action.offMs); + break; + case PUPTriggerEngine::ActionType::StopBlinkLamp: + StopBlinkLamp(controller, action.number); + break; + } + } +}; + +InterceptorOutputOverrides g_interceptorOutputs; + static uint64_t CurrentUnixMs() { return static_cast( @@ -2615,7 +2740,7 @@ void PINMAMECALLBACK OnSolenoidUpdated(PinmameSolenoidState* p_solenoidState, co printf("OnSolenoidUpdated: solenoid=%d, state=%d\n", p_solenoidState->solNo, coilState); } - ppuc->SetSolenoidState(p_solenoidState->solNo, coilState); + g_interceptorOutputs.ApplyPinmameCoil(ppuc, p_solenoidState->solNo, coilState); if (isGameOnCoil) { @@ -3617,6 +3742,15 @@ int main(int argc, char** argv) { pPUPTriggerEngine = std::make_unique(); pPUPTriggerEngine->SetDebug(opt_debug || opt_debug_effects); + pPUPTriggerEngine->SetSwitchGroups(ppuc->GetSwitchGroups()); + pPUPTriggerEngine->SetActionCallback( + [](const PUPTriggerEngine::RuleAction& action) + { + if (ppuc != nullptr) + { + g_interceptorOutputs.HandleAction(ppuc, action); + } + }); pPUPTriggerEngine->SetTriggerCallback( [](const char source, const uint16_t id, const uint8_t value) { @@ -3954,6 +4088,7 @@ int main(int argc, char** argv) { pPUPTriggerEngine->Update(); } + g_interceptorOutputs.Service(ppuc); continue; } @@ -3998,9 +4133,15 @@ int main(int argc, char** argv) NoteBallSearchSwitchUpdate(ppuc, ballSearchRunner, switchState->number, newSwitchState, opt_ball_search_delay_ms); + PUPTriggerEngine::SwitchProcessResult switchProcess; + if (pPUPTriggerEngine) + { + switchProcess = pPUPTriggerEngine->ProcessSwitchState(switchState->number, newSwitchState); + } + // Switches between 200 and 240 are custom switches within the io-boards which should not be sent to // pinmame. Switches above 240 will become negative values, for example 243 => -3. - if (switchState->number < 200 || switchState->number > 241) + if (switchProcess.forwardToCpu && (switchState->number < 200 || switchState->number > 241)) { const int switchNumber = (switchState->number < 241) ? switchState->number : 240 - switchState->number; PinmameSetSwitch(switchNumber, newSwitchState); @@ -4011,10 +4152,7 @@ int main(int argc, char** argv) printf("Switch updated: #%d, %d\n", switchState->number, newSwitchState); } - if (pPUPTriggerEngine) - { - pPUPTriggerEngine->OnSwitchState(switchState->number, newSwitchState); - } + delete switchState; } ServiceBallSearchRunner(ppuc, ballSearchRunner, @@ -4032,7 +4170,7 @@ int main(int argc, char** argv) printf("Lamp updated: #%d, %d\n", lampNo, lampState); } - ppuc->SetLampState(lampNo, lampState); + g_interceptorOutputs.ApplyPinmameLamp(ppuc, static_cast(lampNo), lampState); if (pPUPTriggerEngine) { @@ -4061,6 +4199,7 @@ int main(int argc, char** argv) { pPUPTriggerEngine->Update(); } + g_interceptorOutputs.Service(ppuc); { // Needs to be a separate scope for the lock_guard // Process any pending render requests From 8e7d5203dfda6592864784c3a19becce87e75874 Mon Sep 17 00:00:00 2001 From: Markus Kalkbrenner Date: Mon, 22 Jun 2026 23:13:57 +0200 Subject: [PATCH 2/7] started interceptor documentation --- INTERCEPTOR.md | 144 +++++++++++++++++++++++++++++++++++++++++++ README.md | 1 + RULES_AND_EFFECTS.md | 3 + 3 files changed, 148 insertions(+) create mode 100644 INTERCEPTOR.md diff --git a/INTERCEPTOR.md b/INTERCEPTOR.md new file mode 100644 index 0000000..3eac324 --- /dev/null +++ b/INTERCEPTOR.md @@ -0,0 +1,144 @@ +# Interceptor Rules + +The interceptor feature lets `ppuc-pinmame` react to physical machine events +before they are forwarded to PinMAME. It is implemented as an extension of the +existing `--pup-triggers` rule engine, so the same rules file can now emit DMD, +PUP, speech, and board effect triggers, and can also apply host-side game logic. + +Compared with `main`, the interceptor branch adds: + +- named transient rule state with `set_state`, `clear_state`, and `state(...)` +- switch groups from game YAML with `switch_group(...)` and group edge checks +- multi-switch edge expressions such as `switch_rising(10, 11, 12)` +- switch suppression before the event reaches PinMAME +- host-side coil pulses and lamp blinking that temporarily override PinMAME + output for the same output number + +## Rule File + +Interceptor rules live in the normal trigger file loaded with: + +```text +--pup-triggers +``` + +The general rule format remains: + +```text + [value] [options...] : +``` + +Use target `S` for interceptor-only rules. `S` rules are silent: they can set +state, suppress switches, pulse coils, and blink lamps, but they do not emit an +external trigger. + +## Added Options + +- `set_state=` sets a named state when the rule matches. Names may + contain letters, numbers, `_`, `-`, and `.`. +- `state_ms=` sets an optional lifetime for `set_state`. If + omitted or zero, the state remains set until a matching `clear_state` rule + runs. +- `clear_state=` clears a named state when the rule matches. If a rule has + both `clear_state` and `set_state`, the clear happens first. +- `suppress_switch=` prevents a matching physical switch close from + being sent to PinMAME. The later open edge for the same switch is also + suppressed, so PinMAME sees no switch edge at all. Rules still see the + physical switch state. +- `pulse_coil=` pulses a coil from the host when the rule matches. +- `pulse_ms=` sets an optional pulse duration for `pulse_coil`. + The default is 120 ms. +- `blink_lamp=` blinks a lamp while the rule expression is true. +- `blink_on_ms=` and `blink_off_ms=` set optional + blink timing. Both default to 250 ms. + +## Added Expressions + +```text +state() +switch_group() +switch_rising_group() +switch_falling_group() +switch_rising(, , ...) +switch_falling(, , ...) +``` + +`state()` is true while the named state is active. + +`switch_group()` is true when any switch in the group is closed. + +`switch_rising_group()` and `switch_falling_group()` match edge +events from any switch in the group. + +`switch_rising(...)` and `switch_falling(...)` still accept one switch number, +and now also accept a comma-separated list. + +## Switch Groups + +Switch groups are declared in the game YAML and are loaded by `libppuc`: + +```yaml +switchGroups: + playfield: + switches: [10, 11, 12, 13] +``` + +The group name `buttons` is reserved. It is built automatically from switches +marked with `button: true` in `switches` or `switchMatrix.switches`. + +```yaml +switches: + - + description: 'Start Button' + number: 13 + board: 1 + port: 1 + debounce: 50 + button: true +``` + +Group switch numbers are sorted and de-duplicated when the YAML is loaded. + +## Runtime Output Overrides + +PinMAME remains the normal owner of lamps and coils. Interceptor output actions +temporarily override a single output number: + +- `pulse_coil` forces the coil on until the pulse timer expires, then restores + the latest PinMAME coil state for that coil. +- `blink_lamp` forces a lamp blink while the expression is true, then restores + the latest PinMAME lamp state for that lamp. + +If PinMAME changes the same output while the override is active, the new +PinMAME state is remembered and restored when the override ends. + +## Ball Save Example + +This example arms ball save on switch 15, starts a 5 second save window when +any playfield switch closes, blinks lamp 8 while ball save is ready or active, +and suppresses the outhole switch while pulsing coil 7. + +```text +S ball-save-ready set_state=ball_save_ready : switch_rising(15) +S ball-save-active set_state=ball_save state_ms=5000 clear_state=ball_save_ready : state(ball_save_ready) && switch_rising_group(playfield) +S shoot-again-blink blink_lamp=8 : state(ball_save_ready) || state(ball_save) +S outhole-save suppress_switch=9 pulse_coil=7 pulse_ms=120 : state(ball_save) && switch_rising(9) +``` + +Required YAML group: + +```yaml +switchGroups: + playfield: + switches: [10, 11, 12, 13] +``` + +## Implementation Notes + +The branch wires switch processing through `PUPTriggerEngine::ProcessSwitchState` +so the engine can return whether a switch should continue to PinMAME. Lamp and +coil updates from PinMAME go through a small output override layer that preserves +the last PinMAME state while host-side pulses and blinks are active. + +The feature is intentionally host-side. It does not change the RS485 wire +protocol or move rule logic into the IO-board firmware. diff --git a/README.md b/README.md index 989cdc9..09f4d76 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,7 @@ S outhole-save suppress_switch=9 pulse_coil=7 pulse_ms=120 : state(ball_save) && ``` A ready-to-use sample file is available at `examples/pup-triggers.rules`. +Interceptor-specific behavior is documented in `INTERCEPTOR.md`. Speech trigger source: * `O` diff --git a/RULES_AND_EFFECTS.md b/RULES_AND_EFFECTS.md index c760f9c..05bc40d 100644 --- a/RULES_AND_EFFECTS.md +++ b/RULES_AND_EFFECTS.md @@ -209,6 +209,9 @@ S shoot-again-blink blink_lamp=8 : state(ball_save_ready) || state(ball_save) S outhole-save suppress_switch=9 pulse_coil=7 pulse_ms=120 : state(ball_save) && switch_rising(9) ``` +See `INTERCEPTOR.md` for the complete interceptor feature reference and runtime +behavior notes. + So the intended reading is: - rule header: target/output channel plus trigger ID From e00b339d5553e00a0ef9dfe91e773af31e3a8e9c Mon Sep 17 00:00:00 2001 From: Markus Kalkbrenner Date: Sat, 27 Jun 2026 19:44:50 +0200 Subject: [PATCH 3/7] development-friendly build system --- README.md | 9 +++++ platforms/config.sh | 58 +++++++++++++++++++++++++++++ platforms/linux/aarch64/external.sh | 14 +++---- platforms/linux/x64/external.sh | 14 +++---- platforms/macos/arm64/external.sh | 14 +++---- platforms/macos/x64/external.sh | 14 +++---- platforms/win-mingw/x64/external.sh | 14 +++---- platforms/win/x64/external.sh | 14 +++---- platforms/win/x86/external.sh | 14 +++---- 9 files changed, 109 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 09f4d76..d075ec6 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,15 @@ Ready-to-use samples are available at: ### Compiling +The platform build scripts stage pinned third-party dependencies into +`third-party`. For local development, they automatically prefer sibling +checkouts named `../libppuc` and `../libsdldmd` when those directories exist, +then fall back to the pinned GitHub archives from `platforms/config.sh`. + +Set `PPUC_USE_LOCAL_DEPS=0` to force the pinned archive path, or set +`PPUC_LOCAL_DEPS_ROOT=/path/to/workspace` to look for local dependency +checkouts somewhere other than the parent directory. + #### Windows (x64) ```shell diff --git a/platforms/config.sh b/platforms/config.sh index 9d7aa4e..506bef9 100644 --- a/platforms/config.sh +++ b/platforms/config.sh @@ -11,6 +11,64 @@ PINMAME_NVRAM_MAPS_SHA=fa1086d57118e12f4802f3a9683c1e6acfb6ec6d LIBPPUC_SHA=715dd76055e2552b31dcff35508721bf0445a396 LIBSDLDMD_SHA=87c804e1ae53b86846dfb2e8a8fafb908e417e82 +PPUC_SOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PPUC_LOCAL_DEPS_ROOT="${PPUC_LOCAL_DEPS_ROOT:-$(cd "${PPUC_SOURCE_ROOT}/.." && pwd)}" +PPUC_USE_LOCAL_DEPS="${PPUC_USE_LOCAL_DEPS:-1}" +PPUC_LOCAL_DEPS_CACHE_BUSTER="${PPUC_LOCAL_DEPS_CACHE_BUSTER:-$(date +%s)}" + +ppuc_local_dependency_dir() { + local name="$1" + local dir="${PPUC_LOCAL_DEPS_ROOT}/${name}" + + if [ "${PPUC_USE_LOCAL_DEPS}" != "0" ] && [ -d "${dir}" ] && [ "${dir}" != "${PPUC_SOURCE_ROOT}" ]; then + echo "${dir}" + fi +} + +ppuc_dependency_cache_key() { + local name="$1" + local sha="$2" + local local_dir + + local_dir="$(ppuc_local_dependency_dir "${name}")" + if [ -n "${local_dir}" ]; then + echo "local:${local_dir}:${PPUC_LOCAL_DEPS_CACHE_BUSTER}" + else + echo "${sha}" + fi +} + +ppuc_print_dependency_source() { + local label="$1" + local name="$2" + local sha="$3" + local local_dir + + local_dir="$(ppuc_local_dependency_dir "${name}")" + if [ -n "${local_dir}" ]; then + echo " ${label}_SOURCE: local ${local_dir}" + else + echo " ${label}_SOURCE: archive ${sha}" + fi +} + +ppuc_prepare_dependency_source() { + local name="$1" + local sha="$2" + local url="$3" + local local_dir + + local_dir="$(ppuc_local_dependency_dir "${name}")" + if [ -n "${local_dir}" ]; then + echo "Using local ${name}: ${local_dir}" + ln -s "${local_dir}" "${name}" + else + curl -sL "${url}" -o "${name}-${sha}.tar.gz" + tar xzf "${name}-${sha}.tar.gz" + mv "${name}-${sha}" "${name}" + fi +} + if [ -z "${BUILD_TYPE}" ]; then BUILD_TYPE="Release" fi diff --git a/platforms/linux/aarch64/external.sh b/platforms/linux/aarch64/external.sh index e07d900..3bcf33f 100755 --- a/platforms/linux/aarch64/external.sh +++ b/platforms/linux/aarch64/external.sh @@ -14,7 +14,9 @@ echo " ESPEAK_NG_SHA: ${ESPEAK_NG_SHA}" echo " PINMAME_SHA: ${PINMAME_SHA}" echo " PINMAME_NVRAM_MAPS_SHA: ${PINMAME_NVRAM_MAPS_SHA}" echo " LIBPPUC_SHA: ${LIBPPUC_SHA}" +ppuc_print_dependency_source LIBPPUC libppuc "${LIBPPUC_SHA}" echo " LIBSDLDMD_SHA: ${LIBSDLDMD_SHA}" +ppuc_print_dependency_source LIBSDLDMD libsdldmd "${LIBSDLDMD_SHA}" echo "" if [ -z "${CACHE_DIR}" ]; then @@ -33,7 +35,7 @@ cd external # build libsdldmd, SDL3_image, SDL3_mixer # -LIBSDLDMD_EXPECTED_SHA="${LIBSDLDMD_SHA}" +LIBSDLDMD_EXPECTED_SHA="$(ppuc_dependency_cache_key libsdldmd "${LIBSDLDMD_SHA}")" LIBSDLDMD_FOUND_SHA="$([ -f libsdldmd/cache.txt ] && cat libsdldmd/cache.txt || echo "")" if [ -n "${LIBSDLDMD_SHA}" ] && [ "${LIBSDLDMD_EXPECTED_SHA}" != "${LIBSDLDMD_FOUND_SHA}" ]; then @@ -43,9 +45,7 @@ if [ -n "${LIBSDLDMD_SHA}" ] && [ "${LIBSDLDMD_EXPECTED_SHA}" != "${LIBSDLDMD_FO mkdir libsdldmd cd libsdldmd - curl -sL https://github.com/PPUC/libsdldmd/archive/${LIBSDLDMD_SHA}.tar.gz -o libsdldmd-${LIBSDLDMD_SHA}.tar.gz - tar xzf libsdldmd-${LIBSDLDMD_SHA}.tar.gz - mv libsdldmd-${LIBSDLDMD_SHA} libsdldmd + ppuc_prepare_dependency_source libsdldmd "${LIBSDLDMD_SHA}" "https://github.com/PPUC/libsdldmd/archive/${LIBSDLDMD_SHA}.tar.gz" cd libsdldmd BUILD_TYPE=${BUILD_TYPE} platforms/linux/aarch64/external.sh @@ -279,7 +279,7 @@ fi # libppuc # -LIBPPUC_EXPECTED_SHA="${LIBPPUC_SHA}" +LIBPPUC_EXPECTED_SHA="$(ppuc_dependency_cache_key libppuc "${LIBPPUC_SHA}")" LIBPPUC_FOUND_SHA="$([ -f libppuc/cache.txt ] && cat libppuc/cache.txt || echo "")" if [ "${LIBPPUC_EXPECTED_SHA}" != "${LIBPPUC_FOUND_SHA}" ]; then @@ -289,9 +289,7 @@ if [ "${LIBPPUC_EXPECTED_SHA}" != "${LIBPPUC_FOUND_SHA}" ]; then mkdir libppuc cd libppuc - curl -sL https://github.com/PPUC/libppuc/archive/${LIBPPUC_SHA}.tar.gz -o libppuc-${LIBPPUC_SHA}.tar.gz - tar xzf libppuc-${LIBPPUC_SHA}.tar.gz - mv libppuc-${LIBPPUC_SHA} libppuc + ppuc_prepare_dependency_source libppuc "${LIBPPUC_SHA}" "https://github.com/PPUC/libppuc/archive/${LIBPPUC_SHA}.tar.gz" cd libppuc BUILD_TYPE=${BUILD_TYPE} platforms/linux/aarch64/external.sh diff --git a/platforms/linux/x64/external.sh b/platforms/linux/x64/external.sh index 1a5e5f0..dde9273 100755 --- a/platforms/linux/x64/external.sh +++ b/platforms/linux/x64/external.sh @@ -14,7 +14,9 @@ echo " FLITE_SHA: ${FLITE_SHA}" echo " PINMAME_SHA: ${PINMAME_SHA}" echo " PINMAME_NVRAM_MAPS_SHA: ${PINMAME_NVRAM_MAPS_SHA}" echo " LIBPPUC_SHA: ${LIBPPUC_SHA}" +ppuc_print_dependency_source LIBPPUC libppuc "${LIBPPUC_SHA}" echo " LIBSDLDMD_SHA: ${LIBSDLDMD_SHA}" +ppuc_print_dependency_source LIBSDLDMD libsdldmd "${LIBSDLDMD_SHA}" echo "" if [ -z "${CACHE_DIR}" ]; then @@ -33,7 +35,7 @@ cd external # build libsdldmd, SDL3_image, SDL3_mixer # -LIBSDLDMD_EXPECTED_SHA="${LIBSDLDMD_SHA}" +LIBSDLDMD_EXPECTED_SHA="$(ppuc_dependency_cache_key libsdldmd "${LIBSDLDMD_SHA}")" LIBSDLDMD_FOUND_SHA="$([ -f libsdldmd/cache.txt ] && cat libsdldmd/cache.txt || echo "")" if [ -n "${LIBSDLDMD_SHA}" ] && [ "${LIBSDLDMD_EXPECTED_SHA}" != "${LIBSDLDMD_FOUND_SHA}" ]; then @@ -43,9 +45,7 @@ if [ -n "${LIBSDLDMD_SHA}" ] && [ "${LIBSDLDMD_EXPECTED_SHA}" != "${LIBSDLDMD_FO mkdir libsdldmd cd libsdldmd - curl -sL https://github.com/PPUC/libsdldmd/archive/${LIBSDLDMD_SHA}.tar.gz -o libsdldmd-${LIBSDLDMD_SHA}.tar.gz - tar xzf libsdldmd-${LIBSDLDMD_SHA}.tar.gz - mv libsdldmd-${LIBSDLDMD_SHA} libsdldmd + ppuc_prepare_dependency_source libsdldmd "${LIBSDLDMD_SHA}" "https://github.com/PPUC/libsdldmd/archive/${LIBSDLDMD_SHA}.tar.gz" cd libsdldmd BUILD_TYPE=${BUILD_TYPE} platforms/linux/x64/external.sh @@ -277,7 +277,7 @@ fi # libppuc # -LIBPPUC_EXPECTED_SHA="${LIBPPUC_SHA}" +LIBPPUC_EXPECTED_SHA="$(ppuc_dependency_cache_key libppuc "${LIBPPUC_SHA}")" LIBPPUC_FOUND_SHA="$([ -f libppuc/cache.txt ] && cat libppuc/cache.txt || echo "")" if [ "${LIBPPUC_EXPECTED_SHA}" != "${LIBPPUC_FOUND_SHA}" ]; then @@ -287,9 +287,7 @@ if [ "${LIBPPUC_EXPECTED_SHA}" != "${LIBPPUC_FOUND_SHA}" ]; then mkdir libppuc cd libppuc - curl -sL https://github.com/PPUC/libppuc/archive/${LIBPPUC_SHA}.tar.gz -o libppuc-${LIBPPUC_SHA}.tar.gz - tar xzf libppuc-${LIBPPUC_SHA}.tar.gz - mv libppuc-${LIBPPUC_SHA} libppuc + ppuc_prepare_dependency_source libppuc "${LIBPPUC_SHA}" "https://github.com/PPUC/libppuc/archive/${LIBPPUC_SHA}.tar.gz" cd libppuc BUILD_TYPE=${BUILD_TYPE} platforms/linux/x64/external.sh diff --git a/platforms/macos/arm64/external.sh b/platforms/macos/arm64/external.sh index 7ac3df0..dfd7319 100755 --- a/platforms/macos/arm64/external.sh +++ b/platforms/macos/arm64/external.sh @@ -14,7 +14,9 @@ echo " ESPEAK_NG_SHA: ${ESPEAK_NG_SHA}" echo " PINMAME_SHA: ${PINMAME_SHA}" echo " PINMAME_NVRAM_MAPS_SHA: ${PINMAME_NVRAM_MAPS_SHA}" echo " LIBPPUC_SHA: ${LIBPPUC_SHA}" +ppuc_print_dependency_source LIBPPUC libppuc "${LIBPPUC_SHA}" echo " LIBSDLDMD_SHA: ${LIBSDLDMD_SHA}" +ppuc_print_dependency_source LIBSDLDMD libsdldmd "${LIBSDLDMD_SHA}" echo "" if [ -z "${CACHE_DIR}" ]; then @@ -69,7 +71,7 @@ if [ "${FLITE_EXPECTED_SHA}" != "${FLITE_FOUND_SHA}" ] || [ "${FLITE_ARTIFACTS_O cd .. fi -LIBSDLDMD_EXPECTED_SHA="${LIBSDLDMD_SHA}" +LIBSDLDMD_EXPECTED_SHA="$(ppuc_dependency_cache_key libsdldmd "${LIBSDLDMD_SHA}")" LIBSDLDMD_FOUND_SHA="$([ -f libsdldmd/cache.txt ] && cat libsdldmd/cache.txt || echo "")" if [ -n "${LIBSDLDMD_SHA}" ] && [ "${LIBSDLDMD_EXPECTED_SHA}" != "${LIBSDLDMD_FOUND_SHA}" ]; then @@ -79,9 +81,7 @@ if [ -n "${LIBSDLDMD_SHA}" ] && [ "${LIBSDLDMD_EXPECTED_SHA}" != "${LIBSDLDMD_FO mkdir libsdldmd cd libsdldmd - curl -sL https://github.com/PPUC/libsdldmd/archive/${LIBSDLDMD_SHA}.tar.gz -o libsdldmd-${LIBSDLDMD_SHA}.tar.gz - tar xzf libsdldmd-${LIBSDLDMD_SHA}.tar.gz - mv libsdldmd-${LIBSDLDMD_SHA} libsdldmd + ppuc_prepare_dependency_source libsdldmd "${LIBSDLDMD_SHA}" "https://github.com/PPUC/libsdldmd/archive/${LIBSDLDMD_SHA}.tar.gz" cd libsdldmd BUILD_TYPE=${BUILD_TYPE} platforms/macos/arm64/external.sh @@ -280,7 +280,7 @@ fi # libppuc # -LIBPPUC_EXPECTED_SHA="${LIBPPUC_SHA}" +LIBPPUC_EXPECTED_SHA="$(ppuc_dependency_cache_key libppuc "${LIBPPUC_SHA}")" LIBPPUC_FOUND_SHA="$([ -f libppuc/cache.txt ] && cat libppuc/cache.txt || echo "")" if [ "${LIBPPUC_EXPECTED_SHA}" != "${LIBPPUC_FOUND_SHA}" ]; then @@ -290,9 +290,7 @@ if [ "${LIBPPUC_EXPECTED_SHA}" != "${LIBPPUC_FOUND_SHA}" ]; then mkdir libppuc cd libppuc - curl -sL https://github.com/PPUC/libppuc/archive/${LIBPPUC_SHA}.tar.gz -o libppuc-${LIBPPUC_SHA}.tar.gz - tar xzf libppuc-${LIBPPUC_SHA}.tar.gz - mv libppuc-${LIBPPUC_SHA} libppuc + ppuc_prepare_dependency_source libppuc "${LIBPPUC_SHA}" "https://github.com/PPUC/libppuc/archive/${LIBPPUC_SHA}.tar.gz" cd libppuc BUILD_TYPE=${BUILD_TYPE} platforms/macos/arm64/external.sh diff --git a/platforms/macos/x64/external.sh b/platforms/macos/x64/external.sh index 7dab260..dc69060 100755 --- a/platforms/macos/x64/external.sh +++ b/platforms/macos/x64/external.sh @@ -13,7 +13,9 @@ echo " ESPEAK_NG_SHA: ${ESPEAK_NG_SHA}" echo " PINMAME_SHA: ${PINMAME_SHA}" echo " PINMAME_NVRAM_MAPS_SHA: ${PINMAME_NVRAM_MAPS_SHA}" echo " LIBPPUC_SHA: ${LIBPPUC_SHA}" +ppuc_print_dependency_source LIBPPUC libppuc "${LIBPPUC_SHA}" echo " LIBSDLDMD_SHA: ${LIBSDLDMD_SHA}" +ppuc_print_dependency_source LIBSDLDMD libsdldmd "${LIBSDLDMD_SHA}" echo "" if [ -z "${CACHE_DIR}" ]; then @@ -32,7 +34,7 @@ cd external # build libsdldmd, SDL3_image, SDL3_mixer # -LIBSDLDMD_EXPECTED_SHA="${LIBSDLDMD_SHA}" +LIBSDLDMD_EXPECTED_SHA="$(ppuc_dependency_cache_key libsdldmd "${LIBSDLDMD_SHA}")" LIBSDLDMD_FOUND_SHA="$([ -f libsdldmd/cache.txt ] && cat libsdldmd/cache.txt || echo "")" if [ -n "${LIBSDLDMD_SHA}" ] && [ "${LIBSDLDMD_EXPECTED_SHA}" != "${LIBSDLDMD_FOUND_SHA}" ]; then @@ -42,9 +44,7 @@ if [ -n "${LIBSDLDMD_SHA}" ] && [ "${LIBSDLDMD_EXPECTED_SHA}" != "${LIBSDLDMD_FO mkdir libsdldmd cd libsdldmd - curl -sL https://github.com/PPUC/libsdldmd/archive/${LIBSDLDMD_SHA}.tar.gz -o libsdldmd-${LIBSDLDMD_SHA}.tar.gz - tar xzf libsdldmd-${LIBSDLDMD_SHA}.tar.gz - mv libsdldmd-${LIBSDLDMD_SHA} libsdldmd + ppuc_prepare_dependency_source libsdldmd "${LIBSDLDMD_SHA}" "https://github.com/PPUC/libsdldmd/archive/${LIBSDLDMD_SHA}.tar.gz" cd libsdldmd BUILD_TYPE=${BUILD_TYPE} platforms/macos/x64/external.sh @@ -239,7 +239,7 @@ fi # libppuc # -LIBPPUC_EXPECTED_SHA="${LIBPPUC_SHA}" +LIBPPUC_EXPECTED_SHA="$(ppuc_dependency_cache_key libppuc "${LIBPPUC_SHA}")" LIBPPUC_FOUND_SHA="$([ -f libppuc/cache.txt ] && cat libppuc/cache.txt || echo "")" if [ "${LIBPPUC_EXPECTED_SHA}" != "${LIBPPUC_FOUND_SHA}" ]; then @@ -249,9 +249,7 @@ if [ "${LIBPPUC_EXPECTED_SHA}" != "${LIBPPUC_FOUND_SHA}" ]; then mkdir libppuc cd libppuc - curl -sL https://github.com/PPUC/libppuc/archive/${LIBPPUC_SHA}.tar.gz -o libppuc-${LIBPPUC_SHA}.tar.gz - tar xzf libppuc-${LIBPPUC_SHA}.tar.gz - mv libppuc-${LIBPPUC_SHA} libppuc + ppuc_prepare_dependency_source libppuc "${LIBPPUC_SHA}" "https://github.com/PPUC/libppuc/archive/${LIBPPUC_SHA}.tar.gz" cd libppuc BUILD_TYPE=${BUILD_TYPE} platforms/macos/x64/external.sh diff --git a/platforms/win-mingw/x64/external.sh b/platforms/win-mingw/x64/external.sh index c4153a6..0f22e5f 100755 --- a/platforms/win-mingw/x64/external.sh +++ b/platforms/win-mingw/x64/external.sh @@ -12,7 +12,9 @@ echo " SDL_MIXER_SHA: ${SDL_MIXER_SHA}" echo " PINMAME_SHA: ${PINMAME_SHA}" echo " PINMAME_NVRAM_MAPS_SHA: ${PINMAME_NVRAM_MAPS_SHA}" echo " LIBPPUC_SHA: ${LIBPPUC_SHA}" +ppuc_print_dependency_source LIBPPUC libppuc "${LIBPPUC_SHA}" echo " LIBSDLDMD_SHA: ${LIBSDLDMD_SHA}" +ppuc_print_dependency_source LIBSDLDMD libsdldmd "${LIBSDLDMD_SHA}" echo "" if [ -z "${CACHE_DIR}" ]; then @@ -32,7 +34,7 @@ mkdir -p \ third-party/runtime-libs/win-mingw-x64 cd external -LIBSDLDMD_EXPECTED_SHA="${LIBSDLDMD_SHA}" +LIBSDLDMD_EXPECTED_SHA="$(ppuc_dependency_cache_key libsdldmd "${LIBSDLDMD_SHA}")" LIBSDLDMD_FOUND_SHA="$([ -f libsdldmd/cache.txt ] && cat libsdldmd/cache.txt || echo "")" if [ -n "${LIBSDLDMD_SHA}" ] && [ "${LIBSDLDMD_EXPECTED_SHA}" != "${LIBSDLDMD_FOUND_SHA}" ]; then @@ -42,9 +44,7 @@ if [ -n "${LIBSDLDMD_SHA}" ] && [ "${LIBSDLDMD_EXPECTED_SHA}" != "${LIBSDLDMD_FO mkdir libsdldmd cd libsdldmd - curl -sL https://github.com/PPUC/libsdldmd/archive/${LIBSDLDMD_SHA}.tar.gz -o libsdldmd-${LIBSDLDMD_SHA}.tar.gz - tar xzf libsdldmd-${LIBSDLDMD_SHA}.tar.gz - mv libsdldmd-${LIBSDLDMD_SHA} libsdldmd + ppuc_prepare_dependency_source libsdldmd "${LIBSDLDMD_SHA}" "https://github.com/PPUC/libsdldmd/archive/${LIBSDLDMD_SHA}.tar.gz" cd libsdldmd BUILD_TYPE=${BUILD_TYPE} platforms/win-mingw/x64/external.sh @@ -187,7 +187,7 @@ if [ "${PINMAME_NVRAM_MAPS_EXPECTED_SHA}" != "${PINMAME_NVRAM_MAPS_FOUND_SHA}" ] cd .. fi -LIBPPUC_EXPECTED_SHA="${LIBPPUC_SHA}" +LIBPPUC_EXPECTED_SHA="$(ppuc_dependency_cache_key libppuc "${LIBPPUC_SHA}")" LIBPPUC_FOUND_SHA="$([ -f libppuc/cache.txt ] && cat libppuc/cache.txt || echo "")" if [ "${LIBPPUC_EXPECTED_SHA}" != "${LIBPPUC_FOUND_SHA}" ]; then @@ -197,9 +197,7 @@ if [ "${LIBPPUC_EXPECTED_SHA}" != "${LIBPPUC_FOUND_SHA}" ]; then mkdir libppuc cd libppuc - curl -sL https://github.com/PPUC/libppuc/archive/${LIBPPUC_SHA}.tar.gz -o libppuc-${LIBPPUC_SHA}.tar.gz - tar xzf libppuc-${LIBPPUC_SHA}.tar.gz - mv libppuc-${LIBPPUC_SHA} libppuc + ppuc_prepare_dependency_source libppuc "${LIBPPUC_SHA}" "https://github.com/PPUC/libppuc/archive/${LIBPPUC_SHA}.tar.gz" cd libppuc BUILD_TYPE=${BUILD_TYPE} platforms/win-mingw/x64/external.sh diff --git a/platforms/win/x64/external.sh b/platforms/win/x64/external.sh index 41cae2e..54df9e1 100755 --- a/platforms/win/x64/external.sh +++ b/platforms/win/x64/external.sh @@ -10,7 +10,9 @@ echo " SDL_MIXER_SHA: ${SDL_MIXER_SHA}" echo " PINMAME_SHA: ${PINMAME_SHA}" echo " PINMAME_NVRAM_MAPS_SHA: ${PINMAME_NVRAM_MAPS_SHA}" echo " LIBPPUC_SHA: ${LIBPPUC_SHA}" +ppuc_print_dependency_source LIBPPUC libppuc "${LIBPPUC_SHA}" echo " LIBSDLDMD_SHA: ${LIBSDLDMD_SHA}" +ppuc_print_dependency_source LIBSDLDMD libsdldmd "${LIBSDLDMD_SHA}" echo "" if [ -z "${CACHE_DIR}" ]; then @@ -28,7 +30,7 @@ cd external # build libsdldmd, SDL3_image, SDL3_mixer # -LIBSDLDMD_EXPECTED_SHA="${LIBSDLDMD_SHA}" +LIBSDLDMD_EXPECTED_SHA="$(ppuc_dependency_cache_key libsdldmd "${LIBSDLDMD_SHA}")" LIBSDLDMD_FOUND_SHA="$([ -f libsdldmd/cache.txt ] && cat libsdldmd/cache.txt || echo "")" if [ -n "${LIBSDLDMD_SHA}" ] && [ "${LIBSDLDMD_EXPECTED_SHA}" != "${LIBSDLDMD_FOUND_SHA}" ]; then @@ -38,9 +40,7 @@ if [ -n "${LIBSDLDMD_SHA}" ] && [ "${LIBSDLDMD_EXPECTED_SHA}" != "${LIBSDLDMD_FO mkdir libsdldmd cd libsdldmd - curl -sL https://github.com/PPUC/libsdldmd/archive/${LIBSDLDMD_SHA}.tar.gz -o libsdldmd-${LIBSDLDMD_SHA}.tar.gz - tar xzf libsdldmd-${LIBSDLDMD_SHA}.tar.gz - mv libsdldmd-${LIBSDLDMD_SHA} libsdldmd + ppuc_prepare_dependency_source libsdldmd "${LIBSDLDMD_SHA}" "https://github.com/PPUC/libsdldmd/archive/${LIBSDLDMD_SHA}.tar.gz" cd libsdldmd BUILD_TYPE=${BUILD_TYPE} platforms/win/x64/external.sh @@ -190,7 +190,7 @@ fi # libppuc # -LIBPPUC_EXPECTED_SHA="${LIBPPUC_SHA}" +LIBPPUC_EXPECTED_SHA="$(ppuc_dependency_cache_key libppuc "${LIBPPUC_SHA}")" LIBPPUC_FOUND_SHA="$([ -f libppuc/cache.txt ] && cat libppuc/cache.txt || echo "")" if [ "${LIBPPUC_EXPECTED_SHA}" != "${LIBPPUC_FOUND_SHA}" ]; then @@ -200,9 +200,7 @@ if [ "${LIBPPUC_EXPECTED_SHA}" != "${LIBPPUC_FOUND_SHA}" ]; then mkdir libppuc cd libppuc - curl -sL https://github.com/PPUC/libppuc/archive/${LIBPPUC_SHA}.tar.gz -o libppuc-${LIBPPUC_SHA}.tar.gz - tar xzf libppuc-${LIBPPUC_SHA}.tar.gz - mv libppuc-${LIBPPUC_SHA} libppuc + ppuc_prepare_dependency_source libppuc "${LIBPPUC_SHA}" "https://github.com/PPUC/libppuc/archive/${LIBPPUC_SHA}.tar.gz" cd libppuc platforms/win/x64/external.sh diff --git a/platforms/win/x86/external.sh b/platforms/win/x86/external.sh index 58af0e6..aed514e 100755 --- a/platforms/win/x86/external.sh +++ b/platforms/win/x86/external.sh @@ -10,7 +10,9 @@ echo " SDL_MIXER_SHA: ${SDL_MIXER_SHA}" echo " PINMAME_SHA: ${PINMAME_SHA}" echo " PINMAME_NVRAM_MAPS_SHA: ${PINMAME_NVRAM_MAPS_SHA}" echo " LIBPPUC_SHA: ${LIBPPUC_SHA}" +ppuc_print_dependency_source LIBPPUC libppuc "${LIBPPUC_SHA}" echo " LIBSDLDMD_SHA: ${LIBSDLDMD_SHA}" +ppuc_print_dependency_source LIBSDLDMD libsdldmd "${LIBSDLDMD_SHA}" echo "" if [ -z "${CACHE_DIR}" ]; then @@ -28,7 +30,7 @@ cd external # build libsdldmd, SDL3_image, SDL3_mixer # -LIBSDLDMD_EXPECTED_SHA="${LIBSDLDMD_SHA}" +LIBSDLDMD_EXPECTED_SHA="$(ppuc_dependency_cache_key libsdldmd "${LIBSDLDMD_SHA}")" LIBSDLDMD_FOUND_SHA="$([ -f libsdldmd/cache.txt ] && cat libsdldmd/cache.txt || echo "")" if [ -n "${LIBSDLDMD_SHA}" ] && [ "${LIBSDLDMD_EXPECTED_SHA}" != "${LIBSDLDMD_FOUND_SHA}" ]; then @@ -38,9 +40,7 @@ if [ -n "${LIBSDLDMD_SHA}" ] && [ "${LIBSDLDMD_EXPECTED_SHA}" != "${LIBSDLDMD_FO mkdir libsdldmd cd libsdldmd - curl -sL https://github.com/PPUC/libsdldmd/archive/${LIBSDLDMD_SHA}.tar.gz -o libsdldmd-${LIBSDLDMD_SHA}.tar.gz - tar xzf libsdldmd-${LIBSDLDMD_SHA}.tar.gz - mv libsdldmd-${LIBSDLDMD_SHA} libsdldmd + ppuc_prepare_dependency_source libsdldmd "${LIBSDLDMD_SHA}" "https://github.com/PPUC/libsdldmd/archive/${LIBSDLDMD_SHA}.tar.gz" cd libsdldmd BUILD_TYPE=${BUILD_TYPE} platforms/win/x86/external.sh @@ -192,7 +192,7 @@ fi # libppuc # -LIBPPUC_EXPECTED_SHA="${LIBPPUC_SHA}" +LIBPPUC_EXPECTED_SHA="$(ppuc_dependency_cache_key libppuc "${LIBPPUC_SHA}")" LIBPPUC_FOUND_SHA="$([ -f libppuc/cache.txt ] && cat libppuc/cache.txt || echo "")" if [ "${LIBPPUC_EXPECTED_SHA}" != "${LIBPPUC_FOUND_SHA}" ]; then @@ -202,9 +202,7 @@ if [ "${LIBPPUC_EXPECTED_SHA}" != "${LIBPPUC_FOUND_SHA}" ]; then mkdir libppuc cd libppuc - curl -sL https://github.com/PPUC/libppuc/archive/${LIBPPUC_SHA}.tar.gz -o libppuc-${LIBPPUC_SHA}.tar.gz - tar xzf libppuc-${LIBPPUC_SHA}.tar.gz - mv libppuc-${LIBPPUC_SHA} libppuc + ppuc_prepare_dependency_source libppuc "${LIBPPUC_SHA}" "https://github.com/PPUC/libppuc/archive/${LIBPPUC_SHA}.tar.gz" cd libppuc platforms/win/x86/external.sh From 6c0f398d0fac4d91e4d580579ea96a5744ee42ac Mon Sep 17 00:00:00 2001 From: Markus Kalkbrenner Date: Sun, 28 Jun 2026 09:40:17 +0200 Subject: [PATCH 4/7] added lua --- CMakeLists.txt | 87 +- INTERCEPTOR.md | 174 +-- README.md | 148 +-- RULES_AND_EFFECTS.md | 40 +- examples/flash.rules | 67 - examples/flash.speech | 15 - examples/ppuc-pinmame.ini | 7 +- examples/pup-triggers.rules | 74 -- examples/rules.lua | 52 + platforms/config.sh | 25 + platforms/linux/aarch64/external.sh | 3 + platforms/linux/x64/external.sh | 3 + platforms/macos/arm64/external.sh | 3 + platforms/macos/x64/external.sh | 3 + platforms/win-mingw/x64/external.sh | 3 + platforms/win/x64/external.sh | 3 + platforms/win/x86/external.sh | 3 + src/LuaRulesEngine.cpp | 733 +++++++++++ src/LuaRulesEngine.h | 144 +++ src/PUPTriggerEngine.cpp | 1754 --------------------------- src/PUPTriggerEngine.h | 205 ---- src/RulesAction.h | 20 + src/SpeechCliSupport.cpp | 11 - src/SpeechCliSupport.h | 1 - src/SpeechTriggerMap.cpp | 107 -- src/SpeechTriggerMap.h | 16 - src/menu.cpp | 3 +- src/ppuc.cpp | 165 ++- 28 files changed, 1303 insertions(+), 2566 deletions(-) delete mode 100644 examples/flash.rules delete mode 100644 examples/flash.speech delete mode 100644 examples/pup-triggers.rules create mode 100644 examples/rules.lua create mode 100644 src/LuaRulesEngine.cpp create mode 100644 src/LuaRulesEngine.h delete mode 100644 src/PUPTriggerEngine.cpp delete mode 100644 src/PUPTriggerEngine.h create mode 100644 src/RulesAction.h delete mode 100644 src/SpeechTriggerMap.cpp delete mode 100644 src/SpeechTriggerMap.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 576ecd2..de3d0ae 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -92,6 +92,85 @@ if(EXISTS "${PPUC_STAGED_INCLUDE_DIR}") list(APPEND PPUC_INCLUDE_DIRS "${PPUC_STAGED_INCLUDE_DIR}") endif() +set(PPUC_LUA_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/third-party/lua-src") +set(PPUC_LUA_INCLUDE_DIR "") +set(PPUC_LUA_LIBRARY "") +set(PPUC_LUA_SOURCES "") +if(EXISTS "${PPUC_LUA_SOURCE_DIR}/lua.h") + set(PPUC_LUA_INCLUDE_DIR "${PPUC_LUA_SOURCE_DIR}") + set(PPUC_LUA_SOURCES + "${PPUC_LUA_SOURCE_DIR}/lapi.c" + "${PPUC_LUA_SOURCE_DIR}/lauxlib.c" + "${PPUC_LUA_SOURCE_DIR}/lbaselib.c" + "${PPUC_LUA_SOURCE_DIR}/lcode.c" + "${PPUC_LUA_SOURCE_DIR}/lcorolib.c" + "${PPUC_LUA_SOURCE_DIR}/lctype.c" + "${PPUC_LUA_SOURCE_DIR}/ldblib.c" + "${PPUC_LUA_SOURCE_DIR}/ldebug.c" + "${PPUC_LUA_SOURCE_DIR}/ldo.c" + "${PPUC_LUA_SOURCE_DIR}/ldump.c" + "${PPUC_LUA_SOURCE_DIR}/lfunc.c" + "${PPUC_LUA_SOURCE_DIR}/lgc.c" + "${PPUC_LUA_SOURCE_DIR}/linit.c" + "${PPUC_LUA_SOURCE_DIR}/liolib.c" + "${PPUC_LUA_SOURCE_DIR}/llex.c" + "${PPUC_LUA_SOURCE_DIR}/lmathlib.c" + "${PPUC_LUA_SOURCE_DIR}/lmem.c" + "${PPUC_LUA_SOURCE_DIR}/loadlib.c" + "${PPUC_LUA_SOURCE_DIR}/lobject.c" + "${PPUC_LUA_SOURCE_DIR}/lopcodes.c" + "${PPUC_LUA_SOURCE_DIR}/loslib.c" + "${PPUC_LUA_SOURCE_DIR}/lparser.c" + "${PPUC_LUA_SOURCE_DIR}/lstate.c" + "${PPUC_LUA_SOURCE_DIR}/lstring.c" + "${PPUC_LUA_SOURCE_DIR}/lstrlib.c" + "${PPUC_LUA_SOURCE_DIR}/ltable.c" + "${PPUC_LUA_SOURCE_DIR}/ltablib.c" + "${PPUC_LUA_SOURCE_DIR}/ltm.c" + "${PPUC_LUA_SOURCE_DIR}/lundump.c" + "${PPUC_LUA_SOURCE_DIR}/lutf8lib.c" + "${PPUC_LUA_SOURCE_DIR}/lvm.c" + "${PPUC_LUA_SOURCE_DIR}/lzio.c" + ) + message(STATUS "Lua source dir: ${PPUC_LUA_SOURCE_DIR}") +else() + find_path(PPUC_LUA_INCLUDE_DIR + NAMES lua.h + PATHS + "${PPUC_STAGED_INCLUDE_DIR}" + "${PPUC_STAGED_INCLUDE_DIR}/lua" + "${PPUC_STAGED_INCLUDE_DIR}/lua5.4" + "${PPUC_STAGED_INCLUDE_DIR}/lua54" + ) + if(NOT PPUC_LUA_INCLUDE_DIR) + find_path(PPUC_LUA_INCLUDE_DIR + NAMES lua.h + PATH_SUFFIXES lua5.4 lua54 lua + ) + endif() + + find_library(PPUC_LUA_LIBRARY + NAMES lua54 lua5.4 lua + PATHS + "${PPUC_STAGED_BUILD_LIB_DIR}" + "${PPUC_STAGED_RUNTIME_LIB_DIR}" + ) + if(NOT PPUC_LUA_LIBRARY) + find_library(PPUC_LUA_LIBRARY + NAMES lua54 lua5.4 lua + ) + endif() + + if(NOT PPUC_LUA_INCLUDE_DIR OR NOT PPUC_LUA_LIBRARY) + message(FATAL_ERROR "Could not find Lua source, headers, or library. Run the platform external.sh script first.") + endif() + + message(STATUS "Lua include dir: ${PPUC_LUA_INCLUDE_DIR}") + message(STATUS "Lua library: ${PPUC_LUA_LIBRARY}") +endif() + +list(APPEND PPUC_INCLUDE_DIRS "${PPUC_LUA_INCLUDE_DIR}") + if(CMAKE_BUILD_TYPE STREQUAL "Debug") if(ENABLE_SANITIZERS AND (PLATFORM STREQUAL "macos" OR PLATFORM STREQUAL "linux")) set(SANITIZER_FLAGS -fsanitize=address,undefined) @@ -123,9 +202,9 @@ endif() add_executable(ppuc-pinmame src/AudioOutput.cpp - src/PUPTriggerEngine.cpp + src/LuaRulesEngine.cpp + ${PPUC_LUA_SOURCES} src/SpeechCliSupport.cpp - src/SpeechTriggerMap.cpp src/SpeechService.cpp src/ppuc.cpp ) @@ -280,6 +359,7 @@ if(PLATFORM STREQUAL "win" OR PLATFORM STREQUAL "win-mingw") SDL364 SDL3_image64 yaml-cpp + ${PPUC_LUA_LIBRARY} ws2_32 ) else() @@ -292,6 +372,7 @@ if(PLATFORM STREQUAL "win" OR PLATFORM STREQUAL "win-mingw") SDL3 SDL3_image yaml-cpp + ${PPUC_LUA_LIBRARY} ws2_32 ) endif() @@ -322,6 +403,7 @@ else() SDL3 SDL3_image ${PPUC_YAML_CPP_LINK_LIBRARY} + ${PPUC_LUA_LIBRARY} ) else() target_link_libraries(ppuc-pinmame LINK_PUBLIC @@ -333,6 +415,7 @@ else() SDL3 SDL3_image ${PPUC_YAML_CPP_LINK_LIBRARY} + ${PPUC_LUA_LIBRARY} ) endif() diff --git a/INTERCEPTOR.md b/INTERCEPTOR.md index 3eac324..45c2e7a 100644 --- a/INTERCEPTOR.md +++ b/INTERCEPTOR.md @@ -1,81 +1,48 @@ -# Interceptor Rules +# Interceptor Lua Rules The interceptor feature lets `ppuc-pinmame` react to physical machine events -before they are forwarded to PinMAME. It is implemented as an extension of the -existing `--pup-triggers` rule engine, so the same rules file can now emit DMD, -PUP, speech, and board effect triggers, and can also apply host-side game logic. - -Compared with `main`, the interceptor branch adds: - -- named transient rule state with `set_state`, `clear_state`, and `state(...)` -- switch groups from game YAML with `switch_group(...)` and group edge checks -- multi-switch edge expressions such as `switch_rising(10, 11, 12)` -- switch suppression before the event reaches PinMAME -- host-side coil pulses and lamp blinking that temporarily override PinMAME - output for the same output number - -## Rule File - -Interceptor rules live in the normal trigger file loaded with: +before they are forwarded to PinMAME. Interceptor logic lives in the Lua rules +file loaded with: ```text ---pup-triggers +--rules ``` -The general rule format remains: +The same Lua rules file can emit DMD/PUP triggers, speech callouts, board effect +triggers, and host-side interceptor actions. -```text - [value] [options...] : -``` +## Runtime API -Use target `S` for interceptor-only rules. `S` rules are silent: they can set -state, suppress switches, pulse coils, and blink lamps, but they do not emit an -external trigger. - -## Added Options - -- `set_state=` sets a named state when the rule matches. Names may - contain letters, numbers, `_`, `-`, and `.`. -- `state_ms=` sets an optional lifetime for `set_state`. If - omitted or zero, the state remains set until a matching `clear_state` rule - runs. -- `clear_state=` clears a named state when the rule matches. If a rule has - both `clear_state` and `set_state`, the clear happens first. -- `suppress_switch=` prevents a matching physical switch close from - being sent to PinMAME. The later open edge for the same switch is also - suppressed, so PinMAME sees no switch edge at all. Rules still see the - physical switch state. -- `pulse_coil=` pulses a coil from the host when the rule matches. -- `pulse_ms=` sets an optional pulse duration for `pulse_coil`. - The default is 120 ms. -- `blink_lamp=` blinks a lamp while the rule expression is true. -- `blink_on_ms=` and `blink_off_ms=` set optional - blink timing. Both default to 250 ms. - -## Added Expressions +Interceptor rules use the `ppuc` Lua namespace: -```text -state() -switch_group() -switch_rising_group() -switch_falling_group() -switch_rising(, , ...) -switch_falling(, , ...) +```lua +function ppuc.onSwitchChanged(number, state) + if ppuc.stateActive("ballSave") and ppuc.switchClosing(9) then + ppuc.suppressSwitch(9) + ppuc.pulseCoil(7, 120) + end +end ``` -`state()` is true while the named state is active. - -`switch_group()` is true when any switch in the group is closed. - -`switch_rising_group()` and `switch_falling_group()` match edge -events from any switch in the group. - -`switch_rising(...)` and `switch_falling(...)` still accept one switch number, -and now also accept a comma-separated list. +Useful functions: + +- `ppuc.switchState(number)` +- `ppuc.switchClosing(number)` +- `ppuc.switchOpening(number)` +- `ppuc.switchGroupState(name)` +- `ppuc.switchGroupClosing(name)` +- `ppuc.switchGroupOpening(name)` +- `ppuc.setState(name)` and `ppuc.setState(name, durationMs)` +- `ppuc.clearState(name)` +- `ppuc.stateActive(name)` +- `ppuc.suppressSwitch(number)` +- `ppuc.pulseCoil(number, durationMs)` +- `ppuc.blinkLamp(number, onMs, offMs)` +- `ppuc.stopBlinkLamp(number)` ## Switch Groups -Switch groups are declared in the game YAML and are loaded by `libppuc`: +Switch groups are declared in the game YAML and loaded by `libppuc`: ```yaml switchGroups: @@ -86,59 +53,50 @@ switchGroups: The group name `buttons` is reserved. It is built automatically from switches marked with `button: true` in `switches` or `switchMatrix.switches`. -```yaml -switches: - - - description: 'Start Button' - number: 13 - board: 1 - port: 1 - debounce: 50 - button: true -``` - -Group switch numbers are sorted and de-duplicated when the YAML is loaded. - ## Runtime Output Overrides PinMAME remains the normal owner of lamps and coils. Interceptor output actions temporarily override a single output number: -- `pulse_coil` forces the coil on until the pulse timer expires, then restores - the latest PinMAME coil state for that coil. -- `blink_lamp` forces a lamp blink while the expression is true, then restores - the latest PinMAME lamp state for that lamp. +- `ppuc.pulseCoil(...)` forces the coil on until the pulse timer expires, then + restores the latest PinMAME coil state for that coil. +- `ppuc.blinkLamp(...)` forces a lamp blink until `ppuc.stopBlinkLamp(...)`, + then restores the latest PinMAME lamp state for that lamp. -If PinMAME changes the same output while the override is active, the new -PinMAME state is remembered and restored when the override ends. +If PinMAME changes the same output while the override is active, the new PinMAME +state is remembered and restored when the override ends. ## Ball Save Example -This example arms ball save on switch 15, starts a 5 second save window when -any playfield switch closes, blinks lamp 8 while ball save is ready or active, -and suppresses the outhole switch while pulsing coil 7. - -```text -S ball-save-ready set_state=ball_save_ready : switch_rising(15) -S ball-save-active set_state=ball_save state_ms=5000 clear_state=ball_save_ready : state(ball_save_ready) && switch_rising_group(playfield) -S shoot-again-blink blink_lamp=8 : state(ball_save_ready) || state(ball_save) -S outhole-save suppress_switch=9 pulse_coil=7 pulse_ms=120 : state(ball_save) && switch_rising(9) -``` - -Required YAML group: - -```yaml -switchGroups: - playfield: - switches: [10, 11, 12, 13] +This example arms ball save on switch 15, starts a 5 second save window when any +playfield switch closes, blinks lamp 8 while ball save is ready or active, and +suppresses the outhole switch while pulsing coil 7. + +```lua +function ppuc.onSwitchChanged(number, state) + if ppuc.switchClosing(15) then + ppuc.setState("ballSaveReady") + end + + if ppuc.stateActive("ballSaveReady") and ppuc.switchGroupClosing("playfield") then + ppuc.clearState("ballSaveReady") + ppuc.setState("ballSave", 5000) + end + + if ppuc.stateActive("ballSave") and ppuc.switchClosing(9) then + ppuc.suppressSwitch(9) + ppuc.pulseCoil(7, 120) + end +end + +function ppuc.onRulesUpdate() + if ppuc.stateActive("ballSaveReady") or ppuc.stateActive("ballSave") then + ppuc.blinkLamp(8, 250, 250) + else + ppuc.stopBlinkLamp(8) + end +end ``` -## Implementation Notes - -The branch wires switch processing through `PUPTriggerEngine::ProcessSwitchState` -so the engine can return whether a switch should continue to PinMAME. Lamp and -coil updates from PinMAME go through a small output override layer that preserves -the last PinMAME state while host-side pulses and blinks are active. - The feature is intentionally host-side. It does not change the RS485 wire -protocol or move rule logic into the IO-board firmware. +protocol or move rules into the IO-board firmware. diff --git a/README.md b/README.md index d075ec6..bb77b46 100644 --- a/README.md +++ b/README.md @@ -61,11 +61,8 @@ These components are still in an early development stage and the documentation w * -p VALUE * Serum ignore number of unknown frames * optional -* --pup-triggers path - * path to a lightweight PUP trigger rules file - * optional -* --speech-file path - * path to a speech trigger text file used with speech trigger rules +* --rules path + * path to a Lua rules file * optional * --music-files csv * comma-separated MP3 playlist for gameplay background music @@ -161,61 +158,54 @@ pwmOutput: ballSearch: true ``` -### PUP Trigger Rules +### Lua Rules + +Use `--rules ` to run a Lua rules script. 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: -Use `--pup-triggers ` to map switch/lamp/coil conditions to calls of `SetPUPTrigger(source, id, value)`. -This trigger feature is independent from `--pup`, and can also drive speech callouts and board-local PPUC effects. +```lua +function ppuc.onSwitchChanged(number, state) + if ppuc.switchClosing(13) and ppuc.lampState(42) then + ppuc.pupTrigger("P", 100, 1) + end -Rule syntax: + if ppuc.stateActive("ballSave") and ppuc.switchClosing(9) then + ppuc.suppressSwitch(9) + ppuc.pulseCoil(7, 120) + end +end -```text - [value] [cooldown=] [delay=] : +function ppuc.onLampChanged(number, state) + if ppuc.lampRising(23) and not ppuc.attractMode() then + ppuc.speech("New highscore!") + end +end ``` -Expression functions: -* `switch()` -* `lamp()` -* `coil()` -* `attract` or `attract()` -* `switch_rising()` -* `switch_falling()` -* `lamp_rising()` -* `lamp_falling()` -* `coil_rising()` -* `coil_falling()` - -Operators: -* `!` -* `&&` -* `||` -* parentheses `(...)` - -Rule options: -* `cooldown=` - * suppress retriggering until the cooldown window has elapsed -* `delay=` - * wait before firing after the expression matches - * for state-based expressions, the condition must still be true when the delay expires - * for edge expressions like `switch_rising(...)`, the matching edge arms the delayed trigger once -* `set_state=` with optional `state_ms=` - * set a named transient state that can be tested with `state()` - * omit `state_ms` for a state that remains set until `clear_state=` -* `clear_state=` - * clear a named state when the rule matches -* `suppress_switch=` - * prevent a matching physical switch close from being reported to PinMAME - * the matching switch open is also suppressed so PinMAME sees no edge -* `pulse_coil=` with optional `pulse_ms=` - * pulse a coil from the host; default pulse is 120 ms -* `blink_lamp=` with optional `blink_on_ms=` and `blink_off_ms=` - * blink a lamp while the rule expression remains true; defaults are 250/250 ms - -Additional expression functions: -* `state()` -* `switch_group()` -* `switch_rising_group()` -* `switch_falling_group()` -* `switch_rising(, , ...)` and `switch_falling(, , ...)` +Supported handlers: +* `ppuc.onSwitchChanged(number, state)` +* `ppuc.onLampChanged(number, state)` +* `ppuc.onCoilChanged(number, state)` +* `ppuc.onBallChanged(ball)` +* `ppuc.onPlayerChanged(player)` +* `ppuc.onRulesUpdate()` + +State and edge helpers: +* `ppuc.switchState(number)`, `ppuc.lampState(number)`, `ppuc.coilState(number)` +* `ppuc.switchClosing(number)`, `ppuc.switchOpening(number)` +* `ppuc.lampRising(number)`, `ppuc.lampFalling(number)` +* `ppuc.coilRising(number)`, `ppuc.coilFalling(number)` +* `ppuc.currentBall()`, `ppuc.currentPlayer()`, `ppuc.attractMode()` + +Named states, history, and switch groups: +* `ppuc.setState(name)` and `ppuc.setState(name, durationMs)` +* `ppuc.clearState(name)` and `ppuc.stateActive(name)` +* `ppuc.triggerHistory(id)` and `ppuc.triggerHistory(id, windowMs)` +* `ppuc.triggerSequence(windowMs, id1, id2, id3)` +* `ppuc.switchGroupState(name)`, `ppuc.switchGroupClosing(name)`, `ppuc.switchGroupOpening(name)` Switch groups can be declared in the game YAML: @@ -228,50 +218,29 @@ switchGroups: The group `buttons` is built in from switches marked `button: true` and cannot be overridden in YAML. -Example: - -```text -P 100 1 : switch_rising(13) && lamp(42) -P 101 1 cooldown=500 : switch_rising(13) && attract -P 102 1 delay=750 : switch(13) && attract -O 60010 1 : lamp_rising(23) && !attract -F cabinet-attract 1 : lamp_rising(5) && attract -S ball-save-ready set_state=ball_save_ready : switch_rising(15) -S ball-save-active set_state=ball_save state_ms=5000 clear_state=ball_save_ready : state(ball_save_ready) && switch_rising_group(playfield) -S shoot-again-blink blink_lamp=8 : state(ball_save_ready) || state(ball_save) -S outhole-save suppress_switch=9 pulse_coil=7 pulse_ms=120 : state(ball_save) && switch_rising(9) -``` +Outputs and integrations: +* `ppuc.pupTrigger(source, id, value)` +* `ppuc.speech(text)` +* `ppuc.effectTrigger(id, value)` +* `ppuc.suppressSwitch(number)` +* `ppuc.pulseCoil(number, durationMs)` +* `ppuc.blinkLamp(number, onMs, offMs)` and `ppuc.stopBlinkLamp(number)` -A ready-to-use sample file is available at `examples/pup-triggers.rules`. +A ready-to-use sample file is available at `examples/rules.lua`. Interceptor-specific behavior is documented in `INTERCEPTOR.md`. -Speech trigger source: -* `O` - * spoken callout target - * the trigger `id` is looked up in a `--speech-file` +Board effect trigger source: * `F` * board-local effect trigger * forwarded to `libppuc` as a runtime event with source `EVENT_SOURCE_EFFECT` * use matching `trigger.source: F` plus `trigger.name` or `trigger.number` in the game YAML effect block -### Speech Trigger Text Files - -Use `--speech-file ` together with `--speech` and `--pup-triggers` to map speech trigger IDs to spoken text. +Speech callouts use the configured speech backend directly from Lua: -Syntax: - -```text - : +```lua +ppuc.speech("New highscore!") ``` -Example: - -```text -60010: New highscore! -``` - -If a trigger rule emits source `O` with id `60010`, the speech backend will speak that text. - Speech backends: * `auto` * prefer `espeak-ng` when available, otherwise `flite` @@ -287,8 +256,7 @@ Examples: * `--speech-backend flite --speech-voice kal` Ready-to-use samples are available at: -* `examples/flash.rules` -* `examples/flash.speech` +* `examples/rules.lua` ### Compiling diff --git a/RULES_AND_EFFECTS.md b/RULES_AND_EFFECTS.md index 05bc40d..c82c30a 100644 --- a/RULES_AND_EFFECTS.md +++ b/RULES_AND_EFFECTS.md @@ -92,7 +92,7 @@ Two PWM effects should be defined in the Flash YAML. - Priority: `1` - Repeat: `0` -Under the new model, this effect remains defined in YAML, but its trigger logic belongs only in the rule file. +Under the new model, this effect remains defined in YAML, but its trigger logic belongs only in the Lua rules file. #### Flash Shaker @@ -105,12 +105,12 @@ Under the new model, this effect remains defined in YAML, but its trigger logic - Priority: `2` - Repeat: `0` -This is a second shaker pattern that should be triggered from the rule file instead of from YAML-generated trigger entries. +This is a second shaker pattern that should be triggered from the Lua rules file instead of from YAML-generated trigger entries. -## Trigger Rules From `flash.rules` +## Lua Rules From `rules.lua` Rules file: -[flash.rules](/Volumes/data/workspace/PPUC/ppuc_combined/ppuc/examples/flash.rules) +[rules.lua](/Volumes/data/workspace/PPUC/ppuc_combined/ppuc/examples/rules.lua) These rules watch ROM-visible switch, lamp, and coil changes and emit additional actions. @@ -118,7 +118,7 @@ These rules watch ROM-visible switch, lamp, and coil changes and emit additional The word `source` comes from the older effect-trigger vocabulary inherited from DOF, the Direct Output Framework. -That old name is still visible in some code and protocol constants, but in the first field of a rule line it is better understood as a target channel, not as the source of the condition. +That old name is still visible in some code and protocol constants. In Lua rules, the target channel is passed explicitly to functions such as `ppuc.pupTrigger(...)`, `ppuc.speech(...)`, and `ppuc.effectTrigger(...)`. Example: @@ -243,22 +243,13 @@ Current `D` triggers: These are not original ROM outputs. They are interpretation layers built from ROM state transitions. -### Speech Trigger Rules +### Speech Rules -Target channel `O` is used for speech. +Speech callout text is defined directly in Lua with `ppuc.speech(...)`. Current speech-triggered rule: -- `60010`: Highscore - -The speech text is defined in: -[flash.speech](/Volumes/data/workspace/PPUC/ppuc_combined/ppuc/examples/flash.speech) - -Current speech lines: - -- `60010`: `New highscore!` -- `60009`: `Game over.` -- `60007`: `Extra ball!` +- Highscore: `ppuc.speech("New highscore!")` Only IDs emitted by an `O` rule are spoken. Right now the rules file explicitly emits `O 60010`, so `New highscore!` is the currently wired speech callout from the sample rule set. @@ -305,17 +296,14 @@ Matching YAML effect definition: repeat: -1 ``` -The name is hashed deterministically on both sides, so the rule file and YAML can refer to the same effect without a hand-maintained numeric ID table. +The name is hashed deterministically on both sides, so the Lua rules file and YAML can refer to the same effect without a hand-maintained numeric ID table. No YAML `trigger:` block is required for named effects anymore. -### Sample `F` Rules Added In The Repo - -The sample `flash.rules` now also contains: +### Sample Effect Rules Added In The Repo -- `F cabinet-flash-attract 1 cooldown=20000 : lamp_rising(5) && attract` -- `F shaker-flash-hit 1 : coil_rising(6) && !attract` +The sample `rules.lua` contains calls such as: -Rules can also use `delay=` when an effect should fire after a hold time instead of immediately. +- `ppuc.effectTrigger(1000, 1)` These demonstrate the intended future direction: @@ -325,13 +313,13 @@ These demonstrate the intended future direction: Important current status: - the runtime `F` transport path is implemented -- the sample `F` rules exist +- sample Lua effect rules exist - named YAML effects can now auto-register themselves as `F` targets So the intended setup is now: - YAML defines effect targets and parameters -- `flash.rules` defines all boolean logic and trigger conditions +- `rules.lua` defines all boolean logic and trigger conditions - YAML effect `trigger:` blocks are no longer needed for named effects ## Config Tool Status diff --git a/examples/flash.rules b/examples/flash.rules deleted file mode 100644 index b6328b6..0000000 --- a/examples/flash.rules +++ /dev/null @@ -1,67 +0,0 @@ -# Format: -# [value] [cooldown=] [delay=] : -# -# Expression functions: -# - switch() current switch state (true when closed) -# - lamp() current lamp state (true when lit) -# - coil() current coil state (true when active) -# - attract / attract() true if table is in attract mode (no game running) -# - switch_rising() switch transitioned 0 -> 1 in this event -# - switch_falling() -# - lamp_rising() -# - lamp_falling() -# - coil_rising() -# - coil_falling() -# -# Operators: !, &&, || and parentheses - -# Ball 1 -D 60001 1 : coil_rising(13) && lamp(42) && !attract - -# Ball 2 -D 60002 1 : coil_rising(13) && lamp(43) && !attract - -# Ball 3 -D 60003 1 : coil_rising(13) && lamp(44) && !attract - -# Extra Ball -D 60007 1 : lamp_rising(22) && !attract - -# Shoot Again -D 60016 1 : lamp_falling(22) && !attract - -# Bonus x2 -D 60005 1 : lamp_rising(20) && !attract - -# Bonus x3 -D 60006 1 : lamp_rising(21) && !attract - -# Flash -D 60008 1 cooldown=20000 : lamp_rising(5) && attract -F cabinet-flash-attract 1 cooldown=20000 : lamp_rising(5) && attract - -# Game Over -D 60009 1 : lamp_rising(23) && !attract - -# Highscore -D 60010 1 : lamp_rising(23) && !attract -O 60010 1 : lamp_rising(23) && !attract - -# progress-1.mp4, 30fps -D 60012 1 : lamp_rising(24) && !attract - -# progress-1.mp4, 30fps -D 60013 1 : lamp_rising(25) && !attract - -# progress-1.mp4, 30fps -D 60014 1 : lamp_rising(26) && !attract - -# progress-1.mp4, 30fps -D 60015 1 : lamp_rising(27) && !attract - -# Special -D 60017 1 : (switch_rising(13) || switch_rising(12)) && (lamp(2) || lamp(23)) && !attract - -# Tilt -D 60018 1 : lamp_rising(27) && !attract -F shaker-flash-hit 1 : coil_rising(6) && !attract diff --git a/examples/flash.speech b/examples/flash.speech deleted file mode 100644 index 224ff8a..0000000 --- a/examples/flash.speech +++ /dev/null @@ -1,15 +0,0 @@ -# Format: -# : -# -# Use with trigger rules that emit source "O". -# Example in flash.rules: -# O 60010 1 : lamp_rising(23) && !attract - -# Highscore -60010: New highscore! - -# Game Over -60009: Game over. - -# Extra Ball -60007: Extra ball! diff --git a/examples/ppuc-pinmame.ini b/examples/ppuc-pinmame.ini index 0e676bf..3e5ae38 100644 --- a/examples/ppuc-pinmame.ini +++ b/examples/ppuc-pinmame.ini @@ -20,11 +20,8 @@ Serial = # Windows example: D:\PinMAME\ PinmamePath = -# Optional PUP trigger rules file. -PUPTriggers = - -# Optional speech trigger text file. -SpeechFile = +# Optional Lua rules file. +Rules = # Optional comma-separated MP3 playlist for gameplay background music. MusicFiles = diff --git a/examples/pup-triggers.rules b/examples/pup-triggers.rules deleted file mode 100644 index bd20cfa..0000000 --- a/examples/pup-triggers.rules +++ /dev/null @@ -1,74 +0,0 @@ -# Format: -# [value] [cooldown=] [delay=] [set_player=] -# [clear_player_history=] [set_state=] [state_ms=] -# [clear_state=] [suppress_switch=] [pulse_coil=] -# [pulse_ms=] [blink_lamp=] [blink_on_ms=] -# [blink_off_ms=] : -# Special source: -# - S silent rule: apply state/history side effects, but emit no external trigger -# -# Expression functions: -# - ball() true if the current ball-in-play number matches -# - player() true if the current player matches -# - history() true if this trigger id fired before for the current player -# - history(, ) same, limited to the last milliseconds -# - sequence(, , , ...) true if those trigger ids fired in order within -# - state() true while a named state is active -# - switch() current switch state (true when closed) -# - switch_group() true if any switch in a configured group is closed -# - lamp() current lamp state (true when lit) -# - coil() current coil state (true when active) -# - attract / attract() true if table is in attract mode (no game running) -# - switch_rising([, ...]) switch transitioned 0 -> 1 in this event -# - switch_falling() -# - switch_rising_group() -# - switch_falling_group() -# - lamp_rising() -# - lamp_falling() -# - coil_rising() -# - coil_falling() -# -# Operators: !, &&, || and parentheses - -# Example from request: -# Trigger when switch 13 closes while lamp 42 is lit. -P 100 1 : switch_rising(13) && lamp(42) - -# Trigger when lamp 77 turns on and either switch 25 or 26 is closed. -P 101 1 : lamp_rising(77) && (switch(25) || switch(26)) - -# Trigger when switch 54 opens while lamp 77 is off. -P 102 0 : switch_falling(54) && !lamp(77) - -# Trigger when coil 20 becomes active while lamp 42 is lit. -P 103 1 : coil_rising(20) && lamp(42) - -# Trigger when the game advances to ball 2. -P 106 1 : ball(2) - -# Example: player selection from an external lamp-driven event without emitting anything. -S 201 1 set_player=1 : lamp_rising(101) - -# Example: clear all stored history for player 1 without emitting anything. -S 205 1 clear_player_history=1 : player(1) && ball(1) - -# Example: emit once per player, then emit a "ball save" style trigger if it repeats within 2 seconds. -P 202 1 : player(1) && !history(202) -P 203 1 : player(1) && history(202, 2000) - -# Example: detect "A then B then C" within 2 seconds for the current player. -P 204 1 : sequence(2000, 301, 302, 303) - -# Trigger at most every 500ms while in attract mode. -P 104 1 cooldown=500 : switch_rising(13) && attract - -# Trigger 750ms after switch 13 closes in attract mode. -# If switch 13 opens again before 750ms, this delayed trigger is cancelled. -P 105 1 delay=750 : switch(13) && attract - -# Interceptor example: host-side ball save. -# Requires a YAML switchGroups.playfield list, plus real switch/lamp/coil numbers. -# S ball-save-ready set_state=ball_save_ready : switch_rising(15) -# S ball-save-active set_state=ball_save state_ms=5000 clear_state=ball_save_ready : state(ball_save_ready) && switch_rising_group(playfield) -# S shoot-again-blink blink_lamp=8 : state(ball_save_ready) || state(ball_save) -# S outhole-save suppress_switch=9 pulse_coil=7 pulse_ms=120 : state(ball_save) && switch_rising(9) diff --git a/examples/rules.lua b/examples/rules.lua new file mode 100644 index 0000000..fd8c4b1 --- /dev/null +++ b/examples/rules.lua @@ -0,0 +1,52 @@ +-- PPUC Lua rules example. +-- +-- Define any of these handlers as needed: +-- ppuc.onSwitchChanged(number, state) +-- ppuc.onLampChanged(number, state) +-- ppuc.onCoilChanged(number, state) +-- ppuc.onBallChanged(ball) +-- ppuc.onPlayerChanged(player) +-- ppuc.onRulesUpdate() + +function ppuc.onSwitchChanged(number, state) + if ppuc.switchClosing(13) and ppuc.lampState(42) then + ppuc.pupTrigger("P", 100, 1) + end + + if ppuc.switchClosing(13) and ppuc.attractMode() then + ppuc.pupTrigger("P", 101, 1) + end + + -- Host-side ball-save interceptor example. + if ppuc.switchClosing(15) then + ppuc.setState("ballSaveReady") + end + + if ppuc.stateActive("ballSaveReady") and ppuc.switchGroupClosing("playfield") then + ppuc.clearState("ballSaveReady") + ppuc.setState("ballSave", 5000) + end + + if ppuc.stateActive("ballSave") and ppuc.switchClosing(9) then + ppuc.suppressSwitch(9) + ppuc.pulseCoil(7, 120) + end +end + +function ppuc.onLampChanged(number, state) + if ppuc.lampRising(23) and not ppuc.attractMode() then + ppuc.speech("New highscore!") + end + + if ppuc.lampRising(5) and ppuc.attractMode() then + ppuc.effectTrigger(1000, 1) + end +end + +function ppuc.onRulesUpdate() + if ppuc.stateActive("ballSaveReady") or ppuc.stateActive("ballSave") then + ppuc.blinkLamp(8, 250, 250) + else + ppuc.stopBlinkLamp(8) + end +end diff --git a/platforms/config.sh b/platforms/config.sh index 506bef9..1157b19 100644 --- a/platforms/config.sh +++ b/platforms/config.sh @@ -6,6 +6,7 @@ SDL_IMAGE_SHA=bec9134a26c7d0f31b36d6083c25296e04cabff5 SDL_MIXER_SHA=72a81869b45e249e8e67102db4e98dd2441f05a1 FLITE_SHA=6c9f20dc915b17f5619340069889db0aa007fcdc ESPEAK_NG_SHA=1.52.0 +LUA_VERSION=5.4.8 PINMAME_SHA=bf74d40ef837bdfc377c0266c0ef71b3ed59a751 PINMAME_NVRAM_MAPS_SHA=fa1086d57118e12f4802f3a9683c1e6acfb6ec6d LIBPPUC_SHA=715dd76055e2552b31dcff35508721bf0445a396 @@ -69,6 +70,30 @@ ppuc_prepare_dependency_source() { fi } +ppuc_stage_lua_source() { + local expected="${LUA_VERSION}" + local found + + found="$([ -f lua/cache.txt ] && cat lua/cache.txt || echo "")" + if [ "${expected}" != "${found}" ] || [ ! -f lua/lua/src/lua.h ]; then + echo "Preparing Lua. Expected: ${expected}, Found: ${found}" + + rm -rf lua + mkdir lua + cd lua + + ppuc_prepare_dependency_source lua "${LUA_VERSION}" "https://www.lua.org/ftp/lua-${LUA_VERSION}.tar.gz" + echo "${expected}" > cache.txt + + cd .. + fi + + mkdir -p ../third-party/include/lua ../third-party/lua-src + cp lua/lua/src/*.h ../third-party/include/lua/ + cp lua/lua/src/*.h ../third-party/lua-src/ + cp lua/lua/src/*.c ../third-party/lua-src/ +} + if [ -z "${BUILD_TYPE}" ]; then BUILD_TYPE="Release" fi diff --git a/platforms/linux/aarch64/external.sh b/platforms/linux/aarch64/external.sh index 3bcf33f..5e66b79 100755 --- a/platforms/linux/aarch64/external.sh +++ b/platforms/linux/aarch64/external.sh @@ -11,6 +11,7 @@ echo " SDL_IMAGE_SHA: ${SDL_IMAGE_SHA}" echo " SDL_MIXER_SHA: ${SDL_MIXER_SHA}" echo " FLITE_SHA: ${FLITE_SHA}" echo " ESPEAK_NG_SHA: ${ESPEAK_NG_SHA}" +echo " LUA_VERSION: ${LUA_VERSION}" echo " PINMAME_SHA: ${PINMAME_SHA}" echo " PINMAME_NVRAM_MAPS_SHA: ${PINMAME_NVRAM_MAPS_SHA}" echo " LIBPPUC_SHA: ${LIBPPUC_SHA}" @@ -31,6 +32,8 @@ echo "" mkdir -p external ${CACHE_DIR} cd external +ppuc_stage_lua_source + # # build libsdldmd, SDL3_image, SDL3_mixer # diff --git a/platforms/linux/x64/external.sh b/platforms/linux/x64/external.sh index dde9273..c7e254e 100755 --- a/platforms/linux/x64/external.sh +++ b/platforms/linux/x64/external.sh @@ -11,6 +11,7 @@ echo " SDL_IMAGE_SHA: ${SDL_IMAGE_SHA}" echo " SDL_MIXER_SHA: ${SDL_MIXER_SHA}" echo " ESPEAK_NG_SHA: ${ESPEAK_NG_SHA}" echo " FLITE_SHA: ${FLITE_SHA}" +echo " LUA_VERSION: ${LUA_VERSION}" echo " PINMAME_SHA: ${PINMAME_SHA}" echo " PINMAME_NVRAM_MAPS_SHA: ${PINMAME_NVRAM_MAPS_SHA}" echo " LIBPPUC_SHA: ${LIBPPUC_SHA}" @@ -31,6 +32,8 @@ echo "" mkdir -p external ${CACHE_DIR} cd external +ppuc_stage_lua_source + # # build libsdldmd, SDL3_image, SDL3_mixer # diff --git a/platforms/macos/arm64/external.sh b/platforms/macos/arm64/external.sh index dfd7319..ab238d5 100755 --- a/platforms/macos/arm64/external.sh +++ b/platforms/macos/arm64/external.sh @@ -11,6 +11,7 @@ echo " SDL_IMAGE_SHA: ${SDL_IMAGE_SHA}" echo " SDL_MIXER_SHA: ${SDL_MIXER_SHA}" echo " FLITE_SHA: ${FLITE_SHA}" echo " ESPEAK_NG_SHA: ${ESPEAK_NG_SHA}" +echo " LUA_VERSION: ${LUA_VERSION}" echo " PINMAME_SHA: ${PINMAME_SHA}" echo " PINMAME_NVRAM_MAPS_SHA: ${PINMAME_NVRAM_MAPS_SHA}" echo " LIBPPUC_SHA: ${LIBPPUC_SHA}" @@ -31,6 +32,8 @@ echo "" mkdir -p external ${CACHE_DIR} cd external +ppuc_stage_lua_source + # # flite # diff --git a/platforms/macos/x64/external.sh b/platforms/macos/x64/external.sh index dc69060..aede570 100755 --- a/platforms/macos/x64/external.sh +++ b/platforms/macos/x64/external.sh @@ -10,6 +10,7 @@ echo "Building libraries..." echo " SDL_IMAGE_SHA: ${SDL_IMAGE_SHA}" echo " SDL_MIXER_SHA: ${SDL_MIXER_SHA}" echo " ESPEAK_NG_SHA: ${ESPEAK_NG_SHA}" +echo " LUA_VERSION: ${LUA_VERSION}" echo " PINMAME_SHA: ${PINMAME_SHA}" echo " PINMAME_NVRAM_MAPS_SHA: ${PINMAME_NVRAM_MAPS_SHA}" echo " LIBPPUC_SHA: ${LIBPPUC_SHA}" @@ -30,6 +31,8 @@ echo "" mkdir -p external ${CACHE_DIR} cd external +ppuc_stage_lua_source + # # build libsdldmd, SDL3_image, SDL3_mixer # diff --git a/platforms/win-mingw/x64/external.sh b/platforms/win-mingw/x64/external.sh index 0f22e5f..7e8d2b1 100755 --- a/platforms/win-mingw/x64/external.sh +++ b/platforms/win-mingw/x64/external.sh @@ -9,6 +9,7 @@ NUM_PROCS=$(nproc) echo "Building libraries..." echo " SDL_IMAGE_SHA: ${SDL_IMAGE_SHA}" echo " SDL_MIXER_SHA: ${SDL_MIXER_SHA}" +echo " LUA_VERSION: ${LUA_VERSION}" echo " PINMAME_SHA: ${PINMAME_SHA}" echo " PINMAME_NVRAM_MAPS_SHA: ${PINMAME_NVRAM_MAPS_SHA}" echo " LIBPPUC_SHA: ${LIBPPUC_SHA}" @@ -34,6 +35,8 @@ mkdir -p \ third-party/runtime-libs/win-mingw-x64 cd external +ppuc_stage_lua_source + LIBSDLDMD_EXPECTED_SHA="$(ppuc_dependency_cache_key libsdldmd "${LIBSDLDMD_SHA}")" LIBSDLDMD_FOUND_SHA="$([ -f libsdldmd/cache.txt ] && cat libsdldmd/cache.txt || echo "")" diff --git a/platforms/win/x64/external.sh b/platforms/win/x64/external.sh index 54df9e1..e015fa6 100755 --- a/platforms/win/x64/external.sh +++ b/platforms/win/x64/external.sh @@ -7,6 +7,7 @@ source ./platforms/config.sh echo "Building libraries..." echo " SDL_IMAGE_SHA: ${SDL_IMAGE_SHA}" echo " SDL_MIXER_SHA: ${SDL_MIXER_SHA}" +echo " LUA_VERSION: ${LUA_VERSION}" echo " PINMAME_SHA: ${PINMAME_SHA}" echo " PINMAME_NVRAM_MAPS_SHA: ${PINMAME_NVRAM_MAPS_SHA}" echo " LIBPPUC_SHA: ${LIBPPUC_SHA}" @@ -26,6 +27,8 @@ echo "" mkdir -p external ${CACHE_DIR} cd external +ppuc_stage_lua_source + # # build libsdldmd, SDL3_image, SDL3_mixer # diff --git a/platforms/win/x86/external.sh b/platforms/win/x86/external.sh index aed514e..b197092 100755 --- a/platforms/win/x86/external.sh +++ b/platforms/win/x86/external.sh @@ -7,6 +7,7 @@ source ./platforms/config.sh echo "Building libraries..." echo " SDL_IMAGE_SHA: ${SDL_IMAGE_SHA}" echo " SDL_MIXER_SHA: ${SDL_MIXER_SHA}" +echo " LUA_VERSION: ${LUA_VERSION}" echo " PINMAME_SHA: ${PINMAME_SHA}" echo " PINMAME_NVRAM_MAPS_SHA: ${PINMAME_NVRAM_MAPS_SHA}" echo " LIBPPUC_SHA: ${LIBPPUC_SHA}" @@ -26,6 +27,8 @@ echo "" mkdir -p external ${CACHE_DIR} cd external +ppuc_stage_lua_source + # # build libsdldmd, SDL3_image, SDL3_mixer # diff --git a/src/LuaRulesEngine.cpp b/src/LuaRulesEngine.cpp new file mode 100644 index 0000000..ef6de10 --- /dev/null +++ b/src/LuaRulesEngine.cpp @@ -0,0 +1,733 @@ +#include "LuaRulesEngine.h" + +#include +#include +#include +#include + +extern "C" +{ +#include +#include +#include +} + +namespace +{ +constexpr uint32_t kHistoryRetentionMs = 60000; +constexpr char kBoardEffectTriggerSource = 'F'; + +using LuaFn = int (*)(lua_State*); + +void OpenLuaLibrary(lua_State* L, const char* name, lua_CFunction fn) +{ + luaL_requiref(L, name, fn, 1); + lua_pop(L, 1); +} + +void SetPpucFunction(lua_State* L, LuaRulesEngine* engine, const char* name, LuaFn fn) +{ + lua_pushlightuserdata(L, engine); + lua_pushcclosure(L, fn, 1); + lua_setfield(L, -2, name); +} +} // namespace + +LuaRulesEngine::LuaRulesEngine() = default; + +LuaRulesEngine::~LuaRulesEngine() +{ + if (m_lua != nullptr) + { + lua_close(m_lua); + m_lua = nullptr; + } +} + +void LuaRulesEngine::SetDebug(bool debug) +{ + std::lock_guard lock(m_mutex); + m_debug = debug; +} + +void LuaRulesEngine::SetTriggerCallback(TriggerCallback callback) +{ + std::lock_guard lock(m_mutex); + m_triggerCallback = std::move(callback); +} + +void LuaRulesEngine::SetSpeechCallback(SpeechCallback callback) +{ + std::lock_guard lock(m_mutex); + m_speechCallback = std::move(callback); +} + +void LuaRulesEngine::SetActionCallback(ActionCallback callback) +{ + std::lock_guard lock(m_mutex); + m_actionCallback = std::move(callback); +} + +void LuaRulesEngine::SetSwitchGroups(const std::unordered_map>& switchGroups) +{ + std::lock_guard lock(m_mutex); + m_switchGroups = switchGroups; +} + +bool LuaRulesEngine::InitializeLua(std::string& error) +{ + if (m_lua != nullptr) + { + lua_close(m_lua); + m_lua = nullptr; + } + + m_lua = luaL_newstate(); + if (m_lua == nullptr) + { + error = "Unable to create Lua state"; + return false; + } + + OpenLuaLibrary(m_lua, LUA_GNAME, luaopen_base); + OpenLuaLibrary(m_lua, LUA_TABLIBNAME, luaopen_table); + OpenLuaLibrary(m_lua, LUA_STRLIBNAME, luaopen_string); + OpenLuaLibrary(m_lua, LUA_MATHLIBNAME, luaopen_math); + lua_pushnil(m_lua); + lua_setglobal(m_lua, "dofile"); + lua_pushnil(m_lua); + lua_setglobal(m_lua, "loadfile"); + RegisterApi(); + return true; +} + +void LuaRulesEngine::RegisterApi() +{ + lua_newtable(m_lua); + + SetPpucFunction(m_lua, this, "switchState", LuaSwitchState); + SetPpucFunction(m_lua, this, "lampState", LuaLampState); + SetPpucFunction(m_lua, this, "coilState", LuaCoilState); + SetPpucFunction(m_lua, this, "currentBall", LuaCurrentBall); + SetPpucFunction(m_lua, this, "currentPlayer", LuaCurrentPlayer); + SetPpucFunction(m_lua, this, "attractMode", LuaAttractMode); + + SetPpucFunction(m_lua, this, "switchClosing", LuaSwitchClosing); + SetPpucFunction(m_lua, this, "switchOpening", LuaSwitchOpening); + SetPpucFunction(m_lua, this, "lampRising", LuaLampRising); + SetPpucFunction(m_lua, this, "lampFalling", LuaLampFalling); + SetPpucFunction(m_lua, this, "coilRising", LuaCoilRising); + SetPpucFunction(m_lua, this, "coilFalling", LuaCoilFalling); + + SetPpucFunction(m_lua, this, "switchGroupState", LuaSwitchGroupState); + SetPpucFunction(m_lua, this, "switchGroupClosing", LuaSwitchGroupClosing); + SetPpucFunction(m_lua, this, "switchGroupOpening", LuaSwitchGroupOpening); + + SetPpucFunction(m_lua, this, "setState", LuaSetState); + SetPpucFunction(m_lua, this, "clearState", LuaClearState); + SetPpucFunction(m_lua, this, "stateActive", LuaStateActive); + SetPpucFunction(m_lua, this, "triggerHistory", LuaTriggerHistory); + SetPpucFunction(m_lua, this, "triggerSequence", LuaTriggerSequence); + + SetPpucFunction(m_lua, this, "pupTrigger", LuaPupTrigger); + SetPpucFunction(m_lua, this, "speech", LuaSpeech); + SetPpucFunction(m_lua, this, "effectTrigger", LuaEffectTrigger); + SetPpucFunction(m_lua, this, "suppressSwitch", LuaSuppressSwitch); + SetPpucFunction(m_lua, this, "pulseCoil", LuaPulseCoil); + SetPpucFunction(m_lua, this, "blinkLamp", LuaBlinkLamp); + SetPpucFunction(m_lua, this, "stopBlinkLamp", LuaStopBlinkLamp); + + lua_setglobal(m_lua, "ppuc"); +} + +bool LuaRulesEngine::LoadScript(const char* path, std::string& error) +{ + std::lock_guard lock(m_mutex); + m_fatalError = false; + m_fatalErrorMessage.clear(); + + if (!InitializeLua(error)) + { + return false; + } + + if (luaL_loadfile(m_lua, path) != LUA_OK) + { + error = lua_tostring(m_lua, -1); + lua_pop(m_lua, 1); + return false; + } + + if (lua_pcall(m_lua, 0, 0, 0) != LUA_OK) + { + error = lua_tostring(m_lua, -1); + lua_pop(m_lua, 1); + return false; + } + + return true; +} + +bool LuaRulesEngine::CallHandler(const char* name) +{ + lua_getglobal(m_lua, "ppuc"); + lua_getfield(m_lua, -1, name); + if (!lua_isfunction(m_lua, -1)) + { + lua_pop(m_lua, 2); + return true; + } + + lua_remove(m_lua, -2); + if (lua_pcall(m_lua, 0, 0, 0) != LUA_OK) + { + SetFatalError(lua_tostring(m_lua, -1)); + lua_pop(m_lua, 1); + return false; + } + return true; +} + +bool LuaRulesEngine::CallHandler(const char* name, int arg1) +{ + lua_getglobal(m_lua, "ppuc"); + lua_getfield(m_lua, -1, name); + if (!lua_isfunction(m_lua, -1)) + { + lua_pop(m_lua, 2); + return true; + } + + lua_remove(m_lua, -2); + lua_pushinteger(m_lua, arg1); + if (lua_pcall(m_lua, 1, 0, 0) != LUA_OK) + { + SetFatalError(lua_tostring(m_lua, -1)); + lua_pop(m_lua, 1); + return false; + } + return true; +} + +bool LuaRulesEngine::CallHandler(const char* name, int arg1, int arg2) +{ + lua_getglobal(m_lua, "ppuc"); + lua_getfield(m_lua, -1, name); + if (!lua_isfunction(m_lua, -1)) + { + lua_pop(m_lua, 2); + return true; + } + + lua_remove(m_lua, -2); + lua_pushinteger(m_lua, arg1); + lua_pushinteger(m_lua, arg2); + if (lua_pcall(m_lua, 2, 0, 0) != LUA_OK) + { + SetFatalError(lua_tostring(m_lua, -1)); + lua_pop(m_lua, 1); + return false; + } + return true; +} + +void LuaRulesEngine::Update() +{ + std::lock_guard lock(m_mutex); + if (m_lua == nullptr || m_fatalError) + { + return; + } + + const uint64_t nowMs = GetNowMs(); + PruneHistoryLocked(nowMs); + PruneNamedStatesLocked(nowMs); + m_currentEvent = CurrentEvent{}; + CallHandler("onRulesUpdate"); +} + +LuaRulesEngine::SwitchProcessResult LuaRulesEngine::ProcessSwitchState(int number, uint8_t state) +{ + std::lock_guard lock(m_mutex); + SwitchProcessResult result; + const uint8_t normalized = state == 0 ? 0 : 1; + const uint8_t old = GetState(m_switchStates, number); + m_switchStates[number] = normalized; + m_currentEvent = CurrentEvent{EventType::Switch, number, old, normalized}; + + if (m_lua != nullptr && !m_fatalError) + { + CallHandler("onSwitchChanged", number, normalized); + } + + if (normalized == 0 && m_suppressedSwitchOpen.erase(number) > 0) + { + result.forwardToCpu = false; + } + else if (m_suppressedSwitchOpen.find(number) != m_suppressedSwitchOpen.end()) + { + result.forwardToCpu = false; + } + + m_currentEvent = CurrentEvent{}; + return result; +} + +void LuaRulesEngine::OnLampState(int number, uint8_t state) +{ + std::lock_guard lock(m_mutex); + const uint8_t normalized = state == 0 ? 0 : 1; + const uint8_t old = GetState(m_lampStates, number); + m_lampStates[number] = normalized; + m_currentEvent = CurrentEvent{EventType::Lamp, number, old, normalized}; + if (m_lua != nullptr && !m_fatalError) + { + CallHandler("onLampChanged", number, normalized); + } + m_currentEvent = CurrentEvent{}; +} + +void LuaRulesEngine::OnCoilState(int number, uint8_t state) +{ + std::lock_guard lock(m_mutex); + const uint8_t normalized = state == 0 ? 0 : 1; + const uint8_t old = GetState(m_coilStates, number); + m_coilStates[number] = normalized; + m_currentEvent = CurrentEvent{EventType::Coil, number, old, normalized}; + if (m_lua != nullptr && !m_fatalError) + { + CallHandler("onCoilChanged", number, normalized); + } + m_currentEvent = CurrentEvent{}; +} + +void LuaRulesEngine::SetCurrentBall(uint8_t currentBall) +{ + std::lock_guard lock(m_mutex); + if (m_currentBall == currentBall) + { + return; + } + m_currentBall = currentBall; + if (m_lua != nullptr && !m_fatalError) + { + CallHandler("onBallChanged", currentBall); + } +} + +void LuaRulesEngine::SetCurrentPlayer(uint8_t currentPlayer) +{ + std::lock_guard lock(m_mutex); + if (m_currentPlayer == currentPlayer) + { + return; + } + m_currentPlayer = currentPlayer; + if (m_lua != nullptr && !m_fatalError) + { + CallHandler("onPlayerChanged", currentPlayer); + } +} + +void LuaRulesEngine::SetAttractMode(bool attractMode) +{ + std::lock_guard lock(m_mutex); + m_attractMode = attractMode; +} + +bool LuaRulesEngine::HasFatalError() const +{ + std::lock_guard lock(m_mutex); + return m_fatalError; +} + +const std::string& LuaRulesEngine::GetFatalError() const +{ + return m_fatalErrorMessage; +} + +uint8_t LuaRulesEngine::GetState(const std::unordered_map& states, int number) const +{ + const auto it = states.find(number); + return it == states.end() ? 0 : it->second; +} + +bool LuaRulesEngine::IsRising(EventType type, int number) const +{ + return m_currentEvent.type == type && m_currentEvent.number == number && m_currentEvent.oldValue == 0 && + m_currentEvent.newValue != 0; +} + +bool LuaRulesEngine::IsFalling(EventType type, int number) const +{ + return m_currentEvent.type == type && m_currentEvent.number == number && m_currentEvent.oldValue != 0 && + m_currentEvent.newValue == 0; +} + +bool LuaRulesEngine::SwitchGroupStateActive(const std::string& name) const +{ + const auto it = m_switchGroups.find(name); + if (it == m_switchGroups.end()) + { + return false; + } + + for (const uint16_t number : it->second) + { + if (GetState(m_switchStates, number) != 0) + { + return true; + } + } + return false; +} + +bool LuaRulesEngine::SwitchGroupEdge(const std::string& name, bool rising) const +{ + if (m_currentEvent.type != EventType::Switch) + { + return false; + } + + const auto it = m_switchGroups.find(name); + if (it == m_switchGroups.end()) + { + return false; + } + + if (std::find(it->second.begin(), it->second.end(), static_cast(m_currentEvent.number)) == it->second.end()) + { + return false; + } + return rising ? IsRising(EventType::Switch, m_currentEvent.number) : IsFalling(EventType::Switch, m_currentEvent.number); +} + +bool LuaRulesEngine::HistoryContains(uint16_t id, uint32_t windowMs, uint64_t nowMs) const +{ + for (auto it = m_history.rbegin(); it != m_history.rend(); ++it) + { + if (it->player != m_currentPlayer || it->id != id) + { + continue; + } + if (windowMs == 0 || nowMs - it->timestampMs <= windowMs) + { + return true; + } + } + return false; +} + +bool LuaRulesEngine::SequenceOccurred(const std::vector& ids, uint32_t windowMs, uint64_t nowMs) const +{ + if (ids.empty()) + { + return false; + } + + size_t next = ids.size(); + uint64_t newest = 0; + uint64_t oldest = 0; + for (auto it = m_history.rbegin(); it != m_history.rend(); ++it) + { + if (it->player != m_currentPlayer) + { + continue; + } + if (it->id != ids[next - 1]) + { + continue; + } + if (newest == 0) + { + newest = it->timestampMs; + } + oldest = it->timestampMs; + next--; + if (next == 0) + { + return windowMs == 0 || (nowMs - newest <= windowMs && newest - oldest <= windowMs); + } + } + return false; +} + +bool LuaRulesEngine::NamedStateActive(const std::string& name, uint64_t nowMs) const +{ + const auto it = m_namedStates.find(name); + return it != m_namedStates.end() && (it->second == 0 || it->second > nowMs); +} + +uint64_t LuaRulesEngine::GetNowMs() const +{ + return static_cast( + std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()) + .count()); +} + +void LuaRulesEngine::PruneHistoryLocked(uint64_t nowMs) +{ + while (!m_history.empty() && nowMs - m_history.front().timestampMs > kHistoryRetentionMs) + { + m_history.pop_front(); + } +} + +void LuaRulesEngine::PruneNamedStatesLocked(uint64_t nowMs) +{ + for (auto it = m_namedStates.begin(); it != m_namedStates.end();) + { + if (it->second != 0 && it->second <= nowMs) + { + it = m_namedStates.erase(it); + } + else + { + ++it; + } + } +} + +void LuaRulesEngine::RecordTriggerLocked(uint16_t id, uint64_t nowMs) +{ + m_history.push_back(HistoryEntry{id, m_currentPlayer, nowMs}); + PruneHistoryLocked(nowMs); +} + +void LuaRulesEngine::SetFatalError(const std::string& error) +{ + m_fatalError = true; + m_fatalErrorMessage = error.empty() ? "Lua rules runtime error" : error; +} + +LuaRulesEngine* LuaRulesEngine::FromLua(lua_State* L) +{ + return static_cast(lua_touserdata(L, lua_upvalueindex(1))); +} + +int LuaRulesEngine::LuaSwitchState(lua_State* L) +{ + auto* engine = FromLua(L); + lua_pushboolean(L, engine->GetState(engine->m_switchStates, static_cast(luaL_checkinteger(L, 1))) != 0); + return 1; +} + +int LuaRulesEngine::LuaLampState(lua_State* L) +{ + auto* engine = FromLua(L); + lua_pushboolean(L, engine->GetState(engine->m_lampStates, static_cast(luaL_checkinteger(L, 1))) != 0); + return 1; +} + +int LuaRulesEngine::LuaCoilState(lua_State* L) +{ + auto* engine = FromLua(L); + lua_pushboolean(L, engine->GetState(engine->m_coilStates, static_cast(luaL_checkinteger(L, 1))) != 0); + return 1; +} + +int LuaRulesEngine::LuaCurrentBall(lua_State* L) +{ + lua_pushinteger(L, FromLua(L)->m_currentBall); + return 1; +} + +int LuaRulesEngine::LuaCurrentPlayer(lua_State* L) +{ + lua_pushinteger(L, FromLua(L)->m_currentPlayer); + return 1; +} + +int LuaRulesEngine::LuaAttractMode(lua_State* L) +{ + lua_pushboolean(L, FromLua(L)->m_attractMode); + return 1; +} + +int LuaRulesEngine::LuaSwitchClosing(lua_State* L) +{ + lua_pushboolean(L, FromLua(L)->IsRising(EventType::Switch, static_cast(luaL_checkinteger(L, 1)))); + return 1; +} + +int LuaRulesEngine::LuaSwitchOpening(lua_State* L) +{ + lua_pushboolean(L, FromLua(L)->IsFalling(EventType::Switch, static_cast(luaL_checkinteger(L, 1)))); + return 1; +} + +int LuaRulesEngine::LuaLampRising(lua_State* L) +{ + lua_pushboolean(L, FromLua(L)->IsRising(EventType::Lamp, static_cast(luaL_checkinteger(L, 1)))); + return 1; +} + +int LuaRulesEngine::LuaLampFalling(lua_State* L) +{ + lua_pushboolean(L, FromLua(L)->IsFalling(EventType::Lamp, static_cast(luaL_checkinteger(L, 1)))); + return 1; +} + +int LuaRulesEngine::LuaCoilRising(lua_State* L) +{ + lua_pushboolean(L, FromLua(L)->IsRising(EventType::Coil, static_cast(luaL_checkinteger(L, 1)))); + return 1; +} + +int LuaRulesEngine::LuaCoilFalling(lua_State* L) +{ + lua_pushboolean(L, FromLua(L)->IsFalling(EventType::Coil, static_cast(luaL_checkinteger(L, 1)))); + return 1; +} + +int LuaRulesEngine::LuaSwitchGroupState(lua_State* L) +{ + lua_pushboolean(L, FromLua(L)->SwitchGroupStateActive(luaL_checkstring(L, 1))); + return 1; +} + +int LuaRulesEngine::LuaSwitchGroupClosing(lua_State* L) +{ + lua_pushboolean(L, FromLua(L)->SwitchGroupEdge(luaL_checkstring(L, 1), true)); + return 1; +} + +int LuaRulesEngine::LuaSwitchGroupOpening(lua_State* L) +{ + lua_pushboolean(L, FromLua(L)->SwitchGroupEdge(luaL_checkstring(L, 1), false)); + return 1; +} + +int LuaRulesEngine::LuaSetState(lua_State* L) +{ + auto* engine = FromLua(L); + const std::string name = luaL_checkstring(L, 1); + const uint32_t durationMs = lua_gettop(L) >= 2 ? static_cast(luaL_checkinteger(L, 2)) : 0; + engine->m_namedStates[name] = durationMs == 0 ? 0 : engine->GetNowMs() + durationMs; + return 0; +} + +int LuaRulesEngine::LuaClearState(lua_State* L) +{ + FromLua(L)->m_namedStates.erase(luaL_checkstring(L, 1)); + return 0; +} + +int LuaRulesEngine::LuaStateActive(lua_State* L) +{ + auto* engine = FromLua(L); + lua_pushboolean(L, engine->NamedStateActive(luaL_checkstring(L, 1), engine->GetNowMs())); + return 1; +} + +int LuaRulesEngine::LuaTriggerHistory(lua_State* L) +{ + auto* engine = FromLua(L); + const uint16_t id = static_cast(luaL_checkinteger(L, 1)); + const uint32_t windowMs = lua_gettop(L) >= 2 ? static_cast(luaL_checkinteger(L, 2)) : 0; + lua_pushboolean(L, engine->HistoryContains(id, windowMs, engine->GetNowMs())); + return 1; +} + +int LuaRulesEngine::LuaTriggerSequence(lua_State* L) +{ + auto* engine = FromLua(L); + const int argc = lua_gettop(L); + const uint32_t windowMs = static_cast(luaL_checkinteger(L, 1)); + std::vector ids; + ids.reserve(argc > 1 ? static_cast(argc - 1) : 0); + for (int i = 2; i <= argc; ++i) + { + ids.push_back(static_cast(luaL_checkinteger(L, i))); + } + lua_pushboolean(L, engine->SequenceOccurred(ids, windowMs, engine->GetNowMs())); + return 1; +} + +int LuaRulesEngine::LuaPupTrigger(lua_State* L) +{ + auto* engine = FromLua(L); + const char* source = luaL_checkstring(L, 1); + const uint16_t id = static_cast(luaL_checkinteger(L, 2)); + const uint8_t value = lua_gettop(L) >= 3 ? static_cast(luaL_checkinteger(L, 3)) : 1; + if (engine->m_triggerCallback) + { + engine->m_triggerCallback(source != nullptr && source[0] != '\0' ? source[0] : 'P', id, value); + } + engine->RecordTriggerLocked(id, engine->GetNowMs()); + return 0; +} + +int LuaRulesEngine::LuaSpeech(lua_State* L) +{ + auto* engine = FromLua(L); + const char* text = luaL_checkstring(L, 1); + if (engine->m_speechCallback && text != nullptr && text[0] != '\0') + { + engine->m_speechCallback(text); + } + return 0; +} + +int LuaRulesEngine::LuaEffectTrigger(lua_State* L) +{ + auto* engine = FromLua(L); + const uint16_t id = static_cast(luaL_checkinteger(L, 1)); + const uint8_t value = lua_gettop(L) >= 2 ? static_cast(luaL_checkinteger(L, 2)) : 1; + if (engine->m_triggerCallback) + { + engine->m_triggerCallback(kBoardEffectTriggerSource, id, value); + } + engine->RecordTriggerLocked(id, engine->GetNowMs()); + return 0; +} + +int LuaRulesEngine::LuaSuppressSwitch(lua_State* L) +{ + auto* engine = FromLua(L); + const int number = static_cast(luaL_checkinteger(L, 1)); + if (engine->m_currentEvent.type == EventType::Switch && engine->m_currentEvent.number == number && + engine->m_currentEvent.newValue != 0) + { + engine->m_suppressedSwitchOpen.insert(number); + } + return 0; +} + +int LuaRulesEngine::LuaPulseCoil(lua_State* L) +{ + auto* engine = FromLua(L); + if (engine->m_actionCallback) + { + engine->m_actionCallback(RulesAction{RulesActionType::PulseCoil, + static_cast(luaL_checkinteger(L, 1)), + static_cast(luaL_checkinteger(L, 2)), + 0, + 0}); + } + return 0; +} + +int LuaRulesEngine::LuaBlinkLamp(lua_State* L) +{ + auto* engine = FromLua(L); + if (engine->m_actionCallback) + { + engine->m_actionCallback(RulesAction{RulesActionType::StartBlinkLamp, + static_cast(luaL_checkinteger(L, 1)), + 0, + static_cast(luaL_checkinteger(L, 2)), + static_cast(luaL_checkinteger(L, 3))}); + } + return 0; +} + +int LuaRulesEngine::LuaStopBlinkLamp(lua_State* L) +{ + auto* engine = FromLua(L); + if (engine->m_actionCallback) + { + engine->m_actionCallback(RulesAction{RulesActionType::StopBlinkLamp, static_cast(luaL_checkinteger(L, 1)), 0, 0, 0}); + } + return 0; +} diff --git a/src/LuaRulesEngine.h b/src/LuaRulesEngine.h new file mode 100644 index 0000000..867b292 --- /dev/null +++ b/src/LuaRulesEngine.h @@ -0,0 +1,144 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "RulesAction.h" + +struct lua_State; + +class LuaRulesEngine +{ + public: + using TriggerCallback = std::function; + using SpeechCallback = std::function; + using ActionCallback = std::function; + + struct SwitchProcessResult + { + bool forwardToCpu = true; + }; + + LuaRulesEngine(); + ~LuaRulesEngine(); + + LuaRulesEngine(const LuaRulesEngine&) = delete; + LuaRulesEngine& operator=(const LuaRulesEngine&) = delete; + + void SetDebug(bool debug); + void SetTriggerCallback(TriggerCallback callback); + void SetSpeechCallback(SpeechCallback callback); + void SetActionCallback(ActionCallback callback); + void SetSwitchGroups(const std::unordered_map>& switchGroups); + bool LoadScript(const char* path, std::string& error); + void Update(); + + SwitchProcessResult ProcessSwitchState(int number, uint8_t state); + void OnLampState(int number, uint8_t state); + void OnCoilState(int number, uint8_t state); + void SetCurrentBall(uint8_t currentBall); + void SetCurrentPlayer(uint8_t currentPlayer); + void SetAttractMode(bool attractMode); + + bool HasFatalError() const; + const std::string& GetFatalError() const; + + private: + enum class EventType + { + None, + Switch, + Lamp, + Coil + }; + + struct CurrentEvent + { + EventType type = EventType::None; + int number = 0; + uint8_t oldValue = 0; + uint8_t newValue = 0; + }; + + struct HistoryEntry + { + uint16_t id = 0; + uint8_t player = 0; + uint64_t timestampMs = 0; + }; + + uint8_t GetState(const std::unordered_map& states, int number) const; + bool IsRising(EventType type, int number) const; + bool IsFalling(EventType type, int number) const; + bool SwitchGroupStateActive(const std::string& name) const; + bool SwitchGroupEdge(const std::string& name, bool rising) const; + bool HistoryContains(uint16_t id, uint32_t windowMs, uint64_t nowMs) const; + bool SequenceOccurred(const std::vector& ids, uint32_t windowMs, uint64_t nowMs) const; + bool NamedStateActive(const std::string& name, uint64_t nowMs) const; + uint64_t GetNowMs() const; + void PruneHistoryLocked(uint64_t nowMs); + void PruneNamedStatesLocked(uint64_t nowMs); + void RecordTriggerLocked(uint16_t id, uint64_t nowMs); + + bool InitializeLua(std::string& error); + void RegisterApi(); + bool CallHandler(const char* name); + bool CallHandler(const char* name, int arg1); + bool CallHandler(const char* name, int arg1, int arg2); + void SetFatalError(const std::string& error); + + static LuaRulesEngine* FromLua(lua_State* L); + static int LuaSwitchState(lua_State* L); + static int LuaLampState(lua_State* L); + static int LuaCoilState(lua_State* L); + static int LuaCurrentBall(lua_State* L); + static int LuaCurrentPlayer(lua_State* L); + static int LuaAttractMode(lua_State* L); + static int LuaSwitchClosing(lua_State* L); + static int LuaSwitchOpening(lua_State* L); + static int LuaLampRising(lua_State* L); + static int LuaLampFalling(lua_State* L); + static int LuaCoilRising(lua_State* L); + static int LuaCoilFalling(lua_State* L); + static int LuaSwitchGroupState(lua_State* L); + static int LuaSwitchGroupClosing(lua_State* L); + static int LuaSwitchGroupOpening(lua_State* L); + static int LuaSetState(lua_State* L); + static int LuaClearState(lua_State* L); + static int LuaStateActive(lua_State* L); + static int LuaTriggerHistory(lua_State* L); + static int LuaTriggerSequence(lua_State* L); + static int LuaPupTrigger(lua_State* L); + static int LuaSpeech(lua_State* L); + static int LuaEffectTrigger(lua_State* L); + static int LuaSuppressSwitch(lua_State* L); + static int LuaPulseCoil(lua_State* L); + static int LuaBlinkLamp(lua_State* L); + static int LuaStopBlinkLamp(lua_State* L); + + lua_State* m_lua = nullptr; + std::unordered_map m_switchStates; + std::unordered_map m_lampStates; + std::unordered_map m_coilStates; + std::unordered_map m_namedStates; + std::unordered_map> m_switchGroups; + std::unordered_set m_suppressedSwitchOpen; + std::deque m_history; + CurrentEvent m_currentEvent; + TriggerCallback m_triggerCallback; + SpeechCallback m_speechCallback; + ActionCallback m_actionCallback; + uint8_t m_currentBall = 0; + uint8_t m_currentPlayer = 0; + bool m_attractMode = true; + bool m_debug = false; + bool m_fatalError = false; + std::string m_fatalErrorMessage; + mutable std::mutex m_mutex; +}; diff --git a/src/PUPTriggerEngine.cpp b/src/PUPTriggerEngine.cpp deleted file mode 100644 index 4792dc3..0000000 --- a/src/PUPTriggerEngine.cpp +++ /dev/null @@ -1,1754 +0,0 @@ -#include "PUPTriggerEngine.h" - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "io-boards/Event.h" - -namespace -{ -constexpr uint32_t kHistoryRetentionMs = 60000; -constexpr char kSilentTriggerSource = 'S'; - -std::string Trim(const std::string& input) -{ - size_t start = 0; - while (start < input.size() && ::isspace(static_cast(input[start]))) - { - start++; - } - - size_t end = input.size(); - while (end > start && ::isspace(static_cast(input[end - 1]))) - { - end--; - } - - return input.substr(start, end - start); -} - -bool ParseUInt16Token(const std::string& token, uint16_t& out) -{ - if (token.empty()) - { - return false; - } - - int value = 0; - for (char c : token) - { - if (!::isdigit(static_cast(c))) - { - return false; - } - - value = (value * 10) + (c - '0'); - if (value > 65535) - { - return false; - } - } - - out = static_cast(value); - return true; -} - -bool ParseTriggerIdToken(const std::string& token, uint16_t& out) -{ - if (ParseUInt16Token(token, out)) - { - return true; - } - - if (token.empty()) - { - return false; - } - - for (char c : token) - { - const unsigned char uc = static_cast(c); - if (!(::isalnum(uc) || c == '_' || c == '-' || c == '.')) - { - return false; - } - } - - out = HashNamedTriggerId(token.c_str()); - return true; -} - -bool ParseUInt8Token(const std::string& token, uint8_t& out) -{ - if (token.empty()) - { - return false; - } - - int value = 0; - for (char c : token) - { - if (!::isdigit(static_cast(c))) - { - return false; - } - - value = (value * 10) + (c - '0'); - if (value > 255) - { - return false; - } - } - - out = static_cast(value); - return true; -} - -bool ParseUInt32Token(const std::string& token, uint32_t& out) -{ - if (token.empty()) - { - return false; - } - - uint64_t value = 0; - for (char c : token) - { - if (!::isdigit(static_cast(c))) - { - return false; - } - - value = (value * 10) + static_cast(c - '0'); - if (value > 4294967295ULL) - { - return false; - } - } - - out = static_cast(value); - return true; -} - -bool IsNameToken(const std::string& token) -{ - if (token.empty()) - { - return false; - } - - for (char c : token) - { - const unsigned char uc = static_cast(c); - if (!(::isalnum(uc) || c == '_' || c == '-' || c == '.')) - { - return false; - } - } - return true; -} - -class TriggerExpressionParser -{ - public: - explicit TriggerExpressionParser(const std::string& input) : m_input(input), m_position(0) {} - - std::unique_ptr Parse() - { - auto root = ParseOr(); - if (!root) - { - return nullptr; - } - - SkipWhitespace(); - if (!IsEnd()) - { - m_error = "unexpected trailing token"; - return nullptr; - } - - return root; - } - - const std::string& Error() const { return m_error; } - - private: - std::unique_ptr ParseOr() - { - auto left = ParseAnd(); - if (!left) - { - return nullptr; - } - - while (true) - { - SkipWhitespace(); - if (!Match("||")) - { - break; - } - - auto right = ParseAnd(); - if (!right) - { - m_error = "expected expression after ||"; - return nullptr; - } - - auto node = std::make_unique(); - node->type = PUPTriggerEngine::ExprNodeType::Or; - node->left = std::move(left); - node->right = std::move(right); - left = std::move(node); - } - - return left; - } - - std::unique_ptr ParseAnd() - { - auto left = ParseUnary(); - if (!left) - { - return nullptr; - } - - while (true) - { - SkipWhitespace(); - if (!Match("&&")) - { - break; - } - - auto right = ParseUnary(); - if (!right) - { - m_error = "expected expression after &&"; - return nullptr; - } - - auto node = std::make_unique(); - node->type = PUPTriggerEngine::ExprNodeType::And; - node->left = std::move(left); - node->right = std::move(right); - left = std::move(node); - } - - return left; - } - - std::unique_ptr ParseUnary() - { - SkipWhitespace(); - if (Match("!")) - { - auto operand = ParseUnary(); - if (!operand) - { - m_error = "expected expression after !"; - return nullptr; - } - - auto node = std::make_unique(); - node->type = PUPTriggerEngine::ExprNodeType::Not; - node->left = std::move(operand); - return node; - } - - return ParsePrimary(); - } - - std::unique_ptr ParsePrimary() - { - SkipWhitespace(); - if (Match("(")) - { - auto expression = ParseOr(); - if (!expression) - { - return nullptr; - } - - SkipWhitespace(); - if (!Match(")")) - { - m_error = "missing closing )"; - return nullptr; - } - - return expression; - } - - std::string identifier = ParseIdentifier(); - if (identifier.empty()) - { - m_error = "expected expression"; - return nullptr; - } - - for (char& character : identifier) - { - character = static_cast(::tolower(character)); - } - - if (identifier == "true" || identifier == "false") - { - auto node = std::make_unique(); - node->type = PUPTriggerEngine::ExprNodeType::Literal; - node->literal = identifier == "true"; - return node; - } - - if (identifier == "attract") - { - SkipWhitespace(); - if (Match("(")) - { - SkipWhitespace(); - if (!Match(")")) - { - m_error = "missing ) in function 'attract'"; - return nullptr; - } - } - - auto node = std::make_unique(); - node->type = PUPTriggerEngine::ExprNodeType::AttractState; - return node; - } - - PUPTriggerEngine::ExprNodeType functionType; - if (identifier == "ball") - { - functionType = PUPTriggerEngine::ExprNodeType::BallState; - } - else if (identifier == "player") - { - functionType = PUPTriggerEngine::ExprNodeType::PlayerState; - } - else if (identifier == "history") - { - return ParseHistoryFunction(); - } - else if (identifier == "sequence") - { - return ParseSequenceFunction(); - } - else if (identifier == "state") - { - return ParseNamedStateFunction(); - } - else if (identifier == "switch") - { - functionType = PUPTriggerEngine::ExprNodeType::SwitchState; - } - else if (identifier == "switch_group") - { - return ParseGroupFunction(PUPTriggerEngine::ExprNodeType::SwitchGroupState, identifier); - } - else if (identifier == "lamp") - { - functionType = PUPTriggerEngine::ExprNodeType::LampState; - } - else if (identifier == "coil") - { - functionType = PUPTriggerEngine::ExprNodeType::CoilState; - } - else if (identifier == "switch_rising") - { - return ParseSwitchNumberListFunction(PUPTriggerEngine::ExprNodeType::SwitchRising, identifier); - } - else if (identifier == "switch_falling") - { - return ParseSwitchNumberListFunction(PUPTriggerEngine::ExprNodeType::SwitchFalling, identifier); - } - else if (identifier == "switch_rising_group") - { - return ParseGroupFunction(PUPTriggerEngine::ExprNodeType::SwitchRisingGroup, identifier); - } - else if (identifier == "switch_falling_group") - { - return ParseGroupFunction(PUPTriggerEngine::ExprNodeType::SwitchFallingGroup, identifier); - } - else if (identifier == "lamp_rising") - { - functionType = PUPTriggerEngine::ExprNodeType::LampRising; - } - else if (identifier == "lamp_falling") - { - functionType = PUPTriggerEngine::ExprNodeType::LampFalling; - } - else if (identifier == "coil_rising") - { - functionType = PUPTriggerEngine::ExprNodeType::CoilRising; - } - else if (identifier == "coil_falling") - { - functionType = PUPTriggerEngine::ExprNodeType::CoilFalling; - } - else - { - m_error = "unknown identifier '" + identifier + "'"; - return nullptr; - } - - SkipWhitespace(); - if (!Match("(")) - { - m_error = "missing ( after function '" + identifier + "'"; - return nullptr; - } - - int number = 0; - if (!ParseUnsignedNumber(number)) - { - m_error = "expected numeric argument in function '" + identifier + "'"; - return nullptr; - } - - SkipWhitespace(); - if (!Match(")")) - { - m_error = "missing ) in function '" + identifier + "'"; - return nullptr; - } - - auto node = std::make_unique(); - node->type = functionType; - node->number = number; - node->numbers.push_back(number); - return node; - } - - std::unique_ptr ParseNamedStateFunction() - { - SkipWhitespace(); - if (!Match("(")) - { - m_error = "missing ( after function 'state'"; - return nullptr; - } - - const std::string name = ParseArgumentToken(); - if (name.empty()) - { - m_error = "expected state name in function 'state'"; - return nullptr; - } - - SkipWhitespace(); - if (!Match(")")) - { - m_error = "missing ) in function 'state'"; - return nullptr; - } - - auto node = std::make_unique(); - node->type = PUPTriggerEngine::ExprNodeType::NamedState; - node->name = name; - return node; - } - - std::unique_ptr ParseGroupFunction( - PUPTriggerEngine::ExprNodeType type, const std::string& identifier) - { - SkipWhitespace(); - if (!Match("(")) - { - m_error = "missing ( after function '" + identifier + "'"; - return nullptr; - } - - const std::string name = ParseArgumentToken(); - if (name.empty()) - { - m_error = "expected group name in function '" + identifier + "'"; - return nullptr; - } - - SkipWhitespace(); - if (!Match(")")) - { - m_error = "missing ) in function '" + identifier + "'"; - return nullptr; - } - - auto node = std::make_unique(); - node->type = type; - node->name = name; - return node; - } - - std::unique_ptr ParseSwitchNumberListFunction( - PUPTriggerEngine::ExprNodeType type, const std::string& identifier) - { - SkipWhitespace(); - if (!Match("(")) - { - m_error = "missing ( after function '" + identifier + "'"; - return nullptr; - } - - std::vector numbers; - while (true) - { - int number = 0; - if (!ParseUnsignedNumber(number)) - { - m_error = "expected numeric argument in function '" + identifier + "'"; - return nullptr; - } - numbers.push_back(number); - - SkipWhitespace(); - if (!Match(",")) - { - break; - } - SkipWhitespace(); - } - - if (!Match(")")) - { - m_error = "missing ) in function '" + identifier + "'"; - return nullptr; - } - - auto node = std::make_unique(); - node->type = type; - node->number = numbers.empty() ? 0 : numbers.front(); - node->numbers = std::move(numbers); - return node; - } - - std::unique_ptr ParseHistoryFunction() - { - SkipWhitespace(); - if (!Match("(")) - { - m_error = "missing ( after function 'history'"; - return nullptr; - } - - std::string triggerToken = ParseArgumentToken(); - uint16_t triggerId = 0; - if (!ParseTriggerIdToken(triggerToken, triggerId)) - { - m_error = "expected trigger id in function 'history'"; - return nullptr; - } - - uint32_t windowMs = 0; - SkipWhitespace(); - if (Match(",")) - { - int window = 0; - if (!ParseUnsignedNumber(window)) - { - m_error = "expected numeric window in function 'history'"; - return nullptr; - } - windowMs = static_cast(window); - SkipWhitespace(); - } - - if (!Match(")")) - { - m_error = "missing ) in function 'history'"; - return nullptr; - } - - auto node = std::make_unique(); - node->type = PUPTriggerEngine::ExprNodeType::HistoryState; - node->triggerIds.push_back(triggerId); - node->windowMs = windowMs; - return node; - } - - std::unique_ptr ParseSequenceFunction() - { - SkipWhitespace(); - if (!Match("(")) - { - m_error = "missing ( after function 'sequence'"; - return nullptr; - } - - int window = 0; - if (!ParseUnsignedNumber(window)) - { - m_error = "expected numeric window in function 'sequence'"; - return nullptr; - } - - std::vector triggerIds; - while (true) - { - SkipWhitespace(); - if (!Match(",")) - { - break; - } - - std::string triggerToken = ParseArgumentToken(); - uint16_t triggerId = 0; - if (!ParseTriggerIdToken(triggerToken, triggerId)) - { - m_error = "expected trigger id in function 'sequence'"; - return nullptr; - } - triggerIds.push_back(triggerId); - } - - if (triggerIds.size() < 2) - { - m_error = "function 'sequence' requires at least two trigger ids"; - return nullptr; - } - - SkipWhitespace(); - if (!Match(")")) - { - m_error = "missing ) in function 'sequence'"; - return nullptr; - } - - auto node = std::make_unique(); - node->type = PUPTriggerEngine::ExprNodeType::SequenceState; - node->windowMs = static_cast(window); - node->triggerIds = std::move(triggerIds); - return node; - } - - std::string ParseIdentifier() - { - SkipWhitespace(); - const size_t start = m_position; - while (!IsEnd()) - { - const char c = m_input[m_position]; - if (::isalnum(static_cast(c)) || c == '_') - { - m_position++; - } - else - { - break; - } - } - - if (m_position == start) - { - return ""; - } - - return m_input.substr(start, m_position - start); - } - - std::string ParseArgumentToken() - { - SkipWhitespace(); - const size_t start = m_position; - while (!IsEnd()) - { - const char c = m_input[m_position]; - const unsigned char uc = static_cast(c); - if (::isalnum(uc) || c == '_' || c == '-' || c == '.') - { - m_position++; - } - else - { - break; - } - } - - if (m_position == start) - { - return ""; - } - - return m_input.substr(start, m_position - start); - } - - bool ParseUnsignedNumber(int& out) - { - SkipWhitespace(); - if (IsEnd() || !::isdigit(static_cast(m_input[m_position]))) - { - return false; - } - - int value = 0; - while (!IsEnd() && ::isdigit(static_cast(m_input[m_position]))) - { - value = (value * 10) + (m_input[m_position] - '0'); - m_position++; - } - - out = value; - return true; - } - - bool Match(const char* token) - { - size_t idx = 0; - while (token[idx] != '\0') - { - if (m_position + idx >= m_input.size() || m_input[m_position + idx] != token[idx]) - { - return false; - } - idx++; - } - - m_position += idx; - return true; - } - - void SkipWhitespace() - { - while (!IsEnd() && ::isspace(static_cast(m_input[m_position]))) - { - m_position++; - } - } - - bool IsEnd() const { return m_position >= m_input.size(); } - - std::string m_input; - size_t m_position; - std::string m_error; -}; -} // namespace - -bool PUPTriggerEngine::LoadRules(const char* path, std::string& error) -{ - std::ifstream input(path); - if (!input.is_open()) - { - error = "Unable to open PUP trigger file"; - return false; - } - - std::vector loadedRules; - std::string line; - size_t lineNo = 0; - - while (std::getline(input, line)) - { - lineNo++; - const size_t comment = line.find('#'); - if (comment != std::string::npos) - { - line = line.substr(0, comment); - } - - line = Trim(line); - if (line.empty()) - { - continue; - } - - const size_t separator = line.find(':'); - if (separator == std::string::npos) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": missing ':' separator."; - return false; - } - - const std::string triggerPart = Trim(line.substr(0, separator)); - const std::string expressionPart = Trim(line.substr(separator + 1)); - if (triggerPart.empty() || expressionPart.empty()) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": missing trigger or expression."; - return false; - } - - std::istringstream triggerStream(triggerPart); - std::vector tokens; - std::string token; - while (triggerStream >> token) - { - tokens.push_back(token); - } - - if (tokens.size() < 2) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + - ": expected ' [value] : '."; - return false; - } - - const std::string& sourceToken = tokens[0]; - const std::string& idToken = tokens[1]; - - if (sourceToken.size() != 1) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": source must be a single character."; - return false; - } - - Rule rule; - rule.source = sourceToken[0]; - rule.line = lineNo; - - if (!ParseTriggerIdToken(idToken, rule.id)) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": id must be uint16 or a named token."; - return false; - } - - bool hasValue = false; - for (size_t i = 2; i < tokens.size(); i++) - { - const std::string& option = tokens[i]; - if (option.rfind("cooldown=", 0) == 0) - { - const std::string msToken = option.substr(9); - if (!ParseUInt32Token(msToken, rule.cooldownMs)) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": cooldown must be uint32 milliseconds."; - return false; - } - continue; - } - - if (option.rfind("delay=", 0) == 0) - { - const std::string msToken = option.substr(6); - if (!ParseUInt32Token(msToken, rule.delayMs)) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": delay must be uint32 milliseconds."; - return false; - } - continue; - } - - if (option.rfind("set_player=", 0) == 0) - { - uint8_t player = 0; - if (!ParseUInt8Token(option.substr(11), player)) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": set_player must be uint8."; - return false; - } - rule.setPlayer = player; - continue; - } - - if (option.rfind("clear_player_history=", 0) == 0) - { - uint8_t player = 0; - if (!ParseUInt8Token(option.substr(21), player)) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + - ": clear_player_history must be uint8."; - return false; - } - rule.clearPlayerHistory = player; - continue; - } - - if (option.rfind("set_state=", 0) == 0) - { - const std::string name = option.substr(10); - if (!IsNameToken(name)) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": set_state must be a name token."; - return false; - } - rule.setState = name; - continue; - } - - if (option.rfind("clear_state=", 0) == 0) - { - const std::string name = option.substr(12); - if (!IsNameToken(name)) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": clear_state must be a name token."; - return false; - } - rule.clearState = name; - continue; - } - - if (option.rfind("state_ms=", 0) == 0) - { - if (!ParseUInt32Token(option.substr(9), rule.stateMs)) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": state_ms must be uint32 milliseconds."; - return false; - } - continue; - } - - if (option.rfind("suppress_switch=", 0) == 0) - { - uint16_t number = 0; - if (!ParseUInt16Token(option.substr(16), number)) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": suppress_switch must be uint16."; - return false; - } - rule.suppressSwitch = static_cast(number); - continue; - } - - if (option.rfind("pulse_coil=", 0) == 0) - { - uint16_t number = 0; - if (!ParseUInt16Token(option.substr(11), number)) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": pulse_coil must be uint16."; - return false; - } - rule.pulseCoil = static_cast(number); - continue; - } - - if (option.rfind("pulse_ms=", 0) == 0) - { - if (!ParseUInt32Token(option.substr(9), rule.pulseMs)) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": pulse_ms must be uint32 milliseconds."; - return false; - } - continue; - } - - if (option.rfind("blink_lamp=", 0) == 0) - { - uint16_t number = 0; - if (!ParseUInt16Token(option.substr(11), number)) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": blink_lamp must be uint16."; - return false; - } - rule.blinkLamp = static_cast(number); - continue; - } - - if (option.rfind("blink_on_ms=", 0) == 0) - { - if (!ParseUInt32Token(option.substr(12), rule.blinkOnMs) || rule.blinkOnMs == 0) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": blink_on_ms must be a positive uint32."; - return false; - } - continue; - } - - if (option.rfind("blink_off_ms=", 0) == 0) - { - if (!ParseUInt32Token(option.substr(13), rule.blinkOffMs) || rule.blinkOffMs == 0) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": blink_off_ms must be a positive uint32."; - return false; - } - continue; - } - - if (!hasValue) - { - if (!ParseUInt8Token(option, rule.value)) - { - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": value must be uint8."; - return false; - } - hasValue = true; - continue; - } - - error = "Invalid PUP trigger line " + std::to_string(lineNo) + ": unknown option '" + option + "'."; - return false; - } - - TriggerExpressionParser parser(expressionPart); - rule.expression = parser.Parse(); - if (!rule.expression) - { - error = "Invalid PUP trigger expression on line " + std::to_string(lineNo) + ": " + parser.Error(); - return false; - } - - rule.eventTriggered = UsesEventEdges(rule.expression.get()); - - loadedRules.push_back(std::move(rule)); - } - - std::lock_guard lock(m_mutex); - m_rules = std::move(loadedRules); - m_switchStates.clear(); - m_lampStates.clear(); - m_coilStates.clear(); - m_namedStates.clear(); - m_suppressedSwitchOpen.clear(); - m_history.clear(); - m_currentBall = 0; - m_currentPlayer = 0; - return true; -} - -void PUPTriggerEngine::SetDebug(bool debug) -{ - std::lock_guard lock(m_mutex); - m_debug = debug; -} - -void PUPTriggerEngine::SetTriggerCallback(TriggerCallback callback) -{ - std::lock_guard lock(m_mutex); - m_triggerCallback = std::move(callback); -} - -void PUPTriggerEngine::SetActionCallback(ActionCallback callback) -{ - std::lock_guard lock(m_mutex); - m_actionCallback = std::move(callback); -} - -void PUPTriggerEngine::SetSwitchGroups(const std::unordered_map>& switchGroups) -{ - std::lock_guard lock(m_mutex); - m_switchGroups = switchGroups; -} - -void PUPTriggerEngine::SetAttractMode(bool attractMode) -{ - std::lock_guard lock(m_mutex); - if (attractMode && !m_attractMode) - { - m_history.clear(); - m_currentBall = 0; - m_currentPlayer = 0; - } - m_attractMode = attractMode; -} - -size_t PUPTriggerEngine::GetRuleCount() const -{ - std::lock_guard lock(m_mutex); - return m_rules.size(); -} - -void PUPTriggerEngine::Update() -{ - const uint64_t nowMs = GetNowMs(); - std::vector matched; - std::vector actions; - TriggerCallback triggerCallback; - ActionCallback actionCallback; - bool debug = false; - { - std::lock_guard lock(m_mutex); - PruneHistoryLocked(nowMs); - PruneNamedStatesLocked(nowMs); - const TriggerEvent timerEvent = {EventType::Timer, 0, 0, 0}; - CollectBlinkActions(nowMs, timerEvent, actions); - CollectDueTriggers(nowMs, matched, actions); - RecordMatchedTriggers(matched, nowMs); - triggerCallback = m_triggerCallback; - actionCallback = m_actionCallback; - debug = m_debug; - } - - DispatchMatchesAndActions(matched, actions, triggerCallback, actionCallback, debug); -} - -uint8_t PUPTriggerEngine::GetState(const std::unordered_map& states, int number) const -{ - const auto it = states.find(number); - return it == states.end() ? 0 : it->second; -} - -bool PUPTriggerEngine::EvaluateExpression(const ExprNode* node, const TriggerEvent& event) const -{ - if (!node) - { - return false; - } - - switch (node->type) - { - case ExprNodeType::Literal: - return node->literal; - case ExprNodeType::AttractState: - return m_attractMode; - case ExprNodeType::Not: - return !EvaluateExpression(node->left.get(), event); - case ExprNodeType::And: - return EvaluateExpression(node->left.get(), event) && EvaluateExpression(node->right.get(), event); - case ExprNodeType::Or: - return EvaluateExpression(node->left.get(), event) || EvaluateExpression(node->right.get(), event); - case ExprNodeType::BallState: - return m_currentBall == static_cast(node->number); - case ExprNodeType::PlayerState: - return m_currentPlayer == static_cast(node->number); - case ExprNodeType::HistoryState: - return !node->triggerIds.empty() && HistoryContains(node->triggerIds[0], node->windowMs, GetNowMs()); - case ExprNodeType::SequenceState: - return SequenceOccurred(node->triggerIds, node->windowMs, GetNowMs()); - case ExprNodeType::SwitchState: - if (!node->numbers.empty()) - { - for (int number : node->numbers) - { - if (GetState(m_switchStates, number) != 0) - { - return true; - } - } - return false; - } - return GetState(m_switchStates, node->number) != 0; - case ExprNodeType::LampState: - return GetState(m_lampStates, node->number) != 0; - case ExprNodeType::CoilState: - return GetState(m_coilStates, node->number) != 0; - case ExprNodeType::NamedState: - return NamedStateActive(node->name, GetNowMs()); - case ExprNodeType::SwitchGroupState: - return SwitchGroupStateActive(node->name); - case ExprNodeType::SwitchRising: - return event.type == EventType::Switch && - (node->numbers.empty() || - std::find(node->numbers.begin(), node->numbers.end(), event.number) != node->numbers.end()) && - event.oldValue == 0 && event.newValue != 0; - case ExprNodeType::SwitchFalling: - return event.type == EventType::Switch && - (node->numbers.empty() || - std::find(node->numbers.begin(), node->numbers.end(), event.number) != node->numbers.end()) && - event.oldValue != 0 && event.newValue == 0; - case ExprNodeType::SwitchRisingGroup: - return event.type == EventType::Switch && event.oldValue == 0 && event.newValue != 0 && - SwitchGroupContainsEvent(node->name, event); - case ExprNodeType::SwitchFallingGroup: - return event.type == EventType::Switch && event.oldValue != 0 && event.newValue == 0 && - SwitchGroupContainsEvent(node->name, event); - case ExprNodeType::LampRising: - return event.type == EventType::Lamp && event.number == node->number && event.oldValue == 0 && - event.newValue != 0; - case ExprNodeType::LampFalling: - return event.type == EventType::Lamp && event.number == node->number && event.oldValue != 0 && - event.newValue == 0; - case ExprNodeType::CoilRising: - return event.type == EventType::Coil && event.number == node->number && event.oldValue == 0 && - event.newValue != 0; - case ExprNodeType::CoilFalling: - return event.type == EventType::Coil && event.number == node->number && event.oldValue != 0 && - event.newValue == 0; - } - - return false; -} - -bool PUPTriggerEngine::HistoryContains(const uint16_t triggerId, const uint32_t windowMs, const uint64_t nowMs) const -{ - for (auto it = m_history.rbegin(); it != m_history.rend(); ++it) - { - if (windowMs > 0 && nowMs > it->timestampMs && nowMs - it->timestampMs > static_cast(windowMs)) - { - break; - } - if (m_currentPlayer != 0 && it->player != m_currentPlayer) - { - continue; - } - if (it->id == triggerId) - { - return true; - } - } - return false; -} - -bool PUPTriggerEngine::SequenceOccurred(const std::vector& triggerIds, const uint32_t windowMs, - const uint64_t nowMs) const -{ - if (triggerIds.empty()) - { - return false; - } - - size_t matchedCount = 0; - const uint64_t earliestAllowedMs = - windowMs == 0 || nowMs < static_cast(windowMs) ? 0 : nowMs - static_cast(windowMs); - - for (const auto& entry : m_history) - { - if (entry.timestampMs < earliestAllowedMs) - { - continue; - } - if (m_currentPlayer != 0 && entry.player != m_currentPlayer) - { - continue; - } - if (entry.id == triggerIds[matchedCount]) - { - matchedCount++; - if (matchedCount == triggerIds.size()) - { - return true; - } - } - } - - return false; -} - -bool PUPTriggerEngine::NamedStateActive(const std::string& name, uint64_t nowMs) const -{ - const auto it = m_namedStates.find(name); - if (it == m_namedStates.end()) - { - return false; - } - return it->second == 0 || nowMs < it->second; -} - -bool PUPTriggerEngine::SwitchGroupStateActive(const std::string& name) const -{ - const auto it = m_switchGroups.find(name); - if (it == m_switchGroups.end()) - { - return false; - } - - for (const uint16_t number : it->second) - { - if (GetState(m_switchStates, static_cast(number)) != 0) - { - return true; - } - } - return false; -} - -bool PUPTriggerEngine::SwitchGroupContainsEvent(const std::string& name, const TriggerEvent& event) const -{ - const auto it = m_switchGroups.find(name); - if (it == m_switchGroups.end()) - { - return false; - } - - return std::find(it->second.begin(), it->second.end(), static_cast(event.number)) != it->second.end(); -} - -bool PUPTriggerEngine::UsesEventEdges(const ExprNode* node) const -{ - if (!node) - { - return false; - } - - switch (node->type) - { - case ExprNodeType::SwitchRising: - case ExprNodeType::SwitchFalling: - case ExprNodeType::SwitchRisingGroup: - case ExprNodeType::SwitchFallingGroup: - case ExprNodeType::LampRising: - case ExprNodeType::LampFalling: - case ExprNodeType::CoilRising: - case ExprNodeType::CoilFalling: - return true; - default: - return UsesEventEdges(node->left.get()) || UsesEventEdges(node->right.get()); - } -} - -uint64_t PUPTriggerEngine::GetNowMs() const -{ - return static_cast( - std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()) - .count()); -} - -bool PUPTriggerEngine::CanTriggerNow(const Rule& rule, uint64_t nowMs) const -{ - return rule.cooldownMs == 0 || nowMs >= (rule.lastTriggeredMs + static_cast(rule.cooldownMs)); -} - -void PUPTriggerEngine::CollectDueTriggers(uint64_t nowMs, std::vector& matched, - std::vector& actions) -{ - for (auto& rule : m_rules) - { - if (!rule.pending || nowMs < rule.pendingTriggerMs) - { - continue; - } - - if (!CanTriggerNow(rule, nowMs)) - { - continue; - } - - rule.lastTriggeredMs = nowMs; - rule.pending = false; - rule.pendingTriggerMs = 0; - ApplyRuleSideEffects(rule, nowMs, actions); - matched.push_back({rule.source, rule.id, rule.value, rule.line, - rule.setPlayer != 0 ? rule.setPlayer : m_currentPlayer, m_currentBall}); - } -} - -void PUPTriggerEngine::CollectBlinkActions(uint64_t nowMs, const TriggerEvent& event, std::vector& actions) -{ - for (auto& rule : m_rules) - { - if (rule.blinkLamp < 0) - { - continue; - } - - const bool active = EvaluateExpression(rule.expression.get(), event); - if (active && !rule.blinkActive) - { - rule.blinkActive = true; - actions.push_back({ActionType::StartBlinkLamp, rule.blinkLamp, 0, rule.blinkOnMs, rule.blinkOffMs}); - } - else if (!active && rule.blinkActive) - { - rule.blinkActive = false; - actions.push_back({ActionType::StopBlinkLamp, rule.blinkLamp, 0, 0, 0}); - } - } -} - -void PUPTriggerEngine::RecordMatchedTriggers(const std::vector& matched, const uint64_t nowMs) -{ - for (const auto& match : matched) - { - m_history.push_back({match.source, match.id, match.value, match.player, nowMs}); - if (match.player != 0) - { - m_currentPlayer = match.player; - } - } -} - -void PUPTriggerEngine::ApplyRuleSideEffects(Rule& rule, uint64_t nowMs, std::vector& actions) -{ - if (!rule.clearState.empty()) - { - m_namedStates.erase(rule.clearState); - } - - if (!rule.setState.empty()) - { - const uint64_t expiresAt = rule.stateMs == 0 ? 0 : nowMs + static_cast(rule.stateMs); - m_namedStates[rule.setState] = expiresAt; - } - - if (rule.pulseCoil >= 0) - { - actions.push_back({ActionType::PulseCoil, rule.pulseCoil, rule.pulseMs, 0, 0}); - } -} - -void PUPTriggerEngine::PruneHistoryLocked(const uint64_t nowMs) -{ - while (!m_history.empty() && nowMs > m_history.front().timestampMs && - nowMs - m_history.front().timestampMs > static_cast(kHistoryRetentionMs)) - { - m_history.pop_front(); - } -} - -void PUPTriggerEngine::PruneNamedStatesLocked(const uint64_t nowMs) -{ - auto it = m_namedStates.begin(); - while (it != m_namedStates.end()) - { - if (it->second != 0 && nowMs >= it->second) - { - it = m_namedStates.erase(it); - } - else - { - ++it; - } - } -} - -void PUPTriggerEngine::ClearPlayerHistoryLocked(const uint8_t player) -{ - if (player == 0) - { - return; - } - - auto it = m_history.begin(); - while (it != m_history.end()) - { - if (it->player == player) - { - it = m_history.erase(it); - } - else - { - ++it; - } - } -} - -void PUPTriggerEngine::DispatchMatchesAndActions(const std::vector& matched, - const std::vector& actions, - TriggerCallback triggerCallback, - ActionCallback actionCallback, - bool debug) -{ - for (const auto& match : matched) - { - if (debug) - { - printf("PUP trigger matched (line=%zu player=%u ball=%u): source=%c id=%u value=%u\n", match.line, - match.player, match.ball, match.source, match.id, match.value); - } - - if (match.source != kSilentTriggerSource && triggerCallback) - { - triggerCallback(match.source, match.id, match.value); - } - } - - if (actionCallback) - { - for (const auto& action : actions) - { - actionCallback(action); - } - } -} - -PUPTriggerEngine::SwitchProcessResult PUPTriggerEngine::HandleStateChange(EventType type, int number, uint8_t state, - std::unordered_map& states) -{ - const uint64_t nowMs = GetNowMs(); - std::vector matched; - std::vector actions; - TriggerCallback triggerCallback; - ActionCallback actionCallback; - bool debug = false; - SwitchProcessResult result; - { - std::lock_guard lock(m_mutex); - PruneHistoryLocked(nowMs); - PruneNamedStatesLocked(nowMs); - - const uint8_t oldState = GetState(states, number); - states[number] = state; - - const TriggerEvent event = {type, number, oldState, state}; - for (auto& rule : m_rules) - { - const bool expressionMatched = EvaluateExpression(rule.expression.get(), event); - - if (rule.delayMs > 0) - { - if (rule.eventTriggered) - { - if (expressionMatched && !rule.pending) - { - rule.pending = true; - rule.pendingTriggerMs = nowMs + static_cast(rule.delayMs); - } - } - else - { - if (expressionMatched) - { - if (!rule.conditionActive) - { - rule.conditionActive = true; - if (!rule.pending) - { - rule.pending = true; - rule.pendingTriggerMs = nowMs + static_cast(rule.delayMs); - } - } - } - else - { - rule.conditionActive = false; - rule.pending = false; - rule.pendingTriggerMs = 0; - } - } - continue; - } - - if (!expressionMatched) - { - continue; - } - - if (!CanTriggerNow(rule, nowMs)) - { - continue; - } - - rule.lastTriggeredMs = nowMs; - ApplyRuleSideEffects(rule, nowMs, actions); - const uint8_t matchedPlayer = rule.setPlayer != 0 ? rule.setPlayer : m_currentPlayer; - if (rule.clearPlayerHistory != 0) - { - ClearPlayerHistoryLocked(rule.clearPlayerHistory == 255 ? matchedPlayer : rule.clearPlayerHistory); - } - if (type == EventType::Switch && rule.suppressSwitch == number && state != 0) - { - result.forwardToCpu = false; - m_suppressedSwitchOpen.insert(number); - } - matched.push_back({rule.source, rule.id, rule.value, rule.line, matchedPlayer, m_currentBall}); - } - - if (type == EventType::Switch && state == 0 && m_suppressedSwitchOpen.find(number) != m_suppressedSwitchOpen.end()) - { - result.forwardToCpu = false; - m_suppressedSwitchOpen.erase(number); - } - - CollectBlinkActions(nowMs, event, actions); - CollectDueTriggers(nowMs, matched, actions); - RecordMatchedTriggers(matched, nowMs); - - triggerCallback = m_triggerCallback; - actionCallback = m_actionCallback; - debug = m_debug; - } - - DispatchMatchesAndActions(matched, actions, triggerCallback, actionCallback, debug); - return result; -} - -void PUPTriggerEngine::HandleBallChange(uint8_t currentBall) -{ - const uint64_t nowMs = GetNowMs(); - std::vector matched; - std::vector actions; - TriggerCallback triggerCallback; - ActionCallback actionCallback; - bool debug = false; - { - std::lock_guard lock(m_mutex); - PruneHistoryLocked(nowMs); - PruneNamedStatesLocked(nowMs); - - const uint8_t oldBall = m_currentBall; - if (oldBall == currentBall) - { - return; - } - - m_currentBall = currentBall; - - const TriggerEvent event = {EventType::Ball, static_cast(currentBall), oldBall, currentBall}; - for (auto& rule : m_rules) - { - const bool expressionMatched = EvaluateExpression(rule.expression.get(), event); - - if (rule.delayMs > 0) - { - if (rule.eventTriggered) - { - if (expressionMatched && !rule.pending) - { - rule.pending = true; - rule.pendingTriggerMs = nowMs + static_cast(rule.delayMs); - } - } - else - { - if (expressionMatched) - { - if (!rule.conditionActive) - { - rule.conditionActive = true; - if (!rule.pending) - { - rule.pending = true; - rule.pendingTriggerMs = nowMs + static_cast(rule.delayMs); - } - } - } - else - { - rule.conditionActive = false; - rule.pending = false; - rule.pendingTriggerMs = 0; - } - } - continue; - } - - if (!expressionMatched) - { - continue; - } - - if (!CanTriggerNow(rule, nowMs)) - { - continue; - } - - rule.lastTriggeredMs = nowMs; - ApplyRuleSideEffects(rule, nowMs, actions); - const uint8_t matchedPlayer = rule.setPlayer != 0 ? rule.setPlayer : m_currentPlayer; - if (rule.clearPlayerHistory != 0) - { - ClearPlayerHistoryLocked(rule.clearPlayerHistory == 255 ? matchedPlayer : rule.clearPlayerHistory); - } - matched.push_back({rule.source, rule.id, rule.value, rule.line, matchedPlayer, m_currentBall}); - } - - CollectBlinkActions(nowMs, event, actions); - CollectDueTriggers(nowMs, matched, actions); - RecordMatchedTriggers(matched, nowMs); - - triggerCallback = m_triggerCallback; - actionCallback = m_actionCallback; - debug = m_debug; - } - - DispatchMatchesAndActions(matched, actions, triggerCallback, actionCallback, debug); -} - -void PUPTriggerEngine::HandlePlayerChange(uint8_t currentPlayer) -{ - const uint64_t nowMs = GetNowMs(); - std::vector matched; - std::vector actions; - TriggerCallback triggerCallback; - ActionCallback actionCallback; - bool debug = false; - { - std::lock_guard lock(m_mutex); - PruneHistoryLocked(nowMs); - PruneNamedStatesLocked(nowMs); - - const uint8_t oldPlayer = m_currentPlayer; - if (oldPlayer == currentPlayer) - { - return; - } - - m_currentPlayer = currentPlayer; - - const TriggerEvent event = {EventType::Player, static_cast(currentPlayer), oldPlayer, currentPlayer}; - for (auto& rule : m_rules) - { - const bool expressionMatched = EvaluateExpression(rule.expression.get(), event); - - if (rule.delayMs > 0) - { - if (rule.eventTriggered) - { - if (expressionMatched && !rule.pending) - { - rule.pending = true; - rule.pendingTriggerMs = nowMs + static_cast(rule.delayMs); - } - } - else - { - if (expressionMatched) - { - if (!rule.conditionActive) - { - rule.conditionActive = true; - if (!rule.pending) - { - rule.pending = true; - rule.pendingTriggerMs = nowMs + static_cast(rule.delayMs); - } - } - } - else - { - rule.conditionActive = false; - rule.pending = false; - rule.pendingTriggerMs = 0; - } - } - continue; - } - - if (!expressionMatched) - { - continue; - } - - if (!CanTriggerNow(rule, nowMs)) - { - continue; - } - - rule.lastTriggeredMs = nowMs; - ApplyRuleSideEffects(rule, nowMs, actions); - const uint8_t matchedPlayer = rule.setPlayer != 0 ? rule.setPlayer : m_currentPlayer; - if (rule.clearPlayerHistory != 0) - { - ClearPlayerHistoryLocked(rule.clearPlayerHistory == 255 ? matchedPlayer : rule.clearPlayerHistory); - } - matched.push_back({rule.source, rule.id, rule.value, rule.line, matchedPlayer, m_currentBall}); - } - - CollectBlinkActions(nowMs, event, actions); - CollectDueTriggers(nowMs, matched, actions); - RecordMatchedTriggers(matched, nowMs); - - triggerCallback = m_triggerCallback; - actionCallback = m_actionCallback; - debug = m_debug; - } - - DispatchMatchesAndActions(matched, actions, triggerCallback, actionCallback, debug); -} - -void PUPTriggerEngine::OnSwitchState(int number, uint8_t state) -{ - ProcessSwitchState(number, state); -} - -PUPTriggerEngine::SwitchProcessResult PUPTriggerEngine::ProcessSwitchState(int number, uint8_t state) -{ - return HandleStateChange(EventType::Switch, number, state == 0 ? 0 : 1, m_switchStates); -} - -void PUPTriggerEngine::OnLampState(int number, uint8_t state) -{ - (void)HandleStateChange(EventType::Lamp, number, state == 0 ? 0 : 1, m_lampStates); -} - -void PUPTriggerEngine::OnCoilState(int number, uint8_t state) -{ - (void)HandleStateChange(EventType::Coil, number, state == 0 ? 0 : 1, m_coilStates); -} - -void PUPTriggerEngine::SetCurrentBall(uint8_t currentBall) -{ - HandleBallChange(currentBall); -} - -void PUPTriggerEngine::SetCurrentPlayer(uint8_t currentPlayer) -{ - HandlePlayerChange(currentPlayer); -} diff --git a/src/PUPTriggerEngine.h b/src/PUPTriggerEngine.h deleted file mode 100644 index 8a3db21..0000000 --- a/src/PUPTriggerEngine.h +++ /dev/null @@ -1,205 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -class PUPTriggerEngine -{ - public: - using TriggerCallback = std::function; - - enum class ActionType - { - PulseCoil, - StartBlinkLamp, - StopBlinkLamp - }; - - struct RuleAction - { - ActionType type; - int number = 0; - uint32_t durationMs = 0; - uint32_t onMs = 0; - uint32_t offMs = 0; - }; - - struct SwitchProcessResult - { - bool forwardToCpu = true; - }; - - using ActionCallback = std::function; - - void SetDebug(bool debug); - void SetTriggerCallback(TriggerCallback callback); - void SetActionCallback(ActionCallback callback); - void SetSwitchGroups(const std::unordered_map>& switchGroups); - bool LoadRules(const char* path, std::string& error); - size_t GetRuleCount() const; - void Update(); - - SwitchProcessResult ProcessSwitchState(int number, uint8_t state); - void OnSwitchState(int number, uint8_t state); - void OnLampState(int number, uint8_t state); - void OnCoilState(int number, uint8_t state); - void SetCurrentBall(uint8_t currentBall); - void SetCurrentPlayer(uint8_t currentPlayer); - void SetAttractMode(bool attractMode); - - // Internal types are public to keep parser implementation simple in .cpp. - enum class EventType - { - Switch, - Lamp, - Coil, - Ball, - Player, - Timer - }; - - struct TriggerEvent - { - EventType type; - int number; - uint8_t oldValue; - uint8_t newValue; - }; - - enum class ExprNodeType - { - Literal, - AttractState, - Not, - And, - Or, - BallState, - PlayerState, - HistoryState, - SequenceState, - SwitchState, - LampState, - CoilState, - NamedState, - SwitchGroupState, - SwitchRising, - SwitchFalling, - SwitchRisingGroup, - SwitchFallingGroup, - LampRising, - LampFalling, - CoilRising, - CoilFalling - }; - - struct ExprNode - { - ExprNodeType type; - int number = 0; - bool literal = false; - uint32_t windowMs = 0; - std::string name; - std::vector numbers; - std::vector triggerIds; - std::unique_ptr left; - std::unique_ptr right; - }; - - struct Rule - { - char source = 'P'; - uint16_t id = 0; - uint8_t value = 1; - size_t line = 0; - uint32_t cooldownMs = 0; - uint32_t delayMs = 0; - uint64_t lastTriggeredMs = 0; - uint64_t pendingTriggerMs = 0; - bool pending = false; - bool conditionActive = false; - bool eventTriggered = false; - uint8_t setPlayer = 0; - uint8_t clearPlayerHistory = 0; - std::string setState; - std::string clearState; - uint32_t stateMs = 0; - int suppressSwitch = -1; - int pulseCoil = -1; - uint32_t pulseMs = 120; - int blinkLamp = -1; - uint32_t blinkOnMs = 250; - uint32_t blinkOffMs = 250; - bool blinkActive = false; - std::unique_ptr expression; - }; - - struct MatchedTrigger - { - char source = 'P'; - uint16_t id = 0; - uint8_t value = 1; - size_t line = 0; - uint8_t player = 0; - uint8_t ball = 0; - }; - - struct HistoryEntry - { - char source = 'P'; - uint16_t id = 0; - uint8_t value = 1; - uint8_t player = 0; - uint64_t timestampMs = 0; - }; - - private: - uint8_t GetState(const std::unordered_map& states, int number) const; - bool EvaluateExpression(const ExprNode* node, const TriggerEvent& event) const; - bool UsesEventEdges(const ExprNode* node) const; - bool HistoryContains(uint16_t triggerId, uint32_t windowMs, uint64_t nowMs) const; - bool SequenceOccurred(const std::vector& triggerIds, uint32_t windowMs, uint64_t nowMs) const; - bool NamedStateActive(const std::string& name, uint64_t nowMs) const; - bool SwitchGroupStateActive(const std::string& name) const; - bool SwitchGroupContainsEvent(const std::string& name, const TriggerEvent& event) const; - uint64_t GetNowMs() const; - bool CanTriggerNow(const Rule& rule, uint64_t nowMs) const; - void CollectDueTriggers(uint64_t nowMs, std::vector& matched, std::vector& actions); - void CollectBlinkActions(uint64_t nowMs, const TriggerEvent& event, std::vector& actions); - void RecordMatchedTriggers(const std::vector& matched, uint64_t nowMs); - void ApplyRuleSideEffects(Rule& rule, uint64_t nowMs, std::vector& actions); - void PruneHistoryLocked(uint64_t nowMs); - void PruneNamedStatesLocked(uint64_t nowMs); - void ClearPlayerHistoryLocked(uint8_t player); - SwitchProcessResult HandleStateChange(EventType type, int number, uint8_t state, std::unordered_map& states); - void HandleBallChange(uint8_t currentBall); - void HandlePlayerChange(uint8_t currentPlayer); - void DispatchMatchesAndActions(const std::vector& matched, - const std::vector& actions, - TriggerCallback triggerCallback, - ActionCallback actionCallback, - bool debug); - - std::unordered_map m_switchStates; - std::unordered_map m_lampStates; - std::unordered_map m_coilStates; - std::unordered_map m_namedStates; - std::unordered_map> m_switchGroups; - std::unordered_set m_suppressedSwitchOpen; - std::vector m_rules; - std::deque m_history; - TriggerCallback m_triggerCallback; - ActionCallback m_actionCallback; - uint8_t m_currentBall = 0; - uint8_t m_currentPlayer = 0; - bool m_attractMode = true; - bool m_debug = false; - mutable std::mutex m_mutex; -}; diff --git a/src/RulesAction.h b/src/RulesAction.h new file mode 100644 index 0000000..d6f0cd1 --- /dev/null +++ b/src/RulesAction.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +enum class RulesActionType +{ + PulseCoil, + StartBlinkLamp, + StopBlinkLamp +}; + +struct RulesAction +{ + RulesActionType type; + int number = 0; + uint32_t durationMs = 0; + uint32_t onMs = 0; + uint32_t offMs = 0; +}; + diff --git a/src/SpeechCliSupport.cpp b/src/SpeechCliSupport.cpp index 0dcff12..380b570 100644 --- a/src/SpeechCliSupport.cpp +++ b/src/SpeechCliSupport.cpp @@ -145,7 +145,6 @@ bool ParseSpeechCliOptions(const char* backendArg, bool ValidateSpeechAudioUsage(bool noSound, bool speechEnabled, bool greetingEnabled, - const char* speechFile, std::string* errorMessage) { if (errorMessage != nullptr) @@ -153,16 +152,6 @@ bool ValidateSpeechAudioUsage(bool noSound, errorMessage->clear(); } - if (speechFile != nullptr && speechFile[0] != '\0' && noSound) - { - if (errorMessage != nullptr) - { - *errorMessage = - "--speech-file requires audio output and cannot be used with --no-sound"; - } - return false; - } - if (noSound && (speechEnabled || greetingEnabled)) { if (errorMessage != nullptr) diff --git a/src/SpeechCliSupport.h b/src/SpeechCliSupport.h index a67ace0..9dd2690 100644 --- a/src/SpeechCliSupport.h +++ b/src/SpeechCliSupport.h @@ -18,7 +18,6 @@ bool ParseSpeechCliOptions(const char* backendArg, bool ValidateSpeechAudioUsage(bool noSound, bool speechEnabled, bool greetingEnabled, - const char* speechFile, std::string* errorMessage); bool CreateConfiguredSpeechService(AudioOutput& audioOutput, diff --git a/src/SpeechTriggerMap.cpp b/src/SpeechTriggerMap.cpp deleted file mode 100644 index 89f4c52..0000000 --- a/src/SpeechTriggerMap.cpp +++ /dev/null @@ -1,107 +0,0 @@ -#include "SpeechTriggerMap.h" - -#include -#include - -namespace -{ -std::string Trim(const std::string& input) -{ - const size_t first = input.find_first_not_of(" \t\r\n"); - if (first == std::string::npos) - { - return {}; - } - - const size_t last = input.find_last_not_of(" \t\r\n"); - return input.substr(first, last - first + 1); -} - -bool ParseUInt16(const std::string& token, uint16_t& value) -{ - try - { - const unsigned long parsed = std::stoul(token, nullptr, 10); - if (parsed > 0xFFFFul) - { - return false; - } - value = static_cast(parsed); - return true; - } - catch (...) - { - return false; - } -} -} // namespace - -bool SpeechTriggerMap::Load(const char* path, std::string& error) -{ - std::ifstream input(path); - if (!input.is_open()) - { - error = "Unable to open speech trigger file"; - return false; - } - - std::unordered_map loaded; - std::string line; - size_t lineNo = 0; - while (std::getline(input, line)) - { - lineNo++; - const size_t comment = line.find('#'); - if (comment != std::string::npos) - { - line = line.substr(0, comment); - } - - line = Trim(line); - if (line.empty()) - { - continue; - } - - const size_t separator = line.find(':'); - if (separator == std::string::npos) - { - error = "Invalid speech trigger line " + std::to_string(lineNo) + - ": missing ':' separator."; - return false; - } - - const std::string idPart = Trim(line.substr(0, separator)); - const std::string textPart = Trim(line.substr(separator + 1)); - if (idPart.empty() || textPart.empty()) - { - error = "Invalid speech trigger line " + std::to_string(lineNo) + - ": missing id or text."; - return false; - } - - uint16_t id = 0; - if (!ParseUInt16(idPart, id)) - { - error = "Invalid speech trigger line " + std::to_string(lineNo) + - ": id must be uint16."; - return false; - } - - loaded[id] = textPart; - } - - m_entries = std::move(loaded); - return true; -} - -const std::string* SpeechTriggerMap::Find(uint16_t id) const -{ - const auto it = m_entries.find(id); - return it == m_entries.end() ? nullptr : &it->second; -} - -size_t SpeechTriggerMap::GetEntryCount() const -{ - return m_entries.size(); -} diff --git a/src/SpeechTriggerMap.h b/src/SpeechTriggerMap.h deleted file mode 100644 index 72b3b1d..0000000 --- a/src/SpeechTriggerMap.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include -#include -#include - -class SpeechTriggerMap -{ -public: - bool Load(const char* path, std::string& error); - const std::string* Find(uint16_t id) const; - size_t GetEntryCount() const; - -private: - std::unordered_map m_entries; -}; diff --git a/src/menu.cpp b/src/menu.cpp index d619ef0..6718fe3 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -1109,8 +1109,7 @@ int main(int argc, char* argv[]) } std::string speechError; - if (!ValidateSpeechAudioUsage(optNoSound, optSpeech, optGreeting, nullptr, - &speechError)) + if (!ValidateSpeechAudioUsage(optNoSound, optSpeech, optGreeting, &speechError)) { fprintf(stderr, "%s\n", speechError.c_str()); return 1; diff --git a/src/ppuc.cpp b/src/ppuc.cpp index b652f8c..f0127a3 100644 --- a/src/ppuc.cpp +++ b/src/ppuc.cpp @@ -44,13 +44,12 @@ #include "SDLDMD/SDLDMD.h" #endif #include "AudioOutput.h" -#include "PUPTriggerEngine.h" +#include "LuaRulesEngine.h" #include "SDL3/SDL.h" #include "SDL3/SDL_filesystem.h" #include "SDL3_image/SDL_image.h" #include "SpeechCliSupport.h" #include "SpeechService.h" -#include "SpeechTriggerMap.h" #include "cargs.h" #include "io-boards/Event.h" #include "io-boards/PPUCPlatforms.h" @@ -74,12 +73,10 @@ constexpr uint32_t kBallSearchCoilPulseMs = 200; DMDUtil::DMD* pDmd; PPUC* ppuc; -std::unique_ptr pPUPTriggerEngine; -std::unique_ptr pSpeechTriggerMap; +std::unique_ptr pLuaRulesEngine; std::unique_ptr pAudioOutput; std::unique_ptr pSpeechService; -constexpr char kSpeechTriggerSource = 'O'; constexpr char kBoardEffectTriggerSource = 'F'; enum class PinmameMapEncoding @@ -307,7 +304,6 @@ bool opt_no_serial = false; bool opt_no_sound = false; bool opt_speech = false; bool opt_greeting = false; -const char* opt_speech_file = NULL; const char* opt_music_files = NULL; uint32_t opt_music_gap_ms = 2000; const char* opt_speech_backend = "auto"; @@ -441,9 +437,19 @@ struct InterceptorOutputOverrides void StartBlinkLamp(PPUC* controller, int number, uint32_t onMs, uint32_t offMs) { + const uint32_t normalizedOnMs = onMs == 0 ? 250 : onMs; + const uint32_t normalizedOffMs = offMs == 0 ? 250 : offMs; + const auto existing = lampBlinks.find(number); + if (existing != lampBlinks.end()) + { + existing->second.onMs = normalizedOnMs; + existing->second.offMs = normalizedOffMs; + return; + } + auto& blink = lampBlinks[number]; - blink.onMs = onMs == 0 ? 250 : onMs; - blink.offMs = offMs == 0 ? 250 : offMs; + blink.onMs = normalizedOnMs; + blink.offMs = normalizedOffMs; blink.outputOn = true; blink.nextToggle = std::chrono::steady_clock::now() + std::chrono::milliseconds(blink.onMs); controller->SetLampState(number, 1); @@ -490,17 +496,17 @@ struct InterceptorOutputOverrides } } - void HandleAction(PPUC* controller, const PUPTriggerEngine::RuleAction& action) + void HandleAction(PPUC* controller, const RulesAction& action) { switch (action.type) { - case PUPTriggerEngine::ActionType::PulseCoil: + case RulesActionType::PulseCoil: PulseCoil(controller, action.number, action.durationMs); break; - case PUPTriggerEngine::ActionType::StartBlinkLamp: + case RulesActionType::StartBlinkLamp: StartBlinkLamp(controller, action.number, action.onMs, action.offMs); break; - case PUPTriggerEngine::ActionType::StopBlinkLamp: + case RulesActionType::StopBlinkLamp: StopBlinkLamp(controller, action.number); break; } @@ -2295,10 +2301,6 @@ static struct cag_option options[] = { .access_name = "greeting", .value_name = NULL, .description = "Speak a startup greeting for speech debugging (optional)"}, - {.identifier = '9', - .access_name = "speech-file", - .value_name = "VALUE", - .description = "Path to speech trigger text file (optional)"}, {.identifier = 'o', .access_name = "music-files", .value_name = "VALUE", @@ -2342,9 +2344,9 @@ static struct cag_option options[] = { .value_name = "VALUE", .description = "Enable PUP videos (optional)"}, {.identifier = 'y', - .access_name = "pup-triggers", + .access_name = "rules", .value_name = "VALUE", - .description = "Path to PUP trigger rules file (optional)"}, + .description = "Path to Lua rules file (optional)"}, {.identifier = 'i', .access_letters = "i", .access_name = "console-display", @@ -2768,18 +2770,18 @@ void PINMAMECALLBACK OnSolenoidUpdated(PinmameSolenoidState* p_solenoidState, co } } - if (pPUPTriggerEngine) + if (pLuaRulesEngine) { if (isGameOnCoil) { - pPUPTriggerEngine->SetAttractMode(coilState == 0); + pLuaRulesEngine->SetAttractMode(coilState == 0); if (coilState == 0) { - pPUPTriggerEngine->SetCurrentBall(0); - pPUPTriggerEngine->SetCurrentPlayer(0); + pLuaRulesEngine->SetCurrentBall(0); + pLuaRulesEngine->SetCurrentPlayer(0); } } - pPUPTriggerEngine->OnCoilState(p_solenoidState->solNo, coilState); + pLuaRulesEngine->OnCoilState(p_solenoidState->solNo, coilState); } } @@ -2838,7 +2840,7 @@ int main(int argc, char** argv) cag_option_context cag_context; const char* config_file = NULL; const char* opt_ini_file = NULL; - const char* opt_pup_triggers = NULL; + const char* opt_rules = NULL; const char* opt_backbox_address = NULL; uint16_t opt_backbox_port = 6789; const char* opt_serial = NULL; @@ -2956,10 +2958,8 @@ int main(int argc, char** argv) opt_serial = DuplicateOptionalIniString(value); else if (key == "PinmamePath") opt_pinmame_path = DuplicateOptionalIniString(value); - else if (key == "PUPTriggers") - opt_pup_triggers = DuplicateOptionalIniString(value); - else if (key == "SpeechFile") - opt_speech_file = DuplicateOptionalIniString(value); + else if (key == "Rules") + opt_rules = DuplicateOptionalIniString(value); else if (key == "MusicFiles") opt_music_files = DuplicateOptionalIniString(value); else if (key == "MusicGapMs") @@ -3143,9 +3143,6 @@ int main(int argc, char** argv) case 'Y': opt_greeting = true; break; - case '9': - opt_speech_file = cag_option_get_value(&cag_context); - break; case 'o': opt_music_files = cag_option_get_value(&cag_context); break; @@ -3180,7 +3177,7 @@ int main(int argc, char** argv) opt_pup = true; break; case 'y': - opt_pup_triggers = cag_option_get_value(&cag_context); + opt_rules = cag_option_get_value(&cag_context); break; case 'i': opt_console_display = true; @@ -3427,7 +3424,7 @@ int main(int argc, char** argv) if (!ValidateSpeechAudioUsage(opt_no_sound, (opt_speech || HasOptionValue(opt_speech_voice) || HasOptionValue(opt_speech_rate_arg) || HasOptionValue(opt_speech_pitch_arg)), - opt_greeting, opt_speech_file, &speechValidationError)) + opt_greeting, &speechValidationError)) { fprintf(stderr, "%s\n", speechValidationError.c_str()); return 1; @@ -3470,7 +3467,7 @@ int main(int argc, char** argv) const auto initializeSpeechIfNeeded = [&]() -> bool { - if (pSpeechService != nullptr || !(opt_speech || opt_greeting || opt_speech_file)) + if (pSpeechService != nullptr || !(opt_speech || opt_greeting)) { return true; } @@ -3738,20 +3735,28 @@ int main(int argc, char** argv) } dmdConfig->SetRoundedCorners(opt_rounded_corners); - if (opt_pup_triggers) + if (opt_rules) { - pPUPTriggerEngine = std::make_unique(); - pPUPTriggerEngine->SetDebug(opt_debug || opt_debug_effects); - pPUPTriggerEngine->SetSwitchGroups(ppuc->GetSwitchGroups()); - pPUPTriggerEngine->SetActionCallback( - [](const PUPTriggerEngine::RuleAction& action) + pLuaRulesEngine = std::make_unique(); + pLuaRulesEngine->SetDebug(opt_debug || opt_debug_effects); + pLuaRulesEngine->SetSwitchGroups(ppuc->GetSwitchGroups()); + pLuaRulesEngine->SetActionCallback( + [](const RulesAction& action) { if (ppuc != nullptr) { g_interceptorOutputs.HandleAction(ppuc, action); } }); - pPUPTriggerEngine->SetTriggerCallback( + pLuaRulesEngine->SetSpeechCallback( + [](const std::string& text) + { + if (pSpeechService != nullptr) + { + pSpeechService->SpeakText(text); + } + }); + pLuaRulesEngine->SetTriggerCallback( [](const char source, const uint16_t id, const uint8_t value) { if (source == kBoardEffectTriggerSource) @@ -3767,19 +3772,6 @@ int main(int argc, char** argv) return; } - if (source == kSpeechTriggerSource) - { - if (pSpeechService && pSpeechTriggerMap) - { - const std::string* text = pSpeechTriggerMap->Find(id); - if (text) - { - pSpeechService->SpeakText(*text); - } - } - return; - } - if (pDmd) { pDmd->SetPUPTrigger(source, id, value); @@ -3787,25 +3779,12 @@ int main(int argc, char** argv) }); std::string error; - if (!pPUPTriggerEngine->LoadRules(opt_pup_triggers, error)) + if (!pLuaRulesEngine->LoadScript(opt_rules, error)) { - printf("%s: %s\n", error.c_str(), opt_pup_triggers); + printf("%s: %s\n", error.c_str(), opt_rules); return 1; } - printf("Loaded %zu PUP trigger rule(s) from %s\n", pPUPTriggerEngine->GetRuleCount(), opt_pup_triggers); - } - - if (opt_speech_file) - { - pSpeechTriggerMap = std::make_unique(); - std::string error; - if (!pSpeechTriggerMap->Load(opt_speech_file, error)) - { - printf("%s: %s\n", error.c_str(), opt_speech_file); - return 1; - } - printf("Loaded %zu speech trigger text entr%s from %s\n", pSpeechTriggerMap->GetEntryCount(), - pSpeechTriggerMap->GetEntryCount() == 1 ? "y" : "ies", opt_speech_file); + printf("Loaded Lua rules from %s\n", opt_rules); } PinmameConfig config = { @@ -4071,8 +4050,8 @@ int main(int argc, char** argv) } } - trackCurrentBall = pPUPTriggerEngine != nullptr && trackingConfig.currentBall.available; - trackCurrentPlayer = pPUPTriggerEngine != nullptr && trackingConfig.currentPlayer.available; + trackCurrentBall = pLuaRulesEngine != nullptr && trackingConfig.currentBall.available; + trackCurrentPlayer = pLuaRulesEngine != nullptr && trackingConfig.currentPlayer.available; nextTrackedStatePollAt = std::chrono::steady_clock::now(); } @@ -4084,9 +4063,14 @@ int main(int argc, char** argv) if (game_state.load(std::memory_order_acquire) == 0) { - if (pPUPTriggerEngine) + if (pLuaRulesEngine) { - pPUPTriggerEngine->Update(); + pLuaRulesEngine->Update(); + if (pLuaRulesEngine->HasFatalError()) + { + printf("Lua rules error: %s\n", pLuaRulesEngine->GetFatalError().c_str()); + running = false; + } } g_interceptorOutputs.Service(ppuc); continue; @@ -4102,7 +4086,7 @@ int main(int argc, char** argv) uint8_t currentBall = 0; if (TryDecodeTrackedPinmameValue(trackingConfig.currentBall, ¤tBall)) { - pPUPTriggerEngine->SetCurrentBall(currentBall); + pLuaRulesEngine->SetCurrentBall(currentBall); } else if (!loggedMissingCurrentBallApi && (opt_debug || opt_debug_errors)) { @@ -4116,7 +4100,7 @@ int main(int argc, char** argv) uint8_t currentPlayer = 0; if (TryDecodeTrackedPinmameValue(trackingConfig.currentPlayer, ¤tPlayer)) { - pPUPTriggerEngine->SetCurrentPlayer(currentPlayer); + pLuaRulesEngine->SetCurrentPlayer(currentPlayer); } else if (!loggedMissingCurrentPlayerApi && (opt_debug || opt_debug_errors)) { @@ -4133,10 +4117,15 @@ int main(int argc, char** argv) NoteBallSearchSwitchUpdate(ppuc, ballSearchRunner, switchState->number, newSwitchState, opt_ball_search_delay_ms); - PUPTriggerEngine::SwitchProcessResult switchProcess; - if (pPUPTriggerEngine) + LuaRulesEngine::SwitchProcessResult switchProcess; + if (pLuaRulesEngine) { - switchProcess = pPUPTriggerEngine->ProcessSwitchState(switchState->number, newSwitchState); + switchProcess = pLuaRulesEngine->ProcessSwitchState(switchState->number, newSwitchState); + if (pLuaRulesEngine->HasFatalError()) + { + printf("Lua rules error: %s\n", pLuaRulesEngine->GetFatalError().c_str()); + running = false; + } } // Switches between 200 and 240 are custom switches within the io-boards which should not be sent to @@ -4172,9 +4161,14 @@ int main(int argc, char** argv) g_interceptorOutputs.ApplyPinmameLamp(ppuc, static_cast(lampNo), lampState); - if (pPUPTriggerEngine) + if (pLuaRulesEngine) { - pPUPTriggerEngine->OnLampState(static_cast(lampNo), lampState); + pLuaRulesEngine->OnLampState(static_cast(lampNo), lampState); + if (pLuaRulesEngine->HasFatalError()) + { + printf("Lua rules error: %s\n", pLuaRulesEngine->GetFatalError().c_str()); + running = false; + } } } @@ -4195,9 +4189,14 @@ int main(int argc, char** argv) } } - if (pPUPTriggerEngine) + if (pLuaRulesEngine) { - pPUPTriggerEngine->Update(); + pLuaRulesEngine->Update(); + if (pLuaRulesEngine->HasFatalError()) + { + printf("Lua rules error: %s\n", pLuaRulesEngine->GetFatalError().c_str()); + running = false; + } } g_interceptorOutputs.Service(ppuc); From c2a1f64bb0a7373be5deb32771bb19f364a71b9f Mon Sep 17 00:00:00 2001 From: Markus Kalkbrenner Date: Sun, 28 Jun 2026 16:36:33 +0200 Subject: [PATCH 5/7] integrated LUA based rule handling --- INTERCEPTOR.md | 51 +++++- README.md | 44 +++-- examples/ppuc-pinmame.ini | 2 +- examples/rules.lua | 15 +- src/LuaRulesEngine.cpp | 344 ++++++++++++++++++++++++++++++-------- src/LuaRulesEngine.h | 29 +++- src/RulesAction.h | 3 +- src/ppuc.cpp | 80 ++++++++- 8 files changed, 457 insertions(+), 111 deletions(-) diff --git a/INTERCEPTOR.md b/INTERCEPTOR.md index 45c2e7a..42ab493 100644 --- a/INTERCEPTOR.md +++ b/INTERCEPTOR.md @@ -5,11 +5,12 @@ before they are forwarded to PinMAME. Interceptor logic lives in the Lua rules file loaded with: ```text ---rules +--rules ``` -The same Lua rules file can emit DMD/PUP triggers, speech callouts, board effect -triggers, and host-side interceptor actions. +The path can be one Lua file or a directory of Lua files. The same Lua rules can +emit DMD/PUP triggers, speech callouts, board effect triggers, and host-side +interceptor actions. ## Runtime API @@ -17,7 +18,7 @@ Interceptor rules use the `ppuc` Lua namespace: ```lua function ppuc.onSwitchChanged(number, state) - if ppuc.stateActive("ballSave") and ppuc.switchClosing(9) then + if ppuc.stateActive("ballSave") and number == 9 and state == 1 then ppuc.suppressSwitch(9) ppuc.pulseCoil(7, 120) end @@ -27,15 +28,16 @@ end Useful functions: - `ppuc.switchState(number)` -- `ppuc.switchClosing(number)` -- `ppuc.switchOpening(number)` - `ppuc.switchGroupState(name)` - `ppuc.switchGroupClosing(name)` - `ppuc.switchGroupOpening(name)` +- `number` and `state` inside `ppuc.onSwitchChanged(number, state)` - `ppuc.setState(name)` and `ppuc.setState(name, durationMs)` - `ppuc.clearState(name)` - `ppuc.stateActive(name)` +- `ppuc.after(delayMs, function() ... end)` - `ppuc.suppressSwitch(number)` +- `ppuc.sendSwitchToCpu(number, state)` - `ppuc.pulseCoil(number, durationMs)` - `ppuc.blinkLamp(number, onMs, offMs)` - `ppuc.stopBlinkLamp(number)` @@ -66,6 +68,39 @@ temporarily override a single output number: If PinMAME changes the same output while the override is active, the new PinMAME state is remembered and restored when the override ends. +## Non-Blocking Delays + +Use `ppuc.after(delayMs, function() ... end)` when a rule needs delayed work. +This schedules the function on the Lua rules update tick; it does not sleep and +does not block `ppuc-pinmame`. + +```lua +function ppuc.onSwitchChanged(number, state) + if number == 16 and state == 1 then + ppuc.after(500, function() + ppuc.speech("Test") + end) + end +end +``` + +Suppressed switches can be sent to PinMAME later by scheduling explicit CPU +switch states: + +```lua +function ppuc.onSwitchChanged(number, state) + if number == 16 and state == 1 then + ppuc.suppressSwitch(16) + ppuc.after(500, function() + ppuc.sendSwitchToCpu(16, 1) + end) + ppuc.after(650, function() + ppuc.sendSwitchToCpu(16, 0) + end) + end +end +``` + ## Ball Save Example This example arms ball save on switch 15, starts a 5 second save window when any @@ -74,7 +109,7 @@ suppresses the outhole switch while pulsing coil 7. ```lua function ppuc.onSwitchChanged(number, state) - if ppuc.switchClosing(15) then + if number == 15 and state == 1 then ppuc.setState("ballSaveReady") end @@ -83,7 +118,7 @@ function ppuc.onSwitchChanged(number, state) ppuc.setState("ballSave", 5000) end - if ppuc.stateActive("ballSave") and ppuc.switchClosing(9) then + if ppuc.stateActive("ballSave") and number == 9 and state == 1 then ppuc.suppressSwitch(9) ppuc.pulseCoil(7, 120) end diff --git a/README.md b/README.md index bb77b46..6d5a1b7 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ These components are still in an early development stage and the documentation w * Serum ignore number of unknown frames * optional * --rules path - * path to a Lua rules file + * path to one Lua rules file or a directory containing Lua rule files * optional * --music-files csv * comma-separated MP3 playlist for gameplay background music @@ -160,31 +160,37 @@ pwmOutput: ### Lua Rules -Use `--rules ` to run a Lua rules script. Rules are independent from `--pup`, -and can also drive speech callouts, board-local PPUC effects, and host-side -interceptor behavior. +Use `--rules ` to run Lua rules. The path can point to one `.lua` file or +to a directory. Directory loading is non-recursive, loads top-level `*.lua` +files in filename order, and fails on the first load or runtime error. 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: ```lua function ppuc.onSwitchChanged(number, state) - if ppuc.switchClosing(13) and ppuc.lampState(42) then + if number == 13 and state == 1 and ppuc.lampState(42) then ppuc.pupTrigger("P", 100, 1) end - if ppuc.stateActive("ballSave") and ppuc.switchClosing(9) then + 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 ppuc.lampRising(23) and not ppuc.attractMode() then + if number == 23 and state == 1 and not ppuc.attractMode() then ppuc.speech("New highscore!") end end ``` +When 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)` @@ -193,18 +199,17 @@ Supported handlers: * `ppuc.onPlayerChanged(player)` * `ppuc.onRulesUpdate()` -State and edge helpers: +State helpers and handler values: * `ppuc.switchState(number)`, `ppuc.lampState(number)`, `ppuc.coilState(number)` -* `ppuc.switchClosing(number)`, `ppuc.switchOpening(number)` -* `ppuc.lampRising(number)`, `ppuc.lampFalling(number)` -* `ppuc.coilRising(number)`, `ppuc.coilFalling(number)` * `ppuc.currentBall()`, `ppuc.currentPlayer()`, `ppuc.attractMode()` +* Changed handlers receive `number` and `state`; use `state == 1` for active/closed/on and `state == 0` for inactive/open/off. Named states, history, and switch groups: * `ppuc.setState(name)` and `ppuc.setState(name, durationMs)` * `ppuc.clearState(name)` and `ppuc.stateActive(name)` * `ppuc.triggerHistory(id)` and `ppuc.triggerHistory(id, windowMs)` * `ppuc.triggerSequence(windowMs, id1, id2, id3)` +* `ppuc.onlyOnceEvery(name, durationMs)` returns true only once per named time window * `ppuc.switchGroupState(name)`, `ppuc.switchGroupClosing(name)`, `ppuc.switchGroupOpening(name)` Switch groups can be declared in the game YAML: @@ -219,13 +224,28 @@ 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 work * `ppuc.pupTrigger(source, id, value)` * `ppuc.speech(text)` -* `ppuc.effectTrigger(id, value)` +* `ppuc.effectTrigger(id, value)` or `ppuc.effectTrigger(name, value)` * `ppuc.suppressSwitch(number)` +* `ppuc.sendSwitchToCpu(number, state)` * `ppuc.pulseCoil(number, durationMs)` * `ppuc.blinkLamp(number, onMs, offMs)` and `ppuc.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: + +```lua +function ppuc.onSwitchChanged(number, state) + if number == 16 and state == 1 then + ppuc.after(500, function() + ppuc.speech("Test") + end) + end +end +``` + A ready-to-use sample file is available at `examples/rules.lua`. Interceptor-specific behavior is documented in `INTERCEPTOR.md`. diff --git a/examples/ppuc-pinmame.ini b/examples/ppuc-pinmame.ini index 3e5ae38..684f766 100644 --- a/examples/ppuc-pinmame.ini +++ b/examples/ppuc-pinmame.ini @@ -20,7 +20,7 @@ Serial = # Windows example: D:\PinMAME\ PinmamePath = -# Optional Lua rules file. +# Optional Lua rules file or directory. Rules = # Optional comma-separated MP3 playlist for gameplay background music. diff --git a/examples/rules.lua b/examples/rules.lua index fd8c4b1..ee427e1 100644 --- a/examples/rules.lua +++ b/examples/rules.lua @@ -9,16 +9,19 @@ -- ppuc.onRulesUpdate() function ppuc.onSwitchChanged(number, state) - if ppuc.switchClosing(13) and ppuc.lampState(42) then + if number == 13 and state == 1 and ppuc.lampState(42) then ppuc.pupTrigger("P", 100, 1) end - if ppuc.switchClosing(13) and ppuc.attractMode() then + if number == 13 and state == 1 and ppuc.attractMode() then ppuc.pupTrigger("P", 101, 1) + ppuc.after(500, function() + ppuc.speech("Delayed attract switch callout") + end) end -- Host-side ball-save interceptor example. - if ppuc.switchClosing(15) then + if number == 15 and state == 1 then ppuc.setState("ballSaveReady") end @@ -27,18 +30,18 @@ function ppuc.onSwitchChanged(number, state) ppuc.setState("ballSave", 5000) end - if ppuc.stateActive("ballSave") and ppuc.switchClosing(9) then + 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 ppuc.lampRising(23) and not ppuc.attractMode() then + if number == 23 and state == 1 and not ppuc.attractMode() then ppuc.speech("New highscore!") end - if ppuc.lampRising(5) and ppuc.attractMode() then + if number == 5 and state == 1 and ppuc.attractMode() then ppuc.effectTrigger(1000, 1) end end diff --git a/src/LuaRulesEngine.cpp b/src/LuaRulesEngine.cpp index ef6de10..fd2d624 100644 --- a/src/LuaRulesEngine.cpp +++ b/src/LuaRulesEngine.cpp @@ -16,6 +16,14 @@ namespace { constexpr uint32_t kHistoryRetentionMs = 60000; constexpr char kBoardEffectTriggerSource = 'F'; +constexpr const char* kHandlerNames[] = { + "onSwitchChanged", + "onLampChanged", + "onCoilChanged", + "onBallChanged", + "onPlayerChanged", + "onRulesUpdate", +}; using LuaFn = int (*)(lua_State*); @@ -31,6 +39,25 @@ void SetPpucFunction(lua_State* L, LuaRulesEngine* engine, const char* name, Lua lua_pushcclosure(L, fn, 1); lua_setfield(L, -2, name); } + +uint16_t HashNamedTriggerId(const char* name) +{ + if (name == nullptr) + { + return 0; + } + + uint32_t hash = 2166136261u; + while (*name != '\0') + { + hash ^= static_cast(*name++); + hash *= 16777619u; + } + + hash ^= (hash >> 16); + const uint16_t reduced = static_cast(hash & 0xFFFFu); + return reduced == 0 ? 1 : reduced; +} } // namespace LuaRulesEngine::LuaRulesEngine() = default; @@ -39,6 +66,8 @@ LuaRulesEngine::~LuaRulesEngine() { if (m_lua != nullptr) { + ClearRegisteredHandlers(); + ClearScheduledCallbacks(); lua_close(m_lua); m_lua = nullptr; } @@ -112,13 +141,6 @@ void LuaRulesEngine::RegisterApi() SetPpucFunction(m_lua, this, "currentPlayer", LuaCurrentPlayer); SetPpucFunction(m_lua, this, "attractMode", LuaAttractMode); - SetPpucFunction(m_lua, this, "switchClosing", LuaSwitchClosing); - SetPpucFunction(m_lua, this, "switchOpening", LuaSwitchOpening); - SetPpucFunction(m_lua, this, "lampRising", LuaLampRising); - SetPpucFunction(m_lua, this, "lampFalling", LuaLampFalling); - SetPpucFunction(m_lua, this, "coilRising", LuaCoilRising); - SetPpucFunction(m_lua, this, "coilFalling", LuaCoilFalling); - SetPpucFunction(m_lua, this, "switchGroupState", LuaSwitchGroupState); SetPpucFunction(m_lua, this, "switchGroupClosing", LuaSwitchGroupClosing); SetPpucFunction(m_lua, this, "switchGroupOpening", LuaSwitchGroupOpening); @@ -128,11 +150,14 @@ void LuaRulesEngine::RegisterApi() SetPpucFunction(m_lua, this, "stateActive", LuaStateActive); SetPpucFunction(m_lua, this, "triggerHistory", LuaTriggerHistory); SetPpucFunction(m_lua, this, "triggerSequence", LuaTriggerSequence); + SetPpucFunction(m_lua, this, "onlyOnceEvery", LuaOnlyOnceEvery); + SetPpucFunction(m_lua, this, "after", LuaAfter); SetPpucFunction(m_lua, this, "pupTrigger", LuaPupTrigger); SetPpucFunction(m_lua, this, "speech", LuaSpeech); SetPpucFunction(m_lua, this, "effectTrigger", LuaEffectTrigger); SetPpucFunction(m_lua, this, "suppressSwitch", LuaSuppressSwitch); + SetPpucFunction(m_lua, this, "sendSwitchToCpu", LuaSendSwitchToCpu); SetPpucFunction(m_lua, this, "pulseCoil", LuaPulseCoil); SetPpucFunction(m_lua, this, "blinkLamp", LuaBlinkLamp); SetPpucFunction(m_lua, this, "stopBlinkLamp", LuaStopBlinkLamp); @@ -141,16 +166,98 @@ void LuaRulesEngine::RegisterApi() } bool LuaRulesEngine::LoadScript(const char* path, std::string& error) +{ + return LoadScripts(std::vector{path}, error); +} + +bool LuaRulesEngine::LoadScripts(const std::vector& paths, std::string& error) { std::lock_guard lock(m_mutex); m_fatalError = false; m_fatalErrorMessage.clear(); + ClearRegisteredHandlers(); + ClearScheduledCallbacks(); if (!InitializeLua(error)) { return false; } + for (const std::string& path : paths) + { + if (!LoadScriptIntoState(path.c_str(), error)) + { + return false; + } + } + + return true; +} + +void LuaRulesEngine::ClearRegisteredHandlers() +{ + if (m_lua == nullptr) + { + m_handlers.clear(); + return; + } + + for (auto& entry : m_handlers) + { + for (const int ref : entry.second) + { + luaL_unref(m_lua, LUA_REGISTRYINDEX, ref); + } + } + m_handlers.clear(); +} + +void LuaRulesEngine::ClearScheduledCallbacks() +{ + if (m_lua != nullptr) + { + for (const ScheduledCallback& callback : m_scheduledCallbacks) + { + luaL_unref(m_lua, LUA_REGISTRYINDEX, callback.ref); + } + } + m_scheduledCallbacks.clear(); + m_nextScheduledSequence = 0; +} + +void LuaRulesEngine::ClearPpucHandlers() +{ + lua_getglobal(m_lua, "ppuc"); + for (const char* name : kHandlerNames) + { + lua_pushnil(m_lua); + lua_setfield(m_lua, -2, name); + } + lua_pop(m_lua, 1); +} + +void LuaRulesEngine::CapturePpucHandlers() +{ + lua_getglobal(m_lua, "ppuc"); + for (const char* name : kHandlerNames) + { + lua_getfield(m_lua, -1, name); + if (lua_isfunction(m_lua, -1)) + { + m_handlers[name].push_back(luaL_ref(m_lua, LUA_REGISTRYINDEX)); + } + else + { + lua_pop(m_lua, 1); + } + } + lua_pop(m_lua, 1); +} + +bool LuaRulesEngine::LoadScriptIntoState(const char* path, std::string& error) +{ + ClearPpucHandlers(); + if (luaL_loadfile(m_lua, path) != LUA_OK) { error = lua_tostring(m_lua, -1); @@ -165,72 +272,134 @@ bool LuaRulesEngine::LoadScript(const char* path, std::string& error) return false; } + CapturePpucHandlers(); return true; } bool LuaRulesEngine::CallHandler(const char* name) { - lua_getglobal(m_lua, "ppuc"); - lua_getfield(m_lua, -1, name); - if (!lua_isfunction(m_lua, -1)) + const auto it = m_handlers.find(name); + if (it == m_handlers.end()) { - lua_pop(m_lua, 2); return true; } - lua_remove(m_lua, -2); - if (lua_pcall(m_lua, 0, 0, 0) != LUA_OK) + for (const int handlerRef : it->second) { - SetFatalError(lua_tostring(m_lua, -1)); - lua_pop(m_lua, 1); - return false; + if (!CallRegisteredHandler(handlerRef, name, {})) + { + return false; + } } return true; } bool LuaRulesEngine::CallHandler(const char* name, int arg1) { - lua_getglobal(m_lua, "ppuc"); - lua_getfield(m_lua, -1, name); - if (!lua_isfunction(m_lua, -1)) + const auto it = m_handlers.find(name); + if (it == m_handlers.end()) { - lua_pop(m_lua, 2); return true; } - lua_remove(m_lua, -2); - lua_pushinteger(m_lua, arg1); - if (lua_pcall(m_lua, 1, 0, 0) != LUA_OK) + for (const int handlerRef : it->second) { - SetFatalError(lua_tostring(m_lua, -1)); - lua_pop(m_lua, 1); - return false; + if (!CallRegisteredHandler(handlerRef, name, {arg1})) + { + return false; + } } return true; } bool LuaRulesEngine::CallHandler(const char* name, int arg1, int arg2) { - lua_getglobal(m_lua, "ppuc"); - lua_getfield(m_lua, -1, name); - if (!lua_isfunction(m_lua, -1)) + const auto it = m_handlers.find(name); + if (it == m_handlers.end()) { - lua_pop(m_lua, 2); return true; } - lua_remove(m_lua, -2); - lua_pushinteger(m_lua, arg1); - lua_pushinteger(m_lua, arg2); - if (lua_pcall(m_lua, 2, 0, 0) != LUA_OK) + for (const int handlerRef : it->second) + { + if (!CallRegisteredHandler(handlerRef, name, {arg1, arg2})) + { + return false; + } + } + return true; +} + +bool LuaRulesEngine::CallRegisteredHandler(int handlerRef, const char* name, const std::vector& args) +{ + lua_rawgeti(m_lua, LUA_REGISTRYINDEX, handlerRef); + for (const int arg : args) + { + lua_pushinteger(m_lua, arg); + } + + if (lua_pcall(m_lua, static_cast(args.size()), 0, 0) != LUA_OK) { - SetFatalError(lua_tostring(m_lua, -1)); + std::string error = lua_tostring(m_lua, -1); + if (name != nullptr && name[0] != '\0') + { + error = std::string(name) + ": " + error; + } + SetFatalError(error); lua_pop(m_lua, 1); return false; } return true; } +bool LuaRulesEngine::CallScheduledCallback(int callbackRef) +{ + lua_rawgeti(m_lua, LUA_REGISTRYINDEX, callbackRef); + luaL_unref(m_lua, LUA_REGISTRYINDEX, callbackRef); + + if (lua_pcall(m_lua, 0, 0, 0) != LUA_OK) + { + std::string error = lua_tostring(m_lua, -1); + SetFatalError(std::string("scheduled callback: ") + error); + lua_pop(m_lua, 1); + return false; + } + return true; +} + +void LuaRulesEngine::RunDueScheduledCallbacks(uint64_t nowMs) +{ + std::vector due; + for (auto it = m_scheduledCallbacks.begin(); it != m_scheduledCallbacks.end();) + { + if (it->dueMs <= nowMs) + { + due.push_back(*it); + it = m_scheduledCallbacks.erase(it); + } + else + { + ++it; + } + } + + std::sort(due.begin(), due.end(), [](const ScheduledCallback& a, const ScheduledCallback& b) { + if (a.dueMs != b.dueMs) + { + return a.dueMs < b.dueMs; + } + return a.sequence < b.sequence; + }); + + for (const ScheduledCallback& callback : due) + { + if (!CallScheduledCallback(callback.ref) || m_fatalError) + { + return; + } + } +} + void LuaRulesEngine::Update() { std::lock_guard lock(m_mutex); @@ -243,6 +412,11 @@ void LuaRulesEngine::Update() PruneHistoryLocked(nowMs); PruneNamedStatesLocked(nowMs); m_currentEvent = CurrentEvent{}; + RunDueScheduledCallbacks(nowMs); + if (m_fatalError) + { + return; + } CallHandler("onRulesUpdate"); } @@ -458,6 +632,18 @@ bool LuaRulesEngine::NamedStateActive(const std::string& name, uint64_t nowMs) c return it != m_namedStates.end() && (it->second == 0 || it->second > nowMs); } +bool LuaRulesEngine::StartOnceEvery(const std::string& name, uint32_t durationMs, uint64_t nowMs) +{ + PruneNamedStatesLocked(nowMs); + if (NamedStateActive(name, nowMs)) + { + return false; + } + + m_namedStates[name] = durationMs == 0 ? 0 : nowMs + durationMs; + return true; +} + uint64_t LuaRulesEngine::GetNowMs() const { return static_cast( @@ -544,42 +730,6 @@ int LuaRulesEngine::LuaAttractMode(lua_State* L) return 1; } -int LuaRulesEngine::LuaSwitchClosing(lua_State* L) -{ - lua_pushboolean(L, FromLua(L)->IsRising(EventType::Switch, static_cast(luaL_checkinteger(L, 1)))); - return 1; -} - -int LuaRulesEngine::LuaSwitchOpening(lua_State* L) -{ - lua_pushboolean(L, FromLua(L)->IsFalling(EventType::Switch, static_cast(luaL_checkinteger(L, 1)))); - return 1; -} - -int LuaRulesEngine::LuaLampRising(lua_State* L) -{ - lua_pushboolean(L, FromLua(L)->IsRising(EventType::Lamp, static_cast(luaL_checkinteger(L, 1)))); - return 1; -} - -int LuaRulesEngine::LuaLampFalling(lua_State* L) -{ - lua_pushboolean(L, FromLua(L)->IsFalling(EventType::Lamp, static_cast(luaL_checkinteger(L, 1)))); - return 1; -} - -int LuaRulesEngine::LuaCoilRising(lua_State* L) -{ - lua_pushboolean(L, FromLua(L)->IsRising(EventType::Coil, static_cast(luaL_checkinteger(L, 1)))); - return 1; -} - -int LuaRulesEngine::LuaCoilFalling(lua_State* L) -{ - lua_pushboolean(L, FromLua(L)->IsFalling(EventType::Coil, static_cast(luaL_checkinteger(L, 1)))); - return 1; -} - int LuaRulesEngine::LuaSwitchGroupState(lua_State* L) { lua_pushboolean(L, FromLua(L)->SwitchGroupStateActive(luaL_checkstring(L, 1))); @@ -644,6 +794,28 @@ int LuaRulesEngine::LuaTriggerSequence(lua_State* L) return 1; } +int LuaRulesEngine::LuaOnlyOnceEvery(lua_State* L) +{ + auto* engine = FromLua(L); + const std::string name = luaL_checkstring(L, 1); + const uint32_t durationMs = lua_gettop(L) >= 2 ? static_cast(luaL_checkinteger(L, 2)) : 0; + lua_pushboolean(L, engine->StartOnceEvery(name, durationMs, engine->GetNowMs())); + return 1; +} + +int LuaRulesEngine::LuaAfter(lua_State* L) +{ + auto* engine = FromLua(L); + const lua_Integer delayArg = luaL_checkinteger(L, 1); + luaL_checktype(L, 2, LUA_TFUNCTION); + const uint64_t delayMs = delayArg <= 0 ? 0 : static_cast(delayArg); + lua_pushvalue(L, 2); + const int callbackRef = luaL_ref(L, LUA_REGISTRYINDEX); + engine->m_scheduledCallbacks.push_back( + ScheduledCallback{engine->GetNowMs() + delayMs, engine->m_nextScheduledSequence++, callbackRef}); + return 0; +} + int LuaRulesEngine::LuaPupTrigger(lua_State* L) { auto* engine = FromLua(L); @@ -672,7 +844,15 @@ int LuaRulesEngine::LuaSpeech(lua_State* L) int LuaRulesEngine::LuaEffectTrigger(lua_State* L) { auto* engine = FromLua(L); - const uint16_t id = static_cast(luaL_checkinteger(L, 1)); + uint16_t id = 0; + if (lua_type(L, 1) == LUA_TSTRING) + { + id = HashNamedTriggerId(lua_tostring(L, 1)); + } + else + { + id = static_cast(luaL_checkinteger(L, 1)); + } const uint8_t value = lua_gettop(L) >= 2 ? static_cast(luaL_checkinteger(L, 2)) : 1; if (engine->m_triggerCallback) { @@ -694,6 +874,21 @@ int LuaRulesEngine::LuaSuppressSwitch(lua_State* L) return 0; } +int LuaRulesEngine::LuaSendSwitchToCpu(lua_State* L) +{ + auto* engine = FromLua(L); + if (engine->m_actionCallback) + { + engine->m_actionCallback(RulesAction{RulesActionType::SendSwitchToCpu, + static_cast(luaL_checkinteger(L, 1)), + luaL_checkinteger(L, 2) == 0 ? static_cast(0) : static_cast(1), + 0, + 0, + 0}); + } + return 0; +} + int LuaRulesEngine::LuaPulseCoil(lua_State* L) { auto* engine = FromLua(L); @@ -701,6 +896,7 @@ int LuaRulesEngine::LuaPulseCoil(lua_State* L) { engine->m_actionCallback(RulesAction{RulesActionType::PulseCoil, static_cast(luaL_checkinteger(L, 1)), + 0, static_cast(luaL_checkinteger(L, 2)), 0, 0}); @@ -716,6 +912,7 @@ int LuaRulesEngine::LuaBlinkLamp(lua_State* L) engine->m_actionCallback(RulesAction{RulesActionType::StartBlinkLamp, static_cast(luaL_checkinteger(L, 1)), 0, + 0, static_cast(luaL_checkinteger(L, 2)), static_cast(luaL_checkinteger(L, 3))}); } @@ -727,7 +924,8 @@ int LuaRulesEngine::LuaStopBlinkLamp(lua_State* L) auto* engine = FromLua(L); if (engine->m_actionCallback) { - engine->m_actionCallback(RulesAction{RulesActionType::StopBlinkLamp, static_cast(luaL_checkinteger(L, 1)), 0, 0, 0}); + engine->m_actionCallback( + RulesAction{RulesActionType::StopBlinkLamp, static_cast(luaL_checkinteger(L, 1)), 0, 0, 0, 0}); } return 0; } diff --git a/src/LuaRulesEngine.h b/src/LuaRulesEngine.h index 867b292..21da01f 100644 --- a/src/LuaRulesEngine.h +++ b/src/LuaRulesEngine.h @@ -37,6 +37,7 @@ class LuaRulesEngine void SetActionCallback(ActionCallback callback); void SetSwitchGroups(const std::unordered_map>& switchGroups); bool LoadScript(const char* path, std::string& error); + bool LoadScripts(const std::vector& paths, std::string& error); void Update(); SwitchProcessResult ProcessSwitchState(int number, uint8_t state); @@ -73,6 +74,13 @@ class LuaRulesEngine uint64_t timestampMs = 0; }; + struct ScheduledCallback + { + uint64_t dueMs = 0; + uint64_t sequence = 0; + int ref = 0; + }; + uint8_t GetState(const std::unordered_map& states, int number) const; bool IsRising(EventType type, int number) const; bool IsFalling(EventType type, int number) const; @@ -81,6 +89,7 @@ class LuaRulesEngine bool HistoryContains(uint16_t id, uint32_t windowMs, uint64_t nowMs) const; bool SequenceOccurred(const std::vector& ids, uint32_t windowMs, uint64_t nowMs) const; bool NamedStateActive(const std::string& name, uint64_t nowMs) const; + bool StartOnceEvery(const std::string& name, uint32_t durationMs, uint64_t nowMs); uint64_t GetNowMs() const; void PruneHistoryLocked(uint64_t nowMs); void PruneNamedStatesLocked(uint64_t nowMs); @@ -88,9 +97,17 @@ class LuaRulesEngine bool InitializeLua(std::string& error); void RegisterApi(); + void ClearRegisteredHandlers(); + void ClearScheduledCallbacks(); + void ClearPpucHandlers(); + void CapturePpucHandlers(); + bool LoadScriptIntoState(const char* path, std::string& error); bool CallHandler(const char* name); bool CallHandler(const char* name, int arg1); bool CallHandler(const char* name, int arg1, int arg2); + bool CallRegisteredHandler(int handlerRef, const char* name, const std::vector& args); + bool CallScheduledCallback(int callbackRef); + void RunDueScheduledCallbacks(uint64_t nowMs); void SetFatalError(const std::string& error); static LuaRulesEngine* FromLua(lua_State* L); @@ -100,12 +117,6 @@ class LuaRulesEngine static int LuaCurrentBall(lua_State* L); static int LuaCurrentPlayer(lua_State* L); static int LuaAttractMode(lua_State* L); - static int LuaSwitchClosing(lua_State* L); - static int LuaSwitchOpening(lua_State* L); - static int LuaLampRising(lua_State* L); - static int LuaLampFalling(lua_State* L); - static int LuaCoilRising(lua_State* L); - static int LuaCoilFalling(lua_State* L); static int LuaSwitchGroupState(lua_State* L); static int LuaSwitchGroupClosing(lua_State* L); static int LuaSwitchGroupOpening(lua_State* L); @@ -114,10 +125,13 @@ class LuaRulesEngine static int LuaStateActive(lua_State* L); static int LuaTriggerHistory(lua_State* L); static int LuaTriggerSequence(lua_State* L); + static int LuaOnlyOnceEvery(lua_State* L); + static int LuaAfter(lua_State* L); static int LuaPupTrigger(lua_State* L); static int LuaSpeech(lua_State* L); static int LuaEffectTrigger(lua_State* L); static int LuaSuppressSwitch(lua_State* L); + static int LuaSendSwitchToCpu(lua_State* L); static int LuaPulseCoil(lua_State* L); static int LuaBlinkLamp(lua_State* L); static int LuaStopBlinkLamp(lua_State* L); @@ -128,8 +142,10 @@ class LuaRulesEngine std::unordered_map m_coilStates; std::unordered_map m_namedStates; std::unordered_map> m_switchGroups; + std::unordered_map> m_handlers; std::unordered_set m_suppressedSwitchOpen; std::deque m_history; + std::deque m_scheduledCallbacks; CurrentEvent m_currentEvent; TriggerCallback m_triggerCallback; SpeechCallback m_speechCallback; @@ -140,5 +156,6 @@ class LuaRulesEngine bool m_debug = false; bool m_fatalError = false; std::string m_fatalErrorMessage; + uint64_t m_nextScheduledSequence = 0; mutable std::mutex m_mutex; }; diff --git a/src/RulesAction.h b/src/RulesAction.h index d6f0cd1..ec5cdc5 100644 --- a/src/RulesAction.h +++ b/src/RulesAction.h @@ -4,6 +4,7 @@ enum class RulesActionType { + SendSwitchToCpu, PulseCoil, StartBlinkLamp, StopBlinkLamp @@ -13,8 +14,8 @@ struct RulesAction { RulesActionType type; int number = 0; + uint8_t state = 0; uint32_t durationMs = 0; uint32_t onMs = 0; uint32_t offMs = 0; }; - diff --git a/src/ppuc.cpp b/src/ppuc.cpp index f0127a3..daa12f2 100644 --- a/src/ppuc.cpp +++ b/src/ppuc.cpp @@ -380,6 +380,12 @@ static void PrintFlushedLogLine(const char* prefix, const char* message) fflush(stdout); } +static void SendSwitchToCpu(int number, uint8_t state) +{ + const int switchNumber = (number < 241) ? number : 240 - number; + PinmameSetSwitch(switchNumber, state == 0 ? 0 : 1); +} + struct InterceptorOutputOverrides { struct CoilPulse @@ -500,6 +506,9 @@ struct InterceptorOutputOverrides { switch (action.type) { + case RulesActionType::SendSwitchToCpu: + SendSwitchToCpu(action.number, action.state); + break; case RulesActionType::PulseCoil: PulseCoil(controller, action.number, action.durationMs); break; @@ -1261,6 +1270,59 @@ static const char* DuplicateOptionalIniString(const std::string& value) return DuplicateIniString(value); } +static bool CollectRulesScripts(const char* pathArg, std::vector& scripts, std::string& error) +{ + scripts.clear(); + error.clear(); + + if (!HasOptionValue(pathArg)) + { + error = "Rules path is empty"; + return false; + } + + std::error_code ec; + const std::filesystem::path rulesPath(pathArg); + if (std::filesystem::is_regular_file(rulesPath, ec)) + { + scripts.push_back(rulesPath.string()); + return true; + } + + if (std::filesystem::is_directory(rulesPath, ec)) + { + for (const std::filesystem::directory_entry& entry : std::filesystem::directory_iterator(rulesPath, ec)) + { + if (ec) + { + error = "Unable to read Lua rules directory"; + return false; + } + if (!entry.is_regular_file(ec)) + { + ec.clear(); + continue; + } + const std::filesystem::path filePath = entry.path(); + if (filePath.extension() == ".lua") + { + scripts.push_back(filePath.string()); + } + } + + std::sort(scripts.begin(), scripts.end()); + if (scripts.empty()) + { + error = "Lua rules directory contains no .lua files"; + return false; + } + return true; + } + + error = "Rules path is not a file or directory"; + return false; +} + static const char* kAnsiStrikeOn = "\033[9m"; static const char* kAnsiStrikeOff = "\033[0m"; @@ -3778,13 +3840,24 @@ int main(int argc, char** argv) } }); + std::vector ruleScripts; std::string error; - if (!pLuaRulesEngine->LoadScript(opt_rules, error)) + if (!CollectRulesScripts(opt_rules, ruleScripts, error)) + { + printf("%s: %s\n", error.c_str(), opt_rules); + return 1; + } + + if (!pLuaRulesEngine->LoadScripts(ruleScripts, error)) { printf("%s: %s\n", error.c_str(), opt_rules); return 1; } - printf("Loaded Lua rules from %s\n", opt_rules); + + for (const std::string& ruleScript : ruleScripts) + { + printf("Loaded Lua rules from %s\n", ruleScript.c_str()); + } } PinmameConfig config = { @@ -4132,8 +4205,7 @@ int main(int argc, char** argv) // pinmame. Switches above 240 will become negative values, for example 243 => -3. if (switchProcess.forwardToCpu && (switchState->number < 200 || switchState->number > 241)) { - const int switchNumber = (switchState->number < 241) ? switchState->number : 240 - switchState->number; - PinmameSetSwitch(switchNumber, newSwitchState); + SendSwitchToCpu(switchState->number, newSwitchState); } if (opt_debug || opt_debug_switches) From 57ec8590bc005e6e88842bbd616138344a1b4fb3 Mon Sep 17 00:00:00 2001 From: Markus Kalkbrenner Date: Sun, 28 Jun 2026 19:40:28 +0200 Subject: [PATCH 6/7] added Sys11 GI support, updated docs --- RULES_AND_EFFECTS.md | 482 +++++++++++++------------------------------ src/ppuc.cpp | 15 ++ 2 files changed, 163 insertions(+), 334 deletions(-) diff --git a/RULES_AND_EFFECTS.md b/RULES_AND_EFFECTS.md index c82c30a..54be937 100644 --- a/RULES_AND_EFFECTS.md +++ b/RULES_AND_EFFECTS.md @@ -1,195 +1,134 @@ -# Flash Rules And Effects +# Rules And Effects -This document describes the behavior added by the PPUC layer on top of the original ROM when the game is run through PinMAME. +PPUC rules are Lua scripts loaded by `ppuc-pinmame` with: -The original ROM still owns the game rules, scoring, lamp logic, solenoid timing, switch matrix behavior, ball flow, and attract/game modes. -PPUC adds extra outputs and trigger-driven presentation around that baseline. - -## What PPUC Adds - -PPUC currently adds four kinds of enhancements: - -1. Extra addressable LEDs that do not exist in the original machine. -2. A shaker motor effect layer. -3. Trigger rules that emit DMD or PUP-style events from ROM state changes. -4. Optional speech callouts driven by those trigger rules. - -PPUC does not replace the ROM logic here. -It observes ROM-driven switch, lamp, and coil activity and uses that activity to drive modern extras. - -## Flash Example -### Cabinet LED String - -The Flash game config defines an addressable LED string named `Cabinet` on IO board `1`, port `29`. - -Source: -[Flash_877888bc-06a9-428a-91ab-6d821e104107.yml](../ppuc_games/flash/Flash_877888bc-06a9-428a-91ab-6d821e104107.yml) - -#### Physical Layout - -- LEDs `0-7` are assigned individually. -- LEDs `8-93` are grouped into segment `1`. - -#### LEDs 0-7 - -These are configured as GI-linked cabinet/button/status lights: - -- LED `0`: left flipper button bottom, `FF00FF` -- LED `1`: left flipper button bottom, `004AFF` -- LED `2`: start button, `FF7F00` -- LED `3`: right flipper button top, `FF00FF` -- LED `4`: right flipper button bottom, `004AFF` -- LED `5`: coin 1, `FFFFFF` -- LED `6`: coin 2, `FFFFFF` -- LED `7`: coin 3, `FFFFFF` - -All of these map to GI string `1`, so they follow ROM-driven GI state rather than acting as independent PinMAME lamps. - -#### LEDs 8-93 - -This range is configured as `segment 1`. -The intent of this segment is effect playback, not direct PinMAME lamp ownership. - -Current configured effect: - -- Description: `Exprerssion Lights` -- Segment: `1` -- Color: `0000FF` -- WS2812FX effect: `22` -- Duration: `0` (unlimited) -- Speed: `0` -- Priority: `1` -- Repeat: `-1` - -Under the new model, this segment should be defined in YAML only as an effect target. -The logic deciding when it starts should live only in the rule file. - -### Shaker Motor - -The Flash game config also defines a shaker on IO board `1`, port `19`. - -It is declared as: - -- Type: `shaker` -- Number: `100` -- Power limit: `128` - -This is not ROM-native hardware. -PPUC adds it as a modern feedback device. - -### Configured Shaker Effects - -Two PWM effects should be defined in the Flash YAML. - -#### Jet Bumper Shaker - -- Description: `Jet Bumper Shaker` -- PWM effect: `3` -- Frequency: `4` -- Max intensity: `128` -- Min intensity: `0` -- Duration: `0` -- Priority: `1` -- Repeat: `0` - -Under the new model, this effect remains defined in YAML, but its trigger logic belongs only in the Lua rules file. +```text +--rules +``` -#### Flash Shaker +The path can be one `.lua` file or a directory containing `.lua` files. When a +directory is used, `ppuc-pinmame` loads top-level `.lua` files in filename order. -- Description: `Flash Shaker` -- PWM effect: `1` -- Frequency: `4` -- Max intensity: `192` -- Min intensity: `0` -- Duration: `1000 ms` -- Priority: `2` -- Repeat: `0` +The old trigger-rule file format and separate speech text file are no longer +supported. Speech callouts are written directly in Lua with `ppuc.speech(...)`. -This is a second shaker pattern that should be triggered from the Lua rules file instead of from YAML-generated trigger entries. +## What Rules Can Do -## Lua Rules From `rules.lua` +Lua rules observe PinMAME and physical-machine state, then trigger extra PPUC +behavior: -Rules file: -[rules.lua](/Volumes/data/workspace/PPUC/ppuc_combined/ppuc/examples/rules.lua) +- send DMD/PUP triggers with `ppuc.pupTrigger(...)` +- trigger board-local effects with `ppuc.effectTrigger(...)` +- speak text with `ppuc.speech(...)` +- delay work without blocking with `ppuc.after(...)` +- suppress or later send physical switches to PinMAME +- pulse coils and blink lamps as host-side interceptor output overrides -These rules watch ROM-visible switch, lamp, and coil changes and emit additional actions. +The original ROM still owns scoring, original lamp logic, solenoid timing, +switch matrix behavior, ball flow, and attract/game mode. PPUC adds extra output +and presentation behavior around that baseline. -### Terminology +## Lua API -The word `source` comes from the older effect-trigger vocabulary inherited from DOF, the Direct Output Framework. +Common state helpers: -That old name is still visible in some code and protocol constants. In Lua rules, the target channel is passed explicitly to functions such as `ppuc.pupTrigger(...)`, `ppuc.speech(...)`, and `ppuc.effectTrigger(...)`. +```lua +ppuc.switchState(number) +ppuc.lampState(number) +ppuc.coilState(number) +ppuc.currentBall() +ppuc.currentPlayer() +ppuc.attractMode() +``` -Example: +Named states, timing, and switch groups: + +```lua +ppuc.setState(name) +ppuc.setState(name, durationMs) +ppuc.clearState(name) +ppuc.stateActive(name) +ppuc.onlyOnceEvery(name, durationMs) +ppuc.switchGroupState(name) +ppuc.switchGroupClosing(name) +ppuc.switchGroupOpening(name) +``` -```text -F cabinet-flash-attract 1 : lamp_rising(5) && attract +Outputs and integrations: + +```lua +ppuc.after(delayMs, function() ... end) +ppuc.pupTrigger(source, id, value) +ppuc.speech(text) +ppuc.effectTrigger(id, value) +ppuc.effectTrigger(name, value) +ppuc.suppressSwitch(number) +ppuc.sendSwitchToCpu(number, state) +ppuc.pulseCoil(number, durationMs) +ppuc.blinkLamp(number, onMs, offMs) +ppuc.stopBlinkLamp(number) ``` -Here: +Handlers: -- `F` is the target channel for the effect event that will be emitted. It means effect. -- `cabinet-flash-attract` is the effect trigger ID or name -- `lamp_rising(5) && attract` is the condition +```lua +function ppuc.onSwitchChanged(number, state) +end -There is also a silent channel: +function ppuc.onLampChanged(number, state) +end -- `S` means evaluate the rule and apply state/history side effects, but emit no external trigger. +function ppuc.onCoilChanged(number, state) +end -Inside the rule expression itself we no longer use those old single-character codes. -The conditions are written with readable words: +function ppuc.onBallChanged(ball) +end -- `ball(...)` -- `player(...)` -- `history(...)` -- `sequence(...)` -- `state(...)` -- `switch(...)` -- `switch_group(...)` -- `lamp(...)` -- `coil(...)` -- `switch_rising(...)` -- `switch_rising_group(...)` -- `lamp_rising(...)` -- `coil_rising(...)` -- `attract` +function ppuc.onPlayerChanged(player) +end -Current limitation: -`ball(...)` is currently populated from PinMAME CPU RAM only for Williams System 3, System 4, and System 6 style games. -`player(...)` can now be supplied either by runtime state updates or by rules that use `set_player=`. -Trigger history is retained per player for a rolling time window and is cleared when the table returns to attract mode. -Rules can also use `clear_player_history=` to drop stored history for a specific player before the new trigger is recorded. +function ppuc.onRulesUpdate() +end +``` -### Interceptor Rules +## Interceptor Rules -Interceptor behavior is part of the same rules file. Rules still observe -physical switch state, even when a switch event is suppressed before it reaches -PinMAME. +Interceptor behavior is part of the Lua rules engine. It lets `ppuc-pinmame` +react to physical machine events before they are forwarded to PinMAME. -Additional rule options: +```lua +function ppuc.onSwitchChanged(number, state) + if ppuc.stateActive("ballSave") and number == 9 and state == 1 then + ppuc.suppressSwitch(9) + ppuc.pulseCoil(7, 120) + end +end +``` -- `set_state=` with optional `state_ms=` -- `clear_state=` -- `suppress_switch=` -- `pulse_coil=` with optional `pulse_ms=`; default is 120 ms -- `blink_lamp=` with optional `blink_on_ms=` and - `blink_off_ms=`; defaults are 250/250 ms +Suppressed switches can be sent to PinMAME later: + +```lua +function ppuc.onSwitchChanged(number, state) + if number == 16 and state == 1 then + ppuc.suppressSwitch(16) + ppuc.after(500, function() + ppuc.sendSwitchToCpu(16, 1) + end) + ppuc.after(650, function() + ppuc.sendSwitchToCpu(16, 0) + end) + end +end +``` -Additional expression functions: +`ppuc.after(...)` schedules work on the rules update tick. It does not sleep and +does not block `ppuc-pinmame`. -- `state()` -- `switch_group()` -- `switch_rising_group()` -- `switch_falling_group()` -- `switch_rising(, , ...)` -- `switch_falling(, , ...)` +See `INTERCEPTOR.md` for a focused interceptor reference. -`suppress_switch=` blocks a matching switch close from being forwarded -to PinMAME and also blocks the matching switch open. Host-side coil pulses and -lamp blinking temporarily override normal PinMAME output for that output number; -when the override ends, the last PinMAME output state is restored. +## Switch Groups -Switch groups can be declared in game YAML: +Switch groups are declared in game YAML and loaded by `libppuc`: ```yaml switchGroups: @@ -197,195 +136,70 @@ switchGroups: switches: [10, 11, 12, 13] ``` -The group `buttons` is built in from switches marked `button: true` and cannot -be overridden. +The `buttons` group name is reserved. It is built automatically from switches +marked with `button: true` in `switches` or `switchMatrix.switches`. -Example: +In the config-tool, switch group names are entered on the game node as one name +per line: ```text -S ball-save-ready set_state=ball_save_ready : switch_rising(15) -S ball-save-active set_state=ball_save state_ms=5000 clear_state=ball_save_ready : state(ball_save_ready) && switch_rising_group(playfield) -S shoot-again-blink blink_lamp=8 : state(ball_save_ready) || state(ball_save) -S outhole-save suppress_switch=9 pulse_coil=7 pulse_ms=120 : state(ball_save) && switch_rising(9) +playfield +standups ``` -See `INTERCEPTOR.md` for the complete interceptor feature reference and runtime -behavior notes. - -So the intended reading is: - -- rule header: target/output channel plus trigger ID -- rule body: readable boolean logic over ball, switch, lamp, coil, and attract state - -### DMD / PUP Trigger Rules - -Target channel `D` emits extra trigger IDs for display/video/DMD-side integrations. - -Current `D` triggers: +Switches are added to those groups with the checkboxes on each switch edit page. -- `60001`: Ball 1 -- `60002`: Ball 2 -- `60003`: Ball 3 -- `60005`: Bonus x2 -- `60006`: Bonus x3 -- `60007`: Extra Ball -- `60008`: Flash attract event -- `60009`: Game Over -- `60010`: Highscore -- `60012`: progress-1.mp4 trigger -- `60013`: progress-1.mp4 trigger -- `60014`: progress-1.mp4 trigger -- `60015`: progress-1.mp4 trigger -- `60016`: Shoot Again off -- `60017`: Special -- `60018`: Tilt +## SYS11 Coil-To-GI Mapping -These are not original ROM outputs. -They are interpretation layers built from ROM state transitions. +Some Williams System 11 games use one or more coils to control GI strings or +parts of GI. PPUC supports this with `coilGiMappings` in game YAML: -### Speech Rules - -Speech callout text is defined directly in Lua with `ppuc.speech(...)`. - -Current speech-triggered rule: - -- Highscore: `ppuc.speech("New highscore!")` - -Only IDs emitted by an `O` rule are spoken. -Right now the rules file explicitly emits `O 60010`, so `New highscore!` is the currently wired speech callout from the sample rule set. - -## New Rule-Driven Board Effects - -PPUC now also supports board-local effect triggering directly from the trigger-rule engine. - -This is the new path intended for: - -- named WS2812FX segment effects -- named shaker / WavePWM effects -- any effect that should run on the IO board and not be treated like a normal PinMAME lamp or coil - -### Rule Source - -Target channel `F` in a rules file means: - -- evaluate the rule in `ppuc` -- send a runtime `EVENT_SOURCE_EFFECT` -- let the firmware match that event against configured LED or PWM effects - -### Named Trigger IDs +```yaml +coilGiMappings: + - coil: 12 + gi: 1 + onBrightness: 8 + offBrightness: 0 + - coil: 12 + gi: 2 + onBrightness: 8 + offBrightness: 0 +``` -Rules and named YAML effect definitions can now use names instead of only numeric IDs. +When PinMAME reports the mapped coil as active, `ppuc-pinmame` sets the mapped +GI string to `onBrightness`. When the coil becomes inactive, it sets the GI +string to `offBrightness`. -Example rule: +In the config-tool, coil/GI mappings are entered on the game node as one mapping +per line: ```text -F cabinet-flash-attract 1 : lamp_rising(5) && attract +12: 1,2 = 8/0 +13: 3 = 0/8 ``` -Matching YAML effect definition: +The format is: -```yaml -- name: cabinet-flash-attract - effect: running_random - segment: 1 - color: 0000FF - speed: 0 - duration: 0 - mode: 0 - priority: 1 - repeat: -1 +```text +coil: gi[,gi...] = onBrightness/offBrightness ``` -The name is hashed deterministically on both sides, so the Lua rules file and YAML can refer to the same effect without a hand-maintained numeric ID table. -No YAML `trigger:` block is required for named effects anymore. - -### Sample Effect Rules Added In The Repo - -The sample `rules.lua` contains calls such as: - -- `ppuc.effectTrigger(1000, 1)` - -These demonstrate the intended future direction: +Brightness values are clamped to the PPUC GI range `0..8`. -- cabinet segment effects triggered by game state -- shaker effects triggered by ROM activity +## Board Effect Triggers -Important current status: +Board-local PWM and LED effects are defined in YAML as effect targets. Lua rules +start those effects with `ppuc.effectTrigger(...)`. -- the runtime `F` transport path is implemented -- sample Lua effect rules exist -- named YAML effects can now auto-register themselves as `F` targets - -So the intended setup is now: - -- YAML defines effect targets and parameters -- `rules.lua` defines all boolean logic and trigger conditions -- YAML effect `trigger:` blocks are no longer needed for named effects - -## Config Tool Status - -`../config-tool` now needs one explicit machine effect name per LED or PWM effect. - -- That field is exported as YAML `name:`. -- The old effect-level `trigger:` field has been removed from LED and PWM effect content types. -- Generated YAML now emits only static effect definitions plus the machine effect name. -- All trigger logic now belongs exclusively in the `*.rules` file. - -## Effect Names - -PPUC now accepts named effect values in addition to numeric IDs. - -### WS2812FX Names - -Examples of accepted LED effect names: - -- `static` -- `blink` -- `breath` -- `color_wipe` -- `scan` -- `running_lights` -- `sparkle` -- `strobe` -- `chase_rainbow` -- `running_color` -- `running_random` -- `larson_scanner` -- `comet` -- `fireworks` -- `fire_flicker` -- `tricolor_chase` -- `twinklefox` -- `rain` -- `heartbeat` -- `multi_comet` -- `popcorn` -- `oscillator` - -### PWM / WavePWM Names - -Accepted PWM effect names: - -- `sine` -- `ramp_down_stop` -- `impulse` - -These names make the Flash YAML easier to read than raw numeric effect IDs. - -## Summary - -For Flash, the PPUC layer currently adds: - -- cabinet button/coin/start lighting on an external addressable LED string -- one large cabinet LED segment reserved for effect playback -- shaker motor feedback effects -- extra DMD/PUP trigger events derived from ROM activity -- optional speech callouts - -And it now supports a cleaner next step: - -- rule-driven named board effects via target channel `F` -- named trigger IDs -- named LED and PWM effect modes +```lua +function ppuc.onSwitchChanged(number, state) + if number == 18 and state == 1 then + ppuc.effectTrigger("jet-bumper", 1) + end +end +``` -That combination is the intended mechanism for cabinet animations and shaker patterns that should exist beside the original ROM rather than inside it. +The board effect trigger source is `F` internally. In YAML effect definitions, +use matching `trigger.source: F` plus `trigger.name` or `trigger.number` when a +simple board-side trigger is needed. For new rule-driven behavior, prefer Lua +logic and named effects. diff --git a/src/ppuc.cpp b/src/ppuc.cpp index daa12f2..1ab5735 100644 --- a/src/ppuc.cpp +++ b/src/ppuc.cpp @@ -2806,6 +2806,21 @@ void PINMAMECALLBACK OnSolenoidUpdated(PinmameSolenoidState* p_solenoidState, co g_interceptorOutputs.ApplyPinmameCoil(ppuc, p_solenoidState->solNo, coilState); + for (const PPUCCoilGiMapping& mapping : ppuc->GetCoilGiMappings()) + { + if (mapping.coil != p_solenoidState->solNo) + { + continue; + } + const uint8_t brightness = coilState != 0 ? mapping.onBrightness : mapping.offBrightness; + if (opt_debug || opt_debug_coils) + { + printf("Coil GI mapping: solenoid=%d, state=%d, gi=%u, brightness=%u\n", p_solenoidState->solNo, coilState, + mapping.gi, brightness); + } + ppuc->SetGIState(mapping.gi, brightness); + } + if (isGameOnCoil) { ball_search_game_running.store(coilState != 0, std::memory_order_release); From c0a6a64173fa60c0602babdaaaeca396cfc30a33 Mon Sep 17 00:00:00 2001 From: Markus Kalkbrenner Date: Tue, 30 Jun 2026 21:53:46 +0200 Subject: [PATCH 7/7] updated deps --- .github/workflows/ppuc.yml | 2 +- .gitignore | 1 + platforms/config.sh | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ppuc.yml b/.github/workflows/ppuc.yml index 03ce1ea..d348647 100644 --- a/.github/workflows/ppuc.yml +++ b/.github/workflows/ppuc.yml @@ -1,6 +1,6 @@ name: ppuc on: - workflow_dispatch: + pull_request: defaults: run: diff --git a/.gitignore b/.gitignore index 2ce5455..46cec1e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ third-party/runtime-libs/ third-party/build-libs/ third-party/include/ third-party/pinmame-nvram-maps +third-party/lua-src diff --git a/platforms/config.sh b/platforms/config.sh index 1157b19..4dbfdfa 100644 --- a/platforms/config.sh +++ b/platforms/config.sh @@ -9,8 +9,8 @@ ESPEAK_NG_SHA=1.52.0 LUA_VERSION=5.4.8 PINMAME_SHA=bf74d40ef837bdfc377c0266c0ef71b3ed59a751 PINMAME_NVRAM_MAPS_SHA=fa1086d57118e12f4802f3a9683c1e6acfb6ec6d -LIBPPUC_SHA=715dd76055e2552b31dcff35508721bf0445a396 -LIBSDLDMD_SHA=87c804e1ae53b86846dfb2e8a8fafb908e417e82 +LIBPPUC_SHA=93afce7bfcef68a7766283fb129b0d85f6838229 +LIBSDLDMD_SHA=72a7e9777af59fe430c6f6ae77159e0b8c7301b4 PPUC_SOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PPUC_LOCAL_DEPS_ROOT="${PPUC_LOCAL_DEPS_ROOT:-$(cd "${PPUC_SOURCE_ROOT}/.." && pwd)}"