first commit
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
// Extracts the SAGE AssetType hash -> type name mapping from OpenSAGE's
|
||||
// AssetType.cs into src/model/asset-types.json.
|
||||
//
|
||||
// Usage:
|
||||
// node tools/extract-asset-types.mjs [path-to-AssetType.cs]
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const defaultSource = resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"OpenSAGE",
|
||||
"src",
|
||||
"OpenSage.Game",
|
||||
"Data",
|
||||
"StreamFS",
|
||||
"AssetType.cs",
|
||||
);
|
||||
const sourcePath = process.argv[2] ?? defaultSource;
|
||||
|
||||
const text = readFileSync(sourcePath, "utf8");
|
||||
|
||||
// Match lines like:
|
||||
// GameObject = 0x942FFF2D,
|
||||
// W3dAnimation = 0x2448AE30,
|
||||
const typeRegex = /^\s*(\w+)\s*=\s*0x([0-9A-Fa-f]{8})\s*,/gm;
|
||||
const types = new Map();
|
||||
let match;
|
||||
while ((match = typeRegex.exec(text)) !== null) {
|
||||
const name = match[1];
|
||||
const hash = match[2].toUpperCase();
|
||||
const numeric = parseInt(hash, 16);
|
||||
if (types.has(numeric)) {
|
||||
console.warn(`duplicate hash 0x${hash}: ${types.get(numeric)} vs ${name}`);
|
||||
}
|
||||
types.set(numeric, name);
|
||||
}
|
||||
|
||||
if (types.size === 0) {
|
||||
console.error(`No AssetType entries found in ${sourcePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const output = {
|
||||
version: 1,
|
||||
source: "OpenSAGE src/OpenSage.Game/Data/StreamFS/AssetType.cs",
|
||||
count: types.size,
|
||||
types: Object.fromEntries(
|
||||
[...types.entries()].map(([hash, name]) => [hash, name]),
|
||||
),
|
||||
};
|
||||
|
||||
const outPath = resolve(__dirname, "..", "src", "model", "asset-types.json");
|
||||
mkdirSync(dirname(outPath), { recursive: true });
|
||||
writeFileSync(outPath, JSON.stringify(output, null, 1));
|
||||
console.log(`Wrote ${types.size} asset types to ${outPath}`);
|
||||
@@ -0,0 +1,430 @@
|
||||
// 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)`,
|
||||
);
|
||||
Reference in New Issue
Block a user