rl-item-mod 1.0.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/dist/upk.js ADDED
@@ -0,0 +1,376 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as zlib from 'zlib';
4
+ import * as crypto from 'crypto';
5
+ import { fileURLToPath } from 'url';
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ export class DecryptionProvider {
8
+ static keys = [];
9
+ static loadKeys() {
10
+ if (this.keys.length > 0)
11
+ return;
12
+ const keysPath = path.join(__dirname, '../python/keys.txt');
13
+ if (!fs.existsSync(keysPath)) {
14
+ console.warn(" Warning: keys.txt not found. Encrypted files will fail.");
15
+ return;
16
+ }
17
+ const lines = fs.readFileSync(keysPath, 'utf8').split('\n');
18
+ this.keys = lines.map(l => Buffer.from(l.trim(), 'base64')).filter(b => b.length === 32);
19
+ }
20
+ static decryptECB(data, key) {
21
+ const decipher = crypto.createDecipheriv('aes-256-ecb', key, null);
22
+ decipher.setAutoPadding(false);
23
+ return Buffer.concat([decipher.update(data), decipher.final()]);
24
+ }
25
+ static encryptECB(data, key) {
26
+ const cipher = crypto.createCipheriv('aes-256-ecb', key, null);
27
+ cipher.setAutoPadding(false);
28
+ return Buffer.concat([cipher.update(data), cipher.final()]);
29
+ }
30
+ static findKey(encryptedHeader, firstPlainByte) {
31
+ this.loadKeys();
32
+ for (const key of this.keys) {
33
+ try {
34
+ const decrypted = this.decryptECB(encryptedHeader.subarray(0, 16), key);
35
+ // Check if the decrypted content makes sense.
36
+ // Usually the first entry in the name table is an FString.
37
+ // If it's a valid string length, it's likely the right key.
38
+ const len = decrypted.readInt32LE(0);
39
+ if (Math.abs(len) < 1000 && Math.abs(len) > 0) {
40
+ return key;
41
+ }
42
+ }
43
+ catch (e) { }
44
+ }
45
+ return null;
46
+ }
47
+ }
48
+ export class BinaryReader {
49
+ buffer;
50
+ pos = 0;
51
+ constructor(buffer) {
52
+ this.buffer = buffer;
53
+ }
54
+ readI32() { const v = this.buffer.readInt32LE(this.pos); this.pos += 4; return v; }
55
+ readU32() { const v = this.buffer.readUInt32LE(this.pos); this.pos += 4; return v; }
56
+ readI64() { const v = this.buffer.readBigInt64LE(this.pos); this.pos += 8; return v; }
57
+ readU64() { const v = this.buffer.readBigUint64LE(this.pos); this.pos += 8; return v; }
58
+ readFString() {
59
+ const len = this.readI32();
60
+ if (len === 0)
61
+ return "";
62
+ if (len > 0) {
63
+ const s = this.buffer.toString('utf8', this.pos, this.pos + len - 1);
64
+ this.pos += len;
65
+ return s;
66
+ }
67
+ else {
68
+ const absLen = Math.abs(len) * 2;
69
+ const s = this.buffer.toString('utf16le', this.pos, this.pos + absLen - 2);
70
+ this.pos += absLen;
71
+ return s;
72
+ }
73
+ }
74
+ skip(n) { this.pos += n; }
75
+ }
76
+ export class UPKFile {
77
+ filePath;
78
+ summary = null;
79
+ exports = [];
80
+ dataBuffer = null;
81
+ decryptionKey = null;
82
+ constructor(filePath) {
83
+ this.filePath = filePath;
84
+ }
85
+ getData() {
86
+ return this.dataBuffer;
87
+ }
88
+ readBytes(pos, size) {
89
+ if (!this.dataBuffer) {
90
+ const buffer = Buffer.alloc(size);
91
+ const fd = fs.openSync(this.filePath, 'r');
92
+ try {
93
+ fs.readSync(fd, buffer, 0, size, pos);
94
+ }
95
+ finally {
96
+ fs.closeSync(fd);
97
+ }
98
+ return buffer;
99
+ }
100
+ return this.dataBuffer.subarray(pos, pos + size);
101
+ }
102
+ readSummary() {
103
+ const initialBuffer = this.readBytes(0, 4096);
104
+ const r = new BinaryReader(initialBuffer);
105
+ const tag = r.readU32();
106
+ if (tag !== 0x9E2A83C1)
107
+ throw new Error('Invalid UPK Tag');
108
+ const version = r.readU32();
109
+ const fileVersion = version & 0xFFFF;
110
+ const licenseeVersion = version >> 16;
111
+ const headerSize = r.readI32();
112
+ const folderName = r.readFString();
113
+ const packageFlags = r.readU32();
114
+ const nameCount = r.readI32();
115
+ const nameOffset = r.readI32();
116
+ const exportCount = r.readI32();
117
+ const exportOffset = r.readI32();
118
+ const importCount = r.readI32();
119
+ const importOffset = r.readI32();
120
+ const dependsOffset = r.readI32();
121
+ // Rocket League encrypts the metadata of all compressed packages
122
+ const isCompressed = (packageFlags & 0x02000000) !== 0;
123
+ const isEncrypted = isCompressed || (packageFlags & 0x01000000) !== 0;
124
+ let workingHeader = Buffer.alloc(headerSize);
125
+ if (isEncrypted) {
126
+ console.log(` Package is encrypted. Searching for key...`);
127
+ const encryptedHeader = Buffer.alloc(headerSize);
128
+ const fd = fs.openSync(this.filePath, 'r');
129
+ fs.readSync(fd, encryptedHeader, 0, headerSize, 0);
130
+ fs.closeSync(fd);
131
+ let encryptedPart = encryptedHeader.subarray(nameOffset);
132
+ const remainder = encryptedPart.length % 16;
133
+ if (remainder !== 0) {
134
+ const padded = Buffer.alloc(encryptedPart.length + (16 - remainder));
135
+ encryptedPart.copy(padded);
136
+ encryptedPart = padded;
137
+ }
138
+ const key = DecryptionProvider.findKey(encryptedPart, 0);
139
+ if (!key)
140
+ throw new Error("Could not find a valid decryption key for this package.");
141
+ console.log(` Decryption key found: ${key.toString('base64').substring(0, 8)}...`);
142
+ this.decryptionKey = key;
143
+ const decryptedPart = DecryptionProvider.decryptECB(encryptedPart, key);
144
+ encryptedHeader.copy(workingHeader, 0, 0, nameOffset);
145
+ decryptedPart.copy(workingHeader, nameOffset);
146
+ }
147
+ else {
148
+ const fd = fs.openSync(this.filePath, 'r');
149
+ fs.readSync(fd, workingHeader, 0, headerSize, 0);
150
+ fs.closeSync(fd);
151
+ }
152
+ // Use workingHeader for deeper parsing
153
+ const hr = new BinaryReader(workingHeader);
154
+ hr.pos = r.pos; // Sync positions
155
+ hr.skip(16); // ImportExportGuidsOffset to ThumbnailTableOffset
156
+ hr.skip(16); // Guid
157
+ const genCount = hr.readI32();
158
+ hr.skip(genCount * 12);
159
+ hr.skip(8); // EngineVersion, CookerVersion
160
+ const compressionFlags = hr.readU32();
161
+ // Standard UE3 chunks
162
+ const standardChunks = [];
163
+ const standardChunkCount = hr.readI32();
164
+ for (let i = 0; i < standardChunkCount; i++) {
165
+ standardChunks.push({
166
+ uncompressedOffset: hr.readI32(),
167
+ uncompressedSize: hr.readI32(),
168
+ compressedOffset: hr.readI32(),
169
+ compressedSize: hr.readI32()
170
+ });
171
+ }
172
+ // Rocket League Metadata
173
+ hr.readI32(); // Unknown
174
+ const stringCount = hr.readI32();
175
+ for (let i = 0; i < stringCount; i++)
176
+ hr.readFString();
177
+ const texAllocCount = hr.readI32();
178
+ for (let i = 0; i < texAllocCount; i++) {
179
+ hr.skip(20);
180
+ const subCount = hr.readI32();
181
+ hr.skip(subCount * 4);
182
+ }
183
+ // File Compression Metadata
184
+ const garbageSize = hr.readI32();
185
+ const rlChunksOffset = hr.readI32();
186
+ const lastBlockSize = hr.readI32();
187
+ let chunks = standardChunks;
188
+ if (rlChunksOffset > 0) {
189
+ const cr = new BinaryReader(workingHeader);
190
+ cr.pos = nameOffset + rlChunksOffset;
191
+ const rlChunkCount = cr.readI32();
192
+ console.log(` RL Chunk Table found at 0x${cr.pos.toString(16)}. Count: ${rlChunkCount}`);
193
+ if (rlChunkCount > 0 && rlChunkCount < 10000) {
194
+ chunks = [];
195
+ for (let i = 0; i < rlChunkCount; i++) {
196
+ chunks.push({
197
+ uncompressedOffset: Number(cr.readI64()),
198
+ uncompressedSize: cr.readI32(),
199
+ compressedOffset: Number(cr.readI64()),
200
+ compressedSize: cr.readI32()
201
+ });
202
+ }
203
+ }
204
+ }
205
+ this.summary = {
206
+ tag, fileVersion, licenseeVersion, headerSize, folderName,
207
+ packageFlags, nameCount, nameOffset, exportCount, exportOffset,
208
+ importCount, importOffset, dependsOffset,
209
+ compressionFlags, compressedChunks: chunks
210
+ };
211
+ if (isCompressed) {
212
+ this.decompress(workingHeader);
213
+ }
214
+ else {
215
+ this.dataBuffer = fs.readFileSync(this.filePath);
216
+ if (isEncrypted) {
217
+ workingHeader.copy(this.dataBuffer, 0, 0, headerSize);
218
+ this.summary.packageFlags &= ~0x01000000;
219
+ }
220
+ }
221
+ }
222
+ decompress(headerBuffer) {
223
+ if (!this.summary)
224
+ return;
225
+ if (this.summary.compressedChunks.length === 0) {
226
+ console.warn(" Warning: Package marked as compressed but no chunks found.");
227
+ this.dataBuffer = fs.readFileSync(this.filePath);
228
+ return;
229
+ }
230
+ console.log(` Decompressing ${this.summary.compressedChunks.length} chunks...`);
231
+ const fd = fs.openSync(this.filePath, 'r');
232
+ let uncompressedBuffers = [headerBuffer];
233
+ for (const chunk of this.summary.compressedChunks) {
234
+ const chunkPayload = Buffer.alloc(chunk.compressedSize);
235
+ fs.readSync(fd, chunkPayload, 0, chunk.compressedSize, chunk.compressedOffset);
236
+ let payloadPos = 0;
237
+ const magic = chunkPayload.readUInt32LE(payloadPos);
238
+ payloadPos += 4;
239
+ if (magic !== 0x9E2A83C1) {
240
+ throw new Error(`Invalid chunk magic at offset ${chunk.compressedOffset}`);
241
+ }
242
+ const blockSize = chunkPayload.readInt32LE(payloadPos);
243
+ payloadPos += 4;
244
+ const totalCompressedSize = chunkPayload.readInt32LE(payloadPos);
245
+ payloadPos += 4;
246
+ const totalUncompressedSize = chunkPayload.readInt32LE(payloadPos);
247
+ payloadPos += 4;
248
+ const blocks = [];
249
+ let sumUncomp = 0;
250
+ while (sumUncomp < totalUncompressedSize) {
251
+ const compSize = chunkPayload.readInt32LE(payloadPos);
252
+ payloadPos += 4;
253
+ const uncompSize = chunkPayload.readInt32LE(payloadPos);
254
+ payloadPos += 4;
255
+ blocks.push({ compSize, uncompSize });
256
+ sumUncomp += uncompSize;
257
+ }
258
+ for (const block of blocks) {
259
+ const compressedBlock = chunkPayload.subarray(payloadPos, payloadPos + block.compSize);
260
+ payloadPos += block.compSize;
261
+ try {
262
+ const inflated = zlib.inflateSync(compressedBlock);
263
+ uncompressedBuffers.push(inflated);
264
+ }
265
+ catch (e) {
266
+ throw new Error(`Block decompression failed in chunk at ${chunk.compressedOffset}: ${e}`);
267
+ }
268
+ }
269
+ }
270
+ fs.closeSync(fd);
271
+ this.dataBuffer = Buffer.concat(uncompressedBuffers);
272
+ this.summary.packageFlags &= ~0x02000000;
273
+ console.log(` Decompression complete. Final size: ${this.dataBuffer.length} bytes.`);
274
+ }
275
+ readFString(buffer, pos) {
276
+ const length = buffer.readInt32LE(pos);
277
+ if (length === 0)
278
+ return { str: '', bytesRead: 4 };
279
+ if (length > 0) {
280
+ const str = buffer.toString('utf8', pos + 4, pos + 4 + length - 1);
281
+ return { str, bytesRead: 4 + length };
282
+ }
283
+ else {
284
+ const absLen = Math.abs(length) * 2;
285
+ const str = buffer.toString('utf16le', pos + 4, pos + 4 + absLen - 2);
286
+ return { str, bytesRead: 4 + absLen };
287
+ }
288
+ }
289
+ readExportMap() {
290
+ if (!this.summary)
291
+ return;
292
+ if (!this.dataBuffer) {
293
+ console.error(" Error: Attempted to read ExportMap before data buffer was loaded.");
294
+ return;
295
+ }
296
+ const r = new BinaryReader(this.dataBuffer);
297
+ r.pos = this.summary.exportOffset;
298
+ this.exports = [];
299
+ console.log(` Parsing Export Map (${this.summary.exportCount} entries)...`);
300
+ for (let i = 0; i < this.summary.exportCount; i++) {
301
+ const classIndex = r.readI32();
302
+ const superIndex = r.readI32();
303
+ const outerIndex = r.readI32();
304
+ const objectNameIndex = r.readI32();
305
+ const objectNameNumber = r.readI32(); // FName instance number
306
+ const archetypeIndex = r.readI32();
307
+ const objectFlags = r.readU64();
308
+ const serialSize = r.readI32();
309
+ const serialOffset = Number(r.readI64());
310
+ const exportFlags = r.readI32();
311
+ // Rocket League extras
312
+ const netObjCount = r.readI32();
313
+ r.skip(netObjCount * 4);
314
+ r.skip(16); // PackageGuid
315
+ r.readI32(); // PackageFlags
316
+ this.exports.push({
317
+ classIndex, superIndex, outerIndex,
318
+ objectNameIndex, archetypeIndex,
319
+ objectFlags, serialSize, serialOffset, exportFlags
320
+ });
321
+ }
322
+ }
323
+ extractExport(index) {
324
+ if (this.exports.length === 0)
325
+ throw new Error('Export table is empty. Did readExportMap fail?');
326
+ const exp = this.exports[index];
327
+ if (!exp)
328
+ throw new Error(`Export index ${index} out of bounds (Table size: ${this.exports.length})`);
329
+ return this.readBytes(exp.serialOffset, exp.serialSize);
330
+ }
331
+ save() {
332
+ if (!this.dataBuffer || !this.summary)
333
+ throw new Error('File not loaded');
334
+ // If we have a key, we MUST re-encrypt the header before saving
335
+ if (this.decryptionKey) {
336
+ console.log(` Re-encrypting header for ${path.basename(this.filePath)}...`);
337
+ const headerSize = this.summary.headerSize;
338
+ const nameOffset = this.summary.nameOffset;
339
+ let plainPart = this.dataBuffer.subarray(nameOffset, headerSize);
340
+ const remainder = plainPart.length % 16;
341
+ if (remainder !== 0) {
342
+ const padded = Buffer.alloc(plainPart.length + (16 - remainder));
343
+ plainPart.copy(padded);
344
+ plainPart = padded;
345
+ }
346
+ const encryptedPart = DecryptionProvider.encryptECB(plainPart, this.decryptionKey);
347
+ const finalSaveBuffer = Buffer.from(this.dataBuffer);
348
+ encryptedPart.copy(finalSaveBuffer, nameOffset, 0, headerSize - nameOffset);
349
+ // Set the encrypted flag back
350
+ finalSaveBuffer.writeUInt32LE(this.summary.packageFlags | 0x01000000, this.findPackageFlagsOffset());
351
+ fs.writeFileSync(this.filePath, finalSaveBuffer);
352
+ }
353
+ else {
354
+ fs.writeFileSync(this.filePath, this.dataBuffer);
355
+ }
356
+ console.log(` Patched and Saved: ${path.basename(this.filePath)}`);
357
+ }
358
+ patchExport(index, newContent) {
359
+ const exp = this.exports[index];
360
+ if (!exp)
361
+ throw new Error('Export index out of bounds');
362
+ if (!this.dataBuffer || !this.summary)
363
+ throw new Error('File not loaded');
364
+ const patchSize = Math.min(newContent.length, exp.serialSize);
365
+ newContent.copy(this.dataBuffer, exp.serialOffset, 0, patchSize);
366
+ this.save();
367
+ }
368
+ findPackageFlagsOffset() {
369
+ // Basic summary: tag(4), version(4), headerSize(4), folderName(var)
370
+ // packageFlags comes after folderName
371
+ // folderName is an FString
372
+ const folderNameLen = this.dataBuffer.readInt32LE(12);
373
+ const bytesRead = folderNameLen === 0 ? 4 : (folderNameLen > 0 ? 4 + folderNameLen : 4 + Math.abs(folderNameLen) * 2);
374
+ return 12 + bytesRead;
375
+ }
376
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "rl-item-mod",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "A comprehensive CLI tool for safely applying visual asset swaps to Rocket League UPK files with full encryption and binary offset handling.",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "rl-item-mod": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "python"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "start": "ts-node index.ts",
17
+ "test": "echo \"Error: no test specified\" && exit 1"
18
+ },
19
+ "keywords": [
20
+ "rocket-league",
21
+ "upk",
22
+ "modding",
23
+ "asset-swapper"
24
+ ],
25
+ "author": "RLItemMod",
26
+ "license": "ISC",
27
+ "dependencies": {
28
+ "axios": "^1.16.0",
29
+ "commander": "^14.0.3",
30
+ "inquirer": "^13.4.2",
31
+ "string-similarity": "^4.0.4"
32
+ },
33
+ "devDependencies": {
34
+ "@types/inquirer": "^9.0.9",
35
+ "@types/node": "^25.6.0",
36
+ "@types/string-similarity": "^4.0.2",
37
+ "ts-node": "^10.9.2",
38
+ "typescript": "^6.0.3"
39
+ }
40
+ }