239 lines
8.6 KiB
JavaScript
239 lines
8.6 KiB
JavaScript
// Pull the vehicle models out of a GTA:SA install.
|
|
//
|
|
// node tools/extract-vehicles.js --game "D:/.../Grand Theft Auto San Andreas"
|
|
//
|
|
// data/vehicles.ide gives the id -> model/txd mapping and data/carcols.dat the
|
|
// paint palette. Upgrade parts come from data/maps/veh_mods/veh_mods.ide, and
|
|
// data/carmods.dat says which ones each vehicle accepts. models/gta3.img holds
|
|
// every file. Everything lands in models/vehicles/ as <id>.dff / <id>.txd -
|
|
// vehicles are 400-611 and upgrades 1000-1193, so one flat folder does - next
|
|
// to the shared vehicle.txd and a vehicles.json describing the lot.
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { Img } = require('./img.js');
|
|
|
|
const USUAL_PLACES = [
|
|
'C:/Program Files (x86)/Rockstar Games/GTA San Andreas',
|
|
'C:/Program Files/Rockstar Games/GTA San Andreas',
|
|
'C:/Program Files (x86)/Steam/steamapps/common/Grand Theft Auto San Andreas',
|
|
'D:/SteamLibrary/steamapps/common/Grand Theft Auto San Andreas',
|
|
'D:/Games/GTA San Andreas',
|
|
];
|
|
|
|
function parseArgs(argv) {
|
|
const args = { game: '', out: path.join(__dirname, '..', 'models', 'vehicles') };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
if (argv[i] === '--game') args.game = argv[++i];
|
|
else if (argv[i] === '--out') args.out = argv[++i];
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function findGame(given) {
|
|
const candidates = given ? [given] : USUAL_PLACES;
|
|
for (const dir of candidates) {
|
|
if (fs.existsSync(path.join(dir, 'data', 'vehicles.ide'))) return dir;
|
|
}
|
|
throw new Error(
|
|
given
|
|
? `no data/vehicles.ide under ${given}`
|
|
: 'could not find a GTA:SA install - pass --game "<path>"'
|
|
);
|
|
}
|
|
|
|
// Both ide and dat files are comma or whitespace separated, with # comments and
|
|
// named sections closed by 'end'.
|
|
function readSections(file) {
|
|
const sections = new Map();
|
|
let current = null;
|
|
for (let line of fs.readFileSync(file, 'latin1').split(/\r?\n/)) {
|
|
line = line.replace(/#.*$/, '').trim();
|
|
if (!line) continue;
|
|
if (!current) {
|
|
current = { name: line.toLowerCase(), lines: [] };
|
|
sections.set(current.name, current.lines);
|
|
continue;
|
|
}
|
|
if (line.toLowerCase() === 'end') { current = null; continue; }
|
|
current.lines.push(line);
|
|
}
|
|
return sections;
|
|
}
|
|
|
|
// Three retail vehicles.ide lines (emperor, wayfarer, dodo) drop the comma
|
|
// between the model and txd names, so whitespace separates fields too - which
|
|
// is what the game's own parser does. No field we read contains a space.
|
|
function fields(line) {
|
|
return line.split(/[,\s]+/).map((f) => f.trim()).filter((f) => f !== '');
|
|
}
|
|
|
|
function parseVehicles(gameDir) {
|
|
const cars = readSections(path.join(gameDir, 'data', 'vehicles.ide')).get('cars') || [];
|
|
return cars.map((line) => {
|
|
const f = fields(line);
|
|
// Field 12 is the wheel scale for every vehicle type: cars append the wheel
|
|
// model id and two scales, planes append a LOD model id after them.
|
|
const wheelScale = Number(f[12]);
|
|
return {
|
|
id: Number(f[0]),
|
|
model: f[1],
|
|
txd: f[2],
|
|
type: f[3],
|
|
gameName: f[5] || f[1],
|
|
wheelScale: Number.isFinite(wheelScale) ? wheelScale : 1,
|
|
};
|
|
}).filter((v) => Number.isFinite(v.id) && v.model);
|
|
}
|
|
|
|
// carcols has a 'col' palette, a 'car' section of primary/secondary pairs and a
|
|
// 'car4' section for the handful of vehicles that use four slots.
|
|
function parseCarcols(gameDir) {
|
|
const sections = readSections(path.join(gameDir, 'data', 'carcols.dat'));
|
|
|
|
const palette = (sections.get('col') || []).map((line) => fields(line).map(Number).slice(0, 3));
|
|
|
|
const combos = new Map();
|
|
const collect = (name, width) => {
|
|
for (const line of sections.get(name) || []) {
|
|
const f = fields(line);
|
|
const model = f[0].toLowerCase();
|
|
const ids = f.slice(1).map(Number).filter(Number.isFinite);
|
|
const out = [];
|
|
for (let i = 0; i + width <= ids.length; i += width) out.push(ids.slice(i, i + width));
|
|
if (out.length) combos.set(model, out);
|
|
}
|
|
};
|
|
collect('car', 2);
|
|
collect('car4', 4); // wins where a model appears in both
|
|
|
|
return { palette, combos };
|
|
}
|
|
|
|
// The upgrade parts are ordinary objects in their own ide, ids 1000-1193, all
|
|
// of them pointing at the shared vehicle txd.
|
|
function parseUpgrades(gameDir) {
|
|
const file = path.join(gameDir, 'data', 'maps', 'veh_mods', 'veh_mods.ide');
|
|
const objs = readSections(file).get('objs') || [];
|
|
return objs.map((line) => {
|
|
const f = fields(line);
|
|
return { id: Number(f[0]), model: f[1] };
|
|
}).filter((u) => Number.isFinite(u.id) && u.model);
|
|
}
|
|
|
|
// carmods lists which parts each vehicle accepts ('mods'), which left/right
|
|
// parts are fitted as a pair ('link'), and the wheel shop line-ups ('wheel').
|
|
function parseCarmods(gameDir) {
|
|
const sections = readSections(path.join(gameDir, 'data', 'carmods.dat'));
|
|
|
|
const mods = new Map();
|
|
for (const line of sections.get('mods') || []) {
|
|
const f = fields(line);
|
|
// Retail comments the bike lines out, so they never reach here.
|
|
if (f.length > 1) mods.set(f[0].toLowerCase(), f.slice(1).map((n) => n.toLowerCase()));
|
|
}
|
|
|
|
const links = new Map();
|
|
for (const line of sections.get('link') || []) {
|
|
const f = fields(line);
|
|
if (f.length === 2) links.set(f[0].toLowerCase(), f[1].toLowerCase());
|
|
}
|
|
|
|
return { mods, links };
|
|
}
|
|
|
|
function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const gameDir = findGame(args.game);
|
|
console.log(`game -> ${gameDir}`);
|
|
|
|
const vehicles = parseVehicles(gameDir);
|
|
const { palette, combos } = parseCarcols(gameDir);
|
|
const upgrades = parseUpgrades(gameDir);
|
|
const { mods, links } = parseCarmods(gameDir);
|
|
console.log(
|
|
`data -> ${vehicles.length} vehicles, ${upgrades.length} upgrades, ` +
|
|
`${palette.length} palette colours`
|
|
);
|
|
|
|
fs.mkdirSync(args.out, { recursive: true });
|
|
|
|
// The five textures every vehicle shares - grunge, glass, lights, tyres -
|
|
// live here rather than in the per-vehicle txd, so without it most of a car
|
|
// renders untextured. The upgrade parts use it as their only dictionary.
|
|
const sharedTxd = path.join(gameDir, 'models', 'generic', 'vehicle.txd');
|
|
if (!fs.existsSync(sharedTxd)) throw new Error(`missing ${sharedTxd}`);
|
|
fs.copyFileSync(sharedTxd, path.join(args.out, 'vehicle.txd'));
|
|
|
|
const img = new Img(path.join(gameDir, 'models', 'gta3.img'));
|
|
const missing = [];
|
|
|
|
const byModel = new Map();
|
|
const upgradeMeta = {};
|
|
let upgradesWritten = 0;
|
|
for (const upgrade of upgrades) {
|
|
const dff = img.read(`${upgrade.model}.dff`);
|
|
if (!dff) { missing.push(`${upgrade.id} ${upgrade.model}.dff`); continue; }
|
|
fs.writeFileSync(path.join(args.out, `${upgrade.id}.dff`), dff);
|
|
upgradeMeta[upgrade.id] = { name: upgrade.model };
|
|
byModel.set(upgrade.model.toLowerCase(), upgrade.id);
|
|
upgradesWritten++;
|
|
}
|
|
|
|
const meta = {};
|
|
let written = 0;
|
|
for (const vehicle of vehicles) {
|
|
const dff = img.read(`${vehicle.model}.dff`);
|
|
if (!dff) { missing.push(`${vehicle.id} ${vehicle.model}.dff`); continue; }
|
|
fs.writeFileSync(path.join(args.out, `${vehicle.id}.dff`), dff);
|
|
|
|
const txd = img.read(`${vehicle.txd}.txd`);
|
|
if (txd) fs.writeFileSync(path.join(args.out, `${vehicle.id}.txd`), txd);
|
|
|
|
// A paintjob is a one texture dictionary sitting next to the vehicle in the
|
|
// archive as <model>1.txd upwards. Written out zero based, because that is
|
|
// what ChangeVehiclePaintjob counts from. Thirteen vehicles have any.
|
|
let paintjobs = 0;
|
|
for (let n = 1; ; n++) {
|
|
const job = img.read(`${vehicle.model}${n}.txd`);
|
|
if (!job) break;
|
|
fs.writeFileSync(path.join(args.out, `${vehicle.id}_pj${n - 1}.txd`), job);
|
|
paintjobs++;
|
|
}
|
|
|
|
meta[vehicle.id] = {
|
|
name: vehicle.model,
|
|
gameName: vehicle.gameName,
|
|
type: vehicle.type,
|
|
wheelScale: vehicle.wheelScale,
|
|
paintjobs,
|
|
colors: combos.get(vehicle.model.toLowerCase()) || [],
|
|
mods: (mods.get(vehicle.model.toLowerCase()) || [])
|
|
.map((name) => byModel.get(name))
|
|
.filter((id) => id !== undefined),
|
|
};
|
|
written++;
|
|
}
|
|
img.close();
|
|
|
|
// Wheels are not in any vehicle's mods list because any wheel fits anything.
|
|
const wheels = upgrades
|
|
.filter((u) => /^wheel_/.test(u.model) && upgradeMeta[u.id])
|
|
.map((u) => u.id);
|
|
|
|
const linkIds = {};
|
|
for (const [left, right] of links) {
|
|
if (byModel.has(left) && byModel.has(right)) linkIds[byModel.get(left)] = byModel.get(right);
|
|
}
|
|
|
|
fs.writeFileSync(
|
|
path.join(args.out, 'vehicles.json'),
|
|
JSON.stringify({ palette, vehicles: meta, upgrades: upgradeMeta, wheels, links: linkIds }, null, 1)
|
|
);
|
|
|
|
console.log(`written -> ${written} vehicles, ${upgradesWritten} upgrades into ${args.out}`);
|
|
if (missing.length) console.log(`missing -> ${missing.length}: ${missing.join(', ')}`);
|
|
}
|
|
|
|
main();
|