Skip to content

Commit ec3e317

Browse files
cinderblockclaude
andcommitted
Support all .ms10..ms19 files + content-based format detection
The decoder was hardcoded to two extensions (.ewprj, .ms14) with the matching headers baked into decode.ts. Replace that with a content-driven approach: the file's magic bytes determine the format, so any compressed EW file decodes regardless of filename. - New `src/formats.ts` is the single source of truth: a `FORMATS` table with one entry per container variant (key, label, header, extension pattern), plus helpers (`detectFormatByHeader`, `formatForExtension`, `formatByKey`, `knownFormatsList`). - `decode.ts` now reads the longest known header up front, identifies the format from the bytes, and rewinds the file position if the actual match is shorter than the read. - `encode.ts`: `encode()` takes an options object with an optional `format` override; `headerFor` is kept as a thin wrapper for tests. `ewe` exposes the override as `--format <key>` (e.g. for renamed files or unconventional extensions). - Coverage: Multisim 10 through 19 are recognized by extension; .ms13 and .ms14 are content-verified, the rest are extrapolated. Out-of-range versions still work via `--format multisim`. Verified end-to-end: decoded a real DanceDongle.ms13 (Multisim 13 schema 66) and round-tripped it byte-identical. 43 tests pass across 3 files, biome+tsc clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 16546fb commit ec3e317

7 files changed

Lines changed: 276 additions & 53 deletions

File tree

README.md

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,36 @@
22

33
Two CLIs:
44

5-
- **`ewd`** — decode an EWB project (`.ewprj`, `.ms14`) into its underlying XML.
6-
- **`ewe`** — encode XML back into a `.ewprj`/`.ms14` the original software can re-open.
5+
- **`ewd`** — decode an EWB project into its underlying XML.
6+
- **`ewe`** — encode XML back into a compressed EWB file the original software can re-open.
77

8-
The container is a small header (`CompressedElectronicsWorkbenchXML` or
9-
`MSMCompressedElectronicsWorkbenchXML`), then a 64-bit little-endian total
10-
decompressed length, then a sequence of `(decompressed_length: u32, compressed_length: u32, pkware_implode_block)` sections. Compression is
8+
## Supported formats
9+
10+
| Key | Extension(s) | Header |
11+
| ---------- | --------------------- | --------------------------------------- |
12+
| `ewprj` | `.ewprj` | `CompressedElectronicsWorkbenchXML` |
13+
| `multisim` | `.ms10``.ms19` | `MSMCompressedElectronicsWorkbenchXML` |
14+
15+
`ewd` identifies the format by reading the magic bytes at the start of the
16+
file, so the filename extension can be anything (renamed files, no extension,
17+
`.dat`, etc. all decode correctly).
18+
19+
`ewe` infers the format from the **output** filename's extension; pass
20+
`--format <key>` to override for non-standard extensions. Out-of-range
21+
Multisim versions (e.g. `.ms9` or future `.ms20+`) need an explicit
22+
`--format multisim`.
23+
24+
The container is a small header, then a 64-bit little-endian total
25+
decompressed length, then a sequence of `(decompressed_length: u32,
26+
compressed_length: u32, pkware_implode_block)` sections. Compression is
1127
PKWare DCL Implode (ASCII literal mode, large dictionary). Multisim chunks
1228
big files into 900 000-decompressed-byte sections; `ewe` mirrors that.
1329

30+
> Note: the `*.prj` / `*.usr` / `*.ldb` files that ship as part of NI's
31+
> "Electronics Workbench Database" are Microsoft Access (Jet) databases,
32+
> not the compressed-XML container described here. They aren't handled by
33+
> `ewd` / `ewe`.
34+
1435
## Install
1536

