Last active
March 24, 2021 23:01
-
-
Save Ne3tCode/6fd0080e28b9c6dbfb2d83f12115071b to your computer and use it in GitHub Desktop.
protobufdumper.js v2.2
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const path = require('path'); | |
const fs = require('fs'); | |
const CONFIG = { | |
ProtoFilterEnabled: false, | |
KnownProtoPath: 'Protobufs', | |
InputPath: './files/', | |
OutputPath: './dump/', | |
CommonProtoFileName: 'common.proto', | |
ServiceProtoFileNameTemplate: 'service_%svcname%.proto' | |
}; | |
for (let i = 2; i < process.argv.length; ++i) { | |
if (process.argv[i] === '--filter-known-protos') { | |
CONFIG.ProtoFilterEnabled = true; | |
} else if (process.argv[i].startsWith('--known-protos-dir=')) { | |
CONFIG.KnownProtoPath = process.argv[i].substring(19); | |
} else if (process.argv[i].startsWith('--input-dir=')) { | |
CONFIG.InputPath = process.argv[i].substring(12); | |
} else if (process.argv[i].startsWith('--output-dir=')) { | |
CONFIG.OutputPath = process.argv[i].substring(13); | |
} else { | |
console.log('Unknown argument:', process.argv[i]); | |
} | |
} | |
CONFIG.OutputPath = path.resolve(CONFIG.OutputPath); | |
if (!fs.existsSync(CONFIG.OutputPath)) { | |
fs.mkdirSync(CONFIG.OutputPath, { recursive: true, mode: 0o755 }); | |
} | |
if (!fs.statSync(CONFIG.OutputPath).isDirectory()) { | |
throw `"${CONFIG.OutputPath}" is not a directory`; | |
} | |
Promise.all([ | |
getKnownProtobufMessages(path.resolve(CONFIG.KnownProtoPath)), | |
getListOfJSFilesToParse(path.resolve(CONFIG.InputPath)) | |
]) | |
.then(([{knownMessages, knownServices}, files]) => Start(knownMessages, knownServices, files)) | |
.then(() => console.log('OK')) | |
.catch(console.error); | |
function Start(knownMessages, knownServices, files) { | |
global.window = {}; | |
files.forEach((file) => { | |
console.log(file); | |
try { | |
require(file); | |
} catch(e) { | |
console.error(`Unable to execute "${path.basename(file)}", skipping.`); | |
console.error(e); | |
} | |
}); | |
const filteredServices = []; | |
let bHasUnknownRequest = false; | |
let allProtos; | |
window.webpackJsonp.forEach((file) => { | |
let fileProtos; | |
try { | |
fileProtos = handleFile(file[1]); | |
} catch(e) { | |
console.error(e); | |
return; | |
} | |
if (!allProtos) { | |
allProtos = fileProtos; | |
} else { | |
mergeProtos(allProtos, fileProtos); | |
} | |
}); | |
const filteredProtos = allProtos.messages.filter((proto) => !knownMessages.has(proto.name)); | |
filteredProtos.forEach((proto) => { | |
proto.fields.forEach((field) => { | |
if (field.type[0] === ".") { | |
if (!proto.dependencies) | |
proto.dependencies = new Set(); | |
proto.dependencies.add(field.type.substr(1)); // message name | |
} | |
}); | |
}); | |
allProtos.services.forEach((methods, service) => { | |
if (CONFIG.ProtoFilterEnabled && knownServices.has(service)) { | |
methods = methods.filter((method) => { | |
return !knownServices.get(service).includes(method.name); | |
}); | |
} | |
if (methods.length > 0) { | |
filteredServices.push({ | |
service, | |
methods | |
}); | |
methods.forEach((method) => { | |
// message not found | |
if (!method.request) { | |
method.request = 'NotImplemented'; | |
bHasUnknownRequest = true; | |
} | |
if (!method.response) { | |
method.response = 'NotImplemented'; | |
bHasUnknownRequest = true; | |
} | |
}); | |
} | |
}); | |
if (bHasUnknownRequest) { | |
filteredProtos.push({ "name": "NotImplemented", "fields": [] }); | |
knownMessages.set('NotImplemented', CONFIG.CommonProtoFileName); | |
} | |
Dump(CONFIG.OutputPath, filteredProtos, filteredServices, knownMessages); | |
} | |
function mergeProtos(toProtos, fromProtos) { | |
fromProtos.messages.forEach((_message) => { | |
// do we care about fields? some js files may be based on old proto | |
let proto = toProtos.messages.find((message) => message.name === _message.name); | |
if (proto) { | |
// fix cross-file type dependencies | |
proto.fields.forEach((field) => { | |
if (field.fixme) { | |
let _field = _message.fields.find((_field) => _field.name === field.name); | |
if (_field && !_field.fixme) { | |
field.type = _field.type; | |
field.fixme = false; | |
} | |
} | |
}); | |
} else { | |
toProtos.messages.push(_message); | |
} | |
}); | |
// merge services | |
fromProtos.services.forEach((_methods, serviceName) => { | |
if (toProtos.services.has(serviceName)) { | |
let methods = toProtos.services.get(serviceName); | |
_methods.forEach((_method) => { | |
let method = methods.find((method) => method.name === _method.name); | |
if (method && !method.request && _method.request) { | |
// update request name | |
method.request = _method.request; | |
} else if (!method) { | |
// this method doesn't exist in other file, protos are out of sync(?) | |
methods.push(_method); | |
} | |
}); | |
} else { | |
toProtos.services.set(serviceName, _methods); | |
} | |
}); | |
} | |
function extractDependencies(allProtos, protoName, knownMessages) { | |
let protos = []; | |
let imports = new Set(); | |
const file = knownMessages.get(protoName); | |
if (file) { | |
imports.add(file); | |
} else { | |
let proto = allProtos.find((proto) => proto.name === protoName); | |
if (!proto) { | |
console.error('Could not find dependency:', protoName); | |
} else { | |
protos.push(proto); | |
if (proto.dependencies) { | |
proto.dependencies.forEach((depName) => { | |
if (protoName === depName) return; // recursive msg (e.g. CEconItem_Description) | |
let deps = extractDependencies(allProtos, depName, knownMessages); | |
protos.push(...deps.protos); | |
deps.imports.forEach((file) => imports.add(file)); | |
}); | |
} | |
} | |
} | |
return {protos, imports}; | |
} | |
function getListOfJSFilesToParse(dirName) { | |
return fs.promises.readdir(dirName).then((files) => | |
files.filter((fileName) => fileName.endsWith('js')) | |
.map((fileName) => path.join(dirName, fileName)) | |
); | |
} | |
function getKnownProtobufMessages(dirName) { | |
const msgRegex = /([ \t]*)message (\w+) \{/g; | |
const svcRegex = /service (\w+) \{|rpc (\w+) \(/g; | |
const knownMessages = new Map(); | |
const knownServices = new Map(); | |
let MsgAndLevel = []; | |
if (!CONFIG.ProtoFilterEnabled) { | |
return Promise.resolve({knownMessages, knownServices}); | |
} | |
return fs.promises.readdir(dirName).then((files) => { | |
files.filter((file) => file.endsWith(".proto")).forEach((fileName) => { | |
let file = fs.readFileSync(path.join(dirName, fileName)).toString(); | |
let matches; | |
while (matches = msgRegex.exec(file)) { | |
let currLevel = matches[1].length; | |
let msgName = matches[2]; | |
if (MsgAndLevel[0] && MsgAndLevel[0].level >= currLevel) { | |
let prevMsg; | |
do { | |
prevMsg = MsgAndLevel.shift(); | |
} while (prevMsg && prevMsg.level > currLevel); | |
} | |
if (MsgAndLevel[0]) { | |
msgName = MsgAndLevel[0].name + '_' + msgName; | |
} | |
MsgAndLevel.unshift({ "name": msgName, "level": currLevel }); | |
knownMessages.set(msgName, fileName); | |
} | |
let methods; | |
while (matches = svcRegex.exec(file)) { | |
if (matches[1]) { | |
// service name | |
knownServices.set(matches[1], methods = []); | |
} else { | |
// method name | |
methods.push(matches[2]); | |
} | |
} | |
}); | |
return {knownMessages, knownServices}; | |
}); | |
} | |
function handleFile(file) { | |
const moduleProtosMap = new Map(); | |
let protoShortNamesToLongNames = {}; | |
let protoShortNamesAliases = {}; | |
let messages = []; | |
let svcRex = /(?:SendNotification\()?"(\w+)\.(\w+)#1",(?:request:([_a-zA-Z\$]{1,2})|\w[\),]([_a-zA-Z\$]{1,2})?)/g; | |
const services = new Map(); | |
let modules = new Map(); | |
for (let moduleName in file) { | |
modules.set(moduleName, file[moduleName].toString()); | |
} | |
// 1-st pass | |
modules.forEach((module, currentModuleName) => { | |
let protoConstructorMatch = module.match(/([_a-zA-Z\$]{1,2})(?==\(?(?:[_a-zA-Z\$]\("[\w\+\/]{4}"\),)*(?:[_a-zA-Z\$]{1,2}\.Message,?)+\)?)/g); | |
if (!protoConstructorMatch) | |
return; | |
let currentModuleProtos = []; | |
protoShortNamesToLongNames[currentModuleName] = {}; | |
protoShortNamesAliases[currentModuleName] = {}; | |
(module.match(/[a-z]\.[a-z]\([a-z],"([a-zA-Z]+)",\(?function\(\){return ([_a-zA-Z]+)}\)/g) || []).forEach((alias) => { | |
let [/*skip*/, aliasName, protoShortName] = alias.match(/[a-z]\.[a-z]\([a-z],"([a-zA-Z]+)",\(?function\(\){return ([_a-zA-Z]+)}\)/); | |
protoShortNamesAliases[currentModuleName][aliasName] = protoShortName; | |
}); | |
protoConstructorMatch = protoConstructorMatch.map(x => '\\(' + x + '\\)').join('|'); | |
// Each message is immediately followed by (x) or (xx) so split on that | |
module.split(new RegExp('(' + protoConstructorMatch + ')[,;\\}]')).forEach((part) => { | |
let match = part.match(/([_a-zA-Z\$]{1,2})=function\([a-zA-Z\$]{1,2}\){function [a-zA-Z\$]\([a-zA-Z\$]\){.{1,120}\.initialize.*}$/); | |
if (!match) { | |
return; | |
} | |
// Extract the minified variable name | |
let minVarName = match[1]; | |
let func = match[0] | |
.replace(/^[_a-zA-Z\$]{1,2}=/, '') // var name | |
.replace(/(Object\()?[_a-zA-Z\$]{1,2}\.[a-zA-Z\$]\)?\([a-zA-Z\$],[a-zA-Z\$]\),/g, '') // junk | |
.replace(/(?<=c:)([_a-zA-Z\$]{1,2}(\.[_a-zA-Z\$]{1,2})?)(?=,)?/g, '"$1"') // constructor | |
.replace(/(?<=br:|bw:)[^,\}]+?(?:read|write)(\w+)(?=,)?/g, '"$1"'); // reader/writer | |
eval('func=(' + func + ')'); | |
let proto = decodeProtobuf(func(), minVarName); | |
currentModuleProtos.push(proto); | |
messages.push(proto); | |
protoShortNamesToLongNames[currentModuleName][minVarName] = proto.name; | |
}); | |
moduleProtosMap.set(currentModuleName, currentModuleProtos); | |
let matches; | |
while (matches = svcRex.exec(module)) { | |
let svcName = matches[1], | |
methodName = matches[2], | |
msgRequestName = null, msgResponseName = null; | |
if (matches[3]) { // S->C notification | |
msgRequestName = protoShortNamesToLongNames[currentModuleName][matches[3]]; | |
} else if (matches[4]) { // response | |
msgResponseName = protoShortNamesToLongNames[currentModuleName][matches[4]]; | |
} else { // C->S notification | |
// try to fix it below | |
} | |
if (!msgRequestName) { | |
let proto = currentModuleProtos.find((proto) => | |
(proto.name === "C" + svcName + "_" + methodName + (matches[4] ? "_Request" : "_Notification")) | |
|| !matches[4] && (proto.name === "C" + svcName + "_" + methodName.replace(/^Notify/, "") + "_Notification") | |
); | |
if (!proto && msgResponseName) { | |
proto = currentModuleProtos.find((proto) => proto.name === msgResponseName.replace(/_Response$/, "_Request")); | |
} | |
if (proto) { | |
msgRequestName = proto.name; | |
} | |
} | |
if (!msgResponseName && !matches[4]) { | |
msgResponseName = "NoResponse"; | |
} | |
if (!services.has(svcName)) { | |
services.set(svcName, []); | |
} | |
services.get(svcName).push({ | |
"name": methodName, | |
"request": msgRequestName, | |
"response": msgResponseName | |
}); | |
} | |
}); | |
// 2-nd pass | |
modules.forEach((module, currentModuleName) => { | |
if (!moduleProtosMap.has(currentModuleName)) | |
return; | |
moduleProtosMap.get(currentModuleName).forEach((proto) => { | |
proto.fields.forEach((field) => { | |
if (protoShortNamesToLongNames[currentModuleName][field.type]) { | |
field.type = '.' + protoShortNamesToLongNames[currentModuleName][field.type]; | |
} else if (field.type.includes('.')) { | |
let [moduleVarName, typeShortNameAlias] = field.type.split('.'); | |
let match = module.match(new RegExp(moduleVarName + '=[a-z]\\("([\\w\\+\\/]{4})"\\)')); | |
if (match && protoShortNamesToLongNames[match[1]] && protoShortNamesAliases[match[1]]) { | |
field.type = '.' + protoShortNamesToLongNames[match[1]][ | |
protoShortNamesAliases[match[1]][typeShortNameAlias] | |
]; | |
} else { | |
// external dependency? will be resolved on merge time | |
field.type = 'UNKNOWN'; | |
field.fixme = true; | |
} | |
} | |
}); | |
}); | |
}); | |
return {messages, services}; | |
} | |
function decodeProtobuf(proto, minVarName) { | |
let name = proto.prototype.getClassName(); | |
let fields = []; | |
let protoFields = proto.M ? proto.M().fields : {}; | |
for (let fieldName in protoFields) { | |
let field = protoFields[fieldName]; | |
let fieldDesc = { | |
id: field.n, | |
flag: field.r ? 'repeated' : 'optional', | |
name: fieldName, | |
type: '?' | |
}; | |
if (field.c) { | |
// It's a nested message of some sort | |
if (field.c === proto.name) { // constructor name, special case for recursive messages | |
fieldDesc.type = minVarName; | |
} else { | |
fieldDesc.type = field.c; | |
} | |
} else { | |
fieldDesc.type = field.br.toLowerCase().replace('64string', '64'); | |
if (fieldDesc.type === 'enum') { | |
// ToDo: RE enums | |
fieldDesc.type = 'int32'; | |
fieldDesc.description = 'enum'; | |
} | |
} | |
// default? | |
if (field.hasOwnProperty('d')) { | |
if (fieldDesc.type === 'string') { | |
fieldDesc.default = JSON.stringify(field.d); | |
} else { | |
fieldDesc.default = field.d; | |
} | |
} | |
fields.push(fieldDesc); | |
} | |
return {name, fields}; | |
} | |
function Dump(outputFullPath, filteredProtos, filteredServices, knownMessages) { | |
let AllToDump = new Set(); | |
// dump services | |
while (filteredServices.length) { | |
let servicesToDump = []; | |
let index = -1; | |
let nonClientName; | |
let service = filteredServices.shift(); | |
servicesToDump.push(service); | |
// dump "Svcname" + "SvcnameClient" + "SvcnameNotifications" together | |
if (service.service.endsWith('Client')) { | |
nonClientName = service.service.substr(0, service.service.length - 6); | |
index = filteredServices.findIndex((otherService) => otherService.service === nonClientName); | |
if (index !== -1) { | |
servicesToDump.push(filteredServices.splice(index, 1)[0]); | |
} | |
} | |
// ToDo: repeat? | |
index = filteredServices.findIndex((otherService) => otherService.service === service.service + 'Client' || otherService.service === service.service + 'Notifications' || (!!nonClientName && otherService.service === nonClientName + 'Notifications')); | |
if (index !== -1) { | |
servicesToDump.push(filteredServices.splice(index, 1)[0]); | |
} | |
let protosToDump = []; | |
let importsToDump = new Set(['steammessages_unified_base.steamclient.proto']); // always include "unified_base" to service proto | |
for (service of servicesToDump) { | |
for (const method of service.methods) { | |
let deps = extractDependencies(filteredProtos, method.request, knownMessages); | |
protosToDump.push(...deps.protos); | |
deps.imports.forEach((file) => importsToDump.add(file)); | |
if (method.response !== 'NoResponse') { | |
deps = extractDependencies(filteredProtos, method.response, knownMessages); | |
protosToDump.push(...deps.protos); | |
deps.imports.forEach((file) => importsToDump.add(file)); | |
} | |
} | |
} | |
let svcName = nonClientName || servicesToDump[0].service; | |
if (CONFIG.ProtoFilterEnabled) { | |
// if/when filtering is enabled, find orphaned messages "CSvcname_*" and add them to dump | |
let proto = filteredProtos.find((proto) => proto.name.startsWith('C' + svcName + '_')); | |
if (proto) { | |
let deps = extractDependencies(filteredProtos, proto.name, knownMessages); | |
protosToDump.push(...deps.protos); | |
deps.imports.forEach((file) => importsToDump.add(file)); | |
} | |
} | |
protosToDump.forEach((proto) => { | |
if (!proto.dependentServices) { | |
proto.dependentServices = new Set(); | |
} | |
proto.dependentServices.add(svcName); | |
}); | |
let fileName = CONFIG.ServiceProtoFileNameTemplate.replace('%svcname%', svcName.toLowerCase()); | |
AllToDump.add({fileName, importsToDump, protosToDump, servicesToDump}); | |
} | |
for (let {fileName, importsToDump, protosToDump, servicesToDump} of AllToDump) { | |
fileName = path.join(outputFullPath, fileName); | |
protosToDump = protosToDump.filter((proto, idx) => { | |
if (protosToDump.indexOf(proto) !== idx) { | |
// remove duplicates | |
return false; | |
} | |
if (!proto.dependentServices || proto.dependentServices.size > 1 || | |
filteredProtos.some((commonProto) => (!commonProto.dependentServices || commonProto.bCommon) && commonProto.dependencies && commonProto.dependencies.has(proto.name)) | |
) { | |
// several services depend on this proto, move it to "common.proto" | |
importsToDump.add(CONFIG.CommonProtoFileName); | |
proto.bCommon = true; | |
return false; | |
} | |
return true; | |
}); | |
outputToFile(fileName, importsToDump, protosToDump, servicesToDump); | |
} | |
filteredProtos = filteredProtos.filter((proto) => { | |
if (!proto.dependentServices || proto.bCommon) { | |
return true; | |
} | |
return false; | |
}); | |
// dump remaining common protos | |
const imports = new Set(['steammessages_unified_base.steamclient.proto']); // "unified_base" is required for `description` option | |
if (CONFIG.ProtoFilterEnabled) { | |
filteredProtos.forEach((proto) => { | |
if (proto.dependencies) { | |
proto.dependencies.forEach((depName) => { | |
const file = knownMessages.get(depName); | |
if (file) { | |
imports.add(file); | |
} | |
}); | |
} | |
}); | |
} | |
// sort messages by name to prevent shuffling | |
filteredProtos.sort((a, b) => a.name.localeCompare(b.name, 'en-US')); | |
let fileName = path.join(outputFullPath, CONFIG.CommonProtoFileName); | |
outputToFile(fileName, imports, filteredProtos, filteredServices); | |
} | |
function outputImports(imports, stream = process.stdout) { | |
for (const importName of imports) { | |
stream.write(`import "${importName}";\n`); | |
} | |
stream.write("\n"); | |
} | |
function outputServices(services, stream = process.stdout) { | |
for (const service of services) { | |
stream.write(`service ${service.service} {\n`); | |
for (const method of service.methods) { | |
stream.write(`\trpc ${method.name} (.${method.request}) returns (.${method.response});\n`); | |
} | |
stream.write("}\n\n"); | |
} | |
} | |
function outputProtos(protos, stream = process.stdout) { | |
protos.forEach((proto) => { | |
// DEBUG | |
if (proto.bCommon && proto.dependentServices) { | |
stream.write(`// Used by: ${[...proto.dependentServices]}\n`); | |
} | |
stream.write(`message ${proto.name} {\n`); | |
proto.fields.forEach((field) => { | |
stream.write(`\t${field.flag} ${field.type} ${field.name} = ${field.id}`); | |
let options = []; | |
if (field.hasOwnProperty("default")) { | |
options.push(`default = ${field.default}`); | |
} | |
if (field.hasOwnProperty("description")) { | |
options.push(`(description) = "${field.description}"`); | |
} | |
if (options.length) { | |
stream.write(` [${options.join(', ')}]`); | |
} | |
stream.write(';\n'); | |
}); | |
stream.write("}\n\n"); | |
}); | |
} | |
function outputToFile(fileName, imports, protos, services) { | |
let stream = fs.createWriteStream(fileName, { flags: 'w', encoding: 'utf8' }); | |
outputImports(imports, stream); | |
outputProtos(protos, stream); | |
outputServices(services, stream); | |
stream.end(); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment