Giter Site home page Giter Site logo

ue4.1x-noob-reversing-journey's Introduction

UE4.1x Noob Reversing Journey

All credit to @cafo678 at https://www.unknowncheats.me/forum/members/3653138.html

Forum thread: https://www.unknowncheats.me/forum/unreal-engine-4-a/435110-noob-journey-ue4-hacking.html

INTRODUCTION

Hi guys,

First things first, I'm Italian and my english can be very bad, please excuse me.

Second, this will be a very long read, I do this to help myself and I hope even someone else.
Repeating all and writing it down is just a way to imagazine info for me.

Third, don't know why the images can't load.

If you want just to help me, thanks and please jump directly at the end where there are my questions.

Here a lot of info on why I'm here, what I want to achieve, mainly implemented to let you know why I'm even writing this post (you can skip, I wont blame you):

I'm a UE4 Gameplay programmer from 6 months, made a couple of projects for myself to start looking for a job.
I know how the engine works at the top level, but know nothing on how the engine works on low level. Pretty good on C++.

This could piss off a lot of people but I am not really interested in Game Hacking on a general level, I don't want to make cheats, but lately I had the impulse to make Mods.

I've approached this whole thing for a basic reason: sometime I play games in which, being a game developer myself, I start to wonder: "If I were the developer I would have implemented this mechanic in this way".

This happened with 2 main games recently:

  • Cyberpunk (but just for the will of putting some fixes tbh, poor devs),
  • FFVII Remake (FFVII for PS1 is my fav game of all time).

I started with a lot of basics guides here, on the other famous forum and YT, did a lot of Cheat Engine and Assault Cube these weeks.

Joined Cyberpunk discord before Christmas while starting to do some tuts here and there and ... shit, those people are on another level, maybe I could be of help with 6 months of studying.

So I've switched very fast to my next idea: FFVII.
The game should come out on April, and I have a few months to get ready to know how I could implement the things I have in my mind. Plus is made with UE4, a Engine I already use, this is perfect!

So I started to search how to mod UE4 Games, discovered that before modding them you have to reverse them, did some other research and now I think to have a pretty clear idea on how to proceed, I'll Implement the steps as I go forward.

JOURNEY

STEP 1: FINDING GNames && GObjects

Every Unreal Game has 2 very important variables that should help me proceed on my journey:

-GNames is a TArray of Unicode Strings
-GObject is a TArray of Class Pointers

With GObjects I should have access to most (all?) objects in the game, with GNames I should know what they are.
These infos will help me build an SDK to mod the game.

So, how to find them? Need to start practice!

STEP 1-a: GNames, JUMPER && FPS TEMPLATE

I've found here on the forum a post that talked about Jumper removed by staff, free, simple, a little boring, and made with UE4, downloaded.

One thing I know about GNames: they look like "None, ByteProperty, someProperty ecc".
Not much, but better than nothing.

There are a lot of info on how to find GNames but I found mainly 3 ways of doing that:

  • Analyzing the EXE with sigs (array of bytes that should be almost identical in every game)
  • Reversing via Source Code and IDA
  • Using fast methods that other people found for me.

EASY METHOD

Started with the easy way, from a YT video the steps are very simple.

  • Open Cheat Engine
  • Attach to Jumper
  • Search the String "MulticastDelegateProperty"
  • Browse memory regions of the found addresses, and find the one in which you read something like "None, ByteProperty, someProperty ecc" scrolling up a bit from where the windows open - 3 results, and the first worked for me:

Alt 1-png


- Like you can see I have 16 blank bytes before "None". Find the address of the first byte of the first 8 blank bytes before "None"
- Search it as 8 bytes HEX, if you find nothing, try the 8 blank bytes before
- Now just follow back all the pointers you find till you have 2 static addresses - pretty easy, after 2 mins:

Alt 2-png


- You want to put those offsets in ReClass and use the one that has 2 pointers as first entries:

Alt 3-png


- Follow the first pointer and:

Alt 4-png


- That was easy, apparently this is what I need, have I learned something?

No.

But, I have a offset now: 21DD6B8.

IDA and SOURCE

Let's see if I can find the same info using Source and IDA. Jumper uses UE4.10, I've seen this by simply viewing the proprieties of the EXE.

Here it is in "UnrealNames.cpp":

TNameEntryArray& FName::GetNames()
{
	static TNameEntryArray*	Names = NULL;
	if( Names == NULL )
	{
		check(IsInGameThread());
		Names = new TNameEntryArray();
	}
	return *Names;
}

Searching for the call at GetNames() in this file led me here (line 678)

	FNameEntry* OldHash=NameHash[iHash];
	TNameEntryArray& Names = GetNames();
	if (OutIndex < 0)
	{
		OutIndex = Names.AddZeroed(1);
	}
	else
	{
		check(OutIndex < Names.Num());
	}
	FNameEntry* NewEntry = AllocateNameEntry( InName, OutIndex, OldHash, FNameInitHelper<TCharType>::IsAnsi );
	if (FPlatformAtomics::InterlockedCompareExchangePointer((void**)&Names[OutIndex], NewEntry, NULL) != NULL) // we use an atomic operation to check for unexpected concurrency, verify alignment, etc
	{
		UE_LOG(LogUnrealNames, Fatal, TEXT("Hardcoded name '%s' at index %i was duplicated (or unexpected concurrency). Existing entry is '%s'."), *NewEntry->GetPlainNameString(), NewEntry->GetIndex(), *Names[OutIndex]->GetPlainNameString() );
	}
	if (FPlatformAtomics::InterlockedCompareExchangePointer((void**)&NameHash[iHash], NewEntry, OldHash) != OldHash) // we use an atomic operation to check for unexpected concurrency, verify alignment, etc
	{
		check(0); // someone changed this while we were changing it
	}
	check(OutIndex >= 0);
	return true;
}

That's juicy, search in IDA the string "Hardcoded name '%s' at index %i was duplicated (or unexpected concurrency). Existing entry is '%s'."
Follow the XRef, decompile and let's see what we got. Right above the string I can read very clearly "_InterlockedCompareExchenge64", 2 times.

Alt 5-png


I can see those line also in the source, and GetNames() is even more far up. So let's scroll up.

Alt 6-png


Wait... can you see 3 times "qword_1421DD6B8"?
Remember the offset before? 21DD6B8!
IDA starts with the base address at 14000000! That's it!

Am I done? Boh.

I was confused though: I was searching for GetNames(): a function and that was not a function.
In IDA functions are like "sub_XXXXXXXX", I started clicking on all function near that point but no luck


Clicking on the qword itself brought me here, and the function on that line (sub_1401BD5C0) was the same I've already found in the pseudocode with no luck.

Alt 7-png


Now, I don't know really what I have done here and I'm hoping someone can clear my mind, but here it comes the illumination.
Remeber in Cheat Engine? When we grabbed the offset, we grabbed the address 16 bytes before the "None".
16 bytes, that's 10 in HEX, 21DD6B8 + 10 = 21DD6C8!
In the image you can see that at that adrees there is another function: sub_1401BD490!

Clicked it, F5, and discovered that the first thing it does is call another function: sub_1401DA190.

Alt 8-png


Clicked it, scrolled down a bit and...



Alt 9-png


Interesting that it calls sub_1401BD490 on every Name, I looked a little bit at source code but I didn't understand exactly what I was looking.
Again, if you want you can clear my mind.
At least, something important learned, GNames are "offseted" (I'm writing too much, now I come up with new words) by 10. Better keep in mind that.

SIGS && PDB

Ok now, let'see this signatures.
From what I understand, once I have found Gnames, I can take the array of bytes and compare it with others, thing is, I don't have other games, well... let's make one!

Downloaded UE4.10 and cooked the FPS Template with PDB.
Opened in IDA, and it's to easy, just search for it. From a YT video I heard to take the bytes from the "mov" to the "jnz". I'll highlight them for you in the pic.

Alt 10-png


48 8B 05 B5 8F 1B 02 48 85 C0 75 50

That's our array!

Let's look at Jumper now.
Oh... that's a problem, none of the function I found look even similar to the one from the Template. I was all happy with emoticon and stuff, but maybe I wasn't even looking at GNames this whole time.



It' 4 AM, I'll call it a day, any advice is welcomed


STEP 1-b: GNames, JUMPER and FPS TEMPLATE - Was I looking at GNames? - Quick Update 08/01

I know you bastards are probably laughing at me!
But I have a PDB!
The question is: Was I looking at GNames?
Well, just let's see in the Template Game what I was looking!

I have redone all the steps that I have done in Jumper in the Template and here we are.

Alt 11-png


To begin, no sign of GetNames even with the PDB, but in the source is there, just after FName::Hash()


This is bad, why the source tell me lies

And:

  • sub_1401BD5C0 is FName::InitInternal_FindOrAddNameEntry()
  • sub_1401BD490 is FName::InitInternal()
  • sub_1401DA190 is FName::StaticInit()

So the answer is: No moron, you wasn't looking at GetNames().

I'm out of ammo here, seems that I have picked the wrong literal string.

STEP 1-c: Time for GObjects - Apparently the offset is good - Update 09/01

So I have done research and I have understood something but I have not understood the fact that GetNames() is missing in IDA.
I'll make this one the first question for you.

But, getting to what I have understood, the offset of GNames I have seems very good since it looks like a tons of others on the web.
I just need to see if it wil work, but for that I need GObjects!

What I know of GObjects? Nothing really, at least with GNames we had a clear sign it was it, the None, ByteProperty ecc.
Here nothing. One thing I noticed though, in reclass there are always some "FF" in the video I watched.

Ok the methods are the same:

  • Analyzing the EXE with sigs (array of bytes that should be almost identical in every game)
  • Reversing via Source Code and IDA
  • Using fast methods that other people found for me.

Unreal Finder Tool

This time I will start with an other easy way though, UFT by CorrM, so his tool gives me offset 21EDE80. Let's Compare using source and IDA.
There is nothing to say here, if you want to know how to use it just watch his video.

IDA && SOURCE

Source, UObjectHash.cpp:
FUObjectArray& GetUObjectArray()
{
	static FUObjectArray GlobalUObjectArray;
	return GlobalUObjectArray;
}

Find ref, find literals, you know how it works, I'm using "Object not found" from UGameViewportClient::HandleDisplayAllCommand().

4 results, we can do this.

The first has something weird:

            v12 = v16;
          sub_1401AA950(a3, L"Property '%s' not found on object '%s'", v19, v12);
          if ( v16 )
            sub_140157CF0(v16);
        }
        else
        {
          v10 = (__int64 *)(*(_QWORD *)(a1 + 64) + 32i64 * (int)sub_140D47260(a1 + 64, 1i64));
          *v10 = v8;
          v10[2] = v14;
        }
      }
      else
      {
        sub_1401AA950(a3, L"Object not found");
      }
    }
  }
  return 1;
}

There isn't any "Property '%s' not found on object '%s'" in my function. Let' search this in source.

Found just one result! If there is also here the GetUObjectArray() we can use this!

Source:

bool UGameViewportClient::HandleDisplayCommand( const TCHAR* Cmd, FOutputDevice& Ar )
{
	TCHAR ObjectName[256];
	TCHAR PropStr[256];
	if ( FParse::Token(Cmd, ObjectName, ARRAY_COUNT(ObjectName), true) &&
		FParse::Token(Cmd, PropStr, ARRAY_COUNT(PropStr), true) )
	{
		UObject* Obj = FindObject<UObject>(ANY_PACKAGE, ObjectName);
		if (Obj != NULL)
		{
			FName PropertyName(PropStr, FNAME_Find);
			if (PropertyName != NAME_None && FindField<UProperty>(Obj->GetClass(), PropertyName) != NULL)
			{
				FDebugDisplayProperty& NewProp = DebugProperties[DebugProperties.AddZeroed()];
				NewProp.Obj = Obj;
				NewProp.PropertyName = PropertyName;
			}
			else
			{
				Ar.Logf(TEXT("Property '%s' not found on object '%s'"), PropStr, *Obj->GetName());
			}
		}
		else
		{
			Ar.Logf(TEXT("Object not found"));
		}
	}
 
	return true;
}

Nope Let's try the others.
Fastforward: All 3 that remains could be my function.

Let's try finding "Object not found" in the source.
Yep, 4 results in fact, all in GameViewPortClient.cpp

The first one is HandleDisplayCommand() and there isn't our function there.

All the others have it though, those are HandleDisplayAllCommand(), HandleDisplayAllLocationCommand() and HandleDisplayAllRotationCommand().

