Skip to content

Commit 5d15cdf

Browse files
authored
Merge pull request #46 from Justin99x/master
Add Save Options Module
2 parents a1231e0 + 1ae707b commit 5d15cdf

File tree

4 files changed

+697
-0
lines changed

4 files changed

+697
-0
lines changed

src/save_options/__init__.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import save_options.hooks # noqa: F401 # pyright: ignore[reportUnusedImport]
2+
from mods_base.mod_list import base_mod
3+
from save_options.options import (
4+
BoolSaveOption,
5+
DropdownSaveOption,
6+
HiddenSaveOption,
7+
KeybindSaveOption,
8+
SaveOption,
9+
SliderSaveOption,
10+
SpinnerSaveOption,
11+
)
12+
from save_options.registration import register_save_options
13+
14+
__all__: tuple[str, ...] = (
15+
"BoolSaveOption",
16+
"DropdownSaveOption",
17+
"HiddenSaveOption",
18+
"KeybindSaveOption",
19+
"SaveOption",
20+
"SliderSaveOption",
21+
"SpinnerSaveOption",
22+
"__author__",
23+
"__version__",
24+
"__version_info__",
25+
"register_save_options",
26+
)
27+
28+
__version_info__: tuple[int, int] = (1, 0)
29+
__version__: str = f"{__version_info__[0]}.{__version_info__[1]}"
30+
__author__: str = "bl-sdk"
31+
32+
"""
33+
This library allows for any arbitrary JSON encodable data to be saved in the character's save
34+
file. You will manage the data that will be written to the save file by storing values in special
35+
SaveOption objects. These objects all inherit from ValueOption objects as defined in mods_base,
36+
but provide some additional functionality:
37+
38+
- Values from the options will be saved to and loaded from the character save files. If the option
39+
is also registered in the mod as a regular option (i.e., in Mod.options), the options will also
40+
save to the mod's settings file. These values will be loaded for any character that has not had
41+
any values saved yet. If you don't want a save option to be stored in the mod settings file, make
42+
sure it is not added to Mod.options.
43+
44+
- When no player save is available (main menu with no character), the options behave as button
45+
options, with a message showing that a player needs to be loaded.
46+
47+
If you have custom option subclasses, you can turn any of them into a save option simply by
48+
also inheriting from the SaveOption mixin class. Note you only need to do this for ValueOptions -
49+
the base GroupedOption/NestedOption classes are treated as containers already, and ButtonOptions
50+
would have no effect.
51+
52+
Two optional callbacks can be defined:
53+
- on_save: runs before the save file is written. You can use this callback to do any just-in-time
54+
updates of the save entries (e.g., get data from the WPC that you would like saved).
55+
56+
- on_load: runs immediately after regular save data is applied to the character upon loading into
57+
the game.
58+
59+
Example usage for saving anarchy stacks:
60+
```
61+
anarchy_save_option = HiddenSaveOption("anarchy", 0)
62+
def on_save():
63+
anarchy_save_option.value = get_anarchy_stacks()
64+
65+
def on_load():
66+
set_anarchy_stacks(anarchy_save_option.value)
67+
68+
mod = build_mod()
69+
register_save_options(mod)
70+
```
71+
"""
72+
73+
base_mod.components.append(base_mod.ComponentInfo("Save Options", __version__))

