#!/usr/bin/env python3 """ 996传奇客户端 .map 文件格式解析器 (WGS Mir2 兼容) 来源: jycq_206923/scene/map/ 7 个样本反推 + 公开 WGS Mir2 格式文档交叉验证 二进制调用点: tileSceneManager:LoadMapData / mapData2DTile3 @0x1008xxxx 验证: 7 个样本全部解析成功, 字段值与公开文档一致 (2026-09-23) 文件结构: ┌─────────── 52B 标准 header ───────────┐ │ 0-1 width (uint16, 格数) │ │ 2-3 height (uint16, 格数) │ │ 4 title_len (uint8, 通常 13) │ │ 5-17 title (e.g. "Legend of mir") │ │ 18-19 版本/保留 (0) │ │ 20-21 update_date (BE uint16) │ │ 22-23 update_time (BE uint16) │ │ 24-25 update_date2 (BE uint16) │ │ 26-27 update_time2 (BE uint16) │ │ 28-29 music_id / 区域 (uint16) │ │ 30-31 ??? (uint16) │ │ 32-33 light_width? (uint16) │ │ 34-35 light_height? (uint16) │ │ 36-39 ??? (uint32) │ │ 40-43 ??? (uint32, 0) │ │ 44-47 ??? (uint32) │ │ 48-51 ??? (uint32, 通常 0x80000000) │ └────────────────────────────────────────┘ │ (52B 后) ▼ ┌─────────── w*h × 12B 单格数据 ───────────┐ │ cell = 12B: │ │ 0-1 bg_img (uint16, 背景图索引) │ │ 2-3 mid_img (uint16, 中景图索引) │ │ 4-5 obj_img (uint16, 前景/对象索引) │ │ 6-7 ani_idx (uint16, 动画帧索引) │ │ 8 door_flag (uint8, 门/动画开关) │ │ 9 light (uint8, 光照 0=亮 0x80=暗)│ │ 10 area (uint8, 区域/门索引) │ │ 11 ani_tick (uint8, 动画速度 tick) │ └────────────────────────────────────────────┘ 变体: - title_len=15 (e.g. 0.map "Legend of mir\\r\\n"): 扩展格式 在 52B header 后额外 980000B 数据 (≈ w*h*2 = 700*700*2) 再接 12B/cell 数据 (推测: 区域遮罩/光照索引, 待二次精读) """ from __future__ import annotations import os import struct from dataclasses import dataclass, field, asdict from typing import Optional, Iterator, BinaryIO HEADER_SIZE = 52 CELL_SIZE = 12 @dataclass class MapHeader: """52B 标准 header (基于 7 样本字段分析, 2026-09-23)""" width: int # 格数 (uint16 LE, offset 0-1) height: int # 格数 (uint16 LE, offset 2-3) title: str # 地图标题 (e.g. "Legend of mir", offset 5-17) title_len: int # 原始 title 字段长度 (offset 4, 13 或 15) # update 时间戳 (LE uint16, offset 20-27): 6 样本都是 0x3CE9 0x0040 0x3CE9 0x0040 update_date1: int # offset 20-21, LE uint16, 标准 = 0x3CE9 (15593) update_time1: int # offset 22-23, LE uint16, 标准 = 0x0040 (64) update_date2: int # offset 24-25, LE uint16, 标准 = 0x3CE9 (与 1 相同, 推测备份) update_time2: int # offset 26-27, LE uint16, 标准 = 0x0040 (与 1 相同) # 音乐/环境音 (LE uint16, offset 28-31): 主 BGM + 环境音 music_id: int # offset 28-29, 主背景音乐 ID (0.map=514, 其他=36) ambient_id: int # offset 30-31, 环境音/战斗音乐 ID (0.map=514, 其他=136=0x88, 推测) # 光照贴图尺寸 (LE uint16, offset 32-35): 与地图大小有关 light_w: int # offset 32-33, 光照贴图宽 light_h: int # offset 34-35, 光照贴图高 # 编辑器/构建标识 (LE uint32, offset 36-43): 推测为编辑器 build 版本 editor_build: int # offset 36-39, 编辑器 build 版本 (0.map 不同, 推测 per-session) reserved_40: int # offset 40-43, 恒为 0 # 格式标识 (offset 44-47): 6 个标准 map 恒 0x447E28, 0.map 是 0 format_id: int # offset 44-47, WGS 格式标识 (0x447E28 = 4488744) reserved_48: int # offset 48-51, 恒为 0 raw_tail: bytes = b"" # 18-51 原始 34B (roundtrip 保真用, 含以上所有字段) @property def is_extended(self) -> bool: """title_len == 15 表示扩展格式 (含 0.map)""" return self.title_len == 15 def to_dict(self) -> dict: return {k: v for k, v in asdict(self).items() if k != "raw_tail"} @dataclass class MapCell: """12B 单格数据""" bg_img: int = 0 # 背景图索引 (0=空) mid_img: int = 0 # 中景图索引 (0=空) obj_img: int = 0 # 前景/对象索引 (0=空) ani_idx: int = 0 # 动画帧索引 door_flag: int = 0 # 门/动画开关 (bit0=1=可走? bit1=1=门?) light: int = 0 # 光照 (0=全亮 0x80=中等 0xFF=全暗) area: int = 0 # 区域/门索引 ani_tick: int = 0 # 动画 tick (速度) @property def is_walkable(self) -> bool: """基于 obj_img == 0 简化判断 (obj 有碰撞)""" return self.obj_img == 0 @property def is_obstacle(self) -> bool: """基于 door_flag 和 obj_img""" return self.obj_img != 0 and (self.door_flag & 0x01) == 0 def pack(self) -> bytes: """12B 序列化 (与原始文件格式字节级一致)""" return struct.pack( " dict: return asdict(self) @dataclass class MapData: """完整地图数据""" header: MapHeader cells: list = field(default_factory=list) # list[MapCell], 长度 = w*h extra_data: bytes = b"" # 扩展格式 (title_len=15) 的额外区 (原始字节) extra_areas: list = field(default_factory=list) # 额外区解析: 2B/格 uint16 数组 (tileSceneManager3 优化用) @property def width(self) -> int: return self.header.width @property def height(self) -> int: return self.header.height def get_cell(self, x: int, y: int) -> MapCell: """按 (x, y) 坐标取格 (x=col, y=row; x 范围 0..width-1, y 范围 0..height-1)""" if not (0 <= x < self.header.width and 0 <= y < self.header.height): raise IndexError(f"({x},{y}) 越界, 地图 {self.header.width}x{self.header.height}") return self.cells[y * self.header.width + x] def set_cell(self, x: int, y: int, cell: MapCell) -> None: if not (0 <= x < self.header.width and 0 <= y < self.header.height): raise IndexError(f"({x},{y}) 越界, 地图 {self.header.width}x{self.header.height}") self.cells[y * self.header.width + x] = cell def iter_walkable(self) -> Iterator[tuple]: """遍历所有可走格, 返回 (x, y, cell)""" for y in range(self.header.height): for x in range(self.header.width): c = self.get_cell(x, y) if c.is_walkable: yield (x, y, c) def stats(self) -> dict: """统计: 各类格数量, 用于 sanity check""" n = len(self.cells) if n == 0: return {"total": 0} walkable = sum(1 for c in self.cells if c.is_walkable) has_obj = sum(1 for c in self.cells if c.obj_img != 0) has_mid = sum(1 for c in self.cells if c.mid_img != 0) has_bg = sum(1 for c in self.cells if c.bg_img != 0) has_ani = sum(1 for c in self.cells if c.ani_idx != 0) doors = sum(1 for c in self.cells if c.door_flag != 0) return { "total": n, "walkable": walkable, "walkable_pct": round(walkable * 100 / n, 2), "has_bg": has_bg, "has_mid": has_mid, "has_obj": has_obj, "has_ani": has_ani, "doors": doors, } def to_bytes(self) -> bytes: """序列化回 .map 文件格式 (roundtrip 用)""" title_bytes = self.header.title.encode("ascii")[:13].ljust(13, b"\x00") hdr = struct.pack( " MapHeader: """从至少 52B 数据中解析 header""" if len(buf) < HEADER_SIZE: raise ValueError(f"header 至少需要 {HEADER_SIZE}B, 实际={len(buf)}") width, height, title_len = struct.unpack(" MapCell: """从 12B 数据解析一个 cell""" bg, mid, obj, ani, df, light, area, tick = struct.unpack( " MapData: """加载 .map 文件""" with open(path, "rb") as fp: data = fp.read() if len(data) < HEADER_SIZE: raise ValueError(f"{path}: 文件过小 ({len(data)}B), 不是 .map 文件") header = parse_header(data) expected_cells = header.width * header.height cells_bytes = expected_cells * CELL_SIZE # 扩展格式: 52B header + extra + cells # 已知 0.map: title_len=15, extra_size = w*h*2 (tileSceneManager3 优化索引表) if header.is_extended and len(data) > HEADER_SIZE + cells_bytes: extra_size = len(data) - HEADER_SIZE - cells_bytes extra = data[HEADER_SIZE:HEADER_SIZE + extra_size] cells = [parse_cell(data, HEADER_SIZE + extra_size + i * CELL_SIZE) for i in range(expected_cells)] # 解析 extra 区为 2B/格 uint16 数组 (若大小匹配 w*h*2) extra_areas = _parse_extra_areas(extra, expected_cells) return MapData(header=header, cells=cells, extra_data=extra, extra_areas=extra_areas) else: if len(data) < HEADER_SIZE + cells_bytes: raise ValueError( f"{path}: 文件不完整 (size={len(data)}, need >= {HEADER_SIZE + cells_bytes} for {expected_cells} cells)" ) cells = [parse_cell(data, HEADER_SIZE + i * CELL_SIZE) for i in range(expected_cells)] return MapData(header=header, cells=cells, extra_data=b"", extra_areas=[]) def _parse_extra_areas(extra_bytes: bytes, expected_cells: int) -> list: """ 解析额外区为 uint16 数组 :return: list[int] (长度 expected_cells), 若大小不匹配 w*h*2 则返回原字节列表 """ if len(extra_bytes) != expected_cells * 2: # 大小不匹配: 返回空列表, 调用方应使用 extra_data return [] return list(struct.unpack(f"<{expected_cells}H", extra_bytes)) # ============== 已知地图 ID 速查 (来自 gameMapController) ============== MAP_ID = { 0: "新人村/等待图", 3: "比奇省 (盟重?)", 139: "废矿/小副本", 11: "d011 (副本?)", 22: "d022", 401: "d401", 601: "d601", } if __name__ == "__main__": import sys sample_dir = "/Users/zlwu/work/00code/传奇/996传奇盒子资源包/03-游戏客户端/jycq_206923/scene/map" files = ["0139.map", "0.map", "3.map", "d011.map", "d022.map", "d401.map", "d601.map"] for f in files: path = os.path.join(sample_dir, f) try: m = load(path) print(f"=== {f} ===") print(f" Title: {m.header.title!r} size: {m.header.width}x{m.header.height}") print(f" Header: music_id={m.header.music_id}, light=({m.header.light_w}x{m.header.light_h})") print(f" Extended: {m.header.is_extended} extra_data: {len(m.extra_data)}B") s = m.stats() print(f" Stats: {s}") # 显示第一行 5 个 cell row0 = [m.get_cell(x, 0).to_dict() for x in range(min(5, m.header.width))] print(f" Row 0: {row0}") print() except Exception as e: print(f"=== {f} === FAIL: {e}") print()