wscodec 0.2.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/json.mjs +614 -0
- package/package.json +9 -3
- package/properties.mjs +172 -25
- package/values.mjs +4 -0
- package/wscodec.mjs +20 -1
package/json.mjs
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON converter for the wscodec object tree. Part of the public codec API.
|
|
3
|
+
*
|
|
4
|
+
* blobToJSON(blob) UnrealBlob → JSON-safe plain object
|
|
5
|
+
* jsonToBlob(json) plain object → UnrealBlob
|
|
6
|
+
* blobToJSONString(blob) shortcut: blobToJSON + JSON.stringify (with -0/NaN/Inf handling)
|
|
7
|
+
* jsonStringToBlob(str) shortcut: JSON.parse (with -0/NaN/Inf handling) + jsonToBlob
|
|
8
|
+
*
|
|
9
|
+
* Round-trip guarantee: when the input bytes decode cleanly, the chain
|
|
10
|
+
* bytes → UnrealBlob.decode → blobToJSON → JSON.stringify
|
|
11
|
+
* → JSON.parse → jsonToBlob → UnrealBlob.serialize
|
|
12
|
+
* produces the same bytes as the input. Verified on every row of every tested
|
|
13
|
+
* Soulmask world.db (`node test/test-json-full.mjs <path>`).
|
|
14
|
+
*
|
|
15
|
+
* Design rules:
|
|
16
|
+
* - Convert as much as possible to structured JSON. Base64 is reserved for
|
|
17
|
+
* genuinely undecoded content: OpaqueValue (codec gave up on this slot)
|
|
18
|
+
* and ArrayValue._perElementTrailings (JianZhuInstYuanXings placement
|
|
19
|
+
* blocks whose semantics we don't decode).
|
|
20
|
+
* - Preserve every wire-form distinction needed for byte-identical round
|
|
21
|
+
* trip: FString isNull flags, ObjectRef kindOnePrefix /
|
|
22
|
+
* hasTerminatorTrailer, StructValue propStream-vs-binary form, FText
|
|
23
|
+
* historyType variants, ArrayValue innerTag for struct arrays,
|
|
24
|
+
* MapProperty<StructProperty,_> key/value conventions, etc.
|
|
25
|
+
* - JSON shapes are tagged with discriminators where the decoder needs to
|
|
26
|
+
* dispatch: `form` on StructValue ('binary' | 'propStream' | 'decodeError'),
|
|
27
|
+
* `historyType` on FTextValue, `_opaque` on OpaqueValue payloads.
|
|
28
|
+
*
|
|
29
|
+
* Numeric edge cases (-0, NaN, +/-Infinity) round-trip through
|
|
30
|
+
* blobToJSONString / jsonStringToBlob via a sentinel substitution; the bare
|
|
31
|
+
* blobToJSON / jsonToBlob pair preserves them inside the JS object tree
|
|
32
|
+
* (because Object.is(-0, -0)) but a naive JSON.stringify would lose them.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { FName, FGuid } from './primitives.mjs';
|
|
36
|
+
import { StructValue } from './structs.mjs';
|
|
37
|
+
import { PropertyTag, Property, ArrayValue, SetValue, MapValue } from './properties.mjs';
|
|
38
|
+
import { ObjectRef, SoftObjectRef, FTextValue, OpaqueValue } from './values.mjs';
|
|
39
|
+
// Intentional cycle: UnrealBlob is defined in wscodec.mjs, and wscodec.mjs
|
|
40
|
+
// re-exports this module's symbols. ESM live bindings make this safe as long
|
|
41
|
+
// as UnrealBlob is only USED inside function bodies (deferred), which it is.
|
|
42
|
+
import { UnrealBlob } from './wscodec.mjs';
|
|
43
|
+
|
|
44
|
+
// ── base64 helpers (Node Buffer) ────────────────────────────────────────────
|
|
45
|
+
function b64encode(u8) { return Buffer.from(u8.buffer, u8.byteOffset, u8.byteLength).toString('base64'); }
|
|
46
|
+
function b64decode(s) { return new Uint8Array(Buffer.from(s, 'base64')); }
|
|
47
|
+
|
|
48
|
+
// ── FName helpers ───────────────────────────────────────────────────────────
|
|
49
|
+
// Names round-trip as bare strings in the common case. When isUnicode or
|
|
50
|
+
// isNull is non-default, fall back to an object form so the wire flags survive.
|
|
51
|
+
function nameJSON(fn) {
|
|
52
|
+
if (fn == null) return null;
|
|
53
|
+
if (fn instanceof FName) {
|
|
54
|
+
if (!fn.isUnicode && !fn.isNull && (fn.number | 0) === 0) return fn.value;
|
|
55
|
+
return { value: fn.value, isUnicode: fn.isUnicode, isNull: fn.isNull, number: fn.number };
|
|
56
|
+
}
|
|
57
|
+
return String(fn);
|
|
58
|
+
}
|
|
59
|
+
function nameFromJSON(j) {
|
|
60
|
+
if (j == null) return null;
|
|
61
|
+
return FName.from(j);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── PropertyTag JSON ────────────────────────────────────────────────────────
|
|
65
|
+
function tagToJSON(tag) {
|
|
66
|
+
const j = { name: nameJSON(tag.name), type: nameJSON(tag.type), size: tag.size };
|
|
67
|
+
if (tag.arrayIndex) j.arrayIndex = tag.arrayIndex;
|
|
68
|
+
switch (tag.type?.value) {
|
|
69
|
+
case 'StructProperty': j.structName = nameJSON(tag.structName); j.structGuid = tag.structGuid?.value ?? null; break;
|
|
70
|
+
case 'BoolProperty': j.boolVal = tag.boolVal; break;
|
|
71
|
+
case 'ByteProperty':
|
|
72
|
+
case 'EnumProperty': j.enumName = nameJSON(tag.enumName); break;
|
|
73
|
+
case 'ArrayProperty':
|
|
74
|
+
case 'SetProperty': j.innerType = nameJSON(tag.innerType); break;
|
|
75
|
+
case 'MapProperty': j.innerType = nameJSON(tag.innerType); j.valueType = nameJSON(tag.valueType); break;
|
|
76
|
+
}
|
|
77
|
+
if (tag.hasPropertyGuid) {
|
|
78
|
+
j.hasPropertyGuid = true;
|
|
79
|
+
j.propertyGuid = tag.propertyGuid?.value ?? null;
|
|
80
|
+
}
|
|
81
|
+
return j;
|
|
82
|
+
}
|
|
83
|
+
function tagFromJSON(j) {
|
|
84
|
+
const tag = new PropertyTag({
|
|
85
|
+
name: nameFromJSON(j.name),
|
|
86
|
+
type: nameFromJSON(j.type),
|
|
87
|
+
size: j.size,
|
|
88
|
+
arrayIndex: j.arrayIndex || 0,
|
|
89
|
+
hasPropertyGuid: !!j.hasPropertyGuid,
|
|
90
|
+
});
|
|
91
|
+
switch (tag.type?.value) {
|
|
92
|
+
case 'StructProperty': tag.structName = nameFromJSON(j.structName); tag.structGuid = j.structGuid ? new FGuid(j.structGuid) : null; break;
|
|
93
|
+
case 'BoolProperty': tag.boolVal = j.boolVal; break;
|
|
94
|
+
case 'ByteProperty':
|
|
95
|
+
case 'EnumProperty': tag.enumName = nameFromJSON(j.enumName); break;
|
|
96
|
+
case 'ArrayProperty':
|
|
97
|
+
case 'SetProperty': tag.innerType = nameFromJSON(j.innerType); break;
|
|
98
|
+
case 'MapProperty': tag.innerType = nameFromJSON(j.innerType); tag.valueType = nameFromJSON(j.valueType); break;
|
|
99
|
+
}
|
|
100
|
+
if (tag.hasPropertyGuid) tag.propertyGuid = j.propertyGuid ? new FGuid(j.propertyGuid) : null;
|
|
101
|
+
return tag;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Value JSON, dispatching on tag.type and tag.innerType/valueType ─────────
|
|
105
|
+
function valueToJSON(tag, value) {
|
|
106
|
+
if (value instanceof OpaqueValue) {
|
|
107
|
+
return { _opaque: true, bytes: b64encode(value.bytes), reason: value.reason };
|
|
108
|
+
}
|
|
109
|
+
const t = tag.type.value;
|
|
110
|
+
switch (t) {
|
|
111
|
+
case 'IntProperty': case 'Int8Property': case 'Int16Property':
|
|
112
|
+
case 'UInt16Property': case 'UInt32Property':
|
|
113
|
+
case 'FloatProperty': case 'DoubleProperty':
|
|
114
|
+
case 'BoolProperty':
|
|
115
|
+
return value;
|
|
116
|
+
case 'Int64Property': case 'UInt64Property':
|
|
117
|
+
return value; // already string
|
|
118
|
+
case 'StrProperty':
|
|
119
|
+
return value; // bare string
|
|
120
|
+
case 'NameProperty':
|
|
121
|
+
case 'EnumProperty':
|
|
122
|
+
return nameJSON(value);
|
|
123
|
+
case 'ByteProperty':
|
|
124
|
+
// enumName==='None' → numeric byte, else FName.
|
|
125
|
+
return tag.enumName?.value === 'None' ? value : nameJSON(value);
|
|
126
|
+
case 'ObjectProperty': case 'ClassProperty':
|
|
127
|
+
case 'WeakObjectProperty': case 'LazyObjectProperty':
|
|
128
|
+
case 'WSObjectProperty':
|
|
129
|
+
return objectRefToJSON(value);
|
|
130
|
+
case 'SoftObjectProperty': case 'SoftClassProperty':
|
|
131
|
+
return { assetPath: value.assetPath, subPath: value.subPath };
|
|
132
|
+
case 'StructProperty':
|
|
133
|
+
return structValueToJSON(value);
|
|
134
|
+
case 'ArrayProperty':
|
|
135
|
+
return arrayValueToJSON(value, tag);
|
|
136
|
+
case 'SetProperty':
|
|
137
|
+
return setValueToJSON(value, tag);
|
|
138
|
+
case 'MapProperty':
|
|
139
|
+
return mapValueToJSON(value, tag);
|
|
140
|
+
case 'TextProperty':
|
|
141
|
+
return fTextToJSON(value);
|
|
142
|
+
default:
|
|
143
|
+
throw new Error(`valueToJSON: unsupported type ${t}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function valueFromJSON(tag, j) {
|
|
148
|
+
if (j && typeof j === 'object' && !Array.isArray(j) && j._opaque) {
|
|
149
|
+
return new OpaqueValue(b64decode(j.bytes), j.reason ?? null);
|
|
150
|
+
}
|
|
151
|
+
const t = tag.type.value;
|
|
152
|
+
switch (t) {
|
|
153
|
+
case 'IntProperty': case 'Int8Property': case 'Int16Property':
|
|
154
|
+
case 'UInt16Property': case 'UInt32Property':
|
|
155
|
+
case 'FloatProperty': case 'DoubleProperty':
|
|
156
|
+
case 'BoolProperty':
|
|
157
|
+
return j;
|
|
158
|
+
case 'Int64Property': case 'UInt64Property':
|
|
159
|
+
return String(j);
|
|
160
|
+
case 'StrProperty':
|
|
161
|
+
return j;
|
|
162
|
+
case 'NameProperty':
|
|
163
|
+
case 'EnumProperty':
|
|
164
|
+
return nameFromJSON(j);
|
|
165
|
+
case 'ByteProperty':
|
|
166
|
+
return tag.enumName?.value === 'None' ? j : nameFromJSON(j);
|
|
167
|
+
case 'ObjectProperty': case 'ClassProperty':
|
|
168
|
+
case 'WeakObjectProperty': case 'LazyObjectProperty':
|
|
169
|
+
case 'WSObjectProperty':
|
|
170
|
+
return objectRefFromJSON(j);
|
|
171
|
+
case 'SoftObjectProperty': case 'SoftClassProperty':
|
|
172
|
+
return new SoftObjectRef({ assetPath: j.assetPath, subPath: j.subPath });
|
|
173
|
+
case 'StructProperty':
|
|
174
|
+
return structValueFromJSON(j, tag.structName?.value);
|
|
175
|
+
case 'ArrayProperty':
|
|
176
|
+
return arrayValueFromJSON(j, tag);
|
|
177
|
+
case 'SetProperty':
|
|
178
|
+
return setValueFromJSON(j, tag);
|
|
179
|
+
case 'MapProperty':
|
|
180
|
+
return mapValueFromJSON(j, tag);
|
|
181
|
+
case 'TextProperty':
|
|
182
|
+
return fTextFromJSON(j);
|
|
183
|
+
default:
|
|
184
|
+
throw new Error(`valueFromJSON: unsupported type ${t}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── ObjectRef ───────────────────────────────────────────────────────────────
|
|
189
|
+
function objectRefToJSON(v) {
|
|
190
|
+
if (!(v instanceof ObjectRef)) throw new Error(`objectRefToJSON: expected ObjectRef, got ${v?.constructor?.name}`);
|
|
191
|
+
const j = { kind: v._objectKind };
|
|
192
|
+
if (v._kindOnePrefix != null) j.kindOnePrefix = v._kindOnePrefix;
|
|
193
|
+
if (v.path !== null) j.path = v.path;
|
|
194
|
+
if (v._pathIsNull) j.pathIsNull = true;
|
|
195
|
+
if (v.classPath !== null) j.classPath = v.classPath;
|
|
196
|
+
if (v._classPathIsNull) j.classPathIsNull = true;
|
|
197
|
+
if (Array.isArray(v.embedded)) {
|
|
198
|
+
j.embedded = v.embedded.map(propertyToJSON);
|
|
199
|
+
if (v.terminated) j.embeddedTerminated = true;
|
|
200
|
+
if (v.hasTerminatorTrailer) j.hasTerminatorTrailer = true;
|
|
201
|
+
}
|
|
202
|
+
return j;
|
|
203
|
+
}
|
|
204
|
+
function objectRefFromJSON(j) {
|
|
205
|
+
return new ObjectRef({
|
|
206
|
+
kind: j.kind,
|
|
207
|
+
kindOnePrefix: 'kindOnePrefix' in j ? j.kindOnePrefix : null,
|
|
208
|
+
path: 'path' in j ? j.path : null,
|
|
209
|
+
pathIsNull: !!j.pathIsNull,
|
|
210
|
+
classPath: 'classPath' in j ? j.classPath : null,
|
|
211
|
+
classPathIsNull: !!j.classPathIsNull,
|
|
212
|
+
embedded: Array.isArray(j.embedded) ? j.embedded.map(propertyFromJSON) : null,
|
|
213
|
+
terminated: !!j.embeddedTerminated,
|
|
214
|
+
hasTerminatorTrailer: !!j.hasTerminatorTrailer,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── StructValue ─────────────────────────────────────────────────────────────
|
|
219
|
+
// Three forms, distinguished by the `form` discriminator:
|
|
220
|
+
// "binary" — STRUCT_HANDLERS produced a plain object/string for this struct
|
|
221
|
+
// "propStream" — unknown struct name, decoded as a nested property stream
|
|
222
|
+
// "decodeError"— struct decode failed; opaqueTail holds the leftover bytes
|
|
223
|
+
function structValueToJSON(v) {
|
|
224
|
+
if (!(v instanceof StructValue)) throw new Error(`structValueToJSON: expected StructValue, got ${v?.constructor?.name}`);
|
|
225
|
+
if (v._structDecodeError) {
|
|
226
|
+
return {
|
|
227
|
+
form: 'decodeError',
|
|
228
|
+
error: v._structDecodeError,
|
|
229
|
+
opaqueTail: v._opaqueTail ? b64encode(v._opaqueTail) : null,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (Array.isArray(v.value)) {
|
|
233
|
+
return {
|
|
234
|
+
form: 'propStream',
|
|
235
|
+
terminated: !!v.terminated,
|
|
236
|
+
properties: v.value.map(propertyToJSON),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
// Binary handler. Special-case value shapes that aren't plain objects:
|
|
240
|
+
// Guid → FGuid (has .value); we JSON it as a bare string
|
|
241
|
+
// DateTime/Timespan → already string
|
|
242
|
+
let jval = v.value;
|
|
243
|
+
if (v.value instanceof FGuid) jval = v.value.value;
|
|
244
|
+
return { form: 'binary', value: jval };
|
|
245
|
+
}
|
|
246
|
+
function structValueFromJSON(j, structName) {
|
|
247
|
+
if (j.form === 'decodeError') {
|
|
248
|
+
return new StructValue(structName, {
|
|
249
|
+
value: [], decodeError: j.error,
|
|
250
|
+
opaqueTail: j.opaqueTail ? b64decode(j.opaqueTail) : null,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (j.form === 'propStream') {
|
|
254
|
+
return new StructValue(structName, {
|
|
255
|
+
value: j.properties.map(propertyFromJSON),
|
|
256
|
+
terminated: !!j.terminated,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (j.form === 'binary') {
|
|
260
|
+
let val = j.value;
|
|
261
|
+
if (structName === 'Guid' && typeof val === 'string') val = new FGuid(val);
|
|
262
|
+
return new StructValue(structName, { value: val });
|
|
263
|
+
}
|
|
264
|
+
throw new Error(`structValueFromJSON: unknown form '${j.form}'`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── ArrayValue / SetValue / MapValue ────────────────────────────────────────
|
|
268
|
+
function arrayValueToJSON(v, tag) {
|
|
269
|
+
if (!(v instanceof ArrayValue)) throw new Error(`arrayValueToJSON: expected ArrayValue, got ${v?.constructor?.name}`);
|
|
270
|
+
const innerType = tag.innerType.value;
|
|
271
|
+
const j = { elements: v.elements.map(e => arrayElementToJSON(e, innerType, v._arrayInnerTag)) };
|
|
272
|
+
if (v._arrayInnerTag) j.innerTag = tagToJSON(v._arrayInnerTag);
|
|
273
|
+
if (v._perElementTrailings) {
|
|
274
|
+
// Per-element placement-binary block (JianZhuInstYuanXings). Each entry is
|
|
275
|
+
// either null OR { transforms: [[16 floats], …], ids: [u32, …], aux: [[16 floats], …] }.
|
|
276
|
+
// Header and strides are constants on the wire — synthesized on write.
|
|
277
|
+
j.perElementTrailings = v._perElementTrailings.map(t => {
|
|
278
|
+
if (t == null) return null;
|
|
279
|
+
return { transforms: t.transforms, ids: t.ids, aux: t.aux };
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return j;
|
|
283
|
+
}
|
|
284
|
+
function arrayValueFromJSON(j, tag) {
|
|
285
|
+
const innerType = tag.innerType.value;
|
|
286
|
+
const innerTag = j.innerTag ? tagFromJSON(j.innerTag) : null;
|
|
287
|
+
const elements = j.elements.map(e => arrayElementFromJSON(e, innerType, innerTag));
|
|
288
|
+
let perElementTrailings = null;
|
|
289
|
+
if (Array.isArray(j.perElementTrailings)) {
|
|
290
|
+
perElementTrailings = j.perElementTrailings.map(t => {
|
|
291
|
+
if (t == null) return null;
|
|
292
|
+
return { transforms: t.transforms, ids: t.ids, aux: t.aux };
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return new ArrayValue({ elements, innerTag, perElementTrailings });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Array/Set element converters. For StructProperty arrays the element is a
|
|
299
|
+
// StructValue (the struct-array path always emits one inner PropertyTag and
|
|
300
|
+
// uses STRUCT_HANDLERS or a property stream per element).
|
|
301
|
+
function arrayElementToJSON(e, innerType, innerTag) {
|
|
302
|
+
if (innerType === 'StructProperty') return structValueToJSON(e);
|
|
303
|
+
switch (innerType) {
|
|
304
|
+
case 'IntProperty': case 'Int8Property': case 'Int16Property':
|
|
305
|
+
case 'UInt16Property': case 'UInt32Property':
|
|
306
|
+
case 'FloatProperty': case 'DoubleProperty':
|
|
307
|
+
case 'BoolProperty': case 'ByteProperty':
|
|
308
|
+
return e;
|
|
309
|
+
case 'Int64Property': case 'UInt64Property':
|
|
310
|
+
return e; // already string
|
|
311
|
+
case 'StrProperty':
|
|
312
|
+
return e;
|
|
313
|
+
case 'NameProperty': case 'EnumProperty':
|
|
314
|
+
return nameJSON(e);
|
|
315
|
+
case 'TextProperty':
|
|
316
|
+
if (e instanceof OpaqueValue) return { _opaque: true, bytes: b64encode(e.bytes), reason: e.reason };
|
|
317
|
+
return fTextToJSON(e);
|
|
318
|
+
case 'ObjectProperty': case 'ClassProperty':
|
|
319
|
+
case 'WeakObjectProperty': case 'LazyObjectProperty':
|
|
320
|
+
case 'WSObjectProperty':
|
|
321
|
+
return objectRefToJSON(e);
|
|
322
|
+
case 'SoftObjectProperty': case 'SoftClassProperty':
|
|
323
|
+
return { assetPath: e.assetPath, subPath: e.subPath };
|
|
324
|
+
default:
|
|
325
|
+
throw new Error(`arrayElementToJSON: unsupported innerType ${innerType}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function arrayElementFromJSON(j, innerType, innerTag) {
|
|
329
|
+
if (innerType === 'StructProperty') return structValueFromJSON(j, innerTag?.structName?.value);
|
|
330
|
+
if (j && typeof j === 'object' && !Array.isArray(j) && j._opaque) {
|
|
331
|
+
return new OpaqueValue(b64decode(j.bytes), j.reason ?? null);
|
|
332
|
+
}
|
|
333
|
+
switch (innerType) {
|
|
334
|
+
case 'IntProperty': case 'Int8Property': case 'Int16Property':
|
|
335
|
+
case 'UInt16Property': case 'UInt32Property':
|
|
336
|
+
case 'FloatProperty': case 'DoubleProperty':
|
|
337
|
+
case 'BoolProperty': case 'ByteProperty':
|
|
338
|
+
return j;
|
|
339
|
+
case 'Int64Property': case 'UInt64Property':
|
|
340
|
+
return String(j);
|
|
341
|
+
case 'StrProperty':
|
|
342
|
+
return j;
|
|
343
|
+
case 'NameProperty': case 'EnumProperty':
|
|
344
|
+
return nameFromJSON(j);
|
|
345
|
+
case 'TextProperty':
|
|
346
|
+
return fTextFromJSON(j);
|
|
347
|
+
case 'ObjectProperty': case 'ClassProperty':
|
|
348
|
+
case 'WeakObjectProperty': case 'LazyObjectProperty':
|
|
349
|
+
case 'WSObjectProperty':
|
|
350
|
+
return objectRefFromJSON(j);
|
|
351
|
+
case 'SoftObjectProperty': case 'SoftClassProperty':
|
|
352
|
+
return new SoftObjectRef({ assetPath: j.assetPath, subPath: j.subPath });
|
|
353
|
+
default:
|
|
354
|
+
throw new Error(`arrayElementFromJSON: unsupported innerType ${innerType}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function setValueToJSON(v, tag) {
|
|
359
|
+
return {
|
|
360
|
+
removed: v.removed.map(e => setElementToJSON(e, tag.innerType.value)),
|
|
361
|
+
elements: v.elements.map(e => setElementToJSON(e, tag.innerType.value)),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
function setValueFromJSON(j, tag) {
|
|
365
|
+
return new SetValue({
|
|
366
|
+
removed: j.removed.map(e => setElementFromJSON(e, tag.innerType.value)),
|
|
367
|
+
elements: j.elements.map(e => setElementFromJSON(e, tag.innerType.value)),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
// Set<StructProperty> elements are raw FGuid values (per readSetElement);
|
|
371
|
+
// other inner types share the array-element shape.
|
|
372
|
+
function setElementToJSON(e, innerType) {
|
|
373
|
+
if (innerType === 'StructProperty') return e; // Guid string
|
|
374
|
+
return arrayElementToJSON(e, innerType, null);
|
|
375
|
+
}
|
|
376
|
+
function setElementFromJSON(j, innerType) {
|
|
377
|
+
if (innerType === 'StructProperty') return j; // Guid string
|
|
378
|
+
return arrayElementFromJSON(j, innerType, null);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function mapValueToJSON(v, tag) {
|
|
382
|
+
const keyType = tag.innerType.value;
|
|
383
|
+
const valType = tag.valueType.value;
|
|
384
|
+
return {
|
|
385
|
+
removed: v.removed.map(k => mapElementToJSON(k, keyType, /*isKey=*/true)),
|
|
386
|
+
entries: v.entries.map(e => ({
|
|
387
|
+
key: mapElementToJSON(e.key, keyType, true),
|
|
388
|
+
value: mapElementToJSON(e.value, valType, false),
|
|
389
|
+
})),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function mapValueFromJSON(j, tag) {
|
|
393
|
+
const keyType = tag.innerType.value;
|
|
394
|
+
const valType = tag.valueType.value;
|
|
395
|
+
return new MapValue({
|
|
396
|
+
removed: j.removed.map(k => mapElementFromJSON(k, keyType, true)),
|
|
397
|
+
entries: j.entries.map(e => ({
|
|
398
|
+
key: mapElementFromJSON(e.key, keyType, true),
|
|
399
|
+
value: mapElementFromJSON(e.value, valType, false),
|
|
400
|
+
})),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
// Map<StructProperty,_> keys are always 16-byte FGuids (string). Map values
|
|
404
|
+
// with StructProperty type are either a StructValue (propStream form) OR a
|
|
405
|
+
// Guid string; we distinguish on JSON shape (object with form=propStream vs
|
|
406
|
+
// bare string).
|
|
407
|
+
function mapElementToJSON(v, type, isKey) {
|
|
408
|
+
if (type === 'StructProperty') {
|
|
409
|
+
if (isKey) return v; // Guid string
|
|
410
|
+
if (v instanceof StructValue) return structValueToJSON(v);
|
|
411
|
+
return v; // Guid string
|
|
412
|
+
}
|
|
413
|
+
return arrayElementToJSON(v, type, null);
|
|
414
|
+
}
|
|
415
|
+
function mapElementFromJSON(j, type, isKey) {
|
|
416
|
+
if (type === 'StructProperty') {
|
|
417
|
+
if (isKey) return j;
|
|
418
|
+
if (j && typeof j === 'object' && j.form) return structValueFromJSON(j, '(map value)');
|
|
419
|
+
return j;
|
|
420
|
+
}
|
|
421
|
+
return arrayElementFromJSON(j, type, null);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── FText ───────────────────────────────────────────────────────────────────
|
|
425
|
+
function fTextToJSON(v) {
|
|
426
|
+
if (!(v instanceof FTextValue)) throw new Error(`fTextToJSON: expected FTextValue, got ${v?.constructor?.name}`);
|
|
427
|
+
const j = { flags: v.flags, historyType: v.historyType };
|
|
428
|
+
if (v.historyType === -1) {
|
|
429
|
+
if (v.displayString != null) {
|
|
430
|
+
j.displayString = v.displayString;
|
|
431
|
+
if (v._displayStringIsNull) j.displayStringIsNull = true;
|
|
432
|
+
} else {
|
|
433
|
+
j.displayString = null;
|
|
434
|
+
}
|
|
435
|
+
} else if (v.historyType === 0) {
|
|
436
|
+
j.namespace = v.namespace; if (v._namespaceIsNull) j.namespaceIsNull = true;
|
|
437
|
+
j.key = v.key; if (v._keyIsNull) j.keyIsNull = true;
|
|
438
|
+
if (v.sourceString != null) {
|
|
439
|
+
j.sourceString = v.sourceString;
|
|
440
|
+
if (v._sourceStringIsNull) j.sourceStringIsNull = true;
|
|
441
|
+
} else {
|
|
442
|
+
j.sourceString = null;
|
|
443
|
+
}
|
|
444
|
+
} else if (v.historyType === 1) {
|
|
445
|
+
j.sourceFmt = fTextToJSON(v.sourceFmt);
|
|
446
|
+
j.arguments = v.arguments.map(a => fTextNamedArgToJSON(a));
|
|
447
|
+
} else if (v.historyType === 2) {
|
|
448
|
+
j.sourceFmt = fTextToJSON(v.sourceFmt);
|
|
449
|
+
j.arguments = v.arguments.map(a => fTextArgToJSON(a));
|
|
450
|
+
} else if (v.historyType === 4) {
|
|
451
|
+
j.sourceValue = fTextArgToJSON(v.sourceValue);
|
|
452
|
+
j.formatOptions = v.formatOptions;
|
|
453
|
+
if (v.culture != null) {
|
|
454
|
+
j.culture = v.culture;
|
|
455
|
+
if (v._cultureIsNull) j.cultureIsNull = true;
|
|
456
|
+
} else {
|
|
457
|
+
j.culture = null;
|
|
458
|
+
}
|
|
459
|
+
} else {
|
|
460
|
+
// Unknown historyType: codec stored remaining bytes verbatim in _raw.
|
|
461
|
+
j._raw = v._raw ? b64encode(v._raw) : null;
|
|
462
|
+
}
|
|
463
|
+
return j;
|
|
464
|
+
}
|
|
465
|
+
function fTextFromJSON(j) {
|
|
466
|
+
const ht = j.historyType;
|
|
467
|
+
if (ht === -1) {
|
|
468
|
+
return new FTextValue({
|
|
469
|
+
flags: j.flags, historyType: -1,
|
|
470
|
+
displayString: j.displayString ?? null,
|
|
471
|
+
displayStringIsNull: !!j.displayStringIsNull,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
if (ht === 0) {
|
|
475
|
+
return new FTextValue({
|
|
476
|
+
flags: j.flags, historyType: 0,
|
|
477
|
+
namespace: j.namespace ?? '', namespaceIsNull: !!j.namespaceIsNull,
|
|
478
|
+
key: j.key ?? '', keyIsNull: !!j.keyIsNull,
|
|
479
|
+
sourceString: j.sourceString ?? null, sourceStringIsNull: !!j.sourceStringIsNull,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
if (ht === 1) {
|
|
483
|
+
return new FTextValue({
|
|
484
|
+
flags: j.flags, historyType: 1,
|
|
485
|
+
sourceFmt: fTextFromJSON(j.sourceFmt),
|
|
486
|
+
arguments: j.arguments.map(a => fTextNamedArgFromJSON(a)),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
if (ht === 2) {
|
|
490
|
+
return new FTextValue({
|
|
491
|
+
flags: j.flags, historyType: 2,
|
|
492
|
+
sourceFmt: fTextFromJSON(j.sourceFmt),
|
|
493
|
+
arguments: j.arguments.map(a => fTextArgFromJSON(a)),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
if (ht === 4) {
|
|
497
|
+
return new FTextValue({
|
|
498
|
+
flags: j.flags, historyType: 4,
|
|
499
|
+
sourceValue: fTextArgFromJSON(j.sourceValue),
|
|
500
|
+
formatOptions: j.formatOptions ?? null,
|
|
501
|
+
culture: j.culture ?? null,
|
|
502
|
+
cultureIsNull: !!j.cultureIsNull,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
return new FTextValue({
|
|
506
|
+
flags: j.flags, historyType: ht,
|
|
507
|
+
_raw: j._raw ? b64decode(j._raw) : null,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
function fTextArgToJSON(a) {
|
|
511
|
+
if (a.type === 4) return { type: 4, value: fTextToJSON(a.value) };
|
|
512
|
+
return { type: a.type, value: a.value };
|
|
513
|
+
}
|
|
514
|
+
function fTextArgFromJSON(a) {
|
|
515
|
+
if (a.type === 4) return { type: 4, value: fTextFromJSON(a.value) };
|
|
516
|
+
return { type: a.type, value: a.value };
|
|
517
|
+
}
|
|
518
|
+
// Named-format arg: same as positional but with a key FString prefix.
|
|
519
|
+
function fTextNamedArgToJSON(a) {
|
|
520
|
+
const j = { key: a.key };
|
|
521
|
+
if (a.keyIsNull) j.keyIsNull = true;
|
|
522
|
+
j.type = a.type;
|
|
523
|
+
j.value = a.type === 4 ? fTextToJSON(a.value) : a.value;
|
|
524
|
+
return j;
|
|
525
|
+
}
|
|
526
|
+
function fTextNamedArgFromJSON(a) {
|
|
527
|
+
return {
|
|
528
|
+
key: a.key,
|
|
529
|
+
keyIsNull: !!a.keyIsNull,
|
|
530
|
+
type: a.type,
|
|
531
|
+
value: a.type === 4 ? fTextFromJSON(a.value) : a.value,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ── Property ────────────────────────────────────────────────────────────────
|
|
536
|
+
function propertyToJSON(p) {
|
|
537
|
+
return { tag: tagToJSON(p.tag), value: valueToJSON(p.tag, p.value) };
|
|
538
|
+
}
|
|
539
|
+
function propertyFromJSON(j) {
|
|
540
|
+
const tag = tagFromJSON(j.tag);
|
|
541
|
+
const value = valueFromJSON(tag, j.value);
|
|
542
|
+
return new Property(tag, value);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ── Top-level UnrealBlob ────────────────────────────────────────────────────
|
|
546
|
+
export function blobToJSON(blob) {
|
|
547
|
+
const j = {
|
|
548
|
+
versionTag: blob.versionTag,
|
|
549
|
+
terminated: !!blob.terminated,
|
|
550
|
+
properties: blob.properties.map(propertyToJSON),
|
|
551
|
+
};
|
|
552
|
+
if (blob.bodyTrailing && blob.bodyTrailing.length > 0) {
|
|
553
|
+
j.bodyTrailing = b64encode(blob.bodyTrailing);
|
|
554
|
+
}
|
|
555
|
+
return j;
|
|
556
|
+
}
|
|
557
|
+
export function jsonToBlob(j) {
|
|
558
|
+
const blob = new UnrealBlob({
|
|
559
|
+
versionTag: j.versionTag,
|
|
560
|
+
properties: j.properties.map(propertyFromJSON),
|
|
561
|
+
terminated: !!j.terminated,
|
|
562
|
+
bodyTrailing: j.bodyTrailing ? b64decode(j.bodyTrailing) : null,
|
|
563
|
+
});
|
|
564
|
+
blob._dirty = true; // force re-encode path on serialize()
|
|
565
|
+
blob._recomputeSizes = true; // JSON is the editing path; tag.size must
|
|
566
|
+
// be rewritten from actual value bytes.
|
|
567
|
+
return blob;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── -0 / NaN / Infinity preservation ────────────────────────────────────────
|
|
571
|
+
// JSON drops sign on negative zero (`JSON.stringify(-0) === "0"`) and turns
|
|
572
|
+
// non-finite numbers into `null`. UE serializes them verbatim, so any of these
|
|
573
|
+
// in the data round-trips as a different bit pattern unless we intervene.
|
|
574
|
+
// We substitute a sentinel string at stringify time and reverse it at parse
|
|
575
|
+
// time, via JSON.stringify's replacer / JSON.parse's reviver hooks.
|
|
576
|
+
//
|
|
577
|
+
// The sentinel surface is space-bounded to make accidental collision with a
|
|
578
|
+
// real string vanishingly unlikely. If you ever see a string field containing
|
|
579
|
+
// these exact literals in your data, audit this list first.
|
|
580
|
+
const NEG_ZERO_SENTINEL = ' __wscodec_neg_zero__ ';
|
|
581
|
+
const POS_INF_SENTINEL = ' __wscodec_pos_inf__ ';
|
|
582
|
+
const NEG_INF_SENTINEL = ' __wscodec_neg_inf__ ';
|
|
583
|
+
const NAN_SENTINEL = ' __wscodec_nan__ ';
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* JSON.stringify replacer that substitutes sentinels for -0 / Infinity / NaN.
|
|
587
|
+
* Pass this to any JSON.stringify call that may contain wscodec-derived numbers
|
|
588
|
+
* (including a blob nested inside a larger envelope). Use jsonReviver on the
|
|
589
|
+
* matching JSON.parse to invert.
|
|
590
|
+
*/
|
|
591
|
+
export function jsonReplacer(_key, value) {
|
|
592
|
+
if (typeof value !== 'number') return value;
|
|
593
|
+
if (Object.is(value, -0)) return NEG_ZERO_SENTINEL;
|
|
594
|
+
if (value === Infinity) return POS_INF_SENTINEL;
|
|
595
|
+
if (value === -Infinity) return NEG_INF_SENTINEL;
|
|
596
|
+
if (Number.isNaN(value)) return NAN_SENTINEL;
|
|
597
|
+
return value;
|
|
598
|
+
}
|
|
599
|
+
/** Inverse of jsonReplacer. Pass to JSON.parse(text, jsonReviver). */
|
|
600
|
+
export function jsonReviver(_key, value) {
|
|
601
|
+
if (typeof value !== 'string') return value;
|
|
602
|
+
switch (value) {
|
|
603
|
+
case NEG_ZERO_SENTINEL: return -0;
|
|
604
|
+
case POS_INF_SENTINEL: return Infinity;
|
|
605
|
+
case NEG_INF_SENTINEL: return -Infinity;
|
|
606
|
+
case NAN_SENTINEL: return NaN;
|
|
607
|
+
default: return value;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Stringify a blob with proper handling of -0 / Infinity / NaN. Use this instead of bare JSON.stringify. */
|
|
612
|
+
export function blobToJSONString(blob, indent) { return JSON.stringify(blobToJSON(blob), jsonReplacer, indent); }
|
|
613
|
+
/** Parse + reconstruct a blob with proper handling of -0 / Infinity / NaN. Use this instead of bare JSON.parse. */
|
|
614
|
+
export function jsonStringToBlob(str) { return jsonToBlob(JSON.parse(str, jsonReviver)); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wscodec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Pure-JS codec for Soulmask actor_data property streams (UE 4.27 FPropertyTag wire format). Zero runtime dependencies. Accepts uncompressed bytes, returns JS objects, and vice versa. Round-trip byte-identical against every actor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./wscodec.mjs",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"./primitives": "./primitives.mjs",
|
|
11
11
|
"./structs": "./structs.mjs",
|
|
12
12
|
"./values": "./values.mjs",
|
|
13
|
-
"./properties": "./properties.mjs"
|
|
13
|
+
"./properties": "./properties.mjs",
|
|
14
|
+
"./json": "./json.mjs"
|
|
14
15
|
},
|
|
15
16
|
"files": [
|
|
16
17
|
"wscodec.mjs",
|
|
@@ -19,10 +20,15 @@
|
|
|
19
20
|
"structs.mjs",
|
|
20
21
|
"values.mjs",
|
|
21
22
|
"properties.mjs",
|
|
23
|
+
"json.mjs",
|
|
22
24
|
"README.md"
|
|
23
25
|
],
|
|
24
26
|
"scripts": {
|
|
25
|
-
"test": "node test/test-roundtrip.mjs"
|
|
27
|
+
"test": "node test/test-roundtrip.mjs",
|
|
28
|
+
"test:json": "node test/test-json-full.mjs",
|
|
29
|
+
"test:json-spot": "node test/test-json-roundtrip.mjs",
|
|
30
|
+
"export-db": "node scripts/db-to-json.mjs",
|
|
31
|
+
"import-db": "node scripts/json-to-db.mjs"
|
|
26
32
|
},
|
|
27
33
|
"keywords": [
|
|
28
34
|
"soulmask",
|
package/properties.mjs
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* elements, embedded object data) do NOT.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import { Writer } from './io.mjs';
|
|
28
29
|
import { FName, FGuid } from './primitives.mjs';
|
|
29
30
|
import { StructValue, STRUCT_HANDLERS } from './structs.mjs';
|
|
30
31
|
import { ObjectRef, SoftObjectRef, FTextValue, OpaqueValue } from './values.mjs';
|
|
@@ -291,6 +292,35 @@ function readFText(cursor, sizeHint) {
|
|
|
291
292
|
sourceString: ssFS.value, sourceStringIsNull: ssFS.isNull,
|
|
292
293
|
});
|
|
293
294
|
}
|
|
295
|
+
if (historyType === 1) {
|
|
296
|
+
// NamedFormat (FTextHistory_NamedFormat): a format-pattern FText plus a
|
|
297
|
+
// TMap<FString, FFormatArgumentValue>. Wire shape:
|
|
298
|
+
// FText SourceFmt
|
|
299
|
+
// int32 NumArguments
|
|
300
|
+
// for each: FString Key, int8 ContentType, value-by-type
|
|
301
|
+
// ContentType codes match historyType=2 (0=Int64 1=UInt64 2=Float32
|
|
302
|
+
// 3=Float64 4=Text 5=Gender). Soulmask uses this for named placeholders
|
|
303
|
+
// like "X={X} Y={Y} Z={Z}" in ParamArrayTxt elements of JingYingRiZhiList.
|
|
304
|
+
const sourceFmt = readFText(cursor, Infinity);
|
|
305
|
+
const numArgs = cursor.readInt32();
|
|
306
|
+
const args = [];
|
|
307
|
+
for (let i = 0; i < numArgs; i++) {
|
|
308
|
+
const keyFS = cursor.readFString();
|
|
309
|
+
const type = cursor.readInt8();
|
|
310
|
+
let value;
|
|
311
|
+
switch (type) {
|
|
312
|
+
case 0: value = cursor.readInt64().toString(); break;
|
|
313
|
+
case 1: value = cursor.readUint64().toString(); break;
|
|
314
|
+
case 2: value = cursor.readFloat32(); break;
|
|
315
|
+
case 3: value = cursor.readFloat64(); break;
|
|
316
|
+
case 4: value = readFText(cursor, Infinity); break;
|
|
317
|
+
case 5: value = cursor.readInt8(); break;
|
|
318
|
+
default: throw new Error(`readFText: unknown NamedFormat ContentType ${type}`);
|
|
319
|
+
}
|
|
320
|
+
args.push({ key: keyFS.value, keyIsNull: keyFS.isNull, type, value });
|
|
321
|
+
}
|
|
322
|
+
return new FTextValue({ flags, historyType: 1, sourceFmt, arguments: args });
|
|
323
|
+
}
|
|
294
324
|
if (historyType === 2) {
|
|
295
325
|
// ArgumentFormat: a format-pattern FText plus an ordered argument list.
|
|
296
326
|
// Each argument is a ContentType byte (EFormatArgumentType) followed by
|
|
@@ -393,6 +423,22 @@ function writeFText(writer, value) {
|
|
|
393
423
|
writer.writeFString(value.namespace ?? '', null, value._namespaceIsNull);
|
|
394
424
|
writer.writeFString(value.key ?? '', null, value._keyIsNull);
|
|
395
425
|
writer.writeFString(value.sourceString ?? '', null, value._sourceStringIsNull);
|
|
426
|
+
} else if (value.historyType === 1) {
|
|
427
|
+
writeFText(writer, value.sourceFmt);
|
|
428
|
+
writer.writeInt32(value.arguments.length);
|
|
429
|
+
for (const arg of value.arguments) {
|
|
430
|
+
writer.writeFString(arg.key ?? '', null, arg.keyIsNull);
|
|
431
|
+
writer.writeInt8(arg.type);
|
|
432
|
+
switch (arg.type) {
|
|
433
|
+
case 0: writer.writeInt64(arg.value); break;
|
|
434
|
+
case 1: writer.writeUint64(arg.value); break;
|
|
435
|
+
case 2: writer.writeFloat32(arg.value); break;
|
|
436
|
+
case 3: writer.writeFloat64(arg.value); break;
|
|
437
|
+
case 4: writeFText(writer, arg.value); break;
|
|
438
|
+
case 5: writer.writeInt8(arg.value); break;
|
|
439
|
+
default: throw new Error(`writeFText: unknown NamedFormat ContentType ${arg.type}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
396
442
|
} else if (value.historyType === 2) {
|
|
397
443
|
writeFText(writer, value.sourceFmt);
|
|
398
444
|
writer.writeInt32(value.arguments.length);
|
|
@@ -593,19 +639,23 @@ function readArrayValue(cursor, tag, sizeHint) {
|
|
|
593
639
|
* yuan-xing element is followed by a fixed-shape block:
|
|
594
640
|
*
|
|
595
641
|
* [8 bytes zero header]
|
|
596
|
-
* [u32 stride=64] [u32 count] [
|
|
597
|
-
* [u32 stride= 4] [u32 count] [
|
|
598
|
-
* [u32 stride=64] [u32 count] [
|
|
642
|
+
* [u32 stride=64] [u32 count] [count × 16 float32] world 4×4 transforms (per placed piece)
|
|
643
|
+
* [u32 stride= 4] [u32 count] [count × u32] per-piece u32 ids
|
|
644
|
+
* [u32 stride=64] [u32 count] [count × 16 float32] per-piece aux (bbox + scale-ish floats)
|
|
645
|
+
*
|
|
646
|
+
* Returns { transforms, ids, aux } on success: arrays of decoded values
|
|
647
|
+
* (rather than raw byte slices). Returns null (cursor rolled back) when the
|
|
648
|
+
* bytes don't match. Non-JianZhuInstYuanXings ObjectProperty arrays have no
|
|
649
|
+
* such block, so peeking-and-rolling-back keeps them unaffected.
|
|
599
650
|
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
* have no such block, so peeking-and-rolling-back keeps them unaffected.
|
|
651
|
+
* The 8-byte zero header and the fixed strides (64/4/64) are synthesized on
|
|
652
|
+
* write — no field in the returned object carries them.
|
|
603
653
|
*
|
|
604
654
|
* Verified by in-game experiment 2026-05-18: numElements counts UNIQUE
|
|
605
|
-
* prototypes (foundation, wall, door frame, …);
|
|
606
|
-
* placed-piece count for that prototype;
|
|
607
|
-
*
|
|
608
|
-
*
|
|
655
|
+
* prototypes (foundation, wall, door frame, …); transforms.length is the
|
|
656
|
+
* placed-piece count for that prototype; aux.length is typically the same
|
|
657
|
+
* or one greater. The earlier "single trailing block after all elements"
|
|
658
|
+
* model was wrong; these blocks are interleaved per element.
|
|
609
659
|
*/
|
|
610
660
|
function tryReadObjectArrayPerElementBlock(cursor, endOff) {
|
|
611
661
|
const start = cursor.pos();
|
|
@@ -618,8 +668,7 @@ function tryReadObjectArrayPerElementBlock(cursor, endOff) {
|
|
|
618
668
|
if (cursor.dv.getUint32(start + 8, true) !== 64) return null;
|
|
619
669
|
|
|
620
670
|
try {
|
|
621
|
-
cursor.skip(8);
|
|
622
|
-
const header = cursor.bytes.subarray(start, start + 8).slice();
|
|
671
|
+
cursor.skip(8); // zero header
|
|
623
672
|
const sections = [];
|
|
624
673
|
const expected = [64, 4, 64];
|
|
625
674
|
for (let i = 0; i < 3; i++) {
|
|
@@ -630,9 +679,26 @@ function tryReadObjectArrayPerElementBlock(cursor, endOff) {
|
|
|
630
679
|
if (count > 1_000_000) throw new Error(`implausible count ${count}`);
|
|
631
680
|
const dataBytes = stride * count;
|
|
632
681
|
if (cursor.pos() + dataBytes > endOff) throw new Error(`section ${i} data overruns budget`);
|
|
633
|
-
|
|
682
|
+
if (i === 1) {
|
|
683
|
+
const ids = new Array(count);
|
|
684
|
+
for (let k = 0; k < count; k++) ids[k] = cursor.readUint32();
|
|
685
|
+
sections.push(ids);
|
|
686
|
+
} else {
|
|
687
|
+
// 16 float32 per element (4×4 matrix, row-major in UE's FMatrix layout).
|
|
688
|
+
// Non-canonical NaN bit patterns are common in Soulmask aux data
|
|
689
|
+
// (observed 0xFFFFFFFF as "invalid" sentinel) and would collapse to
|
|
690
|
+
// canonical 0x7FC00000 if round-tripped via a JS Number, so we
|
|
691
|
+
// capture them as { $nanBits } wrappers instead.
|
|
692
|
+
const arr = new Array(count);
|
|
693
|
+
for (let k = 0; k < count; k++) {
|
|
694
|
+
const m = new Array(16);
|
|
695
|
+
for (let j = 0; j < 16; j++) m[j] = readFloat32PreservingNan(cursor);
|
|
696
|
+
arr[k] = m;
|
|
697
|
+
}
|
|
698
|
+
sections.push(arr);
|
|
699
|
+
}
|
|
634
700
|
}
|
|
635
|
-
return {
|
|
701
|
+
return { transforms: sections[0], ids: sections[1], aux: sections[2] };
|
|
636
702
|
} catch {
|
|
637
703
|
cursor.seek(start);
|
|
638
704
|
return null;
|
|
@@ -640,11 +706,45 @@ function tryReadObjectArrayPerElementBlock(cursor, endOff) {
|
|
|
640
706
|
}
|
|
641
707
|
|
|
642
708
|
function writeObjectArrayPerElementBlock(writer, block) {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
709
|
+
// 8-byte zero header.
|
|
710
|
+
writer.writeUint32(0);
|
|
711
|
+
writer.writeUint32(0);
|
|
712
|
+
// Section 0: transforms (count × 16 float32).
|
|
713
|
+
writer.writeUint32(64);
|
|
714
|
+
writer.writeUint32(block.transforms.length);
|
|
715
|
+
for (const m of block.transforms) for (const f of m) writeFloat32PreservingNan(writer, f);
|
|
716
|
+
// Section 1: ids (count × u32).
|
|
717
|
+
writer.writeUint32(4);
|
|
718
|
+
writer.writeUint32(block.ids.length);
|
|
719
|
+
for (const id of block.ids) writer.writeUint32(id);
|
|
720
|
+
// Section 2: aux (count × 16 float32).
|
|
721
|
+
writer.writeUint32(64);
|
|
722
|
+
writer.writeUint32(block.aux.length);
|
|
723
|
+
for (const m of block.aux) for (const f of m) writeFloat32PreservingNan(writer, f);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Float32 helpers that preserve non-canonical NaN bit patterns. JavaScript's
|
|
727
|
+
// Number type collapses all NaN bit patterns into the canonical 0x7FC00000
|
|
728
|
+
// on any DataView.setFloat32 call, so a wire NaN like 0xFFFFFFFF (observed in
|
|
729
|
+
// Soulmask JianZhuInstYuanXings aux data) would not round-trip if we used
|
|
730
|
+
// readFloat32 / writeFloat32 directly. We instead carry NaN-bit-patterns as
|
|
731
|
+
// { $nanBits: u32 } wrapper objects.
|
|
732
|
+
function readFloat32PreservingNan(cursor) {
|
|
733
|
+
const bits = cursor.dv.getUint32(cursor.offset, true);
|
|
734
|
+
// NaN: exponent all 1s AND mantissa non-zero. The single "canonical NaN"
|
|
735
|
+
// (0x7FC00000) is allowed to round-trip through Number, but every other
|
|
736
|
+
// NaN bit pattern needs the wrapper.
|
|
737
|
+
if ((bits & 0x7F800000) === 0x7F800000 && (bits & 0x007FFFFF) !== 0 && bits !== 0x7FC00000) {
|
|
738
|
+
cursor.offset += 4;
|
|
739
|
+
return { $nanBits: bits >>> 0 };
|
|
740
|
+
}
|
|
741
|
+
return cursor.readFloat32();
|
|
742
|
+
}
|
|
743
|
+
function writeFloat32PreservingNan(writer, f) {
|
|
744
|
+
if (f !== null && typeof f === 'object' && '$nanBits' in f) {
|
|
745
|
+
writer.writeUint32(f.$nanBits >>> 0);
|
|
746
|
+
} else {
|
|
747
|
+
writer.writeFloat32(f);
|
|
648
748
|
}
|
|
649
749
|
}
|
|
650
750
|
|
|
@@ -656,14 +756,33 @@ function isObjectInnerType(t) {
|
|
|
656
756
|
|
|
657
757
|
function writeArrayValue(writer, tag, value) {
|
|
658
758
|
const innerType = tag.innerType.value;
|
|
759
|
+
const recompute = !!writer._wsRecomputeSizes;
|
|
659
760
|
writer.writeInt32(value.elements.length);
|
|
660
761
|
if (innerType === 'StructProperty') {
|
|
661
|
-
value._arrayInnerTag.write(writer);
|
|
662
762
|
const structName = value._arrayInnerTag.structName.value;
|
|
663
763
|
const handler = STRUCT_HANDLERS[structName];
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
764
|
+
// On recompute, set innerTag.size to the encoded size of element 0.
|
|
765
|
+
// All array-of-struct elements share the same innerTag, and stock UE
|
|
766
|
+
// uses one size value as a hint for the whole element shape. Self-
|
|
767
|
+
// delimiting struct streams (None terminator) make the exact value less
|
|
768
|
+
// critical for reading, but writing the truthful size keeps validators
|
|
769
|
+
// happy.
|
|
770
|
+
let origInnerSize = value._arrayInnerTag.size;
|
|
771
|
+
if (recompute && value.elements.length > 0) {
|
|
772
|
+
const sub = new Writer(64);
|
|
773
|
+
sub._wsRecomputeSizes = true;
|
|
774
|
+
if (handler) handler.write(sub, value.elements[0].value);
|
|
775
|
+
else writeNestedPropertyStream(sub, value.elements[0].value);
|
|
776
|
+
value._arrayInnerTag.size = sub.finalize().length;
|
|
777
|
+
}
|
|
778
|
+
try {
|
|
779
|
+
value._arrayInnerTag.write(writer);
|
|
780
|
+
for (const e of value.elements) {
|
|
781
|
+
if (handler) handler.write(writer, e.value);
|
|
782
|
+
else writeNestedPropertyStream(writer, e.value);
|
|
783
|
+
}
|
|
784
|
+
} finally {
|
|
785
|
+
if (recompute) value._arrayInnerTag.size = origInnerSize;
|
|
667
786
|
}
|
|
668
787
|
return;
|
|
669
788
|
}
|
|
@@ -1139,12 +1258,40 @@ export function readPropertyStream(cursor, endOffset = Infinity, consumeTerminat
|
|
|
1139
1258
|
}
|
|
1140
1259
|
|
|
1141
1260
|
export function writePropertyStream(writer, properties, emitTerminatorTrailer = false) {
|
|
1261
|
+
// When the writer carries the recompute flag, we encode each property's
|
|
1262
|
+
// value into a temporary sub-buffer, then overwrite tag.size with the
|
|
1263
|
+
// sub-buffer's length before writing the tag. This is required for edits:
|
|
1264
|
+
// the wire stores tag.size literally, and a stale value (e.g. after
|
|
1265
|
+
// lengthening an FString) leaves the next reader misaligned.
|
|
1266
|
+
//
|
|
1267
|
+
// The flag propagates to sub-buffers so nested streams inside StructValue /
|
|
1268
|
+
// ObjectRef / Array<Struct> also get recomputed sizes. Direct callers that
|
|
1269
|
+
// want byte-identical preservation (the test-roundtrip.mjs path) leave the
|
|
1270
|
+
// flag unset; UnrealBlob.serialize sets it from blob._recomputeSizes.
|
|
1271
|
+
const recompute = !!writer._wsRecomputeSizes;
|
|
1142
1272
|
for (const p of properties) {
|
|
1143
1273
|
if (p._sizeMismatch) {
|
|
1144
1274
|
throw new Error(`writePropertyStream: property '${p.name}' has _sizeMismatch (${JSON.stringify(p._sizeMismatch)}); cannot safely re-emit`);
|
|
1145
1275
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1276
|
+
if (recompute) {
|
|
1277
|
+
const sub = new Writer(64);
|
|
1278
|
+
sub._wsRecomputeSizes = true;
|
|
1279
|
+
writeValue(sub, p.tag, p.value);
|
|
1280
|
+
const valueBytes = sub.finalize();
|
|
1281
|
+
const origSize = p.tag.size;
|
|
1282
|
+
p.tag.size = valueBytes.length;
|
|
1283
|
+
try {
|
|
1284
|
+
p.tag.write(writer);
|
|
1285
|
+
writer.writeBytes(valueBytes);
|
|
1286
|
+
} finally {
|
|
1287
|
+
// Restore so we don't mutate the blob's tags across multiple
|
|
1288
|
+
// serialize() calls. A subsequent recompute will rewrite them.
|
|
1289
|
+
p.tag.size = origSize;
|
|
1290
|
+
}
|
|
1291
|
+
} else {
|
|
1292
|
+
p.tag.write(writer);
|
|
1293
|
+
writeValue(writer, p.tag, p.value);
|
|
1294
|
+
}
|
|
1148
1295
|
}
|
|
1149
1296
|
new FName('None').write(writer);
|
|
1150
1297
|
if (emitTerminatorTrailer) writer.writeInt32(0);
|
package/values.mjs
CHANGED
|
@@ -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;
|
package/wscodec.mjs
CHANGED
|
@@ -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;
|
|
@@ -163,12 +171,22 @@ export class UnrealBlob {
|
|
|
163
171
|
* appending `bodyTrailing` after the None terminator + 4-byte FName.Number
|
|
164
172
|
* trailer that `writePropertyStream` emits.
|
|
165
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
|
+
*
|
|
166
184
|
* Throws if `_dirty` is true AND `error` is set: re-emitting would produce
|
|
167
185
|
* a malformed stream (the property tree is empty after a structural
|
|
168
186
|
* failure). Clear `.error` first if you intentionally want to emit from
|
|
169
187
|
* an externally-constructed properties array.
|
|
170
188
|
*/
|
|
171
|
-
serialize() {
|
|
189
|
+
serialize({ recomputeSizes } = {}) {
|
|
172
190
|
if (!this._dirty && this._raw instanceof Uint8Array) return this._raw;
|
|
173
191
|
|
|
174
192
|
if (this.error != null) {
|
|
@@ -179,6 +197,7 @@ export class UnrealBlob {
|
|
|
179
197
|
}
|
|
180
198
|
|
|
181
199
|
const w = new Writer(this._raw?.length || 256);
|
|
200
|
+
if (recomputeSizes ?? this._recomputeSizes) w._wsRecomputeSizes = true;
|
|
182
201
|
w.writeUint32(this.versionTag);
|
|
183
202
|
writePropertyStream(w, this.properties, /*emitTerminatorTrailer=*/true);
|
|
184
203
|
if (this.bodyTrailing && this.bodyTrailing.length > 0) {
|