首页
社区
课程
招聘
[原创]打造柚子(yuzu)模拟器的金手指工具
2024-3-14 13:13 6918

[原创]打造柚子(yuzu)模拟器的金手指工具

2024-3-14 13:13
6918

本文从我的知乎文章贴过来哈,感觉内容与看雪论坛还有点关系。

最近比较令人意外的事情是柚子模拟器因为被任天堂起诉而关闭,所以后续switch模拟器如何发展还很不好说。不过就如世界每天都有各种灾难,而我们的生活依然要继续一样,柚子模拟器关闭了,但我们的“学习”还得继续。(目前最新的柚子模拟器代码我恰好在关闭前同步更新了,master主干是柚子官方关闭前的代码,AddMemorySniffer分支则是我进行的修改,只是之前还想着定期要同步一下,以后就只能看yuzu-mirror或者suyu-emu这个能不能起来了)

这次我要说的是与金手指相关的事,也就是关于游戏修改的事情。

一、柚子模拟器的部分机制
柚子模拟器用来模拟cpu的模块叫dynarmic,我本来以为它是一个第三方组件,结果柚子关闭后,这个工程的github也关闭了,才发现这部分就是柚子团队开发的。。

基于冯.诺依曼架构的计算机,核心思想就是存储程序的思想,所以存储是核心,代码与数据都保存在存储器中,而金手指对游戏的修改,表面上看都是对内存的修改,实际上则是有2类,一类是数据,一类则是代码(当然从修改的角度看,都是改的数据,因为程序也是存储起来的嘛)

1、内存

柚子模拟器的core模块里有一个类叫Memory,看起来实现了内存的读写,然后dynarmic组件需要外部提供一个回调类,主要方法就是内存读写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
struct UserCallbacks {
    virtual ~UserCallbacks() = default;
 
    // All reads through this callback are 4-byte aligned.
    // Memory must be interpreted as little endian.
    virtual std::optional<std::uint32_t> MemoryReadCode(VAddr vaddr) { return MemoryRead32(vaddr); }
 
    // Reads through these callbacks may not be aligned.
    virtual std::uint8_t MemoryRead8(VAddr vaddr) = 0;
    virtual std::uint16_t MemoryRead16(VAddr vaddr) = 0;
    virtual std::uint32_t MemoryRead32(VAddr vaddr) = 0;
    virtual std::uint64_t MemoryRead64(VAddr vaddr) = 0;
    virtual Vector MemoryRead128(VAddr vaddr) = 0;
 
    // Writes through these callbacks may not be aligned.
    virtual void MemoryWrite8(VAddr vaddr, std::uint8_t value) = 0;
    virtual void MemoryWrite16(VAddr vaddr, std::uint16_t value) = 0;
    virtual void MemoryWrite32(VAddr vaddr, std::uint32_t value) = 0;
    virtual void MemoryWrite64(VAddr vaddr, std::uint64_t value) = 0;
    virtual void MemoryWrite128(VAddr vaddr, Vector value) = 0;
 
    // Writes through these callbacks may not be aligned.
    virtual bool MemoryWriteExclusive8(VAddr /*vaddr*/, std::uint8_t /*value*/, std::uint8_t /*expected*/) { return false; }
    virtual bool MemoryWriteExclusive16(VAddr /*vaddr*/, std::uint16_t /*value*/, std::uint16_t /*expected*/) { return false; }
    virtual bool MemoryWriteExclusive32(VAddr /*vaddr*/, std::uint32_t /*value*/, std::uint32_t /*expected*/) { return false; }
    virtual bool MemoryWriteExclusive64(VAddr /*vaddr*/, std::uint64_t /*value*/, std::uint64_t /*expected*/) { return false; }
    virtual bool MemoryWriteExclusive128(VAddr /*vaddr*/, Vector /*value*/, Vector /*expected*/) { return false; }
 
    // If this callback returns true, the JIT will assume MemoryRead* callbacks will always
    // return the same value at any point in time for this vaddr. The JIT may use this information
    // in optimizations.
    // A conservative implementation that always returns false is safe.
    virtual bool IsReadOnlyMemory(VAddr /*vaddr*/) { return false; }
 
    /// The interpreter must execute exactly num_instructions starting from PC.
    virtual void InterpreterFallback(VAddr pc, size_t num_instructions) = 0;
 
    // This callback is called whenever a SVC instruction is executed.
    virtual void CallSVC(std::uint32_t swi) = 0;
 
    virtual void ExceptionRaised(VAddr pc, Exception exception) = 0;
    virtual void DataCacheOperationRaised(DataCacheOperation /*op*/, VAddr /*value*/) {}
    virtual void InstructionCacheOperationRaised(InstructionCacheOperation /*op*/, VAddr /*value*/) {}
    virtual void InstructionSynchronizationBarrierRaised() {}
 
    // Timing-related callbacks
    // ticks ticks have passed
    virtual void AddTicks(std::uint64_t ticks) = 0;
    // How many more ticks am I allowed to execute?
    virtual std::uint64_t GetTicksRemaining() = 0;
    // Get value in the emulated counter-timer physical count register.
    virtual std::uint64_t GetCNTPCT() = 0;
};

那么模拟器的内存读写都是通过Memory类来实现的吗?想像一下arm指令集,大部分指令都涉及对内存的读或写,如果都要通过回调来实现,是不是感觉特别慢呢(其实我一开始也被这个回调误导了很久)。

dynarmic里的vm在执行arm指令时,并不是通过Memory类来实现的,它其实是模拟了cpu的分页式存储机制,有个页表来直接进行地址的转换,然后vm就直接操作内存了(这在dynarmic里被称为inline page tables优化,意思可能是直接内联了页表里的内存指针)。这样效率就比较高,不过为了支持调试,比如内存断点,柚子也模拟了硬件内存断点的基础机制,这个机制是Memory实现的

1
void MarkRegionDebug(u64 vaddr, u64 size, bool debug)

如果一块内存被标记为debug了,那么vm对该内存的操作就会改为调用前面的回调接口的内存读写方法。然后我们在Memory的读写方法里hook进一些代码就可以实现类似内存断点的调试了。

2、jit

dynarmic的主要功能是一个jit虚拟机,这个模块主要分为前端与后端2大块,前端主要是arm指令的解码与IR基本块的构建,arm指令有标准规范,IR则是dynarmic自己设计的,所以其实还有一块是IR。前端其实有点像是逆向的流程,从arm二进制代码逆向出基于IR的程序结构(可以认为是由基本块构成的程序流图)。后端是将前端的程序结构翻译到host的指令代码(当然,其实就是一块内存数据)。在dynarmic里,这几部分的名称是decoder、translate、emit与jit(dynarmic里面jit所在文件名叫interface,可能是因为jit vm也是这个模块的对外接口的原因)。

前面说的其实是一个jit虚拟机的高层结构,可能不同的虚拟机都类似,所以这些其实不是重点。虚拟机的大致执行流程如下图上半部分所示。
dynarmic jit虚拟机

上面的图的下半部分是后端与vm执行的关键点。

vm是按pc来执行代码的,对于arm64指令,每条指令都是4字节长度。形象的看vm就是一条指令接一条指令的解释执行,但很显然,这样效率太低了。

实际上dynarmic虚拟机在程序入口时发起执行调用,然后jit执行相关代码,并不会每条指令执行后都返回到调用方,也就是vm其实并不是一条指令一条指令的解释执行。最起码,对于一个基本块,这些指令完全可以一次由cpu执行完;当然这还远远不够,jit会分析基本块之间的链接关系,如果下一个基本块的地址在jit时是确定的,那么也不需要返回到调用方再重新发起jit执行调用,对于返回指令也有类似优化,这在dynarmic里分别叫block linking优化与return stack buffer(RSB)优化(RSB是intel cpu的特性,AMD cpu的叫RAS--return address stack)。与直接跳转到目的地址执行有关的优化还有一个是fast dispather,这个采取2层dispatch,使用一个MRU cache来查找跳转目标。

jit总有返回到调用方的时候,这些情形是jit无法继续处理的时候,这与cpu很类似,包括系统调用、中断与异常、内存地址无效(虚拟内存映射到物理内存)、以及与调试功能相关的断点、单步执行等。

这种运行方式主要就是为了执行效率,要不然游戏就该跑不动了。但同时这种方式也让调试分析变得比较麻烦,比如我们想hook每次过程调用,就没有合适的hook点。

所以我们需要对dynarmic的机制做些改造。

3、调试支持

柚子模拟器实现了gdb server的协议(这部分代码在core/debugger目录下,gdbstub.h/cpp实现了协议),所以是支持gdb远程调试的,然后idapro可以使用gdb server的协议远程调试,这就实现了调试的闭环。

调试除了查看线程状态如寄存器、堆栈外,最主要的就是中断执行过程,而模拟器作为解释器,是比较容易实现中断执行的。对jit要复杂一些,其实就是前面提到的jit优化正好是不利于调试的,所以调试时需要关掉一些jit优化,柚子的单步执行就是这样的。

所以对模拟器来说,支持调试可能没那么复杂,柚子在jit vm上实现了单步执行,然后支持了断点指令brk,再加上内存的调试属性与读写回调,基本就实现了gdb能支持的所有调试功能。(我很惊讶ryujinx好像是不支持调试的,不知道作者出于什么原因放弃了这个功能)

二、对jit的改造
1、需求

我们其实需要一些与调试类似的功能,比如我希望能记录某个时刻调用过哪些函数。暂停然后单步运行是可以的,但是效率可能太低了,短时间可能会执行了几百个上千个的函数,而指令可能是几万条,一个一个的看对眼睛与大脑都是很大的考验。而且单步执行就会打断游戏的UI,这样就很难跟踪一个具体操作对应的代码了。

2、关闭一些优化

我们前面说过有三个优化是不利于调试的,所以首先需要关掉它们:block linking、return stack buffer(RSB)、fast dispatcher。这三个优化都在设置的debug下的cpu页签里。首先还需要打开一个总的开关才能修改,这个开关也在debug设置里。

3、修改jit的框架指令

框架指令共有6种:

