s2cfgtojson
Version:
Converts Stalker 2 Cfg file into POJOs
487 lines (486 loc) • 15.2 kB
JavaScript
import { readBinaryCfg } from "./binCfgParser.mjs";
const TAB = " ";
const WILDCARD = "_wildcard";
const KEYWORDS = [
"refurl", // a file path to override
"refkey", // SID to override
"bskipref", // ??? not sure
"bpatch", // allows patching only specific keys
];
const REMOVE_NODE = "removenode";
const INTERNAL_PROPS = new Map([
["__internal__", "_"],
["fork", ""], // methods
["removeNode", ""], // methods
["addNode", ""], // methods
["clone", ""], // methods
["entries", ""], // methods
["forEach", ""], // methods
["filter", ""], // methods
["map", ""], // methods
["fromJson", ""], // methods
["toJson", ""], // methods
["toString", ""], // methods
["fromString", ""], // methods
]);
const INTERNAL_PROPS_INV = new Map(Array.from(INTERNAL_PROPS.entries()).map(([k, v]) => [v, k]));
const REF_INTERNAL_PROPS = new Map([
["rawName", "w"],
["refurl", "u"],
["refkey", "k"],
["bskipref", "s"],
["bpatch", "p"],
["isArray", "a"],
["isRoot", "r"],
["useAsterisk", "*"],
]);
const REF_INTERNAL_PROPS_INV = new Map(Array.from(REF_INTERNAL_PROPS.entries()).map(([k, v]) => [v, k]));
/**
* This file is part of the Stalker 2 Modding Tools project.
* This is a base class for all structs.
*/
export class Struct {
__internal__ = new Refs();
/**
* Creates a new struct instance.
*/
constructor(parentOrRaw) {
if (parentOrRaw instanceof Struct) {
Object.assign(this, parentOrRaw.clone());
}
if (typeof parentOrRaw === "string") {
Object.assign(this, Struct.fromString(parentOrRaw)[0]);
}
if (typeof parentOrRaw === "object" && parentOrRaw !== null) {
Object.assign(this, Struct.fromJson(parentOrRaw));
}
}
fork(clone = false) {
const patch = clone
? this.clone()
: createDynamicClassInstance(this.__internal__.rawName || this.constructor.name);
patch.__internal__.isRoot = this.__internal__.isRoot;
patch.__internal__.isArray = this.__internal__.isArray;
patch.__internal__.useAsterisk = this.__internal__.useAsterisk;
function markAsbPatch(s) {
s.__internal__.bpatch = true;
Object.values(s)
.filter((v) => v instanceof Struct)
.forEach(markAsbPatch);
}
markAsbPatch(patch);
return patch;
}
removeNode(key) {
if (this.__internal__.bpatch !== true) {
throw new Error("Cannot remove node from non-patch struct. Use fork() first.");
}
if (this[key] instanceof Struct) {
this[key].__internal__.removenode = true;
}
else {
console.warn(`Attempting to remove node on non-struct value. Old value: '${this.__internal__.rawName}.${key}' = ${this[key]}. Assigning 'empty' instead.`);
this[key] = "empty";
}
return this;
}
addNode(value, key) {
if (key === undefined) {
const nextIndex = Object.keys(this)
.map((k) => parseInt(k))
.filter((k) => !isNaN(k))
.sort((a, b) => a - b)
.pop();
this[nextIndex !== undefined ? nextIndex + 1 : 0] = value;
}
else {
if (typeof value === "object") {
if (!(value instanceof Struct)) {
value = new Struct(value);
}
value.__internal__.rawName = String(key);
}
this[key] = value;
}
return this;
}
clone() {
const newInstance = new Struct();
newInstance.__internal__ = new Refs(this.__internal__);
this.forEach(([k, v]) => {
if (v instanceof Struct) {
newInstance[k] = v.clone();
}
else {
newInstance[k] = v;
}
});
return newInstance;
}
entries() {
return Object.entries(this).filter(([key]) => !INTERNAL_PROPS.has(key));
}
forEach(callback) {
this.entries().forEach(([key, value], i, arr) => callback([key, value], i, arr));
}
/**
* Filters the struct entries based on a callback function. Returns a copy.
* @param callback
*/
filter(callback) {
const clone = this.clone();
clone.entries().forEach((entry, i, arr) => {
if (!callback(entry, i, arr)) {
delete clone[entry[0]];
}
});
return clone;
}
/**
* Maps the struct entries based on a callback function. Returns a copy.
* @param callback
*/
map(callback) {
const clone = this.clone();
clone.entries().forEach(([key, value], i, arr) => {
clone[key] = callback([key, value], i, arr);
if (clone[key] === null || clone[key] === undefined) {
delete clone[key];
}
});
return clone;
}
static fromJson(obj, minified = false) {
if (typeof obj === "object" && !!obj) {
const instance = new Struct();
Object.entries(obj).forEach(([key, value]) => {
const nKey = fromMinifiedKey(key, minified);
if (nKey === "__internal__") {
instance[nKey] = new Refs(Object.fromEntries(Object.entries(value).map(([k, v]) => [
fromMinifiedKey(k, minified),
v,
])));
}
else {
instance[nKey] = Struct.fromJson(value, minified);
}
});
return instance;
}
return obj;
}
toJson(minify = false) {
const obj = {};
Object.entries(this).forEach(([key, value]) => {
let nKey = maybeMinifyKey(key, minify);
if (value instanceof Struct) {
obj[nKey] = value.toJson(minify);
}
else if (value instanceof Refs) {
obj[nKey] = Object.fromEntries(Object.entries(value).map(([k, v]) => [maybeMinifyKey(k, minify), v]));
}
else {
obj[nKey] = value;
}
});
return obj;
}
toString() {
if (!(this.__internal__ instanceof Refs)) {
this.__internal__ = new Refs(this.__internal__);
}
let text = this.__internal__.isRoot
? `${this.__internal__.rawName} : `
: "";
text += "struct.begin";
const refs = this.__internal__.toString();
if (refs) {
text += ` {${refs}}`;
}
text += "\n";
// Add all keys
text += this.entries()
.map(([key, value]) => {
const nameAlreadyRendered = value instanceof Struct && value.__internal__.isRoot;
const useAsterisk = this.__internal__.isArray && this.__internal__.useAsterisk;
let keyOrIndex = "";
let equalsOrColon = "";
let spaceOrNoSpace = "";
if (!nameAlreadyRendered) {
keyOrIndex = renderKeyName(key, useAsterisk) + " ";
equalsOrColon = value instanceof Struct ? ":" : "=";
spaceOrNoSpace = value === "" ? "" : " ";
}
const renderedValue = value instanceof Struct && value.__internal__.removenode
? REMOVE_NODE
: value;
return pad(`${keyOrIndex}${equalsOrColon}${spaceOrNoSpace}${renderedValue}`);
})
.join("\n");
text += "\nstruct.end";
return text;
}
static fromString(text) {
return walk(text.trim().split("\n"));
}
static fromBinary(bytes) {
return readBinaryCfg(bytes);
}
}
export class Refs {
rawName;
refurl;
refkey;
bskipref;
bpatch;
isArray;
isRoot;
useAsterisk;
removenode;
constructor(ref) {
if (typeof ref === "string") {
ref
.split(";")
.map((ref) => ref.trim())
.filter(Boolean)
.reduce((acc, ref) => {
const [key, value] = ref.split("=");
if (KEYWORDS.includes(key.trim())) {
acc[key.trim()] = value ? value.trim() : true;
}
return acc;
}, this);
}
if (typeof ref === "object") {
Object.assign(this, ref);
}
}
toString() {
return KEYWORDS.map((k) => [k, this[k]])
.filter(([_, v]) => v !== "" && v !== undefined && v !== false)
.map(([k, v]) => {
if (v === true)
return k;
if (k === "refkey") {
return `${k}=${renderKeyName(`${v}`, this.useAsterisk)}`;
}
return `${k}=${v}`;
})
.join(";");
}
}
const structHeadRegex = new RegExp(`^\\s*((.*)\\s*:)?\\s*struct\\.begin\\s*({\\s*((${KEYWORDS.join("|")})\\s*(=.+)?)\\s*})?`);
function parseHead(line, index) {
const match = line.match(structHeadRegex);
if (!match) {
throw new Error(`Invalid struct head: ${line}`);
}
const dummy = createDynamicClassInstance(match[2]?.trim() || "", index);
if (match[3]) {
const parsed = new Refs(match[4]);
dummy.__internal__.refurl = parsed.refurl;
dummy.__internal__.refkey = parsed.refkey;
dummy.__internal__.bskipref = parsed.bskipref;
dummy.__internal__.bpatch = parsed.bpatch;
}
return dummy;
}
export function pad(text) {
return `${TAB}${text.replace(/\n+/g, `\n${TAB}`)}`;
}
function isNumber(ref) {
return Number.isInteger(parseInt(ref)) || typeof ref === "number";
}
export function createDynamicClassInstance(rawName, index) {
const parsedName = parseStructName(rawName) || `UnnamedStruct${index}`;
const name = makeSafeClassName(parsedName);
return new (new Function("parent", "Refs", `return class ${name} extends parent {
__internal__ = new Refs({ rawName: "${rawName.trim()}" });
}`)(Struct, Refs))();
}
function parseKeyValue(line, parent, index) {
const match = line.match(/^(.*?)(\s*:\s*|\s*=\s*)(.*)$/);
if (!match) {
throw new Error(`Invalid key-value pair: ${line}`);
}
const key = parseKey(match[1].trim(), parent, index);
parent[key] = parseValue(match[3].trim());
}
function walk(lines) {
const roots = [];
const stack = [];
let index = 0;
while (index < lines.length) {
const line = lines[index++].trim();
if (line.startsWith("#") || line.startsWith("//")) {
continue; // Skip comments
}
const current = stack[stack.length - 1];
if (line.includes("struct.begin")) {
const newStruct = parseHead(line, index);
if (current) {
const key = parseKey(renderStructName(newStruct.constructor.name), current, index);
current[key] = newStruct;
}
else {
newStruct.__internal__.isRoot = true;
roots.push(newStruct);
}
stack.push(newStruct);
}
else if (line.includes("struct.end")) {
stack.pop();
}
else if (line.includes("=") && current) {
parseKeyValue(line, current, index);
}
}
return roots;
}
export function parseKey(key, parent, index) {
let normKey = key;
if (key.startsWith("[") && key.endsWith("]")) {
parent.__internal__.isArray = true;
normKey = extractKeyFromBrackets(key);
if (normKey === "*") {
parent.__internal__.useAsterisk = true;
return Object.keys(parent).length - 1;
}
if (parent[normKey] !== undefined) {
return `${normKey}_dupe_${index}`;
}
return normKey;
}
if (parent[normKey] !== undefined) {
return `${normKey}_dupe_${index}`;
}
return normKey;
}
export function parseValue(value) {
if (value === "true" || value === "false") {
return value === "true";
}
try {
// understand +- 0.1f / 1. / 0.f / .1 / .1f -> ((\d*)\.?(\d+)|(\d+)\.?(\d*))f?
const matches = value.match(/^(-?)(\d*)\.?(\d*)f?$/);
const minus = matches[1];
const first = matches[2];
const second = matches[3];
if (first || second) {
return parseFloat(`${minus ? "-" : ""}${first || 0}${second ? `.${second}` : ""}`);
}
}
catch (e) { }
return value;
}
function renderStructName(name) {
if (name === WILDCARD) {
return "[*]"; // Special case for wildcard structs
}
if (`${name}`.startsWith("_")) {
return renderStructName(name.slice(1)); // Special case for indexed structs
}
if (isNumber(name)) {
return `[${parseInt(name)}]`;
}
return name;
}
function renderKeyName(key, useAsterisk) {
if (`${key}`.startsWith("_")) {
return renderKeyName(key.slice(1), useAsterisk); // Special case for indexed structs
}
if (`${key}`.includes("*") || useAsterisk) {
return "[*]"; // Special case for wildcard structs
}
if (`${key}`.includes("_dupe_")) {
return renderKeyName(key.slice(0, key.indexOf("_dupe_")));
}
if (isNumber(key)) {
return `[${parseInt(key)}]`;
}
return key;
}
function extractKeyFromBrackets(key) {
if (/\[(.+)]/.test(key)) {
return key.match(/\[(.+)]/)[1];
}
return "";
}
function parseStructName(name) {
if (extractKeyFromBrackets(name) === "*") {
return WILDCARD; // Special case for wildcard structs
}
if (isNumber(extractKeyFromBrackets(name))) {
return `_${name.match(/\[(\d+)]/)[1]}`; // Special case for indexed structs
}
return name
.replace(/\W/g, "_")
.replace(/^\d+/, "_")
.replace(/_+/g, "_")
.replace(/^_+/, "");
}
const RESERVED_IDENTIFIERS = new Set([
"break",
"case",
"catch",
"class",
"const",
"continue",
"debugger",
"default",
"delete",
"do",
"else",
"export",
"extends",
"finally",
"for",
"function",
"if",
"import",
"in",
"instanceof",
"new",
"return",
"super",
"switch",
"this",
"throw",
"try",
"typeof",
"var",
"void",
"while",
"with",
"yield",
"enum",
"await",
"implements",
"package",
"protected",
"static",
"interface",
"private",
"public",
"let",
]);
function makeSafeClassName(name) {
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ||
RESERVED_IDENTIFIERS.has(name)) {
return `_${name}`;
}
return name;
}
function maybeMinifyKey(key, minify) {
return minify &&
(INTERNAL_PROPS.has(key) || REF_INTERNAL_PROPS.has(key))
? INTERNAL_PROPS.get(key) || REF_INTERNAL_PROPS.get(key)
: key;
}
function fromMinifiedKey(key, minified) {
if (!minified)
return key;
return (INTERNAL_PROPS_INV.get(key) ||
REF_INTERNAL_PROPS_INV.get(key) ||
key);
}