297 lines
9.5 KiB
TypeScript
297 lines
9.5 KiB
TypeScript
/*
|
|
* SPDX-License-Identifier: MIT
|
|
*
|
|
* TypeScript port of compcol `src/quantum/decoder.rs` at commit
|
|
* 04a6db2aa7bd487a89c559631d79d1b384139f50.
|
|
* Copyright (c) 2026 Karpeles Lab Inc., MIT License.
|
|
*
|
|
* Modified for synchronous CAB CFDATA chunks, fixed output buffers, explicit
|
|
* operation/cancellation budgets, strict frame/output boundaries, and one
|
|
* synthetic 0xFF trailer byte per CAB block as required by the reference.
|
|
*/
|
|
|
|
import { fail } from '../errors.js';
|
|
import { QuantumBitReader } from './bit-reader.js';
|
|
import { QuantumBudget, type QuantumDecoderOptions } from './budget.js';
|
|
import { QuantumArithmeticDecoder, QuantumModel } from './model.js';
|
|
import {
|
|
EXTRA_BITS,
|
|
LENGTH_BASE,
|
|
LENGTH_EXTRA,
|
|
POSITION_BASE,
|
|
} from './tables.js';
|
|
|
|
const FRAME_SIZE = 32 * 1024;
|
|
const MIN_WINDOW_BITS = 10;
|
|
const MAX_WINDOW_BITS = 21;
|
|
const MAX_TRAILER_ZERO_BYTES = 4;
|
|
const CAB_BLOCK_SENTINEL = 0xff;
|
|
|
|
export class QuantumDecoder {
|
|
private readonly window: Uint8Array;
|
|
private readonly windowMask: number;
|
|
private readonly budget: QuantumBudget;
|
|
private readonly bitReader: QuantumBitReader;
|
|
private readonly arithmetic: QuantumArithmeticDecoder;
|
|
private input = new Uint8Array(0);
|
|
private windowPosition = 0;
|
|
private headerRead = false;
|
|
private frameRemaining = FRAME_SIZE;
|
|
private finished = false;
|
|
private inputFinished = false;
|
|
|
|
private readonly model0: QuantumModel;
|
|
private readonly model1: QuantumModel;
|
|
private readonly model2: QuantumModel;
|
|
private readonly model3: QuantumModel;
|
|
private readonly model4: QuantumModel;
|
|
private readonly model5: QuantumModel;
|
|
private readonly model6: QuantumModel;
|
|
private readonly model6Length: QuantumModel;
|
|
private readonly model7: QuantumModel;
|
|
|
|
constructor(windowBits: number, options: QuantumDecoderOptions) {
|
|
if (
|
|
!Number.isInteger(windowBits) ||
|
|
windowBits < MIN_WINDOW_BITS ||
|
|
windowBits > MAX_WINDOW_BITS
|
|
) {
|
|
fail(
|
|
'quantum-invalid-window',
|
|
`Quantum window size 2^${windowBits} is outside 2^${MIN_WINDOW_BITS}..2^${MAX_WINDOW_BITS}.`,
|
|
{ structure: 'CFFOLDER.typeCompress' }
|
|
);
|
|
}
|
|
const windowSize = 2 ** windowBits;
|
|
this.window = new Uint8Array(windowSize);
|
|
this.windowMask = windowSize - 1;
|
|
this.budget = new QuantumBudget(options);
|
|
this.bitReader = new QuantumBitReader(this.budget);
|
|
this.arithmetic = new QuantumArithmeticDecoder(this.budget);
|
|
|
|
const positionModelSize = windowBits * 2;
|
|
this.model0 = new QuantumModel(0, 64, this.budget);
|
|
this.model1 = new QuantumModel(64, 64, this.budget);
|
|
this.model2 = new QuantumModel(128, 64, this.budget);
|
|
this.model3 = new QuantumModel(192, 64, this.budget);
|
|
this.model4 = new QuantumModel(
|
|
0,
|
|
Math.min(positionModelSize, 24),
|
|
this.budget
|
|
);
|
|
this.model5 = new QuantumModel(
|
|
0,
|
|
Math.min(positionModelSize, 36),
|
|
this.budget
|
|
);
|
|
this.model6 = new QuantumModel(0, positionModelSize, this.budget);
|
|
this.model6Length = new QuantumModel(0, 27, this.budget);
|
|
this.model7 = new QuantumModel(0, 7, this.budget);
|
|
}
|
|
|
|
appendCabBlock(compressed: Uint8Array, isLastBlock: boolean): void {
|
|
if (this.inputFinished) {
|
|
fail(
|
|
'quantum-data-after-final-block',
|
|
'Quantum decoder received data after the final CAB block.',
|
|
{ structure: 'Quantum stream' }
|
|
);
|
|
}
|
|
this.appendBlockBytes(compressed);
|
|
if (isLastBlock) {
|
|
this.inputFinished = true;
|
|
this.bitReader.setEndOfInput();
|
|
}
|
|
}
|
|
|
|
decompressNext(outputLength: number, isLastBlock: boolean): Uint8Array {
|
|
if (this.finished) {
|
|
fail(
|
|
'quantum-data-after-final-block',
|
|
'Quantum decoder received data after the final CAB block.',
|
|
{ structure: 'Quantum stream' }
|
|
);
|
|
}
|
|
if (
|
|
!Number.isInteger(outputLength) ||
|
|
outputLength <= 0 ||
|
|
outputLength > FRAME_SIZE
|
|
) {
|
|
fail(
|
|
'quantum-invalid-chunk-size',
|
|
`Quantum CAB chunk output size ${outputLength} is outside 1..${FRAME_SIZE}.`,
|
|
{ structure: 'CFDATA.cbUncomp' }
|
|
);
|
|
}
|
|
if (!isLastBlock && outputLength !== FRAME_SIZE) {
|
|
fail(
|
|
'quantum-partial-intermediate-frame',
|
|
'A non-final Quantum CAB block must produce exactly 32 KiB.',
|
|
{ structure: 'CFDATA.cbUncomp' }
|
|
);
|
|
}
|
|
|
|
const output = new Uint8Array(outputLength);
|
|
let outputOffset = 0;
|
|
while (outputOffset < output.length) {
|
|
this.budget.consume();
|
|
if (this.frameRemaining === 0) this.completeFrameTrailer();
|
|
if (!this.headerRead) {
|
|
this.arithmetic.initializeFrame(this.bitReader, this.input);
|
|
this.headerRead = true;
|
|
}
|
|
outputOffset = this.decodePacket(output, outputOffset);
|
|
}
|
|
|
|
// CAB blocks before the final partial block are complete Quantum frames.
|
|
// Consume their alignment and synthetic sentinel now, matching qtmd.c,
|
|
// so a malformed final frame cannot evade trailer validation.
|
|
if (this.frameRemaining === 0) this.completeFrameTrailer();
|
|
|
|
this.compactInput();
|
|
if (isLastBlock) this.finished = true;
|
|
return output;
|
|
}
|
|
|
|
private decodePacket(output: Uint8Array, outputOffset: number): number {
|
|
const selector = this.arithmetic.getSymbol(
|
|
this.model7,
|
|
this.bitReader,
|
|
this.input
|
|
);
|
|
if (selector < 4) {
|
|
const model = [this.model0, this.model1, this.model2, this.model3][
|
|
selector
|
|
]!;
|
|
const symbol = this.arithmetic.getSymbol(
|
|
model,
|
|
this.bitReader,
|
|
this.input
|
|
);
|
|
this.window[this.windowPosition & this.windowMask] = symbol & 0xff;
|
|
output[outputOffset] = symbol & 0xff;
|
|
this.windowPosition += 1;
|
|
this.frameRemaining -= 1;
|
|
this.budget.consume();
|
|
return outputOffset + 1;
|
|
}
|
|
|
|
let matchLength: number;
|
|
let matchOffset: number;
|
|
if (selector === 4 || selector === 5) {
|
|
const model = selector === 4 ? this.model4 : this.model5;
|
|
const slot = this.arithmetic.getSymbol(model, this.bitReader, this.input);
|
|
matchOffset = this.readPosition(slot);
|
|
matchLength = selector === 4 ? 3 : 4;
|
|
} else if (selector === 6) {
|
|
const lengthSlot = this.arithmetic.getSymbol(
|
|
this.model6Length,
|
|
this.bitReader,
|
|
this.input
|
|
);
|
|
if (lengthSlot >= LENGTH_EXTRA.length) {
|
|
fail('quantum-invalid-length-slot', 'Invalid Quantum length slot.', {
|
|
structure: 'Quantum match',
|
|
});
|
|
}
|
|
const lengthExtraBits = LENGTH_EXTRA[lengthSlot]!;
|
|
const lengthExtra = this.bitReader.readManyBits(
|
|
lengthExtraBits,
|
|
this.input
|
|
);
|
|
matchLength = LENGTH_BASE[lengthSlot]! + lengthExtra + 5;
|
|
const positionSlot = this.arithmetic.getSymbol(
|
|
this.model6,
|
|
this.bitReader,
|
|
this.input
|
|
);
|
|
matchOffset = this.readPosition(positionSlot);
|
|
} else {
|
|
fail('quantum-invalid-selector', 'Invalid Quantum packet selector.', {
|
|
structure: 'Quantum packet',
|
|
});
|
|
}
|
|
|
|
if (matchOffset <= 0 || matchOffset > this.window.length) {
|
|
fail(
|
|
'quantum-invalid-match-offset',
|
|
`Quantum match offset ${matchOffset} exceeds its ${this.window.length}-byte window.`,
|
|
{ structure: 'Quantum match' }
|
|
);
|
|
}
|
|
if (matchLength > this.frameRemaining) {
|
|
fail(
|
|
'quantum-frame-overrun',
|
|
'Quantum match crosses its 32 KiB frame boundary.',
|
|
{ structure: 'Quantum match' }
|
|
);
|
|
}
|
|
if (matchLength > output.length - outputOffset) {
|
|
fail(
|
|
'quantum-output-overrun',
|
|
'Quantum match exceeds the CAB block output declaration.',
|
|
{ structure: 'CFDATA.cbUncomp' }
|
|
);
|
|
}
|
|
|
|
for (let index = 0; index < matchLength; index += 1) {
|
|
const source =
|
|
(this.windowPosition - matchOffset + this.window.length) &
|
|
this.windowMask;
|
|
const value = this.window[source]!;
|
|
this.window[this.windowPosition & this.windowMask] = value;
|
|
output[outputOffset + index] = value;
|
|
this.windowPosition += 1;
|
|
}
|
|
this.frameRemaining -= matchLength;
|
|
this.budget.consume(matchLength);
|
|
return outputOffset + matchLength;
|
|
}
|
|
|
|
private readPosition(slot: number): number {
|
|
if (slot >= EXTRA_BITS.length) {
|
|
fail('quantum-invalid-position-slot', 'Invalid Quantum position slot.', {
|
|
structure: 'Quantum match',
|
|
});
|
|
}
|
|
const extra = this.bitReader.readManyBits(EXTRA_BITS[slot]!, this.input);
|
|
return POSITION_BASE[slot]! + extra + 1;
|
|
}
|
|
|
|
private completeFrameTrailer(): void {
|
|
this.bitReader.alignToByte();
|
|
let zeroBytes = 0;
|
|
while (true) {
|
|
const value = this.bitReader.readBits(8, this.input);
|
|
if (value === CAB_BLOCK_SENTINEL) break;
|
|
if (value !== 0 || zeroBytes >= MAX_TRAILER_ZERO_BYTES) {
|
|
fail(
|
|
'quantum-invalid-frame-trailer',
|
|
'Quantum frame trailer is not zero padding followed by 0xFF.',
|
|
{ structure: 'Quantum frame trailer' }
|
|
);
|
|
}
|
|
zeroBytes += 1;
|
|
this.budget.consume();
|
|
}
|
|
this.headerRead = false;
|
|
this.frameRemaining = FRAME_SIZE;
|
|
}
|
|
|
|
private appendBlockBytes(compressed: Uint8Array): void {
|
|
this.compactInput();
|
|
const appended = new Uint8Array(this.input.length + compressed.length + 1);
|
|
appended.set(this.input);
|
|
appended.set(compressed, this.input.length);
|
|
appended[appended.length - 1] = CAB_BLOCK_SENTINEL;
|
|
this.input = appended;
|
|
}
|
|
|
|
private compactInput(): void {
|
|
const consumed = this.bitReader.offset;
|
|
if (consumed === 0) return;
|
|
this.input = this.input.slice(consumed);
|
|
this.bitReader.rebase(consumed);
|
|
}
|
|
}
|