The last 2 are pratically identical, the other one has an if statement extra.
If I find the one that differs in IDA, i know I can use either one of the remaining ones. Fastest thought: let's compare how many lines of code there are:

  • sub_140C658D0 308 lines
  • sub_140C65EB0 126 lines
  • sub_140C661A0 126 lines

Bingo! I can use either one of the latters and it will be or HandleDisplayAllLocationCommand() or HandleDisplayAllRotationCommand().
They are identical so I don't care, let's take HandleDisplayAllLocationCommand() and sub_140C661A0.

Source:
bool UGameViewportClient::HandleDisplayAllLocationCommand( const TCHAR* Cmd, FOutputDevice& Ar )
{
	TCHAR ClassName[256];
	if (FParse::Token(Cmd, ClassName, ARRAY_COUNT(ClassName), true))
	{
		UClass* Cls = FindObject<UClass>(ANY_PACKAGE, ClassName);
		if (Cls != NULL)
		{
			// add all un-GCable things immediately as that list is static
			// so then we only have to iterate over dynamic things each frame
			for (TObjectIterator<UObject> It(true); It; ++It)
			{
				if (!GetUObjectArray().IsDisregardForGC(*It))
				{
					break;
				}
				else if (It->IsA(Cls))
				{
					FDebugDisplayProperty& NewProp = DebugProperties[DebugProperties.AddZeroed()];
					NewProp.Obj = *It;
					NewProp.PropertyName = NAME_Location;
					NewProp.bSpecialProperty = true;
				}
			}
			FDebugDisplayProperty& NewProp = DebugProperties[DebugProperties.AddZeroed()];
			NewProp.Obj = Cls;
			NewProp.PropertyName = NAME_Location;
			NewProp.bSpecialProperty = true;
		}
		else
		{
			Ar.Logf(TEXT("Object not found"));
		}
	}
 
	return true;
}

IDA:

char __fastcall sub_140C661A0(__int64 a1, __int64 a2, __int64 a3, __int64 a4)
{
  __int64 v6; // rax
  __int64 v7; // r12
  __int64 v8; // rax
  __int64 v9; // r8
  __int64 v10; // rsi
  int v11; // er15
  int v12; // edi
  int v13; // ebp
  __int64 v14; // r14
  __int64 v15; // rbx
  __int64 v16; // rdx
  __int64 v17; // rcx
  int v18; // edx
  int v19; // eax
  int v20; // ecx
  int v21; // eax
  int v22; // ecx
  __int64 v23; // rbx
  __int64 v24; // rdx
  __int64 v25; // rcx
  int v26; // eax
  __int64 v27; // rcx
  __int64 v28; // rax
  __int64 v29; // rcx
  __int64 v30; // rcx
  __int64 v32; // [rsp+28h] [rbp-270h] BYREF
  __int64 v33; // [rsp+30h] [rbp-268h] BYREF
  int v34; // [rsp+38h] [rbp-260h]
  int v35; // [rsp+48h] [rbp-250h]
  char v36[512]; // [rsp+50h] [rbp-248h] BYREF
 
  v32 = a2;
  LOBYTE(a4) = 1;
  if ( (unsigned __int8)sub_1401B70C0(&v32, v36, 256i64, a4) )
  {
    v6 = sub_14020CB90(L"/Script/CoreUObject");
    v7 = sub_1402766C0(v6, -1i64, v36, 0i64);
    if ( v7 )
    {
      v8 = sub_14027A6E0(L"/Script/CoreUObject");
      LOBYTE(v9) = 1;
      sub_1401FEFE0(&v33, v8, v9, 16i64);
      v10 = v33;
      v11 = v35;
      v12 = v34;
LABEL_4:
      while ( v12 < *(_DWORD *)(v10 + 4112) )
      {
        if ( v12 < 0 )
          break;
        v13 = v12 / 0x4000;
        v14 = v12 % 0x4000;
        v15 = *(_QWORD *)(*(_QWORD *)(v10 + 8i64 * (v12 / 0x4000) + 16) + 8 * v14);
        if ( *(_DWORD *)(v15 + 12) > GetUObjectArray()[1] )
          break;
        if ( (unsigned int)(*(_DWORD *)(*(_QWORD *)(*(_QWORD *)(*(_QWORD *)(v10 + 8i64 * v13 + 16) + 8 * v14) + 16i64)
                                      + 136i64)
                          - *(_DWORD *)(v7 + 136)) <= *(_DWORD *)(v7 + 140) )
        {
          v16 = *(_QWORD *)(a1 + 64) + 32i64 * (int)sub_140D47260(a1 + 64, 1i64);
          v17 = *(_QWORD *)(*(_QWORD *)(v10 + 8i64 * v13 + 16) + 8 * v14);
          *(_DWORD *)(v16 + 24) |= 1u;
          *(_QWORD *)(v16 + 16) = 249i64;
          *(_QWORD *)v16 = v17;
        }
        v18 = *(_DWORD *)(v10 + 4112);
        do
        {
          if ( ++v12 >= v18 )
            break;
          while ( 1 )
          {
            v19 = v12;
            v20 = v12 & 0x3FFF;
            if ( v12 < 0 )
            {
              v19 = v12 + 0x3FFF;
              v20 -= 0x4000;
            }
            if ( *(_QWORD *)(*(_QWORD *)(v10 + 8i64 * (v19 >> 14) + 16) + 8i64 * v20) )
              break;
            if ( ++v12 >= v18 )
              goto LABEL_4;
          }
          v21 = v12;
          v22 = v12 & 0x3FFF;
          if ( v12 < 0 )
          {
            v21 = v12 + 0x3FFF;
            v22 -= 0x4000;
          }
        }
        while ( (v11 & *(_DWORD *)(*(_QWORD *)(*(_QWORD *)(v10 + 8i64 * (v21 >> 14) + 16) + 8i64 * v22) + 8i64)) != 0 );
      }
      v23 = *(int *)(a1 + 72);
      v24 = *(unsigned int *)(a1 + 76);
      v25 = (unsigned int)(v23 + 1);
      *(_DWORD *)(a1 + 72) = v25;
      if ( (int)v25 > (int)v24 )
      {
        v26 = sub_14011BA90(v25, v24, 32i64);
        *(_DWORD *)(a1 + 76) = v26;
        v27 = *(_QWORD *)(a1 + 64);
        if ( v27 || v26 )
          *(_QWORD *)(a1 + 64) = sub_140165C50(v27, 32i64 * v26, 0i64);
      }
      v28 = *(_QWORD *)(a1 + 64);
      v29 = 32 * v23;
      *(_QWORD *)(v29 + v28) = 0i64;
      *(_QWORD *)(v29 + v28 + 8) = 0i64;
      *(_QWORD *)(v29 + v28 + 16) = 0i64;
      *(_QWORD *)(v29 + v28 + 24) = 0i64;
      v30 = *(_QWORD *)(a1 + 64) + 32 * v23;
      *(_DWORD *)(v30 + 24) |= 1u;
      *(_QWORD *)v30 = v7;
      *(_QWORD *)(v30 + 16) = 249i64;
    }
    else
    {
      sub_1401AA950(a3, L"Object not found");
    }
  }
  return 1;
}

