wscodec 0.1.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/properties.mjs ADDED
@@ -0,0 +1,1111 @@
1
+ /**
2
+ * Property tag + property tree machinery.
3
+ *
4
+ * Layout (UE 4.27, PropertyTag.h, Soulmask tweaks):
5
+ *
6
+ * FString Name
7
+ * [if Name == "None": consume int32 trailer if outermost, stream ends]
8
+ * FString Type
9
+ * int32 Size // bytes of value data following the tag
10
+ * int32 ArrayIndex
11
+ * // type-specific tag data:
12
+ * if Type == "StructProperty": FString StructName + FGuid StructGuid
13
+ * if Type == "BoolProperty": u8 BoolVal
14
+ * if Type == "ByteProperty": FString EnumName
15
+ * if Type == "EnumProperty": FString EnumName
16
+ * if Type == "ArrayProperty": FString InnerType
17
+ * if Type == "SetProperty": FString InnerType
18
+ * if Type == "MapProperty": FString InnerType + FString ValueType
19
+ * u8 HasPropertyGuid
20
+ * if HasPropertyGuid: FGuid PropertyGuid
21
+ * // then: Size bytes of value data (format depends on Type)
22
+ *
23
+ * The OUTERMOST property stream's "None" terminator carries a 4-byte
24
+ * trailer (FName.Number = 0). Nested streams (struct, array-of-struct
25
+ * elements, embedded object data) do NOT.
26
+ */
27
+
28
+ import { FName, FGuid } from './primitives.mjs';
29
+ import { StructValue, STRUCT_HANDLERS } from './structs.mjs';
30
+ import { ObjectRef, SoftObjectRef, FTextValue, OpaqueValue } from './values.mjs';
31
+
32
+ // ==========================================================================
33
+ // PropertyTag — the header preceding each property's value bytes.
34
+ // ==========================================================================
35
+ export class PropertyTag {
36
+ constructor(fields = {}) {
37
+ this.name = fields.name ?? null;
38
+ this.type = fields.type ?? null;
39
+ this.size = fields.size ?? 0;
40
+ this.arrayIndex = fields.arrayIndex ?? 0;
41
+ this.structName = fields.structName ?? null;
42
+ this.structGuid = fields.structGuid ?? null;
43
+ this.boolVal = fields.boolVal ?? null;
44
+ this.enumName = fields.enumName ?? null;
45
+ this.innerType = fields.innerType ?? null;
46
+ this.valueType = fields.valueType ?? null;
47
+ this.hasPropertyGuid = !!fields.hasPropertyGuid;
48
+ this.propertyGuid = fields.propertyGuid ?? null;
49
+ this.isTerminator = !!fields.isTerminator;
50
+ }
51
+
52
+ static read(cursor) {
53
+ const name = FName.read(cursor);
54
+ if (name.value === 'None') return new PropertyTag({ name, isTerminator: true });
55
+
56
+ const tag = new PropertyTag({
57
+ name,
58
+ type: FName.read(cursor),
59
+ size: cursor.readInt32(),
60
+ arrayIndex: cursor.readInt32(),
61
+ });
62
+
63
+ switch (tag.type.value) {
64
+ case 'StructProperty': tag.structName = FName.read(cursor); tag.structGuid = FGuid.read(cursor); break;
65
+ case 'BoolProperty': tag.boolVal = cursor.readUint8(); break;
66
+ case 'ByteProperty': tag.enumName = FName.read(cursor); break;
67
+ case 'EnumProperty': tag.enumName = FName.read(cursor); break;
68
+ case 'ArrayProperty': tag.innerType = FName.read(cursor); break;
69
+ case 'SetProperty': tag.innerType = FName.read(cursor); break;
70
+ case 'MapProperty': tag.innerType = FName.read(cursor); tag.valueType = FName.read(cursor); break;
71
+ }
72
+ tag.hasPropertyGuid = cursor.readUint8() !== 0;
73
+ if (tag.hasPropertyGuid) tag.propertyGuid = FGuid.read(cursor);
74
+ return tag;
75
+ }
76
+
77
+ write(writer) {
78
+ this.name.write(writer);
79
+ if (this.isTerminator) return;
80
+ this.type.write(writer);
81
+ writer.writeInt32(this.size);
82
+ writer.writeInt32(this.arrayIndex);
83
+ switch (this.type.value) {
84
+ case 'StructProperty': this.structName.write(writer); this.structGuid.write(writer); break;
85
+ case 'BoolProperty': writer.writeUint8(this.boolVal); break;
86
+ case 'ByteProperty': this.enumName.write(writer); break;
87
+ case 'EnumProperty': this.enumName.write(writer); break;
88
+ case 'ArrayProperty': this.innerType.write(writer); break;
89
+ case 'SetProperty': this.innerType.write(writer); break;
90
+ case 'MapProperty': this.innerType.write(writer); this.valueType.write(writer); break;
91
+ }
92
+ writer.writeUint8(this.hasPropertyGuid ? 1 : 0);
93
+ if (this.hasPropertyGuid) this.propertyGuid.write(writer);
94
+ }
95
+ }
96
+
97
+ // ==========================================================================
98
+ // Container value classes
99
+ // ==========================================================================
100
+ export class ArrayValue {
101
+ constructor({ elements = [], innerTag = null, perElementTrailings = null } = {}) {
102
+ this.elements = elements;
103
+ this._arrayInnerTag = innerTag;
104
+ // Soulmask per-element placement-binary for ArrayProperty<ObjectProperty>
105
+ // in JianZhuInstYuanXings (building-zone yuan-xing arrays). Parallel to
106
+ // `elements`; entries may be null when an element has no trailing of its
107
+ // own. See readObjectArrayPerElementBlock / writeObjectArrayPerElementBlock.
108
+ this._perElementTrailings = perElementTrailings;
109
+ }
110
+ }
111
+
112
+ export class SetValue {
113
+ constructor({ removed = [], elements = [] } = {}) {
114
+ this.removed = removed;
115
+ this.elements = elements;
116
+ }
117
+ }
118
+
119
+ export class MapValue {
120
+ constructor({ removed = [], entries = [] } = {}) {
121
+ this.removed = removed;
122
+ this.entries = entries;
123
+ }
124
+ }
125
+
126
+ // ==========================================================================
127
+ // Property — one tag + its decoded value.
128
+ // ==========================================================================
129
+ export class Property {
130
+ constructor(tag, value, { sizeMismatch = null } = {}) {
131
+ this.tag = tag;
132
+ this.value = value;
133
+ if (sizeMismatch) this._sizeMismatch = sizeMismatch;
134
+ }
135
+ get name() { return this.tag.name?.value ?? null; }
136
+ get type() { return this.tag.type?.value ?? null; }
137
+ }
138
+
139
+ // ==========================================================================
140
+ // Value codec — dispatches on tag.type.value.
141
+ //
142
+ // sizeHint is the tag's Size field (bytes following the tag). Containers
143
+ // (Array/Set/Map) and StructProperty use it as the byte budget for nested
144
+ // decoding; on failure they fall back to OpaqueValue so the stream stays
145
+ // consistent.
146
+ // ==========================================================================
147
+ export function readValue(cursor, tag, sizeHint) {
148
+ const t = tag.type.value;
149
+ switch (t) {
150
+ case 'IntProperty': return cursor.readInt32();
151
+ case 'Int8Property': return cursor.readInt8();
152
+ case 'Int16Property': return cursor.readInt16();
153
+ case 'Int64Property': return cursor.readInt64().toString();
154
+ case 'UInt16Property': return cursor.readUint16();
155
+ case 'UInt32Property': return cursor.readUint32();
156
+ case 'UInt64Property': return cursor.readUint64().toString();
157
+ case 'FloatProperty': return cursor.readFloat32();
158
+ case 'DoubleProperty': return cursor.readFloat64();
159
+ case 'BoolProperty': return tag.boolVal !== 0;
160
+ case 'StrProperty': return cursor.readFString().value;
161
+ case 'NameProperty': return FName.read(cursor);
162
+ case 'ObjectProperty':
163
+ case 'ClassProperty':
164
+ case 'WeakObjectProperty':
165
+ case 'LazyObjectProperty':
166
+ case 'WSObjectProperty': // Soulmask-specific alias (per BLOB_FORMAT.md)
167
+ return readObjectValue(cursor, sizeHint);
168
+ case 'SoftObjectProperty':
169
+ case 'SoftClassProperty':
170
+ return new SoftObjectRef({ assetPath: cursor.readFString().value, subPath: cursor.readFString().value });
171
+ case 'ByteProperty':
172
+ return tag.enumName.value === 'None' ? cursor.readUint8() : FName.read(cursor);
173
+ case 'EnumProperty':
174
+ return FName.read(cursor);
175
+ case 'StructProperty':
176
+ return StructValue.read(cursor, tag.structName.value, sizeHint, readPropertyStream, peekLooksLikePropertyTag);
177
+ case 'ArrayProperty':
178
+ case 'SetProperty':
179
+ case 'MapProperty': {
180
+ const opaqueStart = cursor.pos();
181
+ try {
182
+ if (t === 'ArrayProperty') return readArrayValue(cursor, tag, sizeHint);
183
+ if (t === 'SetProperty') return readSetValue(cursor, tag);
184
+ return readMapValue(cursor, tag);
185
+ } catch (e) {
186
+ cursor.seek(opaqueStart);
187
+ return new OpaqueValue(cursor.readBytes(sizeHint).slice(), `${t} decode failed: ${e.message}`);
188
+ }
189
+ }
190
+ case 'TextProperty':
191
+ return readFText(cursor, sizeHint);
192
+ case 'MulticastDelegateProperty':
193
+ case 'MulticastInlineDelegateProperty':
194
+ case 'MulticastSparseDelegateProperty':
195
+ case 'DelegateProperty':
196
+ // Wire format (per UE source):
197
+ // [int32 NumDelegates]
198
+ // For each: [UObject ref] [FName FunctionName]
199
+ // The UObject-ref encoding inside a delegate is archive-dependent and
200
+ // we don't have ground-truth Soulmask data to verify it. Preserve the
201
+ // bytes verbatim so round-trip via OpaqueValue stays byte-identical;
202
+ // a structured decoder can be slotted in later when we see real data.
203
+ return new OpaqueValue(cursor.readBytes(sizeHint).slice(), `${t} (recognized; structured decode not yet implemented)`);
204
+ default:
205
+ return new OpaqueValue(cursor.readBytes(sizeHint).slice(), `Unknown property type ${t}`);
206
+ }
207
+ }
208
+
209
+ export function writeValue(writer, tag, value) {
210
+ // Decode may have fallen back to OpaqueValue for any property type —
211
+ // Array/Set/Map/Struct/Text decode failures, unknown property types,
212
+ // overshoot recoveries, etc. Emit the captured bytes verbatim so the
213
+ // outer stream stays aligned regardless of which slot held the opaque.
214
+ // (TextProperty and the `default:` case used to do this individually;
215
+ // a single guard up here covers every slot uniformly.)
216
+ if (value instanceof OpaqueValue) { value.write(writer); return; }
217
+ const t = tag.type.value;
218
+ switch (t) {
219
+ case 'IntProperty': writer.writeInt32(value); return;
220
+ case 'Int8Property': writer.writeInt8(value); return;
221
+ case 'Int16Property': writer.writeInt16(value); return;
222
+ case 'Int64Property': writer.writeInt64(value); return;
223
+ case 'UInt16Property': writer.writeUint16(value); return;
224
+ case 'UInt32Property': writer.writeUint32(value); return;
225
+ case 'UInt64Property': writer.writeUint64(value); return;
226
+ case 'FloatProperty': writer.writeFloat32(value); return;
227
+ case 'DoubleProperty': writer.writeFloat64(value); return;
228
+ case 'BoolProperty': return; // value lives in the tag
229
+ case 'StrProperty': writer.writeFString(value); return;
230
+ case 'NameProperty': FName.from(value).write(writer); return;
231
+ case 'ObjectProperty':
232
+ case 'ClassProperty':
233
+ case 'WeakObjectProperty':
234
+ case 'LazyObjectProperty':
235
+ case 'WSObjectProperty':
236
+ writeObjectValue(writer, value); return;
237
+ case 'SoftObjectProperty':
238
+ case 'SoftClassProperty':
239
+ if (value instanceof SoftObjectRef) value.write(writer);
240
+ else new SoftObjectRef(value).write(writer);
241
+ return;
242
+ case 'ByteProperty':
243
+ if (tag.enumName.value === 'None') writer.writeUint8(value);
244
+ else FName.from(value).write(writer);
245
+ return;
246
+ case 'EnumProperty':
247
+ FName.from(value).write(writer); return;
248
+ case 'StructProperty':
249
+ value.write(writer, writeNestedPropertyStream);
250
+ return;
251
+ case 'ArrayProperty': writeArrayValue(writer, tag, value); return;
252
+ case 'SetProperty': writeSetValue(writer, tag, value); return;
253
+ case 'MapProperty': writeMapValue(writer, tag, value); return;
254
+ case 'TextProperty':
255
+ if (value instanceof FTextValue) { writeFText(writer, value); return; }
256
+ throw new Error('writeValue: TextProperty: expected FTextValue or OpaqueValue');
257
+ default:
258
+ throw new Error(`writeValue: no encoder for type ${t}`);
259
+ }
260
+ }
261
+
262
+ // -------- TextProperty (FText) --------
263
+ function readFText(cursor, sizeHint) {
264
+ const start = cursor.pos();
265
+ try {
266
+ const flags = cursor.readUint32();
267
+ const historyType = cursor.readInt8();
268
+ if (historyType === -1) {
269
+ // None / culture-invariant: optional display string
270
+ const bHas = cursor.readInt32();
271
+ let displayString = null, displayStringIsNull = false;
272
+ if (bHas) {
273
+ const fs = cursor.readFString();
274
+ displayString = fs.value;
275
+ displayStringIsNull = fs.isNull;
276
+ }
277
+ return new FTextValue({ flags, historyType: -1, displayString, displayStringIsNull });
278
+ }
279
+ if (historyType === 0) {
280
+ // Base / localized: namespace + key + source string. Empty strings on
281
+ // 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
+ // exact wire form.
284
+ const nsFS = cursor.readFString();
285
+ const kFS = cursor.readFString();
286
+ const ssFS = cursor.readFString();
287
+ return new FTextValue({
288
+ flags, historyType: 0,
289
+ namespace: nsFS.value, namespaceIsNull: nsFS.isNull,
290
+ key: kFS.value, keyIsNull: kFS.isNull,
291
+ sourceString: ssFS.value, sourceStringIsNull: ssFS.isNull,
292
+ });
293
+ }
294
+ if (historyType === 2) {
295
+ // ArgumentFormat: a format-pattern FText plus an ordered argument list.
296
+ // Each argument is a ContentType byte (EFormatArgumentType) followed by
297
+ // the value for that type:
298
+ // 0=Int(int64) 1=UInt(uint64) 2=Float(f32) 3=Double(f64)
299
+ // 4=Text(FText, recursive) 5=Gender(int8)
300
+ // No argument names on the wire — arguments are positional ({0}, {1} …).
301
+ const sourceFmt = readFText(cursor, Infinity);
302
+ const numArgs = cursor.readInt32();
303
+ const args = [];
304
+ for (let i = 0; i < numArgs; i++) {
305
+ const type = cursor.readInt8();
306
+ let value;
307
+ switch (type) {
308
+ case 0: value = cursor.readInt64().toString(); break;
309
+ case 1: value = cursor.readUint64().toString(); break;
310
+ case 2: value = cursor.readFloat32(); break;
311
+ case 3: value = cursor.readFloat64(); break;
312
+ case 4: value = readFText(cursor, Infinity); break;
313
+ case 5: value = cursor.readInt8(); break;
314
+ default: throw new Error(`readFText: unknown ArgumentFormat ContentType ${type}`);
315
+ }
316
+ args.push({ type, value });
317
+ }
318
+ return new FTextValue({ flags, historyType: 2, sourceFmt, arguments: args });
319
+ }
320
+ if (historyType === 4) {
321
+ // AsNumber (FTextHistory_AsNumber):
322
+ // FFormatArgumentValue SourceValue
323
+ // uint32 bHasFormatOptions ← legacy UE3-style bool (4 bytes, not 1)
324
+ // [FNumberFormattingOptions FormatOptions]
325
+ // uint32 bHasCulture ← same; uint32 not uint8
326
+ // [FString TargetCulture]
327
+ //
328
+ // Inside FNumberFormattingOptions, AlwaysSign and UseGrouping are also
329
+ // uint32 booleans. Only RoundingMode (int8) and the four digit-count
330
+ // fields (int32) follow the modern sizes. This matches the actual wire
331
+ // bytes — empirically MaxIntDigits = ~324 (close to DBL_MAX_10_EXP+1
332
+ // = 309) and MaxFracDigits = 3 (UE default) under this interpretation.
333
+ const argType = cursor.readInt8();
334
+ let argValue;
335
+ switch (argType) {
336
+ case 0: argValue = cursor.readInt64().toString(); break;
337
+ case 1: argValue = cursor.readUint64().toString(); break;
338
+ case 2: argValue = cursor.readFloat32(); break;
339
+ case 3: argValue = cursor.readFloat64(); break;
340
+ case 4: argValue = readFText(cursor, Infinity); break;
341
+ case 5: argValue = cursor.readInt64().toString(); break;
342
+ default: throw new Error(`readFText: unknown FFormatArgumentValue type ${argType} in AsNumber`);
343
+ }
344
+ const sourceValue = { type: argType, value: argValue };
345
+ const bHasFormatOptions = cursor.readUint32();
346
+ let formatOptions = null;
347
+ if (bHasFormatOptions) {
348
+ formatOptions = {
349
+ alwaysSign: cursor.readUint32(),
350
+ useGrouping: cursor.readUint32(),
351
+ roundingMode: cursor.readInt8(),
352
+ minIntDigits: cursor.readInt32(),
353
+ maxIntDigits: cursor.readInt32(),
354
+ minFracDigits: cursor.readInt32(),
355
+ maxFracDigits: cursor.readInt32(),
356
+ };
357
+ }
358
+ const bHasCulture = cursor.readUint32();
359
+ let culture = null, cultureIsNull = false;
360
+ if (bHasCulture) {
361
+ const cFS = cursor.readFString();
362
+ culture = cFS.value;
363
+ cultureIsNull = cFS.isNull;
364
+ }
365
+ return new FTextValue({ flags, historyType: 4, sourceValue, formatOptions, culture, cultureIsNull });
366
+ }
367
+ // Unknown history type: preserve remaining bytes verbatim for round-trip.
368
+ // 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
370
+ // whether to fall back to OpaqueValue at the element or array level.
371
+ if (!isFinite(sizeHint)) throw new Error(`readFText: unimplemented historyType ${historyType} (no size budget; cannot store raw bytes)`);
372
+ const remaining = sizeHint - (cursor.pos() - start);
373
+ const raw = remaining > 0 ? cursor.readBytes(remaining).slice() : new Uint8Array(0);
374
+ return new FTextValue({ flags, historyType, _raw: raw });
375
+ } catch (e) {
376
+ // When sizeHint is Infinity we cannot capture a byte-safe OpaqueValue;
377
+ // rethrow so the nearest finite-budget caller (readValue's array/map catch)
378
+ // handles it. A caller with a finite budget does cursor.seek(start) + OpaqueValue.
379
+ if (!isFinite(sizeHint)) throw e;
380
+ cursor.seek(start);
381
+ return new OpaqueValue(cursor.readBytes(sizeHint).slice(), `TextProperty decode failed: ${e.message}`);
382
+ }
383
+ }
384
+
385
+ function writeFText(writer, value) {
386
+ writer.writeUint32(value.flags);
387
+ writer.writeInt8(value.historyType);
388
+ if (value.historyType === -1) {
389
+ const has = value.displayString != null ? 1 : 0;
390
+ writer.writeInt32(has);
391
+ if (has) writer.writeFString(value.displayString, null, value._displayStringIsNull);
392
+ } else if (value.historyType === 0) {
393
+ writer.writeFString(value.namespace ?? '', null, value._namespaceIsNull);
394
+ writer.writeFString(value.key ?? '', null, value._keyIsNull);
395
+ writer.writeFString(value.sourceString ?? '', null, value._sourceStringIsNull);
396
+ } else if (value.historyType === 2) {
397
+ writeFText(writer, value.sourceFmt);
398
+ writer.writeInt32(value.arguments.length);
399
+ for (const arg of value.arguments) {
400
+ writer.writeInt8(arg.type);
401
+ switch (arg.type) {
402
+ case 0: writer.writeInt64(arg.value); break;
403
+ case 1: writer.writeUint64(arg.value); break;
404
+ case 2: writer.writeFloat32(arg.value); break;
405
+ case 3: writer.writeFloat64(arg.value); break;
406
+ case 4: writeFText(writer, arg.value); break;
407
+ case 5: writer.writeInt8(arg.value); break;
408
+ default: throw new Error(`writeFText: unknown ArgumentFormat ContentType ${arg.type}`);
409
+ }
410
+ }
411
+ } else if (value.historyType === 4) {
412
+ const sv = value.sourceValue;
413
+ writer.writeInt8(sv.type);
414
+ switch (sv.type) {
415
+ case 0: writer.writeInt64(sv.value); break;
416
+ case 1: writer.writeUint64(sv.value); break;
417
+ case 2: writer.writeFloat32(sv.value); break;
418
+ case 3: writer.writeFloat64(sv.value); break;
419
+ case 4: writeFText(writer, sv.value); break;
420
+ case 5: writer.writeInt64(sv.value); break;
421
+ default: throw new Error(`writeFText: unknown FFormatArgumentValue type ${sv.type} in AsNumber`);
422
+ }
423
+ // Legacy uint32 booleans — see readFText AsNumber for rationale.
424
+ const hasFormatOptions = value.formatOptions != null;
425
+ writer.writeUint32(hasFormatOptions ? 1 : 0);
426
+ if (hasFormatOptions) {
427
+ const f = value.formatOptions;
428
+ writer.writeUint32(f.alwaysSign);
429
+ writer.writeUint32(f.useGrouping);
430
+ writer.writeInt8(f.roundingMode);
431
+ writer.writeInt32(f.minIntDigits);
432
+ writer.writeInt32(f.maxIntDigits);
433
+ writer.writeInt32(f.minFracDigits);
434
+ writer.writeInt32(f.maxFracDigits);
435
+ }
436
+ const hasCulture = value.culture != null;
437
+ writer.writeUint32(hasCulture ? 1 : 0);
438
+ if (hasCulture) writer.writeFString(value.culture, null, value._cultureIsNull);
439
+ } else {
440
+ if (value._raw) writer.writeBytes(value._raw);
441
+ }
442
+ }
443
+
444
+ // -------- ObjectProperty (top-level shape) --------
445
+ //
446
+ // Soulmask ObjectProperty values vary in shape based on the tag's size budget:
447
+ //
448
+ // tag.size = 1 → just the kind byte. No path/classPath FString
449
+ // on the wire at all.
450
+ // tag.size > 1, no embedded → kind + path FString. classPath FString may
451
+ // also be present if the budget extends past
452
+ // the path.
453
+ // tag.size encloses an FString
454
+ // property stream → kind + path + classPath + nested stream
455
+ // terminated by None.
456
+ //
457
+ // The presence/absence of each FString matters for byte-identical round-trip:
458
+ // `path: null` means "not on the wire", whereas `path: ''` (with isNull flag)
459
+ // preserves the wire's choice between null-form (SaveNum=0, 4 bytes) and
460
+ // empty-with-terminator (SaveNum=1 plus 1-byte NUL, 5 bytes). The previous
461
+ // version always wrote `writeFString(this.path)` for ObjectRef and emitted
462
+ // a 4-byte null FString even for kind-only values — silently inflating the
463
+ // encoded blob by 4 B for every kind-only reference.
464
+ function readObjectValue(cursor, sizeHint) {
465
+ const start = cursor.pos();
466
+ try {
467
+ const kind = cursor.readUint8();
468
+ // sizeHint=1 → value is just the kind byte (null/bare reference).
469
+ if (cursor.pos() - start >= sizeHint) {
470
+ return new ObjectRef({ kind });
471
+ }
472
+ // Soulmask kind=0x01 (hard actor reference, e.g. HBindBGCompActor on
473
+ // NPC pawns) prepends a 4-byte field between the kind byte and the
474
+ // path FString. Observed value is always 1; semantic unknown — captured
475
+ // verbatim and replayed on write. Without this branch the reader treats
476
+ // those four bytes as the path FString's SaveNum, which overshoots the
477
+ // budget and falls back to OpaqueValue (the symptom that hid every
478
+ // pawn→inventory link from ReferencesService).
479
+ let kindOnePrefix = null;
480
+ if (kind === 0x01) {
481
+ kindOnePrefix = cursor.readUint32();
482
+ if (cursor.pos() - start >= sizeHint) {
483
+ return new ObjectRef({ kind, kindOnePrefix });
484
+ }
485
+ }
486
+ const pathFS = cursor.readFString();
487
+ // Guard against path FStrings whose SaveNum overshoots the value budget —
488
+ // this happens for properties whose format differs from kind+path+... and
489
+ // whose first "path" bytes happen to encode a huge length.
490
+ if (cursor.pos() - start > sizeHint) throw new Error('path FString exceeded value budget');
491
+ if (cursor.pos() - start >= sizeHint) {
492
+ return new ObjectRef({ kind, kindOnePrefix, path: pathFS.value, pathIsNull: pathFS.isNull });
493
+ }
494
+ const classPathFS = cursor.readFString();
495
+ if (cursor.pos() - start > sizeHint) throw new Error('classPath FString exceeded value budget');
496
+ if (cursor.pos() - start >= sizeHint) {
497
+ return new ObjectRef({ kind, kindOnePrefix,
498
+ path: pathFS.value, pathIsNull: pathFS.isNull,
499
+ classPath: classPathFS.value, classPathIsNull: classPathFS.isNull });
500
+ }
501
+ const stream = readPropertyStream(cursor, start + sizeHint);
502
+ // Some Soulmask embedded streams (e.g. JianZhuInstGLQComponent) use the
503
+ // outermost-stream None trailer (4-byte FName.Number). Skip it when
504
+ // exactly 4 bytes remain within the tag's size budget, and record that
505
+ // it was there so the writer can replay it for byte-identical round-trip.
506
+ let hasTerminatorTrailer = false;
507
+ if (stream.terminated && cursor.pos() + 4 === start + sizeHint && cursor.remaining() >= 4) {
508
+ cursor.skip(4);
509
+ hasTerminatorTrailer = true;
510
+ }
511
+ return new ObjectRef({ kind, kindOnePrefix,
512
+ path: pathFS.value, pathIsNull: pathFS.isNull,
513
+ classPath: classPathFS.value, classPathIsNull: classPathFS.isNull,
514
+ embedded: stream.properties, terminated: stream.terminated, hasTerminatorTrailer });
515
+ } catch (e) {
516
+ cursor.seek(start);
517
+ return new OpaqueValue(cursor.readBytes(sizeHint).slice(), `ObjectProperty decode failed: ${e.message}`);
518
+ }
519
+ }
520
+
521
+ function writeObjectValue(writer, value) {
522
+ if (value instanceof ObjectRef) { value.write(writer); return; }
523
+ // Bare-string fallback: write kind byte + path only.
524
+ writer.writeUint8(0x03);
525
+ writer.writeFString(value ?? '');
526
+ }
527
+
528
+ // -------- Array / Set / Map --------
529
+ function readArrayValue(cursor, tag, sizeHint) {
530
+ const startOff = cursor.pos();
531
+ const numElements = cursor.readInt32();
532
+ const innerType = tag.innerType.value;
533
+ const elements = [];
534
+
535
+ if (innerType === 'StructProperty') {
536
+ const innerTag = PropertyTag.read(cursor);
537
+ if (innerTag.isTerminator || innerTag.type.value !== 'StructProperty') {
538
+ throw new Error(`readArrayValue: expected StructProperty inner tag, got ${innerTag.type?.value}`);
539
+ }
540
+ const structName = innerTag.structName.value;
541
+ const handler = STRUCT_HANDLERS[structName];
542
+ if (handler) {
543
+ for (let i = 0; i < numElements; i++) elements.push(new StructValue(structName, { value: handler.read(cursor) }));
544
+ } else {
545
+ for (let i = 0; i < numElements; i++) {
546
+ const stream = readPropertyStream(cursor, startOff + sizeHint);
547
+ elements.push(new StructValue(structName, { value: stream.properties, terminated: stream.terminated }));
548
+ }
549
+ }
550
+ return new ArrayValue({ elements, innerTag });
551
+ }
552
+
553
+ // ObjectProperty elements have variable wire shapes (kind-only, +path,
554
+ // +path+classPath, +full embedded stream) and no per-element delimiter.
555
+ // We give each element the FULL remaining array budget and let the
556
+ // field-presence heuristics in readArrayElement (saveNum magnitude,
557
+ // "/"-prefix on classPath, identifier-start on embedded-stream first
558
+ // byte) decide where to stop. Each element terminates at:
559
+ // - kind-only (kind=0 early-out)
560
+ // - end of path FString if no classPath byte pattern follows
561
+ // - end of classPath FString if no embedded property-tag pattern follows
562
+ // - the None terminator (+ optional 4-byte FName.Number trailer)
563
+ //
564
+ // Soulmask JianZhuInstYuanXings layout (the building-zone yuan-xing array)
565
+ // interleaves placement-binary AFTER each element: every kind=3 yuan-xing
566
+ // is followed by 8 zero bytes + 3 stride/count sections describing the
567
+ // placed-piece transforms, ids and aux data for THAT prototype. See
568
+ // tryReadObjectArrayPerElementBlock for the format and detection logic.
569
+ const isObj = isObjectInnerType(innerType);
570
+ const endOff = startOff + sizeHint;
571
+ const perElementTrailings = [];
572
+ let anyPerElementTrailing = false;
573
+ for (let i = 0; i < numElements; i++) {
574
+ let elemSizeHint = Infinity;
575
+ if (isObj) elemSizeHint = endOff - cursor.pos();
576
+ elements.push(readArrayElement(cursor, innerType, elemSizeHint));
577
+ if (isObj) {
578
+ const t = tryReadObjectArrayPerElementBlock(cursor, endOff);
579
+ if (t) anyPerElementTrailing = true;
580
+ perElementTrailings.push(t); // null when no per-element trailing for this element
581
+ }
582
+ }
583
+
584
+ return new ArrayValue({
585
+ elements,
586
+ perElementTrailings: anyPerElementTrailing ? perElementTrailings : null,
587
+ });
588
+ }
589
+
590
+ /**
591
+ * Per-element placement-binary block for ArrayProperty<ObjectProperty> in
592
+ * JianZhuInstYuanXings (Soulmask building-zone yuan-xing arrays). Each kind=3
593
+ * yuan-xing element is followed by a fixed-shape block:
594
+ *
595
+ * [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)
599
+ *
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.
603
+ *
604
+ * 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.
609
+ */
610
+ function tryReadObjectArrayPerElementBlock(cursor, endOff) {
611
+ const start = cursor.pos();
612
+ // Minimum 8B header + 3 × 8B section header = 32B (zero-count is allowed).
613
+ if (endOff - start < 32) return null;
614
+ for (let i = 0; i < 8; i++) {
615
+ if (cursor.bytes[start + i] !== 0) return null;
616
+ }
617
+ // Section 0's stride must be 64. Used as the disambiguating signature.
618
+ if (cursor.dv.getUint32(start + 8, true) !== 64) return null;
619
+
620
+ try {
621
+ cursor.skip(8);
622
+ const header = cursor.bytes.subarray(start, start + 8).slice();
623
+ const sections = [];
624
+ const expected = [64, 4, 64];
625
+ for (let i = 0; i < 3; i++) {
626
+ if (endOff - cursor.pos() < 8) throw new Error(`section ${i} header overruns budget`);
627
+ const stride = cursor.readUint32();
628
+ const count = cursor.readUint32();
629
+ if (stride !== expected[i]) throw new Error(`section ${i} stride ${stride} != ${expected[i]}`);
630
+ if (count > 1_000_000) throw new Error(`implausible count ${count}`);
631
+ const dataBytes = stride * count;
632
+ if (cursor.pos() + dataBytes > endOff) throw new Error(`section ${i} data overruns budget`);
633
+ sections.push({ stride, count, data: cursor.readBytes(dataBytes).slice() });
634
+ }
635
+ return { header, sections };
636
+ } catch {
637
+ cursor.seek(start);
638
+ return null;
639
+ }
640
+ }
641
+
642
+ 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);
648
+ }
649
+ }
650
+
651
+ function isObjectInnerType(t) {
652
+ return t === 'ObjectProperty' || t === 'ClassProperty'
653
+ || t === 'WeakObjectProperty' || t === 'LazyObjectProperty'
654
+ || t === 'WSObjectProperty';
655
+ }
656
+
657
+ function writeArrayValue(writer, tag, value) {
658
+ const innerType = tag.innerType.value;
659
+ writer.writeInt32(value.elements.length);
660
+ if (innerType === 'StructProperty') {
661
+ value._arrayInnerTag.write(writer);
662
+ const structName = value._arrayInnerTag.structName.value;
663
+ 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);
667
+ }
668
+ return;
669
+ }
670
+ const perEl = value._perElementTrailings;
671
+ for (let i = 0; i < value.elements.length; i++) {
672
+ writeArrayElement(writer, innerType, value.elements[i]);
673
+ if (perEl && perEl[i]) writeObjectArrayPerElementBlock(writer, perEl[i]);
674
+ }
675
+ }
676
+
677
+ function readSetValue(cursor, tag) {
678
+ const innerType = tag.innerType.value;
679
+ const numToRemove = cursor.readInt32();
680
+ const removed = [];
681
+ for (let i = 0; i < numToRemove; i++) removed.push(readSetElement(cursor, innerType));
682
+ const numElements = cursor.readInt32();
683
+ const elements = [];
684
+ for (let i = 0; i < numElements; i++) elements.push(readSetElement(cursor, innerType));
685
+ return new SetValue({ removed, elements });
686
+ }
687
+
688
+ // Set elements for StructProperty inner type are raw binary structs with no
689
+ // inner PropertyTag wrapper (unlike ArrayProperty<StructProperty>, which does
690
+ // have one). Every observed Set<StructProperty> in world.db uses 16-byte Guids
691
+ // as elements — the same assumption MapProperty makes for Struct keys.
692
+ function readSetElement(cursor, innerType) {
693
+ if (innerType === 'StructProperty') return FGuid.read(cursor).value;
694
+ return readArrayElement(cursor, innerType);
695
+ }
696
+
697
+ function writeSetValue(writer, tag, value) {
698
+ const innerType = tag.innerType.value;
699
+ writer.writeInt32(value.removed.length);
700
+ for (const v of value.removed) writeSetElement(writer, innerType, v);
701
+ writer.writeInt32(value.elements.length);
702
+ for (const v of value.elements) writeSetElement(writer, innerType, v);
703
+ }
704
+
705
+ function writeSetElement(writer, innerType, value) {
706
+ if (innerType === 'StructProperty') { new FGuid(value).write(writer); return; }
707
+ writeArrayElement(writer, innerType, value);
708
+ }
709
+
710
+ function readMapValue(cursor, tag) {
711
+ const keyType = tag.innerType.value;
712
+ const valType = tag.valueType.value;
713
+ const numKeysToRemove = cursor.readInt32();
714
+ const removed = [];
715
+ for (let i = 0; i < numKeysToRemove; i++) removed.push(readMapElement(cursor, keyType, /*isKey=*/true));
716
+ const numElements = cursor.readInt32();
717
+ const entries = [];
718
+ for (let i = 0; i < numElements; i++) {
719
+ const key = readMapElement(cursor, keyType, /*isKey=*/true);
720
+ const val = readMapElement(cursor, valType, /*isKey=*/false);
721
+ entries.push({ key, value: val });
722
+ }
723
+ return new MapValue({ removed, entries });
724
+ }
725
+
726
+ function writeMapValue(writer, tag, value) {
727
+ const keyType = tag.innerType.value;
728
+ const valType = tag.valueType.value;
729
+ writer.writeInt32(value.removed.length);
730
+ for (const k of value.removed) writeMapElement(writer, keyType, k, /*isKey=*/true);
731
+ writer.writeInt32(value.entries.length);
732
+ for (const e of value.entries) {
733
+ writeMapElement(writer, keyType, e.key, /*isKey=*/true);
734
+ writeMapElement(writer, valType, e.value, /*isKey=*/false);
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Map element (one key or one value) when the map's inner/value type is
740
+ * StructProperty — Soulmask uses several conventions that diverge from
741
+ * stock UE 4.27 here:
742
+ *
743
+ * Key (StructProperty) → a raw 16-byte FGuid. The map tag declares
744
+ * no struct shape; every populated Map<Struct,_>
745
+ * we've observed in world.db (the guild
746
+ * manager maps in GAMEMODE) uses guids as
747
+ * keys. Other key shapes would need this
748
+ * assumption revisited.
749
+ * Value (StructProperty) → EITHER a nested property stream
750
+ * (`GongHuiMap`, `PlayerGongHuiDataMap`,
751
+ * `GeRenJianZhuYingHuoList`, `GeRenMapRiZhi`)
752
+ * OR a raw 16-byte FGuid (`PlayerGongHuiMap`,
753
+ * a player→guild membership lookup).
754
+ * We sniff which by peeking ahead — a
755
+ * property stream starts with an FString
756
+ * length prefix for the first tag's name
757
+ * (small positive int, body is identifier
758
+ * chars + NUL); a Guid's first 4 bytes
759
+ * are arbitrary hex and almost never satisfy
760
+ * that pattern.
761
+ *
762
+ * For non-struct inner/value types we delegate to readArrayElement /
763
+ * writeArrayElement (array and set elements share the same wire shape
764
+ * as map keys/values for those types).
765
+ *
766
+ * Note: for these custom Soulmask maps the MapProperty's tag.size does
767
+ * NOT match the actual byte span of the data section (observed:
768
+ * tag.size=632838, actual=636422 for a populated GongHuiMap). The
769
+ * 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.
771
+ */
772
+ function readMapElement(cursor, type, isKey) {
773
+ if (type !== 'StructProperty') return readArrayElement(cursor, type);
774
+ if (isKey) return FGuid.read(cursor).value;
775
+ if (peekLooksLikePropertyTag(cursor)) {
776
+ const stream = readPropertyStream(cursor);
777
+ return new StructValue('(map value)', { value: stream.properties, terminated: stream.terminated });
778
+ }
779
+ return FGuid.read(cursor).value;
780
+ }
781
+
782
+ function writeMapElement(writer, type, value, isKey) {
783
+ if (type !== 'StructProperty') { writeArrayElement(writer, type, value); return; }
784
+ if (isKey) { new FGuid(value).write(writer); return; }
785
+ // Distinguish on the decoded value's shape:
786
+ // StructValue → property stream (write tags + None)
787
+ // string → 16-byte Guid
788
+ if (value instanceof StructValue && Array.isArray(value.value)) {
789
+ writePropertyStream(writer, value.value, false);
790
+ return;
791
+ }
792
+ if (typeof value === 'string') {
793
+ new FGuid(value).write(writer);
794
+ return;
795
+ }
796
+ throw new Error('writeMapElement: unexpected StructProperty map value shape');
797
+ }
798
+
799
+ /**
800
+ * 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?
802
+ *
803
+ * A property name FString is:
804
+ * - int32 SaveNum > 0 and reasonably small (<= 64 chars in Soulmask)
805
+ * - SaveNum bytes of ANSI body whose last byte is NUL
806
+ * - body chars (minus NUL) are identifier-safe: A-Z, a-z, 0-9, _.
807
+ *
808
+ * Random GUID bytes effectively never satisfy this — the first uint32
809
+ * of a Guid is ~uniform over [0..2^32), and even when it lands in a
810
+ * "plausible length" range the printable-ASCII + NUL-terminator check
811
+ * eliminates the false positives.
812
+ */
813
+ function peekLooksLikePropertyTag(cursor) {
814
+ if (cursor.remaining() < 8) return false;
815
+ const off = cursor.pos();
816
+ const len = cursor.dv.getInt32(off, true);
817
+ if (len <= 1 || len > 64) return false;
818
+ if (cursor.remaining() < 4 + len) return false;
819
+ if (cursor.bytes[off + 4 + len - 1] !== 0) return false; // NUL terminator
820
+ for (let i = 0; i < len - 1; i++) {
821
+ const b = cursor.bytes[off + 4 + i];
822
+ const ok = b === 0x5F // _
823
+ || (b >= 0x30 && b <= 0x39) // 0-9
824
+ || (b >= 0x41 && b <= 0x5A) // A-Z
825
+ || (b >= 0x61 && b <= 0x7A); // a-z
826
+ if (!ok) return false;
827
+ }
828
+ return true;
829
+ }
830
+
831
+ // Read/write one array OR set element of a non-struct inner type.
832
+ // (No per-element FPropertyTag wrapper for these inner types.)
833
+ //
834
+ // `sizeHint` is the per-element byte budget; only ObjectProperty-family
835
+ // inner types need it, since their wire shape is variable (kind-only,
836
+ // kind+path, kind+path+classPath, or full kind+path+classPath+embedded).
837
+ // Other inner types have fixed sizes determined by the type itself, so
838
+ // they ignore the hint.
839
+ function readArrayElement(cursor, innerType, sizeHint = Infinity) {
840
+ switch (innerType) {
841
+ case 'IntProperty': return cursor.readInt32();
842
+ case 'Int8Property': return cursor.readInt8();
843
+ case 'Int16Property': return cursor.readInt16();
844
+ case 'Int64Property': return cursor.readInt64().toString();
845
+ case 'UInt16Property': return cursor.readUint16();
846
+ case 'UInt32Property': return cursor.readUint32();
847
+ case 'UInt64Property': return cursor.readUint64().toString();
848
+ case 'FloatProperty': return cursor.readFloat32();
849
+ case 'DoubleProperty': return cursor.readFloat64();
850
+ case 'BoolProperty': return cursor.readUint8() !== 0;
851
+ case 'ByteProperty': return cursor.readUint8();
852
+ case 'EnumProperty': return FName.read(cursor);
853
+ case 'NameProperty': return FName.read(cursor);
854
+ case 'StrProperty': return cursor.readFString().value;
855
+ case 'TextProperty':
856
+ // TextProperty elements are self-delimiting (FText is a self-describing
857
+ // format); sizeHint is passed as Infinity since per-element size isn't
858
+ // available. HistoryType -1, 0, and 2 all read cleanly without it.
859
+ return readFText(cursor, Infinity);
860
+ case 'ObjectProperty':
861
+ case 'ClassProperty':
862
+ case 'WeakObjectProperty':
863
+ case 'LazyObjectProperty':
864
+ case 'WSObjectProperty': {
865
+ // Bounded read, mirroring readObjectValue. The variable wire shapes
866
+ // (kind-only, +path, +path+classPath, +embedded) are disambiguated by
867
+ // sizeHint — without the bound we'd read past the element into the
868
+ // next property's tag, which causes catastrophic cascade failures
869
+ // (cf. ChengHaoList in serial 92 and friends). Capture per-FString
870
+ // isNull flags for byte-identical round-trip of empty wire-FStrings.
871
+ // Soulmask kind=0x01 carries a 4-byte prefix (see readObjectValue).
872
+ const start = cursor.pos();
873
+ const kind = cursor.readUint8();
874
+ if (cursor.pos() - start >= sizeHint) {
875
+ return new ObjectRef({ kind });
876
+ }
877
+ // kind=0 is a null/None object reference: the wire payload is just the
878
+ // kind byte with no path, classPath, or embedded stream. Without this
879
+ // early-out, the FString reader would interpret the next 4 bytes (which
880
+ // belong to either the next element or the trailing binary section)
881
+ // as a path saveNum — typically a huge garbage value that overshoots
882
+ // the array. Seen in JianZhuInstYuanXings, ZhuangBeiLanDaoJuJiYiList,
883
+ // and KuaiJieLanDaoJuJiYiList trailing slots.
884
+ if (kind === 0) {
885
+ return new ObjectRef({ kind });
886
+ }
887
+ let kindOnePrefix = null;
888
+ if (kind === 0x01) {
889
+ kindOnePrefix = cursor.readUint32();
890
+ if (cursor.pos() - start >= sizeHint) {
891
+ return new ObjectRef({ kind, kindOnePrefix });
892
+ }
893
+ }
894
+ const pathFS = cursor.readFString();
895
+ if (cursor.pos() - start > sizeHint) throw new Error('path FString exceeded array-element budget');
896
+ {
897
+ const consumed = cursor.pos() - start;
898
+ const remainingBudget = sizeHint - consumed;
899
+ // Guard 1: no room for even a null-form classPath FString (4 bytes).
900
+ // This catches floor() rounding error in the equal-split heuristic
901
+ // (e.g. budget=86 but actual element size=85).
902
+ if (consumed >= sizeHint || remainingBudget < 4) {
903
+ return new ObjectRef({ kind, kindOnePrefix, path: pathFS.value, pathIsNull: pathFS.isNull });
904
+ }
905
+ // Guard 2: peek the candidate classPath SaveNum. If it's outside the
906
+ // range of any plausible path string (≤ 1024 chars, or a negative
907
+ // UTF-16 length in the same range), the budget was inflated by a large
908
+ // trailing binary section (e.g. JianZhuInstYuanXings inside
909
+ // JianZhuInstGLQComponent.embedded) and there is no classPath.
910
+ const peekSN = cursor.dv.getInt32(cursor.pos(), true);
911
+ if (peekSN > 1024 || peekSN < -1024) {
912
+ return new ObjectRef({ kind, kindOnePrefix, path: pathFS.value, pathIsNull: pathFS.isNull });
913
+ }
914
+ // Guard 3: in Soulmask, classPath is always an asset path of the form
915
+ // "/Script/Module.Class" or "/Game/...". The first content character
916
+ // is therefore "/" (0x2F). When the bytes after path are actually the
917
+ // start of the NEXT element (kind byte + optional kindOnePrefix +
918
+ // path saveNum), peekSN can fall in the [-1024, 1024] range — e.g.
919
+ // bytes `01 01 00 00` from a kind=1 element with kindOnePrefix=1 read
920
+ // as int32 = 257. The previous saveNum-magnitude guard misses this;
921
+ // checking the first content byte for '/' catches it cleanly. Allow
922
+ // empty/null forms (saveNum ∈ {-1, 0, 1}) as edge cases.
923
+ if (peekSN !== 0 && peekSN !== 1 && peekSN !== -1) {
924
+ const firstCharOff = cursor.pos() + 4;
925
+ if (peekSN > 0) {
926
+ // ANSI classPath: first content byte should be '/'
927
+ if (firstCharOff >= cursor.bytes.length || cursor.bytes[firstCharOff] !== 0x2F) {
928
+ return new ObjectRef({ kind, kindOnePrefix, path: pathFS.value, pathIsNull: pathFS.isNull });
929
+ }
930
+ } else {
931
+ // Unicode classPath: first content code unit should be '/\0' (2F 00 LE)
932
+ if (firstCharOff + 1 >= cursor.bytes.length ||
933
+ cursor.bytes[firstCharOff] !== 0x2F ||
934
+ cursor.bytes[firstCharOff + 1] !== 0x00) {
935
+ return new ObjectRef({ kind, kindOnePrefix, path: pathFS.value, pathIsNull: pathFS.isNull });
936
+ }
937
+ }
938
+ }
939
+ }
940
+ const classPathFS = cursor.readFString();
941
+ if (cursor.pos() - start > sizeHint) throw new Error('classPath FString exceeded array-element budget');
942
+ if (cursor.pos() - start >= sizeHint) {
943
+ return new ObjectRef({
944
+ kind, kindOnePrefix,
945
+ path: pathFS.value, pathIsNull: pathFS.isNull,
946
+ classPath: classPathFS.value, classPathIsNull: classPathFS.isNull,
947
+ });
948
+ }
949
+ // Guard 4: embedded-stream presence. Same problem as the classPath
950
+ // guards but one level deeper — when the element has classPath but
951
+ // NO embedded stream, the bytes that follow classPath are the start
952
+ // of the NEXT element (kind byte) or the trailing binary section
953
+ // (12 zero bytes of origin). An embedded stream begins with a
954
+ // PropertyTag, which starts with the property name FString. Property
955
+ // names are short identifiers (≤256 chars) starting with a letter or
956
+ // underscore, so we peek the candidate name saveNum and first content
957
+ // byte to disambiguate.
958
+ if (sizeHint - (cursor.pos() - start) >= 4) {
959
+ const peekNameSN = cursor.dv.getInt32(cursor.pos(), true);
960
+ if (peekNameSN <= 0 || peekNameSN > 256) {
961
+ return new ObjectRef({
962
+ kind, kindOnePrefix,
963
+ path: pathFS.value, pathIsNull: pathFS.isNull,
964
+ classPath: classPathFS.value, classPathIsNull: classPathFS.isNull,
965
+ });
966
+ }
967
+ const firstNameByte = cursor.bytes[cursor.pos() + 4];
968
+ const isIdentStart = firstNameByte != null && (
969
+ (firstNameByte >= 0x41 && firstNameByte <= 0x5A) || // A-Z
970
+ (firstNameByte >= 0x61 && firstNameByte <= 0x7A) || // a-z
971
+ firstNameByte === 0x5F // _
972
+ );
973
+ if (!isIdentStart) {
974
+ return new ObjectRef({
975
+ kind, kindOnePrefix,
976
+ path: pathFS.value, pathIsNull: pathFS.isNull,
977
+ classPath: classPathFS.value, classPathIsNull: classPathFS.isNull,
978
+ });
979
+ }
980
+ }
981
+ const stream = readPropertyStream(cursor, start + sizeHint);
982
+ // Trailer detection: some Soulmask embedded streams (notably the inner
983
+ // ObjectProperty streams in JianZhuInstYuanXings) carry the outermost-
984
+ // stream 4-byte FName.Number trailer after their None terminator. In the
985
+ // top-level readObjectValue this is detected by "exactly 4 bytes left in
986
+ // budget"; here in array elements the budget is generous (= remaining
987
+ // array bytes) so we detect via content instead: a trailer is typically
988
+ // 0x00000000 (FName.Number=0), and what follows is either the next
989
+ // element's kind byte (0x01/0x03, non-zero) or the trailing binary
990
+ // section's origin (12 zero bytes, also distinctive). If the next 4
991
+ // bytes are all zero AND there's at least one more byte we can consume
992
+ // them as the trailer for round-trip fidelity.
993
+ let hasTerminatorTrailer = false;
994
+ if (stream.terminated && cursor.pos() + 4 <= start + sizeHint && cursor.remaining() >= 4) {
995
+ const peekTrailer = cursor.dv.getInt32(cursor.pos(), true);
996
+ if (peekTrailer === 0) {
997
+ cursor.skip(4);
998
+ hasTerminatorTrailer = true;
999
+ }
1000
+ }
1001
+ return new ObjectRef({
1002
+ kind, kindOnePrefix,
1003
+ path: pathFS.value, pathIsNull: pathFS.isNull,
1004
+ classPath: classPathFS.value, classPathIsNull: classPathFS.isNull,
1005
+ embedded: stream.properties, terminated: stream.terminated, hasTerminatorTrailer,
1006
+ });
1007
+ }
1008
+ case 'SoftObjectProperty':
1009
+ case 'SoftClassProperty':
1010
+ return new SoftObjectRef({ assetPath: cursor.readFString().value, subPath: cursor.readFString().value });
1011
+ default:
1012
+ throw new Error(`readArrayElement: unsupported innerType '${innerType}'`);
1013
+ }
1014
+ }
1015
+
1016
+ function writeArrayElement(writer, innerType, value) {
1017
+ switch (innerType) {
1018
+ case 'IntProperty': writer.writeInt32(value); return;
1019
+ case 'Int8Property': writer.writeInt8(value); return;
1020
+ case 'Int16Property': writer.writeInt16(value); return;
1021
+ case 'Int64Property': writer.writeInt64(value); return;
1022
+ case 'UInt16Property': writer.writeUint16(value); return;
1023
+ case 'UInt32Property': writer.writeUint32(value); return;
1024
+ case 'UInt64Property': writer.writeUint64(value); return;
1025
+ case 'FloatProperty': writer.writeFloat32(value); return;
1026
+ case 'DoubleProperty': writer.writeFloat64(value); return;
1027
+ case 'BoolProperty': writer.writeUint8(value ? 1 : 0); return;
1028
+ case 'ByteProperty': writer.writeUint8(value); return;
1029
+ case 'EnumProperty':
1030
+ case 'NameProperty': FName.from(value).write(writer); return;
1031
+ case 'StrProperty': writer.writeFString(value); return;
1032
+ case 'TextProperty':
1033
+ if (value instanceof FTextValue) { writeFText(writer, value); return; }
1034
+ value.write(writer); return; // OpaqueValue fallback
1035
+ case 'ObjectProperty':
1036
+ case 'ClassProperty':
1037
+ case 'WeakObjectProperty':
1038
+ case 'LazyObjectProperty':
1039
+ case 'WSObjectProperty':
1040
+ // Variable wire shape (kind-only, +path, +path+classPath, +embedded).
1041
+ // ObjectRef.write decides per-field which to emit based on which
1042
+ // fields were on the wire at read time (path === null means absent).
1043
+ if (value instanceof ObjectRef) { value.write(writer); return; }
1044
+ // Bare-string fallback for array-of-ObjectProperty.
1045
+ writer.writeUint8(0x03);
1046
+ writer.writeFString(value ?? '');
1047
+ return;
1048
+ case 'SoftObjectProperty':
1049
+ case 'SoftClassProperty':
1050
+ (value instanceof SoftObjectRef ? value : new SoftObjectRef(value)).write(writer);
1051
+ return;
1052
+ default:
1053
+ throw new Error(`writeArrayElement: unsupported innerType '${innerType}'`);
1054
+ }
1055
+ }
1056
+
1057
+ // ==========================================================================
1058
+ // Property stream
1059
+ // ==========================================================================
1060
+ /**
1061
+ * Read property tags until either a "None" terminator or `endOffset` is
1062
+ * reached. `consumeTerminatorTrailer` is for the outermost stream only.
1063
+ */
1064
+ export function readPropertyStream(cursor, endOffset = Infinity, consumeTerminatorTrailer = false) {
1065
+ const properties = [];
1066
+ while (cursor.pos() < endOffset && !cursor.eof()) {
1067
+ const tag = PropertyTag.read(cursor);
1068
+ if (tag.isTerminator) {
1069
+ if (consumeTerminatorTrailer && cursor.pos() + 4 <= endOffset && cursor.remaining() >= 4) {
1070
+ cursor.skip(4);
1071
+ }
1072
+ return { properties, terminated: true, endPos: cursor.pos() };
1073
+ }
1074
+ const valueStart = cursor.pos();
1075
+ const value = readValue(cursor, tag, tag.size);
1076
+ const valueEnd = cursor.pos();
1077
+ const actualSize = valueEnd - valueStart;
1078
+ let sizeMismatch = null;
1079
+ if (actualSize !== tag.size) {
1080
+ // Reader disagreed with the tag's claimed Size. Trust the tag and
1081
+ // capture the discrepancy so the encoder can warn.
1082
+ sizeMismatch = { expected: tag.size, actual: actualSize };
1083
+ cursor.seek(valueStart + tag.size);
1084
+ }
1085
+ properties.push(new Property(tag, value, { sizeMismatch }));
1086
+ }
1087
+ return { properties, terminated: false, endPos: cursor.pos() };
1088
+ }
1089
+
1090
+ export function writePropertyStream(writer, properties, emitTerminatorTrailer = false) {
1091
+ for (const p of properties) {
1092
+ if (p._sizeMismatch) {
1093
+ throw new Error(`writePropertyStream: property '${p.name}' has _sizeMismatch (${JSON.stringify(p._sizeMismatch)}); cannot safely re-emit`);
1094
+ }
1095
+ p.tag.write(writer);
1096
+ writeValue(writer, p.tag, p.value);
1097
+ }
1098
+ new FName('None').write(writer);
1099
+ if (emitTerminatorTrailer) writer.writeInt32(0);
1100
+ }
1101
+
1102
+ // Nested-stream wrapper. Imported by values.mjs (ObjectRef.write,
1103
+ // StructValue.write) to avoid needing a writePropertyStream re-export.
1104
+ // `emitTerminatorTrailer` defaults to false because nested streams in
1105
+ // stock UE 4.27 don't carry the 4-byte FName.Number trailer that the
1106
+ // outermost stream does — but some Soulmask embedded ObjectProperty
1107
+ // streams DO (see ObjectRef.hasTerminatorTrailer / readObjectValue's
1108
+ // trailer-skip detection), so callers can opt in.
1109
+ export function writeNestedPropertyStream(writer, properties, emitTerminatorTrailer = false) {
1110
+ writePropertyStream(writer, properties, emitTerminatorTrailer);
1111
+ }