Last active
October 18, 2025 21:23
-
-
Save linusnorton/6dbc771a2cb92f6ff4acf7ec4388ab98 to your computer and use it in GitHub Desktop.
Dawn of War save game editor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| import sys, struct, re, curses | |
| from pathlib import Path | |
| def find_title_location(b: bytes): | |
| """Find the save title using the length-prefixed UTF-16LE format.""" | |
| # Search for reasonable title patterns - length prefix followed by UTF-16LE text | |
| for i in range(len(b) - 20): | |
| # Check for a small length value (1-32 chars typical for titles) | |
| if i + 4 > len(b): | |
| continue | |
| length = struct.unpack_from('<I', b, i)[0] | |
| if not (1 <= length <= 32): | |
| continue | |
| # Check if we have enough space for the title | |
| title_start = i + 4 | |
| title_bytes = length * 2 # UTF-16LE = 2 bytes per char | |
| if title_start + title_bytes + 2 > len(b): | |
| continue | |
| # Try to decode as UTF-16LE | |
| try: | |
| title_data = b[title_start:title_start + title_bytes] | |
| title = title_data.decode('utf-16le') | |
| # Check if it's valid ASCII text (common for titles) | |
| if all(32 <= ord(c) <= 126 for c in title): | |
| # Check for the marker after the title | |
| marker_pos = title_start + title_bytes | |
| has_marker = False | |
| if marker_pos + 4 <= len(b): | |
| if b[marker_pos:marker_pos+2] == b'\x01\x00' and b[marker_pos+2:marker_pos+4] == b'\x00\x00': | |
| has_marker = True | |
| elif b[marker_pos:marker_pos+2] == b'\x00\x00': | |
| has_marker = False | |
| else: | |
| continue # Invalid termination | |
| return i, length, title, has_marker | |
| except: | |
| continue | |
| return None, None, None, None | |
| HERO_ANCHORS = { | |
| "force_commander": r"sm_force_commander|force_commander", | |
| "tarkus": r"sm_tactical_marine", | |
| "avitus": r"sm_devastator_marine", | |
| "thaddeus": r"sm_assault_marine", | |
| "cyrus": r"sm_scout_marine", | |
| } | |
| # empirically derived xp relative offsets (bytes) from anchor | |
| HERO_XP_REL_OFF = { | |
| "force_commander": 18, | |
| "tarkus": 25, | |
| "avitus": 28, | |
| "thaddeus": 26, | |
| "cyrus": 21, | |
| } | |
| # Known trait offsets (relative to anchor) discovered from actual save analysis | |
| KNOWN_TRAITS_REL = { | |
| "force_commander": 23, | |
| "tarkus": 30, | |
| "avitus": 33, | |
| "thaddeus": 31, | |
| "cyrus": 26, | |
| } | |
| SEARCH_RADIUS = 4096 # bytes around anchor to auto-discover traits when needed | |
| # Trait index mapping: save file stores [Stamina, Strength, Ranged, Will] | |
| # but we want to display as [Stamina, Ranged, Strength, Will] (indices [0,2,1,3]) | |
| TRAIT_DISPLAY_ORDER = [0, 2, 1, 3] # Maps display position to save file index | |
| TRAIT_SAVE_ORDER = [0, 2, 1, 3] # Maps save file position to display index | |
| # ASCII Art for title and logo | |
| DAWN_OF_WAR_LOGO = [ | |
| " ██████╗ ██████╗ ██╗ ██╗", | |
| " ██╔══██╗██╔═══██╗██║ ██║", | |
| " ██║ ██║██║ ██║██║ █╗ ██║", | |
| " ██║ ██║██║ ██║██║███╗██║", | |
| " ██████╔╝╚██████╔╝╚███╔███╔╝", | |
| " ╚═════╝ ╚═════╝ ╚══╝╚══╝ " | |
| ] | |
| TITLE_ART = [ | |
| "██████╗ █████╗ ██╗ ██╗███╗ ██╗ ██████╗ ███████╗ ██╗ ██╗ █████╗ ██████╗ ██╗██╗", | |
| "██╔══██╗██╔══██╗██║ ██║████╗ ██║ ██╔═══██╗██╔════╝ ██║ ██║██╔══██╗██╔══██╗ ██║██║", | |
| "██║ ██║███████║██║ █╗ ██║██╔██╗ ██║ ██║ ██║█████╗ ██║ █╗ ██║███████║██████╔╝ ██║██║", | |
| "██║ ██║██╔══██║██║███╗██║██║╚██╗██║ ██║ ██║██╔══╝ ██║███╗██║██╔══██║██╔══██╗ ██║██║", | |
| "██████╔╝██║ ██║╚███╔███╔╝██║ ╚████║ ╚██████╔╝██║ ╚███╔███╔╝██║ ██║██║ ██║ ██║██║", | |
| "╚═════╝ ╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝╚═╝", | |
| "", | |
| " █▀▀ ▄▀█ █░█ █▀▀ █▀▀ █▀▄ █ ▀█▀ █▀█ █▀█", | |
| " ▄▄█ █▀█ ▀▄▀ ██▄ ██▄ █▄▀ █ ░█░ █▄█ █▀▄" | |
| ] | |
| # ASCII Art for each character - actual faces from Dawn of War II portraits | |
| CHARACTER_ART = { | |
| "force_commander": [ | |
| " ╔═════════════════════╗", | |
| " ║ FORCE COMMANDER ║", | |
| " ╚═════════════════════╝", | |
| "", | |
| " %%%%%@%%%%%@%%###**@", | |
| " #%%%%%#%@@@@@%%%%@@@", | |
| " %%%%#*#####=:*%@@@@@", | |
| " @@@@**##*=-. :@@%%%@", | |
| " @@@@###=-=+**=#@@@@%", | |
| " @@@@#%*=-+=-=.+@@@%%", | |
| " @@%%%%%%%%#=--*%%@@@", | |
| " @%%@@%@%%%%*++#%%%@@", | |
| " %%%@@%%%%%%#**%%%%#%", | |
| " %%%@@@@@%%%##%@@%%%#", | |
| " @@%%@@@@@@@@@@@@%%##", | |
| " %@@@%%%%%%%%%%%%%%%%" | |
| ], | |
| "tarkus": [ | |
| " ╔════════════╗", | |
| " ║ TARKUS ║", | |
| " ╚════════════╝", | |
| "", | |
| " %@@%%%%%%%%%@@@%%%%%", | |
| " *++#%%%+=-:-+##%%%#%", | |
| " *=+#%##*=:...-#%%%%%", | |
| " *+######*-:..-*##%@@", | |
| " #**#@#*##+-:-*#%**%%", | |
| " %##%@%##%##*+*###=*%", | |
| " @%#%@%#%%*+*==###++#", | |
| " @%##@%@%%%***+%#**+#", | |
| " %%#*%%%@@@%##%%+=#*#", | |
| " %##*#%%@@@@@@#++#**#", | |
| " %###**##%%%#*+#%#+*%", | |
| " %###%#*****##%%##*#%", | |
| " %%%##%%%%%%%%%%%%%%@" | |
| ], | |
| "avitus": [ | |
| " ╔════════════╗", | |
| " ║ AVITUS ║", | |
| " ╚════════════╝", | |
| "", | |
| " @@@@@@@@@@@@@@@@@@@@", | |
| " %%%@@%#*+=++#@@@@@@@", | |
| " #++%#**=-:::-+***#%@", | |
| " *+###*+-. .-+++***%", | |
| " ###%%#*=::.-==*+***@", | |
| " %#%%%##**=++-=#+##*@", | |
| " ##%%%#*=+:-.:+@+#%#%", | |
| " %%%%%%#**+--=#%+###%", | |
| " @#%#%%##**+*#*#*%%%@", | |
| " @####%#%%%%@%**%%%%@", | |
| " @%####%%@@@@%*#%%%%@", | |
| " @%%%**+*######%@%#%@", | |
| " @@%%%%##*###%@@%%#%%" | |
| ], | |
| "thaddeus": [ | |
| " ╔══════════════╗", | |
| " ║ THADDEUS ║", | |
| " ╚══════════════╝", | |
| "", | |
| " @@@@@@@@@@@@**####%@", | |
| " @%%@@@%*+*+*++#####%", | |
| " %#@@%*+=:-:=**%%@%@@", | |
| " %%%%###*=+==*++*%%%@", | |
| " @%%%**+--:..=+#####@", | |
| " @%%%%**=:-:-+*@%###%", | |
| " %%%#%###*+*+-#@%+#%@", | |
| " %@%#%%**#===+%@*+#@@", | |
| " %%##@%%##*++%@*+#%@@", | |
| " %#*#%@@@%%##*++#%@@@", | |
| " @*++*%%@@@%=+#%%##%@", | |
| " %%#++++*#*+*#%%###%@", | |
| " @@@@######%%%%%%%@@@" | |
| ], | |
| "cyrus": [ | |
| " ╔═══════════╗", | |
| " ║ CYRUS ║", | |
| " ╚═══════════╝", | |
| "", | |
| " @@@@@@@%%%@@@@@@@@@@", | |
| " @@@@@#*=*+=#@@@@@@@@", | |
| " @@@@#+-:=+--++==++%@", | |
| " @@@%+--*=:+++#+=::-*", | |
| " @@%*++*-:::**##-:::+", | |
| " %+-=*#*-*--+###-::=*", | |
| " *=-=*##*==-*%%+:-=*#", | |
| " *+++**#*+**#%=:-***#", | |
| " #+++**#%##*%==*#*+=*", | |
| " %##***#%@%@#+#%%**+#", | |
| " %#%##*****##*#@%#*#%", | |
| " @%##*****##%%%#%###%", | |
| " @@%%##%%%%@@%**%%%%@" | |
| ] | |
| } | |
| def find_anchor(b: bytes, pattern: str): | |
| m = re.search(pattern.encode('utf-8'), b, re.I) | |
| return m.start() if m else None | |
| def read_f32(b: bytes, off: int) -> float: | |
| return struct.unpack_from('<f', b, off)[0] | |
| def write_f32(buf: bytearray, off: int, val: float): | |
| struct.pack_into('<f', buf, off, float(val)) | |
| def read_u32(b: bytes, off: int) -> int: | |
| return struct.unpack_from('<I', b, off)[0] | |
| def write_u32(buf: bytearray, off: int, val: int): | |
| struct.pack_into('<I', buf, off, int(val)) | |
| def discover_traits(b: bytes, base: int): | |
| """Find a plausible traits block: four small u32 (0..50) followed by small u32 (0..50).""" | |
| s = max(0, base - SEARCH_RADIUS) | |
| e = min(len(b), base + SEARCH_RADIUS) | |
| best = None | |
| for off in range(s, e - 20, 4): | |
| a,b1,c,d = struct.unpack_from('<IIII', b, off) | |
| un = struct.unpack_from('<I', b, off+16)[0] | |
| # sanity: small ints, and sum not huge | |
| if all(0 <= x <= 50 for x in (a,b1,c,d)) and 0 <= un <= 50: | |
| score = 100 - min(50, a+b1+c+d) # prefer smaller totals (early campaign typical) | |
| # heuristic: near anchor is better | |
| dist = abs(off - base) | |
| score -= dist / 1024.0 | |
| if (best is None) or (score > best[0]): | |
| best = (score, off, (a,b1,c,d), un) | |
| return None if best is None else best[1:] # (offset, (a,b,c,d), unspent) | |
| class SaveModel: | |
| def __init__(self, path: Path): | |
| self.path = path | |
| self.orig = path.read_bytes() | |
| self.buf = bytearray(self.orig) | |
| self.anchors = {} | |
| self.xp_offs = {} | |
| self.trait_offs = {} | |
| self.traits = {} # hero -> (a,b,c,d, unspent) | |
| self.xp = {} # hero -> float | |
| self.title_off = None # Offset to length prefix | |
| self.title_len = None # Character count | |
| self.title_marker = False | |
| self.title = None | |
| def locate(self): | |
| b = bytes(self.buf) | |
| # Find title with proper length-prefixed format | |
| off, clen, title, marker = find_title_location(b) | |
| if off is not None: | |
| self.title_off = off | |
| self.title_len = clen | |
| self.title_marker = marker | |
| self.title = title | |
| for hero, pat in HERO_ANCHORS.items(): | |
| base = find_anchor(b, pat) | |
| if base is None: | |
| continue | |
| self.anchors[hero] = base | |
| # XP | |
| rel = HERO_XP_REL_OFF.get(hero) | |
| if rel is not None: | |
| self.xp_offs[hero] = base + rel | |
| try: | |
| self.xp[hero] = read_f32(b, self.xp_offs[hero]) | |
| except Exception: | |
| pass | |
| # Traits | |
| if hero in KNOWN_TRAITS_REL: | |
| toff = base + KNOWN_TRAITS_REL[hero] | |
| a,b1,c,d = struct.unpack_from('<IIII', b, toff) | |
| un = struct.unpack_from('<I', b, toff+16)[0] | |
| self.trait_offs[hero] = toff | |
| self.traits[hero] = [a,b1,c,d,un] | |
| else: | |
| found = discover_traits(b, base) | |
| if found: | |
| toff, (a,b1,c,d), un = found | |
| self.trait_offs[hero] = toff | |
| self.traits[hero] = [a,b1,c,d,un] | |
| def set_xp(self, hero: str, val: float): | |
| off = self.xp_offs.get(hero) | |
| if off is None: return | |
| write_f32(self.buf, off, float(val)) | |
| self.xp[hero] = float(val) | |
| def set_trait_bucket(self, hero: str, idx: int, val: int): | |
| off = self.trait_offs.get(hero) | |
| if off is None: return | |
| write_u32(self.buf, off + 4*idx, int(val)) | |
| if hero in self.traits: | |
| self.traits[hero][idx] = int(val) | |
| def set_unspent(self, hero: str, val: int): | |
| off = self.trait_offs.get(hero) | |
| if off is None: return | |
| write_u32(self.buf, off + 16, int(val)) | |
| if hero in self.traits: | |
| self.traits[hero][4] = int(val) | |
| def set_title(self, title: str): | |
| if self.title_off is None or self.title_len is None: | |
| return | |
| # Truncate if too long | |
| if len(title) > self.title_len: | |
| title = title[:self.title_len] | |
| # Write the length prefix (stays the same or smaller) | |
| write_u32(self.buf, self.title_off, len(title)) | |
| # Encode title as UTF-16LE | |
| enc = title.encode('utf-16le') | |
| # Calculate where to write | |
| title_data_start = self.title_off + 4 | |
| max_bytes = self.title_len * 2 # Maximum space available | |
| # Clear the old title area first | |
| for i in range(max_bytes): | |
| self.buf[title_data_start + i] = 0 | |
| # Write new title | |
| self.buf[title_data_start:title_data_start + len(enc)] = enc | |
| # Add marker if original had one (after the max title space) | |
| marker_pos = title_data_start + max_bytes | |
| if self.title_marker: | |
| self.buf[marker_pos:marker_pos+2] = b'\x01\x00' | |
| self.buf[marker_pos+2:marker_pos+4] = b'\x00\x00' | |
| else: | |
| self.buf[marker_pos:marker_pos+2] = b'\x00\x00' | |
| self.title = title | |
| def save(self, save_as: Path = None): | |
| target = save_as or self.path | |
| # Backup original only when overwriting the original file | |
| if save_as is None: | |
| bak = self.path.with_suffix(self.path.suffix + '.bak') | |
| with open(bak, 'wb') as f: f.write(self.orig) | |
| with open(target, 'wb') as f: | |
| f.write(self.buf) | |
| def simple_input(stdscr, prompt, initial=""): | |
| """Simple text input with proper cursor handling""" | |
| h, w = stdscr.getmaxyx() | |
| y = h - 2 # Use second-to-last line | |
| # Clear the line and show prompt | |
| stdscr.move(y, 0) | |
| stdscr.clrtoeol() | |
| stdscr.addstr(y, 0, prompt) | |
| # Calculate available space for input | |
| prompt_len = len(prompt) | |
| max_input_len = w - prompt_len - 2 | |
| # Initialize text with initial value | |
| text = initial[:max_input_len] if initial else "" | |
| cursor_pos = len(text) | |
| curses.curs_set(1) # Show cursor | |
| while True: | |
| # Display current text | |
| stdscr.move(y, prompt_len) | |
| stdscr.clrtoeol() | |
| display_text = text[:max_input_len] | |
| stdscr.addstr(y, prompt_len, display_text) | |
| # Position cursor | |
| cursor_x = prompt_len + min(cursor_pos, max_input_len - 1) | |
| stdscr.move(y, cursor_x) | |
| stdscr.refresh() | |
| ch = stdscr.getch() | |
| if ch in (10, 13): # Enter | |
| break | |
| elif ch == 27: # Escape | |
| text = initial | |
| break | |
| elif ch in (curses.KEY_BACKSPACE, 127, 8): # Backspace | |
| if cursor_pos > 0: | |
| text = text[:cursor_pos-1] + text[cursor_pos:] | |
| cursor_pos -= 1 | |
| elif ch == curses.KEY_DC: # Delete | |
| if cursor_pos < len(text): | |
| text = text[:cursor_pos] + text[cursor_pos+1:] | |
| elif ch == curses.KEY_LEFT: | |
| if cursor_pos > 0: | |
| cursor_pos -= 1 | |
| elif ch == curses.KEY_RIGHT: | |
| if cursor_pos < len(text): | |
| cursor_pos += 1 | |
| elif ch == curses.KEY_HOME: | |
| cursor_pos = 0 | |
| elif ch == curses.KEY_END: | |
| cursor_pos = len(text) | |
| elif 32 <= ch <= 126: # Printable characters | |
| if len(text) < max_input_len: | |
| text = text[:cursor_pos] + chr(ch) + text[cursor_pos:] | |
| cursor_pos += 1 | |
| curses.curs_set(0) | |
| # Clear the input line | |
| stdscr.move(y, 0) | |
| stdscr.clrtoeol() | |
| return text | |
| def run_tui(stdscr, model: SaveModel): | |
| curses.curs_set(0) | |
| stdscr.nodelay(False) | |
| # Initialize colors if available | |
| if curses.has_colors(): | |
| curses.start_color() | |
| curses.init_pair(1, curses.COLOR_RED, curses.COLOR_BLACK) # Dawn of War red | |
| curses.init_pair(2, curses.COLOR_YELLOW, curses.COLOR_BLACK) # Gold/yellow | |
| curses.init_pair(3, curses.COLOR_BLUE, curses.COLOR_BLACK) # Blue accents | |
| curses.init_pair(4, curses.COLOR_GREEN, curses.COLOR_BLACK) # Success messages | |
| # Order heroes to match save file order as requested | |
| hero_order = ["force_commander", "tarkus", "avitus", "thaddeus", "cyrus"] | |
| heroes = [h for h in hero_order if h in model.anchors] | |
| sel_hero_idx = 0 | |
| field_idx = 0 # 0=XP, 1..4 = trait buckets (0..3), 5 = unspent | |
| help_lines = [ | |
| "Navigation: ↑↓ select hero, ←→ select field | Edit: Enter | Adjust: +/- | Title: T", | |
| "Commands: S=save, W=save as, R=reload, Q=quit" | |
| ] | |
| def draw(): | |
| stdscr.clear() | |
| h, w = stdscr.getmaxyx() | |
| current_y = 0 | |
| # ASCII Art Title Header | |
| logo_start_x = 2 | |
| title_start_x = logo_start_x + max(len(line) for line in DAWN_OF_WAR_LOGO) + 4 | |
| # Draw Dawn of War logo on the left | |
| for i, line in enumerate(DAWN_OF_WAR_LOGO): | |
| if current_y + i < h and len(line) < w: | |
| stdscr.addstr(current_y + i, logo_start_x, line, curses.A_BOLD | curses.color_pair(1) if curses.has_colors() else curses.A_BOLD) | |
| # Draw title art next to logo | |
| for i, line in enumerate(TITLE_ART): | |
| if current_y + i < h and title_start_x + len(line) < w: | |
| if i >= len(TITLE_ART) - 2: # Last two lines are subtitle | |
| color = curses.color_pair(2) if curses.has_colors() else curses.A_BOLD | |
| else: | |
| color = curses.A_BOLD | |
| stdscr.addstr(current_y + i, title_start_x, line[:w-title_start_x-1], color) | |
| current_y = len(TITLE_ART) + 2 | |
| # File name | |
| if current_y < h: | |
| stdscr.addstr(current_y, 2, f"File: {model.path.name}"[:w-3], curses.A_UNDERLINE) | |
| current_y += 1 | |
| # Help text | |
| for i, hl in enumerate(help_lines): | |
| if current_y + i < h: | |
| stdscr.addstr(current_y + i, 2, hl[:w-3]) | |
| current_y += len(help_lines) + 1 | |
| # Save title display | |
| title_txt = model.title if model.title else '(no title found)' | |
| if current_y < h: | |
| stdscr.addstr(current_y, 2, f"Save Title: {title_txt}"[:w-3], curses.A_BOLD) | |
| current_y += 2 | |
| # Calculate available space for data table and character art | |
| data_width = min(80, w // 2) # Left side for data | |
| art_start_x = data_width + 2 # Right side for character art | |
| # Table header | |
| if current_y < h: | |
| header = "Hero XP Traits [Sta,Rng,Str,Wil] Unsp" | |
| stdscr.addstr(current_y, 2, header[:data_width-3]) | |
| current_y += 1 | |
| if current_y < h: | |
| stdscr.addstr(current_y, 2, "-" * min(data_width-3, 55)) | |
| current_y += 1 | |
| table_start_y = current_y | |
| # Hero data rows | |
| for row, hero in enumerate(heroes): | |
| y = table_start_y + row | |
| if y >= h - 2: # Leave room for status line and input | |
| break | |
| xp = model.xp.get(hero, None) | |
| tr = model.traits.get(hero, None) | |
| # Format XP | |
| if xp is not None: | |
| xp_txt = f"{xp:7.1f}" | |
| else: | |
| xp_txt = " n/a " | |
| # Format traits in display order (Stamina, Ranged, Strength, Will) | |
| if tr: | |
| # Reorder from save file to display: [0,2,1,3] -> [Stamina,Ranged,Strength,Will] | |
| display_traits = [tr[TRAIT_DISPLAY_ORDER[i]] for i in range(4)] | |
| b_txt = f"[{display_traits[0]:2},{display_traits[1]:2},{display_traits[2]:2},{display_traits[3]:2}]" | |
| un_txt = f"{tr[4]:2}" | |
| else: | |
| b_txt = "[ -, -, -, -]" | |
| un_txt = " -" | |
| # Build and display row | |
| hero_name = hero.replace('_', ' ').title()[:12] | |
| line = f"{hero_name:12} {xp_txt} {b_txt} {un_txt}" | |
| stdscr.addstr(y, 2, line[:data_width-3]) | |
| # Highlight current selection | |
| if row == sel_hero_idx: | |
| # Calculate highlight position based on field | |
| if field_idx == 0: # XP field | |
| x_start, x_len = 14, 7 | |
| elif 1 <= field_idx <= 4: # Trait buckets | |
| # Position for each trait value in the bracket | |
| base_x = 22 | |
| offsets = [1, 4, 7, 10] # Positions within "[XX,XX,XX,XX]" | |
| x_start = base_x + offsets[field_idx - 1] | |
| x_len = 2 | |
| else: # field_idx == 5, Unspent | |
| x_start, x_len = 37, 3 | |
| # Apply highlight | |
| if x_start + x_len <= data_width: | |
| try: | |
| stdscr.chgat(y, x_start, x_len, curses.A_REVERSE) | |
| except: | |
| pass # Ignore if we can't highlight | |
| # Draw character art on the right side | |
| if heroes and sel_hero_idx < len(heroes): | |
| selected_hero = heroes[sel_hero_idx] | |
| if selected_hero in CHARACTER_ART and art_start_x < w: | |
| art_lines = CHARACTER_ART[selected_hero] | |
| art_y = table_start_y | |
| for i, art_line in enumerate(art_lines): | |
| if art_y + i < h - 2 and art_start_x + len(art_line) < w: | |
| try: | |
| # Use different colors for different parts of the character art | |
| if i <= 2: # Header box | |
| color = curses.color_pair(2) if curses.has_colors() else curses.A_BOLD | |
| elif "██" in art_line or "▓▓" in art_line: # Armor parts | |
| color = curses.color_pair(3) if curses.has_colors() else curses.A_BOLD | |
| else: # Frame/structure | |
| color = curses.A_BOLD | |
| stdscr.addstr(art_y + i, art_start_x, art_line, color) | |
| except: | |
| pass # Ignore if we can't draw | |
| stdscr.refresh() | |
| draw() | |
| while True: | |
| ch = stdscr.getch() | |
| if ch in (ord('q'), ord('Q')): | |
| break | |
| elif ch in (curses.KEY_UP, ord('k')): | |
| sel_hero_idx = max(0, sel_hero_idx-1) | |
| elif ch in (curses.KEY_DOWN, ord('j')): | |
| sel_hero_idx = min(len(heroes)-1, sel_hero_idx+1) | |
| elif ch in (curses.KEY_LEFT, ord('h')): | |
| field_idx = (field_idx - 1) % 6 | |
| elif ch in (curses.KEY_RIGHT, ord('l')): | |
| field_idx = (field_idx + 1) % 6 | |
| elif ch in (ord('+'), ord('=')): | |
| hero = heroes[sel_hero_idx] | |
| if field_idx == 0 and hero in model.xp: | |
| model.set_xp(hero, model.xp[hero] + 50.0) | |
| elif 1 <= field_idx <= 4 and hero in model.traits: | |
| # Map display index to save file index | |
| save_idx = TRAIT_DISPLAY_ORDER[field_idx-1] | |
| v = model.traits[hero][save_idx] + 1 | |
| model.set_trait_bucket(hero, save_idx, v) | |
| elif field_idx == 5 and hero in model.traits: | |
| model.set_unspent(hero, model.traits[hero][4] + 1) | |
| elif ch == ord('-'): | |
| hero = heroes[sel_hero_idx] | |
| if field_idx == 0 and hero in model.xp: | |
| model.set_xp(hero, max(0.0, model.xp[hero] - 50.0)) | |
| elif 1 <= field_idx <= 4 and hero in model.traits: | |
| # Map display index to save file index | |
| save_idx = TRAIT_DISPLAY_ORDER[field_idx-1] | |
| v = max(0, model.traits[hero][save_idx] - 1) | |
| model.set_trait_bucket(hero, save_idx, v) | |
| elif field_idx == 5 and hero in model.traits: | |
| model.set_unspent(hero, max(0, model.traits[hero][4] - 1)) | |
| elif ch in (curses.KEY_ENTER, 10, 13): | |
| hero = heroes[sel_hero_idx] | |
| h, w = stdscr.getmaxyx() | |
| if field_idx == 0 and hero in model.xp: | |
| val = simple_input(stdscr, f"Set XP for {hero}: ", f"{model.xp[hero]:.2f}") | |
| try: | |
| model.set_xp(hero, float(val)) | |
| except Exception: | |
| pass | |
| elif 1 <= field_idx <= 4 and hero in model.traits: | |
| # Map display index to save file index | |
| save_idx = TRAIT_DISPLAY_ORDER[field_idx-1] | |
| trait_names = ["Stamina", "Ranged", "Strength", "Will"] | |
| cur = model.traits[hero][save_idx] | |
| val = simple_input(stdscr, f"Set {trait_names[field_idx-1]} for {hero}: ", f"{cur}") | |
| try: | |
| model.set_trait_bucket(hero, save_idx, int(val)) | |
| except Exception: | |
| pass | |
| elif field_idx == 5 and hero in model.traits: | |
| cur = model.traits[hero][4] | |
| val = simple_input(stdscr, f"Set UNSPENT for {hero}: ", f"{cur}") | |
| try: | |
| model.set_unspent(hero, int(val)) | |
| except Exception: | |
| pass | |
| elif ch in (ord('t'), ord('T')): | |
| h, w = stdscr.getmaxyx() | |
| cur = model.title if model.title else '' | |
| max_len = model.title_len if model.title_len else 32 | |
| val = simple_input(stdscr, f"Set Title (max {max_len} chars): ", cur) | |
| if val: | |
| model.set_title(val) | |
| color = curses.color_pair(4) if curses.has_colors() else curses.A_BOLD | |
| stdscr.addstr(h-1, 0, f"✓ Title set to: {val}"[:w-1], color) | |
| elif ch in (ord('s'), ord('S')): | |
| h, w = stdscr.getmaxyx() | |
| try: | |
| model.save() | |
| color = curses.color_pair(4) if curses.has_colors() else curses.A_BOLD | |
| stdscr.addstr(h-1, 0, "✓ Saved (backup: .bak)"[:w-1], color) | |
| except Exception as e: | |
| color = curses.color_pair(1) if curses.has_colors() else curses.A_BOLD | |
| stdscr.addstr(h-1, 0, f"✗ Save failed: {e}"[:w-1], color) | |
| elif ch in (ord('w'), ord('W')): | |
| h, w = stdscr.getmaxyx() | |
| default_path = str(Path(model.path).with_suffix(".edited.sav")) | |
| path = simple_input(stdscr, "Save As: ", default_path) | |
| if path: | |
| try: | |
| model.save(Path(path)) | |
| color = curses.color_pair(4) if curses.has_colors() else curses.A_BOLD | |
| stdscr.addstr(h-1, 0, f"✓ Saved as: {path}"[:w-1], color) | |
| except Exception as e: | |
| color = curses.color_pair(1) if curses.has_colors() else curses.A_BOLD | |
| stdscr.addstr(h-1, 0, f"✗ Save failed: {e}"[:w-1], color) | |
| elif ch in (ord('r'), ord('R')): | |
| h, w = stdscr.getmaxyx() | |
| try: | |
| new_bytes = Path(model.path).read_bytes() | |
| model.orig = new_bytes | |
| model.buf = bytearray(new_bytes) | |
| model.anchors.clear(); model.xp_offs.clear(); model.trait_offs.clear() | |
| model.traits.clear(); model.xp.clear() | |
| model.title = None; model.title_off = None; model.title_len = None | |
| model.locate() | |
| color = curses.color_pair(4) if curses.has_colors() else curses.A_BOLD | |
| stdscr.addstr(h-1, 0, "✓ Reloaded from disk"[:w-1], color) | |
| except Exception as e: | |
| color = curses.color_pair(1) if curses.has_colors() else curses.A_BOLD | |
| stdscr.addstr(h-1, 0, f"✗ Reload failed: {e}"[:w-1], color) | |
| draw() | |
| def main(): | |
| if len(sys.argv) != 2: | |
| print("Usage: python3 dow2_save_tui_fixed.py /path/to/YourSave.sav") | |
| sys.exit(1) | |
| path = Path(sys.argv[1]) | |
| if not path.exists(): | |
| print(f"Not found: {path}") | |
| sys.exit(2) | |
| model = SaveModel(path) | |
| model.locate() | |
| # Debug info | |
| if model.title: | |
| print(f"Found title: '{model.title}' at offset {model.title_off:#x}") | |
| else: | |
| print("No title found") | |
| curses.wrapper(run_tui, model) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment