Article
teaching a game from 2005 to wear clothes it was never given
i wanted custom player skins on an open.mp server, but with players connecting on the stock sa-mp 0.3.7 client. version 0.3.7 has no support for custom models at all. the feature simply does not exist.
the straightforward answer is to use the 0.3dl client, which already handles all of this. i wanted to find out whether the older client could be made to do it instead, so i wrote a client-side .asi plugin that convinces every layer involved to cooperate.
this is a writeup of that plugin. it works, and it gets there by some fairly questionable means.
the plan
open.mp supports custom skins fully, but it only ships them to clients that identify as 0.3dl. that splits the work into two problems:
- the network problem (awkward but tractable): convince open.mp that we are a 0.3dl client, then speak its download protocol correctly.
- the model problem (the harder one): take a downloaded
.dff.txdand make an engine that has no concept of custom skin ids render it anyway, without crashing, on the right ped, with working animations.
skins only for now. objects can wait.
step 1: reporting a different client version
open.mp decides your client version at connect time from a legacy version code and an auth token. the relevant values:
037 = 4057 (0x0FD9)
03DL = 4062 (0x0FDE)
token: SAMPRakNet::GetToken() == (challenge ^ version)so to be treated as dl, the client has to send version 4062 and a token of challenge ^ 4062. both are built inside one function on the client, CNetGame::Packet_ConnectionSucceeded. i disassembled it and found the point where it writes 4057 into the join packet and xors the challenge with the same 4057. both immediates end in the byte 0xD9.
the fix turns out to be very small: change one byte, twice, from 0xD9 to 0xDE. that is the entire masquerade. 4057 becomes 4062, and open.mp starts sending artwork.
i guarded it carefully, because patching arbitrary bytes into someone else's game is a good way to generate confusing bug reports. PatchByte only writes if the byte is still what i expect, so a different build or a double apply refuses instead of corrupting anything.
step 2: the packet layout mismatch
as soon as open.mp believes we are dl, it starts sending dl-format packets. these are mostly identical to 0.3.7 packets, except several of them carry an extra 4-byte field the stock parser knows nothing about. skin assignment is the interesting one.
on the dl wire, ScrSetPlayerSkin is u16 PlayerID, u32 Skin, u32 CustomSkin, which is 80 bits. stock 0.3.7 reads u32 PlayerID, u32 skin, which is 64 bits. the player id widths do not match, so everything after it is read shifted by 16 bits. the game ends up with a garbage skin id and logs that it is not a valid ped model, because it is reading my custom skin id as a ped model.
so i hook ScrSetPlayerSkin, read the dl fields while they are still byte-aligned, store the real custom skin id in a map from playerId to customSkin, then rewrite the packet buffer in place into the 0.3.7 layout and shrink numberOfBitsOfData back to 64. the game then parses a perfectly ordinary, valid base skin, and i still have the custom id for later.
step 3: the artwork download protocol
the protocol itself is a small state machine over six rpc ids:
179 ModelRequest(server tells us about a model)181 RequestDFFand182 RequestTXD(we ask for files we do not have cached)183 ModelUrl(server hands over a url)184 FinishDownload(we report that everything arrived)185 DownloadCompleted(server confirms we may render)
you send an http GET to the url, crc32 the bytes, cache them keyed by checksum, and once every file for every model is verified you fire 184.
one detail cost me an hour: the open.mp built-in webserver returns 401 to any request whose User-Agent is not exactly SAMP/0.3. not a prefix match, not a substring match, exactly that string. my download code was correct and still got rejected repeatedly until i hardcoded the one user-agent the server accepts.
step 4: rendering a model the engine does not know
at this point the network layer works and the files are on disk, crc-verified. what remains is making an engine from 2004 render a model it was never compiled to know about.
the proper approach is to extend the model pool: grow ms_modelInfoPtrs and its neighbours so ids up to 30000 exist, register each custom skin as its own CPedModelInfo, and do the rest of the limit-adjuster work. i looked into it. ms_modelInfoPtrs alone is referenced from 707 places in the exe. relocating that array is a project on its own, so i skipped it.
instead i used clump swapping. every base ped model has a template renderware clump hanging off its model info at offset +0x1C. when the game spawns a ped, it clones that template. so if i point base skin 1's template at my downloaded clump, the next ped spawned as skin 1 clones my custom model instead. the base model id is untouched, the game's own spawn path does all the ped setup, and the result renders correctly.
my first attempt did not work. it crashed on connect, immediately.
the reason was that i tried to be too clever: i took the already-spawned player ped and re-instanced it live with DeleteRwObject followed by CreateRwObject. removing the renderware object from under a ped whose skeleton and animation pointers are already wired up makes the game dereference freed memory. the lesson was to leave live peds alone, swap the template only, and let the game clone it naturally on the next spawn. once i stopped trying to force it, it rendered.
step 5: multiple players and the streaming system
one skin on the local player is a demo. a usable feature needs multiple skins and remote players.
remote players came almost for free. because i rewrite each player's skin down to their base id, a remote player wearing custom 20001 is, as far as the game's ped is concerned, just base skin 1, and base 1's template is already swapped, so they clone the custom clump too. from there i generalised from swapping one skin to swapping every downloaded skin into its own base template, but only for bases a real player is actually wearing. different custom skins on different base ids work fine.
the streaming system was the harder problem.
the trap is ownership. i stored my clump inside the base model's template slot, and gta's streamer owns that slot. so when nobody wears skin 1 for a while and memory gets tight, the streamer unloads model 1 and calls RpClumpDestroy on what it believes is model 1's clump, which is now my clump. my pointer is immediately dangling. freeing it again would be a double free, and re-swapping it would be a use-after-free.
the fix is a per-frame check: i verify whether the template still holds my clump. if it does not, the streamer has already freed it, so i drop my dangling pointer without freeing it again and reload a fresh clump from cache. render::RestoreAll does the same on reconnect or gmx, putting the game's original clump back and freeing only the clumps the streamer did not already free. sharing ownership between two systems that know nothing about each other is as awkward as it sounds.
step 6: why the skin only applied on the second try
this bug was my favourite, because the symptom looked arbitrary. the first time you applied a custom skin it rendered as the plain base skin. run the command a second time and the custom model appeared. every time.
it was a race. when you first wear a skin, its base ped model is not resident yet. sa-mp requests it, the game loads it and rebuilds the ped, all before my per-frame swap gets a chance to run. so the first ped is cloned from the un-swapped template, and my swap lands one beat too late. the second time, the base model is already loaded and already swapped, so it renders.
two fixes, both unremarkable in the useful way. first, swap the template inside the ScrSetPlayerSkin hook (render::EnsureSwapForCustom), before the game rebuilds the ped. second, keep every custom skin's base model pinned resident with CStreaming::RequestModel, so the swap always has a loaded template to land on. it now renders on the first attempt, warm or cold.
step 7: verifying addresses in ida
i was about to add a per-ped hook, working from the assumption that CEntity::CreateRwObject lived at 0x532B70. i would have hooked it, shipped it, and then spent an evening wondering why nothing happened.
so i opened the exe in ida and checked. 0x532B70 is not CreateRwObject. it is a boolean predicate that reads the model type flags, inspects 2d effects and returns a bool. hooking it would have done nothing useful, and i would have suspected every part of the code except the address. in the same session i confirmed the real CStreaming::RequestModel at 0x4087E0 by watching it index the streaming info array with a stride of 0x14 and modify the per-model flag byte, which is unmistakable once you see it.
the lesson is simple: your memory of an address you read once months ago is not reliable. verify it in the disassembler.
step 8: moving downloads off the game thread
early on, the download was synchronous, running inside the rpc handler on the game thread. that was fine for a 50kb placeholder on localhost, and not fine at all for a real 4mb skin over the internet, where the client would freeze mid-connect while wininet worked.
so downloads moved to a background worker. dl::Enqueue puts a job on a queue, one detached worker thread fetches and crc-checks serially, and the main thread collects finished results each frame via artwork::Pump. the worker never touches the model list or any game state, it only owns its file and the two queues, so there is no shared-state hazard to reason about. the game thread stays responsive and the connect no longer looks like a hang.
step 9: shipping it
finally, some ci, so i stop building this by hand under wine. a manual build workflow produces the .asi as an artifact, and a manual release workflow, triggered with a version string, builds it, zips the .asi together with the readme and license, and cuts a github release. both are trigger-only, because a plugin that patches a game's memory should not publish itself every time i fix a typo in a comment.
what i learned
- byte patches are remarkably powerful. two bytes turned a 0.3.7 client into a client that open.mp treats as 0.3dl.
- the hard part was never the network. it was convincing a rigid, 20-year-old model system to hold a pointer it does not own, without either side noticing.
- most of my bugs were about ownership and timing rather than logic: who frees this, and is the thing i need loaded yet.
- open the disassembler before you assume you know an address. every time.
is any of this a sensible way to add custom skins? not really. but it works, and the parts that were difficult were difficult for reasons worth writing down.
next, probably objects.