From 905f23f135cc319290ab7b4ae236b8af681b9cfb Mon Sep 17 00:00:00 2001 From: shen <> Date: Wed, 30 Sep 2026 08:13:29 -0700 Subject: [PATCH] Fix model track-group movement and RGB destination-alpha blending --- audit/evidence/2026-09-30-bear-root-motion.md | 25 ++++++ audit/evidence/2026-09-30-gungsin-blending.md | 36 ++++++++ audit/history.jsonl | 2 + audit/manifest.json | 17 +++- .../EffectLib/EffectInstance.cpp.json | 3 +- .../EffectLib/ParticleSystemInstance.cpp.json | 3 +- .../EterGrnLib/ModelInstanceUpdate.cpp.json | 3 +- src/gr2/include/gr2/types.h | 12 +++ src/gr2/src/gr2_anim.cpp | 84 +++++++++-------- src/host/blend_state.h | 16 ++++ src/host/vk_pipelines.cpp | 3 +- src/platform/EterGrnLib/GrannyRuntime.cpp | 76 ++++++++++------ src/port/CMakeLists.txt | 8 ++ tests/host/blend_state_test.cpp | 36 ++++++++ tests/host/rgb_target_blend_test.py | 25 ++++++ tests/port/port_granny_root_motion_test.cpp | 90 +++++++++++++++++++ 16 files changed, 367 insertions(+), 72 deletions(-) create mode 100644 audit/evidence/2026-09-30-bear-root-motion.md create mode 100644 audit/evidence/2026-09-30-gungsin-blending.md create mode 100644 src/host/blend_state.h create mode 100644 tests/host/blend_state_test.cpp create mode 100644 tests/host/rgb_target_blend_test.py create mode 100644 tests/port/port_granny_root_motion_test.cpp diff --git a/audit/evidence/2026-09-30-bear-root-motion.md b/audit/evidence/2026-09-30-bear-root-motion.md new file mode 100644 index 00000000..e584e9b0 --- /dev/null +++ b/audit/evidence/2026-09-30-bear-root-motion.md @@ -0,0 +1,25 @@ +# Bear animation group binding + +40250 `EterGrnLib/ModelInstanceUpdate.cpp::UpdateTransform` delegates to +`GrannyUpdateModelMatrix`; that gameplay caller remains unchanged. ModelInstanceMotion +binds controls through `GrannyPlayControlledAnimation`. + +The real packed bear model is named Bip01. Both `bear/02.gr2` (walk) and `03.gr2` +(run) contain Hedra first, Bip01 second. Hedra has zero accumulation; Bip01 has +the translation in the corresponding unchanged MSA (-93.25 and -299.89 Y). +The previous adapter exposed a count of two with one public group pointer, flattened +bone binding across groups, and always accumulated the first group's translation. + +The reader now preserves each group's name, placement, accumulation, text tracks, +and flattened-track range. Runtime controls match the model name, excluding morph +groups, and use only the selected group for bone binding and root accumulation. + +`tests/port/port_granny_root_motion_test.cpp` loads those assets through PackBackend +and the runtime APIs used by the live client. It checks both public groups, rotated +root displacement across a loop boundary, inverse displacement, and stationary Hedra. +The same test linked with HEAD's previous GrannyRuntime fails six assertions; the +updated runtime passes. Desktop and `MT_TEST_MOBILE=1` login-flow regressions pass. +Native macOS renderer and Android debug APK builds pass. + +This is narrow evidence for constant-extracted motion. VDA extraction and complete +Windows Granny output equivalence remain unverified; the aggregate contract stays PARTIAL. diff --git a/audit/evidence/2026-09-30-gungsin-blending.md b/audit/evidence/2026-09-30-gungsin-blending.md new file mode 100644 index 00000000..42e58566 --- /dev/null +++ b/audit/evidence/2026-09-30-gungsin-blending.md @@ -0,0 +1,36 @@ +# Assassin gungsin / 魅影 destination-alpha blending + +The original skilldesc identifies 魅影 as skill 32, gungsin. The unchanged PC/PC2 +MSA files attach gungsintanyeong start/end/blow effects. Base and grade-four start/end +MSE particles use SRCALPHA (5) / DESTALPHA (7), with MODULATE color operation. +EffectInstance and ParticleSystemInstance match the reference except include casing; +their state order, emission, asset colors, and textures remain unchanged. + +RecordingDevice exposes an X8R8G8B8 back buffer and R5G6B5 GPU shadow targets. +Both have implicit destination alpha 1. Vulkan stores the scene in RGBA and the +previous pipeline incorrectly used the stored, draw-modified alpha for DESTALPHA. +This attenuated the background, including zero-alpha opaque scene pixels. + +`src/host/blend_state.h` normalizes DESTALPHA to ONE, INVDESTALPHA to ZERO, and +SRCALPHASAT to ZERO for RGB targets before creating the Vulkan blend pipeline. +Ordinary source-alpha, additive, and color blend factors remain unchanged. This +helper applies only to the currently supported recorded RGB targets; future actual +alpha render targets must retain their own destination-alpha semantics. + +Focused CTest `host.blend_state` passes. The GPU regression command is: + +```sh +python3 tests/host/rgb_target_blend_test.py build/native/src/port/host_blend_state_test build/release/src/host/mt_native_render +``` + +It draws an opaque zero-alpha background followed by a SRCALPHA/DESTALPHA overlay, +reads the native renderer's BMP pixel, and checks RGB (128,128,160). The old native +executable fails with (0,64,128); the updated executable passes. This exercises +actual Vulkan blending, not just the mapping helper. + +Native fake-server skill inspection used `--py-exec` to select the main instance, +set assassin race 1 and armor 0, then `chr.PushOnceMotion(chr.MOTION_SKILL+2,0.0)`. +Screenshots at 8 and 30 frames show bright particles and were inspected. These +timed screenshots are not a deterministic Windows visual oracle. Android debug +APK builds successfully; physical Android GPU and live-server skill-grade checks +remain unverified. The aggregate render contract stays PARTIAL. diff --git a/audit/history.jsonl b/audit/history.jsonl index 7718e0cd..d3b78354 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -642,3 +642,5 @@ {"date": "2026-09-30", "unit": "UserInterface/UserInterface.cpp", "action": "port", "evidence": "ApplicationSetErrorString/ApplicationStringTable_Initialize verbatim + port.app_loop; Main ADAPTED to PythonBoot; Win32 entry/launcher options N_A"} {"date": "2026-09-30", "unit": "ScriptLib/PythonUtils.cpp", "action": "port", "evidence": "already verbatim (udiff: MSVC cast + perf counters); mapped"} {"date": "2026-09-30", "unit": "ScriptLib/Resource.cpp", "action": "port", "evidence": "verbatim 40250 (was a rewrite that also registered .png); ctest 29/29, screenshot unchanged"} +{"date": "2026-09-30", "unit": "EterGrnLib/ModelInstanceUpdate.cpp", "action": "fix-platform-adapter", "evidence": "2026-09-30: native Granny adapter now selects the model-named track group, preserving all group metadata and limiting bone binding to that group. Real packed bear walk/run (auxiliary Hedra before Bip01) fail with the previous runtime and pass in tests/port/port_granny_root_motion_test.cpp. See audit/evidence/2026-09-30-bear-root-motion.md."} +{"date": "2026-09-30", "unit": "EffectLib/ParticleSystemInstance.cpp", "action": "fix-platform-adapter", "evidence": "XRGB destination alpha semantics fixed in Vulkan pipeline; focused mapping + GPU pixel regression; original particle logic unchanged; desktop/mobile login regressions and Android build pass."} diff --git a/audit/manifest.json b/audit/manifest.json index 80d896e2..b89f91bf 100644 --- a/audit/manifest.json +++ b/audit/manifest.json @@ -1902,9 +1902,13 @@ "project/mob_view_test.gd", "project/motion_event_payload_test.gd", "project/motion_registry_test.gd", - "project/hit_view_test.gd" + "project/hit_view_test.gd", + "tests/port/port_granny_root_motion_test.cpp" ], - "last_test_result": "PASS: PC playersettingmodule motion registry (registration order/weights), HORSE parent fallback, knockdown/stand-up early-return, MSA MotionDuration authority, LoopData skill timing, motion event payloads, and hot clip/MSA cache reuse regressions passed; NPC/full RaceData/event-consumer parity remains PARTIAL" + "last_test_result": "PASS: PC playersettingmodule motion registry (registration order/weights), HORSE parent fallback, knockdown/stand-up early-return, MSA MotionDuration authority, LoopData skill timing, motion event payloads, and hot clip/MSA cache reuse regressions passed; NPC/full RaceData/event-consumer parity remains PARTIAL", + "commands": [ + "2026-09-30: ctest --test-dir build/native -R port.granny_root_motion --output-on-failure PASS; HEAD GrannyRuntime linked against the same packed-asset test FAIL (zero root displacement). audit/evidence/2026-09-30-bear-root-motion.md" + ] }, "remaining": [ "扩展已接入的 PC playersettingmodule registry 快照,对比 NPC motlist、horse/wedding/skill 的每个 motion key 和缺失 key 失败语义", @@ -3860,9 +3864,14 @@ "project/motion_event_payload_test.gd", "project/motion_event_visibility_test.gd", "project/net_world_motion_event_test.gd", - "project/motion_event_warp_test.gd" + "project/motion_event_warp_test.gd", + "tests/host/blend_state_test.cpp", + "tests/host/rgb_target_blend_test.py" ], - "last_test_result": "PASS" + "last_test_result": "PASS", + "commands": [ + "2026-09-30: host.blend_state PASS; python3 tests/host/rgb_target_blend_test.py build/native/src/port/host_blend_state_test build/release/src/host/mt_native_render PASS (128,128,160); previous executable FAIL (0,64,128). Native gungsin motion screenshots inspected; Android APK build PASS. audit/evidence/2026-09-30-gungsin-blending.md" + ] }, "remaining": [ "type 4/6 的生产分支和专用字段消费;type 9 WARP 已接入但远端目标生命周期仍待 live 验证", diff --git a/audit/port-map/EffectLib/EffectInstance.cpp.json b/audit/port-map/EffectLib/EffectInstance.cpp.json index f3be070b..f41ba3e7 100644 --- a/audit/port-map/EffectLib/EffectInstance.cpp.json +++ b/audit/port-map/EffectLib/EffectInstance.cpp.json @@ -56,7 +56,8 @@ "status": "PORTED", "impl": [ "src/port/EffectLib/EffectInstance.cpp:CEffectInstance::OnRender" - ] + ], + "note": "2026-09-30: reference statements remain verbatim apart from include case. Native Vulkan adapter now interprets destination alpha as 1 on recorded X8R8G8B8/R5G6B5 targets; gungsin start/end particles use SRCALPHA/DESTALPHA. Focused mapping and actual GPU pixel regression fail before/pass after. See audit/evidence/2026-09-30-gungsin-blending.md." }, "CEffectInstance::SetGlobalMatrix": { "status": "PORTED", diff --git a/audit/port-map/EffectLib/ParticleSystemInstance.cpp.json b/audit/port-map/EffectLib/ParticleSystemInstance.cpp.json index 1df751e1..0d8dc9e7 100644 --- a/audit/port-map/EffectLib/ParticleSystemInstance.cpp.json +++ b/audit/port-map/EffectLib/ParticleSystemInstance.cpp.json @@ -44,7 +44,8 @@ "status": "PORTED", "impl": [ "src/port/EffectLib/ParticleSystemInstance.cpp:CParticleSystemInstance::OnRender" - ] + ], + "note": "2026-09-30: reference statements remain verbatim apart from include case. Native Vulkan adapter now interprets destination alpha as 1 on recorded X8R8G8B8/R5G6B5 targets; gungsin start/end particles use SRCALPHA/DESTALPHA. Focused mapping and actual GPU pixel regression fail before/pass after. See audit/evidence/2026-09-30-gungsin-blending.md." }, "CParticleSystemInstance::OnSetDataPointer": { "status": "PORTED", diff --git a/audit/port-map/EterGrnLib/ModelInstanceUpdate.cpp.json b/audit/port-map/EterGrnLib/ModelInstanceUpdate.cpp.json index 7c3d6f13..a6a2e8a2 100644 --- a/audit/port-map/EterGrnLib/ModelInstanceUpdate.cpp.json +++ b/audit/port-map/EterGrnLib/ModelInstanceUpdate.cpp.json @@ -20,7 +20,8 @@ "status": "PORTED", "impl": [ "src/port/EterGrnLib/ModelInstanceUpdate.cpp:CGrannyModelInstance::UpdateTransform" - ] + ], + "note": "2026-09-30: native Granny adapter now selects the model-named track group, preserving all group metadata and limiting bone binding to that group. Real packed bear walk/run (auxiliary Hedra before Bip01) fail with the previous runtime and pass in tests/port/port_granny_root_motion_test.cpp. See audit/evidence/2026-09-30-bear-root-motion.md." }, "CGrannyModelInstance::Deform": { "status": "PORTED", diff --git a/src/gr2/include/gr2/types.h b/src/gr2/include/gr2/types.h index 4beb1689..1e3f3a79 100644 --- a/src/gr2/include/gr2/types.h +++ b/src/gr2/include/gr2/types.h @@ -125,6 +125,17 @@ struct BoneTrack { void sample(float t, Transform& out) const; }; +// Preserve group boundaries: an animation can contain an auxiliary object group before its model. +struct AnimationTrackGroup { + std::string name; + Transform initial_placement; + int32_t accumulation_flags = 0; + float loop_translation[3] = {0.0f, 0.0f, 0.0f}; + std::vector text_tracks; + size_t first_track = 0; + size_t track_count = 0; +}; + struct Animation { std::string name; float duration = 0.0f; @@ -137,6 +148,7 @@ struct Animation { int32_t accumulation_flags = 0; float loop_translation[3] = {0.0f, 0.0f, 0.0f}; std::vector tracks; + std::vector track_groups; // t 处每个 track 的局部 4x4(granny_transform 组合语义,行主序)。 // out 按 tracks 顺序;bone_index 见各 track。 void sample_local(float t, std::vector& out) const; diff --git a/src/gr2/src/gr2_anim.cpp b/src/gr2/src/gr2_anim.cpp index 5c6806d8..211a3834 100644 --- a/src/gr2/src/gr2_anim.cpp +++ b/src/gr2/src/gr2_anim.cpp @@ -110,47 +110,53 @@ bool File::Impl::extract_animation(Ref anim_obj, Ref anim_type, const Skeleton* out.track_group_count = int32_t(groups.size()); const auto& tgms = parse_type(mTG->ref_type); - // TrackGroups[0].AccumulationFlags (Int32) + LoopTranslation (Real32[3]) - if (!groups.empty() && groups[0].valid()) { - if (const TypeMember* m = find_member(tgms, "AccumulationFlags")) - out.accumulation_flags = rd_i32(member_slot(groups[0], *m)); - if (const TypeMember* m = find_member(tgms, "LoopTranslation")) { - Ref r = member_slot(groups[0], *m); - for (int i = 0; i < 3; ++i) - out.loop_translation[i] = rd_f32(Ref{r.section, r.offset + uint32_t(i) * 4u}); - } - } - - // TrackGroups[0].TextTracks: RefToArray text_track{String Name; RefToArray Entries( - // text_track_entry{Real32 TimeStamp; String Text})}(motion event 用) - if (!groups.empty() && groups[0].valid()) - if (const TypeMember* mTx = find_member(tgms, "TextTracks")) { - Ref xb; uint32_t xc = 0, xs = 0; - if (read_ref_to_array(groups[0], *mTx, xb, xc, xs) && xc && xs) { - const auto& xms = parse_type(mTx->ref_type); - const TypeMember* mXN = find_member(xms, "Name"); - const TypeMember* mXE = find_member(xms, "Entries"); - for (uint32_t i = 0; i < xc && i < 4096; ++i) { - Ref x = {xb.section, xb.offset + i * xs}; - TextTrack tt; - if (mXN) tt.name = rd_str(member_slot(x, *mXN)); - Ref eb; uint32_t ec = 0, es = 0; - if (mXE && read_ref_to_array(x, *mXE, eb, ec, es) && ec && es) { - const auto& ems = parse_type(mXE->ref_type); - const TypeMember* mTS = find_member(ems, "TimeStamp"); - const TypeMember* mTT = find_member(ems, "Text"); - for (uint32_t k = 0; k < ec && k < 65536; ++k) { - Ref e = {eb.section, eb.offset + k * es}; - TextTrackEntry te; - if (mTS) te.time_stamp = rd_f32(member_slot(e, *mTS)); - if (mTT) te.text = rd_str(member_slot(e, *mTT)); - tt.entries.push_back(std::move(te)); + for (Ref group : groups) { + AnimationTrackGroup data; + if (group.valid()) { + if (const auto* m = find_member(tgms, "Name")) data.name = rd_str(member_slot(group, *m)); + if (const auto* m = find_member(tgms, "InitialPlacement")) read_transform(member_slot(group, *m), data.initial_placement); + if (const auto* m = find_member(tgms, "AccumulationFlags")) data.accumulation_flags = rd_i32(member_slot(group, *m)); + if (const auto* m = find_member(tgms, "LoopTranslation")) { + Ref r = member_slot(group, *m); + for (int i = 0; i < 3; ++i) data.loop_translation[i] = rd_f32({r.section, r.offset + uint32_t(i) * 4u}); + } + if (const auto* mTx = find_member(tgms, "TextTracks")) { + Ref xb; uint32_t xc = 0, xs = 0; + if (read_ref_to_array(group, *mTx, xb, xc, xs) && xc && xs) { + const auto& xms = parse_type(mTx->ref_type); + const auto* mXN = find_member(xms, "Name"); + const auto* mXE = find_member(xms, "Entries"); + for (uint32_t i = 0; i < xc && i < 4096; ++i) { + Ref x = {xb.section, xb.offset + i * xs}; + TextTrack tt; + if (mXN) tt.name = rd_str(member_slot(x, *mXN)); + Ref eb; uint32_t ec = 0, es = 0; + if (mXE && read_ref_to_array(x, *mXE, eb, ec, es) && ec && es) { + const auto& ems = parse_type(mXE->ref_type); + const auto* mTS = find_member(ems, "TimeStamp"); + const auto* mTT = find_member(ems, "Text"); + for (uint32_t k = 0; k < ec && k < 65536; ++k) { + Ref e = {eb.section, eb.offset + k * es}; + TextTrackEntry te; + if (mTS) te.time_stamp = rd_f32(member_slot(e, *mTS)); + if (mTT) te.text = rd_str(member_slot(e, *mTT)); + tt.entries.push_back(std::move(te)); + } } + data.text_tracks.push_back(std::move(tt)); } - out.text_tracks.push_back(std::move(tt)); } } } + out.track_groups.push_back(std::move(data)); + } + // Existing inspection API exposes the first group; runtime binding uses track_groups by name. + if (!out.track_groups.empty()) { + const auto& first = out.track_groups.front(); + out.accumulation_flags = first.accumulation_flags; + std::copy_n(first.loop_translation, 3, out.loop_translation); + out.text_tracks = first.text_tracks; + } const TypeMember* mTT = find_member(tgms, "TransformTracks"); if (!mTT) return true; @@ -167,7 +173,10 @@ bool File::Impl::extract_animation(Ref anim_obj, Ref anim_type, const Skeleton* const TypeMember* mPos = find_member(ttms, "PositionCurve"); const TypeMember* mScl = find_member(ttms, "ScaleShearCurve"); - for (Ref g : groups) { + for (size_t group_index = 0; group_index < groups.size(); ++group_index) { + Ref g = groups[group_index]; + auto& group_data = out.track_groups[group_index]; + group_data.first_track = out.tracks.size(); if (!g.valid()) continue; Ref base; uint32_t count = 0, stride = 0; if (!read_ref_to_array(g, *mTT, base, count, stride) || count == 0) continue; @@ -191,6 +200,7 @@ bool File::Impl::extract_animation(Ref anim_obj, Ref anim_type, const Skeleton* if (skel->bones[k].name == bt.bone_name) { bt.bone_index = int32_t(k); break; } out.tracks.push_back(std::move(bt)); } + group_data.track_count = out.tracks.size() - group_data.first_track; } return true; } diff --git a/src/host/blend_state.h b/src/host/blend_state.h new file mode 100644 index 00000000..0df97cba --- /dev/null +++ b/src/host/blend_state.h @@ -0,0 +1,16 @@ +#pragma once +#include + +namespace mt_host { +// PORT: the recorded back buffer is X8R8G8B8 and GPU shadow targets are R5G6B5. +// Their destination alpha is always 1, even when Vulkan storage has an alpha +// channel. Reading that storage alpha makes DESTALPHA particles darken the scene. +inline std::uint8_t d3d_blend_for_rgb_target(std::uint8_t factor) { + switch (factor) { + case 7: return 2; // DESTALPHA -> ONE + case 8: return 1; // INVDESTALPHA -> ZERO + case 11: return 1; // SRCALPHASAT -> min(As, 1 - Ad) = ZERO (RGB) + default: return factor; + } +} +} // namespace mt_host diff --git a/src/host/vk_pipelines.cpp b/src/host/vk_pipelines.cpp index 9a1fd010..7c73264e 100644 --- a/src/host/vk_pipelines.cpp +++ b/src/host/vk_pipelines.cpp @@ -2,6 +2,7 @@ #include "vulkan_window.h" #include "host_util.h" +#include "blend_state.h" #include #include @@ -212,7 +213,7 @@ VkPipeline VulkanWindow::get_pipeline(std::uint8_t cull, std::uint8_t depth, std } auto d3d_to_vk_blend = [](std::uint8_t d3d, VkBlendFactor fallback) -> VkBlendFactor { - switch (d3d) { + switch (d3d_blend_for_rgb_target(d3d)) { case 1: return VK_BLEND_FACTOR_ZERO; case 2: return VK_BLEND_FACTOR_ONE; case 3: return VK_BLEND_FACTOR_SRC_COLOR; diff --git a/src/platform/EterGrnLib/GrannyRuntime.cpp b/src/platform/EterGrnLib/GrannyRuntime.cpp index 83083030..0bab3d59 100644 --- a/src/platform/EterGrnLib/GrannyRuntime.cpp +++ b/src/platform/EterGrnLib/GrannyRuntime.cpp @@ -285,14 +285,19 @@ struct ModelRec std::vector mesh_bindings; }; +struct TrackGroupRec +{ + granny_track_group pub{}; + std::vector text_tracks; + std::vector> entries; +}; + struct AnimationRec { granny_animation pub{}; const gr2::Animation* src = nullptr; - granny_track_group group{}; - granny_track_group* groups[1]{}; - std::vector text_tracks; - std::vector> entries; + std::vector group_records; + std::vector groups; }; const MeshRec* mesh_rec(const granny_mesh* m) @@ -481,23 +486,30 @@ void build_file(granny_file& f) r.pub.Duration = src.duration; r.pub.TimeStep = src.time_step; r.pub.Oversampling = src.oversampling; - r.entries.resize(src.text_tracks.size()); - for (size_t t = 0; t < src.text_tracks.size(); ++t) - { - for (const gr2::TextTrackEntry& e : src.text_tracks[t].entries) - r.entries[t].push_back({e.time_stamp, e.text.c_str()}); - granny_text_track tt{}; - tt.Name = src.text_tracks[t].name.c_str(); - tt.EntryCount = int32_t(r.entries[t].size()); - tt.Entries = r.entries[t].empty() ? nullptr : r.entries[t].data(); - r.text_tracks.push_back(tt); - } - r.group.Name = r.pub.Name; - r.group.TextTrackCount = int32_t(r.text_tracks.size()); - r.group.TextTracks = r.text_tracks.empty() ? nullptr : r.text_tracks.data(); - r.groups[0] = &r.group; - r.pub.TrackGroupCount = std::max(1, src.track_group_count); - r.pub.TrackGroups = r.groups; + r.group_records.resize(src.track_groups.size()); + r.groups.resize(src.track_groups.size()); + for (size_t g = 0; g < src.track_groups.size(); ++g) { + const auto& data = src.track_groups[g]; + auto& record = r.group_records[g]; + record.entries.resize(data.text_tracks.size()); + for (size_t t = 0; t < data.text_tracks.size(); ++t) { + for (const auto& e : data.text_tracks[t].entries) + record.entries[t].push_back({e.time_stamp, e.text.c_str()}); + granny_text_track tt{}; + tt.Name = data.text_tracks[t].name.c_str(); + tt.EntryCount = int32_t(record.entries[t].size()); + tt.Entries = record.entries[t].empty() ? nullptr : record.entries[t].data(); + record.text_tracks.push_back(tt); + } + record.pub.Name = data.name.c_str(); + record.pub.Flags = data.accumulation_flags; + to_granny(data.initial_placement, record.pub.InitialPlacement); + record.pub.TextTrackCount = int32_t(record.text_tracks.size()); + record.pub.TextTracks = record.text_tracks.empty() ? nullptr : record.text_tracks.data(); + r.groups[g] = &record.pub; + } + r.pub.TrackGroupCount = int32_t(r.groups.size()); + r.pub.TrackGroups = r.groups.data(); f.animation_ptrs.push_back(&r.pub); } @@ -1045,6 +1057,7 @@ struct granny_control { granny_model_instance* instance = nullptr; const AnimationRec* animation = nullptr; + const gr2::AnimationTrackGroup* track_group = nullptr; std::vector bone_track; // model bone → animation track (-1: none) bool live = false; float current_clock = 0.0f; @@ -1172,8 +1185,8 @@ void GrannyFreeCompletedModelControls(granny_model_instance const* ModelInstance release_control(c); } -// PORT: GrannyUpdateModelMatrix reimplemented for constant-extraction root motion only (walk/run carry -// AccumulationFlags=AccumulationExtracted + LoopTranslation, e.g. warrior run (0,-300,0) = .msa Accumulation). +// PORT: constant-extraction root motion comes from the group bound to the model by name, +// not necessarily TrackGroups[0] (bear: Hedra decoration then Bip01, with translation only in Bip01). // Each live control contributes LoopTranslation over the local-clock span of the last SecondsElapsed, // blended by control weight; Dest = Delta x ModelMatrix (row vectors). VDA extraction is not implemented. void GrannyUpdateModelMatrix(granny_model_instance const* ModelInstance, granny_real32 SecondsElapsed, @@ -1188,8 +1201,8 @@ void GrannyUpdateModelMatrix(granny_model_instance const* ModelInstance, granny_ for (const granny_control* c : ModelInstance->controls) { const float w = control_weight(c); - const gr2::Animation* a = c->animation->src; - if (w <= 0.0f || !a || !(a->accumulation_flags & 1) || (a->accumulation_flags & 4)) + const auto* group = c->track_group; + if (w <= 0.0f || !group || !(group->accumulation_flags & 1) || (group->accumulation_flags & 4)) continue; const float dur = c->animation->pub.Duration; float t1 = c->local_clock, t0 = t1 - SecondsElapsed * c->speed; @@ -1201,7 +1214,7 @@ void GrannyUpdateModelMatrix(granny_model_instance const* ModelInstance, granny_ } const float k = dur > 0.0f ? (t1 - t0) / dur : 0.0f; for (int i = 0; i < 3; ++i) - delta[i] += w * k * a->loop_translation[i]; + delta[i] += w * k * group->loop_translation[i]; total += w; } if (total > 0.0f) @@ -1218,6 +1231,14 @@ granny_control* GrannyPlayControlledAnimation(granny_real32 StartTime, granny_an { if (!Animation || !Model) return nullptr; + const auto& source = *animation_rec(Animation)->src; + const gr2::AnimationTrackGroup* group = nullptr; + for (const auto& candidate : source.track_groups) + if (!(candidate.accumulation_flags & 8) && name_equal(candidate.name.c_str(), Model->model->Name)) { + group = &candidate; + break; + } + if (!group) return nullptr; granny_control* c; auto& pool = control_pool(); if (pool.empty()) @@ -1230,6 +1251,7 @@ granny_control* GrannyPlayControlledAnimation(granny_real32 StartTime, granny_an } c->instance = Model; c->animation = animation_rec(Animation); + c->track_group = group; c->live = true; c->current_clock = StartTime; @@ -1237,7 +1259,7 @@ granny_control* GrannyPlayControlledAnimation(granny_real32 StartTime, granny_an const auto& tracks = c->animation->src->tracks; c->bone_track.assign(sk ? sk->BoneCount : 0, -1); for (int b = 0; b < int(c->bone_track.size()); ++b) - for (size_t t = 0; t < tracks.size(); ++t) + for (size_t t = group->first_track; t < group->first_track + group->track_count; ++t) if (name_equal(tracks[t].bone_name.c_str(), sk->Bones[b].Name)) { c->bone_track[b] = int(t); diff --git a/src/port/CMakeLists.txt b/src/port/CMakeLists.txt index 54597d2c..00357bd0 100644 --- a/src/port/CMakeLists.txt +++ b/src/port/CMakeLists.txt @@ -283,6 +283,14 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME) COMMAND $ ${MT_40250_CLIENT} ${MT_PYTHON_STDLIB_ZIP}) set_tests_properties(port.app_loop PROPERTIES SKIP_RETURN_CODE 77) + add_executable(host_blend_state_test "${PROJECT_SOURCE_DIR}/tests/host/blend_state_test.cpp") + add_test(NAME host.blend_state COMMAND host_blend_state_test) + + add_executable(port_granny_root_motion_test ${PROJECT_SOURCE_DIR}/tests/port/port_granny_root_motion_test.cpp) + target_link_libraries(port_granny_root_motion_test PRIVATE port_platform) + add_test(NAME port.granny_root_motion COMMAND port_granny_root_motion_test "${MT_40250_CLIENT}") + set_tests_properties(port.granny_root_motion PROPERTIES SKIP_RETURN_CODE 77) + add_executable(port_login_flow_test ${PROJECT_SOURCE_DIR}/tests/port/port_login_flow_test.cpp ${PROJECT_SOURCE_DIR}/tests/port/port_login_flow_server.cpp ${PROJECT_SOURCE_DIR}/tests/port/classic/classic_cipher.cpp) diff --git a/tests/host/blend_state_test.cpp b/tests/host/blend_state_test.cpp new file mode 100644 index 00000000..86844cb2 --- /dev/null +++ b/tests/host/blend_state_test.cpp @@ -0,0 +1,36 @@ +#include "../../src/host/blend_state.h" +#include "../../src/host/draw_capture.h" +#include + +int main(int argc, char** argv) { + int failures = 0; + auto check = [&](bool ok, const char* message) { + if (!ok) { std::fprintf(stderr, "FAIL %s\n", message); ++failures; } + }; + using mt_host::d3d_blend_for_rgb_target; + check(d3d_blend_for_rgb_target(7) == 2, "gungsin DESTALPHA preserves the background on XRGB"); + check(d3d_blend_for_rgb_target(8) == 1, "INVDESTALPHA contributes zero on XRGB"); + check(d3d_blend_for_rgb_target(11) == 1, "SRCALPHASAT contributes zero RGB on XRGB"); + for (std::uint8_t factor = 1; factor <= 10; ++factor) + if (factor != 7 && factor != 8) + check(d3d_blend_for_rgb_target(factor) == factor, "ordinary alpha/additive/color factors preserved"); + if (argc == 2) { + std::vector draws; + // An opaque scene draw can output alpha 0 into Vulkan's RGBA storage. + // Gungsin's SRCALPHA / DESTALPHA must still preserve that XRGB background. + for (auto color : {0x00804020u, 0x800080ffu}) { + Render3DDraw d{}; + for (auto* matrix : {d.world, d.view, d.proj}) + for (int i = 0; i < 16; ++i) matrix[i] = (i % 5 == 0) ? 1 : 0; + d.positions = {-1,-1,0.5f, 1,-1,0.5f, 1,1,0.5f, -1,1,0.5f}; + d.indices = {0,1,2,0,2,3}; + d.diffuse = {color,color,color,color}; + d.color_op[0] = d.alpha_op[0] = 2; // SELECTARG1 / DIFFUSE + d.color_op[1] = d.alpha_op[1] = 1; // DISABLE + if (!draws.empty()) { d.alpha_blend = 1; d.src_blend = 5; d.dest_blend = 7; } + draws.push_back(std::move(d)); + } + native_draw_capture::write(argv[1], draws, {}, 960, 640, {}); + } + return failures ? 1 : 0; +} diff --git a/tests/host/rgb_target_blend_test.py b/tests/host/rgb_target_blend_test.py new file mode 100644 index 00000000..801c75a6 --- /dev/null +++ b/tests/host/rgb_target_blend_test.py @@ -0,0 +1,25 @@ +"""GPU regression: python3 rgb_target_blend_test.py .""" +import pathlib +import struct +import subprocess +import sys +import tempfile + +with tempfile.TemporaryDirectory(prefix="metin2-rgb-blend-") as directory: + capture = pathlib.Path(directory) / "destalpha.capture" + screenshot = pathlib.Path(directory) / "destalpha.bmp" + subprocess.run([sys.argv[1], str(capture)], check=True) + subprocess.run([sys.argv[2], "--capture", str(capture), "--frames", "1", + "--width", "320", "--height", "240", "--screenshot-out", str(screenshot)], + check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + data = screenshot.read_bytes() + offset = struct.unpack_from(" +#include +#include +#include +#include +#include +#include + +namespace { +int failures = 0; +void check(bool ok, const char* message) { + if (!ok) { std::fprintf(stderr, "FAIL %s\n", message); ++failures; } +} +granny_file* load(const std::string& path) { + std::vector bytes; + if (!mtpack40250::read(path, bytes)) return nullptr; + return GrannyReadEntireFileFromMemory(bytes.size(), bytes.data()); +} +void test_motion(granny_model* model, const char* name) { + const std::string base = "d:/ymir work/monster/bear/"; + granny_file* file = load(base + name + ".gr2"); + check(file != nullptr, "bear animation loads from the live pack path"); + if (!file) return; + auto* info = GrannyGetFileInfo(file); + check(info && info->AnimationCount == 1, "one animation"); + if (!info || info->AnimationCount != 1) { GrannyFreeFile(file); return; } + auto* animation = info->Animations[0]; + check(animation->TrackGroupCount == 2, "both track groups exposed safely"); + const bool auxiliaryFirst = animation->TrackGroupCount > 0 && + !std::strcmp(animation->TrackGroups[0]->Name, "Hedra"); + check(auxiliaryFirst, "auxiliary group first"); + if (animation->TrackGroupCount == 2 && auxiliaryFirst) { + check(!std::strcmp(animation->TrackGroups[1]->Name, "Bip01"), "model group second"); + } + std::vector msa; + check(mtpack40250::read(base + name + ".msa", msa), "motion specification loads"); + float accumulation[3] = {}; + std::istringstream lines(std::string(msa.begin(), msa.end())); + for (std::string line; std::getline(lines, line);) + if (line.rfind("Accumulation", 0) == 0) + std::sscanf(line.c_str(), "Accumulation %f %f %f", accumulation, accumulation + 1, accumulation + 2); + check(accumulation[1] < 0, "moving resource has forward accumulation"); + for (bool inverse : {false, true}) { + auto* instance = GrannyInstantiateModel(model); + auto* control = GrannyPlayControlledAnimation(0, animation, instance); + check(control != nullptr, "animation binds to named model"); + if (control) { + GrannySetControlLoopCount(control, 0); + const float dt = 0.1f; + const float clock = animation->Duration + dt; // across the loop boundary + GrannySetModelClock(instance, clock); + float matrix[16] = {0,1,0,0, -1,0,0,0, 0,0,1,0, 100,200,0,1}; + GrannyUpdateModelMatrix(instance, dt, matrix, matrix, inverse); + const float expectedX = 100 - (inverse ? -1.0f : 1.0f) * accumulation[1] * dt / animation->Duration; + check(std::abs(matrix[12] - expectedX) < 0.02f && std::abs(matrix[13] - 200) < 0.02f, + "model group root displacement, loop, rotation and inverse"); + } + GrannyFreeModelInstance(instance); + } + // Hedra has no root displacement even though the other group moves. + granny_model decoration = *model; + decoration.Name = "Hedra"; + auto* instance = GrannyInstantiateModel(&decoration); + auto* control = GrannyPlayControlledAnimation(0, animation, instance); + check(control != nullptr, "auxiliary object binds to its own group"); + GrannySetModelClock(instance, 0.1f); + float matrix[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1}; + GrannyUpdateModelMatrix(instance, 0.1f, matrix, matrix, false); + check(matrix[12] == 0 && matrix[13] == 0, "auxiliary group does not inherit bear movement"); + GrannyFreeModelInstance(instance); + GrannyFreeFile(file); +} +} +int main(int argc, char** argv) { + if (argc < 2 || !mtpack40250::initialize(argv[1])) return std::getenv("MT_ASSETS_STRICT") ? 1 : 77; + auto* file = load("d:/ymir work/monster/bear/bear.gr2"); + check(file != nullptr, "bear model loads"); + if (!file) return 1; + auto* info = GrannyGetFileInfo(file); + check(info && info->ModelCount == 1, "one bear model"); + if (info && info->ModelCount == 1) { + test_motion(info->Models[0], "02"); + test_motion(info->Models[0], "03"); + } + GrannyFreeFile(file); + return failures ? 1 : 0; +}