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.
- ff9mapkit/__init__.py +18 -0
- ff9mapkit/__main__.py +36 -0
- ff9mapkit/_animdb.py +2994 -0
- ff9mapkit/_animdb_all.py +14125 -0
- ff9mapkit/_fieldtable.py +1516 -0
- ff9mapkit/_fieldtext.py +845 -0
- ff9mapkit/_held_poses.py +44 -0
- ff9mapkit/_itemdb.py +65 -0
- ff9mapkit/_modeldb.py +725 -0
- ff9mapkit/_narrowmap_data.py +10 -0
- ff9mapkit/_npcparams.py +634 -0
- ff9mapkit/_regen_animdb.py +72 -0
- ff9mapkit/_regen_animdb_all.py +66 -0
- ff9mapkit/_regen_fieldtable.py +95 -0
- ff9mapkit/_regen_fieldtext.py +66 -0
- ff9mapkit/_regen_modeldb.py +67 -0
- ff9mapkit/_regen_npcparams.py +123 -0
- ff9mapkit/_regen_scenedb.py +57 -0
- ff9mapkit/_scenedb.py +869 -0
- ff9mapkit/abilities.py +225 -0
- ff9mapkit/animations.py +120 -0
- ff9mapkit/archetypes.py +218 -0
- ff9mapkit/areatitle.py +76 -0
- ff9mapkit/battle/__init__.py +21 -0
- ff9mapkit/battle/abilityfeatures.py +294 -0
- ff9mapkit/battle/actiondelta.py +441 -0
- ff9mapkit/battle/aiauthor.py +305 -0
- ff9mapkit/battle/ailint.py +140 -0
- ff9mapkit/battle/aipatch.py +175 -0
- ff9mapkit/battle/battleai.py +148 -0
- ff9mapkit/battle/battlecsv.py +390 -0
- ff9mapkit/battle/battlepatch.py +395 -0
- ff9mapkit/battle/build.py +558 -0
- ff9mapkit/battle/camera_codec.py +332 -0
- ff9mapkit/battle/camera_data.py +128 -0
- ff9mapkit/battle/characterdelta.py +789 -0
- ff9mapkit/battle/event_data.py +72 -0
- ff9mapkit/battle/extract.py +540 -0
- ff9mapkit/battle/fbx.py +223 -0
- ff9mapkit/battle/reskin.py +149 -0
- ff9mapkit/battle/scene_codec.py +314 -0
- ff9mapkit/battle/scene_data.py +369 -0
- ff9mapkit/battle/scenelint.py +125 -0
- ff9mapkit/battle/seqasm.py +131 -0
- ff9mapkit/battle/seqauthor.py +220 -0
- ff9mapkit/battle/seqcodec.py +300 -0
- ff9mapkit/battle/seqdis.py +106 -0
- ff9mapkit/battle/seqpatch.py +137 -0
- ff9mapkit/battle_bgm.py +133 -0
- ff9mapkit/binutils.py +60 -0
- ff9mapkit/build.py +5445 -0
- ff9mapkit/campaign.py +1276 -0
- ff9mapkit/catalog.py +316 -0
- ff9mapkit/chain.py +358 -0
- ff9mapkit/cli.py +3114 -0
- ff9mapkit/config.py +360 -0
- ff9mapkit/content/__init__.py +13 -0
- ff9mapkit/content/areatitle.py +36 -0
- ff9mapkit/content/ate.py +118 -0
- ff9mapkit/content/camera.py +123 -0
- ff9mapkit/content/chest.py +186 -0
- ff9mapkit/content/choice.py +163 -0
- ff9mapkit/content/conductor.py +217 -0
- ff9mapkit/content/cutscene.py +290 -0
- ff9mapkit/content/encounter.py +41 -0
- ff9mapkit/content/entry_settle.py +50 -0
- ff9mapkit/content/equipment.py +93 -0
- ff9mapkit/content/event.py +191 -0
- ff9mapkit/content/gateway.py +101 -0
- ff9mapkit/content/inventory.py +59 -0
- ff9mapkit/content/itemdata.py +644 -0
- ff9mapkit/content/itemtext.py +168 -0
- ff9mapkit/content/jump.py +114 -0
- ff9mapkit/content/ladder.py +633 -0
- ff9mapkit/content/movement.py +53 -0
- ff9mapkit/content/music.py +97 -0
- ff9mapkit/content/npc.py +348 -0
- ff9mapkit/content/object.py +340 -0
- ff9mapkit/content/onentry.py +135 -0
- ff9mapkit/content/party.py +111 -0
- ff9mapkit/content/pathfind.py +138 -0
- ff9mapkit/content/platform.py +314 -0
- ff9mapkit/content/player.py +168 -0
- ff9mapkit/content/prop.py +75 -0
- ff9mapkit/content/region.py +340 -0
- ff9mapkit/content/reinit.py +59 -0
- ff9mapkit/content/savepoint.py +90 -0
- ff9mapkit/content/shop.py +178 -0
- ff9mapkit/content/sps_trigger.py +66 -0
- ff9mapkit/content/startup.py +71 -0
- ff9mapkit/content/synthesis.py +106 -0
- ff9mapkit/content/text.py +183 -0
- ff9mapkit/content/textcarry.py +290 -0
- ff9mapkit/content/verbatim.py +86 -0
- ff9mapkit/content/walkmesh_hotfix.py +38 -0
- ff9mapkit/data/__init__.py +48 -0
- ff9mapkit/data/_regen_provenance.py +142 -0
- ff9mapkit/data/provenance/blank.es.patch +1 -0
- ff9mapkit/data/provenance/blank.fr.patch +1 -0
- ff9mapkit/data/provenance/blank.gr.patch +1 -0
- ff9mapkit/data/provenance/blank.it.patch +1 -0
- ff9mapkit/data/provenance/blank.jp.patch +1 -0
- ff9mapkit/data/provenance/blank.uk.patch +1 -0
- ff9mapkit/data/provenance/blank.us.patch +1 -0
- ff9mapkit/data/provenance/manifest.json +65 -0
- ff9mapkit/data/provenance/region_template.patch +1 -0
- ff9mapkit/data/reference_arcs.toml +89 -0
- ff9mapkit/data/region_catalog.toml +593 -0
- ff9mapkit/deploystack.py +358 -0
- ff9mapkit/dialogue.py +803 -0
- ff9mapkit/eb/__init__.py +12 -0
- ff9mapkit/eb/_exprtable.py +59 -0
- ff9mapkit/eb/_membertable.py +38 -0
- ff9mapkit/eb/_optables.py +537 -0
- ff9mapkit/eb/_regen_optables.py +76 -0
- ff9mapkit/eb/cmdasm.py +323 -0
- ff9mapkit/eb/disasm.py +332 -0
- ff9mapkit/eb/edit.py +439 -0
- ff9mapkit/eb/exprasm.py +158 -0
- ff9mapkit/eb/model.py +178 -0
- ff9mapkit/eb/opcodes.py +463 -0
- ff9mapkit/eblint.py +177 -0
- ff9mapkit/editor/__init__.py +20 -0
- ff9mapkit/editor/app.py +950 -0
- ff9mapkit/editor/battle_forms.py +240 -0
- ff9mapkit/editor/breadcrumb.py +89 -0
- ff9mapkit/editor/dialogs.py +116 -0
- ff9mapkit/editor/feedback.py +208 -0
- ff9mapkit/editor/forms.py +632 -0
- ff9mapkit/editor/graphview.py +350 -0
- ff9mapkit/editor/jobs.py +342 -0
- ff9mapkit/editor/model.py +243 -0
- ff9mapkit/editor/picker.py +120 -0
- ff9mapkit/editor/theme.py +212 -0
- ff9mapkit/eventscan.py +1441 -0
- ff9mapkit/extract.py +2279 -0
- ff9mapkit/flags.py +693 -0
- ff9mapkit/forkreport.py +1383 -0
- ff9mapkit/hub.py +477 -0
- ff9mapkit/idgated.py +101 -0
- ff9mapkit/infohub.py +580 -0
- ff9mapkit/items.py +63 -0
- ff9mapkit/itemstats.py +346 -0
- ff9mapkit/journey.py +1902 -0
- ff9mapkit/keyitems.py +93 -0
- ff9mapkit/logic_add.py +632 -0
- ff9mapkit/logic_edit.py +728 -0
- ff9mapkit/logic_map.py +526 -0
- ff9mapkit/pack.py +175 -0
- ff9mapkit/playerswap.py +231 -0
- ff9mapkit/prop_archetypes.py +228 -0
- ff9mapkit/provision.py +282 -0
- ff9mapkit/refarc.py +825 -0
- ff9mapkit/save.py +337 -0
- ff9mapkit/save_items.py +1673 -0
- ff9mapkit/scene/__init__.py +11 -0
- ff9mapkit/scene/arena.py +63 -0
- ff9mapkit/scene/bgart.py +140 -0
- ff9mapkit/scene/bgi.py +732 -0
- ff9mapkit/scene/bgs.py +174 -0
- ff9mapkit/scene/bgx.py +185 -0
- ff9mapkit/scene/cam.py +345 -0
- ff9mapkit/scene/guide.py +311 -0
- ff9mapkit/scene/paint.py +506 -0
- ff9mapkit/scene/placeholder.py +107 -0
- ff9mapkit/sjbinary.py +285 -0
- ff9mapkit/sps/__init__.py +17 -0
- ff9mapkit/sps/author.py +294 -0
- ff9mapkit/sps/catalog.py +88 -0
- ff9mapkit/sps/codec.py +264 -0
- ff9mapkit/sps/edit.py +184 -0
- ff9mapkit/sps/lint.py +58 -0
- ff9mapkit/sps/render.py +116 -0
- ff9mapkit/sps/templates.py +47 -0
- ff9mapkit/sps/texture.py +131 -0
- ff9mapkit/walkmesh_hotfixes.py +163 -0
- ff9mapkit/workspace/__init__.py +18 -0
- ff9mapkit/workspace/battledoc.py +985 -0
- ff9mapkit/workspace/builddoc.py +607 -0
- ff9mapkit/workspace/forms_qt.py +586 -0
- ff9mapkit/workspace/importdoc.py +665 -0
- ff9mapkit/workspace/mapview.py +131 -0
- ff9mapkit/workspace/palette.py +85 -0
- ff9mapkit/workspace/savedoc.py +664 -0
- ff9mapkit/workspace/shell.py +6907 -0
- ff9mapkit/workspace/style.py +105 -0
- ff9mapkit/workspace/tuningdialog.py +223 -0
- ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
- ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
- ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
- ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
- ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
- ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
ff9mapkit/sps/render.py
ADDED
|
@@ -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()]
|
ff9mapkit/sps/texture.py
ADDED
|
@@ -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"]
|