Character Appearance Phase 1 Done
Bump appearance payloads to version 2 and add `morphRegions` and `facialBoneMorphs` capture, serialization, and proxy application. Also fix head-part syncing when sender and proxy counts differ by reallocating the proxy array instead of deferring the whole list, and update the Python harness and protocol docs to match.
This commit is contained in:
@@ -10,6 +10,9 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Appearance protocol version 2 with optional `morphRegions` (region morph slider values) and `facialBoneMorphs` (bone-based facial morph transforms) fields so proxies receive advanced face-shape morphs that previously did not sync.
|
||||||
|
- Capture of `TESNPC::morphRegionSliderValues` and `TESNPC::facialBoneRegionSliderValues` in `CaptureLocalAppearance`, plus game-thread application of both onto runtime proxies in `ApplyProxyAppearanceFromRemoteState`.
|
||||||
|
- `server/fake_client.py` parsing/output and `server/fake_player.py` sample payloads for the new `morphRegions` and `facialBoneMorphs` appearance fields.
|
||||||
- Optional `appearance` transform snapshots for best-effort runtime proxy body and face visuals, including height, body morph weight, body tint, hair colors, head-part IDs, and morph slider values while leaving gender switching out.
|
- Optional `appearance` transform snapshots for best-effort runtime proxy body and face visuals, including height, body morph weight, body tint, hair colors, head-part IDs, and morph slider values while leaving gender switching out.
|
||||||
- Game-thread proxy appearance application for supported `appearance` fields after runtime proxy 3D is ready, with race switching deferred for future proxy-base compatibility work.
|
- Game-thread proxy appearance application for supported `appearance` fields after runtime proxy 3D is ready, with race switching deferred for future proxy-base compatibility work.
|
||||||
- `server/fake_client.py` parsing/output and `server/fake_player.py` sample payloads for relayed `appearance` transform data.
|
- `server/fake_client.py` parsing/output and `server/fake_player.py` sample payloads for relayed `appearance` transform data.
|
||||||
@@ -29,6 +32,8 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
|
|||||||
- `server/fake_client.py` parsing and output for relayed `equippedItems` transform data.
|
- `server/fake_client.py` parsing and output for relayed `equippedItems` transform data.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Proxy head parts (including hair) now apply even when the sender's head-part count differs from the proxy base; the head-part array is reallocated with the game allocator instead of deferring the whole list on a count mismatch.
|
||||||
|
- Appearance apply log now reports applied-vs-sent head-part counts (`headParts=applied/sent`) so deferred or partial head-part application is visible.
|
||||||
- New clients now receive existing players' last transform snapshots immediately after `welcome`, including `appearance`, so late joiners do not start with default proxy visuals while waiting for the next sender heartbeat.
|
- New clients now receive existing players' last transform snapshots immediately after `welcome`, including `appearance`, so late joiners do not start with default proxy visuals while waiting for the next sender heartbeat.
|
||||||
- Runtime proxy candidate isolation now ignores actors flagged as deleted, so a freshly spawned proxy is controlled instead of the slot adopting a still-present stale proxy that was marked for deletion the same frame.
|
- Runtime proxy candidate isolation now ignores actors flagged as deleted, so a freshly spawned proxy is controlled instead of the slot adopting a still-present stale proxy that was marked for deletion the same frame.
|
||||||
- Stale saved runtime `COPlayerProxy` actors are deleted before spawning a fresh PlaceAtMe proxy, preventing duplicate visible proxies when an old uncontrolled proxy persisted in a save.
|
- Stale saved runtime `COPlayerProxy` actors are deleted before spawning a fresh PlaceAtMe proxy, preventing duplicate visible proxies when an old uncontrolled proxy persisted in a save.
|
||||||
|
|||||||
Binary file not shown.
@@ -9,6 +9,73 @@ failed experiments, successful tests, and next steps.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-30 - Head-Part Count Mismatch Fix (Hair Sync)
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
After Phase 1 morph sync, two-client testing showed an asymmetric result: Client 2's proxy of Client 1 got morphs and hair, but Client 1's proxy of Client 2 got morphs but no hair. The log showed `Deferred head-part appearance for remote player 2; proxy count=12 packet count=14`. The apply code only overwrote head parts when the proxy base count exactly matched the sender's count, otherwise it deferred the entire head-part list (so hair, eyes, brows, etc. all stayed default). Player 2 had 14 head parts vs the proxy base's 12, so nothing applied; Player 1 happened to have 12, matching the base, so it worked the other direction. This is a pre-existing limitation, not a Phase 2 (tint) concern.
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `plugin/src/F4TProxyActorController.cpp`
|
||||||
|
- `changelog.md`
|
||||||
|
- `docs/dev-log.md`
|
||||||
|
|
||||||
|
### Details
|
||||||
|
- `ApplyProxyAppearanceFromRemoteState` now resolves all sender head parts first (skipping any unresolved local forms), then either overwrites in place when the count matches or reallocates `npc->headParts` with `RE::calloc` and updates `npc->numHeadParts` when it differs.
|
||||||
|
- The previous head-part array is intentionally not freed, to avoid touching memory that may be shared with the base template or an alternate head-part list. Count-changing appearance updates are rare and deduped, so the bounded leak is acceptable for the prototype.
|
||||||
|
- The apply success log now reports `headParts=applied/sent` instead of just the sent count, which previously masked the deferral.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Static review and linter only; no in-game run yet.
|
||||||
|
- Recommended manual check: reproduce the prior two-client scenario (characters with differing head-part counts) and confirm both proxies now show hair, with a log line like `headParts=14/14` and no `Deferred head-part appearance` warning.
|
||||||
|
- Watch for any head/face instability after the array reallocation across repeated appearance changes.
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- Head parts, morphs, and tints are still written to the proxy's base `TESNPC`; with 3+ remote players sharing a single proxy base on one client, appearances could clobber. Not exercised by the current two-player test.
|
||||||
|
- Skin colour/tint sync still pending Phase 2.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- Continue with Phase 2 (tint/skin colour) per the appearance plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-30 - Facial Morph Appearance Sync (Appearance v2, Phase 1)
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Investigated a report that synced proxies were "just the default proxy", with skin colour, facial hair, and some facial morphs not transferring. Root cause: `CaptureLocalAppearance` only copied a subset of `TESNPC` face data. Three face-defining members were never captured or applied: `morphRegionSliderValues`, `facialBoneRegionSliderValues`, and `tintingData`. The currently-synced `morphSliderValues` is only a partial morph set, so only some morphs transferred. This change is Phase 1 of a phased fix: it adds the two safe, plain-data morph payloads. Skin colour/tint reconstruction (`tintingData`) is deferred to Phase 2 because it requires high-risk game-thread reconstruction of polymorphic `BGSCharacterTint` entries.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- Bumped the appearance payload `version` default to 2. New fields are optional/additive; receivers tolerate their absence and keep existing proxy morph data.
|
||||||
|
- Added `morphRegions` (flat array of region slider floats) and `facialBoneMorphs` (`{ id, position[3], rotation[3], scale[3] }`) to the appearance snapshot.
|
||||||
|
- Capture reads `npc->morphRegionSliderValues` (`BSTArray<float>`) and `npc->facialBoneRegionSliderValues` (`BSTHashMap<uint32, BGSCharacterMorph::Transform>`) with null guards.
|
||||||
|
- Apply allocates/clears the matching `TESNPC` containers on the proxy and fills them, then reuses `RefreshProxyAfterAppearanceChange` to trigger the existing head/face 3D refresh. Followed the existing `new`-on-null pattern used for `morphSliderValues`.
|
||||||
|
- Equality checks in both `AreAppearanceStatesEqual` copies (main.cpp and the proxy controller) compare the new fields so changes still trigger resend/reapply.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Static review only so far; no in-game run yet.
|
||||||
|
- Recommended: `python -m py_compile server\fake_client.py server\fake_player.py` for the harness changes.
|
||||||
|
- Recommended manual check: run the Python server, connect two Fallout 4 instances, and confirm Client 1's proxy of Client 2 now picks up advanced face-shape morphs that previously did not transfer, with no new appearance warnings in `CommonwealthOnline.log`.
|
||||||
|
- Backwards compatibility: confirm version 1 packets (without `morphRegions`/`facialBoneMorphs`) still apply without error.
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- Skin colour, complexion, and beard shade still will not sync until Phase 2 (`tintingData` reconstruction) lands.
|
||||||
|
- In-game confirmation of the new morph fields is still needed.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- Phase 2: capture `PlayerCharacter::tintingData`, resolve the proxy race's tint templates via `TESRace::FaceRelatedData::tintingTemplate`, and reconstruct `BGSCharacterTint` entries on the proxy to fix skin colour and beard shade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-06-29 - Late-Join Appearance Snapshot Fix
|
## 2026-06-29 - Late-Join Appearance Snapshot Fix
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
@@ -49,9 +49,19 @@ namespace F4T::RemotePlayerState
|
|||||||
float large{};
|
float large{};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bone-based facial morph (TESNPC::facialBoneRegionSliderValues entry).
|
||||||
|
// Mirrors BGSCharacterMorph::Transform (position, rotation, scale).
|
||||||
|
struct RemoteAppearanceFacialBoneMorph
|
||||||
|
{
|
||||||
|
std::uint32_t id{};
|
||||||
|
float position[3]{};
|
||||||
|
float rotation[3]{};
|
||||||
|
float scale[3]{ 1.0F, 1.0F, 1.0F };
|
||||||
|
};
|
||||||
|
|
||||||
struct RemoteAppearance
|
struct RemoteAppearance
|
||||||
{
|
{
|
||||||
std::uint32_t version{ 1 };
|
std::uint32_t version{ 2 };
|
||||||
std::uint32_t raceFormId{};
|
std::uint32_t raceFormId{};
|
||||||
float height{ 1.0F };
|
float height{ 1.0F };
|
||||||
RemoteAppearanceMorphWeight morphWeight;
|
RemoteAppearanceMorphWeight morphWeight;
|
||||||
@@ -60,6 +70,8 @@ namespace F4T::RemotePlayerState
|
|||||||
std::uint32_t facialHairColorFormId{};
|
std::uint32_t facialHairColorFormId{};
|
||||||
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<RemoteAppearanceFacialBoneMorph> facialBoneMorphs;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct RemotePlayerState
|
struct RemotePlayerState
|
||||||
|
|||||||
@@ -427,6 +427,26 @@ namespace
|
|||||||
return static_cast<std::uint8_t>(std::clamp(*value, 0.0, 255.0));
|
return static_cast<std::uint8_t>(std::clamp(*value, 0.0, 255.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ReadFloat3(const Json& a_object, const char* a_fieldName, float (&a_out)[3])
|
||||||
|
{
|
||||||
|
const auto field = a_object.find(a_fieldName);
|
||||||
|
if (field == a_object.end() || !field->is_array()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto count = std::min<std::size_t>(field->size(), 3);
|
||||||
|
for (std::size_t index = 0; index < count; ++index) {
|
||||||
|
const auto& element = (*field)[index];
|
||||||
|
if (!element.is_number()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const auto number = element.get<double>();
|
||||||
|
if (std::isfinite(number)) {
|
||||||
|
a_out[index] = static_cast<float>(number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
std::uint32_t ReadOptionalHexFormId(const Json& a_object, const char* a_fieldName)
|
std::uint32_t ReadOptionalHexFormId(const Json& a_object, const char* a_fieldName)
|
||||||
{
|
{
|
||||||
const auto text = ReadString(a_object, a_fieldName);
|
const auto text = ReadString(a_object, a_fieldName);
|
||||||
@@ -523,6 +543,43 @@ namespace
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (const auto morphRegions = appearanceObject->find("morphRegions");
|
||||||
|
morphRegions != appearanceObject->end() && morphRegions->is_array()) {
|
||||||
|
for (const auto& value : *morphRegions) {
|
||||||
|
if (!value.is_number()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const auto number = value.get<double>();
|
||||||
|
appearance.morphRegionSliders.push_back(std::isfinite(number) ? static_cast<float>(number) : 0.0F);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (const auto facialBoneMorphs = appearanceObject->find("facialBoneMorphs");
|
||||||
|
facialBoneMorphs != appearanceObject->end() && facialBoneMorphs->is_array()) {
|
||||||
|
for (const auto& morph : *facialBoneMorphs) {
|
||||||
|
if (!morph.is_object()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto idText = ReadString(morph, "id");
|
||||||
|
if (!idText) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto id = ParseHexFormId(*idText);
|
||||||
|
if (!id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
F4T::RemotePlayerState::RemoteAppearanceFacialBoneMorph boneMorph{};
|
||||||
|
boneMorph.id = *id;
|
||||||
|
ReadFloat3(morph, "position", boneMorph.position);
|
||||||
|
ReadFloat3(morph, "rotation", boneMorph.rotation);
|
||||||
|
ReadFloat3(morph, "scale", boneMorph.scale);
|
||||||
|
appearance.facialBoneMorphs.push_back(boneMorph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return appearance;
|
return appearance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1171,6 +1228,24 @@ namespace F4T::Networking
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
appearance["morphs"] = std::move(morphs);
|
appearance["morphs"] = std::move(morphs);
|
||||||
|
|
||||||
|
auto morphRegions = nlohmann::json::array();
|
||||||
|
for (const auto value : a_appearance->morphRegionSliders) {
|
||||||
|
morphRegions.push_back(value);
|
||||||
|
}
|
||||||
|
appearance["morphRegions"] = std::move(morphRegions);
|
||||||
|
|
||||||
|
auto facialBoneMorphs = nlohmann::json::array();
|
||||||
|
for (const auto& morph : a_appearance->facialBoneMorphs) {
|
||||||
|
facialBoneMorphs.push_back({
|
||||||
|
{ "id", std::format("{:08X}", morph.id) },
|
||||||
|
{ "position", { morph.position[0], morph.position[1], morph.position[2] } },
|
||||||
|
{ "rotation", { morph.rotation[0], morph.rotation[1], morph.rotation[2] } },
|
||||||
|
{ "scale", { morph.scale[0], morph.scale[1], morph.scale[2] } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
appearance["facialBoneMorphs"] = std::move(facialBoneMorphs);
|
||||||
|
|
||||||
packet["appearance"] = std::move(appearance);
|
packet["appearance"] = std::move(appearance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2052,7 +2052,9 @@ namespace
|
|||||||
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.headPartFormIds != a_right.headPartFormIds ||
|
||||||
a_left.morphSliders.size() != a_right.morphSliders.size()) {
|
a_left.morphRegionSliders != a_right.morphRegionSliders ||
|
||||||
|
a_left.morphSliders.size() != a_right.morphSliders.size() ||
|
||||||
|
a_left.facialBoneMorphs.size() != a_right.facialBoneMorphs.size()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2063,6 +2065,17 @@ namespace
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (std::size_t index = 0; index < a_left.facialBoneMorphs.size(); ++index) {
|
||||||
|
const auto& left = a_left.facialBoneMorphs[index];
|
||||||
|
const auto& right = a_right.facialBoneMorphs[index];
|
||||||
|
if (left.id != right.id ||
|
||||||
|
!std::equal(std::begin(left.position), std::end(left.position), std::begin(right.position)) ||
|
||||||
|
!std::equal(std::begin(left.rotation), std::end(left.rotation), std::begin(right.rotation)) ||
|
||||||
|
!std::equal(std::begin(left.scale), std::end(left.scale), std::begin(right.scale))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2380,8 +2393,12 @@ namespace
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::size_t appliedHeadPartCount = 0;
|
||||||
if (!appearance.headPartFormIds.empty()) {
|
if (!appearance.headPartFormIds.empty()) {
|
||||||
if (npc->headParts && npc->numHeadParts == static_cast<std::int8_t>(appearance.headPartFormIds.size())) {
|
// Resolve the sender's head parts to local forms first; skip any that the
|
||||||
|
// receiver cannot resolve so a missing form never aborts the whole list.
|
||||||
|
std::vector<RE::BGSHeadPart*> resolvedHeadParts;
|
||||||
|
resolvedHeadParts.reserve(appearance.headPartFormIds.size());
|
||||||
for (std::size_t index = 0; index < appearance.headPartFormIds.size(); ++index) {
|
for (std::size_t index = 0; index < appearance.headPartFormIds.size(); ++index) {
|
||||||
auto* headPart = RE::TESForm::GetFormByID<RE::BGSHeadPart>(appearance.headPartFormIds[index]);
|
auto* headPart = RE::TESForm::GetFormByID<RE::BGSHeadPart>(appearance.headPartFormIds[index]);
|
||||||
if (!headPart) {
|
if (!headPart) {
|
||||||
@@ -2393,20 +2410,46 @@ namespace
|
|||||||
a_remotePlayer.playerId));
|
a_remotePlayer.playerId));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
resolvedHeadParts.push_back(headPart);
|
||||||
|
}
|
||||||
|
|
||||||
if (npc->headParts[index] != headPart) {
|
if (!resolvedHeadParts.empty()) {
|
||||||
npc->headParts[index] = headPart;
|
const auto desiredCount = resolvedHeadParts.size();
|
||||||
|
const bool countMatches =
|
||||||
|
npc->headParts && static_cast<std::size_t>(npc->numHeadParts) == desiredCount;
|
||||||
|
|
||||||
|
if (countMatches) {
|
||||||
|
for (std::size_t index = 0; index < desiredCount; ++index) {
|
||||||
|
if (npc->headParts[index] != resolvedHeadParts[index]) {
|
||||||
|
npc->headParts[index] = resolvedHeadParts[index];
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
appliedHeadPartCount = desiredCount;
|
||||||
|
} else {
|
||||||
|
// Counts differ from the proxy base, so reallocate the head-part
|
||||||
|
// array with the game allocator (the array is freed by the game's
|
||||||
|
// MemoryManager). The previous array is intentionally left for the
|
||||||
|
// game to manage rather than freed here, to avoid touching memory
|
||||||
|
// that may be shared with the base template or an alternate list.
|
||||||
|
auto* newHeadParts = RE::calloc<RE::BGSHeadPart*>(desiredCount);
|
||||||
|
if (newHeadParts) {
|
||||||
|
for (std::size_t index = 0; index < desiredCount; ++index) {
|
||||||
|
newHeadParts[index] = resolvedHeadParts[index];
|
||||||
|
}
|
||||||
|
npc->headParts = newHeadParts;
|
||||||
|
npc->numHeadParts = static_cast<std::int8_t>(desiredCount);
|
||||||
|
appliedHeadPartCount = desiredCount;
|
||||||
|
changed = true;
|
||||||
} else {
|
} else {
|
||||||
LogThrottledWarning(
|
LogThrottledWarning(
|
||||||
"proxy_appearance_headpart_count_mismatch_" + std::to_string(a_remotePlayer.playerId),
|
"proxy_appearance_headpart_alloc_failed_" + std::to_string(a_remotePlayer.playerId),
|
||||||
std::format(
|
std::format(
|
||||||
"Deferred head-part appearance for remote player {}; proxy count={} packet count={}.",
|
"Could not resize head-part array for remote player {}; allocation of {} entries failed.",
|
||||||
a_remotePlayer.playerId,
|
a_remotePlayer.playerId,
|
||||||
static_cast<int>(npc->numHeadParts),
|
desiredCount));
|
||||||
appearance.headPartFormIds.size()));
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2423,15 +2466,42 @@ namespace
|
|||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!appearance.morphRegionSliders.empty()) {
|
||||||
|
if (!npc->morphRegionSliderValues) {
|
||||||
|
npc->morphRegionSliderValues = new RE::BSTArray<float>();
|
||||||
|
}
|
||||||
|
npc->morphRegionSliderValues->clear();
|
||||||
|
for (const auto value : appearance.morphRegionSliders) {
|
||||||
|
npc->morphRegionSliderValues->push_back(std::isfinite(value) ? value : 0.0F);
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!appearance.facialBoneMorphs.empty()) {
|
||||||
|
if (!npc->facialBoneRegionSliderValues) {
|
||||||
|
npc->facialBoneRegionSliderValues = new RE::BSTHashMap<std::uint32_t, RE::BGSCharacterMorph::Transform>();
|
||||||
|
}
|
||||||
|
npc->facialBoneRegionSliderValues->clear();
|
||||||
|
for (const auto& morph : appearance.facialBoneMorphs) {
|
||||||
|
RE::BGSCharacterMorph::Transform transform{};
|
||||||
|
transform.position = { morph.position[0], morph.position[1], morph.position[2] };
|
||||||
|
transform.rotation = { morph.rotation[0], morph.rotation[1], morph.rotation[2] };
|
||||||
|
transform.scale = { morph.scale[0], morph.scale[1], morph.scale[2] };
|
||||||
|
npc->facialBoneRegionSliderValues->insert({ morph.id, transform });
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
a_slot.hasAppliedAppearanceState = true;
|
a_slot.hasAppliedAppearanceState = true;
|
||||||
a_slot.lastAppliedAppearance = appearance;
|
a_slot.lastAppliedAppearance = appearance;
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
RefreshProxyAfterAppearanceChange(a_slot, a_proxy);
|
RefreshProxyAfterAppearanceChange(a_slot, a_proxy);
|
||||||
LogInfoWithLocalPlayerPrefix(std::format(
|
LogInfoWithLocalPlayerPrefix(std::format(
|
||||||
"Applied synced appearance to runtime proxy actor {:08X} for remote player {}: headParts={}, morphs={}.",
|
"Applied synced appearance to runtime proxy actor {:08X} for remote player {}: headParts={}/{}, morphs={}.",
|
||||||
a_proxy.GetFormID(),
|
a_proxy.GetFormID(),
|
||||||
a_remotePlayer.playerId,
|
a_remotePlayer.playerId,
|
||||||
|
appliedHeadPartCount,
|
||||||
appearance.headPartFormIds.size(),
|
appearance.headPartFormIds.size(),
|
||||||
appearance.morphSliders.size()));
|
appearance.morphSliders.size()));
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-1
@@ -15,6 +15,7 @@
|
|||||||
#include "RE/B/BSFixedString.h"
|
#include "RE/B/BSFixedString.h"
|
||||||
#include "RE/I/IAnimationGraphManagerHolder.h"
|
#include "RE/I/IAnimationGraphManagerHolder.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
@@ -66,6 +67,7 @@ namespace
|
|||||||
using RemoteEquippedItem = F4T::RemotePlayerState::RemoteEquippedItem;
|
using RemoteEquippedItem = F4T::RemotePlayerState::RemoteEquippedItem;
|
||||||
using RemoteAppearance = F4T::RemotePlayerState::RemoteAppearance;
|
using RemoteAppearance = F4T::RemotePlayerState::RemoteAppearance;
|
||||||
using RemoteAppearanceMorph = F4T::RemotePlayerState::RemoteAppearanceMorph;
|
using RemoteAppearanceMorph = F4T::RemotePlayerState::RemoteAppearanceMorph;
|
||||||
|
using RemoteAppearanceFacialBoneMorph = F4T::RemotePlayerState::RemoteAppearanceFacialBoneMorph;
|
||||||
|
|
||||||
struct SyncedEquipmentSlot
|
struct SyncedEquipmentSlot
|
||||||
{
|
{
|
||||||
@@ -271,6 +273,30 @@ namespace
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (npc->morphRegionSliderValues) {
|
||||||
|
for (const auto& value : *npc->morphRegionSliderValues) {
|
||||||
|
appearance.morphRegionSliders.push_back(std::isfinite(value) ? value : 0.0F);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (npc->facialBoneRegionSliderValues) {
|
||||||
|
for (const auto& region : *npc->facialBoneRegionSliderValues) {
|
||||||
|
const auto& transform = region.second;
|
||||||
|
RemoteAppearanceFacialBoneMorph morph{};
|
||||||
|
morph.id = region.first;
|
||||||
|
morph.position[0] = transform.position.x;
|
||||||
|
morph.position[1] = transform.position.y;
|
||||||
|
morph.position[2] = transform.position.z;
|
||||||
|
morph.rotation[0] = transform.rotation.x;
|
||||||
|
morph.rotation[1] = transform.rotation.y;
|
||||||
|
morph.rotation[2] = transform.rotation.z;
|
||||||
|
morph.scale[0] = transform.scale.x;
|
||||||
|
morph.scale[1] = transform.scale.y;
|
||||||
|
morph.scale[2] = transform.scale.z;
|
||||||
|
appearance.facialBoneMorphs.push_back(morph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return appearance;
|
return appearance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +332,9 @@ namespace
|
|||||||
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.headPartFormIds != a_right.headPartFormIds ||
|
||||||
a_left.morphSliders.size() != a_right.morphSliders.size()) {
|
a_left.morphRegionSliders != a_right.morphRegionSliders ||
|
||||||
|
a_left.morphSliders.size() != a_right.morphSliders.size() ||
|
||||||
|
a_left.facialBoneMorphs.size() != a_right.facialBoneMorphs.size()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +345,17 @@ namespace
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (std::size_t index = 0; index < a_left.facialBoneMorphs.size(); ++index) {
|
||||||
|
const auto& left = a_left.facialBoneMorphs[index];
|
||||||
|
const auto& right = a_right.facialBoneMorphs[index];
|
||||||
|
if (left.id != right.id ||
|
||||||
|
!std::equal(std::begin(left.position), std::end(left.position), std::begin(right.position)) ||
|
||||||
|
!std::equal(std::begin(left.rotation), std::end(left.rotation), std::begin(right.rotation)) ||
|
||||||
|
!std::equal(std::begin(left.scale), std::end(left.scale), std::begin(right.scale))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -74,7 +74,7 @@ Example after server processing:
|
|||||||
{ "slot": "headband", "formId": "" }
|
{ "slot": "headband", "formId": "" }
|
||||||
],
|
],
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"raceFormId": "00013746",
|
"raceFormId": "00013746",
|
||||||
"height": 1.0,
|
"height": 1.0,
|
||||||
"morphWeight": { "thin": 0.0, "muscular": 0.0, "large": 0.0 },
|
"morphWeight": { "thin": 0.0, "muscular": 0.0, "large": 0.0 },
|
||||||
@@ -82,7 +82,9 @@ Example after server processing:
|
|||||||
"hairColorFormId": "",
|
"hairColorFormId": "",
|
||||||
"facialHairColorFormId": "",
|
"facialHairColorFormId": "",
|
||||||
"headParts": [],
|
"headParts": [],
|
||||||
"morphs": []
|
"morphs": [],
|
||||||
|
"morphRegions": [],
|
||||||
|
"facialBoneMorphs": []
|
||||||
},
|
},
|
||||||
"serverTime": 1780212128.3011043
|
"serverTime": 1780212128.3011043
|
||||||
}
|
}
|
||||||
@@ -160,7 +162,7 @@ receivers keep the proxy's existing/default body and face. When present, it is a
|
|||||||
best-effort snapshot for male-proxy-compatible runtime visuals:
|
best-effort snapshot for male-proxy-compatible runtime visuals:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
version Appearance payload version. Current value is 1.
|
version Appearance payload version. Current value is 2.
|
||||||
raceFormId Hex-string race form ID captured for diagnostics/future use; receivers do not switch proxy race yet.
|
raceFormId Hex-string race form ID captured for diagnostics/future use; receivers do not switch proxy race yet.
|
||||||
height NPC height scalar.
|
height NPC height scalar.
|
||||||
morphWeight Body triangle weights: thin, muscular, and large.
|
morphWeight Body triangle weights: thin, muscular, and large.
|
||||||
@@ -169,8 +171,14 @@ hairColorFormId Hex-string `CLFM` form ID for hair color, or empty when un
|
|||||||
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.
|
||||||
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).
|
||||||
|
facialBoneMorphs Bone-based facial morphs as `{ "id": hex-string, "position": [x,y,z], "rotation": [x,y,z], "scale": [x,y,z] }` entries (added in version 2).
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`morphRegions` and `facialBoneMorphs` are optional and additive. Version 1
|
||||||
|
senders omit them; receivers must treat their absence as "no change" and keep
|
||||||
|
existing proxy morph data.
|
||||||
|
|
||||||
Receivers must treat missing or malformed appearance subfields as non-fatal and
|
Receivers must treat missing or malformed appearance subfields as non-fatal and
|
||||||
apply only the parts they can resolve locally. Gender is intentionally not part
|
apply only the parts they can resolve locally. Gender is intentionally not part
|
||||||
of this payload yet; female proxy support can add it later.
|
of this payload yet; female proxy support can add it later.
|
||||||
|
|||||||
+38
-2
@@ -99,6 +99,8 @@ def get_optional_appearance(packet: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
"facialHairColorFormId": value.get("facialHairColorFormId", ""),
|
"facialHairColorFormId": value.get("facialHairColorFormId", ""),
|
||||||
"headParts": [],
|
"headParts": [],
|
||||||
"morphs": [],
|
"morphs": [],
|
||||||
|
"morphRegions": [],
|
||||||
|
"facialBoneMorphs": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
head_parts = value.get("headParts", [])
|
head_parts = value.get("headParts", [])
|
||||||
@@ -113,9 +115,41 @@ def get_optional_appearance(packet: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
if isinstance(morph, dict) and isinstance(morph.get("id", ""), str)
|
if isinstance(morph, dict) and isinstance(morph.get("id", ""), str)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Added in appearance version 2; older senders omit these and the lists stay empty.
|
||||||
|
morph_regions = value.get("morphRegions", [])
|
||||||
|
if isinstance(morph_regions, list):
|
||||||
|
appearance["morphRegions"] = [
|
||||||
|
get_optional_float({"v": entry}, "v")
|
||||||
|
for entry in morph_regions
|
||||||
|
if isinstance(entry, (int, float))
|
||||||
|
]
|
||||||
|
|
||||||
|
facial_bone_morphs = value.get("facialBoneMorphs", [])
|
||||||
|
if isinstance(facial_bone_morphs, list):
|
||||||
|
appearance["facialBoneMorphs"] = [
|
||||||
|
{
|
||||||
|
"id": morph.get("id", ""),
|
||||||
|
"position": _read_float3(morph.get("position")),
|
||||||
|
"rotation": _read_float3(morph.get("rotation")),
|
||||||
|
"scale": _read_float3(morph.get("scale"), default=1.0),
|
||||||
|
}
|
||||||
|
for morph in facial_bone_morphs
|
||||||
|
if isinstance(morph, dict) and isinstance(morph.get("id", ""), str)
|
||||||
|
]
|
||||||
|
|
||||||
return appearance
|
return appearance
|
||||||
|
|
||||||
|
|
||||||
|
def _read_float3(value: Any, default: float = 0.0) -> list[float]:
|
||||||
|
result = [default, default, default]
|
||||||
|
if isinstance(value, list):
|
||||||
|
for index in range(min(len(value), 3)):
|
||||||
|
element = value[index]
|
||||||
|
if isinstance(element, (int, float)):
|
||||||
|
result[index] = float(element)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def update_host_world_state(packet: dict[str, Any]) -> None:
|
def update_host_world_state(packet: dict[str, Any]) -> None:
|
||||||
global host_world_state
|
global host_world_state
|
||||||
|
|
||||||
@@ -238,7 +272,9 @@ def print_remote_player(player: dict[str, Any]) -> None:
|
|||||||
(
|
(
|
||||||
f"version={appearance.get('version')}, race={appearance.get('raceFormId') or '<empty>'}, "
|
f"version={appearance.get('version')}, race={appearance.get('raceFormId') or '<empty>'}, "
|
||||||
f"height={appearance.get('height')}, headParts={len(appearance.get('headParts', []))}, "
|
f"height={appearance.get('height')}, headParts={len(appearance.get('headParts', []))}, "
|
||||||
f"morphs={len(appearance.get('morphs', []))}"
|
f"morphs={len(appearance.get('morphs', []))}, "
|
||||||
|
f"morphRegions={len(appearance.get('morphRegions', []))}, "
|
||||||
|
f"facialBoneMorphs={len(appearance.get('facialBoneMorphs', []))}"
|
||||||
)
|
)
|
||||||
if isinstance(appearance, dict)
|
if isinstance(appearance, dict)
|
||||||
else "<not sent>"
|
else "<not sent>"
|
||||||
@@ -284,7 +320,7 @@ def print_remote_players() -> None:
|
|||||||
)
|
)
|
||||||
appearance = player.get("appearance")
|
appearance = player.get("appearance")
|
||||||
appearance_text = (
|
appearance_text = (
|
||||||
f"race={appearance.get('raceFormId') or '<empty>'}, headParts={len(appearance.get('headParts', []))}, morphs={len(appearance.get('morphs', []))}"
|
f"race={appearance.get('raceFormId') or '<empty>'}, headParts={len(appearance.get('headParts', []))}, morphs={len(appearance.get('morphs', []))}, morphRegions={len(appearance.get('morphRegions', []))}, facialBoneMorphs={len(appearance.get('facialBoneMorphs', []))}"
|
||||||
if isinstance(appearance, dict)
|
if isinstance(appearance, dict)
|
||||||
else "<not sent>"
|
else "<not sent>"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ IDLE_ANGLE_Z = 3.93
|
|||||||
TELEPORT_TEST_OFFSET_X = 900.0
|
TELEPORT_TEST_OFFSET_X = 900.0
|
||||||
TELEPORT_TEST_OFFSET_Y = 450.0
|
TELEPORT_TEST_OFFSET_Y = 450.0
|
||||||
DEFAULT_APPEARANCE = {
|
DEFAULT_APPEARANCE = {
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"raceFormId": "00013746",
|
"raceFormId": "00013746",
|
||||||
"height": 1.0,
|
"height": 1.0,
|
||||||
"morphWeight": {"thin": 0.0, "muscular": 0.0, "large": 0.0},
|
"morphWeight": {"thin": 0.0, "muscular": 0.0, "large": 0.0},
|
||||||
@@ -53,6 +53,8 @@ DEFAULT_APPEARANCE = {
|
|||||||
"facialHairColorFormId": "",
|
"facialHairColorFormId": "",
|
||||||
"headParts": [],
|
"headParts": [],
|
||||||
"morphs": [],
|
"morphs": [],
|
||||||
|
"morphRegions": [],
|
||||||
|
"facialBoneMorphs": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
ClientSnapshotProvider = Callable[[], list[dict[str, Any]]]
|
ClientSnapshotProvider = Callable[[], list[dict[str, Any]]]
|
||||||
|
|||||||
Reference in New Issue
Block a user