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
+11
View File
@@ -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 # origin, with nginx proxying to S3. No CORS needed, but the bytes go through
# your box. See the commented block in deploy/nginx.conf. # your box. See the commented block in deploy/nginx.conf.
# --- Vehicles ---------------------------------------------------------------
# Vehicles are a second collection of <id>.dff / <id>.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. # How often to re-list the bucket, in ms. Default 15 minutes.
# INDEX_REFRESH_MS=900000 # INDEX_REFRESH_MS=900000
+4
View File
@@ -4,3 +4,7 @@ node_modules/
# Extracted game assets - keep them out of the repo # Extracted game assets - keep them out of the repo
models/*.dff models/*.dff
models/*.txd models/*.txd
models_rotated_fixed/
rotated-fixed-ids.txt
skin-orientation-audit.txt
models/vehicles/
+134 -2
View File
@@ -5,8 +5,8 @@ Drop a `.dff` and `.txd` into `models/`, open `/samp/skin/<id>`, and it renders.
The RenderWare files are parsed in the browser — no conversion step, no Blender, The RenderWare files are parsed in the browser — no conversion step, no Blender,
no build tooling. no build tooling.
`/samp/skin/<id>` is the only thing the server exposes. The root, `/index.html` `/samp/skin/<id>` and `/samp/vehicle/<id>` are the only things the server
and any other path return 404. exposes. The root, `/index.html` and any other path return 404.
## Running ## 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. Extracted models are gitignored; they're Rockstar's assets, not yours to ship.
## Vehicles
`/samp/vehicle/<id>` renders a vehicle, where `<id>` 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/<id>.dff` / `<id>.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 `<model>1.txd` upwards, extracted zero based to `<id>_pj<n>.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 ## Naming
Files are named by id: `<id>.dff` and `<id>.txd`, in `models/` locally or under Files are named by id: `<id>.dff` and `<id>.txd`, in `models/` locally or under
@@ -88,6 +219,7 @@ failed fetch instead.
## API ## API
- `GET /api/skin/:id` — the URLs for one id, or 404 - `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 ## What's implemented
+5 -2
View File
@@ -4,13 +4,16 @@
{ {
"Effect": "Allow", "Effect": "Allow",
"Action": "s3:GetObject", "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", "Effect": "Allow",
"Action": "s3:ListBucket", "Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::your-bucket", "Resource": "arn:aws:s3:::your-bucket",
"Condition": { "StringLike": { "s3:prefix": "skins/*" } } "Condition": { "StringLike": { "s3:prefix": ["skins/*", "vehicles/*"] } }
} }
] ]
} }
+24 -14
View File
@@ -1,13 +1,27 @@
# /etc/nginx/sites-available/model.southwest-roleplay.dev # /etc/nginx/sites-available/model.southwest-roleplay.dev
# #
# Only /samp/skin/<id> and the assets that page needs are exposed. Everything # Plain HTTP to start with. Run
# else, including the root, returns 404. certbot rewrites this for 443 and # sudo certbot --nginx -d model.southwest-roleplay.dev
# leaves the location blocks untouched. # 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/<id> 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 { server {
listen 80; listen 80;
listen [::]:80;
server_name model.southwest-roleplay.dev; 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/ { location /samp/skin/ {
proxy_pass http://127.0.0.1:3000; proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -31,17 +45,13 @@ server {
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
} }
# Path A only: model files come from S3 through nginx, so they stay # The catch-all below does not block certbot: the nginx plugin inserts a
# same-origin and no bucket CORS rule is needed. Delete this block if you # more specific location for /.well-known/acme-challenge/ while it runs.
# set MODELS_BASE_URL and let the browser fetch the CDN directly. # Only if you switch to the webroot plugin would you need this permanently:
location /models/ { #
proxy_pass https://your-bucket.s3.eu-west-2.amazonaws.com/skins/; # location ^~ /.well-known/acme-challenge/ {
proxy_set_header Host your-bucket.s3.eu-west-2.amazonaws.com; # root /var/www/html;
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";
}
location / { location / {
return 404; return 404;
+141 -19
View File
@@ -20,44 +20,163 @@ async function fetchBuffer(url) {
return res.arrayBuffer(); return res.arrayBuffer();
} }
function render(name, dffBuffer, txdBuffer) { async function fetchJSON(url) {
const dff = parseDFF(dffBuffer); const res = await fetch(url);
const txd = txdBuffer ? parseTXD(txdBuffer) : null; if (!res.ok) throw new Error(`${res.status} fetching ${url}`);
const report = viewer.load(dff, txd); 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( console.log(
`${name}: rw ${dff.versionText}, ${report.meshes} mesh(es), ` + `${name}: rw ${dff.versionText}, ${report.meshes} mesh(es), ` +
`${report.vertices} verts, ${report.triangles} tris, ` + `${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) { if (report.missingTextures.size) {
warnings.push('not in txd: ' + [...report.missingTextures].join(', ')); warnings.push('not in txd: ' + [...report.missingTextures].join(', '));
} }
warnings.forEach((warning) => console.warn(warning)); warnings.forEach((warning) => console.warn(warning));
setError(''); setError('');
return dff;
} }
async function loadSkin(id) { async function loadModel(kind, id) {
try { try {
const res = await fetch(`/api/skin/${encodeURIComponent(id)}`); const res = await fetch(`/api/${kind}/${encodeURIComponent(id)}`);
const meta = await res.json(); const meta = await res.json();
if (!res.ok) { if (!res.ok) {
setError(meta.error || `${res.status} loading ${id}`); setError(meta.error || `${res.status} loading ${id}`);
return; 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), 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) { } catch (err) {
console.error(err); console.error(err);
setError(err.message); 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() { function setupDragAndDrop() {
document.body.addEventListener('dragover', (e) => { document.body.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
@@ -69,17 +188,20 @@ function setupDragAndDrop() {
document.body.classList.remove('dragging'); document.body.classList.remove('dragging');
const files = [...e.dataTransfer.files]; const files = [...e.dataTransfer.files];
const dffFile = files.find((f) => f.name.toLowerCase().endsWith('.dff')); 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) { if (!dffFile) {
setError('drop a .dff (and its .txd) together'); setError('drop a .dff (and its .txd) together');
return; return;
} }
try { try {
render( const name = dffFile.name.replace(/\.dff$/i, '');
dffFile.name.replace(/\.dff$/i, ''), const dffBuffer = await dffFile.arrayBuffer();
await dffFile.arrayBuffer(), const txdBuffers = await Promise.all(txdFiles.map((f) => f.arrayBuffer()));
txdFile ? await txdFile.arrayBuffer() : null
); const vehicle = looksLikeVehicle(parseDFF(dffBuffer));
if (vehicle && txdBuffers.length < 2) txdBuffers.push(await sharedVehicleTxd());
render(name, dffBuffer, txdBuffers, { vehicle });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setError(err.message); setError(err.message);
@@ -89,5 +211,5 @@ function setupDragAndDrop() {
setupDragAndDrop(); setupDragAndDrop();
const match = location.pathname.match(/^\/samp\/skin\/(.+)$/); const match = location.pathname.match(/^\/samp\/(skin|vehicle)\/(.+)$/);
if (match) loadSkin(decodeURIComponent(match[1])); if (match) loadModel(match[1], decodeURIComponent(match[2]));
+149
View File
@@ -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 };
}
+194 -19
View File
@@ -1,5 +1,6 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { OrbitControls } from '/vendor/OrbitControls.js'; import { OrbitControls } from '/vendor/OrbitControls.js';
import { planVehicle, upgradeAtomics, paintSlot, DEFAULT_PAINT } from './vehicle.js';
const MISSING_COLOR = 0xb0b0b0; const MISSING_COLOR = 0xb0b0b0;
@@ -28,6 +29,7 @@ export class Viewer {
this.model = null; this.model = null;
this.textures = []; this.textures = [];
this.txds = [];
window.addEventListener('resize', () => this.resize()); window.addEventListener('resize', () => this.resize());
this.resize(); this.resize();
@@ -57,6 +59,9 @@ export class Viewer {
} }
this.textures.forEach((t) => t.dispose()); this.textures.forEach((t) => t.dispose());
this.textures = []; this.textures = [];
this.txds = [];
this.paintjob = null;
this.paintColors = null;
} }
makeTexture(source) { makeTexture(source) {
@@ -74,19 +79,35 @@ export class Viewer {
return texture; 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 = { const params = {
color: new THREE.Color(material.color[0], material.color[1], material.color[2]), color: new THREE.Color(material.color[0], material.color[1], material.color[2]),
side: THREE.DoubleSide, side: THREE.DoubleSide,
shininess: 8, shininess: 8,
}; };
if (slot >= 0) params.color = paint[slot] || paint.fallback;
if (material.texture && material.texture.name) { if (material.texture && material.texture.name) {
const key = material.texture.name.toLowerCase(); const source = this.findTexture(material.texture.name);
const source = txd && txd.textures.get(key);
if (source) { if (source) {
params.map = this.makeTexture(source); params.map = this.makeTexture(source);
params.color = new THREE.Color(0xffffff); if (slot < 0) params.color = new THREE.Color(0xffffff);
if (source.hasAlpha) { if (source.hasAlpha) {
params.transparent = true; params.transparent = true;
params.alphaTest = 0.35; params.alphaTest = 0.35;
@@ -94,7 +115,7 @@ export class Viewer {
} }
} else { } else {
report.missingTextures.add(material.texture.name); 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.transparent = true;
params.opacity = material.color[3]; 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) { buildGeometry(geo) {
@@ -117,6 +143,23 @@ export class Viewer {
return geometry; 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. // 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 // 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. // the atomic frame's *parent*, so that frame's own transform is skipped.
@@ -136,9 +179,33 @@ export class Viewer {
return matrix; 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.clear();
this.txds = [].concat(txd || []).filter(Boolean);
const report = { missingTextures: new Set(), meshes: 0, triangles: 0, vertices: 0 }; 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(); const root = new THREE.Object3D();
// RenderWare is Z up; three.js is Y up. // RenderWare is Z up; three.js is Y up.
@@ -148,29 +215,137 @@ export class Viewer {
? dff.atomics ? dff.atomics
: dff.geometries.map((_, i) => ({ geometry: i, frame: -1 })); : 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]; const geo = dff.geometries[atomic.geometry];
if (!geo) continue; if (!geo) return;
const mesh = new THREE.Mesh( const mesh = this.buildMesh(geo, report, paint);
this.buildGeometry(geo),
geo.materials.length
? geo.materials.map((m) => this.buildMaterial(m, txd, report))
: new THREE.MeshPhongMaterial({ color: MISSING_COLOR, side: THREE.DoubleSide })
);
mesh.applyMatrix4(this.worldMatrix(dff.frames, atomic.frame, geo.skinned)); 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); root.add(mesh);
report.meshes++; this.countMesh(report, geo);
report.vertices += geo.numVertices; });
report.triangles += geo.indices.length / 3;
if (plan) {
this.addWheels(root, dff, plan, report, paint, options);
this.addParts(root, dff, plan, report, paint);
} }
this.scene.add(root); this.scene.add(root);
this.model = root; this.model = root;
if (options.paintjob) this.applyPaintjob(options.paintjob);
this.frame(); this.frame();
return report; 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() { frame() {
if (!this.model) return; if (!this.model) return;
const box = new THREE.Box3().setFromObject(this.model); const box = new THREE.Box3().setFromObject(this.model);
+105 -50
View File
@@ -11,19 +11,11 @@ const PORT = process.env.PORT || 3000;
// otherwise the local models/ folder is used, which is what development does. // 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. // 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_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_REGION = process.env.MODEL_S3_REGION || process.env.AWS_REGION;
const S3_ACCESS_KEY = process.env.MODEL_S3_ACCESS_KEY_ID || ''; const S3_ACCESS_KEY = process.env.MODEL_S3_ACCESS_KEY_ID || '';
const S3_SECRET_KEY = process.env.MODEL_S3_SECRET_ACCESS_KEY || ''; const S3_SECRET_KEY = process.env.MODEL_S3_SECRET_ACCESS_KEY || '';
const MODEL_ROOT = process.env.MODEL_ROOT || path.join(__dirname, 'models'); 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); const INDEX_REFRESH_MS = Number(process.env.INDEX_REFRESH_MS || 15 * 60 * 1000);
function normalisePrefix(prefix) { function normalisePrefix(prefix) {
@@ -31,11 +23,37 @@ function normalisePrefix(prefix) {
return trimmed ? trimmed + '/' : ''; return trimmed ? trimmed + '/' : '';
} }
// id -> { dff: true, txd: true }. Built once at boot and refreshed on a timer function normaliseBase(url) {
// so newly uploaded skins appear without a restart. return url.replace(/\/+$/, '');
let index = new Map(); }
let indexError = null;
// Skins and vehicles are the same thing twice over: a folder (or bucket prefix)
// of <id>.dff / <id>.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) { function buildIndex(keys, prefix) {
const out = new Map(); const out = new Map();
for (const key of keys) { for (const key of keys) {
@@ -47,7 +65,8 @@ function buildIndex(keys, prefix) {
entry[match[2].toLowerCase()] = true; entry[match[2].toLowerCase()] = true;
out.set(match[1], entry); 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); for (const [id, entry] of out) if (!entry.dff) out.delete(id);
return out; return out;
} }
@@ -68,7 +87,7 @@ function getS3Client() {
return s3Client; return s3Client;
} }
async function listBucketKeys() { async function listBucketKeys(prefix) {
const { ListObjectsV2Command } = require('@aws-sdk/client-s3'); const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
const client = getS3Client(); const client = getS3Client();
@@ -76,7 +95,7 @@ async function listBucketKeys() {
let token; let token;
do { do {
const page = await client.send( 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); for (const object of page.Contents || []) keys.push(object.Key);
token = page.IsTruncated ? page.NextContinuationToken : undefined; token = page.IsTruncated ? page.NextContinuationToken : undefined;
@@ -84,54 +103,89 @@ async function listBucketKeys() {
return keys; return keys;
} }
function listLocalKeys() { function listLocalKeys(root) {
if (!fs.existsSync(MODEL_ROOT)) return []; if (!fs.existsSync(root)) return [];
return fs.readdirSync(MODEL_ROOT, { withFileTypes: true }) return fs.readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isFile()) .filter((entry) => entry.isFile())
.map((entry) => entry.name); .map((entry) => entry.name);
} }
async function refreshIndex() { async function refreshIndex() {
try { for (const [name, collection] of Object.entries(collections)) {
index = S3_BUCKET try {
? buildIndex(await listBucketKeys(), S3_PREFIX) collection.index = S3_BUCKET
: buildIndex(listLocalKeys(), ''); ? buildIndex(await listBucketKeys(collection.prefix), collection.prefix)
indexError = null; : buildIndex(listLocalKeys(collection.root), '');
} catch (err) { collection.error = null;
// Keep serving the previous index rather than going dark on a transient } catch (err) {
// S3 error; the next refresh will pick it up. // Keep serving the previous index rather than going dark on a transient
indexError = err.message; // S3 error; the next refresh will pick it up.
console.error('model index refresh failed:', err.message); collection.error = err.message;
console.error(`${name} index refresh failed:`, err.message);
}
} }
} }
app.use(express.static(path.join(__dirname, 'public'), { index: false })); 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 // 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) { 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) => { function describe(collection, id) {
const entry = index.get(req.params.id); const entry = collection.index.get(id);
if (!entry) { if (!entry) return null;
if (!index.size && indexError) { const model = {
return res.status(503).json({ error: 'Model index unavailable' }); id,
} name: id,
return res.status(404).json({ error: `No model for "${req.params.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({ return model;
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,
});
});
app.get('/samp/skin/:id', (req, res) => { function serveModel(name) {
res.sendFile(path.join(__dirname, 'public', 'viewer.html')); 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. // Nothing else exists - no root page, no catalogue.
app.use((req, res) => res.status(404).type('text/plain').send('Not found')); 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(); setInterval(refreshIndex, INDEX_REFRESH_MS).unref();
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`modelviewer -> http://localhost:${PORT}/samp/skin/7`); console.log(`modelviewer -> http://localhost:${PORT}/samp/skin/7`);
console.log(`models from -> ${S3_BUCKET ? `s3://${S3_BUCKET}/${S3_PREFIX}` : MODEL_ROOT}`); console.log(` -> http://localhost:${PORT}/samp/vehicle/411`);
console.log(`served from -> ${MODELS_BASE_URL}`); console.log(`models from -> ${S3_BUCKET ? `s3://${S3_BUCKET}/` : MODEL_ROOT}`);
console.log(`indexed -> ${index.size} skins`); console.log(`indexed -> ${collections.skin.index.size} skins, ` +
`${collections.vehicle.index.size} vehicles`);
}); });
}); });
+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();
+63
View File
@@ -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 };