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
+6 -4
View File
@@ -487,9 +487,10 @@ namespace
appearance.version = static_cast<std::uint32_t>(*version);
}
}
appearance.raceFormId = ReadOptionalHexFormId(*appearanceObject, "raceFormId");
appearance.hairColorFormId = ReadOptionalHexFormId(*appearanceObject, "hairColorFormId");
appearance.facialHairColorFormId = ReadOptionalHexFormId(*appearanceObject, "facialHairColorFormId");
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();
+8 -3
View File
@@ -2050,9 +2050,10 @@ namespace
a_left.bodyTintColor.g != a_right.bodyTintColor.g ||
a_left.bodyTintColor.b != a_right.bodyTintColor.b ||
a_left.bodyTintColor.a != a_right.bodyTintColor.a ||
a_left.hairColorFormId != a_right.hairColorFormId ||
a_left.facialHairColorFormId != a_right.facialHairColorFormId ||
a_left.headPartFormIds != a_right.headPartFormIds ||
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 ||
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;
if (!appearance.headPartFormIds.empty()) {
// 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.a = static_cast<std::uint8_t>(npc->bodyTintColorA);
if (npc->headRelatedData) {
if (npc->headRelatedData->hairColor) {
appearance.hairColorFormId = npc->headRelatedData->hairColor->GetFormID();
}
if (npc->headRelatedData->facialHairColor) {
appearance.facialHairColorFormId = npc->headRelatedData->facialHairColor->GetFormID();
}
if (npc->headRelatedData) {
if (npc->headRelatedData->hairColor) {
appearance.hairColorFormId = npc->headRelatedData->hairColor->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()) {
if (headPart) {
@@ -354,9 +361,10 @@ namespace
a_left.bodyTintColor.g != a_right.bodyTintColor.g ||
a_left.bodyTintColor.b != a_right.bodyTintColor.b ||
a_left.bodyTintColor.a != a_right.bodyTintColor.a ||
a_left.hairColorFormId != a_right.hairColorFormId ||
a_left.facialHairColorFormId != a_right.facialHairColorFormId ||
a_left.headPartFormIds != a_right.headPartFormIds ||
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 ||
a_left.morphSliders.size() != a_right.morphSliders.size() ||