#!/usr/bin/env python3
"""STELLAVANITY English translation patcher.

Reads your own original SV123.exe and produces an install-ready `translation-patch-v<version>/`
folder:
  - SV123_EN.exe : the original exe with the English strings patched IN-PLACE (same size, no
    relocation) plus the disk-priority patch, so the engine
    loads the loose English data + translated images from disk before the .sv archives (also useful to apply custom mods).
  - datpatch2/   : loose English .dat files (medals, accessory descriptions), built by
    overlaying translations.json onto the original JP templates from templates/.
  - translated UI image overrides (from loose_images/).
  - manual english/ : a English translation of the game's HTML manual.
  - lore archive english/ : English translation of the original (now-offline) website's
    story/world/character/tips pages, recovered from the Internet Archive.
  - INSTALL.txt.

All translated text comes from translations.json. Each exe string
is written over the original bytes within its original byte budget; the script errors (never
truncates) if a translation would overflow. Only the exact string locations recorded in
translations.json are touched, and each is verified against its original bytes before writing.

Optional extras:
  --config-exe PATH : also translate the standalone graphics config tool (起動前設定.exe) ->
    config_en.exe (in-place strings).
  --practice        : also build SV123_EN_FREE_PRACTICE.exe — a copy of SV123_EN.exe with every
    Practice-menu option unlocked (all difficulties x areas 5-7 + the fake/true bosses content (Elm Order, Vier Dummy and Vier)),
    by NOPing the 27 shop-purchase gates in the practice menu. No save edit needed.

Usage:
  python3 patch.py --exe "/path/to/your/SV123.exe" [--out DIR] \
                   [--config-exe "/path/to/your/起動前設定.exe"] [--practice]
"""
import argparse, json, os, shutil, struct, sys

HERE = os.path.dirname(os.path.abspath(__file__))
EXE_NAME = "SV123_EN.exe"

def _read_version():
    """Version is the first line of changelog.txt (e.g. 'v1.0'); the sole place to bump it."""
    try:
        first = open(os.path.join(HERE, "changelog.txt"), encoding="utf-8").readline().strip()
    except OSError:
        return "0.0"
    return (first.split()[0].lstrip("vV") if first.split() else "") or "0.0"

VERSION = _read_version()
PRIORITY_FOFF = 0x44bd70
PRIORITY_OLD = bytes.fromhex("0f85c3000000")
PRIORITY_NEW = bytes.fromhex("e9c400000090")

INSTALL_TXT = """STELLAVANITY English translation patch v{version} — install
{rule}

Translated by Yorugith, not associated with TRIS-GRAM or Feathered Ether.

1. Back up your original SV123.exe, your save files (system.dat, system2.dat), and the
   replay/ folder.
2. Copy EVERYTHING in this folder into your STELLAVANITY game folder
   (the one containing the original SV123.exe). In a normal install nothing is overwritten:
     - {exe}          -> the translated executable (run this instead of SV123.exe)
     - 
     - datpatch2/         -> loose English data files (medals, accessory descriptions)
{images_line}{extras_line}
3. Launch {exe}.

Notes:
 - The translated exe loads the loose datpatch2/ files from disk (disk-priority patch);
   keep them next to the exe.
 - Your original SV123.exe is untouched; you can always go back to it.
 - This is a fan translation patch — it requires your own legally-obtained copy of the game.
"""


class PE:
    """Minimal PE reader: virtual address -> file offset (for in-place string patching)."""
    def __init__(self, data):
        self.d = bytearray(data)
        e = struct.unpack_from("<I", self.d, 0x3c)[0]; coff = e + 4
        nsec = struct.unpack_from("<H", self.d, coff + 2)[0]
        opt = coff + 20; opt_size = struct.unpack_from("<H", self.d, coff + 16)[0]
        self.imgbase = struct.unpack_from("<I", self.d, opt + 28)[0]
        sectbl = opt + opt_size
        self.sections = []
        for i in range(nsec):
            o = sectbl + i * 40
            vs, va, srd, prd = struct.unpack_from("<IIII", self.d, o + 8)
            self.sections.append((va, vs, prd, srd))

    def va2off(self, va):
        r = va - self.imgbase
        for va0, vs, prd, srd in self.sections:
            if va0 <= r < va0 + max(vs, srd):
                return prd + (r - va0)
        return None


# Practice-menu purchase gates: the 27 callers of the "is this item owned?" check
# FUN_004c1b10 inside the practice-menu builder (build loop 0x7ae5e4..0x7aed0d). Each is a
# `je rel8` (74 XX) that skips registering a (difficulty, area/boss) option when the matching
# "PRACTICE …" shop item is not owned. NOPing both bytes (90 90) makes every option always
# register -> all practice unlocked (all difficulties x areas 5-7 + the "???" secret/true-boss
# content, incl. Elm Order, Vier Dummy and Vier). Only these 54 bytes change; the exe stays the same size.
PRACTICE_JE_VAS = [
    0x7ae5e4, 0x7ae61b, 0x7ae653, 0x7ae68b, 0x7ae6c3, 0x7ae6fb, 0x7ae733, 0x7ae76b, 0x7ae7a3,
    0x7ae7db, 0x7ae813, 0x7ae84b, 0x7ae883, 0x7ae8bb, 0x7ae8f3, 0x7ae92b, 0x7ae963, 0x7ae99b,
    0x7ae9d3, 0x7aea5f, 0x7aeaeb, 0x7aeb77, 0x7aebd9, 0x7aec3b, 0x7aec9d, 0x7aecd5, 0x7aed0d,
]

# Practice RANK selector lock. On both the rank-increase (0x7af54c) and rank-decrease
# (0x7afb5c) paths the menu does `xor eax,eax; … ; mov [ebp-0x524], eax` (89 85 …), i.e. it
# stores a "rank change allowed" flag as 0 (locked). Flipping the ModRM 0x85 -> 0x8d makes it
# `mov [ebp-0x524], ecx` — ECX is the non-zero rank-selector pointer, so the flag becomes
# non-zero (unlocked) and RANK becomes selectable.
#
# Credit: these two rank-unlock addresses came from the svprac by
# inakilbss (https://github.com/inakilbss/svprac).
RANK_ENABLE_VAS = [0x7af54c, 0x7afb5c]   # 0x85 (mov [ebp-0x524],eax) -> 0x8d (mov [ebp-0x524],ecx)


def patch_strings(pe, strings):
    """In-place patch a list of {va, max_bytes, original, english} into pe.d (same size).

    Asserts every English translation fits its original byte budget (errors, never truncates),
    and verifies the original bytes before overwriting. Returns the number of strings written.
    """
    d = pe.d
    inplace, errors = [], []
    for e in strings:
        tgt = e.get("english")
        if not tgt:
            continue
        off = pe.va2off(int(e["va"], 16))
        orig = e["original"].encode("cp932"); tb = tgt.encode("cp932")
        if len(tb) > e["max_bytes"]:
            errors.append("OVERFLOW %s %dB>%dB: %r" % (e["va"], len(tb), e["max_bytes"], tgt))
        else:
            inplace.append((off, e["max_bytes"], tb, orig))
    if errors:
        sys.exit("translation(s) exceed their byte budget:\n" + "\n".join("  " + x for x in errors))
    for off, budget, tb, orig in inplace:
        have = bytes(d[off:off + len(orig)])
        if have != orig and bytes(d[off:off + len(tb)]) != tb:
            sys.exit("in-place mismatch at 0x%x (unexpected exe bytes)" % off)
        d[off:off + budget] = tb + b"\0" * (budget - len(tb))
    return len(inplace)


def patch_exe(src_exe, out_exe, T):
    """Write out_exe = src_exe with the disk-priority patch + English strings patched in place."""
    pe = PE(open(src_exe, "rb").read()); d = pe.d

    # disk-priority patch: make the engine check the disk before the .sv archive
    cur = bytes(d[PRIORITY_FOFF:PRIORITY_FOFF + 6])
    if cur == PRIORITY_OLD:
        d[PRIORITY_FOFF:PRIORITY_FOFF + 6] = PRIORITY_NEW
    elif cur != PRIORITY_NEW:
        sys.exit("disk-priority site mismatch (not a stock v1.23 SV123.exe?): " + cur.hex())

    n = patch_strings(pe, T.get("exe", {}).get("strings", []))
    open(out_exe, "wb").write(d)
    print("  patched exe: %s (%d bytes, %d strings in place)" % (out_exe, len(d), n))


def patch_config_exe(src_exe, out_exe, T):
    """Translate the standalone graphics config tool (起動前設定.exe) in place -> config_en.exe.

    Same in-place engine as the game exe. Source text comes from translations.json's 'config_exe' section.
    """
    cfg = T.get("config_exe")
    if not cfg:
        sys.exit("--config-exe given but translations.json has no 'config_exe' section")
    pe = PE(open(src_exe, "rb").read())
    if len(pe.d) != os.path.getsize(src_exe):
        sys.exit("config exe read mismatch")
    n = patch_strings(pe, cfg.get("strings", []))
    open(out_exe, "wb").write(pe.d)
    print("  patched config exe: %s (%d bytes, %d strings in place)" % (out_exe, len(pe.d), n))


def make_free_practice(en_exe, out_exe):
    """Copy the built SV123_EN.exe and unlock every Practice option by NOPing the 27 purchase
    gates (`je` 74 XX -> 90 90) in the practice menu. Same size; only those 54 bytes change."""
    pe = PE(open(en_exe, "rb").read()); d = pe.d
    n = 0
    for va in PRACTICE_JE_VAS:
        off = pe.va2off(va)
        if d[off] != 0x74:
            sys.exit("practice gate 0x%x: expected je (0x74), found 0x%02x (wrong exe?)" % (va, d[off]))
        d[off] = 0x90; d[off + 1] = 0x90; n += 1
    r = 0
    for va in RANK_ENABLE_VAS:
        off = pe.va2off(va)
        if d[off] != 0x85:
            sys.exit("rank-enable site 0x%x: expected 0x85, found 0x%02x (wrong exe?)" % (va, d[off]))
        d[off] = 0x8d; r += 1
    open(out_exe, "wb").write(d)
    print("  free-practice exe: %s (%d bytes, %d practice gates + %d rank locks unlocked)"
          % (out_exe, len(d), n, r))


def write_loose(outdir, T):
    """Build the loose English .dat files by overlaying translations.json onto the JP templates."""
    for f in T.get("files", []):
        enc = f.get("encoding", "cp932")
        nlc = "\r\n" if f.get("newline", "crlf") == "crlf" else "\n"
        tmpl = os.path.join(HERE, "templates", f["path"])      # original JP file = structure template
        raw = open(tmpl, "rb").read().decode(enc)
        srcnl = "\r\n" if "\r\n" in raw else "\n"
        rows = [r.split(",") for r in raw.split(srcnl)]
        for en in f["entries"]:                                # overlay each translated cell
            ln, col = en["line"], en["col"]
            val = en.get("english") or en["original"]
            if ln < len(rows) and col < len(rows[ln]):
                rows[ln][col] = val
        text = nlc.join(",".join(c) for c in rows)
        p = os.path.join(outdir, f["path"]); os.makedirs(os.path.dirname(p), exist_ok=True)
        open(p, "wb").write(text.encode(enc))
        print("  loose: %s (%d entries)" % (f["path"], len(f["entries"])))


def bundle_images(outdir):
    """Copy the UI image overrides from loose_images/ (skip *_original* snapshots and .json)."""
    n = 0
    img_src = os.path.join(HERE, "loose_images")
    if os.path.isdir(img_src):
        for root, _, files in os.walk(img_src):
            for f in files:
                if not f.endswith(".png") or "_original" in f:
                    continue
                rel = os.path.relpath(os.path.join(root, f), img_src)
                dst = os.path.join(outdir, rel); os.makedirs(os.path.dirname(dst), exist_ok=True)
                shutil.copy2(os.path.join(root, f), dst); n += 1
    print("  bundled %d images" % n)
    return n


