@gripforgeai/mcp 0.1.15 → 0.1.17
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.
- package/dist/architecture-tools.js +13 -9
- package/dist/asset-production-tools.js +1 -1
- package/dist/game-creation-tools.js +1 -1
- package/dist/game-import-agent.js +457 -0
- package/dist/game-import-extract.js +67 -0
- package/dist/game-import-normalize.js +72 -0
- package/dist/game-import-reverse.js +122 -0
- package/dist/game-import-tools.js +138 -0
- package/dist/game-import-types.js +1 -0
- package/dist/game-import-worker.js +34 -0
- package/dist/gamekit-tools.js +110 -9
- package/dist/scene-tools.js +5 -1
- package/dist/server.js +5 -1
- package/package.json +4 -3
- package/runtime/game-import-normalize.py +40 -0
|
@@ -1,6 +1,6 @@
|
|
|
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}$/);
|
|
@@ -10,6 +10,8 @@ export function registerArchitectureTools(register, options, schema = z) {
|
|
|
10
10
|
name: schema.string().min(1).max(100).optional(), prompt: schema.string().min(3).max(300),
|
|
11
11
|
style: schema.enum(['realistic', 'stylized', 'lowpoly', 'handpainted']).optional(),
|
|
12
12
|
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.`),
|
|
13
|
+
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.'),
|
|
14
|
+
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
15
|
use: schema.enum(['residential', 'retail', 'office', 'industrial', 'mixed']).optional(),
|
|
14
16
|
floors: schema.number().int().min(1).max(30).optional(),
|
|
15
17
|
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,6 +20,7 @@ export function registerArchitectureTools(register, options, schema = z) {
|
|
|
18
20
|
concept: ref.optional().describe('Owned PNG/JPEG/WebP revision for Meshy image-to-3D. At most 12 MiB. Mutually exclusive with source.'),
|
|
19
21
|
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
22
|
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.'),
|
|
23
|
+
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
24
|
source: ref.optional().describe('Reuse an owned static GLB revision instead of paying for this building. The source is never modified.'),
|
|
22
25
|
}).strict();
|
|
23
26
|
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.');
|
|
@@ -42,18 +45,19 @@ export function registerArchitectureTools(register, options, schema = z) {
|
|
|
42
45
|
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
46
|
}).strict();
|
|
44
47
|
const definitions = [
|
|
45
|
-
{ name: ARCHITECTURE_TOOL_NAMES[
|
|
48
|
+
{ 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() } },
|
|
49
|
+
{ 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
50
|
{ name: ARCHITECTURE_TOOL_NAMES[1], kind: 'building', shape: { ...shared, recipe: building,
|
|
47
51
|
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
52
|
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
|
|
53
|
+
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
54
|
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
|
|
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.' },
|
|
55
|
+
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.'),
|
|
56
|
+
}, 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.' },
|
|
57
|
+
{ 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
58
|
{ 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
59
|
];
|
|
56
|
-
for (const tool of definitions)
|
|
60
|
+
for (const tool of definitions.sort((a, b) => ARCHITECTURE_TOOL_NAMES.indexOf(a.name) - ARCHITECTURE_TOOL_NAMES.indexOf(b.name)))
|
|
57
61
|
register(tool.name, { title: tool.title, description: tool.description, inputSchema: tool.shape,
|
|
58
62
|
annotations: { readOnlyHint: tool.kind === 'schema', destructiveHint: false, idempotentHint: tool.kind === 'schema', openWorldHint: tool.kind !== 'schema' } }, async (args, extra) => {
|
|
59
63
|
const parsed = schema.object(tool.shape).strict().safeParse(args);
|
|
@@ -64,10 +68,10 @@ export function registerArchitectureTools(register, options, schema = z) {
|
|
|
64
68
|
return { isError: true, content: [{ type: 'text', text: 'GripForge API key required.' }] };
|
|
65
69
|
const { workspace_id, ...body } = parsed.data;
|
|
66
70
|
try {
|
|
67
|
-
const response = await fetch(options.apiUrl.replace(/\/$/, '') + (tool.kind === 'module' ? '/api/v1/environment-modules' : '/api/v1/architecture'), {
|
|
71
|
+
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
72
|
method: tool.kind === 'schema' ? 'GET' : 'POST',
|
|
69
73
|
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
|
|
74
|
+
...(tool.kind === 'schema' ? {} : { body: JSON.stringify(['module', 'urban'].includes(tool.kind) ? body : { ...body, kind: tool.kind }) }),
|
|
71
75
|
signal: AbortSignal.any([AbortSignal.timeout(60_000), ...(extra?.signal ? [extra.signal] : [])]),
|
|
72
76
|
});
|
|
73
77
|
const data = await response.json();
|
|
@@ -47,7 +47,7 @@ export function registerAssetProductionTools(register, options, schema = z) {
|
|
|
47
47
|
}));
|
|
48
48
|
register('gripforge_asset_search', {
|
|
49
49
|
title: 'Search workspace and Community assets',
|
|
50
|
-
description: 'Search actual asset models, materials and animations, independently of Game Kit modules. Returns provenance, import requirements and Library ids. Results are ranked best first by fitness for a GripForge game (score/100, grade A–D, reasons: triangle budget for the target, rigged and clean rig, clips, empty-handed characters, attachable weapons, classified, file size, adoption); `best` is the top pick across sources. Prefer grade A/B; read the reasons before choosing a C/D. Search both sources before manufacturing missing roles. An empty kit search does not mean there are no assets. Search is free; never substitutes proxy geometry for finished models.',
|
|
50
|
+
description: 'Search actual asset models, materials and animations, independently of Game Kit modules. Returns provenance, import requirements and Library ids. Results are ranked best first by fitness for a GripForge game (score/100, grade A–D, reasons: triangle budget for the target, rigged and clean rig, clips, empty-handed characters, attachable weapons, classified, file size, adoption); `best` is the top pick across sources. Prefer grade A/B; read the reasons before choosing a C/D. Search both sources before manufacturing missing roles. An empty kit search does not mean there are no assets. Search is free; never substitutes proxy geometry for finished models. In a Game Kit project, use it to fill what gripforge_game_audit reports missing (a creature slot gets ranked proposals from gripforge_gamekit_creatures; a character without clips is animated, not left idle).',
|
|
51
51
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
52
52
|
inputSchema: {
|
|
53
53
|
q: schema.string().max(200).describe('Visual role or style, e.g. sci-fi turret, rock, soldier.'),
|
|
@@ -13,7 +13,7 @@ export function registerGameCreationTools(register, options, schema = z) {
|
|
|
13
13
|
};
|
|
14
14
|
register(GAME_CREATION_TOOL_NAMES[0], {
|
|
15
15
|
title: 'Prepare and resume a game',
|
|
16
|
-
description: 'The website and CLI share this saved 3D game creation workflow. Ask which engine (Unity, Godot, Unreal, Three.js) if unspecified; do not choose silently. create saves a brief without spending. quote(operation=plan) estimates AI preparation, accept(quote_token) starts its durable job ONLY after user approval of the shown price. get returns a persistent operation log, slots, studio_url and the same project to resume. Planning can select an appropriate preset OR compose real engine-compatible modules. A missing preset never means the genre needs a native engine. composition.modules pins all module versions; composition.tasks lists the custom logic still to implement. Assembly is a foundation, not a completed custom game: use the coding agent to finish those tasks on the selected engine (including web/Three.js). quote refine with message updates the same plan after approval, preserves selected assets and reviewed work, and persists the conversation. use_reference with slot_id and an owned image asset_id supplies an uploaded concept for visual review. assets searches compatible workspace/community items ranked by the brief and role; select chooses one or sets mode=generate with a prompt. For generation: quote concept, obtain approval, accept, poll get, show the concept, approve_concept only after visual user review; quote generate, obtain approval, accept, poll, show the model in its studio, approve_asset only after visual review. Never infer visual approval from technical success. quote build then accept acquires the selected community assets and binds them to a Game Kit prototype, without generating extra assets. With explicit allow_incomplete=true on the build quote, unreviewed models are excluded and missing slots use module defaults where available or remain empty. Show missing_assets before approval; never imply that an incomplete prototype is finished. Native engine projects still need CLI delivery/import; a web project has a play_url. Revision required for edits. Cancel/retry preserve completed steps. 2D unsupported. No automatic spending, invented asset IDs, or unrelated template substitutions.',
|
|
16
|
+
description: 'The website and CLI share this saved 3D game creation workflow. Ask which engine (Unity, Godot, Unreal, Three.js) if unspecified; do not choose silently. create saves a brief without spending. quote(operation=plan) estimates AI preparation, accept(quote_token) starts its durable job ONLY after user approval of the shown price. get returns a persistent operation log, slots, studio_url and the same project to resume. Planning can select an appropriate preset OR compose real engine-compatible modules. A missing preset never means the genre needs a native engine. composition.modules pins all module versions; composition.tasks lists the custom logic still to implement. Assembly is a foundation, not a completed custom game: use the coding agent to finish those tasks on the selected engine (including web/Three.js). After a build, get also returns creatures (the models bound so that no unit is a capsule) and audit { verdict: complete | playable_with_defects | incomplete, headline, fixed (the free fixes applied: models, standard clips, control scheme), defects [{ severity, message, fix { tool, args, credits? } }], next }: show the user the verdict and what is left, work through next (gripforge_game_audit reads it again), and never present a verdict other than complete as a finished game. quote refine with message updates the same plan after approval, preserves selected assets and reviewed work, and persists the conversation. use_reference with slot_id and an owned image asset_id supplies an uploaded concept for visual review. assets searches compatible workspace/community items ranked by the brief and role; select chooses one or sets mode=generate with a prompt. For generation: quote concept, obtain approval, accept, poll get, show the concept, approve_concept only after visual user review; quote generate, obtain approval, accept, poll, show the model in its studio, approve_asset only after visual review. Never infer visual approval from technical success. quote build then accept acquires the selected community assets and binds them to a Game Kit prototype, without generating extra assets. With explicit allow_incomplete=true on the build quote, unreviewed models are excluded and missing slots use module defaults where available or remain empty. Show missing_assets before approval; never imply that an incomplete prototype is finished. Native engine projects still need CLI delivery/import; a web project has a play_url. Revision required for edits. Cancel/retry preserve completed steps. 2D unsupported. No automatic spending, invented asset IDs, or unrelated template substitutions.',
|
|
17
17
|
inputSchema: shape, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
18
18
|
}, async (args, extra) => {
|
|
19
19
|
const parsed = schema.object(shape).strict().safeParse(args);
|
|
@@ -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
|
+
}
|