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/structs.mjs
CHANGED
|
@@ -3,16 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Soulmask is UE 4.27 so "core" structs (Vector etc.) use 32-bit floats.
|
|
5
5
|
* Known struct names read directly as binary; unknown struct names fall
|
|
6
|
-
* through to a nested property stream (handled in properties.mjs, not here
|
|
7
|
-
*
|
|
6
|
+
* through to a nested property stream (handled in properties.mjs, not here;
|
|
7
|
+
* StructValue.read is supplied a `streamReader` callback to avoid a
|
|
8
8
|
* load-order cycle).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { FGuid } from './primitives.mjs';
|
|
12
12
|
|
|
13
|
-
// Binary struct handlers. Each entry has read(cursor)
|
|
13
|
+
// Binary struct handlers. Each entry has read(cursor) -> plain object and
|
|
14
14
|
// write(writer, plainObject). The plain object is what callers see as
|
|
15
15
|
// `structValue.value` when the struct is one of these known shapes.
|
|
16
|
+
//
|
|
17
|
+
// Consumers can extend this registry to teach the codec about additional
|
|
18
|
+
// known-binary structs; prefer the `registerStructHandler` helper below over
|
|
19
|
+
// mutating this object directly, since it validates the handler shape.
|
|
16
20
|
export const STRUCT_HANDLERS = {
|
|
17
21
|
Vector: { read: c => ({ x: c.readFloat32(), y: c.readFloat32(), z: c.readFloat32() }),
|
|
18
22
|
write: (w, v) => { w.writeFloat32(v.x); w.writeFloat32(v.y); w.writeFloat32(v.z); } },
|
|
@@ -24,12 +28,20 @@ export const STRUCT_HANDLERS = {
|
|
|
24
28
|
write: (w, v) => { w.writeFloat32(v.pitch); w.writeFloat32(v.yaw); w.writeFloat32(v.roll); } },
|
|
25
29
|
Quat: { read: c => ({ x: c.readFloat32(), y: c.readFloat32(), z: c.readFloat32(), w: c.readFloat32() }),
|
|
26
30
|
write: (w, v) => { w.writeFloat32(v.x); w.writeFloat32(v.y); w.writeFloat32(v.z); w.writeFloat32(v.w); } },
|
|
31
|
+
// FColor wire order is B, G, R, A (not R, G, B, A). This matches UE4's
|
|
32
|
+
// FColor::Serialize, where the in-memory union exposes the bytes in BGRA
|
|
33
|
+
// order to match Windows DIB / DirectX texture layout. Don't "fix" the
|
|
34
|
+
// ordering; it's correct as-is.
|
|
27
35
|
Color: { read: c => ({ b: c.readUint8(), g: c.readUint8(), r: c.readUint8(), a: c.readUint8() }),
|
|
28
36
|
write: (w, v) => { w.writeUint8(v.b); w.writeUint8(v.g); w.writeUint8(v.r); w.writeUint8(v.a); } },
|
|
29
37
|
LinearColor: { read: c => ({ r: c.readFloat32(), g: c.readFloat32(), b: c.readFloat32(), a: c.readFloat32() }),
|
|
30
38
|
write: (w, v) => { w.writeFloat32(v.r); w.writeFloat32(v.g); w.writeFloat32(v.b); w.writeFloat32(v.a); } },
|
|
31
|
-
Guid
|
|
32
|
-
|
|
39
|
+
// Guid returns an FGuid INSTANCE (not a bare string). FGuid carries
|
|
40
|
+
// toJSON/equals/isZero helpers; the write path accepts FGuid or a bare
|
|
41
|
+
// 8-4-4-4-12 string for backward compatibility with code that built the
|
|
42
|
+
// struct value from a literal.
|
|
43
|
+
Guid: { read: c => FGuid.read(c),
|
|
44
|
+
write: (w, v) => FGuid.from(v).write(w) },
|
|
33
45
|
DateTime: { read: c => c.readInt64().toString(),
|
|
34
46
|
write: (w, v) => w.writeInt64(v) },
|
|
35
47
|
Timespan: { read: c => c.readInt64().toString(),
|
|
@@ -48,6 +60,27 @@ export const STRUCT_HANDLERS = {
|
|
|
48
60
|
write: (w, v) => { STRUCT_HANDLERS.Quat.write(w, v.rotation); STRUCT_HANDLERS.Vector.write(w, v.translation); STRUCT_HANDLERS.Vector.write(w, v.scale3D); } },
|
|
49
61
|
};
|
|
50
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Register (or replace) a struct handler. Callers can use this to teach the
|
|
65
|
+
* codec about additional binary structs the game emits that aren't in the
|
|
66
|
+
* stock registry. Without a handler, an unknown struct name falls through to
|
|
67
|
+
* the nested-property-stream path; that's still correct when the struct is
|
|
68
|
+
* actually tagged, and is byte-identical on round-trip via OpaqueValue when
|
|
69
|
+
* it isn't.
|
|
70
|
+
*
|
|
71
|
+
* Validates that `handler` has both `read(cursor)` and `write(writer, value)`
|
|
72
|
+
* functions and that `name` is a non-empty string.
|
|
73
|
+
*/
|
|
74
|
+
export function registerStructHandler(name, handler) {
|
|
75
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
76
|
+
throw new TypeError('registerStructHandler: name must be a non-empty string');
|
|
77
|
+
}
|
|
78
|
+
if (!handler || typeof handler.read !== 'function' || typeof handler.write !== 'function') {
|
|
79
|
+
throw new TypeError('registerStructHandler: handler must expose read(cursor) and write(writer, value) functions');
|
|
80
|
+
}
|
|
81
|
+
STRUCT_HANDLERS[name] = handler;
|
|
82
|
+
}
|
|
83
|
+
|
|
51
84
|
export class StructValue {
|
|
52
85
|
constructor(structName, { value = null, terminated = false, decodeError = null, opaqueTail = null } = {}) {
|
|
53
86
|
this._structName = structName;
|
|
@@ -70,7 +103,7 @@ export class StructValue {
|
|
|
70
103
|
* PropertyTag (FString name with identifier-character ASCII content).
|
|
71
104
|
* When supplied and the wire bytes look tagged, the read switches to
|
|
72
105
|
* the property-stream path even for structs that have a known binary
|
|
73
|
-
* handler
|
|
106
|
+
* handler: Soulmask encodes known-binary structs (Transform, Box, ...)
|
|
74
107
|
* as TAGGED property streams inside Map struct values, which would
|
|
75
108
|
* otherwise be misread as raw 40-byte Transforms / etc. The decision
|
|
76
109
|
* is recorded on the returned StructValue via `Array.isArray(value)`,
|
package/values.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Wrapper classes for non-trivial property-value shapes:
|
|
3
|
-
* ObjectRef
|
|
4
|
-
* SoftObjectRef
|
|
5
|
-
* FTextValue
|
|
6
|
-
* OpaqueValue
|
|
3
|
+
* ObjectRef : ObjectProperty / ClassProperty / Weak / Lazy
|
|
4
|
+
* SoftObjectRef : SoftObjectProperty / SoftClassProperty
|
|
5
|
+
* FTextValue : TextProperty (FText: localized / culture-invariant string)
|
|
6
|
+
* OpaqueValue : bytes we don't decode (fallback for unknown/unimplemented)
|
|
7
7
|
*
|
|
8
8
|
* Array/Set/Map values live in properties.mjs because they're tightly
|
|
9
9
|
* coupled to PropertyTag (struct arrays carry an inner tag).
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { writeNestedPropertyStream } from './properties.mjs';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Soulmask ObjectProperty value layout. Each field is optional
|
|
19
|
+
* Soulmask ObjectProperty value layout. Each field is optional; the wire
|
|
20
20
|
* shape is bounded by the property tag's size budget and the reader stops
|
|
21
21
|
* at whichever boundary it hits first:
|
|
22
22
|
*
|
|
@@ -25,7 +25,7 @@ import { writeNestedPropertyStream } from './properties.mjs';
|
|
|
25
25
|
* 4-byte field sitting between the kind byte
|
|
26
26
|
* and pathFS. Observed value is always 1; the
|
|
27
27
|
* semantic meaning is unclear (a flag, an
|
|
28
|
-
* FName.Number, or a count)
|
|
28
|
+
* FName.Number, or a count); we capture and
|
|
29
29
|
* replay it verbatim for byte-identical
|
|
30
30
|
* round-trip. Seen on hard actor references
|
|
31
31
|
* like NPC `HBindBGCompActor` (the pawn's
|
|
@@ -67,7 +67,7 @@ export class ObjectRef {
|
|
|
67
67
|
this.terminated = terminated;
|
|
68
68
|
// When true, the embedded property stream was followed by a 4-byte
|
|
69
69
|
// FName.Number trailer (the outermost-stream None-trailer convention,
|
|
70
|
-
// applied here by Soulmask to some nested ObjectProperty embeddeds
|
|
70
|
+
// applied here by Soulmask to some nested ObjectProperty embeddeds,
|
|
71
71
|
// e.g. JianZhuInstGLQComponent). The reader detects this when exactly
|
|
72
72
|
// 4 trailing bytes remain inside the tag's size budget; the writer
|
|
73
73
|
// replays them so round-trip stays byte-identical.
|
|
@@ -118,28 +118,28 @@ export class SoftObjectRef {
|
|
|
118
118
|
* UE4 FText wire format:
|
|
119
119
|
* uint32 Flags
|
|
120
120
|
* int8 HistoryType
|
|
121
|
-
*
|
|
121
|
+
* HistoryType -1 (None / culture-invariant):
|
|
122
122
|
* int32 bHasCultureInvariantString
|
|
123
123
|
* [FString displayString] (only when bHasCultureInvariantString != 0)
|
|
124
|
-
*
|
|
124
|
+
* HistoryType 0 (Base / localized):
|
|
125
125
|
* FString Namespace
|
|
126
126
|
* FString Key
|
|
127
127
|
* FString SourceString
|
|
128
|
-
*
|
|
129
|
-
* FText SourceFmt
|
|
128
|
+
* HistoryType 2 (OrderedFormat):
|
|
129
|
+
* FText SourceFmt (the format pattern, e.g. "{0} < {1} >")
|
|
130
130
|
* int32 NumArguments
|
|
131
131
|
* for each: int8 ContentType + value
|
|
132
132
|
* 0=Int(int64) 1=UInt(uint64) 2=Float(f32) 3=Double(f64)
|
|
133
133
|
* 4=Text(FText) 5=Gender(int8)
|
|
134
|
-
*
|
|
134
|
+
* HistoryType 4 (AsNumber, FTextHistory_AsNumber):
|
|
135
135
|
* FFormatArgumentValue SourceValue (int8 type + value-by-type)
|
|
136
136
|
* uint32 bHasFormatOptions ← legacy UE3-style 4-byte bool, NOT 1-byte
|
|
137
137
|
* [FNumberFormattingOptions FormatOptions]
|
|
138
138
|
* uint32 bHasCulture ← also a uint32 bool
|
|
139
139
|
* [FString TargetCulture]
|
|
140
140
|
* FNumberFormattingOptions = AlwaysSign(uint32) + UseGrouping(uint32) +
|
|
141
|
-
* RoundingMode(int8) + 4
|
|
142
|
-
*
|
|
141
|
+
* RoundingMode(int8) + 4 x int32 digit-count fields.
|
|
142
|
+
* All other types: remaining bytes stored in _raw for verbatim round-trip.
|
|
143
143
|
*/
|
|
144
144
|
export class FTextValue {
|
|
145
145
|
constructor({
|
|
@@ -168,6 +168,9 @@ export class FTextValue {
|
|
|
168
168
|
this._keyIsNull = keyIsNull;
|
|
169
169
|
this.sourceString = sourceString ?? null;
|
|
170
170
|
this._sourceStringIsNull = sourceStringIsNull;
|
|
171
|
+
} else if (historyType === 1) {
|
|
172
|
+
this.sourceFmt = sourceFmt ?? null; // FTextValue (the pattern)
|
|
173
|
+
this.arguments = args ?? []; // [{key, keyIsNull, type, value}]
|
|
171
174
|
} else if (historyType === 2) {
|
|
172
175
|
this.sourceFmt = sourceFmt ?? null; // FTextValue (the pattern)
|
|
173
176
|
this.arguments = args ?? []; // [{type, value}]
|
|
@@ -185,6 +188,7 @@ export class FTextValue {
|
|
|
185
188
|
get text() {
|
|
186
189
|
if (this.historyType === -1) return this.displayString;
|
|
187
190
|
if (this.historyType === 0) return this.sourceString ?? null;
|
|
191
|
+
if (this.historyType === 1) return this.sourceFmt?.text ?? null;
|
|
188
192
|
if (this.historyType === 2) return this.sourceFmt?.text ?? null;
|
|
189
193
|
if (this.historyType === 4) {
|
|
190
194
|
const v = this.sourceValue?.value;
|
|
@@ -195,20 +199,21 @@ export class FTextValue {
|
|
|
195
199
|
}
|
|
196
200
|
|
|
197
201
|
/**
|
|
198
|
-
* Holds raw bytes we couldn't (or wouldn't) decode. `reason` is
|
|
199
|
-
* debugging only; encoding writes the bytes back verbatim
|
|
202
|
+
* Holds raw bytes we couldn't (or wouldn't) decode. `reason` is a free-form
|
|
203
|
+
* string for debugging only; encoding writes the bytes back verbatim, so a
|
|
204
|
+
* value the codec couldn't parse still round-trips byte-identical.
|
|
200
205
|
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
206
|
+
* OpaqueValue is the codec's universal fallback for everything from unknown
|
|
207
|
+
* property types to mid-decode recoveries (Struct/Array/Set/Map/Text whose
|
|
208
|
+
* inner shape didn't parse cleanly). The reader's contract is: on any
|
|
209
|
+
* structural failure inside a finite byte budget, rewind to the value's
|
|
210
|
+
* start and capture the budget verbatim into an OpaqueValue, so the outer
|
|
211
|
+
* stream stays byte-aligned regardless of what went wrong inside.
|
|
205
212
|
*/
|
|
206
213
|
export class OpaqueValue {
|
|
207
214
|
constructor(bytes, reason = null) {
|
|
208
|
-
this.
|
|
209
|
-
|
|
215
|
+
this.bytes = bytes;
|
|
216
|
+
this.reason = reason;
|
|
210
217
|
}
|
|
211
|
-
|
|
212
|
-
get reason() { return this._opaqueReason ?? null; }
|
|
213
|
-
write(writer) { writer.writeBytes(this._opaque); }
|
|
218
|
+
write(writer) { writer.writeBytes(this.bytes); }
|
|
214
219
|
}
|
package/wscodec.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* wscodec
|
|
2
|
+
* wscodec: pure-JS codec for Soulmask actor_data property streams.
|
|
3
3
|
*
|
|
4
4
|
* The library accepts uncompressed bytes (the payload that comes out of
|
|
5
5
|
* Soulmask's outer LZ4 wrapper) and returns a JavaScript object tree, and
|
|
6
|
-
* vice versa. It has zero runtime dependencies
|
|
6
|
+
* vice versa. It has zero runtime dependencies; LZ4 handling, SQLite
|
|
7
7
|
* access, etc. are the caller's responsibility.
|
|
8
8
|
*
|
|
9
9
|
* Wire layout (the bytes accepted by `UnrealBlob.decode` and produced by
|
|
@@ -18,14 +18,14 @@
|
|
|
18
18
|
* The SQLite `actor_table.data_version` column stores the NEGATIVE of the
|
|
19
19
|
* wire-format DataVersion. A healthy blob with DataVersion=2 lives in a row
|
|
20
20
|
* whose `data_version` column reads -2. The wire bytes themselves are
|
|
21
|
-
* always the unsigned 0x00000002
|
|
21
|
+
* always the unsigned 0x00000002; the negation is purely a column-side
|
|
22
22
|
* convention.
|
|
23
23
|
*
|
|
24
24
|
* Round-trip safety: when `_dirty` is false, `serialize` returns the
|
|
25
25
|
* original input bytes verbatim. When `_dirty` is true, it re-emits the
|
|
26
26
|
* property stream from scratch via `writePropertyStream`. Both paths are
|
|
27
27
|
* verified byte-identical against every row in a tested world.db
|
|
28
|
-
* (
|
|
28
|
+
* (`npm test`).
|
|
29
29
|
*
|
|
30
30
|
* Re-exports the most commonly used types so callers can do
|
|
31
31
|
* import { UnrealBlob, FName, FGuid, ObjectRef, ... } from 'wscodec';
|
|
@@ -38,7 +38,7 @@ import { readPropertyStream, writePropertyStream } from './properties.mjs';
|
|
|
38
38
|
// Convenience re-exports for the public API surface.
|
|
39
39
|
export { Cursor, Writer } from './io.mjs';
|
|
40
40
|
export { FName, FGuid } from './primitives.mjs';
|
|
41
|
-
export { StructValue, STRUCT_HANDLERS } from './structs.mjs';
|
|
41
|
+
export { StructValue, STRUCT_HANDLERS, registerStructHandler } from './structs.mjs';
|
|
42
42
|
export { ObjectRef, SoftObjectRef, FTextValue, OpaqueValue } from './values.mjs';
|
|
43
43
|
export {
|
|
44
44
|
PropertyTag, Property,
|
|
@@ -46,6 +46,14 @@ export {
|
|
|
46
46
|
readPropertyStream, writePropertyStream, writeNestedPropertyStream,
|
|
47
47
|
readValue, writeValue,
|
|
48
48
|
} from './properties.mjs';
|
|
49
|
+
// JSON converter: declared at the bottom so UnrealBlob (below) is already
|
|
50
|
+
// defined when json.mjs's deferred references resolve. ESM live bindings
|
|
51
|
+
// keep this load-order safe even with the json.mjs ↔ wscodec.mjs cycle.
|
|
52
|
+
export {
|
|
53
|
+
blobToJSON, jsonToBlob,
|
|
54
|
+
blobToJSONString, jsonStringToBlob,
|
|
55
|
+
jsonReplacer, jsonReviver,
|
|
56
|
+
} from './json.mjs';
|
|
49
57
|
|
|
50
58
|
const NAME = 'unreal-properties';
|
|
51
59
|
const VERSION_HEADER_SIZE = 4;
|
|
@@ -69,10 +77,25 @@ export class UnrealBlob {
|
|
|
69
77
|
this._dirty = false;
|
|
70
78
|
}
|
|
71
79
|
|
|
72
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Codec-adapter name (`'unreal-properties'`). Surfaced for registries that
|
|
82
|
+
* dispatch on a codec's `kind` field; matches the `name` on the bare
|
|
83
|
+
* `codec` adapter exported at the bottom of this module.
|
|
84
|
+
*/
|
|
85
|
+
get kind() { return NAME; }
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Number of bytes the blob was decoded from (`_raw.length`), or 0 if the
|
|
89
|
+
* blob was constructed without an input buffer. NOT the post-serialize
|
|
90
|
+
* size; for that, call `serialize().length`.
|
|
91
|
+
*/
|
|
73
92
|
get totalSize() { return this._raw ? this._raw.length : 0; }
|
|
74
93
|
|
|
75
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* First TOP-LEVEL property with the given tag name, or null. Does NOT
|
|
96
|
+
* traverse into embedded streams, struct values, array elements, or map
|
|
97
|
+
* entries. Use `findPropertyDeep` to walk the full tree.
|
|
98
|
+
*/
|
|
76
99
|
findProperty(propName) {
|
|
77
100
|
for (const p of this.properties) {
|
|
78
101
|
if (p.tag && p.tag.name && p.tag.name.value === propName) return p;
|
|
@@ -80,6 +103,23 @@ export class UnrealBlob {
|
|
|
80
103
|
return null;
|
|
81
104
|
}
|
|
82
105
|
|
|
106
|
+
/**
|
|
107
|
+
* First property with the given tag name found anywhere in the property
|
|
108
|
+
* tree, or null. Performs a depth-first traversal across:
|
|
109
|
+
*
|
|
110
|
+
* - top-level properties
|
|
111
|
+
* - ObjectRef.embedded streams (nested ObjectProperty values)
|
|
112
|
+
* - StructValue.value when it's a tagged property array
|
|
113
|
+
* - ArrayProperty / SetProperty struct elements
|
|
114
|
+
* - MapProperty entries: both key (if StructValue) and value
|
|
115
|
+
*
|
|
116
|
+
* Returns the first match in traversal order; later matches are not
|
|
117
|
+
* surfaced. For all matches, walk the tree manually.
|
|
118
|
+
*/
|
|
119
|
+
findPropertyDeep(propName) {
|
|
120
|
+
return _findPropertyDeep(this.properties, propName);
|
|
121
|
+
}
|
|
122
|
+
|
|
83
123
|
static detect(u8) {
|
|
84
124
|
if (!u8 || u8.length < VERSION_HEADER_SIZE) return false;
|
|
85
125
|
const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
|
@@ -90,7 +130,7 @@ export class UnrealBlob {
|
|
|
90
130
|
* Parse uncompressed property-stream bytes into an UnrealBlob.
|
|
91
131
|
*
|
|
92
132
|
* On unrecoverable structural failure the returned blob has `error` set
|
|
93
|
-
* and `properties` empty
|
|
133
|
+
* and `properties` empty. Callers that need a hard failure should check
|
|
94
134
|
* `blob.error` after decode.
|
|
95
135
|
*/
|
|
96
136
|
static decode(u8) {
|
|
@@ -125,15 +165,39 @@ export class UnrealBlob {
|
|
|
125
165
|
/**
|
|
126
166
|
* Return the uncompressed property-stream bytes for this blob.
|
|
127
167
|
*
|
|
128
|
-
* Pass-through when `_dirty` is false: returns the input bytes verbatim
|
|
129
|
-
*
|
|
130
|
-
*
|
|
168
|
+
* Pass-through when `_dirty` is false: returns the input bytes verbatim,
|
|
169
|
+
* even if `error` is set (the original bytes round-trip even when decode
|
|
170
|
+
* was incomplete). Re-encodes from `properties` when `_dirty` is true,
|
|
171
|
+
* appending `bodyTrailing` after the None terminator + 4-byte FName.Number
|
|
131
172
|
* trailer that `writePropertyStream` emits.
|
|
173
|
+
*
|
|
174
|
+
* Options:
|
|
175
|
+
* `recomputeSizes` — override `this._recomputeSizes`. When truthy, every
|
|
176
|
+
* PropertyTag.size field (and ArrayValue innerTag.size) is rewritten
|
|
177
|
+
* from the actual encoded value byte count. Required after edits that
|
|
178
|
+
* change variable-length fields (FString contents, FText, MapValue
|
|
179
|
+
* contents, etc.); without it, a stale size field leaves the Soulmask
|
|
180
|
+
* reader misaligned and the blob is rejected on load. The default is
|
|
181
|
+
* to honor `this._recomputeSizes`; jsonToBlob sets that to true so
|
|
182
|
+
* the JSON pipeline always recomputes.
|
|
183
|
+
*
|
|
184
|
+
* Throws if `_dirty` is true AND `error` is set: re-emitting would produce
|
|
185
|
+
* a malformed stream (the property tree is empty after a structural
|
|
186
|
+
* failure). Clear `.error` first if you intentionally want to emit from
|
|
187
|
+
* an externally-constructed properties array.
|
|
132
188
|
*/
|
|
133
|
-
serialize() {
|
|
189
|
+
serialize({ recomputeSizes } = {}) {
|
|
134
190
|
if (!this._dirty && this._raw instanceof Uint8Array) return this._raw;
|
|
135
191
|
|
|
192
|
+
if (this.error != null) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`UnrealBlob.serialize: cannot re-emit a blob with decode error (${this.error}). ` +
|
|
195
|
+
`Leave _dirty=false to pass through _raw verbatim, or clear .error if you've replaced .properties manually.`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
136
199
|
const w = new Writer(this._raw?.length || 256);
|
|
200
|
+
if (recomputeSizes ?? this._recomputeSizes) w._wsRecomputeSizes = true;
|
|
137
201
|
w.writeUint32(this.versionTag);
|
|
138
202
|
writePropertyStream(w, this.properties, /*emitTerminatorTrailer=*/true);
|
|
139
203
|
if (this.bodyTrailing && this.bodyTrailing.length > 0) {
|
|
@@ -143,6 +207,59 @@ export class UnrealBlob {
|
|
|
143
207
|
}
|
|
144
208
|
}
|
|
145
209
|
|
|
210
|
+
// Deep-search helper. Walks the property tree in depth-first order and
|
|
211
|
+
// returns the first Property whose tag.name matches. Kept out of the class
|
|
212
|
+
// body so the recursion can reach into nested shapes uniformly without
|
|
213
|
+
// having to thread `this` around.
|
|
214
|
+
function _findPropertyDeep(properties, propName) {
|
|
215
|
+
if (!Array.isArray(properties)) return null;
|
|
216
|
+
for (const p of properties) {
|
|
217
|
+
if (p.tag && p.tag.name && p.tag.name.value === propName) return p;
|
|
218
|
+
const v = p.value;
|
|
219
|
+
if (v == null) continue;
|
|
220
|
+
// ObjectRef with embedded property stream.
|
|
221
|
+
if (v.embedded) {
|
|
222
|
+
const hit = _findPropertyDeep(v.embedded, propName);
|
|
223
|
+
if (hit) return hit;
|
|
224
|
+
}
|
|
225
|
+
// StructValue: .value is either a property array (unknown struct) or a
|
|
226
|
+
// plain binary record (known struct). Only the array form is searchable.
|
|
227
|
+
if (v._structName && Array.isArray(v.value)) {
|
|
228
|
+
const hit = _findPropertyDeep(v.value, propName);
|
|
229
|
+
if (hit) return hit;
|
|
230
|
+
}
|
|
231
|
+
// ArrayProperty / SetProperty struct elements + ObjectRef embeddeds.
|
|
232
|
+
if (Array.isArray(v.elements)) {
|
|
233
|
+
for (const e of v.elements) {
|
|
234
|
+
if (e && e._structName && Array.isArray(e.value)) {
|
|
235
|
+
const hit = _findPropertyDeep(e.value, propName);
|
|
236
|
+
if (hit) return hit;
|
|
237
|
+
}
|
|
238
|
+
if (e && e.embedded) {
|
|
239
|
+
const hit = _findPropertyDeep(e.embedded, propName);
|
|
240
|
+
if (hit) return hit;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// MapProperty entries: both key (if StructValue) and value can hold a
|
|
245
|
+
// nested property stream.
|
|
246
|
+
if (Array.isArray(v.entries)) {
|
|
247
|
+
for (const ent of v.entries) {
|
|
248
|
+
if (ent.key && ent.key._structName && Array.isArray(ent.key.value)) {
|
|
249
|
+
const hit = _findPropertyDeep(ent.key.value, propName);
|
|
250
|
+
if (hit) return hit;
|
|
251
|
+
}
|
|
252
|
+
const ev = ent.value;
|
|
253
|
+
if (ev && ev._structName && Array.isArray(ev.value)) {
|
|
254
|
+
const hit = _findPropertyDeep(ev.value, propName);
|
|
255
|
+
if (hit) return hit;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
146
263
|
// Generic codec-adapter shape (name + detect + decode + encode), suitable
|
|
147
264
|
// for plugging into any registry that uses that quartet. Operates on the
|
|
148
265
|
// uncompressed bytes that `UnrealBlob.decode` accepts; for callers reading
|