Last active
February 15, 2026 16:32
-
-
Save nommiin/583c8b79fb4201dbb6883af2a1c57eb9 to your computer and use it in GitHub Desktop.
setup.ts - A simple bun.sh script to setup a GMRT project for TypeScript usage
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| setup.ts - v0.1 | |
| last updated: 2/15/26 @ 10:22am | |
| developed by Nommiin | |
| usage: | |
| 1. save as "setup.ts" to your project's directory (next to the .yyp file) | |
| 2. run the script with bun (see https://bun.sh/ for installation) | |
| > bun run setup | |
| 3. for each .js file in your project, create a neighboring .ts file as needed | |
| 4. run your project in the editor | |
| - if you confirmed "yes" to changing local_settings.json, then your project will automatically run tsc via bun at build-time each time | |
| - if you chose "no", you will need to point gamemaker toward the "buildgraph.xml" file in your project's root | |
| notes: | |
| - if you no longer want to use typescript, be sure to reset the "custom build graph file" option in your GMRT preferences | |
| - a tsconfig.json file is made in the project root which is used for tsc invocation, feel free to adjust as needed | |
| - "gamemaker.d.ts" will also be created based off your latest legacy runtime install | |
| */ | |
| if (!process.versions.bun) { | |
| console.error(`! script must be ran using bun`); | |
| process.exit(1); | |
| } | |
| import { Glob } from "bun"; | |
| import { join } from "node:path"; | |
| import { existsSync } from "node:fs"; | |
| const cwd = require("node:process").cwd(); | |
| const username = require("node:os").userInfo().username; | |
| class Version { | |
| public readonly Major: number; | |
| public readonly Minor: number; | |
| public readonly Build: number; | |
| public readonly Revision: number; | |
| constructor(major: number, minor: number, build: number, revision: number) { | |
| this.Major = major; | |
| this.Minor = minor; | |
| this.Build = build; | |
| this.Revision = revision; | |
| } | |
| public static Parse(input: string): Version { | |
| const parts = input.split(".").map((e, i) => { | |
| const int = parseInt(e); | |
| if (Number.isNaN(int)) { | |
| throw new Error(`could not parse version string, expected number at position ${i + 1}: '${input}'`); | |
| } | |
| return int; | |
| }) as [number, number, number, number]; | |
| if (parts.length != 4) { | |
| throw new Error(`could not parse version string, expected 4 parts (got ${parts.length})`); | |
| } | |
| return new Version(...parts); | |
| } | |
| // based on https://learn.microsoft.com/en-us/dotnet/api/system.version.compareto | |
| public CompareTo(value: Version): number { | |
| if (this == value) return 0; | |
| if (this.Major == value.Major) { | |
| if (this.Minor == value.Minor) { | |
| if (this.Build == value.Build) { | |
| if (this.Revision == value.Revision) return 0; | |
| if (this.Revision <= value.Revision) return -1; | |
| return 1; | |
| } else { | |
| if (this.Build <= value.Build) return -1; | |
| return 1; | |
| } | |
| } else { | |
| if (this.Minor <= value.Minor) return -1; | |
| return 1; | |
| } | |
| } else { | |
| if (this.Major <= value.Major) return -1; | |
| } | |
| return 1; | |
| } | |
| } | |
| enum GameMaker { | |
| NONE = -1, | |
| STABLE, | |
| BETA | |
| } | |
| function get_path(type: GameMaker): string { | |
| return (type == GameMaker.BETA ? "-Beta" : ""); | |
| } | |
| function get_login(data: {login: string, userID: string}): string { | |
| const login = data.login; | |
| return login.slice(0, login.indexOf("@")) + "_" + data.userID; | |
| } | |
| const known = new Map<string, string[]>([ | |
| ["Asset", [ | |
| "GMObject", | |
| "GMSprite", | |
| "GMScript", | |
| "GMFont", | |
| "GMAnimCurve", | |
| "GMSound", | |
| "GMTileSet", | |
| "GMPath", | |
| "GMRoom", | |
| "GMAudioGroup", | |
| "GMShader", | |
| "GMParticleSystem", | |
| "GMSequence", | |
| "GMTimeline", | |
| "Any" | |
| ]], | |
| ["Id", [ | |
| "Sound", | |
| "Instance", | |
| "DsMap", | |
| "BackgroundElement", | |
| "Socket", | |
| "Buffer", | |
| "ExternalCall", | |
| "PhysicsIndex", | |
| "PhysicsParticleGroup", | |
| "Layer", | |
| "TileElementId", | |
| "ParticleElement", | |
| "ParticleSystem", | |
| "Surface", | |
| "Camera" | |
| ]], | |
| ["Constant", [ | |
| ]] | |
| ]); | |
| function get_type(types: string, is_code: boolean = true): string { | |
| let out: string[] = []; | |
| for(const type of types.split(",").flatMap(e => e.split("|")).map(e => e.trim())) { | |
| switch (type) { | |
| case "Real": | |
| case "Rela": out.push("number"); break; | |
| case "String": out.push("string"); break; | |
| case "Bool": out.push("boolean"); break; | |
| case "Struct": out.push("Record<string, unknown>"); break; | |
| case "Array": out.push("any[]"); break; | |
| case "Array[Real]": out.push("number[]"); break; | |
| case "Array[String]": out.push("string[]"); break; | |
| case "Array[Bool]": out.push("boolean[]"); break; | |
| case "Array[Id.Instance]": out.push("Id.Instance[]"); break; | |
| case "Array[Id.Surface]": out.push("Id.Surface[]"); break; | |
| case "Array[Id.Camera]": out.push("Id.Camera[]"); break; | |
| case "Array[Asset.GMObject]": out.push("Asset.GMObject[]"); break; | |
| case "Undefined": out.push("undefined"); break; | |
| case "Asset": out.push("Asset.Any"); break; | |
| case "ArgumentIdentity": | |
| case "Any": out.push("any"); break; | |
| case "Id.Instance": | |
| case "Function": out.push(type); break; | |
| default: { | |
| const sep = type.indexOf("."); | |
| if (sep > 0) { | |
| const prefix = type.slice(0, sep); | |
| if (known.get(prefix)?.includes(type.slice(sep + 1))) { | |
| out.push(type); | |
| break; | |
| } | |
| } | |
| out.push(is_code ? `/*${type}*/ any` : type); | |
| break; | |
| } | |
| } | |
| } | |
| return (out.length == 0 ? "any" : out.join(" | ")); | |
| } | |
| interface FunctionDefinition { | |
| Name: string; | |
| Deprecated: boolean; | |
| ReturnType: string; | |
| Pure: boolean; | |
| Description?: string; | |
| Parameters: ParameterDefinition[]; | |
| } | |
| interface ParameterDefinition { | |
| Name: string; | |
| Type: string; | |
| Optional: boolean; | |
| Description?: string; | |
| } | |
| interface ConstantDefinition { | |
| Name: string; | |
| Class?: string; | |
| Type: string; | |
| Deprecated?: boolean; | |
| Description?: string; | |
| } | |
| interface VariableDefinition { | |
| Name: string; | |
| Type: string; | |
| Deprecated?: boolean; | |
| Get?: boolean; | |
| Set?: boolean; | |
| Instance: boolean; | |
| Description?: string; | |
| } | |
| async function write_typedecl(input: string, output: string) { | |
| // parse functions | |
| let function_list: FunctionDefinition[] = []; | |
| let function_current: FunctionDefinition | undefined = undefined; | |
| (new HTMLRewriter() | |
| .on("Function", { | |
| element(e) { | |
| let name = e.getAttribute("Name"); | |
| if (!name) { | |
| return; | |
| } else { | |
| let reserved = false; | |
| switch (name) { | |
| case "typeof": reserved = true; break; | |
| case "instanceof": reserved = true; break; | |
| } | |
| if (reserved) { | |
| name = `__${name}`; | |
| } | |
| } | |
| if (function_current) { | |
| function_list.push(function_current); | |
| function_current = undefined; | |
| } | |
| function_current = { | |
| Name: name, | |
| Deprecated: (e.getAttribute("Deprecated") ?? "false") == "true", | |
| ReturnType: e.getAttribute("ReturnType") ?? "any", | |
| Pure: (e.getAttribute("Pure") ?? "true") == "true", | |
| Parameters: [] | |
| } | |
| }, | |
| }).on("Description", { | |
| text(e) { | |
| const text = e.text.trim(); | |
| if (text.length > 0 && function_current) { | |
| function_current.Description = text.split("\n").map(e => e.trim()).filter(e => e.length > 0).join("\n"); | |
| } | |
| } | |
| }).on("Parameter", { | |
| element(e) { | |
| let name = e.getAttribute("Name"); | |
| if (!name || !function_current) { | |
| return; | |
| } else { | |
| let reserved = false; | |
| switch (name) { | |
| case "function": reserved = true; break; | |
| case "default": reserved = true; break; | |
| case "float": reserved = true; break; | |
| case "byte": reserved = true; break; | |
| case "string": reserved = true; break; | |
| case "...": name = "...args"; break; | |
| case "instance_id/global": name = "instance_id_or_global"; break; | |
| default: { | |
| const optional = name.indexOf(" (optional)") | |
| if (optional > 0) { | |
| name = name.slice(0, optional); | |
| } | |
| break; | |
| } | |
| } | |
| if (reserved) { | |
| name = `__${name}`; | |
| } | |
| } | |
| function_current.Parameters.push({ | |
| Name: name.replaceAll(" ", "_"), | |
| Type: e.getAttribute("Type") ?? "any", | |
| Optional: (name.startsWith("...") ? false : ((e.getAttribute("Optional") ?? "false") == "true")) | |
| }); | |
| }, | |
| text(e) { | |
| const text = e.text.trim(); | |
| if (text.length > 0 && function_current) { | |
| const last = function_current.Parameters.at(-1); | |
| if (last) { | |
| last.Description = text; | |
| } | |
| } | |
| } | |
| }) | |
| ).transform(input); | |
| if (function_current) function_list.push(function_current); | |
| console.log(`- parsed ${function_list.length} function definitions`); | |
| // parse constants | |
| let constant_list: ConstantDefinition[] = []; | |
| let constant_current: ConstantDefinition | undefined = undefined; | |
| (new HTMLRewriter() | |
| .on("Constant", { | |
| element(e) { | |
| let name = e.getAttribute("Name"); | |
| if (!name) { | |
| return; | |
| } | |
| if (constant_current) { | |
| constant_list.push(constant_current); | |
| constant_current = undefined; | |
| } | |
| constant_current = { | |
| Name: name, | |
| Class: e.getAttribute("Class") ?? undefined, | |
| Type: e.getAttribute("Type") ?? "Any", | |
| Deprecated: (e.getAttribute("Deprecated") ?? false) == "true" | |
| } | |
| }, | |
| text(e) { | |
| const text = e.text.trim(); | |
| if (text.length > 0 && constant_current) { | |
| constant_current.Description = text.split("\n").map(e => e.trim()).filter(e => e.length > 0).join("\n"); | |
| } | |
| } | |
| }) | |
| ).transform(input); | |
| if (constant_current) constant_list.push(constant_current); | |
| console.log(`- parsed ${constant_list.length} constant defintions`); | |
| // parse globals | |
| let variable_list: VariableDefinition[] = []; | |
| let variable_current: VariableDefinition | undefined = undefined; | |
| (new HTMLRewriter() | |
| .on("Variable", { | |
| element(e) { | |
| let name = e.getAttribute("Name"); | |
| if (!name) { | |
| return; | |
| } | |
| if (variable_current) { | |
| variable_list.push(variable_current); | |
| variable_current = undefined; | |
| } | |
| variable_current = { | |
| Name: name, | |
| Type: e.getAttribute("Type") ?? "Any", | |
| Deprecated: (e.getAttribute("Deprecated") ?? false) == "true", | |
| Get: (e.getAttribute("Get") ?? false) == "true", | |
| Set: (e.getAttribute("Set") ?? false) == "true", | |
| Instance: (e.getAttribute("Instance") ?? false) == "true" | |
| } | |
| }, | |
| text(e) { | |
| const text = e.text.trim(); | |
| if (text.length > 0 && variable_current) { | |
| variable_current.Description = text.split("\n").map(e => e.trim()).filter(e => e.length > 0).join("\n"); | |
| } | |
| } | |
| }) | |
| ).transform(input); | |
| if (variable_current) variable_list.push(variable_current); | |
| console.log(`- parsed ${variable_list.length} variable defintions`); | |
| // EXPERIMENTAL: merge classes with known | |
| const classes = [...new Set(constant_list.map(e => e.Class).filter(e => e))] as string[]; | |
| const known_classes = known.get("Constant") as string[]; | |
| known.set("Constant", [...known_classes, ...classes]); | |
| const decl: string[] = [ | |
| "// generated using setup.ts by nommiin\n", | |
| "/* Namespaces */" | |
| ]; | |
| for(const [ns, types] of known) { | |
| decl.push(`declare namespace ${ns} {`); | |
| for(const type of types) { | |
| decl.push(` type ${type} = number;`); | |
| } | |
| decl.push(`}\n`); | |
| } | |
| decl.push("/* Functions */"); | |
| for(const func of function_list) { | |
| // these functions have odd signatures, so i'm skipping them for now | |
| switch (func.Name) { | |
| case "vertex_float2": | |
| case "vertex_float3": | |
| case "vertex_float4": | |
| case "vertex_ubyte4": | |
| case "struct_set_from_hash": | |
| case "analytics_event_ext": continue; | |
| } | |
| let line = `declare function ${func.Name}(`, jsdoc: string[] = []; | |
| if (func.Description) { | |
| jsdoc.push(...func.Description.split("\n").map(e => e.trim()), ""); | |
| } | |
| if (func.Deprecated) jsdoc.push("@deprecated"); | |
| func.Parameters.forEach((parameter: ParameterDefinition, index: number) => { | |
| line += `${parameter.Name}${parameter.Optional ? "?" : ""}: ${get_type(parameter.Type)}`; | |
| if (parameter.Name.startsWith("...")) { | |
| line += "[]"; | |
| } | |
| jsdoc.push(`@param {${get_type(parameter.Type, false)}} ${parameter.Name} - ${parameter.Description}`) | |
| if (index < func.Parameters.length - 1) { | |
| line += ", "; | |
| } | |
| }); | |
| jsdoc.push(`@returns {${get_type(func.ReturnType, false)}}`); | |
| line += `): ${get_type(func.ReturnType)};\n`; | |
| decl.push(`/**\n${jsdoc.map(e => " * " + e).join("\n")}\n */\n${line}`); | |
| } | |
| decl.push("/* Constants */") | |
| for(const constant of constant_list) { | |
| switch (constant.Name) { | |
| case "true": | |
| case "false": | |
| case "self": | |
| case "undefined": | |
| case "NaN": continue; | |
| } | |
| let line = `declare const ${constant.Name}: `, jsdoc: string[] = []; | |
| if (constant.Description) { | |
| jsdoc.push(...constant.Description.split("\n").map(e => e.trim())); | |
| } | |
| if (constant.Deprecated) jsdoc.push("@deprecated"); | |
| if (constant.Class && classes.includes(constant.Class)) { | |
| line += `Constant.${constant.Class}`; | |
| } else { | |
| line += get_type(constant.Type); | |
| } | |
| if (jsdoc.length > 0) { | |
| line = `/**\n${jsdoc.map(e => " * " + e).join("\n")}\n */\n` + line; | |
| } | |
| decl.push(`${line};\n`); | |
| } | |
| decl.push("/* Global Variables */"); | |
| for(const variable of variable_list) { | |
| decl.push(`declare const ${variable.Name}: ${get_type(variable.Type)};`); | |
| } | |
| await Bun.write(output, decl.join("\n")); | |
| console.log(`$ wrote gamemaker type declaration file: "${output}"`); | |
| } | |
| async function rewrite_buildgraph(input: string, output: string) { | |
| let first = true; | |
| await Bun.write(output, new HTMLRewriter().on("Step", { | |
| element(e) { | |
| if (!first) { | |
| return; | |
| } | |
| e.append(`<Process Exe="bun" Arguments="run tsc --p tsconfig.json" WorkingDir="\${ProjectDir}" LogOutput="true" Verbose="true"/>`, {html: true}); | |
| first = false; | |
| } | |
| }).transform(input)); | |
| console.log(`$ wrote patched build graph file: "${output}"`); | |
| } | |
| try { | |
| const yyp = Bun.file(Array.from(new Glob("*.yyp").scanSync(".")).map((e: string) => join(cwd, e)).at(0) ?? ""); | |
| if (!await yyp.exists()) { | |
| throw new Error(`could not locate .yyp file, please run script in your project's root`); | |
| } | |
| // select GM version | |
| let type = GameMaker.NONE; | |
| while (type < GameMaker.STABLE || type > GameMaker.BETA) { | |
| const ask = parseInt(prompt(`\n? please select your target version:\n1.) GameMaker\n2.) GameMaker Beta (recommended)\n>`) ?? ""); | |
| if (!Number.isNaN(ask)) { | |
| type = ask - 1; | |
| } | |
| } | |
| // write gamemaker types from newest legacy runtime & assets | |
| const runtime_root = `C:\\ProgramData\\GameMakerStudio2${get_path(type)}\\Cache\\runtimes\\`; | |
| if (!existsSync(runtime_root)) { | |
| throw new Error(`could not locate runtime folder: ${runtime_root} (is GameMaker installed?)`); | |
| } | |
| // @ts-ignore | |
| let runtime_newest: {version: Version, path: string} = undefined; | |
| for(const runtime of Array.from(new Glob("runtime-*\\GmlSpec.xml").scanSync(runtime_root))) { | |
| const version = Version.Parse(runtime.slice(runtime.indexOf("-") + 1, runtime.indexOf("\\"))); | |
| if (runtime_newest == undefined || runtime_newest.version.CompareTo(version) < 0) { | |
| runtime_newest = {version, path: join(runtime_root, runtime)}; | |
| } | |
| } | |
| if (runtime_newest) { | |
| await write_typedecl(await Bun.file(runtime_newest.path).text(), join(cwd, "gamemaker.d.ts")); | |
| } else { | |
| throw new Error(`could not locate runtime installation: ${runtime_root} (is GameMaker installed?)`); | |
| } | |
| // choose a platform buildgraph | |
| const targets = join(`C:\\Users\\${username}\\gmpm${get_path(type).toLowerCase()}\\GMRT\\Release\\bin\\targets`); | |
| if (!existsSync(targets)) { | |
| throw new Error(`could not locate targets folder: ${targets} (is GMRT installed?)`); | |
| } | |
| const search = Array.from(new Glob("*.xml").scanSync(targets)).sort(); | |
| if (search.length == 0) { | |
| throw new Error(`could not locate any buildgraph files: ${targets} (is GMRT installed?)`); | |
| } | |
| const options = search.map((e: string, ind: number) => { | |
| return `${ind + 1}.) ${e.slice("buildgraph-".length, -(".xml".length))}${e.includes("win64-prod") ? " (recommended)" : ""}` | |
| }); | |
| let buildgraph = undefined; | |
| while (!buildgraph) { | |
| const ask = parseInt(prompt(`\n? please select your target platform:\n${options.join("\n")}\n>`) ?? ""); | |
| if (ask > 0 && ask <= options.length) { | |
| buildgraph = search[ask - 1]; | |
| } | |
| } | |
| // write updated buildgraph with tsc step | |
| await rewrite_buildgraph(await Bun.file(join(targets, buildgraph)).text(), join(cwd, "buildgraph.xml")); | |
| // create tsconfig tailored for GM | |
| const tsconfig = join(cwd, "tsconfig.json"); | |
| await Bun.write(tsconfig, JSON.stringify({ | |
| compilerOptions: { | |
| baseUrl: ".", | |
| module: "none", | |
| target: "es2020", | |
| checkJs: false, | |
| typeRoots: [ | |
| "./gamemaker.d.ts" | |
| ] | |
| }, | |
| include: [ | |
| "**/*.ts" | |
| ], | |
| exclude: [ | |
| "./setup.ts", | |
| "**/*.js" | |
| ] | |
| }, undefined, 4)); | |
| console.log(`$ wrote tsconfig.json file: "${tsconfig}"`); | |
| // check if local_settings.json should be overwritten or copied | |
| const ask_settings = confirm(`\n? to simplify building, this script will patch your "local_settings.json" file to point to "buildgraph.xml" in your project's root.\n(a copy of your settings will be saved as "local_settings.json.bak")\n\nif you would like to have these changes made, please confirm:\n>`); | |
| if (ask_settings) { | |
| const roaming = `C:\\Users\\${username}\\AppData\\Roaming\\GameMakerStudio2${get_path(type)}\\`; | |
| if (!existsSync(roaming)) { | |
| throw new Error(`could not locate gamemaker settings folder: ${roaming} (is GameMaker installed?)`); | |
| } | |
| const user = Bun.file(join(roaming, "um.json")); | |
| if (!await user.exists()) { | |
| throw new Error(`could not locate "um.json" file: ${user.name} (are you logged into GameMaker?)`); | |
| } | |
| const settings_file = join(roaming, get_login(await user.json()), "local_settings.json"), settings = Bun.file(settings_file); | |
| if (!await settings.exists()) { | |
| throw new Error(`could not locate "local_settings.json" file: ${settings.name} (are you logged into GameMaker?)`); | |
| } | |
| // read settings | |
| const settings_data = await settings.json(); | |
| // create backup | |
| const settings_backup = settings_file.slice(0, settings_file.lastIndexOf(".")) + ".json.bak"; | |
| if (!await Bun.file(settings_backup).exists()) { | |
| await Bun.write(settings_backup, JSON.stringify(settings_data, undefined, 4)); | |
| console.log(`$ wrote backup settings file: "${settings_backup}"`); | |
| } else { | |
| console.warn(`! backup of "local_settings.json" already exists, skipping backup creation`); | |
| } | |
| // write changes | |
| const existing_path = settings_data["machine.Cronus.cronus_build_graph_path"], new_path = "${project_dir}\\buildgraph.xml"; | |
| if (existing_path && existing_path != new_path) { | |
| console.warn(`! build graph path was previously set, this will be overwritten ("${existing_path}")`); | |
| } | |
| await Bun.write(settings_file, JSON.stringify({ | |
| ...settings_data, | |
| "machine.Cronus.cronus_build_graph_path": new_path, | |
| }, undefined, 4)); | |
| console.warn(`\n! remember that GMRT will now look for "buildgraph.xml" next to your yyp file to successfully build.\n either run this script for subsequent projects or clear the "custom build graph file" field within GameMaker to restore defaults\n`); | |
| console.log(`$ wrote patched settings file: "${settings_file}"`); | |
| } else { | |
| console.warn(`\n! please remember to set the "custom build graph file" setting within GameMaker to point to your "buildgraph.xml" file so that typescript compilation is performed\n`); | |
| } | |
| console.log(`- successfully setup typescript for "${yyp.name}", woohoo!`); | |
| } catch (e: any) { | |
| console.error(`\n! an exception was thrown: "${e?.message ?? e}"`); | |
| process.exit(1); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment