mirror of
				https://github.com/PabloMK7/citra.git
				synced 2025-10-31 05:40:04 +00:00 
			
		
		
		
	UDS: Add coonnect and disconnect
This commit is contained in:
		
							parent
							
								
									3c03da12c8
								
							
						
					
					
						commit
						f29a9ac3c1
					
				
					 1 changed files with 200 additions and 49 deletions
				
			
		|  | @ -7,6 +7,7 @@ | |||
| #include <atomic> | ||||
| #include <cstring> | ||||
| #include <list> | ||||
| #include <map> | ||||
| #include <mutex> | ||||
| #include <unordered_map> | ||||
| #include <vector> | ||||
|  | @ -75,13 +76,22 @@ static u8 network_channel = DefaultNetworkChannel; | |||
| // Information about the network that we're currently connected to.
 | ||||
| static NetworkInfo network_info; | ||||
| 
 | ||||
| // Mapping of mac addresses to their respective node_ids.
 | ||||
| static std::map<MacAddress, u32> node_map; | ||||
| 
 | ||||
| // Event that will generate and send the 802.11 beacon frames.
 | ||||
| static CoreTiming::EventType* beacon_broadcast_event; | ||||
| 
 | ||||
| // Callback identifier for the OnWifiPacketReceived event.
 | ||||
| static Network::RoomMember::CallbackHandle<Network::WifiPacket> wifi_packet_received; | ||||
| 
 | ||||
| // Mutex to synchronize access to the connection status between the emulation thread and the
 | ||||
| // network thread.
 | ||||
| static std::mutex connection_status_mutex; | ||||
| 
 | ||||
| // token for the blocking ConnectToNetwork
 | ||||
| static ThreadContinuationToken connection_token; | ||||
| 
 | ||||
| // Mutex to synchronize access to the list of received beacons between the emulation thread and the
 | ||||
| // network thread.
 | ||||
