add LZNT1 compression operation

This commit is contained in:
skywalker 2026-06-06 19:42:57 +08:00
parent d735496641
commit 25347b9f2b
5 changed files with 236 additions and 3 deletions

View File

@ -417,6 +417,7 @@
"LZMA Compress", "LZMA Compress",
"LZ4 Decompress", "LZ4 Decompress",
"LZ4 Compress", "LZ4 Compress",
"LZNT1 Compress",
"LZNT1 Decompress" "LZNT1 Decompress"
] ]
}, },

View File

@ -1,6 +1,6 @@
/** /**
* *
* LZNT1 Decompress. * LZNT1 compression and decompression.
* *
* @author 0xThiebaut [thiebaut.dev] * @author 0xThiebaut [thiebaut.dev]
* @copyright Crown Copyright 2023 * @copyright Crown Copyright 2023
@ -13,6 +13,8 @@ import Utils from "../Utils.mjs";
import OperationError from "../errors/OperationError.mjs"; import OperationError from "../errors/OperationError.mjs";
const COMPRESSED_MASK = 1 << 15, const COMPRESSED_MASK = 1 << 15,
SIGNATURE_MASK = 3 << 12,
BLOCK_SIZE = 4096,
SIZE_MASK = (1 << 12) - 1; SIZE_MASK = (1 << 12) - 1;
/** /**
@ -28,6 +30,116 @@ function getDisplacement(offset) {
return result; return result;
} }
/**
* @param {byteArray} output
* @param {number} header
*/
function appendBlockHeader(output, header) {
output.push(...Utils.intToByteArray(header, 2, "little"));
}
/**
* @param {byteArray} block
* @param {number} pos
* @returns {{offset: number, length: number, lengthBits: number}|null}
*/
function findBestMatch(block, pos) {
const displacement = getDisplacement(pos - 1),
lengthBits = 12 - displacement,
maxOffset = Math.min(pos, 1 << (4 + displacement)),
maxLength = Math.min(block.length - pos, (0xFFF >> displacement) + 3);
let bestOffset = 0,
bestLength = 0;
for (let offset = 1; offset <= maxOffset; offset++) {
let length = 0;
while (
length < maxLength &&
block[pos + length] === block[pos - offset + length]
) {
length++;
}
if (length > bestLength) {
bestOffset = offset;
bestLength = length;
if (bestLength === maxLength) break;
}
}
if (bestLength < 3) return null;
return {
offset: bestOffset,
length: bestLength,
lengthBits,
};
}
/**
* @param {byteArray} block
* @returns {byteArray}
*/
function compressBlock(block) {
const compressed = [];
let pos = 0;
while (pos < block.length) {
const headerIndex = compressed.length;
let header = 0,
bit = 1,
tokens = 0;
compressed.push(0);
while (tokens < 8 && pos < block.length) {
const match = findBestMatch(block, pos);
if (match) {
const pointer = ((match.offset - 1) << match.lengthBits) | (match.length - 3);
compressed.push(...Utils.intToByteArray(pointer, 2, "little"));
header |= bit;
pos += match.length;
} else {
compressed.push(block[pos++]);
}
bit <<= 1;
tokens++;
}
compressed[headerIndex] = header;
}
return compressed;
}
/**
* @param {byteArray} uncompressed
* @returns {byteArray}
*/
export function compress(uncompressed) {
const compressed = [];
for (let offset = 0; offset < uncompressed.length; offset += BLOCK_SIZE) {
const block = uncompressed.slice(offset, offset + BLOCK_SIZE),
compressedBlock = compressBlock(block);
if (block.length === 1 || compressedBlock.length < block.length) {
appendBlockHeader(
compressed,
SIGNATURE_MASK | COMPRESSED_MASK | (compressedBlock.length - 1)
);
compressed.push(...compressedBlock);
} else {
appendBlockHeader(compressed, SIGNATURE_MASK | (block.length - 1));
compressed.push(...block);
}
}
return compressed;
}
/** /**
* @param {byteArray} compressed * @param {byteArray} compressed
* @returns {byteArray} * @returns {byteArray}

View File

@ -0,0 +1,41 @@
/**
* @author skyswordw
* @copyright Crown Copyright 2026
* @license Apache-2.0
*/
import Operation from "../Operation.mjs";
import {compress} from "../lib/LZNT1.mjs";
/**
* LZNT1 Compress operation
*/
class LZNT1Compress extends Operation {
/**
* LZNT1 Compress constructor
*/
constructor() {
super();
this.name = "LZNT1 Compress";
this.module = "Compression";
this.description = "Compresses data using the LZNT1 algorithm.<br><br>Similar to the Windows API <code>RtlCompressBuffer</code>.";
this.infoURL = "https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-xca/5655f4a3-6ba4-489b-959f-e1f407c52f15";
this.inputType = "byteArray";
this.outputType = "byteArray";
this.args = [];
}
/**
* @param {byteArray} input
* @param {Object[]} args
* @returns {byteArray}
*/
run(input, args) {
return compress(input);
}
}
export default LZNT1Compress;

