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/LICENSE +21 -0
- package/README.md +211 -0
- package/io.mjs +150 -0
- package/package.json +39 -0
- package/primitives.mjs +70 -0
- package/properties.mjs +1111 -0
- package/structs.mjs +125 -0
- package/values.mjs +214 -0
- package/wscodec.mjs +156 -0
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
|
+
}
|