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]
### 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.
- 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.
+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)
### Summary
+1
View File
@@ -82,6 +82,7 @@ namespace F4T::RemotePlayerState
RemoteAppearanceTintColor bodyTintColor;
std::uint32_t hairColorFormId{};
std::uint32_t facialHairColorFormId{};
std::uint32_t complexionFormId{};
std::vector<std::uint32_t> headPartFormIds;
std::vector<RemoteAppearanceMorph> morphSliders;
std::vector<float> morphRegionSliders;
+3 -1
View File
@@ -490,6 +490,7 @@ namespace
appearance.raceFormId = ReadOptionalHexFormId(*appearanceObject, "raceFormId");
appearance.hairColorFormId = ReadOptionalHexFormId(*appearanceObject, "hairColorFormId");
appearance.facialHairColorFormId = ReadOptionalHexFormId(*appearanceObject, "facialHairColorFormId");
appearance.complexionFormId = ReadOptionalHexFormId(*appearanceObject, "complexionFormId");
if (const auto height = ReadDouble(*appearanceObject, "height")) {
if (std::isfinite(*height) && *height > 0.0 && *height < 10.0) {
@@ -1247,7 +1248,8 @@ namespace F4T::Networking
{ "a", a_appearance->bodyTintColor.a }
} },
{ "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();
+5
View File
@@ -2052,6 +2052,7 @@ namespace
a_left.bodyTintColor.a != a_right.bodyTintColor.a ||
a_left.hairColorFormId != a_right.hairColorFormId ||
a_left.facialHairColorFormId != a_right.facialHairColorFormId ||
a_left.complexionFormId != a_right.complexionFormId ||
a_left.headPartFormIds != a_right.headPartFormIds ||
a_left.morphRegionSliders != a_right.morphRegionSliders ||
a_left.tints != a_right.tints ||
@@ -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;
if (!appearance.headPartFormIds.empty()) {
// Resolve the sender's head parts to local forms first; skip any that the
+8
View File
@@ -260,6 +260,13 @@ namespace
}
}
// 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()) {
if (headPart) {
appearance.headPartFormIds.push_back(headPart->GetFormID());
@@ -356,6 +363,7 @@ namespace
a_left.bodyTintColor.a != a_right.bodyTintColor.a ||
a_left.hairColorFormId != a_right.hairColorFormId ||
a_left.facialHairColorFormId != a_right.facialHairColorFormId ||
a_left.complexionFormId != a_right.complexionFormId ||
a_left.headPartFormIds != a_right.headPartFormIds ||
a_left.morphRegionSliders != a_right.morphRegionSliders ||
a_left.tints != a_right.tints ||
+2
View File
@@ -81,6 +81,7 @@ Example after server processing:
"bodyTintColor": { "r": 255, "g": 220, "b": 190, "a": 255 },
"hairColorFormId": "",
"facialHairColorFormId": "",
"complexionFormId": "",
"headParts": [],
"morphs": [],
"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.
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.
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.
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).
+1
View File
@@ -97,6 +97,7 @@ def get_optional_appearance(packet: dict[str, Any]) -> dict[str, Any] | None:
"bodyTintColor": value.get("bodyTintColor", {}),
"hairColorFormId": value.get("hairColorFormId", ""),
"facialHairColorFormId": value.get("facialHairColorFormId", ""),
"complexionFormId": value.get("complexionFormId", ""),
"headParts": [],
"morphs": [],
"morphRegions": [],
+1
View File
@@ -51,6 +51,7 @@ DEFAULT_APPEARANCE = {
"bodyTintColor": {"r": 255, "g": 220, "b": 190, "a": 255},
"hairColorFormId": "",
"facialHairColorFormId": "",
"complexionFormId": "",
"headParts": [],
"morphs": [],
"morphRegions": [],