import type { DungeonDefinition, DungeonTransform, Vector3Tuple } from "./dungeonTypes"; import { RUNEWAKER_NATIVE_LIGHTING_SOURCE } from "./generated/runewakerNativeLighting.generated"; type Vector = readonly [number, number, number]; type Quaternion = readonly [number, number, number, number]; interface NativeColor { readonly argb: string; /** Rune Engine's direct ARGB byte-to-float conversion; values are linear. */ readonly rgba: readonly [number, number, number, number]; } interface NativeZoneSettings { readonly timeGroup: number; readonly weatherGroup: number; readonly primary: NativeColor; readonly secondary: NativeColor; readonly ambient: NativeColor; readonly specular: NativeColor; readonly fogColor: NativeColor; readonly fog: { readonly constant: number; readonly start: number; readonly end: number }; readonly standardAnglesRadians: Vector; readonly specularAnglesRadians: Vector; readonly nativeDirectionsLeftHandedYUp: { readonly primary: Vector; readonly secondary0: Vector; readonly secondary1: Vector; readonly specular: Vector; }; readonly weatherSystem: string; readonly skyGradientTexture: string; readonly customSkyBox: string; readonly cloudCover: number; readonly cloudSharpness: number; readonly cloudColors: readonly [NativeColor, NativeColor]; } interface NativeZone { readonly id: number; readonly parentId: number; readonly name: string; readonly zoneType: number; readonly settings: readonly NativeZoneSettings[]; } interface NativeZoneTrigger { readonly triggerType: number; readonly zoneId: number; readonly zoneType: number; readonly priority: number; readonly obb: { readonly center: Vector; readonly axes: readonly [Vector, Vector, Vector]; readonly halfExtents: Vector; }; } interface NativeDungeonLightingData { readonly source: { readonly wdb: string; readonly wdbSha256: string; readonly coordinateSystem: string; readonly metersPerSourceUnit: number; readonly primaryPlacement: { readonly scale: Vector; readonly rotation: Quaternion; readonly translation: Vector }; }; readonly zones: readonly NativeZone[]; readonly zoneTriggers: readonly NativeZoneTrigger[]; } const SOURCE = RUNEWAKER_NATIVE_LIGHTING_SOURCE as unknown as { readonly runtimeDefaults: { readonly timeGroup: number; readonly weatherGroup: number }; readonly dungeons: Readonly>; }; export interface RunewakerLightingOptions { readonly timeGroup?: number; readonly weatherGroup?: number; /** Three.js world-space camera far distance, in runtime units/meters. */ readonly cameraFarDistance?: number; } export interface ResolvedRunewakerDirectionalLight { readonly role: "primary" | "secondary-0" | "secondary-1"; readonly colorArgb: string; readonly colorLinear: Vector; readonly intensity: number; /** Adapted source `m_direction`, which points in the light-ray direction. */ readonly rayDirection: Vector; /** Opposite of Rune's stored direction; Rune's SH code uses this convention. */ readonly toLightDirection: Vector; } export interface ResolvedRunewakerLighting { readonly dungeonId: string; readonly sourceWdb: string; readonly sourceWdbSha256: string; readonly sourceCoordinateSystem: string; readonly metersPerSourceUnit: number; readonly sourcePosition: Vector; readonly triggerZone: { readonly id: number; readonly name: string; readonly priority: number; readonly verticalHitDistanceSourceUnits: number; }; readonly settingsZone: { readonly id: number; readonly name: string }; readonly timeGroup: number; readonly weatherGroup: number; readonly ambient: { readonly colorArgb: string; readonly colorLinear: Vector; readonly intensity: 1 }; readonly directionalLights: readonly [ ResolvedRunewakerDirectionalLight, ResolvedRunewakerDirectionalLight, ResolvedRunewakerDirectionalLight, ]; readonly specular: { readonly colorArgb: string; readonly colorLinear: Vector; readonly direction: Vector }; readonly fog: { readonly colorArgb: string; readonly colorLinear: Vector; readonly constant: number; readonly startFactor: number; readonly endFactor: number; readonly startDistance?: number; readonly endDistance?: number; }; readonly sky: { readonly weatherSystem: string; readonly skyGradientTexture: string; readonly customSkyBox: string; readonly cloudCover: number; readonly cloudSharpness: number; readonly cloudColorsLinear: readonly [Vector, Vector]; }; } interface ZoneSelection { readonly trigger: NativeZoneTrigger; readonly boundaryDistance: number; readonly triggerZone: NativeZone; readonly settingsZone: NativeZone; readonly settings: NativeZoneSettings; readonly timeGroup: number; readonly weatherGroup: number; } function add(a: Vector, b: Vector): Vector { return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; } function subtract(a: Vector, b: Vector): Vector { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; } function multiply(a: Vector, b: Vector): Vector { return [a[0] * b[0], a[1] * b[1], a[2] * b[2]]; } function divide(a: Vector, b: Vector): Vector | null { if (b.some((component) => Math.abs(component) < 1e-12)) return null; return [a[0] / b[0], a[1] / b[1], a[2] / b[2]]; } function dot(a: Vector, b: Vector): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } function normalize(value: Vector): Vector { const length = Math.hypot(value[0], value[1], value[2]); if (length < 1e-12) return [0, 0, 0]; return [value[0] / length, value[1] / length, value[2] / length]; } function conjugate(q: Quaternion): Quaternion { return [-q[0], -q[1], -q[2], q[3]]; } function multiplyQuaternions(a: Quaternion, b: Quaternion): Quaternion { const [ax, ay, az, aw] = a; const [bx, by, bz, bw] = b; return [ ax * bw + aw * bx + ay * bz - az * by, ay * bw + aw * by + az * bx - ax * bz, az * bw + aw * bz + ax * by - ay * bx, aw * bw - ax * bx - ay * by - az * bz, ]; } function rotateByQuaternion(value: Vector, quaternion: Quaternion): Vector { const length = Math.hypot(quaternion[0], quaternion[1], quaternion[2], quaternion[3]); if (length < 1e-12) return value; const normalized: Quaternion = quaternion.map((component) => component / length) as unknown as Quaternion; const qv: Quaternion = [value[0], value[1], value[2], 0]; const result = multiplyQuaternions(multiplyQuaternions(normalized, qv), conjugate(normalized)); return [result[0], result[1], result[2]]; } function quaternionFromEulerXYZ(rotation: Vector3Tuple): Quaternion { const [x, y, z] = rotation; const c1 = Math.cos(x / 2); const c2 = Math.cos(y / 2); const c3 = Math.cos(z / 2); const s1 = Math.sin(x / 2); const s2 = Math.sin(y / 2); const s3 = Math.sin(z / 2); return [ s1 * c2 * c3 + c1 * s2 * s3, c1 * s2 * c3 - s1 * c2 * s3, c1 * c2 * s3 + s1 * s2 * c3, c1 * c2 * c3 - s1 * s2 * s3, ]; } function pointFromRuntimeWorldToDungeonLocal(transform: DungeonTransform, point: Vector): Vector | null { if (Math.abs(transform.scale) < 1e-12) return null; const inverseRotation = conjugate(quaternionFromEulerXYZ(transform.rotation)); return divide(rotateByQuaternion(subtract(point, transform.position), inverseRotation), [transform.scale, transform.scale, transform.scale]); } function pointFromDungeonLocalToSource(data: NativeDungeonLightingData, point: Vector): Vector | null { const sourceLocal = divide([point[0], point[1], -point[2]], [ data.source.metersPerSourceUnit, data.source.metersPerSourceUnit, data.source.metersPerSourceUnit, ]); if (!sourceLocal) return null; const scaled = multiply(sourceLocal, data.source.primaryPlacement.scale); return add(rotateByQuaternion(scaled, data.source.primaryPlacement.rotation), data.source.primaryPlacement.translation); } function sourceDirectionToRuntime(data: NativeDungeonLightingData, transform: DungeonTransform, direction: Vector): Vector | null { const placementLocal = rotateByQuaternion(direction, conjugate(data.source.primaryPlacement.rotation)); const unscaled = divide(placementLocal, data.source.primaryPlacement.scale); if (!unscaled) return null; const localThree: Vector = [unscaled[0], unscaled[1], -unscaled[2]]; const runtimeDirection = rotateByQuaternion( [localThree[0] * transform.scale, localThree[1] * transform.scale, localThree[2] * transform.scale], quaternionFromEulerXYZ(transform.rotation), ); return normalize(runtimeDirection); } function segmentObbEntry(point: Vector, delta: Vector, trigger: NativeZoneTrigger): number | null { let entry = 0; let exit = 1; const relative = subtract(point, trigger.obb.center); for (let axisIndex = 0; axisIndex < 3; axisIndex += 1) { const axis = trigger.obb.axes[axisIndex]; const originProjection = dot(relative, axis); const deltaProjection = dot(delta, axis); const extent = trigger.obb.halfExtents[axisIndex]; if (Math.abs(deltaProjection) < 1e-12) { if (originProjection < -extent || originProjection > extent) return null; continue; } let t0 = (-extent - originProjection) / deltaProjection; let t1 = (extent - originProjection) / deltaProjection; if (t0 > t1) [t0, t1] = [t1, t0]; entry = Math.max(entry, t0); exit = Math.min(exit, t1); if (entry > exit) return null; } return entry; } function verticalTriggerDistance(point: Vector, trigger: NativeZoneTrigger): number | null { const down = segmentObbEntry(point, [0, -10000, 0], trigger); const up = segmentObbEntry(point, [0, 10000, 0], trigger); if (down == null) return up; if (up == null) return down; return Math.min(down, up); } function isDescendant(zones: readonly NativeZone[], candidateId: number, ancestorId: number): boolean { const byId = new Map(zones.map((zone) => [zone.id, zone])); let zone = byId.get(candidateId); while (zone && zone.parentId !== -1) { if (zone.parentId === ancestorId) return true; zone = byId.get(zone.parentId); } return false; } function selectSettings(zone: NativeZone, timeGroup: number, weatherGroup: number): { settings: NativeZoneSettings; timeGroup: number; weatherGroup: number } | null { const exact = zone.settings.find((entry) => entry.timeGroup === timeGroup && entry.weatherGroup === weatherGroup); if (exact) return { settings: exact, timeGroup: exact.timeGroup, weatherGroup: exact.weatherGroup }; const sameTime = zone.settings.find((entry) => entry.timeGroup === timeGroup); if (sameTime) return { settings: sameTime, timeGroup: sameTime.timeGroup, weatherGroup: sameTime.weatherGroup }; const any = zone.settings[0]; return any ? { settings: any, timeGroup: any.timeGroup, weatherGroup: any.weatherGroup } : null; } function selectZoneSettings( data: NativeDungeonLightingData, sourcePosition: Vector, requestedTimeGroup: number, requestedWeatherGroup: number, ): ZoneSelection | null { let selectedTrigger: NativeZoneTrigger | null = null; let selectedDistance = Number.POSITIVE_INFINITY; // The native selector initializes its signed priority to 0xFFFFFFFF (-1). let selectedPriority = -1; for (const trigger of data.zoneTriggers) { const distance = verticalTriggerDistance(sourcePosition, trigger); if (distance == null) continue; const prefer = trigger.priority > selectedPriority || (trigger.priority === selectedPriority && distance < selectedDistance) || (trigger.priority === selectedPriority && distance === selectedDistance && selectedTrigger != null && isDescendant(data.zones, trigger.zoneId, selectedTrigger.zoneId)); if (prefer) { selectedTrigger = trigger; selectedDistance = distance; selectedPriority = trigger.priority; } } if (!selectedTrigger) return null; const triggerZone = data.zones.find((zone) => zone.id === selectedTrigger!.zoneId); if (!triggerZone) return null; let settingsZone: NativeZone | undefined = triggerZone; let chosen: ReturnType = null; while (settingsZone) { chosen = selectSettings(settingsZone, requestedTimeGroup, requestedWeatherGroup); if (chosen) break; settingsZone = data.zones.find((zone) => zone.id === settingsZone!.parentId); } if (!settingsZone || !chosen) return null; return { trigger: selectedTrigger, boundaryDistance: selectedDistance, triggerZone, settingsZone, settings: chosen.settings, timeGroup: chosen.timeGroup, weatherGroup: chosen.weatherGroup, }; } /** Convert a Three.js world point back to the source WDB world coordinates. */ export function runewakerRuntimePositionToSource( definition: Pick, runtimeWorldPosition: Vector3Tuple, ): Vector | null { const data = SOURCE.dungeons[definition.id]; if (!data) return null; const local = pointFromRuntimeWorldToDungeonLocal(definition.transform, runtimeWorldPosition); return local ? pointFromDungeonLocalToSource(data, local) : null; } /** Convert a source WDB point to the current dungeon's Three.js world space. */ export function runewakerSourcePositionToRuntime( definition: Pick, sourcePosition: Vector3Tuple, ): Vector | null { const data = SOURCE.dungeons[definition.id]; if (!data) return null; const sourceDelta = subtract(sourcePosition, data.source.primaryPlacement.translation); const inversePrimaryRotation = conjugate(data.source.primaryPlacement.rotation); const localSource = rotateByQuaternion(sourceDelta, inversePrimaryRotation); const scaledLocal = divide(localSource, data.source.primaryPlacement.scale); if (!scaledLocal) return null; const localThree: Vector = [scaledLocal[0] * data.source.metersPerSourceUnit, scaledLocal[1] * data.source.metersPerSourceUnit, -scaledLocal[2] * data.source.metersPerSourceUnit]; return add( rotateByQuaternion( [localThree[0] * definition.transform.scale, localThree[1] * definition.transform.scale, localThree[2] * definition.transform.scale], quaternionFromEulerXYZ(definition.transform.rotation), ), definition.transform.position, ); } /** Resolve source zone lighting at a runtime world position; null keeps the authored profile. */ export function resolveRunewakerNativeLighting( definition: Pick, runtimeWorldPosition: Vector3Tuple, options: RunewakerLightingOptions = {}, ): ResolvedRunewakerLighting | null { const data = SOURCE.dungeons[definition.id]; if (!data) return null; const sourcePosition = runewakerRuntimePositionToSource(definition, runtimeWorldPosition); if (!sourcePosition) return null; const selection = selectZoneSettings( data, sourcePosition, options.timeGroup ?? SOURCE.runtimeDefaults.timeGroup, options.weatherGroup ?? SOURCE.runtimeDefaults.weatherGroup, ); if (!selection) return null; const settings = selection.settings; const rayDirection = (sourceDirection: Vector): Vector => sourceDirectionToRuntime(data, definition.transform, sourceDirection) ?? [0, 0, 0]; const far = options.cameraFarDistance; const fogScale = settings.fog.end > 1 ? 1 / 1600 : 1; const startFactor = settings.fog.start * fogScale; const endFactor = settings.fog.end * fogScale; const triggerDistance = selection.boundaryDistance * 10000; return { dungeonId: definition.id, sourceWdb: data.source.wdb, sourceWdbSha256: data.source.wdbSha256, sourceCoordinateSystem: data.source.coordinateSystem, metersPerSourceUnit: data.source.metersPerSourceUnit, sourcePosition, triggerZone: { id: selection.triggerZone.id, name: selection.triggerZone.name, priority: selection.trigger.priority, verticalHitDistanceSourceUnits: triggerDistance, }, settingsZone: { id: selection.settingsZone.id, name: selection.settingsZone.name }, timeGroup: selection.timeGroup, weatherGroup: selection.weatherGroup, ambient: { colorArgb: settings.ambient.argb, colorLinear: settings.ambient.rgba.slice(0, 3) as unknown as Vector, intensity: 1 }, directionalLights: [ { role: "primary", colorArgb: settings.primary.argb, colorLinear: settings.primary.rgba.slice(0, 3) as unknown as Vector, intensity: 1, rayDirection: rayDirection(settings.nativeDirectionsLeftHandedYUp.primary), toLightDirection: scaleVector(rayDirection(settings.nativeDirectionsLeftHandedYUp.primary), -1), }, { role: "secondary-0", colorArgb: settings.secondary.argb, colorLinear: settings.secondary.rgba.slice(0, 3) as unknown as Vector, intensity: 0.8, rayDirection: rayDirection(settings.nativeDirectionsLeftHandedYUp.secondary0), toLightDirection: scaleVector(rayDirection(settings.nativeDirectionsLeftHandedYUp.secondary0), -1), }, { role: "secondary-1", colorArgb: settings.secondary.argb, colorLinear: settings.secondary.rgba.slice(0, 3) as unknown as Vector, intensity: 0.8, rayDirection: rayDirection(settings.nativeDirectionsLeftHandedYUp.secondary1), toLightDirection: scaleVector(rayDirection(settings.nativeDirectionsLeftHandedYUp.secondary1), -1), }, ], specular: { colorArgb: settings.specular.argb, colorLinear: settings.specular.rgba.slice(0, 3) as unknown as Vector, direction: rayDirection(settings.nativeDirectionsLeftHandedYUp.specular), }, fog: { colorArgb: settings.fogColor.argb, colorLinear: settings.fogColor.rgba.slice(0, 3) as unknown as Vector, constant: settings.fog.constant, startFactor, endFactor, ...(far != null ? { startDistance: startFactor * far, endDistance: endFactor * far } : {}), }, sky: { weatherSystem: settings.weatherSystem, skyGradientTexture: settings.skyGradientTexture, customSkyBox: settings.customSkyBox, cloudCover: settings.cloudCover, cloudSharpness: settings.cloudSharpness, cloudColorsLinear: [ settings.cloudColors[0].rgba.slice(0, 3) as unknown as Vector, settings.cloudColors[1].rgba.slice(0, 3) as unknown as Vector, ], }, }; } function scaleVector(vector: Vector, scalar: number): Vector { return [vector[0] * scalar, vector[1] * scalar, vector[2] * scalar]; } /** Source map for generated-data integrity checks; consumers should use the resolver. */ export const RUNEWAKER_NATIVE_LIGHTING_DUNGEON_IDS = Object.freeze(Object.keys(SOURCE.dungeons));