MF4 补 ##SD/##SI/VLSD + 信号枚举文本 enumText + .mat 改随机读(流式进度)

1) 修复上轮记录的两个已知问题
   - MatLoader: LoadPlainObject 不再为拿 dims 调 ReadDataset(整块解码第二次),
     改用 ReadDatasetInfo;普通 v7.3 大矩阵的时间/内存不再翻倍。
   - MdfFile::TextBlock 注释与实现对齐:真的支持 ##MD(UTF-8 XML,无长度前缀)。

2) 信号枚举信息(以 TestData/NEW-MF4.mf4 为目标)
   - 新增 SignalEnumText(DataTable.hpp):values[] + texts[] + Empty()/Find(),
     命名用 enumText —— 它描述"取值代表哪个状态",不是单位。
   - DataTable::columnEnumText 与 columns 同下标;SignalData::enumText 由
     BuildSignalGroup 移动赋值;其它格式(CSV/.mat)保持为空。
   - MF4 cc_type = 7:cc_val[i](F64,数据段 +24)↔ cc_ref[4 + i](##TX/##MD 文本),
     最后一个引用是默认文本,不使用。
   - 界面只在**光标取值标注**追加 " = <枚举文本>"(等号两侧各一个空格、无尖括号),
     查表用采样原值(ApplyDrawTransform 之前);常规状态显示一个像素都不变。
   - 实测:NEW-MF4.mf4 8 信号 / 4 条带枚举(32+5+32+5),MF4-Record.MF4 另有 2 种(12/32)。

3) MF4 支持范围
   - ##SI 采样索引:只有索引 ≥2 条且全部 < 周期数才生效(逐记录有效性位图)。
     实测部分工具给每个通道挂只含常量 513/4 的 ##SI,必须忽略,否则整列变 NaN。
   - ##SD + VLSD(cn_type == 1):##SD 数据段是「UINT32 长度 + 字节」样本序列,
     记录里放 64 位偏移;AppendSignalData 递归展开 SD/DZ/DL/HL(单通道上限 512MB),
     DecodeVlsdValue 先按直接偏移、失败再按 >>1 回退,取样本第一个元素。
   - CN 的 cn_data / md_unit / md_comment 下标按链接总数反推:8 链接(MDF 4.0 风格)
     = 换算[4]/数据[5]/单位[6]/注释[7],9 链接(4.1)= [5]/[6]/[7]/[8]。
     实测 Simulink 导出的“4.10”仍写 8 链接,不能写死 Link(cn,4/5)。
   - 主通道:cn_sync_type 1/2/3(时间/角度/距离)→ 名字 t/time/timestamp/zeit/时间
     (含 t[...])→ 第一个数值通道兜底;cg_flags 第 2 位(远程主通道)跳过 sync 判定。
   - 记录长度优先 cg_data_bytes(CG 数据段 +24);旧实现把 U8(cgData) 当
     cg_record_id_len,其实是 cg_flags 低字节(总线事件位)。
   - 进度:先走 DG 链得到真实组数(不再写死 64),hasBytes = true,bytesRead 取
     MdfFile::ReadAt 累计(夹到文件大小);4MB 分块读取检查取消。
   - ##DZ:数据段前有 28 字节头(original_type/zip_type/param/original_size/zip_size),
     旧实现整段当 zlib 流是错的;只支持纯 deflate(zip_type = 0)。
   - 一个信号都没有时,错误信息附带「布局无法解析 / 缺少时间通道 / 空数据组」计数。

4) .mat:内存映射 → 随机读 + 元数据块缓存(消除 SEH 风险)
   - MatHdf5::Open 改为 CreateFileW(FILE_FLAG_SEQUENTIAL_SCAN) + ReadFile;
     删除 MappedBytes,改为 ReadAt / Cached(16 × 64KB LRU + 上次命中槽)/ Tag /
     ReadData / ReadCString。I/O 失败只会变成 false,不再触发页访问异常。
   - 数据读取:连续布局按 4MB 切片;分块布局按需 + 64KB 对齐。
     固定 4MB 预读在交错存放的分块数据集上会把读盘量放大到 2.2 倍(2874MB/1293MB),
     改后 1301MB。
   - 关键提速:dims=[N]、chunk=[32768,1] 归一化后 rank=2、每行 1 字节,逐行算坐标
     是 12.9 亿次迭代;新增「rank == 2 且 chunkDims[1] == dims[1]」快路径(整块一次
     sink)。BIG_MAT.mat(1.29GB / 5 × 16,142,471):21.5s → 7.9s,私有内存 +741MB。
   - 进度:Open 立刻上报「0 / 总大小」,之后每读一块回调 ReadProgress(bytesRead,total)
     → MatLoader 映射到 0.02–0.98 并单调夹紧(字节进度与逐流解码取较大值),收尾 1.0。
     遮罩层百分比不再一上来就 100%;取消会中止(StreamDataset 返回 false),不留 NaN 空洞。
   - 连续布局数据不足(文件被截断)现在明确失败,不再静默截短。

验证(全部离屏:无可见窗口、不注入鼠标键盘屏幕)
- 新增 .preflight/perf/loadercheck(数据层)32/32:
  NEW-MF4 8 信号 / 4 条枚举 / 枚举文本逐条核对 / 9396 与 94 行;
  MF4-Record 124 信号 / 3 轴 / 4 条枚举;data-sample.mat 4 组 8 信号 / 行数;
  BIG_MAT.mat 5 信号 × 16,142,471 + 进度(total 已知、ratio 单调、bytes 递增、
  起点 ≤0.05、终值 1.0、更新 ≥10 次)。
- 新增 .preflight/perf/enumui(真实 Application + SW_HIDE)12/12:
  只显示 gVR_WinOpenReq[0],光标落在枚举采样点上 —— 标签宽度 265px;
  临时清空 enumText 后 25px;差值 240px 与 " = WinOpenReqAtDrvr_Idle" 的
  UiMeasureTextWidth 完全一致;恢复后宽度回到 265px。
- 回归:datacheck 69/69、matcheck 21/21、matui 15/15(loading bytes 达到总大小)、
  verify 49/49。
- make release / make debug 零警告。Build/Release/TargetCheck.exe = 820,224 字节
  (上轮 785,920,+34KB)。(Target.exe 正在被用户运行的程序占用,链接步骤未覆盖它。)
This commit is contained in:
DeepSeek-V4.1-Flash committed 2026-10-20 01:56:47 +08:00
1 parent 75f0fafd41
commit 181f95cc3a
10 files changed
+1328 -291

No files matched your search

