diff --git a/public/globe1.webp b/public/globe1.webp new file mode 100644 index 0000000..cef69ca Binary files /dev/null and b/public/globe1.webp differ diff --git a/public/globe2.webp b/public/globe2.webp new file mode 100644 index 0000000..5bafe2c Binary files /dev/null and b/public/globe2.webp differ diff --git a/public/globe3.webp b/public/globe3.webp new file mode 100644 index 0000000..8889b8f Binary files /dev/null and b/public/globe3.webp differ diff --git a/public/globe4.webp b/public/globe4.webp new file mode 100644 index 0000000..12272ab Binary files /dev/null and b/public/globe4.webp differ diff --git a/public/globe5.webp b/public/globe5.webp new file mode 100644 index 0000000..63fa410 Binary files /dev/null and b/public/globe5.webp differ diff --git a/public/globe6.webp b/public/globe6.webp new file mode 100644 index 0000000..6f83bd2 Binary files /dev/null and b/public/globe6.webp differ diff --git a/src/core/formats/SaveGameParser.js b/src/core/formats/SaveGameParser.js index 1934aa5..7811720 100644 --- a/src/core/formats/SaveGameParser.js +++ b/src/core/formats/SaveGameParser.js @@ -45,7 +45,9 @@ export class SaveGameParser { stateLocations: [], stateCountOffset: null, stateCount: 0, - statesEndOffset: null // Where to insert new states (before previous_area) + statesEndOffset: null, // Where to insert new states (before previous_area) + variables: new Map(), // Map + variablesEndOffset: null // Where END_OF_VARIABLES marker ends }; } @@ -70,20 +72,30 @@ export class SaveGameParser { } /** - * Skip over the variables section + * Parse the variables section, storing name/value pairs with their offsets * Must be called after parseHeader() */ - skipVariables() { + parseVariables() { while (true) { + const nameOffset = this.reader.tell(); const nameLength = this.reader.readU8(); const name = this.reader.readString(nameLength); if (name === 'END_OF_VARIABLES') { + this.parsed.variablesEndOffset = this.reader.tell(); break; } + const valueOffset = this.reader.tell(); const valueLength = this.reader.readU8(); - this.reader.skip(valueLength); + const value = this.reader.readString(valueLength); + + this.parsed.variables.set(name, { + value, + nameOffset, + valueOffset, + valueLength + }); } } @@ -371,11 +383,11 @@ export class SaveGameParser { /** * Full parse for the save editor (header + missions) - * @returns {{ header: SaveGameHeader, missions: MissionScores, stateLocations: GameStateLocation[] }} + * @returns {{ header: SaveGameHeader, missions: MissionScores, stateLocations: GameStateLocation[], variables: Map }} */ parse() { this.parseHeader(); - this.skipVariables(); + this.parseVariables(); this.skipCharacters(); this.skipPlants(); this.skipBuildings(); diff --git a/src/core/formats/SaveGameSerializer.js b/src/core/formats/SaveGameSerializer.js index 44639b1..71d8b5c 100644 --- a/src/core/formats/SaveGameSerializer.js +++ b/src/core/formats/SaveGameSerializer.js @@ -285,6 +285,58 @@ export class SaveGameSerializer { 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; + } + } + /** * Get the byte offset for a mission score * @param {string} missionType diff --git a/src/core/savegame/colorUtils.js b/src/core/savegame/colorUtils.js new file mode 100644 index 0000000..089453d --- /dev/null +++ b/src/core/savegame/colorUtils.js @@ -0,0 +1,119 @@ +/** + * Color utilities for LEGO Island's custom HSV-based background color + * + * IMPORTANT: LEGO Island uses a NON-STANDARD HSV algorithm (see legoutils.cpp) + * - H (Hue): 0-100 maps to standard hue wheel + * - S (Saturation): NOT standard saturation! S=100 produces WHITE + * - V (Value): Brightness component + * + * For vivid colors, keep S in the 40-70 range. The default sky is "set 56 54 68". + */ + +/** + * Parse a backgroundcolor variable value into HSV components + * @param {string} value - Value in format "set H S V" + * @returns {{ h: number, s: number, v: number }} HSV values (0-100) + */ +export function parseBackgroundColor(value) { + const match = value.match(/^set\s+(\d+)\s+(\d+)\s+(\d+)$/); + if (!match) { + // Default sky color + return { h: 56, s: 54, v: 68 }; + } + return { + h: parseInt(match[1], 10), + s: parseInt(match[2], 10), + v: parseInt(match[3], 10) + }; +} + +/** + * Format HSV values into a backgroundcolor variable value + * @param {number} h - Hue (0-100) + * @param {number} s - Saturation (0-100) + * @param {number} v - Value/Brightness (0-100) + * @returns {string} Value in format "set H S V" + */ +export function formatBackgroundColor(h, s, v) { + return `set ${Math.round(h)} ${Math.round(s)} ${Math.round(v)}`; +} + +/** + * Convert LEGO Island's custom HSV (0-100 scale) to RGB (0-255) + * Replicates the exact algorithm from legoutils.cpp ConvertHSVToRGB() + * + * WARNING: S=100 always produces white due to the algorithm! + * + * @param {number} h - Hue (0-100) + * @param {number} s - Saturation (0-100) + * @param {number} v - Value/Brightness (0-100) + * @returns {{ r: number, g: number, b: number }} RGB values (0-255) + */ +export function hsvToRgb(h, s, v) { + // Convert 0-100 scale to 0-1 (as the game does with * 0.01) + const hNorm = h / 100; + const sNorm = s / 100; + const vNorm = v / 100; + + // LEGO Island's custom algorithm (from legoutils.cpp ConvertHSVToRGB) + let max; + if (sNorm > 0.5) { + max = (1.0 - vNorm) * sNorm + vNorm; + } else { + max = (vNorm + 1.0) * sNorm; + } + + if (max <= 0) { + return { r: 0, g: 0, b: 0 }; + } + + const min = sNorm * 2.0 - max; + const hueSegment = Math.floor(hNorm * 6); + const hueFraction = hNorm * 6.0 - hueSegment; + const delta = hueFraction * ((max - min) / max) * max; + const ascending = min + delta; // Channel value rising from min + const descending = max - delta; // Channel value falling from max + + let r, g, b; + switch (hueSegment) { + case 0: // Red to Yellow + r = max; g = ascending; b = min; + break; + case 1: // Yellow to Green + r = descending; g = max; b = min; + break; + case 2: // Green to Cyan + r = min; g = max; b = ascending; + break; + case 3: // Cyan to Blue + r = min; g = descending; b = max; + break; + case 4: // Blue to Magenta + r = ascending; g = min; b = max; + break; + case 5: // Magenta to Red + case 6: + r = max; g = min; b = descending; + break; + default: + r = 0; g = 0; b = 0; + } + + return { + r: Math.round(r * 255), + g: Math.round(g * 255), + b: Math.round(b * 255) + }; +} + +/** + * Convert HSV (0-100 scale) to hex color string + * @param {number} h - Hue (0-100) + * @param {number} s - Saturation (0-100) + * @param {number} v - Value/Brightness (0-100) + * @returns {string} Hex color string (e.g., "#ffcc00") + */ +export function hsvToHex(h, s, v) { + const { r, g, b } = hsvToRgb(h, s, v); + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; +} diff --git a/src/core/savegame/index.js b/src/core/savegame/index.js index 2bab6d1..a17593e 100644 --- a/src/core/savegame/index.js +++ b/src/core/savegame/index.js @@ -11,6 +11,7 @@ export { SaveGameSerializer, createSerializer } from '../formats/index.js'; export { PlayersParser, parsePlayers } from '../formats/index.js'; export { PlayersSerializer, createPlayersSerializer } from '../formats/index.js'; export * from './constants.js'; +export * from './colorUtils.js'; // Import dependencies import { readBinaryFile, writeBinaryFile, fileExists, listFiles } from '../opfs.js'; @@ -24,6 +25,7 @@ import { getSaveFileName, PLAYERS_FILE, Actor, ActorNames } from './constants.js * @property {string} fileName - Save file name * @property {Object|null} header - Parsed header data * @property {Object|null} missions - Mission scores + * @property {Map|null} variables - Parsed variables (name -> { value, nameOffset, valueOffset, valueLength }) * @property {string|null} playerName - Player name from Players.gsi * @property {ArrayBuffer|null} buffer - Raw file buffer (for editing) */ @@ -87,6 +89,7 @@ export async function listSaveSlots() { fileName, header: null, missions: null, + variables: null, playerName: null, buffer: null }; @@ -98,6 +101,7 @@ export async function listSaveSlots() { const parsed = parseSaveGame(buffer); slot.header = parsed.header; slot.missions = parsed.missions; + slot.variables = parsed.variables; slot.buffer = buffer; // Try to get player name @@ -160,6 +164,7 @@ export async function loadSaveSlot(slotNumber) { fileName, header: parsed.header, missions: parsed.missions, + variables: parsed.variables, playerName, buffer }; @@ -219,6 +224,17 @@ export async function updateSaveSlot(slotNumber, updates) { } } + // Apply variable update + if (updates.variable) { + const { name, value } = updates.variable; + const varSerializer = createSerializer(newBuffer); + const result = varSerializer.updateVariable(name, value); + if (result) { + newBuffer = result; + modified = true; + } + } + // Only save if something was actually modified if (!modified) { return slot; diff --git a/src/lib/SaveEditorPage.svelte b/src/lib/SaveEditorPage.svelte index c5faa00..d571586 100644 --- a/src/lib/SaveEditorPage.svelte +++ b/src/lib/SaveEditorPage.svelte @@ -3,6 +3,8 @@ import BackButton from './BackButton.svelte'; import Carousel from './Carousel.svelte'; import MissionScoresEditor from './save-editor/MissionScoresEditor.svelte'; + import SkyColorEditor from './save-editor/SkyColorEditor.svelte'; + import LightPositionEditor from './save-editor/LightPositionEditor.svelte'; import { saveEditorState, currentPage } from '../stores.js'; import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js'; import { Actor, ActorNames } from '../core/savegame/constants.js'; @@ -18,7 +20,8 @@ const saveTabs = [ { id: 'player', label: 'Player', firstSection: 'name' }, - { id: 'scores', label: 'Scores', firstSection: null } + { id: 'scores', label: 'Scores', firstSection: null }, + { id: 'island', label: 'Island', firstSection: 'skycolor' } ]; // Reset state when navigating to this page @@ -107,6 +110,23 @@ } } + async function handleVariableUpdate(update) { + if (selectedSlot === null) return; + + try { + const updated = await updateSaveSlot(selectedSlot, update); + if (updated) { + slots = slots.map(s => + s.slotNumber === selectedSlot + ? { ...s, variables: updated.variables } + : s + ); + } + } catch (e) { + console.error('Failed to update variable:', e); + } + } + // Tab/section functions function switchTab(tab) { activeTab = tab.id; @@ -354,6 +374,27 @@ onUpdate={handleMissionUpdate} /> + + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+
{/if} diff --git a/src/lib/save-editor/LightPositionEditor.svelte b/src/lib/save-editor/LightPositionEditor.svelte new file mode 100644 index 0000000..8c0941b --- /dev/null +++ b/src/lib/save-editor/LightPositionEditor.svelte @@ -0,0 +1,110 @@ + + +
+
+ {#each positions as position} + + {/each} +
+ {#if !isDefault} + + {/if} +
+ + diff --git a/src/lib/save-editor/SkyColorEditor.svelte b/src/lib/save-editor/SkyColorEditor.svelte new file mode 100644 index 0000000..c9c73cf --- /dev/null +++ b/src/lib/save-editor/SkyColorEditor.svelte @@ -0,0 +1,228 @@ + + +
+
+
+
+ + + +
+
+ {#if !isDefault} + + {/if} +
+ +