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:
@@ -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.
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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() ||
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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": [],
|
||||||
|
|||||||
@@ -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": [],
|
||||||
|
|||||||
Reference in New Issue
Block a user