wscodec 0.2.0 → 0.3.1

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 CHANGED
@@ -90,11 +90,15 @@ returns an `UnrealBlob` with:
90
90
  | `error` | `string \| null` | populated when structural decode failed |
91
91
  | `_raw` | `Uint8Array` | the input bytes, retained for pass-through serialize |
92
92
  | `_dirty` | `boolean` | set by mutating callers to force re-encode |
93
+ | `_recomputeSizes` | `boolean` | when true, every `tag.size` is rewritten from the actual value byte count on serialize. Set automatically by `jsonToBlob`; see [Editing](#editing) |
93
94
 
94
- `blob.serialize()` returns a `Uint8Array`. When `_dirty` is false it
95
- returns `_raw` verbatim (byte-identical pass-through). When `_dirty`
96
- is true it re-emits the property stream from `properties` via
97
- `writePropertyStream`.
95
+ `blob.serialize(options?)` returns a `Uint8Array`. When `_dirty` is
96
+ false it returns `_raw` verbatim (byte-identical pass-through). When
97
+ `_dirty` is true it re-emits the property stream from `properties` via
98
+ `writePropertyStream`. Pass `{ recomputeSizes: true }` (or set
99
+ `blob._recomputeSizes`) to recompute every `tag.size` from the actual
100
+ encoded value bytes — required after any edit that changes a
101
+ variable-length field.
98
102
 
99
103
  `blob.findProperty(name)` returns the first top-level property whose
100
104
  tag name matches, or `null`. It does NOT traverse into embedded
@@ -114,10 +118,10 @@ JavaScript shape depends on the tag's type:
114
118
  | `StrProperty`, `NameProperty` | string / `FName` |
115
119
  | `StructProperty` | `StructValue`. `.value` is either a plain object for known binary structs (`Vector`, `Quat`, `Transform`, ...), an `FGuid` instance for the `Guid` struct, or a nested property array for unknown structs |
116
120
  | `ArrayProperty`, `SetProperty` | `ArrayValue` / `SetValue` with `.elements` |
117
- | `MapProperty` | `MapValue` with `.entries: [[key, value], ...]` |
121
+ | `MapProperty` | `MapValue` with `.entries: [{ key, value }, ...]` and `.removed: [...]` |
118
122
  | `ObjectProperty`, `ClassProperty`, `Weak*`, `Lazy*`, `WSObjectProperty` | `ObjectRef` (kind + optional path/classPath/embedded stream) |
119
123
  | `SoftObjectProperty`, `SoftClassProperty` | `SoftObjectRef` (`assetPath`, `subPath`) |
120
- | `TextProperty` | `FTextValue` (handles UE4 FText history types -1, 0, 2, 4) |
124
+ | `TextProperty` | `FTextValue` (handles UE4 FText history types -1, 0, 1, 2, 4) |
121
125
  | anything wscodec couldn't structurally decode | `OpaqueValue`. Bytes retained verbatim |
122
126
 
123
127
  Submodule re-exports make the value classes importable directly:
@@ -126,6 +130,8 @@ Submodule re-exports make the value classes importable directly:
126
130
  import { ObjectRef, SoftObjectRef, FTextValue, OpaqueValue, StructValue } from 'wscodec';
127
131
  import { PropertyTag, ArrayValue, SetValue, MapValue } from 'wscodec';
128
132
  import { FName, FGuid } from 'wscodec';
133
+ import { blobToJSON, jsonToBlob, blobToJSONString, jsonStringToBlob,
134
+ jsonReplacer, jsonReviver } from 'wscodec';
129
135
  ```
130
136
 
131
137
  Lower-level helpers (`Cursor`, `Writer`, `readPropertyStream`,
@@ -155,11 +161,77 @@ The helper validates that both `read(cursor)` and `write(writer, value)`
155
161
  are functions. Register before calling `UnrealBlob.decode` on any blob
156
162
  that uses the type.
157
163
 
164
+ ### JSON conversion
165
+
166
+ The object tree round-trips through JSON. This is the recommended path
167
+ for editing: the tree becomes plain JSON, edits are plain JS object
168
+ mutations, and the JSON-to-blob pipeline handles size recomputation,
169
+ sentinel substitution for `-0` / `Infinity` / `NaN`, and base64 for the
170
+ small fraction of bytes that the codec doesn't structurally decode.
171
+
172
+ ```js
173
+ import {
174
+ UnrealBlob,
175
+ blobToJSON, jsonToBlob,
176
+ blobToJSONString, jsonStringToBlob,
177
+ } from 'wscodec';
178
+
179
+ const blob = UnrealBlob.decode(uncompressedBytes);
180
+
181
+ // Object-tree round trip (preserves -0 in memory via Object.is, but a
182
+ // naive JSON.stringify on the result would lose it; see below).
183
+ const obj = blobToJSON(blob);
184
+ const blob2 = jsonToBlob(obj);
185
+
186
+ // String round trip — use this whenever the JSON crosses a stringify
187
+ // boundary (file I/O, sockets, etc.). The sentinels guard non-finite
188
+ // numbers and -0 across the conversion.
189
+ const json = blobToJSONString(blob, 2 /* optional indent */);
190
+ const blob3 = jsonStringToBlob(json);
191
+
192
+ // blob3.serialize() reproduces the input bytes (modulo the wire's
193
+ // optional "inflated tag.size" lies, which jsonToBlob normalizes).
194
+ ```
195
+
196
+ `blobToJSON` produces a plain-object tree with:
197
+
198
+ - `FName` values flattened to bare strings (with metadata-object fallback only when `isUnicode`/`isNull`/`number` aren't defaults)
199
+ - `FGuid` flattened to its canonical 8-4-4-4-12 hex string
200
+ - `Int64Property` / `UInt64Property` / `DateTime` / `Timespan` as decimal strings
201
+ - `StructValue` discriminated by `form: "binary" | "propStream" | "decodeError"`
202
+ - `OpaqueValue` as `{ _opaque: true, bytes: <base64>, reason }`
203
+ - `bodyTrailing` as base64 when present
204
+ - `ArrayValue._perElementTrailings` (the `JianZhuInstYuanXings` per-piece placement cache) as `{ transforms: [[16 floats], …], ids: [u32, …], aux: [[16 floats], …] }` — see [Round-trip guarantees](#round-trip-guarantees) for the NaN-bit-preservation note
205
+
206
+ `jsonToBlob` returns an `UnrealBlob` with `_dirty = true` and
207
+ `_recomputeSizes = true`, so `blob.serialize()` will rewrite every
208
+ `tag.size` from the actual encoded value bytes. That makes the JSON
209
+ pipeline safe for arbitrary edits — including ones that change FString
210
+ lengths, add/remove array elements, or grow nested structs.
211
+
212
+ If you need to build a larger JSON envelope around an `UnrealBlob`
213
+ (e.g., a full db export), use `jsonReplacer` / `jsonReviver` with your
214
+ own `JSON.stringify` / `JSON.parse` calls so the same `-0`/`NaN`/`Infinity`
215
+ sentinels are applied uniformly:
216
+
217
+ ```js
218
+ import { blobToJSON, jsonToBlob, jsonReplacer, jsonReviver } from 'wscodec';
219
+
220
+ const envelope = { actor_serial: 17, blob: blobToJSON(blob), other: '...' };
221
+ const text = JSON.stringify(envelope, jsonReplacer);
222
+ const parsed = JSON.parse(text, jsonReviver);
223
+ const blob2 = jsonToBlob(parsed.blob);
224
+ ```
225
+
226
+ The codec is consumable as a submodule: `import { blobToJSON } from 'wscodec/json';`.
227
+
158
228
  ### Editing
159
229
 
160
- The library does not provide typed mutators. Callers manipulate the
161
- `properties` tree directly, then set `_dirty` on the ROOT blob to
162
- force a re-encode.
230
+ Two paths are supported. For most edits, **go through JSON** ([§
231
+ JSON conversion](#json-conversion)) it handles size recomputation
232
+ and numeric edge cases automatically. For low-level edits that
233
+ change zero-cost fields (numbers, bools, single bytes), you can also
234
+ mutate the object tree directly.
163
235
 
164
236
  ```js
165
237
  import { UnrealBlob, FName } from 'wscodec';
@@ -186,31 +258,38 @@ inventory.value.elements.push(new FName('Item_Wood'));
186
258
  // (5) Remove an element. Just splice it out; don't set null.
187
259
  inventory.value.elements.splice(0, 1);
188
260
 
189
- // Always set _dirty on the ROOT blob (not on nested properties). The
190
- // flag is read by blob.serialize() to decide pass-through vs re-encode.
261
+ // Tell the encoder to (a) re-emit from properties at all, and (b)
262
+ // recompute every tag.size from the actual encoded value bytes. The
263
+ // recompute is REQUIRED whenever any edit could change a value's
264
+ // encoded byte count (FStrings, FText, array length, nested structs).
265
+ // It's free when nothing changed in size, so just turning it on by
266
+ // default for any direct edit is the safest path.
191
267
  blob._dirty = true;
268
+ blob._recomputeSizes = true;
192
269
 
193
270
  const updatedBytes = blob.serialize(); // re-emits from properties
194
271
  ```
195
272
 
196
273
  Gotchas:
197
274
 
198
- - `_dirty` lives on the root `UnrealBlob`, not on nested `Property` /
199
- `ArrayValue` / `StructValue` objects. Mutating a deep value without
200
- setting `blob._dirty = true` returns the original `_raw` bytes
201
- unchanged.
275
+ - `_dirty` and `_recomputeSizes` live on the root `UnrealBlob`, not on
276
+ nested `Property` / `ArrayValue` / `StructValue` objects. Mutating a
277
+ deep value without setting `blob._dirty = true` returns the original
278
+ `_raw` bytes unchanged.
202
279
  - `BoolProperty` values live in the `tag` (`tag.boolVal`), not in
203
280
  `property.value`. To flip a bool, edit `prop.tag.boolVal`.
204
281
  - Removing a property means splicing it out of `blob.properties`, not
205
282
  setting `property.value = null`.
206
- - If you change a value's encoded SIZE (e.g. extending an FString),
207
- the property's `tag.size` is recomputed on write, but any property
208
- that previously carried a `_sizeMismatch` annotation refuses to
209
- re-emit. Such properties are extremely rare in healthy world.db
210
- files and are reported by `npm test`.
283
+ - **Anything that changes encoded byte count requires `_recomputeSizes = true`.**
284
+ Lengthening an FString, adding an array element, swapping a known-
285
+ binary struct for a propStream any of these without recompute leaves
286
+ every dependent `tag.size` stale, and Soulmask's reader will walk off
287
+ the end of the value into the next property's bytes. Symptom: edited
288
+ blob loads but with reset/missing fields downstream of the edit.
289
+ - The JSON pipeline (`jsonToBlob`, `jsonStringToBlob`) sets this for you.
211
290
  - `serialize()` throws if `_dirty` is true AND `error` is set:
212
291
  re-emitting from an empty properties array would produce a malformed
213
- stream. Leave `_dirty=false` to pass through `_raw` verbatim, or
292
+ stream. Leave `_dirty = false` to pass through `_raw` verbatim, or
214
293
  clear `.error` first if you've replaced `.properties` manually.
215
294
  - 64-bit integer values (`Int64Property`, `UInt64Property`,
216
295
  `DateTime`, `Timespan`) round-trip as decimal strings. If you
@@ -218,9 +297,12 @@ Gotchas:
218
297
  (`|v| <= Number.MAX_SAFE_INTEGER`); otherwise the writer throws
219
298
  rather than silently lose precision.
220
299
 
221
- `serialize()` for a dirty blob is byte-identical to a fresh
222
- `decode + serialize` cycle on its output, verified on every row of
223
- the tested `world.db`.
300
+ `serialize()` is byte-identical to a fresh `decode + serialize` cycle
301
+ on its output, verified on every row of the tested `world.db`. With
302
+ recompute enabled the encoder may produce shorter bytes than the
303
+ original when the wire's `tag.size` over-stated the actual value byte
304
+ count (some Soulmask Maps do this); the bytes still decode to the same
305
+ object tree, and tested in-game loads accept both forms.
224
306
 
225
307
  ## LZ4 integration
226
308
 
@@ -273,9 +355,11 @@ bytes if you need that).
273
355
 
274
356
  For every row in the tested `world.db`:
275
357
 
276
- - `UnrealBlob.decode(inner)` succeeds without `error` set.
277
- - `blob.serialize()` with `_dirty = false` returns the input bytes byte-identical.
278
- - `blob.serialize()` with `_dirty = true` re-emits from `properties` and is byte-identical to the input.
358
+ - `UnrealBlob.decode(inner)` succeeds without `error` set and produces zero `OpaqueValue` entries (every property type decodes structurally).
359
+ - `blob.serialize()` with `_dirty = false` returns the input bytes byte-identical (pass-through).
360
+ - `blob.serialize()` with `_dirty = true` and `_recomputeSizes = false` re-emits from `properties` and is byte-identical to the input.
361
+ - `blob.serialize()` with `_dirty = true` and `_recomputeSizes = true` (the JSON-pipeline default) re-emits with `tag.size` rewritten from the actual value byte count. Decoding the result yields the same property tree as the input; the wire bytes may differ where the input's stored sizes over-stated the actual value byte count (some Soulmask Maps do this).
362
+ - The same `UnrealBlob` going through `blobToJSON` → `jsonToBlob` → `serialize` yields bytes that decode to the same tree as the input.
279
363
 
280
364
  Coverage includes every known Soulmask wire-format quirk:
281
365
 
@@ -293,11 +377,22 @@ Coverage includes every known Soulmask wire-format quirk:
293
377
  `JianZhuInstYuanXings` arrays (`YuanXing` = "prototype", so
294
378
  "building-zone yuan-xing" is the list of building-piece prototypes
295
379
  inside a building zone) interleave a fixed-shape binary block after
296
- each ObjectProperty element: an 8-byte header + three stride/count
297
- sections (per-piece world transforms, ids, and aux data).
380
+ each ObjectProperty element. The codec decodes the block structurally
381
+ into `{ transforms: [[16 floats], …], ids: [u32, …], aux: [[16 floats], …] }`.
382
+ The transforms are row-major UE `FMatrix`-style 4×4 matrices; the
383
+ translation lives at indices 12, 13, 14. Non-canonical NaN bit
384
+ patterns (observed `0xFFFFFFFF` as a sentinel in aux data) are
385
+ preserved via `{ $nanBits: u32 }` wrappers, because JS `Number`
386
+ collapses all NaNs to `0x7FC00000`. In-game testing confirms Soulmask
387
+ renders building pieces from their `RelativeTransform` property (a
388
+ `StructProperty<Transform>` in `MapInstJianZhuDataList`); the
389
+ per-element trailings carry the same data as a render-side cache, so
390
+ edits that move pieces must update both.
298
391
  - **ArrayProperty<TextProperty> with mixed FText history types.**
299
392
  Elements use history types -1 (culture-invariant), 0 (localized),
300
- 2 (ordered format), and 4 (`FTextHistory_AsNumber`). History type 4
393
+ 1 (`FTextHistory_NamedFormat` a format pattern plus a
394
+ `TMap<FString, FFormatArgumentValue>` of named arguments), 2
395
+ (ordered format), and 4 (`FTextHistory_AsNumber`). History type 4
301
396
  embeds a legacy UE3-style `FNumberFormattingOptions` whose boolean
302
397
  fields are 4 bytes wide rather than the modern 1 byte; the codec
303
398
  emits this correctly.
@@ -311,23 +406,66 @@ Coverage includes every known Soulmask wire-format quirk:
311
406
  actual 636422); pair shapes are detected by peeking at the next
312
407
  bytes rather than trusting the declared size.
313
408
 
314
- ## Running the test
409
+ ## Running the tests
315
410
 
316
411
  ```sh
317
412
  git clone https://github.com/auroris/SoulmaskCodec.git
318
413
  cd SoulmaskCodec
319
414
  npm install
415
+
416
+ # Byte-identical roundtrip across every row of a world.db.
320
417
  npm test # looks for world.db two dirs up by default
321
- # or
322
418
  node test/test-roundtrip.mjs /path/to/world.db
419
+
420
+ # JSON-pipeline roundtrip. Encodes both sides with recomputeSizes=true
421
+ # and compares; verifies blobToJSON ↔ jsonToBlob is lossless.
422
+ npm run test:json -- /path/to/world.db
423
+ npm run test:json-spot -- /path/to/world.db # spot-check on rows that exercise each code path
323
424
  ```
324
425
 
325
426
  Test deps: `lz4-wasm-nodejs` (LZ4 inside the test) and
326
427
  `better-sqlite3` (reads the `world.db` SQLite file). Both are picked
327
428
  up via npm module resolution; if `better-sqlite3` isn't installed at
328
- the package root the test will surface that with a clear error. See
429
+ the package root the tests will surface that with a clear error. See
329
430
  the Setup section above for the build-tools prerequisite on Windows.
330
431
 
432
+ ## Bundled scripts
433
+
434
+ The repo also ships full db ↔ JSON utilities under [scripts/](scripts/).
435
+ These are NOT shipped in the npm package (the codec stays zero-dep); they
436
+ live in the repo as reference workflows.
437
+
438
+ ```sh
439
+ # Dump every row of a world.db (LZ4-decompressing actor_data and decoding
440
+ # through wscodec where possible) to a single JSON file.
441
+ npm run export-db -- /path/to/world.db world.json
442
+
443
+ # Inverse: rebuild a SQLite db from the JSON export. The npm script
444
+ # already runs node with --max-old-space-size=4096 (necessary for
445
+ # multi-hundred-MB exports).
446
+ npm run import-db -- world.json /path/to/new.db
447
+
448
+ # Diff two world.db files at the uncompressed level (tolerates LZ4 re-compression).
449
+ node scripts/diff-dbs.mjs a.db b.db
450
+
451
+ # Search every decoded blob for a substring (custom names, UIDs, asset paths).
452
+ node scripts/find-string.mjs /path/to/world.db "Claude's Chest"
453
+
454
+ # Pretty-print one actor's full property tree.
455
+ node scripts/dump-actor.mjs /path/to/world.db <actor_serial> [out.json]
456
+
457
+ # Merge every workbench access log, NPC work log, and clan log into a single
458
+ # timestamp-sorted .log file. .NET ticks → ISO-8601 UTC; FText placeholders
459
+ # substituted into their NamedFormat / OrderedFormat templates.
460
+ npm run dump-logs -- /path/to/world.db world.log
461
+ ```
462
+
463
+ The export/import pair has been validated end-to-end against Soulmask
464
+ itself: a full db → JSON → db round-trip produces a save that the game
465
+ loads cleanly. See [docs/helpers-handoff.md](docs/helpers-handoff.md)
466
+ for notes on building a higher-level save-edit helper library on top of
467
+ the codec.
468
+
331
469
  ## License
332
470
 
333
471
  MIT.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "wscodec",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
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",
@@ -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,10 +20,16 @@
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 --max-old-space-size=4096 scripts/json-to-db.mjs",
32
+ "dump-logs": "node scripts/dump-logs.mjs"
26
33
  },
27
34
  "keywords": [
28
35
  "soulmask",
package/properties.mjs CHANGED
@@ -25,6 +25,7 @@
25
25
  * elements, embedded object data) do NOT.
26
26
  */
27
27
 
28
+ import { Writer } from './io.mjs';
28
29
  import { FName, FGuid } from './primitives.mjs';
29
30
  import { StructValue, STRUCT_HANDLERS } from './structs.mjs';
30
31
  import { ObjectRef, SoftObjectRef, FTextValue, OpaqueValue } from './values.mjs';
@@ -291,6 +292,35 @@ function readFText(cursor, sizeHint) {
291
292
  sourceString: ssFS.value, sourceStringIsNull: ssFS.isNull,
292
293
  });
293
294
  }
295
+ if (historyType === 1) {
296
+ // NamedFormat (FTextHistory_NamedFormat): a format-pattern FText plus a
297
+ // TMap<FString, FFormatArgumentValue>. Wire shape:
298
+ // FText SourceFmt
299
+ // int32 NumArguments
300
+ // for each: FString Key, int8 ContentType, value-by-type
301
+ // ContentType codes match historyType=2 (0=Int64 1=UInt64 2=Float32
302
+ // 3=Float64 4=Text 5=Gender). Soulmask uses this for named placeholders
303
+ // like "X={X} Y={Y} Z={Z}" in ParamArrayTxt elements of JingYingRiZhiList.
304
+ const sourceFmt = readFText(cursor, Infinity);
305
+ const numArgs = cursor.readInt32();
306
+ const args = [];
307
+ for (let i = 0; i < numArgs; i++) {
308
+ const keyFS = cursor.readFString();
309
+ const type = cursor.readInt8();
310
+ let value;
311
+ switch (type) {
312
+ case 0: value = cursor.readInt64().toString(); break;
313
+ case 1: value = cursor.readUint64().toString(); break;
314
+ case 2: value = cursor.readFloat32(); break;
315
+ case 3: value = cursor.readFloat64(); break;
316
+ case 4: value = readFText(cursor, Infinity); break;
317
+ case 5: value = cursor.readInt8(); break;
318
+ default: throw new Error(`readFText: unknown NamedFormat ContentType ${type}`);
319
+ }
320
+ args.push({ key: keyFS.value, keyIsNull: keyFS.isNull, type, value });
321
+ }
322
+ return new FTextValue({ flags, historyType: 1, sourceFmt, arguments: args });
323
+ }
294
324
  if (historyType === 2) {
295
325
  // ArgumentFormat: a format-pattern FText plus an ordered argument list.
296
326
  // Each argument is a ContentType byte (EFormatArgumentType) followed by
@@ -393,6 +423,22 @@ function writeFText(writer, value) {
393
423
  writer.writeFString(value.namespace ?? '', null, value._namespaceIsNull);
394
424
  writer.writeFString(value.key ?? '', null, value._keyIsNull);
395
425
  writer.writeFString(value.sourceString ?? '', null, value._sourceStringIsNull);
426
+ } else if (value.historyType === 1) {
427
+ writeFText(writer, value.sourceFmt);
428
+ writer.writeInt32(value.arguments.length);
429
+ for (const arg of value.arguments) {
430
+ writer.writeFString(arg.key ?? '', null, arg.keyIsNull);
431
+ writer.writeInt8(arg.type);
432
+ switch (arg.type) {
433
+ case 0: writer.writeInt64(arg.value); break;
434
+ case 1: writer.writeUint64(arg.value); break;
435
+ case 2: writer.writeFloat32(arg.value); break;
436
+ case 3: writer.writeFloat64(arg.value); break;
437
+ case 4: writeFText(writer, arg.value); break;
438
+ case 5: writer.writeInt8(arg.value); break;
439
+ default: throw new Error(`writeFText: unknown NamedFormat ContentType ${arg.type}`);
440
+ }
441
+ }
396
442
  } else if (value.historyType === 2) {
397
443
  writeFText(writer, value.sourceFmt);
398
444
  writer.writeInt32(value.arguments.length);
@@ -593,19 +639,23 @@ function readArrayValue(cursor, tag, sizeHint) {
593
639
  * yuan-xing element is followed by a fixed-shape block:
594
640
  *
595
641
  * [8 bytes zero header]
596
- * [u32 stride=64] [u32 count] [count×64 bytes] world 4×4 transforms (per placed piece)
597
- * [u32 stride= 4] [u32 count] [count× 4 bytes] per-piece u32 ids
598
- * [u32 stride=64] [u32 count] [count×64 bytes] per-piece aux (bbox + scale-ish floats)
642
+ * [u32 stride=64] [u32 count] [count × 16 float32] world 4×4 transforms (per placed piece)
643
+ * [u32 stride= 4] [u32 count] [count × u32] per-piece u32 ids
644
+ * [u32 stride=64] [u32 count] [count × 16 float32] per-piece aux (bbox + scale-ish floats)
645
+ *
646
+ * Returns { transforms, ids, aux } on success: arrays of decoded values
647
+ * (rather than raw byte slices). Returns null (cursor rolled back) when the
648
+ * bytes don't match. Non-JianZhuInstYuanXings ObjectProperty arrays have no
649
+ * such block, so peeking-and-rolling-back keeps them unaffected.
599
650
  *
600
- * Returns { header, sections } on success. Returns null (cursor rolled back)
601
- * when the bytes don't match. Non-JianZhuInstYuanXings ObjectProperty arrays
602
- * have no such block, so peeking-and-rolling-back keeps them unaffected.
651
+ * The 8-byte zero header and the fixed strides (64/4/64) are synthesized on
652
+ * write no field in the returned object carries them.
603
653
  *
604
654
  * Verified by in-game experiment 2026-05-18: numElements counts UNIQUE
605
- * prototypes (foundation, wall, door frame, …); section 0/1 counts are the
606
- * placed-piece count for that prototype; section 2 count is typically that
607
- * count or one greater. The earlier "single trailing block after all
608
- * elements" model was wrong; these blocks are interleaved per element.
655
+ * prototypes (foundation, wall, door frame, …); transforms.length is the
656
+ * placed-piece count for that prototype; aux.length is typically the same
657
+ * or one greater. The earlier "single trailing block after all elements"
658
+ * model was wrong; these blocks are interleaved per element.
609
659
  */
610
660
  function tryReadObjectArrayPerElementBlock(cursor, endOff) {
611
661
  const start = cursor.pos();
@@ -618,8 +668,7 @@ function tryReadObjectArrayPerElementBlock(cursor, endOff) {
618
668
  if (cursor.dv.getUint32(start + 8, true) !== 64) return null;
619
669
 
620
670
  try {
621
- cursor.skip(8);
622
- const header = cursor.bytes.subarray(start, start + 8).slice();
671
+ cursor.skip(8); // zero header
623
672
  const sections = [];
624
673
  const expected = [64, 4, 64];
625
674
  for (let i = 0; i < 3; i++) {
@@ -630,9 +679,26 @@ function tryReadObjectArrayPerElementBlock(cursor, endOff) {
630
679
  if (count > 1_000_000) throw new Error(`implausible count ${count}`);
631
680
  const dataBytes = stride * count;
632
681
  if (cursor.pos() + dataBytes > endOff) throw new Error(`section ${i} data overruns budget`);
633
- sections.push({ stride, count, data: cursor.readBytes(dataBytes).slice() });
682
+ if (i === 1) {
683
+ const ids = new Array(count);
684
+ for (let k = 0; k < count; k++) ids[k] = cursor.readUint32();
685
+ sections.push(ids);
686
+ } else {
687
+ // 16 float32 per element (4×4 matrix, row-major in UE's FMatrix layout).
688
+ // Non-canonical NaN bit patterns are common in Soulmask aux data
689
+ // (observed 0xFFFFFFFF as "invalid" sentinel) and would collapse to
690
+ // canonical 0x7FC00000 if round-tripped via a JS Number, so we
691
+ // capture them as { $nanBits } wrappers instead.
692
+ const arr = new Array(count);
693
+ for (let k = 0; k < count; k++) {
694
+ const m = new Array(16);
695
+ for (let j = 0; j < 16; j++) m[j] = readFloat32PreservingNan(cursor);
696
+ arr[k] = m;
697
+ }
698
+ sections.push(arr);
699
+ }
634
700
  }
635
- return { header, sections };
701
+ return { transforms: sections[0], ids: sections[1], aux: sections[2] };
636
702
  } catch {
637
703
  cursor.seek(start);
638
704
  return null;
@@ -640,11 +706,45 @@ function tryReadObjectArrayPerElementBlock(cursor, endOff) {
640
706
  }
641
707
 
642
708
  function writeObjectArrayPerElementBlock(writer, block) {
643
- writer.writeBytes(block.header);
644
- for (const s of block.sections) {
645
- writer.writeUint32(s.stride);
646
- writer.writeUint32(s.count);
647
- writer.writeBytes(s.data);
709
+ // 8-byte zero header.
710
+ writer.writeUint32(0);
711
+ writer.writeUint32(0);
712
+ // Section 0: transforms (count × 16 float32).
713
+ writer.writeUint32(64);
714
+ writer.writeUint32(block.transforms.length);
715
+ for (const m of block.transforms) for (const f of m) writeFloat32PreservingNan(writer, f);
716
+ // Section 1: ids (count × u32).
717
+ writer.writeUint32(4);
718
+ writer.writeUint32(block.ids.length);
719
+ for (const id of block.ids) writer.writeUint32(id);
720
+ // Section 2: aux (count × 16 float32).
721
+ writer.writeUint32(64);
722
+ writer.writeUint32(block.aux.length);
723
+ for (const m of block.aux) for (const f of m) writeFloat32PreservingNan(writer, f);
724
+ }
725
+
726
+ // Float32 helpers that preserve non-canonical NaN bit patterns. JavaScript's
727
+ // Number type collapses all NaN bit patterns into the canonical 0x7FC00000
728
+ // on any DataView.setFloat32 call, so a wire NaN like 0xFFFFFFFF (observed in
729
+ // Soulmask JianZhuInstYuanXings aux data) would not round-trip if we used
730
+ // readFloat32 / writeFloat32 directly. We instead carry NaN-bit-patterns as
731
+ // { $nanBits: u32 } wrapper objects.
732
+ function readFloat32PreservingNan(cursor) {
733
+ const bits = cursor.dv.getUint32(cursor.offset, true);
734
+ // NaN: exponent all 1s AND mantissa non-zero. The single "canonical NaN"
735
+ // (0x7FC00000) is allowed to round-trip through Number, but every other
736
+ // NaN bit pattern needs the wrapper.
737
+ if ((bits & 0x7F800000) === 0x7F800000 && (bits & 0x007FFFFF) !== 0 && bits !== 0x7FC00000) {
738
+ cursor.offset += 4;
739
+ return { $nanBits: bits >>> 0 };
740
+ }
741
+ return cursor.readFloat32();
742
+ }
743
+ function writeFloat32PreservingNan(writer, f) {
744
+ if (f !== null && typeof f === 'object' && '$nanBits' in f) {
745
+ writer.writeUint32(f.$nanBits >>> 0);
746
+ } else {
747
+ writer.writeFloat32(f);
648
748
  }
649
749
  }
650
750
 
@@ -656,14 +756,33 @@ function isObjectInnerType(t) {
656
756
 
657
757
  function writeArrayValue(writer, tag, value) {
658
758
  const innerType = tag.innerType.value;
759
+ const recompute = !!writer._wsRecomputeSizes;
659
760
  writer.writeInt32(value.elements.length);
660
761
  if (innerType === 'StructProperty') {
661
- value._arrayInnerTag.write(writer);
662
762
  const structName = value._arrayInnerTag.structName.value;
663
763
  const handler = STRUCT_HANDLERS[structName];
664
- for (const e of value.elements) {
665
- if (handler) handler.write(writer, e.value);
666
- else writeNestedPropertyStream(writer, e.value);
764
+ // On recompute, set innerTag.size to the encoded size of element 0.
765
+ // All array-of-struct elements share the same innerTag, and stock UE
766
+ // uses one size value as a hint for the whole element shape. Self-
767
+ // delimiting struct streams (None terminator) make the exact value less
768
+ // critical for reading, but writing the truthful size keeps validators
769
+ // happy.
770
+ let origInnerSize = value._arrayInnerTag.size;
771
+ if (recompute && value.elements.length > 0) {
772
+ const sub = new Writer(64);
773
+ sub._wsRecomputeSizes = true;
774
+ if (handler) handler.write(sub, value.elements[0].value);
775
+ else writeNestedPropertyStream(sub, value.elements[0].value);
776
+ value._arrayInnerTag.size = sub.finalize().length;
777
+ }
778
+ try {
779
+ value._arrayInnerTag.write(writer);
780
+ for (const e of value.elements) {
781
+ if (handler) handler.write(writer, e.value);
782
+ else writeNestedPropertyStream(writer, e.value);
783
+ }
784
+ } finally {
785
+ if (recompute) value._arrayInnerTag.size = origInnerSize;
667
786
  }
668
787
  return;
669
788
  }
@@ -1139,12 +1258,40 @@ export function readPropertyStream(cursor, endOffset = Infinity, consumeTerminat
1139
1258
  }
1140
1259
 
1141
1260
  export function writePropertyStream(writer, properties, emitTerminatorTrailer = false) {
1261
+ // When the writer carries the recompute flag, we encode each property's
1262
+ // value into a temporary sub-buffer, then overwrite tag.size with the
1263
+ // sub-buffer's length before writing the tag. This is required for edits:
1264
+ // the wire stores tag.size literally, and a stale value (e.g. after
1265
+ // lengthening an FString) leaves the next reader misaligned.
1266
+ //
1267
+ // The flag propagates to sub-buffers so nested streams inside StructValue /
1268
+ // ObjectRef / Array<Struct> also get recomputed sizes. Direct callers that
1269
+ // want byte-identical preservation (the test-roundtrip.mjs path) leave the
1270
+ // flag unset; UnrealBlob.serialize sets it from blob._recomputeSizes.
1271
+ const recompute = !!writer._wsRecomputeSizes;
1142
1272
  for (const p of properties) {
1143
1273
  if (p._sizeMismatch) {
1144
1274
  throw new Error(`writePropertyStream: property '${p.name}' has _sizeMismatch (${JSON.stringify(p._sizeMismatch)}); cannot safely re-emit`);
1145
1275
  }
1146
- p.tag.write(writer);
1147
- writeValue(writer, p.tag, p.value);
1276
+ if (recompute) {
1277
+ const sub = new Writer(64);
1278
+ sub._wsRecomputeSizes = true;
1279
+ writeValue(sub, p.tag, p.value);
1280
+ const valueBytes = sub.finalize();
1281
+ const origSize = p.tag.size;
1282
+ p.tag.size = valueBytes.length;
1283
+ try {
1284
+ p.tag.write(writer);
1285
+ writer.writeBytes(valueBytes);
1286
+ } finally {
1287
+ // Restore so we don't mutate the blob's tags across multiple
1288
+ // serialize() calls. A subsequent recompute will rewrite them.
1289
+ p.tag.size = origSize;
1290
+ }
1291
+ } else {
1292
+ p.tag.write(writer);
1293
+ writeValue(writer, p.tag, p.value);
1294
+ }
1148
1295
  }
1149
1296
  new FName('None').write(writer);
1150
1297
  if (emitTerminatorTrailer) writer.writeInt32(0);
package/values.mjs CHANGED
@@ -168,6 +168,9 @@ export class FTextValue {
168
168
  this._keyIsNull = keyIsNull;
169
169
  this.sourceString = sourceString ?? null;
170
170
  this._sourceStringIsNull = sourceStringIsNull;
171
+ } else if (historyType === 1) {
172
+ this.sourceFmt = sourceFmt ?? null; // FTextValue (the pattern)
173
+ this.arguments = args ?? []; // [{key, keyIsNull, type, value}]
171
174
  } else if (historyType === 2) {
172
175
  this.sourceFmt = sourceFmt ?? null; // FTextValue (the pattern)
173
176
  this.arguments = args ?? []; // [{type, value}]
@@ -185,6 +188,7 @@ export class FTextValue {
185
188
  get text() {
186
189
  if (this.historyType === -1) return this.displayString;
187
190
  if (this.historyType === 0) return this.sourceString ?? null;
191
+ if (this.historyType === 1) return this.sourceFmt?.text ?? null;
188
192
  if (this.historyType === 2) return this.sourceFmt?.text ?? null;
189
193
  if (this.historyType === 4) {
190
194
  const v = this.sourceValue?.value;
package/wscodec.mjs CHANGED
@@ -46,6 +46,14 @@ export {
46
46
  readPropertyStream, writePropertyStream, writeNestedPropertyStream,
47
47
  readValue, writeValue,
48
48
  } from './properties.mjs';
49
+ // JSON converter: declared at the bottom so UnrealBlob (below) is already
50
+ // defined when json.mjs's deferred references resolve. ESM live bindings
51
+ // keep this load-order safe even with the json.mjs ↔ wscodec.mjs cycle.
52
+ export {
53
+ blobToJSON, jsonToBlob,
54
+ blobToJSONString, jsonStringToBlob,
55
+ jsonReplacer, jsonReviver,
56
+ } from './json.mjs';
49
57
 
50
58
  const NAME = 'unreal-properties';
51
59
  const VERSION_HEADER_SIZE = 4;
@@ -163,12 +171,22 @@ export class UnrealBlob {
163
171
  * appending `bodyTrailing` after the None terminator + 4-byte FName.Number
164
172
  * trailer that `writePropertyStream` emits.
165
173
  *
174
+ * Options:
175
+ * `recomputeSizes` — override `this._recomputeSizes`. When truthy, every
176
+ * PropertyTag.size field (and ArrayValue innerTag.size) is rewritten
177
+ * from the actual encoded value byte count. Required after edits that
178
+ * change variable-length fields (FString contents, FText, MapValue
179
+ * contents, etc.); without it, a stale size field leaves the Soulmask
180
+ * reader misaligned and the blob is rejected on load. The default is
181
+ * to honor `this._recomputeSizes`; jsonToBlob sets that to true so
182
+ * the JSON pipeline always recomputes.
183
+ *
166
184
  * Throws if `_dirty` is true AND `error` is set: re-emitting would produce
167
185
  * a malformed stream (the property tree is empty after a structural
168
186
  * failure). Clear `.error` first if you intentionally want to emit from
169
187
  * an externally-constructed properties array.
170
188
  */
171
- serialize() {
189
+ serialize({ recomputeSizes } = {}) {
172
190
  if (!this._dirty && this._raw instanceof Uint8Array) return this._raw;
173
191
 
174
192
  if (this.error != null) {
@@ -179,6 +197,7 @@ export class UnrealBlob {
179
197
  }
180
198
 
181
199
  const w = new Writer(this._raw?.length || 256);
200
+ if (recomputeSizes ?? this._recomputeSizes) w._wsRecomputeSizes = true;
182
201
  w.writeUint32(this.versionTag);
183
202
  writePropertyStream(w, this.properties, /*emitTerminatorTrailer=*/true);
184
203
  if (this.bodyTrailing && this.bodyTrailing.length > 0) {