2026-08-29 20:49:27 +01:00
2026-08-29 20:31:31 +01:00
2026-08-29 20:31:31 +01:00
2026-08-29 20:31:31 +01:00
2026-08-29 20:49:27 +01:00
2026-08-29 20:31:31 +01:00
2026-08-29 20:31:31 +01:00
2026-08-29 20:31:31 +01:00
2026-08-29 20:31:31 +01:00
2026-08-29 20:49:27 +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> 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 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.

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

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%