(RunCode与StepCode的函数原型:第一个参数实际是A64JitState的指针

1
2
using RunCodeFuncType = HaltReason (*)(void*, CodePtr);

a、runcode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
align();
run_code = getCurr<RunCodeFuncType>();
 
// This serves two purposes:
// 1. It saves all the registers we as a callee need to save.
// 2. It aligns the stack so that the code the JIT emits can assume
//    that the stack is appropriately aligned for CALLs.
ABI_PushCalleeSaveRegistersAndAdjustStack(*this, sizeof(StackLayout));
 
mov(r15, ABI_PARAM1);
mov(rbx, ABI_PARAM2);  // save temporarily in non-volatile register
 
if (cb.enable_cycle_counting) {
    cb.GetTicksRemaining->EmitCall(*this);
    mov(qword[rsp + ABI_SHADOW_SPACE + offsetof(StackLayout, cycles_to_run)], ABI_RETURN);
    mov(qword[rsp + ABI_SHADOW_SPACE + offsetof(StackLayout, cycles_remaining)], ABI_RETURN);
}
 
rcp(*this);
 
cmp(dword[r15 + jsi.offsetof_halt_reason], 0);
jne(return_to_caller_mxcsr_already_exited, T_NEAR);
 
SwitchMxcsrOnEntry();
jmp(rbx);

这是一个基本块的jit代码的开始框架,我们只看简化配置下的流程,首先使用r15记录了JitState,然后将要执行的代码赋给rbx,然后判断halt_reason是否不为0(就是单步、中断、异常等状态),如果不为0则直接返回,否则跳转到要执行的代码。看起来似乎没什么奇怪的地方。

runcode代码在调用VM的Run方法时使用(我们说的返回到VM调用方,就是这个Run函数正常返回):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
HaltReason Run() {
    ASSERT(!is_executing);
    PerformRequestedCacheInvalidation(static_cast<HaltReason>(Atomic::Load(&jit_state.halt_reason)));
 
    is_executing = true;
    SCOPE_EXIT {
        this->is_executing = false;
    };
 
    // TODO: Check code alignment
 
    const CodePtr current_code_ptr = [this] {
        // RSB optimization
        const u32 new_rsb_ptr = (jit_state.rsb_ptr - 1) & A64JitState::RSBPtrMask;
        if (jit_state.GetUniqueHash() == jit_state.rsb_location_descriptors[new_rsb_ptr]) {
            jit_state.rsb_ptr = new_rsb_ptr;
            return reinterpret_cast<CodePtr>(jit_state.rsb_codeptrs[new_rsb_ptr]);
        }
 
        return GetCurrentBlock();
    }();
 
    const HaltReason hr = block_of_code.RunCode(&jit_state, current_code_ptr);
 
    PerformRequestedCacheInvalidation(hr);
 
    return hr;
}

b、stepcode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
align();
step_code = getCurr<RunCodeFuncType>();
 
ABI_PushCalleeSaveRegistersAndAdjustStack(*this, sizeof(StackLayout));
 
mov(r15, ABI_PARAM1);
 
if (cb.enable_cycle_counting) {
    mov(qword[rsp + ABI_SHADOW_SPACE + offsetof(StackLayout, cycles_to_run)], 1);
    mov(qword[rsp + ABI_SHADOW_SPACE + offsetof(StackLayout, cycles_remaining)], 1);
}
 
rcp(*this);
 
cmp(dword[r15 + jsi.offsetof_halt_reason], 0);
jne(return_to_caller_mxcsr_already_exited, T_NEAR);
lock();
or_(dword[r15 + jsi.offsetof_halt_reason], static_cast<u32>(HaltReason::Step));
 
SwitchMxcsrOnEntry();
jmp(ABI_PARAM2);

这是一个基本块的单步执行jit代码的开始框架,这个与非单步的代码的差别主要是给halt_reason加上了Step状态。似乎也没啥特别的地方,不过它启发我们加上halt_reason似乎就有机会在指令执行后返回到VM调用方,但是并没有这么简单,我们看到Arm指令反编译到IR时,对单步还有一些处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
IR::Block Translate(LocationDescriptor descriptor, MemoryReadCodeFuncType memory_read_code, TranslationOptions options) {
    const bool single_step = descriptor.SingleStepping();
 
    IR::Block block{descriptor};
    TranslatorVisitor visitor{block, descriptor, std::move(options)};
 
    bool should_continue = true;
    do {
        const u64 pc = visitor.ir.current_location->PC();
 
        if (const auto instruction = memory_read_code(pc)) {
            if (auto decoder = Decode<TranslatorVisitor>(*instruction)) {
                should_continue = decoder->get().call(visitor, *instruction);
            } else {
                should_continue = visitor.InterpretThisInstruction();
            }
        } else {
            should_continue = visitor.RaiseException(Exception::NoExecuteFault);
        }
 
        visitor.ir.current_location = visitor.ir.current_location->AdvancePC(4);
        block.CycleCount()++;
    } while (should_continue && !single_step);
 
    if (single_step && should_continue) {
        visitor.ir.SetTerm(IR::Term::LinkBlock{*visitor.ir.current_location});
    }
 
    ASSERT_MSG(block.HasTerminal(), "Terminal has not been set");
 
    block.SetEndLocation(*visitor.ir.current_location);
 
    return block;
}

可以看到对于单步执行,执行完一条指令后,会再执行一条IR::Term::LinkBlock指令。

stepcode在调用VM的Step时使用(我们说的返回到VM调用方,在单步时就是这个Step函数正常返回):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
HaltReason Step() {
    ASSERT(!is_executing);
    PerformRequestedCacheInvalidation(static_cast<HaltReason>(Atomic::Load(&jit_state.halt_reason)));
 
    is_executing = true;
    SCOPE_EXIT {
        this->is_executing = false;
    };
 
    const HaltReason hr = block_of_code.StepCode(&jit_state, GetCurrentSingleStep());
 
    PerformRequestedCacheInvalidation(hr);
 
    return hr;
}

c、returnfromruncode

1
2
3
4
5
6
7
8
9
10
11
align();
return_from_run_code[0] = getCurr<const void*>();
 
cmp(dword[r15 + jsi.offsetof_halt_reason], 0);
jne(return_to_caller);
if (cb.enable_cycle_counting) {
    cmp(qword[rsp + ABI_SHADOW_SPACE + offsetof(StackLayout, cycles_remaining)], 0);
    jng(return_to_caller);
}
cb.LookupBlock->EmitCall(*this);
jmp(ABI_RETURN);

这段代码用于从jit代码跳转到dispatch的代码,也就是根据PC跳到PC对应的jit代码的地方。我们比较关心的是与单步执行有关的一些IR的翻译,有4个指令会判断单步标志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
void A64EmitX64::EmitTerminalImpl(IR::Term::LinkBlock terminal, IR::LocationDescriptor, bool is_single_step) {
    if (!conf.HasOptimization(OptimizationFlag::BlockLinking) || is_single_step) {
        code.mov(rax, A64::LocationDescriptor{terminal.next}.PC());
        code.mov(qword[r15 + offsetof(A64JitState, pc)], rax);
        code.ReturnFromRunCode();
        return;
    }
 
    if (conf.enable_cycle_counting) {
        code.cmp(qword[rsp + ABI_SHADOW_SPACE + offsetof(StackLayout, cycles_remaining)], 0);
 
        patch_information[terminal.next].jg.push_back(code.getCurr());
        if (const auto next_bb = GetBasicBlock(terminal.next)) {
            EmitPatchJg(terminal.next, next_bb->entrypoint);
        } else {
            EmitPatchJg(terminal.next);
        }
    } else {
        code.cmp(dword[r15 + offsetof(A64JitState, halt_reason)], 0);
 
        patch_information[terminal.next].jz.push_back(code.getCurr());
        if (const auto next_bb = GetBasicBlock(terminal.next)) {
            EmitPatchJz(terminal.next, next_bb->entrypoint);
        } else {
            EmitPatchJz(terminal.next);
        }
    }
 
    code.mov(rax, A64::LocationDescriptor{terminal.next}.PC());
    code.mov(qword[r15 + offsetof(A64JitState, pc)], rax);
    code.ForceReturnFromRunCode();
}
 
void A64EmitX64::EmitTerminalImpl(IR::Term::LinkBlockFast terminal, IR::LocationDescriptor, bool is_single_step) {
    if (!conf.HasOptimization(OptimizationFlag::BlockLinking) || is_single_step) {
        code.mov(rax, A64::LocationDescriptor{terminal.next}.PC());
        code.mov(qword[r15 + offsetof(A64JitState, pc)], rax);
        code.ReturnFromRunCode();
        return;
    }
 
    patch_information[terminal.next].jmp.push_back(code.getCurr());
    if (auto next_bb = GetBasicBlock(terminal.next)) {
        EmitPatchJmp(terminal.next, next_bb->entrypoint);
    } else {
        EmitPatchJmp(terminal.next);
    }
}
 
void A64EmitX64::EmitTerminalImpl(IR::Term::PopRSBHint, IR::LocationDescriptor, bool is_single_step) {
    if (!conf.HasOptimization(OptimizationFlag::ReturnStackBuffer) || is_single_step) {
        code.ReturnFromRunCode();
        return;
    }
 
    code.jmp(terminal_handler_pop_rsb_hint);
}
 
void A64EmitX64::EmitTerminalImpl(IR::Term::FastDispatchHint, IR::LocationDescriptor, bool is_single_step) {
    if (!conf.HasOptimization(OptimizationFlag::FastDispatch) || is_single_step) {
        code.ReturnFromRunCode();
        return;
    }
 
    code.jmp(terminal_handler_fast_dispatch_hint);
}

我们可以看到在单步执行时,除RSB与FastDispatch外,都是修改JitState的PC值为指令对应的PC值,然后执行returnfromruncode,而returnfromruncode首先判断halt_reason是否为0,不为0则跳到返回到VM调用方的代码,为0则调用Dynarmic::A64::Jit::Impl::GetCurrentBlockThunk(由cb.LookupBlock->EmitCall(*this);生成)来获取下一条指令的jit代码入口,然后跳到jit代码入口执行(这里不是使用runcode框架代码来运行的jit代码,这就是我们调用Run的时候并不能一条指令一条指令执行的最主要的原因,优化也是在此基础上的优化)

d、returnfromruncode+MXCSR_ALREADY_EXITED

1
2
3
4
5
6
7
8
9
10
11
12
align();
return_from_run_code[MXCSR_ALREADY_EXITED] = getCurr<const void*>();
 
cmp(dword[r15 + jsi.offsetof_halt_reason], 0);
jne(return_to_caller_mxcsr_already_exited);
if (cb.enable_cycle_counting) {
    cmp(qword[rsp + ABI_SHADOW_SPACE + offsetof(StackLayout, cycles_remaining)], 0);
    jng(return_to_caller_mxcsr_already_exited);
}
SwitchMxcsrOnEntry();
cb.LookupBlock->EmitCall(*this);
jmp(ABI_RETURN);

如后面所说,这个只在指令解码失败需要解释执行该指令时出现,我们略过就好。

e、return_to_caller

f、return_to_caller_mxcsr_already_exited

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
align();
return_from_run_code[FORCE_RETURN] = getCurr<const void*>();
L(return_to_caller);
 
SwitchMxcsrOnExit();
// fallthrough
 
return_from_run_code[MXCSR_ALREADY_EXITED | FORCE_RETURN] = getCurr<const void*>();
L(return_to_caller_mxcsr_already_exited);
 
if (cb.enable_cycle_counting) {
    cb.AddTicks->EmitCall(*this, [this](RegList param) {
        mov(param[0], qword[rsp + ABI_SHADOW_SPACE + offsetof(StackLayout, cycles_to_run)]);
        sub(param[0], qword[rsp + ABI_SHADOW_SPACE + offsetof(StackLayout, cycles_remaining)]);
    });
}
 
xor_(eax, eax);
lock();
xchg(dword[r15 + jsi.offsetof_halt_reason], eax);
 
ABI_PopCalleeSaveRegistersAndAdjustStack(*this, sizeof(StackLayout));
ret();

这是正常返回调用方的流程,jit指令如果能跳转到return_to_caller或return_to_caller_mxcsr_already_exited,就会返回到VM调用方,也就是前面提到的Run/Step的调用方。

==【一些补充注解】==

注1:

下面是与Mxcsr相关的2个函数,可以看到是保存与恢复MXCSR状态寄存器的。所以return_to_caller与return_to_caller_mxcsr_already_exited的差别是前者多了一步恢复MXCSR的操作。目前实际会出现MXCSR_ALREADY_EXITED的情形只有一种是指令Decode失败后委托给使用方来解释执行该指令的时候。所以我们分析时可以忽略这个情形。

1
2
3
4
5
6
7
8
9
void BlockOfCode::SwitchMxcsrOnEntry() {
    stmxcsr(dword[rsp + ABI_SHADOW_SPACE + offsetof(StackLayout, save_host_MXCSR)]);
    ldmxcsr(dword[r15 + jsi.offsetof_guest_MXCSR]);
}
 
void BlockOfCode::SwitchMxcsrOnExit() {
    stmxcsr(dword[r15 + jsi.offsetof_guest_MXCSR]);
    ldmxcsr(dword[rsp + ABI_SHADOW_SPACE + offsetof(StackLayout, save_host_MXCSR)]);
}

注2:

rcp生成的指令是使用r14与r13保存页表与fastmem_pointer,与我们要解决的问题关系不大。

1
2
3
4
5
6
7
8
9
10
static std::function<void(BlockOfCode&)> GenRCP(const A64::UserConfig& conf) {
    return [conf](BlockOfCode& code) {
        if (conf.page_table) {
            code.mov(code.r14, mcl::bit_cast<u64>(conf.page_table));
        }
        if (conf.fastmem_pointer) {
            code.mov(code.r13, *conf.fastmem_pointer);
        }
    };
}

注3:

ABI_PushCalleeSaveRegistersAndAdjustStack与ABI_PopCalleeSaveRegistersAndAdjustStack主要是对寄存器的保存与恢复,类似编译高级语言生成的prologue与epilogue代码。我们也不用太关注。

注4:

查找指令jit代码入口的指令生成,这句话生成对Dynarmic::A64::Jit::Impl::GetCurrentBlockThunk的调用,用以获取当前PC对应的jit指令入口(所以在执行到这里时当前PC需要调整为合适的值)

1
cb.LookupBlock->EmitCall(*this);

==【补充注解结束】==

从这些代码看,如果关掉了我们之前提到过的3个优化后,似乎只要在Run函数里也像Step函数一样把halt_reason设置上就可以让每次执行jit代码后返回到VM调用方了。对单步执行,的确是这样的,因为每条指令都是从Step调用来的,然后都标记了halt_reason,执行完一条指令后就会返回到VM调用方,然后继续执行下一条指令,如此循环。

但是,如果不是想单步(我们显然不是要重复实现一个已经有的功能,我们想要的是一个比较高效的能在游戏过程中抓数据的机制),这里就比较微妙了。假设我们只想在每次分支指令执行后返回到VM调用方,也就是每个基本块的入口执行后返回。那对于一个由多条指令组成的基本块,我们就不能在Run开始就标记上halt_reason(否则第一条指令就返回了),但一旦我们没有标记,按照前面看到的执行机制,除非遇到中断或异常,执行是不会返回到调用方的。

这里涉及两件事,一件事是我们看来需要根据指令是什么来决定要不要标记halt_reason(因为我们不能使用Step来标记halt_reason,我们还需要选择一个状态值,dynarmic提供了几个预留的值供外部使用,柚子模拟器已经用了几个,不过还有剩余,比如UserDefined1就没有被用)。我们考虑加上两类条件过滤:

a、首先我们可以限定一下指令范围,比如限定到main模块的范围。

b、然后我们通常关心的是流程发生跳转的时候,也就是过程调用、跳转(条件或无条件)、过程返回这类指令。

想限定这些我们就需要知道jit代码入口对应的Arm PC与指令码,这个在jit之后已经没有对应了,所以我们还需要修改一下emit部分,在发出每个基本块的指令时,在指令前空出16个字节来,8个用于保存PC,然后4个字节用于对齐或预留给将来,再4个字节保存arm指令码。

1
2
3
4
5
6
// Start emitting.
code.align();
code.dq(pc);
code.dd(0);
code.dd(firstArmInst);
const u8* const entrypoint = code.getCurr();

这样数据就准备好了,然后我们在jit框架代码里加上对PC与指令码的判断,选择性加上HaltReason即可。(jit框架代码本身也是jit出的host指令,所以这部分相当于写点汇编代码)

我们先写个通用的判断与跳转:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
void BlockOfCode::GenHaltReasonSet(Xbyak::Label& run_code_entry) {
    Xbyak::Label _dummy;
    GenHaltReasonSetImpl(false, run_code_entry, _dummy);
}
void BlockOfCode::GenHaltReasonSet(Xbyak::Label& run_code_entry, Xbyak::Label& ret_code_entry) {
    GenHaltReasonSetImpl(true, run_code_entry, ret_code_entry);
}
void BlockOfCode::GenHaltReasonSetImpl(bool isRet, Xbyak::Label& run_code_entry, Xbyak::Label& ret_code_entry) {
    Xbyak::Label normal_code, halt_reason_set;
    if (halt_reason_on_run) {
        if (isRet) {
            push(ABI_RETURN);
            push(rbx);
            mov(rbx, ABI_RETURN);
        }
        push(rsi);
        push(rdi);
        push(r14);
        push(r13);
 
        mov(esi, word[rbx - 4]);
        mov(r14, dword[rbx - 16]);
 
        mov(r13, trace_scope_begin);
        cmp(r14, r13);
        jl(normal_code, T_NEAR);
        mov(r13, trace_scope_end);
        cmp(r14, r13);
        jge(normal_code, T_NEAR);
 
        mov(edi, esi);
        and_(edi, 0xfc000000);
        cmp(edi, 0x94000000);//BL
        jz(halt_reason_set, T_NEAR);
 
        mov(edi, esi);
        and_(edi, 0xfffffc1f);
        cmp(edi, 0xd63f0000);//BLR
        jz(halt_reason_set, T_NEAR);
 
        mov(edi, esi);
        and_(edi, 0xfffff800);
        cmp(edi, 0xd63f0800);//BLRxxx
        jz(halt_reason_set, T_NEAR);
 
        mov(edi, esi);
        and_(edi, 0xff000010);
        cmp(edi, 0x54000000);//B.cond
        jz(halt_reason_set, T_NEAR);
 
        mov(edi, esi);
        and_(edi, 0xff000010);
        cmp(edi, 0x54000010);//BC.cond
        jz(halt_reason_set, T_NEAR);
 
        mov(edi, esi);
        and_(edi, 0x7f000000);
        cmp(edi, 0x35000000);//CBNZ
        jz(halt_reason_set, T_NEAR);
 
        mov(edi, esi);
        and_(edi, 0x7f000000);
        cmp(edi, 0x34000000);//CBZ
        jz(halt_reason_set, T_NEAR);
 
        mov(edi, esi);
        and_(edi, 0x7f000000);
        cmp(edi, 0x37000000);//TBNZ
        jz(halt_reason_set, T_NEAR);
 
        mov(edi, esi);
        and_(edi, 0x7f000000);
        cmp(edi, 0x36000000);//TBZ
        jz(halt_reason_set, T_NEAR);
 
        mov(edi, esi);
        and_(edi, 0xfc000000);
        cmp(edi, 0x14000000);//B
        jz(halt_reason_set, T_NEAR);
 
        mov(edi, esi);
        and_(edi, 0xfffffc1f);
        cmp(edi, 0xd61f0000);//BR
        jz(halt_reason_set, T_NEAR);
 
        mov(edi, esi);
        and_(edi, 0xfffff800);
        cmp(edi, 0xd61f0800);//BRxxx
        jz(halt_reason_set, T_NEAR);
 
        mov(edi, esi);
        and_(edi, 0xfffffc1f);
        cmp(edi, 0xd65f0000);//RET
        jz(halt_reason_set, T_NEAR);
 
        mov(edi, esi);
        and_(edi, 0xfffffbff);
        cmp(edi, 0xd65f0bff);//RETAA, RETAB
        jz(halt_reason_set, T_NEAR);
 
        mov(edi, esi);
        and_(edi, 0xffc0001f);
        cmp(edi, 0x5500001f);//RETAASPPC, RETABSPPC
        jz(halt_reason_set, T_NEAR);
 
        mov(edi, esi);
        and_(edi, 0xfffffbe0);
        cmp(edi, 0xd65f0be0);//RETAASPPC, RETABSPPC
        jz(halt_reason_set, T_NEAR);
 
        L(normal_code);
        pop(r13);
        pop(r14);
        pop(rdi);
        pop(rsi);
        if (isRet) {
            pop(rbx);
            pop(ABI_RETURN);
        }
        jmp(run_code_entry, T_NEAR);
 
        L(halt_reason_set);
        pop(r13);
        pop(r14);
        pop(rdi);
        pop(rsi);
        lock();
        or_(dword[r15 + jsi.offsetof_halt_reason], halt_reason_on_run);
 
        if (isRet) {
            pop(rbx);
            pop(ABI_RETURN);
            jmp(ret_code_entry, T_NEAR);
        }
    }
}

然后第一件事只需要修改jit框架代码runcode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
align();
run_code = getCurr<RunCodeFuncType>();
 
// This serves two purposes:
// 1. It saves all the registers we as a callee need to save.
// 2. It aligns the stack so that the code the JIT emits can assume
//    that the stack is appropriately aligned for CALLs.
ABI_PushCalleeSaveRegistersAndAdjustStack(*this, sizeof(StackLayout));
 
mov(r15, ABI_PARAM1);
mov(rbx, ABI_PARAM2);  // save temporarily in non-volatile register
 
if (cb.enable_cycle_counting) {
    cb.GetTicksRemaining->EmitCall(*this);
    mov(qword[rsp + ABI_SHADOW_SPACE + offsetof(StackLayout, cycles_to_run)], ABI_RETURN);
    mov(qword[rsp + ABI_SHADOW_SPACE + offsetof(StackLayout, cycles_remaining)], ABI_RETURN);
}
 
rcp(*this);
 
cmp(dword[r15 + jsi.offsetof_halt_reason], 0);
jne(return_to_caller_mxcsr_already_exited, T_NEAR);
 
GenHaltReasonSet(run_code_entry);
 
L(run_code_entry);
SwitchMxcsrOnEntry();
jmp(rbx);

第二件事是对于我们没有标记halt_reason的指令,因为它们不返回,在它们之后的分支指令也会跟着一块跑飞。所以我们还需要一个办法让跑飞的代码在遇到分支指令时能返回到VM调用方。这主要是需要在runfromruncode的时候进行处理(我们的前提是已经关闭了3个优化),也就是在从一个基本块跳到另一个基本块的dispatch代码这里,我们把dispatch代码稍做修改,再次根据PC与指令做一次判断,然后对需要返回的,跳转到返回的jit框架代码,而不是进行dispatch(dispatch是指根据PC取jit代码然后跳转到jit代码入口)

这个需要修改2个框架代码:

a、runfromruncode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
align();
return_from_run_code[0] = getCurr<const void*>();
 
cmp(dword[r15 + jsi.offsetof_halt_reason], 0);
jne(return_to_caller, T_NEAR);
if (cb.enable_cycle_counting) {
    cmp(qword[rsp + ABI_SHADOW_SPACE + offsetof(StackLayout, cycles_remaining)], 0);
    jng(return_to_caller, T_NEAR);
}
cb.LookupBlock->EmitCall(*this);
 
Xbyak::Label next_code_entry0;
GenHaltReasonSet(next_code_entry0, return_to_caller);
L(next_code_entry0);
jmp(ABI_RETURN);

b、runfromruncode+MXCSR_ALREADY_EXITED

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
align();
return_from_run_code[MXCSR_ALREADY_EXITED] = getCurr<const void*>();
 
cmp(dword[r15 + jsi.offsetof_halt_reason], 0);
jne(return_to_caller_mxcsr_already_exited, T_NEAR);
if (cb.enable_cycle_counting) {
    cmp(qword[rsp + ABI_SHADOW_SPACE + offsetof(StackLayout, cycles_remaining)], 0);
    jng(return_to_caller_mxcsr_already_exited, T_NEAR);
}
SwitchMxcsrOnEntry();
cb.LookupBlock->EmitCall(*this);
 
Xbyak::Label next_code_entry1;
GenHaltReasonSet(next_code_entry1, return_to_caller_mxcsr_already_exited);
L(next_code_entry1);
jmp(ABI_RETURN);

这样我们就实现了在模拟器执行分支指令时返回到VM调用方的机制。当然,并不是只有分支指令时会返回,因为中断、异常的时候也会返回到VM调用方。

三、内存数据修改工具
0、命令行样式命令与函数样式命令

我们为柚子添加的命令有两类,一类就是通常意义的命令行样式的命令,为了简单,这种形式的命令只处理没有参数或有一个参数的情形。另一类则是函数调用样式的命令,这种可以有多个参数,并且参数是有类型的,字符串需要用引号括起来。由于命令与函数添加的越来越多,我加了一个help命令用来列出所有命令与函数,help命令可以带一个参数,参数用来过滤命令与函数列表。

1
help

1
help xxx

使用help命令获取符合条件的命令与函数列表

有几个特殊的函数样式的命令用于实现批量命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
a、cmd("命令");
 
执行字符串描述的命令。同时终止目前正在运行的脚本。
 
b、qcmd("命令");
 
排除执行字符串描述的命令。命令在当前正在运行的脚本执行完后执行。
 
c、wait(毫秒数);
 
等待指定时间后继续执行后面的脚本代码。
 
d、loop(循环次数){...};
 
指定循环次数的循环语句,可以用来重复执行多次某个命令或函数。迭代的当前次数(从0开始计)使用$$来访问。多层嵌套循环的迭代器变量$$会被重写,所以有嵌套循环时在循环入口需要自己定义一个变量暂存迭代器变量的值。
 
e、looplist(list){...};
 
遍历列表的循环,迭代的当前元素使用$$来访问。多层嵌套循环的迭代器变量$$会被重写,所以有嵌套循环时在循环入口需要自己定义一个变量暂存迭代器变量的值。
 
f、foreach(v1,v2,v3,...){...};
 
指定列表元素的循环,迭代的当前元素使用$$来访问。多层嵌套循环的迭代器变量$$会被重写,所以有嵌套循环时在循环入口需要自己定义一个变量暂存迭代器变量的值。
 
g、if(条件){...}elseif(条件)/elif(条件){...}else{...};
 
条件判断语句,elseif/elif/else都是可选的部分。

(我使用MetaDSL语法来实现脚本命令,与c语言最大的不同是多个函数间需要用分号分隔(当前块的最后一个语句可以不加),即便某个函数是大括号结尾也需要加分号)

比如,我可以这样一次执行2条命令(这种情形使用cmd效果相同):

1
qcmd("help sniffing");qcmd("savelist temp.txt");

1、内存搜索、查看与修改命令

为了方便使用,内存搜索命令尽量使用比较少的参数。我们添加的内存搜索相关的命令主要有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
a、findmem([val1,val2,...])
搜索一次数据。搜索指定的数据列表的数值一次,搜索结果显示在信息列表窗口里。(findmemory是与此命令功能相同的函数,不过它主要用在脚本代码里,参数比较多,并且结果不会显示在ui上)
b、searchmem([val1,val2,...])
搜索多次数据。搜索指定的数据列表的数值多次,搜索结果显示在信息列表窗口里。(searchmemory是与此命令功能相同的函数,不过它主要用在脚本代码里,参数比较多,并且结果不会显示在ui上)
为了实现搜索,还有许多参数由别的命令来指定(也有一个默认值)
c、clearmemscope
清空搜索范围,清空后的搜索范围是整个游戏的地址空间。这是个特别大的地址范围,所以一般不要在清空范围后搜索,可能会卡很久。
d、setmemscope main
指定搜索范围为模块名或build_id对应的模块的地址范围,有一些常用的名称,main是主模块名。然后heap、alias、stack、kernel map、code、alias code、addr space是对整个进程而言的section。
我们可能比较常用的是main、heap与alias三个。启动游戏时默认的搜索范围是main模块的地址范围。
e、setmemscopebegin 0x80004000
指定搜索范围的开始地址。
f、setmemscopeend 0x87000000
指定搜索范围的结束地址。
g、setmemstep 4
指定内存搜索的步长,也就在一个地址查找后,下一个地址增长多少,默认为4字节。
h、setmemsize 4
指定内存搜索的值的大小,我们在搜索命令时会指定一系列要搜索的目标值,这个命令给定这些数值的内存尺寸,默认为4字节。
i、setmemrange 256
指定内存范围(不是搜索的范围),这个参数的涵义是我们搜索的所有目标值的地址范围,只有所有的数值都找到,并且这些值的地址范围在这个参数内,才认为是搜索到了一次结果。
j、setmemcount 10
指定searchmem命令搜索到的结果的最大数量。因为有时候满足条件的地址可能有多个,全用searchmem时可以避免只搜索到一个并不是我们想要的结果。
k、showmem(0x21593f0000, 200[, 1|2|4|8])
显示内存数据,参数分别是起始地址、要显示的内存块的size、单个内存数据的size(可选,默认为4)。内存数据会显示在ui上。一般用来观察搜索结果区域里除目标数据外的其它数据。
l、echo(readmemory(0x21593f0000, 4))
这是2个函数组合成的一个语句,用来显示单个内存数据,2个参数分别是地址与数据尺寸,只能是1248之一。
m、writememory(0x21593f0000, 127[, 1|2|4|8])
修改内存数据,参数分别是内存地址、要写入的数据,数据尺寸(可选,只能是1248之一,默认为4)。
n、dumpmemory(0x21593f0000, 0x400000, "file.dat");
dump指定内存块到文件。参数分别是内存起始地址,内存块size,保存的文件。

这里就简单列一下命令,后面说到游戏修改实例时再看如何使用。

2、内存快照与比对功能

这部分主要使用ui操作。

内存快照与比对功能ui

通常的操作步骤如下:

a、我们首先勾选Enable,此时信息列表窗口会显示当前运行的游戏的内存段信息,同时如果我们没有输入Start/Size/Step的话会默认使用heap的起始地址,size会设置为一个固定的值0x10000000,step设为4。

b、根据需要修改Start/Size/Step,这是稍后要用到的内存搜索范围与搜索步长(同时也是搜索值的size)。

c、如果我们有一个比较明确的值要搜索,可以输入Value。

d、点击AddSniffing按钮,此时将在指定内存范围内搜索Value,搜索到的内存地址将添加到监听内存地址列表中。如果未指定Value时将指定内存范围按Step大小拆分成若干监听内存地址并添加到列表,所以不指定Value时内存范围不要太大,否则不只操作慢,还会耗费大量内存。

e、此时继续游戏一会。

f、如果我们想要监听的游戏数据没有变化,点Keep Unchanged按钮,否则按实际情况点另外3个Keep按钮。此时会对监听地址列表进行过滤,保留符合条件的内存地址,并显示前10个地址到信息列表窗口。

g、如果看到当前内存地址数量已经少到我们可以分析了,就可以停止操作了。

h、有时候我们可能找不到符合条件的内存地址,此时可以使用Rollback/Unrollback按钮回退/前进到最近的Keep结果。因为我们要保存每次Keep操作的结果,所以这里也会占比较多的内存。

i、最理想的状态是,我们看到最后几次Keep结果里的内存地址的数据正好与我们在游戏里看到的一样,这表明我们已经找到了保存游戏数据的内存,一般就可以直接修改了。

j、如果我们最后只有几条数据并且这些数据正是我们想要锁定的数据,那么我们可以点击SaveAbs或SaveRel来保存一个金手指文件,这个金手指文件使用绝对地址或相对地址的指令来锁定这些内存值(不做条件判断,每个tick都重新写入)。

AddSniffing按钮的ui功能相对较弱,我们也提供了两个函数样式的命令来添加内存监听地址

a、addsniffing(addr,size[,step,val])
这个函数与ui的操作是一样的,因为是命令,所以可以批量处理添加多个内存范围或地址。
b、addsniffingfromsearch(find_vals)
这个函数与内存搜索命令相似,它也使用内存搜索的其它参数,不过它将搜索到的结果内存(是一个与setmemrange指定的大小相同的区域)添加到监听内存列表。
3、内存调试日志

内存调试日志其实是一个类似数据断点的功能,我们指定了要调试的内存地址后,每次这些地址被读/写/指针访问时,就会记录当时的调用栈与执行上下文的信息。这些信息显示在ui上,我们可以用命令保存到文件。

所有命令里的addr都可以是十进制或十六进制表示(0x开始)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
a、addtracecstring addr
添加读取c字符串的日志,参数addr是字符串的开始地址
b、addtracepointer 参数addr
添加获取指针的日志,参数addr是指针的值
c、addtraceread addr
添加读内存的日志,参数addr是读取的内存地址
d、addtracewrite addr
添加写内存的日志,参数addr是写入的内存地址
e、removetracecstring addr
去掉读指定地址addr c字符串的后续日志
f、removetracepointer addr
去掉获取指针addr的后续日志
g、removetraceread addr
去掉读内存addr的后续日志
h、removetracewrite addr
去掉写内存addr的后续日志
i、settraceswi swi
指定记录指定系统服务的信息,swi是系统服务号,如果是0x7fffffff则记录所有系统服务的信息(这个命令暂时没想到用处,所以只支持指定一个或全部)。
j、cleartracebuffer
清空trace buffer,之前各种日志都记录在这个buffer
k、savetracebuffer file
保存trace buffer到文件

这里也只简单列一下命令,本文的游戏修改实例未涉及这些命令的使用。

四、内存指令修改工具
1、指令日志与比对

指令日志用来跟踪程序执行了哪些指令,为了避免游戏卡死,指令日志只记录pc值。使用此功能前,我们需要关掉block linking、RSB与fast dispatcher三个优化。也可以指定哪些指令记录到日志。

1
2
3
4
5
6
7
8
9
10
a、addlogb
添加无条件跳转 B/BR/BRxxx 指令到记录指令条件中。
b、addlogbc
添加条件跳转 B.cond/BC.cond/CBNZ/CBZ/TBNZ/TBZ 指令到记录指令条件中。
c、addlogbl
添加过程调用 BL/BLR/BLRxxx 指令到记录指令条件中。
d、addlogret
添加返回 RET/RETxxx 指令到记录指令条件中。
e、clearloginsts
清空指令条件,不设指令条件时,所有分支指令都被记录,包括不是分支指令的基本块入口指令也会记录。

也有一个函数样式的命令用来增加一个更具体的分支指令到记录条件

1
addloginst(mask, value)

当 (指令码 & mask) == value 时,这条分支指令会被记录
下面是指令日志与比对的命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
a、startpccount or startpccount ix
开始pc记录,ix用于在特定处理器上开启。
b、stoppccount or stoppccount ix
停止pc记录,ix用于停止特定处理器的记录。
c、storepccount
暂存当前的pc记录,作为上一次的pc记录,并清空当前pc记录。
d、keeppccount, keep last and current pc count info
将上一次pc记录与当前pc记录合并为当前pc记录结果,并清空当前pc记录。
e、keepnewpccount
将当前pc记录相对于上一次的pc记录的新pc记录作为当前pc记录结果,并清空当前pc记录。
f、keepsamepccount
将当前pc记录与上一次的pc记录相同的pc记录作为当前pc记录结果,并清空当前pc记录。
g、usepccountarray 0_or_1
设置是否使用预先分配的数组来记录pc,默认开启(本来是为了速度更快的,好像并不明显)
h、clearpccount
清空当前pc记录信息。
i、savepccount file
将pc记录结果存到文件。
j、setmaxpccount num
设置保存到文件的pc记录的条件,这个条件的意思是pc记录里的pc执行到的次数小于等于此值时,这个pc记录就保存到文件,默认为10

2、断点与单步跟踪

单步跟踪主要用来记录单步执行时每条指令的调用栈与环境信息。断点用来记录执行到断点时的调用栈与环境信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
a、setstarttracebp addr
添加一个指令断点,当执行触发此断点时,启动单步执行trace,并记录调用栈等环境信息,单步执行的次数受maxstepcount的限制。
这个断点触发后即删除。
b、setstoptracebp addr
添加一个指令断点,当执行触发此断点时,停止单步执行trace。
这个断点触发后即删除。
c、starttrace or starttrace ix
立即开始单步执行trace,参数ix可以指明只对特定处理器开启。单步执行的次数受maxstepcount限制
d、stoptrace or stoptrace ix
停止单步执行trace,参数ix指明只对特定处理器停止。
e、cleartrace
清空设置的trace与断点。
f、settracescope section_key
通过模块名或build_id来设置trace的地址范围
g、settracescopebegin addr
设置trace范围的起始地址
h、settracescopeend addr
设置trace范围的结束地址
i、setmaxstepcount count
设置单步trace的最大步数

断点与单步跟踪的信息也记录在trace buffer里,所以下面2条命令也影响这块。

1
2
3
4
j、cleartracebuffer
清空trace buffer,之前各种日志都记录在这个buffer
k、savetracebuffer file
保存trace buffer到文件

3、指令读写(同内存修改)

1
2
3
4
5
6
7
8
a、showmem(0x21593f0000, 200[, 1|2|4|8])
显示内存数据,参数分别是起始地址、要显示的内存块的size、单个内存数据的size(可选,默认为4)。内存数据会显示在ui上。一般用来观察搜索结果区域里除目标数据外的其它数据。
b、echo(readmemory(0x21593f0000, 4))
这是2个函数组合成的一个语句,用来显示单个内存数据,2个参数分别是地址与数据尺寸,只能是1248之一。
c、writememory(0x21593f0000, 127[, 1|2|4|8])
修改内存数据,参数分别是内存地址、要写入的数据,数据尺寸(可选,只能是1248之一,默认为4)。
d、dumpmemory(0x21593f0000, 0x400000, "file.dat");
dump指定内存块到文件。参数分别是内存起始地址,内存块size,保存的文件。

五、可编译到金手指的脚本
柚子模拟器支持的金手指代码是一个小型的虚拟机指令集(目前有十多条指令),由于是指令集样式的,它采用位编码来指明各种信息,这样手写起来就和人工翻译汇编代码到机器指令一样。

我就想能不能把我们已经支持的命令脚本用在这呢,或者说提供一个编译/汇编器,把命令脚本翻译为金手指指令。

这通常有两种方式,一种是标准的编译器做法,我们定义好高层的语言,然后编译到目标代码。另一种则是把输出目标代码看成脚本代码的输出,这样其实就是为脚本添加API就可以了,只不过一般的脚本API都是函数或对象的形式,不太适合表达if/else或语句块这样的嵌套语法。不过我们的命令脚本是基于MetaDSL的,它的特点就是支持语句与块形式的API语法。所以我就采用了后面这种做法。

所以我们的金手指脚本其实是加了一些命令,但语法上与一个脚本语言没什么差别,下面我们就来看看这个脚本语言。

1、语句

dmnt_file()

{

//金手指脚本代码
};

dmnt_if()

{
}
elif/elseif()

{
}
else

{
};

dmnt_loop()

{
};

1
2
3
4
5
6
a、dmnt_file语句
[dmnt_file]:dmnt_file(name,module[,file_dir[,build_id]]){...}; statement
b、dmnt_if语句
[dmnt_if]:dmnt_if(exp){...}; or dmnt_if(exp){...}elseif/elif(exp){...}else{...}; or dmnt_if(exp)func(...); statement
c、dmnt_loop语句
[dmnt_loop]:dmnt_loop(reg,ct){...}; statement

2、函数

这文章写的太长了,偷偷懒就把文档贴这了:)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
[dmnt_add]:dmnt_xxx(mem_width,result_reg,lhs_reg,rhs[,rhs_is_val_1or0]), all type is integer, xxx:add|sub|mul|lshift|rshift|and|or|not|xor|mov
[dmnt_and]:dmnt_xxx(mem_width,result_reg,lhs_reg,rhs[,rhs_is_val_1or0]), all type is integer, xxx:add|sub|mul|lshift|rshift|and|or|not|xor|mov
[dmnt_calc_offset]:dmnt_calc_offset(offset,addr,region), all type is integer
[dmnt_comment]:dmnt_comment(str)
[dmnt_debug]:dmnt_debug(mem_width,log_id,opd_type,val1[,val2]), all type is integer
[dmnt_eq]:dmnt_xxx(mem_width,mem_region,offset,val), all type is integer, xxx:gt|ge|lt|le|eq|ne
[dmnt_ge]:dmnt_xxx(mem_width,mem_region,offset,val), all type is integer, xxx:gt|ge|lt|le|eq|ne
[dmnt_gt]:dmnt_xxx(mem_width,mem_region,offset,val), all type is integer, xxx:gt|ge|lt|le|eq|ne
[dmnt_key]:dmnt_key(key) key:A|B|X|Y|LS|RS|L|R|ZL|ZR|Plus|Minus|Left|Up|Right|Down|LSL|LSU|LSR|LSD|RSL|RSU|RSR|RSD|SL|SR
[dmnt_keypress]:dmnt_keypress(key1,key2,...); all type is integer, key can get by dmnt_key(const)
[dmnt_le]:dmnt_xxx(mem_width,mem_region,offset,val), all type is integer, xxx:gt|ge|lt|le|eq|ne
[dmnt_legacy_add]:dmnt_legacy_xxx(mem_width,reg,val), all type is integer, xxx:add|sub|mul|lshift|rshift
[dmnt_legacy_lshift]:dmnt_legacy_xxx(mem_width,reg,val), all type is integer, xxx:add|sub|mul|lshift|rshift
[dmnt_legacy_mul]:dmnt_legacy_xxx(mem_width,reg,val), all type is integer, xxx:add|sub|mul|lshift|rshift
[dmnt_legacy_rshift]:dmnt_legacy_xxx(mem_width,reg,val), all type is integer, xxx:add|sub|mul|lshift|rshift
[dmnt_legacy_sub]:dmnt_legacy_xxx(mem_width,reg,val), all type is integer, xxx:add|sub|mul|lshift|rshift
[dmnt_load_m2r]:dmnt_load_m2r(mem_width[,mem_region],reg,offset), all type is integer
[dmnt_load_v2r]:dmnt_load_v2r(reg,val), all type is integer
[dmnt_lshift]:dmnt_xxx(mem_width,result_reg,lhs_reg,rhs[,rhs_is_val_1or0]), all type is integer, xxx:add|sub|mul|lshift|rshift|and|or|not|xor|mov
[dmnt_lt]:dmnt_xxx(mem_width,mem_region,offset,val), all type is integer, xxx:gt|ge|lt|le|eq|ne
[dmnt_mov]:dmnt_xxx(mem_width,result_reg,lhs_reg,rhs[,rhs_is_val_1or0]), all type is integer, xxx:add|sub|mul|lshift|rshift|and|or|not|xor|mov
[dmnt_mul]:dmnt_xxx(mem_width,result_reg,lhs_reg,rhs[,rhs_is_val_1or0]), all type is integer, xxx:add|sub|mul|lshift|rshift|and|or|not|xor|mov
[dmnt_ne]:dmnt_xxx(mem_width,mem_region,offset,val), all type is integer, xxx:gt|ge|lt|le|eq|ne
[dmnt_not]:dmnt_xxx(mem_width,result_reg,lhs_reg,rhs[,rhs_is_val_1or0]), all type is integer, xxx:add|sub|mul|lshift|rshift|and|or|not|xor|mov
[dmnt_offset]:dmnt_offset(name) name:no_offset|offset_reg|offset_fixed|region_and_base|region_and_relative|region_and_relative_and_offset
[dmnt_operand]:dmnt_operand(name) name:mem_and_relative|mem_and_offset|reg_and_relative|reg_and_offset|static_value|register_value|reg_other|restore_register|save_register|clear_saved_value|clear_register
[dmnt_or]:dmnt_xxx(mem_width,result_reg,lhs_reg,rhs[,rhs_is_val_1or0]), all type is integer, xxx:add|sub|mul|lshift|rshift|and|or|not|xor|mov
[dmnt_pause]:dmnt_pause()
[dmnt_read_mem]:dmnt_read_mem(val,addr[,val_size]), all type is integer
[dmnt_reg_eq]:dmnt_reg_xxx(mem_width,src_reg,opd_type,val1[,val2]), all type is integer, xxx:gt|ge|lt|le|eq|ne
[dmnt_reg_ge]:dmnt_reg_xxx(mem_width,src_reg,opd_type,val1[,val2]), all type is integer, xxx:gt|ge|lt|le|eq|ne
[dmnt_reg_gt]:dmnt_reg_xxx(mem_width,src_reg,opd_type,val1[,val2]), all type is integer, xxx:gt|ge|lt|le|eq|ne
[dmnt_reg_le]:dmnt_reg_xxx(mem_width,src_reg,opd_type,val1[,val2]), all type is integer, xxx:gt|ge|lt|le|eq|ne
[dmnt_reg_lt]:dmnt_reg_xxx(mem_width,src_reg,opd_type,val1[,val2]), all type is integer, xxx:gt|ge|lt|le|eq|ne
[dmnt_reg_ne]:dmnt_reg_xxx(mem_width,src_reg,opd_type,val1[,val2]), all type is integer, xxx:gt|ge|lt|le|eq|ne
[dmnt_reg_rw]:dmnt_reg_rw(static_reg_index,reg), all type is integer, static_reg_index: 0x00 to 0x7F for reading or 0x80 to 0xFF for writing
[dmnt_reg_sr]:dmnt_reg_sr(dest_reg,src_reg,opd_type), all type is integer
[dmnt_reg_sr_mask]:dmnt_reg_sr_mask(opd_type,mask), all type is integer
[dmnt_region]:dmnt_region(mem_region) mem_region:main|heap|alias|aslr
[dmnt_resume]:dmnt_resume()
[dmnt_rshift]:dmnt_xxx(mem_width,result_reg,lhs_reg,rhs[,rhs_is_val_1or0]), all type is integer, xxx:add|sub|mul|lshift|rshift|and|or|not|xor|mov
[dmnt_store_r2m]:dmnt_store_r2m(mem_width,src_reg,mem_reg,reg_inc_1or0,[offset_type,offset_or_reg_or_region[,offset]]), all type is integer
[dmnt_store_v2a]:dmnt_store_v2a(mem_width,mem_region,reg,offset,val), all type is integer
[dmnt_store_v2m]:dmnt_store_v2m(mem_width,mem_reg,reg_inc_1or0,val[,offset_reg]), all type is integer
[dmnt_sub]:dmnt_xxx(mem_width,result_reg,lhs_reg,rhs[,rhs_is_val_1or0]), all type is integer, xxx:add|sub|mul|lshift|rshift|and|or|not|xor|mov
[dmnt_xor]:dmnt_xxx(mem_width,result_reg,lhs_reg,rhs[,rhs_is_val_1or0]), all type is integer, xxx:add|sub|mul|lshift|rshift|and|or|not|xor|mov

