// Generates a compact JSON schema model from the RA3 Mod SDK XSD files. // // Usage: // node tools/xsd-to-model.mjs [path-to-CnC3Types.xsd] [output.json] // // The model is used at runtime by the extension for completions, hover // documentation and diagnostics. It is regenerated by developers, not by // end users. import { XMLParser } from "fast-xml-parser"; import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, } from "node:fs"; import { dirname, resolve, basename, relative } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const DEFAULT_ROOT = process.env.RA3_SDK_XSD ?? "C:\\Apps\\RA3-MODSDK-X\\Schemas\\xsd\\CnC3Types.xsd"; const rootXsdPath = resolve(process.argv[2] ?? DEFAULT_ROOT); const outPath = resolve( process.argv[3] ?? resolve(__dirname, "..", "src", "model", "schema-model.json"), ); if (!existsSync(rootXsdPath)) { console.error(`Root XSD not found: ${rootXsdPath}`); console.error("Pass a path or set RA3_SDK_XSD."); process.exit(1); } // ── XML parsing ────────────────────────────────────────────────────── const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", removeNSPrefix: true, parseTagValue: false, parseAttributeValue: false, trimValues: true, ignoreDeclaration: true, }); const asArray = (x) => (Array.isArray(x) ? x : x == null ? [] : [x]); // ── Registry ───────────────────────────────────────────────────────── const loadedFiles = new Map(); // absolute path -> parsed document const complexTypes = new Map(); // name -> raw node const simpleTypes = new Map(); // name -> raw node const globalElements = new Map(); // name -> {type, doc} function loadXsd(absPath, seen = new Set()) { const key = absPath.toLowerCase(); if (loadedFiles.has(key)) return loadedFiles.get(key); if (seen.has(key)) return null; // include cycle seen.add(key); let text; try { text = readFileSync(absPath, "utf8"); } catch (err) { console.warn(` ! cannot read ${absPath}: ${err.message}`); return null; } const doc = parser.parse(text); loadedFiles.set(key, doc); const schema = doc?.schema ?? doc?.["xs:schema"] ?? {}; for (const inc of asArray(schema.include)) { const loc = inc?.["@_schemaLocation"]; if (!loc) continue; loadXsd(resolve(dirname(absPath), loc), seen); } for (const ct of asArray(schema.complexType)) { const name = ct?.["@_name"]; if (name) complexTypes.set(name, ct); } for (const st of asArray(schema.simpleType)) { const name = st?.["@_name"]; if (name) simpleTypes.set(name, st); } for (const el of asArray(schema.element)) { const name = el?.["@_name"]; if (!name) continue; if (el.complexType) { // Inline complexType for a global element (e.g. AssetDeclaration). const keyName = `@global:${name}`; complexTypes.set(keyName, el.complexType); globalElements.set(name, { type: keyName, doc: docOf(el) }); } else if (el["@_type"]) { globalElements.set(name, { type: el["@_type"], doc: docOf(el) }); } } return doc; } function docOf(node) { const doc = node?.annotation?.documentation; if (doc == null) return ""; return String(doc).trim(); } // ── Attribute / simple type helpers ────────────────────────────────── function normalizeTypeName(raw) { if (!raw) return null; return String(raw).replace(/^xs:/, "").trim(); } const BUILTIN = new Set([ "string", "boolean", "integer", "int", "long", "short", "byte", "unsignedInt", "unsignedShort", "unsignedByte", "decimal", "float", "double", "anyURI", "date", "dateTime", "duration", "token", "NMTOKEN", "Name", "ID", ]); function patternAllowsDefine(pattern) { return /\(=\[_a-zA-Z]/.test(pattern) || /=\$/.test(pattern); } function enumOfSimpleType(node) { const restriction = node?.restriction; if (!restriction) return []; const enums = asArray(restriction.enumeration).map((e) => e?.["@_value"]); return enums.filter((v) => v != null); } function patternOfSimpleType(node) { const restriction = node?.restriction; if (!restriction) return null; const pats = asArray(restriction.pattern).map((p) => p?.["@_value"]); return pats.length ? pats : null; } /** * Resolve an attribute type reference to a normalized descriptor. * Returns { kind: "builtin"|"simple"|"complex", name, refType, enumValues, * allowsDefine, isBoolean, base, doc } */ function resolveTypeDescriptor(typeName, memo = new Map()) { const name = normalizeTypeName(typeName); if (name == null) return null; if (memo.has(name)) return memo.get(name); if (BUILTIN.has(name)) { const desc = { kind: "builtin", name, refType: null, enumValues: [], allowsDefine: false, isBoolean: name === "boolean", base: null, doc: "", }; memo.set(name, desc); return desc; } const st = simpleTypes.get(name); if (st) { memo.set(name, null); // guard against cycles const base = normalizeTypeName(st?.restriction?.["@_base"]); const baseDesc = base ? resolveTypeDescriptor(base, memo) : null; const enumValues = enumOfSimpleType(st); const patterns = patternOfSimpleType(st); const refType = st?.["@_refType"] ?? (baseDesc?.refType ?? null); const desc = { kind: "simple", name, refType: normalizeTypeName(refType) || baseDesc?.refType || null, enumValues: enumValues.length ? enumValues : baseDesc?.enumValues ?? [], allowsDefine: (patterns ? patterns.some(patternAllowsDefine) : false) || baseDesc?.allowsDefine || false, isRef: st?.["@_isRef"] === "true" || st?.["@_isWeakRef"] === "true" || baseDesc?.isRef || false, isBoolean: name === "SageBool" || baseDesc?.isBoolean || false, base, doc: docOf(st), }; memo.set(name, desc); return desc; } if (complexTypes.has(name) || name.startsWith("@")) { const desc = { kind: "complex", name, refType: null, enumValues: [], allowsDefine: false, isRef: false, isBoolean: false, base: null, doc: "", }; memo.set(name, desc); return desc; } const desc = { kind: "unknown", name, refType: null, enumValues: [], allowsDefine: false, isRef: false, isBoolean: false, base: null, doc: "", }; memo.set(name, desc); return desc; } // ── Complex type expansion ─────────────────────────────────────────── function collectChildren(node, out = []) { if (!node || typeof node !== "object") return out; for (const el of asArray(node.element)) { const name = el?.["@_name"]; if (!name) continue; const child = { name, type: normalizeTypeName(el["@_type"]), min: parseInt(el["@_minOccurs"] ?? "1", 10), max: el["@_maxOccurs"] === "unbounded" ? -1 : parseInt(el["@_maxOccurs"] ?? "1", 10), doc: docOf(el), }; if (el.complexType) { const inlineKey = `@inline:${name}`; if (!complexTypes.has(inlineKey)) complexTypes.set(inlineKey, el.complexType); child.type = inlineKey; } out.push(child); } for (const key of ["sequence", "choice", "all"]) { for (const container of asArray(node[key])) { collectChildren(container, out); } } return out; } function collectAttributes(node, out = []) { if (!node) return out; for (const attr of asArray(node.attribute)) { const name = attr?.["@_name"]; if (!name) continue; const use = attr?.["@_use"]; let desc = null; let inlineEnum = []; if (attr["@_type"]) { desc = resolveTypeDescriptor(attr["@_type"]); } else if (attr.simpleType) { inlineEnum = enumOfSimpleType(attr.simpleType); const pats = patternOfSimpleType(attr.simpleType); desc = { kind: "simple", name: `@attr:${name}`, refType: attr.simpleType?.["@_refType"] ?? null, enumValues: inlineEnum, allowsDefine: pats ? pats.some(patternAllowsDefine) : false, isBoolean: false, base: normalizeTypeName(attr.simpleType?.restriction?.["@_base"]), doc: docOf(attr.simpleType), }; } const entry = { name, required: use === "required", default: attr["@_Default"] ?? attr["@_default"] ?? null, doc: docOf(attr), kind: desc?.kind ?? "unknown", type: desc?.name ?? null, refType: normalizeTypeName(desc?.refType) ?? null, enumValues: desc?.enumValues ?? inlineEnum ?? [], allowsDefine: desc?.allowsDefine ?? false, isRef: desc?.isRef ?? false, isBoolean: desc?.isBoolean ?? false, base: normalizeTypeName(desc?.base) ?? null, }; out.push(entry); } return out; } const expandedTypes = new Map(); // typeName -> {children, attributes, base, doc} function expandComplexType(name, chain = []) { if (expandedTypes.has(name)) return expandedTypes.get(name); if (chain.includes(name)) { // inheritance cycle (should not happen) - bail out const empty = { children: [], attributes: [], base: null, doc: "" }; expandedTypes.set(name, empty); return empty; } const node = complexTypes.get(name); if (!node) return null; const extension = node?.complexContent?.extension; const baseName = normalizeTypeName(extension?.["@_base"] ?? node?.simpleContent?.extension?.["@_base"]); const base = baseName ? expandComplexType(baseName, [...chain, name]) : null; const ownChildren = collectChildren(extension ?? node); const ownAttributes = collectAttributes(extension ?? node); const children = [...(base?.children ?? []), ...ownChildren]; const attributes = [...(base?.attributes ?? [])]; for (const attr of ownAttributes) { const idx = attributes.findIndex((a) => a.name === attr.name); if (idx >= 0) attributes[idx] = attr; else attributes.push(attr); } const result = { children, attributes, base: baseName, doc: docOf(node), }; expandedTypes.set(name, result); return result; } // ── Build model ────────────────────────────────────────────────────── console.log(`Loading XSD tree from ${rootXsdPath} ...`); const start = Date.now(); loadXsd(rootXsdPath); console.log( `Loaded ${loadedFiles.size} XSD files in ${((Date.now() - start) / 1000).toFixed(1)}s` + ` (${complexTypes.size} complexTypes, ${simpleTypes.size} simpleTypes, ${globalElements.size} global elements)`, ); // Top-level elements come from the AssetDeclaration inline choice plus any // global elements registered directly. const assetDecl = globalElements.get("AssetDeclaration"); const topLevelElements = new Set(); if (assetDecl) { const declType = expandComplexType(assetDecl.type); for (const child of declType?.children ?? []) { topLevelElements.add(child.name); } } // Serialize resolved complex types. const typesOut = {}; for (const name of complexTypes.keys()) { const resolved = expandComplexType(name); if (resolved) { typesOut[name] = { kind: "complex", ...resolved, }; } } for (const [name, node] of simpleTypes) { const desc = resolveTypeDescriptor(name); typesOut[name] = { kind: "simple", base: desc?.base ?? normalizeTypeName(node?.restriction?.["@_base"]) ?? null, refType: desc?.refType ?? node?.["@_refType"] ?? null, isRef: node?.["@_isRef"] === "true" || desc?.refType != null, enumValues: desc?.enumValues ?? [], allowsDefine: desc?.allowsDefine ?? false, doc: docOf(node), }; } // Inheritance map: subtype -> [base names], used to match refType against // concrete asset types (a ref to BaseAudioEventInfo should also match // AudioEvent). const subTypesOf = {}; for (const [name, node] of complexTypes) { const base = normalizeTypeName(node?.complexContent?.extension?.["@_base"]); if (base) { (subTypesOf[name] ??= []).push(base); } } const model = { version: 2, rootXsd: basename(rootXsdPath), targetNamespace: "uri:ea.com:eala:asset", topLevelElements: [...topLevelElements].sort(), elements: Object.fromEntries( [...globalElements.entries()].map(([name, info]) => [ name, { type: normalizeTypeName(info.type), doc: info.doc }, ]), ), types: typesOut, subTypesOf, }; mkdirSync(dirname(outPath), { recursive: true }); writeFileSync(outPath, JSON.stringify(model)); const sizeKb = Math.round(statSync(outPath).size / 1024); console.log( `Wrote ${outPath} (${sizeKb} KB, ${topLevelElements.size} top-level elements, ` + `${Object.keys(typesOut).length} types)`, );