游戏

剑网3|JCL 文件格式与解析

JCL 文件格式与解析

1. 格式与适用范围

《剑网3》的 .jcl 文件是战斗事件日志。正文为逐行记录的文本,每行由六列组成,以制表符分隔;第六列是 Lua 数据表,需要根据事件编号解释其中的字段。读取正文不需要解密,也不需要执行 Lua。

解析时容易出错的地方主要有:Lua 表中的稀疏键、三种不同的时钟、分多次补齐的角色资料,以及事件编号与效果数值标签的区别。本文依次说明这些规则,并给出一份只依赖 Python 3.10+ 标准库的解析器。

字段说明依据固定版本源码、格式资料和四份公开样本。原始资料记录的研究及实测日期为 2026-10-25:四份样本共 107,317 条记录,覆盖已定义的 29 种事件中的 27 种,未出现事件 16、17。这些结果只说明已记录样本的解析情况,不能据此推断所有游戏版本都适用。

表中的“源码定义”表示固定版本源码给出了字段或用途;“样本出现”表示四份文件中出现过该结构。只有源码或旧版资料支持的字段会单独注明,尚未确定的含义保留为未知。字段存在且能够解析,不等于相应游戏机制已经经过受控实验。来源编号对应文末参考资料。

2. 六列结构与 Lua 表

2.1 字节、行及六列

正文为文本,逻辑上一行一事件。写入时将 Lua 数据中的换行、制表符转义后写入日志;LF 和 CRLF 均应处理。四份样本文件严格 GBK 解码成功、UTF-8 不能解码;不能由 .jcl 扩展名断定 UTF-8。示例默认 utf-8-sig,读取这四份样本必须指定 --encoding gbk;不使用 errors="ignore" 或自动替换乱码。

列(从 1 起)Python 下标类型含义与单位证据
10十进制整数历史/实现相关校验值,保留原值;不是记录序号源码定义、格式说明及样本
21整数GetLogicFrameCount(),游戏逻辑帧计数源码定义、格式说明及样本
32整数GetCurrentTime(),Unix 秒时间戳源码定义、格式说明及样本
43整数GetTime(),客户端毫秒时钟,不是网络延迟源码定义、格式说明及样本
54整数事件编号,源码定义 1–29源码定义、格式说明及样本
65Lua table 文本事件负载,需递归解析,不能按逗号简单切分源码定义、格式说明及样本

使用 line.split("\t", 5),只拆前五个分隔符,再解析第六列。不要对整行使用通用空白 split(),它会拆坏名字、消息和空字符串。空行、列数不足、错误编码或截断表应报告行号,不能悄悄丢弃。

第一列的 CRC 在资料中存在版本或说明差异:早期格式说明将它称为废弃字段,但固定版本源码仍执行链式计算,以 0 为初始值,将前一条记录的校验值、当行第 2–6 列文本和内部盐传给 GetStringCRC;重放开战前缓存时也会重算此链。1 7

本文只解析正文,不实现 CRC 验证,输出中的 crc_verified 固定为 false。不能直接用 zlib.crc32 代替上述算法。文件 SHA-256 只用于核对样本字节,不证明日志真实性。

2.2 Lua 表:位置和显式键同时存在

统一使用 C[n] 表示外层第 n 列,P[n] 表示第六列 Lua 表中的数字键 n,P[9][13] 表示嵌套键。Lua 顺序项从 1 开始;结果表和镶嵌表却有合法的显式键 0。

代码窗口
{1001,"示例角色",7,10225,600000,[8]="1234567890123456789"}

上式存在键 1、2、3、4、5、8,缺 6、7。不是六元素数组;不得把 GUID 移到装备字段。{[0]=100,[13]=80,[14]=0} 也不是从第 0 项开始的密集数组,而是三个带标签的结果项。

解析器需支持嵌套 {}、顺序项、[数字]=值、["字符串"]=值、引号与转义、整数、布尔、nil 和空表;字符串内的逗号、花括号、等号都不是分隔符。不存在、nil、false、0、空字符串、空表必须区分。Lua 执行器会删除值为 nil 的表项;本示例是语法数据读取器,显式 nil 保留为 JSON null,不模拟 Lua 的删键副作用。

本文用 {"$table": [[key,value], ...]} 表示任意 Lua 表,保留键的类型、出现顺序、重复键及显式 nil。数字键 1 与字符串键 "1" 不混淆;数学上为整数的数字键 1.0 归为 1。辅助 fields 只投影已知数字键,重复键取末次;完整信息仍在 payload 和 raw_line。后者保留解码后的原行,不包含行终止符/BOM,因此不承诺 JSON 能逐字节还原原文件。

3. 事件字段字典

除特别标注外,下表位置都指 P 的一基数字键,类型 i=整数、n=数值、s=字符串、b=布尔、t=Lua 表;b/i 表示来源使用布尔语义但实物可能为 0/1。ID 的数值只作标识。坐标、朝向按游戏原生单位保留,本文没有证实米制或角度制。例值若出现,均为人工示意,不用于确定枚举范围。

下列事件的基本字段来自写入定义。1 装备、战斗标识和效果结果的补充来源在对应段落注明。

“可缺”不仅指版本变更:源 API 为 nil 时,序列化也可能省掉该键;解析器不强制所有已知项必有、不把缺项补零。所有已见顶层键都列在下表;未来多出的键继续留在原始负载。

3.1 战斗、场景进出与角色状态

事件位置类型字段及含义可选性/边界证据
1 FIGHT_TIME1bbFighting,true 开战、false 结束样本仅见 true源码定义及样本
12sszUUID,战斗标识按整体字符串保存源码定义及样本
13inDuring,持续毫秒开战为 0,不能作最终时长源码定义及样本
14idwMapID,地图 ID早期格式说明未列,样本中存在源码定义及样本
2 玩家进入;3 玩家离开1i玩家运行期 ID离场不等于死亡源码定义及样本
6 NPC 进入;7 NPC 离开1iNPC 运行期 ID不是模板 ID源码定义及样本
10 交互物件进入;11 离开1i交互物件运行期 ID与玩家/NPC 分域源码定义及样本
5 玩家战斗状态;9 NPC 战斗状态1i运行期 ID角色级别状态源码定义及样本
5/92b/ibFight,是否进战样本为布尔源码定义及样本
5/93、4n当前生命、最大生命无对象句柄时源码写 -1源码定义及样本
5/95、6n当前内力、最大内力无对象句柄时源码写 -1源码定义及样本

固定版本中的战斗标识组装为 edition::region_server::unix_seconds::teamID::memberXor/count::UglobalID/runtimeID。该结构仅对应固定源码版本,不保证所有历史文件都相同,也不符合 RFC 4122 UUID 格式。解析时不应随意拆分服务器名中的下划线;只需关联战斗时,保留完整字符串即可。3

3.2 4:玩家资料与嵌套装备、奇穴

位置类型源字段含义、可选性证据
1idwID运行期玩家 ID源码定义及样本
2sszName名称,可能含跨服后缀;不要据名字合并实体源码定义及样本
3idwForceID门派/势力 ID,不是心法 ID源码定义及样本
4idwKungfuID心法技能 ID;未取到时初始化为 -1源码定义及样本
5nnEquipScore装分;异步取得,可缺源码定义及样本
6taEquip装备列表;可缺或为空源码定义及样本
7taTalent奇穴列表;可缺或为空源码定义及样本
8sszGUID跨服角色标识;可为空字符串,不能转浮点数源码定义及样本
9ttZhenPai镇派技能 ID → 等级;只记录非零等级,映射由源码定义源码定义;样本 647 次均为空表,非空内容未验证

装分、装备、奇穴、镇派分别通过回调补齐,每次返回都写一条事件 4。四份样本共有 2,186 条事件 4,其中键 5 出现 2,163 次、键 6 出现 1,625 次、键 7 出现 1,063 次、键 9 出现 647 次。这些记录分多轮补充资料,不应当作相互覆盖的完整快照。用同 ID 的已出现字段更新索引,缺项不擦除旧字段;做某时刻复盘仍应查当时的原事件,不使用未来信息覆盖历史。

装备 P[6][条目键] = E。固定版本源码采用八项结构,样本中的 28,868 件装备记录也全部为八项。装备列表的排列位置不是插槽号,真正插槽在 E[1]。

E 位置类型含义说明/证据
1inEquipIndex,装备栏索引源码定义及样本;部分枚举见下表,仍需匹配游戏版本
2idwTabType,物品表类别源码定义及样本;需与 E[3] 联合查装备
3idwTabIndex,该表中的物品 ID源码定义及样本;不是图标 ID
4inStrengthLevel,精炼等级源码定义及样本;不是装备品质
5taSlotItem,镶嵌内容稀疏孔位映射,必须保留键 0,源码定义及样本
6idwPermanentEnchantID,永久附魔 ID源码定义;样本中存在该位置,不凭数值推断大小附魔
7idwTemporaryEnchantID,临时附魔 ID源码定义;0 仍是原始值
8idwTemporaryEnchantLeftSeconds,临时附魔剩余秒源码定义;不能解读为“大附魔 ID”

镶嵌 E[5][孔位] 在样本中均为两个元素的表;源码明确键 0 为五彩石,1 起为五行石孔位(样本见 1–3)4。示意 {[0]={5,13918},{5,24446}}:第 0 孔与第 1 孔同时存在,子项分别为 [dwTabType,dwTabIndex],即物品表类别和物品 ID。24446 是物品 ID,不是石头等级;从 ID 得到品级/属性仍需匹配版本的物品字典。本程序完整保留子表。旧版资料还记录了附魔位置不同的九项装备布局,但缺少对应样本。8 因此示例只为各来源一致的前五项添加字段名。第六至八项应在确认文件版本后按上表解释,不跨版本套用。

奇穴 P[7][层/条目键] = {nIndex,dwSkillID,dwSkillLevel}:分别为同层选择索引、技能 ID、等级,三项均有源码定义,不能将 nIndex 当技能 ID。镇派 P[9] 为技能 ID → 等级映射,零等级不写入;不支持镇派的环境返回空表,样本 647 条镇派表也全为空,不能据此声称验证了非空镇派数据。

装备栏 E[1] 的已知兼容枚举如下。它表示栏位,与 EQUIPMENT_SUB 装备分类枚举不同。5

索引名称索引名称
0近战武器1重剑
2远程武器3上衣
4头部5项链
6左戒指7右戒指
8腰带9腰缀
10裤子11鞋子
12护臂13–16扩展背包 1–4
17安全产品额外背包格18–22仓库扩展背包 1–5
23暗器24–27样本中出现,兼容枚举未定义,含义未知

兼容枚举定义 TOTAL=24,运行时则优先使用客户端的 EQUIPMENT_INVENTORY;样本中实际出现了 0–27。4 5 因此不得按静态兼容表截掉 24–27,也不能把 18–22 的物品一律当穿戴战斗装备计入属性。

3.3 8:NPC;12:交互物件;29:状态快照

