|
| 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