Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 指解码后的像素数量,三项限制默认为零,检查非空图像前需要设置;
Expand Down
12 changes: 11 additions & 1 deletion docs/studio-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions include/Interface/IUtilAssetsIntegrity.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
4 changes: 4 additions & 0 deletions memory/project_overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions src/StudioModelValidation.h
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ class CStudioModelValidation
m_pData(static_cast<const byte*>(pData)),
m_Size(size),
m_Limit(size),
m_pResult(pResult)
m_pResult(pResult),
m_LookaheadLimit(size)
{
if (m_pResult)
m_pResult->ReasonStr[0] = 0;
Expand Down Expand Up @@ -70,7 +71,8 @@ class CStudioModelValidation
return Fail("invalid kept structure length");
}

m_Limit = static_cast<size_t>(m_Header.texturedataindex);
m_Limit = static_cast<size_t>(m_Header.texturedataindex);
m_LookaheadLimit = m_Limit;
}

if (!Textures() ||
Expand All @@ -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");

Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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;
};
29 changes: 27 additions & 2 deletions tests/SmokeTests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<size_t>(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<char> groupBytes((std::istreambuf_iterator<char>(groupInput)), std::istreambuf_iterator<char>());
++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;
Expand Down
18 changes: 18 additions & 0 deletions tests/StudioModelTests.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::int16_t, 3> finalRun{static_cast<std::int16_t>((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<mstudioseqdesc_t>(mainModel.header.seqindex);
const auto mainRun = mainModel.AppendStructure(finalRun);
auto mainAnim = mainModel.Read<mstudioanim_t>(mainSeq.animindex);
mainAnim.offset[0] = static_cast<unsigned short>(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<std::int16_t>((3 << 8) | 2));
Expect(Reason::OK, api.CheckStudioModel(mainModel.bytes.data(), mainModel.bytes.size(), nullptr), "main model span longer than its sequence");
}
Loading