事件位置类型含义证据/限制
8 NPC_INFO1、2i、s运行期 NPC ID、名称源码定义及样本
83、4i、i模板 ID、雇主/主人运行期 ID dwEmployer源码定义及样本;无主人常见 0,保留原值
85、6、7n、n、nx、y、z源码定义及样本;不能把 z 当生命值
88n朝向 nFaceDirection源码定义及样本
12 DOODAD_INFO1、2i、i运行期物件 ID、模板 ID源码定义及样本;此处没有名字字段
123、4、5、6nx、y、z、朝向源码定义及样本
29 TARGET_INFORMATION1、2i、i目标类型 TARGET 枚举、运行期 ID源码定义及样本;不能把类型当阵营/职业,枚举数值未在本文穷举
293、4、5、6nx、y、z、朝向源码定义及样本;第 5 项不是场景 ID
297、8n当前生命、最大生命源码定义及样本
299、10n当前内力、最大内力源码定义及样本
2911nnDamageAbsorbValue,伤害吸收值状态源码定义及样本;不是此帧新增吸收量

事件 29 受 PvP 记录开关、竞技场环境及每目标节流限制,默认节流参数为 200ms,且随相关事件触发;它不是保证每 200ms 一次的全实体全量轨迹。8/12 也有信息更新节流。没有 29 不代表人物没动,不能无限外推位置或把生命快照差直接算成伤害。

3.4 13:Buff 更新

位置类型含义证据/限制
1i受体 ID owner源码定义及样本
2b/ibDelete,删除标志样本为布尔;false 包括新增/刷新,不只“施放成功”
3inIndex,该目标上的 Buff 索引与 Buff ID 区别;不保证跨生命周期唯一
4b/ibCanCancel,是否可取消源码定义为可取消;旧版资料称有利气劲,不能据此判定增益/减益 8
5iBuff ID源码定义及样本
6i层数源码定义及样本
7i预计结束逻辑帧 endframe非时长、非 Unix 时间
8b/ibinit 候选,初始化/全量刷新相关源码注释仍带问号;保留为 init_raw,精确语义未知
9iBuff 等级源码定义及样本
10i来源角色 ID srcid源码定义及样本;不是技能 ID
11b/iisvalid 候选,有效标志源码注释命名,具体机制边界未实测;输出 valid_raw
12ileftframe,剩余逻辑帧源码定义及格式说明;四份样本均未出现,不补 0

Buff 覆盖统计需要按目标、索引和 Buff 身份维护状态,并参考来源、等级与层数;刷新会更新结束帧,删除结束当前实例。开战导入可能带有已有 Buff,日志也可能在到期/删除之前结束。本文提供状态事件,不把这些不完整区间自动变成完整覆盖率。

3.5 14–18:文字与团队事件

事件位置类型含义及限制证据
14 PLAYER_SAY1、2、3、4s、i、i、s纯文本、喊话者 ID、频道号、名字;实际仅保存 NPC 喊话源码定义及样本
15 ON_WARNING_MESSAGE1、2s、s警告类型、文本;类型常见 MSG_NOTICE_GREEN/YELLOW/RED源码定义及样本
16 PARTY_ADD_MEMBER1、2、3i、i、i团队 ID、成员 ID、分组索引源码定义及格式说明;样本未出现,分组基数不猜
17 PARTY_SET_MEMBER_ONLINE_FLAG1、2、3i、i、i团队 ID、成员 ID、在线标志源码定义及格式说明;离线时补 0,其他取值保留,样本未出现
18 MSG_SYS1、2s、s系统文本、频道字符串源码定义及样本;不是第 1 项为频道

14/18 的文本已经过生产者富文本转纯文本处理,不包含全部原始聊天格式。不要把文字消息正则识别结果当作稳定事件协议。

3.6 19–28:施放、效果、命中、治疗与死亡

事件位置类型含义及限制证据
19 CAST1、2、3i、i、i施放者、技能 ID、技能等级;是施放事件,不等于造成一次伤害源码定义及样本
20 CAST_RESPOND1、2、3、4i、i、i、i施放者、技能 ID、等级、SKILL_RESULT_CODE 响应码源码定义及样本;响应码枚举未穷举,不猜成功码
21 EFFECT1、2i、i施放者、目标源码定义及样本
213b/ibReact,源码称是否反击样本为 0/1;旧版资料称隐藏类别,精确机制待确认,保留 react_raw 8
214、5、6i、i、i效果类型、效果 ID、效果等级源码定义及样本;类型 1 按技能、2 按 Buff 解释,不能将全部 ID 当主动技能 6
217b/i会心标志源码定义及样本;样本为布尔
218i结果表条目数 nCount不是命中次数、目标数或结果码;样本中 31,742 条效果记录均与结果表条目数一致
219ttResultCount,按数值类型标签组织的结果表见下一节;显式索引 0 合法
22 BLOCK1–5;6i施放者、目标、效果类型、效果 ID、效果等级;伤害类型源码定义及样本;第 6 项引用 SKILL_RESULT_TYPE,非伤害数值
23 SHIELD1–5i施放者、目标、效果类型、效果 ID、效果等级源码定义及样本;被屏蔽,不等于治疗盾吸收的数值记录
24 MISS1–5i同上,技能未命中源码定义及样本
25 HIT1–5i同上,技能命中源码定义及样本;没有伤害数值,不与 21 累加计伤
26 DODGE1–5i同上,闪避源码定义及样本;不是打断日志
27 COMMON_HEALTH1、2i、n承疗角色 ID、增加的生命值源码定义及样本;没有施放者,不可硬归属某治疗玩家
28 DEATH1、2i、i死亡目标、击杀者第二项可缺;样本 37 条中有 2 条缺击杀者

写入端会在开战时为已重伤队友补 28 {id,nil},所以日志里的死亡记录不保证都是战斗开始后新发生的死亡。击杀者缺失不能自动填成目标自己或某个“环境伤害 NPC”。

3.7 21 的结果表:标签 0–16

结果表的键是数值标签,不是条目的出现顺序。以下枚举由固定版本源码定义,量纲为对应的伤害、治疗或资源量。2 “样本次数”表示四份文件中包含该键的记录数;次数为 0 表示没有出现,不表示字段值为零。

键源码枚举名含义样本次数备注
0PHYSICS_DAMAGE外功伤害2,779总伤害分项
1SOLAR_MAGIC_DAMAGE阳性伤害345总伤害分项
2NEUTRAL_MAGIC_DAMAGE混元伤害263总伤害分项
3LUNAR_MAGIC_DAMAGE阴性伤害1,581总伤害分项
4POISON_DAMAGE毒性伤害2,452总伤害分项
5REFLECTIED_DAMAGE反弹伤害167保留源码拼写;样本中这 167 条的值均非零,是否计入总伤害需明确口径
6THERAPY治疗3,593总治疗候选口径
7STEAL_LIFE生命偷取1,476不与 6 无条件重复累计
8ABSORB_THERAPY化解治疗87不是未知占位;不可直接视作有效治疗
9ABSORB_DAMAGE化解伤害1,238归属盾提供者需要额外状态逻辑
10SHIELD_DAMAGE无效伤害0源码定义,样本未出现;与事件 23 概念相关但不是同一字段
11PARRY_DAMAGE拆招70旧版资料也称“招架”,此处沿用源码命名 8
12INSIGHT_DAMAGE识破0源码定义;可用于判定识破结果,样本未出现
13EFFECTIVE_DAMAGE有效伤害31,742全部效果记录都有,可能为 0
14EFFECTIVE_THERAPY有效治疗31,742全部效果记录都有,可能为 0
15TRANSFER_LIFE吸取生命0源码定义,样本未出现;与 7 为不同标签
16TRANSFER_MANA吸取内力0源码定义,样本未出现

例如人工构造 P[9]={[0]=100,[13]=80,[14]=0}:外功原始量 100、有效伤害 80、有效治疗 0,P[8]=3。不能把最后的 0 当成缺失字段,也不能把 13/14 当第 13/14 个数组元素。语义输出只有实存键;统计时才可在明确公式下将缺失伤害分项作为 0。

已确认的事件编号为 1–29。编号 30 缺少足够的写入定义与样本依据;30 及其他未定义编号均作为未知事件保存,不推断含义。

4. 时间轴与统计口径

4.1 时钟、战斗边界与文件名

  • 局部时间轴:选定起点后的 C[4] - start_client_ms。C[3] 用来确定日历时间,但只有秒精度,不能把它乘 1000 当精确技能先后时间;C[2] 是另一条逻辑帧轴。相同时间戳按原文件顺序保留,不自行重排或去重。
  • Buff 期限:事件 13 的 P[7] 是预计结束的绝对逻辑帧,P[12] 是剩余帧候选,不是毫秒。帧差换算需已确认的该客户端 GAME_FPS;时间换算使用该变量 2,本文不凭经验硬编码 16 或渲染帧率。
  • 战斗边界:事件 1 的 fighting 明确开始/结束,结束记录 duration_ms 取自战斗时长,脱战判定有约 5 秒缓冲 3。写入端会导入开战前约一秒事件,因此首条记录不一定是事件 1,首末事件跨度不等于官方战斗时长。四份样本都只有一条 fighting=true,duration=0,没有结束事件,不能据此算出“官方战斗时长”。
  • 命名:文件名由时间、地图名/ID、出现次数最多的 NPC 名/模板 ID 组成;最高频 NPC 不一定是真实首领。用户改名、工具加前缀、样本匿名文件名都不影响行解析。地图优先使用存在的事件 1 P[4];缺失时可将文件名作为带来源标记的辅助信息,不强制按 split('-')[6] 定位。
  • 不同 ID:运行期角色 dwID、跨服 GUID、NPC 模板 ID、地图 ID、装备表 ID、技能/Buff ID 属于不同空间。GUID 保留字符串,大整数不要经过 JavaScript Number。交互物件与角色 ID 可能冲突,实体索引至少使用 player:/npc:/doodad: 前缀。

JX3DAT 是另一种整体 Lua 数据结构,使用 return {...} 和全局缓存,不能按 JCL 的逐行六列格式读取。两者的事件编号也不同:JX3DAT 的技能效果和 Buff 分别对应 1、5,JCL 则对应 21、13。给 JCL 第六列加上 return,也不会使它变成 JX3DAT。6

4.2 解析与实体关联

代码窗口
原文件字节 + 指定编码
→ 按 LF/CRLF 取物理行,记录行号
→ 前五列整数 + 第六列完整 Lua 数据文本
→ 递归数据解析:显式键/顺序项/嵌套表/转义
→ 保留完整 payload;按事件编号投影 fields
→ 玩家/NPC/物件分域建立资料索引,保留每条历史记录
→ 按分析目标选时间区间和统计口径,再计算指标

解析阶段保留原始施放者。统计召唤物贡献时,可根据 NPC 的 dwEmployer 另加归属字段,但不覆盖原 caster。按“某人的召唤物”之类的名字猜主人,会受到跨服同名、NPC 名中含“的”和资料到达顺序的影响,示例不采用这一规则。

读取结束后,实体索引能关联早于资料记录出现的事件;但 last_present_fields 是末次已知字段拼接结果,不是某时刻的完整角色快照。所有原事件和资料行号都保留,支持之后按时间回查;不对未知 ID 造出假玩家。

4.3 统计公式与口径

在选定效果事件集及其时间窗口后,令 R=P[9]:

代码窗口
总伤害(包含反弹) = Σ事件 Σk=0..5 R.get(k, 0)
总伤害(不含反弹) = Σ事件 Σk=0..4 R.get(k, 0)
有效伤害 = Σ事件 R.get(13, 0)
总治疗 = Σ事件 R.get(6, 0)
有效治疗 = Σ事件 R.get(14, 0)
DPS / HPS = 已明确的伤害量 / 治疗量 ÷ 明确的统计秒数

两种总伤害口径相差反弹伤害,选择时需明确统计用途。上述公式只聚合选定事件的数值;客户端面板还可能涉及目标过滤、会心与识破分类、盾归属等处理,因此结果不一定相同。2

普通治疗事件 27,以及结果标签中的生命偷取 7、吸取 15/16、吸收 8/9,都应分别核对,不能无条件叠加到治疗标签 6/14。25 的命中日志和 19 的施放日志没有伤害量;把它们当“技能次数”时也须先定义次数是施放、命中还是效果触发。

总治疗 - 有效治疗 可作为同一口径下的非有效差额,但不保证每种机制都能直接称为“溢出治疗”;伤害差额也不能全部叫“过量击杀”。把盾化解伤害归到盾提供者,需要跟踪 Buff 或盾状态;事件 21 本身并不直接提供这项归属。RDPS/RHPS、减伤贡献、控制链、死亡原因和首领阶段识别属于进一步模型,需要技能机制、来源归属和战斗窗口,不是新增几个索引就能还原。

4.4 外部字典与记录范围

  • JCL 可得到运行期玩家/NPC 名,但技能效果名通常不随每条记录给出。效果名应按 (scheme,id,level) 查询;仅按数字 ID 查询可能把技能和 Buff 混淆,也可能忽略等级差异。
  • 技能、心法和地图名称需要外部字典。11 这些字典会变化,分析结果应注明匹配的游戏版本与数据版本。示例只保留日志中的字段,不联网查询,也不写死当前版本的名称。
  • 四份样本均为竞技场日志,不足以验证副本首领、所有游戏分支及旧版装备。日志的记录范围受环境开关、附近/自身相关过滤、可观察实体、事件源和节流影响。“没记录到”不能证明“没发生”。
  • 缺失战斗结束、缺资料、未出现的枚举、未知尾项都必须可追溯。未知字段保留并不等于其含义已经解释;原始数据完整读取与机制完整重建是两种覆盖度。

5. 独立 Python 解析器

将下面的程序保存为 jcl_parse.py,使用 Python 3.10+ 运行,无需安装第三方库。输出结构:source(文件身份/编码/CRC 状态)、event_counts、entities、events;每个事件有原始负载、原行、已知字段。equipment_common 只额外命名装备前五项,talents_named 命名奇穴三项,result_fields 命名结果键 0–16。

解析器支持样本使用的 Lua 数据子集,以及有限浮点、单/双引号、常用短转义、十进制字节转义、命名键和分号分隔。支持 UTF-8/BOM、GBK、GB18030,LF/CRLF。不是完整 Lua 语言:不接受 return、表达式、函数调用、注释、长括号字符串或未知转义;遇到这些内容明确报错,不求值。十进制转义先组回字节,再按所选编码解码。前五列都必须为整数;内部字段按实际 Lua 类型保留,文档字段类型表用于解释而非强行转换。

文件一次读入、所有事件保存在内存,然后输出一份 JSON,便于研究核对;原始与命名视图重复占空间,不适合直接用于超大文件的流式处理。语法正确但事件字段不足时依然保留,不捏造默认值;语法损坏则整次失败、stderr 含行号、退出码 2,解析完成前不写 stdout。

