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 diff --git a/doc/sample_file.sav b/doc/sample_file.sav new file mode 100644 index 0000000..7db5868 Binary files /dev/null and b/doc/sample_file.sav differ diff --git a/doc/savefile.md b/doc/savefile.md new file mode 100644 index 0000000..d0521ec --- /dev/null +++ b/doc/savefile.md @@ -0,0 +1,122 @@ +# Savefile Format documentation (for LT1 EU) + +**Total length:** 0x2000 (8192) + +## Header + +**Starts at:** 0 +**Length:** 0x150 (336) + +``` +0x000 w: hash of the following data +0x004 b[14]: magic word "ATAMFIREBELLNY" +0x012 ?x2 +0x014 b: current / last saved file +0x015 b[3]: file present? (bool) +0x018 b[64][3]: current location name (or "NO DATA") +0x0d8 w[3]: playtimeMinutes +0x0e4 w[3]: playtimeHours +0x0f0 w[3]: puzzlesDiscovered +0x0fc w[3]: puzzlesSolved +0x108 b[20][3]: savefile name (or 0x81f4838f8393837b81f4, which decodes to "♪ワンボ♪" in SHIFT-JIS) +0x144 b[3]: savefile cleared? (bool) +0x147 ?x9 +``` + +**Padding:** 0x150 to 0x3e8, filled with 0xff + +## Savefiles + +**Starts at:** 0x3e8 (1000), 0xbb8 (3000), 0x1388 (5000) +**Length:** 0x7d0 (2000) + +If a file is not present, this region is filled with 0xff instead. + +``` +0x000 b: current puzzle ID (internal) +0x001 b: current room ID +0x002 b: current puzzle solved? (bool) +0x003 b: current puzzle should be retried? (bool) +0x004 b: current puzzle was aborted without solving? (bool) +0x005 ? +0x006 ? +0x007 ? +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 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 + # -----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: is the current script executed right after a puzzle ended? (nonsense in a savefile) +0x110 w: current event +0x114 b[480]: event flags (8bit[]) + # 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]: 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: picarat count +0x431 b[20]: puzzle pieces obtained? (list of bool) +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]: ? +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]: puzzle favorites (bitfield) +0x512 b[8]: journal entry unread flags +0x51a b[2]: mysteries unread? (bitfield) +0x51c w: recap event ID +0x520 b[20]: dogname +0x534 w: clues given by dog +0x538 w: hash of all previous data + +# ~0x53c to ~0x7d0: padding 0 +``` + +## Postfix + +**Starts at:** 0x1b58 (7000) +**Length:** 0x78 (120) + +``` +0x1b58 w: hash of the following data +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 diff --git a/formats/savefile/__init__.py b/formats/savefile/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/formats/savefile/lt1_eu.py b/formats/savefile/lt1_eu.py new file mode 100644 index 0000000..5f8281f --- /dev/null +++ b/formats/savefile/lt1_eu.py @@ -0,0 +1,471 @@ +import contextlib +from dataclasses import dataclass, field +from enum import IntEnum +from typing import Mapping, Optional, Tuple, Annotated +from ctypes import c_byte, c_int, c_short + + +class ScriptType(IntEnum): + RESET = 0 + PUZZLE = 1 + ROOM = 2 + + +@dataclass +class PuzzleFlags: + discovered: bool = False + solved: bool = False + unk: bool = False + hints_unlocked: int | Tuple[bool, bool, bool] = 0 + """ + Normally when a hint is unlocked, all previous ones are also unlocked; + but it may technically be possible for that not to be the case. + """ + fail_count: int = 0 + """ + Counts up to 3 + """ + + @classmethod + def from_byte(cls, data: c_byte) -> "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 + """ + + +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)") + + read_hash = int.from_bytes(raw[:4], "little") + actual_hash = compute_hash(raw[4:0x150]) + if read_hash != actual_hash: + print("WARN: input savefile had invalid global hash; will be accepted anyway") + + data.cur_file = raw[0x14] + + for i in range(3): + read_file(raw, i, data) + + read_bonus(raw, data) + + return data + + +def read_file(raw: bytes, i: int, data: Savedata): + file_present = raw[0x15 + i] != 0 + if not file_present: + data.files[i] = None + return + file = Savefile() + data.files[i] = file + + # Header block + 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 * 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 + 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 + i * 4 : 0xF0 + (i + 1) * 4], "little" + ) + file.puzzles_solved = int.from_bytes( + raw[0xFC + i * 4 : 0xFC + (i + 1) * 4], "little" + ) + + file.game_cleared = raw[0x144 + i] != 0 + + # File body block + block_offset = 1000 + 2000 * i + block = raw[block_offset : block_offset + 2000] + read_hash = int.from_bytes(block[0x538 : 0x538 + 4], "little") + actual_hash = compute_hash(block[:0x538]) + 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] + 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 = dict(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): + read_hash = int.from_bytes(raw[0x1B58:0x1B5C], "little") + actual_hash = compute_hash(raw[0x1B5C:0x1BD0]) + if read_hash != actual_hash: + print( + "WARN: input savefile bonus section has invalid hash; will be accepted anyway" + ) + + # TODO + + +def compute_hash(data: bytes) -> int: + 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: + with contextlib.suppress(UnicodeDecodeError): + return raw.decode(enc) + 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..a96adb2 --- /dev/null +++ b/formats/savefile/test.py @@ -0,0 +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