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
ff9mapkit/flags.py ADDED
@@ -0,0 +1,693 @@
1
+ """FF9 story-flag registry + save inspector (the NAME / VIEW / UNDERSTAND layer).
2
+
3
+ FF9 keeps all save-persistent story state in one place: ``EventState.gEventGlobal``, a 2048-byte array
4
+ (the engine's ``VariableSource.Global`` space, Base64'd into the save JSON under key ``"gEventGlobal"``,
5
+ ``JsonParser.cs:522,579``). This module is the kit's canonical map of that heap -- grounded in the
6
+ Memoria source + a 676-field census (see ``research/STORY_FLAGS.md``). It does three things:
7
+
8
+ 1. **NAME** -- a registry of FF9's known named vars / reserved regions / scenario milestones, plus
9
+ author-side name resolution so a ``field.toml`` can gate on a *named* flag instead of a raw index
10
+ (a ``[[flag]]`` table: ``[[flag]] name = "switch_pulled" index = 8520``).
11
+ 2. **CREATE-safely** -- the provably-safe allocation band (``FIRST_SAFE_FLAG`` = 8512, the first bit
12
+ clear of ALL real-FF9 usage; the chest band 8376-8511 + the choice scratch are reserved). These
13
+ constants are the single source of truth (``campaign.py`` imports them).
14
+ 3. **VIEW / UNDERSTAND** -- decode a save's ``gEventGlobal`` blob into a human report (ScenarioCounter
15
+ + nearest story beat, FieldEntrance, treasure-hunter points, opened-chest count, set story bits
16
+ annotated by region).
17
+
18
+ Addressing reminder (engine ``EBin.GetVariableValueInternal``): a **Bit** index N -> byte ``N>>3`` bit
19
+ ``N&7``; a **Byte/Int16/UInt16** index is a raw byte offset. So "bit 184" = byte 23, but "byte 184" is a
20
+ different location -- the registry keeps the two kinds apart.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import difflib
26
+ import json
27
+ import struct
28
+ from dataclasses import dataclass, field
29
+
30
+ # --- the provably-safe story-flag allocation band (single source of truth; campaign.py imports these) ---
31
+ # Real FF9 uses save-persistent bit-flags up to bit 8511 (the treasure-chest "opened" bitfield, bits
32
+ # 8376-8511). The choice-visibility scratch sits at byte 2040 = bits 16320+. So custom story flags MUST
33
+ # live in [8512, 16320). 8512 (start of byte 1064) is the first bit clear of ALL real-FF9 usage.
34
+ FIRST_SAFE_FLAG = 8512
35
+ CHEST_FLAG_LO, CHEST_FLAG_HI = 8376, 8511 # real-FF9 treasure-chest "opened" bitfield
36
+ CHOICE_SCRATCH_FLOOR = 16320 # byte 2040: engine/kit-owned choice mask scratch
37
+
38
+
39
+ # ============================ the registry ============================
40
+ @dataclass(frozen=True)
41
+ class WordVar:
42
+ """A named multi-byte var at a fixed BYTE offset (ScenarioCounter, FieldEntrance, ...)."""
43
+ name: str
44
+ byte: int # starting byte offset
45
+ width: int # bytes (1, 2)
46
+ signed: bool
47
+ meaning: str
48
+ tier: str # a=engine-grounded, b=empirical, c=uncertain
49
+ source: str
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class BitRegion:
54
+ """A named/reserved range of BIT indices (worldmap unlocks, chest block, byte-23 handshake, ...)."""
55
+ name: str
56
+ lo: int # inclusive bit index
57
+ hi: int # inclusive bit index
58
+ meaning: str
59
+ reserved: bool # a mod must NOT allocate here
60
+ tier: str
61
+ source: str
62
+
63
+
64
+ # Named word vars (byte-addressed). Order: low offsets first. Each is a save-persistent byte/word the
65
+ # engine C# reads at a FIXED index (so the meaning IS the engine's own var name -- tier a). Found by
66
+ # scanning every `gEventGlobal[<const>]` read in the Memoria source (the engine-reader pass).
67
+ NAMED_WORDS = [
68
+ WordVar("ScenarioCounter", 0, 2, False, "Master story-progress value (1..12000).", "a",
69
+ "EventState.cs:16-24; EBin.cs:34"),
70
+ WordVar("FieldEntrance", 2, 2, True, "Last entrance / arrival map index (read by every field).", "a",
71
+ "EventState.cs:26-34; EBin.cs:35"),
72
+ WordVar("TranceGaugeFlag", 16, 1, False, "Trance gauge enable (0/1); also gates the Trance status UI.", "a",
73
+ "battle.cs:38; StatusUI.cs:291"),
74
+ WordVar("GarnetDepressFlag", 17, 1, False, "Garnet summon-depression state (summons withheld).", "a",
75
+ "battle.cs:39"),
76
+ WordVar("GarnetSummonFlag", 18, 1, False, "Garnet summon availability.", "a", "battle.cs:40"),
77
+ # Worldmap Navi known-location bitmasks (bytes 92-99 = 4 UInt16 slots F0-F3, the engine's
78
+ # keventNaviLocF0..F3). `w_naviLocationAvailable` (ff9.cs:6957-6982) reads all four as bitmasks partitioning 64
79
+ # Navi locations into 16-per-slot groups; a set bit reveals a location on the worldmap. Previously seen
80
+ # only as "write-only worldmap-unlock bits"; the engine reads them at these fixed indices as words.
81
+ WordVar("WorldmapKnownLocationsF0", 92, 2, False, "Worldmap known-locations bitmask, slot F0 / locations "
82
+ "0-15 (the engine's `knownLocations` / keventNaviLocF0); a set bit reveals a location on the Navi "
83
+ "worldmap (the engine ORs in e.g. 0x7C0 Treno/South Gates, 0xC000 Dali).", "a",
84
+ "ff9.cs:2315-2317,6927-6935,6960-6982"),
85
+ WordVar("WorldmapKnownLocationsF1", 94, 2, False, "Worldmap known-locations bitmask, slot F1 / locations "
86
+ "16-31 (keventNaviLocF1).", "a", "ff9.cs:2320-2323,6960-6982"),
87
+ WordVar("WorldmapKnownLocationsF2", 96, 2, False, "Worldmap known-locations bitmask, slot F2 / locations "
88
+ "32-47 (keventNaviLocF2).", "a", "ff9.cs:2325-2328,6960-6982"),
89
+ WordVar("WorldmapKnownLocationsF3", 98, 2, False, "Worldmap known-locations bitmask, slot F3 / locations "
90
+ "48-63 (keventNaviLocF3).", "a", "ff9.cs:2330-2333,6960-6982"),
91
+ WordVar("NaviMode", 100, 1, False, "Worldmap Navi/cursor navigation mode.", "a", "ff9.cs:2266-2271"),
92
+ WordVar("WorldmapTransport", 102, 1, False, "Worldmap transport id (0=on foot, 8=Invincible, ...).", "a",
93
+ "WorldConfiguration.cs:256"),
94
+ WordVar("VegetableItemUsed", 181, 1, False, "Dead Pepper / vegetable item used flag (gates re-use).", "a",
95
+ "ItemUI.cs:47,960"),
96
+ WordVar("MoveControl", 190, 1, True, "Current field/worldmap move-control (transport) index.", "a",
97
+ "ff9.cs:5793"),
98
+ WordVar("ChocoDigLevel", 191, 1, False, "Choco's dig ability level (set to 5 at milestones); also the "
99
+ "chocobo-kind gate for the vegetable item.", "a", "ChocographUI.cs:245; EMinigame.cs:454; ItemUI.cs:48"),
100
+ WordVar("TonberiCount", 192, 1, False, "Tonberry encounter/kill counter (battle).", "a", "battle.cs:41"),
101
+ WordVar("SummonRayFlag", 193, 1, False, "Summon 'ray' animation flag (battle).", "a", "battle.cs:42"),
102
+ WordVar("SummonAllLongFlag", 207, 1, False, "Show full-length summon animations toggle (battle).", "a",
103
+ "battle.cs:43"),
104
+ WordVar("MagicDisabledFlag", 227, 1, False, "Nonzero disables magic in the menu (e.g. Oeilvert's "
105
+ "anti-magic field; set by Oeilvert fields).", "a", "AbilityUI.cs:28,881"),
106
+ ]
107
+
108
+ # Reserved / named BIT regions (bit-addressed). A mod must not allocate into a reserved region.
109
+ # Specific named bits are listed BEFORE the broad band they sit inside, so bit_region() resolves the
110
+ # precise name first (e.g. bit 815 -> "mognet_central_discovered", not the broad "worldmap_unlocks").
111
+ BIT_REGIONS = [
112
+ BitRegion("field_menu_guard", 184, 184, "Engine handshake: 'in-field menu/transition in progress'. "
113
+ "Re-checked + cleared every Main_Init.", True, "a", "disassembly fields 50/100/300"),
114
+ BitRegion("boot_scratch", 191, 191, "Companion scratch bit zeroed on every boot.", True, "a",
115
+ "disassembly"),
116
+ BitRegion("chocobo_paradise_discovered", 814, 814, "Chocobo's Paradise discovered (byte 101 & 0x40); "
117
+ "gates its world-map alternate form.", True, "a", "WorldConfiguration.cs:183-184"),
118
+ BitRegion("mognet_central_discovered", 815, 815, "Mognet Central discovered (byte 101 & 0x80); gates its "
119
+ "world-map alternate form. The only engine-grounded Mognet bit in gEventGlobal.", True, "a",
120
+ "WorldConfiguration.cs:183-184"),
121
+ BitRegion("worldmap_unlocks", 736, 823, "Worldmap/Navi cursor + location-unlock/first-visit bits "
122
+ "(consumed by engine C#; mostly write-only on the field side).", True, "a/b",
123
+ "ff9.cs:2259-2333; census"),
124
+ BitRegion("chest_opened", CHEST_FLAG_LO, CHEST_FLAG_HI, "Treasure-chest field-script registry: a "
125
+ "byte-identical 130-entry dispatch block (WindowSync + set/gate a literal chest bit, branch) "
126
+ "emitted verbatim into ~48 chest-bearing fields (Ice Cavern, Burmecia Vault, Dali Storage, "
127
+ "Cleyra, Palace, ...) -- so the census sees all 48 as writers of every bit. The STOCK ENGINE "
128
+ "does NOT read this region: the Treasure-Hunter rank is scored from a SEPARATE region (bytes "
129
+ "182-186 + 896-975, see TH_POINT_RANGES). Reserved because real field logic gates/sets it; "
130
+ "NEVER allocate here.", True, "b",
131
+ "census (byte-identical block in ~48 chest fields; verified from .eb bytes -- engine does NOT score this band)"),
132
+ BitRegion("choice_scratch", CHOICE_SCRATCH_FLOOR, CHOICE_SCRATCH_FLOOR + 15,
133
+ "Choice-visibility mask scratch (kit MASK_SCRATCH_IDX); engine/kit-owned.", True, "a", "region.py:57"),
134
+ ]
135
+
136
+ # Informational (NON-reserved) named story-flag clusters from the 676-field census: contiguous bit bands
137
+ # named by their dominant writer area, for ANNOTATING a decoded save's set bits (not for allocation -- they
138
+ # sit below FIRST_SAFE_FLAG anyway). These are "where these flags are written from", not a proven per-bit
139
+ # meaning. Derived + verified by the ff9-understand-layer workflow (research/gen_understand_layer.py).
140
+ STORY_REGIONS = [
141
+ BitRegion("hilda_garde_invincible_events", 196, 199, "Late-game airship/event flags "
142
+ "(Lindblum Castle / Hilda Garde 3 / Invincible).", False, "c", "census"),
143
+ BitRegion("chocobo_dig_state", 848, 853, "Chocobo Hot & Cold / Chocograph minigame state.", False, "b",
144
+ "census; EMinigame.cs"),
145
+ BitRegion("chocobo_forest_state", 888, 895, "Chocobo Hot & Cold dig-spot / chocograph-found bits.", False,
146
+ "b", "census; EMinigame.cs"),
147
+ BitRegion("chocograph_found_opened", 1040, 1087, "Chocograph 'found'/'opened' treasure bitfields "
148
+ "(choco-dig minigame).", False, "b", "census; ChocographUI.cs"),
149
+ BitRegion("chocobo_garden_state", 1156, 1159, "Chocobo Hot & Cold dig-progress flags.", False, "c", "census"),
150
+ BitRegion("chocobo_air_garden_state", 1416, 1423, "Chocobo Hot & Cold / Air Garden unlock state "
151
+ "(top of the choco-dig band, bytes 106-177).", False, "c", "census"),
152
+ # (byte 227 / bit 1816 was a census "Oeilvert event" cluster -> it's the MagicDisabledFlag word above,
153
+ # set by Oeilvert's anti-magic field. Named there, so no separate bit region.)
154
+ BitRegion("dali_madain_iifa_events", 2048, 2128, "Early-mid story band (Dali / Madain Sari / Iifa Tree).",
155
+ False, "b", "census"),
156
+ BitRegion("prima_vista_evil_forest_events", 2418, 2495, "Prologue band (Prima Vista / Evil Forest / North "
157
+ "Gate). NB: corrects the report's 'Lindblum festival @ 304-335' -- those bits are the prologue; "
158
+ "the Hunt-Festival score is the separate UInt16 words at bytes 314/316.", False, "b", "census"),
159
+ BitRegion("lindblum_events", 2592, 2663, "The true Lindblum cluster (25 Lindblum fields; town/festival "
160
+ "event flags).", False, "b", "census"),
161
+ BitRegion("disc2_3_dungeon_events", 2817, 2983, "Disc-2/3 dungeon/town band (Treno / Conde Petie / Bran "
162
+ "Bal / Black Mage Village).", False, "b", "census"),
163
+ BitRegion("outer_continent_events", 3228, 3263, "Outer-Continent traversal (Mount Gulug / Fossil Roo / "
164
+ "Qu's Marsh).", False, "b", "census"),
165
+ BitRegion("ipsen_ice_cavern_events", 3457, 3471, "Mixed: Ipsen's Castle + Ice Cavern (name with caution).",
166
+ False, "c", "census"),
167
+ BitRegion("desert_palace_lindblum_events", 3536, 3671, "Disc-3 Kuja-stronghold + Hilda-search flags "
168
+ "(Desert Palace / Lindblum Castle).", False, "b", "census"),
169
+ BitRegion("alexandria_events", 3712, 3718, "Alexandria-town event flags (clean single-area cluster).",
170
+ False, "b", "census"),
171
+ BitRegion("cleyra_alexandria_gizamaluke_events", 3784, 3905, "Disc-2 Burmecia-war / Cleyra-assault arc "
172
+ "(Cleyra / Alexandria / Gizamaluke's Grotto).", False, "b", "census"),
173
+ BitRegion("alexandria_castle_events", 3948, 3967, "Alexandria Castle interior event flags.", False, "c",
174
+ "census"),
175
+ BitRegion("mognet_central_state", 4046, 4047, "Mognet (moogle-mail) sidequest progress -- written only by "
176
+ "Mognet Central (field 3100). Dominant-writer inference; exact per-bit meaning empirical.", False,
177
+ "c", "census"),
178
+ ]
179
+
180
+ # UNDERSTAND note (ff9-understand-layer workflow, engine-verified): ATE ("Active Time Event") seen-state is
181
+ # NOT in this 2048-byte heap -- it lives in AchievementState.AteCheck (Int32[100], save key "AteCheckArray").
182
+ # ATE selection is a per-field .eb script branch keyed on (fldLocNo, fldMapNo, ScenarioCounter, chosen choice)
183
+ # via the hardcoded EMinigame.MappingATEID switch. So there is NO gEventGlobal "ATE flag index" to name.
184
+ ATE_STATE_LOCATION = "AchievementState.AteCheck (Int32[100], save key 'AteCheckArray') -- not gEventGlobal"
185
+
186
+ # Treasure-Hunter scoring byte ranges (EventState.GetTreasureHunterPoints): (byte_lo, byte_hi, weight).
187
+ TH_POINT_RANGES = [(896, 960, 1), (966, 975, 1), (182, 186, 2)]
188
+
189
+ # ScenarioCounter -> story AREA progression: the value where the game enters each area, derived from a
190
+ # field-granular census x field-manifest join (research/gen_understand_layer.py: each value -> its setter
191
+ # field -> that field's manifest room) and curated/verified by the ff9-understand-layer workflow (3
192
+ # adversarial lenses + research). Use nearest_milestone(sc) for "what story beat is this". In-game-validated
193
+ # (SC 7200 -> Alexandria Castle). This 52-anchor table supersedes the earlier 43-anchor zone-coded one, which
194
+ # mislabelled several beats (5900 was "Iifa Tree" -> really Fossil Roo; 9990 "Outer Continent" -> Mount Gulug;
195
+ # 9400 "Hilda Garde" -> Blue Narciss; 11610 "Crystal World" -> Memoria) and lost real beats (Burmecia, Oeilvert,
196
+ # the second shrine, Pandemonium, Memoria).
197
+ SCENARIO_MILESTONES = {
198
+ 1000: "Prima Vista", 2020: "Evil Forest", 2300: "Evil Forest", 2500: "Ice Cavern",
199
+ 2600: "Dali", 2700: "Dali (underground)", 2800: "Observatory Mountain", 2910: "Cargo Ship",
200
+ 3000: "Lindblum Castle", 3100: "Lindblum", 3710: "Gizamaluke's Grotto", 3750: "South Gate",
201
+ 3800: "Burmecia", 4445: "Treno", 4500: "Gargan Roo", 4600: "Alexandria Castle",
202
+ 4650: "Cleyra", 4990: "Red Rose", 5030: "Alexandria Castle", 5510: "Pinnacle Rocks",
203
+ 5660: "Lindblum", 5900: "Fossil Roo", 6100: "Conde Petie", 6300: "Conde Petie Mountain Path",
204
+ 6600: "Madain Sari", 6700: "Iifa Tree", 6800: "Madain Sari", 6900: "Iifa Tree",
205
+ 7010: "Alexandria", 7200: "Alexandria Castle", 7550: "Treno", 8000: "Alexandria",
206
+ 8400: "Alexandria Castle", 9000: "Lindblum", 9400: "Blue Narciss", 9510: "Desert Palace",
207
+ 9605: "Oeilvert", 9800: "Desert Palace", 9990: "Mount Gulug", 10000: "Lindblum Castle",
208
+ 10400: "Alexandria Castle", 10500: "Ipsen's Castle", 10600: "Hilda Garde 3", 10620: "Water Shrine",
209
+ 10670: "Earth Shrine", 10830: "Terra", 10900: "Bran Bal", 10930: "Pandemonium",
210
+ 11100: "Invincible", 11610: "Memoria", 11765: "Crystal World", 12000: "Crystal World (ending)",
211
+ }
212
+ # IsEikoAbducted (EventState.cs:36): 9860 <= ScenarioCounter < 9990.
213
+ EIKO_ABDUCTED_LO, EIKO_ABDUCTED_HI = 9860, 9989
214
+
215
+
216
+ def bit_to_byte(bit: int) -> tuple:
217
+ """Bit index -> (byte, bit-within-byte). Engine: byte = bit>>3, bit = bit&7."""
218
+ return (bit >> 3, bit & 7)
219
+
220
+
221
+ def bit_region(bit: int):
222
+ """The :class:`BitRegion` a bit falls in, or None (unmapped = free/custom space). Reserved bands are
223
+ checked first, then the informational story clusters -- so a reserved verdict always wins."""
224
+ for r in BIT_REGIONS:
225
+ if r.lo <= bit <= r.hi:
226
+ return r
227
+ for r in STORY_REGIONS:
228
+ if r.lo <= bit <= r.hi:
229
+ return r
230
+ return None
231
+
232
+
233
+ def is_reserved(bit: int) -> bool:
234
+ """True if ``bit`` is in a reserved region (chest band, worldmap unlocks, byte-23 handshake, scratch)."""
235
+ r = bit_region(bit)
236
+ return bool(r and r.reserved)
237
+
238
+
239
+ def is_safe_custom(bit: int) -> bool:
240
+ """True if ``bit`` is in the provably-safe custom band [FIRST_SAFE_FLAG, CHOICE_SCRATCH_FLOOR) and not
241
+ inside a reserved region."""
242
+ return FIRST_SAFE_FLAG <= bit < CHOICE_SCRATCH_FLOOR and not is_reserved(bit)
243
+
244
+
245
+ def nearest_milestone(scenario: int):
246
+ """(value, beat) of the highest milestone <= ``scenario``, or None (before the first)."""
247
+ below = [v for v in SCENARIO_MILESTONES if v <= scenario]
248
+ if not below:
249
+ return None
250
+ v = max(below)
251
+ return (v, SCENARIO_MILESTONES[v])
252
+
253
+
254
+ def resolve_scenario(token) -> int:
255
+ """A ScenarioCounter VALUE from an int / digit-string, or an area name (the lowest value whose beat
256
+ matches, case/substring-insensitive -- so 'ice' -> 2500 'Ice Cavern'). Raises on an unknown name."""
257
+ s = str(token).strip()
258
+ if s.lstrip("-").isdigit():
259
+ return int(s)
260
+ hits = sorted(v for v, beat in SCENARIO_MILESTONES.items() if s.lower() in beat.lower())
261
+ if not hits:
262
+ opts = ", ".join(sorted(set(SCENARIO_MILESTONES.values())))
263
+ raise ValueError(f"unknown scenario area {token!r}. Known areas: {opts}")
264
+ return hits[0]
265
+
266
+
267
+ # ============================ author-side name resolution ============================
268
+ # field.toml content keys whose value is a single flag INDEX (a name or an int).
269
+ _FLAG_INDEX_KEYS = ("requires_flag", "requires_flag_clear", "flag")
270
+ # keys whose value is a [index, value] pair (resolve element 0).
271
+ _FLAG_PAIR_KEYS = ("set_flag",)
272
+ # the content sections whose items (and nested options/steps) carry flag fields.
273
+ # (``chest``: its ``flag`` = the opened-bit + ``requires_flag``/``requires_flag_clear`` = the appearance gate.)
274
+ _FLAG_SECTIONS = ("event", "npc", "gateway", "prop", "choice", "cutscene", "on_entry", "chest")
275
+
276
+
277
+ def _norm(s) -> str:
278
+ return "".join(c for c in str(s).lower() if c.isalnum() or c == "_")
279
+
280
+
281
+ def collect_flag_defs(raw: dict) -> dict:
282
+ """``{normalized_name: index}`` from a project's ``[[flag]]`` table. Each entry needs a ``name`` and an
283
+ ``index``; the index is validated into the safe custom band (clear of real-FF9 usage). Raises
284
+ ValueError on a missing field, a duplicate name, or an out-of-band index."""
285
+ out = {}
286
+ for i, fdef in enumerate(raw.get("flag", []) or []):
287
+ if not isinstance(fdef, dict) or "name" not in fdef or "index" not in fdef:
288
+ raise ValueError(f"[[flag]] #{i}: needs both `name` and `index` (e.g. "
289
+ f'name = "switch_pulled", index = {FIRST_SAFE_FLAG}).')
290
+ name, idx = str(fdef["name"]), int(fdef["index"])
291
+ key = _norm(name)
292
+ if key in out:
293
+ raise ValueError(f"[[flag]] duplicate name {name!r}.")
294
+ if CHEST_FLAG_LO <= idx <= CHEST_FLAG_HI:
295
+ raise ValueError(f"[[flag]] {name!r}: index {idx} is inside real-FF9's treasure-chest band "
296
+ f"{CHEST_FLAG_LO}-{CHEST_FLAG_HI} -> save corruption; use "
297
+ f"[{FIRST_SAFE_FLAG}, {CHOICE_SCRATCH_FLOOR}).")
298
+ if not (FIRST_SAFE_FLAG <= idx < CHOICE_SCRATCH_FLOOR):
299
+ raise ValueError(f"[[flag]] {name!r}: index {idx} is outside the safe custom band "
300
+ f"[{FIRST_SAFE_FLAG}, {CHOICE_SCRATCH_FLOOR}); pick an index there.")
301
+ out[key] = idx
302
+ return out
303
+
304
+
305
+ def project_flag_names(raw: dict) -> dict:
306
+ """``{absolute_gEventGlobal_bit: display_name}`` from a project's ``[[flag]]`` table -- for ANNOTATING a
307
+ save's custom-band bits with the modder's own names in the Story State view. A named ``[[flag]] index`` is
308
+ an ABSOLUTE gEventGlobal bit: it is NEVER offset by any campaign/journey flag-window (only the nameless
309
+ auto-flags carry a deployed base), so the save stores it at exactly ``index`` in every mode -- this is a
310
+ pure identity map, no offset arithmetic. Fail-safe: a malformed table (``collect_flag_defs`` raises) ->
311
+ ``{}`` (no annotation rather than a wrong one). A duplicate index under different names -> an explicit
312
+ ambiguity sentinel (never a silent pick)."""
313
+ try:
314
+ collect_flag_defs(raw) # validate band + duplicate-name exactly as the build does
315
+ except (ValueError, TypeError):
316
+ return {}
317
+ seen: dict = {} # idx -> [names], to flag a cross-name index collision
318
+ for fdef in raw.get("flag", []) or []:
319
+ try:
320
+ idx, name = int(fdef["index"]), str(fdef["name"])
321
+ except (KeyError, TypeError, ValueError):
322
+ continue
323
+ names = seen.setdefault(idx, [])
324
+ if name not in names:
325
+ names.append(name)
326
+ return {idx: (names[0] if len(names) == 1 else "<ambiguous: " + " / ".join(names) + ">")
327
+ for idx, names in seen.items()}
328
+
329
+
330
+ def _fmt_bits(bits, names=None) -> str:
331
+ """Render a bit list (capped at 20, matching the existing summary cap) -- labelling any bit present in
332
+ ``names`` as ``bit=name``. With ``names`` empty/None the output is byte-identical to ``str(bits[:20])`` +
333
+ the ' ...' elision, so an un-annotated report is unchanged."""
334
+ names = names or {}
335
+ shown = [f"{b}={names[b]}" if b in names else str(b) for b in bits[:20]]
336
+ return "[" + ", ".join(shown) + "]" + (" ..." if len(bits) > 20 else "")
337
+
338
+
339
+ def collect_safe_flag_indices(raw: dict) -> set:
340
+ """Every SAFE-BAND gEventGlobal bit index the project references as a story flag -- ``[[flag]]`` defs,
341
+ ``[startup].flags``, and every content section's flag fields (``requires_flag``/``flag``/``set_flag``/
342
+ ``set_flags``, recursing options/steps). Assumes :func:`resolve_project_flags` already ran (references are
343
+ ints); out-of-band / non-int values are dropped. Used to RESERVE these so an auto-allocated ``[[logic_add]]``
344
+ once-guard never aliases an authored story flag (which would silently pre-fire the guard)."""
345
+ out: set = set()
346
+
347
+ def _take(v):
348
+ if isinstance(v, int) and not isinstance(v, bool) and is_safe_custom(v):
349
+ out.add(v)
350
+
351
+ try:
352
+ for idx in collect_flag_defs(raw).values():
353
+ _take(idx)
354
+ except ValueError: # a malformed [[flag]] table -> load already failed; ignore here
355
+ pass
356
+ su = raw.get("startup")
357
+ if isinstance(su, dict):
358
+ for p in su.get("flags", []) or []:
359
+ if isinstance(p, dict):
360
+ _take(p.get("flag"))
361
+
362
+ def _walk(item):
363
+ if not isinstance(item, dict):
364
+ return
365
+ for k in _FLAG_INDEX_KEYS:
366
+ _take(item.get(k))
367
+ for k in _FLAG_PAIR_KEYS:
368
+ pair = item.get(k)
369
+ if isinstance(pair, list) and pair:
370
+ _take(pair[0])
371
+ for sf in (item.get("set_flags") or []):
372
+ if isinstance(sf, dict):
373
+ _take(sf.get("flag"))
374
+ for sub in ("options", "steps"):
375
+ for it in (item.get(sub) or []):
376
+ _walk(it)
377
+ for sec in _FLAG_SECTIONS:
378
+ val = raw.get(sec)
379
+ if isinstance(val, dict):
380
+ _walk(val)
381
+ elif isinstance(val, list):
382
+ for it in val:
383
+ _walk(it)
384
+ return out
385
+
386
+
387
+ def resolve(value, name_map: dict) -> int:
388
+ """Resolve a flag reference (an int, a digit-string, or a registered name) to its index. An int /
389
+ digit-string passes through unchanged; a name is looked up case/spacing-insensitively in ``name_map``
390
+ (the project's ``[[flag]]`` defs). Raises ValueError (with near-miss hints) on an unknown name."""
391
+ if isinstance(value, bool):
392
+ raise ValueError("a flag reference cannot be a boolean")
393
+ if isinstance(value, int):
394
+ return value
395
+ s = str(value).strip()
396
+ if s.lstrip("-").isdigit():
397
+ return int(s)
398
+ key = _norm(s)
399
+ if key in name_map:
400
+ return name_map[key]
401
+ hints = difflib.get_close_matches(key, list(name_map), n=5, cutoff=0.4)
402
+ extra = (f" Did you mean: {', '.join(hints)}?" if hints
403
+ else " Define it in a [[flag]] table (name + index).")
404
+ raise ValueError(f"unknown flag name {value!r}.{extra}")
405
+
406
+
407
+ def _resolve_flag_dicts(lst, name_map: dict):
408
+ """Resolve the ``flag`` field (name -> int) in a ``[{flag = <name|index>, value = 0|1}, ...]`` list --
409
+ the shape used by a gateway's ``set_flags`` (on-exit advance) and ``[startup]``'s ``flags`` (presets).
410
+ Rewrites in place; a non-list or a dict without ``flag`` is left untouched."""
411
+ if isinstance(lst, list):
412
+ for p in lst:
413
+ if isinstance(p, dict) and "flag" in p:
414
+ p["flag"] = resolve(p["flag"], name_map)
415
+
416
+
417
+ def _resolve_item(item: dict, name_map: dict):
418
+ """Rewrite a content item's flag fields (names -> ints) in place, recursing into options/steps."""
419
+ for k in _FLAG_INDEX_KEYS:
420
+ if k in item:
421
+ item[k] = resolve(item[k], name_map)
422
+ for k in _FLAG_PAIR_KEYS:
423
+ if k in item and isinstance(item[k], list) and item[k]:
424
+ item[k] = [resolve(item[k][0], name_map)] + list(item[k][1:])
425
+ _resolve_flag_dicts(item.get("set_flags"), name_map) # gateway on-exit advance (write-side story flags)
426
+ for sub in ("options", "steps"):
427
+ if isinstance(item.get(sub), list):
428
+ for it in item[sub]:
429
+ if isinstance(it, dict):
430
+ _resolve_item(it, name_map)
431
+
432
+
433
+ def resolve_project_flags(raw: dict, extra_names: dict | None = None) -> dict:
434
+ """Rewrite all flag-name references in a project dict to integer indices, IN PLACE, using the
435
+ project's own ``[[flag]]`` table merged with ``extra_names`` (e.g. campaign-level shared flags).
436
+ Returns the merged name map. A project with no named flags is left byte-for-byte unchanged (every
437
+ numeric flag passes through), so this is a no-op for existing projects. Call once at load."""
438
+ name_map = dict(extra_names or {})
439
+ name_map.update(collect_flag_defs(raw))
440
+ for sec in _FLAG_SECTIONS:
441
+ val = raw.get(sec)
442
+ if isinstance(val, dict): # [cutscene] is a single table
443
+ _resolve_item(val, name_map)
444
+ elif isinstance(val, list): # [[event]]/[[npc]]/... are arrays of tables
445
+ for it in val:
446
+ if isinstance(it, dict):
447
+ _resolve_item(it, name_map)
448
+ su = raw.get("startup") # [startup] is a single table; its `flags` presets carry names
449
+ if isinstance(su, dict):
450
+ _resolve_flag_dicts(su.get("flags"), name_map)
451
+ return name_map
452
+
453
+
454
+ # ============================ save inspector (VIEW) ============================
455
+ @dataclass
456
+ class SaveReport:
457
+ scenario_counter: int
458
+ milestone: tuple | None # (value, beat) of the nearest milestone <= scenario, or None
459
+ eiko_abducted: bool
460
+ field_entrance: int
461
+ treasure_hunter_points: int
462
+ chests_opened: int # set bits in the chest band 8376-8511
463
+ set_bits: list = field(default_factory=list) # all set bit indices (sorted)
464
+ named_words: list = field(default_factory=list) # [(WordVar, value)] for non-zero named words
465
+
466
+
467
+ def _read_word(blob: bytes, byte: int, width: int, signed: bool) -> int:
468
+ chunk = blob[byte:byte + width]
469
+ if len(chunk) < width:
470
+ chunk = chunk + b"\x00" * (width - len(chunk))
471
+ fmt = {1: "b" if signed else "B", 2: "<h" if signed else "<H"}[width]
472
+ return struct.unpack(fmt, chunk)[0]
473
+
474
+
475
+ def _count_bits(byte_val: int) -> int:
476
+ return bin(byte_val).count("1")
477
+
478
+
479
+ def decode_gEventGlobal(blob: bytes) -> SaveReport:
480
+ """Decode a 2048-byte ``gEventGlobal`` blob into a :class:`SaveReport`. Shorter blobs are tolerated
481
+ (zero-padded); longer ones are truncated to 2048 (the engine array size)."""
482
+ if len(blob) < 2048:
483
+ blob = blob + b"\x00" * (2048 - len(blob))
484
+ blob = blob[:2048]
485
+ scenario = _read_word(blob, 0, 2, False)
486
+ th = 0
487
+ for lo, hi, weight in TH_POINT_RANGES:
488
+ for b in range(lo, hi + 1):
489
+ th += weight * _count_bits(blob[b])
490
+ chests = sum(_count_bits(blob[b]) for b in range(CHEST_FLAG_LO >> 3, (CHEST_FLAG_HI >> 3) + 1))
491
+ set_bits = [byte * 8 + bit for byte in range(2048) for bit in range(8) if blob[byte] >> bit & 1]
492
+ named = [(w, _read_word(blob, w.byte, w.width, w.signed)) for w in NAMED_WORDS
493
+ if _read_word(blob, w.byte, w.width, w.signed) != 0]
494
+ return SaveReport(
495
+ scenario_counter=scenario, milestone=nearest_milestone(scenario),
496
+ eiko_abducted=EIKO_ABDUCTED_LO <= scenario <= EIKO_ABDUCTED_HI,
497
+ field_entrance=_read_word(blob, 2, 2, True), treasure_hunter_points=th,
498
+ chests_opened=chests, set_bits=set_bits, named_words=named)
499
+
500
+
501
+ def gEventGlobal_from_save(text_or_path) -> bytes:
502
+ """Extract + Base64-decode the ``gEventGlobal`` blob from a Memoria save. Accepts: a path to a save
503
+ JSON, raw JSON text, or a bare Base64 string. (The on-disk ``EncryptedSavedData`` must be decrypted
504
+ to JSON first -- out of scope here; this reads the open JSON/Base64 form, JsonParser.cs:522.)"""
505
+ s = str(text_or_path)
506
+ raw = None
507
+ if "{" in s and '"' in s: # looks like JSON text
508
+ raw = s
509
+ else:
510
+ try:
511
+ with open(s, "r", encoding="utf-8") as fh:
512
+ raw = fh.read()
513
+ except (OSError, ValueError):
514
+ raw = None
515
+ if raw is not None and "{" in raw:
516
+ obj = json.loads(raw)
517
+ b64 = _find_key(obj, "gEventGlobal")
518
+ if b64 is None:
519
+ raise ValueError("no 'gEventGlobal' key found in the save JSON")
520
+ return base64.b64decode(b64)
521
+ # bare Base64: the FILE CONTENT if we read one (raw), else the input string itself.
522
+ return base64.b64decode((raw if raw is not None else s).strip())
523
+
524
+
525
+ def _find_key(obj, key):
526
+ """Depth-first search for ``key`` in a nested dict/list (the save JSON nests gEventGlobal under a
527
+ profile object), returning its value or None."""
528
+ if isinstance(obj, dict):
529
+ if key in obj:
530
+ return obj[key]
531
+ for v in obj.values():
532
+ r = _find_key(v, key)
533
+ if r is not None:
534
+ return r
535
+ elif isinstance(obj, list):
536
+ for v in obj:
537
+ r = _find_key(v, key)
538
+ if r is not None:
539
+ return r
540
+ return None
541
+
542
+
543
+ def _group_set_bits(set_bits):
544
+ """Group set story BITs by named region. Returns ``(by_region{name:[bits]}, custom[bits],
545
+ unmapped[bits], n_story)``. Bits inside a named WORD var's bytes (ScenarioCounter/FieldEntrance/...)
546
+ are EXCLUDED -- those are word data, not story bits. Shared by :func:`render_report` (single save)
547
+ and :func:`render_diff` (the set/cleared deltas) so both classify a bit the same way."""
548
+ word_bytes = {b for w in NAMED_WORDS for b in range(w.byte, w.byte + w.width)}
549
+ by_region: dict = {}
550
+ custom, unmapped, n_story = [], [], 0
551
+ for bit in set_bits:
552
+ if (bit >> 3) in word_bytes: # part of a named word var (ScenarioCounter/FieldEntrance/..)
553
+ continue
554
+ n_story += 1
555
+ r = bit_region(bit)
556
+ if r is not None:
557
+ by_region.setdefault(r.name, []).append(bit)
558
+ elif is_safe_custom(bit):
559
+ custom.append(bit)
560
+ else:
561
+ unmapped.append(bit)
562
+ return by_region, custom, unmapped, n_story
563
+
564
+
565
+ def render_report(rep: SaveReport, *, show_bits: bool = False, names: dict | None = None) -> str:
566
+ """A human-readable summary of a decoded save. ``names`` (an optional ``{absolute_bit: authored_name}`` map,
567
+ e.g. from :func:`project_flag_names` for the open project) labels matching custom-band bits; empty/None
568
+ leaves the output byte-identical."""
569
+ L = ["FF9 gEventGlobal (story state)", "=" * 32]
570
+ ms = f" -> {rep.milestone[1]} (>= {rep.milestone[0]})" if rep.milestone else " (before the first milestone)"
571
+ L.append(f"ScenarioCounter : {rep.scenario_counter}{ms}")
572
+ if rep.eiko_abducted:
573
+ L.append(" [IsEikoAbducted window -- Desert Palace]")
574
+ L.append(f"FieldEntrance : {rep.field_entrance}")
575
+ L.append(f"Treasure-Hunter : {rep.treasure_hunter_points} pts (chests/icons opened)")
576
+ L.append(f"Chests opened : {rep.chests_opened} (bits {CHEST_FLAG_LO}-{CHEST_FLAG_HI})")
577
+ if rep.named_words:
578
+ L.append("Named vars set :")
579
+ for w, v in rep.named_words:
580
+ L.append(f" - {w.name} = {v}")
581
+ by_region, custom, unmapped, n_story = _group_set_bits(rep.set_bits)
582
+ L.append(f"Set story bits : {n_story} "
583
+ f"(in {len(by_region)} known region(s), {len(custom)} custom, {len(unmapped)} unmapped)")
584
+ for name, bits in sorted(by_region.items()):
585
+ L.append(f" [{name}] {len(bits)} bit(s)")
586
+ if custom:
587
+ L.append(f" [custom 8512+] {len(custom)} bit(s): {_fmt_bits(custom, names)}")
588
+ if show_bits and unmapped:
589
+ L.append(f" [unmapped] {unmapped}")
590
+ return "\n".join(L)
591
+
592
+
593
+ @dataclass
594
+ class FlagDiff:
595
+ """The story-state delta between two saves (A -> B): what a story beat / play session changed."""
596
+ scenario_from: int
597
+ scenario_to: int
598
+ field_entrance_from: int
599
+ field_entrance_to: int
600
+ th_from: int
601
+ th_to: int
602
+ chests_from: int
603
+ chests_to: int
604
+ bits_set: list = field(default_factory=list) # bits TRUE in B but not A (newly set)
605
+ bits_cleared: list = field(default_factory=list) # bits TRUE in A but not B (cleared)
606
+ words_changed: list = field(default_factory=list) # [(WordVar, old, new)] (excl. Scenario/FieldEntrance)
607
+
608
+ @property
609
+ def empty(self) -> bool:
610
+ return not (self.bits_set or self.bits_cleared or self.words_changed
611
+ or self.scenario_from != self.scenario_to
612
+ or self.field_entrance_from != self.field_entrance_to
613
+ or self.th_from != self.th_to or self.chests_from != self.chests_to)
614
+
615
+
616
+ def diff_reports(a: SaveReport, b: SaveReport) -> FlagDiff:
617
+ """Diff two decoded saves (A -> B). The set/cleared bit lists + the changed word vars are what a story
618
+ beat (or a play session) wrote to ``gEventGlobal`` -- the practical way to learn what a transition does
619
+ (save before, do the thing, save after, diff). Scenario/FieldEntrance are reported as their own deltas
620
+ (not in ``words_changed``, to avoid double-listing)."""
621
+ sa, sb = set(a.set_bits), set(b.set_bits)
622
+ wa = {w.name: (w, v) for w, v in a.named_words}
623
+ wb = {w.name: (w, v) for w, v in b.named_words}
624
+ words = []
625
+ for name in sorted(set(wa) | set(wb)):
626
+ if name in ("ScenarioCounter", "FieldEntrance"): # shown as dedicated deltas below
627
+ continue
628
+ w = (wa.get(name) or wb.get(name))[0]
629
+ old = wa[name][1] if name in wa else 0
630
+ new = wb[name][1] if name in wb else 0
631
+ if old != new:
632
+ words.append((w, old, new))
633
+ return FlagDiff(
634
+ scenario_from=a.scenario_counter, scenario_to=b.scenario_counter,
635
+ field_entrance_from=a.field_entrance, field_entrance_to=b.field_entrance,
636
+ th_from=a.treasure_hunter_points, th_to=b.treasure_hunter_points,
637
+ chests_from=a.chests_opened, chests_to=b.chests_opened,
638
+ bits_set=sorted(sb - sa), bits_cleared=sorted(sa - sb), words_changed=words)
639
+
640
+
641
+ def render_diff(diff: FlagDiff, *, show_bits: bool = False, names: dict | None = None) -> str:
642
+ """A human-readable A -> B story-state delta (the output of :func:`diff_reports`). ``names`` labels matching
643
+ custom-band bits (see :func:`render_report`); empty/None leaves the output byte-identical."""
644
+ def beat(v):
645
+ m = nearest_milestone(v)
646
+ return f"{v} ({m[1]})" if m else f"{v}"
647
+ L = ["FF9 gEventGlobal diff (A -> B)", "=" * 32]
648
+ if diff.scenario_from != diff.scenario_to:
649
+ L.append(f"ScenarioCounter : {beat(diff.scenario_from)} -> {beat(diff.scenario_to)}")
650
+ if diff.field_entrance_from != diff.field_entrance_to:
651
+ L.append(f"FieldEntrance : {diff.field_entrance_from} -> {diff.field_entrance_to}")
652
+ if diff.th_from != diff.th_to:
653
+ L.append(f"Treasure-Hunter : {diff.th_from} -> {diff.th_to} pts ({diff.th_to - diff.th_from:+d})")
654
+ if diff.chests_from != diff.chests_to:
655
+ L.append(f"Chests opened : {diff.chests_from} -> {diff.chests_to} ({diff.chests_to - diff.chests_from:+d})")
656
+ if diff.words_changed:
657
+ L.append("Named vars changed :")
658
+ for w, old, new in diff.words_changed:
659
+ L.append(f" - {w.name}: {old} -> {new}")
660
+ for tag, bits in (("SET (newly true)", diff.bits_set), ("CLEARED (now false)", diff.bits_cleared)):
661
+ if not bits:
662
+ continue
663
+ by_region, custom, unmapped, n = _group_set_bits(bits)
664
+ L.append(f"Bits {tag}: {n}")
665
+ for name, bs in sorted(by_region.items()):
666
+ L.append(f" [{name}] {len(bs)} bit(s): {bs[:20]}{' ...' if len(bs) > 20 else ''}")
667
+ if custom:
668
+ L.append(f" [custom 8512+] {len(custom)} bit(s): {_fmt_bits(custom, names)}")
669
+ if unmapped:
670
+ L.append(f" [unmapped] {len(unmapped)}" + (f": {unmapped}" if show_bits else " bit(s)"))
671
+ if diff.empty:
672
+ L.append("(no story-state difference)")
673
+ return "\n".join(L)
674
+
675
+
676
+ # ============================ registry browse (NAME) ============================
677
+ def registry_rows() -> list:
678
+ """``[(kind, name, location, meaning, tier)]`` for the CLI / docs -- named vars + reserved regions +
679
+ scenario milestones + the safe band, in one flat listing."""
680
+ rows = []
681
+ for w in NAMED_WORDS:
682
+ loc = f"byte {w.byte}" + (f"-{w.byte + w.width - 1}" if w.width > 1 else "")
683
+ rows.append(("var", w.name, loc, w.meaning, w.tier))
684
+ for r in BIT_REGIONS:
685
+ tag = "RESERVED" if r.reserved else "region"
686
+ rows.append((tag, r.name, f"bits {r.lo}-{r.hi}", r.meaning, r.tier))
687
+ for r in STORY_REGIONS:
688
+ rows.append(("story", r.name, f"bits {r.lo}-{r.hi}", r.meaning, r.tier))
689
+ for v, beat in sorted(SCENARIO_MILESTONES.items()):
690
+ rows.append(("scenario", str(v), "ScenarioCounter", beat, "a"))
691
+ rows.append(("band", "safe_custom", f"bits {FIRST_SAFE_FLAG}-{CHOICE_SCRATCH_FLOOR - 1}",
692
+ "Allocate custom story flags here (clear of all real-FF9 usage).", "a"))
693
+ return rows