GHIDRA, исполняемые файлы Playstation 1, FLIRT-сигнатуры и PsyQ +38



Привет всем,



Не знаю как вам, а мне всегда хотелось пореверсить старые приставочные игры, имея в запасе ещё и декомпилятор. И вот, этот радостный момент в моей жизни настал — вышла GHIDRA. О том, что это такое, писать не буду, можно легко загуглить. И, отзывы настолько разные (особенно от ретроградов), что новичку будет сложно даже решиться на запуск этого чуда… Вот вам пример: "20 лет работал в иде, и смотрю я на вашу Гидру с большим недоверием, потому что АНБ. Но когда-нибудь запущу и проверю её в деле".


Если в двух словах — запускать Гидру не страшно. И то, что мы получаем после запуска перекроет весь ваш страх перед закладками-и-бэкдорами от вездесущих АНБ.


Так вот, о чём это я… Есть такая приставка: Sony Playstation 1 (PS1, PSX, Плойка). Под неё было создано множество крутых игр, появилась куча франшиз, которые популярны до сих пор. И мне однажды захотелось узнать, как они устроены: какие есть форматы данных, используется ли сжатие ресурсов, попробовать что-то перевести на русский язык (сразу скажу, пока не перевёл ни одной игры).


Начал с того, что написал с товарищем на Delphi крутую утилиту для работы с форматом TIM (это что-то типа BMP от мира Playstation): Tim2View. В своё время пользовалась успехом (а может и сейчас пользуется). Потом захотелось углубиться в сжатие.


image


И тут начались проблемы. С процессором MIPS я тогда ещё не был знаком. Взялся изучать. С IDA Pro я тоже тогда не был знаком (я пришёл к реверсу игр на Sega Mega Drive позже Playstation). Но, благодаря интернету я узнал, что как раз таки IDA Pro поддерживает загрузку и анализ исполняемых файлов PS1: PS-X EXE. Попробовал закинуть файл игры (кажется, это были Lemmings) со странным именем и расширением, типа SLUS_123.45 в Иду, получил кучу строк ассемблерного кода (к счастью, я уже имел представление о том, что это такое, благодаря exe-шникам Винды под x86), и начал разбираться.



Первым трудным для понимания местом был конвейер инструкций. Например, вы видите вызов какой-то функции, а сразу за ним идёт загрузка в регистр параметра, который должен использоваться в этой функции. Если вкратце, то перед всякими прыжками и вызовами функций сначала выполняется следующая за прыжком/вызовом инструкция, а уже потом — сам вызов или прыжок.


После всех пройденных трудностей мне удалось написать несколько упаковщиков/распаковщиков ресурсов игр. Но именно изучением кода я никогда не занимался. Почему? Ну, всё банально: кода было очень много, обращения к BIOS и функциям, которые понять было фактически невозможно (они были библиотечными, а SDK под плойку у меня тогда не было), инструкции, работающие с тремя регистрами одновременно, отсутствие декомпилятора.


И вот, спустя много-много лет, выходит GHIDRA. Среди поддерживаемых декомпилятором платформ есть MIPS. О, радость! Давайте же скорее попробуем что-то декомпилировать! Но… меня ждал облом. Исполняемые файлы PS-X EXE не поддерживаются Гидрой. Не беда, напишем свой!


Собственно код


Хватит лирических отступлений, давайте писать код. Как создавать свои загрузчики для Ghidra, я уже представление имел, о чём писал ранее. Поэтому, осталось только найти Memory Map первой плойки, адреса регистров и, можно собирать и грузить бинари. Сказано — сделано.


Код был готов, регистры и регионы добавлялись и распознавались, но на местах вызовов библиотечных функций и функций BIOS по-прежнему было большое белое пятно. И, к сожалению, поддержки FLIRT у Гидры не было. Если нету, давай добавим.


Формат FLIRT-сигнатур известен и описан в pat.txt файлике, который можно найти в SDK Иды. Также, у Иды есть утилита для создания этих сигнатур специально из библиотечных файлов Playstation, и называется: ppsx. Скачал SDK для плойки, который называется PsyQ Playstation Development Kit, нашёл там lib-файлы и попробовал создать из них хоть какие-то сигнатуры — успешно. Получается текстовичок, в котором каждая строка имеет определённый формат. Остаётся написать код, который будет парсить эти строки, и применять их на код.



