Last active
April 4, 2026 20:50
-
-
Save benhatsor/befd30f8c4b623fd641671c6dabbf7d3 to your computer and use it in GitHub Desktop.
Simple Minecraft Decompiler.
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
| /** | |
| * Simple Minecraft Decompiler | |
| * | |
| * Usage: | |
| * - Decompile latest version: `node simple-mc-decompiler.js` | |
| * - To specify version: `node simple-mc-decompiler.js [version]` | |
| * - version = `latest` or `latest-release` / `latest-snapshot` / [version name] | |
| * - Add `-s` or `--server` to decompile server | |
| */ | |
| import fs from 'node:fs/promises'; | |
| import { existsSync } from 'node:fs'; | |
| import { spawn } from 'node:child_process'; | |
| import { PassThrough } from 'node:stream'; | |
| import readline from 'node:readline'; | |
| import path from 'node:path'; | |
| import os from 'node:os'; | |
| const serverArgAliases = ['-s', '--server']; | |
| async function main() { | |
| // remove initial "node decompile.js" args | |
| // and clone array | |
| let args = process.argv.slice(2); | |
| let versionParam = 'latest'; | |
| if (args[0] && !serverArgAliases.includes(args[0])) { | |
| versionParam = args[0]; | |
| args.shift(); | |
| } | |
| const isServer = args.some(arg => | |
| serverArgAliases.includes(arg) | |
| ); | |
| const serverLog = isServer ? ' (server)' : ''; | |
| console.info(`Fetching version '${versionParam}'${serverLog}...`); | |
| const versionObj = await getVersionObj({ | |
| versionParam | |
| }); | |
| const versionJarURL = await getVersionJarURL({ | |
| versionObj, | |
| isServer | |
| }); | |
| await using tempDir = await fs.mkdtempDisposable( | |
| path.join(os.tmpdir(), 'decompiler-') // 'decompiler-' | |
| ); | |
| console.info(`Downloading ${versionObj.id}${serverLog} jar...`); | |
| const serverSuffix = isServer ? '-server' : ''; | |
| const versionJarName = `${versionObj.id}${serverSuffix}.jar`; | |
| let versionJarPath = path.join(tempDir.path, versionJarName); | |
| await downloadFile({ | |
| at: versionJarURL, | |
| to: versionJarPath | |
| }); | |
| if (isServer) { | |
| console.info(`Extracting server jar from bundle...`); | |
| const extractedBundleJarFolderName = `extractedBundle-${versionObj.id}${serverSuffix}`; | |
| const extractedBundleJarPath = path.join(tempDir.path, extractedBundleJarFolderName); | |
| const serverJarInnerPath = path.join( | |
| 'META-INF', 'versions', versionObj.id, `server-${versionObj.id}.jar` | |
| ); | |
| const serverJarPath = path.join(extractedBundleJarPath, serverJarInnerPath); | |
| await createDirIfNeccessary(extractedBundleJarPath); | |
| const { output, exitPromise } = executeCommand({ | |
| command: `jar`, | |
| args: [`xf`, path.resolve(versionJarPath), serverJarInnerPath], | |
| options: { | |
| cwd: extractedBundleJarPath | |
| } | |
| }); | |
| await logOverwritingOutput(output); | |
| await exitPromise; | |
| if (existsSync(serverJarPath)) { | |
| versionJarPath = serverJarPath; | |
| } else { | |
| console.info(`Couldn't extract server jar. Decompiling entire bundle instead.`); | |
| } | |
| } | |
| console.info(`Fetching latest Vineflower release...`); | |
| const vfReleaseResp = await fetch(`https://api.github.com/repos/Vineflower/vineflower/releases/latest`); | |
| const vfRelease = await vfReleaseResp.json(); | |
| console.info(`Downloading Vineflower ${vfRelease.tag_name}...`); | |
| const vineflowerJarName = `vineflower-${vfRelease.tag_name}.jar`; | |
| const vineflowerJarPath = path.join(tempDir.path, vineflowerJarName); | |
| const vfReleaseJar = vfRelease.assets.find(asset => asset.name === vineflowerJarName); | |
| const vfReleaseJarURL = vfReleaseJar.browser_download_url; | |
| await downloadFile({ | |
| at: vfReleaseJarURL, | |
| to: vineflowerJarPath | |
| }); | |
| console.info(`Decompiling ${versionObj.id}${serverLog} jar...`); | |
| const outputFolderName = `decompiled-${versionObj.id}${serverSuffix}`; | |
| const outputPath = path.resolve(outputFolderName); | |
| await createDirIfNeccessary(outputPath); | |
| await decompileFile({ | |
| vineflowerJarPath, versionJarPath, outputPath | |
| }); | |
| console.info(`Decompiled succesfully to: ${outputPath}`); | |
| } | |
| async function decompileFile({ vineflowerJarPath, versionJarPath, outputPath }) { | |
| const { output, exitPromise } = executeCommand({ | |
| command: `java`, | |
| args: [`-jar`, vineflowerJarPath, `-dgs=1`, `-asc=1`, versionJarPath, outputPath] | |
| }); | |
| await logOverwritingOutput(output); | |
| await exitPromise; | |
| const nestedJars = await findJars(outputPath); | |
| for (const jarPath of nestedJars) { | |
| const jarOutputPath = jarPath.replace(/\.jar$/, ''); | |
| await createDirIfNeccessary(jarOutputPath); | |
| await decompileFile({ | |
| vineflowerJarPath, | |
| versionJarPath: jarPath, | |
| outputPath: jarOutputPath | |
| }); | |
| await fs.rm(jarPath); | |
| } | |
| } | |
| async function findJars(dir) { | |
| const results = []; | |
| const entries = await fs.readdir(dir, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| const fullPath = path.join(dir, entry.name); | |
| if (entry.isDirectory()) { | |
| results.push(...await findJars(fullPath)); | |
| } else if (entry.name.endsWith('.jar')) { | |
| results.push(fullPath); | |
| } | |
| } | |
| return results; | |
| } | |
| const mcVersionManifestURL = | |
| 'https://piston-meta.mojang.com/mc/game/version_manifest.json'; | |
| const versionKeywords = { | |
| latest: { | |
| release: ['latest', 'latest-release'], | |
| snapshot: ['latest-snapshot'], | |
| } | |
| }; | |
| async function getVersionObj({ | |
| versionParam = 'latest' | |
| }) { | |
| const versionManifestResp = await fetch(mcVersionManifestURL); | |
| const versionManifest = await versionManifestResp.json(); | |
| let versionId = versionParam; | |
| if (versionKeywords.latest.release.includes(versionParam)) { | |
| versionId = versionManifest.latest.release; | |
| } else if (versionKeywords.latest.snapshot.includes(versionParam)) { | |
| versionId = versionManifest.latest.snapshot; | |
| } | |
| const versionObj = versionManifest.versions.find(version => | |
| version.id === versionId | |
| ); | |
| if (!versionObj) { | |
| throw new Error(`Version dosen't exist.`); | |
| } | |
| return versionObj; | |
| } | |
| async function getVersionJarURL({ versionObj, isServer = false }) { | |
| const versionResp = await fetch(versionObj.url); | |
| const version = await versionResp.json(); | |
| const versionType = isServer ? 'server' : 'client'; | |
| const versionJarURL = version.downloads[versionType].url; | |
| return versionJarURL; | |
| } | |
| async function downloadFile({ at: fileURL, to: filePath }) { | |
| const response = await fetch(fileURL); | |
| if (!response.ok || !response.body) { | |
| throw new Error(`Couldn't fetch file.`); | |
| } | |
| const totalBytes = Number(response.headers.get('content-length')) || 0; | |
| let downloadedBytes = 0; | |
| const fileHandle = await fs.open(filePath, 'w'); | |
| try { | |
| for await (const chunk of response.body) { | |
| await fileHandle.write(chunk); | |
| downloadedBytes += chunk.byteLength; | |
| if (totalBytes > 0) { | |
| const percent = ((downloadedBytes / totalBytes) * 100).toFixed(1); | |
| const mb = (downloadedBytes / 1e6).toFixed(2); | |
| const totalMb = (totalBytes / 1e6).toFixed(2); | |
| process.stdout.write(`\r ${mb} MB / ${totalMb} MB (${percent}%)`); | |
| } | |
| } | |
| process.stdout.write('\n'); | |
| } finally { | |
| await fileHandle.close(); | |
| } | |
| } | |
| async function createDirIfNeccessary(path) { | |
| if (!existsSync(path)) { | |
| await fs.mkdir(path, { recursive: true }); | |
| } | |
| } | |
| function executeCommand({ command, args = [], options = {} }) { | |
| const process = spawn(command, args, options); | |
| const output = new PassThrough(); | |
| output.setEncoding('utf8'); | |
| process.stdout.pipe(output); | |
| process.stderr.pipe(output); | |
| const { promise: exitPromise, resolve, reject } = Promise.withResolvers(); | |
| process.on('error', reject); | |
| process.on('close', (code) => { | |
| if (code !== 0) reject(new Error(`Process exited with code ${code}`)); | |
| else resolve(); | |
| }); | |
| return { | |
| process, | |
| output, | |
| exitPromise | |
| }; | |
| } | |
| async function logOverwritingOutput(output) { | |
| for await (const data of output) { | |
| const lines = data.trim().split('\n'); | |
| for (const line of lines) { | |
| readline.clearLine(process.stdout, 0); | |
| readline.cursorTo(process.stdout, 0); | |
| const prefix = ' '; | |
| let trimmedLine = prefix + line.trim(); | |
| // trim line to console width | |
| trimmedLine = trimmedLine.slice(0, process.stdout.columns); | |
| process.stdout.write(trimmedLine); | |
| } | |
| } | |
| readline.clearLine(process.stdout, 0); | |
| readline.cursorTo(process.stdout, 0); | |
| } | |
| main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment