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/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
- * StructValue.read is supplied a `streamReader` callback to avoid a
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) plain object and
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: { read: c => FGuid.read(c).value,
32
- write: (w, v) => new FGuid(v).write(w) },
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 Soulmask encodes known-binary structs (Transform, Box, ...)
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 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)
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 the wire
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) we capture and
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
- * HistoryType -1 (None / culture-invariant):
121
+ * HistoryType -1 (None / culture-invariant):
122
122
  * int32 bHasCultureInvariantString
123
123
  * [FString displayString] (only when bHasCultureInvariantString != 0)
124
- * HistoryType 0 (Base / localized):
124
+ * HistoryType 0 (Base / localized):
125
125
  * FString Namespace
126
126
  * FString Key
127
127
  * FString SourceString
128
- * HistoryType 2 (OrderedFormat):
129
- * FText SourceFmt the format pattern, e.g. "{0} < {1} >"
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
- * HistoryType 4 (AsNumber, FTextHistory_AsNumber):
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 × int32 digit-count fields.
142
- * All other types: remaining bytes stored in _raw for verbatim round-trip.
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 for
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
- * 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.
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._opaque = bytes;
209
- if (reason) this._opaqueReason = reason;
215
+ this.bytes = bytes;
216
+ this.reason = reason;
210
217
  }
211
- get bytes() { return this._opaque; }
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 pure-JS codec for Soulmask actor_data property streams.
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 LZ4 handling, SQLite
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 the negation is purely a column-side
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
- * (174.6 MB, 11,667 rows; `npm test`).
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
- get kind() { return NAME; }
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
- /** First top-level property with the given name, or null. */
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 callers that need a hard failure should check
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
- * Re-encodes from `properties` when `_dirty` is true. `bodyTrailing`, if
130
- * present, is appended after the None terminator + 4-byte FName.Number
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