PatParser


Так как каждая строка имеет определённый формат, логично будет написать регулярное выражение. Оно получилось таким:


private static final Pattern linePat = Pattern.compile("^((?:[0-9A-F\\.]{2})+) ([0-9A-F]{2}) ([0-9A-F]{4}) ([0-9A-F]{4}) ((?:[:\\^][0-9A-F]{4}@? [\\.\\w]+ )+)((?:[0-9A-F\\.]{2})+)?$");

Ну и для выделения потом в списке модулей отдельно смещения, типа, и имени функции, пишем отдельный регэксп:


private static final Pattern modulePat = Pattern.compile("([:\\^][0-9A-F]{4}@?) ([\\.\\w]+) ");

Теперь пройдёмся по составляющим каждой сигнатуры отдельно:


  1. Сначала идёт hex-последовательность из байт (0-9A-F), где некоторые из них могут быть любыми (символ точки "."). Поэтому создаём класс, который будет хранить такую последовательность. Я назвал её MaskedBytes:

MaskedBytes.java
package pat;

public class MaskedBytes {

    private final byte[] bytes, masks;

    public final byte[] getBytes() {
        return bytes;
    }

    public final byte[] getMasks() {
        return masks;
    }

    public final int getLength() {
        return bytes.length;
    }

    public MaskedBytes(byte[] bytes, byte[] masks) {
        this.bytes = bytes;
        this.masks = masks;
    }

    public static MaskedBytes extend(MaskedBytes src, MaskedBytes add) {
        return extend(src, add.getBytes(), add.getMasks());
    }

    public static MaskedBytes extend(MaskedBytes src, byte[] addBytes, byte[] addMasks) {
        int length = src.getBytes().length;

        byte[] tmpBytes = new byte[length + addBytes.length];
        byte[] tmpMasks = new byte[length + addMasks.length];

        System.arraycopy(src.getBytes(), 0, tmpBytes, 0, length);
        System.arraycopy(addBytes, 0, tmpBytes, length, addBytes.length);

        System.arraycopy(src.getMasks(), 0, tmpMasks, 0, length);
        System.arraycopy(addMasks, 0, tmpMasks, length, addMasks.length);

        return new MaskedBytes(tmpBytes, tmpMasks);
    }
}

  1. Длина блока, от которого посчитана CRC16.
  2. CRC16, в которой используется свой полином (0x8408):

Код подсчёта CRC16
public static boolean checkCrc16(byte[] bytes, short resCrc) {
    if ( bytes.length == 0 )
        return true;

    int crc = 0xFFFF;

    for (int i = 0; i < bytes.length; ++i) {
        int a = bytes[i];

        for (int x = 0; x < 8; ++x) {
            if (((crc ^ a) & 1) != 0) {
                crc = (crc >> 1) ^ 0x8408;
            }
            else {
                crc >>= 1;
            }

            a >>= 1;
        }
    }

    crc = ~crc;
    int x = crc;
    crc = (crc << 8) | ((x >> 8) & 0xFF);

    crc &= 0xFFFF;

    return (short)crc == resCrc;
}

  1. Полная длина "модуля" в байтах.
  2. Список глобальных имён (то, что нам нужно).
  3. Список ссылок на другие имена (тоже нужно).
  4. Хвостовые байты.

У каждого имени в модуле есть определённый тип и смещение относительно начала. Тип может быть обозначен одним из символов: :, ^, @, в зависимости от типа:


  • ":NAME": глобальное имя. Именно ради таких имён я всё и затеял;
  • ":NAME@": локальное имя/метка. Можно и не обозначать, но пусть будет;
  • "^NAME": ссылка на имя.

С одной стороны всё просто, но, ссылка запросто может быть не ссылкой на функцию (и, соответственно, прыжок будет относительным), а на глобальную переменную. В чём, скажете вы, проблема? А она в том, что в PSX нельзя одной инструкцией затолкать целый DWORD в регистр. Для этого необходимо загрузить его в виде половинок. Дело в том, в MIPS размер инструкции ограничен четырьмя байтами. И, казалось бы, нужно всего лишь сначала получить одну половинку из одной инструкции, а затем дизассемблировать следующую — и получить вторую половинку. Но не так всё просто. Первая половинка может быть загружена инструкций 5 назад, а ссылка в модуле будет дана только после загрузки её второй половины. Пришлось писать изощрённый парсер (наверное его можно доработать).


