From 65c6aaac5b635b0dda08c2e1287af4256864fac9 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 1 Sep 2026 01:04:57 +0100 Subject: [PATCH] Vehicles --- .env.example | 11 ++ .gitignore | 4 + README.md | 136 +++++++++++++++++++++- deploy/iam-policy.json | 7 +- deploy/nginx.conf | 38 +++--- public/js/main.js | 160 ++++++++++++++++++++++--- public/js/vehicle.js | 149 ++++++++++++++++++++++++ public/js/viewer.js | 213 +++++++++++++++++++++++++++++++--- server.js | 155 +++++++++++++++++-------- tools/extract-vehicles.js | 238 ++++++++++++++++++++++++++++++++++++++ tools/img.js | 63 ++++++++++ 11 files changed, 1068 insertions(+), 106 deletions(-) create mode 100644 public/js/vehicle.js create mode 100644 tools/extract-vehicles.js create mode 100644 tools/img.js diff --git a/.env.example b/.env.example index cfc6df7..df095c9 100644 --- a/.env.example +++ b/.env.example @@ -25,5 +25,16 @@ MODELS_BASE_URL="https://static.southwest-roleplay.com/samp/skins" # origin, with nginx proxying to S3. No CORS needed, but the bytes go through # your box. See the commented block in deploy/nginx.conf. +# --- Vehicles --------------------------------------------------------------- +# Vehicles are a second collection of .dff / .txd, plus the shared +# vehicle.txd and the vehicles.json catalogue that tools/extract-vehicles.js +# writes alongside them. They are configured separately from skins because +# MODEL_ROOT usually points at a skins folder somewhere else. +# +# Local default: models/vehicles in this directory, served at /models/vehicles. +# VEHICLE_ROOT="C:/samp/vehicles" +# VEHICLE_S3_KEY_PREFIX="samp/vehicles" +# VEHICLES_BASE_URL="https://static.southwest-roleplay.com/samp/vehicles" + # How often to re-list the bucket, in ms. Default 15 minutes. # INDEX_REFRESH_MS=900000 diff --git a/.gitignore b/.gitignore index fb3bc3b..6782f78 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ node_modules/ # Extracted game assets - keep them out of the repo models/*.dff models/*.txd +models_rotated_fixed/ +rotated-fixed-ids.txt +skin-orientation-audit.txt +models/vehicles/ diff --git a/README.md b/README.md index e6d46b9..e15b6c2 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ Drop a `.dff` and `.txd` into `models/`, open `/samp/skin/`, and it renders. The RenderWare files are parsed in the browser — no conversion step, no Blender, no build tooling. -`/samp/skin/` is the only thing the server exposes. The root, `/index.html` -and any other path return 404. +`/samp/skin/` and `/samp/vehicle/` are the only things the server +exposes. The root, `/index.html` and any other path return 404. ## Running @@ -52,6 +52,137 @@ That yields **265 skins**. Three groups are absent for real reasons: Extracted models are gitignored; they're Rockstar's assets, not yours to ship. +## Vehicles + +`/samp/vehicle/` renders a vehicle, where `` is the SA-MP model id +(400-611). They come out of your own install the same way skins do: + +```bash +node tools/extract-vehicles.js --game "D:/SteamLibrary/steamapps/common/Grand Theft Auto San Andreas" +``` + +That reads `data/vehicles.ide` for the id -> model mapping, pulls each `.dff` +and `.txd` out of `models/gta3.img`, and writes them to +`models/vehicles/.dff` / `.txd`. All 212 come across, along with the 194 +upgrade parts from `data/maps/veh_mods/veh_mods.ide` - vehicles are 400-611 and +parts 1000-1193, so one flat folder holds both. It also copies two things the +viewer needs: + +- `vehicle.txd`, from `models/generic/`. The grunge, glass, light and tyre + textures every vehicle's materials name live there rather than in the + per-vehicle txd, so without it most of a car renders untextured. +- `vehicles.json`, built from `data/carcols.dat` and `data/carmods.dat`: the 127 + entry colour palette, each vehicle's factory colour combinations, how many + paintjobs it has, and which upgrade parts it accepts. + +A vehicle clump is not just "every atomic, drawn at its frame". Three rules come +with it, all in [public/js/vehicle.js](public/js/vehicle.js): + +- **Damage variants.** `bump_front_ok` and `bump_front_dam` are both in the + clump. The `_dam` ones are hidden, or they z-fight the intact panels. +- **`chassis_vlo`.** The low detail chassis sits inside the real one, and is + hidden for the same reason. +- **Wheels.** A car carries exactly *one* `wheel` atomic, parented to whichever + `wheel_XX_dummy` the modeller happened to pick. The game clones it to every + dummy, so the viewer does too - mirrored on the left, because all the dummies + share one orientation and differ only in position. Six wheelers work out of + the box (`wheel_lm_dummy`), and a vehicle that models a corner itself keeps + its own geometry. + +### Colours + +SA encodes paint in the material colour: the game looks for a reserved value and +swaps in the carcols entry. Checked against the retail models - every car uses +`60,255,0` for the primary, two thirds also use `255,0,175` for the secondary, +and the `car4` vehicles (camper, cement, squalo) add `0,255,255` for the third. +Those materials keep their colour when textured rather than being forced to +white, so the grunge map multiplies over the body colour the way it does in +game. + +Colours are carcols palette indices - the same numbers `ChangeVehicleColor` +takes - and default to the vehicle's first factory combination: + +``` +/samp/vehicle/596?c1=3&c2=1 +``` + +Repainting does not need a reload, so the console gets `setColors(3, 1)`, plus +`carcols` for the palette and `viewer` for everything else. + +### Upgrade parts + +Parts are fitted by component id - the same numbers `AddVehicleComponent` takes +- and stack with the colour parameters: + +``` +/samp/vehicle/401?u=1005,1006,1001,1007,1020,1013,1008,1025&c1=3&c2=1 +``` + +An upgrade dff is a single atomic on a single root frame and carries no +attachment information at all. What holds it on is a dummy frame in the vehicle, +picked by the prefix of the part's model name: + +| prefix | frame | | +| --- | --- | --- | +| `spl_` `rf_` `nto_` `lgt_` | `ug_spoiler` `ug_roof` `ug_nitro` `ug_lights` | | +| `bnt_` `bntl_` `bntr_` | `ug_bonnet` `ug_bonnet_left` `ug_bonnet_right` | | +| `wg_l_` `wg_r_` | `ug_wing_left` `ug_wing_right` | | +| `misc_a_` `misc_c_` | `misc_a` `misc_c` | | +| `fbb_` `bbb_` | `ug_frontbullbar` `ug_backbullbar` | slamvan only | +| `fbmp_` `rbmp_` | `bump_front_dummy` `bump_rear_dummy` | replaces the stock bumper | +| `exh_` | `exhaust_ok` | replaces the stock exhaust | +| `wheel_` | every wheel dummy | | + +Two of those took measuring rather than guessing: + +- **Exhausts hang off `exhaust_ok`, not `exhaust`.** The `exhaust` frame is the + smoke emitter and sits about a metre behind the car. Across the 31 vehicles + that accept `exh_b_l`, mounting on the stock exhaust component puts the pipe + end within a few cm of the rear of the body every time; mounting on `exhaust` + overshoots by a metre every time. +- **A part's own root transform is dropped.** Fitting one re-frames its atomic + onto the vehicle's dummy, which discards the frame it came with. Only the + bumpers have a non-identity root - `fbmp_a_l`'s is a metre off centre and its + geometry cancels that out, exactly like the stock bumper does against + `bump_front_dummy` - so they are the only parts where it shows. + +Parts carry their own `_dam` twins, skipped like the vehicle's own. Fitting a +left hand body kit part fits its right hand twin too, which is what carmods' +`link` section is for. Wheels are the one part that needs scaling: a stock wheel +already comes at the vehicle's own size, an upgrade wheel is modelled at unit +diameter and the ide's wheel scale is what sizes it - measured, infernus stock +0.70 against a scale of 0.70, linerun 1.10 against 1.10, every `wheel_*` upgrade +0.99. + +`?u=` accepts anything; `mods` in the console lists what the vehicle is actually +offered, id to part name. + +### Paintjobs + +Thirteen vehicles take one - the six Transfender tuners, the six lowriders and +the camper. Three each, except broadway with two and camper with one: + +``` +/samp/vehicle/562?pj=0 +``` + +A paintjob is a one texture dictionary stored beside the vehicle in the archive +as `1.txd` upwards, extracted zero based to `_pj.txd` because that +is what `ChangeVehiclePaintjob` counts from. It is not a name for name texture +swap: the vehicle's own txd marks the panels a paintjob covers by giving them a +texture called `remap*`, and fitting one replaces that texture on every material +using it - seven of them on the elegy, across the chassis, doors, bonnet, boot +and both bumpers. + +The artwork carries the colour, so those panels stop taking the primary paint +while a paintjob is on and go back to it when it comes off. `setPaintjob(0)` in +the console switches without a reload, and anything out of range takes it back +off the way 3 does in game. + +Not yet handled: `stereo` and `hydralics` have component ids but no geometry, +the light corona materials, dirt level, and the `_hi`/`_lo` LOD split some +planes use. + ## Naming Files are named by id: `.dff` and `.txd`, in `models/` locally or under @@ -88,6 +219,7 @@ failed fetch instead. ## API - `GET /api/skin/:id` — the URLs for one id, or 404 +- `GET /api/vehicle/:id` — the same, plus the shared txd and catalogue URLs ## What's implemented diff --git a/deploy/iam-policy.json b/deploy/iam-policy.json index ae3f796..b036240 100644 --- a/deploy/iam-policy.json +++ b/deploy/iam-policy.json @@ -4,13 +4,16 @@ { "Effect": "Allow", "Action": "s3:GetObject", - "Resource": "arn:aws:s3:::your-bucket/skins/*" + "Resource": [ + "arn:aws:s3:::your-bucket/skins/*", + "arn:aws:s3:::your-bucket/vehicles/*" + ] }, { "Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::your-bucket", - "Condition": { "StringLike": { "s3:prefix": "skins/*" } } + "Condition": { "StringLike": { "s3:prefix": ["skins/*", "vehicles/*"] } } } ] } diff --git a/deploy/nginx.conf b/deploy/nginx.conf index e2d4f61..2b7039a 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -1,13 +1,27 @@ # /etc/nginx/sites-available/model.southwest-roleplay.dev # -# Only /samp/skin/ and the assets that page needs are exposed. Everything -# else, including the root, returns 404. certbot rewrites this for 443 and -# leaves the location blocks untouched. +# Plain HTTP to start with. Run +# sudo certbot --nginx -d model.southwest-roleplay.dev +# and certbot adds the listen 443 / ssl_certificate lines and the redirect, +# the same way it manages the other sites on this box. +# +# Only /samp/skin/ and the assets that page needs are exposed; everything +# else returns 404. Model files are not served here at all - MODELS_BASE_URL +# points the browser straight at static.southwest-roleplay.com. server { listen 80; + listen [::]:80; server_name model.southwest-roleplay.dev; + # Nothing caches in front of this origin, so compress here. three.module.js + # is 1.3 MB uncompressed. gzip_proxied is required: every response comes + # from the Node upstream, and nginx skips proxied responses without it. + gzip on; + gzip_proxied any; + gzip_types application/javascript; + gzip_min_length 1024; + location /samp/skin/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; @@ -31,17 +45,13 @@ server { add_header Cache-Control "public, max-age=31536000, immutable"; } - # Path A only: model files come from S3 through nginx, so they stay - # same-origin and no bucket CORS rule is needed. Delete this block if you - # set MODELS_BASE_URL and let the browser fetch the CDN directly. - location /models/ { - proxy_pass https://your-bucket.s3.eu-west-2.amazonaws.com/skins/; - proxy_set_header Host your-bucket.s3.eu-west-2.amazonaws.com; - proxy_hide_header x-amz-id-2; - proxy_hide_header x-amz-request-id; - proxy_hide_header x-amz-server-side-encryption; - add_header Cache-Control "public, max-age=31536000, immutable"; - } + # The catch-all below does not block certbot: the nginx plugin inserts a + # more specific location for /.well-known/acme-challenge/ while it runs. + # Only if you switch to the webroot plugin would you need this permanently: + # + # location ^~ /.well-known/acme-challenge/ { + # root /var/www/html; + # } location / { return 404; diff --git a/public/js/main.js b/public/js/main.js index 51d0357..ce71eca 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -20,44 +20,163 @@ async function fetchBuffer(url) { return res.arrayBuffer(); } -function render(name, dffBuffer, txdBuffer) { - const dff = parseDFF(dffBuffer); - const txd = txdBuffer ? parseTXD(txdBuffer) : null; - const report = viewer.load(dff, txd); +async function fetchJSON(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`${res.status} fetching ${url}`); + return res.json(); +} +// A vehicle clump is recognisable by its frame names: every one of them has a +// chassis and a set of wheel dummies. +function looksLikeVehicle(dff) { + return dff.frames.some((frame) => /^(chassis|wheel_[lr][fmb]_dummy)$/i.test(frame.name)); +} + +// Paint comes from the url (?c1=3&c2=1, carcols palette indices, the same +// numbers ChangeVehicleColor takes) and falls back to the vehicle's first +// factory combo. An index with no entry is left for the viewer to default. +function resolvePaint(catalogue, id) { + if (!catalogue) return null; + const params = new URLSearchParams(location.search); + const info = catalogue.vehicles && catalogue.vehicles[id]; + const factory = (info && info.colors && info.colors[0]) || []; + + return [0, 1, 2, 3].map((slot) => { + const given = params.get(`c${slot + 1}`); + const index = given !== null && given !== '' ? Number(given) : factory[slot]; + return Number.isFinite(index) ? catalogue.palette[index] || null : null; + }); +} + +// Parts come from ?u=1010,1008 - component ids, the same numbers +// AddVehicleComponent takes. Fitting a left hand body kit part fits its right +// hand twin too, which is what carmods' link section is for. +async function resolveUpgrades(meta, catalogue) { + const raw = new URLSearchParams(location.search).get('u'); + if (!raw || !catalogue || !catalogue.upgrades) return []; + + const wanted = []; + for (const field of raw.split(',')) { + const id = Number(field.trim()); + if (!Number.isFinite(id)) continue; + wanted.push(id); + if (catalogue.links[id] !== undefined) wanted.push(catalogue.links[id]); + } + + const parts = await Promise.all( + [...new Set(wanted)] + .filter((id) => catalogue.upgrades[id]) + .map(async (id) => ({ + id, + name: catalogue.upgrades[id].name, + dff: parseDFF(await fetchBuffer(`${meta.base}/${id}.dff`)), + })) + ); + return parts; +} + +// Paintjobs are numbered from zero, the way ChangeVehiclePaintjob counts them. +// Anything outside the vehicle's range means no paintjob, which is what 3 does +// in game. +async function fetchPaintjob(meta, catalogue, id, index) { + const info = catalogue && catalogue.vehicles && catalogue.vehicles[id]; + if (!info || !Number.isFinite(index) || index < 0 || index >= info.paintjobs) return null; + return parseTXD(await fetchBuffer(`${meta.base}/${id}_pj${index}.txd`)); +} + +function render(name, dffBuffer, txdBuffers, options = {}) { + const dff = parseDFF(dffBuffer); + const txds = txdBuffers.filter(Boolean).map((buffer) => parseTXD(buffer)); + const report = viewer.load(dff, txds, options); + + const textureCount = txds.reduce((total, txd) => total + txd.textures.size, 0); console.log( `${name}: rw ${dff.versionText}, ${report.meshes} mesh(es), ` + `${report.vertices} verts, ${report.triangles} tris, ` + - `${txd ? txd.textures.size : 0} textures` + `${textureCount} textures${options.vehicle ? ', vehicle' : ''}` + + (options.paintjobs ? `, ${options.paintjobs} paintjob(s)` : '') ); - const warnings = [...dff.errors, ...(txd ? txd.errors : [])]; + const warnings = [...dff.errors, ...txds.flatMap((txd) => txd.errors)]; if (report.missingTextures.size) { warnings.push('not in txd: ' + [...report.missingTextures].join(', ')); } warnings.forEach((warning) => console.warn(warning)); setError(''); + return dff; } -async function loadSkin(id) { +async function loadModel(kind, id) { try { - const res = await fetch(`/api/skin/${encodeURIComponent(id)}`); + const res = await fetch(`/api/${kind}/${encodeURIComponent(id)}`); const meta = await res.json(); if (!res.ok) { setError(meta.error || `${res.status} loading ${id}`); return; } - const [dffBuffer, txdBuffer] = await Promise.all([ + + // The shared dictionary and the carcols catalogue are optional: a missing + // one costs textures or factory colours, not the render. + const [dffBuffer, txdBuffer, sharedBuffer, catalogue] = await Promise.all([ fetchBuffer(meta.dff), - meta.txd ? fetchBuffer(meta.txd) : Promise.resolve(null), + meta.txd ? fetchBuffer(meta.txd) : null, + meta.shared ? fetchBuffer(meta.shared).catch(() => null) : null, + meta.meta ? fetchJSON(meta.meta).catch(() => null) : null, ]); - render(id, dffBuffer, txdBuffer); + + const vehicle = kind === 'vehicle'; + const info = (vehicle && catalogue && catalogue.vehicles[id]) || null; + const wanted = new URLSearchParams(location.search).get('pj'); + // The vehicle's own textures win over the shared ones. + render(id, dffBuffer, [txdBuffer, sharedBuffer], { + vehicle, + paint: vehicle ? resolvePaint(catalogue, id) : null, + upgrades: vehicle ? await resolveUpgrades(meta, catalogue) : null, + wheelScale: info ? info.wheelScale : 1, + paintjob: + vehicle && wanted !== null && wanted !== '' + ? await fetchPaintjob(meta, catalogue, id, Number(wanted)) + : null, + paintjobs: info ? info.paintjobs : 0, + }); + + if (vehicle && catalogue) { + // Repainting needs no reload, so leave the palette and the viewer within + // reach of the console: setColors(3, 1) is ChangeVehicleColor(v, 3, 1). + window.setColors = (...ids) => + viewer.setPaint(ids.map((index) => catalogue.palette[index] || null)); + window.carcols = catalogue; + window.viewer = viewer; + // setPaintjob(0) is ChangeVehiclePaintjob(v, 0); anything out of range + // takes it back off, the way 3 does in game. + window.setPaintjob = async (index) => + viewer.applyPaintjob(await fetchPaintjob(meta, catalogue, id, index)); + // What ?u= accepts here, as id -> part name. Wheels are listed for every + // vehicle because any wheel fits anything. + window.mods = Object.fromEntries( + ((info && info.mods) || []) + .concat(catalogue.wheels || []) + .filter((partId) => catalogue.upgrades && catalogue.upgrades[partId]) + .map((partId) => [partId, catalogue.upgrades[partId].name]) + ); + } } catch (err) { console.error(err); setError(err.message); } } +// Dropped vehicles get the shared dictionary fetched for them, otherwise most +// of the body renders untextured. +async function sharedVehicleTxd() { + try { + const meta = await fetchJSON('/api/vehicle/411'); + return meta.shared ? await fetchBuffer(meta.shared) : null; + } catch { + return null; + } +} + function setupDragAndDrop() { document.body.addEventListener('dragover', (e) => { e.preventDefault(); @@ -69,17 +188,20 @@ function setupDragAndDrop() { document.body.classList.remove('dragging'); const files = [...e.dataTransfer.files]; const dffFile = files.find((f) => f.name.toLowerCase().endsWith('.dff')); - const txdFile = files.find((f) => f.name.toLowerCase().endsWith('.txd')); + const txdFiles = files.filter((f) => f.name.toLowerCase().endsWith('.txd')); if (!dffFile) { setError('drop a .dff (and its .txd) together'); return; } try { - render( - dffFile.name.replace(/\.dff$/i, ''), - await dffFile.arrayBuffer(), - txdFile ? await txdFile.arrayBuffer() : null - ); + const name = dffFile.name.replace(/\.dff$/i, ''); + const dffBuffer = await dffFile.arrayBuffer(); + const txdBuffers = await Promise.all(txdFiles.map((f) => f.arrayBuffer())); + + const vehicle = looksLikeVehicle(parseDFF(dffBuffer)); + if (vehicle && txdBuffers.length < 2) txdBuffers.push(await sharedVehicleTxd()); + + render(name, dffBuffer, txdBuffers, { vehicle }); } catch (err) { console.error(err); setError(err.message); @@ -89,5 +211,5 @@ function setupDragAndDrop() { setupDragAndDrop(); -const match = location.pathname.match(/^\/samp\/skin\/(.+)$/); -if (match) loadSkin(decodeURIComponent(match[1])); +const match = location.pathname.match(/^\/samp\/(skin|vehicle)\/(.+)$/); +if (match) loadModel(match[1], decodeURIComponent(match[2])); diff --git a/public/js/vehicle.js b/public/js/vehicle.js new file mode 100644 index 0000000..9fff4f7 --- /dev/null +++ b/public/js/vehicle.js @@ -0,0 +1,149 @@ +// Vehicle specific rules for turning a clump into a scene. +// +// A car dff is not just "every atomic, drawn at its frame". It also carries the +// crash damage variants, a low detail chassis and a single wheel that the game +// instances four (or six) times. Rendering it literally gives you a z-fighting +// mess with one wheel on the front right. + +// Paint is encoded in the material colour. The game looks for these exact +// values and swaps in the carcols entry for the vehicle's colour slot. +// Verified against the retail models: every car uses 60,255,0 for the primary, +// two thirds also use 255,0,175 for the secondary, and the car4 vehicles +// (camper, cement, squalo) add 0,255,255 for the third. Body kit parts carry +// the same masks, so they paint along with the car. +const PAINT_MASKS = [ + [60, 255, 0], + [255, 0, 175], + [0, 255, 255], + [255, 0, 255], +]; + +// Near enough every vehicle also carries one material each in 255,175,0 / +// 185,255,0 / 0,255,200 / 255,60,0. Those mark light panels, not paint, and are +// deliberately absent from the list above so they keep their own colour. + +export const DEFAULT_PAINT = [200, 200, 200]; + +// Material colours come straight from bytes, so the match is exact. +export function paintSlot(color) { + const r = Math.round(color[0] * 255); + const g = Math.round(color[1] * 255); + const b = Math.round(color[2] * 255); + for (let i = 0; i < PAINT_MASKS.length; i++) { + const mask = PAINT_MASKS[i]; + if (mask[0] === r && mask[1] === g && mask[2] === b) return i; + } + return -1; +} + +// wheel_lf_dummy, wheel_rb_dummy, and the middle axle a six wheeler adds. +const WHEEL_DUMMY = /^wheel_([lr])[fmb]_dummy$/i; + +// An upgrade dff is a single atomic on a single root frame - it carries no +// attachment information at all. What holds it on is a dummy frame in the +// vehicle, and the prefix of the part's model name picks which. Longer +// prefixes come first so bntl_ is not eaten by bnt_. +const UPGRADE_SLOTS = [ + [/^bntl_/, 'ug_bonnet_left'], + [/^bntr_/, 'ug_bonnet_right'], + [/^bnt_/, 'ug_bonnet'], + [/^spl_/, 'ug_spoiler'], + [/^rf_/, 'ug_roof'], + [/^wg_l_/, 'ug_wing_left'], + [/^wg_r_/, 'ug_wing_right'], + [/^nto_/, 'ug_nitro'], + [/^lgt_/, 'ug_lights'], + // Not the 'exhaust' frame - that one is the smoke emitter and sits about a + // metre behind the car. The stock exhaust component is the mount, and the + // upgrade takes its place: measured across the 31 vehicles that accept + // exh_b_l, hanging it there puts the pipe end within a few cm of the rear of + // the body every time. + [/^exh_/, 'exhaust_ok', /^exhaust/], + [/^misc_a_/, 'misc_a'], + [/^misc_c_/, 'misc_c'], + // The slamvan is the only vehicle with bull bar mounts, and they sit in + // front of the bumper rather than replacing it. + [/^fbb_/, 'ug_frontbullbar'], + [/^bbb_/, 'ug_backbullbar'], + // Bumpers take the stock one's place rather than sitting on top of it. + [/^fbmp_/, 'bump_front_dummy', /^bump_front/], + [/^rbmp_/, 'bump_rear_dummy', /^bump_rear/], +]; + +// The atomics of an upgrade clump worth drawing. +// +// Fitting a part re-frames its atomic onto the vehicle's dummy, which drops the +// part's own root transform - so callers place the geometry at the dummy and +// ignore the clump's frame. The bumpers are where this shows: fbmp_a_l's root +// frame is a metre off centre and its geometry cancels that out, exactly like +// the stock bumper does against bump_front_dummy. +// +// Parts carry their own crash variants too, so the _dam twin is skipped the +// same way the vehicle's own are. +export function upgradeAtomics(dff) { + return dff.atomics.filter( + (atomic) => !((dff.frames[atomic.frame].name || '').toLowerCase().endsWith('_dam')) + ); +} + +// null means the part has nothing to draw - stereo and hydralics are upgrades +// with a model id but no visible geometry. +export function upgradeSlot(name) { + const model = String(name).toLowerCase(); + if (/^wheel_/.test(model)) return { wheel: true }; + for (const [prefix, frame, replaces] of UPGRADE_SLOTS) { + if (prefix.test(model)) return { frame, replaces }; + } + return null; +} + +// upgrades is a list of { name, dff } - parts already fetched and parsed. +export function planVehicle(dff, upgrades = []) { + const hide = new Set(); + const wheels = []; + const parts = []; + const replaced = []; + let wheelModel = null; + const frameName = (index) => ((dff.frames[index] && dff.frames[index].name) || '').toLowerCase(); + + for (const upgrade of upgrades) { + const slot = upgradeSlot(upgrade.name); + if (!slot) continue; + if (slot.wheel) { wheelModel = upgrade; continue; } + // A vehicle simply has no frame for a part it was never meant to take. + const frame = dff.frames.findIndex((f) => f.name.toLowerCase() === slot.frame); + if (frame < 0) continue; + parts.push({ upgrade, frame }); + if (slot.replaces) replaced.push(slot.replaces); + } + + dff.atomics.forEach((atomic, i) => { + const name = frameName(atomic.frame); + // bump_front_dam sits on top of bump_front_ok, chassis_vlo inside chassis. + if (name.endsWith('_dam') || name.endsWith('_vlo')) hide.add(i); + if (replaced.some((prefix) => prefix.test(name))) hide.add(i); + }); + + // The lone 'wheel' atomic hangs off whichever dummy the modeller happened to + // parent it to, and gets cloned to the rest. Left hand wheels are mirrored, + // because every dummy shares the same orientation and only differs in + // position - so without the flip the tyre wall faces inwards. + const wheel = dff.atomics.findIndex((atomic) => frameName(atomic.frame) === 'wheel'); + if (wheel >= 0) { + hide.add(wheel); + const modelled = new Set(dff.atomics.map((atomic) => atomic.frame)); + dff.frames.forEach((frame, i) => { + const match = WHEEL_DUMMY.exec(frame.name); + if (!match) return; + if (modelled.has(i)) return; // this corner models its own wheel + wheels.push({ + atomic: wheel, + localFrame: dff.atomics[wheel].frame, + frame: i, + mirror: match[1].toLowerCase() === 'l', + }); + }); + } + + return { hide, wheels, parts, wheelModel }; +} diff --git a/public/js/viewer.js b/public/js/viewer.js index dc9b244..407c8db 100644 --- a/public/js/viewer.js +++ b/public/js/viewer.js @@ -1,5 +1,6 @@ import * as THREE from 'three'; import { OrbitControls } from '/vendor/OrbitControls.js'; +import { planVehicle, upgradeAtomics, paintSlot, DEFAULT_PAINT } from './vehicle.js'; const MISSING_COLOR = 0xb0b0b0; @@ -28,6 +29,7 @@ export class Viewer { this.model = null; this.textures = []; + this.txds = []; window.addEventListener('resize', () => this.resize()); this.resize(); @@ -57,6 +59,9 @@ export class Viewer { } this.textures.forEach((t) => t.dispose()); this.textures = []; + this.txds = []; + this.paintjob = null; + this.paintColors = null; } makeTexture(source) { @@ -74,19 +79,35 @@ export class Viewer { return texture; } - buildMaterial(material, txd, report) { + // Vehicles reference the shared generic/vehicle.txd as well as their own, so + // lookups walk the dictionaries in the order they were given. + findTexture(name) { + const key = name.toLowerCase(); + for (const txd of this.txds) { + const source = txd.textures.get(key); + if (source) return source; + } + return null; + } + + buildMaterial(material, report, paint) { + // A paint material keeps its colour when textured rather than being reset + // to white: the game multiplies the grunge map over the body colour, which + // is what map * color does here. + const slot = paint ? paintSlot(material.color) : -1; + const params = { color: new THREE.Color(material.color[0], material.color[1], material.color[2]), side: THREE.DoubleSide, shininess: 8, }; + if (slot >= 0) params.color = paint[slot] || paint.fallback; if (material.texture && material.texture.name) { - const key = material.texture.name.toLowerCase(); - const source = txd && txd.textures.get(key); + const source = this.findTexture(material.texture.name); if (source) { params.map = this.makeTexture(source); - params.color = new THREE.Color(0xffffff); + if (slot < 0) params.color = new THREE.Color(0xffffff); if (source.hasAlpha) { params.transparent = true; params.alphaTest = 0.35; @@ -94,7 +115,7 @@ export class Viewer { } } else { report.missingTextures.add(material.texture.name); - params.color = new THREE.Color(MISSING_COLOR); + if (slot < 0) params.color = new THREE.Color(MISSING_COLOR); } } @@ -102,7 +123,12 @@ export class Viewer { params.transparent = true; params.opacity = material.color[3]; } - return new THREE.MeshPhongMaterial(params); + const built = new THREE.MeshPhongMaterial(params); + if (slot >= 0) built.userData.paintSlot = slot; + // A vehicle that takes paintjobs marks the panels one covers by giving them + // a remap* texture. Fitting a paintjob swaps that texture out. + if (material.texture && /^remap/i.test(material.texture.name)) built.userData.remap = true; + return built; } buildGeometry(geo) { @@ -117,6 +143,23 @@ export class Viewer { return geometry; } + buildMesh(geo, report, paint) { + return new THREE.Mesh( + this.buildGeometry(geo), + geo.materials.length + ? geo.materials.map((m) => this.buildMaterial(m, report, paint)) + : new THREE.MeshPhongMaterial({ color: MISSING_COLOR, side: THREE.DoubleSide }) + ); + } + + // Counted per instance rather than per build, because the wheel is built once + // and drawn four times. + countMesh(report, geo) { + report.meshes++; + report.vertices += geo.numVertices; + report.triangles += geo.indices.length / 3; + } + // Frames form a tree; an atomic is placed by its frame's world transform. // Skinned geometry is the exception: its vertices are stored in the space of // the atomic frame's *parent*, so that frame's own transform is skipped. @@ -136,9 +179,33 @@ export class Viewer { return matrix; } - load(dff, txd) { + // colors is a list of [r, g, b] bytes, one per paint slot. They come from + // carcols, so they are sRGB rather than working space values. + makePaint(colors) { + // A slot with no colour is left undefined so it falls back below, which is + // what a vehicle that only paints two of its four slots wants. + const paint = (colors || []).map((c) => + Array.isArray(c) + ? new THREE.Color().setRGB(c[0] / 255, c[1] / 255, c[2] / 255, THREE.SRGBColorSpace) + : undefined + ); + paint.fallback = new THREE.Color().setRGB( + DEFAULT_PAINT[0] / 255, + DEFAULT_PAINT[1] / 255, + DEFAULT_PAINT[2] / 255, + THREE.SRGBColorSpace + ); + return paint; + } + + // txd is one dictionary or a list of them, searched in order. + // options: { vehicle: true, paint: [[r, g, b], ...] } + load(dff, txd, options = {}) { this.clear(); + this.txds = [].concat(txd || []).filter(Boolean); const report = { missingTextures: new Set(), meshes: 0, triangles: 0, vertices: 0 }; + this.paintColors = options.paint || null; + const paint = options.vehicle ? this.makePaint(options.paint) : null; const root = new THREE.Object3D(); // RenderWare is Z up; three.js is Y up. @@ -148,29 +215,137 @@ export class Viewer { ? dff.atomics : dff.geometries.map((_, i) => ({ geometry: i, frame: -1 })); - for (const atomic of atomics) { + const plan = options.vehicle ? planVehicle(dff, options.upgrades) : null; + + atomics.forEach((atomic, index) => { + if (plan && plan.hide.has(index)) return; const geo = dff.geometries[atomic.geometry]; - if (!geo) continue; - const mesh = new THREE.Mesh( - this.buildGeometry(geo), - geo.materials.length - ? geo.materials.map((m) => this.buildMaterial(m, txd, report)) - : new THREE.MeshPhongMaterial({ color: MISSING_COLOR, side: THREE.DoubleSide }) - ); + if (!geo) return; + const mesh = this.buildMesh(geo, report, paint); mesh.applyMatrix4(this.worldMatrix(dff.frames, atomic.frame, geo.skinned)); - mesh.name = (dff.frames[atomic.frame] && dff.frames[atomic.frame].name) || 'atomic' + report.meshes; + mesh.name = (dff.frames[atomic.frame] && dff.frames[atomic.frame].name) || 'atomic' + index; root.add(mesh); - report.meshes++; - report.vertices += geo.numVertices; - report.triangles += geo.indices.length / 3; + this.countMesh(report, geo); + }); + + if (plan) { + this.addWheels(root, dff, plan, report, paint, options); + this.addParts(root, dff, plan, report, paint); } this.scene.add(root); this.model = root; + if (options.paintjob) this.applyPaintjob(options.paintjob); this.frame(); return report; } + // A paintjob is a one texture dictionary that stands in for the remap texture + // on the body panels. The artwork carries the colour, so those panels stop + // taking the primary paint while one is fitted. Pass null to strip it. + applyPaintjob(txd) { + if (!this.model) return; + this.paintjob = txd || null; + const source = txd ? [...txd.textures.values()][0] : null; + const map = source ? this.makeTexture(source) : null; + const paint = this.makePaint(this.paintColors); + + this.model.traverse((obj) => { + [].concat(obj.material || []).forEach((material) => { + if (!material.userData.remap) return; + if (map) { + if (material.userData.baseMap === undefined) material.userData.baseMap = material.map; + material.map = map; + material.color.set(0xffffff); + } else { + if (material.userData.baseMap !== undefined) material.map = material.userData.baseMap; + const slot = material.userData.paintSlot; + if (slot !== undefined) material.color.copy(paint[slot] || paint.fallback); + } + material.needsUpdate = true; + }); + }); + } + + // The wheel is built once and cloned per corner, so all four instances share + // one geometry and one material set. + addWheels(root, dff, plan, report, paint, options) { + if (!plan.wheels.length) return; + + // A stock wheel already comes at the vehicle's own size; an upgrade wheel is + // modelled at unit diameter and the ide's wheel scale is what sizes it. + // Measured: infernus stock 0.70 with scale 0.70, linerun 1.10 with 1.10, + // every wheel_* upgrade 0.99. + let source = dff; + let atomic = dff.atomics[plan.wheels[0].atomic]; + let scale = 1; + // A fitted wheel is re-framed onto the dummy like any other part, so its + // own root transform goes; the stock one is part of the vehicle's own + // hierarchy and keeps it. + let local = new THREE.Matrix4(); + if (plan.wheelModel) { + source = plan.wheelModel.dff; + atomic = upgradeAtomics(source)[0]; + scale = options.wheelScale || 1; + } else if (atomic) { + local = new THREE.Matrix4().fromArray(source.frames[atomic.frame].matrix); + } + if (!atomic) return; + const geo = source.geometries[atomic.geometry]; + if (!geo) return; + + // The base is never added to the scene and never transformed: every corner + // clones it, so no corner inherits another's placement. + const base = this.buildMesh(geo, report, paint); + + for (const wheel of plan.wheels) { + const mesh = base.clone(); + const matrix = this.worldMatrix(dff.frames, wheel.frame, false); + if (wheel.mirror) matrix.multiply(new THREE.Matrix4().makeScale(-1, 1, 1)); + if (scale !== 1) matrix.multiply(new THREE.Matrix4().makeScale(scale, scale, scale)); + matrix.multiply(local); + mesh.applyMatrix4(matrix); + mesh.name = dff.frames[wheel.frame].name; + root.add(mesh); + this.countMesh(report, geo); + } + } + + // Upgrade parts are separate clumps hung off a dummy frame in the vehicle. + // Their textures all come from the shared vehicle.txd, which is already in + // this.txds, and their paint masks go through buildMaterial like any other. + addParts(root, dff, plan, report, paint) { + for (const part of plan.parts) { + const upgrade = part.upgrade.dff; + const attach = this.worldMatrix(dff.frames, part.frame, false); + for (const atomic of upgradeAtomics(upgrade)) { + const geo = upgrade.geometries[atomic.geometry]; + if (!geo) continue; + const mesh = this.buildMesh(geo, report, paint); + mesh.applyMatrix4(attach); + mesh.name = part.upgrade.name; + root.add(mesh); + this.countMesh(report, geo); + } + } + } + + // Repaint in place - no reparse, no rebuild. + setPaint(colors) { + if (!this.model) return; + this.paintColors = colors; + const paint = this.makePaint(colors); + this.model.traverse((obj) => { + [].concat(obj.material || []).forEach((material) => { + const slot = material.userData && material.userData.paintSlot; + if (slot === undefined) return; + // A panel under a paintjob shows the artwork, not the body colour. + if (this.paintjob && material.userData.remap) return; + material.color.copy(paint[slot] || paint.fallback); + }); + }); + } + frame() { if (!this.model) return; const box = new THREE.Box3().setFromObject(this.model); diff --git a/server.js b/server.js index 91fd887..0a19398 100644 --- a/server.js +++ b/server.js @@ -11,19 +11,11 @@ const PORT = process.env.PORT || 3000; // otherwise the local models/ folder is used, which is what development does. // MODEL_S3_* names match the other projects; the plain names are accepted too. const S3_BUCKET = process.env.MODEL_S3_BUCKET || process.env.S3_BUCKET || ''; -const S3_PREFIX = normalisePrefix( - process.env.MODEL_S3_KEY_PREFIX || process.env.S3_PREFIX || 'skins/' -); const S3_REGION = process.env.MODEL_S3_REGION || process.env.AWS_REGION; const S3_ACCESS_KEY = process.env.MODEL_S3_ACCESS_KEY_ID || ''; const S3_SECRET_KEY = process.env.MODEL_S3_SECRET_ACCESS_KEY || ''; const MODEL_ROOT = process.env.MODEL_ROOT || path.join(__dirname, 'models'); -// Where the browser fetches model files from. The default keeps them on this -// origin under /models, which nginx can proxy to S3. Point it at a CDN origin -// instead to have the browser fetch them directly. -const MODELS_BASE_URL = (process.env.MODELS_BASE_URL || '/models').replace(/\/+$/, ''); - const INDEX_REFRESH_MS = Number(process.env.INDEX_REFRESH_MS || 15 * 60 * 1000); function normalisePrefix(prefix) { @@ -31,11 +23,37 @@ function normalisePrefix(prefix) { return trimmed ? trimmed + '/' : ''; } -// id -> { dff: true, txd: true }. Built once at boot and refreshed on a timer -// so newly uploaded skins appear without a restart. -let index = new Map(); -let indexError = null; +function normaliseBase(url) { + return url.replace(/\/+$/, ''); +} +// Skins and vehicles are the same thing twice over: a folder (or bucket prefix) +// of .dff / .txd pairs, and a base url the browser fetches them from. +// Vehicles additionally share one texture dictionary between every model. +const collections = { + skin: { + root: MODEL_ROOT, + prefix: normalisePrefix(process.env.MODEL_S3_KEY_PREFIX || process.env.S3_PREFIX || 'skins/'), + baseUrl: normaliseBase(process.env.MODELS_BASE_URL || '/models'), + index: new Map(), + error: null, + }, + vehicle: { + // Not derived from MODEL_ROOT: that points at a folder of skins, which is + // often somewhere else entirely. This is where the extractor writes. + root: process.env.VEHICLE_ROOT || path.join(__dirname, 'models', 'vehicles'), + prefix: normalisePrefix(process.env.VEHICLE_S3_KEY_PREFIX || 'vehicles/'), + baseUrl: normaliseBase(process.env.VEHICLES_BASE_URL || '/models/vehicles'), + index: new Map(), + error: null, + // Written next to the models by tools/extract-vehicles.js. + shared: 'vehicle.txd', + meta: 'vehicles.json', + }, +}; + +// id -> { dff: true, txd: true }. Built once at boot and refreshed on a timer +// so newly uploaded models appear without a restart. function buildIndex(keys, prefix) { const out = new Map(); for (const key of keys) { @@ -47,7 +65,8 @@ function buildIndex(keys, prefix) { entry[match[2].toLowerCase()] = true; out.set(match[1], entry); } - // A txd on its own is not a model. + // A txd on its own is not a model - which is also what drops the shared + // vehicle.txd out of the vehicle index. for (const [id, entry] of out) if (!entry.dff) out.delete(id); return out; } @@ -68,7 +87,7 @@ function getS3Client() { return s3Client; } -async function listBucketKeys() { +async function listBucketKeys(prefix) { const { ListObjectsV2Command } = require('@aws-sdk/client-s3'); const client = getS3Client(); @@ -76,7 +95,7 @@ async function listBucketKeys() { let token; do { const page = await client.send( - new ListObjectsV2Command({ Bucket: S3_BUCKET, Prefix: S3_PREFIX, ContinuationToken: token }) + new ListObjectsV2Command({ Bucket: S3_BUCKET, Prefix: prefix, ContinuationToken: token }) ); for (const object of page.Contents || []) keys.push(object.Key); token = page.IsTruncated ? page.NextContinuationToken : undefined; @@ -84,54 +103,89 @@ async function listBucketKeys() { return keys; } -function listLocalKeys() { - if (!fs.existsSync(MODEL_ROOT)) return []; - return fs.readdirSync(MODEL_ROOT, { withFileTypes: true }) +function listLocalKeys(root) { + if (!fs.existsSync(root)) return []; + return fs.readdirSync(root, { withFileTypes: true }) .filter((entry) => entry.isFile()) .map((entry) => entry.name); } async function refreshIndex() { - try { - index = S3_BUCKET - ? buildIndex(await listBucketKeys(), S3_PREFIX) - : buildIndex(listLocalKeys(), ''); - indexError = null; - } catch (err) { - // Keep serving the previous index rather than going dark on a transient - // S3 error; the next refresh will pick it up. - indexError = err.message; - console.error('model index refresh failed:', err.message); + for (const [name, collection] of Object.entries(collections)) { + try { + collection.index = S3_BUCKET + ? buildIndex(await listBucketKeys(collection.prefix), collection.prefix) + : buildIndex(listLocalKeys(collection.root), ''); + collection.error = null; + } catch (err) { + // Keep serving the previous index rather than going dark on a transient + // S3 error; the next refresh will pick it up. + collection.error = err.message; + console.error(`${name} index refresh failed:`, err.message); + } } } app.use(express.static(path.join(__dirname, 'public'), { index: false })); // In production the model files come from S3 via nginx or a CDN, so this only -// serves them during local development. +// serves them during local development. The vehicle mount comes first because +// the two roots are independent and /models would otherwise swallow the path. if (!S3_BUCKET) { - app.use('/models', express.static(MODEL_ROOT, { immutable: true, maxAge: '365d' })); + // Model files never change under an id, so they cache hard. The catalogue is + // rewritten every time the extractor runs, so it must not. + const cache = { + immutable: true, + maxAge: '365d', + setHeaders(res, file) { + if (file.endsWith('.json')) res.setHeader('Cache-Control', 'no-cache'); + }, + }; + app.use('/models/vehicles', express.static(collections.vehicle.root, cache)); + app.use('/models', express.static(MODEL_ROOT, cache)); } -app.get('/api/skin/:id', (req, res) => { - const entry = index.get(req.params.id); - if (!entry) { - if (!index.size && indexError) { - return res.status(503).json({ error: 'Model index unavailable' }); - } - return res.status(404).json({ error: `No model for "${req.params.id}"` }); +function describe(collection, id) { + const entry = collection.index.get(id); + if (!entry) return null; + const model = { + id, + name: id, + dff: `${collection.baseUrl}/${id}.dff`, + txd: entry.txd ? `${collection.baseUrl}/${id}.txd` : null, + }; + // The shared dictionary holds the grunge, glass, light and tyre textures that + // every vehicle's materials name but no vehicle txd contains. + if (collection.shared) model.shared = `${collection.baseUrl}/${collection.shared}`; + // Upgrade parts are indexed alongside the vehicles - 400-611 against + // 1000-1193 - so the page fetches them straight off the base. + if (collection.meta) { + model.meta = `${collection.baseUrl}/${collection.meta}`; + model.base = collection.baseUrl; } - res.json({ - id: req.params.id, - name: req.params.id, - dff: `${MODELS_BASE_URL}/${req.params.id}.dff`, - txd: entry.txd ? `${MODELS_BASE_URL}/${req.params.id}.txd` : null, - }); -}); + return model; +} -app.get('/samp/skin/:id', (req, res) => { - res.sendFile(path.join(__dirname, 'public', 'viewer.html')); -}); +function serveModel(name) { + const collection = collections[name]; + return (req, res) => { + const model = describe(collection, req.params.id); + if (!model) { + if (!collection.index.size && collection.error) { + return res.status(503).json({ error: 'Model index unavailable' }); + } + return res.status(404).json({ error: `No ${name} for "${req.params.id}"` }); + } + res.json(model); + }; +} + +app.get('/api/skin/:id', serveModel('skin')); +app.get('/api/vehicle/:id', serveModel('vehicle')); + +const viewerPage = (req, res) => res.sendFile(path.join(__dirname, 'public', 'viewer.html')); +app.get('/samp/skin/:id', viewerPage); +app.get('/samp/vehicle/:id', viewerPage); // Nothing else exists - no root page, no catalogue. app.use((req, res) => res.status(404).type('text/plain').send('Not found')); @@ -140,8 +194,9 @@ refreshIndex().then(() => { setInterval(refreshIndex, INDEX_REFRESH_MS).unref(); app.listen(PORT, () => { console.log(`modelviewer -> http://localhost:${PORT}/samp/skin/7`); - console.log(`models from -> ${S3_BUCKET ? `s3://${S3_BUCKET}/${S3_PREFIX}` : MODEL_ROOT}`); - console.log(`served from -> ${MODELS_BASE_URL}`); - console.log(`indexed -> ${index.size} skins`); + console.log(` -> http://localhost:${PORT}/samp/vehicle/411`); + console.log(`models from -> ${S3_BUCKET ? `s3://${S3_BUCKET}/` : MODEL_ROOT}`); + console.log(`indexed -> ${collections.skin.index.size} skins, ` + + `${collections.vehicle.index.size} vehicles`); }); }); diff --git a/tools/extract-vehicles.js b/tools/extract-vehicles.js new file mode 100644 index 0000000..b4998b3 --- /dev/null +++ b/tools/extract-vehicles.js @@ -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 .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(); diff --git a/tools/img.js b/tools/img.js new file mode 100644 index 0000000..3f15dec --- /dev/null +++ b/tools/img.js @@ -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 };