diff --git a/UnleashedRecomp/CMakeLists.txt b/UnleashedRecomp/CMakeLists.txt index b8129743..e938a55b 100644 --- a/UnleashedRecomp/CMakeLists.txt +++ b/UnleashedRecomp/CMakeLists.txt @@ -107,6 +107,7 @@ elseif (ANDROID) set(UNLEASHED_RECOMP_OS_CXX_SOURCES "os/android/logger_android.cpp" "os/android/media_android.cpp" + "os/android/page_watch.cpp" "os/android/process_android.cpp" "os/android/storage_android.cpp" "os/android/user_android.cpp" diff --git a/UnleashedRecomp/cpu/guest_thread.cpp b/UnleashedRecomp/cpu/guest_thread.cpp index 3dbe9152..943107a9 100644 --- a/UnleashedRecomp/cpu/guest_thread.cpp +++ b/UnleashedRecomp/cpu/guest_thread.cpp @@ -3,22 +3,76 @@ #include #include #include +#include #include "ppc_context.h" constexpr size_t PCR_SIZE = 0xAB0; constexpr size_t TLS_SIZE = 0x100; constexpr size_t TEB_SIZE = 0x2E0; -constexpr size_t STACK_SIZE = 0x40000; -constexpr size_t TOTAL_SIZE = PCR_SIZE + TLS_SIZE + TEB_SIZE + STACK_SIZE; +constexpr size_t DEFAULT_STACK_SIZE = 0x40000; constexpr size_t TEB_OFFSET = PCR_SIZE + TLS_SIZE; -GuestThreadContext::GuestThreadContext(uint32_t cpuNumber) +// Written to the bottom (deepest addresses) of every guest stack; the watchdog and the +// thread's own teardown check it to catch silent stack overruns into the neighbouring +// heap allocations (issue #27 corruption pattern). +constexpr uint32_t STACK_CANARY_WORD = 0x57ACC0DE; +constexpr size_t STACK_CANARY_BYTES = 256; + +struct GuestStackRecord +{ + const uint8_t* canary; + uint32_t threadId; + bool reported; +}; + +static std::mutex g_guestStackMutex; +static std::vector g_guestStacks; + +static void FillStackCanary(uint8_t* canary) +{ + for (size_t i = 0; i < STACK_CANARY_BYTES; i += sizeof(uint32_t)) + *(uint32_t*)(canary + i) = STACK_CANARY_WORD; +} + +static bool IsStackCanaryIntact(const uint8_t* canary) +{ + for (size_t i = 0; i < STACK_CANARY_BYTES; i += sizeof(uint32_t)) + { + if (*(const uint32_t*)(canary + i) != STACK_CANARY_WORD) + return false; + } + return true; +} + +void GuestThread::CheckStackCanaries() +{ + std::lock_guard lock(g_guestStackMutex); + for (auto& record : g_guestStacks) + { + if (!record.reported && !IsStackCanaryIntact(record.canary)) + { + record.reported = true; + LOGF_ERROR("GUEST STACK OVERFLOW: thread {:X} overran its guest stack into the user heap.", + record.threadId); + } + } +} + +GuestThreadContext::GuestThreadContext(uint32_t cpuNumber, uint32_t stackSize) { assert(thread == nullptr); - thread = (uint8_t*)g_userHeap.Alloc(TOTAL_SIZE); - memset(thread, 0, TOTAL_SIZE); + // Honor the stack size the game requested through ExCreateThread (rounded up to + // 64 KiB); previously every thread got a fixed 256 KiB regardless of the request. + size_t guestStackSize = stackSize != 0 + ? (size_t(stackSize) + 0xFFFF) & ~size_t(0xFFFF) + : DEFAULT_STACK_SIZE; + guestStackSize = std::max(guestStackSize, size_t(DEFAULT_STACK_SIZE)); + + allocSize = PCR_SIZE + TLS_SIZE + TEB_SIZE + guestStackSize; + thread = (uint8_t*)g_userHeap.Alloc(allocSize); + memset(thread, 0, allocSize); *(uint32_t*)thread = ByteSwap(g_memory.MapVirtual(thread + PCR_SIZE)); // tls pointer *(uint32_t*)(thread + 0x100) = ByteSwap(g_memory.MapVirtual(thread + PCR_SIZE + TLS_SIZE)); // teb pointer @@ -27,16 +81,35 @@ GuestThreadContext::GuestThreadContext(uint32_t cpuNumber) *(uint32_t*)(thread + PCR_SIZE + 0x10) = 0xFFFFFFFF; // that one TLS entry that felt quirky *(uint32_t*)(thread + PCR_SIZE + TLS_SIZE + 0x14C) = ByteSwap(GuestThread::GetCurrentThreadId()); // thread id - ppcContext.r1.u64 = g_memory.MapVirtual(thread + PCR_SIZE + TLS_SIZE + TEB_SIZE + STACK_SIZE); // stack pointer + ppcContext.r1.u64 = g_memory.MapVirtual(thread + allocSize); // stack pointer ppcContext.r13.u64 = g_memory.MapVirtual(thread); ppcContext.fpscr.loadFromHost(); + uint8_t* canary = thread + PCR_SIZE + TLS_SIZE + TEB_SIZE; + FillStackCanary(canary); + { + std::lock_guard lock(g_guestStackMutex); + g_guestStacks.push_back({ canary, GuestThread::GetCurrentThreadId(), false }); + } + assert(GetPPCContext() == nullptr); SetPPCContext(ppcContext); } GuestThreadContext::~GuestThreadContext() { + const uint8_t* canary = thread + PCR_SIZE + TLS_SIZE + TEB_SIZE; + if (!IsStackCanaryIntact(canary)) + { + LOGF_ERROR("GUEST STACK OVERFLOW: thread {:X} overran its {} KiB guest stack (detected at exit).", + GuestThread::GetCurrentThreadId(), (allocSize - PCR_SIZE - TLS_SIZE - TEB_SIZE) / 1024); + } + + { + std::lock_guard lock(g_guestStackMutex); + std::erase_if(g_guestStacks, [&](const GuestStackRecord& r) { return r.canary == canary; }); + } + g_userHeap.Free(thread); } @@ -144,7 +217,7 @@ uint32_t GuestThread::Start(const GuestThreadParams& params) const auto procMask = (uint8_t)(params.flags >> 24); const auto cpuNumber = procMask == 0 ? 0 : 7 - std::countl_zero(procMask); - GuestThreadContext ctx(cpuNumber); + GuestThreadContext ctx(cpuNumber, params.stackSize); ctx.ppcContext.r3.u64 = params.value; g_memory.FindFunction(params.function)(ctx.ppcContext, g_memory.base); diff --git a/UnleashedRecomp/cpu/guest_thread.h b/UnleashedRecomp/cpu/guest_thread.h index e12e91a4..c723e303 100644 --- a/UnleashedRecomp/cpu/guest_thread.h +++ b/UnleashedRecomp/cpu/guest_thread.h @@ -17,8 +17,9 @@ struct GuestThreadContext { PPCContext ppcContext{}; uint8_t* thread = nullptr; + size_t allocSize = 0; - GuestThreadContext(uint32_t cpuNumber); + GuestThreadContext(uint32_t cpuNumber, uint32_t stackSize = 0); ~GuestThreadContext(); }; @@ -27,6 +28,11 @@ struct GuestThreadParams uint32_t function; uint32_t value; uint32_t flags; + // Guest stack size requested through ExCreateThread; 0 selects the default. + // Ignoring it used to force every thread onto a fixed 256 KiB stack carved + // from the shared user heap, so any deeper thread silently overran into + // neighbouring allocations (issue #27). + uint32_t stackSize = 0; }; struct GuestThreadHandle : KernelObject @@ -55,6 +61,10 @@ struct GuestThread static uint32_t GetCurrentThreadId(); static void SetLastError(uint32_t error); + // Logs an error for every live guest stack whose bottom canary was overwritten. + // Called periodically by the Android log watchdog. + static void CheckStackCanaries(); + #ifdef _WIN32 static void SetThreadName(uint32_t threadId, const char* name); #endif diff --git a/UnleashedRecomp/gpu/video.cpp b/UnleashedRecomp/gpu/video.cpp index edc429f3..be6f6f68 100644 --- a/UnleashedRecomp/gpu/video.cpp +++ b/UnleashedRecomp/gpu/video.cpp @@ -308,6 +308,150 @@ static uint32_t g_statPipelineBinds; static uint32_t g_statBarrierCalls; static uint32_t g_statFramebufferSets; +#if defined(__ANDROID__) +// Periodic, machine-readable summaries for driver A/B tests. The on-screen profiler is +// useful while playing, but screenshots cannot distinguish a GPU regression from fence, +// acquire, present, or CPU pressure and do not preserve tail latency. Keep the hot path to +// a few appends and emit/sort only once per five-second window. +struct AndroidPerformanceSample +{ + double applicationMs; + double gpuMs; + double presentMs; + double frameFenceMs; + double presentWaitMs; + double acquireMs; + uint32_t drawCalls; + uint32_t renderPasses; + uint32_t pipelineBinds; + uint32_t barriers; + uint32_t framebufferSets; +}; + +static std::vector g_androidPerformanceSamples; +static std::chrono::steady_clock::time_point g_androidPerformanceWindowStart; +static std::chrono::nanoseconds g_androidPerformanceCpuStart; +static uint64_t g_androidPerformanceWindowIndex; + +static std::chrono::nanoseconds GetProcessCpuTime() +{ + timespec value{}; + clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &value); + return std::chrono::seconds(value.tv_sec) + std::chrono::nanoseconds(value.tv_nsec); +} + +template +static double PerformancePercentile(const std::vector& samples, + double percentile, Getter getter) +{ + std::vector values; + values.reserve(samples.size()); + for (const AndroidPerformanceSample& sample : samples) + values.push_back(getter(sample)); + + std::sort(values.begin(), values.end()); + const size_t index = size_t(std::ceil(percentile * double(values.size() - 1))); + return values[index]; +} + +static void LogAndroidPerformanceSample(uint32_t swapWidth, uint32_t swapHeight) +{ + const auto now = std::chrono::steady_clock::now(); + if (g_androidPerformanceSamples.empty()) + { + g_androidPerformanceSamples.reserve(1024); + g_androidPerformanceWindowStart = now; + g_androidPerformanceCpuStart = GetProcessCpuTime(); + } + + g_androidPerformanceSamples.push_back({ + App::s_deltaTime * 1000.0, + g_gpuFrameProfiler.value.load(), + g_presentProfiler.value.load(), + g_frameFenceProfiler.value.load(), + g_presentWaitProfiler.value.load(), + g_swapChainAcquireProfiler.value.load(), + g_statDrawCalls, + g_statRenderPassBegins, + g_statPipelineBinds, + g_statBarrierCalls, + g_statFramebufferSets, + }); + + const double windowMs = std::chrono::duration(now - g_androidPerformanceWindowStart).count(); + if (windowMs < 5000.0) + return; + + const auto app = [](const AndroidPerformanceSample& value) { return value.applicationMs; }; + const auto gpu = [](const AndroidPerformanceSample& value) { return value.gpuMs; }; + const auto present = [](const AndroidPerformanceSample& value) { return value.presentMs; }; + double appTotal = 0.0; + double gpuTotal = 0.0; + double presentTotal = 0.0; + double fenceTotal = 0.0; + double presentWaitTotal = 0.0; + double acquireTotal = 0.0; + uint64_t drawTotal = 0; + uint64_t renderPassTotal = 0; + uint64_t pipelineBindTotal = 0; + uint64_t barrierTotal = 0; + uint64_t framebufferSetTotal = 0; + uint32_t over16 = 0; + uint32_t over33 = 0; + uint32_t over50 = 0; + + for (const AndroidPerformanceSample& sample : g_androidPerformanceSamples) + { + appTotal += sample.applicationMs; + gpuTotal += sample.gpuMs; + presentTotal += sample.presentMs; + fenceTotal += sample.frameFenceMs; + presentWaitTotal += sample.presentWaitMs; + acquireTotal += sample.acquireMs; + drawTotal += sample.drawCalls; + renderPassTotal += sample.renderPasses; + pipelineBindTotal += sample.pipelineBinds; + barrierTotal += sample.barriers; + framebufferSetTotal += sample.framebufferSets; + over16 += sample.applicationMs > (1000.0 / 60.0); + over33 += sample.applicationMs > (1000.0 / 30.0); + over50 += sample.applicationMs > 50.0; + } + + const double count = double(g_androidPerformanceSamples.size()); + const double cpuMs = std::chrono::duration(GetProcessCpuTime() - g_androidPerformanceCpuStart).count(); + LOGF("PERF window={} duration_ms={:.1f} frames={} fps={:.2f} cpu_core_pct={:.1f} " + "app_ms(avg/p50/p95/p99/max)={:.3f}/{:.3f}/{:.3f}/{:.3f}/{:.3f} " + "gpu_ms(avg/p50/p95/p99/max)={:.3f}/{:.3f}/{:.3f}/{:.3f}/{:.3f} " + "present_ms(avg/p95/max)={:.3f}/{:.3f}/{:.3f} " + "wait_ms(fence/present/acquire)={:.3f}/{:.3f}/{:.3f} " + "stutter_frames(>16.67/>33.33/>50)={}/{}/{} " + "work_per_frame(draws/passes/pipelines/barriers/framebuffers)={:.1f}/{:.1f}/{:.1f}/{:.1f}/{:.1f} " + "swapchain={}x{} config(fps/vsync)={}/{}", + g_androidPerformanceWindowIndex++, windowMs, g_androidPerformanceSamples.size(), + count * 1000.0 / windowMs, cpuMs * 100.0 / windowMs, + appTotal / count, PerformancePercentile(g_androidPerformanceSamples, 0.50, app), + PerformancePercentile(g_androidPerformanceSamples, 0.95, app), + PerformancePercentile(g_androidPerformanceSamples, 0.99, app), + PerformancePercentile(g_androidPerformanceSamples, 1.00, app), + gpuTotal / count, PerformancePercentile(g_androidPerformanceSamples, 0.50, gpu), + PerformancePercentile(g_androidPerformanceSamples, 0.95, gpu), + PerformancePercentile(g_androidPerformanceSamples, 0.99, gpu), + PerformancePercentile(g_androidPerformanceSamples, 1.00, gpu), + presentTotal / count, PerformancePercentile(g_androidPerformanceSamples, 0.95, present), + PerformancePercentile(g_androidPerformanceSamples, 1.00, present), + fenceTotal / count, presentWaitTotal / count, acquireTotal / count, + over16, over33, over50, + double(drawTotal) / count, double(renderPassTotal) / count, double(pipelineBindTotal) / count, + double(barrierTotal) / count, double(framebufferSetTotal) / count, + swapWidth, swapHeight, Config::FPS.Value, Config::VSync.Value); + + g_androidPerformanceSamples.clear(); + g_androidPerformanceWindowStart = now; + g_androidPerformanceCpuStart = GetProcessCpuTime(); +} +#endif + // On Android there is no F1 key: the initial state comes from Config::ShowProfiler // (launcher checkbox), and closing the overlay writes the config back so it stays // closed on the next launch (issue #46). See DrawProfiler. @@ -3092,6 +3236,12 @@ void Video::Present() } g_presentProfiler.Reset(); + +#if defined(__ANDROID__) + LogAndroidPerformanceSample( + g_swapChain ? g_swapChain->getWidth() : 0, + g_swapChain ? g_swapChain->getHeight() : 0); +#endif } void Video::StartPipelinePrecompilation() @@ -6098,13 +6248,30 @@ static bool LoadTexture(GuestTexture& texture, const uint8_t* data, size_t dataS { forceCubeMap &= (ddsDesc.type == ddspp::Texture2D) && (ddsDesc.arraySize == 1); uint32_t arraySize = ddsDesc.type == ddspp::TextureType::Cubemap ? (ddsDesc.arraySize * 6) : ddsDesc.arraySize; - + + // Texture Quality: serve the texture from further down its own mip chain + // (the DDS already contains the smaller levels, so this only skips data). + // Mipless textures (UI, fonts) and anything that would drop below 32px + // stay untouched, so HUD art and small detail maps keep their pixels. + uint32_t mipSkip = 0; + if (Config::TextureQuality != ETextureQuality::Full && ddsDesc.numMips > 1) + { + mipSkip = Config::TextureQuality == ETextureQuality::Quarter ? 2u : 1u; + while (mipSkip > 0 && + (mipSkip >= ddsDesc.numMips || + std::max(ddsDesc.width >> mipSkip, ddsDesc.height >> mipSkip) < 32)) + { + mipSkip--; + } + } + const uint32_t textureMips = ddsDesc.numMips - mipSkip; + RenderTextureDesc desc; desc.dimension = ConvertTextureDimension(ddsDesc.type); - desc.width = ddsDesc.width; - desc.height = ddsDesc.height; - desc.depth = ddsDesc.depth; - desc.mipLevels = ddsDesc.numMips; + desc.width = std::max(1u, ddsDesc.width >> mipSkip); + desc.height = std::max(1u, ddsDesc.height >> mipSkip); + desc.depth = std::max(1u, ddsDesc.type == ddspp::Texture3D ? ddsDesc.depth >> mipSkip : ddsDesc.depth); + desc.mipLevels = textureMips; desc.arraySize = arraySize; desc.format = ConvertDXGIFormat(ddsDesc.format); desc.flags = ddsDesc.type == ddspp::TextureType::Cubemap ? RenderTextureFlag::CUBE : RenderTextureFlag::NONE; @@ -6134,7 +6301,7 @@ static bool LoadTexture(GuestTexture& texture, const uint8_t* data, size_t dataS RenderTextureViewDesc viewDesc; viewDesc.format = desc.format; viewDesc.dimension = ConvertTextureViewDimension(ddsDesc.type); - viewDesc.mipLevels = ddsDesc.numMips; + viewDesc.mipLevels = textureMips; viewDesc.componentMapping = componentMapping; if (forceCubeMap) @@ -6144,8 +6311,8 @@ static bool LoadTexture(GuestTexture& texture, const uint8_t* data, size_t dataS texture.descriptorIndex = g_textureDescriptorAllocator.allocate(); g_textureDescriptorSet->setTexture(texture.descriptorIndex, texture.texture, RenderTextureLayout::SHADER_READ, texture.textureView.get()); - texture.width = ddsDesc.width; - texture.height = ddsDesc.height; + texture.width = desc.width; + texture.height = desc.height; texture.viewDimension = viewDesc.dimension; struct Slice @@ -6169,7 +6336,7 @@ static bool LoadTexture(GuestTexture& texture, const uint8_t* data, size_t dataS { for (uint32_t mipSlice = 0; mipSlice < ddsDesc.numMips; mipSlice++) { - auto& slice = slices.emplace_back(); + Slice slice; slice.width = std::max(1u, ddsDesc.width >> mipSlice); slice.height = std::max(1u, ddsDesc.height >> mipSlice); @@ -6198,8 +6365,14 @@ static bool LoadTexture(GuestTexture& texture, const uint8_t* data, size_t dataS slice.dstRowCount = slice.rowCount; } + // Source data always advances over every mip in the file; skipped + // top mips are simply never uploaded. curSrcOffset += slice.srcRowPitch * slice.rowCount * slice.depth; + if (mipSlice < mipSkip) + continue; + curDstOffset += (slice.dstRowPitch * slice.dstRowCount * slice.depth + PLACEMENT_ALIGNMENT - 1) & ~(PLACEMENT_ALIGNMENT - 1); + slices.push_back(slice); } } @@ -6290,7 +6463,7 @@ static bool LoadTexture(GuestTexture& texture, const uint8_t* data, size_t dataS footprintRowWidth = (slice.dstRowPitch * 8) / ddsDesc.bitsPerPixelOrBlock * ddsDesc.blockWidth; g_copyCommandList->copyTextureRegion( - RenderTextureCopyLocation::Subresource(texture.texture, subresourceIndex % ddsDesc.numMips, subresourceIndex / ddsDesc.numMips), + RenderTextureCopyLocation::Subresource(texture.texture, subresourceIndex % textureMips, subresourceIndex / textureMips), RenderTextureCopyLocation::PlacedFootprint(uploadBuffer.get(), desc.format, slice.width, slice.height, slice.depth, footprintRowWidth, slice.dstOffset)); }; diff --git a/UnleashedRecomp/kernel/heap.cpp b/UnleashedRecomp/kernel/heap.cpp index 11753c23..f446f248 100644 --- a/UnleashedRecomp/kernel/heap.cpp +++ b/UnleashedRecomp/kernel/heap.cpp @@ -3,6 +3,8 @@ #include "memory.h" #include "function.h" +#include + constexpr size_t RESERVED_BEGIN = 0x7FEA0000; constexpr size_t RESERVED_END = 0xA0000000; @@ -45,7 +47,35 @@ void Heap::Free(void* ptr) else { std::lock_guard lock(mutex); +#ifdef __ANDROID__ + // issue #27: the game's teardown races leave stale guest pointers into + // RtlAllocateHeap memory too, not only into the small-block allocator. + // A stale write into a freed-and-reused user-heap block corrupts o1heap + // fragment headers, crashing a later o1heapAllocate on a garbage next + // pointer. Defer the real free (contents intact, no poison - o1heap + // only writes its metadata once the fragment is actually freed) so the + // Xbox 360's memory-stays-intact-until-reuse behaviour holds here as + // well. Bounded by entries and bytes; oldest frees complete first. + static constexpr size_t DEFER_CAP = 4096; + static constexpr size_t DEFER_MAX_BYTES = 16 * 1024 * 1024; + struct DeferredFree { void* ptr; size_t size; }; + static std::deque s_deferred; // guarded by `mutex` + static size_t s_deferredBytes = 0; + + size_t size = Size(ptr); + while (!s_deferred.empty() && + (s_deferred.size() >= DEFER_CAP || s_deferredBytes + size > DEFER_MAX_BYTES)) + { + o1heapFree(heap, s_deferred.front().ptr); + s_deferredBytes -= s_deferred.front().size; + s_deferred.pop_front(); + } + + s_deferred.push_back({ ptr, size }); + s_deferredBytes += size; +#else o1heapFree(heap, ptr); +#endif } } diff --git a/UnleashedRecomp/kernel/imports.cpp b/UnleashedRecomp/kernel/imports.cpp index 58b33fab..248f2b08 100644 --- a/UnleashedRecomp/kernel/imports.cpp +++ b/UnleashedRecomp/kernel/imports.cpp @@ -726,7 +726,18 @@ void RtlFillMemoryUlong() void KeBugCheckEx() { +#ifdef __ANDROID__ + // The game's CRT assert handler funnels here (issue #27): with destroyed + // animation nodes skipped by the guards, the pose validator notices the + // incomplete output and asserts. The guest code restores its stack and + // returns normally when the bugcheck comes back, so surviving it costs one + // imperfect pose frame instead of the process. Kept fatal on desktop. + static std::atomic s_reportCount{ 0 }; + if (s_reportCount.fetch_add(1, std::memory_order_relaxed) < 16) + LOG_ERROR("KeBugCheckEx reached; continuing (guest assert survived)."); +#else __builtin_debugtrap(); +#endif } uint32_t KeGetCurrentProcessType() @@ -982,7 +993,14 @@ void VdEnableDisableClockGating() void KeBugCheck() { +#ifdef __ANDROID__ + // See KeBugCheckEx: the guest assert path survives a returning bugcheck. + static std::atomic s_reportCount{ 0 }; + if (s_reportCount.fetch_add(1, std::memory_order_relaxed) < 16) + LOG_ERROR("KeBugCheck reached; continuing (guest assert survived)."); +#else __builtin_debugtrap(); +#endif } void KeLockL2() @@ -1421,7 +1439,7 @@ uint32_t ExCreateThread(be* handle, uint32_t stackSize, be* uint32_t hostThreadId; - *handle = GetKernelHandle(GuestThread::Start({ startAddress, startContext, creationFlags }, &hostThreadId)); + *handle = GetKernelHandle(GuestThread::Start({ startAddress, startContext, creationFlags, stackSize }, &hostThreadId)); if (threadId != nullptr) *threadId = hostThreadId; diff --git a/UnleashedRecomp/kernel/memory.cpp b/UnleashedRecomp/kernel/memory.cpp index e348ab12..277ecf8c 100644 --- a/UnleashedRecomp/kernel/memory.cpp +++ b/UnleashedRecomp/kernel/memory.cpp @@ -33,6 +33,21 @@ Memory::Memory() // reads are benign (zeros) with no visual artifacts, while faulting made the // game unplayable there. Indirect calls fetched through such zeros are guarded // separately in ppc_detail.h. Desktop builds keep the trap to catch new bugs. + + // Absorber region past the arena end. Destructors of the issue #27 objects + // leave 0xFFFFFFFF in pointer fields; guest code that races object teardown + // dereferences them with positive offsets, and recompiled 64-bit address + // arithmetic (base + 0xFFFFFFFF + offset) lands past the 4GB arena instead + // of wrapping like the 360 did. Map 16MB of anonymous RW right after the + // arena so those accesses read zeros / scribble into a scratch area instead + // of faulting; the pages cost nothing until touched. + if (base != nullptr) + { + void* absorber = mmap(base + PPC_MEMORY_SIZE, 16ull * 1024 * 1024, + PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE | MAP_FIXED_NOREPLACE, -1, 0); + if (absorber == MAP_FAILED) + LOG_ERROR("Failed to map the guest arena absorber region; -1-based accesses will fault."); + } #else mprotect(base, 4096, PROT_NONE); #endif diff --git a/UnleashedRecomp/locale/config_locale.cpp b/UnleashedRecomp/locale/config_locale.cpp index 6b618537..925308f5 100644 --- a/UnleashedRecomp/locale/config_locale.cpp +++ b/UnleashedRecomp/locale/config_locale.cpp @@ -784,6 +784,78 @@ CONFIG_DEFINE_ENUM_LOCALE(EShadowResolution) } }; +CONFIG_DEFINE_LOCALE(PlanarReflections) +{ + { ELanguage::English, { "Planar Reflections", "Render real-time reflections on reflective surfaces. Turning this off skips a whole scene render pass on stages that use them." } }, + { ELanguage::Japanese, { "[平面:へいめん]リフレクション", "[反射面:はんしゃめん]のリアルタイム[反射:はんしゃ]を​レンダリングします" } }, + { ELanguage::German, { "Planare Reflexionen", "Rendere Echtzeit-Reflexionen auf spiegelnden Oberflächen. Das Deaktivieren spart einen ganzen Szenen-Renderdurchlauf auf Leveln, die sie verwenden." } }, + { ELanguage::French, { "Réflexions planaires", "Affiche des réflexions en temps réel sur les surfaces réfléchissantes. Désactiver cette option évite tout un rendu de scène sur les niveaux qui les utilisent." } }, + { ELanguage::Spanish, { "Reflejos planares", "Renderiza reflejos en tiempo real en las superficies reflectantes. Desactivarlo ahorra una pasada completa de renderizado en las fases que los usan." } }, + { ELanguage::Italian, { "Riflessi planari", "Renderizza riflessi in tempo reale sulle superfici riflettenti. Disattivandolo si salta un\'intera passata di rendering nei livelli che li usano." } } +}; + +CONFIG_DEFINE_LOCALE(TextureQuality) +{ + { ELanguage::English, { "Texture Quality", "Reduce the resolution of game textures to save GPU memory and bandwidth. Applies to textures as they load; restart the stage to see the change." } }, + { ELanguage::Japanese, { "テクスチャ[品質:ひんしつ]", "テクスチャの[解像度:かいぞうど]を​[下:さ]げて​GPUメモリと​[帯域:たいいき]を​[節約:せつやく]します" } }, + { ELanguage::German, { "Texturqualität", "Verringere die Auflösung der Spieltexturen, um GPU-Speicher und Bandbreite zu sparen. Gilt beim Laden; starte den Level neu, um die Änderung zu sehen." } }, + { ELanguage::French, { "Qualité des textures", "Réduit la résolution des textures du jeu pour économiser la mémoire et la bande passante du GPU. S'applique au chargement ; relancez le niveau pour voir le changement." } }, + { ELanguage::Spanish, { "Calidad de texturas", "Reduce la resolución de las texturas del juego para ahorrar memoria y ancho de banda de la GPU. Se aplica al cargar; reinicia la fase para ver el cambio." } }, + { ELanguage::Italian, { "Qualità delle texture", "Riduce la risoluzione delle texture del gioco per risparmiare memoria e banda della GPU. Si applica al caricamento; riavvia il livello per vedere la modifica." } } +}; + +CONFIG_DEFINE_ENUM_LOCALE(ETextureQuality) +{ + { + ELanguage::English, + { + { ETextureQuality::Full, { "FULL", "Full: original texture resolution." } }, + { ETextureQuality::Half, { "HALF", "Half: skips the top mip level of every texture, like a half-resolution texture pack, but covering the base game, DLC and mods." } }, + { ETextureQuality::Quarter, { "QUARTER", "Quarter: skips the top two mip levels of every texture. Lowest memory and bandwidth use." } } + } + }, + { + ELanguage::Japanese, + { + { ETextureQuality::Full, { "フル", "フル: オリジナルの解像度" } }, + { ETextureQuality::Half, { "ハーフ", "ハーフ: 各テクスチャの最上位ミップを省略します" } }, + { ETextureQuality::Quarter, { "クォーター", "クォーター: 上位2つのミップを省略します" } } + } + }, + { + ELanguage::German, + { + { ETextureQuality::Full, { "VOLL", "Voll: originale Texturauflösung." } }, + { ETextureQuality::Half, { "HALB", "Halb: überspringt die oberste Mip-Stufe jeder Textur — wie ein Texturpaket in halber Auflösung, deckt aber Spiel, DLC und Mods ab." } }, + { ETextureQuality::Quarter, { "VIERTEL", "Viertel: überspringt die obersten zwei Mip-Stufen. Geringster Speicher- und Bandbreitenverbrauch." } } + } + }, + { + ELanguage::French, + { + { ETextureQuality::Full, { "COMPLÈTE", "Complète : résolution d'origine des textures." } }, + { ETextureQuality::Half, { "MOITIÉ", "Moitié : ignore le niveau de mip supérieur de chaque texture, comme un pack de textures en demi-résolution couvrant le jeu, les DLC et les mods." } }, + { ETextureQuality::Quarter, { "QUART", "Quart : ignore les deux niveaux de mip supérieurs. Consommation mémoire et bande passante minimales." } } + } + }, + { + ELanguage::Spanish, + { + { ETextureQuality::Full, { "COMPLETA", "Completa: resolución original de las texturas." } }, + { ETextureQuality::Half, { "MEDIA", "Media: omite el nivel de mip superior de cada textura, como un paquete de texturas a media resolución que cubre el juego, los DLC y los mods." } }, + { ETextureQuality::Quarter, { "CUARTO", "Cuarto: omite los dos niveles de mip superiores. Mínimo uso de memoria y ancho de banda." } } + } + }, + { + ELanguage::Italian, + { + { ETextureQuality::Full, { "COMPLETA", "Completa: risoluzione originale delle texture." } }, + { ETextureQuality::Half, { "METÀ", "Metà: salta il livello mip superiore di ogni texture, come un pacchetto di texture a metà risoluzione che copre gioco, DLC e mod." } }, + { ETextureQuality::Quarter, { "QUARTO", "Quarto: salta i due livelli mip superiori. Consumo minimo di memoria e banda." } } + } + } +}; + // Japanese Notes: This localization should include furigana. CONFIG_DEFINE_LOCALE(GITextureFiltering) { diff --git a/UnleashedRecomp/main.cpp b/UnleashedRecomp/main.cpp index a3107ee7..9d2b3463 100644 --- a/UnleashedRecomp/main.cpp +++ b/UnleashedRecomp/main.cpp @@ -326,6 +326,31 @@ int main(int argc, char *argv[]) HostStartup(); +#ifdef __ANDROID__ + // The desktop installer wizard produces patched/default.xex; the Android + // launcher's installer only copies files the user points it at. When a raw + // dump (game/ + update/, no patched/) was copied, produce the patched + // executable here so the install becomes bootable without a PC. + { + std::filesystem::path root = GetGamePath(); + std::filesystem::path baseXex = root / "game" / "default.xex"; + std::filesystem::path updateXexp = root / "update" / "default.xexp"; + std::filesystem::path patchedXex = root / "patched" / "default.xex"; + std::error_code patchEc; + if (!std::filesystem::exists(patchedXex, patchEc) && + std::filesystem::exists(baseXex, patchEc) && + std::filesystem::exists(updateXexp, patchEc)) + { + std::filesystem::create_directories(root / "patched", patchEc); + XexPatcher::Result patchResult = XexPatcher::apply(baseXex, updateXexp, patchedXex); + if (patchResult == XexPatcher::Result::Success) + LOG("Created patched/default.xex from the raw game dump."); + else + LOGFN_ERROR("Failed to create patched/default.xex from the raw dump (XexPatcher result {}).", int(patchResult)); + } + } +#endif + std::filesystem::path modulePath; bool isGameInstalled = Installer::checkGameInstall(GetGamePath(), modulePath); bool runInstallerWizard = forceInstaller || forceDLCInstaller || !isGameInstalled; diff --git a/UnleashedRecomp/os/android/logger_android.cpp b/UnleashedRecomp/os/android/logger_android.cpp index fa438724..c0344431 100644 --- a/UnleashedRecomp/os/android/logger_android.cpp +++ b/UnleashedRecomp/os/android/logger_android.cpp @@ -1,5 +1,7 @@ #include +#include +#include #include #include @@ -231,6 +233,9 @@ static void* WatchdogThread(void*) if (s_watchdogSuspended.load(std::memory_order_relaxed)) continue; + // Catch guest stacks that silently overran into the user heap (issue #27). + GuestThread::CheckStackCanaries(); + const double now = MonotonicSeconds() - s_startSeconds; const double last = s_lastHeartbeat.load(std::memory_order_relaxed); const uint64_t frames = s_frameCount.load(std::memory_order_relaxed); @@ -355,6 +360,20 @@ static void CrashWriteAddress(int fd, const char* label, uint64_t address) static void CrashSignalHandler(int signal, siginfo_t* info, void* contextPtr) { +#if defined(__aarch64__) + // Software watchpoint (issue #27): a write fault on the armed page is logged + // with the writer's pc and execution continues; it is not a crash. + if (signal == SIGSEGV && info != nullptr && contextPtr != nullptr) + { + const ucontext_t* watchContext = static_cast(contextPtr); + if (os::android::PageWatchHandleFault(info->si_addr, + watchContext->uc_mcontext.pc, watchContext->uc_mcontext.regs[30], GetTid())) + { + return; + } + } +#endif + const int fd = s_logRawFd.load(std::memory_order_acquire); if (fd >= 0) { @@ -486,8 +505,8 @@ void os::logger::Init() // Create log.txt promptly (and roll the previous one) so a tester always finds a // fresh file, even if this run happens to log nothing else before a freeze. WriteLogRecord("[logger]", nullptr, "Unleashed Recomp log started", 28); - static constexpr char BuildVersion[] = "=== APK VERSION: 0.4.0 (2026-07-12) ==="; - static constexpr char BuildId[] = "ANDROID_BUILD_ID=0.4.0-release"; + static constexpr char BuildVersion[] = "=== APK VERSION: 1.5.1-roadmap-v37 (2026-07-12) ==="; + static constexpr char BuildId[] = "ANDROID_BUILD_ID=1.5.1-roadmap-v37-diag24-agentremove"; WriteLogRecord("[build]", nullptr, BuildVersion, sizeof(BuildVersion) - 1); WriteLogRecord("[build]", nullptr, BuildId, sizeof(BuildId) - 1); LogDeviceInfo(); diff --git a/UnleashedRecomp/os/android/page_watch.cpp b/UnleashedRecomp/os/android/page_watch.cpp new file mode 100644 index 00000000..e1f0bd71 --- /dev/null +++ b/UnleashedRecomp/os/android/page_watch.cpp @@ -0,0 +1,105 @@ +#include "page_watch.h" + +#include + +#include +#include +#include +#include +#include +#include + +// The armed page address (page-aligned) and the exact watched field. Zero = disarmed. +// The crash handler reads these lock-free; arming uses a CAS so only one page is +// watched at a time. +static std::atomic s_watchPage{ 0 }; +static std::atomic s_watchExact{ 0 }; +static std::atomic s_hitCount{ 0 }; +static std::atomic s_armCount{ 0 }; + +static constexpr uint32_t MAX_LOGGED_HITS = 64; +static constexpr uint32_t MAX_ARMS = 512; + +// libmain.so load base, cached in normal context so the signal handler can log +// module-relative pcs without calling dladdr (not async-signal-safe). +static std::atomic s_moduleBase{ 0 }; + +namespace os::android +{ + +void ArmPageWatch(void* hostAddress, void* exactAddress) +{ + if (s_armCount.fetch_add(1, std::memory_order_relaxed) >= MAX_ARMS) + return; + + if (s_moduleBase.load(std::memory_order_relaxed) == 0) + { + Dl_info info{}; + if (dladdr(reinterpret_cast(&ArmPageWatch), &info) != 0) + s_moduleBase.store(reinterpret_cast(info.dli_fbase), std::memory_order_relaxed); + } + + const uintptr_t page = reinterpret_cast(hostAddress) & ~uintptr_t(4095); + + uintptr_t expected = s_watchPage.load(std::memory_order_relaxed); + if (expected == page) + { + // Same page: refresh protection (a hit may have restored RW). + s_watchExact.store(reinterpret_cast(exactAddress), std::memory_order_relaxed); + mprotect(reinterpret_cast(page), 4096, PROT_READ); + return; + } + + if (expected != 0) + return; // another page is being watched; keep it + + if (!s_watchPage.compare_exchange_strong(expected, page, std::memory_order_acq_rel)) + return; + + s_watchExact.store(reinterpret_cast(exactAddress), std::memory_order_relaxed); + if (mprotect(reinterpret_cast(page), 4096, PROT_READ) == 0) + { + LOGF_ERROR("PageWatch: armed write trap on host page {:X} (exact field {:X}).", + page, reinterpret_cast(exactAddress)); + } + else + { + s_watchPage.store(0, std::memory_order_release); + } +} + +bool PageWatchHandleFault(void* faultAddress, uint64_t pc, uint64_t lr, int tid) +{ + const uintptr_t page = s_watchPage.load(std::memory_order_acquire); + if (page == 0) + return false; + + const uintptr_t fault = reinterpret_cast(faultAddress); + if (fault < page || fault >= page + 4096) + return false; + + // Restore access first so the faulting instruction can complete on return. + mprotect(reinterpret_cast(page), 4096, PROT_READ | PROT_WRITE); + + const uint32_t hit = s_hitCount.fetch_add(1, std::memory_order_relaxed); + if (hit < MAX_LOGGED_HITS) + { + // Async-signal-safe logging: plain snprintf + write to stderr, which the + // logger already mirrors into log.txt via the stderr pipe thread. + const uintptr_t exact = s_watchExact.load(std::memory_order_relaxed); + const uintptr_t moduleBase = s_moduleBase.load(std::memory_order_relaxed); + const uint64_t pcOff = (moduleBase != 0 && pc >= moduleBase) ? pc - moduleBase : 0; + const uint64_t lrOff = (moduleBase != 0 && lr >= moduleBase) ? lr - moduleBase : 0; + char line[256]; + int n = snprintf(line, sizeof(line), + "PAGEWATCH HIT%s: tid=%d writes %" PRIxPTR " (page+0x%03x) pc=libmain+0x%" PRIx64 " lr=libmain+0x%" PRIx64 "\n", + fault <= exact && exact < fault + 16 ? " (EXACT)" : "", + tid, fault, unsigned(fault & 4095), pcOff, lrOff); + if (n > 0) + write(STDERR_FILENO, line, size_t(n)); + } + + return true; +} + +} // namespace os::android diff --git a/UnleashedRecomp/os/android/page_watch.h b/UnleashedRecomp/os/android/page_watch.h new file mode 100644 index 00000000..2b50c64d --- /dev/null +++ b/UnleashedRecomp/os/android/page_watch.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +// Software watchpoint for the issue #27 corruption hunt: the animation-node guard arms +// a read-only trap on the page holding a broken node, and the SIGSEGV handler logs the +// PC of whoever writes there (the corruptor), restores access and lets execution +// continue. Diagnostic only. +namespace os::android +{ + // Makes the page containing hostAddress read-only and remembers exactAddress + // (the field we care about) for hit classification. Re-arming an armed page is a + // cheap no-op. Thread-safe. + void ArmPageWatch(void* hostAddress, void* exactAddress); + + // Called from the crash handler for SIGSEGV. If the fault is a write to the + // watched page: logs the writer's pc/lr (async-signal-safe), restores RW and + // returns true (the faulting instruction restarts and proceeds). The next guard + // hit re-arms the watch. + bool PageWatchHandleFault(void* faultAddress, uint64_t pc, uint64_t lr, int tid); +} diff --git a/UnleashedRecomp/patches/CGameModeStageTitle_patches.cpp b/UnleashedRecomp/patches/CGameModeStageTitle_patches.cpp index 1cf67250..7d66d2e9 100644 --- a/UnleashedRecomp/patches/CGameModeStageTitle_patches.cpp +++ b/UnleashedRecomp/patches/CGameModeStageTitle_patches.cpp @@ -1,5 +1,6 @@ #include #include +#include // SWA::CGameModeStageTitle::Update PPC_FUNC_IMPL(__imp__sub_825518B8); @@ -9,6 +10,10 @@ PPC_FUNC(sub_825518B8) __imp__sub_825518B8(ctx, base); + // The title game mode covers the start screen, the main menu and the world + // map: navigation surfaces where the touch stick should act as a D-pad. + TouchControls::NotifyMenuVisible(); + if (g_quitMessageOpen) pGameModeStageTitle->m_AdvertiseMovieWaitTime = 0; } diff --git a/UnleashedRecomp/patches/CHudPause_patches.cpp b/UnleashedRecomp/patches/CHudPause_patches.cpp index 51990faa..55af2b52 100644 --- a/UnleashedRecomp/patches/CHudPause_patches.cpp +++ b/UnleashedRecomp/patches/CHudPause_patches.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -128,6 +129,9 @@ PPC_FUNC(sub_824B0930) auto pHudPause = (SWA::CHudPause*)g_memory.Translate(ctx.r3.u32); auto pInputState = SWA::CInputState::GetInstance(); + if (pHudPause->m_IsShown) + TouchControls::NotifyMenuVisible(); + g_achievementMenuIntroTime += App::s_deltaTime; if (g_isAchievementMenuOutro) diff --git a/UnleashedRecomp/patches/inspire_patches.cpp b/UnleashedRecomp/patches/inspire_patches.cpp index 4e493bce..0d14ca9e 100644 --- a/UnleashedRecomp/patches/inspire_patches.cpp +++ b/UnleashedRecomp/patches/inspire_patches.cpp @@ -1,6 +1,7 @@ #include "inspire_patches.h" #include #include +#include #include #include #include @@ -46,6 +47,8 @@ PPC_FUNC(sub_82B98D80) g_pScene = (SWA::Inspire::CScene*)g_memory.Translate(ctx.r3.u32); g_isFirstFrameChecked = false; g_eventDispatchCount = 0; + + TouchControls::NotifyCutsceneActive(true); } // ~SWA::Inspire::CScene @@ -61,6 +64,8 @@ PPC_FUNC(sub_82B98D30) g_loadedMouthExplosionAnimation = false; g_hideMorphModels = false; + + TouchControls::NotifyCutsceneActive(false); } PPC_FUNC_IMPL(__imp__sub_82B9BA98); diff --git a/UnleashedRecomp/patches/misc_patches.cpp b/UnleashedRecomp/patches/misc_patches.cpp index e41cb5c0..451d2d5c 100644 --- a/UnleashedRecomp/patches/misc_patches.cpp +++ b/UnleashedRecomp/patches/misc_patches.cpp @@ -1,8 +1,19 @@ #include #include #include +#include #include +#include +#include + +#ifdef __ANDROID__ +#include +#include +#include +#include +#include +#endif #include #include #include @@ -197,6 +208,672 @@ PPC_FUNC(sub_824EE620) ctx.r3.u32 = PersistentStorageManager::ShouldDisplayDLCMessage(true); } +#ifdef __ANDROID__ + +// issue #27 diag8: lifetime tracking for the game's small-block allocator. +// +// sub_82EA8A30 is the guest bucketed freelist allocator (Havok hkThreadMemory +// layout: per-thread instance fetched from TLS, lock-free bucket pop) and +// sub_82EA8AB0 is its free counterpart. The diag7 page watch showed a buffer +// allocated through sub_82EA8A30 (by sub_82BB5870, count*48 bytes) being +// copied right over a live animation node 16 bytes above it — i.e. the +// allocator handed out overlapping blocks. This tracker records every +// alloc/free in a ring buffer and keeps a live-block bitmap so the moment a +// block is handed out twice we log both owners, and the evaluator guard can +// print the full lifetime history of the broken node's memory. + +namespace +{ + constexpr uint32_t TRACK_LIMIT = 0x20000000; // 360 had 512 MB; game heap lives below this + constexpr size_t EVT_CAP = 1 << 16; + + struct AllocEvent + { + uint32_t addr; + uint32_t size; + uint32_t ra; // host return address as libmain offset (symbolizable to sub_XXXXXXXX) + uint16_t tid; + uint8_t op; // 1 = alloc, 2 = free + uint8_t pad; + }; + + AllocEvent s_events[EVT_CAP]; + std::atomic s_evtIdx{ 0 }; + + // One bit per 8-byte granule (allocator rounds sizes to 8): 8 MB. + std::atomic s_liveBits[TRACK_LIMIT / 8 / 64]; + + uintptr_t TrackModuleBase() + { + static uintptr_t s_base = [] + { + Dl_info info{}; + dladdr((void*)&TrackModuleBase, &info); + return (uintptr_t)info.dli_fbase; + }(); + return s_base; + } + + uint16_t TrackTid() + { + static thread_local uint16_t t_tid = (uint16_t)gettid(); + return t_tid; + } + + void RecordAllocEvent(uint32_t addr, uint32_t size, uint32_t ra, uint8_t op) + { + uint64_t i = s_evtIdx.fetch_add(1, std::memory_order_relaxed); + AllocEvent& e = s_events[i & (EVT_CAP - 1)]; + e.addr = addr; + e.size = size; + e.ra = ra; + e.tid = TrackTid(); + e.op = op; + } + + // Returns true when setting bits that were already set: the allocator handed + // out memory overlapping a still-live tracked block. + bool MarkLiveRange(uint32_t addr, uint32_t size, bool set) + { + if (addr == 0 || size == 0 || size > 0x100000 || addr >= TRACK_LIMIT || size > TRACK_LIMIT - addr) + return false; + + uint32_t g0 = addr >> 3; + uint32_t g1 = (addr + size - 1) >> 3; + bool overlap = false; + + for (uint32_t w = g0 / 64; w <= g1 / 64; w++) + { + uint32_t bs = (w == g0 / 64) ? g0 % 64 : 0; + uint32_t be = (w == g1 / 64) ? g1 % 64 : 63; + uint64_t mask = (be == 63 ? ~0ull : ((1ull << (be + 1)) - 1)) & ~((1ull << bs) - 1); + + if (set) + { + if (s_liveBits[w].fetch_or(mask, std::memory_order_relaxed) & mask) + overlap = true; + } + else + { + s_liveBits[w].fetch_and(~mask, std::memory_order_relaxed); + } + } + + return overlap; + } + + void DumpAllocHistory(uint32_t target, uint32_t range, const char* tag) + { + uint64_t end = s_evtIdx.load(std::memory_order_acquire); + uint64_t begin = end > EVT_CAP ? end - EVT_CAP : 0; + int printed = 0; + + for (uint64_t i = begin; i < end && printed < 24; i++) + { + const AllocEvent& e = s_events[i & (EVT_CAP - 1)]; + uint32_t sz = e.size ? e.size : 1; + if (e.addr < target + range && target < e.addr + sz) + { + LOGF_ERROR("ALLOCTRACK[{}] #{} {} addr={:08X} size={} tid={} ra=libmain+0x{:X}", + tag, i, e.op == 1 ? "alloc" : "free ", e.addr, e.size, e.tid, e.ra); + printed++; + } + } + + if (printed == 0) + LOGF_ERROR("ALLOCTRACK[{}] no recorded events touch {:08X}", tag, target); + } + + void TrackAlloc(uint32_t addr, uint32_t size, uint32_t ra) + { + RecordAllocEvent(addr, size, ra, 1); + + if (MarkLiveRange(addr, size, true)) + { + static std::atomic s_dblCount{ 0 }; + if (s_dblCount.fetch_add(1, std::memory_order_relaxed) < 16) + { + LOGF_ERROR("ALLOCTRACK double allocation: {:08X}+{} handed out while still live " + "(tid={} ra=libmain+0x{:X})", addr, size, TrackTid(), ra); + DumpAllocHistory(addr, size, "double"); + } + } + } + + void TrackFree(uint32_t addr, uint32_t size, uint32_t ra) + { + RecordAllocEvent(addr, size, ra, 2); + MarkLiveRange(addr, size, false); + } + + // diag9: free quarantine. The diag8 logs proved the crash chain on every + // affected device: a 512-byte node (factory sub_822EBF60) is destroyed by + // its own deallocating destructor (sub_82ED4BB8) while a live parent blend + // node still references it, and within a dozen allocator events the memory + // is reused by sub_82BB5870's transform buffers whose float data then gets + // read through the dangling pointer. Hold small freed blocks in a per-thread + // FIFO before really freeing them, and poison the payload with 0xFF so any + // dangling reader sees -1 - which the evaluator guard already treats as + // invalid and skips deterministically. If the ring crash disappears (or its + // frequency collapses) with this build, the use-after-free chain is proven + // end to end. + // All sizes this allocator serves are quarantined: tester logs kept finding + // stale references into blocks above the previous 512-byte cutoff (the + // count*48 transform buffers reach 13792 bytes), showing up as float data + // overwriting live nodes and poison pointers landing in CRT lock lists. + // A byte budget bounds the held memory per guest thread. + constexpr size_t QUAR_CAP = 4096; + constexpr uint32_t QUAR_MAX_SIZE = 64 * 1024; + constexpr uint64_t QUAR_MAX_BYTES = 8ull * 1024 * 1024; + + struct QuarantinedFree + { + uint32_t heap; + uint32_t addr; + uint32_t size; + }; + + thread_local QuarantinedFree t_quarantine[QUAR_CAP]; + thread_local size_t t_quarantineHead = 0; + thread_local size_t t_quarantineCount = 0; + thread_local uint64_t t_quarantineBytes = 0; + + // Global quarantine membership bitmap (one bit per 8-byte granule of the + // first 512MB, like s_liveBits). The collision-pair hooks below use it to + // recognise pointers into destroyed objects without touching the poison. + std::atomic s_quarBits[TRACK_LIMIT / 8 / 64]; + + void MarkQuarantined(uint32_t addr, uint32_t size, bool set) + { + if (addr == 0 || size == 0 || addr >= TRACK_LIMIT || size > TRACK_LIMIT - addr) + return; + + uint32_t g0 = addr >> 3; + uint32_t g1 = (addr + size - 1) >> 3; + for (uint32_t w = g0 / 64; w <= g1 / 64; w++) + { + uint32_t bs = (w == g0 / 64) ? g0 % 64 : 0; + uint32_t be = (w == g1 / 64) ? g1 % 64 : 63; + uint64_t mask = (be == 63 ? ~0ull : ((1ull << (be + 1)) - 1)) & ~((1ull << bs) - 1); + if (set) + s_quarBits[w].fetch_or(mask, std::memory_order_relaxed); + else + s_quarBits[w].fetch_and(~mask, std::memory_order_relaxed); + } + } + + bool IsQuarantined(uint32_t addr) + { + if (addr == 0 || addr >= TRACK_LIMIT) + return false; + uint32_t g = addr >> 3; + return (s_quarBits[g / 64].load(std::memory_order_relaxed) >> (g % 64)) & 1; + } +} + +// Guest small-block allocate(heap r3, size r4) -> r3. +PPC_FUNC_IMPL(__imp__sub_82EA8A30); +PPC_FUNC(sub_82EA8A30) +{ + uint32_t size = ctx.r4.u32; + uintptr_t ra = (uintptr_t)__builtin_return_address(0); + + __imp__sub_82EA8A30(ctx, base); + + TrackAlloc(ctx.r3.u32, size, (uint32_t)(ra - TrackModuleBase())); +} + +// Guest small-block free(heap r3, ptr r4, size r5). +PPC_FUNC_IMPL(__imp__sub_82EA8AB0); +PPC_FUNC(sub_82EA8AB0) +{ + uint32_t heap = ctx.r3.u32; + uint32_t addr = ctx.r4.u32; + uint32_t size = ctx.r5.u32; + uintptr_t ra = (uintptr_t)__builtin_return_address(0); + + if (addr != 0) + TrackFree(addr, size, (uint32_t)(ra - TrackModuleBase())); + + // The heap instance is per-thread (fetched from TLS by the guest code), so + // deferring a free and completing it later from the same thread is safe. + // + // Poison and the arena absorber work as a pair. Without poison, freed + // blocks keep plausible stale pointers and racing teardown code follows + // those chains for multiple hops, eventually writing through memory that + // was reused at another allocator level (o1heap fragment headers died + // this way). Poison cuts every stale chain at its first hop, and the + // absorber region past the arena end turns that cut - a dereference of + // 0xFFFFFFFF plus a field offset - into a harmless read of zeros or a + // write into scratch instead of a crash. + if (addr != 0 && size > 0 && size <= QUAR_MAX_SIZE) + { + memset(base + addr, 0xFF, size); + MarkQuarantined(addr, size, true); + + // Evict oldest entries until both the slot and the byte budget fit. + while (t_quarantineCount == QUAR_CAP || + (t_quarantineCount > 0 && t_quarantineBytes + size > QUAR_MAX_BYTES)) + { + const QuarantinedFree oldest = t_quarantine[t_quarantineHead]; + t_quarantineHead = (t_quarantineHead + 1) % QUAR_CAP; + t_quarantineCount--; + t_quarantineBytes -= oldest.size; + MarkQuarantined(oldest.addr, oldest.size, false); + + ctx.r3.u32 = oldest.heap; + ctx.r4.u32 = oldest.addr; + ctx.r5.u32 = oldest.size; + __imp__sub_82EA8AB0(ctx, base); + } + + t_quarantine[(t_quarantineHead + t_quarantineCount) % QUAR_CAP] = { heap, addr, size }; + t_quarantineCount++; + t_quarantineBytes += size; + return; + } + + __imp__sub_82EA8AB0(ctx, base); +} + +// --------------------------------------------------------------------------- +// Collision-pair drain hooks (issue #27, the mechanism-level fix). +// +// During a physics step, pair add/removes are queued as raw collidable +// pointers (sub_82EF18B0 / sub_82EF19F0) and drained later by sub_82EF2348. +// Nothing pins the entities, so a pair member can be destroyed while its +// entries still sit in the queues. The drain then does two fatal things: +// +// - sub_82EF5680 CREATES an agent for a queued add whose collidable is +// already freed - the agent is born dangling, gets registered into the +// live partner's slot table, is evaluated immediately (the original ring +// crash) and can never be removed, because its identity lives in freed +// memory. These undead agents are the "references that outlive seconds". +// +// - sub_82F760A8 looks an agent up for a queued remove by walking the +// collidable's slot table at +76/+80; on a freed collidable the walk +// reads poison and silently returns "not found". +// +// Both drain call sites handle a null result gracefully, so recognising +// destroyed pair members via the quarantine bitmap and returning null endows +// the drain with exactly the tolerance the Xbox 360's intact-memory timing +// used to provide. The Xbox behaviour: lookups on freed-but-intact memory +// still succeeded. Ours: lookups on freed memory correctly no-op. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Race reproduction harness (local testing only, marker-file gated). +// +// The issue #27 race window is "an entity is destroyed while its pair entries +// sit in the drain queues" - normally microseconds wide, which is why it +// never reproduces on our devices. repro_race.txt stretches the window by +// sleeping a few milliseconds at the drain entry, letting a mid-range tablet +// reproduce what AYN handhelds hit naturally. repro_nofilter.txt disables the +// pair filter for A/B validation: race+nofilter should crash exactly like the +// tester devices; race+filter should survive with "dropped agent creation" +// lines. Both markers are read once, from the external driver_import folder +// or the internal files dir (the latter is creatable via run-as). +// --------------------------------------------------------------------------- + +static bool ReproMarker(const char* name) +{ + std::error_code ec; + return std::filesystem::exists(os::android::GetExternalFilesDir() / "driver_import" / name, ec) || + std::filesystem::exists(os::android::GetInternalFilesDir() / name, ec); +} + +static bool ReproRaceEnabled() +{ + static bool s_enabled = [] + { + bool enabled = ReproMarker("repro_race.txt"); + if (enabled) + LOG_ERROR("REPRO: drain delay perturbation enabled (repro_race.txt)."); + return enabled; + }(); + return s_enabled; +} + +static bool ReproFilterDisabled() +{ + static bool s_disabled = [] + { + bool disabled = ReproMarker("repro_nofilter.txt"); + if (disabled) + LOG_ERROR("REPRO: pair filter DISABLED (repro_nofilter.txt)."); + return disabled; + }(); + return s_disabled; +} + +// Pair-queue drain: the perturbation point. Sleeping here stretches the +// enqueue-to-drain window from microseconds to milliseconds. +PPC_FUNC_IMPL(__imp__sub_82EF2348); +PPC_FUNC(sub_82EF2348) +{ + if (ReproRaceEnabled()) + usleep(2000 + (rand() % 6000)); + + __imp__sub_82EF2348(ctx, base); +} + +// Agent factory (r3/r4 = shape entries inside the two collidables). Never +// create an agent for a destroyed pair member. diag19's six tester logs +// proved the entry pointers themselves are never quarantined (0 hits), while +// diag8 event ordering proved agents are still born after a node's death: +// the collidable outlives its animation/physics node, and the factory takes +// that node from entry+184 (the same field the enqueue dedup compares). Check +// those node pointers, not just the entries. +PPC_FUNC_IMPL(__imp__sub_82EF5680); +PPC_FUNC(sub_82EF5680) +{ + if (!ReproFilterDisabled()) + { + auto entryNodeDead = [&](uint32_t entry) + { + if (IsQuarantined(entry)) + return true; + if (entry < 0x1000 || entry >= 0xFFFF0000) + return false; // malformed entry: let the original code decide + uint32_t node = PPC_LOAD_U32(entry + 184); + return node != 0 && IsQuarantined(node); + }; + + if (entryNodeDead(ctx.r3.u32) || entryNodeDead(ctx.r4.u32)) + { + static std::atomic s_dropCount{ 0 }; + if (s_dropCount.fetch_add(1, std::memory_order_relaxed) < 16) + LOGFN_ERROR("sub_82EF5680: dropped agent creation for destroyed pair (a={:08X} b={:08X})", ctx.r3.u32, ctx.r4.u32); + ctx.r3.u32 = 0; + return; + } + } + + __imp__sub_82EF5680(ctx, base); +} + +// Agent lookup by pair (walks u32[coll+76] slot table, count at +80). Miss +// fast on destroyed members instead of walking poisoned tables. +PPC_FUNC_IMPL(__imp__sub_82F760A8); +PPC_FUNC(sub_82F760A8) +{ + if (!ReproFilterDisabled() && + (IsQuarantined(ctx.r3.u32) || IsQuarantined(ctx.r4.u32))) + { + ctx.r3.u32 = 0; + return; + } + + __imp__sub_82F760A8(ctx, base); +} + +#endif + +#ifdef __ANDROID__ + +// The destructor-deferral experiments (diag17/diag18: 100ms and 1s) are +// deliberately NOT part of this build. They disproved their own premise - +// the dangling references are not a finite timing window but undead agents +// born from the drain (see the pair hooks above) - and deferring the +// destructor onto whichever thread later drains the queue is a novel +// concurrency risk the game never had. + +// --------------------------------------------------------------------------- +// The two halves of the actual lifetime defect (diag21). +// +// Agent nodes are born in exactly one place: sub_82F764C8(?, childA, childB, +// ...) stores both children at +16/+20 of the fresh node and registers the +// node into each child's registration table (u32[child+76], count at +80, +// 8-byte entries of {agent, partner} - the same layout sub_82F760A8 walks). +// The child's deallocating destructor (sub_82ED4BB8) does NOT walk that +// table: nothing tells live parents their child is gone. On the Xbox 360 the +// stale pointer kept reading intact memory until reuse; on this port it reads +// poison. Two hooks close both halves: +// 1. births: refuse to create an agent whose child is already destroyed; +// 2. deaths: deliver the notification the game never sends - walk the dying +// node's registration table and point every live parent's matching child +// slot at the sink node (evaluator-invisible, registration-safe). +// --------------------------------------------------------------------------- + +static uint32_t GetSinkChild(uint8_t* base); + +// Agent-node constructor: the only birthplace of parent nodes. +PPC_FUNC_IMPL(__imp__sub_82F764C8); +PPC_FUNC(sub_82F764C8) +{ + if (IsQuarantined(ctx.r4.u32) || IsQuarantined(ctx.r5.u32)) + { + static std::atomic s_dropCount{ 0 }; + if (s_dropCount.fetch_add(1, std::memory_order_relaxed) < 16) + LOGFN_ERROR("sub_82F764C8: refused agent birth with destroyed child (a={:08X} b={:08X})", ctx.r4.u32, ctx.r5.u32); + ctx.r3.u32 = 0; + return; + } + + __imp__sub_82F764C8(ctx, base); +} + +// Family destructor: deliver the missing parent notification before the +// object dies. Layout guards keep this a no-op for family members that do +// not carry a registration table. +// +// Offset note (the diag21 lesson, notified=0 in every log): parents do not +// reference the object base r3 - every broken child pointer in every log is +// base+0x10 (090E4D10 in block 090E4D00, 0A6A3510 in 0A6A3500, ...). The +// node lives embedded at +0x10, so its registration table pointer sits at +// object+0x5C - which is exactly the field the very first crashes of this +// issue faulted on (reads of guest 0x5C through a null node). +PPC_FUNC_IMPL(__imp__sub_82ED4BB8); +PPC_FUNC(sub_82ED4BB8) +{ + const uint32_t dyingObject = ctx.r3.u32; + const uint32_t dying = dyingObject + 0x10; // the node parents point at + if (dyingObject >= 0x1000 && dyingObject < 0xFFFF0000) + { + uint32_t table = PPC_LOAD_U32(dying + 76); + uint32_t count = PPC_LOAD_U32(dying + 80); + + if (table >= 0x1000 && table < 0xFFFF0000 && count > 0 && count <= 1024 && + !IsQuarantined(table)) + { + // Remove every registered agent through the game's own removal + // path: sub_82EF58A0(agent) is self-contained (it derives its + // dispatch context from the agent) and is exactly what the + // remove-pair drain calls. This deregisters the agent from both + // children's tables AND the animation job list, so the pose + // validator sees a consistent graph - unlike the previous sink + // substitution, which left skipped nodes behind and drove the + // game's assert into a storm that overflowed a guest stack. + const uint32_t savedR3 = ctx.r3.u32; + const uint32_t savedR4 = ctx.r4.u32; + uint32_t removed = 0; + + for (uint32_t guard = 0; guard < 1024; guard++) + { + uint32_t c = PPC_LOAD_U32(dying + 80); + if (c == 0 || c > 1024) + break; + + uint32_t tbl = PPC_LOAD_U32(dying + 76); + if (tbl < 0x1000 || tbl >= 0xFFFF0000) + break; + + uint32_t agent = PPC_LOAD_U32(tbl + (c - 1) * 8); + if (agent < 0x1000 || agent >= 0xFFFF0000 || IsQuarantined(agent)) + { + // Unremovable entry: drop it so the loop advances. + PPC_STORE_U32(dying + 80, c - 1); + continue; + } + + ctx.r3.u32 = agent; + sub_82EF58A0(ctx, base); + removed++; + + // The removal must shrink the table; force progress if not. + if (PPC_LOAD_U32(dying + 80) >= c) + PPC_STORE_U32(dying + 80, c - 1); + } + + ctx.r3.u32 = savedR3; + ctx.r4.u32 = savedR4; + + if (removed > 0) + { + static std::atomic s_notifyCount{ 0 }; + if (s_notifyCount.fetch_add(1, std::memory_order_relaxed) < 16) + LOGFN_ERROR("sub_82ED4BB8: removed {} live agents of dying node {:08X}", removed, dying); + } + } + } + + __imp__sub_82ED4BB8(ctx, base); +} + +// Node child repair (issue #27). Three routines dereference a node's children +// (+16/+20) and write to each child's slot table through u32[child+76]: the +// evaluator, the single-node clone (sub_82F768E0) and the pool swap-remove +// flusher (sub_82F76698). When a child was destroyed early (the proven +// use-after-free), that dereference reads the quarantine poison 0xFFFFFFFF and +// faults past the 4GB guest arena - or, before the quarantine existed, wrote +// through reused garbage and corrupted the heap at random. Repair the node +// before those routines run: a dead child is replaced with its surviving +// sibling (the type-4 blend degrades to blending a clip with itself), and if +// both children are dead they are pointed at a synthetic "sink" node whose +// data pointer reads as destroyed (so the evaluator skips it) and whose slot +// table is a private scratch buffer (so registrations land harmlessly). + +// Only reject pointers that cannot be dereferenced safely (null page, 0/-1 +// destructed/poisoned markers, or so high that +76 leaves the 4GB guest +// arena). Slot tables may live in the static image, so no upper heap bound. +static bool NodePtrUsable(uint32_t ptr) +{ + return ptr >= 0x1000 && ptr < 0xFFFF0000; +} + +// A child is dead when its pointer is unusable or its payload reads as +// destructed/poisoned (data pointer at +8, slot table pointer at +76). +static bool NodeChildDead(uint8_t* base, uint32_t child) +{ + if (!NodePtrUsable(child)) + return true; + uint32_t data = PPC_LOAD_U32(child + 8); + if (data == 0 || data == 0xFFFFFFFF) + return true; + return !NodePtrUsable(PPC_LOAD_U32(child + 76)); +} + +// Lazily built guest-side stand-in child: evaluator-invisible, registration-safe. +// Slot indices come from u16 fields scaled by 8, so the scratch table covers the +// full 16-bit range. +static uint32_t GetSinkChild(uint8_t* base) +{ + static std::atomic s_sink{ 0 }; + uint32_t sink = s_sink.load(std::memory_order_acquire); + if (sink != 0) + return sink; + + static std::mutex s_mutex; + std::lock_guard lock(s_mutex); + sink = s_sink.load(std::memory_order_relaxed); + if (sink != 0) + return sink; + + constexpr uint32_t NODE_SIZE = 128; + constexpr uint32_t TABLE_SIZE = 0x10000 * 8; + void* host = g_userHeap.AllocPhysical(NODE_SIZE + TABLE_SIZE, 16); + memset(host, 0, NODE_SIZE + TABLE_SIZE); + uint32_t guest = g_memory.MapVirtual(host); + + PPC_STORE_U32(guest + 8, 0xFFFFFFFF); // data: reads as destroyed + PPC_STORE_U32(guest + 76, guest + NODE_SIZE); // slot table: private scratch + + s_sink.store(guest, std::memory_order_release); + return guest; +} + +// Returns true when the node needed repair. Dead children are always replaced +// with the sink, never with the surviving sibling: the sink keeps registration +// writes safe while its destroyed-reading data pointer keeps the evaluator +// guard skipping the node. Substituting the sibling was tried and re-armed the +// evaluation with a mismatched clip, which tripped the game's own pose +// consistency assert (KeBugCheck from sub_82BB5870). +static bool RepairNodeChildren(uint8_t* base, uint32_t node, const char* who, bool& bothDead) +{ + bothDead = false; + if (!NodePtrUsable(node)) + return false; + + uint32_t childA = PPC_LOAD_U32(node + 16); + uint32_t childB = PPC_LOAD_U32(node + 20); + bool deadA = NodeChildDead(base, childA); + bool deadB = NodeChildDead(base, childB); + + if (!deadA && !deadB) + return false; + + bothDead = deadA && deadB; + uint32_t sink = GetSinkChild(base); + if (deadA) + PPC_STORE_U32(node + 16, sink); + if (deadB) + PPC_STORE_U32(node + 20, sink); + + static std::atomic s_repairCount{ 0 }; + if (s_repairCount.fetch_add(1, std::memory_order_relaxed) < 8) + { + LOGF_ERROR("{}: sink substituted for dead child{}{} of {:08X} (childA={:08X} childB={:08X})", + who, deadA ? "A" : "", deadB ? "B" : "", node, childA, childB); + } + return true; +} + +// Single-node clone: copies 128 bytes from the source node (r4), including the +// child pointers, then registers the clone in each child's slot table. +PPC_FUNC_IMPL(__imp__sub_82F768E0); +PPC_FUNC(sub_82F768E0) +{ + bool bothDead = false; + if (RepairNodeChildren(base, ctx.r4.u32, "sub_82F768E0", bothDead) && bothDead) + { + // Callers iterate a batch and do not consume the return value; skipping + // a fully dead clone is cleaner than registering the sink twice more. + ctx.r3.u32 = 0; + return; + } + + __imp__sub_82F768E0(ctx, base); +} + +// Pool swap-remove (r3 = pool {offset, chunk array, chunk count}, r4 = node +// being removed): the pool's last node is copied over r4 and then re-registered +// through its child slot tables. Repair that node before the move; skipping is +// not an option here because the pool bookkeeping must advance. +PPC_FUNC_IMPL(__imp__sub_82F76698); +PPC_FUNC(sub_82F76698) +{ + uint32_t pool = ctx.r3.u32; + if (NodePtrUsable(pool)) + { + uint32_t offset = PPC_LOAD_U32(pool + 0); + uint32_t chunks = PPC_LOAD_U32(pool + 4); + uint32_t count = PPC_LOAD_U32(pool + 8); + if (NodePtrUsable(chunks) && count > 0 && offset >= 128) + { + uint32_t lastChunk = PPC_LOAD_U32(chunks + (count - 1) * 4); + if (NodePtrUsable(lastChunk)) + { + bool bothDead = false; + RepairNodeChildren(base, lastChunk + offset - 128, "sub_82F76698", bothDead); + } + } + } + + __imp__sub_82F76698(ctx, base); +} + +#endif + // Animation node evaluator. The function dispatches on a type byte at +0; the type-4 // branch interpolates between two child nodes (+16 / +20) and dereferences each child's // data pointer at +8 without validating it. On some devices (issue #27: Snapdragon @@ -218,12 +895,30 @@ PPC_FUNC(sub_82F77188) if (invalidPtr(dataA) || invalidPtr(dataB)) { +#ifdef __ANDROID__ + // Watchpoint the broken data field: whoever writes that page next gets + // its pc logged by the crash handler (PAGEWATCH HIT lines in log.txt). + uint32_t brokenChild = invalidPtr(dataB) ? childB : childA; + if (!invalidPtr(brokenChild)) + os::android::ArmPageWatch(base + brokenChild + 8, base + brokenChild + 8); +#endif + static std::atomic s_reportCount{ 0 }; if (s_reportCount.fetch_add(1, std::memory_order_relaxed) < 8) { LOGFN_ERROR("sub_82F77188: skipping type-4 node with invalid data " "(this={:08X} childA={:08X} dataA={:08X} childB={:08X} dataB={:08X})", ctx.r3.u32, childA, dataA, childB, dataB); + +#ifdef __ANDROID__ + // diag8: print the allocation lifetime of the broken child node + // and its parent, so we can tell use-after-free (freed, then the + // memory re-handed to someone else) from double allocation. + uint32_t brokenNode = invalidPtr(dataB) ? childB : childA; + if (!invalidPtr(brokenNode)) + DumpAllocHistory(brokenNode, 48, "node"); + DumpAllocHistory(ctx.r3.u32, 48, "parent"); +#endif } return; diff --git a/UnleashedRecomp/patches/video_patches.cpp b/UnleashedRecomp/patches/video_patches.cpp index a2320b37..e2192b9f 100644 --- a/UnleashedRecomp/patches/video_patches.cpp +++ b/UnleashedRecomp/patches/video_patches.cpp @@ -141,3 +141,27 @@ PPC_FUNC(sub_82E38650) __imp__sub_82E38650(ctx, base); } + +// Planar reflection pass gate (virtual, reached via vtable). The function reads +// the global reflection-enable flag at 0x832FA0D8 first thing and takes the +// "no reflections in this scene" early-out when it is zero - the exact path +// every stage without reflective surfaces already takes. With the option off, +// present that zero to just this function, skipping the reflection scene pass +// without disturbing the flag for the rest of the engine (pipeline compilation +// on the host reads it too). +PPC_FUNC_IMPL(__imp__sub_82BAC240); +PPC_FUNC(sub_82BAC240) +{ + auto* reflectionFlag = (uint8_t*)g_memory.Translate(0x832FA0D8); + + if (!Config::PlanarReflections && *reflectionFlag != 0) + { + uint8_t saved = *reflectionFlag; + *reflectionFlag = 0; + __imp__sub_82BAC240(ctx, base); + *reflectionFlag = saved; + return; + } + + __imp__sub_82BAC240(ctx, base); +} diff --git a/UnleashedRecomp/ui/options_menu.cpp b/UnleashedRecomp/ui/options_menu.cpp index a00452c1..b1b5c106 100644 --- a/UnleashedRecomp/ui/options_menu.cpp +++ b/UnleashedRecomp/ui/options_menu.cpp @@ -1244,7 +1244,10 @@ static void DrawConfigOptions() DrawConfigOption(rowCount++, yOffset, &Config::HorizontalCamera, true); DrawConfigOption(rowCount++, yOffset, &Config::VerticalCamera, true); DrawConfigOption(rowCount++, yOffset, &Config::Vibration, true); +#ifndef __ANDROID__ + // Window focus is a desktop concept; Android apps pause in background. DrawConfigOption(rowCount++, yOffset, &Config::AllowBackgroundInput, true); +#endif #ifdef __ANDROID__ DrawConfigOption(rowCount++, yOffset, &Config::TouchControls, true); #endif @@ -1265,7 +1268,10 @@ static void DrawConfigOptions() #ifdef __ANDROID__ DrawConfigOption(rowCount++, yOffset, &Config::VulkanDriver, true); DrawConfigOption(rowCount++, yOffset, &Config::RenderMode, true); -#endif +#else + // Windowing options make no sense on Android: the game always runs + // fullscreen on the one display at its native size, and the + // compositor vsyncs every frame regardless. DrawConfigOption(rowCount++, yOffset, &Config::WindowSize, !Config::Fullscreen, &Localise("Options_Desc_NotAvailableFullscreen"), 0, 0, (int32_t)GameWindow::GetDisplayModes().size() - 1, false); @@ -1278,16 +1284,21 @@ static void DrawConfigOptions() monitorReason = &Localise("Options_Desc_NotAvailableHardware"); DrawConfigOption(rowCount++, yOffset, &Config::Monitor, canChangeMonitor, monitorReason, 0, 0, displayCount - 1, false); +#endif DrawConfigOption(rowCount++, yOffset, &Config::AspectRatio, true); DrawConfigOption(rowCount++, yOffset, &Config::ResolutionScale, true, nullptr, 0.25f, 1.0f, 2.0f); +#ifndef __ANDROID__ DrawConfigOption(rowCount++, yOffset, &Config::Fullscreen, true); DrawConfigOption(rowCount++, yOffset, &Config::VSync, true); +#endif DrawConfigOption(rowCount++, yOffset, &Config::FPS, true, nullptr, FPS_MIN, 120, FPS_MAX); DrawConfigOption(rowCount++, yOffset, &Config::Brightness, true); DrawConfigOption(rowCount++, yOffset, &Config::AntiAliasing, Config::AntiAliasing.InaccessibleValues.size() != 3, &Localise("Options_Desc_NotAvailableHardware")); DrawConfigOption(rowCount++, yOffset, &Config::TransparencyAntiAliasing, Config::AntiAliasing != EAntiAliasing::None, &Localise("Options_Desc_NotAvailableMSAA")); DrawConfigOption(rowCount++, yOffset, &Config::ShadowResolution, true); + DrawConfigOption(rowCount++, yOffset, &Config::TextureQuality, true); + DrawConfigOption(rowCount++, yOffset, &Config::PlanarReflections, true); DrawConfigOption(rowCount++, yOffset, &Config::GITextureFiltering, true); DrawConfigOption(rowCount++, yOffset, &Config::MotionBlur, true); DrawConfigOption(rowCount++, yOffset, &Config::XboxColorCorrection, true); diff --git a/UnleashedRecomp/ui/touch_controls.cpp b/UnleashedRecomp/ui/touch_controls.cpp index 19a3fa05..c6e33543 100644 --- a/UnleashedRecomp/ui/touch_controls.cpp +++ b/UnleashedRecomp/ui/touch_controls.cpp @@ -3,6 +3,7 @@ #include "imgui_utils.h" #include "button_guide.h" #include "game_window.h" +#include "options_menu.h" #include #include #include @@ -119,6 +120,17 @@ namespace std::atomic g_autoVisible{ true }; XAMINPUT_GAMEPAD g_state{}; + // ---- Adaptive context (menu / cutscene) -------------------------------- + // Menus stamp a timestamp every frame they are visible (guest update thread); + // the render thread treats the flag as active while the stamp is fresh, so no + // destructor hooks are needed. The cutscene flag is edge-triggered from the + // Inspire scene ctor/dtor hooks. + std::atomic g_menuSeenAtMs{ 0 }; + std::atomic g_inspireSceneActive{ false }; + constexpr uint64_t MENU_STAMP_FRESH_MS = 250; + + enum class ETouchContext { Normal, Menu, Cutscene }; + // Finger driving the analog stick (-1 = none). Render-thread only. SDL_FingerID g_stickFingerId = (SDL_FingerID)-1; @@ -291,6 +303,52 @@ namespace return pressed; } + // D-pad drawn in place of the left stick while a menu is open. The whole + // stick zone is the hit area; direction comes from the finger's angle with + // an 8-way split (diagonals press two directions), matching how the game's + // menus read the physical D-pad. + void DrawDpad(ImDrawList* dl, const std::vector& fps, ImVec2 c, + float baseR, float zoneR, XAMINPUT_GAMEPAD& st) + { + uint16_t bits = 0; + for (const auto& fp : fps) + { + const float dx = fp.pos.x - c.x; + const float dy = fp.pos.y - c.y; + const float dist2 = dx * dx + dy * dy; + if (dist2 > zoneR * zoneR || dist2 < baseR * baseR * 0.04f) + continue; + + // 8-way: a component counts when it carries at least half the other. + if (std::fabs(dx) >= std::fabs(dy) * 0.5f) + bits |= dx > 0.0f ? XAMINPUT_GAMEPAD_DPAD_RIGHT : XAMINPUT_GAMEPAD_DPAD_LEFT; + if (std::fabs(dy) >= std::fabs(dx) * 0.5f) + bits |= dy > 0.0f ? XAMINPUT_GAMEPAD_DPAD_DOWN : XAMINPUT_GAMEPAD_DPAD_UP; + } + st.wButtons |= bits; + + dl->AddCircleFilled(c, baseR, IM_COL32(0, 0, 0, bits ? 90 : 55), 48); + dl->AddCircle(c, baseR, IM_COL32(255, 255, 255, 130), 48, 3.0f); + + struct { float ox, oy; uint16_t bit; } dirs[4] = + { + { 0.0f, -1.0f, XAMINPUT_GAMEPAD_DPAD_UP }, + { 1.0f, 0.0f, XAMINPUT_GAMEPAD_DPAD_RIGHT }, + { 0.0f, 1.0f, XAMINPUT_GAMEPAD_DPAD_DOWN }, + { -1.0f, 0.0f, XAMINPUT_GAMEPAD_DPAD_LEFT }, + }; + for (const auto& d : dirs) + { + const ImVec2 tip { c.x + d.ox * baseR * 0.78f, c.y + d.oy * baseR * 0.78f }; + const ImVec2 b1 { c.x + d.ox * baseR * 0.38f - d.oy * baseR * 0.26f, + c.y + d.oy * baseR * 0.38f - d.ox * baseR * 0.26f }; + const ImVec2 b2 { c.x + d.ox * baseR * 0.38f + d.oy * baseR * 0.26f, + c.y + d.oy * baseR * 0.38f + d.ox * baseR * 0.26f }; + const bool on = (st.wButtons & d.bit) != 0; + dl->AddTriangleFilled(tip, b1, b2, IM_COL32(255, 255, 255, on ? 230 : 120)); + } + } + // Draw a control's static visual (no press detection) - used by the editor. void DrawElemVisual(ImDrawList* dl, int i, const ElemRect& r) { @@ -410,6 +468,16 @@ const XAMINPUT_GAMEPAD& TouchControls::GetGamepadState() return g_state; } +void TouchControls::NotifyMenuVisible() +{ + g_menuSeenAtMs.store(SDL_GetTicks64(), std::memory_order_relaxed); +} + +void TouchControls::NotifyCutsceneActive(bool active) +{ + g_inspireSceneActive.store(active, std::memory_order_relaxed); +} + void TouchControls::Init() { // The glyph atlas is owned by ButtonGuide (initialised before us). Load the @@ -499,6 +567,57 @@ void TouchControls::Draw() { XAMINPUT_GAMEPAD st{}; + // Adaptive context: cutscenes collapse everything into one SKIP button, + // menus swap the analog stick for a D-pad and release the camera finger. + // Only Inspire scenes count as cutscenes: the WMV movie manager singleton + // stays alive long after playback, so it cannot be used as a signal (it + // locked the whole title screen into SKIP mode when tried). + ETouchContext context = ETouchContext::Normal; + if (g_inspireSceneActive.load(std::memory_order_relaxed)) + { + context = ETouchContext::Cutscene; + } + else if (OptionsMenu::s_isVisible || + SDL_GetTicks64() - g_menuSeenAtMs.load(std::memory_order_relaxed) < MENU_STAMP_FRESH_MS) + { + context = ETouchContext::Menu; + } + + if (context == ETouchContext::Cutscene) + { + g_stickFingerId = (SDL_FingerID)-1; + g_rstickFingerId = (SDL_FingerID)-1; + g_camFingerId = (SDL_FingerID)-1; + + // One wide SKIP button at the Start button's spot. + const float skipHW = MENU_HW * vh * g_layout.scale * 2.2f; + const float skipHH = MENU_HH * vh * g_layout.scale; + const ImVec2 skipC = ElemRectOf(TC_START, vw, vh).c; + + std::vector pts; + pts.reserve(fps.size()); + for (const auto& fp : fps) + pts.push_back(fp.pos); + + const bool pressed = AnyFingerInRect(pts, + { skipC.x - skipHW, skipC.y - skipHH }, { skipC.x + skipHW, skipC.y + skipHH }); + if (pressed) + st.wButtons |= XAMINPUT_GAMEPAD_START; + + dl->AddRectFilled({ skipC.x - skipHW, skipC.y - skipHH }, { skipC.x + skipHW, skipC.y + skipHH }, + IM_COL32(0, 0, 0, pressed ? 150 : 90), skipHH * 0.5f); + dl->AddRect({ skipC.x - skipHW, skipC.y - skipHH }, { skipC.x + skipHW, skipC.y + skipHH }, + IM_COL32(255, 255, 255, 150), skipHH * 0.5f, 0, 2.0f); + const char* skipLabel = "SKIP >>"; + const ImVec2 ts = font->CalcTextSizeA(fontPx, FLT_MAX, 0.0f, skipLabel); + dl->AddText(font, fontPx, { skipC.x - ts.x * 0.5f, skipC.y - ts.y * 0.5f }, + IM_COL32(255, 255, 255, pressed ? 255 : 220), skipLabel); + + g_state = st; + g_prevIds = std::move(curIds); + return; + } + // ---- Left analog stick ---- const ImVec2 stickC(g_layout.x[TC_STICK] * vw, g_layout.y[TC_STICK] * vh); const float baseR = STICK_BASE_R * vh * g_layout.scale; @@ -531,7 +650,7 @@ void TouchControls::Draw() ImVec2 thumbPos = stickC; bool stickActive = false; - if (stickPos) + if (stickPos && context != ETouchContext::Menu) { const float dx = stickPos->x - stickC.x; const float dy = stickPos->y - stickC.y; @@ -551,7 +670,10 @@ void TouchControls::Draw() } // ---- Camera: virtual right stick ---- - const auto cameraMode = Config::TouchCamera.Value; + // Menus release the camera finger: a drag there would only feed a camera + // nobody controls and swallow taps on the right half of the screen. + const auto cameraMode = context == ETouchContext::Menu + ? EAndroidTouchCameraMode::Off : Config::TouchCamera.Value; if (cameraMode == EAndroidTouchCameraMode::RightStick) { const ImVec2 rstickC(g_layout.x[TC_RSTICK] * vw, g_layout.y[TC_RSTICK] * vh); @@ -683,9 +805,16 @@ void TouchControls::Draw() if (fp.id != g_stickFingerId && fp.id != g_rstickFingerId && fp.id != g_camFingerId) pts.push_back(fp.pos); - dl->AddCircleFilled(stickC, baseR, IM_COL32(0, 0, 0, stickActive ? 90 : 55), 48); - dl->AddCircle(stickC, baseR, IM_COL32(255, 255, 255, 130), 48, 3.0f); - dl->AddCircleFilled(thumbPos, thumbR, IM_COL32(255, 255, 255, stickActive ? 170 : 110), 32); + if (context == ETouchContext::Menu) + { + DrawDpad(dl, fps, stickC, baseR, zoneR, st); + } + else + { + dl->AddCircleFilled(stickC, baseR, IM_COL32(0, 0, 0, stickActive ? 90 : 55), 48); + dl->AddCircle(stickC, baseR, IM_COL32(255, 255, 255, 130), 48, 3.0f); + dl->AddCircleFilled(thumbPos, thumbR, IM_COL32(255, 255, 255, stickActive ? 170 : 110), 32); + } // ---- Face buttons ---- const float faceR = FACE_BTN_R * vh * g_layout.scale; diff --git a/UnleashedRecomp/ui/touch_controls.h b/UnleashedRecomp/ui/touch_controls.h index f75f8755..1dff463c 100644 --- a/UnleashedRecomp/ui/touch_controls.h +++ b/UnleashedRecomp/ui/touch_controls.h @@ -26,6 +26,14 @@ class TouchControls // Latest synthesised pad state (player 1). Valid only while IsVisible(). static const XAMINPUT_GAMEPAD& GetGamepadState(); + // Game-context signals for the adaptive layout. NotifyMenuVisible must be + // called every frame a navigation menu is on screen (the flag decays on its + // own); the cutscene flag is edge-triggered from scene ctor/dtor hooks. In a + // menu the left stick becomes a D-pad; in a cutscene everything collapses to + // a single SKIP button. + static void NotifyMenuVisible(); + static void NotifyCutsceneActive(bool active); + static void Init(); static void Draw(); }; diff --git a/UnleashedRecomp/user/config.cpp b/UnleashedRecomp/user/config.cpp index dfd8eec4..86a109fb 100644 --- a/UnleashedRecomp/user/config.cpp +++ b/UnleashedRecomp/user/config.cpp @@ -372,6 +372,7 @@ CONFIG_DEFINE_ENUM_TEMPLATE(EAntiAliasing) CONFIG_DEFINE_ENUM_TEMPLATE(EShadowResolution) { { "Original", EShadowResolution::Original }, + { "256", EShadowResolution::x256 }, { "512", EShadowResolution::x512 }, { "1024", EShadowResolution::x1024 }, { "2048", EShadowResolution::x2048 }, @@ -379,6 +380,13 @@ CONFIG_DEFINE_ENUM_TEMPLATE(EShadowResolution) { "8192", EShadowResolution::x8192 }, }; +CONFIG_DEFINE_ENUM_TEMPLATE(ETextureQuality) +{ + { "Full", ETextureQuality::Full }, + { "Half", ETextureQuality::Half }, + { "Quarter", ETextureQuality::Quarter }, +}; + CONFIG_DEFINE_ENUM_TEMPLATE(EGITextureFiltering) { { "Bilinear", EGITextureFiltering::Bilinear }, diff --git a/UnleashedRecomp/user/config.h b/UnleashedRecomp/user/config.h index c2311fe9..ba799108 100644 --- a/UnleashedRecomp/user/config.h +++ b/UnleashedRecomp/user/config.h @@ -141,6 +141,7 @@ enum class EAntiAliasing : uint32_t enum class EShadowResolution : int32_t { Original = -1, + x256 = 256, x512 = 512, x1024 = 1024, x2048 = 2048, @@ -154,6 +155,17 @@ enum class EGITextureFiltering : uint32_t Bicubic }; +// Drops the top mip levels of game textures at load time: Half skips one level, +// Quarter skips two. Cuts GPU memory and sampling bandwidth like a low-res +// texture pack, but covers every texture (base game, DLC, mods) with no extra +// files. Textures without mip chains (UI, fonts) are unaffected. +enum class ETextureQuality : uint32_t +{ + Full, + Half, + Quarter +}; + enum class EDepthOfFieldQuality : uint32_t { Auto, diff --git a/UnleashedRecomp/user/config_def.h b/UnleashedRecomp/user/config_def.h index b000ff1c..03af7b51 100644 --- a/UnleashedRecomp/user/config_def.h +++ b/UnleashedRecomp/user/config_def.h @@ -91,6 +91,8 @@ CONFIG_DEFINE_LOCALISED("Video", bool, TransparencyAntiAliasing, true); CONFIG_DEFINE("Video", uint32_t, AnisotropicFiltering, 16); #endif CONFIG_DEFINE_ENUM_LOCALISED("Video", EShadowResolution, ShadowResolution, EShadowResolution::x4096); +CONFIG_DEFINE_ENUM_LOCALISED("Video", ETextureQuality, TextureQuality, ETextureQuality::Full); +CONFIG_DEFINE_LOCALISED("Video", bool, PlanarReflections, true); CONFIG_DEFINE_ENUM_LOCALISED("Video", EGITextureFiltering, GITextureFiltering, EGITextureFiltering::Bicubic); CONFIG_DEFINE_ENUM("Video", EDepthOfFieldQuality, DepthOfFieldQuality, EDepthOfFieldQuality::Auto); #ifdef __ANDROID__ diff --git a/UnleashedRecompLib/ppc/ppc_detail.h b/UnleashedRecompLib/ppc/ppc_detail.h index 51ce2d2f..c24fe514 100644 --- a/UnleashedRecompLib/ppc/ppc_detail.h +++ b/UnleashedRecompLib/ppc/ppc_detail.h @@ -19,6 +19,26 @@ extern "C" void PPCIndirectCallMissing(PPCContext& ctx, uint8_t* base, uint32_t // pointer 0. The stock macro dereferences the function table unconditionally, // which for such targets reads far outside the table and jumps to garbage. // Validate the target range and the resolved host pointer instead of crashing. +// DIAGNOSTIC (issue #27, TSO experiment): give every guest load acquire semantics and +// every guest store release semantics via thread fences, emulating the x86-TSO memory +// model the game demonstrably works under (desktop upstream) on weakly-ordered ARM64. +// Working hypothesis: the game's lightly-synchronized structures (its free-list heap, +// the animation registries) are safe on the in-order Xenon and on x86-TSO, but tear on +// ARM64 - object memory observed containing other owners' data (XML text, floats where +// vtables belong), the allocator freelist head reads as -1, and KeBugCheck fires. +// Fences instead of ldar/stlr because guest accesses may be unaligned. Costs FPS; if +// it eliminates the crashes, a production variant with aligned fast paths follows. +#ifdef __ANDROID__ +#define PPC_LOAD_U8(x) (__extension__({ uint8_t ppcLoadV_ = *(volatile uint8_t *)(base + (x)); __atomic_thread_fence(__ATOMIC_ACQUIRE); ppcLoadV_; })) +#define PPC_LOAD_U16(x) (__extension__({ uint16_t ppcLoadV_ = __builtin_bswap16(*(volatile uint16_t*)(base + (x))); __atomic_thread_fence(__ATOMIC_ACQUIRE); ppcLoadV_; })) +#define PPC_LOAD_U32(x) (__extension__({ uint32_t ppcLoadV_ = __builtin_bswap32(*(volatile uint32_t*)(base + (x))); __atomic_thread_fence(__ATOMIC_ACQUIRE); ppcLoadV_; })) +#define PPC_LOAD_U64(x) (__extension__({ uint64_t ppcLoadV_ = __builtin_bswap64(*(volatile uint64_t*)(base + (x))); __atomic_thread_fence(__ATOMIC_ACQUIRE); ppcLoadV_; })) +#define PPC_STORE_U8(x, y) do { __atomic_thread_fence(__ATOMIC_RELEASE); *(volatile uint8_t *)(base + (x)) = (y); } while (0) +#define PPC_STORE_U16(x, y) do { __atomic_thread_fence(__ATOMIC_RELEASE); *(volatile uint16_t*)(base + (x)) = __builtin_bswap16(y); } while (0) +#define PPC_STORE_U32(x, y) do { __atomic_thread_fence(__ATOMIC_RELEASE); *(volatile uint32_t*)(base + (x)) = __builtin_bswap32(y); } while (0) +#define PPC_STORE_U64(x, y) do { __atomic_thread_fence(__ATOMIC_RELEASE); *(volatile uint64_t*)(base + (x)) = __builtin_bswap64(y); } while (0) +#endif + // The valid range spans to the image end, not just the code section: kernel import // thunks live directly after the code (first at exactly PPC_CODE_BASE+PPC_CODE_SIZE) // and are registered into the function table by Memory::InsertFunction. diff --git a/android-apk/app/src/main/java/org/libsdl/app/LauncherActivity.java b/android-apk/app/src/main/java/org/libsdl/app/LauncherActivity.java index 35c0edd9..5ce077a6 100644 --- a/android-apk/app/src/main/java/org/libsdl/app/LauncherActivity.java +++ b/android-apk/app/src/main/java/org/libsdl/app/LauncherActivity.java @@ -40,6 +40,10 @@ /** Lightweight pre-flight screen. It deliberately does not load SDL or Vulkan. */ public final class LauncherActivity extends Activity { private static final int REQUEST_DRIVER = 1001; + private static final int REQUEST_GAME_ZIP = 1002; + private static final int REQUEST_GAME_TREE = 1003; + private static final int REQUEST_MOD_ZIP = 1004; + private static final int REQUEST_MOD_TREE = 1005; private static final String[] DLC_DIRECTORIES = { "Apotos & Shamar Adventure Pack", "Chun-nan Adventure Pack", "Empire City & Adabat Adventure Pack", "Holoska Adventure Pack", @@ -99,6 +103,7 @@ private View buildPage() { LinearLayout files = card(R.string.launcher_game_files); installStatus = statusText(); files.addView(installStatus); + files.addView(button(R.string.launcher_install_game, view -> chooseInstallSource(true))); LinearLayout fileButtons = row(); fileButtons.addView(button(R.string.launcher_open_files, view -> openFiles("game:")), weighted()); fileButtons.addView(button(R.string.launcher_recheck, view -> refreshStatuses()), weighted()); @@ -129,8 +134,11 @@ private View buildPage() { LinearLayout mods = card(R.string.launcher_mods); mods.addView(text(getString(R.string.launcher_mods_summary), 14, false)); - mods.addView(button(R.string.launcher_manage_mods, - view -> startActivity(new Intent(this, ModManagerActivity.class)))); + LinearLayout modButtons = row(); + modButtons.addView(button(R.string.launcher_manage_mods, + view -> startActivity(new Intent(this, ModManagerActivity.class))), weighted()); + modButtons.addView(button(R.string.launcher_install_mod, view -> chooseInstallSource(false)), weighted()); + mods.addView(modButtons); page.addView(mods); LinearLayout debug = card(R.string.launcher_debug); @@ -205,10 +213,11 @@ private InstallState inspectInstallation() { return new InstallState(false, getString(R.string.error_storage_write, root)); } + // patched/default.xex is intentionally not required here: since the raw-dump + // installer landed, the native side creates it from game+update on first boot. LinkedHashMap required = new LinkedHashMap<>(); required.put("game/default.xex", new File(root, "game/default.xex")); required.put("update/default.xexp", new File(root, "update/default.xexp")); - required.put("patched/default.xex", new File(root, "patched/default.xex")); List missing = new ArrayList<>(); for (Map.Entry item : required.entrySet()) { if (!item.getValue().isFile() || item.getValue().length() == 0) { @@ -218,7 +227,7 @@ private InstallState inspectInstallation() { if (!missing.isEmpty()) { File misplaced = findFile(root, "default.xex", 3); if (misplaced != null && !misplaced.equals(required.get("game/default.xex")) && - !misplaced.equals(required.get("patched/default.xex"))) { + !misplaced.equals(new File(root, "patched/default.xex"))) { return new InstallState(false, getString(R.string.error_game_nested, relativePath(root, misplaced), root)); } @@ -320,7 +329,25 @@ private void chooseDriver() { @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); - if (requestCode != REQUEST_DRIVER || resultCode != RESULT_OK || data == null || data.getData() == null) return; + if (resultCode != RESULT_OK || data == null || data.getData() == null) return; + switch (requestCode) { + case REQUEST_GAME_ZIP: + startInstall(data.getData(), true, true); + return; + case REQUEST_GAME_TREE: + startInstall(data.getData(), false, true); + return; + case REQUEST_MOD_ZIP: + startInstall(data.getData(), true, false); + return; + case REQUEST_MOD_TREE: + startInstall(data.getData(), false, false); + return; + case REQUEST_DRIVER: + break; + default: + return; + } Uri uri = data.getData(); String name = queryDisplayName(uri); String lower = name.toLowerCase(Locale.ROOT); @@ -351,6 +378,304 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { } } + // ------------------------------------------------------------------ + // Game files / mod installer: copy from a user-picked ZIP archive or + // folder into the game root, locating the content root automatically. + // ------------------------------------------------------------------ + + private void chooseInstallSource(boolean gameFiles) { + String[] items = { getString(R.string.install_source_zip), getString(R.string.install_source_folder) }; + new AlertDialog.Builder(this) + .setTitle(gameFiles ? R.string.launcher_install_game : R.string.launcher_install_mod) + .setItems(items, (dialog, which) -> { + if (which == 0) { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[] { + "application/zip", "application/x-zip-compressed", "application/octet-stream" }); + startActivityForResult(intent, gameFiles ? REQUEST_GAME_ZIP : REQUEST_MOD_ZIP); + } else { + startActivityForResult(new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE), + gameFiles ? REQUEST_GAME_TREE : REQUEST_MOD_TREE); + } + }) + .show(); + } + + /** One copyable file inside the picked source, addressed by a relative path. */ + private static final class SourceEntry { + final String path; // normalized, '/'-separated, no leading slash + final Uri document; // tree sources only; null for zip entries + SourceEntry(String path, Uri document) { + this.path = path; + this.document = document; + } + } + + private static final class InstallProgress { + volatile boolean cancelled; + TextView label; + AlertDialog dialog; + int files; + long bytes; + } + + private void startInstall(Uri source, boolean isZip, boolean gameFiles) { + InstallProgress progress = new InstallProgress(); + progress.label = text(getString(R.string.install_scanning), 15, false); + progress.label.setPadding(dp(20), dp(16), dp(20), dp(16)); + progress.dialog = new AlertDialog.Builder(this) + .setTitle(R.string.install_progress_title) + .setView(progress.label) + .setCancelable(false) + .setNegativeButton(R.string.install_cancel, (dialog, which) -> progress.cancelled = true) + .show(); + + String fallbackModName = safeName(stripZipExtension(queryDisplayName(source))); + new Thread(() -> { + try { + List entries = isZip ? listZipEntries(source) : listTreeEntries(source); + Map plan = gameFiles + ? planGameInstall(entries) + : planModInstall(entries, fallbackModName); + int modCount = gameFiles ? 0 : countPlannedMods(plan); + + if (plan.isEmpty()) { + finishInstall(progress, getString(gameFiles + ? R.string.error_install_no_game : R.string.error_install_no_mod), false); + return; + } + + if (isZip) { + copyZipEntries(source, plan, progress); + } else { + copyTreeEntries(plan, progress); + } + + if (progress.cancelled) { + finishInstall(progress, getString(R.string.install_cancelled), false); + } else { + finishInstall(progress, gameFiles + ? getString(R.string.install_done_game) + : getString(R.string.install_done_mod, modCount), true); + } + } catch (Exception exception) { + String reason = exception.getMessage() != null + ? exception.getMessage() : exception.getClass().getSimpleName(); + finishInstall(progress, getString(R.string.error_install_failed, reason), false); + } + }, "installer").start(); + } + + private void finishInstall(InstallProgress progress, String message, boolean success) { + runOnUiThread(() -> { + progress.dialog.dismiss(); + new AlertDialog.Builder(this) + .setTitle(success ? R.string.install_progress_title : R.string.error_title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show(); + refreshStatuses(); + }); + } + + private void publishProgress(InstallProgress progress) { + String status = getString(R.string.install_progress_status, progress.files, formatBytes(progress.bytes)); + runOnUiThread(() -> progress.label.setText(status)); + } + + /** Normalizes a zip/tree relative path; returns null when it must be skipped. */ + private static String normalizeRelativePath(String raw) { + String path = raw.replace('\\', '/'); + while (path.startsWith("/")) path = path.substring(1); + if (path.isEmpty() || path.endsWith("/")) return null; + for (String segment : path.split("/")) { + if (segment.isEmpty() || segment.equals(".") || segment.equals("..")) return null; + } + return path; + } + + private List listZipEntries(Uri source) throws IOException { + List entries = new ArrayList<>(); + try (java.util.zip.ZipInputStream zip = new java.util.zip.ZipInputStream( + openSourceStream(source))) { + java.util.zip.ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + if (entry.isDirectory()) continue; + String path = normalizeRelativePath(entry.getName()); + if (path != null) entries.add(new SourceEntry(path, null)); + } + } + return entries; + } + + private List listTreeEntries(Uri treeUri) { + List entries = new ArrayList<>(); + listTreeChildren(treeUri, DocumentsContract.getTreeDocumentId(treeUri), "", entries, 0); + return entries; + } + + private void listTreeChildren(Uri treeUri, String documentId, String prefix, + List out, int depth) { + if (depth > 8) return; + Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, documentId); + try (android.database.Cursor cursor = getContentResolver().query(children, new String[] { + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE }, null, null, null)) { + if (cursor == null) return; + while (cursor.moveToNext()) { + String childId = cursor.getString(0); + String name = cursor.getString(1); + String mime = cursor.getString(2); + if (name == null || childId == null) continue; + if (DocumentsContract.Document.MIME_TYPE_DIR.equals(mime)) { + listTreeChildren(treeUri, childId, prefix + name + "/", out, depth + 1); + } else { + String path = normalizeRelativePath(prefix + name); + if (path != null) { + out.add(new SourceEntry(path, + DocumentsContract.buildDocumentUriUsingTree(treeUri, childId))); + } + } + } + } + } + + /** Everything under the folder that holds game/default.xex goes into the game root. */ + private Map planGameInstall(List entries) { + String marker = "game/default.xex"; + String prefix = null; + for (SourceEntry entry : entries) { + if (entry.path.equals(marker) || entry.path.endsWith("/" + marker)) { + String candidate = entry.path.substring(0, entry.path.length() - marker.length()); + if (prefix == null || candidate.length() < prefix.length()) prefix = candidate; + } + } + Map plan = new LinkedHashMap<>(); + if (prefix == null) return plan; + File root = AppStorage.activeGameRoot(this); + for (SourceEntry entry : entries) { + if (entry.path.startsWith(prefix)) { + plan.put(entry, new File(root, entry.path.substring(prefix.length()))); + } + } + return plan; + } + + /** Each folder holding a mod.ini becomes /mods/. */ + private Map planModInstall(List entries, String fallbackName) { + List roots = new ArrayList<>(); + for (SourceEntry entry : entries) { + if (entry.path.equals("mod.ini") || entry.path.endsWith("/mod.ini")) { + roots.add(entry.path.substring(0, entry.path.length() - "mod.ini".length())); + } + } + // Outermost roots only: a nested mod.ini belongs to its parent mod's content. + List outer = new ArrayList<>(); + for (String root : roots) { + boolean nested = false; + for (String other : roots) { + if (!other.equals(root) && root.startsWith(other)) { nested = true; break; } + } + if (!nested) outer.add(root); + } + + Map plan = new LinkedHashMap<>(); + File modsDir = new File(AppStorage.activeGameRoot(this), "mods"); + for (SourceEntry entry : entries) { + for (String root : outer) { + if (!entry.path.startsWith(root)) continue; + String folderName = root.isEmpty() + ? fallbackName + : root.substring(root.lastIndexOf('/', root.length() - 2) + 1, root.length() - 1); + plan.put(entry, new File(new File(modsDir, safeName(folderName)), + entry.path.substring(root.length()))); + break; + } + } + return plan; + } + + private static int countPlannedMods(Map plan) { + java.util.HashSet modDirs = new java.util.HashSet<>(); + for (File destination : plan.values()) { + File parent = destination; + while (parent.getParentFile() != null + && !"mods".equals(parent.getParentFile().getName())) { + parent = parent.getParentFile(); + } + modDirs.add(parent.getName()); + } + return modDirs.size(); + } + + private InputStream openSourceStream(Uri uri) throws IOException { + InputStream input = getContentResolver().openInputStream(uri); + if (input == null) throw new IOException("Cannot open the selected source"); + return input; + } + + private void copyZipEntries(Uri source, Map plan, InstallProgress progress) + throws IOException { + Map byPath = new LinkedHashMap<>(); + for (Map.Entry item : plan.entrySet()) { + byPath.put(item.getKey().path, item.getValue()); + } + try (java.util.zip.ZipInputStream zip = new java.util.zip.ZipInputStream( + openSourceStream(source))) { + java.util.zip.ZipEntry entry; + while ((entry = zip.getNextEntry()) != null && !progress.cancelled) { + if (entry.isDirectory()) continue; + String path = normalizeRelativePath(entry.getName()); + File destination = path != null ? byPath.get(path) : null; + if (destination == null) continue; + copyStreamToFile(zip, destination, progress); + } + } + } + + private void copyTreeEntries(Map plan, InstallProgress progress) + throws IOException { + for (Map.Entry item : plan.entrySet()) { + if (progress.cancelled) return; + try (InputStream input = openSourceStream(item.getKey().document)) { + copyStreamToFile(input, item.getValue(), progress); + } + } + } + + private void copyStreamToFile(InputStream input, File destination, InstallProgress progress) + throws IOException { + File parent = destination.getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("Cannot create " + parent); + } + try (FileOutputStream output = new FileOutputStream(destination)) { + byte[] buffer = new byte[256 * 1024]; + int count; + long sinceUpdate = 0; + while ((count = input.read(buffer)) >= 0) { + if (progress.cancelled) return; + output.write(buffer, 0, count); + progress.bytes += count; + sinceUpdate += count; + if (sinceUpdate >= 16 * 1024 * 1024) { + sinceUpdate = 0; + publishProgress(progress); + } + } + } + progress.files++; + if (progress.files % 25 == 0) publishProgress(progress); + } + + private static String stripZipExtension(String name) { + return name.toLowerCase(Locale.ROOT).endsWith(".zip") + ? name.substring(0, name.length() - 4) : name; + } + private void openFiles(String documentId) { try { Uri directory = DocumentsContract.buildRootUri(getPackageName() + ".documents", diff --git a/android-apk/app/src/main/java/org/libsdl/app/ModManagerActivity.java b/android-apk/app/src/main/java/org/libsdl/app/ModManagerActivity.java index 2e46c943..8155a959 100644 --- a/android-apk/app/src/main/java/org/libsdl/app/ModManagerActivity.java +++ b/android-apk/app/src/main/java/org/libsdl/app/ModManagerActivity.java @@ -213,23 +213,18 @@ private void scanForMods(File directory, int depth, Map result Collections.addAll(sorted, children); sorted.sort(Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); + // Parse the ini files of this level before descending: "mod" sorts before + // "mod.ini", so a mod whose content folders hold thousands of files used to + // exhaust the scan budget before its own mod.ini was ever read, hiding it + // and every mod after it (issue #58). + boolean foundModHere = false; for (File child : sorted) { - if (scannedFiles >= MAX_SCANNED_FILES) { - break; - } - if (Files.isSymbolicLink(child.toPath())) { - continue; - } - if (child.isDirectory()) { - scanForMods(child, depth + 1, result); + if (!child.isFile() || !child.getName().toLowerCase(Locale.ROOT).endsWith(".ini") + || Files.isSymbolicLink(child.toPath())) { continue; } scannedFiles++; - if (!child.getName().toLowerCase(Locale.ROOT).endsWith(".ini")) { - continue; - } - Map> ini = readIni(child); if (!isSupportedModIni(ini)) { continue; @@ -242,10 +237,31 @@ private void scanForMods(File directory, int depth, Map result continue; } result.put(canonicalPath, new ModEntry(child, canonicalPath, findTitle(child, ini))); + foundModHere = true; } catch (IOException ignored) { // A path that cannot be canonicalised cannot be handed safely to ModLoader. } } + + // A mod's folder only contains its own content; other mods never nest inside + // one, so skipping the descent keeps the scan budget for sibling mods. + if (foundModHere) { + return; + } + + for (File child : sorted) { + if (scannedFiles >= MAX_SCANNED_FILES) { + break; + } + if (Files.isSymbolicLink(child.toPath())) { + continue; + } + if (child.isDirectory()) { + scanForMods(child, depth + 1, result); + } else if (!child.getName().toLowerCase(Locale.ROOT).endsWith(".ini")) { + scannedFiles++; + } + } } private static boolean isSupportedModIni(Map> ini) { diff --git a/android-apk/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android-apk/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index bc5fad25..d1adcefb 100644 --- a/android-apk/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android-apk/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,6 @@ - + diff --git a/android-apk/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android-apk/app/src/main/res/mipmap-hdpi/ic_launcher.png index 4bb09105..a2a54025 100644 Binary files a/android-apk/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android-apk/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android-apk/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android-apk/app/src/main/res/mipmap-mdpi/ic_launcher.png index b8a955ff..7fd031a3 100644 Binary files a/android-apk/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android-apk/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android-apk/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android-apk/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 845f3d8a..71ef01dd 100644 Binary files a/android-apk/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android-apk/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android-apk/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android-apk/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index ec0b652f..038d0b5b 100644 Binary files a/android-apk/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android-apk/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android-apk/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android-apk/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 24443c8d..5e5efc36 100644 Binary files a/android-apk/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android-apk/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android-apk/app/src/main/res/mipmap/ic_launcher_foreground.png b/android-apk/app/src/main/res/mipmap/ic_launcher_foreground.png index 63657bc2..db1b04b3 100644 Binary files a/android-apk/app/src/main/res/mipmap/ic_launcher_foreground.png and b/android-apk/app/src/main/res/mipmap/ic_launcher_foreground.png differ diff --git a/android-apk/app/src/main/res/mipmap/ic_launcher_monochrome.png b/android-apk/app/src/main/res/mipmap/ic_launcher_monochrome.png index 1e2a421b..fc9829ae 100644 Binary files a/android-apk/app/src/main/res/mipmap/ic_launcher_monochrome.png and b/android-apk/app/src/main/res/mipmap/ic_launcher_monochrome.png differ diff --git a/android-apk/app/src/main/res/values-de/strings.xml b/android-apk/app/src/main/res/values-de/strings.xml new file mode 100644 index 00000000..9bad0307 --- /dev/null +++ b/android-apk/app/src/main/res/values-de/strings.xml @@ -0,0 +1,94 @@ + + UnleashedRecomp + Unleashed Recomp Spieldateien + Aktives Spiel, Mods, Konfiguration und Spielstände + Unleashed Recomp Transfers + Mods, Treiber-Importe, Logs und Diagnose-Aufzeichnungen + Unleashed Mods + Unleashed Mods + Kopiere jeden Mod-Ordner in das mods-Verzeichnis, aktualisiere diese Liste, aktiviere Mods und lege ihre Priorität fest. Speichern schreibt die standardmäßige CPKREDIR-Datenbank; starte das Spiel neu, um Änderungen zu übernehmen. + Dateien öffnen + Aktualisieren + Mod-Liste speichern + Keine kompatiblen mod.ini-Dateien gefunden. Öffne den Mod-Ordner, erstelle mods/ und kopiere HMM- oder UMM-Mods hinein. + %1$d kompatible Mods gefunden. + %1$d aktivierte Mods in Prioritätsreihenfolge gespeichert. + Mod-Änderungen werden nach einem Neustart des Spiels übernommen. + Konfigurationsverzeichnis kann nicht erstellt werden: %1$s + Mod-Datenbank kann nicht gespeichert werden: %1$s + Android-Dateien konnte nicht geöffnet werden. + + Unleashed Recomp + Prüfe die Installation und konfiguriere das Spiel vor dem Start. + Spieldateien + Spielordner öffnen + Erneut prüfen + Grafiktreiber + Vulkan-Treiber + Turnip-Rendermodus + .so / .zip importieren + Treiberordner + Steuerung + Bildschirmsteuerung + Kamerasteuerung + Touch-Steuerung anordnen + Mods + Kompatible HMM/UMM-Mods aktivieren und ihre Priorität festlegen. + Mods verwalten + Debug-Optionen + FPS anzeigen + Profiler-Overlay anzeigen + Intro-Logos überspringen + Vulkan-Synchronisationsvalidierung (langsam) + GFXReconstruct-Trace aufzeichnen (sehr groß und langsam) + Logs und Aufzeichnungen öffnen + Spiel starten + %1$d Treiberpaket(e) werden beim nächsten Start installiert. + Importierter Treiber verfügbar: %1$s + Mitgelieferte Turnip-Treiber und der Systemtreiber sind verfügbar. Kein importierter Treiber gefunden. + Der letzte Vulkan-Start wurde nicht abgeschlossen. Die automatische Wiederherstellung verwendet einen sichereren Treiber; wähle hier System, falls das Problem erneut auftritt. + Neuestes Log gefunden (%1$s). + Noch kein Log. Ein Log wird beim Spielstart erstellt. + Startbereit. Hauptspiel, Update und alle DLCs wurden erkannt. + Startbereit. Hauptspiel und Update erkannt; DLC-Pakete: %1$d von %2$d. + Fortsetzen nicht möglich + Die App kann ihren Speicherordner nicht erstellen:\n%1$s\n\nPrüfe freien Speicherplatz und Speicherverfügbarkeit. + Der Spielordner ist nicht beschreibbar:\n%1$s\n\nEr wurde möglicherweise von adb oder einem anderen Benutzer erstellt. Lösche ihn und lass die App ihn neu erstellen. + Spieldateien sind nicht installiert. Kopiere die entpackten Xbox-360-Dump-Ordner game, update und patched nach:\n%1$s\n\nKopiere keine APK, PC-Spieldateien oder Verknüpfungen. + Die Installation ist unvollständig oder beschädigt. Fehlende erforderliche Dateien:%1$s\n\nErwartetes Stammverzeichnis:\n%2$s + Die Spieldateien scheinen im falschen Ordner verschachtelt zu sein (%1$s). Verschiebe die Ordner game, update, patched und optional dlc direkt nach:\n%2$s + Einstellungen konnten nicht gespeichert werden: %1$s + Der Layout-Editor konnte nicht gestartet werden: %1$s + Der Layout-Editor verwendet den Spiel-Renderer, daher ist eine gültige Spielinstallation erforderlich. + Wähle eine einfache Vulkan-.so-Datei oder ein AdrenoTools/ExynosTools-.zip-Paket. + Treiber konnte nicht importiert werden: %1$s + Android-Dateien konnte den App-Ordner nicht öffnen. + %1$s wurde kopiert. Er wird beim Spielstart geprüft und installiert. + Spieldateien installieren (.zip / Ordner) + Mod installieren (.zip / Ordner) + Aus einem ZIP-Archiv + Aus einem Ordner + Installiere… + %1$d Dateien kopiert, %2$s + Quelle wird gelesen… + Abbrechen + Spieldateien installiert. Die gepatchte ausführbare Datei wird bei Bedarf beim ersten Start automatisch erstellt. + Installierte Mods: %1$d. Aktiviere sie im Mod-Manager. + Installation abgebrochen. Teilweise kopierte Dateien blieben erhalten. + Installation fehlgeschlagen: %1$s + Die gewählte Quelle enthält kein game/default.xex. Wähle den Dump mit den Ordnern game, update und optional dlc. + Die gewählte Quelle enthält keine mod.ini, daher wurde kein Mod gefunden. + + + Auto (empfohlen)SystemMitgeliefertes TurnipAdreno 710 (Vauzi)Importiert + + + Auto (empfohlen)GMEMSysmem + + + AutoImmer anAus + + + Wischen auf dem BildschirmRechter StickAus + + diff --git a/android-apk/app/src/main/res/values-es/strings.xml b/android-apk/app/src/main/res/values-es/strings.xml new file mode 100644 index 00000000..81937e32 --- /dev/null +++ b/android-apk/app/src/main/res/values-es/strings.xml @@ -0,0 +1,94 @@ + + UnleashedRecomp + Archivos del juego Unleashed Recomp + Juego activo, mods, configuración y partidas guardadas + Transferencias de Unleashed Recomp + Mods, importaciones de controladores, registros y capturas de diagnóstico + Mods de Unleashed + Mods de Unleashed + Copia cada carpeta de mod en el directorio mods, actualiza esta lista, activa los mods y ordena su prioridad. Guardar escribe la base de datos CPKREDIR estándar; reinicia el juego para aplicar los cambios. + Abrir archivos + Actualizar + Guardar lista de mods + No se encontraron archivos mod.ini compatibles. Abre la carpeta de mods, crea mods/ y copia allí mods HMM o UMM. + Se encontraron %1$d mods compatibles. + Se guardaron %1$d mods activados en orden de prioridad. + Los cambios de mods se aplicarán tras reiniciar el juego. + No se puede crear el directorio de configuración: %1$s + No se puede guardar la base de datos de mods: %1$s + No se pudo abrir Archivos de Android. + + Unleashed Recomp + Comprueba la instalación y configura el juego antes de iniciarlo. + Archivos del juego + Abrir carpeta del juego + Comprobar de nuevo + Controlador gráfico + Controlador Vulkan + Modo de renderizado Turnip + Importar .so / .zip + Carpeta de controladores + Controles + Controles en pantalla + Control de cámara + Organizar controles táctiles + Mods + Activa mods HMM/UMM compatibles y establece su prioridad. + Gestionar mods + Opciones de depuración + Mostrar FPS + Mostrar el perfilador + Omitir logotipos de introducción + Validación de sincronización Vulkan (lento) + Grabar traza GFXReconstruct (muy grande y lento) + Abrir registros y capturas + Iniciar juego + %1$d paquete(s) de controladores se instalarán en el próximo inicio. + Controlador importado disponible: %1$s + Los controladores Turnip incluidos y el del sistema están disponibles. No se encontró ningún controlador importado. + El último arranque de Vulkan no terminó. La recuperación automática usará un controlador más seguro; elige Sistema aquí si el problema se repite. + Último registro encontrado (%1$s). + Aún no hay registro. Se crea uno al iniciar el juego. + Listo para iniciar. Se detectaron el juego base, la actualización y todos los DLC. + Listo para iniciar. Juego base y actualización detectados; packs DLC: %1$d de %2$d. + No se puede continuar + La aplicación no puede crear su carpeta de almacenamiento:\n%1$s\n\nComprueba el espacio libre y la disponibilidad del almacenamiento. + No se puede escribir en la carpeta del juego:\n%1$s\n\nPuede haber sido creada por adb u otro usuario. Elimínala y deja que la aplicación la vuelva a crear. + Los archivos del juego no están instalados. Copia las carpetas extraídas del volcado de Xbox 360 game, update y patched en:\n%1$s\n\nNo copies un APK, archivos de la versión de PC ni accesos directos. + La instalación está incompleta o dañada. Archivos requeridos que faltan:%1$s\n\nRaíz esperada:\n%2$s + Los archivos del juego parecen estar anidados en la carpeta equivocada (%1$s). Mueve las carpetas game, update, patched y opcionalmente dlc directamente a:\n%2$s + No se pudieron guardar los ajustes: %1$s + No se pudo iniciar el editor de disposición: %1$s + El editor de disposición usa el renderizador del juego, por lo que se requiere una instalación válida del juego. + Elige un archivo Vulkan .so simple o un paquete .zip de AdrenoTools/ExynosTools. + No se pudo importar el controlador: %1$s + Archivos de Android no pudo abrir la carpeta de la aplicación. + %1$s se copió. Se validará e instalará al iniciar el juego. + Instalar archivos del juego (.zip / carpeta) + Instalar un mod (.zip / carpeta) + Desde un archivo ZIP + Desde una carpeta + Instalando… + %1$d archivos copiados, %2$s + Leyendo el origen… + Cancelar + Archivos del juego instalados. El ejecutable parcheado se prepara automáticamente en el primer inicio si es necesario. + Mods instalados: %1$d. Actívalos en el gestor de mods. + Instalación cancelada. Los archivos copiados parcialmente se dejaron en su lugar. + La instalación falló: %1$s + El origen seleccionado no contiene game/default.xex. Elige el volcado que contiene las carpetas game, update y opcionalmente dlc. + El origen seleccionado no contiene ningún mod.ini, así que no se encontró ningún mod. + + + Auto (recomendado)SistemaTurnip incluidoAdreno 710 (Vauzi)Importado + + + Auto (recomendado)GMEMSysmem + + + AutoSiempre activadosDesactivados + + + Deslizar en la pantallaStick derechoDesactivado + + diff --git a/android-apk/app/src/main/res/values-fr/strings.xml b/android-apk/app/src/main/res/values-fr/strings.xml new file mode 100644 index 00000000..192d671e --- /dev/null +++ b/android-apk/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,94 @@ + + UnleashedRecomp + Fichiers du jeu Unleashed Recomp + Jeu actif, mods, configuration et sauvegardes + Transferts Unleashed Recomp + Mods, imports de pilotes, journaux et captures de diagnostic + Mods Unleashed + Mods Unleashed + Copiez chaque dossier de mod dans le répertoire mods, actualisez cette liste, activez les mods et définissez leur priorité. L\'enregistrement écrit la base CPKREDIR standard ; redémarrez le jeu pour appliquer les changements. + Ouvrir les fichiers + Actualiser + Enregistrer la liste des mods + Aucun fichier mod.ini compatible trouvé. Ouvrez le dossier des mods, créez mods/, puis copiez-y des mods HMM ou UMM. + %1$d mods compatibles trouvés. + %1$d mods activés enregistrés par ordre de priorité. + Les changements de mods seront appliqués après le redémarrage du jeu. + Impossible de créer le répertoire de configuration : %1$s + Impossible d\'enregistrer la base des mods : %1$s + Impossible d\'ouvrir Fichiers Android. + + Unleashed Recomp + Vérifiez l\'installation et configurez le jeu avant de le lancer. + Fichiers du jeu + Ouvrir le dossier du jeu + Vérifier à nouveau + Pilote graphique + Pilote Vulkan + Mode de rendu Turnip + Importer .so / .zip + Dossier des pilotes + Commandes + Commandes à l\'écran + Contrôle de la caméra + Disposer les commandes tactiles + Mods + Activez les mods HMM/UMM compatibles et définissez leur priorité. + Gérer les mods + Options de débogage + Afficher les FPS + Afficher le profileur + Passer les logos d\'intro + Validation de synchronisation Vulkan (lent) + Enregistrer une trace GFXReconstruct (très volumineux et lent) + Ouvrir les journaux et captures + Lancer le jeu + %1$d paquet(s) de pilotes seront installés au prochain lancement. + Pilote importé disponible : %1$s + Les pilotes Turnip intégrés et le pilote système sont disponibles. Aucun pilote importé trouvé. + Le dernier démarrage Vulkan ne s\'est pas terminé. La récupération automatique utilisera un pilote plus sûr ; choisissez Système ici si le problème se répète. + Dernier journal trouvé (%1$s). + Pas encore de journal. Un journal est créé au démarrage du jeu. + Prêt à lancer. Jeu de base, mise à jour et tous les DLC détectés. + Prêt à lancer. Jeu de base et mise à jour détectés ; packs DLC : %1$d sur %2$d. + Impossible de continuer + L\'application ne peut pas créer son dossier de stockage :\n%1$s\n\nVérifiez l\'espace libre et la disponibilité du stockage. + Le dossier du jeu n\'est pas accessible en écriture :\n%1$s\n\nIl a peut-être été créé par adb ou un autre utilisateur. Supprimez-le et laissez l\'application le recréer. + Les fichiers du jeu ne sont pas installés. Copiez les dossiers extraits du dump Xbox 360 game, update et patched dans :\n%1$s\n\nNe copiez pas d\'APK, de fichiers PC ou de raccourci. + L\'installation est incomplète ou endommagée. Fichiers requis manquants :%1$s\n\nRacine attendue :\n%2$s + Les fichiers du jeu semblent imbriqués dans le mauvais dossier (%1$s). Déplacez les dossiers game, update, patched et éventuellement dlc directement dans :\n%2$s + Impossible d\'enregistrer les paramètres : %1$s + Impossible de démarrer l\'éditeur de disposition : %1$s + L\'éditeur de disposition utilise le moteur de rendu du jeu, une installation valide du jeu est donc requise. + Choisissez un fichier Vulkan .so simple ou un paquet .zip AdrenoTools/ExynosTools. + Impossible d\'importer le pilote : %1$s + Fichiers Android n\'a pas pu ouvrir le dossier de l\'application. + %1$s a été copié. Il sera validé et installé au lancement du jeu. + Installer les fichiers du jeu (.zip / dossier) + Installer un mod (.zip / dossier) + Depuis une archive ZIP + Depuis un dossier + Installation… + %1$d fichiers copiés, %2$s + Lecture de la source… + Annuler + Fichiers du jeu installés. L\'exécutable patché est préparé automatiquement au premier lancement si nécessaire. + Mods installés : %1$d. Activez-les dans le gestionnaire de mods. + Installation annulée. Les fichiers partiellement copiés ont été conservés. + Échec de l\'installation : %1$s + La source sélectionnée ne contient pas game/default.xex. Choisissez le dump contenant les dossiers game, update et éventuellement dlc. + La source sélectionnée ne contient pas de mod.ini, aucun mod n\'a donc été trouvé. + + + Auto (recommandé)SystèmeTurnip intégréAdreno 710 (Vauzi)Importé + + + Auto (recommandé)GMEMSysmem + + + AutoToujours activéesDésactivées + + + Glisser sur l\'écranStick droitDésactivé + + diff --git a/android-apk/app/src/main/res/values-it/strings.xml b/android-apk/app/src/main/res/values-it/strings.xml new file mode 100644 index 00000000..58a5a448 --- /dev/null +++ b/android-apk/app/src/main/res/values-it/strings.xml @@ -0,0 +1,94 @@ + + UnleashedRecomp + File di gioco di Unleashed Recomp + Gioco attivo, mod, configurazione e salvataggi + Trasferimenti di Unleashed Recomp + Mod, importazioni di driver, log e acquisizioni diagnostiche + Mod di Unleashed + Mod di Unleashed + Copia ogni cartella di mod nella directory mods, aggiorna questo elenco, attiva le mod e ordina la loro priorità. Il salvataggio scrive il database CPKREDIR standard; riavvia il gioco per applicare le modifiche. + Apri file + Aggiorna + Salva elenco mod + Nessun file mod.ini compatibile trovato. Apri la cartella delle mod, crea mods/ e copiaci dentro mod HMM o UMM. + Trovate %1$d mod compatibili. + Salvate %1$d mod attivate in ordine di priorità. + Le modifiche alle mod verranno applicate dopo il riavvio del gioco. + Impossibile creare la directory di configurazione: %1$s + Impossibile salvare il database delle mod: %1$s + Impossibile aprire File di Android. + + Unleashed Recomp + Verifica l\'installazione e configura il gioco prima dell\'avvio. + File di gioco + Apri cartella del gioco + Controlla di nuovo + Driver grafico + Driver Vulkan + Modalità di rendering Turnip + Importa .so / .zip + Cartella dei driver + Controlli + Controlli su schermo + Controllo della telecamera + Disponi i controlli touch + Mod + Attiva mod HMM/UMM compatibili e imposta la loro priorità. + Gestisci mod + Opzioni di debug + Mostra FPS + Mostra il profiler + Salta i loghi introduttivi + Validazione sincronizzazione Vulkan (lenta) + Registra traccia GFXReconstruct (molto grande e lenta) + Apri log e acquisizioni + Avvia il gioco + %1$d pacchetto/i di driver verranno installati al prossimo avvio. + Driver importato disponibile: %1$s + Sono disponibili i driver Turnip inclusi e il driver di sistema. Nessun driver importato trovato. + L\'ultimo avvio di Vulkan non è stato completato. Il ripristino automatico userà un driver più sicuro; scegli Sistema qui se il problema si ripete. + Ultimo log trovato (%1$s). + Nessun log ancora. Un log viene creato all\'avvio del gioco. + Pronto all\'avvio. Rilevati gioco base, aggiornamento e tutti i DLC. + Pronto all\'avvio. Rilevati gioco base e aggiornamento; pacchetti DLC: %1$d di %2$d. + Impossibile continuare + L\'app non può creare la sua cartella di archiviazione:\n%1$s\n\nControlla lo spazio libero e la disponibilità dell\'archiviazione. + La cartella del gioco non è scrivibile:\n%1$s\n\nPotrebbe essere stata creata da adb o da un altro utente. Eliminala e lascia che l\'app la ricrei. + I file di gioco non sono installati. Copia le cartelle estratte dal dump Xbox 360 game, update e patched in:\n%1$s\n\nNon copiare un APK, file della versione PC o collegamenti. + L\'installazione è incompleta o danneggiata. File richiesti mancanti:%1$s\n\nRadice prevista:\n%2$s + I file di gioco sembrano annidati nella cartella sbagliata (%1$s). Sposta le cartelle game, update, patched ed eventualmente dlc direttamente in:\n%2$s + Impossibile salvare le impostazioni: %1$s + Impossibile avviare l\'editor del layout: %1$s + L\'editor del layout usa il renderer del gioco, quindi è richiesta un\'installazione valida del gioco. + Scegli un semplice file Vulkan .so o un pacchetto .zip AdrenoTools/ExynosTools. + Impossibile importare il driver: %1$s + File di Android non ha potuto aprire la cartella dell\'applicazione. + %1$s è stato copiato. Verrà convalidato e installato all\'avvio del gioco. + Installa i file di gioco (.zip / cartella) + Installa una mod (.zip / cartella) + Da un archivio ZIP + Da una cartella + Installazione… + %1$d file copiati, %2$s + Lettura dell\'origine… + Annulla + File di gioco installati. L\'eseguibile con patch viene preparato automaticamente al primo avvio se necessario. + Mod installate: %1$d. Attivale nel gestore delle mod. + Installazione annullata. I file copiati parzialmente sono stati lasciati al loro posto. + Installazione non riuscita: %1$s + L\'origine selezionata non contiene game/default.xex. Scegli il dump che contiene le cartelle game, update ed eventualmente dlc. + L\'origine selezionata non contiene alcun mod.ini, quindi non è stata trovata nessuna mod. + + + Auto (consigliato)SistemaTurnip inclusoAdreno 710 (Vauzi)Importato + + + Auto (consigliato)GMEMSysmem + + + AutoSempre attiviDisattivati + + + Scorrimento sullo schermoStick destroDisattivato + + diff --git a/android-apk/app/src/main/res/values-ja/strings.xml b/android-apk/app/src/main/res/values-ja/strings.xml new file mode 100644 index 00000000..d2d13154 --- /dev/null +++ b/android-apk/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,94 @@ + + UnleashedRecomp + Unleashed Recomp ゲームファイル + ゲーム本体、MOD、設定、セーブデータ + Unleashed Recomp 転送フォルダー + MOD、ドライバーのインポート、ログ、診断キャプチャ + Unleashed MOD + Unleashed MOD + 各MODフォルダーを mods ディレクトリにコピーし、このリストを更新して、MODを有効化し優先順位を並べ替えてください。保存すると標準の CPKREDIR データベースに書き込まれます。変更の適用にはゲームの再起動が必要です。 + ファイルを開く + 更新 + MODリストを保存 + 互換性のある mod.ini が見つかりません。「フォルダーを開く」で mods/ を作成し、HMM または UMM のMODをコピーしてください。 + 互換性のあるMODが %1$d 件見つかりました。 + 有効なMOD %1$d 件を優先順位順に保存しました。 + MODの変更はゲームの再起動後に適用されます。 + 設定ディレクトリを作成できません: %1$s + MODデータベースを保存できません: %1$s + Android のファイルアプリを開けませんでした。 + + Unleashed Recomp + 起動前にインストール状態を確認し、ゲームを設定してください。 + ゲームファイル + ゲームフォルダーを開く + 再確認 + グラフィックドライバー + Vulkan ドライバー + Turnip レンダーモード + .so / .zip をインポート + ドライバーフォルダー + 操作 + 画面上のコントロール + カメラ操作 + タッチコントロールの配置 + MOD + 互換性のある HMM/UMM MOD を有効化し、優先順位を設定します。 + MODの管理 + デバッグオプション + FPS を表示 + プロファイラーオーバーレイを表示 + オープニングロゴをスキップ + Vulkan 同期検証(低速) + GFXReconstruct トレースを記録(非常に大きく低速) + ログとキャプチャを開く + ゲームを起動 + %1$d 個のドライバーパッケージが次回起動時にインストールされます。 + インポート済みドライバー: %1$s + 同梱の Turnip ドライバーとシステムドライバーが利用可能です。インポート済みドライバーはありません。 + 前回の Vulkan 起動が完了しませんでした。自動リカバリーがより安全なドライバーを使用します。問題が繰り返す場合はここで「システム」を選択してください。 + 最新のログが見つかりました(%1$s)。 + まだログはありません。ログはゲーム開始時に作成されます。 + 起動できます。ゲーム本体、アップデート、すべてのDLCを検出しました。 + 起動できます。ゲーム本体とアップデートを検出。DLCパック: %1$d / %2$d。 + 続行できません + アプリはストレージフォルダーを作成できません:\n%1$s\n\n空き容量とストレージの状態を確認してください。 + ゲームフォルダーに書き込めません:\n%1$s\n\nadb や別のユーザーによって作成された可能性があります。削除してアプリに再作成させてください。 + ゲームファイルがインストールされていません。展開した Xbox 360 ダンプの game、update、patched フォルダーを次の場所にコピーしてください:\n%1$s\n\nAPK、PC版ファイル、ショートカットはコピーしないでください。 + インストールが不完全か破損しています。不足している必須ファイル:%1$s\n\n想定されるルート:\n%2$s + ゲームファイルが誤ったフォルダー(%1$s)に入れ子になっているようです。game、update、patched および任意の dlc フォルダーを直接次の場所に移動してください:\n%2$s + 設定を保存できませんでした: %1$s + レイアウトエディターを開始できませんでした: %1$s + レイアウトエディターはゲームのレンダラーを使用するため、有効なゲームのインストールが必要です。 + Vulkan の .so ファイル、または AdrenoTools/ExynosTools の .zip パッケージを選択してください。 + ドライバーをインポートできませんでした: %1$s + Android のファイルアプリでアプリのフォルダーを開けませんでした。 + %1$s をコピーしました。ゲーム起動時に検証・インストールされます。 + ゲームファイルをインストール(.zip / フォルダー) + MODをインストール(.zip / フォルダー) + ZIP アーカイブから + フォルダーから + インストール中… + %1$d ファイルをコピー済み、%2$s + ソースを読み取り中… + キャンセル + ゲームファイルをインストールしました。必要に応じて、パッチ済み実行ファイルは初回起動時に自動的に作成されます。 + インストールしたMOD: %1$d 件。MODマネージャーで有効化してください。 + インストールをキャンセルしました。コピー途中のファイルはそのまま残っています。 + インストールに失敗しました: %1$s + 選択したソースに game/default.xex が含まれていません。game、update、任意の dlc フォルダーを含むダンプを選択してください。 + 選択したソースに mod.ini が含まれていないため、MODが見つかりませんでした。 + + + 自動(推奨)システム同梱 TurnipAdreno 710 (Vauzi)インポート済み + + + 自動(推奨)GMEMSysmem + + + 自動常に表示オフ + + + 画面スワイプ右スティックオフ + + diff --git a/android-apk/app/src/main/res/values-pt/strings.xml b/android-apk/app/src/main/res/values-pt/strings.xml new file mode 100644 index 00000000..c47fd538 --- /dev/null +++ b/android-apk/app/src/main/res/values-pt/strings.xml @@ -0,0 +1,94 @@ + + UnleashedRecomp + Arquivos do jogo Unleashed Recomp + Jogo ativo, mods, configuração e saves + Transferências do Unleashed Recomp + Mods, importações de drivers, logs e capturas de diagnóstico + Mods do Unleashed + Mods do Unleashed + Copie cada pasta de mod para o diretório mods, atualize esta lista, ative os mods e organize a prioridade deles. Salvar grava o banco de dados CPKREDIR padrão; reinicie o jogo para aplicar as mudanças. + Abrir arquivos + Atualizar + Salvar lista de mods + Nenhum arquivo mod.ini compatível encontrado. Abra a pasta de mods, crie mods/ e copie mods HMM ou UMM para dentro dela. + %1$d mods compatíveis encontrados. + %1$d mods ativados salvos em ordem de prioridade. + As mudanças de mods serão aplicadas após reiniciar o jogo. + Não foi possível criar o diretório de configuração: %1$s + Não foi possível salvar o banco de dados de mods: %1$s + Não foi possível abrir o Arquivos do Android. + + Unleashed Recomp + Verifique a instalação e configure o jogo antes de iniciar. + Arquivos do jogo + Abrir pasta do jogo + Verificar novamente + Driver gráfico + Driver Vulkan + Modo de renderização Turnip + Importar .so / .zip + Pasta de drivers + Controles + Controles na tela + Controle da câmera + Organizar controles de toque + Mods + Ative mods HMM/UMM compatíveis e defina a prioridade deles. + Gerenciar mods + Opções de depuração + Mostrar FPS + Mostrar o profiler + Pular logotipos de introdução + Validação de sincronização Vulkan (lento) + Gravar trace GFXReconstruct (muito grande e lento) + Abrir logs e capturas + Iniciar jogo + %1$d pacote(s) de driver serão instalados na próxima inicialização. + Driver importado disponível: %1$s + Os drivers Turnip incluídos e o driver do sistema estão disponíveis. Nenhum driver importado encontrado. + A última inicialização do Vulkan não foi concluída. A recuperação automática usará um driver mais seguro; escolha Sistema aqui se o problema se repetir. + Último log encontrado (%1$s). + Ainda não há log. Um log é criado quando o jogo inicia. + Pronto para iniciar. Jogo base, atualização e todos os DLCs foram detectados. + Pronto para iniciar. Jogo base e atualização detectados; pacotes DLC: %1$d de %2$d. + Não é possível continuar + O aplicativo não consegue criar sua pasta de armazenamento:\n%1$s\n\nVerifique o espaço livre e a disponibilidade do armazenamento. + Não é possível gravar na pasta do jogo:\n%1$s\n\nEla pode ter sido criada pelo adb ou por outro usuário. Exclua-a e deixe o aplicativo recriá-la. + Os arquivos do jogo não estão instalados. Copie as pastas extraídas do dump do Xbox 360 game, update e patched para:\n%1$s\n\nNão copie um APK, arquivos da versão de PC ou atalhos. + A instalação está incompleta ou danificada. Arquivos obrigatórios ausentes:%1$s\n\nRaiz esperada:\n%2$s + Os arquivos do jogo parecem estar aninhados na pasta errada (%1$s). Mova as pastas game, update, patched e opcionalmente dlc diretamente para:\n%2$s + Não foi possível salvar as configurações: %1$s + Não foi possível iniciar o editor de layout: %1$s + O editor de layout usa o renderizador do jogo, então uma instalação válida do jogo é necessária. + Escolha um arquivo Vulkan .so simples ou um pacote .zip AdrenoTools/ExynosTools. + Não foi possível importar o driver: %1$s + O Arquivos do Android não conseguiu abrir a pasta do aplicativo. + %1$s foi copiado. Ele será validado e instalado ao iniciar o jogo. + Instalar arquivos do jogo (.zip / pasta) + Instalar um mod (.zip / pasta) + De um arquivo ZIP + De uma pasta + Instalando… + %1$d arquivos copiados, %2$s + Lendo a origem… + Cancelar + Arquivos do jogo instalados. O executável com patch é preparado automaticamente na primeira inicialização, se necessário. + Mods instalados: %1$d. Ative-os no gerenciador de mods. + Instalação cancelada. Os arquivos copiados parcialmente foram mantidos. + Falha na instalação: %1$s + A origem selecionada não contém game/default.xex. Escolha o dump que contém as pastas game, update e opcionalmente dlc. + A origem selecionada não contém nenhum mod.ini, então nenhum mod foi encontrado. + + + Auto (recomendado)SistemaTurnip incluídoAdreno 710 (Vauzi)Importado + + + Auto (recomendado)GMEMSysmem + + + AutoSempre ativadosDesativados + + + Deslizar na telaAnalógico direitoDesativado + + diff --git a/android-apk/app/src/main/res/values-ru/strings.xml b/android-apk/app/src/main/res/values-ru/strings.xml index 46d01e49..f077ae4e 100644 --- a/android-apk/app/src/main/res/values-ru/strings.xml +++ b/android-apk/app/src/main/res/values-ru/strings.xml @@ -1,5 +1,10 @@ + UnleashedRecomp + Файлы игры Unleashed Recomp + Активная игра, моды, конфигурация и сохранения + Передача файлов Unleashed Recomp + Моды, импорт драйверов, логи и диагностические записи Unleashed: моды Моды Unleashed Скопируйте каждый мод отдельной папкой в каталог mods, обновите список, включите моды и задайте их приоритет. Сохранение создаёт стандартную базу CPKREDIR; для применения перезапустите игру. @@ -60,6 +65,20 @@ Не удалось импортировать драйвер: %1$s Не удалось открыть папку приложения в системных «Файлах». %1$s скопирован. Драйвер будет проверен и установлен при запуске игры. + Установить файлы игры (.zip / папка) + Установить мод (.zip / папка) + Из ZIP-архива + Из папки + Установка… + Скопировано файлов: %1$d, %2$s + Чтение источника… + Отмена + Файлы игры установлены. Пропатченный исполняемый файл при необходимости будет создан автоматически при первом запуске. + Установлено модов: %1$d. Включите их в менеджере модов. + Установка отменена. Частично скопированные файлы остались на месте. + Установка не удалась: %1$s + В выбранном источнике нет game/default.xex. Выберите дамп, содержащий папки game, update и (опционально) dlc. + В выбранном источнике нет mod.ini — мод не найден. Авто (рекомендуется)СистемныйВстроенный TurnipAdreno 710 (Vauzi)Импортированный diff --git a/android-apk/app/src/main/res/values/strings.xml b/android-apk/app/src/main/res/values/strings.xml index 3bce49a2..8122e7a5 100644 --- a/android-apk/app/src/main/res/values/strings.xml +++ b/android-apk/app/src/main/res/values/strings.xml @@ -64,6 +64,20 @@ Could not import the driver: %1$s Android Files could not open the application folder. %1$s was copied. It will be validated and installed at game launch. + Install game files (.zip / folder) + Install a mod (.zip / folder) + From a ZIP archive + From a folder + Installing… + %1$d files copied, %2$s + Reading the source… + Cancel + Game files installed. The patched executable is prepared automatically on the first launch if needed. + Installed mods: %1$d. Enable them in the mod manager. + Installation cancelled. Partially copied files were left in place. + Installation failed: %1$s + The selected source does not contain game/default.xex. Choose the dump that holds the game, update and optional dlc folders. + The selected source does not contain a mod.ini, so no mod could be found in it. Auto (recommended)SystemBundled TurnipAdreno 710 (Vauzi)Imported