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

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();
}