剑网3|家园蓝图编解码与校验
家园蓝图二进制编解码与校验
1. 简介与支持范围
家园蓝图文件由头部、节点树和末尾校验组成。在已解析的四份样本中,正文没有加密,也没有外层压缩;末尾校验由正文的 MD5 摘要经字节轮转和异或得到。读取文件时,需要先检查校验,再按节点类型解析各字段。
目前确认的格式分为两类:第 7 版的部分布局,以及三份包含额外节点类型的样本。对应的读写模式如下:
| 模式 | 适用文件 | 支持操作 | 限制 |
|---|---|---|---|
v7_subset | 内部第 7 版,节点只含 0/3/8/5/6/7;全局文件还须符合 222 字节前缀布局和根节点约束 | 解析、JSON 导出、受支持字段编辑后编码 | 不是所有第 7 版蓝图都符合此子集。 |
observed_samples | 按 SHA-256 匹配的三份扩展样本,内部版本为 4、3、7 | 解析、JSON 导出、原文件逐字节还原 | 1/2/4/9 的额外布局仅对这些样本开放;编码后必须仍与原样本相同,不支持修改后导出。 |
末尾校验用于检查内容完整性,不使用密钥,也不提供身份认证。重新编码得到的文件尚未经过游戏内导入验证。
2. 字节约定、文件封装和校验
2.1 基本类型
所有多字节数值均采用小端序,字段之间没有隐含的对齐填充。表中的偏移从 0 开始,范围包含两端:文件头的偏移相对于文件起点,节点字段的偏移相对于当前节点起点。
| 类型 | 字节数 | Python struct 格式 | 含义 |
|---|---|---|---|
| uint8 | 1 | <B | 无符号整数,0–255。 |
| uint16 | 2 | <H | 无符号整数,0–65,535。 |
| uint32 | 4 | <I | 无符号整数,0–4,294,967,295。 |
| float32 | 4 | <f | IEEE 754 单精度浮点数。 |
| 原始字节 | 指定长度 | 直接切片 | 不作字符集解码;JSON 中保存为十六进制字符串。 |
2.2 文件封装
file = payload || trailerpayload = prefix || rootNode(递归包含所有子节点)trailer = transform(MD5(payload)),固定 16 字节|| 表示字节拼接。正文包括头部和节点树,不含末尾的校验段。文件类型和版本需要读取头部确认:.blueprintx 也可能是全局文件,.blueprintx16 中的 16 不是内部版本号。
| 文件偏移 | 类型/长度 | 字段 | 规则 |
|---|---|---|---|
| 0 | uint8 | global | 0 是局部组件,1 是全局蓝图。 |
| 1–4 | uint32 | version | 四份样本中的版本号为 3、4、7。 |
| 5 起 | 可变 | 场景前缀剩余部分或节点树 | 由版本、标志及支持模式决定。 |
| 文件末尾 16 字节 | bytes[16] | trailer | 变换后的 MD5 原始摘要字节,不是十六进制文本。 |
| 文件类型 | 前缀总长度/树起点 | 说明 |
|---|---|---|
| 第 7 版局部组件 | 5 | 头部恒为 00 07 00 00 00,随后是根节点。 |
| 第 7 版支持的全局文件 | 222 | 包含前 5 字节通用头及场景参数。 |
| 第 3、4 版全局样本 | 46 | 仅在指定样本中确认,同版本的其他文件可能不同。 |
脚本最多读取 32,000,000 字节。局部组件至少需要 5 字节头 + 5 字节空挂点 + 16 字节校验 = 26 字节;第 7 版全局文件至少需要 275 字节。长度检查通过后仍需解析节点,根节点及其子树必须恰好结束于 len(file) - 16。
2.3 校验算法与逆变换
令 D = MD5(payload).digest(),得到 16 字节摘要。末尾校验 C 为:
C[i] = D[(i + 1) mod 16] XOR 0x33,i = 0…15即先将 [D0,D1,…,D15] 排列为 [D1,D2,…,D15,D0],再逐字节异或十进制 51。这里是字节位置轮转,不是对每个字节做位旋转;输入必须是 MD5 的原始字节,不能用 hexdigest() 返回的 32 字符字符串代替。
反向还原 MD5 摘要:
D[j] = C[(j - 1) mod 16] XOR 0x33,j = 0…15异或同一常量可逆,因此先异或、再将顺序循环右移一位即可。逆变换只能恢复摘要,不能从 MD5 摘要恢复正文。Python 中可用 hashlib.md5(..., usedforsecurity=False) 计算标准 MD5,直接传入字节,无需转换为字符串。
验证时,对 file[:-16] 重新计算 C,再与 file[-16:] 比较。写入时,将新正文与重新计算的 C 拼接。下面用空正文展示摘要变换的测试向量;空正文自身不是蓝图:
MD5(b"") = d41d8cd98f00b204e9800998ecf8427echecksum(b"") = 2ebfeabc338137dab33aabdfcb714de73. 全局场景前缀字段
3.1 第 7 版:222 字节前缀
这套布局的前缀共 222 字节,字段如下。表中的固定值是脚本接受的范围,并非所有游戏地图都使用这些值。未知区域需保留原字节,不能填零。
| 文件偏移 | 类型/长度 | 已知用途或限制 |
|---|---|---|
| 0 | uint8 | global=1。 |
| 1–4 | uint32 | version=7。 |
| 5–8 | float32 | 宽度参数,按 width × 50 表示,样本为 10,600。 |
| 9–12 | float32 | 长度参数,按 length × 50 表示,样本为 10,800。 |
| 13–16 | float32 | 变换后包围盒的 minX。 |
| 17–20 | float32 | minY。 |
| 21–24 | float32 | minZ。 |
| 25–28 | float32 | maxX。 |
| 29–32 | float32 | maxY。 |
| 33–36 | float32 | maxZ。 |
| 37–40 | uint32 | 实例编号上界,按 最大 instanceId + 1 表示,初始样本为 2,623。 |
| 41 | uint8 | 支持子集要求为 0,非零视为不支持的恢复信息。 |
| 42–45 | uint32 | 支持子集要求为 0,恢复相关具体语义未解释。 |
| 46–49 | uint32 | 支持子集固定要求为 2,地表格式标记。 |
| 50–53 | uint32 | 支持子集固定要求为 3。 |
| 54–61 | bytes[8] | 未解释,保留原字节。 |
| 62–101 | float32[10] | 地表参数;各值须有限,各项含义尚未确认。第 k 项起点为 62+4k,k=0…9。 |
| 102–122 | 21 字节记录 | 地表记录 index=0,布局见下表。 |
| 123–143 | 21 字节记录 | 地表记录 index=1。 |
| 144–164 | 21 字节记录 | 地表记录 index=2。 |
| 165–168 | uint32 | 支持子集固定要求为 1,植被格式标记。 |
| 169–176 | bytes[8] | 未解释,保留原字节。 |
| 177 | uint8 | 支持子集要求为 0,非零视为植被绘制或未知格式。 |
| 178–181 | bytes[4] | 未解释,保留原字节。 |
| 182–221 | float32[10] | 植被参数;各值须有限,各项含义尚未确认。第 k 项起点为 182+4k。 |
地表记录起点 s = 102 + 21 × index:
| 相对记录偏移 | 类型 | 支持子集中的值 |
|---|---|---|
| 0–3 | uint32 | 0 |
| 4–7 | uint32 | 0 |
| 8–11 | uint32 | index,依次为 0、1、2 |
| 12–15 | uint32 | 0 |
| 16–19 | uint32 | 0 |
| 20 | uint8 | 0 |
v7_subset 模式要求全局根节点为 type=5,index=0,至少有一个子节点,变换的前五项为 [1,0,0,0,1]。旧版样本和第 7 版扩展样本按各自的节点树读取,不受这组根节点方向约束。
3.2 两份旧版样本:46 字节前缀
| 文件偏移 | 类型/长度 | 解析方式 |
|---|---|---|
| 0 | uint8 | 两份样本均为 1。 |
| 1–4 | uint32 | 分别为 3、4。 |
| 5–36 | float32[8] 的位置 | 观察值可按此类型读取,但未将第 7 版的尺寸/包围盒语义套用到旧版。 |
| 37–40 | uint32 的位置 | 原样保留,不推断与第 7 版字段含义相同。 |
| 41 | uint8 的位置 | 原样保留,具体含义尚未确认。 |
| 42–45 | uint32 的位置 | 原样保留,具体含义尚未确认。 |
工具将完整前缀保存在 header.rawHex 中,编码时直接写回。第 4 版样本的根节点带有旋转,不能要求其四元数为单位四元数。
4. 节点布局
每个节点以 type 开始,固定区后紧接按顺序排列的子节点。节点树采用前序布局,没有统一的“节点总长度”字段。childrenCount 只表示直接子节点数,因此需要递归读完每棵子树,才能找到下一个同级节点。type=4 的布局另行说明,它包含样本中观察到的跨度字段和变长路径。
4.1 type=0/3/8:固定区 49 字节
| 节点内偏移 | 类型 | JSON 字段 | 含义 |
|---|---|---|---|
| 0 | uint8 | type | 0、3 或 8。 |
| 1–4 | uint32 | flags | 原值保留,不解释各标志位。 |
| 5–8 | uint32 | instanceId | 实例编号。 |
| 9–12 | uint32 | representId | 表现资源 ID;不是家具物品 furnitureId。 |
| 13 | uint8 | p1 | 参数字节,具体含义尚未确认。 |
| 14 | uint8 | p2 | 参数字节,具体含义尚未确认。 |
| 15–16 | uint16 | 不单独保存 | childrenCount,编码时由 len(children) 得到。 |
| 17–48 | float32[8] | transform | 无论是否有子节点都存在。 |
| 49 起 | Node[] | children | 顺序排列的子树。 |
固定区对应的格式为 <BIIIBBH8f。这几种节点虽然布局相同,在游戏中的用途却可能不同,不能直接替换类型值。
4.2 type=5/6/7:固定区 5 或 37 字节
| 节点内偏移 | 类型 | JSON 字段 | 规则 |
|---|---|---|---|
| 0 | uint8 | type | 5、6 或 7。 |
| 1–2 | uint16 | index | 挂点等结构的索引。 |
| 3–4 | uint16 | 不单独保存 | childrenCount。 |
| 5–36 | float32[8] | transform | 仅在 childrenCount > 0 时存在。 |
| 5 或 37 起 | Node[] | children | 无子节点时记录在偏移 5 处结束;有子节点时从 37 开始。 |
空挂点没有变换字段,若将所有挂点都按 37 字节读取,后续数据就会错位。输入的空挂点若带有 transform,工具会报错,不会直接丢弃该字段。
4.3 type=1 与 type=9:只对固定样本开放
| 字段 | 类型 | type=1 偏移 | type=9 偏移 | JSON 字段 |
|---|---|---|---|---|
| 类型 | uint8 | 0 | 0 | type |
| 未解释字段 1 | uint32 | 1–4 | 1–4 | uninterpretedFields.uint32At1 |
| 未解释字段 2 | uint32 | 5–8 | 5–8 | uninterpretedFields.uint32At5 |
| 额外字节 | uint8 | 不存在 | 9 | uninterpretedFields.uint8At9 |
| 子节点数 | uint16 | 9–10 | 10–11 | 由 children 长度恢复 |
| 变换 | float32[8] | 11–42 | 12–43 | transform,总是存在 |
| 子树 | Node[] | 43 起 | 44 起 | children |
两个 uint32 的含义尚未确认,没有证据表明它们是家具 ID。两种类型的固定区分别为 43、44 字节。
4.4 type=2:固定区 54 字节,只对固定样本开放
| 节点内偏移 | 类型 | JSON 字段 |
|---|---|---|
| 0 | uint8 | type=2 |
| 1–4 | uint32 | flags |
| 5–8 | uint32 | instanceId |
| 9–12 | uint32 | representId |
| 13 | uint8 | p1 |
| 14 | uint8 | p2 |
| 15–19 | bytes[5] | uninterpretedExtensionAt15Hex,10 个十六进制字符 |
| 20–21 | uint16 | 子节点数 |
| 22–53 | float32[8] | transform,总是存在 |
| 54 起 | Node[] | children |
4.5 type=4:部分解析的光源候选组件
三份扩展样本中共有 449 条 type=4 记录,每条都能拆成固定字段、变长纹理路径和尾部变换,并按这些字段重建。从父节点、编号、资源路径和参数分布来看,它们很可能是光源组件。具体参数仍需对照修改灯光前后的蓝图来确认。
JSON 中将其标记为 interpretation="partially_decoded_light_candidate"。已确认布局的字符串和数值直接解析;含义不明的字段按偏移命名,其余未知区域保留原字节。
4.5.1 整体分段与长度公式
令 N 为纹理路径的 ASCII 字节数,样本中 N 只出现 0、65、68:
0…195 固定区,共 196 字节,包含 type 和观察到的跨度196…195+N 纹理路径,N 字节;N=0 时为空字符串196+N NUL 终止符,1 字节,值 0197+N…198+N uint16AfterTexture,2 字节,449 条均为 0199+N…230+N transform,8×float32,共 32 字节
整个记录跨度 = 196 + N + 1 + 2 + 32 = 231 + N| N | 总跨度 | 路径终止符偏移 | 变换起点 | 记录数 |
|---|---|---|---|---|
| 0 | 231 | 196 | 199 | 256 |
| 65 | 296 | 261 | 264 | 28 |
| 68 | 299 | 264 | 267 | 165 |
296 与 299 字节记录之间的差异可由字符串长度解释,无需假设额外的 3 字节参数。
跨度字段的整数宽度尚未确定。从偏移 1 读取 uint16,结果与记录跨度一致;但由于偏移 3、4 均为零,读取 uint32 也会得到相同值。脚本按 uint16 读取跨度,将偏移 3–6 保存为 uint32At3,可以无损还原这些样本。原始 C/C++ 结构采用哪种字段类型,目前还无法确定。
4.5.2 固定区与尾部字段
type=4 的解析字段保存在 fields 对象中,数组下标从 0 开始。顶层 transform 的八个分量与其他节点顺序相同。
| 节点内偏移 | 读取类型 | JSON 字段 | 观察结果与可能含义 |
|---|---|---|---|
| 0 | uint8 | type | 4。 |
| 1–2 | uint16 | observedLengthUint16At1 | 与跨度一致;字段实际位宽仍有上述歧义。 |
| 3–6 | uint32 | fields.uint32At3 | 全为 0;尚不能确定是否为 flags。 |
| 7–10 | uint32 | fields.uint32At7 | 很可能是实例编号,449 条均接续此前同父组件子树的最大实例编号。 |
| 11–14 | uint32 | fields.uint32At11 | 全为 0;可能是类型或标志,但无法区分。 |
| 15–18 | float32 | fields.float32At15To39[0] | 颜色 R 候选,值为 1 或约 0.701961。 |
| 19–22 | float32 | 同数组 [1] | 颜色 G 候选。 |
| 23–26 | float32 | 同数组 [2] | 颜色 B 候选。 |
| 27–30 | float32 | 同数组 [3] | 颜色 A 候选,全部为 1。 |
| 31–34 | float32 | 同数组 [4] | 灯光强度候选,观察值为 1、1.5、2、2.5、4、6。 |
| 35–38 | float32 | 同数组 [5] | 外圈半径/作用范围候选,观察值 200–1300。 |
| 39–42 | float32 | 同数组 [6] | 内圈半径候选,观察值 10–250,全部小于上一字段。 |
| 43–50 | bytes[8] | fields.bytes43To50Hex | 未解释。旧版数值分布与第 7 版明显不同,不能可靠命名。 |
| 51–94 | bytes[44] | fields.bytes51To94Hex | 449 条均为零,保留完整字节;尚无依据将其划分为 11 个数值字段。 |
| 95–98 | float32 | fields.float32At95 | 全为 1;阴影强度等含义仅是候选。 |
| 99–102 | float32 | fields.float32At99 | 全为约 0.001;可能是深度偏移,缺少变化样本。 |
| 103–106 | uint32 | fields.uint32At103 | 全为 256;可能是投影精度,也可能是多个小字段的组合。 |
| 107–110 | float32 | fields.float32At107 | 全为 10;可能是近平面距离,缺少变化样本。 |
| 111–114 | uint32 | fields.uint32At111 | 全为 1;是否为布尔开关尚未确认。 |
| 115–118 | uint32 | fields.uint32At115 | 全为 1;语义未确认。 |
| 119–126 | bytes[8] | fields.bytes119To126Hex | 未解释;按整数观察时有类似地址的数值,但不能确认是指针,不清零。 |
| 127–138 | float32[3] | fields.float32At127To135 | 全为 [1,0,0],含义未确认。 |
| 139–146 | bytes[8] | fields.bytes139To146Hex | 含义未知,能否跨进程重建尚未确认,保留原字节、不清零。 |
| 147–194 | float32[12] | fields.float32At147To191 | 逐项观察见下表,尚不能确定是否为矩阵。 |
| 195 | uint8 | fields.uint8At195 | 全为 0,可能是独立标志或字符串相关信息,未确认。 |
| 196 至 NUL 前 | ASCII | fields.texturePath | 已确认可读 DDS 资源路径,允许空串;保留大小写及斜杠方向。 |
| NUL 位置 | uint8 | 无独立字段 | 编码时写 0,不计入 texturePath。 |
| NUL 后 2 字节 | uint16 | fields.uint16AfterTexture | 全为 0;位置类似子节点数,但没有非零样本,不能据此确认。 |
| 最后 32 字节 | float32[8] | transform | 缩放、四元数、局部平移的强候选;数值结构与其他节点相同。 |
float32At147To191 的取值分布如下,括号内为出现次数。表中的 0.4、0.6、0.7、1.2 是便于阅读的近似值,JSON 保留实际解码得到的 float32 数值。
| 数组下标 | 文件内相对偏移 | 观察值 |
|---|---|---|
| 0、1、2、3 | 147、151、155、159 | 全为 0。 |
| 4 | 163 | 全为 1。 |
| 5 | 167 | 全为 0。 |
| 6 | 171 | 1(398)、0(50)、50(1)。 |
| 7 | 175 | 0(437)、0.4(8)、0.7(3)、0.6(1)。 |
| 8 | 179 | 0(448)、200(1)。 |
| 9 | 183 | 1(448)、1.2(1)。 |
| 10 | 187 | 全为 1。 |
| 11 | 191 | 1(448)、1.2(1)。 |
连续的零值或一值无法说明原始字段在哪里分界。偏移 43、119、139 处的 8 字节区域也尚不能确定是否属于 float64 或 uint64 字段,因此仍保留原字节。
4.5.3 为什么认为是光源组件
- **结构位置:**449 条都在 type=3 的父节点下,且全部是最后一个直接子节点。
- **实例关系:**偏移 7 的 uint32 在每个文件内唯一,与已有
instanceId不冲突;全部等于父节点及其前面兄弟子树的最大instanceId + 1。其中两条记录需要计入兄弟节点的子树,才能得到这一结果。这也支持该字段是实例编号而非家具资源 ID 的判断。 - **资源路径:**193 条有非空路径,均指向
lighttexture目录中的.dds;其余 256 条路径为空。非空路径有 16 种原始字符串,其中部分仅大小写和斜杠方向不同。 - **颜色候选:**偏移 15–30 的四个 float32 全部在
[0,1],第四个恒为 1。偏移 15–42 的七个参数在同一个父representId下完全一致,覆盖 48 种父资源,未发现同资源的多套初始参数。 - **变换候选:**全部 449 条尾部缩放等于父节点缩放;四元数平方范数与 1 的最大误差约
1.192093×10^-7。
颜色空间、强度和半径的单位、灯光子类型及各标志位仍未确定,脚本暂不支持灯光编辑。
4.5.4 已观察纹理路径
下面路径原样保留;反斜杠形式和正斜杠形式不合并。
| 路径 | 记录数 |
|---|---|
| 空字符串 | 256 |
data\source\maps_source\texture\lighttexture\h_light4_4_1008_001.dds | 49 |
data\source\maps_source\texture\lighttexture\h_light1_1_1005_001.dds | 35 |
data\source\maps_source\texture\lighttexture\light-shadow_004.dds | 28 |
data\source\maps_source\texture\lighttexture\h_light2_2_1001_001.dds | 24 |
data\source\maps_source\texture\lighttexture\h_light2_2_1008_001.dds | 9 |
data\source\maps_source\texture\lighttexture\h_light1_1_1003_001.dds | 9 |
data/source/maps_source/Texture/lightTexture/H_Light4_4_1007_001.dds | 8 |
data\source\maps_source\texture\lighttexture\h_light4_4_1007_001.dds | 6 |
data\source\maps_source\texture\lighttexture\h_light2_2_1009_001.dds | 5 |
data\source\maps_source\texture\lighttexture\h_light2_2_1002_001.dds | 5 |
data\source\maps_source\texture\lighttexture\h_light3_3_1003_001.dds | 3 |
data/source/maps_source/Texture/lightTexture/H_Light2_2_1008_001.dds | 3 |
data\source\maps_source\texture\lighttexture\h_light3_3_1002_001.dds | 3 |
data\source\maps_source\texture\lighttexture\h_light2_2_1006_001.dds | 3 |
data\source\maps_source\texture\lighttexture\h_light2_2_1007_001.dds | 2 |
data/source/maps_source/Texture/lightTexture/H_Light2_2_1001_001.dds | 1 |
4.5.5 JSON 表示与重建
重建记录只需 fields、顶层 transform 和 observedLengthUint16At1。解析时额外输出的 rawHex 用于核对原始字节。encode_type4() 依次写入字段、路径、NUL、路径后的整数和变换;若保留了 rawHex,重建结果必须与它一致,否则报错。删去整块 rawHex 不影响还原。
type=4 的 JSON 不设置 children: []。样本中路径后的 uint16 都是零,但尚不清楚它是否表示子节点数,因此也不能认定这类节点没有子结构。脚本只接受该字段为零、路径长度和记录跨度符合已知布局、版本在白名单内的记录。
4.6 变换与遍历限制
transform 为 8 个 float32:
| 数组下标 | 相对变换起点的偏移 | 字段 |
|---|---|---|
| 0 | 0–3 | scale,必须大于零。 |
| 1 | 4–7 | 四元数 qx。 |
| 2 | 8–11 | qy。 |
| 3 | 12–15 | qz。 |
| 4 | 16–19 | qw。 |
| 5 | 20–23 | 局部平移 tx。 |
| 6 | 24–27 | ty。 |
| 7 | 28–31 | tz。 |
所有值必须是有限数值,不接受 NaN 或 Infinity。读写时保留零值、负零和分量顺序,不自动归一化四元数。节点位置通常按米数乘 100 表示;完整的父子变换和挂接规则尚未验证,不能直接把局部平移作为最终世界坐标。
脚本将根节点深度记为 0,最大深度限制为 128,总记录数最多 2,400,001,每个节点最多有 65,535 个直接子节点,文件大小也不能超过前述上限。读取字段前会检查剩余长度;遇到未知类型、截断或尾随字节时立即报错。
5. JSON 定义与还原规则
解析结果保存为 jx3-blueprint-standalone 格式的 JSON,编码时再从中还原二进制文件。这是脚本使用的中间格式,不是游戏原生格式,也不接受结构不同的 JSON。
| 字段 | 类型 | 含义/编码行为 |
|---|---|---|
format | string | 固定为 jx3-blueprint-standalone。 |
schemaVersion | integer | 固定为 2,包含 type=4 的具体字段;不接受旧版仅保存原始块的 JSON。 |
profile | string | v7_subset 或 observed_samples。 |
source.fileName | string | 记录原文件名,不决定格式。 |
source.bytes | integer | 记录原文件大小。 |
source.sha256 | string | 原文件 SHA-256;扩展模式用它核对样本身份,普通模式不把它当修改后文件哈希。 |
header.global | integer | 0 或 1,须与 rawHex 一致。 |
header.version | integer | 内部版本,须与 rawHex 一致。 |
header.byteLength | integer | 前缀长度,须与 rawHex 解码长度一致。 |
header.rawHex | string | 完整前缀原字节,包括前 5 字节通用头;编码时实际写入的依据。 |
root | object | 第 4 节定义的递归节点。 |
节点 byteOffset | integer | 原文件中记录的起点;解析生成,编码时不使用。 |
节点 subtreeByteLength | integer | 原记录及其已解析子树的总字节跨度;编码时不使用。 |
普通模式下,新建文件可以省略 source 和节点的偏移、跨度字段。修改节点不会更新这些记录:source、偏移和跨度仍对应原文件,需要对编码结果重新执行 decode 才能得到新值。childrenCount 由实际子节点数组计算。
已知浮点数先读为 Python float,再通过 JSON 数值写回 float32。手工格式化或四舍五入可能改变原始字节,若要无损还原,应保留解码后的精度。全局未知前缀、类型 2 的未知 5 字节以及 type=4 内部未解释的字节区域通过 hex 保留。type=4 的已拆分数值、路径和变换从字段重建;整块 rawHex 仅作可选交叉核对。
扩展模式在编码后比较整份文件的 SHA-256,只有与白名单中的原样本一致才允许输出。所有编码结果还会重新解析一次,检查格式和正文结束位置。
6. 读写流程与字段维护
读取:二进制文件 → 长度检查 → 验证尾部校验 → 识别版本和支持模式 → 验证场景前缀 → 递归读取节点 → 确认没有剩余正文 → JSON写入:JSON → 检查模式和前缀 → 前序写节点与子节点数量 → 原样保留未知块 → 计算新校验 → 再解析检查 → 二进制保存验证:读取 → JSON 序列化/反序列化 → 编码 → 与原文件逐字节比较seal()、unseal() 负责长度检查和末尾校验的读写,encode()、decode() 检查内部结构。扩展模式还会按文件 SHA-256 确认版本和跨度,只接受已验证的样本。
修改普通模式中的家具数量、位置或层级时,需要同时更新实例 ID、最大 ID、场景包围盒、父子挂点和方向。脚本不会自动计算这些值。局部文件与全局文件也不能只靠替换文件头来转换。
7. Python 实现
将以下代码保存为 blueprint_tool.py,使用 Python 3.10 或更新版本运行,只需标准库。命令用法见第 8 节。
输入文件只读。输出文件的父目录需要预先创建;目标文件若已存在,脚本会报错,不会覆盖。
"""标准库蓝图读写工具:第 7 版已知子集,以及三份固定扩展样本。"""from __future__ import annotations
import argparsefrom collections import Counterimport hashlibimport hmacimport jsonimport mathfrom pathlib import Pathimport struct
MAX_FILE_BYTES = 32_000_000MAX_DEPTH = 128MAX_NODES = 2_400_001OBJECT_TYPES = (0, 3, 8)SOCKET_TYPES = (5, 6, 7)OBSERVED_PROFILES = { "bb323f6358bb9755531c59e1babc71cdebef061c0b76cf2d2ecb671be32358b2": (4, 46), "791d1c2440e70aae4baa2d710c517cd7fe1c4ebbdb2b6e238ddf2cae0ac8b8d3": (3, 46), "d090547217b1ad06a558e1ce854c60c4d28749d862802f304f978e664bf04e9b": (7, 222),}
# 偏移明确不等于业务含义明确;按偏移命名,避免把推测固化为协议字段名。TYPE4_VALUE_FIELDS = ( ("uint32At3", 3, "<I"), ("uint32At7", 7, "<I"), ("uint32At11", 11, "<I"), ("float32At15To39", 15, "<7f"), ("float32At95", 95, "<f"), ("float32At99", 99, "<f"), ("uint32At103", 103, "<I"), ("float32At107", 107, "<f"), ("uint32At111", 111, "<I"), ("uint32At115", 115, "<I"), ("float32At127To135", 127, "<3f"), ("float32At147To191", 147, "<12f"), ("uint8At195", 195, "<B"),)TYPE4_RAW_FIELDS = ( ("bytes43To50Hex", 43, 8), ("bytes51To94Hex", 51, 44), ("bytes119To126Hex", 119, 8), ("bytes139To146Hex", 139, 8),)
def decode_type4(raw: bytes) -> dict: if (len(raw) not in (231, 296, 299) or raw[0] != 4 or struct.unpack_from("<H", raw, 1)[0] != len(raw)): raise ValueError("未确认的 type=4 记录跨度") # 三份样本的字符串从 196 开始,到第一个 NUL 结束,随后是 2+32 字节。 end = raw.find(b"\0", 196) if end < 196 or end + 35 != len(raw): raise ValueError("type=4 路径终止位置或尾部长度不匹配") path = raw[196:end].decode("ascii") if any(not 32 <= ord(char) <= 126 for char in path): raise ValueError("type=4 路径不是已观察的可打印 ASCII") fields = {} for name, offset, fmt in TYPE4_VALUE_FIELDS: values = struct.unpack_from(fmt, raw, offset) if fmt.endswith("f") and not all(math.isfinite(x) for x in values): raise ValueError(f"type=4 非有限浮点数:{name}") fields[name] = values[0] if len(values) == 1 else list(values) for name, offset, size in TYPE4_RAW_FIELDS: fields[name] = raw[offset:offset + size].hex() fields["texturePath"] = path fields["uint16AfterTexture"] = struct.unpack_from("<H", raw, end + 1)[0] if fields["uint16AfterTexture"] != 0: raise ValueError("未观察到非零的 type=4 路径后字段") transform = list(struct.unpack_from("<8f", raw, end + 3)) validate_transform(transform) return {"type": 4, "interpretation": "partially_decoded_light_candidate", "observedLengthUint16At1": len(raw), "fields": fields, "transform": transform, "rawHex": raw.hex()}
def encode_type4(node: dict) -> bytes: fields = node["fields"] path = fields["texturePath"].encode("ascii") if any(not 32 <= byte <= 126 for byte in path): raise ValueError("type=4 路径只能包含可打印 ASCII,不能含 NUL") length = 231 + len(path) if length not in (231, 296, 299) or node["observedLengthUint16At1"] != length: raise ValueError("type=4 路径长度与记录跨度不符") output = bytearray(length) struct.pack_into("<BH", output, 0, 4, length) for name, offset, fmt in TYPE4_VALUE_FIELDS: value = fields[name] count = len(struct.unpack(fmt, bytes(struct.calcsize(fmt)))) values = [value] if count == 1 else value if not isinstance(values, list) or len(values) != count: raise ValueError(f"type=4 字段项数错误:{name}") if fmt.endswith("f") and any(type(x) not in (int, float) or not math.isfinite(x) for x in values): raise ValueError(f"type=4 非有限浮点数:{name}") struct.pack_into(fmt, output, offset, *values) for name, offset, size in TYPE4_RAW_FIELDS: raw = bytes.fromhex(fields[name]) if len(raw) != size: raise ValueError(f"type=4 原始区域长度错误:{name}") output[offset:offset + size] = raw output[196:196 + len(path)] = path end = 196 + len(path) if fields["uint16AfterTexture"] != 0: raise ValueError("不支持非零的 type=4 路径后字段") struct.pack_into("<BH", output, end, 0, fields["uint16AfterTexture"]) validate_transform(node["transform"]) struct.pack_into("<8f", output, end + 3, *node["transform"]) # rawHex 只用于交叉核对;移除此字段后仍从各字段重建,而非回放整块。 if "rawHex" in node and bytes(output) != bytes.fromhex(node["rawHex"]): raise ValueError("type=4 字段与整块 rawHex 冲突") return bytes(output)
def checksum(payload: bytes | bytearray) -> bytes: digest = hashlib.md5(payload, usedforsecurity=False).digest() return bytes(digest[(i + 1) % 16] ^ 0x33 for i in range(16))
def recover_md5(trailer: bytes) -> bytes: if len(trailer) != 16: raise ValueError("校验段必须为 16 字节") return bytes(trailer[(i - 1) % 16] ^ 0x33 for i in range(16))
def unseal(data: bytes) -> bytes: if not 26 <= len(data) <= MAX_FILE_BYTES: raise ValueError("文件长度无效") payload, trailer = data[:-16], data[-16:] if not hmac.compare_digest(checksum(payload), trailer): raise ValueError("蓝图校验失败") return payload
def seal(payload: bytes | bytearray) -> bytes: if not 10 <= len(payload) <= MAX_FILE_BYTES - 16: raise ValueError("正文长度无效") return bytes(payload) + checksum(payload)
def validate_transform(values: list) -> None: if not isinstance(values, list) or len(values) != 8: raise ValueError("transform 必须是 8 个数的数组") if any(type(x) not in (int, float) or not math.isfinite(x) for x in values): raise ValueError("transform 包含非有限数值") if values[0] <= 0: raise ValueError("缩放必须大于零")
def validate_v7_prefix(prefix: bytes) -> None: if len(prefix) != 222 or struct.unpack_from("<BI", prefix) != (1, 7): raise ValueError("不是受支持的第 7 版全局前缀") for offset in (*range(5, 37, 4), *range(62, 102, 4), *range(182, 222, 4)): if not math.isfinite(struct.unpack_from("<f", prefix, offset)[0]): raise ValueError(f"场景前缀含非有限数值:{offset}") if prefix[41] != 0 or struct.unpack_from("<I", prefix, 42)[0] != 0: raise ValueError("不支持带恢复信息的前缀") if struct.unpack_from("<II", prefix, 46) != (2, 3): raise ValueError("不支持这种地表格式") for index in range(3): if struct.unpack_from("<IIIIIB", prefix, 102 + index * 21) != (0, 0, index, 0, 0, 0): raise ValueError("不支持地表绘制") if struct.unpack_from("<I", prefix, 165)[0] != 1 or prefix[177] != 0: raise ValueError("不支持植被绘制或未知植被格式")
def validate_v7_root(root: dict) -> None: transform = root.get("transform") if (root["type"] != 5 or root.get("index") != 0 or not root["children"] or transform is None or transform[:5] != [1, 0, 0, 0, 1]): raise ValueError("不支持这种第 7 版全局根节点或方向")
class Reader: def __init__(self, payload: bytes, offset: int, extended: bool): self.payload, self.offset, self.extended = payload, offset, extended self.nodes = 0
def read(self, fmt: str) -> tuple: size = struct.calcsize(fmt) if self.offset + size > len(self.payload): raise ValueError(f"字段被截断:{self.offset}") values = struct.unpack_from(fmt, self.payload, self.offset) self.offset += size return values
def node(self, depth: int = 0) -> dict: self.nodes += 1 if self.nodes > MAX_NODES or depth > MAX_DEPTH: raise ValueError("节点数或深度超限") start = self.offset kind = self.read("<B")[0] node = {"type": kind, "byteOffset": start} if self.extended and kind == 4: length = self.read("<H")[0] if length not in (231, 296, 299) or start + length > len(self.payload): raise ValueError("未确认的 type=4 记录跨度") node.update(decode_type4(self.payload[start:start + length])) self.offset = start + length else: if kind in OBJECT_TYPES or (self.extended and kind == 2): flags, instance, represent, p1, p2 = self.read("<IIIBB") node.update(flags=flags, instanceId=instance, representId=represent, p1=p1, p2=p2) if kind == 2: node["uninterpretedExtensionAt15Hex"] = bytes(self.read("<5B")).hex() count = self.read("<H")[0] elif kind in SOCKET_TYPES: node["index"], count = self.read("<HH") elif self.extended and kind in (1, 9): field1, field5 = self.read("<II") fields = {"uint32At1": field1, "uint32At5": field5} if kind == 9: fields["uint8At9"] = self.read("<B")[0] node["uninterpretedFields"] = fields count = self.read("<H")[0] else: raise ValueError(f"不支持的节点类型:{kind},偏移:{start}") if kind not in SOCKET_TYPES or count: node["transform"] = list(self.read("<8f")) validate_transform(node["transform"]) node["children"] = [self.node(depth + 1) for _ in range(count)] node["subtreeByteLength"] = self.offset - start return node
def decode(data: bytes, name: str = "") -> dict: payload = unseal(data) digest = hashlib.sha256(data).hexdigest() flag, version = struct.unpack_from("<BI", payload) observed = OBSERVED_PROFILES.get(digest) if observed: expected_version, prefix_length = observed if (flag, version) != (1, expected_version): raise ValueError("样本头不匹配") profile = "observed_samples" else: if flag not in (0, 1) or version != 7: raise ValueError("不支持该版本;旧版扩展仅接受三份固定样本") prefix_length = 222 if flag else 5 profile = "v7_subset" if flag and version == 7: if len(data) < 275: raise ValueError("全局蓝图长度不足") validate_v7_prefix(payload[:prefix_length]) reader = Reader(payload, prefix_length, observed is not None) root = reader.node() if reader.offset != len(payload): raise ValueError("节点树结束后存在未解释字节") if flag and profile == "v7_subset": validate_v7_root(root) return { "format": "jx3-blueprint-standalone", "schemaVersion": 2, "profile": profile, "source": {"fileName": name, "bytes": len(data), "sha256": digest}, "header": {"global": flag, "version": version, "byteLength": prefix_length, "rawHex": payload[:prefix_length].hex()}, "root": root, }
def encode(document: dict) -> bytes: if document["format"] != "jx3-blueprint-standalone" or document["schemaVersion"] != 2: raise ValueError("JSON 格式或版本不匹配") profile = document["profile"] header = document["header"] output = bytearray.fromhex(header["rawHex"]) if len(output) != header["byteLength"] or len(output) < 5: raise ValueError("前缀长度不匹配") flag, version = struct.unpack_from("<BI", output) if (flag, version) != (header["global"], header["version"]): raise ValueError("头字段与原始前缀冲突") extended = profile == "observed_samples" if extended: source_hash = document["source"]["sha256"] expected = OBSERVED_PROFILES.get(source_hash) if flag != 1 or expected != (version, len(output)): raise ValueError("扩展样本身份不匹配") elif profile == "v7_subset": if version != 7 or flag not in (0, 1) or len(output) != (222 if flag else 5): raise ValueError("不是受支持的第 7 版头") else: raise ValueError("不支持的解析配置") if flag and version == 7: validate_v7_prefix(bytes(output)) pending, total = [(document["root"], 0)], 0 while pending: node, depth = pending.pop() total += 1 if total > MAX_NODES or depth > MAX_DEPTH: raise ValueError("节点数或深度超限") kind = node["type"] if type(kind) is not int: raise ValueError("节点类型必须为整数") if extended and kind == 4: output.extend(encode_type4(node)) else: children = node["children"] if not isinstance(children, list) or len(children) > 65535: raise ValueError("子节点必须是至多 65535 项的数组") output.extend(struct.pack("<B", kind)) if kind in OBJECT_TYPES or (extended and kind == 2): output.extend(struct.pack("<IIIBB", node["flags"], node["instanceId"], node["representId"], node["p1"], node["p2"])) if kind == 2: extra = bytes.fromhex(node["uninterpretedExtensionAt15Hex"]) if len(extra) != 5: raise ValueError("type=2 扩展必须为 5 字节") output.extend(extra) output.extend(struct.pack("<H", len(children))) elif kind in SOCKET_TYPES: output.extend(struct.pack("<HH", node["index"], len(children))) elif extended and kind in (1, 9): fields = node["uninterpretedFields"] output.extend(struct.pack("<II", fields["uint32At1"], fields["uint32At5"])) if kind == 9: output.extend(struct.pack("<B", fields["uint8At9"])) output.extend(struct.pack("<H", len(children))) else: raise ValueError(f"不支持的节点类型:{kind}") if kind not in SOCKET_TYPES or children: validate_transform(node["transform"]) output.extend(struct.pack("<8f", *node["transform"])) elif "transform" in node: raise ValueError("空挂点不能携带 transform") # 栈后进先出,因此反向入栈才能维持文件中的前序顺序。 pending.extend((child, depth + 1) for child in reversed(children)) if len(output) + 16 > MAX_FILE_BYTES: raise ValueError("文件长度超限") data = seal(output) if extended and hashlib.sha256(data).hexdigest() != source_hash: raise ValueError("扩展样本仅支持原样重建,不支持修改后导出") decode(data) # 再读一次,确认输出仍满足同一套格式约束。 return data
def read_binary(path: Path) -> bytes: with path.open("rb") as source: data = source.read(MAX_FILE_BYTES + 1) if len(data) > MAX_FILE_BYTES: raise ValueError("文件长度超限") return data
def analyze_type4(document: dict) -> dict: """复现字段还原与父子关系统计;结果是证据,不是字段语义认证。""" records, normal_ids = [], set() pending = [(document["root"], None)] while pending: node, parent = pending.pop() if "instanceId" in node: normal_ids.add(node["instanceId"]) if node["type"] == 4: records.append((node, parent)) pending.extend((child, node) for child in node.get("children", [])) checks = Counter() lengths, paths, parents = Counter(), Counter(), Counter() ids, norm_errors, parameter_sets = [], [], {} for node, parent in records: fields = node["fields"] raw = bytes.fromhex(node["rawHex"]) without_raw = dict(node) del without_raw["rawHex"] checks["fieldReencodeIdenticalWithoutRawHex"] += encode_type4(without_raw) == raw lengths[len(raw)] += 1 paths[fields["texturePath"]] += 1 ids.append(fields["uint32At7"]) checks["lengthEquals231PlusPathBytes"] += len(raw) == 231 + len(fields["texturePath"].encode("ascii")) checks["normalizedColorCandidate"] += all(0 <= x <= 1 for x in fields["float32At15To39"][:4]) norm_errors.append(abs(sum(x * x for x in node["transform"][1:5]) - 1)) if parent is not None: parents[parent["type"]] += 1 checks["isLastDirectChild"] += parent["children"][-1] is node checks["scaleEqualsParent"] += node["transform"][0] == parent["transform"][0] preceding_ids = [parent["instanceId"]] if "instanceId" in parent else [] preceding = parent["children"][:parent["children"].index(node)] while preceding: previous = preceding.pop() if "instanceId" in previous: preceding_ids.append(previous["instanceId"]) preceding.extend(previous.get("children", [])) checks["idEqualsPrecedingSubtreeMaxPlusOne"] += bool(preceding_ids) and fields["uint32At7"] == max(preceding_ids) + 1 resource = parent.get("representId") if resource is not None: parameter_sets.setdefault(resource, set()).add(tuple(fields["float32At15To39"])) return { "file": document["source"]["fileName"], "type4Count": len(records), "lengthCounts": dict(sorted(lengths.items())), "pathCounts": dict(sorted(paths.items())), "parentTypeCounts": dict(sorted(parents.items())), "checksPassedCount": dict(checks), "uniqueCandidateIds": len(set(ids)), "candidateIdCollisionsWithKnownNodes": len(set(ids) & normal_ids), "maxQuaternionSquaredNormError": max(norm_errors, default=0), "parentResourceCount": len(parameter_sets), "resourcesWithMultipleParameterSets": sorted(key for key, values in parameter_sets.items() if len(values) != 1), }
def main() -> None: parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest="command", required=True) for name in ("decode", "encode"): command = commands.add_parser(name) command.add_argument("source", type=Path) command.add_argument("output", type=Path) commands.add_parser("verify").add_argument("source", type=Path) commands.add_parser("demo").add_argument("output", type=Path) commands.add_parser("type4-report").add_argument("sources", type=Path, nargs="+") args = parser.parse_args() try: if args.command == "type4-report": reports = [analyze_type4(decode(read_binary(path), path.name)) for path in args.sources] print(json.dumps(reports, ensure_ascii=False, allow_nan=False, indent=2)) elif args.command in ("decode", "verify"): original = read_binary(args.source) document = decode(original, args.source.name) serialized = json.dumps(document, ensure_ascii=False, allow_nan=False, indent=2) if encode(json.loads(serialized)) != original: raise ValueError("JSON 往返重编码与原文件不同") if args.command == "decode": with args.output.open("x", encoding="utf-8") as output: output.write(serialized + "\n") print(json.dumps({"file": args.source.name, "profile": document["profile"], "version": document["header"]["version"], "checksumValid": True, "jsonRoundtripByteIdentical": True}, ensure_ascii=False)) else: if args.command == "encode": document = json.loads(args.source.read_text(encoding="utf-8")) else: # 最小空挂点,仅用于验证二进制封装,不是可摆放的家具设计。 document = { "format": "jx3-blueprint-standalone", "schemaVersion": 2, "profile": "v7_subset", "header": {"global": 0, "version": 7, "byteLength": 5, "rawHex": "0007000000"}, "root": {"type": 5, "index": 0, "children": []}, } data = encode(document) with args.output.open("xb") as output: output.write(data) print(f"已保存 {len(data)} 字节:{args.output}") except (ValueError, KeyError, TypeError, OSError, struct.error, OverflowError, RecursionError) as exc: parser.error(str(exc))
if __name__ == "__main__": main()8. 运行方法与最小示例
在脚本所在目录执行以下命令,将示例路径替换为实际文件路径。python3 需指向 Python 3.10 或更新版本:
# 先验证校验、完整结构和 JSON 往返,不写文件。python3 blueprint_tool.py verify input.blueprintx
# 二进制转 JSON;输出文件不得已经存在。python3 blueprint_tool.py decode input.blueprintx output.json
# 只依赖上一步 JSON 还原完整二进制,不需要原蓝图文件。python3 blueprint_tool.py encode output.json rebuilt.blueprintx
# 生成一个 26 字节的空局部组件测试件,不需要家具库或已有蓝图。python3 blueprint_tool.py demo empty.blueprintxpython3 blueprint_tool.py verify empty.blueprintxpython3 blueprint_tool.py decode empty.blueprintx empty.jsonpython3 blueprint_tool.py encode empty.json empty-rebuilt.blueprintx
# 针对一个或多个支持文件,复现 type=4 字段与父子关系统计。python3 blueprint_tool.py type4-report sample1.blueprintx sample2.blueprintx sample3.blueprintx空组件用于测试编码规则,不能作为家具设计摆放。也可以将下面的 JSON 保存为 UTF-8 编码的 empty-input.json,再执行 encode:
{ "format": "jx3-blueprint-standalone", "schemaVersion": 2, "profile": "v7_subset", "header": { "global": 0, "version": 7, "byteLength": 5, "rawHex": "0007000000" }, "root": { "type": 5, "index": 0, "children": [] }}它的正文是 00 07 00 00 00 05 00 00 00 00。前 5 字节为局部头,后 5 字节为 type=5、index=0、子节点数=0 的空挂点;末尾另附 16 字节校验。
type4-report 输出每份文件的记录数、长度/路径分布、逐字段重建结果、候选实例编号与父节点关系、四元数误差及同资源参数一致性。文件不含 type=4 时,记录数为零。
verify 成功时返回 checksumValid: true 与 jsonRoundtripByteIdentical: true。decode 在保存 JSON 前也会执行相同的往返检查。输入损坏、布局不受支持、JSON 字段不一致或输出文件已存在时,命令会报错并以非零状态码退出。
9. 样本与验证结果
9.1 样本信息
下表的 SHA-256 均由整份原文件计算。A 按第 7 版普通模式解析,B、C、D 按扩展模式解析,并列入样本白名单。
| 编号 | 原文件名 | 完整文件 SHA-256 |
|---|---|---|
| A | HomelandDesign_20261019-092347_F22.blueprintx16 | 84d8669a315ecaaf29510b34909d7f8ebe8dd38f006f80d1e1e996d7191c3863 |
| B | BP7267B28C427751D4AA77.blueprintx | bb323f6358bb9755531c59e1babc71cdebef061c0b76cf2d2ecb671be32358b2 |
| C | BPBE586571CC0E8E48970C.blueprintx | 791d1c2440e70aae4baa2d710c517cd7fe1c4ebbdb2b6e238ddf2cae0ac8b8d3 |
| D | BP9258ABC71DBBACAB7C0E.blueprintx | d090547217b1ad06a558e1ce854c60c4d28749d862802f304f978e664bf04e9b |
| 编号 | 内部版本 | global | 文件字节数 | 前缀字节数 | 解析记录数 | type=4 记录数 |
|---|---|---|---|---|---|---|
| A | 7 | 1 | 157,981 | 222 | 7,867 | 0 |
| B | 4 | 1 | 661,313 | 46 | 24,890 | 175 |
| C | 3 | 1 | 390,900 | 46 | 15,032 | 223 |
| D | 7 | 1 | 230,792 | 222 | 9,548 | 51 |
记录数包含挂点和光源候选组件等记录,不是家具件数。A 含 2,622 条 type=0 记录和 5,245 条 type=5 记录。
| 编号 | 231 字节 type=4 | 296 字节 type=4 | 299 字节 type=4 |
|---|---|---|---|
| B | 94 | 18 | 63 |
| C | 123 | 10 | 90 |
| D | 39 | 0 | 12 |
9.2 校验数据
| 编号 | MD5(payload) 的十六进制表示 | 原文件末尾 16 字节的十六进制表示 |
|---|---|---|
| A | 2a8d6ae452e8b2f359d74dc674c3d716 | be59d761db81c06ae47ef547f0e42519 |
| B | 3565ceb3fda495e30b130ce420e48820 | 56fd80ce97a6d038203fd713d7bb1306 |
| C | 65845e1f2a7a485540cf50adf3063014 | b76d2c19497b6673fc639ec035032756 |
| D | 39053c6b205da2c5c1bc5db60a5074e9 | 360f58136e91f6f28f6e85396347da0a |
例如 A 的 MD5 第二字节是 8d,8d XOR 33 = be,对应校验首字节;MD5 首字节是 2a,2a XOR 33 = 19,对应校验末字节。
9.3 文件解析与验证边界
第 9.1 节中的四份蓝图均已完成解析、JSON 保存和二进制重编码。以下是脚本的验证结果:
- 四份文件均通过末尾校验和完整节点遍历。保存为 JSON 后重新编码,输出与原文件逐字节一致。
- 摘要逆变换恢复正确 MD5;正文修改但不更新校验时被拒绝。
- 命令行可完成空组件的生成、解析和还原。从 JSON 还原时不需要原蓝图文件。
- 未支持节点、截断、正确校验下的尾随数据、非法浮点及空挂点变换被拒绝。
- 扩展样本修改后导出被拒绝,已有输出文件不会被覆盖。
验证范围限于上述格式和样本。type=4 已能按字段还原,但部分光源参数的含义仍是推测,一些字段也尚未解释。完整的父子空间变换、碰撞、光照和游戏内导入均未验证;同版本的其他蓝图也可能使用不同的头部长度。
9.4 type=4 验证结果
测试使用第 7 节的脚本,在 Python 隔离模式下运行,检查了 B、C、D 三份文件中的全部 type=4 记录:
| 检查项 | B:175 条 | C:223 条 | D:51 条 |
|---|---|---|---|
删除整块 rawHex 后,逐字段重建仍与原记录逐字节一致 | 175 | 223 | 51 |
跨度等于 231 + 路径 ASCII 字节数 | 175 | 223 | 51 |
| 所有原始区域和解析字段连续覆盖整条记录 | 175 | 223 | 51 |
| 父类型为 3,且是最后一个直接子节点 | 175 | 223 | 51 |
| 偏移 7 的编号等于父及前面兄弟子树的最大实例编号 + 1 | 175 | 223 | 51 |
| 尾部缩放等于父节点缩放 | 175 | 223 | 51 |
颜色候选四元组各分量位于 [0,1] | 175 | 223 | 51 |
| 候选编号与普通节点已有实例编号冲突 | 0 | 0 | 0 |
移除三份文件中所有 type=4 的整块 rawHex,仅保留具体字段及局部未知区域后,JSON 往返仍能逐字节还原整份文件。A 文件也能按第 7 版普通模式解析,最小空组件的生成与还原检查均通过。
其他验证结果:
- 偏移 196 的字符串与原始切片一致,末尾变换与原文件最后 32 字节独立解包结果一致。
- 在单记录编码层修改
uint32At7,只有偏移 7–10 发生变化,确认编码时使用了字段值;保留旧rawHex时会因冲突被拒绝。整份扩展文件仍受原 SHA-256 限制,无法导出修改版本。 - 非 ASCII 路径、嵌入 NUL、错误路径终止位置、非零路径后字段、NaN、数组项数错误、未知区域长度错误、记录跨度不符均被拒绝。
schemaVersion=1被拒绝。工具使用版本 2,需要重新执行decode,生成包含具体字段的 JSON;旧 JSON 中缺失的字段不会用默认值补齐。- type=4 的字段布局能与样本字节逐一对应,但验证范围仍限于这三份文件。含义不明的字段按偏移命名,未解析区域保留原字节。
9.5 type=4 记录示例
以下为 B 文件偏移 520,770、跨度 296 的 type=4 节点。父节点为 type=3, instanceId=231678, representId=8066;前面两个兄弟的实例编号为 231679、231680,本记录偏移 7 的值为 231681。路径长 65 字节,末尾变换从节点内偏移 264 开始。
下面的节点对象可直接传给 encode_type4(node),但不能作为完整蓝图 JSON 使用。它省略了整块 rawHex,保留了重建所需的未知区域;1.2 等数值仍使用 float32 解码后的精度。
{ "type": 4, "interpretation": "partially_decoded_light_candidate", "observedLengthUint16At1": 296, "fields": { "uint32At3": 0, "uint32At7": 231681, "uint32At11": 0, "float32At15To39": [ 1.0, 0.5568780303001404, 0.18269626796245575, 1.0, 2.0, 300.0, 10.0 ], "float32At95": 1.0, "float32At99": 0.0010000000474974513, "uint32At103": 256, "float32At107": 10.0, "uint32At111": 1, "uint32At115": 1, "float32At127To135": [ 1.0, 0.0, 0.0 ], "float32At147To191": [ 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0 ], "uint8At195": 0, "bytes43To50Hex": "00ef5f3528000000", "bytes51To94Hex": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "bytes119To126Hex": "0000000000000000", "bytes139To146Hex": "067bc30e98010000", "texturePath": "data\\source\\maps_source\\texture\\lighttexture\\light-shadow_004.dds", "uint16AfterTexture": 0 }, "transform": [ 1.2000000476837158, 0.0, 0.0, 0.0, 1.0, 0.0, 32.54927062988281, 0.0 ]}encode_type4() 对此对象返回 296 字节,与原文件 [520770:521066] 完全相同。前七个浮点数可能依次表示颜色 (1,0.556878,0.182696,1)、强度 2、外圈范围 300 和内圈范围 10。这些含义仍待确认,场景中的实际光照效果尚未验证。