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/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
|