11 KiB
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 withAddCharModel, so there is nothing to extract. - Id 0 (CJ) has no single model.
player.dffis 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, frommodels/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 fromdata/carcols.datanddata/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_okandbump_front_damare both in the clump. The_damones 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
wheelatomic, parented to whicheverwheel_XX_dummythe 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, notexhaust. Theexhaustframe is the smoke emitter and sits about a metre behind the car. Across the 31 vehicles that acceptexh_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 onexhaustovershoots 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 againstbump_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
/modelson 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 404GET /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 aTHREE.Skeletonplus 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
Pelvisin most peds but offRootin 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.