From 20488e012f4c421990c1cd077c24191fffdc55bf Mon Sep 17 00:00:00 2001 From: ilona Date: Mon, 7 Oct 2024 14:30:50 +0200 Subject: [PATCH 1/6] feat(save): added initial documentation of the lt1_eu save format, + a rudimentary parser --- doc/sample_file.sav | Bin 0 -> 8192 bytes doc/savefile.md | 103 ++++++++ formats/savefile/__init__.py | 0 formats/savefile/lt1_eu.py | 458 +++++++++++++++++++++++++++++++++++ formats/savefile/test.py | 10 + 5 files changed, 571 insertions(+) create mode 100644 doc/sample_file.sav create mode 100644 doc/savefile.md create mode 100644 formats/savefile/__init__.py create mode 100644 formats/savefile/lt1_eu.py create mode 100644 formats/savefile/test.py diff --git a/doc/sample_file.sav b/doc/sample_file.sav new file mode 100644 index 0000000000000000000000000000000000000000..7db58681b80c7edc2777341213a07e8280a5287f GIT binary patch literal 8192 zcmZ2)5#H_?;^^z<8RY8Z>f_@V$-uzC$iNViU!Gc|5RzY@;Fgo0Uj!5+4fy#hxB$&1 zO%sx8O7&YInZ=F7ZpXpS%*oG7M3MtB8^1L7H&1S^Zv29fz$OL^E;#r<3PwX<5Qadv zA2gLR%P}ypQvjGz^fIGxVH_Y0Vjc!71+hRFLI5eS9vwfFH`Bce5kiHgIEJ%8LC%6)Jj0LSS;Xv!yFL8 zFkt}`3(zV1ni(0`z)~w1AHY-n2YZJ9%s^=j@ZmpP4Tc=1fC3gHbTO5oiv0i2&#+Ad zBl^Ix$-v0O%)-jX&cVsW&BM#bFCZu+jH(;LgCtp~8DI)14lw}|`k=H2iwAIYz!C@v zaf8ibs2EWZ{0~%0+ka!wLp "PuzzleFlags": + hints = (data & 8 != 0, data & 0x10 != 0, data & 0x20 != 0) + if (hints[0] or not hints[1]) and (hints[1] or not hints[2]): + hints = 3 if hints[2] else 2 if hints[1] else 1 if hints[0] else 0 + return PuzzleFlags( + discovered=data & 1 != 0, + solved=data & 2 != 0, + unk=data & 4 != 0, + hints_unlocked=hints, + fail_count=(data & 0xC0) >> 6, + ) + + +@dataclass +class StoryStateFlags: + dog_unlocked: bool = False + """offset 500""" + painting_unlocked: bool = False + """offset 501""" + hotelroom_unlocked: bool = False + """offset 502""" + + puzzle_at_nazobaba: Mapping[int, bool] = field(default_factory=dict) + """ + Set true when a puzzle was sent to Nazobaba (Granny Riddleton). + offset 800 + + + TODO: put in the puzzle flags somehow + """ + pass + + +class HotelroomPlacement(IntEnum): + LAYTON = 0 + LUKE = 1 + UNPLACED = 2 + UNDISCOVERED = 3 # TODO: unsure + + +@dataclass +class Savefile: + filename: str = "♪ワンボ♪" + """ + The filename chosen by the player. Max length 20. + For empty files, replaced with "♪ワンボ♪" in SHIFT-JIS + (almost certainly a remnant from the Japanese version) + """ + cur_location: str = "NO DATA" + """ + The current location of the savefile, displayed on the file select screen. + Can be set to any nonsense text, and need not match the current room ID, + but will be corrected on next save. Max length 63. + For empty files, replaced with "NO DATA" + """ + playtime: int = 0 + """ + Playtime in seconds, used internally. + """ + playtime_simple: Tuple[int, int] = (0, 0) + """ + The simplified playtime, split in (hours, minutes), for direct display + on the file select screen. Can be set to nonsense that doesn't match + `playtime`, but will be corrected on next save. + """ + puzzles_discovered: int = 0 + """ + Number of discovered puzzles, for display on the file select screen + """ + puzzles_solved: int = 0 + """ + Number of solved puzzles, for display on the file select screen + """ + game_cleared: bool = False + """ + Whether the player has finished the game on this file. + """ + + cur_room_id: int = 0 + """ + The current room ID when saving, determines where the player spawns + back in. + """ + cur_puzzle_id: int = 0 + """ + Internal state that doesn't actually belong in a savefile: + the last solved/attempted/relevant puzzle when saving + """ + cur_puzzle_solved: bool = False + """ + Internal state that doesn't actually belong in a savefile: + flag set by the puzzle engine and used by the grader + """ + cur_puzzle_retry: bool = False + """ + Internal state that doesn't actually belong in a savefile: + flag set to true when the user decides to restart a puzzle after + grading is done (set by user to "Try again") + """ + cur_puzzle_aborted: bool = False + """ + Internal state that doesn't actually belong in a savefile: + flag set to true if the user has quit out of the puzzle. Used + by the puzzle framework to skip grading. + """ + cur_script_type: ScriptType = ScriptType.RESET + """ + The current script type; determines to what state the game will return + after loading a save. Normally this is ROOM, but one could try if setting + to (eg.) PUZZLE would work as intended. + """ + return_script_type: ScriptType = ScriptType.RESET + """ + For self-contained engines like puzzles or event movies: + the script type that the game should return to once that engine completes. + This allows a puzzle to either return to the event that started it, or the + puzzle index, or the wifi puzzle list etc. + """ + puzzle_win_img: int = 0 + """ + Internal state that doesn't actually belong in a savefile: + to my understanding, used to determine which image ID is shown + on the grading screen if the puzzle is correct + """ + puzzle_flags: Mapping[int, PuzzleFlags] = field( + default_factory=lambda: {i: PuzzleFlags() for i in range(256)} + ) + + cur_event: int = 0 + """ + The current event ID when saving, normally the last event played + right before (or still in progress if the save dialog has been triggered + by an event). If `cur_script_type` was EVENT, this event would (likely) + begin right after loading the savefile and watching the recap (unless + the recap messes up these values) + """ + event_flags: Mapping[int, bool] = field( + default_factory=lambda: {i: False for i in range(480)} + ) + """ + Array of flags, seemingly one for every event. Potentially as simple as + "has this event been seen before?" + There are 480 entries, as many as the game handles scripts. + """ + hint_coins: int = 0 + """ + Number of hint coins. Because you can spend hint coins, this is + tracked separately from the flags for which specific coins were found. + """ + + inventory: Annotated[list[int], 8] = field(default_factory=list) + """ + A list of (at most 8) item IDs which are shown to be collected in + the trunk. + """ + + story_flags: StoryStateFlags = field(default_factory=StoryStateFlags) + """ + Various story-state related flags are collected here. Collecting + all of their meanings is WIP. + """ + + cur_objective: int = 0 + """ + The ID of the current objective/mission, as displayed on the top + screen banner. + """ + dog_parts_backlog: list[int] = field(default_factory=list) + """ + List of dog part item IDs that were collected, but not yet placed. + Used in the minigame to show these parts in collection order. + """ + dog_parts_placed: Mapping[int, bool] = field( + default_factory=lambda: {i: False for i in range(32)} + ) + """ + Flags for each of the dog parts: set true if the part was placed. + """ + puzzle_pieces_obtained: Mapping[int, bool] = field( + default_factory=lambda: {i: False for i in range(20)} + ) + """ + Records for each puzzle piece whether it was obtained already + """ + puzzle_pieces_location: Mapping[int, int] = field( + default_factory=lambda: {i: 0xFF for i in range(20)} + ) + """ + The slot in which each puzzle piece was placed, if any. + TODO: what's the value for unplaced pieces? + """ + + hotelroom_items: Mapping[int, HotelroomPlacement] = field( + default_factory=lambda: {i: HotelroomPlacement.UNDISCOVERED for i in range(32)} + ) + """ + Where each specific hotelroom item is placed. + """ + + news_journal: bool = False + """ + Whether the Journal in the trunk should have the NEW badge + """ + news_dog: bool = False + """ + Whether the Dog/Gizmo minigame should have the NEW badge + """ + news_hotelroom: bool = False + """ + Whether the hotelroom minigame should have the NEW image + """ + news_painting: bool = False + """ + Whether the painting minigame should have the NEW badge + """ + news_mysteries: bool = False + """ + Whether the Mysteries category in the trunk should have the NEW badge + """ + journal_entries_unread: Mapping[int, bool] = field( + default_factory=lambda: {i: False for i in range(64)} + ) + """ + For each of the 45 journal entries, whether each one of them + is unread. + """ + dog_name: str = "" + """ + The name given to the dog after completing the Gizmo minigame + """ + + unk1: c_byte = 0 + unk2: c_byte = 0 + unk3: c_byte = 0 + unk4: c_byte = 0 + unk5: c_int = 0 + unk6: c_short = 0 + unk7: Annotated[list[c_byte], 64] = field(default_factory=list) + unk8: Annotated[list[c_byte], 64] = field(default_factory=list) + unk9: c_int = 0 + unk10: Annotated[list[c_byte], 32] = field(default_factory=list) + unk_bitfield2: Mapping[int, bool] = field(default_factory=dict) + unk11: c_int = 0 + unk12: c_int = 0 + + +@dataclass +class SaveBonusData: + pass + + +@dataclass +class Savedata: + """ + The entire save data written by the game + + Magic Word: "ATAMFIREBELLNY" + """ + + files: Annotated[list[Optional[Savefile]], 3] = field( + default_factory=lambda: [None, None, None] + ) + cur_file: int = 0 + bonus_data: SaveBonusData = field(default_factory=SaveBonusData) + + +def read_savedata(raw: bytes) -> Savedata: + data = Savedata() + if raw[4 : 4 + 14] != b"ATAMFIREBELLNY": + raise ValueError("Input buffer is not a valid LT1_EU savefile (magic mismatch)") + + hash = int.from_bytes(raw[0:4], "little") + actual_hash = compute_hash(raw[4:0x150]) + if hash != actual_hash: + print("WARN: input savefile had invalid global hash; will be accepted anyway") + + data.cur_file = raw[0x14] + + for id in range(3): + read_file(raw, id, data) + + read_bonus(raw, data) + pass + + +def read_file(raw: bytes, id: int, data: Savedata): + file_present = raw[0x15 + id] != 0 + if not file_present: + data.files[id] = None + return + file = Savefile() + + # Header block + raw_filename = raw[0x108 + 20 * id : 0x108 + 20 * (id + 1)] + dec_filename = decode_str(raw_filename) + if dec_filename is None: + print("WARN: filename is not a valid encoding") + file.filename = dec_filename + + raw_location = raw[0x18 + 64 * id : 0x18 + 64 * (id + 1)] + dec_location = decode_str(raw_location) + if dec_location is None: + print("WARN: current location name is not a valid encoding") + file.cur_location = dec_location + + file.playtime_simple = ( + int.from_bytes(raw[0xE4 + id * 4 : 0xE4 + (id + 1) * 4], "little"), + int.from_bytes(raw[0xD8 + id * 4 : 0xD8 + (id + 1) * 4], "little"), + ) + file.puzzles_discovered = int.from_bytes( + raw[0xF0 + id * 4 : 0xF0 + (id + 1) * 4], "little" + ) + file.puzzles_solved = int.from_bytes( + raw[0xFC + id * 4 : 0xFC + (id + 1) * 4], "little" + ) + + file.game_cleared = raw[0x144 + id] != 0 + + # File body block + block_offset = 1000 + 2000 * id + block = raw[block_offset : block_offset + 2000] + hash = int.from_bytes(block[0x538 : 0x538 + 4], "little") + actual_hash = compute_hash(block[:0x538]) + if hash != actual_hash: + print(f"WARN: input savefile {id+1} has invalid hash; will be accepted anyway") + + file.cur_puzzle_id = block[0x0] + file.cur_room_id = block[0x1] + file.cur_puzzle_solved = block[0x2] + file.cur_puzzle_retry = block[0x3] + file.cur_puzzle_aborted = block[0x4] + file.unk1 = block[0x5] + file.unk2 = block[0x6] + file.unk3 = block[0x7] + file.puzzle_win_img = block[0x8] + file.cur_script_type = block[0x9] + file.return_script_type = block[0xA] + file.unk4 = block[0xB] + + file.puzzle_flags = { + i: PuzzleFlags.from_byte(b) for i, b in enumerate(block[0xC:0x10C]) + } + + file.unk5 = int.from_bytes(block[0x10C:0x110], "little") + file.cur_event = int.from_bytes(block[0x110:0x114], "little") + file.event_flags = {i: b != 0 for i, b in enumerate(block[0x114:0x2F4])} + file.unk6 = int.from_bytes(block[0x2F4:0x2F6], "little") + file.unk7 = list(block[0x2F6:0x336]) + file.unk8 = list(block[0x336:0x376]) + + file.hint_coins = int.from_bytes(block[0x376:0x378], "little") + file.playtime = int.from_bytes(block[0x378:0x380], "little") + file.inventory = list(block[0x380:0x388]) + + read_storyflags(block, file) + + file.cur_objective = block[0x408] + file.dog_parts_backlog = list(block[0x409:0x429]) + file.dog_parts_placed = { + i: b != 0 for i, b in enumerate(expand_flags(block[0x429:0x42D])) + } + file.unk9 = int.from_bytes(block[0x42D:0x431], "little") + file.puzzle_pieces_obtained = {i: b != 0 for i, b in enumerate(block[0x431:0x445])} + file.puzzle_pieces_location = {i: v for i, v in enumerate(block[0x445:459])} + + hotelroom_locations = [None for _ in range(32)] + for i in range(32): + for loc in range(4): + if block[0x459 + i + 32 * loc] != 0: + if hotelroom_locations[i] is not None: + print(f"WARN: hotelroom item {i} assigned to multiple locations") + hotelroom_locations[i] = loc + file.hotelroom_items = hotelroom_locations + + # ?????? + + file.news_journal = block[0x4ED] + file.news_dog = block[0x4EE] + file.news_hotelroom = block[0x4EF] + file.news_painting = block[0x4F0] + file.news_mysteries = block[0x4F1] + + file.unk10 = list(block[0x4F2:0x512]) + + file.journal_entries_unread = { + i: b != 0 for i, b in enumerate(expand_flags(block[0x512:0x51A])) + } + + file.unk_bitfield2 = expand_flags(block[0x51A:0x51C]) + file.unk11 = int.from_bytes(block[0x51C:0x520], "little") + file.dog_name = decode_str(block[0x520:0x534]) + file.unk12 = int.from_bytes(block[0x534:0x538], "little") + + +def read_storyflags(block: bytes, data: Savefile): + data.story_flags = StoryStateFlags() + flags = expand_flags(block[0x388:0x408]) + data.story_flags.dog_unlocked = flags[500] + data.story_flags.painting_unlocked = flags[501] + data.story_flags.hotelroom_unlocked = flags[502] + + +def read_bonus(raw: bytes, data: Savedata): + hash = int.from_bytes(raw[0x1B58:0x1B5C], "little") + actual_hash = compute_hash(raw[0x1B5C:0x1BD0]) + if hash != actual_hash: + print( + "WARN: input savefile bonus section has invalid hash; will be accepted anyway" + ) + + # TODO + pass + + +def compute_hash(data: bytes) -> int: + pass + + +def decode_str(raw: bytes) -> str: + raw = raw.rstrip(b'\0') + encodings_to_try = ["utf8", "shift-jis"] + for enc in encodings_to_try: + try: + return raw.decode(enc) + except UnicodeDecodeError: + pass + return None + + +def expand_flags(raw: bytes) -> list[bool]: + return [b & (1 << i) != 0 for b in raw for i in range(8)] diff --git a/formats/savefile/test.py b/formats/savefile/test.py new file mode 100644 index 0000000..5c548d1 --- /dev/null +++ b/formats/savefile/test.py @@ -0,0 +1,10 @@ +from formats.savefile.lt1_eu import read_savedata + + +def test_lt1eu(file: str): + with open(file, 'rb') as f: + raw = f.read() + save = read_savedata(raw) + +if __name__ == "__main__": + test_lt1eu('doc/sample_file.sav') \ No newline at end of file From 205c74794d11d729bb0a3c7275a7944de056b7de Mon Sep 17 00:00:00 2001 From: ilona Date: Mon, 7 Oct 2024 16:06:49 +0200 Subject: [PATCH 2/6] fix(save): loaded information for each savefile actually gets stored in the main savedata --- formats/savefile/lt1_eu.py | 1 + 1 file changed, 1 insertion(+) diff --git a/formats/savefile/lt1_eu.py b/formats/savefile/lt1_eu.py index 1d001bc..86292fc 100644 --- a/formats/savefile/lt1_eu.py +++ b/formats/savefile/lt1_eu.py @@ -316,6 +316,7 @@ def read_file(raw: bytes, id: int, data: Savedata): data.files[id] = None return file = Savefile() + data.files[id] = file # Header block raw_filename = raw[0x108 + 20 * id : 0x108 + 20 * (id + 1)] From 682d6bf4c8f622bf942e2e3b7a8842c0e9c3fd04 Mon Sep 17 00:00:00 2001 From: ilonachan Date: Mon, 7 Oct 2024 19:47:38 +0200 Subject: [PATCH 3/6] fix(save): now it should work --- formats/savefile/lt1_eu.py | 70 ++++++++++++++++++++++---------------- formats/savefile/test.py | 4 +++ 2 files changed, 45 insertions(+), 29 deletions(-) diff --git a/formats/savefile/lt1_eu.py b/formats/savefile/lt1_eu.py index 86292fc..5f8281f 100644 --- a/formats/savefile/lt1_eu.py +++ b/formats/savefile/lt1_eu.py @@ -1,3 +1,4 @@ +import contextlib from dataclasses import dataclass, field from enum import IntEnum from typing import Mapping, Optional, Tuple, Annotated @@ -55,7 +56,6 @@ class StoryStateFlags: TODO: put in the puzzle flags somehow """ - pass class HotelroomPlacement(IntEnum): @@ -296,61 +296,62 @@ def read_savedata(raw: bytes) -> Savedata: if raw[4 : 4 + 14] != b"ATAMFIREBELLNY": raise ValueError("Input buffer is not a valid LT1_EU savefile (magic mismatch)") - hash = int.from_bytes(raw[0:4], "little") + read_hash = int.from_bytes(raw[:4], "little") actual_hash = compute_hash(raw[4:0x150]) - if hash != actual_hash: + if read_hash != actual_hash: print("WARN: input savefile had invalid global hash; will be accepted anyway") data.cur_file = raw[0x14] - for id in range(3): - read_file(raw, id, data) + for i in range(3): + read_file(raw, i, data) read_bonus(raw, data) - pass + + return data -def read_file(raw: bytes, id: int, data: Savedata): - file_present = raw[0x15 + id] != 0 +def read_file(raw: bytes, i: int, data: Savedata): + file_present = raw[0x15 + i] != 0 if not file_present: - data.files[id] = None + data.files[i] = None return file = Savefile() - data.files[id] = file + data.files[i] = file # Header block - raw_filename = raw[0x108 + 20 * id : 0x108 + 20 * (id + 1)] + raw_filename = raw[0x108 + 20 * i : 0x108 + 20 * (i + 1)] dec_filename = decode_str(raw_filename) if dec_filename is None: print("WARN: filename is not a valid encoding") file.filename = dec_filename - raw_location = raw[0x18 + 64 * id : 0x18 + 64 * (id + 1)] + raw_location = raw[0x18 + 64 * i : 0x18 + 64 * (i + 1)] dec_location = decode_str(raw_location) if dec_location is None: print("WARN: current location name is not a valid encoding") file.cur_location = dec_location file.playtime_simple = ( - int.from_bytes(raw[0xE4 + id * 4 : 0xE4 + (id + 1) * 4], "little"), - int.from_bytes(raw[0xD8 + id * 4 : 0xD8 + (id + 1) * 4], "little"), + int.from_bytes(raw[0xE4 + i * 4 : 0xE4 + (i + 1) * 4], "little"), + int.from_bytes(raw[0xD8 + i * 4 : 0xD8 + (i + 1) * 4], "little"), ) file.puzzles_discovered = int.from_bytes( - raw[0xF0 + id * 4 : 0xF0 + (id + 1) * 4], "little" + raw[0xF0 + i * 4 : 0xF0 + (i + 1) * 4], "little" ) file.puzzles_solved = int.from_bytes( - raw[0xFC + id * 4 : 0xFC + (id + 1) * 4], "little" + raw[0xFC + i * 4 : 0xFC + (i + 1) * 4], "little" ) - file.game_cleared = raw[0x144 + id] != 0 + file.game_cleared = raw[0x144 + i] != 0 # File body block - block_offset = 1000 + 2000 * id + block_offset = 1000 + 2000 * i block = raw[block_offset : block_offset + 2000] - hash = int.from_bytes(block[0x538 : 0x538 + 4], "little") + read_hash = int.from_bytes(block[0x538 : 0x538 + 4], "little") actual_hash = compute_hash(block[:0x538]) - if hash != actual_hash: - print(f"WARN: input savefile {id+1} has invalid hash; will be accepted anyway") + if read_hash != actual_hash: + print(f"WARN: input savefile {i+1} has invalid hash; will be accepted anyway") file.cur_puzzle_id = block[0x0] file.cur_room_id = block[0x1] @@ -389,7 +390,7 @@ def read_file(raw: bytes, id: int, data: Savedata): } file.unk9 = int.from_bytes(block[0x42D:0x431], "little") file.puzzle_pieces_obtained = {i: b != 0 for i, b in enumerate(block[0x431:0x445])} - file.puzzle_pieces_location = {i: v for i, v in enumerate(block[0x445:459])} + file.puzzle_pieces_location = dict(enumerate(block[0x445:459])) hotelroom_locations = [None for _ in range(32)] for i in range(32): @@ -429,29 +430,40 @@ def read_storyflags(block: bytes, data: Savefile): def read_bonus(raw: bytes, data: Savedata): - hash = int.from_bytes(raw[0x1B58:0x1B5C], "little") + read_hash = int.from_bytes(raw[0x1B58:0x1B5C], "little") actual_hash = compute_hash(raw[0x1B5C:0x1BD0]) - if hash != actual_hash: + if read_hash != actual_hash: print( "WARN: input savefile bonus section has invalid hash; will be accepted anyway" ) # TODO - pass def compute_hash(data: bytes) -> int: - pass + acc1 = 0xffff + acc2 = 0xffff + + for i in range(0, len(data), 360): + block = data[i : min(i + 360, len(data))] + + for j in range(0, len(block), 2): + val = int.from_bytes(block[j : j + 2], "little") + acc1 = (acc1 + val) & 0xffffffff + acc2 = (acc2 + acc1) & 0xffffffff + acc1 = (acc1 >> 16) + (acc1 & 0xffff) + acc2 = (acc2 >> 16) + (acc2 & 0xffff) + + return ((acc1 >> 16) + (acc1 & 0xffff) | ((acc2 >> 16) + (acc2 & 0xffff)) << 16) & 0xffffffff + def decode_str(raw: bytes) -> str: raw = raw.rstrip(b'\0') encodings_to_try = ["utf8", "shift-jis"] for enc in encodings_to_try: - try: + with contextlib.suppress(UnicodeDecodeError): return raw.decode(enc) - except UnicodeDecodeError: - pass return None diff --git a/formats/savefile/test.py b/formats/savefile/test.py index 5c548d1..a96adb2 100644 --- a/formats/savefile/test.py +++ b/formats/savefile/test.py @@ -1,10 +1,14 @@ +from dataclasses import asdict from formats.savefile.lt1_eu import read_savedata +import yaml def test_lt1eu(file: str): with open(file, 'rb') as f: raw = f.read() save = read_savedata(raw) + with open(f"{file}.yml", 'w', encoding="utf-8") as f: + yaml.safe_dump(asdict(save), f, sort_keys=False) if __name__ == "__main__": test_lt1eu('doc/sample_file.sav') \ No newline at end of file From d1fd1af5d574a7528ab0767bc315fcb075839b61 Mon Sep 17 00:00:00 2001 From: ilonachan Date: Wed, 9 Oct 2024 22:40:18 +0200 Subject: [PATCH 4/6] doc(save): catalogued more save fields; wifi puzzle format is fully understood! --- doc/savefile.md | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/doc/savefile.md b/doc/savefile.md index cf6bb13..f2d917a 100644 --- a/doc/savefile.md +++ b/doc/savefile.md @@ -51,12 +51,12 @@ If a file is not present, this region is filled with 0xff instead. # -----1--: ? (seems to always be set together with solved, at least for story puzzles) # --111---: least to most significant: hints 1-3 unlocked # 11------: number of previous failed attempts -0x10c w: ? +0x10c w: is the current script executed right after a puzzle ended? (nonsense in a savefile) 0x110 w: current event -0x114 b[480]: event flags +0x114 b[480]: event flags (8bit[]) 0x2f4 hw: ? -0x2f6 b[64]: ? -0x336 b[64]: ? +0x2f6 b[64]: engine played before? (bool[]) +0x336 b[64]: hint coins collected (bitfield) 0x376 hw: hint coint count 0x378 dw: playtime (in seconds) 0x380 b[8]: inventory @@ -65,26 +65,26 @@ If a file is not present, this region is filled with 0xff instead. # puzzleid+800: puzzle sent to nazobaba 0x408 b: current objective 0x409 b[32]: dog collected (but not placed) part list -0x429 b[4]: dog part placed flags +0x429 b[4]: dog part placed? (bitfield) 0x42d w: ? 0x431 b[20]: puzzle pieces obtained? (list of bool) 0x445 b[20]: puzzle pieces placed slot??? -0x459 b[32]: hotelroom layton -0x479 b[32]: hotelroom luke -0x499 b[32]: ? -0x4b9 b[32]: ? -????????? +0x459 b[20]: puzzle pieces ??? +0x46d b[32]: hotelroom layton +0x48d b[32]: hotelroom luke +0x4ad b[32]: ? +0x4cd b[32]: ? 0x4ed b: journal has news? 0x4ee b: dog has news? 0x4ef b: hotelroom has news? 0x4f0 b: painting has news? 0x4f1 b: mysteries has news? -0x4f2 b[32]: ? +0x4f2 b[32]: puzzle favorites (bitfield) 0x512 b[8]: journal entry unread flags 0x51a b[2]: bitfield 2 ??? -0x51c w: ? +0x51c w: recap event ID 0x520 b[20]: dogname -0x534 w: ? +0x534 w: clues given by dog 0x538 w: hash of all previous data # ~0x53c to ~0x7d0: padding 0 @@ -97,7 +97,17 @@ If a file is not present, this region is filled with 0xff instead. ``` 0x1b58 w: hash of the following data -0x1b5c ???? +0x1b5c w: number of downloadable puzzles +0x1b60 w: number of downloadable puzzles (again???) + # I believe one of these values stores how many puzzle have been downloaded, + # while the other stores how many WILL ever be downloadable. We can't really + # test the distinction now, because the game service is down... maybe the + # program that simulates it knows what these mean. +0x1b64 struct[27]: Wifi puzzles + ~0x0 b: puzzle ID (yes all the downloadable puzzles are already in the files, and unlocked by ID) + ~0x1 b: date of release: day + ~0x2 b: date of release: month + ~0x3 b: date of release: year (relative to 2000) ``` **Padding:** 0x1bd0 to 0x2000, filled with 0xff \ No newline at end of file From 233cf88fa4a87ecb8132ea84ab925603ecdee0a0 Mon Sep 17 00:00:00 2001 From: ilonachan Date: Sun, 13 Oct 2024 19:27:02 +0200 Subject: [PATCH 5/6] doc(save): catalogued all save fields that I could in some way find the relevance for. It's possible some are still used in functions that aren't even recognized as code yet, but for now I'll have to assume this is fairly complete. --- doc/savefile.md | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/doc/savefile.md b/doc/savefile.md index f2d917a..d0521ec 100644 --- a/doc/savefile.md +++ b/doc/savefile.md @@ -41,10 +41,10 @@ If a file is not present, this region is filled with 0xff instead. 0x005 ? 0x006 ? 0x007 ? -0x008 b: puzzle win image??? +0x008 b: puzzle win image 0x009 b: current script type 0x00a b: return script type (when script type is eg PUZZLE, a puzzle will begin, and when it ends this type will be started) -0x00b ? +0x00b b: move mode (whether or not the boot was pressed and the exits shown. They were not.) 0x00c b[256]: puzzle flags # -------1: discovered # ------1-: solved @@ -54,22 +54,31 @@ If a file is not present, this region is filled with 0xff instead. 0x10c w: is the current script executed right after a puzzle ended? (nonsense in a savefile) 0x110 w: current event 0x114 b[480]: event flags (8bit[]) -0x2f4 hw: ? + # 257: hotelroom completed + # 258: painting completed + # 259: game completed +0x2f4 hw: global story progress?? Chapter number?? 0x2f6 b[64]: engine played before? (bool[]) 0x336 b[64]: hint coins collected (bitfield) 0x376 hw: hint coint count 0x378 dw: playtime (in seconds) 0x380 b[8]: inventory -0x388 b[128]: bitfield 1??? - # 0x1f4-0x1f6: dog/painting/hotelroom unlocked +0x388 b[128]: various story flags + # 500-502: dog/painting/hotel unlocked + # 506: challenges unlocked (probably by clearing the game or any minigame) + # 507-509: dog/hotel/painting completed + # 511: All puzzles except the last Layton's Challenges complete + # 512: game cleared # puzzleid+800: puzzle sent to nazobaba + # 300-319: mystery discovered + # 320-339: mystery solved 0x408 b: current objective 0x409 b[32]: dog collected (but not placed) part list 0x429 b[4]: dog part placed? (bitfield) -0x42d w: ? +0x42d w: picarat count 0x431 b[20]: puzzle pieces obtained? (list of bool) -0x445 b[20]: puzzle pieces placed slot??? -0x459 b[20]: puzzle pieces ??? +0x445 b[20]: puzzle pieces placed slot +0x459 b[20]: puzzle pieces placed rotation 0x46d b[32]: hotelroom layton 0x48d b[32]: hotelroom luke 0x4ad b[32]: ? @@ -81,7 +90,7 @@ If a file is not present, this region is filled with 0xff instead. 0x4f1 b: mysteries has news? 0x4f2 b[32]: puzzle favorites (bitfield) 0x512 b[8]: journal entry unread flags -0x51a b[2]: bitfield 2 ??? +0x51a b[2]: mysteries unread? (bitfield) 0x51c w: recap event ID 0x520 b[20]: dogname 0x534 w: clues given by dog From e735f2d500b4f1a9681f75b54e99edc6f673b947 Mon Sep 17 00:00:00 2001 From: ilonachan Date: Sun, 13 Oct 2024 22:39:27 +0200 Subject: [PATCH 6/6] doc: full documentation of every room in the game. Technical details like location ID should be obtainable by the room scripts too, but context information like which rooms belong to the postgame is valuable. TODO: Analyse/visualize room layout and connections in a graph automatically --- doc/rooms.csv | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 doc/rooms.csv diff --git a/doc/rooms.csv b/doc/rooms.csv new file mode 100644 index 0000000..9ab1888 --- /dev/null +++ b/doc/rooms.csv @@ -0,0 +1,120 @@ +Room ID,Name,Location ID,Variant,Description,Exits to,Image variants +1,Drawbridge,1,,First room in the game,4, +2,Village Square,2,,The clock tower area,"3, 4, 5, 6, 16, 87",some cut content +3,Manor Road,3,,"Right of the Village Square, where Marco stands","2, 7, 11", +4,Entrance,4,,"Right after the Drawbridge, where the Inn is","1, 2, 8, 15", +5,Park Road,5,,"Left of the Village Square, where the angry guy stands","2, 9, 10", +6,Village Hall,6,,Right door on the Village Square,2, +7,Village Shop,7,,Blue door on Manor Road,3, +8,Inn,8,,,"4, 23", +9,Restaurant,9,,"On Park Road, where Crouton works",5,~n: nighttime (cut content) +10,Park Gate,10,,,5, +11,Manor Border,11,,The river where Ramon stands,"3, 14", +12,Manor Foyer,12,,,"13, 14",~_o: bookshelf opened +13,Manor Parlour,13,,,12,~n: nighttime (cut content) +14,Reinhold Manor,14,,Before the entrance,"12, 11", +15,Drawbridge,15,Let down,Only very briefly visible: drawbridge let down,4, +16,Clock Tower,16,,Behind it,"2, 17", +17,Fork in the Road,17,,,"16, 18, 19", +18,Market,18,,Left of the Fork in the Road,"17, 20", +19,Northern Path,19,,Right of the Fork in the Road,"17, 20", +20,Northern Hill,20,,,"18, 19", +21,Village Square,2,Nighttime,,"22, 23, 24, 26, 87", +22,Manor Road,3,Nighttime,,"11, 21", +23,Entrance,4,Nighttime,,"8, 21", +24,Park Road,5,Nighttime,,"21, 25", +25,Park Gate,10,Nighttime,,24, +26,Clock Tower,16,Nighttime,,"21, 27", +27,Fork in the Road,17,Nighttime,,"26, 28, 29, 31", +28,Market,18,Nighttime,,"27, 30", +29,Northern Path,19,Nighttime,,"27, 30, 32", +30,Northern Hill,20,Nighttime,,"28, 29", +31,Crumm's Cafe,31,,On the Fork in the Road,27, +32,Prosciutto's,32,,On the Northern Path,29, +33,Archibald's,33,,On the Clock Tower lil plaza thing,48, +34,Village Square,2,Day 2,,"35, 36", +35,Manor Road,3,Day 2,,"34, 39, 43", +36,Entrance,4,Day 2,,"34, 40", +37,Park Road,5,Day 2,,"34, 41, 42", +38,Village Hall,6,Day 2,,34, +39,Village Shop,7,Day 2,,35, +40,Inn,8,Day 2/?,,"36, 96", +41,Restaurant,9,Day 2,,37, +42,Park Gate,10,Day 2,,"37, 53","~_o: gate open, ~_o2: gate open and ferris wheel destroyed" +43,Manor Border,11,Day 2,What happens if you define the same exit twice?,"35, 46", +44,Manor Foyer,12,Day 2,,"45, 46", +45,Manor Parlour,13,Day 2,,"44, 116", +46,Reinhold Manor,14,Day 2,,"43, 44", +47,Drawbridge,1,Day 2,,36, +48,Clock Tower,16,Day 2,,"33, 34, 49", +49,Fork in the Road,17,Day 2,"room_49_bg is a 6x4 grid of squares, seems to be technical","48, 50, 51", +50,Market,18,Day 2,,"49, 52, 64", +51,Northern Path,19,Day 2,,"49, 52, 118", +52,Northern Hill,20,Day 2,,"50, 51", +53,Park Path Fork,53,,First room in the park,"54, 56, 59, 102",b_~: destroyed +54,Abandoned Tent,54,,Left of the Park Path Fork,"53, 55", +55,Ferris Wheel,55,,,"54, 57", +56,Shack Path,56,,,"53, 57, 60",b_~: destroyed +57,Waterside Shack,57,,,"55, 56", +58,Ferris Wheel,55,Destroyed,,"59, 60",room_58_bg +59,Abandoned Tent,54,Destroyed,,"53, 58",room_59_bg +60,Waterside Shack,57,Destroyed,,"56, 58, 61",room_60_bg +61,Under the Shack,61,,,"60, 62", +62,Underground Path,62,,,"61, 63", +63,Underground Area,63,,The final room under the Waterside Shack,62, +64,Path Entrance,64,,Behind the Market,"65, 110", +65,Tower Road,65,,,"64, 66, 67", +66,Dead End,66,,Before opening,65, +67,Dead End,66,Opened,,"65, 68",room_67_bg +68,Tower Entrance,68,,The Gate at the bottom,"67, 70", +69,Tower Cellar,69,,The room where you crash land and meet Bruno,70, +70,Tower Floor 1,70,,"Ground level, for non-americans. Right behind the Entrance.","68, 69, 71", +71,Tower Floor 2,71,,,"70, 72", +72,Tower Floor 3,72,,,"71, 73", +73,Tower Floor 4,73,,,"72, 74", +74,Tower Floor 5,74,,,"73, 75", +75,Tower Floor 6,75,,,"74, 76", +76,Tower Floor 7,76,,,"75, 77", +77,Spiral Staircase,77,,,"76, 78", +78,Tower Top Floor,78,,,"77, 79", +79,Flora's Room,79,,Can never actually be visited as a room in the game; the in-script does all the work,, +80,Tower Top Floor,78,???,,"77, 79", +81,Village Square,2,Epilogue,"Never actually visitable, but copies the default room image. The special image is the setting of the epilogue, with the tower gone.",87, +82,,---,,,, +83,,---,,"The Reinhold Fortune treasure room, but only as an image.",, +84,Archibald's,33,Desk,A close-up of Archibald's desk,33, +85,Manor Parlour,85,???,,"12, 89",room_13_bg +86,,---,,"Viola's Grave. This isn't actually a room, but the image being called this implies that it was at some point.",, +87,Granny R.'s,87,,"Granny Riddleton's Cottage, outside (Nazobaba)","2, 21, 34, 88, 94", +88,Puzzle Shack,88,,Inside of Granny Riddleton's Cottage,87, +89,Dahlia's Room,89,,,"13, 45, 85", +90,Sewer,90,,Down the manhole on Park Road,"5, 91, 92, 97", +91,Sewer Dead End,91,,Right of the Sewer entrance,90, +92,Sewer Exit,92,,Left of the Sewer entrance,"90, 93", +93,Outside the Sewer,93,,"Behind Sewer Exit, different view on the Laytonmobile",92, +94,Village Square,2,Postgame,,"87, 95, 96, 97, 98, 108", +95,Manor Road,3,Postgame,,"94, 99, 103", +96,Entrance,4,Postgame,,"94, 100, 107", +97,Park Road,5,Postgame,,"90, 94, 101, 102", +98,Village Hall,6,Postgame,,94, +99,Village Shop,7,Postgame,"room_99_bg is a 6x4 grid of squares, seems to be technical",95, +100,Inn,8,Postgame,,96, +101,Restaurant,9,Postgame,,97, +102,Park Gate,10,Postgame,,"53, 97", +103,Manor Border,11,Postgame,,"95, 106", +104,Manor Foyer,12,Postgame,,"105, 106", +105,Manor Parlour,13,Postgame,,"89, 104, 116", +106,Reinhold Manor,14,Postgame,,"103, 104", +107,Drawbridge,1,Postgame,,96, +108,Clock Tower,16,Postgame,,"94, 109, 115", +109,Fork in the Road,17,Postgame,,"108, 110, 111, 113", +110,Market,18,Postgame,,"64, 109, 112", +111,Northern Path,19,Postgame,,"109, 112, 114", +112,Northern Hill,20,Postgame,,"110, 111", +113,Crumm's Cafe,31,Postgame,,109, +114,Prosciutto's,32,Postgame,,111, +115,Archibald's,33,Postgame,,108, +116,Dahlia's Room,89,Day 2/Postgame,,"13, 45, 85, 105", +117,Crumm's Cafe,31,???,Cafe might have been open on Day 2 at some point,49, +118,Prosciutto's,32,Day 2,,51, +119,Flora's Portrait,119,,Used for the final minigame