这些函数与金手指基本上是对应的,有些参数我就不解释了,参阅金手指文档吧:)

金手指指令文档

3、实例

估计多数普通的金手指都不需要使用上面这一堆命令,如果是简单修改内存就能办到的话,修改下面这个金手指文件通常就可以搞定了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
dmnt_file("经验物品", "main"){
    @region = dmnt_region(main);
     
    @base = 0x0000000080004000;
 
    @addr1 = 0x00000000802EF908;
    @addr2 = 0x00000000802E8370;
    @addr3 = 0x00000000802E8374;
    @addr4 = 0x000000008042A02C;
    @addr5 = 0x000000008042A038;
     
    @wval1 = 0x12BFEC14;
    @wval2 = 0x1E2C1000;
    @wval3 = 0x52800808;
    @wval4 = 0x52A88C2A;
    @wval5 = 0x1E212800;
 
    @v1 = dmnt_read_mem(@wval1, @addr1);
    @v2 = dmnt_read_mem(@wval2, @addr2);
    @v3 = dmnt_read_mem(@wval3, @addr3);
    @v4 = dmnt_read_mem(@wval4, @addr4);
    @v5 = dmnt_read_mem(@wval5, @addr5);
 
    @offset1 = dmnt_calc_offset(@addr1 - @base, @addr1, @region);
    @offset2 = dmnt_calc_offset(@addr2 - @base, @addr2, @region);
    @offset3 = dmnt_calc_offset(@addr3 - @base, @addr3, @region);
    @offset4 = dmnt_calc_offset(@addr4 - @base, @addr4, @region);
    @offset5 = dmnt_calc_offset(@addr5 - @base, @addr5, @region);
 
    dmnt_load_v2r(0,0);
    dmnt_if(dmnt_ne(4,@region,@offset1,@v1)){
        dmnt_store_v2a(4,@region,0,@offset1,@v1);
    };
    dmnt_if(dmnt_ne(4,@region,@offset2,@v2)){
        dmnt_store_v2a(4,@region,0,@offset2,@v2);
    };
    dmnt_if(dmnt_ne(4,@region,@offset3,@v3)){
        dmnt_store_v2a(4,@region,0,@offset3,@v3);
    };
    dmnt_if(dmnt_ne(4,@region,@offset4,@v4)){
        dmnt_store_v2a(4,@region,0,@offset4,@v4);
    };
    dmnt_if(dmnt_ne(4,@region,@offset5,@v5)){
        dmnt_store_v2a(4,@region,0,@offset5,@v5);
    };
};

这个是 伊苏X 1.05 的一个金手指脚本,主要需要修改5处内存,都是4字节。内存地址位于main模块。需要更多的内存就复制增加新的就好了。

@base 是main模块的基地址,如果不是main模块按需要修改。

@addr1~@addr5是需要修改的内存地址。

@wval1~@wval5是希望修改成的值。

这个脚本的逻辑,是对每个内存地址,先读取其值看是否是期望的值,如果不是则写入。如果我们是在修改好的游戏上运行,@wval1~@wval5可以置成0,此时脚本会读取当前游戏在这些内存的值作为期望值。

写入数据用的指令是Store Static Value to Memory,对应我们脚本的函数是dmnt_store_v2a,金手指写到内存地址也需要一个寄存器保存offset,所以我们用dmnt_load_v2r函数给0号寄存器赋0来作为寄存器偏移。然后我们就把实际偏移作为立即数(@offset1~@offset5)提供就可以了。

最后再说一遍,这个脚本需要特别注意的地方,是每个语句结束的大括号后要加分号(最后一个语句可以不加)。

上面文件生成的金手指文件如下,如果你正好用的是伊苏X 1.05版本,这个就作为看这么长的文章的福利拿去用好了:)

主要功能:

a、每次加载时背包里所有物品(除DLC)与金钱全满(超上限)

