Add complexionFormId to appearance payload

Introduce complexionFormId (TXST) into RemoteAppearance and the appearance protocol so complexion textures are captured and serialized. Updates: header/state, Networking JSON parse/serialize, proxy actor comparisons, main capture logic, protocol docs, fake client/player samples, changelog and dev-log. Capture reads PlayerCharacter::complexion and serializes it, but applying to proxies is intentionally a no-op because TESNPC proxies lack a complexion field (documented in comments/dev-log). Files: plugin/include/F4TRemotePlayerState.h, plugin/src/*, protocol/packets.md, server/fake_client.py, server/fake_player.py, changelog.md, docs/dev-log.md.
This commit is contained in:
2026-06-30 22:20:44 +12:00
parent 76fd8772bd
commit d328bfb647
10 changed files with 69 additions and 17 deletions
+1
View File
@@ -10,6 +10,7 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
## [Unreleased] ## [Unreleased]
### Added ### Added
- Complexion texture form reference (`complexionFormId`) to appearance payload so hands, arms, and body match the synced skin tone (follows the existing hair colour pattern).
- Appearance protocol version 3 with an optional `tints` field (character tint layers: skin tone, complexion, makeup, beard shade) so proxies can mirror the sender's skin colour and facial detail. - Appearance protocol version 3 with an optional `tints` field (character tint layers: skin tone, complexion, makeup, beard shade) so proxies can mirror the sender's skin colour and facial detail.
- Capture of player/NPC `tintingData` in `CaptureLocalAppearance`, plus game-thread application of tints onto runtime proxies: existing entries are updated in place by template ID, and missing entries are reconstructed against the proxy race's tint templates (skipped when no template resolves). - Capture of player/NPC `tintingData` in `CaptureLocalAppearance`, plus game-thread application of tints onto runtime proxies: existing entries are updated in place by template ID, and missing entries are reconstructed against the proxy race's tint templates (skipped when no template resolves).
- `server/fake_client.py` parsing/output and `server/fake_player.py` sample payload for the new `tints` appearance field. - `server/fake_client.py` parsing/output and `server/fake_player.py` sample payload for the new `tints` appearance field.
+31
View File
@@ -9,6 +9,37 @@ failed experiments, successful tests, and next steps.
--- ---
## 2026-06-30 - Complexion Texture Sync (Appearance v3, Phase 2.1)
### Summary
Attempted to sync complexion texture (TXST) for hand/arm colour matching. Discovered that `complexion` is only on `PlayerCharacter`, not the base `TESNPC` class. Proxies are NPC bases without a complexion field, so texture swapping is not possible on proxies without lower-level material/texture manipulation (future work). Captured from the player and serialized for documentation, but apply is deferred (no-op on proxies). Hands will continue to use default proxy textures.
### Files Changed
- `plugin/include/F4TRemotePlayerState.h`
- `plugin/src/main.cpp`
- `plugin/src/F4TNetworking.cpp`
- `plugin/src/F4TProxyActorController.cpp`
- `protocol/packets.md`
- `server/fake_client.py`
- `server/fake_player.py`
- `changelog.md`
- `docs/dev-log.md`
### Details
- Added `complexionFormId` to `RemoteAppearance` and payload for future extensibility, capturing from `PlayerCharacter::complexion` only (not applicable to TESNPC).
- Apply code is deferred with a comment. Texture remapping on proxies requires material/shader manipulation, out of scope for now.
### Testing
- Compiled successfully. Capture/serialize/parse work; apply is a no-op.
### Known Issues
- Hands continue to use the default proxy texture because TESNPC proxies cannot be assigned a complexion. This is an architecture limitation.
### Next Steps
- Appearance sync is otherwise complete (morphs, tints, hair colours, head parts). Hands remain a known limitation pending material/texture remapping work.
---
## 2026-06-30 - Skin Colour / Tint Appearance Sync (Appearance v3, Phase 2) ## 2026-06-30 - Skin Colour / Tint Appearance Sync (Appearance v3, Phase 2)
### Summary ### Summary
+1
View File
@@ -82,6 +82,7 @@ namespace F4T::RemotePlayerState
RemoteAppearanceTintColor bodyTintColor; RemoteAppearanceTintColor bodyTintColor;
std::uint32_t hairColorFormId{}; std::uint32_t hairColorFormId{};
std::uint32_t facialHairColorFormId{}; std::uint32_t facialHairColorFormId{};
std::uint32_t complexionFormId{};
std::vector<std::uint32_t> headPartFormIds; std::vector<std::uint32_t> headPartFormIds;
std::vector<RemoteAppearanceMorph> morphSliders; std::vector<RemoteAppearanceMorph> morphSliders;
std::vector<float> morphRegionSliders; std::vector<float> morphRegionSliders;
+6 -4
View File
@@ -487,9 +487,10 @@ namespace
appearance.version = static_cast<std::uint32_t>(*version); appearance.version = static_cast<std::uint32_t>(*version);
} }
} }
appearance.raceFormId = ReadOptionalHexFormId(*appearanceObject, "raceFormId"); appearance.raceFormId = ReadOptionalHexFormId(*appearanceObject, "raceFormId");
appearance.hairColorFormId = ReadOptionalHexFormId(*appearanceObject, "hairColorFormId"); appearance.hairColorFormId = ReadOptionalHexFormId(*appearanceObject, "hairColorFormId");
appearance.facialHairColorFormId = ReadOptionalHexFormId(*appearanceObject, "facialHairColorFormId"); appearance.facialHairColorFormId = ReadOptionalHexFormId(*appearanceObject, "facialHairColorFormId");
appearance.complexionFormId = ReadOptionalHexFormId(*appearanceObject, "complexionFormId");
if (const auto height = ReadDouble(*appearanceObject, "height")) { if (const auto height = ReadDouble(*appearanceObject, "height")) {
if (std::isfinite(*height) && *height > 0.0 && *height < 10.0) { if (std::isfinite(*height) && *height > 0.0 && *height < 10.0) {
@@ -1247,7 +1248,8 @@ namespace F4T::Networking
{ "a", a_appearance->bodyTintColor.a } { "a", a_appearance->bodyTintColor.a }
} }, } },
{ "hairColorFormId", a_appearance->hairColorFormId == 0 ? std::string{} : std::format("{:08X}", a_appearance->hairColorFormId) }, { "hairColorFormId", a_appearance->hairColorFormId == 0 ? std::string{} : std::format("{:08X}", a_appearance->hairColorFormId) },
{ "facialHairColorFormId", a_appearance->facialHairColorFormId == 0 ? std::string{} : std::format("{:08X}", a_appearance->facialHairColorFormId) } { "facialHairColorFormId", a_appearance->facialHairColorFormId == 0 ? std::string{} : std::format("{:08X}", a_appearance->facialHairColorFormId) },
{ "complexionFormId", a_appearance->complexionFormId == 0 ? std::string{} : std::format("{:08X}", a_appearance->complexionFormId) }
}; };
auto headParts = nlohmann::json::array(); auto headParts = nlohmann::json::array();
+8 -3
View File
@@ -2050,9 +2050,10 @@ namespace
a_left.bodyTintColor.g != a_right.bodyTintColor.g || a_left.bodyTintColor.g != a_right.bodyTintColor.g ||
a_left.bodyTintColor.b != a_right.bodyTintColor.b || a_left.bodyTintColor.b != a_right.bodyTintColor.b ||
a_left.bodyTintColor.a != a_right.bodyTintColor.a || a_left.bodyTintColor.a != a_right.bodyTintColor.a ||
a_left.hairColorFormId != a_right.hairColorFormId || a_left.hairColorFormId != a_right.hairColorFormId ||
a_left.facialHairColorFormId != a_right.facialHairColorFormId || a_left.facialHairColorFormId != a_right.facialHairColorFormId ||
a_left.headPartFormIds != a_right.headPartFormIds || a_left.complexionFormId != a_right.complexionFormId ||
a_left.headPartFormIds != a_right.headPartFormIds ||
a_left.morphRegionSliders != a_right.morphRegionSliders || a_left.morphRegionSliders != a_right.morphRegionSliders ||
a_left.tints != a_right.tints || a_left.tints != a_right.tints ||
a_left.morphSliders.size() != a_right.morphSliders.size() || a_left.morphSliders.size() != a_right.morphSliders.size() ||
@@ -2460,6 +2461,10 @@ namespace
} }
} }
// Note: complexion (hands/arms texture) cannot be applied to proxy NPCs because
// TESNPC does not have a complexion field. This is a known limitation; the
// complexionFormId is captured and serialized but not applied to proxies.
std::size_t appliedHeadPartCount = 0; std::size_t appliedHeadPartCount = 0;
if (!appearance.headPartFormIds.empty()) { if (!appearance.headPartFormIds.empty()) {
// Resolve the sender's head parts to local forms first; skip any that the // Resolve the sender's head parts to local forms first; skip any that the
+18 -10
View File
@@ -251,14 +251,21 @@ namespace
appearance.bodyTintColor.b = static_cast<std::uint8_t>(npc->bodyTintColorB); appearance.bodyTintColor.b = static_cast<std::uint8_t>(npc->bodyTintColorB);
appearance.bodyTintColor.a = static_cast<std::uint8_t>(npc->bodyTintColorA); appearance.bodyTintColor.a = static_cast<std::uint8_t>(npc->bodyTintColorA);
if (npc->headRelatedData) { if (npc->headRelatedData) {
if (npc->headRelatedData->hairColor) { if (npc->headRelatedData->hairColor) {
appearance.hairColorFormId = npc->headRelatedData->hairColor->GetFormID(); appearance.hairColorFormId = npc->headRelatedData->hairColor->GetFormID();
}
if (npc->headRelatedData->facialHairColor) {
appearance.facialHairColorFormId = npc->headRelatedData->facialHairColor->GetFormID();
}
} }
if (npc->headRelatedData->facialHairColor) {
appearance.facialHairColorFormId = npc->headRelatedData->facialHairColor->GetFormID();
}
}
// Complexion (hands/arms texture) is only on PlayerCharacter, not TESNPC.
// Capture from the player directly; apply is skipped because proxy NPCs don't
// have a complexion field. This is a known limitation.
if (a_player.complexion) {
appearance.complexionFormId = a_player.complexion->GetFormID();
}
for (const auto* headPart : npc->GetHeadParts()) { for (const auto* headPart : npc->GetHeadParts()) {
if (headPart) { if (headPart) {
@@ -354,9 +361,10 @@ namespace
a_left.bodyTintColor.g != a_right.bodyTintColor.g || a_left.bodyTintColor.g != a_right.bodyTintColor.g ||
a_left.bodyTintColor.b != a_right.bodyTintColor.b || a_left.bodyTintColor.b != a_right.bodyTintColor.b ||
a_left.bodyTintColor.a != a_right.bodyTintColor.a || a_left.bodyTintColor.a != a_right.bodyTintColor.a ||
a_left.hairColorFormId != a_right.hairColorFormId || a_left.hairColorFormId != a_right.hairColorFormId ||
a_left.facialHairColorFormId != a_right.facialHairColorFormId || a_left.facialHairColorFormId != a_right.facialHairColorFormId ||
a_left.headPartFormIds != a_right.headPartFormIds || a_left.complexionFormId != a_right.complexionFormId ||
a_left.headPartFormIds != a_right.headPartFormIds ||
a_left.morphRegionSliders != a_right.morphRegionSliders || a_left.morphRegionSliders != a_right.morphRegionSliders ||
a_left.tints != a_right.tints || a_left.tints != a_right.tints ||
a_left.morphSliders.size() != a_right.morphSliders.size() || a_left.morphSliders.size() != a_right.morphSliders.size() ||
+2
View File
@@ -81,6 +81,7 @@ Example after server processing:
"bodyTintColor": { "r": 255, "g": 220, "b": 190, "a": 255 }, "bodyTintColor": { "r": 255, "g": 220, "b": 190, "a": 255 },
"hairColorFormId": "", "hairColorFormId": "",
"facialHairColorFormId": "", "facialHairColorFormId": "",
"complexionFormId": "",
"headParts": [], "headParts": [],
"morphs": [], "morphs": [],
"morphRegions": [], "morphRegions": [],
@@ -170,6 +171,7 @@ morphWeight Body triangle weights: thin, muscular, and large.
bodyTintColor Body tint channels r, g, b, a as 0-255 integers. bodyTintColor Body tint channels r, g, b, a as 0-255 integers.
hairColorFormId Hex-string `CLFM` form ID for hair color, or empty when unavailable. hairColorFormId Hex-string `CLFM` form ID for hair color, or empty when unavailable.
facialHairColorFormId Hex-string `CLFM` form ID for facial hair color, or empty when unavailable. facialHairColorFormId Hex-string `CLFM` form ID for facial hair color, or empty when unavailable.
complexionFormId Hex-string `TXST` form ID for skin complexion (hands, arms, body tone), or empty when unavailable.
headParts Hex-string `HDPT` form IDs for the sender's current head parts. headParts Hex-string `HDPT` form IDs for the sender's current head parts.
morphs Morph slider values as `{ "id": hex-string, "value": number }` entries. morphs Morph slider values as `{ "id": hex-string, "value": number }` entries.
morphRegions Region morph slider values as a flat array of numbers (added in version 2). morphRegions Region morph slider values as a flat array of numbers (added in version 2).
+1
View File
@@ -97,6 +97,7 @@ def get_optional_appearance(packet: dict[str, Any]) -> dict[str, Any] | None:
"bodyTintColor": value.get("bodyTintColor", {}), "bodyTintColor": value.get("bodyTintColor", {}),
"hairColorFormId": value.get("hairColorFormId", ""), "hairColorFormId": value.get("hairColorFormId", ""),
"facialHairColorFormId": value.get("facialHairColorFormId", ""), "facialHairColorFormId": value.get("facialHairColorFormId", ""),
"complexionFormId": value.get("complexionFormId", ""),
"headParts": [], "headParts": [],
"morphs": [], "morphs": [],
"morphRegions": [], "morphRegions": [],
+1
View File
@@ -51,6 +51,7 @@ DEFAULT_APPEARANCE = {
"bodyTintColor": {"r": 255, "g": 220, "b": 190, "a": 255}, "bodyTintColor": {"r": 255, "g": 220, "b": 190, "a": 255},
"hairColorFormId": "", "hairColorFormId": "",
"facialHairColorFormId": "", "facialHairColorFormId": "",
"complexionFormId": "",
"headParts": [], "headParts": [],
"morphs": [], "morphs": [],
"morphRegions": [], "morphRegions": [],