diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index e47fe4e..cc2da67 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -179,3 +179,11 @@ so centred labels (the "Start New Game" button) were flushed right. Fixed to - Exactly eight escape bytes changed (`\b` to `\a`) across the intro message and repeated mission description. - Center-coordinate test first failed on prior left-aligned mapping (0 != 66). New mapping passes at widths 360/400/480 using engine font metrics including 5px spaces. All 32 tests pass; rollback restores the previous copy exactly. - User exited game; deployed centered file and reread/verified it. SHA256: `941fb4899d37304a304a985743c18cfd4b7dfed7613d831f8359c4d6b6972d6d`. Visual acceptance pending. + +## 2026-09-20 — Chinese full-stop baseline correction + +- Inspected live 12px SimSun atlas: U+3002 ink bounding box `(2,5,6,9)` in a 12x12 cell leaves three empty rows below, reproducing the user's raised punctuation. +- Anchored only U+3002 to the cell bottom: bbox `(2,8,6,12)`, same eight pixels and shape. All other atlas pixels and bytes remain identical. Updated builder to retain this placement on future generation; `fix_full_stop.py` patches existing TGA without rebuilding other glyphs. +- Prepared matching loose atlas and in-place same-size `Textures.res` entry; bytes before and after that entry remain identical. No executable, metrics, UV table, script, or other punctuation changes. +- 34 tests pass. Checked original/fixed glyph bounds and exact shape preservation; rollback of both files tested against backups in an isolated directory. Comparison PNG uses actual atlas pixels enlarged 8x. +- Backup: `E:/DragonHD/backup_game/full-stop-20260920`. User explicitly authorized terminating the running game for installation. Real in-game appearance pending. diff --git a/tools/build_cjk.py b/tools/build_cjk.py index d346861..8f51963 100644 --- a/tools/build_cjk.py +++ b/tools/build_cjk.py @@ -157,6 +157,16 @@ def pick_font(size): raise SystemExit("no CJK ttf found") +def lower_full_stop(tile): + """Anchor the Chinese full stop at the bottom of its full-width cell.""" + bbox = tile.getchannel('A').getbbox() + if bbox is None: + return tile.copy() + result = Image.new('RGBA', tile.size, (0, 0, 0, 0)) + result.paste(tile.crop(bbox), (bbox[0], tile.height - (bbox[3] - bbox[1]))) + return result + + def main(): ap = argparse.ArgumentParser() ap.add_argument("--game", default=DEFAULT_GAME) @@ -211,6 +221,8 @@ def main(): x = col * cell y = args.cjk_y0 + row * cell d.text((x, y + args.draw_dy), uni, font=font, fill=(255, 255, 255, 255)) + if uni == '\u3002': + atlas.paste(lower_full_stop(atlas.crop((x, y, x + cell, y + cell))), (x, y)) cells[code] = {"x": x, "y": y, "uni": uni} # sanity: ink must fit the cell bb = atlas.crop((x, y, x + cell, y + cell)).getbbox() diff --git a/tools/fix_full_stop.py b/tools/fix_full_stop.py new file mode 100644 index 0000000..17aae5d --- /dev/null +++ b/tools/fix_full_stop.py @@ -0,0 +1,45 @@ +"""Patch only U+3002 pixels in an existing CJK atlas; preserve its TGA bytes.""" +import argparse +import json +from pathlib import Path +from build_cjk import lower_full_stop, tga_to_image, tga_geometry + + +def patch_atlas(raw, metadata): + image, desc = tga_to_image(raw) + cell = metadata['cell'] + if list(image.size) != metadata['atlas']: + raise ValueError('atlas geometry mismatch') + entry = metadata['cells'][str(int.from_bytes('。'.encode('gb2312'), 'big'))] + if entry['uni'] != '。': + raise ValueError('full-stop cell mismatch') + x, y = entry['x'], entry['y'] + tile = image.crop((x, y, x + cell, y + cell)) + fixed = lower_full_stop(tile) + idlen, _, w, h, _, _ = tga_geometry(raw) + result = bytearray(raw) + for j in range(cell): + row = y + j if desc & 0x20 else h - 1 - y - j + for i in range(cell): + offset = 18 + idlen + (row * w + x + i) * 4 + r, g, b, a = fixed.getpixel((i, j)) + result[offset:offset + 4] = bytes((b, g, r, a)) + return bytes(result) + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--source', type=Path, required=True) + p.add_argument('--cells', type=Path, required=True) + p.add_argument('--out', type=Path, required=True) + args = p.parse_args() + if args.source.resolve() == args.out.resolve(): + p.error('output must differ from source') + result = patch_atlas(args.source.read_bytes(), json.loads(args.cells.read_text(encoding='utf-8'))) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_bytes(result) + print('Full stop anchored at cell bottom; other atlas bytes preserved.') + + +if __name__ == '__main__': + main() diff --git a/tools/test_full_stop.py b/tools/test_full_stop.py new file mode 100644 index 0000000..455de22 --- /dev/null +++ b/tools/test_full_stop.py @@ -0,0 +1,35 @@ +import unittest +from PIL import Image +from build_cjk import lower_full_stop, image_to_tga, tga_to_image +from fix_full_stop import patch_atlas + + +class FullStopTests(unittest.TestCase): + def test_ink_moves_to_bottom_without_shape_loss(self): + tile = Image.new('RGBA', (12, 12)) + for x, y in [(3, 5), (4, 5), (2, 6), (5, 6), (2, 7), (5, 7), (3, 8), (4, 8)]: + tile.putpixel((x, y), (255, 255, 255, 255)) + fixed = lower_full_stop(tile) + self.assertEqual(fixed.getbbox(), (2, 8, 6, 12)) + self.assertEqual(tile.crop((2, 5, 6, 9)).tobytes(), fixed.crop((2, 8, 6, 12)).tobytes()) + self.assertEqual(lower_full_stop(fixed).tobytes(), fixed.tobytes()) + + def test_patch_preserves_neighbors_header_footer_and_orientation(self): + for desc in (8, 40): + image = Image.new('RGBA', (24, 24), (7, 8, 9, 255)) + image.paste((0, 0, 0, 0), (12, 12, 24, 24)) + image.putpixel((14, 17), (255, 255, 255, 255)) + raw = image_to_tga(image, desc, b'unchanged-footer') + meta = {'cell': 12, 'atlas': [24, 24], 'cells': {'41379': {'uni': '。', 'x': 12, 'y': 12}}} + fixed = patch_atlas(raw, meta) + after, _ = tga_to_image(fixed) + self.assertEqual(fixed[:18], raw[:18]) + self.assertTrue(fixed.endswith(b'unchanged-footer')) + self.assertEqual(after.crop((0, 0, 24, 12)).tobytes(), image.crop((0, 0, 24, 12)).tobytes()) + self.assertEqual(after.crop((0, 12, 12, 24)).tobytes(), image.crop((0, 12, 12, 24)).tobytes()) + self.assertEqual(after.getpixel((14, 23)), (255, 255, 255, 255)) + self.assertEqual(patch_atlas(fixed, meta), fixed) + + +if __name__ == '__main__': + unittest.main()