map_parser.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. #!/usr/bin/env python3
  2. """
  3. 996传奇客户端 .map 文件格式解析器 (WGS Mir2 兼容)
  4. 来源: jycq_206923/scene/map/ 7 个样本反推 + 公开 WGS Mir2 格式文档交叉验证
  5. 二进制调用点: tileSceneManager:LoadMapData / mapData2DTile3 @0x1008xxxx
  6. 验证: 7 个样本全部解析成功, 字段值与公开文档一致 (2026-09-23)
  7. 文件结构:
  8. ┌─────────── 52B 标准 header ───────────┐
  9. │ 0-1 width (uint16, 格数) │
  10. │ 2-3 height (uint16, 格数) │
  11. │ 4 title_len (uint8, 通常 13) │
  12. │ 5-17 title (e.g. "Legend of mir") │
  13. │ 18-19 版本/保留 (0) │
  14. │ 20-21 update_date (BE uint16) │
  15. │ 22-23 update_time (BE uint16) │
  16. │ 24-25 update_date2 (BE uint16) │
  17. │ 26-27 update_time2 (BE uint16) │
  18. │ 28-29 music_id / 区域 (uint16) │
  19. │ 30-31 ??? (uint16) │
  20. │ 32-33 light_width? (uint16) │
  21. │ 34-35 light_height? (uint16) │
  22. │ 36-39 ??? (uint32) │
  23. │ 40-43 ??? (uint32, 0) │
  24. │ 44-47 ??? (uint32) │
  25. │ 48-51 ??? (uint32, 通常 0x80000000) │
  26. └────────────────────────────────────────┘
  27. │ (52B 后)
  28. ▼
  29. ┌─────────── w*h × 12B 单格数据 ───────────┐
  30. │ cell = 12B: │
  31. │ 0-1 bg_img (uint16, 背景图索引) │
  32. │ 2-3 mid_img (uint16, 中景图索引) │
  33. │ 4-5 obj_img (uint16, 前景/对象索引) │
  34. │ 6-7 ani_idx (uint16, 动画帧索引) │
  35. │ 8 door_flag (uint8, 门/动画开关) │
  36. │ 9 light (uint8, 光照 0=亮 0x80=暗)│
  37. │ 10 area (uint8, 区域/门索引) │
  38. │ 11 ani_tick (uint8, 动画速度 tick) │
  39. └────────────────────────────────────────────┘
  40. 变体:
  41. - title_len=15 (e.g. 0.map "Legend of mir\\r\\n"): 扩展格式
  42. 在 52B header 后额外 980000B 数据 (≈ w*h*2 = 700*700*2)
  43. 再接 12B/cell 数据 (推测: 区域遮罩/光照索引, 待二次精读)
  44. """
  45. from __future__ import annotations
  46. import os
  47. import struct
  48. from dataclasses import dataclass, field, asdict
  49. from typing import Optional, Iterator, BinaryIO
  50. HEADER_SIZE = 52
  51. CELL_SIZE = 12
  52. @dataclass
  53. class MapHeader:
  54. """52B 标准 header (基于 7 样本字段分析, 2026-09-23)"""
  55. width: int # 格数 (uint16 LE, offset 0-1)
  56. height: int # 格数 (uint16 LE, offset 2-3)
  57. title: str # 地图标题 (e.g. "Legend of mir", offset 5-17)
  58. title_len: int # 原始 title 字段长度 (offset 4, 13 或 15)
  59. # update 时间戳 (LE uint16, offset 20-27): 6 样本都是 0x3CE9 0x0040 0x3CE9 0x0040
  60. update_date1: int # offset 20-21, LE uint16, 标准 = 0x3CE9 (15593)
  61. update_time1: int # offset 22-23, LE uint16, 标准 = 0x0040 (64)
  62. update_date2: int # offset 24-25, LE uint16, 标准 = 0x3CE9 (与 1 相同, 推测备份)
  63. update_time2: int # offset 26-27, LE uint16, 标准 = 0x0040 (与 1 相同)
  64. # 音乐/环境音 (LE uint16, offset 28-31): 主 BGM + 环境音
  65. music_id: int # offset 28-29, 主背景音乐 ID (0.map=514, 其他=36)
  66. ambient_id: int # offset 30-31, 环境音/战斗音乐 ID (0.map=514, 其他=136=0x88, 推测)
  67. # 光照贴图尺寸 (LE uint16, offset 32-35): 与地图大小有关
  68. light_w: int # offset 32-33, 光照贴图宽
  69. light_h: int # offset 34-35, 光照贴图高
  70. # 编辑器/构建标识 (LE uint32, offset 36-43): 推测为编辑器 build 版本
  71. editor_build: int # offset 36-39, 编辑器 build 版本 (0.map 不同, 推测 per-session)
  72. reserved_40: int # offset 40-43, 恒为 0
  73. # 格式标识 (offset 44-47): 6 个标准 map 恒 0x447E28, 0.map 是 0
  74. format_id: int # offset 44-47, WGS 格式标识 (0x447E28 = 4488744)
  75. reserved_48: int # offset 48-51, 恒为 0
  76. raw_tail: bytes = b"" # 18-51 原始 34B (roundtrip 保真用, 含以上所有字段)
  77. @property
  78. def is_extended(self) -> bool:
  79. """title_len == 15 表示扩展格式 (含 0.map)"""
  80. return self.title_len == 15
  81. def to_dict(self) -> dict:
  82. return {k: v for k, v in asdict(self).items() if k != "raw_tail"}
  83. @dataclass
  84. class MapCell:
  85. """12B 单格数据"""
  86. bg_img: int = 0 # 背景图索引 (0=空)
  87. mid_img: int = 0 # 中景图索引 (0=空)
  88. obj_img: int = 0 # 前景/对象索引 (0=空)
  89. ani_idx: int = 0 # 动画帧索引
  90. door_flag: int = 0 # 门/动画开关 (bit0=1=可走? bit1=1=门?)
  91. light: int = 0 # 光照 (0=全亮 0x80=中等 0xFF=全暗)
  92. area: int = 0 # 区域/门索引
  93. ani_tick: int = 0 # 动画 tick (速度)
  94. @property
  95. def is_walkable(self) -> bool:
  96. """基于 obj_img == 0 简化判断 (obj 有碰撞)"""
  97. return self.obj_img == 0
  98. @property
  99. def is_obstacle(self) -> bool:
  100. """基于 door_flag 和 obj_img"""
  101. return self.obj_img != 0 and (self.door_flag & 0x01) == 0
  102. def pack(self) -> bytes:
  103. """12B 序列化 (与原始文件格式字节级一致)"""
  104. return struct.pack(
  105. "<HHHHBBBB",
  106. self.bg_img & 0xFFFF, self.mid_img & 0xFFFF,
  107. self.obj_img & 0xFFFF, self.ani_idx & 0xFFFF,
  108. self.door_flag & 0xFF, self.light & 0xFF,
  109. self.area & 0xFF, self.ani_tick & 0xFF,
  110. )
  111. def to_dict(self) -> dict:
  112. return asdict(self)
  113. @dataclass
  114. class MapData:
  115. """完整地图数据"""
  116. header: MapHeader
  117. cells: list = field(default_factory=list) # list[MapCell], 长度 = w*h
  118. extra_data: bytes = b"" # 扩展格式 (title_len=15) 的额外区 (原始字节)
  119. extra_areas: list = field(default_factory=list) # 额外区解析: 2B/格 uint16 数组 (tileSceneManager3 优化用)
  120. @property
  121. def width(self) -> int:
  122. return self.header.width
  123. @property
  124. def height(self) -> int:
  125. return self.header.height
  126. def get_cell(self, x: int, y: int) -> MapCell:
  127. """按 (x, y) 坐标取格 (x=col, y=row; x 范围 0..width-1, y 范围 0..height-1)"""
  128. if not (0 <= x < self.header.width and 0 <= y < self.header.height):
  129. raise IndexError(f"({x},{y}) 越界, 地图 {self.header.width}x{self.header.height}")
  130. return self.cells[y * self.header.width + x]
  131. def set_cell(self, x: int, y: int, cell: MapCell) -> None:
  132. if not (0 <= x < self.header.width and 0 <= y < self.header.height):
  133. raise IndexError(f"({x},{y}) 越界, 地图 {self.header.width}x{self.header.height}")
  134. self.cells[y * self.header.width + x] = cell
  135. def iter_walkable(self) -> Iterator[tuple]:
  136. """遍历所有可走格, 返回 (x, y, cell)"""
  137. for y in range(self.header.height):
  138. for x in range(self.header.width):
  139. c = self.get_cell(x, y)
  140. if c.is_walkable:
  141. yield (x, y, c)
  142. def stats(self) -> dict:
  143. """统计: 各类格数量, 用于 sanity check"""
  144. n = len(self.cells)
  145. if n == 0:
  146. return {"total": 0}
  147. walkable = sum(1 for c in self.cells if c.is_walkable)
  148. has_obj = sum(1 for c in self.cells if c.obj_img != 0)
  149. has_mid = sum(1 for c in self.cells if c.mid_img != 0)
  150. has_bg = sum(1 for c in self.cells if c.bg_img != 0)
  151. has_ani = sum(1 for c in self.cells if c.ani_idx != 0)
  152. doors = sum(1 for c in self.cells if c.door_flag != 0)
  153. return {
  154. "total": n,
  155. "walkable": walkable,
  156. "walkable_pct": round(walkable * 100 / n, 2),
  157. "has_bg": has_bg,
  158. "has_mid": has_mid,
  159. "has_obj": has_obj,
  160. "has_ani": has_ani,
  161. "doors": doors,
  162. }
  163. def to_bytes(self) -> bytes:
  164. """序列化回 .map 文件格式 (roundtrip 用)"""
  165. title_bytes = self.header.title.encode("ascii")[:13].ljust(13, b"\x00")
  166. hdr = struct.pack(
  167. "<HHB",
  168. self.header.width & 0xFFFF,
  169. self.header.height & 0xFFFF,
  170. len(self.header.title) & 0xFF,
  171. ) + title_bytes
  172. # 18-51: 用解析时保留的 raw_tail 保真 (date/time/version 等未完全解析字段)
  173. hdr += self.header.raw_tail
  174. assert len(hdr) == HEADER_SIZE, f"header 拼装错误: {len(hdr)}B != {HEADER_SIZE}B"
  175. cells_bytes = b"".join(c.pack() for c in self.cells)
  176. # 优先使用解析后的 extra_areas (2B/格 uint16 数组), fallback 到原始 extra_data
  177. if self.extra_areas and len(self.extra_areas) == len(self.cells):
  178. extra_bytes = struct.pack(f"<{len(self.extra_areas)}H", *self.extra_areas)
  179. else:
  180. extra_bytes = self.extra_data
  181. return hdr + extra_bytes + cells_bytes
  182. def parse_header(buf: bytes) -> MapHeader:
  183. """从至少 52B 数据中解析 header"""
  184. if len(buf) < HEADER_SIZE:
  185. raise ValueError(f"header 至少需要 {HEADER_SIZE}B, 实际={len(buf)}")
  186. width, height, title_len = struct.unpack("<HHB", buf[:5])
  187. title_bytes = buf[5:5 + title_len]
  188. title = title_bytes.split(b"\x00", 1)[0].decode("ascii", errors="replace")
  189. # 18-19: 保留 (恒 0)
  190. # 20-27: LE uint16 × 4 (update date1/time1/date2/time2; 字节 e9 3c 40 00 → 0x3CE9/0x0040)
  191. update_date1, update_time1, update_date2, update_time2 = struct.unpack(
  192. "<HHHH", buf[20:28]
  193. )
  194. # 28-35: LE uint16 × 4 (music_id / ambient_id / light_w / light_h)
  195. music_id, ambient_id, light_w, light_h = struct.unpack("<HHHH", buf[28:36])
  196. # 36-51: LE uint32 × 4 (editor_build / reserved_40 / format_id / reserved_48)
  197. editor_build, reserved_40, format_id, reserved_48 = struct.unpack("<IIII", buf[36:52])
  198. raw_tail = buf[18:HEADER_SIZE] # 保真用
  199. return MapHeader(
  200. width=width, height=height, title=title, title_len=title_len,
  201. update_date1=update_date1, update_time1=update_time1,
  202. update_date2=update_date2, update_time2=update_time2,
  203. music_id=music_id, ambient_id=ambient_id,
  204. light_w=light_w, light_h=light_h,
  205. editor_build=editor_build, reserved_40=reserved_40,
  206. format_id=format_id, reserved_48=reserved_48,
  207. raw_tail=raw_tail,
  208. )
  209. def parse_cell(buf: bytes, offset: int = 0) -> MapCell:
  210. """从 12B 数据解析一个 cell"""
  211. bg, mid, obj, ani, df, light, area, tick = struct.unpack(
  212. "<HHHHBBBB", buf[offset:offset + CELL_SIZE]
  213. )
  214. return MapCell(
  215. bg_img=bg, mid_img=mid, obj_img=obj, ani_idx=ani,
  216. door_flag=df, light=light, area=area, ani_tick=tick,
  217. )
  218. def load(path: str) -> MapData:
  219. """加载 .map 文件"""
  220. with open(path, "rb") as fp:
  221. data = fp.read()
  222. if len(data) < HEADER_SIZE:
  223. raise ValueError(f"{path}: 文件过小 ({len(data)}B), 不是 .map 文件")
  224. header = parse_header(data)
  225. expected_cells = header.width * header.height
  226. cells_bytes = expected_cells * CELL_SIZE
  227. # 扩展格式: 52B header + extra + cells
  228. # 已知 0.map: title_len=15, extra_size = w*h*2 (tileSceneManager3 优化索引表)
  229. if header.is_extended and len(data) > HEADER_SIZE + cells_bytes:
  230. extra_size = len(data) - HEADER_SIZE - cells_bytes
  231. extra = data[HEADER_SIZE:HEADER_SIZE + extra_size]
  232. cells = [parse_cell(data, HEADER_SIZE + extra_size + i * CELL_SIZE) for i in range(expected_cells)]
  233. # 解析 extra 区为 2B/格 uint16 数组 (若大小匹配 w*h*2)
  234. extra_areas = _parse_extra_areas(extra, expected_cells)
  235. return MapData(header=header, cells=cells, extra_data=extra, extra_areas=extra_areas)
  236. else:
  237. if len(data) < HEADER_SIZE + cells_bytes:
  238. raise ValueError(
  239. f"{path}: 文件不完整 (size={len(data)}, need >= {HEADER_SIZE + cells_bytes} for {expected_cells} cells)"
  240. )
  241. cells = [parse_cell(data, HEADER_SIZE + i * CELL_SIZE) for i in range(expected_cells)]
  242. return MapData(header=header, cells=cells, extra_data=b"", extra_areas=[])
  243. def _parse_extra_areas(extra_bytes: bytes, expected_cells: int) -> list:
  244. """
  245. 解析额外区为 uint16 数组
  246. :return: list[int] (长度 expected_cells), 若大小不匹配 w*h*2 则返回原字节列表
  247. """
  248. if len(extra_bytes) != expected_cells * 2:
  249. # 大小不匹配: 返回空列表, 调用方应使用 extra_data
  250. return []
  251. return list(struct.unpack(f"<{expected_cells}H", extra_bytes))
  252. # ============== 已知地图 ID 速查 (来自 gameMapController) ==============
  253. MAP_ID = {
  254. 0: "新人村/等待图",
  255. 3: "比奇省 (盟重?)",
  256. 139: "废矿/小副本",
  257. 11: "d011 (副本?)",
  258. 22: "d022",
  259. 401: "d401",
  260. 601: "d601",
  261. }
  262. if __name__ == "__main__":
  263. import sys
  264. sample_dir = "/Users/zlwu/work/00code/传奇/996传奇盒子资源包/03-游戏客户端/jycq_206923/scene/map"
  265. files = ["0139.map", "0.map", "3.map", "d011.map", "d022.map", "d401.map", "d601.map"]
  266. for f in files:
  267. path = os.path.join(sample_dir, f)
  268. try:
  269. m = load(path)
  270. print(f"=== {f} ===")
  271. print(f" Title: {m.header.title!r} size: {m.header.width}x{m.header.height}")
  272. print(f" Header: music_id={m.header.music_id}, light=({m.header.light_w}x{m.header.light_h})")
  273. print(f" Extended: {m.header.is_extended} extra_data: {len(m.extra_data)}B")
  274. s = m.stats()
  275. print(f" Stats: {s}")
  276. # 显示第一行 5 个 cell
  277. row0 = [m.get_cell(x, 0).to_dict() for x in range(min(5, m.header.width))]
  278. print(f" Row 0: {row0}")
  279. print()
  280. except Exception as e:
  281. print(f"=== {f} === FAIL: {e}")
  282. print()