/** * Implements RAM Calculation functionality. * * Uses acorn-walk to recursively walk through the AST, calculating RAM usage along the way. */ import * as walk from "acorn-walk"; import type * as acorn from "acorn"; import { extendAcornWalkForTypeScriptNodes } from "../ThirdParty/acorn-typescript-walk"; import { extend as extendAcornWalkForJsxNodes } from "acorn-jsx-walk"; import { RamCalculationErrorCode } from "./RamCalculationErrorCodes"; import { RamCosts, RamCostConstants } from "../Netscript/RamCostGenerator"; import type { Script } from "./Script"; import type { Node } from "../NetscriptJSEvaluator"; import type { ScriptFilePath } from "../Paths/ScriptFilePath"; import type { ServerName } from "../Types/strings"; import { roundToTwo } from "../utils/helpers/roundToTwo"; import { type AST, type FileTypeFeature, getFileType, getFileTypeFeature, getModuleScript, parseAST, ModuleResolutionError, } from "../utils/ScriptTransformer"; export interface RamUsageEntry { type: "ns" | "dom" | "fn" | "misc"; name: string; cost: number; } export type RamCalculationSuccess = { cost: number; entries: RamUsageEntry[]; errorCode?: never; errorMessage?: never; }; export type RamCalculationFailure = { cost?: never; entries?: never; errorCode: RamCalculationErrorCode; errorMessage?: string; }; export type RamCalculation = RamCalculationSuccess | RamCalculationFailure; // Extend acorn-walk to support TypeScript nodes. extendAcornWalkForTypeScriptNodes(walk.base); // Extend acorn-walk to support JSX nodes. extendAcornWalkForJsxNodes(walk.base); // These special strings are used to reference the presence of a given logical // construct within a user script. const specialReferenceIF = "__SPECIAL_referenceIf"; const specialReferenceFOR = "__SPECIAL_referenceFor"; const specialReferenceWHILE = "__SPECIAL_referenceWhile"; // This special string is used to signal that RAM is being overriden for a script. // It doesn't apply when importing that script. // The nature of the name guarantees it can never be conflated with a valid identifier. const specialReferenceRAM = ".^SPECIAL_ramOverride"; // The global scope of a script is registered under this key during parsing. const memCheckGlobalKey = ".__GLOBAL__"; /** Function for getting a function's ram cost, either from the ramcost function (singularity) or the static cost */ function getNumericCost(cost: number | (() => number)): number { return typeof cost === "function" ? cost() : cost; } /** * Parses code into an AST and walks through it recursively to calculate * RAM usage. Also accounts for imported modules. * @param ast - AST of the code being parsed * @param scriptName - The name of the script that ram needs to be added to * @param server - Servername of the scripts for Error Message * @param fileTypeFeature * @param otherScripts - All other scripts on the server. Used to account for imported scripts * */ function parseOnlyRamCalculate( ast: AST, scriptName: ScriptFilePath, server: ServerName, fileTypeFeature: FileTypeFeature, otherScripts: Map, ): RamCalculation { /** * Maps dependent identifiers to their dependencies. * * The initial identifier is .__GLOBAL__. * It depends on all the functions declared in the module, all the global scopes * of its imports, and any identifiers referenced in this global scope. Each * function depends on all the identifiers referenced internally. * We walk the dependency graph to calculate RAM usage, given that some identifiers * reference Netscript functions which have a RAM cost. */ let dependencyMap: Record> = {}; // Scripts we've parsed. const completedParses = new Set(); // Scripts we've discovered that need to be parsed. const parseQueue: ScriptFilePath[] = []; // Parses a chunk of code with a given module name, and updates parseQueue and dependencyMap. function parseCode(ast: AST, moduleName: ScriptFilePath, fileTypeFeatureOfModule: FileTypeFeature): void { const result = parseOnlyCalculateDeps(ast, moduleName, fileTypeFeatureOfModule, otherScripts); completedParses.add(moduleName); // Add any additional modules to the parse queue; for (const additionalModule of result.additionalModules) { if (!completedParses.has(additionalModule) && !parseQueue.includes(additionalModule)) { parseQueue.push(additionalModule); } } // Splice all the references in dependencyMap = Object.assign(dependencyMap, result.dependencyMap); } // Parse the initial module, which is the "main" script that is being run const initialModule = scriptName; parseCode(ast, initialModule, fileTypeFeature); // Process additional modules, which occurs if the "main" script has any imports while (parseQueue.length > 0) { const nextModule = parseQueue.shift(); if (nextModule === undefined) { throw new Error("nextModule should not be undefined"); } if (nextModule.startsWith("https://") || nextModule.startsWith("http://")) { continue; } const script = otherScripts.get(nextModule); if (!script) { return { errorCode: RamCalculationErrorCode.ImportError, errorMessage: `"${nextModule}" does not exist on server: ${server}`, }; } const scriptFileType = getFileType(script.filename); let moduleAST; try { moduleAST = parseAST(script.code, scriptFileType); } catch (error) { return { errorCode: RamCalculationErrorCode.ImportError, errorMessage: `Cannot parse module: ${nextModule}. Filename: ${script.filename}. Reason: ${ error instanceof Error ? error.message : String(error) }.`, }; } parseCode(moduleAST, nextModule, getFileTypeFeature(scriptFileType)); } // Finally, walk the reference map and generate a ram cost. The initial set of keys to scan // are those that start with the name of the main script. let ram: number = RamCostConstants.Base; const detailedCosts: RamUsageEntry[] = [{ type: "misc", name: "baseCost", cost: RamCostConstants.Base }]; const unresolvedRefs = Object.keys(dependencyMap).filter((s) => s.startsWith(initialModule)); const resolvedRefs = new Set(); const loadedFns: Record = {}; while (unresolvedRefs.length > 0) { const ref = unresolvedRefs.shift(); if (ref === undefined) { throw new Error("ref should not be undefined"); } if (ref.endsWith(specialReferenceRAM)) { if (ref !== initialModule + specialReferenceRAM) { // All RAM override tokens that *aren't* for the main module should be discarded. continue; } // This is a RAM override for the main module. We can end ram calculation immediately. const [first] = dependencyMap[ref]; const override = Number(first); return { cost: override, entries: [{ type: "misc", name: "override", cost: override }] }; } // Check if this is one of the special keys, and add the appropriate ram cost if so. if (ref === "hacknet" && !resolvedRefs.has("hacknet")) { ram += RamCostConstants.HacknetNodes; detailedCosts.push({ type: "ns", name: "hacknet", cost: RamCostConstants.HacknetNodes }); } if (ref === "document" && !resolvedRefs.has("document")) { ram += RamCostConstants.Dom; detailedCosts.push({ type: "dom", name: "document", cost: RamCostConstants.Dom }); } if (ref === "window" && !resolvedRefs.has("window")) { ram += RamCostConstants.Dom; detailedCosts.push({ type: "dom", name: "window", cost: RamCostConstants.Dom }); } resolvedRefs.add(ref); if (ref.endsWith(".*")) { // A prefix reference. We need to find all matching identifiers. const prefix = ref.slice(0, ref.length - 2); for (const ident of Object.keys(dependencyMap).filter((k) => k.startsWith(prefix))) { for (const dep of dependencyMap[ident] || []) { if (!resolvedRefs.has(dep)) { unresolvedRefs.push(dep); } } } } else { // An exact reference. Add all dependencies of this ref. for (const dep of dependencyMap[ref] || []) { if (!resolvedRefs.has(dep)) { unresolvedRefs.push(dep); } } } // Check if this identifier is a function in the workerScript environment. // If it is, then we need to get its RAM cost. try { // Only count each function once if (loadedFns[ref]) { continue; } loadedFns[ref] = true; // This accounts for namespaces (Bladeburner, CodingContract, etc.) const findFunc = ( prefix: string, obj: object, ref: string, ): { func: (() => number) | number; refDetail: string } | undefined => { if (!obj) { return; } const elem = Object.entries(obj).find(([key]) => key === ref); if (elem !== undefined && (typeof elem[1] === "function" || typeof elem[1] === "number")) { return { func: elem[1] as (() => number) | number, refDetail: `${prefix}${ref}` }; } for (const [key, value] of Object.entries(obj)) { const found = findFunc(`${key}.`, value as object, ref); if (found) { return found; } } return undefined; }; const details = findFunc("", RamCosts, ref); const fnRam = getNumericCost(details?.func ?? 0); ram += fnRam; detailedCosts.push({ type: "fn", name: details?.refDetail ?? "", cost: fnRam }); } catch (error) { console.error(error); continue; } } if (ram > RamCostConstants.Max) { ram = RamCostConstants.Max; detailedCosts.push({ type: "misc", name: "Max Ram Cap", cost: RamCostConstants.Max }); } return { cost: ram, entries: detailedCosts.filter((e) => e.cost > 0) }; } export function checkInfiniteLoop(ast: AST, code: string): number[] { function nodeHasTrueTest(node: acorn.Node): boolean { return node.type === "Literal" && "raw" in node && (node.raw === "true" || node.raw === "1"); } function hasAwait(ast: acorn.Node): boolean { let hasAwait = false; walk.recursive( ast, {}, { AwaitExpression: () => { hasAwait = true; }, }, ); return hasAwait; } const possibleLines: number[] = []; walk.recursive( ast as acorn.Node, // Pretend that ast is an acorn node {}, { WhileStatement: (node: Node, st: unknown, walkDeeper: walk.WalkerCallback) => { const previousLines = code.slice(0, node.start).trimEnd().split("\n"); const lineNumber = previousLines.length + 1; if (previousLines[previousLines.length - 1].match(/^\s*\/\/\s*@ignore-infinite/)) { return; } if (nodeHasTrueTest(node.test) && !hasAwait(node)) { possibleLines.push(lineNumber); } else { node.body && walkDeeper(node.body, st); } }, }, ); return possibleLines; } interface ParseDepsResult { dependencyMap: Record | undefined>; additionalModules: ScriptFilePath[]; } /** * Helper function that parses a single script. It returns a map of all dependencies, * which are items in the code's AST that potentially need to be evaluated * for RAM usage calculations. It also returns an array of additional modules * that need to be parsed (i.e. are 'import'ed scripts). */ function parseOnlyCalculateDeps( ast: AST, currentModule: ScriptFilePath, fileTypeFeature: FileTypeFeature, otherScripts: Map, ): ParseDepsResult { // Everything from the global scope goes in ".". Everything else goes in ".function", where only // the outermost layer of functions counts. const globalKey = currentModule + memCheckGlobalKey; const dependencyMap: Record | undefined> = {}; dependencyMap[globalKey] = new Set(); // If we reference this internal name, we're really referencing that external name. // Filled when we import names from other modules. const internalToExternal: Record = {}; const additionalModules: ScriptFilePath[] = []; // References get added pessimistically. They are added for thisModule.name, name, and for // any aliases. function addRef(key: string, name: string, module = currentModule): void { const s = dependencyMap[key] || (dependencyMap[key] = new Set()); const external = internalToExternal[name]; if (external !== undefined) { s.add(external); } s.add(module + "." + name); s.add(name); // For builtins like hack. } //A list of identifiers that resolve to "native Javascript code" const objectPrototypeProperties = Object.getOwnPropertyNames(Object.prototype); interface State { key: string; } function checkRamOverride(node: Node) { // To trigger a syntactic RAM override, the first statement must be a call // to ns.ramOverride() (or something that looks similar). if (!node.body || !node.body.length) return; const statement = node.body[0]; if (statement.type !== "ExpressionStatement") return; const expr = statement.expression; if (expr.type !== "CallExpression") return; if (!expr.arguments || expr.arguments.length !== 1) return; function findIdentifier(node: Node): Node { for (;;) { // Find the identifier node attached to the call switch (node.type) { case "ParenthesizedExpression": case "ChainExpression": node = node.expression; break; case "MemberExpression": node = node.property; break; default: return node; } } } const idNode = findIdentifier(expr.callee); if (idNode.type !== "Identifier" || idNode.name !== "ramOverride") return; // For the time being, we only handle simple literals for the argument. // If needed, this could be extended to simple constant expressions. const literal = expr.arguments[0]; if (literal.type !== "Literal") return; const value = literal.value; if (typeof value !== "number") return; // Finally, we know the syntax checks out for applying the RAM override. // But the value might be illegal. if (!isFinite(value) || value < RamCostConstants.Base) return; // This is an unusual arrangement; the "function name" here is our special // case, and it is "depending on" the stringified value of our ram override // (which is not any kind of identifier). dependencyMap[currentModule + specialReferenceRAM] = new Set([roundToTwo(value).toString()]); } // If we discover a dependency identifier, state.key is the dependent identifier. // walkDeeper is for doing recursive walks of expressions in composites that we handle. function commonVisitors(): walk.RecursiveVisitors { return { Identifier: (node: Node, st: State) => { if (objectPrototypeProperties.includes(node.name)) { return; } addRef(st.key, node.name); }, WhileStatement: (node: Node, st: State, walkDeeper: walk.WalkerCallback) => { addRef(st.key, specialReferenceWHILE); node.test && walkDeeper(node.test, st); node.body && walkDeeper(node.body, st); }, DoWhileStatement: (node: Node, st: State, walkDeeper: walk.WalkerCallback) => { addRef(st.key, specialReferenceWHILE); node.test && walkDeeper(node.test, st); node.body && walkDeeper(node.body, st); }, ForStatement: (node: Node, st: State, walkDeeper: walk.WalkerCallback) => { addRef(st.key, specialReferenceFOR); node.init && walkDeeper(node.init, st); node.test && walkDeeper(node.test, st); node.update && walkDeeper(node.update, st); node.body && walkDeeper(node.body, st); }, IfStatement: (node: Node, st: State, walkDeeper: walk.WalkerCallback) => { addRef(st.key, specialReferenceIF); node.test && walkDeeper(node.test, st); node.consequent && walkDeeper(node.consequent, st); node.alternate && walkDeeper(node.alternate, st); }, MemberExpression: (node: Node, st: State, walkDeeper: walk.WalkerCallback) => { node.object && walkDeeper(node.object, st); node.property && walkDeeper(node.property, st); }, }; } walk.recursive( ast as acorn.Node, // Pretend that ast is an acorn node { key: globalKey }, Object.assign( { ImportDeclaration: (node: Node, st: State) => { const rawImportModuleName = node.source.value; // Skip these modules. They are popular path aliases of NetscriptDefinitions.d.ts. if (fileTypeFeature.isTypeScript && (rawImportModuleName === "@nsdefs" || rawImportModuleName === "@ns")) { return; } const importModuleName = getModuleScript(rawImportModuleName, currentModule, otherScripts).filename; additionalModules.push(importModuleName); // This module's global scope refers to that module's global scope, no matter how we // import it. const set = dependencyMap[st.key]; if (set === undefined) throw new Error("set should not be undefined"); set.add(importModuleName + memCheckGlobalKey); for (let i = 0; i < node.specifiers.length; ++i) { const spec = node.specifiers[i]; if (spec.imported !== undefined && spec.local !== undefined) { // We depend on specific things. internalToExternal[spec.local.name] = importModuleName + "." + spec.imported.name; } else { // We depend on everything. const set = dependencyMap[st.key]; if (set === undefined) throw new Error("set should not be undefined"); set.add(importModuleName + ".*"); } } }, FunctionDeclaration: (node: Node) => { if (node.id?.name === "main") { checkRamOverride(node.body); } // node.id will be null when using 'export default'. Add a module name indicating the default export. const key = currentModule + "." + (node.id === null ? "__SPECIAL_DEFAULT_EXPORT__" : node.id.name); walk.recursive(node, { key: key }, commonVisitors()); }, ExportNamedDeclaration: (node: Node, st: State, walkDeeper: walk.WalkerCallback) => { if (node.declaration !== null) { // if this is true, the statement is not a named export, but rather a exported function/variable walkDeeper(node.declaration, st); return; } for (const specifier of node.specifiers) { const exportedDepName = currentModule + "." + specifier.exported.name; if (node.source !== null) { // if this is true, we are re-exporting something addRef(exportedDepName, specifier.local.name, node.source.value); additionalModules.push(node.source.value); } else if (specifier.exported.name !== specifier.local.name) { // this makes sure we are not refering to ourselves // if this is not true, we don't need to add anything addRef(exportedDepName, specifier.local.name); } } }, }, commonVisitors(), ), ); return { dependencyMap: dependencyMap, additionalModules: additionalModules }; } /** * Calculate RAM usage of a script * * @param input - Code's AST or code of the script * @param scriptName - The script's name. Used to resolve relative paths * @param server - Servername of the scripts for Error Message * @param otherScripts - Other scripts on the server * @returns */ export function calculateRamUsage( input: AST | string, scriptName: ScriptFilePath, server: ServerName, otherScripts: Map, ): RamCalculation { try { const fileType = getFileType(scriptName); const ast = typeof input === "string" ? parseAST(input, fileType) : input; return parseOnlyRamCalculate(ast, scriptName, server, getFileTypeFeature(fileType), otherScripts); } catch (error) { return { errorCode: error instanceof ModuleResolutionError ? RamCalculationErrorCode.ImportError : RamCalculationErrorCode.SyntaxError, errorMessage: error instanceof Error ? error.message : String(error), }; } }