剑网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 下标 | 类型 | 含义与单位 | 证据 |
|---|---|---|---|---|
| 1 | 0 | 十进制整数 | 历史/实现相关校验值,保留原值;不是记录序号 | 源码定义、格式说明及样本 |
| 2 | 1 | 整数 | GetLogicFrameCount(),游戏逻辑帧计数 | 源码定义、格式说明及样本 |
| 3 | 2 | 整数 | GetCurrentTime(),Unix 秒时间戳 | 源码定义、格式说明及样本 |
| 4 | 3 | 整数 | GetTime(),客户端毫秒时钟,不是网络延迟 | 源码定义、格式说明及样本 |
| 5 | 4 | 整数 | 事件编号,源码定义 1–29 | 源码定义、格式说明及样本 |
| 6 | 5 | Lua 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_TIME | 1 | b | bFighting,true 开战、false 结束 | 样本仅见 true | 源码定义及样本 |
| 1 | 2 | s | szUUID,战斗标识 | 按整体字符串保存 | 源码定义及样本 |
| 1 | 3 | i | nDuring,持续毫秒 | 开战为 0,不能作最终时长 | 源码定义及样本 |
| 1 | 4 | i | dwMapID,地图 ID | 早期格式说明未列,样本中存在 | 源码定义及样本 |
| 2 玩家进入;3 玩家离开 | 1 | i | 玩家运行期 ID | 离场不等于死亡 | 源码定义及样本 |
| 6 NPC 进入;7 NPC 离开 | 1 | i | NPC 运行期 ID | 不是模板 ID | 源码定义及样本 |
| 10 交互物件进入;11 离开 | 1 | i | 交互物件运行期 ID | 与玩家/NPC 分域 | 源码定义及样本 |
| 5 玩家战斗状态;9 NPC 战斗状态 | 1 | i | 运行期 ID | 角色级别状态 | 源码定义及样本 |
| 5/9 | 2 | b/i | bFight,是否进战 | 样本为布尔 | 源码定义及样本 |
| 5/9 | 3、4 | n | 当前生命、最大生命 | 无对象句柄时源码写 -1 | 源码定义及样本 |
| 5/9 | 5、6 | n | 当前内力、最大内力 | 无对象句柄时源码写 -1 | 源码定义及样本 |
固定版本中的战斗标识组装为 edition::region_server::unix_seconds::teamID::memberXor/count::UglobalID/runtimeID。该结构仅对应固定源码版本,不保证所有历史文件都相同,也不符合 RFC 4122 UUID 格式。解析时不应随意拆分服务器名中的下划线;只需关联战斗时,保留完整字符串即可。3
3.2 4:玩家资料与嵌套装备、奇穴
| 位置 | 类型 | 源字段 | 含义、可选性 | 证据 |
|---|---|---|---|---|
| 1 | i | dwID | 运行期玩家 ID | 源码定义及样本 |
| 2 | s | szName | 名称,可能含跨服后缀;不要据名字合并实体 | 源码定义及样本 |
| 3 | i | dwForceID | 门派/势力 ID,不是心法 ID | 源码定义及样本 |
| 4 | i | dwKungfuID | 心法技能 ID;未取到时初始化为 -1 | 源码定义及样本 |
| 5 | n | nEquipScore | 装分;异步取得,可缺 | 源码定义及样本 |
| 6 | t | aEquip | 装备列表;可缺或为空 | 源码定义及样本 |
| 7 | t | aTalent | 奇穴列表;可缺或为空 | 源码定义及样本 |
| 8 | s | szGUID | 跨服角色标识;可为空字符串,不能转浮点数 | 源码定义及样本 |
| 9 | t | tZhenPai | 镇派技能 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 位置 | 类型 | 含义 | 说明/证据 |
|---|---|---|---|
| 1 | i | nEquipIndex,装备栏索引 | 源码定义及样本;部分枚举见下表,仍需匹配游戏版本 |
| 2 | i | dwTabType,物品表类别 | 源码定义及样本;需与 E[3] 联合查装备 |
| 3 | i | dwTabIndex,该表中的物品 ID | 源码定义及样本;不是图标 ID |
| 4 | i | nStrengthLevel,精炼等级 | 源码定义及样本;不是装备品质 |
| 5 | t | aSlotItem,镶嵌内容 | 稀疏孔位映射,必须保留键 0,源码定义及样本 |
| 6 | i | dwPermanentEnchantID,永久附魔 ID | 源码定义;样本中存在该位置,不凭数值推断大小附魔 |
| 7 | i | dwTemporaryEnchantID,临时附魔 ID | 源码定义;0 仍是原始值 |
| 8 | i | dwTemporaryEnchantLeftSeconds,临时附魔剩余秒 | 源码定义;不能解读为“大附魔 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_INFO | 1、2 | i、s | 运行期 NPC ID、名称 | 源码定义及样本 |
| 8 | 3、4 | i、i | 模板 ID、雇主/主人运行期 ID dwEmployer | 源码定义及样本;无主人常见 0,保留原值 |
| 8 | 5、6、7 | n、n、n | x、y、z | 源码定义及样本;不能把 z 当生命值 |
| 8 | 8 | n | 朝向 nFaceDirection | 源码定义及样本 |
| 12 DOODAD_INFO | 1、2 | i、i | 运行期物件 ID、模板 ID | 源码定义及样本;此处没有名字字段 |
| 12 | 3、4、5、6 | n | x、y、z、朝向 | 源码定义及样本 |
| 29 TARGET_INFORMATION | 1、2 | i、i | 目标类型 TARGET 枚举、运行期 ID | 源码定义及样本;不能把类型当阵营/职业,枚举数值未在本文穷举 |
| 29 | 3、4、5、6 | n | x、y、z、朝向 | 源码定义及样本;第 5 项不是场景 ID |
| 29 | 7、8 | n | 当前生命、最大生命 | 源码定义及样本 |
| 29 | 9、10 | n | 当前内力、最大内力 | 源码定义及样本 |
| 29 | 11 | n | nDamageAbsorbValue,伤害吸收值状态 | 源码定义及样本;不是此帧新增吸收量 |
事件 29 受 PvP 记录开关、竞技场环境及每目标节流限制,默认节流参数为 200ms,且随相关事件触发;它不是保证每 200ms 一次的全实体全量轨迹。8/12 也有信息更新节流。没有 29 不代表人物没动,不能无限外推位置或把生命快照差直接算成伤害。
3.4 13:Buff 更新
| 位置 | 类型 | 含义 | 证据/限制 |
|---|---|---|---|
| 1 | i | 受体 ID owner | 源码定义及样本 |
| 2 | b/i | bDelete,删除标志 | 样本为布尔;false 包括新增/刷新,不只“施放成功” |
| 3 | i | nIndex,该目标上的 Buff 索引 | 与 Buff ID 区别;不保证跨生命周期唯一 |
| 4 | b/i | bCanCancel,是否可取消 | 源码定义为可取消;旧版资料称有利气劲,不能据此判定增益/减益 8 |
| 5 | i | Buff ID | 源码定义及样本 |
| 6 | i | 层数 | 源码定义及样本 |
| 7 | i | 预计结束逻辑帧 endframe | 非时长、非 Unix 时间 |
| 8 | b/i | binit 候选,初始化/全量刷新相关 | 源码注释仍带问号;保留为 init_raw,精确语义未知 |
| 9 | i | Buff 等级 | 源码定义及样本 |
| 10 | i | 来源角色 ID srcid | 源码定义及样本;不是技能 ID |
| 11 | b/i | isvalid 候选,有效标志 | 源码注释命名,具体机制边界未实测;输出 valid_raw |
| 12 | i | leftframe,剩余逻辑帧 | 源码定义及格式说明;四份样本均未出现,不补 0 |
Buff 覆盖统计需要按目标、索引和 Buff 身份维护状态,并参考来源、等级与层数;刷新会更新结束帧,删除结束当前实例。开战导入可能带有已有 Buff,日志也可能在到期/删除之前结束。本文提供状态事件,不把这些不完整区间自动变成完整覆盖率。
3.5 14–18:文字与团队事件
| 事件 | 位置 | 类型 | 含义及限制 | 证据 |
|---|---|---|---|---|
| 14 PLAYER_SAY | 1、2、3、4 | s、i、i、s | 纯文本、喊话者 ID、频道号、名字;实际仅保存 NPC 喊话 | 源码定义及样本 |
| 15 ON_WARNING_MESSAGE | 1、2 | s、s | 警告类型、文本;类型常见 MSG_NOTICE_GREEN/YELLOW/RED | 源码定义及样本 |
| 16 PARTY_ADD_MEMBER | 1、2、3 | i、i、i | 团队 ID、成员 ID、分组索引 | 源码定义及格式说明;样本未出现,分组基数不猜 |
| 17 PARTY_SET_MEMBER_ONLINE_FLAG | 1、2、3 | i、i、i | 团队 ID、成员 ID、在线标志 | 源码定义及格式说明;离线时补 0,其他取值保留,样本未出现 |
| 18 MSG_SYS | 1、2 | s、s | 系统文本、频道字符串 | 源码定义及样本;不是第 1 项为频道 |
14/18 的文本已经过生产者富文本转纯文本处理,不包含全部原始聊天格式。不要把文字消息正则识别结果当作稳定事件协议。
3.6 19–28:施放、效果、命中、治疗与死亡
| 事件 | 位置 | 类型 | 含义及限制 | 证据 |
|---|---|---|---|---|
| 19 CAST | 1、2、3 | i、i、i | 施放者、技能 ID、技能等级;是施放事件,不等于造成一次伤害 | 源码定义及样本 |
| 20 CAST_RESPOND | 1、2、3、4 | i、i、i、i | 施放者、技能 ID、等级、SKILL_RESULT_CODE 响应码 | 源码定义及样本;响应码枚举未穷举,不猜成功码 |
| 21 EFFECT | 1、2 | i、i | 施放者、目标 | 源码定义及样本 |
| 21 | 3 | b/i | bReact,源码称是否反击 | 样本为 0/1;旧版资料称隐藏类别,精确机制待确认,保留 react_raw 8 |
| 21 | 4、5、6 | i、i、i | 效果类型、效果 ID、效果等级 | 源码定义及样本;类型 1 按技能、2 按 Buff 解释,不能将全部 ID 当主动技能 6 |
| 21 | 7 | b/i | 会心标志 | 源码定义及样本;样本为布尔 |
| 21 | 8 | i | 结果表条目数 nCount | 不是命中次数、目标数或结果码;样本中 31,742 条效果记录均与结果表条目数一致 |
| 21 | 9 | t | tResultCount,按数值类型标签组织的结果表 | 见下一节;显式索引 0 合法 |
| 22 BLOCK | 1–5;6 | i | 施放者、目标、效果类型、效果 ID、效果等级;伤害类型 | 源码定义及样本;第 6 项引用 SKILL_RESULT_TYPE,非伤害数值 |
| 23 SHIELD | 1–5 | i | 施放者、目标、效果类型、效果 ID、效果等级 | 源码定义及样本;被屏蔽,不等于治疗盾吸收的数值记录 |
| 24 MISS | 1–5 | i | 同上,技能未命中 | 源码定义及样本 |
| 25 HIT | 1–5 | i | 同上,技能命中 | 源码定义及样本;没有伤害数值,不与 21 累加计伤 |
| 26 DODGE | 1–5 | i | 同上,闪避 | 源码定义及样本;不是打断日志 |
| 27 COMMON_HEALTH | 1、2 | i、n | 承疗角色 ID、增加的生命值 | 源码定义及样本;没有施放者,不可硬归属某治疗玩家 |
| 28 DEATH | 1、2 | i、i | 死亡目标、击杀者 | 第二项可缺;样本 37 条中有 2 条缺击杀者 |
写入端会在开战时为已重伤队友补 28 {id,nil},所以日志里的死亡记录不保证都是战斗开始后新发生的死亡。击杀者缺失不能自动填成目标自己或某个“环境伤害 NPC”。
3.7 21 的结果表:标签 0–16
结果表的键是数值标签,不是条目的出现顺序。以下枚举由固定版本源码定义,量纲为对应的伤害、治疗或资源量。2 “样本次数”表示四份文件中包含该键的记录数;次数为 0 表示没有出现,不表示字段值为零。
| 键 | 源码枚举名 | 含义 | 样本次数 | 备注 |
|---|---|---|---|---|
| 0 | PHYSICS_DAMAGE | 外功伤害 | 2,779 | 总伤害分项 |
| 1 | SOLAR_MAGIC_DAMAGE | 阳性伤害 | 345 | 总伤害分项 |
| 2 | NEUTRAL_MAGIC_DAMAGE | 混元伤害 | 263 | 总伤害分项 |
| 3 | LUNAR_MAGIC_DAMAGE | 阴性伤害 | 1,581 | 总伤害分项 |
| 4 | POISON_DAMAGE | 毒性伤害 | 2,452 | 总伤害分项 |
| 5 | REFLECTIED_DAMAGE | 反弹伤害 | 167 | 保留源码拼写;样本中这 167 条的值均非零,是否计入总伤害需明确口径 |
| 6 | THERAPY | 治疗 | 3,593 | 总治疗候选口径 |
| 7 | STEAL_LIFE | 生命偷取 | 1,476 | 不与 6 无条件重复累计 |
| 8 | ABSORB_THERAPY | 化解治疗 | 87 | 不是未知占位;不可直接视作有效治疗 |
| 9 | ABSORB_DAMAGE | 化解伤害 | 1,238 | 归属盾提供者需要额外状态逻辑 |
| 10 | SHIELD_DAMAGE | 无效伤害 | 0 | 源码定义,样本未出现;与事件 23 概念相关但不是同一字段 |
| 11 | PARRY_DAMAGE | 拆招 | 70 | 旧版资料也称“招架”,此处沿用源码命名 8 |
| 12 | INSIGHT_DAMAGE | 识破 | 0 | 源码定义;可用于判定识破结果,样本未出现 |
| 13 | EFFECTIVE_DAMAGE | 有效伤害 | 31,742 | 全部效果记录都有,可能为 0 |
| 14 | EFFECTIVE_THERAPY | 有效治疗 | 31,742 | 全部效果记录都有,可能为 0 |
| 15 | TRANSFER_LIFE | 吸取生命 | 0 | 源码定义,样本未出现;与 7 为不同标签 |
| 16 | TRANSFER_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 保留字符串,大整数不要经过 JavaScriptNumber。交互物件与角色 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 argparseimport codecsimport hashlibimport jsonimport mathimport reimport sysfrom collections import Counterfrom 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 Pathrows = [ '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')PYpython3 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 不参与解析成功判定。
| 编号 | 固定版本文件名 | 字节数 | 事件行数 | 严格解码 |
|---|---|---|---|---|
| P1 | 3v3-乐山大佛窟(128)-(sample).jcl | 2,410,347 | 21,858 | GBK |
| P2 | 3v3-沃石火狱(624)-(sample).jcl | 4,014,466 | 34,756 | GBK |
| P3 | 5v5-墟海之眼(362)-(sample).jcl | 2,941,675 | 24,724 | GBK |
| P4 | 5v5-藏剑武库(529)-(sample).jcl | 2,982,010 | 25,979 | GBK |
SHA-256:
P1 16f0637b7005f96d03bf01d68a76ce2bdec62158c352de14ea13cd1c55efa727P2 99634624801396d319981684e6b7a6f513c6fc6caaa0f0bf39638a282f7e81cfP3 787e52b92ba87682e1cb21251f6deb3df105646c253e3f9dc8a7ed69f7ad5d15P4 6ec97a9d3a384c6ac23d7bf28ef650c7e321bff3f9ae694bc3e5d42221f5ace5原始验证记录显示,107,317/107,317 行均通过六列结构和 Lua 表解析,没有跳过坏行;已见顶层事件键均在第 3 节列出。六列中没有显式的客户端构建号,不能用来源提交日期代替游戏版本。未在样本中出现的字段,仍只能视为源码或格式资料中的定义。
7.2 事件覆盖矩阵
| 事件 | P1 | P2 | P3 | P4 | 总计 |
|---|---|---|---|---|---|
| 1 | 1 | 1 | 1 | 1 | 4 |
| 2 | 1 | 4 | 5 | 5 | 15 |
| 3 | 6 | 9 | 14 | 14 | 43 |
| 4 | 396 | 639 | 607 | 544 | 2,186 |
| 5 | 11 | 12 | 17 | 17 | 57 |
| 6 | 166 | 92 | 79 | 133 | 470 |
| 7 | 170 | 101 | 90 | 139 | 500 |
| 8 | 357 | 277 | 232 | 252 | 1,118 |
| 9 | 145 | 143 | 88 | 51 | 427 |
| 10 | 2 | 13 | 4 | 11 | 30 |
| 11 | 4 | 17 | 8 | 13 | 42 |
| 12 | 5 | 23 | 9 | 14 | 51 |
| 13 | 8,360 | 13,715 | 7,616 | 8,122 | 37,813 |
| 14 | 8 | 9 | 4 | 5 | 26 |
| 15 | 2 | 3 | 0 | 0 | 5 |
| 16 | 0 | 0 | 0 | 0 | 0 |
| 17 | 0 | 0 | 0 | 0 | 0 |
| 18 | 27 | 21 | 21 | 12 | 81 |
| 19 | 206 | 160 | 125 | 54 | 545 |
| 20 | 447 | 913 | 436 | 386 | 2,182 |
| 21 | 4,941 | 10,631 | 8,052 | 8,118 | 31,742 |
| 22 | 0 | 10 | 0 | 4 | 14 |
| 23 | 52 | 55 | 115 | 22 | 244 |
| 24 | 50 | 7 | 48 | 27 | 132 |
| 25 | 2,576 | 2,098 | 2,306 | 3,452 | 10,432 |
| 26 | 16 | 4 | 30 | 0 | 50 |
| 27 | 76 | 10 | 74 | 77 | 237 |
| 28 | 5 | 11 | 8 | 13 | 37 |
| 29 | 3,828 | 5,778 | 4,735 | 4,493 | 18,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.utilfrom pathlib import Pathscript = 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'])PY7.4 尚未确认的范围
- 事件 16/17、Buff 第 12 项、结果标签 10/12/15/16 有来源定义,但未在这四份样本中出现;事件 30 无足够定义。保留未知数据,不保证所有未来版本。
SKILL_RESULT_CODE全枚举、TARGET全枚举、装备栏 24–27、物品表类别全集及坐标/朝向的现实单位尚未完整证实;镇派非空技能等级映射只有源码证据。- Buff 初始化/有效标志、效果反击标志的精确游戏机制仍需受控场景验证;本文保留源码命名、原值和争议,不以变量名代替实验。
- 四份竞技场样本不代表 PvE、缘起、国际服和全部历史版本;九项装备的旧帖布局缺少对应样本。
- 原始验证未覆盖 CRC 链、游戏客户端回导、客户端统计面板一致性、RDPS/RHPS 或生产性能。示例是数据解析与研究工具,不是完整复盘产品。
8. 参考资料
以下来源沿用原始资料截至 2026-10-25 的查阅记录。源码和样本链接固定到对应提交;网页说明及外部字典可能继续更新。正文中的“源码定义”和“样本出现”仅指这些版本与文件。
- 茗伊 JCL 写入定义:
cattenblue/MY@9f1b06ea36581a202d73a61f7e143b997ad3c85e。事件与字段(154–184、348–837 行)、六列与 CRC(298–339 行)。 - 茗伊结果数值枚举与时间换算:与第 1 项同一提交。结果标签 0–16(161–178 行)、原始量与有效量(843–851 行)、逻辑帧换算。用于核对数值定义与时间单位,不代表解析结果必然等于客户端统计面板。
- 茗伊战斗边界与战斗标识:与第 1 项同一提交。战斗时长和 UUID 结构(24–73 行)。
- 茗伊角色资料读取:与第 1 项同一提交。装备镶嵌与镇派映射(116–152、359–425 行)。
- 茗伊装备栏兼容常量:与第 1 项同一提交。装备栏枚举(325–351 行)。运行时优先使用客户端枚举,此表不代表所有版本的全集。
- 剑三警长格式参考:
moeheart/jx3bla@29e0fb69d924a3abc8425526599ac586d452816c。JCL 与 JX3DAT 读取、事件字段模型、Lua 表读取。 - 茗伊:JCL 数据说明,发布于 2022-10-25,更新于 2022-11-23。说明纯文本、六列结构及事件 1–29。
- 花姐:jcl 数据格式整理,原始资料记录的页面更新日期为 2024-06-13。包含结果表、装备和奇穴的早期说明,部分字段仍未确认。
- jcl-loader:
coedor01/jcl-loader@57b78c067286e78283f6eb9f636130951cbecb99。字段文档、解析源码,作为补充格式资料。 - 公开样本:
gyorkluu/jx3-pvp-analyzer@c8d28cf8f9cf2103decc67e65f5535680df06381。四份 JCL 文件、样本导出说明。仅使用样本及导出说明,不将配套字段命名作为格式依据。 - 外部字典:JX3BOX 游戏数据、心法资料、地图索引。使用时需另行确认与日志匹配的游戏版本和数据版本。