├── icons ├── fork.png ├── run.png ├── stop.png ├── clean.png └── run-and-fork.png ├── config.json ├── LICENSE ├── BuilderDrives.js ├── README.md ├── BuilderProjectProperties.js ├── BuilderOutput.js ├── BuilderOutputAside.js ├── BuilderPreferences.js ├── main.js └── BuilderCompile.js /icons/fork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YAL-GMEdit/builder/HEAD/icons/fork.png -------------------------------------------------------------------------------- /icons/run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YAL-GMEdit/builder/HEAD/icons/run.png -------------------------------------------------------------------------------- /icons/stop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YAL-GMEdit/builder/HEAD/icons/stop.png -------------------------------------------------------------------------------- /icons/clean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YAL-GMEdit/builder/HEAD/icons/clean.png -------------------------------------------------------------------------------- /icons/run-and-fork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YAL-GMEdit/builder/HEAD/icons/run-and-fork.png -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "builder", 3 | "scripts": [ 4 | "main.js", 5 | "build.js", 6 | "BuilderPreferences.js", 7 | "BuilderProjectProperties.js", 8 | "BuilderCompile.js", 9 | "BuilderDrives.js", 10 | "BuilderOutput.js", 11 | "BuilderOutputAside.js" 12 | ] 13 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 nommiin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /BuilderDrives.js: -------------------------------------------------------------------------------- 1 | class BuilderDrives { 2 | static file = new $gmedit["electron.ConfigFile"]("session", "builder-drives"); 3 | 4 | static add(path) { 5 | let raw = Builder.Command.execSync("wmic logicaldisk get caption").toString(); 6 | let lines = raw.replace(/\r/g, "").split("\n"); 7 | let takenLetters = {}; 8 | for (let line of lines) { 9 | let mt = /([A-Z]):/.exec(line); 10 | if (mt) takenLetters[mt[1]] = true; 11 | } 12 | 13 | let freeLetters = []; 14 | for (let i = "A".charCodeAt(); i <= "Z".charCodeAt(); i++) { 15 | let c = String.fromCharCode(i); 16 | if (!takenLetters[c]) freeLetters.push(c); 17 | } 18 | //console.log("Candidate letters:", freeLetters); 19 | if (freeLetters.length == 0) return null; 20 | 21 | let drive = freeLetters[0 | (Math.random() * freeLetters.length)]; 22 | try { 23 | Builder.Command.execSync(`subst ${drive}: "${path}"`); 24 | } catch (x) { 25 | BuilderOutput.main.write(`Failed to subst ${drive}: `, x); 26 | return null; 27 | } 28 | BuilderOutput.main.write(`Using Virtual Drive: ${drive}`); 29 | 30 | let conf = this.file; 31 | if (conf.sync()) conf.data = []; 32 | conf.data.push(drive); 33 | conf.flush(); 34 | 35 | return drive; 36 | } 37 | 38 | static remove(drive) { 39 | drive ??= Builder.Drive; 40 | if (drive == null) return; 41 | BuilderOutput.main.write(`Removing Virtual Drive: ${drive}`); 42 | Builder.Command.execSync(`subst /d ${drive}:`); 43 | 44 | let conf = this.file; 45 | if (conf.sync()) conf.data = []; 46 | let ind = conf.data.indexOf(drive); 47 | if (ind >= 0) { 48 | conf.data.splice(ind, 1); 49 | conf.flush(); 50 | } 51 | } 52 | 53 | static removeCurrent() { 54 | for (let drive of Builder.Drives) { 55 | this.remove(drive); 56 | } 57 | Builder.Drives.length = 0; 58 | Builder.Drive = ""; 59 | } 60 | 61 | static clean() { 62 | let conf = this.file; 63 | if (conf.sync()) conf.data = []; 64 | let done = []; 65 | for (let c of conf.data) { 66 | try { 67 | Builder.Command.execSync(`subst /d ${c}:`); 68 | done.push(c); 69 | } catch(e) {}; 70 | } 71 | conf.data = []; 72 | conf.flush(); 73 | Electron_Dialog.showMessageBox({type: "info", title: "Builder", message: `Finished cleaning virtual drives (${done.join(", ")}).`}); 74 | } 75 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # builder 2 | ![screenshot](https://i.imgur.com/vBhrrvR.png) 3 | 4 | # about 5 | builder was made to make [GMEdit](https://yellowafterlife.itch.io/gmedit) usable without the need of having GameMaker Studio 2 open in the background to compile projects. builder works by using your pre-existing settings made by GMS 2 to pass arguments into GMAssetCompiler and compile your project. builder was developed with faster development in mind, skipping most file generation and secondary applications being required to compile. builder even supports switching the runtime that you build your project with as well as compiling the project as it's already running! You can download a pre-packaged version of builder from [here!](https://github.com/nommiin/builder/releases) 6 | 7 | If you found the plugin useful, consider donating to help support development on [itch.io!](https://nommiiin.itch.io/builder) 8 | 9 | # usage 10 | 1. create a folder named "builder" inside of `%appdata%/AceGM/GMEdit/plugins/` on Windows, `/Users//Library/Application Support/AceGM/GMEdit/plugins` on macOS 11 | 2. clone this repo and copy all files into "builder" folder 12 | 3. launch GMEdit and open a project 13 | 4. open the main menu and select "Run Project" 14 | 5. optional: adjust what runtime to use in the "Preferences" menu 15 | 16 | # todo/goals 17 | * a "clean project" button 18 | * more compiler customization (worker threads, verbose output, etc) 19 | * in-editor progress bar, based on what GMAssetCompiler is doing 20 | * support GMS 1 projects? (unlikely) 21 | 22 | # help 23 | * **my game compiles but doesn't open:** make sure you are using the correct runtime, as the default runtime is likely to be the oldest runtime you have downloaded 24 | * **an error occurs in GMEdit but not in GMS 2:** again, this is likely an issue with the default runtime that builder selects. be sure to check and make sure that the runtime builder is using is the same as in GMS 2 25 | 26 | # thanks 27 | * [YellowAfterlife](https://twitter.com/YellowAfterlife) for accepting PRs that help the core functionality of this plugn, answering my endless questions, submitting PRs that help improve the plugin, and providing the screenshot used in the readme 28 | * [Sidorakh](https://github.com/sidorakh/) for testing and supporting the plugin 29 | * [Katie](https://twitter.com/347online) for encouraging me to add macOS support to the plugin 30 | -------------------------------------------------------------------------------- /BuilderProjectProperties.js: -------------------------------------------------------------------------------- 1 | class BuilderProjectProperties { 2 | static build(project, target) { 3 | const Preferences = $gmedit["ui.Preferences"]; 4 | let group = Preferences.addGroup(target, "builder Settings"); 5 | const defaultVersion = ""; 6 | 7 | // 8 | let ensureJSON = (isDefault) => { 9 | let properties = project.properties; 10 | let json = properties.builderSettings; 11 | if (isDefault && json == null) return null; 12 | if (json == null) json = properties.builderSettings = {}; 13 | return json; 14 | }; 15 | 16 | let json = project.properties.builderSettings; 17 | const ProjectProperties = $gmedit["ui.project.ProjectProperties"]; 18 | 19 | // 20 | let versions = [defaultVersion]; 21 | for (let [_, set] of Object.entries(BuilderPreferences.current.runtimeSettings)) { 22 | versions = versions.concat(set.runtimeList); 23 | } 24 | 25 | Preferences.addDropdown(group, 26 | "Version override", 27 | json?.runtimeVersion ?? defaultVersion, 28 | versions, 29 | (v) => { 30 | if (v == defaultVersion) v = null; 31 | let json = ensureJSON(v == null); 32 | if (json == null) return; 33 | json.runtimeVersion = v; 34 | ProjectProperties.save(project, project.properties); 35 | }); 36 | 37 | // 38 | let extraArgsField = Preferences.addInput(group, 39 | "Additional runner arguments", 40 | json?.forkArguments ?? "", 41 | (v) => { 42 | if (v == "") v = null; 43 | let json = ensureJSON(v == null); 44 | if (json == null) return; 45 | json.extraArguments = v; 46 | ProjectProperties.save(project, project.properties); 47 | }).querySelector("input"); 48 | 49 | // 50 | let forkArgsField = Preferences.addInput(group, 51 | "Fork arguments override", 52 | json?.forkArguments ?? "", 53 | (v) => { 54 | if (v == "") v = null; 55 | let json = ensureJSON(v == null); 56 | if (json == null) return; 57 | json.forkArguments = v; 58 | ProjectProperties.save(project, project.properties); 59 | }).querySelector("input"); 60 | forkArgsField.placeholder = BuilderPreferences.current.forkArguments; 61 | 62 | // 63 | let steamAppIdEl = Preferences.addInput(group, 64 | "Steam App ID override", 65 | json?.steamAppID ?? "", 66 | (v) => { 67 | v = parseInt(v); 68 | if (isNaN(v)) v = null; 69 | let json = ensureJSON(v == null); 70 | if (json == null) return; 71 | json.steamAppID = v; 72 | ProjectProperties.save(project, project.properties); 73 | }); 74 | steamAppIdEl.querySelector("input").placeholder = "0 to disable, blank to auto-detect"; 75 | } 76 | 77 | static ready() { 78 | GMEdit.on("projectPropertiesBuilt", (e) => { 79 | this.build(e.project, e.target); 80 | }); 81 | } 82 | } -------------------------------------------------------------------------------- /BuilderOutput.js: -------------------------------------------------------------------------------- 1 | class BuilderOutput { 2 | 3 | /** @type {BuilderOutput} */ 4 | static main = null; 5 | 6 | /** @type {BuilderOutput} */ 7 | static aside = null; 8 | 9 | static fileKind = (function() { 10 | const KCode = $gmedit["file.kind.KCode"]; 11 | function KBuilderOutput() { 12 | KCode.call(this); 13 | this.setChangedOnEdits = false; 14 | } 15 | KBuilderOutput.prototype = GMEdit.extend(KCode.prototype, {}); 16 | return new KBuilderOutput(); 17 | })(); 18 | 19 | constructor(title, aceEditor) { 20 | this.gmlFile = new $gmedit["gml.file.GmlFile"](title, null, BuilderOutput.fileKind, ""); 21 | this.aceSession = this.gmlFile.codeEditor.session; 22 | this.aceEditors = [aceEditor]; 23 | } 24 | 25 | clear(text = "") { 26 | this.aceSession.setValue(text); 27 | } 28 | 29 | write(text, addLineBreak = true) { 30 | let row = this.aceSession.getLength() - 1; 31 | let col; 32 | if (row < 0) { 33 | row = 0; 34 | col = this.aceSession.getLine(row).length; 35 | } else { 36 | col = this.aceSession.getLine(row).length; 37 | if (col > 0 && addLineBreak) { 38 | text = "\n" + text; 39 | } 40 | } 41 | let pos = { row: row, column: col }; 42 | this.aceSession.insert(pos, text); 43 | 44 | let undoManager = this.aceSession.getUndoManager(); 45 | if (undoManager) undoManager.markClean(); 46 | 47 | for (let aceEditor of this.aceEditors) { 48 | if (aceEditor.session.gmlFile != this.gmlFile) continue; 49 | let renderer = aceEditor.renderer; 50 | row = this.aceSession.getLength() - 1; 51 | let pos = renderer.$cursorLayer.getPixelPosition({row: row, column: 0}); 52 | let offset = pos.top; 53 | offset -= renderer.$size.scrollerHeight - renderer.lineHeight * 2; 54 | renderer.session.setScrollTop(offset); 55 | } 56 | } 57 | 58 | static open(isFork) { 59 | const prefs = BuilderPreferences.current; 60 | const forkAside = isFork && prefs.forkInSideView; 61 | const reuseTab = prefs.reuseTab; 62 | const lookFor = forkAside ? this.aside : this.main; 63 | 64 | const title = `${isFork && forkAside ? "Fork" : "Output"} (${Builder.GetTime()})`; 65 | 66 | if (lookFor && reuseTab) 67 | for (let tab of document.querySelectorAll(".chrome-tab")) { 68 | if (tab.gmlFile == lookFor.gmlFile) { 69 | tab.querySelector(".chrome-tab-title-text").innerText = title; 70 | if (forkAside) { 71 | BuilderOutputAside.show(lookFor); 72 | } else tab.click(); 73 | return lookFor; 74 | } 75 | } 76 | 77 | const GmlFile = $gmedit["gml.file.GmlFile"]; 78 | let output = new BuilderOutput(title, window.aceEditor); 79 | if (forkAside) { 80 | this.aside = output; 81 | let currentTab = GmlFile.current.tabEl; 82 | GmlFile.openTab(output.gmlFile); 83 | BuilderOutputAside.show(output); 84 | currentTab.click(); 85 | } else { 86 | this.main = output; 87 | GmlFile.openTab(output.gmlFile); 88 | } 89 | 90 | return output; 91 | } 92 | } -------------------------------------------------------------------------------- /BuilderOutputAside.js: -------------------------------------------------------------------------------- 1 | class BuilderOutputAside { 2 | 3 | /** @type {AceEditor} */ 4 | static aceEditor = null; 5 | 6 | static aceSession = null; 7 | 8 | /** @type {HTMLDivElement} */ 9 | static sizer = null; 10 | 11 | /** @type {GMEdit_Splitter} */ 12 | static splitter = null; 13 | 14 | /** @type {HTMLDivElement} */ 15 | static container = null; 16 | 17 | /** @type {HTMLDivElement} */ 18 | static parent = null; 19 | 20 | /** @type {BuilderOutput} */ 21 | static output = null; 22 | 23 | static editorID = "builder_fork"; 24 | 25 | static clearOnNextOpen = false; 26 | 27 | static prepare() { 28 | this.container = document.createElement("div"); 29 | this.container.classList.add("ace_container"); 30 | 31 | this.sizer = document.createElement("div"); 32 | this.sizer.setAttribute("splitter-element", "#" + this.editorID); 33 | this.sizer.setAttribute("splitter-lskey", "aside_width"); 34 | this.sizer.setAttribute("splitter-default-width", "" + (aceEditor.container.clientWidth >> 1)); 35 | this.sizer.classList.add("splitter-td"); 36 | 37 | let nextCont = document.createElement("div"); 38 | nextCont.classList.add("ace_container"); 39 | // .ace_container[editor] -> .ace_container[.ace_container[editor], splitter, .ace_container[aside_editor]]: 40 | let mainCont = aceEditor.container.parentElement; 41 | var mainChildren = []; 42 | for (let el of mainCont.children) mainChildren.push(el); 43 | for (let ch of mainChildren) { 44 | mainCont.removeChild(ch); 45 | nextCont.appendChild(ch); 46 | } 47 | mainCont.style.setProperty("flex-direction", "row"); 48 | mainCont.appendChild(nextCont); 49 | mainCont.appendChild(this.sizer); 50 | mainCont.appendChild(this.container); 51 | this.parent = mainCont; 52 | 53 | var textarea = document.createElement("textarea"); 54 | this.container.appendChild(textarea); 55 | this.aceEditor = GMEdit.aceTools.createEditor(textarea); 56 | 57 | this.container.id = this.editorID; 58 | this.splitter = new GMEdit_Splitter(this.sizer); 59 | 60 | this.aceEditor.commands.addCommand({ 61 | name: "exitPeekAside", 62 | bindKey: "Escape|Ctrl-W", 63 | exec: (e) => { 64 | for (let tab of $gmedit["ui.ChromeTabs"].element.querySelectorAll(".chrome-tab")) { 65 | if (tab.gmlFile != e.session.gmlFile) continue; 66 | BuilderOutputAside.hide(); 67 | /*if (!tab.classList.contains("chrome-tab-current")) { 68 | tab.querySelector(".chrome-tab-close").click(); 69 | }*/ 70 | break; 71 | } 72 | } 73 | }); 74 | } 75 | 76 | static emitResize() { 77 | var e = new CustomEvent("resize"); 78 | e.initEvent("resize"); 79 | window.dispatchEvent(e); 80 | } 81 | 82 | static onFileClose(e) { 83 | if (e.file == BuilderOutputAside.output?.gmlFile) { 84 | BuilderOutputAside.hide(); 85 | } 86 | } 87 | 88 | /** @param {BuilderOutput} output */ 89 | static show(output) { 90 | if (this.output == null) { 91 | GMEdit.on("fileClose", this.onFileClose); 92 | if (this.aceEditor != null) { 93 | this.parent.appendChild(this.sizer); 94 | this.parent.appendChild(this.container); 95 | } else this.prepare(); 96 | this.emitResize(); 97 | } else { 98 | if (this.clearOnNextOpen) { 99 | this.clearOnNextOpen = false; 100 | this.output.clear(); 101 | } 102 | } 103 | this.output = output; 104 | this.aceSession = GMEdit.aceTools.cloneSession(output.aceSession); 105 | this.aceEditor.setSession(this.aceSession); 106 | output.aceEditors.push(this.aceEditor); 107 | } 108 | 109 | static hide() { 110 | if (this.output == null) return; 111 | GMEdit.off("fileClose", this.onFileClose); 112 | this.parent.removeChild(this.sizer); 113 | this.parent.removeChild(this.container); 114 | this.output = null; 115 | this.aceSession = null; 116 | this.emitResize(); 117 | setTimeout(() => window.aceEditor.focus()); 118 | } 119 | } -------------------------------------------------------------------------------- /BuilderPreferences.js: -------------------------------------------------------------------------------- 1 | class BuilderPreferences { 2 | static path = Electron_App.getPath("userData") + "/GMEdit/config/Builder-preferences.json"; 3 | 4 | static current = { 5 | reuseTab: false, 6 | saveCompile: false, 7 | stopCompile: false, 8 | displayLine: true, 9 | forkArguments: "", 10 | forkInSideView: false, 11 | showRunAndFork: false, 12 | useVirtualDrives: false, 13 | cleanOnError: false, 14 | cleanAfterRun: false, 15 | runtimeSettings: { 16 | Stable: { 17 | location: process.env.ProgramData + "/GameMakerStudio2/Cache/runtimes/", 18 | runtimeList: [], 19 | selection: "" 20 | }, 21 | Beta: { 22 | location: process.env.ProgramData + "/GameMakerStudio2-Beta/Cache/runtimes/", 23 | runtimeList: [], 24 | selection: "" 25 | } 26 | } 27 | }; 28 | 29 | static save() { 30 | Electron_FS.writeFileSync(this.path, JSON.stringify(this.current, (k, v) => { 31 | return k == "runtimeList" ? undefined : v; 32 | }, " ")); 33 | } 34 | 35 | static load() { 36 | Object.assign(this.current, JSON.parse(Electron_FS.readFileSync(this.path))); 37 | } 38 | 39 | static init() { 40 | if (Electron_FS.existsSync(this.path)) { 41 | try { 42 | this.load(); 43 | let pref = this.current; 44 | if (pref.runtimeLocation != null) { 45 | // migrate legacy settings 46 | this.Preferences.runtimeSettings.Stable.location = this.Preferences.runtimeLocation; 47 | delete this.Preferences.runtimeLocation; 48 | this.Preferences.runtimeSettings.Stable.selection = this.Preferences.runtimeSelection; 49 | delete this.Preferences.runtimeSelection; 50 | delete this.Preferences.runtimeList; 51 | } 52 | } catch (x) { 53 | console.error("[Builder] Failed to load preferences:", x); 54 | } 55 | } else this.save(); 56 | } 57 | 58 | static element; 59 | 60 | static build() { 61 | const Preferences = $gmedit["ui.Preferences"]; 62 | let root = document.createElement("div"); 63 | this.element = root; 64 | const addSep = (out) => { 65 | let hr = document.createElement("hr"); 66 | out.appendChild(hr); 67 | } 68 | 69 | for (let [key, set] of Object.entries(this.current.runtimeSettings)) { 70 | let runtimeGroup = Preferences.addGroup(root, `Runtime Settings (${key})`); 71 | let element, label; 72 | 73 | element = Preferences.addInput(runtimeGroup, "Runtime Location", set.location, (value) => { 74 | set.location = value; 75 | BuilderPreferences.save(); 76 | }); 77 | let runtimeLocationInput = element.querySelector("input"); 78 | label = element.querySelector("label"); 79 | label.appendChild(document.createTextNode(" (")); 80 | label.appendChild(Preferences.createFuncAnchor("Reset", function() { 81 | switch (key) { 82 | case "Stable": set.location = process.env.ProgramData + "/GameMakerStudio2/Cache/runtimes/"; break; 83 | case "Beta": set.location = process.env.ProgramData + "/GameMakerStudio2-Beta/Cache/runtimes/"; break; 84 | default: return; 85 | } 86 | runtimeLocationInput.value = set.location; 87 | BuilderPreferences.save(); 88 | })); 89 | label.appendChild(document.createTextNode(")")); 90 | 91 | element = Preferences.addDropdown(runtimeGroup, "Current Runtime", set.selection, set.runtimeList, (value) => { 92 | set.selection = value; 93 | BuilderPreferences.save(); 94 | }); 95 | let runtimeListSelect = element.querySelector("select"); 96 | label = element.querySelector("label"); 97 | label.appendChild(document.createTextNode(" (")); 98 | label.appendChild(Preferences.createFuncAnchor("Rescan", function() { 99 | runtimeListSelect.innerHTML = ""; 100 | for (let rt of Builder.GetRuntimes(set.location)) { 101 | let option = document.createElement("option"); 102 | option.innerHTML = option.value = rt; 103 | runtimeListSelect.appendChild(option); 104 | } 105 | runtimeListSelect.value = set.selection; 106 | BuilderPreferences.save(); 107 | })); 108 | label.appendChild(document.createTextNode(")")); 109 | } 110 | 111 | let settingsGroup = Preferences.addGroup(root, "Builder Settings"); 112 | if (Builder.Platform == "win") { 113 | Preferences.addCheckbox(settingsGroup, 'Use virtual drives', this.current.useVirtualDrives, (value) => { 114 | this.current.useVirtualDrives = value; 115 | this.save(); 116 | }); 117 | Preferences.addButton(settingsGroup, "Clean virtual drives", () => { 118 | BuilderDrives.clean(); 119 | }); 120 | addSep(settingsGroup); 121 | } 122 | Preferences.addCheckbox(settingsGroup, 'Show "Run & Fork" in main menu', this.current.showRunAndFork, (value) => { 123 | this.current.showRunAndFork = value; 124 | Builder.MenuItems.runAndFork.visible = value; 125 | this.save(); 126 | }); 127 | Preferences.addInput(settingsGroup, "Fork Arguments", this.current.forkArguments, (value) => { 128 | this.current.forkArguments = value; 129 | this.save(); 130 | }); 131 | Preferences.addCheckbox(settingsGroup, 'Show "fork" log in a side view', this.current.forkInSideView, (value) => { 132 | this.current.forkInSideView = value; 133 | this.save(); 134 | }); 135 | addSep(settingsGroup); 136 | 137 | Preferences.addCheckbox(settingsGroup, "Reuse output tab", this.current.reuseTab, (value) => { 138 | this.current.reuseTab = value; 139 | this.save(); 140 | }); 141 | Preferences.addCheckbox(settingsGroup, "Save all tabs upon compile", this.current.saveCompile, (value) => { 142 | this.current.saveCompile = value; 143 | this.save(); 144 | }); 145 | Preferences.addCheckbox(settingsGroup, "Stop running instances upon compile", this.current.stopCompile, (value) => { 146 | this.current.stopCompile = value; 147 | this.save(); 148 | }); 149 | Preferences.addCheckbox(settingsGroup, "Open source file after fatal errors", this.current.displayLine, (value) => { 150 | this.current.displayLine = value; 151 | this.save(); 152 | }); 153 | Preferences.addCheckbox(settingsGroup, "Clean cache on compile error", this.current.cleanOnError, (value) => { 154 | this.current.cleanOnError = value; 155 | this.save(); 156 | }); 157 | Preferences.addCheckbox(settingsGroup, "Clean cache after run", this.current.cleanAfterRun, (value) => { 158 | this.current.cleanAfterRun = value; 159 | this.save(); 160 | }); 161 | Preferences.addText(root, `builder v${Builder.Version} by nommiin`); 162 | } 163 | 164 | static ready() { 165 | GMEdit.on("preferencesBuilt", (e) => { 166 | let out = e.target.querySelector('.plugin-settings[for="builder"]'); 167 | if (this.element == null) this.build(); 168 | out.appendChild(this.element); 169 | }); 170 | } 171 | } -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | if (require("os").type().includes("Darwin")) process.env.ProgramData = "/Users/Shared"; 2 | 3 | Builder = { 4 | Version: "1.25", 5 | MenuItems: { list: [], run: null, stop: null, fork: null }, 6 | Platform: require("os").type(), 7 | RuntimeSettings: null, 8 | LoadKeywords: function(path) { 9 | const custBase = $gmedit["electron.FileWrap"].userPath + "/api/" + GmlAPI.version.getName(); 10 | const GmlAPILoader = $gmedit["gml.GmlAPILoader"]; 11 | const GmlParseAPI = $gmedit["parsers.GmlParseAPI"]; 12 | const getText = (path) => { 13 | if (Electron_FS.existsSync(path)) { 14 | try { 15 | return Electron_FS.readFileSync(path, "utf8") 16 | } catch (x) { 17 | console.error(x); 18 | return null; 19 | } 20 | } else return null; 21 | } 22 | for (let platform of Electron_FS.readdirSync(path)) { 23 | let platformPath = `${path}/${platform}`; 24 | if (!Electron_FS.statSync(platformPath).isDirectory()) continue; 25 | let fnamesPath = platformPath + "/fnames"; 26 | if (!Electron_FS.existsSync(fnamesPath)) continue; 27 | let apiText = Electron_FS.readFileSync(fnamesPath, "utf8"); 28 | // 29 | let custDir = custBase + "/" + platform; 30 | if (Electron_FS.existsSync(custDir)) { 31 | let replText = getText(custDir + "/replace.gml"); 32 | if (replText) apiText = GmlAPILoader.applyPatchFile(apiText, replText); 33 | // 34 | let extraText = getText(custDir + "/extra.gml"); 35 | if (extraText) apiText += "\n" + extraText; 36 | } 37 | // 38 | let args = GmlAPILoader.getArgs(); 39 | GmlParseAPI.loadStd(apiText, args); 40 | } 41 | }, 42 | ProjectVersion: function(project) { 43 | // GMEdit seems to adjust the .version property to be a gml_GmlVersion class, check if it's an object or not to maintain backwards compatibility 44 | if (typeof(project.version) == "object") { 45 | switch (project.version.config.projectMode) { 46 | case "gms2": return 2; 47 | case "gms1": return 1; 48 | } 49 | return -1; 50 | } 51 | return project.version; 52 | }, 53 | GetRuntimes: function(path) { 54 | let Runtimes = []; 55 | try { 56 | Electron_FS.readdirSync(path).forEach((e) => { 57 | let rtStat = Electron_FS.statSync(path + e); 58 | if (rtStat.isDirectory() && Electron_FS.existsSync(path + e + "/fnames")) { 59 | Runtimes.push(e); 60 | } 61 | }); 62 | } catch (x) { 63 | console.warn(`Failed to index ${path}:`, x); 64 | } 65 | Runtimes.sort((a, b) => a < b ? 1 : -1); 66 | return Runtimes; 67 | }, 68 | InitalizeRuntimes: function(set, showWarning) { 69 | // [Re-]gather list of runtimes 70 | set.runtimeList = this.GetRuntimes(set.location); 71 | 72 | if (set.runtimeList.length <= 0) { 73 | if (showWarning) Electron_Dialog.showMessageBox({ 74 | type: "warning", 75 | message: `builder was unable to find any runtimes in ${set.location}, please verify your runtime location and rescan.` 76 | }); 77 | set.selection = ""; 78 | return; 79 | } 80 | 81 | if (set.selection.trim() == "" || !set.runtimeList.includes(set.selection)) { 82 | set.selection = set.runtimeList[0]; 83 | } 84 | }, 85 | Initalize: function() { 86 | // Check if platform is supported 87 | this.Platform = (this.Platform.includes("Windows") ? "win" : (this.Platform.includes("Darwin") ? "mac" : "unknown")); 88 | if (this.Platform == "unknown") { 89 | Electron_Dialog.showMessageBox({ 90 | type: "error", 91 | title: "Builder", 92 | message: `builder v${Builder.Version} is not supported on your platform (${require("os").type()})` 93 | }); 94 | return false; 95 | } 96 | 97 | // Load preferences file 98 | BuilderPreferences.init(); 99 | for (let [key, val] of Object.entries(BuilderPreferences.current.runtimeSettings)) { 100 | this.InitalizeRuntimes(val, key == "Stable"); 101 | } 102 | 103 | let runtimeSettings = BuilderPreferences.current.runtimeSettings.Stable; 104 | Builder.LoadKeywords(runtimeSettings.location + runtimeSettings.selection); 105 | return true; 106 | } 107 | }; 108 | 109 | (function() { 110 | function initCommands() { 111 | const commands = [{ 112 | name: "builder-run", 113 | title: "builder: Compile and run", 114 | bindKey: "F5", 115 | exec: () => Builder.Run(), 116 | }, { 117 | name: "builder-run-and-fork", 118 | title: "builder: Compile and run two instances", 119 | bindKey: { win: "ctrl-F5", mac: "cmd-F5" }, 120 | exec: () => Builder.Run(true), 121 | }, { 122 | name: "builder-stop", 123 | title: "builder: Stop compiler or runner process", 124 | bindKey: "F6", 125 | exec: Builder.Stop, 126 | }, { 127 | name: "builder-fork", 128 | title: "builder: Fork instance of runner", 129 | bindKey: "F7", 130 | exec: Builder.Fork, 131 | }]; 132 | 133 | let hashHandler = $gmedit["ui.KeyboardShortcuts"].hashHandler; 134 | let AceCommands = $gmedit["ace.AceCommands"]; 135 | for (let cmd of commands) { 136 | hashHandler.addCommand(cmd); 137 | AceCommands.add(cmd); 138 | AceCommands.addToPalette({ 139 | name: cmd.title, 140 | exec: cmd.name, 141 | }) 142 | } 143 | } 144 | GMEdit.register("builder", { 145 | init: function(config) { 146 | // Initalize Builder! 147 | if (Builder.Initalize() == false) { 148 | console.error("builder - Failed to initalize."); 149 | return; 150 | } 151 | 152 | // Create main menu items! 153 | let MainMenu = $gmedit["ui.MainMenu"].menu; 154 | for (let [index, mainMenuItem] of MainMenu.items.entries()) { 155 | if (mainMenuItem.id != "close-project") continue; 156 | Builder.MenuItems.list = [ 157 | new Electron_MenuItem({ 158 | id: "builder-sep", 159 | type: "separator" 160 | }), 161 | Builder.MenuItems.run = new Electron_MenuItem({ 162 | id: "builder-run", 163 | label: "Run", 164 | accelerator: "F5", 165 | icon: config.dir + "/icons/run.png", 166 | enabled: false, 167 | click: () => Builder.Run() 168 | }), 169 | Builder.MenuItems.runAndFork = new Electron_MenuItem({ 170 | id: "builder-run-and-fork", 171 | label: "Run and Fork", 172 | accelerator: "Ctrl+F5", 173 | icon: config.dir + "/icons/run-and-fork.png", 174 | enabled: false, 175 | visible: BuilderPreferences.current.showRunAndFork, 176 | click: () => Builder.Run(true) 177 | }), 178 | Builder.MenuItems.stop = new Electron_MenuItem({ 179 | id: "builder-stop", 180 | label: "Stop", 181 | accelerator: "F6", 182 | icon: config.dir + "/icons/stop.png", 183 | enabled: false, 184 | click: Builder.Stop 185 | }), 186 | Builder.MenuItems.fork = new Electron_MenuItem({ 187 | id: "builder-fork", 188 | label: "Fork", 189 | accelerator: "F7", 190 | icon: config.dir + "/icons/fork.png", 191 | enabled: false, 192 | click: Builder.Fork 193 | }), 194 | Builder.MenuItems.clean = new Electron_MenuItem({ 195 | id: "builder-clean", 196 | label: "Clean", 197 | accelerator: "Ctrl+F7", 198 | icon: config.dir + "/icons/clean.png", 199 | enabled: false, 200 | click: Builder.CleanGUI 201 | }), 202 | ]; 203 | for (let newItem of Builder.MenuItems.list) { 204 | MainMenu.insert(++index, newItem); 205 | } 206 | break; 207 | } 208 | if (Builder.MenuItems.run == null) return; // probably running in GMLive.js 209 | 210 | BuilderPreferences.ready(); 211 | BuilderProjectProperties.ready(); 212 | 213 | // Add ace commands! 214 | initCommands(); 215 | 216 | // Hook into finishedIndexing 217 | const Project = $gmedit["gml.Project"]; 218 | const YyJson = $gmedit["yy.YyJson"]; 219 | let finishedIndexing = Project.prototype.finishedIndexing; 220 | function onFinishedIndexing() { 221 | if (Builder.ProjectVersion(this) != 2) return; 222 | const project = Project.current; 223 | const projectContent = project.readTextFileSync(project.name); 224 | 225 | const v23 = Project.current.isGMS23; 226 | if (v23 == null) v23 = YyJson.isExtJson(projectContent); 227 | 228 | const projectRoot = YyJson.parse(projectContent); 229 | if (v23) { 230 | this.configs = []; 231 | function addConfigRec(project, config) { 232 | if (!project.configs.includes(config.name)) project.configs.push(config.name); 233 | for (let childConfig of config.children) addConfigRec(project, childConfig); 234 | } 235 | addConfigRec(this, projectRoot.configs); 236 | } else { 237 | this.configs = ["default"]; 238 | for (let configData of projectRoot.configs) { 239 | for (let configName of configData.split(";")) { 240 | if (!this.configs.includes(configName)) this.configs.push(configName); 241 | } 242 | } 243 | } 244 | if (this.builderIDEVersion == null) { 245 | if (v23) { 246 | const md = projectRoot.MetaData; 247 | if (md) { 248 | this.builderIDEVersion = md.IDEVersion; 249 | } else this.builderIDEVersion = "2.3.0.0"; 250 | } else { 251 | this.builderIDEVersion = "2.2.5.0"; 252 | } 253 | } 254 | 255 | let sf = Builder.SessionsFile; 256 | if (sf.sync()) sf.data = {}; 257 | let path = project.path; 258 | let pc = sf.data[path]; 259 | if (pc) pc.mtime = Date.now(); 260 | this.config = (pc && pc.config) || (v23 ? "Default" : "default"); 261 | sf.flush(); 262 | 263 | let TreeView = $gmedit["ui.treeview.TreeView"]; 264 | let Configurations = undefined; 265 | for (let dir of document.querySelectorAll(".dir")) { 266 | if (dir.textContent == "Configs") { 267 | Configurations = dir; 268 | break; 269 | } 270 | } 271 | Configurations = Configurations || TreeView.makeAssetDir("Configs", ""); 272 | 273 | this.configs.forEach((configName) => { 274 | let Configuration = TreeView.makeItem(configName); 275 | Configuration.addEventListener("dblclick", function() { 276 | Project.current.config = configName; 277 | // 278 | let sf = Builder.SessionsFile; 279 | if (sf.sync()) sf.data = {}; 280 | let path = Project.current.path; 281 | let pc = sf.data[path]; 282 | if (pc == null) pc = sf.data[path] = { }; 283 | pc.config = configName; 284 | pc.mtime = Date.now(); 285 | sf.flush(); 286 | // 287 | document.getElementById("project-name").innerText = `${Project.current.displayName} (${configName})`; 288 | }); 289 | Configurations.treeItems.appendChild(Configuration); 290 | }); 291 | TreeView.element.appendChild(Configurations); 292 | 293 | document.getElementById("project-name").innerText = `${Project.current.displayName} (${this.config})`; 294 | } 295 | Project.prototype.finishedIndexing = function(arguments) { 296 | let result = finishedIndexing.apply(this, arguments); 297 | try { 298 | onFinishedIndexing.call(this); 299 | } catch (x) { 300 | console.error(x); 301 | } 302 | return result; 303 | } 304 | 305 | function projectOpened() { 306 | for (let item of Builder.MenuItems.list) item.enabled = false; 307 | let project = $gmedit["gml.Project"].current; 308 | if (Builder.ProjectVersion(project) == 2) { 309 | Builder.MenuItems.run.enabled = true; 310 | Builder.MenuItems.runAndFork.enabled = true; 311 | Builder.MenuItems.clean.enabled = true; 312 | let runtime; 313 | const pref = BuilderPreferences.current; 314 | if (project.version.name == "v23" 315 | && pref.runtimeSettings.Stable.selection < "runtime-2.3" 316 | && pref.runtimeSettings.Beta.selection != "" 317 | ) { 318 | // if runtime is set to 2.2.5 but project uses 2.3, prefer a beta runtime 319 | runtime = pref.runtimeSettings.Beta; 320 | } else runtime = pref.runtimeSettings.Stable; 321 | Builder.RuntimeSettings = runtime; 322 | Builder.LoadKeywords(runtime.location + runtime.selection); 323 | } 324 | } 325 | GMEdit.on("projectOpen", projectOpened); 326 | 327 | GMEdit.on("projectClose", function() { 328 | for (let item of Builder.MenuItems.list) item.enabled = false; 329 | }); 330 | 331 | const dispatchProjectOpenOnReady = $gmedit["plugins.PluginManager"].dispatchProjectOpenOnReady; 332 | if (dispatchProjectOpenOnReady === undefined) { 333 | projectOpened(); 334 | } else if (dispatchProjectOpenOnReady === true) { 335 | let project = $gmedit["gml.Project"].current; 336 | try { 337 | onFinishedIndexing.call(project); 338 | } catch (x) { 339 | console.error(x); 340 | } 341 | } 342 | } 343 | }); 344 | })(); 345 | -------------------------------------------------------------------------------- /BuilderCompile.js: -------------------------------------------------------------------------------- 1 | class BuilderCompile { 2 | static run(autoRunFork) { 3 | let project = $gmedit["gml.Project"].current; 4 | if (Builder.ProjectVersion(project) != 2) return false; 5 | BuilderCompile.runAsync(autoRunFork, project); 6 | return true; 7 | } 8 | static async runAsync(autoRunFork, project) { 9 | project ??= $gmedit["gml.Project"].current; 10 | 11 | const path = require("path") 12 | const isWindows = (Builder.Platform == "win"); 13 | 14 | // Clear any past errors! 15 | Builder.Errors = []; 16 | Builder.ErrorMet = false; 17 | 18 | // Create or reuse output tab! 19 | let output = BuilderOutput.open(false); 20 | let abort = (text) => { 21 | output.write(text); 22 | return false; 23 | } 24 | output.clear(`Compile Started: ${Builder.GetTime()}`); 25 | 26 | // Save all edits if enabled! 27 | if (BuilderPreferences.current.saveCompile == true) { 28 | for (let tab of document.querySelectorAll(".chrome-tab-changed")) { 29 | let file = tab.gmlFile; 30 | if (file && file.__changed && file.path != null) file.save(); 31 | } 32 | } 33 | 34 | // Close any runners if open 35 | if (BuilderPreferences.current.stopCompile == true) { 36 | if (Builder.Runner.length > 0) { 37 | Builder.Runner.forEach((e) => { 38 | e.kill(); 39 | }); 40 | } 41 | Builder.Runner = []; 42 | } 43 | 44 | // Find the temporary directory! 45 | output.write(`IDE: ${project.builderIDEVersion}`); 46 | let builderSettings = project.properties.builderSettings; 47 | let runtimeSelection; 48 | function removeRuntimePrefix(version) { 49 | const prefixRegex = /^runtime-(.+)/; 50 | const mt = prefixRegex.exec(version); 51 | return mt ? mt[1] : version; 52 | } 53 | function findExactRuntime(desiredVersion) { 54 | desiredVersion = removeRuntimePrefix(desiredVersion); 55 | for (let set of Object.values(BuilderPreferences.current.runtimeSettings)) { 56 | for (let runtimeVersion of set.runtimeList) { 57 | let runtimeVersionNP = removeRuntimePrefix(runtimeVersion); 58 | if (runtimeVersionNP != desiredVersion) continue; 59 | return set.location + runtimeVersion; 60 | } 61 | } 62 | return null; 63 | } 64 | if (builderSettings?.runtimeVersion) { 65 | runtimeSelection = builderSettings.runtimeVersion; 66 | let rtPath = findExactRuntime(runtimeSelection); 67 | if (rtPath == null) return abort(`Couldn't find runtime ${runtimeSelection} that is set in project properties!`); 68 | Builder.Runtime = rtPath; 69 | } else { 70 | function getRuntimeNumbers(version) { 71 | let parts = version.split("."); 72 | parts = parts.map(part => { 73 | let n = parseInt(part); 74 | return isNaN(n) ? 0 : n; 75 | }); 76 | return parts; 77 | } 78 | function findClosestRuntime(prefix, oldest = false) { 79 | for (const set of Object.values(BuilderPreferences.current.runtimeSettings)) { 80 | let rtl = set.runtimeList; 81 | rtl = rtl.map(v => { 82 | const vnp = removeRuntimePrefix(v); 83 | return { version: v, noPrefix: vnp, numbers: getRuntimeNumbers(vnp) }; 84 | }); 85 | rtl = rtl.filter(v => v.noPrefix.startsWith(prefix)); 86 | if (rtl.length == 0) continue; 87 | rtl.sort((a, b) => { 88 | const an = a.numbers; 89 | const bn = b.numbers; 90 | const n = Math.max(an.length, bn.length); 91 | for (let i = 0; i < n; i++) { 92 | const av = an[i] ?? 0; 93 | const bv = bn[i] ?? 0; 94 | let d = bv - av; 95 | if (oldest) d = -d; 96 | if (d != 0) return d; 97 | } 98 | return 0; 99 | }); 100 | const rt = rtl[0]; 101 | rt.path = set.location + rt.version; 102 | return rt; 103 | } 104 | return null; 105 | } 106 | // 107 | runtimeSelection = null; 108 | let ideVersion = project.builderIDEVersion; 109 | const isLTS = /^20\d\d\.0\./.test(ideVersion); 110 | let rtPath = findExactRuntime(ideVersion); 111 | if (rtPath) { 112 | runtimeSelection = ideVersion; 113 | Builder.Runtime = rtPath; 114 | } else { 115 | function setRuntime(rt) { 116 | runtimeSelection = rt.version; 117 | Builder.Runtime = rt.path; 118 | } 119 | let versionParts = ideVersion.split("."); 120 | for (let vn = versionParts.length; --vn >= 2;) { 121 | let versionPrefix = versionParts.slice(0, vn).join(".") + "."; 122 | let rt = findClosestRuntime(versionPrefix); 123 | if (rt) { 124 | setRuntime(rt); 125 | break; 126 | } 127 | } 128 | if (runtimeSelection) { 129 | // OK! 130 | } else if (/^2022\./.test(ideVersion) && !isLTS) { 131 | // if it's 2022.x, try 2023.x? 132 | let rt = findClosestRuntime("2023.", true); 133 | if (rt) setRuntime(rt); 134 | } else if (/^2\.3\./.test(ideVersion)) { 135 | // if it's 2.3.x, try LTS? 136 | let rt = findClosestRuntime("2022.0."); 137 | if (rt) setRuntime(rt); 138 | } 139 | } 140 | if (runtimeSelection == null) { 141 | return abort(`Could not find a good runtime match! Try picking one manually.`); 142 | } else { 143 | output.write(`Best-matching runtime is ${runtimeSelection}`); 144 | } 145 | } 146 | // 147 | let appName = (() => { 148 | let rt = Builder.Runtime; 149 | let at = rt.lastIndexOf("/Cache"); 150 | if (at < 0) return "GameMakerStudio2"; 151 | rt = rt.substring(0, at); 152 | at = rt.lastIndexOf("/"); 153 | if (at < 0) return "GameMakerStudio2"; 154 | return rt.substring(at + 1); 155 | })(); 156 | output.write(`Program: ${appName}`); 157 | output.write(`Runtime: ${Builder.Runtime}`) 158 | let steamworksPath = null; 159 | let Userpath, Temporary, GMS2CacheDir; { 160 | let appBase = (isWindows ? Electron_App.getPath("appData") : `/Users/${process.env.LOGNAME}/.config`); 161 | let appDir = `${appBase}/${appName}`; 162 | if (!Electron_FS.existsSync(appDir)) Electron_FS.mkdirSync(appDir); 163 | // 164 | try { 165 | let userData = JSON.parse(Electron_FS.readFileSync(`${appDir}/um.json`)); 166 | let username = userData.login || userData.username; 167 | // "you@domain.com" -> "you": 168 | let usernameAtSign = username.indexOf("@"); 169 | if (usernameAtSign >= 0) username = username.slice(0, usernameAtSign); 170 | // 171 | Userpath = `${appDir}/${username}_${userData.userID}`; 172 | GMS2CacheDir = `${appDir}/Cache/GMS2CACHE`; 173 | } catch (x) { 174 | return abort([ 175 | "Failed to figure out your user path!", 176 | "Make sure you're logged in.", 177 | "Error: " + x 178 | ].join("\n")); 179 | } 180 | // 181 | try { 182 | let userSettings = JSON.parse(Electron_FS.readFileSync(`${Userpath}/local_settings.json`)); 183 | let dir; 184 | Temporary = userSettings["machine.General Settings.Paths.IDE.TempFolder"]; 185 | dir = userSettings["machine.General Settings.Paths.IDE.AssetCacheFolder"]; 186 | if (dir) GMS2CacheDir = dir + "\\GMS2CACHE"; 187 | steamworksPath = userSettings["machine.Platform Settings.Steam.steamsdk_path"]; 188 | } catch (x) { 189 | console.error("Failed to read temporary folder path, assuming default.", x); 190 | Temporary = null; 191 | } 192 | if (!Temporary) { // figure out default location 193 | if (isWindows) { 194 | Temporary = `${process.env.LOCALAPPDATA}/${appName}`; 195 | } else { 196 | Temporary = require("os").tmpdir(); 197 | if (Temporary.endsWith("/T")) Temporary = Temporary.slice(0, -2); // ? 198 | } 199 | } 200 | // for an off-chance that your %LOCALAPPDATA%/GameMakerStudio2 directory doesn't exist 201 | if (!Electron_FS.existsSync(Temporary)) Electron_FS.mkdirSync(Temporary); 202 | if (!isWindows) { 203 | Temporary += "/GameMakerStudio2"; 204 | if (!Electron_FS.existsSync(Temporary)) Electron_FS.mkdirSync(Temporary); 205 | } 206 | Temporary += "/GMS2TEMP"; 207 | if (!Electron_FS.existsSync(Temporary)) Electron_FS.mkdirSync(Temporary); 208 | if (!Electron_FS.existsSync(Temporary)) Electron_FS.mkdirSync(Temporary); 209 | } 210 | output.write("Temp directory: " + Temporary); 211 | 212 | let Name = project.name.slice(0, project.name.lastIndexOf(".")); 213 | Builder.Name = Builder.Sanitize(Name); 214 | Builder.Cache = `${GMS2CacheDir}/${Name}`; 215 | 216 | // Check for GMAssetCompiler and Runner files! 217 | let GMAssetCompilerDirOrig = Builder.Runtime + "/bin"; 218 | let GMAssetCompilerPathOrig = GMAssetCompilerDirOrig + "/GMAssetCompiler.exe"; 219 | const GMAssetCompilerDir2022Container = `${Builder.Runtime}/bin/assetcompiler/${isWindows ? "windows" : "osx"}`; 220 | const isArm = Electron_FS.existsSync(`${GMAssetCompilerDir2022Container}/arm64`); 221 | let GMAssetCompilerDir2022 = `${Builder.Runtime}/bin/assetcompiler/${isWindows ? "windows" : "osx"}/${isArm ? 'arm64' : 'x64'}`; 222 | let GMAssetCompilerPath2022 = `${GMAssetCompilerDir2022}/GMAssetCompiler${isWindows ? ".exe" : ""}`; 223 | let GMAssetCompilerDir = GMAssetCompilerDirOrig; 224 | let GMAssetCompilerPath = GMAssetCompilerPathOrig; 225 | let DotNET6Flag = false; 226 | 227 | if (!Electron_FS.existsSync(GMAssetCompilerPath)) { 228 | if (Electron_FS.existsSync(GMAssetCompilerPath2022)) { 229 | GMAssetCompilerDir = GMAssetCompilerDir2022; 230 | GMAssetCompilerPath = GMAssetCompilerPath2022; 231 | DotNET6Flag = true; 232 | } else { 233 | output.write(`!!! Could not find "GMAssetCompiler${isWindows ? ".exe" : ""}" in ${GMAssetCompilerPath}`); 234 | Builder.Stop(); 235 | return; 236 | } 237 | } 238 | let runnerPath = null; 239 | let x64flag = null; // determines value of /64bitgame= (null to not pass) 240 | let optPlat = null; // platform options YY (to avoid loading them twice) 241 | if (isWindows) { 242 | if (Electron_FS.existsSync(runnerPath = `${Builder.Runtime}/windows/Runner.exe`)) { 243 | try { 244 | optPlat = project.readYyFileSync(`options/windows/options_windows.yy`); 245 | x64flag = optPlat["option_windows_use_x64"]; 246 | } catch (e) { 247 | console.log("Error checking x64 flag:", e); 248 | } 249 | } else if (Electron_FS.existsSync(runnerPath = `${Builder.Runtime}/windows/x64/Runner.exe`)) { 250 | // no x86 runtime so this surely is x64 251 | x64flag = true; 252 | } else runnerPath = null; 253 | } else { 254 | if (Electron_FS.existsSync(runnerPath = `${Builder.Runtime}/mac/YoYo Runner.app/Contents/MacOS/Mac_Runner`)) { 255 | // OK! 256 | } else runnerPath = null; 257 | } 258 | if (runnerPath == null) { 259 | output.write(`!!! Could not find runner executable in "${Builder.Runtime}"`); 260 | Builder.Stop(); 261 | return; 262 | } 263 | Builder.RunnerPath = runnerPath; 264 | Builder.MenuItems.stop.enabled = true; 265 | 266 | // Create substitute drive on Windows! 267 | const TemporaryUnmapped = Temporary; 268 | if (isWindows && BuilderPreferences.current.useVirtualDrives) { 269 | let drive = BuilderDrives.add(Temporary); 270 | if (drive == null) { 271 | output.write(`!!! Could not find a free drive letter to use`) 272 | return; 273 | } 274 | Builder.Drive = drive; 275 | Builder.Drives.push(drive); 276 | Temporary = drive + ":/"; 277 | } else if (!Temporary.endsWith("/") && !Temporary.endsWith("\\")) { 278 | Temporary += "/"; 279 | } 280 | Builder.Outpath = Temporary + Name + "_" + Builder.Random(); 281 | output.write("Using output path: " + Builder.Outpath); 282 | output.write(""); // GMAC doesn't line-break at the start 283 | 284 | /* 285 | Target bit flags: 286 | Windows: 1 << 6 287 | Mac: 1 << 1 288 | IOS: 1 << 2 289 | Android: 1 << 3 290 | HTML5: 1 << 5 291 | Linux: 1 << 7 292 | WASM: 1 << 63 293 | OperaGX: 1 << 34 294 | */ 295 | const targetMask = isWindows ? 64 : 2; 296 | const targetMachine = isWindows ? "windows" : "mac"; 297 | const targetMachineFriendly = isWindows ? "Windows" : "macOS"; 298 | 299 | // I don't know where I'm supposed to get feature flag list from 300 | let ffe = (function() { 301 | let plain = "operagx-yyc,intellisense,nullish,login_sso,test"; 302 | let shifted = ""; 303 | for (let i = 0; i < plain.length; i++) { 304 | shifted += String.fromCharCode(plain.charCodeAt(i) + 10); 305 | } 306 | return btoa(shifted); 307 | })(); 308 | 309 | // Apparently using forward slashes in paths breaks caching now, go figure 310 | const fixSlashes = (path) => { 311 | return isWindows ? path.split("/").join("\\") : path; 312 | } 313 | 314 | let outputPath = `${Builder.Outpath}/${Builder.Name}.${Builder.Extension}`; 315 | let optionsIniPath = fixSlashes(Builder.Outpath + "/options.ini"); 316 | 317 | const compilerArgs = [ 318 | `/compile`, 319 | `/majorv=1`, // /mv 320 | `/minorv=0`, // /iv 321 | `/releasev=0`, // /rv 322 | `/buildv=0`, // /bv 323 | `/zpex`, // GMS2 mode 324 | `/NumProcessors=8`, // /j 325 | `/gamename=${Name}`, // /gn spaces/etc. will be replaced automatically 326 | `/TempDir=${fixSlashes(Temporary)}`, // /td 327 | `/CacheDir=${fixSlashes(Builder.Cache)}`, // /cd 328 | `/runtimePath=${fixSlashes(Builder.Runtime)}`, // /rtp 329 | `/zpuf=${fixSlashes(Userpath)}`, // GMS2 user folder 330 | `/machine=${targetMachine}`, // /m 331 | `/target=${targetMask}`, // /tgt 332 | `/llvmSource=${fixSlashes(Builder.Runtime + "/interpreted/")}`, 333 | `/nodnd`, 334 | `/config=${project.config}`, 335 | `/outputDir=${fixSlashes(Builder.Outpath)}`, 336 | `/ShortCircuit=True`, 337 | `/optionsini=${optionsIniPath}`, 338 | `/CompileToVM`, 339 | `/baseproject=${fixSlashes(Builder.Runtime + "/BaseProject/BaseProject.yyp")}`, 340 | `/verbose`, 341 | `/bt=run`, // build type 342 | `/runtime=vm`, // "vm" or "yyc" 343 | ]; 344 | if (!/^runtime-[27]\./.test(runtimeSelection)) { 345 | // not 2.x/7.x - in other words, 2022+ 346 | compilerArgs.push(`/debug`); 347 | compilerArgs.push(`/ffe=${ffe}`); 348 | } 349 | if (x64flag != null) compilerArgs.push("/64bitgame=" + x64flag); 350 | compilerArgs.push(project.path); 351 | //for (let arg of compilerArgs) output.write(arg); 352 | // 353 | let extensionNames = []; // only for 2.3+! 354 | try { 355 | if (project.isGMS23) for (let resName in project.yyResources) { 356 | let res = project.yyResources[resName]; 357 | if (res == null) continue; 358 | let id = res.id; 359 | if (id == null) continue; 360 | let extName = id.name; 361 | if (extName == null) continue; 362 | let extRel = id.path; 363 | if (extRel == null || !extRel.startsWith("extensions/")) continue; 364 | extensionNames.push(resName); 365 | } 366 | } catch (e) { 367 | console.error("Failed to enumerate extensions:", e); 368 | } 369 | 370 | // 371 | let runUserCommandStep_env = null; 372 | const runUserCommandStep_init_env = () => { 373 | let env = {}; 374 | let iniSections = []; 375 | let iniAdd = (sectionName, key, value) => { 376 | let section = iniSections.filter(q => q.name == sectionName)[0]; 377 | if (section == null) iniSections.push(section = { name: sectionName, pairs: []}); 378 | let pairs = section.pairs; 379 | let pair = pairs.filter(q => q.key == key)[0]; 380 | if (pair == null) { 381 | pairs.push({key, value}); 382 | } else pair.value = value; 383 | } 384 | if (isWindows && x64flag != null) iniAdd("Windows", "Usex64", x64flag ? "True" : "False"); 385 | // baseline: 386 | env["YYPLATFORM_name"] = targetMachineFriendly; 387 | try { 388 | let platName = targetMachine; 389 | optPlat ??= project.readYyFileSync(`options/${platName}/options_${platName}.yy`); 390 | for (let key in optPlat) { 391 | //if (!key.startsWith("option_")) continue; 392 | let val = optPlat[key]; 393 | if (typeof(val) == "boolean") val = val ? "True" : "False"; 394 | env["YYPLATFORM_" + key] = val; 395 | } 396 | } catch (e) { 397 | console.error("Error while getting platform options:", e); 398 | } 399 | // 400 | if (x64flag && env["YYPLATFORM_option_windows_use_x64"] == "False") { 401 | env["YYPLATFORM_option_windows_use_x64"] = "True"; 402 | } 403 | // I can't figure out why isRunningFromIDE returns false for builder-launched games 404 | let appid = env["YYEXTOPT_Steamworks_AppID"]; 405 | if (appid != null) { 406 | if (!Electron_FS.existsSync(Builder.Outpath)) Electron_FS.mkdirSync(Builder.Outpath); 407 | Electron_FS.writeFileSync(Builder.Outpath + "/steam_appid.txt", "" + appid); 408 | } 409 | // 410 | env["YYTARGET_runtime"] = "VM"; 411 | env["YYtargetMask"] = targetMask; 412 | env["YYoutputFolder"] = Builder.Outpath; 413 | env["YYassetCompiler"] = " " + compilerArgs.join(" "); 414 | env["YYcompile_output_file_name"] = outputPath; 415 | env["YYconfig"] = project.config; 416 | env["YYconfigParents"] = ""; // TODO 417 | env["YYdebug"] = "False"; 418 | env["YYprojectName"] = Name; 419 | env["YYprojectPath"] = project.path; 420 | env["YYprojectDir"] = project.dir; 421 | env["YYruntimeLocation"] = Builder.Runtime; 422 | let runtimeVersion = runtimeSelection; 423 | if (runtimeVersion.startsWith("runtime-")) runtimeVersion = runtimeVersion.substring("runtime-".length); 424 | env["YYruntimeVersion"] = runtimeVersion; 425 | env["YYuserDir"] = Userpath; 426 | env["YYtempFolder"] = TemporaryUnmapped; 427 | env["YYtempFolderUnmapped"] = TemporaryUnmapped; 428 | env["YYverbose"] = "True"; 429 | // 430 | //console.log(env); 431 | Object.assign(env, process.env); 432 | // collecting extension options is a little messy but what can you do 433 | for (let resName of extensionNames) { 434 | let res = project.yyResources[resName]; 435 | let id = res.id; 436 | let extName = id.name; 437 | let extRel = id.path; 438 | if (extRel == null || !extRel.startsWith("extensions/")) continue; 439 | let optRel = "options/extensions/" + id.name + ".json"; 440 | if (!project.existsSync(optRel)) continue; 441 | try { 442 | let ext = project.readYyFileSync(extRel); 443 | let optRoot = project.readYyFileSync(optRel); 444 | let configurables = optRoot.configurables; 445 | // collect files with PreGraphicsInitialisation... 446 | for (let file of ext.files) { 447 | let func = file.functions.filter(q => q.name == "PreGraphicsInitialisation")[0]; 448 | if (func == null) continue; 449 | let aliases = [file.filename]; 450 | for (let proxy of file.ProxyFiles) aliases.push(proxy.name); 451 | let PathTools = $gmedit["haxe.io.Path"]; 452 | if (isWindows) { 453 | aliases = aliases.filter(name => { 454 | return PathTools.extension(name).toLowerCase() == "dll"; 455 | }); 456 | } else { 457 | aliases = aliases.filter(name => { 458 | return PathTools.extension(name).toLowerCase() != "dll"; 459 | }); 460 | } 461 | if (aliases.length > 0) iniAdd(extName, "PreGraphicsInitFile", aliases.join("|")); 462 | } 463 | for (let optDef of ext.options) { 464 | if (optDef.optType == 5) continue; // label! 465 | 466 | let optGUID = optDef.guid; 467 | let optVal = configurables[optGUID]; 468 | if (optVal != null 469 | && typeof(optVal) == "object" 470 | && optVal.Default != null 471 | ) optVal = optVal.Default.value; 472 | optVal ??= optDef.defaultValue; 473 | if (optVal == null) continue; 474 | // variables: 475 | optVal = optVal.replace(/%(\w+)%/g, (mt, name) => { 476 | return env[name] ?? mt; 477 | }); 478 | 479 | if (optDef.optType == 4) { // path! 480 | if (!path.isAbsolute(optVal)) { 481 | optVal = path.normalize(path.join(project.dir, optVal)); 482 | } 483 | } 484 | 485 | env[`YYEXTOPT_${extName}_${optDef.name}`] = optVal; 486 | if (optDef.exportToINI) iniAdd(extName, optDef.name, optVal); 487 | } 488 | } catch (e) { 489 | console.error(`Error while getting options for ${id.name}:`, e); 490 | } 491 | } 492 | // 493 | try { 494 | let optMain = project.readYyFileSync("options/main/options_main.yy"); 495 | for (let key in optMain) { 496 | //if (!key.startsWith("option_")) continue; 497 | env["YYMAIN_" + key] = optMain[key]; 498 | } 499 | } catch (e) { 500 | console.error("Error while getting main options:", e); 501 | } 502 | // write the ini file: 503 | if (iniSections.length > 0) { 504 | let iniLines = []; 505 | for (let section of iniSections) { 506 | iniLines.push("[" + section.name + "]"); 507 | if (section.name == "Steamworks") { 508 | let sdkPair = section.pairs.filter((p) => p.key == "SteamSDK")[0]; 509 | if (sdkPair) sdkPair.value = sdkPair.value.split("\\\\").join("\\"); 510 | } 511 | for (let pair of section.pairs) { 512 | iniLines.push(pair.key + "=" + pair.value); 513 | } 514 | } 515 | iniLines.push(""); 516 | if (!Electron_FS.existsSync(Builder.Outpath)) Electron_FS.mkdirSync(Builder.Outpath); 517 | Electron_FS.writeFileSync(optionsIniPath, iniLines.join("\r\n")); 518 | } 519 | // 520 | runUserCommandStep_env = env; 521 | } 522 | /** @returns {bool} trouble */ 523 | const runUserCommandStep_1 = async (path) => { 524 | if (!project.isGMS23) return false; 525 | if (runUserCommandStep_env == null) runUserCommandStep_init_env(); 526 | path = project.fullPath(path); 527 | //console.log(path, Electron_FS.existsSync(path)); 528 | if (!Electron_FS.existsSync(path)) return false; 529 | // Well? I don't want to print streams when we're done, I want it as it happens 530 | let proc; 531 | try { 532 | output.write(`Running "${path}"`); 533 | output.write(""); 534 | proc = Builder.Command.spawn(path, { 535 | env: runUserCommandStep_env, 536 | shell: true, 537 | }); 538 | } catch (e) { 539 | output.write(`Failed to run "${path}": ` + e); 540 | console.error(`Failed to run "${path}":`, e); 541 | return true; 542 | } 543 | let exitCode = null; 544 | proc.stdout.on("data", (e) => { 545 | output.write(e.toString(), false); 546 | }); 547 | proc.stderr.on("data", (e) => { 548 | output.write(e.toString(), false); 549 | }); 550 | proc.on("close", (_exitCode) => { 551 | exitCode = _exitCode; 552 | output.write(`Finished "${path}", exitCode=${_exitCode} (0x${_exitCode.toString(16)})`); 553 | }); 554 | Builder.Compiler = proc; 555 | const asyncSleep = (delay) => { 556 | return new Promise((resolve, reject) => { 557 | setTimeout(() => resolve(null), delay); 558 | }); 559 | } 560 | let waitCount = 0; 561 | while (exitCode == null) { 562 | waitCount += 1; 563 | let waitAmt = waitCount < 10 ? 10 : waitCount < 50 ? 25 : 50; 564 | await asyncSleep(waitAmt); 565 | } 566 | Builder.Compiler = null; 567 | return exitCode != 0; 568 | } 569 | const runUserCommandStep = async (name) => { 570 | let scriptRel = name + (isWindows?".bat":".sh"); 571 | if (await runUserCommandStep_1(scriptRel)) return true; 572 | for (let resName of extensionNames) { 573 | let res = project.yyResources[resName]; 574 | let esPath = $gmedit["haxe.io.Path"].directory(res.id.path) + "/" + scriptRel; 575 | if (await runUserCommandStep_1(esPath)) return true; 576 | } 577 | return false; 578 | } 579 | 580 | // Run the compiler! 581 | let compileStartTime = Date.now(); 582 | if (await runUserCommandStep("pre_build_step")) return; 583 | if (isWindows) { 584 | Builder.Compiler = Builder.Command.spawn(GMAssetCompilerPath, compilerArgs, { 585 | cwd: Builder.Runtime, 586 | }); 587 | } else if (DotNET6Flag) { 588 | Builder.Compiler = Builder.Command.spawn(GMAssetCompilerPath, compilerArgs); 589 | } else { 590 | Builder.Compiler = Builder.Command.spawn( 591 | "/Library/Frameworks/Mono.framework/Versions/Current/Commands/mono", 592 | [GMAssetCompilerPath].concat(compilerArgs) 593 | ); 594 | } 595 | 596 | // Capture compiler output! 597 | output.write(""); // (because stdout is appended raw) 598 | Builder.Compiler.stdout.on("data", (e) => { 599 | let text = e.toString(); 600 | switch (Builder.Parse(text, 0)) { 601 | case 1: Builder.Stop(); 602 | default: output.write(text, false); 603 | } 604 | }); 605 | Builder.Compiler.stderr.on("data", (e) => { 606 | let text = e.toString(); 607 | switch (Builder.Parse(text, 0)) { 608 | case 1: Builder.Stop(); 609 | default: output.write(text, false); 610 | } 611 | }); 612 | 613 | Builder.Compiler.on("close", async (exitCode) => { 614 | if (exitCode != 0 || Builder.Compiler == undefined || Builder.ErrorMet) { 615 | BuilderOutput.main.write(`Compile Ended: ${Builder.GetTime()} (${(Date.now() - compileStartTime)/1000}s)`); 616 | Builder.CleanRuntime(); 617 | if (BuilderPreferences.current.cleanOnError) Builder.CleanCache(); 618 | return; 619 | } 620 | BuilderOutput.main.write(`Compile Finished: ${Builder.GetTime()} (${(Date.now() - compileStartTime)/1000}s)`); 621 | 622 | // Rename output file! 623 | if (Name != Builder.Name || !isWindows) { 624 | let executableName = isWindows ? Name : "game"; 625 | Electron_FS.renameSync(`${Builder.Outpath}/${executableName}.${Builder.Extension}`, `${Builder.Outpath}/${Builder.Name}.${Builder.Extension}`); 626 | Electron_FS.renameSync(`${Builder.Outpath}/${executableName}.yydebug`, `${Builder.Outpath}/${Builder.Name}.yydebug`); 627 | } 628 | 629 | // Copy Steam API binary if needed: 630 | if (Electron_FS.existsSync(`${Builder.Outpath}/steam_appid.txt`) && steamworksPath) try { 631 | if (isWindows) { 632 | Electron_FS.copyFileSync(`${steamworksPath}/redistributable_bin/steam_api.dll`, `${Builder.Outpath}/steam_api.dll`); 633 | } else { 634 | // note: not tested at all 635 | Electron_FS.copyFileSync(`${steamworksPath}/redistributable_bin/osx32/libsteam_api.dylib`, `${Builder.Outpath}/libsteam_api.dylib`); 636 | } 637 | } catch (x) { 638 | console.error("Failed to copy steam_api:", x); 639 | } 640 | Builder.Compiler = undefined; 641 | 642 | if (await runUserCommandStep("post_build_step")) return; 643 | 644 | if (await runUserCommandStep("pre_run_step")) return; 645 | BuilderOutputAside.clearOnNextOpen = true; 646 | Builder.Runner.push(Builder.Spawn(Builder.Runtime, Builder.Outpath, Builder.Name, false, (_code) => { 647 | runUserCommandStep("post_run_step"); 648 | })); 649 | Builder.MenuItems.fork.enabled = true; 650 | if (autoRunFork) Builder.Fork(); 651 | }); 652 | } 653 | } 654 | --------------------------------------------------------------------------------