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/structs.mjs ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * StructValue + STRUCT_HANDLERS registry.
3
+ *
4
+ * Soulmask is UE 4.27 so "core" structs (Vector etc.) use 32-bit floats.
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
+ * — StructValue.read is supplied a `streamReader` callback to avoid a
8
+ * load-order cycle).
9
+ */
10
+
11
+ import { FGuid } from './primitives.mjs';
12
+
13
+ // Binary struct handlers. Each entry has read(cursor) → plain object and
14
+ // write(writer, plainObject). The plain object is what callers see as
15
+ // `structValue.value` when the struct is one of these known shapes.
16
+ export const STRUCT_HANDLERS = {
17
+ Vector: { read: c => ({ x: c.readFloat32(), y: c.readFloat32(), z: c.readFloat32() }),
18
+ write: (w, v) => { w.writeFloat32(v.x); w.writeFloat32(v.y); w.writeFloat32(v.z); } },
19
+ Vector2D: { read: c => ({ x: c.readFloat32(), y: c.readFloat32() }),
20
+ write: (w, v) => { w.writeFloat32(v.x); w.writeFloat32(v.y); } },
21
+ Vector4: { read: c => ({ x: c.readFloat32(), y: c.readFloat32(), z: c.readFloat32(), w: c.readFloat32() }),
22
+ write: (w, v) => { w.writeFloat32(v.x); w.writeFloat32(v.y); w.writeFloat32(v.z); w.writeFloat32(v.w); } },
23
+ Rotator: { read: c => ({ pitch: c.readFloat32(), yaw: c.readFloat32(), roll: c.readFloat32() }),
24
+ write: (w, v) => { w.writeFloat32(v.pitch); w.writeFloat32(v.yaw); w.writeFloat32(v.roll); } },
25
+ Quat: { read: c => ({ x: c.readFloat32(), y: c.readFloat32(), z: c.readFloat32(), w: c.readFloat32() }),
26
+ write: (w, v) => { w.writeFloat32(v.x); w.writeFloat32(v.y); w.writeFloat32(v.z); w.writeFloat32(v.w); } },
27
+ Color: { read: c => ({ b: c.readUint8(), g: c.readUint8(), r: c.readUint8(), a: c.readUint8() }),
28
+ write: (w, v) => { w.writeUint8(v.b); w.writeUint8(v.g); w.writeUint8(v.r); w.writeUint8(v.a); } },
29
+ LinearColor: { read: c => ({ r: c.readFloat32(), g: c.readFloat32(), b: c.readFloat32(), a: c.readFloat32() }),
30
+ write: (w, v) => { w.writeFloat32(v.r); w.writeFloat32(v.g); w.writeFloat32(v.b); w.writeFloat32(v.a); } },
31
+ Guid: { read: c => FGuid.read(c).value,
32
+ write: (w, v) => new FGuid(v).write(w) },
33
+ DateTime: { read: c => c.readInt64().toString(),
34
+ write: (w, v) => w.writeInt64(v) },
35
+ Timespan: { read: c => c.readInt64().toString(),
36
+ write: (w, v) => w.writeInt64(v) },
37
+ IntPoint: { read: c => ({ x: c.readInt32(), y: c.readInt32() }),
38
+ write: (w, v) => { w.writeInt32(v.x); w.writeInt32(v.y); } },
39
+ IntVector: { read: c => ({ x: c.readInt32(), y: c.readInt32(), z: c.readInt32() }),
40
+ write: (w, v) => { w.writeInt32(v.x); w.writeInt32(v.y); w.writeInt32(v.z); } },
41
+ Box: { read: c => ({ min: STRUCT_HANDLERS.Vector.read(c), max: STRUCT_HANDLERS.Vector.read(c), isValid: c.readUint8() }),
42
+ write: (w, v) => { STRUCT_HANDLERS.Vector.write(w, v.min); STRUCT_HANDLERS.Vector.write(w, v.max); w.writeUint8(v.isValid); } },
43
+ Sphere: { read: c => ({ center: STRUCT_HANDLERS.Vector.read(c), radius: c.readFloat32() }),
44
+ write: (w, v) => { STRUCT_HANDLERS.Vector.write(w, v.center); w.writeFloat32(v.radius); } },
45
+ Plane: { read: c => ({ x: c.readFloat32(), y: c.readFloat32(), z: c.readFloat32(), w: c.readFloat32() }),
46
+ write: (w, v) => { w.writeFloat32(v.x); w.writeFloat32(v.y); w.writeFloat32(v.z); w.writeFloat32(v.w); } },
47
+ Transform: { read: c => ({ rotation: STRUCT_HANDLERS.Quat.read(c), translation: STRUCT_HANDLERS.Vector.read(c), scale3D: STRUCT_HANDLERS.Vector.read(c) }),
48
+ 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
+ };
50
+
51
+ export class StructValue {
52
+ constructor(structName, { value = null, terminated = false, decodeError = null, opaqueTail = null } = {}) {
53
+ this._structName = structName;
54
+ this.value = value;
55
+ this.terminated = terminated;
56
+ if (decodeError) this._structDecodeError = decodeError;
57
+ if (opaqueTail) this._opaqueTail = opaqueTail;
58
+ }
59
+
60
+ get structName() { return this._structName; }
61
+ get isKnownBinary() { return STRUCT_HANDLERS[this._structName] != null; }
62
+
63
+ /**
64
+ * `streamReader(cursor, endOffset)` is `readPropertyStream` from
65
+ * properties.mjs, passed in to avoid a module cycle. Returns
66
+ * { properties, terminated, endPos }.
67
+ *
68
+ * `peekFn(cursor)` (optional) peeks at the cursor without advancing it
69
+ * and returns true if the bytes look like the start of a tagged
70
+ * PropertyTag (FString name with identifier-character ASCII content).
71
+ * When supplied and the wire bytes look tagged, the read switches to
72
+ * the property-stream path even for structs that have a known binary
73
+ * handler — Soulmask encodes known-binary structs (Transform, Box, ...)
74
+ * as TAGGED property streams inside Map struct values, which would
75
+ * otherwise be misread as raw 40-byte Transforms / etc. The decision
76
+ * is recorded on the returned StructValue via `Array.isArray(value)`,
77
+ * so `write()` dispatches correctly.
78
+ */
79
+ static read(cursor, structName, sizeHint, streamReader, peekFn = null) {
80
+ const handler = STRUCT_HANDLERS[structName];
81
+ if (handler && (!peekFn || !peekFn(cursor))) {
82
+ return new StructValue(structName, { value: handler.read(cursor) });
83
+ }
84
+ const startOff = cursor.pos();
85
+ let nested;
86
+ try { nested = streamReader(cursor, startOff + sizeHint); }
87
+ catch (e) {
88
+ const consumed = cursor.pos() - startOff;
89
+ const tail = sizeHint - consumed;
90
+ const opaqueTail = tail > 0 ? cursor.readBytes(tail).slice() : null;
91
+ return new StructValue(structName, { value: [], decodeError: e.message, opaqueTail });
92
+ }
93
+ return new StructValue(structName, { value: nested.properties, terminated: nested.terminated });
94
+ }
95
+
96
+ /**
97
+ * `streamWriter(writer, propertiesArray)` is `writePropertyStream` (nested form).
98
+ *
99
+ * Dispatches on the value shape, NOT on whether a handler exists: when
100
+ * `value` is an array of Property objects, the struct was decoded via
101
+ * the property-stream path (regardless of handler registration) and
102
+ * must be written the same way for byte-identical round-trip. The
103
+ * handler path runs only when the value is a plain object.
104
+ */
105
+ write(writer, streamWriter) {
106
+ if (Array.isArray(this.value)) {
107
+ if (this._structDecodeError && this.value.length === 0) {
108
+ if (this._opaqueTail) writer.writeBytes(this._opaqueTail);
109
+ return;
110
+ }
111
+ if (this._structDecodeError && this.value.length > 0) {
112
+ throw new Error(`StructValue.write: struct '${this._structName}' had decode error and partial properties; cannot safely re-emit`);
113
+ }
114
+ streamWriter(writer, this.value);
115
+ return;
116
+ }
117
+ const handler = STRUCT_HANDLERS[this._structName];
118
+ if (handler) { handler.write(writer, this.value); return; }
119
+ if (this._structDecodeError) {
120
+ if (this._opaqueTail) writer.writeBytes(this._opaqueTail);
121
+ return;
122
+ }
123
+ throw new Error(`StructValue.write: no handler for '${this._structName}' and value is not a property array`);
124
+ }
125
+ }
package/values.mjs ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Wrapper classes for non-trivial property-value shapes:
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
+ *
8
+ * Array/Set/Map values live in properties.mjs because they're tightly
9
+ * coupled to PropertyTag (struct arrays carry an inner tag).
10
+ */
11
+
12
+ // Circular import: properties.mjs imports ObjectRef/SoftObjectRef/OpaqueValue
13
+ // from this module, and ObjectRef.write needs writeNestedPropertyStream
14
+ // at call time. ESM live bindings make this safe as long as the binding is
15
+ // only USED inside function bodies (deferred), which it is.
16
+ import { writeNestedPropertyStream } from './properties.mjs';
17
+
18
+ /**
19
+ * Soulmask ObjectProperty value layout. Each field is optional — the wire
20
+ * shape is bounded by the property tag's size budget and the reader stops
21
+ * at whichever boundary it hits first:
22
+ *
23
+ * u8 kind always present (observed 0x03 at top level; arrays vary)
24
+ * u32 kindOnePrefix ONLY when kind === 0x01. Soulmask-specific
25
+ * 4-byte field sitting between the kind byte
26
+ * and pathFS. Observed value is always 1; the
27
+ * semantic meaning is unclear (a flag, an
28
+ * FName.Number, or a count) — we capture and
29
+ * replay it verbatim for byte-identical
30
+ * round-trip. Seen on hard actor references
31
+ * like NPC `HBindBGCompActor` (the pawn's
32
+ * link to its inventory actor).
33
+ * FString path present iff there's budget left after kind
34
+ * (and kindOnePrefix, when applicable)
35
+ * FString classPath present iff sizeHint > kind + path length
36
+ * nested stream present iff sizeHint > kind + path + classPath length;
37
+ * terminated by None, optionally followed by a 4-byte
38
+ * FName.Number trailer for certain Soulmask embeddeds
39
+ *
40
+ * `null` for `path` or `classPath` means the field was NOT on the wire
41
+ * (so the writer skips it). An empty string with the corresponding `isNull`
42
+ * flag preserves the wire distinction between FString null-form (SaveNum=0,
43
+ * 4 bytes) and empty-with-terminator (SaveNum=1, 5 bytes).
44
+ */
45
+ export class ObjectRef {
46
+ constructor({
47
+ kind = 0x03,
48
+ kindOnePrefix = null,
49
+ path = null,
50
+ pathIsNull = false,
51
+ classPath = null,
52
+ classPathIsNull = false,
53
+ embedded = null,
54
+ terminated = false,
55
+ hasTerminatorTrailer = false,
56
+ } = {}) {
57
+ this._objectKind = kind;
58
+ // null = "not on the wire" (any kind other than 0x01, or a kind=0x01
59
+ // ObjectRef built programmatically without an explicit prefix).
60
+ // Numeric = capture from the wire; replayed verbatim on write.
61
+ this._kindOnePrefix = kindOnePrefix;
62
+ this.path = path;
63
+ this._pathIsNull = pathIsNull;
64
+ this.classPath = classPath;
65
+ this._classPathIsNull = classPathIsNull;
66
+ this.embedded = embedded;
67
+ this.terminated = terminated;
68
+ // When true, the embedded property stream was followed by a 4-byte
69
+ // FName.Number trailer (the outermost-stream None-trailer convention,
70
+ // applied here by Soulmask to some nested ObjectProperty embeddeds —
71
+ // e.g. JianZhuInstGLQComponent). The reader detects this when exactly
72
+ // 4 trailing bytes remain inside the tag's size budget; the writer
73
+ // replays them so round-trip stays byte-identical.
74
+ this.hasTerminatorTrailer = hasTerminatorTrailer;
75
+ }
76
+
77
+ /** When true, this ObjectRef carries an embedded nested property stream. */
78
+ get hasEmbedded() { return Array.isArray(this.embedded); }
79
+
80
+ write(writer, { requireClassPath = false } = {}) {
81
+ writer.writeUint8(this._objectKind ?? 0x03);
82
+ // Soulmask kind=0x01 actor reference: replay the captured 4-byte
83
+ // prefix between the kind byte and the path FString. Only emitted
84
+ // when it was on the wire at read time (null otherwise).
85
+ if (this._kindOnePrefix !== null && this._kindOnePrefix !== undefined) {
86
+ writer.writeUint32(this._kindOnePrefix);
87
+ }
88
+ // Kind-only on the wire: path was null AND nothing forces emission.
89
+ if (this.path === null && !requireClassPath && !this.hasEmbedded) return;
90
+ writer.writeFString(this.path ?? '', null, this._pathIsNull);
91
+ // classPath was either on the wire (non-null) or is forced by `requireClassPath`
92
+ // (the array-of-ObjectProperty caller, which always writes all three fields)
93
+ // or by the presence of an embedded stream (the stream can't be reached
94
+ // on the wire without a classPath FString in front of it).
95
+ if (requireClassPath || this.classPath !== null || this.hasEmbedded) {
96
+ writer.writeFString(this.classPath ?? '', null, this._classPathIsNull);
97
+ }
98
+ if (this.hasEmbedded) {
99
+ writeNestedPropertyStream(writer, this.embedded, this.hasTerminatorTrailer);
100
+ }
101
+ }
102
+ }
103
+
104
+ export class SoftObjectRef {
105
+ constructor({ assetPath = '', subPath = '' } = {}) {
106
+ this.assetPath = assetPath;
107
+ this.subPath = subPath;
108
+ }
109
+ write(writer) {
110
+ writer.writeFString(this.assetPath);
111
+ writer.writeFString(this.subPath);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Decoded FText value (TextProperty).
117
+ *
118
+ * UE4 FText wire format:
119
+ * uint32 Flags
120
+ * int8 HistoryType
121
+ * — HistoryType -1 (None / culture-invariant):
122
+ * int32 bHasCultureInvariantString
123
+ * [FString displayString] (only when bHasCultureInvariantString != 0)
124
+ * — HistoryType 0 (Base / localized):
125
+ * FString Namespace
126
+ * FString Key
127
+ * FString SourceString
128
+ * — HistoryType 2 (OrderedFormat):
129
+ * FText SourceFmt — the format pattern, e.g. "{0} < {1} >"
130
+ * int32 NumArguments
131
+ * for each: int8 ContentType + value
132
+ * 0=Int(int64) 1=UInt(uint64) 2=Float(f32) 3=Double(f64)
133
+ * 4=Text(FText) 5=Gender(int8)
134
+ * — HistoryType 4 (AsNumber, FTextHistory_AsNumber):
135
+ * FFormatArgumentValue SourceValue (int8 type + value-by-type)
136
+ * uint32 bHasFormatOptions ← legacy UE3-style 4-byte bool, NOT 1-byte
137
+ * [FNumberFormattingOptions FormatOptions]
138
+ * uint32 bHasCulture ← also a uint32 bool
139
+ * [FString TargetCulture]
140
+ * FNumberFormattingOptions = AlwaysSign(uint32) + UseGrouping(uint32) +
141
+ * RoundingMode(int8) + 4 × int32 digit-count fields.
142
+ * — All other types: remaining bytes stored in _raw for verbatim round-trip.
143
+ */
144
+ export class FTextValue {
145
+ constructor({
146
+ flags = 0, historyType = -1,
147
+ displayString, displayStringIsNull = false,
148
+ namespace, namespaceIsNull = false,
149
+ key, keyIsNull = false,
150
+ sourceString, sourceStringIsNull = false,
151
+ sourceFmt, arguments: args,
152
+ sourceValue, formatOptions, culture, cultureIsNull = false,
153
+ _raw,
154
+ } = {}) {
155
+ this.flags = flags;
156
+ this.historyType = historyType;
157
+ // Per-field isNull flags preserve the wire's choice between FString
158
+ // null-form (SaveNum=0, 4 B on wire) and empty-with-terminator
159
+ // (SaveNum=1, 5 B). The two forms decode to the same JS string ("")
160
+ // but round-trip-equal encoding requires picking the original form.
161
+ if (historyType === -1) {
162
+ this.displayString = displayString ?? null;
163
+ this._displayStringIsNull = displayStringIsNull;
164
+ } else if (historyType === 0) {
165
+ this.namespace = namespace ?? '';
166
+ this._namespaceIsNull = namespaceIsNull;
167
+ this.key = key ?? '';
168
+ this._keyIsNull = keyIsNull;
169
+ this.sourceString = sourceString ?? null;
170
+ this._sourceStringIsNull = sourceStringIsNull;
171
+ } else if (historyType === 2) {
172
+ this.sourceFmt = sourceFmt ?? null; // FTextValue (the pattern)
173
+ this.arguments = args ?? []; // [{type, value}]
174
+ } else if (historyType === 4) {
175
+ this.sourceValue = sourceValue ?? null; // { type: int8, value: number|string|FTextValue }
176
+ this.formatOptions = formatOptions ?? null; // FNumberFormattingOptions or null
177
+ this.culture = culture ?? null; // FString value or null
178
+ this._cultureIsNull = cultureIsNull;
179
+ } else {
180
+ this._raw = _raw ?? null;
181
+ }
182
+ }
183
+
184
+ /** Best displayable string for this FText, or null if none. */
185
+ get text() {
186
+ if (this.historyType === -1) return this.displayString;
187
+ if (this.historyType === 0) return this.sourceString ?? null;
188
+ if (this.historyType === 2) return this.sourceFmt?.text ?? null;
189
+ if (this.historyType === 4) {
190
+ const v = this.sourceValue?.value;
191
+ return v != null ? String(v) : null;
192
+ }
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Holds raw bytes we couldn't (or wouldn't) decode. `reason` is for
199
+ * debugging only; encoding writes the bytes back verbatim.
200
+ *
201
+ * Two access paths intentionally co-exist:
202
+ * - `.bytes` / `.reason` — the canonical getter API.
203
+ * - `._opaque` / `._opaqueReason` — backing-store fields, also publicly
204
+ * readable for consumers that pre-date the getter API.
205
+ */
206
+ export class OpaqueValue {
207
+ constructor(bytes, reason = null) {
208
+ this._opaque = bytes;
209
+ if (reason) this._opaqueReason = reason;
210
+ }
211
+ get bytes() { return this._opaque; }
212
+ get reason() { return this._opaqueReason ?? null; }
213
+ write(writer) { writer.writeBytes(this._opaque); }
214
+ }
package/wscodec.mjs ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * wscodec — pure-JS codec for Soulmask actor_data property streams.
3
+ *
4
+ * The library accepts uncompressed bytes (the payload that comes out of
5
+ * Soulmask's outer LZ4 wrapper) and returns a JavaScript object tree, and
6
+ * vice versa. It has zero runtime dependencies — LZ4 handling, SQLite
7
+ * access, etc. are the caller's responsibility.
8
+ *
9
+ * Wire layout (the bytes accepted by `UnrealBlob.decode` and produced by
10
+ * `UnrealBlob.serialize`):
11
+ * [0..3] u32 LE version tag = 0x00000002
12
+ * [4..] FPropertyTag stream terminated by FString "None" + int32 0
13
+ *
14
+ * Soulmask actor_data envelope (handled OUTSIDE this library):
15
+ * [0..3] u32 LE outer version tag = 0x00000002
16
+ * [4..] LZ4 block size-prefixed; decompresses to the bytes above.
17
+ *
18
+ * The SQLite `actor_table.data_version` column stores the NEGATIVE of the
19
+ * wire-format DataVersion. A healthy blob with DataVersion=2 lives in a row
20
+ * whose `data_version` column reads -2. The wire bytes themselves are
21
+ * always the unsigned 0x00000002 — the negation is purely a column-side
22
+ * convention.
23
+ *
24
+ * Round-trip safety: when `_dirty` is false, `serialize` returns the
25
+ * original input bytes verbatim. When `_dirty` is true, it re-emits the
26
+ * property stream from scratch via `writePropertyStream`. Both paths are
27
+ * verified byte-identical against every row in a tested world.db
28
+ * (174.6 MB, 11,667 rows; `npm test`).
29
+ *
30
+ * Re-exports the most commonly used types so callers can do
31
+ * import { UnrealBlob, FName, FGuid, ObjectRef, ... } from 'wscodec';
32
+ * instead of reaching into individual submodules.
33
+ */
34
+
35
+ import { Cursor, Writer } from './io.mjs';
36
+ import { readPropertyStream, writePropertyStream } from './properties.mjs';
37
+
38
+ // Convenience re-exports for the public API surface.
39
+ export { Cursor, Writer } from './io.mjs';
40
+ export { FName, FGuid } from './primitives.mjs';
41
+ export { StructValue, STRUCT_HANDLERS } from './structs.mjs';
42
+ export { ObjectRef, SoftObjectRef, FTextValue, OpaqueValue } from './values.mjs';
43
+ export {
44
+ PropertyTag, Property,
45
+ ArrayValue, SetValue, MapValue,
46
+ readPropertyStream, writePropertyStream, writeNestedPropertyStream,
47
+ readValue, writeValue,
48
+ } from './properties.mjs';
49
+
50
+ const NAME = 'unreal-properties';
51
+ const VERSION_HEADER_SIZE = 4;
52
+ export const VERSION_TAG = 0x00000002;
53
+
54
+ export class UnrealBlob {
55
+ constructor({
56
+ versionTag = VERSION_TAG,
57
+ properties = [],
58
+ terminated = false,
59
+ bodyTrailing = null,
60
+ error = null,
61
+ raw = null,
62
+ } = {}) {
63
+ this.versionTag = versionTag;
64
+ this.properties = properties;
65
+ this.terminated = terminated;
66
+ this.bodyTrailing = bodyTrailing;
67
+ this.error = error;
68
+ this._raw = raw;
69
+ this._dirty = false;
70
+ }
71
+
72
+ get kind() { return NAME; }
73
+ get totalSize() { return this._raw ? this._raw.length : 0; }
74
+
75
+ /** First top-level property with the given name, or null. */
76
+ findProperty(propName) {
77
+ for (const p of this.properties) {
78
+ if (p.tag && p.tag.name && p.tag.name.value === propName) return p;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ static detect(u8) {
84
+ if (!u8 || u8.length < VERSION_HEADER_SIZE) return false;
85
+ const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
86
+ return dv.getUint32(0, true) === VERSION_TAG;
87
+ }
88
+
89
+ /**
90
+ * Parse uncompressed property-stream bytes into an UnrealBlob.
91
+ *
92
+ * On unrecoverable structural failure the returned blob has `error` set
93
+ * and `properties` empty — callers that need a hard failure should check
94
+ * `blob.error` after decode.
95
+ */
96
+ static decode(u8) {
97
+ if (!UnrealBlob.detect(u8)) {
98
+ const head = u8 ? Array.from(u8.subarray(0, 4)).map(b => b.toString(16).padStart(2, '0')).join(' ') : '(empty)';
99
+ throw new Error(`UnrealBlob.decode: not an unreal-properties blob (header bytes: ${head})`);
100
+ }
101
+
102
+ const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
103
+ const versionTag = dv.getUint32(0, true);
104
+ const cursor = new Cursor(u8, VERSION_HEADER_SIZE);
105
+
106
+ let properties = [];
107
+ let terminated = false;
108
+ let bodyTrailing = null;
109
+ let error = null;
110
+
111
+ try {
112
+ const stream = readPropertyStream(cursor, u8.length, /*consumeTerminatorTrailer=*/true);
113
+ properties = stream.properties;
114
+ terminated = stream.terminated;
115
+ if (cursor.pos() < u8.length) {
116
+ bodyTrailing = u8.slice(cursor.pos());
117
+ }
118
+ } catch (e) {
119
+ error = e.message;
120
+ }
121
+
122
+ return new UnrealBlob({ versionTag, properties, terminated, bodyTrailing, error, raw: u8 });
123
+ }
124
+
125
+ /**
126
+ * Return the uncompressed property-stream bytes for this blob.
127
+ *
128
+ * Pass-through when `_dirty` is false: returns the input bytes verbatim.
129
+ * Re-encodes from `properties` when `_dirty` is true. `bodyTrailing`, if
130
+ * present, is appended after the None terminator + 4-byte FName.Number
131
+ * trailer that `writePropertyStream` emits.
132
+ */
133
+ serialize() {
134
+ if (!this._dirty && this._raw instanceof Uint8Array) return this._raw;
135
+
136
+ const w = new Writer(this._raw?.length || 256);
137
+ w.writeUint32(this.versionTag);
138
+ writePropertyStream(w, this.properties, /*emitTerminatorTrailer=*/true);
139
+ if (this.bodyTrailing && this.bodyTrailing.length > 0) {
140
+ w.writeBytes(this.bodyTrailing);
141
+ }
142
+ return w.finalize();
143
+ }
144
+ }
145
+
146
+ // Generic codec-adapter shape (name + detect + decode + encode), suitable
147
+ // for plugging into any registry that uses that quartet. Operates on the
148
+ // uncompressed bytes that `UnrealBlob.decode` accepts; for callers reading
149
+ // Soulmask's actor_data column directly, wrap this with the column's outer
150
+ // LZ4 envelope (4-byte version tag + size-prefixed LZ4 block).
151
+ export const codec = {
152
+ name: NAME,
153
+ detect: u8 => UnrealBlob.detect(u8),
154
+ decode: u8 => UnrealBlob.decode(u8),
155
+ encode: blob => blob.serialize(),
156
+ };