b、打死怪物时直接满级(记不太清是打死还是升级时了)

c、使用DLC物品加基础属性时,直接加满(超上限)。

文件名:7E06539B5874B9C4.txt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{ 经验物品 7E06539B5874B9C4 [0100A0C01BED8000] }
40000000 00000000 00000000
14060000 002EB908 12BFEC14
04000000 002EB908 12BFEC14
20000000
14060000 002E4370 1E2C1000
04000000 002E4370 1E2C1000
20000000
14060000 002E4374 52800808
04000000 002E4374 52800808
20000000
14060000 0042602C 52A88C2A
04000000 0042602C 52A88C2A
20000000
14060000 00426038 1E212800
04000000 00426038 1E212800
20000000

4、柚子模拟器的金手指目录结构

使用ui上的Run Script按钮,选择这个脚本文件,然后会在yuzu.exe所在的目录下生成一个金手指文件,文件名是当前游戏main模块的build_id。

金手指文件在柚子里目录结构应该如下:

1
[title_id]/我们想用的金手指功能名称/cheats/[build_id].txt

其中title_id是游戏的一个标识,build_id是我们修改的模块的build_id。在游戏没有运行时,右键点游戏图标,选Open Mod Data Location,就会打开游戏的[title_id]目录,然后在下面按目录结构创建目录,最后把yuzu.exe目录下的[build_id].txt拷到cheats目录下,然后再在游戏图标上右键选Properties,应该就多了一个金手指模块了,勾选并启动游戏,金手指就用上了。

