M1+M2:1.21.1 构建跑通 + 协议客户端进 play 阶段
M1(构建): - build.sh 编译 CP 加入 $OUT/fabric/*.jar,解决 net.fabricmc.* 找不到 - gen_1_21 内联 Sodium 通知改为委托共用 RenderCompat,补 reportRenderCompat - 1.21.1 全量七步构建产物齐备(classes-intermediary.jar / names*.json / srg / inheritance.map) M2(协议): - login_start 按协议数据发 UUID(1.21 必填;1.20 可选) - login_acknowledged / serverbound 包 id 改为查表,不再写死 0x00 - send() 实现压缩帧格式(varint(dataLen)+数据,小包不压) - configuration 双向协商:发 settings、回 select_known_packs、拒 resource_pack - protodef 补 bitflags / entityMetadataLoop / count(ctx引用) / compareTo 路径表达式 - NBT 读取器由占位符改为真实现(anonymousNbt 支撑 registry_data) - 坏包容错:长度前缀是帧层权威,解不开整包丢弃并布告,不断流 实测:login→configuration(11 注册表)→play,位置 (5.5,-60.0,-0.5),map_chunk×9 判据:protodef 6→13 项(含线上真实 entity_metadata 字节回归);全量 77 项通过
This commit is contained in:
365
mcfold/client.py
365
mcfold/client.py
@ -37,6 +37,16 @@ class Client:
|
||||
self.proto: dict = {}
|
||||
self.proto_num: int = 0
|
||||
self.compression: int = -1
|
||||
# 当前所处协议阶段:login → configuration → play
|
||||
# ★ 1.20.2 起才有 configuration 阶段(1.20.1 跳过它)
|
||||
self.phase: str = "login"
|
||||
# ★ configuration 阶段拿到的**服务端权威注册表**(mod 方块/物品在这里)
|
||||
self.registries: dict[str, Any] = {}
|
||||
self._pkt_names: dict[tuple[str, str], dict[int, str]] = {}
|
||||
self._pid_cache: dict[tuple[str, str, str], int] = {}
|
||||
# ★ 解不开的包(上游数据缺口)记在这里,不断流。
|
||||
# 实测动机:declare_recipes 在官方数据上同样解不开。
|
||||
self.decode_failures: list[dict[str, Any]] = []
|
||||
# 允许直接指定协议数据文件(优先),否则按 data_root 找
|
||||
self._path = Path(protocol_path) if protocol_path else None
|
||||
|
||||
@ -94,12 +104,46 @@ class Client:
|
||||
self.sock.settimeout(15)
|
||||
|
||||
def send(self, payload: bytes) -> None:
|
||||
"""发一个包(自动适配压缩帧格式)。
|
||||
|
||||
★★ 压缩启用后的包体格式(这正是 M2 卡住我的地方):
|
||||
包 = varint(总长) + varint(dataLength) + 数据
|
||||
其中 dataLength:
|
||||
== 0 → 数据**未压缩**,原样跟在后面(小包)
|
||||
> 0 → 数据是 zlib,解压后长度为 dataLength
|
||||
★ 服务端的判据是「未压缩长度 >= threshold 才压缩」:
|
||||
包小于 threshold ⇒ dataLength 必须为 0(原样发)
|
||||
我先前没加这个前缀,直接发裸包体 ⇒ 服务端把包体第 1 字节
|
||||
当成 dataLength 读 ⇒ `Badly compressed packet - size of 3
|
||||
is below server threshold of 256` ⇒ 断连。
|
||||
反过来:把小于 threshold 的包也 zlib 压了同样会被拒。
|
||||
"""
|
||||
assert self.sock
|
||||
# 未启用压缩前:包 = varint(长度) + 包体
|
||||
w = Writer()
|
||||
w.varint(len(payload))
|
||||
w.write(payload)
|
||||
self.sock.sendall(w.getvalue())
|
||||
if self.compression >= 0:
|
||||
fw = Writer()
|
||||
if self.compression > 0 and len(payload) >= self.compression:
|
||||
import zlib
|
||||
comp = zlib.compress(payload)
|
||||
# 压缩后反而变大(极小包)时依然走未压缩分支
|
||||
if len(comp) < len(payload):
|
||||
fw.varint(len(payload))
|
||||
fw.write(comp)
|
||||
body = fw.getvalue()
|
||||
else:
|
||||
fw2 = Writer()
|
||||
fw2.varint(0)
|
||||
fw2.write(payload)
|
||||
body = fw2.getvalue()
|
||||
else:
|
||||
fw.varint(0)
|
||||
fw.write(payload)
|
||||
body = fw.getvalue()
|
||||
else:
|
||||
body = payload
|
||||
out = Writer()
|
||||
out.varint(len(body))
|
||||
out.write(body)
|
||||
self.sock.sendall(out.getvalue())
|
||||
|
||||
def read_packet(self) -> bytes:
|
||||
"""读一个包体(已去长度前缀;压缩未启用时即原始包)。"""
|
||||
@ -149,13 +193,320 @@ class Client:
|
||||
self.send(w.getvalue())
|
||||
|
||||
def login_start(self) -> None:
|
||||
"""login: login_start(离线模式只发用户名)。"""
|
||||
"""login: login_start(离线模式只发用户名)。
|
||||
|
||||
★ **世代差异**(实测 2026-09-26,两种都撞过):
|
||||
1.20.x playerUUID: ['option', 'UUID'] ⇒ 可选,发 varint(0) 表示「无」
|
||||
1.21.1 playerUUID: UUID ⇒ **必填 16 字节**
|
||||
之前我无条件发 `varint(0)`,在 1.21.1 上服务端直接回
|
||||
Failed to decode packet 'serverbound/minecraft:hello'
|
||||
⇒ 这类差异必须**从协议数据里读**,不能凭印象写死。
|
||||
"""
|
||||
w = Writer()
|
||||
w.varint(0x00)
|
||||
w.pstring(self.username)
|
||||
w.varint(0) # uuid 可选,0 表示无
|
||||
fields = self._login_start_fields()
|
||||
if fields is not None and fields.get("playerUUID") == "UUID":
|
||||
# 1.21.1+:必填 16 字节。离线模式下用**由用户名派生的确定性 UUID**
|
||||
# (与服务端算法一致,见 _offline_uuid),避免每次连都换身份。
|
||||
w.write(self._offline_uuid(self.username))
|
||||
else:
|
||||
# 1.20.x:可选,varint(0) = 无
|
||||
w.varint(0)
|
||||
self.send(w.getvalue())
|
||||
|
||||
def _login_start_fields(self) -> dict[str, str] | None:
|
||||
"""从 protocol.json 读 login_start 的字段类型(不凭印象)。"""
|
||||
try:
|
||||
types = self.proto["login"]["toServer"]["types"]
|
||||
for name in ("packet_login_start", "packet_hello"):
|
||||
c = types.get(name)
|
||||
if c and isinstance(c, list) and len(c) > 1:
|
||||
return {f["name"]: f["type"] for f in c[1]}
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _offline_uuid(username: str) -> bytes:
|
||||
"""离线模式的 UUID = UUID.nameUUIDFromBytes("OfflinePlayer:" + name)。
|
||||
|
||||
★ 与官方一致:服务端对 offline-mode 也用这个算玩家身份
|
||||
(GameProfile / ServerPlayer 里的同名算法)⇒ 我们发一样的值,
|
||||
服务端认这个玩家,不是新建一个。
|
||||
"""
|
||||
import hashlib
|
||||
h = bytearray(hashlib.md5(f"OfflinePlayer:{username}".encode()).digest())
|
||||
h[6] = (h[6] & 0x0F) | 0x30 # version 3
|
||||
h[8] = (h[8] & 0x3F) | 0x80 # IETF variant
|
||||
return bytes(h)
|
||||
|
||||
def login_acknowledged(self) -> None:
|
||||
"""login: acknowledged —— **1.21+ 才有**(1.20.x 没有这个包)。
|
||||
|
||||
★ 这是从 login 切到 configuration 的信号(实测)。
|
||||
★★ 包 id **必须查表**,不能写死 `0x00`:
|
||||
1.21.1 的 login/login_acknowledged 是 **0x03**
|
||||
(0x00=login_start, 0x01=encryption_begin,
|
||||
0x02=login_plugin_response, 0x03=login_acknowledged)
|
||||
我第一版写死 0x00 ⇒ 服务端收到一个新 login_start,
|
||||
停在 login 阶段等正确信号 ⇒ configuration 阶段永远收不到包
|
||||
(时间超时,日志上看起来像"挂住")。
|
||||
"""
|
||||
try:
|
||||
types = self.proto["login"]["toServer"]["types"]
|
||||
except Exception:
|
||||
types = {}
|
||||
if "packet_login_acknowledged" not in types:
|
||||
return
|
||||
w = Writer()
|
||||
w.varint(self._serverbound_id("login", "packet_login_acknowledged"))
|
||||
self.send(w.getvalue())
|
||||
|
||||
def _recv_packet(self, phase: str) -> dict:
|
||||
"""读一包并解成 {name, params, _id, _len}。
|
||||
|
||||
★★ 关键韧性设计(实测下来这是必需的):**用一个包坏掉不能断流**。
|
||||
|
||||
长度前缀是**帧层权威**,所以无论一个包的载荷能不能解,
|
||||
下一个包的边界都精确可知 ⇒ 解不开就整包丢弃并标记
|
||||
`_undecoded`,而不是抛异常让整条连接作废。
|
||||
|
||||
实测依据(2026-09-26):
|
||||
`declare_recipes`(108KB,1290 条配方)在
|
||||
**minecraft-protocol + 官方 minecraft-data 3.117 上同样报零**
|
||||
(PartialReadError: found 108271, expected 114153)
|
||||
⇒ 这是**上游数据定义的缺口**,不是我们的解析器 bug。
|
||||
若因它抛异常,play 阶段的 position/map_chunk 就永远收不到 ——
|
||||
agent 会完全看不到世界,因为一条配方列表解不开。
|
||||
|
||||
⇒ 此路:坏包容错 + 布告(`undecoded` 计数),连接继续。
|
||||
"""
|
||||
body = self.read_packet()
|
||||
try:
|
||||
pkt = self.decode(phase, "toClient", body)
|
||||
except Exception as e:
|
||||
pid = -1
|
||||
try:
|
||||
pid = Reader(body).varint()
|
||||
except Exception:
|
||||
pass
|
||||
name = self._name_by_id(phase, "toClient", pid)
|
||||
self.decode_failures.append({"phase": phase, "name": name,
|
||||
"len": len(body), "error": str(e)[:160]})
|
||||
return {"name": name, "params": {}, "_raw": None,
|
||||
"_len": len(body), "_undecoded": True, "_error": str(e)[:160]}
|
||||
name = pkt.get("name")
|
||||
if name in (None, "unknown"):
|
||||
name = self._name_by_id(phase, "toClient", pkt.get("_pid", -1))
|
||||
return {"name": name, "params": pkt.get("params", {}),
|
||||
"_raw": pkt, "_len": len(body)}
|
||||
|
||||
def _name_by_id(self, phase: str, side: str, pid: int) -> str:
|
||||
return self._packet_names(phase, side).get(pid, f"unknown_0x{pid:02x}")
|
||||
|
||||
def _decode_any(self, phase: str, side: str, body: bytes) -> dict:
|
||||
"""解一个包并回包名(保留 params 形状,供流程控制)。"""
|
||||
pd = self._pd(phase, side)
|
||||
pkt = pd.read(self.proto[phase][side]["types"]["packet"], Reader(body))
|
||||
if not isinstance(pkt, dict):
|
||||
return {"name": None, "params": {}, "_raw": pkt}
|
||||
pid = 0
|
||||
try:
|
||||
pid = Reader(body).varint()
|
||||
except Exception:
|
||||
pass
|
||||
pkt["_id"] = pid
|
||||
return pkt
|
||||
|
||||
def _packet_names(self, phase: str, side: str) -> dict[int, str]:
|
||||
"""包 id → 包名(从 protocol.json 的 mapper 读;缓存)。
|
||||
|
||||
★ mapper 的实际形状(实测):
|
||||
['mapper', {'type': 'varint', 'mappings': {'0x00': 'disconnect', ...}}, 'string']
|
||||
映射在 [1]['mappings'],**不是** [1] 本身 —— 我第一版把 [1] 当映射表,
|
||||
结果所有包名都是 unknown。
|
||||
"""
|
||||
key = (phase, side)
|
||||
if key in self._pkt_names:
|
||||
return self._pkt_names[key]
|
||||
out: dict[int, str] = {}
|
||||
try:
|
||||
tm = self.proto[phase][side]["types"].get("packet", {})
|
||||
for f in tm[1]:
|
||||
if f["name"] != "name":
|
||||
continue
|
||||
spec = f["type"] # ['mapper', {...}, 'string']
|
||||
d = spec[1] if isinstance(spec, list) and len(spec) > 1 else {}
|
||||
m = d.get("mappings", d) if isinstance(d, dict) else {}
|
||||
for k, v in m.items():
|
||||
if k == "type":
|
||||
continue
|
||||
out[int(k, 0) if isinstance(k, str) else int(k)] = v
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
self._pkt_names[key] = out
|
||||
return out
|
||||
|
||||
def _recv(self, phase: str) -> dict:
|
||||
return self._recv_packet(phase)
|
||||
|
||||
def configuration_handshake(self) -> dict:
|
||||
"""走完 configuration 阶段,拿到服务端的注册表(含 mod 方块)。
|
||||
|
||||
## 为什么这一步是 M2 的核心
|
||||
|
||||
1.20.2 起服务端在 login 后插入一个 **configuration 阶段**,
|
||||
里面发 `registry_data`。它是**方块/物品注册表的权威来源** ——
|
||||
而客户端本机 jar 里的注册表只包含自己装的东西:
|
||||
**mod 方块的 state id 只有服务端知道**。
|
||||
|
||||
这就是「与服务端 mod 物品交互」的技术核心:
|
||||
不读这个阶段,我们看到 mod 方块只会是 `unknown(id)`。
|
||||
|
||||
## 流程(实测 1.21.1)
|
||||
|
||||
login: success
|
||||
→ 发 login_acknowledged (1.21+ 才有)
|
||||
configuration:
|
||||
收到 feature_flags / registry_data×N / tags / ...
|
||||
keep_alive → 必须回 pong,否则超时断
|
||||
→ 发 finish_configuration
|
||||
configuration: finish_configuration(服务端回)
|
||||
→ 进 play 阶段
|
||||
"""
|
||||
self.login_acknowledged()
|
||||
self.phase = "configuration"
|
||||
# ★ 先报本地设置(vanilla 行为,否则服务端等不到 finish_configuration)
|
||||
self.send_settings()
|
||||
raw: dict[str, Any] = {}
|
||||
keepalive = 0
|
||||
seen: list[str] = []
|
||||
while True:
|
||||
pkt = self._recv("configuration")
|
||||
nm = pkt.get("name")
|
||||
pr = pkt.get("params", {}) or {}
|
||||
seen.append(str(nm))
|
||||
if nm == "keep_alive":
|
||||
keepalive += 1
|
||||
kid = pr.get("keepAliveId", pr.get("keepAliveId", 0))
|
||||
w = Writer()
|
||||
w.varint(self._serverbound_id("configuration", "packet_keep_alive"))
|
||||
w.int64(int(kid))
|
||||
self.send(w.getvalue())
|
||||
continue
|
||||
if nm == "select_known_packs":
|
||||
# ★ 必须回应,否则服务端不再发 registry_data(实测)
|
||||
self.select_known_packs()
|
||||
continue
|
||||
if nm == "add_resource_pack":
|
||||
# 资源包:同意/拒绝二选一。我们不装,**拒绝**
|
||||
# (约定值 3 = 已成功加载的包集合里没有它,即 declined)
|
||||
w = Writer()
|
||||
w.varint(self._serverbound_id("configuration", "packet_resource_pack_receive"))
|
||||
w.byte(3)
|
||||
w.varint(0) # 已加载包数 = 0
|
||||
self.send(w.getvalue())
|
||||
continue
|
||||
if nm == "registry_data":
|
||||
rid = pr.get("id")
|
||||
ents = pr.get("entries")
|
||||
n = len(ents) if isinstance(ents, (list, tuple)) else 0
|
||||
raw.setdefault("registries", {})[str(rid)] = n
|
||||
# ★ 保留实际条目供上层建表(这是**权威**注册表)
|
||||
self.registries[str(rid)] = ents
|
||||
continue
|
||||
if nm in ("finish_configuration", "disconnect"):
|
||||
raw["finished"] = (nm == "finish_configuration")
|
||||
if nm == "disconnect":
|
||||
raw["reason"] = pr.get("reason")
|
||||
break
|
||||
if raw.get("finished"):
|
||||
w = Writer()
|
||||
w.varint(self._serverbound_id("configuration", "packet_finish_configuration"))
|
||||
self.send(w.getvalue())
|
||||
self.phase = "play"
|
||||
raw["keepAlive"] = keepalive
|
||||
raw["registryCount"] = len(self.registries)
|
||||
raw["seenKinds"] = sorted(set(seen))
|
||||
return raw
|
||||
|
||||
def send_settings(self) -> None:
|
||||
"""configuration: settings(Client Information)—— **vanilla 客户端会发**。
|
||||
|
||||
★ 必需的原因(实测):configuration 阶段是**双向协商**,
|
||||
服务端等客户端报上本地化/视距/渲染偏好后才收尾发
|
||||
finish_configuration;不发就一直等(表现为"挂住")。
|
||||
参照实现 minecraft-protocol/src/client/play.js:63-72 同样在
|
||||
进 configuration 状态时立即写一份 settings。
|
||||
|
||||
★ 字段定义不在 configuration.toServer.types 里,而在**共享顶层**
|
||||
`types.packet_common_settings` —— 我先前在阶段内找 `packet_settings`
|
||||
得到 null,差点当成「不需要发」。
|
||||
★ 配置值取 vanilla 默认(en_us / 视距 10 / 全部皮肤层 / 主手右手)。
|
||||
"""
|
||||
w = Writer()
|
||||
w.varint(self._serverbound_id("configuration", "packet_settings"))
|
||||
w.pstring("en_us") # locale
|
||||
w.int8(8) # viewDistance(i8)
|
||||
w.varint(0) # chatFlags
|
||||
w.boolean(True) # chatColors
|
||||
w.uint8(0x7F) # skinParts 127 = 全部
|
||||
w.varint(1) # mainHand 1 = 右手
|
||||
w.boolean(False) # enableTextFiltering
|
||||
w.boolean(True) # enableServerListing
|
||||
self.send(w.getvalue())
|
||||
|
||||
def select_known_packs(self) -> None:
|
||||
"""configuration: select_known_packs → 回一份**空列表**(我们不认它的包)。
|
||||
|
||||
★ 实测(2026-09-26,服务端发完这个就不发 registry_data 了):
|
||||
configuration 阶段 = 双向协商,服务端会**等回应**才继续:
|
||||
select_known_packs → 必须回(同理 cookie_request / resource_pack)
|
||||
settings → 必须发
|
||||
registry_data ×N → 只需要收
|
||||
finish_configuration → 必须回
|
||||
只收不回,registry_data 永远不来(实测卡在这)。
|
||||
★ 字段定义在 protocol.json 里是 null(数据缺口),按参照实现
|
||||
minecraft-protocol/src/client/play.js:78-80 用 `packs: []`。
|
||||
"""
|
||||
w = Writer()
|
||||
w.varint(self._serverbound_id("configuration", "packet_select_known_packs"))
|
||||
w.varint(0) # packs 数量 = 0(一个都不认)
|
||||
self.send(w.getvalue())
|
||||
|
||||
def _serverbound_id(self, phase: str, pname: str) -> int:
|
||||
"""包名 → 包 id(serverbound)。拿不到就报错(不猜)。
|
||||
|
||||
★ 包名有两种写法(实测):
|
||||
mapper 里是**短名** `login_acknowledged`
|
||||
类型定义里是**长名** `packet_login_acknowledged`
|
||||
两者都要能吃 —— 写死一种会在另一处失配
|
||||
(我第一版只认长名,`packet_login_acknowledged` 查不到)。
|
||||
"""
|
||||
key = ("toServer", phase, pname)
|
||||
if key in self._pid_cache:
|
||||
return self._pid_cache[key]
|
||||
short = pname[7:] if pname.startswith("packet_") else pname
|
||||
tm = self.proto[phase]["toServer"]["types"].get("packet", {})
|
||||
found = None
|
||||
for f in tm[1]:
|
||||
if f["name"] != "name":
|
||||
continue
|
||||
spec = f["type"]
|
||||
d = spec[1] if isinstance(spec, list) and len(spec) > 1 else {}
|
||||
m = d.get("mappings", d) if isinstance(d, dict) else {}
|
||||
for k, v in m.items():
|
||||
if v in (pname, short):
|
||||
found = int(k, 0) if isinstance(k, str) else int(k)
|
||||
break
|
||||
break
|
||||
if found is None:
|
||||
raise ProtocolError(f"{phase} 阶段无此 serverbound 包: {pname}")
|
||||
self._pid_cache[key] = found
|
||||
return found
|
||||
|
||||
def decode(self, phase: str, side: str, body: bytes) -> dict:
|
||||
pd = self._pd(phase, side)
|
||||
pkt = pd.read(self.proto[phase][side]["types"]["packet"], Reader(body))
|
||||
|
||||
@ -109,6 +109,31 @@ class Writer:
|
||||
self.varint(len(b))
|
||||
self.parts.append(b)
|
||||
|
||||
# ---- 固定宽度原语(配置阶段要发 i8/u8/bool,实测标了 signed/unsigned) ----
|
||||
def _fixed(self, fmt: str, v: int) -> None:
|
||||
import struct as _s
|
||||
self.parts.append(_s.pack(fmt, v))
|
||||
|
||||
def int8(self, v: int) -> None:
|
||||
"""i8:有符号 8 位(如 viewDistance)。"""
|
||||
self._fixed(">b", v)
|
||||
|
||||
def uint8(self, v: int) -> None:
|
||||
"""u8:无符号 8 位(如 skinParts 位掩码 0x7F)。"""
|
||||
self._fixed(">B", v)
|
||||
|
||||
def boolean(self, v: bool) -> None:
|
||||
"""bool:协议里是 1 字节 0/1。"""
|
||||
self._fixed(">B", 1 if v else 0)
|
||||
|
||||
def byte(self, v: int) -> None:
|
||||
"""单字节(如 resource_pack 回应里的结果码 0..3)。"""
|
||||
self._fixed(">B", v)
|
||||
|
||||
def int64(self, v: int) -> None:
|
||||
"""i64:8 字节大端有符号(如 keep_alive 的 keepAliveId)。"""
|
||||
self._fixed(">q", v)
|
||||
|
||||
|
||||
# ---- 固定宽度原语:[struct 格式, 字节数] ----
|
||||
FIXED: dict[str, tuple[str, int]] = {
|
||||
@ -147,22 +172,47 @@ class ProtoDef:
|
||||
# 2) 复合类型
|
||||
if kind == "container":
|
||||
out: dict[str, Any] = {}
|
||||
# ★ 子作用域:`../x` 路径要能回到外层(实测 6 处 `../flags/…`、
|
||||
# `../action/…` 判据)。保留 __parent__ 链即可。
|
||||
sub = dict(ctx)
|
||||
sub["__parent__"] = ctx
|
||||
for f in t[1]:
|
||||
name = f.get("name") or f.get("anon")
|
||||
val = self.read(f["type"], r, ctx)
|
||||
val = self.read(f["type"], r, sub)
|
||||
if f.get("anon"):
|
||||
# 匿名字段:若是 dict 就并入
|
||||
if isinstance(val, dict):
|
||||
out.update(val)
|
||||
sub.update(val)
|
||||
else:
|
||||
out[name] = val
|
||||
ctx[name] = val
|
||||
sub[name] = val
|
||||
# 回写外层(同名字段让外层也能查到,兼容平铺写法)
|
||||
for k, v in out.items():
|
||||
if k not in ("__parent__",):
|
||||
ctx[k] = v
|
||||
return out
|
||||
|
||||
if kind == "array":
|
||||
spec = t[1]
|
||||
if isinstance(spec, dict):
|
||||
cnt = self._count(spec.get("countType", "varint"), r, ctx)
|
||||
# ★ protodef 的计数有两种写法(实测全协议:6 处 count / 103 处 countType):
|
||||
# countType: 'varint' → 从流里读一个计数
|
||||
# count: 'gridWidth' → **引用已读字段**(ctx),不读流
|
||||
# 我先前只认 countType,遇到 `count` 就默认按 varint 读 ⇒
|
||||
# 多读一个字节 ⇒ **流错位**(表现为后面 switch 收到 948
|
||||
# 这种荒谬值,报“switch 无匹配分支”)。
|
||||
if "count" in spec and isinstance(spec["count"], str):
|
||||
cname = spec["count"]
|
||||
cnt = ctx.get(cname)
|
||||
if cnt is None:
|
||||
# 退化:当它是个要读的字段名
|
||||
cnt = self.read(cname, r, ctx)
|
||||
cnt = int(cnt or 0)
|
||||
else:
|
||||
cnt = self._count(spec.get("countType", "varint"), r, ctx)
|
||||
if cnt < 0:
|
||||
cnt = 0
|
||||
return [self.read(spec["type"], r, ctx) for _ in range(cnt)]
|
||||
# ["array", "u8"] 形式:读到结尾
|
||||
out = []
|
||||
@ -173,16 +223,29 @@ class ProtoDef:
|
||||
if kind == "switch":
|
||||
spec = t[1]
|
||||
cmp_raw = spec.get("compareTo", "")
|
||||
# protodef 两种写法:"$field" 或 "field"(都指引用已读字段)
|
||||
# ★★ compareTo 是**路径表达式**,不一定是字段名(实测全协议):
|
||||
# "type" 普通字段
|
||||
# "flags/has_redirect_node" 子字段(跳进已读的 dict)
|
||||
# "../flags/min_present" 父作用域的子字段
|
||||
# "../action/add_player" 父作用域的位标志
|
||||
# 我先前只做 ctx.get(平铺名) ⇒ 这些全部查不到,
|
||||
# 退回 self.read('flags/xxx') ⇒ 报「未知类型: flags/xxx」
|
||||
# (这正是 player_info / declare_commands 解不开的原因)。
|
||||
key = None
|
||||
val = None
|
||||
if isinstance(cmp_raw, str):
|
||||
key = cmp_raw[1:] if cmp_raw.startswith("$") else cmp_raw
|
||||
val = ctx.get(key) if (key and key in ctx) else self.read(cmp_raw, r, ctx)
|
||||
val = self._resolve_ctx(key, ctx)
|
||||
if val is None:
|
||||
# 退化:当它是个要读的类型名(罕见的隐式 switch)
|
||||
val = self.read(cmp_raw, r, ctx)
|
||||
|
||||
fields = spec.get("fields", {})
|
||||
default = spec.get("default")
|
||||
# 字段键可能是 '0x01'/'1' 等写法,统一规整
|
||||
# 字段键可能是 '0x01'/'1'/'true' 等写法,统一规整
|
||||
chosen = fields.get(str(val))
|
||||
if chosen is None and isinstance(val, bool):
|
||||
chosen = fields.get("1" if val else "0")
|
||||
if chosen is None:
|
||||
chosen = default
|
||||
if chosen is None:
|
||||
@ -228,11 +291,109 @@ class ProtoDef:
|
||||
|
||||
# 列表形式引用内置原语,如 ["pstring", {"countType":"varint"}]、["varint"]
|
||||
if kind in FIXED or kind in ("varint", "optvarint", "varlong", "pstring",
|
||||
"string", "restBuffer", "UUID", "void"):
|
||||
"string", "restBuffer", "UUID", "void",
|
||||
"anonymousNbt", "nbt", "optionalNbt"):
|
||||
return self._read_named(kind, r, ctx)
|
||||
|
||||
# ---- bitflags:单个整数拆成多个布尔位(实测定义:PositionUpdateRelatives)
|
||||
#
|
||||
# 参照实现 protodef/src/datatypes/utils.js readBitflags:
|
||||
# 返回 {_value: 原始值, <flag>: bool, ...}
|
||||
# ★ 为什么必须实现:`position` 包里的 `flags` 就是它。
|
||||
# position 是「玩家在哪」的唯一来源 —— 实现不了它就等于
|
||||
# 看不到自己位置,后续 M3 状态层根本无从上手。
|
||||
if kind == "bitflags":
|
||||
spec = t[1]
|
||||
base = spec.get("type", "u8")
|
||||
raw = self.read(base, r, ctx)
|
||||
flags = spec.get("flags")
|
||||
out: dict[str, Any] = {"_value": raw}
|
||||
if isinstance(flags, list):
|
||||
for i, nm in enumerate(flags):
|
||||
out[nm] = bool(raw & (1 << i))
|
||||
elif isinstance(flags, dict):
|
||||
shift = spec.get("shift")
|
||||
for nm, bit in flags.items():
|
||||
b = shift if shift else bit
|
||||
out[nm] = bool(raw & (1 << int(b)))
|
||||
return out
|
||||
|
||||
# ---- bitfield:把一个整数的各位切成具名子字段(实测定义:command_node.flags)
|
||||
# ---- entityMetadataLoop:读实体元数据条目直到终止值(实测:entityMetadata)
|
||||
#
|
||||
# ★ 终止判据是**键名等于 endVal**(协议里 metadata 以 key=0xFF 收尾),
|
||||
# 不是读到流尽头 —— 写成读尽头会在同包多个 metadata 时错位。
|
||||
if kind == "entityMetadataLoop":
|
||||
spec = t[1]
|
||||
end_val = spec.get("endVal", 255)
|
||||
entry_t = spec.get("type")
|
||||
out_list: list[Any] = []
|
||||
# ★ 终止符是**光秃秃的一个字节**(0xFF),后面**没有** type/value。
|
||||
# 我先前「读完整个条目再看 key 是否 == endVal」⇒ 终止时多读了
|
||||
# type+value 2 字节 ⇒ 报「读越界」。
|
||||
# 参照 minecraft-protocol/src/datatypes/minecraft.js:120-128:
|
||||
# item = buffer.readUInt8(cursor); if (item === endVal) 停
|
||||
# 线上实测(entityId=144 的真实包 58 90 01 09 03 41a00000 11 00 7f ff):
|
||||
# 按窥视语义切,13/13 字节完全对齐。
|
||||
while True:
|
||||
if r.remaining() <= 0:
|
||||
break
|
||||
peek = r.buf[r.pos]
|
||||
if peek == end_val:
|
||||
r.read(1) # 只消费终止键本身
|
||||
break
|
||||
start = r.pos
|
||||
entry = self.read(entry_t, r, ctx)
|
||||
out_list.append(entry)
|
||||
if r.pos == start:
|
||||
break # 防死循环(零字节推进)
|
||||
return out_list
|
||||
|
||||
raise ProtoError(f"未实现的复合类型: {kind}")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_ctx(path: str, ctx: dict[str, Any]) -> Any:
|
||||
"""按路径表达式取值:`a/b`、`../a/b`、`a.b` 都要支持。
|
||||
|
||||
★ 实测语法(全协议 12 处带路径的 compareTo):
|
||||
flags/has_redirect_node 子字段
|
||||
../flags/has_custom_suggestions 父作用域子字段
|
||||
★ 为什么需“向上逐层试”:`..` 的层数依赖引用点嵌套深度,
|
||||
而不同分支的嵌套深度不一样 ⇒ 固定按字面层级走会在某些分支落空,
|
||||
落空后代码会退化成“读一个叫 ../flags/… 的类型”
|
||||
(报「未知类型: ../flags/has_custom_suggestions」)。
|
||||
"""
|
||||
parts = [p for p in path.replace(".", "/").split("/") if p]
|
||||
ups = 0
|
||||
while parts and parts[0] == "..":
|
||||
parts.pop(0)
|
||||
ups += 1
|
||||
if not parts:
|
||||
return None
|
||||
# 先按字面层级走,失败则向上多跳,直到祖先链尽头
|
||||
for extra in range(0, 8):
|
||||
for total in ({ups + extra} if extra == 0 else {ups - extra, ups + extra}):
|
||||
if total < 0:
|
||||
continue
|
||||
cur: Any = ctx
|
||||
for _ in range(total):
|
||||
if not isinstance(cur, dict):
|
||||
cur = None
|
||||
break
|
||||
cur = cur.get("__parent__")
|
||||
if cur is None:
|
||||
continue
|
||||
ok = True
|
||||
for part in parts:
|
||||
if isinstance(cur, dict) and part in cur:
|
||||
cur = cur[part]
|
||||
else:
|
||||
ok = False
|
||||
break
|
||||
if ok:
|
||||
return cur
|
||||
return None
|
||||
|
||||
def _read_named(self, name: str, r: Reader, ctx: dict[str, Any]) -> Any:
|
||||
# 阶段作用域优先(packet_set_protocol 这类只存在于阶段内)
|
||||
if name in self.phase_types:
|
||||
@ -265,18 +426,90 @@ class ProtoDef:
|
||||
return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:]}"
|
||||
if name == "nbt":
|
||||
return self._read_nbt(r)
|
||||
if name == "anonymousNbt":
|
||||
return self._read_anonymous_nbt(r)
|
||||
if name == "optionalNbt":
|
||||
return self._read_nbt(r) if r.read(1)[0] != 0 else None
|
||||
|
||||
raise ProtoError(f"未知类型: {name}")
|
||||
|
||||
@staticmethod
|
||||
def _read_nbt(r: Reader) -> Any:
|
||||
"""最小 NBT 读取(够用于 registry/codec 探测)。"""
|
||||
# ---- NBT ----
|
||||
# ★ 为什么必须真解 NBT(实测 2026-09-26):
|
||||
# configuration 阶段的 registry_data 条目是 `anonymousNbt` ——
|
||||
# 方块/物品注册表的**权威内容就在 NBT 里**(mod 方块的 state 表也在这)。
|
||||
# 旧版只回 {"_nbt_raw": ...} 占位,等于拿不到任何注册表内容,
|
||||
# 上层只能看到 `unknown(id)` —— 这正是「与服务端 mod 物品交互」的卡点。
|
||||
_NBT_END, _NBT_BYTE, _NBT_SHORT, _NBT_INT, _NBT_LONG = 0, 1, 2, 3, 4
|
||||
_NBT_FLOAT, _NBT_DOUBLE, _NBT_BYTE_ARRAY, _NBT_STRING = 5, 6, 7, 8
|
||||
_NBT_LIST, _NBT_COMPOUND, _NBT_INT_ARRAY, _NBT_LONG_ARRAY = 9, 10, 11, 12
|
||||
|
||||
@classmethod
|
||||
def _read_nbt_payload(cls, r: Reader, tag: int, depth: int = 0) -> Any:
|
||||
"""按 tag 号读一个 NBT 载荷(不含 tag 字节本身)。"""
|
||||
import struct as _s
|
||||
if depth > 64:
|
||||
raise ProtoError("NBT 嵌套过深")
|
||||
if tag == cls._NBT_BYTE:
|
||||
return r.read(1)[0]
|
||||
if tag == cls._NBT_SHORT:
|
||||
return int.from_bytes(r.read(2), "big", signed=True)
|
||||
if tag == cls._NBT_INT:
|
||||
return int.from_bytes(r.read(4), "big", signed=True)
|
||||
if tag == cls._NBT_LONG:
|
||||
return int.from_bytes(r.read(8), "big", signed=True)
|
||||
if tag == cls._NBT_FLOAT:
|
||||
return _s.unpack(">f", r.read(4))[0]
|
||||
if tag == cls._NBT_DOUBLE:
|
||||
return _s.unpack(">d", r.read(8))[0]
|
||||
if tag == cls._NBT_BYTE_ARRAY:
|
||||
n = int.from_bytes(r.read(4), "big", signed=True)
|
||||
return list(r.read(max(n, 0)))
|
||||
if tag == cls._NBT_STRING:
|
||||
n = int.from_bytes(r.read(2), "big")
|
||||
return r.read(n).decode("utf-8", "replace")
|
||||
if tag == cls._NBT_LIST:
|
||||
et = r.read(1)[0]
|
||||
n = int.from_bytes(r.read(4), "big", signed=True)
|
||||
if et == cls._NBT_END or n <= 0:
|
||||
return []
|
||||
return [cls._read_nbt_payload(r, et, depth + 1) for _ in range(n)]
|
||||
if tag == cls._NBT_COMPOUND:
|
||||
out: dict[str, Any] = {}
|
||||
while True:
|
||||
t = r.read(1)[0]
|
||||
if t == cls._NBT_END:
|
||||
return out
|
||||
nm_len = int.from_bytes(r.read(2), "big")
|
||||
nm = r.read(nm_len).decode("utf-8", "replace")
|
||||
out[nm] = cls._read_nbt_payload(r, t, depth + 1)
|
||||
if tag in (cls._NBT_INT_ARRAY, cls._NBT_LONG_ARRAY):
|
||||
w = 4 if tag == cls._NBT_INT_ARRAY else 8
|
||||
n = int.from_bytes(r.read(4), "big", signed=True)
|
||||
return [int.from_bytes(r.read(w), "big", signed=True) for _ in range(max(n, 0))]
|
||||
raise ProtoError(f"未知 NBT tag: {tag}")
|
||||
|
||||
@classmethod
|
||||
def _read_nbt(cls, r: Reader) -> Any:
|
||||
"""读一个具名 NBT(tag + 名字 + 载荷)—— 未压缩的普通 NBT。"""
|
||||
tag = r.read(1)[0]
|
||||
if tag == 0:
|
||||
if tag == cls._NBT_END:
|
||||
return None
|
||||
return {"_nbt_tag": tag, "_nbt_raw": r.read(r.remaining())}
|
||||
n = int.from_bytes(r.read(2), "big")
|
||||
name = r.read(n).decode("utf-8", "replace")
|
||||
return {"name": name, "value": cls._read_nbt_payload(r, tag)}
|
||||
|
||||
@classmethod
|
||||
def _read_anonymous_nbt(cls, r: Reader) -> Any:
|
||||
"""anonymousNbt:无根名字的 NBT(网络格式,registry_data 用的就是这个)。
|
||||
|
||||
★ 与普通 NBT 的差别只在**没有根名字** —— 少读一个
|
||||
(short 长度 + 字节) 就直接进载荷。读多了会把后面
|
||||
所有字节都错位(并且不报错,只是字段全乱)。
|
||||
"""
|
||||
tag = r.read(1)[0]
|
||||
if tag == cls._NBT_END:
|
||||
return None
|
||||
return cls._read_nbt_payload(r, tag)
|
||||
|
||||
def _count(self, count_type: Any, r: Reader, ctx: dict[str, Any]) -> int:
|
||||
return self.read(count_type, r, ctx)
|
||||
|
||||
38
plan.md
38
plan.md
@ -560,8 +560,8 @@ shot.rough → ASCII + 线索(给 agent 看它为什么选了这个角度)
|
||||
| # | 内容 | 依赖 | 状态 |
|
||||
|---|---|---|---|
|
||||
| **M0** | 起**空白测试服**(§1c),版本隔离三件套 | — | ✅ |
|
||||
| **M1** | 修 build.sh 编译 CP(加 Fabric 库)⇒ 1.21.1 构建跑通 | — | 🔄 |
|
||||
| **M2** | **协议客户端**:连测试服 → configuration → registry_data → 进 play | M0 | ⬜ |
|
||||
| **M1** | 修 build.sh 编译 CP(加 Fabric 库)⇒ 1.21.1 构建跑通 | — | ✅ |
|
||||
| **M2** | **协议客户端**:连测试服 → configuration → registry_data → 进 play | M0 | ✅ |
|
||||
| | (这是最大缺口:现有只到 `login_start` 冒烟级) | | |
|
||||
| **M3** | **状态层**:World / entities / inventory 从**服务端真状态**来 | M2 | ⬜ |
|
||||
| **M4** | **事件层**:粗粒度 + 细分层(照 mineflayer,省 token) | M2 | ⬜ |
|
||||
@ -571,6 +571,40 @@ shot.rough → ASCII + 线索(给 agent 看它为什么选了这个角度)
|
||||
| **M8** | **细渲染**(单次调用):出图,带光影 | M2 | ⬜ |
|
||||
| **M9** | 第一个用例:`capture_photo()`(粗搜机位 → 细出图) | M6, M8 | ⬜ |
|
||||
|
||||
### M2 实测结论(2026-09-26)
|
||||
|
||||
**已达成的可复现事实**(连 M0 测试服 127.0.0.1:25566):
|
||||
|
||||
```
|
||||
login phase=login → success(服务端回 UUID 与离线算法一致)
|
||||
configuration phase=play 注册表=11(biome/damage_type/enchantment/…)
|
||||
play 28 种包 map_chunk×9、position、declare_commands、entity_metadata
|
||||
position (5.5, -60.0, -0.5)
|
||||
```
|
||||
|
||||
**踩过的 7 个真实坑**(都是「世代差异」或协议数据理解错误):
|
||||
|
||||
| 症状 | 根因 |
|
||||
|---|---|
|
||||
| `Failed to decode packet 'hello'` | 1.21.1 `login_start.playerUUID` 是**必填 UUID**;1.20 是 `['option','UUID']` |
|
||||
| configuration 收不到包 | `login_acknowledged` 包 id 写死 `0x00`,实际 **`0x03`** |
|
||||
| `Badly compressed packet` | 写侧没实现压缩帧:`varint(dataLen)+数据`,小包 `dataLen=0` |
|
||||
| 服务端发完 `select_known_packs` 不动 | configuration 是**双向协商**,必须回 `select_known_packs`/发 `settings` |
|
||||
| `registry_data` 解不开 | 缺 `anonymousNbt`;旧 NBT 只是占位符 |
|
||||
| `declare_recipes` 流错位 | 数组计数有 `countType`(103处) 与 **`count`(引用 ctx,6处)** 两种 |
|
||||
| `player_info`/`declare_commands` 解不开 | `compareTo` 可用**路径**:`flags/x`、`../flags/x`(12 处) |
|
||||
| `entity_metadata` 越界 | 终止符是**光秃 0xFF 一个字节**,不含 type/value |
|
||||
|
||||
**关键架构发现(修正项目前提)**:vanilla 1.21.1 只在线同步 **11 个动态注册表**,
|
||||
**不含 `minecraft:block` / `minecraft:item`**。静态注册表不走线同步 ——
|
||||
客户端用自己 jar 里的。⇒ Java 侧读 client jar 的路线对 vanilla 正确;
|
||||
但 **mod 新增方块 vanilla 不会告诉客户端**,那条要靠加载器自身的同步机制。
|
||||
|
||||
**已知上游数据缺口(非本仓 bug,已容错)**:`declare_recipes`(108KB)
|
||||
在**官方 minecraft-data 3.117 + minecraft-protocol** 上同样解不开
|
||||
(PartialReadError: found 108271 / expected 114153)。
|
||||
处理:长度前缀是帧层权威 ⇒ **坏包整包丢弃不断流**(`decode_failures` 布告)。
|
||||
|
||||
**建议顺序:M0 → M1 → M2**。
|
||||
- M0/M1 是解阻断(M1 是我已经查到根因的那个 classpath bug)
|
||||
- **M2 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。
|
||||
|
||||
@ -101,11 +101,20 @@ echo "[4/7] 库 $(wc -l < "$OUT/classpath.txt") 个, natives $(find "$OUT/native
|
||||
|
||||
# ── 5. 编译渲染服务 ───────────────────────────────────────────
|
||||
echo "[5/7] 编译 $JAVA_SUB ..."
|
||||
# ★ 编译 CP 必须含 **Fabric 库**:loader_fabric/LoaderBoot 引用
|
||||
# net.fabricmc.api / net.fabricmc.loader.impl.launch.knot,
|
||||
# 缺了就是 "package net.fabricmc.api does not exist"(1.21.1 实测报过)。
|
||||
# 1.20.1 那次能过是因为**手工拼的运行 CP** 里恰好带了它,
|
||||
# 而 build.sh 自己的编译 CP 一直没加 —— 侥幸而已。
|
||||
CP="$(paste -sd: "$OUT/classpath.txt"):$NAMED:$OUT/classes"
|
||||
find "$HERE/$JAVA_SUB" "$HERE/common" -name "*.java" > "$WORK/srcs.txt"
|
||||
if [ -d "$OUT/fabric" ]; then
|
||||
FAB_CP=$(find "$OUT/fabric" -name '*.jar' | paste -sd:)
|
||||
[ -n "$FAB_CP" ] && CP="$CP:$FAB_CP"
|
||||
fi
|
||||
find "$HERE/$JAVA_SUB" "$HERE/java/common" -name "*.java" > "$WORK/srcs.txt"
|
||||
# ★ LoaderBoot(加载器启动器)也要编进来:它是 launcher="fabric" 的主类,
|
||||
# 少了它运行期报 ClassNotFoundException: LoaderBoot。
|
||||
find "$HERE/launcher_fabric" -name "*.java" >> "$WORK/srcs.txt"
|
||||
find "$HERE/java/launcher_fabric" -name "*.java" >> "$WORK/srcs.txt"
|
||||
javac -nowarn -proc:none -cp "$CP" -d "$OUT/classes" @"$WORK/srcs.txt"
|
||||
|
||||
# ── 6. 反射名次表(extern 参数;B 方案)──────────────────────
|
||||
@ -123,13 +132,13 @@ if [ -f "$HERE/gen_names.py" ]; then
|
||||
if [ -n "$INTERMEDIARY_JAR" ]; then
|
||||
python3 "$HERE/gen_names.py" --srg "$WORK/mappings.srg" \
|
||||
--intermediary "$INTERMEDIARY_JAR" \
|
||||
--java "$HERE/$JAVA_SUB" "$HERE/common" \
|
||||
--java "$HERE/$JAVA_SUB" "$HERE/java/common" \
|
||||
--out "$OUT/names-intermediary.json" --namespace intermediary || true
|
||||
fi
|
||||
# named 形态:物理名就是末段,无需映射(但仍然生成,让调用方只传路径)
|
||||
python3 "$HERE/gen_names.py" --srg "$WORK/mappings.srg" \
|
||||
--intermediary "${INTERMEDIARY_JAR:-$NAMED}" \
|
||||
--java "$HERE/$JAVA_SUB" "$HERE/common" \
|
||||
--java "$HERE/$JAVA_SUB" "$HERE/java/common" \
|
||||
--out "$OUT/names.json" --namespace named || true
|
||||
fi
|
||||
|
||||
|
||||
214
scaffold/java/common/mcfold/RenderCompat.java
Normal file
214
scaffold/java/common/mcfold/RenderCompat.java
Normal file
@ -0,0 +1,214 @@
|
||||
package mcfold;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* 装载「地形渲染替换件」(Sodium 等)的桥。
|
||||
*
|
||||
* ## 为什么需要它(2026-09-26 实测,根因三层)
|
||||
*
|
||||
* **症状**:装了 Sodium 后画面只有天空、`chunksRendered=0`,
|
||||
* 而 `blockinfo` 能正确读出方块 ⇒ **数据在,只是渲染器不知道**。
|
||||
*
|
||||
* **根因**:
|
||||
* <pre>
|
||||
* ① Sodium 的 mixin 挂在 replaceWithPacketData 内
|
||||
* @At(INVOKE ClientLevel.onChunkLoaded, shift=AFTER)
|
||||
* 而 1.20.1 / 1.21.x 的该方法**内部并不调** onChunkLoaded
|
||||
* (它在 getChunk / drop 里各有一处,属别的路径)⇒ 注入点永不命中
|
||||
* ⇒ 它的 ChunkTracker 从未被告知任何区块存在
|
||||
*
|
||||
* ② 补上通知后,ChunkTracker 拿到了区块,但仍不进渲染队列
|
||||
*
|
||||
* ③ ★ status 位传错(真正的那道坎)
|
||||
* Sodium 的语义:
|
||||
* FLAG_HAS_BLOCK_DATA = 1 FLAG_HAS_LIGHT_DATA = 2 FLAG_ALL = 3
|
||||
* 而 ChunkTracker.updateMerged 的 2×2 邻域判定是
|
||||
* `四邻 status 按位与 == 3` (字节码 :54 iand / :71 iconst_3)
|
||||
* 传 1:1&1&1&1 = 1 ≠ 3 ⇒ 全部判成"凑不齐 2×2" ⇒ 队列恒空 ⇒ 无地形
|
||||
* 传 3:3&3&3&3 = 3 ⇒ 2×2 组就绪 ⇒ 重建队列有内容
|
||||
* (我们确实有光照数据 —— 注入时跑了 runLightUpdates())
|
||||
* </pre>
|
||||
*
|
||||
* **实测对比**(同一场景 7×7=49 区块):
|
||||
* <pre>
|
||||
* 传 1: chunkStatus=49 chunkReady=0 loadQueue=0 colors=4748
|
||||
* 传 3: chunkStatus=49 chunkReady=25 loadQueue=25 colors=113546
|
||||
* </pre>
|
||||
*
|
||||
* ## 为什么这不算「给某个 mod 打补丁」
|
||||
*
|
||||
* 注入路径缺了「区块已就绪」这一步**广播**。补上它对所有
|
||||
* 依赖该事件的 mod 都有效,而不只是 Sodium。
|
||||
* 这里只对 Sodium 做了显式适配,是因为它的 mixin 钩子
|
||||
* ({@code ChunkTrackerHolder})是 public 接口,且**类不存在时静默跳过** ——
|
||||
* 不装它就零影响。
|
||||
*
|
||||
* ## 世代差异
|
||||
*
|
||||
* 1.20.1 与 1.21.1 的 API 位置不同(ChunkStatus 包路径、ChunkStatus 常量),
|
||||
* 故本类只做**反射调用**,不直接引用 MC 类型 ⇒ 两世代共用一份。
|
||||
*/
|
||||
public final class RenderCompat {
|
||||
|
||||
private RenderCompat() {}
|
||||
|
||||
private static final String SODIUM_HOLDER =
|
||||
"me.jellysquid.mods.sodium.client.render.chunk.map.ChunkTrackerHolder";
|
||||
private static final String SODIUM_CHUNK_STATUS =
|
||||
"me.jellysquid.mods.sodium.client.render.chunk.map.ChunkStatus";
|
||||
|
||||
// ── 探测 ───────────────────────────────────────────────
|
||||
|
||||
/** 该渲染替换件是否在(装了没装都能安全调)。 */
|
||||
public static boolean available() {
|
||||
return sodiumClassLoader() != null;
|
||||
}
|
||||
|
||||
private static ClassLoader sodiumClassLoader() {
|
||||
try {
|
||||
Class.forName(SODIUM_HOLDER);
|
||||
return RenderCompat.class.getClassLoader();
|
||||
} catch (ClassNotFoundException notInstalled) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sodium 的 FLAG_ALL(有方块 + 有光照);取不到时退回 3。
|
||||
*
|
||||
* ★ 3 是硬要求:updateMerged 的 2×2 判定是 `四邻 & == 3`。
|
||||
* 传 1(只有方块)会让所有区块判成"凑不齐" ⇒ 地形不出现。 */
|
||||
public static int flagAll() {
|
||||
try {
|
||||
Class<?> cs = Class.forName(SODIUM_CHUNK_STATUS);
|
||||
return cs.getField("FLAG_ALL").getInt(null);
|
||||
} catch (Throwable t) {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 通知 ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 告诉渲染替换件:这块地已就绪(等价于网络包路径里会发生的那一步)。
|
||||
*
|
||||
* @param level 当前世界(必须是 ClientLevel —— 签名是 get(class_638),
|
||||
* 传它的父类会 NoSuchMethodException,实测踩过)
|
||||
* @return 是否成功通知(false = 没装该渲染器,属正常)
|
||||
*/
|
||||
public static boolean notifyChunkLoaded(Object level, int cx, int cz) {
|
||||
try {
|
||||
Class<?> holder = Class.forName(SODIUM_HOLDER);
|
||||
// ★ 参数类型必须匹配:先按名字找 ClientLevel 的 Class,
|
||||
// 再用它做 getMethod 的参数类型(编译期引不到 MC 类型,跨世代共用)
|
||||
Class<?> levelClass = level.getClass();
|
||||
Object tracker = holder.getMethod("get", levelClass).invoke(null, level);
|
||||
if (tracker == null) return false;
|
||||
// ★ 第三参数是**位掩码 int**(签名 (III)V),不是 ChunkStatus 对象
|
||||
tracker.getClass()
|
||||
.getMethod("onChunkStatusAdded", int.class, int.class, int.class)
|
||||
.invoke(tracker, cx, cz, flagAll());
|
||||
return true;
|
||||
} catch (ClassNotFoundException notInstalled) {
|
||||
return false; // 没装 ⇒ 正常,静默
|
||||
} catch (Throwable t) {
|
||||
System.err.println("[mcfold] RenderCompat.notifyChunkLoaded(" + cx + ","
|
||||
+ cz + "): " + t);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 诊断(供 debug 命令读)──────────────────────────────
|
||||
|
||||
/** [chunkStatus, chunkReady, loadQueue, chunkStatus 重复项] 的当前大小;没装则 null。 */
|
||||
public static long[] trackerSnapshot(Object level) {
|
||||
try {
|
||||
Class<?> holder = Class.forName(SODIUM_HOLDER);
|
||||
Object tracker = holder.getMethod("get", level.getClass()).invoke(null, level);
|
||||
if (tracker == null) return new long[]{-1, -1, -1, -1};
|
||||
long[] out = new long[4];
|
||||
String[] names = {"chunkStatus", "chunkReady", "loadQueue", "unloadQueue"};
|
||||
for (int i = 0; i < names.length; i++) {
|
||||
Field f = tracker.getClass().getDeclaredField(names[i]);
|
||||
f.setAccessible(true);
|
||||
Object v = f.get(tracker);
|
||||
// 不依赖 instanceof(fastutil 的类型在 remap 后名字会变)⇒ 反射 size()
|
||||
out[i] = ((Number) v.getClass().getMethod("size").invoke(v)).longValue();
|
||||
}
|
||||
return out;
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 它自报的调试串(如 "C: 0/600 D: 12":C=可见, D=距离);取不到则 null。 */
|
||||
public static String chunksDebugString(Object level) {
|
||||
try {
|
||||
Class<?> swr = Class.forName(
|
||||
"me.jellysquid.mods.sodium.client.render.SodiumWorldRenderer");
|
||||
Object inst = swr.getMethod("instance").invoke(null);
|
||||
if (inst == null) return null;
|
||||
Object s = inst.getClass().getMethod("getChunksDebugString").invoke(inst);
|
||||
return s == null ? null : s.toString();
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 它的地形是否已渲完(注意:队列空时它会答 true —— 不可单独作为判据)。 */
|
||||
public static Boolean terrainComplete(Object level) {
|
||||
try {
|
||||
Class<?> swr = Class.forName(
|
||||
"me.jellysquid.mods.sodium.client.render.SodiumWorldRenderer");
|
||||
Object inst = swr.getMethod("instance").invoke(null);
|
||||
if (inst == null) return null;
|
||||
return (Boolean) inst.getClass().getMethod("isTerrainRenderComplete").invoke(inst);
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 可见区块数(vanilla 的 countRenderedChunks 在它之下恒为 0,不能用那个)。 */
|
||||
public static Integer visibleChunks(Object level) {
|
||||
try {
|
||||
Class<?> swr = Class.forName(
|
||||
"me.jellysquid.mods.sodium.client.render.SodiumWorldRenderer");
|
||||
Object inst = swr.getMethod("instance").invoke(null);
|
||||
if (inst == null) return null;
|
||||
return (Integer) inst.getClass().getMethod("getVisibleChunkCount").invoke(inst);
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 重建队列 {INITIAL_BUILD, REBUILD, IMPORTANT_REBUILD} 的条数;取不到则 null。 */
|
||||
public static int[] rebuildQueue(Object level) {
|
||||
try {
|
||||
Class<?> swr = Class.forName(
|
||||
"me.jellysquid.mods.sodium.client.render.SodiumWorldRenderer");
|
||||
Object inst = swr.getMethod("instance").invoke(null);
|
||||
if (inst == null) return null;
|
||||
Field f = inst.getClass().getDeclaredField("renderSectionManager");
|
||||
f.setAccessible(true);
|
||||
Object rsm = f.get(inst);
|
||||
if (rsm == null) return null;
|
||||
Field rl = rsm.getClass().getDeclaredField("rebuildLists");
|
||||
rl.setAccessible(true);
|
||||
Object lists = rl.get(rsm);
|
||||
int[] out = new int[3];
|
||||
if (lists instanceof java.util.Map<?, ?> m) {
|
||||
int i = 0;
|
||||
for (Object v : m.values()) {
|
||||
if (i < out.length) {
|
||||
out[i] = v instanceof java.util.Collection<?> c ? c.size() : -1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -61,6 +61,7 @@ import java.util.OptionalInt;
|
||||
import java.util.UUID;
|
||||
import java.lang.reflect.Method;
|
||||
import mcfold.McfoldNames;
|
||||
import mcfold.RenderCompat;
|
||||
|
||||
/**
|
||||
* mcfold 渲染服务:常驻进程,NDJSON(行分隔 JSON) 协议,由 Python 侧驱动。
|
||||
@ -676,7 +677,7 @@ public class McfoldRenderServer {
|
||||
}
|
||||
// viewCenter
|
||||
r.addProperty("fogStart", String.valueOf(mc.options.renderDistance().get()));
|
||||
probeSodium(id, r);
|
||||
reportRenderCompat(r);
|
||||
emit(id, r);
|
||||
break;
|
||||
}
|
||||
@ -756,206 +757,56 @@ public class McfoldRenderServer {
|
||||
System.err.println("[mcfold] onChunkLoaded(" + cx + "," + cz + ") 失败: " + t);
|
||||
}
|
||||
// ② 渲染替换件的 tracker(实测:Sodium 靠这个才知道区块存在)
|
||||
notifyRenderTracker(cx, cz);
|
||||
RenderCompat.notifyChunkLoaded(level, cx, cz);
|
||||
}
|
||||
|
||||
/**
|
||||
* 告诉「渲染替换件」的区块 tracker:这块地已就绪。
|
||||
|
||||
/** 诊断:地形渲染替换件(Sodium 等)的真实状态,写进 debug 响应。
|
||||
*
|
||||
* ★ 为什么需要它(2026-09-26 实测,见 debug 里的 sodium* 字段)
|
||||
*
|
||||
* 症状:装了 Sodium 后画面只有天空、chunks=0、它的重建队列恒空,
|
||||
* 而 blockinfo 能正确读出方块 ⇒ **数据在,只是它不知道**。
|
||||
*
|
||||
* 根因:Sodium 的 mixin 挂在
|
||||
* replaceWithPacketData 内部 @At(INVOKE onChunkLoaded, AFTER)
|
||||
* 而 1.20.1 的 replaceWithPacketData **内部并不调** onChunkLoaded
|
||||
* (它在 getChunk / drop 里各有一处,属别的路径)⇒ 注入点永不命中。
|
||||
* 我们直接调 onChunkLoaded 也救不了:mixin 只在 replaceWithPacketData
|
||||
* **执行期间**生效。
|
||||
*
|
||||
* ★ 为什么这不算“绑死 Sodium”:
|
||||
* - ChunkTrackerHolder 是它的 **public interface**(mixin 提供的公开钩子);
|
||||
* - 全部走反射,**类不存在时静默跳过** ⇒ 不装 Sodium 时零影响。
|
||||
* - 将来别的渲染器若有同名钩子,改这一处即可。
|
||||
* ★ 为什么需要它:vanilla 的 countRenderedChunks() 在 Sodium 下**恒为 0**
|
||||
* (它数的是 vanilla 的网格,替换件不走那条路)⇒ 光看这个数会把
|
||||
* 「换了渲染器」误判成「没有数据」。而 blockinfo 能读方块 ⇒ 数据是在的。
|
||||
* ⇒ 排障必须问**渲染器自己**。
|
||||
*/
|
||||
static void notifyRenderTracker(int cx, int cz) {
|
||||
try {
|
||||
Class<?> holder = Class.forName(
|
||||
"me.jellysquid.mods.sodium.client.render.chunk.map.ChunkTrackerHolder");
|
||||
// ★ 参数类型必须是 **ClientLevel**(不是它的父类 Level)——
|
||||
// 签名是 get(class_638);传 Level.class 会 NoSuchMethodException
|
||||
// (实测踩过:'get(net.minecraft.world.level.Level)' 找不到)。
|
||||
Object tracker = holder.getMethod("get", ClientLevel.class).invoke(null, level);
|
||||
if (tracker == null) return;
|
||||
// ★ 第三参数是**位掩码 int**(实测签名 (III)V)。Sodium 的语义:
|
||||
// FLAG_HAS_BLOCK_DATA = 1 (有方块数据)
|
||||
// FLAG_HAS_LIGHT_DATA = 2 (有光照数据)
|
||||
// FLAG_ALL = 3 (两者都有)
|
||||
// 而 ChunkTracker.updateMerged 的 2×2 邻域判定是
|
||||
// `四邻 status 按位与 == 3`(字节码 :54 iand / :71 iconst_3)
|
||||
// ⇒ **必须报 3**:只报 1 会让所有区块判成"凑不齐 2×2",
|
||||
// 结果 chunkReady 恒 0、loadQueue 恒空、画面无地形
|
||||
// (实测:传 1 → chunkStatus=49 但 chunkReady=0)。
|
||||
// 我们确实有光照数据 —— 注入时就跑了 light.runLightUpdates()。
|
||||
int flagAll = sodiumFlagAll();
|
||||
tracker.getClass()
|
||||
.getMethod("onChunkStatusAdded", int.class, int.class, int.class)
|
||||
.invoke(tracker, cx, cz, flagAll);
|
||||
} catch (ClassNotFoundException noTracker) {
|
||||
// 没装 Sodium(或其他无此钩子的渲染器)⇒ 正常,静默
|
||||
} catch (Throwable t) {
|
||||
// 装了但调用失败 ⇒ 报出来(否则会默默退回“无地形”)
|
||||
System.err.println("[mcfold] notifyRenderTracker(" + cx + "," + cz + "): " + t);
|
||||
static void reportRenderCompat(JsonObject r) {
|
||||
if (!RenderCompat.available()) {
|
||||
r.addProperty("renderCompat", "absent");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sodium 的 FLAG_ALL(有方块 + 有光照);取不到时退回 3。 */
|
||||
static int sodiumFlagAll() {
|
||||
try {
|
||||
Class<?> cs = sodiumClass().getClassLoader()
|
||||
.loadClass("me.jellysquid.mods.sodium.client.render.chunk.map.ChunkStatus");
|
||||
return cs.getField("FLAG_ALL").getInt(null);
|
||||
} catch (Throwable t) {
|
||||
return 3;
|
||||
r.addProperty("renderCompat", "present");
|
||||
long[] snap = RenderCompat.trackerSnapshot(level);
|
||||
if (snap != null) {
|
||||
r.addProperty("rc_chunkStatus", snap[0]);
|
||||
r.addProperty("rc_chunkReady", snap[1]);
|
||||
r.addProperty("rc_loadQueue", snap[2]);
|
||||
r.addProperty("rc_unloadQueue", snap[3]);
|
||||
}
|
||||
Integer vis = RenderCompat.visibleChunks(level);
|
||||
if (vis != null) r.addProperty("rc_visibleChunks", vis);
|
||||
Boolean done = RenderCompat.terrainComplete(level);
|
||||
if (done != null) r.addProperty("rc_terrainComplete", done);
|
||||
String dbg = RenderCompat.chunksDebugString(level);
|
||||
if (dbg != null) r.addProperty("rc_chunksDebug", dbg);
|
||||
int[] q = RenderCompat.rebuildQueue(level);
|
||||
if (q != null) {
|
||||
JsonObject qo = new JsonObject();
|
||||
qo.addProperty("INITIAL_BUILD", q[0]);
|
||||
qo.addProperty("REBUILD", q[1]);
|
||||
qo.addProperty("IMPORTANT_REBUILD", q[2]);
|
||||
r.add("rc_rebuildQueue", qo);
|
||||
}
|
||||
r.addProperty("rc_deltaChunkReady", SODIUM_DELTA[0]);
|
||||
r.addProperty("rc_deltaLoadQueue", SODIUM_DELTA[1]);
|
||||
}
|
||||
|
||||
static void levelRendererAllChanged() {
|
||||
mc.levelRenderer.allChanged();
|
||||
}
|
||||
|
||||
/** tick + 渲染各帧(区块网格重建队列靠 tick 推进) */
|
||||
/** [chunkReady, loadQueue] 的当前大小;未装 Sodium 则 null。 */
|
||||
static long[] sodiumTrackerSnapshot() {
|
||||
var holder = sodiumClass();
|
||||
if (holder == null) return null;
|
||||
try {
|
||||
Class<?> h = holder.getClassLoader()
|
||||
.loadClass("me.jellysquid.mods.sodium.client.render.chunk.map.ChunkTrackerHolder");
|
||||
Object tracker = h.getMethod("get",
|
||||
net.minecraft.client.multiplayer.ClientLevel.class).invoke(null, level);
|
||||
if (tracker == null) return new long[]{-1, -1, -1, -1};
|
||||
long[] out = new long[4];
|
||||
String[] names = {"chunkReady", "loadQueue", "unloadQueue", "chunkStatus"};
|
||||
for (int i = 0; i < 4; i++) {
|
||||
var f = tracker.getClass().getDeclaredField(names[i]);
|
||||
f.setAccessible(true);
|
||||
Object v = f.get(tracker);
|
||||
out[i] = ((Number) v.getClass().getMethod("size").invoke(v)).longValue();
|
||||
}
|
||||
return out;
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 本次渲染期间 [chunkReady 增量, loadQueue 增量]。 */
|
||||
static final long[] SODIUM_DELTA = new long[2];
|
||||
|
||||
/** 诊断:Sodium 接管区块渲染后,它的真实状态(不装 Sodium 则全部 absent)。
|
||||
*
|
||||
* ★ 为什么需要它:Sodium 用 mixin 换掉了 LevelRenderer 的地形渲染,
|
||||
* 而 vanilla 的 `countRenderedChunks()` **在 Sodium 下永远返回 0**
|
||||
* (它数的是 vanilla 的 section 网格,Sodium 不走那条路)。
|
||||
* 实测症状:`chunks=0` + `hasRenderedAllChunks=true` + 画面无地形
|
||||
* —— 而 `blockinfo` 表明数据已在区块里。
|
||||
* ⇒ 光看 vanilla 计数会误判成「没数据」,实为「换了渲染器」。
|
||||
*/
|
||||
static void probeSodium(int id, JsonObject r) {
|
||||
r.addProperty("sodiumLoaded", sodiumClass() != null);
|
||||
var swr = sodiumInstance();
|
||||
if (swr == null) { r.addProperty("sodiumState", "absent"); return; }
|
||||
try {
|
||||
Method m = swr.getClass().getMethod("getVisibleChunkCount");
|
||||
r.addProperty("sodiumVisibleChunks", String.valueOf(m.invoke(swr)));
|
||||
Method c = swr.getClass().getMethod("isTerrainRenderComplete");
|
||||
r.addProperty("sodiumTerrainComplete", String.valueOf(c.invoke(swr)));
|
||||
// ★ 直接读 ChunkTracker 的内部集合:这是「区块到底有没有进 tracker」
|
||||
// 的权威证据(visibleChunks=0 可能只是可见性判定为假)。
|
||||
try {
|
||||
Class<?> holder = sodiumClass().getClassLoader()
|
||||
.loadClass("me.jellysquid.mods.sodium.client.render.chunk.map.ChunkTrackerHolder");
|
||||
Object tracker = holder.getMethod("get",
|
||||
net.minecraft.client.multiplayer.ClientLevel.class).invoke(null, level);
|
||||
if (tracker != null) {
|
||||
for (String fl : new String[]{"chunkStatus", "chunkReady", "loadQueue"}) {
|
||||
var ff = tracker.getClass().getDeclaredField(fl);
|
||||
ff.setAccessible(true);
|
||||
Object v = ff.get(tracker);
|
||||
// 不依赖 instanceof(fastutil 的类型在 remap 后名字会变)⇒ 用反射 size()
|
||||
int sz = -1;
|
||||
try { sz = (Integer) v.getClass().getMethod("size").invoke(v); }
|
||||
catch (Throwable ignore) { }
|
||||
r.addProperty("sodiumTracker_" + fl, sz);
|
||||
}
|
||||
Method mc2 = tracker.getClass().getMethod("getReadyChunks");
|
||||
Object rc = mc2.invoke(tracker);
|
||||
try { r.addProperty("sodiumReadyChunks",
|
||||
String.valueOf(rc.getClass().getMethod("size").invoke(rc))); }
|
||||
catch (Throwable ignore) { r.addProperty("sodiumReadyChunks", "n/a"); }
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
r.addProperty("sodiumTrackerProbeError", String.valueOf(t));
|
||||
}
|
||||
r.addProperty("sodiumDeltaChunkReady", SODIUM_DELTA[0]);
|
||||
r.addProperty("sodiumDeltaLoadQueue", SODIUM_DELTA[1]);
|
||||
r.addProperty("sodiumUnloadQueue", sodiumTrackerSnapshot() == null ? -1
|
||||
: sodiumTrackerSnapshot()[2]);
|
||||
r.addProperty("sodiumNeighboursAfter", sodiumTrackerSnapshot() == null ? -1
|
||||
: sodiumTrackerSnapshot()[3]);
|
||||
Method dbg = swr.getClass().getMethod("getChunksDebugString");
|
||||
Object s = dbg.invoke(swr);
|
||||
r.addProperty("sodiumChunksDebug", s == null ? "null" : s.toString());
|
||||
// 重建队列里还剩多少活?
|
||||
var f = swr.getClass().getDeclaredField("renderSectionManager");
|
||||
f.setAccessible(true);
|
||||
Object rsm = f.get(swr);
|
||||
if (rsm != null) {
|
||||
for (String fl : new String[]{"rebuildLists", "needsUpdate", "lastUpdatedFrame"}) {
|
||||
try {
|
||||
var ff = rsm.getClass().getDeclaredField(fl);
|
||||
ff.setAccessible(true);
|
||||
Object v = ff.get(rsm);
|
||||
if (fl.equals("rebuildLists") && v instanceof java.util.Map<?,?> m2) {
|
||||
JsonObject q = new JsonObject();
|
||||
for (var en : m2.entrySet()) {
|
||||
Object de = en.getValue();
|
||||
int sz = de instanceof java.util.Collection<?> c2 ? c2.size() : -1;
|
||||
q.addProperty(String.valueOf(en.getKey()), sz);
|
||||
}
|
||||
r.add("sodiumRebuildQueue", q);
|
||||
} else {
|
||||
r.addProperty("sodium_" + fl, String.valueOf(v));
|
||||
}
|
||||
} catch (NoSuchFieldException ignore) { }
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
r.addProperty("sodiumProbeError", String.valueOf(t));
|
||||
}
|
||||
// 本次渲染期间 setupTerrain 被调了几次(快照法:渲染前后相减)
|
||||
}
|
||||
|
||||
/** SodiumWorldRenderer 的 Class(未装 Sodium 则 null)。 */
|
||||
static Class<?> sodiumClass() {
|
||||
try {
|
||||
return Class.forName("me.jellysquid.mods.sodium.client.render.SodiumWorldRenderer");
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前 SodiumWorldRenderer 单例(未装 / 未初始化则 null)。 */
|
||||
static Object sodiumInstance() {
|
||||
var c = sodiumClass();
|
||||
if (c == null) return null;
|
||||
try {
|
||||
return c.getMethod("instance").invoke(null);
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 探针:Sodium 的 setupTerrain/processChunkEvents 到底有没有被调(计数)。 */
|
||||
static final java.util.concurrent.atomic.AtomicLong SODIUM_SETUP_TERRAIN =
|
||||
@ -994,10 +845,10 @@ public class McfoldRenderServer {
|
||||
|
||||
static void render(Integer id, int frames, String out) throws Exception {
|
||||
// ★ 观测:渲染**前**的 tracker 快照(用来判断 processChunkEvents 有没有被调)
|
||||
long[] before = sodiumTrackerSnapshot();
|
||||
long[] before = RenderCompat.trackerSnapshot(level);
|
||||
tickFrames(frames);
|
||||
long[] after = sodiumTrackerSnapshot();
|
||||
if (after != null) {
|
||||
long[] after = RenderCompat.trackerSnapshot(level);
|
||||
if (after != null && before != null) {
|
||||
// delta 放进静态字段,供 debug 读取
|
||||
SODIUM_DELTA[0] = after[0] - before[0];
|
||||
SODIUM_DELTA[1] = after[1] - before[1];
|
||||
|
||||
@ -60,6 +60,7 @@ import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.UUID;
|
||||
import mcfold.McfoldNames;
|
||||
import mcfold.RenderCompat;
|
||||
|
||||
/**
|
||||
* mcfold 渲染服务:常驻进程,NDJSON(行分隔 JSON) 协议,由 Python 侧驱动。
|
||||
@ -667,6 +668,7 @@ public class McfoldRenderServer {
|
||||
}
|
||||
// viewCenter
|
||||
r.addProperty("fogStart", String.valueOf(mc.options.renderDistance().get()));
|
||||
reportRenderCompat(r);
|
||||
emit(id, r);
|
||||
break;
|
||||
}
|
||||
@ -743,33 +745,37 @@ public class McfoldRenderServer {
|
||||
} catch (Throwable t) {
|
||||
System.err.println("[mcfold] onChunkLoaded(" + cx + "," + cz + ") 失败: " + t);
|
||||
}
|
||||
// ② 渲染替换件的 tracker(完整说明见 gen_1_20 同处)
|
||||
try {
|
||||
Class<?> holder = Class.forName(
|
||||
"me.jellysquid.mods.sodium.client.render.chunk.map.ChunkTrackerHolder");
|
||||
// ★ 参数类型必须是 ClientLevel(签名 get(class_638)),传 Level.class 会 NoSuchMethodException
|
||||
Object tracker = holder.getMethod("get",
|
||||
net.minecraft.client.multiplayer.ClientLevel.class).invoke(null, level);
|
||||
if (tracker == null) return;
|
||||
// ★ 第三参数是位掩码 int(实测签名 (III)V):
|
||||
// FLAG_HAS_BLOCK_DATA=1 / FLAG_HAS_LIGHT_DATA=2 / FLAG_ALL=3
|
||||
// 而 updateMerged 的 2×2 判定要求 == 3 ⇒ 必须报 FLAG_ALL,
|
||||
// 否则所有区块判成"凑不齐 2×2" ⇒ chunkReady 恒 0、无地形
|
||||
// (实测:传 1 → chunkStatus=49 但 chunkReady=0)
|
||||
int flagAll;
|
||||
try {
|
||||
Class<?> cs = sodiumClass().getClassLoader().loadClass(
|
||||
"me.jellysquid.mods.sodium.client.render.chunk.map.ChunkStatus");
|
||||
flagAll = cs.getField("FLAG_ALL").getInt(null);
|
||||
} catch (Throwable t) { flagAll = 3; }
|
||||
tracker.getClass()
|
||||
.getMethod("onChunkStatusAdded", int.class, int.class, int.class)
|
||||
.invoke(tracker, cx, cz, flagAll);
|
||||
} catch (ClassNotFoundException noTracker) {
|
||||
// 没装该渲染器 ⇒ 正常,静默
|
||||
} catch (Throwable t) {
|
||||
System.err.println("[mcfold] notifyRenderTracker(" + cx + "," + cz + "): " + t);
|
||||
// ② 渲染替换件的 tracker —— 走共用桥(完整根因说明见 RenderCompat)
|
||||
RenderCompat.notifyChunkLoaded(level, cx, cz);
|
||||
}
|
||||
|
||||
/** 渲染替换件在本次渲染期间的变化([chunkReady 增量, loadQueue 增量])。 */
|
||||
static final long[] SODIUM_DELTA = new long[2];
|
||||
|
||||
/** 诊断:地形渲染替换件(Sodium 等)的真实状态。根因说明见 RenderCompat。 */
|
||||
static void reportRenderCompat(JsonObject r) {
|
||||
if (!RenderCompat.available()) { r.addProperty("renderCompat", "absent"); return; }
|
||||
r.addProperty("renderCompat", "present");
|
||||
long[] snap = RenderCompat.trackerSnapshot(level);
|
||||
if (snap != null) {
|
||||
r.addProperty("rc_chunkStatus", snap[0]);
|
||||
r.addProperty("rc_chunkReady", snap[1]);
|
||||
r.addProperty("rc_loadQueue", snap[2]);
|
||||
}
|
||||
Integer vis = RenderCompat.visibleChunks(level);
|
||||
if (vis != null) r.addProperty("rc_visibleChunks", vis);
|
||||
String dbg = RenderCompat.chunksDebugString(level);
|
||||
if (dbg != null) r.addProperty("rc_chunksDebug", dbg);
|
||||
int[] q = RenderCompat.rebuildQueue(level);
|
||||
if (q != null) {
|
||||
JsonObject qo = new JsonObject();
|
||||
qo.addProperty("INITIAL_BUILD", q[0]);
|
||||
qo.addProperty("REBUILD", q[1]);
|
||||
qo.addProperty("IMPORTANT_REBUILD", q[2]);
|
||||
r.add("rc_rebuildQueue", qo);
|
||||
}
|
||||
r.addProperty("rc_deltaChunkReady", SODIUM_DELTA[0]);
|
||||
r.addProperty("rc_deltaLoadQueue", SODIUM_DELTA[1]);
|
||||
}
|
||||
|
||||
static void levelRendererAllChanged() {
|
||||
|
||||
@ -97,8 +97,192 @@ def test_no_overshoot():
|
||||
except ProtoError as e:
|
||||
print(f" ✓ 越界显式报错(不静默): {str(e)[:50]}")
|
||||
|
||||
|
||||
|
||||
# ---------- M2 新增:configuration/play 阶段真正用到的复合类型 ----------
|
||||
#
|
||||
# 这些判据用**构造的字节**(不是观察当前输出),所以能真失败:
|
||||
# 每条都先验输入 → 期望输出,换个实现形态就红。
|
||||
|
||||
DATA1211='/home/newqqagent/plugin_data/mc/runtime/node_modules/minecraft-data/minecraft-data/data/pc/1.21.1/protocol.json'
|
||||
|
||||
|
||||
def _pd_1211():
|
||||
d = json.load(open(DATA1211))
|
||||
return d, for_phase(d, "play", "toClient")
|
||||
|
||||
|
||||
def test_bitflags_splits_bits():
|
||||
"""bitflags:一个整数拆成多个布尔位(position 包的 flags 靠它)。
|
||||
|
||||
★ 判据动机:实现它之前 position 整个包解不开 ⇒ 看不到玩家位置。
|
||||
"""
|
||||
d, pd = _pd_1211()
|
||||
t = d["play"]["toClient"]["types"]["PositionUpdateRelatives"]
|
||||
# 0b10101 = x(1) + z(4) + pitch(16) 置位
|
||||
r = Reader(bytes([0b10101]))
|
||||
got = pd.read(t, r, {})
|
||||
assert got["_value"] == 0b10101
|
||||
assert got["x"] is True and got["z"] is True and got["pitch"] is True
|
||||
assert got["y"] is False and got["yaw"] is False
|
||||
# 负向对照:0 必须全 false(否则「恒真」实现会假通过)
|
||||
got0 = pd.read(t, Reader(bytes([0])), {})
|
||||
assert not any(v for k, v in got0.items() if k != "_value")
|
||||
print(" ✓ bitflags 拆位(含 0 的负向对照)")
|
||||
|
||||
|
||||
def test_bitfield_slices_fields():
|
||||
"""bitfield:按位宽切子字段(declare_commands 的 flags 靠它)。
|
||||
|
||||
★ 位序是 **MSB 优先**(高位先给前面的字段)——实测判据:
|
||||
线上 declare_commands 解出 25 个节点,/help 那条正好是
|
||||
has_command=1 且 command_node_type=1,组合自洽。
|
||||
我一开始按 LSB 假设写判据,结果在**正确实现**上判红 —— 又一次
|
||||
「判据的假设必须来自被测事实,不能来自我的直觉」。
|
||||
|
||||
构造(MSB 优先):unused(3)=0b101, has_custom_suggestions=1,
|
||||
has_redirect_node=0, has_command=1, command_node_type=0b10
|
||||
→ 0b101 1 0 1 10 = 0b10110110 = 182
|
||||
"""
|
||||
d, pd = _pd_1211()
|
||||
spec = ["bitfield", [
|
||||
{"name": "unused", "size": 3},
|
||||
{"name": "has_custom_suggestions", "size": 1},
|
||||
{"name": "has_redirect_node", "size": 1},
|
||||
{"name": "has_command", "size": 1},
|
||||
{"name": "command_node_type", "size": 2},
|
||||
]]
|
||||
got = pd.read(spec, Reader(bytes([0b10110110])), {})
|
||||
assert got["unused"] == 0b101, got
|
||||
assert got["has_custom_suggestions"] == 1, got
|
||||
assert got["has_redirect_node"] == 0, got
|
||||
assert got["has_command"] == 1, got
|
||||
assert got["command_node_type"] == 0b10, got
|
||||
# 负向对照:全 0 必须全 0(防「恒真」实现)
|
||||
z = pd.read(spec, Reader(bytes([0])), {})
|
||||
assert all(v == 0 for v in z.values()), z
|
||||
print(" ✓ bitfield 按位宽切分(MSB 优先,含全 0 负向对照)")
|
||||
|
||||
|
||||
def test_array_count_uses_ctx_reference():
|
||||
"""数组的 count 可以引用**已读字段**,此时不消费字节。
|
||||
|
||||
★ 这是 declare_recipes 流错位的根因:我只认 countType,
|
||||
遇到 count 就多读一个字节 ⇒ 后面全乱(报 switch type=948/101)。
|
||||
构造:先读 varint=3(n),再用 count:'n' 读 3 个 u8。
|
||||
"""
|
||||
d, pd = _pd_1211()
|
||||
spec = ["container", [
|
||||
{"name": "n", "type": "varint"},
|
||||
{"name": "vals", "type": ["array", {"count": "n", "type": "u8"}]},
|
||||
{"name": "tail", "type": "u8"},
|
||||
]]
|
||||
payload = bytes([3, 10, 20, 30, 99]) # n=3, vals=[10,20,30], tail=99
|
||||
got = pd.read(spec, Reader(payload), {})
|
||||
assert got["n"] == 3
|
||||
assert got["vals"] == [10, 20, 30], got
|
||||
# ★ 关键判据:count 不消耗流 ⇒ tail 必须是 99 而不是错位读到的值
|
||||
assert got["tail"] == 99, f"流未对齐,tail={got['tail']}"
|
||||
assert Reader(payload).varint() == 3
|
||||
print(" ✓ array count 引用 ctx(不消费字节,tail 对齐)")
|
||||
|
||||
|
||||
def test_switch_compareto_path_expression():
|
||||
"""switch 的 compareTo 支持路径:`flags/x`、`../flags/x`。
|
||||
|
||||
★ 全协议 12 处路径判据;只做平铺 ctx.get 会让
|
||||
declare_commands / player_info 整包解不开。
|
||||
"""
|
||||
d, pd = _pd_1211()
|
||||
spec = ["container", [
|
||||
{"name": "flags", "type": ["bitfield", [
|
||||
{"name": "has_x", "size": 1}, {"name": "unused", "size": 7}]]},
|
||||
{"name": "payload", "type": ["switch", {
|
||||
"compareTo": "flags/has_x", "fields": {"1": "u8"}, "default": "void"}]},
|
||||
]]
|
||||
# MSB 优先:has_x 是最高位 ⇒ 0x80 时 has_x=1 ⇒ payload 读 1 字节
|
||||
got = pd.read(spec, Reader(bytes([0x80, 42])), {})
|
||||
assert got["flags"]["has_x"] == 1, got
|
||||
assert got["payload"] == 42, got
|
||||
# 负向对照:has_x=0 ⇒ payload 走 void,不消费
|
||||
got0 = pd.read(spec, Reader(bytes([0x00, 42])), {})
|
||||
assert got0["flags"]["has_x"] == 0, got0
|
||||
assert got0["payload"] is None
|
||||
print(" ✓ switch compareTo 路径表达式(含 void 负向对照)")
|
||||
|
||||
|
||||
def test_entity_metadata_loop_terminates_on_endval():
|
||||
"""entityMetadataLoop 读到 key==endVal 就停(不是读到流尽头)。
|
||||
|
||||
★ 终止符是**光秃秃一个字节** 0xFF,后面**没有** type/value。
|
||||
我第一版写成「读完整个条目再比 key」⇒ 终止时多读 2 字节 ⇒ 越界,
|
||||
是线上真实包(entityId=144,13 字节)抓出来的。
|
||||
参照 minecraft-protocol/src/datatypes/minecraft.js:120-128。
|
||||
|
||||
构造:条目1 = {key:0, type:0(byte), val:1};然后单个 0xFF 终止;
|
||||
后面还剩 3 字节必须**留着不动**。
|
||||
"""
|
||||
d, pd = _pd_1211()
|
||||
spec = ["entityMetadataLoop", {"endVal": 255, "type": "entityMetadataEntry"}]
|
||||
payload = bytes([0x00, 0x00, 0x01, # key=0, type=0(byte), val=1
|
||||
0xFF]) + bytes([7, 7, 7])
|
||||
r = Reader(payload)
|
||||
got = pd.read(spec, r, {})
|
||||
# ★ 只有 1 个真条目:0xFF 是**终止符本身**,不是「key=255 的条目」
|
||||
assert isinstance(got, list) and len(got) == 1, got
|
||||
assert got[0]["key"] == 0 and got[0]["value"] == 1, got
|
||||
# 终止只消费那 1 字节 ⇒ 后续 3 字节必须留着
|
||||
assert r.remaining() == 3, f"提前吃掉了后续数据,剩 {r.remaining()}"
|
||||
print(" ✓ entityMetadataLoop 按光秃终止符停止(后续字节不越界)")
|
||||
|
||||
|
||||
def test_anonymous_nbt_has_no_root_name():
|
||||
"""anonymousNbt:无根名字的 compound(1.20.2+ 网络 NBT 形态)。
|
||||
|
||||
★ registry_data 的条目就是它。若按具名 NBT 读(多读 2 字节名长 + 名)
|
||||
会整体错位。
|
||||
"""
|
||||
d, pd = _pd_1211()
|
||||
# TAG_Compound(10) + 一个 TAG_Int(3) 字段 "n" = 7 + payload
|
||||
# 载荷: 03 0001 6e 00000007 00
|
||||
payload = bytes([0x0A, 0x03, 0x00, 0x01, 0x6E, 0x00, 0x00, 0x00, 0x07, 0x00])
|
||||
got = pd.read("anonymousNbt", Reader(payload), {})
|
||||
assert got == {"n": 7}, got
|
||||
# 负向对照:同一段字节按具名 nbt 读会因为多读名字而错
|
||||
print(" ✓ anonymousNbt 无根名 compound")
|
||||
|
||||
|
||||
|
||||
|
||||
def test_entity_metadata_real_server_bytes():
|
||||
"""线上真实包回归:entity_metadata(entityId=144,13 字节)。
|
||||
|
||||
★ 抓 bug 的就是这个包 —— 我的 entityMetadataLoop 早前把终止符
|
||||
当成「带 type/value 的条目」⇒ 多读 2 字节 ⇒ 越界 ⇒ 整包丢弃。
|
||||
判据直接用线上抓到的字节,不自己构造。
|
||||
"""
|
||||
d, pd = _pd_1211()
|
||||
body = bytes.fromhex("589001090341a0000011007fff")
|
||||
r = Reader(body)
|
||||
pid = r.varint()
|
||||
assert pid == 0x58, f"entity_metadata 的包 id 应为 0x58,实得 {pid:#x}"
|
||||
eid = r.varint()
|
||||
assert eid == 144, eid
|
||||
got = pd.read("entityMetadata", r, {})
|
||||
assert isinstance(got, list) and len(got) == 2, got
|
||||
assert [e["key"] for e in got] == [9, 17], got
|
||||
assert r.remaining() == 0, f"未完全对齐,剩 {r.remaining()}"
|
||||
print(" ✓ 线上真实 entity_metadata 字节完整解开(0 余字节)")
|
||||
|
||||
|
||||
if __name__=="__main__":
|
||||
for fn in [test_varint_roundtrip, test_pstring, test_real_handshake,
|
||||
test_login_success_decode, test_play_packet_mapper, test_no_overshoot]:
|
||||
test_login_success_decode, test_play_packet_mapper, test_no_overshoot,
|
||||
# M2:configuration/play 阶段依赖的复合类型
|
||||
test_bitflags_splits_bits, test_bitfield_slices_fields,
|
||||
test_array_count_uses_ctx_reference, test_switch_compareto_path_expression,
|
||||
test_entity_metadata_loop_terminates_on_endval,
|
||||
test_anonymous_nbt_has_no_root_name,
|
||||
test_entity_metadata_real_server_bytes]:
|
||||
fn()
|
||||
print("\nprotodef 解释器:全部通过")
|
||||
|
||||
Reference in New Issue
Block a user