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,369 @@
1
+ """Tune a forked battle's gameplay (the BTL_SCENE ``dbfile0000.raw16``) from a battle.toml ``[scene]``.
2
+
3
+ A tier-c mint forks a donor battle's raw16 verbatim; this module SURGICALLY patches it with author
4
+ overrides -- enemy positions, stats, rewards, and the camera pose -- WITHOUT changing enemy TYPES (so the
5
+ forked raw17 attack sequences + the loaded enemy GEO/models stay valid). Only the edited fields change;
6
+ every other byte is verbatim, so we never risk mis-packing the 116-byte monster struct.
7
+
8
+ Layout (from Memoria ``BTL_SCENE.ReadBattleScene``):
9
+ header 8B: Ver(1) PatCount(1) TypCount(1) AtkCount(1) Flags(2) pad(2)
10
+ pattern i @ 8 + 56*i (56B): Rate(1) MonsterCount(1) Camera(1) Pad0(1) AP(u32) then 4x SB2_PUT(12B)
11
+ SB2_PUT j @ +8 + 12*j: TypeNo(1) Flags(1) Pease(1) Pad(1) Xpos(i16) Ypos(i16) Zpos(i16) Rot(i16)
12
+ monster t @ 8 + 56*PatCount + 116*t (116B, SB2_MON_PARM): the fields we edit are
13
+ ResistStatus@0 AutoStatus@4 InitialStatus@8 (u32 BattleStatus masks)
14
+ MaxHP@12(u16) MaxMP@14(u16) WinGil@16(u16) WinExp@18(u16) WinItems@20(4B) StealItems@24(4B)
15
+ Element{Speed@52,Str@53,Mag@54,Spr@55} Null/Absorb/Half/Weak-Element@60/61/62/63(1B each)
16
+ Level@64 Category@65 HitRate@66 Phys/Mag-Def+Evade@67-70 BlueMagic@71 WinCard@105
17
+ pattern AP @ pattern+4 (u32) = the GAMEPLAY AP reward (the per-type AP@50 is unused for rewards).
18
+
19
+ A stat edit targets the slot's TYPE (SB2_MON_PARM is per type), so two slots sharing a type share stats
20
+ (a real FF9 constraint) -- the editor warns when a type is tuned twice. Element/status fields take a list of
21
+ NAMES (or a raw int); see :mod:`battlecsv` for the name<->bit tables and the [PatchableField] note.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import struct
26
+
27
+ from .. import items
28
+ from . import battlecsv
29
+
30
+ _HDR = 8
31
+ _PAT = 56
32
+ _MON = 116
33
+ _PUT = 12
34
+ _FLG_TARGETABLE = 1 # SB2_PUT.FLG_TARGETABLE -- the enemy can be selected/attacked (FLG_MULTIPART=2)
35
+
36
+ # SB2_MON_PARM scalar field -> (offset, struct-fmt). Offsets RELATIVE to the 116-byte monster block; verified
37
+ # vs BTL_SCENE.cs:54-122 + the scene_codec round-trip. All raw17-safe (no model/type bytes).
38
+ _MON_FIELDS = {
39
+ "hp": (12, "<H"), "mp": (14, "<H"), "gil": (16, "<H"), "exp": (18, "<H"),
40
+ "speed": (52, "<B"), "strength": (53, "<B"), "magic": (54, "<B"), "spirit": (55, "<B"),
41
+ "level": (64, "<B"), "category": (65, "<B"), "hit_rate": (66, "<B"),
42
+ "phys_def": (67, "<B"), "phys_evade": (68, "<B"), "mag_def": (69, "<B"), "mag_evade": (70, "<B"),
43
+ "blue_magic": (71, "<B"), "win_card": (105, "<B"),
44
+ }
45
+ _MON_INT_MAX = {"<H": 0xFFFF, "<B": 0xFF}
46
+
47
+ # Element-affinity bytes (a 1-byte EffectElement bitmask each) + status masks (a u32 BattleStatus each),
48
+ # RELATIVE to the monster block. The author value is a list of element/status NAMES (or a raw int) -- see
49
+ # battlecsv.encode_elements / encode_status. `null` = GuardElement (nullified/immune elements).
50
+ _MON_ELEM_FIELDS = {"null": 60, "absorb": 61, "half": 62, "weak": 63}
51
+ _MON_STATUS_FIELDS = {"resist_status": 0, "auto_status": 4, "initial_status": 8}
52
+
53
+ # Per-enemy MON flags (SB2_MON_PARM.Flags @48, u16 = ENEMY_INFO.flags) -- raw16-ONLY (not a [PatchableField], so
54
+ # BattlePatch can't reach it; this is the one enemy-identity gap the BP surface leaves). Named bits from
55
+ # ENEMY.cs:37-39: die_atk/die_dmg pick the death-animation path; non_dying_boss = the enemy SURVIVES HP=0 (a
56
+ # scripted boss can't be killed by normal damage). The author value is a list of NAMES or a raw int (the high
57
+ # bits are read by some enemies' AI .eb, so a raw int passes them through).
58
+ _MON_FLAGS_OFF = 48
59
+ _MON_FLAG_NAMES = {"die_atk": 1, "die_dmg": 2, "non_dying_boss": 4}
60
+
61
+ # [scene]-level Flags (the scene header @4, a u16) -- the ENCOUNTER RULES. Named bits from BTL_SCENE scene_flags
62
+ # (scene_codec: preemptive = SpecialStart bit0, back_attack bit1, no_exp bit3, can't-escape = Runaway bit5).
63
+ # Authoring a `[scene] flags` list CLEARS these 4 known bits then ORs the named ones (any OTHER header bit is
64
+ # preserved); a raw int REPLACES the whole word. Absent -> the donor's header flags are kept verbatim.
65
+ _SCENE_FLAGS_OFF = 4
66
+ _SCENE_FLAG_NAMES = {"preemptive": 0x01, "back_attack": 0x02, "no_exp": 0x08, "no_escape": 0x20}
67
+ _SCENE_FLAG_MASK = 0x01 | 0x02 | 0x08 | 0x20
68
+
69
+ # RE-SKIN: the (offset, length) byte ranges of the MODEL + display fields, copied VERBATIM from a real donor
70
+ # enemy so a forked enemy's BODY looks like a different creature while keeping its own gameplay. The appearance
71
+ # is a self-consistent group: Geo (the model), Mot[6] (six GLOBAL animation ids = the IDLE/DAMAGE/DEATH motions,
72
+ # `BattleUnit.cs:858-878`; the engine resolves them to THAT model's clips, `btl_init.cs:240`/`:521-522`, and a
73
+ # clip that doesn't belong to the loaded model FREEZES the battle), Mesh[2], Radius (-> radius_collision,
74
+ # `btl_init.cs:68`; model-size-attached -- the Geo table only sets radius_effect/height, so Radius@28 is LIVE,
75
+ # keep it), plus the model-ATTACHED cosmetics (bone[4], die/start SFX, status-icon bones + offsets, shadow
76
+ # bones + offsets). Swapping Geo alone leaves the OLD model's Mot ids -> wrong/missing clips, so a re-skin
77
+ # transplants the WHOLE block. The GAMEPLAY fields (status/hp/mp/rewards/stats/elements/level/category/defences/
78
+ # blue_magic/win_card) are deliberately OUTSIDE these ranges -- they stay the target's.
79
+ #
80
+ # SCOPE -- this is a BODY re-skin, NOT a full one (★ IN-GAME PROVEN 2026-06-13: a Goblin re-skinned to the Fang
81
+ # IDLED as a quadruped Fang but ATTACKED Goblin-like). The transplanted Mot[6] DO drive the new model's own idle/
82
+ # damage/death; but the per-ATTACK animation is bound by the donor scene's raw17 btlseq (keyed by Konran@78, the
83
+ # per-type AnmOfsList selector, `btlseq.cs:1150-1151`), which is KEPT -- so the ATTACK plays the TARGET's clip
84
+ # retargeted onto the new mesh (clip-path-by-name, `AnimationFactory.cs:60`, never crashes). DO NOT add Konran@78
85
+ # or MesCnt@79 (the message-count cursor) to these ranges -- both are raw17/text linkage tied to the TARGET scene.
86
+ # Flags@48 (incl. die_atk/die_dmg, which pick the death-anim path) also stays the target's -- it carries gameplay bits.
87
+ _RESKIN_RANGES = (
88
+ (28, 20), # Radius(2) Geo(2) Mot[6](12) Mesh[2](4) -- the model + its 6 idle/damage/death anim ids
89
+ (72, 6), # Bone[4](4) DieSfx(2)
90
+ (80, 18), # IconBone[6] IconY[6] IconZ[6] -- status-icon attach (bone indices are model-specific)
91
+ (98, 6), # StartSfx(2) ShadowX(2) ShadowZ(2)
92
+ (104, 1), # ShadowBone (NOT WinCard@105 -- a reward)
93
+ (106, 4), # ShadowOfsX(2) ShadowOfsZ(2)
94
+ (110, 1), # ShadowBone2 (NOT Pad0@111)
95
+ )
96
+
97
+
98
+ class SceneEditError(ValueError):
99
+ pass
100
+
101
+
102
+ def parse_counts(raw16: bytes):
103
+ """(PatCount, TypCount, AtkCount) from the header."""
104
+ if len(raw16) < _HDR:
105
+ raise SceneEditError("raw16 too short")
106
+ return raw16[1], raw16[2], raw16[3]
107
+
108
+
109
+ def _mon_base(patcount: int) -> int:
110
+ return _HDR + _PAT * patcount
111
+
112
+
113
+ def mon_block(raw16: bytes, type_no: int) -> bytes:
114
+ """The verbatim 116-byte SB2_MON_PARM block for enemy ``type_no`` -- the SOURCE of a re-skin transplant
115
+ (read from a real donor scene). Raises if ``type_no`` is out of range or the block is truncated."""
116
+ patcount, typcount, _atk = parse_counts(raw16)
117
+ if not 0 <= type_no < typcount:
118
+ raise SceneEditError(f"donor type {type_no} out of range (the donor scene has {typcount} type(s))")
119
+ base = _mon_base(patcount) + _MON * type_no
120
+ if len(raw16) < base + _MON:
121
+ raise SceneEditError("donor raw16 truncated -- can't read the monster block")
122
+ return bytes(raw16[base:base + _MON])
123
+
124
+
125
+ def _apply_reskin_block(b: bytearray, mon_off: int, donor_block: bytes, slot=None) -> None:
126
+ """Copy the :data:`_RESKIN_RANGES` (model + display fields) from a real donor's 116-byte monster block
127
+ into the target type's block at ``mon_off`` -- the model swap. The gameplay fields are left untouched."""
128
+ if len(donor_block) != _MON:
129
+ where = f"slot {slot}: " if slot is not None else ""
130
+ raise SceneEditError(f"{where}re-skin donor block is {len(donor_block)} bytes, expected {_MON}")
131
+ for off, ln in _RESKIN_RANGES:
132
+ b[mon_off + off:mon_off + off + ln] = donor_block[off:off + ln]
133
+
134
+
135
+ def apply_scene_edits(raw16: bytes, scene: dict) -> tuple[bytes, list[str]]:
136
+ """Patch ``raw16`` with a battle.toml ``[scene]`` dict. Returns (patched_bytes, warnings).
137
+
138
+ SPAWN COMPOSITION (``monster_count`` set) re-composes a DETERMINISTIC fight: it writes the SAME
139
+ composition (count + per-slot type/placement) to EVERY pattern, so whichever pattern the engine rolls
140
+ yields the user's fight -- and ``build.py`` re-authors the battle eb's Main_Init to match (one enemy-AI
141
+ object per slot), which is what lets a mint exceed the donor's natural enemy count (up to the engine's
142
+ hard cap of 4) using the scene's existing types. Without ``monster_count`` it only TUNES the one
143
+ ``pattern`` (positions/stats/rewards/camera), leaving the composition + the eb untouched.
144
+ """
145
+ patcount, typcount, _atk = parse_counts(raw16)
146
+ b = bytearray(raw16)
147
+ warnings: list[str] = []
148
+ enemies = scene.get("enemy", [])
149
+
150
+ if "flags" in scene: # [scene] ENCOUNTER RULES -> the header Flags u16
151
+ cur = struct.unpack_from("<H", b, _SCENE_FLAGS_OFF)[0]
152
+ struct.pack_into("<H", b, _SCENE_FLAGS_OFF, _encode_scene_flags(scene["flags"], cur))
153
+
154
+ cam = None
155
+ if "camera" in scene:
156
+ cam = int(scene["camera"])
157
+ if not 0 <= cam <= 255:
158
+ raise SceneEditError(f"[scene] camera {cam} out of range (0-2 = a fixed PSX pose, >=3 random)")
159
+ mc = None
160
+ if "monster_count" in scene:
161
+ mc = int(scene["monster_count"])
162
+ if not 1 <= mc <= 4:
163
+ raise SceneEditError(f"[scene] monster_count {mc} out of range (1-4; engine hard cap)")
164
+ ap = None
165
+ if "ap" in scene:
166
+ ap = int(scene["ap"])
167
+ if not 0 <= ap <= 0xFFFFFFFF:
168
+ raise SceneEditError(f"[scene] ap {ap} out of range (0-{0xFFFFFFFF})")
169
+
170
+ # spawn composition -> apply to ALL patterns (uniform/deterministic); else tune the one selected pattern
171
+ if mc is not None:
172
+ pats = list(range(patcount))
173
+ else:
174
+ p = int(scene.get("pattern", 0))
175
+ if not 0 <= p < patcount:
176
+ raise SceneEditError(f"[scene] pattern {p} out of range (this scene has {patcount} pattern(s))")
177
+ pats = [p]
178
+
179
+ mon_base = _mon_base(patcount)
180
+ for pat in pats:
181
+ pat_off = _HDR + _PAT * pat
182
+ if cam is not None:
183
+ b[pat_off + 2] = cam
184
+ if mc is not None:
185
+ b[pat_off + 1] = mc
186
+ for e in enemies:
187
+ _edit_placement(b, pat_off, e, typcount)
188
+ count = b[pat_off + 1] # every ACTIVE slot must be a valid, hittable type
189
+ for s in range(count):
190
+ po = pat_off + 8 + _PUT * s
191
+ if b[po] >= typcount:
192
+ raise SceneEditError(f"active slot {s} (monster_count {count}) has enemy type {b[po]} >= "
193
+ f"TypCount {typcount}; give it a 'type' of 0-{typcount - 1}")
194
+ if not (b[po + 1] & _FLG_TARGETABLE):
195
+ raise SceneEditError(f"active slot {s} (monster_count {count}) is not targetable -- set its "
196
+ f"'type' so it becomes a normal attackable enemy (else the fight can't end)")
197
+
198
+ # the AP reward is per-PATTERN (the gameplay-effective AP, awarded whole) -> write it to EVERY pattern so
199
+ # whichever formation the engine rolls gives the authored AP.
200
+ if ap is not None:
201
+ for pat in range(patcount):
202
+ struct.pack_into("<I", b, _HDR + _PAT * pat + 4, ap)
203
+
204
+ # stats / rewards are per TYPE (one shared block; same-type slots share it) -> apply once (slot types are
205
+ # uniform across patterns, so resolve each enemy's type from the representative pattern).
206
+ rep = _HDR + _PAT * pats[0] + 8
207
+ tuned_types: dict[int, int] = {}
208
+ for e in enemies:
209
+ slot = int(e["slot"])
210
+ stat_keys = [k for k in e if k in _MON_FIELDS or k in _MON_ELEM_FIELDS
211
+ or k in _MON_STATUS_FIELDS or k in ("drop", "steal", "flags")]
212
+ reskin_block = e.get("_reskin_block") # a resolved real-donor block (build injects it)
213
+ if not stat_keys and reskin_block is None:
214
+ continue
215
+ type_no = b[rep + _PUT * slot]
216
+ if type_no in tuned_types and tuned_types[type_no] != slot:
217
+ warnings.append(f"slots {tuned_types[type_no]} and {slot} share enemy type {type_no}; "
218
+ f"their stats/model are the SAME data -- slot {slot} wins")
219
+ tuned_types.setdefault(type_no, slot)
220
+ if type_no >= typcount:
221
+ raise SceneEditError(f"slot {slot} references type {type_no} >= TypCount {typcount}")
222
+ mon_off = mon_base + _MON * type_no
223
+ if reskin_block is not None: # re-skin: transplant the donor's model+display block
224
+ _apply_reskin_block(b, mon_off, reskin_block, slot)
225
+ for k in stat_keys:
226
+ if k in _MON_FIELDS:
227
+ off, fmt = _MON_FIELDS[k]
228
+ v = int(e[k])
229
+ if not 0 <= v <= _MON_INT_MAX[fmt]:
230
+ raise SceneEditError(f"slot {slot} {k}={v} out of range (0-{_MON_INT_MAX[fmt]})")
231
+ struct.pack_into(fmt, b, mon_off + off, v)
232
+ elif k in _MON_ELEM_FIELDS: # null/absorb/half/weak: a 1-byte element bitmask
233
+ try:
234
+ v = battlecsv.encode_elements(e[k])
235
+ except ValueError as ex:
236
+ raise SceneEditError(f"slot {slot} {k}: {ex}")
237
+ if not 0 <= v <= 0xFF:
238
+ raise SceneEditError(f"slot {slot} {k} bitmask {v} out of range (0-255)")
239
+ b[mon_off + _MON_ELEM_FIELDS[k]] = v
240
+ elif k in _MON_STATUS_FIELDS: # resist/auto/initial: a u32 BattleStatus mask
241
+ try:
242
+ v = battlecsv.encode_status(e[k])
243
+ except ValueError as ex:
244
+ raise SceneEditError(f"slot {slot} {k}: {ex}")
245
+ struct.pack_into("<I", b, mon_off + _MON_STATUS_FIELDS[k], v & 0xFFFFFFFF)
246
+ elif k == "flags": # per-enemy MON flags @48 (non_dying_boss / die_*)
247
+ struct.pack_into("<H", b, mon_off + _MON_FLAGS_OFF, _encode_flags(e[k], slot))
248
+ else: # drop / steal: 4 item slots (id/name; 255 = none)
249
+ base = mon_off + (20 if k == "drop" else 24)
250
+ for i, iid in enumerate(_resolve_items(e[k], slot, k)):
251
+ b[base + i] = iid
252
+ return bytes(b), warnings
253
+
254
+
255
+ def _edit_placement(b: bytearray, pat_off: int, e: dict, typcount: int) -> None:
256
+ """Apply one [[scene.enemy]]'s slot TYPE + placement (pos/y/rot) within a single pattern."""
257
+ if "slot" not in e:
258
+ raise SceneEditError("[[scene.enemy]] needs a 'slot' (0-3, the placement in the pattern)")
259
+ slot = int(e["slot"])
260
+ if not 0 <= slot < 4:
261
+ raise SceneEditError(f"[[scene.enemy]] slot {slot} out of range (0-3)")
262
+ put_off = pat_off + 8 + _PUT * slot
263
+ if "type" in e:
264
+ t = int(e["type"])
265
+ if not 0 <= t < typcount:
266
+ raise SceneEditError(f"slot {slot} type {t} out of range (0-{typcount - 1}); must be an enemy "
267
+ f"type ALREADY in this scene, so the forked raw17/GEO/AI covers it")
268
+ b[put_off] = t
269
+ b[put_off + 1] = _FLG_TARGETABLE # normal, targetable, single-part enemy
270
+ # GROUND it: default an activated slot's height to slot 0's Ypos (a real on-ground enemy). Explicit y wins.
271
+ struct.pack_into("<h", b, put_off + 6, struct.unpack_from("<h", b, pat_off + 8 + 6)[0])
272
+ if "pos" in e:
273
+ pos = list(e["pos"])
274
+ if len(pos) not in (2, 3):
275
+ raise SceneEditError(f"[[scene.enemy]] slot {slot}: pos must be [x, z] or [x, y, z]")
276
+ struct.pack_into("<h", b, put_off + 4, _clamp_i16(int(pos[0]))) # Xpos
277
+ struct.pack_into("<h", b, put_off + 8, _clamp_i16(int(pos[-1]))) # Zpos
278
+ if len(pos) == 3:
279
+ struct.pack_into("<h", b, put_off + 6, _clamp_i16(int(pos[1]))) # Ypos (height)
280
+ if "y" in e:
281
+ struct.pack_into("<h", b, put_off + 6, _clamp_i16(int(e["y"])))
282
+ if "rot" in e:
283
+ struct.pack_into("<h", b, put_off + 10, _clamp_i16(int(e["rot"])))
284
+
285
+
286
+ def _encode_flags(value, slot) -> int:
287
+ """A ``[[scene.enemy]] flags`` value -> a u16. Accepts a list of NAMES (``_MON_FLAG_NAMES``, OR'd), a single
288
+ name, or a raw int (passes the unnamed high bits through to the enemy's AI)."""
289
+ if isinstance(value, bool):
290
+ raise SceneEditError(f"slot {slot} flags can't be a boolean")
291
+ if isinstance(value, int):
292
+ if not 0 <= value <= 0xFFFF:
293
+ raise SceneEditError(f"slot {slot} flags {value} out of range (0-65535)")
294
+ return value
295
+ names = [value] if isinstance(value, str) else value
296
+ if not isinstance(names, list):
297
+ raise SceneEditError(f"slot {slot} flags must be a name / list of names {sorted(_MON_FLAG_NAMES)} "
298
+ f"or a raw int")
299
+ if names and all(isinstance(n, int) and not isinstance(n, bool) for n in names):
300
+ return _or_raw_bits(names, lambda n: f"slot {slot} flags {n} out of range (0-65535)") # the [9] round-trip of `flags = 9`
301
+ bits = 0
302
+ for nm in names:
303
+ key = str(nm).strip().lower()
304
+ if key not in _MON_FLAG_NAMES:
305
+ raise SceneEditError(f"slot {slot} unknown enemy flag {nm!r}; known: {sorted(_MON_FLAG_NAMES)} "
306
+ f"(or pass a raw int)")
307
+ bits |= _MON_FLAG_NAMES[key]
308
+ return bits
309
+
310
+
311
+ def _or_raw_bits(ints, err) -> int:
312
+ """OR a list of raw u16 ints into one flag word (the GUI round-trip of a raw-int `flags = N`: a STRLIST
313
+ re-parses `N` to the one-int list `[N]`, which must mean the same raw word, not a flag NAMED `N`)."""
314
+ word = 0
315
+ for n in ints:
316
+ if not 0 <= n <= 0xFFFF:
317
+ raise SceneEditError(err(n))
318
+ word |= n
319
+ return word
320
+
321
+
322
+ def _encode_scene_flags(value, current: int) -> int:
323
+ """A ``[scene] flags`` value -> the new header Flags u16. A list of NAMES (``_SCENE_FLAG_NAMES``) CLEARS the
324
+ 4 known bits in ``current`` then ORs the named ones (preserving any other header bit); a raw int REPLACES the
325
+ whole word. The encounter RULES: preemptive / back_attack / no_exp / no_escape (can't-flee)."""
326
+ if isinstance(value, bool):
327
+ raise SceneEditError("[scene] flags can't be a boolean (use a name list or a raw int)")
328
+ if isinstance(value, int):
329
+ if not 0 <= value <= 0xFFFF:
330
+ raise SceneEditError(f"[scene] flags {value} out of range (0-65535)")
331
+ return value
332
+ names = [value] if isinstance(value, str) else value
333
+ if not isinstance(names, list):
334
+ raise SceneEditError(f"[scene] flags must be a name / list of names {sorted(_SCENE_FLAG_NAMES)} or a raw int")
335
+ if names and all(isinstance(n, int) and not isinstance(n, bool) for n in names):
336
+ return _or_raw_bits(names, lambda n: f"[scene] flags {n} out of range (0-65535)") # the [9] round-trip of `flags = 9`
337
+ bits = current & ~_SCENE_FLAG_MASK
338
+ for nm in names:
339
+ key = str(nm).strip().lower()
340
+ if key not in _SCENE_FLAG_NAMES:
341
+ raise SceneEditError(f"[scene] unknown flag {nm!r}; known: {sorted(_SCENE_FLAG_NAMES)} (or a raw int)")
342
+ bits |= _SCENE_FLAG_NAMES[key]
343
+ return bits
344
+
345
+
346
+ def _clamp_i16(v: int) -> int:
347
+ return max(-32768, min(32767, v))
348
+
349
+
350
+ def _resolve_items(value, slot: int, key: str) -> list[int]:
351
+ if not isinstance(value, list) or len(value) != 4:
352
+ raise SceneEditError(f"slot {slot} {key} must be a list of exactly 4 items "
353
+ f"(name/id; use \"none\" or 255 for an empty slot)")
354
+ out = []
355
+ for it in value:
356
+ if isinstance(it, str) and it.strip().lower() in ("none", "", "-"):
357
+ out.append(255)
358
+ else:
359
+ out.append(items.resolve(it))
360
+ return out
361
+
362
+
363
+ def validate_scene(raw16: bytes, scene: dict) -> list[str]:
364
+ """Offline problems (empty => OK). Re-runs the edit on a copy to surface any error as a message."""
365
+ try:
366
+ _, warnings = apply_scene_edits(raw16, scene)
367
+ return []
368
+ except (SceneEditError, ValueError) as e:
369
+ return [str(e)]
@@ -0,0 +1,125 @@
1
+ """Offline BALANCE lint for a battle scene -- the "I can't see the game" superpower for battle tuning.
2
+
3
+ Reads a parsed :class:`~ff9mapkit.battle.scene_codec.Scene` (every enemy's stats / affinities / rewards) and
4
+ flags design problems an actual playthrough would otherwise reveal. The bar is TRUST: it must be quiet on
5
+ well-designed vanilla fights and loud only on real problems -- so every check here was validated against a
6
+ sweep of all ~562 shipped scenes to confirm it does not cry wolf (a noisy lint trains the user to ignore it).
7
+
8
+ Checks:
9
+ * no_reward (warn) -- a real fight that yields NOTHING (0 EXP / gil / AP / no drops or steals).
10
+ * bad_item (warn) -- a drop/steal references an item id that isn't a real item.
11
+ * status_immune (info)-- the enemy resists EVERY common offensive status -> status abilities are dead choices.
12
+ * element_wall (info) -- the enemy resists/absorbs/halves >=7/8 elements -> almost no element does full damage.
13
+ * phys_wall / mag_wall (info) -- a defence in the weapon-power band (>=50; real enemies cap ~24, FF9 weapon
14
+ power caps ~108) -> attacks are heavily reduced (FF9 defence is SUBTRACTIVE).
15
+ * level5 (info) -- the enemy's Level is a multiple of 5 AND it isn't Death-immune -> LV5 Death one-shots it.
16
+
17
+ Severity: ``warn`` = a likely real problem; ``info`` = design awareness (an intentional choice may be fine).
18
+
19
+ NOT done here (deferred -- the kit has no live party model): a turns-to-kill / time-to-kill-a-PC estimator and
20
+ an economy-curve-vs-zone check. FF9 physical damage is `Attack(~Strength) * max(1, weaponPower - defence)` with
21
+ 3-4 attackers/round, so a single-attacker turns estimate is off by ~1-2 orders of magnitude and flags ~half the
22
+ bestiary as a "sponge" -- it carries no signal without a real party model, so it is intentionally omitted.
23
+
24
+ Pure + offline; reads only the parsed scene (the offensive-status mask is built at import from committed tables).
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass
29
+
30
+ from .. import items
31
+ from . import battlecsv
32
+
33
+ # The common statuses a PLAYER tries to inflict -- if an enemy resists ALL of these, status-disabling
34
+ # abilities are dead choices against it. (Buffs like Haste/Protect and odd ones are excluded on purpose.)
35
+ _OFFENSIVE_STATUSES = ["Petrify", "Venom", "Silence", "Blind", "Death", "Confuse", "Berserk", "Stop",
36
+ "Poison", "Sleep", "Slow", "Mini"]
37
+ _OFFENSIVE_MASK = battlecsv.encode_status(_OFFENSIVE_STATUSES)
38
+ _DEATH_MASK = battlecsv.encode_status(["Death"])
39
+
40
+ _N_ELEMENTS = 8
41
+ # A subtractive-defence "wall": FF9 weapon power runs ~40-108 (Excalibur II caps at 108) and real shipped
42
+ # enemies cap at phys_def ~24, so a defence at/above this band is an AUTHORED wall that floors normal attacks.
43
+ _WALL_DEF = 50
44
+
45
+
46
+ @dataclass
47
+ class Finding:
48
+ severity: str # "warn" | "info"
49
+ code: str # a short stable id (e.g. "status_immune")
50
+ message: str
51
+
52
+ def __str__(self) -> str:
53
+ return f"[{self.severity}] {self.message}"
54
+
55
+
56
+ def lint_scene(scene) -> list[Finding]:
57
+ """Return balance :class:`Finding`s for a parsed Scene (empty => nothing flagged)."""
58
+ out: list[Finding] = []
59
+ combat = [m for m in scene.monsters if m.hp > 1] # hp<=1 => a placeholder / multipart / non-fightable type
60
+
61
+ # --- scene-level: does the whole fight reward anything? ---
62
+ if combat:
63
+ total_exp = sum(m.exp for m in combat)
64
+ total_gil = sum(m.gil for m in combat)
65
+ max_ap = max((p.ap for p in scene.patterns), default=0)
66
+ has_item = any(i != 255 for m in combat for i in (*m.drop, *m.steal))
67
+ if total_exp == 0 and total_gil == 0 and max_ap == 0 and not has_item:
68
+ out.append(Finding("warn", "no_reward",
69
+ "this battle yields NOTHING: 0 EXP, 0 gil, 0 AP, no drops/steals"))
70
+
71
+ # --- per-enemy-type ---
72
+ for t, m in enumerate(scene.monsters):
73
+ if m.hp <= 1:
74
+ continue
75
+ lbl = f"enemy type {t} (Lv {m.level}, HP {m.hp})"
76
+
77
+ # counter-play: an enemy that resists/absorbs/halves nearly every element (almost no element exploits it)
78
+ resisted = set(battlecsv.decode_elements(m.guard_element)) \
79
+ | set(battlecsv.decode_elements(m.absorb_element)) | set(battlecsv.decode_elements(m.half_element))
80
+ if len(resisted) >= _N_ELEMENTS - 1:
81
+ out.append(Finding("info", "element_wall",
82
+ f"{lbl}: resists/absorbs/halves {len(resisted)}/{_N_ELEMENTS} elements -- almost "
83
+ f"no element does full damage"))
84
+
85
+ # counter-play: immune to every offensive status -> status abilities are dead choices
86
+ if (m.resist_status & _OFFENSIVE_MASK) == _OFFENSIVE_MASK:
87
+ out.append(Finding("info", "status_immune",
88
+ f"{lbl}: immune to every common offensive status -- status abilities are dead "
89
+ f"choices here"))
90
+
91
+ # LV5 Death exploit -- only when it actually lands (the level is a multiple of 5 AND Death isn't resisted)
92
+ if m.level > 0 and m.level % 5 == 0 and not ((m.resist_status | m.auto_status) & _DEATH_MASK):
93
+ out.append(Finding("info", "level5",
94
+ f"{lbl}: level {m.level} is a multiple of 5 and not Death-immune -- LV5 Death "
95
+ f"one-shots it"))
96
+
97
+ # rewards: an item id that isn't a real item
98
+ for kind, ids in (("drop", m.drop), ("steal", m.steal)):
99
+ for i in ids:
100
+ if i != 255 and items.name_of(i) is None:
101
+ out.append(Finding("warn", "bad_item",
102
+ f"{lbl}: {kind} references item id {i}, which is not a known item"))
103
+
104
+ # subtractive-defence walls (a defence in the weapon-power band floors normal attacks)
105
+ if m.phys_def >= _WALL_DEF:
106
+ out.append(Finding("info", "phys_wall",
107
+ f"{lbl}: phys_def {m.phys_def} -- physical attacks heavily reduced (FF9 weapon "
108
+ f"power ~40-108; subtractive defence)"))
109
+ if m.mag_def >= _WALL_DEF:
110
+ out.append(Finding("info", "mag_wall",
111
+ f"{lbl}: mag_def {m.mag_def} -- magical attacks heavily reduced"))
112
+
113
+ return out
114
+
115
+
116
+ def format_findings(findings, *, indent: str = " ") -> str:
117
+ """A human-readable block (empty findings => an OK line)."""
118
+ if not findings:
119
+ return f"{indent}lint: no balance problems flagged."
120
+ warns = [f for f in findings if f.severity == "warn"]
121
+ infos = [f for f in findings if f.severity == "info"]
122
+ lines = [f"{indent}lint: {len(warns)} warning(s), {len(infos)} note(s)"]
123
+ for f in warns + infos:
124
+ lines.append(f"{indent} {f}")
125
+ return "\n".join(lines)
@@ -0,0 +1,131 @@
1
+ """The raw17 sequence ASSEMBLER -- the inverse of :mod:`seqdis` (the analog of ``eb/cmdasm`` for enemy AI). Turns
2
+ a textual sequence source into the instruction bytes the engine interprets, so a NET-NEW attack choreography can
3
+ be authored from scratch (then spliced by :mod:`seqauthor` with the length-changing :func:`seqcodec.serialize_repacked`
4
+ repack). A sequence has NO control flow (no jumps/labels) -- it is a flat list of ``Name(field=value, ...)``
5
+ instructions ending in a terminator -- so the assembler is a direct line-by-line transcription, with each operand
6
+ range-checked against its field width/signedness.
7
+
8
+ Round-trip INVARIANT (proven over the 562-corpus): ``assemble(to_source(instrs)) == instrs`` and
9
+ ``to_source(parse_instr(b))`` re-assembles to the original bytes -- the assembler and the codec decoder are exact
10
+ mutual inverses. Only the open-source opcode NAMES are committed.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re as _re
15
+
16
+ from . import seqcodec as _sc
17
+
18
+
19
+ class SeqAsmError(ValueError):
20
+ pass
21
+
22
+
23
+ _NAME_TO_OP = {nm: op for op, (nm, _f) in _sc._OPS.items()}
24
+ _LINE_RE = _re.compile(r"^\s*(?:\[\d+\]\s*)?([A-Za-z_]\w*)\s*(?:\(([^()]*)\))?\s*$") # tolerates a [offset] prefix
25
+
26
+
27
+ def _parse_int(tok: str) -> int:
28
+ tok = tok.strip()
29
+ try:
30
+ return int(tok, 0) # accepts 10, 0x0a, -5
31
+ except ValueError:
32
+ raise SeqAsmError(f"operand value {tok!r} is not an integer")
33
+
34
+
35
+ def _field_range(width: int, signed: bool):
36
+ return (-(1 << (8 * width - 1)), (1 << (8 * width - 1)) - 1) if signed else (0, (1 << (8 * width)) - 1)
37
+
38
+
39
+ def assemble_instr_text(line: str) -> _sc.Instr:
40
+ """One ``Name(field=value, ...)`` line -> an :class:`seqcodec.Instr` (offset 0). A leading ``[offset]`` and a
41
+ trailing ``# comment`` are tolerated (so a disassembly line pastes back in). Every real operand must be given
42
+ (the 0x19 ``Sfx`` ``_pad`` hole defaults to 0); each is range-checked against its field."""
43
+ line = line.split("#", 1)[0].strip()
44
+ m = _LINE_RE.match(line)
45
+ if not m:
46
+ raise SeqAsmError(f"cannot parse instruction {line!r} (expected `Name(field=value, ...)`)")
47
+ name, argstr = m.group(1), (m.group(2) or "").strip()
48
+ op = _NAME_TO_OP.get(name)
49
+ if op is None:
50
+ raise SeqAsmError(f"unknown opcode name {name!r} (see `battle-seq` / the seqcodec opcode table)")
51
+ fields = _sc._OPS[op][1]
52
+ provided: dict = {}
53
+ if argstr:
54
+ for part in argstr.split(","):
55
+ if "=" not in part:
56
+ raise SeqAsmError(f"{name}: operand {part.strip()!r} must be `field=value`")
57
+ k, v = part.split("=", 1)
58
+ key = k.strip()
59
+ if key in provided:
60
+ raise SeqAsmError(f"{name}: operand {key!r} given more than once")
61
+ provided[key] = _parse_int(v)
62
+ fieldnames = {fn for fn, _o, _w, _s, _k in fields}
63
+ unknown = set(provided) - fieldnames
64
+ if unknown:
65
+ raise SeqAsmError(f"{name}: unknown operand(s) {sorted(unknown)} (fields: {sorted(fieldnames) or 'none'})")
66
+ operands = []
67
+ for fn, _o, w, signed, _k in fields:
68
+ if fn in provided:
69
+ val = provided[fn]
70
+ elif fn == "_pad":
71
+ val = 0 # the discarded Sfx hole -- default 0 (engine ignores it)
72
+ else:
73
+ raise SeqAsmError(f"{name}: missing operand {fn!r}")
74
+ lo, hi = _field_range(w, signed)
75
+ if not lo <= val <= hi:
76
+ raise SeqAsmError(f"{name}.{fn} = {val} is out of range [{lo}, {hi}] "
77
+ f"({w}-byte {'signed' if signed else 'unsigned'})")
78
+ operands.append(val)
79
+ return _sc.Instr(op, 0, operands)
80
+
81
+
82
+ def assemble(source: str) -> list:
83
+ """A multi-line / ``;``-separated sequence source -> ``[Instr]``. Must end in a terminator (End/FastEnd) and
84
+ contain NO terminator before the end (an unreachable tail is a likely authoring error). Blank lines + ``#``
85
+ comments are ignored. The byte form is ``b"".join(seqcodec.emit_instr(i) for i in assemble(src))``."""
86
+ raw_lines = [ln for chunk in source.replace(";", "\n").splitlines() for ln in [chunk.strip()] if ln]
87
+ instrs = []
88
+ for ln in raw_lines:
89
+ if ln.split("#", 1)[0].strip(): # skip pure-comment / blank lines
90
+ instrs.append(assemble_instr_text(ln))
91
+ if not instrs:
92
+ raise SeqAsmError("empty sequence source (need at least a terminator)")
93
+ if instrs[-1].op not in _sc.TERMINATORS:
94
+ raise SeqAsmError(f"a sequence must end in a terminator (End or FastEnd); got {instrs[-1].name}")
95
+ for i, ins in enumerate(instrs[:-1]):
96
+ if ins.op in _sc.TERMINATORS:
97
+ raise SeqAsmError(f"terminator {ins.name} at instruction {i} is not last -- the tail is unreachable")
98
+ out = assemble_bytes(instrs) # SELF-VERIFY: the bytes re-decode to exactly these instrs
99
+ redec, _end = _sc._decode_body(out, 0, len(out))
100
+ if [(i.op, tuple(i.operands)) for i in redec] != [(i.op, tuple(i.operands)) for i in instrs]:
101
+ raise SeqAsmError("internal: assembled bytes did not re-decode to the source instructions")
102
+ return instrs
103
+
104
+
105
+ def assemble_fragment(source: str) -> list:
106
+ """A sequence FRAGMENT (for a mid-body splice via :func:`seqauthor.insert_sequence`) -> ``[Instr]``. Like
107
+ :func:`assemble` but with NO terminator: a fragment is inserted INTO a body, so it must NOT contain End/FastEnd
108
+ (which would truncate the body early)."""
109
+ raw_lines = [ln for chunk in source.replace(";", "\n").splitlines() for ln in [chunk.strip()] if ln]
110
+ instrs = [assemble_instr_text(ln) for ln in raw_lines if ln.split("#", 1)[0].strip()]
111
+ if not instrs:
112
+ raise SeqAsmError("empty fragment source")
113
+ for ins in instrs:
114
+ if ins.op in _sc.TERMINATORS:
115
+ raise SeqAsmError(f"a fragment must not contain a terminator ({ins.name}) -- it is spliced mid-body")
116
+ return instrs
117
+
118
+
119
+ def assemble_bytes(instrs) -> bytes:
120
+ return b"".join(_sc.emit_instr(i) for i in instrs)
121
+
122
+
123
+ def to_source(instrs, *, multiline: bool = True) -> str:
124
+ """The canonical (round-trippable) source for an instruction list -- includes EVERY operand (incl. the Sfx
125
+ ``_pad``), so ``assemble(to_source(x)) == x`` byte-for-byte. (The human ``seqdis`` view hides ``_pad`` + adds
126
+ notes; this is the machine form.)"""
127
+ lines = []
128
+ for ins in instrs:
129
+ args = ", ".join(f"{fn}={val}" for (fn, _o, _w, _s, _k), val in zip(ins.fields, ins.operands))
130
+ lines.append(f"{ins.name}({args})" if args else ins.name)
131
+ return "\n".join(lines) if multiline else "; ".join(lines)