add TorqueScript transpiler and runtime

This commit is contained in:
Brian Beck 2025-11-30 11:44:47 -08:00
parent c8391a1056
commit 7d10fb7dee
49 changed files with 12324 additions and 2075 deletions

View file

@ -1,7 +1,8 @@
import { memo, useEffect, useRef } from "react";
import { useThree, useFrame } from "@react-three/fiber";
import { PositionalAudio, Vector3 } from "three";
import { ConsoleObject, getPosition, getProperty } from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty } from "../mission";
import { audioToUrl } from "../loaders";
import { useAudio } from "./AudioContext";
import { useDebug, useSettings } from "./SettingsProvider";
@ -35,24 +36,16 @@ function getCachedAudioBuffer(
export const AudioEmitter = memo(function AudioEmitter({
object,
}: {
object: ConsoleObject;
object: TorqueObject;
}) {
const { debugMode } = useDebug();
const fileName = getProperty(object, "fileName")?.value ?? "";
const volume = parseFloat(getProperty(object, "volume")?.value ?? "1");
const minDistance = parseFloat(
getProperty(object, "minDistance")?.value ?? "1",
);
const maxDistance = parseFloat(
getProperty(object, "maxDistance")?.value ?? "1",
);
const minLoopGap = parseFloat(
getProperty(object, "minLoopGap")?.value ?? "0",
);
const maxLoopGap = parseFloat(
getProperty(object, "maxLoopGap")?.value ?? "0",
);
const is3D = parseInt(getProperty(object, "is3D")?.value ?? "0");
const fileName = getProperty(object, "fileName") ?? "";
const volume = getProperty(object, "volume") ?? 1;
const minDistance = getProperty(object, "minDistance") ?? 1;
const maxDistance = getProperty(object, "maxDistance") ?? 1;
const minLoopGap = getProperty(object, "minLoopGap") ?? 0;
const maxLoopGap = getProperty(object, "maxLoopGap") ?? 0;
const is3D = getProperty(object, "is3D") ?? 0;
const [x, y, z] = getPosition(object);
const { scene, camera } = useThree();

View file

@ -1,20 +1,14 @@
import { useEffect, useId, useMemo, useRef } from "react";
import { PerspectiveCamera } from "@react-three/drei";
import { useEffect, useId, useMemo } from "react";
import { useCameras } from "./CamerasProvider";
import { useSettings } from "./SettingsProvider";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
} from "../mission";
import { Quaternion, Vector3 } from "three";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation } from "../mission";
import { Vector3 } from "three";
export function Camera({ object }: { object: ConsoleObject }) {
export function Camera({ object }: { object: TorqueObject }) {
const { registerCamera, unregisterCamera } = useCameras();
const id = useId();
const dataBlock = getProperty(object, "dataBlock").value;
const dataBlock = getProperty(object, "dataBlock");
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);

View file

@ -6,7 +6,6 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";

View file

@ -72,8 +72,6 @@ export function DebugPlaceholder({ color }: { color: string }) {
return debugMode ? <ShapePlaceholder color={color} /> : null;
}
export type StaticShapeType = "StaticShape" | "TSStatic" | "Item" | "Turret";
export const ShapeModel = memo(function ShapeModel() {
const { shapeName } = useShapeInfo();
const { debugMode } = useDebug();

View file

@ -3,13 +3,8 @@ import { ErrorBoundary } from "react-error-boundary";
import { Mesh } from "three";
import { useGLTF, useTexture } from "@react-three/drei";
import { BASE_URL, interiorTextureToUrl, interiorToUrl } from "../loaders";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { setupColor } from "../textureUtils";
import { FloatingLabel } from "./FloatingLabel";
import { useDebug } from "./SettingsProvider";
@ -93,9 +88,9 @@ function DebugInteriorPlaceholder() {
export const InteriorInstance = memo(function InteriorInstance({
object,
}: {
object: ConsoleObject;
object: TorqueObject;
}) {
const interiorFile = getProperty(object, "interiorFile").value;
const interiorFile = getProperty(object, "interiorFile");
const position = useMemo(() => getPosition(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
const q = useMemo(() => getRotation(object), [object]);

View file

@ -1,12 +1,7 @@
import { Suspense, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import { useSimGroup } from "./SimGroup";
@ -61,9 +56,9 @@ const TEAM_NAMES = {
2: "Inferno",
};
export function Item({ object }: { object: ConsoleObject }) {
export function Item({ object }: { object: TorqueObject }) {
const simGroup = useSimGroup();
const dataBlock = getProperty(object, "dataBlock").value;
const dataBlock = getProperty(object, "dataBlock") ?? "";
const position = useMemo(() => getPosition(object), [object]);
const scale = useMemo(() => getScale(object), [object]);

View file

@ -1,21 +1,74 @@
import { useQuery } from "@tanstack/react-query";
import { loadMission } from "../loaders";
import {
executeMission,
type ParsedMission,
type ExecutedMission,
} from "../mission";
import { createScriptLoader } from "../torqueScript/scriptLoader.browser";
import { renderObject } from "./renderObject";
import { memo } from "react";
import { memo, useEffect, useState } from "react";
function useMission(name: string) {
const loadScript = createScriptLoader();
function useParsedMission(name: string) {
return useQuery({
queryKey: ["mission", name],
queryKey: ["parsedMission", name],
queryFn: () => loadMission(name),
});
}
export const Mission = memo(function Mission({ name }: { name: string }) {
const { data: mission } = useMission(name);
function useExecutedMission(parsedMission: ParsedMission | undefined) {
const [executedMission, setExecutedMission] = useState<
ExecutedMission | undefined
>();
if (!mission) {
useEffect(() => {
if (!parsedMission) {
setExecutedMission(undefined);
return;
}
// Clear previous mission immediately to avoid rendering with destroyed runtime
setExecutedMission(undefined);
let cancelled = false;
let result: ExecutedMission | undefined;
async function run() {
try {
const executed = await executeMission(parsedMission, { loadScript });
if (cancelled) {
executed.runtime.destroy();
} else {
result = executed;
setExecutedMission(executed);
}
} catch (error) {
if (!cancelled) {
console.error("Failed to execute mission:", error);
}
}
}
run();
return () => {
cancelled = true;
result?.runtime.destroy();
};
}, [parsedMission]);
return executedMission;
}
export const Mission = memo(function Mission({ name }: { name: string }) {
const { data: parsedMission } = useParsedMission(name);
const executedMission = useExecutedMission(parsedMission);
if (!executedMission) {
return null;
}
return mission.objects.map((object, i) => renderObject(object, i));
return executedMission.objects.map((object, i) => renderObject(object, i));
});

View file

@ -1,9 +1,9 @@
import { createContext, useContext, useMemo } from "react";
import { ConsoleObject } from "../mission";
import type { TorqueObject } from "../torqueScript";
import { renderObject } from "./renderObject";
export type SimGroupContextType = {
object: ConsoleObject;
object: TorqueObject;
parent: SimGroupContextType;
hasTeams: boolean;
team: null | number;
@ -15,7 +15,7 @@ export function useSimGroup() {
return useContext(SimGroupContext);
}
export function SimGroup({ object }: { object: ConsoleObject }) {
export function SimGroup({ object }: { object: TorqueObject }) {
const parent = useSimGroup();
const simGroup: SimGroupContextType = useMemo(() => {
@ -26,12 +26,14 @@ export function SimGroup({ object }: { object: ConsoleObject }) {
hasTeams = true;
if (parent.team != null) {
team = parent.team;
} else if (object.instanceName) {
const match = object.instanceName.match(/^team(\d+)$/i);
team = parseInt(match[1], 10);
} else if (object._name) {
const match = object._name.match(/^team(\d+)$/i);
if (match) {
team = parseInt(match[1], 10);
}
}
} else if (object.instanceName) {
hasTeams = object.instanceName.toLowerCase() === "teams";
} else if (object._name) {
hasTeams = object._name.toLowerCase() === "teams";
}
return {
@ -49,7 +51,7 @@ export function SimGroup({ object }: { object: ConsoleObject }) {
return (
<SimGroupContext.Provider value={simGroup}>
{object.children.map((child, i) => renderObject(child, i))}
{(object._children ?? []).map((child, i) => renderObject(child, i))}
</SimGroupContext.Provider>
);
}

View file

@ -2,7 +2,8 @@ import { Suspense, useMemo, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useCubeTexture } from "@react-three/drei";
import { Color, ShaderMaterial, BackSide, Euler } from "three";
import { ConsoleObject, getProperty, getRotation } from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getProperty } from "../mission";
import { useSettings } from "./SettingsProvider";
import { BASE_URL, getUrlForPath, loadDetailMapList } from "../loaders";
import { useThree } from "@react-three/fiber";
@ -139,27 +140,27 @@ export function SkyBox({
);
}
export function Sky({ object }: { object: ConsoleObject }) {
export function Sky({ object }: { object: TorqueObject }) {
const { fogEnabled } = useSettings();
// Skybox textures.
const materialList = getProperty(object, "materialList")?.value;
const materialList = getProperty(object, "materialList");
// Fog parameters.
// TODO: There can be multiple fog volumes/layers. Render simple fog for now.
const fogDistance = useMemo(() => {
const distanceString = getProperty(object, "fogDistance")?.value;
if (distanceString) {
return parseFloat(distanceString);
}
return getProperty(object, "fogDistance");
}, [object]);
const fogColor = useMemo(() => {
const colorString = getProperty(object, "fogColor")?.value;
const colorString = getProperty(object, "fogColor");
if (colorString) {
// `colorString` might specify an alpha value, but three.js doesn't
// support opacity on fog or scene backgrounds, so ignore it.
const [r, g, b] = colorString.split(" ").map((s) => parseFloat(s));
// Note: This is a space-separated string, so we split and parse each component.
const [r, g, b] = colorString
.split(" ")
.map((s: string) => parseFloat(s));
return [
new Color().setRGB(r, g, b),
new Color().setRGB(r, g, b).convertSRGBToLinear(),

View file

@ -1,12 +1,7 @@
import { Suspense, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
@ -20,6 +15,8 @@ const dataBlockToShapeName = {
GeneratorLarge: "station_generator_large.dts",
InteriorFlagStand: "int_flagstand.dts",
LightMaleHuman_Dead: "light_male_dead.dts",
MediumMaleHuman_Dead: "medium_male_dead.dts",
HeavyMaleHuman_Dead: "heavy_male_dead.dts",
LogoProjector: "teamlogo_projector.dts",
SensorLargePulse: "sensor_pulse_large.dts",
SensorMediumPulse: "sensor_pulse_medium.dts",
@ -44,8 +41,8 @@ function getDataBlockShape(dataBlock: string) {
return _caseInsensitiveLookup[dataBlock.toLowerCase()];
}
export function StaticShape({ object }: { object: ConsoleObject }) {
const dataBlock = getProperty(object, "dataBlock").value;
export function StaticShape({ object }: { object: TorqueObject }) {
const dataBlock = getProperty(object, "dataBlock") ?? "";
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);

View file

@ -1,25 +1,29 @@
import { useMemo } from "react";
import { Color } from "three";
import { ConsoleObject, getProperty } from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getProperty } from "../mission";
export function Sun({ object }: { object: ConsoleObject }) {
export function Sun({ object }: { object: TorqueObject }) {
const direction = useMemo(() => {
const directionStr = getProperty(object, "direction")?.value ?? "0 0 -1";
const [x, y, z] = directionStr.split(" ").map((s) => parseFloat(s));
const directionStr = getProperty(object, "direction") ?? "0 0 -1";
// Note: This is a space-separated string, so we split and parse each component.
const [x, y, z] = directionStr.split(" ").map((s: string) => parseFloat(s));
// Scale the direction vector to position the light far from the scene
const scale = 5000;
return [x * scale, y * scale, z * scale] as [number, number, number];
}, [object]);
const color = useMemo(() => {
const colorStr = getProperty(object, "color")?.value ?? "1 1 1 1";
const [r, g, b] = colorStr.split(" ").map((s) => parseFloat(s));
const colorStr = getProperty(object, "color") ?? "1 1 1 1";
// Note: This is a space-separated string, so we split and parse each component.
const [r, g, b] = colorStr.split(" ").map((s: string) => parseFloat(s));
return [r, g, b] as [number, number, number];
}, [object]);
const ambient = useMemo(() => {
const ambientStr = getProperty(object, "ambient")?.value ?? "0.5 0.5 0.5 1";
const [r, g, b] = ambientStr.split(" ").map((s) => parseFloat(s));
const ambientStr = getProperty(object, "ambient") ?? "0.5 0.5 0.5 1";
// Note: This is a space-separated string, so we split and parse each component.
const [r, g, b] = ambientStr.split(" ").map((s: string) => parseFloat(s));
return [r, g, b] as [number, number, number];
}, [object]);

View file

@ -1,17 +1,12 @@
import { Suspense, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
export function TSStatic({ object }: { object: ConsoleObject }) {
const shapeName = getProperty(object, "shapeName").value;
export function TSStatic({ object }: { object: TorqueObject }) {
const shapeName = getProperty(object, "shapeName");
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);

View file

@ -15,13 +15,8 @@ import {
import { useTexture } from "@react-three/drei";
import { uint16ToFloat32 } from "../arrayUtils";
import { loadTerrain, terrainTextureToUrl } from "../loaders";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import {
setupColor,
setupMask,
@ -204,28 +199,19 @@ function TerrainMaterial({
export const TerrainBlock = memo(function TerrainBlock({
object,
}: {
object: ConsoleObject;
object: TorqueObject;
}) {
const terrainFile: string = getProperty(object, "terrainFile").value;
const terrainFile = getProperty(object, "terrainFile");
const squareSize = useMemo(() => {
const squareSizeString: string | undefined = getProperty(
object,
"squareSize",
)?.value;
return squareSizeString
? parseInt(squareSizeString, 10)
: DEFAULT_SQUARE_SIZE;
return getProperty(object, "squareSize") ?? DEFAULT_SQUARE_SIZE;
}, [object]);
const emptySquares: number[] = useMemo(() => {
const emptySquaresString: string | undefined = getProperty(
object,
"emptySquares",
)?.value;
return emptySquaresString
? emptySquaresString.split(" ").map((s) => parseInt(s, 10))
const emptySquaresValue = getProperty(object, "emptySquares");
// Note: This is a space-separated string, so we split and parse each component.
return emptySquaresValue
? emptySquaresValue.split(" ").map((s: string) => parseInt(s, 10))
: [];
}, [object]);

View file

@ -1,12 +1,7 @@
import { Suspense, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
@ -34,9 +29,9 @@ function getDataBlockShape(dataBlock: string) {
return _caseInsensitiveLookup[dataBlock.toLowerCase()];
}
export function Turret({ object }: { object: ConsoleObject }) {
const dataBlock = getProperty(object, "dataBlock").value;
const initialBarrel = getProperty(object, "initialBarrel")?.value;
export function Turret({ object }: { object: TorqueObject }) {
const dataBlock = getProperty(object, "dataBlock") ?? "";
const initialBarrel = getProperty(object, "initialBarrel");
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);

View file

@ -2,13 +2,8 @@ import { memo, Suspense, useEffect, useMemo } from "react";
import { useTexture } from "@react-three/drei";
import { BoxGeometry, DoubleSide } from "three";
import { textureToUrl } from "../loaders";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { setupColor } from "../textureUtils";
export function WaterMaterial({
@ -35,14 +30,14 @@ export function WaterMaterial({
export const WaterBlock = memo(function WaterBlock({
object,
}: {
object: ConsoleObject;
object: TorqueObject;
}) {
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);
const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(object), [object]);
const surfaceTexture =
getProperty(object, "surfaceTexture")?.value ?? "liquidTiles/BlueWater";
getProperty(object, "surfaceTexture") ?? "liquidTiles/BlueWater";
const geometry = useMemo(() => {
const geom = new BoxGeometry(scaleX, scaleY, scaleZ);

View file

@ -1,12 +1,13 @@
import { useMemo } from "react";
import { ConsoleObject, getPosition, getProperty } from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty } from "../mission";
import { FloatingLabel } from "./FloatingLabel";
import { useSimGroup } from "./SimGroup";
export function WayPoint({ object }: { object: ConsoleObject }) {
export function WayPoint({ object }: { object: TorqueObject }) {
const simGroup = useSimGroup();
const position = useMemo(() => getPosition(object), [object]);
const label = getProperty(object, "name")?.value;
const label = getProperty(object, "name");
return label ? (
<FloatingLabel position={position} opacity={0.6}>

View file

@ -1,4 +1,4 @@
import { ConsoleObject } from "../mission";
import type { TorqueObject } from "../torqueScript";
import { TerrainBlock } from "./TerrainBlock";
import { WaterBlock } from "./WaterBlock";
import { SimGroup } from "./SimGroup";
@ -29,7 +29,7 @@ const componentMap = {
WayPoint,
};
export function renderObject(object: ConsoleObject, key: string | number) {
const Component = componentMap[object.className];
export function renderObject(object: TorqueObject, key: string | number) {
const Component = componentMap[object._className];
return Component ? <Component key={key} object={object} /> : null;
}

30
src/fileUtils.ts Normal file
View file

@ -0,0 +1,30 @@
import fs from "node:fs/promises";
import type { Dirent } from "node:fs";
import path from "node:path";
export async function walkDirectory(
dir: string,
{
onFile,
onDir = () => true,
}: {
onFile?: (fileInfo: { entry: Dirent<string> }) => void | Promise<void>;
onDir?: (dirInfo: { entry: Dirent<string> }) => boolean | Promise<boolean>;
},
): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const shouldRecurse = onDir ? await onDir({ entry }) : true;
if (shouldRecurse) {
const subDir = path.join(entry.parentPath, entry.name);
await walkDirectory(subDir, { onFile, onDir });
}
} else if (entry.isFile()) {
if (onFile) {
await onFile({ entry });
}
}
}
}

View file

@ -1,8 +1,10 @@
export function parseImageFrameList(source: string) {
export function parseImageFileList(source: string) {
const lines = source
.split(/(?:\r\n|\r|\n)/g)
.map((line) => line.trim())
.filter(Boolean);
.filter(Boolean)
.filter((line) => !line.startsWith(";")); // discard comments
return lines.map((line) => {
const fileWithCount = line.match(/^(.+)\s(\d+)$/);
if (fileWithCount) {

View file

@ -1,4 +1,4 @@
import { parseImageFrameList } from "./ifl";
import { parseImageFileList } from "./imageFileList";
import { getActualResourcePath, getMissionInfo, getSource } from "./manifest";
import { parseMissionScript } from "./mission";
import { parseTerrainBuffer } from "./terrain";
@ -94,5 +94,5 @@ export async function loadImageFrameList(iflPath: string) {
const url = getUrlForPath(iflPath);
const res = await fetch(url);
const source = await res.text();
return parseImageFrameList(source);
return parseImageFileList(source);
}

View file

@ -1,7 +1,19 @@
import untypedManifest from "../public/manifest.json";
import untypedManifest from "@/public/manifest.json";
import { normalizePath } from "./stringUtils";
const manifest = untypedManifest as {
resources: Record<string, string[]>;
// Source tuple: [sourcePath] or [sourcePath, actualPath] if casing differs
type SourceTuple = [string] | [string, string];
// Resource entry: [firstSeenPath, ...sourceTuples]
type ResourceEntry = [string, ...SourceTuple[]];
/**
* Manifest format: keys are normalized (lowercased) paths, values are
* [firstSeenPath, ...sourceTuples] where each source tuple is either:
* - [sourcePath] if the file has the same casing as firstSeenPath
* - [sourcePath, actualPath] if the file has different casing in that source
*/
const manifest = untypedManifest as unknown as {
resources: Record<string, ResourceEntry>;
missions: Record<
string,
{
@ -12,86 +24,81 @@ const manifest = untypedManifest as {
>;
};
export function getSource(resourcePath: string) {
const sources = manifest.resources[resourcePath];
if (sources && sources.length > 0) {
return sources[sources.length - 1];
function normalizeKey(resourcePath: string): string {
return normalizePath(resourcePath).toLowerCase();
}
function getEntry(resourcePath: string): ResourceEntry | undefined {
return manifest.resources[normalizeKey(resourcePath)];
}
/**
* Get the source vl2 archive for a resource (or empty string for loose files).
* Returns the last/winning source since later vl2s override earlier ones.
*/
export function getSource(resourcePath: string): string {
const entry = getEntry(resourcePath);
if (entry && entry.length > 1) {
const lastSourceTuple = entry[entry.length - 1] as SourceTuple;
return lastSourceTuple[0];
} else {
throw new Error(`Resource not found in manifest: ${resourcePath}`);
}
}
const _resourcePathCache = new Map();
export function getActualResourcePath(resourcePath: string) {
if (_resourcePathCache.has(resourcePath)) {
return _resourcePathCache.get(resourcePath);
}
const actualResourcePath = getActualResourcePathUncached(resourcePath);
_resourcePathCache.set(resourcePath, actualResourcePath);
return actualResourcePath;
}
export function getActualResourcePathUncached(resourcePath: string) {
if (manifest.resources[resourcePath]) {
return resourcePath;
}
const resourcePaths = getResourceList();
const lowerCased = resourcePath.toLowerCase();
// First, try exact case-insensitive match
const foundLowerCase = resourcePaths.find(
(s) => s.toLowerCase() === lowerCased,
);
if (foundLowerCase) {
return foundLowerCase;
/**
* Get the actual resource path with its original casing as seen in the filesystem.
* This handles case-insensitive lookups by normalizing the input path.
*/
export function getActualResourcePath(resourcePath: string): string {
const entry = getEntry(resourcePath);
if (entry) {
return entry[0]; // First element is the first-seen casing
}
// For paths with numeric suffixes (e.g., "generator0.png"), strip the number and try again
// e.g., "generator0.png" -> "generator.png"
// Fallback: try stripping numeric suffixes (e.g., "generator0.png" -> "generator.png")
const pathWithoutNumber = resourcePath.replace(/\d+(\.(png))$/i, "$1");
const lowerCasedWithoutNumber = pathWithoutNumber.toLowerCase();
if (pathWithoutNumber !== resourcePath) {
// If we stripped a number, try to find the version without it
const foundWithoutNumber = resourcePaths.find(
(s) => s.toLowerCase() === lowerCasedWithoutNumber,
);
if (foundWithoutNumber) {
return foundWithoutNumber;
const entryWithoutNumber = getEntry(pathWithoutNumber);
if (entryWithoutNumber) {
return entryWithoutNumber[0];
}
}
const isTexture = resourcePath.startsWith("textures/");
if (isTexture) {
const foundNested = resourcePaths.find(
(s) =>
s
.replace(
/^(textures\/)((lush|desert|badlands|lava|ice|jaggedclaw|terrainTiles)\/)/,
"$1",
)
.toLowerCase() === lowerCased,
);
if (foundNested) {
return foundNested;
// Fallback: try nested texture paths
const normalized = normalizeKey(resourcePath);
if (normalized.startsWith("textures/")) {
for (const key of Object.keys(manifest.resources)) {
const stripped = key.replace(
/^(textures\/)((lush|desert|badlands|lava|ice|jaggedclaw|terraintiles)\/)/,
"$1",
);
if (stripped === normalized) {
return manifest.resources[key][0];
}
}
}
return resourcePath;
}
const _cachedResourceList = Object.keys(manifest.resources);
export function getResourceList() {
return _cachedResourceList;
export function getResourceList(): string[] {
return Object.keys(manifest.resources);
}
export function getFilePath(resourcePath: string) {
const source = getSource(resourcePath);
if (source) {
return `public/base/@vl2/${source}/${resourcePath}`;
export function getFilePath(resourcePath: string): string {
const entry = getEntry(resourcePath);
if (!entry) {
return `docs/base/${resourcePath}`;
}
const [firstSeenPath, ...sourceTuples] = entry;
const lastSourceTuple = sourceTuples[sourceTuples.length - 1];
const lastSource = lastSourceTuple[0];
const actualPath = lastSourceTuple[1] ?? firstSeenPath;
if (lastSource) {
return `docs/base/@vl2/${lastSource}/${actualPath}`;
} else {
return `public/base/${resourcePath}`;
return `docs/base/${actualPath}`;
}
}

View file

@ -1,25 +1,33 @@
import { Quaternion, Vector3 } from "three";
import parser from "@/generated/mission.cjs";
import {
parse,
createRuntime,
type TorqueObject,
type TorqueRuntime,
type TorqueRuntimeOptions,
} from "./torqueScript";
import type * as AST from "./torqueScript/ast";
const definitionComment = /^ (DisplayName|MissionTypes) = (.+)$/i;
const sectionBeginComment = /^--- ([A-Z ]+) BEGIN ---$/;
const sectionEndComment = /^--- ([A-Z ]+) END ---$/;
// Patterns for extracting metadata from comments
const definitionComment =
/^[ \t]*(DisplayName|MissionTypes|BriefingWAV|Bitmap|PlanetName)[ \t]*=[ \t]*(.+)$/i;
const sectionBeginComment = /^[ \t]*-+[ \t]*([A-Z ]+)[ \t]+BEGIN[ \t]*-+$/i;
const sectionEndComment = /^[ \t]*-+[ \t]*([A-Z ]+)[ \t]+END[ \t]*-+$/i;
function parseComment(text) {
interface CommentSection {
name: string | null;
comments: string[];
}
function parseCommentMarker(text: string) {
let match;
match = text.match(sectionBeginComment);
if (match) {
return {
type: "sectionBegin",
name: match[1],
};
return { type: "sectionBegin" as const, name: match[1] };
}
match = text.match(sectionEndComment);
if (match) {
return {
type: "sectionEnd",
name: match[1],
};
return { type: "sectionEnd" as const, name: match[1] };
}
match = text.match(definitionComment);
if (match) {
@ -32,206 +40,178 @@ function parseComment(text) {
return null;
}
function parseInstance(instance) {
return {
className: instance.className,
instanceName: instance.instanceName,
properties: instance.body
.filter((def) => def.type === "definition")
.map((def) => {
switch (def.value.type) {
case "string":
case "number":
case "boolean":
return {
target: def.target,
value: def.value.value,
};
case "reference":
return {
target: def.target,
value: def.value,
};
function extractCommentMetadata(ast: AST.Program): {
pragma: Record<string, string>;
sections: CommentSection[];
} {
const pragma: Record<string, string> = {};
const sections: CommentSection[] = [];
let currentSection: CommentSection = { name: null, comments: [] };
default:
throw new Error(
`Unhandled value type: ${def.target.name} = ${def.value.type}`,
);
}
}),
children: instance.body
.filter((def) => def.type === "instance")
.map((def) => parseInstance(def)),
};
}
export function parseMissionScript(script) {
// Clean up the script:
// - Remove code-like parts of the script so it's easier to parse.
script = script.replace(
/(\/\/--- OBJECT WRITE END ---\s+)(?:.|[\r\n])*$/,
"$1",
);
let objectWriteBegin = /(\/\/--- OBJECT WRITE BEGIN ---\s+)/.exec(script);
const firstSimGroup = /[\r\n]new SimGroup/.exec(script);
script =
script.slice(0, objectWriteBegin.index + objectWriteBegin[1].length) +
script.slice(firstSimGroup.index);
objectWriteBegin = /(\/\/--- OBJECT WRITE BEGIN ---\s+)/.exec(script);
const missionStringEnd = /(\/\/--- MISSION STRING END ---\s+)/.exec(script);
if (missionStringEnd) {
script =
script.slice(0, missionStringEnd.index + missionStringEnd[1].length) +
script.slice(objectWriteBegin.index);
}
// console.log(script);
const doc = parser.parse(script);
let section = { name: null, definitions: [] };
const mission: {
pragma: Record<string, string | null>;
sections: Array<{ name: string | null; definitions: any[] }>;
} = {
pragma: {},
sections: [],
};
for (const statement of doc) {
switch (statement.type) {
case "comment": {
const parsed = parseComment(statement.text);
if (parsed) {
switch (parsed.type) {
case "definition": {
if (section.name) {
section.definitions.push(statement);
// Walk through all items looking for comments
function processItems(items: (AST.Statement | AST.Comment)[]) {
for (const item of items) {
if (item.type === "Comment") {
const marker = parseCommentMarker(item.value);
if (marker) {
switch (marker.type) {
case "definition":
if (currentSection.name === null) {
// Top-level definitions are pragma (normalize key to lowercase)
pragma[marker.identifier.toLowerCase()] = marker.value;
} else {
mission.pragma[parsed.identifier] = parsed.value;
currentSection.comments.push(item.value);
}
break;
}
case "sectionEnd": {
if (parsed.name !== section.name) {
throw new Error("Ending unmatched section!");
case "sectionBegin":
// Save current section if it has content
if (
currentSection.name !== null ||
currentSection.comments.length > 0
) {
sections.push(currentSection);
}
if (section.name || section.definitions.length) {
mission.sections.push(section);
}
section = { name: null, definitions: [] };
// Normalize section name to uppercase for consistent lookups
currentSection = {
name: marker.name.toUpperCase(),
comments: [],
};
break;
}
case "sectionBegin": {
if (section.name) {
throw new Error("Already in a section!");
case "sectionEnd":
if (currentSection.name !== null) {
sections.push(currentSection);
}
if (section.name || section.definitions.length) {
mission.sections.push(section);
}
section = { name: parsed.name, definitions: [] };
currentSection = { name: null, comments: [] };
break;
}
}
} else {
section.definitions.push(statement);
// Regular comment
currentSection.comments.push(item.value);
}
break;
}
default: {
section.definitions.push(statement);
}
}
}
if (section.name || section.definitions.length) {
mission.sections.push(section);
processItems(ast.body as (AST.Statement | AST.Comment)[]);
// Don't forget the last section
if (currentSection.name !== null || currentSection.comments.length > 0) {
sections.push(currentSection);
}
return { pragma, sections };
}
export function parseMissionScript(script: string): ParsedMission {
// Parse the script to AST
const ast = parse(script);
// Extract comment metadata (pragma, sections) from AST
const { pragma, sections } = extractCommentMetadata(ast);
// Helper to extract section content
function getSection(name: string): string | null {
return (
sections
.find((s) => s.name === name)
?.comments.map((c) => c.trimStart())
.join("\n") ?? null
);
}
return {
displayName:
mission.pragma.DisplayName ?? mission.pragma.Displayname ?? null,
missionTypes:
mission.pragma.MissionTypes?.split(/\s+/).filter(Boolean) ?? [],
missionQuote:
mission.sections
.find((section) => section.name === "MISSION QUOTE")
?.definitions.filter((def) => def.type === "comment")
.map((def) => def.text)
.join("\n") ?? null,
missionString:
mission.sections
.find((section) => section.name === "MISSION STRING")
?.definitions.filter((def) => def.type === "comment")
.map((def) => def.text)
.join("\n") ?? null,
objects: mission.sections
.find((section) => section.name === "OBJECT WRITE")
?.definitions.filter((def) => def.type === "instance")
.map((def) => parseInstance(def)),
globals: mission.sections
.filter((section) => !section.name)
.flatMap((section) =>
section.definitions.filter((def) => def.type === "definition"),
),
displayName: pragma.displayname ?? null,
missionTypes: pragma.missiontypes?.split(/\s+/).filter(Boolean) ?? [],
missionBriefing: getSection("MISSION BRIEFING"),
briefingWav: pragma.briefingwav ?? null,
bitmap: pragma.bitmap ?? null,
planetName: pragma.planetname ?? null,
missionBlurb: getSection("MISSION BLURB"),
missionQuote: getSection("MISSION QUOTE"),
missionString: getSection("MISSION STRING"),
execScriptPaths: ast.execScriptPaths,
hasDynamicExec: ast.hasDynamicExec,
ast,
};
}
export type Mission = ReturnType<typeof parseMissionScript>;
export type ConsoleObject = Mission["objects"][number];
export async function executeMission(
parsedMission: ParsedMission,
options: TorqueRuntimeOptions = {},
): Promise<ExecutedMission> {
// Create a runtime and execute the code
const runtime = createRuntime(options);
const loadedScript = await runtime.loadFromAST(parsedMission.ast);
loadedScript.execute();
export function* iterObjects(objectList) {
// Find root objects (objects without parents that aren't datablocks)
const objects: TorqueObject[] = [];
for (const obj of runtime.state.objectsById.values()) {
if (!obj._isDatablock && !obj._parent) {
objects.push(obj);
}
}
return {
mission: parsedMission,
objects,
runtime,
};
}
export interface ParsedMission {
displayName: string | null;
missionTypes: string[];
missionBriefing: string | null;
briefingWav: string | null;
bitmap: string | null;
planetName: string | null;
missionBlurb: string | null;
missionQuote: string | null;
missionString: string | null;
execScriptPaths: string[];
hasDynamicExec: boolean;
ast: AST.Program;
}
export interface ExecutedMission {
mission: ParsedMission;
objects: TorqueObject[];
runtime: TorqueRuntime;
}
export function* iterObjects(
objectList: TorqueObject[],
): Generator<TorqueObject> {
for (const obj of objectList) {
yield obj;
for (const child of iterObjects(obj.children)) {
yield child;
if (obj._children) {
yield* iterObjects(obj._children);
}
}
}
export function getTerrainBlock(mission: Mission): ConsoleObject {
for (const obj of iterObjects(mission.objects)) {
if (obj.className === "TerrainBlock") {
return obj;
}
}
throw new Error("No TerrainBlock found!");
export function getProperty(obj: TorqueObject, name: string): any {
return obj[name.toLowerCase()];
}
export function getTerrainFile(mission: Mission) {
const terrainBlock = getTerrainBlock(mission);
return terrainBlock.properties.find(
(prop) => prop.target.name === "terrainFile",
).value;
}
export function getProperty(obj: ConsoleObject, name: string) {
const property = obj.properties.find((p) => p.target.name === name);
// console.log({ name, property });
return property;
}
export function getPosition(obj: ConsoleObject): [number, number, number] {
const position = getProperty(obj, "position")?.value ?? "0 0 0";
const [x, y, z] = position.split(" ").map((s) => parseFloat(s));
// Convert Torque3D coordinates to Three.js: XYZ -> YZX
export function getPosition(obj: TorqueObject): [number, number, number] {
const position = obj.position ?? "0 0 0";
const [x, y, z] = position.split(" ").map((s: string) => parseFloat(s));
return [y || 0, z || 0, x || 0];
}
export function getScale(obj: ConsoleObject): [number, number, number] {
const scale = getProperty(obj, "scale")?.value ?? "1 1 1";
const [sx, sy, sz] = scale.split(" ").map((s) => parseFloat(s));
// Convert Torque3D coordinates to Three.js: XYZ -> YZX
export function getScale(obj: TorqueObject): [number, number, number] {
const scale = obj.scale ?? "1 1 1";
const [sx, sy, sz] = scale.split(" ").map((s: string) => parseFloat(s));
return [sy || 0, sz || 0, sx || 0];
}
export function getRotation(obj: ConsoleObject): Quaternion {
const rotation = getProperty(obj, "rotation")?.value ?? "1 0 0 0";
export function getRotation(obj: TorqueObject): Quaternion {
const rotation = obj.rotation ?? "1 0 0 0";
const [ax, ay, az, angleDegrees] = rotation
.split(" ")
.map((s) => parseFloat(s));
// Convert Torque3D coordinates to Three.js: XYZ -> YZX
.map((s: string) => parseFloat(s));
const axis = new Vector3(ay, az, ax).normalize();
const angleRadians = -angleDegrees * (Math.PI / 180);
return new Quaternion().setFromAxisAngle(axis, angleRadians);

233
src/torqueScript/README.md Normal file
View file

@ -0,0 +1,233 @@
# TorqueScript Transpiler
Transpiles TorqueScript (`.cs`/`.mis` files) to JavaScript. Includes a runtime
that implements TorqueScript semantics and built-ins.
## Usage
```typescript
import { parse, transpile } from "./index";
import { createRuntime } from "./runtime";
// Parse and transpile
const { code, ast } = transpile(source);
// Create runtime and execute
const runtime = createRuntime();
const script = await runtime.loadFromSource(source);
script.execute();
// Access results
runtime.$g.get("myGlobal"); // Get global variable
runtime.$.call(obj, "method"); // Call method on object
```
## Why Transpile to JavaScript?
- No TypeScript compiler needed at runtime
- Can dynamically transpile and execute in the browser
- The transpiler and runtime are written in TypeScript, but output is plain JS
## Key Differences from JavaScript
TorqueScript has semantics that don't map cleanly to JavaScript. The transpiler
and runtime handle these differences.
### Case Insensitivity
All identifiers are case-insensitive: functions, methods, variables, object
names, and properties. The runtime uses `CaseInsensitiveMap` for lookups.
### Namespaces, Not Classes
TorqueScript has no `class` keyword. The `::` in `function Player::onKill` is
a naming convention that registers a function in a **namespace**—it doesn't
define or reference a class.
```torquescript
function Item::onPickup(%this) {
echo("Picked up: " @ %this.getName());
}
```
This registers a function named `onPickup` in the `Item` namespace. You can
define functions in any namespace—`Item`, `MyGame`, `Util`—whether or not
objects of that "type" exist.
When you call a method on an object (`%obj.onPickup()`), the engine searches
for a matching function through a **namespace chain**. Every object has an
associated namespace (typically its C++ class name like `Item` or `Player`),
and namespaces are chained to parent namespaces. The engine walks up this
chain until it finds a function or fails.
```torquescript
new Item(HealthPack) { };
HealthPack.onPickup(); // Searches: Item -> SimObject -> ...
```
The `%this` parameter receives the object handle automatically—it's not magic
OOP binding, just a calling convention.
### `Parent::` is Package-Based
`Parent::method()` does NOT call a superclass. It calls the previous definition
of the same function before the current package was activated. Packages are
layers that override functions:
```torquescript
function DefaultGame::onKill(%game, %client) {
// base behavior
}
package CTFGame {
function DefaultGame::onKill(%game, %client) {
Parent::onKill(%game, %client); // calls the base version
// CTF-specific behavior
}
};
```
The runtime maintains a stack of function definitions per name. `Parent::` calls
the previous entry in that stack.
### Numeric Coercion
All arithmetic and comparison operators coerce operands to numbers. Empty
strings and undefined variables become `0`. This differs from JavaScript's
behavior:
```torquescript
$x = "5" + "3"; // 8, not "53"
$y = $undefined + 1; // 1, not NaN
```
### Integer vs Float Operations
TorqueScript uses different numeric types internally:
| Operator | Type | JavaScript Equivalent |
| ---------------------- | --------------- | ---------------------- |
| `+` `-` `*` `/` | 64-bit float | Direct (with coercion) |
| `%` | 32-bit signed | `$.mod(a, b)` |
| `&` `\|` `^` `<<` `>>` | 32-bit unsigned | `$.bitand()` etc. |
Division by zero returns `0` (not `Infinity`).
### String Operators
| TorqueScript | JavaScript |
| ------------ | ---------------------------------- |
| `%a @ %b` | `$.concat(a, b)` |
| `%a SPC %b` | `$.concat(a, " ", b)` |
| `%a TAB %b` | `$.concat(a, "\t", b)` |
| `%a $= %b` | `$.streq(a, b)` (case-insensitive) |
### Array Variables
TorqueScript "arrays" are string-keyed, implemented via variable name
concatenation:
```torquescript
$items[0] = "first"; // Sets $items0
$items["key"] = "named"; // Sets $itemskey
$arr[%i, %j] = %val; // Sets $arr{i}_{j}
```
### Switch Statements
TorqueScript `switch` has implicit break (no fallthrough). `switch$` does
case-insensitive string matching. The `or` keyword combines cases:
```torquescript
switch (%x) {
case 1 or 2 or 3:
doSomething(); // No break needed
default:
doOther();
}
```
## Generated Code Structure
The transpiler emits JavaScript that calls into a runtime API:
```javascript
// Function registration
$.registerFunction("myFunc", function() { ... });
$.registerMethod("Player", "onKill", function() { ... });
// Variable access via stores
const $l = $.locals(); // Per-function local store
$l.set("x", value); // Set local
$l.get("x"); // Get local
$g.set("GlobalVar", value); // Set global
$g.get("GlobalVar"); // Get global
// Object/method operations
$.create("SimGroup", "MissionGroup", { ... });
$.call(obj, "method", arg1, arg2);
$.parent("CurrentClass", "method", thisObj, ...args);
// Operators with proper coercion
$.add(a, b); $.sub(a, b); $.mul(a, b); $.div(a, b);
$.mod(a, b); $.bitand(a, b); $.shl(a, b); // etc.
```
## Runtime API
The runtime exposes three main objects:
- **`$`** (`RuntimeAPI`): Object/method system, operators, property access
- **`$f`** (`FunctionsAPI`): Call standalone functions by name
- **`$g`** (`GlobalsAPI`): Global variable storage
Key methods on `$`:
```typescript
// Registration
registerMethod(className, methodName, fn)
registerFunction(name, fn)
package(name, bodyFn)
// Object creation
create(className, instanceName, props, children?)
datablock(className, instanceName, parentName, props)
deleteObject(obj)
// Property access (case-insensitive)
prop(obj, name)
setProp(obj, name, value)
// Method dispatch
call(obj, methodName, ...args)
nsCall(namespace, method, ...args)
parent(currentClass, methodName, thisObj, ...args)
```
## Script Loading
The runtime supports `exec()` for loading dependent scripts:
```typescript
const runtime = createRuntime({
loadScript: async (path) => {
// Return script source or null if not found
return await fetch(path).then((r) => r.text());
},
});
// Dependencies are resolved before execution
const script = await runtime.loadFromPath("scripts/main.cs");
script.execute();
```
- Scripts are executed once; subsequent `exec()` calls are no-ops
- Circular dependencies are handled (each script runs once)
- Paths are normalized (backslashes → forward slashes, lowercased)
## Built-in Functions
The runtime implements common TorqueScript built-ins like `echo`, `exec`,
`schedule`, `activatePackage`, string functions (`getWord`, `strLen`, etc.),
math functions (`mFloor`, `mSin`, etc.), and vector math (`vectorAdd`,
`vectorDist`, etc.). See `createBuiltins()` in `runtime.ts` for the full list.

250
src/torqueScript/ast.ts Normal file
View file

@ -0,0 +1,250 @@
export interface BaseNode {
type: string;
}
export interface Program extends BaseNode {
type: "Program";
body: Statement[];
comments?: Comment[];
execScriptPaths: string[];
hasDynamicExec: boolean;
}
export type Statement =
| ExpressionStatement
| FunctionDeclaration
| PackageDeclaration
| DatablockDeclaration
| ObjectDeclaration
| IfStatement
| ForStatement
| WhileStatement
| DoWhileStatement
| SwitchStatement
| ReturnStatement
| BreakStatement
| ContinueStatement
| BlockStatement;
export interface ExpressionStatement extends BaseNode {
type: "ExpressionStatement";
expression: Expression;
}
export interface FunctionDeclaration extends BaseNode {
type: "FunctionDeclaration";
name: Identifier;
params: Variable[];
body: BlockStatement;
}
export interface PackageDeclaration extends BaseNode {
type: "PackageDeclaration";
name: Identifier;
body: Statement[];
comments?: Comment[];
}
export interface DatablockDeclaration extends BaseNode {
type: "DatablockDeclaration";
className: Identifier;
instanceName: Identifier | null;
parent: Identifier | null;
body: ObjectBodyItem[];
}
export interface ObjectDeclaration extends BaseNode {
type: "ObjectDeclaration";
className: Identifier | Expression;
instanceName: Identifier | Expression | null;
body: ObjectBodyItem[];
}
export type ObjectBodyItem = Assignment | ObjectDeclaration;
export interface Assignment extends BaseNode {
type: "Assignment";
target: Identifier | IndexExpression;
value: Expression;
}
export interface IfStatement extends BaseNode {
type: "IfStatement";
test: Expression;
consequent: Statement;
alternate: Statement | null;
}
export interface ForStatement extends BaseNode {
type: "ForStatement";
init: Expression | null;
test: Expression | null;
update: Expression | null;
body: Statement;
}
export interface WhileStatement extends BaseNode {
type: "WhileStatement";
test: Expression;
body: Statement;
}
export interface DoWhileStatement extends BaseNode {
type: "DoWhileStatement";
test: Expression;
body: Statement;
}
export interface SwitchStatement extends BaseNode {
type: "SwitchStatement";
stringMode: boolean;
discriminant: Expression;
cases: SwitchCase[];
}
export interface SwitchCase extends BaseNode {
type: "SwitchCase";
test: Expression | Expression[] | null; // null = default, array = "or" syntax
consequent: Statement[];
}
export interface ReturnStatement extends BaseNode {
type: "ReturnStatement";
value: Expression | null;
}
export interface BreakStatement extends BaseNode {
type: "BreakStatement";
}
export interface ContinueStatement extends BaseNode {
type: "ContinueStatement";
}
export interface BlockStatement extends BaseNode {
type: "BlockStatement";
body: Statement[];
comments?: Comment[];
}
export type Expression =
| Identifier
| Variable
| NumberLiteral
| StringLiteral
| BooleanLiteral
| BinaryExpression
| UnaryExpression
| PostfixExpression
| AssignmentExpression
| ConditionalExpression
| CallExpression
| MemberExpression
| IndexExpression
| TagDereferenceExpression
| ObjectDeclaration
| DatablockDeclaration;
export interface Identifier extends BaseNode {
type: "Identifier";
name: string;
}
export interface Variable extends BaseNode {
type: "Variable";
scope: "local" | "global";
name: string;
}
export interface NumberLiteral extends BaseNode {
type: "NumberLiteral";
value: number;
}
export interface StringLiteral extends BaseNode {
type: "StringLiteral";
value: string;
tagged?: boolean;
}
export interface BooleanLiteral extends BaseNode {
type: "BooleanLiteral";
value: boolean;
}
export interface BinaryExpression extends BaseNode {
type: "BinaryExpression";
operator: string;
left: Expression;
right: Expression;
}
export interface UnaryExpression extends BaseNode {
type: "UnaryExpression";
operator: string;
argument: Expression;
}
export interface PostfixExpression extends BaseNode {
type: "PostfixExpression";
operator: string;
argument: Expression;
}
export interface AssignmentExpression extends BaseNode {
type: "AssignmentExpression";
operator: string;
target: Expression;
value: Expression;
}
export interface ConditionalExpression extends BaseNode {
type: "ConditionalExpression";
test: Expression;
consequent: Expression;
alternate: Expression;
}
export interface CallExpression extends BaseNode {
type: "CallExpression";
callee: Expression;
arguments: Expression[];
}
export interface MemberExpression extends BaseNode {
type: "MemberExpression";
object: Expression;
property: Identifier | Expression;
computed?: boolean;
}
export interface IndexExpression extends BaseNode {
type: "IndexExpression";
object: Expression;
index: Expression | Expression[]; // Single or multi-index access: $arr[i] or $arr[i, j]
}
export interface TagDereferenceExpression extends BaseNode {
type: "TagDereferenceExpression";
argument: Expression;
}
export interface Comment extends BaseNode {
type: "Comment";
value: string;
}
export function isMethodName(name: Identifier): boolean {
return name.name.includes("::");
}
export function parseMethodName(
name: string,
): { namespace: string; method: string } | null {
const idx = name.indexOf("::");
if (idx === -1) return null;
return {
namespace: name.slice(0, idx),
method: name.slice(idx + 2),
};
}

View file

@ -0,0 +1,690 @@
import type { BuiltinsContext, TorqueFunction } from "./types";
import { normalizePath } from "./utils";
function parseVector(v: any): [number, number, number] {
const parts = String(v ?? "0 0 0")
.split(" ")
.map(Number);
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
}
// TorqueScript unit delimiters (from SDK source):
// - Words: space, tab, newline (" \t\n")
// - Fields: tab, newline ("\t\n")
// - Records: newline ("\n")
const FIELD_DELIM = /[\t\n]/;
const FIELD_DELIM_CHAR = "\t"; // Use tab when joining
/**
* Default TorqueScript built-in functions.
*
* Names are lowercased to optimize lookup, since TorqueScript is case-insensitive.
*/
export function createBuiltins(
ctx: BuiltinsContext,
): Record<string, TorqueFunction> {
const { runtime } = ctx;
return {
// Console
echo(...args: any[]): void {
console.log(...args.map((a) => String(a ?? "")));
},
warn(...args: any[]): void {
console.warn(...args.map((a) => String(a ?? "")));
},
error(...args: any[]): void {
console.error(...args.map((a) => String(a ?? "")));
},
call(funcName: any, ...args: any[]): any {
return runtime().$f.call(String(funcName ?? ""), ...args);
},
eval(_code: any): any {
throw new Error(
"eval() not implemented: requires runtime parsing and execution",
);
},
collapseescape(str: any): string {
// Single-pass replacement to correctly handle sequences like \\n
return String(str ?? "").replace(/\\([ntr\\])/g, (_, char) => {
if (char === "n") return "\n";
if (char === "t") return "\t";
if (char === "r") return "\r";
return "\\";
});
},
expandescape(str: any): string {
return String(str ?? "")
.replace(/\\/g, "\\\\")
.replace(/\n/g, "\\n")
.replace(/\t/g, "\\t")
.replace(/\r/g, "\\r");
},
export(pattern: any, filename?: any, append?: any): void {
console.warn(`export(${pattern}): not implemented`);
},
quit(): void {
console.warn("quit(): not implemented in browser");
},
trace(_enable: any): void {
// Enable/disable function call tracing
},
// Type checking
isobject(obj: any): boolean {
return runtime().$.isObject(obj);
},
typeof(obj: any): string {
if (obj == null) return "";
if (typeof obj === "object" && obj._class) return obj._className;
return typeof obj;
},
// Object lookup
nametoid(name: string): number {
return runtime().$.nameToId(name);
},
isfunction(name: string): boolean {
return runtime().$.isFunction(name);
},
// String functions
strlen(str: any): number {
return String(str ?? "").length;
},
strchr(str: any, char: any): string {
// Returns remainder of string starting at first occurrence of char, or ""
const s = String(str ?? "");
const c = String(char ?? "")[0] ?? "";
const idx = s.indexOf(c);
return idx >= 0 ? s.substring(idx) : "";
},
strpos(haystack: any, needle: any, offset?: any): number {
const s = String(haystack ?? "");
const n = String(needle ?? "");
const o = Number(offset) || 0;
return s.indexOf(n, o);
},
strcmp(a: any, b: any): number {
const sa = String(a ?? "");
const sb = String(b ?? "");
return sa < sb ? -1 : sa > sb ? 1 : 0;
},
stricmp(a: any, b: any): number {
const sa = String(a ?? "").toLowerCase();
const sb = String(b ?? "").toLowerCase();
return sa < sb ? -1 : sa > sb ? 1 : 0;
},
strstr(haystack: any, needle: any): number {
return String(haystack ?? "").indexOf(String(needle ?? ""));
},
getsubstr(str: any, start: any, len?: any): string {
const s = String(str ?? "");
const st = Number(start) || 0;
if (len === undefined) return s.substring(st);
return s.substring(st, st + (Number(len) || 0));
},
getword(str: any, index: any): string {
const words = String(str ?? "").split(/\s+/);
const i = Number(index) || 0;
return words[i] ?? "";
},
getwordcount(str: any): number {
const s = String(str ?? "").trim();
if (s === "") return 0;
return s.split(/\s+/).length;
},
getfield(str: any, index: any): string {
const fields = String(str ?? "").split(FIELD_DELIM);
const i = Number(index) || 0;
return fields[i] ?? "";
},
getfieldcount(str: any): number {
const s = String(str ?? "");
if (s === "") return 0;
return s.split(FIELD_DELIM).length;
},
setword(str: any, index: any, value: any): string {
const words = String(str ?? "").split(/\s+/);
const i = Number(index) || 0;
words[i] = String(value ?? "");
return words.join(" ");
},
setfield(str: any, index: any, value: any): string {
const fields = String(str ?? "").split(FIELD_DELIM);
const i = Number(index) || 0;
fields[i] = String(value ?? "");
return fields.join(FIELD_DELIM_CHAR);
},
firstword(str: any): string {
const words = String(str ?? "").split(/\s+/);
return words[0] ?? "";
},
restwords(str: any): string {
const words = String(str ?? "").split(/\s+/);
return words.slice(1).join(" ");
},
trim(str: any): string {
return String(str ?? "").trim();
},
ltrim(str: any): string {
return String(str ?? "").replace(/^\s+/, "");
},
rtrim(str: any): string {
return String(str ?? "").replace(/\s+$/, "");
},
strupr(str: any): string {
return String(str ?? "").toUpperCase();
},
strlwr(str: any): string {
return String(str ?? "").toLowerCase();
},
strreplace(str: any, from: any, to: any): string {
return String(str ?? "")
.split(String(from ?? ""))
.join(String(to ?? ""));
},
filterstring(str: any, _replacementChars?: any): string {
// Filters profanity/bad words from the string (requires bad word dictionary)
// Since we don't have a bad word filter, just return the string unchanged
return String(str ?? "");
},
stripchars(str: any, chars: any): string {
// Removes all characters in `chars` from the string
const s = String(str ?? "");
const toRemove = new Set(String(chars ?? "").split(""));
return s
.split("")
.filter((c) => !toRemove.has(c))
.join("");
},
getfields(str: any, start: any, end?: any): string {
const fields = String(str ?? "").split(FIELD_DELIM);
const s = Number(start) || 0;
const e = end !== undefined ? Number(end) + 1 : 1000000;
return fields.slice(s, e).join(FIELD_DELIM_CHAR);
},
getwords(str: any, start: any, end?: any): string {
const words = String(str ?? "").split(/\s+/);
const s = Number(start) || 0;
const e = end !== undefined ? Number(end) + 1 : 1000000;
return words.slice(s, e).join(" ");
},
removeword(str: any, index: any): string {
const words = String(str ?? "").split(/\s+/);
const i = Number(index) || 0;
words.splice(i, 1);
return words.join(" ");
},
removefield(str: any, index: any): string {
const fields = String(str ?? "").split(FIELD_DELIM);
const i = Number(index) || 0;
fields.splice(i, 1);
return fields.join(FIELD_DELIM_CHAR);
},
getrecord(str: any, index: any): string {
const records = String(str ?? "").split("\n");
const i = Number(index) || 0;
return records[i] ?? "";
},
getrecordcount(str: any): number {
const s = String(str ?? "");
if (s === "") return 0;
return s.split("\n").length;
},
setrecord(str: any, index: any, value: any): string {
const records = String(str ?? "").split("\n");
const i = Number(index) || 0;
records[i] = String(value ?? "");
return records.join("\n");
},
removerecord(str: any, index: any): string {
const records = String(str ?? "").split("\n");
const i = Number(index) || 0;
records.splice(i, 1);
return records.join("\n");
},
nexttoken(_str: any, _tokenVar: any, _delim: any): string {
// nextToken modifies a variable to store the remainder of the string,
// which cannot be implemented correctly from a builtin function.
throw new Error(
"nextToken() is not implemented: it requires variable mutation",
);
},
strtoplayername(str: any): string {
// Sanitizes a string to be a valid player name
return String(str ?? "")
.replace(/[^\w\s-]/g, "")
.trim();
},
// Math functions
mabs(n: any): number {
return Math.abs(Number(n) || 0);
},
mfloor(n: any): number {
return Math.floor(Number(n) || 0);
},
mceil(n: any): number {
return Math.ceil(Number(n) || 0);
},
msqrt(n: any): number {
return Math.sqrt(Number(n) || 0);
},
mpow(base: any, exp: any): number {
return Math.pow(Number(base) || 0, Number(exp) || 0);
},
msin(n: any): number {
return Math.sin(Number(n) || 0);
},
mcos(n: any): number {
return Math.cos(Number(n) || 0);
},
mtan(n: any): number {
return Math.tan(Number(n) || 0);
},
masin(n: any): number {
return Math.asin(Number(n) || 0);
},
macos(n: any): number {
return Math.acos(Number(n) || 0);
},
matan(rise: any, run: any): number {
// SDK: mAtan(rise, run) - always requires 2 args, returns atan2
return Math.atan2(Number(rise) || 0, Number(run) || 0);
},
mlog(n: any): number {
return Math.log(Number(n) || 0);
},
getrandom(a?: any, b?: any): number {
// SDK behavior:
// - 0 args: returns float 0-1
// - 1 arg: returns int 0 to a
// - 2 args: returns int a to b
if (a === undefined) {
return Math.random();
}
if (b === undefined) {
return Math.floor(Math.random() * (Number(a) + 1));
}
const min = Number(a) || 0;
const max = Number(b) || 0;
return Math.floor(Math.random() * (max - min + 1)) + min;
},
getrandomseed(): number {
throw new Error("getRandomSeed() not implemented");
},
setrandomseed(_seed: any): void {
throw new Error("setRandomSeed() not implemented");
},
mdegtorad(deg: any): number {
return (Number(deg) || 0) * (Math.PI / 180);
},
mradtodeg(rad: any): number {
return (Number(rad) || 0) * (180 / Math.PI);
},
mfloatlength(n: any, precision: any): string {
return (Number(n) || 0).toFixed(Number(precision) || 0);
},
getboxcenter(box: any): string {
// Box format: "minX minY minZ maxX maxY maxZ"
const parts = String(box ?? "")
.split(" ")
.map(Number);
const minX = parts[0] || 0;
const minY = parts[1] || 0;
const minZ = parts[2] || 0;
const maxX = parts[3] || 0;
const maxY = parts[4] || 0;
const maxZ = parts[5] || 0;
return `${(minX + maxX) / 2} ${(minY + maxY) / 2} ${(minZ + maxZ) / 2}`;
},
// Vector math (3-component vectors as space-separated strings)
vectoradd(a: any, b: any): string {
const [ax, ay, az] = parseVector(a);
const [bx, by, bz] = parseVector(b);
return `${ax + bx} ${ay + by} ${az + bz}`;
},
vectorsub(a: any, b: any): string {
const [ax, ay, az] = parseVector(a);
const [bx, by, bz] = parseVector(b);
return `${ax - bx} ${ay - by} ${az - bz}`;
},
vectorscale(v: any, s: any): string {
const [x, y, z] = parseVector(v);
const scale = Number(s) || 0;
return `${x * scale} ${y * scale} ${z * scale}`;
},
vectordot(a: any, b: any): number {
const [ax, ay, az] = parseVector(a);
const [bx, by, bz] = parseVector(b);
return ax * bx + ay * by + az * bz;
},
vectorcross(a: any, b: any): string {
const [ax, ay, az] = parseVector(a);
const [bx, by, bz] = parseVector(b);
return `${ay * bz - az * by} ${az * bx - ax * bz} ${ax * by - ay * bx}`;
},
vectorlen(v: any): number {
const [x, y, z] = parseVector(v);
return Math.sqrt(x * x + y * y + z * z);
},
vectornormalize(v: any): string {
const [x, y, z] = parseVector(v);
const len = Math.sqrt(x * x + y * y + z * z);
if (len === 0) return "0 0 0";
return `${x / len} ${y / len} ${z / len}`;
},
vectordist(a: any, b: any): number {
const [ax, ay, az] = parseVector(a);
const [bx, by, bz] = parseVector(b);
const dx = ax - bx;
const dy = ay - by;
const dz = az - bz;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
},
// Matrix math - these require full 3D matrix operations with axis-angle/quaternion
// conversions that we haven't implemented
matrixcreate(_pos: any, _rot: any): string {
throw new Error(
"MatrixCreate() not implemented: requires axis-angle rotation math",
);
},
matrixcreatefromeuler(_euler: any): string {
throw new Error(
"MatrixCreateFromEuler() not implemented: requires Euler→Quaternion→AxisAngle conversion",
);
},
matrixmultiply(_a: any, _b: any): string {
throw new Error(
"MatrixMultiply() not implemented: requires full 4x4 matrix multiplication",
);
},
matrixmulpoint(_mat: any, _point: any): string {
throw new Error(
"MatrixMulPoint() not implemented: requires full transform application",
);
},
matrixmulvector(_mat: any, _vec: any): string {
throw new Error(
"MatrixMulVector() not implemented: requires rotation matrix application",
);
},
// Simulation
getsimtime(): number {
return Date.now() - runtime().state.startTime;
},
getrealtime(): number {
return Date.now();
},
// Schedule
schedule(
delay: any,
_obj: any,
func: any,
...args: any[]
): ReturnType<typeof setTimeout> {
const ms = Number(delay) || 0;
const rt = runtime();
const timeoutId = setTimeout(() => {
rt.state.pendingTimeouts.delete(timeoutId);
rt.$f.call(String(func), ...args);
}, ms);
rt.state.pendingTimeouts.add(timeoutId);
return timeoutId;
},
cancel(id: any): void {
clearTimeout(id);
runtime().state.pendingTimeouts.delete(id);
},
iseventpending(id: any): boolean {
return runtime().state.pendingTimeouts.has(id);
},
// Script loading
exec(path: any): boolean {
const pathString = String(path ?? "");
console.debug(
`exec(${JSON.stringify(pathString)}): preparing to execute…`,
);
const normalizedPath = normalizePath(pathString);
const rt = runtime();
const { executedScripts, scripts } = rt.state;
// Check if already executed
if (executedScripts.has(normalizedPath)) {
console.debug(
`exec(${JSON.stringify(pathString)}): skipping (already executed)`,
);
return true;
}
// Get the pre-parsed AST from the scripts map
const ast = scripts.get(normalizedPath);
if (ast == null) {
console.warn(`exec(${JSON.stringify(pathString)}): script not found`);
return false;
}
// Mark as executed before running (handles circular deps)
executedScripts.add(normalizedPath);
console.debug(`exec(${JSON.stringify(pathString)}): executing!`);
rt.executeAST(ast);
return true;
},
compile(_path: any): boolean {
throw new Error(
"compile() not implemented: requires DSO bytecode compiler",
);
},
// Misc
isdemo(): boolean {
// FIXME: Unsure if this is referring to demo (.rec) playback, or a demo
// version of the game.
return false;
},
// Files
isfile(_path: any): boolean {
throw new Error("isFile() not implemented: requires filesystem access");
},
fileext(path: any): string {
const s = String(path ?? "");
const dot = s.lastIndexOf(".");
return dot >= 0 ? s.substring(dot) : "";
},
filebase(path: any): string {
const s = String(path ?? "");
const slash = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
const dot = s.lastIndexOf(".");
const start = slash >= 0 ? slash + 1 : 0;
const end = dot > start ? dot : s.length;
return s.substring(start, end);
},
filepath(path: any): string {
const s = String(path ?? "");
const slash = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
return slash >= 0 ? s.substring(0, slash) : "";
},
expandfilename(_path: any): string {
throw new Error(
"expandFilename() not implemented: requires filesystem path expansion",
);
},
findfirstfile(_pattern: any): string {
throw new Error(
"findFirstFile() not implemented: requires filesystem directory listing",
);
},
findnextfile(_pattern: any): string {
throw new Error(
"findNextFile() not implemented: requires filesystem directory listing",
);
},
getfilecrc(_path: any): number {
throw new Error(
"getFileCRC() not implemented: requires filesystem access",
);
},
iswriteablefilename(path: any): boolean {
return false;
},
// Package management
activatepackage(name: any): void {
runtime().$.activatePackage(String(name ?? ""));
},
deactivatepackage(name: any): void {
runtime().$.deactivatePackage(String(name ?? ""));
},
ispackage(name: any): boolean {
return runtime().$.isPackage(String(name ?? ""));
},
// Messaging (stubs - no networking layer)
addmessagecallback(_msgType: any, _callback: any): void {
// No-op: message callbacks are for multiplayer networking
},
// ===== ENGINE STUBS =====
// These functions are called by scripts but require engine features we don't have.
// They're implemented as no-ops or return sensible defaults.
// Audio (OpenAL)
alxcreatesource(..._args: any[]): number {
return 0;
},
alxgetwavelen(_source: any): number {
return 0;
},
alxlistenerf(_param: any, _value: any): void {},
alxplay(..._args: any[]): number {
return 0;
},
alxsetchannelvolume(_channel: any, _volume: any): void {},
alxsourcef(_source: any, _param: any, _value: any): void {},
alxstop(_source: any): void {},
alxstopall(): void {},
// Device I/O
activatedirectinput(): void {},
activatekeyboard(): void {},
deactivatedirectinput(): void {},
deactivatekeyboard(): void {},
disablejoystick(): void {},
enablejoystick(): void {},
enablewinconsole(_enable: any): void {},
isjoystickdetected(): boolean {
return false;
},
lockmouse(_lock: any): void {},
// Video/Display
addmaterialmapping(_from: any, _to: any): void {},
flushtexturecache(): void {},
getdesktopresolution(): string {
return "1920 1080 32";
},
getdisplaydevicelist(): string {
return "OpenGL";
},
getresolutionlist(_device: any): string {
return "640 480\t800 600\t1024 768\t1280 720\t1920 1080";
},
getvideodriverinfo(): string {
return "WebGL";
},
isdevicefullscreenonly(_device: any): boolean {
return false;
},
isfullscreen(): boolean {
return false;
},
screenshot(_filename: any): void {},
setdisplaydevice(_device: any): boolean {
return true;
},
setfov(_fov: any): void {},
setinteriorrendermode(_mode: any): void {},
setopenglanisotropy(_level: any): void {},
setopenglmipreduction(_level: any): void {},
setopenglskymipreduction(_level: any): void {},
setopengltexturecompressionhint(_hint: any): void {},
setscreenmode(
_width: any,
_height: any,
_bpp: any,
_fullscreen: any,
): void {},
setverticalsync(_enable: any): void {},
setzoomspeed(_speed: any): void {},
togglefullscreen(): void {},
videosetgammacorrection(_gamma: any): void {},
snaptoggle(): void {},
// Networking
addtaggedstring(_str: any): number {
return 0;
},
buildtaggedstring(_format: any, ..._args: any[]): string {
return "";
},
detag(_tagged: any): string {
return String(_tagged ?? "");
},
gettag(_str: any): number {
return 0;
},
gettaggedstring(_tag: any): string {
return "";
},
removetaggedstring(_tag: any): void {},
commandtoclient(_client: any, _func: any, ..._args: any[]): void {},
commandtoserver(_func: any, ..._args: any[]): void {},
cancelserverquery(): void {},
querymasterserver(..._args: any[]): void {},
querysingleserver(..._args: any[]): void {},
setnetport(_port: any): boolean {
return true;
},
startheartbeat(): void {},
stopheartbeat(): void {},
gotowebpage(_url: any): void {
// Could potentially open URL in browser
},
// Scene/Physics
containerboxempty(..._args: any[]): boolean {
return true;
},
containerraycast(..._args: any[]): string {
return "";
},
containersearchcurrdist(): number {
return 0;
},
containersearchnext(): number {
return 0;
},
initcontainerradiussearch(..._args: any[]): void {},
calcexplosioncoverage(..._args: any[]): number {
return 1;
},
getcontrolobjectaltitude(): number {
return 0;
},
getcontrolobjectspeed(): number {
return 0;
},
getterrainheight(_pos: any): number {
return 0;
},
lightscene(..._args: any[]): void {},
pathonmissionloaddone(): void {},
};
}

756
src/torqueScript/codegen.ts Normal file
View file

@ -0,0 +1,756 @@
import type * as AST from "./ast";
import { parseMethodName } from "./ast";
const INTEGER_OPERATORS = new Set(["%", "&", "|", "^", "<<", ">>"]);
const ARITHMETIC_OPERATORS = new Set(["+", "-", "*", "/"]);
const COMPARISON_OPERATORS = new Set(["<", "<=", ">", ">=", "==", "!="]);
const OPERATOR_HELPERS: Record<string, string> = {
// Arithmetic
"+": "$.add",
"-": "$.sub",
"*": "$.mul",
"/": "$.div",
// Comparison
"<": "$.lt",
"<=": "$.le",
">": "$.gt",
">=": "$.ge",
"==": "$.eq",
"!=": "$.ne",
// Integer
"%": "$.mod",
"&": "$.bitand",
"|": "$.bitor",
"^": "$.bitxor",
"<<": "$.shl",
">>": "$.shr",
};
export interface GeneratorOptions {
indent?: string;
runtime?: string;
functions?: string;
globals?: string;
locals?: string;
}
export class CodeGenerator {
private indent: string;
private runtime: string;
private functions: string;
private globals: string;
private locals: string;
private indentLevel = 0;
private currentClass: string | null = null;
private currentFunction: string | null = null;
constructor(options: GeneratorOptions = {}) {
this.indent = options.indent ?? " ";
this.runtime = options.runtime ?? "$";
this.functions = options.functions ?? "$f";
this.globals = options.globals ?? "$g";
this.locals = options.locals ?? "$l";
}
private getAccessInfo(target: AST.Expression): {
getter: string;
setter: (value: string) => string;
postIncHelper?: string;
postDecHelper?: string;
} | null {
// Variable: $x or %x
if (target.type === "Variable") {
const name = JSON.stringify(target.name);
const store = target.scope === "global" ? this.globals : this.locals;
return {
getter: `${store}.get(${name})`,
setter: (value) => `${store}.set(${name}, ${value})`,
postIncHelper: `${store}.postInc(${name})`,
postDecHelper: `${store}.postDec(${name})`,
};
}
// MemberExpression: obj.prop
if (target.type === "MemberExpression") {
const obj = this.expression(target.object);
const prop =
target.property.type === "Identifier"
? JSON.stringify(target.property.name)
: this.expression(target.property);
return {
getter: `${this.runtime}.prop(${obj}, ${prop})`,
setter: (value) => `${this.runtime}.setProp(${obj}, ${prop}, ${value})`,
postIncHelper: `${this.runtime}.propPostInc(${obj}, ${prop})`,
postDecHelper: `${this.runtime}.propPostDec(${obj}, ${prop})`,
};
}
// IndexExpression: $arr[0] or obj[key]
if (target.type === "IndexExpression") {
const indices = Array.isArray(target.index)
? target.index.map((i) => this.expression(i))
: [this.expression(target.index)];
// Variable with index: $foo[0] becomes $foo0
if (target.object.type === "Variable") {
const baseName = JSON.stringify(target.object.name);
const store =
target.object.scope === "global" ? this.globals : this.locals;
const indicesStr = indices.join(", ");
return {
getter: `${store}.get(${baseName}, ${indicesStr})`,
setter: (value) =>
`${store}.set(${baseName}, ${indicesStr}, ${value})`,
postIncHelper: `${store}.postInc(${baseName}, ${indicesStr})`,
postDecHelper: `${store}.postDec(${baseName}, ${indicesStr})`,
};
}
// Object index access: obj[key]
const obj = this.expression(target.object);
const index =
indices.length === 1
? indices[0]
: `${this.runtime}.key(${indices.join(", ")})`;
return {
getter: `${this.runtime}.getIndex(${obj}, ${index})`,
setter: (value) =>
`${this.runtime}.setIndex(${obj}, ${index}, ${value})`,
postIncHelper: `${this.runtime}.indexPostInc(${obj}, ${index})`,
postDecHelper: `${this.runtime}.indexPostDec(${obj}, ${index})`,
};
}
return null;
}
generate(ast: AST.Program): string {
const lines: string[] = [];
for (const stmt of ast.body) {
const code = this.statement(stmt);
if (code) lines.push(code);
}
return lines.join("\n\n");
}
private statement(node: AST.Statement | AST.Comment): string {
switch (node.type) {
case "Comment":
// Skip comments in generated output (or could emit as JS comments)
return "";
case "ExpressionStatement":
return this.line(`${this.expression(node.expression)};`);
case "FunctionDeclaration":
return this.functionDeclaration(node);
case "PackageDeclaration":
return this.packageDeclaration(node);
case "DatablockDeclaration":
return this.datablockDeclaration(node);
case "ObjectDeclaration":
return this.line(`${this.objectDeclaration(node)};`);
case "IfStatement":
return this.ifStatement(node);
case "ForStatement":
return this.forStatement(node);
case "WhileStatement":
return this.whileStatement(node);
case "DoWhileStatement":
return this.doWhileStatement(node);
case "SwitchStatement":
return this.switchStatement(node);
case "ReturnStatement":
return this.returnStatement(node);
case "BreakStatement":
return this.line("break;");
case "ContinueStatement":
return this.line("continue;");
case "BlockStatement":
return this.blockStatement(node);
default:
throw new Error(`Unknown statement type: ${(node as any).type}`);
}
}
private functionDeclaration(node: AST.FunctionDeclaration): string {
const nameInfo = parseMethodName(node.name.name);
if (nameInfo) {
// Method: Class::method - runtime handles case normalization
const className = nameInfo.namespace;
const methodName = nameInfo.method;
this.currentClass = className.toLowerCase();
this.currentFunction = methodName.toLowerCase();
const body = this.functionBody(node.body, node.params);
this.currentClass = null;
this.currentFunction = null;
return `${this.line(`${this.runtime}.registerMethod(${JSON.stringify(className)}, ${JSON.stringify(methodName)}, function() {`)}\n${body}\n${this.line(`});`)}`;
} else {
// Standalone function - runtime handles case normalization
const funcName = node.name.name;
this.currentFunction = funcName.toLowerCase();
const body = this.functionBody(node.body, node.params);
this.currentFunction = null;
return `${this.line(`${this.runtime}.registerFunction(${JSON.stringify(funcName)}, function() {`)}\n${body}\n${this.line(`});`)}`;
}
}
private functionBody(
node: AST.BlockStatement,
params: AST.Variable[],
): string {
this.indentLevel++;
const lines: string[] = [];
lines.push(this.line(`const ${this.locals} = ${this.runtime}.locals();`));
for (let i = 0; i < params.length; i++) {
lines.push(
this.line(
`${this.locals}.set(${JSON.stringify(params[i].name)}, arguments[${i}]);`,
),
);
}
for (const stmt of node.body) {
lines.push(this.statement(stmt));
}
this.indentLevel--;
return lines.join("\n");
}
private packageDeclaration(node: AST.PackageDeclaration): string {
// Runtime handles case normalization
const pkgName = JSON.stringify(node.name.name);
this.indentLevel++;
const body = node.body.map((s) => this.statement(s)).join("\n\n");
this.indentLevel--;
return `${this.line(`${this.runtime}.package(${pkgName}, function() {`)}\n${body}\n${this.line(`});`)}`;
}
private datablockDeclaration(node: AST.DatablockDeclaration): string {
// Runtime handles case normalization
const className = JSON.stringify(node.className.name);
const instanceName = node.instanceName
? JSON.stringify(node.instanceName.name)
: "null";
const parentName = node.parent ? JSON.stringify(node.parent.name) : "null";
const props = this.objectBody(node.body);
return this.line(
`${this.runtime}.datablock(${className}, ${instanceName}, ${parentName}, ${props});`,
);
}
private objectDeclaration(node: AST.ObjectDeclaration): string {
// Runtime handles case normalization
const className =
node.className.type === "Identifier"
? JSON.stringify(node.className.name)
: this.expression(node.className);
const instanceName =
node.instanceName === null
? "null"
: node.instanceName.type === "Identifier"
? JSON.stringify(node.instanceName.name)
: this.expression(node.instanceName);
// Separate properties and child objects
const props: AST.Assignment[] = [];
const children: AST.ObjectDeclaration[] = [];
for (const item of node.body) {
if (item.type === "Assignment") {
props.push(item);
} else {
children.push(item);
}
}
const propsStr = this.objectBody(props);
if (children.length > 0) {
const childrenStr = children
.map((c) => this.objectDeclaration(c))
.join(",\n");
return `${this.runtime}.create(${className}, ${instanceName}, ${propsStr}, [\n${childrenStr}\n])`;
}
return `${this.runtime}.create(${className}, ${instanceName}, ${propsStr})`;
}
private objectBody(items: AST.ObjectBodyItem[]): string {
if (items.length === 0) return "{}";
const props: string[] = [];
for (const item of items) {
if (item.type === "Assignment") {
const value = this.expression(item.value);
if (item.target.type === "Identifier") {
// Simple property: fieldName = value
const key = item.target.name;
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
props.push(`${key}: ${value}`);
} else {
props.push(`[${JSON.stringify(key)}]: ${value}`);
}
} else if (item.target.type === "IndexExpression") {
// Indexed property: stateName[0] = value
// This sets a property on the object being defined, not an external variable
const propKey = this.objectPropertyKey(item.target);
props.push(`[${propKey}]: ${value}`);
} else {
// Other computed property key
const computedKey = this.expression(item.target);
props.push(`[${computedKey}]: ${value}`);
}
}
}
// Format: single line for 1 prop, multiline for 2+
if (props.length <= 1) {
return `{ ${props.join(", ")} }`;
}
const innerIndent = this.indent.repeat(this.indentLevel + 1);
const outerIndent = this.indent.repeat(this.indentLevel);
return `{\n${innerIndent}${props.join(",\n" + innerIndent)}\n${outerIndent}}`;
}
/**
* Generate a property key for an indexed expression inside an object/datablock body.
* stateName[0] -> $.key("stateName", 0)
* arr[i, j] -> $.key("arr", i, j)
*/
private objectPropertyKey(node: AST.IndexExpression): string {
// Get the base name - should be an identifier for datablock properties
const baseName =
node.object.type === "Identifier"
? JSON.stringify(node.object.name)
: this.expression(node.object);
// Get the indices
const indices = Array.isArray(node.index)
? node.index.map((i) => this.expression(i)).join(", ")
: this.expression(node.index);
return `${this.runtime}.key(${baseName}, ${indices})`;
}
private ifStatement(node: AST.IfStatement): string {
const test = this.expression(node.test);
const consequent = this.statementAsBlock(node.consequent);
if (node.alternate) {
if (node.alternate.type === "IfStatement") {
// else if
const alternate = this.ifStatement(node.alternate).replace(/^\s*/, "");
return this.line(`if (${test}) ${consequent} else ${alternate}`);
} else {
const alternate = this.statementAsBlock(node.alternate);
return this.line(`if (${test}) ${consequent} else ${alternate}`);
}
}
return this.line(`if (${test}) ${consequent}`);
}
private forStatement(node: AST.ForStatement): string {
const init = node.init ? this.expression(node.init) : "";
const test = node.test ? this.expression(node.test) : "";
const update = node.update ? this.expression(node.update) : "";
const body = this.statementAsBlock(node.body);
return this.line(`for (${init}; ${test}; ${update}) ${body}`);
}
private whileStatement(node: AST.WhileStatement): string {
const test = this.expression(node.test);
const body = this.statementAsBlock(node.body);
return this.line(`while (${test}) ${body}`);
}
private doWhileStatement(node: AST.DoWhileStatement): string {
const body = this.statementAsBlock(node.body);
const test = this.expression(node.test);
return this.line(`do ${body} while (${test});`);
}
private switchStatement(node: AST.SwitchStatement): string {
if (node.stringMode) {
// switch$ requires runtime helper for case-insensitive matching
return this.switchStringStatement(node);
}
const discriminant = this.expression(node.discriminant);
this.indentLevel++;
const cases: string[] = [];
for (const c of node.cases) {
cases.push(this.switchCase(c));
}
this.indentLevel--;
return `${this.line(`switch (${discriminant}) {`)}\n${cases.join("\n")}\n${this.line("}")}`;
}
private switchCase(node: AST.SwitchCase): string {
const lines: string[] = [];
// Handle "or" syntax: case 1 or 2 or 3:
if (node.test === null) {
lines.push(this.line("default:"));
} else if (Array.isArray(node.test)) {
for (const t of node.test) {
lines.push(this.line(`case ${this.expression(t)}:`));
}
} else {
lines.push(this.line(`case ${this.expression(node.test)}:`));
}
this.indentLevel++;
for (const stmt of node.consequent) {
lines.push(this.statement(stmt));
}
lines.push(this.line("break;"));
this.indentLevel--;
return lines.join("\n");
}
private switchStringStatement(node: AST.SwitchStatement): string {
// switch$ uses case-insensitive string matching - emit runtime call
const discriminant = this.expression(node.discriminant);
const cases: string[] = [];
for (const c of node.cases) {
if (c.test === null) {
cases.push(`default: () => { ${this.blockContent(c.consequent)} }`);
} else if (Array.isArray(c.test)) {
for (const t of c.test) {
cases.push(
`${this.expression(t)}: () => { ${this.blockContent(c.consequent)} }`,
);
}
} else {
cases.push(
`${this.expression(c.test)}: () => { ${this.blockContent(c.consequent)} }`,
);
}
}
return this.line(
`${this.runtime}.switchStr(${discriminant}, { ${cases.join(", ")} });`,
);
}
private returnStatement(node: AST.ReturnStatement): string {
if (node.value) {
return this.line(`return ${this.expression(node.value)};`);
}
return this.line("return;");
}
private blockStatement(node: AST.BlockStatement): string {
this.indentLevel++;
const content = node.body.map((s) => this.statement(s)).join("\n");
this.indentLevel--;
return `{\n${content}\n${this.line("}")}`;
}
private statementAsBlock(node: AST.Statement): string {
if (node.type === "BlockStatement") {
return this.blockStatement(node);
}
// Wrap single statement in block
this.indentLevel++;
const content = this.statement(node);
this.indentLevel--;
return `{\n${content}\n${this.line("}")}`;
}
private blockContent(stmts: AST.Statement[]): string {
return stmts.map((s) => this.statement(s).trim()).join(" ");
}
// ===========================================================================
// Expressions
// ===========================================================================
private expression(node: AST.Expression): string {
switch (node.type) {
case "Identifier":
return this.identifier(node);
case "Variable":
return this.variable(node);
case "NumberLiteral":
return String(node.value);
case "StringLiteral":
return JSON.stringify(node.value);
case "BooleanLiteral":
return String(node.value);
case "BinaryExpression":
return this.binaryExpression(node);
case "UnaryExpression":
return this.unaryExpression(node);
case "PostfixExpression":
return this.postfixExpression(node);
case "AssignmentExpression":
return this.assignmentExpression(node);
case "ConditionalExpression":
return `(${this.expression(node.test)} ? ${this.expression(node.consequent)} : ${this.expression(node.alternate)})`;
case "CallExpression":
return this.callExpression(node);
case "MemberExpression":
return this.memberExpression(node);
case "IndexExpression":
return this.indexExpression(node);
case "TagDereferenceExpression":
return `${this.runtime}.deref(${this.expression(node.argument)})`;
case "ObjectDeclaration":
return this.objectDeclaration(node);
case "DatablockDeclaration":
// Datablocks as expressions are rare but possible - runtime handles case normalization
return `${this.runtime}.datablock(${JSON.stringify(node.className.name)}, ${node.instanceName ? JSON.stringify(node.instanceName.name) : "null"}, ${node.parent ? JSON.stringify(node.parent.name) : "null"}, ${this.objectBody(node.body)})`;
default:
throw new Error(`Unknown expression type: ${(node as any).type}`);
}
}
private identifier(node: AST.Identifier): string {
const info = parseMethodName(node.name);
if (info && info.namespace.toLowerCase() === "parent") {
return node.name;
}
if (info) {
return `${this.runtime}.nsRef(${JSON.stringify(info.namespace)}, ${JSON.stringify(info.method)})`;
}
return JSON.stringify(node.name);
}
private variable(node: AST.Variable): string {
if (node.scope === "global") {
return `${this.globals}.get(${JSON.stringify(node.name)})`;
}
return `${this.locals}.get(${JSON.stringify(node.name)})`;
}
private binaryExpression(node: AST.BinaryExpression): string {
const left = this.expression(node.left);
const right = this.expression(node.right);
const op = node.operator;
// Integer operations need runtime helpers
if (INTEGER_OPERATORS.has(op)) {
const helper = OPERATOR_HELPERS[op];
return `${helper}(${left}, ${right})`;
}
// String concat operators
const concat = this.concatExpression(left, op, right);
if (concat) return concat;
// String comparison operators
if (op === "$=") {
return `${this.runtime}.streq(${left}, ${right})`;
}
if (op === "!$=") {
return `!${this.runtime}.streq(${left}, ${right})`;
}
// Logical operators (short-circuit, pass through)
if (op === "&&" || op === "||") {
return `(${left} ${op} ${right})`;
}
// Arithmetic operators use runtime helpers for proper numeric coercion
if (ARITHMETIC_OPERATORS.has(op)) {
const helper = OPERATOR_HELPERS[op];
return `${helper}(${left}, ${right})`;
}
// Comparison operators use runtime helpers for proper numeric coercion
if (COMPARISON_OPERATORS.has(op)) {
const helper = OPERATOR_HELPERS[op];
return `${helper}(${left}, ${right})`;
}
// Fallback (shouldn't reach here with valid TorqueScript)
return `(${left} ${op} ${right})`;
}
private unaryExpression(node: AST.UnaryExpression): string {
if (node.operator === "++" || node.operator === "--") {
const access = this.getAccessInfo(node.argument);
if (access) {
const delta = node.operator === "++" ? 1 : -1;
// Prefix: set and return the new value
return access.setter(`${this.runtime}.add(${access.getter}, ${delta})`);
}
}
const arg = this.expression(node.argument);
if (node.operator === "~") {
return `${this.runtime}.bitnot(${arg})`;
}
if (node.operator === "-") {
return `${this.runtime}.neg(${arg})`;
}
// ! passes through (JS boolean coercion works correctly)
return `${node.operator}${arg}`;
}
private postfixExpression(node: AST.PostfixExpression): string {
const access = this.getAccessInfo(node.argument);
if (access) {
const helper =
node.operator === "++" ? access.postIncHelper : access.postDecHelper;
if (helper) {
return helper;
}
}
return `${this.expression(node.argument)}${node.operator}`;
}
private assignmentExpression(node: AST.AssignmentExpression): string {
const value = this.expression(node.value);
const op = node.operator;
const access = this.getAccessInfo(node.target);
if (!access) {
throw new Error(`Unhandled assignment target type: ${node.target.type}`);
}
if (op === "=") {
// Simple assignment
return access.setter(value);
} else {
// Compound assignment: +=, -=, etc.
const baseOp = op.slice(0, -1);
const newValue = this.compoundAssignmentValue(
access.getter,
baseOp,
value,
);
return access.setter(newValue);
}
}
private callExpression(node: AST.CallExpression): string {
const args = node.arguments.map((a) => this.expression(a)).join(", ");
if (node.callee.type === "Identifier") {
const name = node.callee.name;
const info = parseMethodName(name);
if (info && info.namespace.toLowerCase() === "parent") {
if (this.currentClass) {
return `${this.runtime}.parent(${JSON.stringify(this.currentClass)}, ${JSON.stringify(info.method)}, arguments[0]${args ? ", " + args : ""})`;
} else if (this.currentFunction) {
return `${this.runtime}.parentFunc(${JSON.stringify(this.currentFunction)}${args ? ", " + args : ""})`;
} else {
throw new Error("Parent:: call outside of function context");
}
}
if (info) {
return `${this.runtime}.nsCall(${JSON.stringify(info.namespace)}, ${JSON.stringify(info.method)}${args ? ", " + args : ""})`;
}
return `${this.functions}.call(${JSON.stringify(name)}${args ? ", " + args : ""})`;
}
if (node.callee.type === "MemberExpression") {
const obj = this.expression(node.callee.object);
const method =
node.callee.property.type === "Identifier"
? JSON.stringify(node.callee.property.name)
: this.expression(node.callee.property);
return `${this.runtime}.call(${obj}, ${method}${args ? ", " + args : ""})`;
}
const callee = this.expression(node.callee);
return `${callee}(${args})`;
}
private memberExpression(node: AST.MemberExpression): string {
const obj = this.expression(node.object);
if (node.computed || node.property.type !== "Identifier") {
return `${this.runtime}.prop(${obj}, ${this.expression(node.property)})`;
}
return `${this.runtime}.prop(${obj}, ${JSON.stringify(node.property.name)})`;
}
private indexExpression(node: AST.IndexExpression): string {
const indices = Array.isArray(node.index)
? node.index.map((i) => this.expression(i))
: [this.expression(node.index)];
if (node.object.type === "Variable") {
const baseName = JSON.stringify(node.object.name);
const store = node.object.scope === "global" ? this.globals : this.locals;
return `${store}.get(${baseName}, ${indices.join(", ")})`;
}
const obj = this.expression(node.object);
if (indices.length === 1) {
return `${this.runtime}.getIndex(${obj}, ${indices[0]})`;
}
return `${this.runtime}.getIndex(${obj}, ${this.runtime}.key(${indices.join(", ")}))`;
}
private line(code: string): string {
return this.indent.repeat(this.indentLevel) + code;
}
private concatExpression(
left: string,
op: string,
right: string,
): string | null {
switch (op) {
case "@":
return `${this.runtime}.concat(${left}, ${right})`;
case "SPC":
return `${this.runtime}.concat(${left}, " ", ${right})`;
case "TAB":
return `${this.runtime}.concat(${left}, "\\t", ${right})`;
case "NL":
return `${this.runtime}.concat(${left}, "\\n", ${right})`;
default:
return null;
}
}
private compoundAssignmentValue(
getter: string,
op: string,
value: string,
): string {
// Integer operators need runtime helpers
if (INTEGER_OPERATORS.has(op)) {
const helper = OPERATOR_HELPERS[op];
return `${helper}(${getter}, ${value})`;
}
// String concat operators
const concat = this.concatExpression(getter, op, value);
if (concat) return concat;
// Arithmetic operators need runtime helpers for proper numeric coercion
if (ARITHMETIC_OPERATORS.has(op)) {
const helper = OPERATOR_HELPERS[op];
return `${helper}(${getter}, ${value})`;
}
// Fallback (shouldn't reach here with valid TorqueScript)
return `(${getter} ${op} ${value})`;
}
}
export function generate(ast: AST.Program, options?: GeneratorOptions): string {
const generator = new CodeGenerator(options);
return generator.generate(ast);
}

46
src/torqueScript/index.ts Normal file
View file

@ -0,0 +1,46 @@
import TorqueScript from "@/generated/TorqueScript.cjs";
import { generate, type GeneratorOptions } from "./codegen";
import type { Program } from "./ast";
export { generate, type GeneratorOptions } from "./codegen";
export type { Program } from "./ast";
export { createBuiltins } from "./builtins";
export { createRuntime } from "./runtime";
export { normalizePath } from "./utils";
export type {
BuiltinsContext,
BuiltinsFactory,
RuntimeState,
TorqueObject,
TorqueRuntime,
TorqueRuntimeOptions,
} from "./types";
export interface ParseOptions {
filename?: string;
}
export type TranspileOptions = ParseOptions & GeneratorOptions;
export function parse(source: string, options?: ParseOptions): Program {
try {
return TorqueScript.parse(source);
} catch (error: any) {
if (options?.filename && error.location) {
throw new Error(
`${options.filename}:${error.location.start.line}:${error.location.start.column}: ${error.message}`,
{ cause: error },
);
}
throw error;
}
}
export function transpile(
source: string,
options?: TranspileOptions,
): { code: string; ast: Program } {
const ast = parse(source, options);
const code = generate(ast, options);
return { code, ast };
}

File diff suppressed because it is too large Load diff

895
src/torqueScript/runtime.ts Normal file
View file

@ -0,0 +1,895 @@
import { generate } from "./codegen";
import { parse, type Program } from "./index";
import { createBuiltins as defaultCreateBuiltins } from "./builtins";
import { CaseInsensitiveMap, normalizePath } from "./utils";
import type {
BuiltinsContext,
FunctionStack,
FunctionsAPI,
GlobalsAPI,
LoadedScript,
LoadScriptOptions,
LocalsAPI,
MethodStack,
PackageState,
RuntimeAPI,
RuntimeState,
TorqueFunction,
TorqueMethod,
TorqueObject,
TorqueRuntime,
TorqueRuntimeOptions,
VariableStoreAPI,
} from "./types";
function normalize(name: string): string {
return name.toLowerCase();
}
function toU32(value: any): number {
return (Number(value) | 0) >>> 0;
}
function toI32(value: any): number {
return Number(value) | 0;
}
/** Coerce instance name to string, returning null for empty/null values. */
function toName(value: any): string | null {
if (value == null) return null;
if (typeof value === "string") return value || null;
if (typeof value === "number") return String(value);
throw new Error(`Invalid instance name type: ${typeof value}`);
}
export function createRuntime(
options: TorqueRuntimeOptions = {},
): TorqueRuntime {
const methods = new CaseInsensitiveMap<CaseInsensitiveMap<MethodStack>>();
const functions = new CaseInsensitiveMap<FunctionStack>();
const packages = new CaseInsensitiveMap<PackageState>();
const activePackages: string[] = [];
const FIRST_DATABLOCK_ID = 3;
const FIRST_DYNAMIC_ID = 1027;
let nextDatablockId = FIRST_DATABLOCK_ID;
let nextObjectId = FIRST_DYNAMIC_ID;
const objectsById = new Map<number, TorqueObject>();
const objectsByName = new CaseInsensitiveMap<TorqueObject>();
const datablocks = new CaseInsensitiveMap<TorqueObject>();
const globals = new CaseInsensitiveMap<any>();
const executedScripts = new Set<string>();
const scripts = new Map<string, Program>();
const pendingTimeouts = new Set<ReturnType<typeof setTimeout>>();
let currentPackage: PackageState | null = null;
let runtimeRef: TorqueRuntime | null = null;
const getRuntime = () => runtimeRef!;
const createBuiltins = options.builtins ?? defaultCreateBuiltins;
const builtinsCtx: BuiltinsContext = { runtime: getRuntime };
const builtins = createBuiltins(builtinsCtx);
function registerMethod(
className: string,
methodName: string,
fn: TorqueMethod,
): void {
if (currentPackage) {
if (!currentPackage.methods.has(className)) {
currentPackage.methods.set(className, new CaseInsensitiveMap());
}
currentPackage.methods.get(className)!.set(methodName, fn);
} else {
if (!methods.has(className)) {
methods.set(className, new CaseInsensitiveMap());
}
const classMethods = methods.get(className)!;
if (!classMethods.has(methodName)) {
classMethods.set(methodName, []);
}
classMethods.get(methodName)!.push(fn);
}
}
function registerFunction(name: string, fn: TorqueFunction): void {
if (currentPackage) {
currentPackage.functions.set(name, fn);
} else {
if (!functions.has(name)) {
functions.set(name, []);
}
functions.get(name)!.push(fn);
}
}
function activatePackage(name: string): void {
const pkg = packages.get(name);
if (!pkg || pkg.active) return;
pkg.active = true;
activePackages.push(pkg.name);
for (const [className, methodMap] of pkg.methods) {
if (!methods.has(className)) {
methods.set(className, new CaseInsensitiveMap());
}
const classMethods = methods.get(className)!;
for (const [methodName, fn] of methodMap) {
if (!classMethods.has(methodName)) {
classMethods.set(methodName, []);
}
classMethods.get(methodName)!.push(fn);
}
}
for (const [funcName, fn] of pkg.functions) {
if (!functions.has(funcName)) {
functions.set(funcName, []);
}
functions.get(funcName)!.push(fn);
}
}
function deactivatePackage(name: string): void {
const pkg = packages.get(name);
if (!pkg || !pkg.active) return;
pkg.active = false;
// Find and remove from activePackages (case-insensitive search)
const idx = activePackages.findIndex(
(n) => n.toLowerCase() === name.toLowerCase(),
);
if (idx !== -1) activePackages.splice(idx, 1);
// Remove the specific functions this package added (not just pop!)
for (const [className, methodMap] of pkg.methods) {
const classMethods = methods.get(className);
if (!classMethods) continue;
for (const [methodName, fn] of methodMap) {
const stack = classMethods.get(methodName);
if (stack) {
const fnIdx = stack.indexOf(fn);
if (fnIdx !== -1) stack.splice(fnIdx, 1);
}
}
}
for (const [funcName, fn] of pkg.functions) {
const stack = functions.get(funcName);
if (stack) {
const fnIdx = stack.indexOf(fn);
if (fnIdx !== -1) stack.splice(fnIdx, 1);
}
}
}
function packageFn(name: string, fn: () => void): void {
let pkg = packages.get(name);
if (!pkg) {
pkg = {
name,
active: false,
methods: new CaseInsensitiveMap(),
functions: new CaseInsensitiveMap(),
};
packages.set(name, pkg);
}
const prevPackage = currentPackage;
currentPackage = pkg;
fn();
currentPackage = prevPackage;
activatePackage(name);
}
function createObject(
className: string,
instanceName: string | null,
props: Record<string, any>,
children?: TorqueObject[],
): TorqueObject {
const normClass = normalize(className);
const id = nextObjectId++;
const obj: TorqueObject = {
_class: normClass,
_className: className,
_id: id,
};
for (const [key, value] of Object.entries(props)) {
obj[normalize(key)] = value;
}
objectsById.set(id, obj);
const name = toName(instanceName);
if (name) {
obj._name = name;
objectsByName.set(name, obj);
}
if (children) {
for (const child of children) {
child._parent = obj;
}
obj._children = children;
}
const onAdd = findMethod(className, "onAdd");
if (onAdd) {
onAdd(obj);
}
return obj;
}
function deleteObject(obj: any): boolean {
if (obj == null) return false;
// Resolve object if given by ID or name
let target: TorqueObject | undefined;
if (typeof obj === "number") {
target = objectsById.get(obj);
} else if (typeof obj === "string") {
target = objectsByName.get(obj);
} else if (typeof obj === "object" && obj._id) {
target = obj;
}
if (!target) return false;
// Call onRemove if it exists
const onRemove = findMethod(target._className, "onRemove");
if (onRemove) {
onRemove(target);
}
// Remove from tracking maps
objectsById.delete(target._id);
if (target._name) {
objectsByName.delete(target._name);
}
if (target._isDatablock && target._name) {
datablocks.delete(target._name);
}
// Remove from parent's children array
if (target._parent && target._parent._children) {
const idx = target._parent._children.indexOf(target);
if (idx !== -1) {
target._parent._children.splice(idx, 1);
}
}
// Recursively delete children
if (target._children) {
for (const child of [...target._children]) {
deleteObject(child);
}
}
return true;
}
function datablock(
className: string,
instanceName: string | null,
parentName: string | null,
props: Record<string, any>,
): TorqueObject {
const normClass = normalize(className);
const id = nextDatablockId++;
const obj: TorqueObject = {
_class: normClass,
_className: className,
_id: id,
_isDatablock: true,
};
const parentKey = toName(parentName);
if (parentKey) {
const parentObj = datablocks.get(parentKey);
if (parentObj) {
for (const [key, value] of Object.entries(parentObj)) {
if (!key.startsWith("_")) {
obj[key] = value;
}
}
obj._parent = parentObj;
}
}
for (const [key, value] of Object.entries(props)) {
obj[normalize(key)] = value;
}
objectsById.set(id, obj);
const name = toName(instanceName);
if (name) {
obj._name = name;
objectsByName.set(name, obj);
datablocks.set(name, obj);
}
return obj;
}
function prop(obj: any, name: string): any {
if (obj == null) return "";
return obj[normalize(name)] ?? "";
}
function setProp(obj: any, name: string, value: any): any {
if (obj == null) return value;
obj[normalize(name)] = value;
return value;
}
function getIndex(obj: any, index: any): any {
if (obj == null) return "";
return obj[String(index)] ?? "";
}
function setIndex(obj: any, index: any, value: any): any {
if (obj == null) return value;
obj[String(index)] = value;
return value;
}
function postIncDec(obj: any, key: string, delta: 1 | -1): number {
if (obj == null) return 0;
const oldValue = toNum(obj[key]);
obj[key] = oldValue + delta;
return oldValue;
}
function propPostInc(obj: any, name: string): number {
return postIncDec(obj, normalize(name), 1);
}
function propPostDec(obj: any, name: string): number {
return postIncDec(obj, normalize(name), -1);
}
function indexPostInc(obj: any, index: any): number {
return postIncDec(obj, String(index), 1);
}
function indexPostDec(obj: any, index: any): number {
return postIncDec(obj, String(index), -1);
}
// TorqueScript array indexing: foo[0] -> foo0, foo[0,1] -> foo0_1
function key(base: string, ...indices: any[]): string {
return base + indices.join("_");
}
function findMethod(
className: string,
methodName: string,
): TorqueMethod | null {
const classMethods = methods.get(className);
if (classMethods) {
const stack = classMethods.get(methodName);
if (stack && stack.length > 0) {
return stack[stack.length - 1];
}
}
return null;
}
function findFunction(name: string): TorqueFunction | null {
const stack = functions.get(name);
if (stack && stack.length > 0) {
return stack[stack.length - 1];
}
return null;
}
function call(obj: any, methodName: string, ...args: any[]): any {
if (obj == null) return "";
// Dereference string/number names to actual objects
if (typeof obj === "string" || typeof obj === "number") {
obj = deref(obj);
if (obj == null) return "";
}
const objClass = obj._className || obj._class;
if (objClass) {
const fn = findMethod(objClass, methodName);
if (fn) {
return fn(obj, ...args);
}
}
const db = obj._datablock || obj;
if (db._parent) {
let current = db._parent;
while (current) {
const parentClass = current._className || current._class;
if (parentClass) {
const fn = findMethod(parentClass, methodName);
if (fn) {
return fn(obj, ...args);
}
}
current = current._parent;
}
}
return "";
}
function nsCall(namespace: string, method: string, ...args: any[]): any {
const fn = findMethod(namespace, method);
if (fn) {
return (fn as TorqueFunction)(...args);
}
return "";
}
function nsRef(
namespace: string,
method: string,
): ((...args: any[]) => any) | null {
const fn = findMethod(namespace, method);
if (fn) {
return (...args: any[]) => (fn as TorqueFunction)(...args);
}
return null;
}
function parent(
currentClass: string,
methodName: string,
thisObj: any,
...args: any[]
): any {
const classMethods = methods.get(currentClass);
if (!classMethods) return "";
const stack = classMethods.get(methodName);
if (!stack || stack.length < 2) return "";
// Call parent method with the object as first argument
return stack[stack.length - 2](thisObj, ...args);
}
function parentFunc(currentFunc: string, ...args: any[]): any {
const stack = functions.get(currentFunc);
if (!stack || stack.length < 2) return "";
return stack[stack.length - 2](...args);
}
function toNum(value: any): number {
if (value == null || value === "") return 0;
const n = Number(value);
return isNaN(n) ? 0 : n;
}
function add(a: any, b: any): number {
return toNum(a) + toNum(b);
}
function sub(a: any, b: any): number {
return toNum(a) - toNum(b);
}
function mul(a: any, b: any): number {
return toNum(a) * toNum(b);
}
function div(a: any, b: any): number {
const divisor = toNum(b);
if (divisor === 0) return 0; // TorqueScript returns 0 for division by zero
return toNum(a) / divisor;
}
function neg(a: any): number {
return -toNum(a);
}
function lt(a: any, b: any): boolean {
return toNum(a) < toNum(b);
}
function le(a: any, b: any): boolean {
return toNum(a) <= toNum(b);
}
function gt(a: any, b: any): boolean {
return toNum(a) > toNum(b);
}
function ge(a: any, b: any): boolean {
return toNum(a) >= toNum(b);
}
function eq(a: any, b: any): boolean {
return toNum(a) === toNum(b);
}
function ne(a: any, b: any): boolean {
return toNum(a) !== toNum(b);
}
function mod(a: any, b: any): number {
const ib = toI32(b);
if (ib === 0) return 0;
return toI32(a) % ib;
}
function bitand(a: any, b: any): number {
return toU32(a) & toU32(b);
}
function bitor(a: any, b: any): number {
return toU32(a) | toU32(b);
}
function bitxor(a: any, b: any): number {
return toU32(a) ^ toU32(b);
}
function shl(a: any, b: any): number {
return toU32(toU32(a) << (toU32(b) & 31));
}
function shr(a: any, b: any): number {
return toU32(a) >>> (toU32(b) & 31);
}
function bitnot(a: any): number {
return ~toU32(a) >>> 0;
}
function concat(...parts: any[]): string {
return parts.map((p) => String(p ?? "")).join("");
}
function streq(a: any, b: any): boolean {
return String(a ?? "").toLowerCase() === String(b ?? "").toLowerCase();
}
function switchStr(
value: any,
cases: Record<string, () => void> & { default?: () => void },
): void {
const normValue = String(value ?? "").toLowerCase();
for (const [caseValue, handler] of Object.entries(cases)) {
if (caseValue === "default") continue;
if (normalize(caseValue) === normValue) {
handler();
return;
}
}
if (cases.default) {
cases.default();
}
}
function deref(tag: any): any {
if (tag == null || tag === "") return null;
return objectsByName.get(String(tag)) ?? null;
}
function nameToId(name: string): number {
const obj = objectsByName.get(name);
return obj ? obj._id : 0;
}
function isObject(obj: any): boolean {
if (obj == null) return false;
if (typeof obj === "object" && obj._id) return true;
if (typeof obj === "number") return objectsById.has(obj);
if (typeof obj === "string") return objectsByName.has(obj);
return false;
}
function isFunction(name: string): boolean {
return functions.has(name);
}
function isPackage(name: string): boolean {
return packages.has(name);
}
function createVariableStore(
storage: CaseInsensitiveMap<any>,
): VariableStoreAPI {
// TorqueScript array indexing: $foo[0] -> $foo0, $foo[0,1] -> $foo0_1
function fullName(name: string, indices: any[]): string {
return name + indices.join("_");
}
return {
get(name: string, ...indices: any[]): any {
return storage.get(fullName(name, indices)) ?? "";
},
set(name: string, ...args: any[]): any {
if (args.length === 0) {
throw new Error("set() requires at least a value argument");
}
if (args.length === 1) {
storage.set(name, args[0]);
return args[0];
}
const value = args[args.length - 1];
const indices = args.slice(0, -1);
storage.set(fullName(name, indices), value);
return value;
},
postInc(name: string, ...indices: any[]): number {
const key = fullName(name, indices);
const oldValue = toNum(storage.get(key));
storage.set(key, oldValue + 1);
return oldValue;
},
postDec(name: string, ...indices: any[]): number {
const key = fullName(name, indices);
const oldValue = toNum(storage.get(key));
storage.set(key, oldValue - 1);
return oldValue;
},
};
}
function createLocals(): LocalsAPI {
return createVariableStore(new CaseInsensitiveMap<any>());
}
const $: RuntimeAPI = {
registerMethod,
registerFunction,
package: packageFn,
activatePackage,
deactivatePackage,
create: createObject,
datablock,
deleteObject,
prop,
setProp,
getIndex,
setIndex,
propPostInc,
propPostDec,
indexPostInc,
indexPostDec,
key,
call,
nsCall,
nsRef,
parent,
parentFunc,
add,
sub,
mul,
div,
neg,
lt,
le,
gt,
ge,
eq,
ne,
mod,
bitand,
bitor,
bitxor,
shl,
shr,
bitnot,
concat,
streq,
switchStr,
deref,
nameToId,
isObject,
isFunction,
isPackage,
locals: createLocals,
};
const $f: FunctionsAPI = {
call(name: string, ...args: any[]): any {
const fn = findFunction(name);
if (fn) {
return fn(...args);
}
// Builtins are stored with lowercase keys
const builtin = builtins[name.toLowerCase()];
if (builtin) {
return builtin(...args);
}
throw new Error(
`Unknown function: ${name}(${args
.map((a) => JSON.stringify(a))
.join(", ")})`,
);
},
};
const $g: GlobalsAPI = createVariableStore(globals);
const generatedCode = new WeakMap<Program, string>();
const state: RuntimeState = {
methods,
functions,
packages,
activePackages,
objectsById,
objectsByName,
datablocks,
globals,
executedScripts,
scripts,
generatedCode,
pendingTimeouts,
startTime: Date.now(),
};
function destroy(): void {
for (const timeoutId of state.pendingTimeouts) {
clearTimeout(timeoutId);
}
state.pendingTimeouts.clear();
}
function getOrGenerateCode(ast: Program): string {
let code = generatedCode.get(ast);
if (code == null) {
code = generate(ast);
generatedCode.set(ast, code);
}
return code;
}
function executeAST(ast: Program): void {
const code = getOrGenerateCode(ast);
const execFn = new Function("$", "$f", "$g", code);
execFn($, $f, $g);
}
function createLoadedScript(ast: Program, path?: string): LoadedScript {
return {
execute(): void {
if (path) {
const normalized = normalizePath(path);
state.executedScripts.add(normalized);
}
executeAST(ast);
},
};
}
async function loadDependencies(
ast: Program,
loading: Set<string>,
): Promise<void> {
const loader = options.loadScript;
if (!loader) {
// No loader, can't resolve dependencies
if (ast.execScriptPaths.length > 0) {
console.warn(
`Script has exec() calls but no loadScript provided:`,
ast.execScriptPaths,
);
}
return;
}
for (const ref of ast.execScriptPaths) {
const normalized = normalizePath(ref);
// Skip if already loaded or currently loading (cycle detection)
if (state.scripts.has(normalized) || loading.has(normalized)) {
continue;
}
loading.add(normalized);
const source = await loader(ref);
if (source == null) {
console.warn(`Script not found: ${ref}`);
loading.delete(normalized);
continue;
}
let depAst: Program;
try {
depAst = parse(source, { filename: ref });
} catch (err) {
console.warn(`Failed to parse script: ${ref}`, err);
loading.delete(normalized);
continue;
}
// Recursively load this script's dependencies first
await loadDependencies(depAst, loading);
// Store the parsed AST
state.scripts.set(normalized, depAst);
loading.delete(normalized);
}
}
async function loadFromPath(path: string): Promise<LoadedScript> {
const loader = options.loadScript;
if (!loader) {
throw new Error("loadFromPath requires loadScript option to be set");
}
// Check if already loaded (avoid unnecessary fetch)
const normalized = normalizePath(path);
if (state.scripts.has(normalized)) {
return createLoadedScript(state.scripts.get(normalized)!, path);
}
const source = await loader(path);
if (source == null) {
throw new Error(`Script not found: ${path}`);
}
return loadFromSource(source, { path });
}
async function loadFromSource(
source: string,
loadOptions?: LoadScriptOptions,
): Promise<LoadedScript> {
// Check if already loaded
if (loadOptions?.path) {
const normalized = normalizePath(loadOptions.path);
if (state.scripts.has(normalized)) {
return createLoadedScript(
state.scripts.get(normalized)!,
loadOptions.path,
);
}
}
const ast = parse(source, { filename: loadOptions?.path });
return loadFromAST(ast, loadOptions);
}
async function loadFromAST(
ast: Program,
loadOptions?: LoadScriptOptions,
): Promise<LoadedScript> {
// Load dependencies
const loading = new Set<string>();
if (loadOptions?.path) {
const normalized = normalizePath(loadOptions.path);
loading.add(normalized);
state.scripts.set(normalized, ast);
}
await loadDependencies(ast, loading);
return createLoadedScript(ast, loadOptions?.path);
}
runtimeRef = {
$,
$f,
$g,
state,
destroy,
executeAST,
loadFromPath,
loadFromSource,
loadFromAST,
};
return runtimeRef;
}

View file

@ -0,0 +1,30 @@
import type { ScriptLoader } from "./types";
import { getUrlForPath } from "../loaders";
/**
* Creates a script loader for browser environments that fetches scripts
* using the manifest-based URL resolution.
*/
export function createScriptLoader(): ScriptLoader {
return async (path: string): Promise<string | null> => {
let url: string;
try {
url = getUrlForPath(path);
} catch (err) {
console.warn(`Script not in manifest: ${path}`, err);
return null;
}
try {
const response = await fetch(url);
if (!response.ok) {
console.warn(`Script fetch failed: ${path} (${response.status})`);
return null;
}
return await response.text();
} catch (err) {
console.warn(`Script fetch error: ${path}`, err);
return null;
}
};
}

View file

@ -0,0 +1,28 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { ScriptLoader } from "./types";
export interface CreateScriptLoaderOptions {
searchPaths: string[];
}
export function createScriptLoader(
options: CreateScriptLoaderOptions,
): ScriptLoader {
const { searchPaths } = options;
return async (path: string): Promise<string | null> => {
const normalizedPath = path.replace(/\\/g, "/");
for (const basePath of searchPaths) {
const fullPath = join(basePath, normalizedPath);
try {
return await readFile(fullPath, "utf8");
} catch {
// File doesn't exist in this search path, try next
}
}
return null;
};
}

179
src/torqueScript/types.ts Normal file
View file

@ -0,0 +1,179 @@
import type { Program } from "./ast";
import type { CaseInsensitiveMap } from "./utils";
export type TorqueFunction = (...args: any[]) => any;
export type TorqueMethod = (this_: TorqueObject, ...args: any[]) => any;
export interface TorqueObject {
_class: string; // normalized class name
_className: string; // original class name
_id: number;
_name?: string;
_isDatablock?: boolean;
_parent?: TorqueObject;
_children?: TorqueObject[];
[key: string]: any;
}
export type MethodStack = TorqueMethod[];
export type FunctionStack = TorqueFunction[];
export interface PackageState {
name: string;
active: boolean;
methods: CaseInsensitiveMap<CaseInsensitiveMap<TorqueMethod>>; // class -> method -> fn
functions: CaseInsensitiveMap<TorqueFunction>;
}
export interface RuntimeState {
methods: CaseInsensitiveMap<CaseInsensitiveMap<MethodStack>>;
functions: CaseInsensitiveMap<FunctionStack>;
packages: CaseInsensitiveMap<PackageState>;
activePackages: readonly string[];
objectsById: Map<number, TorqueObject>;
objectsByName: CaseInsensitiveMap<TorqueObject>;
datablocks: CaseInsensitiveMap<TorqueObject>;
globals: CaseInsensitiveMap<any>;
executedScripts: Set<string>;
scripts: Map<string, Program>;
generatedCode: WeakMap<Program, string>;
pendingTimeouts: Set<ReturnType<typeof setTimeout>>;
startTime: number;
}
export interface TorqueRuntime {
$: RuntimeAPI;
$f: FunctionsAPI;
$g: GlobalsAPI;
state: RuntimeState;
destroy(): void;
executeAST(ast: Program): void;
loadFromPath(path: string): Promise<LoadedScript>;
loadFromSource(
source: string,
options?: LoadScriptOptions,
): Promise<LoadedScript>;
loadFromAST(ast: Program, options?: LoadScriptOptions): Promise<LoadedScript>;
}
export type ScriptLoader = (path: string) => Promise<string | null>;
export interface LoadedScript {
execute(): void;
}
export interface TorqueRuntimeOptions {
loadScript?: ScriptLoader;
builtins?: BuiltinsFactory;
}
export interface LoadScriptOptions {
path?: string;
}
export interface RuntimeAPI {
// Registration
registerMethod(className: string, methodName: string, fn: TorqueMethod): void;
registerFunction(name: string, fn: TorqueFunction): void;
package(name: string, fn: () => void): void;
activatePackage(name: string): void;
deactivatePackage(name: string): void;
// Object creation and deletion
create(
className: string,
instanceName: string | null,
props: Record<string, any>,
children?: TorqueObject[],
): TorqueObject;
datablock(
className: string,
instanceName: string | null,
parentName: string | null,
props: Record<string, any>,
): TorqueObject;
deleteObject(obj: any): boolean;
// Property access
prop(obj: any, name: string): any;
setProp(obj: any, name: string, value: any): any;
getIndex(obj: any, index: any): any;
setIndex(obj: any, index: any, value: any): any;
propPostInc(obj: any, name: string): number;
propPostDec(obj: any, name: string): number;
indexPostInc(obj: any, index: any): number;
indexPostDec(obj: any, index: any): number;
key(...parts: any[]): string;
// Method dispatch
call(obj: any, methodName: string, ...args: any[]): any;
nsCall(namespace: string, method: string, ...args: any[]): any;
nsRef(namespace: string, method: string): ((...args: any[]) => any) | null;
parent(currentClass: string, methodName: string, ...args: any[]): any;
parentFunc(currentFunc: string, ...args: any[]): any;
// Arithmetic (numeric coercion)
add(a: any, b: any): number;
sub(a: any, b: any): number;
mul(a: any, b: any): number;
div(a: any, b: any): number;
neg(a: any): number;
// Numeric comparison
lt(a: any, b: any): boolean;
le(a: any, b: any): boolean;
gt(a: any, b: any): boolean;
ge(a: any, b: any): boolean;
eq(a: any, b: any): boolean;
ne(a: any, b: any): boolean;
// Integer math
mod(a: any, b: any): number;
bitand(a: any, b: any): number;
bitor(a: any, b: any): number;
bitxor(a: any, b: any): number;
shl(a: any, b: any): number;
shr(a: any, b: any): number;
bitnot(a: any): number;
// String operations
concat(...parts: any[]): string;
streq(a: any, b: any): boolean;
switchStr(
value: any,
cases: Record<string, () => void> & { default?: () => void },
): void;
// Special
deref(tag: any): any;
nameToId(name: string): number;
isObject(obj: any): boolean;
isFunction(name: string): boolean;
isPackage(name: string): boolean;
// Local variable scope
locals(): LocalsAPI;
}
export interface FunctionsAPI {
call(name: string, ...args: any[]): any;
}
export interface VariableStoreAPI {
get(name: string, ...indices: any[]): any;
set(name: string, ...args: any[]): any;
postInc(name: string, ...indices: any[]): number;
postDec(name: string, ...indices: any[]): number;
}
// Backwards compatibility aliases
export type GlobalsAPI = VariableStoreAPI;
export type LocalsAPI = VariableStoreAPI;
export interface BuiltinsContext {
runtime: () => TorqueRuntime;
}
export type BuiltinsFactory = (
ctx: BuiltinsContext,
) => Record<string, TorqueFunction>;

94
src/torqueScript/utils.ts Normal file
View file

@ -0,0 +1,94 @@
/**
* Map with case-insensitive key lookups, preserving original casing.
* The underlying map stores values with original key casing for inspection.
*/
export class CaseInsensitiveMap<V> {
private map = new Map<string, V>();
private keyLookup = new Map<string, string>(); // normalized -> original
constructor(entries?: Iterable<readonly [string, V]> | null) {
if (entries) {
for (const [key, value] of entries) {
this.set(key, value);
}
}
}
get size(): number {
return this.map.size;
}
get(key: string): V | undefined {
const originalKey = this.keyLookup.get(key.toLowerCase());
return originalKey !== undefined ? this.map.get(originalKey) : undefined;
}
set(key: string, value: V): this {
const norm = key.toLowerCase();
const existingKey = this.keyLookup.get(norm);
if (existingKey !== undefined) {
// Key exists, update value using existing casing
this.map.set(existingKey, value);
} else {
// New key, store with original casing
this.keyLookup.set(norm, key);
this.map.set(key, value);
}
return this;
}
has(key: string): boolean {
return this.keyLookup.has(key.toLowerCase());
}
delete(key: string): boolean {
const norm = key.toLowerCase();
const originalKey = this.keyLookup.get(norm);
if (originalKey !== undefined) {
this.keyLookup.delete(norm);
return this.map.delete(originalKey);
}
return false;
}
clear(): void {
this.map.clear();
this.keyLookup.clear();
}
keys(): IterableIterator<string> {
return this.map.keys();
}
values(): IterableIterator<V> {
return this.map.values();
}
entries(): IterableIterator<[string, V]> {
return this.map.entries();
}
[Symbol.iterator](): IterableIterator<[string, V]> {
return this.map[Symbol.iterator]();
}
forEach(
callback: (value: V, key: string, map: CaseInsensitiveMap<V>) => void,
): void {
for (const [key, value] of this.map) {
callback(value, key, this);
}
}
get [Symbol.toStringTag](): string {
return "CaseInsensitiveMap";
}
getOriginalKey(key: string): string | undefined {
return this.keyLookup.get(key.toLowerCase());
}
}
export function normalizePath(path: string): string {
return path.replace(/\\/g, "/").toLowerCase();
}