Vehicles
This commit is contained in:
@@ -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,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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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]));
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
for (const [name, collection] of Object.entries(collections)) {
|
||||||
try {
|
try {
|
||||||
index = S3_BUCKET
|
collection.index = S3_BUCKET
|
||||||
? buildIndex(await listBucketKeys(), S3_PREFIX)
|
? buildIndex(await listBucketKeys(collection.prefix), collection.prefix)
|
||||||
: buildIndex(listLocalKeys(), '');
|
: buildIndex(listLocalKeys(collection.root), '');
|
||||||
indexError = null;
|
collection.error = null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Keep serving the previous index rather than going dark on a transient
|
// Keep serving the previous index rather than going dark on a transient
|
||||||
// S3 error; the next refresh will pick it up.
|
// S3 error; the next refresh will pick it up.
|
||||||
indexError = err.message;
|
collection.error = err.message;
|
||||||
console.error('model index refresh failed:', 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 = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(503).json({ error: 'Model index unavailable' });
|
||||||
}
|
}
|
||||||
return res.status(404).json({ error: `No model for "${req.params.id}"` });
|
return res.status(404).json({ error: `No ${name} for "${req.params.id}"` });
|
||||||
}
|
}
|
||||||
res.json({
|
res.json(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) => {
|
app.get('/api/skin/:id', serveModel('skin'));
|
||||||
res.sendFile(path.join(__dirname, 'public', 'viewer.html'));
|
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`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// Pull the vehicle models out of a GTA:SA install.
|
||||||
|
//
|
||||||
|
// node tools/extract-vehicles.js --game "D:/.../Grand Theft Auto San Andreas"
|
||||||
|
//
|
||||||
|
// data/vehicles.ide gives the id -> model/txd mapping and data/carcols.dat the
|
||||||
|
// paint palette. Upgrade parts come from data/maps/veh_mods/veh_mods.ide, and
|
||||||
|
// data/carmods.dat says which ones each vehicle accepts. models/gta3.img holds
|
||||||
|
// every file. Everything lands in models/vehicles/ as <id>.dff / <id>.txd -
|
||||||
|
// vehicles are 400-611 and upgrades 1000-1193, so one flat folder does - next
|
||||||
|
// to the shared vehicle.txd and a vehicles.json describing the lot.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { Img } = require('./img.js');
|
||||||
|
|
||||||
|
const USUAL_PLACES = [
|
||||||
|
'C:/Program Files (x86)/Rockstar Games/GTA San Andreas',
|
||||||
|
'C:/Program Files/Rockstar Games/GTA San Andreas',
|
||||||
|
'C:/Program Files (x86)/Steam/steamapps/common/Grand Theft Auto San Andreas',
|
||||||
|
'D:/SteamLibrary/steamapps/common/Grand Theft Auto San Andreas',
|
||||||
|
'D:/Games/GTA San Andreas',
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = { game: '', out: path.join(__dirname, '..', 'models', 'vehicles') };
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
if (argv[i] === '--game') args.game = argv[++i];
|
||||||
|
else if (argv[i] === '--out') args.out = argv[++i];
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGame(given) {
|
||||||
|
const candidates = given ? [given] : USUAL_PLACES;
|
||||||
|
for (const dir of candidates) {
|
||||||
|
if (fs.existsSync(path.join(dir, 'data', 'vehicles.ide'))) return dir;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
given
|
||||||
|
? `no data/vehicles.ide under ${given}`
|
||||||
|
: 'could not find a GTA:SA install - pass --game "<path>"'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both ide and dat files are comma or whitespace separated, with # comments and
|
||||||
|
// named sections closed by 'end'.
|
||||||
|
function readSections(file) {
|
||||||
|
const sections = new Map();
|
||||||
|
let current = null;
|
||||||
|
for (let line of fs.readFileSync(file, 'latin1').split(/\r?\n/)) {
|
||||||
|
line = line.replace(/#.*$/, '').trim();
|
||||||
|
if (!line) continue;
|
||||||
|
if (!current) {
|
||||||
|
current = { name: line.toLowerCase(), lines: [] };
|
||||||
|
sections.set(current.name, current.lines);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.toLowerCase() === 'end') { current = null; continue; }
|
||||||
|
current.lines.push(line);
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three retail vehicles.ide lines (emperor, wayfarer, dodo) drop the comma
|
||||||
|
// between the model and txd names, so whitespace separates fields too - which
|
||||||
|
// is what the game's own parser does. No field we read contains a space.
|
||||||
|
function fields(line) {
|
||||||
|
return line.split(/[,\s]+/).map((f) => f.trim()).filter((f) => f !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVehicles(gameDir) {
|
||||||
|
const cars = readSections(path.join(gameDir, 'data', 'vehicles.ide')).get('cars') || [];
|
||||||
|
return cars.map((line) => {
|
||||||
|
const f = fields(line);
|
||||||
|
// Field 12 is the wheel scale for every vehicle type: cars append the wheel
|
||||||
|
// model id and two scales, planes append a LOD model id after them.
|
||||||
|
const wheelScale = Number(f[12]);
|
||||||
|
return {
|
||||||
|
id: Number(f[0]),
|
||||||
|
model: f[1],
|
||||||
|
txd: f[2],
|
||||||
|
type: f[3],
|
||||||
|
gameName: f[5] || f[1],
|
||||||
|
wheelScale: Number.isFinite(wheelScale) ? wheelScale : 1,
|
||||||
|
};
|
||||||
|
}).filter((v) => Number.isFinite(v.id) && v.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// carcols has a 'col' palette, a 'car' section of primary/secondary pairs and a
|
||||||
|
// 'car4' section for the handful of vehicles that use four slots.
|
||||||
|
function parseCarcols(gameDir) {
|
||||||
|
const sections = readSections(path.join(gameDir, 'data', 'carcols.dat'));
|
||||||
|
|
||||||
|
const palette = (sections.get('col') || []).map((line) => fields(line).map(Number).slice(0, 3));
|
||||||
|
|
||||||
|
const combos = new Map();
|
||||||
|
const collect = (name, width) => {
|
||||||
|
for (const line of sections.get(name) || []) {
|
||||||
|
const f = fields(line);
|
||||||
|
const model = f[0].toLowerCase();
|
||||||
|
const ids = f.slice(1).map(Number).filter(Number.isFinite);
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i + width <= ids.length; i += width) out.push(ids.slice(i, i + width));
|
||||||
|
if (out.length) combos.set(model, out);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collect('car', 2);
|
||||||
|
collect('car4', 4); // wins where a model appears in both
|
||||||
|
|
||||||
|
return { palette, combos };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The upgrade parts are ordinary objects in their own ide, ids 1000-1193, all
|
||||||
|
// of them pointing at the shared vehicle txd.
|
||||||
|
function parseUpgrades(gameDir) {
|
||||||
|
const file = path.join(gameDir, 'data', 'maps', 'veh_mods', 'veh_mods.ide');
|
||||||
|
const objs = readSections(file).get('objs') || [];
|
||||||
|
return objs.map((line) => {
|
||||||
|
const f = fields(line);
|
||||||
|
return { id: Number(f[0]), model: f[1] };
|
||||||
|
}).filter((u) => Number.isFinite(u.id) && u.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// carmods lists which parts each vehicle accepts ('mods'), which left/right
|
||||||
|
// parts are fitted as a pair ('link'), and the wheel shop line-ups ('wheel').
|
||||||
|
function parseCarmods(gameDir) {
|
||||||
|
const sections = readSections(path.join(gameDir, 'data', 'carmods.dat'));
|
||||||
|
|
||||||
|
const mods = new Map();
|
||||||
|
for (const line of sections.get('mods') || []) {
|
||||||
|
const f = fields(line);
|
||||||
|
// Retail comments the bike lines out, so they never reach here.
|
||||||
|
if (f.length > 1) mods.set(f[0].toLowerCase(), f.slice(1).map((n) => n.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = new Map();
|
||||||
|
for (const line of sections.get('link') || []) {
|
||||||
|
const f = fields(line);
|
||||||
|
if (f.length === 2) links.set(f[0].toLowerCase(), f[1].toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mods, links };
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const gameDir = findGame(args.game);
|
||||||
|
console.log(`game -> ${gameDir}`);
|
||||||
|
|
||||||
|
const vehicles = parseVehicles(gameDir);
|
||||||
|
const { palette, combos } = parseCarcols(gameDir);
|
||||||
|
const upgrades = parseUpgrades(gameDir);
|
||||||
|
const { mods, links } = parseCarmods(gameDir);
|
||||||
|
console.log(
|
||||||
|
`data -> ${vehicles.length} vehicles, ${upgrades.length} upgrades, ` +
|
||||||
|
`${palette.length} palette colours`
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.mkdirSync(args.out, { recursive: true });
|
||||||
|
|
||||||
|
// The five textures every vehicle shares - grunge, glass, lights, tyres -
|
||||||
|
// live here rather than in the per-vehicle txd, so without it most of a car
|
||||||
|
// renders untextured. The upgrade parts use it as their only dictionary.
|
||||||
|
const sharedTxd = path.join(gameDir, 'models', 'generic', 'vehicle.txd');
|
||||||
|
if (!fs.existsSync(sharedTxd)) throw new Error(`missing ${sharedTxd}`);
|
||||||
|
fs.copyFileSync(sharedTxd, path.join(args.out, 'vehicle.txd'));
|
||||||
|
|
||||||
|
const img = new Img(path.join(gameDir, 'models', 'gta3.img'));
|
||||||
|
const missing = [];
|
||||||
|
|
||||||
|
const byModel = new Map();
|
||||||
|
const upgradeMeta = {};
|
||||||
|
let upgradesWritten = 0;
|
||||||
|
for (const upgrade of upgrades) {
|
||||||
|
const dff = img.read(`${upgrade.model}.dff`);
|
||||||
|
if (!dff) { missing.push(`${upgrade.id} ${upgrade.model}.dff`); continue; }
|
||||||
|
fs.writeFileSync(path.join(args.out, `${upgrade.id}.dff`), dff);
|
||||||
|
upgradeMeta[upgrade.id] = { name: upgrade.model };
|
||||||
|
byModel.set(upgrade.model.toLowerCase(), upgrade.id);
|
||||||
|
upgradesWritten++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {};
|
||||||
|
let written = 0;
|
||||||
|
for (const vehicle of vehicles) {
|
||||||
|
const dff = img.read(`${vehicle.model}.dff`);
|
||||||
|
if (!dff) { missing.push(`${vehicle.id} ${vehicle.model}.dff`); continue; }
|
||||||
|
fs.writeFileSync(path.join(args.out, `${vehicle.id}.dff`), dff);
|
||||||
|
|
||||||
|
const txd = img.read(`${vehicle.txd}.txd`);
|
||||||
|
if (txd) fs.writeFileSync(path.join(args.out, `${vehicle.id}.txd`), txd);
|
||||||
|
|
||||||
|
// A paintjob is a one texture dictionary sitting next to the vehicle in the
|
||||||
|
// archive as <model>1.txd upwards. Written out zero based, because that is
|
||||||
|
// what ChangeVehiclePaintjob counts from. Thirteen vehicles have any.
|
||||||
|
let paintjobs = 0;
|
||||||
|
for (let n = 1; ; n++) {
|
||||||
|
const job = img.read(`${vehicle.model}${n}.txd`);
|
||||||
|
if (!job) break;
|
||||||
|
fs.writeFileSync(path.join(args.out, `${vehicle.id}_pj${n - 1}.txd`), job);
|
||||||
|
paintjobs++;
|
||||||
|
}
|
||||||
|
|
||||||
|
meta[vehicle.id] = {
|
||||||
|
name: vehicle.model,
|
||||||
|
gameName: vehicle.gameName,
|
||||||
|
type: vehicle.type,
|
||||||
|
wheelScale: vehicle.wheelScale,
|
||||||
|
paintjobs,
|
||||||
|
colors: combos.get(vehicle.model.toLowerCase()) || [],
|
||||||
|
mods: (mods.get(vehicle.model.toLowerCase()) || [])
|
||||||
|
.map((name) => byModel.get(name))
|
||||||
|
.filter((id) => id !== undefined),
|
||||||
|
};
|
||||||
|
written++;
|
||||||
|
}
|
||||||
|
img.close();
|
||||||
|
|
||||||
|
// Wheels are not in any vehicle's mods list because any wheel fits anything.
|
||||||
|
const wheels = upgrades
|
||||||
|
.filter((u) => /^wheel_/.test(u.model) && upgradeMeta[u.id])
|
||||||
|
.map((u) => u.id);
|
||||||
|
|
||||||
|
const linkIds = {};
|
||||||
|
for (const [left, right] of links) {
|
||||||
|
if (byModel.has(left) && byModel.has(right)) linkIds[byModel.get(left)] = byModel.get(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(args.out, 'vehicles.json'),
|
||||||
|
JSON.stringify({ palette, vehicles: meta, upgrades: upgradeMeta, wheels, links: linkIds }, null, 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`written -> ${written} vehicles, ${upgradesWritten} upgrades into ${args.out}`);
|
||||||
|
if (missing.length) console.log(`missing -> ${missing.length}: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// IMG archive reader.
|
||||||
|
//
|
||||||
|
// San Andreas ships VER2 archives: the 'VER2' tag, a u32 entry count, then one
|
||||||
|
// 32 byte directory entry per file -
|
||||||
|
// u32 offset, in 2048 byte sectors
|
||||||
|
// u16 streaming size, u16 size in archive (zero on retail, so prefer the former)
|
||||||
|
// char name[24], null padded
|
||||||
|
// Bodies follow the directory, each starting on a sector boundary.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const SECTOR = 2048;
|
||||||
|
|
||||||
|
class Img {
|
||||||
|
constructor(file) {
|
||||||
|
this.fd = fs.openSync(file, 'r');
|
||||||
|
this.path = file;
|
||||||
|
|
||||||
|
const header = Buffer.alloc(8);
|
||||||
|
fs.readSync(this.fd, header, 0, 8, 0);
|
||||||
|
if (header.toString('ascii', 0, 4) !== 'VER2') {
|
||||||
|
throw new Error(`${file} is not a VER2 img archive`);
|
||||||
|
}
|
||||||
|
const count = header.readUInt32LE(4);
|
||||||
|
|
||||||
|
const dir = Buffer.alloc(count * 32);
|
||||||
|
fs.readSync(this.fd, dir, 0, dir.length, 8);
|
||||||
|
|
||||||
|
// Lower cased, because the ide names and the archive names disagree on case.
|
||||||
|
this.entries = new Map();
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const at = i * 32;
|
||||||
|
const name = dir.toString('ascii', at + 8, at + 32).split('\0')[0];
|
||||||
|
const sectors = dir.readUInt16LE(at + 4) || dir.readUInt16LE(at + 6);
|
||||||
|
this.entries.set(name.toLowerCase(), {
|
||||||
|
name,
|
||||||
|
offset: dir.readUInt32LE(at) * SECTOR,
|
||||||
|
size: sectors * SECTOR,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
has(name) {
|
||||||
|
return this.entries.has(name.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry bodies are padded out to the sector size, and every reader downstream
|
||||||
|
// walks chunk headers rather than trusting the length, so the padding is
|
||||||
|
// harmless and gets written as-is.
|
||||||
|
read(name) {
|
||||||
|
const entry = this.entries.get(name.toLowerCase());
|
||||||
|
if (!entry) return null;
|
||||||
|
const buffer = Buffer.alloc(entry.size);
|
||||||
|
fs.readSync(this.fd, buffer, 0, entry.size, entry.offset);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
fs.closeSync(this.fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Img };
|
||||||
Reference in New Issue
Block a user