游戏

剑网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 格式含义
uint81<B无符号整数,0–255。
uint162<H无符号整数,0–65,535。
uint324<I无符号整数,0–4,294,967,295。
float324<fIEEE 754 单精度浮点数。
原始字节指定长度直接切片不作字符集解码;JSON 中保存为十六进制字符串。

2.2 文件封装

代码窗口
file = payload || trailer
payload = prefix || rootNode(递归包含所有子节点)
trailer = transform(MD5(payload)),固定 16 字节

|| 表示字节拼接。正文包括头部和节点树,不含末尾的校验段。文件类型和版本需要读取头部确认:.blueprintx 也可能是全局文件,.blueprintx16 中的 16 不是内部版本号。

文件偏移类型/长度字段规则
0uint8global0 是局部组件,1 是全局蓝图。
1–4uint32version四份样本中的版本号为 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"") = d41d8cd98f00b204e9800998ecf8427e
checksum(b"") = 2ebfeabc338137dab33aabdfcb714de7

3. 全局场景前缀字段

3.1 第 7 版:222 字节前缀

这套布局的前缀共 222 字节,字段如下。表中的固定值是脚本接受的范围,并非所有游戏地图都使用这些值。未知区域需保留原字节,不能填零。

文件偏移类型/长度已知用途或限制
0uint8global=1。
1–4uint32version=7。
5–8float32宽度参数,按 width × 50 表示,样本为 10,600。
9–12float32长度参数,按 length × 50 表示,样本为 10,800。
13–16float32变换后包围盒的 minX。
17–20float32minY。
21–24float32minZ。
25–28float32maxX。
29–32float32maxY。
33–36float32maxZ。
37–40uint32实例编号上界,按 最大 instanceId + 1 表示,初始样本为 2,623。
41uint8支持子集要求为 0,非零视为不支持的恢复信息。
42–45uint32支持子集要求为 0,恢复相关具体语义未解释。
46–49uint32支持子集固定要求为 2,地表格式标记。
50–53uint32支持子集固定要求为 3。
54–61bytes[8]未解释,保留原字节。
62–101float32[10]地表参数;各值须有限,各项含义尚未确认。第 k 项起点为 62+4k,k=0…9。
102–12221 字节记录地表记录 index=0,布局见下表。
123–14321 字节记录地表记录 index=1。
144–16421 字节记录地表记录 index=2。
165–168uint32支持子集固定要求为 1,植被格式标记。
169–176bytes[8]未解释,保留原字节。
177uint8支持子集要求为 0,非零视为植被绘制或未知格式。
178–181bytes[4]未解释,保留原字节。
182–221float32[10]植被参数;各值须有限,各项含义尚未确认。第 k 项起点为 182+4k。

地表记录起点 s = 102 + 21 × index:

相对记录偏移类型支持子集中的值
0–3uint320
4–7uint320
8–11uint32index,依次为 0、1、2
12–15uint320
16–19uint320
20uint80

v7_subset 模式要求全局根节点为 type=5,index=0,至少有一个子节点,变换的前五项为 [1,0,0,0,1]。旧版样本和第 7 版扩展样本按各自的节点树读取,不受这组根节点方向约束。

3.2 两份旧版样本:46 字节前缀

文件偏移类型/长度解析方式
0uint8两份样本均为 1。
1–4uint32分别为 3、4。
5–36float32[8] 的位置观察值可按此类型读取,但未将第 7 版的尺寸/包围盒语义套用到旧版。
37–40uint32 的位置原样保留,不推断与第 7 版字段含义相同。
41uint8 的位置原样保留,具体含义尚未确认。
42–45uint32 的位置原样保留,具体含义尚未确认。

工具将完整前缀保存在 header.rawHex 中,编码时直接写回。第 4 版样本的根节点带有旋转,不能要求其四元数为单位四元数。

4. 节点布局

每个节点以 type 开始,固定区后紧接按顺序排列的子节点。节点树采用前序布局,没有统一的“节点总长度”字段。childrenCount 只表示直接子节点数,因此需要递归读完每棵子树,才能找到下一个同级节点。type=4 的布局另行说明,它包含样本中观察到的跨度字段和变长路径。

4.1 type=0/3/8:固定区 49 字节

节点内偏移类型JSON 字段含义
0uint8type0、3 或 8。
1–4uint32flags原值保留,不解释各标志位。
5–8uint32instanceId实例编号。
9–12uint32representId表现资源 ID;不是家具物品 furnitureId。
13uint8p1参数字节,具体含义尚未确认。
14uint8p2参数字节,具体含义尚未确认。
15–16uint16不单独保存childrenCount,编码时由 len(children) 得到。
17–48float32[8]transform无论是否有子节点都存在。
49 起Node[]children顺序排列的子树。

固定区对应的格式为 <BIIIBBH8f。这几种节点虽然布局相同,在游戏中的用途却可能不同,不能直接替换类型值。

4.2 type=5/6/7:固定区 5 或 37 字节

节点内偏移类型JSON 字段规则
0uint8type5、6 或 7。
1–2uint16index挂点等结构的索引。
3–4uint16不单独保存childrenCount。
5–36float32[8]transform仅在 childrenCount > 0 时存在。
5 或 37 起Node[]children无子节点时记录在偏移 5 处结束;有子节点时从 37 开始。

空挂点没有变换字段,若将所有挂点都按 37 字节读取,后续数据就会错位。输入的空挂点若带有 transform,工具会报错,不会直接丢弃该字段。

4.3 type=1 与 type=9:只对固定样本开放

字段类型type=1 偏移type=9 偏移JSON 字段
类型uint800type
未解释字段 1uint321–41–4uninterpretedFields.uint32At1
未解释字段 2uint325–85–8uninterpretedFields.uint32At5
额外字节uint8不存在9uninterpretedFields.uint8At9
子节点数uint169–1010–11由 children 长度恢复
变换float32[8]11–4212–43transform,总是存在
子树Node[]43 起44 起children

两个 uint32 的含义尚未确认,没有证据表明它们是家具 ID。两种类型的固定区分别为 43、44 字节。

4.4 type=2:固定区 54 字节,只对固定样本开放

