def __init__(self, prefix, tracker_version, log_finder): self.state = TrackerState("", tracker_version, Options().game_version, "", "", -1) self.log = logging.getLogger("tracker") self.wdir_prefix = prefix self.log_finder = log_finder self.reset()
def __init__(self, prefix, tracker_version): self.state = TrackerState("", tracker_version, Options().game_version) self.log = logging.getLogger("tracker") self.file_prefix = prefix self.reset() # FIXME run summary. self.run_ended = 0
def __init__(self, prefix, tracker_version, log_finder): self.state = TrackerState("", tracker_version, Options().game_version) self.log = logging.getLogger("tracker") self.wdir_prefix = prefix self.log_finder = log_finder self.reset()
def __init__(self, prefix, tracker_version): self.state = TrackerState("", tracker_version) self.log = logging.getLogger("tracker") self.file_prefix = prefix self.game_version = "" self.__reset() # FIXME run summary. self.run_ended = 0
def run(self): """ The main routine which controls everything """ update_notifier = self.check_for_update() framecount = 0 # Create drawing tool to use to draw everything - it'll create its own screen drawing_tool = DrawingTool(self.file_prefix) drawing_tool.set_window_title(update_notifier) parser = LogParser(self.file_prefix, self.tracker_version) opt = Options() log = logging.getLogger("tracker") event_result = None state = None read_from_server = opt.read_from_server write_to_server = opt.write_to_server state_version = -1 twitch_username = None new_states_queue = [] screen_error_message = None while event_result != Event.DONE: # Check for events and handle them event_result = drawing_tool.handle_events() # A change means the user has (de)activated an option if opt.read_from_server != read_from_server\ or opt.twitch_name != twitch_username: twitch_username = opt.twitch_name read_from_server = opt.read_from_server new_states_queue = [] # Also restart version count if we go back and forth from log.txt to server if read_from_server: state_version = -1 state = None # show who we are watching in the title bar drawing_tool.set_window_title(update_notifier, watching_player=twitch_username, updates_queued=len(new_states_queue)) else: drawing_tool.set_window_title(update_notifier) if opt.write_to_server and opt.write_to_server != write_to_server: write_to_server = True drawing_tool.set_window_title(update_notifier, uploading=True) if not opt.write_to_server: write_to_server = False if opt.read_from_server: # Change the delay for polling, as we probably don't want to fetch it every second update_timer = 2 else: update_timer = self.read_timer if event_result == Event.OPTIONS_UPDATE: # By setting the framecount to 0 we ensure we'll refresh the state right away framecount = 0 screen_error_message = None # force updates after changing options if state is not None: state.modified = True # Now we re-process the log file to get anything that might have loaded; # do it every update_timer seconds (making sure to truncate to an integer # or else it might never mod to 0) if (framecount % int(Options().framerate_limit * update_timer) == 0): # Let the parser do his thing and give us a state if opt.read_from_server: base_url = opt.trackerserver_url + "/tracker/api/user/" + opt.twitch_name json_dict = None try: json_version = urllib2.urlopen(base_url + "/version").read() if int(json_version) > state_version: # FIXME better handling of 404 error ? json_state = urllib2.urlopen(base_url).read() json_dict = json.loads(json_state) new_state = TrackerState.from_json(json_dict) if new_state is None: raise Exception state_version = int(json_version) new_states_queue.append((state_version, new_state)) drawing_tool.set_window_title(update_notifier, watching_player=twitch_username, updates_queued=len(new_states_queue), read_delay=opt.read_delay) except Exception: state = None log.error("Couldn't load state from server") import traceback log.error(traceback.format_exc()) if json_dict is not None: their_version = "" if "tracker_version" in json_dict: their_version = json_dict["tracker_version"] else: # this is the only version that can upload to the server but doesn't include a version string their_version = "0.10-beta1" if their_version != self.tracker_version: screen_error_message = "They are using tracker version " + their_version + " but you have " + self.tracker_version else: force_draw = state and state.modified state = parser.parse() if force_draw: state.modified = True if write_to_server and not opt.trackerserver_authkey: screen_error_message = "Your authkey is blank. Get a new authkey in the options menu and paste it into the authkey text field." if state is not None and write_to_server and state.modified and screen_error_message is None: opener = urllib2.build_opener(urllib2.HTTPHandler) put_url = opt.trackerserver_url + "/tracker/api/update/" + opt.trackerserver_authkey json_string = json.dumps(state, cls=TrackerStateEncoder, sort_keys=True) request = urllib2.Request(put_url, data=json_string) request.add_header('Content-Type', 'application/json') request.get_method = lambda: 'PUT' try: result = opener.open(request) result_json = json.loads(result.read()) updated_user = result_json["updated_user"] if updated_user is None: screen_error_message = "The server didn't recognize you. Try getting a new authkey in the options menu." else: screen_error_message = None except Exception as e: import traceback errmsg = traceback.format_exc() log.error("ERROR: Couldn't send item info to server") log.error(errmsg) screen_error_message = "ERROR: Couldn't send item info to server, check tracker_log.txt" # check the new state at the front of the queue to see if it's time to use it if len(new_states_queue) > 0: (state_timestamp, new_state) = new_states_queue[0] current_timestamp = int(time.time()) if current_timestamp - state_timestamp >= opt.read_delay or state is None: state = new_state new_states_queue.pop(0) drawing_tool.set_window_title(update_notifier, watching_player=twitch_username, updates_queued=len(new_states_queue), read_delay=opt.read_delay) if state is None and screen_error_message is None: if read_from_server: screen_error_message = "Unable to read state from server. Please verify your options setup and tracker_log.txt" else: screen_error_message = "log.txt not found. Put the RebirthItemTracker folder inside the isaac folder, next to log.txt" if screen_error_message is not None: drawing_tool.write_error_message(screen_error_message) else: # We got a state, now we draw it drawing_tool.draw_state(state) drawing_tool.tick() framecount += 1 # main loop finished. program is exiting drawing_tool.save_window_position()
def run(self): """ The main routine which controls everything """ framecount = 0 # Create drawing tool to use to draw everything - it'll create its own screen drawing_tool = DrawingTool(wdir_prefix) drawing_tool.set_window_title_info( update_notifier=(" v" + self.tracker_version)) opt = Options() parser = LogParser(wdir_prefix, self.tracker_version, LogFinder()) event_result = None state = None custom_title_enabled = opt.custom_title_enabled read_from_server = opt.read_from_server write_to_server = opt.write_to_server game_version = opt.game_version state_version = -1 twitch_username = None new_states_queue = [] screen_error_message = None retry_in = 0 update_timer = opt.log_file_check_seconds last_game_version = None while event_result != Event.DONE: # Check for events and handle them event_result = drawing_tool.handle_events() # The user checked or unchecked the "Custom Title Enabled" checkbox if opt.custom_title_enabled != custom_title_enabled: custom_title_enabled = opt.custom_title_enabled drawing_tool.update_window_title() # The user started or stopped watching someone from the server (or they started watching a new person from the server) if opt.read_from_server != read_from_server or opt.twitch_name != twitch_username: twitch_username = opt.twitch_name read_from_server = opt.read_from_server new_states_queue = [] # Also restart version count if we go back and forth from log.txt to server if read_from_server: state_version = -1 state = None # Change the delay for polling, as we probably don't want to fetch it every second update_timer_override = 2 # Show who we are watching in the title bar drawing_tool.set_window_title_info( watching=True, watching_player=twitch_username, updates_queued=len(new_states_queue)) else: drawing_tool.set_window_title_info(watching=False) update_timer_override = 0 # The user started or stopped broadcasting to the server if opt.write_to_server != write_to_server: write_to_server = opt.write_to_server drawing_tool.set_window_title_info( uploading=opt.write_to_server) if opt.game_version != game_version: parser.reset() game_version = opt.game_version # Force refresh state if we updated options or if we need to retry # to contact the server. if (event_result == Event.OPTIONS_UPDATE or (screen_error_message is not None and retry_in == 0)): # By setting the framecount to 0 we ensure we'll refresh the state right away framecount = 0 screen_error_message = None retry_in = 0 # Force updates after changing options if state is not None: state.modified = True # normally we check for updates based on how the option is set # when doing network stuff, this can be overridden update_delay = opt.log_file_check_seconds if update_timer_override != 0: update_delay = update_timer_override # Now we re-process the log file to get anything that might have loaded; # do it every update_timer seconds (making sure to truncate to an integer # or else it might never mod to 0) frames_between_checks = int(Options().framerate_limit * update_delay) if frames_between_checks <= 0: frames_between_checks = 1 if framecount % frames_between_checks == 0: if retry_in != 0: retry_in -= 1 # Let the parser do his thing and give us a state if opt.read_from_server: base_url = opt.trackerserver_url + "/tracker/api/user/" + opt.twitch_name json_dict = None try: json_version = urllib.request.urlopen( base_url + "/version").read() if int(json_version) > state_version: # FIXME better handling of 404 error ? json_state = urllib.request.urlopen( base_url).read() json_dict = json.loads(json_state) new_state = TrackerState.from_json(json_dict) if new_state is None: raise Exception("server gave us empty state") state_version = int(json_version) new_states_queue.append((state_version, new_state)) drawing_tool.set_window_title_info( updates_queued=len(new_states_queue)) except Exception: state = None log_error("Couldn't load state from server\n" + traceback.format_exc()) if json_dict is not None: if "tracker_version" in json_dict: their_version = json_dict["tracker_version"] else: # This is the only version that can upload to the server but doesn't include a version string their_version = "0.10-beta1" if their_version != self.tracker_version: screen_error_message = "They are using tracker version " + their_version + " but you have " + self.tracker_version else: force_draw = state and state.modified state = parser.parse() if force_draw and state is not None: state.modified = True if write_to_server and not opt.trackerserver_authkey: screen_error_message = "Your authkey is blank. Get a new authkey in the options menu and paste it into the authkey text field." if state is not None and write_to_server and state.modified and screen_error_message is None: opener = urllib.request.build_opener( urllib.request.HTTPHandler) put_url = opt.trackerserver_url + "/tracker/api/update/" + opt.trackerserver_authkey json_string = json.dumps( state, cls=TrackerStateEncoder, sort_keys=True).encode("utf-8") request = urllib.request.Request(put_url, data=json_string) request.add_header('Content-Type', 'application/json') request.get_method = lambda: 'PUT' try: result = opener.open(request) result_json = json.loads(result.read()) updated_user = result_json["updated_user"] if updated_user is None: screen_error_message = "The server didn't recognize you. Try getting a new authkey in the options menu." else: screen_error_message = None except Exception as e: log_error( "ERROR: Couldn't send item info to server\n" + traceback.format_exc()) screen_error_message = "ERROR: Couldn't send item info to server, check tracker_log.txt" # Retry to write the state in 10*update_timer (aka 10 sec in write mode) retry_in = 10 # Check the new state at the front of the queue to see if it's time to use it if len(new_states_queue) > 0: (state_timestamp, new_state) = new_states_queue[0] current_timestamp = int(time.time()) if current_timestamp - state_timestamp >= opt.read_delay or opt.read_delay == 0 or state is None: state = new_state new_states_queue.pop(0) drawing_tool.set_window_title_info( updates_queued=len(new_states_queue)) if state is None and screen_error_message is None: if read_from_server: screen_error_message = "Unable to read state from server. Please verify your options setup and tracker_log.txt" # Retry to read the state in 5*update_timer (aka 10 sec in read mode) retry_in = 5 else: screen_error_message = "log.txt for " + opt.game_version + " not found. Make sure you have the right game selected in the options." if screen_error_message is not None: drawing_tool.write_error_message(screen_error_message) else: # We got a state, now we draw it drawing_tool.draw_state(state, framecount) # if we're watching someone and they change their game version, it can require us to reset if state and last_game_version != state.game_version: drawing_tool.reset_options() last_game_version = state.game_version drawing_tool.tick() framecount += 1 # Main loop finished; program is exiting drawing_tool.save_window_position() Options().save_options(wdir_prefix + "options.json")
class LogParser(object): """ This class loads Isaac's log file, and incrementally modify a state representing this log """ def __init__(self, prefix, tracker_version, log_finder): self.state = TrackerState("", tracker_version, Options().game_version, "", "", -1) self.log = logging.getLogger("tracker") self.wdir_prefix = prefix self.log_finder = log_finder self.reset() def reset(self): """Reset variable specific to the log file/run""" # Variables describing the parser state self.getting_start_items = False self.reseeding_floor = False self.current_seed = "" # Cached contents of log self.content = "" # Log split into lines self.splitfile = [] self.run_start_line = 0 self.seek = 0 self.spawned_coop_baby = 0 self.log_file_handle = None # if they switched between rebirth and afterbirth, the log file we use could change self.log_file_path = self.log_finder.find_log_file(self.wdir_prefix) self.state.reset(self.current_seed, Options().game_version, "") self.greed_mode_starting_rooms = ( '1.1000', '1.1010', '1.1011', '1.1012', '1.1013', '1.1014', '1.1015', '1.1016', '1.1017', '1.1018', '1.2000', '1.2001', '1.2002', '1.2003', '1.2004', '1.2005', '1.2006', '1.2007', '1.2008', '1.2009', '1.3000', '1.3001', '1.3002', '1.3003', '1.3004', '1.3005', '1.3006', '1.3007', '1.3008', '1.3009', '1.3010', '1.4000', '1.4001', '1.4002', '1.4003', '1.4004', '1.4005', '1.4006', '1.4007', '1.4008', '1.4009', '1.4010', '1.5000', '1.5001', '1.5002', '1.5003', '1.5004', '1.5005', '1.5006', '1.5007', '1.5008', '1.5009', '1.5010', '1.6000', '1.6001', '1.6002', '1.6003', '1.6004', '1.6005', '1.6006', '1.6007', '1.6008', '1.6009') self.first_floor = None self.first_line = "" self.curse_first_floor = "" def parse(self): """ Parse the log file and return a TrackerState object, or None if the log file couldn't be found """ self.opt = Options() # Attempt to load log_file if not self.__load_log_file(): return None self.splitfile = self.content.splitlines() # This will become true if we are getting starting items self.getting_start_items = False # Process log's new output for current_line_number, line in enumerate(self.splitfile[self.seek:]): self.__parse_line(current_line_number, line) self.seek = len(self.splitfile) return self.state def __parse_line(self, line_number, line): """ Parse a line using the (line_number, line) tuple """ # In Afterbirth+, nearly all lines start with this. # We want to slice it off. info_prefix = '[INFO] - ' if line.startswith(info_prefix): line = line[len(info_prefix):] # Messages printed by mods have this prefix. # strip it, so mods can spoof actual game log messages to us if they want to luadebug_prefix = 'Lua Debug: ' if line.startswith(luadebug_prefix): line = line[len(luadebug_prefix):] # AB and AB+ version messages both start with this text (AB+ has a + at the end) if line.startswith('Binding of Isaac: Repentance') or line.startswith( 'Binding of Isaac: Afterbirth') or line.startswith( 'Binding of Isaac: Rebirth'): self.__parse_version_number(line) if line.startswith('welcomeBanner:'): self.__parse_version_number(line, True) if line.startswith('Loading PersistentData'): self.__parse_save(line) if line.startswith('RNG Start Seed:'): self.__parse_seed(line, line_number) if line.startswith( 'Initialized player with Variant') and self.state.player == -1: self.__parse_player(line) if self.opt.game_version == "Repentance" and line.startswith( 'Level::Init' ) and self.state.greedmode is None: # Store the line of the first floor in Repentance because we can detect if we are in greed mode only after this line in the log self.first_line = line self.curse_first_floor = "" elif line.startswith('Level::Init'): self.__parse_floor(line, line_number) if line.startswith('Room'): self.__parse_room(line) if self.opt.game_version == "Repentance": self.detect_greed_mode(line, line_number) self.state.remove_additional_char_items() if line.startswith("Curse"): self.__parse_curse(line) if line.startswith("Spawn co-player!"): self.spawned_coop_baby = line_number + self.seek if re.search(r"Added \d+ Collectibles", line): self.log.debug("Reroll detected!") self.state.reroll() if line.startswith('Adding collectible '): self.__parse_item_add(line_number, line) if line.startswith('Gulping trinket ') or line.startswith( 'Adding smelted trinket '): self.__parse_trinket_gulp(line) if line.startswith('Removing collectible ') or line.startswith( 'Removing smelted trinket '): self.__parse_item_remove(line) if line.startswith('Executing command: reseed'): # racing+ re-generates floors if they contain duplicate rooms. we need to track that this is happening # so we don't erroneously think the entire run is being restarted when it happens on b1. self.reseeding_floor = True if line.startswith('Caught exception,'): self.__backup_log(crash=True) if line.startswith('Isaac has shut down'): self.__backup_log() def __trigger_new_run(self, line_number): self.log.debug("Starting new run, seed: %s", self.current_seed) self.run_start_line = line_number + self.seek self.state.reset(self.current_seed, Options().game_version, self.state.racing_plus_version) def __parse_version_number(self, line, racingplus=False): words = line.split() if not racingplus: self.state.version_number = words[-1] else: regexp_str = r"welcomeBanner:(\d+) - [|] Racing[+] (\d+).(\d+).(\d+) initialized." search_result = re.search(regexp_str, line) if search_result is None: return False self.state.racing_plus_version = "/ R+: " + str( int(search_result.group(2))) + "." + str( int(search_result.group(3))) + "." + str( int(search_result.group( 4))) if search_result is not None else "" def __parse_save(self, line): regexp_str = r"Loading PersistentData (\d+)" search_result = re.search(regexp_str, line) self.state.save = int( search_result.group(1)) if search_result is not None else 0 def __parse_seed(self, line, line_number): """ Parse a seed line """ # This assumes a fixed width, but from what I see it seems safe self.current_seed = line[16:25] space_split = line.split(" ") # Antibirth doesn't have a proper way to detect run resets # it will wipe the tracker when doing a "continue" if (self.opt.game_version == "Repentance" and space_split[6] in ('[New,', '[Daily,')) or self.opt.game_version == "Antibirth": self.__trigger_new_run(line_number) elif (self.opt.game_version == "Repentance" and space_split[6] == '[Continue,'): self.state.load_from_export_state() def __parse_player(self, line): regexp_str = r"Initialized player with Variant (\d+) and Subtype (\d+)" search_result = re.search(regexp_str, line) self.state.player = int( search_result.group(2) ) if search_result is not None else 8 # Put it on Lazarus by default def __parse_room(self, line): """ Parse a room line """ if 'Start Room' not in line: self.getting_start_items = False match = re.search(r"Room (.+?)\(", line) if match: room_id = match.group(1) self.state.change_room(room_id) def detect_greed_mode(self, line, line_number): # Detect if we're in Greed mode or not in Repentance. We must do a ton of hacky things to show the first floor with curses because we can't detect greed mode in one line anymore match = re.search(r"Room (.+?)\(", line) if match: room_id = match.group(1) if room_id == '18.1000': # Genesis room self.state.item_list = [] self.state.set_transformations() elif self.state.greedmode is None: self.state.greedmode = room_id in self.greed_mode_starting_rooms self.__parse_floor(self.first_line, line_number) self.__parse_curse(self.curse_first_floor) def __parse_floor(self, line, line_number): """ Parse the floor in line and push it to the state """ # Create a floor tuple with the floor id and the alternate id if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+" or self.opt.game_version == "Repentance": regexp_str = r"Level::Init m_Stage (\d+), m_StageType (\d+)" elif self.opt.game_version == "Rebirth" or self.opt.game_version == "Antibirth": regexp_str = r"Level::Init m_Stage (\d+), m_AltStage (\d+)" else: return search_result = re.search(regexp_str, line) if search_result is None: self.log.debug( "log.txt line doesn't match expected regex\nline: \"" + line + "\"\nregex:\"" + regexp_str + "\"") return floor = int(search_result.group(1)) alt = search_result.group(2) self.getting_start_items = True # we use generation of the first floor as our trigger that a new run started. # in racing+, it doesn't count if the game is currently in the process of "reseeding" that floor. # in antibirth, this doesn't work at all; instead we have to use the seed being printed as our trigger. # that means if you s+q in antibirth, it resets the tracker. # In Repentance, Downpour 1 and Dross 1 are considered Stage 1. # So we need to add a condition to avoid tracker resetting when entering those floors. # In Repentance, don't trigger a new run on floor 1 because of the R Key item if self.reseeding_floor: self.reseeding_floor = False elif floor == 1 and self.opt.game_version != "Antibirth" and self.opt.game_version != "Repentance": self.__trigger_new_run(line_number) # Special handling for the Cathedral and The Chest and Afterbirth if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+" or self.opt.game_version == "Repentance": self.log.debug("floor") # In Afterbirth, Cath is an alternate of Sheol (which is 10) # and Chest is an alternate of Dark Room (which is 11) # In Repentance, alt paths are same stage as their counterparts (ex: Basement 1 = Downpour 1) if alt == '4' or alt == '5': floor += 15 elif floor == 10 and alt == '0': floor -= 1 elif floor == 11 and alt == '1': floor += 1 elif floor == 9: floor = 13 elif floor == 12: floor = 14 elif floor == 13: floor = 15 else: # In Rebirth, floors have different numbers if alt == '1' and (floor == 9 or floor == 11): floor += 1 floor_id = 'f' + str(floor) # Greed mode if (alt == '3' and self.opt.game_version != "Repentance") or ( self.opt.game_version == "Repentance" and self.state.greedmode): floor_id += 'g' self.state.add_floor(Floor(floor_id)) self.state.export_state() return True def __parse_curse(self, line): """ Parse the curse and add it to the last floor """ if self.state.racing_plus_version != "": return if self.curse_first_floor == "": self.curse_first_floor = line elif self.state.greedmode is not None: self.curse_first_floor = "" if line.startswith("Curse of the Labyrinth!") or ( self.curse_first_floor == "Curse of the Labyrinth!" and self.opt.game_version == "Repentance"): self.state.add_curse(Curse.Labyrinth) if line.startswith("Curse of Blind") or ( self.curse_first_floor == "Curse of Blind" and self.opt.game_version == "Repentance"): self.state.add_curse(Curse.Blind) def __parse_item_add(self, line_number, line): """ Parse an item and push it to the state """ if len(self.splitfile) > 1 and self.splitfile[line_number + self.seek - 1] == line: self.log.debug("Skipped duplicate item line from baby presence") return False is_Jacob_item = line.endswith( "(Jacob)" ) and self.opt.game_version == "Repentance" and self.state.player == 19 is_Esau_item = line.endswith( " 1 (Esau)" ) and self.opt.game_version == "Repentance" and self.state.player == 19 if self.state.player in ( 14, 33): # Don't show keeper head on keeper and tainted keeper is_Strawman_item = "player 0" not in line and line.endswith( "(Keeper)") and self.state.contains_item('667') is_EsauSoul_item = "player 0" not in line and line.endswith( "(Esau)") elif self.state.player == 19: is_Strawman_item = line.endswith( "(Keeper)") and self.state.contains_item('667') is_EsauSoul_item = "player 0" not in line and "player 1 " not in line and line.endswith( "(Esau)") else: is_Strawman_item = line.endswith( "(Keeper)") and self.state.contains_item('667') is_EsauSoul_item = "player 0" not in line and line.endswith( "(Esau)") if self.state.player == 19 and not is_Esau_item and not is_Jacob_item and not is_Strawman_item and not is_EsauSoul_item: # This is when J&E transform into another character self.state.player = 8 # Put it on Lazarus by default just in case we got another Anemic elif self.state.player not in (19, 37) and is_Jacob_item: self.state.player = 19 space_split = line.split(" ") numeric_id = space_split[ 2] # When you pick up an item, this has the form: "Adding collectible 105 (The D6)" or "Adding collectible 105 (The D6) to Player 0 (Isaac)" in Repentance double_word_char = line.endswith("(The Lost)") or line.endswith( "(The Forgotten)") or line.endswith("(The Soul)") or line.endswith( "(Black Judas)") or line.endswith("(Random Baby)") if self.opt.game_version == "Repentance" and double_word_char: item_name = " ".join(space_split[3:-4])[1:-4] elif self.opt.game_version == "Repentance": item_name = " ".join(space_split[3:-4])[1:-1] else: item_name = " ".join(space_split[3:])[1:-1] item_id = "" if int(numeric_id) < 0: numeric_id = "-1" # Check if we recognize the numeric id if Item.contains_info(numeric_id): item_id = numeric_id else: # it might be a modded custom item. let's see if we recognize the name item_id = Item.modded_item_id_prefix + item_name if not Item.contains_info(item_id): item_id = "NEW" self.log.debug("Picked up item. id: %s, name: %s", item_id, item_name) if ((line_number + self.seek) - self.spawned_coop_baby) < (len(self.state.item_list) + 10) \ and self.state.contains_item(item_id): self.log.debug("Skipped duplicate item line from baby entry") return False # It's a blind pickup if we're on a blind floor and we don't have the Black Candle blind_pickup = self.state.last_floor.floor_has_curse( Curse.Blind) and not self.state.contains_item('260') if not (numeric_id == "214" and ( (self.state.contains_item('214') and self.state.contains_item('332')) or (self.state.player == 8 and self.state.contains_item('214')))): added = self.state.add_item( Item(item_id, numeric_id, self.state.last_floor, self.getting_start_items, blind=blind_pickup, is_Jacob_item=is_Jacob_item, is_Esau_item=is_Esau_item, is_Strawman_item=is_Strawman_item, is_EsauSoul_item=is_EsauSoul_item, shown=Item.get_item_info(item_id).shown)) if not added: self.log.debug( "Skipped adding item %s to avoid space-bar duplicate", item_id) else: self.log.debug( "Skipped adding Anemic from Lazarus Rags because we already have Anemic" ) if item_id in ("144", "238", "239", "278", "388", "550", "552", "626", "627"): self.__parse_add_multi_items() self.state.export_state() return True def __parse_add_multi_items(self): """Add custom sprites for multi-segmented items like Super Bum, key pieces or knife pieces""" # item.info.shown = False is for not showing the item on the tracker # item.shown = False is for the export_state function to store the actual shown value instead of the initial value item.info.shown if self.state.contains_item('238') and self.state.contains_item( '239') and not self.state.contains_item('3000'): for item in reversed(self.state.item_list): if item.item_id in ("238", "239"): item.info.shown = False item.shown = False self.state.add_item( Item("3000", "3000", floor=self.state.last_floor)) elif self.state.contains_item('550') and self.state.contains_item( '552'): for item in reversed(self.state.item_list): if item.item_id == "550": item.info.shown = False item.shown = False elif self.state.contains_item('144') and self.state.contains_item( '278' ) and self.state.contains_item('388') and not self.state.contains_item( '3001' ) and self.opt.game_version != "Rebirth" and self.opt.game_version != "Antibirth": for item in reversed(self.state.item_list): if item.item_id in ("144", "278", "388"): item.info.shown = False item.shown = False self.state.add_item( Item("3001", "3001", floor=self.state.last_floor)) elif self.state.contains_item('626') and self.state.contains_item( '627') and not self.state.contains_item('3002'): for item in reversed(self.state.item_list): if item.item_id in ("626", "627"): item.info.shown = False item.shown = False self.state.add_item( Item("3002", "3002", floor=self.state.last_floor)) def __parse_trinket_gulp(self, line): """ Parse a (modded) trinket gulp and push it to the state """ space_split = line.split(" ") # When using a mod like racing+ on AB+, a trinket gulp has the form: "Gulping trinket 10" # In Repentance, a gulped trinket has the form : "Adding smelted trinket 10" if self.opt.game_version == "Repentance" and int( space_split[3]) > 30000: numeric_id = str(int(space_split[3])) elif self.opt.game_version == "Repentance": numeric_id = str( int(space_split[3]) + 2000 ) # the tracker hackily maps trinkets to items 2000 and up. else: numeric_id = str( int(space_split[2]) + 2000 ) # the tracker hackily maps trinkets to items 2000 and up. is_Jacob_item = line.endswith( "(Jacob)" ) and self.opt.game_version == "Repentance" and self.state.player == 19 is_Esau_item = line.endswith( "(Esau)") and self.opt.game_version == "Repentance" # Check if we recognize the numeric id if Item.contains_info(numeric_id): item_id = numeric_id else: item_id = "NEW" numeric_id = "t" + str(numeric_id) if item_id == "NEW" else item_id self.log.debug("Gulped trinket: %s", item_id) added = self.state.add_item( Item(item_id, numeric_id, self.state.last_floor, self.getting_start_items, is_Jacob_item=is_Jacob_item, is_Esau_item=is_Esau_item)) if not added: self.log.debug( "Skipped adding item %s to avoid space-bar duplicate", item_id) self.state.export_state() return True def __parse_item_remove(self, line): """ Parse an item and remove it from the state """ space_split = line.split(" ") # Hacky string manipulation double_word_char = line.endswith("(The Lost)") or line.endswith( "(The Forgotten)") or line.endswith("(The Soul)") or line.endswith( "(Black Judas)") or line.endswith("(Random Baby)") # When you lose an item, this has the form: "Removing collectible 105 (The D6)" if self.opt.game_version == "Repentance" and space_split[ 2] == "trinket" and int(space_split[3]) < 30000: item_id = str(int(space_split[3]) + 2000) item_name = " ".join( space_split[3:-5])[3:-1] if double_word_char else " ".join( space_split[3:-4])[3:-1] else: item_id = space_split[2] item_name = " ".join( space_split[3:-5])[1:-1] if double_word_char else " ".join( space_split[3:-4])[1:-1] # Check if the item ID exists if Item.contains_info(item_id): removal_id = item_id else: # that means it's probably a custom item removal_id = Item.modded_item_id_prefix + item_name self.log.debug("Removed item. id: %s", removal_id) if item_id in ("144", "238", "239", "278", "388", "626", "627"): self.__parse_remove_multi_items(item_id=item_id) if item_id == "667": self.state.remove_additional_char_items(strawman=True) # A check will be made inside the remove_item function # to see if this item is actually in our inventory or not. return self.state.remove_item(removal_id) def __parse_remove_multi_items(self, item_id): """Remove custom sprites for multi-segmented items like Super Bum, key pieces or knife pieces""" if item_id in ("238", "239"): for item in reversed(self.state.item_list): if item.item_id in ("238", "239"): item.info.shown = True self.state.remove_item("3000") elif item_id in ("144", "278", "388"): for item in reversed(self.state.item_list): if item.item_id in ("144", "278", "388"): item.info.shown = True self.state.remove_item("3001") elif item_id in ("626", "627"): for item in reversed(self.state.item_list): if item.item_id in ("626", "627"): item.info.shown = True self.state.remove_item("3002") def __load_log_file(self): if self.log_file_path is None: return False if self.log_file_handle is None: self.log_file_handle = open(self.log_file_path, 'r', encoding='Latin-1', errors='remplace') cached_length = len(self.content) file_size = os.path.getsize(self.log_file_path) if cached_length > file_size or cached_length == 0: # New log file or first time loading the log self.reset() self.content = open(self.log_file_path, 'r', encoding='Latin-1', errors='remplace').read() elif cached_length < file_size: # Append existing content self.log_file_handle.seek(cached_length + 1) self.content += self.log_file_handle.read() return True def __backup_log(self, crash=False): if self.log_file_path is None: return False logsTarget = '/backup logs' if not os.path.exists(self.wdir_prefix + logsTarget): os.mkdir(self.wdir_prefix + logsTarget) target = self.wdir_prefix + logsTarget racingPlusLogsTarget = '/Racing+ logs' if self.state.racing_plus_version != "": if not os.path.exists(target + racingPlusLogsTarget): os.mkdir(target + racingPlusLogsTarget) target += racingPlusLogsTarget now = datetime.now() logTime = now.strftime("%d-%m-%Y %H-%M-%S") target += '/' + logTime if crash: target = target + " [crash]" if self.state.racing_plus_version != "": racingPlusVersion = self.state.racing_plus_version.replace( "/ R+: ", "") target = target + " - " + racingPlusVersion versionNumber = self.state.version_number.replace("v1", "1").replace( ".0000", "") target = target + " - " + versionNumber + ".txt" shutil.copy(self.log_file_path, target) if os.path.exists(self.wdir_prefix + logsTarget + racingPlusLogsTarget): self.__delete_last_logs("backup logs/", 1) self.__delete_last_logs("backup logs/Racing+ logs/", 0) else: self.__delete_last_logs("backup logs/", 0) def __delete_last_logs(self, folder, numFolders): path = self.wdir_prefix + folder mtime = lambda f: os.stat(os.path.join(path, f)).st_mtime sorted_ls = list(sorted(os.listdir(path), key=mtime)) if len(sorted_ls) >= 200: del_list = sorted_ls[numFolders:(len(sorted_ls) - 200)] for file in del_list: os.remove(path + file)
class LogParser(object): """ This class loads Isaac's log file, and incrementally modify a state representing this log """ def __init__(self, prefix, tracker_version, log_finder): self.state = TrackerState("", tracker_version, Options().game_version) self.log = logging.getLogger("tracker") self.wdir_prefix = prefix self.log_finder = log_finder self.reset() def reset(self): """Reset variable specific to the log file/run""" # Variables describing the parser state self.getting_start_items = False self.reseeding_floor = False self.current_room = "" self.current_seed = "" self.seedline = "" # Cached contents of log self.content = "" # Log split into lines self.splitfile = [] self.run_start_line = 0 self.seek = 0 self.spawned_coop_baby = 0 self.log_file_handle = None # if they switched between rebirth and afterbirth, the log file we use could change self.log_file_path = self.log_finder.find_log_file(self.wdir_prefix) self.state.reset(self.current_seed, Options().game_version) self.lazarus = False self.greedmode = 0 self.first_floor = None self.first_line = "" self.curse_first_floor = "" # Avoid tracker resetting on B1 for Backasswards challenge self.backasswards = False def parse(self): """ Parse the log file and return a TrackerState object, or None if the log file couldn't be found """ self.opt = Options() # Attempt to load log_file if not self.__load_log_file(): return None self.splitfile = self.content.splitlines() # This will become true if we are getting starting items self.getting_start_items = False # Process log's new output for current_line_number, line in enumerate(self.splitfile[self.seek:]): self.__parse_line(current_line_number, line) self.seek = len(self.splitfile) return self.state def __parse_line(self, line_number, line): """ Parse a line using the (line_number, line) tuple """ # In Afterbirth+, nearly all lines start with this. # We want to slice it off. info_prefix = '[INFO] - ' if line.startswith(info_prefix): line = line[len(info_prefix):] # Messages printed by mods have this prefix. # strip it, so mods can spoof actual game log messages to us if they want to luadebug_prefix = 'Lua Debug: ' if line.startswith(luadebug_prefix): line = line[len(luadebug_prefix):] # AB and AB+ version messages both start with this text (AB+ has a + at the end) if line.startswith('Binding of Isaac: Repentance') or line.startswith( 'Binding of Isaac: Afterbirth') or line.startswith( 'Binding of Isaac: Rebirth'): self.__parse_version_number(line) if line.startswith('RNG Start Seed:') and line != self.seedline: self.__parse_seed(line, line_number) self.greedmode = 0 if self.opt.game_version == "Repentance" and line.startswith( 'Level::Init' ) and self.greedmode == 0: # Store the line of the first floor in Repentance because we can detect if we are in greed mode only after this line in the log self.first_line = line elif line.startswith('Level::Init'): self.__parse_floor(line, line_number) if line.startswith('Room'): self.__parse_room(line) # Detect if we're in Greed mode or not in Repentance. We must do a ton of hacky things to show the first floor with curses because we can't detect greed mode in one line anymore if self.opt.game_version == "Repentance": greed_mode_starting_rooms = ( '1.1000', '1.1010', '1.1011', '1.1012', '1.1013', '1.1014', '1.1015', '1.1016', '1.1017', '1.1018', '1.2000', '1.2001', '1.2002', '1.2003', '1.2004', '1.2005', '1.2006', '1.2007', '1.2008', '1.2009', '1.3000', '1.3001', '1.3002', '1.3003', '1.3004', '1.3005', '1.3006', '1.3007', '1.3008', '1.3009', '1.3010', '1.4000', '1.4001', '1.4002', '1.4003', '1.4004', '1.4005', '1.4006', '1.4007', '1.4008', '1.4009', '1.4010', '1.5000', '1.5001', '1.5002', '1.5003', '1.5004', '1.5005', '1.5006', '1.5007', '1.5008', '1.5009', '1.5010', '1.6000', '1.6001', '1.6002', '1.6003', '1.6004', '1.6005', '1.6006', '1.6007', '1.6008', '1.6009') match = re.search(r"Room (.+?)\(", line) room_id = match.group(1) if room_id == '18.1000': self.state.item_list = [] elif room_id in greed_mode_starting_rooms and self.greedmode == 0: self.greedmode = 2 self.__parse_floor(self.first_line, line_number) self.__parse_curse(self.curse_first_floor) elif self.greedmode == 0 or room_id == '5.50000': # 5.5000 is Mega Satan's Room in Challenge #31 if line.startswith('Room 5.5000'): self.backasswards = True else: self.backasswards = False self.greedmode = 1 self.__parse_floor(self.first_line, line_number) self.__parse_curse(self.curse_first_floor) if line.endswith( 'Subtype 8' ): # This is to detect if we play as Lazarus to avoid showing two Anemics in the tracker self.lazarus = True else: # To reset self.lazarus if we don't play it because it would never add Anemic again unless you close/re-open the game for i in range(20): if i != 8 and line.endswith("Subtype " + str(i)): self.lazarus = False if line.startswith("Curse"): self.__parse_curse(line) if line.startswith("Spawn co-player!"): self.spawned_coop_baby = line_number + self.seek if re.search(r"Added \d+ Collectibles", line): self.log.debug("Reroll detected!") self.state.reroll() if line.startswith('Adding collectible '): self.__parse_item_add(line_number, line) if line.startswith('Gulping trinket ') or line.startswith( 'Adding smelted trinket '): self.__parse_trinket_gulp(line) if line.startswith('Removing collectible ') or line.startswith( 'Removing voided collectible ') or line.startswith( 'Removing smelted trinket '): self.__parse_item_remove(line) if line.startswith('Executing command: reseed'): # racing+ re-generates floors if they contain duplicate rooms. we need to track that this is happening # so we don't erroneously think the entire run is being restarted when it happens on b1. self.reseeding_floor = True def __trigger_new_run(self, line_number): self.log.debug("Starting new run, seed: %s", self.current_seed) self.run_start_line = line_number + self.seek self.state.reset(self.current_seed, Options().game_version) def __parse_version_number(self, line): words = line.split() self.state.version_number = words[-1] def __parse_seed(self, line, line_number): """ Parse a seed line """ # This assumes a fixed width, but from what I see it seems safe self.current_seed = line[16:25] self.seedline = line # In Repentance if you Save&Quit, the RNG Start Seed line will happen again so we need to store the whole line to not trigger the function if this line is duplicated self.__trigger_new_run(line_number) # Antibirth doesn't have a proper way to detect run resets # it will wipe the tracker when doing a "continue" if self.opt.game_version == "Antibirth": self.__trigger_new_run(line_number) def __parse_room(self, line): """ Parse a room line """ if 'Start Room' not in line: self.getting_start_items = False match = re.search(r"Room (.+?)\(", line) if match: room_id = match.group(1) self.state.change_room(room_id) def __parse_floor(self, line, line_number): """ Parse the floor in line and push it to the state """ # Create a floor tuple with the floor id and the alternate id if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+" or self.opt.game_version == "Repentance": regexp_str = r"Level::Init m_Stage (\d+), m_StageType (\d+)" elif self.opt.game_version == "Rebirth" or self.opt.game_version == "Antibirth": regexp_str = r"Level::Init m_Stage (\d+), m_AltStage (\d+)" else: return search_result = re.search(regexp_str, line) if search_result is None: self.log.debug( "log.txt line doesn't match expected regex\nline: \"" + line + "\"\nregex:\"" + regexp_str + "\"") return floor = int(search_result.group(1)) alt = search_result.group(2) self.getting_start_items = True # we use generation of the first floor as our trigger that a new run started. # in racing+, it doesn't count if the game is currently in the process of "reseeding" that floor. # in antibirth, this doesn't work at all; instead we have to use the seed being printed as our trigger. # that means if you s+q in antibirth, it resets the tracker. # In Repentance, Downpour 1 and Dross 1 are considered Stage 1. # So we need to add a condition to avoid tracker reseting when entering those floors. # In Repentance, don't trigger a new run on floor 1 because of the R Key item if self.reseeding_floor: self.reseeding_floor = False elif floor == 1 and self.opt.game_version != "Antibirth" and self.opt.game_version != "Repentance" and self.backasswards == False: self.__trigger_new_run(line_number) # Special handling for the Cathedral and The Chest and Afterbirth if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+" or self.opt.game_version == "Repentance": self.log.debug("floor") # In Afterbirth, Cath is an alternate of Sheol (which is 10) # and Chest is an alternate of Dark Room (which is 11) # In Repentance, alt paths are same stage as their counterparts (ex: Basement 1 = Downpour 1) if alt == '4' or alt == '5': floor += 15 elif floor == 10 and alt == '0': floor -= 1 elif floor == 11 and alt == '1': floor += 1 elif floor == 9: floor = 13 elif floor == 12: floor = 14 elif floor == 13: floor = 15 else: # In Rebirth, floors have different numbers if alt == '1' and (floor == 9 or floor == 11): floor += 1 floor_id = 'f' + str(floor) # Greed mode if (alt == '3' and self.opt.game_version != "Repentance") or ( self.opt.game_version == "Repentance" and self.greedmode == 2): floor_id += 'g' self.state.add_floor(Floor(floor_id)) return True def __parse_curse(self, line): """ Parse the curse and add it to the last floor """ if self.curse_first_floor == "": self.curse_first_floor = line elif self.greedmode != 0: self.curse_first_floor = "" if line.startswith( "Curse of the Labyrinth!" ) or self.curse_first_floor == "Curse of the Labyrinth!": self.state.add_curse(Curse.Labyrinth) if line.startswith("Curse of Blind" ) or self.curse_first_floor == "Curse of Blind": self.state.add_curse(Curse.Blind) if line.startswith("Curse of the Lost!"): self.state.add_curse(Curse.Lost) def __parse_item_add(self, line_number, line): """ Parse an item and push it to the state """ if len(self.splitfile) > 1 and self.splitfile[line_number + self.seek - 1] == line: self.log.debug("Skipped duplicate item line from baby presence") return False is_Jacob_item = line.endswith( "(Jacob)" ) and self.opt.game_version == "Repentance" # The second part of the condition is to avoid showing Jacob's Head if you play on a modded char in AB+ is_Esau_item = line.endswith( "(Esau)" ) and self.opt.game_version == "Repentance" # The second part of the condition is to avoid showing Esau's Head if you play on a modded char in AB+ end_name = -15 if is_Jacob_item or is_Esau_item else -1 space_split = line.split(" ") numeric_id = space_split[ 2] # When you pick up an item, this has the form: "Adding collectible 105 (The D6)" item_name = " ".join(space_split[3:])[1:end_name] item_id = "" if int(numeric_id) < 0: numeric_id = "-1" if int( numeric_id ) == 577: # Damocles can't be rerolled by D4 so no need to show it twice numeric_id = "656" # Check if we recognize the numeric id if Item.contains_info(numeric_id): item_id = numeric_id else: # it might be a modded custom item. let's see if we recognize the name item_id = Item.modded_item_id_prefix + item_name if not Item.contains_info(item_id): item_id = "NEW" self.log.debug("Picked up item. id: %s, name: %s", item_id, item_name) if ((line_number + self.seek) - self.spawned_coop_baby) < (len(self.state.item_list) + 10) \ and self.state.contains_item(item_id): self.log.debug("Skipped duplicate item line from baby entry") return False # It's a blind pickup if we're on a blind floor and we don't have the Black Candle blind_pickup = self.state.last_floor.floor_has_curse( Curse.Blind) and not self.state.contains_item('260') if not (numeric_id == "214" and ((self.state.contains_item('214') and self.state.contains_item('332')) or (self.lazarus and self.state.contains_item('214')))): added = self.state.add_item( Item(item_id, self.state.last_floor, self.getting_start_items, blind=blind_pickup, is_Jacob_item=is_Jacob_item, is_Esau_item=is_Esau_item)) if not added: self.log.debug( "Skipped adding item %s to avoid space-bar duplicate", item_id) else: self.log.debug( "Skipped adding Anemic from Lazarus Rags because we already have Anemic" ) return True def __parse_trinket_gulp(self, line): """ Parse a (modded) trinket gulp and push it to the state """ space_split = line.split(" ") # When using a mod like racing+ on AB+, a trinket gulp has the form: "Gulping trinket 10" # In Repentance, a gulped trinket has the form : "Adding smelted trinket 10" if self.opt.game_version == "Repentance": numeric_id = str( int(space_split[3]) + 2000 ) # the tracker hackily maps trinkets to items 2000 and up. else: numeric_id = str( int(space_split[2]) + 2000 ) # the tracker hackily maps trinkets to items 2000 and up. is_Jacob_item = line.endswith( "(Jacob)") and self.opt.game_version == "Repentance" is_Esau_item = line.endswith( "(Esau)") and self.opt.game_version == "Repentance" # Check if we recognize the numeric id if Item.contains_info(numeric_id): item_id = numeric_id else: item_id = "NEW" self.log.debug("Gulped trinket: %s", item_id) added = self.state.add_item( Item(item_id, self.state.last_floor, self.getting_start_items, is_Jacob_item=is_Jacob_item, is_Esau_item=is_Esau_item)) if not added: self.log.debug( "Skipped adding item %s to avoid space-bar duplicate", item_id) return True def __parse_item_remove(self, line): """ Parse an item and remove it from the state """ space_split = line.split(" ") # Hacky string manipulation # When you lose an item, this has the form: "Removing collectible 105 (The D6)" or "Removing voided collectible 105 (The D6)" if self.opt.game_version == "Repentance": item_id = space_split[3] if space_split[2] == "trinket": item_id = str(int(space_split[3]) + 2000) else: item_id = space_split[2] item_name = " ".join(space_split[3:])[1:-1] # Check if the item ID exists if Item.contains_info(item_id): removal_id = item_id else: # that means it's probably a custom item removal_id = Item.modded_item_id_prefix + item_name self.log.debug("Removed item. id: %s", removal_id) # A check will be made inside the remove_item function # to see if this item is actually in our inventory or not. return self.state.remove_item(removal_id) def __load_log_file(self): if self.log_file_path is None: return False if self.log_file_handle is None: self.log_file_handle = open(self.log_file_path, 'r') cached_length = len(self.content) file_size = os.path.getsize(self.log_file_path) if cached_length > file_size or cached_length == 0: # New log file or first time loading the log self.reset() self.content = open(self.log_file_path, 'r').read() elif cached_length < file_size: # Append existing content self.log_file_handle.seek(cached_length + 1) self.content += self.log_file_handle.read() return True
class LogParser(object): """ This class load Isaac's log file, and incrementally modify a state representing this log """ def __init__(self, prefix, tracker_version): self.state = TrackerState("", tracker_version, Options().game_version) self.log = logging.getLogger("tracker") self.file_prefix = prefix self.reset() # FIXME run summary. self.run_ended = 0 def reset(self): """Reset variable specific to the log file/run""" # Variables describing the parser state self.getting_start_items = False self.current_room = "" self.current_seed = "" # Cached contents of log self.content = "" # Log split into lines self.splitfile = [] self.run_start_line = 0 self.seek = 0 self.spawned_coop_baby = 0 self.state.reset(self.current_seed, Options().game_version) def parse(self): """ Parse the log file and return a TrackerState object, or None if the log file couldn't be found """ self.opt = Options() # Attempt to load log_file if not self.__load_log_file(): return None self.splitfile = self.content.splitlines() self.getting_start_items = False # This will become true if we are getting starting items # Process log's new output for current_line_number, line in enumerate(self.splitfile[self.seek:]): self.__parse_line(current_line_number, line) self.seek = len(self.splitfile) return self.state def __parse_line(self, line_number, line): """ Parse a line using the (line_number, line) tuple """ #in afterbirth+, all lines start with this. we want to slice it off. info_prefix = '[INFO] - ' if line.startswith(info_prefix): line = line[len(info_prefix):] # Check and handle the end of the run; the order is important # - we want it after boss kill but before "RNG Start Seed" self.__check_end_run(line_number + self.seek, line) if line.startswith('RNG Start Seed:'): self.__parse_seed(line, line_number) if line.startswith('Room'): self.__parse_room(line) if line.startswith('Level::Init'): self.__parse_floor(line) if line.startswith("Curse"): self.__parse_curse(line) if line.startswith("Spawn co-player!"): self.spawned_coop_baby = line_number + self.seek if re.search(r"Added \d+ Collectibles", line): self.log.debug("Reroll detected!") self.state.reroll() if line.startswith('Adding collectible'): self.__parse_item(line_number, line) def __parse_seed(self, line, line_number): """ Parse a seed line """ # This assumes a fixed width, but from what I see it seems safe self.current_seed = line[16:25] # when we see a new seed, that means it's a new run self.log.debug("Starting new run, seed: %s", self.current_seed) self.run_start_line = line_number + self.seek self.state.reset(self.current_seed, Options().game_version) self.run_ended = False def __parse_room(self, line): """ Parse a room line """ if 'Start Room' not in line: self.getting_start_items = False def __parse_floor(self, line): """ Parse the floor in line and push it to the state """ # Create a floor tuple with the floor id and the alternate id if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+": regexp_str = r"Level::Init m_Stage (\d+), m_StageType (\d+)" elif self.opt.game_version == "Rebirth" or self.opt.game_version == "Antibirth": regexp_str = r"Level::Init m_Stage (\d+), m_AltStage (\d+)" else: return floor_tuple = tuple( [re.search(regexp_str, line).group(x) for x in [1, 2]]) self.getting_start_items = True # Assume floors aren't cursed until we see they are floor = int(floor_tuple[0]) alt = floor_tuple[1] # Special handling for cath and chest and Afterbirth if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+": # In Afterbirth Cath is an alternate of Sheol (which is 10) # and Chest is an alternate of Dark room (which is 11) if floor == 10 and alt == '0': floor -= 1 elif floor == 11 and alt == '1': floor += 1 else: # In Rebirth floors have different numbers if alt == '1' and (floor == 9 or floor == 11): floor += 1 floor_id = 'f' + str(floor) # Greed mode if alt == '3': floor_id += 'g' self.state.add_floor(Floor(floor_id)) return True def __parse_curse(self, line): """ Parse the curse and add it to the last floor """ if line.startswith("Curse of the Labyrinth!"): self.state.add_curse(Curse.Labyrinth) if line.startswith("Curse of Blind"): self.state.add_curse(Curse.Blind) if line.startswith("Curse of the Lost!"): self.state.add_curse(Curse.Lost) def __parse_item(self, line_number, line): """ Parse an Item and push it to the state """ if len(self.splitfile) > 1 and self.splitfile[line_number + self.seek - 1] == line: self.log.debug("Skipped duplicate item line from baby presence") return False space_split = line.split(" ") # Hacky string manipulation item_id = space_split[ 2] # A string has the form of "Adding collectible 105 (The D6)" # Check if the item ID exists if not Item.contains_info(item_id): item_id = "NEW" item_name = " ".join(space_split[3:])[1:-1] self.log.debug("Picked up item. id: %s, name: %s", item_id, item_name) if ((line_number + self.seek) - self.spawned_coop_baby) < (len(self.state.item_list) + 10) \ and self.state.contains_item(item_id): self.log.debug("Skipped duplicate item line from baby entry") return False #it's a blind pickup if we're on a blind floor and they don't have the black candle blind_pickup = self.state.last_floor.floor_has_curse( Curse.Blind) and not self.state.contains_item('260') added = self.state.add_item( Item(item_id, self.state.last_floor, self.getting_start_items, blind=blind_pickup)) if not added: self.log.debug( "Skipped adding item %s to avoid space-bar duplicate", item_id) return True def __load_log_file(self): """ Attempt to load log file from common location. Return true if successfully loaded, false otherwise """ path = None logfile_location = "" version_path_fragment = self.opt.game_version if version_path_fragment == "Antibirth": version_path_fragment = "Rebirth" if platform.system() == "Windows": logfile_location = os.environ[ 'USERPROFILE'] + '/Documents/My Games/Binding of Isaac {}/' elif platform.system() == "Linux": logfile_location = os.getenv( 'XDG_DATA_HOME', os.path.expanduser('~') + '/.local/share') + '/binding of isaac {}/' version_path_fragment = version_path_fragment.lower() elif platform.system() == "Darwin": logfile_location = os.path.expanduser( '~') + '/Library/Application Support/Binding of Isaac {}/' logfile_location = logfile_location.format(version_path_fragment) for check in (self.file_prefix + '../log.txt', logfile_location + 'log.txt'): if os.path.isfile(check): path = check break if path is None: return False cached_length = len(self.content) file_size = os.path.getsize(path) if cached_length > file_size or cached_length == 0: # New log file or first time loading the log self.reset() self.content = open(path, 'rb').read() elif cached_length < file_size: # Append existing content f = open(path, 'rb') f.seek(cached_length + 1) self.content += f.read() return True # Above are legacy method for handling end of run def __check_end_run(self, line_number, line): if not self.run_ended: died_to = "" end_type = "" # FIXME right now I don't think boss detection in the log is working properly if self.state.last_boss and self.state.last_boss[0] in [ '???', 'The Lamb', 'Mega Satan' ]: end_type = "Won" elif (self.state.seed != '') and line.startswith('RNG Start Seed:'): end_type = "Reset" elif line.startswith('Game Over.'): end_type = "Death" died_to = re.search(r'(?i)Killed by \((.*)\) spawned', line).group(1) if end_type: last_run = { "bosses": self.state.bosses, "items": self.state.item_list, "seed": self.state.seed, "died_to": died_to, "end_type": end_type } self.run_ended = True self.log.debug("End of Run! %s", last_run) if end_type != "Reset": self.__save_file(self.run_start_line, line_number, last_run) def __save_file(self, start, end, last_run): dir_name = self.file_prefix + "run_logs" if not os.path.isdir(dir_name): os.mkdir(dir_name) timestamp = int(time.time()) seed = self.state.seed.replace(" ", "") data = "\n".join(self.splitfile[start:end + 1]) # FIXME improve data = "%s\nRUN_OVER_LINE\n%s" % (data, last_run) run_name = "%s%s.log" % (seed, timestamp) in_memory_file = StringIO.StringIO() with zipfile.ZipFile(in_memory_file, mode='w', compression=zipfile.ZIP_DEFLATED) as zf: zf.writestr(run_name, data) with open(self.file_prefix + "run_logs/" + run_name + ".zip", "wb") as f: f.write(in_memory_file.getvalue())
class LogParser(object): """ This class load Isaac's log file, and incrementally modify a state representing this log """ def __init__(self, prefix, tracker_version, log_finder): self.state = TrackerState("", tracker_version, Options().game_version) self.log = logging.getLogger("tracker") self.wdir_prefix = prefix self.log_finder = log_finder self.reset() def reset(self): """Reset variable specific to the log file/run""" # Variables describing the parser state self.getting_start_items = False self.current_room = "" self.current_seed = "" # Cached contents of log self.content = "" # Log split into lines self.splitfile = [] self.run_start_line = 0 self.seek = 0 self.spawned_coop_baby = 0 self.log_file_handle = None # if they switched between rebirth and afterbirth, the log file we use could change self.log_file_path = self.log_finder.find_log_file(self.wdir_prefix) self.state.reset(self.current_seed, Options().game_version) def parse(self): """ Parse the log file and return a TrackerState object, or None if the log file couldn't be found """ self.opt = Options() # Attempt to load log_file if not self.__load_log_file(): return None self.splitfile = self.content.splitlines() # This will become true if we are getting starting items self.getting_start_items = False # Process log's new output for current_line_number, line in enumerate(self.splitfile[self.seek:]): self.__parse_line(current_line_number, line) self.seek = len(self.splitfile) return self.state def __parse_line(self, line_number, line): """ Parse a line using the (line_number, line) tuple """ # In Afterbirth+, nearly all lines start with this. # We want to slice it off. info_prefix = '[INFO] - ' if line.startswith(info_prefix): line = line[len(info_prefix):] # Messages printed by mods have this prefix. # strip it, so mods can spoof actual game log messages to us if they want to luadebug_prefix = 'Lua Debug: ' if line.startswith(luadebug_prefix): line = line[len(luadebug_prefix):] # AB and AB+ version messages both start with this text (AB+ has a + at the end) if line.startswith('Binding of Isaac: Afterbirth'): self.__parse_version_number(line) if line.startswith('Binding of Isaac: Rebirth'): self.__parse_version_number(line) if line.startswith('RNG Start Seed:'): self.__parse_seed(line, line_number) if line.startswith('Room'): self.__parse_room(line) if line.startswith('Level::Init'): self.__parse_floor(line, line_number) if line.startswith("Curse"): self.__parse_curse(line) if line.startswith("Spawn co-player!"): self.spawned_coop_baby = line_number + self.seek if re.search(r"Added \d+ Collectibles", line): self.log.debug("Reroll detected!") self.state.reroll() if line.startswith('Adding collectible '): self.__parse_item_add(line_number, line) if line.startswith('Gulping trinket '): self.__parse_trinket_gulp(line) if line.startswith('Removing collectible '): self.__parse_item_remove(line) def __trigger_new_run(self, line_number): self.log.debug("Starting new run, seed: %s", self.current_seed) self.run_start_line = line_number + self.seek self.state.reset(self.current_seed, Options().game_version) def __parse_version_number(self, line): words = line.split() self.state.version_number = words[-1] def __parse_seed(self, line, line_number): """ Parse a seed line """ # This assumes a fixed width, but from what I see it seems safe self.current_seed = line[16:25] # Antibirth doesn't have a proper way to detect run resets # it will wipe the tracker when doing a "continue" if self.opt.game_version == "Antibirth": self.__trigger_new_run(line_number) def __parse_room(self, line): """ Parse a room line """ if 'Start Room' not in line: self.getting_start_items = False match = re.search(r"Room (.+?)\(", line) if match: room_id = match.group(1) self.state.change_room(room_id) def __parse_floor(self, line, line_number): """ Parse the floor in line and push it to the state """ # Create a floor tuple with the floor id and the alternate id if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+": regexp_str = r"Level::Init m_Stage (\d+), m_StageType (\d+)" elif self.opt.game_version == "Rebirth" or self.opt.game_version == "Antibirth": regexp_str = r"Level::Init m_Stage (\d+), m_AltStage (\d+)" else: return search_result = re.search(regexp_str, line) if search_result is None: self.log.debug( "log.txt line doesn't match expected regex\nline: \"" + line + "\"\nregex:\"" + regexp_str + "\"") return floor = int(search_result.group(1)) alt = search_result.group(2) self.getting_start_items = True # we use generation of the first floor as our trigger that a new run started. # in antibirth, this doesn't work; instead we have to use the seed being printed as our trigger # that means if you s+q in antibirth, it resets the tracker. if floor == 1 and self.opt.game_version != "Antibirth": self.__trigger_new_run(line_number) # Special handling for the Cathedral and The Chest and Afterbirth if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+": # In Afterbirth, Cath is an alternate of Sheol (which is 10) # and Chest is an alternate of Dark Room (which is 11) if floor == 10 and alt == '0': floor -= 1 elif floor == 11 and alt == '1': floor += 1 else: # In Rebirth, floors have different numbers if alt == '1' and (floor == 9 or floor == 11): floor += 1 floor_id = 'f' + str(floor) # Greed mode if alt == '3': floor_id += 'g' self.state.add_floor(Floor(floor_id)) return True def __parse_curse(self, line): """ Parse the curse and add it to the last floor """ if line.startswith("Curse of the Labyrinth!"): self.state.add_curse(Curse.Labyrinth) if line.startswith("Curse of Blind"): self.state.add_curse(Curse.Blind) if line.startswith("Curse of the Lost!"): self.state.add_curse(Curse.Lost) def __parse_item_add(self, line_number, line): """ Parse an item and push it to the state """ if len(self.splitfile) > 1 and self.splitfile[line_number + self.seek - 1] == line: self.log.debug("Skipped duplicate item line from baby presence") return False space_split = line.split(" ") numeric_id = space_split[ 2] # When you pick up an item, this has the form: "Adding collectible 105 (The D6)" item_name = " ".join(space_split[3:])[1:-1] item_id = "" # Check if we recognize the numeric id if Item.contains_info(numeric_id): item_id = numeric_id else: # it might be a modded custom item. let's see if we recognize the name item_id = Item.modded_item_id_prefix + item_name if not Item.contains_info(item_id): item_id = "NEW" self.log.debug("Picked up item. id: %s, name: %s", item_id, item_name) if ((line_number + self.seek) - self.spawned_coop_baby) < (len(self.state.item_list) + 10) \ and self.state.contains_item(item_id): self.log.debug("Skipped duplicate item line from baby entry") return False # It's a blind pickup if we're on a blind floor and we don't have the Black Candle blind_pickup = self.state.last_floor.floor_has_curse( Curse.Blind) and not self.state.contains_item('260') added = self.state.add_item( Item(item_id, self.state.last_floor, self.getting_start_items, blind=blind_pickup)) if not added: self.log.debug( "Skipped adding item %s to avoid space-bar duplicate", item_id) return True def __parse_trinket_gulp(self, line): """ Parse a (modded) trinket gulp and push it to the state """ space_split = line.split(" ") # When using a mod like racing+, a trinket gulp has the form: "Gulping trinket 10" numeric_id = str( int(space_split[2]) + 2000) # the tracker hackily maps trinkets to items 2000 and up. # Check if we recognize the numeric id if Item.contains_info(numeric_id): item_id = numeric_id else: item_id = "NEW" self.log.debug("Gulped trinket: %s", item_id) added = self.state.add_item( Item(item_id, self.state.last_floor, self.getting_start_items)) if not added: self.log.debug( "Skipped adding item %s to avoid space-bar duplicate", item_id) return True def __parse_item_remove(self, line): """ Parse an item and remove it from the state """ space_split = line.split(" ") # Hacky string manipulation item_id = space_split[ 2] # When you lose an item, this has the form: "Removing collectible 105 (The D6)" item_name = " ".join(space_split[3:])[1:-1] # Check if the item ID exists if Item.contains_info(item_id): removal_id = item_id else: # that means it's probably a custom item removal_id = Item.modded_item_id_prefix + item_name self.log.debug("Removed item. id: %s", removal_id) # A check will be made inside the remove_item function # to see if this item is actually in our inventory or not. return self.state.remove_item(removal_id) def __load_log_file(self): if self.log_file_path is None: return False if self.log_file_handle is None: self.log_file_handle = open(self.log_file_path, 'rb') cached_length = len(self.content) file_size = os.path.getsize(self.log_file_path) if cached_length > file_size or cached_length == 0: # New log file or first time loading the log self.reset() self.content = open(self.log_file_path, 'rb').read() elif cached_length < file_size: # Append existing content self.log_file_handle.seek(cached_length + 1) self.content += self.log_file_handle.read() return True
class LogParser(object): """ This class loads Isaac's log file, and incrementally modify a state representing this log """ def __init__(self, prefix, tracker_version, log_finder): self.state = TrackerState("", tracker_version, Options().game_version) self.log = logging.getLogger("tracker") self.wdir_prefix = prefix self.log_finder = log_finder self.reset() def reset(self): """Reset variable specific to the log file/run""" # Variables describing the parser state self.getting_start_items = False self.reseeding_floor = False self.current_room = "" self.current_seed = "" # Cached contents of log self.content = "" # Log split into lines self.splitfile = [] self.run_start_line = 0 self.seek = 0 self.spawned_coop_baby = 0 self.log_file_handle = None # if they switched between rebirth and afterbirth, the log file we use could change self.log_file_path = self.log_finder.find_log_file(self.wdir_prefix) self.state.reset(self.current_seed, Options().game_version) def parse(self): """ Parse the log file and return a TrackerState object, or None if the log file couldn't be found """ self.opt = Options() # Attempt to load log_file if not self.__load_log_file(): return None self.splitfile = self.content.splitlines() # This will become true if we are getting starting items self.getting_start_items = False # Process log's new output for current_line_number, line in enumerate(self.splitfile[self.seek:]): self.__parse_line(current_line_number, line) self.seek = len(self.splitfile) return self.state def __parse_line(self, line_number, line): """ Parse a line using the (line_number, line) tuple """ # In Afterbirth+, nearly all lines start with this. # We want to slice it off. info_prefix = '[INFO] - ' if line.startswith(info_prefix): line = line[len(info_prefix):] # Messages printed by mods have this prefix. # strip it, so mods can spoof actual game log messages to us if they want to luadebug_prefix ='Lua Debug: ' if line.startswith(luadebug_prefix): line = line[len(luadebug_prefix):] # AB and AB+ version messages both start with this text (AB+ has a + at the end) if line.startswith('Binding of Isaac: Afterbirth'): self.__parse_version_number(line) if line.startswith('Binding of Isaac: Rebirth'): self.__parse_version_number(line) if line.startswith('RNG Start Seed:'): self.__parse_seed(line, line_number) if line.startswith('Room'): self.__parse_room(line) if line.startswith('Level::Init'): self.__parse_floor(line, line_number) if line.startswith("Curse"): self.__parse_curse(line) if line.startswith("Spawn co-player!"): self.spawned_coop_baby = line_number + self.seek if re.search(r"Added \d+ Collectibles", line): self.log.debug("Reroll detected!") self.state.reroll() if line.startswith('Adding collectible '): self.__parse_item_add(line_number, line) if line.startswith('Gulping trinket '): self.__parse_trinket_gulp(line) if line.startswith('Removing collectible '): self.__parse_item_remove(line) if line.startswith('Executing command: reseed'): # racing+ re-generates floors if they contain duplicate rooms. we need to track that this is happening # so we don't erroneously think the entire run is being restarted when it happens on b1. self.reseeding_floor = True def __trigger_new_run(self, line_number): self.log.debug("Starting new run, seed: %s", self.current_seed) self.run_start_line = line_number + self.seek self.state.reset(self.current_seed, Options().game_version) def __parse_version_number(self, line): words = line.split() self.state.version_number = words[-1] def __parse_seed(self, line, line_number): """ Parse a seed line """ # This assumes a fixed width, but from what I see it seems safe self.current_seed = line[16:25] # Antibirth doesn't have a proper way to detect run resets # it will wipe the tracker when doing a "continue" if self.opt.game_version == "Antibirth": self.__trigger_new_run(line_number) def __parse_room(self, line): """ Parse a room line """ if 'Start Room' not in line: self.getting_start_items = False match = re.search(r"Room (.+?)\(", line) if match: room_id = match.group(1) self.state.change_room(room_id) def __parse_floor(self, line, line_number): """ Parse the floor in line and push it to the state """ # Create a floor tuple with the floor id and the alternate id if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+": regexp_str = r"Level::Init m_Stage (\d+), m_StageType (\d+)" elif self.opt.game_version == "Rebirth" or self.opt.game_version == "Antibirth": regexp_str = r"Level::Init m_Stage (\d+), m_AltStage (\d+)" else: return search_result = re.search(regexp_str, line) if search_result is None: self.log.debug("log.txt line doesn't match expected regex\nline: \"" + line+ "\"\nregex:\"" + regexp_str + "\"") return floor = int(search_result.group(1)) alt = search_result.group(2) self.getting_start_items = True # we use generation of the first floor as our trigger that a new run started. # in racing+, it doesn't count if the game is currently in the process of "reseeding" that floor. # in antibirth, this doesn't work at all; instead we have to use the seed being printed as our trigger. # that means if you s+q in antibirth, it resets the tracker. if self.reseeding_floor: self.reseeding_floor = False elif floor == 1 and self.opt.game_version != "Antibirth": self.__trigger_new_run(line_number) # Special handling for the Cathedral and The Chest and Afterbirth if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+": # In Afterbirth, Cath is an alternate of Sheol (which is 10) # and Chest is an alternate of Dark Room (which is 11) if floor == 10 and alt == '0': floor -= 1 elif floor == 11 and alt == '1': floor += 1 else: # In Rebirth, floors have different numbers if alt == '1' and (floor == 9 or floor == 11): floor += 1 floor_id = 'f' + str(floor) # Greed mode if alt == '3': floor_id += 'g' self.state.add_floor(Floor(floor_id)) return True def __parse_curse(self, line): """ Parse the curse and add it to the last floor """ if line.startswith("Curse of the Labyrinth!"): self.state.add_curse(Curse.Labyrinth) if line.startswith("Curse of Blind"): self.state.add_curse(Curse.Blind) if line.startswith("Curse of the Lost!"): self.state.add_curse(Curse.Lost) def __parse_item_add(self, line_number, line): """ Parse an item and push it to the state """ if len(self.splitfile) > 1 and self.splitfile[line_number + self.seek - 1] == line: self.log.debug("Skipped duplicate item line from baby presence") return False space_split = line.split(" ") numeric_id = space_split[2] # When you pick up an item, this has the form: "Adding collectible 105 (The D6)" item_name = " ".join(space_split[3:])[1:-1] item_id = "" # Check if we recognize the numeric id if Item.contains_info(numeric_id): item_id = numeric_id else: # it might be a modded custom item. let's see if we recognize the name item_id = Item.modded_item_id_prefix + item_name if not Item.contains_info(item_id): item_id = "NEW" self.log.debug("Picked up item. id: %s, name: %s", item_id, item_name) if ((line_number + self.seek) - self.spawned_coop_baby) < (len(self.state.item_list) + 10) \ and self.state.contains_item(item_id): self.log.debug("Skipped duplicate item line from baby entry") return False # It's a blind pickup if we're on a blind floor and we don't have the Black Candle blind_pickup = self.state.last_floor.floor_has_curse(Curse.Blind) and not self.state.contains_item('260') added = self.state.add_item(Item(item_id, self.state.last_floor, self.getting_start_items, blind=blind_pickup)) if not added: self.log.debug("Skipped adding item %s to avoid space-bar duplicate", item_id) return True def __parse_trinket_gulp(self, line): """ Parse a (modded) trinket gulp and push it to the state """ space_split = line.split(" ") # When using a mod like racing+, a trinket gulp has the form: "Gulping trinket 10" numeric_id = str(int(space_split[2]) + 2000) # the tracker hackily maps trinkets to items 2000 and up. # Check if we recognize the numeric id if Item.contains_info(numeric_id): item_id = numeric_id else: item_id = "NEW" self.log.debug("Gulped trinket: %s", item_id) added = self.state.add_item(Item(item_id, self.state.last_floor, self.getting_start_items)) if not added: self.log.debug("Skipped adding item %s to avoid space-bar duplicate", item_id) return True def __parse_item_remove(self, line): """ Parse an item and remove it from the state """ space_split = line.split(" ") # Hacky string manipulation item_id = space_split[2] # When you lose an item, this has the form: "Removing collectible 105 (The D6)" item_name = " ".join(space_split[3:])[1:-1] # Check if the item ID exists if Item.contains_info(item_id): removal_id = item_id else: # that means it's probably a custom item removal_id = Item.modded_item_id_prefix + item_name self.log.debug("Removed item. id: %s", removal_id) # A check will be made inside the remove_item function # to see if this item is actually in our inventory or not. return self.state.remove_item(removal_id) def __load_log_file(self): if self.log_file_path is None: return False if self.log_file_handle is None: self.log_file_handle = open(self.log_file_path, 'r') cached_length = len(self.content) file_size = os.path.getsize(self.log_file_path) if cached_length > file_size or cached_length == 0: # New log file or first time loading the log self.reset() self.content = open(self.log_file_path, 'r').read() elif cached_length < file_size: # Append existing content self.log_file_handle.seek(cached_length + 1) self.content += self.log_file_handle.read() return True
def run(self): """ The main routine which controls everything """ framecount = 0 # Create drawing tool to use to draw everything - it'll create its own screen drawing_tool = DrawingTool(wdir_prefix) drawing_tool.set_window_title_info(update_notifier=(" v" + self.tracker_version)) opt = Options() parser = LogParser(wdir_prefix, self.tracker_version, LogFinder()) event_result = None state = None custom_title_enabled = opt.custom_title_enabled read_from_server = opt.read_from_server write_to_server = opt.write_to_server game_version = opt.game_version state_version = -1 twitch_username = None new_states_queue = [] screen_error_message = None retry_in = 0 update_timer = opt.log_file_check_seconds last_game_version = None while event_result != Event.DONE: # Check for events and handle them event_result = drawing_tool.handle_events() # The user checked or unchecked the "Custom Title Enabled" checkbox if opt.custom_title_enabled != custom_title_enabled: custom_title_enabled = opt.custom_title_enabled drawing_tool.update_window_title() # The user started or stopped watching someone from the server (or they started watching a new person from the server) if opt.read_from_server != read_from_server or opt.twitch_name != twitch_username: twitch_username = opt.twitch_name read_from_server = opt.read_from_server new_states_queue = [] # Also restart version count if we go back and forth from log.txt to server if read_from_server: state_version = -1 state = None # Change the delay for polling, as we probably don't want to fetch it every second update_timer_override = 2 # Show who we are watching in the title bar drawing_tool.set_window_title_info(watching=True, watching_player=twitch_username, updates_queued=len(new_states_queue)) else: drawing_tool.set_window_title_info(watching=False) update_timer_override = 0 # The user started or stopped broadcasting to the server if opt.write_to_server != write_to_server: write_to_server = opt.write_to_server drawing_tool.set_window_title_info(uploading=opt.write_to_server) if opt.game_version != game_version: parser.reset() game_version = opt.game_version # Force refresh state if we updated options or if we need to retry # to contact the server. if (event_result == Event.OPTIONS_UPDATE or (screen_error_message is not None and retry_in == 0)): # By setting the framecount to 0 we ensure we'll refresh the state right away framecount = 0 screen_error_message = None retry_in = 0 # Force updates after changing options if state is not None: state.modified = True # normally we check for updates based on how the option is set # when doing network stuff, this can be overridden update_delay = opt.log_file_check_seconds if update_timer_override != 0: update_delay = update_timer_override # Now we re-process the log file to get anything that might have loaded; # do it every update_timer seconds (making sure to truncate to an integer # or else it might never mod to 0) frames_between_checks = int(Options().framerate_limit * update_delay) if frames_between_checks <= 0: frames_between_checks = 1 if framecount % frames_between_checks == 0: if retry_in != 0: retry_in -= 1 # Let the parser do his thing and give us a state if opt.read_from_server: base_url = opt.trackerserver_url + "/tracker/api/user/" + opt.twitch_name json_dict = None try: json_version = urllib.request.urlopen(base_url + "/version").read() if int(json_version) > state_version: # FIXME better handling of 404 error ? json_state = urllib.request.urlopen(base_url).read() json_dict = json.loads(json_state, "utf-8") new_state = TrackerState.from_json(json_dict) if new_state is None: raise Exception("server gave us empty state") state_version = int(json_version) new_states_queue.append((state_version, new_state)) drawing_tool.set_window_title_info(updates_queued=len(new_states_queue)) except Exception: state = None log_error("Couldn't load state from server\n" + traceback.format_exc()) if json_dict is not None: if "tracker_version" in json_dict: their_version = json_dict["tracker_version"] else: # This is the only version that can upload to the server but doesn't include a version string their_version = "0.10-beta1" if their_version != self.tracker_version: screen_error_message = "They are using tracker version " + their_version + " but you have " + self.tracker_version else: force_draw = state and state.modified state = parser.parse() if force_draw: state.modified = True if write_to_server and not opt.trackerserver_authkey: screen_error_message = "Your authkey is blank. Get a new authkey in the options menu and paste it into the authkey text field." if state is not None and write_to_server and state.modified and screen_error_message is None: opener = urllib.request.build_opener(urllib.request.HTTPHandler) put_url = opt.trackerserver_url + "/tracker/api/update/" + opt.trackerserver_authkey json_string = json.dumps(state, cls=TrackerStateEncoder, sort_keys=True) request = urllib.request.Request(put_url, data=json_string) request.add_header('Content-Type', 'application/json') request.get_method = lambda: 'PUT' try: result = opener.open(request) result_json = json.loads(result.read()) updated_user = result_json["updated_user"] if updated_user is None: screen_error_message = "The server didn't recognize you. Try getting a new authkey in the options menu." else: screen_error_message = None except Exception as e: log_error("ERROR: Couldn't send item info to server\n" + traceback.format_exc()) screen_error_message = "ERROR: Couldn't send item info to server, check tracker_log.txt" # Retry to write the state in 10*update_timer (aka 10 sec in write mode) retry_in = 10 # Check the new state at the front of the queue to see if it's time to use it if len(new_states_queue) > 0: (state_timestamp, new_state) = new_states_queue[0] current_timestamp = int(time.time()) if current_timestamp - state_timestamp >= opt.read_delay or opt.read_delay == 0 or state is None: state = new_state new_states_queue.pop(0) drawing_tool.set_window_title_info(updates_queued=len(new_states_queue)) if state is None and screen_error_message is None: if read_from_server: screen_error_message = "Unable to read state from server. Please verify your options setup and tracker_log.txt" # Retry to read the state in 5*update_timer (aka 10 sec in read mode) retry_in = 5 else: screen_error_message = "log.txt for " + opt.game_version + " not found. Make sure you have the right game selected in the options." if screen_error_message is not None: drawing_tool.write_error_message(screen_error_message) else: # We got a state, now we draw it drawing_tool.draw_state(state) # if we're watching someone and they change their game version, it can require us to reset if state and last_game_version != state.game_version: drawing_tool.reset_options() last_game_version = state.game_version drawing_tool.tick() framecount += 1 # Main loop finished; program is exiting drawing_tool.save_window_position() Options().save_options(wdir_prefix + "options.json")
class LogParser(object): """ This class load Isaac's log file, and incrementally modify a state representing this log """ def __init__(self, prefix, tracker_version): self.state = TrackerState("", tracker_version) self.log = logging.getLogger("tracker") self.file_prefix = prefix self.game_version = "" self.__reset() # FIXME run summary. self.run_ended = 0 def __reset(self): """Reset variable specific to the log file/run""" # Variables describing the parser state self.getting_start_items = False self.current_room = "" self.current_seed = "" # Cached contents of log self.content = "" # Log split into lines self.splitfile = [] self.run_start_line = 0 self.seek = 0 self.spawned_coop_baby = 0 def parse(self): """ Parse the log file and return a TrackerState object, or None if the log file couldn't be found """ # Attempt to load log_file if not self.__load_log_file(): return None self.splitfile = self.content.splitlines() self.getting_start_items = False # This will become true if we are getting starting items # Process log's new output for current_line_number, line in enumerate(self.splitfile[self.seek:]): self.__parse_line(current_line_number, line) self.seek = len(self.splitfile) return self.state def __parse_line(self, line_number, line): """ Parse a line using the (line_number, line) tuple """ if line.startswith('Mom clear time:'): self.__parse_boss(line) # Check and handle the end of the run; the order is important # - we want it after boss kill but before "RNG Start Seed" self.__check_end_run(line_number + self.seek, line) if line.startswith('RNG Start Seed:'): self.__parse_seed(line) if line.startswith('Room'): self.__parse_room(line) if line.startswith('Level::Init'): self.__parse_floor(line_number, line) if line.startswith("Curse"): self.__parse_curse(line) if line.startswith("Spawn co-player!"): self.spawned_coop_baby = line_number + self.seek if re.search(r"Added \d+ Collectibles", line): self.log.debug("Reroll detected!") self.state.reroll() if line.startswith('Adding collectible'): self.__parse_item(line_number, line) def __parse_boss(self, line): """ Parse a boss line """ # TODO "Boss %i added to SaveState", Mom:6, It lives:25, # Satan/The Lamb:24/54 Isaac/???:39/40, Mega Satan:55 kill_time = int(line.split(" ")[-1]) # If you re-enter a room you get a "mom clear time" again, # check for that (can you fight the same boss twice?) # FIXME right now we only have support for Mom (6) self.state.add_boss("6") def __parse_seed(self, line): """ Parse a seed line """ # This assumes a fixed width, but from what I see it seems safe self.current_seed = line[16:25] def __parse_room(self, line): """ Parse a room line """ self.current_room = re.search(r'\((.*)\)', line).group(1) if 'Start Room' not in line: self.getting_start_items = False self.log.debug("Entered room: %s", self.current_room) def __parse_floor(self, line_number, line): """ Parse the floor in line and push it to the state """ # Create a floor tuple with the floor id and the alternate id if self.game_version == "Afterbirth": regexp_str = r"Level::Init m_Stage (\d+), m_StageType (\d+)" else: regexp_str = r"Level::Init m_Stage (\d+), m_AltStage (\d+)" floor_tuple = tuple([re.search(regexp_str, line).group(x) for x in [1, 2]]) self.getting_start_items = True # Assume floors aren't cursed until we see they are floor = int(floor_tuple[0]) alt = floor_tuple[1] # Special handling for cath and chest and Afterbirth if self.game_version == "Afterbirth": # In Afterbirth Cath is an alternate of Sheol (which is 10) # and Chest is an alternate of Dark room (which is 11) if floor == 10 and alt == '0': floor -= 1 elif floor == 11 and alt == '1': floor += 1 else: # In Rebirth floors have different numbers if alt == '1' and (floor == 9 or floor == 11): floor += 1 floor_id = 'f' + str(floor) # Greed mode if alt == '3': floor_id += 'g' # when we see a new floor 1, that means a new run has started if floor == 1: self.log.debug("Starting new run, seed: %s", self.current_seed) self.run_start_line = line_number + self.seek self.state.reset(self.current_seed) self.run_ended = False self.state.add_floor(Floor(floor_id)) return True def __parse_curse(self, line): """ Parse the curse and add it to the last floor """ if line.startswith("Curse of the Labyrinth!"): self.state.add_curse(Curse.Labyrinth) if line.startswith("Curse of Blind"): self.state.add_curse(Curse.Blind) if line.startswith("Curse of the Lost!"): self.state.add_curse(Curse.Lost) def __parse_item(self, line_number, line): """ Parse an Item and push it to the state """ if len(self.splitfile) > 1 and self.splitfile[line_number + self.seek - 1] == line: self.log.debug("Skipped duplicate item line from baby presence") return False space_split = line.split(" ") # Hacky string manipulation item_id = space_split[2] # A string has the form of "Adding collectible 105 (The D6)" # Check if the item ID exists if not Item.contains_info(item_id): item_id = "NEW" item_name = " ".join(space_split[3:])[1:-1] self.log.debug("Picked up item. id: %s, name: %s", item_id, item_name) if ((line_number + self.seek) - self.spawned_coop_baby) < (len(self.state.item_list) + 10) \ and self.state.contains_item(item_id): self.log.debug("Skipped duplicate item line from baby entry") return False #it's a blind pickup if we're on a blind floor and they don't have the black candle blind_pickup = self.state.last_floor.floor_has_curse(Curse.Blind) and not self.state.contains_item('260') added = self.state.add_item(Item(item_id, self.state.last_floor, self.getting_start_items, blind=blind_pickup)) if not added: self.log.debug("Skipped adding item %s to avoid space-bar duplicate", item_id) return True def __load_log_file(self): """ Attempt to load log file from common location. Return true if successfully loaded, false otherwise """ path = None logfile_location = "" game_names = ("Afterbirth", "Rebirth") if platform.system() == "Windows": logfile_location = os.environ['USERPROFILE'] + '/Documents/My Games/Binding of Isaac {}/' elif platform.system() == "Linux": logfile_location = os.getenv('XDG_DATA_HOME', os.path.expanduser('~') + '/.local/share') + '/binding of isaac {}/' game_names = ("afterbirth", "rebirth") elif platform.system() == "Darwin": logfile_location = os.path.expanduser('~') + '/Library/Application Support/Binding of Isaac {}/' if os.path.exists(logfile_location.format(game_names[0])): logfile_location = logfile_location.format(game_names[0]) self.game_version = "Afterbirth" else: logfile_location = logfile_location.format(game_names[1]) self.game_version = "Rebirth" for check in (self.file_prefix + '../log.txt', logfile_location + 'log.txt'): if os.path.isfile(check): path = check break if path is None: return False cached_length = len(self.content) file_size = os.path.getsize(path) if cached_length > file_size or cached_length == 0: # New log file or first time loading the log self.__reset() self.content = open(path, 'rb').read() elif cached_length < file_size: # Append existing content f = open(path, 'rb') f.seek(cached_length + 1) self.content += f.read() return True # Above are legacy method for handling end of run def __check_end_run(self, line_number, line): if not self.run_ended: died_to = "" end_type = "" # FIXME right now I don't think boss detection in the log is working properly if self.state.last_boss and self.state.last_boss[0] in ['???', 'The Lamb', 'Mega Satan']: end_type = "Won" elif (self.state.seed != '') and line.startswith('RNG Start Seed:'): end_type = "Reset" elif line.startswith('Game Over.'): end_type = "Death" died_to = re.search(r'(?i)Killed by \((.*)\) spawned', line).group(1) if end_type: last_run = { "bosses": self.state.bosses, "items": self.state.item_list, "seed": self.state.seed, "died_to": died_to, "end_type": end_type } self.run_ended = True self.log.debug("End of Run! %s", last_run) if end_type != "Reset": self.__save_file(self.run_start_line, line_number, last_run) def __save_file(self, start, end, last_run): dir_name = self.file_prefix + "run_logs" if not os.path.isdir(dir_name): os.mkdir(dir_name) timestamp = int(time.time()) seed = self.state.seed.replace(" ", "") data = "\n".join(self.splitfile[start:end + 1]) # FIXME improve data = "%s\nRUN_OVER_LINE\n%s" % (data, last_run) run_name = "%s%s.log" % (seed, timestamp) in_memory_file = StringIO.StringIO() with zipfile.ZipFile(in_memory_file, mode='w', compression=zipfile.ZIP_DEFLATED) as zf: zf.writestr(run_name, data) with open(self.file_prefix + "run_logs/" + run_name + ".zip", "wb") as f: f.write(in_memory_file.getvalue())