Skip to content

Commit

Permalink
Fix: Resolve issues where some heavy-nested fields we not being creat…
Browse files Browse the repository at this point in the history
…ed correctly if no exist

- Some fields, like maxSp and unusedStatusPoint, don't exist until the player actually uses them or does something ingame to trigger them to exist.
- Paver already creates these fields if they don't exist, and this worked for most fields. However, a couple fields (like maxHp and maxSp) are more deeply nested than others, and when they didn't exist Paver was creating them with the incorrect structure.
- This was causing conversion back into SAV to fail.

- Another, more impactful add here for add'l safety: When converting back into a SAV, we now attempt to make a 'staging' SAV file and check that converted correctly before overwriting the Level.sav.
  • Loading branch information
adefee committed Feb 11, 2024
1 parent 68a3d85 commit 3cb25c5
Show file tree
Hide file tree
Showing 6 changed files with 79 additions and 57 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adefee/palworld-save-editor",
"version": "2.0.12",
"version": "2.0.13",
"description": "Comprehensive and extensible save editor and reporting tool for Palworld",
"main": "dist/index.js",
"type": "commonjs",
Expand Down
Binary file renamed paver-v2.0.12.zip → paver-v2.0.13.zip
Binary file not shown.
20 changes: 13 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,15 +687,21 @@ const saveEditorMain = async () => {
}

if (!isErrorsInConvertion && criticalErrors.length < 1) {
console.info('Removing old Level.sav before generating new one...')
if (fs.existsSync(`${targetGameSaveDirectoryPath}/Level.sav`)) {
deleteFileSync(`${targetGameSaveDirectoryPath}/Level.sav`);
}


// Now let's call CheahJS's tools to convert into Level.sav
await convertSavAndJson(saveToolsInstallPath, levelSavJsonPath, 'Level.sav');
// The extra boolean here will tell us to export a staged file so we don't overwrite the old yet.
// This way, if there are errors with conversion (either our fault or CheahJS), we don't lose the original.
await convertSavAndJson(saveToolsInstallPath, levelSavJsonPath, 'Level.sav.json', true);

// If we have a staged file, we'll move it over the original.
if (fs.existsSync(`${targetGameSaveDirectoryPath}/Level.sav.staged`)) {
console.info("Staged changes to Level.sav appear to have been sucessful, so we'll now overwrite Level.sav with the staged changes. Hope you made a backup :)")
fs.renameSync(`${targetGameSaveDirectoryPath}/Level.sav.staged`, `${targetGameSaveDirectoryPath}/Level.sav`);
} else {
console.info("Unable to find staged LEvel.sav")
}
} else if (isErrorsInConvertion) {
criticalErrors.push('Errors encountered when converting Level.sav.json back into Level.sav.');
criticalErrors.push('Errors encountered when converting Level.sav.json back into Level.sav. Your original Level.sav file should not have been modified.');
}

} catch (err) {
Expand Down
10 changes: 8 additions & 2 deletions src/lib/convertSavAndJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const execAsync = promisify(exec);
* @param {string} label Optional label for use in console output, makes things more human-friendly
* @returns {[boolean, string[]]} Array with success (boolean), and array of applicable errors.
*/
const convertSavAndJson = async (relativeInstallPath: string, targetPath: string, label: string = ''): Promise<[boolean, string[]]> => {
const convertSavAndJson = async (relativeInstallPath: string, targetPath: string, label: string = '', stageOutput: boolean = false): Promise<[boolean, string[]]> => {
const errors = [];

let convertTargetType = 'SAV';
Expand All @@ -25,7 +25,13 @@ const convertSavAndJson = async (relativeInstallPath: string, targetPath: string

console.info(`Running CheahJS' awesome save-tools to convert to ${convertTargetType} in ${fullTargetConversionPath} (this may take time based on save filesize):`);

const { stdout, stderr } = await execAsync(`python "${relativeInstallPath}/convert.py" "${targetPath}"`);
let pyCmd = `python "${relativeInstallPath}/convert.py" "${targetPath}"`;
if (stageOutput) {
console.info(`Staging output to ${targetPath}${convertTargetType === 'SAV' ? '' : '.json'}.staged`)
pyCmd += ` --output="${targetPath.replace('.json', '')}${convertTargetType === 'SAV' ? '' : '.json'}.staged"`
}

const { stdout, stderr } = await execAsync(pyCmd);
console.log(stdout);

if (stderr) {
Expand Down
76 changes: 38 additions & 38 deletions src/lib/getFieldMap.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,31 @@
export interface IFieldMapEntry {
info?: string,
parameterId: string | null, // This will be the parameterId of the field, if it has one (amost never).
targetKey: string,
type: string,
validate?: (val: any) => boolean,
validationError?: string,
postprocess?: (val: any) => any,
notSupported?: string,
followChildren?: boolean,
whatDoesThiDo?: boolean,
subTypeKey?: string,
subTypeValue?: string,
findWithFilter?: (val: any) => boolean,
struct_id?: string, // Some fields may optionally contain a struct_id, which we use if we need to rebuild the entire object (e.g. maxSp may not exist)
struct_type?: string, // Some fields may optionally contain a struct_type, which we use if we need to rebuild the entire object (e.g. maxSp may not exist)
paverId?: string, // Some fields may optionally contain a paverId, which is just an internal identifer for when Paver applies custom logic.
structure?: any
}

/**
*
* @param filename Filename (Level.sav or Player.sav). This just lets us more easily discriminate which values are available for which file.
* @param enableGuardrails Deeper validation to help ensure values are within the "intended" confines of the game.
* @returns
*/
export const getPlayerFieldMapByFile = (filename: string, enableGuardrails = true): {
[key: string]: {
info?: string,
parameterId: string | null, // This will be the parameterId of the field, if it has one (amost never).
targetKey: string,
type: string,
validate?: (val: any) => boolean,
validationError?: string,
postprocess?: (val: any) => any,
notSupported?: string,
followChildren?: boolean,
whatDoesThiDo?: boolean,
subTypeKey?: string,
subTypeValue?: string,
findWithFilter?: (val: any) => boolean,
struct_id?: string, // Some fields may optionally contain a struct_id, which we use if we need to rebuild the entire object (e.g. maxSp may not exist)
struct_type?: string, // Some fields may optionally contain a struct_type, which we use if we need to rebuild the entire object (e.g. maxSp may not exist)
paverId?: string, // Some fields may optionally contain a paverId, which is just an internal identifer for when Paver applies custom logic.
}
[key: string]: IFieldMapEntry
} => {
let returnValue = {};
const defaultNotSupportedValue = "This field is not (yet) supported - it should be available in the near future!";
Expand All @@ -31,24 +34,7 @@ export const getPlayerFieldMapByFile = (filename: string, enableGuardrails = tru
* Build an initial map of values, and then our switch statement can reference the same value with aliases as needed
*/
const fieldMapAliasedValues: {
[key: string]: {
info?: string,
parameterId: string | null, // This will be the parameterId of the field, if it has one (amost never).
targetKey: string,
type: string,
validate?: (val: any) => boolean,
validationError?: string,
postprocess?: (val: any) => any,
notSupported?: string,
followChildren?: boolean,
whatDoesThiDo?: boolean,
subTypeKey?: string,
subTypeValue?: string,
struct_id?: string,
struct_type?: string,
findWithFilter?: (val: any) => boolean,
paverId?: string, // Some fields may optionally contain a paverId, which is just an internal identifer for when Paver applies custom logic.
}
[key: string]: IFieldMapEntry
} = {
level: {
info: "This is a player's level. Specifying `level` without `exp` will automatically calculate and set the player to the matching total XP for the specified level.",
Expand Down Expand Up @@ -93,16 +79,30 @@ export const getPlayerFieldMapByFile = (filename: string, enableGuardrails = tru
type: 'Int64Property',
validate: (val) => Number.isInteger(val) && val >= 0,
validationError: 'currentHp should be an integer greater than 0. The ingame default is 50000.',
structure: [
// Level 1 structure for 'HP'
{ "struct_type": "FixedPoint64", "struct_id": "00000000-0000-0000-0000-000000000000", "id": null, "type": "StructProperty" },
// Level 2 structure for 'value'
{},
// Level 3 structure for 'Value'
{ "id": null, "type": "StructProperty" }
]
},
maxSp: {
info: "This is the player's base maximum Stamina, before add'l modifiers (like Pals and stat points, etc). The ingame default is 50000.",
parameterId: null,
targetKey: 'MaxSP.value.Value.value',
type: 'Int64Property',
struct_type: 'FixedPoint64',
struct_id: '00000000-0000-0000-0000-000000000000',
validate: (val) => Number.isInteger(val) && val >= 0,
validationError: 'currentSp should be an integer greater than 0. The ingame default is 50000.',
structure: [
// Level 1 structure for 'HP'
{ "struct_type": "FixedPoint64", "struct_id": "00000000-0000-0000-0000-000000000000", "id": null, "type": "StructProperty" },
// Level 2 structure for 'value'
{},
// Level 3 structure for 'Value'
{ "id": null, "type": "StructProperty" }
]
},
hunger: {
info: "This is the player's hunger. The ingame default is 100. For some reason, the game calculates this as a 14-point float. Not sure why it needs that much precision.",
Expand Down
28 changes: 19 additions & 9 deletions src/lib/setNestedValue.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,36 @@ const setNestedValue = (objectToModify, fieldMap, newValue) => {
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];

// Check if we need to create a new object or a specific structure
if (!currentObject[key]) {
currentObject[key] = {};
// If structure info is provided for this level, use it to create the structure
if (fieldMap.structure && fieldMap.structure[i]) {
currentObject[key] = { ...fieldMap.structure[i] }; // Use spread to clone structure if needed
} else {
currentObject[key] = {};
}
}

currentObject = currentObject[key];
}

const lastKey = keys[keys.length - 1];
// Record the old value and return it and log.
oldValue = currentObject[lastKey];

// Handle setting new value and possibly additional metadata
if (!oldValue) {
// There was no previous value, so we need to add some add'l meta to this new property
// Seems like id is (always?) null here.
// Set additional properties at the same level
currentObject['id'] = fieldMap?.parameterId || null;
currentObject[lastKey] = newValue;
currentObject['type'] = fieldMap?.type;
// Check if there's a structure defined for the new key
if (fieldMap.structure && fieldMap.structure[keys.length - 1]) {
// Merge the structure with the current object, ensuring new value is set
Object.assign(currentObject, { ...fieldMap.structure[keys.length - 1], [lastKey]: newValue });
} else {
// No specific structure, proceed as before
currentObject['id'] = fieldMap?.parameterId || null;
currentObject[lastKey] = newValue;
currentObject['type'] = fieldMap?.type;
}
} else {
currentObject[keys[keys.length - 1]] = newValue;
currentObject[lastKey] = newValue;
}

return {
Expand Down

0 comments on commit 3cb25c5

Please sign in to comment.