mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-01-19 12:14:47 +00:00
add TorqueScript transpiler and runtime
This commit is contained in:
parent
c8391a1056
commit
7d10fb7dee
67
CLAUDE.md
Normal file
67
CLAUDE.md
Normal 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
659
TorqueScript.pegjs
Normal 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
6407
generated/TorqueScript.cjs
Normal file
File diff suppressed because it is too large
Load diff
8
generated/TorqueScript.d.cts
Normal file
8
generated/TorqueScript.d.cts
Normal 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
|
|
@ -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"
|
||||
|
|
@ -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
10
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
scripts/parse-torquescript.ts
Normal file
13
scripts/parse-torquescript.ts
Normal 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);
|
||||
11
scripts/transpile-torquescript.ts
Normal file
11
scripts/transpile-torquescript.ts
Normal 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);
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
30
src/fileUtils.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
133
src/manifest.ts
133
src/manifest.ts
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
330
src/mission.ts
330
src/mission.ts
|
|
@ -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
233
src/torqueScript/README.md
Normal 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
250
src/torqueScript/ast.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
690
src/torqueScript/builtins.ts
Normal file
690
src/torqueScript/builtins.ts
Normal 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
756
src/torqueScript/codegen.ts
Normal 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
46
src/torqueScript/index.ts
Normal 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 };
|
||||
}
|
||||
1194
src/torqueScript/runtime.spec.ts
Normal file
1194
src/torqueScript/runtime.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
895
src/torqueScript/runtime.ts
Normal file
895
src/torqueScript/runtime.ts
Normal 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;
|
||||
}
|
||||
30
src/torqueScript/scriptLoader.browser.ts
Normal file
30
src/torqueScript/scriptLoader.browser.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
28
src/torqueScript/scriptLoader.node.ts
Normal file
28
src/torqueScript/scriptLoader.node.ts
Normal 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
179
src/torqueScript/types.ts
Normal 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
94
src/torqueScript/utils.ts
Normal 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();
|
||||
}
|
||||
Loading…
Reference in a new issue