Fix model track-group movement and RGB destination-alpha blending

This commit is contained in:
shen
2026-09-30 08:13:29 -07:00
parent f345bd5d3b
commit 905f23f135
16 changed files with 367 additions and 72 deletions
@@ -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.
@@ -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.
+2
View File
@@ -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."}
+13 -4
View File
@@ -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 验证",
@@ -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",
@@ -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",
@@ -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",
+12
View File
@@ -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<TextTrack> 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<BoneTrack> tracks;
std::vector<AnimationTrackGroup> track_groups;
// t 处每个 track 的局部 4x4(granny_transform 组合语义,行主序)。
// out 按 tracks 顺序;bone_index 见各 track。
void sample_local(float t, std::vector<Mat4>& out) const;
+47 -37
View File
@@ -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;
}
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <cstdint>
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
+2 -1
View File
@@ -2,6 +2,7 @@
#include "vulkan_window.h"
#include "host_util.h"
#include "blend_state.h"
#include <cstring>
#include <stdexcept>
@@ -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;
+49 -27
View File
@@ -285,14 +285,19 @@ struct ModelRec
std::vector<granny_model_mesh_binding> mesh_bindings;
};
struct TrackGroupRec
{
granny_track_group pub{};
std::vector<granny_text_track> text_tracks;
std::vector<std::vector<granny_text_track_entry>> entries;
};
struct AnimationRec
{
granny_animation pub{};
const gr2::Animation* src = nullptr;
granny_track_group group{};
granny_track_group* groups[1]{};
std::vector<granny_text_track> text_tracks;
std::vector<std::vector<granny_text_track_entry>> entries;
std::vector<TrackGroupRec> group_records;
std::vector<granny_track_group*> 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<int32_t>(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<int> 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);
+8
View File
@@ -283,6 +283,14 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
COMMAND $<TARGET_FILE:port_app_loop_test> ${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)
+36
View File
@@ -0,0 +1,36 @@
#include "../../src/host/blend_state.h"
#include "../../src/host/draw_capture.h"
#include <cstdio>
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<Render3DDraw> 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;
}
+25
View File
@@ -0,0 +1,25 @@
"""GPU regression: python3 rgb_target_blend_test.py <host_blend_state_test> <mt_native_render>."""
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("<I", data, 10)[0]
width, height = struct.unpack_from("<ii", data, 18)
bits = struct.unpack_from("<H", data, 28)[0]
assert bits in (24, 32), f"unsupported screenshot format: {bits}"
stride = ((width * bits + 31) // 32) * 4
pixel = offset + (abs(height) // 2) * stride + (width // 2) * (bits // 8)
actual = tuple(data[pixel:pixel + 3][::-1])
expected = (128, 128, 160) # source * 128/255 + background * 1
assert all(abs(a - b) <= 1 for a, b in zip(actual, expected)), (actual, expected)
print(f"PASS XRGB SRCALPHA/DESTALPHA GPU pixel: {actual}")
@@ -0,0 +1,90 @@
// Real packed bear assets: the auxiliary Hedra group precedes the moving Bip01 group.
#include "granny.h"
#include "../../src/platform/PackBackend.h"
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <sstream>
#include <string>
#include <vector>
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<uint8_t> 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<uint8_t> 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;
}