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/README.md +59 -0
- package/dist/assets.js +110 -0
- package/dist/index.js +277 -0
- package/dist/scratch/ts_debug_exports.js +33 -0
- package/dist/swapper.js +81 -0
- package/dist/upk.js +376 -0
- package/package.json +40 -0
- package/python/items.json +82850 -0
- package/python/keys.txt +1049 -0
- package/python/rl_asset_swapper.py +991 -0
- package/python/rl_upk_editor.py +3859 -0
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# RLItemMod
|
|
2
|
+
|
|
3
|
+
A powerful, user-friendly Node.js CLI tool for performing visual asset swaps in Rocket League.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`RLItemMod` provides an interactive terminal wizard that allows you to swap in-game items (e.g., swapping a standard boost for Alpha Reward). Under the hood, it seamlessly invokes the advanced `RLUPKTools` Python engine to accurately parse `.upk` encryption, perfectly expand Name Table string offsets, and rebuild the package architecture without causing game crashes.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Interactive Wizard**: A beautiful command-line interface to search for and select your source and target items.
|
|
12
|
+
- **Python Interop**: Leverages a robust Python backend to handle complex LZO decompression, AES decryption, and binary offset shifting.
|
|
13
|
+
- **Automated Backups**: Automatically backs up original game assets before patching, with a one-click CLI restore feature.
|
|
14
|
+
- **Item Database**: Uses a built-in `items.json` database for fuzzy-searching and mapping in-game item names directly to their underlying UPK files.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
### Prerequisites
|
|
19
|
+
|
|
20
|
+
- Node.js (v18+)
|
|
21
|
+
- Python 3.8+ (must be available in your system PATH)
|
|
22
|
+
|
|
23
|
+
### Global Install (Recommended)
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install -g rl-item-mod
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Local Development
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
git clone https://github.com/bitsfdb/RLItemMod.git
|
|
33
|
+
cd RLItemMod
|
|
34
|
+
npm install
|
|
35
|
+
npm run build
|
|
36
|
+
npm link
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
Simply launch the interactive wizard from your terminal:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
rl-item-mod
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or run directly via npx:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx rl-item-mod@latest
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Credits
|
|
54
|
+
|
|
55
|
+
Massive credits to [CrunchyRL/RLUPKTools](https://github.com/CrunchyRL/RLUPKTools) for making this repository possible. The advanced Python engineering for parsing and shifting Unreal Engine 3 UPK binaries was instrumental in making this project work safely.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT
|
package/dist/assets.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import stringSimilarity from 'string-similarity';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
// Load and transform items.json into ASSET_MAP
|
|
7
|
+
const ITEMS_JSON_PATH = path.join(__dirname, '../python/items.json');
|
|
8
|
+
let loadedAssets = [];
|
|
9
|
+
try {
|
|
10
|
+
if (fs.existsSync(ITEMS_JSON_PATH)) {
|
|
11
|
+
const rawData = JSON.parse(fs.readFileSync(ITEMS_JSON_PATH, 'utf8'));
|
|
12
|
+
if (rawData.Items) {
|
|
13
|
+
loadedAssets = rawData.Items.map((p) => ({
|
|
14
|
+
name: p["Product"],
|
|
15
|
+
productId: p["ID"],
|
|
16
|
+
packageName: p["AssetPackage"] ? p["AssetPackage"].replace('.upk', '') : '',
|
|
17
|
+
assetPath: p["AssetPath"],
|
|
18
|
+
slot: p["Slot"]
|
|
19
|
+
})).filter((a) => a.packageName !== '');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
console.warn(" Warning: Failed to load items.json. Using minimal fallback list.");
|
|
25
|
+
}
|
|
26
|
+
export const ASSET_MAP = loadedAssets.length > 0 ? loadedAssets : [
|
|
27
|
+
{ name: "Standard Boost", packageName: "Standard_Boost_SF", productId: 63 },
|
|
28
|
+
{ name: "Flamethrower", packageName: "Flamethrower_SF", productId: 36 },
|
|
29
|
+
{ name: "(Alpha Reward) Gold Rush", packageName: "Boost_AlphaReward_SF", productId: 32 },
|
|
30
|
+
{ name: "Octane", packageName: "Body_Octane_SF", productId: 23 },
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* Performs a fuzzy/case-insensitive search for an item name or product ID.
|
|
34
|
+
* If mapping fails, it scans the provided directory for potential matches.
|
|
35
|
+
*/
|
|
36
|
+
export async function resolvePackagePath(query, cookedDir) {
|
|
37
|
+
const q = query.toLowerCase();
|
|
38
|
+
// 1. Check Internal Map first
|
|
39
|
+
let match = ASSET_MAP.find(a => a.name.toLowerCase() === q || a.productId?.toString() === q);
|
|
40
|
+
if (match) {
|
|
41
|
+
const fullPath = path.join(cookedDir, `${match.packageName}.upk`);
|
|
42
|
+
if (fs.existsSync(fullPath)) {
|
|
43
|
+
return { path: fullPath, name: match.packageName };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// 2. Directory Scan + Keyword Search
|
|
47
|
+
if (!fs.existsSync(cookedDir)) {
|
|
48
|
+
console.warn(` Warning: CookedPCConsole directory not found at: ${cookedDir}`);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const files = fs.readdirSync(cookedDir)
|
|
52
|
+
.filter(f => f.endsWith('.upk'))
|
|
53
|
+
.filter(f => !f.toLowerCase().includes('t_sf') && !f.toLowerCase().includes('sf_t'));
|
|
54
|
+
const keywords = q.split(' ').filter(k => k.length > 2); // only words > 2 chars
|
|
55
|
+
// Find files containing ALL keywords
|
|
56
|
+
let keywordMatches = files.filter(f => {
|
|
57
|
+
const lowerF = f.toLowerCase();
|
|
58
|
+
return keywords.every(k => lowerF.includes(k));
|
|
59
|
+
});
|
|
60
|
+
if (keywordMatches.length === 1) {
|
|
61
|
+
return {
|
|
62
|
+
path: path.join(cookedDir, keywordMatches[0]),
|
|
63
|
+
name: keywordMatches[0].replace('.upk', '')
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
else if (keywordMatches.length > 1) {
|
|
67
|
+
return { candidates: keywordMatches };
|
|
68
|
+
}
|
|
69
|
+
// 3. String Similarity (Fuzzy Matching)
|
|
70
|
+
const matches = stringSimilarity.findBestMatch(q, files);
|
|
71
|
+
const topMatches = matches.ratings
|
|
72
|
+
.sort((a, b) => b.rating - a.rating)
|
|
73
|
+
.slice(0, 5)
|
|
74
|
+
.filter(m => m.rating > 0.3); // Threshold for sanity
|
|
75
|
+
if (topMatches.length > 0) {
|
|
76
|
+
// If the best match is very high, return it, otherwise return candidates
|
|
77
|
+
if (topMatches[0].rating > 0.8) {
|
|
78
|
+
return {
|
|
79
|
+
path: path.join(cookedDir, topMatches[0].target),
|
|
80
|
+
name: topMatches[0].target.replace('.upk', '')
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return { candidates: topMatches.map(m => m.target) };
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
export function getClosestFiles(query, cookedDir) {
|
|
88
|
+
if (!fs.existsSync(cookedDir))
|
|
89
|
+
return [];
|
|
90
|
+
const files = fs.readdirSync(cookedDir)
|
|
91
|
+
.filter(f => f.endsWith('.upk'))
|
|
92
|
+
.filter(f => !f.toLowerCase().includes('t_sf') && !f.toLowerCase().includes('sf_t'));
|
|
93
|
+
const matches = stringSimilarity.findBestMatch(query.toLowerCase(), files);
|
|
94
|
+
return matches.ratings
|
|
95
|
+
.sort((a, b) => b.rating - a.rating)
|
|
96
|
+
.slice(0, 5)
|
|
97
|
+
.map(m => m.target);
|
|
98
|
+
}
|
|
99
|
+
export function searchAssets(term) {
|
|
100
|
+
const q = term.toLowerCase();
|
|
101
|
+
return ASSET_MAP.filter(a => {
|
|
102
|
+
const nameMatch = a.name.toLowerCase().includes(q);
|
|
103
|
+
const pkgMatch = a.packageName && a.packageName.toLowerCase().includes(q);
|
|
104
|
+
const idMatch = a.productId?.toString() === q;
|
|
105
|
+
const slotMatch = a.slot?.toLowerCase().includes(q);
|
|
106
|
+
const matches = nameMatch || pkgMatch || idMatch || slotMatch;
|
|
107
|
+
const isThumbnail = a.packageName && (a.packageName.toLowerCase().includes('t_sf') || a.packageName.toLowerCase().includes('sf_t'));
|
|
108
|
+
return matches && !isThumbnail;
|
|
109
|
+
}).slice(0, 20); // Limit to top 20 for CLI readability
|
|
110
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import inquirer from 'inquirer';
|
|
7
|
+
import { UPKFile } from './upk.js';
|
|
8
|
+
import { resolvePackagePath, searchAssets } from './assets.js';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
const program = new Command();
|
|
13
|
+
program
|
|
14
|
+
.name('RLItemMod')
|
|
15
|
+
.description('Rocket League Surgical UPK Patcher')
|
|
16
|
+
.version('1.3.0');
|
|
17
|
+
const API_ENDPOINT = 'https://dank/rlapi';
|
|
18
|
+
const DEFAULT_COOKED_DIR = 'E:\\games\\rocketleague\\TAGame\\CookedPCConsole';
|
|
19
|
+
/**
|
|
20
|
+
* Interactive Wizard Flow
|
|
21
|
+
*/
|
|
22
|
+
async function runInteractiveWizard() {
|
|
23
|
+
let active = true;
|
|
24
|
+
while (active) {
|
|
25
|
+
console.clear();
|
|
26
|
+
console.log('Welcome to RLItemMod, What would you like to do?');
|
|
27
|
+
console.log('-----------------------------------------------------');
|
|
28
|
+
const { action } = await inquirer.prompt([{
|
|
29
|
+
type: 'rawlist',
|
|
30
|
+
name: 'action',
|
|
31
|
+
message: 'What would you like to do?',
|
|
32
|
+
choices: [
|
|
33
|
+
{ name: 'Swap Item (Visual & Offset Shifting)', value: 'swap' },
|
|
34
|
+
{ name: 'Search Asset Database', value: 'search' },
|
|
35
|
+
{ name: 'Restore Backups', value: 'restore' },
|
|
36
|
+
{ name: 'Configure Game Directory', value: 'config' },
|
|
37
|
+
{ name: 'Exit', value: 'exit' }
|
|
38
|
+
]
|
|
39
|
+
}]);
|
|
40
|
+
if (action === 'exit') {
|
|
41
|
+
active = false;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
if (action === 'config') {
|
|
45
|
+
const { newDir } = await inquirer.prompt([{
|
|
46
|
+
type: 'input',
|
|
47
|
+
name: 'newDir',
|
|
48
|
+
message: 'Path to CookedPCConsole:',
|
|
49
|
+
default: global.COOKED_DIR || DEFAULT_COOKED_DIR
|
|
50
|
+
}]);
|
|
51
|
+
global.COOKED_DIR = newDir;
|
|
52
|
+
console.log(` Game directory updated to: ${newDir}`);
|
|
53
|
+
await inquirer.prompt([{ type: 'input', name: 'pause', message: 'Press Enter to continue...' }]);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (action === 'search') {
|
|
57
|
+
const { term } = await inquirer.prompt([{
|
|
58
|
+
type: 'input',
|
|
59
|
+
name: 'term',
|
|
60
|
+
message: 'Enter search term (Item Name or Package):'
|
|
61
|
+
}]);
|
|
62
|
+
const results = searchAssets(term);
|
|
63
|
+
console.table(results);
|
|
64
|
+
await inquirer.prompt([{ type: 'input', name: 'pause', message: 'Press Enter to continue...' }]);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (action === 'swap') {
|
|
68
|
+
const cookedDir = global.COOKED_DIR || DEFAULT_COOKED_DIR;
|
|
69
|
+
console.log('\n--- STEP 1: Target Item (The one you OWN) ---');
|
|
70
|
+
const target = await promptForItemAndUPK('Search for your OWNED item:', cookedDir);
|
|
71
|
+
if (!target) {
|
|
72
|
+
await inquirer.prompt([{ type: 'input', name: 'pause', message: 'Press Enter to continue...' }]);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
console.log('\n--- STEP 2: Source Item (The one you WANT) ---');
|
|
76
|
+
const source = await promptForItemAndUPK('Search for the item you WANT:', cookedDir);
|
|
77
|
+
if (!source) {
|
|
78
|
+
await inquirer.prompt([{ type: 'input', name: 'pause', message: 'Press Enter to continue...' }]);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
console.log(`\n Swapping ${target.item.name} for ${source.item.name}...`);
|
|
82
|
+
const targetUpk = new UPKFile(target.path);
|
|
83
|
+
try {
|
|
84
|
+
const pythonScriptPath = path.resolve(__dirname, '../python/rl_asset_swapper.py');
|
|
85
|
+
backupFile(target.path);
|
|
86
|
+
const cmd = `python "${pythonScriptPath}" --no-gui --target "${target.item.name}" --donor "${source.item.name}" --no-preserve-header-offsets --overwrite --donor-dir "${cookedDir}" --output-dir "${cookedDir}"`;
|
|
87
|
+
console.log(`\n Executing advanced Python offset-shifter...`);
|
|
88
|
+
execSync(cmd, { stdio: 'inherit' });
|
|
89
|
+
console.log(' SUCCESS: Visual Swap complete! please restart your game and view your new item!');
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
console.error(` Failed: ${e.message}`);
|
|
93
|
+
}
|
|
94
|
+
await inquirer.prompt([{ type: 'input', name: 'pause', message: 'Press Enter to continue...' }]);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (action === 'restore') {
|
|
98
|
+
const cookedDir = global.COOKED_DIR || DEFAULT_COOKED_DIR;
|
|
99
|
+
console.log('\n--- Restoring Backups ---');
|
|
100
|
+
try {
|
|
101
|
+
const files = fs.readdirSync(cookedDir);
|
|
102
|
+
const backups = files.filter(f => f.endsWith('.bak'));
|
|
103
|
+
if (backups.length === 0) {
|
|
104
|
+
console.log(' No backups found.');
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
for (const bak of backups) {
|
|
108
|
+
const original = bak.replace('.bak', '');
|
|
109
|
+
const bakPath = path.join(cookedDir, bak);
|
|
110
|
+
const originalPath = path.join(cookedDir, original);
|
|
111
|
+
console.log(` Restoring ${original}...`);
|
|
112
|
+
fs.copyFileSync(bakPath, originalPath);
|
|
113
|
+
fs.unlinkSync(bakPath);
|
|
114
|
+
}
|
|
115
|
+
console.log(' SUCCESS: All backups restored!');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
console.error(` Failed: ${e.message}`);
|
|
120
|
+
}
|
|
121
|
+
await inquirer.prompt([{ type: 'input', name: 'pause', message: 'Press Enter to continue...' }]);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Helper to handle the "Search -> Refine -> Pick" flow for a UPK item
|
|
128
|
+
*/
|
|
129
|
+
async function promptForItemAndUPK(message, cookedDir) {
|
|
130
|
+
const { searchTerm } = await inquirer.prompt([{
|
|
131
|
+
type: 'input',
|
|
132
|
+
name: 'searchTerm',
|
|
133
|
+
message
|
|
134
|
+
}]);
|
|
135
|
+
const matches = searchAssets(searchTerm);
|
|
136
|
+
if (matches.length === 0) {
|
|
137
|
+
console.error(` No items found matching "${searchTerm}".`);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const { selectedItem } = await inquirer.prompt([{
|
|
141
|
+
type: 'rawlist',
|
|
142
|
+
name: 'selectedItem',
|
|
143
|
+
message: 'Select the item:',
|
|
144
|
+
choices: matches.map(m => ({
|
|
145
|
+
name: `${m.name} [ID: ${m.productId}] (${m.packageName})`,
|
|
146
|
+
value: m
|
|
147
|
+
}))
|
|
148
|
+
}]);
|
|
149
|
+
const owned = selectedItem.productId.toString();
|
|
150
|
+
const result = await resolvePackagePath(owned, cookedDir);
|
|
151
|
+
if (!result) {
|
|
152
|
+
console.error(` Could not resolve "${owned}".`);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
let finalUpkPath = '';
|
|
156
|
+
if ('candidates' in result) {
|
|
157
|
+
let currentCandidates = result.candidates;
|
|
158
|
+
while (currentCandidates.length > 20) {
|
|
159
|
+
console.log(` Found ${currentCandidates.length} potential matches.`);
|
|
160
|
+
const { refine } = await inquirer.prompt([{
|
|
161
|
+
type: 'input',
|
|
162
|
+
name: 'refine',
|
|
163
|
+
message: 'Too many files found! Enter a sub-keyword to narrow it down (or press Enter to see all):'
|
|
164
|
+
}]);
|
|
165
|
+
if (!refine)
|
|
166
|
+
break;
|
|
167
|
+
const filtered = currentCandidates.filter(c => c.toLowerCase().includes(refine.toLowerCase()));
|
|
168
|
+
if (filtered.length > 0)
|
|
169
|
+
currentCandidates = filtered;
|
|
170
|
+
}
|
|
171
|
+
const { selected } = await inquirer.prompt([{
|
|
172
|
+
type: 'rawlist',
|
|
173
|
+
name: 'selected',
|
|
174
|
+
message: `Multiple matches found. Select one (Showing top ${Math.min(currentCandidates.length, 50)}):`,
|
|
175
|
+
choices: currentCandidates.slice(0, 50)
|
|
176
|
+
}]);
|
|
177
|
+
finalUpkPath = path.join(cookedDir, selected);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
finalUpkPath = result.path;
|
|
181
|
+
}
|
|
182
|
+
return { path: finalUpkPath, item: selectedItem };
|
|
183
|
+
}
|
|
184
|
+
async function executePatch(upkPath, data, exportIndex) {
|
|
185
|
+
try {
|
|
186
|
+
console.log(` Patching ${upkPath}...`);
|
|
187
|
+
backupFile(upkPath);
|
|
188
|
+
const upk = new UPKFile(upkPath);
|
|
189
|
+
upk.readSummary();
|
|
190
|
+
upk.readExportMap();
|
|
191
|
+
if (exportIndex === -1) {
|
|
192
|
+
exportIndex = upk.exports.reduce((maxIdx, curr, idx, arr) => curr.serialSize > arr[maxIdx].serialSize ? idx : maxIdx, 0);
|
|
193
|
+
console.log(` Auto-selected Export[${exportIndex}] (Size: ${upk.exports[exportIndex].serialSize} bytes)`);
|
|
194
|
+
}
|
|
195
|
+
const newHex = (typeof data === 'string') ? fs.readFileSync(data) : data;
|
|
196
|
+
upk.patchExport(exportIndex, newHex);
|
|
197
|
+
console.log(' SUCCESS: Patch complete!');
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
console.error(' CRITICAL FAILURE:', error.message);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function backupFile(filePath) {
|
|
204
|
+
const backupPath = `${filePath}.bak`;
|
|
205
|
+
if (!fs.existsSync(backupPath)) {
|
|
206
|
+
console.log(` Creating backup: ${path.basename(backupPath)}`);
|
|
207
|
+
fs.copyFileSync(filePath, backupPath);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// --- CLI COMMANDS ---
|
|
211
|
+
program
|
|
212
|
+
.command('list')
|
|
213
|
+
.description('Shows user\'s owned items from the API')
|
|
214
|
+
.action(async () => {
|
|
215
|
+
// Existing list logic...
|
|
216
|
+
console.log('Fetching items... (Placeholder)');
|
|
217
|
+
});
|
|
218
|
+
program
|
|
219
|
+
.command('search')
|
|
220
|
+
.argument('<term>', 'Keyword to search for')
|
|
221
|
+
.action((term) => {
|
|
222
|
+
const results = searchAssets(term);
|
|
223
|
+
console.table(results);
|
|
224
|
+
});
|
|
225
|
+
program
|
|
226
|
+
.command('swap')
|
|
227
|
+
.requiredOption('--owned <itemName>', 'Item name or Product ID')
|
|
228
|
+
.requiredOption('--target <targetHexFile>', 'Hex file path')
|
|
229
|
+
.option('--upk <path>', 'Explicit UPK path')
|
|
230
|
+
.option('--dir <path>', 'Cooked dir', DEFAULT_COOKED_DIR)
|
|
231
|
+
.option('--export <index>', 'Export index')
|
|
232
|
+
.action(async (options) => {
|
|
233
|
+
// Call executePatch with resolved paths...
|
|
234
|
+
let upkPath = options.upk;
|
|
235
|
+
let cookedDir = options.dir;
|
|
236
|
+
if (!fs.existsSync(cookedDir)) {
|
|
237
|
+
console.error(` Error: CookedPCConsole directory not found at: ${cookedDir}`);
|
|
238
|
+
console.log('Use --dir <path> to specify the correct game directory.');
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (!upkPath) {
|
|
242
|
+
const res = await resolvePackagePath(options.owned, cookedDir);
|
|
243
|
+
if (res && 'path' in res)
|
|
244
|
+
upkPath = res.path;
|
|
245
|
+
}
|
|
246
|
+
if (!upkPath) {
|
|
247
|
+
console.error(` Error: Could not resolve item "${options.owned}" in ${cookedDir}.`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
await executePatch(upkPath, options.target, options.export ? parseInt(options.export) : -1);
|
|
251
|
+
});
|
|
252
|
+
// Handle Ctrl+C gracefully
|
|
253
|
+
process.on('SIGINT', () => {
|
|
254
|
+
console.log('\n Exiting RLItemMod. See you next time!');
|
|
255
|
+
process.exit(0);
|
|
256
|
+
});
|
|
257
|
+
async function runSafeWizard() {
|
|
258
|
+
try {
|
|
259
|
+
await runInteractiveWizard();
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
if (e.name === 'ExitPromptError') {
|
|
263
|
+
console.log('\n Goodbye!');
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
console.error('\n An unexpected error occurred:', e.message);
|
|
267
|
+
}
|
|
268
|
+
process.exit(0);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Default to wizard if no command is provided
|
|
272
|
+
if (process.argv.length <= 2) {
|
|
273
|
+
runSafeWizard();
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
program.parse(process.argv);
|
|
277
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { UPKFile } from '../dist/upk.js';
|
|
2
|
+
const upk = new UPKFile('E:\\games\\rocketleague\\TAGame\\CookedPCConsole\\explosion_badaboom_SF.upk');
|
|
3
|
+
try {
|
|
4
|
+
upk.readSummary();
|
|
5
|
+
const r = upk.dataBuffer ? new (require('../dist/upk.js').BinaryReader)(upk.dataBuffer) : null;
|
|
6
|
+
if (r) {
|
|
7
|
+
r.pos = upk.summary.exportOffset;
|
|
8
|
+
for (let i = 0; i < upk.summary.exportCount; i++) {
|
|
9
|
+
const classIndex = r.readI32();
|
|
10
|
+
const superIndex = r.readI32();
|
|
11
|
+
const outerIndex = r.readI32();
|
|
12
|
+
const objectNameIndex = r.readI32();
|
|
13
|
+
const objectNameNumber = r.readI32();
|
|
14
|
+
const archetypeIndex = r.readI32();
|
|
15
|
+
const objectFlags = r.readU64();
|
|
16
|
+
const serialSize = r.readI32();
|
|
17
|
+
const serialOffset = r.readI32();
|
|
18
|
+
const exportFlags = r.readI32();
|
|
19
|
+
const netObjCount = r.readI32();
|
|
20
|
+
console.log(`Export ${i}: NetObjCount = ${netObjCount}, pos before skip: ${r.pos}`);
|
|
21
|
+
if (netObjCount < 0 || netObjCount > 1000) {
|
|
22
|
+
console.log(`Abnormal netObjCount: ${netObjCount} at export ${i}`);
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
r.skip(netObjCount * 4);
|
|
26
|
+
r.skip(16); // PackageGuid
|
|
27
|
+
r.readI32(); // PackageFlags
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
console.error(e);
|
|
33
|
+
}
|
package/dist/swapper.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Implements the "Fixed Rename" logic from RLUPKTools.
|
|
3
|
+
* This renames a name table entry IN-PLACE by padding with nulls.
|
|
4
|
+
* This ensures the header size and all subsequent offsets remain identical.
|
|
5
|
+
*/
|
|
6
|
+
export class UPKSwapper {
|
|
7
|
+
upk;
|
|
8
|
+
constructor(upk) {
|
|
9
|
+
this.upk = upk;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Renames an existing name entry to a new name.
|
|
13
|
+
* The new name must be shorter than or equal to the original name's space.
|
|
14
|
+
*/
|
|
15
|
+
fixedRename(oldName, newName) {
|
|
16
|
+
if (!this.upk.summary || !this.upk.getData())
|
|
17
|
+
return false;
|
|
18
|
+
const data = this.upk.getData();
|
|
19
|
+
const nameOffset = this.upk.summary.nameOffset;
|
|
20
|
+
let pos = nameOffset;
|
|
21
|
+
for (let i = 0; i < this.upk.summary.nameCount; i++) {
|
|
22
|
+
const entryStart = pos;
|
|
23
|
+
const length = data.readInt32LE(pos);
|
|
24
|
+
pos += 4;
|
|
25
|
+
let currentName;
|
|
26
|
+
let totalBytes;
|
|
27
|
+
if (length > 0) {
|
|
28
|
+
// ANSI
|
|
29
|
+
currentName = data.toString('utf8', pos, pos + length - 1);
|
|
30
|
+
totalBytes = length;
|
|
31
|
+
pos += length;
|
|
32
|
+
}
|
|
33
|
+
else if (length < 0) {
|
|
34
|
+
// UTF-16
|
|
35
|
+
const absLen = Math.abs(length) * 2;
|
|
36
|
+
currentName = data.toString('utf16le', pos, pos + absLen - 2);
|
|
37
|
+
totalBytes = absLen;
|
|
38
|
+
pos += absLen;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
pos += 8; // Skip flags
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
// Skip ObjectFlags (8 bytes)
|
|
45
|
+
pos += 8;
|
|
46
|
+
if (currentName === oldName) {
|
|
47
|
+
console.log(` Found name entry "${oldName}" at index ${i}. Renaming to "${newName}"...`);
|
|
48
|
+
// Prepare the new string
|
|
49
|
+
const newBuf = Buffer.alloc(totalBytes, 0);
|
|
50
|
+
if (length > 0) {
|
|
51
|
+
newBuf.write(newName, 'utf8');
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
newBuf.write(newName, 'utf16le');
|
|
55
|
+
}
|
|
56
|
+
// Write it back
|
|
57
|
+
newBuf.copy(data, entryStart + 4);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Swaps all references of donorName to targetName in the package.
|
|
65
|
+
* This is useful for "tricking" the engine into loading one asset instead of another.
|
|
66
|
+
*/
|
|
67
|
+
swapAssetReferences(targetAssetName, donorAssetName) {
|
|
68
|
+
console.log(` Swapping references: ${targetAssetName} <-> ${donorAssetName}`);
|
|
69
|
+
// We actually want to rename the TARGET name to something else,
|
|
70
|
+
// and the DONOR name (which we might have imported) to the TARGET name.
|
|
71
|
+
// But for a simple swap where we just patch the file, we can just rename
|
|
72
|
+
// the internal package name.
|
|
73
|
+
const success = this.fixedRename(targetAssetName, donorAssetName);
|
|
74
|
+
if (success) {
|
|
75
|
+
console.log(` Successfully swapped ${targetAssetName} for ${donorAssetName} references.`);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
console.warn(` Failed to find name entry for ${targetAssetName}.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|