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

67
CLAUDE.md Normal file
View file

@ -0,0 +1,67 @@
- Keep track of what your current working directory is, and consider it when
running shell commands. Running a command in a directory you didn't expect can
potentially be catastrophic. Always use the correct relative paths to files
based on the current directory you are in.
- Some scripts may expect the current working directory of the process to be
a specific path. For example, most scripts in the `scripts` folder expect to
be run from the root of the repository, so file and directory paths are
relative to that.
- Always write new scripts in TypeScript. Use ES6 import and export, never
`require()` or `createRequire()`.
- If you think you need to use `require()` to get something working, try
changing how you are importing it instead (like importing the default export,
`import * as ...`, or other strategies).
- Despite preferring TypeScript, it's OK if some code generation tools output
.js, .cjs, or .mjs files. For example, Peggy (formerly PEG.js) generated
parsers are JavaScript. Likewise with .js files that were generated by the
TorqueScript transpiler.
- TypeScript can be executed using `tsx`.
- Don't commit to git (or change any git history) unless requested. I will
review your changes and decide when (and whether) to commit.
- Import Node built-in modules using the `node:` prefix.
- Use Promise-based APIs when available. For example, prefer using
`node:fs/promises` over `node:fs`.
- For `node:fs` and `node:path`, prefer importing the whole module rather than
individual functions, since they contain a lot of exports with short names.
It often looks nicer to refer to them in code like `fs.readFile`, `path.join`,
and so on.
- Format code according to my Prettier settings. If there is no Prettier config
defined, use Prettier's default settings. After any code additions or changes,
running Prettier on the code should not produce any changes. Instead of
memorizing how to format code correctly, you may run Prettier itself as a tool
to do formatting for you.
- The TorqueScript grammar written in Peggy (formerly PEG.js) can be rebuilt
by running `npm run build:parser`.
- Typechecking can be done with `npm run typecheck`.
- Game files and .vl2 contents (like .mis, .cs, shapes like .dif and .dts,
textures like .png and .ifl, and more) can all be found in the `docs/base`
folder relative to the repository root.
- You are most likely running on a macOS system, therefore some command line
tools (like `awk` and others) may NOT be POSIX compliant. Before executing any
commands for the first time, determine what type of system you are on and what
type of shell you are executing within. This will prevent failures due to
incorrect shell syntax, arguments, or other assumptions.
- Do not write excessive comments, and prefer being concise when writing JSDoc
style comments. Assume your code will be read by a competent programmer and
don't over-explain. Prefer excluding `@param` and `@returns` from JSDoc
comments, as TypeScript type annotations and parameter names are often
sufficient - let the function names, class names, argument/parameter names,
and types do much of the talking. Prefer only writing the description and
occasional `@example` blocks in JSDoc comments (and only include examples if
the usage is somewhat complex).
- JSDoc comments are more likely to be needed as documentation for the public
API of the codebase. This is useful for people reading docs (which may be
extracted from the JSDoc comments) or importing the code (if their IDE
shows the JSDoc description). You should therefore prioritize writing JSDoc
comments for exports (as opposed to internal helpers).
- When in doubt, use the already existing code to gauge the number of comments
to write and their level of detail.
- As for single-line and inline comments, only write them around code if it's
tricky and non-obvious, to clarify the motivation for doing something.
- Don't write long Markdown (.md) files documenting your plans unless requested.
It is much better to have documentation of the system in its final state
rather than every detail of your planning.
- Do not make any assumptions about how TorqueScript works, as it has some
uncommon syntax and semantics. Instead, reference documentation on the web, or
the code for the Torque3D SDK, which contains the official grammar and source
code.

659
TorqueScript.pegjs Normal file
View file

