mirror of
https://github.com/marcrobledo/savegame-editors.git
synced 2025-04-28 09:05:10 +00:00
Merge pull request #427 from aquacluck/totkmultifile
TOTK: Add savegame slot picker
This commit is contained in:
commit
0cd1fe1598
@ -24,6 +24,18 @@ MarcFile.prototype.writeU16String=function(pos,maxLength,str){
|
||||
for(;i<maxLength;i++)
|
||||
this.writeU16(pos+i*2,0)
|
||||
}
|
||||
MarcFile.newFromPromise=async function(file){
|
||||
var ret = await new Promise((resolve, reject) => {
|
||||
try {
|
||||
var marcFile = new MarcFile(file, function() {
|
||||
resolve(marcFile);
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -45,6 +57,73 @@ MarcDragAndDrop=(function(){
|
||||
return false
|
||||
}
|
||||
|
||||
// Drop handler function to get all files. Thanks xieliming https://stackoverflow.com/a/53058574
|
||||
async function getAllFiles(dataTransferItemList) {
|
||||
let fileEntries = [];
|
||||
// Use BFS to traverse entire directory/file structure
|
||||
let queue = [];
|
||||
// Unfortunately dataTransferItemList is not iterable i.e. no forEach
|
||||
for (let i = 0; i < dataTransferItemList.length; i++) {
|
||||
// Note webkitGetAsEntry a non-standard feature and may change
|
||||
// Usage is necessary for handling directories
|
||||
queue.push(dataTransferItemList[i].webkitGetAsEntry());
|
||||
}
|
||||
while (queue.length > 0) {
|
||||
let entry = queue.shift();
|
||||
if (entry.isFile) {
|
||||
fileEntries.push(entry);
|
||||
} else if (entry.isDirectory) {
|
||||
let reader = entry.createReader();
|
||||
queue.push(...await readAllDirectoryEntries(reader));
|
||||
}
|
||||
}
|
||||
return await Promise.all(fileEntries.map(entry => toFilePromise(entry)));
|
||||
}
|
||||
// Get all the entries (files or sub-directories) in a directory by calling readEntries until it returns empty array
|
||||
async function readAllDirectoryEntries(directoryReader) {
|
||||
let entries = [];
|
||||
let readEntries = await readEntriesPromise(directoryReader);
|
||||
while (readEntries.length > 0) {
|
||||
entries.push(...readEntries);
|
||||
readEntries = await readEntriesPromise(directoryReader);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
// Wrap readEntries in a promise to make working with readEntries easier
|
||||
async function readEntriesPromise(directoryReader) {
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
directoryReader.readEntries(resolve, reject);
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
async function toFilePromise(fileEntry) {
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
fileEntry.file(function(file){
|
||||
// Patch webkitRelativePath for Chrome which doesn't always set it? https://github.com/ant-design/ant-design/issues/16426
|
||||
// TODO could we just pass fileEntry.fullpath out instead of patching this?
|
||||
Object.defineProperties(file, {
|
||||
webkitRelativePath: {
|
||||
writable: true,
|
||||
},
|
||||
});
|
||||
file.webkitRelativePath = file.webkitRelativePath || fileEntry.fullPath.replace(/^\//, '');
|
||||
Object.defineProperties(file, {
|
||||
webkitRelativePath: {
|
||||
writable: false,
|
||||
},
|
||||
});
|
||||
resolve(file);
|
||||
}, reject);
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
function removeClass(){document.body.className=document.body.className.replace(' dragging-files','')}
|
||||
addEvent(document,'dragenter',function(e){
|
||||
if(checkIfHasFiles(e)){
|
||||
@ -77,12 +156,15 @@ MarcDragAndDrop=(function(){
|
||||
if(!dropOutside){
|
||||
enableDropOutside();
|
||||
}
|
||||
addEvent(document.getElementById(z),'drop',function(e){
|
||||
if(!checkIfHasFiles(e))
|
||||
|
||||
addEvent(document.getElementById(z),'drop',async function (e) {
|
||||
var files = await getAllFiles(e.dataTransfer.items);
|
||||
if(files.length==0) {
|
||||
return false;
|
||||
}
|
||||
no(e);
|
||||
removeClass();
|
||||
f(e.dataTransfer.files)
|
||||
f(files);
|
||||
});
|
||||
},
|
||||
addGlobalZone:function(f,t){
|
||||
@ -129,10 +211,6 @@ function _tempFileLoadFunction(){
|
||||
}
|
||||
}
|
||||
|
||||
function loadSavegameFromInput(input){
|
||||
tempFile=new MarcFile(input.files[0], _tempFileLoadFunction);
|
||||
}
|
||||
|
||||
function saveChanges(){
|
||||
if(decodeURIComponent(document.cookie).indexOf('hideWarningMessage=1')>=0 || location.protocol==='file:'){ /* chrome does not write cookies in local, so skip warning message in that case */
|
||||
SavegameEditor.save();
|
||||
@ -192,8 +270,17 @@ window.addEventListener('load', function(){
|
||||
inputFile.type='file';
|
||||
inputFile.className='hidden';
|
||||
inputFile.id='file-load';
|
||||
inputFile.addEventListener('change', function(){
|
||||
loadSavegameFromInput(this);
|
||||
// Requires a folder for "browse window" picking, but this works when running webpage from filesystem on Chrome, where dropping folders does not work.
|
||||
// `webkitGetAsEntry` may be a better workaround https://github.com/danialfarid/ng-file-upload/issues/236#issuecomment-45053629
|
||||
// inputFile.webkitdirectory = true;
|
||||
inputFile.addEventListener('change', async function(evt){
|
||||
if(this.files.length == 1 || typeof SavegameEditor.showSavegameIndex === 'undefined') {
|
||||
// Load savegame from file
|
||||
tempFile=new MarcFile(this.files[0], _tempFileLoadFunction);
|
||||
} else {
|
||||
// Some games have a complex structure of multiple savegames, so we provide a custom picker+overview
|
||||
await SavegameEditor.showSavegameIndex(this.files);
|
||||
}
|
||||
}, false);
|
||||
|
||||
dragZone.appendChild(dragMessage);
|
||||
@ -250,8 +337,14 @@ window.addEventListener('load', function(){
|
||||
}
|
||||
|
||||
|
||||
MarcDragAndDrop.add('dragzone', function(droppedFiles){
|
||||
tempFile=new MarcFile(droppedFiles[0], _tempFileLoadFunction);
|
||||
MarcDragAndDrop.add('dragzone', async function(droppedFiles){
|
||||
if(droppedFiles.length == 1 || typeof SavegameEditor.showSavegameIndex === 'undefined') {
|
||||
// Load savegame from file
|
||||
tempFile=new MarcFile(droppedFiles[0], _tempFileLoadFunction);
|
||||
} else {
|
||||
// Some games have a complex structure of multiple savegames, so we provide a custom picker+overview
|
||||
await SavegameEditor.showSavegameIndex(droppedFiles);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
@ -959,6 +959,9 @@
|
||||
</div>
|
||||
|
||||
<div id="dialog-caption" class="dialog text-center"></div>
|
||||
<div id="dialog-savegameindex" class="dialog text-center">
|
||||
<h3 data-translate="Select save slot to edit">Select save slot to edit</h3><div id="container-savegameslots"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="toasts-container"></div>
|
||||
|
@ -13,6 +13,9 @@ Locale.add('es', {
|
||||
'Import':'Importar',
|
||||
'Duplicate':'Duplicar',
|
||||
'Delete':'Borrar',
|
||||
'Open':'Abrir',
|
||||
'Select save slot to edit': 'Selecciona guardar ranura para editar',
|
||||
'Autosave':'Guardado automático',
|
||||
|
||||
|
||||
/* navbar */
|
||||
@ -2078,4 +2081,4 @@ Obj_SubstituteCloth_30:'Tela del campeón orni',
|
||||
Obj_SubstituteCloth_31:'Tela de la campeona zora',
|
||||
Obj_SubstituteCloth_32:'Tela de la campeona gerudo',
|
||||
Obj_SubstituteCloth_51:'Tela de la nueva túnica de campeón'
|
||||
});
|
||||
});
|
||||
|
@ -41,6 +41,17 @@ body{
|
||||
}
|
||||
|
||||
|
||||
/* pick savegame slot screen */
|
||||
#container-savegameslots .row-item{
|
||||
background-color: rgba(0,0,0,0.2);
|
||||
padding:0;
|
||||
margin:16px 0;
|
||||
align-items:normal;
|
||||
min-height:144px; /* caption 256x144 */
|
||||
max-height:144px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
The legend of Zelda: Tears of the Kingdom savegame editor (last update 2024-01-02)
|
||||
The legend of Zelda: Tears of the Kingdom savegame editor (last update 2024-02-11)
|
||||
|
||||
by Marc Robledo 2023-2024
|
||||
*/
|
||||
@ -1018,30 +1018,49 @@ SavegameEditor={
|
||||
MarcTooltips.add('#container-'+catId+' input',{position:'left',align:'center'});
|
||||
},
|
||||
|
||||
captionReadBoolByHash:function(marcFile, targetHash) {
|
||||
for(var i=0x000028; i<0x000001c0; i+=8){
|
||||
var hash=marcFile.readU32(i);
|
||||
if(hash===targetHash){
|
||||
return marcFile.readBytes(i+4, 1)[0] == 1;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
captionReadU32ByHash:function(marcFile, targetHash) {
|
||||
for(var i=0x000028; i<0x000001c0; i+=8){
|
||||
var hash=marcFile.readU32(i);
|
||||
if(hash===targetHash){
|
||||
return marcFile.readU32(i+4);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
makeImgFromCaption:function(marcFile){
|
||||
var jpgOffset = this.captionReadU32ByHash(marcFile, 0x63696a32);
|
||||
var jpgSize=marcFile.readU32(jpgOffset);
|
||||
|
||||
var arrayBuffer=marcFile._u8array.buffer.slice(jpgOffset+4, jpgOffset+4+jpgSize);
|
||||
var blob=new Blob([arrayBuffer], {type:'image/jpeg'});
|
||||
var imageUrl=(window.URL || window.webkitURL).createObjectURL(blob);
|
||||
var img=new Image();
|
||||
img.src=imageUrl;
|
||||
return img;
|
||||
},
|
||||
|
||||
/* check if savegame is valid */
|
||||
checkValidSavegame:function(){
|
||||
tempFile.littleEndian=true;
|
||||
//if(tempFile.fileName==='caption.sav'){
|
||||
if(/caption/.test(tempFile.fileName)){
|
||||
for(var i=0x000028; i<0x000001c0; i+=8){
|
||||
var hash=tempFile.readU32(i);
|
||||
if(hash===0x63696a32){ //found JPG hash
|
||||
var jpgOffset=tempFile.readU32(i+4);
|
||||
var jpgSize=tempFile.readU32(jpgOffset);
|
||||
|
||||
var arrayBuffer=tempFile._u8array.buffer.slice(jpgOffset+4, jpgOffset+4+jpgSize);
|
||||
var blob=new Blob([arrayBuffer], {type:'image/jpeg'});
|
||||
var imageUrl=(window.URL || window.webkitURL).createObjectURL(blob);
|
||||
var img=new Image();
|
||||
img.src=imageUrl;
|
||||
document.getElementById('dialog-caption').innerHTML='';
|
||||
document.getElementById('dialog-caption').appendChild(img);
|
||||
window.setTimeout(function(){
|
||||
MarcDialogs.open('caption')
|
||||
}, 100);
|
||||
|
||||
break;
|
||||
}
|
||||
var img = this.makeImgFromCaption(tempFile);
|
||||
if(img){
|
||||
document.getElementById('dialog-caption').innerHTML='';
|
||||
document.getElementById('dialog-caption').appendChild(img);
|
||||
window.setTimeout(function(){
|
||||
MarcDialogs.open('caption')
|
||||
}, 100);
|
||||
}
|
||||
}else if(tempFile.readU32(0)===0x01020304 && tempFile.fileSize>=2307552 && tempFile.fileSize<4194304){
|
||||
Variable.findHashTableEnd();
|
||||
@ -1065,6 +1084,93 @@ SavegameEditor={
|
||||
return false
|
||||
},
|
||||
|
||||
showSavegameIndex:async function(droppedFiles) {
|
||||
// Parse savegames into their respective slots
|
||||
var slotCaptionDate = [];
|
||||
var slotCaptionImg = [];
|
||||
var slotCaptionIsAutosave = [];
|
||||
var slotProgressMarcFile = [];
|
||||
for(var i=0; i<droppedFiles.length; i++) {
|
||||
var file = droppedFiles[i];
|
||||
var filePath = file.webkitRelativePath || ''; // non standard but supported everywhere
|
||||
var slotMatch = filePath.match(/slot_0([012345])/);
|
||||
if(!slotMatch || slotMatch.length != 2) {
|
||||
continue;
|
||||
}
|
||||
var slot_i = parseInt(slotMatch[1]);
|
||||
|
||||
if(file.name == "caption.sav") {
|
||||
var marcFile = await MarcFile.newFromPromise(file);
|
||||
marcFile.littleEndian = true; // this gets hardcoded in checkValidSavegame too
|
||||
|
||||
var year = this.captionReadU32ByHash(marcFile, 0x9811A3F7);
|
||||
var minute = this.captionReadU32ByHash(marcFile, 0x27853BF7);
|
||||
var hour = this.captionReadU32ByHash(marcFile, 0x23F3D75E);
|
||||
var month = this.captionReadU32ByHash(marcFile, 0xDFD840D3);
|
||||
var day = this.captionReadU32ByHash(marcFile, 0xBD46F485);
|
||||
var slotDate = new Date(year, month-1, day, hour, minute);
|
||||
slotCaptionDate[slot_i] = slotDate;
|
||||
|
||||
var isAutosave = this.captionReadBoolByHash(marcFile, 0x25F03CAA);
|
||||
slotCaptionIsAutosave[slot_i] = isAutosave;
|
||||
//console.log(slot_i, slotDate, isAutosave);
|
||||
|
||||
var img = this.makeImgFromCaption(marcFile);
|
||||
slotCaptionImg[slot_i] = img;
|
||||
} else if(file.name == "progress.sav") {
|
||||
var marcFile = await MarcFile.newFromPromise(file);
|
||||
marcFile.littleEndian = true; // this gets hardcoded in checkValidSavegame too
|
||||
slotProgressMarcFile[slot_i] = marcFile;
|
||||
}
|
||||
}
|
||||
// Sort slot indexes by date descending, ranking hardsaves higher on tie
|
||||
var sortedSlots = slotCaptionDate.map((x,i) => [x,i]).sort((a,b) => {
|
||||
var cmp = b[0]-a[0];
|
||||
if (cmp) { return cmp; }
|
||||
return slotCaptionIsAutosave[a[1]]-slotCaptionIsAutosave[b[1]];
|
||||
}).map(pair => pair[1]);
|
||||
|
||||
// Show slot picker with caption.sav metadata
|
||||
$('#container-savegameslots').html('');
|
||||
for(let i=0; i<6; i++) {
|
||||
let slot_i = sortedSlots[i];
|
||||
var img = slotCaptionImg[slot_i];
|
||||
var progressMarcFile = slotProgressMarcFile[slot_i];
|
||||
|
||||
var row = $('<div></div>').addClass('row row-item');
|
||||
var columnLeft = $('<div></div>').addClass('row-item-left');
|
||||
var columnRight = $('<div></div>').addClass('row-item-right');
|
||||
row.append(columnLeft, columnRight);
|
||||
|
||||
if(img){
|
||||
columnLeft.append(img);
|
||||
var button = $('<button></button>').addClass('btn').text(_('Open') + ' slot_0' + slot_i).on('click', (evt)=>{
|
||||
MarcDialogs.close();
|
||||
tempFile = slotProgressMarcFile[slot_i];
|
||||
_tempFileLoadFunction();
|
||||
});
|
||||
|
||||
var slotDate = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}).format(slotCaptionDate[slot_i]);
|
||||
slotDate = $("<div></div>").text(slotDate);
|
||||
|
||||
columnRight.append(slotDate, button);
|
||||
|
||||
if(slotCaptionIsAutosave[slot_i]) {
|
||||
var autosaveTag = $("<div></div>").text(_("Autosave"));
|
||||
columnRight.append(autosaveTag);
|
||||
}
|
||||
}
|
||||
|
||||
$('#container-savegameslots').append(row);
|
||||
}
|
||||
|
||||
window.setTimeout(function(){
|
||||
MarcDialogs.open('savegameindex')
|
||||
}, 100);
|
||||
},
|
||||
|
||||
preload:function(){
|
||||
/* implement String.slug for item searching purposes */
|
||||
|
Loading…
x
Reference in New Issue
Block a user