From 11a838b40cfa1d95a1f08926fad134978b39c238 Mon Sep 17 00:00:00 2001 From: warman Date: Thu, 3 Apr 2025 19:42:06 -0400 Subject: [PATCH 1/7] chore: version override --- src/version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.ts b/src/version.ts index aa9605e..b4c7b13 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,3 +1,3 @@ // x-release-please-start-version -export const VERSION = '1.4.1' +export const VERSION = '1.4.1-hack-v2' // x-release-please-end From e89799b53d63474e2776e6b95d5e317ac4726d51 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Fri, 4 Apr 2025 20:12:43 +0200 Subject: [PATCH 2/7] feat: asset viewer (#52) * feat: asset viewer * fix: commit --- deno.jsonc | 3 +- deno.lock | 48 +++++++++++++++++++++++++- src/commands/asset.ts | 78 ++++++++++++++++++++++++++++++++++++++++++ src/commands/pkg.ts | 2 +- src/commands/script.ts | 4 +-- src/index.ts | 2 ++ src/lib/logger.ts | 10 +++--- 7 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 src/commands/asset.ts diff --git a/deno.jsonc b/deno.jsonc index 1893a42..3613a64 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -36,6 +36,7 @@ "@std/jsonc": "jsr:@std/jsonc@^1.0.1", "@std/path": "jsr:@std/path@^1.0.7", "@std/streams": "jsr:@std/streams@^1.0.7", - "@std/testing": "jsr:@std/testing@^1.0.5" + "@std/testing": "jsr:@std/testing@^1.0.5", + "ueblueprint":"npm:ueblueprint@2.0.0" } } diff --git a/deno.lock b/deno.lock index 46c9cbb..6837bf5 100644 --- a/deno.lock +++ b/deno.lock @@ -65,6 +65,7 @@ "npm:@types/node@*": "22.5.4", "npm:esbuild@0.20.2": "0.20.2", "npm:esbuild@0.24.0": "0.24.0", + "npm:ueblueprint@2.0.0": "2.0.0", "npm:zod-to-json-schema@*": "3.23.5_zod@3.23.8" }, "jsr": { @@ -418,12 +419,24 @@ "@esbuild/win32-x64@0.24.0": { "integrity": "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==" }, + "@lit-labs/ssr-dom-shim@1.3.0": { + "integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==" + }, + "@lit/reactive-element@1.6.3": { + "integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==", + "dependencies": [ + "@lit-labs/ssr-dom-shim" + ] + }, "@types/node@22.5.4": { "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", "dependencies": [ "undici-types" ] }, + "@types/trusted-types@2.0.7": { + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + }, "esbuild@0.20.2": { "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", "dependencies": [ @@ -481,6 +494,38 @@ "@esbuild/win32-x64@0.24.0" ] }, + "lit-element@3.3.3": { + "integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==", + "dependencies": [ + "@lit-labs/ssr-dom-shim", + "@lit/reactive-element", + "lit-html" + ] + }, + "lit-html@2.8.0": { + "integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==", + "dependencies": [ + "@types/trusted-types" + ] + }, + "lit@2.8.0": { + "integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==", + "dependencies": [ + "@lit/reactive-element", + "lit-element", + "lit-html" + ] + }, + "parsernostrum@1.2.6": { + "integrity": "sha512-Ho+y3yoqVCHRtqsKVqltsv17MgjP8Np+VIC8nd2cyEAsko5hNiZZpA6mi0krvfv8XmrS+tOpra83d4UctJtmQg==" + }, + "ueblueprint@2.0.0": { + "integrity": "sha512-M9xIAAl7H6pqkkwJ5pyDLiOUkMq7ti75RiD6W20Sp3e+VW1KhQ3M9H9BXyw61qF8hKjD2Y+CYTd260hGFWjJpg==", + "dependencies": [ + "lit", + "parsernostrum" + ] + }, "undici-types@6.19.8": { "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, @@ -680,7 +725,8 @@ "jsr:@std/jsonc@^1.0.1", "jsr:@std/path@^1.0.7", "jsr:@std/streams@^1.0.7", - "jsr:@std/testing@^1.0.5" + "jsr:@std/testing@^1.0.5", + "npm:ueblueprint@2.0.0" ] } } diff --git a/src/commands/asset.ts b/src/commands/asset.ts new file mode 100644 index 0000000..4290727 --- /dev/null +++ b/src/commands/asset.ts @@ -0,0 +1,78 @@ +import { Command } from '../deps.ts' +import type { CliOptions, GlobalOptions } from '../lib/types.ts' + +const url = Deno.env.get('RENDER_URL') || 'http://localhost:8787' + +const blueprint = new Command().description('Visualize your blueprint') + .option( + '-b, --blueprint ', + 'Path to the input file', + { required: true }, + ) + .option('-o, --output ', 'Path to the output in local', { + default: 'index.html', + }) + .option('-l, --local', 'Local export', { default: true }) + .action( + async (options) => { + if (!options.blueprint) { + console.log('Input file path is required') + return + } + const data = Deno.readFileSync(options.blueprint) + const decoder = new TextDecoder('utf-8') + const bluePrint = decoder.decode(data) + if (!options.local) { + // Send bluePrint to the server + + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ blueprint: bluePrint }), + }) + const blob = await res.blob() + + Deno.writeFileSync('result.png', await blob.bytes()) + } else { + const encoder = new TextEncoder() + const html = ` + + + + + UE Blueprint + + + + + + + + + + + + + + ` + + Deno.writeFileSync(options.output, encoder.encode(html)) + } + }, + ) + +export const asset = new Command().description('View uasset').action(function () { + this.showHelp() +}).command('blueprint', blueprint) diff --git a/src/commands/pkg.ts b/src/commands/pkg.ts index fe9e8a9..ca38273 100644 --- a/src/commands/pkg.ts +++ b/src/commands/pkg.ts @@ -58,7 +58,7 @@ export const pkg = new Command() .option('--profile ', 'Build profile', { default: 'client', required: true }) .action(async (options) => { const { platform, configuration, dryRun, profile, archiveDirectory, zip } = options as PkgOptions - const cfg = await Config.getInstance() + const cfg = Config.getInstance() const { engine: { path: enginePath }, project: { path: projectPath } } = cfg.mergeConfigCLIConfig({ cliOptions: options, }) diff --git a/src/commands/script.ts b/src/commands/script.ts index 41a9c7d..642aeca 100644 --- a/src/commands/script.ts +++ b/src/commands/script.ts @@ -4,7 +4,7 @@ import { logger } from '../lib/logger.ts' import * as esbuild from 'https://deno.land/x/esbuild@v0.24.0/mod.js' -import { denoPlugins } from 'jsr:@luca/esbuild-deno-loader@0.11.0' +import { denoPlugins } from 'jsr:@luca/esbuild-deno-loader@0.11.1' import { Config } from '../lib/config.ts' export const script = new Command() @@ -42,7 +42,7 @@ export const script = new Command() const script = (await import(builtOutput)) as Script await script.main(context) - } catch (e) { + } catch (e: any) { logger.error(e) Deno.exit(1) } diff --git a/src/index.ts b/src/index.ts index 12efa62..5ccb0ec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { workflow } from './commands/workflow/index.ts' import { clean } from './commands/clean.ts' import { cmd } from './cmd.ts' import { script } from './commands/script.ts' +import { asset } from './commands/asset.ts' await cmd .name('runreal') @@ -31,4 +32,5 @@ await cmd .command('buildgraph', buildgraph) .command('workflow', workflow) .command('script', script) + .command('asset', asset) .parse(Deno.args) diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 45a16c5..36a0bcc 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -49,10 +49,10 @@ class Logger { this.logLevel = level } - private formatMessage(level: LogLevel, message: any) { + private formatMessage(level: LogLevel, message: string) { const timestamp = new Date().toISOString() const messageStr = typeof message === 'object' ? `\n${Deno.inspect(message, { colors: true })}` : message - let levelStr + let levelStr: string switch (level) { case LogLevel.INFO: levelStr = fmt.bold(fmt.green(level)) @@ -93,21 +93,21 @@ class Logger { return level >= this.logLevel } - info(message: any) { + info(message: string) { if (!this.shouldLog(LogLevel.INFO)) return const formatted = this.formatMessage(LogLevel.INFO, message) console.log(formatted) this.writeToFile(formatted) } - error(message: any) { + error(message: string) { if (!this.shouldLog(LogLevel.ERROR)) return const formatted = this.formatMessage(LogLevel.ERROR, message) console.error(formatted) this.writeToFile(formatted) } - debug(message: any) { + debug(message: string) { if (!this.shouldLog(LogLevel.DEBUG)) return const formatted = this.formatMessage(LogLevel.DEBUG, message) console.log(formatted) From d4b64d2197a87e5b2a7563e2921e3793a52b6fd4 Mon Sep 17 00:00:00 2001 From: warman Date: Fri, 4 Apr 2025 15:47:25 -0400 Subject: [PATCH 3/7] feat: runpython command --- src/commands/runpython.ts | 75 +++++++++++++++++++++++++++++++++++++++ src/index.ts | 2 ++ src/lib/engine.ts | 34 ++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 src/commands/runpython.ts diff --git a/src/commands/runpython.ts b/src/commands/runpython.ts new file mode 100644 index 0000000..8da9e85 --- /dev/null +++ b/src/commands/runpython.ts @@ -0,0 +1,75 @@ +import { Command } from '../deps.ts' +import { createEngine } from '../lib/engine.ts' +import { findProjectFile } from '../lib/utils.ts' +import { Config } from '../lib/config.ts' +import { logger } from '../lib/logger.ts' +import type { GlobalOptions } from '../lib/types.ts' +import { path } from '../deps.ts' +import { exec } from '../lib/utils.ts' +import { Engine, getEditorPath } from '../lib/engine.ts' + +export type RunPythonOptions = typeof runpython extends Command ? Options + : never + +export const runpython = new Command() + .description('Run Python script in Unreal Engine headless mode') + .option('-s, --script ', 'Path to Python script', { required: true }) + .option('--stdout', 'Redirect output to stdout', { default: true }) + .option('--nosplash', 'Skip splash screen', { default: true }) + .option('--nopause', 'Don\'t pause after execution', { default: true }) + .option('--nosound', 'Disable sound', { default: true }) + .option('--args ', 'Additional arguments to pass to the script') + .action(async (options) => { + const config = Config.getInstance() + const { engine: { path: enginePath }, project: { path: projectPath } } = config.mergeConfigCLIConfig({ + cliOptions: options, + }) + + const projectFile = await findProjectFile(projectPath).catch(() => { + logger.error(`Could not find project file in ${projectPath}`) + Deno.exit(1) + }) + + if (!projectFile) { + logger.error(`Could not find project file in ${projectPath}`) + Deno.exit(1) + } + + // Resolve absolute path to script + const scriptPath = path.resolve(options.script) + + // Get the editor executable path based on platform + const currentPlatform = Engine.getCurrentPlatform() + const editorExePath = getEditorPath(enginePath, currentPlatform) + + // Build command arguments + const args = [ + projectFile, + '-run=pythonscript', + `-script="${scriptPath}"`, + '-unattended', + ] + + // Add optional flags + if (options.stdout) args.push('-stdout') + if (options.nosplash) args.push('-nosplash') + if (options.nopause) args.push('-nopause') + if (options.nosound) args.push('-nosound') + + // Add any additional arguments + if (options.args) { + args.push(options.args) + } + + logger.info(`Running Python script: ${scriptPath}`) + logger.debug(`Full command: ${editorExePath} ${args.join(' ')}`) + + try { + const result = await exec(editorExePath, args) + return result + } catch (error: unknown) { + logger.error(`Error running Python script: ${error instanceof Error ? error.message : String(error)}`) + Deno.exit(1) + } + }) + diff --git a/src/index.ts b/src/index.ts index 5ccb0ec..5d2d8b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { clean } from './commands/clean.ts' import { cmd } from './cmd.ts' import { script } from './commands/script.ts' import { asset } from './commands/asset.ts' +import { runpython } from './commands/runpython.ts' await cmd .name('runreal') @@ -33,4 +34,5 @@ await cmd .command('workflow', workflow) .command('script', script) .command('asset', asset) + .command('runpython', runpython) .parse(Deno.args) diff --git a/src/lib/engine.ts b/src/lib/engine.ts index d509d99..686d8bd 100644 --- a/src/lib/engine.ts +++ b/src/lib/engine.ts @@ -415,3 +415,37 @@ export function createEngine(enginePath: string): Engine { throw new Error(`Unsupported platform: ${Deno.build.os}`) } } + +/** + * Get the platform-specific path to the Unreal Editor executable + */ +export function getEditorPath(enginePath: string, platform: EnginePlatform): string { + switch (platform) { + case EnginePlatform.Windows: + return path.join( + enginePath, + 'Engine', + 'Binaries', + 'Win64', + 'UnrealEditor.exe' + ) + case EnginePlatform.Mac: + return path.join( + enginePath, + 'Engine', + 'Binaries', + 'Mac', + 'UnrealEditor' + ) + case EnginePlatform.Linux: + return path.join( + enginePath, + 'Engine', + 'Binaries', + 'Linux', + 'UnrealEditor' + ) + default: + throw new Error(`Unsupported platform: ${platform}`) + } +} \ No newline at end of file From 7d1f16ff8323522cbe0b7ba00715e4e5c5634014 Mon Sep 17 00:00:00 2001 From: warman Date: Fri, 4 Apr 2025 20:45:08 -0400 Subject: [PATCH 4/7] feat: cursed ai code to parse blueprint data --- src/commands/uasset/extract-eventgraph.ts | 112 ++++++++++ src/commands/uasset/index.ts | 15 ++ src/commands/uasset/parse.ts | 243 ++++++++++++++++++++++ src/commands/uasset/render-blueprint.ts | 41 ++++ src/index.ts | 4 +- src/lib/utils.ts | 36 ++++ 6 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 src/commands/uasset/extract-eventgraph.ts create mode 100644 src/commands/uasset/index.ts create mode 100644 src/commands/uasset/parse.ts create mode 100644 src/commands/uasset/render-blueprint.ts diff --git a/src/commands/uasset/extract-eventgraph.ts b/src/commands/uasset/extract-eventgraph.ts new file mode 100644 index 0000000..7b11807 --- /dev/null +++ b/src/commands/uasset/extract-eventgraph.ts @@ -0,0 +1,112 @@ +import { Command } from '../../deps.ts' +import type { GlobalOptions } from '../../lib/types.ts' +import { generateBlueprintHtml } from '../../lib/utils.ts' +import { logger } from '../../lib/logger.ts' + +export type ExtractEventGraphOptions = typeof extractEventGraph extends + Command, [], GlobalOptions> ? Options + : never + +/** + * Find the EventGraph section in the exported uasset file as is + * @param fileContent The content of the .copy file as a string + * @returns The EventGraph section as a string, or null if not found + */ +function findEventGraph(fileContent: string): string | null { + // Find the "Begin Object Name="EventGraph"" section + const lines = fileContent.split('\n'); + const eventGraphNodes: string[] = []; + + let insideEventGraph = false; + let insideNodeSection = false; + let nodeCount = 0; + let currentObject = ''; + let insideBeginObjectBlock = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + if (!line) continue; + + // Check if we're starting a Begin Object Name="EventGraph" section + if (line.startsWith('Begin Object Name="EventGraph"')) { + insideEventGraph = true; + nodeCount = 0; + insideNodeSection = false; + continue; + } + + // Skip the end of the EventGraph parent object + if (insideEventGraph && line === 'End Object' && !insideBeginObjectBlock && !insideNodeSection) { + insideEventGraph = false; + continue; + } + + // Check if we're inside an EventGraph and starting a node section with Begin Object Name="..." + if (insideEventGraph && line.startsWith('Begin Object Name="K2Node_')) { + nodeCount++; + currentObject = line; + eventGraphNodes.push(currentObject); + insideBeginObjectBlock = true; + continue; + } + + // Process EventGraph child blocks that define nodes + if (insideEventGraph && line.startsWith('Begin Object Class="/Script/BlueprintGraph.K2Node_')) { + nodeCount++; + currentObject = line; + eventGraphNodes.push(currentObject); + insideBeginObjectBlock = true; + continue; + } + + // If we're inside a node definition in the EventGraph, keep collecting its content + if (insideEventGraph && insideBeginObjectBlock && !line.startsWith('Begin Object') && !line.startsWith('End Object')) { + eventGraphNodes.push(' ' + line); + continue; + } + + // Handle the end of a node's Begin/End Object block + if (insideEventGraph && insideBeginObjectBlock && line === 'End Object') { + eventGraphNodes.push(line); + insideBeginObjectBlock = false; + currentObject = ''; + continue; + } + } + + // Return the extracted EventGraph nodes, or null if none found + return nodeCount > 0 ? eventGraphNodes.join('\n') : null; +} + +export const extractEventGraph = new Command() + .description('extract event graph from exported uasset') + .option('-i, --input ', 'Path to the exported uasset file', { required: true }) + .option('-o, --output ', 'Output file path extracted event graph', { required: false }) + .option('-r, --render', 'Save the output as rendered html', { default: false }) + .action((options) => { + try { + const data = Deno.readTextFileSync(options.input); + const eventGraph = findEventGraph(data); + if (eventGraph) { + if (options.output) { + if (options.render) { + const html = generateBlueprintHtml(eventGraph) + Deno.writeTextFileSync(options.output, html) + } else { + Deno.writeTextFileSync(options.output, eventGraph); + } + logger.info(`EventGraph extracted to ${options.output}`); + } else { + logger.info(eventGraph); + } + } else { + logger.error('No EventGraph found in the file'); + Deno.exit(1); + } + return; + } catch (error: unknown) { + logger.error(`Error parsing blueprint: ${error instanceof Error ? error.message : String(error)}`); + Deno.exit(1); + } + }) diff --git a/src/commands/uasset/index.ts b/src/commands/uasset/index.ts new file mode 100644 index 0000000..ac839d3 --- /dev/null +++ b/src/commands/uasset/index.ts @@ -0,0 +1,15 @@ +import { Command } from '../../deps.ts' +import type { GlobalOptions } from '../../lib/types.ts' + +import { parse } from './parse.ts' +import { renderBlueprint } from './render-blueprint.ts' +import { extractEventGraph } from './extract-eventgraph.ts' + +export const uasset = new Command() + .description('uasset') + .action(function () { + this.showHelp() + }) + .command('parse', parse) + .command('render-blueprint', renderBlueprint) + .command('extract-eventgraph', extractEventGraph) diff --git a/src/commands/uasset/parse.ts b/src/commands/uasset/parse.ts new file mode 100644 index 0000000..4f81783 --- /dev/null +++ b/src/commands/uasset/parse.ts @@ -0,0 +1,243 @@ +import { Command } from '../../deps.ts' +import type { GlobalOptions } from '../../lib/types.ts' +import { logger } from '../../lib/logger.ts' + +export type ParseOptions = typeof parse extends + Command, [], GlobalOptions> ? Options + : never + +/** + * Normalizes path strings in Unreal Engine format by handling escaped slashes + * @param path The path string to normalize + * @returns Normalized path + */ +function normalizePath(path: string): string { + if (!path) return path; + + // If the path is enclosed in quotes, remove them + if (path.startsWith('"') && path.endsWith('"')) { + path = path.substring(1, path.length - 1); + } + + // Replace escaped backslashes with forward slashes + return path.replace(/\\\\/g, '/'); +} + +/** + * Handles string values by removing quotes and normalizing paths + * @param value String value to process + * @returns Processed string + */ +function processStringValue(value: string): string { + if (!value) return value; + + // If it's a quoted string, unquote it + if (value.startsWith('"') && value.endsWith('"')) { + value = value.substring(1, value.length - 1); + } + + // If it looks like a path (contains /Script/ or has forward slashes) + if (value.includes('/Script/') || value.includes('/') || value.includes('\\')) { + return normalizePath(value); + } + + return value; +} + +/** + * Parses an Unreal Engine Blueprint .copy file into a JavaScript object + * @param fileContent The content of the .copy file as a string + * @returns The parsed blueprint object + */ +function parseBlueprint(fileContent: string) { + const lines = fileContent.split('\n'); + const stack: any[] = []; + let currentObject: any = null; + const rootObjects: any[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + if (line.startsWith('Begin Object')) { + // Extract Class and Name using regex + const classMatch = line.match(/Class=([^\s"]+)/); + const nameMatch = line.match(/Name="([^"]+)"/); + const exportPathMatch = line.match(/ExportPath="([^"]+)"/); + + const newObject: any = { + type: 'object', + properties: {}, + children: [] + }; + + if (classMatch) newObject.class = normalizePath(classMatch[1]); + if (nameMatch) newObject.name = nameMatch[1]; + if (exportPathMatch) newObject.exportPath = normalizePath(exportPathMatch[1]); + + if (currentObject) { + stack.push(currentObject); + currentObject.children.push(newObject); + } else { + rootObjects.push(newObject); + } + currentObject = newObject; + } else if (line.startsWith('End Object')) { + if (stack.length > 0) { + currentObject = stack.pop(); + } else { + currentObject = null; + } + } else if (currentObject && line.includes('=')) { + // Handle property lines + const equalPos = line.indexOf('='); + const key = line.substring(0, equalPos).trim(); + const value = line.substring(equalPos + 1).trim(); + + // Special handling for complex properties like arrays, parenthesized values, etc. + if (value.startsWith('(') && !value.endsWith(')')) { + // This is a multi-line property + let complexValue = value; + while (!complexValue.endsWith(')') && i < lines.length - 1) { + i++; + complexValue += ' ' + lines[i].trim(); + } + currentObject.properties[key] = parseComplexProperty(complexValue); + } else if (value.startsWith('(')) { + // Parse complex property with parentheses into an object + const parsedValue = parseComplexProperty(value); + currentObject.properties[key] = parsedValue; + } else if (key.startsWith('CustomProperties')) { + // Handle CustomProperties as a special case + if (!currentObject.customProperties) { + currentObject.customProperties = []; + } + currentObject.customProperties.push(processStringValue(value)); + } else if (key.includes('(')) { + // Handle array properties like Nodes(0) + const arrayMatch = key.match(/([^\(]+)\((\d+)\)/); + if (arrayMatch) { + const arrayName = arrayMatch[1]; + const arrayIndex = parseInt(arrayMatch[2]); + + if (!currentObject.properties[arrayName]) { + currentObject.properties[arrayName] = []; + } + + // Ensure array has enough elements + while (currentObject.properties[arrayName].length <= arrayIndex) { + currentObject.properties[arrayName].push(null); + } + + // Process string values in array elements + const processedValue = value.startsWith('"') || + value.includes('/') || + value.includes('\\') ? + processStringValue(value) : value; + + currentObject.properties[arrayName][arrayIndex] = processedValue; + } else { + currentObject.properties[key] = processStringValue(value); + } + } else { + currentObject.properties[key] = processStringValue(value); + } + } + } + + return rootObjects.length === 1 ? rootObjects[0] : rootObjects; +} + +/** + * Parse complex properties with parentheses, typically representing structs + * @param value The complex property value string + * @returns Parsed object representation + */ +function parseComplexProperty(value: string): any { + if (!value.startsWith('(') || !value.endsWith(')')) { + return processStringValue(value); + } + + // Remove outer parentheses + const content = value.substring(1, value.length - 1); + + // Split by commas, handling nested parentheses + const parts: string[] = []; + let currentPart = ''; + let depth = 0; + let inQuotes = false; + + for (let i = 0; i < content.length; i++) { + const char = content[i]; + + // Handle quotes to avoid splitting inside quoted strings + if (char === '"' && (i === 0 || content[i-1] !== '\\')) { + inQuotes = !inQuotes; + } + + if (!inQuotes) { + if (char === '(') depth++; + else if (char === ')') depth--; + else if (char === ',' && depth === 0) { + parts.push(currentPart.trim()); + currentPart = ''; + continue; + } + } + + currentPart += char; + } + + if (currentPart) { + parts.push(currentPart.trim()); + } + + // Convert parts to key-value pairs + const result: Record = {}; + + for (const part of parts) { + const equalPos = part.indexOf('='); + if (equalPos !== -1) { + const key = part.substring(0, equalPos).trim(); + const val = part.substring(equalPos + 1).trim(); + + // Recursively parse nested complex properties + if (val.startsWith('(') && val.endsWith(')')) { + result[key] = parseComplexProperty(val); + } else { + result[key] = processStringValue(val); + } + } else { + // Handle valueless properties or booleans + result[part.trim()] = true; + } + } + + return result; +} + +export const parse = new Command() + .description('parse exported uasset') + .option('-i, --input ', 'Path to the exported uasset file', { required: true }) + .option('-o, --output ', 'Output file path for the JSON result', { required: false }) + .option('--pretty', 'Pretty print the JSON output', { default: true }) + .action((options) => { + try { + const data = Deno.readTextFileSync(options.input); + const parsedObject = parseBlueprint(data); + + const jsonOutput = options.pretty + ? JSON.stringify(parsedObject, null, 2) + : JSON.stringify(parsedObject); + + if (options.output) { + Deno.writeTextFileSync(options.output, jsonOutput); + logger.info(`Parsed blueprint written to ${options.output}`); + } else { + console.log(jsonOutput); + } + } catch (error: unknown) { + logger.error(`Error parsing blueprint: ${error instanceof Error ? error.message : String(error)}`); + Deno.exit(1); + } + }) diff --git a/src/commands/uasset/render-blueprint.ts b/src/commands/uasset/render-blueprint.ts new file mode 100644 index 0000000..823aa68 --- /dev/null +++ b/src/commands/uasset/render-blueprint.ts @@ -0,0 +1,41 @@ +import { Command } from '../../deps.ts' +import type { GlobalOptions } from '../../lib/types.ts' +import { generateBlueprintHtml } from '../../lib/utils.ts' + +const url = Deno.env.get('RENDER_URL') || 'http://localhost:8787' + +export const renderBlueprint = new Command() + .description('Render your blueprint') + .option( + '-b, --blueprint ', + 'Path to the input file', + { required: true }, + ) + .option('-o, --output ', 'Path to the output in local', { + default: 'index.html', + }) + .option('-l, --local', 'Local export', { default: true }) + .action( + async (options) => { + const data = Deno.readFileSync(options.blueprint) + const decoder = new TextDecoder('utf-8') + const blueprint = decoder.decode(data) + if (!options.local) { + // Send blueprint to the server + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ blueprint }), + }) + const blob = await res.blob() + + Deno.writeFileSync('result.png', await blob.bytes()) + } else { + const encoder = new TextEncoder() + const html = generateBlueprintHtml(blueprint) + Deno.writeFileSync(options.output, encoder.encode(html)) + } + }, + ) diff --git a/src/index.ts b/src/index.ts index 5d2d8b8..dccffd8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,8 +12,8 @@ import { workflow } from './commands/workflow/index.ts' import { clean } from './commands/clean.ts' import { cmd } from './cmd.ts' import { script } from './commands/script.ts' -import { asset } from './commands/asset.ts' import { runpython } from './commands/runpython.ts' +import { uasset } from './commands/uasset/index.ts' await cmd .name('runreal') @@ -33,6 +33,6 @@ await cmd .command('buildgraph', buildgraph) .command('workflow', workflow) .command('script', script) - .command('asset', asset) .command('runpython', runpython) + .command('uasset', uasset) .parse(Deno.args) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 3951453..5751caa 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -372,3 +372,39 @@ export function formatIsoTimestamp( ): string { return new Intl.DateTimeFormat(locale, options).format(new Date(ts)) } + +export const generateBlueprintHtml = (blueprint: string) => { + const html = ` + + + + + UE Blueprint + + + + + + + + + + + + + +` + return html +} + + From b8740b752484709641085fe9b0bf8915f40d6b7b Mon Sep 17 00:00:00 2001 From: warman Date: Fri, 4 Apr 2025 20:53:02 -0400 Subject: [PATCH 5/7] chore: save exported html to same location --- src/commands/uasset/extract-eventgraph.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/commands/uasset/extract-eventgraph.ts b/src/commands/uasset/extract-eventgraph.ts index 7b11807..cc8e6dc 100644 --- a/src/commands/uasset/extract-eventgraph.ts +++ b/src/commands/uasset/extract-eventgraph.ts @@ -2,6 +2,7 @@ import { Command } from '../../deps.ts' import type { GlobalOptions } from '../../lib/types.ts' import { generateBlueprintHtml } from '../../lib/utils.ts' import { logger } from '../../lib/logger.ts' +import * as path from 'jsr:@std/path' export type ExtractEventGraphOptions = typeof extractEventGraph extends Command, [], GlobalOptions> ? Options @@ -82,21 +83,18 @@ function findEventGraph(fileContent: string): string | null { export const extractEventGraph = new Command() .description('extract event graph from exported uasset') .option('-i, --input ', 'Path to the exported uasset file', { required: true }) - .option('-o, --output ', 'Output file path extracted event graph', { required: false }) .option('-r, --render', 'Save the output as rendered html', { default: false }) .action((options) => { try { const data = Deno.readTextFileSync(options.input); const eventGraph = findEventGraph(data); if (eventGraph) { - if (options.output) { - if (options.render) { - const html = generateBlueprintHtml(eventGraph) - Deno.writeTextFileSync(options.output, html) - } else { - Deno.writeTextFileSync(options.output, eventGraph); - } - logger.info(`EventGraph extracted to ${options.output}`); + if (options.render) { + const html = generateBlueprintHtml(eventGraph) + const basename = path.basename(options.input, path.extname(options.input)) + const basepath = path.dirname(options.input) + Deno.writeTextFileSync(`${basepath}/${basename}.html`, html) + logger.info(`EventGraph extracted to ${basename}.html`); } else { logger.info(eventGraph); } From 093bff8b93b48723928cd64cee68ef5bd6cfb68d Mon Sep 17 00:00:00 2001 From: warman Date: Fri, 4 Apr 2025 20:53:27 -0400 Subject: [PATCH 6/7] chore: fmt --- src/commands/runpython.ts | 24 +- src/commands/uasset/extract-eventgraph.ts | 178 +++++----- src/commands/uasset/index.ts | 4 +- src/commands/uasset/parse.ts | 395 +++++++++++----------- src/commands/uasset/render-blueprint.ts | 2 +- src/lib/engine.ts | 8 +- src/lib/utils.ts | 2 - 7 files changed, 306 insertions(+), 307 deletions(-) diff --git a/src/commands/runpython.ts b/src/commands/runpython.ts index 8da9e85..9ed6f0e 100644 --- a/src/commands/runpython.ts +++ b/src/commands/runpython.ts @@ -8,7 +8,8 @@ import { path } from '../deps.ts' import { exec } from '../lib/utils.ts' import { Engine, getEditorPath } from '../lib/engine.ts' -export type RunPythonOptions = typeof runpython extends Command ? Options +export type RunPythonOptions = typeof runpython extends + Command ? Options : never export const runpython = new Command() @@ -16,7 +17,7 @@ export const runpython = new Command() .option('-s, --script ', 'Path to Python script', { required: true }) .option('--stdout', 'Redirect output to stdout', { default: true }) .option('--nosplash', 'Skip splash screen', { default: true }) - .option('--nopause', 'Don\'t pause after execution', { default: true }) + .option('--nopause', "Don't pause after execution", { default: true }) .option('--nosound', 'Disable sound', { default: true }) .option('--args ', 'Additional arguments to pass to the script') .action(async (options) => { @@ -24,24 +25,24 @@ export const runpython = new Command() const { engine: { path: enginePath }, project: { path: projectPath } } = config.mergeConfigCLIConfig({ cliOptions: options, }) - + const projectFile = await findProjectFile(projectPath).catch(() => { logger.error(`Could not find project file in ${projectPath}`) Deno.exit(1) }) - + if (!projectFile) { logger.error(`Could not find project file in ${projectPath}`) Deno.exit(1) } - + // Resolve absolute path to script const scriptPath = path.resolve(options.script) - + // Get the editor executable path based on platform const currentPlatform = Engine.getCurrentPlatform() const editorExePath = getEditorPath(enginePath, currentPlatform) - + // Build command arguments const args = [ projectFile, @@ -49,21 +50,21 @@ export const runpython = new Command() `-script="${scriptPath}"`, '-unattended', ] - + // Add optional flags if (options.stdout) args.push('-stdout') if (options.nosplash) args.push('-nosplash') if (options.nopause) args.push('-nopause') if (options.nosound) args.push('-nosound') - + // Add any additional arguments if (options.args) { args.push(options.args) } - + logger.info(`Running Python script: ${scriptPath}`) logger.debug(`Full command: ${editorExePath} ${args.join(' ')}`) - + try { const result = await exec(editorExePath, args) return result @@ -72,4 +73,3 @@ export const runpython = new Command() Deno.exit(1) } }) - diff --git a/src/commands/uasset/extract-eventgraph.ts b/src/commands/uasset/extract-eventgraph.ts index cc8e6dc..f880d67 100644 --- a/src/commands/uasset/extract-eventgraph.ts +++ b/src/commands/uasset/extract-eventgraph.ts @@ -10,101 +10,103 @@ export type ExtractEventGraphOptions = typeof extractEventGraph extends /** * Find the EventGraph section in the exported uasset file as is - * @param fileContent The content of the .copy file as a string + * @param fileContent The content of the .copy file as a string * @returns The EventGraph section as a string, or null if not found */ function findEventGraph(fileContent: string): string | null { - // Find the "Begin Object Name="EventGraph"" section - const lines = fileContent.split('\n'); - const eventGraphNodes: string[] = []; - - let insideEventGraph = false; - let insideNodeSection = false; - let nodeCount = 0; - let currentObject = ''; - let insideBeginObjectBlock = false; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - - if (!line) continue; - - // Check if we're starting a Begin Object Name="EventGraph" section - if (line.startsWith('Begin Object Name="EventGraph"')) { - insideEventGraph = true; - nodeCount = 0; - insideNodeSection = false; - continue; - } - - // Skip the end of the EventGraph parent object - if (insideEventGraph && line === 'End Object' && !insideBeginObjectBlock && !insideNodeSection) { - insideEventGraph = false; - continue; - } - - // Check if we're inside an EventGraph and starting a node section with Begin Object Name="..." - if (insideEventGraph && line.startsWith('Begin Object Name="K2Node_')) { - nodeCount++; - currentObject = line; - eventGraphNodes.push(currentObject); - insideBeginObjectBlock = true; - continue; - } - - // Process EventGraph child blocks that define nodes - if (insideEventGraph && line.startsWith('Begin Object Class="/Script/BlueprintGraph.K2Node_')) { - nodeCount++; - currentObject = line; - eventGraphNodes.push(currentObject); - insideBeginObjectBlock = true; - continue; - } - - // If we're inside a node definition in the EventGraph, keep collecting its content - if (insideEventGraph && insideBeginObjectBlock && !line.startsWith('Begin Object') && !line.startsWith('End Object')) { - eventGraphNodes.push(' ' + line); - continue; - } - - // Handle the end of a node's Begin/End Object block - if (insideEventGraph && insideBeginObjectBlock && line === 'End Object') { - eventGraphNodes.push(line); - insideBeginObjectBlock = false; - currentObject = ''; - continue; - } - } - - // Return the extracted EventGraph nodes, or null if none found - return nodeCount > 0 ? eventGraphNodes.join('\n') : null; + // Find the "Begin Object Name="EventGraph"" section + const lines = fileContent.split('\n') + const eventGraphNodes: string[] = [] + + let insideEventGraph = false + let insideNodeSection = false + let nodeCount = 0 + let currentObject = '' + let insideBeginObjectBlock = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim() + + if (!line) continue + + // Check if we're starting a Begin Object Name="EventGraph" section + if (line.startsWith('Begin Object Name="EventGraph"')) { + insideEventGraph = true + nodeCount = 0 + insideNodeSection = false + continue + } + + // Skip the end of the EventGraph parent object + if (insideEventGraph && line === 'End Object' && !insideBeginObjectBlock && !insideNodeSection) { + insideEventGraph = false + continue + } + + // Check if we're inside an EventGraph and starting a node section with Begin Object Name="..." + if (insideEventGraph && line.startsWith('Begin Object Name="K2Node_')) { + nodeCount++ + currentObject = line + eventGraphNodes.push(currentObject) + insideBeginObjectBlock = true + continue + } + + // Process EventGraph child blocks that define nodes + if (insideEventGraph && line.startsWith('Begin Object Class="/Script/BlueprintGraph.K2Node_')) { + nodeCount++ + currentObject = line + eventGraphNodes.push(currentObject) + insideBeginObjectBlock = true + continue + } + + // If we're inside a node definition in the EventGraph, keep collecting its content + if ( + insideEventGraph && insideBeginObjectBlock && !line.startsWith('Begin Object') && !line.startsWith('End Object') + ) { + eventGraphNodes.push(' ' + line) + continue + } + + // Handle the end of a node's Begin/End Object block + if (insideEventGraph && insideBeginObjectBlock && line === 'End Object') { + eventGraphNodes.push(line) + insideBeginObjectBlock = false + currentObject = '' + continue + } + } + + // Return the extracted EventGraph nodes, or null if none found + return nodeCount > 0 ? eventGraphNodes.join('\n') : null } export const extractEventGraph = new Command() .description('extract event graph from exported uasset') .option('-i, --input ', 'Path to the exported uasset file', { required: true }) - .option('-r, --render', 'Save the output as rendered html', { default: false }) + .option('-r, --render', 'Save the output as rendered html', { default: false }) .action((options) => { - try { - const data = Deno.readTextFileSync(options.input); - const eventGraph = findEventGraph(data); - if (eventGraph) { - if (options.render) { - const html = generateBlueprintHtml(eventGraph) - const basename = path.basename(options.input, path.extname(options.input)) - const basepath = path.dirname(options.input) - Deno.writeTextFileSync(`${basepath}/${basename}.html`, html) - logger.info(`EventGraph extracted to ${basename}.html`); - } else { - logger.info(eventGraph); - } - } else { - logger.error('No EventGraph found in the file'); - Deno.exit(1); - } - return; - } catch (error: unknown) { - logger.error(`Error parsing blueprint: ${error instanceof Error ? error.message : String(error)}`); - Deno.exit(1); - } + try { + const data = Deno.readTextFileSync(options.input) + const eventGraph = findEventGraph(data) + if (eventGraph) { + if (options.render) { + const html = generateBlueprintHtml(eventGraph) + const basename = path.basename(options.input, path.extname(options.input)) + const basepath = path.dirname(options.input) + Deno.writeTextFileSync(`${basepath}/${basename}.html`, html) + logger.info(`EventGraph extracted to ${basename}.html`) + } else { + logger.info(eventGraph) + } + } else { + logger.error('No EventGraph found in the file') + Deno.exit(1) + } + return + } catch (error: unknown) { + logger.error(`Error parsing blueprint: ${error instanceof Error ? error.message : String(error)}`) + Deno.exit(1) + } }) diff --git a/src/commands/uasset/index.ts b/src/commands/uasset/index.ts index ac839d3..9b26f86 100644 --- a/src/commands/uasset/index.ts +++ b/src/commands/uasset/index.ts @@ -3,7 +3,7 @@ import type { GlobalOptions } from '../../lib/types.ts' import { parse } from './parse.ts' import { renderBlueprint } from './render-blueprint.ts' -import { extractEventGraph } from './extract-eventgraph.ts' +import { extractEventGraph } from './extract-eventgraph.ts' export const uasset = new Command() .description('uasset') @@ -12,4 +12,4 @@ export const uasset = new Command() }) .command('parse', parse) .command('render-blueprint', renderBlueprint) - .command('extract-eventgraph', extractEventGraph) + .command('extract-eventgraph', extractEventGraph) diff --git a/src/commands/uasset/parse.ts b/src/commands/uasset/parse.ts index 4f81783..d6925c0 100644 --- a/src/commands/uasset/parse.ts +++ b/src/commands/uasset/parse.ts @@ -12,15 +12,15 @@ export type ParseOptions = typeof parse extends * @returns Normalized path */ function normalizePath(path: string): string { - if (!path) return path; - - // If the path is enclosed in quotes, remove them - if (path.startsWith('"') && path.endsWith('"')) { - path = path.substring(1, path.length - 1); - } - - // Replace escaped backslashes with forward slashes - return path.replace(/\\\\/g, '/'); + if (!path) return path + + // If the path is enclosed in quotes, remove them + if (path.startsWith('"') && path.endsWith('"')) { + path = path.substring(1, path.length - 1) + } + + // Replace escaped backslashes with forward slashes + return path.replace(/\\\\/g, '/') } /** @@ -29,19 +29,19 @@ function normalizePath(path: string): string { * @returns Processed string */ function processStringValue(value: string): string { - if (!value) return value; - - // If it's a quoted string, unquote it - if (value.startsWith('"') && value.endsWith('"')) { - value = value.substring(1, value.length - 1); - } - - // If it looks like a path (contains /Script/ or has forward slashes) - if (value.includes('/Script/') || value.includes('/') || value.includes('\\')) { - return normalizePath(value); - } - - return value; + if (!value) return value + + // If it's a quoted string, unquote it + if (value.startsWith('"') && value.endsWith('"')) { + value = value.substring(1, value.length - 1) + } + + // If it looks like a path (contains /Script/ or has forward slashes) + if (value.includes('/Script/') || value.includes('/') || value.includes('\\')) { + return normalizePath(value) + } + + return value } /** @@ -50,102 +50,103 @@ function processStringValue(value: string): string { * @returns The parsed blueprint object */ function parseBlueprint(fileContent: string) { - const lines = fileContent.split('\n'); - const stack: any[] = []; - let currentObject: any = null; - const rootObjects: any[] = []; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line) continue; - - if (line.startsWith('Begin Object')) { - // Extract Class and Name using regex - const classMatch = line.match(/Class=([^\s"]+)/); - const nameMatch = line.match(/Name="([^"]+)"/); - const exportPathMatch = line.match(/ExportPath="([^"]+)"/); - - const newObject: any = { - type: 'object', - properties: {}, - children: [] - }; - - if (classMatch) newObject.class = normalizePath(classMatch[1]); - if (nameMatch) newObject.name = nameMatch[1]; - if (exportPathMatch) newObject.exportPath = normalizePath(exportPathMatch[1]); - - if (currentObject) { - stack.push(currentObject); - currentObject.children.push(newObject); - } else { - rootObjects.push(newObject); - } - currentObject = newObject; - } else if (line.startsWith('End Object')) { - if (stack.length > 0) { - currentObject = stack.pop(); - } else { - currentObject = null; - } - } else if (currentObject && line.includes('=')) { - // Handle property lines - const equalPos = line.indexOf('='); - const key = line.substring(0, equalPos).trim(); - const value = line.substring(equalPos + 1).trim(); - - // Special handling for complex properties like arrays, parenthesized values, etc. - if (value.startsWith('(') && !value.endsWith(')')) { - // This is a multi-line property - let complexValue = value; - while (!complexValue.endsWith(')') && i < lines.length - 1) { - i++; - complexValue += ' ' + lines[i].trim(); - } - currentObject.properties[key] = parseComplexProperty(complexValue); - } else if (value.startsWith('(')) { - // Parse complex property with parentheses into an object - const parsedValue = parseComplexProperty(value); - currentObject.properties[key] = parsedValue; - } else if (key.startsWith('CustomProperties')) { - // Handle CustomProperties as a special case - if (!currentObject.customProperties) { - currentObject.customProperties = []; - } - currentObject.customProperties.push(processStringValue(value)); - } else if (key.includes('(')) { - // Handle array properties like Nodes(0) - const arrayMatch = key.match(/([^\(]+)\((\d+)\)/); - if (arrayMatch) { - const arrayName = arrayMatch[1]; - const arrayIndex = parseInt(arrayMatch[2]); - - if (!currentObject.properties[arrayName]) { - currentObject.properties[arrayName] = []; - } - - // Ensure array has enough elements - while (currentObject.properties[arrayName].length <= arrayIndex) { - currentObject.properties[arrayName].push(null); - } - - // Process string values in array elements - const processedValue = value.startsWith('"') || - value.includes('/') || - value.includes('\\') ? - processStringValue(value) : value; - - currentObject.properties[arrayName][arrayIndex] = processedValue; - } else { - currentObject.properties[key] = processStringValue(value); - } - } else { - currentObject.properties[key] = processStringValue(value); - } - } - } - - return rootObjects.length === 1 ? rootObjects[0] : rootObjects; + const lines = fileContent.split('\n') + const stack: any[] = [] + let currentObject: any = null + const rootObjects: any[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim() + if (!line) continue + + if (line.startsWith('Begin Object')) { + // Extract Class and Name using regex + const classMatch = line.match(/Class=([^\s"]+)/) + const nameMatch = line.match(/Name="([^"]+)"/) + const exportPathMatch = line.match(/ExportPath="([^"]+)"/) + + const newObject: any = { + type: 'object', + properties: {}, + children: [], + } + + if (classMatch) newObject.class = normalizePath(classMatch[1]) + if (nameMatch) newObject.name = nameMatch[1] + if (exportPathMatch) newObject.exportPath = normalizePath(exportPathMatch[1]) + + if (currentObject) { + stack.push(currentObject) + currentObject.children.push(newObject) + } else { + rootObjects.push(newObject) + } + currentObject = newObject + } else if (line.startsWith('End Object')) { + if (stack.length > 0) { + currentObject = stack.pop() + } else { + currentObject = null + } + } else if (currentObject && line.includes('=')) { + // Handle property lines + const equalPos = line.indexOf('=') + const key = line.substring(0, equalPos).trim() + const value = line.substring(equalPos + 1).trim() + + // Special handling for complex properties like arrays, parenthesized values, etc. + if (value.startsWith('(') && !value.endsWith(')')) { + // This is a multi-line property + let complexValue = value + while (!complexValue.endsWith(')') && i < lines.length - 1) { + i++ + complexValue += ' ' + lines[i].trim() + } + currentObject.properties[key] = parseComplexProperty(complexValue) + } else if (value.startsWith('(')) { + // Parse complex property with parentheses into an object + const parsedValue = parseComplexProperty(value) + currentObject.properties[key] = parsedValue + } else if (key.startsWith('CustomProperties')) { + // Handle CustomProperties as a special case + if (!currentObject.customProperties) { + currentObject.customProperties = [] + } + currentObject.customProperties.push(processStringValue(value)) + } else if (key.includes('(')) { + // Handle array properties like Nodes(0) + const arrayMatch = key.match(/([^\(]+)\((\d+)\)/) + if (arrayMatch) { + const arrayName = arrayMatch[1] + const arrayIndex = parseInt(arrayMatch[2]) + + if (!currentObject.properties[arrayName]) { + currentObject.properties[arrayName] = [] + } + + // Ensure array has enough elements + while (currentObject.properties[arrayName].length <= arrayIndex) { + currentObject.properties[arrayName].push(null) + } + + // Process string values in array elements + const processedValue = value.startsWith('"') || + value.includes('/') || + value.includes('\\') + ? processStringValue(value) + : value + + currentObject.properties[arrayName][arrayIndex] = processedValue + } else { + currentObject.properties[key] = processStringValue(value) + } + } else { + currentObject.properties[key] = processStringValue(value) + } + } + } + + return rootObjects.length === 1 ? rootObjects[0] : rootObjects } /** @@ -154,90 +155,88 @@ function parseBlueprint(fileContent: string) { * @returns Parsed object representation */ function parseComplexProperty(value: string): any { - if (!value.startsWith('(') || !value.endsWith(')')) { - return processStringValue(value); - } - - // Remove outer parentheses - const content = value.substring(1, value.length - 1); - - // Split by commas, handling nested parentheses - const parts: string[] = []; - let currentPart = ''; - let depth = 0; - let inQuotes = false; - - for (let i = 0; i < content.length; i++) { - const char = content[i]; - - // Handle quotes to avoid splitting inside quoted strings - if (char === '"' && (i === 0 || content[i-1] !== '\\')) { - inQuotes = !inQuotes; - } - - if (!inQuotes) { - if (char === '(') depth++; - else if (char === ')') depth--; - else if (char === ',' && depth === 0) { - parts.push(currentPart.trim()); - currentPart = ''; - continue; - } - } - - currentPart += char; - } - - if (currentPart) { - parts.push(currentPart.trim()); - } - - // Convert parts to key-value pairs - const result: Record = {}; - - for (const part of parts) { - const equalPos = part.indexOf('='); - if (equalPos !== -1) { - const key = part.substring(0, equalPos).trim(); - const val = part.substring(equalPos + 1).trim(); - - // Recursively parse nested complex properties - if (val.startsWith('(') && val.endsWith(')')) { - result[key] = parseComplexProperty(val); - } else { - result[key] = processStringValue(val); - } - } else { - // Handle valueless properties or booleans - result[part.trim()] = true; - } - } - - return result; + if (!value.startsWith('(') || !value.endsWith(')')) { + return processStringValue(value) + } + + // Remove outer parentheses + const content = value.substring(1, value.length - 1) + + // Split by commas, handling nested parentheses + const parts: string[] = [] + let currentPart = '' + let depth = 0 + let inQuotes = false + + for (let i = 0; i < content.length; i++) { + const char = content[i] + + // Handle quotes to avoid splitting inside quoted strings + if (char === '"' && (i === 0 || content[i - 1] !== '\\')) { + inQuotes = !inQuotes + } + + if (!inQuotes) { + if (char === '(') depth++ + else if (char === ')') depth-- + else if (char === ',' && depth === 0) { + parts.push(currentPart.trim()) + currentPart = '' + continue + } + } + + currentPart += char + } + + if (currentPart) { + parts.push(currentPart.trim()) + } + + // Convert parts to key-value pairs + const result: Record = {} + + for (const part of parts) { + const equalPos = part.indexOf('=') + if (equalPos !== -1) { + const key = part.substring(0, equalPos).trim() + const val = part.substring(equalPos + 1).trim() + + // Recursively parse nested complex properties + if (val.startsWith('(') && val.endsWith(')')) { + result[key] = parseComplexProperty(val) + } else { + result[key] = processStringValue(val) + } + } else { + // Handle valueless properties or booleans + result[part.trim()] = true + } + } + + return result } export const parse = new Command() .description('parse exported uasset') .option('-i, --input ', 'Path to the exported uasset file', { required: true }) - .option('-o, --output ', 'Output file path for the JSON result', { required: false }) - .option('--pretty', 'Pretty print the JSON output', { default: true }) + .option('-o, --output ', 'Output file path for the JSON result', { required: false }) + .option('--pretty', 'Pretty print the JSON output', { default: true }) .action((options) => { - try { - const data = Deno.readTextFileSync(options.input); - const parsedObject = parseBlueprint(data); - - const jsonOutput = options.pretty - ? JSON.stringify(parsedObject, null, 2) - : JSON.stringify(parsedObject); - - if (options.output) { - Deno.writeTextFileSync(options.output, jsonOutput); - logger.info(`Parsed blueprint written to ${options.output}`); - } else { - console.log(jsonOutput); - } - } catch (error: unknown) { - logger.error(`Error parsing blueprint: ${error instanceof Error ? error.message : String(error)}`); - Deno.exit(1); - } + try { + const data = Deno.readTextFileSync(options.input) + const parsedObject = parseBlueprint(data) + + const jsonOutput = options.pretty ? JSON.stringify(parsedObject, null, 2) : JSON.stringify(parsedObject) + + if (options.output) { + Deno.writeTextFileSync(options.output, jsonOutput) + logger.info(`Parsed blueprint written to ${options.output}`) + } else { + console.log(jsonOutput) + } + } catch (error: unknown) { + logger.error(`Error parsing blueprint: ${error instanceof Error ? error.message : String(error)}`) + Deno.exit(1) + } }) diff --git a/src/commands/uasset/render-blueprint.ts b/src/commands/uasset/render-blueprint.ts index 823aa68..a4f39bc 100644 --- a/src/commands/uasset/render-blueprint.ts +++ b/src/commands/uasset/render-blueprint.ts @@ -5,7 +5,7 @@ import { generateBlueprintHtml } from '../../lib/utils.ts' const url = Deno.env.get('RENDER_URL') || 'http://localhost:8787' export const renderBlueprint = new Command() - .description('Render your blueprint') + .description('Render your blueprint') .option( '-b, --blueprint ', 'Path to the input file', diff --git a/src/lib/engine.ts b/src/lib/engine.ts index 686d8bd..4c2db44 100644 --- a/src/lib/engine.ts +++ b/src/lib/engine.ts @@ -427,7 +427,7 @@ export function getEditorPath(enginePath: string, platform: EnginePlatform): str 'Engine', 'Binaries', 'Win64', - 'UnrealEditor.exe' + 'UnrealEditor.exe', ) case EnginePlatform.Mac: return path.join( @@ -435,7 +435,7 @@ export function getEditorPath(enginePath: string, platform: EnginePlatform): str 'Engine', 'Binaries', 'Mac', - 'UnrealEditor' + 'UnrealEditor', ) case EnginePlatform.Linux: return path.join( @@ -443,9 +443,9 @@ export function getEditorPath(enginePath: string, platform: EnginePlatform): str 'Engine', 'Binaries', 'Linux', - 'UnrealEditor' + 'UnrealEditor', ) default: throw new Error(`Unsupported platform: ${platform}`) } -} \ No newline at end of file +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 5751caa..316f9b5 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -406,5 +406,3 @@ ${blueprint} ` return html } - - From ae2bbce0e5d96f0a46f434327306d54b3bf656dc Mon Sep 17 00:00:00 2001 From: warman Date: Fri, 4 Apr 2025 21:05:22 -0400 Subject: [PATCH 7/7] chore: add option to extract-eventgraph a dir --- src/commands/uasset/extract-eventgraph.ts | 44 ++++++++++++++--------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/commands/uasset/extract-eventgraph.ts b/src/commands/uasset/extract-eventgraph.ts index f880d67..75a6d7c 100644 --- a/src/commands/uasset/extract-eventgraph.ts +++ b/src/commands/uasset/extract-eventgraph.ts @@ -3,6 +3,7 @@ import type { GlobalOptions } from '../../lib/types.ts' import { generateBlueprintHtml } from '../../lib/utils.ts' import { logger } from '../../lib/logger.ts' import * as path from 'jsr:@std/path' +import * as fs from 'jsr:@std/fs' export type ExtractEventGraphOptions = typeof extractEventGraph extends Command, [], GlobalOptions> ? Options @@ -82,29 +83,40 @@ function findEventGraph(fileContent: string): string | null { return nodeCount > 0 ? eventGraphNodes.join('\n') : null } +async function extractEventGraphFromFile(filePath: string, render: boolean) { + const data = await Deno.readTextFile(filePath) + const eventGraph = findEventGraph(data) + if (eventGraph) { + if (render) { + const html = generateBlueprintHtml(eventGraph) + const basename = path.basename(filePath, path.extname(filePath)) + const basepath = path.dirname(filePath) + await Deno.writeTextFile(`${basepath}/${basename}.html`, html) + } else { + logger.info(eventGraph) + } + } +} + export const extractEventGraph = new Command() .description('extract event graph from exported uasset') - .option('-i, --input ', 'Path to the exported uasset file', { required: true }) + .option('-i, --input ', 'Path to the exported uasset file or directory containing exported uassets', { + required: true, + }) .option('-r, --render', 'Save the output as rendered html', { default: false }) - .action((options) => { + .action(async (options) => { try { - const data = Deno.readTextFileSync(options.input) - const eventGraph = findEventGraph(data) - if (eventGraph) { - if (options.render) { - const html = generateBlueprintHtml(eventGraph) - const basename = path.basename(options.input, path.extname(options.input)) - const basepath = path.dirname(options.input) - Deno.writeTextFileSync(`${basepath}/${basename}.html`, html) - logger.info(`EventGraph extracted to ${basename}.html`) - } else { - logger.info(eventGraph) + const isDirectory = await fs.exists(options.input, { isDirectory: true }) + if (isDirectory) { + const files = await Deno.readDir(options.input) + for await (const file of files) { + if (file.isFile) { + await extractEventGraphFromFile(path.join(options.input, file.name), options.render) + } } } else { - logger.error('No EventGraph found in the file') - Deno.exit(1) + await extractEventGraphFromFile(options.input, options.render) } - return } catch (error: unknown) { logger.error(`Error parsing blueprint: ${error instanceof Error ? error.message : String(error)}`) Deno.exit(1)