wscodec 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +176 -54
- package/io.mjs +68 -5
- package/json.mjs +614 -0
- package/package.json +25 -5
- package/primitives.mjs +68 -5
- package/properties.mjs +247 -49
- package/structs.mjs +39 -6
- package/values.mjs +30 -25
- package/wscodec.mjs +129 -12
package/primitives.mjs
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* FName and FGuid.
|
|
2
|
+
* FName and FGuid: the two pervasive identifier types in UE serialization.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* trailing FName.Number int32).
|
|
6
|
-
*
|
|
4
|
+
* Soulmask's quirk for FName: the property-stream wire form is a plain
|
|
5
|
+
* FString (no trailing FName.Number int32). Stock UE 4.27 serializes FName
|
|
6
|
+
* inside a property tag as FString + int32 Number; Soulmask drops the int32
|
|
7
|
+
* everywhere except the OUTERMOST None terminator (which still carries a
|
|
8
|
+
* 4-byte FName.Number = 0 trailer, handled by the property-stream reader).
|
|
9
|
+
*
|
|
10
|
+
* `FName.read` / instance `write` therefore use the Soulmask form (bare
|
|
11
|
+
* FString). The full UE form is exposed separately as `FName.readWithNumber`
|
|
12
|
+
* / instance `writeWithNumber`: not used by Soulmask today, but wired up so
|
|
13
|
+
* the codec can speak the standard wire format if the game's serializer ever
|
|
14
|
+
* adopts it.
|
|
7
15
|
*/
|
|
8
16
|
|
|
17
|
+
const ZERO_GUID = '00000000-0000-0000-0000-000000000000';
|
|
18
|
+
|
|
9
19
|
export class FName {
|
|
10
20
|
constructor(value, { isUnicode = false, number = 0, isNull = false } = {}) {
|
|
11
21
|
this.value = value;
|
|
12
22
|
this.isUnicode = isUnicode;
|
|
23
|
+
// FName.Number: zero in every observed Soulmask FName (the wire form
|
|
24
|
+
// omits it). Preserved on the instance for round-trip when the full UE
|
|
25
|
+
// form is used via readWithNumber/writeWithNumber.
|
|
13
26
|
this.number = number;
|
|
14
27
|
// Tracks the wire-form distinction between an FString with SaveNum=0
|
|
15
28
|
// (the "null" form) and SaveNum=1 (empty-with-terminator). Only ever
|
|
@@ -18,15 +31,39 @@ export class FName {
|
|
|
18
31
|
this.isNull = isNull;
|
|
19
32
|
}
|
|
20
33
|
toString() { return this.value; }
|
|
34
|
+
/** JSON-friendly form: the bare name string (drops isUnicode/number/isNull metadata). */
|
|
35
|
+
toJSON() { return this.value; }
|
|
21
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Read an FName in the Soulmask property-stream form: a bare FString,
|
|
39
|
+
* no trailing FName.Number. `number` is left at 0.
|
|
40
|
+
*/
|
|
22
41
|
static read(cursor) {
|
|
23
42
|
const s = cursor.readFString();
|
|
24
43
|
return new FName(s.value, { isUnicode: s.isUnicode, isNull: !!s.isNull });
|
|
25
44
|
}
|
|
26
45
|
|
|
46
|
+
/** Write the Soulmask form (FString only). */
|
|
27
47
|
write(writer) { writer.writeFString(this.value, this.isUnicode, this.isNull); }
|
|
28
48
|
|
|
29
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Read an FName in the stock UE 4.27 property-tag form: FString + int32
|
|
51
|
+
* Number. Use this if you're decoding a non-Soulmask stream or a future
|
|
52
|
+
* Soulmask wire format that re-adopts the int32 suffix.
|
|
53
|
+
*/
|
|
54
|
+
static readWithNumber(cursor) {
|
|
55
|
+
const s = cursor.readFString();
|
|
56
|
+
const number = cursor.readInt32();
|
|
57
|
+
return new FName(s.value, { isUnicode: s.isUnicode, isNull: !!s.isNull, number });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Write the stock UE form (FString + int32 Number). */
|
|
61
|
+
writeWithNumber(writer) {
|
|
62
|
+
writer.writeFString(this.value, this.isUnicode, this.isNull);
|
|
63
|
+
writer.writeInt32(this.number | 0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Accepts an FName, a bare string, or a plain {value,isUnicode,isNull,number} record. */
|
|
30
67
|
static from(x) {
|
|
31
68
|
if (x instanceof FName) return x;
|
|
32
69
|
if (typeof x === 'string') return new FName(x);
|
|
@@ -46,6 +83,31 @@ export class FGuid {
|
|
|
46
83
|
constructor(value) { this.value = value; }
|
|
47
84
|
toString() { return this.value; }
|
|
48
85
|
|
|
86
|
+
/**
|
|
87
|
+
* JSON-friendly form: the bare GUID string, so `JSON.stringify(fguid)`
|
|
88
|
+
* yields `"AABBCCDD-..."` rather than `{"value":"AABBCCDD-..."}`.
|
|
89
|
+
*/
|
|
90
|
+
toJSON() { return this.value; }
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Structural equality. Case-insensitive: an FGuid constructed from a
|
|
94
|
+
* lowercase string compares equal to one read off the wire (uppercase).
|
|
95
|
+
* Accepts an FGuid or a string; anything else returns false.
|
|
96
|
+
*/
|
|
97
|
+
equals(other) {
|
|
98
|
+
const otherStr = other instanceof FGuid ? other.value
|
|
99
|
+
: typeof other === 'string' ? other
|
|
100
|
+
: null;
|
|
101
|
+
if (otherStr == null) return false;
|
|
102
|
+
return String(this.value).toUpperCase() === otherStr.toUpperCase();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** True iff the GUID is all zeros (the conventional null-GUID sentinel). */
|
|
106
|
+
isZero() { return String(this.value).toUpperCase() === ZERO_GUID; }
|
|
107
|
+
|
|
108
|
+
/** All-zero FGuid sentinel. New instance per call (FGuid is mutable). */
|
|
109
|
+
static zero() { return new FGuid(ZERO_GUID); }
|
|
110
|
+
|
|
49
111
|
static read(cursor) {
|
|
50
112
|
const A = cursor.readUint32(), B = cursor.readUint32(), C = cursor.readUint32(), D = cursor.readUint32();
|
|
51
113
|
const h = (n, w) => n.toString(16).padStart(w, '0').toUpperCase();
|
|
@@ -62,6 +124,7 @@ export class FGuid {
|
|
|
62
124
|
writer.writeUint32(A); writer.writeUint32(B); writer.writeUint32(C); writer.writeUint32(D);
|
|
63
125
|
}
|
|
64
126
|
|
|
127
|
+
/** Accepts an FGuid or a canonical 8-4-4-4-12 hex string. */
|
|
65
128
|
static from(x) {
|
|
66
129
|
if (x instanceof FGuid) return x;
|
|
67
130
|
if (typeof x === 'string') return new FGuid(x);
|
package/properties.mjs
CHANGED
|
@@ -25,12 +25,13 @@
|
|
|
25
25
|
* elements, embedded object data) do NOT.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import { Writer } from './io.mjs';
|
|
28
29
|
import { FName, FGuid } from './primitives.mjs';
|
|
29
30
|
import { StructValue, STRUCT_HANDLERS } from './structs.mjs';
|
|
30
31
|
import { ObjectRef, SoftObjectRef, FTextValue, OpaqueValue } from './values.mjs';
|
|
31
32
|
|
|
32
33
|
// ==========================================================================
|
|
33
|
-
// PropertyTag
|
|
34
|
+
// PropertyTag: the header preceding each property's value bytes.
|
|
34
35
|
// ==========================================================================
|
|
35
36
|
export class PropertyTag {
|
|
36
37
|
constructor(fields = {}) {
|
|
@@ -124,7 +125,7 @@ export class MapValue {
|
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
// ==========================================================================
|
|
127
|
-
// Property
|
|
128
|
+
// Property: one tag + its decoded value.
|
|
128
129
|
// ==========================================================================
|
|
129
130
|
export class Property {
|
|
130
131
|
constructor(tag, value, { sizeMismatch = null } = {}) {
|
|
@@ -137,7 +138,7 @@ export class Property {
|
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
// ==========================================================================
|
|
140
|
-
// Value codec
|
|
141
|
+
// Value codec: dispatches on tag.type.value.
|
|
141
142
|
//
|
|
142
143
|
// sizeHint is the tag's Size field (bytes following the tag). Containers
|
|
143
144
|
// (Array/Set/Map) and StructProperty use it as the byte budget for nested
|
|
@@ -163,7 +164,7 @@ export function readValue(cursor, tag, sizeHint) {
|
|
|
163
164
|
case 'ClassProperty':
|
|
164
165
|
case 'WeakObjectProperty':
|
|
165
166
|
case 'LazyObjectProperty':
|
|
166
|
-
case 'WSObjectProperty': // Soulmask
|
|
167
|
+
case 'WSObjectProperty': // Soulmask alias for ObjectProperty (same wire layout, different tag name)
|
|
167
168
|
return readObjectValue(cursor, sizeHint);
|
|
168
169
|
case 'SoftObjectProperty':
|
|
169
170
|
case 'SoftClassProperty':
|
|
@@ -207,7 +208,7 @@ export function readValue(cursor, tag, sizeHint) {
|
|
|
207
208
|
}
|
|
208
209
|
|
|
209
210
|
export function writeValue(writer, tag, value) {
|
|
210
|
-
// Decode may have fallen back to OpaqueValue for any property type
|
|
211
|
+
// Decode may have fallen back to OpaqueValue for any property type:
|
|
211
212
|
// Array/Set/Map/Struct/Text decode failures, unknown property types,
|
|
212
213
|
// overshoot recoveries, etc. Emit the captured bytes verbatim so the
|
|
213
214
|
// outer stream stays aligned regardless of which slot held the opaque.
|
|
@@ -279,7 +280,7 @@ function readFText(cursor, sizeHint) {
|
|
|
279
280
|
if (historyType === 0) {
|
|
280
281
|
// Base / localized: namespace + key + source string. Empty strings on
|
|
281
282
|
// the wire may use either null-form (SaveNum=0) or empty-with-terminator
|
|
282
|
-
// (SaveNum=1)
|
|
283
|
+
// (SaveNum=1). Capture `isNull` per-field so the writer reproduces the
|
|
283
284
|
// exact wire form.
|
|
284
285
|
const nsFS = cursor.readFString();
|
|
285
286
|
const kFS = cursor.readFString();
|
|
@@ -291,13 +292,42 @@ function readFText(cursor, sizeHint) {
|
|
|
291
292
|
sourceString: ssFS.value, sourceStringIsNull: ssFS.isNull,
|
|
292
293
|
});
|
|
293
294
|
}
|
|
295
|
+
if (historyType === 1) {
|
|
296
|
+
// NamedFormat (FTextHistory_NamedFormat): a format-pattern FText plus a
|
|
297
|
+
// TMap<FString, FFormatArgumentValue>. Wire shape:
|
|
298
|
+
// FText SourceFmt
|
|
299
|
+
// int32 NumArguments
|
|
300
|
+
// for each: FString Key, int8 ContentType, value-by-type
|
|
301
|
+
// ContentType codes match historyType=2 (0=Int64 1=UInt64 2=Float32
|
|
302
|
+
// 3=Float64 4=Text 5=Gender). Soulmask uses this for named placeholders
|
|
303
|
+
// like "X={X} Y={Y} Z={Z}" in ParamArrayTxt elements of JingYingRiZhiList.
|
|
304
|
+
const sourceFmt = readFText(cursor, Infinity);
|
|
305
|
+
const numArgs = cursor.readInt32();
|
|
306
|
+
const args = [];
|
|
307
|
+
for (let i = 0; i < numArgs; i++) {
|
|
308
|
+
const keyFS = cursor.readFString();
|
|
309
|
+
const type = cursor.readInt8();
|
|
310
|
+
let value;
|
|
311
|
+
switch (type) {
|
|
312
|
+
case 0: value = cursor.readInt64().toString(); break;
|
|
313
|
+
case 1: value = cursor.readUint64().toString(); break;
|
|
314
|
+
case 2: value = cursor.readFloat32(); break;
|
|
315
|
+
case 3: value = cursor.readFloat64(); break;
|
|
316
|
+
case 4: value = readFText(cursor, Infinity); break;
|
|
317
|
+
case 5: value = cursor.readInt8(); break;
|
|
318
|
+
default: throw new Error(`readFText: unknown NamedFormat ContentType ${type}`);
|
|
319
|
+
}
|
|
320
|
+
args.push({ key: keyFS.value, keyIsNull: keyFS.isNull, type, value });
|
|
321
|
+
}
|
|
322
|
+
return new FTextValue({ flags, historyType: 1, sourceFmt, arguments: args });
|
|
323
|
+
}
|
|
294
324
|
if (historyType === 2) {
|
|
295
325
|
// ArgumentFormat: a format-pattern FText plus an ordered argument list.
|
|
296
326
|
// Each argument is a ContentType byte (EFormatArgumentType) followed by
|
|
297
327
|
// the value for that type:
|
|
298
328
|
// 0=Int(int64) 1=UInt(uint64) 2=Float(f32) 3=Double(f64)
|
|
299
329
|
// 4=Text(FText, recursive) 5=Gender(int8)
|
|
300
|
-
// No argument names on the wire
|
|
330
|
+
// No argument names on the wire; arguments are positional ({0}, {1} ...).
|
|
301
331
|
const sourceFmt = readFText(cursor, Infinity);
|
|
302
332
|
const numArgs = cursor.readInt32();
|
|
303
333
|
const args = [];
|
|
@@ -328,7 +358,7 @@ function readFText(cursor, sizeHint) {
|
|
|
328
358
|
// Inside FNumberFormattingOptions, AlwaysSign and UseGrouping are also
|
|
329
359
|
// uint32 booleans. Only RoundingMode (int8) and the four digit-count
|
|
330
360
|
// fields (int32) follow the modern sizes. This matches the actual wire
|
|
331
|
-
// bytes
|
|
361
|
+
// bytes: empirically MaxIntDigits = ~324 (close to DBL_MAX_10_EXP+1
|
|
332
362
|
// = 309) and MaxFracDigits = 3 (UE default) under this interpretation.
|
|
333
363
|
const argType = cursor.readInt8();
|
|
334
364
|
let argValue;
|
|
@@ -366,7 +396,7 @@ function readFText(cursor, sizeHint) {
|
|
|
366
396
|
}
|
|
367
397
|
// Unknown history type: preserve remaining bytes verbatim for round-trip.
|
|
368
398
|
// When called from an array-element context sizeHint is Infinity because
|
|
369
|
-
// the per-element byte budget is unknown
|
|
399
|
+
// the per-element byte budget is unknown; throw so the callers can decide
|
|
370
400
|
// whether to fall back to OpaqueValue at the element or array level.
|
|
371
401
|
if (!isFinite(sizeHint)) throw new Error(`readFText: unimplemented historyType ${historyType} (no size budget; cannot store raw bytes)`);
|
|
372
402
|
const remaining = sizeHint - (cursor.pos() - start);
|
|
@@ -393,6 +423,22 @@ function writeFText(writer, value) {
|
|
|
393
423
|
writer.writeFString(value.namespace ?? '', null, value._namespaceIsNull);
|
|
394
424
|
writer.writeFString(value.key ?? '', null, value._keyIsNull);
|
|
395
425
|
writer.writeFString(value.sourceString ?? '', null, value._sourceStringIsNull);
|
|
426
|
+
} else if (value.historyType === 1) {
|
|
427
|
+
writeFText(writer, value.sourceFmt);
|
|
428
|
+
writer.writeInt32(value.arguments.length);
|
|
429
|
+
for (const arg of value.arguments) {
|
|
430
|
+
writer.writeFString(arg.key ?? '', null, arg.keyIsNull);
|
|
431
|
+
writer.writeInt8(arg.type);
|
|
432
|
+
switch (arg.type) {
|
|
433
|
+
case 0: writer.writeInt64(arg.value); break;
|
|
434
|
+
case 1: writer.writeUint64(arg.value); break;
|
|
435
|
+
case 2: writer.writeFloat32(arg.value); break;
|
|
436
|
+
case 3: writer.writeFloat64(arg.value); break;
|
|
437
|
+
case 4: writeFText(writer, arg.value); break;
|
|
438
|
+
case 5: writer.writeInt8(arg.value); break;
|
|
439
|
+
default: throw new Error(`writeFText: unknown NamedFormat ContentType ${arg.type}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
396
442
|
} else if (value.historyType === 2) {
|
|
397
443
|
writeFText(writer, value.sourceFmt);
|
|
398
444
|
writer.writeInt32(value.arguments.length);
|
|
@@ -420,7 +466,7 @@ function writeFText(writer, value) {
|
|
|
420
466
|
case 5: writer.writeInt64(sv.value); break;
|
|
421
467
|
default: throw new Error(`writeFText: unknown FFormatArgumentValue type ${sv.type} in AsNumber`);
|
|
422
468
|
}
|
|
423
|
-
// Legacy uint32 booleans
|
|
469
|
+
// Legacy uint32 booleans (see readFText AsNumber for rationale).
|
|
424
470
|
const hasFormatOptions = value.formatOptions != null;
|
|
425
471
|
writer.writeUint32(hasFormatOptions ? 1 : 0);
|
|
426
472
|
if (hasFormatOptions) {
|
|
@@ -459,7 +505,7 @@ function writeFText(writer, value) {
|
|
|
459
505
|
// preserves the wire's choice between null-form (SaveNum=0, 4 bytes) and
|
|
460
506
|
// empty-with-terminator (SaveNum=1 plus 1-byte NUL, 5 bytes). The previous
|
|
461
507
|
// version always wrote `writeFString(this.path)` for ObjectRef and emitted
|
|
462
|
-
// a 4-byte null FString even for kind-only values
|
|
508
|
+
// a 4-byte null FString even for kind-only values, silently inflating the
|
|
463
509
|
// encoded blob by 4 B for every kind-only reference.
|
|
464
510
|
function readObjectValue(cursor, sizeHint) {
|
|
465
511
|
const start = cursor.pos();
|
|
@@ -471,7 +517,7 @@ function readObjectValue(cursor, sizeHint) {
|
|
|
471
517
|
}
|
|
472
518
|
// Soulmask kind=0x01 (hard actor reference, e.g. HBindBGCompActor on
|
|
473
519
|
// NPC pawns) prepends a 4-byte field between the kind byte and the
|
|
474
|
-
// path FString. Observed value is always 1; semantic unknown
|
|
520
|
+
// path FString. Observed value is always 1; semantic unknown. Captured
|
|
475
521
|
// verbatim and replayed on write. Without this branch the reader treats
|
|
476
522
|
// those four bytes as the path FString's SaveNum, which overshoots the
|
|
477
523
|
// budget and falls back to OpaqueValue (the symptom that hid every
|
|
@@ -484,7 +530,7 @@ function readObjectValue(cursor, sizeHint) {
|
|
|
484
530
|
}
|
|
485
531
|
}
|
|
486
532
|
const pathFS = cursor.readFString();
|
|
487
|
-
// Guard against path FStrings whose SaveNum overshoots the value budget
|
|
533
|
+
// Guard against path FStrings whose SaveNum overshoots the value budget:
|
|
488
534
|
// this happens for properties whose format differs from kind+path+... and
|
|
489
535
|
// whose first "path" bytes happen to encode a huge length.
|
|
490
536
|
if (cursor.pos() - start > sizeHint) throw new Error('path FString exceeded value budget');
|
|
@@ -593,19 +639,23 @@ function readArrayValue(cursor, tag, sizeHint) {
|
|
|
593
639
|
* yuan-xing element is followed by a fixed-shape block:
|
|
594
640
|
*
|
|
595
641
|
* [8 bytes zero header]
|
|
596
|
-
* [u32 stride=64] [u32 count] [
|
|
597
|
-
* [u32 stride= 4] [u32 count] [
|
|
598
|
-
* [u32 stride=64] [u32 count] [
|
|
642
|
+
* [u32 stride=64] [u32 count] [count × 16 float32] world 4×4 transforms (per placed piece)
|
|
643
|
+
* [u32 stride= 4] [u32 count] [count × u32] per-piece u32 ids
|
|
644
|
+
* [u32 stride=64] [u32 count] [count × 16 float32] per-piece aux (bbox + scale-ish floats)
|
|
599
645
|
*
|
|
600
|
-
* Returns {
|
|
601
|
-
*
|
|
602
|
-
*
|
|
646
|
+
* Returns { transforms, ids, aux } on success: arrays of decoded values
|
|
647
|
+
* (rather than raw byte slices). Returns null (cursor rolled back) when the
|
|
648
|
+
* bytes don't match. Non-JianZhuInstYuanXings ObjectProperty arrays have no
|
|
649
|
+
* such block, so peeking-and-rolling-back keeps them unaffected.
|
|
650
|
+
*
|
|
651
|
+
* The 8-byte zero header and the fixed strides (64/4/64) are synthesized on
|
|
652
|
+
* write — no field in the returned object carries them.
|
|
603
653
|
*
|
|
604
654
|
* Verified by in-game experiment 2026-05-18: numElements counts UNIQUE
|
|
605
|
-
* prototypes (foundation, wall, door frame, …);
|
|
606
|
-
* placed-piece count for that prototype;
|
|
607
|
-
*
|
|
608
|
-
*
|
|
655
|
+
* prototypes (foundation, wall, door frame, …); transforms.length is the
|
|
656
|
+
* placed-piece count for that prototype; aux.length is typically the same
|
|
657
|
+
* or one greater. The earlier "single trailing block after all elements"
|
|
658
|
+
* model was wrong; these blocks are interleaved per element.
|
|
609
659
|
*/
|
|
610
660
|
function tryReadObjectArrayPerElementBlock(cursor, endOff) {
|
|
611
661
|
const start = cursor.pos();
|
|
@@ -618,8 +668,7 @@ function tryReadObjectArrayPerElementBlock(cursor, endOff) {
|
|
|
618
668
|
if (cursor.dv.getUint32(start + 8, true) !== 64) return null;
|
|
619
669
|
|
|
620
670
|
try {
|
|
621
|
-
cursor.skip(8);
|
|
622
|
-
const header = cursor.bytes.subarray(start, start + 8).slice();
|
|
671
|
+
cursor.skip(8); // zero header
|
|
623
672
|
const sections = [];
|
|
624
673
|
const expected = [64, 4, 64];
|
|
625
674
|
for (let i = 0; i < 3; i++) {
|
|
@@ -630,9 +679,26 @@ function tryReadObjectArrayPerElementBlock(cursor, endOff) {
|
|
|
630
679
|
if (count > 1_000_000) throw new Error(`implausible count ${count}`);
|
|
631
680
|
const dataBytes = stride * count;
|
|
632
681
|
if (cursor.pos() + dataBytes > endOff) throw new Error(`section ${i} data overruns budget`);
|
|
633
|
-
|
|
682
|
+
if (i === 1) {
|
|
683
|
+
const ids = new Array(count);
|
|
684
|
+
for (let k = 0; k < count; k++) ids[k] = cursor.readUint32();
|
|
685
|
+
sections.push(ids);
|
|
686
|
+
} else {
|
|
687
|
+
// 16 float32 per element (4×4 matrix, row-major in UE's FMatrix layout).
|
|
688
|
+
// Non-canonical NaN bit patterns are common in Soulmask aux data
|
|
689
|
+
// (observed 0xFFFFFFFF as "invalid" sentinel) and would collapse to
|
|
690
|
+
// canonical 0x7FC00000 if round-tripped via a JS Number, so we
|
|
691
|
+
// capture them as { $nanBits } wrappers instead.
|
|
692
|
+
const arr = new Array(count);
|
|
693
|
+
for (let k = 0; k < count; k++) {
|
|
694
|
+
const m = new Array(16);
|
|
695
|
+
for (let j = 0; j < 16; j++) m[j] = readFloat32PreservingNan(cursor);
|
|
696
|
+
arr[k] = m;
|
|
697
|
+
}
|
|
698
|
+
sections.push(arr);
|
|
699
|
+
}
|
|
634
700
|
}
|
|
635
|
-
return {
|
|
701
|
+
return { transforms: sections[0], ids: sections[1], aux: sections[2] };
|
|
636
702
|
} catch {
|
|
637
703
|
cursor.seek(start);
|
|
638
704
|
return null;
|
|
@@ -640,11 +706,45 @@ function tryReadObjectArrayPerElementBlock(cursor, endOff) {
|
|
|
640
706
|
}
|
|
641
707
|
|
|
642
708
|
function writeObjectArrayPerElementBlock(writer, block) {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
709
|
+
// 8-byte zero header.
|
|
710
|
+
writer.writeUint32(0);
|
|
711
|
+
writer.writeUint32(0);
|
|
712
|
+
// Section 0: transforms (count × 16 float32).
|
|
713
|
+
writer.writeUint32(64);
|
|
714
|
+
writer.writeUint32(block.transforms.length);
|
|
715
|
+
for (const m of block.transforms) for (const f of m) writeFloat32PreservingNan(writer, f);
|
|
716
|
+
// Section 1: ids (count × u32).
|
|
717
|
+
writer.writeUint32(4);
|
|
718
|
+
writer.writeUint32(block.ids.length);
|
|
719
|
+
for (const id of block.ids) writer.writeUint32(id);
|
|
720
|
+
// Section 2: aux (count × 16 float32).
|
|
721
|
+
writer.writeUint32(64);
|
|
722
|
+
writer.writeUint32(block.aux.length);
|
|
723
|
+
for (const m of block.aux) for (const f of m) writeFloat32PreservingNan(writer, f);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Float32 helpers that preserve non-canonical NaN bit patterns. JavaScript's
|
|
727
|
+
// Number type collapses all NaN bit patterns into the canonical 0x7FC00000
|
|
728
|
+
// on any DataView.setFloat32 call, so a wire NaN like 0xFFFFFFFF (observed in
|
|
729
|
+
// Soulmask JianZhuInstYuanXings aux data) would not round-trip if we used
|
|
730
|
+
// readFloat32 / writeFloat32 directly. We instead carry NaN-bit-patterns as
|
|
731
|
+
// { $nanBits: u32 } wrapper objects.
|
|
732
|
+
function readFloat32PreservingNan(cursor) {
|
|
733
|
+
const bits = cursor.dv.getUint32(cursor.offset, true);
|
|
734
|
+
// NaN: exponent all 1s AND mantissa non-zero. The single "canonical NaN"
|
|
735
|
+
// (0x7FC00000) is allowed to round-trip through Number, but every other
|
|
736
|
+
// NaN bit pattern needs the wrapper.
|
|
737
|
+
if ((bits & 0x7F800000) === 0x7F800000 && (bits & 0x007FFFFF) !== 0 && bits !== 0x7FC00000) {
|
|
738
|
+
cursor.offset += 4;
|
|
739
|
+
return { $nanBits: bits >>> 0 };
|
|
740
|
+
}
|
|
741
|
+
return cursor.readFloat32();
|
|
742
|
+
}
|
|
743
|
+
function writeFloat32PreservingNan(writer, f) {
|
|
744
|
+
if (f !== null && typeof f === 'object' && '$nanBits' in f) {
|
|
745
|
+
writer.writeUint32(f.$nanBits >>> 0);
|
|
746
|
+
} else {
|
|
747
|
+
writer.writeFloat32(f);
|
|
648
748
|
}
|
|
649
749
|
}
|
|
650
750
|
|
|
@@ -656,14 +756,33 @@ function isObjectInnerType(t) {
|
|
|
656
756
|
|
|
657
757
|
function writeArrayValue(writer, tag, value) {
|
|
658
758
|
const innerType = tag.innerType.value;
|
|
759
|
+
const recompute = !!writer._wsRecomputeSizes;
|
|
659
760
|
writer.writeInt32(value.elements.length);
|
|
660
761
|
if (innerType === 'StructProperty') {
|
|
661
|
-
value._arrayInnerTag.write(writer);
|
|
662
762
|
const structName = value._arrayInnerTag.structName.value;
|
|
663
763
|
const handler = STRUCT_HANDLERS[structName];
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
764
|
+
// On recompute, set innerTag.size to the encoded size of element 0.
|
|
765
|
+
// All array-of-struct elements share the same innerTag, and stock UE
|
|
766
|
+
// uses one size value as a hint for the whole element shape. Self-
|
|
767
|
+
// delimiting struct streams (None terminator) make the exact value less
|
|
768
|
+
// critical for reading, but writing the truthful size keeps validators
|
|
769
|
+
// happy.
|
|
770
|
+
let origInnerSize = value._arrayInnerTag.size;
|
|
771
|
+
if (recompute && value.elements.length > 0) {
|
|
772
|
+
const sub = new Writer(64);
|
|
773
|
+
sub._wsRecomputeSizes = true;
|
|
774
|
+
if (handler) handler.write(sub, value.elements[0].value);
|
|
775
|
+
else writeNestedPropertyStream(sub, value.elements[0].value);
|
|
776
|
+
value._arrayInnerTag.size = sub.finalize().length;
|
|
777
|
+
}
|
|
778
|
+
try {
|
|
779
|
+
value._arrayInnerTag.write(writer);
|
|
780
|
+
for (const e of value.elements) {
|
|
781
|
+
if (handler) handler.write(writer, e.value);
|
|
782
|
+
else writeNestedPropertyStream(writer, e.value);
|
|
783
|
+
}
|
|
784
|
+
} finally {
|
|
785
|
+
if (recompute) value._arrayInnerTag.size = origInnerSize;
|
|
667
786
|
}
|
|
668
787
|
return;
|
|
669
788
|
}
|
|
@@ -688,7 +807,7 @@ function readSetValue(cursor, tag) {
|
|
|
688
807
|
// Set elements for StructProperty inner type are raw binary structs with no
|
|
689
808
|
// inner PropertyTag wrapper (unlike ArrayProperty<StructProperty>, which does
|
|
690
809
|
// have one). Every observed Set<StructProperty> in world.db uses 16-byte Guids
|
|
691
|
-
// as elements
|
|
810
|
+
// as elements: the same assumption MapProperty makes for Struct keys.
|
|
692
811
|
function readSetElement(cursor, innerType) {
|
|
693
812
|
if (innerType === 'StructProperty') return FGuid.read(cursor).value;
|
|
694
813
|
return readArrayElement(cursor, innerType);
|
|
@@ -737,7 +856,7 @@ function writeMapValue(writer, tag, value) {
|
|
|
737
856
|
|
|
738
857
|
/**
|
|
739
858
|
* Map element (one key or one value) when the map's inner/value type is
|
|
740
|
-
* StructProperty
|
|
859
|
+
* StructProperty: Soulmask uses several conventions that diverge from
|
|
741
860
|
* stock UE 4.27 here:
|
|
742
861
|
*
|
|
743
862
|
* Key (StructProperty) → a raw 16-byte FGuid. The map tag declares
|
|
@@ -751,7 +870,7 @@ function writeMapValue(writer, tag, value) {
|
|
|
751
870
|
* `GeRenJianZhuYingHuoList`, `GeRenMapRiZhi`)
|
|
752
871
|
* OR a raw 16-byte FGuid (`PlayerGongHuiMap`,
|
|
753
872
|
* a player→guild membership lookup).
|
|
754
|
-
* We sniff which by peeking ahead
|
|
873
|
+
* We sniff which by peeking ahead. A
|
|
755
874
|
* property stream starts with an FString
|
|
756
875
|
* length prefix for the first tag's name
|
|
757
876
|
* (small positive int, body is identifier
|
|
@@ -767,7 +886,7 @@ function writeMapValue(writer, tag, value) {
|
|
|
767
886
|
* NOT match the actual byte span of the data section (observed:
|
|
768
887
|
* tag.size=632838, actual=636422 for a populated GongHuiMap). The
|
|
769
888
|
* decoder advances the cursor based on pair count + per-pair shape,
|
|
770
|
-
* NOT the tag.size
|
|
889
|
+
* NOT the tag.size, which is why this works despite the size lie.
|
|
771
890
|
*/
|
|
772
891
|
function readMapElement(cursor, type, isKey) {
|
|
773
892
|
if (type !== 'StructProperty') return readArrayElement(cursor, type);
|
|
@@ -798,17 +917,25 @@ function writeMapElement(writer, type, value, isKey) {
|
|
|
798
917
|
|
|
799
918
|
/**
|
|
800
919
|
* Peek the next bytes of `cursor` (without advancing): do they look like
|
|
801
|
-
* the start of a PropertyTag
|
|
920
|
+
* the start of a PropertyTag (i.e. an FString that names a property)?
|
|
802
921
|
*
|
|
803
922
|
* A property name FString is:
|
|
804
923
|
* - int32 SaveNum > 0 and reasonably small (<= 64 chars in Soulmask)
|
|
805
924
|
* - SaveNum bytes of ANSI body whose last byte is NUL
|
|
806
925
|
* - body chars (minus NUL) are identifier-safe: A-Z, a-z, 0-9, _.
|
|
807
926
|
*
|
|
808
|
-
* Random GUID bytes effectively never satisfy this
|
|
927
|
+
* Random GUID bytes effectively never satisfy this: the first uint32
|
|
809
928
|
* of a Guid is ~uniform over [0..2^32), and even when it lands in a
|
|
810
929
|
* "plausible length" range the printable-ASCII + NUL-terminator check
|
|
811
930
|
* eliminates the false positives.
|
|
931
|
+
*
|
|
932
|
+
* Caveat: we only match ANSI property names (SaveNum > 0). Every Soulmask
|
|
933
|
+
* property name observed in world.db is ASCII, so a negative-SaveNum
|
|
934
|
+
* (UTF-16) tag is currently treated as "not a tag" and the caller falls
|
|
935
|
+
* through to the alternate read path. If a future Soulmask version emits
|
|
936
|
+
* UTF-16 property names inside a Map<Struct,Struct> value, this needs an
|
|
937
|
+
* additional branch matching saveNum < 0 with the equivalent UTF-16
|
|
938
|
+
* identifier-character + NUL-terminator check.
|
|
812
939
|
*/
|
|
813
940
|
function peekLooksLikePropertyTag(cursor) {
|
|
814
941
|
if (cursor.remaining() < 8) return false;
|
|
@@ -836,6 +963,49 @@ function peekLooksLikePropertyTag(cursor) {
|
|
|
836
963
|
// kind+path, kind+path+classPath, or full kind+path+classPath+embedded).
|
|
837
964
|
// Other inner types have fixed sizes determined by the type itself, so
|
|
838
965
|
// they ignore the hint.
|
|
966
|
+
//
|
|
967
|
+
// =====================================================================
|
|
968
|
+
// Heuristics preamble: how this reader disambiguates ObjectProperty
|
|
969
|
+
// elements that don't carry a per-element delimiter on the wire.
|
|
970
|
+
//
|
|
971
|
+
// Stock UE ArrayProperty<ObjectProperty> writes a sequence of object
|
|
972
|
+
// values back-to-back with no length tag and no separator between
|
|
973
|
+
// elements. Each element's wire form is one of:
|
|
974
|
+
//
|
|
975
|
+
// (A) kind-only 1 byte
|
|
976
|
+
// (B) kind+path 1 byte + FString
|
|
977
|
+
// (C) kind+path+class 1 byte + FString + FString
|
|
978
|
+
// (D) kind+path+class+embedded property stream (terminated by None)
|
|
979
|
+
//
|
|
980
|
+
// Without per-element bounds we'd read past the element's actual end
|
|
981
|
+
// into either the next element's kind byte or a trailing binary section
|
|
982
|
+
// (origin / placement data) and cascade-fail.
|
|
983
|
+
//
|
|
984
|
+
// We address this with four guards, each cheap and orthogonal:
|
|
985
|
+
//
|
|
986
|
+
// Guard 1: budget exhaustion. After path, if there's no room for even
|
|
987
|
+
// a null-form classPath FString (4 bytes), stop here.
|
|
988
|
+
// Guard 2: implausible saveNum magnitude. A real classPath is short
|
|
989
|
+
// (<= 1024 chars); a peek that decodes to a huge magnitude
|
|
990
|
+
// usually means we're looking at the start of the next
|
|
991
|
+
// element's bytes instead.
|
|
992
|
+
// Guard 3: classPath starts with '/'. Soulmask asset paths are always
|
|
993
|
+
// "/Script/..." or "/Game/...". A peek whose first content
|
|
994
|
+
// byte isn't '/' (or '/' '\0' for UTF-16) is the next
|
|
995
|
+
// element's payload, not a real classPath.
|
|
996
|
+
// Guard 4: embedded-stream signature. The bytes following classPath
|
|
997
|
+
// either start a PropertyTag (identifier-character name with
|
|
998
|
+
// a small ANSI SaveNum) or they don't; if they don't, the
|
|
999
|
+
// element ends without an embedded stream.
|
|
1000
|
+
//
|
|
1001
|
+
// The same logic governs whether a 4-byte trailer at the element's tail
|
|
1002
|
+
// is consumed: only when the next 4 bytes are 0x00000000 (FName.Number)
|
|
1003
|
+
// AND we're still within budget.
|
|
1004
|
+
//
|
|
1005
|
+
// In practice this catches every known Soulmask actor in the tested
|
|
1006
|
+
// world.db. Adding a new game-specific element shape means adding a
|
|
1007
|
+
// new guard, not relaxing the existing ones.
|
|
1008
|
+
// =====================================================================
|
|
839
1009
|
function readArrayElement(cursor, innerType, sizeHint = Infinity) {
|
|
840
1010
|
switch (innerType) {
|
|
841
1011
|
case 'IntProperty': return cursor.readInt32();
|
|
@@ -864,7 +1034,7 @@ function readArrayElement(cursor, innerType, sizeHint = Infinity) {
|
|
|
864
1034
|
case 'WSObjectProperty': {
|
|
865
1035
|
// Bounded read, mirroring readObjectValue. The variable wire shapes
|
|
866
1036
|
// (kind-only, +path, +path+classPath, +embedded) are disambiguated by
|
|
867
|
-
// sizeHint
|
|
1037
|
+
// sizeHint. Without the bound we'd read past the element into the
|
|
868
1038
|
// next property's tag, which causes catastrophic cascade failures
|
|
869
1039
|
// (cf. ChengHaoList in serial 92 and friends). Capture per-FString
|
|
870
1040
|
// isNull flags for byte-identical round-trip of empty wire-FStrings.
|
|
@@ -878,7 +1048,7 @@ function readArrayElement(cursor, innerType, sizeHint = Infinity) {
|
|
|
878
1048
|
// kind byte with no path, classPath, or embedded stream. Without this
|
|
879
1049
|
// early-out, the FString reader would interpret the next 4 bytes (which
|
|
880
1050
|
// belong to either the next element or the trailing binary section)
|
|
881
|
-
// as a path saveNum
|
|
1051
|
+
// as a path saveNum: typically a huge garbage value that overshoots
|
|
882
1052
|
// the array. Seen in JianZhuInstYuanXings, ZhuangBeiLanDaoJuJiYiList,
|
|
883
1053
|
// and KuaiJieLanDaoJuJiYiList trailing slots.
|
|
884
1054
|
if (kind === 0) {
|
|
@@ -915,7 +1085,7 @@ function readArrayElement(cursor, innerType, sizeHint = Infinity) {
|
|
|
915
1085
|
// "/Script/Module.Class" or "/Game/...". The first content character
|
|
916
1086
|
// is therefore "/" (0x2F). When the bytes after path are actually the
|
|
917
1087
|
// start of the NEXT element (kind byte + optional kindOnePrefix +
|
|
918
|
-
// path saveNum), peekSN can fall in the [-1024, 1024] range
|
|
1088
|
+
// path saveNum), peekSN can fall in the [-1024, 1024] range, e.g.
|
|
919
1089
|
// bytes `01 01 00 00` from a kind=1 element with kindOnePrefix=1 read
|
|
920
1090
|
// as int32 = 257. The previous saveNum-magnitude guard misses this;
|
|
921
1091
|
// checking the first content byte for '/' catches it cleanly. Allow
|
|
@@ -947,7 +1117,7 @@ function readArrayElement(cursor, innerType, sizeHint = Infinity) {
|
|
|
947
1117
|
});
|
|
948
1118
|
}
|
|
949
1119
|
// Guard 4: embedded-stream presence. Same problem as the classPath
|
|
950
|
-
// guards but one level deeper
|
|
1120
|
+
// guards but one level deeper: when the element has classPath but
|
|
951
1121
|
// NO embedded stream, the bytes that follow classPath are the start
|
|
952
1122
|
// of the NEXT element (kind byte) or the trailing binary section
|
|
953
1123
|
// (12 zero bytes of origin). An embedded stream begins with a
|
|
@@ -1088,12 +1258,40 @@ export function readPropertyStream(cursor, endOffset = Infinity, consumeTerminat
|
|
|
1088
1258
|
}
|
|
1089
1259
|
|
|
1090
1260
|
export function writePropertyStream(writer, properties, emitTerminatorTrailer = false) {
|
|
1261
|
+
// When the writer carries the recompute flag, we encode each property's
|
|
1262
|
+
// value into a temporary sub-buffer, then overwrite tag.size with the
|
|
1263
|
+
// sub-buffer's length before writing the tag. This is required for edits:
|
|
1264
|
+
// the wire stores tag.size literally, and a stale value (e.g. after
|
|
1265
|
+
// lengthening an FString) leaves the next reader misaligned.
|
|
1266
|
+
//
|
|
1267
|
+
// The flag propagates to sub-buffers so nested streams inside StructValue /
|
|
1268
|
+
// ObjectRef / Array<Struct> also get recomputed sizes. Direct callers that
|
|
1269
|
+
// want byte-identical preservation (the test-roundtrip.mjs path) leave the
|
|
1270
|
+
// flag unset; UnrealBlob.serialize sets it from blob._recomputeSizes.
|
|
1271
|
+
const recompute = !!writer._wsRecomputeSizes;
|
|
1091
1272
|
for (const p of properties) {
|
|
1092
1273
|
if (p._sizeMismatch) {
|
|
1093
1274
|
throw new Error(`writePropertyStream: property '${p.name}' has _sizeMismatch (${JSON.stringify(p._sizeMismatch)}); cannot safely re-emit`);
|
|
1094
1275
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1276
|
+
if (recompute) {
|
|
1277
|
+
const sub = new Writer(64);
|
|
1278
|
+
sub._wsRecomputeSizes = true;
|
|
1279
|
+
writeValue(sub, p.tag, p.value);
|
|
1280
|
+
const valueBytes = sub.finalize();
|
|
1281
|
+
const origSize = p.tag.size;
|
|
1282
|
+
p.tag.size = valueBytes.length;
|
|
1283
|
+
try {
|
|
1284
|
+
p.tag.write(writer);
|
|
1285
|
+
writer.writeBytes(valueBytes);
|
|
1286
|
+
} finally {
|
|
1287
|
+
// Restore so we don't mutate the blob's tags across multiple
|
|
1288
|
+
// serialize() calls. A subsequent recompute will rewrite them.
|
|
1289
|
+
p.tag.size = origSize;
|
|
1290
|
+
}
|
|
1291
|
+
} else {
|
|
1292
|
+
p.tag.write(writer);
|
|
1293
|
+
writeValue(writer, p.tag, p.value);
|
|
1294
|
+
}
|
|
1097
1295
|
}
|
|
1098
1296
|
new FName('None').write(writer);
|
|
1099
1297
|
if (emitTerminatorTrailer) writer.writeInt32(0);
|
|
@@ -1103,7 +1301,7 @@ export function writePropertyStream(writer, properties, emitTerminatorTrailer =
|
|
|
1103
1301
|
// StructValue.write) to avoid needing a writePropertyStream re-export.
|
|
1104
1302
|
// `emitTerminatorTrailer` defaults to false because nested streams in
|
|
1105
1303
|
// stock UE 4.27 don't carry the 4-byte FName.Number trailer that the
|
|
1106
|
-
// outermost stream does
|
|
1304
|
+
// outermost stream does. But some Soulmask embedded ObjectProperty
|
|
1107
1305
|
// streams DO (see ObjectRef.hasTerminatorTrailer / readObjectValue's
|
|
1108
1306
|
// trailer-skip detection), so callers can opt in.
|
|
1109
1307
|
export function writeNestedPropertyStream(writer, properties, emitTerminatorTrailer = false) {
|