From 3f0aaa877c09a159e1976bbccaf12e98561975be Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Sun, 27 Sep 2026 09:45:00 -0400 Subject: [PATCH 1/7] =?UTF-8?q?fix(emerald):=20map=20channel=20IDs=20from?= =?UTF-8?q?=20EM=20Soft=20and=20honor=20the=20[ValU]=20pressure=20unit=20?= =?UTF-8?q?=E2=80=94=20closes=20#93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IDs 1, 15, 24, 26, 31 and 41 were guessed wrong: 41 is Throttle Pos, 1 is the lambda input (mV), 24 is Ign Adv, 26 is Inj Duration (%), 15 is Boost PWM and 31 is the 0-15 load site. MAP is stored in kPa or mbar per the second [ValU] value, so a kPa-unit log read 10x low. Adds the issue #93 log as a fixture. --- CLAUDE.md | 17 +- .../emerald/EM Log MG ZS Turbo boost run.lg1 | Bin 0 -> 48000 bytes .../emerald/EM Log MG ZS Turbo boost run.lg2 | 22 ++ src/parsers/emerald.rs | 228 +++++++++++++++--- 4 files changed, 236 insertions(+), 31 deletions(-) create mode 100644 exampleLogs/emerald/EM Log MG ZS Turbo boost run.lg1 create mode 100644 exampleLogs/emerald/EM Log MG ZS Turbo boost run.lg2 diff --git a/CLAUDE.md b/CLAUDE.md index e2612c9..dd6ecd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -504,6 +504,21 @@ because it also leads with a `Time` column, and only matches a first column of e by name (lowercased) in `find_gps_channels` (`src/ui/widgets/track_map.rs`); renaming or suffixing them silently disables the Track Map for MSL logs. +**Emerald parser load-bearing behaviors** (`src/parsers/emerald.rs`): + +- **Channel IDs come from EM Soft's variable list, and most are unconfirmed** - the `[chanN]` + value in the `.lg2` is an ID into EM Soft's logger variable list, which Emerald does not + document. Only IDs 1, 15, 20, 24, 26, 31, 32 and 41 are confirmed (issue #93, reporter's EM + Soft labels). The rest are original guesses. ID 1 is the K6 lambda input in mV (not TPS), + 41 is TPS, 31 is the 0-15 load site (not injector duty). Confirm an ID against an EM Soft + screenshot before renaming it. `spec/OECUASpecs/adapters/emerald/emerald-lg.adapter.yaml` + still carries the old guesses in `internal_id`; UltraLog does not read that field. +- **MAP scaling depends on `[ValU]`** - the second `[ValU]` value is the logger's pressure unit: + `1` stores MAP in whole kPa, `2` in mbar. Hardcoding either one makes the other log read 10x + wrong. Unknown codes keep the mbar scaling. +- **Ign Adv (ID 24) offset is inferred** - `raw * 0.1 - 50` gives 5.5° at idle and 14-17° at + 230 kPa boost. It is not from Emerald documentation. + **Haltech parser load-bearing behaviors** (`src/parsers/haltech.rs`, added for wall-clock-timestamped exports): - **Last-known-value substitution** - Unparseable/blank fields are filled with the last successfully parsed value for that column (`0.0` before the first valid sample), matching the approach used in `parsers/ecumaster.rs`. This preserves column alignment instead of shifting subsequent columns when a field fails to parse. @@ -646,7 +661,7 @@ Example log files are in `exampleLogs/` organized by ECU type: - `exampleLogs/aim/` - AiM XRK/DRK files - `exampleLogs/link/` - Link ECU LLG files - `exampleLogs/woolich/` - Woolich Racing Tuned CSV exports -- `exampleLogs/emerald/` - Emerald K6/M3D `.lg1`/`.lg2` files +- `exampleLogs/emerald/` - Emerald K6/M3D `.lg1`/`.lg2` files (`EM Log MG ZS Turbo boost run` is the issue #93 log, `[ValU]` pressure unit kPa; the others use mbar) - `exampleLogs/megasquirt/` - MegaSquirt TunerStudio CSV exports, plus a `_gps.mlg` fixture with synthetic GPS channels (generated by `examples/inject_fake_gps_mlg.rs`; the coordinates are a fake closed loop, not a real location) - `exampleLogs/mhd/` - MHD Tuning CSV exports (VIN redacted) - `exampleLogs/msl/` - TunerStudio MSL excerpt from issue #86 (RealDash logging a Speeduino; trimmed to 2000 data rows, keeping the leading no-GPS-fix rows where `GPS Date`/`GPS Time` are blank) diff --git a/exampleLogs/emerald/EM Log MG ZS Turbo boost run.lg1 b/exampleLogs/emerald/EM Log MG ZS Turbo boost run.lg1 new file mode 100644 index 0000000000000000000000000000000000000000..1d143ac770087f5c30f81572ec8d052c9466ff82 GIT binary patch literal 48000 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 literal 0 HcmV?d00001 diff --git a/exampleLogs/emerald/EM Log MG ZS Turbo boost run.lg2 b/exampleLogs/emerald/EM Log MG ZS Turbo boost run.lg2 new file mode 100644 index 0000000..ecdce5c --- /dev/null +++ b/exampleLogs/emerald/EM Log MG ZS Turbo boost run.lg2 @@ -0,0 +1,22 @@ +[chan1] +20 +[chan2] +41 +[chan3] +1 +[chan4] +32 +[chan5] +24 +[chan6] +26 +[chan7] +15 +[chan8] +31 +[ValU] +0 +1 +0 +0 +0 diff --git a/src/parsers/emerald.rs b/src/parsers/emerald.rs index 3c37027..44a0f41 100644 --- a/src/parsers/emerald.rs +++ b/src/parsers/emerald.rs @@ -9,6 +9,11 @@ //! - LG1 file: 24-byte records (8-byte OLE timestamp + 8 x 2-byte u16 values) //! //! The channel IDs map to specific ECU parameters (RPM, TPS, temperatures, etc.) +//! +//! LG1 values are stored as the logger's display value times a fixed factor +//! (TPS 0-1000 for 0-100.0 %), not as raw ECU bytes. Pressure is the exception: +//! the `[ValU]` section of the LG2 records the pressure unit the logger was set +//! to, and MAP is stored in that unit (see [`PressureUnit`]). use serde::Serialize; use std::error::Error; @@ -28,14 +33,56 @@ struct ChannelDefinition { offset: f64, } +/// Pressure unit recorded in the second value of the LG2 `[ValU]` section. +/// +/// Two logs from the same car show the effect: with `1` MAP is stored in whole +/// kPa (25-235), with `2` it is stored in mbar (586 at idle, 2327 on boost). +/// Any other code keeps the mbar scaling the parser has always used. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum PressureUnit { + Kpa, + #[default] + Mbar, +} + +impl PressureUnit { + fn from_valu(code: Option) -> Self { + match code { + Some(1) => PressureUnit::Kpa, + _ => PressureUnit::Mbar, + } + } + + /// Scale from the stored value to kPa + fn kpa_scale(self) -> f64 { + match self { + PressureUnit::Kpa => 1.0, + PressureUnit::Mbar => 0.1, + } + } +} + +/// Channel layout and unit settings read from an LG2 file +#[derive(Clone, Debug, PartialEq)] +struct Lg2Config { + /// (slot, channel ID) pairs, sorted by slot + channels: Vec<(u8, u8)>, + pressure_unit: PressureUnit, +} + /// Get channel definition for a known channel ID -fn get_channel_definition(id: u8) -> ChannelDefinition { +/// +/// IDs 1, 15, 20, 24, 26, 31, 32 and 41 are confirmed against EM Soft by the +/// log attached to issue #93 (MG ZS turbo). The other IDs are unconfirmed +/// guesses from the original reverse engineering. +fn get_channel_definition(id: u8, pressure_unit: PressureUnit) -> ChannelDefinition { match id { - // Core engine parameters + // "AFR/Lambda" in EM Soft: the K6's own lambda input, a 0-1 V + // narrowband signal stored in mV (every log tops out at 900). 1 => ChannelDefinition { - name: "TPS", - unit: "%", - scale: 0.1, + name: "Lambda Sensor", + unit: "V", + scale: 0.001, offset: 0.0, }, 2 => ChannelDefinition { @@ -116,10 +163,11 @@ fn get_channel_definition(id: u8) -> ChannelDefinition { scale: 1.0, offset: 0.0, }, + // "BoostPWM" in EM Soft 15 => ChannelDefinition { - name: "Gear", - unit: "", - scale: 1.0, + name: "Boost PWM", + unit: "%", + scale: 0.1, offset: 0.0, }, 16 => ChannelDefinition { @@ -170,11 +218,15 @@ fn get_channel_definition(id: u8) -> ChannelDefinition { scale: 0.1, offset: 0.0, }, + // "Ign Adv" in EM Soft. Stored in 0.5° steps with a +50° offset: + // 555 at idle -> 5.5°, 850 at light-load cruise -> 35°, 640-675 at + // 230 kPa boost -> 14-17.5°. The offset is inferred from those + // values, not from Emerald documentation. 24 => ChannelDefinition { - name: "Fuel Pressure", - unit: "kPa", + name: "Ignition Advance", + unit: "°", scale: 0.1, - offset: 0.0, + offset: -50.0, }, 25 => ChannelDefinition { name: "Coolant Temp Corr", @@ -182,8 +234,9 @@ fn get_channel_definition(id: u8) -> ChannelDefinition { scale: 0.1, offset: 0.0, }, + // "Inj Duration" in EM Soft, which reports it as duty (%), not ms 26 => ChannelDefinition { - name: "Air Temp Corr", + name: "Inj Duration", unit: "%", scale: 0.1, offset: 0.0, @@ -212,16 +265,17 @@ fn get_channel_definition(id: u8) -> ChannelDefinition { scale: 0.1, offset: 0.0, }, + // "Load site" in EM Soft: the fuel/ignition map row index (0-15) 31 => ChannelDefinition { - name: "Inj Duty", - unit: "%", - scale: 0.1, + name: "Load Site", + unit: "", + scale: 1.0, offset: 0.0, }, 32 => ChannelDefinition { name: "MAP", unit: "kPa", - scale: 0.1, + scale: pressure_unit.kpa_scale(), offset: 0.0, }, 33 => ChannelDefinition { @@ -242,6 +296,13 @@ fn get_channel_definition(id: u8) -> ChannelDefinition { scale: 1.0, offset: 0.0, }, + // "Throttle Pos" in EM Soft + 41 => ChannelDefinition { + name: "TPS", + unit: "%", + scale: 0.1, + offset: 0.0, + }, // AFR/Lambda channels 45 => ChannelDefinition { name: "AFR", @@ -400,8 +461,9 @@ impl Emerald { } /// Parse the LG2 channel definition file - fn parse_lg2(contents: &str) -> Result, Box> { + fn parse_lg2(contents: &str) -> Result> { let mut channels: Vec<(u8, u8)> = Vec::new(); + let mut pressure_code: Option = None; let lines: Vec<&str> = contents.lines().collect(); let mut i = 0; @@ -423,6 +485,9 @@ impl Emerald { i += 1; } } + } else if line == "[ValU]" { + // One value per line; the second is the pressure unit + pressure_code = lines.get(i + 2).and_then(|l| l.trim().parse::().ok()); } i += 1; @@ -435,7 +500,10 @@ impl Emerald { // Sort by slot number to ensure correct order channels.sort_by_key(|(slot, _)| *slot); - Ok(channels) + Ok(Lg2Config { + channels, + pressure_unit: PressureUnit::from_valu(pressure_code), + }) } /// Parse Emerald log files (requires both .lg1 and .lg2) @@ -454,7 +522,7 @@ impl Emerald { })?; // Parse channel definitions - let channel_defs = Self::parse_lg2(&lg2_contents)?; + let config = Self::parse_lg2(&lg2_contents)?; // Read LG1 file (binary data) let lg1_path = base_path.with_extension("lg1"); @@ -466,13 +534,13 @@ impl Emerald { ) })?; - Self::parse_binary_with_channels(&lg1_data, &channel_defs, path) + Self::parse_binary_with_channels(&lg1_data, &config, path) } /// Parse the LG1 binary data with channel definitions fn parse_binary_with_channels( data: &[u8], - channel_defs: &[(u8, u8)], + config: &Lg2Config, source_path: &Path, ) -> Result> { if !Self::detect(data) { @@ -488,8 +556,8 @@ impl Emerald { // Build channel metadata let mut channels: Vec = Vec::with_capacity(8); - for (slot, channel_id) in channel_defs { - let def = get_channel_definition(*channel_id); + for (slot, channel_id) in &config.channels { + let def = get_channel_definition(*channel_id, config.pressure_unit); let name = if def.name == "Unknown" { format!("Channel {} (ID {})", slot, channel_id) } else { @@ -634,35 +702,61 @@ mod tests { fn test_parse_lg2() { let lg2_content = "[chan1]\n19\n[chan2]\n46\n[chan3]\n2\n[chan4]\n20\n[chan5]\n1\n[chan6]\n31\n[chan7]\n32\n[chan8]\n17\n[ValU]\n0\n2\n0\n0\n0\n"; - let channels = Emerald::parse_lg2(lg2_content).unwrap(); + let config = Emerald::parse_lg2(lg2_content).unwrap(); + let channels = &config.channels; assert_eq!(channels.len(), 8); assert_eq!(channels[0], (1, 19)); // Coolant Temp assert_eq!(channels[1], (2, 46)); // AFR assert_eq!(channels[2], (3, 2)); // Air Temp assert_eq!(channels[3], (4, 20)); // RPM - assert_eq!(channels[4], (5, 1)); // TPS - assert_eq!(channels[5], (6, 31)); // Inj Duty + assert_eq!(channels[4], (5, 1)); // Lambda Sensor + assert_eq!(channels[5], (6, 31)); // Load Site assert_eq!(channels[6], (7, 32)); // MAP assert_eq!(channels[7], (8, 17)); // Battery + assert_eq!(config.pressure_unit, PressureUnit::Mbar); + } + + #[test] + fn test_parse_lg2_pressure_unit() { + let kpa = "[chan1]\n20\n[chan2]\n32\n[chan3]\n1\n[chan4]\n41\n[ValU]\n0\n1\n0\n0\n0\n"; + assert_eq!( + Emerald::parse_lg2(kpa).unwrap().pressure_unit, + PressureUnit::Kpa + ); + + // A missing [ValU] section keeps the mbar scaling + let none = "[chan1]\n20\n[chan2]\n32\n[chan3]\n1\n[chan4]\n41\n"; + assert_eq!( + Emerald::parse_lg2(none).unwrap().pressure_unit, + PressureUnit::Mbar + ); } #[test] fn test_channel_definitions() { // Test known channel IDs - let rpm = get_channel_definition(20); + let rpm = get_channel_definition(20, PressureUnit::Mbar); assert_eq!(rpm.name, "RPM"); assert_eq!(rpm.unit, "RPM"); - let coolant = get_channel_definition(19); + let coolant = get_channel_definition(19, PressureUnit::Mbar); assert_eq!(coolant.name, "Coolant Temp"); assert_eq!(coolant.unit, "°C"); - let tps = get_channel_definition(1); + // Issue #93: ID 41 is Throttle Pos and ID 1 is the lambda input + let tps = get_channel_definition(41, PressureUnit::Mbar); assert_eq!(tps.name, "TPS"); assert_eq!(tps.unit, "%"); + assert_eq!( + get_channel_definition(1, PressureUnit::Mbar).name, + "Lambda Sensor" + ); + + assert_eq!(get_channel_definition(32, PressureUnit::Kpa).scale, 1.0); + assert_eq!(get_channel_definition(32, PressureUnit::Mbar).scale, 0.1); // Test unknown channel - let unknown = get_channel_definition(255); + let unknown = get_channel_definition(255, PressureUnit::Mbar); assert_eq!(unknown.name, "Unknown"); } @@ -720,4 +814,78 @@ mod tests { eprintln!("Parsed {} data records", log.data.len()); } + + /// First-record values as a (name, unit, value) list + fn first_record(log: &Log) -> Vec<(String, String, f64)> { + log.channels + .iter() + .zip(&log.data[0]) + .map(|(ch, v)| (ch.name(), ch.unit().to_string(), v.as_f64())) + .collect() + } + + fn assert_channel(record: &[(String, String, f64)], name: &str, unit: &str, value: f64) { + let (_, got_unit, got) = record + .iter() + .find(|(n, _, _)| n == name) + .unwrap_or_else(|| panic!("missing channel {name}: {record:?}")); + assert_eq!(got_unit, unit, "{name} unit"); + assert!((got - value).abs() < 1e-9, "{name}: {got} != {value}"); + } + + #[test] + fn test_issue_93_channel_mapping() { + // Log attached to issue #93: [chan1..8] = 20, 41, 1, 32, 24, 26, 15, 31, + // [ValU] pressure unit 1 (kPa). The reporter's EM Soft labels are + // Engine Speed, Throttle Pos, AFR/Lambda, MAP, Ign Adv, Inj Duration, + // BoostPWM, Load site. + let path = Path::new("exampleLogs/emerald/EM Log MG ZS Turbo boost run.lg1"); + let log = Emerald::parse_file(path).expect("Should parse successfully"); + assert_eq!(log.data.len(), 2000); + + let record = first_record(&log); + let names: Vec<&str> = record.iter().map(|(n, _, _)| n.as_str()).collect(); + assert_eq!( + names, + [ + "RPM", + "TPS", + "Lambda Sensor", + "MAP", + "Ignition Advance", + "Inj Duration", + "Boost PWM", + "Load Site" + ] + ); + + // Raw first record: 3732, 580, 430, 150, 745, 210, 286, 11 + assert_channel(&record, "RPM", "RPM", 3732.0); + assert_channel(&record, "TPS", "%", 58.0); + assert_channel(&record, "Lambda Sensor", "V", 0.43); + assert_channel(&record, "MAP", "kPa", 150.0); + assert_channel(&record, "Ignition Advance", "°", 24.5); + assert_channel(&record, "Inj Duration", "%", 21.0); + assert_channel(&record, "Boost PWM", "%", 28.6); + assert_channel(&record, "Load Site", "", 11.0); + + // Load site is a 0-15 map row index for the whole log + let slot = names.iter().position(|n| *n == "Load Site").unwrap(); + assert!( + log.data + .iter() + .all(|r| (0.0..=15.0).contains(&r[slot].as_f64())) + ); + } + + #[test] + fn test_mbar_pressure_unit_map() { + // [ValU] pressure unit 2: MAP stored in mbar, 586 at idle + let path = Path::new("exampleLogs/emerald/EM Log MG ZS Turbo idle and rev.lg1"); + let log = Emerald::parse_file(path).expect("Should parse successfully"); + let record = first_record(&log); + assert_channel(&record, "MAP", "kPa", 58.6); + assert_channel(&record, "Lambda Sensor", "V", 0.52); + assert_channel(&record, "Load Site", "", 0.0); + } } From 53b3ce0da5de69c559eeffa19ab8955c4a21bab6 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Sun, 27 Sep 2026 10:22:49 -0400 Subject: [PATCH 2/7] chore(release): bump to 2.15.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- docs/index.html | 6 +++--- docs/sitemap.xml | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8138730..d0a71a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4578,7 +4578,7 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "ultralog" -version = "2.15.0" +version = "2.15.1" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index a221b77..25433ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ultralog" -version = "2.15.0" +version = "2.15.1" edition = "2024" # egui/eframe 0.36 is the binding constraint on the minimum supported Rust # version; edition 2024 itself only needs 1.85. diff --git a/README.md b/README.md index 7c98168..cc3d4b8 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A high-performance, cross-platform ECU log viewer written in Rust. ![CI](https://github.com/ClassicMiniDIY/UltraLog/actions/workflows/ci.yml/badge.svg) ![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg) -![Version](https://img.shields.io/badge/version-2.15.0-green.svg) +![Version](https://img.shields.io/badge/version-2.15.1-green.svg) --- diff --git a/docs/index.html b/docs/index.html index 2ec9e0a..955749f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -112,8 +112,8 @@ "applicationCategory": "UtilitiesApplication", "applicationSubCategory": "Automotive Software", "operatingSystem": ["Windows 10", "Windows 11", "macOS", "Linux"], - "softwareVersion": "2.15.0", - "releaseNotes": "https://github.com/ClassicMiniDIY/UltraLog/releases/tag/v2.15.0", + "softwareVersion": "2.15.1", + "releaseNotes": "https://github.com/ClassicMiniDIY/UltraLog/releases/tag/v2.15.1", "downloadUrl": "https://github.com/ClassicMiniDIY/UltraLog/releases/latest", "installUrl": "https://github.com/ClassicMiniDIY/UltraLog/releases/latest", "screenshot": [ @@ -1429,7 +1429,7 @@

