@gripforgeai/mcp 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,212 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, writeFile, rename, stat, realpath } from 'node:fs/promises';
3
+ import { resolve, sep } from 'node:path';
4
+ import { inflateSync, deflateSync } from 'node:zlib';
5
+ export const sha256 = (b) => createHash('sha256').update(b).digest('hex');
6
+ export async function atomicJson(file, value) { const tmp = file + '.' + process.pid + '.tmp'; await writeFile(tmp, JSON.stringify(value)); await rename(tmp, file); }
7
+ export async function readJson(file) { if ((await stat(file)).size > 64 * 1024 * 1024)
8
+ throw Error('Manifest exceeds 64 MB.'); return JSON.parse(await readFile(file, 'utf8')); }
9
+ export async function exportFile(dir, file) {
10
+ const root = await realpath(dir), path = await realpath(resolve(root, file));
11
+ if (!path.startsWith(root + sep))
12
+ throw Error('Export file escapes its job directory.');
13
+ return path;
14
+ }
15
+ const signature = Buffer.from('89504e470d0a1a0a', 'hex');
16
+ function crc32(data) { let crc = 0xffffffff; for (const byte of data) {
17
+ crc ^= byte;
18
+ for (let i = 0; i < 8; i++)
19
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
20
+ } return (crc ^ 0xffffffff) >>> 0; }
21
+ /** Native RGBA8 data, without gamma conversion (height is packed in RG). */
22
+ export function decodeExportPng(bytes) {
23
+ if (!bytes.subarray(0, 8).equals(signature))
24
+ throw Error('Invalid PNG signature.');
25
+ let width = 0, height = 0, channels = 0, ended = false;
26
+ const parts = [];
27
+ for (let at = 8; at + 12 <= bytes.length;) {
28
+ const length = bytes.readUInt32BE(at), end = at + length + 12;
29
+ if (end > bytes.length)
30
+ throw Error('Truncated PNG.');
31
+ const type = bytes.toString('ascii', at + 4, at + 8), data = bytes.subarray(at + 8, end - 4);
32
+ if (crc32(bytes.subarray(at + 4, end - 4)) !== bytes.readUInt32BE(end - 4))
33
+ throw Error('PNG checksum mismatch.');
34
+ if (type === 'IHDR') {
35
+ if (width || length !== 13 || data[8] !== 8 || ![2, 6].includes(data[9]) || data[10] || data[11] || data[12])
36
+ throw Error('Expected non-interlaced RGB/RGBA8 PNG.');
37
+ width = data.readUInt32BE(0);
38
+ height = data.readUInt32BE(4);
39
+ channels = data[9] === 6 ? 4 : 3;
40
+ if (!width || !height || width > 8192 || height > 8192)
41
+ throw Error('PNG dimensions exceed export budget.');
42
+ }
43
+ else if (type === 'IDAT')
44
+ parts.push(data);
45
+ else if (type === 'IEND') {
46
+ ended = true;
47
+ break;
48
+ }
49
+ at = end;
50
+ }
51
+ if (!width || !ended)
52
+ throw Error('Incomplete PNG.');
53
+ const stride = width * channels, expected = (stride + 1) * height, raw = inflateSync(Buffer.concat(parts), { maxOutputLength: expected });
54
+ if (raw.length !== expected)
55
+ throw Error('Invalid PNG data length.');
56
+ const pixels = Buffer.alloc(stride * height);
57
+ for (let y = 0; y < height; y++) {
58
+ const filter = raw[y * (stride + 1)];
59
+ if (filter > 4)
60
+ throw Error('Invalid PNG filter.');
61
+ for (let x = 0; x < stride; x++) {
62
+ const at = y * stride + x, a = x >= channels ? pixels[at - channels] : 0, b = y ? pixels[at - stride] : 0, c = y && x >= channels ? pixels[at - stride - channels] : 0;
63
+ const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
64
+ pixels[at] = raw[y * (stride + 1) + x + 1] + (filter === 0 ? 0 : filter === 1 ? a : filter === 2 ? b : filter === 3 ? Math.floor((a + b) / 2) : pa <= pb && pa <= pc ? a : pb <= pc ? b : c);
65
+ }
66
+ }
67
+ return { width, height, channels, pixels };
68
+ }
69
+ export function encodeExportPng(width, height, pixels, channels = 4) {
70
+ const chunk = (name, data) => { const out = Buffer.alloc(data.length + 12); out.writeUInt32BE(data.length); out.write(name, 4); data.copy(out, 8); out.writeUInt32BE(crc32(out.subarray(4, out.length - 4)), out.length - 4); return out; };
71
+ const header = Buffer.alloc(13);
72
+ header.writeUInt32BE(width);
73
+ header.writeUInt32BE(height, 4);
74
+ header[8] = 8;
75
+ header[9] = channels === 4 ? 6 : 2;
76
+ const stride = width * channels, scan = Buffer.alloc(height * (stride + 1));
77
+ for (let y = 0; y < height; y++)
78
+ pixels.copy(scan, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
79
+ return Buffer.concat([signature, chunk('IHDR', header), chunk('IDAT', deflateSync(scan)), chunk('IEND', Buffer.alloc(0))]);
80
+ }
81
+ const vec = (x = 0, y = 0, z = 0) => ({ x, y, z });
82
+ const identity = () => ({ translation: vec(), rotation: { ...vec(), w: 1 }, scale: vec(1, 1, 1) });
83
+ const base = (id, name = id) => ({ id, name, parentId: null, transform: identity(), visible: true, locked: false });
84
+ const round = (n) => Math.round(n * 1e6) / 1e6;
85
+ const refFor = (file) => ({ assetId: 'ue_' + file.sha256.slice(0, 24), revisionId: 'rev_' + file.sha256.slice(0, 24) });
86
+ export function decodeUnrealTerrain(t, bytes, resolution, visibility) {
87
+ const png = decodeExportPng(bytes), mask = visibility ? decodeExportPng(visibility) : undefined;
88
+ if (png.width !== t.width || png.height !== t.height || mask && (mask.width !== t.width || mask.height !== t.height))
89
+ throw Error('Landscape grid does not match its PNG.');
90
+ const spanX = t.width - 1, spanZ = t.height - 1, longest = Math.max(spanX, spanZ), sx = Math.max(1, Math.round((resolution - 1) * spanX / longest)), sz = Math.max(1, Math.round((resolution - 1) * spanZ / longest));
91
+ const q = t.component_quads, occupied = new Set(t.components.map(([x, y]) => x + ':' + y));
92
+ const present = (x, z) => occupied.has((Math.floor((t.base[0] + Math.min(spanX - .00001, Math.max(0, x))) / q) * q) + ':' + (Math.floor((t.base[1] + Math.min(spanZ - .00001, Math.max(0, z))) / q) * q));
93
+ const hidden = (x, z) => mask ? mask.pixels[(Math.round(z) * mask.width + Math.round(x)) * mask.channels] >= 128 : false;
94
+ const sample = (x, z) => { const at = (Math.min(t.height - 1, Math.max(0, z)) * t.width + Math.min(t.width - 1, Math.max(0, x))) * png.channels; return (png.pixels[at] * 256 + png.pixels[at + 1] - 32768) / 128 * t.scale[2] * .01; };
95
+ const heights = [], holes = [];
96
+ for (let z = 0; z <= sz; z++)
97
+ for (let x = 0; x <= sx; x++) {
98
+ const px = x / sx * spanX, pz = z / sz * spanZ, ix = Math.floor(px), iz = Math.floor(pz), u = px - ix, v = pz - iz;
99
+ const a = sample(ix, iz) * (1 - u) + sample(ix + 1, iz) * u, b = sample(ix, iz + 1) * (1 - u) + sample(ix + 1, iz + 1) * u;
100
+ heights.push(present(px, pz) ? Math.round((a * (1 - v) + b * v) * 1000) / 1000 : 0);
101
+ }
102
+ for (let z = 0; z < sz; z++)
103
+ for (let x = 0; x < sx; x++) {
104
+ let hole = false;
105
+ // Every source sample overlapped by this quad is checked, so small painted holes survive downsampling.
106
+ for (let j = Math.floor(z / sz * spanZ); j <= Math.ceil((z + 1) / sz * spanZ) && !hole; j++)
107
+ for (let i = Math.floor(x / sx * spanX); i <= Math.ceil((x + 1) / sx * spanX); i++)
108
+ if (!present(i, j) || hidden(i, j)) {
109
+ hole = true;
110
+ break;
111
+ }
112
+ holes.push(hole ? 1 : 0);
113
+ }
114
+ return { size: [spanX * t.scale[0] * .01, spanZ * t.scale[1] * .01], segments: [sx, sz], heights, holes,
115
+ center: vec((t.origin[0] + (t.base[0] + spanX / 2) * t.scale[0]) * .01, t.origin[2] * .01, (t.origin[1] + (t.base[1] + spanZ / 2) * t.scale[1]) * .01) };
116
+ }
117
+ export async function buildUnrealStudioPackage(directory) {
118
+ const dir = await realpath(directory), manifest = await readJson(resolve(dir, 'native.json')), request = await readJson(resolve(dir, 'request.json'));
119
+ if (manifest.schema !== 'gripforge.unreal-export.v1' || manifest.status !== 'exported-awaiting-gripforge-review' || sha256(await readFile(resolve(dir, 'request.json'))) !== manifest.request_hash)
120
+ throw Error('Native export must finish successfully before assembly.');
121
+ const assets = [], nodes = [], assetRefs = new Map();
122
+ async function addFile(file, name, kind, mime) {
123
+ const path = await exportFile(dir, file.file), bytes = await readFile(path);
124
+ if (bytes.length > 128 * 1024 * 1024 || bytes.length !== file.bytes || sha256(bytes) !== file.sha256)
125
+ throw Error('Changed or oversized export: ' + file.file);
126
+ const ref = refFor(file);
127
+ if (!assets.some(a => a.sha256 === file.sha256))
128
+ assets.push({ ...file, ...ref, name, kind, mime });
129
+ return ref;
130
+ }
131
+ for (const [key, file] of Object.entries(manifest.assets))
132
+ assetRefs.set(key, await addFile(file, file.name, 'prop', 'model/gltf-binary'));
133
+ nodes.push({ ...base('props', 'Props & buildings'), type: 'group' }, { ...base('vegetation', 'Vegetation'), type: 'group' }, { ...base('terrain', 'Terrain'), type: 'group' });
134
+ const batches = new Map();
135
+ let zeroScaleInstances = 0;
136
+ for (const instance of manifest.instances) {
137
+ const asset = assetRefs.get(instance.asset);
138
+ if (!asset)
139
+ throw Error('Missing exported mesh: ' + instance.asset);
140
+ const t = instance.transform, p = t.translation, q = t.rotation, s = t.scale;
141
+ // Unreal foliage retains removed placements as zero-scale tombstones. They have no visible geometry.
142
+ if ([s.x, s.y, s.z].every(n => n === 0)) {
143
+ zeroScaleInstances++;
144
+ continue;
145
+ }
146
+ const row = [p.x, p.y, p.z, q.x, q.y, q.z, q.w, s.x, s.y, s.z].map(round);
147
+ if (!row.every(Number.isFinite) || Math.abs(Math.hypot(q.x, q.y, q.z, q.w) - 1) > .001 || [s.x, s.y, s.z].some(n => Math.abs(n) < .00001))
148
+ throw Error('Invalid native transform: ' + instance.id);
149
+ if (instance.batch && [s.x, s.y, s.z].every(n => n > 0)) {
150
+ const cx = Math.floor(p.x / 64), cz = Math.floor(p.z / 64), prefix = instance.asset + ':' + cx + ':' + cz;
151
+ let part = 0, key = prefix;
152
+ while ((batches.get(key)?.instanceBatch.ids.length ?? 0) >= 4096)
153
+ key = prefix + ':' + (++part);
154
+ if (!batches.has(key)) {
155
+ const b = { ...base('batch_' + sha256(key).slice(0, 24), instance.name.slice(0, 110) + ` [${cx},${cz}]`), type: 'asset', parentId: 'vegetation', asset, role: 'vegetation', materials: {}, parameters: { source: 'unreal-instancing' }, instanceBatch: { ids: [], transforms: [] } };
156
+ batches.set(key, b);
157
+ nodes.push(b);
158
+ }
159
+ const b = batches.get(key);
160
+ b.instanceBatch.ids.push(instance.id);
161
+ b.instanceBatch.transforms.push(row);
162
+ }
163
+ else
164
+ nodes.push({ ...base(instance.id, instance.name), type: 'asset', parentId: instance.batch ? 'vegetation' : 'props', asset, role: 'prop', transform: t, materials: {}, parameters: { unrealSource: instance.source } });
165
+ }
166
+ let overview;
167
+ for (const t of manifest.terrains) {
168
+ await addFile(t.heightmap, t.id + ' source height', 'texture', 'image/png');
169
+ if (t.visibility)
170
+ await addFile(t.visibility, t.id + ' source holes', 'texture', 'image/png');
171
+ const terrain = decodeUnrealTerrain(t, await readFile(await exportFile(dir, t.heightmap.file)), request.terrain_resolution ?? 513, t.visibility ? await readFile(await exportFile(dir, t.visibility.file)) : undefined);
172
+ let albedo = t.albedo;
173
+ if (Math.abs(terrain.size[0] - terrain.size[1]) > .001) {
174
+ const source = decodeExportPng(await readFile(await exportFile(dir, albedo.file)));
175
+ const w = Math.max(1, Math.round(source.width * terrain.size[0] / (t.albedo_span_cm * .01))), h = Math.max(1, Math.round(source.height * terrain.size[1] / (t.albedo_span_cm * .01))), data = Buffer.alloc(w * h * source.channels);
176
+ if (w > source.width || h > source.height)
177
+ throw Error('Invalid Landscape albedo extent.');
178
+ const ox = Math.floor((source.width - w) / 2), oy = Math.floor((source.height - h) / 2);
179
+ for (let y = 0; y < h; y++)
180
+ source.pixels.copy(data, y * w * source.channels, ((y + oy) * source.width + ox) * source.channels, ((y + oy) * source.width + ox + w) * source.channels);
181
+ const bytes = encodeExportPng(w, h, data, source.channels), file = t.id + '-albedo-cropped.png';
182
+ await writeFile(resolve(dir, file), bytes);
183
+ albedo = { file, bytes: bytes.length, sha256: sha256(bytes) };
184
+ }
185
+ const map = await addFile(albedo, t.id + ' albedo', 'texture', 'image/png');
186
+ nodes.push({ ...base('terrain_' + sha256(t.id).slice(0, 24), t.id), type: 'terrain', parentId: 'terrain', transform: { ...identity(), translation: terrain.center }, size: terrain.size, segments: terrain.segments, heights: terrain.heights, holes: terrain.holes, layers: [], material: { color: '#ffffff', roughness: 1, metalness: 0, map } });
187
+ overview ??= terrain;
188
+ }
189
+ const placements = manifest.instances;
190
+ if (!overview && placements.length) {
191
+ const axes = ['x', 'y', 'z'], min = axes.map(k => Math.min(...placements.map(p => p.transform.translation[k]))), max = axes.map(k => Math.max(...placements.map(p => p.transform.translation[k])));
192
+ overview = { center: vec(...min.map((v, i) => (v + max[i]) / 2)), size: [Math.max(10, max[0] - min[0]), Math.max(10, max[2] - min[2])] };
193
+ }
194
+ if (!overview)
195
+ throw Error('Nothing portable was found in the selected map.');
196
+ const center = overview.center, span = Math.max(...overview.size), sun = manifest.environment.sun ?? [1, 2, 1];
197
+ nodes.push({ ...base('environment', 'Environment (portable approximation)'), type: 'environment', preset: 'outdoor', background: '#9dc4e1', intensity: .8, exposure: 1, rotation: 0, fog: null, sky: { mode: 'physical', sun: vec(...sun), turbidity: 3, rayleigh: 1.5, intensity: .8 } });
198
+ nodes.push({ ...base('camera_overview', 'Overview'), type: 'camera', projection: 'perspective', transform: { ...identity(), translation: vec(center.x + span * .55, center.y + span * .7, center.z + span * .65) }, target: center, fov: 55, near: .1, far: Math.max(3000, span * 6), zoom: 1 });
199
+ for (const [i, c] of manifest.cameras.entries())
200
+ nodes.push({ ...base('camera_native_' + i, c.name.slice(0, 160)), type: 'camera', projection: 'perspective', transform: { ...identity(), translation: vec(...c.position) }, target: vec(...c.target), fov: Math.min(175, Math.max(1, c.fov)), near: .1, far: Math.max(3000, span * 6), zoom: 1 });
201
+ const warnings = [...manifest.warnings, 'Repeated foliage is editable by spatial patch. Member IDs and transforms are retained; per-blade gizmos are not implemented.', 'Portable environment lighting is an approximation; compare against Unreal before validation.'];
202
+ if (zeroScaleInstances)
203
+ warnings.push(`${zeroScaleInstances} zero-scale native placements have no visible geometry and were omitted; their original transforms remain in native.json.`);
204
+ const document = { schemaVersion: 1, id: 'unreal_' + manifest.request_hash.slice(0, 24), workspaceId: 'local-preview', name: manifest.project + ' — ' + manifest.source_level.split('/').pop(), purpose: 'map', revision: 0, nodes, activeCameraId: 'camera_overview', metadata: { unrealSource: { level: manifest.source_level, sha256: manifest.source_sha256, omitted: manifest.omitted }, lifecycle: 'work', warnings } };
205
+ const input = { schema: 'gripforge.unreal-studio-import.v1', source: { engine: 'unreal', level: manifest.source_level, sha256: manifest.source_sha256, warnings }, document };
206
+ if (nodes.length > 20000 || Buffer.byteLength(JSON.stringify(input)) > 16 * 1024 * 1024)
207
+ throw Error('Portable scene exceeds the Studio budget; export smaller regions.');
208
+ await atomicJson(resolve(dir, 'scene.json'), document);
209
+ const portable = { schema: 'gripforge.scene-package.v1', scene: 'scene.json', sceneHash: sha256(JSON.stringify(document)), assets, cameras: ['camera_overview', ...nodes.filter(n => n.id.startsWith('camera_native_')).map(n => n.id)], source: input.source, instanceCount: manifest.instances.length - zeroScaleInstances, zeroScaleInstances, nodeCount: nodes.length, lifecycle: 'work' };
210
+ await atomicJson(resolve(dir, 'manifest.json'), portable);
211
+ return { directory: dir, input, manifest: portable };
212
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod/v4';
2
+ export const WEAPON_MOTION_TOOL_NAMES = ['gripforge_weapon_profile', 'gripforge_weapon_motion', 'gripforge_weapon_motion_review'];
3
+ export function registerWeaponMotionTools(register, options, s = z) {
4
+ const v = s.tuple([s.number(), s.number(), s.number()]);
5
+ const common = { source_id: s.string().regex(/^lib_[A-Za-z0-9_-]{8,64}$/), workspace_id: s.string().max(100).optional() };
6
+ const profile = { ...common, family: s.enum(['axe', 'sword', 'hammer', 'staff']).optional().describe('Required on the first call. Never infer axe vs sword from geometry alone.'), hand: s.enum(['left', 'right']).optional(), hands: s.union([s.literal(1), s.literal(2)]).optional(), weaponNode: s.string().max(150).optional(), primaryAnchor: v.optional(), secondaryAnchor: v.optional().describe('Required for two hands; weapon-node local coordinates.'), handleAxis: v.optional(), edgeAxis: v.optional() };
7
+ const generate = { ...profile, slots: s.array(s.enum(['idle', 'walk', 'run', 'attack1', 'attack2', 'attack3', 'hit', 'death', 'dodge', 'block', 'jump_start', 'jump_loop', 'jump_land'])).min(1).max(13).optional(), name: s.string().max(150).optional(), idempotency_key: s.string().regex(/^[a-zA-Z0-9_.:-]{8,160}$/).optional() };
8
+ const definitions = [
9
+ { name: WEAPON_MOTION_TOOL_NAMES[0], action: 'profile', shape: profile, description: 'Measure an owned armed GLB instance and return its editable weapon/motion profile: explicit family, holding hand, dimensions, grip anchors, handle and cutting-edge axes. Use gripforge_attach first. Default axes are +Y handle and +Z edge; inspect/correct them. Does not mutate the Library source or certify the initial seating.' },
10
+ { name: WEAPON_MOTION_TOOL_NAMES[1], action: 'generate', shape: generate, description: 'Queue a persistent weapon-aware motion draft from an owned armed GLB. Axe/hammer get distinct procedural cleave, backhand and overhead attacks; sword uses the explicit UAL sword pack. Staff attacks fail with compatible_motion_missing; no silent sword fallback. Holds Grip finger poses, supports a second-hand anchor with IK. Poll gripforge_generation_read, cancel/retry with generation tools. Returns a NEW work asset and Character Studio URL; never replaces the game/source or visually validates a result. 0 generation credits.' },
11
+ { name: WEAPON_MOTION_TOOL_NAMES[2], action: 'review', shape: common, description: 'Independently sample the saved weapon-motion GLB at 60 Hz: grip drift, finger pose, wrist rotation, joint discontinuities, second-hand reach and torso overlaps. Reports technicalPassed separately from visualStatus=not_reviewed. Inspect idle, wind-up, impact, recovery and locomotion in GripForge before using the draft. No promotion or mutation.' },
12
+ ];
13
+ for (const d of definitions)
14
+ register(d.name, { title: d.name, description: d.description, inputSchema: d.shape, annotations: { readOnlyHint: d.action !== 'generate', destructiveHint: false, idempotentHint: d.action !== 'generate', openWorldHint: false } }, async (args, extra) => {
15
+ const key = options.getApiKey();
16
+ if (!key)
17
+ return { isError: true, content: [{ type: 'text', text: 'GripForge API key required.' }] };
18
+ const parsed = s.object(d.shape).strict().safeParse(args);
19
+ if (!parsed.success)
20
+ return { isError: true, content: [{ type: 'text', text: parsed.error.message }] };
21
+ const { workspace_id, ...body } = parsed.data;
22
+ try {
23
+ const response = await fetch(options.apiUrl.replace(/\/$/, '') + '/api/v1/weapon-motion', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': key, ...(workspace_id ? { 'x-workspace-id': workspace_id } : {}) }, body: JSON.stringify({ action: d.action, ...body }), signal: AbortSignal.any([AbortSignal.timeout(150000), ...(extra?.signal ? [extra.signal] : [])]) });
24
+ const data = await response.json();
25
+ for (const k of ['studio_url', 'status_url'])
26
+ if (typeof data[k] === 'string' && data[k].startsWith('/'))
27
+ data[k] = new URL(data[k], options.apiUrl).href;
28
+ return { ...(!response.ok ? { isError: true } : {}), structuredContent: data, content: [{ type: 'text', text: JSON.stringify(data) }] };
29
+ }
30
+ catch (error) {
31
+ return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Weapon motion request failed.' }] };
32
+ }
33
+ });
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gripforgeai/mcp",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "The tools your AI needs to make your games. Turn prompts into production-ready game assets — animated characters with their weapons attached, seamless textures, terrain, VFX, HUDs — and playable game kits Unity, Godot, Unreal or Three.js can load.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,6 +9,11 @@
9
9
  },
10
10
  "files": [
11
11
  "dist",
12
+ "runtime/unreal-scene-import.py",
13
+ "runtime/unreal-scene-export.py",
14
+ "runtime/unreal-scene-upload.mjs",
15
+ "runtime/unreal_scene_math.py",
16
+ "runtime/unreal_spawn_review.py",
12
17
  "README.md"
13
18
  ],
14
19
  "scripts": {
@@ -0,0 +1,341 @@
1
+ """GripForge read-only UE -> portable scene worker (Python commandlet).
2
+
3
+ Run in a separate editor process with rendering enabled and -GFExportJob=<dir>.
4
+ No source asset/map is saved. Atomic checkpoints survive the MCP request.
5
+ """
6
+ import hashlib
7
+ import json
8
+ import math
9
+ import os
10
+ import re
11
+ import time
12
+ import traceback
13
+ from pathlib import Path
14
+ import unreal
15
+
16
+ match = re.search(r'-GFExportJob=(?:"([^"]+)"|(\S+))', unreal.SystemLibrary.get_command_line())
17
+ if not match:
18
+ raise RuntimeError('Missing -GFExportJob')
19
+ folder = Path(match.group(1) or match.group(2)).resolve()
20
+ request = json.loads((folder / 'request.json').read_text())
21
+ fingerprint = hashlib.sha256((folder / 'request.json').read_bytes()).hexdigest()
22
+ manifest_path = folder / 'native.json'
23
+ state_path = folder / 'status.json'
24
+
25
+
26
+ def atomic(path, value):
27
+ tmp = path.with_suffix(path.suffix + '.tmp')
28
+ tmp.write_text(json.dumps(value, separators=(',', ':')))
29
+ tmp.replace(path)
30
+
31
+
32
+ def sha(path):
33
+ h = hashlib.sha256()
34
+ with path.open('rb') as f:
35
+ for b in iter(lambda: f.read(1024 * 1024), b''):
36
+ h.update(b)
37
+ return h.hexdigest()
38
+
39
+
40
+ def cancelled():
41
+ p = folder / 'control.json'
42
+ if p.exists() and json.loads(p.read_text()).get('cancel'):
43
+ raise InterruptedError('Export cancelled; completed assets retained')
44
+
45
+
46
+ def progress(stage, **kw):
47
+ cancelled()
48
+ atomic(state_path, dict(schema='gripforge.unreal-export-job.v1', request_hash=fingerprint,
49
+ status='running', stage=stage, pid=os.getpid(), updated_at=time.time(), **kw))
50
+
51
+
52
+ def xyz(v):
53
+ return [float(v.x), float(v.y), float(v.z)]
54
+
55
+
56
+ def transform(t):
57
+ q = t.rotation
58
+ p, s = t.translation, t.scale3d
59
+ return dict(translation=dict(x=p.x*.01, y=p.z*.01, z=p.y*.01),
60
+ rotation=dict(x=-q.x, y=-q.z, z=-q.y, w=q.w),
61
+ scale=dict(x=s.x, y=s.z, z=s.y))
62
+
63
+
64
+ def native_prop(obj, names, default=None):
65
+ for name in names:
66
+ try:
67
+ return obj.get_editor_property(name)
68
+ except Exception:
69
+ pass
70
+ return default
71
+
72
+
73
+ def file_record(file):
74
+ p = folder / file
75
+ return dict(file=file, sha256=sha(p), bytes=p.stat().st_size)
76
+
77
+
78
+ def done(record):
79
+ p = folder / record['file']
80
+ return p.is_file() and sha(p) == record['sha256']
81
+
82
+
83
+ def package_file(path):
84
+ package = path.split('.')[0]
85
+ if package.startswith('/Game/'):
86
+ return Path(unreal.Paths.project_content_dir()) / (package[6:] + '.uasset')
87
+ if package.startswith('/Engine/'):
88
+ return Path(unreal.Paths.engine_content_dir()) / (package[8:] + '.uasset')
89
+ return None
90
+
91
+
92
+ def png(rt, name):
93
+ unreal.RenderingLibrary.export_render_target(world, rt, str(folder), name)
94
+ path = folder / name
95
+ if not path.is_file() or path.stat().st_size < 64:
96
+ raise RuntimeError('Render target export failed: ' + name)
97
+ return file_record(name)
98
+
99
+
100
+ try:
101
+ assert request['schema'] == 'gripforge.unreal-export-request.v1'
102
+ assert Path(unreal.Paths.get_project_file_path()).resolve() == Path(request['project_file']).resolve()
103
+ source_file = Path(request['project_file']).parent / 'Content' / (request['source_level'][6:] + '.umap')
104
+ assert sha(source_file) == request['source_sha256'], 'Source level changed; prepare a new job'
105
+ progress('loading_source')
106
+ world = unreal.EditorLoadingAndSavingUtils.load_map(request['source_level'])
107
+ assert world and world.get_path_name().split('.')[0] == request['source_level']
108
+ actors = unreal.GameplayStatics.get_all_actors_of_class(world, unreal.Actor)
109
+ previous = json.loads(manifest_path.read_text()) if manifest_path.exists() else {}
110
+ if previous:
111
+ assert previous['request_hash'] == fingerprint, 'Job inputs changed'
112
+ manifest = dict(schema='gripforge.unreal-export.v1', request_hash=fingerprint,
113
+ source_level=request['source_level'], source_sha256=request['source_sha256'],
114
+ project=Path(request['project_file']).stem, units='metres', up='Y',
115
+ assets=previous.get('assets', {}), instances=[], terrains=previous.get('terrains', []),
116
+ environment={}, lights=[], cameras=[], omitted={}, warnings=[])
117
+ definitions, landscapes, dependencies = {}, [], {}
118
+
119
+ def omit(kind):
120
+ manifest['omitted'][kind] = manifest['omitted'].get(kind, 0) + 1
121
+
122
+ def pin(path):
123
+ p = package_file(path)
124
+ if p and p.is_file():
125
+ info = p.stat()
126
+ dependencies[str(p)] = [info.st_size, info.st_mtime_ns]
127
+
128
+ pinned_materials = set()
129
+ def pin_material(material):
130
+ if not material or material.get_path_name() in pinned_materials:
131
+ return
132
+ pinned_materials.add(material.get_path_name())
133
+ pin(material.get_path_name())
134
+ if isinstance(material, unreal.Material):
135
+ for tex in unreal.MaterialEditingLibrary.get_used_textures(material):
136
+ pin(tex.get_path_name())
137
+ else:
138
+ for value in native_prop(material, ['texture_parameter_values'], []):
139
+ tex = value.get_editor_property('parameter_value')
140
+ if tex:
141
+ pin(tex.get_path_name())
142
+ pin_material(native_prop(material, ['parent']))
143
+
144
+ for actor in actors:
145
+ cancelled()
146
+ cls = actor.get_class().get_path_name()
147
+ if isinstance(actor, unreal.LandscapeProxy):
148
+ landscapes.append(actor)
149
+ continue
150
+ if isinstance(actor, unreal.DirectionalLight):
151
+ comp = actor.get_component_by_class(unreal.DirectionalLightComponent)
152
+ direction = actor.get_actor_forward_vector()
153
+ color = comp.get_light_color()
154
+ manifest['environment'] = dict(sun=[-direction.x, -direction.z, -direction.y],
155
+ color=[color.r, color.g, color.b], intensity=comp.get_editor_property('intensity'))
156
+ continue
157
+ if isinstance(actor, unreal.CameraActor):
158
+ c = actor.get_camera_component()
159
+ p, d = actor.get_actor_location(), actor.get_actor_forward_vector()
160
+ manifest['cameras'].append(dict(name=actor.get_actor_label(), position=[p.x*.01,p.z*.01,p.y*.01],
161
+ target=[(p.x+d.x*1000)*.01,(p.z+d.z*1000)*.01,(p.y+d.y*1000)*.01], fov=c.field_of_view))
162
+ # Procedural UE skies and water are reported, not exported as opaque domes/planes.
163
+ if re.search(r'(?:GoodSky|UltraDynamicSky|SkySphere|SkyAtmosphere|CartoonWater)', cls, re.I):
164
+ omit(cls)
165
+ continue
166
+ components = actor.get_components_by_class(unreal.StaticMeshComponent)
167
+ supported = 0
168
+ for component in components:
169
+ mesh = component.static_mesh
170
+ if not mesh or not component.is_visible():
171
+ continue
172
+ if isinstance(component, unreal.SplineMeshComponent):
173
+ omit('SplineMeshComponent (deformed mesh requires a dedicated bake)')
174
+ continue
175
+ materials = [component.get_material(i) for i in range(component.get_num_materials())]
176
+ paths = [m.get_path_name() if m else None for m in materials]
177
+ source = dict(mesh=mesh.get_path_name(), materials=paths)
178
+ key = hashlib.sha256(json.dumps(source, sort_keys=True).encode()).hexdigest()[:24]
179
+ definitions[key] = (mesh, materials, source)
180
+ pin(mesh.get_path_name())
181
+ for material in materials:
182
+ pin_material(material)
183
+ transforms = []
184
+ if isinstance(component, unreal.InstancedStaticMeshComponent):
185
+ transforms = [component.get_instance_transform(i, world_space=True) for i in range(component.get_instance_count())]
186
+ else:
187
+ transforms = [component.get_world_transform()]
188
+ for i, t in enumerate(transforms):
189
+ identifier = component.get_path_name() + ':' + str(i)
190
+ manifest['instances'].append(dict(id='ue_'+hashlib.sha256(identifier.encode()).hexdigest()[:24],
191
+ name=actor.get_actor_label()[:145], source=identifier, asset=key, transform=transform(t),
192
+ batch=isinstance(component, unreal.InstancedStaticMeshComponent)))
193
+ supported += len(transforms)
194
+ if not supported and not components and any(s in cls for s in ['Niagara', 'Decal', 'SpotLight', 'RectLight', 'PointLight']):
195
+ omit(cls)
196
+ manifest['source_snapshot'] = dependencies
197
+ if previous.get('source_snapshot') and previous['source_snapshot'] != dependencies:
198
+ raise RuntimeError('Source dependencies changed; use a new export job')
199
+ atomic(manifest_path, manifest)
200
+ assert len(manifest['instances']) <= 100000, 'Map exceeds 100,000 portable instances; export regions'
201
+ progress('inventory', instances=len(manifest['instances']), unique_assets=len(definitions), landscapes=len(landscapes))
202
+
203
+ # UE finishes compiling before render-target capture/material baking.
204
+ # Commandlets do not tick the editor to apply asynchronous shader results.
205
+ # Preload the export shader as well, before the native blocking compile command.
206
+ unreal.load_asset('/Engine/EditorLandscapeResources/Landscape_Heightmap_To_RenderTarget2D')
207
+ unreal.load_asset('/Engine/EditorLandscapeResources/Landscape_Weightmap_To_RenderTarget2D')
208
+ unreal.SystemLibrary.execute_console_command(world, 'Editor.AsyncAssetCompilationFinishAll')
209
+ for landscape in landscapes:
210
+ cancelled()
211
+ key = landscape.get_name()
212
+ old = next((t for t in manifest['terrains'] if t['id'] == key), None)
213
+ if old and old.get('bake_version') == 2 and all(done(old[k]) for k in ['heightmap', 'albedo']):
214
+ continue
215
+ progress('terrain', terrain=key)
216
+ rotation = landscape.get_actor_rotation()
217
+ assert abs(rotation.pitch)+abs(rotation.yaw)+abs(rotation.roll) < .001, 'Rotated Landscape needs a dedicated adapter'
218
+ components = landscape.get_components_by_class(unreal.LandscapeComponent)
219
+ bases = [(int(c.get_editor_property('section_base_x')), int(c.get_editor_property('section_base_y'))) for c in components]
220
+ q = native_prop(components[0], ['ComponentSizeQuads', 'component_size_quads'])
221
+ if not q:
222
+ diffs = [abs(b-a) for axis in [0,1] for a,b in zip(sorted(set(p[axis] for p in bases)), sorted(set(p[axis] for p in bases))[1:])]
223
+ assert diffs, 'Cannot determine single-component Landscape resolution'
224
+ q = math.gcd(*diffs)
225
+ minx, miny = min(p[0] for p in bases), min(p[1] for p in bases)
226
+ maxx, maxy = max(p[0] for p in bases)+q, max(p[1] for p in bases)+q
227
+ width, height = maxx-minx+1, maxy-miny+1
228
+ assert max(width,height) <= 8192, 'Landscape exceeds 8192 height samples; export regions'
229
+ rt = unreal.RenderingLibrary.create_render_target2d(world, width, height, unreal.TextureRenderTargetFormat.RTF_RGBA8)
230
+ assert landscape.landscape_export_heightmap_to_render_target(rt, True, False)
231
+ heightmap = png(rt, key+'-height-rg.png')
232
+ unreal.RenderingLibrary.release_render_target2d(rt)
233
+ visibility = None
234
+ layer_names = [str(n) for n in landscape.get_target_layer_names(True)] if hasattr(landscape, 'get_target_layer_names') else []
235
+ visibility_name = next((n for n in layer_names if 'visibility' in n.lower()), None)
236
+ if visibility_name:
237
+ rt = unreal.RenderingLibrary.create_render_target2d(world, width, height, unreal.TextureRenderTargetFormat.RTF_RGBA8)
238
+ assert landscape.landscape_export_weightmap_to_render_target(rt, visibility_name), 'Visibility mask export failed'
239
+ visibility = png(rt, key+'-visibility.png')
240
+ unreal.RenderingLibrary.release_render_target2d(rt)
241
+ p, s = landscape.get_actor_location(), landscape.get_actor_scale3d()
242
+ assert s.x > 0 and s.y > 0 and s.z > 0, 'Reflected Landscape requires a dedicated adapter'
243
+ center = unreal.Vector(p.x+(minx+maxx)*s.x/2, p.y+(miny+maxy)*s.y/2, p.z+200000)
244
+ capture = unreal.get_editor_subsystem(unreal.EditorActorSubsystem).spawn_actor_from_class(unreal.SceneCapture2D, center, unreal.Rotator(-90,-90,0))
245
+ comp = capture.get_component_by_class(unreal.SceneCaptureComponent2D)
246
+ size = request.get('terrain_texture_size', 4096)
247
+ rt = unreal.RenderingLibrary.create_render_target2d(world, size, size, unreal.TextureRenderTargetFormat.RTF_RGBA8)
248
+ comp.set_editor_property('texture_target', rt)
249
+ comp.set_editor_property('projection_type', unreal.CameraProjectionMode.ORTHOGRAPHIC)
250
+ # Export a square orthographic projection; the manifest retains the crop.
251
+ span = max((maxx-minx)*s.x, (maxy-miny)*s.y)
252
+ comp.set_editor_property('ortho_width', span)
253
+ comp.set_editor_property('capture_source', unreal.SceneCaptureSource.SCS_BASE_COLOR)
254
+ comp.set_editor_property('primitive_render_mode', unreal.SceneCapturePrimitiveRenderMode.PRM_USE_SHOW_ONLY_LIST)
255
+ comp.show_only_actor_components(landscape)
256
+ comp.set_editor_property('capture_every_frame', False)
257
+ comp.set_editor_property('capture_on_movement', False)
258
+ comp.capture_scene()
259
+ albedo = png(rt, key+'-albedo.png')
260
+ unreal.get_editor_subsystem(unreal.EditorActorSubsystem).destroy_actor(capture)
261
+ unreal.RenderingLibrary.release_render_target2d(rt)
262
+ row = dict(id=key, bake_version=2, heightmap=heightmap, albedo=albedo, width=width, height=height,
263
+ base=[minx,miny], components=bases, component_quads=q, origin=xyz(p), scale=xyz(s),
264
+ albedo_span_cm=span, material=landscape.get_editor_property('landscape_material').get_path_name())
265
+ if visibility:
266
+ row['visibility'] = visibility
267
+ manifest['terrains'] = [t for t in manifest['terrains'] if t['id'] != key]+[row]
268
+ atomic(manifest_path, manifest)
269
+
270
+ options = unreal.GLTFExportOptions()
271
+ options.set_editor_property('export_uniform_scale', .01)
272
+ options.set_editor_property('bake_material_inputs', unreal.GLTFMaterialBakeMode.USE_MESH_DATA)
273
+ options.set_editor_property('default_material_bake_size', unreal.GLTFMaterialBakeSize(request.get('texture_size',1024), request.get('texture_size',1024)))
274
+ options.set_editor_property('texture_image_format', unreal.GLTFTextureImageFormat.PNG)
275
+ options.set_editor_property('adjust_normalmaps', True)
276
+ options.set_editor_property('export_vertex_colors', False)
277
+ options.set_editor_property('export_lights', False)
278
+ options.set_editor_property('export_cameras', False)
279
+ options.set_editor_property('export_level_sequences', False)
280
+ (folder/'assets').mkdir(exist_ok=True)
281
+ for index, (key, (mesh, materials, source)) in enumerate(definitions.items()):
282
+ progress('meshes', completed=index, total=len(definitions), asset=mesh.get_name())
283
+ old = manifest['assets'].get(key)
284
+ if old and done(old):
285
+ continue
286
+ # Export one neutral instance of each mesh/material combination. No mutation of Library/native assets.
287
+ temp = unreal.get_editor_subsystem(unreal.EditorActorSubsystem).spawn_actor_from_class(unreal.StaticMeshActor, unreal.Vector(0,0,0))
288
+ comp = temp.static_mesh_component
289
+ comp.set_static_mesh(mesh)
290
+ for slot, material in enumerate(materials):
291
+ if material:
292
+ comp.set_material(slot, material)
293
+ name = 'assets/'+key+'.glb'
294
+ try:
295
+ unreal.GLTFExporter.export_to_gltf(world, str(folder/name), options, {temp})
296
+ assert (folder/name).is_file(), 'GLB export failed: '+source['mesh']
297
+ manifest['assets'][key] = dict(file_record(name), name=mesh.get_name(), source=source)
298
+ atomic(manifest_path, manifest)
299
+ finally:
300
+ unreal.get_editor_subsystem(unreal.EditorActorSubsystem).destroy_actor(temp)
301
+ if manifest['terrains']:
302
+ progress('native_reference')
303
+ terrain = manifest['terrains'][0]
304
+ o, s, base = terrain['origin'], terrain['scale'], terrain['base']
305
+ sx, sy = (terrain['width']-1)*s[0], (terrain['height']-1)*s[1]
306
+ span = max(sx, sy)
307
+ target = unreal.Vector(o[0]+(base[0]+(terrain['width']-1)/2)*s[0], o[1]+(base[1]+(terrain['height']-1)/2)*s[1], o[2])
308
+ position = target + unreal.Vector(span*.55, span*.65, span*.7)
309
+ capture = unreal.get_editor_subsystem(unreal.EditorActorSubsystem).spawn_actor_from_class(unreal.SceneCapture2D, position, unreal.MathLibrary.find_look_at_rotation(position,target))
310
+ try:
311
+ comp = capture.get_component_by_class(unreal.SceneCaptureComponent2D)
312
+ rt = unreal.RenderingLibrary.create_render_target2d(world, 1600, 900, unreal.TextureRenderTargetFormat.RTF_RGBA8)
313
+ comp.set_editor_property('texture_target', rt)
314
+ comp.set_editor_property('fov_angle', math.degrees(2*math.atan(math.tan(math.radians(55)/2)*1600/900)))
315
+ comp.set_editor_property('capture_source', unreal.SceneCaptureSource.SCS_FINAL_COLOR_LDR)
316
+ comp.set_editor_property('capture_every_frame', False)
317
+ comp.set_editor_property('capture_on_movement', False)
318
+ unreal.SystemLibrary.execute_console_command(world, 'Editor.AsyncAssetCompilationFinishAll')
319
+ comp.capture_scene()
320
+ manifest['native_reference'] = png(rt, 'unreal-overview.png')
321
+ unreal.RenderingLibrary.release_render_target2d(rt)
322
+ finally:
323
+ unreal.get_editor_subsystem(unreal.EditorActorSubsystem).destroy_actor(capture)
324
+ for file, before in dependencies.items():
325
+ info = Path(file).stat()
326
+ assert [info.st_size,info.st_mtime_ns] == before, 'Source changed during export: '+file
327
+ assert sha(source_file) == request['source_sha256'], 'Source level changed during export'
328
+ manifest['warnings'] = [
329
+ 'Native Blueprint logic, Niagara, decals, animated water and procedural sky are not portable; omitted classes are counted.',
330
+ 'Complex static materials are baked. Time/view/world-dependent shader behavior is not retained.',
331
+ 'Landscape base color is a top-down native bake; material paint layers remain a source provenance, not an editable UE shader graph.',
332
+ ]
333
+ manifest['status'] = 'exported-awaiting-gripforge-review'
334
+ atomic(manifest_path,manifest)
335
+ atomic(state_path,dict(status='succeeded',stage='exported',request_hash=fingerprint,updated_at=time.time(),
336
+ instances=len(manifest['instances']),assets=len(manifest['assets']),terrains=len(manifest['terrains']),lifecycle='work'))
337
+ except Exception as error:
338
+ atomic(state_path, dict(status='cancelled' if isinstance(error,InterruptedError) else 'failed',
339
+ error=str(error), traceback=traceback.format_exc(), request_hash=fingerprint,updated_at=time.time()))
340
+ unreal.log_error(traceback.format_exc())
341
+ raise