// 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 .dff / .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 ""' ); } // 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 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();