Load the player's chosen hair and face morphs in game - #54
Merged
Conversation
zaxxx
approved these changes
Jul 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bug
A character's face and hair were only correct on the character-select screen. In game every player spawned with a hardcoded hair mesh (1757 NewHair1) and no head morphs at all — everyone wore the same default face. Logging in from a second machine also showed a default face at character select, because the morph data was never sent by the server; the creating client was just rendering its own in-memory copy.
Root cause
Two independent gaps, both server-side:
What the data actually looks like
ga_characters.morph_data is the client's CharacterInfoStruct.nMorphSettings array (TgDataInterface.uc:19), sent verbatim in the ADD_PLAYER_CHARACTER DWORDS field: uint32 (node index, weight) pairs, indices 0–73, weights 0–255 with 127 neutral. Confirmed against every row in server.db and against a live packet capture.
Disassembling the client settled the two thiet right.ATgPawn_Character::ApplyMorphSettings (vtable 0x116c74b8 slot 0x964, impl 0x109dc1d0) walks the head anim tree, reads each node's pose id from byte [node+0x99], and indexes r_nMorphSettings with it — so the array is addressed by pose
id, not by array position. It then does cmp se-sent-value, substituting a default weightfor pose ids above the bound: r_nMaxMorphIndexSentFromServer is inclusive, so it gets maxIdx, not maxIdx + 1.
Hair asm ids are gendered — male hair is asmle 850 — and a mismatched pair crashes theclient at 0x109d1f5b on the hair-component deref. Asm 403 PC_CHARBUILD_Bald is character-builder-only and crashes the same way from the in-game pawn-attach pipeline, so it falls back too.
the last piece. A capture of ADD_PLAYER_CHARACTER (0x0082) shows exactly six fields — PROFILE_ID, HEAD_ASM_ID, HAIR_ASM_ID, GENDER_TYPE_VALUE_ID, DWORDS, CALLER_ID — and no SKIN_MATERIAL_PARAMETER_ID or EYE_MATERIAL_PARAMETER_ID, which is why those two columns are 0 for every character that has ever been created. The tone travels inside the morph blob instead: after the pose pairs there is a run of exactly 7 entries with node index 0, matching the builder's GetSkinColorsFromHeadMorphData(out SkinColor, out BaseSkinColor, out SkinTone), and the first of those 7 is the only value that differs between characters. Sending the morph block therefore fixes the tone as a side effect — no new column, no new field.
Changes
src/GameServer/Cosmetics/CosmeticEquip.cpp
src/ControlServer/TcpSession/TcpSession.cpp
on — every column involved already existed and was already populated.
Testing
Two clients on a LAN against one server, VR Arena.
Known cosmetic seam, deliberately not addressed: on the character-select model the face reads its colour from the morph blob while the body reads SkinToneParameterId from the assembly, which stays 0 — so arms render slightly darker than the face there. It does not occur in ga inventing a mapping from the blob's tonebyte onto asm_data_set_material_parameters (type 521) with nothing to validate it against.
Expanded on #17 PR by YPowTecH
Related to: https://discord.com/channels/994691794627461211/1530601026896400464