grf-file-extractor 1.0.35
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 +33 -0
- package/dist/index.js +32 -0
- package/dist/index.mjs +7 -0
- package/index.js +192 -0
- package/lib/Loaders/GameFile.js +368 -0
- package/lib/Loaders/GameFileDecrypt.js +346 -0
- package/lib/Utils/BinaryReader.js +334 -0
- package/lib/Utils/BinaryWriter.js +326 -0
- package/lib/Utils/Inflate.js +382 -0
- package/lib/Utils/Struct.js +76 -0
- package/lib/Vendors/text-encoding.js +2494 -0
- package/lib/cps.js +72 -0
- package/lib/extractor.js +258 -0
- package/lib/grf.js +41 -0
- package/package.json +24 -0
- package/profile.js +17 -0
- package/src/index.ts +3 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +10 -0
package/lib/cps.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// CPS (cps.dll / Fury.dll) decryption support
|
|
2
|
+
// Extracts the RC4 S-box from a cps.dll and provides decryption for GRF entries
|
|
3
|
+
|
|
4
|
+
var fs = require('fs')
|
|
5
|
+
|
|
6
|
+
// Load encryption key from a CPS DLL or a raw S-box hex file
|
|
7
|
+
function loadKey(path) {
|
|
8
|
+
var data = fs.readFileSync(path)
|
|
9
|
+
|
|
10
|
+
// If the file is small and looks like a hex dump (text file), parse as raw S-box
|
|
11
|
+
var text = data.toString('utf8').trim()
|
|
12
|
+
var hexMatch = text.match(/^(?:SBOX:)?([0-9a-fA-F]{512})$/)
|
|
13
|
+
if (hexMatch) {
|
|
14
|
+
return new Uint8Array(Buffer.from(hexMatch[1], 'hex'))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Otherwise treat as a DLL: search for UTF-16LE L"NUMBER" marker
|
|
18
|
+
var pattern = [0x4E, 0x00, 0x55, 0x00, 0x4D, 0x00, 0x42, 0x00, 0x45, 0x00, 0x52, 0x00]
|
|
19
|
+
var offset = -1
|
|
20
|
+
|
|
21
|
+
for (var i = 0; i < data.length - pattern.length; i++) {
|
|
22
|
+
var match = true
|
|
23
|
+
for (var j = 0; j < pattern.length; j++) {
|
|
24
|
+
if (data[i + j] !== pattern[j]) { match = false; break }
|
|
25
|
+
}
|
|
26
|
+
if (match) { offset = i; break }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (offset === -1) {
|
|
30
|
+
throw new Error('CPS: Could not find encryption key in ' + path)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Key is after L"NUMBER\0" (14 bytes) + 4 byte header
|
|
34
|
+
var keyStart = offset + 14 + 4
|
|
35
|
+
var key = data.slice(keyStart, keyStart + 260)
|
|
36
|
+
|
|
37
|
+
if (key.length < 260) {
|
|
38
|
+
throw new Error('CPS: Not enough key data in ' + path)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Build S-box using the DLL's key scheduling algorithm
|
|
42
|
+
var a = (key[3] << 24) | (key[2] << 16) | (key[1] << 8) | key[0]
|
|
43
|
+
var sbox = new Uint8Array(256)
|
|
44
|
+
for (var i = 0; i < 256; i++) {
|
|
45
|
+
sbox[i] = ((a & 0xFF) ^ key[i + 4]) & 0xFF
|
|
46
|
+
a = Math.imul(a, 0x2F)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return sbox
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// RC4 decrypt a buffer in-place using the S-box
|
|
53
|
+
// initialI is typically the entry's real_size (decompressed size)
|
|
54
|
+
function decrypt(buffer, sbox, initialI) {
|
|
55
|
+
var S = new Uint8Array(sbox) // working copy
|
|
56
|
+
var i = initialI & 0xFF
|
|
57
|
+
var j = 0
|
|
58
|
+
|
|
59
|
+
for (var k = 0; k < buffer.length; k++) {
|
|
60
|
+
i = (i + 1) & 0xFF
|
|
61
|
+
j = (j + S[i]) & 0xFF
|
|
62
|
+
var tmp = S[i]; S[i] = S[j]; S[j] = tmp
|
|
63
|
+
buffer[k] ^= S[(S[i] + S[j]) & 0xFF]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return buffer
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = {
|
|
70
|
+
loadKey: loadKey,
|
|
71
|
+
decrypt: decrypt
|
|
72
|
+
}
|
package/lib/extractor.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Grf full extraction
|
|
2
|
+
var GRF = require('./Loaders/GameFile.js')
|
|
3
|
+
var Inflate = require('./Utils/Inflate.js')
|
|
4
|
+
var GameFileDecrypt = require('./Loaders/GameFileDecrypt.js')
|
|
5
|
+
var EventEmitter = require('events').EventEmitter
|
|
6
|
+
var Cluster = require('cluster')
|
|
7
|
+
var Os = require('os')
|
|
8
|
+
var Fs = require('fs')
|
|
9
|
+
var Util = require('util')
|
|
10
|
+
var Zlib = require('zlib')
|
|
11
|
+
var Cps = require('./cps.js')
|
|
12
|
+
var fs = require('fs')
|
|
13
|
+
|
|
14
|
+
// Constructor
|
|
15
|
+
var Extractor = function Constructor(grf, options) {
|
|
16
|
+
this._progress = 0
|
|
17
|
+
this.grf = grf
|
|
18
|
+
this.concurrency = options.concurrency || 100
|
|
19
|
+
this.output_dir = options.output || ''
|
|
20
|
+
this.cpsSbox = options.cpsSbox || null
|
|
21
|
+
this.mkdirPaths = [] // List of paths already created
|
|
22
|
+
|
|
23
|
+
// Start at next tick, give time for event listener to bind
|
|
24
|
+
process.nextTick(extractStart.bind(this))
|
|
25
|
+
|
|
26
|
+
return this
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Event emmiter
|
|
30
|
+
Util.inherits(Extractor, EventEmitter);
|
|
31
|
+
|
|
32
|
+
// Start the extraction
|
|
33
|
+
function extractStart() {
|
|
34
|
+
var numCpus = Os.cpus().length
|
|
35
|
+
var cluster = Cluster
|
|
36
|
+
var grf = this.grf
|
|
37
|
+
var concurrency = this.concurrency
|
|
38
|
+
var extract_folder = (this.output_dir ? this.output_dir.replace(/\/?$/, '/') : '') // Add trailling slash to folder, if specified
|
|
39
|
+
var self = this
|
|
40
|
+
|
|
41
|
+
// The master only spawn workers to work on the GRF
|
|
42
|
+
// And receives progress updates
|
|
43
|
+
if(cluster.isMaster) {
|
|
44
|
+
// Fork some workers
|
|
45
|
+
for(var i = 0; i < numCpus; i++) {
|
|
46
|
+
cluster.fork()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Workers online
|
|
50
|
+
var workers = []
|
|
51
|
+
|
|
52
|
+
// Separate the entries table into the number of workers
|
|
53
|
+
var entries_slices = [{
|
|
54
|
+
start: 0,
|
|
55
|
+
end: 0,
|
|
56
|
+
}]
|
|
57
|
+
var last = 0
|
|
58
|
+
var chunk_size = Math.floor(grf.entries.length / numCpus)
|
|
59
|
+
|
|
60
|
+
for(var i = 0; i < numCpus; i++) {
|
|
61
|
+
next = last + chunk_size
|
|
62
|
+
|
|
63
|
+
// If last iteration, set this slice to the rest of the entries array
|
|
64
|
+
if(i == numCpus - 1) {
|
|
65
|
+
next = grf.entries.length
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
entries_slices[i] = {
|
|
69
|
+
start: last,
|
|
70
|
+
end: next,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
last = next
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A worker is online, send it work :)
|
|
77
|
+
cluster.on('online', function(worker) {
|
|
78
|
+
var worker_id = workers.length
|
|
79
|
+
|
|
80
|
+
// Send a slice of the entries table to this worker to process
|
|
81
|
+
var slice = entries_slices[ worker_id ]
|
|
82
|
+
worker.send(slice)
|
|
83
|
+
|
|
84
|
+
grf.debug("Sending to worker #%d slice %d to %d.", worker_id, slice.start, slice.end)
|
|
85
|
+
|
|
86
|
+
// Worker is reporting its progress
|
|
87
|
+
worker.on('message', function(msg) {
|
|
88
|
+
if(msg.cmd == 'progress') {
|
|
89
|
+
self._progress++
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// Register worker
|
|
94
|
+
workers.push(worker)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
self.emit('start')
|
|
98
|
+
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Else is, worker
|
|
103
|
+
// Wait for the master to send us the go signal
|
|
104
|
+
process.on('message', function(msg) {
|
|
105
|
+
// Receive a slice to work on
|
|
106
|
+
var entries = grf.entries.slice(msg.start, msg.end)
|
|
107
|
+
var next = 0
|
|
108
|
+
|
|
109
|
+
grf.debug("Worker received slice from %d to %d to work on.", msg.start, msg.end)
|
|
110
|
+
grf.debug('Worker extracting', entries.length, 'files.')
|
|
111
|
+
|
|
112
|
+
// Launch # concurrent extractors
|
|
113
|
+
for(var i = 0; i < concurrency; i++){
|
|
114
|
+
extract_next()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function extract_next() {
|
|
118
|
+
if(next >= entries.length) {
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
var i = next++
|
|
123
|
+
var entry = entries[i]
|
|
124
|
+
var buffer = Buffer.alloc(entry.length_aligned);
|
|
125
|
+
var file = entry.filename.replace( /\\/g, '/') // Replace windows back slash \ to unix forward slash
|
|
126
|
+
var fullpath = extract_folder + file
|
|
127
|
+
|
|
128
|
+
if(entry.type & GRF.FILELIST_TYPE_FILE) {
|
|
129
|
+
// Read entry from .grf file
|
|
130
|
+
Fs.read(grf.file.fd, buffer, 0, entry.length_aligned, entry.offset + GRF.struct_header.size, function(err, bytesRead, buffer){
|
|
131
|
+
if(err) {
|
|
132
|
+
console.error("Error reading %s: %s", file, err.message)
|
|
133
|
+
finish()
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Write entry to file
|
|
138
|
+
self.writeEntry(entry, fullpath, buffer, finish)
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
// Directory?
|
|
142
|
+
else {
|
|
143
|
+
self.makeDir(fullpath, finish)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Extract next
|
|
147
|
+
function finish() {
|
|
148
|
+
// Notify master of progress
|
|
149
|
+
process.send({cmd: 'progress'})
|
|
150
|
+
extract_next()
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// Avoid workers crashing because of excpections
|
|
156
|
+
process.on('uncaughtException', function(err) {
|
|
157
|
+
console.error(err)
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Progress
|
|
162
|
+
Extractor.prototype.progress = function progress() {
|
|
163
|
+
return this._progress
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Make a directory
|
|
167
|
+
Extractor.prototype.makeDir = function mkDir(path, callback) {
|
|
168
|
+
// Create directoy
|
|
169
|
+
if(typeof this.mkdirPaths[path] === 'undefined') {
|
|
170
|
+
var self = this
|
|
171
|
+
|
|
172
|
+
fs.mkdir(path, { recursive: true }, function (err) {
|
|
173
|
+
if(err) {
|
|
174
|
+
console.error("Error creating directory %s: %s", path, err.message)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Add to created paths hashtable
|
|
178
|
+
self.mkdirPaths[path] = true
|
|
179
|
+
|
|
180
|
+
callback()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Directoy already exixsts
|
|
187
|
+
callback()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Write entry buffer to a file
|
|
191
|
+
Extractor.prototype.writeEntry = function writeEntry(entry, path, buffer, callback) {
|
|
192
|
+
// Decode buffer if needed
|
|
193
|
+
if (entry.type & GRF.FILELIST_TYPE_ENCRYPT_MIXED) {
|
|
194
|
+
var data = new Uint8Array(buffer)
|
|
195
|
+
GameFileDecrypt.decodeFull( data, entry.length_aligned, entry.pack_size)
|
|
196
|
+
buffer = Buffer.from(data)
|
|
197
|
+
}
|
|
198
|
+
else if (entry.type & GRF.FILELIST_TYPE_ENCRYPT_HEADER) {
|
|
199
|
+
var data = new Uint8Array(buffer)
|
|
200
|
+
GameFileDecrypt.decodeHeader( data, entry.length_aligned )
|
|
201
|
+
buffer = Buffer.from(data)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
var self = this
|
|
205
|
+
|
|
206
|
+
function writeInflated(buf) {
|
|
207
|
+
var folder = path.substring(0, path.lastIndexOf("/"))
|
|
208
|
+
self.makeDir(folder, function() {
|
|
209
|
+
self.writeFile(path, buf, callback)
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (self.cpsSbox) {
|
|
214
|
+
// CPS encrypted GRF: RC4 decrypt a copy, then inflate
|
|
215
|
+
var decrypted = Buffer.from(buffer)
|
|
216
|
+
Cps.decrypt(decrypted, self.cpsSbox, entry.real_size)
|
|
217
|
+
|
|
218
|
+
Zlib.inflate(decrypted, function(err, buf) {
|
|
219
|
+
if(!err) {
|
|
220
|
+
writeInflated(buf)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Fallback: try without CPS decryption (some files may be unencrypted)
|
|
225
|
+
Zlib.inflate(buffer, function(err2, buf2) {
|
|
226
|
+
if(!err2) {
|
|
227
|
+
writeInflated(buf2)
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
console.error("Error decompressing %s: %s", path, err.message2)
|
|
231
|
+
callback()
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
} else {
|
|
235
|
+
Zlib.inflate(buffer, function(err, buf) {
|
|
236
|
+
if(err) {
|
|
237
|
+
console.error("Error decompressing %s: %s", path, err.message)
|
|
238
|
+
callback()
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
writeInflated(buf)
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Write buffer to file
|
|
247
|
+
Extractor.prototype.writeFile = function writeFile(path, buffer, callback) {
|
|
248
|
+
Fs.writeFile(path, buffer, function (err) {
|
|
249
|
+
if (err) {
|
|
250
|
+
console.error("Error writing %s: %s", path, err.message)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
callback()
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Exports
|
|
258
|
+
module.exports = Extractor
|
package/lib/grf.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// This librarie extends default roBrowser software
|
|
2
|
+
// It extends roBrowser default GRF loader
|
|
3
|
+
// Giving it a set a new set of API's and replacing some performance critical functions
|
|
4
|
+
var Grf = module.exports = require('./Loaders/GameFile.js')
|
|
5
|
+
var Extractor = require('./extractor')
|
|
6
|
+
|
|
7
|
+
// Should we print debug messages?
|
|
8
|
+
Grf.prototype.printDebug = false
|
|
9
|
+
|
|
10
|
+
// Search for a pattern on the .grf file
|
|
11
|
+
// @param regexp: A regular expression instance
|
|
12
|
+
Grf.prototype.searchPattern = function search(regexp, callback) {
|
|
13
|
+
var matches = []
|
|
14
|
+
|
|
15
|
+
for(var i in this.entries) {
|
|
16
|
+
var entry = this.entries[i]
|
|
17
|
+
var filename = entry.filename
|
|
18
|
+
|
|
19
|
+
if(regexp.test(filename)) {
|
|
20
|
+
matches.push(entry)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
callback(matches)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Extract all files from the .grf to a folder
|
|
28
|
+
// @param options {
|
|
29
|
+
// output: output directory
|
|
30
|
+
// concurrency: concurrency rate
|
|
31
|
+
// }
|
|
32
|
+
Grf.prototype.extract = function(options) {
|
|
33
|
+
return new Extractor(this, options)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Print a debug message if verbosity is set
|
|
37
|
+
Grf.prototype.debug = function debug() {
|
|
38
|
+
if(this.printDebug) {
|
|
39
|
+
console.log.apply(this, arguments)
|
|
40
|
+
}
|
|
41
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "grf-file-extractor",
|
|
3
|
+
"version": "1.0.35",
|
|
4
|
+
"description": "A command line GRF extractor, used to manage Ragnarok's .grf files.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"grf-file-extractor": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "npm test",
|
|
11
|
+
"build": "tsup src/index.ts --format cjs,esm --dts"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"ragnarok",
|
|
15
|
+
"grf",
|
|
16
|
+
"robrowser",
|
|
17
|
+
"extractor"
|
|
18
|
+
],
|
|
19
|
+
"author": "ayenpanda",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=10.12.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/profile.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Profiler
|
|
2
|
+
var cluster = require('cluster')
|
|
3
|
+
var agent = require('webkit-devtools-agent');
|
|
4
|
+
|
|
5
|
+
if(cluster.isMaster) {
|
|
6
|
+
agent.start()
|
|
7
|
+
}
|
|
8
|
+
else {
|
|
9
|
+
var id = cluster.worker.id
|
|
10
|
+
agent.start({
|
|
11
|
+
port: 9999 + id,
|
|
12
|
+
ipc_port: 3333 + id,
|
|
13
|
+
})
|
|
14
|
+
console.log("Worker #%d inspector on port %d.", id, 9999 + id)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
require('./index.js')
|
package/src/index.ts
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"strict": true,
|
|
4
|
+
"noImplicitAny": true,
|
|
5
|
+
"module": "commonjs",
|
|
6
|
+
"target": "ES2022",
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"strictNullChecks": true,
|
|
11
|
+
"moduleResolution": "node",
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"noEmit": true,
|
|
14
|
+
"ignoreDeprecations": "6.0"
|
|
15
|
+
},
|
|
16
|
+
"include": ["**/*.ts"],
|
|
17
|
+
"exclude": ["node_modules", "dist"]
|
|
18
|
+
}
|