From 19d920704142c6eebfecc8874065e32b9f567388 Mon Sep 17 00:00:00 2001 From: omega-fallon Date: Mon, 6 Apr 2026 18:40:36 -0400 Subject: [PATCH 1/5] Initial commit: support from BRARCHIVE to ZIP & json --- src/handlers/brarchive.ts | 118 ++++++++++++++++++++++++++++++++++++++ src/handlers/index.ts | 2 + 2 files changed, 120 insertions(+) create mode 100644 src/handlers/brarchive.ts diff --git a/src/handlers/brarchive.ts b/src/handlers/brarchive.ts new file mode 100644 index 00000000..09372d18 --- /dev/null +++ b/src/handlers/brarchive.ts @@ -0,0 +1,118 @@ +// file: brarchive.ts + +import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; +import CommonFormats, { Category } from "src/CommonFormats.ts"; +import JSZip from "jszip"; + +function read_lendian_4(a: number, b: number, c: number, d: number): number { + return a + (b * Math.pow(16,2)) + (c * Math.pow(16,4)) + (d * Math.pow(16,6)); +} + +class BRARCHIVEHandler implements FormatHandler { + public name: string = "brarchive"; + public supportedFormats?: FileFormat[]; + public ready: boolean = false; + + async init () { + this.supportedFormats = [ + CommonFormats.ZIP.supported("zip", false, true, true), + CommonFormats.JSON.supported("json", false, true, true), + { + name: "BRARCHIVE - Minecraft Bedrock Archive", + format: "brarchive", + extension: "brarchive", + mime: "image/x-brarchive", + from: true, + to: false, + internal: "brarchive", + category: Category.DATA, + lossless: false + }, + ]; + + this.ready = true; + } + + async doConvert ( + inputFiles: FileData[], + inputFormat: FileFormat, + outputFormat: FileFormat + ): Promise { + const outputFiles: FileData[] = []; + + if (inputFormat.internal === "brarchive" && (outputFormat.internal === "zip" || outputFormat.internal === "json")) { + // Thanks to @santiago046's documentation + + const file_entry_size = 1 + 247 + 4 + 4; // the size of a single FileEntry + + for (const file of inputFiles) { + const decoder = new TextDecoder(); + const numEntries = read_lendian_4(file.bytes[0x08],file.bytes[0x09],file.bytes[0x0A],file.bytes[0x0B]); + + // FileEntries begin at 0x10. Read them and store to an array we can look up later. + let byte_cursor = 0x10; + const FileEntries_info = []; + for (let i = 0; i < numEntries; i++) { + const file_name_length = file.bytes[byte_cursor]; + byte_cursor++; + const file_name = decoder.decode(file.bytes.subarray(byte_cursor,byte_cursor+file_name_length)); + byte_cursor += 247; // Names are stored as padded 247-length strings?? Weird but seems to be true. + const relative_offset = read_lendian_4(file.bytes[byte_cursor],file.bytes[byte_cursor+1],file.bytes[byte_cursor+2],file.bytes[byte_cursor+3]); + const absolute_offset = relative_offset + 16 + file_entry_size*numEntries; + byte_cursor += 4; + const data_size = read_lendian_4(file.bytes[byte_cursor],file.bytes[byte_cursor+1],file.bytes[byte_cursor+2],file.bytes[byte_cursor+3]); + + if (data_size === 0) { + throw new Error("Error, file has no data. Can't meaningfully convert."); + } + + // Finally, store that information in the array + FileEntries_info.push([file_name,absolute_offset,data_size]); + + // Jump byte_cursor one more time so we start the next loop at the next entry. + byte_cursor += 4; + } + + // We now have the information we need. Begin pushing the files to a FileData array. + const archiveFiles: FileData[] = []; + + for (let i = 0; i < FileEntries_info.length; i++) { + archiveFiles.push({ + name: FileEntries_info[i][0], + bytes: file.bytes.subarray(FileEntries_info[i][1],FileEntries_info[i][1]+FileEntries_info[i][2]), + }); + } + + if (outputFormat.internal === "zip") { + // And now, we simply zip the files! + const zip = new JSZip(); + for (const ar_file of archiveFiles) { + zip.file(ar_file.name, ar_file.bytes); + } + + // Finally, output the zip. + const output = await zip.generateAsync({ type: "uint8array" }); + outputFiles.push({ bytes: output, name: file.name.split(".").slice(0, -1).join(".") + "." + outputFormat.extension }); + } + else if (outputFormat.internal === "json") { + // First, validate that everything in the archive is in fact a .json file. + for (const ar_file of archiveFiles) { + if (ar_file.name.endsWith(".json")) { + throw new Error("Error, brarchive doesn't consist solely of .json files, so we can't convert straight to that."); + } + } + + // Then we just add the files to the output. + outputFiles.push(...archiveFiles); + } + } + } + else { + throw new Error("Invalid input-output"); + } + + return outputFiles; + } +} + +export default BRARCHIVEHandler; \ No newline at end of file diff --git a/src/handlers/index.ts b/src/handlers/index.ts index 0c6fae4b..580af808 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -72,6 +72,7 @@ import xcursorHandler from "./xcursor.ts"; import shToElfHandler from "./shToElf.ts"; import cssHandler from "./css.ts"; import TypstHandler from "./typst.ts"; +import BRARCHIVEHandler from "./brarchive.ts"; const handlers: FormatHandler[] = []; try { handlers.push(new svgTraceHandler()) } catch (_) { }; @@ -150,5 +151,6 @@ try { handlers.push(new xcursorHandler()) } catch (_) { }; try { handlers.push(new shToElfHandler()) } catch (_) { }; try { handlers.push(new cssHandler()) } catch (_) { }; try { handlers.push(new TypstHandler()) } catch (_) { }; +try { handlers.push(new BRARCHIVEHandler()) } catch (_) { }; export default handlers; From acbc3969b48a91cf191975b25efd2b7a2cf5d9ba Mon Sep 17 00:00:00 2001 From: omega-fallon Date: Mon, 6 Apr 2026 18:43:39 -0400 Subject: [PATCH 2/5] Safeguard for sevenZip --- src/handlers/brarchive.ts | 2 +- src/handlers/sevenZip.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/handlers/brarchive.ts b/src/handlers/brarchive.ts index 09372d18..d7f86dd5 100644 --- a/src/handlers/brarchive.ts +++ b/src/handlers/brarchive.ts @@ -25,7 +25,7 @@ class BRARCHIVEHandler implements FormatHandler { from: true, to: false, internal: "brarchive", - category: Category.DATA, + category: Category.ARCHIVE, lossless: false }, ]; diff --git a/src/handlers/sevenZip.ts b/src/handlers/sevenZip.ts index 359b73f8..7865d975 100644 --- a/src/handlers/sevenZip.ts +++ b/src/handlers/sevenZip.ts @@ -95,6 +95,10 @@ class sevenZipHandler implements FormatHandler { ): Promise { const outputFiles: FileData[] = []; + if (inputFormat.internal === "brarchive") { + throw new Error(`sevenZipHandler cannot convert from ${inputFormat.mime}`); + } + if (!this.supportedFormats.some(format => format.to && format.internal === outputFormat.internal)) { throw new Error(`sevenZipHandler cannot convert to ${outputFormat.mime}`); } From 9aec3a39b2163c1c542cfce05e78830a0836bc60 Mon Sep 17 00:00:00 2001 From: omega-fallon Date: Mon, 6 Apr 2026 19:42:33 -0400 Subject: [PATCH 3/5] Support for lossless round trip! --- src/handlers/brarchive.ts | 127 +++++++++++++++++++++++++++++++++++--- 1 file changed, 117 insertions(+), 10 deletions(-) diff --git a/src/handlers/brarchive.ts b/src/handlers/brarchive.ts index d7f86dd5..5dc9d96d 100644 --- a/src/handlers/brarchive.ts +++ b/src/handlers/brarchive.ts @@ -8,6 +8,27 @@ function read_lendian_4(a: number, b: number, c: number, d: number): number { return a + (b * Math.pow(16,2)) + (c * Math.pow(16,4)) + (d * Math.pow(16,6)); } +function write_lendian_4(x: number): number[] { + if (x > 0xFFFFFFFF) { + throw new Error("Error in write_lendian_4: number too big."); + } + if (x < 0x00) { + throw new Error("Error in write_lendian_4: number is negative."); + } + + let num_string = x.toString(16); + + while (num_string.length < 8) { + num_string = "0"+num_string; + } + + console.log("write_lendian_4: "+"("+x+")"+" ("+num_string+")"); + + const array : number[] = [parseInt(num_string.substring(6,8),16),parseInt(num_string.substring(4,6),16),parseInt(num_string.substring(2,4),16),parseInt(num_string.substring(0,2),16)]; + console.log("write_lendian_4: "+array); + return array +} + class BRARCHIVEHandler implements FormatHandler { public name: string = "brarchive"; public supportedFormats?: FileFormat[]; @@ -15,18 +36,18 @@ class BRARCHIVEHandler implements FormatHandler { async init () { this.supportedFormats = [ - CommonFormats.ZIP.supported("zip", false, true, true), - CommonFormats.JSON.supported("json", false, true, true), + CommonFormats.ZIP.supported("zip", true, true, true), + CommonFormats.JSON.supported("json", true, true, true), { - name: "BRARCHIVE - Minecraft Bedrock Archive", + name: "Minecraft Bedrock Archive", format: "brarchive", extension: "brarchive", mime: "image/x-brarchive", from: true, - to: false, + to: true, internal: "brarchive", category: Category.ARCHIVE, - lossless: false + lossless: true, }, ]; @@ -47,7 +68,7 @@ class BRARCHIVEHandler implements FormatHandler { for (const file of inputFiles) { const decoder = new TextDecoder(); - const numEntries = read_lendian_4(file.bytes[0x08],file.bytes[0x09],file.bytes[0x0A],file.bytes[0x0B]); + const numEntries : number = read_lendian_4(file.bytes[0x08],file.bytes[0x09],file.bytes[0x0A],file.bytes[0x0B]); // FileEntries begin at 0x10. Read them and store to an array we can look up later. let byte_cursor = 0x10; @@ -57,8 +78,8 @@ class BRARCHIVEHandler implements FormatHandler { byte_cursor++; const file_name = decoder.decode(file.bytes.subarray(byte_cursor,byte_cursor+file_name_length)); byte_cursor += 247; // Names are stored as padded 247-length strings?? Weird but seems to be true. - const relative_offset = read_lendian_4(file.bytes[byte_cursor],file.bytes[byte_cursor+1],file.bytes[byte_cursor+2],file.bytes[byte_cursor+3]); - const absolute_offset = relative_offset + 16 + file_entry_size*numEntries; + const relative_offset : number = read_lendian_4(file.bytes[byte_cursor],file.bytes[byte_cursor+1],file.bytes[byte_cursor+2],file.bytes[byte_cursor+3]); + const absolute_offset : number = relative_offset + 16 + file_entry_size*numEntries; byte_cursor += 4; const data_size = read_lendian_4(file.bytes[byte_cursor],file.bytes[byte_cursor+1],file.bytes[byte_cursor+2],file.bytes[byte_cursor+3]); @@ -97,8 +118,8 @@ class BRARCHIVEHandler implements FormatHandler { else if (outputFormat.internal === "json") { // First, validate that everything in the archive is in fact a .json file. for (const ar_file of archiveFiles) { - if (ar_file.name.endsWith(".json")) { - throw new Error("Error, brarchive doesn't consist solely of .json files, so we can't convert straight to that."); + if (!ar_file.name.endsWith(".json")) { + throw new Error("Error, brarchive doesn't consist solely of .json files, so we can't convert straight to that. "+ar_file.name); } } @@ -107,6 +128,92 @@ class BRARCHIVEHandler implements FormatHandler { } } } + else if ((inputFormat.internal === "json" || inputFormat.internal === "zip") && outputFormat.internal === "brarchive") { + const working_files : FileData[] = []; + + // Special handling for zip input. + if (inputFormat.internal === "zip") { + for (const file of inputFiles) { + const zip = new JSZip(); + await zip.loadAsync(file.bytes); + + // Extract all files from ZIP + for (const [filename, zipEntry] of Object.entries(zip.files)) { + if (!zipEntry.dir) { + if (filename.endsWith(".json") === false) { + throw new Error("Archive contains more than just .json files; abort."); + } + else { + const data = await zipEntry.async("uint8array"); + working_files.push({ + name: filename, + bytes: data + }); + } + } + } + } + + // Throw error if empty + if (working_files.length === 0) { + throw new Error("No applicable files to unzip found."); + } + } + else { + working_files.push(...inputFiles); + } + + // Unlikely, but handle it. + if (working_files.length > 0xFFFFFFFF) { + throw new Error("Too many input files to encode in 4 bytes."); + } + + // Zip all the inputs into one archive. + const working_bytes : number[] = []; + + // Write headers and magic numbers. + working_bytes.push(0x7D, 0x27, 0x25, 0xB1); + working_bytes.push(0xA0, 0x52, 0x70, 0x26); + working_bytes.push(...write_lendian_4(working_files.length)); + working_bytes.push(0x01, 0x00, 0x00, 0x00); + + // Start writing FileEntry's + const encoder = new TextEncoder(); + for (let i = 0; i < working_files.length; i++) { + // Shorten name if need be + let name = working_files[i].name + while ((encoder.encode(name)).length > 247) { + name = name.substring(0,name.length-1); + } + + const name_bytes : Uint8Array = encoder.encode(name); + working_bytes.push(name_bytes.length); + + // Push name and padding + working_bytes.push(...name_bytes); + for (let pushed = name_bytes.length; pushed < 247; pushed++) { + working_bytes.push(0x00); + } + + // Relative offset is the sum of the file sizes of every file that comes before it. + let relative_offset = 0; + for (let i2 = i-1; i2 >= 0; i2--) { + relative_offset += working_files[i2].bytes.length; + } + working_bytes.push(...write_lendian_4(relative_offset)); + + // Then, the file size of *this* file. + working_bytes.push(...write_lendian_4(working_files[i].bytes.length)); + } + + // FileEntry's are done. Write raw data. + for (let i = 0; i < working_files.length; i++) { + working_bytes.push(...working_files[i].bytes); + } + + // Finally, push our file. + outputFiles.push({bytes: new Uint8Array(working_bytes), name: "Unnamed.brarchive"}); + } else { throw new Error("Invalid input-output"); } From c45baeed58d8193b7524dc3cab4012c1a047b4a9 Mon Sep 17 00:00:00 2001 From: omega-fallon Date: Mon, 6 Apr 2026 22:00:52 -0400 Subject: [PATCH 4/5] Type-casting shenanigans fixed. --- src/handlers/brarchive.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/handlers/brarchive.ts b/src/handlers/brarchive.ts index 5dc9d96d..80234ebb 100644 --- a/src/handlers/brarchive.ts +++ b/src/handlers/brarchive.ts @@ -16,7 +16,7 @@ function write_lendian_4(x: number): number[] { throw new Error("Error in write_lendian_4: number is negative."); } - let num_string = x.toString(16); + let num_string : string = x.toString(16); while (num_string.length < 8) { num_string = "0"+num_string; @@ -72,18 +72,18 @@ class BRARCHIVEHandler implements FormatHandler { // FileEntries begin at 0x10. Read them and store to an array we can look up later. let byte_cursor = 0x10; - const FileEntries_info = []; + const FileEntries_info : string[][] = []; for (let i = 0; i < numEntries; i++) { const file_name_length = file.bytes[byte_cursor]; byte_cursor++; - const file_name = decoder.decode(file.bytes.subarray(byte_cursor,byte_cursor+file_name_length)); + const file_name : string = decoder.decode(file.bytes.subarray(byte_cursor,byte_cursor+file_name_length)); byte_cursor += 247; // Names are stored as padded 247-length strings?? Weird but seems to be true. const relative_offset : number = read_lendian_4(file.bytes[byte_cursor],file.bytes[byte_cursor+1],file.bytes[byte_cursor+2],file.bytes[byte_cursor+3]); - const absolute_offset : number = relative_offset + 16 + file_entry_size*numEntries; + const absolute_offset : string = (relative_offset + 16 + file_entry_size*numEntries).toString(); byte_cursor += 4; - const data_size = read_lendian_4(file.bytes[byte_cursor],file.bytes[byte_cursor+1],file.bytes[byte_cursor+2],file.bytes[byte_cursor+3]); + const data_size : string = (read_lendian_4(file.bytes[byte_cursor],file.bytes[byte_cursor+1],file.bytes[byte_cursor+2],file.bytes[byte_cursor+3])).toString(); - if (data_size === 0) { + if (data_size === "0") { throw new Error("Error, file has no data. Can't meaningfully convert."); } @@ -100,7 +100,7 @@ class BRARCHIVEHandler implements FormatHandler { for (let i = 0; i < FileEntries_info.length; i++) { archiveFiles.push({ name: FileEntries_info[i][0], - bytes: file.bytes.subarray(FileEntries_info[i][1],FileEntries_info[i][1]+FileEntries_info[i][2]), + bytes: file.bytes.subarray(parseInt(FileEntries_info[i][1]),parseInt(FileEntries_info[i][1])+parseInt(FileEntries_info[i][2])), }); } From 1ebce2f615a99a62633cafb5fb08b89be391760b Mon Sep 17 00:00:00 2001 From: omega-fallon Date: Tue, 7 Apr 2026 14:09:58 -0400 Subject: [PATCH 5/5] Better behavior for zip -> brarchive. --- src/handlers/brarchive.ts | 108 ++++++++++++++++++++------------------ 1 file changed, 58 insertions(+), 50 deletions(-) diff --git a/src/handlers/brarchive.ts b/src/handlers/brarchive.ts index 80234ebb..27e769e7 100644 --- a/src/handlers/brarchive.ts +++ b/src/handlers/brarchive.ts @@ -129,7 +129,7 @@ class BRARCHIVEHandler implements FormatHandler { } } else if ((inputFormat.internal === "json" || inputFormat.internal === "zip") && outputFormat.internal === "brarchive") { - const working_files : FileData[] = []; + const working_files: { [key: string]: FileData[] } = {}; // Special handling for zip input. if (inputFormat.internal === "zip") { @@ -137,82 +137,90 @@ class BRARCHIVEHandler implements FormatHandler { const zip = new JSZip(); await zip.loadAsync(file.bytes); + let done_something_flag = false; + // Extract all files from ZIP + working_files[file.name] = []; for (const [filename, zipEntry] of Object.entries(zip.files)) { if (!zipEntry.dir) { if (filename.endsWith(".json") === false) { throw new Error("Archive contains more than just .json files; abort."); } else { + done_something_flag = true; const data = await zipEntry.async("uint8array"); - working_files.push({ + working_files[file.name].push({ name: filename, bytes: data }); } } } - } - - // Throw error if empty - if (working_files.length === 0) { - throw new Error("No applicable files to unzip found."); + + // Throw error if empty + if (!done_something_flag) { + throw new Error("No applicable files to unzip found in "+file.name); + } } } else { - working_files.push(...inputFiles); + working_files["Unnamed.brarchive"] = []; + working_files["Unnamed.brarchive"].push(...inputFiles); } - // Unlikely, but handle it. - if (working_files.length > 0xFFFFFFFF) { - throw new Error("Too many input files to encode in 4 bytes."); - } - - // Zip all the inputs into one archive. - const working_bytes : number[] = []; - - // Write headers and magic numbers. - working_bytes.push(0x7D, 0x27, 0x25, 0xB1); - working_bytes.push(0xA0, 0x52, 0x70, 0x26); - working_bytes.push(...write_lendian_4(working_files.length)); - working_bytes.push(0x01, 0x00, 0x00, 0x00); - - // Start writing FileEntry's - const encoder = new TextEncoder(); - for (let i = 0; i < working_files.length; i++) { - // Shorten name if need be - let name = working_files[i].name - while ((encoder.encode(name)).length > 247) { - name = name.substring(0,name.length-1); + // Iterate through each collection of files. + for (const key in working_files) { + // Unlikely, but handle it. + if (working_files[key].length > 0xFFFFFFFF) { + throw new Error("Too many input files to encode in 4 bytes."); } + + // Zip all the inputs into one archive. + const working_bytes : number[] = []; - const name_bytes : Uint8Array = encoder.encode(name); - working_bytes.push(name_bytes.length); + // Write headers and magic numbers. + working_bytes.push(0x7D, 0x27, 0x25, 0xB1); + working_bytes.push(0xA0, 0x52, 0x70, 0x26); + working_bytes.push(...write_lendian_4(working_files[key].length)); + working_bytes.push(0x01, 0x00, 0x00, 0x00); - // Push name and padding - working_bytes.push(...name_bytes); - for (let pushed = name_bytes.length; pushed < 247; pushed++) { - working_bytes.push(0x00); + // Start writing FileEntry's + const encoder = new TextEncoder(); + for (let i = 0; i < working_files[key].length; i++) { + // Shorten name if need be + let name = working_files[key][i].name + while ((encoder.encode(name)).length > 247) { + name = name.substring(0,name.length-1); + } + + const name_bytes : Uint8Array = encoder.encode(name); + working_bytes.push(name_bytes.length); + + // Push name and padding + working_bytes.push(...name_bytes); + for (let pushed = name_bytes.length; pushed < 247; pushed++) { + working_bytes.push(0x00); + } + + // Relative offset is the sum of the file sizes of every file that comes before it. + let relative_offset = 0; + for (let i2 = i-1; i2 >= 0; i2--) { + relative_offset += working_files[key][i2].bytes.length; + } + working_bytes.push(...write_lendian_4(relative_offset)); + + // Then, the file size of *this* file. + working_bytes.push(...write_lendian_4(working_files[key][i].bytes.length)); } - // Relative offset is the sum of the file sizes of every file that comes before it. - let relative_offset = 0; - for (let i2 = i-1; i2 >= 0; i2--) { - relative_offset += working_files[i2].bytes.length; + // FileEntry's are done. Write raw data. + for (let i = 0; i < working_files[key].length; i++) { + working_bytes.push(...working_files[key][i].bytes); } - working_bytes.push(...write_lendian_4(relative_offset)); - // Then, the file size of *this* file. - working_bytes.push(...write_lendian_4(working_files[i].bytes.length)); + // Finally, push our file. + outputFiles.push({bytes: new Uint8Array(working_bytes), name: key.split(".").slice(0, -1).join(".") + "." + outputFormat.extension}); } - - // FileEntry's are done. Write raw data. - for (let i = 0; i < working_files.length; i++) { - working_bytes.push(...working_files[i].bytes); - } - - // Finally, push our file. - outputFiles.push({bytes: new Uint8Array(working_bytes), name: "Unnamed.brarchive"}); } else { throw new Error("Invalid input-output");