Skip to content

Commit 0e8b20c

Browse files
cinderblockclaude
andcommitted
Add encoder (ewe), symmetric to decoder (ewd)
`ewe` re-compresses an XML file back into a `.ewprj` or `.ms14` the original software can open, using node-pkware's `implode` with ASCII literals and the large dictionary (matching what Multisim/Ultiboard emit). Decompressed sections are chunked at 900 000 bytes to mirror the section layout observed in real files. Output format is inferred from the target extension; default output path strips a trailing `.xml` from the input. A decode/encode/decode round-trip is byte-identical on every sample tested. Re-encoded files are larger than the originals because node-pkware's implode skips an unimplemented repetition search; the output is still valid PKWare DCL Implode and `ewd` accepts it. Also renamed `src/main.ts` to `src/ewd.ts` so the two CLIs share a naming convention, and added `npm run ewd` / `npm run ewe` scripts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b05a1f8 commit 0e8b20c

5 files changed

Lines changed: 209 additions & 14 deletions

File tree

README.md

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,69 @@
1-
# Electronics Workbench Decoder (and Encoder?)
1+
# Electronics Workbench Decoder and Encoder
22

3-
Decompresses National Instruments Electronics Workbench / Multisim / Ultiboard
4-
project files (`.ewprj`, `.ms14`) into their underlying XML.
3+
Two CLIs:
54

6-
The container is a small header (`CompressedElectronicsWorkbenchXML` or
7-
`MSMCompressedElectronicsWorkbenchXML`) followed by a 64-bit total decompressed
8-
length, then a sequence of `(decompressed_length: u32, compressed_length: u32, pkware_implode_block)` sections. Compression is PKWare DCL Implode (ASCII,
9-
large dictionary).
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.
107

11-
For each input file, a `<filename>.xml` is written next to the input.
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
11+
PKWare DCL Implode (ASCII literal mode, large dictionary). Multisim chunks
12+
big files into 900 000-decompressed-byte sections; `ewe` mirrors that.
1213

13-
## Development
14+
## Install
1415

1516
```bash
1617
npm i
17-
npm run dev -- --verbose ./samples/Temp.ewprj ./samples/Design1.ms14
1818
```
1919

20-
CLI options:
20+
## Decode
21+
22+
```bash
23+
npm run ewd -- --verbose ./samples/Temp.ewprj ./samples/Design1.ms14
24+
```
25+
26+
For each input, writes `<filename>.xml` next to it.
27+
28+
Options:
2129