View File

@ -681,6 +681,10 @@ WWFkYSBZYWRh\r
assert.strictEqual(chef.LZNT1Decompress("\x1a\xb0\x00compress\x00edtestda\x04ta\x07\x88alot").toString(), "compressedtestdatacompressedalot"); assert.strictEqual(chef.LZNT1Decompress("\x1a\xb0\x00compress\x00edtestda\x04ta\x07\x88alot").toString(), "compressedtestdatacompressedalot");
}), }),
it("LZNT1 Compress", () => {
assert.strictEqual(chef.LZNT1Decompress(chef.LZNT1Compress("compressedtestdatacompressedalot")).toString(), "compressedtestdatacompressedalot");
}),
it("MD6", () => { it("MD6", () => {
assert.strictEqual(chef.MD6("Head Over Heels", {key: "arty"}).toString(), "d8f7fe4931fbaa37316f76283d5f615f50ddd54afdc794b61da522556aee99ad"); assert.strictEqual(chef.MD6("Head Over Heels", {key: "arty"}).toString(), "d8f7fe4931fbaa37316f76283d5f615f50ddd54afdc794b61da522556aee99ad");
}), }),
@ -1178,4 +1182,3 @@ ExifImageHeight: 57`);
]); ]);

View File

@ -1,5 +1,5 @@
/** /**
* LZNT1 Decompress tests. * LZNT1 tests.
* *
* @author 0xThiebaut [thiebaut.dev] * @author 0xThiebaut [thiebaut.dev]
* @copyright Crown Copyright 2023 * @copyright Crown Copyright 2023
@ -8,6 +8,21 @@
import TestRegister from "../../lib/TestRegister.mjs"; import TestRegister from "../../lib/TestRegister.mjs";
TestRegister.addTests([ TestRegister.addTests([
{
name: "LZNT1 Compress: repeated input",
input: "compressedtestdatacompressedalot",
expectedOutput: "1a b0 00 63 6f 6d 70 72 65 73 73 00 65 64 74 65 73 74 64 61 04 74 61 07 88 61 6c 6f 74",
recipeConfig: [
{
op: "LZNT1 Compress",
args: []
},
{
op: "To Hex",
args: ["Space", 0]
}
],
},
{ {
name: "LZNT1 Decompress", name: "LZNT1 Decompress",
input: "\x1a\xb0\x00compress\x00edtestda\x04ta\x07\x88alot", input: "\x1a\xb0\x00compress\x00edtestda\x04ta\x07\x88alot",
@ -18,5 +33,66 @@ TestRegister.addTests([
args: [] args: []
} }
], ],
},
{
name: "LZNT1 Compress: incompressible input",
input: "abcdefghijklmnopqrstuvwxyz",
expectedOutput: "19 30 61 62 63 64 65 66 67 68 69 6a 6b 6c 6d 6e 6f 70 71 72 73 74 75 76 77 78 79 7a",
recipeConfig: [
{
op: "LZNT1 Compress",
args: []
},
{
op: "To Hex",
args: ["Space", 0]
}
],
},
{
name: "LZNT1 Compress/Decompress: binary",
input: "00 01 02 03 00 01 02 03 00 01 02 03 ff ff ff ff 00",
expectedOutput: "00 01 02 03 00 01 02 03 00 01 02 03 ff ff ff ff 00",
recipeConfig: [
{
op: "From Hex",
args: ["Space"]
},
{
op: "LZNT1 Compress",
args: []
},
{
op: "LZNT1 Decompress",
args: []
},
{
op: "To Hex",
args: ["Space", 0]
}
],
},
{
name: "LZNT1 Compress/Decompress: single byte",
input: "41",
expectedOutput: "41",
recipeConfig: [
{
op: "From Hex",
args: ["None"]
},
{
op: "LZNT1 Compress",
args: []
},
{
op: "LZNT1 Decompress",
args: []
},
{
op: "To Hex",
args: ["None", 0]
}
],
} }
]); ]);