diff --git a/README.md b/README.md index 5c56679..a52420f 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,10 @@ factory. The returned instance is a singleton owned by the DLL. Keep it loaded w using the interface and do not delete the instance. - `CheckStudioModel` accepts the existing IDST/IDSQ model formats, version 10. +- `CheckStudioSequenceGroup` checks an IDSQ file's animation against its main model. + It accepts the last frame's interpolation read up to + `UTIL_ASSETS_INTEGRITY_SEQUENCE_GROUP_TAIL_BYTES` past the group, as stock group + files need; callers that animate the group keep that many zeroed bytes after it. - `Check8bitBMP` checks for FreeImage's indexed-color classification and applies `MaxWidth`, `MaxHeight` and `MaxSize` when a result object is supplied. - `MaxSize` is the decoded pixel count, not the input file size. All three limits diff --git a/README.zh-CN.md b/README.zh-CN.md index 7f6e151..d94d53a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -36,6 +36,9 @@ scripts\build-UtilAssetsIntegrity-x86-Debug.bat 返回对象由 DLL 持有,使用期间保持 DLL 已加载,不要删除该对象。 - `CheckStudioModel` 检查原有 IDST/IDSQ、版本 10 的模型格式。 +- `CheckStudioSequenceGroup` 依据主模型检查 IDSQ 文件的动画数据。原版序列组文件需要 + 最后一帧的插值前瞻读取越过文件末尾,因此允许其最多越过 + `UTIL_ASSETS_INTEGRITY_SEQUENCE_GROUP_TAIL_BYTES` 字节;对序列组做动画的调用方需在数据后保留同样多的零字节。 - `Check8bitBMP` 使用 FreeImage 的索引色分类检查图像,传入结果对象时应用 `MaxWidth`、`MaxHeight` 和 `MaxSize` 限制。 - `MaxSize` 指解码后的像素数量,三项限制默认为零,检查非空图像前需要设置; diff --git a/docs/studio-validation.md b/docs/studio-validation.md index 5916279..758ea78 100644 --- a/docs/studio-validation.md +++ b/docs/studio-validation.md @@ -28,10 +28,20 @@ validates only the sequence-file header because the file alone does not describe sequences, bones or animation record offsets. It must not be treated as full animation validation. All input pointers are borrowed synchronously; the DLL retains none. +At the last frame of a run, GoldSrc's animators read the first sample after its last +span with no weight (the frame is clamped to `numframes - 1`, where `s` is 0). Every +stock Half-Life group file (`scientist01.mdl`, `barney01.mdl`, ...) ends before that +sample, up to two `mstudioanimvalue_t` short. `CheckStudioSequenceGroup()` therefore +accepts that read up to `UTIL_ASSETS_INTEGRITY_SEQUENCE_GROUP_TAIL_BYTES` (4) past the +group's bytes; the samples themselves must still lie inside the file. Callers that +animate a group must keep that many zeroed bytes after it. Main models keep no such +tail and stay checked against their kept structure. + `tests/StudioModelTests.h` exercises the corrected cases through the real DLL, including a string ending at a PAGE_NOACCESS guard page and malformed external RLE channels. Both x86 build scripts run these regressions and the existing BMP tests against build -and installed DLLs. The smoke runner's optional third argument scans an MDL directory. +and installed DLLs. The smoke runner's optional third argument scans an MDL directory +and checks each sequence group file found beside a main model against it. The corrected sources/header/tests were also migrated to HLND2T-DiligentGraphics, where a local CMake target builds x86/x64 with the engine SDK and existing FreeImage. diff --git a/include/Interface/IUtilAssetsIntegrity.h b/include/Interface/IUtilAssetsIntegrity.h index 75b9101..455569e 100644 --- a/include/Interface/IUtilAssetsIntegrity.h +++ b/include/Interface/IUtilAssetsIntegrity.h @@ -57,6 +57,8 @@ class IUtilAssetsIntegrity : public IBaseInterface //Checks every sequence belonging to groupIndex using the main model's structural header (raw or engine-cached). //Unlike standalone IDSQ header validation, this checks the bones, blends, all six RLE channels and interpolation reads. + //The interpolation read at the last frame of a run may end up to UTIL_ASSETS_INTEGRITY_SEQUENCE_GROUP_TAIL_BYTES past + //the group's bytes, as stock group files do; callers that animate the group keep that many zeroed bytes after it. //Appended after the existing methods so their vtable slots and the result layouts stay unchanged. virtual UtilAssetsIntegrityCheckReason CheckStudioSequenceGroup( const void* model, @@ -71,3 +73,7 @@ class IUtilAssetsIntegrity : public IBaseInterface //UTIL_ASSETS_INTEGRITY_INTERFACE_VERSION through CreateInterface. #define UTIL_ASSETS_INTEGRITY_INTERFACE_VERSION "UtilAssetsIntegrityAPI_001" + +//GoldSrc's animators read the first sample after a run's last span at its last frame, with no weight: up to two +//mstudioanimvalue_t past that span. Stock group files (such as scientist01.mdl) end before it. +#define UTIL_ASSETS_INTEGRITY_SEQUENCE_GROUP_TAIL_BYTES 4 diff --git a/memory/project_overview.md b/memory/project_overview.md index 1b4970d..0004b3c 100644 --- a/memory/project_overview.md +++ b/memory/project_overview.md @@ -17,6 +17,10 @@ model validation retains `MAXSTUDIOSKINS=100` and bounds references by `numtextu API_001's existing vtable slots and result layouts are preserved. The interface is published only through `EXPOSE_SINGLE_INTERFACE`; main-model-aware external animation validation is the `CheckStudioSequenceGroup()` method appended to that interface. +That method lets the last frame's interpolation read end up to +`UTIL_ASSETS_INTEGRITY_SEQUENCE_GROUP_TAIL_BYTES` (4) past the group, as every stock +Half-Life group file needs; callers that animate a group keep that many zeroed bytes +after it, and main models stay checked strictly. The remainder of this note is the **historical initial-migration snapshot**. Its statements about preserved buggy behavior, missing validation and byte-identical diff --git a/src/StudioModelValidation.h b/src/StudioModelValidation.h index 69e5b1e..2dde6ae 100644 --- a/src/StudioModelValidation.h +++ b/src/StudioModelValidation.h @@ -26,7 +26,8 @@ class CStudioModelValidation m_pData(static_cast(pData)), m_Size(size), m_Limit(size), - m_pResult(pResult) + m_pResult(pResult), + m_LookaheadLimit(size) { if (m_pResult) m_pResult->ReasonStr[0] = 0; @@ -70,7 +71,8 @@ class CStudioModelValidation return Fail("invalid kept structure length"); } - m_Limit = static_cast(m_Header.texturedataindex); + m_Limit = static_cast(m_Header.texturedataindex); + m_LookaheadLimit = m_Limit; } if (!Textures() || @@ -93,6 +95,10 @@ class CStudioModelValidation if (code != Reason::OK) return code; + //Callers keep zeroed tail bytes after the group for the last frame's interpolation read (see the interface). + static_assert(UTIL_ASSETS_INTEGRITY_SEQUENCE_GROUP_TAIL_BYTES == 2 * sizeof(mstudioanimvalue_t), "Lookahead of two samples"); + m_LookaheadLimit = m_Limit + UTIL_ASSETS_INTEGRITY_SEQUENCE_GROUP_TAIL_BYTES; + if (!pModel || modelBytes < sizeof(studiohdr_t) || modelBytes > INT_MAX) return Fail("missing main model for sequence group"); @@ -442,7 +448,7 @@ class CStudioModelValidation return Bad("animation samples outside file"); if (covered + span.num.total <= seq.numframes && - !Range(offset, size_t(span.num.valid) + 3, sizeof(mstudioanimvalue_t), m_Limit)) + !Range(offset, size_t(span.num.valid) + 3, sizeof(mstudioanimvalue_t), m_LookaheadLimit)) { return Bad("animation interpolation lookahead outside file"); } @@ -568,4 +574,7 @@ class CStudioModelValidation size_t m_Work{}; studiohdr_t m_Header{}; UtilAssetsIntegrityCheckResult_StudioModel* m_pResult; + + //Where interpolation reads may end: m_Limit, or past it by the tail callers keep after a sequence group. + size_t m_LookaheadLimit; }; diff --git a/tests/SmokeTests.cpp b/tests/SmokeTests.cpp index d529e26..6551ce2 100644 --- a/tests/SmokeTests.cpp +++ b/tests/SmokeTests.cpp @@ -212,7 +212,7 @@ int wmain(int argc, wchar_t** argv) return 1; if (argc == 4) { - size_t checked = 0, rejected = 0; + size_t checked = 0, checkedGroups = 0, rejected = 0; for (const auto& entry : std::filesystem::recursive_directory_iterator(argv[3])) { if (!entry.is_regular_file() || entry.path().extension() != L".mdl") continue; @@ -224,9 +224,34 @@ int wmain(int argc, wchar_t** argv) { ++rejected; std::cout << entry.path().string() << ": " << result.ReasonStr << '\n'; + continue; + } + + //A group file's header says nothing of its animation: check each group found beside a main model + //against it, with the bytes of the main model the engine keeps. + studiohdr_t header{}; + if (std::memcmp(bytes.data(), "IDST", 4)) + continue; + std::memcpy(&header, bytes.data(), sizeof(header)); + const auto modelBytes = header.textureindex ? static_cast(header.texturedataindex) : bytes.size(); + for (int group = 1; group < header.numseqgroups; ++group) + { + mstudioseqgroup_t record{}; + std::memcpy(&record, bytes.data() + header.seqgroupindex + group * sizeof(record), sizeof(record)); + const auto groupPath = entry.path().parent_path() / std::filesystem::path(record.name).filename(); + std::ifstream groupInput(groupPath, std::ios::binary); + if (!groupInput) + continue; + const std::vector groupBytes((std::istreambuf_iterator(groupInput)), std::istreambuf_iterator()); + ++checkedGroups; + if (api->CheckStudioSequenceGroup(bytes.data(), modelBytes, group, groupBytes.data(), groupBytes.size(), &result) != UtilAssetsIntegrityCheckReason::OK) + { + ++rejected; + std::cout << groupPath.string() << " (group " << group << " of " << entry.path().filename().string() << "): " << result.ReasonStr << '\n'; + } } } - std::cout << "Scanned " << checked << " MDL files; rejected " << rejected << ".\n"; + std::cout << "Scanned " << checked << " MDL files and " << checkedGroups << " sequence groups against their main models; rejected " << rejected << ".\n"; } std::cout << "Public interface, StudioModel and BMP smoke tests passed.\n"; return 0; diff --git a/tests/StudioModelTests.h b/tests/StudioModelTests.h index 0a2305e..5d38121 100644 --- a/tests/StudioModelTests.h +++ b/tests/StudioModelTests.h @@ -283,4 +283,22 @@ void CheckSequenceGroups(HMODULE library, IUtilAssetsIntegrity& api) group[runOffset] = 3; group[runOffset + 1] = 2; Check(checkGroup(model.bytes.data(), model.header.texturedataindex, 1, group.data(), group.size(), nullptr) != Reason::OK, "RLE valid exceeds total"); + + //Stock group files end right after their last span, whose last frame reads the next sample with no weight: that + //read lies in the tail callers keep after the group. + const std::array finalRun{static_cast((2 << 8) | 2), 10, 20}; + std::memcpy(group.data() + runOffset, finalRun.data(), sizeof(finalRun)); + Expect(Reason::OK, checkGroup(model.bytes.data(), model.header.texturedataindex, 1, group.data(), group.size(), nullptr), "last frame lookahead in the caller's tail"); + + //A main model keeps no tail: the same span at the end of its kept structure is rejected, and accepted when it + //covers more frames than the sequence, which reads nothing after it. + auto mainModel = StudioFixture{}; + const auto mainSeq = mainModel.Read(mainModel.header.seqindex); + const auto mainRun = mainModel.AppendStructure(finalRun); + auto mainAnim = mainModel.Read(mainSeq.animindex); + mainAnim.offset[0] = static_cast(mainRun - mainSeq.animindex); + mainModel.Write(mainSeq.animindex, mainAnim); + Check(api.CheckStudioModel(mainModel.bytes.data(), mainModel.bytes.size(), nullptr) != Reason::OK, "main model lookahead past its kept structure"); + mainModel.Write(mainRun, static_cast((3 << 8) | 2)); + Expect(Reason::OK, api.CheckStudioModel(mainModel.bytes.data(), mainModel.bytes.size(), nullptr), "main model span longer than its sequence"); }