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/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
|
+
};
|