+8
View File
@@ -393,6 +393,14 @@ namespace jdi
{
std::wstring name; /**< 信号名称(加载时取列名 / 通道名;用户可在界面上重命名)。 */
std::size_t axisIndex = 0; /**< 所属时间轴在分组内的下标。 */
/**
* @brief 取值 → 文本的枚举表(源格式自带时才有,例如 MDF4 `cc_type = 7`)。
*
* ⚠️ 这**不是**单位、也不是注释:它只回答「这个数值代表什么状态」。
* 空表示该信号没有枚举信息,界面必须与以前完全一致(不加任何尾缀)。
* 启用光标时,取值标注才在数值之后追加 ` = <枚举文本>`。
*/
SignalEnumText enumText;
std::vector<double> values; /**< 采样值,缺失处为 NaN。 */
double minValue = 0.0; /**< 有限采样值最小值。 */
double maxValue = 0.0; /**< 有限采样值最大值。 */
+63
View File
@@ -1,6 +1,7 @@
#ifndef __DATA_TABLE_HPP__
#define __DATA_TABLE_HPP__
#include <cmath>
#include <cstddef>
#include <functional>
#include <string>
@@ -9,6 +10,61 @@
namespace jdi
{
/**
* @brief 一列数值的「值 → 文本」枚举表(如 ASAM MDF v4 的 `cc_type = 7` 换算)。
*
* 它描述的是**信号本身的取值含义**(例如 `0 = Off`、`1 = On`),
* 与单位、注释、物理量纲无关,因此这里刻意命名为 `enumText` 而不是 `unit` / `label`。
*
* 空表(`texts.empty()`)表示该列没有枚举信息,界面必须保持与以前完全一致的显示。
*/
struct SignalEnumText
{
/** @brief 枚举取值(与采样列同一量纲,通常是整数)。 */
std::vector<double> values;
/** @brief 与 `values` 一一对应的显示文本。 */
std::vector<std::wstring> texts;
/** @brief 是否没有任何枚举信息。 */
bool Empty() const { return values.empty() || texts.size() != values.size(); }
/**
* @brief 按取值查找枚举文本。
* @param [IN] value 取值(物理值,未叠加界面「调整」变换)。
* @return 命中返回对应文本指针,未命中返回 nullptr。
*/
const std::wstring* Find(double value) const
{
if (Empty() || !(value == value)) // NaN 永远不命中
{
return nullptr;
}
for (std::size_t index = 0; index < values.size(); ++index)
{
if (values[index] == value)
{
return &texts[index];
}
}
// 采样值按 double 保存,整数枚举在任意位宽下都是精确可表示的,
// 但个别工具会写 `1.0000001` 这类带噪声的换算表:再做一次四舍五入匹配。
const double rounded = std::nearbyint(value);
if (rounded == value)
{
return nullptr;
}
for (std::size_t index = 0; index < values.size(); ++index)
{
if (std::nearbyint(values[index]) == rounded)
{
return &texts[index];
}
}
return nullptr;
}
};
/**
* @brief 内存中的**列优先**数据表(与具体文件格式无关)。
*
@@ -25,6 +81,13 @@ namespace jdi
{
std::vector<std::wstring> headers; /**< 列名(已去除引号与首尾空白)。 */
std::vector<std::vector<double>> columns; /**< 列优先数值矩阵:columns[列][行],缺失处为 NaN。 */
/**
* @brief 与 `columns` 同下标的「值 → 文本」枚举表(没有枚举信息的列为空)。
*
* 加载器只在源格式自带枚举文本(如 MDF4 `cc_type = 7`)时填充;
* 它不是单位、不是注释,界面只在光标读数处以尾缀形式展示。
*/
std::vector<SignalEnumText> columnEnumText;
std::size_t rowCount = 0; /**< 有效数据行数。 */
wchar_t delimiter = L','; /**< 实际使用的分隔符(仅文本格式有意义)。 */
unsigned long long sourceBytes = 0; /**< 源文件字节数(用于按比例预留容量)。 */
+82 -33
View File
@@ -97,29 +97,48 @@ namespace jdi
using OpenProgress = std::function<bool(std::uint64_t bytesRead, std::uint64_t totalBytes)>;
/**
* @brief 打开文件(**内存映射**,不把整个文件读进堆内存)。
* @brief 打开文件(**随机读取**,不映射、不把整个文件读进堆内存)。
*
* ⚠️ 早期版本把整个文件一次读入 `std::vector`(注释里假设“`.mat` 通常几十 MB”),
* 但 SDI 导出的录波文件可能是 **GB 级**:整文件读入 + 分块解码 + 再拷成 double 会同时
* 驻留“文件 + 未压缩字节 + double”三份内存。现改为:
* * 映射文件(只占虚拟地址空间,按页面需求驻留);
* * 分块数据集逐 chunk 解压并**直接写入**自持有的 double 列(见 `ReadDoubleStream`);
* * 进度回调在映射完成后立即报告一次(字节已全部可访问),后续解码阶段由 `MatLoader` 驱动。
* 早期版本把整个文件一次读入 `std::vector`,后来改成整文件内存映射;
* 但映射在「文件被截断 / 网络共享掉线 / 移动介质拔出」时会触发页访问异常,
* 而这类异常属于 SEH,GCC/MinGW 下的 `try/catch` **捕获不到**,只能让进程崩溃。
*
* 因此这里改为 SEC2 式的「`ReadFile` 随机读 + 元数据块缓存」:
* * 所有读取都经过 `ReadAt`,I/O 失败只会变成 `false`(上层给出错误提示);
* * 对象头 / B-tree / 本地堆等热点元数据走 64KB 块缓存(LRU);
* * 数据段按 4MB 切片顺序读,并对小 chunk 做顺序预读。
* 解析代码本身与映射版完全一致。
*
* @param [IN] path 文件路径。
* @param [OUT] error 失败原因。
* @param [IN] progress 读取进度回调(可为空)。
* @param [IN] progress 打开阶段的进度回调(可为空,只在上报总大小时调用一次)。
* @return 成功返回 true;用户取消时也返回 false。
*/
bool Open(const std::wstring& path, std::wstring& error,
const OpenProgress& progress = OpenProgress());
void Close();
/** @brief 析构时释放映射(否则栈上对象的映射会留到进程退出)。 */
/** @brief 析构时关闭文件句柄。 */
~MatHdf5() { Close(); }
/** @brief 文件字节数(映射长度)。 */
std::uint64_t FileBytes() const { return static_cast<std::uint64_t>(m_data.size()); }
/** @brief 文件字节数。 */
std::uint64_t FileBytes() const { return m_size; }
/** @brief 已从文件实际读取的字节数(用于「已读 / 总大小」进度)。 */
std::uint64_t BytesRead() const { return m_bytesRead; }
/**
* @brief 流式解码期间的字节进度回调。
*
* 每读一个块(或每 4MB 切片)调用一次;返回 false 表示请求取消。
*/
using ReadProgress = std::function<bool(std::uint64_t bytesRead, std::uint64_t totalBytes)>;
/** @brief 设置流式解码期间的字节进度回调(可为空)。 */
void SetReadProgress(const ReadProgress& progress) const { m_readProgress = progress; }
/** @brief 流式解码是否已被进度回调取消。 */
bool Cancelled() const { return m_readCancelled; }
/** @brief 根分组地址。 */
std::uint64_t Root() const { return m_root; }
@@ -192,6 +211,38 @@ namespace jdi
std::uint64_t Addr(std::uint64_t offset) const;
std::uint64_t Resolve(std::uint64_t address) const;
/** @brief 随机读取一段字节(不经过缓存;累加已读字节数)。 */
bool ReadAt(std::uint64_t offset, void* dst, std::size_t bytes) const;
/**
* @brief 取一段**元数据**的只读指针(经 64KB 块缓存)。
*
* ⚠️ 返回的指针只在下次调用前有效(缓存块可能被替换),调用方必须立即用完。
* @return 成功返回指针;越界或读失败返回 nullptr。
*/
const std::uint8_t* Cached(std::uint64_t offset, std::size_t bytes) const;
/** @brief 判断 `offset` 处的 4 字节块标识是否等于 `id`(`id` 为 4 字符)。 */
bool Tag(std::uint64_t offset, const char* id) const;
/**
* @brief 读取一个 NUL 结尾的短字符串(本地堆里的成员名)。
*
* 名字很短但可能跨越 64KB 缓存块边界,因此按块边界截断、必要时继续向后读。
*/
void ReadCString(std::uint64_t offset, std::string& out) const;
/** @brief 丢失顺序预读缓冲(随机跳转后调用)。 */
void DropReadAhead() const;
/**
* @brief 从文件读一段**数据**(带顺序预读,供小 chunk 连续读取)。
*
* 若请求落在当前预读窗口内直接返回指针;否则从 `offset` 起预读最多 4MB。
* @return 成功返回指针(仅在下次调用前有效),否则 nullptr。
*/
const std::uint8_t* ReadData(std::uint64_t offset, std::size_t bytes) const;
void ReadMessages(std::uint64_t address, std::vector<Message>& messages) const;
bool ParseType(const std::uint8_t* data, std::size_t size, std::size_t offset, Type& out,
std::size_t& consumed) const;
@@ -200,31 +251,29 @@ namespace jdi
void WalkSymbolNode(std::uint64_t node, std::uint64_t heapData,
std::vector<std::pair<std::string, std::uint64_t>>& out) const;
/**
* @brief 内存映射的文件视图(代替“整个文件读进 `vector`”)。
*
* ⚠️ 行为与 `std::vector<std::uint8_t>` 的**只读子集**一致(`size/data/begin/end/[]`),
* 因此所有“按偏移取字节”的解析代码一行都不用改;但**虚拟地址不等于物理内存**:
* 只有真正被解析到的页才驻留,GB 级文件不会再先吃掉一整个文件大小的私有内存。
*/
struct MappedBytes
/** @brief 元数据缓存槽(固定槽数,最近最少使用优先淘汰)。 */
struct CacheSlot
{
const std::uint8_t* pointer = nullptr;
std::size_t length = 0;
const std::uint8_t* data() const { return pointer; }
std::size_t size() const { return length; }
const std::uint8_t& operator[](std::size_t index) const { return pointer[index]; }
const std::uint8_t* begin() const { return pointer; }
const std::uint8_t* end() const { return pointer + length; }
void clear()
{
pointer = nullptr;
length = 0;
}
std::uint64_t base = 0;
std::uint32_t length = 0;
std::uint64_t stamp = 0;
std::vector<std::uint8_t> bytes;
};
MappedBytes m_data; /**< 文件映射视图(只读;语义同字节数组)。 */
void* m_fileHandle = nullptr; /**< `HANDLE`(避免头文件引入 windows.h)。 */
void* m_mapHandle = nullptr;
std::uint64_t m_size = 0; /**< 文件字节数。 */
mutable std::uint64_t m_bytesRead = 0; /**< 已实际读取的字节数(进度上报用)。 */
mutable std::vector<CacheSlot> m_cache; /**< 元数据块缓存。 */
mutable std::uint64_t m_stamp = 0; /**< 缓存访问计数。 */
mutable std::size_t m_lastSlot = 0; /**< 上次命中的槽(连续标量读取的快路径)。 */
mutable std::vector<std::uint8_t> m_readAhead; /**< 顺序预读缓冲。 */
mutable std::uint64_t m_readAheadBase = 0;
mutable std::uint32_t m_readAheadLength = 0;
mutable bool m_readAheadValid = false;
mutable std::vector<std::uint8_t> m_slice; /**< 连续布局切片缓冲(复用容量)。 */
mutable std::vector<std::uint8_t> m_chunk; /**< 大于预读窗口的分块缓冲(复用容量)。 */
mutable ReadProgress m_readProgress; /**< 流式解码进度回调(可空)。 */
mutable bool m_readCancelled = false; /**< 进度回调请求取消。 */
std::uint64_t m_base = 0;
std::uint64_t m_root = 0;
int m_offsetSize = 8;
+17 -5
View File
@@ -12,20 +12,32 @@ namespace jdi
* @brief 读取并解析一个 ASAM MDF v4(`.mf4` / `.mdf`)测量文件。
*
* 只解析**绘制需要的那部分**:头块版本、数据组(DG)→ 通道组(CG)→ 通道(CN)/换算(CC),
* 以及每个通道组的原始记录;字符串/字节数组通道、压缩数据块(DZ/HZ)与 MIME 通道一律跳过。
* 以及每个通道组的原始记录;字符串/字节数组通道、MIME 通道一律跳过。
*
* **时间轴**:MDF 的每个通道组(CG)自带一个时间基准。本加载器把每个 CG 输出为一个
* 时间轴块(`TableBlock`):块的时间列取自该组的**时间主通道**(`cn_sync_type == 1`,
* 退回名为 `time`/`t` 的通道,再退回第一个数值通道),其余数值通道作为该块的信号列。
* 时间轴块(`TableBlock`):块的时间列取自该组的**主通道**
*(`cn_sync_type` = 1 时间 / 2 角度 / 3 距离,退回名为 `time`/`t`/`timestamp` 的通道,
* 再退回第一个数值通道),其余数值通道作为该块的信号列。
* 内容完全相同的时间轴在数据模型层会被合并(见 `BuildSignalGroup`)。
* `cg_flags` 的「远程主通道」位会跳过第一步,避免把普通信号误当时间轴。
*
* **数据块**:`##DT` / `##DV` / `##DZ`(zlib + 转置压缩之外的纯 deflate)/
* `##DL`(数据列表)/ `##HL`(头部列表),支持记录跨段拼接。
*
* **VLSD(`cn_type == 1`)**:记录里是 64 位偏移,样本在 `##SD`(`UINT32` 长度 + 数据)。
* 本项目取样本的第一个元素作为该采样点的取值。
*
* **采样索引(`##SI`)**:仅当索引表至少两条且全部落在记录范围内时生效
*(否则忽略——实测部分工具会给每个通道挂一个只含常量 513 的 `##SI`)。
*
* 支持的通道编码:无符号/有符号整数(1–64 位,任意位偏移)与 IEEE-754 浮点(32/64 位)。
* 支持的换算:无换算、线性(`phys = c0 + c1 * raw`)。其它换算按“无换算”处理。
* 支持的换算:无换算、线性(`phys = c0 + c1 * raw`)、值 → 文本(`cc_type = 7`,输出到
* `DataTable::columnEnumText`,界面只在光标读数处以尾缀显示)。其它换算按“无换算”处理。
*
* @param [IN] filePath MDF 文件完整路径。
* @param [OUT] source 解析结果(每个通道组一个时间轴块)。
* @param [OUT] error 失败原因描述(成功时清空)。
* @param [IN] progress 进度回调(按字节比例),可为空。
* @param [IN] progress 进度回调(按已处理数据组比例;同时上报已读/总字节),可为空。
* @return 解析成功返回 true。
*/
bool LoadMf4Source(const std::wstring& filePath, LoadedSource& source, std::wstring& error,