2230
- `-v`, `--verbose` — log per-section progress
2331
- `-c`, `--concurrent` — decode multiple files in parallel
2432
- positional args — files to decode
33+
34+
## Encode
35+
36+
```bash
37+
npm run ewe -- --verbose ./samples/Temp.ewprj.xml
38+
# or with explicit output:
39+
npm run ewe -- --output ./out.ewprj ./samples/Temp.ewprj.xml
40+
```
41+
42+
By default, strips a trailing `.xml` from each input to derive the output
43+
path. Format is inferred from the output extension (`.ewprj` vs `.ms14`).
44+
45+
Options:
46+
47+
- `-v`, `--verbose` — log per-section progress
48+
- `-c`, `--concurrent` — encode multiple files in parallel
49+
- `-o`, `--output <path>` — explicit output path (single-input only)
50+
- positional args — XML files to encode
51+
52+
### Compression ratio caveat
53+
54+
`ewe` uses [`node-pkware`](https://github.com/cinderblock/node-pkware)'s
55+
`implode` for compression. Its repetition search is currently incomplete
56+
(see the upstream `TODO: search for a better repetition` log lines), so
57+
re-encoded files are several times larger than the originals. They are
58+
still valid: a decode/encode/decode round-trip on every sample tested
59+
produces byte-identical XML, and the resulting files are accepted by
60+
`ewd`. Improving compression density is a `node-pkware` problem.
61+
62+
## Development
63+
64+
```bash
65+
npm run dev:ewd -- --verbose ./samples/Temp.ewprj
66+
npm run dev:ewe -- --verbose ./samples/Temp.ewprj.xml
67+
```
68+
69+
Both use `ts-node-dev` for fast reload.

package.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
{
22
"name": "electronics-workbench-decoder",
33
"version": "0.0.0",
4-
"description": "Scripts to decode compressed EWB files",
4+
"description": "Decode (ewd) and encode (ewe) compressed Electronics Workbench / Multisim / Ultiboard files",
55
"author": "Cameron Tacklind <cameron@tacklind.com>",
66
"license": "ISC",
77
"main": "decode",
88
"scripts": {
9-
"start": "ts-node src/main.ts",
10-
"dev": "ts-node-dev --rs --respawn src/main.ts --"
9+
"start": "ts-node src/ewd.ts",
10+
"dev": "ts-node-dev --rs --respawn src/ewd.ts --",
11+
"ewd": "ts-node src/ewd.ts",
12+
"ewe": "ts-node src/ewe.ts",
13+
"dev:ewd": "ts-node-dev --rs --respawn src/ewd.ts --",
14+
"dev:ewe": "ts-node-dev --rs --respawn src/ewe.ts --"
1115
},
1216
"engines": {
1317
"node": ">=8.0.0",

src/encode.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { promises as fs } from 'fs';
2+
import winston from 'winston';
3+
import { constants, implode, stream } from 'node-pkware';
4+
import { Readable } from 'stream';
5+
6+
const { COMPRESSION_ASCII, DICTIONARY_SIZE_LARGE } = constants;
7+
const { streamToBuffer, through } = stream;
8+
9+
// Decompressed bytes per block. Multisim/Ultiboard ship files chunked at this
10+
// boundary; matching it keeps the section layout identical to originals.
11+
export const DEFAULT_BLOCK_SIZE = 900000;
12+
13+
function compressBlock(block: Buffer): Promise<Buffer> {
14+
return new Promise<Buffer>((resolve, reject) => {
15+
const src = Readable.from(block);
16+
const compressor = through(
17+
implode(COMPRESSION_ASCII, DICTIONARY_SIZE_LARGE, {
18+
inputBufferSize: block.length,
19+
outputBufferSize: block.length,
20+
}),
21+
);
22+
src.on('error', reject);
23+
compressor.on('error', reject);
24+
src.pipe(compressor).pipe(streamToBuffer(resolve));
25+
});
26+
}
27+
28+
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');
34+
}
35+
throw new Error(`I don't know what header to use for: ${targetFilename}`);
36+
}
37+
38+
function inferOutFile(inFile: string): string {
39+
if (inFile.endsWith('.xml')) return inFile.slice(0, -'.xml'.length);
40+
throw new Error(`Cannot infer output filename from ${inFile}, pass --output`);
41+
}
42+
43+
export async function encode(
44+
inFile: string,
45+
logger: winston.Logger,
46+
outFile = inferOutFile(inFile),
47+
blockSize = DEFAULT_BLOCK_SIZE,
48+
): Promise<void> {
49+
if (!inFile) throw new Error('No input filename provided');
50+
if (blockSize <= 0) throw new RangeError('blockSize must be > 0');
51+
52+
const header = headerFor(outFile);
53+
54+
logger.verbose(`Encoding ${inFile} -> ${outFile}`);
55+
56+
const xml = await fs.readFile(inFile);
57+
const totalLength = xml.length;
58+
logger.silly(`Read ${totalLength} bytes from ${inFile}`);
59+
60+
const output = await fs.open(outFile, 'w');
61+
try {
62+
await output.write(header);
63+
64+
const sizeBuf = Buffer.allocUnsafe(8);
65+
sizeBuf.writeBigUInt64LE(BigInt(totalLength));
66+
await output.write(sizeBuf);
67+
68+
let section = 0;
69+
for (let offset = 0; offset < totalLength; offset += blockSize) {
70+
const end = Math.min(offset + blockSize, totalLength);
71+
const slice = xml.subarray(offset, end);
72+
73+
const compressed = await compressBlock(slice);
74+
75+
logger.silly(`Section #${section}: ${slice.length} bytes -> ${compressed.length} compressed`);
76+
77+
const sectionHeader = Buffer.allocUnsafe(8);
78+
sectionHeader.writeUInt32LE(slice.length, 0);
79+
sectionHeader.writeUInt32LE(compressed.length, 4);
80+
81+
await output.write(sectionHeader);
82+
await output.write(compressed);
83+
84+
section++;
85+
}
86+
87+
logger.verbose(`Wrote ${outFile} (${section} section${section === 1 ? '' : 's'})`);
88+
} finally {
89+
await output.close();
90+
}
91+
}
File renamed without changes.

src/ewe.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import winston from 'winston';
2+
import commandLineArgs from 'command-line-args';
3+
import { encode } from './encode';
4+
5+
export async function main() {
6+
const { files, output, verbose, concurrent } = commandLineArgs([
7+
{ name: 'verbose', alias: 'v', type: Boolean },
8+
{ name: 'concurrent', alias: 'c', type: Boolean },
9+
{ name: 'output', alias: 'o', type: String },
10+
{ name: 'files', type: String, multiple: true, defaultOption: true },
11+
]) as {
12+
files: string[];
13+
output?: string;
14+
verbose: boolean;
15+
concurrent: boolean;
16+
};
17+
18+
const logger = winston.createLogger({
19+
level: verbose ? 'verbose' : 'info',
20+
format: winston.format.json(),
21+
transports: [
22+
new winston.transports.Console({
23+
format: winston.format.combine(winston.format.colorize(), winston.format.simple()),
24+
}),
25+
],
26+
});
27+
28+
if (!files) {
29+
logger.error('No files specified');
30+
process.exitCode = 1;
31+
return;
32+
}
33+
34+
if (output && files.length > 1) {
35+
logger.error('--output cannot be combined with multiple input files');
36+
process.exitCode = 1;
37+
return;
38+
}
39+
40+
if (concurrent) {
41+
await Promise.all(files.map(f => encode(f, logger, output)));
42+
} else {
43+
for (const f of files) {
44+
logger.info(`Next file: ${f}`);
45+
await encode(f, logger, output);
46+
}
47+
}
48+
}
49+
50+
if (require.main === module) {
51+
main().catch(e => {
52+
console.error(e);
53+
process.exitCode = 1;
54+
});
55+
}

0 commit comments

Comments
 (0)