代码窗口
#!/usr/bin/env python3
"""JCL 数据解析示例:Python 3.10+,仅标准库,不执行 Lua。"""
import argparse
import codecs
import hashlib
import json
import math
import re
import sys
from collections import Counter
from pathlib import Path
class LuaTable:
def __init__(self, entries):
self.entries = entries
def get(self, key, default=None):
# 数字键 1 与字符串键 "1" 不合并;原 entries 也保留重复键。
for k, value in reversed(self.entries):
if type(k) is type(key) and k == key:
return value
return default
class LuaDataParser:
number = re.compile(r"[+-]?(?:0[xX][0-9a-fA-F]+|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)")
name = re.compile(r"[A-Za-z_][A-Za-z_0-9]*")
def __init__(self, text, encoding="utf-8"):
self.text = text
self.pos = 0
self.encoding = codecs.lookup(encoding).name.replace("utf-8-sig", "utf-8")
def fail(self, message):
raise ValueError(f"Lua 列 {self.pos + 1}: {message}")
def space(self):
while self.pos < len(self.text) and self.text[self.pos].isspace():
self.pos += 1
def take(self, token):
self.space()
if self.text.startswith(token, self.pos):
self.pos += len(token)
return True
return False
def need(self, token):
if not self.take(token):
self.fail(f"应为 {token!r}")
def string(self):
quote = self.text[self.pos]
self.pos += 1
result = bytearray()
escapes = {"a": "\a", "b": "\b", "f": "\f", "n": "\n", "r": "\r",
"t": "\t", "v": "\v", "\\": "\\", '"': '"', "'": "'"}
while self.pos < len(self.text):
char = self.text[self.pos]
self.pos += 1
if char == quote:
return result.decode(self.encoding)
if char != "\\":
result.extend(char.encode(self.encoding))
continue
if self.pos == len(self.text):
self.fail("字符串转义被截断")
char = self.text[self.pos]
self.pos += 1
if char in escapes:
result.extend(escapes[char].encode(self.encoding))
elif char.isascii() and char.isdigit():
digits = char
while len(digits) < 3 and self.pos < len(self.text) and self.text[self.pos] in "0123456789":
digits += self.text[self.pos]
self.pos += 1
if int(digits) > 255:
self.fail("十进制转义超出一个字节")
result.append(int(digits))
else:
self.fail(f"不支持的字符串转义 \\{char}")
self.fail("字符串未闭合")
def value(self):
self.space()
if self.pos == len(self.text):
self.fail("缺少值")
if self.text[self.pos] in "\"'":
return self.string()
if self.take("{"):
entries, implicit = [], 1
while not self.take("}"):
if self.take("["):
key = self.value()
if type(key) not in (int, float, str, bool):
self.fail("表键必须是标量且不能为 nil")
if type(key) is float and key.is_integer():
key = int(key)
self.need("]")
self.need("=")
else:
self.space()
start = self.pos
match = self.name.match(self.text, self.pos)
if match:
self.pos = match.end()
if match and self.take("="):
key = match.group()
else:
self.pos = start
key = implicit
implicit += 1
entries.append((key, self.value()))
if self.take("}"):
break
if not (self.take(",") or self.take(";")):
self.fail("缺少表项分隔符")
return LuaTable(entries)
match = self.number.match(self.text, self.pos)
if match:
self.pos = match.end()
token = match.group()
if "x" in token.lower():
return int(token, 16)
if not any(c in token.lower() for c in ".e"):
return int(token)
value = float(token)
if not math.isfinite(value):
self.fail("非有限数字")
return value
match = self.name.match(self.text, self.pos)
if match and match.group() in ("true", "false", "nil"):
self.pos = match.end()
return {"true": True, "false": False, "nil": None}[match.group()]
self.fail("仅支持 Lua 数据字面量")
def parse(self):
value = self.value()
self.space()
if self.pos != len(self.text):
self.fail("表后存在额外内容")
if not isinstance(value, LuaTable):
self.fail("JCL 载荷必须为表")
return value
# 位置均为 Lua 一基索引;名字属于本文输出接口,不是文件中的键名。
FIELDS = {
1: ("fight_time", "fighting uuid duration_ms map_id"),
2: ("player_enter", "id"), 3: ("player_leave", "id"),
4: ("player_info", "id name force_id kungfu_id equip_score equipment talents guid zhenpai"),
5: ("player_fight", "id fighting life max_life mana max_mana"),
6: ("npc_enter", "id"), 7: ("npc_leave", "id"),
8: ("npc_info", "id name template_id employer_id x y z face"),
9: ("npc_fight", "id fighting life max_life mana max_mana"),
10: ("doodad_enter", "id"), 11: ("doodad_leave", "id"),
12: ("doodad_info", "id template_id x y z face"),
13: ("buff_update", "target_id delete index can_cancel buff_id stack end_frame init_raw level source_id valid_raw left_frame"),
14: ("npc_say", "text speaker_id channel name"),
15: ("warning", "warning_type text"),
16: ("party_add", "team_id member_id group_index"),
17: ("party_online", "team_id member_id online_flag"),
18: ("system_message", "text channel"),
19: ("cast", "caster_id skill_id level"),
20: ("cast_respond", "caster_id skill_id level respond"),
21: ("effect", "caster_id target_id react_raw scheme effect_id level critical result_count results"),
22: ("block", "caster_id target_id scheme effect_id level damage_type"),
23: ("shield", "caster_id target_id scheme effect_id level"),
24: ("miss", "caster_id target_id scheme effect_id level"),
25: ("hit", "caster_id target_id scheme effect_id level"),
26: ("dodge", "caster_id target_id scheme effect_id level"),
27: ("common_health", "target_id delta_life"),
28: ("death", "target_id killer_id"),
29: ("target_information", "target_type id x y z face life max_life mana max_mana absorb_value"),
}
def named(table, names):
if not isinstance(table, LuaTable):
return {}
names = names.split()
return {names[key - 1]: value for key, value in table.entries
if type(key) is int and 1 <= key <= len(names)}
def json_default(value):
if isinstance(value, LuaTable):
return {"$table": value.entries}
raise TypeError(type(value).__name__)
def parse_file(path, encoding):
if codecs.lookup(encoding).name not in ("utf-8", "utf-8-sig", "gbk", "gb18030"):
raise ValueError("示例只支持 utf-8、utf-8-sig、gbk、gb18030")
raw = path.read_bytes()
events, counts, entities = [], Counter(), {}
# 逐物理行严格解码,错误定位到文件行;不猜编码、不忽略坏字节。
lines = raw.split(b"\n")
if lines and lines[-1] == b"":
lines.pop()
for lineno, raw_line in enumerate(lines, 1):
try:
line = raw_line.removesuffix(b"\r").decode(encoding)
if lineno == 1:
line = line.removeprefix("\ufeff")
if not line:
raise ValueError("空记录")
columns = line.split("\t", 5)
if len(columns) != 6:
raise ValueError(f"需要六列,实际 {len(columns)} 列")
if any(re.fullmatch(r"-?\d+", col) is None for col in columns[:5]):
raise ValueError("前五列必须为整数")
crc, frame, unix_s, client_ms, event_type = map(int, columns[:5])
payload = LuaDataParser(columns[5], encoding).parse()
except (ValueError, RecursionError) as exc:
raise ValueError(f"{path.name}: 第 {lineno} 行: {exc}") from exc
kind, names = FIELDS.get(event_type, ("unknown", ""))
fields = named(payload, names)
event = {"line": lineno, "crc": crc, "frame": frame, "unix_s": unix_s,
"client_ms": client_ms, "event_type": event_type, "kind": kind,
"fields": fields, "payload": payload, "raw_line": line}
if event_type == 4:
equipment = fields.get("equipment")
if isinstance(equipment, LuaTable):
# 只解释跨来源一致的前五项;附魔尾部存在版本/文档冲突,留在原表。
event["equipment_common"] = [
{"entry_key": k, "fields": named(v, "slot table_type item_id strength sockets")}
for k, v in equipment.entries]
talents = fields.get("talents")
if isinstance(talents, LuaTable):
event["talents_named"] = [
{"entry_key": k, "fields": named(v, "index skill_id level")}
for k, v in talents.entries]
if event_type == 21 and isinstance(fields.get("results"), LuaTable):
results = fields["results"]
event["result_fields"] = {}
result_names = {0: "physical", 1: "solar", 2: "neutral", 3: "lunar", 4: "poison",
5: "reflected", 6: "heal", 7: "steal_life", 8: "absorb_heal",
9: "absorb_damage", 10: "shield_damage", 11: "parry", 12: "insight",
13: "effective_damage", 14: "effective_heal",
15: "transfer_life", 16: "transfer_mana"}
for key, value in results.entries:
if type(key) is int and key in result_names:
event["result_fields"][result_names[key]] = value
if event_type in (4, 8, 12) and type(fields.get("id")) is int:
entity_kind = {4: "player", 8: "npc", 12: "doodad"}[event_type]
entity_key = f"{entity_kind}:{fields['id']}"
entity = entities.setdefault(entity_key, {"record_lines": [], "last_present_fields": {}})
entity["record_lines"].append(lineno)
# 缺项不覆盖旧值;显式 nil 仍覆盖。历史事件是时间点真值,末次索引仅便于查找。
entity["last_present_fields"].update(fields)
counts[event_type] += 1
events.append(event)
return {"source": {"file": path.name, "bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest(),
"encoding": encoding, "crc_verified": False},
"event_counts": dict(sorted(counts.items())), "entities": entities, "events": events}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", type=Path)
parser.add_argument("--encoding", default="utf-8-sig", help="默认 utf-8-sig;简体客户端样本可指定 gbk")
args = parser.parse_args()
try:
output = parse_file(args.input, args.encoding)
except (OSError, ValueError, LookupError) as exc:
parser.exit(2, f"解析失败: {exc}\n")
# 输出固定 UTF-8 JSON;先完整解析再写出,错误时不会产生半份 JSON。
encoded = json.dumps(output, ensure_ascii=False, allow_nan=False, default=json_default)
sys.stdout.buffer.write(encoded.encode("utf-8") + b"\n")
if __name__ == "__main__":
main()