节点内偏移类型JSON 字段
0uint8type=2
1–4uint32flags
5–8uint32instanceId
9–12uint32representId
13uint8p1
14uint8p2
15–19bytes[5]uninterpretedExtensionAt15Hex,10 个十六进制字符
20–21uint16子节点数
22–53float32[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 字节,值 0
197+N…198+N uint16AfterTexture,2 字节,449 条均为 0
199+N…230+N transform,8×float32,共 32 字节
整个记录跨度 = 196 + N + 1 + 2 + 32 = 231 + N
N总跨度路径终止符偏移变换起点记录数
0231196199256
6529626126428
68299264267165

296 与 299 字节记录之间的差异可由字符串长度解释,无需假设额外的 3 字节参数。

跨度字段的整数宽度尚未确定。从偏移 1 读取 uint16,结果与记录跨度一致;但由于偏移 3、4 均为零,读取 uint32 也会得到相同值。脚本按 uint16 读取跨度,将偏移 3–6 保存为 uint32At3,可以无损还原这些样本。原始 C/C++ 结构采用哪种字段类型,目前还无法确定。

4.5.2 固定区与尾部字段

type=4 的解析字段保存在 fields 对象中,数组下标从 0 开始。顶层 transform 的八个分量与其他节点顺序相同。

节点内偏移读取类型JSON 字段观察结果与可能含义
0uint8type4。
1–2uint16observedLengthUint16At1与跨度一致;字段实际位宽仍有上述歧义。
3–6uint32fields.uint32At3全为 0;尚不能确定是否为 flags。
7–10uint32fields.uint32At7很可能是实例编号,449 条均接续此前同父组件子树的最大实例编号。
11–14uint32fields.uint32At11全为 0;可能是类型或标志,但无法区分。
15–18float32fields.float32At15To39[0]颜色 R 候选,值为 1 或约 0.701961。
19–22float32同数组 [1]颜色 G 候选。
23–26float32同数组 [2]颜色 B 候选。
27–30float32同数组 [3]颜色 A 候选,全部为 1。
31–34float32同数组 [4]灯光强度候选,观察值为 1、1.5、2、2.5、4、6。
35–38float32同数组 [5]外圈半径/作用范围候选,观察值 200–1300。
39–42float32同数组 [6]内圈半径候选,观察值 10–250,全部小于上一字段。
43–50bytes[8]fields.bytes43To50Hex未解释。旧版数值分布与第 7 版明显不同,不能可靠命名。
51–94bytes[44]fields.bytes51To94Hex449 条均为零,保留完整字节;尚无依据将其划分为 11 个数值字段。
95–98float32fields.float32At95全为 1;阴影强度等含义仅是候选。
99–102float32fields.float32At99全为约 0.001;可能是深度偏移,缺少变化样本。
103–106uint32fields.uint32At103全为 256;可能是投影精度,也可能是多个小字段的组合。
107–110float32fields.float32At107全为 10;可能是近平面距离,缺少变化样本。
111–114uint32fields.uint32At111全为 1;是否为布尔开关尚未确认。
115–118uint32fields.uint32At115全为 1;语义未确认。
119–126bytes[8]fields.bytes119To126Hex未解释;按整数观察时有类似地址的数值,但不能确认是指针,不清零。
127–138float32[3]fields.float32At127To135全为 [1,0,0],含义未确认。
139–146bytes[8]fields.bytes139To146Hex含义未知,能否跨进程重建尚未确认,保留原字节、不清零。
147–194float32[12]fields.float32At147To191逐项观察见下表,尚不能确定是否为矩阵。
195uint8fields.uint8At195全为 0,可能是独立标志或字符串相关信息,未确认。
196 至 NUL 前ASCIIfields.texturePath已确认可读 DDS 资源路径,允许空串;保留大小写及斜杠方向。
NUL 位置uint8无独立字段编码时写 0,不计入 texturePath。
NUL 后 2 字节uint16fields.uint16AfterTexture全为 0;位置类似子节点数,但没有非零样本,不能据此确认。
最后 32 字节float32[8]transform缩放、四元数、局部平移的强候选;数值结构与其他节点相同。

float32At147To191 的取值分布如下,括号内为出现次数。表中的 0.4、0.6、0.7、1.2 是便于阅读的近似值,JSON 保留实际解码得到的 float32 数值。

数组下标文件内相对偏移观察值
0、1、2、3147、151、155、159全为 0。
4163全为 1。
5167全为 0。
61711(398)、0(50)、50(1)。
71750(437)、0.4(8)、0.7(3)、0.6(1)。
81790(448)、200(1)。
91831(448)、1.2(1)。
10187全为 1。
111911(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.dds49
data\source\maps_source\texture\lighttexture\h_light1_1_1005_001.dds35
data\source\maps_source\texture\lighttexture\light-shadow_004.dds28
data\source\maps_source\texture\lighttexture\h_light2_2_1001_001.dds24
data\source\maps_source\texture\lighttexture\h_light2_2_1008_001.dds9
data\source\maps_source\texture\lighttexture\h_light1_1_1003_001.dds9
data/source/maps_source/Texture/lightTexture/H_Light4_4_1007_001.dds8
data\source\maps_source\texture\lighttexture\h_light4_4_1007_001.dds6
data\source\maps_source\texture\lighttexture\h_light2_2_1009_001.dds5
data\source\maps_source\texture\lighttexture\h_light2_2_1002_001.dds5
data\source\maps_source\texture\lighttexture\h_light3_3_1003_001.dds3
data/source/maps_source/Texture/lightTexture/H_Light2_2_1008_001.dds3
data\source\maps_source\texture\lighttexture\h_light3_3_1002_001.dds3
data\source\maps_source\texture\lighttexture\h_light2_2_1006_001.dds3
data\source\maps_source\texture\lighttexture\h_light2_2_1007_001.dds2
data/source/maps_source/Texture/lightTexture/H_Light2_2_1001_001.dds1

4.5.5 JSON 表示与重建

重建记录只需 fields、顶层 transform 和 observedLengthUint16At1。解析时额外输出的 rawHex 用于核对原始字节。encode_type4() 依次写入字段、路径、NUL、路径后的整数和变换;若保留了 rawHex,重建结果必须与它一致,否则报错。删去整块 rawHex 不影响还原。

type=4 的 JSON 不设置 children: []。样本中路径后的 uint16 都是零,但尚不清楚它是否表示子节点数,因此也不能认定这类节点没有子结构。脚本只接受该字段为零、路径长度和记录跨度符合已知布局、版本在白名单内的记录。

4.6 变换与遍历限制

transform 为 8 个 float32:

数组下标相对变换起点的偏移字段
00–3scale,必须大于零。
14–7四元数 qx。
28–11qy。
312–15qz。
416–19qw。
520–23局部平移 tx。
624–27ty。
728–31tz。

所有值必须是有限数值,不接受 NaN 或 Infinity。读写时保留零值、负零和分量顺序,不自动归一化四元数。节点位置通常按米数乘 100 表示;完整的父子变换和挂接规则尚未验证,不能直接把局部平移作为最终世界坐标。

脚本将根节点深度记为 0,最大深度限制为 128,总记录数最多 2,400,001,每个节点最多有 65,535 个直接子节点,文件大小也不能超过前述上限。读取字段前会检查剩余长度;遇到未知类型、截断或尾随字节时立即报错。

5. JSON 定义与还原规则

解析结果保存为 jx3-blueprint-standalone 格式的 JSON,编码时再从中还原二进制文件。这是脚本使用的中间格式,不是游戏原生格式,也不接受结构不同的 JSON。

字段类型含义/编码行为
formatstring固定为 jx3-blueprint-standalone。
schemaVersioninteger固定为 2,包含 type=4 的具体字段;不接受旧版仅保存原始块的 JSON。
profilestringv7_subset 或 observed_samples。
source.fileNamestring记录原文件名,不决定格式。
source.bytesinteger记录原文件大小。
source.sha256string原文件 SHA-256;扩展模式用它核对样本身份,普通模式不把它当修改后文件哈希。
header.globalinteger0 或 1,须与 rawHex 一致。
header.versioninteger内部版本,须与 rawHex 一致。
header.byteLengthinteger前缀长度,须与 rawHex 解码长度一致。
header.rawHexstring完整前缀原字节,包括前 5 字节通用头;编码时实际写入的依据。
rootobject第 4 节定义的递归节点。
节点 byteOffsetinteger原文件中记录的起点;解析生成,编码时不使用。
节点 subtreeByteLengthinteger原记录及其已解析子树的总字节跨度;编码时不使用。

普通模式下,新建文件可以省略 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 argparse
from collections import Counter
import hashlib
import hmac
import json
import math
from pathlib import Path
import struct
MAX_FILE_BYTES = 32_000_000
MAX_DEPTH = 128
MAX_NODES = 2_400_001
OBJECT_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.blueprintx
python3 blueprint_tool.py verify empty.blueprintx
python3 blueprint_tool.py decode empty.blueprintx empty.json
python3 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
AHomelandDesign_20261019-092347_F22.blueprintx1684d8669a315ecaaf29510b34909d7f8ebe8dd38f006f80d1e1e996d7191c3863
BBP7267B28C427751D4AA77.blueprintxbb323f6358bb9755531c59e1babc71cdebef061c0b76cf2d2ecb671be32358b2
CBPBE586571CC0E8E48970C.blueprintx791d1c2440e70aae4baa2d710c517cd7fe1c4ebbdb2b6e238ddf2cae0ac8b8d3
DBP9258ABC71DBBACAB7C0E.blueprintxd090547217b1ad06a558e1ce854c60c4d28749d862802f304f978e664bf04e9b
编号内部版本global文件字节数前缀字节数解析记录数type=4 记录数
A71157,9812227,8670
B41661,3134624,890175
C31390,9004615,032223
D71230,7922229,54851

记录数包含挂点和光源候选组件等记录,不是家具件数。A 含 2,622 条 type=0 记录和 5,245 条 type=5 记录。

编号231 字节 type=4296 字节 type=4299 字节 type=4
B941863
C1231090
D39012

9.2 校验数据

编号MD5(payload) 的十六进制表示原文件末尾 16 字节的十六进制表示
A2a8d6ae452e8b2f359d74dc674c3d716be59d761db81c06ae47ef547f0e42519
B3565ceb3fda495e30b130ce420e4882056fd80ce97a6d038203fd713d7bb1306
C65845e1f2a7a485540cf50adf3063014b76d2c19497b6673fc639ec035032756
D39053c6b205da2c5c1bc5db60a5074e9360f58136e91f6f28f6e85396347da0a

例如 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 后,逐字段重建仍与原记录逐字节一致17522351
跨度等于 231 + 路径 ASCII 字节数17522351
所有原始区域和解析字段连续覆盖整条记录17522351
父类型为 3,且是最后一个直接子节点17522351
偏移 7 的编号等于父及前面兄弟子树的最大实例编号 + 117522351
尾部缩放等于父节点缩放17522351
颜色候选四元组各分量位于 [0,1]17522351
候选编号与普通节点已有实例编号冲突000

移除三份文件中所有 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。这些含义仍待确认,场景中的实际光照效果尚未验证。