Here I made one mistake initially, I started to analyze the code from the bottom, and there is a while loop that can trick you, leading you to guess that sub_14011BA90 could be GetObjects() and sub_140165C50 AddZeroed(), but once analyzed was pretty obvious they were not.
Restarted from the top I ended up with these comments:

char __fastcall sub_140C661A0(__int64 a1, __int64 a2, __int64 a3, __int64 a4)
{
  __int64 v6; // rax
  __int64 v7; // r12
  __int64 v8; // rax
  __int64 v9; // r8
  __int64 v10; // rsi
  int v11; // er15
  int v12; // edi
  int v13; // ebp
  __int64 v14; // r14
  __int64 v15; // rbx
  __int64 v16; // rdx
  __int64 v17; // rcx
  int v18; // edx
  int v19; // eax
  int v20; // ecx
  int v21; // eax
  int v22; // ecx
  __int64 v23; // rbx
  __int64 v24; // rdx
  __int64 v25; // rcx
  int v26; // eax
  __int64 v27; // rcx
  __int64 v28; // rax
  __int64 v29; // rcx
  __int64 v30; // rcx
  __int64 v32; // [rsp+28h] [rbp-270h] BYREF
  __int64 v33; // [rsp+30h] [rbp-268h] BYREF
  int v34; // [rsp+38h] [rbp-260h]
  int v35; // [rsp+48h] [rbp-250h]
  char v36[512]; // [rsp+50h] [rbp-248h] BYREF
 
  v32 = a2;
  LOBYTE(a4) = 1;
  if ( (unsigned __int8)sub_1401B70C0(&v32, v36, 256i64, a4) )// if (FParse::Token(Cmd, ClassName, ARRAY_COUNT(ClassName), true))
  {
    v6 = sub_14020CB90(L"/Script/CoreUObject"); // No idea
    v7 = sub_1402766C0(v6, -1i64, v36, 0i64);   // FindObject() since it analyzes v7 in the next if
    if ( v7 )
    {
      v8 = sub_14027A6E0(L"/Script/CoreUObject");// No idea
      LOBYTE(v9) = 1;
      sub_1401FEFE0(&v33, v8, v9, 16i64);       // FObjectIterator
      v10 = v33;
      v11 = v35;
      v12 = v34;
LABEL_4:
      while ( v12 < *(_DWORD *)(v10 + 4112) )   // for loop
      {
        if ( v12 < 0 )
          break;
        v13 = v12 / 0x4000;
        v14 = v12 % 0x4000;
        v15 = *(_QWORD *)(*(_QWORD *)(v10 + 8i64 * (v12 / 0x4000) + 16) + 8 * v14);
        if ( *(_DWORD *)(v15 + 12) > sub_14026F0F0()[1] )// GetUObjectArray()
          break;
        if ( (unsigned int)(*(_DWORD *)(*(_QWORD *)(*(_QWORD *)(*(_QWORD *)(v10 + 8i64 * v13 + 16) + 8 * v14) + 16i64)
                                      + 136i64)
                          - *(_DWORD *)(v7 + 136)) <= *(_DWORD *)(v7 + 140) )
        {
          v16 = *(_QWORD *)(a1 + 64) + 32i64 * (int)sub_140D47260(a1 + 64, 1i64);// AddZeroed()
          v17 = *(_QWORD *)(*(_QWORD *)(v10 + 8i64 * v13 + 16) + 8 * v14);
          *(_DWORD *)(v16 + 24) |= 1u;
          *(_QWORD *)(v16 + 16) = 249i64;
          *(_QWORD *)v16 = v17;
        }
        v18 = *(_DWORD *)(v10 + 4112);
        do
        {
          if ( ++v12 >= v18 )
            break;
          while ( 1 )
          {
            v19 = v12;
            v20 = v12 & 0x3FFF;
            if ( v12 < 0 )
            {
              v19 = v12 + 0x3FFF;
              v20 -= 0x4000;
            }
            if ( *(_QWORD *)(*(_QWORD *)(v10 + 8i64 * (v19 >> 14) + 16) + 8i64 * v20) )
              break;
            if ( ++v12 >= v18 )
              goto LABEL_4;
          }
          v21 = v12;
          v22 = v12 & 0x3FFF;
          if ( v12 < 0 )
          {
            v21 = v12 + 0x3FFF;
            v22 -= 0x4000;
          }
        }
        while ( (v11 & *(_DWORD *)(*(_QWORD *)(*(_QWORD *)(v10 + 8i64 * (v21 >> 14) + 16) + 8i64 * v22) + 8i64)) != 0 );
      }
      v23 = *(int *)(a1 + 72);
      v24 = *(unsigned int *)(a1 + 76);
      v25 = (unsigned int)(v23 + 1);
      *(_DWORD *)(a1 + 72) = v25;
      if ( (int)v25 > (int)v24 )
      {
        v26 = sub_14011BA90(v25, v24, 32i64);   // No idea
        *(_DWORD *)(a1 + 76) = v26;
        v27 = *(_QWORD *)(a1 + 64);
        if ( v27 || v26 )
          *(_QWORD *)(a1 + 64) = sub_140165C50(v27, 32i64 * v26, 0i64);// NO idea
      }
      v28 = *(_QWORD *)(a1 + 64);
      v29 = 32 * v23;
      *(_QWORD *)(v29 + v28) = 0i64;
      *(_QWORD *)(v29 + v28 + 8) = 0i64;
      *(_QWORD *)(v29 + v28 + 16) = 0i64;
      *(_QWORD *)(v29 + v28 + 24) = 0i64;
      v30 = *(_QWORD *)(a1 + 64) + 32 * v23;
      *(_DWORD *)(v30 + 24) |= 1u;
      *(_QWORD *)v30 = v7;
      *(_QWORD *)(v30 + 16) = 249i64;
    }
    else
    {
      sub_1401AA950(a3, L"Object not found");   // Log
    }
  }
  return 1;
}

Got into sub_14026F0F0():

*sub_14026F0F0()
{
  if ( dword_1421EEF00 <= *(_DWORD *)(*((_QWORD *)NtCurrentTeb()->ThreadLocalStoragePointer + (unsigned int)TlsIndex)
                                    + 16i64) )
    return &dword_1421EDE70;
  Init_thread_header(&dword_1421EEF00);
  if ( dword_1421EEF00 != -1 )
    return &dword_1421EDE70;
  sub_140269280(&dword_1421EDE70);
  atexit(sub_14193BC40);
  Init_thread_footer(&dword_1421EEF00);
  return &dword_1421EDE70;
}

It's easy to think that our array could be the returned value: dword_1421EDE70, offset being 21EDE70. UFT gave me 21EDE80. We are there but with this offset of 10, weird, i will try both.

SIGS && PDB

Let's open our PDB game and let's see if we have done all correct.

Well... it seems I got it

Alt 12-png

In a YT video a guy said to get the bytes from lea to the call, and that GObject usually starts with 48 8D 0D.
Let's see... well there are a lot that starts with 48 8D 0D and exactly which lea and call should I use?

I don't know, another question for you.
I admit this is the part where I don't rellay understand what to look.

Fuck, for the time being I'll take the first i meet.

Template: 48 8D 0D 30 70 10 02 E8 17 0A 4A 01
Jumper: 48 8D 0D E0 FD F7 01 E8 F7 34 6A 01
Possible sig: 48 8D 0D ?? ?? ?? ?? E8 ?? ?? ?? 01

STEP 2: DUMPING GNames && GObjects

Template Game:
GNames: 0x232F530
GObjects: 0x23422C0 or 0x23422B0

Jumper:
GNames: 0x21DD6B8
GObjects: 0x21EDE80 or 0x21EDE70

For what I've understood to dump the lists we can use an Instance Logger and inject it in the process.
I've found one here on the forum made by TheFeckless I think.
I have to study this code but I want to see if it works simply changing the offsets.

STEP 2-a: Dumping JUMPER

Injected and...
GNames WORKED!
GObjects blank
I have the other option though.

Injected and...
GObjects full of INVALID NAME INDEX and shit

Uff... 2 things may be wrong:

  • the offsets -duh
  • I need to change something inside the code to match my UE version structure

I'll start studying the logger code.

STEP 2-b: Dumping JUMPER - Why GObjects do not works? - Update 11/01

So... my post is up from a few days now, and no one has yet answered
What? You don't like me? Well, I will do it on my own

Jokes aside, let's begin from where we were.
We need to check why GObject is not dumping, we need to check if the offset is good and if the code of the logger need some adjustments.

Checking the offsets - GNAMES

Let's understand first what we need:
GNames is an array of pointers to names, GNames worked in the dump, so it is good. But let's check it just to understand better.
The offset is 0x21DD6B8, let's open ReClass and check.

Alt 13-png


Ok so we can clearly see there are 2 pointers, let's dereference those and see what they are.

Alt 14-png


Ok so the first pointer points to other 2 pointers. The second one seems nothing we can use. Let's delete it.
Let's dereference these other 2 pointers we had from the first one.

Alt 15-png


Ohh, THAT seems an array. Or better, they seems 2 arrays.
Let's see what they are pointing, I will open the first 2 pointers of every array for comparison.

Alt 16-png


Ok we can see a pattern. Every pointer points to an empty region of the size of 0x10. At 0x10 there is our name.
Like we can see, the first one at offset 0x10 has None. The second has ByteProperty. The first of the second array has MessageIndex. The latter has MessageType.

Like I expected GNames is good. And since we are here let's put some types and names in this reclass section so we can understand better the structure of 4.10

So let's see in the source what we are looking, we start like always from the GetNames() method.

TNameEntryArray& FName::GetNames()
{
	static TNameEntryArray*	Names = NULL;
	if( Names == NULL )
	{
		check(IsInGameThread());
		Names = new TNameEntryArray();
	}
	return *Names;
}

So GetNames() returns a TNameEntryArray, let'go to his definition:

typedef TStaticIndirectArrayThreadSafeRead<FNameEntry, 2 * 1024 * 1024 /* 2M unique FNames */, 16384 /* allocated in 64K/128K chunks */ > TNameEntryArray;

Woo, and what is that? Definition of TStaticIndirectArrayThreadSafeRead:

/**
 * Simple array type that can be expanded without invalidating existing entries.
 * This is critical to thread safe FNames.
 *    @Param ElementType Type of the pointer we are storing in the array
 *    @Param MaxTotalElements absolute maximum number of elements this array can ever hold
 *    @Param ElementsPerChunk how many elements to allocate in a chunk
 **/
 template<typename ElementType, int32 MaxTotalElements, int32 ElementsPerChunk>