1637
Requires [bun](https://bun.sh) (≥ 1.0).
@@ -39,16 +60,19 @@ Options:
3960
bun run ewe --verbose ./samples/Temp.ewprj.xml
4061
# or with explicit output:
4162
bun run ewe --output ./out.ewprj ./samples/Temp.ewprj.xml
63+
# or force a format on a non-standard extension:
64+
bun run ewe --format multisim --output ./out.dat ./samples/Design1.ms14.xml
4265
```
4366

4467
By default, strips a trailing `.xml` from each input to derive the output
45-
path. Format is inferred from the output extension (`.ewprj` vs `.ms14`).
68+
path. Format is inferred from the output extension (see the table above).
4669

4770
Options:
4871

4972
- `-v`, `--verbose` — log per-section progress
5073
- `-c`, `--concurrent` — encode multiple files in parallel
5174
- `-o`, `--output <path>` — explicit output path (single-input only)
75+
- `-f`, `--format <key>` — force a format (`ewprj` or `multisim`)
5276
- positional args — XML files to encode
5377

5478
### Compression ratio caveat
@@ -67,9 +91,10 @@ produces byte-identical XML, and the resulting files are accepted by
6791
bun test
6892
```
6993

70-
Covers unit tests for the encoder's filename helpers and round-trip
71-
integration tests (tiny `.ewprj`, tiny `.ms14`, multi-block payload that
72-
spans more than one PKWare section, and an empty payload).
94+
Covers the format registry (extension matching, header detection),
95+
encoder filename helpers, and round-trip integration tests (tiny
96+
`.ewprj`, tiny `.ms14`, multi-block payload that spans more than one
97+
PKWare section, and an empty payload).
7398

7499
## Development
75100

src/decode.ts

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs';
22
import { Readable } from 'node:stream';
33
import { explode, stream } from 'node-pkware';
44
import type winston from 'winston';
5+
import { detectFormatByHeader, knownFormatsList, MAX_HEADER_LENGTH } from './formats';
56
import { UnexpectedValue } from './util/UnexpectedValue';
67

78
const { streamToBuffer, through } = stream;
@@ -22,18 +23,6 @@ async function decodeBlock(block: Buffer, expectedLength: number): Promise<Buffe
2223
export async function decode(filename: string, logger: winston.Logger, outFile = `${filename}.xml`): Promise<void> {
2324
if (!filename) throw new Error('No filename provided');
2425

25-
let expectedHeader: Buffer;
26-
27-
if (filename.endsWith('.ewprj')) {
28-
logger.silly(`Opening EWPRJ: ${filename}`);
29-
expectedHeader = Buffer.from('CompressedElectronicsWorkbenchXML');
30-
} else if (filename.endsWith('.ms14')) {
31-
logger.silly(`Opening MultiSIM: ${filename}`);
32-
expectedHeader = Buffer.from('MSMCompressedElectronicsWorkbenchXML');
33-
} else {
34-
throw new Error(`I don't know how to parse: ${filename}`);
35-
}
36-
3726
const file = await fs.open(filename, 'r');
3827
let pos = 0;
3928

@@ -78,15 +67,20 @@ export async function decode(filename: string, logger: winston.Logger, outFile =
7867
const outputFile = await fs.open(outFile, 'w');
7968

8069
try {
81-
const header = await read(expectedHeader.length);
82-
83-
logger.silly('Read header successfully');
84-
85-
if (!header.equals(expectedHeader)) {
86-
throw new UnexpectedValue('File header does not match', expectedHeader, header);
70+
const headerBuffer = await read(MAX_HEADER_LENGTH);
71+
const format = detectFormatByHeader(headerBuffer);
72+
73+
if (!format) {
74+
throw new UnexpectedValue(
75+
`File header doesn't match any known EW format. Supported: ${knownFormatsList()}`,
76+
`one of ${knownFormatsList()}`,
77+
headerBuffer.toString('ascii'),
78+
);
8779
}
8880

89-
logger.silly('Header matches as expected');
81+
// Rewind past any bytes we read beyond the actual header length.
82+
pos -= MAX_HEADER_LENGTH - format.header.length;
83+
logger.silly(`Detected format: ${format.label}`);
9084

9185
const finalLength = await readNumber(8);
9286

src/encode.test.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,50 @@
11
import { describe, expect, test } from 'bun:test';
22
import { headerFor, inferOutFile } from './encode';
33

4+
const EWPRJ_HEADER = 'CompressedElectronicsWorkbenchXML';
5+
const MULTISIM_HEADER = 'MSMCompressedElectronicsWorkbenchXML';
6+
47
describe('headerFor', () => {
5-
test('returns the .ewprj header for .ewprj filenames', () => {
6-
expect(headerFor('foo.ewprj').toString('ascii')).toBe('CompressedElectronicsWorkbenchXML');
8+
test('returns the EWPRJ header for .ewprj filenames', () => {
9+
expect(headerFor('foo.ewprj').toString('ascii')).toBe(EWPRJ_HEADER);
710
});
811

9-
test('returns the .ms14 header for .ms14 filenames', () => {
10-
expect(headerFor('foo.ms14').toString('ascii')).toBe('MSMCompressedElectronicsWorkbenchXML');
12+
test.each([
13+
'foo.ms10',
14+
'foo.ms11',
15+
'foo.ms12',
16+
'foo.ms13',
17+
'foo.ms14',
18+
'foo.ms19',
19+
])('returns the Multisim header for %s', name => {
20+
expect(headerFor(name).toString('ascii')).toBe(MULTISIM_HEADER);
1121
});
1222

1323
test('throws on unknown extensions', () => {
14-
expect(() => headerFor('foo.txt')).toThrow(/don't know what header/);
24+
expect(() => headerFor('foo.txt')).toThrow(/Cannot infer format/);
1525
expect(() => headerFor('foo')).toThrow();
1626
expect(() => headerFor('foo.ewprj.bak')).toThrow();
1727
});
1828

1929
test('matches the extension at the end, not anywhere', () => {
20-
// these all end in .ewprj or .ms14
2130
expect(() => headerFor('something.ewprj')).not.toThrow();
2231
expect(() => headerFor('dir/sub.ms14')).not.toThrow();
23-
// but these do not
2432
expect(() => headerFor('.ewprj.xml')).toThrow();
2533
expect(() => headerFor('.ms14.something')).toThrow();
2634
});
35+
36+
test('does not match Multisim versions outside the verified range', () => {
37+
// .ms9 and .ms20+ aren't in the conservative pattern; user can pass --format multisim
38+
expect(() => headerFor('foo.ms9')).toThrow();
39+
expect(() => headerFor('foo.ms20')).toThrow();
40+
});
2741
});
2842

2943
describe('inferOutFile', () => {
3044
test('strips a trailing .xml', () => {
3145
expect(inferOutFile('foo.ewprj.xml')).toBe('foo.ewprj');
3246
expect(inferOutFile('bar.ms14.xml')).toBe('bar.ms14');
47+
expect(inferOutFile('baz.ms10.xml')).toBe('baz.ms10');
3348
});
3449

3550
test('handles paths with directories', () => {

src/encode.ts

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs';
22
import { Readable } from 'node:stream';
33
import { constants, implode, stream } from 'node-pkware';
44
import type winston from 'winston';
5+
import { type EwbFormat, formatForExtension, knownFormatsList } from './formats';
56

67
const { COMPRESSION_ASCII, DICTIONARY_SIZE_LARGE } = constants;
78
const { streamToBuffer, through } = stream;
@@ -25,33 +26,51 @@ function compressBlock(block: Buffer): Promise<Buffer> {
2526
});
2627
}
2728

28-
export function headerFor(targetFilename: string): Buffer {
29-
if (targetFilename.endsWith('.ewprj')) {
30-
return Buffer.from('CompressedElectronicsWorkbenchXML');
31-
}
32-
if (targetFilename.endsWith('.ms14')) {
33-
return Buffer.from('MSMCompressedElectronicsWorkbenchXML');
29+
/**
30+
* Resolve the target format. Prefers an explicit `format` override; falls back
31+
* to inferring from the output filename's extension.
32+
*/
33+
export function resolveFormat(targetFilename: string, override?: EwbFormat): EwbFormat {
34+
if (override) return override;
35+
const inferred = formatForExtension(targetFilename);
36+
if (!inferred) {
37+
throw new Error(
38+
`Cannot infer format for "${targetFilename}". Pass an explicit format. Known: ${knownFormatsList()}.`,
39+
);
3440
}
35-
throw new Error(`I don't know what header to use for: ${targetFilename}`);
41+
return inferred;
42+
}
43+
44+
/** Backward-compatible helper: returns the magic header bytes for a target filename. */
45+
export function headerFor(targetFilename: string): Buffer {
46+
return Buffer.from(resolveFormat(targetFilename).header, 'ascii');
3647
}
3748

3849
export function inferOutFile(inFile: string): string {
3950
if (inFile.endsWith('.xml')) return inFile.slice(0, -'.xml'.length);
4051
throw new Error(`Cannot infer output filename from ${inFile}, pass --output`);
4152
}
4253

43-
export async function encode(
44-
inFile: string,
45-
logger: winston.Logger,
46-
outFile = inferOutFile(inFile),
47-
blockSize = DEFAULT_BLOCK_SIZE,
48-
): Promise<void> {
54+
export interface EncodeOptions {
55+
/** Output filename. Defaults to stripping a trailing `.xml` from `inFile`. */
56+
outFile?: string;
57+
/** Decompressed bytes per section. Defaults to `DEFAULT_BLOCK_SIZE`. */
58+
blockSize?: number;
59+
/** Force a specific output format. Overrides extension-based inference. */
60+
format?: EwbFormat;
61+
}
62+
63+
export async function encode(inFile: string, logger: winston.Logger, options: EncodeOptions = {}): Promise<void> {
4964
if (!inFile) throw new Error('No input filename provided');
65+
66+
const outFile = options.outFile ?? inferOutFile(inFile);
67+
const blockSize = options.blockSize ?? DEFAULT_BLOCK_SIZE;
5068
if (blockSize <= 0) throw new RangeError('blockSize must be > 0');
5169

52-
const header = headerFor(outFile);
70+
const format = resolveFormat(outFile, options.format);
71+
const header = Buffer.from(format.header, 'ascii');
5372

54-
logger.verbose(`Encoding ${inFile} -> ${outFile}`);
73+
logger.verbose(`Encoding ${inFile} -> ${outFile} as ${format.label}`);
5574

5675
const xml = await fs.readFile(inFile);
5776
const totalLength = xml.length;

src/ewe.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import commandLineArgs from 'command-line-args';
22
import winston from 'winston';
33
import { encode } from './encode';
4+
import { FORMATS, formatByKey, knownFormatsList } from './formats';
45

56
export async function main() {
6-
const { files, output, verbose, concurrent } = commandLineArgs([
7+
const { files, output, format, verbose, concurrent } = commandLineArgs([
78
{ name: 'verbose', alias: 'v', type: Boolean },
89
{ name: 'concurrent', alias: 'c', type: Boolean },
910
{ name: 'output', alias: 'o', type: String },
11+
{ name: 'format', alias: 'f', type: String },
1012
{ name: 'files', type: String, multiple: true, defaultOption: true },
1113
]) as {
1214
files: string[];
1315
output?: string;
16+
format?: string;
1417
verbose: boolean;
1518
concurrent: boolean;
1619
};
@@ -37,12 +40,26 @@ export async function main() {
3740
return;
3841
}
3942

43+
let resolvedFormat: ReturnType<typeof formatByKey>;
44+
if (format) {
45+
resolvedFormat = formatByKey(format);
46+
if (!resolvedFormat) {
47+
logger.error(
48+
`Unknown --format "${format}". Known: ${FORMATS.map(f => f.key).join(', ')} (${knownFormatsList()})`,
49+
);
50+
process.exitCode = 1;
51+
return;
52+
}
53+
}
54+
55+
const options = { outFile: output, format: resolvedFormat };
56+
4057
if (concurrent) {
41-
await Promise.all(files.map(f => encode(f, logger, output)));
58+
await Promise.all(files.map(f => encode(f, logger, options)));
4259
} else {
4360
for (const f of files) {
4461
logger.info(`Next file: ${f}`);
45-
await encode(f, logger, output);
62+
await encode(f, logger, options);
4663
}
4764
}
4865
}

0 commit comments

Comments
 (0)