六、switch游戏修改实例
1、星之海洋——第二个故事R

这个介绍一种最简单的改法,用winhex或hxd直接打开存档文件,搜索人物属性和钱的十六进制字节数据就能找到,其实都不用搜索,第一屏直接能看到这些数据都在一块:)改完保存一下,然后在游戏里加载就可以看到已经变成修改后的数据了。

金钱等级生命魔法
金钱等级生命魔法
各种属性
各种属性
所以这个游戏都用不着我们前面说的各种工具了。

2、塞尔达王国之泪1.2

这个需要使用内存搜索功能。背包里的物品数量、金钱都可以修改。这个我没有尝试改存档,没准也可以。

这个我主要使用searchmem命令来查找的,王国之泪的数据存在堆上,这个的地址范围比较大,可能搜索需要些时间。下面是搜索到的2个符合的内存区域,search result是列出的我们要找的数据与发现它们的地址。area memory则是发现这些数据的连续内存区域的数据,可以通过看其它数据来确认是否是要找的内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
command: setmemscope heap
command: setmemrange 256
command: searchmem([123,95,109,181,154,120,6,187,70,36,109,26,15,126,46]);
===search result===
addr: 21593f7cc4 hex_val: 5f dec_val: 95
addr: 21593f7cf0 hex_val: f dec_val: 15
addr: 21593f7cd8 hex_val: 6 dec_val: 6
addr: 21593f7ce0 hex_val: 46 dec_val: 70
addr: 21593f7cf8 hex_val: 2e dec_val: 46
addr: 21593f7cd0 hex_val: 9a dec_val: 154
addr: 21593f7cec hex_val: 1a dec_val: 26
addr: 21593f7ce4 hex_val: 24 dec_val: 36
addr: 21593f7cd4 hex_val: 78 dec_val: 120
addr: 21593f7ce8 hex_val: 6d dec_val: 109
addr: 21593f7cdc hex_val: bb dec_val: 187
addr: 21593f7cc0 hex_val: 7b dec_val: 123
addr: 21593f7cf4 hex_val: 7e dec_val: 126
addr: 21593f7ccc hex_val: b5 dec_val: 181
===area memory===
addr: 21593f7cc0 hex_val: 7b dec_val: 123
addr: 21593f7cc4 hex_val: 5f dec_val: 95
addr: 21593f7cc8 hex_val: 6d dec_val: 109
addr: 21593f7ccc hex_val: b5 dec_val: 181
addr: 21593f7cd0 hex_val: 9a dec_val: 154
addr: 21593f7cd4 hex_val: 78 dec_val: 120
addr: 21593f7cd8 hex_val: 6 dec_val: 6
addr: 21593f7cdc hex_val: bb dec_val: 187
addr: 21593f7ce0 hex_val: 46 dec_val: 70
addr: 21593f7ce4 hex_val: 24 dec_val: 36
addr: 21593f7ce8 hex_val: 6d dec_val: 109
addr: 21593f7cec hex_val: 1a dec_val: 26
addr: 21593f7cf0 hex_val: f dec_val: 15
===search result===
addr: 21593f84d8 hex_val: 46 dec_val: 70
addr: 21593f84d0 hex_val: 6 dec_val: 6
addr: 21593f84e8 hex_val: f dec_val: 15
addr: 21593f84c8 hex_val: 9a dec_val: 154
addr: 21593f84e4 hex_val: 1a dec_val: 26
addr: 21593f84e0 hex_val: 6d dec_val: 109
addr: 21593f84d4 hex_val: bb dec_val: 187
addr: 21593f84b8 hex_val: 7b dec_val: 123
addr: 21593f84bc hex_val: 5f dec_val: 95
addr: 21593f84c4 hex_val: b5 dec_val: 181
addr: 21593f84cc hex_val: 78 dec_val: 120
addr: 21593f84dc hex_val: 24 dec_val: 36
addr: 21593f84ec hex_val: 7e dec_val: 126
addr: 21593f84f0 hex_val: 2e dec_val: 46
===area memory===
addr: 21593f84b8 hex_val: 7b dec_val: 123
addr: 21593f84bc hex_val: 5f dec_val: 95
addr: 21593f84c0 hex_val: 6d dec_val: 109
addr: 21593f84c4 hex_val: b5 dec_val: 181
addr: 21593f84c8 hex_val: 9a dec_val: 154
addr: 21593f84cc hex_val: 78 dec_val: 120
addr: 21593f84d0 hex_val: 6 dec_val: 6
addr: 21593f84d4 hex_val: bb dec_val: 187
addr: 21593f84d8 hex_val: 46 dec_val: 70
addr: 21593f84dc hex_val: 24 dec_val: 36
addr: 21593f84e0 hex_val: 6d dec_val: 109
addr: 21593f84e4 hex_val: 1a dec_val: 26
addr: 21593f84e8 hex_val: f dec_val: 15
command: savelist list.txt

这2块内存都是,所以我们把它们都修改一下,命令如下(王国之泪1.2版本应该直接可用):

1
loop(140){writememory(0x21593f7cc0+$$*4,386);};loop(140){writememory(0x21593f84b8+$$*4,386);}

执行后,物品背包的物品就都变成386个了,可能得关闭了重新打开一下,数据变化后最好存一下盘。
物品背包

类似的,我们也能找到另外几个页签的数据的保存地址,这里就不重复列了,为了感谢您看了这么久的文章,我直接给出修改命令吧。

左纳乌背包:

1
loop(22){writememory(0x21593fa310+$$*4,386);};loop(22){writememory(0x21593fa400+$$*4,386);}

左纳乌背包

弓箭:

1
writememory(0x21593f51d0,999);writememory(0x21593f51d8,999);

弓箭与金币

金币:

1
writememory(0x21593d090c,999999);writememory(0x21593d0910,999999);

地图魂魄等:

1
writememory(0x21593f6694,999);writememory(0x21593f69b4,999);writememory(0x21593f669c,999);writememory(0x21593f69bc,999);writememory(0x21593f66a0,999);writememory(0x21593f69c0,999999);writememory(0x21593f66ac,999);writememory(0x21593f69cc,999);writememory(0x21593f66b8,999);writememory(0x21593f69d8,999);writememory(0x21593f66bc,999);writememory(0x21593f69dc,999);writememory(0x21593f66c4,999);writememory(0x21593f69e4,999999);writememory(0x21593f66d4,999);writememory(0x21593f69f4,999);

地图魂魄等

食物等:

1
loop(10){writememory(0x21593f62d0+$$*4,900);};loop(10){writememory(0x21593f64b0+$$*4,900);}

食物

食物这里比较奇怪的是,有些食物背包只能放一个,我们改了后,看起来也仍然是一个,但消耗掉一个后,关了背包再打开,发现又有了,我怀疑它其实真的存了多个了:)

3、荒野大镖客1

这个需要使用内存快照与比对功能,我们只修改金钱,这里给出一种在轮盘赌的时候修改金钱的办法。手上有10块钱的时候就可以去轮盘赌了,初始会有100个筹码,在轮盘赌开始后,筹码为100时,我们添加内存监听,监听main模块里4字节数值为100的内存地址。
监听main模块里4字节值为100的内存

然后开始赌,每次扣筹码后,点击Keep Change(或者用Keep Decreased,应该会更快收敛)。大约5、6次后就能确定保存筹码的内存了。
根据筹码数值变化能直接看到对应的内存了

这样我们就看到是在内存地址0x874aac68保存的筹码(这个地址不是固定的,每次运行可能不一样),然后我们用命令把它改成1000000

1
writememory(0x874aac68,1000000);

再接着下注一次,会看到筹码变成了一串0,表明改成功了,退出轮盘赌存盘即可。
金钱与来源统计

