This commit is contained in:
jack
2026-09-01 01:04:57 +01:00
parent d3d1c12b28
commit 65c6aaac5b
11 changed files with 1068 additions and 106 deletions
+238
View File
@@ -0,0 +1,238 @@
// 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();