mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-01-19 20:25:01 +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",
|
basePath: "/t2-mapper",
|
||||||
assetPrefix: "/t2-mapper/",
|
assetPrefix: "/t2-mapper/",
|
||||||
trailingSlash: true,
|
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/fiber": "^9.3.0",
|
||||||
"@react-three/postprocessing": "^3.0.4",
|
"@react-three/postprocessing": "^3.0.4",
|
||||||
"@tanstack/react-query": "^5.90.8",
|
"@tanstack/react-query": "^5.90.8",
|
||||||
|
"ignore": "^7.0.5",
|
||||||
"lodash.orderby": "^4.6.0",
|
"lodash.orderby": "^4.6.0",
|
||||||
"next": "^15.5.2",
|
"next": "^15.5.2",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
|
@ -2373,6 +2374,15 @@
|
||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/immediate": {
|
||||||
"version": "3.0.6",
|
"version": "3.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"build": "next build && touch docs/.nojekyll",
|
||||||
"clean": "rimraf .next",
|
"clean": "rimraf .next",
|
||||||
"deploy": "npm run build && git add -f docs && git commit -m \"Deploy\" && git push",
|
"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/fiber": "^9.3.0",
|
||||||
"@react-three/postprocessing": "^3.0.4",
|
"@react-three/postprocessing": "^3.0.4",
|
||||||
"@tanstack/react-query": "^5.90.8",
|
"@tanstack/react-query": "^5.90.8",
|
||||||
|
"ignore": "^7.0.5",
|
||||||
"lodash.orderby": "^4.6.0",
|
"lodash.orderby": "^4.6.0",
|
||||||
"next": "^15.5.2",
|
"next": "^15.5.2",
|
||||||
"react": "^19.1.1",
|
"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 fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { parseArgs } from "node:util";
|
||||||
|
import ignore from "ignore";
|
||||||
import unzipper from "unzipper";
|
import unzipper from "unzipper";
|
||||||
import { normalizePath } from "@/src/stringUtils";
|
import { normalizePath } from "@/src/stringUtils";
|
||||||
import manifest from "@/public/manifest.json";
|
import { walkDirectory } from "@/src/fileUtils";
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const inputBaseDir = process.env.BASE_DIR || "GameData/base";
|
const inputBaseDir = process.env.BASE_DIR || "GameData/base";
|
||||||
const outputBaseDir = "docs/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() {
|
async function extractAssets({ clean }: { clean: boolean }) {
|
||||||
await fs.mkdir(outputBaseDir, { recursive: true });
|
const vl2Files: string[] = [];
|
||||||
const filePaths = Object.keys(manifest).sort();
|
const looseFiles: string[] = [];
|
||||||
for (const filePath of filePaths) {
|
|
||||||
const sources = manifest[filePath];
|
// Discover all files
|
||||||
for (const source of sources) {
|
await walkDirectory(inputBaseDir, {
|
||||||
if (source) {
|
onFile: ({ entry }) => {
|
||||||
let archive = archives.get(source);
|
const filePath = path.join(entry.parentPath, entry.name);
|
||||||
if (!archive) {
|
const resourcePath = normalizePath(path.relative(inputBaseDir, filePath));
|
||||||
const archivePath = `${inputBaseDir}/${source}`;
|
if (!ignoreList.ignores(resourcePath)) {
|
||||||
archive = await unzipper.Open.file(archivePath);
|
if (/\.vl2$/i.test(entry.name)) {
|
||||||
archives.set(source, archive);
|
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 fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { parseArgs } from "node:util";
|
import { parseArgs } from "node:util";
|
||||||
import { Dirent } from "node:fs";
|
|
||||||
import orderBy from "lodash.orderby";
|
import orderBy from "lodash.orderby";
|
||||||
|
import ignore from "ignore";
|
||||||
import { normalizePath } from "@/src/stringUtils";
|
import { normalizePath } from "@/src/stringUtils";
|
||||||
|
import { walkDirectory } from "@/src/fileUtils";
|
||||||
import { parseMissionScript } from "@/src/mission";
|
import { parseMissionScript } from "@/src/mission";
|
||||||
|
|
||||||
const baseDir = process.env.BASE_DIR || "docs/base";
|
const baseDir = process.env.BASE_DIR || "docs/base";
|
||||||
|
|
||||||
async function walkDirectory(
|
// Most files we're not interested in would have already been ignored by the
|
||||||
dir: string,
|
// `extract-assets` script - but some extra files still may have popped up from
|
||||||
{
|
// the host sytem.
|
||||||
onFile,
|
const ignoreList = ignore().add(`
|
||||||
onDir = () => true,
|
.DS_Store
|
||||||
}: {
|
`);
|
||||||
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 });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
type SourceTuple =
|
||||||
const fullPath = path.join(dir, entry.name);
|
// 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()) {
|
// Resource entry: [firstSeenActualPath, ...sourceTuples]
|
||||||
const shouldRecurse = await onDir({ dir, entry, fullPath });
|
type ResourceEntry = [firstSeenActualPath: string, ...SourceTuple[]];
|
||||||
if (shouldRecurse) {
|
|
||||||
await walkDirectory(fullPath, { onFile, onDir });
|
|
||||||
}
|
|
||||||
} else if (entry.isFile()) {
|
|
||||||
await onFile({ dir, entry, fullPath });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log and return the manifest of files for the given game asset directory.
|
* 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
|
* 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
|
* (map related assets) from the `Tribes2/GameData/base` folder. The manifest
|
||||||
* consists of the set of unique paths (case sensitive!) represented by the file
|
* consists of the set of unique paths represented by the file tree AND the vl2
|
||||||
* tree AND the vl2 files as if they had been unzipped. Thus, each file in the
|
* files as if they had been unzipped. Keys are normalized (lowercased) paths
|
||||||
* manifest can have one or more "sources". If the file appears outside of a vl2,
|
* for case-insensitive lookup.
|
||||||
* 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
|
* Values are arrays where the first element is the first-seen casing of the
|
||||||
* layering approach where paths inside lexicographically-higher vl2 files win
|
* path, followed by source tuples. Each source tuple is either:
|
||||||
* over the same path outside of a vl2 or in a lexicographically-lower vl2 file.
|
* - [sourcePath] if the file has the same casing as firstSeenPath
|
||||||
* So, to choose the same final asset as the engine, choose the last source in
|
* - [sourcePath, actualPath] if the file has different casing in that source
|
||||||
* the list for any given path.
|
*
|
||||||
|
* 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:
|
* Example:
|
||||||
*
|
*
|
||||||
* ```
|
* ```
|
||||||
* {
|
* {
|
||||||
* "textures/terrainTiles/green.png": ["textures.vl2"],
|
* "textures/terraintiles/green.png": [
|
||||||
* "textures/lava/ds_iwal01a.png": [
|
* "textures/terrainTiles/green.png",
|
||||||
* "lava.vl2",
|
* ["textures.vl2"],
|
||||||
* "yHDTextures2.0.vl2",
|
* ["otherTextures.vl2", "Textures/TerrainTiles/Green.PNG"]
|
||||||
* "zAddOnsVL2s/zDiscord-Map-Pack-4.7.1.vl2"
|
|
||||||
* ]
|
* ]
|
||||||
* }
|
* }
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
async function buildManifest() {
|
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[] = [];
|
const looseFiles: string[] = [];
|
||||||
|
|
||||||
await walkDirectory(baseDir, {
|
await walkDirectory(baseDir, {
|
||||||
onFile: ({ fullPath }) => {
|
onFile: ({ entry }) => {
|
||||||
looseFiles.push(normalizePath(fullPath));
|
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";
|
return entry.name !== "@vl2";
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const filePath of looseFiles) {
|
for (const resourcePath of looseFiles) {
|
||||||
const relativePath = normalizePath(path.relative(baseDir, filePath));
|
const normalizedKey = resourcePath.toLowerCase();
|
||||||
fileSources.set(relativePath, [""]);
|
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[] = [];
|
let archiveDirs: string[] = [];
|
||||||
await walkDirectory(`${baseDir}/@vl2`, {
|
await walkDirectory(`${baseDir}/@vl2`, {
|
||||||
onFile: () => {},
|
onFile: () => {},
|
||||||
onDir: ({ dir, entry, fullPath }) => {
|
onDir: ({ entry }) => {
|
||||||
if (entry.name.endsWith(".vl2")) {
|
if (/\.vl2$/i.test(entry.name)) {
|
||||||
archiveDirs.push(fullPath);
|
const archivePath = path.join(entry.parentPath, entry.name);
|
||||||
|
archiveDirs.push(archivePath);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
@ -101,7 +106,7 @@ async function buildManifest() {
|
||||||
|
|
||||||
archiveDirs = orderBy(
|
archiveDirs = orderBy(
|
||||||
archiveDirs,
|
archiveDirs,
|
||||||
[(fullPath) => path.basename(fullPath).toLowerCase()],
|
[(archivePath) => path.basename(archivePath).toLowerCase()],
|
||||||
["asc"],
|
["asc"],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -110,49 +115,67 @@ async function buildManifest() {
|
||||||
path.relative(`${baseDir}/@vl2`, archivePath),
|
path.relative(`${baseDir}/@vl2`, archivePath),
|
||||||
);
|
);
|
||||||
await walkDirectory(archivePath, {
|
await walkDirectory(archivePath, {
|
||||||
onFile: ({ dir, entry, fullPath }) => {
|
onFile: ({ entry }) => {
|
||||||
const filePath = normalizePath(path.relative(archivePath, fullPath));
|
const resourcePath = normalizePath(
|
||||||
const sources = fileSources.get(filePath) ?? [];
|
path.relative(archivePath, path.join(entry.parentPath, entry.name)),
|
||||||
sources.push(relativeArchivePath);
|
);
|
||||||
fileSources.set(filePath, sources);
|
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<
|
const missions: Record<
|
||||||
string,
|
string,
|
||||||
{ resourcePath: string; displayName: string | null; missionTypes: string[] }
|
{ resourcePath: string; displayName: string | null; missionTypes: string[] }
|
||||||
> = {};
|
> = {};
|
||||||
|
|
||||||
const orderedFiles = Array.from(fileSources.keys()).sort();
|
const sortedResourceKeys = Array.from(fileSources.keys()).sort();
|
||||||
for (const filePath of orderedFiles) {
|
|
||||||
const sources = fileSources.get(filePath);
|
for (const resourceKey of sortedResourceKeys) {
|
||||||
resources[filePath] = sources;
|
const entry = fileSources.get(resourceKey)!;
|
||||||
const lastSource = sources[sources.length - 1];
|
resources[resourceKey] = entry;
|
||||||
|
const [firstSeenPath, ...sourceTuples] = entry;
|
||||||
|
const lastSourceTuple = sourceTuples[sourceTuples.length - 1];
|
||||||
|
const lastSource = lastSourceTuple[0];
|
||||||
|
const lastActualPath = lastSourceTuple[1] ?? firstSeenPath;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`${filePath}${sources[0] ? ` 📦 ${sources[0]}` : ""}${
|
`${firstSeenPath}${sourceTuples[0][0] ? ` 📦 ${sourceTuples[0][0]}` : ""}${
|
||||||
sources.length > 1
|
sourceTuples.length > 1
|
||||||
? sources
|
? sourceTuples
|
||||||
.slice(1)
|
.slice(1)
|
||||||
.map((source) => ` ❗️ ${source}`)
|
.map((tuple) => ` ❗️ ${tuple[0]}`)
|
||||||
.join("")
|
.join("")
|
||||||
: ""
|
: ""
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const resolvedPath = lastSource
|
const resolvedPath = lastSource
|
||||||
? path.join(baseDir, "@vl2", lastSource, filePath)
|
? path.join(baseDir, "@vl2", lastSource, lastActualPath)
|
||||||
: path.join(baseDir, filePath);
|
: path.join(baseDir, lastActualPath);
|
||||||
|
|
||||||
if (filePath.endsWith(".mis")) {
|
if (resourceKey.endsWith(".mis")) {
|
||||||
const missionScript = await fs.readFile(resolvedPath, "utf8");
|
const missionScript = await fs.readFile(resolvedPath, "utf8");
|
||||||
const mission = parseMissionScript(missionScript);
|
const mission = parseMissionScript(missionScript);
|
||||||
const baseName = path.basename(filePath, ".mis");
|
const baseName = path.basename(firstSeenPath, ".mis");
|
||||||
missions[baseName] = {
|
missions[baseName] = {
|
||||||
resourcePath: filePath,
|
resourcePath: resourceKey,
|
||||||
displayName: mission.displayName,
|
displayName: mission.displayName,
|
||||||
missionTypes: mission.missionTypes,
|
missionTypes: mission.missionTypes,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { inspect, parseArgs } from "node:util";
|
import { inspect, parseArgs } from "node:util";
|
||||||
import { parseImageFrameList } from "@/src/ifl";
|
import { parseImageFileList } from "@/src/imageFileList";
|
||||||
import { getFilePath } from "@/src/manifest";
|
import { getFilePath } from "@/src/manifest";
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
|
|
@ -50,7 +50,7 @@ async function run() {
|
||||||
}
|
}
|
||||||
const missionScript = fs.readFileSync(framesFile, "utf8");
|
const missionScript = fs.readFileSync(framesFile, "utf8");
|
||||||
console.log(
|
console.log(
|
||||||
inspect(parseImageFrameList(missionScript), {
|
inspect(parseImageFileList(missionScript), {
|
||||||
colors: true,
|
colors: true,
|
||||||
depth: Infinity,
|
depth: Infinity,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { iterObjects, parseMissionScript } from "@/src/mission";
|
import path from "node:path";
|
||||||
import { parseArgs } from "node:util";
|
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.
|
* 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
|
* tsx scripts/mission-properties.ts -t TerrainBlock -p position
|
||||||
*/
|
*/
|
||||||
const { values, positionals } = parseArgs({
|
const { values } = parseArgs({
|
||||||
allowPositionals: true,
|
allowPositionals: true,
|
||||||
options: {
|
options: {
|
||||||
types: {
|
types: {
|
||||||
|
|
@ -41,6 +42,115 @@ const propertyList =
|
||||||
? null
|
? null
|
||||||
: new Set(values.properties.split(","));
|
: 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({
|
async function run({
|
||||||
typeList,
|
typeList,
|
||||||
propertyList,
|
propertyList,
|
||||||
|
|
@ -51,18 +161,26 @@ async function run({
|
||||||
valuesOnly: boolean;
|
valuesOnly: boolean;
|
||||||
}) {
|
}) {
|
||||||
for await (const inFile of fs.glob("docs/base/**/*.mis")) {
|
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 missionScript = await fs.readFile(inFile, "utf8");
|
||||||
const mission = parseMissionScript(missionScript);
|
|
||||||
for (const consoleObject of iterObjects(mission.objects)) {
|
let ast: AST.Program;
|
||||||
if (!typeList || typeList.has(consoleObject.className)) {
|
try {
|
||||||
for (const property of consoleObject.properties) {
|
ast = parse(missionScript);
|
||||||
if (!propertyList || propertyList.has(property.target.name)) {
|
} 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) {
|
if (valuesOnly) {
|
||||||
console.log(property.value);
|
console.log(property.value);
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
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 { memo, useEffect, useRef } from "react";
|
||||||
import { useThree, useFrame } from "@react-three/fiber";
|
import { useThree, useFrame } from "@react-three/fiber";
|
||||||
import { PositionalAudio, Vector3 } from "three";
|
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 { audioToUrl } from "../loaders";
|
||||||
import { useAudio } from "./AudioContext";
|
import { useAudio } from "./AudioContext";
|
||||||
import { useDebug, useSettings } from "./SettingsProvider";
|
import { useDebug, useSettings } from "./SettingsProvider";
|
||||||
|
|
@ -35,24 +36,16 @@ function getCachedAudioBuffer(
|
||||||
export const AudioEmitter = memo(function AudioEmitter({
|
export const AudioEmitter = memo(function AudioEmitter({
|
||||||
object,
|
object,
|
||||||
}: {
|
}: {
|
||||||
object: ConsoleObject;
|
object: TorqueObject;
|
||||||
}) {
|
}) {
|
||||||
const { debugMode } = useDebug();
|
const { debugMode } = useDebug();
|
||||||
const fileName = getProperty(object, "fileName")?.value ?? "";
|
const fileName = getProperty(object, "fileName") ?? "";
|
||||||
const volume = parseFloat(getProperty(object, "volume")?.value ?? "1");
|
const volume = getProperty(object, "volume") ?? 1;
|
||||||
const minDistance = parseFloat(
|
const minDistance = getProperty(object, "minDistance") ?? 1;
|
||||||
getProperty(object, "minDistance")?.value ?? "1",
|
const maxDistance = getProperty(object, "maxDistance") ?? 1;
|
||||||
);
|
const minLoopGap = getProperty(object, "minLoopGap") ?? 0;
|
||||||
const maxDistance = parseFloat(
|
const maxLoopGap = getProperty(object, "maxLoopGap") ?? 0;
|
||||||
getProperty(object, "maxDistance")?.value ?? "1",
|
const is3D = getProperty(object, "is3D") ?? 0;
|
||||||
);
|
|
||||||
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 [x, y, z] = getPosition(object);
|
const [x, y, z] = getPosition(object);
|
||||||
const { scene, camera } = useThree();
|
const { scene, camera } = useThree();
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,14 @@
|
||||||
import { useEffect, useId, useMemo, useRef } from "react";
|
import { useEffect, useId, useMemo } from "react";
|
||||||
import { PerspectiveCamera } from "@react-three/drei";
|
|
||||||
import { useCameras } from "./CamerasProvider";
|
import { useCameras } from "./CamerasProvider";
|
||||||
import { useSettings } from "./SettingsProvider";
|
import type { TorqueObject } from "../torqueScript";
|
||||||
import {
|
import { getPosition, getProperty, getRotation } from "../mission";
|
||||||
ConsoleObject,
|
import { Vector3 } from "three";
|
||||||
getPosition,
|
|
||||||
getProperty,
|
|
||||||
getRotation,
|
|
||||||
} from "../mission";
|
|
||||||
import { Quaternion, Vector3 } from "three";
|
|
||||||
|
|
||||||
export function Camera({ object }: { object: ConsoleObject }) {
|
export function Camera({ object }: { object: TorqueObject }) {
|
||||||
const { registerCamera, unregisterCamera } = useCameras();
|
const { registerCamera, unregisterCamera } = useCameras();
|
||||||
const id = useId();
|
const id = useId();
|
||||||
|
|
||||||
const dataBlock = getProperty(object, "dataBlock").value;
|
const dataBlock = getProperty(object, "dataBlock");
|
||||||
const position = useMemo(() => getPosition(object), [object]);
|
const position = useMemo(() => getPosition(object), [object]);
|
||||||
const q = useMemo(() => getRotation(object), [object]);
|
const q = useMemo(() => getRotation(object), [object]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,6 @@ export function DebugPlaceholder({ color }: { color: string }) {
|
||||||
return debugMode ? <ShapePlaceholder color={color} /> : null;
|
return debugMode ? <ShapePlaceholder color={color} /> : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StaticShapeType = "StaticShape" | "TSStatic" | "Item" | "Turret";
|
|
||||||
|
|
||||||
export const ShapeModel = memo(function ShapeModel() {
|
export const ShapeModel = memo(function ShapeModel() {
|
||||||
const { shapeName } = useShapeInfo();
|
const { shapeName } = useShapeInfo();
|
||||||
const { debugMode } = useDebug();
|
const { debugMode } = useDebug();
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,8 @@ import { ErrorBoundary } from "react-error-boundary";
|
||||||
import { Mesh } from "three";
|
import { Mesh } from "three";
|
||||||
import { useGLTF, useTexture } from "@react-three/drei";
|
import { useGLTF, useTexture } from "@react-three/drei";
|
||||||
import { BASE_URL, interiorTextureToUrl, interiorToUrl } from "../loaders";
|
import { BASE_URL, interiorTextureToUrl, interiorToUrl } from "../loaders";
|
||||||
import {
|
import type { TorqueObject } from "../torqueScript";
|
||||||
ConsoleObject,
|
import { getPosition, getProperty, getRotation, getScale } from "../mission";
|
||||||
getPosition,
|
|
||||||
getProperty,
|
|
||||||
getRotation,
|
|
||||||
getScale,
|
|
||||||
} from "../mission";
|
|
||||||
import { setupColor } from "../textureUtils";
|
import { setupColor } from "../textureUtils";
|
||||||
import { FloatingLabel } from "./FloatingLabel";
|
import { FloatingLabel } from "./FloatingLabel";
|
||||||
import { useDebug } from "./SettingsProvider";
|
import { useDebug } from "./SettingsProvider";
|
||||||
|
|
@ -93,9 +88,9 @@ function DebugInteriorPlaceholder() {
|
||||||
export const InteriorInstance = memo(function InteriorInstance({
|
export const InteriorInstance = memo(function InteriorInstance({
|
||||||
object,
|
object,
|
||||||
}: {
|
}: {
|
||||||
object: ConsoleObject;
|
object: TorqueObject;
|
||||||
}) {
|
}) {
|
||||||
const interiorFile = getProperty(object, "interiorFile").value;
|
const interiorFile = getProperty(object, "interiorFile");
|
||||||
const position = useMemo(() => getPosition(object), [object]);
|
const position = useMemo(() => getPosition(object), [object]);
|
||||||
const scale = useMemo(() => getScale(object), [object]);
|
const scale = useMemo(() => getScale(object), [object]);
|
||||||
const q = useMemo(() => getRotation(object), [object]);
|
const q = useMemo(() => getRotation(object), [object]);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
import { Suspense, useMemo } from "react";
|
import { Suspense, useMemo } from "react";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import {
|
import type { TorqueObject } from "../torqueScript";
|
||||||
ConsoleObject,
|
import { getPosition, getProperty, getRotation, getScale } from "../mission";
|
||||||
getPosition,
|
|
||||||
getProperty,
|
|
||||||
getRotation,
|
|
||||||
getScale,
|
|
||||||
} from "../mission";
|
|
||||||
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
|
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
|
||||||
import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
||||||
import { useSimGroup } from "./SimGroup";
|
import { useSimGroup } from "./SimGroup";
|
||||||
|
|
@ -61,9 +56,9 @@ const TEAM_NAMES = {
|
||||||
2: "Inferno",
|
2: "Inferno",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Item({ object }: { object: ConsoleObject }) {
|
export function Item({ object }: { object: TorqueObject }) {
|
||||||
const simGroup = useSimGroup();
|
const simGroup = useSimGroup();
|
||||||
const dataBlock = getProperty(object, "dataBlock").value;
|
const dataBlock = getProperty(object, "dataBlock") ?? "";
|
||||||
|
|
||||||
const position = useMemo(() => getPosition(object), [object]);
|
const position = useMemo(() => getPosition(object), [object]);
|
||||||
const scale = useMemo(() => getScale(object), [object]);
|
const scale = useMemo(() => getScale(object), [object]);
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,74 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { loadMission } from "../loaders";
|
import { loadMission } from "../loaders";
|
||||||
|
import {
|
||||||
|
executeMission,
|
||||||
|
type ParsedMission,
|
||||||
|
type ExecutedMission,
|
||||||
|
} from "../mission";
|
||||||
|
import { createScriptLoader } from "../torqueScript/scriptLoader.browser";
|
||||||
import { renderObject } from "./renderObject";
|
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({
|
return useQuery({
|
||||||
queryKey: ["mission", name],
|
queryKey: ["parsedMission", name],
|
||||||
queryFn: () => loadMission(name),
|
queryFn: () => loadMission(name),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Mission = memo(function Mission({ name }: { name: string }) {
|
function useExecutedMission(parsedMission: ParsedMission | undefined) {
|
||||||
const { data: mission } = useMission(name);
|
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 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 { createContext, useContext, useMemo } from "react";
|
||||||
import { ConsoleObject } from "../mission";
|
import type { TorqueObject } from "../torqueScript";
|
||||||
import { renderObject } from "./renderObject";
|
import { renderObject } from "./renderObject";
|
||||||
|
|
||||||
export type SimGroupContextType = {
|
export type SimGroupContextType = {
|
||||||
object: ConsoleObject;
|
object: TorqueObject;
|
||||||
parent: SimGroupContextType;
|
parent: SimGroupContextType;
|
||||||
hasTeams: boolean;
|
hasTeams: boolean;
|
||||||
team: null | number;
|
team: null | number;
|
||||||
|
|
@ -15,7 +15,7 @@ export function useSimGroup() {
|
||||||
return useContext(SimGroupContext);
|
return useContext(SimGroupContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SimGroup({ object }: { object: ConsoleObject }) {
|
export function SimGroup({ object }: { object: TorqueObject }) {
|
||||||
const parent = useSimGroup();
|
const parent = useSimGroup();
|
||||||
|
|
||||||
const simGroup: SimGroupContextType = useMemo(() => {
|
const simGroup: SimGroupContextType = useMemo(() => {
|
||||||
|
|
@ -26,12 +26,14 @@ export function SimGroup({ object }: { object: ConsoleObject }) {
|
||||||
hasTeams = true;
|
hasTeams = true;
|
||||||
if (parent.team != null) {
|
if (parent.team != null) {
|
||||||
team = parent.team;
|
team = parent.team;
|
||||||
} else if (object.instanceName) {
|
} else if (object._name) {
|
||||||
const match = object.instanceName.match(/^team(\d+)$/i);
|
const match = object._name.match(/^team(\d+)$/i);
|
||||||
team = parseInt(match[1], 10);
|
if (match) {
|
||||||
|
team = parseInt(match[1], 10);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (object.instanceName) {
|
} else if (object._name) {
|
||||||
hasTeams = object.instanceName.toLowerCase() === "teams";
|
hasTeams = object._name.toLowerCase() === "teams";
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -49,7 +51,7 @@ export function SimGroup({ object }: { object: ConsoleObject }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SimGroupContext.Provider value={simGroup}>
|
<SimGroupContext.Provider value={simGroup}>
|
||||||
{object.children.map((child, i) => renderObject(child, i))}
|
{(object._children ?? []).map((child, i) => renderObject(child, i))}
|
||||||
</SimGroupContext.Provider>
|
</SimGroupContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { Suspense, useMemo, useEffect, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useCubeTexture } from "@react-three/drei";
|
import { useCubeTexture } from "@react-three/drei";
|
||||||
import { Color, ShaderMaterial, BackSide, Euler } from "three";
|
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 { useSettings } from "./SettingsProvider";
|
||||||
import { BASE_URL, getUrlForPath, loadDetailMapList } from "../loaders";
|
import { BASE_URL, getUrlForPath, loadDetailMapList } from "../loaders";
|
||||||
import { useThree } from "@react-three/fiber";
|
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();
|
const { fogEnabled } = useSettings();
|
||||||
|
|
||||||
// Skybox textures.
|
// Skybox textures.
|
||||||
const materialList = getProperty(object, "materialList")?.value;
|
const materialList = getProperty(object, "materialList");
|
||||||
|
|
||||||
// Fog parameters.
|
// Fog parameters.
|
||||||
// TODO: There can be multiple fog volumes/layers. Render simple fog for now.
|
// TODO: There can be multiple fog volumes/layers. Render simple fog for now.
|
||||||
const fogDistance = useMemo(() => {
|
const fogDistance = useMemo(() => {
|
||||||
const distanceString = getProperty(object, "fogDistance")?.value;
|
return getProperty(object, "fogDistance");
|
||||||
if (distanceString) {
|
|
||||||
return parseFloat(distanceString);
|
|
||||||
}
|
|
||||||
}, [object]);
|
}, [object]);
|
||||||
|
|
||||||
const fogColor = useMemo(() => {
|
const fogColor = useMemo(() => {
|
||||||
const colorString = getProperty(object, "fogColor")?.value;
|
const colorString = getProperty(object, "fogColor");
|
||||||
if (colorString) {
|
if (colorString) {
|
||||||
// `colorString` might specify an alpha value, but three.js doesn't
|
// `colorString` might specify an alpha value, but three.js doesn't
|
||||||
// support opacity on fog or scene backgrounds, so ignore it.
|
// 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 [
|
return [
|
||||||
new Color().setRGB(r, g, b),
|
new Color().setRGB(r, g, b),
|
||||||
new Color().setRGB(r, g, b).convertSRGBToLinear(),
|
new Color().setRGB(r, g, b).convertSRGBToLinear(),
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
import { Suspense, useMemo } from "react";
|
import { Suspense, useMemo } from "react";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import {
|
import type { TorqueObject } from "../torqueScript";
|
||||||
ConsoleObject,
|
import { getPosition, getProperty, getRotation, getScale } from "../mission";
|
||||||
getPosition,
|
|
||||||
getProperty,
|
|
||||||
getRotation,
|
|
||||||
getScale,
|
|
||||||
} from "../mission";
|
|
||||||
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
|
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
|
||||||
import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
||||||
|
|
||||||
|
|
@ -20,6 +15,8 @@ const dataBlockToShapeName = {
|
||||||
GeneratorLarge: "station_generator_large.dts",
|
GeneratorLarge: "station_generator_large.dts",
|
||||||
InteriorFlagStand: "int_flagstand.dts",
|
InteriorFlagStand: "int_flagstand.dts",
|
||||||
LightMaleHuman_Dead: "light_male_dead.dts",
|
LightMaleHuman_Dead: "light_male_dead.dts",
|
||||||
|
MediumMaleHuman_Dead: "medium_male_dead.dts",
|
||||||
|
HeavyMaleHuman_Dead: "heavy_male_dead.dts",
|
||||||
LogoProjector: "teamlogo_projector.dts",
|
LogoProjector: "teamlogo_projector.dts",
|
||||||
SensorLargePulse: "sensor_pulse_large.dts",
|
SensorLargePulse: "sensor_pulse_large.dts",
|
||||||
SensorMediumPulse: "sensor_pulse_medium.dts",
|
SensorMediumPulse: "sensor_pulse_medium.dts",
|
||||||
|
|
@ -44,8 +41,8 @@ function getDataBlockShape(dataBlock: string) {
|
||||||
return _caseInsensitiveLookup[dataBlock.toLowerCase()];
|
return _caseInsensitiveLookup[dataBlock.toLowerCase()];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StaticShape({ object }: { object: ConsoleObject }) {
|
export function StaticShape({ object }: { object: TorqueObject }) {
|
||||||
const dataBlock = getProperty(object, "dataBlock").value;
|
const dataBlock = getProperty(object, "dataBlock") ?? "";
|
||||||
|
|
||||||
const position = useMemo(() => getPosition(object), [object]);
|
const position = useMemo(() => getPosition(object), [object]);
|
||||||
const q = useMemo(() => getRotation(object), [object]);
|
const q = useMemo(() => getRotation(object), [object]);
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,29 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Color } from "three";
|
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 direction = useMemo(() => {
|
||||||
const directionStr = getProperty(object, "direction")?.value ?? "0 0 -1";
|
const directionStr = getProperty(object, "direction") ?? "0 0 -1";
|
||||||
const [x, y, z] = directionStr.split(" ").map((s) => parseFloat(s));
|
// 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
|
// Scale the direction vector to position the light far from the scene
|
||||||
const scale = 5000;
|
const scale = 5000;
|
||||||
return [x * scale, y * scale, z * scale] as [number, number, number];
|
return [x * scale, y * scale, z * scale] as [number, number, number];
|
||||||
}, [object]);
|
}, [object]);
|
||||||
|
|
||||||
const color = useMemo(() => {
|
const color = useMemo(() => {
|
||||||
const colorStr = getProperty(object, "color")?.value ?? "1 1 1 1";
|
const colorStr = getProperty(object, "color") ?? "1 1 1 1";
|
||||||
const [r, g, b] = colorStr.split(" ").map((s) => parseFloat(s));
|
// 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];
|
return [r, g, b] as [number, number, number];
|
||||||
}, [object]);
|
}, [object]);
|
||||||
|
|
||||||
const ambient = useMemo(() => {
|
const ambient = useMemo(() => {
|
||||||
const ambientStr = getProperty(object, "ambient")?.value ?? "0.5 0.5 0.5 1";
|
const ambientStr = getProperty(object, "ambient") ?? "0.5 0.5 0.5 1";
|
||||||
const [r, g, b] = ambientStr.split(" ").map((s) => parseFloat(s));
|
// 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];
|
return [r, g, b] as [number, number, number];
|
||||||
}, [object]);
|
}, [object]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,12 @@
|
||||||
import { Suspense, useMemo } from "react";
|
import { Suspense, useMemo } from "react";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import {
|
import type { TorqueObject } from "../torqueScript";
|
||||||
ConsoleObject,
|
import { getPosition, getProperty, getRotation, getScale } from "../mission";
|
||||||
getPosition,
|
|
||||||
getProperty,
|
|
||||||
getRotation,
|
|
||||||
getScale,
|
|
||||||
} from "../mission";
|
|
||||||
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
|
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
|
||||||
import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
||||||
|
|
||||||
export function TSStatic({ object }: { object: ConsoleObject }) {
|
export function TSStatic({ object }: { object: TorqueObject }) {
|
||||||
const shapeName = getProperty(object, "shapeName").value;
|
const shapeName = getProperty(object, "shapeName");
|
||||||
|
|
||||||
const position = useMemo(() => getPosition(object), [object]);
|
const position = useMemo(() => getPosition(object), [object]);
|
||||||
const q = useMemo(() => getRotation(object), [object]);
|
const q = useMemo(() => getRotation(object), [object]);
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,8 @@ import {
|
||||||
import { useTexture } from "@react-three/drei";
|
import { useTexture } from "@react-three/drei";
|
||||||
import { uint16ToFloat32 } from "../arrayUtils";
|
import { uint16ToFloat32 } from "../arrayUtils";
|
||||||
import { loadTerrain, terrainTextureToUrl } from "../loaders";
|
import { loadTerrain, terrainTextureToUrl } from "../loaders";
|
||||||
import {
|
import type { TorqueObject } from "../torqueScript";
|
||||||
ConsoleObject,
|
import { getPosition, getProperty, getRotation, getScale } from "../mission";
|
||||||
getPosition,
|
|
||||||
getProperty,
|
|
||||||
getRotation,
|
|
||||||
getScale,
|
|
||||||
} from "../mission";
|
|
||||||
import {
|
import {
|
||||||
setupColor,
|
setupColor,
|
||||||
setupMask,
|
setupMask,
|
||||||
|
|
@ -204,28 +199,19 @@ function TerrainMaterial({
|
||||||
export const TerrainBlock = memo(function TerrainBlock({
|
export const TerrainBlock = memo(function TerrainBlock({
|
||||||
object,
|
object,
|
||||||
}: {
|
}: {
|
||||||
object: ConsoleObject;
|
object: TorqueObject;
|
||||||
}) {
|
}) {
|
||||||
const terrainFile: string = getProperty(object, "terrainFile").value;
|
const terrainFile = getProperty(object, "terrainFile");
|
||||||
|
|
||||||
const squareSize = useMemo(() => {
|
const squareSize = useMemo(() => {
|
||||||
const squareSizeString: string | undefined = getProperty(
|
return getProperty(object, "squareSize") ?? DEFAULT_SQUARE_SIZE;
|
||||||
object,
|
|
||||||
"squareSize",
|
|
||||||
)?.value;
|
|
||||||
return squareSizeString
|
|
||||||
? parseInt(squareSizeString, 10)
|
|
||||||
: DEFAULT_SQUARE_SIZE;
|
|
||||||
}, [object]);
|
}, [object]);
|
||||||
|
|
||||||
const emptySquares: number[] = useMemo(() => {
|
const emptySquares: number[] = useMemo(() => {
|
||||||
const emptySquaresString: string | undefined = getProperty(
|
const emptySquaresValue = getProperty(object, "emptySquares");
|
||||||
object,
|
// Note: This is a space-separated string, so we split and parse each component.
|
||||||
"emptySquares",
|
return emptySquaresValue
|
||||||
)?.value;
|
? emptySquaresValue.split(" ").map((s: string) => parseInt(s, 10))
|
||||||
|
|
||||||
return emptySquaresString
|
|
||||||
? emptySquaresString.split(" ").map((s) => parseInt(s, 10))
|
|
||||||
: [];
|
: [];
|
||||||
}, [object]);
|
}, [object]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
import { Suspense, useMemo } from "react";
|
import { Suspense, useMemo } from "react";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import {
|
import type { TorqueObject } from "../torqueScript";
|
||||||
ConsoleObject,
|
import { getPosition, getProperty, getRotation, getScale } from "../mission";
|
||||||
getPosition,
|
|
||||||
getProperty,
|
|
||||||
getRotation,
|
|
||||||
getScale,
|
|
||||||
} from "../mission";
|
|
||||||
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
|
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
|
||||||
import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
||||||
|
|
||||||
|
|
@ -34,9 +29,9 @@ function getDataBlockShape(dataBlock: string) {
|
||||||
return _caseInsensitiveLookup[dataBlock.toLowerCase()];
|
return _caseInsensitiveLookup[dataBlock.toLowerCase()];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Turret({ object }: { object: ConsoleObject }) {
|
export function Turret({ object }: { object: TorqueObject }) {
|
||||||
const dataBlock = getProperty(object, "dataBlock").value;
|
const dataBlock = getProperty(object, "dataBlock") ?? "";
|
||||||
const initialBarrel = getProperty(object, "initialBarrel")?.value;
|
const initialBarrel = getProperty(object, "initialBarrel");
|
||||||
|
|
||||||
const position = useMemo(() => getPosition(object), [object]);
|
const position = useMemo(() => getPosition(object), [object]);
|
||||||
const q = useMemo(() => getRotation(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 { useTexture } from "@react-three/drei";
|
||||||
import { BoxGeometry, DoubleSide } from "three";
|
import { BoxGeometry, DoubleSide } from "three";
|
||||||
import { textureToUrl } from "../loaders";
|
import { textureToUrl } from "../loaders";
|
||||||
import {
|
import type { TorqueObject } from "../torqueScript";
|
||||||
ConsoleObject,
|
import { getPosition, getProperty, getRotation, getScale } from "../mission";
|
||||||
getPosition,
|
|
||||||
getProperty,
|
|
||||||
getRotation,
|
|
||||||
getScale,
|
|
||||||
} from "../mission";
|
|
||||||
import { setupColor } from "../textureUtils";
|
import { setupColor } from "../textureUtils";
|
||||||
|
|
||||||
export function WaterMaterial({
|
export function WaterMaterial({
|
||||||
|
|
@ -35,14 +30,14 @@ export function WaterMaterial({
|
||||||
export const WaterBlock = memo(function WaterBlock({
|
export const WaterBlock = memo(function WaterBlock({
|
||||||
object,
|
object,
|
||||||
}: {
|
}: {
|
||||||
object: ConsoleObject;
|
object: TorqueObject;
|
||||||
}) {
|
}) {
|
||||||
const position = useMemo(() => getPosition(object), [object]);
|
const position = useMemo(() => getPosition(object), [object]);
|
||||||
const q = useMemo(() => getRotation(object), [object]);
|
const q = useMemo(() => getRotation(object), [object]);
|
||||||
const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(object), [object]);
|
const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(object), [object]);
|
||||||
|
|
||||||
const surfaceTexture =
|
const surfaceTexture =
|
||||||
getProperty(object, "surfaceTexture")?.value ?? "liquidTiles/BlueWater";
|
getProperty(object, "surfaceTexture") ?? "liquidTiles/BlueWater";
|
||||||
|
|
||||||
const geometry = useMemo(() => {
|
const geometry = useMemo(() => {
|
||||||
const geom = new BoxGeometry(scaleX, scaleY, scaleZ);
|
const geom = new BoxGeometry(scaleX, scaleY, scaleZ);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { useMemo } from "react";
|
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 { FloatingLabel } from "./FloatingLabel";
|
||||||
import { useSimGroup } from "./SimGroup";
|
import { useSimGroup } from "./SimGroup";
|
||||||
|
|
||||||
export function WayPoint({ object }: { object: ConsoleObject }) {
|
export function WayPoint({ object }: { object: TorqueObject }) {
|
||||||
const simGroup = useSimGroup();
|
const simGroup = useSimGroup();
|
||||||
const position = useMemo(() => getPosition(object), [object]);
|
const position = useMemo(() => getPosition(object), [object]);
|
||||||
const label = getProperty(object, "name")?.value;
|
const label = getProperty(object, "name");
|
||||||
|
|
||||||
return label ? (
|
return label ? (
|
||||||
<FloatingLabel position={position} opacity={0.6}>
|
<FloatingLabel position={position} opacity={0.6}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ConsoleObject } from "../mission";
|
import type { TorqueObject } from "../torqueScript";
|
||||||
import { TerrainBlock } from "./TerrainBlock";
|
import { TerrainBlock } from "./TerrainBlock";
|
||||||
import { WaterBlock } from "./WaterBlock";
|
import { WaterBlock } from "./WaterBlock";
|
||||||
import { SimGroup } from "./SimGroup";
|
import { SimGroup } from "./SimGroup";
|
||||||
|
|
@ -29,7 +29,7 @@ const componentMap = {
|
||||||
WayPoint,
|
WayPoint,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function renderObject(object: ConsoleObject, key: string | number) {
|
export function renderObject(object: TorqueObject, key: string | number) {
|
||||||
const Component = componentMap[object.className];
|
const Component = componentMap[object._className];
|
||||||
return Component ? <Component key={key} object={object} /> : null;
|
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
|
const lines = source
|
||||||
.split(/(?:\r\n|\r|\n)/g)
|
.split(/(?:\r\n|\r|\n)/g)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean)
|
||||||
|
.filter((line) => !line.startsWith(";")); // discard comments
|
||||||
|
|
||||||
return lines.map((line) => {
|
return lines.map((line) => {
|
||||||
const fileWithCount = line.match(/^(.+)\s(\d+)$/);
|
const fileWithCount = line.match(/^(.+)\s(\d+)$/);
|
||||||
if (fileWithCount) {
|
if (fileWithCount) {
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { parseImageFrameList } from "./ifl";
|
import { parseImageFileList } from "./imageFileList";
|
||||||
import { getActualResourcePath, getMissionInfo, getSource } from "./manifest";
|
import { getActualResourcePath, getMissionInfo, getSource } from "./manifest";
|
||||||
import { parseMissionScript } from "./mission";
|
import { parseMissionScript } from "./mission";
|
||||||
import { parseTerrainBuffer } from "./terrain";
|
import { parseTerrainBuffer } from "./terrain";
|
||||||
|
|
@ -94,5 +94,5 @@ export async function loadImageFrameList(iflPath: string) {
|
||||||
const url = getUrlForPath(iflPath);
|
const url = getUrlForPath(iflPath);
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const source = await res.text();
|
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 {
|
// Source tuple: [sourcePath] or [sourcePath, actualPath] if casing differs
|
||||||
resources: Record<string, string[]>;
|
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<
|
missions: Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
|
|
@ -12,86 +24,81 @@ const manifest = untypedManifest as {
|
||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getSource(resourcePath: string) {
|
function normalizeKey(resourcePath: string): string {
|
||||||
const sources = manifest.resources[resourcePath];
|
return normalizePath(resourcePath).toLowerCase();
|
||||||
if (sources && sources.length > 0) {
|
}
|
||||||
return sources[sources.length - 1];
|
|
||||||
|
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 {
|
} else {
|
||||||
throw new Error(`Resource not found in manifest: ${resourcePath}`);
|
throw new Error(`Resource not found in manifest: ${resourcePath}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const _resourcePathCache = new Map();
|
/**
|
||||||
|
* Get the actual resource path with its original casing as seen in the filesystem.
|
||||||
export function getActualResourcePath(resourcePath: string) {
|
* This handles case-insensitive lookups by normalizing the input path.
|
||||||
if (_resourcePathCache.has(resourcePath)) {
|
*/
|
||||||
return _resourcePathCache.get(resourcePath);
|
export function getActualResourcePath(resourcePath: string): string {
|
||||||
}
|
const entry = getEntry(resourcePath);
|
||||||
const actualResourcePath = getActualResourcePathUncached(resourcePath);
|
if (entry) {
|
||||||
_resourcePathCache.set(resourcePath, actualResourcePath);
|
return entry[0]; // First element is the first-seen casing
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For paths with numeric suffixes (e.g., "generator0.png"), strip the number and try again
|
// Fallback: try stripping numeric suffixes (e.g., "generator0.png" -> "generator.png")
|
||||||
// e.g., "generator0.png" -> "generator.png"
|
|
||||||
const pathWithoutNumber = resourcePath.replace(/\d+(\.(png))$/i, "$1");
|
const pathWithoutNumber = resourcePath.replace(/\d+(\.(png))$/i, "$1");
|
||||||
const lowerCasedWithoutNumber = pathWithoutNumber.toLowerCase();
|
|
||||||
|
|
||||||
if (pathWithoutNumber !== resourcePath) {
|
if (pathWithoutNumber !== resourcePath) {
|
||||||
// If we stripped a number, try to find the version without it
|
const entryWithoutNumber = getEntry(pathWithoutNumber);
|
||||||
const foundWithoutNumber = resourcePaths.find(
|
if (entryWithoutNumber) {
|
||||||
(s) => s.toLowerCase() === lowerCasedWithoutNumber,
|
return entryWithoutNumber[0];
|
||||||
);
|
|
||||||
if (foundWithoutNumber) {
|
|
||||||
return foundWithoutNumber;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTexture = resourcePath.startsWith("textures/");
|
// Fallback: try nested texture paths
|
||||||
if (isTexture) {
|
const normalized = normalizeKey(resourcePath);
|
||||||
const foundNested = resourcePaths.find(
|
if (normalized.startsWith("textures/")) {
|
||||||
(s) =>
|
for (const key of Object.keys(manifest.resources)) {
|
||||||
s
|
const stripped = key.replace(
|
||||||
.replace(
|
/^(textures\/)((lush|desert|badlands|lava|ice|jaggedclaw|terraintiles)\/)/,
|
||||||
/^(textures\/)((lush|desert|badlands|lava|ice|jaggedclaw|terrainTiles)\/)/,
|
"$1",
|
||||||
"$1",
|
);
|
||||||
)
|
if (stripped === normalized) {
|
||||||
.toLowerCase() === lowerCased,
|
return manifest.resources[key][0];
|
||||||
);
|
}
|
||||||
if (foundNested) {
|
|
||||||
return foundNested;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return resourcePath;
|
return resourcePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _cachedResourceList = Object.keys(manifest.resources);
|
export function getResourceList(): string[] {
|
||||||
|
return Object.keys(manifest.resources);
|
||||||
export function getResourceList() {
|
|
||||||
return _cachedResourceList;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFilePath(resourcePath: string) {
|
export function getFilePath(resourcePath: string): string {
|
||||||
const source = getSource(resourcePath);
|
const entry = getEntry(resourcePath);
|
||||||
if (source) {
|
if (!entry) {
|
||||||
return `public/base/@vl2/${source}/${resourcePath}`;
|
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 {
|
} 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 { 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;
|
// Patterns for extracting metadata from comments
|
||||||
const sectionBeginComment = /^--- ([A-Z ]+) BEGIN ---$/;
|
const definitionComment =
|
||||||
const sectionEndComment = /^--- ([A-Z ]+) END ---$/;
|
/^[ \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;
|
let match;
|
||||||
match = text.match(sectionBeginComment);
|
match = text.match(sectionBeginComment);
|
||||||
if (match) {
|
if (match) {
|
||||||
return {
|
return { type: "sectionBegin" as const, name: match[1] };
|
||||||
type: "sectionBegin",
|
|
||||||
name: match[1],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
match = text.match(sectionEndComment);
|
match = text.match(sectionEndComment);
|
||||||
if (match) {
|
if (match) {
|
||||||
return {
|
return { type: "sectionEnd" as const, name: match[1] };
|
||||||
type: "sectionEnd",
|
|
||||||
name: match[1],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
match = text.match(definitionComment);
|
match = text.match(definitionComment);
|
||||||
if (match) {
|
if (match) {
|
||||||
|
|
@ -32,206 +40,178 @@ function parseComment(text) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseInstance(instance) {
|
function extractCommentMetadata(ast: AST.Program): {
|
||||||
return {
|
pragma: Record<string, string>;
|
||||||
className: instance.className,
|
sections: CommentSection[];
|
||||||
instanceName: instance.instanceName,
|
} {
|
||||||
properties: instance.body
|
const pragma: Record<string, string> = {};
|
||||||
.filter((def) => def.type === "definition")
|
const sections: CommentSection[] = [];
|
||||||
.map((def) => {
|
let currentSection: CommentSection = { name: null, comments: [] };
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
default:
|
// Walk through all items looking for comments
|
||||||
throw new Error(
|
function processItems(items: (AST.Statement | AST.Comment)[]) {
|
||||||
`Unhandled value type: ${def.target.name} = ${def.value.type}`,
|
for (const item of items) {
|
||||||
);
|
if (item.type === "Comment") {
|
||||||
}
|
const marker = parseCommentMarker(item.value);
|
||||||
}),
|
if (marker) {
|
||||||
children: instance.body
|
switch (marker.type) {
|
||||||
.filter((def) => def.type === "instance")
|
case "definition":
|
||||||
.map((def) => parseInstance(def)),
|
if (currentSection.name === null) {
|
||||||
};
|
// Top-level definitions are pragma (normalize key to lowercase)
|
||||||
}
|
pragma[marker.identifier.toLowerCase()] = marker.value;
|
||||||
|
|
||||||
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);
|
|
||||||
} else {
|
} else {
|
||||||
mission.pragma[parsed.identifier] = parsed.value;
|
currentSection.comments.push(item.value);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
case "sectionBegin":
|
||||||
case "sectionEnd": {
|
// Save current section if it has content
|
||||||
if (parsed.name !== section.name) {
|
if (
|
||||||
throw new Error("Ending unmatched section!");
|
currentSection.name !== null ||
|
||||||
|
currentSection.comments.length > 0
|
||||||
|
) {
|
||||||
|
sections.push(currentSection);
|
||||||
}
|
}
|
||||||
if (section.name || section.definitions.length) {
|
// Normalize section name to uppercase for consistent lookups
|
||||||
mission.sections.push(section);
|
currentSection = {
|
||||||
}
|
name: marker.name.toUpperCase(),
|
||||||
section = { name: null, definitions: [] };
|
comments: [],
|
||||||
|
};
|
||||||
break;
|
break;
|
||||||
}
|
case "sectionEnd":
|
||||||
case "sectionBegin": {
|
if (currentSection.name !== null) {
|
||||||
if (section.name) {
|
sections.push(currentSection);
|
||||||
throw new Error("Already in a section!");
|
|
||||||
}
|
}
|
||||||
if (section.name || section.definitions.length) {
|
currentSection = { name: null, comments: [] };
|
||||||
mission.sections.push(section);
|
|
||||||
}
|
|
||||||
section = { name: parsed.name, definitions: [] };
|
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
section.definitions.push(statement);
|
// Regular comment
|
||||||
|
currentSection.comments.push(item.value);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
section.definitions.push(statement);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (section.name || section.definitions.length) {
|
processItems(ast.body as (AST.Statement | AST.Comment)[]);
|
||||||
mission.sections.push(section);
|
|
||||||
|
// 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 {
|
return {
|
||||||
displayName:
|
displayName: pragma.displayname ?? null,
|
||||||
mission.pragma.DisplayName ?? mission.pragma.Displayname ?? null,
|
missionTypes: pragma.missiontypes?.split(/\s+/).filter(Boolean) ?? [],
|
||||||
missionTypes:
|
missionBriefing: getSection("MISSION BRIEFING"),
|
||||||
mission.pragma.MissionTypes?.split(/\s+/).filter(Boolean) ?? [],
|
briefingWav: pragma.briefingwav ?? null,
|
||||||
missionQuote:
|
bitmap: pragma.bitmap ?? null,
|
||||||
mission.sections
|
planetName: pragma.planetname ?? null,
|
||||||
.find((section) => section.name === "MISSION QUOTE")
|
missionBlurb: getSection("MISSION BLURB"),
|
||||||
?.definitions.filter((def) => def.type === "comment")
|
missionQuote: getSection("MISSION QUOTE"),
|
||||||
.map((def) => def.text)
|
missionString: getSection("MISSION STRING"),
|
||||||
.join("\n") ?? null,
|
execScriptPaths: ast.execScriptPaths,
|
||||||
missionString:
|
hasDynamicExec: ast.hasDynamicExec,
|
||||||
mission.sections
|
ast,
|
||||||
.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"),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Mission = ReturnType<typeof parseMissionScript>;
|
export async function executeMission(
|
||||||
export type ConsoleObject = Mission["objects"][number];
|
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) {
|
for (const obj of objectList) {
|
||||||
yield obj;
|
yield obj;
|
||||||
for (const child of iterObjects(obj.children)) {
|
if (obj._children) {
|
||||||
yield child;
|
yield* iterObjects(obj._children);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTerrainBlock(mission: Mission): ConsoleObject {
|
export function getProperty(obj: TorqueObject, name: string): any {
|
||||||
for (const obj of iterObjects(mission.objects)) {
|
return obj[name.toLowerCase()];
|
||||||
if (obj.className === "TerrainBlock") {
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("No TerrainBlock found!");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTerrainFile(mission: Mission) {
|
export function getPosition(obj: TorqueObject): [number, number, number] {
|
||||||
const terrainBlock = getTerrainBlock(mission);
|
const position = obj.position ?? "0 0 0";
|
||||||
return terrainBlock.properties.find(
|
const [x, y, z] = position.split(" ").map((s: string) => parseFloat(s));
|
||||||
(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
|
|
||||||
return [y || 0, z || 0, x || 0];
|
return [y || 0, z || 0, x || 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getScale(obj: ConsoleObject): [number, number, number] {
|
export function getScale(obj: TorqueObject): [number, number, number] {
|
||||||
const scale = getProperty(obj, "scale")?.value ?? "1 1 1";
|
const scale = obj.scale ?? "1 1 1";
|
||||||
const [sx, sy, sz] = scale.split(" ").map((s) => parseFloat(s));
|
const [sx, sy, sz] = scale.split(" ").map((s: string) => parseFloat(s));
|
||||||
// Convert Torque3D coordinates to Three.js: XYZ -> YZX
|
|
||||||
return [sy || 0, sz || 0, sx || 0];
|
return [sy || 0, sz || 0, sx || 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRotation(obj: ConsoleObject): Quaternion {
|
export function getRotation(obj: TorqueObject): Quaternion {
|
||||||
const rotation = getProperty(obj, "rotation")?.value ?? "1 0 0 0";
|
const rotation = obj.rotation ?? "1 0 0 0";
|
||||||
const [ax, ay, az, angleDegrees] = rotation
|
const [ax, ay, az, angleDegrees] = rotation
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.map((s) => parseFloat(s));
|
.map((s: string) => parseFloat(s));
|
||||||
// Convert Torque3D coordinates to Three.js: XYZ -> YZX
|
|
||||||
const axis = new Vector3(ay, az, ax).normalize();
|
const axis = new Vector3(ay, az, ax).normalize();
|
||||||
const angleRadians = -angleDegrees * (Math.PI / 180);
|
const angleRadians = -angleDegrees * (Math.PI / 180);
|
||||||
return new Quaternion().setFromAxisAngle(axis, angleRadians);
|
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