6. 运行示例

保存 jcl_parse.py 后,使用实际文件路径运行。默认编码是 utf-8-sig;GBK 文件需指定 --encoding gbk:

代码窗口
python3 '/实际路径/jcl_parse.py' '/实际路径/战斗.jcl' --encoding gbk > '/实际路径/战斗.json'

以下独立命令在当前目录生成两条人工构造的记录,不是真实战斗数据。字符串中的 \t 在 Python 执行时变成真实制表符,不要把展示用的反斜杠 t 原封不动复制进 JCL。

代码窗口
python3 - <<'PY'
from pathlib import Path
rows = [
'0\t100\t1700000000\t1000\t21\t{1001,2001,0,1,123,1,false,3,{[0]=100,[13]=80,[14]=0}}',
'0\t101\t1700000000\t1060\t28\t{2001}',
]
Path('example.jcl').write_text('\n'.join(rows) + '\n', encoding='utf-8')
PY
python3 jcl_parse.py example.jcl > example.json

输出是完整 JSON,以下是第一条记录的字段视图(省略原始负载、原行、文件哈希等其他部分):

代码窗口
{
"line": 1,
"client_ms": 1000,
"event_type": 21,
"kind": "effect",
"fields": {
"caster_id": 1001,
"target_id": 2001,
"react_raw": 0,
"scheme": 1,
"effect_id": 123,
"level": 1,
"critical": false,
"result_count": 3,
"results": {"$table": [[0, 100], [13, 80], [14, 0]]}
},
"result_fields": {"physical": 100, "effective_damage": 80, "effective_heal": 0}
}

第二条 fields 只有 {"target_id":2001},不包含伪造的 killer_id。若事件编号改为 999,输出 kind="unknown"、fields={},但 payload/raw_line 仍完整保存。已知事件中的未知尾项也不会因为不在 FIELDS 中而丢失。

7. 样本覆盖与限制

以下统计来自原始资料在 2026-10-25 记录的验证结果。本次整理保留样本身份、哈希和计数,未重新执行四份真实文件的全量验证。

7.1 样本身份

四份样本来自同一固定版本目录,来源说明仅对文件名做了匿名处理。10 因此这里只列文件身份与汇总,不展示真实角色名、GUID 或原始喊话。文件名中的地图 ID 不参与解析成功判定。

编号固定版本文件名字节数事件行数严格解码
P13v3-乐山大佛窟(128)-(sample).jcl2,410,34721,858GBK
P23v3-沃石火狱(624)-(sample).jcl4,014,46634,756GBK
P35v5-墟海之眼(362)-(sample).jcl2,941,67524,724GBK
P45v5-藏剑武库(529)-(sample).jcl2,982,01025,979GBK

SHA-256:

代码窗口
P1 16f0637b7005f96d03bf01d68a76ce2bdec62158c352de14ea13cd1c55efa727
P2 99634624801396d319981684e6b7a6f513c6fc6caaa0f0bf39638a282f7e81cf
P3 787e52b92ba87682e1cb21251f6deb3df105646c253e3f9dc8a7ed69f7ad5d15
P4 6ec97a9d3a384c6ac23d7bf28ef650c7e321bff3f9ae694bc3e5d42221f5ace5

