isle.pizza/src/core/formats/WdbParser.js
Christian Semmler c85eeef56a
Some checks are pending
Build / build (push) Waiting to run
Feature/vehicle part editor (#14)
* Add vehicle part color editor to save editor

- Add Vehicles tab with 3D preview of customizable vehicle parts
- Support all 43 colorable parts across 4 vehicles (dune buggy, helicopter, jetski, race car)
- Implement shared LOD resolution for proper rendering of all parts
- Extract reusable NavButton and ResetButton components
- Remove debug console.log statements from ScoreCube

* Reserve space for reset button in vehicle editor to prevent layout shift

* Add tooltip to vehicle editor explaining click-to-cycle interaction

* Add scroll-into-view behavior for name slots on mobile

When name slots overflow on narrow screens, focusing a partially visible
slot now smoothly scrolls it into view. Scrollbar is hidden for cleaner UI.

* Extract EditorTooltip component for consistent tooltip positioning

Refactored tooltip markup into reusable EditorTooltip component used by
both ScoreCube and VehicleEditor. Tooltip now consistently appears in
top right corner of the editor section.

* Add transparency support to vehicle part rendering

Apply mesh alpha values from WDB to Three.js materials. In the original
game, alpha=0 means opaque while alpha>0 enables transparency. Disable
depthWrite for transparent meshes to prevent z-fighting.

* Use MeshLambertMaterial for original game-like rendering

Switch from MeshStandardMaterial (PBR) to MeshLambertMaterial for flat,
vibrant colors matching the original game. Simplify lighting setup for
solid colors without visible shadows.

* Fix WDB texture parsing for parts vs models

Parts and models have different texture info formats - models include a
skipTextures field that parts don't have. Add isModel parameter to
parseTextureInfo to handle this difference correctly. Also remove silent
catch blocks and overly defensive checks.
2026-02-02 02:08:12 +01:00

529 lines
16 KiB
JavaScript

import { BinaryReader } from './BinaryReader.js';
/**
* Parser for LEGO Island WORLD.WDB files
* Based on wdb.ksy specification and source code analysis
*/
export class WdbParser {
constructor(buffer) {
this.reader = new BinaryReader(buffer);
this.buffer = buffer;
}
/**
* Parse the WDB file structure
* @returns {{ worlds: Array, globalTexturesSize: number, globalPartsSize: number }}
*/
parse() {
const numWorlds = this.reader.readS32();
const worlds = [];
for (let i = 0; i < numWorlds; i++) {
worlds.push(this.parseWorldEntry());
}
const globalTexturesSize = this.reader.readU32();
// Skip global textures for now - BIGCUBE.GIF is in model_data
this.reader.skip(globalTexturesSize);
const globalPartsSize = this.reader.readU32();
// Skip global parts
this.reader.skip(globalPartsSize);
return { worlds, globalTexturesSize, globalPartsSize };
}
parseWorldEntry() {
const nameLen = this.reader.readS32();
const name = this.reader.readString(nameLen).replace(/\0/g, '');
// Parse parts
const numParts = this.reader.readS32();
const parts = [];
for (let i = 0; i < numParts; i++) {
parts.push(this.parsePartReference());
}
// Parse models
const numModels = this.reader.readS32();
const models = [];
for (let i = 0; i < numModels; i++) {
models.push(this.parseModelEntry());
}
return { name, numParts, parts, models };
}
parsePartReference() {
const nameLen = this.reader.readU32();
const name = this.reader.readString(nameLen).replace(/\0/g, '');
const dataLength = this.reader.readU32();
const dataOffset = this.reader.readU32();
return { name, dataLength, dataOffset };
}
parseModelEntry() {
const nameLen = this.reader.readU32();
const name = this.reader.readString(nameLen).replace(/\0/g, '');
const dataLength = this.reader.readU32();
const dataOffset = this.reader.readU32();
const presenterLen = this.reader.readU32();
const presenter = this.reader.readString(presenterLen).replace(/\0/g, '');
const location = this.readVertex3();
const direction = this.readVertex3();
const up = this.readVertex3();
const visible = this.reader.readU8();
return { name, dataLength, dataOffset, presenter, location, direction, up, visible };
}
readVertex3() {
return {
x: this.reader.readF32(),
y: this.reader.readF32(),
z: this.reader.readF32()
};
}
/**
* Read string and strip null terminators
*/
readCleanString(length) {
return this.reader.readString(length).replace(/\0/g, '');
}
/**
* Parse part data blob at specified offset
* Parts have a simpler structure than models - no animation, direct LOD data
* @param {number} offset - Absolute file offset
* @returns {{ parts: Array, textures: Array }}
*/
parsePartData(offset) {
this.reader.seek(offset);
const textureInfoOffset = this.reader.readU32();
const numRois = this.reader.readU32();
const parts = [];
for (let i = 0; i < numRois; i++) {
const nameLen = this.reader.readU32();
const name = this.readCleanString(nameLen);
const numLods = this.reader.readU32();
const roiInfoOffset = this.reader.readU32();
const lods = [];
for (let j = 0; j < numLods; j++) {
lods.push(this.parseLod());
}
parts.push({ name, lods });
}
this.reader.seek(offset + textureInfoOffset);
const textures = this.parseTextureInfo();
return { parts, textures };
}
/**
* Parse model_data blob at specified offset
* @param {number} offset - Absolute file offset
* @returns {{ version: number, anim: object, roi: object, textures: Array }}
*/
parseModelData(offset) {
this.reader.seek(offset);
const version = this.reader.readU32();
if (version !== 19) {
throw new Error(`Unexpected model version: ${version}, expected 19`);
}
const textureInfoOffset = this.reader.readU32();
const numRois = this.reader.readU32();
// Parse animation data
const anim = this.parseModelAnim();
// Parse ROI hierarchy
const roi = this.parseRoi();
this.reader.seek(offset + textureInfoOffset);
const textures = this.parseTextureInfo(true); // Models have skipTextures field
return { version, anim, roi, textures };
}
parseModelAnim() {
const numActors = this.reader.readU32();
const actors = [];
for (let i = 0; i < numActors; i++) {
const nameLen = this.reader.readU32();
if (nameLen > 0) {
const name = this.readCleanString(nameLen);
const actorType = this.reader.readU32();
actors.push({ name, actorType });
}
}
const duration = this.reader.readS32();
const rootNode = this.parseAnimTreeNode();
return { actors, duration, rootNode };
}
parseAnimTreeNode() {
const data = this.parseAnimNodeData();
const numChildren = this.reader.readU32();
const children = [];
for (let i = 0; i < numChildren; i++) {
children.push(this.parseAnimTreeNode());
}
return { data, children };
}
parseAnimNodeData() {
const nameLen = this.reader.readU32();
const name = nameLen > 0 ? this.readCleanString(nameLen) : '';
// Translation keys
const numTranslationKeys = this.reader.readU16();
const translationKeys = [];
for (let i = 0; i < numTranslationKeys; i++) {
translationKeys.push(this.parseTranslationKey());
}
// Rotation keys
const numRotationKeys = this.reader.readU16();
const rotationKeys = [];
for (let i = 0; i < numRotationKeys; i++) {
rotationKeys.push(this.parseRotationKey());
}
// Scale keys
const numScaleKeys = this.reader.readU16();
const scaleKeys = [];
for (let i = 0; i < numScaleKeys; i++) {
scaleKeys.push(this.parseScaleKey());
}
// Morph keys
const numMorphKeys = this.reader.readU16();
const morphKeys = [];
for (let i = 0; i < numMorphKeys; i++) {
morphKeys.push(this.parseMorphKey());
}
return { name, translationKeys, rotationKeys, scaleKeys, morphKeys };
}
parseAnimKey() {
const timeAndFlags = this.reader.readS32();
const time = timeAndFlags & 0xFFFFFF;
const flags = (timeAndFlags >> 24) & 0xFF;
return { time, flags };
}
parseTranslationKey() {
const key = this.parseAnimKey();
const x = this.reader.readF32();
const y = this.reader.readF32();
const z = this.reader.readF32();
return { ...key, x, y, z };
}
parseRotationKey() {
const key = this.parseAnimKey();
const angle = this.reader.readF32(); // w component
const x = this.reader.readF32();
const y = this.reader.readF32();
const z = this.reader.readF32();
return { ...key, angle, x, y, z };
}
parseScaleKey() {
const key = this.parseAnimKey();
const x = this.reader.readF32();
const y = this.reader.readF32();
const z = this.reader.readF32();
return { ...key, x, y, z };
}
parseMorphKey() {
const key = this.parseAnimKey();
const visible = this.reader.readU8();
return { ...key, visible };
}
parseRoi() {
const nameLen = this.reader.readU32();
const name = this.reader.readString(nameLen).replace(/\0/g, '');
// Bounding sphere
const boundingSphere = {
center: this.readVertex3(),
radius: this.reader.readF32()
};
// Bounding box
const boundingBox = {
min: this.readVertex3(),
max: this.readVertex3()
};
// Texture name (for color/material reference)
const textureNameLen = this.reader.readU32();
const textureName = textureNameLen > 0 ? this.readCleanString(textureNameLen) : null;
// Shared LOD list flag
const sharedLodList = this.reader.readU8();
let lods = [];
if (sharedLodList === 0) {
const numLods = this.reader.readU32();
if (numLods > 0) {
const nextRoiOffset = this.reader.readU32();
for (let i = 0; i < numLods; i++) {
lods.push(this.parseLod());
}
}
}
// Children
const numChildren = this.reader.readU32();
const children = [];
for (let i = 0; i < numChildren; i++) {
children.push(this.parseRoi());
}
return { name, boundingSphere, boundingBox, textureName, sharedLodList: sharedLodList !== 0, lods, children };
}
parseLod() {
const flags = this.reader.readU32();
const numMeshes = this.reader.readU32();
if (numMeshes === 0) {
return { flags, numMeshes, vertices: [], normals: [], textureVertices: [], meshes: [] };
}
// Packed vertex/normal counts
const vertexNormalCounts = this.reader.readU32();
const vertexCount = vertexNormalCounts & 0xFFFF;
const normalCount = (vertexNormalCounts >> 17) & 0x7FFF;
const numTextureVertices = this.reader.readS32();
// Read vertices
const vertices = [];
for (let i = 0; i < vertexCount; i++) {
vertices.push(this.readVertex3());
}
// Read normals
const normals = [];
for (let i = 0; i < normalCount; i++) {
normals.push(this.readVertex3());
}
// Read texture vertices (UVs)
const textureVertices = [];
for (let i = 0; i < numTextureVertices; i++) {
textureVertices.push({
u: this.reader.readF32(),
v: this.reader.readF32()
});
}
// Read meshes
const meshes = [];
for (let i = 0; i < numMeshes; i++) {
meshes.push(this.parseMesh());
}
return { flags, numMeshes, vertexCount, normalCount, vertices, normals, textureVertices, meshes };
}
parseMesh() {
const numPolygons = this.reader.readU16();
const numVertices = this.reader.readU16();
// Polygon indices (vertex/normal pairs)
const polygonIndices = [];
for (let i = 0; i < numPolygons; i++) {
polygonIndices.push({
a: this.reader.readU32(),
b: this.reader.readU32(),
c: this.reader.readU32()
});
}
// Texture indices
const numTextureIndices = this.reader.readU32();
const textureIndices = [];
if (numTextureIndices > 0) {
for (let i = 0; i < numPolygons; i++) {
textureIndices.push({
a: this.reader.readU32(),
b: this.reader.readU32(),
c: this.reader.readU32()
});
}
}
// Mesh properties
const properties = this.parseMeshProperties();
return { numPolygons, numVertices, polygonIndices, textureIndices, properties };
}
parseMeshProperties() {
const color = {
r: this.reader.readU8(),
g: this.reader.readU8(),
b: this.reader.readU8()
};
const alpha = this.reader.readF32();
const shading = this.reader.readU8();
const unknown0x0d = this.reader.readU8();
const unknown0x20 = this.reader.readU8();
const useAlias = this.reader.readU8();
const textureNameLen = this.reader.readU32();
const textureName = textureNameLen > 0 ? this.readCleanString(textureNameLen) : null;
const materialNameLen = this.reader.readU32();
const materialName = materialNameLen > 0 ? this.readCleanString(materialNameLen) : null;
return { color, alpha, shading, useAlias, textureName, materialName };
}
/**
* Parse texture info block
* @param {boolean} isModel - If true, read skipTextures field (models have it, parts don't)
*/
parseTextureInfo(isModel = false) {
const numTextures = this.reader.readU32();
// Models have an extra skipTextures field that parts don't have
// See legomodelpresenter.cpp vs legopartpresenter.cpp in LEGO1 source
if (isModel) {
this.reader.readU32(); // skipTextures - skip over this field
}
const textures = [];
for (let i = 0; i < numTextures; i++) {
const nameLen = this.reader.readU32();
let name = this.readCleanString(nameLen).toLowerCase();
// Handle '^' prefix (hi-res/lo-res pair)
let hasHighRes = false;
if (name.startsWith('^')) {
name = name.substring(1);
hasHighRes = true;
// Read hi-res texture
const hiRes = this.parseLegoImage();
// Skip lo-res texture
this.skipLegoImage();
textures.push({ name, ...hiRes });
} else {
textures.push({ name, ...this.parseLegoImage() });
}
}
return textures;
}
parseLegoImage() {
const width = this.reader.readU32();
const height = this.reader.readU32();
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));
return { width, height, paletteSize, palette, pixels };
}
skipLegoImage() {
const width = this.reader.readU32();
const height = this.reader.readU32();
const paletteSize = this.reader.readU32();
this.reader.skip(paletteSize * 3); // palette
this.reader.skip(width * height); // pixels
}
}
/**
* Helper to find an ROI by name in a hierarchy
* @param {object} roi - Root ROI
* @param {string} name - Name to find (case-insensitive)
* @returns {object|null}
*/
export function findRoi(roi, name) {
if (roi.name.toLowerCase() === name.toLowerCase()) {
return roi;
}
for (const child of roi.children || []) {
const found = findRoi(child, name);
if (found) return found;
}
return null;
}
/**
* Resolve LODs for an ROI, handling shared LOD lists
* This mirrors how the game's ViewLODListManager resolves shared parts
* @param {object} roi - ROI data with lods and sharedLodList flag
* @param {Map} partsMap - Map of part name (lowercase) -> part data with lods
* @returns {Array} - Array of LODs (may be empty)
*/
export function resolveLods(roi, partsMap) {
// If ROI has its own LODs, use them
if (roi.lods && roi.lods.length > 0) {
return roi.lods;
}
// If ROI uses shared LOD list, look up by name (strip trailing digits)
// This matches the game's logic in LegoROI::Read
if (roi.sharedLodList && roi.name && partsMap) {
const baseName = roi.name.replace(/\d+$/, '').toLowerCase();
const part = partsMap.get(baseName);
if (part && part.lods && part.lods.length > 0) {
return part.lods;
}
}
return [];
}
/**
* Build a parts lookup map from a world's parts array
* @param {WdbParser} parser - Parser instance for reading part data
* @param {Array} worldParts - Array of part references from world entry
* @returns {Map} - Map of part name (lowercase) -> part data
*/
export function buildPartsMap(parser, worldParts) {
const partsMap = new Map();
if (!worldParts || worldParts.length === 0) return partsMap;
for (const partRef of worldParts) {
const partData = parser.parsePartData(partRef.dataOffset);
for (const part of partData.parts) {
partsMap.set(part.name.toLowerCase(), part);
}
}
return partsMap;
}