В итоге, создаём enum для трёх типов имён:


ModuleType.java
package pat;

public enum ModuleType {
    GLOBAL_NAME, LOCAL_NAME, REF_NAME;

    public boolean isGlobal() {
        return this == GLOBAL_NAME;
    }

    public boolean isLocal() {
        return this == LOCAL_NAME;
    }

    public boolean isReference() {
        return this == REF_NAME;
    }

    @Override
    public String toString() {
        if (isGlobal()) {
            return "Global";
        } else if (isLocal()) {
            return "Local";
        } else {
            return "Reference";
        }
    }
}

Давайте напишем код, который преобразовывает текстовые шестнадцатеричные последовательности и точки в тип MaskedBytes:


hexStringToMaskedBytesArray()
private MaskedBytes hexStringToMaskedBytesArray(String s) {
    MaskedBytes res = null;

    if (s != null) {
        int len = s.length();
        byte[] bytes = new byte[len / 2];
        byte[] masks = new byte[len / 2];
        for (int i = 0; i < len; i += 2) {
            char c1 = s.charAt(i);
            char c2 = s.charAt(i + 1);

            masks[i / 2] = (byte) (
                    (((c1 == '.') ? 0x0 : 0xF) << 4) |
                    (((c2 == '.') ? 0x0 : 0xF) << 0)
                    );
            bytes[i / 2] = (byte) (
                    (((c1 == '.') ? 0x0 : Character.digit(c1, 16)) << 4) |
                    (((c2 == '.') ? 0x0 : Character.digit(c2, 16)) << 0)
                    );

        }

        res = new MaskedBytes(bytes, masks);
    }

    return res;
}

Уже можно подумать и о классе, который будет хранить информацию о каждой отдельной функции: имя функции, смещение в модуле, и тип:


ModuleData.java
package pat;

public class ModuleData {

    private final long offset;
    private final String name;
    private final ModuleType type;

    public ModuleData(long offset, String name, ModuleType type) {
        this.offset = offset;
        this.name = name;
        this.type = type;
    }

    public final long getOffset() {
        return offset;
    }

    public final String getName() {
        return name;
    }

    public final ModuleType getType() {
        return type;
    }
}

И, последнее: класс, который будет хранить всё, что указано в каждой строке pat-файла, то бишь: байты, crc, список имён со смещениями:


SignatureData.java
package pat;

import java.util.Arrays;
import java.util.List;

public class SignatureData {
    private final MaskedBytes templateBytes, tailBytes;
    private MaskedBytes fullBytes;
    private final int crc16Length;
    private final short crc16;
    private final int moduleLength;
    private final List<ModuleData> modules;

    public SignatureData(MaskedBytes templateBytes, int crc16Length,
            short crc16, int moduleLength, List<ModuleData> modules, MaskedBytes tailBytes) {
        this.templateBytes = this.fullBytes = templateBytes;
        this.crc16Length = crc16Length;
        this.crc16 = crc16;
        this.moduleLength = moduleLength;
        this.modules = modules;
        this.tailBytes = tailBytes;

        if (this.tailBytes != null) {
            int addLength = moduleLength - templateBytes.getLength() - tailBytes.getLength();

            byte[] addBytes = new byte[addLength];
            byte[] addMasks = new byte[addLength];
            Arrays.fill(addBytes, (byte)0x00);
            Arrays.fill(addMasks, (byte)0x00);

            this.fullBytes = MaskedBytes.extend(this.templateBytes, addBytes, addMasks);
            this.fullBytes = MaskedBytes.extend(this.fullBytes, tailBytes);
        }
    }

    public MaskedBytes getTemplateBytes() {
        return templateBytes;
    }

    public MaskedBytes getTailBytes() {
        return tailBytes;
    }

    public MaskedBytes getFullBytes() {
        return fullBytes;
    }

    public int getCrc16Length() {
        return crc16Length;
    }

    public short getCrc16() {
        return crc16;
    }

    public int getModuleLength() {
        return moduleLength;
    }

    public List<ModuleData> getModules() {
        return modules;
    }
}

Теперь основное: пишем код для создания всех эти классов:


Парсинг одной взятой строки pat-файла
private List<ModuleData> parseModuleData(String s) {
    List<ModuleData> res = new ArrayList<ModuleData>();

    if (s != null) {
        Matcher m = modulePat.matcher(s);

        while (m.find()) {
            String __offset = m.group(1);

            ModuleType type = __offset.startsWith(":") ? ModuleType.GLOBAL_NAME : ModuleType.REF_NAME;
            type = (type == ModuleType.GLOBAL_NAME && __offset.endsWith("@")) ? ModuleType.LOCAL_NAME : type;

            String _offset = __offset.replaceAll("[:^@]", "");

            long offset = Integer.parseInt(_offset, 16);
            String name = m.group(2);

            res.add(new ModuleData(offset, name, type));
        }
    }

    return res;
}

Парсинг всех строк pat-файла
private void parse(List<String> lines) {
    modulesCount = 0L;

    signatures = new ArrayList<SignatureData>();

    int linesCount = lines.size();

    monitor.initialize(linesCount);
    monitor.setMessage("Reading signatures...");

    for (int i = 0; i < linesCount; ++i) {
        String line = lines.get(i);
        Matcher m = linePat.matcher(line);

        if (m.matches()) {
            MaskedBytes pp = hexStringToMaskedBytesArray(m.group(1));
            int ll = Integer.parseInt(m.group(2), 16);
            short ssss = (short)Integer.parseInt(m.group(3), 16);
            int llll = Integer.parseInt(m.group(4), 16);

            List<ModuleData> modules = parseModuleData(m.group(5));

            MaskedBytes tail = null;
            if (m.group(6) != null) {
                tail = hexStringToMaskedBytesArray(m.group(6));
            }

            signatures.add(new SignatureData(pp, ll, ssss, llll, modules, tail));
            modulesCount += modules.size();
        }
        monitor.incrementProgress(1);
    }
}

Код создания функции там, где распозналась одна из сигнатур:


Создание функции
private static void disasmInstruction(Program program, Address address) {
    DisassembleCommand cmd = new DisassembleCommand(address, null, true);
    cmd.applyTo(program, TaskMonitor.DUMMY);
}

public static void setFunction(Program program, FlatProgramAPI fpa, Address address, String name, boolean isFunction, boolean isEntryPoint, MessageLog log) {
    try {
        if (fpa.getInstructionAt(address) == null)
            disasmInstruction(program, address);

        if (isFunction) {
            fpa.createFunction(address, name);
        }
        if (isEntryPoint) {
            fpa.addEntryPoint(address);
        }

        if (isFunction && program.getSymbolTable().hasSymbol(address)) {
            return;
        }

        program.getSymbolTable().createLabel(address, name, SourceType.IMPORTED);
    } catch (InvalidInputException e) {
        log.appendException(e);
    }
}

Самое сложное место, как говорилось ранее — подсчёт ссылки на другое имя/переменную (возможно, код нуждается в доработке):


Подсчёт ссылки
public static void setInstrRefName(Program program, FlatProgramAPI fpa, PseudoDisassembler ps, Address address, String name, MessageLog log) {
    ReferenceManager refsMgr = program.getReferenceManager();

    Reference[] refs = refsMgr.getReferencesFrom(address);

    if (refs.length == 0) {
        disasmInstruction(program, address);
        refs = refsMgr.getReferencesFrom(address);

        if (refs.length == 0) {
            refs = refsMgr.getReferencesFrom(address.add(4));

            if (refs.length == 0) {
                refs = refsMgr.getFlowReferencesFrom(address.add(4));

                Instruction instr = program.getListing().getInstructionAt(address.add(4));

                if (instr == null) {
                    disasmInstruction(program, address.add(4));
                    instr = program.getListing().getInstructionAt(address.add(4));

                    if (instr == null) {
                        return;
                    }
                }

                FlowType flowType = instr.getFlowType();

                if (refs.length == 0 && !(flowType.isJump() || flowType.isCall() || flowType.isTerminal())) {
                    return;
                }

                refs = refsMgr.getReferencesFrom(address.add(8));

                if (refs.length == 0) {
                    return;
                }
            }
        }
    }

    try {
        program.getSymbolTable().createLabel(refs[0].getToAddress(), name, SourceType.IMPORTED);
    } catch (InvalidInputException e) {
        log.appendException(e);
    }
}

И, финальный штрих — применяем сигнатуры:


applySignatures()
public void applySignatures(ByteProvider provider, Program program, Address imageBase, Address startAddr, Address endAddr, MessageLog log) throws IOException {
    BinaryReader reader = new BinaryReader(provider, false);
    PseudoDisassembler ps = new PseudoDisassembler(program);
    FlatProgramAPI fpa = new FlatProgramAPI(program);

    monitor.initialize(getAllModulesCount());
    monitor.setMessage("Applying signatures...");

    for (SignatureData sig : signatures) {
        MaskedBytes fullBytes = sig.getFullBytes();
        MaskedBytes tmpl = sig.getTemplateBytes();

        Address addr = program.getMemory().findBytes(startAddr, endAddr, fullBytes.getBytes(), fullBytes.getMasks(), true, TaskMonitor.DUMMY);

        if (addr == null) {
            monitor.incrementProgress(sig.getModules().size());
            continue;
        }

        addr = addr.subtract(imageBase.getOffset());

        byte[] nextBytes = reader.readByteArray(addr.getOffset() + tmpl.getLength(), sig.getCrc16Length());

        if (!PatParser.checkCrc16(nextBytes, sig.getCrc16())) {
            monitor.incrementProgress(sig.getModules().size());
            continue;
        }

        addr = addr.add(imageBase.getOffset());

        List<ModuleData> modules = sig.getModules();
        for (ModuleData data : modules) {
            Address _addr = addr.add(data.getOffset());

            if (data.getType().isGlobal()) {
                setFunction(program, fpa, _addr, data.getName(), data.getType().isGlobal(), false, log);
            }

            monitor.setMessage(String.format("%s function %s at 0x%08X", data.getType(), data.getName(), _addr.getOffset()));

            monitor.incrementProgress(1);
        }

        for (ModuleData data : modules) {
            Address _addr = addr.add(data.getOffset());

            if (data.getType().isReference()) {
                setInstrRefName(program, fpa, ps, _addr, data.getName(), log);
            }

            monitor.setMessage(String.format("%s function %s at 0x%08X", data.getType(), data.getName(), _addr.getOffset()));

            monitor.incrementProgress(1);
        }
    }
}

Тут можно рассказать об одной интересной функции: findBytes(). С её помощью можно искать определённые последовательности байт, с указанными битовыми масками для каждого байта. Метод вызывается так:


Address addr = program.getMemory().findBytes(startAddr, endAddr, bytes, masks, forward, TaskMonitor.DUMMY);

В итоге возвращается адрес, с которого начинаются байты, либо null.


Пишем анализатор


Давайте сделаем красиво, и не будем применять сигнатуры, если мы не хотим, а позволим выбирать этот шаг пользователю. Для этого необходимо будет написать свой собственный анализатор кода (вы могли видеть подобные в этом списке — это всё они, да):



Итак, чтобы вклиниться в этот список, нужно будет унаследоваться от класса AbstractAnalyzer и переопределить некоторые методы:


  1. Конструктор. Должен будет вызвать конструктор базового класса с указанием имени, описания анализатора, и его типа (об этом позже). У меня выглядит как-то так:

public PsxAnalyzer() {
    super("PSYQ Signatures", "PSX signatures applier", AnalyzerType.INSTRUCTION_ANALYZER);
}

  1. getDefaultEnablement(). Определяет, доступен ли наш анализатор всегда, или же только при выполнении каких-то условий (например, если используется наш же загрузчик).
  2. canAnalyze(). Можно ли вообще использовать данный анализатор на загружаемом бинарном файле.
    Пункты 2 и 3 можно в принципе проверять одной единственной функцией:

public static boolean isPsxLoader(Program program) {
    return program.getExecutableFormat().equalsIgnoreCase(PsxLoader.PSX_LOADER);
}

Где PsxLoader.PSX_LOADER хранит имя загрузчика, и определено ранее в нём же.


Итого, имеем:


@Override
public boolean getDefaultEnablement(Program program) {
    return isPsxLoader(program);
}

@Override
public boolean canAnalyze(Program program) {
    return isPsxLoader(program);
}

  1. registerOptions(). Вовсе не обязательно переопределять этот метод, но, если нам нужно что-то до анализа спросить у пользователя, например, путь к pat-файлу, то лучше всего это делать в данном методе. Получаем:

private static final String OPTION_NAME = "PSYQ PAT-File Path";
private File file = null;

@Override
public void registerOptions(Options options, Program program) {
    try {
        file = Application.getModuleDataFile("psyq4_7.pat").getFile(false);
    } catch (FileNotFoundException e) {

    }
    options.registerOption(OPTION_NAME, OptionType.FILE_TYPE, file, null,
            "PAT-File (FLAIR) created from PSYQ library files");
}

Тут необходимо пояснить. Статический метод getModuleDataFile() класса Application возвращает полный путь к файлу в каталоге data, который имеется в дереве нашего модуля, и может хранить любые необходимые файлы, на которые мы захотим позже сослаться.


Ну а метод registerOption() регистрирует опцию с именем указанным в OPTION_NAME, типом File (т.е. у пользователя будет возможность выбирать файл через обычное диалоговое окно), дефолтным значением и описанием.


Далее. Т.к. нормальной возможности потом сослаться на зарегистрированную опцию у нас не будет, потребуется переопределить метод optionsChanged():


@Override
public void optionsChanged(Options options, Program program) {
    super.optionsChanged(options, program);

    file = options.getFile(OPTION_NAME, file);
}

Здесь мы просто обновляем глобальную переменную согласно новому значению.


Метод added(). Теперь основное: метод, который будет вызываться при запуске анализатора. В него нам будет приходить список адресов доступных для анализа, но, нам нужны только те, что содержат код. Поэтому нужно отфильтровать. Итоговый код:


Метод added()
@Override
public boolean added(Program program, AddressSetView set, TaskMonitor monitor, MessageLog log)
        throws CancelledException {

    if (file == null) {
        return true;
    }

    Memory memory = program.getMemory();
    AddressRangeIterator it = memory.getLoadedAndInitializedAddressSet().getAddressRanges();
    while (!monitor.isCancelled() && it.hasNext()) {
        AddressRange range = it.next();

        try {
            MemoryBlock block = program.getMemory().getBlock(range.getMinAddress());
            if (block.isInitialized() && block.isExecute() && block.isLoaded()) {
                PatParser pat = new PatParser(file, monitor);
                RandomAccessByteProvider provider = new RandomAccessByteProvider(new File(program.getExecutablePath()));

                pat.applySignatures(provider, program, block.getStart(), block.getStart(), block.getEnd(), log);
            }
        } catch (IOException e) {
            log.appendException(e);
            return false;
        }
    }
    return true;
}

Тут мы проходимся по списку адресов, которые являются исполняемыми, и пытаемся применить там сигнатуры.



Выводы и финалочка


Вроде всё. На самом деле, супер сложного здесь ничего нет. Примеры есть, сообщество живое, можно спокойно спрашивать о том, что не понятно, пока пишешь код. Итог: рабочий загрузчик и анализатор исполняемых файлов Playstation 1.



Все исходные коды доступны здесь: ghidra_psx_ldr
Релизы тут: Releases

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (13):

  1. VBKesha
    /#20027252 / +1

    Ооо супер, давно это ждал.

    • DrMefistO
      /#20027360

      Далее будет про Amiga Hunk — исполняемые файлы для AmigaOS.

      • VBKesha
        /#20027378

        С Амигой не знаком, не довелось, однако думаю будет интересно.
        PS. Мучительный вопрос, для NES будет? Хотя количество проблем там мне кажется выше крыши.

        • DrMefistO
          /#20027382 / +1

          Портировать NES-загрузчик с Иды проблем вообще нет. Самое сложное, это адресация. В теории, возможно.

  2. Ununtrium
    /#20028256

    Если в двух словах — запускать Гидру не страшно.


    Из чего конкретно сделан этот вывод? Словить XXE в гидре — как нефиг делать.

    • DrMefistO
      /#20028326 / -1

      Но тебя не поимеют извне. А запускать чужие проекты конечно не стоит. А ещё: Гидру можно пересобрать самому.

  3. Romhack
    /#20028328

    Каждый новый выпуск IDA с момента выхода декомпилятора внимательно просматривал changelog декомпилятора в надежде найти поддержку MIPS и каждый раз обламывался. Перепробовал кучу сторонних решений, но ни один не заработал.
    Спасибо АНБ, хотя загрузчик для PSX могли бы и сами написать, куда только идут деньги налогоплательщиков?!

  4. TTEMMA
    /#20032774

    Спасибо за плагин
    А то эта загрузка DWORD в память реально выбешивает, долго выискиваешь, где загрузилась то одна часть, то другая