├── screenshot.png ├── .gitignore ├── README.md └── holocure_save_tool.py /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aclich/Holocure_save_editor/HEAD/screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | venv/ 3 | release/ 4 | __pycache__/ 5 | *.dat 6 | *.json 7 | *.ini 8 | *.zip 9 | *.ipynb 10 | *.ico 11 | dist/ 12 | build/ 13 | *.spec 14 | test/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Holocure Save Tool 2 | A simple save Tool for Holocure fan game. 3 | Download (exe): https://github.com/aclich/Holocure_save_editor/releases/download/v0.0.3-C/HoloCure_Save_Tool_v0.0.3_C.zip 4 | - Support game version 0.4.1663293877 5 | - Add scroll bar in editor. (issue https://github.com/aclich/Holocure_save_editor/issues/6#issue-1376130532) 6 | - Fix Issue https://github.com/aclich/Holocure_save_editor/issues/8 7 | 8 | 9 | ### Known Issue: 10 | - Not compatible with older game version of save. 11 | 12 | ### v0.0.3 Expected Update: 13 | - Basic compatible with previous save version. 🚀 14 | - Optimize UI ⏸️ 15 | 16 | #### Virus detected issue 17 | The released exe is packed by pyinstaller, and it will cause **false positives** in some anti-virus software. Please don't worry if you downloaded or used it. To see my full explanation, please refer to https://github.com/aclich/Holocure_save_editor/issues/4#issuecomment-1174153928. 18 | 19 | ## Feature: 20 | #### Save editing: 21 | - Holocoin 22 | - Shop items 23 | - Charactor gacha level 24 | - Tears 25 | - Lock, Unlock items/weapons/collabs/outfits/stages 26 | 27 | #### Save Inheritance (Transfer): 28 | - Help player Inherit save from other PC to current PC. 29 | 30 | #### Screenshot: 31 | 32 | 33 | ## How to Use (exe) 34 | 0. Select which tool to use `(editor or Inheritance tool)` 35 | - Editor: 36 | 1. Select the save file (Default path: 'C:\\Users\\\\AppData\\Local\\HoloCure\\save.dat') 37 | 2. Change the data in editor 38 | 3. Save the data and replace the original save file (please manually backup the original save file) 39 | - Inheritance Tool: 40 | 1. Select save from other PC (Or the save you want to inheritance) 41 | 2. Select save in current PC (Already selected in default, make sure the save file exists. If not, run the game first.) 42 | 3. Click Run. 43 | 44 | ## Run from source code 45 | ### Requirements 46 | - python > 3.6 47 | ### Steps 48 | 0. Install python if haven't (version > 3.6) 49 | 1. git clone https://github.com/aclich/Holocure_save_editor.git 50 | 2. cd Holocure_save_editor 51 | 3. python holocure_save_editor.py 52 | 53 | - - - 54 | 55 | ***Note** 56 | Build Command: 57 | >``` 58 | >pyinstaller.exe --upx-dir --noconsole --onefile --name HoloCure_Save_Tool_{VERSION} --icon .\holocure_save_tool.py --clean 59 | >``` -------------------------------------------------------------------------------- /holocure_save_tool.py: -------------------------------------------------------------------------------- 1 | VERSION = '0.0.3-C' 2 | ABOUT_MSG=f'''HoloCure bad bad Save Tool 3 | Version: {VERSION} 4 | Date: 2022/09/18 5 | Author: Aclich 6 | Require: python > 3.6, tkinter 7 | Source code: https://github.com/aclich/Holocure_save_editor 8 | 9 | Change Log: 10 | - Update for new save content🤯. 11 | 12 | Tested Game version 13 | 0.4.1663293877 14 | 0.4.1662728581 15 | 16 | Known issue: 17 | Not compatible with older version of save file. 18 | ''' 19 | 20 | 21 | import json, base64, os 22 | import tkinter as tk 23 | from tkinter import CENTER, DISABLED, messagebox 24 | from tkinter.filedialog import askopenfilename, asksaveasfilename 25 | from typing import List, Tuple 26 | 27 | LVL_KEYS = [] #['characters', 'tears'] 28 | NUMB_KEYS = [] 29 | CHK_KEYS = ['specUnlock', 'refund', 'challenge', 'GROff', 'growth'] 30 | UNKNOW_KEYS = ['unlockedCharacters', 'eliminate', 'completedStages'] 31 | LIST_MAP = {'unlockedItems': ['BodyPillow', 'FullMeal', 'PikiPikiPiman', 'SuccubusHorn', 'Headphones', 'UberSheep', 32 | 'HolyMilk', 'Sake', 'FaceMask', 'CreditCard', 'GorillasPaw', 'InjectionAsacoco', 33 | 'IdolCostume', 'Plushie', 'StudyGlasses', 'SuperChattoTime', 'EnergyDrink', 'Halu', 34 | 'Membership', 'GWSPill', 'ChickensFeather', 'Bandaid', 'Limiter', 'PiggyBank'], 35 | 'unlockedWeapons': ['PsychoAxe', 'Glowstick', 'SpiderCooking', 'Tailplug', 'BLBook', 'EliteLava', 36 | 'HoloBomb', 'HoloLaser', 'CuttingBoard', 'IdolSong', 'CEOTears', 'WamyWater', 37 | 'XPotato'], 38 | 'seenCollabs': ['BreatheInAsacoco', 'DragonBeam', 'EliteCooking', 'FlatBoard', 39 | 'MiComet', 'BLLover', 'LightBeam', 'IdolConcert', 'StreamOfTears', 40 | 'MariLamy', 'BrokenDreams', 'RapDog'], 41 | 'unlockedStages': ['STAGE 1', 'STAGE 2', 'STAGE 1 (HARD)'], 42 | 'unlockedOutfits': ['default', 'ameAlt1', 'kiaraAlt1', 'ameAlt1', 'inaAlt1', 'guraAlt1', 'calliAlt1', 43 | 'kiaraAlt1', 'irysAlt1', 'baeAlt1', 'sanaAlt1', 'faunaAlt1', 'mumeiAlt1', 'kroniiAlt1', 44 | 'kurokami'] 45 | } 46 | ORIG_LIST_MAP, ORIG_CHK_KEYS = LIST_MAP, CHK_KEYS 47 | GEOMETRY='+600+200' 48 | InitPath = os.path.join(os.environ['LOCALAPPDATA'], 'HoloCure') 49 | AskSavePath = lambda init_path=InitPath: askopenfilename(title='select save data', 50 | initialdir=init_path, 51 | filetypes=[['.dat save', '*.dat']]) 52 | 53 | def PopError(func): 54 | def wrap(self: tk.Toplevel, *args, **kwargs): 55 | _return = None 56 | try: 57 | _return = func(*args, **kwargs) 58 | except Exception as e: 59 | messagebox.showerror(title=e.__class__.__name__, message=f'{e}') 60 | self.deiconify() 61 | return _return 62 | return wrap 63 | 64 | class SaveEditor(object): 65 | def __init__(self) -> None: 66 | pass 67 | 68 | def _update_keys(self) -> None: 69 | global NUMB_KEYS, LVL_KEYS, LIST_MAP, UNKNOW_KEYS 70 | NUMB_KEYS, LVL_KEYS, LIST_MAP, CHK_KEYS = [], [], ORIG_LIST_MAP, ORIG_CHK_KEYS 71 | for k, v in self.save_js.items(): 72 | if isinstance(v, list) and len(v) > 0 and all((isinstance(l, list) and isinstance(l[1], (float, int)) for l in v)): 73 | LVL_KEYS += [k] 74 | elif k not in (*LIST_MAP.keys(), *CHK_KEYS, *UNKNOW_KEYS, *LVL_KEYS) and isinstance(v, (float, int)): 75 | NUMB_KEYS += [k] 76 | elif k not in (*LVL_KEYS, *LIST_MAP) and isinstance(v, list) and len(v) and isinstance(v[0], str): 77 | LIST_MAP.update({k:v}) 78 | else: 79 | UNKNOW_KEYS += [k] 80 | 81 | for rm_key in [k for k in LIST_MAP if k not in self.save_js]: 82 | _ = LIST_MAP.pop(rm_key, None) 83 | 84 | for rm_key in [k for k in CHK_KEYS if k not in self.save_js]: 85 | _ = CHK_KEYS.remove(rm_key) 86 | 87 | def load_file(self, file_path: str) -> Tuple[str, int, dict]: 88 | self._decrypt_str = ''.join([chr(b) for b in base64.b64decode(open(file_path, 'rb').read())]) 89 | self._trunc_point = self._decrypt_str.find('{"') if self._decrypt_str.find('{ "') == -1 else self._decrypt_str.find('{ "') 90 | self.save_js = json.loads(self._decrypt_str[self._trunc_point:self._decrypt_str.rfind('}')+1]) 91 | self._update_keys() 92 | return self._decrypt_str, self._trunc_point, self.save_js 93 | 94 | def save_file(self, file_path: str): 95 | out_str = f"{self._decrypt_str[:self._trunc_point]}{json.dumps(self.save_js)}" 96 | open(file_path, 'wb+').write(base64.b64encode(bytes((ord(s) for s in out_str)))) 97 | 98 | def inerit_save(self, orig_path: str, curr_path: str): 99 | _, _, orig_save_js = self.load_file(orig_path) 100 | self._decrypt_str, self._trunc_point, _ = self.load_file(curr_path) 101 | self.save_js = orig_save_js 102 | self.save_file(curr_path) 103 | 104 | class mainApp(tk.Tk): 105 | def __init__(self): 106 | super().__init__() 107 | self.title(f'HoloCure Save Tool {VERSION}') 108 | self.resizable(0, 0) 109 | self.geometry(GEOMETRY) 110 | self._create_component() 111 | self._layout() 112 | 113 | def _create_component(self): 114 | self.editor_btn = tk.Button(self, text="Save Editor", command=lambda: editorPage(self)) 115 | self.inherit_btn = tk.Button(self, text="Save Inheritance", command=lambda: saveInheritPage(self)) 116 | self.about_btn = tk.Button(self, text='About', command=lambda: aboutpop(self)) 117 | 118 | def _layout(self): 119 | for btn in (self.editor_btn, self.inherit_btn, self.about_btn): 120 | btn.pack(padx=120, pady=(10, 10)) 121 | 122 | class editorPage(tk.Toplevel): 123 | def __init__(self, mainapp:mainApp, **kwargs): 124 | super().__init__(mainapp, **kwargs) 125 | self.title('Holocure Save Editor') 126 | self.editor = SaveEditor() 127 | self._open_save(self) 128 | if self.file_path == '': 129 | self.destroy() 130 | return 131 | self.geometry(f"690x750{GEOMETRY}") 132 | self.resizable(0, 100) 133 | 134 | ### Mousewheel Events 135 | def _bind_mouse_wheel(self, event): 136 | self.bind_all("", self._on_mouse_wheel) 137 | 138 | def _unbind_mouse_wheel(self, event): 139 | self.canvas.unbind_all('') 140 | 141 | def _on_mouse_wheel(self, event): 142 | self.canvas.yview_scroll(-1 * int((event.delta / 120)), "units") 143 | ### End of Mousewheel Events 144 | 145 | def _create_component(self): 146 | ###Create Scrollable Frame 147 | self.ground_frame = tk.Frame(self) 148 | self.ground_frame.pack(fill=tk.BOTH, expand=1) 149 | 150 | self.canvas = tk.Canvas(self.ground_frame) 151 | self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=1) 152 | 153 | self.vertical_bar = tk.Scrollbar(self.ground_frame, orient=tk.VERTICAL, command=self.canvas.yview) 154 | self.vertical_bar.pack(side=tk.RIGHT, fill=tk.Y) 155 | 156 | self.canvas.configure(yscrollcommand=self.vertical_bar.set) 157 | self.canvas.bind('', lambda e: self.canvas.configure(scrollregion=self.canvas.bbox('all'))) 158 | 159 | self.scroll_frame = tk.Frame(self.canvas) 160 | self.canvas.create_window((0,0), window=self.scroll_frame, anchor='nw') 161 | 162 | self.canvas.bind('', self._bind_mouse_wheel) 163 | self.canvas.bind('', self._unbind_mouse_wheel) 164 | ###End of Create Scrollable Frame 165 | 166 | self.mskframe = miskFrame(self) 167 | self.chkframes = [unlockFrame(self, key_name=k) for k in LIST_MAP.keys()] 168 | self.LevelFrames = [LevelFrame(self, key_name=k) for k in LVL_KEYS] 169 | 170 | self.open_btn = tk.Button(self.scroll_frame, text='Open', justify=CENTER, command=lambda: self._open_save(self)) 171 | self.save_btn = tk.Button(self.scroll_frame, text='Save', justify=CENTER, command=lambda: self._save_as(self)) 172 | 173 | def _layout(self): 174 | t_col, _row = 2, 0 175 | self.mskframe.grid(column=0, columnspan=t_col, row=_row, sticky='news', pady=(0,10)) 176 | for _row, frame in enumerate(self.chkframes, 1): 177 | frame.grid(column=0, columnspan=t_col, row=_row, sticky='news', pady=(0,5)) 178 | for _row, frame in enumerate(self.LevelFrames, _row+1): 179 | frame.grid(column=0, columnspan=t_col, row=_row, sticky='news', pady=(0,10)) 180 | self.open_btn.grid(column=0, row=_row+1) 181 | self.save_btn.grid(column=1, row=_row+1, pady=10) 182 | self.withdraw() 183 | self.deiconify() 184 | 185 | @PopError 186 | def _open_save(self): 187 | self.file_path = AskSavePath() 188 | if self.file_path == '': 189 | return 190 | self.editor.load_file(self.file_path) 191 | if hasattr(self, 'ground_frame'): 192 | self.ground_frame.destroy() 193 | self._create_component() 194 | self._layout() 195 | 196 | @PopError 197 | def _save_as(self): 198 | save_file_path = asksaveasfilename(defaultextension='.dat', 199 | initialdir=os.path.dirname(os.path.abspath(self.file_path)), 200 | initialfile='save.dat', 201 | filetypes=[['.dat save', '*.dat']]) 202 | #misk data 203 | self.editor.save_js.update({key: float(var.get()) for key, var in self.mskframe.var_map.items()}) 204 | 205 | #unlocks data 206 | self.editor.save_js.update({frame.key_name: [key for key, val in frame.check_map.items() if val.get()] 207 | for frame in self.chkframes}) 208 | 209 | #Levels data 210 | self.editor.save_js.update({frame.key_name: [[chr, float(lv.get())] for chr, lv in frame.chr_var.items()] 211 | for frame in self.LevelFrames}) 212 | 213 | self.editor.save_file(save_file_path) 214 | messagebox.showinfo(title='info', message=f"Saved!\n raw_data:\n{self.editor.save_js}") 215 | 216 | class miskFrame(tk.Frame): 217 | def __init__(self, parent: editorPage, **kwargs): 218 | super().__init__(parent.scroll_frame, **kwargs) 219 | self.parent = parent 220 | self.vcmd = (self.register(self._valid_num), '%P') 221 | self._create_component() 222 | self._layout() 223 | 224 | def _create_component(self): 225 | self.title_label = tk.Label(self, text='HoloCure Save Editor', justify=CENTER, bg='#36C6FF') 226 | 227 | self.var_map = {k: tk.IntVar(value=self.parent.editor.save_js[k]) 228 | for k in [*NUMB_KEYS, *CHK_KEYS]} 229 | self.lb_map = {k: tk.Label(self, text=f'{k}: ') for k in NUMB_KEYS} 230 | self.en_map = {k: tk.Entry(self, textvariable=self.var_map[k], 231 | validatecommand=self.vcmd, width=5) for k in NUMB_KEYS} 232 | self.ck_map = {k: tk.Checkbutton(self, text=f'{k}', 233 | variable=self.var_map[k], onvalue=1.0, offvalue=0.0) for k in CHK_KEYS} 234 | 235 | def _layout(self): 236 | col = row = 0 237 | self.title_label.grid(column=col, row=row, columnspan=100, sticky='nwes') 238 | row += 1 239 | for i, key in zip(range(0,len(self.lb_map)*2, 2), self.lb_map): 240 | col, _row = i%10, i//10+row 241 | self.lb_map[key].grid(column=col, row=_row, sticky='e', pady=5) 242 | self.en_map[key].grid(column=col+1, row=_row, sticky='w', padx=(0, 10), pady=5) 243 | col, row = 0, _row+1 244 | for i, key in enumerate(self.ck_map): 245 | self.ck_map[key].grid(column=i%6, row=row+i//6, columnspan=1, sticky='w', ipady=10) 246 | 247 | def _valid_num(self, P): 248 | if str.isdigit(P): 249 | return True 250 | return False 251 | 252 | class unlockFrame(tk.Frame): 253 | def __init__(self, parent: editorPage, key_name:str, **kwargs): 254 | super().__init__(parent.scroll_frame, **kwargs) 255 | self.key_name = key_name 256 | self.parent = parent 257 | self.items_name = LIST_MAP[key_name] 258 | self._create_component() 259 | self._layout() 260 | 261 | def _create_component(self): 262 | self.sub_lb = tk.Label(self, text=f"{self.key_name}:") 263 | self.check_map = {k:tk.IntVar(value=1 if k in self.parent.editor.save_js[self.key_name] else 0) 264 | for k in self.items_name} 265 | self.ck_map = {k:tk.Checkbutton(self, text=k, variable=self.check_map[k], onvalue=1, offvalue=0) for k in self.items_name} 266 | 267 | def _layout(self): 268 | col = row = 0 269 | self.sub_lb.grid(column=col,columnspan=3 ,row=row, sticky="nsw") 270 | row += 1 271 | for i, check_box in enumerate(self.ck_map.values()): 272 | check_box.grid(column=i%6, row=row+i//6, sticky='w', pady=(0,3), padx=(0, 5)) 273 | 274 | class LevelFrame(tk.Frame): 275 | def __init__(self, parent:editorPage, key_name:str, **kwargs): 276 | super().__init__(parent.scroll_frame, **kwargs) 277 | self.parent, self.key_name = parent, key_name 278 | self.chr_list: list = self.parent.editor.save_js[key_name] 279 | self._create_component() 280 | self._layout() 281 | 282 | def _create_component(self): 283 | self.sub_lb = tk.Label(self, text=self.key_name) 284 | self.chr_var = {k: tk.IntVar(value=lv) for k, lv in self.chr_list} 285 | self.chr_lb = {k:tk.Label(self, text=f"{k}: ") for k, _ in self.chr_list} 286 | self.chr_ent = {k: tk.Entry(self, width=5, 287 | textvariable=self.chr_var[k]) for k, _ in self.chr_list} 288 | 289 | def _layout(self): 290 | row = col = 0 291 | self.sub_lb.grid(column=col, columnspan=3, row=row, sticky='w') 292 | row += 1 293 | for i, chr in zip(range(0,len(self.chr_lb)*2,2), self.chr_lb): 294 | col, _row = i%14, i//14+row 295 | self.chr_lb[chr].grid(column=col, row=_row, sticky='w', pady=(0,5)) 296 | self.chr_ent[chr].grid(column=col+1, row=_row, sticky='e', pady=(0,5), padx=(0,5)) 297 | 298 | class saveInheritPage(tk.Toplevel): 299 | def __init__(self, mainapp:mainApp, **kwargs): 300 | super().__init__(mainapp, **kwargs) 301 | self.title('Save Inheritace') 302 | self.geometry(GEOMETRY) 303 | self.resizable(0, 0) 304 | self.editor = SaveEditor() 305 | self._create_component() 306 | self._layout() 307 | 308 | def _create_component(self): 309 | self.var_map = {k: tk.StringVar(value=v) 310 | for k, v in {'orig': '', 311 | 'curr': os.path.join(InitPath, 'save.dat').replace('\\', '/') 312 | }.items() 313 | } 314 | self.lb_map = {k: tk.Label(self, textvariable=self.var_map[k]) for k in {'orig', 'curr'}} 315 | self.open_orig_btn = tk.Button(self, text='Select Save From Other PC', 316 | command=lambda: self._select_save('orig'), width=25) 317 | self.open_curr_btn = tk.Button(self, text='Select Save In This PC', 318 | command=lambda: self._select_save('curr'), width=25) 319 | self.inherit_btn = tk.Button(self, text='Run', command=lambda: self._run_inherit(self)) 320 | self.btn_map = { 321 | 'orig': self.open_orig_btn, 322 | 'curr': self.open_curr_btn 323 | } 324 | 325 | def _layout(self): 326 | for i, k in enumerate(('orig','curr')): 327 | self.btn_map[k].grid(column=0, row=i, sticky='nsw', pady=(10,0), padx=(10,0)) 328 | self.lb_map[k].grid(column=1, row=i, sticky='nsw', pady=(10,0), padx=(5,10)) 329 | self.inherit_btn.grid(column=0, row=2, sticky='nsw', pady=(10,10), padx=10) 330 | 331 | def _select_save(self, key: str): 332 | _file_path = AskSavePath(self.var_map[key].get()) if self.var_map[key].get() else AskSavePath() 333 | self.var_map[key].set(_file_path) 334 | self.deiconify() 335 | 336 | @PopError 337 | def _run_inherit(self): 338 | _orig_save = self.var_map['orig'].get() 339 | _curr_save = self.var_map['curr'].get() 340 | self.editor.inerit_save(_orig_save, _curr_save) 341 | messagebox.showinfo(title='Inheritence success', message=f'Success!\nRaw data:\n{self.editor.save_js}') 342 | 343 | class aboutpop(tk.Toplevel): 344 | def __init__(self, mainapp, **kwargs): 345 | super().__init__(mainapp, **kwargs) 346 | self.title('About') 347 | self.geometry(GEOMETRY) 348 | self.resizable(0, 0) 349 | self._create_component() 350 | self._layout() 351 | 352 | def _create_component(self): 353 | self.txt = tk.Text(self, wrap='word', height=len(ABOUT_MSG.split('\n')), bg=mainapp.cget('bg'), font=('Arial', 10)) 354 | self.txt.insert('end', ABOUT_MSG) 355 | self.txt.config(state=DISABLED) 356 | self.ok_btn = tk.Button(self, text='OK', command=lambda: self.destroy()) 357 | 358 | def _layout(self): 359 | self.txt.pack() 360 | self.ok_btn.pack(anchor=CENTER) 361 | 362 | if __name__ == "__main__": 363 | mainapp = mainApp() 364 | mainapp.mainloop() --------------------------------------------------------------------------------