Skip to content

Instantly share code, notes, and snippets.

@linusnorton
Last active January 1, 2026 16:52
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()
@itaranto
Copy link

itaranto commented Jan 1, 2026

This is amazing.

Can you clarify what's the license of this code?

I've made some changes, mostly adding support for Davian Thule and Chaos Rising.

I can share them here if you want.

@itaranto
Copy link

itaranto commented Jan 1, 2026

I'm also curious, how did you reverse-engineer the format? What tools did you use for that?

@linusnorton
Copy link
Author

Hey, the license is MIT. You can do whatever you want.

It's a vibe-coded mess. I was experimenting with Claude Code and I asked it to reverse engineer the save game format. I did make some further improvements and I was trying to get inventory management to work but I wasn't quite able to.

One thing to bear in mind is that the difficulty of the game scales with your unit level, so be careful with that. It's probably best just to change the skill point allocation.

@itaranto
Copy link

itaranto commented Jan 1, 2026

That's all good to know, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment