Closed GreenComfyTea closed 5 months ago
Unfortunately, MHWs networking system is not as well organized as MHRs. There are no wrappers around Steam API calls.
Specifically for the search filters, the game doesn't even make a direct call to AddRequestLobbyListDistanceFilter
, just like you mentioned in Rise. Search filters are all set up in one big function, MtNet::Steam::Context::startRequest
.
The SteamInternal_ContextInit
functions retrieve steam interfaces. There are a couple relevant calls here. The SearchKey*
filters are game version and network branch data. Then there is a key SlotPublicOpen
which requires the filters to return only lobbies that have at least 1 open slot.
Furthermore, the game is most likely programmed to display an error when the result count is > 32 because steam refuses to return more than 32 results for me. I removed the error and tried setting the value passed to the function to 100 always, but it never returns more than 32 results.
So to change these filters you'd have to hook this function and make Steam API calls yourself. I can provide a C# interface to do so, but I don't know if there really is much use-case. If anything, have an API that lets plugins set specific filter values. Even then, I don't know if that is really something SPL should have responsibility over. That feels more like something to be implemented by a plugin, as a sort of extension.
Regarding the Quest Search, the same rules go as for the session search itself. It is handled through the same function. Raising the limit up to 32 is possible, anything past that doesn't do anything.
I rewrote the code from the screenshot to better understand what's going on (for myself): https://pastebin.com/fiG9t1GG.
I have a few questions.
1) Do you have any idea why it calls Steam_Internal_ContextInit
multiple times? Does it destroy the context after each function call?
2) I can't hook AddRequestLobbyListResultStringFilter
, right?
If anything, have an API that lets plugins set specific filter values. Even then, I don't know if that is really something SPL should have responsibility over. That feels more like something to be implemented by a plugin, as a sort of extension.
Yes, of course. A plugin is what i meant. I simply ask for an api/interface because i saw some commits trying to make more update-resistance with less hardcoded stuff. So I assumed that handling it thru SPL can be more reliable than if I read memory/hooked functions directly with hardcoded values in the plugin, because I see these things as highly desired by the community.
Furthermore, the game is most likely programmed to display an error when the result count is > 32 because steam refuses to return more than 32 results for me. I removed the error and tried setting the value passed to the function to 100 always, but it never returns more than 32 results.
Pog, the value in the CE table didn't affect quest search. That's good news!
Do you have any idea why it calls Steam_Internal_ContextInit multiple times? Does it destroy the context after each function call?
Usually that function is not something you call yourself. Most likely it was wrapped inside some lightweight struct/class. Something like this:
struct SteamContext {
ISteamMatchmaking* getSteamMatchmaking() { /* SteamInternal_ContextInit call here */ }
// other stuff
};
And the devs didn't really thing much about what happens behind that get...
function and just wrote code like this
context->getSteamMatchmaking()->AddRequestLobbyListResultStringFilter(...);
context->getSteamMatchmaking()->AddRequestLobbyListResultNumericalFilter(...);
...
I can't hook AddRequestLobbyListResultStringFilter, right?
You could, tho I wouldn't recommend it tbh. The cleaner way would be to hook that big function I was talking about, which is at 0x1421e2430
. It gets a MtNetRequest
object passed to it which contains a mPhase
member at offset 0xE0.
The search request gets made when mPhase
is equal to 1, but due to some compiler optimizations you'd likely have to check for mPhase == 0
instead.
I rewrote the code from the screenshot to better understand what's going on (for myself): https://pastebin.com/fiG9t1GG.
Don't worry about that too much π
. That's decompiled code, the call to AddFavouriteGame
isn't actually that, it calls ISteamUser::GetSteamID
, I just couldn't be bothered to create a vtable for it and map it out in ghidra so it looks like that.
And.. the full function body is just under 350 lines long soo yeah.
Some things:
FUN_1421e5790(^local_ownerID, 0xf, s_SearchKey%d_142fc6b08, *arr2[-1]);
This is a call to snprintf
with the format string "SearchKey%d"
// ???
if(*(char *) (param_1 + 0x30) != '\0') {
*(int *) (param_1 + 0x34) -= 1;
LeaveCriticalSection((LPCRITICAL_SECTION)(param_1 + 8));
}
This is purely for synchronization purposes, the weird != '\0'
check is just a bool check for if that specific structure is "thread safe", i.e. does it have a critical section object. There is a corresponding EnterCriticalSection
call up above.
Interesting, thanks for detailed explanation.
Ghidra is taking long time to do analyzing... It looks confusing anyway. For now I've tried this, but it leads to a crash (I assume because there should be more parameters).
private delegate void StartRequestDelegate(nint mtNetRequestPointer);
private Hook<StartRequestDelegate> _startRequestDelegate;
...
public void OnLoad()
{
_startRequestDelegate = Hook.Create<StartRequestDelegate>(0x1421e2430, (mtNetRequestPointer) =>
{
Log.Info($"{mtNetRequestPointer.ToString()}: startRequest");
_startRequestDelegate.Original(mtNetRequestPointer);
});
}
Yes, that function takes 2 parameters.
You could write it like so
private delegate int StartRequestDelegate(nint netCore, MtObject netRequest);
private MarshallingHook<StartRequestDelegate> _startRequestHook;
public void OnLoad()
{
_startRequestHook = MarshallingHook.Create<StartRequestDelegate>(0x1421e2430, (netCore, netRequest) =>
{
Log.Info($"StartRequest: Phase={netRequest.Get<uint>(0xE0)}");
return _startRequestHook.Original(netCore, netRequest);
});
}
Also requires a using SharpPluginLoader.Core.Experimental;
for the MarshallingHook
class.
Btw, I saw in your original post you mentioned not knowing how to do direct memory access, there are a couple examples here.
Yeah, I was still confused after reading it.
I assume that process's base address is already accounted because MemoryUtil
is for reading game's memory.
For this:
The first step would be to get the pointer stored at base address + 51C2478
, right?
I tried:
int value = MemoryUtil.Read<int>(0x51C2478);
and
nint value = MemoryUtil.Read<nint>(0x51C2478);
and
unsafe
{
int* pointer = MemoryUtil.ReadPointer<int>(0x51C2478);
int value = *pointer;
}
and
unsafe
{
nint* pointer = MemoryUtil.ReadPointer<nint>(0x51C2478);
nint value = *pointer;
}
All led me to a crash.
PS. I thought you were joking about ghidra and 50 hours...
Regarding ghidra, I wouldn't recommend doing a full analysis from nothing lol. You can download my pre-analyzed binary here. I upload a new one every once in a while. Latest one I uploaded just a couple minutes ago (MonsterHunterWorld_15.20_Fexty_20240219.gzf
).
You can just import the .gzf into an existing ghidra project and you'll have all the annotations I made. Additionally you should also download the MonsterHunterWorld.gdt
from that folder and import that from the datatype manager window.
The base address is not accounted for with MemoryUtil
(or anywhere in SPL, really). For MHW "base address + x" will basically always evaluate to 0x140000000 + x
. The only time this isn't the case is if the person running it has ASLR enabled, which personally I've never come across.
If you really want to be ultra safe you can pull in GetModuleHandleA/W
from kernel32.dll
as a DllImport and call it with either null
or "MonsterHunterWorld.exe"
. That will give you back the base address of the process.
Additionally, to change the max results you should do this instead:
// in startRequest hook
netRequest.GetRef<uint>(0x60) = 32;
Since that other pointer will get overwritten by the value stored in the net request.
Regarding ghidra, I wouldn't recommend doing a full analysis from nothing lol. You can download my pre-analyzed binary here. I upload a new one every once in a while. Latest one I uploaded just a couple minutes ago (MonsterHunterWorld_15.20_Fexty_20240219.gzf).
I got MonsterHunterWorld_15.20_Fexty_20240108.gzf
but went for full analysis because latest mhw is 15.21...
The base address is not accounted for with MemoryUtil (or anywhere in SPL, really). For MHW "base address + x" will basically always evaluate to 0x140000000 + x. The only time this isn't the case is if the person running it has ASLR enabled, which personally I've never come across.
I see, thanks. I will try it later. π
The exe for 15.20 is identical to the one for 15.21.
FYI, because of your help I was able to improve Region Lock Fix for Rise and it now supports Lobbies too! Thank you very much! β€οΈ
[UPD.] Fixed Language Lock too. WICKED.
With your code I am still crashing when I initiate session search. π€
private delegate int StartRequestDelegate(nint netCore, MtObject netRequest);
private MarshallingHook<StartRequestDelegate> _startRequestHook;
public void OnLoad()
{
_startRequestHook = MarshallingHook.Create<StartRequestDelegate>(0x1421e2430, (netCore, netRequest) =>
{
Log.Info($"StartRequest: Phase={netRequest.Get<uint>(0xE0)}");
return _startRequestHook.Original(netCore, netRequest);
});
}
Uh, not sure what's going on then. I will try this out myself when I get back from work today.
Added a Steam Matchmaking API with 8b9f6192ab793c216ab343e93a579ad785a2e31e.
The way it works is you subscribe to the OnLobbySearch
event. In that function you can modify the maximum number of results, and add arbitrary filters using functions exposed by the SharpPluginLoader.Core.Steam.Matchmaking
class.
God's work! Thank you very much! β€οΈ β€οΈ β€οΈ
I have issues with my plugin atm. I am confused with new event system. None of the events are firing and Plugin TeaOverlay does not have an entry point
is printed in the console.
using SharpPluginLoader.Core;
using ImGuiNET;
using System.Diagnostics;
using SharpPluginLoader.Core.Memory;
using SharpPluginLoader.Core.Experimental;
namespace TeaOverlay;
public class TeaOverlayPlugin : IPlugin
{
public string Name => $"Tea Overlay v1";
public string Author => "GreenComfyTea";
public PluginData Initialize()
{
Log.Info("Initialize");
return new PluginData();
}
public void OnLoad()
{
Log.Info("OnLoad");
}
void IPlugin.OnImGuiRender()
{
Log.Info("OnImGuiRender");
}
public void OnImGuiFreeRender()
{
Log.Info("OnImGuiFreeRender");
}
public void OnLobbySearch(ref int maxResults)
{
Log.Info("OnLobbySearch");
}
}
PS. Some examples do not compile because they were not updated for new event system and are missing mandatory Author
property.
That sounds to me like the SPL Core binary was copied to your plugins output directory, and now your plugin depends on the types inside its local instance of SPL, instead of the main one.
Make sure, if you're referencing the assembly directly instead of the NuGet package, that you have <private>false</private>
set for it so it doesn't get copied to your out dir.
OH SHIT you are right. I am referencing the .dll directly, but it does copy a new core dll into my plugin. I swear it wasn't happening before (at least with newtonjson.dll, so i was copying it manually). *facepalm*
Ok, back to OnLobbySearch
. All other events work now, but not this one. I get no console output when searching for sessions or quests.
public void OnLobbySearch(ref int maxResults)
{
Log.Info($"OnLobbySearch");
}
[UPD.] Meanwhile direct hook started working but calling AddRequestLobbyListDistanceFilter
causes a crash.
private delegate int SearchLobbiesDelegate(nint netCore, nint netRequest);
private Hook<SearchLobbiesDelegate> SearchLobbiesHook;
SearchLobbiesHook = Hook.Create<SearchLobbiesDelegate>(0x1421e2430, (netCore, netRequest) =>
{
var phase = MemoryUtil.Read<int>(netRequest + 0xE0);
if (phase != 0) return SearchLobbiesHook!.Original(netCore, netRequest);
Log.Info("Searching for Lobbies");
ref int maxResults = ref MemoryUtil.GetRef<int>(netRequest + 0x60);
Log.Info("Set Max Result to 32");
maxResults = 32;
Log.Info("Trying to set Distance to WorldWide -> Crash");
Matchmaking.AddRequestLobbyListDistanceFilter(LobbyDistanceFilter.WorldWide);
return SearchLobbiesHook!.Original(netCore, netRequest);
});
Alright, I made some mistakes in my initial commit (lack of testing lol). Should be fixed in 5640882cabe7eb3aef2e8afafd382bcbeefcc443.
It works now π
Sup, I want to report about my current progress and ask for further assistance.
So far Region Lock
fix, Lobby Count
increase and Player Count
filter are implemented.
I also plan to implement a bypass for Player Type
filter, because Any
search returns only lobbies with Any
option selected, which is not exactly the behavior I would expect. This requires me to hook AddRequestLobbyListNumericalFilter
, which I already do successfully. It's just that I basically duplicated your code to get the address.
private delegate void numericalFilter_Delegate(nint steamInterface, nint keyAddress, int value, int comparison);
private MarshallingHook<numericalFilter_Delegate> numericalFilter;
// 0x7FFE2A0B5700
var numericalFilterAddress = SteamApi.GetVirtualFunction(SteamApi.VirtualFunctionIndex.AddRequestLobbyListNumericalFilter);
numericalFilter = MarshallingHook.Create<numericalFilter_Delegate>(numericalFilterAddress, OnNumericalFilter);
SteamApi
is the copy-paste class of your Matchmaking
. Would be nice if you were to expose Matchmaking.VirtualFunctionIndex
enum and Matchmaking.GetVirtualFunction
(but with implicit steamInterface
).
public static nint GetVirtualFunction(VirtualFunctionIndex functionIndex)
{
var steamInterface = GetSteamMatchmakingInterface();
return GetVirtualFunction(steamInterface, functionIndex);
}
Regarding the Quest Search, the same rules go as for the session search itself. It is handled through the same function. Raising the limit up to 32 is possible, anything past that doesn't do anything.
This is actually not true. The hardcap set by Steam Matchmaking API is 50. Setting maxResults
to 50 does actually make Matchmaking API return all 50 results. They simply are not being displayed.
SteamAPICall_t RequestLobbyList();
returns a callback with LobbyMatchList_t
that has to be iterated with GetLobbyByIndex
. That's what the game does in phase 2.
Here is the loop to go over each lobby:
index = 0;
do {
ppIVar11 = SteamInternal_ContextInit(&PTR_SteamAPI_GetISteamMatchmaking_143e98558);
(*(code*)(*ppIVar11)->vft->GetLobbyByIndex)(*ppIVar11, &lobbyMatchList, index);
if ((*(longlong*)(netCore + 0x136d0) == 0) ||
(*(longlong*)(netCore + 0x358) != lobbyMatchList)) {
lVar20 = (longlong)index2* 0x250 + netCore;
cVar7 = FUN_1421dec80(lobbyMatchList, lVar20 + 0x138d8);
if (cVar7 != '\0') {
*puVar17 = 0;
ppIVar11 = SteamInternal_ContextInit(&PTR_SteamAPI_GetISteamMatchmaking_143e98558);
_Source = (char*)(*(code*)(*ppIVar11)->vft->GetLobbyData)
(*ppIVar11, lobbyMatchList, s_Name_1434fd8f8);
strncpy(puVar17 + 8, _Source, 0x20);
puVar17[0x27] = 0;
FUN_142197fb0(lVar20 + 0x13a90, 0xf, lVar20 + 0x13a58, 8);
uVar8 = *(uint*)(netCore + 0x186e0);
if (((uVar8 & 1) != 0) && (0 < *(int*)(puVar17 + -0x40))) {
FUN_142204b00(netCore, index, lVar20 + 0x13928);
uVar8 = *(uint*)(netCore + 0x186e0);
}
if ((uVar8 & 2) != 0) {
puVar10[-1] = *(undefined8*)(puVar17 + -0x18);
*puVar10 = *(undefined8*)(puVar17 + -0x10);
puVar10[1] = 0xffff;
*(undefined*)(puVar10 + 2) = 0;
FUN_1422065b0(QosChannel::Instance);
}
index2 = index2 + 1;
puVar17 = puVar17 + 0x250;
puVar10 = puVar10 + 4;
if ((*(int*)(netCore + 0x186d8) <= index2) || (0x20 < index2)) break;
}
}
index = index + 1;
} while (index < *(uint*)(netCore + 0x1871c));
netCore + 0x1871c
(printed as loopLimit
) in the while condition corresponds with actual number of lobbies returned by Matchmaking API.
Inside the loop there are 2 break conditions.
First one is: if(index2 >= netCore + 0x186d8)
, where netCore + 0x186d8
corresponds with maxResults
(printed as breakCondition
).
Second one is: if(index2 > 32)
.
With Min Players = 14
and maxResults = 49
:
With Min Players = 1
and maxResults = 49
:
Patching the second condition to if(index2 > 50)
works:
resultCountSanityCheckPatch2 = new Patch((nint)0x1421e2905, [0x83, 0xfd, 0x32], true);
The loop continues after 32 lobbies and gets lobby data for 33rd and 34rd lobby correctly. But on 34rd lobby after getting lobby data it calls FUN_142204b00
and the game crashes with an error:
Fatal error. System.AccessViolationException: Attempted to read or write protected memory. This is often an indication that other memory is corrupt.
The function:
void FUN_142204b00(longlong *param_1,undefined4 param_2,undefined8 param_3)
{
char cVar1;
if (*(char *)(param_1 + 6) != '\0') {
EnterCriticalSection((LPCRITICAL_SECTION)(param_1 + 1));
*(int *)((longlong)param_1 + 0x34) = *(int *)((longlong)param_1 + 0x34) + 1;
}
if (param_1[0x37] != 0) {
cVar1 = (**(code **)(*param_1 + 0x28))(param_1);
if (cVar1 == '\0') {
(**(code **)(*(longlong *)param_1[0x37] + 0x48))((longlong *)param_1[0x37],param_2,param_3);
}
}
if (*(char *)(param_1 + 6) != '\0') {
*(int *)((longlong)param_1 + 0x34) = *(int *)((longlong)param_1 + 0x34) + -1;
LeaveCriticalSection((LPCRITICAL_SECTION)(param_1 + 1));
}
return;
}
Hooking this function and skipping original call, prevents the crash as expected. No lobbies are shown in the game, but the loop actually stops after 34rd lobby because netCore + 0x186d8
(aka breakCondition
) is set to 0 somewhere in the 34rd iteration.
My only assumption is that in netCore
there are only so much memory reserved for lobbies, because in the loop it does:
lVar20 = (longlong) index2 * 0x250 + netCore;
that is later passed to different functions:
cVar7 = FUN_1421dec80(local_res10, lVar20 + 0x138d8);
FUN_142197fb0(lVar20 + 0x13a90, 0xf, lVar20 + 0x13a58, 8);
FUN_142204b00(param_1, index, lVar20 + 0x13928);
Thou it assigns lVar20
data inside FUN_142197fb0
and FUN_142204b00
without a crash. I am clueless how to continue rn.
The object only holds space for 32 lobbies. By writing more than 32 you're overwriting other data in the object. And after 34 you're going outside of the memory allocated by it. The objects start at 0x13A90, and there's 32 * 0x250 = 0x4A00 bytes for the objects. So anything past 0x18490 is no longer lobby data. The total size of the object is 0x18850.
You can technically change the allocation size, but you'd still have to move the other objects around and adjust offsets which is honestly not a very realistic thing to do.
So realistically there is no sensible way to fix this without for example moving the lobby array to the very end of the object and increasing the allocation size to fit 50 entries. That would also require adjusting every place where the lobbies are accessed to make sure it's using the correct offset.
Hmm, that's what I thought. Thanks for the confirmation! Guess I will settle with 32 lobbies. :/
Matchmaking.GetVirtualFunction
is now exposed as of da0b496909d2920abf0dad7f074965045efb349a.
Officially implemented with Version 0.0.4
Ok, one more thing xdd
Can you expose GetSteamMatchmakingInterface()
, please? Turns out in online sessions when not host the game calls NumericalFilter
a lot with a different interface and it causes a crash, so I need it to filter out irrelevant hook calls.
Done as of 932ecb358472d5edc5b9b86ba049d513635aa6ed
There is an issue with hooking that seems to be related to SPL (or even Reloaded.Hooks), rather than to my mod.
Hooking AddRequestLobbyListNumericalFilter
prevents Friend Session Search from working. It seems to not perform the search at all and always say that no sessions found.
The issue seems to be cause by simple fact that the hook exists. If I do nothing inside the hook, just call the original function - the issue persists. Commenting out Hook.Create
fixes it. The proper functional hook in the mod works fine in every other aspect.
private delegate void numericalFilter_Delegate(nint steamInterface, nint keyAddress, int value, int comparison);
private Hook<numericalFilter_Delegate> NumericalFilterHook { get; set; }
var numericalFilterAddress = Matchmaking.GetVirtualFunction(Matchmaking.VirtualFunctionIndex.AddRequestLobbyListNumericalFilter);
// Causes Friend Session Search to not work
NumericalFilterHook = Hook.Create<numericalFilter_Delegate>(numericalFilterAddress, (steamInterface, keyAddress, value, comparison) =>
{
NumericalFilterHook!.Original(steamInterface, keyAddress, value, comparison);
});
Any thoughts?
No clue tbh. You could try hooking from C++ using something like MinHook or safetyhook and see if it still happens. If it does then it might be some steam "anticheat". Ways around it that I could think of is either try a vtable hook, or mid-function hooks in the places where that function is called. Depending on how many call sites there are that might be really annoying tho.
Just a follow up. mid-hooks won't work because I need to skip the call sometimes, as I understand, it can't be done with mid-hooks. And I was not successful with making vtable hook work, because I am dumb, that's why xD.
All I know is that the steam interface is different either for 96 or 112 bytes than the one stored in your Steam API when I search for friend sessions, and the rest of the parameters is junk.
Region Lock Fix
SPL hooks to a lot of in-game functions, which is really cool. I assume you have some good way or just good knowledge and experience of how to find those functions. Therefore, I assume there is chance you might find the function that will disable Region Lock.
In Rise it is done by literally 1 function call. By visually observing how World and Rise behave, I make a big assumption that the way it is handled in World is the same as in Rise, which would mean a function of same functionality have a high chance of existing. And perhaps there is a chance you may find it.
How it works in Rise
Both in Rise and probably World (I am 99% sure) Join Request (SOS) discovery is provided thru Steam Matchmaking API (Docs) and after that the games establish p2p connection. The most interesting part in the API is
LobbyDistanceFilter
(Docs). It literally defines how far from your Steam region the Matchmaking API will search for a match.As per docs it can have 4 states:
In Rise the devs kindly made a wrapper to this API:
via.network.SessionSteam
which is inherited from bigvia.Network.SessionBase
base class. This is a sensible thing to do, right? Having a wrapper for the API you are using? I believe a similar wrapper should exist in World too.In Rise:![image](https://github.com/Fexty12573/SharpPluginLoader/assets/30152047/18d37cd9-413c-46b3-85cc-1d19b1611797)
setLobbyDistanceFilter(UInt32 lobbyDistanceFilter)
is the function that wrapsAddRequestLobbyListDistanceFilter(ELobbyDistanceFilter eLobbyDistanceFilter)
function in the Matchmaking API.This function is actually never gets called while playing (maybe it gets called once on game initialization but before REFramework is initialized, so I can't detect this call). Therefore, Steam Matchmaking simply uses the default value =
k_ELobbyDistanceFilterDefault
, which limits tolobbies in the same region or nearby regions
,The way I did in Rise is by hooking to
setIsInvisible(Boolean isInvisible)
function. This function is being called before each search for Join Requests. The hook provides me with a reference toSessionSteam
instance, so I can callsetLobbyDistanceFilter()
function in pre function of the hook.Obviously, hooking onto
setIsInvisible()
function is not mandatory.SessionSteam
instance exists somewhere in session managers as a field. Hooking is just simpler.I cannot guarantee that in World
setIsInvisible()
exists, but I strongly believe that a similarSessionSteam
wrapper and a similarsetLobbyDistanceFilter()
function exist!Matchmaking QoL β 1 - Session Search Limit
By default the limit is
20
. We actually already know the memory address for it. Cheat Engine table by Marcus101RR already includes a pointer to it. But running cheat engine for such thing is kinda dank. SPL/Plugin is more fit for this task. I am aware that SPL already allows direct memory access, I just haven't figure out how to use it yet (forgive my foolishness π ). But, perhaps, this value should be exposed thru SPL API? Otherwise, can you help with translating the CE pointer chain into C# code?The value itself can be increased to 32 safely. Going further makes the game throw an error.
Default Limit Screenshot - 20/20
![image](https://github.com/Fexty12573/SharpPluginLoader/assets/30152047/55e8f6e7-d755-4da5-b043-77ae53e7ee82)Cheat Engine Screenshot
![image](https://github.com/Fexty12573/SharpPluginLoader/assets/30152047/ef57170d-56ca-47f6-96f1-39bd5040b071)Pointer Chain Screenshot
![image](https://github.com/Fexty12573/SharpPluginLoader/assets/30152047/3ccc0c82-de5a-4487-9508-17a943d8d521)32 Limit Screenshot - 29/32
![image](https://github.com/Fexty12573/SharpPluginLoader/assets/30152047/8aba1b32-7019-4e23-9e7b-4d6cf3ee744f)Game Error Screenshot
![image](https://github.com/Fexty12573/SharpPluginLoader/assets/30152047/ddb79a86-f456-4391-b3ff-c3758560a63c)Matchmaking QoL β 2 - Session Search Filter
A lot of found sessions are just 1/16 - people playing by themselves. When I am not playing with friends, I usually look for most populated session. An option to filter 1/16 sessions out would be nice.
If sessions are handled by Steam Matchmaking API, it probably uses some of those. And if not there still should be a packet going out, defining the search rules, that can be altered?
Matchmaking QoL β 3 - Quest Search Limit
By default the limit is
20
too. It is probably stored the same way as session search limit. Again, maybe it should be exposed thru SPL API if found?Quest Search Screenshot - 20/20
![image](https://github.com/Fexty12573/SharpPluginLoader/assets/30152047/b30f27e2-8677-4688-b5ab-df354758afc1)