async def execute_remote_patches(self, executor: MemoryOperationExecutor, patches: List[DolRemotePatch]) -> None: """ Executes a given set of patches on the given memory operator. Should only be called if the bool returned by `current_game_status` is False, but validation of this fact is implementation-dependant. :param executor: :param patches: List of patches to execute :return: """ memory_operations = [] for patch in patches: memory_operations.extend(patch.memory_operations) patch_address, patch_bytes = all_prime_dol_patches.create_remote_execution_body( self.version.string_display, [ instruction for patch in patches for instruction in patch.instructions ], ) memory_operations.extend([ MemoryOperation(patch_address, write_bytes=patch_bytes), MemoryOperation(self.version.cstate_manager_global + 0x2, write_bytes=b"\x01"), ]) self.logger.debug( f"Performing {len(memory_operations)} ops with {len(patches)} patches" ) await executor.perform_memory_operations(memory_operations)
async def _ensure_initialized_game_memory(self): if self._game_memory_initialized: return world = self.game.world_list.worlds[0] await self.perform_memory_operations([ # Build String MemoryOperation(self._used_version.build_string_address, write_bytes=self._used_version.build_string), # current CWorld MemoryOperation(self._used_version.game_state_pointer, offset=4, write_bytes=world.extra['asset_id'].to_bytes( 4, "big")), # CPlayer VTable MemoryOperation( self._used_version.cstate_manager_global + 0x14fc, offset=0, write_bytes=self._used_version.cplayer_vtable.to_bytes( 4, "big")), # CPlayerState MemoryOperation(self._used_version.cstate_manager_global + 0x150c, write_bytes=0xA00000.to_bytes(4, "big")), ]) self._game_memory_initialized = True
async def current_game_status(self, executor: MemoryOperationExecutor) -> Tuple[bool, Optional[World]]: """ Fetches the world the player's currently at, or None if they're not in-game. :param executor: :return: bool indicating if there's a pending `execute_remote_patches` operation. """ cstate_manager_global = self.version.cstate_manager_global mlvl_offset = 8 asset_id_size = struct.calcsize(self._asset_id_format()) # TODO: there's one extra pointer indirection cplayer_offset = 40 player_offset = 0x2184 memory_ops = [ MemoryOperation(self.version.game_state_pointer, offset=mlvl_offset, read_byte_count=asset_id_size), MemoryOperation(cstate_manager_global + 0x2, read_byte_count=1), MemoryOperation(cstate_manager_global + cplayer_offset, offset=player_offset, read_byte_count=4), ] results = await executor.perform_memory_operations(memory_ops) player_pointer = results.get(memory_ops[2]) player_vtable = None if player_pointer is not None: player_vtable = await executor.perform_single_memory_operation(MemoryOperation( struct.unpack(">I", player_pointer)[0], read_byte_count=4, )) pending_op_byte = results[memory_ops[1]] has_pending_op = pending_op_byte != b"\x00" return has_pending_op, self._current_status_world(results.get(memory_ops[0]), player_vtable)
async def current_game_status( self, executor: MemoryOperationExecutor) -> Tuple[bool, Optional[World]]: """ Fetches the world the player's currently at, or None if they're not in-game. :param executor: :return: bool indicating if there's a pending `execute_remote_patches` operation. """ cstate_manager_global = self.version.cstate_manager_global asset_id_size = struct.calcsize(self._asset_id_format()) mlvl_offset = 0x84 cplayer_offset = 0x84c memory_ops = [ MemoryOperation(self.version.game_state_pointer, offset=mlvl_offset, read_byte_count=asset_id_size), MemoryOperation(cstate_manager_global + 0x2, read_byte_count=1), MemoryOperation(cstate_manager_global + cplayer_offset, offset=0, read_byte_count=4), ] results = await executor.perform_memory_operations(memory_ops) pending_op_byte = results[memory_ops[1]] has_pending_op = pending_op_byte != b"\x00" return has_pending_op, self._current_status_world( results.get(memory_ops[0]), results.get(memory_ops[2]))
async def test_execute_remote_patches(connector: EchoesRemoteConnector, version: EchoesDolVersion, mocker): # Setup patch_address, patch_bytes = MagicMock(), MagicMock() mock_remote_execute: MagicMock = mocker.patch( "randovania.patching.prime.all_prime_dol_patches.create_remote_execution_body", return_value=(patch_address, patch_bytes)) executor = AsyncMock() memory_op_a = MemoryOperation(1234, write_bytes=b"1234") instructions = [BaseInstruction(), BaseInstruction()] patches = [ DolRemotePatch([memory_op_a], instructions[:1]), DolRemotePatch([], instructions[1:]), ] memory_operations = [ memory_op_a, MemoryOperation(patch_address, write_bytes=patch_bytes), MemoryOperation(version.cstate_manager_global + 0x2, write_bytes=b"\x01"), ] # Run await connector.execute_remote_patches(executor, patches) # Assert mock_remote_execute.assert_called_once_with(version.string_display, instructions) executor.perform_memory_operations.assert_awaited_once_with( memory_operations)
def _memory_operation( self, op: MemoryOperation, pointers: Dict[int, Optional[int]]) -> Optional[bytes]: op.validate_byte_sizes() address = op.address if op.offset is not None: if address not in pointers: raise MemoryOperationException( f"Invalid op: {address:x} is not in pointers") if pointers[address] is None: return None address = pointers[address] + op.offset _validate_range(address, op.byte_count) if not self.dolphin.is_hooked(): raise MemoryOperationException("Lost connection do Dolphin") try: result = None if op.read_byte_count is not None: result = self.dolphin.read_bytes(address, op.read_byte_count) if op.write_bytes is not None: self.dolphin.write_bytes(address, op.write_bytes) self.logger.debug( f"Wrote {op.write_bytes.hex()} to {address:x}") except RuntimeError as e: raise MemoryOperationException(f"Lost connection do Dolphin: {e}") return result
async def test_perform_memory_operations_invalid(executor: NintendontExecutor): executor._socket = MagicMock() executor._socket.max_input = 120 executor._socket.max_output = 100 executor._socket.max_addresses = 8 executor._socket.writer.drain = AsyncMock() executor._socket.reader.read = AsyncMock(side_effect=[ b"\x03" + b"A" * 50 + b"B" * 30, ]) # Run with pytest.raises(MemoryOperationException): await executor.perform_memory_operations([ MemoryOperation(0x1000, read_byte_count=50), MemoryOperation(0x2000, read_byte_count=10), MemoryOperation(0x2000, read_byte_count=10), ]) # Assert executor._socket.writer.drain.assert_has_awaits([call()]) executor._socket.writer.write.assert_has_calls([ call( b'\x00\x03\x02\x01\x00\x00\x10\x00\x00\x00 \x00\x802\x81\n\x81\n'), ]) executor._socket.reader.read.assert_has_awaits([call(1024)])
async def test_perform_memory_operations_success(executor: NintendontExecutor): executor._socket = MagicMock() executor._socket.max_input = 120 executor._socket.max_output = 100 executor._socket.max_addresses = 8 executor._socket.writer.drain = AsyncMock() executor._socket.reader.read = AsyncMock(side_effect=[ b"\x03" + b"A" * 50 + b"B" * 30, b"\x01" + b"C" * 60, ]) ops = { MemoryOperation(0x1000, read_byte_count=50): b"A" * 50, MemoryOperation(0x1000, offset=10, read_byte_count=30, write_bytes=b"1" * 30): b"B" * 30, MemoryOperation(0x1000, read_byte_count=60): b"C" * 60, } # Run result = await executor.perform_memory_operations(list(ops.keys())) # Assert executor._socket.writer.drain.assert_has_awaits([call(), call()]) executor._socket.writer.write.assert_has_calls([ call(b'\x00\x02\x01\x01\x00\x00\x10\x00' + b'\x80\x32' + b'\xd0\x1e\x00\n' + (b"1" * 30)), call(b'\x00\x01\x01\x01\x00\x00\x10\x00\x80\x3c'), ]) assert result == ops executor._socket.reader.read.assert_has_awaits([call(1024), call(1024)])
def _memory_operation(self, op: MemoryOperation) -> Optional[bytes]: op.validate_byte_sizes() address = op.address if op.offset is not None: address = self._read_memory_format(">I", address)[0] address += op.offset result = None if op.read_byte_count is not None: result = self._read_memory(address, op.read_byte_count) if op.write_bytes is not None: self._write_memory(address, op.write_bytes) return result
async def is_this_version(self, executor: MemoryOperationExecutor) -> bool: """Returns True if the accessible memory matches the version of this connector.""" operation = MemoryOperation(self.version.build_string_address, read_byte_count=len( self.version.build_string)) build_string = await executor.perform_single_memory_operation(operation ) return build_string == self.version.build_string
async def _memory_op_for_items( self, executor: MemoryOperationExecutor, items: List[ItemResourceInfo], ) -> List[MemoryOperation]: player_state_pointer = int.from_bytes( await executor.perform_single_memory_operation( MemoryOperation( address=self.version.cstate_manager_global + 0x8b8, read_byte_count=4, )), "big") return [ MemoryOperation( address=player_state_pointer, offset=_prime1_powerup_offset(item.extra["item_id"]), read_byte_count=8, ) for item in items ]
def _prepare_requests_for( self, ops: List[MemoryOperation]) -> List[RequestBatch]: requests: List[RequestBatch] = [] current_batch = RequestBatch() def _new_request(): nonlocal current_batch requests.append(current_batch) current_batch = RequestBatch() processes_ops = [] max_write_size = self._socket.max_input - 20 for i, op in enumerate(ops): if op.byte_count == 0: continue op.validate_byte_sizes() if op.read_byte_count is None and ( op.write_bytes is not None and len(op.write_bytes) > max_write_size): self.logger.debug( f"Operation {i} had {len(op.write_bytes)} bytes, " f"above the limit of {max_write_size}. Splitting.") for offset in range(0, len(op.write_bytes), max_write_size): if op.offset is None: address = op.address + offset op_offset = None else: address = op.address op_offset = op.offset + offset processes_ops.append( MemoryOperation( address=address, offset=op_offset, write_bytes=op.write_bytes[offset:min( offset + max_write_size, len(op.write_bytes))], )) else: processes_ops.append(op) for op in processes_ops: experimental = current_batch.copy() experimental.add_op(op) if not experimental.is_compatible_with(self._socket): _new_request() current_batch.add_op(op) if not current_batch.is_compatible_with(self._socket): raise ValueError( f"Request {op} is not compatible with current server.") # Finish the last batch _new_request() return requests
async def test_perform_memory_operations(executor: DolphinExecutor): executor.dolphin.follow_pointers.return_value = 0x80003000 executor.dolphin.read_bytes.side_effect = [b"A" * 50, b"B" * 30, b"C" * 10] # Run result = await executor.perform_memory_operations([ MemoryOperation(0x80001000, offset=20, read_byte_count=50), MemoryOperation(0x80001000, offset=10, read_byte_count=30, write_bytes=b"1" * 30), MemoryOperation(0x80002000, read_byte_count=10), ]) # Assert assert list(result.values()) == [b"A" * 50, b"B" * 30, b"C" * 10] executor.dolphin.follow_pointers.assert_called_once_with(0x80001000, [0x0]) executor.dolphin.read_bytes.assert_has_calls([ call(0x80003000 + 20, 50), call(0x80003000 + 10, 30), call(0x80002000, 10), ]) executor.dolphin.write_bytes.assert_called_once_with(0x80003000 + 10, b"1" * 30)
async def _memory_op_for_items(self, executor: MemoryOperationExecutor, items: List[ItemResourceInfo], ) -> List[MemoryOperation]: player_state_pointer = self.version.cstate_manager_global + 0x150c return [ MemoryOperation( address=player_state_pointer, offset=_echoes_powerup_offset(item.extra["item_id"]), read_byte_count=8, ) for item in items ]
async def test_write_string_to_game_buffer(connector: EchoesRemoteConnector, version: EchoesDolVersion, message_original, message_encoded, previous_size): # Setup connector._last_message_size = previous_size # Run result = connector._write_string_to_game_buffer(message_original) # Assert assert result == MemoryOperation( version.string_display.message_receiver_string_ref, write_bytes=message_encoded)
async def _identify_game(self) -> Optional[RemoteConnector]: all_connectors: List[PrimeRemoteConnector] = [ Prime1RemoteConnector(version) for version in prime1_dol_versions.ALL_VERSIONS ] all_connectors.extend([ EchoesRemoteConnector(version) for version in echoes_dol_versions.ALL_VERSIONS ]) all_connectors.extend([ CorruptionRemoteConnector(version) for version in corruption_dol_versions.ALL_VERSIONS ]) read_first_ops = [ MemoryOperation(connectors.version.build_string_address, read_byte_count=min( len(connectors.version.build_string), 4)) for connectors in all_connectors ] try: first_ops_result = await self.executor.perform_memory_operations( read_first_ops) except (RuntimeError, MemoryOperationException) as e: self.logger.debug(f"Unable to probe for game version: {e}") return None possible_connectors = [ connectors for connectors, read_op in zip(all_connectors, read_first_ops) if first_ops_result.get(read_op) == connectors.version.build_string[:4] ] for connector in possible_connectors: try: is_version = await connector.is_this_version(self.executor) except (RuntimeError, MemoryOperationException) as e: return None if is_version: self.logger.info( f"identified game as {connector.game_enum.long_name}: {connector.version.description}" ) return connector
async def test_fetch_game_status(connector: CorruptionRemoteConnector, has_world, has_pending_op, has_cplayer, correct_vtable): # Setup expected_world = connector.game.world_list.worlds[1] cplayer_address = 0x8099FFAA executor = AsyncMock() executor.perform_memory_operations.side_effect = lambda ops: { ops[0]: expected_world.extra["asset_id"].to_bytes(8, "big") if has_world else b"DEADBEEF", ops[1]: b"\x01" if has_pending_op else b"\x00", ops[2]: cplayer_address.to_bytes(4, "big") if has_cplayer else None, } if correct_vtable: vtable_memory_return = connector.version.cplayer_vtable.to_bytes( 4, "big") else: vtable_memory_return = b"CAFE" executor.perform_single_memory_operation.return_value = vtable_memory_return # Run actual_has_op, actual_world = await connector.current_game_status(executor) # Assert if has_world and has_cplayer and correct_vtable: assert actual_world is expected_world else: assert actual_world is None assert actual_has_op == has_pending_op if has_cplayer: executor.perform_single_memory_operation.assert_awaited_once_with( MemoryOperation( cplayer_address, read_byte_count=4, )) else: executor.perform_single_memory_operation.assert_not_awaited()
def _write_string_to_game_buffer(self, message: str) -> MemoryOperation: overhead_size = 6 # 2 bytes for an extra char to differentiate sizes encoded_message = message.encode( "utf-16_be")[:self.version.string_display.max_message_size - overhead_size] # The game doesn't handle very well a string at the same address with same size being # displayed multiple times if len(encoded_message) == self._last_message_size: encoded_message += b'\x00 ' self._last_message_size = len(encoded_message) # Add the null terminator encoded_message += b"\x00\x00" if len(encoded_message) & 3: # Ensure the size is a multiple of 4 num_to_align = (len(encoded_message) | 3) - len(encoded_message) + 1 encoded_message += b"\x00" * num_to_align return MemoryOperation( self.version.string_display.message_receiver_string_ref, write_bytes=encoded_message)
async def test_perform_single_giant_memory_operation( executor: NintendontExecutor): executor._socket = MagicMock() executor._socket.max_input = 120 executor._socket.max_output = 100 executor._socket.max_addresses = 8 executor._socket.writer.drain = AsyncMock() executor._socket.reader.read = AsyncMock(side_effect=[ b"\x01", b"\x01", ]) # Run result = await executor.perform_single_memory_operation( MemoryOperation(0x1000, write_bytes=b"1" * 200), ) # Assert executor._socket.writer.drain.assert_has_awaits([call(), call()]) executor._socket.writer.write.assert_has_calls([ call(b'\x00\x01\x01\x01\x00\x00\x10\x00' + b'\x40\x64' + (b"1" * 100)), call(b'\x00\x01\x01\x01\x00\x00\x10\x64' + b'\x40\x64' + (b"1" * 100)), ]) assert result is None executor._socket.reader.read.assert_has_awaits([call(1024), call(1024)])