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/primitives.mjs CHANGED
@@ -1,15 +1,28 @@
1
1
  /**
2
- * FName and FGuid.
2
+ * FName and FGuid: the two pervasive identifier types in UE serialization.
3
3
  *
4
- * In this Soulmask format FName is serialized as a plain FString (no
5
- * trailing FName.Number int32). The `number` field stays 0 and exists
6
- * only for symmetry with full UE FNames.
4
+ * Soulmask's quirk for FName: the property-stream wire form is a plain
5
+ * FString (no trailing FName.Number int32). Stock UE 4.27 serializes FName
6
+ * inside a property tag as FString + int32 Number; Soulmask drops the int32
7
+ * everywhere except the OUTERMOST None terminator (which still carries a
8
+ * 4-byte FName.Number = 0 trailer, handled by the property-stream reader).
9
+ *
10
+ * `FName.read` / instance `write` therefore use the Soulmask form (bare
11
+ * FString). The full UE form is exposed separately as `FName.readWithNumber`
12
+ * / instance `writeWithNumber`: not used by Soulmask today, but wired up so
13
+ * the codec can speak the standard wire format if the game's serializer ever
14
+ * adopts it.
7
15
  */
8
16
 
17
+ const ZERO_GUID = '00000000-0000-0000-0000-000000000000';
18
+
9
19
  export class FName {
10
20
  constructor(value, { isUnicode = false, number = 0, isNull = false } = {}) {
11
21
  this.value = value;
12
22
  this.isUnicode = isUnicode;
23
+ // FName.Number: zero in every observed Soulmask FName (the wire form
24
+ // omits it). Preserved on the instance for round-trip when the full UE
25
+ // form is used via readWithNumber/writeWithNumber.
13
26
  this.number = number;
14
27
  // Tracks the wire-form distinction between an FString with SaveNum=0
15
28
  // (the "null" form) and SaveNum=1 (empty-with-terminator). Only ever
@@ -18,15 +31,39 @@ export class FName {
18
31
  this.isNull = isNull;
19
32
  }
20
33
  toString() { return this.value; }
34
+ /** JSON-friendly form: the bare name string (drops isUnicode/number/isNull metadata). */
35
+ toJSON() { return this.value; }
21
36
 
37
+ /**
38
+ * Read an FName in the Soulmask property-stream form: a bare FString,
39
+ * no trailing FName.Number. `number` is left at 0.
40
+ */
22
41
  static read(cursor) {
23
42
  const s = cursor.readFString();
24
43
  return new FName(s.value, { isUnicode: s.isUnicode, isNull: !!s.isNull });
25
44
  }
26
45
 
46
+ /** Write the Soulmask form (FString only). */
27
47
  write(writer) { writer.writeFString(this.value, this.isUnicode, this.isNull); }
28
48
 
29
- /** Accepts an FName, a bare string, or a plain {value,isUnicode,isNull} record. */
49
+ /**
50
+ * Read an FName in the stock UE 4.27 property-tag form: FString + int32
51
+ * Number. Use this if you're decoding a non-Soulmask stream or a future
52
+ * Soulmask wire format that re-adopts the int32 suffix.
53
+ */
54
+ static readWithNumber(cursor) {
55
+ const s = cursor.readFString();
56
+ const number = cursor.readInt32();
57
+ return new FName(s.value, { isUnicode: s.isUnicode, isNull: !!s.isNull, number });
58
+ }
59
+
60
+ /** Write the stock UE form (FString + int32 Number). */
61
+ writeWithNumber(writer) {
62
+ writer.writeFString(this.value, this.isUnicode, this.isNull);
63
+ writer.writeInt32(this.number | 0);
64
+ }
65
+
66
+ /** Accepts an FName, a bare string, or a plain {value,isUnicode,isNull,number} record. */
30
67
  static from(x) {
31
68
  if (x instanceof FName) return x;
32
69
  if (typeof x === 'string') return new FName(x);
@@ -46,6 +83,31 @@ export class FGuid {
46
83
  constructor(value) { this.value = value; }
47
84
  toString() { return this.value; }
48
85
 
86
+ /**
87
+ * JSON-friendly form: the bare GUID string, so `JSON.stringify(fguid)`
88
+ * yields `"AABBCCDD-..."` rather than `{"value":"AABBCCDD-..."}`.
89
+ */
90
+ toJSON() { return this.value; }
91
+
92
+ /**
93
+ * Structural equality. Case-insensitive: an FGuid constructed from a
94
+ * lowercase string compares equal to one read off the wire (uppercase).
95
+ * Accepts an FGuid or a string; anything else returns false.
96
+ */
97
+ equals(other) {
98
+ const otherStr = other instanceof FGuid ? other.value
99
+ : typeof other === 'string' ? other
100
+ : null;
101
+ if (otherStr == null) return false;
102
+ return String(this.value).toUpperCase() === otherStr.toUpperCase();
103
+ }
104
+
105
+ /** True iff the GUID is all zeros (the conventional null-GUID sentinel). */
106
+ isZero() { return String(this.value).toUpperCase() === ZERO_GUID; }
107
+
108
+ /** All-zero FGuid sentinel. New instance per call (FGuid is mutable). */
109
+ static zero() { return new FGuid(ZERO_GUID); }
110
+
49
111
  static read(cursor) {
50
112
  const A = cursor.readUint32(), B = cursor.readUint32(), C = cursor.readUint32(), D = cursor.readUint32();
51
113
  const h = (n, w) => n.toString(16).padStart(w, '0').toUpperCase();
@@ -62,6 +124,7 @@ export class FGuid {
62
124
  writer.writeUint32(A); writer.writeUint32(B); writer.writeUint32(C); writer.writeUint32(D);
63
125
  }
64
126
 
127
+ /** Accepts an FGuid or a canonical 8-4-4-4-12 hex string. */
65
128
  static from(x) {
66
129
  if (x instanceof FGuid) return x;
67
130
  if (typeof x === 'string') return new FGuid(x);
package/properties.mjs CHANGED
@@ -25,12 +25,13 @@
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';
31
32
 
32
33
  // ==========================================================================
33
- // PropertyTag the header preceding each property's value bytes.
34
+ // PropertyTag: the header preceding each property's value bytes.
34
35
  // ==========================================================================
35
36
  export class PropertyTag {
36
37
  constructor(fields = {}) {
@@ -124,7 +125,7 @@ export class MapValue {
124
125
  }
125
126
 
126
127
  // ==========================================================================
127
- // Property one tag + its decoded value.
128
+ // Property: one tag + its decoded value.
128
129
  // ==========================================================================
129
130
  export class Property {
130
131
  constructor(tag, value, { sizeMismatch = null } = {}) {
@@ -137,7 +138,7 @@ export class Property {
137
138
  }
138
139
 
139
140
  // ==========================================================================
140
- // Value codec dispatches on tag.type.value.
141
+ // Value codec: dispatches on tag.type.value.
141
142
  //
142
143
  // sizeHint is the tag's Size field (bytes following the tag). Containers
143
144
  // (Array/Set/Map) and StructProperty use it as the byte budget for nested
@@ -163,7 +164,7 @@ export function readValue(cursor, tag, sizeHint) {
163
164
  case 'ClassProperty':
164
165
  case 'WeakObjectProperty':
165
166
  case 'LazyObjectProperty':
166
- case 'WSObjectProperty': // Soulmask-specific alias (per BLOB_FORMAT.md)
167
+ case 'WSObjectProperty': // Soulmask alias for ObjectProperty (same wire layout, different tag name)
167
168
  return readObjectValue(cursor, sizeHint);
168
169
  case 'SoftObjectProperty':
169
170
  case 'SoftClassProperty':
@@ -207,7 +208,7 @@ export function readValue(cursor, tag, sizeHint) {
207
208
  }
208
209
 
209
210
  export function writeValue(writer, tag, value) {
210
- // Decode may have fallen back to OpaqueValue for any property type
211
+ // Decode may have fallen back to OpaqueValue for any property type:
211
212
  // Array/Set/Map/Struct/Text decode failures, unknown property types,
212
213
  // overshoot recoveries, etc. Emit the captured bytes verbatim so the
213
214
  // outer stream stays aligned regardless of which slot held the opaque.
@@ -279,7 +280,7 @@ function readFText(cursor, sizeHint) {
279
280
  if (historyType === 0) {
280
281
  // Base / localized: namespace + key + source string. Empty strings on
281
282
  // the wire may use either null-form (SaveNum=0) or empty-with-terminator
282
- // (SaveNum=1) capture `isNull` per-field so the writer reproduces the
283
+ // (SaveNum=1). Capture `isNull` per-field so the writer reproduces the
283
284
  // exact wire form.
284
285
  const nsFS = cursor.readFString();
285
286
  const kFS = cursor.readFString();
@@ -291,13 +292,42 @@ 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
297
327
  // the value for that type:
298
328
  // 0=Int(int64) 1=UInt(uint64) 2=Float(f32) 3=Double(f64)
299
329
  // 4=Text(FText, recursive) 5=Gender(int8)
300
- // No argument names on the wire arguments are positional ({0}, {1} ).
330
+ // No argument names on the wire; arguments are positional ({0}, {1} ...).
301
331
  const sourceFmt = readFText(cursor, Infinity);
302
332
  const numArgs = cursor.readInt32();
303
333
  const args = [];
@@ -328,7 +358,7 @@ function readFText(cursor, sizeHint) {
328
358
  // Inside FNumberFormattingOptions, AlwaysSign and UseGrouping are also
329
359
  // uint32 booleans. Only RoundingMode (int8) and the four digit-count
330
360
  // fields (int32) follow the modern sizes. This matches the actual wire
331
- // bytes empirically MaxIntDigits = ~324 (close to DBL_MAX_10_EXP+1
361
+ // bytes: empirically MaxIntDigits = ~324 (close to DBL_MAX_10_EXP+1
332
362
  // = 309) and MaxFracDigits = 3 (UE default) under this interpretation.
333
363
  const argType = cursor.readInt8();
334
364
  let argValue;
@@ -366,7 +396,7 @@ function readFText(cursor, sizeHint) {
366
396
  }
367
397
  // Unknown history type: preserve remaining bytes verbatim for round-trip.
368
398
  // When called from an array-element context sizeHint is Infinity because
369
- // the per-element byte budget is unknown throw so the callers can decide
399
+ // the per-element byte budget is unknown; throw so the callers can decide
370
400
  // whether to fall back to OpaqueValue at the element or array level.
371
401
  if (!isFinite(sizeHint)) throw new Error(`readFText: unimplemented historyType ${historyType} (no size budget; cannot store raw bytes)`);
372
402
  const remaining = sizeHint - (cursor.pos() - start);
@@ -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);
@@ -420,7 +466,7 @@ function writeFText(writer, value) {
420
466
  case 5: writer.writeInt64(sv.value); break;
421
467
  default: throw new Error(`writeFText: unknown FFormatArgumentValue type ${sv.type} in AsNumber`);
422
468
  }
423
- // Legacy uint32 booleans see readFText AsNumber for rationale.
469
+ // Legacy uint32 booleans (see readFText AsNumber for rationale).
424
470
  const hasFormatOptions = value.formatOptions != null;
425
471
  writer.writeUint32(hasFormatOptions ? 1 : 0);
426
472
  if (hasFormatOptions) {
@@ -459,7 +505,7 @@ function writeFText(writer, value) {
459
505
  // preserves the wire's choice between null-form (SaveNum=0, 4 bytes) and
460
506
  // empty-with-terminator (SaveNum=1 plus 1-byte NUL, 5 bytes). The previous
461
507
  // version always wrote `writeFString(this.path)` for ObjectRef and emitted
462
- // a 4-byte null FString even for kind-only values silently inflating the
508
+ // a 4-byte null FString even for kind-only values, silently inflating the
463
509
  // encoded blob by 4 B for every kind-only reference.
464
510
  function readObjectValue(cursor, sizeHint) {
465
511
  const start = cursor.pos();
@@ -471,7 +517,7 @@ function readObjectValue(cursor, sizeHint) {
471
517
  }
472
518
  // Soulmask kind=0x01 (hard actor reference, e.g. HBindBGCompActor on
473
519
  // NPC pawns) prepends a 4-byte field between the kind byte and the
474
- // path FString. Observed value is always 1; semantic unknown — captured
520
+ // path FString. Observed value is always 1; semantic unknown. Captured
475
521
  // verbatim and replayed on write. Without this branch the reader treats
476
522
  // those four bytes as the path FString's SaveNum, which overshoots the
477
523
  // budget and falls back to OpaqueValue (the symptom that hid every
@@ -484,7 +530,7 @@ function readObjectValue(cursor, sizeHint) {
484
530
  }
485
531
  }
486
532
  const pathFS = cursor.readFString();
487
- // Guard against path FStrings whose SaveNum overshoots the value budget
533
+ // Guard against path FStrings whose SaveNum overshoots the value budget:
488
534
  // this happens for properties whose format differs from kind+path+... and
489
535
  // whose first "path" bytes happen to encode a huge length.
490
536
  if (cursor.pos() - start > sizeHint) throw new Error('path FString exceeded value budget');
@@ -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)
599
645
  *
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.
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.
650
+ *
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
  }
@@ -688,7 +807,7 @@ function readSetValue(cursor, tag) {
688
807
  // Set elements for StructProperty inner type are raw binary structs with no
689
808
  // inner PropertyTag wrapper (unlike ArrayProperty<StructProperty>, which does
690
809
  // have one). Every observed Set<StructProperty> in world.db uses 16-byte Guids
691
- // as elements the same assumption MapProperty makes for Struct keys.
810
+ // as elements: the same assumption MapProperty makes for Struct keys.
692
811
  function readSetElement(cursor, innerType) {
693
812
  if (innerType === 'StructProperty') return FGuid.read(cursor).value;
694
813
  return readArrayElement(cursor, innerType);
@@ -737,7 +856,7 @@ function writeMapValue(writer, tag, value) {
737
856
 
738
857
  /**
739
858
  * Map element (one key or one value) when the map's inner/value type is
740
- * StructProperty Soulmask uses several conventions that diverge from
859
+ * StructProperty: Soulmask uses several conventions that diverge from
741
860
  * stock UE 4.27 here:
742
861
  *
743
862
  * Key (StructProperty) → a raw 16-byte FGuid. The map tag declares
@@ -751,7 +870,7 @@ function writeMapValue(writer, tag, value) {
751
870
  * `GeRenJianZhuYingHuoList`, `GeRenMapRiZhi`)
752
871
  * OR a raw 16-byte FGuid (`PlayerGongHuiMap`,
753
872
  * a player→guild membership lookup).
754
- * We sniff which by peeking ahead — a
873
+ * We sniff which by peeking ahead. A
755
874
  * property stream starts with an FString
756
875
  * length prefix for the first tag's name
757
876
  * (small positive int, body is identifier
@@ -767,7 +886,7 @@ function writeMapValue(writer, tag, value) {
767
886
  * NOT match the actual byte span of the data section (observed:
768
887
  * tag.size=632838, actual=636422 for a populated GongHuiMap). The
769
888
  * decoder advances the cursor based on pair count + per-pair shape,
770
- * NOT the tag.size which is why this works despite the size lie.
889
+ * NOT the tag.size, which is why this works despite the size lie.
771
890
  */
772
891
  function readMapElement(cursor, type, isKey) {
773
892
  if (type !== 'StructProperty') return readArrayElement(cursor, type);
@@ -798,17 +917,25 @@ function writeMapElement(writer, type, value, isKey) {
798
917
 
799
918
  /**
800
919
  * Peek the next bytes of `cursor` (without advancing): do they look like
801
- * the start of a PropertyTag i.e. an FString that names a property?
920
+ * the start of a PropertyTag (i.e. an FString that names a property)?
802
921
  *
803
922
  * A property name FString is:
804
923
  * - int32 SaveNum > 0 and reasonably small (<= 64 chars in Soulmask)
805
924
  * - SaveNum bytes of ANSI body whose last byte is NUL
806
925
  * - body chars (minus NUL) are identifier-safe: A-Z, a-z, 0-9, _.
807
926
  *
808
- * Random GUID bytes effectively never satisfy this the first uint32
927
+ * Random GUID bytes effectively never satisfy this: the first uint32
809
928
  * of a Guid is ~uniform over [0..2^32), and even when it lands in a
810
929
  * "plausible length" range the printable-ASCII + NUL-terminator check
811
930
  * eliminates the false positives.
931
+ *
932
+ * Caveat: we only match ANSI property names (SaveNum > 0). Every Soulmask
933
+ * property name observed in world.db is ASCII, so a negative-SaveNum
934
+ * (UTF-16) tag is currently treated as "not a tag" and the caller falls
935
+ * through to the alternate read path. If a future Soulmask version emits
936
+ * UTF-16 property names inside a Map<Struct,Struct> value, this needs an
937
+ * additional branch matching saveNum < 0 with the equivalent UTF-16
938
+ * identifier-character + NUL-terminator check.
812
939
  */
813
940
  function peekLooksLikePropertyTag(cursor) {
814
941
  if (cursor.remaining() < 8) return false;
@@ -836,6 +963,49 @@ function peekLooksLikePropertyTag(cursor) {
836
963
  // kind+path, kind+path+classPath, or full kind+path+classPath+embedded).
837
964
  // Other inner types have fixed sizes determined by the type itself, so
838
965
  // they ignore the hint.
966
+ //
967
+ // =====================================================================
968
+ // Heuristics preamble: how this reader disambiguates ObjectProperty
969
+ // elements that don't carry a per-element delimiter on the wire.
970
+ //
971
+ // Stock UE ArrayProperty<ObjectProperty> writes a sequence of object
972
+ // values back-to-back with no length tag and no separator between
973
+ // elements. Each element's wire form is one of:
974
+ //
975
+ // (A) kind-only 1 byte
976
+ // (B) kind+path 1 byte + FString
977
+ // (C) kind+path+class 1 byte + FString + FString
978
+ // (D) kind+path+class+embedded property stream (terminated by None)
979
+ //
980
+ // Without per-element bounds we'd read past the element's actual end
981
+ // into either the next element's kind byte or a trailing binary section
982
+ // (origin / placement data) and cascade-fail.
983
+ //
984
+ // We address this with four guards, each cheap and orthogonal:
985
+ //
986
+ // Guard 1: budget exhaustion. After path, if there's no room for even
987
+ // a null-form classPath FString (4 bytes), stop here.
988
+ // Guard 2: implausible saveNum magnitude. A real classPath is short
989
+ // (<= 1024 chars); a peek that decodes to a huge magnitude
990
+ // usually means we're looking at the start of the next
991
+ // element's bytes instead.
992
+ // Guard 3: classPath starts with '/'. Soulmask asset paths are always
993
+ // "/Script/..." or "/Game/...". A peek whose first content
994
+ // byte isn't '/' (or '/' '\0' for UTF-16) is the next
995
+ // element's payload, not a real classPath.
996
+ // Guard 4: embedded-stream signature. The bytes following classPath
997
+ // either start a PropertyTag (identifier-character name with
998
+ // a small ANSI SaveNum) or they don't; if they don't, the
999
+ // element ends without an embedded stream.
1000
+ //
1001
+ // The same logic governs whether a 4-byte trailer at the element's tail
1002
+ // is consumed: only when the next 4 bytes are 0x00000000 (FName.Number)
1003
+ // AND we're still within budget.
1004
+ //
1005
+ // In practice this catches every known Soulmask actor in the tested
1006
+ // world.db. Adding a new game-specific element shape means adding a
1007
+ // new guard, not relaxing the existing ones.
1008
+ // =====================================================================
839
1009
  function readArrayElement(cursor, innerType, sizeHint = Infinity) {
840
1010
  switch (innerType) {
841
1011
  case 'IntProperty': return cursor.readInt32();
@@ -864,7 +1034,7 @@ function readArrayElement(cursor, innerType, sizeHint = Infinity) {
864
1034
  case 'WSObjectProperty': {
865
1035
  // Bounded read, mirroring readObjectValue. The variable wire shapes
866
1036
  // (kind-only, +path, +path+classPath, +embedded) are disambiguated by
867
- // sizeHint without the bound we'd read past the element into the
1037
+ // sizeHint. Without the bound we'd read past the element into the
868
1038
  // next property's tag, which causes catastrophic cascade failures
869
1039
  // (cf. ChengHaoList in serial 92 and friends). Capture per-FString
870
1040
  // isNull flags for byte-identical round-trip of empty wire-FStrings.
@@ -878,7 +1048,7 @@ function readArrayElement(cursor, innerType, sizeHint = Infinity) {
878
1048
  // kind byte with no path, classPath, or embedded stream. Without this
879
1049
  // early-out, the FString reader would interpret the next 4 bytes (which
880
1050
  // belong to either the next element or the trailing binary section)
881
- // as a path saveNum typically a huge garbage value that overshoots
1051
+ // as a path saveNum: typically a huge garbage value that overshoots
882
1052
  // the array. Seen in JianZhuInstYuanXings, ZhuangBeiLanDaoJuJiYiList,
883
1053
  // and KuaiJieLanDaoJuJiYiList trailing slots.
884
1054
  if (kind === 0) {
@@ -915,7 +1085,7 @@ function readArrayElement(cursor, innerType, sizeHint = Infinity) {
915
1085
  // "/Script/Module.Class" or "/Game/...". The first content character
916
1086
  // is therefore "/" (0x2F). When the bytes after path are actually the
917
1087
  // start of the NEXT element (kind byte + optional kindOnePrefix +
918
- // path saveNum), peekSN can fall in the [-1024, 1024] range e.g.
1088
+ // path saveNum), peekSN can fall in the [-1024, 1024] range, e.g.
919
1089
  // bytes `01 01 00 00` from a kind=1 element with kindOnePrefix=1 read
920
1090
  // as int32 = 257. The previous saveNum-magnitude guard misses this;
921
1091
  // checking the first content byte for '/' catches it cleanly. Allow
@@ -947,7 +1117,7 @@ function readArrayElement(cursor, innerType, sizeHint = Infinity) {
947
1117
  });
948
1118
  }
949
1119
  // Guard 4: embedded-stream presence. Same problem as the classPath
950
- // guards but one level deeper when the element has classPath but
1120
+ // guards but one level deeper: when the element has classPath but
951
1121
  // NO embedded stream, the bytes that follow classPath are the start
952
1122
  // of the NEXT element (kind byte) or the trailing binary section
953
1123
  // (12 zero bytes of origin). An embedded stream begins with a
@@ -1088,12 +1258,40 @@ export function readPropertyStream(cursor, endOffset = Infinity, consumeTerminat
1088
1258
  }
1089
1259
 
1090
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;
1091
1272
  for (const p of properties) {
1092
1273
  if (p._sizeMismatch) {
1093
1274
  throw new Error(`writePropertyStream: property '${p.name}' has _sizeMismatch (${JSON.stringify(p._sizeMismatch)}); cannot safely re-emit`);
1094
1275
  }
1095
- p.tag.write(writer);
1096
- 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
+ }
1097
1295
  }
1098
1296
  new FName('None').write(writer);
1099
1297
  if (emitTerminatorTrailer) writer.writeInt32(0);
@@ -1103,7 +1301,7 @@ export function writePropertyStream(writer, properties, emitTerminatorTrailer =
1103
1301
  // StructValue.write) to avoid needing a writePropertyStream re-export.
1104
1302
  // `emitTerminatorTrailer` defaults to false because nested streams in
1105
1303
  // stock UE 4.27 don't carry the 4-byte FName.Number trailer that the
1106
- // outermost stream does but some Soulmask embedded ObjectProperty
1304
+ // outermost stream does. But some Soulmask embedded ObjectProperty
1107
1305
  // streams DO (see ObjectRef.hasTerminatorTrailer / readObjectValue's
1108
1306
  // trailer-skip detection), so callers can opt in.
1109
1307
  export function writeNestedPropertyStream(writer, properties, emitTerminatorTrailer = false) {