| static std::mutex beacon_mutex; | ||||
|  | @ -119,7 +129,12 @@ std::list<Network::WifiPacket> GetReceivedBeacons(const MacAddress& sender) { | |||
| 
 | ||||
| /// Sends a WifiPacket to the room we're currently connected to.
 | ||||
| void SendPacket(Network::WifiPacket& packet) { | ||||
|     // TODO(Subv): Implement.
 | ||||
|     if (auto room_member = Network::GetRoomMember().lock()) { | ||||
|         if (room_member->GetState() == Network::RoomMember::State::Joined) { | ||||
|             packet.transmitter_address = room_member->GetMacAddress(); | ||||
|             room_member->SendWifiPacket(packet); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /*
 | ||||
|  | @ -214,6 +229,8 @@ static void HandleEAPoLPacket(const Network::WifiPacket& packet) { | |||
| 
 | ||||
|         network_info.total_nodes++; | ||||
| 
 | ||||
|         node_map[packet.transmitter_address] = node_id; | ||||
| 
 | ||||
|         // Send the EAPoL-Logoff packet.
 | ||||
|         using Network::WifiPacket; | ||||
|         WifiPacket eapol_logoff; | ||||
|  | @ -237,6 +254,7 @@ static void HandleEAPoLPacket(const Network::WifiPacket& packet) { | |||
|         } | ||||
|         auto logoff = ParseEAPoLLogoffFrame(packet.data); | ||||
| 
 | ||||
|         network_info.host_mac_address = packet.transmitter_address; | ||||
|         network_info.total_nodes = logoff.connected_nodes; | ||||
|         network_info.max_nodes = logoff.max_nodes; | ||||
| 
 | ||||
|  | @ -260,6 +278,9 @@ static void HandleEAPoLPacket(const Network::WifiPacket& packet) { | |||
|         // If blocking is implemented this lock needs to be changed,
 | ||||
|         // otherwise it might cause deadlocks
 | ||||
|         connection_status_event->Signal(); | ||||
|         if (connection_token.IsValid()) { | ||||
|             ContinueClientThread(connection_token); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  | @ -397,6 +418,36 @@ void HandleAuthenticationFrame(const Network::WifiPacket& packet) { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| /// Handles the deauthentication frames sent from clients to hosts, when they leave a session
 | ||||
| void HandleDeauthenticationFrame(const Network::WifiPacket& packet) { | ||||
|     LOG_DEBUG(Service_NWM, "called"); | ||||
|     std::unique_lock<std::recursive_mutex> hle_lock(HLE::g_hle_lock, std::defer_lock); | ||||
|     std::unique_lock<std::mutex> lock(connection_status_mutex, std::defer_lock); | ||||
|     std::lock(hle_lock, lock); | ||||
|     if (connection_status.status != static_cast<u32>(NetworkStatus::ConnectedAsHost)) { | ||||
|         LOG_ERROR(Service_NWM, "Got deauthentication frame but we are not the host"); | ||||
|         return; | ||||
|     } | ||||
|     if (node_map.find(packet.transmitter_address) == node_map.end()) { | ||||
|         LOG_ERROR(Service_NWM, "Got deauthentication frame from unknown node"); | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     u16 node_id = node_map[packet.transmitter_address]; | ||||
|     auto node = std::find_if(node_info.begin(), node_info.end(), [&node_id](const NodeInfo& info) { | ||||
|         return info.network_node_id == node_id + 1; | ||||
|     }); | ||||
|     ASSERT(node != node_info.end()); | ||||
| 
 | ||||
|     connection_status.node_bitmask &= ~(1 << node_id); | ||||
|     connection_status.changed_nodes |= 1 << node_id; | ||||
|     connection_status.total_nodes--; | ||||
| 
 | ||||
|     network_info.total_nodes--; | ||||
|     node_info.erase(node); | ||||
|     connection_status_event->Signal(); | ||||
| } | ||||
| 
 | ||||
| static void HandleDataFrame(const Network::WifiPacket& packet) { | ||||
|     switch (GetFrameEtherType(packet.data)) { | ||||
|     case EtherType::EAPoL: | ||||
|  | @ -423,6 +474,9 @@ void OnWifiPacketReceived(const Network::WifiPacket& packet) { | |||
|     case Network::WifiPacket::PacketType::Data: | ||||
|         HandleDataFrame(packet); | ||||
|         break; | ||||
|     case Network::WifiPacket::PacketType::Deauthentication: | ||||
|         HandleDeauthenticationFrame(packet); | ||||
|         break; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  | @ -435,13 +489,22 @@ void OnWifiPacketReceived(const Network::WifiPacket& packet) { | |||
|  *      1 : Result of function, 0 on success, otherwise error code | ||||
|  */ | ||||
| static void Shutdown(Interface* self) { | ||||
|     u32* cmd_buff = Kernel::GetCommandBuffer(); | ||||
|     IPC::RequestParser rp(Kernel::GetCommandBuffer(), 0x03, 0, 0); | ||||
| 
 | ||||
|     // TODO(purpasmart): Verify return header on HW
 | ||||
|     if (auto room_member = Network::GetRoomMember().lock()) | ||||
|         room_member->Unbind(wifi_packet_received); | ||||
| 
 | ||||
|     cmd_buff[1] = RESULT_SUCCESS.raw; | ||||
|     // TODO(B3N30): Check on HW if Shutdown signals those events
 | ||||
|     for (auto bind_node : channel_data) { | ||||
|         bind_node.second.event->Signal(); | ||||
|     } | ||||
|     channel_data.clear(); | ||||
| 
 | ||||
|     LOG_WARNING(Service_NWM, "(STUBBED) called"); | ||||
|     recv_buffer_memory.reset(); | ||||
| 
 | ||||
|     IPC::RequestBuilder rb = rp.MakeBuilder(1, 0); | ||||
|     rb.Push(RESULT_SUCCESS); | ||||
|     LOG_DEBUG(Service_NWM, "called"); | ||||
| } | ||||
| 
 | ||||
| /**
 | ||||
|  | @ -494,7 +557,6 @@ static void RecvBeaconBroadcastData(Interface* self) { | |||
| 
 | ||||
|     Memory::WriteBlock(current_buffer_pos, &data_reply_header, sizeof(BeaconDataReplyHeader)); | ||||
|     current_buffer_pos += sizeof(BeaconDataReplyHeader); | ||||
| 
 | ||||
|     // Write each of the received beacons into the buffer
 | ||||
|     for (const auto& beacon : beacons) { | ||||
|         BeaconEntryHeader entry{}; | ||||
|  | @ -560,6 +622,16 @@ static void InitializeWithVersion(Interface* self) { | |||
| 
 | ||||
|     ASSERT_MSG(recv_buffer_memory->size == sharedmem_size, "Invalid shared memory size."); | ||||
| 
 | ||||
|     if (auto room_member = Network::GetRoomMember().lock()) { | ||||
|         wifi_packet_received = room_member->BindOnWifiPacketReceived(OnWifiPacketReceived); | ||||
|     } else { | ||||
|         LOG_ERROR(Service_NWM, "Network isn't initalized"); | ||||
|         IPC::RequestBuilder rb = rp.MakeBuilder(1, 0); | ||||
|         // TODO(B3N30): Find the correct error code and return it;
 | ||||
|         rb.Push<u32>(-1); | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     { | ||||
|         std::lock_guard<std::mutex> lock(connection_status_mutex); | ||||
| 
 | ||||
|  | @ -567,15 +639,14 @@ static void InitializeWithVersion(Interface* self) { | |||
|         // except for the actual status value.
 | ||||
|         connection_status = {}; | ||||
|         connection_status.status = static_cast<u32>(NetworkStatus::NotConnected); | ||||
|         node_info.clear(); | ||||
|         node_info.push_back(NodeInfo{}); | ||||
|     } | ||||
| 
 | ||||
|     IPC::RequestBuilder rb = rp.MakeBuilder(1, 2); | ||||
|     rb.Push(RESULT_SUCCESS); | ||||
|     rb.PushCopyHandles(Kernel::g_handle_table.Create(connection_status_event).Unwrap()); | ||||
| 
 | ||||
|     // TODO(Subv): Connect the OnWifiPacketReceived function to the wifi packet received callback of
 | ||||
|     // the room we're currently in.
 | ||||
| 
 | ||||
|     LOG_DEBUG(Service_NWM, "called sharedmem_size=0x%08X, version=0x%08X, sharedmem_handle=0x%08X", | ||||
|               sharedmem_size, version, sharedmem_handle); | ||||
| } | ||||
|  | @ -857,26 +928,79 @@ static void BeginHostingNetwork(Interface* self) { | |||
| static void DestroyNetwork(Interface* self) { | ||||
|     IPC::RequestParser rp(Kernel::GetCommandBuffer(), 0x08, 0, 0); | ||||
| 
 | ||||
|     // TODO(Subv): Find out what happens if this is called while
 | ||||
|     // no network is being hosted.
 | ||||
| 
 | ||||
|     // Unschedule the beacon broadcast event.
 | ||||
|     CoreTiming::UnscheduleEvent(beacon_broadcast_event, 0); | ||||
| 
 | ||||
|     { | ||||
|         std::lock_guard<std::mutex> lock(connection_status_mutex); | ||||
| 
 | ||||
|         // TODO(Subv): Check if connection_status is indeed reset after this call.
 | ||||
|         connection_status = {}; | ||||
|         connection_status.status = static_cast<u8>(NetworkStatus::NotConnected); | ||||
|     // Only a host can destroy
 | ||||
|     std::lock_guard<std::mutex> lock(connection_status_mutex); | ||||
|     if (connection_status.status != static_cast<u8>(NetworkStatus::ConnectedAsHost)) { | ||||
|         IPC::RequestBuilder rb = rp.MakeBuilder(1, 0); | ||||
|         rb.Push(RESULT_SUCCESS); | ||||
|         LOG_WARNING(Service_NWM, "called with status %u", connection_status.status); | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     // TODO(B3N30): Send 3 Deauth packets
 | ||||
| 
 | ||||
|     u16_le tmp_node_id = connection_status.network_node_id; | ||||
|     connection_status = {}; | ||||
|     connection_status.status = static_cast<u32>(NetworkStatus::NotConnected); | ||||
|     connection_status.network_node_id = tmp_node_id; | ||||
|     connection_status_event->Signal(); | ||||
| 
 | ||||
|     IPC::RequestBuilder rb = rp.MakeBuilder(1, 0); | ||||
| 
 | ||||
|     // TODO(B3N30): HW test if events get signaled here.
 | ||||
|     for (auto bind_node : channel_data) { | ||||
|         bind_node.second.event->Signal(); | ||||
|     } | ||||
|     channel_data.clear(); | ||||
| 
 | ||||
|     rb.Push(RESULT_SUCCESS); | ||||
| 
 | ||||
|     LOG_WARNING(Service_NWM, "called"); | ||||
|     LOG_DEBUG(Service_NWM, "called"); | ||||
| } | ||||
| 
 | ||||
| static void DisconnectNetwork(Interface* self) { | ||||
|     IPC::RequestParser rp(Kernel::GetCommandBuffer(), 0xA, 0, 0); | ||||
|     IPC::RequestBuilder rb = rp.MakeBuilder(1, 0); | ||||
|     rb.Push(RESULT_SUCCESS); | ||||
| 
 | ||||
|     using Network::WifiPacket; | ||||
|     WifiPacket deauth; | ||||
|     { | ||||
|         std::lock_guard<std::mutex> lock(connection_status_mutex); | ||||
|         if (connection_status.status == static_cast<u32>(NetworkStatus::ConnectedAsHost)) { | ||||
|             // A real 3ds makes strange things here. We do the same
 | ||||
|             u16_le tmp_node_id = connection_status.network_node_id; | ||||
|             connection_status = {}; | ||||
|             connection_status.status = static_cast<u32>(NetworkStatus::ConnectedAsHost); | ||||
|             connection_status.network_node_id = tmp_node_id; | ||||
|             LOG_DEBUG(Service_NWM, "called as a host"); | ||||
|             return; | ||||
|         } | ||||
|         u16_le tmp_node_id = connection_status.network_node_id; | ||||
|         connection_status = {}; | ||||
|         connection_status.status = static_cast<u32>(NetworkStatus::NotConnected); | ||||
|         connection_status.network_node_id = tmp_node_id; | ||||
|         connection_status_event->Signal(); | ||||
| 
 | ||||
|         deauth.channel = network_channel; | ||||
|         // TODO(B3N30): Add disconnect reason
 | ||||
|         deauth.data = {}; | ||||
|         deauth.destination_address = network_info.host_mac_address; | ||||
|         deauth.type = WifiPacket::PacketType::Deauthentication; | ||||
|     } | ||||
| 
 | ||||
|     SendPacket(deauth); | ||||
| 
 | ||||
|     // TODO(B3N30): Check on HW if Shutdown signals those events
 | ||||
|     for (auto bind_node : channel_data) { | ||||
|         bind_node.second.event->Signal(); | ||||
|     } | ||||
|     channel_data.clear(); | ||||
| 
 | ||||
|     LOG_DEBUG(Service_NWM, "called"); | ||||
| } | ||||
| 
 | ||||
| /**
 | ||||
|  | @ -1075,6 +1199,55 @@ static void GetChannel(Interface* self) { | |||
|     LOG_DEBUG(Service_NWM, "called"); | ||||
| } | ||||
| 
 | ||||
| /**
 | ||||
|  * NWM_UDS::ConnectToNetwork service function. | ||||
|  * This connects to the specified network | ||||
|  *  Inputs: | ||||
|  *      0 : Command header | ||||
|  *      1 : Connection type: 0x1 = Client, 0x2 = Spectator. | ||||
|  *      2 : Passphrase buffer size | ||||
|  *      3 : (NetworkStructSize<<12) | 0x402 | ||||
|  *      4 : Network struct buffer ptr | ||||
|  *      5 : (PassphraseSize<<12) | 2 | ||||
|  *      6 : Input passphrase buffer ptr | ||||
|  *  Outputs: | ||||
|  *      0 : Return header | ||||
|  *      1 : Result of function, 0 on success, otherwise error code | ||||
|  */ | ||||
| static void ConnectToNetwork(Interface* self) { | ||||
|     IPC::RequestParser rp(Kernel::GetCommandBuffer(), 0x1E, 2, 4); | ||||
| 
 | ||||
|     u8 connection_type = rp.Pop<u8>(); | ||||
|     u32 passphrase_size = rp.Pop<u32>(); | ||||
| 
 | ||||
|     size_t desc_size; | ||||
|     const VAddr network_struct_addr = rp.PopStaticBuffer(&desc_size); | ||||
|     ASSERT(desc_size == sizeof(NetworkInfo)); | ||||
| 
 | ||||
|     size_t passphrase_desc_size; | ||||
|     const VAddr passphrase_addr = rp.PopStaticBuffer(&passphrase_desc_size); | ||||
| 
 | ||||
|     Memory::ReadBlock(network_struct_addr, &network_info, sizeof(network_info)); | ||||
| 
 | ||||
|     // Start the connection sequence
 | ||||
|     StartConnectionSequence(network_info.host_mac_address); | ||||
| 
 | ||||
|     connection_token = | ||||
|         SleepClientThread("uds::ConnectToNetwork", [](Kernel::SharedPtr<Kernel::Thread> thread) { | ||||
|             VAddr address = thread->GetCommandBufferAddress(); | ||||
|             std::array<u32, IPC::COMMAND_BUFFER_LENGTH> buffer; | ||||
|             IPC::RequestBuilder rb(buffer.data(), 0x1E, 1, 0); | ||||
|             // TODO(B3N30): Add error handling for host full and timeout
 | ||||
|             rb.Push(RESULT_SUCCESS); | ||||
|             Memory::WriteBlock(address, &*thread->owner_process, *buffer.data()); | ||||
| 
 | ||||
|             LOG_DEBUG(Service_NWM, "connection sequence finished"); | ||||
|         }); | ||||
| 
 | ||||
|     // TODO(B3N30): Add a timout for the connection sequence
 | ||||
|     LOG_DEBUG(Service_NWM, "called"); | ||||
| } | ||||
| 
 | ||||
| /**
 | ||||
|  * NWM_UDS::SetApplicationData service function. | ||||
|  * Updates the application data that is being broadcast in the beacon frames | ||||
|  | @ -1088,7 +1261,7 @@ static void GetChannel(Interface* self) { | |||
|  *      2 : Channel of the current WiFi network connection. | ||||
|  */ | ||||
| static void SetApplicationData(Interface* self) { | ||||
|     IPC::RequestParser rp(Kernel::GetCommandBuffer(), 0x1A, 1, 2); | ||||
|     IPC::RequestParser rp(Kernel::GetCommandBuffer(), 0x10, 1, 2); | ||||
| 
 | ||||
|     u32 size = rp.Pop<u32>(); | ||||
| 
 | ||||
|  | @ -1147,8 +1320,8 @@ static void DecryptBeaconData(Interface* self) { | |||
|     // This size is hardcoded in the 3DS UDS code.
 | ||||
|     ASSERT(output_buffer_size == sizeof(NodeInfo) * UDSMaxNodes); | ||||
| 
 | ||||
|     LOG_WARNING(Service_NWM, "called in0=%08X in1=%08X out=%08X", encrypted_data0_addr, | ||||
|                 encrypted_data1_addr, output_buffer_addr); | ||||
|     LOG_DEBUG(Service_NWM, "called in0=%08X in1=%08X out=%08X", encrypted_data0_addr, | ||||
|               encrypted_data1_addr, output_buffer_addr); | ||||
| 
 | ||||
|     NetworkInfo net_info; | ||||
|     Memory::ReadBlock(network_struct_addr, &net_info, sizeof(net_info)); | ||||
|  | @ -1158,15 +1331,10 @@ static void DecryptBeaconData(Interface* self) { | |||
|     std::array<u8, 3> oui; | ||||
|     Memory::ReadBlock(encrypted_data0_addr, oui.data(), oui.size()); | ||||
|     ASSERT_MSG(oui == NintendoOUI, "Unexpected OUI"); | ||||
|     Memory::ReadBlock(encrypted_data1_addr, oui.data(), oui.size()); | ||||
|     ASSERT_MSG(oui == NintendoOUI, "Unexpected OUI"); | ||||
| 
 | ||||
|     ASSERT_MSG(Memory::Read8(encrypted_data0_addr + 3) == | ||||
|                    static_cast<u8>(NintendoTagId::EncryptedData0), | ||||
|                "Unexpected tag id"); | ||||
|     ASSERT_MSG(Memory::Read8(encrypted_data1_addr + 3) == | ||||
|                    static_cast<u8>(NintendoTagId::EncryptedData1), | ||||
|                "Unexpected tag id"); | ||||
| 
 | ||||
|     std::vector<u8> beacon_data(data0_size + data1_size); | ||||
|     Memory::ReadBlock(encrypted_data0_addr + 4, beacon_data.data(), data0_size); | ||||
|  | @ -1230,26 +1398,6 @@ static void BeaconBroadcastCallback(u64 userdata, int cycles_late) { | |||
|                               beacon_broadcast_event, 0); | ||||
| } | ||||
| 
 | ||||
| /*
 | ||||
|  * Called when a client connects to an UDS network we're hosting, | ||||
|  * updates the connection status and signals the update event. | ||||
|  * @param network_node_id Network Node Id of the connecting client. | ||||
|  */ | ||||
| void OnClientConnected(u16 network_node_id) { | ||||
|     std::lock_guard<std::mutex> lock(connection_status_mutex); | ||||
|     ASSERT_MSG(connection_status.status == static_cast<u32>(NetworkStatus::ConnectedAsHost), | ||||
|                "Can not accept clients if we're not hosting a network"); | ||||
|     ASSERT_MSG(connection_status.total_nodes < connection_status.max_nodes, | ||||
|                "Can not accept connections on a full network"); | ||||
| 
 | ||||
|     u32 node_id = GetNextAvailableNodeId(); | ||||
|     connection_status.node_bitmask |= 1 << node_id; | ||||
|     connection_status.changed_nodes |= 1 << node_id; | ||||
|     connection_status.nodes[node_id] = network_node_id; | ||||
|     connection_status.total_nodes++; | ||||
|     connection_status_event->Signal(); | ||||
| } | ||||
| 
 | ||||
| const Interface::FunctionInfo FunctionTable[] = { | ||||
|     {0x000102C2, nullptr, "Initialize (deprecated)"}, | ||||
|     {0x00020000, nullptr, "Scrap"}, | ||||
|  | @ -1260,7 +1408,7 @@ const Interface::FunctionInfo FunctionTable[] = { | |||
|     {0x00070080, nullptr, "UpdateNetworkAttribute"}, | ||||
|     {0x00080000, DestroyNetwork, "DestroyNetwork"}, | ||||
|     {0x00090442, nullptr, "ConnectNetwork (deprecated)"}, | ||||
|     {0x000A0000, nullptr, "DisconnectNetwork"}, | ||||
|     {0x000A0000, DisconnectNetwork, "DisconnectNetwork"}, | ||||
|     {0x000B0000, GetConnectionStatus, "GetConnectionStatus"}, | ||||
|     {0x000D0040, GetNodeInformation, "GetNodeInformation"}, | ||||
|     {0x000E0006, nullptr, "DecryptBeaconData (deprecated)"}, | ||||
|  | @ -1275,7 +1423,7 @@ const Interface::FunctionInfo FunctionTable[] = { | |||
|     {0x001A0000, GetChannel, "GetChannel"}, | ||||
|     {0x001B0302, InitializeWithVersion, "InitializeWithVersion"}, | ||||
|     {0x001D0044, BeginHostingNetwork, "BeginHostingNetwork"}, | ||||
|     {0x001E0084, nullptr, "ConnectToNetwork"}, | ||||
|     {0x001E0084, ConnectToNetwork, "ConnectToNetwork"}, | ||||
|     {0x001F0006, DecryptBeaconData, "DecryptBeaconData"}, | ||||
|     {0x00200040, nullptr, "Flush"}, | ||||
|     {0x00210080, nullptr, "SetProbeResponseParam"}, | ||||
|  | @ -1305,6 +1453,9 @@ NWM_UDS::~NWM_UDS() { | |||
|         connection_status.status = static_cast<u32>(NetworkStatus::NotConnected); | ||||
|     } | ||||
| 
 | ||||
|     if (auto room_member = Network::GetRoomMember().lock()) | ||||
|         room_member->Unbind(wifi_packet_received); | ||||
| 
 | ||||
|     CoreTiming::UnscheduleEvent(beacon_broadcast_event, 0); | ||||
| } | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue