Android: Add graphical save/load state selector

This commit is contained in:
Connor McLaughlin 2021-02-07 02:47:19 +10:00
parent b560142015
commit 6ad2b72c2e
14 changed files with 370 additions and 99 deletions

View File

@ -51,6 +51,8 @@ static jclass s_PatchCode_class;
static jmethodID s_PatchCode_constructor;
static jclass s_GameListEntry_class;
static jmethodID s_GameListEntry_constructor;
static jclass s_SaveStateInfo_class;
static jmethodID s_SaveStateInfo_constructor;
namespace AndroidHelpers {
// helper for retrieving the current per-thread jni environment
@ -350,7 +352,7 @@ void AndroidHostInterface::RunOnEmulationThread(std::function<void()> function,
m_mutex.unlock();
}
void AndroidHostInterface::RunLater(std::function<void ()> func)
void AndroidHostInterface::RunLater(std::function<void()> func)
{
std::unique_lock<std::mutex> lock(m_mutex);
m_callback_queue.push_back(std::move(func));
@ -887,7 +889,7 @@ extern "C" jint JNI_OnLoad(JavaVM* vm, void* reserved)
// Create global reference so it doesn't get cleaned up.
JNIEnv* env = AndroidHelpers::GetJNIEnv();
jclass string_class, host_interface_class, patch_code_class, game_list_entry_class;
jclass string_class, host_interface_class, patch_code_class, game_list_entry_class, save_state_info_class;
if ((string_class = env->FindClass("java/lang/String")) == nullptr ||
(s_String_class = static_cast<jclass>(env->NewGlobalRef(string_class))) == nullptr ||
(host_interface_class = env->FindClass("com/github/stenzek/duckstation/AndroidHostInterface")) == nullptr ||
@ -895,7 +897,9 @@ extern "C" jint JNI_OnLoad(JavaVM* vm, void* reserved)
(patch_code_class = env->FindClass("com/github/stenzek/duckstation/PatchCode")) == nullptr ||
(s_PatchCode_class = static_cast<jclass>(env->NewGlobalRef(patch_code_class))) == nullptr ||
(game_list_entry_class = env->FindClass("com/github/stenzek/duckstation/GameListEntry")) == nullptr ||
(s_GameListEntry_class = static_cast<jclass>(env->NewGlobalRef(game_list_entry_class))) == nullptr)
(s_GameListEntry_class = static_cast<jclass>(env->NewGlobalRef(game_list_entry_class))) == nullptr ||
(save_state_info_class = env->FindClass("com/github/stenzek/duckstation/SaveStateInfo")) == nullptr ||
(s_SaveStateInfo_class = static_cast<jclass>(env->NewGlobalRef(save_state_info_class))) == nullptr)
{
Log_ErrorPrint("AndroidHostInterface class lookup failed");
return -1;
@ -937,7 +941,11 @@ extern "C" jint JNI_OnLoad(JavaVM* vm, void* reserved)
(s_GameListEntry_constructor = env->GetMethodID(
s_GameListEntry_class, "<init>",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/"
"String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V")) == nullptr)
"String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V")) == nullptr ||
(s_SaveStateInfo_constructor = env->GetMethodID(
s_SaveStateInfo_class, "<init>",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZII[B)V")) ==
nullptr)
{
Log_ErrorPrint("AndroidHostInterface lookups failed");
return -1;
@ -1574,3 +1582,96 @@ DEFINE_JNI_ARGS_METHOD(jboolean, AndroidHostInterface_setMediaFilename, jstring
return true;
}
static jobject CreateSaveStateInfo(JNIEnv* env, const CommonHostInterface::ExtendedSaveStateInfo& ssi)
{
LocalRefHolder<jstring> path(env, env->NewStringUTF(ssi.path.c_str()));
LocalRefHolder<jstring> title(env, env->NewStringUTF(ssi.title.c_str()));
LocalRefHolder<jstring> code(env, env->NewStringUTF(ssi.game_code.c_str()));
LocalRefHolder<jstring> media_path(env, env->NewStringUTF(ssi.media_path.c_str()));
LocalRefHolder<jstring> timestamp(env, env->NewStringUTF(Timestamp::FromUnixTimestamp(ssi.timestamp).ToString("%c")));
LocalRefHolder<jbyteArray> screenshot_data;
if (!ssi.screenshot_data.empty())
{
const jsize data_size = static_cast<jsize>(ssi.screenshot_data.size() * sizeof(u32));
screenshot_data = LocalRefHolder<jbyteArray>(env, env->NewByteArray(data_size));
env->SetByteArrayRegion(screenshot_data.Get(), 0, data_size,
reinterpret_cast<const jbyte*>(ssi.screenshot_data.data()));
}
return env->NewObject(s_SaveStateInfo_class, s_SaveStateInfo_constructor, path.Get(), title.Get(), code.Get(),
media_path.Get(), timestamp.Get(), static_cast<jint>(ssi.slot),
static_cast<jboolean>(ssi.global), static_cast<jint>(ssi.screenshot_width),
static_cast<jint>(ssi.screenshot_height), screenshot_data.Get());
}
static jobject CreateEmptySaveStateInfo(JNIEnv* env, s32 slot, bool global)
{
return env->NewObject(s_SaveStateInfo_class, s_SaveStateInfo_constructor, nullptr, nullptr, nullptr, nullptr, nullptr,
static_cast<jint>(slot), static_cast<jboolean>(global), static_cast<jint>(0),
static_cast<jint>(0), nullptr);
}
DEFINE_JNI_ARGS_METHOD(jobjectArray, AndroidHostInterface_getSaveStateInfo, jobject obj, jboolean includeEmpty)
{
if (!System::IsValid())
return nullptr;
AndroidHostInterface* hi = AndroidHelpers::GetNativeClass(env, obj);
std::vector<jobject> infos;
// +1 for the quick save only in android.
infos.reserve(1 + CommonHostInterface::PER_GAME_SAVE_STATE_SLOTS + CommonHostInterface::GLOBAL_SAVE_STATE_SLOTS);
const std::string& game_code = System::GetRunningCode();
if (!game_code.empty())
{
for (u32 i = 0; i <= CommonHostInterface::PER_GAME_SAVE_STATE_SLOTS; i++)
{
std::optional<CommonHostInterface::ExtendedSaveStateInfo> esi =
hi->GetExtendedSaveStateInfo(game_code.c_str(), static_cast<s32>(i));
if (esi.has_value())
{
jobject obj = CreateSaveStateInfo(env, esi.value());
if (obj)
infos.push_back(obj);
}
else if (includeEmpty)
{
jobject obj = CreateEmptySaveStateInfo(env, static_cast<s32>(i), false);
if (obj)
infos.push_back(obj);
}
}
}
for (u32 i = 1; i <= CommonHostInterface::GLOBAL_SAVE_STATE_SLOTS; i++)
{
std::optional<CommonHostInterface::ExtendedSaveStateInfo> esi =
hi->GetExtendedSaveStateInfo(nullptr, static_cast<s32>(i));
if (esi.has_value())
{
jobject obj = CreateSaveStateInfo(env, esi.value());
if (obj)
infos.push_back(obj);
}
else if (includeEmpty)
{
jobject obj = CreateEmptySaveStateInfo(env, static_cast<s32>(i), true);
if (obj)
infos.push_back(obj);
}
}
if (infos.empty())
return nullptr;
jobjectArray ret = env->NewObjectArray(static_cast<jsize>(infos.size()), s_SaveStateInfo_class, nullptr);
for (size_t i = 0; i < infos.size(); i++)
{
env->SetObjectArrayElement(ret, static_cast<jsize>(i), infos[i]);
env->DeleteLocalRef(infos[i]);
}
return ret;
}

View File

@ -136,6 +136,8 @@ public class AndroidHostInterface {
public native boolean setMediaFilename(String filename);
public native SaveStateInfo[] getSaveStateInfo(boolean includeEmpty);
static {
System.loadLibrary("duckstation-native");
}

View File

@ -19,6 +19,7 @@ import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.ListView;
import android.widget.Toast;
import androidx.annotation.NonNull;
@ -41,7 +42,6 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde
private boolean mApplySettingsOnSurfaceRestored = false;
private String mGameTitle = null;
private EmulationSurfaceView mContentView;
private int mSaveStateSlot = 0;
private boolean getBooleanSetting(String key, boolean defaultValue) {
return mPreferences.getBoolean(key, defaultValue);
@ -398,42 +398,36 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde
}
AlertDialog.Builder builder = new AlertDialog.Builder(this);
if (mGameTitle != null && !mGameTitle.isEmpty())
builder.setTitle(mGameTitle);
builder.setItems(R.array.emulation_menu, (dialogInterface, i) -> {
switch (i) {
case 0: // Quick Load
case 0: // Load State
{
AndroidHostInterface.getInstance().loadState(false, mSaveStateSlot);
onMenuClosed();
showSaveStateMenu(false);
return;
}
case 1: // Quick Save
case 1: // Save State
{
AndroidHostInterface.getInstance().saveState(false, mSaveStateSlot);
onMenuClosed();
showSaveStateMenu(true);
return;
}
case 2: // Save State Slot
{
showSaveStateSlotMenu();
return;
}
case 3: // Toggle Fast Forward
case 2: // Toggle Fast Forward
{
AndroidHostInterface.getInstance().setFastForwardEnabled(!AndroidHostInterface.getInstance().isFastForwardEnabled());
onMenuClosed();
return;
}
case 4: // More Options
case 3: // More Options
{
showMoreMenu();
return;
}
case 5: // Quit
case 4: // Quit
{
mStopRequested = true;
finish();
@ -445,15 +439,34 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde
builder.create().show();
}
private void showSaveStateSlotMenu() {
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setSingleChoiceItems(R.array.emulation_save_state_slot_menu, mSaveStateSlot, (dialogInterface, i) -> {
mSaveStateSlot = i;
dialogInterface.dismiss();
private void showSaveStateMenu(boolean saving) {
final SaveStateInfo[] infos = AndroidHostInterface.getInstance().getSaveStateInfo(true);
if (infos == null) {
onMenuClosed();
return;
}
final AlertDialog.Builder builder = new AlertDialog.Builder(this);
final ListView listView = new ListView(this);
listView.setAdapter(new SaveStateInfo.ListAdapter(this, infos));
builder.setView(listView);
builder.setOnDismissListener((dialog) -> {
onMenuClosed();
});
builder.setOnCancelListener(dialogInterface -> onMenuClosed());
builder.create().show();
final AlertDialog dialog = builder.create();
listView.setOnItemClickListener((parent, view, position, id) -> {
SaveStateInfo info = infos[position];
if (saving) {
AndroidHostInterface.getInstance().saveState(info.isGlobal(), info.getSlot());
} else {
AndroidHostInterface.getInstance().loadState(info.isGlobal(), info.getSlot());
}
dialog.dismiss();
});
dialog.show();
}
private void showMoreMenu() {

View File

@ -298,7 +298,7 @@ public class GameDirectoriesActivity extends AppCompatActivity {
.setTitle(R.string.edit_game_directories_add_path)
.setMessage(R.string.edit_game_directories_add_path_summary)
.setView(text)
.setPositiveButton("Add", (dialog, which) -> {
.setPositiveButton("Add", (dialog, which) -> {
final String path = text.getText().toString();
if (!path.isEmpty()) {
addSearchDirectory(GameDirectoriesActivity.this, path, true);

View File

@ -0,0 +1,151 @@
package com.github.stenzek.duckstation;
import android.content.Context;
import android.graphics.Bitmap;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ImageView;
import android.widget.TextView;
import java.nio.ByteBuffer;
public class SaveStateInfo {
private String mPath;
private String mGameTitle;
private String mGameCode;
private String mMediaPath;
private String mTimestamp;
private int mSlot;
private boolean mGlobal;
private Bitmap mScreenshot;
public SaveStateInfo(String path, String gameTitle, String gameCode, String mediaPath, String timestamp, int slot, boolean global,
int screenshotWidth, int screenshotHeight, byte[] screenshotData) {
mPath = path;
mGameTitle = gameTitle;
mGameCode = gameCode;
mMediaPath = mediaPath;
mTimestamp = timestamp;
mSlot = slot;
mGlobal = global;
if (screenshotData != null) {
try {
mScreenshot = Bitmap.createBitmap(screenshotWidth, screenshotHeight, Bitmap.Config.ARGB_8888);
mScreenshot.copyPixelsFromBuffer(ByteBuffer.wrap(screenshotData));
} catch (Exception e) {
mScreenshot = null;
}
}
}
public boolean exists() {
return mPath != null;
}
public String getPath() {
return mPath;
}
public String getGameTitle() {
return mGameTitle;
}
public String getGameCode() {
return mGameCode;
}
public String getMediaPath() {
return mMediaPath;
}
public String getTimestamp() {
return mTimestamp;
}
public int getSlot() {
return mSlot;
}
public boolean isGlobal() {
return mGlobal;
}
public Bitmap getScreenshot() {
return mScreenshot;
}
private void fillView(Context context, View view) {
ImageView imageView = (ImageView) view.findViewById(R.id.image);
TextView summaryView = (TextView) view.findViewById(R.id.summary);
TextView gameView = (TextView) view.findViewById(R.id.game);
TextView pathView = (TextView) view.findViewById(R.id.path);
TextView timestampView = (TextView) view.findViewById(R.id.timestamp);
if (mScreenshot != null)
imageView.setImageBitmap(mScreenshot);
else
imageView.setImageDrawable(context.getDrawable(R.drawable.ic_baseline_not_interested_60));
String summaryText;
if (mGlobal)
summaryView.setText(String.format(context.getString(R.string.save_state_info_global_save_n), mSlot));
else if (mSlot == 0)
summaryView.setText(R.string.save_state_info_quick_save);
else
summaryView.setText(String.format(context.getString(R.string.save_state_info_game_save_n), mSlot));
if (exists()) {
gameView.setText(String.format("%s - %s", mGameCode, mGameTitle));
int lastSlashPosition = mMediaPath.lastIndexOf('/');
if (lastSlashPosition >= 0)
pathView.setText(mMediaPath.substring(lastSlashPosition + 1));
else
pathView.setText(mMediaPath);
timestampView.setText(mTimestamp);
} else {
gameView.setText(R.string.save_state_info_slot_is_empty);
pathView.setText("");
timestampView.setText("");
}
}
public static class ListAdapter extends BaseAdapter {
private final Context mContext;
private final SaveStateInfo[] mInfos;
public ListAdapter(Context context, SaveStateInfo[] infos) {
mContext = context;
mInfos = infos;
}
@Override
public int getCount() {
return mInfos.length;
}
@Override
public Object getItem(int position) {
return mInfos[position];
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
if (convertView == null) {
convertView = LayoutInflater.from(mContext).inflate(R.layout.save_state_view_entry, parent, false);
}
mInfos[position].fillView(mContext, convertView);
return convertView;
}
}
}

View File

@ -0,0 +1,5 @@
<vector android:height="60dp" android:tint="?attr/colorControlNormal"
android:viewportHeight="24" android:viewportWidth="24"
android:width="60dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8 0,-1.85 0.63,-3.55 1.69,-4.9L16.9,18.31C15.55,19.37 13.85,20 12,20zM18.31,16.9L7.1,5.69C8.45,4.63 10.15,4 12,4c4.42,0 8,3.58 8,8 0,1.85 -0.63,3.55 -1.69,4.9z"/>
</vector>

View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/linearLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView
android:id="@+id/image"
android:layout_width="80dp"
android:layout_height="60dp"
android:layout_marginTop="20dp"
android:layout_marginBottom="4dp"
android:layout_marginLeft="15dp"
android:layout_alignParentTop="true"
android:scaleType="fitXY"
tools:srcCompat="@drawable/ic_media_cdrom" />
<TextView
android:id="@+id/summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:layout_marginTop="8dp"
android:text="Game Slot 1"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:layout_alignParentTop="true"
android:layout_toRightOf="@id/image" />
<TextView
android:id="@+id/game"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:layout_marginTop="0dp"
android:text="SCES-0000 - Game Name"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:layout_below="@id/summary"
android:layout_toRightOf="@id/image" />
<TextView
android:id="@+id/path"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:layout_marginTop="0dp"
android:text="Dump Name.chd"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:layout_below="@id/game"
android:layout_toRightOf="@id/image" />
<TextView
android:id="@+id/timestamp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:layout_marginTop="0dp"
android:layout_marginBottom="14dp"
android:text="Saved at Timestamp"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:layout_below="@id/path"
android:layout_toRightOf="@id/image" />
</RelativeLayout>

View File

@ -81,7 +81,6 @@
<string-array name="emulation_menu">
<item>Cargar Estado</item>
<item>Guardar Estado</item>
<item>Guardar Estado en Ranura</item>
<item>Activar Avance Rápido</item>
<item>Más Opciones</item>
<item>Salir</item>
@ -94,19 +93,6 @@
<item>Cambiar Control de Pantalla Tactil</item>
<item>Editar Diseño del Control de Pantalla Tactil</item>
</string-array>
<string-array name="emulation_save_state_slot_menu">
<item>Ranura Rápida</item>
<item>Ranura 1</item>
<item>Ranura 2</item>
<item>Ranura 3</item>
<item>Ranura 4</item>
<item>Ranura 5</item>
<item>Ranura 6</item>
<item>Ranura 7</item>
<item>Ranura 8</item>
<item>Ranura 9</item>
<item>Ranura 10</item>
</string-array>
<string-array name="settings_cdrom_read_speedup_entries">
<item>Ninguno (Velocidad Doble)</item>
<item>2x (Velocidad Cuádruple)</item>

View File

@ -81,7 +81,6 @@
<string-array name="emulation_menu">
<item>Carica Stato</item>
<item>Salva Stato</item>
<item>Slot Salvataggio Stato</item>
<item>Abilita/Disabilita Avanti Veloce</item>
<item>Altre Opzioni</item>
<item>Esci</item>
@ -94,19 +93,6 @@
<item>Cambia Controller Touchscreen</item>
<item>Edit Touchscreen Controller Layout</item>
</string-array>
<string-array name="emulation_save_state_slot_menu">
<item>Slot Veloce</item>
<item>Slot Gioco 1</item>
<item>Slot Gioco 2</item>
<item>Slot Gioco 3</item>
<item>Slot Gioco 4</item>
<item>Slot Gioco 5</item>
<item>Slot Gioco 6</item>
<item>Slot Gioco 7</item>
<item>Slot Gioco 8</item>
<item>Slot Gioco 9</item>
<item>Slot Gioco 10</item>
</string-array>
<string-array name="settings_cdrom_read_speedup_entries">
<item>Nessuna Velocità Doppia)</item>
<item>2x (Velocità Quadrupla</item>

View File

@ -81,7 +81,6 @@
<string-array name="emulation_menu">
<item>Staat Laden</item>
<item>Staat Opslaan</item>
<item>Staat Nummer</item>
<item>Doorspoelen aan/uitzetten</item>
<item>Meer Opties</item>
<item>Afsluiten</item>
@ -94,19 +93,6 @@
<item>Touchscreen Controller Aanpassen</item>
<item>Edit Touchscreen Controller Layout</item>
</string-array>
<string-array name="emulation_save_state_slot_menu">
<item>Snel Slot</item>
<item>Game Slot 1</item>
<item>Game Slot 2</item>
<item>Game Slot 3</item>
<item>Game Slot 4</item>
<item>Game Slot 5</item>
<item>Game Slot 6</item>
<item>Game Slot 7</item>
<item>Game Slot 8</item>
<item>Game Slot 9</item>
<item>Game Slot 10</item>
</string-array>
<string-array name="settings_cdrom_read_speedup_entries">
<item>Geen (Dubbele Snelheid)</item>
<item>2x (Vierdubbele Snelheid)</item>

View File

@ -81,7 +81,6 @@
<string-array name="emulation_menu">
<item>Carregar Estado</item>
<item>Salvar Estado</item>
<item>Salvar para Compartimento</item>
<item>Avanço (Fixo)</item>
<item>Mais Opções</item>
<item>Sair</item>
@ -94,19 +93,6 @@
<item>Mudar controle em Tela</item>
<item>Editar Posição dos Controles (Tela)</item>
</string-array>
<string-array name="emulation_save_state_slot_menu">
<item>Armazenamento Rápido</item>
<item>Armazenamento 1</item>
<item>Armazenamento 2</item>
<item>Armazenamento 3</item>
<item>Armazenamento 4</item>
<item>Armazenamento 5</item>
<item>Armazenamento 6</item>
<item>Armazenamento 7</item>
<item>Armazenamento 8</item>
<item>Armazenamento 9</item>
<item>Armazenamento 10</item>
</string-array>
<string-array name="settings_cdrom_read_speedup_entries">
<item>Nenhum</item>
<item>2x (4x Veloz)</item>

View File

@ -81,7 +81,6 @@
<string-array name="emulation_menu">
<item>Загрузить состояние</item>
<item>Сохранить состояние</item>
<item>Слот сохранения</item>
<item>Включить ускорение</item>
<item>Другие опции</item>
<item>Выход</item>

View File

@ -160,7 +160,6 @@
<string-array name="emulation_menu">
<item>Load State</item>
<item>Save State</item>
<item>Save State Slot</item>
<item>Toggle Fast Forward</item>
<item>More Options</item>
<item>Quit</item>
@ -173,19 +172,6 @@
<item>Change Touchscreen Controller</item>
<item>Edit Touchscreen Controller Layout</item>
</string-array>
<string-array name="emulation_save_state_slot_menu">
<item>Quick Slot</item>
<item>Game Slot 1</item>
<item>Game Slot 2</item>
<item>Game Slot 3</item>
<item>Game Slot 4</item>
<item>Game Slot 5</item>
<item>Game Slot 6</item>
<item>Game Slot 7</item>
<item>Game Slot 8</item>
<item>Game Slot 9</item>
<item>Game Slot 10</item>
</string-array>
<string-array name="settings_cdrom_read_speedup_entries">
<item>None (Double Speed)</item>
<item>2x (Quad Speed)</item>

View File

@ -206,4 +206,8 @@
<string name="menu_edit_game_directories_add_path">Add Path</string>
<string name="edit_game_directories_add_path">Add Path</string>
<string name="edit_game_directories_add_path_summary">Enter the full path to the directory with games.\n\nYou can get this from a file manager app.\n\nExample: /storage/emulated/0/games</string>
<string name="save_state_info_slot_is_empty">Slot Is Empty</string>
<string name="save_state_info_game_save_n">Game Save %d</string>
<string name="save_state_info_global_save_n">Global Save %d</string>
<string name="save_state_info_quick_save">Quick Save</string>
</resources>