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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 auroris
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# wscodec
|
|
2
|
+
|
|
3
|
+
Pure-JS codec for Soulmask `actor_data` property streams — the binary
|
|
4
|
+
payload UE 4.27 emits for every actor's serialized state, with the
|
|
5
|
+
Soulmask-specific quirks layered on top.
|
|
6
|
+
|
|
7
|
+
Zero runtime dependencies. Accepts uncompressed bytes, returns
|
|
8
|
+
JavaScript objects, and vice versa. Round-trip is byte-identical
|
|
9
|
+
against every actor in a tested `world.db` (174.6 MB across 11,667
|
|
10
|
+
rows; `npm test`).
|
|
11
|
+
|
|
12
|
+
## Scope
|
|
13
|
+
|
|
14
|
+
The library covers the property-stream wire format only. Soulmask's
|
|
15
|
+
column wire format adds an outer LZ4 envelope on top:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
actor_data column bytes
|
|
19
|
+
├── [0..3] u32 LE outer version tag = 0x00000002
|
|
20
|
+
└── [4..] LZ4 block size-prefixed; decompresses to:
|
|
21
|
+
├── [0..3] u32 LE inner version tag = 0x00000002
|
|
22
|
+
└── [4..] FPropertyTag stream + "None" terminator
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
wscodec handles the bottom half (the bytes that come out of LZ4
|
|
26
|
+
decompression). The caller handles LZ4 — see "LZ4 integration" below
|
|
27
|
+
for a copy-paste recipe with `lz4-wasm-nodejs`.
|
|
28
|
+
|
|
29
|
+
The SQLite `actor_table.data_version` column stores the NEGATIVE of
|
|
30
|
+
the on-wire DataVersion. A healthy blob with wire `DataVersion=2`
|
|
31
|
+
lives in a row whose `data_version` column reads `-2`. The wire
|
|
32
|
+
bytes themselves are always the unsigned `0x00000002`.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
npm install wscodec
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## API
|
|
41
|
+
|
|
42
|
+
### Top-level
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
import { UnrealBlob } from 'wscodec';
|
|
46
|
+
|
|
47
|
+
const blob = UnrealBlob.decode(uncompressedBytes); // Uint8Array → blob
|
|
48
|
+
const bytes = blob.serialize(); // blob → Uint8Array
|
|
49
|
+
UnrealBlob.detect(u8); // sniff version tag → boolean
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`UnrealBlob.decode(u8)` parses the version tag + property stream and
|
|
53
|
+
returns an `UnrealBlob` with:
|
|
54
|
+
|
|
55
|
+
| field | type | meaning |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `versionTag` | `number` | the wire version tag (always 2 in the wild) |
|
|
58
|
+
| `properties` | `Property[]` | top-level property list |
|
|
59
|
+
| `terminated` | `boolean` | whether the stream ended on a `None` terminator |
|
|
60
|
+
| `bodyTrailing` | `Uint8Array \| null` | any bytes past the terminator (rare) |
|
|
61
|
+
| `error` | `string \| null` | populated when structural decode failed |
|
|
62
|
+
| `_raw` | `Uint8Array` | the input bytes, retained for pass-through serialize |
|
|
63
|
+
| `_dirty` | `boolean` | set by mutating callers to force re-encode |
|
|
64
|
+
|
|
65
|
+
`blob.serialize()` returns a `Uint8Array`. When `_dirty` is false it
|
|
66
|
+
returns `_raw` verbatim (byte-identical pass-through). When `_dirty`
|
|
67
|
+
is true it re-emits the property stream from `properties` via
|
|
68
|
+
`writePropertyStream`.
|
|
69
|
+
|
|
70
|
+
`blob.findProperty(name)` returns the first top-level property whose
|
|
71
|
+
tag name matches, or `null`.
|
|
72
|
+
|
|
73
|
+
### Property tree
|
|
74
|
+
|
|
75
|
+
`blob.properties` is an array of `Property` instances. Each carries
|
|
76
|
+
a `PropertyTag` (`name`, `type`, `size`, …) and a `value` whose
|
|
77
|
+
JavaScript shape depends on the tag's type:
|
|
78
|
+
|
|
79
|
+
| tag type | value shape |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `IntProperty`, `FloatProperty`, `BoolProperty`, … | plain JS primitive |
|
|
82
|
+
| `StrProperty`, `NameProperty` | string / `FName` |
|
|
83
|
+
| `StructProperty` | `StructValue` — `.value` is either a plain object (known binary structs like `Vector`, `Quat`, `Transform`, `Guid`, …) or a nested property array |
|
|
84
|
+
| `ArrayProperty`, `SetProperty` | `ArrayValue` / `SetValue` with `.elements` |
|
|
85
|
+
| `MapProperty` | `MapValue` with `.entries: [[key, value], …]` |
|
|
86
|
+
| `ObjectProperty`, `ClassProperty`, `Weak*`, `Lazy*`, `WSObjectProperty` | `ObjectRef` (kind + optional path/classPath/embedded stream) |
|
|
87
|
+
| `SoftObjectProperty`, `SoftClassProperty` | `SoftObjectRef` (`assetPath`, `subPath`) |
|
|
88
|
+
| `TextProperty` | `FTextValue` (handles UE4 FText history types -1, 0, 2, 4) |
|
|
89
|
+
| anything wscodec couldn't structurally decode | `OpaqueValue` — bytes retained verbatim |
|
|
90
|
+
|
|
91
|
+
Submodule re-exports make the value classes importable directly:
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
import { ObjectRef, SoftObjectRef, FTextValue, OpaqueValue, StructValue } from 'wscodec';
|
|
95
|
+
import { PropertyTag, ArrayValue, SetValue, MapValue } from 'wscodec';
|
|
96
|
+
import { FName, FGuid } from 'wscodec';
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Lower-level helpers (`Cursor`, `Writer`, `readPropertyStream`,
|
|
100
|
+
`writePropertyStream`, `readValue`, `writeValue`, `STRUCT_HANDLERS`)
|
|
101
|
+
are also exported for callers building custom workflows on top.
|
|
102
|
+
|
|
103
|
+
### Editing
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
import { UnrealBlob } from 'wscodec';
|
|
107
|
+
|
|
108
|
+
const blob = UnrealBlob.decode(inner);
|
|
109
|
+
|
|
110
|
+
// Mutate the tree directly. The library does not provide typed
|
|
111
|
+
// mutators; callers manipulate `properties` and set `_dirty` to
|
|
112
|
+
// force re-encode.
|
|
113
|
+
blob.findProperty('JianZhuHP').value = 100;
|
|
114
|
+
blob._dirty = true;
|
|
115
|
+
|
|
116
|
+
const updatedBytes = blob.serialize(); // re-emits from properties
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`serialize()` for a dirty blob is byte-identical to a fresh
|
|
120
|
+
`decode + serialize` cycle on its output — verified on every row
|
|
121
|
+
of the tested `world.db`.
|
|
122
|
+
|
|
123
|
+
## LZ4 integration
|
|
124
|
+
|
|
125
|
+
`actor_data` column bytes come out of LZ4 compression. wscodec
|
|
126
|
+
doesn't bundle an LZ4 implementation — that's a caller concern. A
|
|
127
|
+
working recipe using `lz4-wasm-nodejs`:
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
import Database from 'better-sqlite3';
|
|
131
|
+
import * as lz4 from 'lz4-wasm-nodejs';
|
|
132
|
+
import { UnrealBlob } from 'wscodec';
|
|
133
|
+
|
|
134
|
+
const OUTER_VERSION_TAG = 0x00000002;
|
|
135
|
+
const OUTER_HEADER_SIZE = 4;
|
|
136
|
+
|
|
137
|
+
function decodeColumnBytes(u8) {
|
|
138
|
+
// Sniff the outer version tag.
|
|
139
|
+
const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
|
140
|
+
if (u8.length < OUTER_HEADER_SIZE + 4 || dv.getUint32(0, true) !== OUTER_VERSION_TAG) {
|
|
141
|
+
throw new Error('Not a Soulmask actor_data blob');
|
|
142
|
+
}
|
|
143
|
+
// LZ4 block starts right after the version tag.
|
|
144
|
+
const inner = lz4.decompress(u8.subarray(OUTER_HEADER_SIZE));
|
|
145
|
+
return UnrealBlob.decode(inner);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function encodeBlob(blob) {
|
|
149
|
+
const inner = blob.serialize();
|
|
150
|
+
const compressed = lz4.compress(inner);
|
|
151
|
+
const out = new Uint8Array(OUTER_HEADER_SIZE + compressed.length);
|
|
152
|
+
new DataView(out.buffer).setUint32(0, OUTER_VERSION_TAG, true);
|
|
153
|
+
out.set(compressed, OUTER_HEADER_SIZE);
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const db = new Database('./world.db', { readonly: true });
|
|
158
|
+
for (const row of db.prepare('SELECT actor_serial, actor_data FROM actor_table').all()) {
|
|
159
|
+
const blob = decodeColumnBytes(new Uint8Array(row.actor_data));
|
|
160
|
+
console.log(row.actor_serial, blob.properties.length, 'properties');
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Note: LZ4 compression is not deterministic — two compressors will
|
|
165
|
+
produce different bytes for the same input. wscodec's
|
|
166
|
+
byte-identical guarantee covers the inner property-stream bytes;
|
|
167
|
+
the outer column bytes round-trip only for unmodified blobs (cache
|
|
168
|
+
the input column bytes if you need that).
|
|
169
|
+
|
|
170
|
+
## Round-trip guarantees
|
|
171
|
+
|
|
172
|
+
For every row in the tested `world.db`:
|
|
173
|
+
|
|
174
|
+
- `UnrealBlob.decode(inner)` succeeds without `error` set.
|
|
175
|
+
- `blob.serialize()` with `_dirty = false` returns the input bytes byte-identical.
|
|
176
|
+
- `blob.serialize()` with `_dirty = true` re-emits from `properties` and is byte-identical to the input.
|
|
177
|
+
|
|
178
|
+
Coverage: 174,610,207 bytes across 11,667 actors, including every
|
|
179
|
+
known Soulmask wire-format quirk:
|
|
180
|
+
|
|
181
|
+
- `kind=0x01` ObjectProperty with the 4-byte actor-ref prefix.
|
|
182
|
+
- Embedded ObjectProperty streams with the 4-byte FName.Number trailer
|
|
183
|
+
(the Soulmask `JianZhuInstGLQComponent`-style nested format).
|
|
184
|
+
- ArrayProperty<ObjectProperty> with the JianZhuInstYuanXings
|
|
185
|
+
per-element placement-binary blocks (8-byte header + three
|
|
186
|
+
stride/count sections per yuan-xing prototype).
|
|
187
|
+
- ArrayProperty<TextProperty> elements with FText history types
|
|
188
|
+
-1, 0, 2, and 4 (including the legacy UE3-style uint32 booleans in
|
|
189
|
+
FNumberFormattingOptions).
|
|
190
|
+
- SetProperty<StructProperty> with implicit FGuid struct keys.
|
|
191
|
+
- Custom Soulmask Map<Struct,Struct> framing.
|
|
192
|
+
|
|
193
|
+
## Running the test
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
git clone … # repo with world.db at the root
|
|
197
|
+
cd repo/wscodec
|
|
198
|
+
npm install
|
|
199
|
+
npm test # looks for world.db two dirs up by default
|
|
200
|
+
# or
|
|
201
|
+
node test/test-roundtrip.mjs /path/to/world.db
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Test deps: `lz4-wasm-nodejs` (LZ4 inside the test), `better-sqlite3`
|
|
205
|
+
(reads the `world.db` SQLite file). Both are picked up via npm
|
|
206
|
+
module resolution; if `better-sqlite3` isn't installed at the package
|
|
207
|
+
root the test will surface that with a clear error.
|
|
208
|
+
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
MIT.
|
package/io.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor + Writer — byte-level read/write primitives over a Uint8Array.
|
|
3
|
+
*
|
|
4
|
+
* No Unreal semantics here. FString lives here too because it's a stateful
|
|
5
|
+
* read/write on the same DataView; everything else (FName, FGuid, structs,
|
|
6
|
+
* properties) builds on top of these.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class Cursor {
|
|
10
|
+
constructor(bytes, offset = 0) {
|
|
11
|
+
this.bytes = bytes;
|
|
12
|
+
this.dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
13
|
+
this.offset = offset;
|
|
14
|
+
}
|
|
15
|
+
pos() { return this.offset; }
|
|
16
|
+
eof() { return this.offset >= this.bytes.length; }
|
|
17
|
+
remaining() { return this.bytes.length - this.offset; }
|
|
18
|
+
skip(n) { this.offset += n; }
|
|
19
|
+
seek(n) { this.offset = n; }
|
|
20
|
+
|
|
21
|
+
readUint8() { const v = this.dv.getUint8(this.offset); this.offset += 1; return v; }
|
|
22
|
+
readInt8() { const v = this.dv.getInt8(this.offset); this.offset += 1; return v; }
|
|
23
|
+
readUint16() { const v = this.dv.getUint16(this.offset, true); this.offset += 2; return v; }
|
|
24
|
+
readInt16() { const v = this.dv.getInt16(this.offset, true); this.offset += 2; return v; }
|
|
25
|
+
readUint32() { const v = this.dv.getUint32(this.offset, true); this.offset += 4; return v; }
|
|
26
|
+
readInt32() { const v = this.dv.getInt32(this.offset, true); this.offset += 4; return v; }
|
|
27
|
+
readUint64() { const v = this.dv.getBigUint64(this.offset, true); this.offset += 8; return v; }
|
|
28
|
+
readInt64() { const v = this.dv.getBigInt64(this.offset, true); this.offset += 8; return v; }
|
|
29
|
+
readFloat32() { const v = this.dv.getFloat32(this.offset, true); this.offset += 4; return v; }
|
|
30
|
+
readFloat64() { const v = this.dv.getFloat64(this.offset, true); this.offset += 8; return v; }
|
|
31
|
+
readBytes(n) { const out = this.bytes.subarray(this.offset, this.offset + n); this.offset += n; return out; }
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* FString: int32 SaveNum (length in code units INCLUDING null terminator)
|
|
35
|
+
* SaveNum > 0 → ANSI; SaveNum < 0 → UTF-16 LE; SaveNum == 0 → empty.
|
|
36
|
+
*
|
|
37
|
+
* The wire format distinguishes two flavors of empty:
|
|
38
|
+
* SaveNum = 0 → "null" form. No further bytes.
|
|
39
|
+
* SaveNum = 1 (or -1) → "empty-with-terminator". 1 ANSI byte (or 1
|
|
40
|
+
* UTF-16 unit) follows; both are the NUL
|
|
41
|
+
* terminator. The decoded string is still "".
|
|
42
|
+
*
|
|
43
|
+
* Both produce the same JS value (""), but to round-trip byte-identical
|
|
44
|
+
* we need to know which one was on the wire. That distinction lives in
|
|
45
|
+
* `isNull` on the return value.
|
|
46
|
+
*/
|
|
47
|
+
readFString() {
|
|
48
|
+
const saveNum = this.readInt32();
|
|
49
|
+
if (saveNum === 0) return { value: '', isUnicode: false, isNull: true };
|
|
50
|
+
const isUnicode = saveNum < 0;
|
|
51
|
+
const codeUnits = isUnicode ? -saveNum : saveNum;
|
|
52
|
+
const byteLen = isUnicode ? codeUnits * 2 : codeUnits;
|
|
53
|
+
const slice = this.readBytes(byteLen);
|
|
54
|
+
const data = isUnicode ? slice.subarray(0, byteLen - 2) : slice.subarray(0, byteLen - 1);
|
|
55
|
+
let value;
|
|
56
|
+
if (isUnicode) {
|
|
57
|
+
const codes = [];
|
|
58
|
+
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
59
|
+
for (let i = 0; i + 1 < data.length; i += 2) codes.push(dv.getUint16(i, true));
|
|
60
|
+
value = String.fromCharCode(...codes);
|
|
61
|
+
} else {
|
|
62
|
+
value = '';
|
|
63
|
+
for (let i = 0; i < data.length; i++) value += String.fromCharCode(data[i]);
|
|
64
|
+
}
|
|
65
|
+
return { value, isUnicode, isNull: false };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class Writer {
|
|
70
|
+
constructor(initialCapacity = 256) {
|
|
71
|
+
this.buffer = new ArrayBuffer(initialCapacity);
|
|
72
|
+
this.bytes = new Uint8Array(this.buffer);
|
|
73
|
+
this.dv = new DataView(this.buffer);
|
|
74
|
+
this.offset = 0;
|
|
75
|
+
}
|
|
76
|
+
pos() { return this.offset; }
|
|
77
|
+
finalize() { return this.bytes.slice(0, this.offset); }
|
|
78
|
+
|
|
79
|
+
_ensure(n) {
|
|
80
|
+
if (this.offset + n <= this.buffer.byteLength) return;
|
|
81
|
+
let cap = this.buffer.byteLength;
|
|
82
|
+
while (cap < this.offset + n) cap *= 2;
|
|
83
|
+
const newBuf = new ArrayBuffer(cap);
|
|
84
|
+
const newU8 = new Uint8Array(newBuf);
|
|
85
|
+
newU8.set(this.bytes.subarray(0, this.offset));
|
|
86
|
+
this.buffer = newBuf;
|
|
87
|
+
this.bytes = newU8;
|
|
88
|
+
this.dv = new DataView(newBuf);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
writeUint8(v) { this._ensure(1); this.dv.setUint8(this.offset, v); this.offset += 1; }
|
|
92
|
+
writeInt8(v) { this._ensure(1); this.dv.setInt8(this.offset, v); this.offset += 1; }
|
|
93
|
+
writeUint16(v) { this._ensure(2); this.dv.setUint16(this.offset, v, true); this.offset += 2; }
|
|
94
|
+
writeInt16(v) { this._ensure(2); this.dv.setInt16(this.offset, v, true); this.offset += 2; }
|
|
95
|
+
writeUint32(v) { this._ensure(4); this.dv.setUint32(this.offset, v >>> 0, true); this.offset += 4; }
|
|
96
|
+
writeInt32(v) { this._ensure(4); this.dv.setInt32(this.offset, v | 0, true); this.offset += 4; }
|
|
97
|
+
writeUint64(v) { this._ensure(8); this.dv.setBigUint64(this.offset, BigInt(v), true); this.offset += 8; }
|
|
98
|
+
writeInt64(v) { this._ensure(8); this.dv.setBigInt64(this.offset, BigInt(v), true); this.offset += 8; }
|
|
99
|
+
writeFloat32(v) { this._ensure(4); this.dv.setFloat32(this.offset, v, true); this.offset += 4; }
|
|
100
|
+
writeFloat64(v) { this._ensure(8); this.dv.setFloat64(this.offset, v, true); this.offset += 8; }
|
|
101
|
+
writeBytes(u8) { this._ensure(u8.length); this.bytes.set(u8, this.offset); this.offset += u8.length; }
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Write an FString. The `isNull` parameter only matters when `value` is
|
|
105
|
+
* the empty string (or `null`/`undefined`); for non-empty strings the
|
|
106
|
+
* wire form is unambiguous.
|
|
107
|
+
*
|
|
108
|
+
* value = null/undefined → SaveNum = 0 (null form)
|
|
109
|
+
* value = '' and isNull truthy → SaveNum = 0 (null form)
|
|
110
|
+
* value = '' and isNull false → SaveNum = 1/-1 (empty-with-terminator)
|
|
111
|
+
* value = '' and isNull null → SaveNum = 0 (default; matches prior behavior)
|
|
112
|
+
* value = 'x' (any non-empty) → SaveNum encodes content
|
|
113
|
+
*
|
|
114
|
+
* `isUnicode` is auto-detected from the content when not supplied. For
|
|
115
|
+
* an empty-with-terminator string the caller picks the encoding via
|
|
116
|
+
* `isUnicode` (defaults to ANSI).
|
|
117
|
+
*/
|
|
118
|
+
writeFString(value, isUnicode = null, isNull = null) {
|
|
119
|
+
// Null form: no payload bytes.
|
|
120
|
+
if (value == null || (value === '' && isNull !== false)) {
|
|
121
|
+
this.writeInt32(0);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// Auto-detect encoding for non-empty content. Empty-with-terminator
|
|
125
|
+
// keeps whatever the caller passed (default ANSI).
|
|
126
|
+
if (isUnicode === null) {
|
|
127
|
+
isUnicode = false;
|
|
128
|
+
for (let i = 0; i < value.length; i++) {
|
|
129
|
+
if (value.charCodeAt(i) >= 0x80) { isUnicode = true; break; }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (isUnicode) {
|
|
133
|
+
const len = value.length + 1;
|
|
134
|
+
this.writeInt32(-len);
|
|
135
|
+
this._ensure(len * 2);
|
|
136
|
+
for (let i = 0; i < value.length; i++) {
|
|
137
|
+
this.dv.setUint16(this.offset + i * 2, value.charCodeAt(i), true);
|
|
138
|
+
}
|
|
139
|
+
this.dv.setUint16(this.offset + value.length * 2, 0, true);
|
|
140
|
+
this.offset += len * 2;
|
|
141
|
+
} else {
|
|
142
|
+
const len = value.length + 1;
|
|
143
|
+
this.writeInt32(len);
|
|
144
|
+
this._ensure(len);
|
|
145
|
+
for (let i = 0; i < value.length; i++) this.bytes[this.offset + i] = value.charCodeAt(i);
|
|
146
|
+
this.bytes[this.offset + value.length] = 0;
|
|
147
|
+
this.offset += len;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wscodec",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pure-JS codec for Soulmask actor_data property streams (UE4.27 FPropertyTag wire format). Zero runtime dependencies — accepts uncompressed bytes, returns JS objects, and vice versa. Round-trip byte-identical against every actor.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./wscodec.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./wscodec.mjs",
|
|
9
|
+
"./io": "./io.mjs",
|
|
10
|
+
"./primitives": "./primitives.mjs",
|
|
11
|
+
"./structs": "./structs.mjs",
|
|
12
|
+
"./values": "./values.mjs",
|
|
13
|
+
"./properties": "./properties.mjs"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"wscodec.mjs",
|
|
17
|
+
"io.mjs",
|
|
18
|
+
"primitives.mjs",
|
|
19
|
+
"structs.mjs",
|
|
20
|
+
"values.mjs",
|
|
21
|
+
"properties.mjs",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node test/test-roundtrip.mjs"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"soulmask",
|
|
29
|
+
"unreal",
|
|
30
|
+
"ue4",
|
|
31
|
+
"fpropertytag",
|
|
32
|
+
"codec"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"better-sqlite3": "^12.10.0",
|
|
37
|
+
"lz4-wasm-nodejs": "^0.9.2"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/primitives.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FName and FGuid.
|
|
3
|
+
*
|
|
4
|
+
* In this Soulmask format FName is serialized as a plain FString (no
|
|
5
|
+
* trailing FName.Number int32). The `number` field stays 0 and exists
|
|
6
|
+
* only for symmetry with full UE FNames.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class FName {
|
|
10
|
+
constructor(value, { isUnicode = false, number = 0, isNull = false } = {}) {
|
|
11
|
+
this.value = value;
|
|
12
|
+
this.isUnicode = isUnicode;
|
|
13
|
+
this.number = number;
|
|
14
|
+
// Tracks the wire-form distinction between an FString with SaveNum=0
|
|
15
|
+
// (the "null" form) and SaveNum=1 (empty-with-terminator). Only ever
|
|
16
|
+
// meaningful when `value === ''`; for non-empty FNames this stays
|
|
17
|
+
// false and is ignored on write.
|
|
18
|
+
this.isNull = isNull;
|
|
19
|
+
}
|
|
20
|
+
toString() { return this.value; }
|
|
21
|
+
|
|
22
|
+
static read(cursor) {
|
|
23
|
+
const s = cursor.readFString();
|
|
24
|
+
return new FName(s.value, { isUnicode: s.isUnicode, isNull: !!s.isNull });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
write(writer) { writer.writeFString(this.value, this.isUnicode, this.isNull); }
|
|
28
|
+
|
|
29
|
+
/** Accepts an FName, a bare string, or a plain {value,isUnicode,isNull} record. */
|
|
30
|
+
static from(x) {
|
|
31
|
+
if (x instanceof FName) return x;
|
|
32
|
+
if (typeof x === 'string') return new FName(x);
|
|
33
|
+
if (x && typeof x === 'object') {
|
|
34
|
+
return new FName(x.value, {
|
|
35
|
+
isUnicode: !!x.isUnicode,
|
|
36
|
+
number: x.number || 0,
|
|
37
|
+
isNull: !!x.isNull,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
throw new Error('FName.from: unsupported value ' + typeof x);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class FGuid {
|
|
45
|
+
/** Stored as the canonical 8-4-4-4-12 hex string (uppercase). */
|
|
46
|
+
constructor(value) { this.value = value; }
|
|
47
|
+
toString() { return this.value; }
|
|
48
|
+
|
|
49
|
+
static read(cursor) {
|
|
50
|
+
const A = cursor.readUint32(), B = cursor.readUint32(), C = cursor.readUint32(), D = cursor.readUint32();
|
|
51
|
+
const h = (n, w) => n.toString(16).padStart(w, '0').toUpperCase();
|
|
52
|
+
return new FGuid(`${h(A, 8)}-${h(B >>> 16, 4)}-${h(B & 0xFFFF, 4)}-${h(C >>> 16, 4)}-${h(C & 0xFFFF, 4)}${h(D, 8)}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
write(writer) {
|
|
56
|
+
const m = String(this.value).match(/^([0-9A-Fa-f]{8})-([0-9A-Fa-f]{4})-([0-9A-Fa-f]{4})-([0-9A-Fa-f]{4})-([0-9A-Fa-f]{4})([0-9A-Fa-f]{8})$/);
|
|
57
|
+
if (!m) throw new Error(`FGuid.write: invalid FGuid string '${this.value}'`);
|
|
58
|
+
const A = parseInt(m[1], 16);
|
|
59
|
+
const B = (parseInt(m[2], 16) << 16) | parseInt(m[3], 16);
|
|
60
|
+
const C = (parseInt(m[4], 16) << 16) | parseInt(m[5], 16);
|
|
61
|
+
const D = parseInt(m[6], 16);
|
|
62
|
+
writer.writeUint32(A); writer.writeUint32(B); writer.writeUint32(C); writer.writeUint32(D);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static from(x) {
|
|
66
|
+
if (x instanceof FGuid) return x;
|
|
67
|
+
if (typeof x === 'string') return new FGuid(x);
|
|
68
|
+
throw new Error('FGuid.from: unsupported value ' + typeof x);
|
|
69
|
+
}
|
|
70
|
+
}
|