mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-03-01 06:17:38 +00:00
- 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
528 lines
19 KiB
JavaScript
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);
|
|
}
|