@gripforgeai/mcp 0.1.16 → 0.1.18

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.
@@ -1,15 +1,26 @@
1
1
  import { z } from 'zod/v4';
2
2
  import { LOOK_DESCRIPTION, LOOK_VALUES } from './look.js';
3
- export const ARCHITECTURE_TOOL_NAMES = ['gripforge_architecture_schema', 'gripforge_generate_building', 'gripforge_generate_district', 'gripforge_environment_module'];
3
+ export const ARCHITECTURE_TOOL_NAMES = ['gripforge_architecture_schema', 'gripforge_generate_building', 'gripforge_generate_district', 'gripforge_environment_module', 'gripforge_urban_building'];
4
4
  /** Hosted and local MCP share the same recipe and durable server workflow. */
5
5
  export function registerArchitectureTools(register, options, schema = z) {
6
6
  const identifier = schema.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,159}$/);
7
7
  const ref = schema.object({ assetId: identifier, revisionId: identifier, fileRole: identifier.optional() }).strict();
8
+ const point = schema.tuple([schema.number().min(-150).max(150), schema.number().min(-150).max(150), schema.number().min(-150).max(150)]);
9
+ const finishMaterial = { id: schema.string().regex(/^[a-zA-Z0-9_-]{1,48}$/), color: schema.string().regex(/^#[0-9a-f]{6}$/i), roughness: schema.number().min(.05).max(1).optional(), metalness: schema.number().min(0).max(1).optional() };
10
+ const finish = schema.object({ version: schema.literal(1),
11
+ remove: schema.array(schema.object({ center: point, size: schema.tuple([schema.number().min(.001).max(100), schema.number().min(.001).max(100), schema.number().min(.001).max(100)]), rotation: schema.tuple([schema.number().min(-Math.PI * 2).max(Math.PI * 2), schema.number().min(-Math.PI * 2).max(Math.PI * 2), schema.number().min(-Math.PI * 2).max(Math.PI * 2)]).optional() }).strict()).max(64),
12
+ parts: schema.array(schema.discriminatedUnion('kind', [
13
+ schema.object({ kind: schema.literal('shutter'), ...finishMaterial, origin: point, yaw: schema.number().min(-Math.PI * 2).max(Math.PI * 2).optional(), width: schema.number().min(.2).max(15), height: schema.number().min(.1).max(12), slatHeight: schema.number().min(.035).max(.3).optional() }).strict(),
14
+ schema.object({ kind: schema.literal('railing'), ...finishMaterial, points: schema.array(point).min(2).max(16), height: schema.number().min(.1).max(12), postSpacing: schema.number().min(.2).max(3).optional(), barSpacing: schema.number().min(.05).max(.5).optional(), postRadius: schema.number().min(.015).max(.1).optional(), barRadius: schema.number().min(.006).max(.05).optional(), railRadius: schema.number().min(.01).max(.08).optional() }).strict(),
15
+ ])).min(1).max(32),
16
+ }).strict().describe('Free deterministic repair of an owned source + delivery. All coordinates are Y-up metres AFTER the returned importTransform. Explicit oriented removal boxes (rotation XYZ radians), regular shutters (+Z outward, yaw radians) and railing paths (feet, vertical posts, supports slopes). Measure and review regions first. Applied AFTER each LOD simplification to keep thin parts straight. Textures outside cuts stay byte-identical; removed surfaces are not capped. Triangle targets remain soft. No AI auto-segmentation or collision repair.');
8
17
  const building = schema.object({
9
18
  id: schema.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,47}$/).optional(),
10
19
  name: schema.string().min(1).max(100).optional(), prompt: schema.string().min(3).max(300),
11
20
  style: schema.enum(['realistic', 'stylized', 'lowpoly', 'handpainted']).optional(),
12
21
  look: schema.enum(LOOK_VALUES).optional().describe(`${LOOK_DESCRIPTION} Fills style when style is omitted; toon, anime and pixel add their phrase to the prompt.`),
22
+ provider: schema.enum(['meshy', 'tripo']).optional().describe('3D provider pinned to this recipe, Meshy by default. Tripo v3.1 requires one reviewed concept (no multiview yet) and uses v3.5 PBR textures. No automatic paid provider fallback.'),
23
+ tripoQuality: schema.object({ geometry: schema.enum(['standard', 'detailed']).optional(), texture: schema.enum(['standard', 'detailed', 'extreme']).optional() }).strict().optional().describe('Tripo-only quality; default detailed geometry and detailed textures. extreme requests 8K. Read the separate provider quote. Exclusive with Meshy meshQuality and source.'),
13
24
  use: schema.enum(['residential', 'retail', 'office', 'industrial', 'mixed']).optional(),
14
25
  floors: schema.number().int().min(1).max(30).optional(),
15
26
  dimensions: schema.object({ width: schema.number().min(3).max(100).optional(), depth: schema.number().min(3).max(100).optional(), height: schema.number().min(3).max(150).optional() }).strict().optional().describe('Maximum footprint and height in metres. Uniform fit preserves proportions; actual dimensions are returned.'),
@@ -18,7 +29,9 @@ export function registerArchitectureTools(register, options, schema = z) {
18
29
  concept: ref.optional().describe('Owned PNG/JPEG/WebP revision for Meshy image-to-3D. At most 12 MiB. Mutually exclusive with source.'),
19
30
  concepts: schema.array(ref).min(1).max(4).optional().describe('Coherent views of ONE building, front first, for Meshy multi-image-to-3D. Exclusive with concept/source. Review coherence before building.'),
20
31
  meshQuality: schema.object({ geometry: schema.enum(['standard', '2k', '4k']).optional(), texture: schema.enum(['2k', '4k', '8k']).optional() }).strict().optional().describe('Meshy 7.1 quality. Defaults standard geometry and 2K PBR. Multi-image supports standard/2k geometry only. Texture resolution is preserved during optimization. Read the updated quote.'),
32
+ delivery: schema.object({ lods: schema.array(schema.object({ triangles: schema.number().int().min(1000).max(200000), textureSize: schema.union([schema.literal(1024), schema.literal(2048), schema.literal(4096), schema.literal(8192)]), maxError: schema.number().min(0).max(.02).optional(), lockBorder: schema.boolean().optional() }).strict()).min(1).max(3) }).strict().optional().describe('Preserve an immutable full-detail source without provider remeshing, then prepare 1–3 independent LOD GLBs. Highest detail first, decreasing triangles and texture sizes. Default maxError=.001, lockBorder=true prioritizes detail and may exceed targets. For distant levels, explicitly allow e.g. maxError=.005/.01 and lockBorder=false after review. Shared metric fit and preserved PBR. No normal rebake, runtime LOD switching or inferred collision. Also works with source at no provider cost.'),
21
33
  source: ref.optional().describe('Reuse an owned static GLB revision instead of paying for this building. The source is never modified.'),
34
+ finish: finish.optional(),
22
35
  }).strict();
23
36
  const material = schema.record(schema.string(), schema.unknown()).describe('SceneMaterialOverride: color, roughness, metalness, map/normalMap/roughnessMap/metalnessMap as owned immutable SceneAssetRef, repeat:[u,v]. Validated by the shared Scene Engine.');
24
37
  const district = schema.object({
@@ -42,18 +55,19 @@ export function registerArchitectureTools(register, options, schema = z) {
42
55
  palette: schema.object({ stone: schema.string().regex(/^#[0-9a-f]{6}$/i).optional(), recess: schema.string().regex(/^#[0-9a-f]{6}$/i).optional(), trim: schema.string().regex(/^#[0-9a-f]{6}$/i).optional(), water: schema.string().regex(/^#[0-9a-f]{6}$/i).optional() }).strict().optional(),
43
56
  }).strict();
44
57
  const definitions = [
45
- { name: ARCHITECTURE_TOOL_NAMES[0], kind: 'schema', shape: {}, title: 'Architecture · schema', description: 'Read the reusable Meshy building and district contract, example, limits and plan → build workflow. Separate immutable Library assets, scene instances and shared SceneDocument. No paid generation.' },
58
+ { name: ARCHITECTURE_TOOL_NAMES[4], kind: 'urban', title: 'Manufacture a reusable urban building', description: 'Free, deterministic parametric architecture. plan validates a metric recipe; build queues a durable common-compute job and returns job_id to poll with gripforge_generation_read. Real window reveals, balconies, shopfronts, rooftop HVAC, photographic PBR and optional arid shutters/canopies/water tanks. Private immutable GLB plus recipe/report; reuse via Library bindings or scene instances. Nominal width/depth exclude protrusions: use returned actual bounds and preserve proportions. A solid envelope with storefront depth, not traversable interiors, a complete city or an AI replacement for gripforge_generate_building. Review in the actual game. 0 credits.', shape: { stage: schema.enum(['plan', 'build']).optional(), workspace_id: schema.string().max(100).optional(), idempotency_key: shared.idempotency_key, recipe: schema.object({ name: schema.string().min(1).max(100), style: schema.enum(['brick', 'balconies', 'limestone', 'loft', 'workshop', 'office', 'stucco']), width: schema.number().min(6).max(30).optional(), depth: schema.number().min(6).max(30).optional(), floors: schema.number().int().min(1).max(20).optional(), seed: schema.number().int().min(0).max(2147483646).optional(), shop: schema.number().int().min(0).max(7).optional(), climate: schema.enum(['temperate', 'arid']).optional(), palette: schema.object({ facade: schema.string().regex(/^#[a-fA-F0-9]{6}$/).optional(), stone: schema.string().regex(/^#[a-fA-F0-9]{6}$/).optional(), trim: schema.string().regex(/^#[a-fA-F0-9]{6}$/).optional(), roof: schema.string().regex(/^#[a-fA-F0-9]{6}$/).optional() }).strict().optional() }).strict() } },
59
+ { name: ARCHITECTURE_TOOL_NAMES[0], kind: 'schema', shape: {}, title: 'Architecture · schema', description: 'Read the reusable Meshy/Tripo building and district contract, example, limits and plan → build workflow. Separate immutable Library assets, scene instances and shared SceneDocument. No paid generation.' },
46
60
  { name: ARCHITECTURE_TOOL_NAMES[1], kind: 'building', shape: { ...shared, recipe: building,
47
61
  stage: schema.enum(['plan', 'concept', 'build']).optional().describe('plan is free: exact prompts and separate image/3D quotes. concept creates reviewable workspace images. First review artistic scene, then isolated views of the SAME building, then build 3D from chosen isolated references.'),
48
62
  concept_provider: schema.enum(['openai', 'xai']).optional().describe('Default openai (GPT Image 2.5 Sunburst, high quality). xai explicitly selects Imagine. Provider is pinned in the durable job; never silently falls back. Read the provider-specific concept_quote.'),
49
- concept_presentation: schema.enum(['scene', 'isolated']).optional().describe('Default scene: artistic concept in its neighbourhood preserving the user atmosphere and lighting. isolated: technical views of the SAME building for Meshy. Keep rich architectural detail in both.'),
63
+ concept_presentation: schema.enum(['scene', 'isolated']).optional().describe('Default scene: artistic concept in its neighbourhood preserving the user atmosphere and lighting. isolated: technical views of the SAME building for 3D reconstruction. Keep rich architectural detail in both.'),
50
64
  concept_reference: ref.optional().describe('Owned artistic reference to EDIT into new scene/isolated views. Distinct from recipe.concept, which reuses an existing front view without generation. Exclusive with recipe.concept/concepts/source and stage=build.'),
51
- concept_views: schema.array(schema.enum(['front_right', 'front_left', 'rear_right', 'rear_left'])).min(1).max(4).optional().describe('Unique views starting with front_right. Default one view for scene, three for isolated. Alternate views edit the SAME master. recipe.concept reuses an existing master; concept_reference guides a NEW master.'),
52
- }, title: 'Generate a building', description: 'Reusable plan → artistic concept → isolated reference views → review → Meshy build workflow. Generate 1–4 coherent images through OpenAI (1536×1024 high quality, default) or Imagine (2K), saved as private Library drafts; alternate views reference one master. Separate explicit budgets for concepts and 3D. Accepts text, owned single/multiple concept views or an owned static GLB. Meshy PBR/geometry quality, bounded geometry and uniform metric fit. Durable jobs return workspace links; poll generation_read, cancel/retry preserves finished steps. Review images before building and the real Studio render before publishing. No guaranteed interiors, collisions or LODs. Never substitutes procedural geometry or automatically replaces a game asset.' },
53
- { name: ARCHITECTURE_TOOL_NAMES[2], kind: 'district', shape: { ...shared, recipe: district }, title: 'Generate a district', description: 'Plan then build a straight-street district from 1–8 distinct Meshy buildings, each manufactured once and reused as separate editable instances. Includes road, pavements, spawn, daylight and camera in the shared Map SceneDocument. Optional owned PBR road/pavement maps; otherwise simple solid surfaces. Plan returns layout, provider-credit count and account quote without spending. Build requires a budget and returns a persistent job, then Map Studio link. Private work version requiring visual review; no automatic Community publication or game replacement.' },
65
+ concept_views: schema.array(schema.enum(['front_right', 'front_left', 'rear_right', 'rear_left'])).min(1).max(4).optional().describe('Unique views starting with front_right. Default one view for scene or Tripo, three for isolated Meshy. Tripo requires one view. Alternate views edit the SAME master. recipe.concept reuses an existing master; concept_reference guides a NEW master.'),
66
+ }, title: 'Generate a building', description: 'Reusable plan → artistic concept → isolated reference views → review → Meshy or explicitly selected Tripo build workflow. Generate 1–4 images through OpenAI or Imagine; alternate views reference one master but must be checked for actually different viewpoints. If edits repeat the front, use one good concept instead of a false multiview set. Separate explicit budgets for concepts and 3D. Accepts text, owned concept views or a static GLB. Optional delivery preserves the source master and prepares reusable LOD GLBs with PBR and a common metric fit. Durable jobs return workspace links; poll generation_read, cancel/retry preserves finished steps. Review the actual render before publishing. No guaranteed interiors, collisions or runtime LOD switching; no automatic game replacement.' },
67
+ { name: ARCHITECTURE_TOOL_NAMES[2], kind: 'district', shape: { ...shared, recipe: district }, title: 'Generate a district', description: 'Plan then build a straight-street district from 1–8 distinct Meshy/Tripo buildings, each manufactured once and reused as separate editable instances. Includes road, pavements, spawn, daylight and camera in the shared Map SceneDocument. Optional owned PBR road/pavement maps; otherwise simple solid surfaces. Plan returns layout, provider-credit count and account quote without spending. Build requires a budget and returns a persistent job, then Map Studio link. Private work version requiring visual review; no automatic Community publication or game replacement.' },
54
68
  { name: ARCHITECTURE_TOOL_NAMES[3], kind: 'module', shape: { stage: schema.enum(['inspect', 'build']).optional(), recipe: moduleRecipe, name: schema.string().min(1).max(100).optional(), workspace_id: schema.string().max(100).optional() }, title: 'Create a reusable environment module', description: 'Free parametric 3D masonry, independent of any game: pillar, arch, fountain, stairs, wall, pavement, rock, cliff, ruin, bridge, watchtower, shrine or a stone guardian. inspect (default) returns real generated bounds, triangle count, metric recipe and assembly sockets. build saves a private textured GLB in the Library, with bevelled geometry, baked vertex shading and named stone/recess/trim/water materials. Dimensions are nominal metres; bounds report actual moulding overhang. Ground origin, stairs ascend toward -Z, <=23 cm risers; top/bottom sockets support assembly. Seed and palette are repeatable. Does not place assets, generate arbitrary AI models, publish, or guarantee navigation/collisions in a target game. 0 credits.' },
55
69
  ];
56
- for (const tool of definitions)
70
+ for (const tool of definitions.sort((a, b) => ARCHITECTURE_TOOL_NAMES.indexOf(a.name) - ARCHITECTURE_TOOL_NAMES.indexOf(b.name)))
57
71
  register(tool.name, { title: tool.title, description: tool.description, inputSchema: tool.shape,
58
72
  annotations: { readOnlyHint: tool.kind === 'schema', destructiveHint: false, idempotentHint: tool.kind === 'schema', openWorldHint: tool.kind !== 'schema' } }, async (args, extra) => {
59
73
  const parsed = schema.object(tool.shape).strict().safeParse(args);
@@ -64,10 +78,10 @@ export function registerArchitectureTools(register, options, schema = z) {
64
78
  return { isError: true, content: [{ type: 'text', text: 'GripForge API key required.' }] };
65
79
  const { workspace_id, ...body } = parsed.data;
66
80
  try {
67
- const response = await fetch(options.apiUrl.replace(/\/$/, '') + (tool.kind === 'module' ? '/api/v1/environment-modules' : '/api/v1/architecture'), {
81
+ const response = await fetch(options.apiUrl.replace(/\/$/, '') + (tool.kind === 'module' ? '/api/v1/environment-modules' : tool.kind === 'urban' ? '/api/v1/urban-buildings' : '/api/v1/architecture'), {
68
82
  method: tool.kind === 'schema' ? 'GET' : 'POST',
69
83
  headers: { 'content-type': 'application/json', 'x-gripforge-client': 'mcp', ...(key ? { 'x-api-key': key } : {}), ...(typeof workspace_id === 'string' ? { 'x-workspace-id': workspace_id } : {}) },
70
- ...(tool.kind === 'schema' ? {} : { body: JSON.stringify(tool.kind === 'module' ? body : { ...body, kind: tool.kind }) }),
84
+ ...(tool.kind === 'schema' ? {} : { body: JSON.stringify(['module', 'urban'].includes(tool.kind) ? body : { ...body, kind: tool.kind }) }),
71
85
  signal: AbortSignal.any([AbortSignal.timeout(60_000), ...(extra?.signal ? [extra.signal] : [])]),
72
86
  });
73
87
  const data = await response.json();
@@ -0,0 +1,457 @@
1
+ import { constants } from 'node:fs';
2
+ import { readdir, mkdir, readFile, writeFile, rename, realpath, stat, open, access, unlink } from 'node:fs/promises';
3
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { createHash, randomBytes } from 'node:crypto';
5
+ import { homedir } from 'node:os';
6
+ import { normalizedPath } from './game-import-normalize.js';
7
+ const MAX_FILES = 20000, MAX_BYTES = 256 * 1024 * 1024, MAX_JSON = 8 * 1024 * 1024;
8
+ const OMIT = new Set(['.git', '.svn', 'node_modules', '.env', '.aws', '.ssh', 'Library', 'Temp', 'Logs', 'obj', '.DS_Store']);
9
+ export const importHome = () => process.env.GRIPFORGE_GAME_IMPORT_HOME || join(homedir(), '.gripforge', 'game-imports');
10
+ export function jobDirectory(id) {
11
+ if (!/^gi_[a-f0-9]{24}$/.test(id))
12
+ throw Error('Invalid import id');
13
+ return join(importHome(), id);
14
+ }
15
+ export async function loadImportJob(id) { return JSON.parse(await readFile(join(jobDirectory(id), 'job.json'), 'utf8')); }
16
+ export async function saveImportJob(job) {
17
+ const dir = jobDirectory(job.id);
18
+ await mkdir(dir, { recursive: true, mode: 0o700 });
19
+ job.updatedAt = new Date().toISOString();
20
+ const tmp = join(dir, `job.${process.pid}.tmp`);
21
+ await writeFile(tmp, JSON.stringify(job, null, 2), { mode: 0o600 });
22
+ await rename(tmp, join(dir, 'job.json'));
23
+ if (job.report) {
24
+ const report = join(dir, `report.${process.pid}.tmp`);
25
+ await writeFile(report, JSON.stringify(job.report, null, 2), { mode: 0o600 });
26
+ await rename(report, join(dir, 'report.json'));
27
+ }
28
+ }
29
+ export async function newImportJob(path) {
30
+ if (!isAbsolute(path))
31
+ throw Error('Use an absolute local directory path');
32
+ const root = await realpath(path);
33
+ if (!(await stat(root)).isDirectory())
34
+ throw Error('Source must be a directory');
35
+ if (root === sep || root === homedir())
36
+ throw Error('Select the game/project directory, not the whole computer');
37
+ const job = { schema: 'gripforge.game-import-job.v1', id: `gi_${randomBytes(12).toString('hex')}`, root, phase: 'analyze', status: 'queued', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), completed: 0, total: 0, receipts: {}, errors: {} };
38
+ await saveImportJob(job);
39
+ return job;
40
+ }
41
+ export async function confinedFile(root, path) {
42
+ if (!path || isAbsolute(path) || path.split(/[\\/]/).includes('..'))
43
+ throw Error('Unsafe source reference');
44
+ const requested = resolve(root, path), actual = await realpath(requested);
45
+ if (actual !== requested || !actual.startsWith(root + sep))
46
+ throw Error('Symlink or external source reference rejected');
47
+ const info = await stat(actual);
48
+ if (!info.isFile() || info.size > MAX_BYTES)
49
+ throw Error('File unavailable or exceeds 256 MiB');
50
+ return actual;
51
+ }
52
+ async function readSource(root, path) {
53
+ const file = await confinedFile(root, path), handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
54
+ try {
55
+ const s = await handle.stat();
56
+ if (s.size > MAX_BYTES)
57
+ throw Error('File exceeds 256 MiB');
58
+ return await handle.readFile();
59
+ }
60
+ finally {
61
+ await handle.close();
62
+ }
63
+ }
64
+ const digest = (data) => createHash('sha256').update(data).digest('hex');
65
+ const media = new Set(['.png', '.jpg', '.jpeg', '.webp', '.wav', '.ogg', '.mp3']);
66
+ const structured = new Set(['.prefab', '.unity', '.mat', '.asset', '.anim', '.meta', '.cs', '.tscn', '.tres', '.uproject', '.uasset', '.umap', '.pak', '.utoc', '.ucas', '.dll', '.bundle', '.assets', '.fbx', '.obj', '.glb', '.gltf', '.dds', '.tga', '.shader', '.vfx']);
67
+ const nameRules = [
68
+ [/(^|[\W_])(sword|longsword|katana|axe|rifle|ak47|pistol|shotgun|weapon)([\W_]|$)/i, 'weapon'],
69
+ [/(^|[\W_])(building|house|warehouse|tower)([\W_]|$)/i, 'building'],
70
+ [/(^|[\W_])(vehicle|car|truck|kart)([\W_]|$)/i, 'vehicle'],
71
+ [/(^|[\W_])(character|soldier|hero|npc|enemy)([\W_]|$)/i, 'character'],
72
+ [/(^|[\W_])(terrain|landscape|environment)([\W_]|$)/i, 'environment'],
73
+ ];
74
+ function gltfJSON(bytes, ext) {
75
+ if (ext === '.gltf') {
76
+ if (bytes.length > MAX_JSON)
77
+ throw Error('glTF JSON exceeds limit');
78
+ return JSON.parse(bytes.toString());
79
+ }
80
+ if (bytes.length < 20 || bytes.readUInt32LE(0) !== 0x46546c67 || bytes.readUInt32LE(4) !== 2 || bytes.readUInt32LE(8) !== bytes.length || bytes.readUInt32LE(16) !== 0x4e4f534a)
81
+ throw Error('Invalid GLB header');
82
+ const len = bytes.readUInt32LE(12);
83
+ if (len > MAX_JSON || 20 + len > bytes.length)
84
+ throw Error('Invalid GLB JSON length');
85
+ return JSON.parse(bytes.subarray(20, 20 + len).toString());
86
+ }
87
+ export function inspectAsset(path, bytes) {
88
+ const ext = extname(path).toLowerCase(), sha256 = digest(bytes), evidence = [];
89
+ const a = { id: `src_${digest(path).slice(0, 24)}`, path, name: basename(path, ext), format: ext.slice(1), bytes: bytes.length, sha256, kind: 'unknown', evidence, status: 'blocked', details: {}, suggestedKits: [] };
90
+ if (['.glb', '.gltf', '.fbx', '.obj'].includes(ext)) {
91
+ a.kind = 'prop';
92
+ const rule = nameRules.find(([r]) => r.test(path));
93
+ evidence.push({ source: path, reason: 'Mesh file format; role needs review', confidence: .4 });
94
+ if (rule) {
95
+ a.kind = rule[1];
96
+ evidence.push({ source: path, reason: 'Role suggested by filename, not verified visually', confidence: .55 });
97
+ }
98
+ if (ext === '.glb' || ext === '.gltf') {
99
+ try {
100
+ const doc = gltfJSON(bytes, ext);
101
+ if (doc.asset?.version !== '2.0')
102
+ throw Error('Requires glTF 2');
103
+ a.details.meshes = doc.meshes?.length ?? 0;
104
+ a.details.materials = (doc.materials ?? []).map((m, i) => String(m.name ?? `material_${i}`));
105
+ a.details.clips = (doc.animations ?? []).map((m, i) => String(m.name ?? `clip_${i}`));
106
+ a.details.joints = [...new Set((doc.skins ?? []).flatMap((s) => (s.joints ?? []).map((i) => String(doc.nodes?.[i]?.name ?? `joint_${i}`))))];
107
+ if (a.details.joints.length) {
108
+ a.details.rig = 'unclassified';
109
+ evidence.push({ source: path, reason: 'glTF skin joints found; anatomy/retargeting not validated', confidence: 1 });
110
+ }
111
+ if (!a.details.meshes && a.details.clips?.length)
112
+ a.kind = 'animation';
113
+ a.details.dependencies = [...(doc.buffers ?? []), ...(doc.images ?? [])].map((b) => b.uri).filter((s) => typeof s === 'string' && !s.startsWith('data:'));
114
+ a.status = ext === '.glb' && !a.details.dependencies.length ? 'ready' : 'convertible';
115
+ if (ext === '.glb' && a.details.dependencies.length) {
116
+ a.status = 'blocked';
117
+ a.reason = 'GLB references external resources; export a self-contained GLB';
118
+ }
119
+ }
120
+ catch (e) {
121
+ a.reason = String(e);
122
+ }
123
+ }
124
+ else
125
+ a.reason = 'Export to GLB with Blender or the source editor before import';
126
+ }
127
+ else if (media.has(ext)) {
128
+ const valid = ext === '.png' ? bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) : ['.jpg', '.jpeg'].includes(ext) ? bytes[0] === 255 && bytes[1] === 216 : ext === '.webp' ? bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP' : ext === '.wav' ? bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WAVE' : ext === '.ogg' ? bytes.toString('ascii', 0, 4) === 'OggS' : bytes.toString('ascii', 0, 3) === 'ID3' || bytes[0] === 255 && (bytes[1] & 0xe0) === 0xe0;
129
+ a.kind = ['.wav', '.ogg', '.mp3'].includes(ext) ? 'audio' : 'texture';
130
+ a.status = valid ? 'ready' : 'blocked';
131
+ if (!valid)
132
+ a.reason = 'File signature does not match extension';
133
+ evidence.push({ source: path, reason: valid ? 'Media signature verified' : 'Invalid media signature', confidence: 1 });
134
+ }
135
+ else {
136
+ const kind = { '.prefab': 'scene', '.unity': 'scene', '.umap': 'scene', '.tscn': 'scene', '.mat': 'material', '.anim': 'animation', '.cs': 'script', '.dll': 'script', '.shader': 'script', '.vfx': 'vfx', '.pak': 'container', '.utoc': 'container', '.ucas': 'container', '.assets': 'container', '.bundle': 'container' };
137
+ a.kind = kind[ext] ?? 'unknown';
138
+ a.reason = 'Requires an engine exporter or format-specific converter';
139
+ }
140
+ if (a.kind === 'weapon')
141
+ a.suggestedKits = ['weapons.loadout', 'combat.melee', 'combat.projectiles'];
142
+ if (a.kind === 'vehicle')
143
+ a.suggestedKits = ['vehicle.driveable', 'vehicle.enter_exit'];
144
+ return a;
145
+ }
146
+ /** Local orchestration, not a renderer. Source files are never executed or modified. */
147
+ export class GameImportAgent {
148
+ job;
149
+ constructor(job) {
150
+ this.job = job;
151
+ }
152
+ async cancelled() { try {
153
+ await access(join(jobDirectory(this.job.id), 'cancel'));
154
+ return true;
155
+ }
156
+ catch {
157
+ return false;
158
+ } }
159
+ async checkpoint() { if (await this.cancelled()) {
160
+ this.job.status = 'cancelled';
161
+ await saveImportJob(this.job);
162
+ throw Error('Import cancelled');
163
+ } await saveImportJob(this.job); }
164
+ async discover_assets() {
165
+ const files = [], warnings = [], home = await realpath(importHome()), skipHome = !this.job.root.startsWith(home + sep);
166
+ const walk = async (dir, depth) => {
167
+ if (depth > 32) {
168
+ warnings.push(`Depth limit: ${relative(this.job.root, dir)}`);
169
+ return;
170
+ }
171
+ if (await this.cancelled())
172
+ throw Error('Import cancelled');
173
+ const entries = await readdir(dir, { withFileTypes: true });
174
+ for (const e of entries) {
175
+ if (OMIT.has(e.name) || e.name.startsWith('.env') || e.name.startsWith('.'))
176
+ continue;
177
+ const p = join(dir, e.name);
178
+ if (skipHome && (p === home || p.startsWith(home + sep)))
179
+ continue;
180
+ if (e.isSymbolicLink()) {
181
+ warnings.push(`Skipped symlink: ${relative(this.job.root, p)}`);
182
+ continue;
183
+ }
184
+ if (e.isDirectory())
185
+ await walk(p, depth + 1);
186
+ else if (e.isFile()) {
187
+ if (files.length >= MAX_FILES)
188
+ throw Error('Scan exceeds 20,000 files; select a smaller source directory');
189
+ files.push(relative(this.job.root, p).split(sep).join('/'));
190
+ }
191
+ }
192
+ };
193
+ await walk(this.job.root, 0);
194
+ return { files: files.sort(), warnings };
195
+ }
196
+ async detect_engine(files) {
197
+ const has = (r) => files.find(p => r.test(p));
198
+ const unity = has(/(?:UnityPlayer\.(?:dll|so|dylib)|global-metadata\.dat|ProjectSettings\/ProjectVersion\.txt|(?:^|\/)[^/]+_Data\/)/i);
199
+ const unreal = has(/\.uproject$|\.utoc$|(?:^|\/)Content\/Paks\//i);
200
+ const godot = has(/(?:^|\/)project\.godot$|\.pck$/i);
201
+ const web = has(/(?:^|\/)package\.json$/i);
202
+ const name = unity ? 'unity' : unreal ? 'unreal' : godot ? 'godot' : web ? 'web' : 'unknown';
203
+ const evidence = [{ source: unity ?? unreal ?? godot ?? web ?? '.', reason: name === 'unknown' ? 'No recognized engine marker; do not infer native engine from absence' : 'Engine filesystem marker', confidence: name === 'unknown' ? 0 : web ? .4 : .95 }];
204
+ let version;
205
+ const vf = has(/ProjectSettings\/ProjectVersion\.txt$/i);
206
+ if (vf)
207
+ version = (await readSource(this.job.root, vf)).toString().match(/m_EditorVersion:\s*(\S+)/)?.[1];
208
+ const uf = has(/\.uproject$/i);
209
+ if (uf) {
210
+ try {
211
+ version = JSON.parse((await readSource(this.job.root, uf)).toString()).EngineAssociation;
212
+ }
213
+ catch { }
214
+ }
215
+ return { name, version, runtime: name === 'unity' ? has(/global-metadata\.dat$|GameAssembly\.dll$/i) ? 'IL2CPP' : has(/Managed\/Assembly-CSharp\.dll$/i) ? 'Mono' : 'unknown' : name === 'unreal' ? 'native' : name === 'godot' ? 'GDScript / native (unverified)' : 'unknown', evidence };
216
+ }
217
+ select_toolchain(engine) {
218
+ const steps = [{ tool: 'GripForge file importer', purpose: 'Inspect GLB, pack glTF, index images/audio and Unity GUID references', status: 'builtin', reason: 'Known portable formats first' }];
219
+ if (engine.name === 'unity') {
220
+ steps.push({ tool: 'AssetRipper', purpose: 'Export Unity assets through an installed local AssetRipper HTTP instance, then rescan', status: 'external', reason: 'Set GRIPFORGE_ASSETRIPPER_URL to a dedicated loopback instance; external GPL tool, not bundled' });
221
+ steps.push({ tool: 'AssetStudio', purpose: 'Alternative asset exploration/export', status: 'manual', reason: 'Archived original repository; use only for compatible builds' });
222
+ if (engine.runtime === 'IL2CPP')
223
+ steps.push({ tool: 'Cpp2IL', purpose: 'Recover classes and metadata, not meshes', status: 'external', reason: 'Run an installed version with its documented options in an isolated output directory' });
224
+ else if (engine.runtime === 'Mono')
225
+ steps.push({ tool: 'ILSpy', purpose: 'Inspect managed controllers; code stays local', status: 'external', reason: 'Optional installed ilspycmd; does not export meshes' });
226
+ }
227
+ else if (engine.name === 'unreal')
228
+ steps.push({ tool: 'Unreal editor / GripForge Unreal export', purpose: 'Export an owned project through the existing Unreal scene pipeline', status: 'external', reason: 'Native project path supported; cooked packages require compatible extractor, not direct GLB import' });
229
+ else if (engine.name === 'godot')
230
+ steps.push({ tool: 'Godot editor', purpose: 'Export source scenes/meshes and inspect resources', status: 'manual', reason: 'PCK archives are indexed, not unpacked by this version' });
231
+ steps.push({ tool: 'Blender', purpose: 'Normalize extracted FBX/OBJ/DDS/TGA through a durable local manufacture worker', status: 'external', reason: 'Configure GRIPFORGE_BLENDER_BIN; preserves available embedded materials/rig/clips, missing resources require a self-contained source export' });
232
+ if (engine.name === 'unknown')
233
+ steps.push({ tool: 'Ghidra MCP / REA', purpose: 'Investigate unknown formats and document a converter', status: 'external', reason: 'Configure GRIPFORGE_GHIDRA_MCP_URL; use the bounded gripforge_reverse_* abstraction. Fallback only. Analysis is not an asset conversion; no untrusted game code is launched' });
234
+ return steps;
235
+ }
236
+ async analyze_asset_relationships(report) {
237
+ const guids = new Map();
238
+ for (const a of report.assets) {
239
+ try {
240
+ const meta = (await readSource(this.job.root, a.path + '.meta')).toString();
241
+ const guid = meta.match(/^guid:\s*([a-f0-9]{32})/m)?.[1];
242
+ if (guid) {
243
+ a.details.guid = guid;
244
+ if (guids.has(guid))
245
+ report.warnings.push(`Duplicate Unity GUID: ${guid}`);
246
+ else
247
+ guids.set(guid, a.id);
248
+ }
249
+ }
250
+ catch { }
251
+ }
252
+ for (const a of report.assets) {
253
+ if (!['prefab', 'unity', 'mat', 'asset', 'anim'].includes(a.format) || a.bytes > MAX_JSON)
254
+ continue;
255
+ const text = (await readSource(this.job.root, a.path)).toString();
256
+ for (const guid of new Set([...text.matchAll(/guid:\s*([a-f0-9]{32})/g)].map(m => m[1]))) {
257
+ const to = guids.get(guid);
258
+ if (to && to !== a.id)
259
+ report.relationships.push({ from: a.id, to, type: 'references', evidence: { source: a.path, reason: `Serialized Unity GUID reference ${guid}`, confidence: 1 } });
260
+ }
261
+ }
262
+ }
263
+ async analyze() {
264
+ const { files, warnings } = await this.discover_assets(), engine = await this.detect_engine(files);
265
+ const candidates = files.filter(p => media.has(extname(p).toLowerCase()) || structured.has(extname(p).toLowerCase())).filter(p => !p.endsWith('.meta'));
266
+ const report = { schema: 'gripforge.game-import.v1', id: this.job.id, source: basename(this.job.root), createdAt: this.job.createdAt, engine, assets: [], relationships: [], counts: {}, warnings, toolchain: this.select_toolchain(engine), normalization: { supported: ['Self-contained GLB preserving PBR/skins/clips', 'glTF 2 with embedded local buffers/images → GLB', 'PNG/JPEG/WebP and WAV/OGG/MP3', 'Installed Blender FBX/OBJ → GLB and DDS/TGA → PNG with persistent provenance'], requiresReview: ['Semantic kind/subtype', 'Skeleton anatomy and retargeting', 'PBR reconstruction for legacy formats', 'LOD and colliders', 'Sockets and Game Kit behavior mapping', 'GripForge renderer visual review before promotion'] } };
267
+ const previous = new Map(this.job.report?.assets.map(a => [a.path, a]) ?? []);
268
+ this.job.report = report;
269
+ this.job.total = candidates.length;
270
+ this.job.completed = 0;
271
+ for (const p of candidates) {
272
+ try {
273
+ const bytes = await readSource(this.job.root, p);
274
+ const old = previous.get(p);
275
+ const a = old?.sha256 === digest(bytes) ? old : inspectAsset(p, bytes);
276
+ if (a.format === 'gltf' && a.status === 'convertible') {
277
+ a.details.resources = {};
278
+ try {
279
+ for (const uri of a.details.dependencies ?? []) {
280
+ if (/^[a-z]+:/i.test(uri) || uri.startsWith('/') || uri.includes('\\'))
281
+ throw Error('External resource URI rejected');
282
+ const ref = relative(this.job.root, resolve(this.job.root, dirname(p), decodeURIComponent(uri)));
283
+ a.details.resources[ref] = digest(await readSource(this.job.root, ref));
284
+ }
285
+ }
286
+ catch (e) {
287
+ a.status = 'blocked';
288
+ a.reason = String(e);
289
+ }
290
+ }
291
+ report.assets.push(a);
292
+ }
293
+ catch (e) {
294
+ report.warnings.push(`${p}: ${String(e)}`);
295
+ }
296
+ this.job.completed++;
297
+ if (this.job.completed % 10 === 0)
298
+ await this.checkpoint();
299
+ }
300
+ await this.analyze_asset_relationships(report);
301
+ for (const a of report.assets)
302
+ report.counts[a.kind] = (report.counts[a.kind] ?? 0) + 1;
303
+ this.job.status = 'awaiting_selection';
304
+ await this.checkpoint();
305
+ return report;
306
+ }
307
+ async normalize_assets(a) {
308
+ const bytes = await readSource(this.job.root, a.path);
309
+ if (digest(bytes) !== a.sha256)
310
+ throw Error('Source changed after analysis; analyze again');
311
+ if (a.details.normalization) {
312
+ const n = a.details.normalization;
313
+ const output = await readFile(normalizedPath(this.job, a.id, n.format));
314
+ if (n.sourceSha256 !== a.sha256 || digest(output) !== n.sha256)
315
+ throw Error('Normalized checkpoint changed; normalize again');
316
+ return { bytes: output, filename: basename(a.path, extname(a.path)) + '.' + n.format };
317
+ }
318
+ if (a.status === 'blocked')
319
+ throw Error(a.reason ?? 'Extraction/conversion required');
320
+ if (a.format !== 'gltf')
321
+ return { bytes, filename: basename(a.path) };
322
+ const doc = gltfJSON(bytes, '.gltf'), chunks = [], offsets = [];
323
+ let total = 0;
324
+ if (doc.extensionsUsed?.includes('EXT_meshopt_compression'))
325
+ throw Error('meshopt-compressed glTF requires decompression before packing');
326
+ const append = (b) => { const offset = total; chunks.push(b); total += b.length; const pad = (4 - total % 4) % 4; if (pad) {
327
+ chunks.push(Buffer.alloc(pad));
328
+ total += pad;
329
+ } if (total > MAX_BYTES)
330
+ throw Error('Packed glTF exceeds 256 MiB'); return offset; };
331
+ const resource = async (uri) => {
332
+ if (typeof uri !== 'string')
333
+ throw Error('Missing glTF resource URI');
334
+ if (uri.startsWith('data:')) {
335
+ const m = uri.match(/^data:[^;,]+;base64,([a-zA-Z0-9+/=\s]+)$/);
336
+ if (!m)
337
+ throw Error('Unsupported data URI');
338
+ return Buffer.from(m[1], 'base64');
339
+ }
340
+ if (/^[a-z]+:/i.test(uri) || uri.startsWith('/') || uri.includes('\\'))
341
+ throw Error('Remote/absolute glTF resources are forbidden');
342
+ // ../ within the selected root is allowed for ordinary exporter layouts; never outside it.
343
+ const p = relative(this.job.root, resolve(this.job.root, dirname(a.path), decodeURIComponent(uri)));
344
+ const data = await readSource(this.job.root, p);
345
+ if (a.details.resources?.[p] !== digest(data))
346
+ throw Error('glTF dependency changed after analysis; analyze again');
347
+ return data;
348
+ };
349
+ for (const b of doc.buffers ?? []) {
350
+ const content = await resource(b.uri);
351
+ if (content.length < b.byteLength)
352
+ throw Error('Truncated glTF buffer');
353
+ offsets.push(append(content));
354
+ }
355
+ for (const v of doc.bufferViews ?? []) {
356
+ if (offsets[v.buffer] === undefined)
357
+ throw Error('Invalid glTF buffer binding');
358
+ v.byteOffset = (v.byteOffset ?? 0) + offsets[v.buffer];
359
+ v.buffer = 0;
360
+ }
361
+ for (const image of doc.images ?? [])
362
+ if (image.uri) {
363
+ const uri = image.uri, content = await resource(uri), mime = uri.startsWith('data:') ? uri.slice(5, uri.indexOf(';')) : /\.png$/i.test(uri) ? 'image/png' : /\.webp$/i.test(uri) ? 'image/webp' : /\.jpe?g$/i.test(uri) ? 'image/jpeg' : null;
364
+ if (!mime)
365
+ throw Error('Image requires PNG/JPEG/WebP conversion');
366
+ doc.bufferViews ??= [];
367
+ image.bufferView = doc.bufferViews.length;
368
+ image.mimeType = mime;
369
+ doc.bufferViews.push({ buffer: 0, byteOffset: append(content), byteLength: content.length });
370
+ delete image.uri;
371
+ }
372
+ doc.buffers = [{ byteLength: total }];
373
+ const json = Buffer.from(JSON.stringify(doc)), padded = Buffer.concat([json, Buffer.alloc((4 - json.length % 4) % 4, 32)]), binary = Buffer.concat(chunks);
374
+ const header = Buffer.alloc(20);
375
+ header.writeUInt32LE(0x46546c67);
376
+ header.writeUInt32LE(2, 4);
377
+ header.writeUInt32LE(28 + padded.length + binary.length, 8);
378
+ header.writeUInt32LE(padded.length, 12);
379
+ header.writeUInt32LE(0x4e4f534a, 16);
380
+ const bh = Buffer.alloc(8);
381
+ bh.writeUInt32LE(binary.length);
382
+ bh.writeUInt32LE(0x004e4942, 4);
383
+ return { bytes: Buffer.concat([header, padded, bh, binary]), filename: basename(a.path, '.gltf') + '.glb' };
384
+ }
385
+ async import_to_gripforge(key) {
386
+ const job = this.job;
387
+ if (!job.report || !job.rights || !job.origin || !job.workspace || !job.selected?.length)
388
+ throw Error('Analysis, selection, workspace and rights basis required');
389
+ const headers = { 'x-api-key': key, 'x-workspace-id': job.workspace, 'x-gripforge-client': 'mcp' };
390
+ const request = async (body, json = false) => {
391
+ for (let attempt = 0; attempt < 6; attempt++) {
392
+ if (await this.cancelled())
393
+ throw Error('Import cancelled');
394
+ const res = await fetch(job.origin + '/api/v1/game-imports', { method: 'POST', headers: { ...headers, ...(json ? { 'content-type': 'application/json' } : {}) }, body, signal: AbortSignal.timeout(120000) });
395
+ const result = await res.json();
396
+ if (res.status === 503 && result.code === 'import_busy' && attempt < 5) {
397
+ await new Promise(r => setTimeout(r, Math.min(5000, 1000 * (attempt + 1))));
398
+ continue;
399
+ }
400
+ if (!res.ok)
401
+ throw Error(String(result.error ?? `HTTP ${res.status}`));
402
+ return result;
403
+ }
404
+ throw Error('Import persistence lane unavailable; resume later');
405
+ };
406
+ await request(JSON.stringify({ report: job.report, rights: job.rights }), true);
407
+ job.total = job.selected.length;
408
+ job.completed = job.selected.filter(id => job.receipts[id]).length;
409
+ await this.checkpoint();
410
+ for (const id of job.selected) {
411
+ if (job.receipts[id])
412
+ continue;
413
+ await this.checkpoint();
414
+ const a = job.report.assets.find(a => a.id === id);
415
+ if (!a)
416
+ throw Error('Selected asset not in inventory');
417
+ try {
418
+ const output = await this.normalize_assets(a), form = new FormData();
419
+ form.set('job_id', job.id);
420
+ form.set('asset_id', a.id);
421
+ form.set('file', new Blob([new Uint8Array(output.bytes)]), output.filename);
422
+ const result = await request(form);
423
+ job.receipts[id] = { itemId: result.item.id, revision: result.revision?.revisionId, studioUrl: result.studio_url };
424
+ delete job.errors[id];
425
+ job.completed++;
426
+ }
427
+ catch (e) {
428
+ job.errors[id] = e instanceof Error ? e.message : String(e);
429
+ }
430
+ await this.checkpoint();
431
+ }
432
+ job.status = job.selected.some(id => !job.receipts[id]) ? 'failed' : 'completed';
433
+ await this.checkpoint();
434
+ }
435
+ }
436
+ /** Exclusive per-job worker lease. Dead workers can be resumed without discarding receipts. */
437
+ export async function claimImportJob(id) {
438
+ const lock = join(jobDirectory(id), 'worker.lock');
439
+ try {
440
+ const old = Number(await readFile(lock, 'utf8'));
441
+ try {
442
+ process.kill(old, 0);
443
+ throw Error('A worker is already running for this import');
444
+ }
445
+ catch (e) {
446
+ if (e.code !== 'ESRCH')
447
+ throw e;
448
+ }
449
+ await unlink(lock);
450
+ }
451
+ catch (e) {
452
+ if (e.code !== 'ENOENT')
453
+ throw e;
454
+ }
455
+ await writeFile(lock, String(process.pid), { flag: 'wx', mode: 0o600 });
456
+ return async () => { await unlink(lock).catch(() => { }); };
457
+ }
@@ -0,0 +1,71 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { mkdir, readdir, realpath, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { GameImportAgent, confinedFile, saveImportJob, jobDirectory } from './game-import-agent.js';
6
+ const exec = promisify(execFile);
7
+ /** Explicit adapters; never execute a binary discovered inside the source game. */
8
+ export async function extractGame(job) {
9
+ const e = job.extraction;
10
+ if (!e || !job.rights || !job.report)
11
+ throw Error('Extraction plan and authorized rights basis required');
12
+ if (job.report.engine.name !== 'unity')
13
+ throw Error('This adapter only handles Unity. Use the existing Unreal source-project exporter for Unreal.');
14
+ // Preserve the original discovery graph before an extractor replaces the active inventory.
15
+ await writeFile(join(jobDirectory(job.id), 'source-report.json'), JSON.stringify(job.report, null, 2), { flag: 'wx', mode: 0o600 }).catch(error => { if (error.code !== 'EEXIST')
16
+ throw error; });
17
+ await mkdir(e.output, { recursive: true, mode: 0o700 });
18
+ const agent = new GameImportAgent(job);
19
+ await agent.checkpoint();
20
+ if (!e.completed) {
21
+ if (e.tool === 'assetripper') {
22
+ const url = new URL(e.endpoint ?? '');
23
+ if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.username || url.password || url.pathname !== '/')
24
+ throw Error('AssetRipper requires a dedicated local loopback HTTP instance');
25
+ // Version-compatible route discovery prevents invoking imaginary CLI options or a different local service.
26
+ const spec = await fetch(new URL('/openapi/v1.json', url), { signal: AbortSignal.timeout(10000) });
27
+ if (!spec.ok)
28
+ throw Error('AssetRipper OpenAPI unavailable; use a compatible web build or manual export');
29
+ const api = await spec.json();
30
+ if (!api.paths?.['/LoadFolder']?.post || !api.paths?.['/Export/PrimaryContent']?.post)
31
+ throw Error('AssetRipper API lacks required export routes');
32
+ e.version = String(api.info?.version ?? 'unreported');
33
+ await saveImportJob(job);
34
+ const post = async (route, path) => { await agent.checkpoint(); const res = await fetch(new URL(route, url), { method: 'POST', body: new URLSearchParams({ Path: path, CreateSubfolder: 'false' }), redirect: 'manual', signal: AbortSignal.timeout(20 * 60 * 1000) }); if (![200, 302, 303].includes(res.status))
35
+ throw Error(`AssetRipper ${route}: HTTP ${res.status}`); };
36
+ await post('/LoadFolder', e.sourceRoot);
37
+ await post('/Export/PrimaryContent', e.output);
38
+ if (!(await readdir(e.output)).length)
39
+ throw Error('Extractor returned no files; inspect AssetRipper failed-file log');
40
+ }
41
+ else {
42
+ const bin = process.env[e.tool === 'cpp2il' ? 'GRIPFORGE_CPP2IL_BIN' : 'GRIPFORGE_ILSPY_BIN'];
43
+ if (!bin)
44
+ throw Error(`Configure the installed ${e.tool} executable; no automatic installation`);
45
+ const version = await exec(bin, ['--version'], { cwd: e.output, timeout: 10000, maxBuffer: 1024 * 1024 }).catch(() => ({ stdout: 'version not reported' }));
46
+ e.version = version.stdout.trim().slice(0, 200);
47
+ await saveImportJob(job);
48
+ const args = e.tool === 'cpp2il' ? ['--game-path', e.sourceRoot, '--output-to', e.output] : ['--disable-updatecheck', '-p', '-o', e.output, await confinedFile(e.sourceRoot, job.report.assets.find(a => /(?:^|\/)Managed\/Assembly-CSharp\.dll$/i.test(a.path))?.path ?? '')];
49
+ await exec(bin, args, { cwd: e.output, timeout: 20 * 60 * 1000, maxBuffer: 4 * 1024 * 1024 });
50
+ job.report.warnings.push(`${e.tool} metadata/code exported locally to ${e.tool}-output. It is not a mesh extraction and is never uploaded to the Library.`);
51
+ }
52
+ e.completed = true;
53
+ await agent.checkpoint();
54
+ }
55
+ if (e.tool === 'assetripper') {
56
+ const engine = job.report.engine, source = job.report.source;
57
+ job.root = await realpath(e.output);
58
+ job.phase = 'analyze';
59
+ const report = await new GameImportAgent(job).analyze();
60
+ report.source = source;
61
+ report.engine = engine;
62
+ report.toolchain = agent.select_toolchain(engine);
63
+ report.warnings.push('Inventory is from AssetRipper export; native Unity formats still require their own converter.');
64
+ await saveImportJob(job);
65
+ }
66
+ else {
67
+ job.phase = 'analyze';
68
+ job.status = 'awaiting_selection';
69
+ await saveImportJob(job);
70
+ }
71
+ }
@@ -0,0 +1,72 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { mkdir, readFile, writeFile, stat } from 'node:fs/promises';
4
+ import { basename, join, isAbsolute, dirname } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { createHash } from 'node:crypto';
7
+ import { GameImportAgent, confinedFile, inspectAsset, jobDirectory, saveImportJob } from './game-import-agent.js';
8
+ const exec = promisify(execFile), hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
9
+ export function normalizedPath(job, id, format) { return join(jobDirectory(job.id), 'normalized', id, 'output.' + format); }
10
+ /** Local durable conversions. Completed files survive cancellation/crashes; original sources are never modified. */
11
+ export async function normalizeGame(job) {
12
+ if (!job.report || !job.selected?.length || !job.rights)
13
+ throw Error('Select sources and provide a rights basis');
14
+ const bin = process.env.GRIPFORGE_BLENDER_BIN;
15
+ if (!bin || !isAbsolute(bin))
16
+ throw Error('Configure GRIPFORGE_BLENDER_BIN with an installed Blender executable');
17
+ const agent = new GameImportAgent(job);
18
+ job.total = job.selected.length;
19
+ job.completed = 0;
20
+ for (const id of job.selected) {
21
+ await agent.checkpoint();
22
+ const asset = job.report.assets.find(a => a.id === id);
23
+ if (!asset || !['fbx', 'obj', 'dds', 'tga'].includes(asset.format))
24
+ throw Error('Blender normalization supports FBX/OBJ/DDS/TGA only');
25
+ const source = await readFile(await confinedFile(job.root, asset.path));
26
+ if (hash(source) !== asset.sha256)
27
+ throw Error('Source changed after analysis; analyze again');
28
+ const format = ['dds', 'tga'].includes(asset.format) ? 'png' : 'glb', output = normalizedPath(job, id, format), dir = dirname(output);
29
+ await mkdir(join(dir, 'source'), { recursive: true, mode: 0o700 });
30
+ try {
31
+ let bytes;
32
+ if (asset.details.normalization) {
33
+ const cached = await readFile(output).catch(() => undefined);
34
+ if (cached && hash(cached) === asset.details.normalization.sha256)
35
+ bytes = cached;
36
+ }
37
+ if (!bytes) {
38
+ const input = join(dir, 'source', basename(asset.path));
39
+ await writeFile(input, source, { mode: 0o600 });
40
+ // Staging excludes source scripts and arbitrary external references. Embedded FBX materials are retained.
41
+ // OBJ material sidecars need explicit portable export: never follow unvalidated MTL references in Blender.
42
+ if (asset.format === 'obj' && /^\s*mtllib\s/m.test(source.toString()))
43
+ throw Error('OBJ references an MTL sidecar; export a self-contained GLB or embedded FBX to preserve PBR');
44
+ const plan = join(dir, 'plan.json'), receipt = join(dir, 'receipt.json');
45
+ await writeFile(plan, JSON.stringify({ input, output, receipt }), { mode: 0o600 });
46
+ await exec(bin, ['--background', '--factory-startup', '--disable-autoexec', '--python-exit-code', '1', '--python', fileURLToPath(new URL('../runtime/game-import-normalize.py', import.meta.url)), '--', plan], { cwd: dir, timeout: 20 * 60 * 1000, maxBuffer: 4 * 1024 * 1024 });
47
+ await agent.checkpoint();
48
+ if ((await stat(output)).size > 256 * 1024 * 1024)
49
+ throw Error('Normalized output exceeds 256 MiB');
50
+ bytes = await readFile(output);
51
+ const inspected = inspectAsset('output.' + format, bytes);
52
+ if (inspected.status !== 'ready')
53
+ throw Error('Converter output is not a portable asset: ' + inspected.reason);
54
+ const version = String(JSON.parse(await readFile(receipt, 'utf8')).version).slice(0, 100);
55
+ asset.details = { ...asset.details, ...inspected.details, normalization: { format, sha256: hash(bytes), sourceSha256: asset.sha256, tool: 'blender', version } };
56
+ asset.evidence.push({ source: asset.path, reason: 'Blender conversion preserving available materials/rig/clips; visual and anatomy review required', confidence: 1 });
57
+ }
58
+ if (format === 'png')
59
+ asset.kind = 'texture';
60
+ asset.status = 'ready';
61
+ delete asset.reason;
62
+ delete job.errors[id];
63
+ job.completed++;
64
+ }
65
+ catch (e) {
66
+ job.errors[id] = e instanceof Error ? e.message : String(e);
67
+ }
68
+ await saveImportJob(job);
69
+ }
70
+ job.status = job.selected.some(id => job.errors[id]) ? 'failed' : 'awaiting_selection';
71
+ await saveImportJob(job);
72
+ }
@@ -0,0 +1,122 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3
+ import { z } from 'zod/v4';
4
+ import { readFile, writeFile } from 'node:fs/promises';
5
+ import { basename, join } from 'node:path';
6
+ import { createHash } from 'node:crypto';
7
+ import { confinedFile, jobDirectory, loadImportJob } from './game-import-agent.js';
8
+ /** Detect containers locally before recommending native code investigation. No binaries are executed. */
9
+ export function detectFormat(bytes) {
10
+ if (bytes.subarray(0, 4).equals(Buffer.from([0x7f, 69, 76, 70])))
11
+ return { format: 'ELF', native: true };
12
+ if (bytes.toString('ascii', 0, 2) === 'MZ')
13
+ return { format: 'PE', native: true };
14
+ if (bytes.length >= 4 && [0xfeedface, 0xfeedfacf, 0xcafebabe, 0xcefaedfe, 0xcffaedfe].includes(bytes.readUInt32BE()))
15
+ return { format: 'Mach-O', native: true };
16
+ for (const [magic, format] of [['glTF', 'GLB'], ['UnityFS', 'Unity bundle'], ['DDS ', 'DDS'], ['PK\u0003\u0004', 'ZIP']])
17
+ if (bytes.toString('latin1', 0, magic.length) === magic)
18
+ return { format, native: false };
19
+ return { format: 'unknown', native: false };
20
+ }
21
+ function endpoint() {
22
+ const url = new URL(process.env.GRIPFORGE_GHIDRA_MCP_URL ?? '');
23
+ if (url.protocol !== 'http:' || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password)
24
+ throw Error('Configure a dedicated loopback GRIPFORGE_GHIDRA_MCP_URL (for example http://127.0.0.1:8081/mcp)');
25
+ return url;
26
+ }
27
+ async function bridge(run) {
28
+ const client = new Client({ name: 'gripforge-game-import', version: '1.0' });
29
+ try {
30
+ await client.connect(new StreamableHTTPClientTransport(endpoint()));
31
+ return await run(client);
32
+ }
33
+ finally {
34
+ await client.close().catch(() => { });
35
+ }
36
+ }
37
+ async function call(client, name, args) {
38
+ const tool = (await client.listTools()).tools.find(t => t.name === name);
39
+ if (!tool)
40
+ throw Error('Compatible Ghidra tool unavailable: ' + name);
41
+ for (const key of tool.inputSchema.required ?? [])
42
+ if (!(key in args))
43
+ throw Error(`Installed Ghidra requires ${key} for ${name}; incompatible adapter contract`);
44
+ const response = await client.callTool({ name, arguments: args }, undefined, { timeout: 120000 });
45
+ if (response.isError)
46
+ throw Error(JSON.stringify(response.content).slice(0, 4000));
47
+ return response;
48
+ }
49
+ export function registerReverseTools(register) {
50
+ const jobId = z.string().regex(/^gi_[a-f0-9]{24}$/), path = z.string().min(1).max(4096);
51
+ const wrap = (run) => async (a) => { try {
52
+ return { content: [{ type: 'text', text: JSON.stringify(await run(a), null, 2) }] };
53
+ }
54
+ catch (e) {
55
+ return { isError: true, content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }] };
56
+ } };
57
+ const common = { job_id: jobId };
58
+ register('gripforge_reverse_detect_format', { title: 'Inspect file magic before reverse engineering', description: 'Local signatures, source hash and known-format-first recommendation. Unknown remains unknown. Does not execute or alter the source.', inputSchema: { ...common, path }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } }, wrap(async (a) => {
59
+ const job = await loadImportJob(a.job_id), bytes = await readFile(await confinedFile(job.root, a.path)), found = detectFormat(bytes);
60
+ return { ...found, sha256: createHash('sha256').update(bytes).digest('hex'), next: found.native ? 'gripforge_reverse_open' : 'Known extractor/converter first; unknown data needs a format-specific investigation, not automatic native decompilation' };
61
+ }));
62
+ register('gripforge_reverse_open', { title: 'Open an authorized native binary in Ghidra', description: 'Imports a copy into a dedicated installed Ghidra analysis project, without running the binary. Requires a finished local import job, native file signature and rights basis. Records hash and explicit program selector, so later reads never depend on the shared current program. No extraction or visual approval is implied.', inputSchema: { ...common, path, rights: z.object({ basis: z.enum(['owned', 'authorized', 'compatible-license']), note: z.string().min(3).max(1000) }).strict() }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false } }, wrap(async (a) => {
63
+ const job = await loadImportJob(a.job_id), file = await confinedFile(job.root, a.path), bytes = await readFile(file);
64
+ if (!detectFormat(bytes).native)
65
+ throw Error('Use a known extractor or inspect the data format first; this is not a recognized native binary');
66
+ const response = await bridge(c => call(c, 'import_file', { file_path: file, project_folder: '/' + job.id, auto_analyze: true }));
67
+ const content = response.content;
68
+ let program;
69
+ for (const block of content)
70
+ if (block.text) {
71
+ try {
72
+ const json = JSON.parse(block.text);
73
+ program = json.data?.name ?? json.name;
74
+ }
75
+ catch { }
76
+ }
77
+ if (!program)
78
+ throw Error('Ghidra did not report the imported program name; no ambiguous current-program session saved');
79
+ const session = { path: a.path, sha256: createHash('sha256').update(bytes).digest('hex'), program: '/' + job.id + '/' + basename(program), endpoint: endpoint().href };
80
+ await writeFile(join(jobDirectory(job.id), 'reverse-session.json'), JSON.stringify({ ...session, rights: a.rights }), { mode: 0o600 });
81
+ return { program: session.program, sha256: session.sha256, analysis: response, next: 'gripforge_reverse_scan', source_unchanged: true };
82
+ }));
83
+ // A deliberately small allowlist. Upstream rename/patch/execute tools are never passed through.
84
+ for (const [name, tool, description, query, group] of [
85
+ ['gripforge_reverse_scan', 'find_functions', 'List native functions with an explicit program selector', false, 'listing'],
86
+ ['gripforge_reverse_find_asset_system', 'search_strings', 'Search native strings for asset loading/format clues; results are evidence, not inferred assets', true, 'listing'],
87
+ ['gripforge_reverse_trace_function', 'get_functions', 'Read one native function to investigate an asset loader; never changes executable code', true, 'function'],
88
+ ]) {
89
+ register(name, { title: description, description: 'Configured local Ghidra MCP bridge only. Verifies source hash and installed tool schema. Bounded output and persisted evidence; no unsupported converter is invented. ' + description, inputSchema: { ...common, ...(query ? { query: z.string().min(1).max(256) } : {}), offset: z.number().int().min(0).optional(), limit: z.number().int().min(1).max(100).optional() }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } }, wrap(async (a) => {
90
+ const job = await loadImportJob(a.job_id), session = JSON.parse(await readFile(join(jobDirectory(job.id), 'reverse-session.json'), 'utf8'));
91
+ if (session.endpoint !== endpoint().href)
92
+ throw Error('Ghidra endpoint changed; open the binary again');
93
+ const bytes = await readFile(await confinedFile(job.root, session.path));
94
+ if (createHash('sha256').update(bytes).digest('hex') !== session.sha256)
95
+ throw Error('Binary changed; open it again');
96
+ const response = await bridge(async (c) => {
97
+ if (!(await c.listTools()).tools.some(t => t.name === tool))
98
+ await call(c, 'load_tool_group', { group });
99
+ const schema = (await c.listTools()).tools.find(t => t.name === tool)?.inputSchema;
100
+ if (!schema?.properties?.program)
101
+ throw Error('Installed Ghidra tool lacks explicit program selection; update the bridge');
102
+ const args = { program: session.program };
103
+ if (schema.properties.offset)
104
+ args.offset = a.offset ?? 0;
105
+ if (schema.properties.limit)
106
+ args.limit = a.limit ?? 40;
107
+ if (tool === 'get_functions' && schema.properties.fields)
108
+ args.fields = 'decompiled_code,callers,callees,signature';
109
+ if (query) {
110
+ const key = tool === 'get_functions' ? ['function', 'address', 'function_address', 'name', 'function_name'].find(k => schema.properties?.[k]) : ['query', 'search', 'search_term', 'filter'].find(k => schema.properties?.[k]);
111
+ if (!key)
112
+ throw Error('Installed Ghidra query schema incompatible');
113
+ args[key] = a.query;
114
+ }
115
+ return call(c, tool, args);
116
+ });
117
+ const evidence = { source_sha256: session.sha256, program: session.program, operation: tool, query: a.query, observed_at: new Date().toISOString(), result: JSON.stringify(response).slice(0, 64000) };
118
+ await writeFile(join(jobDirectory(job.id), 'reverse-' + tool + '.json'), JSON.stringify(evidence), { mode: 0o600 });
119
+ return evidence;
120
+ }));
121
+ }
122
+ }
@@ -0,0 +1,138 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { open, writeFile, unlink, readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { z } from 'zod/v4';
6
+ import { jobDirectory, loadImportJob, newImportJob, saveImportJob } from './game-import-agent.js';
7
+ async function launch(job, key) {
8
+ const log = await open(join(jobDirectory(job.id), 'worker.log'), 'a', 0o600);
9
+ try {
10
+ const child = spawn(process.execPath, [fileURLToPath(new URL('./game-import-worker.js', import.meta.url)), job.id], { detached: true, stdio: ['ignore', log.fd, log.fd], env: { ...process.env, ...(key ? { GRIPFORGE_API_KEY: key } : {}) } });
11
+ await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
12
+ child.unref();
13
+ }
14
+ finally {
15
+ await log.close();
16
+ }
17
+ }
18
+ async function assertIdle(id) {
19
+ try {
20
+ const pid = Number(await readFile(join(jobDirectory(id), 'worker.lock'), 'utf8'));
21
+ try {
22
+ process.kill(pid, 0);
23
+ }
24
+ catch (e) {
25
+ if (e.code === 'ESRCH')
26
+ return;
27
+ throw e;
28
+ }
29
+ throw Error('Worker still running; cancel and wait for it to finish');
30
+ }
31
+ catch (e) {
32
+ if (e.code !== 'ENOENT')
33
+ throw e;
34
+ }
35
+ }
36
+ export function registerGameImportTools(register, options) {
37
+ const result = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], structuredContent: data });
38
+ const wrap = (run) => async (a) => { try {
39
+ return result(await run(a));
40
+ }
41
+ catch (e) {
42
+ return { isError: true, content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }] };
43
+ } };
44
+ const id = z.string().regex(/^gi_[a-f0-9]{24}$/);
45
+ const summary = (job) => ({ job_id: job.id, status: job.status, phase: job.phase, progress: { completed: job.completed, total: job.total }, engine: job.report?.engine, counts: job.report?.counts, errors: job.errors, imported: job.receipts, report_path: join(jobDirectory(job.id), 'report.json'), library_url: `${options.apiUrl}/library`, next: job.status === 'awaiting_selection' ? 'Read inventory, select asset ids, then gripforge_game_import_select' : 'gripforge_game_import_read' });
46
+ register('gripforge_game_import', {
47
+ title: 'Analyze an authorized local game or project',
48
+ description: 'LOCAL MCP ONLY. Analyze → discover → classify → normalize → private Library import. Starts a detached durable local job; returns immediately. Known portable formats first, Unity GUID relations carry evidence, unsupported formats stay blocked with an explicit toolchain. Does not launch the game, bypass encryption, guess sockets or claim behavior/visual validation. Read the inventory and select before uploading. Closing the MCP or Studio does not delete progress; read/resume by job_id. Analysis is free and local. Source files remain unchanged.',
49
+ inputSchema: { path: z.string().min(1).max(4096) }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
50
+ }, wrap(async (a) => { const job = await newImportJob(a.path); await launch(job); return summary(job); }));
51
+ register('gripforge_game_import_read', {
52
+ title: 'Read game import progress and inventory', description: 'Read persistent local state with paginated assets, role confidence, extraction blockers and verified relationship edges. The report_path can be opened in Library → Add asset → From Game. Unknown/bundled files are not counted as successfully imported assets.',
53
+ inputSchema: { job_id: id, kind: z.string().max(40).optional(), offset: z.number().int().min(0).optional(), limit: z.number().int().min(1).max(100).optional() }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
54
+ }, wrap(async (a) => { const job = await loadImportJob(a.job_id), all = (job.report?.assets ?? []).filter(x => !a.kind || x.kind === a.kind); return { ...summary(job), total: all.length, assets: all.slice(a.offset ?? 0, (a.offset ?? 0) + (a.limit ?? 40)), toolchain: job.report?.toolchain, normalization: job.report?.normalization, warnings: job.report?.warnings, relationships: job.report?.relationships }; }));
55
+ register('gripforge_game_import_extract', {
56
+ title: 'Run the selected known Unity extractor', description: 'LOCAL ONLY. Dedicated AssetRipper loopback HTTP adapter for assets; installed Cpp2IL/ILSpy adapters for local code/metadata only. The agent chooses AssetRipper first unless tool is explicit. Uses durable output/checkpoints and rescans exported assets; never executes game code. Requires installed tools configured in the MCP environment, compatible OpenAPI routes and an authorization basis. No Ghidra fallback is silently invoked. Tools/version and errors remain visible. Cancellation is checked between external operations, each bounded to 20 minutes.',
57
+ inputSchema: { job_id: id, tool: z.enum(['assetripper', 'cpp2il', 'ilspy']).optional(), rights: z.object({ basis: z.enum(['owned', 'authorized', 'compatible-license']), note: z.string().min(3).max(1000) }).strict() }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
58
+ }, wrap(async (a) => { await assertIdle(a.job_id); const job = await loadImportJob(a.job_id); if (!job.report || job.report.engine.name !== 'unity')
59
+ throw Error('Finish Unity analysis first'); if (Object.keys(job.receipts).length)
60
+ throw Error('Start a new import before extracting a different inventory'); const tool = a.tool ?? 'assetripper'; if (tool === 'cpp2il' && job.report.engine.runtime !== 'IL2CPP')
61
+ throw Error('Cpp2IL requires IL2CPP evidence'); if (tool === 'ilspy' && job.report.engine.runtime !== 'Mono')
62
+ throw Error('ILSpy requires Mono evidence'); if (job.extraction && job.extraction.tool !== tool)
63
+ throw Error('Start a separate analysis job for another extractor'); job.extraction ??= { tool, sourceRoot: job.root, output: join(jobDirectory(job.id), tool + '-output'), ...(tool === 'assetripper' ? { endpoint: process.env.GRIPFORGE_ASSETRIPPER_URL } : {}) }; job.rights = a.rights; job.phase = 'extract'; job.status = 'queued'; await unlink(join(jobDirectory(job.id), 'cancel')).catch(() => { }); await saveImportJob(job); await launch(job); return summary(job); }));
64
+ register('gripforge_game_import_select', {
65
+ title: 'Import selected game assets into a private workspace', description: 'Requires an analyzed local job, explicit source ids, workspace id and rights basis. Self-contained GLB and standard images/audio import directly; glTF packs its local resources into GLB. Engine-native formats require extraction first. Durable per-file checkpoints and server idempotency preserve completed imports across retries. Saved as work revisions, never published/promoted. Storage quotas apply; no paid AI call. Relations remain a knowledge graph and Game Kit suggestions require actual gameplay integration.',
66
+ inputSchema: { job_id: id, asset_ids: z.array(z.string().regex(/^src_[a-f0-9]{24}$/)).min(1).max(500), workspace_id: z.string().regex(/^ws_[A-Za-z0-9_-]+$/), rights: z.object({ basis: z.enum(['owned', 'authorized', 'compatible-license']), note: z.string().min(3).max(1000) }).strict() }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
67
+ }, wrap(async (a) => {
68
+ const key = options.getApiKey();
69
+ if (!key)
70
+ throw Error('GripForge API key required');
71
+ await assertIdle(a.job_id);
72
+ const job = await loadImportJob(a.job_id);
73
+ if (!job.report || job.completed < job.total && job.phase === 'analyze')
74
+ throw Error('Finish analysis before selecting assets');
75
+ if (job.workspace && job.workspace !== a.workspace_id)
76
+ throw Error('Start a new import to target another workspace');
77
+ const selected = [...new Set(a.asset_ids)];
78
+ for (const id of selected) {
79
+ const asset = job.report.assets.find(x => x.id === id);
80
+ if (!asset || asset.status === 'blocked')
81
+ throw Error(`Asset unavailable for import: ${id}`);
82
+ }
83
+ job.phase = 'import';
84
+ job.selected = selected;
85
+ job.rights = a.rights;
86
+ job.workspace = a.workspace_id;
87
+ job.origin = options.apiUrl.replace(/\/$/, '');
88
+ job.status = 'queued';
89
+ delete job.errors.worker;
90
+ await unlink(join(jobDirectory(job.id), 'cancel')).catch(() => { });
91
+ await saveImportJob(job);
92
+ await launch(job, key);
93
+ return summary(job);
94
+ }));
95
+ register('gripforge_game_import_normalize', {
96
+ title: 'Convert selected extracted assets with Blender',
97
+ description: 'LOCAL durable worker. Installed GRIPFORGE_BLENDER_BIN converts FBX/OBJ to GLB and DDS/TGA to PNG. Keeps available PBR, rig and animations; does not fabricate missing materials or validate appearance. Sources remain unchanged; checksummed outputs persist for resume. OBJ with external MTL and missing FBX textures fail explicitly; export embedded materials first. Run before registration/import. Source engine scenes still require their known exporter.',
98
+ inputSchema: { job_id: id, asset_ids: z.array(z.string().regex(/^src_[a-f0-9]{24}$/)).min(1).max(500), rights: z.object({ basis: z.enum(['owned', 'authorized', 'compatible-license']), note: z.string().min(3).max(1000) }).strict() },
99
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
100
+ }, wrap(async (a) => {
101
+ await assertIdle(a.job_id);
102
+ const job = await loadImportJob(a.job_id);
103
+ if (!job.report || Object.keys(job.receipts).length || job.workspace)
104
+ throw Error('Normalize an analyzed inventory before registering/importing it');
105
+ for (const assetId of a.asset_ids)
106
+ if (!job.report.assets.some(x => x.id === assetId && ['fbx', 'obj', 'dds', 'tga'].includes(x.format)))
107
+ throw Error('Select FBX/OBJ/DDS/TGA inventory sources');
108
+ job.phase = 'normalize';
109
+ job.selected = [...new Set(a.asset_ids)];
110
+ job.rights = a.rights;
111
+ job.status = 'queued';
112
+ await unlink(join(jobDirectory(job.id), 'cancel')).catch(() => { });
113
+ await saveImportJob(job);
114
+ await launch(job);
115
+ return summary(job);
116
+ }));
117
+ register('gripforge_game_import_control', {
118
+ title: 'Cancel or resume a local game import', description: 'Cancel between files; an already committed upload remains imported. Resume preserves successful receipts and checkpoints. After a worker crash, resume with the same job_id. Credentials are supplied by the current MCP session, never saved in the job report.',
119
+ inputSchema: { job_id: id, action: z.enum(['cancel', 'resume']) }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
120
+ }, wrap(async (a) => {
121
+ const job = await loadImportJob(a.job_id);
122
+ if (a.action === 'cancel') {
123
+ await writeFile(join(jobDirectory(job.id), 'cancel'), 'cancel', { mode: 0o600 });
124
+ await assertIdle(job.id).then(async () => { job.status = 'cancelled'; await saveImportJob(job); }).catch(() => { });
125
+ }
126
+ else {
127
+ await assertIdle(job.id);
128
+ if (job.phase === 'import' && !options.getApiKey())
129
+ throw Error('API key required to resume import');
130
+ await unlink(join(jobDirectory(job.id), 'cancel')).catch(() => { });
131
+ job.status = 'queued';
132
+ delete job.errors.worker;
133
+ await saveImportJob(job);
134
+ await launch(job, options.getApiKey() ?? undefined);
135
+ }
136
+ return summary(job);
137
+ }));
138
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import { GameImportAgent, claimImportJob, loadImportJob, saveImportJob } from './game-import-agent.js';
2
+ import { normalizeGame } from './game-import-normalize.js';
3
+ import { extractGame } from './game-import-extract.js';
4
+ const id = process.argv[2];
5
+ const release = await claimImportJob(id);
6
+ const job = await loadImportJob(id);
7
+ try {
8
+ job.status = 'running';
9
+ job.workerPid = process.pid;
10
+ await saveImportJob(job);
11
+ const agent = new GameImportAgent(job);
12
+ if (job.phase === 'normalize')
13
+ await normalizeGame(job);
14
+ else if (job.phase === 'extract')
15
+ await extractGame(job);
16
+ else if (job.phase === 'analyze')
17
+ await agent.analyze();
18
+ else {
19
+ const key = process.env.GRIPFORGE_API_KEY;
20
+ if (!key)
21
+ throw Error('API key required to resume import');
22
+ await agent.import_to_gripforge(key);
23
+ }
24
+ }
25
+ catch (e) {
26
+ job.status = await new GameImportAgent(job).cancelled() ? 'cancelled' : 'failed';
27
+ job.errors.worker = e instanceof Error ? e.message : String(e);
28
+ await saveImportJob(job);
29
+ }
30
+ finally {
31
+ delete job.workerPid;
32
+ await saveImportJob(job);
33
+ await release();
34
+ }
@@ -1,5 +1,6 @@
1
1
  /** Modular Game Kits tools. Studio and MCP call the same Core HTTP API. */
2
2
  import { z } from 'zod/v4';
3
+ import { LOOK_VALUES, LOOK_DESCRIPTION } from './look.js';
3
4
  export const GAMEKIT_TOOL_NAMES = [
4
5
  'gripforge_gamekit_search',
5
6
  'gripforge_gamekit_get',
@@ -12,6 +13,8 @@ export const GAMEKIT_TOOL_NAMES = [
12
13
  'gripforge_gamekit_deliver',
13
14
  'gripforge_game_capabilities',
14
15
  'gripforge_game_project',
16
+ 'gripforge_game_art_direction',
17
+ 'gripforge_game_asset_plan',
15
18
  'gripforge_gamekit_creatures',
16
19
  'gripforge_game_engine',
17
20
  'gripforge_game_content',
@@ -279,6 +282,34 @@ export function registerGameKitTools(register, options, schema = z) {
279
282
  }, { readOnly: true }, (args, extra) => api(`gamekit-projects/${enc(args.project_id)}/capabilities`, args, 'GET', extra?.signal, {
280
283
  goal: typeof args.goal === 'string' ? args.goal : undefined,
281
284
  }));
285
+ tool('gripforge_game_art_direction', 'Read or update the shared art direction of a game', 'Store artistic intent on the existing Game Kit project (game.engine), separately from gameplay: look, world/theme, palette, proportions/materials/lighting notes and device budget. Read first, then set with expected_revision. Unspecified fields are preserved; null clears a field or the profile. Existing assets and rendering are not modified. Use this direction with map wizard.look, VFX look, asset prompts, lighting and UI tools, then inspect gripforge_game_asset_plan and actual-engine captures. 0 credits.', {
286
+ project_id: projectId,
287
+ action: schema.enum(['get', 'set']).describe('get reads the profile/revision; set applies a partial profile update.'),
288
+ expected_revision: schema.number().int().positive().optional().describe('set: revision returned by get; stale changes are refused.'),
289
+ art_direction: schema.object({
290
+ look: schema.enum(LOOK_VALUES).nullable().optional().describe(LOOK_DESCRIPTION),
291
+ theme: schema.string().max(120).nullable().optional(),
292
+ palette: schema.array(schema.string().regex(/^#[a-f\d]{6}$/i)).max(12).nullable().optional(),
293
+ notes: schema.string().max(1600).nullable().optional(),
294
+ device: schema.enum(['mobile', 'desktop']).nullable().optional(),
295
+ }).strict().nullable().optional().describe('set: shared project art direction patch. Null clears the profile or an individual field.'),
296
+ }, { readOnly: false }, (args, extra) => {
297
+ const path = `gamekit-projects/${enc(args.project_id)}/art-direction`;
298
+ if (args.action === 'get')
299
+ return api(path, args, 'GET', extra?.signal);
300
+ if (args.art_direction === undefined || args.expected_revision === undefined)
301
+ return Promise.resolve(fail('set needs art_direction and expected_revision from get.'));
302
+ return api(path, { workspace_id: args.workspace_id, artDirection: args.art_direction, expected_revision: args.expected_revision }, 'PATCH', extra?.signal);
303
+ });
304
+ tool('gripforge_game_asset_plan', 'Audit project asset roles or search compatible candidates', 'Read asset requirements from enabled Game Kits and game.engine archetypes. Without slot: report missing files/bindings, template defaults, metadata style/theme conflicts, unknown classifications and technical ranking concerns. With slot: search accessible Library/Community candidates for that role and the saved art direction; known visual conflicts are excluded. No purchase, generation or binding. Metadata checks do not replace visual/animation/gameplay review in the actual engine. 0 credits.', {
305
+ project_id: projectId,
306
+ slot: schema.string().max(160).optional().describe('Omit for the audit; pass a role id from the audit to search candidates.'),
307
+ q: schema.string().max(400).optional().describe('Optional candidate search terms for this slot.'),
308
+ source: schema.enum(['all', 'workspace', 'community']).optional().describe('Search scope; default all accessible assets.'),
309
+ limit: schema.number().int().min(1).max(48).optional().describe('Maximum candidates, default 12.'),
310
+ }, { readOnly: true }, (args, extra) => api(`gamekit-projects/${enc(args.project_id)}/assets`, args, 'GET', extra?.signal, {
311
+ slot: args.slot, q: args.q, source: args.source, limit: args.limit,
312
+ }));
282
313
  tool('gripforge_game_project', 'List, create, read, bind, feed, set the controls of or delete Game Kit projects', 'Manage Game Kit projects (gkp_…): the container holding installed kits, their lockfile, asset bindings and data collections. action=list lists the workspace projects; create needs name and takes a preset (see gripforge_gamekit_search; adventure is the reference game of game.engine) or an explicit kits map — every new project gets game.engine first (the engine of the game: scenes, archetypes, scripts, life cycle; its answer carries the engine start pack { structure, scene, blocks, order, next, rules } and added, the kits installed for dependencies), then the default kits ui.menu (title / pause / options / load and save screen), input.remap (key remapping), settings.graphics (graphics options), ui.prompts (on-screen key prompts that follow remapping and the gamepad), ui.endscreen (end screen and credits), save.persistence (save slots, quick save, autosave), audio.core (game audio: mixer, music, effects, event → sound table), i18n.text (game texts in the player’s language: translation tables in the translations collection, ICU plurals) ui.theme (the design of every interface as kit config: colours, font, shapes, key glyphs; unset = built-in look) and input.virtualpad (the on-screen gamepad of touch screens, mode auto: shown on a touch screen without a gamepad, Touch controls tab), addedBy default: keep and configure them, leave one out ({ "ui.menu": false }) or remove it only if the user asks; get returns the full ProjectDetail (installed kits, bindings, capabilities, missing, playUrl); bind maps asset slots to Library items (lib_…, null to clear); data reads a collection or upserts / removes documents validated against the kits’ schemas (replace=true swaps the whole collection); controls reads or sets the controls: without scheme it returns the control schemes of the catalogue (fps, third_person, moba_click, top_down, platformer, fighting, vehicle, rts, point_click: movement by keys or by click, what the mouse is for, expected actions and keys, touch layout), the project scheme, the one its kits suggest, every action with its effective key and the keys two actions share in one mode (warnings with a proposed reassignment, never a refusal — also in get → report.controls); with scheme and dry_run=true it returns the diff of keys the scheme would write; with scheme alone it applies it (input.actions overrides marked source: scheme, the touch layout of input.virtualpad, the help bar of ui.prompts). A scheme is a starting proposal: a preset applies its own at creation (create takes controls to pick another one, or none), and any key stays changeable with gripforge_gamekit_configure input.actions { overrides: [{ action, keys, buttons?, disabled? }] } — the action of any kit; those entries always win over the scheme. delete needs confirm=true. Call this first to create or pick the project, then gripforge_gamekit_installed (existing project) or gripforge_gamekit_install. 0 credits.', {
283
314
  action: schema.enum(['list', 'create', 'get', 'bind', 'data', 'controls', 'delete']).describe('list | create | get | bind | data | controls | delete.'),
284
315
  project_id: projectId.optional().describe('Game Kit project id (gkp_…). Required for get, bind, data, controls and delete.'),
@@ -138,7 +138,9 @@ export function registerSceneTools(register, options, schema = z) {
138
138
  tool('gripforge_scene_asset_versions', 'Read asset version and review', 'Read immutable asset work/current pointers and the trusted review of work. Does not create a version.', { assetId: identifier }, true, (args, extra) => call(`scene-assets/${args.assetId}/versions`, args, 'GET', extra?.signal));
139
139
  tool('gripforge_scene_asset_review', 'Review an immutable asset', 'Create a shared test scene and queue an independent review of real GripForge captures. Supports meshes, textures and effects. Never promotes automatically.', { assetId: identifier, revisionId: identifier, idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call(`scene-assets/${args.assetId}/${args.revisionId}/review`, args, 'POST', extra?.signal));
140
140
  tool('gripforge_scene_asset_promote', 'Set the visually approved asset version', 'Promote only when the exact work revision has a matching trusted review for the current renderer. Existing scene instances keep their pinned revision.', { assetId: identifier, revisionId: identifier, expectedCurrent: identifier.nullable() }, false, (args, extra) => call(`scene-assets/${args.assetId}/${args.revisionId}/promote`, args, 'POST', extra?.signal));
141
- tool('gripforge_fps_animation', 'Manufacture first-person weapon animations', 'Queue a persistent Blender job for paired FPS arms and an articulated pistol: idle, draw, fire, tactical reload and empty reload. Recipe: {version:1,preset:"pistol",name:"Pistol FPS",tempo:1}; tempo 0.7–1.3. Returns job_id immediately; poll gripforge_generation_read for the private Library asset, .blend source, event markers and Character Studio FPS link. Generated variants are work revisions, never automatically validated. Use gripforge_generation_cancel/retry; completed steps survive closing the page.', { recipe: record, idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call('fps-animations', args, 'POST', extra?.signal));
141
+ tool('gripforge_fp_hands_schema', 'First-person hand manufacturing contract', 'Read the native FPS hand template, recipe defaults, limits and reuse workflow. Geometry is manufactured from an articulated FPS template, independently of full-body character generation.', {}, true, (args, extra) => call('fp-hands', args, 'GET', extra?.signal));
142
+ tool('gripforge_generate_fp_hands', 'Generate dedicated first-person hands', 'Manufacture a NEW fps-arms work asset using the native FPS template: curved hand surface refinement, fitted skinned long sleeves/cuff, optional tactical glove reinforcement and embedded PBR. recipe={version:1,name,quality:high|standard,gloves:tactical|light,sleeves:long|none,colors?:{fabric:#RRGGBB,sleeve:#RRGGBB,trim:#RRGGBB},source?:{assetId,revisionId,fileRole?}}. No source gives standalone hands with native finger rig, hand_item_r/l sockets and existing starting clips. Optional owned immutable native paired source keeps weapon, camera and animation tracks; source must contain FPS_Hands or FPS_Arms_Mesh, all ten native fingers, embedded GLB <=32 MiB. This is template geometry/garment manufacture, not prompt-to-new-anatomy or arbitrary rigging. Returns job_id; poll generation_read. Inspect fingers, seams and weapon/garment contact in the actual renderer before binding. No external provider charge; never auto-promotes or overwrites.', { recipe: record, idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call('fp-hands', args, 'POST', extra?.signal));
143
+ tool('gripforge_fps_animation', 'Author first-person weapon rigs and materials', 'Queue a persistent FPS work asset. Pistol recipe: {version:1,preset:"pistol",name:"Pistol FPS",tempo:1}, tempo 0.7–1.3; manufactures paired arms and articulated pistol clips plus .blend in Blender. Materials recipe: {version:1,preset:"materials",name:"Tactical gloves",source:{assetId,revisionId},node:"FPS_Hands",materials:{map:{assetId,revisionId},normalMap?:{assetId,revisionId},roughnessMap?:{assetId,revisionId},normalScale:0.35,roughness:1,metalness:0}}. Upload and pin owned textures first; they must match source UVs. Select one exact skinned node; other nodes, geometry, fingers and clips stay intact. Normal is OpenGL +Y; grayscale roughness reads G and requires metalness=0 (skin/fabric). Sources <=32 MB, images PNG/JPEG/WebP <=16 MB and 4096 pixels per side. Materials authors a reusable GLB variant, not new hand anatomy or a new .blend; no provider charge. Returns job_id: poll gripforge_generation_read for private Library ref, report and FPS Studio link. Never automatically validates or promotes. Cancel/retry supported; completed steps survive closing the page.', { recipe: record, idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call('fps-animations', args, 'POST', extra?.signal));
142
144
  tool('gripforge_generation_list', 'List persistent generation jobs', 'List workspace jobs. Jobs and completed steps survive closing the studio or restarting the web server and worker.', {}, true, (args, extra) => call('generation-jobs', args, 'GET', extra?.signal));
143
145
  tool('gripforge_generation_read', 'Read generation progress and result', 'Poll a job returned by a generation tool. Includes durable steps, progress, failure/retry state and the resulting work revision with a Studio link. include_preview returns saved map/building concept images (at most 4). Set preview_view to return one chosen view. Concepts are generated illustrations, not verified 3D renders. Success does not mean visual approval or promotion.', { id: identifier, include_preview: schema.boolean().optional(), preview_view: schema.enum(['overview', 'top_down', 'entrance', 'objective', 'front_right', 'front_left', 'rear_right', 'rear_left']).optional() }, true, (args, extra) => call(`generation-jobs/${args.id}`, args, 'GET', extra?.signal));
144
146
  tool('gripforge_generation_cancel', 'Cancel generation', 'Request worker cancellation while retaining completed steps and artifacts. Closing a page alone does not cancel a job.', { id: identifier }, false, (args, extra) => call(`generation-jobs/${args.id}`, { ...args, action: 'cancel' }, 'POST', extra?.signal));
package/dist/server.js CHANGED
@@ -32,6 +32,8 @@ import { registerGameKitLocalTools } from './gamekit-deliver-local.js';
32
32
  import { registerMapUnrealLocalTools } from './map-unreal-local.js';
33
33
  import { registerMapUnrealImportLocalTools } from './map-unreal-import-local.js';
34
34
  import { registerMapUnrealExportLocalTools } from './map-unreal-export-local.js';
35
+ import { registerReverseTools } from './game-import-reverse.js';
36
+ import { registerGameImportTools } from './game-import-tools.js';
35
37
  const API_URL = process.env.GRIPFORGE_API_URL ?? 'https://gripforge.ai';
36
38
  const API_KEY = process.env.GRIPFORGE_API_KEY;
37
39
  const MCP_SELF = '0.1.9';
@@ -45,6 +47,8 @@ registerGameKitLocalTools(server.registerTool.bind(server), { apiUrl: API_URL, g
45
47
  registerMapUnrealLocalTools(server.registerTool.bind(server));
46
48
  registerMapUnrealImportLocalTools(server.registerTool.bind(server));
47
49
  registerMapUnrealExportLocalTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
50
+ registerReverseTools(server.registerTool.bind(server));
51
+ registerGameImportTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
48
52
  registerJoystickTools(server.registerTool.bind(server));
49
53
  registerAbilityTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
50
54
  registerCreatureRigTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gripforgeai/mcp",
3
- "version": "0.1.16",
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.",
3
+ "version": "0.1.18",
4
+ "description": "The tools your AI needs to make your games. Turn prompts into production-ready game assets \u2014 animated characters with their weapons attached, seamless textures, terrain, VFX, HUDs \u2014 and playable game kits Unity, Godot, Unreal or Three.js can load.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
@@ -14,7 +14,8 @@
14
14
  "runtime/unreal-scene-upload.mjs",
15
15
  "runtime/unreal_scene_math.py",
16
16
  "runtime/unreal_spawn_review.py",
17
- "README.md"
17
+ "README.md",
18
+ "runtime/game-import-normalize.py"
18
19
  ],
19
20
  "scripts": {
20
21
  "build": "tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/server.js', 0o755)\"",
@@ -0,0 +1,40 @@
1
+ """Blender manufacture worker. No source scripts, handlers or .blend files are loaded."""
2
+ import bpy
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ plan = json.loads(Path(sys.argv[sys.argv.index('--') + 1]).read_text())
8
+ source, output = plan['input'], plan['output']
9
+ kind = Path(source).suffix.lower()
10
+ bpy.ops.object.select_all(action='SELECT')
11
+ bpy.ops.object.delete(use_global=False)
12
+ if kind in ('.dds', '.tga'):
13
+ image = bpy.data.images.load(source, check_existing=False)
14
+ if not len(image.pixels):
15
+ raise ValueError('Image decoder returned no pixels')
16
+ image.pack() # Resolve lazy pixels before changing the source filepath.
17
+ if not image.has_data:
18
+ raise ValueError('Image decoder returned no pixels')
19
+ image.filepath_raw = output
20
+ image.file_format = 'PNG'
21
+ image.save()
22
+ else:
23
+ if kind == '.fbx':
24
+ bpy.ops.import_scene.fbx(filepath=source, use_image_search=False)
25
+ elif kind == '.obj':
26
+ bpy.ops.wm.obj_import(filepath=source)
27
+ else:
28
+ raise ValueError('Unsupported source format')
29
+ if not any(o.type == 'MESH' for o in bpy.context.scene.objects):
30
+ raise ValueError('No mesh imported')
31
+ # Pack only staged images. Missing textures remain an explicit conversion failure.
32
+ stage = Path(source).parent.resolve()
33
+ for image in bpy.data.images:
34
+ if image.source == 'FILE' and not image.packed_file:
35
+ path = Path(bpy.path.abspath(image.filepath)).resolve()
36
+ if not path.is_relative_to(stage) or not path.is_file():
37
+ raise ValueError('Missing or external material texture: ' + image.name)
38
+ image.pack()
39
+ bpy.ops.export_scene.gltf(filepath=output, export_format='GLB', export_animations=True)
40
+ Path(plan['receipt']).write_text(json.dumps({'version': bpy.app.version_string}))