(function(global){ 'use strict'; const ARCHIVE_FORMAT='bend-field-save'; const ARCHIVE_VERSION=2; const MAX_ARCHIVE_FILE_BYTES=1024*1024*1024; const MAX_ARCHIVE_RAW_BYTES=2*1024*1024*1024; const MAX_ARCHIVE_LINE_BYTES=8*1024*1024; const MAX_ARCHIVE_EXPANSION_RATIO=100; const MAX_BLOB_BYTES=64*1024*1024; const PROGRESS_INTERVAL=250; const WORKER_TARGET_BYTES=1024*1024; const MAX_WORKER_RECORDS=32; const ArchiveCodec=global.BendArchiveCodec||(typeof module==='object'&&module.exports?require('./archive-codec'):null); if(!ArchiveCodec)throw new Error('BendArchiveCodec is not loaded'); const{encodeUtf8,encodeRecord,crc32Update,crc32Hex,parseRecordLine}=ArchiveCodec; function abortError(){return new DOMException('The operation was canceled.','AbortError')} function throwIfAborted(signal){if(signal?.aborted)throw signal.reason||abortError()} function archiveError(message,code='INVALID_ARCHIVE'){const error=new Error(message);error.code=code;return error} function validateManifestRecord(record,compression){ if(record?.type!=='manifest'||record.format!==ARCHIVE_FORMAT||record.archiveVersion!==ARCHIVE_VERSION)throw archiveError('This is not a supported Bend Field archive.','UNSUPPORTED_ARCHIVE'); if(record.compression!==compression)throw archiveError('The archive compression declaration does not match its contents.'); if(record.encoding!=='ndjson')throw archiveError('The archive encoding is not supported.','UNSUPPORTED_ARCHIVE'); if(!Number.isSafeInteger(record.boardCount)||record.boardCount<1)throw archiveError('The archive board count is invalid.'); if(!Number.isSafeInteger(record.estimatedRawBytes)||record.estimatedRawBytes<0||record.estimatedRawBytes>MAX_ARCHIVE_RAW_BYTES)throw archiveError('The archive size declaration is invalid.','ARCHIVE_TOO_LARGE'); return record; } function boardNumber(id){return/^B(?:0|[1-9]\d*)$/.test(id)?Number(id.slice(1)):-1} function triggerDownload(file,name){ const url=URL.createObjectURL(file),link=document.createElement('a'); link.href=url;link.download=name;link.style.display='none';document.body.append(link);link.click();link.remove(); setTimeout(()=>URL.revokeObjectURL(url),1000); } function progressReporter(callback,base={}){ let last=0; return(patch={},force=false)=>{ if(typeof callback!=='function')return; const now=performance.now(); if(!force&&now-last{const message=event.data||{},entry=pending.get(message.id);if(!entry)return;pending.delete(message.id);if(message.error)entry.reject(archiveError(message.error,'ARCHIVE_WORKER'));else entry.resolve(message)}; worker.onerror=event=>{const error=archiveError(event?.message||'The archive worker failed.','ARCHIVE_WORKER');for(const entry of pending.values())entry.reject(error);pending.clear()}; const request=(op,payload={})=>new Promise((resolve,reject)=>{const id=++nextId;pending.set(id,{resolve,reject});try{worker.postMessage({id,op,...payload})}catch(error){pending.delete(id);reject(error)}}); return{encode:(records,includeInCrc=true)=>request('encode',{records,includeInCrc}),parse:lines=>request('parse',{lines}),snapshot:()=>request('snapshot'),terminate(){for(const entry of pending.values())entry.reject(archiveError('The archive worker was terminated.','ARCHIVE_WORKER'));pending.clear();worker.terminate()}}; } async function createArchiveDestination(name,estimatedBytes=0){ if(typeof global.showSaveFilePicker==='function'){ const handle=await global.showSaveFilePicker({ suggestedName:name, types:[{description:'Bend Field save',accept:{'application/x-bend-field-save':['.bfsave']}}] }); const writable=await handle.createWritable(); return{ kind:'file', writable, async finish(){}, async abort(reason){try{await writable.abort(reason)}catch(_){}} }; } if(global.navigator?.storage?.getDirectory){ const root=await global.navigator.storage.getDirectory(),temporaryName=`.${name}.${Date.now()}.partial`, handle=await root.getFileHandle(temporaryName,{create:true}),writable=await handle.createWritable(); return{ kind:'opfs', writable, async finish(){ const file=await handle.getFile();triggerDownload(file,name); try{await root.removeEntry(temporaryName)}catch(_){} }, async abort(reason){ try{await writable.abort(reason)}catch(_){} try{await root.removeEntry(temporaryName)}catch(_){} } }; } if(estimatedBytes>MAX_BLOB_BYTES)throw archiveError('This browser cannot stream a save file of this size.','UNSUPPORTED_OUTPUT'); const chunks=[];let size=0,closed=false; const writable=new WritableStream({ write(chunk){ const bytes=chunk instanceof Uint8Array?chunk:new Uint8Array(chunk); size+=bytes.byteLength;if(size>MAX_BLOB_BYTES)throw archiveError('The save exceeded the safe in-memory download limit.','OUTPUT_TOO_LARGE'); chunks.push(bytes.slice()); }, close(){closed=true}, abort(){chunks.length=0;size=0} }); return{ kind:'blob', writable, async finish(){ if(!closed)throw archiveError('The save stream did not close.','OUTPUT_INCOMPLETE'); triggerDownload(new Blob(chunks,{type:'application/x-bend-field-save'}),name); }, async abort(){chunks.length=0;size=0} }; } function outputPipeline(writable,useGzip,onBytes){ const counter=new TransformStream({transform(chunk,controller){onBytes(chunk.byteLength);controller.enqueue(chunk)}}); if(useGzip){ const gzip=new CompressionStream('gzip'),pipe=gzip.readable.pipeThrough(counter).pipeTo(writable); return{writer:gzip.writable.getWriter(),pipe}; } const pass=new TransformStream(),pipe=pass.readable.pipeThrough(counter).pipeTo(writable); return{writer:pass.writable.getWriter(),pipe}; } async function writeArchive({writable,manifest,globalState,boards,signal,onProgress}){ throwIfAborted(signal); const useGzip=manifest.compression==='gzip'; if(useGzip&&typeof CompressionStream!=='function')throw archiveError('Gzip compression is not available.','UNSUPPORTED_COMPRESSION'); let bytesWritten=0,rawBytes=0,crc=0xffffffff,crcHex='00000000',boardsDone=0,writer=null,pipe=null; const report=progressReporter(onProgress,{boardsTotal:manifest.boardCount||0}),codec=createArchiveCodec(); try{ ({writer,pipe}=outputPipeline(writable,useGzip,amount=>{bytesWritten+=amount})); const writeRecords=async(records,includeInCrc=true)=>{ throwIfAborted(signal);if(!records.length)return; if(codec){const result=await codec.encode(records,includeInCrc);rawBytes=result.rawBytes;crcHex=result.crc32;for(const buffer of result.chunks){throwIfAborted(signal);await writer.write(new Uint8Array(buffer))}return} for(const record of records){const bytes=encodeRecord(record);if(includeInCrc){crc=crc32Update(crc,bytes);rawBytes+=bytes.byteLength;crcHex=crc32Hex(crc)}await writer.write(bytes)} }; report({phase:'prepare',boardsDone,bytesRead:rawBytes,bytesWritten},true); await writeRecords([{type:'manifest',format:ARCHIVE_FORMAT,archiveVersion:ARCHIVE_VERSION,...manifest},{type:'global',value:globalState}]); const estimatedBoardBytes=Math.max(1024,Math.ceil((Number(manifest.estimatedRawBytes)||0)/Math.max(1,manifest.boardCount||1))),batchLimit=Math.max(1,Math.min(MAX_WORKER_RECORDS,Math.floor(WORKER_TARGET_BYTES/estimatedBoardBytes)||1));let batch=[]; for await(const board of boards){throwIfAborted(signal);batch.push({type:'board',...board});boardsDone++;if(batch.length>=batchLimit){await writeRecords(batch);batch=[];report({phase:'write',boardsDone,bytesRead:rawBytes,bytesWritten})}} await writeRecords(batch); if(boardsDone!==manifest.boardCount)throw archiveError('The exported board count changed during export.','FIELD_CHANGED'); if(codec){const state=await codec.snapshot();rawBytes=state.rawBytes;crcHex=state.crc32}else crcHex=crc32Hex(crc); await writeRecords([{type:'end',boardCount:boardsDone,rawBytes,crc32:crcHex}],false); await writer.close();await pipe;report({phase:'write',boardsDone,bytesRead:rawBytes,bytesWritten},true); return{boardCount:boardsDone,rawBytes,bytesWritten,crc32:crcHex,compression:manifest.compression,worker:codec!=null}; }catch(error){try{await writer?.abort(error)}catch(_){}try{await pipe}catch(_){}throw error} finally{codec?.terminate()} } function decodedStream(file){ const source=file.stream(); return file.slice(0,2).arrayBuffer().then(buffer=>{ const bytes=new Uint8Array(buffer),gzip=bytes[0]===0x1f&&bytes[1]===0x8b; if(gzip){ if(typeof DecompressionStream!=='function')throw archiveError('This browser cannot read gzip save files.','UNSUPPORTED_COMPRESSION'); return{stream:source.pipeThrough(new DecompressionStream('gzip')),compression:'gzip'}; } return{stream:source,compression:'identity'}; }); } async function firstArchiveRecord(file,signal){ if(!(file instanceof Blob))throw archiveError('No save file was selected.'); if(file.size<=0||file.size>MAX_ARCHIVE_FILE_BYTES)throw archiveError('The save file size is outside the supported range.','ARCHIVE_TOO_LARGE'); const decoded=await decodedStream(file),reader=decoded.stream.getReader(),decoder=new TextDecoder(); let text='',decodedBytes=0; try{ while(true){ throwIfAborted(signal); const{value,done}=await reader.read();if(done)break; decodedBytes+=value.byteLength;if(decodedBytes>MAX_ARCHIVE_LINE_BYTES)throw archiveError('The archive manifest is too large.','LINE_TOO_LARGE'); text+=decoder.decode(value,{stream:true});const newline=text.indexOf('\n'); if(newline>=0){ const line=text.slice(0,newline).replace(/\r$/,'');let record; try{record=JSON.parse(line)}catch(_){throw archiveError('The archive manifest is not valid JSON.')} validateManifestRecord(record,decoded.compression);return{record,compression:decoded.compression}; } } throw archiveError('The archive manifest is incomplete.'); }finally{try{await reader.cancel()}catch(_){}} } async function inspectArchive(file,{signal}={}){ const{record}=await firstArchiveRecord(file,signal); return{ archiveVersion:record.archiveVersion, saveSchema:record.saveSchema, gameplayVersion:record.gameplayVersion, worldGeneration:record.worldGeneration, appVersion:record.appVersion, generatorVersion:record.generatorVersion, exportedAt:record.exportedAt, boardCount:record.boardCount, estimatedRawBytes:record.estimatedRawBytes, compression:record.compression }; } async function readArchive(file,{signal,onProgress,onManifest,onGlobal,onBoard}={}){ if(!(file instanceof Blob))throw archiveError('No save file was selected.'); if(file.size<=0||file.size>MAX_ARCHIVE_FILE_BYTES)throw archiveError('The save file size is outside the supported range.','ARCHIVE_TOO_LARGE'); const decoded=await decodedStream(file),reader=decoded.stream.getReader(),decoder=new TextDecoder(),report=progressReporter(onProgress),codec=createArchiveCodec(); let buffer='',decodedBytes=0,rawBytes=0,crc=0xffffffff,crcHex='00000000',phase='manifest',manifest=null,globalState=null,boardsDone=0,previousBoardNumber=-1,footer=null,pendingLines=[],pendingLineBytes=0; const processRecord=async(record,state)=>{ throwIfAborted(signal);rawBytes=state.rawBytes;crcHex=state.crc32; if(phase==='manifest'){ validateManifestRecord(record,decoded.compression);manifest=record;phase='global';await onManifest?.(record);return; } if(phase==='global'){ if(record?.type!=='global'||!record.value||typeof record.value!=='object')throw archiveError('The archive global record is missing or invalid.'); globalState=record.value;phase='boards';await onGlobal?.(record.value);return; } if(phase!=='boards')throw archiveError('The archive contains records after its footer.'); if(record?.type==='board'){ const number=boardNumber(record.id);if(number<0||number<=previousBoardNumber)throw archiveError('Archive boards are duplicated or not in numeric order.'); if(record.id==='B0'&&boardsDone!==0)throw archiveError('B0 must be the first board.');if(boardsDone===0&&record.id!=='B0')throw archiveError('The archive does not begin with B0.');if(boardsDone>=manifest.boardCount)throw archiveError('The archive contains more boards than declared.'); previousBoardNumber=number;await onBoard?.(record,boardsDone);boardsDone++;report({phase:'validate',boardsDone,boardsTotal:manifest.boardCount,bytesRead:decodedBytes,bytesWritten:0});return; } if(record?.type==='end'){ footer=record;phase='done';if(boardsDone!==manifest.boardCount||record.boardCount!==boardsDone)throw archiveError('The archive board count does not match its footer.');if(record.rawBytes!==state.rawBytes||String(record.crc32).toLowerCase()!==state.crc32)throw archiveError('The archive integrity check failed.','CHECKSUM_MISMATCH');return; } throw archiveError('The archive contains an unknown or misplaced record.'); }; const flushLines=async()=>{ if(!pendingLines.length)return;const lines=pendingLines;pendingLines=[];pendingLineBytes=0; if(codec){const result=await codec.parse(lines);for(const item of result.items){if(item.byteLength>MAX_ARCHIVE_LINE_BYTES)throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');await processRecord(item.record,item)}return} for(const sourceLine of lines){let parsed;try{parsed=parseRecordLine(sourceLine)}catch(error){throw archiveError(error.message)}const{line,record,bytes:lineBytes}=parsed;if(!line)throw archiveError('The archive contains an empty record.');if(lineBytes.byteLength>MAX_ARCHIVE_LINE_BYTES)throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');if(record?.type!=='end'){crc=crc32Update(crc,lineBytes);rawBytes+=lineBytes.byteLength;crcHex=crc32Hex(crc)}await processRecord(record,{rawBytes,crc32:crcHex})} }; try{ while(true){throwIfAborted(signal);const{value,done}=await reader.read();if(done)break;decodedBytes+=value.byteLength;if(decodedBytes>MAX_ARCHIVE_RAW_BYTES)throw archiveError('The expanded archive exceeded the size limit.','ARCHIVE_TOO_LARGE');if(decoded.compression==='gzip'&&decodedBytes/Math.max(1,file.size)>MAX_ARCHIVE_EXPANSION_RATIO)throw archiveError('The archive expansion ratio is unsafe.','EXPANSION_LIMIT');buffer+=decoder.decode(value,{stream:true});if(encodeUtf8(buffer).byteLength>MAX_ARCHIVE_LINE_BYTES&&!buffer.includes('\n'))throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');let newline;while((newline=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,newline);buffer=buffer.slice(newline+1);if(!line)throw archiveError('The archive contains an empty record.');const estimatedBytes=line.length*3+1;if(pendingLines.length&&(pendingLines.length>=MAX_WORKER_RECORDS||pendingLineBytes+estimatedBytes>WORKER_TARGET_BYTES))await flushLines();pendingLines.push(line);pendingLineBytes+=estimatedBytes;if(pendingLines.length>=MAX_WORKER_RECORDS||pendingLineBytes>=WORKER_TARGET_BYTES)await flushLines()}} buffer+=decoder.decode();if(buffer){const estimatedBytes=buffer.length*3+1;if(pendingLines.length&&pendingLineBytes+estimatedBytes>WORKER_TARGET_BYTES)await flushLines();pendingLines.push(buffer);pendingLineBytes+=estimatedBytes}await flushLines();if(phase!=='done'||!footer)throw archiveError('The archive footer is missing or incomplete.');report({phase:'validate',boardsDone,boardsTotal:manifest.boardCount,bytesRead:decodedBytes,bytesWritten:0},true);return{manifest,globalState,footer,boardCount:boardsDone,decodedBytes,compression:decoded.compression,worker:codec!=null}; }finally{try{reader.releaseLock()}catch(_){}codec?.terminate()} } async function cleanupTemporaryExports({olderThanMs=24*60*60*1000}={}){ if(!global.navigator?.storage?.getDirectory)return 0;let removed=0;const root=await global.navigator.storage.getDirectory(),cutoff=Date.now()-olderThanMs; for await(const[name]of root.entries()){ const match=/^\.bend-field-save-.*\.(\d+)\.partial$/.exec(name);if(!match||Number(match[1])>cutoff)continue; try{await root.removeEntry(name);removed++}catch(_){} } return removed; } global.BendFieldPersistence=Object.freeze({ ARCHIVE_FORMAT,ARCHIVE_VERSION,MAX_ARCHIVE_FILE_BYTES,MAX_ARCHIVE_RAW_BYTES,MAX_ARCHIVE_LINE_BYTES, MAX_ARCHIVE_EXPANSION_RATIO,MAX_BLOB_BYTES,crc32Update,crc32Hex,createArchiveDestination, inspectArchive,writeArchive,readArchive,cleanupTemporaryExports }); })(globalThis);