发布于

Als(直播/回放)动捕数据传输结构分析

系列: Link!Like!-动捕数据-als
章节: (2/3)

省流

使用了 protobuf 进行数据序列化。

本文只分析从服务端获取的数据,从客户端发送的数据不在此处分析。

本文使用了笔者编写的工具进行分析 TODO 地址,其实算是对数据包结构的总结,而不是逆向分析的复盘。

总结点这里🫲

前言

总所周知(),Linkura在25年5月29日的直播上使用了新的动捕回放,相较之前使用monobitinc家的mrs技术,直播相对来说更加稳定流畅了。

实际wireshark抓包测试,体验上是将更多小包拼接成大包来进行tcp发送,减少了丢小包的数量。(因为如果丢包是直接丢大包(笑))

关于cdn上存放的回放文件格式:

  • 提供了.md文件(一开始以为是markdown,写此片文章时认为是metadata)作为一开始的元信息描述文件。

  • 借用了流媒体相关的hls技术,使用了 m3u8 进行切片,每个分片是.ts文件,但它不是 MPEG-TS,没有半毛钱关系。

估计als这家公司是借用hls这一套设计,一方面借用cdn相关基础设施,另一方面也可能是为了兼容现有的视频流回放播放方案。

protobuf

具体包类型也可以在 Alstromeria.dll Alst.Protocol 中的 DataPackDataFrame中找到

package als;

message DataPack
{
	oneof control
{
	bool data = 2;
	bool pong = 10; // 直播会使用
	int64 segment_started_at = 14;
	bool cache_ended = 15;
}
	repeated .als.DataFrame frames = 16;
}

message DataFrame
{
	oneof message
{
	.als.InstantiateObject instantiate_object = 128;
	.als.UpdateObject update_object = 129;
	.als.Room room = 143;
	.als.AuthorizeResponse authorize_response = 144;    // 直播
	.als.JoinRoomResponse join_room_response = 147;     // 直播
}
}

回放数据

由于回放数据相对来说比较好获取,所以先介绍回放数据的结构。

接下来以 25-07-31 ,小三角给粉丝投稿的物品命名这集为例。

数据包格式

struct Packet {
  uint16_t length;                  // 大端序,包长度:live_mark + microseconds + protobuf_data
  uint8_t live_mark;                // 0 代表直播 1 代表回放 回放这里一直是 0x01
  uint64_t microseconds;            // 大端序,微秒时间戳
  uint8_t protobuf_data[];          // 长度为 length - 1 - 8
}

数据介绍

811c9dc5b61ac_segment00000.ts 分片结构分析

数据包总结
================== STATISTICS ==================
Total packets analyzed: 596
Packets with control messages: 596 (100.0%)
Packets with frames: 594 (99.7%)
Total frames: 2712

Control Message Types:
  Data: 594 (99.7%)
  SegmentStartedAt: 1 (0.2%)
  CacheEnded: 1 (0.2%)
  Total Control Messages: 596

Frame Message Types:
  Object Messages:
    InstantiateObject: 55 (2.0%)
    UpdateObject: 2655 (97.9%)
  Room: 2 (0.1%)
  Total Frame Messages: 2712
================================================

初始化00 ts分片前几个包由如下结构构成:

  1. SegmentStartedAt
  2. Data-Room
  3. CacheEnded
  4. Data-Frames(InstantiateObject|UpdateObject)
  5. 若干Data-Frames(UpdateObject)
SegmentStartedAt
=== Packet #1: 18 bytes ===
  Format: Standard protobuf format (int16 length + int8 unused + int64 timestamp + protobuf data)
  Timestamp: 2025-07-31 11:20:00.753128 UTC (1753960800753128)
  Raw data length: 9 bytes
  Raw data (first 9 bytes): 70 e8 ab ea 93 fd e6 8e 03
  Protobuf Fields:
    Field #14: Varint (wire type: 0, 9 bytes)
      Raw bytes: 70 e8 ab ea 93 fd e6 8e 03
  Control message:
    Type: SegmentStartedAt, Timestamp: 1753960800753128
  No frames

