Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Forge Data Decoder #578

Merged
merged 72 commits into from
Feb 7, 2024
Merged
Show file tree
Hide file tree
Changes from 63 commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
1cb30dd
Start working on forge data handling
CoolCat467 Jul 2, 2023
e2e1d1c
Implement forge data decoder
CoolCat467 Jul 2, 2023
92d13a1
Add tests for forge data decoding
CoolCat467 Jul 2, 2023
55d0c4f
Fix syntax
CoolCat467 Jul 2, 2023
8cf8d4b
Add periods to docstrings
CoolCat467 Jul 2, 2023
412d6e8
Rename JavaForge things to Forge, Forge implies Java
CoolCat467 Jul 2, 2023
7236865
Forgot prefix in tests
CoolCat467 Jul 2, 2023
4e408b6
Apply suggestions from code review
CoolCat467 Jul 2, 2023
d74df88
Complete more fixes from @PerchunPak 's review
CoolCat467 Jul 2, 2023
77d47c5
Try to raise coverage
CoolCat467 Jul 2, 2023
ee2a7fe
Fix test forge data
CoolCat467 Jul 2, 2023
efce932
Fix for python 3.8
CoolCat467 Jul 2, 2023
b4a552f
Remove old code comment
CoolCat467 Jul 2, 2023
95c738a
Add missing `TYPE_CHECKING` import
CoolCat467 Jul 2, 2023
8fb287b
Fix type issues
CoolCat467 Jul 2, 2023
1cbccc4
More type fixes
CoolCat467 Jul 2, 2023
bd4a34e
Flake8 fixes
CoolCat467 Jul 2, 2023
b7e9020
Attempt to fix line too long warnings
CoolCat467 Jul 2, 2023
9d1b799
Fix line too long after formatting
CoolCat467 Jul 2, 2023
720e7f7
Apply suggestions from code review
CoolCat467 Jul 2, 2023
0eb8031
Move some functions around and remove duplicate test coverage
CoolCat467 Jul 2, 2023
0ea7af0
Move forge data decoding into build
CoolCat467 Jul 2, 2023
82cfbaf
Move `decode_optimized` into `ForgeData`
CoolCat467 Jul 2, 2023
8ec22a4
Forgot to call from class
CoolCat467 Jul 2, 2023
b06aeb7
Ignore line with url being too long
CoolCat467 Jul 2, 2023
486bcb2
Merge branch 'py-mine:master' into forge-data
CoolCat467 Jul 2, 2023
ce03427
Apply suggestions from code review
CoolCat467 Jul 3, 2023
8750994
Don't use get, they should exist
CoolCat467 Jul 4, 2023
8ccf57a
Apply suggestions from code review
CoolCat467 Jul 15, 2023
02b932d
Change module docstring from suggestion by @PerchunPak
CoolCat467 Jul 16, 2023
626854f
Add test for older response
CoolCat467 Jul 16, 2023
cb07c11
Make `ForgeModData` and `ForgeModChannel` into dataclasses
CoolCat467 Jul 16, 2023
c6372a0
Remove unused imports
CoolCat467 Jul 16, 2023
02914eb
Seperate forge channel and mod decoding into their new respective dat…
CoolCat467 Jul 16, 2023
64ee784
Initial work on string buffer
CoolCat467 Jul 16, 2023
9025ce8
Update mcstatus/forge_data.py
CoolCat467 Jul 16, 2023
6ca01f7
Add support for older FML network versions
CoolCat467 Jul 16, 2023
bc6863c
Fix modid handling
CoolCat467 Jul 16, 2023
32faf69
Ignore mixed case from network json that we can't change
CoolCat467 Jul 16, 2023
7d53e3a
Apply suggestions from code review
CoolCat467 Jul 17, 2023
1be4554
Implement tests and fixes for older versions of FML network
CoolCat467 Jul 18, 2023
200c834
Rename `ForgeMod` attributes to be more pythonic
CoolCat467 Jul 18, 2023
5c7ac18
Fix type errors
CoolCat467 Jul 18, 2023
ee1872b
Fix function annotation
CoolCat467 Jul 18, 2023
1942b56
Fix type issues
CoolCat467 Jul 18, 2023
93c9ab4
Apply suggestions from code review
CoolCat467 Jul 18, 2023
b103235
Rename attributes on `ForgeDataMod` and `ForgeDataChannel`
CoolCat467 Jul 18, 2023
77d98e7
Merge branch 'py-mine:master' into forge-data
CoolCat467 Sep 21, 2023
76f728b
Formatting
CoolCat467 Sep 21, 2023
80a4bc5
Update mcstatus/forge_data.py
CoolCat467 Oct 1, 2023
7675af1
Remove old comments
CoolCat467 Oct 1, 2023
740503f
Handle reading shorts with StringBuffer
CoolCat467 Oct 1, 2023
89b85d9
Pyright complains arguments with defaults aren't set
CoolCat467 Oct 1, 2023
7cd51b6
update quotes
CoolCat467 Oct 1, 2023
534c366
Move reading code to `StringBuffer`
CoolCat467 Oct 3, 2023
b4f30cf
Change to `StringIO` instead of `IO[str]`
CoolCat467 Oct 3, 2023
7427d64
Merge branch 'py-mine:master' into forge-data
CoolCat467 Dec 12, 2023
4d10d66
Fix fixtures not being imported directly anymore
CoolCat467 Dec 12, 2023
962e300
Rename mod `version` attribute to `marker` and fix type issues
CoolCat467 Dec 13, 2023
d20faf1
Update mcstatus/forge_data.py
CoolCat467 Dec 13, 2023
9297e14
Update tests/status_response/test_java.py
CoolCat467 Dec 13, 2023
fdf64b0
KeyError and remove explicit re-export
CoolCat467 Dec 13, 2023
a906893
Rest of MOTD parse changes and autofixes
CoolCat467 Dec 13, 2023
e2358a7
Update tests/status_response/test_java.py
CoolCat467 Dec 14, 2023
69a1834
Merge branch 'py-mine:master' into forge-data
CoolCat467 Dec 16, 2023
8bc466d
Merge branch 'master' into forge-data
PerchunPak Feb 5, 2024
809ab4a
Apply suggestions from code review
PerchunPak Feb 5, 2024
94c0020
Subclass tests from base class, instead of Java one
PerchunPak Feb 5, 2024
6290644
Move tests to its own file
PerchunPak Feb 5, 2024
1620f7d
Add type ignores to satisfy pyright
PerchunPak Feb 5, 2024
6f8cbd9
Add channels to tests
PerchunPak Feb 5, 2024
505ccab
Add `@pytest.fixture` to build methods in tests
PerchunPak Feb 5, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 260 additions & 0 deletions mcstatus/forge_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
"""Decoder for data from Forge, that is included into a response object.

After 1.18.1, Forge started to compress its mod data into a
UTF-16 string that represents binary data containing data like
the forge mod loader network version, a big list of channels
that all the forge mods use, and a list of mods the server has.

Before 1.18.1, the mod data was in `forgeData` attribute inside
a response object. We support this implementation too.

PerchunPak marked this conversation as resolved.
Show resolved Hide resolved
For more information see this file from forge itself:
https://github.com/MinecraftForge/MinecraftForge/blob/54b08d2711a15418130694342a3fe9a5dfe005d2/src/main/java/net/minecraftforge/network/ServerStatusPing.java#L27-L73
"""

