Chinese main menu: font atlas + GB2312 tables + Strings translation
- build_cjk.py: 2048x1024 UI atlas (original ASCII at 0,0 + full GB2312 at 12px) and cjk_glyph/cjk_metric tables. - cn_strings.py + make_cn_strings.py: escape-aware GB2312 rewrite of Strings.dat (46 entries). - install_cjk.py: one-shot deploy/restore (exe patch, atlas loose+res, strings). - apply_cjk.py: fix metrics cave code byte order (was (second<<8)|first) so CJK width measures correctly and centred labels are no longer flushed right. - docs: DEVLOG + patch notes for the atlas and the fix.
This commit is contained in:
@@ -7,6 +7,10 @@
|
|||||||
!/.gitignore
|
!/.gitignore
|
||||||
!/README.md
|
!/README.md
|
||||||
|
|
||||||
|
# Python caches
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
# Belt-and-suspenders: never track game / crack / media / build artifacts
|
# Belt-and-suspenders: never track game / crack / media / build artifacts
|
||||||
*.res
|
*.res
|
||||||
*.iso
|
*.iso
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ Working data (not tracked): `original/`, `png/`, `report/`, `patch/`, `crack/`.
|
|||||||
| `tools/fontparse.py` | parse `Fonts.dat` into font blocks and glyph rects |
|
| `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/add_section.py` | add an executable section to the game PE |
|
||||||
| `tools/apply_cjk.py` | apply the double-byte font-rendering patch |
|
| `tools/apply_cjk.py` | apply the double-byte font-rendering patch |
|
||||||
|
| `tools/build_cjk.py` | build the 2048x1024 UI font atlas + `cjk_glyph`/`cjk_metric` tables |
|
||||||
|
| `tools/cn_strings.py` | simplified-Chinese translation table (`Id` -> text) |
|
||||||
|
| `tools/make_cn_strings.py` | patch the `English` column of `Strings.dat` to GB2312 |
|
||||||
|
| `tools/install_cjk.py` | deploy/restore the whole patch into the game folder |
|
||||||
| `tools/xrefs.py` | find call/jmp xrefs in the game executable |
|
| `tools/xrefs.py` | find call/jmp xrefs in the game executable |
|
||||||
|
|
||||||
See `docs/` for the reverse-engineered formats and addresses.
|
See `docs/` for the reverse-engineered formats and addresses.
|
||||||
|
|||||||
@@ -3,6 +3,34 @@
|
|||||||
Working log for the *The I of the Dragon* localization / HD project.
|
Working log for the *The I of the Dragon* localization / HD project.
|
||||||
Newest entries on top.
|
Newest entries on top.
|
||||||
|
|
||||||
|
## 2026-09-20 — Chinese in the main menu (first playable)
|
||||||
|
|
||||||
|
- Built `tools/build_cjk.py`: renders the full GB2312 set (7445 double-byte codes) as **12x12**
|
||||||
|
glyphs with simsun (crisp/binary, matching the game's pixel font) into a **2048x1024** TGA.
|
||||||
|
The original 256x128 ASCII artwork is copied to (0,0), so all existing `Fonts.dat` rects keep
|
||||||
|
working unchanged (verified byte-identical). Also emits `cjk_glyph.bin` / `cjk_metric.bin`.
|
||||||
|
Advance/height = 12 (the UI font cap height is ~11-12px), drawn 1px up to fit the cell.
|
||||||
|
- `tools/cn_strings.py` + `tools/make_cn_strings.py`: translate the `English` column of
|
||||||
|
`Strings.dat` to GB2312 (46 entries; escape-aware so `\"%s\"` formats survive).
|
||||||
|
- `tools/install_cjk.py`: one-shot deploy/restore (backs up exe/res/strings; rebuilds `Textures.res`
|
||||||
|
with the new atlas, drops the loose atlas, patches the exe and the strings).
|
||||||
|
- **Result**: the main menu renders Chinese — 开始新游戏 / 载入游戏 / 选项 / 教程 / 制作人员 / 退出 /
|
||||||
|
载入上次存档:"…" — confirmed by the user on screen.
|
||||||
|
|
||||||
|
### Fixed a byte-order bug in the metrics cave
|
||||||
|
|
||||||
|
The draw cave decoded the code as `(first<<8)|second`, but the metrics cave used
|
||||||
|
`movzx eax,al; mov ah,[ebx+1]` which produced `(second<<8)|first`. Every CJK width measured as 0,
|
||||||
|
so centred labels (the "Start New Game" button) were flushed right. Fixed to
|
||||||
|
`movzx eax,al; shl eax,8; mov al,[ebx+1]`. Centring is now correct.
|
||||||
|
|
||||||
|
### Notes / next
|
||||||
|
|
||||||
|
- The menu button labels *are* dynamic (`Strings.dat`) — the earlier suspicion of baked button
|
||||||
|
images was wrong; the layer confusion came from the animated particle background.
|
||||||
|
- Remaining UI text that is baked into textures (credits, tutorial pics) still needs redrawing.
|
||||||
|
- Next: translate the rest of `Strings.dat`, scripts and `*Description.dat`; then HD textures.
|
||||||
|
|
||||||
## 2026-09-20 — Double-byte font patch proven
|
## 2026-09-20 — Double-byte font patch proven
|
||||||
|
|
||||||
- Reverse-engineered the whole text pipeline (see `patch-notes.md`).
|
- Reverse-engineered the whole text pipeline (see `patch-notes.md`).
|
||||||
|
|||||||
+33
-3
@@ -64,17 +64,47 @@ Behaviour of each cave:
|
|||||||
Slot `0xFF` glyph lives at `font+0x100C`, its metric at `font+0x1814`; these do not collide with the
|
Slot `0xFF` glyph lives at `font+0x100C`, its metric at `font+0x1814`; these do not collide with the
|
||||||
real tables.
|
real tables.
|
||||||
|
|
||||||
|
## Byte-order detail (important)
|
||||||
|
|
||||||
|
Both caves must build the code identically as `(first << 8) | second`:
|
||||||
|
|
||||||
|
- draw cave: `movzx ecx,al; shl ecx,8; movzx eax,[edx+1]; or ecx,eax`
|
||||||
|
- metrics cave: `movzx eax,al; shl eax,8; mov al,[ebx+1]` (`al` is free after the `shl`)
|
||||||
|
|
||||||
|
The metrics cave originally used `mov ah,[ebx+1]` which yields `(second<<8)|first`; CJK then
|
||||||
|
measured as width 0 and centred labels were flushed right. Fixed.
|
||||||
|
|
||||||
|
## Font atlas (implemented, `tools/build_cjk.py`)
|
||||||
|
|
||||||
|
`AddChar` normalises rects by the **actual D3D texture size**, so replacing the UI font texture
|
||||||
|
with a larger atlas that still contains the original ASCII art at pixel (0,0) keeps every existing
|
||||||
|
`Fonts.dat` rect valid — no `Fonts.dat` edits needed.
|
||||||
|
|
||||||
|
```
|
||||||
|
Shrift_gb_Germany.TGA 2048 x 1024, 32bpp TGA (type 2, bottom-up, desc 0x08, 26-byte footer)
|
||||||
|
(0,0) 256x128 original ASCII/German artwork (byte-identical copy)
|
||||||
|
(0,128)+ 12x12 cells, 170 per row, full GB2312 (7445 codes), simsun 12px, binary alpha
|
||||||
|
```
|
||||||
|
|
||||||
|
`cjk_glyph` holds normalised UVs `(x/2048, y/1024, (x+12)/2048, (y+12)/1024)`;
|
||||||
|
`cjk_metric` holds `(12, 12)` (advance, height). Deployed as a loose file and inside
|
||||||
|
`Textures.res` (see `tools/install_cjk.py`).
|
||||||
|
|
||||||
## Requirements / limits
|
## Requirements / limits
|
||||||
|
|
||||||
- The font texture must contain **both** ASCII and CJK glyphs (one texture per draw).
|
- The font texture must contain **both** ASCII and CJK glyphs (one texture per draw); done via the
|
||||||
Plan: replace the UI font `Texture` with a 2048x2048 atlas and re-point the ASCII `Char` rects.
|
shared atlas above.
|
||||||
- Lead bytes >= `0x80` are reserved for DBCS. ASCII is untouched.
|
- 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.
|
- Only the `UI` font atlas is extended; the `OldUI` / `UISmall` / Russian fonts still point at their
|
||||||
|
own textures, so CJK drawn with those would need their own atlas/tables too.
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
- `tools/add_section.py` — append an executable PE section.
|
- `tools/add_section.py` — append an executable PE section.
|
||||||
- `tools/apply_cjk.py` — assemble the caves (keystone), add `.cjk`, write hooks and tables.
|
- `tools/apply_cjk.py` — assemble the caves (keystone), add `.cjk`, write hooks and tables.
|
||||||
|
- `tools/build_cjk.py` — build the atlas TGA + `cjk_glyph`/`cjk_metric` from a charset.
|
||||||
|
- `tools/make_cn_strings.py` + `tools/cn_strings.py` — GB2312 translation of `Strings.dat`.
|
||||||
|
- `tools/install_cjk.py` — deploy/restore everything into the game folder.
|
||||||
- `tools/decode_dat.py` — decode/encode the XOR `.dat` files.
|
- `tools/decode_dat.py` — decode/encode the XOR `.dat` files.
|
||||||
- `tools/fontparse.py` — parse `Fonts.dat` into blocks + glyph rects.
|
- `tools/fontparse.py` — parse `Fonts.dat` into blocks + glyph rects.
|
||||||
- `tools/xrefs.py`, `tools/disasm.py`, `tools/scan_disp.py` — RE helpers.
|
- `tools/xrefs.py`, `tools/disasm.py`, `tools/scan_disp.py` — RE helpers.
|
||||||
|
|||||||
+18
-12
@@ -1,4 +1,4 @@
|
|||||||
import struct, sys, os
|
import argparse, struct, sys, os
|
||||||
from keystone import Ks, KS_ARCH_X86, KS_MODE_32
|
from keystone import Ks, KS_ARCH_X86, KS_MODE_32
|
||||||
|
|
||||||
ORIG = r"F:\steam\steamapps\common\The I of the Dragon\TheIOfTheDragon.exe"
|
ORIG = r"F:\steam\steamapps\common\The I of the Dragon\TheIOfTheDragon.exe"
|
||||||
@@ -21,16 +21,16 @@ def align(x, a):
|
|||||||
return (x + a - 1) // a * a
|
return (x + a - 1) // a * a
|
||||||
|
|
||||||
|
|
||||||
def load_tables():
|
def load_tables(tbl):
|
||||||
g = open(os.path.join(TBL, "cjk_glyph.bin"), "rb").read()
|
g = open(os.path.join(tbl, "cjk_glyph.bin"), "rb").read()
|
||||||
m = open(os.path.join(TBL, "cjk_metric.bin"), "rb").read()
|
m = open(os.path.join(tbl, "cjk_metric.bin"), "rb").read()
|
||||||
assert len(g) == 65536 * 16, len(g)
|
assert len(g) == 65536 * 16, len(g)
|
||||||
assert len(m) == 65536 * 8, len(m)
|
assert len(m) == 65536 * 8, len(m)
|
||||||
return g, m
|
return g, m
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main(orig=ORIG, out=OUT, tbl=TBL):
|
||||||
data = bytearray(open(ORIG, "rb").read())
|
data = bytearray(open(orig, "rb").read())
|
||||||
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
|
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
|
||||||
coff = e_lfanew + 4
|
coff = e_lfanew + 4
|
||||||
nsec = struct.unpack_from("<H", data, coff + 2)[0]
|
nsec = struct.unpack_from("<H", data, coff + 2)[0]
|
||||||
@@ -99,7 +99,8 @@ def main():
|
|||||||
cmp al, 0x80
|
cmp al, 0x80
|
||||||
jb ascii
|
jb ascii
|
||||||
movzx eax, al
|
movzx eax, al
|
||||||
mov ah, byte ptr [ebx + 1]
|
shl eax, 8
|
||||||
|
mov al, byte ptr [ebx + 1]
|
||||||
mov ebp, [ecx + 0x14]
|
mov ebp, [ecx + 0x14]
|
||||||
add ebp, dword ptr [eax*8 + {metric}]
|
add ebp, dword ptr [eax*8 + {metric}]
|
||||||
add edx, ebp
|
add edx, ebp
|
||||||
@@ -147,7 +148,7 @@ def main():
|
|||||||
data[sec_off + OFF_DRAW: sec_off + OFF_DRAW + len(draw_code)] = draw_code
|
data[sec_off + OFF_DRAW: sec_off + OFF_DRAW + len(draw_code)] = draw_code
|
||||||
data[sec_off + OFF_MEAS: sec_off + OFF_MEAS + len(meas_code)] = meas_code
|
data[sec_off + OFF_MEAS: sec_off + OFF_MEAS + len(meas_code)] = meas_code
|
||||||
|
|
||||||
glyph, metric = load_tables()
|
glyph, metric = load_tables(tbl)
|
||||||
data[sec_off + OFF_GLYPH: sec_off + OFF_GLYPH + len(glyph)] = glyph
|
data[sec_off + OFF_GLYPH: sec_off + OFF_GLYPH + len(glyph)] = glyph
|
||||||
data[sec_off + OFF_METRIC: sec_off + OFF_METRIC + len(metric)] = metric
|
data[sec_off + OFF_METRIC: sec_off + OFF_METRIC + len(metric)] = metric
|
||||||
|
|
||||||
@@ -164,10 +165,15 @@ def main():
|
|||||||
hook(HOOK_DRAW_VA, draw_va)
|
hook(HOOK_DRAW_VA, draw_va)
|
||||||
hook(HOOK_MEAS_VA, meas_va)
|
hook(HOOK_MEAS_VA, meas_va)
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||||
open(OUT, "wb").write(data)
|
open(out, "wb").write(data)
|
||||||
print("wrote", OUT, len(data), "bytes")
|
print("wrote", out, len(data), "bytes")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--orig", default=ORIG)
|
||||||
|
ap.add_argument("--out", default=OUT)
|
||||||
|
ap.add_argument("--tbl", default=TBL)
|
||||||
|
a = ap.parse_args()
|
||||||
|
main(a.orig, a.out, a.tbl)
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""Build the Chinese UI font atlas + code tables for The I of the Dragon.
|
||||||
|
|
||||||
|
Approach
|
||||||
|
--------
|
||||||
|
The engine normalizes `Fonts.dat` char rects by the *actual* D3D texture size
|
||||||
|
(AddChar -> vtable+0x38 surface desc). So if we replace the UI font texture
|
||||||
|
`UI\\Overhead\\Shrift_gb_Germany.TGA` (256x128) with a bigger atlas that still
|
||||||
|
contains the original ASCII artwork at pixel (0,0), every existing ASCII rect
|
||||||
|
keeps working unchanged.
|
||||||
|
|
||||||
|
CJK glyphs are rendered as full-width cells at the bottom/right of the atlas and
|
||||||
|
described by the side tables consumed by the `.cjk` code caves:
|
||||||
|
|
||||||
|
cjk_glyph : 65536 * float4 (u1,v1,u2,v2)
|
||||||
|
cjk_metric : 65536 * int2 (advance, height)
|
||||||
|
|
||||||
|
`key = (lead_byte << 8) | trail_byte` -- exactly the code the cave builds.
|
||||||
|
|
||||||
|
Outputs (into --out):
|
||||||
|
Shrift_gb_Germany.TGA new texture (TGA type 2, 32bpp, bottom-up, same desc)
|
||||||
|
cjk_glyph.bin
|
||||||
|
cjk_metric.bin
|
||||||
|
cells.json code -> cell / unicode, for diagnostics
|
||||||
|
atlas_preview.png downscaled preview (not deployed)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
DEFAULT_GAME = r"F:\steam\steamapps\common\The I of the Dragon"
|
||||||
|
DEFAULT_OUT = r"E:\DragonHD\build\cjk"
|
||||||
|
TEX_NAME = r"UI\Overhead\Shrift_gb_Germany.TGA"
|
||||||
|
FONT_CANDIDATES = [
|
||||||
|
(r"C:\Windows\Fonts\simsun.ttc", 0),
|
||||||
|
(r"C:\Windows\Fonts\simhei.ttf", 0),
|
||||||
|
(r"C:\Windows\Fonts\msyh.ttc", 0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- res reader
|
||||||
|
def read_res_entry(res_path, want_name):
|
||||||
|
data = open(res_path, "rb").read()
|
||||||
|
count = struct.unpack_from("<I", data, 0)[0]
|
||||||
|
p = 4
|
||||||
|
for _ in range(count):
|
||||||
|
ln = struct.unpack_from("<I", data, p)[0]
|
||||||
|
p += 4
|
||||||
|
name = data[p:p + ln].decode("latin1")
|
||||||
|
p += ln
|
||||||
|
off, size = struct.unpack_from("<II", data, p)
|
||||||
|
p += 8
|
||||||
|
if name == want_name:
|
||||||
|
return data[off:off + size]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_original_tga(game):
|
||||||
|
loose = os.path.join(game, "Data", "Textures", "Ui", "Overhead",
|
||||||
|
os.path.basename(TEX_NAME.replace("\\", os.sep)))
|
||||||
|
if os.path.exists(loose):
|
||||||
|
return open(loose, "rb").read()
|
||||||
|
blob = read_res_entry(os.path.join(game, "Data", "Textures.res"), TEX_NAME)
|
||||||
|
if blob is None:
|
||||||
|
raise SystemExit("cannot locate " + TEX_NAME)
|
||||||
|
return blob
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- TGA <-> PIL
|
||||||
|
def tga_geometry(raw):
|
||||||
|
if len(raw) < 18:
|
||||||
|
raise SystemExit("not a TGA")
|
||||||
|
idlen = raw[0]
|
||||||
|
imgtype = raw[2]
|
||||||
|
w = struct.unpack_from("<H", raw, 12)[0]
|
||||||
|
h = struct.unpack_from("<H", raw, 14)[0]
|
||||||
|
bpp = raw[16]
|
||||||
|
desc = raw[17]
|
||||||
|
return idlen, imgtype, w, h, bpp, desc
|
||||||
|
|
||||||
|
|
||||||
|
def tga_to_image(raw):
|
||||||
|
idlen, imgtype, w, h, bpp, desc = tga_geometry(raw)
|
||||||
|
if imgtype != 2 or bpp != 32:
|
||||||
|
raise SystemExit("unsupported TGA type=%d bpp=%d" % (imgtype, bpp))
|
||||||
|
off = 18 + idlen
|
||||||
|
bottom_up = not (desc & 0x20)
|
||||||
|
img = Image.new("RGBA", (w, h))
|
||||||
|
dst = img.load()
|
||||||
|
row_bytes = w * 4
|
||||||
|
for y in range(h):
|
||||||
|
src_row = (h - 1 - y) if bottom_up else y
|
||||||
|
base = off + src_row * row_bytes
|
||||||
|
row = raw[base:base + row_bytes]
|
||||||
|
for x in range(w):
|
||||||
|
b, g, r, a = row[x * 4:x * 4 + 4]
|
||||||
|
dst[x, y] = (r, g, b, a)
|
||||||
|
return img, desc
|
||||||
|
|
||||||
|
|
||||||
|
def image_to_tga(img, desc=0x08, footer=b""):
|
||||||
|
w, h = img.size
|
||||||
|
rgba = img.convert("RGBA")
|
||||||
|
r, g, b, a = rgba.split()
|
||||||
|
bgra = Image.merge("RGBA", (b, g, r, a))
|
||||||
|
bottom_up = not (desc & 0x20)
|
||||||
|
if bottom_up:
|
||||||
|
bgra = bgra.transpose(Image.FLIP_TOP_BOTTOM)
|
||||||
|
body = bgra.tobytes()
|
||||||
|
hdr = (struct.pack("<BBB", 0, 0, 2)
|
||||||
|
+ struct.pack("<HHB", 0, 0, 0)
|
||||||
|
+ struct.pack("<HHHHBB", 0, 0, w, h, 32, desc))
|
||||||
|
return hdr + body + footer
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- charset
|
||||||
|
def iter_gb2312():
|
||||||
|
"""code(int) -> unicode char, full GB2312 double byte space."""
|
||||||
|
out = {}
|
||||||
|
for hi in range(0xA1, 0xF8):
|
||||||
|
for lo in range(0xA1, 0xFF):
|
||||||
|
try:
|
||||||
|
u = bytes([hi, lo]).decode("gb2312")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
out[(hi << 8) | lo] = u
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def chars_from_text(path, base):
|
||||||
|
"""merge chars found in a utf-8 text file into the base set."""
|
||||||
|
txt = open(path, encoding="utf-8").read()
|
||||||
|
used = {}
|
||||||
|
for ch in txt:
|
||||||
|
if ord(ch) < 0x80:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
enc = ch.encode("gb2312")
|
||||||
|
except Exception:
|
||||||
|
print(" ! cannot gb2312-encode %r" % ch, file=sys.stderr)
|
||||||
|
continue
|
||||||
|
if len(enc) != 2:
|
||||||
|
continue
|
||||||
|
used[(enc[0] << 8) | enc[1]] = ch
|
||||||
|
return used
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- build
|
||||||
|
def pick_font(size):
|
||||||
|
for path, idx in FONT_CANDIDATES:
|
||||||
|
if os.path.exists(path):
|
||||||
|
return ImageFont.truetype(path, size, index=idx), path
|
||||||
|
raise SystemExit("no CJK ttf found")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--game", default=DEFAULT_GAME)
|
||||||
|
ap.add_argument("--out", default=DEFAULT_OUT)
|
||||||
|
ap.add_argument("--size", type=int, default=12,
|
||||||
|
help="CJK pixel size (12 matches the UI pixel font)")
|
||||||
|
ap.add_argument("--cell", type=int, default=12)
|
||||||
|
ap.add_argument("--atlas-w", type=int, default=2048)
|
||||||
|
ap.add_argument("--atlas-h", type=int, default=1024)
|
||||||
|
ap.add_argument("--cjk-y0", type=int, default=128)
|
||||||
|
ap.add_argument("--draw-dy", type=int, default=-1,
|
||||||
|
help="vertical offset of the glyph inside the cell")
|
||||||
|
ap.add_argument("--charset", default="gb2312",
|
||||||
|
choices=["gb2312", "text"],
|
||||||
|
help="gb2312 = every double byte code; text = only chars in --text")
|
||||||
|
ap.add_argument("--text", default=None)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
os.makedirs(args.out, exist_ok=True)
|
||||||
|
|
||||||
|
orig_raw = load_original_tga(args.game)
|
||||||
|
orig_img, desc = tga_to_image(orig_raw)
|
||||||
|
ow, oh = orig_img.size
|
||||||
|
footer = orig_raw[-26:] if len(orig_raw) >= 26 and orig_raw[-18:-1] == b"TRUEVISION-XFILE." else b""
|
||||||
|
print("original %dx%d desc=0x%02x footer=%d" % (ow, oh, desc, len(footer)))
|
||||||
|
|
||||||
|
base = iter_gb2312()
|
||||||
|
if args.charset == "text":
|
||||||
|
base = chars_from_text(args.text, base)
|
||||||
|
print("charset size: %d" % len(base))
|
||||||
|
|
||||||
|
cell = args.cell
|
||||||
|
cols = args.atlas_w // cell
|
||||||
|
rows = (args.atlas_h - args.cjk_y0) // cell
|
||||||
|
if len(base) > cols * rows:
|
||||||
|
raise SystemExit("charset %d does not fit in %dx%d (%d cells)"
|
||||||
|
% (len(base), cols, rows, cols * rows))
|
||||||
|
|
||||||
|
atlas = Image.new("RGBA", (args.atlas_w, args.atlas_h), (0, 0, 0, 0))
|
||||||
|
atlas.paste(orig_img, (0, 0))
|
||||||
|
|
||||||
|
font, fontpath = pick_font(args.size)
|
||||||
|
print("font: %s @ %dpx" % (fontpath, args.size))
|
||||||
|
d = ImageDraw.Draw(atlas)
|
||||||
|
|
||||||
|
cells = {}
|
||||||
|
clipped = 0
|
||||||
|
for i, code in enumerate(sorted(base)):
|
||||||
|
uni = base[code]
|
||||||
|
col = i % cols
|
||||||
|
row = i // cols
|
||||||
|
x = col * cell
|
||||||
|
y = args.cjk_y0 + row * cell
|
||||||
|
d.text((x, y + args.draw_dy), uni, font=font, fill=(255, 255, 255, 255))
|
||||||
|
cells[code] = {"x": x, "y": y, "uni": uni}
|
||||||
|
# sanity: ink must fit the cell
|
||||||
|
bb = atlas.crop((x, y, x + cell, y + cell)).getbbox()
|
||||||
|
if bb is None:
|
||||||
|
clipped += 1
|
||||||
|
|
||||||
|
print("placed %d glyphs (%d empty), grid %dx%d" % (len(cells), clipped, cols, rows))
|
||||||
|
|
||||||
|
# tables
|
||||||
|
glyph = bytearray(65536 * 16)
|
||||||
|
metric = bytearray(65536 * 8)
|
||||||
|
aw, ah = args.atlas_w, args.atlas_h
|
||||||
|
for code, c in cells.items():
|
||||||
|
x, y = c["x"], c["y"]
|
||||||
|
struct.pack_into("<4f", glyph, code * 16,
|
||||||
|
x / aw, y / ah, (x + cell) / aw, (y + cell) / ah)
|
||||||
|
struct.pack_into("<2i", metric, code * 8, cell, cell)
|
||||||
|
|
||||||
|
open(os.path.join(args.out, "cjk_glyph.bin"), "wb").write(glyph)
|
||||||
|
open(os.path.join(args.out, "cjk_metric.bin"), "wb").write(metric)
|
||||||
|
|
||||||
|
tga = image_to_tga(atlas, desc=desc, footer=footer)
|
||||||
|
open(os.path.join(args.out, "Shrift_gb_Germany.TGA"), "wb").write(tga)
|
||||||
|
|
||||||
|
with open(os.path.join(args.out, "cells.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"cell": cell, "atlas": [aw, ah], "cjk_y0": args.cjk_y0,
|
||||||
|
"size": args.size, "font": fontpath, "cells": cells}, f)
|
||||||
|
|
||||||
|
meta = {"cell": cell, "atlas": [aw, ah], "cjk_y0": args.cjk_y0,
|
||||||
|
"size": args.size, "font": fontpath, "count": len(cells),
|
||||||
|
"advance": cell, "height": cell}
|
||||||
|
with open(os.path.join(args.out, "atlas_meta.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(meta, f, indent=2)
|
||||||
|
|
||||||
|
# preview
|
||||||
|
prev = atlas.resize((args.atlas_w // 4, args.atlas_h // 4), Image.NEAREST)
|
||||||
|
prev.save(os.path.join(args.out, "atlas_preview.png"))
|
||||||
|
|
||||||
|
print("atlas TGA %d bytes, glyph/probe tables written to %s" % (len(tga), args.out))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Simplified Chinese UI strings for The I of the Dragon.
|
||||||
|
|
||||||
|
Only *display* values are translated here (the `English` column of
|
||||||
|
`Data\\Misc\\Strings.dat`). Never translate `Id`s, event names, commands or
|
||||||
|
unit types -- doing so is what breaks mission triggers.
|
||||||
|
|
||||||
|
`\\a` is an in-engine text control prefix; keep it verbatim.
|
||||||
|
Format specifiers (`%s`, `%d`, ...) must survive untouched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CN_STRINGS = {
|
||||||
|
# window / main menu
|
||||||
|
"WindowCaption": "龙之眼",
|
||||||
|
"MainMenu_StartNewGame": "开始新游戏",
|
||||||
|
"MainMenu_SaveLoadGame": "保存或载入游戏",
|
||||||
|
"MainMenu_LoadGame": "载入游戏",
|
||||||
|
"MainMenu_ResumeGame": "继续游戏",
|
||||||
|
"MainMenu_ResumeSavedGame": "载入上次存档:\\\"%s\\\"",
|
||||||
|
"MainMenu_ResumeSavedGameAutoTip": "载入上次游戏",
|
||||||
|
"MainMenu_CannotResumeGame": "无法继续游戏",
|
||||||
|
"MainMenu_Options": "选项",
|
||||||
|
"MainMenu_Multi": "教程",
|
||||||
|
"MainMenu_Credits": "制作人员",
|
||||||
|
"MainMenu_Exit": "退出",
|
||||||
|
|
||||||
|
# options
|
||||||
|
"OptionsMenu_ApplyDialog": "\\a应用更改?",
|
||||||
|
"OptionsMenu_Audio": "音频",
|
||||||
|
"OptionsMenu_Video": "视频",
|
||||||
|
"OptionsMenu_Controls": "操作",
|
||||||
|
"OptionsMenu_Game": "游戏选项",
|
||||||
|
"OptionsMenu_AudioTitle": "音频",
|
||||||
|
"OptionsMenu_VideoTitle": "视频",
|
||||||
|
"OptionsMenu_ControlsTitle": "操作",
|
||||||
|
"OptionsMenu_GameTitle": "游戏选项",
|
||||||
|
"OptionsMenu_Exit": "退出",
|
||||||
|
"OptionsMenu_Back": "返回",
|
||||||
|
"OptionsMenu_Okey": "应用",
|
||||||
|
"OptionsMenu_Reset": "重置",
|
||||||
|
|
||||||
|
# save / load
|
||||||
|
"SaveLoadMenu_SaveGame": "保存游戏",
|
||||||
|
"SaveLoadMenu_CannotSaveGame": "无法保存游戏",
|
||||||
|
"SaveLoadMenu_LoadGame": "载入游戏",
|
||||||
|
"SaveLoadMenu_CannotLoadGame": "请先选择一个存档",
|
||||||
|
"SaveLoadMenu_Back": "返回",
|
||||||
|
"SaveLoadMenu_Delete": "删除",
|
||||||
|
"SaveLoadMenu_CannotDelete": "无法删除",
|
||||||
|
"SaveLoadMenu_Cancel": "取消",
|
||||||
|
"SaveLoadMenu_ReplaceDialog": "\\a替换?",
|
||||||
|
"SaveLoadMenu_DeleteDialog": "\\a删除?",
|
||||||
|
|
||||||
|
# multiplayer / tutorial
|
||||||
|
"Multiplayer_Players": "玩家",
|
||||||
|
"Multiplayer_Host": "主机",
|
||||||
|
"Multiplayer_Join": "加入",
|
||||||
|
"Multiplayer_Ready": "准备",
|
||||||
|
"Multiplayer_Start": "开始",
|
||||||
|
"Multiplayer_RedDragon": "红龙 安诺斯",
|
||||||
|
"Multiplayer_BlackDragon": "黑龙 莫罗格",
|
||||||
|
"Multiplayer_BlueDragon": "蓝龙 巴洛斯",
|
||||||
|
"Multiplayer_IP": "IP:",
|
||||||
|
"Multiplayer_PlayerStateDescription": "%s - %d 经验值",
|
||||||
|
"Multiplayer_ClientSessionTerminated": "\\a游戏已被主机终止。",
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""Deploy / restore the Chinese font patch into the game directory.
|
||||||
|
|
||||||
|
`install`:
|
||||||
|
1. back up the pristine exe / Textures.res / Strings.dat (once, into backup_game)
|
||||||
|
2. copy the generated `.cjk` tables, rebuild the patched exe (apply_cjk)
|
||||||
|
3. drop the patched exe into the game folder
|
||||||
|
4. install the font atlas (loose file + inside Textures.res)
|
||||||
|
5. patch Strings.dat with the Chinese `English` column
|
||||||
|
|
||||||
|
`restore`:
|
||||||
|
puts every original file back and removes anything we added.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
GAME = r"F:\steam\steamapps\common\The I of the Dragon"
|
||||||
|
BUILD = r"E:\DragonHD\build\cjk"
|
||||||
|
PATCH = r"E:\DragonHD\patch"
|
||||||
|
BACKUP = r"E:\DragonHD\backup_game"
|
||||||
|
|
||||||
|
EXE = "TheIOfTheDragon.exe"
|
||||||
|
ATLAS_RES_NAME = r"UI\Overhead\Shrift_gb_Germany.TGA"
|
||||||
|
ATLAS_LOOSE = os.path.join("Data", "Textures", "Ui", "Overhead", "Shrift_gb_Germany.TGA")
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import apply_cjk # noqa: E402
|
||||||
|
import make_cn_strings # noqa: E402
|
||||||
|
from cn_strings import CN_STRINGS # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def sha1(path):
|
||||||
|
h = hashlib.sha1()
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def backup_once(src, dst):
|
||||||
|
if os.path.exists(dst):
|
||||||
|
return False
|
||||||
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def replace_res_entries(res_path, repl):
|
||||||
|
data = open(res_path, "rb").read()
|
||||||
|
count = struct.unpack_from("<I", data, 0)[0]
|
||||||
|
p = 4
|
||||||
|
entries = []
|
||||||
|
for _ in range(count):
|
||||||
|
ln = struct.unpack_from("<I", data, p)[0]
|
||||||
|
p += 4
|
||||||
|
name = data[p:p + ln].decode("latin1")
|
||||||
|
p += ln
|
||||||
|
off, size = struct.unpack_from("<II", data, p)
|
||||||
|
p += 8
|
||||||
|
entries.append([name, data[off:off + size]])
|
||||||
|
|
||||||
|
hdr = 4
|
||||||
|
for name, _ in entries:
|
||||||
|
hdr += 4 + len(name) + 8
|
||||||
|
data_start = (hdr + 15) & ~15
|
||||||
|
|
||||||
|
out = bytearray(struct.pack("<I", len(entries)))
|
||||||
|
index = bytearray()
|
||||||
|
payload = bytearray()
|
||||||
|
offset = data_start
|
||||||
|
for i, (name, blob) in enumerate(entries):
|
||||||
|
blob = repl.get(name, blob)
|
||||||
|
nb = name.encode("latin1")
|
||||||
|
index += struct.pack("<I", len(nb)) + nb + struct.pack("<II", offset, len(blob))
|
||||||
|
payload += blob
|
||||||
|
if i != len(entries) - 1:
|
||||||
|
pad = ((len(blob) + 15) & ~15) - len(blob)
|
||||||
|
payload += b"\x00" * pad
|
||||||
|
offset += len(blob) + pad
|
||||||
|
else:
|
||||||
|
offset += len(blob)
|
||||||
|
out += index
|
||||||
|
out += b"\x00" * (data_start - len(out))
|
||||||
|
out += payload
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def install(args):
|
||||||
|
os.makedirs(BACKUP, exist_ok=True)
|
||||||
|
state_path = os.path.join(BACKUP, "state.json")
|
||||||
|
state = {}
|
||||||
|
if os.path.exists(state_path):
|
||||||
|
state = json.load(open(state_path))
|
||||||
|
|
||||||
|
game_exe = os.path.join(args.game, EXE)
|
||||||
|
game_res = os.path.join(args.game, "Data", "Textures.res")
|
||||||
|
game_str = os.path.join(args.game, "Data", "Misc", "Strings.dat")
|
||||||
|
game_loose = os.path.join(args.game, ATLAS_LOOSE)
|
||||||
|
|
||||||
|
def bak(name):
|
||||||
|
return os.path.join(BACKUP, name)
|
||||||
|
|
||||||
|
for src, dst in [(game_exe, bak(EXE)),
|
||||||
|
(game_res, bak("Textures.res")),
|
||||||
|
(game_str, bak("Strings.dat"))]:
|
||||||
|
if backup_once(src, dst):
|
||||||
|
print("backup", os.path.basename(dst))
|
||||||
|
|
||||||
|
if "loose_existed" not in state:
|
||||||
|
state["loose_existed"] = os.path.exists(game_loose)
|
||||||
|
if state["loose_existed"]:
|
||||||
|
backup_once(game_loose, bak("Shrift_gb_Germany.TGA"))
|
||||||
|
json.dump(state, open(state_path, "w"))
|
||||||
|
|
||||||
|
# 1. tables + patched exe (build against the pristine exe)
|
||||||
|
os.makedirs(os.path.join(PATCH, "tables"), exist_ok=True)
|
||||||
|
for t in ("cjk_glyph.bin", "cjk_metric.bin"):
|
||||||
|
shutil.copy2(os.path.join(BUILD, t), os.path.join(PATCH, "tables", t))
|
||||||
|
patch_out = os.path.join(PATCH, "TheIOfTheDragon_cjk.exe")
|
||||||
|
apply_cjk.main(bak(EXE), patch_out, os.path.join(PATCH, "tables"))
|
||||||
|
shutil.copy2(patch_out, game_exe)
|
||||||
|
print("patched exe ->", game_exe)
|
||||||
|
|
||||||
|
# 2. atlas: loose + res
|
||||||
|
atlas_src = os.path.join(BUILD, "Shrift_gb_Germany.TGA")
|
||||||
|
atlas_blob = open(atlas_src, "rb").read()
|
||||||
|
os.makedirs(os.path.dirname(game_loose), exist_ok=True)
|
||||||
|
open(game_loose, "wb").write(atlas_blob)
|
||||||
|
print("loose atlas ->", game_loose)
|
||||||
|
|
||||||
|
if not args.skip_res:
|
||||||
|
open(game_res, "wb").write(
|
||||||
|
replace_res_entries(bak("Textures.res"), {ATLAS_RES_NAME: atlas_blob}))
|
||||||
|
print("res atlas updated ->", game_res)
|
||||||
|
|
||||||
|
# 3. strings
|
||||||
|
raw = open(bak("Strings.dat"), "rb").read()
|
||||||
|
out, done, missing = make_cn_strings.patch(raw, CN_STRINGS)
|
||||||
|
open(game_str, "wb").write(out)
|
||||||
|
print("strings patched %d/%d" % (len(done), len(CN_STRINGS)))
|
||||||
|
if missing:
|
||||||
|
print("MISSING:", missing, file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
print("install done. exe sha1", sha1(game_exe), "res", sha1(game_res))
|
||||||
|
|
||||||
|
|
||||||
|
def restore(args):
|
||||||
|
game_exe = os.path.join(args.game, EXE)
|
||||||
|
game_res = os.path.join(args.game, "Data", "Textures.res")
|
||||||
|
game_str = os.path.join(args.game, "Data", "Misc", "Strings.dat")
|
||||||
|
game_loose = os.path.join(args.game, ATLAS_LOOSE)
|
||||||
|
state_path = os.path.join(BACKUP, "state.json")
|
||||||
|
state = json.load(open(state_path)) if os.path.exists(state_path) else {}
|
||||||
|
|
||||||
|
for name, dst in [(EXE, game_exe), ("Textures.res", game_res), ("Strings.dat", game_str)]:
|
||||||
|
src = os.path.join(BACKUP, name)
|
||||||
|
if os.path.exists(src):
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
print("restored", dst)
|
||||||
|
|
||||||
|
if os.path.exists(game_str + ".orig"):
|
||||||
|
os.remove(game_str + ".orig")
|
||||||
|
|
||||||
|
if state.get("loose_existed"):
|
||||||
|
src = os.path.join(BACKUP, "Shrift_gb_Germany.TGA")
|
||||||
|
if os.path.exists(src):
|
||||||
|
shutil.copy2(src, game_loose)
|
||||||
|
print("restored loose atlas")
|
||||||
|
elif os.path.exists(game_loose):
|
||||||
|
os.remove(game_loose)
|
||||||
|
print("removed loose atlas")
|
||||||
|
|
||||||
|
print("restore done. exe sha1", sha1(game_exe), "res", sha1(game_res))
|
||||||
|
print("strings sha1", sha1(game_str))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("action", choices=["install", "restore"])
|
||||||
|
ap.add_argument("--game", default=GAME)
|
||||||
|
ap.add_argument("--skip-res", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
if args.action == "install":
|
||||||
|
install(args)
|
||||||
|
else:
|
||||||
|
restore(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Patch the `English` column of `Data\\Misc\\Strings.dat` with simplified Chinese.
|
||||||
|
|
||||||
|
Reads the byte-exact file (single byte code page), replaces only the value that
|
||||||
|
follows `Id = "<id>"` and `English = "..."`, and writes GB2312 double byte text.
|
||||||
|
The original is preserved next to it as `Strings.dat.orig` (once).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from cn_strings import CN_STRINGS
|
||||||
|
|
||||||
|
DEFAULT_GAME = r"F:\steam\steamapps\common\The I of the Dragon"
|
||||||
|
|
||||||
|
|
||||||
|
def patch(raw, mapping):
|
||||||
|
done, missing = [], []
|
||||||
|
for sid, cn in mapping.items():
|
||||||
|
enc = cn.encode("gb2312")
|
||||||
|
pat = re.compile(
|
||||||
|
rb'(Id\s*=\s*"' + re.escape(sid.encode("latin1")) + rb'"'
|
||||||
|
rb'\s*[\r\n]+\s*English\s*=\s*")(?:[^"\\]|\\.)*(")'
|
||||||
|
)
|
||||||
|
new, n = pat.subn(lambda m: m.group(1) + enc + m.group(2), raw, count=1)
|
||||||
|
if n:
|
||||||
|
raw = new
|
||||||
|
done.append(sid)
|
||||||
|
else:
|
||||||
|
missing.append(sid)
|
||||||
|
return raw, done, missing
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--game", default=DEFAULT_GAME)
|
||||||
|
ap.add_argument("--file", default=None)
|
||||||
|
ap.add_argument("--restore", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
path = args.file or os.path.join(args.game, "Data", "Misc", "Strings.dat")
|
||||||
|
bak = path + ".orig"
|
||||||
|
|
||||||
|
if args.restore:
|
||||||
|
if os.path.exists(bak):
|
||||||
|
open(path, "wb").write(open(bak, "rb").read())
|
||||||
|
print("restored", path)
|
||||||
|
else:
|
||||||
|
print("no backup at", bak)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not os.path.exists(bak):
|
||||||
|
open(bak, "wb").write(open(path, "rb").read())
|
||||||
|
print("backup ->", bak)
|
||||||
|
|
||||||
|
raw = open(path, "rb").read()
|
||||||
|
out, done, missing = patch(raw, CN_STRINGS)
|
||||||
|
open(path, "wb").write(out)
|
||||||
|
print("patched %d/%d strings (%d bytes)" % (len(done), len(CN_STRINGS), len(out)))
|
||||||
|
if missing:
|
||||||
|
print("MISSING:", missing, file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user