Skip to content

Instantly share code, notes, and snippets.

@linusnorton
Last active October 18, 2025 21:23
Show Gist options
  • Select an option

  • Save linusnorton/6dbc771a2cb92f6ff4acf7ec4388ab98 to your computer and use it in GitHub Desktop.

Select an option

Save linusnorton/6dbc771a2cb92f6ff4acf7ec4388ab98 to your computer and use it in GitHub Desktop.
Dawn of War save game editor
#!/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