diff --git a/Example/KeysForLanguage/FromMod.txt b/Example/KeysForLanguage/FromMod.txt
new file mode 100644
index 0000000..c46f726
--- /dev/null
+++ b/Example/KeysForLanguage/FromMod.txt
@@ -0,0 +1 @@
+TTs visible Hotkeys(Configurable)
\ No newline at end of file
diff --git a/Example/KeysForLanguage/key-value-modded-strings-utf8.txt b/Example/KeysForLanguage/key-value-modded-strings-utf8.txt
new file mode 100644
index 0000000..66c7b4e
--- /dev/null
+++ b/Example/KeysForLanguage/key-value-modded-strings-utf8.txt
@@ -0,0 +1,39 @@
+//INSTRUCTIONS:
+//In this File you can change the Keys that are Displayed to you inGame
+//Below you see the pattern for the Keys in the Building/Unit/Production menue:
+//
+//--------------------------
+//| 01 | 02 | 03 | 04 | 05 |
+//|------------------------|
+//| 06 | 07 | 08 | 09 | 10 |
+//|------------------------|
+//| 11 | 12 | 13 | 14 | 15 |
+//--------------------------
+//
+//The Numbers are a short form of the StringIDs below
+// 01 stands for IDS_MOD_TTS_VISIBLE_HOTKEYS_01 witch is the House in the Building menue
+// in the "" you can put the Key that you want to be displayed
+//
+//Example:
+//I edit the line IDS_MOD_TTS_VISIBLE_HOTKEYS_01 and change "Q" to "P"
+//It now looks like:
+//IDS_MOD_TTS_VISIBLE_HOTKEYS_01 "P"
+//
+//If you want to make individual Keys invisible, just set them to ""
+//
+
+IDS_MOD_TTS_VISIBLE_HOTKEYS_01 "Q"
+IDS_MOD_TTS_VISIBLE_HOTKEYS_02 "W"
+IDS_MOD_TTS_VISIBLE_HOTKEYS_03 "E"
+IDS_MOD_TTS_VISIBLE_HOTKEYS_04 "R"
+IDS_MOD_TTS_VISIBLE_HOTKEYS_05 "T"
+IDS_MOD_TTS_VISIBLE_HOTKEYS_06 "A"
+IDS_MOD_TTS_VISIBLE_HOTKEYS_07 "S"
+IDS_MOD_TTS_VISIBLE_HOTKEYS_08 "D"
+IDS_MOD_TTS_VISIBLE_HOTKEYS_09 "F"
+IDS_MOD_TTS_VISIBLE_HOTKEYS_10 "G"
+IDS_MOD_TTS_VISIBLE_HOTKEYS_11 "Z"
+IDS_MOD_TTS_VISIBLE_HOTKEYS_12 "X"
+IDS_MOD_TTS_VISIBLE_HOTKEYS_13 "C"
+IDS_MOD_TTS_VISIBLE_HOTKEYS_14 "V"
+IDS_MOD_TTS_VISIBLE_HOTKEYS_15 "B"
\ No newline at end of file
diff --git a/src/backend/settings/base.py b/src/backend/settings/base.py
index 9417cdb..02064b4 100644
--- a/src/backend/settings/base.py
+++ b/src/backend/settings/base.py
@@ -129,7 +129,7 @@
]
# Used to update the current patch hotkey files
-CURRENT_VERSION = 133431
+CURRENT_VERSION = 170934
LOGGING = {
diff --git a/src/frontend/src/Info.js b/src/frontend/src/Info.js
index 7a35678..56c967f 100644
--- a/src/frontend/src/Info.js
+++ b/src/frontend/src/Info.js
@@ -6,7 +6,7 @@ const Info = () => {
Requirements
- 83607 (May 15, 2023) is the oldest supported version.
- - 133431 is the latest supported version.
+ - 170934 (March 2026) is the latest supported version.
- A minimum window width of 1200 pixels.
diff --git a/src/hotkeys/hkp/analyze.py b/src/hotkeys/hkp/analyze.py
new file mode 100644
index 0000000..48bb787
--- /dev/null
+++ b/src/hotkeys/hkp/analyze.py
@@ -0,0 +1,303 @@
+"""HKP file analyzer — a reverse-engineering aid for AoE2:DE hotkey files.
+
+Decompresses one or more ``.hkp`` files and dumps a structured analysis
+(header, hex+ASCII body, printable-string scan with surrounding bytes, and a
+best-effort walk using the legacy parser logic) to stdout. Intended for
+diffing old-format vs new-format hotkey files while the binary layout is
+being reverse-engineered.
+
+Run via ``python -m hotkeys.hkp.analyze ...`` from the ``src/``
+directory, or directly as ``python src/hotkeys/hkp/analyze.py``.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import struct
+import sys
+from dataclasses import dataclass, field
+from typing import List, Optional, Tuple
+
+# Dual-mode imports: work both as a package module and as a standalone script.
+if __package__ in (None, ""):
+ _here = os.path.dirname(os.path.abspath(__file__))
+ _src = os.path.abspath(os.path.join(_here, "..", ".."))
+ if _src not in sys.path:
+ sys.path.insert(0, _src)
+ from hotkeys.hkp.izip import decompress # type: ignore
+else:
+ from .izip import decompress
+
+
+HEADER_STRUCT = struct.Struct(" int:
+ return struct.unpack(" str:
+ if max_bytes is not None and len(data) > max_bytes:
+ data = data[:max_bytes]
+ truncated = True
+ else:
+ truncated = False
+
+ lines = []
+ for base in range(0, len(data), 16):
+ chunk = data[base:base + 16]
+ hex_part = " ".join(f"{b:02x}" for b in chunk)
+ hex_part = hex_part.ljust(16 * 3 - 1)
+ ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
+ lines.append(f"{start + base:08x} {hex_part} |{ascii_part}|")
+ if truncated:
+ lines.append(f"... (truncated, showing {max_bytes} of original)")
+ return "\n".join(lines)
+
+
+def find_strings(data: bytes, min_length: int = 6) -> List[StringHit]:
+ hits: List[StringHit] = []
+ i = 0
+ n = len(data)
+ while i < n:
+ if 32 <= data[i] < 127:
+ j = i
+ while j < n and 32 <= data[j] < 127:
+ j += 1
+ if j - i >= min_length:
+ prefix = data[max(0, i - 4):i]
+ if len(prefix) < 4:
+ prefix = b"\x00" * (4 - len(prefix)) + prefix
+ suffix = data[j:j + 8]
+ hits.append(StringHit(
+ offset=i,
+ length=j - i,
+ text=data[i:j].decode("ascii", errors="replace"),
+ prefix4=bytes(prefix),
+ suffix8=bytes(suffix),
+ ))
+ i = j
+ else:
+ i += 1
+ return hits
+
+
+def partial_walk(data: bytes, file_type: str = "HKP") -> WalkResult:
+ """Walk ``data`` using the legacy parser logic, stopping on first failure."""
+ res = WalkResult(ok=False, header=0)
+ off = 0
+ try:
+ (res.header,) = HEADER_STRUCT.unpack_from(data, off)
+ off += HEADER_STRUCT.size
+
+ def read_menu(cur: int) -> Tuple[List[dict], int]:
+ (num_hk,) = COUNT_STRUCT.unpack_from(data, cur)
+ cur += COUNT_STRUCT.size
+ hotkeys = []
+ for _ in range(num_hk):
+ code, sid, c, a, s = OLD_HOTKEY_STRUCT.unpack_from(data, cur)
+ cur += OLD_HOTKEY_STRUCT.size
+ hotkeys.append({"code": code, "id": sid,
+ "ctrl": c, "alt": a, "shift": s})
+ return hotkeys, cur
+
+ res.stage = "base_menus"
+ if file_type == "HKP":
+ for _ in range(3):
+ menu, off = read_menu(off)
+ res.menus.append(menu)
+
+ res.stage = "menu_count"
+ (num_menus,) = COUNT_STRUCT.unpack_from(data, off)
+ off += COUNT_STRUCT.size
+
+ res.stage = "extra_menus"
+ for _ in range(num_menus):
+ menu, off = read_menu(off)
+ res.menus.append(menu)
+
+ res.stopped_at = off
+ res.ok = (off == len(data))
+ if not res.ok:
+ res.error = f"walk ended at 0x{off:x} but file length is 0x{len(data):x}"
+ res.remaining = len(data) - off
+ except struct.error as e:
+ res.stopped_at = off
+ res.error = str(e)
+ res.remaining = len(data) - off
+ return res
+
+
+# ---------------------------------------------------------------------------
+# Reporting
+# ---------------------------------------------------------------------------
+
+def _section(title: str) -> str:
+ return f"\n{'=' * 78}\n== {title}\n{'=' * 78}"
+
+
+def analyze_file(path: str, max_bytes: int, min_string: int,
+ file_type: str = "HKP") -> dict:
+ with open(path, "rb") as f:
+ compressed = f.read()
+ raw = decompress(compressed)
+
+ header = HEADER_STRUCT.unpack_from(raw, 0)[0] if len(raw) >= 4 else 0
+ header_f = FLOAT_STRUCT.unpack_from(raw, 0)[0] if len(raw) >= 4 else float("nan")
+ strings = find_strings(raw, min_length=min_string)
+ walk = partial_walk(raw, file_type=file_type)
+
+ print(_section(f"FILE {path}"))
+ print(f" compressed size : {len(compressed):d} bytes")
+ print(f" decompressed : {len(raw):d} bytes (0x{len(raw):x})")
+ print(f" file_type arg : {file_type}")
+
+ print(_section("HEADER (first 4 bytes)"))
+ print(f" hex : {raw[:4].hex(' ') if len(raw) >= 4 else ''}")
+ print(f" uint32 LE: 0x{header:08x} ({header:d})")
+ print(f" float32 : {header_f!r}")
+
+ print(_section(f"HEX DUMP (max_bytes={max_bytes})"))
+ print(hexdump(raw, start=0, max_bytes=max_bytes))
+
+ print(_section(f"PRINTABLE STRING RUNS (len >= {min_string}, total={len(strings)})"))
+ for h in strings:
+ prefix_u32 = h.prefix_u32_le
+ matches_len = " <-- length prefix matches!" if prefix_u32 == h.length else ""
+ print(f" 0x{h.offset:06x} len={h.length:<3d} "
+ f"prev4={h.prefix4.hex(' ')} (u32={prefix_u32}){matches_len}")
+ print(f" text={h.text!r}")
+ print(f" next8={h.suffix8.hex(' ')}")
+
+ print(_section("LEGACY PARTIAL WALK"))
+ print(f" header_read : 0x{walk.header:08x}")
+ print(f" ok : {walk.ok}")
+ print(f" stopped_at : 0x{walk.stopped_at:x} ({walk.stopped_at:d})")
+ print(f" remaining : {walk.remaining:d} bytes unread")
+ print(f" stage : {walk.stage}")
+ if walk.error:
+ print(f" error : {walk.error}")
+ print(f" menus read : {len(walk.menus)}")
+ for i, m in enumerate(walk.menus):
+ print(f" menu[{i}]: {len(m)} hotkeys")
+
+ return {
+ "path": path,
+ "compressed": len(compressed),
+ "raw": raw,
+ "header": header,
+ "strings": strings,
+ "walk": walk,
+ }
+
+
+def diff_reports(a: dict, b: dict) -> None:
+ print(_section(f"DIFF {os.path.basename(a['path'])} vs {os.path.basename(b['path'])}"))
+ print(f" sizes raw : {len(a['raw'])} vs {len(b['raw'])}")
+ print(f" sizes zipped : {a['compressed']} vs {b['compressed']}")
+ print(f" headers : 0x{a['header']:08x} vs 0x{b['header']:08x}")
+
+ a_strings = {h.text: h.offset for h in a["strings"]}
+ b_strings = {h.text: h.offset for h in b["strings"]}
+ only_a = sorted(set(a_strings) - set(b_strings))
+ only_b = sorted(set(b_strings) - set(a_strings))
+ common = sorted(set(a_strings) & set(b_strings))
+
+ print(_section("STRING LABELS — SHARED (with offsets in each file)"))
+ for s in common:
+ print(f" 0x{a_strings[s]:06x} | 0x{b_strings[s]:06x} {s!r}")
+
+ if only_a:
+ print(_section(f"STRING LABELS — ONLY in {os.path.basename(a['path'])}"))
+ for s in only_a:
+ print(f" 0x{a_strings[s]:06x} {s!r}")
+ if only_b:
+ print(_section(f"STRING LABELS — ONLY in {os.path.basename(b['path'])}"))
+ for s in only_b:
+ print(f" 0x{b_strings[s]:06x} {s!r}")
+
+ # First byte where they diverge
+ ra, rb = a["raw"], b["raw"]
+ lim = min(len(ra), len(rb))
+ first_div = next((i for i in range(lim) if ra[i] != rb[i]), lim)
+ print(_section("FIRST BYTE DIVERGENCE"))
+ print(f" index : 0x{first_div:x}")
+ if first_div < lim:
+ ctx_a = ra[max(0, first_div - 8):first_div + 16]
+ ctx_b = rb[max(0, first_div - 8):first_div + 16]
+ print(f" a[..] : {ctx_a.hex(' ')}")
+ print(f" b[..] : {ctx_b.hex(' ')}")
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+def build_parser() -> argparse.ArgumentParser:
+ p = argparse.ArgumentParser(
+ prog="python -m hotkeys.hkp.analyze",
+ description="Decompress and analyze AoE2:DE .hkp hotkey files.",
+ )
+ p.add_argument("files", nargs="+", help="Paths to .hkp files")
+ p.add_argument("--max-bytes", type=int, default=4096,
+ help="Hexdump byte cap per file (default: 4096, 0 = no limit)")
+ p.add_argument("--min-string", type=int, default=6,
+ help="Minimum printable run length to report (default: 6)")
+ p.add_argument("--type", choices=["HKP", "HKI"], default="HKP",
+ help="File type for the legacy walk (default: HKP)")
+ p.add_argument("--diff", action="store_true",
+ help="When exactly 2 files are given, also print a structural diff.")
+ return p
+
+
+def main(argv: Optional[List[str]] = None) -> int:
+ args = build_parser().parse_args(argv)
+ max_bytes = None if args.max_bytes == 0 else args.max_bytes
+
+ reports = [analyze_file(f, max_bytes=max_bytes,
+ min_string=args.min_string, file_type=args.type)
+ for f in args.files]
+
+ if args.diff:
+ if len(reports) != 2:
+ print("\n[!] --diff requires exactly 2 files", file=sys.stderr)
+ return 2
+ diff_reports(reports[0], reports[1])
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/hotkeys/hkp/keys_for_language.py b/src/hotkeys/hkp/keys_for_language.py
new file mode 100644
index 0000000..930f8a5
--- /dev/null
+++ b/src/hotkeys/hkp/keys_for_language.py
@@ -0,0 +1,213 @@
+"""Generate a key-value-modded-strings-utf8.txt for AoE2:DE hotkey mods.
+
+Reads custom .hkp hotkey files and produces the IDS_MOD_TTS_VISIBLE_HOTKEYS
+entries that control the key labels shown on the in-game command grid:
+
+ | 01 | 02 | 03 | 04 | 05 |
+ | 06 | 07 | 08 | 09 | 10 |
+ | 11 | 12 | 13 | 14 | 15 |
+
+By default, labels are generated for the Economic Build Menu. Use --panel
+to target a specific panel, or --best-fit to pick the most common key per
+position across all command-grid panels.
+
+Run from ``src/``:
+ python -m hotkeys.hkp.keys_for_language Base.hkp [Profile.hkp] [-o output.txt]
+ python -m hotkeys.hkp.keys_for_language Base.hkp --panel "Military Build Menu"
+ python -m hotkeys.hkp.keys_for_language Base.hkp Profile.hkp --best-fit
+ python -m hotkeys.hkp.keys_for_language --list-panels
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+from collections import Counter
+from pathlib import Path
+from typing import Dict, List, Optional
+
+if __package__ in (None, ""):
+ _here = os.path.dirname(os.path.abspath(__file__))
+ _src = os.path.abspath(os.path.join(_here, "..", ".."))
+ if _src not in sys.path:
+ sys.path.insert(0, _src)
+ from hotkeys.hkp.new_hotkey_file import HotkeyFile # type: ignore
+ from hotkeys.hkp.parse import FileType # type: ignore
+ from hotkeys.hkp.strings import hk_groups # type: ignore
+else:
+ from .new_hotkey_file import HotkeyFile
+ from .parse import FileType
+ from .strings import hk_groups
+
+# Panels whose hotkeys map to the 5x3 command grid (up to 15 slots each).
+GRID_PANELS = [
+ "Economic Build Menu",
+ "Military Build Menu",
+ "Villagers",
+ "Military Units",
+ "Town Center",
+ "Dock",
+ "Barracks",
+ "Archery Range",
+ "Stable",
+ "Siege Workshop",
+ "Monastery",
+ "Market",
+ "Castle",
+ "Mill",
+ "Mining Camp",
+ "Lumber Camp",
+ "Blacksmith",
+ "University",
+]
+
+DEFAULT_PANEL = "Economic Build Menu"
+
+# AoE2 virtual-key-code → display label for the mod overlay.
+KEYCODE_LABELS: Dict[int, str] = {
+ 0: "",
+ 8: "Bksp", 9: "Tab", 13: "Enter", 16: "Shift", 17: "Ctrl", 18: "Alt",
+ 19: "Pause", 20: "Caps", 27: "Esc", 32: "Space",
+ 33: "PgUp", 34: "PgDn", 35: "End", 36: "Home",
+ 37: "Left", 38: "Up", 39: "Right", 40: "Down",
+ 44: "PrtSc", 45: "Ins", 46: "Del",
+ 91: "LWin", 92: "RWin", 93: "Menu",
+ 96: "Num0", 97: "Num1", 98: "Num2", 99: "Num3", 100: "Num4",
+ 101: "Num5", 102: "Num6", 103: "Num7", 104: "Num8", 105: "Num9",
+ 106: "Num*", 107: "Num+", 108: "Num,", 109: "Num-", 110: "Num.", 111: "Num/",
+ 112: "F1", 113: "F2", 114: "F3", 115: "F4", 116: "F5", 117: "F6",
+ 118: "F7", 119: "F8", 120: "F9", 121: "F10", 122: "F11", 123: "F12",
+ 144: "NumLk", 145: "ScrLk",
+ 186: ";", 187: "=", 188: ",", 189: "-", 190: ".", 191: "/", 192: "`",
+ 219: "[", 220: "\\", 221: "]", 222: "'",
+ 251: "XBtn2", 252: "XBtn1", 253: "MBtn", 254: "WhlDn", 255: "WhlUp",
+}
+
+
+def keycode_to_label(code: int) -> str:
+ if code in KEYCODE_LABELS:
+ return KEYCODE_LABELS[code]
+ if 48 <= code <= 57:
+ return chr(code)
+ if 65 <= code <= 90:
+ return chr(code)
+ return f"0x{code:02X}"
+
+
+def detect_file_type(name: str) -> FileType:
+ return FileType.HKP if Path(name).stem.lower() == "base" else FileType.HKI
+
+
+def load_hotkeys(paths: List[str]) -> Dict[int, int]:
+ """Load hotkey files and return {string_id: keycode} for all hotkeys."""
+ sid_to_keycode: Dict[int, int] = {}
+ for p in paths:
+ ftype = detect_file_type(p)
+ with open(p, "rb") as f:
+ hf = HotkeyFile(f.read(), False, Path(p).name, ftype)
+ for _key, entry in hf.data.items():
+ sid_to_keycode[entry["string_id"]] = entry["keycode"]
+ return sid_to_keycode
+
+
+def get_panel_keycodes(sid_to_keycode: Dict[int, int],
+ panel: str) -> List[int]:
+ """Return the keycodes for each grid position (0-14) of a panel."""
+ ids = hk_groups.get(panel, [])
+ return [sid_to_keycode.get(ids[i], 0) if i < len(ids) else 0
+ for i in range(15)]
+
+
+def get_best_fit_keycodes(sid_to_keycode: Dict[int, int]) -> List[int]:
+ """For each grid position, pick the most common keycode across all panels."""
+ result = []
+ for pos in range(15):
+ votes: Counter = Counter()
+ for panel in GRID_PANELS:
+ ids = hk_groups.get(panel, [])
+ if pos < len(ids):
+ code = sid_to_keycode.get(ids[pos], 0)
+ if code != 0:
+ votes[code] += 1
+ result.append(votes.most_common(1)[0][0] if votes else 0)
+ return result
+
+
+HEADER_TEMPLATE = """\
+//
+// Generated by: python -m hotkeys.hkp.keys_for_language
+// Panel: {panel}
+//
+// Grid layout:
+// | 01 | 02 | 03 | 04 | 05 |
+// | 06 | 07 | 08 | 09 | 10 |
+// | 11 | 12 | 13 | 14 | 15 |
+//
+// Edit values in "" to change displayed keys. Set to "" to hide a key.
+//
+"""
+
+
+def generate(paths: List[str], panel: str = DEFAULT_PANEL,
+ best_fit: bool = False) -> str:
+ sid_to_keycode = load_hotkeys(paths)
+
+ if best_fit:
+ keycodes = get_best_fit_keycodes(sid_to_keycode)
+ panel_label = "Best Fit (all panels)"
+ else:
+ keycodes = get_panel_keycodes(sid_to_keycode, panel)
+ panel_label = panel
+
+ lines = [HEADER_TEMPLATE.format(panel=panel_label)]
+ for i, code in enumerate(keycodes):
+ label = keycode_to_label(code)
+ lines.append(f'IDS_MOD_TTS_VISIBLE_HOTKEYS_{i + 1:02d} "{label}"')
+ return "\n".join(lines) + "\n"
+
+
+def build_parser() -> argparse.ArgumentParser:
+ p = argparse.ArgumentParser(
+ prog="python -m hotkeys.hkp.keys_for_language",
+ description="Generate key-value-modded-strings-utf8.txt from custom .hkp files.",
+ )
+ p.add_argument("files", nargs="*",
+ help="One or more .hkp files (Base.hkp and/or profile)")
+ p.add_argument("-o", "--output", default=None,
+ help="Output path (default: stdout)")
+ p.add_argument("--panel", default=DEFAULT_PANEL,
+ help=f"Panel to generate labels for (default: {DEFAULT_PANEL})")
+ p.add_argument("--best-fit", action="store_true",
+ help="Pick the most common key per position across all panels")
+ p.add_argument("--list-panels", action="store_true",
+ help="List all available panel names and exit")
+ return p
+
+
+def main(argv: Optional[List[str]] = None) -> int:
+ args = build_parser().parse_args(argv)
+
+ if args.list_panels:
+ print("Available panels:")
+ for name in GRID_PANELS:
+ count = len(hk_groups.get(name, []))
+ print(f" {name} ({count} slots)")
+ return 0
+
+ if not args.files:
+ print("Error: at least one .hkp file is required", file=sys.stderr)
+ return 1
+
+ result = generate(args.files, panel=args.panel, best_fit=args.best_fit)
+ if args.output:
+ Path(args.output).parent.mkdir(parents=True, exist_ok=True)
+ Path(args.output).write_text(result, encoding="utf-8")
+ print(f"Wrote {args.output}")
+ else:
+ print(result, end="")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/hotkeys/hkp/new_hotkey_file.py b/src/hotkeys/hkp/new_hotkey_file.py
index f97d134..406dcac 100644
--- a/src/hotkeys/hkp/new_hotkey_file.py
+++ b/src/hotkeys/hkp/new_hotkey_file.py
@@ -18,6 +18,10 @@
('deo', 0x40400000, {}, 'DE (old)'), # don't ever pick this version
('de', 0x40866666, {4632, 4644, 4664, 4676, 4712, 4724,
4748, 4820, 2672, 2324, 2796, 2336, 4996}, 'Definitive Edition'),
+ # 2026+ format wraps every record in named markers, so the file size is
+ # variable (depends on how many hotkeys/groups are present). Sentinel
+ # `None` means "any size accepted".
+ ('de_new', 0x408a3d71, None, 'Definitive Edition (2026+)'),
]
# Filesizes by path (Base/Profile)
@@ -65,6 +69,11 @@ def __init__(self,
# File size, used to determine version
self._file_size = data['size']
+ # Format ('legacy' or 'new'); used so the unparser writes back in the
+ # same shape we read.
+ self._format = data.get('format', 'legacy')
+ self._handler_magic = data.get('handler_magic')
+
# todo: graceful wrong hotkey file version handling
self.version = self._find_version(self._file_size, self._header)
# Raw menu data
@@ -90,9 +99,14 @@ def update(self, newValues) -> dict:
# todo: make this fail is there's missing strings in hk_mapping
def deserialize_file(self, data):
self.data = {}
- index = 0
- for menu in data['menus']:
- for key in menu:
+ # Preserve the raw parsed menu structure so we can round-trip slots that
+ # don't appear in self.data (id <= 0 sentinels, or hotkeys we filter out
+ # because they have no displayable string). _slot_keys maps each
+ # (menu_idx, slot_idx) of self._raw_menus to the data key it lives under.
+ self._raw_menus = data['menus']
+ self._slot_keys = {}
+ for menu_idx, menu in enumerate(self._raw_menus):
+ for slot_idx, key in enumerate(menu):
if key['id'] <= 0:
continue
@@ -121,19 +135,34 @@ def deserialize_file(self, data):
"ctrl": key['ctrl'],
"alt": key['alt'],
"shift": key['shift'],
- "menu_id": index, }
- index = index + 1
+ "menu_id": menu_idx, }
+ self._slot_keys[(menu_idx, slot_idx)] = dataKey
def serialize_to_file(self):
- # Serializes the data from Hotfile.data to just include the data that the
- # hotkey file has
- output = [[] for _ in range(self._num_menus)]
- for id, hotkey in self.data.items():
- output[hotkey["menu_id"]].append({"code": hotkey["keycode"],
- "id": hotkey["string_id"],
- "ctrl": hotkey["ctrl"],
- "alt": hotkey["alt"],
- "shift": hotkey["shift"]})
+ # Walk the original parsed menu structure and replace each tracked slot
+ # with its (possibly user-edited) value from self.data. Untracked slots
+ # (id <= 0) pass through unchanged. This preserves both order and the
+ # filtered-out sentinel slots that the previous self.data-only rebuild
+ # would silently drop.
+ output = []
+ for menu_idx, menu in enumerate(self._raw_menus):
+ out_menu = []
+ for slot_idx, raw_slot in enumerate(menu):
+ data_key = self._slot_keys.get((menu_idx, slot_idx))
+ if data_key is not None and data_key in self.data:
+ hotkey = self.data[data_key]
+ out_menu.append({"code": hotkey["keycode"],
+ "id": hotkey["string_id"],
+ "ctrl": hotkey["ctrl"],
+ "alt": hotkey["alt"],
+ "shift": hotkey["shift"]})
+ else:
+ out_menu.append({"code": raw_slot["code"],
+ "id": raw_slot["id"],
+ "ctrl": raw_slot["ctrl"],
+ "alt": raw_slot["alt"],
+ "shift": raw_slot["shift"]})
+ output.append(out_menu)
return output
def get_file_size(self) -> int:
@@ -162,7 +191,11 @@ def _build_id_map(cls, menus):
def _find_version(file_size, header):
version = None
for (k, head, sizes, desc) in hk_versions:
- if file_size in sizes and header == head:
+ if header != head:
+ continue
+ # `sizes is None` means any size is valid (used for the variable-size
+ # 2026+ DE format).
+ if sizes is None or file_size in sizes:
version = k
return version
@@ -183,6 +216,8 @@ def __setitem__(self, key, value):
def serialize(self):
unparser = HkUnparser(self._file_type)
hk_dict = dict(size=self._file_size, header=self._header,
- menus=self.serialize_to_file())
+ menus=self.serialize_to_file(),
+ format=self._format,
+ handler_magic=self._handler_magic)
raw = unparser.unparse_to_bytes(hk_dict)
return compress(raw)
diff --git a/src/hotkeys/hkp/parse.py b/src/hotkeys/hkp/parse.py
index 08bdf97..d1e3cc3 100644
--- a/src/hotkeys/hkp/parse.py
+++ b/src/hotkeys/hkp/parse.py
@@ -1,118 +1,295 @@
-import struct
-from collections import namedtuple
-from enum import Enum
-
-
-class FileType(Enum):
- HKP = "HKP"
- HKI = "HKI"
-
-
-HEADER_FORMAT = COUNT_FORMAT = struct.Struct(' None:
- self._file_type = file_type
- self._reset()
-
- def _reset(self, hk_bytes=None):
- self._offset = 0
- self._result = {}
- self._hk_bytes = hk_bytes
-
- def _unpack(self, struct_format: struct.Struct = COUNT_FORMAT):
- data = struct_format.unpack_from(self._hk_bytes, self._offset)
- self._offset += struct_format.size
- return data
-
- def _parse_header(self):
- self._result['header'], = self._unpack(HEADER_FORMAT)
-
- def _parse_base_menus(self):
- self._result['menus'] = [self._parse_menu() for _ in range(3)]
-
- def _parse_menus(self):
- num_menus, = self._unpack()
- for each in range(num_menus):
- self._result['menus'].append(self._parse_menu())
-
- def _parse_menu(self):
- num_hotkeys, = self._unpack()
- return [self._parse_hotkey() for _ in range(num_hotkeys)]
-
- def _parse_hotkey(self) -> dict:
- hotkey_struct = self._unpack(HOTKEY_FORMAT)
- return Hotkey(*hotkey_struct)._asdict()
-
- def parse_to_dict(self, hk_bytes):
- self._reset(hk_bytes)
- self._parse_header()
-
- # .hkp has a 3 constant menus that initialize the array in self._result['menus']
- # before the n-number of menus are processed, meaning that when a .hki file is
- # processed the array has to be initialized intentionally
- if self._file_type == FileType.HKP:
- self._parse_base_menus()
- else:
- self._result['menus'] = []
-
- self._parse_menus()
- self._result['size'] = self._offset
- return self._result
-
- def validate_size(self):
- if self._result['size'] != len(self._hk_bytes):
- raise Exception(
- 'Size {:d} does not equal bytearray length {:d}'
- .format(self._result['size'], len(self._hk_bytes)))
-
-
-class HkUnparser(object):
- def __init__(self, file_type: FileType = FileType.HKI) -> None:
- self._file_type = file_type
- self._reset()
-
- def _reset(self, hk_dict=None):
- self._offset = 0
- self._hk_dict = hk_dict
- self._result = bytearray(hk_dict['size']) if hk_dict else None
-
- def _pack(self, *data, **kwargs):
- struct_format = kwargs.get('struct_format', COUNT_FORMAT)
- struct_format.pack_into(self._result, self._offset, *data)
- self._offset += struct_format.size
-
- def _unparse_header(self, header):
- self._pack(header, struct_format=HEADER_FORMAT)
-
- def _unparse_base_menus(self, hk_dict):
- for each in range(3):
- menu = hk_dict['menus'].pop(0)
- self._unparse_menu(menu)
- return hk_dict
-
- def _unparse_menus(self, menus):
- self._pack(len(menus))
- for menu in menus:
- self._unparse_menu(menu)
-
- def _unparse_menu(self, menu):
- self._pack(len(menu))
- for hotkey in menu:
- self._unparse_hotkey(hotkey)
-
- def _unparse_hotkey(self, hotkey):
- self._pack(*Hotkey(**hotkey), struct_format=HOTKEY_FORMAT)
-
- def unparse_to_bytes(self, hk_dict):
- self._reset(hk_dict)
- self._unparse_header(hk_dict['header'])
-
- if self._file_type == FileType.HKP:
- hk_dict = self._unparse_base_menus(hk_dict)
-
- self._unparse_menus(hk_dict['menus'])
- return self._result
+import struct
+from collections import namedtuple
+from enum import Enum
+
+
+class FileType(Enum):
+ HKP = "HKP"
+ HKI = "HKI"
+
+
+HEADER_FORMAT = COUNT_FORMAT = struct.Struct(' None:
+ self._file_type = file_type
+ self._reset()
+
+ def _reset(self, hk_bytes=None):
+ self._offset = 0
+ self._result = {}
+ self._hk_bytes = hk_bytes
+
+ def _unpack(self, struct_format: struct.Struct = COUNT_FORMAT):
+ data = struct_format.unpack_from(self._hk_bytes, self._offset)
+ self._offset += struct_format.size
+ return data
+
+ def _match_literal(self, literal: bytes):
+ end = self._offset + len(literal)
+ actual = bytes(self._hk_bytes[self._offset:end])
+ if actual != literal:
+ raise struct.error(
+ f"expected literal {literal!r} at offset 0x{self._offset:x}, "
+ f"got {actual!r}")
+ self._offset = end
+
+ def _parse_header(self):
+ self._result['header'], = self._unpack(HEADER_FORMAT)
+
+ # ----- legacy (pre-2026) format -----
+
+ def _parse_legacy_hotkey(self) -> dict:
+ return Hotkey(*self._unpack(HOTKEY_FORMAT))._asdict()
+
+ def _parse_legacy_menu(self):
+ num_hotkeys, = self._unpack()
+ return [self._parse_legacy_hotkey() for _ in range(num_hotkeys)]
+
+ def _parse_legacy_hkp_body(self):
+ self._result['menus'] = [self._parse_legacy_menu() for _ in range(3)]
+ num_extra, = self._unpack()
+ for _ in range(num_extra):
+ self._result['menus'].append(self._parse_legacy_menu())
+
+ def _parse_legacy_hki_body(self):
+ self._result['menus'] = []
+ num_extra, = self._unpack()
+ for _ in range(num_extra):
+ self._result['menus'].append(self._parse_legacy_menu())
+
+ # ----- new (2026+) format -----
+
+ def _parse_new_hotkey(self) -> dict:
+ self._match_literal(HANDLER_BEGIN)
+ magic, = self._unpack()
+ self._result.setdefault('handler_magic', magic)
+ self._match_literal(GROUP_HEADER_GUARD)
+ hotkey = Hotkey(*self._unpack(HOTKEY_FORMAT))._asdict()
+ self._match_literal(HANDLER_END)
+ return hotkey
+
+ def _parse_new_flat_section(self, begin_lit: bytes, end_lit: bytes):
+ self._match_literal(begin_lit)
+ num_hotkeys, = self._unpack()
+ menu = [self._parse_new_hotkey() for _ in range(num_hotkeys)]
+ self._match_literal(end_lit)
+ return menu
+
+ def _parse_new_detached_group(self):
+ num_hotkeys, = self._unpack()
+ self._match_literal(DETACHED_GROUP_BEGIN)
+ menu = [self._parse_new_hotkey() for _ in range(num_hotkeys)]
+ self._match_literal(DETACHED_GROUP_END)
+ return menu
+
+ def _parse_new_hkp_body(self):
+ self._match_literal(ADDITIONAL_BEGIN)
+ menus = [
+ self._parse_new_flat_section(ALL_UNIT_BEGIN, ALL_UNIT_END),
+ self._parse_new_flat_section(ALL_GAME_BEGIN, ALL_GAME_END),
+ self._parse_new_flat_section(ALL_CYCLE_BEGIN, ALL_CYCLE_END),
+ ]
+ self._match_literal(DETACHED_GROUPS_BEGIN)
+ num_groups, = self._unpack()
+ for _ in range(num_groups):
+ menus.append(self._parse_new_detached_group())
+ self._match_literal(DETACHED_GROUPS_END)
+ self._match_literal(ADDITIONAL_END)
+ self._result['menus'] = menus
+
+ def _parse_new_hki_body(self):
+ # The outer group count sits between the header and the first literal,
+ # not after it like the per-section counts in HKP files.
+ num_groups, = self._unpack()
+ self._match_literal(BASE_HOTKEYS_BEGIN)
+ self._match_literal(SHARED_GROUPS_BEGIN)
+ menus = []
+ for _ in range(num_groups):
+ num_hotkeys, = self._unpack()
+ menus.append([self._parse_new_hotkey() for _ in range(num_hotkeys)])
+ self._match_literal(SHARED_GROUPS_END)
+ self._match_literal(BASE_HOTKEYS_END)
+ self._result['menus'] = menus
+
+ # ----- entry points -----
+
+ def parse_to_dict(self, hk_bytes):
+ self._reset(hk_bytes)
+ self._parse_header()
+ self._result['format'] = 'new' if self._result['header'] == NEW_HEADER else 'legacy'
+
+ if self._result['format'] == 'new':
+ if self._file_type == FileType.HKP:
+ self._parse_new_hkp_body()
+ else:
+ self._parse_new_hki_body()
+ else:
+ if self._file_type == FileType.HKP:
+ self._parse_legacy_hkp_body()
+ else:
+ self._parse_legacy_hki_body()
+
+ self._result['size'] = self._offset
+ return self._result
+
+ def validate_size(self):
+ if self._result['size'] != len(self._hk_bytes):
+ raise Exception(
+ 'Size {:d} does not equal bytearray length {:d}'
+ .format(self._result['size'], len(self._hk_bytes)))
+
+
+class HkUnparser(object):
+ def __init__(self, file_type: FileType = FileType.HKI) -> None:
+ self._file_type = file_type
+ self._reset()
+
+ def _reset(self, hk_dict=None):
+ self._offset = 0
+ self._hk_dict = hk_dict
+ # New-format payloads have variable size due to the section literals,
+ # so we build the buffer by appending instead of pre-allocating.
+ self._result = bytearray()
+
+ def _pack(self, *data, **kwargs):
+ struct_format = kwargs.get('struct_format', COUNT_FORMAT)
+ self._result += struct_format.pack(*data)
+ self._offset += struct_format.size
+
+ def _emit_literal(self, literal: bytes):
+ self._result += literal
+ self._offset += len(literal)
+
+ def _unparse_header(self, header):
+ self._pack(header, struct_format=HEADER_FORMAT)
+
+ # ----- legacy -----
+
+ def _unparse_legacy_hotkey(self, hotkey):
+ self._pack(*Hotkey(**hotkey), struct_format=HOTKEY_FORMAT)
+
+ def _unparse_legacy_menu(self, menu):
+ self._pack(len(menu))
+ for hotkey in menu:
+ self._unparse_legacy_hotkey(hotkey)
+
+ def _unparse_legacy_hkp(self, hk_dict):
+ menus = hk_dict['menus']
+ for i in range(3):
+ self._unparse_legacy_menu(menus[i])
+ extra = menus[3:]
+ self._pack(len(extra))
+ for menu in extra:
+ self._unparse_legacy_menu(menu)
+
+ def _unparse_legacy_hki(self, hk_dict):
+ menus = hk_dict['menus']
+ self._pack(len(menus))
+ for menu in menus:
+ self._unparse_legacy_menu(menu)
+
+ # ----- new -----
+
+ def _unparse_new_hotkey(self, hotkey, magic):
+ self._emit_literal(HANDLER_BEGIN)
+ self._pack(magic)
+ self._emit_literal(GROUP_HEADER_GUARD)
+ self._pack(*Hotkey(**hotkey), struct_format=HOTKEY_FORMAT)
+ self._emit_literal(HANDLER_END)
+
+ def _unparse_new_flat_section(self, menu, begin_lit, end_lit, magic):
+ self._emit_literal(begin_lit)
+ self._pack(len(menu))
+ for hotkey in menu:
+ self._unparse_new_hotkey(hotkey, magic)
+ self._emit_literal(end_lit)
+
+ def _unparse_new_detached_group(self, menu, magic):
+ self._pack(len(menu))
+ self._emit_literal(DETACHED_GROUP_BEGIN)
+ for hotkey in menu:
+ self._unparse_new_hotkey(hotkey, magic)
+ self._emit_literal(DETACHED_GROUP_END)
+
+ def _unparse_new_hkp(self, hk_dict):
+ menus = hk_dict['menus']
+ magic = hk_dict.get('handler_magic', HANDLER_MAGIC)
+ self._emit_literal(ADDITIONAL_BEGIN)
+ self._unparse_new_flat_section(menus[0], ALL_UNIT_BEGIN, ALL_UNIT_END, magic)
+ self._unparse_new_flat_section(menus[1], ALL_GAME_BEGIN, ALL_GAME_END, magic)
+ self._unparse_new_flat_section(menus[2], ALL_CYCLE_BEGIN, ALL_CYCLE_END, magic)
+ self._emit_literal(DETACHED_GROUPS_BEGIN)
+ detached = menus[3:]
+ self._pack(len(detached))
+ for menu in detached:
+ self._unparse_new_detached_group(menu, magic)
+ self._emit_literal(DETACHED_GROUPS_END)
+ self._emit_literal(ADDITIONAL_END)
+
+ def _unparse_new_hki(self, hk_dict):
+ menus = hk_dict['menus']
+ magic = hk_dict.get('handler_magic', HANDLER_MAGIC)
+ self._pack(len(menus))
+ self._emit_literal(BASE_HOTKEYS_BEGIN)
+ self._emit_literal(SHARED_GROUPS_BEGIN)
+ for menu in menus:
+ self._pack(len(menu))
+ for hotkey in menu:
+ self._unparse_new_hotkey(hotkey, magic)
+ self._emit_literal(SHARED_GROUPS_END)
+ self._emit_literal(BASE_HOTKEYS_END)
+
+ # ----- entry point -----
+
+ def unparse_to_bytes(self, hk_dict):
+ self._reset(hk_dict)
+ self._unparse_header(hk_dict['header'])
+
+ is_new = hk_dict.get('format') == 'new' or hk_dict['header'] == NEW_HEADER
+ if is_new:
+ if self._file_type == FileType.HKP:
+ self._unparse_new_hkp(hk_dict)
+ else:
+ self._unparse_new_hki(hk_dict)
+ else:
+ if self._file_type == FileType.HKP:
+ self._unparse_legacy_hkp(hk_dict)
+ else:
+ self._unparse_legacy_hki(hk_dict)
+
+ return bytes(self._result)
diff --git a/src/hotkeys/static/hotkeys/defaults/170934/Base.hkp b/src/hotkeys/static/hotkeys/defaults/170934/Base.hkp
new file mode 100644
index 0000000..3f05293
Binary files /dev/null and b/src/hotkeys/static/hotkeys/defaults/170934/Base.hkp differ
diff --git a/src/hotkeys/static/hotkeys/defaults/170934/Profile.hkp b/src/hotkeys/static/hotkeys/defaults/170934/Profile.hkp
new file mode 100644
index 0000000..ae3f1e5
Binary files /dev/null and b/src/hotkeys/static/hotkeys/defaults/170934/Profile.hkp differ
diff --git a/src/hotkeys/static/hotkeys/defaults/170934/key-value-paphos-strings-utf8.txt b/src/hotkeys/static/hotkeys/defaults/170934/key-value-paphos-strings-utf8.txt
new file mode 100644
index 0000000..a6ed37a
--- /dev/null
+++ b/src/hotkeys/static/hotkeys/defaults/170934/key-value-paphos-strings-utf8.txt
@@ -0,0 +1,1202 @@
+//10 Leaders of Achaemenid Civilization
+106440 "10"
+106441 "Darius"
+106442 "Datis"
+106443 "Artaphernes"
+106444 "Cyrus"
+106445 "Cambyses"
+106446 "Xerxes"
+106447 "Artaxerxes"
+106448 "Mardonius"
+106449 "Boges"
+106450 "Tissaphernes"
+
+//10 Leaders of Athenian Civilization
+106460 "10"
+106461 "Themistocles"
+106462 "Aristides"
+106463 "Pericles"
+106464 "Miltiades"
+106465 "Cleon"
+106466 "Nicias"
+106467 "Cimon"
+106468 "Demosthenes"
+106469 "Thucydides"
+106470 "Iphicrates"
+
+//10 Leaders of Spartan Civilization
+106480 "10"
+106481 "Leonidas"
+106482 "Lysander"
+106483 "Brasidas"
+106484 "Pausanias"
+106485 "Agis"
+106486 "Gylippus"
+106487 "Archidamus"
+106488 "Cleomenes"
+106489 "Pleistoanax"
+106490 "Clearchus"
+
+405001 "Immortal (Melee)"
+405002 "Elite Immortal (Melee)"
+405003 "Strategos"
+405004 "Elite Strategos"
+405005 "Hippeus"
+405006 "Elite Hippeus"
+405007 "Hoplite"
+405008 "Elite Hoplite"
+405009 "Lembos"
+405010 "War Lembos"
+405011 "Heavy Lembos"
+405012 "Elite Lembos"
+405013 "Monoreme"
+405014 "Bireme"
+405015 "Trireme"
+405016 "Galley"
+405017 "War Galley"
+405018 "Elite Galley"
+405019 "Incendiary Raft"
+405020 "Incendiary Ship"
+405021 "Heavy Incendiary Ship"
+405022 "Oyster Gatherer"
+405024 "Catapult Ship"
+405025 "Onager Ship"
+405026 "Leviathan"
+405027 "Shipyard"
+405028 "Port"
+405029 "War Chariot"
+405030 "Elite War Chariot"
+405031 "Polemarch"
+405033 "Merchant Ship"
+405034 "Oystering Ship"
+405036 "Immortal (Ranged)"
+405037 "Elite Immortal (Ranged)"
+405038 "Transport Ship (Antiquity)"
+405039 "Levy"
+405040 "Maceman"
+405041 "Swordsman"
+405042 "Paragon"
+405043 "Spearman"
+405044 "Guardsman"
+405045 "Elite Guardsman"
+405046 "Scout Cavalry"
+405047 "Light Cavalry"
+405048 "Raider"
+405049 "Lancer"
+405050 "Shock Cavalry"
+405051 "Imperial Cavalry"
+405052 "Bowman"
+405053 "Laminated Bowman"
+405054 "Recurve Bowman"
+405055 "Gastraphetoros"
+405056 "Skirmisher"
+405057 "Elite Skirmisher"
+405058 "Cavalry Archer"
+405059 "Heavy Cavalry Archer"
+405060 "Fishing Ship"
+405061 "Transport"
+405062 "Palintonon (Packed)"
+405063 "Palintonon"
+405064 "Priestess"
+405065 "Fort"
+405066 "Temple"
+405067 "Priestess with Relic"
+405068 "Academy"
+405069 "Galley - Antiquity"
+405070 "War Galley - Antiquity"
+405083 "Bastion"
+405084 "Immortal"
+405085 "Elite Immortal"
+405087 "Fishing Ship"
+405088 "Fishing Ship"
+405089 "Skirmisher"
+405090 "Elite Skirmisher"
+405091 "Cavalry Archer"
+405092 "Heavy Cavalry Archer"
+405093 "Battering Ram"
+405094 "Capped Ram"
+405095 "Siege Ram"
+405096 "Mangonel"
+405097 "Onager"
+405098 "Siege Onager"
+405099 "Scorpion"
+405100 "Heavy Scorpion"
+405101 "Siege Tower"
+405102 "Transport Ship"
+405103 "Villager"
+405104 "Trade Cart"
+405105 "Barracks"
+405106 "Archery Range"
+405107 "Stable"
+405108 "Siege Workshop"
+405109 "Blacksmith"
+405110 "Fish Trap"
+405111 "Outpost"
+405112 "Palisade Wall"
+405113 "Palisade Gate"
+405114 "Stone Wall"
+405115 "Gate"
+405116 "Fortified Wall"
+405117 "Fortified Palisade Wall"
+405118 "House"
+405119 "Wonder"
+405120 "Mining Camp"
+405121 "Lumber Camp"
+405122 "Mill"
+405123 "Farm"
+405124 "Market"
+405125 "Watch Tower"
+405126 "Town Center"
+
+406001 "Create Immortal"
+406002 "Create Elite Immortal"
+406003 "Create Strategos"
+406004 "Create Elite Strategos"
+406005 "Create Hippeus"
+406006 "Create Elite Hippeus"
+406007 "Create Hoplite"
+406008 "Create Elite Hoplite"
+406009 "Build Lembos"
+406010 "Build War Lembos"
+406011 "Build Heavy Lembos"
+406012 "Build Elite Lembos"
+406013 "Build Monoreme"
+406014 "Build Bireme"
+406015 "Build Trireme"
+406016 "Build Galley"
+406017 "Build War Galley"
+406018 "Build Elite Galley"
+406019 "Build Incendiary Raft"
+406020 "Build Incendiary Ship"
+406021 "Build Heavy Incendiary Ship"
+406024 "Build Catapult Ship"
+406025 "Build Onager Ship"
+406026 "Build Leviathan"
+406027 "Build Shipyard"
+406028 "Build Port"
+406029 "Create War Chariot"
+406030 "Create Elite War Chariot"
+406031 "Create Polemarch"
+406033 "Build Merchant Ship"
+406039 "Create Levy"
+406040 "Create Maceman"
+406041 "Create Swordsman"
+406042 "Create Paragon"
+406043 "Create Spearman"
+406044 "Create Guardsman"
+406045 "Create Elite Guardsman"
+406046 "Create Scout Cavalry"
+406047 "Create Light Cavalry"
+406048 "Create Raider"
+406049 "Create Lancer"
+406050 "Create Shock Cavalry"
+406051 "Create Imperial Cavalry"
+406052 "Create Bowman"
+406053 "Create Laminated Bowman"
+406054 "Create Recurve Bowman"
+406055 "Create Gastraphetoros"
+406056 "Create Skirmisher"
+406057 "Create Elite Skirmisher"
+406058 "Create Cavalry Archer"
+406059 "Create Heavy Cavalry Archer"
+406060 "Create Fishing Ship"
+406061 "Create Transport"
+406062 "Build Palintonon"
+406064 "Create Priestess"
+406065 "Build Fort"
+406066 "Build Temple"
+406068 "Build Academy"
+406069 "Create Mercenary Hoplite"
+406070 "Create Rhodian Slinger"
+406071 "Create Greek Noble Cavalry"
+406072 "Create Scythian Axe Cavalry"
+406073 "Create Bactrian Archer"
+406074 "Create Ekdromos"
+406075 "Create Cretan Archer"
+406076 "Create Camel Raider"
+406077 "Create Tarantine Cavalry"
+406078 "Create Sparabara"
+406079 "Create Sickle Warrior"
+406080 "Create Mercenary Peltast"
+406081 "Create Sakan Axeman"
+406082 "Create Polemarch"
+406083 "Build Bastion"
+406085 "Create Lysander's Raider"
+406087 "Build Fishing Ship"
+406088 "Build Fishing Ship"
+406089 "Create Skirmisher"
+406090 "Create Elite Skirmisher"
+406091 "Create Cavalry Archer"
+406092 "Create Heavy Cavalry Archer"
+406093 "Build Battering Ram"
+406094 "Build Capped Ram"
+406095 "Build Siege Ram"
+406096 "Build Mangonel"
+406097 "Build Onager"
+406098 "Build Siege Onager"
+406099 "Build Scorpion"
+406100 "Build Heavy Scorpion"
+406101 "Build Siege Tower"
+406102 "Build Transport Ship"
+406103 "Create Villager"
+406104 "Build Trade Cart"
+406105 "Build Barracks"
+406106 "Build Archery Range"
+406107 "Build Stable"
+406108 "Build Siege Workshop"
+406109 "Build Blacksmith"
+406110 "Build Fish Trap"
+406111 "Build Outpost"
+406112 "Build Palisade Wall"
+406113 "Build Palisade Gate"
+406114 "Build Stone Wall"
+406115 "Build Gate"
+406116 "Build Fortified Wall"
+406117 "Build Fortified Palisade Wall"
+406118 "Build House"
+406119 "Build Wonder"
+406120 "Build Mining Camp"
+406121 "Build Lumber Camp"
+406122 "Build Mill"
+406123 "Build Farm"
+406124 "Build Market"
+406125 "Build Watch Tower"
+406126 "Build Town Center"
+
+414001 "Immortal"
+414002 "Elite\nImmortal"
+414003 "Strategos"
+414004 "Elite\nStrategos"
+414005 "Hippeus"
+414006 "Elite\nHippeus"
+414007 "Hoplite"
+414008 "Elite\nHoplite"
+414009 "Lembos"
+414010 "War Lembos"
+414011 "Heavy\nLembos"
+414012 "Elite\nLembos"
+414013 "Monoreme"
+414014 "Bireme"
+414015 "Trireme"
+414016 "Galley"
+414017 "War Galley"
+414018 "Elite\nGalley"
+414019 "Incendiary\nRaft"
+414020 "Incendiary\nShip"
+414021 "Heavy\nIncen. Ship"
+414024 "Catapult\nShip"
+414025 "Onager\nShip"
+414026 "Leviathan"
+414027 "Shipyard"
+414028 "Port"
+414029 "War\nChariot"
+414030 "Elite\nWar Chariot"
+414031 "Polemarch"
+414033 "Merchant\nShip"
+414039 "Levy"
+414040 "Maceman"
+414041 "Swordsman"
+414042 "Paragon"
+414043 "Spearman"
+414044 "Guardsman"
+414045 "Elite\nGuardsman"
+414046 "Scout\nCavalry"
+414047 "Light\nCavalry"
+414048 "Raider"
+414049 "Lancer"
+414050 "Shock Cavalry"
+414051 "Imperial Cavalry"
+414052 "Bowman"
+414053 "Laminated\nBowman"
+414054 "Recurve\nBowman"
+414055 "Gastraphetoros"
+414056 "Skirmisher"
+414057 "Elite\nSkirmisher"
+414058 "Cavalry\nArcher"
+414059 "Heavy\nCavalry Archer"
+414060 "Fishing\nShip"
+414061 "Transport"
+414062 "Palintonon"
+414064 "Priestess"
+414065 "Fort"
+414066 "Temple"
+414068 "Academy"
+414083 "Bastion"
+414087 "Fishing Ship"
+414088 "Fishing Ship"
+414089 "Skirmisher"
+414090 "Elite\nSkirmisher"
+414091 "Cavalry\nArcher"
+414092 "Heavy Cavalry\nArcher"
+414093 "Battering\nRam"
+414094 "Capped\nRam"
+414095 "Siege\nRam"
+414096 "Mangonel"
+414097 "Onager"
+414098 "Siege\nOnager"
+414099 "Scorpion"
+414100 "Heavy\nScorpion"
+414101 "Siege\nTower"
+414102 "Transport\nShip"
+414103 "Villager"
+414104 "Trade\nCart"
+414105 "Barracks"
+414106 "Archery\nRange"
+414107 "Stable"
+414108 "Siege\nWorkshop"
+414109 "Blacksmith"
+414110 "Fish\nTrap"
+414111 "Outpost"
+414112 "Palisade\nWall"
+414113 "Palisade\nGate"
+414114 "Stone\nWall"
+414115 "Gate"
+414116 "Fortified\nWall"
+414117 "Fortified\nPalisade Wall"
+414118 "House"
+414119 "Wonder"
+414120 "Mining\nCamp"
+414121 "Lumber\nCamp"
+414122 "Mill"
+414123 "Farm"
+414124 "Market"
+414125 "Watch\nTower"
+414126 "Town\nCenter"
+
+426001 "Create Immortal ()\nAchaemenid unique infantry that can swap between a spear and bow. Strong vs infantry and cavalry. Weak vs archers. Upgrades: attack, armor (Blacksmith); speed (Barracks); creation speed, to Elite Immortal 850F, 350G (Fort); more resistant to Priestesses (Temple).\n "
+426002 "Create Elite Immortal ()\nAchaemenid unique infantry that can swap between a spear and bow. Strong vs infantry and cavalry. Weak vs archers. Upgrades: attack, armor (Blacksmith); speed (Barracks); creation speed (Fort); more resistant to Priestesses (Temple).\n "
+426003 "Create Strategos ()\nAthenian heavy infantry that gives +1 attack to nearby units. Strong vs. infantry. Upgrades: attack, armor (Blacksmith); speed (Barracks); creation speed, to Elite Strategos 700F, 800G (Fort); more resistant to Priestesses (Temple).\n "
+426004 "Create Elite Strategos ()\nAthenian heavy infantry that gives +1 attack to nearby units. Strong vs. infantry. Upgrades: attack, armor (Blacksmith); speed (Barracks); creation speed (Fort); more resistant to Priestesses (Temple).\n "
+426005 "Create Hippeus ()\nSpartan heavy infantry with high HP. Strong vs. infantry and archers. Upgrades: attack, armor (Blacksmith); speed (Barracks); creation speed, to Elite Hippeus 800F, 600G (Fort); more resistant to Priestesses (Temple).\n "
+426006 "Create Elite Hippeus ()\nSpartan heavy infantry with high HP. Strong vs. infantry and archers. Upgrades: attack, armor (Blacksmith); speed (Barracks); creation speed (Fort); more resistant to Priestesses (Temple).\n "
+426007 "Create Hoplite ()\nGreek regional infantry that gives nearby Hoplites +1 melee armor. Strong vs. infantry. Upgrades: attack, armor (Blacksmith); speed, to Elite Hoplite 600F, 550G (Barracks); creation speed (Fort); more resistant to Priestesses (Temple).\n "
+426008 "Create Elite Hoplite ()\nGreek regional infantry that gives nearby Elite Hoplites +1/+1 armor. Strong vs. infantry. Upgrades: attack, armor (Blacksmith); speed (Barracks); creation speed (Fort); more resistant to Priestesses (Temple).\n "
+426009 "Build Lembos ()\nLight scouting ship with weak melee attack. Strong vs. Galleys and Catapult Ships. Weak vs. Monoremes and Incendiary Rafts. Upgrades: speed, cost, to War Lembos 80F, 30G (Port); armor (Shipyard); more resistant to Priestesses (Temple).\n "
+426010 "Build War Lembos ()\nLight scouting ship with weak melee attack. Strong vs. Galleys and Catapult Ships. Weak vs. Monoremes and Incendiary Rafts. Upgrades: speed, cost, to Heavy Lembos 150F, 80G (Port); armor (Shipyard); more resistant to Priestesses (Temple).\n "
+426011 "Build Heavy Lembos ()\nLight scouting ship with weak melee attack. Strong vs. Galleys and Catapult Ships. Weak vs. Monoremes and Incendiary Rafts. Upgrades: speed, cost, to Elite Lembos 350F, 170G (Port); armor (Shipyard); more resistant to Priestesses (Temple).\n "
+426012 "Build Elite Lembos ()\nLight scouting ship with weak melee attack. Strong vs. Galleys and Catapult Ships. Weak vs. Monoremes and Incendiary Rafts. Upgrades: speed, cost (Port); armor (Shipyard); more resistant to Priestesses (Temple).\n "
+426013 "Build Monoreme ()\nMelee warship with a charge attack. Strong vs. Catapult Ships and Lemboi. Weak vs. Galleys and Incendiary Rafts. Upgrades: speed, cost (Port); armor, to Bireme 160W, 95G (Shipyard); more resistant to Priestesses (Temple).\n "
+426014 "Build Bireme ()\nMelee warship with a charge attack. Strong vs. Catapult Ships and Lemboi. Weak vs. Galleys and Incendiary Rafts. Upgrades: speed, cost (Port); armor, to Trireme 280W, 250G (Shipyard); more resistant to Priestesses (Temple).\n "
+426015 "Build Trireme ()\nMelee warship with a charge attack. Strong vs. Catapult Ships and Lemboi. Weak vs. Galleys and Incendiary Rafts. Upgrades: speed, cost (Port); armor (Shipyard); more resistant to Priestesses (Temple).\n "
+426016 "Build Galley ()\nAll-purpose warship with ranged attack. Strong vs. Monoremes and Incendiary Rafts. Weak vs. Catapult Ships and Lemboi. Upgrades: speed, cost (Port); armor, to War Galley 135F, 75G (Shipyard); attack, range (Blacksmith); attack, accuracy (Academy); more resistant to Priestesses (Temple).\n "
+426017 "Build War Galley ()\nAll-purpose warship with ranged attack. Strong vs. Monoremes and Incendiary Rafts. Weak vs. Catapult Ships and Lemboi. Upgrades: speed, cost (Port); armor, to Elite Galley 250F, 225G (Shipyard); attack, range (Blacksmith); attack, accuracy (Academy); more resistant to Priestesses (Temple).\n "
+426018 "Build Elite Galley ()\nAll-purpose warship with ranged attack. Strong vs. Monoremes and Incendiary Rafts. Weak vs. Catapult Ships and Lemboi. Upgrades: speed, cost (Port); armor (Shipyard); attack, range (Blacksmith); attack, accuracy (Academy); more resistant to Priestesses (Temple).\n "
+426019 "Build Incendiary Raft ()\nSingle use explosive warship with splash damage and high attack. Strong vs. units at close range. Weak vs. units at long range. Upgrades: speed, cost (Port); armor, to Incendiary Ship 155W, 80G (Shipyard); more resistant to Priestesses (Temple).\n "
+426020 "Build Incendiary Ship ()\nSingle use explosive warship with splash damage and high attack. Strong vs. units at close range. Weak vs. units at long range. Upgrades: speed, cost (Port); armor, to Heavy Incendiary Ship 250W, 200G (Shipyard); more resistant to Priestesses (Temple).\n "
+426021 "Build Heavy Incendiary Ship ()\nSingle use explosive warship with splash damage and high attack. Strong vs. units at close range. Weak vs. units at long range. Upgrades: speed, cost (Port); armor (Shipyard); more resistant to Priestesses (Temple).\n "
+426024 "Build Catapult Ship ()\nLong range warship with high damage splash attack, but minimum range. Strong vs. massed units at long range. Weak vs. units at close range. Upgrades: speed, cost (Port); armor, to Onager Ship 600F, 500G (Shipyard); attack, range (Academy); more resistant to Priestesses (Temple).\n "
+426025 "Build Onager Ship ()\nLong range warship with high damage splash attack, but minimum range. Strong vs. massed units at long range. Weak vs. units at close range. Upgrades: speed, cost (Port); armor (Shipyard); attack, range (Academy); more resistant to Priestesses (Temple).\n "
+426026 "Build Leviathan ()\nVery long range anti-building warship. Makes nearby warships attack faster. Strong vs. buildings. Weak vs. units. Upgrades: speed, cost (Port); armor (Shipyard); range (Academy); more resistant to Priestesses (Temple).\n "
+426027 "Build Shipyard ()\nUsed to build and upgrade military ships. Upgrades: line of sight (Town Center); hit points, armor (Academy); more resistant to Priestesses (Temple).\n