roxify 1.13.4 → 1.13.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "roxify_native"
3
- version = "1.13.4"
3
+ version = "1.13.5"
4
4
  edition = "2021"
5
5
  publish = false
6
6
 
@@ -59,6 +59,7 @@ bytemuck = { version = "1.14", features = ["derive"] }
59
59
  tokio = { version = "1", features = ["sync", "rt"], optional = true }
60
60
  parking_lot = "0.12"
61
61
  libsais = { version = "0.2.0", default-features = false }
62
+ libc = "0.2"
62
63
 
63
64
  [features]
64
65
  default = []
package/README.md CHANGED
@@ -77,6 +77,17 @@ Roxify 1.13.4 adds adaptive parallel preload for small files before feeding Zstd
77
77
  | Glados-Disc (19,645 files) | NTFS under Linux | 81.608 s | 2.189 s | 37.3x |
78
78
  | Gmod (3,936 files) | NTFS under Linux | 22.578 s | 4.517 s | 5.0x |
79
79
 
80
+ ### Portal 2 comparative reference: ZIP vs PNG
81
+
82
+ Measured on the full `Portal 2` game directory (`3,731 files`, `193 folders`, `12.83 GiB` logical source) to compare classic ZIP packaging against Roxify PNG packing on the same dataset.
83
+
84
+ | Format | Time (s) | Time (min:sec) | Throughput | Compression ratio |
85
+ | --- | ---: | --- | ---: | ---: |
86
+ | ZIP Encode | 633,87 | 10 min 33 s | 21,73 Mo/s | 36,08% |
87
+ | ZIP Decode | 232,88 | 3 min 52 s | 59,15 Mo/s | - |
88
+ | PNG Encode | 157,80 | 2 min 37 s | 87,30 Mo/s | 41,09% |
89
+ | PNG Decode | 156,00 | 2 min 36 s | 88,30 Mo/s | - |
90
+
80
91
  ### Data integrity
81
92
 
82
93
  All benchmark runs completed with byte-exact roundtrip validation. Decode output matched original logical source bytes on every dataset.
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -12,9 +12,51 @@ import { cropAndReconstitute } from './reconstitution.js';
12
12
  import { decodeRobustAudio, isRobustAudioWav } from './robust-audio.js';
13
13
  import { decodeRobustImage, isRobustImage } from './robust-image.js';
14
14
  import { parallelZstdDecompress } from './zstd.js';
15
+ const HEADER_VERSION_V1 = 1;
16
+ const HEADER_VERSION_V2 = 2;
15
17
  function isColorMatch(r1, g1, b1, r2, g2, b2) {
16
18
  return Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2) < 50;
17
19
  }
20
+ function readPayloadLength(buf, idx, version) {
21
+ if (version === HEADER_VERSION_V1) {
22
+ if (idx + 4 > buf.length) {
23
+ throw new DataFormatError('Truncated payload length');
24
+ }
25
+ return { payloadLen: buf.readUInt32BE(idx), nextIdx: idx + 4 };
26
+ }
27
+ if (version === HEADER_VERSION_V2) {
28
+ if (idx + 8 > buf.length) {
29
+ throw new DataFormatError('Truncated payload length');
30
+ }
31
+ const payloadLen = buf.readBigUInt64BE(idx);
32
+ if (payloadLen > BigInt(Number.MAX_SAFE_INTEGER)) {
33
+ throw new DataFormatError('Payload too large for JS decoder');
34
+ }
35
+ return { payloadLen: Number(payloadLen), nextIdx: idx + 8 };
36
+ }
37
+ throw new DataFormatError(`Unsupported pixel header version ${version}`);
38
+ }
39
+ function readPixelPayloadHeader(buf, idx) {
40
+ if (idx + 2 > buf.length) {
41
+ throw new DataFormatError('Pixel payload header truncated');
42
+ }
43
+ const version = buf[idx++];
44
+ const nameLen = buf[idx++];
45
+ let name;
46
+ if (nameLen > 0) {
47
+ if (idx + nameLen > buf.length) {
48
+ throw new DataFormatError('Pixel payload name truncated');
49
+ }
50
+ name = buf.subarray(idx, idx + nameLen).toString('utf8');
51
+ idx += nameLen;
52
+ }
53
+ const { payloadLen, nextIdx } = readPayloadLength(buf, idx, version);
54
+ const available = buf.length - nextIdx;
55
+ if (available < payloadLen) {
56
+ throw new DataFormatError(`Pixel payload truncated: expected ${payloadLen} bytes but only ${available} available`);
57
+ }
58
+ return { version, name, payloadOffset: nextIdx, payloadLen };
59
+ }
18
60
  /**
19
61
  * Un-stretch an image that was nearest-neighbor scaled.
20
62
  * 1. Crops to non-background bounding box
@@ -318,18 +360,12 @@ export async function decodePngToBinary(input, opts = {}) {
318
360
  const pcmData = wavToBytes(processedBuf);
319
361
  // The WAV payload starts with PIXEL_MAGIC ("PXL1")
320
362
  if (pcmData.length >= 4 && pcmData.subarray(0, 4).equals(PIXEL_MAGIC)) {
321
- let idx = 4; // skip PIXEL_MAGIC
322
- const version = pcmData[idx++];
323
- const nameLen = pcmData[idx++];
324
- let name;
325
- if (nameLen > 0) {
326
- name = pcmData.subarray(idx, idx + nameLen).toString('utf8');
327
- idx += nameLen;
328
- }
329
- const payloadLen = pcmData.readUInt32BE(idx);
330
- idx += 4;
331
- const rawPayload = pcmData.subarray(idx, idx + payloadLen);
332
- idx += payloadLen;
363
+ const header = readPixelPayloadHeader(pcmData, 4);
364
+ let idx = header.payloadOffset;
365
+ const version = header.version;
366
+ const name = header.name;
367
+ const rawPayload = pcmData.subarray(idx, idx + header.payloadLen);
368
+ idx += header.payloadLen;
333
369
  // Check for rXFL file list after payload
334
370
  let fileListJson;
335
371
  if (idx + 8 < pcmData.length &&
@@ -652,21 +688,10 @@ export async function decodePngToBinary(input, opts = {}) {
652
688
  if (logicalData.length < 8 + PIXEL_MAGIC.length) {
653
689
  throw new DataFormatError('Pixel mode data too short');
654
690
  }
655
- let idx = 8 + PIXEL_MAGIC.length;
656
- const version = logicalData[idx++];
657
- const nameLen = logicalData[idx++];
658
- let name;
659
- if (nameLen > 0 && nameLen < 256) {
660
- name = logicalData.slice(idx, idx + nameLen).toString('utf8');
661
- idx += nameLen;
662
- }
663
- const payloadLen = logicalData.readUInt32BE(idx);
664
- idx += 4;
665
- const available = logicalData.length - idx;
666
- if (available < payloadLen) {
667
- throw new DataFormatError(`Pixel payload truncated: expected ${payloadLen} bytes but only ${available} available`);
668
- }
669
- const rawPayload = logicalData.slice(idx, idx + payloadLen);
691
+ const header = readPixelPayloadHeader(logicalData, 8 + PIXEL_MAGIC.length);
692
+ const version = header.version;
693
+ const name = header.name;
694
+ const rawPayload = logicalData.slice(header.payloadOffset, header.payloadOffset + header.payloadLen);
670
695
  let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
671
696
  try {
672
697
  payload = await tryDecompress(payload, (info) => {
@@ -945,31 +970,10 @@ export async function decodePngToBinary(input, opts = {}) {
945
970
  }
946
971
  }
947
972
  if (idx > 0) {
948
- const version = pixelBytes[idx++];
949
- const nameLen = pixelBytes[idx++];
950
- let name;
951
- if (nameLen > 0 && nameLen < 256) {
952
- name = pixelBytes.slice(idx, idx + nameLen).toString('utf8');
953
- idx += nameLen;
954
- }
955
- const payloadLen = pixelBytes.readUInt32BE(idx);
956
- idx += 4;
957
- if (idx + 4 <= pixelBytes.length) {
958
- const marker = pixelBytes.slice(idx, idx + 4).toString('utf8');
959
- if (marker === 'rXFL') {
960
- idx += 4;
961
- if (idx + 4 <= pixelBytes.length) {
962
- const jsonLen = pixelBytes.readUInt32BE(idx);
963
- idx += 4;
964
- idx += jsonLen;
965
- }
966
- }
967
- }
968
- const available = pixelBytes.length - idx;
969
- if (available < payloadLen) {
970
- throw new DataFormatError(`Pixel payload truncated: expected ${payloadLen} bytes but only ${available} available`);
971
- }
972
- const rawPayload = pixelBytes.slice(idx, idx + payloadLen);
973
+ const header = readPixelPayloadHeader(pixelBytes, idx);
974
+ const version = header.version;
975
+ const name = header.name;
976
+ const rawPayload = pixelBytes.slice(header.payloadOffset, header.payloadOffset + header.payloadLen);
973
977
  let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
974
978
  try {
975
979
  payload = await tryDecompress(payload, (info) => {
@@ -1162,10 +1166,10 @@ export async function decodePngToBinary(input, opts = {}) {
1162
1166
  ii = found + PIXEL_MAGIC.length;
1163
1167
  }
1164
1168
  if (ii > 0) {
1165
- const version2 = pixelBytes2[ii++];
1166
- const nameLen2 = pixelBytes2[ii++];
1167
- const payloadLen2 = pixelBytes2.readUInt32BE(ii + nameLen2);
1168
- const rawPayload2 = pixelBytes2.slice(ii + nameLen2 + 4, ii + nameLen2 + 4 + payloadLen2);
1169
+ const header2 = readPixelPayloadHeader(pixelBytes2, ii);
1170
+ const version2 = header2.version;
1171
+ const name2 = header2.name;
1172
+ const rawPayload2 = pixelBytes2.slice(header2.payloadOffset, header2.payloadOffset + header2.payloadLen);
1169
1173
  let payload2 = tryDecryptIfNeeded(rawPayload2, opts.passphrase);
1170
1174
  payload2 = await tryDecompress(payload2, (info) => {
1171
1175
  if (opts.onProgress)
@@ -1181,13 +1185,13 @@ export async function decodePngToBinary(input, opts = {}) {
1181
1185
  if (opts.onProgress)
1182
1186
  opts.onProgress({ phase: 'done' });
1183
1187
  progressBar?.stop();
1184
- return { files: unpacked2.files, meta: { name } };
1188
+ return { files: unpacked2.files, meta: { name: name2 } };
1185
1189
  }
1186
1190
  }
1187
1191
  if (opts.onProgress)
1188
1192
  opts.onProgress({ phase: 'done' });
1189
1193
  progressBar?.stop();
1190
- return { buf: payload2, meta: { name } };
1194
+ return { buf: payload2, meta: { name: name2 } };
1191
1195
  }
1192
1196
  }
1193
1197
  throw new DataFormatError('Screenshot mode zstd decompression failed: ' + errMsg);
@@ -9,6 +9,15 @@ import { native } from './native.js';
9
9
  import { encodeRobustAudio } from './robust-audio.js';
10
10
  import { encodeRobustImage } from './robust-image.js';
11
11
  import { parallelZstdCompress } from './zstd.js';
12
+ const HEADER_VERSION_V2 = 2;
13
+ function writePayloadLength(payloadLen) {
14
+ if (!Number.isSafeInteger(payloadLen) || payloadLen < 0) {
15
+ throw new RangeError(`Invalid payload length: ${payloadLen}`);
16
+ }
17
+ const buf = Buffer.alloc(8);
18
+ buf.writeBigUInt64BE(BigInt(payloadLen), 0);
19
+ return buf;
20
+ }
12
21
  /**
13
22
  * Encode a buffer or array of buffers into a PNG image (ROX format).
14
23
  *
@@ -309,9 +318,8 @@ export async function encodeBinaryToPng(input, opts = {}) {
309
318
  if (opts.container === 'sound') {
310
319
  const nameBuf = opts.name ? Buffer.from(opts.name, 'utf8') : Buffer.alloc(0);
311
320
  const nameLen = nameBuf.length;
312
- const payloadLenBuf = Buffer.alloc(4);
313
- payloadLenBuf.writeUInt32BE(payloadTotalLen, 0);
314
- const version = 1;
321
+ const payloadLenBuf = writePayloadLength(payloadTotalLen);
322
+ const version = HEADER_VERSION_V2;
315
323
  let wavPayload = [
316
324
  PIXEL_MAGIC,
317
325
  Buffer.from([version]),
@@ -357,9 +365,8 @@ export async function encodeBinaryToPng(input, opts = {}) {
357
365
  {
358
366
  const nameBuf = opts.name ? Buffer.from(opts.name, 'utf8') : Buffer.alloc(0);
359
367
  const nameLen = nameBuf.length;
360
- const payloadLenBuf = Buffer.alloc(4);
361
- payloadLenBuf.writeUInt32BE(payloadTotalLen, 0);
362
- const version = 1;
368
+ const payloadLenBuf = writePayloadLength(payloadTotalLen);
369
+ const version = HEADER_VERSION_V2;
363
370
  let metaPixel = [
364
371
  Buffer.from([version]),
365
372
  Buffer.from([nameLen]),
package/native/encoder.rs CHANGED
@@ -1,8 +1,11 @@
1
1
  use anyhow::Result;
2
2
 
3
+ use crate::png_chunk_writer::{write_chunked_idat_bytes, write_png_chunk};
4
+
3
5
  const MAGIC: &[u8] = b"ROX1";
4
6
  const PIXEL_MAGIC: &[u8] = b"PXL1";
5
7
  const PNG_HEADER: &[u8] = &[137, 80, 78, 71, 13, 10, 26, 10];
8
+ const HEADER_VERSION_V2: u8 = 2;
6
9
 
7
10
  const MARKER_START: [(u8, u8, u8); 3] = [(255, 0, 0), (0, 255, 0), (0, 0, 255)];
8
11
  const MARKER_END: [(u8, u8, u8); 3] = [(0, 0, 255), (0, 255, 0), (255, 0, 0)];
@@ -204,12 +207,12 @@ fn build_flat_pixel_buffer(
204
207
  }
205
208
 
206
209
  fn build_meta_pixel_with_name_and_filelist(payload: &[u8], name: Option<&str>, file_list: Option<&str>) -> Result<Vec<u8>> {
207
- let version = 1u8;
210
+ let version = HEADER_VERSION_V2;
208
211
  let name_bytes = name.map(|n| n.as_bytes()).unwrap_or(&[]);
209
212
  let name_len = name_bytes.len().min(255) as u8;
210
- let payload_len_bytes = (payload.len() as u32).to_be_bytes();
213
+ let payload_len_bytes = (payload.len() as u64).to_be_bytes();
211
214
 
212
- let mut result = Vec::with_capacity(1 + 1 + name_len as usize + 4 + payload.len() + 256);
215
+ let mut result = Vec::with_capacity(1 + 1 + name_len as usize + 8 + payload.len() + 256);
213
216
  result.push(version);
214
217
  result.push(name_len);
215
218
 
@@ -245,33 +248,18 @@ fn build_png(width: usize, height: usize, idat_data: &[u8], file_list: Option<&s
245
248
  ihdr_data[11] = 0;
246
249
  ihdr_data[12] = 0;
247
250
 
248
- write_chunk(&mut png, b"IHDR", &ihdr_data)?;
249
- write_chunk(&mut png, b"IDAT", idat_data)?;
251
+ write_png_chunk(&mut png, b"IHDR", &ihdr_data)?;
252
+ write_chunked_idat_bytes(&mut png, idat_data)?;
250
253
 
251
254
  if let Some(file_list_json) = file_list {
252
- write_chunk(&mut png, b"rXFL", file_list_json.as_bytes())?;
255
+ write_png_chunk(&mut png, b"rXFL", file_list_json.as_bytes())?;
253
256
  }
254
257
 
255
- write_chunk(&mut png, b"IEND", &[])?;
258
+ write_png_chunk(&mut png, b"IEND", &[])?;
256
259
 
257
260
  Ok(png)
258
261
  }
259
262
 
260
- fn write_chunk(out: &mut Vec<u8>, chunk_type: &[u8; 4], data: &[u8]) -> Result<()> {
261
- let len = data.len() as u32;
262
- out.extend_from_slice(&len.to_be_bytes());
263
- out.extend_from_slice(chunk_type);
264
- out.extend_from_slice(data);
265
-
266
- let mut hasher = crc32fast::Hasher::new();
267
- hasher.update(chunk_type);
268
- hasher.update(data);
269
- let crc = hasher.finalize();
270
-
271
- out.extend_from_slice(&crc.to_be_bytes());
272
- Ok(())
273
- }
274
-
275
263
  fn create_raw_deflate_from_rows(flat: &[u8], row_bytes: usize, height: usize) -> Vec<u8> {
276
264
  let stride = row_bytes + 1;
277
265
  let scanlines_total = height * stride;
@@ -0,0 +1,43 @@
1
+ use std::fs::File;
2
+ #[cfg(target_os = "linux")]
3
+ use std::os::fd::AsRawFd;
4
+
5
+ pub const INPUT_DROP_GRANULARITY: u64 = 8 * 1024 * 1024;
6
+
7
+ pub fn advise_file_sequential(file: &File) {
8
+ #[cfg(target_os = "linux")]
9
+ unsafe {
10
+ let _ = libc::posix_fadvise(file.as_raw_fd(), 0, 0, libc::POSIX_FADV_SEQUENTIAL);
11
+ }
12
+
13
+ #[cfg(not(target_os = "linux"))]
14
+ let _ = file;
15
+ }
16
+
17
+ pub fn advise_drop(file: &File, offset: u64, len: u64) {
18
+ if len == 0 {
19
+ return;
20
+ }
21
+
22
+ #[cfg(target_os = "linux")]
23
+ unsafe {
24
+ let _ = libc::posix_fadvise(
25
+ file.as_raw_fd(),
26
+ offset as libc::off_t,
27
+ len as libc::off_t,
28
+ libc::POSIX_FADV_DONTNEED,
29
+ );
30
+ }
31
+
32
+ #[cfg(not(target_os = "linux"))]
33
+ let _ = (file, offset, len);
34
+ }
35
+
36
+ pub fn sync_and_drop(file: &File, len: u64) {
37
+ if len == 0 {
38
+ return;
39
+ }
40
+
41
+ let _ = file.sync_data();
42
+ advise_drop(file, 0, len);
43
+ }
package/native/lib.rs CHANGED
@@ -17,6 +17,8 @@ mod encoder;
17
17
  mod packer;
18
18
  mod crypto;
19
19
  mod png_utils;
20
+ mod png_chunk_writer;
21
+ mod io_advice;
20
22
  mod image_utils;
21
23
  mod audio;
22
24
  mod progress;
package/native/main.rs CHANGED
@@ -11,6 +11,8 @@ mod encoder;
11
11
  mod packer;
12
12
  mod crypto;
13
13
  mod png_utils;
14
+ mod png_chunk_writer;
15
+ mod io_advice;
14
16
  mod audio;
15
17
  mod reconstitution;
16
18
  mod archive;
@@ -255,32 +257,39 @@ fn parse_requested_files(files: &str) -> anyhow::Result<Vec<String>> {
255
257
  }
256
258
  Commands::List { input } => {
257
259
  let mut file = File::open(&input)?;
258
- let chunks = png_utils::extract_png_chunks_streaming(&mut file).map_err(|e| anyhow::anyhow!(e))?;
260
+ let mut chunk_scan_error: Option<anyhow::Error> = None;
259
261
 
260
- if let Some(rxfl_chunk) = chunks.iter().find(|c| c.name == "rXFL") {
261
- println!("{}", String::from_utf8_lossy(&rxfl_chunk.data));
262
- return Ok(());
263
- }
262
+ match png_utils::extract_png_chunks_streaming(&mut file) {
263
+ Ok(chunks) => {
264
+ if let Some(rxfl_chunk) = chunks.iter().find(|c| c.name == "rXFL") {
265
+ println!("{}", String::from_utf8_lossy(&rxfl_chunk.data));
266
+ return Ok(());
267
+ }
264
268
 
265
- if let Some(meta_chunk) = chunks.iter().find(|c| c.name == "rOXm") {
266
- if let Some(pos) = meta_chunk.data.windows(4).position(|w| w == b"rXFL") {
267
- if pos + 8 <= meta_chunk.data.len() {
268
- let json_len = u32::from_be_bytes([
269
- meta_chunk.data[pos + 4],
270
- meta_chunk.data[pos + 5],
271
- meta_chunk.data[pos + 6],
272
- meta_chunk.data[pos + 7],
273
- ]) as usize;
274
-
275
- let json_start = pos + 8;
276
- let json_end = json_start + json_len;
277
-
278
- if json_end <= meta_chunk.data.len() {
279
- println!("{}", String::from_utf8_lossy(&meta_chunk.data[json_start..json_end]));
280
- return Ok(());
269
+ if let Some(meta_chunk) = chunks.iter().find(|c| c.name == "rOXm") {
270
+ if let Some(pos) = meta_chunk.data.windows(4).position(|w| w == b"rXFL") {
271
+ if pos + 8 <= meta_chunk.data.len() {
272
+ let json_len = u32::from_be_bytes([
273
+ meta_chunk.data[pos + 4],
274
+ meta_chunk.data[pos + 5],
275
+ meta_chunk.data[pos + 6],
276
+ meta_chunk.data[pos + 7],
277
+ ]) as usize;
278
+
279
+ let json_start = pos + 8;
280
+ let json_end = json_start + json_len;
281
+
282
+ if json_end <= meta_chunk.data.len() {
283
+ println!("{}", String::from_utf8_lossy(&meta_chunk.data[json_start..json_end]));
284
+ return Ok(());
285
+ }
286
+ }
281
287
  }
282
288
  }
283
289
  }
290
+ Err(err) => {
291
+ chunk_scan_error = Some(anyhow::anyhow!(err));
292
+ }
284
293
  }
285
294
 
286
295
  let png_data = std::fs::read(&input)?;
@@ -289,11 +298,14 @@ fn parse_requested_files(files: &str) -> anyhow::Result<Vec<String>> {
289
298
  println!("{}", json);
290
299
  return Ok(());
291
300
  }
292
- Err(_) => {}
301
+ Err(pixel_err) => {
302
+ if let Some(chunk_err) = chunk_scan_error {
303
+ return Err(anyhow::anyhow!("chunk scan: {}; pixel scan: {}", chunk_err, pixel_err));
304
+ }
305
+ }
293
306
  }
294
307
 
295
- eprintln!("No file list found in PNG");
296
- std::process::exit(1);
308
+ return Err(anyhow::anyhow!("No file list found in PNG"));
297
309
  }
298
310
  Commands::Havepassphrase { input } => {
299
311
  let buf = read_all(&input)?;
@@ -377,7 +389,7 @@ fn parse_requested_files(files: &str) -> anyhow::Result<Vec<String>> {
377
389
  return Ok(());
378
390
  }
379
391
  Err(e) => {
380
- eprintln!("Streaming decode failed ({}), falling back to in-memory", e);
392
+ return Err(anyhow::anyhow!("Streaming decode failed: {}", e));
381
393
  }
382
394
  }
383
395
  }