diff --git a/public/CHJETL1.tex b/public/CHJETL1.tex new file mode 100644 index 0000000..8795c99 Binary files /dev/null and b/public/CHJETL1.tex differ diff --git a/public/CHJETL2.tex b/public/CHJETL2.tex new file mode 100644 index 0000000..64183a4 Binary files /dev/null and b/public/CHJETL2.tex differ diff --git a/public/CHJETL3.tex b/public/CHJETL3.tex new file mode 100644 index 0000000..e1e7196 Binary files /dev/null and b/public/CHJETL3.tex differ diff --git a/public/CHJETL4.tex b/public/CHJETL4.tex new file mode 100644 index 0000000..5a887db Binary files /dev/null and b/public/CHJETL4.tex differ diff --git a/public/CHJETR1.tex b/public/CHJETR1.tex new file mode 100644 index 0000000..9f70c42 Binary files /dev/null and b/public/CHJETR1.tex differ diff --git a/public/CHJETR2.tex b/public/CHJETR2.tex new file mode 100644 index 0000000..04fa132 Binary files /dev/null and b/public/CHJETR2.tex differ diff --git a/public/CHJETR3.tex b/public/CHJETR3.tex new file mode 100644 index 0000000..81a40e7 Binary files /dev/null and b/public/CHJETR3.tex differ diff --git a/public/CHJETR4.tex b/public/CHJETR4.tex new file mode 100644 index 0000000..17cbca8 Binary files /dev/null and b/public/CHJETR4.tex differ diff --git a/public/CHWIND1.tex b/public/CHWIND1.tex new file mode 100644 index 0000000..565eadf Binary files /dev/null and b/public/CHWIND1.tex differ diff --git a/public/CHWIND2.tex b/public/CHWIND2.tex new file mode 100644 index 0000000..622ec2f Binary files /dev/null and b/public/CHWIND2.tex differ diff --git a/public/CHWIND3.tex b/public/CHWIND3.tex new file mode 100644 index 0000000..e732ddb Binary files /dev/null and b/public/CHWIND3.tex differ diff --git a/public/CHWIND4.tex b/public/CHWIND4.tex new file mode 100644 index 0000000..a02c4df Binary files /dev/null and b/public/CHWIND4.tex differ diff --git a/public/Dbfrfn1.tex b/public/Dbfrfn1.tex new file mode 100644 index 0000000..dcda861 Binary files /dev/null and b/public/Dbfrfn1.tex differ diff --git a/public/Dbfrfn2.tex b/public/Dbfrfn2.tex new file mode 100644 index 0000000..1c594ea Binary files /dev/null and b/public/Dbfrfn2.tex differ diff --git a/public/Dbfrfn3.tex b/public/Dbfrfn3.tex new file mode 100644 index 0000000..ae0162b Binary files /dev/null and b/public/Dbfrfn3.tex differ diff --git a/public/Dbfrfn4.tex b/public/Dbfrfn4.tex new file mode 100644 index 0000000..d93381c Binary files /dev/null and b/public/Dbfrfn4.tex differ diff --git a/public/JSWNSH1.tex b/public/JSWNSH1.tex new file mode 100644 index 0000000..4061a65 Binary files /dev/null and b/public/JSWNSH1.tex differ diff --git a/public/JSWNSH2.tex b/public/JSWNSH2.tex new file mode 100644 index 0000000..7b8341a Binary files /dev/null and b/public/JSWNSH2.tex differ diff --git a/public/JSWNSH3.tex b/public/JSWNSH3.tex new file mode 100644 index 0000000..784375e Binary files /dev/null and b/public/JSWNSH3.tex differ diff --git a/public/JSWNSH4.tex b/public/JSWNSH4.tex new file mode 100644 index 0000000..f08f0ea Binary files /dev/null and b/public/JSWNSH4.tex differ diff --git a/public/jsfrnt1.tex b/public/jsfrnt1.tex new file mode 100644 index 0000000..6652e44 Binary files /dev/null and b/public/jsfrnt1.tex differ diff --git a/public/jsfrnt2.tex b/public/jsfrnt2.tex new file mode 100644 index 0000000..0623692 Binary files /dev/null and b/public/jsfrnt2.tex differ diff --git a/public/jsfrnt3.tex b/public/jsfrnt3.tex new file mode 100644 index 0000000..ea16767 Binary files /dev/null and b/public/jsfrnt3.tex differ diff --git a/public/jsfrnt4.tex b/public/jsfrnt4.tex new file mode 100644 index 0000000..d80c799 Binary files /dev/null and b/public/jsfrnt4.tex differ diff --git a/public/rcback1.tex b/public/rcback1.tex new file mode 100644 index 0000000..43f3749 Binary files /dev/null and b/public/rcback1.tex differ diff --git a/public/rcback2.tex b/public/rcback2.tex new file mode 100644 index 0000000..0705599 Binary files /dev/null and b/public/rcback2.tex differ diff --git a/public/rcback3.tex b/public/rcback3.tex new file mode 100644 index 0000000..8be13f5 Binary files /dev/null and b/public/rcback3.tex differ diff --git a/public/rcback4.tex b/public/rcback4.tex new file mode 100644 index 0000000..76dc789 Binary files /dev/null and b/public/rcback4.tex differ diff --git a/public/rcfrnt1.tex b/public/rcfrnt1.tex new file mode 100644 index 0000000..1c44503 Binary files /dev/null and b/public/rcfrnt1.tex differ diff --git a/public/rcfrnt2.tex b/public/rcfrnt2.tex new file mode 100644 index 0000000..799409e Binary files /dev/null and b/public/rcfrnt2.tex differ diff --git a/public/rcfrnt3.tex b/public/rcfrnt3.tex new file mode 100644 index 0000000..790027b Binary files /dev/null and b/public/rcfrnt3.tex differ diff --git a/public/rcfrnt4.tex b/public/rcfrnt4.tex new file mode 100644 index 0000000..b7290f8 Binary files /dev/null and b/public/rcfrnt4.tex differ diff --git a/public/rctail1.tex b/public/rctail1.tex new file mode 100644 index 0000000..5315552 Binary files /dev/null and b/public/rctail1.tex differ diff --git a/public/rctail2.tex b/public/rctail2.tex new file mode 100644 index 0000000..3ec3112 Binary files /dev/null and b/public/rctail2.tex differ diff --git a/public/rctail3.tex b/public/rctail3.tex new file mode 100644 index 0000000..d07c5e8 Binary files /dev/null and b/public/rctail3.tex differ diff --git a/public/rctail4.tex b/public/rctail4.tex new file mode 100644 index 0000000..ba43831 Binary files /dev/null and b/public/rctail4.tex differ diff --git a/src/App.svelte b/src/App.svelte index 7b9b68e..50914c3 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -69,7 +69,7 @@ // Desktop: position on hover if (!isTouchDevice) { document.addEventListener('mouseenter', (e) => { - const trigger = e.target.closest('.tooltip-trigger'); + const trigger = e.target.closest?.('.tooltip-trigger'); if (trigger) positionTooltip(trigger); }, true); } diff --git a/src/core/formats/SaveGameParser.js b/src/core/formats/SaveGameParser.js index 7811720..f3e71fd 100644 --- a/src/core/formats/SaveGameParser.js +++ b/src/core/formats/SaveGameParser.js @@ -280,7 +280,7 @@ export class SaveGameParser { } if (name === 'Act1State') { - return this.skipAct1State(); + return this.parseAct1State(); } // Unknown state - this shouldn't happen with valid save files @@ -305,71 +305,86 @@ export class SaveGameParser { } /** - * Skip Act1State (variable length with conditional textures) - * @returns {number} - Number of bytes skipped + * Parse Act1State (variable length with conditional textures) + * @returns {number} - Number of bytes consumed */ - skipAct1State() { + parseAct1State() { const startOffset = this.reader.tell(); // Read 7 named planes - const planeNameLengths = []; + const planes = []; for (let i = 0; i < 7; i++) { const nameLength = this.reader.readS16(); - planeNameLengths.push(nameLength); + let name = ''; if (nameLength > 0) { - this.reader.skip(nameLength); // name + name = this.reader.readString(nameLength); } this.reader.skip(36); // position(12) + direction(12) + up(12) + planes.push({ name, nameLength }); } // Conditional textures based on which planes have names - const helicopterHasName = planeNameLengths[3] > 0; - const jetskiHasName = planeNameLengths[4] > 0; - const dunebuggyHasName = planeNameLengths[5] > 0; - const racecarHasName = planeNameLengths[6] > 0; + const textures = new Map(); - if (helicopterHasName) { - for (let i = 0; i < 3; i++) { - this.skipAct1Texture(); - } + if (planes[3].nameLength > 0) { + for (let i = 0; i < 3; i++) this.readAct1Texture(textures); } - - if (jetskiHasName) { - for (let i = 0; i < 2; i++) { - this.skipAct1Texture(); - } + if (planes[4].nameLength > 0) { + for (let i = 0; i < 2; i++) this.readAct1Texture(textures); } - - if (dunebuggyHasName) { - this.skipAct1Texture(); + if (planes[5].nameLength > 0) { + this.readAct1Texture(textures); } - - if (racecarHasName) { - for (let i = 0; i < 3; i++) { - this.skipAct1Texture(); - } + if (planes[6].nameLength > 0) { + for (let i = 0; i < 3; i++) this.readAct1Texture(textures); } // Final fields - this.reader.skip(2); // cpt_click_dialogue_next_index (S16) - this.reader.skip(1); // played_exit_explanation (U8) + const cptClickDialogueNextIndex = this.reader.readS16(); + const playedExitExplanation = this.reader.readU8(); + + this.parsed.act1State = { + planes, + textures, + startOffset, + endOffset: this.reader.tell(), + cptClickDialogueNextIndex, + playedExitExplanation + }; return this.reader.tell() - startOffset; } /** - * Skip a single Act1 texture + * Read a single Act1 texture into the textures map + * @param {Map} textures - Map to store texture data + * @returns {string} - Texture name */ - skipAct1Texture() { + readAct1Texture(textures) { const nameLength = this.reader.readS16(); + let name = ''; if (nameLength > 0) { - this.reader.skip(nameLength); // name + name = this.reader.readString(nameLength); } + const width = this.reader.readU32(); const height = this.reader.readU32(); - const paletteCount = this.reader.readU32(); - this.reader.skip(paletteCount * 3); // palette (RGB triplets) - this.reader.skip(width * height); // bitmap data + const paletteSize = this.reader.readU32(); + + const palette = []; + for (let i = 0; i < paletteSize; i++) { + palette.push({ + r: this.reader.readU8(), + g: this.reader.readU8(), + b: this.reader.readU8() + }); + } + + const pixels = new Uint8Array(this.reader.slice(width * height)); + + const nameLower = name.toLowerCase(); + textures.set(nameLower, { name, width, height, paletteSize, palette, pixels }); + return nameLower; } /** diff --git a/src/core/formats/SaveGameSerializer.js b/src/core/formats/SaveGameSerializer.js index 71d8b5c..c39110e 100644 --- a/src/core/formats/SaveGameSerializer.js +++ b/src/core/formats/SaveGameSerializer.js @@ -3,7 +3,8 @@ * Uses a "patch in place" approach - copies the original buffer and modifies specific bytes */ import { SaveGameParser } from './SaveGameParser.js'; -import { GameStateTypes, GameStateSizes, Actor } from '../savegame/constants.js'; +import { BinaryWriter } from './BinaryWriter.js'; +import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder } from '../savegame/constants.js'; /** * Offsets for header fields @@ -337,6 +338,129 @@ export class SaveGameSerializer { } } + /** + * 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; + } + /** * Get the byte offset for a mission score * @param {string} missionType diff --git a/src/core/formats/TexParser.js b/src/core/formats/TexParser.js new file mode 100644 index 0000000..f74b9d8 --- /dev/null +++ b/src/core/formats/TexParser.js @@ -0,0 +1,44 @@ +/** + * Parser for .tex texture files + * Format: U32 num_textures, then per texture: + * U32 name_buffer_length + name (null-terminated within buffer) + * + U32 width + U32 height + U32 palette_size + * + RGB[palette_size] + pixels[width*height] + */ +import { BinaryReader } from './BinaryReader.js'; + +/** + * Parse a .tex file buffer + * @param {ArrayBuffer} buffer - Raw .tex file contents + * @returns {{ textures: Array<{ name: string, width: number, height: number, paletteSize: number, palette: Array<{r,g,b}>, pixels: Uint8Array }> }} + */ +export function parseTex(buffer) { + const reader = new BinaryReader(buffer); + const numTextures = reader.readU32(); + const textures = []; + + for (let i = 0; i < numTextures; i++) { + const nameBufferLength = reader.readU32(); + const nameRaw = reader.readString(nameBufferLength); + const name = nameRaw.split('\0')[0].toLowerCase(); + + const width = reader.readU32(); + const height = reader.readU32(); + const paletteSize = reader.readU32(); + + const palette = []; + for (let j = 0; j < paletteSize; j++) { + palette.push({ + r: reader.readU8(), + g: reader.readU8(), + b: reader.readU8() + }); + } + + const pixels = new Uint8Array(reader.slice(width * height)); + + textures.push({ name, width, height, paletteSize, palette, pixels }); + } + + return { textures }; +} diff --git a/src/core/formats/WdbParser.js b/src/core/formats/WdbParser.js index 66eaf50..3e08deb 100644 --- a/src/core/formats/WdbParser.js +++ b/src/core/formats/WdbParser.js @@ -310,8 +310,10 @@ export class WdbParser { } // Packed vertex/normal counts + // Game source (legolod.cpp): numVerts = lower16 & MAXSHORT (bits 0-14), bit 15 is a flag + // numNormals = (upper16 >> 1) & MAXSHORT (bits 17-31) const vertexNormalCounts = this.reader.readU32(); - const vertexCount = vertexNormalCounts & 0xFFFF; + const vertexCount = vertexNormalCounts & 0x7FFF; const normalCount = (vertexNormalCounts >> 17) & 0x7FFF; const numTextureVertices = this.reader.readS32(); diff --git a/src/core/formats/index.js b/src/core/formats/index.js index 96ea9e3..a6d3ca4 100644 --- a/src/core/formats/index.js +++ b/src/core/formats/index.js @@ -16,3 +16,6 @@ export { SaveGameSerializer, createSerializer } from './SaveGameSerializer.js'; // Players format export { PlayersParser, parsePlayers } from './PlayersParser.js'; export { PlayersSerializer, createPlayersSerializer } from './PlayersSerializer.js'; + +// Texture format +export { parseTex } from './TexParser.js'; diff --git a/src/core/rendering/VehiclePartRenderer.js b/src/core/rendering/VehiclePartRenderer.js index 477f81f..7a872bf 100644 --- a/src/core/rendering/VehiclePartRenderer.js +++ b/src/core/rendering/VehiclePartRenderer.js @@ -73,6 +73,8 @@ export class VehiclePartRenderer { const texture = new THREE.CanvasTexture(canvas); texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; return texture; } @@ -167,6 +169,9 @@ export class VehiclePartRenderer { } const threeMesh = new THREE.Mesh(geometry, material); + if (meshTextureName) { + threeMesh.userData.textureName = meshTextureName; + } this.modelGroup.add(threeMesh); // Track colorable meshes @@ -252,6 +257,32 @@ export class VehiclePartRenderer { return geometry; } + /** + * Update texture on meshes matching a given texture name + * @param {string} textureName - Texture name to match (case-insensitive) + * @param {{ width: number, height: number, palette: Array<{r,g,b}>, pixels: Uint8Array }} textureData + */ + updateTexture(textureName, textureData) { + if (!this.modelGroup) return; + + const newTexture = this.createTexture(textureData); + const targetName = textureName.toLowerCase(); + + this.modelGroup.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return; + if (child.userData.textureName !== targetName) return; + + const oldMap = child.material.map; + child.material.map = newTexture; + // Set color to white so texture isn't tinted by the fallback color + child.material.color.setRGB(1, 1, 1); + child.material.needsUpdate = true; + if (oldMap && oldMap !== newTexture) oldMap.dispose(); + }); + + this.renderer.render(this.scene, this.camera); + } + /** * Update color of colorable meshes without reloading geometry */ diff --git a/src/core/savegame/constants.js b/src/core/savegame/constants.js index 42cf79e..eb6c8cf 100644 --- a/src/core/savegame/constants.js +++ b/src/core/savegame/constants.js @@ -227,6 +227,36 @@ export const VehicleNames = Object.freeze({ racecar: 'Race Car' }); +// Act1State plane indices for each vehicle type (indices into the 7-plane array) +export const Act1PlaneIndices = Object.freeze({ + helicopter: 3, + jetski: 4, + dunebuggy: 5, + racecar: 6 +}); + +// Parts that have UV-mapped texture regions in Act1State +// textureName: name stored in Act1State, texFiles: base names of 4 default .tex files +export const TexturedParts = Object.freeze({ + chwindy1: { textureName: 'chwind.gif', texFiles: ['CHWIND1', 'CHWIND2', 'CHWIND3', 'CHWIND4'], vehicle: 'helicopter' }, + chljety1: { textureName: 'chjetl.gif', texFiles: ['CHJETL1', 'CHJETL2', 'CHJETL3', 'CHJETL4'], vehicle: 'helicopter' }, + chrjety1: { textureName: 'chjetr.gif', texFiles: ['CHJETR1', 'CHJETR2', 'CHJETR3', 'CHJETR4'], vehicle: 'helicopter' }, + jsfrnty5: { textureName: 'jsfrnt.gif', texFiles: ['jsfrnt1', 'jsfrnt2', 'jsfrnt3', 'jsfrnt4'], vehicle: 'jetski' }, + jswnshy5: { textureName: 'jswnsh.gif', texFiles: ['JSWNSH1', 'JSWNSH2', 'JSWNSH3', 'JSWNSH4'], vehicle: 'jetski' }, + dbfrfny4: { textureName: 'dbfrfn.gif', texFiles: ['Dbfrfn1', 'Dbfrfn2', 'Dbfrfn3', 'Dbfrfn4'], vehicle: 'dunebuggy' }, + rcfrnty6: { textureName: 'rcfrnt.gif', texFiles: ['rcfrnt1', 'rcfrnt2', 'rcfrnt3', 'rcfrnt4'], vehicle: 'racecar' }, + rcbacky6: { textureName: 'rcback.gif', texFiles: ['rcback1', 'rcback2', 'rcback3', 'rcback4'], vehicle: 'racecar' }, + rctailya: { textureName: 'rctail.gif', texFiles: ['rctail1', 'rctail2', 'rctail3', 'rctail4'], vehicle: 'racecar' } +}); + +// Texture write order per vehicle in Act1State (from isle.cpp Act1State::Serialize) +export const Act1TextureOrder = Object.freeze({ + helicopter: ['chwind.gif', 'chjetl.gif', 'chjetr.gif'], + jetski: ['jsfrnt.gif', 'jswnsh.gif'], + dunebuggy: ['dbfrfn.gif'], + racecar: ['rcfrnt.gif', 'rcback.gif', 'rctail.gif'] +}); + // Vehicle part color definitions - 43 parts total (from legogamestate.cpp) export const VehiclePartColors = Object.freeze({ dunebuggy: [ diff --git a/src/core/savegame/imageQuantizer.js b/src/core/savegame/imageQuantizer.js new file mode 100644 index 0000000..2dd4873 --- /dev/null +++ b/src/core/savegame/imageQuantizer.js @@ -0,0 +1,106 @@ +/** + * Image quantization utility for converting uploaded images to palette-indexed format + */ + +/** + * Quantize an image to a palette-indexed format suitable for the game. + * The WDB palette is padded to 256 entries with black — matching how + * LegoTextureInfo::Create() builds the DirectDraw surface palette: + * indices 0..paletteSize-1 get the WDB colors, the rest are {0,0,0}. + * + * @param {HTMLImageElement} img - Source image + * @param {number} targetWidth - Target width in pixels + * @param {number} targetHeight - Target height in pixels + * @param {Array<{r:number,g:number,b:number}>} basePalette - WDB palette to quantize against + * @returns {{ palette: Array<{r:number,g:number,b:number}>, pixels: Uint8Array }} + */ +export function quantizeImage(img, targetWidth, targetHeight, basePalette) { + // Pad to 256 entries with black, mirroring the game's surface palette + const palette = basePalette.slice(); + while (palette.length < 256) { + palette.push({ r: 0, g: 0, b: 0 }); + } + + // Resize to target dimensions + const canvas = document.createElement('canvas'); + canvas.width = targetWidth; + canvas.height = targetHeight; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, targetWidth, targetHeight); + + const imageData = ctx.getImageData(0, 0, targetWidth, targetHeight); + const rgba = imageData.data; + const pixelCount = targetWidth * targetHeight; + + // Build lookup cache for fast nearest-color matching + const paletteLookup = new Map(); + + // Map each pixel to nearest palette entry + const pixels = new Uint8Array(pixelCount); + for (let i = 0; i < pixelCount; i++) { + const r = rgba[i * 4]; + const g = rgba[i * 4 + 1]; + const b = rgba[i * 4 + 2]; + const key = (r << 16) | (g << 8) | b; + + if (paletteLookup.has(key)) { + pixels[i] = paletteLookup.get(key); + } else { + // Find nearest color in palette + let bestIdx = 0; + let bestDist = Infinity; + for (let j = 0; j < palette.length; j++) { + const dr = r - palette[j].r; + const dg = g - palette[j].g; + const db = b - palette[j].b; + const dist = dr * dr + dg * dg + db * db; + if (dist < bestDist) { + bestDist = dist; + bestIdx = j; + } + } + pixels[i] = bestIdx; + paletteLookup.set(key, bestIdx); + } + } + + return { palette, pixels }; +} + +/** + * Square a palette-indexed texture by duplicating rows or columns. + * Mirrors the game's LegoImage::Read(p_square=TRUE) behavior. + * @param {{ width: number, height: number, palette: Array<{r,g,b}>, pixels: Uint8Array, paletteSize?: number }} tex + * @returns {{ width: number, height: number, palette: Array<{r,g,b}>, pixels: Uint8Array, paletteSize: number }} + */ +export function squareTexture(tex) { + const { width, height, palette, pixels } = tex; + if (width === height) return tex; + + const size = Math.max(width, height); + const squared = new Uint8Array(size * size); + + if (width > height) { + const factor = width / height; + for (let x = 0; x < width; x++) { + for (let y = 0; y < height; y++) { + const src = pixels[y * width + x]; + for (let k = 0; k < factor; k++) { + squared[(y * factor + k) * size + x] = src; + } + } + } + } else { + const factor = height / width; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const src = pixels[y * width + x]; + for (let k = 0; k < factor; k++) { + squared[y * size + (x * factor + k)] = src; + } + } + } + } + + return { width: size, height: size, palette, pixels: squared, paletteSize: palette.length }; +} diff --git a/src/core/savegame/index.js b/src/core/savegame/index.js index a17593e..14e2e7c 100644 --- a/src/core/savegame/index.js +++ b/src/core/savegame/index.js @@ -90,6 +90,7 @@ export async function listSaveSlots() { header: null, missions: null, variables: null, + act1State: null, playerName: null, buffer: null }; @@ -102,6 +103,7 @@ export async function listSaveSlots() { slot.header = parsed.header; slot.missions = parsed.missions; slot.variables = parsed.variables; + slot.act1State = parsed.act1State || null; slot.buffer = buffer; // Try to get player name @@ -165,6 +167,7 @@ export async function loadSaveSlot(slotNumber) { header: parsed.header, missions: parsed.missions, variables: parsed.variables, + act1State: parsed.act1State || null, playerName, buffer }; @@ -235,6 +238,17 @@ export async function updateSaveSlot(slotNumber, updates) { } } + // Apply texture update + if (updates.texture) { + const { textureName, textureData } = updates.texture; + const texSerializer = createSerializer(newBuffer); + const result = texSerializer.updateAct1Texture(textureName, textureData); + if (result) { + newBuffer = result; + modified = true; + } + } + // Only save if something was actually modified if (!modified) { return slot; diff --git a/src/core/savegame/textureStorage.js b/src/core/savegame/textureStorage.js new file mode 100644 index 0000000..c54af23 --- /dev/null +++ b/src/core/savegame/textureStorage.js @@ -0,0 +1,94 @@ +const DB_NAME = 'isle-pizza-textures'; +const DB_VERSION = 1; +const STORE_NAME = 'custom-textures'; + +let dbPromise = null; + +function openDB() { + if (dbPromise) return dbPromise; + + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (db.objectStoreNames.contains(STORE_NAME)) { + db.deleteObjectStore(STORE_NAME); + } + const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' }); + store.createIndex('textureName', 'textureName', { unique: false }); + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }).catch((err) => { + dbPromise = null; + throw err; + }); + + return dbPromise; +} + +/** + * Save a processed (quantized + squared) custom texture to IndexedDB. + * @param {{ width: number, height: number, paletteSize: number, palette: Array<{r:number,g:number,b:number}>, pixels: Uint8Array }} textureData + * @param {string} textureName - The game texture this was quantized for (e.g. 'rcfrnt.gif') + * @returns {Promise} The generated id + */ +export async function saveCustomTexture(textureData, textureName) { + const db = await openDB(); + const id = crypto.randomUUID(); + const record = { + id, + timestamp: Date.now(), + textureName, + width: textureData.width, + height: textureData.height, + paletteSize: textureData.paletteSize, + palette: textureData.palette, + pixels: textureData.pixels + }; + + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).put(record); + tx.oncomplete = () => resolve(id); + tx.onerror = () => reject(tx.error); + }); +} + +/** + * List custom textures for a specific game texture, sorted by timestamp descending. + * @param {string} textureName - Filter by game texture name (e.g. 'rcfrnt.gif') + * @returns {Promise} + */ +export async function listCustomTextures(textureName) { + const db = await openDB(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const request = tx.objectStore(STORE_NAME).index('textureName').getAll(textureName); + request.onsuccess = () => { + const results = request.result; + results.sort((a, b) => b.timestamp - a.timestamp); + resolve(results); + }; + request.onerror = () => reject(request.error); + }); +} + +/** + * Delete a custom texture by id. + * @param {string} id + * @returns {Promise} + */ +export async function deleteCustomTexture(id) { + const db = await openDB(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} diff --git a/src/lib/ReadMePage.svelte b/src/lib/ReadMePage.svelte index 5056991..974d4c9 100644 --- a/src/lib/ReadMePage.svelte +++ b/src/lib/ReadMePage.svelte @@ -36,7 +36,8 @@ { id: 'cl0', title: 'February 2026', items: [ { type: 'New', text: 'Save Editor lets you view and modify save files — change your player name, character, and high scores directly from the browser' }, { type: 'New', text: 'Sky Color Editor allows customizing the island sky gradient colors in your save file' }, - { type: 'New', text: 'Vehicle Part Editor enables modifying vehicle parts and colors with a 3D preview' } + { type: 'New', text: 'Vehicle Part Editor enables modifying vehicle parts and colors with a 3D preview' }, + { type: 'New', text: 'Vehicle Texture Editor lets you customize vehicle textures with default presets or your own uploaded images' } ]}, { id: 'cl1', title: 'January 2026', items: [ { type: 'New', text: 'Debug menu for developers and power users. Tap the LEGO Island logo 5 times to unlock OGEL mode and access debug features like teleporting to locations, switching acts, and playing animations' }, diff --git a/src/lib/SaveEditorPage.svelte b/src/lib/SaveEditorPage.svelte index 44ec3f6..54794eb 100644 --- a/src/lib/SaveEditorPage.svelte +++ b/src/lib/SaveEditorPage.svelte @@ -120,7 +120,7 @@ if (updated) { slots = slots.map(s => s.slotNumber === selectedSlot - ? { ...s, variables: updated.variables } + ? { ...s, variables: updated.variables, act1State: updated.act1State } : s ); } diff --git a/src/lib/save-editor/TexturePickerModal.svelte b/src/lib/save-editor/TexturePickerModal.svelte new file mode 100644 index 0000000..38ad4a3 --- /dev/null +++ b/src/lib/save-editor/TexturePickerModal.svelte @@ -0,0 +1,419 @@ + + + + + + + + diff --git a/src/lib/save-editor/VehicleEditor.svelte b/src/lib/save-editor/VehicleEditor.svelte index 8861161..058695f 100644 --- a/src/lib/save-editor/VehicleEditor.svelte +++ b/src/lib/save-editor/VehicleEditor.svelte @@ -7,11 +7,16 @@ VehicleWorlds, VehicleModels, VehicleNames, - VehiclePartColors + VehiclePartColors, + TexturedParts, + Act1PlaneIndices } from '../../core/savegame/constants.js'; + import { squareTexture } from '../../core/savegame/imageQuantizer.js'; + import { parseTex } from '../../core/formats/TexParser.js'; import NavButton from '../NavButton.svelte'; import ResetButton from '../ResetButton.svelte'; import EditorTooltip from '../EditorTooltip.svelte'; + import TexturePickerModal from './TexturePickerModal.svelte'; export let slot; export let onUpdate = () => {}; @@ -37,6 +42,12 @@ // Track current loaded part to avoid redundant reloads let loadedPartKey = null; + // Texture modal state + let showTextureModal = false; + let texturePalette = null; + let wdbTexture = null; + let preloadedDefaults = null; + // Current part info from flat list $: currentEntry = allParts[globalIndex]; $: vehicle = currentEntry?.vehicle || 'dunebuggy'; @@ -48,7 +59,20 @@ : 'lego red'; // Check if current color differs from default - $: isDefault = currentPart && currentColorValue === currentPart.defaultColor; + $: isDefaultColor = currentPart && currentColorValue === currentPart.defaultColor; + + // Texture info for current part (if it's a textured part) + $: textureInfo = currentPart ? TexturedParts[currentPart.part] || null : null; + + // Check if vehicle has a plane in Act1State (vehicle is placed in world) + $: vehicleHasPlane = (() => { + if (!textureInfo || !slot?.act1State) return false; + const planeIdx = Act1PlaneIndices[textureInfo.vehicle]; + return planeIdx !== undefined && slot.act1State.planes[planeIdx]?.nameLength > 0; + })(); + + // Can edit texture: part has texture info AND vehicle plane exists in Act1State + $: canEditTexture = textureInfo && vehicleHasPlane; onMount(async () => { try { @@ -81,14 +105,30 @@ renderer?.dispose(); }); - // Reload part when index changes + // Reload part when index or slot changes $: if (renderer && !loading && currentPart) { - const partKey = `${vehicle}-${globalIndex}`; + const partKey = `${slot?.slotNumber}-${vehicle}-${globalIndex}`; if (partKey !== loadedPartKey) { loadCurrentPart(); } } + // Check if current texture matches the WDB default. + // Declared after the loadCurrentPart block: Svelte 5 runs legacy $: effects + // in source order, and wdbTexture must be set before this evaluates. + function isTextureDefault(info, wdbTex, act1Textures) { + if (!info || !wdbTex || !act1Textures) return true; + const act1Tex = act1Textures.get(info.textureName.toLowerCase()); + if (!act1Tex) return true; + if (act1Tex.pixels.length !== wdbTex.pixels.length) return false; + for (let i = 0; i < act1Tex.pixels.length; i++) { + if (act1Tex.pixels[i] !== wdbTex.pixels[i]) return false; + } + return true; + } + + $: isDefaultTexture = isTextureDefault(textureInfo, wdbTexture, slot?.act1State?.textures); + // Update color when variable changes (without reloading geometry) $: if (renderer && !loading && currentColorValue && loadedPartKey) { renderer.updateColor(currentColorValue); @@ -98,7 +138,7 @@ if (!wdbData || !wdbParser || !currentPart || !renderer) return; partError = null; - const partKey = `${vehicle}-${globalIndex}`; + const partKey = `${slot?.slotNumber}-${vehicle}-${globalIndex}`; try { const worldName = VehicleWorlds[vehicle]; @@ -133,15 +173,72 @@ // Build parts map for shared LOD resolution const partsMap = buildPartsMap(wdbParser, world.parts); + // Build texture list, merging Act1State texture if available + let textures = modelData.textures || []; + if (textureInfo && slot?.act1State?.textures) { + const texKey = textureInfo.textureName.toLowerCase(); + const act1Tex = slot.act1State.textures.get(texKey); + if (act1Tex) { + const existingIdx = textures.findIndex(t => t.name?.toLowerCase() === texKey); + if (existingIdx >= 0) { + textures = [...textures]; + textures[existingIdx] = { ...act1Tex, name: texKey }; + } else { + textures = [...textures, { ...act1Tex, name: texKey }]; + } + } + } + + // Extract palette from the WDB texture (the ground truth) for the + // texture picker modal. The game's LoadBits() only overwrites pixel + // data on the DirectDraw surface — the palette always stays from the + // original WDB load. So custom pixel indices must reference THIS palette. + if (textureInfo) { + const texKey = textureInfo.textureName.toLowerCase(); + const wdbTex = (modelData.textures || []).find(t => t.name === texKey); + if (wdbTex) { + texturePalette = wdbTex.palette; + wdbTexture = squareTexture(wdbTex); + } else { + wdbTexture = null; + } + } else { + wdbTexture = null; + } + // Load part with current color, textures, and parts map for shared LOD lookup - renderer.loadPartWithColor(partRoi, currentColorValue, modelData.textures || [], partsMap); + renderer.loadPartWithColor(partRoi, currentColorValue, textures, partsMap) loadedPartKey = partKey; + + // Preload default .tex files in background for the texture picker + if (textureInfo) { + preloadDefaultTextures(textureInfo); + } else { + preloadedDefaults = null; + } } catch (e) { console.error('Failed to load part:', e); partError = e.message; } } + async function preloadDefaultTextures(info) { + const results = await Promise.all(info.texFiles.map(async (texFile) => { + const response = await fetch(`/${texFile}.tex`); + if (!response.ok) return null; + const buffer = await response.arrayBuffer(); + const parsed = parseTex(buffer); + if (parsed.textures.length > 0) { + return { name: texFile, ...parsed.textures[0] }; + } + return null; + })); + // Only apply if textureInfo hasn't changed since we started + if (textureInfo === info) { + preloadedDefaults = results.filter(Boolean); + } + } + function prevPart() { globalIndex = globalIndex > 0 ? globalIndex - 1 : allParts.length - 1; loadedPartKey = null; @@ -171,17 +268,61 @@ function resetColor() { if (!currentPart) return; - onUpdate({ + const update = { variable: { name: currentPart.variable, value: currentPart.defaultColor } + }; + + // Reset texture to WDB default (equivalent to WriteDefaultTexture in the game). + // wdbTexture is already squared when cached in loadCurrentPart(). + if (canEditTexture && wdbTexture && renderer) { + const texKey = textureInfo.textureName.toLowerCase(); + + renderer.updateTexture(texKey, wdbTexture); + + update.texture = { + textureName: textureInfo.textureName, + textureData: wdbTexture + }; + } + + onUpdate(update); + } + + function openTexturePicker() { + if (!canEditTexture) return; + showTextureModal = true; + } + + function handleTextureSelect(textureData) { + if (!textureInfo || !renderer) return; + + const texKey = textureInfo.textureName.toLowerCase(); + + // Square the texture for game compatibility — the game's DirectDraw + // surfaces are always square, and LoadBits() expects matching dimensions. + // No-op if already square. + const saveData = squareTexture(textureData); + + // Update preview immediately + renderer.updateTexture(texKey, saveData); + + // Save to file + onUpdate({ + texture: { + textureName: textureInfo.textureName, + textureData: saveData + } }); + + showTextureModal = false; } - +
-
- -
- {VehicleNames[vehicle]} - {currentPart?.label || 'Unknown'} +
+
+ +
+ {VehicleNames[vehicle]} + {currentPart?.label || 'Unknown'} +
+
- + {#if textureInfo} + + {/if}
- {#if !isDefault && !loading && !error && !partError} + {#if (!isDefaultColor || !isDefaultTexture) && !loading && !error && !partError} {/if}
+{#if showTextureModal && textureInfo} + showTextureModal = false} + /> +{/if} + diff --git a/workbox-config.cjs b/workbox-config.cjs index 92e8d4d..71e1e2d 100644 --- a/workbox-config.cjs +++ b/workbox-config.cjs @@ -1,7 +1,7 @@ module.exports = { globDirectory: 'dist/', globPatterns: [ - '**/*.{js,css,html,webp,wasm,pdf,mp3,gif,png,svg,json}' + '**/*.{js,css,html,webp,wasm,pdf,mp3,gif,png,svg,json,tex}' ], swSrc: 'src-sw/sw.js', swDest: 'dist/sw.js',