整个操作过程的信息列表窗口内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
===[Sniffing]===
history count:0 []
rollback count:0 []
result count:4326
0 vaddr:817732fc type:2 val:64 old val:0 size:4
1 vaddr:81774e9c type:2 val:64 old val:0 size:4
2 vaddr:817eb8d0 type:2 val:64 old val:0 size:4
3 vaddr:817eb8d8 type:2 val:64 old val:0 size:4
4 vaddr:817eda70 type:2 val:64 old val:0 size:4
5 vaddr:817ee34c type:2 val:64 old val:0 size:4
6 vaddr:817efdc8 type:2 val:64 old val:0 size:4
7 vaddr:817f2db4 type:2 val:64 old val:0 size:4
8 vaddr:817f3bfc type:2 val:64 old val:0 size:4
9 vaddr:817f4258 type:2 val:64 old val:0 size:4
===[KeepChanged]===
history count:1 [0:4326]
rollback count:0 []
result count:71
0 vaddr:854ce558 type:2 val:326c0064 old val:64 size:4
1 vaddr:85737198 type:2 val:0 old val:64 size:4
2 vaddr:861a9bec type:2 val:bb8 old val:64 size:4
3 vaddr:861a9bf0 type:2 val:ffffffff old val:64 size:4
4 vaddr:861f8a64 type:2 val:0 old val:64 size:4
5 vaddr:861fd834 type:2 val:0 old val:64 size:4
6 vaddr:8623e624 type:2 val:1f4 old val:64 size:4
7 vaddr:8623e628 type:2 val:1f4 old val:64 size:4
8 vaddr:8623e664 type:2 val:1f4 old val:64 size:4
9 vaddr:86242b88 type:2 val:0 old val:64 size:4
===[KeepChanged]===
history count:2 [0:4326,1:71]
rollback count:0 []
result count:46
0 vaddr:861a9bec type:2 val:0 old val:bb8 size:4
1 vaddr:861a9bf0 type:2 val:0 old val:ffffffff size:4
2 vaddr:8623e624 type:2 val:0 old val:1f4 size:4
3 vaddr:8623e628 type:2 val:0 old val:1f4 size:4
4 vaddr:8623e664 type:2 val:64 old val:1f4 size:4
5 vaddr:86242b88 type:2 val:1f old val:0 size:4
6 vaddr:86292b98 type:2 val:0 old val:14 size:4
7 vaddr:863254a4 type:2 val:0 old val:1b001b size:4
8 vaddr:8634d82c type:2 val:3f800000 old val:514 size:4
9 vaddr:8634d830 type:2 val:0 old val:ffffffff size:4
===[KeepChanged]===
history count:3 [0:4326,1:71,2:46]
rollback count:0 []
result count:40
0 vaddr:8623e624 type:2 val:1b001b old val:0 size:4
1 vaddr:8623e664 type:2 val:0 old val:64 size:4
2 vaddr:86242b88 type:2 val:0 old val:1f size:4
3 vaddr:863254a4 type:2 val:1b001b old val:0 size:4
4 vaddr:874aac68 type:2 val:46 old val:5c size:4
5 vaddr:88c667b8 type:2 val:65 old val:8d size:4
6 vaddr:88c667d8 type:2 val:65 old val:8d size:4
7 vaddr:88c667f8 type:2 val:65 old val:8d size:4
8 vaddr:88c66818 type:2 val:65 old val:8d size:4
9 vaddr:88c66838 type:2 val:65 old val:6b size:4
===[KeepChanged]===
history count:4 [0:4326,1:71,2:46,3:40]
rollback count:0 []
result count:37
0 vaddr:8623e624 type:2 val:ba83126f old val:1b001b size:4
1 vaddr:8623e664 type:2 val:ff0909 old val:0 size:4
2 vaddr:874aac68 type:2 val:8 old val:46 size:4
3 vaddr:88c667b8 type:2 val:8d old val:65 size:4
4 vaddr:88c667d8 type:2 val:8d old val:65 size:4
5 vaddr:88c667f8 type:2 val:8d old val:65 size:4
6 vaddr:88c66818 type:2 val:8d old val:65 size:4
7 vaddr:88c66838 type:2 val:6b old val:65 size:4
8 vaddr:88c66858 type:2 val:6b old val:65 size:4
9 vaddr:88c66878 type:2 val:6b old val:65 size:4
===[KeepChanged]===
history count:5 [0:4326,1:71,2:46,3:40,4:37]
rollback count:0 []
result count:37
0 vaddr:8623e624 type:2 val:0 old val:ba83126f size:4
1 vaddr:8623e664 type:2 val:0 old val:ff0909 size:4
2 vaddr:874aac68 type:2 val:6 old val:8 size:4
3 vaddr:88c667b8 type:2 val:91 old val:8d size:4
4 vaddr:88c667d8 type:2 val:91 old val:8d size:4
5 vaddr:88c667f8 type:2 val:91 old val:8d size:4
6 vaddr:88c66818 type:2 val:91 old val:8d size:4
7 vaddr:88c66838 type:2 val:91 old val:6b size:4
8 vaddr:88c66858 type:2 val:91 old val:6b size:4
9 vaddr:88c66878 type:2 val:91 old val:6b size:4
command: writememory 0x874aac68 1000000

4、伊苏十 1.05

这个游戏有一些防修改与加密的措施,改存档会报错(估计是有crc校验一类),改内存里的数据好像也会(我在heap与main没搜到,alias段没试,后来在星之海洋里发现原来alias这个段好像也被游戏用来存数据了,有可能还是switch上游戏的惯用法)。

所以这个需要祭出神器ida pro来,与我们的改造相比,这才是真正的神器哈,怎么用我就不说了,这玩意不太简单呢。前面说过,柚子支持gdb server的调试协议,而ida pro就可以用gdb server协议来远程调试,最主要是它还支持arm指令的调试,这非常难得。

为了能调试,我们首先需要把xci或nsp里的main解出来,这个可以用(也是神器哈)

https://github.com/Myster-Tee/NxFileViewer ​github.com/Myster-Tee/NxFileViewer

然后,main要能用ida pro打开,还得把它变成elf文件(也有ida pro的插件能直接加载nso文件的),这个可以用这个工具(switch神器太多了啊)

https://archive.org/download/nx2elf2nso/nx2elf2nso.zip
​archive.org/download/nx2elf2nso/nx2elf2nso.zip

拿到main.elf文件后,我们就可以用ida pro打开,然后远程连接柚子模拟器调试了。

调试细节就不说了,这文章实在太长了。。

经过一番折腾后,我找到了几个修改点,然后写了前面的金手指工具来生成伊苏X 1.05的金手指,前面已经贴过了,别说你是跳到这的哈~

作为实例,我这里想说的是如何找到使用DLC物品加基础属性的代码点的,这里用的是前面打造的pc记录功能。

a、关闭模拟器的三个优化
先勾上这个
先勾上这个

然后关掉这三个
图片描述

b、运行游戏,然后打开背包,找到加基础属性比如体力的DLC物品(假设大家用的是带DLC的那个版本),先不要点确认。

c、执行pc记录的一些命令

1
2
clearpccount
startpccount

d、什么也不要做让游戏跑一会,然后输入命令

1
storepccount

把之前的pc记录暂存成一个参照

e、现在在游戏里点确认,然后输入命令

1
keepnewpccount

生成点确认后新记录的pc

f、输入命令保存结果,文件默认放在yuzu.exe所在目录(如果我们没有指明文件路径的话)

1
savepccount l.txt

h、关闭pc记录

1
2
stoppccount
clearpccount

这就完成了一次与操作相关的pc记录。

然后我们可以多记录几次,得到多个pc记录文件,之后我们可以用文本对比工具,得到多个pc记录里相同的部分,把它们拷出来。

现在需要在ida pro里做些处理了。

a、首先我们打开柚子的远程调试配置
勾上远程调试配置
图片描述

b、重启游戏,会等待连接调试器,我们连接上ida pro。

c、前面比对得到了几次记录的公共记录,我们把其中的地址拷出来,写到一个假设叫addbp.py的python文件,然后把每个地址变成一个python函数调用(地址是列对齐的,所以使用列编辑功能很容易修改,或者正则表达式替换):

1
2
3
4
5
6
7
8
9
10
11
add_bpt(0x80040da8)
add_bpt(0x80040f40)
add_bpt(0x8017bfb0)
add_bpt(0x8017bfe8)
add_bpt(0x8017ca14)
add_bpt(0x80278028)
add_bpt(0x80278040)
add_bpt(0x80278224)
add_bpt(0x80278248)
add_bpt(0x8027824c)
add_bpt(0x802782f0)

我实际得到的地址一共有500多个呢,不用脚本可能会有点难。

d、现在在ida pro里执行上面的addbp.py脚本,这些地址就都加上断点了。

e、现在我们来写一个ida pro的调试脚本,功能是在每个断点断下来后自动删除这个断点,假设保存成python脚本文件dbghook_remove_bp.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
"""
summary: programmatically drive a debugging session
 
description:
  Start a debugging session, step through the first five
  instructions. Each instruction is disassembled after
  execution.
"""
 
import ida_dbg
import ida_ida
import ida_lines
 
class MyDbgHook(ida_dbg.DBG_Hooks):
    """ Own debug hook class that implementd the callback functions """
 
    def __init__(self):
        ida_dbg.DBG_Hooks.__init__(self) # important
        self.steps = 0
 
    def log(self, msg):
        print(">>> %s" % msg)
 
    def dbg_process_start(self, pid, tid, ea, name, base, size):
        self.log("Process started, pid=%d tid=%d name=%s" % (pid, tid, name))
 
    def dbg_process_exit(self, pid, tid, ea, code):
        self.log("Process exited pid=%d tid=%d ea=0x%x code=%d" % (pid, tid, ea, code))
 
    def dbg_library_unload(self, pid, tid, ea, info):
        self.log("Library unloaded: pid=%d tid=%d ea=0x%x info=%s" % (pid, tid, ea, info))
 
    def dbg_process_attach(self, pid, tid, ea, name, base, size):
        self.log("Process attach pid=%d tid=%d ea=0x%x name=%s base=%x size=%x" % (pid, tid, ea, name, base, size))
 
    def dbg_process_detach(self, pid, tid, ea):
        self.log("Process detached, pid=%d tid=%d ea=0x%x" % (pid, tid, ea))
 
    def dbg_library_load(self, pid, tid, ea, name, base, size):
        self.log("Library loaded: pid=%d tid=%d name=%s base=%x" % (pid, tid, name, base))
 
    def dbg_bpt(self, tid, ea):
        self.log("Break point at 0x%x pid=%d" % (ea, tid))
        # return values:
        #   -1 - to display a breakpoint warning dialog
        #        if the process is suspended.
        #    0 - to never display a breakpoint warning dialog.
        #    1 - to always display a breakpoint warning dialog.
        del_bpt(ea)
        ida_dbg.continue_process()
        return 0
 
    def dbg_suspend_process(self):
        self.log("Process suspended")
 
    def dbg_exception(self, pid, tid, ea, exc_code, exc_can_cont, exc_ea, exc_info):
        self.log("Exception: pid=%d tid=%d ea=0x%x exc_code=0x%x can_continue=%d exc_ea=0x%x exc_info=%s" % (
            pid, tid, ea, exc_code & ida_idaapi.BADADDR, exc_can_cont, exc_ea, exc_info))
        # return values:
        #   -1 - to display an exception warning dialog
        #        if the process is suspended.
        #   0  - to never display an exception warning dialog.
        #   1  - to always display an exception warning dialog.
        return 0
 
    def dbg_trace(self, tid, ea):
        self.log("Trace tid=%d ea=0x%x" % (tid, ea))
        # return values:
        #   1  - do not log this trace event;
        #   0  - log it
        return 0
 
    def dbg_step_into(self):
        self.log("Step into")
 
    def dbg_run_to(self, pid, tid=0, ea=0):
        self.log("Runto: tid=%d, ea=%x" % (tid, ea))
 
    def dbg_step_over(self):
        pc = ida_dbg.get_reg_val("PC")
        disasm = ida_lines.tag_remove(
            ida_lines.generate_disasm_line(
                pc))
        self.log("Step over: PC=0x%x, disassembly=%s" % (pc, disasm))
 
