commit caf73760194b08c87dacc83b9c11d3ec776f6305 Author: DragonHD Date: Sun Sep 20 09:57:07 2026 +0800 Initial commit: RE tooling and docs for The I of the Dragon localization/HD diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eab48df --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Ignore everything at the top level except our own code and docs. +# Original game assets, cracks, extracted/extracted-derived binaries and +# generated reports are all regenerable (or not ours) and must not be tracked. +/* +!/tools +!/docs +!/.gitignore +!/README.md + +# Belt-and-suspenders: never track game / crack / media / build artifacts +*.res +*.iso +*.avi +*.wav +*.mp3 +*.tga +*.dds +*.png +*.bin +*.exe +*.dll +*.iso +/crack/ +/original/ +/png/ +/report/ +/patch/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e94f62c --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# The I of the Dragon — fan tools (localization + HD) + +Reverse-engineering and localization tooling for *The I of the Dragon* (Topware / Primal Software). + +This repository tracks **only our own tools and documentation**. It does **not** contain the game, +its assets, or any cracked distribution — those are regenerated/produced locally from a legit copy. + +## Layout + +``` +tools/ Python tooling (unpack, repack, preprocess, font patch, decoders) +docs/ DEVLOG, format specs, patch notes +``` + +Working data (not tracked): `original/`, `png/`, `report/`, `patch/`, `crack/`. + +## Tooling + +| script | purpose | +|---|---| +| `tools/res_unpack.py` | extract a `.res` archive (index + raw blobs, 16-byte aligned) | +| `tools/res_repack.py` | rebuild a `.res` byte-identically from a directory | +| `tools/preprocess.py` | convert textures to PNG + build classification report | +| `tools/make_plan.py` | build an HD upscale plan from the report | +| `tools/decode_dat.py` | decode/encode the XOR-obfuscated `Fonts.dat` / `*Description.dat` | +| `tools/fontparse.py` | parse `Fonts.dat` into font blocks and glyph rects | +| `tools/add_section.py` | add an executable section to the game PE | +| `tools/apply_cjk.py` | apply the double-byte font-rendering patch | +| `tools/xrefs.py` | find call/jmp xrefs in the game executable | + +See `docs/` for the reverse-engineered formats and addresses. + +## Legal + +No game data is distributed here. Patches are produced by the user against their own copy. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md new file mode 100644 index 0000000..e36f72a --- /dev/null +++ b/docs/DEVLOG.md @@ -0,0 +1,54 @@ +# DEVLOG + +Working log for the *The I of the Dragon* localization / HD project. +Newest entries on top. + +## 2026-09-20 — Double-byte font patch proven + +- Reverse-engineered the whole text pipeline (see `patch-notes.md`). +- Built `tools/add_section.py` (PE section injection) and verified the patched exe still runs. +- Built `tools/apply_cjk.py`: assembles two code caves with keystone, adds a `.cjk` section, + hooks the draw loop (`0x4EACB0`) and the metrics loop (`0x4E8026`). +- **Verified on screen**: a GB2312-coded string `0xC1FA` (龙) in a menu label rendered a 16x16 + glyph at the mapped atlas position (green control test). Double-byte decode + render works. +- Game files restored afterwards (exe SHA1 `9EDB9B99...`, `Textures.res` byte-identical). + +### Next +1. Big atlas (2048x2048): ASCII glyphs (copied) + CJK glyphs; re-point `Fonts.dat` `Texture` + ASCII rects. +2. Generator for `cjk_glyph` / `cjk_metric` tables from a chosen charset. +3. Convert `Strings.dat`, scripts, and `*Description.dat` display text to GB2312. +4. Redraw baked-in text textures (menu buttons, credits, tutorial). +5. Package: patched exe + atlas + data files. + +## 2026-09-20 — HD texture preprocessing + +- Cracked the `.res` archive format and built `res_unpack.py` / `res_repack.py` + (byte-identical round-trip once 16-byte alignment was found). +- Extracted `Textures.res` (1884) and `Geometry.res` (1397). +- Built `preprocess.py` (TGA/DDS→PNG, classification) and `make_plan.py`. +- Result: 63.6 Mpix, 1882 PNG, 0 failures. Plan: 1195 upscale / 499 tiling / 182 review / 8 skip + (6 font atlases + 2 non-textures). + +## 2026-09-20 — Cracking the `.dat` obfuscation + +- `Fonts.dat` / `UnitDescription.dat` / `AIDescription.dat` decoded: + `b"==" + XOR(plaintext, "GBDFYTNE")`; key at `0x620D18`, decoder at `0x4AE1D0`. +- `Fonts.dat` parsed: 4 font blocks, per-char atlas rects; parser keeps only the first byte of `Code` + (`0x4EC168`), so the stock engine is single-byte. + +## 2026-09-20 — Initial findings + +- Engine has a data-driven multi-language system: per-language folders for scripts/speech/video/UI + textures and language columns in `Strings.dat`. +- Text is externalized: `Strings.dat` (499 entries, plain) and plain-text scripts. +- First font test: replaced the `e` glyph in `Shrift_gb_Germany.TGA` and confirmed the atlas override + works in game (rendered 龙 where `e` was). +- Investigated the "pirate build": PROPHET 2009 Topware release, languages EN/DE/PL/CZ/HU, **no + Chinese**; its exe is identical to the Steam exe (no engine patch), so the Chinese build the user + remembers was a different repack (not recoverable from it). + +## Decisions + +- Do the localization ourselves (route B): patch the engine for double-byte text, build our own font + atlas, translate only display text (never Ids / event names / unit types) to avoid trigger bugs. +- Keep the repository text-only; regenerate binaries from scripts (see `.gitignore`). diff --git a/docs/formats.md b/docs/formats.md new file mode 100644 index 0000000..958192c --- /dev/null +++ b/docs/formats.md @@ -0,0 +1,84 @@ +# File formats + +All addresses are absolute VAs in `TheIOfTheDragon.exe` (image base `0x400000`). + +## 1. `Data\Misc\Strings.dat` — plain text + +Same syntax as the other `.dat` files, but **not** obfuscated. Blocks look like: + +``` +String +{ + Id = "WindowCaption" + English = "..." + Russian = "..." + German = "..." +} +``` + +- 499 blocks in the Steam build. Keys are `String{ Id, English, Russian, German }` + (retail multilanguage builds also carry `Polish`, `Czech`, `Hungarian`, `Hungarian/Russian` variants + split across separate installed files). +- **Translate only the language values.** Never touch `Id`. +- The file is single-byte code page text (Russian is CP1251). + +## 2. `Data\Misc\Fonts.dat`, `UnitDescription.dat`, `AIDescription.dat` — XOR-obfuscated text + +Layout: `b"==" + XOR(plaintext, repeating key)`. + +- Magic `==` at VA `0x620D24`. +- Repeating XOR key `GBDFYTNE` (8 bytes) at VA `0x620D18`. +- Decoder/encoder routine at `0x4AE1D0`. + +Decoded, these are the same text format as `Strings.dat`: + +- `Fonts.dat`: `Font { Name; Language; Texture; SpaceWidth; LineDistance; Char { Code; X1;Y1;X2;Y2 } ... }` +- `UnitDescription.dat`: unit/enemy definitions (Russian comments in CP1251 + English keys). +- `AIDescription.dat`: `Brain { AI = ...; TypeAI = ...; ... }`. + +Round-trip is exact; see `tools/decode_dat.py`. + +## 3. `res` archives (`Textures.res`, `Geometry.res`) + +``` +uint32 count +repeat count: + uint32 nameLen + char[] name (backslash separated) + uint32 offset (absolute file offset) + uint32 size (exact blob size) +pad the index to 16 bytes +data region: + for each entry: write blob, then pad to 16-byte boundary + (the last blob is NOT padded) +``` + +- All offsets are 16-byte aligned. +- `Textures.res` holds 1884 entries: 947 TGA + 935 DDS (DXT1/DXT5) + 2 misc. +- `res_unpack.py` / `res_repack.py` round-trip is **byte identical** (verified by SHA1). + +## 4. Scripts + +- `Data\Scripts\\MainMission.dsc`, `Tutorial.dsc` — plain text, **loaded by the engine** + (path template `Data\Scripts\%s\...`; `.dsc` is referenced, `.csc` is not). +- Block-structured scripting language. Quoted strings are of two kinds: + - **display text** (translate): `SetMissionDescription("...")` + - **logic identifiers** (never translate): event names `EnableEvent("TimePassed")`, + `LastEvent_Type == "TimePassed"`, commands `EntityCommand("FaceThePlayer",...)`, + modes `SetGlobalAIMode("Disable")`, unit types `CreateEntity(..., "Human", ...)`, etc. +- Translating logic identifiers is what breaks mission triggers ("完成任务不触发剧情"). + +## 5. Language mechanism + +- Current language: registry `HKCU\Software\Primal\Dragon` value `language`. +- Assets are per-language by folder: `Data\Scripts\`, `Data\Sounds\Speech\`, + `Data\Video\`, `Data\Textures\Ui\...\`. +- `Fonts.dat` / `Strings.dat` are installed per language group (observed: `[en de] [pl] [cs] [hu]`; + Steam build ships a single English variant with `English/Russian/German`). +- Loose files under `Data\Textures\...` override the packed copies. + +## 6. Textures + +- TGA: uncompressed 24/32 bpp and RLE 32 bpp. +- DDS: DXT1 (535) and DXT5 (400), plus dimensions from 8x8 up to 1024x1024. +- Total 63.6 Mpix across 1884 textures. diff --git a/docs/patch-notes.md b/docs/patch-notes.md new file mode 100644 index 0000000..b933ef9 --- /dev/null +++ b/docs/patch-notes.md @@ -0,0 +1,80 @@ +# Font rendering map + double-byte patch + +## Font object layout (size `0x191C`, embedded by value in a manager struct) + +Two UI font objects live at `manager + 0x3A8` and `manager + 0x1CC4` (difference = `0x191C`). + +``` ++0x00 texture pointer (D3D texture object) ++0x04 max width ++0x08 max height ++0x0C SpaceWidth ++0x10 LineDistance ++0x14 CharDistance ++0x18 flag (1 = built) ++0x1C 256 glyph entries x 16 bytes = 4 floats (u1,v1,u2,v2) normalized UV ++0x101C 256 metric entries x 8 bytes = { int advance, int height } ++0x181C 256 "defined" bytes +``` + +The table stride differs (16 vs 8 vs 1), which is why the tables cannot be enlarged in place and a +side table is used for CJK. + +## Key code + +| VA | role | +|---|---| +| `0x4EBF90` | parse/load `Fonts.dat` (generic config reader) | +| `0x4E7DC0` | set font texture | +| `0x4E7E30` | `AddChar(code, x1,y1,x2,y2)` — normalizes rect → stores glyph float[4] + metric[2] | +| `0x4EC168` | `mov cl,[ebp]` — parser takes only the **first byte** of `Code` | +| `0x4E7FD0` | measure text; loop body `0x4E7FF0`; metric lookups `0x4E802C`, `0x4E8033` | +| `0x4EAC40` | draw text; loop head | +| `0x4EACB0` | `movzx ecx,al` — glyph lookup site in draw loop | +| `0x4EACDC`,`0x4EAE9A` | metric lookups in draw loop | +| `0x4E56A0` | `IsCharDefined(font, code)` | + +Texture is bound **once per draw call** (`[font]` + vtable `+0xF4`), so ASCII and CJK glyphs must +share one texture atlas. + +## Patch design (implemented) + +A new PE section `.cjk` holds two code caves and two big tables: + +``` +.cjk RVA 0x566000 (VA 0x966000), size 0x1C0000 + 0x00 draw_cave + 0x0100 metrics_cave + 0x0200 cjk_glyph : 65536 x 16 bytes (u1,v1,u2,v2 floats) + 0x100200 cjk_metric : 65536 x 8 bytes (advance,height ints) +``` + +Hooks (5-byte `jmp rel32`): + +- `0x4EACB0 -> draw_cave` (was `movzx ecx,al`) +- `0x4E8026 -> metrics_cave` (was `mov ebp,[ecx+0x14]`) + +Behaviour of each cave: + +- `al < 0x80`: execute the original instructions, jump back to the next instruction. +- `al >= 0x80`: decode a 2-byte code `(al<<8)|[ptr+1]`; copy the glyph float[4] into font glyph slot + `0xFF` and the metric into slot `0xFF`; set `ecx = 0xFF`, advance the string pointer by one extra + byte; jump back into the engine's unchanged quad-building code. + +Slot `0xFF` glyph lives at `font+0x100C`, its metric at `font+0x1814`; these do not collide with the +real tables. + +## Requirements / limits + +- The font texture must contain **both** ASCII and CJK glyphs (one texture per draw). + Plan: replace the UI font `Texture` with a 2048x2048 atlas and re-point the ASCII `Char` rects. +- Lead bytes >= `0x80` are reserved for DBCS. ASCII is untouched. +- Confirmed end to end: a GB2312-coded menu string rendered the mapped glyph on screen. + +## Tools + +- `tools/add_section.py` — append an executable PE section. +- `tools/apply_cjk.py` — assemble the caves (keystone), add `.cjk`, write hooks and tables. +- `tools/decode_dat.py` — decode/encode the XOR `.dat` files. +- `tools/fontparse.py` — parse `Fonts.dat` into blocks + glyph rects. +- `tools/xrefs.py`, `tools/disasm.py`, `tools/scan_disp.py` — RE helpers. diff --git a/tools/add_section.py b/tools/add_section.py new file mode 100644 index 0000000..a410d0a --- /dev/null +++ b/tools/add_section.py @@ -0,0 +1,57 @@ +import struct, sys, shutil, os + + +def align(x, a): + return (x + a - 1) // a * a + + +def main(): + src, dst = sys.argv[1], sys.argv[2] + size = int(sys.argv[3], 0) if len(sys.argv) > 3 else 0x300000 + secname = sys.argv[4] if len(sys.argv) > 4 else ".cjk" + data = bytearray(open(src, "rb").read()) + + e_lfanew = struct.unpack_from(" secs[0][2]: + print("no room for new section header", file=sys.stderr) + sys.exit(3) + + hdr = so + nsec * 40 + name = secname.encode("ascii")[:8].ljust(8, b"\x00") + struct.pack_into("<8sIIII", data, hdr, name, size, new_va, align(size, file_align), new_raw) + struct.pack_into(" %s (%d bytes)" % (secname, new_va, new_raw, size, dst, len(data))) + print("section VA (absolute, imagebase 0x%x): 0x%x" % (imagebase, imagebase + new_va)) + + +if __name__ == "__main__": + main() diff --git a/tools/apply_cjk.py b/tools/apply_cjk.py new file mode 100644 index 0000000..7df825f --- /dev/null +++ b/tools/apply_cjk.py @@ -0,0 +1,173 @@ +import struct, sys, os +from keystone import Ks, KS_ARCH_X86, KS_MODE_32 + +ORIG = r"F:\steam\steamapps\common\The I of the Dragon\TheIOfTheDragon.exe" +OUT = r"E:\DragonHD\patch\TheIOfTheDragon_cjk.exe" +TBL = r"E:\DragonHD\patch\tables" + +HOOK_DRAW_VA = 0x4EACB0 +HOOK_MEAS_VA = 0x4E8026 +BACK_DRAW_VA = 0x4EACB8 +BACK_MEAS_VA = 0x4E8049 + +SEC_SIZE = 0x1C0000 +OFF_DRAW = 0x0000 +OFF_MEAS = 0x0100 +OFF_GLYPH = 0x0200 +OFF_METRIC = 0x100200 + + +def align(x, a): + return (x + a - 1) // a * a + + +def load_tables(): + g = open(os.path.join(TBL, "cjk_glyph.bin"), "rb").read() + m = open(os.path.join(TBL, "cjk_metric.bin"), "rb").read() + assert len(g) == 65536 * 16, len(g) + assert len(m) == 65536 * 8, len(m) + return g, m + + +def main(): + data = bytearray(open(ORIG, "rb").read()) + e_lfanew = struct.unpack_from(" secs[0][2]: + print("no room for section header", file=sys.stderr) + sys.exit(3) + + # add section header + hdr = so + nsec * 40 + struct.pack_into("<8sIIII", data, hdr, b".cjk".ljust(8, b"\x00"), SEC_SIZE, new_va, align(SEC_SIZE, file_align), new_raw) + struct.pack_into(" 0x%x" % (va, target)) + + hook(HOOK_DRAW_VA, draw_va) + hook(HOOK_MEAS_VA, meas_va) + + os.makedirs(os.path.dirname(OUT), exist_ok=True) + open(OUT, "wb").write(data) + print("wrote", OUT, len(data), "bytes") + + +if __name__ == "__main__": + main() diff --git a/tools/bruteforce_transforms.py b/tools/bruteforce_transforms.py new file mode 100644 index 0000000..cefd704 --- /dev/null +++ b/tools/bruteforce_transforms.py @@ -0,0 +1,62 @@ +import os, sys, collections, math + +base = r"F:\steam\steamapps\common\The I of the Dragon\Data\Misc" +files = ["Fonts.dat", "UnitDescription.dat", "AIDescription.dat"] + +def printable_ratio(b): + if not b: return 0 + return sum(1 for x in b if 32 <= x < 127) / len(b) + +def score_text(b): + # rough score: printable ratio + bonus for common english words + r = printable_ratio(b) + sample = bytes(b) + kw = 0 + for w in [b"Char", b"Code", b"Font", b"SpaceWidth", b".tga", b"Shrift", b"Distance", b"Id", b"String"]: + kw += sample.count(w) + return r, kw + +for f in files: + p = os.path.join(base, f) + data = open(p, "rb").read() + print("="*70) + print(f, "size", len(data)) + print("first 32:", data[:32].hex(" ")) + + # 1) single-byte xor + best = [] + for k in range(256): + dec = bytes(x ^ k for x in data) + r, kw = score_text(dec) + best.append((kw, r, k, dec[:80])) + best.sort(key=lambda t: (-t[0], -t[1])) + print("-- best single-byte XOR by keyword --") + for kw, r, k, s in best[:5]: + print(f" k=0x{k:02X} kw={kw} pr={r:.3f} {s!r}") + + # 2) additive / subtractive + best = [] + for k in range(256): + dec = bytes((x - k) & 0xFF for x in data) + r, kw = score_text(dec) + best.append((kw, r, k, dec[:80])) + best.sort(key=lambda t: (-t[0], -t[1])) + print("-- best subtract key --") + for kw, r, k, s in best[:3]: + print(f" k=0x{k:02X} kw={kw} pr={r:.3f} {s!r}") + + # 3) repeating-key XOR, key length 2..64, derive key from space frequency + print("-- repeating-key XOR (key from most-common byte == space) --") + results = [] + for L in range(2, 65): + key = bytearray() + for r in range(L): + col = data[r::L] + c = collections.Counter(col).most_common(1)[0][0] + key.append(c ^ 0x20) + dec = bytes(x ^ key[i % L] for i, x in enumerate(data)) + rr, kw = score_text(dec) + results.append((kw, rr, L, bytes(key), dec[:80])) + results.sort(key=lambda t: (-t[0], -t[1])) + for kw, rr, L, key, s in results[:6]: + print(f" L={L:2d} key={key.hex()} kw={kw} pr={rr:.3f} {s!r}") diff --git a/tools/decode_dat.py b/tools/decode_dat.py new file mode 100644 index 0000000..009670f --- /dev/null +++ b/tools/decode_dat.py @@ -0,0 +1,23 @@ +import os +base = r"F:\steam\steamapps\common\The I of the Dragon\Data\Misc" +out = r"C:\Users\29452\AppData\Local\Temp\opencode\decoded" +os.makedirs(out, exist_ok=True) +key = b"GBDFYTNE" +for f in ["Fonts.dat", "UnitDescription.dat", "AIDescription.dat"]: + d = open(os.path.join(base, f), "rb").read() + assert d[:2] == b"==", d[:2] + body = d[2:] + dec = bytes(body[i] ^ key[i % len(key)] for i in range(len(body))) + fp = os.path.join(out, f + ".txt") + open(fp, "wb").write(dec) + print(f, "->", fp, len(dec), "bytes") + +# also prove re-encode round trips +def enc(text_bytes): + return b"==" + bytes(text_bytes[i] ^ key[i % len(key)] for i in range(len(text_bytes))) +for f in ["Fonts.dat", "UnitDescription.dat", "AIDescription.dat"]: + d = open(os.path.join(base, f), "rb").read() + body = d[2:] + dec = bytes(body[i] ^ key[i % len(key)] for i in range(len(body))) + assert enc(dec) == d, f +print("round-trip OK") diff --git a/tools/disasm.py b/tools/disasm.py new file mode 100644 index 0000000..5070ba6 --- /dev/null +++ b/tools/disasm.py @@ -0,0 +1,46 @@ +import struct, sys +from capstone import * + +exe = r"F:\steam\steamapps\common\The I of the Dragon\TheIOfTheDragon.exe" +data = open(exe, "rb").read() +e_lfanew = struct.unpack_from(" 40 +rows = [] +for y in range(H): + c = 0 + for x in range(2, W): + if abs(px[x, y] - px[x-2, y]) > 45: + c += 1 + rows.append(c) +# report top rows grouped +thr = max(20, sorted(rows)[int(H*0.97)]) +print("W,H", W, H, "row-edge threshold", thr) +bands = [] +y = 0 +while y < H: + if rows[y] > thr: + y0 = y + while y < H and rows[y] > thr: + y += 1 + bands.append((y0, y, max(rows[y0:y]))) + y += 1 +for b in bands[:40]: + print("band y=%d..%d maxedge=%d" % b) diff --git a/tools/fontparse.py b/tools/fontparse.py new file mode 100644 index 0000000..eb4c424 --- /dev/null +++ b/tools/fontparse.py @@ -0,0 +1,81 @@ +import re, os + +txt = open(r"C:\Users\29452\AppData\Local\Temp\opencode\decoded\Fonts.dat.txt", encoding="latin1").read() + +# tokenize blocks +def parse_block(s, i): + # s[i] should be at '{' + assert s[i] == '{' + i += 1 + entries = [] # (name, value, childidx) + children = [] + cur_name = None + while i < len(s): + # skip whitespace + while i < len(s) and s[i] in ' \t\r\n': + i += 1 + if i >= len(s): + break + if s[i] == '}': + return entries, i+1 + # read a name + m = re.match(r'[A-Za-z_#][A-Za-z0-9_#]*', s[i:]) + if m: + name = m.group(0) + i += len(name) + while i < len(s) and s[i] in ' \t\r\n': + i += 1 + if i < len(s) and s[i] == '=': + i += 1 + while i < len(s) and s[i] in ' \t\r\n': + i += 1 + if i < len(s) and s[i] == '"': + j = i+1 + while j < len(s) and s[j] != '"': + j += 1 + val = s[i+1:j] + i = j+1 + else: + m2 = re.match(r'[^\s{}]+', s[i:]) + val = m2.group(0) if m2 else '' + i += len(val) + entries.append((name, val)) + elif i < len(s) and s[i] == '{': + sub, i = parse_block(s, i) + children.append((name, sub)) + entries.append((name, sub)) + else: + entries.append((name, '')) + else: + i += 1 + return entries, i + +# find top-level Font blocks +blocks = [] +for m in re.finditer(r'\bFont\b\s*\{', txt): + start = txt.index('{', m.start()) + ent, _ = parse_block(txt, start) + blocks.append(ent) + +print("num Font blocks:", len(blocks)) +for b in blocks: + d = dict((k, v) for k, v in b if k in ('Name','Language','Texture','#Texture','SpaceWidth','LineDistance','CharDistance')) + langs = [v for k, v in b if k == 'Language'] + texs = [v for k, v in b if k in ('Texture', '#Texture')] + chars = [c for k, c in b if k == 'Char'] + print("Font Name=%s langs=%s textures=%s #char=%d" % (d.get('Name'), langs, texs, len(chars))) + +# print rect for 'e' in first block +def charmap(block): + cm = {} + for k, c in block: + if k == 'Char': + cd = dict((k2, v2) for k2, v2 in c) + cm[cd.get('Code')] = (int(cd.get('X1',0)), int(cd.get('Y1',0)), int(cd.get('X2',0)), int(cd.get('Y2',0))) + return cm + +for bi, b in enumerate(blocks): + cm = charmap(b) + for ch in ['e','a','o','A',' ']: + if ch in cm: + print(f"block{bi} '{ch}' -> {cm[ch]}") diff --git a/tools/make_plan.py b/tools/make_plan.py new file mode 100644 index 0000000..a96deab --- /dev/null +++ b/tools/make_plan.py @@ -0,0 +1,57 @@ +import argparse, csv, collections + + +def decide(flags, fourcc): + fs = set(flags.split(";")) if flags else set() + if "font_atlas" in fs or "non_texture" in fs: + return "skip", "font atlas uses fixed pixel rects in Fonts.dat / not a texture" + if "text_baked" in fs: + return "review", "text baked in image (must be redrawn, AI will garble text)" + if "ui_atlas" in fs: + return "review", "UI atlas, may have fixed pixel coordinates" + if "tiling_likely" in fs: + return "upscale_tiling", "tileable: use seamless/tiling-aware upscale" + return "upscale", "" + + +def main(): + ap = argparse.ArgumentParser(description="Build an upscale plan csv from the preprocessing report.") + ap.add_argument("--report", required=True) + ap.add_argument("--out", required=True) + ap.add_argument("--scale", type=int, default=2) + args = ap.parse_args() + + rows = list(csv.DictReader(open(args.report, newline="", encoding="utf-8"))) + out = [] + counts = collections.Counter() + for r in rows: + action, reason = decide(r.get("flags", ""), r.get("dds_fourcc", "")) + counts[action] += 1 + try: + w = int(r["orig_w"]); h = int(r["orig_h"]) + tw, th = w * args.scale, h * args.scale + except Exception: + tw = th = "" + out.append({ + "name": r["name"], + "png": r["png"], + "orig_w": r["orig_w"], "orig_h": r["orig_h"], + "target_w": tw, "target_h": th, + "has_alpha": r.get("has_alpha", ""), + "dds_fourcc": r.get("dds_fourcc", ""), + "dds_mips": r.get("dds_mips", ""), + "group": r.get("group", ""), + "action": action, + "reason": reason, + }) + with open(args.out, "w", newline="", encoding="utf-8") as f: + wtr = csv.DictWriter(f, fieldnames=list(out[0].keys())) + wtr.writeheader() + wtr.writerows(out) + print("plan written:", args.out) + for k, v in counts.most_common(): + print(" %-16s %d" % (k, v)) + + +if __name__ == "__main__": + main() diff --git a/tools/pe_refs.py b/tools/pe_refs.py new file mode 100644 index 0000000..d4626a9 --- /dev/null +++ b/tools/pe_refs.py @@ -0,0 +1,79 @@ +import struct, sys +from capstone import * + +exe = r"F:\steam\steamapps\common\The I of the Dragon\TheIOfTheDragon.exe" +data = open(exe, "rb").read() + +# --- parse PE --- +e_lfanew = struct.unpack_from(" uv(%.4f,%.4f,%.4f,%.4f) advance=%d" % (CODE, u1, v1, u2, v2, ADVANCE)) + +# ---- 3. set test strings ---- +strp = os.path.join(GAME, "Data", "Misc", "Strings.dat") +enc = open(strp, "rb").read() +test = bytes([CODE >> 8, CODE & 0xFF]) +ids = ["MainMenu_StartNewGame", "MainMenu_LoadGame", "MainMenu_Options", "MainMenu_Credits", + "MainMenu_Exit", "MainMenu_ResumeGame", "MainMenu_SaveLoadGame", "MainMenu_Multi", + "WindowCaption"] +done = [] +for sid in ids: + pat = re.compile((r'(Id\s*=\s*"%s"\s*[\r\n]+\s*English\s*=\s*")[^"]*(")' % re.escape(sid)).encode()) + enc2 = pat.sub(lambda m: m.group(1) + test + m.group(2), enc, count=1) + if enc2 != enc: + done.append(sid) + enc = enc2 +open(strp, "wb").write(enc) +print("test strings set:", done) diff --git a/tools/prep_test2.py b/tools/prep_test2.py new file mode 100644 index 0000000..20b9490 --- /dev/null +++ b/tools/prep_test2.py @@ -0,0 +1,76 @@ +import os, re, struct +from PIL import Image, ImageDraw, ImageFont + +GAME = r"F:\steam\steamapps\common\The I of the Dragon" +TBL = r"E:\DragonHD\patch\tables" +KEY = b"GBDFYTNE" +os.makedirs(TBL, exist_ok=True) + +CODE = 0xC1FA +RED = (122, 0, 138, 16) +GREEN = (140, 0, 156, 16) +ATLAS = "Shrift_gb_Germany.TGA" + +# 1. paint red + green solid rects into atlas +def paint(raw, x1, y1, x2, y2, b, g, r): + w = struct.unpack_from(" red", n) + +# 3. cjk tables: code -> GREEN +gt = bytearray(65536 * 16); mt = bytearray(65536 * 8) +u1 = GREEN[0] / 256.0; v1 = GREEN[1] / 128.0; u2 = GREEN[2] / 256.0; v2 = GREEN[3] / 128.0 +struct.pack_into("<4f", gt, CODE * 16, u1, v1, u2, v2) +struct.pack_into("<2i", mt, CODE * 8, 17, 16) +open(os.path.join(TBL, "cjk_glyph.bin"), "wb").write(gt) +open(os.path.join(TBL, "cjk_metric.bin"), "wb").write(mt) +print("cjk 0x%04X -> green" % CODE) + +# 4. strings: set menu ids to CODE +sp = os.path.join(GAME, "Data", "Misc", "Strings.dat") +encs = open(sp, "rb").read() +test = bytes([CODE >> 8, CODE & 0xFF]) +pat2 = re.compile(rb'(Id\s*=\s*"([^"]*)"\s*[\r\n]+\s*English\s*=\s*")[^"]*(")') +def repl(m): + sid = m.group(2) + if sid.startswith((b"MainMenu", b"OptionsMenu", b"SaveLoadMenu", b"VideoOptions", b"AudioOptions")): + return m.group(1) + test + m.group(3) + return m.group(0) +encs2, n2 = pat2.subn(repl, encs) +open(sp, "wb").write(encs2) +print("menu strings set:", n2) diff --git a/tools/preprocess.py b/tools/preprocess.py new file mode 100644 index 0000000..ac64e65 --- /dev/null +++ b/tools/preprocess.py @@ -0,0 +1,123 @@ +import argparse, os, csv, json, struct, collections +from PIL import Image + +Image.MAX_IMAGE_PIXELS = None + + +def tga_header(b): + return {"w": struct.unpack_from(" %s (%d bytes)" % (len(blobs), args.out, len(out))) + + +if __name__ == "__main__": + main() + diff --git a/tools/res_unpack.py b/tools/res_unpack.py new file mode 100644 index 0000000..1d02caf --- /dev/null +++ b/tools/res_unpack.py @@ -0,0 +1,68 @@ +import argparse, os, struct, csv, hashlib, sys + + +def read_index(path): + data = open(path, "rb").read() + count = struct.unpack_from(" %s" % (len(rows), args.out)) + + +if __name__ == "__main__": + main() diff --git a/tools/scan_disp.py b/tools/scan_disp.py new file mode 100644 index 0000000..2cb9b47 --- /dev/null +++ b/tools/scan_disp.py @@ -0,0 +1,45 @@ +import struct +from capstone import * + +exe = r"F:\steam\steamapps\common\The I of the Dragon\TheIOfTheDragon.exe" +data = open(exe, "rb").read() +e = struct.unpack_from("= 0 else x + xp = x + 2 if x + 2 < W else x + gx = abs(pxs[0][xp, y] - pxs[0][xm, y]) # placeholder + # use mean image gradient + mean_px = [[sum(px[xx, yy] for px in pxs) / n for xx in (x,)] for yy in (y,)] + cp[x, y] = 255 - min(255, int(std)) +import os +# Better: compute static mask = std < 6, then high contrast of mean +mean = Image.new("L", (W, H), 0) +mp = mean.load() +for y in range(H): + for x in range(W): + mp[x, y] = int(sum(px[x, y] for px in pxs) / n) +# gradient of mean +ed = Image.new("L", (W, H), 0) +ep = ed.load() +for y in range(1, H - 1): + for x in range(1, W - 1): + g = abs(mp[x + 1, y] - mp[x - 1, y]) + abs(mp[x, y + 1] - mp[x, y - 1]) + ep[x, y] = min(255, g) +# static mask +st = Image.new("L", (W, H), 0) +sp = st.load() +for y in range(H): + for x in range(W): + vals = [px[x, y] for px in pxs] + m = sum(vals) / n + var = sum((v - m) ** 2 for v in vals) / n + sp[x, y] = 255 if var ** 0.5 < 4 else 0 +# candidate = static AND edge +cand = Image.new("L", (W, H), 0) +cp = cand.load() +for y in range(H): + for x in range(W): + cp[x, y] = ep[x, y] if sp[x, y] else 0 +cand.save(r"C:\Users\29452\AppData\Local\Temp\opencode\staticcand.png") + +# report bounding regions: coarse grid of activity +gw, gh = 40, 24 +cellw, cellh = W // gw, H // gh +print("grid activity (static-edge):") +for gy in range(gh): + line = "" + for gx in range(gw): + s = 0 + for y in range(gy * cellh, min(H, (gy + 1) * cellh), 3): + for x in range(gx * cellw, min(W, (gx + 1) * cellw), 3): + s += cp[x, y] + line += " .:-=+*#%@"[min(9, s // 300)] + print(line) diff --git a/tools/test_all_strings.py b/tools/test_all_strings.py new file mode 100644 index 0000000..2eeb975 --- /dev/null +++ b/tools/test_all_strings.py @@ -0,0 +1,22 @@ +import re, shutil, sys + +GAME = r"F:\steam\steamapps\common\The I of the Dragon" +BK = r"C:\Users\29452\AppData\Local\Temp\opencode\backup\Strings.dat" +p = GAME + r"\Data\Misc\Strings.dat" + +shutil.copyfile(BK, p) +enc = open(p, "rb").read() +test = b"\xc1\xfa" +pat = re.compile(rb'(Id\s*=\s*"([^"]*)"\s*[\r\n]+\s*English\s*=\s*")[^"]*(")') + + +def repl(m): + sid = m.group(2) + if b"Filename" in sid or b"Path" in sid: + return m.group(0) + return m.group(1) + test + m.group(3) + + +enc2, n = pat.subn(repl, enc) +open(p, "wb").write(enc2) +print("strings replaced:", n) diff --git a/tools/tex_survey.py b/tools/tex_survey.py new file mode 100644 index 0000000..e90f347 --- /dev/null +++ b/tools/tex_survey.py @@ -0,0 +1,67 @@ +import struct, os, collections + +res = r"F:\steam\steamapps\common\The I of the Dragon\Data\Textures.res" +data = open(res, "rb").read() +count = struct.unpack_from(" 2 else 120 +img = Image.open(p).convert("L") +W, H = img.size +rows = max(1, int(cols * H / W / 2.1)) +img2 = img.resize((cols, rows)) +px = img2.load() +ramp = " .:-=+*#%@" +for y in range(rows): + line = "" + for x in range(cols): + v = px[x, y] + line += ramp[min(9, (255 - v) * 10 // 256)] + print(line) +print("size", W, H, "->", cols, rows) diff --git a/tools/xrefs.py b/tools/xrefs.py new file mode 100644 index 0000000..e665023 --- /dev/null +++ b/tools/xrefs.py @@ -0,0 +1,44 @@ +import struct, sys + +exe = r"F:\steam\steamapps\common\The I of the Dragon\TheIOfTheDragon.exe" +data = open(exe, "rb").read() +e = struct.unpack_from("