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,116 @@
1
+ """Offline preview of an ``.sps`` effect (Tier 0). Composites each frame's quad cloud over the decoded
2
+ ``spt.tcb`` page into a PNG / contact-sheet / GIF -- "see what FF9's effects look like" without launching
3
+ the game (the bespoke analogue of Memoria's in-engine Model Viewer).
4
+
5
+ The composite is an ADDITIVE approximation: each prim's UV cell is cropped from the page, tinted by its
6
+ RGB-ramp colour, premultiplied by its texel alpha, and added onto a black canvas at the prim's screen
7
+ offset (``pos_x, pos_y``, +Y up). Additive matches fire/smoke/magic (most field SPS are additive-blended);
8
+ pass ``additive=False`` for an alpha-over paste. This is a catalog preview, not the exact engine projection
9
+ (no per-camera GTE transform / depth sort). -> [[project-ff9-sps-authoring]].
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from . import texture
14
+ from .codec import Sps
15
+
16
+
17
+ def _page_image(sps: Sps, tcb: bytes):
18
+ from PIL import Image # noqa: PLC0415 - only the preview path needs PIL
19
+ w, h, rgba = texture.tcb_page_rgba(tcb, sps.tpage_raw, sps.clut_raw)
20
+ return Image.frombytes("RGBA", (w, h), rgba)
21
+
22
+
23
+ def _frame_bounds(sps: Sps, cell_w: int, cell_h: int, pad: int):
24
+ """Canvas size + origin covering EVERY frame's prims (so frames align in a strip/GIF)."""
25
+ xs, ys = [], []
26
+ for frame in sps.frames:
27
+ for p in frame:
28
+ xs.append(p.pos_x)
29
+ ys.append(p.pos_y)
30
+ if not xs:
31
+ return cell_w + 2 * pad, cell_h + 2 * pad, 0.0, 0.0
32
+ hx, hy = cell_w / 2, cell_h / 2
33
+ min_x, max_x = min(xs) - hx, max(xs) + hx
34
+ min_y, max_y = min(ys) - hy, max(ys) + hy
35
+ w = int(round(max_x - min_x)) + 2 * pad
36
+ h = int(round(max_y - min_y)) + 2 * pad
37
+ return max(w, 1), max(h, 1), min_x - pad, max_y + pad # origin x-left / y-top (Y inverted)
38
+
39
+
40
+ def render_frame(sps: Sps, tcb: bytes, frame_index: int = 0, *, scale: int = 3,
41
+ additive: bool = True, _page=None, _bounds=None):
42
+ """Render one frame's quad cloud to a PIL RGBA image (black/transparent where empty)."""
43
+ from PIL import Image, ImageChops # noqa: PLC0415
44
+
45
+ page = _page if _page is not None else _page_image(sps, tcb)
46
+ uv_w = max(1, sps.w_raw - 1)
47
+ uv_h = max(1, sps.h_raw - 1)
48
+ cell_w, cell_h = uv_w * scale, uv_h * scale
49
+ if _bounds is not None:
50
+ W, H, ox, oy = _bounds
51
+ else:
52
+ W, H, ox, oy = _frame_bounds(sps, cell_w, cell_h, pad=cell_w)
53
+
54
+ acc = Image.new("RGB", (W, H), (0, 0, 0))
55
+ if not sps.frames:
56
+ return acc.convert("RGBA")
57
+ prims = sps.frames[frame_index % len(sps.frames)]
58
+ for p in prims:
59
+ ux, uy = sps.uv_table[p.uv_index] if p.uv_index < len(sps.uv_table) else (0, 0)
60
+ cell = page.crop((ux, uy, ux + uv_w, uy + uv_h)).convert("RGBA")
61
+ r, g, b, _pad = sps.rgb_table[p.rgb_index] if p.rgb_index < len(sps.rgb_table) else (255, 255, 255, 0)
62
+ rr, gg, bb, aa = cell.split()
63
+ tint = lambda band, k: band.point(lambda v, k=k: (v * k) >> 8) # noqa: E731 (×k/256)
64
+ rr, gg, bb = tint(rr, r), tint(gg, g), tint(bb, b)
65
+ if additive:
66
+ # premultiply by alpha so transparent texels add nothing, then add onto the canvas
67
+ rgb = Image.merge("RGB", (ImageChops.multiply(rr, aa), ImageChops.multiply(gg, aa),
68
+ ImageChops.multiply(bb, aa)))
69
+ layer = Image.new("RGB", (W, H), (0, 0, 0))
70
+ cx = int(round(p.pos_x - ox)) - cell_w // 2
71
+ cy = int(round(oy - p.pos_y)) - cell_h // 2
72
+ layer.paste(rgb.resize((cell_w, cell_h), Image.NEAREST), (cx, cy))
73
+ acc = ImageChops.add(acc, layer)
74
+ else:
75
+ rgba = Image.merge("RGBA", (rr, gg, bb, aa)).resize((cell_w, cell_h), Image.NEAREST)
76
+ cx = int(round(p.pos_x - ox)) - cell_w // 2
77
+ cy = int(round(oy - p.pos_y)) - cell_h // 2
78
+ acc = acc.convert("RGBA")
79
+ acc.alpha_composite(rgba, (cx, cy))
80
+ acc = acc.convert("RGB")
81
+ # alpha = per-pixel brightness so the preview overlays cleanly (black -> transparent)
82
+ rr, gg, bb = acc.split()
83
+ alpha = ImageChops.lighter(ImageChops.lighter(rr, gg), bb)
84
+ return Image.merge("RGBA", (rr, gg, bb, alpha))
85
+
86
+
87
+ def render_strip(sps: Sps, tcb: bytes, *, scale: int = 3, cols: int = 8, gap: int = 4):
88
+ """Render every frame into one contact-sheet image (frames share a canvas size, so motion aligns)."""
89
+ from PIL import Image # noqa: PLC0415
90
+
91
+ page = _page_image(sps, tcb)
92
+ uv_w, uv_h = max(1, sps.w_raw - 1), max(1, sps.h_raw - 1)
93
+ bounds = _frame_bounds(sps, uv_w * scale, uv_h * scale, pad=uv_w * scale)
94
+ W, H = bounds[0], bounds[1]
95
+ n = max(1, sps.frame_count)
96
+ cols = max(1, min(cols, n))
97
+ rows = (n + cols - 1) // cols
98
+ sheet = Image.new("RGBA", (cols * W + (cols - 1) * gap, rows * H + (rows - 1) * gap), (0, 0, 0, 0))
99
+ for i in range(n):
100
+ cell = render_frame(sps, tcb, i, scale=scale, _page=page, _bounds=bounds)
101
+ sheet.alpha_composite(cell, ((i % cols) * (W + gap), (i // cols) * (H + gap)))
102
+ return sheet
103
+
104
+
105
+ def save_png(image, path) -> None:
106
+ image.save(path)
107
+
108
+
109
+ def save_gif(sps: Sps, tcb: bytes, path, *, scale: int = 3, duration_ms: int = 66) -> None:
110
+ """Write an animated GIF of the effect (default ~15 fps, the engine tick). Black background."""
111
+ page = _page_image(sps, tcb)
112
+ uv_w, uv_h = max(1, sps.w_raw - 1), max(1, sps.h_raw - 1)
113
+ bounds = _frame_bounds(sps, uv_w * scale, uv_h * scale, pad=uv_w * scale)
114
+ frames = [render_frame(sps, tcb, i, scale=scale, _page=page, _bounds=bounds).convert("RGB")
115
+ for i in range(max(1, sps.frame_count))]
116
+ frames[0].save(path, save_all=True, append_images=frames[1:], duration=duration_ms, loop=0)
@@ -0,0 +1,47 @@
1
+ """Curated named SPS effect TEMPLATES -- the friendly starting points for the Tier-2 creator (``[[sps]]
2
+ template = "fire"``). Each template is a ``copy_from`` preset pointing at a known-good donor effect in a
3
+ common, always-present field (the Ice Cavern, disc 1), so it reuses the proven Route-A path (clone a real
4
+ effect's texture + colours + animation, then optionally re-author the geometry). No new art, no DLL.
5
+
6
+ Picked from an offline preview survey of the donor effects (distinct, recognisable types). The registry is
7
+ just identifiers (field token + sps id) -- the bytes are read from the user's install at build time, like
8
+ ``copy_from``; nothing here is Square-Enix data. Browsable (``list_templates``) so the CLI + a future GUI
9
+ creator picker read the same set. Extend freely -- add a row pointing at any donor effect.
10
+ -> [[project-ff9-sps-authoring]], docs/SPS.md.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+ # common donor fields (Ice Cavern, disc 1 -- every install has them)
17
+ _ICCV_JMP = "fbg_n05_iccv_map088_ic_jmp_0"
18
+ _ICCV_BRI = "fbg_n05_iccv_map089_ic_bri_0"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Template:
23
+ description: str
24
+ field: str # donor field token (what copy_from / extract takes)
25
+ sps: int # donor effect id to clone
26
+
27
+
28
+ # name -> Template. `[[sps]] template = "<name>"` resolves to copy_from {field, sps}.
29
+ TEMPLATES: dict[str, Template] = {
30
+ "fire": Template("a small flickering red flame", _ICCV_JMP, 2266), # the in-game-proven melt-fire
31
+ "bonfire": Template("a large orange flame", _ICCV_BRI, 2272),
32
+ "smoke": Template("a soft white smoke / mist cloud", _ICCV_JMP, 231),
33
+ "sparkle": Template("twinkling gold sparkle motes", _ICCV_JMP, 180),
34
+ "embers": Template("scattered drifting orange embers", _ICCV_BRI, 2273),
35
+ "glimmer": Template("a soft crystalline glint", _ICCV_BRI, 344),
36
+ }
37
+
38
+
39
+ def resolve(name: str):
40
+ """The :class:`Template` for ``name`` (raises ``KeyError`` if unknown -- the caller maps it to its
41
+ own error type with the known-names hint)."""
42
+ return TEMPLATES[name]
43
+
44
+
45
+ def list_templates() -> list[tuple[str, str, str, int]]:
46
+ """``[(name, description, donor_field, donor_sps), ...]`` -- for the CLI / GUI picker."""
47
+ return [(n, t.description, t.field, t.sps) for n, t in TEMPLATES.items()]
@@ -0,0 +1,131 @@
1
+ """Decode the shared ``spt.tcb`` texture an ``.sps`` effect samples -- the read-only half of the SPS picture
2
+ (Tier 0 preview). An ``.sps`` carries only quad positions + UV/RGB indices; the PIXELS live in ``spt.tcb``,
3
+ a PSX-VRAM blit blob. This module replays the blits into a simulated VRAM and decodes a TPage (through its
4
+ CLUT) to RGBA, exactly as the engine does.
5
+
6
+ PORTED byte-for-byte from the engine (``PSXTextureMgr.LoadTCBInVram`` / ``LoadImageBin`` :109-449 and
7
+ ``PSXTexture.CreateBufferColor32`` / ``ConvertABGR16toABGR32`` :42-132). No PIL / UnityPy needed -- pure
8
+ bytes in, RGBA bytes out (``render.py`` adds the PIL compositing for a preview image).
9
+
10
+ The model: VRAM is ``u16[1024*512]`` addressed ``vram[y*1024 + x]``. ``spt.tcb`` is two batches of
11
+ ``(x, y, w, h)`` rectangle blits (BGR555 halfwords) -- both the indexed texture page AND the CLUT (palette)
12
+ rows live in that VRAM. An ``.sps``'s ``tpage_raw`` (TP color-mode / TX*64 / TY*256) selects a 256x256 texel
13
+ window; ``clut_raw`` (ClutX*16 / ClutY) selects the palette row. -> [[project-ff9-sps-authoring]].
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import struct
18
+ from array import array
19
+
20
+ VRAM_W = 1024
21
+ VRAM_H = 512
22
+ VRAM_SIZE = VRAM_W * VRAM_H # 524288 u16
23
+
24
+
25
+ class SpsTextureError(ValueError):
26
+ pass
27
+
28
+
29
+ def _load_image_bin(vram: array, x: int, y: int, w: int, h: int, data: bytes, off: int) -> int:
30
+ """Blit a w*h rectangle of LE-u16 pixels into VRAM at (x, y). Returns the new read cursor. Mirrors
31
+ ``PSXTextureMgr.LoadImageBin``."""
32
+ for i in range(h):
33
+ base = (y + i) * VRAM_W + x
34
+ row = base
35
+ for _ in range(w):
36
+ lo = data[off]
37
+ hi = data[off + 1]
38
+ vram[row] = (hi << 8) | lo
39
+ row += 1
40
+ off += 2
41
+ return off
42
+
43
+
44
+ def load_tcb_to_vram(tcb: bytes) -> array:
45
+ """Replay a ``spt.tcb`` container into a fresh simulated VRAM (``array('H')`` of length 524288).
46
+ Mirrors ``PSXTextureMgr.LoadTCBInVram``: a header (secondBatchOffset, firstBatchOffset, firstBatchCount)
47
+ then two batches of rect blits -- the first carries an inline 8-byte rect header before each block's
48
+ pixels, the second packs the rect headers and pixels in separate running cursors."""
49
+ if len(tcb) < 12:
50
+ raise SpsTextureError(f"spt.tcb too short: {len(tcb)} bytes")
51
+ vram = array("H", bytes(VRAM_SIZE * 2)) # zero-initialised (unblitted texels read as transparent)
52
+ second_batch_off, first_batch_off, first_batch_count = struct.unpack_from("<IIi", tcb, 0)
53
+ try:
54
+ # First batch: rect header is inline, immediately followed by its pixels.
55
+ off = first_batch_off
56
+ for _ in range(first_batch_count):
57
+ x, y, w, h = struct.unpack_from("<hhhh", tcb, off)
58
+ _load_image_bin(vram, x, y, w, h, tcb, off + 8)
59
+ off += w * h * 2 + 8
60
+ # Second batch: rect headers packed from second_batch_off+8, pixels from a separate img cursor.
61
+ img_off, second_batch_count = struct.unpack_from("<Ii", tcb, second_batch_off)
62
+ rect_off = second_batch_off + 8
63
+ for _ in range(second_batch_count):
64
+ x, y, w, h = struct.unpack_from("<hhhh", tcb, rect_off)
65
+ img_off = _load_image_bin(vram, x, y, w, h, tcb, img_off)
66
+ rect_off += 8
67
+ except (struct.error, IndexError) as ex:
68
+ raise SpsTextureError(f"malformed spt.tcb: {ex}") from ex
69
+ return vram
70
+
71
+
72
+ def _abgr16_to_rgba(num: int) -> tuple[int, int, int, int]:
73
+ """Expand a PSX BGR555 halfword to RGBA8. Black (0x0000) is transparent; the 0x8000 STP bit OR any colour
74
+ bit makes it opaque. Mirrors ``PSXTexture.ConvertABGR16toABGR32``."""
75
+ r = (num & 0x1F) << 3
76
+ g = (num & 0x3E0) >> 2
77
+ b = (num & 0x7C00) >> 7
78
+ a = 255 if (num & 0x8000) or (num & 0x7FFF) else 0
79
+ return r, g, b, a
80
+
81
+
82
+ def decode_page(vram: array, tpage_raw: int, clut_raw: int, w: int = 256, h: int = 256) -> bytes:
83
+ """Decode a 256x256 (by default) RGBA texture window from VRAM for an ``.sps``'s ``tpage``/``clut``.
84
+ Mirrors ``PSXTexture.CreateBufferColor32`` (TP 0=4bpp / 1=8bpp / 2=16bpp-direct). Returns ``w*h*4``
85
+ bytes, row-major, top row first (the same uniIndex order the engine writes)."""
86
+ tp = (tpage_raw >> 7) & 3
87
+ tx = (tpage_raw & 0x0F) << 6 # VRAM x base (halfwords)
88
+ ty = ((tpage_raw >> 4) & 1) << 8 # VRAM y base
89
+ clut_x = clut_raw & 0x3F
90
+ clut_y = (clut_raw >> 6) & 0x1FF
91
+ clut_base = (clut_x << 4) + (clut_y << 10)
92
+ out = bytearray(w * h * 4)
93
+ o = 0
94
+
95
+ def put(num: int):
96
+ nonlocal o
97
+ r, g, b, a = _abgr16_to_rgba(num)
98
+ out[o] = r; out[o + 1] = g; out[o + 2] = b; out[o + 3] = a
99
+ o += 4
100
+
101
+ if tp == 0: # 4bpp: 4 texels per halfword, low nibble first
102
+ for y in range(h):
103
+ vi = (ty + y) * VRAM_W + tx
104
+ for _ in range(w >> 2):
105
+ pi = vram[vi]
106
+ put(vram[clut_base + (pi & 0xF)])
107
+ put(vram[clut_base + ((pi >> 4) & 0xF)])
108
+ put(vram[clut_base + ((pi >> 8) & 0xF)])
109
+ put(vram[clut_base + (pi >> 12)])
110
+ vi += 1
111
+ elif tp == 1: # 8bpp: 2 texels per halfword
112
+ for y in range(h):
113
+ vi = (ty + y) * VRAM_W + tx
114
+ for _ in range(w >> 1):
115
+ pi = vram[vi]
116
+ put(vram[clut_base + (pi & 0xFF)])
117
+ put(vram[clut_base + (pi >> 8)])
118
+ vi += 1
119
+ else: # 16bpp direct (no CLUT)
120
+ for y in range(h):
121
+ vi = (ty + y) * VRAM_W + tx
122
+ for _ in range(w):
123
+ put(vram[vi])
124
+ vi += 1
125
+ return bytes(out)
126
+
127
+
128
+ def tcb_page_rgba(tcb: bytes, tpage_raw: int, clut_raw: int, w: int = 256, h: int = 256):
129
+ """Convenience: load ``spt.tcb`` and decode the page an effect samples. Returns ``(w, h, rgba_bytes)``."""
130
+ vram = load_tcb_to_vram(tcb)
131
+ return w, h, decode_page(vram, tpage_raw, clut_raw, w, h)
@@ -0,0 +1,163 @@
1
+ """Engine WALKMESH HOTFIXES that a fork loses on a minted id -- the catalog.
2
+
3
+ A handful of real fields rely on a **hardcoded Memoria engine hotfix, keyed on the real ``fldMapNo``**, that
4
+ toggles walkmesh-triangle active-state (``WalkMesh.BGI_triSetActive(triNdx, isActive)``, which flips bit 0 of
5
+ the triangle's ``triFlags`` -- the walkable bit -- both in the loaded ``BGI_DEF`` and the runtime mesh). A
6
+ verbatim/native fork ships the same ``.bgi`` but runs at a CUSTOM id (>= 4000), so every ``mapNo == <real id>``
7
+ guard is false and the hotfix never fires -- the forked walkmesh is subtly wrong at that beat (a wall stays
8
+ walkable, an NPC can't reach its spot, a blocked stair is open). This is the "real-``fldMapNo``-gated engine
9
+ behavior is lost on a mint" residual from ``docs/FORK_FIDELITY.md`` (the carry taxonomy), made concrete for the
10
+ walkmesh dimension.
11
+
12
+ There are two tractability classes:
13
+
14
+ * **LOAD-TIME, unconditional** (``FieldMap.BG_init`` / ``DelayedActiveTri``, keyed on ``fldMapNo`` only): the
15
+ triangle state is asserted at field load with no runtime condition. A fork reproduces it EXACTLY by
16
+ prepending ``EnablePathTriangle(tri, state)`` -- the same opcode (0x9A) whose handler IS ``BGI_triSetActive``
17
+ -- to ``Main_Init`` (``content.walkmesh_hotfix.apply_tri_toggles``). The ``.bgi`` stays byte-verbatim; the
18
+ fix lives in the script layer, exactly as the engine's own opcode would. These carry ``toggles`` and are
19
+ AUTO-reproduced by the build (``[field] walkmesh_tri_toggles``, auto-emitted by ``import`` for these donors).
20
+ * **EVENT-CODE / DYNAMIC / OPCODE-AUGMENT** (``DoEventCode`` + ``turnOffTriManually``, keyed on ``mapNo`` plus a
21
+ runtime condition -- an object uid/sid/tag, a position, or a story var): the toggle fires DURING play, so a
22
+ static prepend can't reproduce it faithfully (e.g. Daguerreo's librarian tris TRACK ``gEventGlobal`` var
23
+ 761060). These are cataloged with a reproduction ``note`` and surfaced by ``fork-report`` as "lost on a mint
24
+ -> fork in-place on the real id (or accept it)". They are NOT auto-applied -- a per-field bespoke splice
25
+ (locate the trigger ``.eb`` site, splice the toggle) is a possible follow-up, recorded in each note.
26
+
27
+ Source (Memoria, ``Assembly-CSharp``, compile-matched to ``6b8bb2d5``):
28
+ ``Global/Field/Map/FieldMap.cs`` (load-time), ``Global/Event/Engine/EventEngine.DoEventCode.cs`` (event-code +
29
+ the BGIACTIVE 0x9A handler's own ``mapNo`` augments), ``Global/Event/Engine/EventEngine.turnOffTriManually.cs``
30
+ (dynamic). Read-only reference data -- ships no Square-Enix bytes.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ from dataclasses import dataclass, field as _dc_field
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Hotfix:
39
+ """One real field's engine walkmesh hotfix.
40
+
41
+ ``kind`` : ``load_time`` (auto-reproducible) | ``event_code`` | ``dynamic`` | ``opcode_augment``.
42
+ ``toggles`` : the load-time ``(tri, state)`` pairs (state 1 = active/walkable, 0 = inactive) -- the AUTO
43
+ set, non-empty only for ``load_time``.
44
+ ``tris`` : every triangle index the hotfix touches (for reporting, incl. the non-auto kinds).
45
+ ``note`` : what it does + the runtime condition + (for non-auto) the reproduction recipe.
46
+ ``source`` : the Memoria source location.
47
+ ``engine_remapped`` : the SHIPPED custom engine already reproduces this hotfix faithfully for a fork (its
48
+ ``fldMapNo`` gate is wrapped with ``EffectiveFieldId``, so it fires for the fork id too) WITH the
49
+ original timing -- so the kit must NOT also prepend a Main_Init toggle. Set when the engine hotfix
50
+ is DELAYED (e.g. 2507's ``DelayedActiveTri`` runs 0.5s after load): the at-load toggle prepend
51
+ fires BEFORE the field's own props settle onto those tris, snapping them to the wrong floor (the
52
+ Ipsen chests dropped a level). Reproduce delayed hotfixes via the engine remap, not the prepend.
53
+ """
54
+
55
+ field_id: int
56
+ name: str
57
+ kind: str
58
+ note: str
59
+ source: str
60
+ toggles: tuple = ()
61
+ tris: tuple = ()
62
+ engine_remapped: bool = False
63
+
64
+ @property
65
+ def auto(self) -> bool:
66
+ """True when the build SHOULD reproduce this hotfix via a Main_Init tri-toggle prepend. False for an
67
+ ``engine_remapped`` hotfix: the shipped engine already reproduces it for the fork id (correct timing),
68
+ and the at-load prepend would mis-time it (see ``engine_remapped``)."""
69
+ return self.kind == "load_time" and bool(self.toggles) and not self.engine_remapped
70
+
71
+
72
+ _HOTFIXES = {
73
+ # --- LOAD-TIME (unconditional at field load; AUTO-reproduced) -------------------------------------------
74
+ 2356: Hotfix(2356, "Gulug/Room (the Red-Dragon-wall room)", "load_time",
75
+ "At field load the engine deactivates 3 floor triangles around a 3D treasure-chest prop (entry "
76
+ "5, GEO_ACC_F0_TBX @ (-426,1664)). The engine comment '(Red Dragon bursting through wall)' names "
77
+ "the ROOM (field 2356), not these tris' job. ★ IN-GAME PROVEN by A/B (2026-06-14): teleporting "
78
+ "to the patch EDGE (-543,1667) -- ~120u from the chest, beyond its collision -- is STUCK with the "
79
+ "toggle (id 30003) and FREE without it (id 30004), so the toggle (not the chest) blocks that "
80
+ "floor. The patch extends ~120u around the chest, so it blocks more than the chest's collision "
81
+ "alone -- the hotfix is NOT redundant. (Confound to avoid: the tri-78 CENTER (39u) coincides "
82
+ "with the chest collision; test the edge.)",
83
+ "FieldMap.cs:112-117", toggles=((78, 0), (79, 0), (80, 0)), tris=(78, 79, 80)),
84
+ 2161: Hotfix(2161, "L. Castle/Guest Room (disc 3)", "load_time",
85
+ "At field load the engine deactivates one triangle (a disc-3 room-layout block). Unconditional.",
86
+ "FieldMap.cs:119-122", toggles=((69, 0),), tris=(69,)),
87
+ 2507: Hotfix(2507, "I. Castle/Stairwell (ladders + stairs)", "load_time",
88
+ "0.5s AFTER load the engine deactivates four stairwell triangles AND drops every non-player NPC's "
89
+ "walkmesh collision (DelayedActiveTri). ENGINE-REMAPPED: the s29 fork-donor patch wraps this gate "
90
+ "with EffectiveFieldId, so it fires for the fork id too -- with the original 0.5s delay AND the "
91
+ "NPC-collision drop. The kit must NOT also prepend an at-load toggle: tris 174/175/177/178 are the "
92
+ "FLOOR the two treasure-chest props snap onto, and the real field's 0.5s delay lets them settle "
93
+ "FIRST, then removes the tris. An at-load prepend removes them BEFORE the chests place, snapping "
94
+ "the chests a floor down (★ caught in-game 2026-06-23). So reproduce via the engine remap only.",
95
+ "FieldMap.cs:139-148", toggles=((174, 0), (175, 0), (177, 0), (178, 0)),
96
+ tris=(174, 175, 177, 178), engine_remapped=True),
97
+
98
+ # --- EVENT-CODE one-shot (locatable trigger; reproducible-but-bespoke; NOT auto-applied) ----------------
99
+ 450: Hotfix(450, "Dali/Field (Grandma's initial position)", "event_code",
100
+ "When Grandma (sid 3) is created at (363, 88) the engine deactivates one triangle. Reproducible "
101
+ "by splicing EnablePathTriangle(24,0) after that CreateObject in the fork's .eb (bespoke; not "
102
+ "auto-applied -- a synth fork's spawn/positions may differ).",
103
+ "DoEventCode.cs:291-292", tris=(24,)),
104
+
105
+ # --- OPCODE-AUGMENT (the BGIACTIVE 0x9A handler itself has mapNo special-cases) ------------------------
106
+ 1753: Hotfix(1753, "(EnablePathTriangle augment)", "opcode_augment",
107
+ "When the field's own .eb runs EnablePathTriangle(207, x), the engine ALSO toggles triangle "
108
+ "208 to the same state. A fork keeps the donor's EnablePathTriangle(207,x) but loses the paired "
109
+ "208 toggle. Reproducible by emitting a paired EnablePathTriangle(208, x) beside it.",
110
+ "DoEventCode.cs:2566-2567", tris=(207, 208)),
111
+ 1606: Hotfix(1606, "(EnablePathTriangle augment)", "opcode_augment",
112
+ "When the field's own .eb runs EnablePathTriangle(107, x), the engine FORCES x = 1 (always "
113
+ "activate). A fork's EnablePathTriangle(107, 0) would deactivate instead. Reproducible by "
114
+ "rewriting that toggle's state operand to 1 in the fork's .eb.",
115
+ "DoEventCode.cs:2568-2569", tris=(107,)),
116
+
117
+ # --- DYNAMIC (toggle tracks runtime story/position state; NOT statically reproducible) -----------------
118
+ 2803: Hotfix(2803, "Daguerreo/2nd Floor (LibrarianB book quest)", "dynamic",
119
+ "The librarian's walkable triangles 105/106 are activated when RunScript(uid 20, tag 18) fires "
120
+ "AND are continuously re-evaluated against gEventGlobal var 761060 (turnOffTriManually). A "
121
+ "static prepend can't track the story var. The interaction also depends on Main_Init shared "
122
+ "helpers (the #14-infeasible quest logic) -- fork in-place on 2803, or accept the book-quest "
123
+ "geometry is at scenario-zero.",
124
+ "DoEventCode.cs:158-162 + turnOffTriManually.cs:39-44", tris=(105, 106)),
125
+ 900: Hotfix(900, "Treno/Pub (Steiner_11)", "dynamic",
126
+ "Triangle 62 is activated when RunScriptAsync(uid 14, level 2, tag 11) fires, and 56/62 are "
127
+ "deactivated by turnOffTriManually on later beats. Tracks runtime script/manual-var state -> "
128
+ "not a static toggle; fork in-place for faithful pub geometry.",
129
+ "DoEventCode.cs:149-150 + turnOffTriManually.cs:14-31", tris=(56, 62)),
130
+ 1421: Hotfix(1421, "Fossil Roo/Mining Site (Lindblum_Worker)", "dynamic",
131
+ "Triangles 109/110 toggle on/off as the worker (sid 5) moves between positions (a moving "
132
+ "block). Position-driven during play -> not a static toggle.",
133
+ "DoEventCode.cs:296-309", tris=(109, 110)),
134
+ 1900: Hotfix(1900, "(turnOffTriManually, sid 4)", "dynamic",
135
+ "Triangle 56 is deactivated by turnOffTriManually when an object with sid 4 triggers it. "
136
+ "Object-driven -> not a static load-time toggle.",
137
+ "turnOffTriManually.cs:8-12", tris=(56,)),
138
+ 1455: Hotfix(1455, "(turnOffTriManually, sid 5)", "dynamic",
139
+ "Triangle 16 is deactivated by turnOffTriManually when an object with sid 5 triggers it. "
140
+ "Object-driven -> not a static load-time toggle.",
141
+ "turnOffTriManually.cs:32-36", tris=(16,)),
142
+ }
143
+
144
+
145
+ def info(field_id) -> "Hotfix | None":
146
+ """The :class:`Hotfix` record for a real field id, or ``None`` if the field has no engine walkmesh hotfix
147
+ (the vast majority). ``field_id`` may be an int or a numeric string."""
148
+ try:
149
+ return _HOTFIXES.get(int(field_id))
150
+ except (TypeError, ValueError):
151
+ return None
152
+
153
+
154
+ def load_time_toggles(field_id) -> list:
155
+ """The ``[(tri, state), ...]`` a fork of ``field_id`` should prepend at load to reproduce its (auto)
156
+ engine walkmesh hotfix, or ``[]`` when the field has none / its hotfix isn't statically reproducible."""
157
+ h = info(field_id)
158
+ return [list(t) for t in h.toggles] if (h and h.auto) else []
159
+
160
+
161
+ def all_ids() -> list:
162
+ """Every real field id with a cataloged engine walkmesh hotfix (sorted)."""
163
+ return sorted(_HOTFIXES)
@@ -0,0 +1,18 @@
1
+ """The FF9 Map Kit **workspace** -- a modern PySide6 (Qt) shell for the kit's GUIs.
2
+
3
+ Phase 3 of the GUI makeover: one dockable window whose left rail IS the journey > campaign > field >
4
+ object hierarchy, a clickable breadcrumb, a central document area, a right inspector, and a bottom
5
+ Output/Problems dock -- the genuinely-modern, scalable replacement for the tkinter ``apps/*.pyw`` suite,
6
+ shipped side-by-side with them.
7
+
8
+ The shell **reuses the kit's tk-free backends unchanged** -- ``editor.feedback`` (Verdict/Problem),
9
+ ``editor.breadcrumb`` (Crumb/trail), ``campaign`` (CampaignPlan/graph), ``editor.forms``/``editor.model``
10
+ -- so only the Qt view layer is new. ``style`` (the QSS builder) is PySide6-FREE and unit-testable; the
11
+ Qt widgets live in ``shell`` (imported lazily by the launcher so this package stays importable headless).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from . import style # noqa: F401 (re-export the PySide6-free QSS builder)
17
+
18
+ __all__ = ["style"]