ff9mapkit 1.0.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,11 @@
1
+ """Background-scene libraries.
2
+
3
+ cam - the camera math: project / decompose / synthesize any FF9 camera + the canvas map
4
+ bgx - the pure-Memoria background scene text format (overlays + camera)
5
+ bgi - the walkmesh codec + flat-floor / obj builder
6
+ guide - author a camera from a spec, frame a floor, emit a paint guide
7
+ """
8
+
9
+ from . import bgi, bgx, cam, guide
10
+
11
+ __all__ = ["cam", "bgx", "bgi", "guide"]
@@ -0,0 +1,63 @@
1
+ """The big flat SCROLLING checkerboard ARENA -- a wide debug stage for staging objects in a row without
2
+ obstruction (huge monster models, the prop/held galleries, the Info Hub in-game preview). A perspective
3
+ checkerboard floor (pure-stdlib placeholder art, projected through the camera so it auto-aligns with the
4
+ walkmesh) + a flat walkmesh + a scrolling camera (window_width 384, range N screens wide).
5
+
6
+ `build_arena()` writes the art + returns the camera/walkmesh meta; `arena_scene_lines()` gives the
7
+ field.toml SCENE that a caller appends `[[npc]]`/`[[prop]]` placements to (a gallery, the preview);
8
+ `arena_toml()` is the standalone debug stage. Lifted here from `tools/build_debug_arena.py` so the package
9
+ (notably the Info Hub spine's `preview_field_toml`) can build a stage without importing a dev script.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from pathlib import Path
15
+
16
+ from . import guide, placeholder
17
+
18
+ PITCH, FOV = 40.0, 42.2 # downward tilt; horizontal FOV
19
+ DIST = 7000.0 # camera pulled back -> larger world floor = zoomed OUT
20
+ BACK_Y, FRONT_Y = 150.0, 430.0 # painted-canvas rows the floor's back/front edges sit on
21
+ BACK_SPAN = 0.43 # back edge's canvas half-span as a fraction of range_w
22
+
23
+
24
+ def build_arena(art_dir: Path, *, screens: int = 3) -> dict:
25
+ """Generate the arena's checkerboard art (back + floor PNGs) into ``art_dir``; return the camera +
26
+ walkmesh the field.toml needs (range_w, quad, zb, zf, spawn_z)."""
27
+ range_w = 384 * screens
28
+ cam = guide.make_camera(PITCH, DIST, fov_x_deg=FOV, range_wh=(range_w, 448))
29
+ frame = guide.frame_floor(cam, back_canvas_y=BACK_Y, front_canvas_y=FRONT_Y,
30
+ back_span_px=range_w * BACK_SPAN)
31
+ quad = [[int(x), int(z)] for (x, z) in guide.walkmesh_corners(frame)]
32
+ # square-ON-SCREEN checkerboard: at pitch p a world Z step foreshortens to ~sin(p) of a world X step,
33
+ # so world cells must be ~1/sin(p) DEEPER than wide to read square. Pick ~6 rows deep, derive columns.
34
+ sinp = math.sin(math.radians(PITCH))
35
+ width, depth = 2 * frame.half_width, abs(frame.zf - frame.zb)
36
+ nz = 6
37
+ nx = max(4, round(width / (depth / nz * sinp)))
38
+ art_dir.mkdir(parents=True, exist_ok=True)
39
+ placeholder.write_placeholders(cam, frame, art_dir / "back.png", art_dir / "floor.png", nx=nx, nz=nz)
40
+ return {"range_w": range_w, "quad": quad, "zb": frame.zb, "zf": frame.zf,
41
+ "spawn_z": int(round((frame.zb + frame.zf) / 2))}
42
+
43
+
44
+ def arena_scene_lines(meta: dict, *, spawn_z=None, name="ARENA", art_prefix="art") -> list:
45
+ """The field.toml SCENE lines (field/camera/walkmesh/layers/player) for an arena -- so a caller can
46
+ append its own `[[npc]]`/`[[prop]]` placements. ``spawn_z`` defaults to the arena centre."""
47
+ if spawn_z is None:
48
+ spawn_z = meta["spawn_z"]
49
+ quad = "[" + ", ".join(f"[{x}, {z}]" for x, z in meta["quad"]) + "]"
50
+ return ["[field]", "id = 4003", f'name = "{name}"', "area = 11", "text_block = 1073", "",
51
+ "[camera]", f"pitch = {PITCH}", f"distance = {int(DIST)}", f"fov = {FOV}",
52
+ f"range = [{meta['range_w']}, 448]", "window_width = 384", "[camera.scroll]", "enabled = true", "",
53
+ "[walkmesh]", f"quad = {quad}", 'frame = "world"', "",
54
+ "[[layers]]", f'image = "{art_prefix}/back.png"', "z = 4000",
55
+ "[[layers]]", f'image = "{art_prefix}/floor.png"', "z = 3000", "",
56
+ "[player]", f"spawn = [0, {spawn_z}]", ""]
57
+
58
+
59
+ def arena_toml(meta: dict, *, name="ARENA", art_prefix="art") -> str:
60
+ """The standalone debug-arena field.toml (player at the centre)."""
61
+ lines = ["# Big flat scrolling checkerboard ARENA -- a debug stage for staging large objects (auto-generated)."]
62
+ lines += arena_scene_lines(meta, name=name, art_prefix=art_prefix)
63
+ return "\n".join(lines)
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """Offline rebuild of a field's per-overlay background PNGs from its atlas + .bgs.
3
+
4
+ This reproduces Memoria's ``[Export] Field=1`` dump (``FieldSceneExporter.ExportOverlay``,
5
+ FieldSceneExporter.cs:177) WITHOUT the in-game step. The engine export is a single blocking
6
+ startup pass over EVERY field (``SceneDirector.MemoriaExport`` -> ``FieldSceneExporter.ExportSafe``
7
+ walks ``mapList.txt``), which is the multi-minute "hang"; here each overlay is composited straight
8
+ from the atlas the kit already reads out of p0data.
9
+
10
+ Byte-exactness (proven on FBG_N00_TSHP_MAP001_TH_CGR_0, overlay 0): cropping the cell this module
11
+ computes out of the engine's OWN dumped ``atlas.png`` reproduces ``Overlay0.png`` with diff == 0 --
12
+ the cell math, the (absent) flip, and the placement are exact. The only delta on a live install
13
+ comes from re-DECODING the p0data atlas offline: a DXT-compressed atlas (Moguri ships the field
14
+ atlas as DXT5 / TextureFormat 12) decodes a hair differently through UnityPy than through Unity's
15
+ runtime, a uniform sub-2/255 per-channel noise that is imperceptible and structurally irrelevant
16
+ (every tile's position/size/index is identical). An uncompressed (vanilla) atlas decodes exactly.
17
+
18
+ The engine packs the upscaled atlas as a grid of ``(TileSize+4)`` cells (a 2px bleed margin each
19
+ side) addressed by a single global sprite index across overlays in order; ``bgs.resolve_sprites``
20
+ already computes that cell (``2 + idx % cpr * (TileSize+4)``, matching ``ExtractSpriteData``,
21
+ BGSCENE_DEF.cs:740). This module is the inverse blit: crop each cell, paste it into the overlay's
22
+ tight canvas at ``(offX*factor, offY*factor)`` (``factor = TileSize/16``), exactly as the engine
23
+ does after its double Y-flip nets out.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from .bgs import TILE
28
+
29
+
30
+ def overlay_size(sprites, tile_size: int):
31
+ """The ``(w, h)`` :func:`assemble_overlay` produces for these sprites at ``tile_size``.
32
+
33
+ The engine sizes a >1-sprite overlay to its tight tile bbox; a 0/1-sprite overlay is a single
34
+ SPRITE_W x SPRITE_H tile (FieldSceneExporter.cs:184-191). Factored out so the repaint repack can
35
+ validate a hand-edited ``Overlay{i}.png`` against the exact dims its tiles were exported at."""
36
+ factor = tile_size // TILE
37
+ if len(sprites) > 1:
38
+ return (max(s.offX for s in sprites) * factor + tile_size,
39
+ max(s.offY for s in sprites) * factor + tile_size)
40
+ return (tile_size, tile_size)
41
+
42
+
43
+ def assemble_overlay(atlas_img, sprites, tile_size: int):
44
+ """One overlay's ``Overlay{i}.png`` as a PIL RGBA image, matching ``FieldSceneExporter.ExportOverlay``.
45
+
46
+ ``sprites`` are the overlay's resolved tile-sprites (``bgs.resolve_sprites`` against
47
+ ``atlas_img.width`` at ``tile_size``), each carrying ``atlasX``/``atlasY`` (the atlas source cell)
48
+ and ``offX``/``offY`` (the tile's 16px-unit position within the overlay). ``tile_size`` is the
49
+ ACTIVE field-map TileSize (vanilla 32 / Moguri 64) -- it MUST match the atlas the cells were
50
+ resolved against, else the grid steps land on the wrong cells (garbled art).
51
+ """
52
+ from PIL import Image # noqa: PLC0415 - only the art path needs PIL
53
+
54
+ factor = tile_size // TILE
55
+ w, h = overlay_size(sprites, tile_size)
56
+ # the engine inits the canvas to Color(1,1,1,0) == white-under-zero-alpha (FieldSceneExporter.cs:215),
57
+ # NOT black; regions no tile covers keep it. Match it for byte-parity (the RGB is invisible at a=0, but
58
+ # the engine export carries it, so a black init would diff 255 per channel across every uncovered pixel).
59
+ canvas = Image.new("RGBA", (w, h), (255, 255, 255, 0))
60
+ for s in sprites:
61
+ cell = atlas_img.crop((s.atlasX, s.atlasY, s.atlasX + tile_size, s.atlasY + tile_size))
62
+ # paste (overwrite, incl. alpha) == the engine's SetPixels; co-located sprites = last wins.
63
+ canvas.paste(cell, (s.offX * factor, s.offY * factor))
64
+ return canvas
65
+
66
+
67
+ def assemble_overlays(atlas_img, overlays, tile_size: int) -> dict:
68
+ """``{overlay_index: PIL RGBA image}`` for every overlay (the offline ``Overlay{i}.png`` set).
69
+
70
+ ``overlays`` must already be sprite-resolved (``bgs.resolve_sprites(data, overlays, atlas_img.width,
71
+ tile_size)``) so each carries its atlas cells. Index ``i`` is the overlay list index -- the same
72
+ ``i`` the engine names ``Overlay{i}.png`` by and that ``extract.compose_background``/``extract_layers``
73
+ key off.
74
+ """
75
+ return {i: assemble_overlay(atlas_img, ov.sprites, tile_size) for i, ov in enumerate(overlays)}
76
+
77
+
78
+ def _bleed_cell(atlas_img, ax, ay, ts: int, pad: int = 2) -> None:
79
+ """Replicate a freshly-written cell's edge pixels ``pad`` px outward into the surrounding atlas
80
+ margin (clamped to the atlas). The native upscaled render path point-samples with a -0.5 texel UV
81
+ shift (BGSCENE_DEF.cs:1322), so when the background is drawn LARGER than the atlas tile (a hi-res
82
+ display), the first/last screen pixel of a tile reads ONE texel into the 2px margin. The shipped
83
+ atlas reserves that margin with a bled copy of the edge for exactly this reason; a repainted tile
84
+ must re-bleed so its edge -- not the stale old tile -- is what bleeds in (else a 1px seam shows)."""
85
+ W, H = atlas_img.size
86
+ x0, x1 = max(0, ax - pad), min(W, ax + ts + pad) # widened, clamped span for top/bottom
87
+ for d in range(1, pad + 1): # left/right columns within the cell rows
88
+ if ax - d >= 0:
89
+ atlas_img.paste(atlas_img.crop((ax, ay, ax + 1, ay + ts)), (ax - d, ay))
90
+ if ax + ts - 1 + d < W:
91
+ atlas_img.paste(atlas_img.crop((ax + ts - 1, ay, ax + ts, ay + ts)), (ax + ts - 1 + d, ay))
92
+ for d in range(1, pad + 1): # top/bottom rows over the widened span -> corners
93
+ if ay - d >= 0:
94
+ atlas_img.paste(atlas_img.crop((x0, ay, x1, ay + 1)), (x0, ay - d))
95
+ if ay + ts - 1 + d < H:
96
+ atlas_img.paste(atlas_img.crop((x0, ay + ts - 1, x1, ay + ts)), (x0, ay + ts - 1 + d))
97
+
98
+
99
+ def repack_overlay(atlas_img, overlay_png, sprites, tile_size: int, *, bleed: int = 2) -> int:
100
+ """Blit a (re)painted ``Overlay{i}.png`` back INTO the atlas -- the inverse of :func:`assemble_overlay`.
101
+ ``atlas_img`` is BOTH the base and the target (mutated in place); returns the count of CHANGED cells.
102
+
103
+ For each sprite this crops the same ``tile_size`` square the assembler pasted FROM the atlas (at
104
+ ``(offX*factor, offY*factor)`` in the overlay PNG) and -- only if it differs from the atlas cell
105
+ already there -- writes it back to that sprite's atlas cell ``(atlasX, atlasY)`` and re-bleeds the
106
+ cell edge into its margin (:func:`_bleed_cell`). Skipping unchanged cells keeps an UNMODIFIED layer
107
+ a byte-exact no-op (crop/paste are pure copies) AND makes a re-pack idempotent, with no separate
108
+ pristine copy to corrupt -- the atlas itself is the base.
109
+
110
+ CO-LOCATED sprites (two tiles sharing one ``(offX, offY)``) are handled like the assembler's
111
+ in-order paste: only the LAST (visible) owner cell is considered; earlier hidden cells -- which the
112
+ engine never samples -- keep their original atlas bytes.
113
+
114
+ ``overlay_png`` MUST be sized exactly :func:`overlay_size` for ``sprites`` at ``tile_size`` (the
115
+ caller reconciles a hand-edited PNG first); a mismatch raises ``ValueError`` rather than garble.
116
+ """
117
+ if not sprites:
118
+ return 0
119
+ exp = overlay_size(sprites, tile_size)
120
+ if tuple(overlay_png.size) != exp:
121
+ raise ValueError(f"repaint overlay is {tuple(overlay_png.size)}, expected {exp} "
122
+ f"(tile_size {tile_size}); re-export or rescale it to match")
123
+ factor = tile_size // TILE
124
+ owner = {} # (offX, offY) -> last sprite index == the visible tile
125
+ for j, s in enumerate(sprites):
126
+ owner[(s.offX, s.offY)] = j
127
+ changed = 0
128
+ for j, s in enumerate(sprites):
129
+ if owner[(s.offX, s.offY)] != j:
130
+ continue
131
+ x0, y0 = s.offX * factor, s.offY * factor
132
+ cell = overlay_png.crop((x0, y0, x0 + tile_size, y0 + tile_size))
133
+ box = (s.atlasX, s.atlasY, s.atlasX + tile_size, s.atlasY + tile_size)
134
+ if cell.tobytes() == atlas_img.crop(box).tobytes():
135
+ continue # unchanged tile -> leave the cell + its margin byte-exact
136
+ atlas_img.paste(cell, (s.atlasX, s.atlasY)) # overwrite incl. alpha == assemble's source cell
137
+ if bleed:
138
+ _bleed_cell(atlas_img, s.atlasX, s.atlasY, tile_size, bleed)
139
+ changed += 1
140
+ return changed