class TStaticIndirectArrayThreadSafeRead
{
	enum
	{
		// figure out how many elements we need in the master table
		ChunkTableSize = (MaxTotalElements + ElementsPerChunk - 1) / ElementsPerChunk
	};
	/** Static master table to chunks of pointers **/
	ElementType** Chunks[ChunkTableSize];
	/** Number of elements we currently have **/
	int32 NumElements;
	/** Number of chunks we currently have **/
	int32 NumChunks;

Ok so is basically a standard array, the first parameter is the type of data it's holding and the others parameters define how this array is divided in multiple chunks.
So going back at the definition of TNameEntryArray: it is an array that contains FNameEntry.

Definition of FNameEntry:

/**
 * A global name, as stored in the global name table.
 */
struct FNameEntry
{
private:
	/** Index of name in hash. */
	NAME_INDEX		Index;
 
public:
	/** Pointer to the next entry in this hash bin's linked list. */
	FNameEntry*		HashNext;
 
private:
	/** Name, variable-sized - note that AllocateNameEntry only allocates memory as needed. */
	union
	{
		ANSICHAR	AnsiName[NAME_SIZE];
		WIDECHAR	WideName[NAME_SIZE];
	};

Here we are! So AnsiName is probably our name (None ecc), FNameEntry is a pointer, Index is an int32, but we avance some space if math isn't an opinion. The name was at offset 0x10 so there is probably some extra space between HashNext and the name.
Let's put all of this in ReClass:

Alt 17-png


Ok so basically we understand 2 things, indexes goes by 2, because... yes.
We see 2 arrays, and the first element of the second has index 32768. 32768/2 = 16384! The number we've seen in the definition! All make sense.

So we can go backwards and name everything else in ReClass.

At the and we have a structure like this:

- Our offset points to all the arrays with names in it

Alt 18-png


- Those pointers refers to the vary entries of those arrays:

Alt 19-png


Simple like drinking a glass of water, like we say here.

Checking the offsets - GOBJECTS

So now.. our enemy.

We start with 2 offsets but they are very close, let's put the smaller one so we can see even the other in ReClass.
0x21EDE70 or 0x21EDE80

Alt 20-png


Ok the pointers are at 0x..80 so the one I picked from IDA was wrong. But come on, so close, UFT gave me the 80 one but I am confident that even without it I would have it spotted.

Let's dereference those:

Alt 21-png


Ok it seems we have already two arrays, seems very similar to the GNames structure. Let's open the first two of every array:

Alt 22-png


Oh we are of course watching to an array of similar objects. On the internet I've seen what an UObject should look like, and the only thing clearly equal to those I've seen on the web is the first pointer: that should be a VTable.
Now let's look at the source and let's see if we can understand the structure.

Start from GetUObjectArray():

FUObjectArray& GetUObjectArray()
{
	static FUObjectArray GlobalUObjectArray;
	return GlobalUObjectArray;
}

Returns an FUObjectArray:

	/** First index into objects array taken into account for GC.							*/
	int32 ObjFirstGCIndex;
	/** Index pointing to last object created in range disregarded for GC.					*/
	int32 ObjLastNonGCIndex;
	/** If true this is the intial load and we should load objects int the disregarded for GC range.	*/
	int32 OpenForDisregardForGC;
	/** Array of all live objects.											*/
	TUObjectArray ObjObjects;

Ok so another array apparently: TUObjectArray

typedef TStaticIndirectArrayThreadSafeRead<UObjectBase, 8 * 1024 * 1024 /* Max 8M UObjects */, 16384 /* allocated in 64K/128K chunks */ > TUObjectArray;

Oh, just like the array of the names, remember? This one is made of UObjectBase though:

	/** Flags used to track and report various object states. This needs to be 8 byte aligned on 32-bit
	    platforms to reduce memory waste */
	EObjectFlags					ObjectFlags;
 
	/** Index into GObjectArray...very private. */
	int32								InternalIndex;
 
	/** Class the object belongs to. */
	UClass*							Class;
 
	/** Name of this object */
	FName							Name;
 
	/** Object this object resides in. */
	UObject*						Outer;

Here we are: this should be our UObject, let's take a look at FName:

	/** Index into the Names array (used to find String portion of the string/number pair used for comparison) */
	NAME_INDEX		ComparisonIndex;
#if WITH_CASE_PRESERVING_NAME
	/** Index into the Names array (used to find String portion of the string/number pair used for display) */
	NAME_INDEX		DisplayIndex;
#endif
	/** Number portion of the string/number pair (stored internally as 1 more than actual, so zero'd memory will be the default, no-instance case) */
	uint32			Number;

Ok so basically FName has an index (int32). This integer refer to the GName array, and here's why we need both.
For every object we take, we see that index, search that index in the Name array and we have the name of the object!
All clear, let's try to put that in ReClass and if matches:

Alt 23-png


This seems pretty good to me, InternalIndex grows by one this time (0, 1 - 16384, 16385), 16384 is the first of the second chunk: 16384 / 1 = 16384 (remeber the definition of TUObjectArray?) and we see the FName Index, apparently the first one has Index 688, and in my dump of GNames that is CoreUObject.

Ok so let's go backwards and define all.

Alt 24-png


2 things:
- this structure is one pointer shorter than GNames like we can see, basically it's like what I've called here GObject is at the same level of what I've called PtrToAllNameEntryArray in the GNames ReClass.
- We don't need to go up to FUObjectArray and define that, our pointer points directly to TUObjectArray, so why bother?

Offsets good apparently, time to check the InstanceLogger code!

Checking the InstanceLogger code

Like I've said, I've found it here on the forum.
First things firts, if you have compiling problem add #include <Psapi.h>, I needed it in VS2019.

I'd like to go by order so let'start from DllMain:

BOOL WINAPI DllMain(HMODULE hModule, DWORD dwReason, LPVOID lpReserved)
{
    switch (dwReason)
    {
    case DLL_PROCESS_ATTACH:
        DisableThreadLibraryCalls(hModule);
        CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)onAttach, NULL, 0, NULL);
        return true;
        break;
 
    case DLL_PROCESS_DETACH:
        return true;
        break;
    }
}

Very basic, let's see onAttach() then:

void onAttach()
{
    AllocConsole();
    freopen("CONOUT$", "w", stdout);
 
    MODULEINFO miGame = GetModuleInfo(NULL);
 
    GObjObjects_offset = (DWORD64)((DWORD64)miGame.lpBaseOfDll + 0x21EDE80);
    Names_offset = (*(DWORD64*)((DWORD64)miGame.lpBaseOfDll + 0x21DD6B8));
 
    GObjObjects = (FUObjectArray*)GObjObjects_offset;
    Names = (TNameEntryArray*)Names_offset;
 
    NameDump();
    ObjectDump();
}

Ok, now Names works, so let' see what it's doing, it takes the offset and it dereference it so the program wants the pointer to the first chunk, this one for understand:

Alt 25-png


Well the GObject offset we are giving points already to the same location but for the objects so no need to dereference:

    GObjObjects_offset = ((DWORD64)miGame.lpBaseOfDll + 0x21EDE80);
    Names_offset = (*(DWORD64*)((DWORD64)miGame.lpBaseOfDll + 0x21DD6B8));

