Skip to content

Commit 8860b78

Browse files
feat: uasset utils and runpython helper (#53)
* chore: version override * feat: asset viewer (#52) * feat: asset viewer * fix: commit * feat: runpython command * feat: cursed ai code to parse blueprint data * chore: save exported html to same location * chore: fmt * chore: add option to extract-eventgraph a dir --------- Co-authored-by: warman <warman@runreal.dev>
1 parent e76a81d commit 8860b78

File tree

13 files changed

+653
-9
lines changed

13 files changed

+653
-9
lines changed

deno.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@std/path": "jsr:@std/path@^1.0.8",
3939
"@std/streams": "jsr:@std/streams@^1.0.9",
4040
"@std/testing": "jsr:@std/testing@^1.0.5",
41+
"ueblueprint":"npm:ueblueprint@2.0.0",
4142
"zod": "npm:zod@3.23.8",
4243
"zod-to-json-schema": "npm:zod-to-json-schema@3.23.5",
4344
"ndjson": "https://deno.land/x/ndjson@1.1.0/mod.ts",

deno.lock

Lines changed: 75 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commands/pkg.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export const pkg = new Command<GlobalOptions>()
5858
.option('--profile <profile:string>', 'Build profile', { default: 'client', required: true })
5959
.action(async (options) => {
6060
const { platform, configuration, dryRun, profile, archiveDirectory, zip } = options as PkgOptions
61-
const cfg = await Config.getInstance()
61+
const cfg = Config.getInstance()
6262
const { engine: { path: enginePath }, project: { path: projectPath } } = cfg.mergeConfigCLIConfig({
6363
cliOptions: options,
6464
})

src/commands/runpython.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { Command } from '@cliffy/command'
2+
import * as path from '@std/path'
3+
import { findProjectFile } from '../lib/utils.ts'
4+
import { Config } from '../lib/config.ts'
5+
import { logger } from '../lib/logger.ts'
6+
import type { GlobalOptions } from '../lib/types.ts'
7+
8+
import { exec } from '../lib/utils.ts'
9+
import { Engine, getEditorPath } from '../lib/engine.ts'
10+
11+
export type RunPythonOptions = typeof runpython extends
12+
Command<void, void, infer Options, infer Argument, GlobalOptions> ? Options
13+
: never
14+
15+
export const runpython = new Command<GlobalOptions>()
16+
.description('Run Python script in Unreal Engine headless mode')
17+
.option('-s, --script <scriptPath:string>', 'Path to Python script', { required: true })
18+
.option('--stdout', 'Redirect output to stdout', { default: true })
19+
.option('--nosplash', 'Skip splash screen', { default: true })
20+
.option('--nopause', "Don't pause after execution", { default: true })
21+
.option('--nosound', 'Disable sound', { default: true })
22+
.option('--args <args:string>', 'Additional arguments to pass to the script')
23+
.action(async (options) => {
24+
const config = Config.getInstance()
25+
const { engine: { path: enginePath }, project: { path: projectPath } } = config.mergeConfigCLIConfig({
26+
cliOptions: options,
27+
})
28+
29+
const projectFile = await findProjectFile(projectPath).catch(() => {
30+
logger.error(`Could not find project file in ${projectPath}`)
31+
Deno.exit(1)
32+
})
33+
34+
if (!projectFile) {
35+
logger.error(`Could not find project file in ${projectPath}`)
36+
Deno.exit(1)
37+
}
38+
39+
// Resolve absolute path to script
40+
const scriptPath = path.resolve(options.script)
41+
42+
// Get the editor executable path based on platform
43+
const currentPlatform = Engine.getCurrentPlatform()
44+
const editorExePath = getEditorPath(enginePath, currentPlatform)
45+
46+
// Build command arguments
47+
const args = [
48+
projectFile,
49+
'-run=pythonscript',
50+
`-script="${scriptPath}"`,
51+
'-unattended',
52+
]
53+
54+
// Add optional flags
55+
if (options.stdout) args.push('-stdout')
56+
if (options.nosplash) args.push('-nosplash')
57+
if (options.nopause) args.push('-nopause')
58+
if (options.nosound) args.push('-nosound')
59+
60+
// Add any additional arguments
61+
if (options.args) {
62+
args.push(options.args)
63+
}
64+
65+
logger.info(`Running Python script: ${scriptPath}`)
66+
logger.debug(`Full command: ${editorExePath} ${args.join(' ')}`)
67+
68+
try {
69+
const result = await exec(editorExePath, args)
70+
return result
71+
} catch (error: unknown) {
72+
logger.error(`Error running Python script: ${error instanceof Error ? error.message : String(error)}`)
73+
Deno.exit(1)
74+
}
75+
})

src/commands/script.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { logger } from '../lib/logger.ts'
66

77
import * as esbuild from 'https://deno.land/x/esbuild@v0.24.0/mod.js'
88

9-
import { denoPlugins } from 'jsr:@luca/esbuild-deno-loader@0.11.0'
9+
import { denoPlugins } from 'jsr:@luca/esbuild-deno-loader@0.11.1'
1010
import { Config } from '../lib/config.ts'
1111

1212
export const script = new Command<GlobalOptions>()
@@ -44,7 +44,7 @@ export const script = new Command<GlobalOptions>()
4444

4545
const script = (await import(builtOutput)) as Script
4646
await script.main(context)
47-
} catch (e) {
47+
} catch (e: any) {
4848
logger.error(e)
4949
Deno.exit(1)
5050
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { Command } from '@cliffy/command'
2+
import type { GlobalOptions } from '../../lib/types.ts'
3+
import { generateBlueprintHtml } from '../../lib/utils.ts'
4+
import { logger } from '../../lib/logger.ts'
5+
import * as path from 'jsr:@std/path'
6+
import * as fs from 'jsr:@std/fs'
7+
8+
export type ExtractEventGraphOptions = typeof extractEventGraph extends
9+
Command<void, void, infer Options extends Record<string, unknown>, [], GlobalOptions> ? Options
10+
: never
11+
12+
/**
13+
* Find the EventGraph section in the exported uasset file as is
14+
* @param fileContent The content of the .copy file as a string
15+
* @returns The EventGraph section as a string, or null if not found
16+
*/
17+
function findEventGraph(fileContent: string): string | null {
18+
// Find the "Begin Object Name="EventGraph"" section
19+
const lines = fileContent.split('\n')
20+
const eventGraphNodes: string[] = []
21+
22+
let insideEventGraph = false
23+
let insideNodeSection = false
24+
let nodeCount = 0
25+
let currentObject = ''
26+
let insideBeginObjectBlock = false
27+
28+
for (let i = 0; i < lines.length; i++) {
29+
const line = lines[i].trim()
30+
31+
if (!line) continue
32+
33+
// Check if we're starting a Begin Object Name="EventGraph" section
34+
if (line.startsWith('Begin Object Name="EventGraph"')) {
35+
insideEventGraph = true
36+
nodeCount = 0
37+
insideNodeSection = false
38+
continue
39+
}
40+
41+
// Skip the end of the EventGraph parent object
42+
if (insideEventGraph && line === 'End Object' && !insideBeginObjectBlock && !insideNodeSection) {
43+
insideEventGraph = false
44+
continue
45+
}
46+
47+
// Check if we're inside an EventGraph and starting a node section with Begin Object Name="..."
48+
if (insideEventGraph && line.startsWith('Begin Object Name="K2Node_')) {
49+
nodeCount++
50+
currentObject = line
51+
eventGraphNodes.push(currentObject)
52+
insideBeginObjectBlock = true
53+
continue
54+
}
55+
56+
// Process EventGraph child blocks that define nodes
57+
if (insideEventGraph && line.startsWith('Begin Object Class="/Script/BlueprintGraph.K2Node_')) {
58+
nodeCount++
59+
currentObject = line
60+
eventGraphNodes.push(currentObject)
61+
insideBeginObjectBlock = true
62+
continue
63+
}
64+
65+
// If we're inside a node definition in the EventGraph, keep collecting its content
66+
if (
67+
insideEventGraph && insideBeginObjectBlock && !line.startsWith('Begin Object') && !line.startsWith('End Object')
68+
) {
69+
eventGraphNodes.push(' ' + line)
70+
continue
71+
}
72+
73+
// Handle the end of a node's Begin/End Object block
74+
if (insideEventGraph && insideBeginObjectBlock && line === 'End Object') {
75+
eventGraphNodes.push(line)
76+
insideBeginObjectBlock = false
77+
currentObject = ''
78+
continue
79+
}
80+
}
81+
82+
// Return the extracted EventGraph nodes, or null if none found
83+
return nodeCount > 0 ? eventGraphNodes.join('\n') : null
84+
}
85+
86+
async function extractEventGraphFromFile(filePath: string, render: boolean) {
87+
const data = await Deno.readTextFile(filePath)
88+
const eventGraph = findEventGraph(data)
89+
if (eventGraph) {
90+
if (render) {
91+
const html = generateBlueprintHtml(eventGraph)
92+
const basename = path.basename(filePath, path.extname(filePath))
93+
const basepath = path.dirname(filePath)
94+
await Deno.writeTextFile(`${basepath}/${basename}.html`, html)
95+
} else {
96+
logger.info(eventGraph)
97+
}
98+
}
99+
}
100+
101+
export const extractEventGraph = new Command<GlobalOptions>()
102+
.description('extract event graph from exported uasset')
103+
.option('-i, --input <file:string>', 'Path to the exported uasset file or directory containing exported uassets', {
104+
required: true,
105+
})
106+
.option('-r, --render', 'Save the output as rendered html', { default: false })
107+
.action(async (options) => {
108+
try {
109+
const isDirectory = await fs.exists(options.input, { isDirectory: true })
110+
if (isDirectory) {
111+
const files = await Deno.readDir(options.input)
112+
for await (const file of files) {
113+
if (file.isFile) {
114+
await extractEventGraphFromFile(path.join(options.input, file.name), options.render)
115+
}
116+
}
117+
} else {
118+
await extractEventGraphFromFile(options.input, options.render)
119+
}
120+
} catch (error: unknown) {
121+
logger.error(`Error parsing blueprint: ${error instanceof Error ? error.message : String(error)}`)
122+
Deno.exit(1)
123+
}
124+
})

src/commands/uasset/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Command } from '@cliffy/command'
2+
import type { GlobalOptions } from '../../lib/types.ts'
3+
4+
import { parse } from './parse.ts'
5+
import { renderBlueprint } from './render-blueprint.ts'
6+
import { extractEventGraph } from './extract-eventgraph.ts'
7+
8+
export const uasset = new Command<GlobalOptions>()
9+
.description('uasset')
10+
.action(function () {
11+
this.showHelp()
12+
})
13+
.command('parse', parse)
14+
.command('render-blueprint', renderBlueprint)
15+
.command('extract-eventgraph', extractEventGraph)

0 commit comments

Comments
 (0)