|
| 1 | +import * as fs from 'fs'; |
| 2 | +import * as path from 'path'; |
| 3 | +import * as zlib from 'zlib'; |
| 4 | + |
| 5 | +/** |
| 6 | + * ── ASAM MDF4 (MF4) parser core ────────────────────────────────────────────── |
| 7 | + * MF4 is a binary automotive measurement format: channel groups of named numeric |
| 8 | + * signals sampled against a time master. This is a MINIMAL, dependency-free MDF |
| 9 | + * 4.x reader for the COMMON case: |
| 10 | + * • data in ##DT (raw), ##DZ (deflate / transposed-deflate) or ##DL/##HL lists, |
| 11 | + * • sorted groups (one channel group per data group), |
| 12 | + * • fixed-length numeric channels (int/uint/float, LE/BE), |
| 13 | + * • raw values (channel conversions/##CC are NOT applied yet). |
| 14 | + * Anything outside that is reported inline (a `[mf4] …` line) and skipped rather |
| 15 | + * than producing wrong numbers. |
| 16 | + * |
| 17 | + * Each record becomes a synthetic line `t=<master> <name>=<value> …` so the |
| 18 | + * existing viewer + Trends field-discovery chart the signals with zero new UI. |
| 19 | + * |
| 20 | + * Records are STREAMED chunk-by-chunk (never the whole decompressed dataset at |
| 21 | + * once), so memory stays bounded even for multi-GB recordings. This module is |
| 22 | + * run inside a worker thread (see mf4Worker.ts) so parsing never blocks the |
| 23 | + * Electron main/UI event loop. |
| 24 | + */ |
| 25 | + |
| 26 | +interface Mdf4Block { id: string; links: number[]; dataStart: number; dataEnd: number; } |
| 27 | + |
| 28 | +function readMdf4Block(buf: Buffer, offset: number): Mdf4Block { |
| 29 | + const id = buf.toString('latin1', offset, offset + 4); // e.g. "##HD" |
| 30 | + const length = Number(buf.readBigUInt64LE(offset + 8)); |
| 31 | + const linkCount = Number(buf.readBigUInt64LE(offset + 16)); |
| 32 | + const links: number[] = []; |
| 33 | + let p = offset + 24; |
| 34 | + for (let i = 0; i < linkCount; i++) { links.push(Number(buf.readBigUInt64LE(p))); p += 8; } |
| 35 | + return { id, links, dataStart: p, dataEnd: offset + length }; |
| 36 | +} |
| 37 | + |
| 38 | +/** Read a ##TX/##MD block's text (null-terminated UTF-8). 0 link → ''. */ |
| 39 | +function readMdf4Text(buf: Buffer, offset: number): string { |
| 40 | + if (!offset) return ''; |
| 41 | + try { |
| 42 | + const b = readMdf4Block(buf, offset); |
| 43 | + if (b.id !== '##TX' && b.id !== '##MD') return ''; |
| 44 | + let s = buf.toString('utf8', b.dataStart, b.dataEnd); |
| 45 | + const nul = s.indexOf('\0'); |
| 46 | + if (nul >= 0) s = s.slice(0, nul); |
| 47 | + return s.trim(); |
| 48 | + } catch { return ''; } |
| 49 | +} |
| 50 | + |
| 51 | +interface Mdf4Channel { name: string; type: number; dataType: number; bitOffset: number; byteOffset: number; bitCount: number; } |
| 52 | + |
| 53 | +/** Decode one channel's value from a record slice. Returns null if unsupported. */ |
| 54 | +function decodeMdf4Value(rec: Buffer, base: number, cn: Mdf4Channel): number | null { |
| 55 | + const off = base + cn.byteOffset; |
| 56 | + const { dataType: dt, bitCount: bits, bitOffset } = cn; |
| 57 | + if (off < 0 || off >= rec.length) return null; |
| 58 | + |
| 59 | + // Byte-aligned, whole-byte fields → use Buffer's native readers. |
| 60 | + if (bitOffset === 0 && bits % 8 === 0) { |
| 61 | + const n = bits / 8; |
| 62 | + if (off + n > rec.length) return null; |
| 63 | + try { |
| 64 | + switch (dt) { |
| 65 | + case 0: return n <= 6 ? rec.readUIntLE(off, n) : Number(rec.readBigUInt64LE(off)); // uint LE |
| 66 | + case 1: return n <= 6 ? rec.readUIntBE(off, n) : Number(rec.readBigUInt64BE(off)); // uint BE |
| 67 | + case 2: return n <= 6 ? rec.readIntLE(off, n) : Number(rec.readBigInt64LE(off)); // int LE |
| 68 | + case 3: return n <= 6 ? rec.readIntBE(off, n) : Number(rec.readBigInt64BE(off)); // int BE |
| 69 | + case 4: return n === 4 ? rec.readFloatLE(off) : n === 8 ? rec.readDoubleLE(off) : null; // float LE |
| 70 | + case 5: return n === 4 ? rec.readFloatBE(off) : n === 8 ? rec.readDoubleBE(off) : null; // float BE |
| 71 | + default: return null; // strings/bytes/complex — not charted |
| 72 | + } |
| 73 | + } catch { return null; } |
| 74 | + } |
| 75 | + |
| 76 | + // Non-aligned integer bit-field (little-endian only). |
| 77 | + if (dt === 0 || dt === 2) { |
| 78 | + const totalBits = bitOffset + bits; |
| 79 | + const n = Math.ceil(totalBits / 8); |
| 80 | + if (n > 8 || off + n > rec.length) return null; |
| 81 | + let v = 0n; |
| 82 | + for (let i = 0; i < n; i++) v |= BigInt(rec[off + i]) << BigInt(8 * i); |
| 83 | + v = (v >> BigInt(bitOffset)) & ((1n << BigInt(bits)) - 1n); |
| 84 | + if (dt === 2 && (v & (1n << BigInt(bits - 1)))) v -= (1n << BigInt(bits)); // sign-extend |
| 85 | + return Number(v); |
| 86 | + } |
| 87 | + return null; |
| 88 | +} |
| 89 | + |
| 90 | +/** |
| 91 | + * Decompress a ##DZ block (MDF4 compressed data). zip_type 0 = raw deflate, |
| 92 | + * 1 = transposition + deflate (used for record data so columns compress better). |
| 93 | + */ |
| 94 | +function inflateMdf4Dz(buf: Buffer, block: Mdf4Block): Buffer { |
| 95 | + const d = block.dataStart; |
| 96 | + const zipType = buf.readUInt8(d + 2); |
| 97 | + const zipParam = buf.readUInt32LE(d + 4); |
| 98 | + const orgLen = Number(buf.readBigUInt64LE(d + 8)); |
| 99 | + const dataLen = Number(buf.readBigUInt64LE(d + 16)); |
| 100 | + const comp = buf.subarray(d + 24, d + 24 + dataLen); |
| 101 | + let out: Buffer = zlib.inflateSync(comp); |
| 102 | + if (zipType === 1 && zipParam > 0) out = untransposeMdf4(out, zipParam, orgLen); |
| 103 | + return out; |
| 104 | +} |
| 105 | + |
| 106 | +/** |
| 107 | + * Reverse MDF4 column-transposition. The original byte matrix (orgLen bytes, |
| 108 | + * `cols` columns, N full rows) was stored column-major; any trailing remainder |
| 109 | + * bytes are appended untransposed. |
| 110 | + */ |
| 111 | +function untransposeMdf4(data: Buffer, cols: number, orgLen: number): Buffer { |
| 112 | + const N = Math.floor(orgLen / cols); |
| 113 | + const out = Buffer.alloc(orgLen); |
| 114 | + for (let j = 0; j < cols; j++) { |
| 115 | + for (let i = 0; i < N; i++) { |
| 116 | + out[i * cols + j] = data[j * N + i]; |
| 117 | + } |
| 118 | + } |
| 119 | + for (let k = N * cols; k < orgLen && k < data.length; k++) out[k] = data[k]; |
| 120 | + return out; |
| 121 | +} |
| 122 | + |
| 123 | +/** |
| 124 | + * Walk a data-section pointer and hand each contiguous chunk of raw record bytes |
| 125 | + * to `onChunk` — following ##DT/##RD (raw), ##DZ (decompressed), ##DL (each listed |
| 126 | + * chunk, then dl_dl_next) and ##HL (unwrap to its DL). Only ONE chunk is held in |
| 127 | + * memory at a time. `onChunk` returning false stops the walk early. Returns false |
| 128 | + * if stopped. `depth` guards cyclic links. |
| 129 | + */ |
| 130 | +function forEachMdf4DataChunk( |
| 131 | + buf: Buffer, |
| 132 | + off: number, |
| 133 | + onChunk: (chunk: Buffer) => boolean, |
| 134 | + depth = 0 |
| 135 | +): boolean { |
| 136 | + if (!off || depth > 64) return true; |
| 137 | + let block: Mdf4Block; |
| 138 | + try { block = readMdf4Block(buf, off); } catch { return true; } |
| 139 | + |
| 140 | + if (block.id === '##DT' || block.id === '##RD') { |
| 141 | + return onChunk(buf.subarray(block.dataStart, block.dataEnd)) !== false; |
| 142 | + } |
| 143 | + if (block.id === '##DZ') { |
| 144 | + let dec: Buffer; |
| 145 | + try { dec = inflateMdf4Dz(buf, block); } catch { return true; } |
| 146 | + return onChunk(dec) !== false; |
| 147 | + } |
| 148 | + if (block.id === '##HL') { |
| 149 | + return forEachMdf4DataChunk(buf, block.links[0], onChunk, depth + 1); // hl_dl_first |
| 150 | + } |
| 151 | + if (block.id === '##DL') { |
| 152 | + let dlOff = off; |
| 153 | + let guard = 0; |
| 154 | + while (dlOff && guard++ < 1_000_000) { |
| 155 | + const dl = readMdf4Block(buf, dlOff); |
| 156 | + const count = buf.readUInt32LE(dl.dataStart + 4); // dl_flags(1)+reserved(3), then dl_count |
| 157 | + for (let i = 0; i < count; i++) { |
| 158 | + const link = dl.links[1 + i]; // links[0] is dl_dl_next; data links follow |
| 159 | + if (link && !forEachMdf4DataChunk(buf, link, onChunk, depth + 1)) return false; |
| 160 | + } |
| 161 | + dlOff = dl.links[0]; // dl_dl_next |
| 162 | + } |
| 163 | + return true; |
| 164 | + } |
| 165 | + return true; |
| 166 | +} |
| 167 | + |
| 168 | +/** |
| 169 | + * Stream fixed-size records (recSize bytes each) across all data chunks, carrying |
| 170 | + * a small remainder across chunk boundaries. `onRecord(rec, base)` returning false |
| 171 | + * stops iteration. Peak memory ≈ one decompressed chunk + (< recSize) leftover. |
| 172 | + */ |
| 173 | +function streamMdf4Records( |
| 174 | + buf: Buffer, |
| 175 | + dataOff: number, |
| 176 | + recSize: number, |
| 177 | + onRecord: (rec: Buffer, base: number) => boolean |
| 178 | +): void { |
| 179 | + let leftover: Buffer | null = null; |
| 180 | + let stop = false; |
| 181 | + forEachMdf4DataChunk(buf, dataOff, (chunk) => { |
| 182 | + if (stop) return false; |
| 183 | + const data = leftover && leftover.length ? Buffer.concat([leftover, chunk]) : chunk; |
| 184 | + let i = 0; |
| 185 | + for (; i + recSize <= data.length; i += recSize) { |
| 186 | + if (onRecord(data, i) === false) { stop = true; break; } |
| 187 | + } |
| 188 | + leftover = stop ? null : data.subarray(i); |
| 189 | + return !stop; |
| 190 | + }); |
| 191 | +} |
| 192 | + |
| 193 | +/** |
| 194 | + * Parse an MF4 file at `filePath` into newline-delimited `t=… name=value` text at |
| 195 | + * `outPath`. Synchronous CPU work — call it from a worker thread. Internal parse |
| 196 | + * errors are written as a `[mf4] …` note rather than thrown; only a non-MDF file |
| 197 | + * throws. Output is written incrementally so a large input never materialises as |
| 198 | + * one giant string. |
| 199 | + */ |
| 200 | +export async function parseMdf4ToFile( |
| 201 | + filePath: string, |
| 202 | + outPath: string, |
| 203 | + onProgress?: (percent: number) => void |
| 204 | +): Promise<void> { |
| 205 | + const buf = fs.readFileSync(filePath); |
| 206 | + if (buf.length < 64 || buf.toString('latin1', 0, 3) !== 'MDF') { |
| 207 | + throw new Error(`Not an MDF/MF4 file: ${path.basename(filePath)}`); |
| 208 | + } |
| 209 | + |
| 210 | + const fd = fs.openSync(outPath, 'w'); |
| 211 | + let first = false; |
| 212 | + let pending = ''; |
| 213 | + const flush = (): void => { |
| 214 | + if (pending) { fs.writeSync(fd, pending); pending = ''; } |
| 215 | + }; |
| 216 | + const writeLine = (line: string): void => { |
| 217 | + pending += first ? '\n' + line : line; |
| 218 | + first = true; |
| 219 | + if (pending.length >= 1 << 20) flush(); // flush every ~1 MB |
| 220 | + }; |
| 221 | + |
| 222 | + try { |
| 223 | + const hd = readMdf4Block(buf, 64); // HDBLOCK directly after the 64-byte IDBLOCK |
| 224 | + let dgOff = hd.links[0] || 0; // hd_dg_first |
| 225 | + let totalRecords = 0; |
| 226 | + |
| 227 | + while (dgOff) { |
| 228 | + const dg = readMdf4Block(buf, dgOff); |
| 229 | + const recIdSize = buf.readUInt8(dg.dataStart); |
| 230 | + const cgFirst = dg.links[1]; |
| 231 | + const dgData = dg.links[2]; |
| 232 | + |
| 233 | + if (cgFirst) { |
| 234 | + const cg = readMdf4Block(buf, cgFirst); |
| 235 | + if (cg.links[0]) writeLine('[mf4] note: data group has multiple channel groups; reading the first only'); |
| 236 | + const cycleCount = Number(buf.readBigUInt64LE(cg.dataStart + 8)); |
| 237 | + const cgFlags = buf.readUInt16LE(cg.dataStart + 16); |
| 238 | + const dataBytes = buf.readUInt32LE(cg.dataStart + 24); |
| 239 | + const invalBytes = buf.readUInt32LE(cg.dataStart + 28); |
| 240 | + |
| 241 | + if (cgFlags & 0x1) { |
| 242 | + writeLine('[mf4] variable-length (VLSD) channel group not supported — skipped'); |
| 243 | + } else { |
| 244 | + // Collect channels. |
| 245 | + const channels: Mdf4Channel[] = []; |
| 246 | + let master: Mdf4Channel | null = null; |
| 247 | + let cnOff = cg.links[1]; // cg_cn_first |
| 248 | + while (cnOff) { |
| 249 | + const cn = readMdf4Block(buf, cnOff); |
| 250 | + const d = cn.dataStart; |
| 251 | + const ch: Mdf4Channel = { |
| 252 | + name: readMdf4Text(buf, cn.links[2]) || `ch${buf.readUInt32LE(d + 4)}`, |
| 253 | + type: buf.readUInt8(d), |
| 254 | + dataType: buf.readUInt8(d + 2), |
| 255 | + bitOffset: buf.readUInt8(d + 3), |
| 256 | + byteOffset: buf.readUInt32LE(d + 4), |
| 257 | + bitCount: buf.readUInt32LE(d + 8), |
| 258 | + }; |
| 259 | + if (ch.type === 2 || ch.type === 3) master = ch; // master / virtual master |
| 260 | + else channels.push(ch); |
| 261 | + cnOff = cn.links[0]; // cn_cn_next |
| 262 | + } |
| 263 | + |
| 264 | + const recSize = recIdSize + dataBytes + invalBytes; |
| 265 | + let groupRecords = 0; |
| 266 | + if (dgData && recSize > 0 && cycleCount > 0) { |
| 267 | + // Stream records chunk-by-chunk (handles ##DT/##DZ/##DL/##HL). |
| 268 | + streamMdf4Records(buf, dgData, recSize, (rec, recStart) => { |
| 269 | + const base = recStart + recIdSize; |
| 270 | + if (base + dataBytes > rec.length) return false; |
| 271 | + const parts: string[] = []; |
| 272 | + if (master) { |
| 273 | + const mv = decodeMdf4Value(rec, base, master); |
| 274 | + if (mv !== null) parts.push(`t=${mv}`); |
| 275 | + } |
| 276 | + for (const ch of channels) { |
| 277 | + const v = decodeMdf4Value(rec, base, ch); |
| 278 | + if (v !== null) parts.push(`${ch.name}=${v}`); |
| 279 | + } |
| 280 | + if (parts.length) writeLine(parts.join(' ')); |
| 281 | + if (++totalRecords % 5000 === 0 && onProgress) { |
| 282 | + onProgress(Math.min(99, Math.round((dgOff / buf.length) * 100))); |
| 283 | + } |
| 284 | + return ++groupRecords < cycleCount; |
| 285 | + }); |
| 286 | + } |
| 287 | + if (groupRecords === 0) { |
| 288 | + const blk = dgData ? readMdf4Block(buf, dgData) : null; |
| 289 | + writeLine(`[mf4] data block ${blk ? blk.id : 'missing'} produced no readable records — skipped`); |
| 290 | + } |
| 291 | + } |
| 292 | + } |
| 293 | + dgOff = dg.links[0]; // dg_dg_next |
| 294 | + } |
| 295 | + |
| 296 | + if (!first) writeLine('[mf4] no readable records found (file may use compression or an unsupported layout)'); |
| 297 | + } catch (err) { |
| 298 | + writeLine(`[mf4] parse stopped: ${String(err)}`); |
| 299 | + } finally { |
| 300 | + flush(); |
| 301 | + fs.closeSync(fd); |
| 302 | + } |
| 303 | + |
| 304 | + onProgress?.(100); |
| 305 | +} |
0 commit comments