| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317 |
- #!/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(
- "<HHHHBBBB",
- self.bg_img & 0xFFFF, self.mid_img & 0xFFFF,
- self.obj_img & 0xFFFF, self.ani_idx & 0xFFFF,
- self.door_flag & 0xFF, self.light & 0xFF,
- self.area & 0xFF, self.ani_tick & 0xFF,
- )
- def to_dict(self) -> 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(
- "<HHB",
- self.header.width & 0xFFFF,
- self.header.height & 0xFFFF,
- len(self.header.title) & 0xFF,
- ) + title_bytes
- # 18-51: 用解析时保留的 raw_tail 保真 (date/time/version 等未完全解析字段)
- hdr += self.header.raw_tail
- assert len(hdr) == HEADER_SIZE, f"header 拼装错误: {len(hdr)}B != {HEADER_SIZE}B"
- cells_bytes = b"".join(c.pack() for c in self.cells)
- # 优先使用解析后的 extra_areas (2B/格 uint16 数组), fallback 到原始 extra_data
- if self.extra_areas and len(self.extra_areas) == len(self.cells):
- extra_bytes = struct.pack(f"<{len(self.extra_areas)}H", *self.extra_areas)
- else:
- extra_bytes = self.extra_data
- return hdr + extra_bytes + cells_bytes
- def parse_header(buf: bytes) -> MapHeader:
- """从至少 52B 数据中解析 header"""
- if len(buf) < HEADER_SIZE:
- raise ValueError(f"header 至少需要 {HEADER_SIZE}B, 实际={len(buf)}")
- width, height, title_len = struct.unpack("<HHB", buf[:5])
- title_bytes = buf[5:5 + title_len]
- title = title_bytes.split(b"\x00", 1)[0].decode("ascii", errors="replace")
- # 18-19: 保留 (恒 0)
- # 20-27: LE uint16 × 4 (update date1/time1/date2/time2; 字节 e9 3c 40 00 → 0x3CE9/0x0040)
- update_date1, update_time1, update_date2, update_time2 = struct.unpack(
- "<HHHH", buf[20:28]
- )
- # 28-35: LE uint16 × 4 (music_id / ambient_id / light_w / light_h)
- music_id, ambient_id, light_w, light_h = struct.unpack("<HHHH", buf[28:36])
- # 36-51: LE uint32 × 4 (editor_build / reserved_40 / format_id / reserved_48)
- editor_build, reserved_40, format_id, reserved_48 = struct.unpack("<IIII", buf[36:52])
- raw_tail = buf[18:HEADER_SIZE] # 保真用
- return MapHeader(
- width=width, height=height, title=title, title_len=title_len,
- update_date1=update_date1, update_time1=update_time1,
- update_date2=update_date2, update_time2=update_time2,
- music_id=music_id, ambient_id=ambient_id,
- light_w=light_w, light_h=light_h,
- editor_build=editor_build, reserved_40=reserved_40,
- format_id=format_id, reserved_48=reserved_48,
- raw_tail=raw_tail,
- )
- def parse_cell(buf: bytes, offset: int = 0) -> MapCell:
- """从 12B 数据解析一个 cell"""
- bg, mid, obj, ani, df, light, area, tick = struct.unpack(
- "<HHHHBBBB", buf[offset:offset + CELL_SIZE]
- )
- return MapCell(
- bg_img=bg, mid_img=mid, obj_img=obj, ani_idx=ani,
- door_flag=df, light=light, area=area, ani_tick=tick,
- )
- def load(path: str) -> 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()
|