src/save_options/hooks.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import json
2+
from json import JSONDecodeError
3+
from typing import Any
4+
5+
from unrealsdk import logging, make_struct
6+
from unrealsdk.hooks import Type
7+
from unrealsdk.unreal import BoundFunction, UObject, WrappedStruct
8+
9+
import save_options.options
10+
from mods_base import JSON, get_pc, hook
11+
from save_options.options import trigger_save
12+
from save_options.registration import (
13+
ModSaveOptions,
14+
load_callbacks,
15+
registered_mods,
16+
registered_save_options,
17+
save_callbacks,
18+
)
19+
20+
# Value doesn't matter, just needs to be consistent and higher than any real DLC package ID
21+
_PACKAGE_ID: int = 99
22+
23+
24+
def _extract_save_data(
25+
lockout_list: list[WrappedStruct],
26+
) -> tuple[dict[str, dict[str, JSON]], list[WrappedStruct]]:
27+
"""
28+
Extracts custom save data from an UnloadableDlcLockoutList.
29+
30+
This function searches through the list for an entry matching the global `_PACKAGE_ID`.
31+
If found, it attempts to parse the `LockoutDefName` field as a JSON string into a dictionary.
32+
Invalid or malformed JSON will result in an empty dictionary and an error being logged.
33+
Any entries not matching the `_PACKAGE_ID` are preserved and returned as the second element
34+
of the tuple to avoid overwriting unrelated data.
35+
36+
Args:
37+
lockout_list: List of LockoutData structs from the character save file.
38+
39+
Returns:
40+
A tuple with two elements:
41+
- A dictionary of extracted save data (empty if none found or invalid)
42+
- A list of LockoutData structs for any entries not associated with our custom saves.
43+
"""
44+
45+
matching_lockout_data = next(
46+
(lockout_data for lockout_data in lockout_list if lockout_data.DlcPackageId == _PACKAGE_ID),
47+
None,
48+
)
49+
if not matching_lockout_data:
50+
return {}, lockout_list
51+
52+
# Preserve other package Ids on the off chance someone else uses this.
53+
lockout_list_other = [
54+
lockout_data for lockout_data in lockout_list if lockout_data.DlcPackageId != _PACKAGE_ID
55+
]
56+
57+
extracted_save_data: dict[str, dict[str, JSON]] = {}
58+
if matching_lockout_data.LockoutDefName:
59+
try:
60+
extracted_save_data = json.loads(matching_lockout_data.LockoutDefName)
61+
except JSONDecodeError:
62+
# Invalid data, just clear the contents
63+
logging.error("Error extracting custom save data from save file, invalid JSON found.")
64+
extracted_save_data = {}
65+
# Pylance saying this instance check unnecessary, but json.loads can return valid non-dict
66+
# objects.
67+
if not isinstance(extracted_save_data, dict): # pyright: ignore[reportUnnecessaryIsInstance]
68+
logging.error(
69+
f"Could not load dict object from custom save string:"
70+
f" {matching_lockout_data.LockoutDefName}",
71+
)
72+
extracted_save_data = {}
73+
return extracted_save_data, lockout_list_other
74+
75+
76+
@hook("WillowGame.WillowSaveGameManager:SaveGame", immediately_enable=True)
77+
def save_game(_1: UObject, args: WrappedStruct, _3: Any, _4: BoundFunction) -> None: # noqa: D103
78+
# We're going to inject our arbitrary save data here. This is the last time the save game can be
79+
# edited before writing to disk. Extremely large string sizes can crash the game, so we may want
80+
# to add a safety check at some point.1M characters has worked fine, so unlikely to be an issue.
81+
82+
# For callbacks, only process enabled mods and only when we're in game. We'll run these first so
83+
# mod can use it to set values on the save options.
84+
enabled_mods = [mod_id for mod_id, mod in registered_mods.items() if mod.is_enabled]
85+
86+
if get_pc().GetWillowPlayerPawn():
87+
for mod_id, callback in save_callbacks.items():
88+
if mod_id in enabled_mods:
89+
callback()
90+
91+
# For saving, we'll overwrite existing mod data for enabled mods. Any disabled/uninstalled mods
92+
# will have their data left alone.
93+
json_save_data, lockout_list = _extract_save_data(args.SaveGame.UnloadableDlcLockoutList)
94+
for mod_id, mod_data in registered_save_options.items():
95+
if mod_id in enabled_mods:
96+
mod_save_data = {
97+
identifier: option_json
98+
for identifier, save_option in mod_data.items()
99+
if (option_json := save_option._to_json()) is not ... # pyright: ignore[reportPrivateUsage]
100+
}
101+
try:
102+
# Only calling this to validate the types, so one mod failing doesn't break
103+
# everything below.
104+
_ = json.dumps(mod_save_data)
105+
json_save_data[mod_id] = mod_save_data
106+
except TypeError:
107+
logging.error(f"Could not write save-specific data for {mod_id}.")
108+
logging.dev_warning(f"Data is not json encodable: {mod_save_data}")
109+
110+
str_save_data = json.dumps(json_save_data)
111+
custom_lockout = make_struct(
112+
"UnloadableDlcLockoutData",
113+
LockoutDefName=str_save_data,
114+
DlcPackageId=_PACKAGE_ID,
115+
)
116+
lockout_list.append(custom_lockout)
117+
args.SaveGame.UnloadableDlcLockoutList = lockout_list
118+
119+
# Reset our var tracking whether any options have changed since last save.
120+
save_options.options.any_option_changed = False
121+
122+
123+
@hook("WillowGame.WillowSaveGameManager:EndLoadGame", Type.POST, immediately_enable=True)
124+
def end_load_game(_1: UObject, _2: WrappedStruct, ret: Any, _4: BoundFunction) -> None: # noqa: D103
125+
# We hook this to send data back to any registered mod save options. This gets called when
126+
# loading character in main menu also. No callback here because the timing of when this is
127+
# called doesn't make much sense to do anything with it. See hook on LoadPlayerSaveGame.
128+
129+
# This function returns the new save game object, so use a post hook and grab it from `ret`
130+
save_game = ret
131+
if not save_game:
132+
return
133+
extracted_save_data, _ = _extract_save_data(save_game.UnloadableDlcLockoutList)
134+
if not extracted_save_data:
135+
return
136+
137+
for mod_id, extracted_mod_data in extracted_save_data.items():
138+
mod_save_options: ModSaveOptions = registered_save_options[mod_id]
139+
for identifier, extracted_value in extracted_mod_data.items():
140+
if save_option := mod_save_options.get(identifier):
141+
save_option._from_json(extracted_value) # pyright: ignore[reportPrivateUsage]
142+
143+
# Resetting change tracking var here too. Obviously a load sets a bunch of options, but we don't
144+
# want to count that as a real change that needs to be saved.
145+
save_options.options.any_option_changed = False
146+
147+
148+
@hook(
149+
"WillowGame.WillowPlayerController:LoadPlayerSaveGame",
150+
Type.POST,
151+
immediately_enable=True,
152+
)
153+
def load_player_save_game(*_: Any) -> None: # noqa: D103
154+
# This function is responsible for applying all save data to the character on loading into a
155+
# map. We use it to run callbacks, with the intent that any save data a mod wants to apply to
156+
# the player can be done here. At this point, save options have already been populated with
157+
# data from the save file through the EndLoadGame hook.
158+
enabled_mods = [mod_id for mod_id, mod in registered_mods.items() if mod.is_enabled]
159+
160+
for mod_id, callback in load_callbacks.items():
161+
if mod_id in enabled_mods:
162+
callback()
163+
164+
165+
@hook("WillowGame.FrontendGFxMovie:HideOptionsMovie", immediately_enable=True)
166+
def hide_options_movie(*_: Any) -> None: # noqa: D103
167+
# When an options movie is closed, we check to see if any save option values have changed since
168+
# the last time the file was saved. If it has, we save the game. This is necessary since values
169+
# changed while in the main menu would get overwritten or just get lost if a new character were
170+
# selected.
171+
172+
if not save_options.options.any_option_changed:
173+
return
174+
175+
trigger_save()

0 commit comments

Comments
 (0)