def bundle_manual(outdir):
    """Copy the translated HTML manual into the patch as 'manual english/'."""
    src = os.path.join(HERE, "manual english")
    if not os.path.isdir(src):
        return False
    shutil.copytree(src, os.path.join(outdir, "manual english"))
    print("  bundled English manual (manual english/)")
    return True


def bundle_lore(outdir):
    """Copy the translated lore archive into the patch as 'lore archive english/'.

    These are the story/world/character/tips pages from the original (now-offline)
    feathered-ether.com site, recovered from the Internet Archive and translated to English by AI.
    """
    src = os.path.join(HERE, "lore archive english")
    if not os.path.isdir(src):
        return False
    shutil.copytree(src, os.path.join(outdir, "lore archive english"))
    print("  bundled English lore archive (lore archive english/)")
    return True


def main():
    ap = argparse.ArgumentParser(description="Build the STELLAVANITY English patch from your own SV123.exe.")
    ap.add_argument("--exe", required=True, help="path to your original SV123.exe")
    ap.add_argument("--out", default=os.path.join(HERE, "translation-patch-v" + VERSION),
                    help="output folder for the built patch (default: ./translation-patch-v%s)" % VERSION)
    ap.add_argument("--config-exe", dest="config_exe", metavar="PATH",
                    help="path to your original 起動前設定.exe (config tool); also builds config_en.exe")
    ap.add_argument("--practice", action="store_true",
                    help="also build SV123_EN_FREE_PRACTICE.exe with every practice option unlocked")
    a = ap.parse_args()

    src_exe = a.exe
    if not os.path.isfile(src_exe):
        sys.exit("SV123.exe not found: %s" % src_exe)
    if a.config_exe and not os.path.isfile(a.config_exe):
        sys.exit("config exe not found: %s" % a.config_exe)
    outdir = a.out
    if os.path.isdir(outdir):
        shutil.rmtree(outdir)
    os.makedirs(outdir)

    T = json.load(open(os.path.join(HERE, "translations.json"), encoding="utf-8"))
    print("building STELLAVANITY English translation patch v%s ..." % VERSION)
    en_exe = os.path.join(outdir, EXE_NAME)
    patch_exe(src_exe, en_exe, T)
    write_loose(outdir, T)
    images_n = bundle_images(outdir)
    has_manual = bundle_manual(outdir)
    has_lore = bundle_lore(outdir)

    extras = []
    if a.config_exe:
        patch_config_exe(a.config_exe, os.path.join(outdir, "config_en.exe"), T)
        extras.append("     - config_en.exe      -> English graphics config tool (run instead of 起動前設定.exe)\n")
    if a.practice:
        make_free_practice(en_exe, os.path.join(outdir, "SV123_EN_FREE_PRACTICE.exe"))
        extras.append("     - SV123_EN_FREE_PRACTICE.exe -> translated exe with all practice options unlocked\n")

    images_line = "     - dat/, datpatch1/  -> UI image overrides (%d files)\n" % images_n
    if has_manual:
        images_line += "     - manual english/  -> English game manual (open index.html)\n"
    if has_lore:
        images_line += "     - lore archive english/  -> English translation of the original site's story/world/character/tips pages (open index.html)\n"
    with open(os.path.join(outdir, "INSTALL.txt"), "w", encoding="utf-8") as fh:
        fh.write(INSTALL_TXT.format(version=VERSION, rule="=" * 40, exe=EXE_NAME,
                                    images_line=images_line, extras_line="".join(extras)))
    print("\nDONE -> %s" % outdir)
    print("  contents:", ", ".join(sorted(os.listdir(outdir))))


if __name__ == "__main__":
    main()
