5.1 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> is the only thing 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.
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 404
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.