Vehicles
This commit is contained in:
@@ -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();
|
||||
@@ -0,0 +1,63 @@
|
||||
// IMG archive reader.
|
||||
//
|
||||
// San Andreas ships VER2 archives: the 'VER2' tag, a u32 entry count, then one
|
||||
// 32 byte directory entry per file -
|
||||
// u32 offset, in 2048 byte sectors
|
||||
// u16 streaming size, u16 size in archive (zero on retail, so prefer the former)
|
||||
// char name[24], null padded
|
||||
// Bodies follow the directory, each starting on a sector boundary.
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
const SECTOR = 2048;
|
||||
|
||||
class Img {
|
||||
constructor(file) {
|
||||
this.fd = fs.openSync(file, 'r');
|
||||
this.path = file;
|
||||
|
||||
const header = Buffer.alloc(8);
|
||||
fs.readSync(this.fd, header, 0, 8, 0);
|
||||
if (header.toString('ascii', 0, 4) !== 'VER2') {
|
||||
throw new Error(`${file} is not a VER2 img archive`);
|
||||
}
|
||||
const count = header.readUInt32LE(4);
|
||||
|
||||
const dir = Buffer.alloc(count * 32);
|
||||
fs.readSync(this.fd, dir, 0, dir.length, 8);
|
||||
|
||||
// Lower cased, because the ide names and the archive names disagree on case.
|
||||
this.entries = new Map();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const at = i * 32;
|
||||
const name = dir.toString('ascii', at + 8, at + 32).split('\0')[0];
|
||||
const sectors = dir.readUInt16LE(at + 4) || dir.readUInt16LE(at + 6);
|
||||
this.entries.set(name.toLowerCase(), {
|
||||
name,
|
||||
offset: dir.readUInt32LE(at) * SECTOR,
|
||||
size: sectors * SECTOR,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
has(name) {
|
||||
return this.entries.has(name.toLowerCase());
|
||||
}
|
||||
|
||||
// Entry bodies are padded out to the sector size, and every reader downstream
|
||||
// walks chunk headers rather than trusting the length, so the padding is
|
||||
// harmless and gets written as-is.
|
||||
read(name) {
|
||||
const entry = this.entries.get(name.toLowerCase());
|
||||
if (!entry) return null;
|
||||
const buffer = Buffer.alloc(entry.size);
|
||||
fs.readSync(this.fd, buffer, 0, entry.size, entry.offset);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
close() {
|
||||
fs.closeSync(this.fd);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Img };
|
||||
Reference in New Issue
Block a user