分片开始渲染的微秒级时间戳,横向对比分片 01、02、03、04、05 可以发现分片持续时间是10秒,与m3u8里的描述一致(#EXTINF:10.000)。

序号时间
002025-07-31 11:20:00.753128 UTC (1753960800753128)
012025-07-31 11:20:10.753128 UTC (1753960810753128)
022025-07-31 11:20:20.753128 UTC (1753960820753128)
032025-07-31 11:20:30.753128 UTC (1753960830753128)
042025-07-31 11:20:40.753128 UTC (1753960840753128)
Data-Room
=== Packet #2: 72 bytes ===
  Format: Standard protobuf format (int16 length + int8 unused + int64 timestamp + protobuf data)
  Timestamp: 2025-07-31 11:20:40.765541 UTC (1753960840765541)
  Raw data length: 63 bytes
  Raw data (first 32 bytes): 82 01 3a fa 08 37 0a 2c 64 65 66 61 75 6c 74 2d 32 35 64 31 65 65 35 66 2d 62 36 36 35 2d 34 61
  Protobuf Fields:
    Field #16: Length-delimited (wire type: 2, 61 bytes)
      Raw bytes: 82 01 3a fa 08 37 0a 2c 64 65 66 61 75 6c 74 2d 32 35 64 31 65 65 35 66 2d 62 36 36 35 2d 34 61 39 63 2d 38 33 63 32 2d 38 34 66 30 32 39 37 62 31 35 34 62 10 f5 af aa a1 fc e5 8e 03
    Field #2: Varint (wire type: 0, 2 bytes)
      Raw bytes: 10 01
  Control message:
    Type: Data, Value: true
  Frames (1):
    Frame #1: 
      Message: Room (id: "default-25d1ee5f-b665-4a9c-83c2-84f0297b154b", started: 1753926200891381, ended: 0)

started 时间是 2025-07-31T01:43:20.891Z 目前没找到什么来源,get_archive_list 无法获取到相关信息。

CacheEnded
=== Packet #3: 11 bytes ===
  Format: Standard protobuf format (int16 length + int8 unused + int64 timestamp + protobuf data)
  Timestamp: 2025-07-31 11:20:00.753128 UTC (1753960800753128)
  Raw data length: 2 bytes
  Raw data (first 2 bytes): 78 01
  Protobuf Fields:
    Field #15: Varint (wire type: 0, 2 bytes)
      Raw bytes: 78 01
  Control message:
    Type: CacheEnded, Value: true
  No frames
Data-Frames(InstantiateObject|UpdateObject)
=== Packet #4: 11780 bytes ===
  Format: Standard protobuf format (int16 length + int8 unused + int64 timestamp + protobuf data)
  Timestamp: 2025-07-31 11:20:00.753128 UTC (1753960800753128)
  Raw data length: 11771 bytes
  Raw data (first 32 bytes): 82 01 3a fa 08 37 0a 2c 64 65 66 61 75 6c 74 2d 32 35 64 31 65 65 35 66 2d 62 36 36 35 2d 34 61
  Protobuf Fields:
  ...
  Control message:
    Type: Data, Value: true
  Frames (95):
    Frame #1: 
      Message: Room (id: "default-25d1ee5f-b665-4a9c-83c2-84f0297b154b", started: 1753926200891381, ended: 0)
    InstantiateObject... Owner ID: "sys" Target: RoomAll (room_id: "default-25d1ee5f-b665-4a9c-83c2-84f0297b154b")
    UpdateObject... Target: RoomAll (room_id: "default-25d1ee5f-b665-4a9c-83c2-84f0297b154b")

Owner ID: "sys" Target: RoomAll (room_id: "default-25d1ee5f-b665-4a9c-83c2-84f0297b154b")

若干Data-Frames(UpdateObject)
=== Packet #5: 1958 bytes ===
  Format: Standard protobuf format (int16 length + int8 unused + int64 timestamp + protobuf data)
  Timestamp: 2025-07-31 11:20:00.759993 UTC (1753960800759993)
  Raw data length: 1949 bytes
  Raw data (first 32 bytes): 82 01 5d 8a 08 5a 40 b3 c1 83 f1 02 48 01 52 20 00 00 00 00 00 00 00 00 dd bd f4 3e e2 d9 ad 3c
  Protobuf Fields:
  ...
  Control message:
    Type: Data, Value: true
  Frames (4):
    UpdateObject... Target: RoomAll (room_id: "default-25d1ee5f-b665-4a9c-83c2-84f0297b154b")

01分片结构分析

================== STATISTICS ==================
Total packets analyzed: 591
Packets with control messages: 590 (99.8%)
Packets with frames: 589 (99.7%)
Total frames: 2705

Control Message Types:
  Data: 588 (99.7%)
  SegmentStartedAt: 1 (0.2%)
  CacheEnded: 1 (0.2%)
  Total Control Messages: 590

Frame Message Types:
  Object Messages:
    InstantiateObject: 55 (2.0%)
    UpdateObject: 2649 (97.9%)
  Room: 1 (0.0%)
  Total Frame Messages: 2705
================================================

与00有区别

  1. SegmentStartedAt 👈 相比于00 过了10秒
  2. Data-Room 与00的包相同
  3. Data-Frames(InstantiateObject|UpdateObject) 👈 Target: CurrentPlayer, 少了个Room
  4. CacheEnded 👈 保持一致
  5. 若干Data-Frames(UpdateObject) 没有变化

116分片统计信息

================== STATISTICS ==================
Total packets analyzed: 540
Packets with control messages: 539 (99.8%)
Packets with frames: 538 (99.6%)
Total frames: 2725

Control Message Types:
  Data: 537 (99.6%)
  SegmentStartedAt: 1 (0.2%)
  CacheEnded: 1 (0.2%)
  Total Control Messages: 539

Frame Message Types:
  Object Messages:
    InstantiateObject: 55 (2.0%)
    UpdateObject: 2669 (97.9%)
  Room: 1 (0.0%)
  Total Frame Messages: 2725
================================================

233分片统计信息

================== STATISTICS ==================
Total packets analyzed: 590
Packets with control messages: 589 (99.8%)
Packets with frames: 588 (99.7%)
Total frames: 2688

Control Message Types:
  Data: 587 (99.7%)
  SegmentStartedAt: 1 (0.2%)
  CacheEnded: 1 (0.2%)
  Total Control Messages: 589

Frame Message Types:
  Object Messages:
    InstantiateObject: 55 (2.0%)
    UpdateObject: 2632 (97.9%)
  Room: 1 (0.0%)
  Total Frame Messages: 2688
================================================

与01分片结构相同

分片总结

分片总的来说分为两种

  1. 最初初始化分片
  2. 后续的数据分片

抓包测试时,播放器实际获取分片的行为是:

获取00、01、03分片,然后再根据 本編開始 "play_time_second": seconds 来查找偏移量,获取目标分片。

直播数据

直播数据是通过笔者编写的工具获取的

同样以 25-07-31 ,小三角给粉丝投稿的物品命名这集为例。

数据包格式

struct Packet {
  uint16_t length;                  // 大端序,包长度:live_mark + protobuf_data
  uint8_t live_mark;                // 0 代表直播 1 代表回放 直播这里一直是 0x00
  uint8_t protobuf_data[];          // 长度为 length - 1
}

可以看见数据中并未携带时间戳 所以工具又补了一个时间戳

struct TimestampPacket {
  uint8_t length;
  uint64_t microseconds;
}

工具保存的二进制为若干 Packet + TimestampPacket

数据介绍

初始分片结构分析

数据包总结
================== STATISTICS ==================
Total packets analyzed: 450
Packets with control messages: 450 (100.0%)
Packets with frames: 445 (98.9%)
Total frames: 2049

Control Message Types:
  Data: 445 (98.9%)
  Pong: 5 (1.1%)
  Total Control Messages: 450

Frame Message Types:
  Response Messages:
    AuthorizeResponse: 1 (0.0%)
    JoinRoomResponse: 1 (0.0%)
  Object Messages:
    InstantiateObject: 55 (2.7%)
    UpdateObject: 1991 (97.2%)
  Room: 1 (0.0%)
  Total Frame Messages: 2049
================================================

握手分片前几个包由如下结构构成:

  1. AuthorizeResponse
  2. Data-Room
  3. JoinRoomResponse
  4. Data-Frames(InstantiateObject|UpdateObject)
  5. 若干Data-Frames(UpdateObject)
  6. 若干Pong包
AuthorizeResponse

对于转换来说不重要,就是正常的握手流程

Data-Room
=== Packet #3: 64 bytes ===
  Format: Mixed protobuf format (int16 length 64 + int8 unused 0x00 + protobuf data)
  Raw data length: 63 bytes
  Raw data (first 32 bytes): 82 01 3a fa 08 37 0a 2c 64 65 66 61 75 6c 74 2d 32 35 64 31 65 65 35 66 2d 62 36 36 35 2d 34 61
  Protobuf Fields:
    Field #16: Length-delimited (wire type: 2, 61 bytes)
      Raw bytes: 82 01 3a fa 08 37 0a 2c 64 65 66 61 75 6c 74 2d 32 35 64 31 65 65 35 66 2d 62 36 36 35 2d 34 61 39 63 2d 38 33 63 32 2d 38 34 66 30 32 39 37 62 31 35 34 62 10 f5 af aa a1 fc e5 8e 03
    Field #2: Varint (wire type: 0, 2 bytes)
      Raw bytes: 10 01
  Control message:
    Type: Data, Value: true
  Frames (1):
    Frame #1: 
      Message: Room (id: "default-25d1ee5f-b665-4a9c-83c2-84f0297b154b", started: 1753926200891381, ended: 0)

✅ 与回放相同

JoinRoomResponse

不重要,就是正常的握手流程

Data-Frames(InstantiateObject|UpdateObject)
=== Packet #7: 7997 bytes ===
  Format: Mixed protobuf format (int16 length 7997 + int8 unused 0x00 + protobuf data)
  Raw data length: 7996 bytes
  Raw data (first 32 bytes): 82 01 44 82 08 41 40 9c 82 a2 c3 04 4a 0c 61 75 64 69 65 6e 63 65 2d 70 6f 64 52 1e e9 ff ff ff
  Protobuf Fields:
  ...
  Control message:
    Type: Data, Value: true
  Frames (94):
    InstantiateObject... Owner ID: "audience-pod" Target: RoomAll (room_id: "")
    UpdateObject... Target: RoomAll (room_id: "")

InstantiateObject id,数量与 回放一致。不一样的就是 Owner ID 和 Target Room ID。 UpdateObject Target Room ID 为空

若干Data-Frames(UpdateObject)
=== Packet #13: 1816 bytes ===
  Format: Mixed protobuf format (int16 length 1816 + int8 unused 0x00 + protobuf data)
  Raw data length: 1815 bytes
  Raw data (first 32 bytes): 82 01 2f 8a 08 2c 40 b3 c1 83 f1 02 48 01 52 20 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  Protobuf Fields:
  ...
  Control message:
    Type: Data, Value: true
  Frames (4):
    UpdateObject... Target: RoomAll (room_id: "")
若干Pong包

对于回放来说不重要,直播用于检测心跳用的

后续分片分析

================== STATISTICS ==================
Total packets analyzed: 450
Packets with control messages: 450 (100.0%)
Packets with frames: 445 (98.9%)
Total frames: 1974

Control Message Types:
  Data: 445 (98.9%)
  Pong: 5 (1.1%)
  Total Control Messages: 450

Frame Message Types:
  Object Messages:
    UpdateObject: 1974 (100.0%)
  Total Frame Messages: 1974
================================================

理论上来说目前直播没有后续分片的说法,无法溯源+拖动进度条 只有 UpdateObject 和 Pong 包

结束的分片

================== STATISTICS ==================
Total packets analyzed: 308
Packets with control messages: 308 (100.0%)
Packets with frames: 193 (62.7%)
Total frames: 594

Control Message Types:
  Data: 193 (62.7%)
  Pong: 115 (37.3%)
  Total Control Messages: 308

Frame Message Types:
  Object Messages:
    UpdateObject: 576 (97.0%)
    DestroyObject: 18 (3.0%)
  Total Frame Messages: 594
================================================

有DestroyObject指令,看了前后的包,基本都是Pong包。

可以推测是服务器关闭后做的finalize相关的操作。

总结

通过对比直播和回放的数据包结构,我们可以发现以下异同点:

总结大表
项目直播数据回放数据相同说明差异说明
包结构uint16_t length
uint8_t live_mark
length: 2字节,大端序
mark: 1字节标记
live_mark:
直播是 0x00,回放是 0x01
uint64_t timestamp回放携带了微秒时间戳
protobuf
初始分片 protobufAuthorizeResponseSegmentStartedAt
Data-Room Room: live id, started, ended 相同
JoinRoomResponseCacheEnded
Data-Frames
Data-Room
(InstantiateObject|UpdateObject)InstantiateObject数量,id相同
UpdateObject初始化数据也相同
InstantiateObject的Owner ID:
直播: "audience-pod"
回放: "sys"

Target:
直播: RoomAll (room_id: "")
回放:RoomAll (room_id: "default-xxx...")
若干Data-Frames(UpdateObject)Target:
直播: RoomAll (room_id: "")
回放:RoomAll (room_id: "default-xxx...")
若干Pong包
后续分片 protobuf回放初始分片回放后续分片
直播流没有后续分片的说法,所以在这里比较回放的初始分片和后续分片
SegmentStartedAt
Data-Room
Data-Frames
Data-Room
(InstantiateObject|UpdateObject)InstantiateObject数量,id相同
UpdateObject初始化数据也相同
Target:
直播分片: RoomAll (room_id: "default-xxx...")
后续分片: CurrentPlayer
CacheEnded
若干Data-Frames(UpdateObject)Target: RoomAll (room_id: "default-xxx...")

详细技术差异分析 Generated By Claude Sonnet 4

Protobuf字段使用对比

Field编号字段名称直播使用回放使用用途说明
Field #2data✅ 频繁使用✅ 频繁使用标识数据包
Field #10pong✅ 心跳检测❌ 不使用直播专用心跳
Field #14segment_started_at❌ 不使用✅ 每分片1次分片时间戳
Field #15cache_ended❌ 不使用✅ 每分片1次缓存结束标记
Field #16frames✅ 主要载荷✅ 主要载荷帧数据数组

对象生命周期管理对比

生命周期阶段直播处理回放处理技术细节
对象创建InstantiateObject (55个)InstantiateObject (55个)数量和ID完全相同
对象更新连续UpdateObject流按分片UpdateObject直播实时,回放分段
对象销毁DestroyObject (结束时)❌ 无显式销毁直播需要清理资源
Owner标识"audience-pod""sys"权限和来源不同

时间同步机制对比

时间维度直播实现回放实现精度差异
包级时间戳❌ 无内置✅ 微秒级回放可精确定位
分片时间戳❌ 无分片概念✅ 10秒间隔回放支持跳转
Room started17539262008913811753926200891381完全相同的起始时间
时序保证网络传输顺序时间戳排序回放可重排序

分片策略详细对比

分片特征直播模式回放模式实现复杂度
初始化分片❌ 无概念✅ 完整状态回放需要状态重建
后续分片❌ 无概念✅ 增量更新回放支持随机访问
分片大小N/A~540-596包固定时长分片
overlap处理N/A分片间状态一致需要状态管理

回放分片内部结构对比

项目初始分片 (00)后续分片 (01+)差异说明
包序列结构SegmentStartedAt → Data-Room → CacheEnded → Data-Frames → UpdateObjectsSegmentStartedAt → Data-Room → Data-Frames → CacheEnded → UpdateObjectsCacheEnded位置不同
SegmentStartedAt2025-07-31 11:20:00.753128+10秒递增精确的10秒间隔
Data-Room✅ 完整Room信息✅ 相同Room信息内容完全一致
CacheEnded位置第3个包 (早期)倒数几个包 (延后)处理顺序优化

回放分片帧内容对比

帧类型初始分片 (00)后续分片 (01+)业务意义
Room帧数量2个 (0.1%)1个 (0.0%)初始分片重复Room信息
InstantiateObject55个 (2.0%)55个 (2.0%)每个分片都完整初始化对象
UpdateObject2655个 (97.9%)2632-2669个 (97.9%)主要数据载荷
Data-Frames中Room✅ 包含额外Room帧❌ 无额外Room帧初始分片冗余保证

回放分片Target策略对比

Target类型初始分片 (00)后续分片 (01+)
InstantiateObject TargetRoomAll (完整房间ID)CurrentPlayer
UpdateObject TargetRoomAll (完整房间ID)RoomAll (完整房间ID)
Room消息TargetRoomAll (完整房间ID)RoomAll (完整房间ID)

分享