Then next couple of lines, Names is good, GObjObjects no... don't need FUObjectArray, let's fix it:

    GObjObjects = (TUObjectArray*)GObjObjects_offset;
    Names = (TNameEntryArray*)Names_offset;

Now the definitions:

using TNameEntryArray = TStaticIndirectArrayThreadSafeRead<FNameEntry, 2 * 1024 * 1024, 16384>;

Oh just like the source, FNameEntry and TStaticIndirectArrayThreadSafeRead:

struct FNameEntry
{
    int Index;
    char pad_0x0004[0x4];
    FNameEntry* HashNext;
    char AnsiName[1024];
};

See the padding, I'm not a complete idiot come on.

template<typename ElementType, __int32 MaxTotalElements, __int32 ElementsPerChunk>
class TStaticIndirectArrayThreadSafeRead
{
public:
    __int32 Num() const
    {
        return numElements;
    }
 
    bool IsValidIndex(__int32 index) const
    {
        return index >= 0 && index < Num() && GetById(index) != nullptr;
    }
 
    ElementType const* const& GetById(__int32 index) const
    {
        return *GetItemPtr(index);
    }
 
private:
    ElementType const* const* GetItemPtr(__int32 Index) const
    {
        const __int32 ChunkIndex = Index / ElementsPerChunk;
        const __int32 WithinChunkIndex = Index % ElementsPerChunk;
        const auto Chunk = chunks[ChunkIndex];
        return Chunk + WithinChunkIndex;
    }
 
    enum
    {
        ChunkTableSize = (MaxTotalElements + ElementsPerChunk - 1) / ElementsPerChunk
    };
 
    ElementType** chunks[ChunkTableSize];
    __int32 numElements;
    __int32 numChunks;
};

That's f*cking perfect! Need to hug this man, make sense that GNames works.
Let's see TUObjectArray though:

class TUObjectArray
{
public:
    FUObjectItem* Objects;
    __int32 MaxElements;
    __int32 NumElements;
};

Mmm... nope, that's clearly a different structure, our TObjectArray should be like this, let's edit it:

using TUObjectArray = TStaticIndirectArrayThreadSafeRead<UObjectBase, 8 * 1024 * 1024, 16384>;

Ok let'see if there is UObjectBase in the logger... nope, but there is UObject, should be similar, let's see:

struct UObject
{
    UCHAR   Unknown[0x10];       // unknowed data
    DWORD   NameIndex;                              // struct FName
};

Yeah so apparently it wants only to know where is the index of FName, make sense, in our case so we can change it in:

struct UObjectBase
{
    UCHAR   Unknown[0x18];       // unknowed data
    DWORD   NameIndex;                              // struct FName
};

0x18, go see the ReClass images.

Ok then it calls the Dump methods

void ObjectDump()
{
    FILE* Log = NULL;
    fopen_s(&Log, "ObjectDump.txt", "w+");
 
    for (DWORD64 i = 0x0; i < GObjObjects->ObjObjects.NumElements; i++)
    {
        if (!GObjObjects->ObjObjects.Objects[i].Object) { continue; }
 
        fprintf(Log, "UObject[%06i] %-50s 0x%llX\n", i, GetName(GObjObjects->ObjObjects.Objects[i].Object), GObjObjects->ObjObjects.Objects[i].Object);
    }
 
    fclose(Log);
}
 
void NameDump()
{
    FILE* Log = NULL;
    fopen_s(&Log, "NameDump.txt", "w+");
 
    for (DWORD64 i = 0x0; i < Names->Num(); i++)
    {
        if (!Names->GetById(i)) { continue; }
 
        fprintf(Log, "Name[%06i] %s\n", i, Names->GetById(i)->AnsiName);
    }
 
    fclose(Log);
}

Ok so we can clearly see how the entire structure of GObject is different, in this case the program take in account FUObject, look inside for TUObject, goes to FUObjectItem and then takes the UObject:

struct UObject
{
    UCHAR   Unknown[0x10];       // unknowed data
    DWORD   NameIndex;                              // struct FName
};
 
class FUObjectItem
{
public:
    UObject* Object;
    __int32 Flags;
    __int32 ClusterIndex;
    __int32 SerialNumber;
    char unknowndata_00[0x4]; //New
};
 
class TUObjectArray
{
public:
    FUObjectItem* Objects;
    __int32 MaxElements;
    __int32 NumElements;
};
 
class FUObjectArray
{
public:
    __int32 ObjFirstGCIndex; //0x0000
    __int32 ObjLastNonGCIndex; //0x0004
    __int32 MaxObjectsNotConsideredByGC; //0x0008
    __int32 OpenForDisregardForGC; //0x000C
 
    TUObjectArray ObjObjects;
};

We don't need any of that, just comment it out.
Then we can change the ObjectDump method to suits us:

void ObjectDump()
{
    FILE* Log = NULL;
    fopen_s(&Log, "ObjectDump.txt", "w+");
 
    //for (DWORD64 i = 0x0; i < GObjObjects->ObjObjects.NumElements; i++)
    for (DWORD64 i = 0x0; i < GObjObjects->Num(); i++)
    {
        //if (!GObjObjects->ObjObjects.Objects[i].Object) { continue; }
        if (!GObjObjects->GetById(i)) { continue; }
 
        //fprintf(Log, "UObject[%06i] %-50s 0x%llX\n", i, GetName(GObjObjects->ObjObjects.Objects[i].Object), GObjObjects->ObjObjects.Objects[i].Object);
        fprintf(Log, "UObject[%06i] %-50s 0x%p\n", i, GetName(GObjObjects->GetById(i)), GObjObjects->GetById(i));
    }
 
    fclose(Log);
}

NameDump() iterates the array, and for every member prints the index then goes into the AnsiName variable ant prints it into the file.

ObjectDump() iterates the array, and for every member prints the index, prints the return value of GetName() and prints the address of the Object.

Ok only GetName() remains:

char* GetName(UObject* Object)
{
    DWORD64 NameIndex = *(PDWORD64)((DWORD64)Object + Offset_Name);
 
    if (NameIndex < 0 || NameIndex > Names->Num())
    {
        static char ret[256];
        sprintf_s(ret, "INVALID NAME INDEX : %i > %i", NameIndex, Names->Num());
        return ret;
    }
    else
    {
        return (char*)Names->GetById(NameIndex)->AnsiName;
    }
}

