Game metadata extractor and some fixes

- App not closing on back pressed (AlberInputListener.java)
- Extract SMDH from rom.
- Extract metadata from SMDH
This commit is contained in:
Gabriel 2023-12-21 02:31:22 -04:00
parent 9ca7e88b6c
commit c0960dcccd
13 changed files with 370 additions and 16 deletions

View file

@ -389,6 +389,7 @@ if(ENABLE_VULKAN)
endif()
if(ANDROID)
set(HEADER_FILES ${HEADER_FILES} include/jni_driver.hpp)
set(ALL_SOURCES ${ALL_SOURCES} src/jni_driver.cpp)
endif()

7
include/jni_driver.hpp Normal file
View file

@ -0,0 +1,7 @@
#include <vector>
#include "helpers.hpp"
class Pandroid {
public:
static void onSmdhLoaded(const std::vector<u8> &smdh);
};

View file

@ -6,6 +6,10 @@
#include "loader/ncch.hpp"
#include "memory.hpp"
#ifdef __ANDROID__
#include "jni_driver.hpp"
#endif
#include <iostream>
bool NCCH::loadFromHeader(Crypto::AESEngine &aesEngine, IOFile& file, const FSInfo &info) {
@ -255,6 +259,11 @@ bool NCCH::parseSMDH(const std::vector<u8>& smdh) {
return false;
}
#ifdef __ANDROID__
Pandroid::onSmdhLoaded(smdh);
#endif
// Bitmask showing which regions are allowed.
// https://www.3dbrew.org/wiki/SMDH#Region_Lockout
const u32 regionMasks = *(u32*)&smdh[0x2018];

View file

@ -8,10 +8,14 @@
#include "renderer_gl/renderer_gl.hpp"
#include "services/hid.hpp"
#include "jni_driver.hpp"
std::unique_ptr<Emulator> emulator = nullptr;
HIDService* hidService = nullptr;
RendererGL* renderer = nullptr;
bool romLoaded = false;
JavaVM* jvm = nullptr;
const char* alberClass = "com/panda3ds/pandroid/AlberDriver";
#define AlberFunction(type, name) JNIEXPORT type JNICALL Java_com_panda3ds_pandroid_AlberDriver_##name
@ -20,7 +24,47 @@ void throwException(JNIEnv* env, const char* message) {
env->ThrowNew(exceptionClass, message);
}
JNIEnv* jniEnv(){
JNIEnv* env;
auto status = jvm->GetEnv((void **)&env, JNI_VERSION_1_6);
if(status == JNI_EDETACHED){
jvm->AttachCurrentThread(&env, nullptr);
} else if(status != JNI_OK){
throw std::runtime_error("Failed to obtain JNIEnv from JVM!!");
}
return env;
}
void Pandroid::onSmdhLoaded(const std::vector<u8> &smdh){
JNIEnv* env = jniEnv();
int size = smdh.size();
jbyteArray result = env->NewByteArray(size);
jbyte buffer[size];
for(int i = 0; i < size; i++){
buffer[i] = (jbyte) smdh[i];
}
env->SetByteArrayRegion(result, 0, size, buffer);
auto clazz = env->FindClass(alberClass);
auto method = env->GetStaticMethodID(clazz, "OnSmdhLoaded", "([B)V");
env->CallStaticVoidMethod(clazz, method, result);
env->DeleteLocalRef(result);
}
extern "C" {
AlberFunction(void, Setup)(JNIEnv* env, jobject obj) {
env->GetJavaVM(&jvm);
}
AlberFunction(void, Initialize)(JNIEnv* env, jobject obj) {
emulator = std::make_unique<Emulator>();
@ -73,4 +117,4 @@ AlberFunction(void, SetCirclepadAxis)(JNIEnv* env, jobject obj, jint x, jint y)
}
}
#undef AlberFunction
#undef AlberFunction

View file

@ -1,8 +1,16 @@
package com.panda3ds.pandroid;
import android.util.Log;
import com.panda3ds.pandroid.data.SMDH;
import com.panda3ds.pandroid.data.game.GameMetadata;
import com.panda3ds.pandroid.utils.Constants;
import com.panda3ds.pandroid.utils.GameUtils;
public class AlberDriver {
AlberDriver() { super(); }
public static native void Setup();
public static native void Initialize();
public static native void RunFrame(int fbo);
public static native boolean HasRomLoaded();
@ -15,5 +23,13 @@ public class AlberDriver {
public static native void TouchScreenUp();
public static native void TouchScreenDown(int x, int y);
public static void OnSmdhLoaded(byte[] buffer) {
Log.i(Constants.LOG_TAG, "Loaded rom smdh");
SMDH smdh = new SMDH(buffer);
GameMetadata game = GameUtils.getCurrentGame();
GameUtils.removeGame(game);
GameUtils.addGame(GameMetadata.applySMDH(game, smdh));
}
static { System.loadLibrary("Alber"); }
}

View file

@ -23,7 +23,7 @@ import com.panda3ds.pandroid.view.PandaLayoutController;
public class GameActivity extends BaseActivity {
private final AlberInputListener inputListener = new AlberInputListener();
private final AlberInputListener inputListener = new AlberInputListener(this);
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {

View file

@ -3,6 +3,7 @@ package com.panda3ds.pandroid.app;
import android.app.Application;
import android.content.Context;
import com.panda3ds.pandroid.AlberDriver;
import com.panda3ds.pandroid.data.config.GlobalConfig;
import com.panda3ds.pandroid.input.InputMap;
import com.panda3ds.pandroid.utils.GameUtils;
@ -17,6 +18,7 @@ public class PandroidApplication extends Application {
GlobalConfig.initialize();
GameUtils.initialize();
InputMap.initialize();
AlberDriver.Setup();
}
public static Context getAppContext() {

View file

@ -1,5 +1,8 @@
package com.panda3ds.pandroid.app.game;
import android.app.Activity;
import android.view.KeyEvent;
import com.panda3ds.pandroid.AlberDriver;
import com.panda3ds.pandroid.input.InputEvent;
import com.panda3ds.pandroid.input.InputMap;
@ -7,7 +10,13 @@ import com.panda3ds.pandroid.input.KeyName;
import com.panda3ds.pandroid.lang.Function;
import com.panda3ds.pandroid.math.Vector2;
import java.util.Objects;
public class AlberInputListener implements Function<InputEvent> {
private final Activity activity;
public AlberInputListener(Activity activity){
this.activity = activity;
}
private final Vector2 axis = new Vector2(0.0f, 0.0f);
@ -15,6 +24,11 @@ public class AlberInputListener implements Function<InputEvent> {
public void run(InputEvent event) {
KeyName key = InputMap.relative(event.getName());
if (Objects.equals(event.getName(), "KEYCODE_BACK")){
activity.onBackPressed();
return;
}
if (key == KeyName.NULL)
return;

View file

@ -51,7 +51,7 @@ public class GamesFragment extends Fragment implements ActivityResultCallback<Ur
String uri = result.toString();
if (GameUtils.findByRomPath(uri) == null) {
FileUtils.makeUriPermanent(uri, FileUtils.MODE_READ);
GameMetadata game = new GameMetadata(FileUtils.getName(uri).split("\\.")[0], uri, "Unknown");
GameMetadata game = new GameMetadata(uri, FileUtils.getName(uri).split("\\.")[0],"Unknown");
GameUtils.addGame(game);
GameUtils.launch(requireActivity(), game);
}

View file

@ -0,0 +1,167 @@
package com.panda3ds.pandroid.data;
import android.graphics.Bitmap;
import com.panda3ds.pandroid.data.game.GameRegion;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
public class SMDH {
private static final int ICON_SIZE = 48;
private static final int META_OFFSET = 0x8 ;
private static final int META_REGION_OFFSET = 0x2018;
private static final int IMAGE_OFFSET = 0x24C0;
private int metaIndex = 1;
private final ByteBuffer smdh;
private final String[] title = new String[12];
private final String[] publisher = new String[12];
private final int[] icon;
private final GameRegion region;
public SMDH(byte[] source){
smdh = ByteBuffer.allocate(source.length);
smdh.position(0);
smdh.put(source);
smdh.position(0);
region = parseRegion();
icon = parseIcon();
parseMeta();
}
private GameRegion parseRegion(){
GameRegion region;
smdh.position(META_REGION_OFFSET);
int regionMasks = smdh.get() & 0xFF;
final boolean japan = (regionMasks & 0x1) != 0;
final boolean northAmerica = (regionMasks & 0x2) != 0;
final boolean europe = (regionMasks & 0x4) != 0;
final boolean australia = (regionMasks & 0x8) != 0;
final boolean china = (regionMasks & 0x10) != 0;
final boolean korea = (regionMasks & 0x20) != 0;
final boolean taiwan = (regionMasks & 0x40) != 0;
if (northAmerica) {
region = GameRegion.NorthAmerican;
} else if (europe) {
region = GameRegion.Europe;
} else if (australia) {
region = GameRegion.Australia;
} else if (japan) {
region = GameRegion.Japan;
metaIndex = 0;
} else if (korea) {
metaIndex = 7;
region = GameRegion.Korean;
} else if (china) {
metaIndex = 6;
region = GameRegion.China;
} else if (taiwan) {
metaIndex = 6;
region = GameRegion.Taiwan;
} else {
region = GameRegion.None;
}
return region;
}
private void parseMeta(){
for (int i = 0; i < 12; i++){
smdh.position(META_OFFSET + (512*i) + 0x80);
byte[] data = new byte[0x100];
smdh.get(data);
title[i] = convertString(data).replaceAll("\n", " ");
}
for (int i = 0; i < 12; i++){
smdh.position(META_OFFSET + (512 * i) + 0x180);
byte[] data = new byte[0x80];
smdh.get(data);
publisher[i] = convertString(data);
}
}
private int[] parseIcon() {
int[] icon = new int[ICON_SIZE*ICON_SIZE];
smdh.position(0);
for (int x = 0; x < ICON_SIZE; x++) {
for (int y = 0; y < ICON_SIZE; y++) {
int curseY = y & ~7;
int curseX = x & ~7;
int i = mortonInterleave(x, y);
int offset = (i + (curseX * 8)) * 2;
offset = offset + curseY * 48 * 2;
smdh.position(offset + IMAGE_OFFSET);
int bit1 = smdh.get() & 0xFF;
int bit2 = smdh.get() & 0xFF;
int pixel = bit1 + (bit2 << 8);
int r = (((pixel & 0xF800) >> 11) << 3);
int g = (((pixel & 0x7E0) >> 5) << 2);
int b = (((pixel & 0x1F)) << 3);
//Convert to ARGB8888
icon[x + 48 * y] = 255 << 24 | (r & 255) << 16 | (g & 255) << 8 | (b & 255);
}
}
return icon;
}
public GameRegion getRegion() {
return region;
}
public Bitmap getBitmapIcon(){
Bitmap bitmap = Bitmap.createBitmap(ICON_SIZE, ICON_SIZE, Bitmap.Config.RGB_565);
bitmap.setPixels(icon,0,ICON_SIZE,0,0,ICON_SIZE,ICON_SIZE);
return bitmap;
}
public int[] getIcon() {
return icon;
}
public String getTitle(){
return title[metaIndex];
}
public String getPublisher(){
return publisher[metaIndex];
}
// SMDH stores string in UTF-16LE format
private static String convertString(byte[] buffer){
try {
return new String(buffer,0, buffer.length, StandardCharsets.UTF_16LE)
.replaceAll("\0","");
} catch (Exception e){
return "";
}
}
// u and v are the UVs of the relevant texel
// Texture data is stored interleaved in Morton order, ie in a Z - order curve as shown here
// https://en.wikipedia.org/wiki/Z-order_curve
// Textures are split into 8x8 tiles.This function returns the in - tile offset depending on the u & v of the texel
// The in - tile offset is the sum of 2 offsets, one depending on the value of u % 8 and the other on the value of y % 8
// As documented in this picture https ://en.wikipedia.org/wiki/File:Moser%E2%80%93de_Bruijn_addition.svg
private static int mortonInterleave(int u, int v) {
int[] xlut = {0, 1, 4, 5, 16, 17, 20, 21};
int[] ylut = {0, 2, 8, 10, 32, 34, 40, 42};
return xlut[u % 8] + ylut[v % 8];
}
}

View file

@ -1,23 +1,42 @@
package com.panda3ds.pandroid.data.game;
import android.graphics.Bitmap;
import android.util.Log;
import androidx.annotation.Nullable;
import com.panda3ds.pandroid.data.SMDH;
import com.panda3ds.pandroid.utils.Constants;
import com.panda3ds.pandroid.utils.GameUtils;
import java.util.Objects;
import java.util.UUID;
public class GameMetadata {
private final String id;
private final String romPath;
private final String title;
private transient final Bitmap icon = Bitmap.createBitmap(48,48, Bitmap.Config.RGB_565);
private final String publisher;
private final GameRegion[] regions = new GameRegion[]{GameRegion.None};
private final GameRegion[] regions;
private transient Bitmap icon;
public GameMetadata(String title, String romPath, String publisher) {
this.id = UUID.randomUUID().toString();
private GameMetadata(String id, String romPath, String title, String publisher, Bitmap icon, GameRegion[] regions){
this.id = id;
this.title = title;
this.publisher = publisher;
this.romPath = romPath;
this.regions = regions;
if (icon != null) {
GameUtils.setGameIcon(id, icon);
}
}
public GameMetadata(String romPath,String title, String publisher, GameRegion[] regions) {
this(UUID.randomUUID().toString(), romPath, title, publisher, null, regions);
}
public GameMetadata(String romPath,String title, String publisher){
this(romPath,title, publisher, new GameRegion[]{GameRegion.None});
}
public String getRomPath() {
@ -37,10 +56,28 @@ public class GameMetadata {
}
public Bitmap getIcon() {
if (icon == null){
icon = GameUtils.loadGameIcon(id);
}
return icon;
}
public GameRegion[] getRegions() {
return regions;
}
@Override
public boolean equals(@Nullable Object obj) {
if (obj instanceof GameMetadata){
return Objects.equals(((GameMetadata) obj).id, id);
}
return false;
}
public static GameMetadata applySMDH(GameMetadata meta, SMDH smdh){
Bitmap icon = smdh.getBitmapIcon();
GameMetadata newMeta = new GameMetadata(meta.getId(), meta.getRomPath(), smdh.getTitle(), smdh.getPublisher(), icon, new GameRegion[]{smdh.getRegion()});
icon.recycle();
return newMeta;
}
}

View file

@ -8,12 +8,16 @@ import androidx.documentfile.provider.DocumentFile;
import com.panda3ds.pandroid.app.PandroidApplication;
import java.io.File;
public class FileUtils {
public static final String MODE_READ = "r";
private static Uri parseUri(String value) {
return Uri.parse(value);
private static DocumentFile parseFile(String path){
if (path.startsWith("/")){
return DocumentFile.fromFile(new File(path));
}
Uri uri = Uri.parse(path);
return DocumentFile.fromSingleUri(getContext(), uri);
}
private static Context getContext() {
@ -21,8 +25,21 @@ public class FileUtils {
}
public static String getName(String path) {
DocumentFile file = DocumentFile.fromSingleUri(getContext(), parseUri(path));
return file.getName();
return parseFile(path).getName();
}
public static boolean createFolder(String path, String name){
DocumentFile folder = parseFile(path);
if (folder.findFile(name) != null){
return true;
}
return folder.createDirectory(name) != null;
}
public static String getPrivatePath(){
return getContext().getFilesDir().getAbsolutePath();
}
public static void makeUriPermanent(String uri, String mode) {
@ -31,6 +48,6 @@ public class FileUtils {
if (mode.toLowerCase().contains("w"))
flags &= Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
getContext().getContentResolver().takePersistableUriPermission(parseUri(uri), flags);
getContext().getContentResolver().takePersistableUriPermission(Uri.parse(uri), flags);
}
}

View file

@ -3,27 +3,39 @@ package com.panda3ds.pandroid.utils;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.util.Log;
import com.google.gson.Gson;
import com.panda3ds.pandroid.app.GameActivity;
import com.panda3ds.pandroid.app.PandroidApplication;
import com.panda3ds.pandroid.data.game.GameMetadata;
import java.io.File;
import java.io.FileOutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Objects;
public class GameUtils {
private static final Bitmap DEFAULT_ICON = Bitmap.createBitmap(48,48, Bitmap.Config.ARGB_8888);
private static final String KEY_GAME_LIST = "gameList";
private static final ArrayList<GameMetadata> games = new ArrayList<>();
private static SharedPreferences data;
private static final Gson gson = new Gson();
private static GameMetadata currentGame;
public static void initialize() {
data = PandroidApplication.getAppContext().getSharedPreferences(Constants.PREF_GAME_UTILS, Context.MODE_PRIVATE);
GameMetadata[] list = gson.fromJson(data.getString(KEY_GAME_LIST, "[]"), GameMetadata[].class);
for (GameMetadata game: list)
game.getIcon();
games.clear();
games.addAll(Arrays.asList(list));
}
@ -38,17 +50,22 @@ public class GameUtils {
}
public static void launch(Context context, GameMetadata game) {
currentGame = game;
String path = PathUtils.getPath(Uri.parse(game.getRomPath()));
context.startActivity(new Intent(context, GameActivity.class).putExtra(Constants.ACTIVITY_PARAMETER_PATH, path));
}
public static GameMetadata getCurrentGame() {
return currentGame;
}
public static void removeGame(GameMetadata game) {
games.remove(game);
saveAll();
}
public static void addGame(GameMetadata game) {
games.add(game);
games.add(0,game);
saveAll();
}
@ -61,4 +78,27 @@ public class GameUtils {
.putString(KEY_GAME_LIST, gson.toJson(games.toArray(new GameMetadata[0])))
.apply();
}
public static void setGameIcon(String id, Bitmap icon) {
try {
File file = new File(FileUtils.getPrivatePath()+"/cache_icons/", id+".png");
file.getParentFile().mkdirs();
FileOutputStream o = new FileOutputStream(file);
icon.compress(Bitmap.CompressFormat.PNG, 100, o);
o.close();
} catch (Exception e){
Log.e(Constants.LOG_TAG, "Error on save game icon: ", e);
}
}
public static Bitmap loadGameIcon(String id) {
try {
File file = new File(FileUtils.getPrivatePath()+"/cache_icons/"+id+".png");
if (file.exists())
return BitmapFactory.decodeFile(file.getAbsolutePath());
} catch (Exception e){
Log.e(Constants.LOG_TAG, "Error on load game icon: ", e);
}
return DEFAULT_ICON;
}
}