Skip to content

Load the player's chosen hair and face morphs in game - #54

Merged
zaxxx merged 1 commit into
commonwealthga:masterfrom
abielsaf:character-config-fix
Jul 25, 2026
Merged

Load the player's chosen hair and face morphs in game#54
zaxxx merged 1 commit into
commonwealthga:masterfrom
abielsaf:character-config-fix

Conversation

@abielsaf

Copy link
Copy Markdown
Collaborator

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:

  1. CosmeticEquip::LoadFromDB builds the pawn's baseline r_CustomCharacterAssembly. It read head_asm_id, gender_type_value_id and profile_id from ga_characters, but hardcoded hairMesh = 1757 and never touched r_nMorphSettings. The columns it needed — hair_asm_id and morph_data — were already populated at character creation and simply never read back.
  2. GSC_CHARACTER_LIST carried the assembly fields and the equipped cosmetics, but no morph payload. TgUICharacterSelectScene.UpdateCustomModel feeds the preview model from two separate sources — SetCustomAssembly(...) and PopulateCurrentMorphSettings() — and we

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

  • Widened the ga_characters read to include hair_asm_id and morph_data.
  • Scatter the morph pairs into r_nMorphSettings[idx] and set r_nMaxMorphIndexSentFromServer = maxIdx (inclusive). No blob leaves it at -1, i.e. the client's own defaults.
  • Hair comes from hair_asm_id; 0 and 403 fall back to a gender-matched default (1757 male / 1974 female) instead of the unconditional 1757.

src/ControlServer/TcpSession/TcpSession.cpp

  • send_character_list_response gains a fourth block, DATA_SET_CHAR_MORPH_SETTINGS, one record per pair: CHARACTER_ID + MORPH_NODE_INDEX + MORPH_NODE_WEIGHT, mirrorYER_INVENTORY record shape. Outer item count 3 → 4.
  • Comment recording the confirmed ADD_PLAYER_CHARACTER field set, so the next person doesn't re-hunt for a skin-tone tag that isn't there.
    on — every column involved already existed and was already populated.

Testing

Two clients on a LAN against one server, VR Arena.

  • In game, both clients: correct face morphsskin tone, on the client that created thecharacter and on the one that never saw it. Female character with hair_asm_id 1941 (type 850) and male characters on type-596 hair — no hair-attach crash on eith
  • Character select before the change: correct face only on the creating client, default skin tone everywhere.
  • Character select after the change: correct face and tone on a client that had never created either character — GSC_CHARACTER_LIST sent 152 morph rows for 2 characters, 3553-byte payload over 3 chunks, no client-side error and no GET_MORPH_SETTINGS (0x0156) request, confirming the character-list block is the entire delivery path.

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

@zaxxx
zaxxx merged commit 9723d0f into commonwealthga:master Jul 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants