2026-09-01 01:04:57 +01:00
2026-08-29 20:31:31 +01:00
2026-09-01 01:04:57 +01:00
2026-09-01 01:04:57 +01:00
2026-09-01 01:04:57 +01:00
2026-09-01 01:04:57 +01:00
2026-09-01 01:04:57 +01:00
2026-08-29 20:31:31 +01:00
2026-08-29 20:31:31 +01:00
2026-09-01 01:04:57 +01:00
2026-09-01 01:04:57 +01:00

GTA:SA model viewer

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, no build tooling.

/samp/skin/<id> and /samp/vehicle/<id> are the only things the server exposes. The root, /index.html and any other path return 404.

Running

npm install
npm start

Then open http://localhost:3000/samp/skin/1.

PORT and MODEL_ROOT are read from the environment, so you can point it at a folder outside the project:

MODEL_ROOT=C:/samp/skins npm start

See .env.example for the full set, including the S3 options.

Getting the default SA-MP skins

The 312 SA-MP skin ids are just GTA:SA ped model ids, so they come out of your own install rather than off the web:

node tools/extract-peds.js --game "D:/SteamLibrary/steamapps/common/Grand Theft Auto San Andreas"

It reads data/peds.ide for the id -> model mapping, pulls each .dff and .txd out of models/gta3.img and models/player.img, and writes them to models/<id>.dff / models/<id>.txd. --game is optional if the install is in one of the usual places.

That yields 265 skins. Three groups are absent for real reasons:

  • The gaps (1-6, 8, 42, 65, 74, 86, 119, 149, 208, 265-273, 289+) are the ids SA-MP treats as invalid. They are simply not in peds.ide.
  • Ids 290-299 (special01-special10) are empty placeholder slots that servers fill with AddCharModel, so there is nothing to extract.
  • Id 0 (CJ) has no single model. player.dff is an 8KB stub and the game assembles him from the clothing components in player.img at runtime, so the extractor skips him rather than writing a placeholder.

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:

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:

  • 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

Files are named by id: <id>.dff and <id>.txd, in models/ locally or under the bucket prefix on S3. The .txd is optional; without it the model renders untextured, and a .txd with no matching .dff is ignored.

You can drag a .dff and .txd straight onto the page to preview them without filing them first, which is handy for checking a custom skin.

Deployment

The server reads its model list from a local folder by default, or from S3 when S3_BUCKET is set. Either way the list is built once at boot and refreshed on a timer (INDEX_REFRESH_MS, default 15 minutes), so uploading a new skin does not need a restart.

MODELS_BASE_URL controls where the browser fetches model files from:

  • unset — files are requested from /models on this origin, and nginx proxies that to S3 or CloudFront. Same-origin, so no bucket CORS rule.
  • set to a CDN origin — the browser fetches S3 directly and the server stays out of the data path. Needs the CORS rule in deploy/bucket-cors.json.

Ready-to-edit configs live in deploy/: an nginx site that exposes only /samp/skin/, a hardened systemd unit, the bucket CORS rule and a read-only IAM policy.

If you would rather not put AWS credentials on the box at all, drop the @aws-sdk/client-s3 dependency and have /api/skin/:id return the URLs without checking the index. You lose clean 404s for unknown ids — the viewer reports the failed fetch instead.

API

  • GET /api/skin/:id — the URLs for one id, or 404
  • GET /api/vehicle/:id — the same, plus the shared txd and catalogue URLs

What's implemented

public/js/rw/ is a standalone RenderWare reader with no dependencies:

  • stream.js — chunk walker. Every RW chunk is a 12 byte header (type, size, libraryID) followed by its body; containers just hold more chunks. Both formats below are built on this.
  • dff.js — clump, frame hierarchy, geometry (positions, normals, UVs, prelit colours, triangles), material list and texture names.
  • txd.js — texture dictionary, D3D8 and D3D9 rasters.
  • dxt.js — DXT1/3/5 decoder, everything ends up as RGBA8.

Verified against 179 dffs and 127 txds (1383 textures) from a SA-MP cache: all parsed, with every triangle index and material id in range.

Things deliberately left out, in rough order of how likely they are to matter:

  • Animation. Ped dffs store vertices in bind pose, so they render standing still, correctly. Posing them would need the Skin PLG (0x116) and HAnim PLG (0x11E) chunks turned into a THREE.Skeleton plus an IFP reader.

    Bind pose has one non-obvious rule: a skinned geometry's vertices live in the space of its atomic frame's parent, not the atomic frame itself, so that one frame is skipped when placing the mesh. The atomic hangs off Pelvis in most peds but off Root in a handful, which is why neither "always apply the frame" nor "always use identity" works. Checked against all 265 extracted peds: every one stands upright at human height under this rule.

  • Native geometry. PS2/Xbox dffs use a different vertex layout and are rejected with a message rather than parsed. PC files are fine.

  • Bin Mesh PLG. SA stores per-material tristrips in 0x50E, but the plain triangle list is always present too, so the viewer groups that by material instead.

  • Environment/specular maps, UV animation, vehicle paint colours, IMG archives.

S
Description
No description provided
Readme
424 KiB
Languages
JavaScript 98.1%
HTML 1.9%