# Remove an existing debug hook
try:
    if debughook:
        print("Removing previous hook ...")
        debughook.unhook()
except:
    pass
 
# Install the debug hook
debughook = MyDbgHook()
debughook.hook()

f、现在在ida pro里执行上面的脚本dbghook_remove_bp.py,然后继续运行游戏,并再次打开物品栏,并选中加体力的DLC物品,记得不要点确认。

g、现在让ida pro的脚本跑一会,等它不再断点的时候,我们在底部的python命令那输入下面脚本代码

1
debughook.unhook()

这个断点删除脚本就算结束了。

h、我们现在应该还剩余一些断点,我们再来写另一个ida pro的调试脚本,功能是执行到断点时记录一些断点的信息,假设保存为python文件dbghook_log_bp.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
"""
summary: programmatically drive a debugging session
 
description:
  Start a debugging session, step through the first five
  instructions. Each instruction is disassembled after
  execution.
"""
 
import ctypes
import ida_dbg
import ida_ida
import ida_lines
import ida_ieee
 
class MyDbgHook(ida_dbg.DBG_Hooks):
    """ Own debug hook class that implementd the callback functions """
 
    def __init__(self):
        ida_dbg.DBG_Hooks.__init__(self) # important
        self.steps = 0
 
    def log(self, msg):
        print(">>> %s" % msg)
 
    def dbg_process_start(self, pid, tid, ea, name, base, size):
        self.log("Process started, pid=%d tid=%d name=%s" % (pid, tid, name))
 
    def dbg_process_exit(self, pid, tid, ea, code):
        self.log("Process exited pid=%d tid=%d ea=0x%x code=%d" % (pid, tid, ea, code))
 
    def dbg_library_unload(self, pid, tid, ea, info):
        self.log("Library unloaded: pid=%d tid=%d ea=0x%x info=%s" % (pid, tid, ea, info))
 
    def dbg_process_attach(self, pid, tid, ea, name, base, size):
        self.log("Process attach pid=%d tid=%d ea=0x%x name=%s base=%x size=%x" % (pid, tid, ea, name, base, size))
 
    def dbg_process_detach(self, pid, tid, ea):
        self.log("Process detached, pid=%d tid=%d ea=0x%x" % (pid, tid, ea))
 
    def dbg_library_load(self, pid, tid, ea, name, base, size):
        self.log("Library loaded: pid=%d tid=%d name=%s base=%x" % (pid, tid, name, base))
 
    def dbg_bpt(self, tid, ea):
        self.log("Break point at 0x%x pid=%d" % (ea, tid))
        regvals = ida_dbg.get_reg_vals(tid)
        ix = 0
        for regval in regvals:
            f1 = 0.0
            f2 = 0.0
            f3 = 0.0
            f4 = 0.0
            num = regval.get_data_size()
            if num==16:
                bytes = regval.bytes()
                data = bytearray(bytes)
                fvals = struct.unpack("<4f", data)
                f1 = fvals[0]
                f2 = fvals[1]
                f3 = fvals[2]
                f4 = fvals[3]
            self.log("reg:%d type:%d ival:%x size:%u fval:%f %f %f %f" % (ix, regval.rvtype, regval.ival, regval.get_data_size(), f1, f2, f3, f4))
            ix += 1
        # return values:
        #   -1 - to display a breakpoint warning dialog
        #        if the process is suspended.
        #    0 - to never display a breakpoint warning dialog.
        #    1 - to always display a breakpoint warning dialog.
        ida_dbg.continue_process()
        return 0
 
    def dbg_suspend_process(self):
        self.log("Process suspended")
 
    def dbg_exception(self, pid, tid, ea, exc_code, exc_can_cont, exc_ea, exc_info):
        self.log("Exception: pid=%d tid=%d ea=0x%x exc_code=0x%x can_continue=%d exc_ea=0x%x exc_info=%s" % (
            pid, tid, ea, exc_code & ida_idaapi.BADADDR, exc_can_cont, exc_ea, exc_info))
        # return values:
        #   -1 - to display an exception warning dialog
        #        if the process is suspended.
        #   0  - to never display an exception warning dialog.
        #   1  - to always display an exception warning dialog.
        return 0
 
    def dbg_trace(self, tid, ea):
        self.log("Trace tid=%d ea=0x%x" % (tid, ea))
        # return values:
        #   1  - do not log this trace event;
        #   0  - log it
        return 0
 
    def dbg_step_into(self):
        self.log("Step into")
 
    def dbg_run_to(self, pid, tid=0, ea=0):
        self.log("Runto: tid=%d, ea=%x" % (tid, ea))
 
    def dbg_step_over(self):
        pc = ida_dbg.get_reg_val("PC")
        disasm = ida_lines.tag_remove(
            ida_lines.generate_disasm_line(
                pc))
        self.log("Step over: PC=0x%x, disassembly=%s" % (pc, disasm))
 
# Remove an existing debug hook
try:
    if debughook:
        print("Removing previous hook ...")
        debughook.unhook()
except:
    pass
 
# Install the debug hook
debughook = MyDbgHook()
debughook.hook()

i、我们现在在ida pro里强制断下游戏,然后运行上面的脚本dbghook_log_bp.py,再继续游戏并点游戏里使用DLC物品的确认,应该会看到有一些断点被断下又继续了

j、再次强制断下游戏,我们把ida pro里刚才脚本的输出拷出来,找个文本文件保存,看看有没有我们感兴趣的数据吧(刚才的DLC物品加基础属性,每次加3点),如果断点寄存器里有相关信息,那对应的断点地址应该是比较关键的地址了,那就去看这段代码就好。

k、如果没找到什么有用的信息,那我们看一下断点记录的第一个断点与最后一个断点,如果汇编代码不是特别多的话,我们可以使用ida pro的tracing功能,然后在第一个断点处开始trace,再次在游戏里操作一次。

l、然后把tracing的结果保存下来,仔细看看这部分吧,没别的技巧了,一般会有发现的。

我们在tracing记录里找找3.0之类的数据,然后看看它后面的代码,然后应该会看到加3的代码(不是直接加3,是加一个寄存器,而之前3赋给了寄存器),这就是我们要找的关键代码了,改一下这段逻辑就可以了。

这里我们找到的修改点有2个,就是我们之前金手指文件5个地址的后2个。

修改后,我们就得到了这样的游戏数值
属性等级

物品数量

======

终于写完了啊,长输一口气~


[培训]二进制漏洞攻防(第3期);满10人开班;模糊测试与工具使用二次开发;网络协议漏洞挖掘;Linux内核漏洞挖掘与利用;AOSP漏洞挖掘与利用;代码审计。

最后于 2024-3-18 20:16 被dreaman编辑 ,原因: 文字梳理
收藏
点赞6
打赏
分享
最新回复 (16)
雪    币: 10154
活跃值: (2185)
能力值: ( LV5,RANK:71 )
在线值:
发帖
回帖
粉丝
joker陈 2024-3-14 16:56
2
0
mark一下
雪    币: 19085
活跃值: (28674)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
秋狝 2024-3-15 13:43
3
1
感谢分享
雪    币: 3864
活跃值: (5488)
能力值: ( LV8,RANK:120 )
在线值:
发帖
回帖
粉丝
badboyl 2 2024-3-15 22:24
4
0
厉害
雪    币: 1412
活跃值: (4128)
能力值: ( LV13,RANK:240 )
在线值:
发帖
回帖
粉丝
IamHuskar 4 2024-3-16 10:05
5
0
arm 2 x86 做得比较好的似乎是 houdini? 这个开源工程可以学习学习
雪    币: 1412
活跃值: (4128)
能力值: ( LV13,RANK:240 )
在线值:
发帖
回帖
粉丝
IamHuskar 4 2024-3-16 10:27
6
0
奇怪的是任天堂为啥硬刚switch模拟器。gamecube/wii的dolphin的就不管了呢
雪    币: 1325
活跃值: (492)
能力值: ( LV12,RANK:450 )
在线值:
发帖
回帖
粉丝
dreaman 11 2024-3-16 11:38
7
0
IamHuskar 奇怪的是任天堂为啥硬刚switch模拟器。gamecube/wii的dolphin的就不管了呢
我觉得是因为steam推出的游戏主机,在宣传时贴上了yuzu的图标,任天堂可不管在PC上跑他们游戏,但现在有一个游戏机也要能跑他们游戏了,应该对他们是真正的威胁了
雪    币: 1325
活跃值: (492)
能力值: ( LV12,RANK:450 )
在线值:
发帖
回帖
粉丝
dreaman 11 2024-3-16 11:43
8
0
IamHuskar arm 2 x86 做得比较好的似乎是 houdini? 这个开源工程可以学习学习
intel houdini代码开源地址是啥啊
雪    币: 1306
活跃值: (130)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
ADFDRD 2024-3-16 12:49
9
0
这个玩的比较深了????单机游戏,娱乐为主,谢谢楼主分享经验
雪    币: 1412
活跃值: (4128)
能力值: ( LV13,RANK:240 )
在线值:
发帖
回帖
粉丝
IamHuskar 4 2024-3-16 15:07
10
0
dreaman intel houdini代码开源地址是啥啊
houdini不开源。主要是之前为了 x86上跑arm游戏 intel上海做的。
雪    币: 1325
活跃值: (492)
能力值: ( LV12,RANK:450 )
在线值:
发帖
回帖
粉丝
dreaman 11 2024-3-16 17:24
11
0
IamHuskar houdini不开源。主要是之前为了 x86上跑arm游戏 intel上海做的。
明白了
雪    币: 11893
活跃值: (8064)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
Genes 2024-3-16 21:22
12
0
大佬牛逼。。。
得亏出新闻的时候,clone了一份,还想着啥时候看看了。
雪    币: 2453
活跃值: (4131)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
小白养的菜鸡 2024-3-19 17:16
13
0
处于好奇,想问下为什么不再qemu-tcg的基础上改,而是自己写jit呢
雪    币: 1325
活跃值: (492)
能力值: ( LV12,RANK:450 )
在线值:
发帖
回帖
粉丝
dreaman 11 2024-3-19 20:49
14
0
小白养的菜鸡 处于好奇,想问下为什么不再qemu-tcg的基础上改,而是自己写jit呢
我也不太清楚真正原因。我猜可能是为了更可控吧,比如方便实现单步调试与内存调试功能。
雪    币: 2453
活跃值: (4131)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
小白养的菜鸡 2024-3-20 09:41
15
0
dreaman 我也不太清楚真正原因。我猜可能是为了更可控吧,比如方便实现单步调试与内存调试功能。
嗯嗯
雪    币: 1412
活跃值: (4128)
能力值: ( LV13,RANK:240 )
在线值:
发帖
回帖
粉丝
IamHuskar 4 2024-3-22 12:21
16
0
小白养的菜鸡 处于好奇,想问下为什么不再qemu-tcg的基础上改,而是自己写jit呢
qemu的tcg是通用的。通用性好,性能来说相对不行。所以模拟器都是自己写jit的。
雪    币: 2453
活跃值: (4131)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
小白养的菜鸡 2024-3-23 22:25
17
0
IamHuskar qemu的tcg是通用的。通用性好,性能来说相对不行。所以模拟器都是自己写jit的。
嗷嗷,了解了
游客
登录 | 注册 方可回帖
返回