Skip to content

Commit

Permalink
Final Fantasy Mystic Quest: Implement new game (ArchipelagoMW#1909)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Alchav authored Nov 26, 2023
1 parent 65f47be commit f54f862
Show file tree
Hide file tree
Showing 16 changed files with 8,072 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Currently, the following games are supported:
* Shivers
* Heretic
* Landstalker: The Treasures of King Nole
* Final Fantasy Mystic Quest

For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
Expand Down
2 changes: 2 additions & 0 deletions WebHostLib/downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ def download_slot_file(room_id, player_id: int):
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
elif slot_data.game == "Kingdom Hearts 2":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.zip"
elif slot_data.game == "Final Fantasy Mystic Quest":
fname = f"AP+{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmq"
else:
return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)
Expand Down
3 changes: 3 additions & 0 deletions WebHostLib/templates/macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@
{% elif patch.game == "Dark Souls III" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download JSON File...</a>
{% elif patch.game == "Final Fantasy Mystic Quest" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMQ File...</a>
{% else %}
No file to download for this game.
{% endif %}
Expand Down
3 changes: 3 additions & 0 deletions docs/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@
# Final Fantasy
/worlds/ff1/ @jtoyoda

# Final Fantasy Mystic Quest
/worlds/ffmq/ @Alchav @wildham0

# Heretic
/worlds/heretic/ @Daivuk

Expand Down
119 changes: 119 additions & 0 deletions worlds/ffmq/Client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@

from NetUtils import ClientStatus, color
from worlds.AutoSNIClient import SNIClient
from .Regions import offset
import logging

snes_logger = logging.getLogger("SNES")

ROM_NAME = (0x7FC0, 0x7FD4 + 1 - 0x7FC0)

READ_DATA_START = 0xF50EA8
READ_DATA_END = 0xF50FE7 + 1

GAME_FLAGS = (0xF50EA8, 64)
COMPLETED_GAME = (0xF50F22, 1)
BATTLEFIELD_DATA = (0xF50FD4, 20)

RECEIVED_DATA = (0xE01FF0, 3)

ITEM_CODE_START = 0x420000

IN_GAME_FLAG = (4 * 8) + 2

NPC_CHECKS = {
4325676: ((6 * 8) + 4, False), # Old Man Level Forest
4325677: ((3 * 8) + 6, True), # Kaeli Level Forest
4325678: ((25 * 8) + 1, True), # Tristam
4325680: ((26 * 8) + 0, True), # Aquaria Vendor Girl
4325681: ((29 * 8) + 2, True), # Phoebe Wintry Cave
4325682: ((25 * 8) + 6, False), # Mysterious Man (Life Temple)
4325683: ((29 * 8) + 3, True), # Reuben Mine
4325684: ((29 * 8) + 7, True), # Spencer
4325685: ((29 * 8) + 6, False), # Venus Chest
4325686: ((29 * 8) + 1, True), # Fireburg Tristam
4325687: ((26 * 8) + 1, True), # Fireburg Vendor Girl
4325688: ((14 * 8) + 4, True), # MegaGrenade Dude
4325689: ((29 * 8) + 5, False), # Tristam's Chest
4325690: ((29 * 8) + 4, True), # Arion
4325691: ((29 * 8) + 0, True), # Windia Kaeli
4325692: ((26 * 8) + 2, True), # Windia Vendor Girl

}


def get_flag(data, flag):
byte = int(flag / 8)
bit = int(0x80 / (2 ** (flag % 8)))
return (data[byte] & bit) > 0


class FFMQClient(SNIClient):
game = "Final Fantasy Mystic Quest"

async def validate_rom(self, ctx):
from SNIClient import snes_read
rom_name = await snes_read(ctx, *ROM_NAME)
if rom_name is None:
return False
if rom_name[:2] != b"MQ":
return False

ctx.rom = rom_name
ctx.game = self.game
ctx.items_handling = 0b001
return True

async def game_watcher(self, ctx):
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read

check_1 = await snes_read(ctx, 0xF53749, 1)
received = await snes_read(ctx, RECEIVED_DATA[0], RECEIVED_DATA[1])
data = await snes_read(ctx, READ_DATA_START, READ_DATA_END - READ_DATA_START)
check_2 = await snes_read(ctx, 0xF53749, 1)
if check_1 == b'\x00' or check_2 == b'\x00':
return

def get_range(data_range):
return data[data_range[0] - READ_DATA_START:data_range[0] + data_range[1] - READ_DATA_START]
completed_game = get_range(COMPLETED_GAME)
battlefield_data = get_range(BATTLEFIELD_DATA)
game_flags = get_range(GAME_FLAGS)

if game_flags is None:
return
if not get_flag(game_flags, IN_GAME_FLAG):
return

if not ctx.finished_game:
if completed_game[0] & 0x80 and game_flags[30] & 0x18:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True

old_locations_checked = ctx.locations_checked.copy()

for container in range(256):
if get_flag(game_flags, (0x20 * 8) + container):
ctx.locations_checked.add(offset["Chest"] + container)

for location, data in NPC_CHECKS.items():
if get_flag(game_flags, data[0]) is data[1]:
ctx.locations_checked.add(location)

for battlefield in range(20):
if battlefield_data[battlefield] == 0:
ctx.locations_checked.add(offset["BattlefieldItem"] + battlefield + 1)

if old_locations_checked != ctx.locations_checked:
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": ctx.locations_checked}])

if received[0] == 0:
received_index = int.from_bytes(received[1:], "big")
if received_index < len(ctx.items_received):
item = ctx.items_received[received_index]
received_index += 1
code = (item.item - ITEM_CODE_START) + 1
if code > 256:
code -= 256
snes_buffered_write(ctx, RECEIVED_DATA[0], bytes([code, *received_index.to_bytes(2, "big")]))
await snes_flush_writes(ctx)
Loading

0 comments on commit f54f862

Please sign in to comment.