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/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.1.0",
4
- "description": "Pure-JS codec for Soulmask actor_data property streams (UE4.27 FPropertyTag wire format). Zero runtime dependencies accepts uncompressed bytes, returns JS objects, and vice versa. Round-trip byte-identical against every actor.",
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"