From 735bc8d3457878a4508e484aa6e37b56646f8f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20Neum=C3=BCller?= Date: Tue, 25 Aug 2026 22:38:10 +0200 Subject: [PATCH 1/2] fix(sim): PushButton honors Reset and InputDisable connectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parser signature for PushButton/PushButton2/PushButton2Sel only mapped InputTrigger and On, so a wired Reset landed at an input index the eval never read — a PushButton driven off via Reset stayed on forever. - parser: add Reset + InputDisable to the PushButton and PushButtonSel input signatures (synthesized when absent, so layouts stay stable) - interpreter: Reset dominates On and forces off; InputDisable gates the trigger edge (PushButton and PushButton2; assumed behavior, flagged) - JIT: same semantics in EvalStep::PushButton (usize::MAX sentinel for absent connectors) so interpreter/JIT equivalence holds Found while simulating the r50 UV-lamp weekday scenario (PushButton with On: weekday-Or, Reset: Not(weekday-Or)). --- lox-sim/src/blocks/state.rs | 22 +++++++++++++++++++--- lox-sim/src/compiler.rs | 16 ++++++++++++++-- lox-sim/src/parser.rs | 4 ++-- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/lox-sim/src/blocks/state.rs b/lox-sim/src/blocks/state.rs index 113c92e4..3a4eaddb 100644 --- a/lox-sim/src/blocks/state.rs +++ b/lox-sim/src/blocks/state.rs @@ -252,12 +252,18 @@ impl Block for PushButton { ) -> Vec { let trigger = inputs.first().copied().unwrap_or(0.0); let force_on = inputs.get(1).copied().unwrap_or(0.0); + let reset = inputs.get(2).copied().unwrap_or(0.0); + let disable = inputs.get(3).copied().unwrap_or(0.0); let prev_trigger = prev_inputs.first().copied().unwrap_or(0.0); let previous = self.is_on; - if is_high(force_on) { + // WARNING: Assumed behavior — not validated against Miniserver. + // Assumption: Reset dominates On; InputDisable gates only the trigger. + if is_high(reset) { + self.is_on = false; + } else if is_high(force_on) { self.is_on = true; - } else if !is_high(prev_trigger) && is_high(trigger) { + } else if !is_high(disable) && !is_high(prev_trigger) && is_high(trigger) { self.is_on = !self.is_on; } @@ -329,10 +335,20 @@ impl Block for PushButton2 { prev_inputs: &[Signal], ) -> Vec { let trigger = inputs.first().copied().unwrap_or(0.0); + let reset = inputs.get(2).copied().unwrap_or(0.0); + let disable = inputs.get(3).copied().unwrap_or(0.0); let prev_trigger = prev_inputs.first().copied().unwrap_or(0.0); let dc_window = params.first().copied().unwrap_or(0.4).max(0.0); let previous = self.is_on; - let rising = !is_high(prev_trigger) && is_high(trigger); + // WARNING: Assumed behavior — not validated against Miniserver. + // Assumption: Reset dominates and cancels a pending double-click; + // InputDisable gates only the trigger. + if is_high(reset) { + self.is_on = false; + self.awaiting_second = false; + } + let rising = + !is_high(reset) && !is_high(disable) && !is_high(prev_trigger) && is_high(trigger); let mut double_click = false; if self.awaiting_second { diff --git a/lox-sim/src/compiler.rs b/lox-sim/src/compiler.rs index 973058e3..2c6b2f5e 100644 --- a/lox-sim/src/compiler.rs +++ b/lox-sim/src/compiler.rs @@ -217,6 +217,10 @@ pub enum EvalStep { trigger: usize, prev_trigger: usize, force_on: usize, + /// Reset/InputDisable signal slots; `usize::MAX` when the block has + /// no such connector (PushButtonSel layouts vary). + reset: usize, + disable: usize, /// outputs: [Q, Qoff, Qon] outputs: [usize; 3], state_idx: usize, @@ -662,6 +666,8 @@ impl CompiledGraph { trigger: resolved_inputs.first().copied().unwrap_or(0), prev_trigger: prev_inputs.first().copied().unwrap_or(0), force_on: resolved_inputs.get(1).copied().unwrap_or(0), + reset: resolved_inputs.get(2).copied().unwrap_or(usize::MAX), + disable: resolved_inputs.get(3).copied().unwrap_or(usize::MAX), outputs: [ *outputs.first().unwrap_or(&0), *outputs.get(1).unwrap_or(&0), @@ -1315,20 +1321,26 @@ impl CompiledGraph { trigger, prev_trigger, force_on, + reset, + disable, outputs, state_idx, } => { let trig = self.signals[*trigger]; let prev_trig = self.prev_signals[*prev_trigger]; let force = self.signals[*force_on]; + let rst = self.signals.get(*reset).copied().unwrap_or(0.0); + let dis = self.signals.get(*disable).copied().unwrap_or(0.0); let outs = *outputs; let si = *state_idx; if let BlockState::PushButton { is_on } = &mut self.state[si] { let previous = *is_on; - if force >= 0.5 { + if rst >= 0.5 { + *is_on = false; + } else if force >= 0.5 { *is_on = true; - } else if prev_trig < 0.5 && trig >= 0.5 { + } else if dis < 0.5 && prev_trig < 0.5 && trig >= 0.5 { *is_on = !*is_on; } let qon = !previous && *is_on; diff --git a/lox-sim/src/parser.rs b/lox-sim/src/parser.rs index e46bd6c2..3c612407 100644 --- a/lox-sim/src/parser.rs +++ b/lox-sim/src/parser.rs @@ -605,12 +605,12 @@ fn block_signature( &["TimeHigh", "TimeLow"], ), "PushButton" | "PushButton2" | "PushButton2Sel" => ( - &["InputTrigger", "On"], + &["InputTrigger", "On", "Reset", "InputDisable"], &["Q", "Qoff", "Qon", "AQ"], &["Min", "Max"], ), "PushButtonSel" => ( - &["InputTrigger", "InputPos", "Reset"], + &["InputTrigger", "InputPos", "Reset", "InputDisable"], &["AQ"], &["Min", "Max", "Step", "Repeat", "Def"], ), From 1fb350d6edcc5063128023e96a53bcdd49a7866d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20Neum=C3=BCller?= Date: Tue, 25 Aug 2026 22:43:29 +0200 Subject: [PATCH 2/2] fix(sim): Monoflop Reset input; InputRef mirrors its fed side to Q and AQ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Monoflop: the parser signature lacked Reset, so a wired Reset was never read and a running pulse could not be aborted. Reset now clears the countdown and blocks retriggering while held (interpreter + JIT; assumed behavior, flagged). - InputRef: the impl forwarded I->Q and AI->AQ separately, but the Miniserver mirrors the one fed side onto BOTH outputs — the r50 corpus wires 'ref.AI <- mem.AQ' with consumers reading ref.Q. Q is now the digital view (non-zero -> 1) and AQ the analog value of whichever side is fed. Found while simulating the r50 alarm chain (Monoflop 'alarm_halten' with Reset: heimkommen_ref.Q, triggered through an AI-fed InputRef). --- lox-sim/src/blocks/io.rs | 21 ++++++++++++++------- lox-sim/src/blocks/timers.rs | 8 +++++++- lox-sim/src/compiler.rs | 9 ++++++++- lox-sim/src/parser.rs | 2 +- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/lox-sim/src/blocks/io.rs b/lox-sim/src/blocks/io.rs index c83736af..a622c043 100644 --- a/lox-sim/src/blocks/io.rs +++ b/lox-sim/src/blocks/io.rs @@ -46,7 +46,12 @@ macro_rules! passthrough_io_block { // --------------------------------------------------------------------------- /// Input reference — proxy that forwards named inputs to the block graph. -/// I→Q (digital) and AI→AQ (analog). +/// +/// A ref is fed on exactly one side (I or AI) but the Miniserver mirrors the +/// signal on BOTH outputs: consumers routinely read Q from an AI-fed ref +/// (r50 corpus: `ref.AI <- mem.AQ` with `monoflop.InputTrigger: ref.Q`). +/// Q is the digital view (non-zero → 1), AQ the analog value; the unfed +/// side idles at 0, so combining the two inputs is lossless. #[derive(Clone, Copy)] pub struct InputRef; @@ -60,7 +65,9 @@ impl Block for InputRef { ) -> Vec { let i = inputs.first().copied().unwrap_or(0.0); let ai = inputs.get(1).copied().unwrap_or(0.0); - vec![i, ai] + let q = if i != 0.0 || ai != 0.0 { 1.0 } else { 0.0 }; + let aq = if ai != 0.0 { ai } else { i }; + vec![q, aq] } fn block_type(&self) -> &str { @@ -260,12 +267,12 @@ mod tests { use super::*; use crate::blocks::create_block; #[test] - fn input_ref_passthrough() { + fn input_ref_mirrors_fed_side_to_both_outputs() { let mut block = InputRef; - // I=42, AI=0 → Q=42, AQ=0 - assert_eq!(block.eval(&[42.0], &[], 0.0, &[]), vec![42.0, 0.0]); - // I=0, AI=99 → Q=0, AQ=99 - assert_eq!(block.eval(&[0.0, 99.0], &[], 0.0, &[]), vec![0.0, 99.0]); + // I=42, AI unfed → Q=1 (digital view), AQ=42 + assert_eq!(block.eval(&[42.0], &[], 0.0, &[]), vec![1.0, 42.0]); + // I unfed, AI=99 → Q=1, AQ=99 (AI-fed refs serve Q consumers) + assert_eq!(block.eval(&[0.0, 99.0], &[], 0.0, &[]), vec![1.0, 99.0]); // empty → Q=0, AQ=0 assert_eq!(block.eval(&[], &[], 0.0, &[]), vec![0.0, 0.0]); } diff --git a/lox-sim/src/blocks/timers.rs b/lox-sim/src/blocks/timers.rs index 0e3dbee2..bacc3e59 100644 --- a/lox-sim/src/blocks/timers.rs +++ b/lox-sim/src/blocks/timers.rs @@ -30,10 +30,16 @@ impl Block for Monoflop { prev_inputs: &[Signal], ) -> Vec { let trigger = inputs.first().copied().unwrap_or(0.0); + let reset = inputs.get(1).copied().unwrap_or(0.0); let prev_trigger = prev_inputs.first().copied().unwrap_or(0.0); let duration = params.first().copied().unwrap_or(1.0).max(0.0); - if !is_high(prev_trigger) && is_high(trigger) { + // WARNING: Assumed behavior — not validated against Miniserver. + // Assumption: Reset aborts the running pulse and blocks retriggering + // while held. + if is_high(reset) { + self.remaining = 0.0; + } else if !is_high(prev_trigger) && is_high(trigger) { self.remaining = duration.max(dt); } diff --git a/lox-sim/src/compiler.rs b/lox-sim/src/compiler.rs index 2c6b2f5e..fe4e74fb 100644 --- a/lox-sim/src/compiler.rs +++ b/lox-sim/src/compiler.rs @@ -137,6 +137,8 @@ pub enum EvalStep { Monoflop { trigger: usize, prev_trigger: usize, + /// Reset signal slot; `usize::MAX` when the block has no Reset wire. + reset: usize, param_duration: usize, output: usize, state_idx: usize, @@ -524,6 +526,7 @@ impl CompiledGraph { EvalStep::Monoflop { trigger: resolved_inputs.first().copied().unwrap_or(0), prev_trigger: prev_inputs.first().copied().unwrap_or(0), + reset: resolved_inputs.get(1).copied().unwrap_or(usize::MAX), param_duration: params.first().copied().unwrap_or(0), output: outputs[0], state_idx: si, @@ -975,18 +978,22 @@ impl CompiledGraph { EvalStep::Monoflop { trigger, prev_trigger, + reset, param_duration, output, state_idx, } => { let trig = self.signals[*trigger]; let prev_trig = self.prev_signals[*prev_trigger]; + let rst = self.signals.get(*reset).copied().unwrap_or(0.0); let duration = self.signals[*param_duration].max(0.0); let out = *output; let si = *state_idx; if let BlockState::Timer { remaining, .. } = &mut self.state[si] { - if prev_trig < 0.5 && trig >= 0.5 { + if rst >= 0.5 { + *remaining = 0.0; + } else if prev_trig < 0.5 && trig >= 0.5 { *remaining = duration.max(dt); } let q = *remaining > 0.0; diff --git a/lox-sim/src/parser.rs b/lox-sim/src/parser.rs index 3c612407..2fe776b7 100644 --- a/lox-sim/src/parser.rs +++ b/lox-sim/src/parser.rs @@ -574,7 +574,7 @@ fn block_signature( &["Q1", "Q2", "Q3", "Q4", "AQ"], &["Time", "V1", "V2", "V3", "V4"], ), - "Monoflop" => (&["InputTrigger"], &["Q"], &["Time"]), + "Monoflop" => (&["InputTrigger", "Reset"], &["Q"], &["Time"]), "Minmax" => ( &["Input1", "Input2", "Input3", "Input4"], &["AQmin", "AQmax"],