isle.pizza/src/core/formats/SaveGameSerializer.js
Christian Semmler 412d8a4233
Extract BaseRenderer to deduplicate actor and vehicle renderers
- Extract shared Three.js setup, lighting, texture, geometry, and
  animation loop code into BaseRenderer base class (~170 lines)
- Deduplicate WdbParser.parseGlobalParts via parsePartData delegation
- Consolidate lego brown/lt grey into shared LegoColors constant
- Remove dead code: updatePartColor, SUFFIX_NAMES, CharacterType,
  getCharacterType, partToLODIndex, unused imports and re-exports
- Simplify updateCharacter and resolve methods by removing unnecessary
  defensive checks on frozen data and bounded UI inputs
- Extract actorKey helper in ActorEditor to deduplicate key computation
- Delete unused animations/manifest.json
2026-02-07 21:51:28 -08:00

528 lines
19 KiB
JavaScript

/**
* Serializer for modifying save game files
* Uses a "patch in place" approach - copies the original buffer and modifies specific bytes
*/
import { SaveGameParser } from './SaveGameParser.js';
import { BinaryWriter } from './BinaryWriter.js';
import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder } from '../savegame/constants.js';
import { CharacterFieldOffsets, CHARACTER_RECORD_SIZE } from '../savegame/actorConstants.js';
/**
* Offsets for header fields
*/
const HEADER_OFFSETS = {
VERSION: 0, // S32, 4 bytes
PLAYER_ID: 4, // S16, 2 bytes
CURRENT_ACT: 6, // U16, 2 bytes
ACTOR_ID: 8 // U8, 1 byte
};
/**
* Map mission type to state name
*/
const MISSION_STATE_MAP = {
pizza: GameStateTypes.PIZZA_MISSION,
carRace: GameStateTypes.CAR_RACE,
jetskiRace: GameStateTypes.JETSKI_RACE,
towTrack: GameStateTypes.TOW_TRACK,
ambulance: GameStateTypes.AMBULANCE
};
/**
* Serializer for modifying save game files
*/
export class SaveGameSerializer {
/**
* @param {ArrayBuffer} originalBuffer - Original file data
*/
constructor(originalBuffer) {
this.original = originalBuffer;
// Parse to find state locations
this.parser = new SaveGameParser(originalBuffer);
this.parsed = this.parser.parse();
}
/**
* Create a copy of the original buffer for modification
* @returns {ArrayBuffer}
*/
createCopy() {
return this.original.slice(0);
}
/**
* Update header fields (act and actor)
* @param {Object} updates - Fields to update
* @param {number} [updates.currentAct] - New act (0-2)
* @param {number} [updates.actorId] - New actor ID (1-5)
* @returns {ArrayBuffer} - Modified buffer
*/
updateHeader(updates) {
const copy = this.createCopy();
const view = new DataView(copy);
if ('currentAct' in updates && updates.currentAct !== undefined) {
view.setUint16(HEADER_OFFSETS.CURRENT_ACT, updates.currentAct, true);
}
if ('actorId' in updates && updates.actorId !== undefined) {
view.setUint8(HEADER_OFFSETS.ACTOR_ID, updates.actorId);
}
return copy;
}
/**
* Create default state data for a mission type
* @param {string} stateName - State name (e.g., 'CarRaceState')
* @returns {Uint8Array} - State data with default values (all zeros)
*/
createDefaultStateData(stateName) {
const size = GameStateSizes[stateName];
if (!size) {
throw new Error(`Unknown state size for: ${stateName}`);
}
const data = new Uint8Array(size);
// For race states, we need to set the actor IDs
if (stateName === GameStateTypes.CAR_RACE || stateName === GameStateTypes.JETSKI_RACE) {
// 5 entries of 5 bytes: id(1) + score(2) + hiScore(2)
for (let i = 0; i < 5; i++) {
data[i * 5] = Actor.PEPPER + i; // Set actor ID
}
}
return data;
}
/**
* Add a missing state to the save file
* @param {ArrayBuffer} buffer - Current buffer
* @param {string} stateName - State name to add
* @returns {ArrayBuffer} - New buffer with state added
*/
addMissingState(buffer, stateName) {
const stateData = this.createDefaultStateData(stateName);
const nameBytes = new TextEncoder().encode(stateName);
// Calculate new state size: name length (2) + name + data
const stateSize = 2 + nameBytes.length + stateData.length;
// Insert position is where states end (before previous_area field)
const insertOffset = this.parsed.statesEndOffset;
// Create new buffer with space for the new state
const newBuffer = new ArrayBuffer(buffer.byteLength + stateSize);
const newView = new DataView(newBuffer);
const newArray = new Uint8Array(newBuffer);
const oldArray = new Uint8Array(buffer);
// Copy everything up to insert point
newArray.set(oldArray.slice(0, insertOffset));
// Write new state at insert point
let offset = insertOffset;
// Write name length (S16)
newView.setInt16(offset, nameBytes.length, true);
offset += 2;
// Write name
newArray.set(nameBytes, offset);
offset += nameBytes.length;
// Write state data
newArray.set(stateData, offset);
offset += stateData.length;
// Copy the rest of the original buffer (previous_area field)
newArray.set(oldArray.slice(insertOffset), offset);
// Increment state count
const stateCountOffset = this.parsed.stateCountOffset;
const oldCount = new DataView(buffer).getInt16(stateCountOffset, true);
newView.setInt16(stateCountOffset, oldCount + 1, true);
// Update parsed data to include the new state
const dataOffset = insertOffset + 2 + nameBytes.length;
this.parsed.stateLocations.push({
name: stateName,
nameOffset: insertOffset,
dataOffset: dataOffset,
dataSize: stateData.length
});
this.parsed.stateCount++;
this.parsed.statesEndOffset = insertOffset + stateSize;
// Update original reference for subsequent operations
this.original = newBuffer;
return newBuffer;
}
/**
* Ensure a mission state exists in the buffer, adding it if missing
* @param {ArrayBuffer} buffer - Current buffer
* @param {string} missionType - Mission type
* @returns {ArrayBuffer} - Buffer with state present
*/
ensureMissionState(buffer, missionType) {
const stateName = MISSION_STATE_MAP[missionType];
if (!stateName) {
throw new Error(`Unknown mission type: ${missionType}`);
}
const stateLocation = this.parsed.stateLocations.find(loc => loc.name === stateName);
if (stateLocation) {
return buffer; // State already exists
}
return this.addMissingState(buffer, stateName);
}
/**
* Update mission scores in the save file
* @param {string} missionType - Mission type (pizza, carRace, jetskiRace, towTrack, ambulance)
* @param {number} actorId - Actor ID (1-5)
* @param {'score'|'highScore'} scoreType - Which score to update
* @param {number} value - New score color value (0-3)
* @param {ArrayBuffer} [buffer] - Optional buffer to use (for chaining operations)
* @returns {ArrayBuffer} - Modified buffer
*/
updateMissionScore(missionType, actorId, scoreType, value, buffer = null) {
const stateName = MISSION_STATE_MAP[missionType];
if (!stateName) {
console.error(`Unknown mission type: ${missionType}`);
return null;
}
// Use provided buffer or create a copy
let workingBuffer = buffer || this.createCopy();
// Ensure the state exists
workingBuffer = this.ensureMissionState(workingBuffer, missionType);
const stateLocation = this.parsed.stateLocations.find(loc => loc.name === stateName);
if (!stateLocation) {
console.error(`Failed to find or create state: ${stateName}`);
return null;
}
const view = new DataView(workingBuffer);
// Calculate offset based on mission type
let offset;
if (missionType === 'pizza') {
// Pizza: 5 actors * 8 bytes (unk(2) + counter(2) + score(2) + hiScore(2))
const actorIndex = actorId - Actor.PEPPER; // 0-4
const entryOffset = stateLocation.dataOffset + (actorIndex * 8);
if (scoreType === 'score') {
offset = entryOffset + 4; // Skip unk + counter
} else {
offset = entryOffset + 6; // Skip unk + counter + score
}
} else if (missionType === 'carRace' || missionType === 'jetskiRace') {
// Race: 5 actors * 5 bytes (id(1) + lastScore(2) + highScore(2))
const actorIndex = actorId - Actor.PEPPER;
const entryOffset = stateLocation.dataOffset + (actorIndex * 5);
if (scoreType === 'score') {
offset = entryOffset + 1; // Skip id
} else {
offset = entryOffset + 3; // Skip id + lastScore
}
} else if (missionType === 'towTrack' || missionType === 'ambulance') {
// Score mission: 5 scores then 5 high scores (all S16)
const actorIndex = actorId - Actor.PEPPER;
if (scoreType === 'score') {
offset = stateLocation.dataOffset + (actorIndex * 2);
} else {
offset = stateLocation.dataOffset + 10 + (actorIndex * 2); // Skip 5 scores
}
}
if (offset !== undefined) {
view.setInt16(offset, value, true);
}
return workingBuffer;
}
/**
* Apply multiple updates at once
* @param {Object} updates - Updates to apply
* @param {Object} [updates.header] - Header updates
* @param {Array} [updates.missions] - Mission score updates
* @returns {ArrayBuffer} - Modified buffer
*/
applyUpdates(updates) {
let buffer = this.original;
// Apply header updates first
if (updates.header) {
const view = new DataView(buffer = buffer.slice(0));
if ('currentAct' in updates.header) {
view.setUint16(HEADER_OFFSETS.CURRENT_ACT, updates.header.currentAct, true);
}
if ('actorId' in updates.header) {
view.setUint8(HEADER_OFFSETS.ACTOR_ID, updates.header.actorId);
}
}
// Apply mission updates
if (updates.missions && updates.missions.length > 0) {
for (const mission of updates.missions) {
buffer = this.updateMissionScore(
mission.missionType,
mission.actorId,
mission.scoreType,
mission.value,
buffer
);
}
}
return buffer;
}
/**
* Update a variable value in the save file
* @param {string} name - Variable name
* @param {string} newValue - New value string
* @param {ArrayBuffer} [buffer] - Optional buffer to use (for chaining operations)
* @returns {ArrayBuffer|null} - Modified buffer or null on error
*/
updateVariable(name, newValue, buffer = null) {
const varInfo = this.parsed.variables.get(name);
if (!varInfo) {
console.error(`Variable not found: ${name}`);
return null;
}
const workingBuffer = buffer || this.createCopy();
const array = new Uint8Array(workingBuffer);
const encoder = new TextEncoder();
const oldLength = varInfo.valueLength;
const newLength = newValue.length;
if (oldLength === newLength) {
// In-place update - same length, just replace bytes
const valueBytes = encoder.encode(newValue);
array.set(valueBytes, varInfo.valueOffset + 1); // +1 for length byte
return workingBuffer;
} else {
// Different length - need to rebuild buffer
const oldArray = new Uint8Array(workingBuffer);
const sizeDiff = newLength - oldLength;
const newBuffer = new ArrayBuffer(workingBuffer.byteLength + sizeDiff);
const newArray = new Uint8Array(newBuffer);
// Copy everything up to the value (including the length byte position)
const valueDataStart = varInfo.valueOffset + 1; // After length byte
newArray.set(oldArray.slice(0, varInfo.valueOffset));
// Write new length byte
newArray[varInfo.valueOffset] = newLength;
// Write new value
const valueBytes = encoder.encode(newValue);
newArray.set(valueBytes, valueDataStart);
// Copy everything after the old value
const afterOldValue = valueDataStart + oldLength;
newArray.set(oldArray.slice(afterOldValue), valueDataStart + newLength);
return newBuffer;
}
}
/**
* Update an Act1State texture in the save file
* @param {string} textureName - Texture name (e.g. 'chwind.gif')
* @param {{ palette: Array<{r,g,b}>, pixels: Uint8Array, width: number, height: number }} newTextureData
* @param {ArrayBuffer} [buffer] - Optional buffer to use
* @returns {ArrayBuffer|null} - Modified buffer or null on error
*/
updateAct1Texture(textureName, newTextureData, buffer = null) {
const workingBuffer = buffer || this.createCopy();
// Re-parse to get fresh Act1State from the working buffer
const freshParser = new SaveGameParser(workingBuffer);
const freshParsed = freshParser.parse();
const act1State = freshParsed.act1State;
if (!act1State) {
console.error('Act1State not found in save file');
return null;
}
const act1Location = freshParsed.stateLocations.find(loc => loc.name === 'Act1State');
if (!act1Location) {
console.error('Act1State location not found');
return null;
}
const targetKey = textureName.toLowerCase();
if (!act1State.textures.has(targetKey)) {
console.error(`Texture not found in Act1State: ${textureName}`);
return null;
}
// Replace texture data, preserving original name
const oldTex = act1State.textures.get(targetKey);
act1State.textures.set(targetKey, {
name: oldTex.name,
width: newTextureData.width,
height: newTextureData.height,
paletteSize: newTextureData.palette.length,
palette: newTextureData.palette,
pixels: newTextureData.pixels
});
return this._rebuildAct1State(workingBuffer, act1Location, act1State);
}
/**
* Rebuild the full buffer with updated Act1State
* @private
*/
_rebuildAct1State(sourceBuffer, act1Location, act1State) {
const writer = new BinaryWriter(sourceBuffer.byteLength + 4096);
const srcArray = new Uint8Array(sourceBuffer);
// Write 7 planes
let readOffset = act1Location.dataOffset;
for (const plane of act1State.planes) {
writer.writeS16(plane.nameLength);
readOffset += 2;
if (plane.nameLength > 0) {
writer.writeString(plane.name);
readOffset += plane.nameLength;
}
// Copy 36 bytes of position/direction/up from source
writer.writeBytes(srcArray.slice(readOffset, readOffset + 36));
readOffset += 36;
}
// Write conditional textures in correct order
const vehicleOrder = ['helicopter', 'jetski', 'dunebuggy', 'racecar'];
const planeIndices = [3, 4, 5, 6];
for (let v = 0; v < vehicleOrder.length; v++) {
const vehicleName = vehicleOrder[v];
const planeIdx = planeIndices[v];
if (act1State.planes[planeIdx].nameLength <= 0) continue;
const textureNames = Act1TextureOrder[vehicleName];
for (const texName of textureNames) {
const texKey = texName.toLowerCase();
const tex = act1State.textures.get(texKey);
if (!tex) continue;
writer.writeS16(tex.name.length);
writer.writeString(tex.name);
writer.writeU32(tex.width);
writer.writeU32(tex.height);
writer.writeU32(tex.paletteSize);
for (const color of tex.palette) {
writer.writeU8(color.r);
writer.writeU8(color.g);
writer.writeU8(color.b);
}
writer.writeBytes(tex.pixels);
}
}
// Write final fields
writer.writeS16(act1State.cptClickDialogueNextIndex);
writer.writeU8(act1State.playedExitExplanation);
const newAct1Data = writer.toUint8Array();
const oldAct1Size = act1Location.dataSize;
const newAct1Size = newAct1Data.length;
const sizeDiff = newAct1Size - oldAct1Size;
// Build final buffer
const newBuffer = new ArrayBuffer(sourceBuffer.byteLength + sizeDiff);
const newArray = new Uint8Array(newBuffer);
// Copy everything before Act1State data
newArray.set(srcArray.slice(0, act1Location.dataOffset));
// Write new Act1State data
newArray.set(newAct1Data, act1Location.dataOffset);
// Copy everything after old Act1State data
const afterOld = act1Location.dataOffset + oldAct1Size;
newArray.set(srcArray.slice(afterOld), act1Location.dataOffset + newAct1Size);
return newBuffer;
}
/**
* Update a character field in the save file
* @param {number} characterIndex - Character index (0-65)
* @param {string} field - Field name from CharacterFieldOffsets
* @param {number} value - New value
* @returns {ArrayBuffer} - Modified buffer
*/
updateCharacter(characterIndex, field, value) {
const workingBuffer = this.createCopy();
const view = new DataView(workingBuffer);
const offset = this.parsed.charactersOffset + (characterIndex * CHARACTER_RECORD_SIZE) + CharacterFieldOffsets[field];
if (field === 'sound' || field === 'move') {
view.setInt32(offset, value, true);
} else {
view.setUint8(offset, value);
}
return workingBuffer;
}
/**
* Get the byte offset for a mission score
* @param {string} missionType
* @param {number} actorId
* @param {'score'|'highScore'} scoreType
* @returns {number|null}
*/
getMissionScoreOffset(missionType, actorId, scoreType) {
const stateName = MISSION_STATE_MAP[missionType];
if (!stateName) return null;
const stateLocation = this.parsed.stateLocations.find(loc => loc.name === stateName);
if (!stateLocation) return null;
const actorIndex = actorId - Actor.PEPPER;
if (missionType === 'pizza') {
const entryOffset = stateLocation.dataOffset + (actorIndex * 8);
return scoreType === 'score' ? entryOffset + 4 : entryOffset + 6;
} else if (missionType === 'carRace' || missionType === 'jetskiRace') {
const entryOffset = stateLocation.dataOffset + (actorIndex * 5);
return scoreType === 'score' ? entryOffset + 1 : entryOffset + 3;
} else if (missionType === 'towTrack' || missionType === 'ambulance') {
if (scoreType === 'score') {
return stateLocation.dataOffset + (actorIndex * 2);
} else {
return stateLocation.dataOffset + 10 + (actorIndex * 2);
}
}
return null;
}
}
/**
* Create a serializer for a save game buffer
* @param {ArrayBuffer} buffer
* @returns {SaveGameSerializer}
*/
export function createSerializer(buffer) {
return new SaveGameSerializer(buffer);
}