Ok for start, the first line is complicated for nothing here, it want to understand where is the offset of the FName index
Well... just:

int NameIndex = Object->NameIndex;

All the rest seems good, it take the FName Index of the object and it goes to see what matches in the Names array.

GetModuleInfo, bDataCompare and FindPattern are for sigs, I haven't even looked at them, I hate sigs, I don't understand them.

Ok so our modified version should look like this:

#include <Windows.h>
#include <stdio.h>
#include <Psapi.h>
#include <cstring>
#include <iostream>
 
DWORD64   GObjObjects_offset = NULL;
DWORD64   Names_offset = NULL;
 
emplate<typename ElementType, __int32 MaxTotalElements, __int32 ElementsPerChunk>
class TStaticIndirectArrayThreadSafeRead
{
public:
    __int32 Num() const
    {
        return numElements;
    }
 
    bool IsValidIndex(__int32 index) const
    {
        return index >= 0 && index < Num() && GetById(index) != nullptr;
    }
 
    ElementType const* const& GetById(__int32 index) const
    {
        if (doonce) std::cout << "*GetItemPtr(index): " << *GetItemPtr(index) << " GetItemPtr(index): " << GetItemPtr(index) <<  std::endl;
        doonce = false;
        return *GetItemPtr(index);
    }
 
private:
    ElementType const* const* GetItemPtr(__int32 Index) const
    {
        const __int32 ChunkIndex = Index / ElementsPerChunk;
        const __int32 WithinChunkIndex = Index % ElementsPerChunk;
        const auto Chunk = chunks[ChunkIndex];
        if (doonce2) std::cout << "Chunk: " << Chunk << " chunks[ChunkIndex]: " << chunks[ChunkIndex] << " Chunk + WithinChunkIndex: " << Chunk + WithinChunkIndex << std::endl;
        doonce2 = false;
        return Chunk + WithinChunkIndex;
    }
 
    enum
    {
        ChunkTableSize = (MaxTotalElements + ElementsPerChunk - 1) / ElementsPerChunk
    };
 
    ElementType** chunks[ChunkTableSize];
    __int32 numElements;
    __int32 numChunks;
};
 
struct UObjectBase
{
    UCHAR   Unknown[0x18];       // unknowed data
    DWORD   NameIndex;                              // struct FName
};
 
using TUObjectArray = TStaticIndirectArrayThreadSafeRead<UObjectBase, 8 * 1024 * 1024, 16384>;
 
struct FNameEntry
{
    int Index;
    char pad_0x0004[0x4];
    FNameEntry* HashNext;
    char AnsiName[1024];
};
 
using TNameEntryArray = TStaticIndirectArrayThreadSafeRead<FNameEntry, 2 * 1024 * 1024, 16384>;
 
TUObjectArray* GObjObjects = NULL;
TNameEntryArray* Names = NULL;
 
char* GetName(const UObjectBase* Object)
{
    int NameIndex = Object->NameIndex;
 
    std::cout << NameIndex << std::endl;
 
    if (NameIndex < 0 || NameIndex > Names->Num())
    {
        static char ret[256];
        sprintf_s(ret, "INVALID NAME INDEX : %i > %i", NameIndex, Names->Num());
        return ret;
    }
    else
    {
        return (char*)Names->GetById(NameIndex)->AnsiName;
    }
}
 
void ObjectDump()
{
    FILE* Log = NULL;
    fopen_s(&Log, "ObjectDump.txt", "w+");
 
    for (DWORD64 i = 0x0; i < GObjObjects->Num(); i++)
    {
        if (!GObjObjects->GetById(i)) { continue; }
 
        fprintf(Log, "UObject[%06i] %-50s 0x%p\n", i, GetName(GObjObjects->GetById(i)), GObjObjects->GetById(i));
    }
 
    fclose(Log);
}
 
void NameDump()
{
    FILE* Log = NULL;
    fopen_s(&Log, "NameDump.txt", "w+");
 
    for (DWORD64 i = 0x0; i < Names->Num(); i++)
    {
        if (!Names->GetById(i)) { continue; }
 
        fprintf(Log, "Name[%06i] %s\n ", i, Names->GetById(i)->AnsiName);
    }
 
    fclose(Log);
}
 
void onAttach()
{
    AllocConsole();
    freopen("CONOUT$", "w", stdout);
 
    MODULEINFO miGame = GetModuleInfo(NULL);
 
    GObjObjects_offset = ((DWORD64)miGame.lpBaseOfDll + 0x21EDE80);
    Names_offset = (*(DWORD64*)((DWORD64)miGame.lpBaseOfDll + 0x21DD6B8));
 
    GObjObjects = (TUObjectArray*)GObjObjects_offset;
    Names = (TNameEntryArray*)Names_offset;
 
    NameDump();
    ObjectDump();
}
 
BOOL WINAPI DllMain(HMODULE hModule, DWORD dwReason, LPVOID lpReserved)
{
    switch (dwReason)
    {
    case DLL_PROCESS_ATTACH:
        DisableThreadLibraryCalls(hModule);
        CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)onAttach, NULL, 0, NULL);
        return true;
        break;
 
    case DLL_PROCESS_DETACH:
        return true;
        break;
    }
}

BUILD TIME!

and...
IT WORKS

Alt 26-png

Wow I wasn't expect it, uhm ok.

So now from what I understand I have to make an SDK. And there is a Generator from KN4CK3R out there.
More code-study time!

QUESTIONS

  • [Read end of 1.b and start of 1.c for more info] Why in the function I picked to find GetNames() (sub_1401BD5C0, FName::InitInternal_FindOrAddNameEntry()) i wasn't able to find it? I was expecting to find a sub_xxxx (GetNames()) and inside it find the offset of the array I needed. But instead I've found directly the offset in FindOrAddNameEntry(). Little bad of course, but why?

  • [Read section SIGS && PDB in 1.c for more info] What instruction interest to me when I want to grab the sigs for GNames or GObjects? I'm taking offsets to the arrays in IDA, but when i have to do with sigs I don't understand what I need to look.

  • Of course if you want to give me advices or correct anything I had said wrong, and there are probably a lot of things , I will be very pleased.


Hope to have not violated any of the rules, I want of course to update this journey as I go forward, everytime I will make a step, I will update the post.

Thanks for the reading,

Cafo.

ue4.1x-noob-reversing-journey's People

Contributors

untyper avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.