Skip to content

Commit ce4b046

Browse files
cinderblockclaude
andcommitted
Migrate to fixed node-pkware fork (v5-fix), drop streaming plumbing
Switches `node-pkware` from the 2021 fork commit to a new `v5-fix` branch based on upstream `arx-tools/node-pkware` v5.9.1 with three algorithmic bugs fixed (commit dacd10b on the fork): - module-level `lastOccurrences` map carrying stale offsets between Implode calls, - `getSizeOfMatching` capping the match at `b - a` for overlapping repetitions (was making run-length-encoding produce literal-per-byte output for repeating-byte runs), - `outputBits` overflow path not pre-allocating a fresh next byte when the spill exactly filled the new byte (caused corruption on the subsequent write). ewd benefit: re-encoded `.ewprj` / `.ms14` are now ~2x the original size instead of 7-10x. Round-trips remain byte-identical on every sample tested. Code change in this repo: `src/decode.ts` and `src/encode.ts` now use the buffer-in/buffer-out `node-pkware/simple` API, drop the streaming plumbing (Readable.from / pipe / streamToBuffer), and become synchronous. New `src/util/buffer.ts` holds the Buffer -> ArrayBuffer helper used by both. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0851d24 commit ce4b046

6 files changed

Lines changed: 38 additions & 49 deletions

File tree

README.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,12 +182,18 @@ untouched at that cell.
182182
### Compression ratio caveat
183183

184184
`ewe` uses [`node-pkware`](https://github.com/cinderblock/node-pkware)'s
185-
`implode` for compression. Its repetition search is currently incomplete
186-
(see the upstream `TODO: search for a better repetition` log lines), so
187-
re-encoded files are several times larger than the originals. They are
188-
still valid: a decode/encode/decode round-trip on every sample tested
189-
produces byte-identical XML, and the resulting files are accepted by
190-
`ewd`. Improving compression density is a `node-pkware` problem.
185+
`implode` for compression. The repetition search isn't quite as
186+
sophisticated as the original PKWare DCL implementation Multisim itself
187+
uses — re-encoded files are roughly **2× the size** of the originals
188+
(e.g. 9 KB `.ewprj` round-trips to 20 KB). The output is still valid
189+
PKWare DCL Implode: round-trips are byte-identical on every sample
190+
tested, and the resulting files are accepted by `ewd`.
191+
192+
This is a substantial improvement over the previous baseline (7-10×
193+
bloat) thanks to fixes in the `v5-fix` branch of the
194+
`cinderblock/node-pkware` fork — see `plans/jet-in-place-edit.md`
195+
context and the fork's `v5-fix` branch for the specific bugs that
196+
were patched.
191197

192198
## Tests
193199

bun.lock

Lines changed: 3 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
},
2424
"dependencies": {
2525
"command-line-args": "^6.0.2",
26-
"node-pkware": "github:cinderblock/node-pkware",
26+
"node-pkware": "cinderblock/node-pkware#v5-fix",
2727
"winston": "^3.19.0"
2828
},
2929
"devDependencies": {

src/decode.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,16 @@
11
import { promises as fs } from 'node:fs';
2-
import { Readable } from 'node:stream';
3-
import { explode, stream } from 'node-pkware';
2+
import { explode } from 'node-pkware/simple';
43
import type winston from 'winston';
54
import { detectFormatByHeader, knownFormatsList, MAX_HEADER_LENGTH } from './formats';
5+
import { bufferToArrayBuffer } from './util/buffer';
66
import { UnexpectedValue } from './util/UnexpectedValue';
77

8-
const { streamToBuffer, through } = stream;
9-
10-
async function decodeBlock(block: Buffer, expectedLength: number): Promise<Buffer> {
11-
const ret = await new Promise<Buffer>(resolve =>
12-
Readable.from(block)
13-
.pipe(through(explode({ inputBufferSize: block.length, outputBufferSize: expectedLength })))
14-
.pipe(streamToBuffer(resolve)),
15-
);
16-
17-
if (ret.length !== expectedLength)
18-
throw new Error(`Decoder returned wrong length. Expected: ${expectedLength}. Got: ${ret.length}.`);
19-
20-
return ret;
8+
function decodeBlock(block: Buffer, expectedLength: number): Buffer {
9+
const result = Buffer.from(explode(bufferToArrayBuffer(block)));
10+
if (result.length !== expectedLength) {
11+
throw new Error(`Decoder returned wrong length. Expected: ${expectedLength}. Got: ${result.length}.`);
12+
}
13+
return result;
2114
}
2215

2316
export async function decode(filename: string, logger: winston.Logger, outFile = `${filename}.xml`): Promise<void> {
@@ -110,7 +103,7 @@ export async function decode(filename: string, logger: winston.Logger, outFile =
110103

111104
decompressedBytesRead += length;
112105

113-
const decodedBlock = await decodeBlock(compressedData, length);
106+
const decodedBlock = decodeBlock(compressedData, length);
114107

115108
written += (await outputFile.write(decodedBlock)).bytesWritten;
116109
}

src/encode.ts

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,15 @@
11
import { promises as fs } from 'node:fs';
2-
import { Readable } from 'node:stream';
3-
import { constants, implode, stream } from 'node-pkware';
2+
import { implode } from 'node-pkware/simple';
43
import type winston from 'winston';
54
import { type EwbFormat, formatForExtension, knownFormatsList } from './formats';
6-
7-
const { COMPRESSION_ASCII, DICTIONARY_SIZE_LARGE } = constants;
8-
const { streamToBuffer, through } = stream;
5+
import { bufferToArrayBuffer } from './util/buffer';
96

107
// Decompressed bytes per block. Multisim/Ultiboard ship files chunked at this
118
// boundary; matching it keeps the section layout identical to originals.
129
export const DEFAULT_BLOCK_SIZE = 900000;
1310

14-
function compressBlock(block: Buffer): Promise<Buffer> {
15-
return new Promise<Buffer>((resolve, reject) => {
16-
const src = Readable.from(block);
17-
const compressor = through(
18-
implode(COMPRESSION_ASCII, DICTIONARY_SIZE_LARGE, {
19-
inputBufferSize: block.length,
20-
outputBufferSize: block.length,
21-
}),
22-
);
23-
src.on('error', reject);
24-
compressor.on('error', reject);
25-
src.pipe(compressor).pipe(streamToBuffer(resolve));
26-
});
11+
function compressBlock(block: Buffer): Buffer {
12+
return Buffer.from(implode(bufferToArrayBuffer(block), 'ascii', 'large'));
2713
}
2814

2915
/**
@@ -95,7 +81,7 @@ export async function encode(inFile: string, logger: winston.Logger, options: En
9581
const end = Math.min(offset + blockSize, totalLength);
9682
const slice = xml.subarray(offset, end);
9783

98-
const compressed = await compressBlock(slice);
84+
const compressed = compressBlock(slice);
9985

10086
logger.silly(`Section #${section}: ${slice.length} bytes -> ${compressed.length} compressed`);
10187

src/util/buffer.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* Convert a Node `Buffer` to a standalone `ArrayBuffer` containing exactly
3+
* the bytes of the Buffer. Buffer instances are often views onto a larger
4+
* shared pool, so we slice to the relevant range.
5+
*/
6+
export function bufferToArrayBuffer(buf: Buffer): ArrayBuffer {
7+
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
8+
}

0 commit comments

Comments
 (0)