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
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Cutscenes -- ordered, control-locked scripted sequences.
|
|
2
|
+
|
|
3
|
+
This is the one thing the declarative content (NPCs / events / flags) can't express: a SEQUENCE that
|
|
4
|
+
runs in order. A cutscene runs its actions in order with the player's control disabled for the
|
|
5
|
+
duration, optionally once (flag-gated), triggered on field entry.
|
|
6
|
+
|
|
7
|
+
There are two flavours, by whether the cutscene names an ``actor``:
|
|
8
|
+
|
|
9
|
+
* **Narration (v1, no actor)** -- a standalone code entry whose function steps through *controller-
|
|
10
|
+
level* actions that need no per-actor targeting: ``say`` (a dialogue/narration window),
|
|
11
|
+
``wait`` (pause N frames), ``set_flag`` (set a story flag). Triggered on load via ``InitCode``.
|
|
12
|
+
|
|
13
|
+
* **Actor cutscene (v2, ``actor = "<npc>"``)** -- the sequence is spliced into THAT NPC's Init (see
|
|
14
|
+
:func:`build_choreography`, used by :func:`ff9mapkit.content.npc.inject_npc` via its ``intro=``),
|
|
15
|
+
so it runs in the NPC's own object context (``gExec`` == the NPC). That lets the *actor* steps work
|
|
16
|
+
with plain base opcodes that act on the executing object: ``walk`` / ``teleport`` (MoveInstantXZY)
|
|
17
|
+
/ ``animation`` (RunAnimation+WaitAnimation) / ``turn`` (TimedTurn+WaitTurn) / ``face_player``
|
|
18
|
+
(TurnTowardObject 250). ``say`` / ``wait`` / ``set_flag`` work there too (they're global). No
|
|
19
|
+
cross-entry RunScript or UID targeting is needed -- and ``Walk`` self-blocks until arrival, so the
|
|
20
|
+
steps stay ordered. The block is ``if (!once) { DisableMove; <steps>; EnableMove; once=1 }``.
|
|
21
|
+
|
|
22
|
+
Both grounded in the standard FF9 pattern -- ``DisableMove`` (0x2D) ... actions ... ``EnableMove``
|
|
23
|
+
(0x2E), flag-gated so a one-time scene doesn't replay -- and in real walk cutscenes (e.g. Gargan
|
|
24
|
+
Roo's Kuja walk function: SetWalkSpeed -> RunAnimation -> WaitAnimation -> InitWalk -> Walk).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import struct
|
|
30
|
+
|
|
31
|
+
from ..eb import EbScript, edit, opcodes
|
|
32
|
+
from . import region as _region
|
|
33
|
+
from . import event as _event
|
|
34
|
+
|
|
35
|
+
# Default flag for a "play once" cutscene: the SAVE-PERSISTENT Global bool (survives reloads), high in
|
|
36
|
+
# gEventGlobal and clear of the event auto-once band (8000+).
|
|
37
|
+
CUTSCENE_FLAG_CLASS = _region.GLOB_BOOL
|
|
38
|
+
DEFAULT_CUTSCENE_FLAG = 8100 # GLOB (save-persistent) once-flag: plays once EVER
|
|
39
|
+
DEFAULT_CUTSCENE_MAP_FLAG = 80 # MAP-bit (transient, byte 10 -- clear of the field's init bits 144-159
|
|
40
|
+
# and the camera Map-byte 24): replays each visit, still once per visit
|
|
41
|
+
|
|
42
|
+
PLAYER_UID = 250 # GetObjUID(250) resolves to the player's control character (engine convention)
|
|
43
|
+
|
|
44
|
+
# A field's Main_Init enables control then runs a ~16-frame entry FadeFilter; for the first frames
|
|
45
|
+
# the field is still fading + the smooth-frame-updater is settling actor positions. Issuing an actor
|
|
46
|
+
# Walk during that window makes the actor circle and never converge (its synchronous Walk then hangs
|
|
47
|
+
# -> softlock). So an ACTOR cutscene waits a warm-up before commanding the actor -- exactly what real
|
|
48
|
+
# entry cutscenes do (Main_Loop `Wait(...)` before RunScript). Tunable via `[cutscene] warmup = N`.
|
|
49
|
+
DEFAULT_WARMUP = 30 # frames (~1s @ 30fps); generous margin over the 16-frame entry fade
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# A compulsory / auto-advance ATE (FF9's FORCED "Active Time Event" cutscene -- no menu, plays at a story
|
|
53
|
+
# beat, e.g. field 956 Gargant / the Festival-of-the-Hunt cluster) is an ordinary cutscene with two cosmetic
|
|
54
|
+
# ATE flourishes, both grounded byte-for-byte in the real grey mode-6 fields (`docs/ATE_SYSTEM.md` Flavor A):
|
|
55
|
+
# * its dialogue windows carry the winATE caption flag (64) -> the "Active Time Event" header. This flag
|
|
56
|
+
# is ALSO what makes the engine tag the closed dialog `isCompulsory` (ETb.ProcessATEDialog) -- the
|
|
57
|
+
# defining, engine-recognized marker of a compulsory ATE.
|
|
58
|
+
# * the body is bracketed `ATE(6) ... ATE(0)` (0xD7) -- the grey-unskippable HUD-icon arm (Gray+force; the
|
|
59
|
+
# mode arg is a 3-bit flag word, not an enum -- &3==2 Gray, &4 force; see EIcon.cs:416-454 / ATE_SYSTEM.md).
|
|
60
|
+
# (NB field 1901's Eiko ATE is the OPTIONAL Blue mode-1 menu hub, NOT this forced flavor -- don't mirror it.)
|
|
61
|
+
# TWO real templates for an auto-playing ATE (676-field byte sweep + the grey-unskippable re-classification,
|
|
62
|
+
# docs/ATE_SYSTEM.md):
|
|
63
|
+
# * ate_mode = 6 (GREY + force-show) = the AUTHENTIC UNSKIPPABLE ATE and the DEFAULT -- the real game's forced
|
|
64
|
+
# ATEs (field 956, the Festival-of-the-Hunt cluster) use ATE(6): a grey, force-shown icon that renders even
|
|
65
|
+
# under the control-lock. It drives the bottom-left "ACTIVE TIME EVENT" HUD banner (ActiveTimeEvent.cs), whose
|
|
66
|
+
# grey "ATE" sprite blinks 1s on / 1s off (DisplayGrayATEText) and shows NO press glyph. ★ in-game proven @30008.
|
|
67
|
+
# * ate_mode = 1 (Blue, no force) = the opt-in quiet no-icon variant: mode 1's render gate
|
|
68
|
+
# (`mode>0 && ((mode&4) || GetUserControl())`) FAILS under the control-lock, so no HUD banner shows -- the
|
|
69
|
+
# winATE CAPTION window is the only marker (also proven @30008, before the mode-6 switch).
|
|
70
|
+
# AVOID ate_mode = 5 (Blue + force): a force-shown Blue icon re-flashes the "Press SELECT" glyph (the Blue
|
|
71
|
+
# coroutine), wrongly inviting a press during an auto-play. mode 2 is unused in the real game; the only grey is 6.
|
|
72
|
+
# (NB the kit holds ATE(6) armed across the whole body so the grey banner blinks throughout -- more legible than
|
|
73
|
+
# real 956, which clears it behind a white fade-in; this matches what players remember seeing.)
|
|
74
|
+
# Seen-state + the ATE80 trophy register only on a REAL field id (MappingATEID keyed on fldMapNo/SC) -- the wall.
|
|
75
|
+
# Mirrors `ate.WIN_ATE`; kept local to avoid importing the ate module (which imports choice -> region).
|
|
76
|
+
ATE_CAPTION_FLAG = 64
|
|
77
|
+
ATE_DEFAULT_MODE = 6 # ATE(mode) HUD arm. 6 = the authentic GREY UNSKIPPABLE banner (default, in-game proven
|
|
78
|
+
# @30008); 1 = opt-in quiet no-icon variant. Avoid 5 (Blue force-show re-flashes press glyph)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def say(text_id: int, *, window: int = 1, flags: int = 128) -> bytes:
|
|
82
|
+
"""Step: open a dialogue/narration window showing ``text_id`` (blocks until the player dismisses).
|
|
83
|
+
``flags = 64`` (winATE) renders it with the "Active Time Event" caption (a compulsory-ATE window)."""
|
|
84
|
+
return opcodes.window_sync(window, flags, text_id)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def wait(frames: int) -> bytes:
|
|
88
|
+
"""Step: pause for ``frames`` frames."""
|
|
89
|
+
return opcodes.wait(frames)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def set_flag(idx: int, value: int = 1, *, flag_class=CUTSCENE_FLAG_CLASS) -> bytes:
|
|
93
|
+
"""Step: set a GlobBool story flag (advance/record state from within the scene)."""
|
|
94
|
+
return _region.set_var(flag_class, idx, value)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --- actor-context steps (v2) -- only valid inside an `actor` cutscene (run in the NPC's entry) ---
|
|
98
|
+
# How fast the actor rotates toward its destination while walking (omega, 0..255). High = the
|
|
99
|
+
# turn-while-walk arc shrinks to ~nothing, so a walk to a point BEHIND the actor turns and goes
|
|
100
|
+
# straight instead of orbiting it forever. This replaces a separate animated pre-turn
|
|
101
|
+
# (TurnTowardPosition/TimedTurn + WaitTurn), which can HANG at ~180deg (the animated big-turn path
|
|
102
|
+
# never completing -> WaitTurn stuck -> softlock). Self-converging + deterministic at exactly 180.
|
|
103
|
+
WALK_TURN_SPEED = 255
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def actor_walk(x: int, z: int, speed: int | None = None) -> bytes:
|
|
107
|
+
"""Step: the actor walks to world (x, z).
|
|
108
|
+
|
|
109
|
+
Sets a high walk-turn-speed first so the Walk rotates tightly toward the destination and walks
|
|
110
|
+
straight (no arc), converging even when the target is directly BEHIND the actor -- without the
|
|
111
|
+
animated pre-turn that hangs at ~180deg. ``StopAnimation`` clears the anim flags first so the
|
|
112
|
+
engine actually swaps idle->walk while moving (else a player-cloned NPC glides in its idle pose).
|
|
113
|
+
``Walk`` blocks until arrival. Optional ``speed`` sets the walk movement speed. Uses the NPC's
|
|
114
|
+
walk animation (set in its Init)."""
|
|
115
|
+
pre = opcodes.set_walk_speed(int(speed)) if speed is not None else b""
|
|
116
|
+
return (pre + opcodes.set_walk_turn_speed(WALK_TURN_SPEED) + opcodes.stop_animation()
|
|
117
|
+
+ opcodes.init_walk() + opcodes.walk(int(x), int(z)))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def actor_teleport(x: int, z: int) -> bytes:
|
|
121
|
+
"""Step: instantly move the actor to world (x, z) -- no walk animation -- then re-enable its
|
|
122
|
+
walkmesh pathing (MoveInstantXZY disables it). Use it as a cutscene's FIRST step to place the
|
|
123
|
+
actor off-screen for a walk-in (the kit handles the engine's POS3 Z-negation; a leading teleport
|
|
124
|
+
runs before the warm-up so the actor settles off-screen rather than flashing at its spawn)."""
|
|
125
|
+
return opcodes.move_instant_xzy(int(x), int(z), 0) + opcodes.set_pathing(1)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Cutscene steps are NON-BLOCKING on the animation system: we never use WaitAnimation/WaitTurn,
|
|
129
|
+
# because they HANG if the actor's anim playback doesn't drive them to completion (a player-cloned
|
|
130
|
+
# NPC's walk/turn anims don't always engage -> WaitTurn/WaitAnimation never return -> softlock). A
|
|
131
|
+
# turn is done INSTANTLY (no turn anim needed); an animation is played then given a fixed hold.
|
|
132
|
+
ANIM_HOLD = 40 # frames to let a played animation run before the next step (~1.3s)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def actor_animation(anim: int, hold: int = ANIM_HOLD) -> bytes:
|
|
136
|
+
"""Step: play animation ``anim`` on the actor, then hold ``hold`` frames (RunAnimation + a fixed
|
|
137
|
+
Wait -- NOT WaitAnimation, which hangs if the anim doesn't complete)."""
|
|
138
|
+
return opcodes.run_animation(int(anim)) + opcodes.wait(int(hold))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def actor_turn(angle: int) -> bytes:
|
|
142
|
+
"""Step: face ``angle`` INSTANTLY (0=south, 64=west, 128=north, 192=east). Instant (TurnInstant) so
|
|
143
|
+
it works without a turn animation and never hangs."""
|
|
144
|
+
return opcodes.turn_instant(int(angle))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def actor_face(uid: int = PLAYER_UID, speed: int = 16) -> bytes:
|
|
148
|
+
"""Step: turn the actor to face an object by UID (default 250 = the player), animated, non-blocking
|
|
149
|
+
(no WaitTurn). Visible only if the turn anim engages; for a guaranteed instant facing use ``turn``."""
|
|
150
|
+
return opcodes.turn_toward_object(int(uid), int(speed))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def compile_steps(steps, txids, *, say_flags: int = 128) -> bytes:
|
|
154
|
+
"""Compile ordered cutscene step dicts to bytes. Handles global steps (``say`` / ``wait`` /
|
|
155
|
+
``set_flag``) and actor-context steps (``walk`` / ``path`` / ``teleport`` / ``animation`` /
|
|
156
|
+
``turn`` / ``face_player``). ``say`` steps consume ``txids`` (a list of resolved text ids) in order.
|
|
157
|
+
|
|
158
|
+
Actor steps are only meaningful inside an ``actor`` cutscene (they act on the executing object);
|
|
159
|
+
:func:`ff9mapkit.build.validate` enforces that. ``say_flags`` is the window flag for every ``say``
|
|
160
|
+
step -- pass ``ATE_CAPTION_FLAG`` (64) to render a compulsory ATE's windows with the ATE caption.
|
|
161
|
+
Same encoders the round-trip tests cover."""
|
|
162
|
+
out, ti = [], 0
|
|
163
|
+
for s in steps:
|
|
164
|
+
if "say" in s:
|
|
165
|
+
out.append(say(txids[ti], flags=say_flags)); ti += 1
|
|
166
|
+
elif "wait" in s:
|
|
167
|
+
out.append(wait(int(s["wait"])))
|
|
168
|
+
elif "set_flag" in s:
|
|
169
|
+
sf = s["set_flag"]
|
|
170
|
+
out.append(set_flag(int(sf[0]), int(sf[1]) if len(sf) > 1 else 1))
|
|
171
|
+
elif "walk" in s:
|
|
172
|
+
out.append(actor_walk(s["walk"][0], s["walk"][1], s.get("speed")))
|
|
173
|
+
elif "path" in s: # a multi-waypoint route = consecutive straight walks
|
|
174
|
+
for pt in s["path"]:
|
|
175
|
+
out.append(actor_walk(int(pt[0]), int(pt[1])))
|
|
176
|
+
elif "teleport" in s:
|
|
177
|
+
out.append(actor_teleport(s["teleport"][0], s["teleport"][1]))
|
|
178
|
+
elif "animation" in s:
|
|
179
|
+
out.append(actor_animation(s["animation"]))
|
|
180
|
+
elif "turn" in s:
|
|
181
|
+
out.append(actor_turn(s["turn"]))
|
|
182
|
+
elif "face_player" in s:
|
|
183
|
+
out.append(actor_face())
|
|
184
|
+
else:
|
|
185
|
+
raise ValueError(f"unknown cutscene step: {s!r}")
|
|
186
|
+
return b"".join(out)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def once_flag_for(cs: dict):
|
|
190
|
+
"""(flag_class, flag_idx) for a cutscene's gate. ``once=true`` -> a SAVE-PERSISTENT Global bool
|
|
191
|
+
(plays once ever); ``once=false`` -> a TRANSIENT Map bool (replays each visit -- the Map var resets
|
|
192
|
+
on field load -- but still runs once per visit). An explicit ``flag = N`` overrides the index."""
|
|
193
|
+
if cs.get("once", True):
|
|
194
|
+
return _region.GLOB_BOOL, int(cs.get("flag", DEFAULT_CUTSCENE_FLAG))
|
|
195
|
+
return _region.MAP_BOOL, int(cs.get("flag", DEFAULT_CUTSCENE_MAP_FLAG))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def build_choreography(steps, txids, flag_idx: int, *, flag_class=CUTSCENE_FLAG_CLASS,
|
|
199
|
+
warmup: int = DEFAULT_WARMUP, ate_mode: int | None = None,
|
|
200
|
+
say_flags: int = 128) -> bytes:
|
|
201
|
+
"""The gated choreography block, PREPENDED to the actor NPC's LOOP (tag 1) -- NOT its Init -- by
|
|
202
|
+
:func:`ff9mapkit.content.npc.inject_npc`. Runs in the NPC's own context (so the actor steps target
|
|
203
|
+
it) AND while the object is 'running' (engine state 1), where the engine ADVANCES animation frames.
|
|
204
|
+
(An Init runs at state 2, where ProcessAnime is skipped -> the model glides frozen; confirmed by an
|
|
205
|
+
in-engine probe -- so the choreography must live in the loop, like real FF9 cutscenes.)
|
|
206
|
+
|
|
207
|
+
Shape: ``if (!flag) { DisableMove; Wait(warmup); <steps>; EnableMove; flag=1 }`` (no trailing RETURN
|
|
208
|
+
-- the loop body + its RETURN follow). ALWAYS gated -- the loop runs every frame, so an ungated
|
|
209
|
+
block would re-fire endlessly; the flag makes it run once per visit. The ``warmup`` Wait (after the
|
|
210
|
+
lock, so the player can't wander) lets the field's entry fade settle before the actor moves.
|
|
211
|
+
|
|
212
|
+
``ate_mode`` (not None) styles it as a compulsory ATE: brackets the steps ``ATE(mode) ... ATE(0)``
|
|
213
|
+
and (with ``say_flags=ATE_CAPTION_FLAG``) gives its windows the ATE caption -- mirrors the real grey
|
|
214
|
+
mode-6 fields (e.g. 956 Gargant), NOT field 1901 (which is the optional Blue mode-1 menu hub)."""
|
|
215
|
+
inner = opcodes.DISABLE_MOVE
|
|
216
|
+
if warmup > 0:
|
|
217
|
+
inner += opcodes.wait(int(warmup))
|
|
218
|
+
if ate_mode is not None:
|
|
219
|
+
inner += opcodes.ate(int(ate_mode)) # arm the blinking "Active Time Event" prompt
|
|
220
|
+
inner += compile_steps(steps, txids, say_flags=say_flags)
|
|
221
|
+
if ate_mode is not None:
|
|
222
|
+
inner += opcodes.ate(0) # disarm before control returns (close the bracket)
|
|
223
|
+
inner += opcodes.ENABLE_MOVE + _region.set_var(flag_class, flag_idx, 1)
|
|
224
|
+
return _region.if_block(_region.cond_not(flag_class, flag_idx), inner)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# A narration cutscene runs in a SEPARATE code entry armed by `InitCode` in Main_Init -- but Main_Init
|
|
228
|
+
# itself calls `EnableMove` (and a fade) AFTER that InitCode. If the director's `DisableMove` ran first
|
|
229
|
+
# it would be immediately overridden by Main_Init's `EnableMove`, so the player keeps control during the
|
|
230
|
+
# text. Yielding a couple of frames first lets Main_Init reach its `EnableMove` (it does so in the first
|
|
231
|
+
# frame), so the director's `DisableMove` is the LAST control-setter and the lock sticks. (An ACTOR
|
|
232
|
+
# cutscene avoids this by living in the NPC's LOOP, which only runs after Init completes.) ~2 frames is
|
|
233
|
+
# imperceptible (<100ms) and the window only shows during the entry fade.
|
|
234
|
+
REORDER_WAIT = 2
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def build_body(steps, once_flag: int | None, flag_class=CUTSCENE_FLAG_CLASS,
|
|
238
|
+
reorder: int = REORDER_WAIT, *, ate_mode: int | None = None,
|
|
239
|
+
then_warp: int | None = None) -> bytes:
|
|
240
|
+
"""The cutscene function body: a brief reorder ``Wait`` (so the lock outlives Main_Init's EnableMove)
|
|
241
|
+
then ``DisableMove`` + the ordered ``steps`` + ``EnableMove``, all gated ``if (!once_flag) { ...;
|
|
242
|
+
once_flag = 1 }`` when ``once_flag`` is set (so it plays once).
|
|
243
|
+
|
|
244
|
+
``ate_mode`` (not None) brackets the steps ``ATE(mode) ... ATE(0)`` -- a compulsory ATE's HUD prompt
|
|
245
|
+
(the winATE caption on its windows is set by the caller via ``compile_steps(say_flags=...)``).
|
|
246
|
+
|
|
247
|
+
``then_warp`` (a field id) makes the scene AUTO-RETURN: it ends with a FADE-TO-BLACK then
|
|
248
|
+
``Field(then_warp)`` instead of restoring control -- exactly how real grey ATEs end (field 956 ->
|
|
249
|
+
``Field(2054)``). The warp sits OUTSIDE the once-gate so it ALWAYS fires (even on a re-entry that skips
|
|
250
|
+
a once'd cutscene, the player still warps back); it transitions away, so it's the last op (no
|
|
251
|
+
``EnableMove`` -- the destination's Main_Init restores control). It fades out first (``warp(fade=True)``)
|
|
252
|
+
so the destination doesn't load in the clear (the static-screen bug). Field() transitions from this
|
|
253
|
+
InitCode'd entry just like the World-Hub menu-row warp does (same code-entry context -- NOT the
|
|
254
|
+
Main_Init no-op case)."""
|
|
255
|
+
pre = opcodes.wait(int(reorder)) if reorder and reorder > 0 else b""
|
|
256
|
+
inner = pre + opcodes.DISABLE_MOVE
|
|
257
|
+
if ate_mode is not None:
|
|
258
|
+
inner += opcodes.ate(int(ate_mode))
|
|
259
|
+
inner += b"".join(steps)
|
|
260
|
+
if ate_mode is not None:
|
|
261
|
+
inner += opcodes.ate(0)
|
|
262
|
+
if then_warp is None:
|
|
263
|
+
inner += opcodes.ENABLE_MOVE # restore control (a normal cutscene stays put)
|
|
264
|
+
if once_flag is not None:
|
|
265
|
+
inner += _region.set_var(flag_class, once_flag, 1)
|
|
266
|
+
body = _region.if_block(_region.cond_not(flag_class, once_flag), inner)
|
|
267
|
+
else:
|
|
268
|
+
body = inner
|
|
269
|
+
if then_warp is not None:
|
|
270
|
+
# fade=True: fade to black BEFORE the warp, like every field transition (gateway/ladder/choice).
|
|
271
|
+
# Without it the destination loads in the clear and you see its camera-init frames (the static-
|
|
272
|
+
# screen bug). A real grey-ATE return may already be black behind the ATE banner, but the kit
|
|
273
|
+
# doesn't reproduce that, so an explicit source-side fade is the safe default. See event.warp.
|
|
274
|
+
body += _event.warp(int(then_warp), fade=True) # AUTO-RETURN: fade to black, then transition
|
|
275
|
+
return body + opcodes.RETURN
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def inject_cutscene(data, steps, *, once_flag: int | None = None, flag_class=CUTSCENE_FLAG_CLASS,
|
|
279
|
+
spawn_wait_n: int = 2, spawn_wait_occurrence: int = 0,
|
|
280
|
+
ate_mode: int | None = None, then_warp: int | None = None) -> bytes:
|
|
281
|
+
"""Append a cutscene code entry (the sequence in :func:`build_body`) and run it on field load via
|
|
282
|
+
an ``InitCode`` (over a Wait filler, or inserted into Main_Init). Returns new .eb bytes.
|
|
283
|
+
``ate_mode`` (not None) styles it as a compulsory ATE (the ``ATE(mode)`` HUD bracket); ``then_warp``
|
|
284
|
+
(a field id) makes it auto-return with ``Field(then_warp)`` at the end."""
|
|
285
|
+
body = build_body(steps, once_flag, flag_class, ate_mode=ate_mode, then_warp=then_warp)
|
|
286
|
+
entry = bytes([0x00, 0x01]) + struct.pack("<HH", 0, 4) + body
|
|
287
|
+
slot = EbScript.from_bytes(data).first_free_slot()
|
|
288
|
+
out = edit.append_entry(data, slot, entry)
|
|
289
|
+
return edit.activate(out, opcodes.init_code(slot, 0), spawn_wait_n=spawn_wait_n,
|
|
290
|
+
spawn_wait_occurrence=spawn_wait_occurrence)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Add random-battle encounters to a field.
|
|
2
|
+
|
|
3
|
+
Appends a type-0 "code" entry whose function runs ``SetRandomBattles`` +
|
|
4
|
+
``SetRandomBattleFrequency``, and activates it from Main_Init via ``InitCode`` written over a
|
|
5
|
+
``Wait`` filler (shift-free). The battle scene id selects which encounter table is used (e.g.
|
|
6
|
+
67 = Evil Forest / the first, weakest battles). Frequency 0..255 (higher = more frequent).
|
|
7
|
+
|
|
8
|
+
NOTE: a field that hosts encounters also needs an after-battle reinit handler or the player
|
|
9
|
+
freezes on return — see :mod:`ff9mapkit.content.reinit`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import struct
|
|
15
|
+
|
|
16
|
+
from ..eb import EbScript, edit, opcodes
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _battle_entry(pattern: int, scenes, freq: int) -> bytes:
|
|
20
|
+
scenes = list(scenes)
|
|
21
|
+
if len(scenes) != 4:
|
|
22
|
+
raise ValueError("need exactly 4 battle scene ids")
|
|
23
|
+
code = opcodes.set_random_battles(pattern, *scenes) + opcodes.set_random_battle_frequency(freq) \
|
|
24
|
+
+ opcodes.RETURN
|
|
25
|
+
# entry: type 0, funcCount 1, funcTable[(tag 0, fpos 4)], then code
|
|
26
|
+
return bytes([0x00, 0x01]) + struct.pack("<HH", 0, 4) + code
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def inject_encounter(eb_bytes, *, scene: int, freq: int = 255, pattern: int = 1, scenes=None,
|
|
30
|
+
slot: int | None = None, spawn_wait_n: int = 2,
|
|
31
|
+
spawn_wait_occurrence: int = 0) -> bytes:
|
|
32
|
+
"""Add encounters of ``scene`` (or an explicit 4-tuple ``scenes``) at ``freq``."""
|
|
33
|
+
if scenes is None:
|
|
34
|
+
scenes = (scene,) * 4
|
|
35
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
36
|
+
if slot is None:
|
|
37
|
+
slot = eb.first_free_slot()
|
|
38
|
+
out = edit.append_entry(eb_bytes, slot, _battle_entry(pattern, scenes, freq))
|
|
39
|
+
out = edit.activate(out, opcodes.init_code(slot, 0), spawn_wait_n=spawn_wait_n,
|
|
40
|
+
spawn_wait_occurrence=spawn_wait_occurrence)
|
|
41
|
+
return out
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Hold the screen black briefly on entry so the camera SETTLES before it is revealed.
|
|
2
|
+
|
|
3
|
+
The Memoria engine runs a per-frame smooth-camera follower (``FieldMap.CenterCameraOnPlayer``, scaled by
|
|
4
|
+
``Memoria.ini``'s ``CameraStabilizer``) for EVERY field. On a warp-in it eases the camera from its
|
|
5
|
+
carried-over position to the spawn-centred target over many frames. Real fields hide this because the
|
|
6
|
+
warp's fade-out blacks the screen while the camera settles; the kit's synthesized ``Main_Init`` reveals
|
|
7
|
+
immediately (its FadeFilter fires right after ``EnableMove``), so on a large-delta entry -- e.g. the World
|
|
8
|
+
Hub entered via a New-Game / F6 warp -- you SEE the camera drift to rest over a few seconds.
|
|
9
|
+
|
|
10
|
+
Fix (engine-independent, ships on stock Memoria -- no DLL, no ``SmoothCamExcludeMaps`` edit): insert
|
|
11
|
+
``DisableMove ; Wait(n) ; EnableMove`` immediately BEFORE Main_Init's reveal fade. The screen is still
|
|
12
|
+
black at that point (the field loads black; the reveal fade is what brings it in), so the smooth-cam
|
|
13
|
+
converges UNSEEN during the wait; the existing fade then reveals the already-settled camera. Control is
|
|
14
|
+
locked during the wait so the player can't wander blind. (memory ``project-ff9-world-hub``;
|
|
15
|
+
``FieldMap.cs`` ``CenterCameraOnPlayer`` / ``SmoothCamExcludeMaps`` / ``CameraStabilizer``.)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from ..eb import EbScript, edit, opcodes
|
|
21
|
+
|
|
22
|
+
FADE_FILTER = 0xEC # WIPERGB / "FadeFilter"; arg0 & 2 set => SUB == a fade-IN (reveal)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def add_entry_settle(eb_bytes, wait_frames: int = 45) -> bytes:
|
|
26
|
+
"""Insert ``DisableMove ; Wait(wait_frames) ; EnableMove`` just before Main_Init's reveal fade so the
|
|
27
|
+
smooth-camera settles behind the black screen. Returns the input unchanged when ``wait_frames <= 0`` or
|
|
28
|
+
Main_Init has no reveal fade (nothing to hide behind)."""
|
|
29
|
+
if wait_frames <= 0:
|
|
30
|
+
return eb_bytes
|
|
31
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
32
|
+
e0 = eb.entry(0)
|
|
33
|
+
f0 = e0.func_by_tag(0) if e0 is not None else None
|
|
34
|
+
if f0 is None:
|
|
35
|
+
return eb_bytes
|
|
36
|
+
fade = None
|
|
37
|
+
for i in eb.instrs(f0):
|
|
38
|
+
if i.op == FADE_FILTER and i.args:
|
|
39
|
+
try:
|
|
40
|
+
mode = int(i.args[0])
|
|
41
|
+
except (TypeError, ValueError):
|
|
42
|
+
continue # an expression-mode fade: not the template reveal -- skip
|
|
43
|
+
if mode & 2: # SUB == fade-IN (reveal); ADD (fade-out) would not help
|
|
44
|
+
fade = i
|
|
45
|
+
break
|
|
46
|
+
if fade is None:
|
|
47
|
+
return eb_bytes
|
|
48
|
+
rel = fade.off - f0.abs_start
|
|
49
|
+
body = opcodes.DISABLE_MOVE + opcodes.wait(wait_frames) + opcodes.ENABLE_MOVE
|
|
50
|
+
return edit.insert_in_function(eb_bytes, 0, 0, rel, body)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""``[[equipment]]`` -- author a character's STARTING equipment (its new-game default loadout).
|
|
2
|
+
|
|
3
|
+
Writes a PARTIAL ``<mod>/StreamingAssets/Data/Characters/DefaultEquipment.csv`` delta -- only the characters
|
|
4
|
+
you specify. The engine MERGES DefaultEquipment low->high (``ff9play.LoadCharacterDefaultEquipment``), so a
|
|
5
|
+
partial file overrides just those characters' default sets and unspecified characters keep the base game's.
|
|
6
|
+
|
|
7
|
+
★ A slot you OMIT starts EMPTY. The row REPLACES that character's whole default set (it is not a per-slot
|
|
8
|
+
patch), so list the full intended loadout. Per-character default equipment is applied at new-game / when a
|
|
9
|
+
character JOINS (``FF9Play_SetDefEquips``), so it composes with story_flags' ``[party]`` -- an added member
|
|
10
|
+
joins wearing its DefaultEquipment gear. New-game scope (no mid-game retro-apply). Lives on the ENTRY field's
|
|
11
|
+
``field.toml``, emitted at the mod-write stage. (memory project-ff9-items-equipment / project-ff9-branch-lanes.)
|
|
12
|
+
|
|
13
|
+
[[equipment]]
|
|
14
|
+
character = "steiner"
|
|
15
|
+
weapon = "Excalibur"
|
|
16
|
+
head = "Genji Helmet"
|
|
17
|
+
armor = "Genji Armor"
|
|
18
|
+
# head/wrist/armor/accessory omitted -> that slot starts empty
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from .. import items as _items
|
|
23
|
+
|
|
24
|
+
# Character name -> EquipmentSetId (Memoria.Data.Characters.EquipmentSetId enum; names/ids only -> provenance-clean).
|
|
25
|
+
EQUIP_SET_ID = {
|
|
26
|
+
"zidane": 0, "vivi": 1, "garnet": 2, "steiner": 3, "freya": 4, "quina": 5,
|
|
27
|
+
"eiko": 6, "amarant": 7, "cinna": 8, "marcus": 9, "blank": 10, "beatrix": 11,
|
|
28
|
+
"marcus2": 12, "beatrix2": 13, "blank2": 14,
|
|
29
|
+
"dagger": 2, "salamander": 7, # aliases (Garnet's alias, Amarant's nickname)
|
|
30
|
+
}
|
|
31
|
+
SET_NAME = {0: "Zidane", 1: "Vivi", 2: "Garnet", 3: "Steiner", 4: "Freya", 5: "Quina", 6: "Eiko",
|
|
32
|
+
7: "Amarant", 8: "Cinna", 9: "Marcus", 10: "Blank", 11: "Beatrix",
|
|
33
|
+
12: "Marcus2", 13: "Beatrix2", 14: "Blank2"}
|
|
34
|
+
SLOTS = ("weapon", "head", "wrist", "armor", "accessory") # the 5 DefaultEquipment.csv equip columns, in order
|
|
35
|
+
MAX_SET_ID = 14
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_set_id(name) -> int:
|
|
39
|
+
"""A character name/alias (or a bare 0-14 set id) -> EquipmentSetId. Raises ValueError on unknown/out-of-range."""
|
|
40
|
+
if isinstance(name, bool) or name is None:
|
|
41
|
+
raise ValueError("[[equipment]] needs a 'character' (zidane..beatrix, marcus2/beatrix2/blank2)")
|
|
42
|
+
if isinstance(name, int) or (isinstance(name, str) and name.strip().lstrip("-").isdigit()):
|
|
43
|
+
i = int(name)
|
|
44
|
+
if not 0 <= i <= MAX_SET_ID:
|
|
45
|
+
raise ValueError(f"equipment character id {i} out of range (0-{MAX_SET_ID})")
|
|
46
|
+
return i
|
|
47
|
+
key = "".join(c for c in str(name).lower() if c.isalnum())
|
|
48
|
+
if key not in EQUIP_SET_ID:
|
|
49
|
+
raise ValueError(f"unknown equipment character {name!r} (zidane..beatrix, marcus2/beatrix2/blank2)")
|
|
50
|
+
return EQUIP_SET_ID[key]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _slot_id(val) -> int:
|
|
54
|
+
"""An equip-slot value -> item id, or -1 for empty (None / 'none' / '' / -1). Resolves names via items."""
|
|
55
|
+
if val is None:
|
|
56
|
+
return -1
|
|
57
|
+
if isinstance(val, str) and val.strip().lower() in ("", "none", "-1"):
|
|
58
|
+
return -1
|
|
59
|
+
if isinstance(val, int) and val < 0:
|
|
60
|
+
return -1
|
|
61
|
+
return _items.resolve(val)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def equipment_rows(entries) -> list:
|
|
65
|
+
"""``[[equipment]]`` dicts -> sorted ``[(set_id, [weapon, head, wrist, armor, accessory]), ...]`` -- one
|
|
66
|
+
COMPLETE row per character (an omitted slot = -1 empty). De-dups by set id (last wins). Resolves item +
|
|
67
|
+
character names; raises ValueError on an unknown name."""
|
|
68
|
+
by_id: dict = {}
|
|
69
|
+
for e in entries:
|
|
70
|
+
sid = resolve_set_id(e.get("character"))
|
|
71
|
+
by_id[sid] = [_slot_id(e.get(s)) for s in SLOTS]
|
|
72
|
+
return sorted(by_id.items())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render_default_equipment(entries) -> str:
|
|
76
|
+
"""The PARTIAL ``DefaultEquipment.csv`` text (header + one row per authored character). Merged over the
|
|
77
|
+
base by the engine, so it overrides only these characters' default sets."""
|
|
78
|
+
lines = [
|
|
79
|
+
"# ff9mapkit [[equipment]] -- a partial starting-equipment delta (merged over the base by the engine).",
|
|
80
|
+
"# Comment;Id;Weapon;Head;Wrist;Armor;Accessory",
|
|
81
|
+
"# ;Int32;Int32;Int32;Int32;Int32;Int32",
|
|
82
|
+
]
|
|
83
|
+
for sid, slots in equipment_rows(entries):
|
|
84
|
+
cmt = SET_NAME.get(sid, f"set{sid}")
|
|
85
|
+
lines.append(f"{cmt};{sid};" + ";".join(str(x) for x in slots))
|
|
86
|
+
return "\n".join(lines) + "\n"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def write_default_equipment(layout, entries) -> None:
|
|
90
|
+
"""Pure writer: emit the equipment delta into ``layout``'s mod root (``Data/Characters/DefaultEquipment.csv``)."""
|
|
91
|
+
path = layout.default_equipment_csv
|
|
92
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
path.write_text(render_default_equipment(entries), encoding="utf-8", newline="\n")
|