wscodec 0.1.0 → 0.3.0
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/README.md +176 -54
- package/io.mjs +68 -5
- package/json.mjs +614 -0
- package/package.json +25 -5
- package/primitives.mjs +68 -5
- package/properties.mjs +247 -49
- package/structs.mjs +39 -6
- package/values.mjs +30 -25
- package/wscodec.mjs +129 -12
package/json.mjs
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON converter for the wscodec object tree. Part of the public codec API.
|
|
3
|
+
*
|
|
4
|
+
* blobToJSON(blob) UnrealBlob → JSON-safe plain object
|
|
5
|
+
* jsonToBlob(json) plain object → UnrealBlob
|
|
6
|
+
* blobToJSONString(blob) shortcut: blobToJSON + JSON.stringify (with -0/NaN/Inf handling)
|
|
7
|
+
* jsonStringToBlob(str) shortcut: JSON.parse (with -0/NaN/Inf handling) + jsonToBlob
|
|
8
|
+
*
|
|
9
|
+
* Round-trip guarantee: when the input bytes decode cleanly, the chain
|
|
10
|
+
* bytes → UnrealBlob.decode → blobToJSON → JSON.stringify
|
|
11
|
+
* → JSON.parse → jsonToBlob → UnrealBlob.serialize
|
|
12
|
+
* produces the same bytes as the input. Verified on every row of every tested
|
|
13
|
+
* Soulmask world.db (`node test/test-json-full.mjs <path>`).
|
|
14
|
+
*
|
|
15
|
+
* Design rules:
|
|
16
|
+
* - Convert as much as possible to structured JSON. Base64 is reserved for
|
|
17
|
+
* genuinely undecoded content: OpaqueValue (codec gave up on this slot)
|
|
18
|
+
* and ArrayValue._perElementTrailings (JianZhuInstYuanXings placement
|
|
19
|
+
* blocks whose semantics we don't decode).
|
|
20
|
+
* - Preserve every wire-form distinction needed for byte-identical round
|
|
21
|
+
* trip: FString isNull flags, ObjectRef kindOnePrefix /
|
|
22
|
+
* hasTerminatorTrailer, StructValue propStream-vs-binary form, FText
|
|
23
|
+
* historyType variants, ArrayValue innerTag for struct arrays,
|
|
24
|
+
* MapProperty<StructProperty,_> key/value conventions, etc.
|
|
25
|
+
* - JSON shapes are tagged with discriminators where the decoder needs to
|
|
26
|
+
* dispatch: `form` on StructValue ('binary' | 'propStream' | 'decodeError'),
|
|
27
|
+
* `historyType` on FTextValue, `_opaque` on OpaqueValue payloads.
|
|
28
|
+
*
|
|
29
|
+
* Numeric edge cases (-0, NaN, +/-Infinity) round-trip through
|
|
30
|
+
* blobToJSONString / jsonStringToBlob via a sentinel substitution; the bare
|
|
31
|
+
* blobToJSON / jsonToBlob pair preserves them inside the JS object tree
|
|
32
|
+
* (because Object.is(-0, -0)) but a naive JSON.stringify would lose them.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { FName, FGuid } from './primitives.mjs';
|
|
36
|
+
import { StructValue } from './structs.mjs';
|
|
37
|
+
import { PropertyTag, Property, ArrayValue, SetValue, MapValue } from './properties.mjs';
|
|
38
|
+
import { ObjectRef, SoftObjectRef, FTextValue, OpaqueValue } from './values.mjs';
|
|
39
|
+
// Intentional cycle: UnrealBlob is defined in wscodec.mjs, and wscodec.mjs
|
|
40
|
+
// re-exports this module's symbols. ESM live bindings make this safe as long
|
|
41
|
+
// as UnrealBlob is only USED inside function bodies (deferred), which it is.
|
|
42
|
+
import { UnrealBlob } from './wscodec.mjs';
|
|
43
|
+
|
|
44
|
+
// ── base64 helpers (Node Buffer) ────────────────────────────────────────────
|
|
45
|
+
function b64encode(u8) { return Buffer.from(u8.buffer, u8.byteOffset, u8.byteLength).toString('base64'); }
|
|
46
|
+
function b64decode(s) { return new Uint8Array(Buffer.from(s, 'base64')); }
|
|
47
|
+
|
|
48
|
+
// ── FName helpers ───────────────────────────────────────────────────────────
|
|
49
|
+
// Names round-trip as bare strings in the common case. When isUnicode or
|
|
50
|
+
// isNull is non-default, fall back to an object form so the wire flags survive.
|
|
51
|
+
function nameJSON(fn) {
|
|
52
|
+
if (fn == null) return null;
|
|
53
|
+
if (fn instanceof FName) {
|
|
54
|
+
if (!fn.isUnicode && !fn.isNull && (fn.number | 0) === 0) return fn.value;
|
|
55
|
+
return { value: fn.value, isUnicode: fn.isUnicode, isNull: fn.isNull, number: fn.number };
|
|
56
|
+
}
|
|
57
|
+
return String(fn);
|
|
58
|
+
}
|
|
59
|
+
function nameFromJSON(j) {
|
|
60
|
+
if (j == null) return null;
|
|
61
|
+
return FName.from(j);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── PropertyTag JSON ────────────────────────────────────────────────────────
|
|
65
|
+
function tagToJSON(tag) {
|
|
66
|
+
const j = { name: nameJSON(tag.name), type: nameJSON(tag.type), size: tag.size };
|
|
67
|
+
if (tag.arrayIndex) j.arrayIndex = tag.arrayIndex;
|
|
68
|
+
switch (tag.type?.value) {
|
|
69
|
+
case 'StructProperty': j.structName = nameJSON(tag.structName); j.structGuid = tag.structGuid?.value ?? null; break;
|
|
70
|
+
case 'BoolProperty': j.boolVal = tag.boolVal; break;
|
|
71
|
+
case 'ByteProperty':
|
|
72
|
+
case 'EnumProperty': j.enumName = nameJSON(tag.enumName); break;
|
|
73
|
+
case 'ArrayProperty':
|
|
74
|
+
case 'SetProperty': j.innerType = nameJSON(tag.innerType); break;
|
|
75
|
+
case 'MapProperty': j.innerType = nameJSON(tag.innerType); j.valueType = nameJSON(tag.valueType); break;
|
|
76
|
+
}
|
|
77
|
+
if (tag.hasPropertyGuid) {
|
|
78
|
+
j.hasPropertyGuid = true;
|
|
79
|
+
j.propertyGuid = tag.propertyGuid?.value ?? null;
|
|
80
|
+
}
|
|
81
|
+
return j;
|
|
82
|
+
}
|
|
83
|
+
function tagFromJSON(j) {
|
|
84
|
+
const tag = new PropertyTag({
|
|
85
|
+
name: nameFromJSON(j.name),
|
|
86
|
+
type: nameFromJSON(j.type),
|
|
87
|
+
size: j.size,
|
|
88
|
+
arrayIndex: j.arrayIndex || 0,
|
|
89
|
+
hasPropertyGuid: !!j.hasPropertyGuid,
|
|
90
|
+
});
|
|
91
|
+
switch (tag.type?.value) {
|
|
92
|
+
case 'StructProperty': tag.structName = nameFromJSON(j.structName); tag.structGuid = j.structGuid ? new FGuid(j.structGuid) : null; break;
|
|
93
|
+
case 'BoolProperty': tag.boolVal = j.boolVal; break;
|
|
94
|
+
case 'ByteProperty':
|
|
95
|
+
case 'EnumProperty': tag.enumName = nameFromJSON(j.enumName); break;
|
|
96
|
+
case 'ArrayProperty':
|
|
97
|
+
case 'SetProperty': tag.innerType = nameFromJSON(j.innerType); break;
|
|
98
|
+
case 'MapProperty': tag.innerType = nameFromJSON(j.innerType); tag.valueType = nameFromJSON(j.valueType); break;
|
|
99
|
+
}
|
|
100
|
+
if (tag.hasPropertyGuid) tag.propertyGuid = j.propertyGuid ? new FGuid(j.propertyGuid) : null;
|
|
101
|
+
return tag;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Value JSON, dispatching on tag.type and tag.innerType/valueType ─────────
|
|
105
|
+
function valueToJSON(tag, value) {
|
|
106
|
+
if (value instanceof OpaqueValue) {
|
|
107
|
+
return { _opaque: true, bytes: b64encode(value.bytes), reason: value.reason };
|
|
108
|
+
}
|
|
109
|
+
const t = tag.type.value;
|
|
110
|
+
switch (t) {
|
|
111
|
+
case 'IntProperty': case 'Int8Property': case 'Int16Property':
|
|
112
|
+
case 'UInt16Property': case 'UInt32Property':
|
|
113
|
+
case 'FloatProperty': case 'DoubleProperty':
|
|
114
|
+
case 'BoolProperty':
|
|
115
|
+
return value;
|
|
116
|
+
case 'Int64Property': case 'UInt64Property':
|
|
117
|
+
return value; // already string
|
|
118
|
+
case 'StrProperty':
|
|
119
|
+
return value; // bare string
|
|
120
|
+
case 'NameProperty':
|
|
121
|
+
case 'EnumProperty':
|
|
122
|
+
return nameJSON(value);
|
|
123
|
+
case 'ByteProperty':
|
|
124
|
+
// enumName==='None' → numeric byte, else FName.
|
|
125
|
+
return tag.enumName?.value === 'None' ? value : nameJSON(value);
|
|
126
|
+
case 'ObjectProperty': case 'ClassProperty':
|
|
127
|
+
case 'WeakObjectProperty': case 'LazyObjectProperty':
|
|
128
|
+
case 'WSObjectProperty':
|
|
129
|
+
return objectRefToJSON(value);
|
|
130
|
+
case 'SoftObjectProperty': case 'SoftClassProperty':
|
|
131
|
+
return { assetPath: value.assetPath, subPath: value.subPath };
|
|
132
|
+
case 'StructProperty':
|
|
133
|
+
return structValueToJSON(value);
|
|
134
|
+
case 'ArrayProperty':
|
|
135
|
+
return arrayValueToJSON(value, tag);
|
|
136
|
+
case 'SetProperty':
|
|
137
|
+
return setValueToJSON(value, tag);
|
|
138
|
+
case 'MapProperty':
|
|
139
|
+
return mapValueToJSON(value, tag);
|
|
140
|
+
case 'TextProperty':
|
|
141
|
+
return fTextToJSON(value);
|
|
142
|
+
default:
|
|
143
|
+
throw new Error(`valueToJSON: unsupported type ${t}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function valueFromJSON(tag, j) {
|
|
148
|
+
if (j && typeof j === 'object' && !Array.isArray(j) && j._opaque) {
|
|
149
|
+
return new OpaqueValue(b64decode(j.bytes), j.reason ?? null);
|
|
150
|
+
}
|
|
151
|
+
const t = tag.type.value;
|
|
152
|
+
switch (t) {
|
|
153
|
+
case 'IntProperty': case 'Int8Property': case 'Int16Property':
|
|
154
|
+
case 'UInt16Property': case 'UInt32Property':
|
|
155
|
+
case 'FloatProperty': case 'DoubleProperty':
|
|
156
|
+
case 'BoolProperty':
|
|
157
|
+
return j;
|
|
158
|
+
case 'Int64Property': case 'UInt64Property':
|
|
159
|
+
return String(j);
|
|
160
|
+
case 'StrProperty':
|
|
161
|
+
return j;
|
|
162
|
+
case 'NameProperty':
|
|
163
|
+
case 'EnumProperty':
|
|
164
|
+
return nameFromJSON(j);
|
|
165
|
+
case 'ByteProperty':
|
|
166
|
+
return tag.enumName?.value === 'None' ? j : nameFromJSON(j);
|
|
167
|
+
case 'ObjectProperty': case 'ClassProperty':
|
|
168
|
+
case 'WeakObjectProperty': case 'LazyObjectProperty':
|
|
169
|
+
case 'WSObjectProperty':
|
|
170
|
+
return objectRefFromJSON(j);
|
|
171
|
+
case 'SoftObjectProperty': case 'SoftClassProperty':
|
|
172
|
+
return new SoftObjectRef({ assetPath: j.assetPath, subPath: j.subPath });
|
|
173
|
+
case 'StructProperty':
|
|
174
|
+
return structValueFromJSON(j, tag.structName?.value);
|
|
175
|
+
case 'ArrayProperty':
|
|
176
|
+
return arrayValueFromJSON(j, tag);
|
|
177
|
+
case 'SetProperty':
|
|
178
|
+
return setValueFromJSON(j, tag);
|
|
179
|
+
case 'MapProperty':
|
|
180
|
+
return mapValueFromJSON(j, tag);
|
|
181
|
+
case 'TextProperty':
|
|
182
|
+
return fTextFromJSON(j);
|
|
183
|
+
default:
|
|
184
|
+
throw new Error(`valueFromJSON: unsupported type ${t}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── ObjectRef ───────────────────────────────────────────────────────────────
|
|
189
|
+
function objectRefToJSON(v) {
|
|
190
|
+
if (!(v instanceof ObjectRef)) throw new Error(`objectRefToJSON: expected ObjectRef, got ${v?.constructor?.name}`);
|
|
191
|
+
const j = { kind: v._objectKind };
|
|
192
|
+
if (v._kindOnePrefix != null) j.kindOnePrefix = v._kindOnePrefix;
|
|
193
|
+
if (v.path !== null) j.path = v.path;
|
|
194
|
+
if (v._pathIsNull) j.pathIsNull = true;
|
|
195
|
+
if (v.classPath !== null) j.classPath = v.classPath;
|
|
196
|
+
if (v._classPathIsNull) j.classPathIsNull = true;
|
|
197
|
+
if (Array.isArray(v.embedded)) {
|
|
198
|
+
j.embedded = v.embedded.map(propertyToJSON);
|
|
199
|
+
if (v.terminated) j.embeddedTerminated = true;
|
|
200
|
+
if (v.hasTerminatorTrailer) j.hasTerminatorTrailer = true;
|
|
201
|
+
}
|
|
202
|
+
return j;
|
|
203
|
+
}
|
|
204
|
+
function objectRefFromJSON(j) {
|
|
205
|
+
return new ObjectRef({
|
|
206
|
+
kind: j.kind,
|
|
207
|
+
kindOnePrefix: 'kindOnePrefix' in j ? j.kindOnePrefix : null,
|
|
208
|
+
path: 'path' in j ? j.path : null,
|
|
209
|
+
pathIsNull: !!j.pathIsNull,
|
|
210
|
+
classPath: 'classPath' in j ? j.classPath : null,
|
|
211
|
+
classPathIsNull: !!j.classPathIsNull,
|
|
212
|
+
embedded: Array.isArray(j.embedded) ? j.embedded.map(propertyFromJSON) : null,
|
|
213
|
+
terminated: !!j.embeddedTerminated,
|
|
214
|
+
hasTerminatorTrailer: !!j.hasTerminatorTrailer,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── StructValue ─────────────────────────────────────────────────────────────
|
|
219
|
+
// Three forms, distinguished by the `form` discriminator:
|
|
220
|
+
// "binary" — STRUCT_HANDLERS produced a plain object/string for this struct
|
|
221
|
+
// "propStream" — unknown struct name, decoded as a nested property stream
|
|
222
|
+
// "decodeError"— struct decode failed; opaqueTail holds the leftover bytes
|
|
223
|
+
function structValueToJSON(v) {
|
|
224
|
+
if (!(v instanceof StructValue)) throw new Error(`structValueToJSON: expected StructValue, got ${v?.constructor?.name}`);
|
|
225
|
+
if (v._structDecodeError) {
|
|
226
|
+
return {
|
|
227
|
+
form: 'decodeError',
|
|
228
|
+
error: v._structDecodeError,
|
|
229
|
+
opaqueTail: v._opaqueTail ? b64encode(v._opaqueTail) : null,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (Array.isArray(v.value)) {
|
|
233
|
+
return {
|
|
234
|
+
form: 'propStream',
|
|
235
|
+
terminated: !!v.terminated,
|
|
236
|
+
properties: v.value.map(propertyToJSON),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
// Binary handler. Special-case value shapes that aren't plain objects:
|
|
240
|
+
// Guid → FGuid (has .value); we JSON it as a bare string
|
|
241
|
+
// DateTime/Timespan → already string
|
|
242
|
+
let jval = v.value;
|
|
243
|
+
if (v.value instanceof FGuid) jval = v.value.value;
|
|
244
|
+
return { form: 'binary', value: jval };
|
|
245
|
+
}
|
|
246
|
+
function structValueFromJSON(j, structName) {
|
|
247
|
+
if (j.form === 'decodeError') {
|
|
248
|
+
return new StructValue(structName, {
|
|
249
|
+
value: [], decodeError: j.error,
|
|
250
|
+
opaqueTail: j.opaqueTail ? b64decode(j.opaqueTail) : null,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (j.form === 'propStream') {
|
|
254
|
+
return new StructValue(structName, {
|
|
255
|
+
value: j.properties.map(propertyFromJSON),
|
|
256
|
+
terminated: !!j.terminated,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (j.form === 'binary') {
|
|
260
|
+
let val = j.value;
|
|
261
|
+
if (structName === 'Guid' && typeof val === 'string') val = new FGuid(val);
|
|
262
|
+
return new StructValue(structName, { value: val });
|
|
263
|
+
}
|
|
264
|
+
throw new Error(`structValueFromJSON: unknown form '${j.form}'`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── ArrayValue / SetValue / MapValue ────────────────────────────────────────
|
|
268
|
+
function arrayValueToJSON(v, tag) {
|
|
269
|
+
if (!(v instanceof ArrayValue)) throw new Error(`arrayValueToJSON: expected ArrayValue, got ${v?.constructor?.name}`);
|
|
270
|
+
const innerType = tag.innerType.value;
|
|
271
|
+
const j = { elements: v.elements.map(e => arrayElementToJSON(e, innerType, v._arrayInnerTag)) };
|
|
272
|
+
if (v._arrayInnerTag) j.innerTag = tagToJSON(v._arrayInnerTag);
|
|
273
|
+
if (v._perElementTrailings) {
|
|
274
|
+
// Per-element placement-binary block (JianZhuInstYuanXings). Each entry is
|
|
275
|
+
// either null OR { transforms: [[16 floats], …], ids: [u32, …], aux: [[16 floats], …] }.
|
|
276
|
+
// Header and strides are constants on the wire — synthesized on write.
|
|
277
|
+
j.perElementTrailings = v._perElementTrailings.map(t => {
|
|
278
|
+
if (t == null) return null;
|
|
279
|
+
return { transforms: t.transforms, ids: t.ids, aux: t.aux };
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return j;
|
|
283
|
+
}
|
|
284
|
+
function arrayValueFromJSON(j, tag) {
|
|
285
|
+
const innerType = tag.innerType.value;
|
|
286
|
+
const innerTag = j.innerTag ? tagFromJSON(j.innerTag) : null;
|
|
287
|
+
const elements = j.elements.map(e => arrayElementFromJSON(e, innerType, innerTag));
|
|
288
|
+
let perElementTrailings = null;
|
|
289
|
+
if (Array.isArray(j.perElementTrailings)) {
|
|
290
|
+
perElementTrailings = j.perElementTrailings.map(t => {
|
|
291
|
+
if (t == null) return null;
|
|
292
|
+
return { transforms: t.transforms, ids: t.ids, aux: t.aux };
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return new ArrayValue({ elements, innerTag, perElementTrailings });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Array/Set element converters. For StructProperty arrays the element is a
|
|
299
|
+
// StructValue (the struct-array path always emits one inner PropertyTag and
|
|
300
|
+
// uses STRUCT_HANDLERS or a property stream per element).
|
|
301
|
+
function arrayElementToJSON(e, innerType, innerTag) {
|
|
302
|
+
if (innerType === 'StructProperty') return structValueToJSON(e);
|
|
303
|
+
switch (innerType) {
|
|
304
|
+
case 'IntProperty': case 'Int8Property': case 'Int16Property':
|
|
305
|
+
case 'UInt16Property': case 'UInt32Property':
|
|
306
|
+
case 'FloatProperty': case 'DoubleProperty':
|
|
307
|
+
case 'BoolProperty': case 'ByteProperty':
|
|
308
|
+
return e;
|
|
309
|
+
case 'Int64Property': case 'UInt64Property':
|
|
310
|
+
return e; // already string
|
|
311
|
+
case 'StrProperty':
|
|
312
|
+
return e;
|
|
313
|
+
case 'NameProperty': case 'EnumProperty':
|
|
314
|
+
return nameJSON(e);
|
|
315
|
+
case 'TextProperty':
|
|
316
|
+
if (e instanceof OpaqueValue) return { _opaque: true, bytes: b64encode(e.bytes), reason: e.reason };
|
|
317
|
+
return fTextToJSON(e);
|
|
318
|
+
case 'ObjectProperty': case 'ClassProperty':
|
|
319
|
+
case 'WeakObjectProperty': case 'LazyObjectProperty':
|
|
320
|
+
case 'WSObjectProperty':
|
|
321
|
+
return objectRefToJSON(e);
|
|
322
|
+
case 'SoftObjectProperty': case 'SoftClassProperty':
|
|
323
|
+
return { assetPath: e.assetPath, subPath: e.subPath };
|
|
324
|
+
default:
|
|
325
|
+
throw new Error(`arrayElementToJSON: unsupported innerType ${innerType}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function arrayElementFromJSON(j, innerType, innerTag) {
|
|
329
|
+
if (innerType === 'StructProperty') return structValueFromJSON(j, innerTag?.structName?.value);
|
|
330
|
+
if (j && typeof j === 'object' && !Array.isArray(j) && j._opaque) {
|
|
331
|
+
return new OpaqueValue(b64decode(j.bytes), j.reason ?? null);
|
|
332
|
+
}
|
|
333
|
+
switch (innerType) {
|
|
334
|
+
case 'IntProperty': case 'Int8Property': case 'Int16Property':
|
|
335
|
+
case 'UInt16Property': case 'UInt32Property':
|
|
336
|
+
case 'FloatProperty': case 'DoubleProperty':
|
|
337
|
+
case 'BoolProperty': case 'ByteProperty':
|
|
338
|
+
return j;
|
|
339
|
+
case 'Int64Property': case 'UInt64Property':
|
|
340
|
+
return String(j);
|
|
341
|
+
case 'StrProperty':
|
|
342
|
+
return j;
|
|
343
|
+
case 'NameProperty': case 'EnumProperty':
|
|
344
|
+
return nameFromJSON(j);
|
|
345
|
+
case 'TextProperty':
|
|
346
|
+
return fTextFromJSON(j);
|
|
347
|
+
case 'ObjectProperty': case 'ClassProperty':
|
|
348
|
+
case 'WeakObjectProperty': case 'LazyObjectProperty':
|
|
349
|
+
case 'WSObjectProperty':
|
|
350
|
+
return objectRefFromJSON(j);
|
|
351
|
+
case 'SoftObjectProperty': case 'SoftClassProperty':
|
|
352
|
+
return new SoftObjectRef({ assetPath: j.assetPath, subPath: j.subPath });
|
|
353
|
+
default:
|
|
354
|
+
throw new Error(`arrayElementFromJSON: unsupported innerType ${innerType}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function setValueToJSON(v, tag) {
|
|
359
|
+
return {
|
|
360
|
+
removed: v.removed.map(e => setElementToJSON(e, tag.innerType.value)),
|
|
361
|
+
elements: v.elements.map(e => setElementToJSON(e, tag.innerType.value)),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
function setValueFromJSON(j, tag) {
|
|
365
|
+
return new SetValue({
|
|
366
|
+
removed: j.removed.map(e => setElementFromJSON(e, tag.innerType.value)),
|
|
367
|
+
elements: j.elements.map(e => setElementFromJSON(e, tag.innerType.value)),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
// Set<StructProperty> elements are raw FGuid values (per readSetElement);
|
|
371
|
+
// other inner types share the array-element shape.
|
|
372
|
+
function setElementToJSON(e, innerType) {
|
|
373
|
+
if (innerType === 'StructProperty') return e; // Guid string
|
|
374
|
+
return arrayElementToJSON(e, innerType, null);
|
|
375
|
+
}
|
|
376
|
+
function setElementFromJSON(j, innerType) {
|
|
377
|
+
if (innerType === 'StructProperty') return j; // Guid string
|
|
378
|
+
return arrayElementFromJSON(j, innerType, null);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function mapValueToJSON(v, tag) {
|
|
382
|
+
const keyType = tag.innerType.value;
|
|
383
|
+
const valType = tag.valueType.value;
|
|
384
|
+
return {
|
|
385
|
+
removed: v.removed.map(k => mapElementToJSON(k, keyType, /*isKey=*/true)),
|
|
386
|
+
entries: v.entries.map(e => ({
|
|
387
|
+
key: mapElementToJSON(e.key, keyType, true),
|
|
388
|
+
value: mapElementToJSON(e.value, valType, false),
|
|
389
|
+
})),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function mapValueFromJSON(j, tag) {
|
|
393
|
+
const keyType = tag.innerType.value;
|
|
394
|
+
const valType = tag.valueType.value;
|
|
395
|
+
return new MapValue({
|
|
396
|
+
removed: j.removed.map(k => mapElementFromJSON(k, keyType, true)),
|
|
397
|
+
entries: j.entries.map(e => ({
|
|
398
|
+
key: mapElementFromJSON(e.key, keyType, true),
|
|
399
|
+
value: mapElementFromJSON(e.value, valType, false),
|
|
400
|
+
})),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
// Map<StructProperty,_> keys are always 16-byte FGuids (string). Map values
|
|
404
|
+
// with StructProperty type are either a StructValue (propStream form) OR a
|
|
405
|
+
// Guid string; we distinguish on JSON shape (object with form=propStream vs
|
|
406
|
+
// bare string).
|
|
407
|
+
function mapElementToJSON(v, type, isKey) {
|
|
408
|
+
if (type === 'StructProperty') {
|
|
409
|
+
if (isKey) return v; // Guid string
|
|
410
|
+
if (v instanceof StructValue) return structValueToJSON(v);
|
|
411
|
+
return v; // Guid string
|
|
412
|
+
}
|
|
413
|
+
return arrayElementToJSON(v, type, null);
|
|
414
|
+
}
|
|
415
|
+
function mapElementFromJSON(j, type, isKey) {
|
|
416
|
+
if (type === 'StructProperty') {
|
|
417
|
+
if (isKey) return j;
|
|
418
|
+
if (j && typeof j === 'object' && j.form) return structValueFromJSON(j, '(map value)');
|
|
419
|
+
return j;
|
|
420
|
+
}
|
|
421
|
+
return arrayElementFromJSON(j, type, null);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── FText ───────────────────────────────────────────────────────────────────
|
|
425
|
+
function fTextToJSON(v) {
|
|
426
|
+
if (!(v instanceof FTextValue)) throw new Error(`fTextToJSON: expected FTextValue, got ${v?.constructor?.name}`);
|
|
427
|
+
const j = { flags: v.flags, historyType: v.historyType };
|
|
428
|
+
if (v.historyType === -1) {
|
|
429
|
+
if (v.displayString != null) {
|
|
430
|
+
j.displayString = v.displayString;
|
|
431
|
+
if (v._displayStringIsNull) j.displayStringIsNull = true;
|
|
432
|
+
} else {
|
|
433
|
+
j.displayString = null;
|
|
434
|
+
}
|
|
435
|
+
} else if (v.historyType === 0) {
|
|
436
|
+
j.namespace = v.namespace; if (v._namespaceIsNull) j.namespaceIsNull = true;
|
|
437
|
+
j.key = v.key; if (v._keyIsNull) j.keyIsNull = true;
|
|
438
|
+
if (v.sourceString != null) {
|
|
439
|
+
j.sourceString = v.sourceString;
|
|
440
|
+
if (v._sourceStringIsNull) j.sourceStringIsNull = true;
|
|
441
|
+
} else {
|
|
442
|
+
j.sourceString = null;
|
|
443
|
+
}
|
|
444
|
+
} else if (v.historyType === 1) {
|
|
445
|
+
j.sourceFmt = fTextToJSON(v.sourceFmt);
|
|
446
|
+
j.arguments = v.arguments.map(a => fTextNamedArgToJSON(a));
|
|
447
|
+
} else if (v.historyType === 2) {
|
|
448
|
+
j.sourceFmt = fTextToJSON(v.sourceFmt);
|
|
449
|
+
j.arguments = v.arguments.map(a => fTextArgToJSON(a));
|
|
450
|
+
} else if (v.historyType === 4) {
|
|
451
|
+
j.sourceValue = fTextArgToJSON(v.sourceValue);
|
|
452
|
+
j.formatOptions = v.formatOptions;
|
|
453
|
+
if (v.culture != null) {
|
|
454
|
+
j.culture = v.culture;
|
|
455
|
+
if (v._cultureIsNull) j.cultureIsNull = true;
|
|
456
|
+
} else {
|
|
457
|
+
j.culture = null;
|
|
458
|
+
}
|
|
459
|
+
} else {
|
|
460
|
+
// Unknown historyType: codec stored remaining bytes verbatim in _raw.
|
|
461
|
+
j._raw = v._raw ? b64encode(v._raw) : null;
|
|
462
|
+
}
|
|
463
|
+
return j;
|
|
464
|
+
}
|
|
465
|
+
function fTextFromJSON(j) {
|
|
466
|
+
const ht = j.historyType;
|
|
467
|
+
if (ht === -1) {
|
|
468
|
+
return new FTextValue({
|
|
469
|
+
flags: j.flags, historyType: -1,
|
|
470
|
+
displayString: j.displayString ?? null,
|
|
471
|
+
displayStringIsNull: !!j.displayStringIsNull,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
if (ht === 0) {
|
|
475
|
+
return new FTextValue({
|
|
476
|
+
flags: j.flags, historyType: 0,
|
|
477
|
+
namespace: j.namespace ?? '', namespaceIsNull: !!j.namespaceIsNull,
|
|
478
|
+
key: j.key ?? '', keyIsNull: !!j.keyIsNull,
|
|
479
|
+
sourceString: j.sourceString ?? null, sourceStringIsNull: !!j.sourceStringIsNull,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
if (ht === 1) {
|
|
483
|
+
return new FTextValue({
|
|
484
|
+
flags: j.flags, historyType: 1,
|
|
485
|
+
sourceFmt: fTextFromJSON(j.sourceFmt),
|
|
486
|
+
arguments: j.arguments.map(a => fTextNamedArgFromJSON(a)),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
if (ht === 2) {
|
|
490
|
+
return new FTextValue({
|
|
491
|
+
flags: j.flags, historyType: 2,
|
|
492
|
+
sourceFmt: fTextFromJSON(j.sourceFmt),
|
|
493
|
+
arguments: j.arguments.map(a => fTextArgFromJSON(a)),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
if (ht === 4) {
|
|
497
|
+
return new FTextValue({
|
|
498
|
+
flags: j.flags, historyType: 4,
|
|
499
|
+
sourceValue: fTextArgFromJSON(j.sourceValue),
|
|
500
|
+
formatOptions: j.formatOptions ?? null,
|
|
501
|
+
culture: j.culture ?? null,
|
|
502
|
+
cultureIsNull: !!j.cultureIsNull,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
return new FTextValue({
|
|
506
|
+
flags: j.flags, historyType: ht,
|
|
507
|
+
_raw: j._raw ? b64decode(j._raw) : null,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
function fTextArgToJSON(a) {
|
|
511
|
+
if (a.type === 4) return { type: 4, value: fTextToJSON(a.value) };
|
|
512
|
+
return { type: a.type, value: a.value };
|
|
513
|
+
}
|
|
514
|
+
function fTextArgFromJSON(a) {
|
|
515
|
+
if (a.type === 4) return { type: 4, value: fTextFromJSON(a.value) };
|
|
516
|
+
return { type: a.type, value: a.value };
|
|
517
|
+
}
|
|
518
|
+
// Named-format arg: same as positional but with a key FString prefix.
|
|
519
|
+
function fTextNamedArgToJSON(a) {
|
|
520
|
+
const j = { key: a.key };
|
|
521
|
+
if (a.keyIsNull) j.keyIsNull = true;
|
|
522
|
+
j.type = a.type;
|
|
523
|
+
j.value = a.type === 4 ? fTextToJSON(a.value) : a.value;
|
|
524
|
+
return j;
|
|
525
|
+
}
|
|
526
|
+
function fTextNamedArgFromJSON(a) {
|
|
527
|
+
return {
|
|
528
|
+
key: a.key,
|
|
529
|
+
keyIsNull: !!a.keyIsNull,
|
|
530
|
+
type: a.type,
|
|
531
|
+
value: a.type === 4 ? fTextFromJSON(a.value) : a.value,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ── Property ────────────────────────────────────────────────────────────────
|
|
536
|
+
function propertyToJSON(p) {
|
|
537
|
+
return { tag: tagToJSON(p.tag), value: valueToJSON(p.tag, p.value) };
|
|
538
|
+
}
|
|
539
|
+
function propertyFromJSON(j) {
|
|
540
|
+
const tag = tagFromJSON(j.tag);
|
|
541
|
+
const value = valueFromJSON(tag, j.value);
|
|
542
|
+
return new Property(tag, value);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ── Top-level UnrealBlob ────────────────────────────────────────────────────
|
|
546
|
+
export function blobToJSON(blob) {
|
|
547
|
+
const j = {
|
|
548
|
+
versionTag: blob.versionTag,
|
|
549
|
+
terminated: !!blob.terminated,
|
|
550
|
+
properties: blob.properties.map(propertyToJSON),
|
|
551
|
+
};
|
|
552
|
+
if (blob.bodyTrailing && blob.bodyTrailing.length > 0) {
|
|
553
|
+
j.bodyTrailing = b64encode(blob.bodyTrailing);
|
|
554
|
+
}
|
|
555
|
+
return j;
|
|
556
|
+
}
|
|
557
|
+
export function jsonToBlob(j) {
|
|
558
|
+
const blob = new UnrealBlob({
|
|
559
|
+
versionTag: j.versionTag,
|
|
560
|
+
properties: j.properties.map(propertyFromJSON),
|
|
561
|
+
terminated: !!j.terminated,
|
|
562
|
+
bodyTrailing: j.bodyTrailing ? b64decode(j.bodyTrailing) : null,
|
|
563
|
+
});
|
|
564
|
+
blob._dirty = true; // force re-encode path on serialize()
|
|
565
|
+
blob._recomputeSizes = true; // JSON is the editing path; tag.size must
|
|
566
|
+
// be rewritten from actual value bytes.
|
|
567
|
+
return blob;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── -0 / NaN / Infinity preservation ────────────────────────────────────────
|
|
571
|
+
// JSON drops sign on negative zero (`JSON.stringify(-0) === "0"`) and turns
|
|
572
|
+
// non-finite numbers into `null`. UE serializes them verbatim, so any of these
|
|
573
|
+
// in the data round-trips as a different bit pattern unless we intervene.
|
|
574
|
+
// We substitute a sentinel string at stringify time and reverse it at parse
|
|
575
|
+
// time, via JSON.stringify's replacer / JSON.parse's reviver hooks.
|
|
576
|
+
//
|
|
577
|
+
// The sentinel surface is space-bounded to make accidental collision with a
|
|
578
|
+
// real string vanishingly unlikely. If you ever see a string field containing
|
|
579
|
+
// these exact literals in your data, audit this list first.
|
|
580
|
+
const NEG_ZERO_SENTINEL = ' __wscodec_neg_zero__ ';
|
|
581
|
+
const POS_INF_SENTINEL = ' __wscodec_pos_inf__ ';
|
|
582
|
+
const NEG_INF_SENTINEL = ' __wscodec_neg_inf__ ';
|
|
583
|
+
const NAN_SENTINEL = ' __wscodec_nan__ ';
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* JSON.stringify replacer that substitutes sentinels for -0 / Infinity / NaN.
|
|
587
|
+
* Pass this to any JSON.stringify call that may contain wscodec-derived numbers
|
|
588
|
+
* (including a blob nested inside a larger envelope). Use jsonReviver on the
|
|
589
|
+
* matching JSON.parse to invert.
|
|
590
|
+
*/
|
|
591
|
+
export function jsonReplacer(_key, value) {
|
|
592
|
+
if (typeof value !== 'number') return value;
|
|
593
|
+
if (Object.is(value, -0)) return NEG_ZERO_SENTINEL;
|
|
594
|
+
if (value === Infinity) return POS_INF_SENTINEL;
|
|
595
|
+
if (value === -Infinity) return NEG_INF_SENTINEL;
|
|
596
|
+
if (Number.isNaN(value)) return NAN_SENTINEL;
|
|
597
|
+
return value;
|
|
598
|
+
}
|
|
599
|
+
/** Inverse of jsonReplacer. Pass to JSON.parse(text, jsonReviver). */
|
|
600
|
+
export function jsonReviver(_key, value) {
|
|
601
|
+
if (typeof value !== 'string') return value;
|
|
602
|
+
switch (value) {
|
|
603
|
+
case NEG_ZERO_SENTINEL: return -0;
|
|
604
|
+
case POS_INF_SENTINEL: return Infinity;
|
|
605
|
+
case NEG_INF_SENTINEL: return -Infinity;
|
|
606
|
+
case NAN_SENTINEL: return NaN;
|
|
607
|
+
default: return value;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Stringify a blob with proper handling of -0 / Infinity / NaN. Use this instead of bare JSON.stringify. */
|
|
612
|
+
export function blobToJSONString(blob, indent) { return JSON.stringify(blobToJSON(blob), jsonReplacer, indent); }
|
|
613
|
+
/** Parse + reconstruct a blob with proper handling of -0 / Infinity / NaN. Use this instead of bare JSON.parse. */
|
|
614
|
+
export function jsonStringToBlob(str) { return jsonToBlob(JSON.parse(str, jsonReviver)); }
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wscodec",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pure-JS codec for Soulmask actor_data property streams (
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Pure-JS codec for Soulmask actor_data property streams (UE 4.27 FPropertyTag wire format). Zero runtime dependencies. Accepts uncompressed bytes, returns JS objects, and vice versa. Round-trip byte-identical against every actor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./wscodec.mjs",
|
|
7
7
|
"exports": {
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"./primitives": "./primitives.mjs",
|
|
11
11
|
"./structs": "./structs.mjs",
|
|
12
12
|
"./values": "./values.mjs",
|
|
13
|
-
"./properties": "./properties.mjs"
|
|
13
|
+
"./properties": "./properties.mjs",
|
|
14
|
+
"./json": "./json.mjs"
|
|
14
15
|
},
|
|
15
16
|
"files": [
|
|
16
17
|
"wscodec.mjs",
|
|
@@ -19,19 +20,38 @@
|
|
|
19
20
|
"structs.mjs",
|
|
20
21
|
"values.mjs",
|
|
21
22
|
"properties.mjs",
|
|
23
|
+
"json.mjs",
|
|
22
24
|
"README.md"
|
|
23
25
|
],
|
|
24
26
|
"scripts": {
|
|
25
|
-
"test": "node test/test-roundtrip.mjs"
|
|
27
|
+
"test": "node test/test-roundtrip.mjs",
|
|
28
|
+
"test:json": "node test/test-json-full.mjs",
|
|
29
|
+
"test:json-spot": "node test/test-json-roundtrip.mjs",
|
|
30
|
+
"export-db": "node scripts/db-to-json.mjs",
|
|
31
|
+
"import-db": "node scripts/json-to-db.mjs"
|
|
26
32
|
},
|
|
27
33
|
"keywords": [
|
|
28
34
|
"soulmask",
|
|
29
35
|
"unreal",
|
|
30
36
|
"ue4",
|
|
31
37
|
"fpropertytag",
|
|
32
|
-
"codec"
|
|
38
|
+
"codec",
|
|
39
|
+
"world.db",
|
|
40
|
+
"actor_data"
|
|
33
41
|
],
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/auroris/SoulmaskCodec.git"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/auroris/SoulmaskCodec#readme",
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/auroris/SoulmaskCodec/issues"
|
|
49
|
+
},
|
|
50
|
+
"author": "auroris",
|
|
34
51
|
"license": "MIT",
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=20"
|
|
54
|
+
},
|
|
35
55
|
"devDependencies": {
|
|
36
56
|
"better-sqlite3": "^12.10.0",
|
|
37
57
|
"lz4-wasm-nodejs": "^0.9.2"
|