Skip to content

Commit 57fb932

Browse files
AlchavJouramie
authored andcommitted
Final Fantasy Mystic Quest: Implement new game (ArchipelagoMW#1909)
FFMQR by @wildham0 Uses an API created by wildham for Map Shuffle, Crest Shuffle and Battlefield Reward Shuffle, using a similar method of obtaining data from an external website to Super Metroid's Varia Preset option. Generates a .apmq file which the user must bring to the FFMQR website https://www.ffmqrando.net/Archipelago to patch their rom. It is not an actual patch file but contains item placement and options data for the FFMQR website to generate a patched rom with for AP. Some of the AP options may seem unusual, using Choice instead of Range where it may seem more appropriate, but these are options that are passed to FFMQR and I can only be as flexible as it is. @wildham0 deserves the bulk of the credit for not only creating FFMQR in the first place but all the ASM work on the rom needed to make this possible, work on FFMQR to allow patching with the .apmq files, and creating the API that meant I did not have to recreate his map shuffle from scratch.
1 parent f4ebb55 commit 57fb932

16 files changed

+8072
-0
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Currently, the following games are supported:
5757
* Shivers
5858
* Heretic
5959
* Landstalker: The Treasures of King Nole
60+
* Final Fantasy Mystic Quest
6061

6162
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
6263
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

WebHostLib/downloads.py

+2
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ def download_slot_file(room_id, player_id: int):
9090
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
9191
elif slot_data.game == "Kingdom Hearts 2":
9292
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.zip"
93+
elif slot_data.game == "Final Fantasy Mystic Quest":
94+
fname = f"AP+{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmq"
9395
else:
9496
return "Game download not supported."
9597
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)

WebHostLib/templates/macros.html

+3
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@
5050
{% elif patch.game == "Dark Souls III" %}
5151
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
5252
Download JSON File...</a>
53+
{% elif patch.game == "Final Fantasy Mystic Quest" %}
54+
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
55+
Download APMQ File...</a>
5356
{% else %}
5457
No file to download for this game.
5558
{% endif %}

docs/CODEOWNERS

+3
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@
5555
# Final Fantasy
5656
/worlds/ff1/ @jtoyoda
5757

58+
# Final Fantasy Mystic Quest
59+
/worlds/ffmq/ @Alchav @wildham0
60+
5861
# Heretic
5962
/worlds/heretic/ @Daivuk
6063

worlds/ffmq/Client.py

+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
2+
from NetUtils import ClientStatus, color
3+
from worlds.AutoSNIClient import SNIClient
4+
from .Regions import offset
5+
import logging
6+
7+
snes_logger = logging.getLogger("SNES")
8+
9+
ROM_NAME = (0x7FC0, 0x7FD4 + 1 - 0x7FC0)
10+
11+
READ_DATA_START = 0xF50EA8
12+
READ_DATA_END = 0xF50FE7 + 1
13+
14+
GAME_FLAGS = (0xF50EA8, 64)
15+
COMPLETED_GAME = (0xF50F22, 1)
16+
BATTLEFIELD_DATA = (0xF50FD4, 20)
17+
18+
RECEIVED_DATA = (0xE01FF0, 3)
19+
20+
ITEM_CODE_START = 0x420000
21+
22+
IN_GAME_FLAG = (4 * 8) + 2
23+
24+
NPC_CHECKS = {
25+
4325676: ((6 * 8) + 4, False), # Old Man Level Forest
26+
4325677: ((3 * 8) + 6, True), # Kaeli Level Forest
27+
4325678: ((25 * 8) + 1, True), # Tristam
28+
4325680: ((26 * 8) + 0, True), # Aquaria Vendor Girl
29+
4325681: ((29 * 8) + 2, True), # Phoebe Wintry Cave
30+
4325682: ((25 * 8) + 6, False), # Mysterious Man (Life Temple)
31+
4325683: ((29 * 8) + 3, True), # Reuben Mine
32+
4325684: ((29 * 8) + 7, True), # Spencer
33+
4325685: ((29 * 8) + 6, False), # Venus Chest
34+
4325686: ((29 * 8) + 1, True), # Fireburg Tristam
35+
4325687: ((26 * 8) + 1, True), # Fireburg Vendor Girl
36+
4325688: ((14 * 8) + 4, True), # MegaGrenade Dude
37+
4325689: ((29 * 8) + 5, False), # Tristam's Chest
38+
4325690: ((29 * 8) + 4, True), # Arion
39+
4325691: ((29 * 8) + 0, True), # Windia Kaeli
40+
4325692: ((26 * 8) + 2, True), # Windia Vendor Girl
41+
42+
}
43+
44+
45+
def get_flag(data, flag):
46+
byte = int(flag / 8)
47+
bit = int(0x80 / (2 ** (flag % 8)))
48+
return (data[byte] & bit) > 0
49+
50+
51+
class FFMQClient(SNIClient):
52+
game = "Final Fantasy Mystic Quest"
53+
54+
async def validate_rom(self, ctx):
55+
from SNIClient import snes_read
56+
rom_name = await snes_read(ctx, *ROM_NAME)
57+
if rom_name is None:
58+
return False
59+
if rom_name[:2] != b"MQ":
60+
return False
61+
62+
ctx.rom = rom_name
63+
ctx.game = self.game
64+
ctx.items_handling = 0b001
65+
return True
66+
67+
async def game_watcher(self, ctx):
68+
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read
69+
70+
check_1 = await snes_read(ctx, 0xF53749, 1)
71+
received = await snes_read(ctx, RECEIVED_DATA[0], RECEIVED_DATA[1])
72+
data = await snes_read(ctx, READ_DATA_START, READ_DATA_END - READ_DATA_START)
73+
check_2 = await snes_read(ctx, 0xF53749, 1)
74+
if check_1 == b'\x00' or check_2 == b'\x00':
75+
return
76+
77+
def get_range(data_range):
78+
return data[data_range[0] - READ_DATA_START:data_range[0] + data_range[1] - READ_DATA_START]
79+
completed_game = get_range(COMPLETED_GAME)
80+
battlefield_data = get_range(BATTLEFIELD_DATA)
81+
game_flags = get_range(GAME_FLAGS)
82+
83+
if game_flags is None:
84+
return
85+
if not get_flag(game_flags, IN_GAME_FLAG):
86+
return
87+
88+
if not ctx.finished_game:
89+
if completed_game[0] & 0x80 and game_flags[30] & 0x18:
90+
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
91+
ctx.finished_game = True
92+
93+
old_locations_checked = ctx.locations_checked.copy()
94+
95+
for container in range(256):
96+
if get_flag(game_flags, (0x20 * 8) + container):
97+
ctx.locations_checked.add(offset["Chest"] + container)
98+
99+
for location, data in NPC_CHECKS.items():
100+
if get_flag(game_flags, data[0]) is data[1]:
101+
ctx.locations_checked.add(location)
102+
103+
for battlefield in range(20):
104+
if battlefield_data[battlefield] == 0:
105+
ctx.locations_checked.add(offset["BattlefieldItem"] + battlefield + 1)
106+
107+
if old_locations_checked != ctx.locations_checked:
108+
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": ctx.locations_checked}])
109+
110+
if received[0] == 0:
111+
received_index = int.from_bytes(received[1:], "big")
112+
if received_index < len(ctx.items_received):
113+
item = ctx.items_received[received_index]
114+
received_index += 1
115+
code = (item.item - ITEM_CODE_START) + 1
116+
if code > 256:
117+
code -= 256
118+
snes_buffered_write(ctx, RECEIVED_DATA[0], bytes([code, *received_index.to_bytes(2, "big")]))
119+
await snes_flush_writes(ctx)

0 commit comments

Comments
 (0)