async def perform_memory_operations( self, ops: List[MemoryOperation]) -> Dict[MemoryOperation, bytes]: if self._socket is None: raise MemoryOperationException("Not connected") requests = self._prepare_requests_for(ops) all_responses = await self._send_requests_to_socket(requests) result = {} for request, response in zip(requests, all_responses): read_index = request.num_validator_bytes for i, op in enumerate(request.ops): if op.read_byte_count is None: continue if _was_invalid_address(response, i): raise MemoryOperationException( "Operation tried to read an invalid address") split = response[read_index:read_index + op.read_byte_count] if len(split) != op.read_byte_count: raise MemoryOperationException( f"Received {len(split)} bytes, expected {op.read_byte_count}" ) else: assert op not in result result[op] = split read_index += op.read_byte_count return result
async def perform_memory_operations( self, ops: List[MemoryOperation]) -> Dict[MemoryOperation, bytes]: pointers_to_read = set() for op in ops: if op.offset is not None: pointers_to_read.add(op.address) pointers = {} for pointer in pointers_to_read: if not self.dolphin.is_hooked(): raise MemoryOperationException("Lost connection do Dolphin") try: pointers[pointer] = self.dolphin.follow_pointers(pointer, [0]) except RuntimeError: pointers[pointer] = None self.logger.debug( f"Failed to read a valid pointer from {pointer:x}") self._test_still_hooked() if not self.dolphin.is_hooked(): raise MemoryOperationException("Lost connection do Dolphin") result = {} for op in ops: op_result = self._memory_operation(op, pointers) if op_result is not None: result[op] = op_result return result
async def _send_requests_to_socket( self, requests: List[RequestBatch]) -> List[bytes]: all_responses = [] try: for request in requests: data = request.build_request_data() self._socket.writer.write(data) await self._socket.writer.drain() if request.output_bytes > 0: response = await asyncio.wait_for( self._socket.reader.read(1024), timeout=15) all_responses.append(response) else: all_responses.append(b"") except (OSError, asyncio.TimeoutError) as e: if isinstance(e, asyncio.TimeoutError): self.logger.warning( f"Timeout when reading response from {self._ip}") self._socket_error = MemoryOperationException( f"Timeout when reading response") else: self.logger.warning( f"Unable to send {len(requests)} requests to {self._ip}:{self._port}: {e}" ) self._socket_error = MemoryOperationException( f"Unable to send {len(requests)} requests: {e}") await self.disconnect() raise self._socket_error from e return all_responses
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_update_calls_interact_with_game(backend: ConnectionBackend, interact_fails, expected_game, connected_game): # Setup backend.connector = None backend._world = True backend.set_expected_game(expected_game) if connected_game is not None: connector = AsyncMock() connector.game_enum = connected_game else: connector = None backend._interact_with_game = AsyncMock(side_effect=MemoryOperationException("err") if interact_fails else None) backend._identify_game = AsyncMock(return_value=connector) should_call_interact = connected_game is not None if should_call_interact: should_call_interact = (expected_game == connected_game) or (expected_game is None) # Run await backend.update(1) # Assert backend._identify_game.assert_awaited_once_with() if should_call_interact: backend._interact_with_game.assert_awaited_once_with(1) if interact_fails: assert backend._world is None else: assert backend._world is not None else: backend._interact_with_game.assert_not_awaited()
async def test_interact_with_game(backend: ConnectionBackend, depth: int, failure_at: Optional[int]): # Setup backend.message_cooldown = 2 backend.connector = AsyncMock() backend.connector.current_game_status.return_value = ( depth <= 1, # has pending op MagicMock() if depth > 0 else None, # world ) expectation = contextlib.nullcontext() if failure_at == 1: backend.connector.get_inventory.side_effect = MemoryOperationException("error at _get_inventory") expectation = pytest.raises(MemoryOperationException, match="error at _get_inventory") backend._multiworld_interaction = AsyncMock() if failure_at == 2: backend._multiworld_interaction.side_effect = MemoryOperationException("error at _check_for_collected_index") expectation = pytest.raises(MemoryOperationException, match="error at _check_for_collected_index") expected_depth = min(depth, failure_at) if failure_at is not None else depth if (failure_at or 999) > depth: expectation = contextlib.nullcontext() # Run with expectation: await backend._interact_with_game(1) # Assert assert backend.message_cooldown == (2 if expected_depth < 2 else 1) backend.connector.current_game_status.assert_awaited_once_with(backend.executor) if expected_depth > 0: backend.connector.get_inventory.assert_awaited_once_with(backend.executor) else: backend.connector.get_inventory.assert_not_awaited() if expected_depth > 1: backend._multiworld_interaction.assert_awaited_once_with() else: backend._multiworld_interaction.assert_not_awaited() if 0 < depth: assert backend._world is not None else: assert backend._world is None
async def get_inventory(self, executor: MemoryOperationExecutor) -> Inventory: """Fetches the inventory represented by the given game memory.""" memory_ops = await self._memory_op_for_items(executor, [ item for item in self.game.resource_database.item if item.extra["item_id"] < 1000 ]) ops_result = await executor.perform_memory_operations(memory_ops) inventory = {} for item, memory_op in zip(self.game.resource_database.item, memory_ops): inv = InventoryItem(*struct.unpack(">II", ops_result[memory_op])) if (inv.amount > inv.capacity or inv.capacity > item.max_capacity ) and (item != self.game.resource_database.multiworld_magic_item): raise MemoryOperationException( f"Received {inv} for {item.long_name}, which is an invalid state." ) inventory[item] = inv return inventory
def _was_invalid_address(response: bytes, i: int) -> bool: try: return not response[i // 8] & (1 << (i % 8)) except IndexError: raise MemoryOperationException( "Server response too short for validator bytes")
def _validate_range(address: int, size: int): if address < MEM1_START or address + size > MEM1_END: raise MemoryOperationException( f"Range {address:x} -> {address + size:x} is outside of the GameCube memory range." )