Unlock Your Performanc
New - v2.15.0 + v2.15.1 Open Source diff --git a/docs/sitemap.xml b/docs/sitemap.xml index 8af033b..9bdcc21 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -3,7 +3,7 @@ xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"> https://ultralog.co/ - 2026-09-18 + 2026-09-27 weekly 1.0 From fe81f29bd6f3b207354e95725d040b78fd706480 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Sun, 27 Sep 2026 10:22:57 -0400 Subject: [PATCH 3/7] docs(emerald): list the channels the parser now maps --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cc3d4b8..19982a8 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,8 @@ Configurable units for 8 measurement categories: - **File type:** Binary format (`.lg1` data + `.lg2` channel definitions) - **Features:** Native binary format parser for Emerald K6/M3D ECUs - **Supported devices:** Emerald K6, M3D, and compatible ECU models -- **Supported data:** TPS, Air Temp, MAP, Lambda, Oil/Fuel Pressure, Oil/Fuel Temp, Exhaust Temp, Boost Target/Duty, RPM, Coolant Temp, Battery Voltage, Ignition Advance, Injector Pulse Width, and more +- **Supported data:** RPM, TPS, MAP, lambda sensor, ignition advance, injector duration, boost PWM, load site, air and coolant temperature, and more +- **Units:** MAP is read in the pressure unit the EM Soft logger was set to (kPa or bar) and shown in kPa - **Note:** Both `.lg1` (data) and `.lg2` (channel definitions) files must be in the same directory ### MegaSquirt / Honda Tuning Studio - Full Support From ab970528f666608f76f4eb8dbf57be60410519c0 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Sun, 27 Sep 2026 10:25:15 -0400 Subject: [PATCH 4/7] docs: note the OECUASpecs companion for Emerald channel renames --- CLAUDE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dd6ecd4..96917db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -511,8 +511,10 @@ because it also leads with a `Time` column, and only matches a first column of e document. Only IDs 1, 15, 20, 24, 26, 31, 32 and 41 are confirmed (issue #93, reporter's EM Soft labels). The rest are original guesses. ID 1 is the K6 lambda input in mV (not TPS), 41 is TPS, 31 is the 0-15 load site (not injector duty). Confirm an ID against an EM Soft - screenshot before renaming it. `spec/OECUASpecs/adapters/emerald/emerald-lg.adapter.yaml` - still carries the old guesses in `internal_id`; UltraLog does not read that field. + screenshot before renaming it. A rename must also go into the `source_names` of + `adapters/emerald/emerald-lg.adapter.yaml` in the ClassicMiniDIY/OECUASpecs repo (fetched + by `build.rs` into the gitignored `spec/OECUASpecs/`); spec normalization matches on those + names. Its `internal_id` field documents the IDs but UltraLog does not read it. - **MAP scaling depends on `[ValU]`** - the second `[ValU]` value is the logger's pressure unit: `1` stores MAP in whole kPa, `2` in mbar. Hardcoding either one makes the other log read 10x wrong. Unknown codes keep the mbar scaling. From 5055465f5a44d5b27d63513cc387659ed1149092 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Sun, 27 Sep 2026 10:31:50 -0400 Subject: [PATCH 5/7] fix(emerald): address pre-PR review feedback --- CLAUDE.md | 6 ++++ README.md | 2 +- src/analysis/tables/channel_map.rs | 7 +++++ src/parsers/emerald.rs | 46 +++++++++++++++++++++++++----- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 96917db..9477145 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -518,6 +518,12 @@ because it also leads with a `Time` column, and only matches a first column of e - **MAP scaling depends on `[ValU]`** - the second `[ValU]` value is the logger's pressure unit: `1` stores MAP in whole kPa, `2` in mbar. Hardcoding either one makes the other log read 10x wrong. Unknown codes keep the mbar scaling. +- **ID 1 is named "Lambda Sensor Voltage" on purpose** - it is a 0-1 V narrowband input where + a higher reading is richer. The `"volt"` veto on `ChannelRole::Lambda` in + `channel_map.rs` depends on that word, so the table generators never map it as lambda. +- **Repeated names get the channel ID appended** - IDs 21/24 and 45/46 share a name, and name + lookups return the first match, so `parse_binary_with_channels` renames the second one + (`Ignition Advance (ID 24)`). - **Ign Adv (ID 24) offset is inferred** - `raw * 0.1 - 50` gives 5.5° at idle and 14-17° at 230 kPa boost. It is not from Emerald documentation. diff --git a/README.md b/README.md index 19982a8..ac70730 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ Configurable units for 8 measurement categories: - **Features:** Native binary format parser for Emerald K6/M3D ECUs - **Supported devices:** Emerald K6, M3D, and compatible ECU models - **Supported data:** RPM, TPS, MAP, lambda sensor, ignition advance, injector duration, boost PWM, load site, air and coolant temperature, and more -- **Units:** MAP is read in the pressure unit the EM Soft logger was set to (kPa or bar) and shown in kPa +- **Units:** MAP is shown in kPa. The EM Soft logger pressure setting must be kPa or bar; other settings are not yet supported - **Note:** Both `.lg1` (data) and `.lg2` (channel definitions) files must be in the same directory ### MegaSquirt / Honda Tuning Studio - Full Support diff --git a/src/analysis/tables/channel_map.rs b/src/analysis/tables/channel_map.rs index d7784e0..1d75ca8 100644 --- a/src/analysis/tables/channel_map.rs +++ b/src/analysis/tables/channel_map.rs @@ -284,6 +284,9 @@ impl ChannelRole { "good", "ready", "protect", + // Sensor voltage (Emerald "Lambda Sensor Voltage" is a 0-1 V + // narrowband input): higher reads richer, so it is not lambda. + "volt", ], Self::LambdaTarget => &[ "error", "airmass", "airflow", "boost", "idle", "rpm", "cam", "angle", "position", @@ -566,6 +569,10 @@ mod tests { 90 ); assert_eq!(name_score(ChannelRole::Lambda, "Target Lambda", None), 0); + assert_eq!( + name_score(ChannelRole::Lambda, "Lambda Sensor Voltage", None), + 0 + ); assert!(name_score(ChannelRole::PulseWidth, "Injector 1 On Time", None) >= 40); assert_eq!( name_score(ChannelRole::PulseWidth, "Injection Stage 1 Dead Time", None), diff --git a/src/parsers/emerald.rs b/src/parsers/emerald.rs index 44a0f41..fdce3d9 100644 --- a/src/parsers/emerald.rs +++ b/src/parsers/emerald.rs @@ -38,6 +38,9 @@ struct ChannelDefinition { /// Two logs from the same car show the effect: with `1` MAP is stored in whole /// kPa (25-235), with `2` it is stored in mbar (586 at idle, 2327 on boost). /// Any other code keeps the mbar scaling the parser has always used. +/// +/// Only ID 32 is known to follow this setting. The other pressure IDs (3, 10, +/// 33) are unconfirmed and keep a fixed x0.1 until a log shows otherwise. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum PressureUnit { Kpa, @@ -78,9 +81,10 @@ struct Lg2Config { fn get_channel_definition(id: u8, pressure_unit: PressureUnit) -> ChannelDefinition { match id { // "AFR/Lambda" in EM Soft: the K6's own lambda input, a 0-1 V - // narrowband signal stored in mV (every log tops out at 900). + // narrowband signal stored in mV (every log tops out at 900). The + // name says "Voltage" so the table generators never take it as lambda. 1 => ChannelDefinition { - name: "Lambda Sensor", + name: "Lambda Sensor Voltage", unit: "V", scale: 0.001, offset: 0.0, @@ -558,11 +562,17 @@ impl Emerald { let mut channels: Vec = Vec::with_capacity(8); for (slot, channel_id) in &config.channels { let def = get_channel_definition(*channel_id, config.pressure_unit); - let name = if def.name == "Unknown" { + let mut name = if def.name == "Unknown" { format!("Channel {} (ID {})", slot, channel_id) } else { def.name.to_string() }; + // Several IDs share a name (21 and 24 are both ignition advance, + // 45 and 46 are both AFR). Name lookups return the first match, + // so a repeat would be unreachable by name. + if channels.iter().any(|c| c.name == name) { + name = format!("{} (ID {})", name, channel_id); + } channels.push(EmeraldChannel { name, @@ -749,7 +759,7 @@ mod tests { assert_eq!(tps.unit, "%"); assert_eq!( get_channel_definition(1, PressureUnit::Mbar).name, - "Lambda Sensor" + "Lambda Sensor Voltage" ); assert_eq!(get_channel_definition(32, PressureUnit::Kpa).scale, 1.0); @@ -850,7 +860,7 @@ mod tests { [ "RPM", "TPS", - "Lambda Sensor", + "Lambda Sensor Voltage", "MAP", "Ignition Advance", "Inj Duration", @@ -862,7 +872,7 @@ mod tests { // Raw first record: 3732, 580, 430, 150, 745, 210, 286, 11 assert_channel(&record, "RPM", "RPM", 3732.0); assert_channel(&record, "TPS", "%", 58.0); - assert_channel(&record, "Lambda Sensor", "V", 0.43); + assert_channel(&record, "Lambda Sensor Voltage", "V", 0.43); assert_channel(&record, "MAP", "kPa", 150.0); assert_channel(&record, "Ignition Advance", "°", 24.5); assert_channel(&record, "Inj Duration", "%", 21.0); @@ -878,6 +888,28 @@ mod tests { ); } + #[test] + fn test_duplicate_names_get_the_channel_id() { + let config = Lg2Config { + channels: vec![(1, 21), (2, 24), (3, 45), (4, 46)], + pressure_unit: PressureUnit::Mbar, + }; + let mut data = vec![0u8; 24]; + data[0..8].copy_from_slice(&46022.5f64.to_le_bytes()); + let log = Emerald::parse_binary_with_channels(&data, &config, Path::new("x.lg1")) + .expect("Should parse successfully"); + let names: Vec = log.channels.iter().map(|c| c.name()).collect(); + assert_eq!( + names, + [ + "Ignition Advance", + "Ignition Advance (ID 24)", + "AFR", + "AFR (ID 46)" + ] + ); + } + #[test] fn test_mbar_pressure_unit_map() { // [ValU] pressure unit 2: MAP stored in mbar, 586 at idle @@ -885,7 +917,7 @@ mod tests { let log = Emerald::parse_file(path).expect("Should parse successfully"); let record = first_record(&log); assert_channel(&record, "MAP", "kPa", 58.6); - assert_channel(&record, "Lambda Sensor", "V", 0.52); + assert_channel(&record, "Lambda Sensor Voltage", "V", 0.52); assert_channel(&record, "Load Site", "", 0.0); } } From cf726683ea528be4eae9a2d0f5dffafb4fbd31b9 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Sun, 27 Sep 2026 18:29:28 -0400 Subject: [PATCH 6/7] fix: address PR #94 review feedback --- CLAUDE.md | 8 +- src/parsers/emerald.rs | 230 ++++++++++++++++++++++++++++++++--------- 2 files changed, 187 insertions(+), 51 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9477145..50e9595 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -522,8 +522,12 @@ because it also leads with a `Time` column, and only matches a first column of e a higher reading is richer. The `"volt"` veto on `ChannelRole::Lambda` in `channel_map.rs` depends on that word, so the table generators never map it as lambda. - **Repeated names get the channel ID appended** - IDs 21/24 and 45/46 share a name, and name - lookups return the first match, so `parse_binary_with_channels` renames the second one - (`Ignition Advance (ID 24)`). + lookups return the first match. `disambiguate_names` keeps the plain name on a + `CONFIRMED_IDS` entry (else the first by slot) and appends ` (ID n)` to the others, so a + formula bound to `Ignition Advance` does not change meaning with slot order. +- **The value column is the slot number, not the list position** - `[chanN]` reads bytes + `8 + (N-1)*2` of the record. `parse_lg2` drops slots outside 1-8 and repeated slots, so an + unreadable or missing `[chanN]` cannot shift later channels onto the wrong data. - **Ign Adv (ID 24) offset is inferred** - `raw * 0.1 - 50` gives 5.5° at idle and 14-17° at 230 kPa boost. It is not from Emerald documentation. diff --git a/src/parsers/emerald.rs b/src/parsers/emerald.rs index fdce3d9..e8be91e 100644 --- a/src/parsers/emerald.rs +++ b/src/parsers/emerald.rs @@ -73,12 +73,19 @@ struct Lg2Config { pressure_unit: PressureUnit, } +/// Channel ID of MAP, the one channel scaled by the `[ValU]` pressure unit +const MAP_ID: u8 = 32; + +/// Channel IDs confirmed against EM Soft by the log attached to issue #93 +/// (MG ZS turbo). The other IDs are unconfirmed guesses from the original +/// reverse engineering. +const CONFIRMED_IDS: [u8; 8] = [1, 15, 20, 24, 26, 31, MAP_ID, 41]; + +/// Number of value slots in an LG1 record (`[chan1]`..`[chan8]`) +const SLOTS: u8 = 8; + /// Get channel definition for a known channel ID -/// -/// IDs 1, 15, 20, 24, 26, 31, 32 and 41 are confirmed against EM Soft by the -/// log attached to issue #93 (MG ZS turbo). The other IDs are unconfirmed -/// guesses from the original reverse engineering. -fn get_channel_definition(id: u8, pressure_unit: PressureUnit) -> ChannelDefinition { +fn get_channel_definition(id: u8) -> ChannelDefinition { match id { // "AFR/Lambda" in EM Soft: the K6's own lambda input, a 0-1 V // narrowband signal stored in mV (every log tops out at 900). The @@ -276,10 +283,12 @@ fn get_channel_definition(id: u8, pressure_unit: PressureUnit) -> ChannelDefinit scale: 1.0, offset: 0.0, }, - 32 => ChannelDefinition { + // Scale here is the mbar default; parse_binary_with_channels + // replaces it from the [ValU] pressure unit. + MAP_ID => ChannelDefinition { name: "MAP", unit: "kPa", - scale: pressure_unit.kpa_scale(), + scale: 0.1, offset: 0.0, }, 33 => ChannelDefinition { @@ -336,6 +345,33 @@ fn get_channel_definition(id: u8, pressure_unit: PressureUnit) -> ChannelDefinit } } +/// Append the channel ID to repeated names. +/// +/// Several IDs share a name (21 and 24 are both ignition advance, 45 and 46 +/// are both AFR), and name lookups return the first match, so a repeat would +/// be unreachable by name. A confirmed ID keeps the plain name whatever slot +/// it is in; the others get ` (ID n)`. +fn disambiguate_names(channels: &mut [EmeraldChannel]) { + for i in 0..channels.len() { + let group: Vec = (0..channels.len()) + .filter(|&j| channels[j].name == channels[i].name) + .collect(); + if group.len() < 2 { + continue; + } + let keep = group + .iter() + .copied() + .find(|&j| CONFIRMED_IDS.contains(&channels[j].channel_id)) + .unwrap_or(group[0]); + for j in group { + if j != keep { + channels[j].name = format!("{} (ID {})", channels[j].name, channels[j].channel_id); + } + } + } +} + /// Emerald ECU channel metadata #[derive(Clone, Debug, Serialize)] pub struct EmeraldChannel { @@ -467,7 +503,7 @@ impl Emerald { /// Parse the LG2 channel definition file fn parse_lg2(contents: &str) -> Result> { let mut channels: Vec<(u8, u8)> = Vec::new(); - let mut pressure_code: Option = None; + let mut valu: Option> = None; let lines: Vec<&str> = contents.lines().collect(); let mut i = 0; @@ -483,15 +519,28 @@ impl Emerald { // Next line should be the channel ID if i + 1 < lines.len() { let id_line = lines[i + 1].trim(); - if let Ok(channel_id) = id_line.parse::() { + // An LG1 record has 8 value slots; a slot outside + // 1-8, or a repeat, has no column of its own. + if let Ok(channel_id) = id_line.parse::() + && (1..=SLOTS).contains(&slot) + && !channels.iter().any(|(s, _)| *s == slot) + { channels.push((slot, channel_id)); } i += 1; } } } else if line == "[ValU]" { - // One value per line; the second is the pressure unit - pressure_code = lines.get(i + 2).and_then(|l| l.trim().parse::().ok()); + // One value per line up to the next section. Blank lines are + // skipped so they cannot shift which value is which. + valu = Some( + lines[i + 1..] + .iter() + .map(|l| l.trim()) + .take_while(|l| !l.starts_with('[')) + .filter(|l| !l.is_empty()) + .collect(), + ); } i += 1; @@ -506,7 +555,10 @@ impl Emerald { Ok(Lg2Config { channels, - pressure_unit: PressureUnit::from_valu(pressure_code), + // The second [ValU] value is the pressure unit + pressure_unit: PressureUnit::from_valu( + valu.and_then(|v| v.get(1).and_then(|c| c.parse::().ok())), + ), }) } @@ -558,30 +610,34 @@ impl Emerald { return Err("LG1 file contains no data records".into()); } - // Build channel metadata - let mut channels: Vec = Vec::with_capacity(8); + // Build channel metadata. `columns` holds each channel's byte offset + // inside a record: the value for [chanN] is always column N, even + // when an earlier slot is missing from the LG2. + let mut channels: Vec = Vec::with_capacity(config.channels.len()); + let mut columns: Vec = Vec::with_capacity(config.channels.len()); for (slot, channel_id) in &config.channels { - let def = get_channel_definition(*channel_id, config.pressure_unit); - let mut name = if def.name == "Unknown" { + let def = get_channel_definition(*channel_id); + let name = if def.name == "Unknown" { format!("Channel {} (ID {})", slot, channel_id) } else { def.name.to_string() }; - // Several IDs share a name (21 and 24 are both ignition advance, - // 45 and 46 are both AFR). Name lookups return the first match, - // so a repeat would be unreachable by name. - if channels.iter().any(|c| c.name == name) { - name = format!("{} (ID {})", name, channel_id); - } + let scale = if *channel_id == MAP_ID { + config.pressure_unit.kpa_scale() + } else { + def.scale + }; channels.push(EmeraldChannel { name, unit: def.unit.to_string(), channel_id: *channel_id, - scale: def.scale, + scale, offset: def.offset, }); + columns.push(8 + (*slot as usize - 1) * 2); } + disambiguate_names(&mut channels); // Parse binary data let mut times: Vec = Vec::with_capacity(num_records); @@ -611,8 +667,8 @@ impl Emerald { // Read 8 channel values (16 bytes, 8 x u16) let mut row: Vec = Vec::with_capacity(channels.len()); - for (ch_idx, channel) in channels.iter().enumerate() { - let value_offset = offset + 8 + (ch_idx * 2); + for (channel, column) in channels.iter().zip(&columns) { + let value_offset = offset + column; let raw_value = u16::from_le_bytes([data[value_offset], data[value_offset + 1]]) as f64; @@ -745,28 +801,22 @@ mod tests { #[test] fn test_channel_definitions() { // Test known channel IDs - let rpm = get_channel_definition(20, PressureUnit::Mbar); + let rpm = get_channel_definition(20); assert_eq!(rpm.name, "RPM"); assert_eq!(rpm.unit, "RPM"); - let coolant = get_channel_definition(19, PressureUnit::Mbar); + let coolant = get_channel_definition(19); assert_eq!(coolant.name, "Coolant Temp"); assert_eq!(coolant.unit, "°C"); // Issue #93: ID 41 is Throttle Pos and ID 1 is the lambda input - let tps = get_channel_definition(41, PressureUnit::Mbar); + let tps = get_channel_definition(41); assert_eq!(tps.name, "TPS"); assert_eq!(tps.unit, "%"); - assert_eq!( - get_channel_definition(1, PressureUnit::Mbar).name, - "Lambda Sensor Voltage" - ); - - assert_eq!(get_channel_definition(32, PressureUnit::Kpa).scale, 1.0); - assert_eq!(get_channel_definition(32, PressureUnit::Mbar).scale, 0.1); + assert_eq!(get_channel_definition(1).name, "Lambda Sensor Voltage"); // Test unknown channel - let unknown = get_channel_definition(255, PressureUnit::Mbar); + let unknown = get_channel_definition(255); assert_eq!(unknown.name, "Unknown"); } @@ -888,26 +938,108 @@ mod tests { ); } + /// One LG1 record whose 8 slot values are `values` + fn record(values: [u16; 8]) -> Vec { + let mut data = 46022.5f64.to_le_bytes().to_vec(); + for v in values { + data.extend_from_slice(&v.to_le_bytes()); + } + data + } + + fn parse_config(config: &Lg2Config, data: &[u8]) -> Log { + Emerald::parse_binary_with_channels(data, config, Path::new("x.lg1")) + .expect("Should parse successfully") + } + #[test] fn test_duplicate_names_get_the_channel_id() { + // The confirmed ID 24 keeps the plain name in either slot order + for channels in [vec![(1, 21), (2, 24)], vec![(1, 24), (2, 21)]] { + let config = Lg2Config { + channels, + pressure_unit: PressureUnit::Mbar, + }; + let log = parse_config(&config, &record([0; 8])); + let plain = log + .channels + .iter() + .find(|c| c.name() == "Ignition Advance") + .expect("plain name kept"); + let Channel::Emerald(ch) = plain else { + panic!("not an Emerald channel") + }; + assert_eq!(ch.channel_id, 24); + assert!( + log.channels + .iter() + .any(|c| c.name() == "Ignition Advance (ID 21)") + ); + } + + // Neither AFR ID is confirmed: the first keeps the plain name let config = Lg2Config { - channels: vec![(1, 21), (2, 24), (3, 45), (4, 46)], + channels: vec![(1, 45), (2, 46)], pressure_unit: PressureUnit::Mbar, }; - let mut data = vec![0u8; 24]; - data[0..8].copy_from_slice(&46022.5f64.to_le_bytes()); - let log = Emerald::parse_binary_with_channels(&data, &config, Path::new("x.lg1")) - .expect("Should parse successfully"); - let names: Vec = log.channels.iter().map(|c| c.name()).collect(); + let names: Vec = parse_config(&config, &record([0; 8])) + .channels + .iter() + .map(|c| c.name()) + .collect(); + assert_eq!(names, ["AFR", "AFR (ID 46)"]); + } + + #[test] + fn test_value_column_follows_slot_number() { + // [chan2] is unreadable, so slot 3 must still read column 3 + let lg2 = "[chan1]\n20\n[chan2]\n\n[chan3]\n41\n[chan4]\n19\n"; + let config = Emerald::parse_lg2(lg2).unwrap(); + assert_eq!(config.channels, [(1, 20), (3, 41), (4, 19)]); + + let log = parse_config(&config, &record([3000, 999, 500, 90, 0, 0, 0, 0])); + let values: Vec = log.data[0].iter().map(|v| v.as_f64()).collect(); + assert_eq!(values, [3000.0, 50.0, 90.0]); + } + + #[test] + fn test_slots_outside_record_are_dropped() { + // [chan9] has no column and a repeated [chan1] would read slot 1 twice + let lg2 = "[chan1]\n20\n[chan1]\n41\n[chan9]\n19\n[chan0]\n2\n[chan8]\n31\n"; + let config = Emerald::parse_lg2(lg2).unwrap(); + assert_eq!(config.channels, [(1, 20), (8, 31)]); + + // Parsing the last record must not read past the end of the data + let data = [record([1; 8]), record([2; 8])].concat(); + assert_eq!(parse_config(&config, &data).data.len(), 2); + } + + #[test] + fn test_valu_ignores_blank_lines() { + let lg2 = "[chan1]\n20\n[chan2]\n32\n[chan3]\n1\n[chan4]\n41\n[ValU]\n\n0\n\n1\n0\n0\n0\n"; assert_eq!( - names, - [ - "Ignition Advance", - "Ignition Advance (ID 24)", - "AFR", - "AFR (ID 46)" - ] + Emerald::parse_lg2(lg2).unwrap().pressure_unit, + PressureUnit::Kpa ); + + // A [ValU] with a single value has no pressure code + let short = "[chan1]\n20\n[chan2]\n32\n[chan3]\n1\n[chan4]\n41\n[ValU]\n0\n[chan5]\n1\n"; + assert_eq!( + Emerald::parse_lg2(short).unwrap().pressure_unit, + PressureUnit::Mbar + ); + } + + #[test] + fn test_map_scale_follows_pressure_unit() { + for (unit, expected) in [(PressureUnit::Kpa, 150.0), (PressureUnit::Mbar, 15.0)] { + let config = Lg2Config { + channels: vec![(1, MAP_ID)], + pressure_unit: unit, + }; + let log = parse_config(&config, &record([150, 0, 0, 0, 0, 0, 0, 0])); + assert_eq!(log.data[0][0].as_f64(), expected); + } } #[test] From 9963af66fb5257f94794f1e4931a27bb2aa131d0 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Sun, 27 Sep 2026 18:32:20 -0400 Subject: [PATCH 7/7] docs(emerald): drop intra-doc link to private PressureUnit --- src/parsers/emerald.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parsers/emerald.rs b/src/parsers/emerald.rs index e8be91e..7871205 100644 --- a/src/parsers/emerald.rs +++ b/src/parsers/emerald.rs @@ -13,7 +13,7 @@ //! LG1 values are stored as the logger's display value times a fixed factor //! (TPS 0-1000 for 0-100.0 %), not as raw ECU bytes. Pressure is the exception: //! the `[ValU]` section of the LG2 records the pressure unit the logger was set -//! to, and MAP is stored in that unit (see [`PressureUnit`]). +//! to, and MAP is stored in that unit (see `PressureUnit`). use serde::Serialize; use std::error::Error;