from __future__ import annotations

from dataclasses import dataclass
from io import StringIO
from typing import Final, TYPE_CHECKING

from mcstatus.protocol.connection import BaseConnection, BaseReadSync, Connection

VERSION_FLAG_IGNORE_SERVER_ONLY: Final = 0b1
IGNORE_SERVER_ONLY: Final = "<not required for client>"


if TYPE_CHECKING:
from typing_extensions import Self, TypedDict

class RawForgeDataChannel(TypedDict):
res: str
"""Channel name and ID (for example ``fml:handshake``)."""
version: str
"""Channel version (for example ``1.2.3.4``)."""
required: bool
"""Is this channel required for client to join?"""

class RawForgeDataMod(TypedDict, total=False):
modid: str
modId: str
modmarker: str
"""Mod version."""
version: str

class RawForgeData(TypedDict, total=False):
fmlNetworkVersion: int
channels: list[RawForgeDataChannel]
mods: list[RawForgeDataMod]
modList: list[RawForgeDataMod]
d: str
truncated: bool

else:
RawForgeDataChannel = dict
RawForgeDataMod = dict
RawForgeData = dict


@dataclass
class ForgeDataChannel:
name: str
"""Channel name and ID (for example ``fml:handshake``)."""
version: str
"""Channel version (for example ``1.2.3.4``)."""
required: bool
"""Is this channel required for client to join?"""

@classmethod
def build(cls, raw: RawForgeDataChannel) -> Self:
"""Build an object about Forge channel from raw response.

:param raw: ``channel`` element in raw forge response :class:`dict`.
:return: :class:`ForgeDataChannel` object.
"""
return cls(name=raw["res"], version=raw["version"], required=raw["required"])

@classmethod
def decode(cls, buffer: Connection, mod_id: str | None = None) -> Self:
"""Decode an object about Forge channel from decoded optimized buffer.

:param buffer: :class:`Connection` object from UTF-16 encoded binary data.
:param mod_id: Optional mod id prefix :class:`str`.
:return: :class:`ForgeDataChannel` object.
"""
channel_identifier = buffer.read_utf()
if mod_id is not None:
channel_identifier = f"{mod_id}:{channel_identifier}"
version = buffer.read_utf()
client_required = buffer.read_bool()

return cls(
name=channel_identifier,
version=version,
required=client_required,
)


@dataclass
class ForgeDataMod:
name: str
marker: str

@classmethod
def build(cls, raw: RawForgeDataMod) -> Self:
"""Build an object about Forge mod from raw response.

:param raw: ``mod`` element in raw forge response :class:`dict`.
:return: :class:`ForgeDataMod` object.
"""
# In FML v1, modmarker was version instead.
mod_version = raw.get("modmarker") or raw.get("version")
if mod_version is None:
raise ValueError("Mod version in Forge mod data must be provided.")

# In FML v2, modid was modId instead. At least one of the two should exist.
mod_id = raw.get("modid") or raw.get("modId")
if mod_id is None:
raise ValueError(f"Mod ID in Forge mod data must be provided. Mod version: {mod_version!r}.")

return cls(name=mod_id, marker=mod_version)

@classmethod
def decode(cls, buffer: Connection) -> tuple[Self, list[ForgeDataChannel]]:
"""Decode data about a Forge mod from decoded optimized buffer.

:param buffer: :class:`Connection` object from UTF-16 encoded binary data.
:return: :class:`tuple` object of :class:`ForgeDataMod` object and :class:`list` of :class:`ForgeDataChannel` objects.
"""
channel_version_flags = buffer.read_varint()

channel_count = channel_version_flags >> 1
is_server = channel_version_flags & VERSION_FLAG_IGNORE_SERVER_ONLY != 0
mod_id = buffer.read_utf()

mod_version = IGNORE_SERVER_ONLY
if not is_server:
mod_version = buffer.read_utf()

channels = []
for _ in range(channel_count):
channels.append(ForgeDataChannel.decode(buffer, mod_id))

return cls(name=mod_id, marker=mod_version), channels


class StringBuffer(BaseReadSync, BaseConnection):
"""String Buffer for reading utf-16 encoded binary data."""

__slots__ = ("stringio", "received")

def __init__(self, stringio: StringIO) -> None:
self.stringio = stringio
self.received = bytearray()

def read(self, length: int) -> bytearray:
"""Read length bytes from ``self``, and return a byte array."""
data = bytearray()
while self.received and len(data) < length:
data.append(self.received.pop(0))
while len(data) < length:
result = self.stringio.read(1)
if not result:
raise IOError(f"Not enough data to read! {len(data)} < {length}")
data.extend(result.encode("utf-16be"))
while len(data) > length:
self.received.append(data.pop())
return data

def remaining(self) -> int:
"""Return number of reads remaining."""
return len(self.stringio.getvalue()) - self.stringio.tell() + len(self.received)

def read_optimized_size(self) -> int:
"""Read encoded data length."""
return self.read_short() | (self.read_short() << 15)

def read_optimized_buffer(self) -> Connection:
"""Read encoded buffer."""
size = self.read_optimized_size()

buffer = Connection()
value, bits = 0, 0
while buffer.remaining() < size:
if bits < 8 and self.remaining():
# Ignoring sign bit
value |= (self.read_short() & 0x7FFF) << bits
bits += 15
buffer.receive((value & 0xFF).to_bytes(1, "big"))
value >>= 8
bits -= 8

return buffer


@dataclass
class ForgeData:
fml_network_version: int
"""Forge Mod Loader network version."""
channels: list[ForgeDataChannel]
"""List of channels, both for mods and non-mods."""
mods: list[ForgeDataMod]
PerchunPak marked this conversation as resolved.
Show resolved Hide resolved
"""List of mods"""
truncated: bool
"""Is the mods list and or channel list incomplete?"""

@staticmethod
def _decode_optimized(string: str) -> Connection:
"""Decode buffer from UTF-16 optimized binary data ``string``."""
with StringIO(string) as text:
str_buffer = StringBuffer(text)
return str_buffer.read_optimized_buffer()

@classmethod
def build(cls, raw: RawForgeData) -> Self | None:
"""Build an object about Forge mods from raw response.

:param raw: ``forgeData`` attribute in raw response :class:`dict`.
:return: :class:`ForgeData` object.
"""
fml_network_version = raw.get("fmlNetworkVersion", 1)

# see https://github.com/MinecraftForge/MinecraftForge/blob/7d0330eb08299935714e34ac651a293e2609aa86/src/main/java/net/minecraftforge/network/ServerStatusPing.java#L27-L73 # noqa: E501 # line too long
if "d" not in raw:
mod_list = raw.get("mods") or raw.get("modList")
if mod_list is None:
raise KeyError("Neither `mods` or `modList` keys exist.")
return cls(
fml_network_version=fml_network_version,
channels=[ForgeDataChannel.build(channel) for channel in raw.get("channels", ())],
mods=[ForgeDataMod.build(mod) for mod in mod_list],
truncated=False,
)

