Skip to content

Commit 03e2f59

Browse files
authored
Merge pull request #19 from apple1417/master
exodus fixups
2 parents 658f789 + 5a53707 commit 03e2f59

File tree

4 files changed

+215
-82
lines changed

4 files changed

+215
-82
lines changed

changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
### Legacy Compat v1.1
66
- Fixed that some legacy mods would not auto-enable properly.
7+
- Added a compat handler for object names - they didn't use to include the number. This previously
8+
caused crash when trying to load Exodus.
9+
- Added a fixup to Constructor, and Constructor-based mods, to handle the new mod folder location.
710

811
### [pyunrealsdk v1.5.2](https://github.com/bl-sdk/pyunrealsdk/blob/master/changelog.md#v152)
912
This version has no `pyunrealsdk` changes, it simply pulled in a new version of `unrealsdk`.

src/legacy_compat/__init__.py

Lines changed: 2 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -89,22 +89,19 @@ def add_compat_module(name: str, module: ModuleType) -> None: # pyright: ignore
8989
ENABLED = False # pyright: ignore[reportConstantRedefinition]
9090
else:
9191
import ctypes
92-
import inspect
9392
import sys
9493
import warnings
95-
from collections.abc import Iterator, Sequence
94+
from collections.abc import Iterator
9695
from contextlib import contextmanager
9796
from functools import wraps
98-
from importlib.machinery import ModuleSpec, SourceFileLoader
99-
from importlib.util import spec_from_file_location
100-
from pathlib import Path
10197

10298
from unrealsdk.hooks import prevent_hooking_direct_calls
10399

104100
from mods_base import MODS_DIR
105101

106102
from . import ModMenu
107103
from . import unrealsdk as old_unrealsdk
104+
from .meta_path_finder import LegacyCompatMetaPathFinder
108105

109106
base_mod.components.append(base_mod.ComponentInfo("Legacy SDK Compat", __version__))
110107

@@ -161,65 +158,6 @@ def add_compat_module(name: str, module: ModuleType) -> None: # pyright: ignore
161158
"Mods.UserFeedback.ctypes": ctypes,
162159
}
163160

164-
# On top of this, we do actually need a full import hook to redirect other mod-specific imports
165-
166-
class LegacyCompatMetaPathFinder:
167-
@staticmethod
168-
def get_importing_file() -> Path:
169-
"""
170-
Gets the file which triggered the in progress import.
171-
172-
Returns:
173-
The importing file.
174-
"""
175-
# Skip the frame for this function and find_spec below
176-
for frame in inspect.stack()[2:]:
177-
# Then skip everything in the import machinery
178-
if "importlib" in frame.filename:
179-
continue
180-
return Path(frame.filename)
181-
raise RuntimeError
182-
183-
@classmethod
184-
def find_spec(
185-
cls,
186-
fullname: str,
187-
path: Sequence[str] | None = None, # noqa: ARG003
188-
target: ModuleType | None = None, # noqa: ARG003
189-
) -> ModuleSpec | None:
190-
# EridiumLib adds it's dist folder with a path relative to the executable - fix that
191-
# We also have some problems with it's copy of requests, so redirect that to our copy
192-
if fullname == "requests" and cls.get_importing_file().parent.name == "EridiumLib":
193-
# Can't easily load the real requests, but turns out all we actually need is a get
194-
# method, which is allowed to just throw
195-
# Using a custom loader to inject it rather than loading from file, since the latter
196-
# doesn't work properly if we're packaged inside a .sdkmod
197-
class FakeRequestsLoader(SourceFileLoader):
198-
def get_data(self, path: str) -> bytes: # noqa: ARG002
199-
return (
200-
b"def get(url: str, timeout: int) -> str: # noqa: D103\n"
201-
b" raise NotImplementedError"
202-
)
203-
204-
return spec_from_file_location(
205-
"Mods.EridiumLib.fake_dist.requests",
206-
"<fake location>",
207-
loader=FakeRequestsLoader(
208-
"Mods.EridiumLib.fake_dist.requests",
209-
"<fake location>",
210-
),
211-
)
212-
if (
213-
fullname == "semver"
214-
and (mod_folder := cls.get_importing_file().parent).name == "EridiumLib"
215-
):
216-
return spec_from_file_location(
217-
"Mods.EridiumLib.fake_dist.semver",
218-
mod_folder / "dist" / "semver.py",
219-
)
220-
221-
return None
222-
223161
@contextmanager
224162
def import_compat_handler() -> Iterator[None]:
225163
"""Context manager to add the import compatibility."""
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import inspect
2+
import re
3+
from collections.abc import Callable, Sequence
4+
from importlib.abc import FileLoader, SourceLoader
5+
from importlib.machinery import ModuleSpec, PathFinder, SourceFileLoader
6+
from importlib.util import spec_from_file_location
7+
from pathlib import Path
8+
from types import ModuleType
9+
10+
# While just messing with `Mod.__path__` is enough for most most mods, there are a few we need to do
11+
# more advanced import hooking on.
12+
13+
14+
class StringSourceLoader(SourceFileLoader):
15+
source: bytes
16+
17+
def __init__(self, fullname: str, path: str, source: bytes) -> None:
18+
super().__init__(fullname, path)
19+
self.source = source
20+
21+
def get_data(self, path: str) -> bytes: # noqa: ARG002, D102
22+
return self.source
23+
24+
25+
def spec_from_string(fullname: str, source: bytes) -> ModuleSpec | None:
26+
"""
27+
Creates a module spec from a hardcoded string.
28+
29+
Args:
30+
fullname: The fully resolved module name.
31+
source: The source code.
32+
"""
33+
return spec_from_file_location(
34+
fullname,
35+
"<fake location>",
36+
loader=StringSourceLoader(fullname, "<fake location>", source),
37+
)
38+
39+
40+
# Inheriting straight from SourceFileLoade causes some other machinery to expect bytecode?
41+
class ReplacementSourceLoader(FileLoader, SourceLoader): # type: ignore
42+
type Pattern = tuple[bytes | re.Pattern[bytes], bytes | Callable[[re.Match[bytes]], bytes]]
43+
44+
replacements: Sequence[Pattern]
45+
46+
def __init__(self, fullname: str, path: str, replacements: Sequence[Pattern]) -> None:
47+
super().__init__(fullname, path)
48+
self.replacements = replacements
49+
50+
def get_data(self, path: str) -> bytes: # noqa: D102
51+
data = Path(path).read_bytes()
52+
for pattern, replacement in self.replacements:
53+
data = re.sub(pattern, replacement, data)
54+
return data
55+
56+
57+
def spec_with_replacements(
58+
fullname: str,
59+
path: Sequence[str] | None = None,
60+
target: ModuleType | None = None,
61+
*replacements: ReplacementSourceLoader.Pattern,
62+
) -> ModuleSpec | None:
63+
"""
64+
Creates a module spec based on the existing module, but applying a set of regex replacements.
65+
66+
Args:
67+
fullname: The fully resolved module name, copied from the original call.
68+
path: The paths to search, copied from the original call.
69+
target: The target copied from the original call.
70+
*replacements: Tuples of a regex pattern and it's replacement.
71+
"""
72+
original_spec = PathFinder.find_spec(fullname, path, target)
73+
if original_spec is None or not original_spec.has_location or original_spec.origin is None:
74+
return None
75+
76+
return spec_from_file_location(
77+
fullname,
78+
original_spec.origin,
79+
loader=ReplacementSourceLoader(fullname, original_spec.origin, replacements),
80+
)
81+
82+
83+
class LegacyCompatMetaPathFinder:
84+
@staticmethod
85+
def get_importing_file() -> Path:
86+
"""
87+
Gets the file which triggered the in progress import.
88+
89+
Returns:
90+
The importing file.
91+
"""
92+
# Skip the frame for this function and find_spec below
93+
for frame in inspect.stack()[2:]:
94+
# Then skip everything in the import machinery
95+
if "importlib" in frame.filename:
96+
continue
97+
return Path(frame.filename)
98+
raise RuntimeError
99+
100+
@classmethod
101+
def find_spec( # noqa: D102
102+
cls,
103+
fullname: str,
104+
path: Sequence[str] | None = None,
105+
target: ModuleType | None = None,
106+
) -> ModuleSpec | None:
107+
importing_file = cls.get_importing_file()
108+
109+
match importing_file.parent.name, fullname:
110+
# EridiumLib adds it's dist folder with a path relative to the executable - fix that
111+
case "EridiumLib", "semver":
112+
return spec_from_file_location(
113+
"Mods.EridiumLib.fake_dist.semver",
114+
importing_file.parent / "dist" / "semver.py",
115+
)
116+
117+
# Something about trying to load a requests build from 6 major versions ago completely
118+
# breaks, we can't easily get it to load.
119+
# However, it turns out all EridiumLib needs is a get method, which is allowed to just
120+
# throw.
121+
case "EridiumLib", "requests":
122+
return spec_from_string(
123+
"Mods.EridiumLib.fake_dist.requests",
124+
(
125+
b"def get(url: str, timeout: int) -> str: # noqa: D103\n"
126+
b" raise NotImplementedError"
127+
),
128+
)
129+
130+
# Constructor has a bad bit of logic for getting the relative file path to execute.
131+
# Since we moved the mods folder, it breaks. Replace it with some proper logic.
132+
case (
133+
("Constructor", "Mods.Constructor.hotfix_manager")
134+
| ("Exodus", "Mods.Exodus.hotfix_manager")
135+
| ("ExodusTPS", "Mods.ExodusTPS.hotfix_manager")
136+
| ("Snowbound", "Mods.Snowbound.hotfix_manager")
137+
):
138+
return spec_with_replacements(
139+
fullname,
140+
path,
141+
target,
142+
# Import on one line just to avoid changing line numbers
143+
(b"import os", b"import os, sys"),
144+
(
145+
rb'( +)exec_path = str\(file\)\.split\("Binaries\\+", 1\)\[1\]([\r\n]+)'
146+
rb'( +)bl2tools.console_command\("exec " \+ exec_path, False\)([\r\n]+)',
147+
rb"\1exec_path = os.path.relpath(file,"
148+
rb' os.path.join(sys.executable, "..", ".."))\2'
149+
rb"\3bl2tools.console_command(f'exec \"{exec_path}\"', False)\4",
150+
),
151+
)
152+
153+
case _, _:
154+
return None

src/legacy_compat/unrealsdk/__init__.py

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# ruff: noqa: N802, N803, D102, D103, N999
22

33
import inspect
4+
import re
45
from collections.abc import Callable, Iterator, Sequence
56
from contextlib import contextmanager
67
from dataclasses import dataclass
@@ -263,10 +264,13 @@ def KeepAlive(obj: UObject, /) -> None:
263264
- The legacy sdk returned None instead of throwing an attribute error when a field didn't exist.
264265
- The legacy sdk had interface properties return an FScriptInterface struct, but since you only ever
265266
accessed the object, the new sdk just returns it directly. We need to return the struct instead.
267+
- In the legacy sdk, name properties did not include the number when converted to a string, which we
268+
need to strip out.
266269
267270
UObject:
268271
- The `ObjectFlags` field on objects used to be split into the upper and lower 32 bits (B and A
269272
respectively), new sdk returns a single 64 bit int. Return a proxy object instead.
273+
- The `Name` field is also a name property, so needs have the suffix stripped as above.
270274
- In the legacy sdk the __repr__ of objects was just kind of wrong, but we need to replicate it.
271275
272276
BoundFunction:
@@ -340,6 +344,21 @@ def _convert_struct_tuple_if_required(prop: UProperty, value: Any) -> Any:
340344
return value
341345

342346

347+
_RE_NAME_SUFFIX = re.compile(r"^(.+)_\d+$")
348+
349+
350+
def _strip_name_property_suffix(name: str) -> str:
351+
"""
352+
Tries to strip the numeric suffix from a name property.
353+
354+
Args:
355+
name: The input name.
356+
Returns:
357+
The stripped name.
358+
"""
359+
return match.group(1) if (match := _RE_NAME_SUFFIX.match(name)) else name
360+
361+
343362
@wraps(UObject.__getattr__)
344363
def _uobject_getattr(self: UObject, name: str) -> Any:
345364
try:
@@ -349,10 +368,13 @@ def _uobject_getattr(self: UObject, name: str) -> Any:
349368

350369
value = self._get_field(prop)
351370

352-
if isinstance(prop, UInterfaceProperty):
353-
return FScriptInterface(value)
354-
355-
return value
371+
match prop:
372+
case UInterfaceProperty():
373+
return FScriptInterface(value)
374+
case UNameProperty():
375+
return _strip_name_property_suffix(value)
376+
case _:
377+
return value
356378

357379

358380
@dataclass
@@ -384,12 +406,16 @@ def __int__(self) -> int:
384406
return _default_object_getattribute(self._obj, "ObjectFlags")
385407

386408

387-
# Because we want to overwrite an exiting field, we have to use getattribute over getattr
409+
# Because we want to overwrite exiting fields, we have to use getattribute over getattr
388410
@wraps(UObject.__getattribute__)
389411
def _uobject_getattribute(self: UObject, name: str) -> Any:
390-
if name == "ObjectFlags":
391-
return _ObjectFlagsProxy(self)
392-
return _default_object_getattribute(self, name)
412+
match name:
413+
case "ObjectFlags":
414+
return _ObjectFlagsProxy(self)
415+
case "Name":
416+
return _strip_name_property_suffix(_default_object_getattribute(self, "Name"))
417+
case _:
418+
return _default_object_getattribute(self, name)
393419

394420

395421
@wraps(UObject.__setattr__)
@@ -422,10 +448,13 @@ def _struct_getattr(self: WrappedStruct, name: str) -> Any:
422448

423449
value = self._get_field(prop)
424450

425-
if isinstance(prop, UInterfaceProperty):
426-
return FScriptInterface(value)
427-
428-
return value
451+
match prop:
452+
case UInterfaceProperty():
453+
return FScriptInterface(value)
454+
case UNameProperty():
455+
return _strip_name_property_suffix(value)
456+
case _:
457+
return value
429458

430459

431460
@wraps(WrappedStruct.__setattr__)
@@ -443,14 +472,23 @@ def _struct_setattr(self: WrappedStruct, name: str, value: Any) -> None:
443472
def _array_getitem[T](self: WrappedArray[T], idx: int | slice) -> T | list[T]:
444473
value = _default_array_getitem(self, idx)
445474

446-
if not isinstance(self._type, UInterfaceProperty):
447-
return value
475+
match self._type:
476+
case UInterfaceProperty():
477+
if isinstance(idx, slice):
478+
val_seq: Sequence[T] = value # type: ignore
479+
return [FScriptInterface(x) for x in val_seq] # type: ignore
448480

449-
if isinstance(idx, slice):
450-
val_seq: Sequence[T] = value # type: ignore
451-
return [FScriptInterface(x) for x in val_seq] # type: ignore
481+
return FScriptInterface(value) # type: ignore
482+
case UNameProperty():
483+
if isinstance(idx, slice):
484+
val_seq: Sequence[T] = value # type: ignore
485+
return [_strip_name_property_suffix(x) for x in val_seq] # type: ignore
452486

453-
return FScriptInterface(value) # type: ignore
487+
return _strip_name_property_suffix(value) # type: ignore
488+
489+
return _strip_name_property_suffix(value)
490+
case _:
491+
return value
454492

455493

456494
@wraps(WrappedArray[Any].__setitem__)

0 commit comments

Comments
 (0)