0
0
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:
Marc Robledo 2024-03-22 08:54:46 +01:00 committed by GitHub
commit 0cd1fe1598
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 248 additions and 32 deletions

View File

@ -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);
}
});

View File

@ -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>

View File

@ -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'
});
});

View File

@ -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;
}

View File

@ -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 */