@ -0,0 +1,659 @@
{{
// Collect exec() script paths during parsing (deduplicated)
const execScriptPathsSet = new Set();
let hasDynamicExec = false;
function buildBinaryExpression(head, tail) {
return tail.reduce((left, [op, right]) => ({
type: 'BinaryExpression',
operator: op,
left,
right
}), head);
}
function buildUnaryExpression(operator, argument) {
return {
type: 'UnaryExpression',
operator,
argument
};
}
function buildCallExpression(callee, args) {
// Check if this is an exec() call
if (callee.type === 'Identifier' && callee.name.toLowerCase() === 'exec') {
if (args.length > 0 && args[0].type === 'StringLiteral') {
execScriptPathsSet.add(args[0].value);
} else {
hasDynamicExec = true;
}
}
return {
type: 'CallExpression',
callee,
arguments: args
};
}
function getExecScriptPaths() {
return Array.from(execScriptPathsSet);
}
}}
// Main entry point
Program
= ws items:((Comment / Statement) ws)* {
return {
type: 'Program',
body: items.map(([item]) => item).filter(Boolean),
execScriptPaths: getExecScriptPaths(),
hasDynamicExec
};
}
// Statements
Statement
= PackageDeclaration
/ FunctionDeclaration
/ DatablockStatement
/ ObjectStatement
/ IfStatement
/ ForStatement
/ DoWhileStatement
/ WhileStatement
/ SwitchStatement
/ ReturnStatement
/ BreakStatement
/ ContinueStatement
/ ExpressionStatement
/ BlockStatement
/ Comment
/ _ ";" _ { return null; }
DatablockStatement
= decl:DatablockDeclaration _ ";"? _ { return decl; }
ObjectStatement
= decl:ObjectDeclaration _ ";"? _ { return decl; }
PackageDeclaration
= "package" __ name:Identifier _ "{" ws items:((Comment / Statement) ws)* "}" _ ";"? {
return {
type: 'PackageDeclaration',
name,
body: items.map(([item]) => item).filter(Boolean)
};
}
FunctionDeclaration
= "function" __ name:FunctionName _ "(" _ params:ParameterList? _ ")" _ body:BlockStatement {
return {
type: 'FunctionDeclaration',
name,
params: params || [],
body
};
}
FunctionName
= namespace:Identifier "::" method:Identifier {
return { type: 'MethodName', namespace, method };
}
/ Identifier
ParameterList
= head:Identifier tail:(_ "," _ Identifier)* {
return [head, ...tail.map(([,,,id]) => id)];
}
DatablockDeclaration
= "datablock" __ className:Identifier _ "(" _ instanceName:ObjectName? _ ")" _ parent:(":" _ Identifier)? _ body:("{" _ ObjectBody* _ "}" _)? {
return {
type: 'DatablockDeclaration',
className,
instanceName,
parent: parent ? parent[2] : null,
body: body ? body[2].filter(Boolean) : []
};
}
ObjectDeclaration
= "new" __ className:ClassNameExpression _ "(" _ instanceName:ObjectName? _ ")" _ body:("{" _ ObjectBody* _ "}" _)? {
return {
type: 'ObjectDeclaration',
className,
instanceName,
body: body ? body[2].filter(Boolean) : []
};
}
ClassNameExpression
= "(" _ expr:Expression _ ")" { return expr; }
/ base:Identifier accessors:(_ "[" _ IndexList _ "]")* {
return accessors.reduce((obj, [,,,indices]) => ({
type: 'IndexExpression',
object: obj,
index: indices
}), base);
}
ObjectBody
= obj:ObjectDeclaration _ ";"? _ { return obj; }
/ db:DatablockDeclaration _ ";"? _ { return db; }
/ Assignment
/ Comment
/ Whitespace
ObjectName
= StringConcatExpression
/ Identifier
/ NumberLiteral
Assignment
= _ target:LeftHandSide _ "=" _ value:Expression _ ";"? _ {
return {
type: 'Assignment',
target,
value
};
}
LeftHandSide
= base:CallExpression accessors:Accessor* {
return accessors.reduce((obj, accessor) => {
if (accessor.type === 'property') {
return {
type: 'MemberExpression',
object: obj,
property: accessor.value
};
} else {
return {
type: 'IndexExpression',
object: obj,
index: accessor.value
};
}
}, base);
}
Accessor
= "." _ property:Identifier { return { type: 'property', value: property }; }
/ "[" _ indices:IndexList _ "]" { return { type: 'index', value: indices }; }
IndexList
= head:Expression tail:(_ "," _ Expression)* {
return tail.length > 0 ? [head, ...tail.map(([,,,expr]) => expr)] : head;
}
IfStatement
= "if" _ "(" _ test:Expression _ ")" _ consequent:Statement alternate:(_ "else" _ Statement)? {
return {
type: 'IfStatement',
test,
consequent,
alternate: alternate ? alternate[3] : null
};
}
ForStatement
= "for" _ "(" _ init:Expression? _ ";" _ test:Expression? _ ";" _ update:Expression? _ ")" _ body:Statement {
return {
type: 'ForStatement',
init,
test,
update,
body
};
}
WhileStatement
= "while" _ "(" _ test:Expression _ ")" _ body:Statement {
return {
type: 'WhileStatement',
test,
body
};
}
DoWhileStatement
= "do" _ body:Statement _ "while" _ "(" _ test:Expression _ ")" _ ";"? {
return {
type: 'DoWhileStatement',
test,
body
};
}
SwitchStatement
= "switch$" _ "(" _ discriminant:Expression _ ")" _ "{" ws items:((Comment / SwitchCase) ws)* "}" {
return {
type: 'SwitchStatement',
stringMode: true,
discriminant,
cases: items.map(([item]) => item).filter(i => i && i.type === 'SwitchCase')
};
}
/ "switch" _ "(" _ discriminant:Expression _ ")" _ "{" ws items:((Comment / SwitchCase) ws)* "}" {
return {
type: 'SwitchStatement',
stringMode: false,
discriminant,
cases: items.map(([item]) => item).filter(i => i && i.type === 'SwitchCase')
};
}
SwitchCase
= "case" __ tests:CaseTestList _ ":" ws items:((Comment / Statement) ws)* {
return {
type: 'SwitchCase',
test: tests,
consequent: items.map(([item]) => item).filter(Boolean)
};
}
/ "default" _ ":" ws items:((Comment / Statement) ws)* {
return {
type: 'SwitchCase',
test: null,
consequent: items.map(([item]) => item).filter(Boolean)
};
}
CaseTestList
= head:CaseTestExpression tail:(_ "or" __ CaseTestExpression)* {
return tail.length > 0 ? [head, ...tail.map(([,,,expr]) => expr)] : head;
}
CaseTestExpression
= AdditiveExpression
ReturnStatement
= "return" value:(__ Expression)? _ ";" {
return {
type: 'ReturnStatement',
value: value ? value[1] : null
};
}
BreakStatement
= "break" _ ";" {
return { type: 'BreakStatement' };
}
ContinueStatement
= "continue" _ ";" {
return { type: 'ContinueStatement' };
}
ExpressionStatement
= expr:Expression _ ";" {
return {
type: 'ExpressionStatement',
expression: expr
};
}
BlockStatement
= "{" ws items:((Comment / Statement) ws)* "}" {
return {
type: 'BlockStatement',
body: items.map(([item]) => item).filter(Boolean)
};
}
// Expressions (in order of precedence)
Expression
= AssignmentExpression
AssignmentExpression
= target:LeftHandSide _ operator:AssignmentOperator _ value:AssignmentExpression {
return {
type: 'AssignmentExpression',
operator,
target,
value
};
}
/ ConditionalExpression
AssignmentOperator
= "=" / "+=" / "-=" / "*=" / "/=" / "%=" / "<<=" / ">>=" / "&=" / "|=" / "^="
ConditionalExpression
= test:LogicalOrExpression _ "?" _ consequent:Expression _ ":" _ alternate:Expression {
return {
type: 'ConditionalExpression',
test,
consequent,
alternate
};
}
/ LogicalOrExpression
LogicalOrExpression
= head:LogicalAndExpression tail:(_ "||" _ LogicalAndExpression)* {
return buildBinaryExpression(head, tail.map(([,op,,right]) => [op, right]));
}
LogicalAndExpression
= head:BitwiseOrExpression tail:(_ "&&" _ BitwiseOrExpression)* {
return buildBinaryExpression(head, tail.map(([,op,,right]) => [op, right]));
}
BitwiseOrExpression
= head:BitwiseXorExpression tail:(_ "|" !"|" _ BitwiseXorExpression)* {
return buildBinaryExpression(head, tail.map(([,op,,,right]) => [op, right]));
}
BitwiseXorExpression
= head:BitwiseAndExpression tail:(_ "^" _ BitwiseAndExpression)* {
return buildBinaryExpression(head, tail.map(([,op,,right]) => [op, right]));
}
BitwiseAndExpression
= head:EqualityExpression tail:(_ "&" !"&" _ EqualityExpression)* {
return buildBinaryExpression(head, tail.map(([,op,,,right]) => [op, right]));
}
EqualityExpression
= head:RelationalExpression tail:(_ EqualityOperator _ RelationalExpression)* {
return buildBinaryExpression(head, tail.map(([,op,,right]) => [op, right]));
}
EqualityOperator
= "==" / "!="
RelationalExpression
= head:StringConcatExpression tail:(_ RelationalOperator _ StringConcatExpression)* {
return buildBinaryExpression(head, tail.map(([,op,,right]) => [op, right]));
}
RelationalOperator
= "<=" / ">=" / "<" / ">"
StringConcatExpression
= head:ShiftExpression tail:(_ StringConcatOperator _ StringConcatRightSide)* {
return buildBinaryExpression(head, tail.map(([,op,,right]) => [op, right]));
}
// Right side of string concat: allow assignments or normal expressions, but minimize backtracking
StringConcatRightSide
= target:LeftHandSide _ assignOp:AssignmentOperator _ value:AssignmentExpression {
return {
type: 'AssignmentExpression',
operator: assignOp,
target,
value
};
}
/ ShiftExpression
StringConcatOperator
= "$=" / "!$=" / "@" / "NL" / "TAB" / "SPC"
ShiftExpression
= head:AdditiveExpression tail:(_ ShiftOperator _ AdditiveExpression)* {
return buildBinaryExpression(head, tail.map(([,op,,right]) => [op, right]));
}
ShiftOperator
= "<<" / ">>"
AdditiveExpression
= head:MultiplicativeExpression tail:(_ ("+" / "-") _ MultiplicativeExpression)* {
return buildBinaryExpression(head, tail.map(([,op,,right]) => [op, right]));
}
MultiplicativeExpression
= head:UnaryExpression tail:(_ ("*" / "/" / "%") _ UnaryExpression)* {
return buildBinaryExpression(head, tail.map(([,op,,right]) => [op, right]));
}
UnaryExpression
= operator:("-" / "!" / "~") _ argument:AssignmentExpression {
return buildUnaryExpression(operator, argument);
}
/ operator:("++" / "--") _ argument:UnaryExpression {
return buildUnaryExpression(operator, argument);
}
/ "*" _ argument:UnaryExpression {
return {
type: 'TagDereferenceExpression',
argument
};
}
/ PostfixExpression
PostfixExpression
= argument:CallExpression _ operator:("++" / "--") {
return {
type: 'PostfixExpression',
operator,
argument
};
}
/ CallExpression
CallExpression
= base:MemberExpression tail:((_ "(" _ ArgumentList? _ ")") / (_ Accessor))* {
return tail.reduce((obj, item) => {
// Check if it's a function call
if (item[1] === '(') {
const [,,,argList] = item;
return buildCallExpression(obj, argList || []);
}
// Otherwise it's an accessor
const accessor = item[1];
if (accessor.type === 'property') {
return {
type: 'MemberExpression',
object: obj,
property: accessor.value
};
} else {
return {
type: 'IndexExpression',
object: obj,
index: accessor.value
};
}
}, base);
}
MemberExpression
= base:PrimaryExpression accessors:(_ Accessor)* {
return accessors.reduce((obj, [, accessor]) => {
if (accessor.type === 'property') {
return {
type: 'MemberExpression',
object: obj,
property: accessor.value
};
} else {
return {
type: 'IndexExpression',
object: obj,
index: accessor.value
};
}
}, base);
}
ArgumentList
= head:Expression tail:(_ "," _ Expression)* {
return [head, ...tail.map(([,,,expr]) => expr)];
}
PrimaryExpression
= ObjectDeclaration
/ DatablockDeclaration
/ StringLiteral
/ NumberLiteral
/ BooleanLiteral
/ Variable
/ ParenthesizedExpression
ParenthesizedExpression
= "(" _ expr:Expression _ ")" { return expr; }
// Variables
Variable
= LocalVariable
/ GlobalVariable
/ PlainIdentifier
LocalVariable
= "%" name:$([a-zA-Z_][a-zA-Z0-9_]*) {
return {
type: 'Variable',
scope: 'local',
name
};
}
GlobalVariable
= "$" name:$("::"? [a-zA-Z_][a-zA-Z0-9_]* ("::" [a-zA-Z_][a-zA-Z0-9_]*)*) {
return {
type: 'Variable',
scope: 'global',
name
};
}
PlainIdentifier
= name:$("parent" [ \t]* "::" [ \t]* [a-zA-Z_][a-zA-Z0-9_]*) {
return {
type: 'Identifier',
name: name.replace(/\s+/g, '')
};
}
/ name:$("parent" ("::" [a-zA-Z_][a-zA-Z0-9_]*)+) {
return {
type: 'Identifier',
name
};
}
/ name:$([a-zA-Z_][a-zA-Z0-9_]* ("::" [a-zA-Z_][a-zA-Z0-9_]*)*) {
return {
type: 'Identifier',
name
};
}
Identifier
= LocalVariable
/ GlobalVariable
/ PlainIdentifier
// Literals
StringLiteral
= '"' chars:DoubleQuotedChar* '"' {
return {
type: 'StringLiteral',
value: chars.join('')
};
}
/ "'" chars:SingleQuotedChar* "'" {
// Single-quoted strings are "tagged" strings in TorqueScript,
// used for network optimization (string sent once, then only tag ID)
return {
type: 'StringLiteral',
value: chars.join(''),
tagged: true
};
}
DoubleQuotedChar
= "\\" char:EscapeSequence { return char; }
/ [^"\\\n\r]
SingleQuotedChar
= "\\" char:EscapeSequence { return char; }
/ [^'\\\n\r]
EscapeSequence
= "n" { return "\n"; }
/ "r" { return "\r"; }
/ "t" { return "\t"; }
/ "x" hex:$([0-9a-fA-F][0-9a-fA-F]) { return String.fromCharCode(parseInt(hex, 16)); }
// TorqueScript color codes - mapped to byte values that skip \t (9), \n (10), \r (13)
// These are processed by the game's text rendering system
/ "cr" { return String.fromCharCode(0x0F); } // reset color
/ "cp" { return String.fromCharCode(0x10); } // push color
/ "co" { return String.fromCharCode(0x11); } // pop color
/ "c" code:$([0-9]) {
// collapseRemap: \c0-\c9 map to bytes that avoid \t, \n, \r
const collapseRemap = [0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x0B, 0x0C, 0x0E];
return String.fromCharCode(collapseRemap[parseInt(code, 10)]);
}
/ char:. { return char; }
NumberLiteral
= hex:$("0" [xX] [0-9a-fA-F]+) !IdentifierChar {
return {
type: 'NumberLiteral',
value: parseInt(hex, 16)
};
}
/ number:$(("-"? [0-9]+ ("." [0-9]+)?) / ("-"? "." [0-9]+)) !IdentifierChar {
return {
type: 'NumberLiteral',
value: parseFloat(number)
};
}
BooleanLiteral
= value:("true" / "false") !IdentifierChar {
return {
type: 'BooleanLiteral',
value: value === "true"
};
}
// Comments
Comment
= SingleLineComment
/ MultiLineComment
SingleLineComment
= "//" text:$[^\n\r]* [\n\r]? {
return {
type: 'Comment',
value: text
};
}
MultiLineComment
= "/*" text:$(!"*/" .)* "*/" {
return {
type: 'Comment',
value: text
};
}
// Whitespace
Whitespace
= [ \t\n\r]+ { return null; }
// Optional whitespace and comments (comments are consumed but not captured)
// Use this between tokens and in expressions
_
= ([ \t\n\r] / SkipComment)*
// Required whitespace (at least one whitespace char, may include comments)
// Use this between keywords that must be separated
__
= [ \t\n\r]+ ([ \t\n\r] / SkipComment)*
// Pure whitespace only (no comments) - used in statement lists where comments are captured separately
ws
= [ \t\n\r]*
// Comments consumed silently (no return value) - used in whitespace rules
SkipComment
= "//" [^\n\r]* [\n\r]?
/ "/*" (!"*/" .)* "*/"
IdentifierChar
= [a-zA-Z0-9_]

6407
generated/TorqueScript.cjs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,8 @@
import type { Program } from "@/src/torqueScript/ast";
export interface ParseOptions {
grammarSource?: string;
startRule?: "Program";
}
export function parse(input: string, options?: ParseOptions): Program;

File diff suppressed because it is too large Load diff

View file

@ -1,95 +0,0 @@
start
= document
document
= body:statement* !. { return body.filter(Boolean); }
statement
= comment
/ instance
/ definition
/ datablock
/ space+ { return null; }
comment
= "//" text:$[^\n\r]* { return { type: 'comment', text }; }
datablock
= "datablock " space* className:identifier space*
"(" space* instanceName:objectName? space* ")" space*
(":" space* baseName:objectName)? space*
"{" body:body* "}" sep*
{
return {
type: 'datablock',
className,
instanceName,
body: body.filter(Boolean)
}
}
instance
= "new " space* className:identifier space*
"(" space* instanceName:objectName? space* ")" space*
"{" body:body* "}" sep*
{
return {
type: 'instance',
className,
instanceName,
body: body.filter(Boolean)
}
}
body
= space+ { return null; }
/ definition
/ instance
/ comment
definition
= target:lhs space* "=" space* value:rhs ";"?
{ return { type: 'definition', target, value }; }
string
= "\"" values:(escape / notDoubleQuote)* "\"" { return { type: 'string', value: values.join('') }; }
/ "'" values:(escape / notSingleQuote)* "'" { return { type: 'string', value: values.join('') }; }
escape = "\\" char:. { return char}
notDoubleQuote = $[^\\"]+
notSingleQuote = $[^\\']+
space = [ \t\n\r] { return null; }
sep = ";"
identifier = $([$%]?[a-zA-Z][a-zA-Z0-9_]*)
objectName
= identifier
/ number
lhs = name:identifier index:index* { return { name, index }; }
rhs
= string
/ number
/ instance
/ boolean
/ ref:identifier { return { type: 'reference', value: ref }; }
index = arrayIndex / propertyIndex
arrayIndex = "[" space* index:accessor space* "]" { return index; }
propertyIndex = "." index:identifier { return index; }
accessor
= number
/ identifier
number = digits:$[0-9.]+ { return { type: 'number', value: parseFloat(digits) }; }
boolean = literal:("true" / "false") { return { type: 'boolean', value: literal === "true" }; }
eol = "\n" / "\r\n" / "\r"

View file

@ -4,4 +4,18 @@ module.exports = {
basePath: "/t2-mapper",
assetPrefix: "/t2-mapper/",
trailingSlash: true,
async headers() {
return [
{
// TorqueScript files should be served as text
source: "/:path*.cs",
headers: [
{
key: "Content-Type",
value: "text/plain; charset=utf-8",
},
],
},
];
},
};

10
package-lock.json generated
View file

@ -13,6 +13,7 @@
"@react-three/fiber": "^9.3.0",
"@react-three/postprocessing": "^3.0.4",
"@tanstack/react-query": "^5.90.8",
"ignore": "^7.0.5",
"lodash.orderby": "^4.6.0",
"next": "^15.5.2",
"react": "^19.1.1",
@ -2373,6 +2374,15 @@
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",

View file

@ -7,7 +7,8 @@
"license": "MIT",
"type": "module",
"scripts": {
"build:parser": "peggy mission.pegjs -o generated/mission.cjs",
"build:manifest": "tsx scripts/generate-manifest.ts -o public/manifest.json",
"build:parser": "peggy TorqueScript.pegjs -o generated/TorqueScript.cjs",
"build": "next build && touch docs/.nojekyll",
"clean": "rimraf .next",
"deploy": "npm run build && git add -f docs && git commit -m \"Deploy\" && git push",
@ -24,6 +25,7 @@
"@react-three/fiber": "^9.3.0",
"@react-three/postprocessing": "^3.0.4",
"@tanstack/react-query": "^5.90.8",
"ignore": "^7.0.5",
"lodash.orderby": "^4.6.0",
"next": "^15.5.2",
"react": "^19.1.1",

File diff suppressed because one or more lines are too long

View file

@ -1,47 +1,107 @@
import fs from "node:fs/promises";
import path from "node:path";
import { parseArgs } from "node:util";
import ignore from "ignore";
import unzipper from "unzipper";
import { normalizePath } from "@/src/stringUtils";
import manifest from "@/public/manifest.json";
import path from "node:path";
import { walkDirectory } from "@/src/fileUtils";
const inputBaseDir = process.env.BASE_DIR || "GameData/base";
const outputBaseDir = "docs/base";
const archives = new Map<string, unzipper.CentralDirectory>();
// NOTE! Yes, the files below will be ignored. But this also expects `inputBaseDir`
// to largely have already been pruned of files that are indistinguishable from
// useful files - like player skins, voice binds, and anything else that will
// not be used by the map tool. So, remove `voice.vl2` before running this, for
// example. Random scripts are typically fine, since they're small (and other
// scripts may expect them to be available).
const ignoreList = ignore().add(`
fonts/
lighting/
prefs/
.DS_Store
*.dso
*.gui
*.ico
*.ml
*.txt
`);
async function buildExtractedGameDataFolder() {
await fs.mkdir(outputBaseDir, { recursive: true });
const filePaths = Object.keys(manifest).sort();
for (const filePath of filePaths) {
const sources = manifest[filePath];
for (const source of sources) {
if (source) {
let archive = archives.get(source);
if (!archive) {
const archivePath = `${inputBaseDir}/${source}`;
archive = await unzipper.Open.file(archivePath);
archives.set(source, archive);
async function extractAssets({ clean }: { clean: boolean }) {
const vl2Files: string[] = [];
const looseFiles: string[] = [];
// Discover all files
await walkDirectory(inputBaseDir, {
onFile: ({ entry }) => {
const filePath = path.join(entry.parentPath, entry.name);
const resourcePath = normalizePath(path.relative(inputBaseDir, filePath));
if (!ignoreList.ignores(resourcePath)) {
if (/\.vl2$/i.test(entry.name)) {
vl2Files.push(filePath);
} else {
looseFiles.push(filePath);
}
const entry = archive.files.find(
(entry) => normalizePath(entry.path) === filePath,
);
const inFile = `${inputBaseDir}/${source}:${filePath}`;
if (!entry) {
throw new Error(`File not found in archive: ${inFile}`);
}
const outFile = `${outputBaseDir}/@vl2/${source}/${filePath}`;
const outDir = path.dirname(outFile);
console.log(`${inFile} -> ${outFile}`);
await fs.mkdir(outDir, { recursive: true });
await fs.writeFile(outFile, entry.stream());
} else {
const inFile = `${inputBaseDir}/${filePath}`;
const outFile = `${outputBaseDir}/${filePath}`;
console.log(`${inFile} -> ${outFile}`);
await fs.cp(inFile, outFile);
}
},
onDir: ({ entry }) => {
const dirPath = path.join(entry.parentPath, entry.name);
const resourcePath =
normalizePath(path.relative(inputBaseDir, dirPath)) + "/";
const shouldRecurse = !ignoreList.ignores(resourcePath);
return shouldRecurse;
},
});
if (clean) {
console.log(`Cleaning ${outputBaseDir}`);
await fs.rm(outputBaseDir, { recursive: true, force: true });
}
await fs.mkdir(outputBaseDir, { recursive: true });
for (const filePath of looseFiles) {
const relativePath = path.relative(inputBaseDir, filePath);
const outFile = path.join(outputBaseDir, relativePath);
const outDir = path.dirname(outFile);
console.log(outFile);
await fs.mkdir(outDir, { recursive: true });
await fs.copyFile(filePath, outFile);
}
// Extract .vl2 files
for (const archivePath of vl2Files) {
const relativePath = path.relative(inputBaseDir, archivePath);
const archive = await unzipper.Open.file(archivePath);
const outputArchiveDir = path.join(outputBaseDir, "@vl2", relativePath);
for (const entry of archive.files) {
if (entry.type === "Directory") continue;
const resourcePath = normalizePath(entry.path);
if (ignoreList.ignores(resourcePath)) continue;
const outFile = path.join(outputArchiveDir, resourcePath);
const outDir = path.dirname(outFile);
console.log(outFile);
await fs.mkdir(outDir, { recursive: true });
const content = await entry.buffer();
await fs.writeFile(outFile, content);
}
}
console.log("Done.");
}
buildExtractedGameDataFolder();
const { values } = parseArgs({
options: {
clean: {
type: "boolean",
default: false,
},
},
});
extractAssets({ clean: values.clean });

View file

@ -1,99 +1,104 @@
import fs from "node:fs/promises";
import path from "node:path";
import { parseArgs } from "node:util";
import { Dirent } from "node:fs";
import orderBy from "lodash.orderby";
import ignore from "ignore";
import { normalizePath } from "@/src/stringUtils";
import { walkDirectory } from "@/src/fileUtils";
import { parseMissionScript } from "@/src/mission";
const baseDir = process.env.BASE_DIR || "docs/base";
async function walkDirectory(
dir: string,
{
onFile,
onDir = () => true,
}: {
onFile: (fileInfo: {
dir: string;
entry: Dirent<string>;
fullPath: string;
}) => void | Promise<void>;
onDir?: (dirInfo: {
dir: string;
entry: Dirent<string>;
fullPath: string;
}) => boolean | Promise<boolean>;
},
): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
// Most files we're not interested in would have already been ignored by the
// `extract-assets` script - but some extra files still may have popped up from
// the host sytem.
const ignoreList = ignore().add(`
.DS_Store
`);
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
type SourceTuple =
// If casing of the path within this source is the same as "first seen" casing
| [sourcePath: string]
// If casing of the path within this source is different
| [sourceName: string, actualPath: string];
if (entry.isDirectory()) {
const shouldRecurse = await onDir({ dir, entry, fullPath });
if (shouldRecurse) {
await walkDirectory(fullPath, { onFile, onDir });
}
} else if (entry.isFile()) {
await onFile({ dir, entry, fullPath });
}
}
}
// Resource entry: [firstSeenActualPath, ...sourceTuples]
type ResourceEntry = [firstSeenActualPath: string, ...SourceTuple[]];
/**
* Log and return the manifest of files for the given game asset directory.
* The assets used to build the mapper are a filtered set of relevant files
* (map related assets) from the `Tribes2/GameData/base` folder. The manifest
* consists of the set of unique paths (case sensitive!) represented by the file
* tree AND the vl2 files as if they had been unzipped. Thus, each file in the
* manifest can have one or more "sources". If the file appears outside of a vl2,
* it will have a blank source (the empty string) first. Each vl2 containing the
* file will then be listed in order. To resolve an asset, the engine uses a
* layering approach where paths inside lexicographically-higher vl2 files win
* over the same path outside of a vl2 or in a lexicographically-lower vl2 file.
* So, to choose the same final asset as the engine, choose the last source in
* the list for any given path.
* consists of the set of unique paths represented by the file tree AND the vl2
* files as if they had been unzipped. Keys are normalized (lowercased) paths
* for case-insensitive lookup.
*
* Values are arrays where the first element is the first-seen casing of the
* path, followed by source tuples. 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
*
* If the file appears outside of a vl2, the source path will be the empty
* string. Each vl2 containing the file will then be listed in order. To resolve
* an asset, the engine uses a layering approach where paths inside
* lexicographically-higher vl2 files win over the same path outside of a vl2
* or in a lexicographically-lower vl2 file. So, to choose the same final asset
* as the engine, choose the last source in the list for any given path.
*
* Example:
*
* ```
* {
* "textures/terrainTiles/green.png": ["textures.vl2"],
* "textures/lava/ds_iwal01a.png": [
* "lava.vl2",
* "yHDTextures2.0.vl2",
* "zAddOnsVL2s/zDiscord-Map-Pack-4.7.1.vl2"
* "textures/terraintiles/green.png": [
* "textures/terrainTiles/green.png",
* ["textures.vl2"],
* ["otherTextures.vl2", "Textures/TerrainTiles/Green.PNG"]
* ]
* }
* ```
*/
async function buildManifest() {
const fileSources = new Map<string, string[]>();
// Map from normalized (lowercased) path to [firstSeenActualPath, ...sourceTuples]
const fileSources = new Map<string, ResourceEntry>();
const looseFiles: string[] = [];
await walkDirectory(baseDir, {
onFile: ({ fullPath }) => {
looseFiles.push(normalizePath(fullPath));
onFile: ({ entry }) => {
const resourcePath = normalizePath(
path.relative(baseDir, path.join(entry.parentPath, entry.name)),
);
if (!ignoreList.ignores(resourcePath)) {
looseFiles.push(resourcePath);
}
},
onDir: ({ dir, entry, fullPath }) => {
onDir: ({ entry }) => {
return entry.name !== "@vl2";
},
});
for (const filePath of looseFiles) {
const relativePath = normalizePath(path.relative(baseDir, filePath));
fileSources.set(relativePath, [""]);
for (const resourcePath of looseFiles) {
const normalizedKey = resourcePath.toLowerCase();
const existing = fileSources.get(normalizedKey);
if (existing) {
const [firstSeenPath] = existing;
if (resourcePath === firstSeenPath) {
existing.push([""]);
} else {
existing.push(["", resourcePath]);
}
} else {
fileSources.set(normalizedKey, [resourcePath, [""]]);
}
}
let archiveDirs: string[] = [];
await walkDirectory(`${baseDir}/@vl2`, {
onFile: () => {},
onDir: ({ dir, entry, fullPath }) => {
if (entry.name.endsWith(".vl2")) {
archiveDirs.push(fullPath);
onDir: ({ entry }) => {
if (/\.vl2$/i.test(entry.name)) {
const archivePath = path.join(entry.parentPath, entry.name);
archiveDirs.push(archivePath);
}
return true;
},
@ -101,7 +106,7 @@ async function buildManifest() {
archiveDirs = orderBy(
archiveDirs,
[(fullPath) => path.basename(fullPath).toLowerCase()],
[(archivePath) => path.basename(archivePath).toLowerCase()],
["asc"],
);
@ -110,49 +115,67 @@ async function buildManifest() {
path.relative(`${baseDir}/@vl2`, archivePath),
);
await walkDirectory(archivePath, {
onFile: ({ dir, entry, fullPath }) => {
const filePath = normalizePath(path.relative(archivePath, fullPath));
const sources = fileSources.get(filePath) ?? [];
sources.push(relativeArchivePath);
fileSources.set(filePath, sources);
onFile: ({ entry }) => {
const resourcePath = normalizePath(
path.relative(archivePath, path.join(entry.parentPath, entry.name)),
);
if (ignoreList.ignores(resourcePath)) {
return;
}
const normalizedKey = resourcePath.toLowerCase();
const existing = fileSources.get(normalizedKey);
if (existing) {
const [firstSeenPath] = existing;
if (resourcePath === firstSeenPath) {
existing.push([relativeArchivePath]);
} else {
existing.push([relativeArchivePath, resourcePath]);
}
} else {
fileSources.set(normalizedKey, [resourcePath, [relativeArchivePath]]);
}
},
});
}
const resources: Record<string, string[]> = {};
const resources: Record<string, ResourceEntry> = {};
const missions: Record<
string,
{ resourcePath: string; displayName: string | null; missionTypes: string[] }
> = {};
const orderedFiles = Array.from(fileSources.keys()).sort();
for (const filePath of orderedFiles) {
const sources = fileSources.get(filePath);
resources[filePath] = sources;
const lastSource = sources[sources.length - 1];
const sortedResourceKeys = Array.from(fileSources.keys()).sort();
for (const resourceKey of sortedResourceKeys) {
const entry = fileSources.get(resourceKey)!;
resources[resourceKey] = entry;
const [firstSeenPath, ...sourceTuples] = entry;
const lastSourceTuple = sourceTuples[sourceTuples.length - 1];
const lastSource = lastSourceTuple[0];
const lastActualPath = lastSourceTuple[1] ?? firstSeenPath;
console.log(
`${filePath}${sources[0] ? ` 📦 ${sources[0]}` : ""}${
sources.length > 1
? sources
`${firstSeenPath}${sourceTuples[0][0] ? ` 📦 ${sourceTuples[0][0]}` : ""}${
sourceTuples.length > 1
? sourceTuples
.slice(1)
.map((source) => ` ❗️ ${source}`)
.map((tuple) => ` ❗️ ${tuple[0]}`)
.join("")
: ""
}`,
);
const resolvedPath = lastSource
? path.join(baseDir, "@vl2", lastSource, filePath)
: path.join(baseDir, filePath);
? path.join(baseDir, "@vl2", lastSource, lastActualPath)
: path.join(baseDir, lastActualPath);
if (filePath.endsWith(".mis")) {
if (resourceKey.endsWith(".mis")) {
const missionScript = await fs.readFile(resolvedPath, "utf8");
const mission = parseMissionScript(missionScript);
const baseName = path.basename(filePath, ".mis");
const baseName = path.basename(firstSeenPath, ".mis");
missions[baseName] = {
resourcePath: filePath,
resourcePath: resourceKey,
displayName: mission.displayName,
missionTypes: mission.missionTypes,
};

View file

@ -1,6 +1,6 @@
import fs from "node:fs";
import { inspect, parseArgs } from "node:util";
import { parseImageFrameList } from "@/src/ifl";
import { parseImageFileList } from "@/src/imageFileList";
import { getFilePath } from "@/src/manifest";
async function run() {
@ -50,7 +50,7 @@ async function run() {
}
const missionScript = fs.readFileSync(framesFile, "utf8");
console.log(
inspect(parseImageFrameList(missionScript), {
inspect(parseImageFileList(missionScript), {
colors: true,
depth: Infinity,
}),

View file

@ -1,7 +1,8 @@
import fs from "node:fs/promises";
import { iterObjects, parseMissionScript } from "@/src/mission";
import path from "node:path";
import { parseArgs } from "node:util";
import { basename } from "node:path";
import { parse } from "@/src/torqueScript";
import type * as AST from "@/src/torqueScript/ast";
/**
* For all missions, log all the property values matching the given filters.
@ -14,7 +15,7 @@ import { basename } from "node:path";
*
* tsx scripts/mission-properties.ts -t TerrainBlock -p position
*/
const { values, positionals } = parseArgs({
const { values } = parseArgs({
allowPositionals: true,
options: {
types: {
@ -41,6 +42,115 @@ const propertyList =
? null
: new Set(values.properties.split(","));
function getClassName(node: AST.Identifier | AST.Expression): string | null {
if (node.type === "Identifier") {
return node.name;
}
return null;
}
function getPropertyName(
target: AST.Identifier | AST.IndexExpression,
): string | null {
if (target.type === "Identifier") {
return target.name;
}
// IndexExpression like foo[0] - get the base name
if (
target.type === "IndexExpression" &&
target.object.type === "Identifier"
) {
return target.object.name;
}
return null;
}
function expressionToString(expr: AST.Expression): string {
switch (expr.type) {
case "StringLiteral":
return expr.value;
case "NumberLiteral":
return String(expr.value);
case "BooleanLiteral":
return String(expr.value);
case "Identifier":
return expr.name;
case "Variable":
return `${expr.scope === "global" ? "$" : "%"}${expr.name}`;
case "BinaryExpression":
return `${expressionToString(expr.left)} ${expr.operator} ${expressionToString(expr.right)}`;
case "UnaryExpression":
return `${expr.operator}${expressionToString(expr.argument)}`;
default:
return `[${expr.type}]`;
}
}
interface ObjectInfo {
className: string;
properties: Array<{ name: string; value: string }>;
children: ObjectInfo[];
}
function extractObject(node: AST.ObjectDeclaration): ObjectInfo | null {
const className = getClassName(node.className);
if (!className) return null;
const properties: Array<{ name: string; value: string }> = [];
const children: ObjectInfo[] = [];
for (const item of node.body) {
if (item.type === "Assignment") {
const name = getPropertyName(item.target);
if (name) {
properties.push({
name,
value: expressionToString(item.value),
});
}
} else if (item.type === "ObjectDeclaration") {
const child = extractObject(item);
if (child) {
children.push(child);
}
}
}
return { className, properties, children };
}
function* walkObjects(ast: AST.Program): Generator<ObjectInfo> {
function* walkStatements(statements: AST.Statement[]): Generator<ObjectInfo> {
for (const stmt of statements) {
if (stmt.type === "ObjectDeclaration") {
const obj = extractObject(stmt);
if (obj) {
yield obj;
yield* walkObjectTree(obj.children);
}
} else if (stmt.type === "ExpressionStatement") {
// Check if expression is an ObjectDeclaration
if (stmt.expression.type === "ObjectDeclaration") {
const obj = extractObject(stmt.expression);
if (obj) {
yield obj;
yield* walkObjectTree(obj.children);
}
}
}
}
}
function* walkObjectTree(objects: ObjectInfo[]): Generator<ObjectInfo> {
for (const obj of objects) {
yield obj;
yield* walkObjectTree(obj.children);
}
}
yield* walkStatements(ast.body);
}
async function run({
typeList,
propertyList,
@ -51,18 +161,26 @@ async function run({
valuesOnly: boolean;
}) {
for await (const inFile of fs.glob("docs/base/**/*.mis")) {
const baseName = basename(inFile);
const baseName = path.basename(inFile);
const missionScript = await fs.readFile(inFile, "utf8");
const mission = parseMissionScript(missionScript);
for (const consoleObject of iterObjects(mission.objects)) {
if (!typeList || typeList.has(consoleObject.className)) {
for (const property of consoleObject.properties) {
if (!propertyList || propertyList.has(property.target.name)) {
let ast: AST.Program;
try {
ast = parse(missionScript);
} catch (err) {
console.error(`Failed to parse ${baseName}:`, err);
continue;
}
for (const obj of walkObjects(ast)) {
if (!typeList || typeList.has(obj.className)) {
for (const property of obj.properties) {
if (!propertyList || propertyList.has(property.name)) {
if (valuesOnly) {
console.log(property.value);
} else {
console.log(
`${baseName} > ${consoleObject.className} > ${property.target.name} = ${property.value}`,
`${baseName} > ${obj.className} > ${property.name} = ${property.value}`,
);
}
}

View file

@ -0,0 +1,13 @@
import fs from "node:fs/promises";
import { inspect } from "node:util";
import { parse } from "@/src/torqueScript";
async function run(scriptPath: string) {
const script = await fs.readFile(scriptPath, "utf8");
const ast = parse(script);
console.log(inspect(ast, { colors: true, depth: Infinity }));
}
const scriptPath = process.argv[2];
await run(scriptPath);

View file

@ -0,0 +1,11 @@
import fs from "node:fs/promises";
import { transpile } from "@/src/torqueScript";
async function run(scriptPath: string) {
const script = await fs.readFile(scriptPath, "utf8");
const { code } = transpile(script);
console.log(code);
}
const scriptPath = process.argv[2];
await run(scriptPath);

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