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 19 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
183 changes: 183 additions & 0 deletions mcstatus/forge_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""Forge Data Decoder
PerchunPak marked this conversation as resolved.
Show resolved Hide resolved

Forge mod data is encoded into a UTF-16 string that represents
PerchunPak marked this conversation as resolved.
Show resolved Hide resolved
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.

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/42115d37d6a46856e3dc914b54a1ce6d33b9872a/src/main/java/net/minecraftforge/network/ServerStatusPing.java
PerchunPak marked this conversation as resolved.
Show resolved Hide resolved
"""

from __future__ import annotations

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

from mcstatus.protocol.connection import Connection

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


if TYPE_CHECKING:
from typing_extensions import NotRequired, Self, TypedDict

class ForgeDataChannel(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 ForgeDataMod(TypedDict):
modid: str
modmarker: str
"""Mod version"""
PerchunPak marked this conversation as resolved.
Show resolved Hide resolved

class RawForgeData(TypedDict):
fmlNetworkVersion: int # noqa: N815
PerchunPak marked this conversation as resolved.
Show resolved Hide resolved
channels: list[ForgeDataChannel]
mods: list[ForgeDataMod]
d: NotRequired[str]
truncated: NotRequired[bool]

else:
ForgeDataChannel = dict
ForgeDataMod = dict
RawForgeData = dict


@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?"""

@classmethod
def build(cls, raw: RawForgeData) -> Self:
"""Build :class:`ForgeData` from raw response :class:`dict`.

:param raw: Raw forge data response :class:`dict`.
:return: :class:`ForgeData` object.
PerchunPak marked this conversation as resolved.
Show resolved Hide resolved
"""
raw.setdefault("fmlNetworkVersion", 0)
raw.setdefault("channels", [])
raw.setdefault("mods", [])
PerchunPak marked this conversation as resolved.
Show resolved Hide resolved
return decode_forge_data(raw)


def decode_optimized(string: str) -> Connection:
PerchunPak marked this conversation as resolved.
Show resolved Hide resolved
"""Decode buffer UTF-16 optimized binary data from `string`."""
text = io.StringIO(string)

def read() -> int:
result = text.read(1)
if not result:
return 0
return ord(result)

size = read() | (read() << 15)

buffer = Connection()
value, bits = 0, 0
for _ in range(len(string) - 2):
while bits >= 8:
buffer.receive((value & 0xFF).to_bytes(length=1, byteorder="big", signed=False))
value >>= 8
bits -= 8
value |= (read() & 0x7FFF) << bits
bits += 15

while buffer.remaining() < size:
buffer.receive((value & 0xFF).to_bytes(length=1, byteorder="big", signed=False))
value >>= 8
bits -= 8
return buffer


def decode_forge_data(response: RawForgeData) -> ForgeData:
PerchunPak marked this conversation as resolved.
Show resolved Hide resolved
"""Decode the encoded forge data if it exists."""

if "d" not in response:
PerchunPak marked this conversation as resolved.
Show resolved Hide resolved
return ForgeData(
fml_network_version=response["fmlNetworkVersion"],
channels=response["channels"],
mods=response["mods"],
truncated=False,
)

buffer = decode_optimized(response["d"])

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

truncated = buffer.read_bool()
mod_size = buffer.read_ushort()
try:
for _ in range(mod_size):
channel_version_flags = buffer.read_varint()

channel_size = 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()

for _ in range(channel_size):
name = buffer.read_utf()
version = buffer.read_utf()
client_required = buffer.read_bool()
channels.append(
ForgeDataChannel(
{
"res": f"{mod_id}:{name}",
"version": version,
"required": client_required,
}
)
)

mods.append(
ForgeDataMod(
{
"modid": mod_id,
"modmarker": mod_version,
}
)
)

non_mod_channel_size = buffer.read_varint()
for _ in range(non_mod_channel_size):
channel_identifier = buffer.read_utf()
version = buffer.read_utf()
client_required = buffer.read_bool()
channels.append(
ForgeDataChannel(
{
"res": channel_identifier,
"version": version,
"required": client_required,
}
)
)
except IOError:
if not truncated:
raise
# Semi-expect errors if truncated, we are missing data

return ForgeData(
fml_network_version=response["fmlNetworkVersion"],
channels=channels,
mods=mods,
truncated=truncated,
)
5 changes: 5 additions & 0 deletions mcstatus/status_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from dataclasses import dataclass
from typing import Any, TYPE_CHECKING

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

if TYPE_CHECKING:
Expand Down Expand Up @@ -41,6 +42,7 @@ class RawJavaResponse(TypedDict):
players: RawJavaResponsePlayers
version: RawJavaResponseVersion
favicon: NotRequired[str]
forgeData: NotRequired[RawForgeData] # noqa: N815

else:
RawJavaResponsePlayer = dict
Expand Down Expand Up @@ -114,6 +116,8 @@ class JavaStatusResponse(BaseStatusResponse):

.. seealso:: :ref:`pages/faq:how to get server image?`
"""
forge_data: ForgeData | None
"""Forge mod data (mod list, channels, etc) if the server is modded"""
CoolCat467 marked this conversation as resolved.
Show resolved Hide resolved

@classmethod
def build(cls, raw: RawJavaResponse, latency: float = 0) -> Self:
Expand All @@ -134,6 +138,7 @@ def build(cls, raw: RawJavaResponse, latency: float = 0) -> Self:
motd=Motd.parse(raw["description"], bedrock=False),
icon=raw.get("favicon"),
latency=latency,
forge_data=ForgeData.build(raw["forgeData"]) if "forgeData" in raw else None,
)

@property
Expand Down
Loading