buffer = cls._decode_optimized(raw["d"])

channels: list[ForgeDataChannel] = []
mods: list[ForgeDataMod] = []

truncated = buffer.read_bool()
mod_count = buffer.read_ushort()
try:
for _ in range(mod_count):
mod, mod_channels = ForgeDataMod.decode(buffer)

channels.extend(mod_channels)
mods.append(mod)

non_mod_channel_count = buffer.read_varint()
for _ in range(non_mod_channel_count):
channels.append(ForgeDataChannel.decode(buffer))
except IOError:
if not truncated:
raise # If answer wasn't truncated, we lost some data on the way

return cls(
fml_network_version=fml_network_version,
channels=channels,
mods=mods,
truncated=truncated,
)
4 changes: 2 additions & 2 deletions mcstatus/motd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def parse(
"""
original_raw = raw.copy() if hasattr(raw, "copy") else raw # type: ignore # Cannot access "copy" for type "str"
if isinstance(raw, list):
raw: RawJavaResponseMotdWhenDict = {"extra": raw}
raw: RawJavaResponseMotdWhenDict = {"extra": raw} # type: ignore[no-redef]
PerchunPak marked this conversation as resolved.
Show resolved Hide resolved

if isinstance(raw, str):
parsed = cls._parse_as_str(raw, bedrock=bedrock)
Expand Down Expand Up @@ -183,7 +183,7 @@ def simplify(self) -> Self:
parsed = [el for index, el in enumerate(parsed) if index not in unused_elements]

parsed = squash_nearby_strings(parsed)
return __class__(parsed, self.raw, bedrock=self.bedrock)
return self.__class__(parsed, self.raw, bedrock=self.bedrock)

def to_plain(self) -> str:
"""Get plain text from a MOTD, without any colors/formatting.
Expand Down
2 changes: 1 addition & 1 deletion mcstatus/querier.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ async def read_query(self) -> QueryResponse:
class QueryResponse:
"""Documentation for this class is written by hand, without docstrings.

This is because the class is not supposted to be auto-documented.
This is because the class is not supposed to be auto-documented.

Please see https://mcstatus.readthedocs.io/en/latest/api/basic/#mcstatus.querier.QueryResponse
for the actual documentation.
Expand Down
16 changes: 15 additions & 1 deletion mcstatus/status_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from dataclasses import dataclass
from typing import Any, TYPE_CHECKING

from mcstatus.forge_data import ForgeData as ForgeData
from mcstatus.forge_data import RawForgeData
from mcstatus.motd import Motd

if TYPE_CHECKING:
Expand Down Expand Up @@ -41,6 +43,9 @@ class RawJavaResponse(TypedDict):
players: RawJavaResponsePlayers
version: RawJavaResponseVersion
favicon: NotRequired[str]
forgeData: NotRequired[RawForgeData]
modinfo: NotRequired[RawForgeData]
enforcesSecureChat: NotRequired[bool]
PerchunPak marked this conversation as resolved.
Show resolved Hide resolved

else:
RawJavaResponsePlayer = dict
Expand Down Expand Up @@ -88,7 +93,7 @@ def description(self) -> str:

@classmethod
@abstractmethod
def build(cls, *args, **kwargs) -> Self:
def build(cls, *args: object, **kwargs: object) -> Self:
"""Build BaseStatusResponse and check is it valid.

:param args: Arguments in specific realisation.
Expand Down Expand Up @@ -123,6 +128,8 @@ class JavaStatusResponse(BaseStatusResponse):

.. seealso:: :ref:`pages/faq:how to get server image?`
"""
forge_data: ForgeData | None
"""Forge mod data (mod list, channels, etc). Only present if this is a forge (modded) server."""

@classmethod
def build(cls, raw: RawJavaResponse, latency: float = 0) -> Self:
Expand All @@ -136,6 +143,12 @@ def build(cls, raw: RawJavaResponse, latency: float = 0) -> Self:
``description`` - :class:`str`) are not of the expected type.
:return: :class:`JavaStatusResponse` object.
"""
forge_data: ForgeData | None = None
if "forgeData" in raw or "modinfo" in raw:
raw_forge = raw.get("forgeData") or raw.get("modinfo")
assert raw_forge is not None
forge_data = ForgeData.build(raw_forge)

return cls(
raw=raw,
players=JavaStatusPlayers.build(raw["players"]),
Expand All @@ -144,6 +157,7 @@ def build(cls, raw: RawJavaResponse, latency: float = 0) -> Self:
enforces_secure_chat=raw.get("enforcesSecureChat"),
icon=raw.get("favicon"),
latency=latency,
forge_data=forge_data,
PerchunPak marked this conversation as resolved.
Show resolved Hide resolved
)

@property
Expand Down
Loading
Loading