原始验证记录显示,107,317/107,317 行均通过六列结构和 Lua 表解析,没有跳过坏行;已见顶层事件键均在第 3 节列出。六列中没有显式的客户端构建号,不能用来源提交日期代替游戏版本。未在样本中出现的字段,仍只能视为源码或格式资料中的定义。

7.2 事件覆盖矩阵

事件P1P2P3P4总计
111114
2145515
369141443
43966396075442,186
51112171757
61669279133470
717010190139500
83572772322521,118
91451438851427
1021341130
1141781342
1252391451
138,36013,7157,6168,12237,813
14894526
1523005
1600000
1700000
182721211281
1920616012554545
204479134363862,182
214,94110,6318,0528,11831,742
220100414
23525511522244
245074827132
252,5762,0982,3063,45210,432
2616430050
2776107477237
2851181337
293,8285,7784,7354,49318,834

7.3 历史合成用例与复现方法

原始资料还记录了在 Python 3.12 下通过的 37 项合成断言,涉及以下情况。

  • 数据结构:嵌套与空表、稀疏键、重复键、数字键 0、数字键与字符串键的区别、显式键不推进顺序项,以及 nil、缺失、false、0、空字符串的区别。
  • 字符与文件编码:中文、引号内分隔符、反斜杠、常用转义、UTF-8 与 GBK 十进制字节转义、数字字面量、CRLF、BOM、末行无换行、GBK 与 GB18030。
  • 事件处理:事件早于资料、缺项不覆盖已有资料、缺少击杀者、未知事件保留及 CLI JSON 输出。
  • 错误输入:截断字符串或表、缺值、缺分隔符、尾随垃圾、函数调用、return、nil 表键、非法转义、非有限数、错误编码、空记录、错误列数和非整数头列。CLI 失败时退出码为 2,stdout 为空,stderr 包含行号。

复现真实样本统计时,先从文末的固定版本样本链接下载四份文件,将解析器保存为 jcl_parse.py,再修改下方两个路径运行。输出的哈希、事件总数和分布应与前两节一致;脚本只读取本地文件。

代码窗口
python3 - <<'PY'
import importlib.util
from pathlib import Path
script = Path('/实际路径/jcl_parse.py')
sample_dir = Path('/实际路径/samples')
spec = importlib.util.spec_from_file_location('jcl_example', script)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for path in sorted(sample_dir.glob('*.jcl')):
data = module.parse_file(path, 'gbk')
print(path.name, data['source']['sha256'], len(data['events']), data['event_counts'])
PY

7.4 尚未确认的范围

  1. 事件 16/17、Buff 第 12 项、结果标签 10/12/15/16 有来源定义,但未在这四份样本中出现;事件 30 无足够定义。保留未知数据,不保证所有未来版本。
  2. SKILL_RESULT_CODE 全枚举、TARGET 全枚举、装备栏 24–27、物品表类别全集及坐标/朝向的现实单位尚未完整证实;镇派非空技能等级映射只有源码证据。
  3. Buff 初始化/有效标志、效果反击标志的精确游戏机制仍需受控场景验证;本文保留源码命名、原值和争议,不以变量名代替实验。
  4. 四份竞技场样本不代表 PvE、缘起、国际服和全部历史版本;九项装备的旧帖布局缺少对应样本。
  5. 原始验证未覆盖 CRC 链、游戏客户端回导、客户端统计面板一致性、RDPS/RHPS 或生产性能。示例是数据解析与研究工具,不是完整复盘产品。

8. 参考资料

以下来源沿用原始资料截至 2026-10-25 的查阅记录。源码和样本链接固定到对应提交;网页说明及外部字典可能继续更新。正文中的“源码定义”和“样本出现”仅指这些版本与文件。

  1. 茗伊 JCL 写入定义:cattenblue/MY@9f1b06ea36581a202d73a61f7e143b997ad3c85e。事件与字段(154–184、348–837 行)、六列与 CRC(298–339 行)。
  2. 茗伊结果数值枚举与时间换算:与第 1 项同一提交。结果标签 0–16(161–178 行)、原始量与有效量(843–851 行)、逻辑帧换算。用于核对数值定义与时间单位,不代表解析结果必然等于客户端统计面板。
  3. 茗伊战斗边界与战斗标识:与第 1 项同一提交。战斗时长和 UUID 结构(24–73 行)。
  4. 茗伊角色资料读取:与第 1 项同一提交。装备镶嵌与镇派映射(116–152、359–425 行)。
  5. 茗伊装备栏兼容常量:与第 1 项同一提交。装备栏枚举(325–351 行)。运行时优先使用客户端枚举,此表不代表所有版本的全集。
  6. 剑三警长格式参考:moeheart/jx3bla@29e0fb69d924a3abc8425526599ac586d452816c。JCL 与 JX3DAT 读取、事件字段模型、Lua 表读取。
  7. 茗伊:JCL 数据说明,发布于 2022-10-25,更新于 2022-11-23。说明纯文本、六列结构及事件 1–29。
  8. 花姐:jcl 数据格式整理,原始资料记录的页面更新日期为 2024-06-13。包含结果表、装备和奇穴的早期说明,部分字段仍未确认。
  9. jcl-loader:coedor01/jcl-loader@57b78c067286e78283f6eb9f636130951cbecb99。字段文档、解析源码,作为补充格式资料。
  10. 公开样本:gyorkluu/jx3-pvp-analyzer@c8d28cf8f9cf2103decc67e65f5535680df06381。四份 JCL 文件、样本导出说明。仅使用样本及导出说明,不将配套字段命名作为格式依据。
  11. 外部字典:JX3BOX 游戏数据、心法资料、地图索引。使用时需另行确认与日志匹配的游戏版本和数据版本。