Skip to main content

open_stomp/
capture.rs

1//! Reader for the capture logs in `captures/logs/` (docs/07):
2//! `<unix ms> <TX|RX> <len> [<endpoint>] <hex…>` and `# MARK <ms> <text>`.
3
4use crate::midi::Assembler;
5use crate::state::Origin;
6
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub enum Entry {
9    Mark { ms: u64, text: String },
10    Message { ms: u64, origin: Origin, bytes: Vec<u8> },
11}
12
13/// Parse a whole capture log into complete messages, reassembling split SysEx.
14pub fn parse(text: &str) -> Vec<Entry> {
15    let (mut tx, mut rx) = (Assembler::default(), Assembler::default());
16    let mut out = vec![];
17    for line in text.lines() {
18        if let Some(rest) = line.strip_prefix("# MARK ") {
19            let (ms, text) = rest.split_once(' ').unwrap_or((rest, ""));
20            out.push(Entry::Mark { ms: ms.parse().unwrap_or(0), text: text.to_owned() });
21            continue;
22        }
23        if line.starts_with('#') {
24            continue;
25        }
26        let mut parts = line.splitn(3, ' ');
27        let (Some(ms), Some(dir), Some(rest)) = (parts.next(), parts.next(), parts.next()) else {
28            continue;
29        };
30        let (origin, asm) = match dir {
31            "TX" => (Origin::Host, &mut tx),
32            "RX" | "MON" => (Origin::Pedal, &mut rx),
33            _ => continue,
34        };
35        let Some(hex) = rest.split_once(']').map(|(_, h)| h) else { continue };
36        let data: Vec<u8> = hex.split_whitespace().filter_map(|h| u8::from_str_radix(h, 16).ok()).collect();
37        let ms = ms.parse().unwrap_or(0);
38        for bytes in asm.push(&data) {
39            out.push(Entry::Message { ms, origin, bytes });
40        }
41    }
42    out
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48    use crate::catalog::Module;
49    use crate::state::{Category, DeviceState};
50
51    fn main_session() -> Vec<Entry> {
52        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/captures/logs/01-main-session.log");
53        parse(&std::fs::read_to_string(path).expect("capture log"))
54    }
55
56    #[test]
57    fn replay_main_session() {
58        let mut st = DeviceState::default();
59        let mut unknown = 0;
60        let mut handshake_done = false;
61        for e in main_session() {
62            let Entry::Message { origin, bytes, .. } = e else { continue };
63            let ev = st.apply(&bytes, origin);
64            if ev.category == Category::Unknown {
65                unknown += 1;
66            }
67            if !handshake_done && st.presets.iter().all(|p| p.is_some()) {
68                handshake_done = true;
69                assert_eq!(st.firmware.as_deref(), Some("V1.1.1"));
70                assert_eq!(st.model_id.as_deref(), Some("JME-01"));
71                assert_eq!(st.product.as_deref(), Some("JME STOMPBOX"));
72                for (i, p) in st.presets.iter().enumerate() {
73                    let p = p.as_ref().unwrap();
74                    assert_eq!(p.index as usize, i);
75                    assert!(!p.name.is_empty(), "preset {i} has a name");
76                    assert!(p.name.chars().all(|c| c.is_ascii_graphic() || c == ' '), "{:?}", p.name);
77                    assert!(p.modules[Module::Amp.index()].model < 40);
78                }
79                // the edit buffer after the handshake is P01
80                assert!(st.live_known);
81            }
82        }
83        assert!(handshake_done);
84        assert!(unknown < 10, "{unknown} unknown messages");
85        let s = st.settings.expect("settings");
86        assert!(s.input_trim <= 20);
87    }
88
89    /// Every preset / settings record on the wire re-encodes to the same bytes, and the
90    /// param-count bytes agree with the catalogue.
91    #[test]
92    fn records_round_trip() {
93        use crate::midi::{Msg, host};
94        use crate::records::{Preset, Settings, param_count};
95        let (mut presets, mut settings) = (0, 0);
96        for e in main_session() {
97            let Entry::Message { bytes, .. } = e else { continue };
98            let Msg::Nep { cmd, dir, payload } = Msg::parse(&bytes) else { continue };
99            if dir != 1 && dir != 2 {
100                continue;
101            }
102            match cmd {
103                0x0B | 0x0C => {
104                    let p = Preset::from_packed(&payload).unwrap();
105                    assert_eq!(p.to_packed(), payload, "preset {}", p.name);
106                    for m in Module::EFFECTS {
107                        let count = p.raw[[25, 32, 41, 61, 66, 73, 20, 48][m.index()]];
108                        let model = p.modules[m.index()].model;
109                        assert_eq!(count, param_count(m, model), "{} {} model {model}", p.name, m.name());
110                    }
111                    if dir == 1 {
112                        assert_eq!(host::preset_write(&p), bytes);
113                    }
114                    presets += 1;
115                }
116                0x14 => {
117                    let s = Settings::from_packed(&payload).unwrap();
118                    assert_eq!(s.to_packed(), payload);
119                    if dir == 1 {
120                        assert_eq!(host::settings_write(&s), bytes);
121                    }
122                    settings += 1;
123                }
124                _ => {}
125            }
126        }
127        assert!(presets >= 100 && settings > 100, "{presets} presets, {settings} settings");
128    }
129
130    /// Rebuild the captured "save as" (P69 renamed "4Ooz!" and stored to P22) from state alone.
131    #[test]
132    fn save_as_matches_capture() {
133        use crate::midi::host;
134        let entries = main_session();
135        let save_at = entries
136            .iter()
137            .position(|e| matches!(e, Entry::Message { origin: Origin::Host, bytes, .. } if bytes.get(5) == Some(&0x12)))
138            .unwrap();
139        let mut st = DeviceState::default();
140        for e in &entries[..save_at] {
141            if let Entry::Message { origin, bytes, .. } = e {
142                st.apply(bytes, *origin);
143            }
144        }
145        assert_eq!(st.current, Some(0x44));
146        let mut p = st.live.clone();
147        p.index = 0x15;
148        p.name = "4Ooz!".into();
149        let ours = host::save_preset(st.current.unwrap(), &p);
150        let theirs: Vec<Vec<u8>> = entries[save_at..]
151            .iter()
152            .filter_map(|e| match e {
153                Entry::Message { origin: Origin::Host, bytes, .. } => Some(bytes.clone()),
154                _ => None,
155            })
156            .take(3)
157            .collect();
158        assert_eq!(ours, theirs);
159    }
160
161    /// Every captured NAM and IR import is rebuilt byte for byte from its decoded parts (slot,
162    /// name, factor, floats), and the imported names reach the state.
163    #[test]
164    fn uploads_match_capture() {
165        use crate::midi::{Cmd, Msg};
166        use crate::upload::{self, Chunk, Reassembler, UserAmpInfo, amp_sub};
167
168        let host: Vec<Vec<u8>> = main_session()
169            .into_iter()
170            .filter_map(|e| match e {
171                Entry::Message { origin: Origin::Host, bytes, .. } => Some(bytes),
172                _ => None,
173            })
174            .collect();
175        let nep = |b: &[u8]| match Msg::parse(b) {
176            Msg::Nep { cmd, dir: 1, payload } => Some((cmd, payload)),
177            _ => None,
178        };
179        // the data chunks from `from` on, until one upload is complete: (index of its last chunk, slot, floats)
180        let collect = |from: usize| -> (usize, u8, Vec<f32>) {
181            let mut r = Reassembler::default();
182            for (k, b) in host.iter().enumerate().skip(from) {
183                let (_, p) = nep(b).expect("data chunk");
184                if let Some((slot, floats)) = r.push(&Chunk::parse(&p).unwrap()) {
185                    return (k, slot, floats);
186                }
187            }
188            panic!("incomplete upload");
189        };
190        let (mut amps, mut irs) = (0, 0);
191        let mut i = 0;
192        while i < host.len() {
193            let Some((cmd, payload)) = nep(&host[i]) else {
194                i += 1;
195                continue;
196            };
197            let chunk = Chunk::parse(&payload);
198            if cmd == Cmd::UserAmp as u8 && let Some(info) = UserAmpInfo::parse(&payload) {
199                // NAM import: slot-info record, then the data
200                let (end, slot, floats) = collect(i + 1);
201                assert_eq!((slot, floats.len(), info.factor), (info.slot, upload::UPLOAD_FLOATS, 1.0));
202                let ours = upload::user_amp_upload(&UserAmpInfo::new(info.slot, &info.name, info.factor), &floats);
203                assert_eq!(ours, host[i..=end], "NAM import {:?}", info.name);
204                amps += 1;
205                i = end;
206            } else if cmd == Cmd::IrData as u8 && chunk.as_ref().is_some_and(|c| c.offset == 0) {
207                // IR import: the data, then the 0x09 name write
208                let (end, slot, floats) = collect(i);
209                let name = crate::records::parse_ir_slot(&nep(&host[end + 1]).unwrap().1).unwrap().1;
210                assert_eq!(upload::user_ir_upload(slot, &name, &floats), host[i..=end + 1], "IR import {name:?}");
211                irs += 1;
212                i = end + 1;
213            } else if cmd == Cmd::UserAmp as u8 && let Some(c) = chunk.filter(|c| c.sub == amp_sub::DELETE) {
214                assert_eq!(upload::user_amp_delete(c.slot), host[i]);
215            }
216            i += 1;
217        }
218        assert_eq!((amps, irs), (3, 1), "FNDR twice + Jcm800, one IR");
219
220        let mut st = DeviceState::default();
221        for e in main_session() {
222            if let Entry::Message { origin, bytes, .. } = e {
223                st.apply(&bytes, origin);
224            }
225        }
226        assert_eq!(st.user_amps[0].as_deref(), Some("FNDR BFSR VB Edge WRM2 CAB FREE"));
227        assert_eq!(st.user_amps[2].as_deref(), Some("Jcm800"));
228        assert_eq!(st.user_irs[0].as_deref(), Some("Delay And Reverb"));
229    }
230
231    #[test]
232    fn preset_switch_applies_cached_preset() {
233        let mut st = DeviceState::default();
234        for e in main_session() {
235            if let Entry::Message { origin, bytes, .. } = e {
236                st.apply(&bytes, origin);
237                if bytes[0] & 0xF0 == 0xC0 && st.presets.iter().all(|p| p.is_some()) {
238                    let i = bytes[1];
239                    assert_eq!(st.current, Some(i));
240                    assert_eq!(st.live.name, st.presets[i as usize].as_ref().unwrap().name);
241                }
242            }
243        }
244    }
245}