mirror of
https://github.com/Liondarkz/issn_pc.git
synced 2026-11-07 04:36:01 -10:00
Compare commits
51
Commits
windows-v0.3.6.3
...
issn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44e475870d | ||
|
|
fd5437b3b2 | ||
|
|
665ec7e084 | ||
|
|
492930ef7a | ||
|
|
a4d7593d11 | ||
|
|
3b62a1dff6 | ||
|
|
e23073cecc | ||
|
|
369cdc646c | ||
|
|
6b38995761 | ||
|
|
43bf6f88bb | ||
|
|
47492dded2 | ||
|
|
e1be566815 | ||
|
|
9f962de970 | ||
|
|
a990c28d2e | ||
|
|
85ffb45a50 | ||
|
|
aa103d9b76 | ||
|
|
9ee3ed9ab7 | ||
|
|
0a1b680385 | ||
|
|
0e92f69731 | ||
|
|
b9b3859b64 | ||
|
|
c0ed9951fe | ||
|
|
afe0ec2587 | ||
|
|
fd7d3cca10 | ||
|
|
c9cb7070eb | ||
|
|
f4612c5290 | ||
|
|
82e8e9a0ff | ||
|
|
4156e98c52 | ||
|
|
87a4aaaa55 | ||
|
|
f5d6c438c6 | ||
|
|
200a34f1e7 | ||
|
|
05b649633d | ||
|
|
17c093c829 | ||
|
|
1c7fe5348d | ||
|
|
05f4926d0b | ||
|
|
48a3179cae | ||
|
|
86af332e2e | ||
|
|
4700403c69 | ||
|
|
30782e12f6 | ||
|
|
f0bacead70 | ||
|
|
4ee1f0ef8b | ||
|
|
986f8e9b71 | ||
|
|
590417d81e | ||
|
|
02a538ea3d | ||
|
|
a53de5f8ac | ||
|
|
67836e533a | ||
|
|
ecfd5be52e | ||
|
|
d22c4ec6ce | ||
|
|
ccd8d6b802 | ||
|
|
487d87a2f4 | ||
|
|
32f2b7fc17 | ||
|
|
dca8aba1e8 |
No files matched your search
@@ -99,9 +99,16 @@ Add to Steam. Building: [packaging/windows/README.md](packaging/windows/README.m
|
||||
|
||||
- Gráficos / graphics: o build do shadPS4 do **axylpup** para o Second Son
|
||||
([axylpup/shadPS4](https://github.com/axylpup/shadPS4)), sobre o
|
||||
[shadPS4](https://github.com/shadps4-emu/shadPS4); trabalhos anteriores no inFAMOUS de
|
||||
**Ayoub** (shadPS4-AYOUB1080p) e **HayatoG** (iss_pc). Mouse 1:1, layout de teclado e
|
||||
sensor de movimento sintético: design do axylpup.
|
||||
[shadPS4](https://github.com/shadps4-emu/shadPS4); trabalho anterior no inFAMOUS de
|
||||
**Ayoub** (shadPS4-AYOUB1080p). Mouse 1:1 e layout de teclado: design do axylpup. Sensor de
|
||||
movimento sintético: o modelo atual é do axylpup; o modo sensor para controles sem giroscópio
|
||||
(L3+R3, inclinar com o analógico, sacudir com um botão) veio antes no iss_pc do HayatoG (13/10).
|
||||
- **HayatoG** ([iss_pc](https://github.com/HayatoG/iss_pc)), o outro port do inFAMOUS para PC
|
||||
(mesma base gow3_pc): core de vídeo/GPU (merge three-way: readbacks precisos, compute
|
||||
pipelines fora da thread da GPU, correção do índice do shader cache, permutações de shader,
|
||||
write merge, atalho de região com "algum bit marcado"), distância de desenho (view hooks e os
|
||||
valores 0.6 / 0.8), respostas de PS4 base offline, correções de travamento no Windows (read
|
||||
fault especulativo, pool do guest, mutex do Wwise) e nomes de NIDs.
|
||||
- Base do port / port base: [gow3_pc](https://github.com/FelipePrado0/gow3_pc) (Felipe Prado),
|
||||
que vem do [bloodborne_pc](https://github.com/Supermedo/bloodborne_pc) (deadinside28,
|
||||
Supermedo), com cuesta4 e a equipe do shadPS4. Documentação herdada: [docs/base](docs/base/README.md).
|
||||
|
||||
@@ -158,8 +158,10 @@ if [[ ${1:-} == --test ]]; then
|
||||
out/mp3-test tests/data/sine.mp3
|
||||
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -pthread "${includes[@]}" -I. -Isrc tests/test_videodec.c "${libraries[@]}" -o out/videodec-test
|
||||
out/videodec-test tests/data/tiny.h264
|
||||
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -pthread -I. -Isrc tests/test_services.c -o out/services-test
|
||||
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -pthread -I. -Isrc tests/test_services.c src/runtime_trophy.c -o out/services-test
|
||||
out/services-test
|
||||
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -I. -Isrc tests/test_trophy.c src/runtime_trophy.c -o out/trophy-test
|
||||
out/trophy-test
|
||||
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -mbmi -mlzcnt -mmovbe -I. -Isrc tests/test_isa_emulate.c out/isa_emulate.o -o out/isa-emulate-test
|
||||
out/isa-emulate-test
|
||||
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -c src/isa_rewrite.c -I. -Isrc -o out/isa_rewrite.o
|
||||
@@ -172,4 +174,7 @@ if [[ ${1:-} == --test ]]; then
|
||||
out/isa-f16c-test
|
||||
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -I. -Isrc tests/test_redzone_guard.c -o out/redzone-guard-test
|
||||
out/redzone-guard-test
|
||||
# Calls the stubs through inline asm too: no red zone.
|
||||
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -mno-red-zone -I. -Isrc tests/test_hero_stubs.c -o out/hero-stubs-test
|
||||
out/hero-stubs-test
|
||||
fi
|
||||
@@ -304,6 +304,9 @@ add_executable(graphics-settings-test EXCLUDE_FROM_ALL ../tests/test_graphics_se
|
||||
shim/issn_settings.cpp)
|
||||
target_include_directories(graphics-settings-test PRIVATE shim)
|
||||
target_compile_options(graphics-settings-test PRIVATE -UNDEBUG)
|
||||
add_executable(postfx-test EXCLUDE_FROM_ALL ../tests/test_postfx.cpp)
|
||||
target_include_directories(postfx-test PRIVATE shadps4)
|
||||
target_compile_options(postfx-test PRIVATE -UNDEBUG -Wall -Wextra -Werror)
|
||||
add_executable(actor-hook-test EXCLUDE_FROM_ALL ../tests/test_actor_hook.cpp)
|
||||
target_include_directories(actor-hook-test PRIVATE shim)
|
||||
target_compile_options(actor-hook-test PRIVATE -UNDEBUG -Wall -Wextra -Werror)
|
||||
|
||||
@@ -55,8 +55,26 @@ int issngpu_choice_poll(void);
|
||||
/* issn-axy: the game finished writing a save (`what`: its subtitle, e.g. the mission): a note
|
||||
* in the corner of the screen for a few seconds. */
|
||||
void issngpu_note_game_saved(const char *what);
|
||||
/* issn 11-04: a trophy was unlocked (src/runtime_trophy.c): a popup with its icon (PNG bytes from
|
||||
* the game's trophy file, NULL: none) and name ("": "Trophy N"); grade "P", "G", "S", "B" or "".
|
||||
* The data is copied before the call returns. */
|
||||
void issngpu_note_trophy(int id, const char *name, const char *grade, const void *png, uint64_t size);
|
||||
/* 1 while the in-game settings menu is open: the game's pad input is held neutral. */
|
||||
int issngpu_overlay_captures_input(void);
|
||||
/* issn 11-04: the menu's sensitivities, read live by runtime_pad.c. mouse: a factor on the mouse
|
||||
* camera's degrees per pixel (1 = default); pad: the right stick (1 = the game's own feel).
|
||||
* set_mouse: - / = in play change it (saved to issn.ini). */
|
||||
float issngpu_mouse_sensitivity(void);
|
||||
void issngpu_set_mouse_sensitivity(float value);
|
||||
float issngpu_pad_sensitivity(void);
|
||||
/* issn 11-04: the spray can pose (pad tilted) for pads without motion sensors: the menu's
|
||||
* switch (Insert > Controls) and H in play. Not saved. */
|
||||
int issngpu_spray_pose(void);
|
||||
/* issn 11-04: the exposure meter's mailbox (src/exposure_hook.h, IssnExposureMailbox) and the
|
||||
* probe's note that its cave is in. */
|
||||
void *issngpu_exposure_mailbox(void);
|
||||
void issngpu_exposure_attached(void);
|
||||
void issngpu_set_spray_pose(int on);
|
||||
/* issn 0.3.6: issn.ini key_map (keys changed in the game's menu), "" when none. */
|
||||
const char *issngpu_saved_key_map(void);
|
||||
/* Number of symbols registered by the vendored libraries (diagnostics). */
|
||||
|
||||
@@ -1117,8 +1117,11 @@ void JitterFrame() {
|
||||
const double ratio = now.width > 0 ? std::max(1.0, double(ref.width) / now.width) : 1.0;
|
||||
const u32 phases = std::clamp(u32(std::lround(8.0 * ratio * ratio)), 8u, 64u);
|
||||
g_jitter_k = (g_jitter_k + 1) % phases;
|
||||
const float jx = Halton(g_jitter_k + 1, 2) - 0.5f; // render px, (-0.5, 0.5)
|
||||
const float jy = Halton(g_jitter_k + 1, 3) - 0.5f;
|
||||
// Anti-aliasing "Off" (vk_dlss.h AaOff): the patch holds the camera at the pixel centre - the
|
||||
// game's own 2 phases would make the un-anti-aliased picture shake by half a pixel.
|
||||
const bool still = Vulkan::Dlss::AaOff();
|
||||
const float jx = still ? 0.0f : Halton(g_jitter_k + 1, 2) - 0.5f; // render px, (-0.5, 0.5)
|
||||
const float jy = still ? 0.0f : Halton(g_jitter_k + 1, 3) - 0.5f;
|
||||
// The image moves by (-X/2, +Y/2) px for numerators (X, Y) (study sec. 2).
|
||||
struct Slot {
|
||||
float x, y;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
#include "common/types.h"
|
||||
|
||||
// issn 11-04 (Discord poll, 13 votes): switches for the game's motion blur and chromatic aberration.
|
||||
// Each effect is one or more of the game's full-screen post passes, known by the pixel (or compute)
|
||||
// shader's program hash, as fs_a6d23116 is the T2x resolve (vk_dlss.h). With a switch off, the
|
||||
// pass is neutralised on the back (Rasterizer::PostFxDraw / PostFxDispatch): its target gets a
|
||||
// copy of its input of the same size, or, with no such input, the draw is dropped.
|
||||
//
|
||||
// Finding the hashes: Insert > Image > "Find post effects (test)" lists the full-screen passes of
|
||||
// the last frame up to the T2x resolve (after the scene, before the HUD) with their hash, target
|
||||
// and inputs, and turns any of them off live; ISSN_POSTFX_LOG=1 writes the same list to the log
|
||||
// once a second. The found hashes go in MotionBlurHashes / ChromaticHashes below, or, without a
|
||||
// rebuild, in ISSN_POSTFX_MB / ISSN_POSTFX_CA (comma-separated hex).
|
||||
namespace Vulkan::PostFx {
|
||||
|
||||
enum Effect : u32 { MotionBlur = 0, Chromatic = 1, EffectCount = 2 };
|
||||
|
||||
/// Built-in hashes per effect (CUSA00223 01.07). Empty until the in-game test names them.
|
||||
inline constexpr u64 MotionBlurHashes[] = {0};
|
||||
inline constexpr u64 ChromaticHashes[] = {0};
|
||||
|
||||
constexpr u32 MaxHashes = 8;
|
||||
struct EffectPrograms {
|
||||
std::array<u64, MaxHashes> hashes{};
|
||||
u32 count = 0;
|
||||
};
|
||||
|
||||
inline void ParseHashList(const char* text, EffectPrograms& out) {
|
||||
while (text && *text && out.count < MaxHashes) {
|
||||
char* end = nullptr;
|
||||
const u64 h = std::strtoull(text, &end, 16);
|
||||
if (end == text) {
|
||||
++text;
|
||||
continue;
|
||||
}
|
||||
if (h != 0) {
|
||||
out.hashes[out.count++] = h;
|
||||
}
|
||||
text = end;
|
||||
}
|
||||
}
|
||||
|
||||
/// The hashes of each effect: the built-in ones, then the environment's.
|
||||
inline const std::array<EffectPrograms, EffectCount>& Programs() {
|
||||
static const std::array<EffectPrograms, EffectCount> programs = [] {
|
||||
std::array<EffectPrograms, EffectCount> p{};
|
||||
const auto add = [](EffectPrograms& e, const u64* list, size_t n, const char* env) {
|
||||
for (size_t i = 0; i < n && e.count < MaxHashes; ++i) {
|
||||
if (list[i] != 0) {
|
||||
e.hashes[e.count++] = list[i];
|
||||
}
|
||||
}
|
||||
ParseHashList(std::getenv(env), e);
|
||||
};
|
||||
add(p[MotionBlur], MotionBlurHashes, std::size(MotionBlurHashes), "ISSN_POSTFX_MB");
|
||||
add(p[Chromatic], ChromaticHashes, std::size(ChromaticHashes), "ISSN_POSTFX_CA");
|
||||
return p;
|
||||
}();
|
||||
return programs;
|
||||
}
|
||||
|
||||
/// Is the effect's pass known for this game build (the menu shows the switch only then)?
|
||||
inline bool Known(Effect e) {
|
||||
return Programs()[e].count != 0;
|
||||
}
|
||||
|
||||
/// The player's switches (Insert > Image, issn.ini motion_blur / chromatic_aberration): true =
|
||||
/// the effect is off. Live.
|
||||
inline std::array<std::atomic<bool>, EffectCount> g_off{};
|
||||
|
||||
/// The test list's live switches: up to 16 program hashes turned off by hand.
|
||||
inline std::array<std::atomic<u64>, 16> g_test_off{};
|
||||
/// Some hash in g_test_off (kept by SetTestOff, so a draw reads one flag, not 16).
|
||||
inline std::atomic<bool> g_any_test{false};
|
||||
/// Test: 0 = copy the input (as the effect switches do), 1 = drop the draw.
|
||||
inline std::atomic<int> g_test_mode{0};
|
||||
|
||||
/// The menu's test section is open (the back collects the list only while it is, or with the log).
|
||||
inline std::atomic<bool> g_collect{false};
|
||||
inline bool LogEnabled() {
|
||||
static const bool on = [] {
|
||||
const char* v = std::getenv("ISSN_POSTFX_LOG");
|
||||
return v && v[0] == '1';
|
||||
}();
|
||||
return on;
|
||||
}
|
||||
|
||||
/// Anything to do on the back: one relaxed load per switch, so the draw path stays as it was
|
||||
/// while everything is on and the test is closed.
|
||||
inline bool Active() {
|
||||
if (g_collect.load(std::memory_order_relaxed) || LogEnabled()) {
|
||||
return true;
|
||||
}
|
||||
for (const auto& off : g_off) {
|
||||
if (off.load(std::memory_order_relaxed)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return g_any_test.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
enum class Action : u8 { Draw, Copy, Drop };
|
||||
|
||||
/// What to do with a pass of this program (back thread).
|
||||
inline Action Decide(u64 hash) {
|
||||
if (hash == 0) {
|
||||
return Action::Draw;
|
||||
}
|
||||
for (u32 e = 0; e < EffectCount; ++e) {
|
||||
if (!g_off[e].load(std::memory_order_relaxed)) {
|
||||
continue;
|
||||
}
|
||||
const auto& p = Programs()[e];
|
||||
for (u32 i = 0; i < p.count; ++i) {
|
||||
if (p.hashes[i] == hash) {
|
||||
return Action::Copy;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const auto& h : g_test_off) {
|
||||
if (h.load(std::memory_order_relaxed) == hash) {
|
||||
return g_test_mode.load(std::memory_order_relaxed) == 1 ? Action::Drop : Action::Copy;
|
||||
}
|
||||
}
|
||||
return Action::Draw;
|
||||
}
|
||||
|
||||
/// Turns one program off or on in the test list (menu).
|
||||
inline void SetTestOff(u64 hash, bool off) {
|
||||
for (auto& h : g_test_off) {
|
||||
if (h.load(std::memory_order_relaxed) == hash) {
|
||||
if (!off) {
|
||||
h.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
off = false; // already listed
|
||||
}
|
||||
}
|
||||
if (off) {
|
||||
for (auto& h : g_test_off) {
|
||||
u64 expected = 0;
|
||||
if (h.compare_exchange_strong(expected, hash, std::memory_order_relaxed)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
bool any = false;
|
||||
for (const auto& h : g_test_off) {
|
||||
any |= h.load(std::memory_order_relaxed) != 0;
|
||||
}
|
||||
g_any_test.store(any, std::memory_order_relaxed);
|
||||
}
|
||||
inline bool IsTestOff(u64 hash) {
|
||||
for (const auto& h : g_test_off) {
|
||||
if (h.load(std::memory_order_relaxed) == hash) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- The list of full-screen passes ----
|
||||
|
||||
struct Input {
|
||||
VAddr addr{};
|
||||
u16 width{}, height{};
|
||||
u16 fmt{}; ///< data format | number format << 8
|
||||
};
|
||||
struct Pass {
|
||||
u64 hash{}; ///< pixel shader (or compute shader) program hash
|
||||
u64 vs_hash{};
|
||||
VAddr target{}; ///< colour target 0 (compute: 0)
|
||||
u16 width{}, height{};
|
||||
u16 fmt{};
|
||||
u8 compute{};
|
||||
u8 action{}; ///< Action taken this frame
|
||||
u8 inputs{};
|
||||
std::array<Input, 4> in{};
|
||||
};
|
||||
|
||||
/// The frame's passes up to the T2x resolve; the back fills `building`, the resolve publishes it.
|
||||
struct List {
|
||||
std::mutex mutex;
|
||||
std::vector<Pass> last; ///< the last published frame
|
||||
u64 frame = 0; ///< published frames
|
||||
};
|
||||
inline List g_list;
|
||||
constexpr size_t MaxPasses = 96;
|
||||
|
||||
} // namespace Vulkan::PostFx
|
||||
@@ -40,6 +40,13 @@ inline int Mode() {
|
||||
if (v && (v[0] == '1' || v[0] == '2')) {
|
||||
return v[0] - '0';
|
||||
}
|
||||
// issn 11-04 (Discord poll): ISSN_AA_OFF=1 - anti-aliasing "Off", the player's name for
|
||||
// mode 2: the T2x resolve becomes a plain copy of the tonemap output and the camera
|
||||
// jitter is held at zero (memory_patcher.cpp JitterFrame), so the picture stays still.
|
||||
const char* off = std::getenv("ISSN_AA_OFF");
|
||||
if (off && off[0] == '1') {
|
||||
return 2;
|
||||
}
|
||||
// issn-axy: ISSN_FSR4=1 - FSR 4 Native AA in the same slot (issn_fsr4.h).
|
||||
const char* fsr4 = std::getenv("ISSN_FSR4");
|
||||
if (fsr4 && fsr4[0] == '1') {
|
||||
@@ -58,6 +65,10 @@ inline bool Enabled() {
|
||||
inline bool OwnUpscaler() {
|
||||
return Mode() == 3 || Mode() == 4;
|
||||
}
|
||||
/// Anti-aliasing "Off" (ISSN_AA_OFF=1 or SHADPS4_DLSS=2): no T2x, no upscaler, no jitter.
|
||||
inline bool AaOff() {
|
||||
return Mode() == 2;
|
||||
}
|
||||
/// The name players see for the running mode.
|
||||
inline const char* ModeName() {
|
||||
switch (Mode()) {
|
||||
@@ -65,6 +76,8 @@ inline const char* ModeName() {
|
||||
return "FSR 4";
|
||||
case 4:
|
||||
return "XeSS";
|
||||
case 2:
|
||||
return "Off";
|
||||
default:
|
||||
return "DLSS";
|
||||
}
|
||||
|
||||
@@ -825,7 +825,20 @@ bool Instance::CreateDevice() {
|
||||
derivative_features.pNext = const_cast<void*>(device_ci.pNext);
|
||||
device_ci.pNext = &derivative_features;
|
||||
}
|
||||
const bool fsr4_ok = fsr4_derivatives && vk12_features.shaderFloat16 &&
|
||||
// issn 11-04: FSR 4 (its INT8 model) on AMD cards only. An RTX 5080 has every feature below,
|
||||
// chose FSR 4 in the in-game menu and lost the device a few seconds into play (GitHub issue
|
||||
// 28); AMD made the model for Radeons. ISSN_FSR4_ANY_GPU=1: any card with the features.
|
||||
static const bool fsr4_any_gpu = [] {
|
||||
const char* v = std::getenv("ISSN_FSR4_ANY_GPU");
|
||||
return v && v[0] == '1';
|
||||
}();
|
||||
const bool fsr4_vendor = fsr4_any_gpu || physical_device.getProperties().vendorID == 0x1002;
|
||||
if (Dlss::Mode() == 3 && !fsr4_vendor) {
|
||||
std::printf("FSR 4: only on AMD Radeon cards: the game's own anti-aliasing stays (choose "
|
||||
"DLSS or XeSS)\n");
|
||||
std::fflush(stdout);
|
||||
}
|
||||
const bool fsr4_ok = fsr4_vendor && fsr4_derivatives && vk12_features.shaderFloat16 &&
|
||||
vk12_features.shaderInt8 && features.shaderInt16 &&
|
||||
vk13_features.shaderIntegerDotProduct &&
|
||||
features.shaderStorageImageExtendedFormats;
|
||||
@@ -954,6 +967,10 @@ bool Instance::CreateDevice() {
|
||||
if (dlss_device || Dlss::Mode() == 2) {
|
||||
Dlss::DeviceCreated(*instance, physical_device, *device, queue_family_index, 0);
|
||||
}
|
||||
if (Dlss::AaOff()) {
|
||||
// Anti-aliasing "Off": nothing to load (DeviceCreated's stub off Windows sets nothing).
|
||||
Dlss::g_ready.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
if (Dlss::Mode() == 3) {
|
||||
// ISSN_FSR4: the T2x resolve is replaced once the device can run the model.
|
||||
Fsr4Aa::SetDeviceSupported(fsr4_ok);
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
#include <set>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <atomic>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
@@ -1662,6 +1664,9 @@ void Rasterizer::DlssResolve() {
|
||||
// a plain copy of the tonemap output (the game without its temporal AA for that frame).
|
||||
++dlss_calls;
|
||||
++dlss_phases[dlss_phase_in & 3];
|
||||
if (PostFx::Active()) [[unlikely]] {
|
||||
PostFxPublish(); // the end of the scene's post chain (issn_postfx.h)
|
||||
}
|
||||
{
|
||||
// issn-axy: more than 100 ms since the last resolve (Home switched it off, a menu, a
|
||||
// load): FSR's history is another picture, so it starts over.
|
||||
@@ -1880,13 +1885,32 @@ void Rasterizer::DlssResolve() {
|
||||
return Fsr4Aa::Input{image.GetImage(), *view.image_view, image.info.size.width,
|
||||
image.info.size.height, image.info.pixel_format};
|
||||
};
|
||||
// issn 11-04: phase 4 is the emulator's Halton offset (memory_patcher.cpp JitterFrame),
|
||||
// which also runs with FSR 4 / XeSS (Dlss::Enabled covers them). This branch read only
|
||||
// the game's 2 phases, so they got jitter 0 while the camera moved up to +-0.5 px each
|
||||
// frame: a soft picture. Now as DLSS: the exact offset, a new history on a switch.
|
||||
float jx = 0.0f, jy = 0.0f;
|
||||
if (dlss_phase_in == 1 || dlss_phase_in == 2) {
|
||||
const bool explicit_jitter = dlss_phase_in == 4;
|
||||
dlss_reset_pending |= explicit_jitter != dlss_last_explicit;
|
||||
dlss_last_explicit = explicit_jitter;
|
||||
if (explicit_jitter) {
|
||||
jx = dlss_jitter_in[0];
|
||||
jy = dlss_jitter_in[1];
|
||||
} else if (dlss_phase_in == 1 || dlss_phase_in == 2) {
|
||||
const float j = dlss_phase_in == 1 ? 0.25f : -0.25f;
|
||||
const u32 combo = Dlss::g_jitter_combo.load(std::memory_order_relaxed);
|
||||
jx = (combo & 1) ? -j : j;
|
||||
jy = (combo & 2) ? -j : j;
|
||||
}
|
||||
// ISSN_JITTER_LOG=1 (test): the first 8 frames' jitter as the upscaler gets it.
|
||||
static const bool jitter_log = [] {
|
||||
const char* v = std::getenv("ISSN_JITTER_LOG");
|
||||
return v && v[0] == '1';
|
||||
}();
|
||||
if (jitter_log && dlss_evaluated < 8) {
|
||||
LOG_WARNING(Render_Vulkan, "{}: jitter phase {} -> ({:+.3f}, {:+.3f}) render px",
|
||||
Dlss::ModeName(), dlss_phase_in, jx, jy);
|
||||
}
|
||||
if (use_xess && !xess) {
|
||||
xess = std::make_unique<Xess::Upscaler>(instance, scheduler);
|
||||
} else if (!use_xess && !fsr4) {
|
||||
@@ -2095,6 +2119,239 @@ void Rasterizer::DlssResolve() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- issn 11-04: motion blur / chromatic aberration switches and the pass list (issn_postfx.h) --
|
||||
|
||||
namespace {
|
||||
u16 PostFxFormat(AmdGpu::DataFormat data, AmdGpu::NumberFormat number) {
|
||||
return static_cast<u16>(static_cast<u32>(data) | (static_cast<u32>(number) << 8));
|
||||
}
|
||||
|
||||
/// A shader's textures as the pass list shows them (at most 4).
|
||||
template <typename Bound>
|
||||
u8 PostFxInputs(const Bound& bound, std::array<PostFx::Input, 4>& in) {
|
||||
u8 n = 0;
|
||||
if (!bound.info) {
|
||||
return 0;
|
||||
}
|
||||
for (const auto& desc : bound.info->images) {
|
||||
if (n == in.size()) {
|
||||
break;
|
||||
}
|
||||
if (desc.is_written) {
|
||||
continue;
|
||||
}
|
||||
const AmdGpu::Image image = desc.GetSharp(bound);
|
||||
if (!image.Valid() || image.Address() == 0) {
|
||||
continue;
|
||||
}
|
||||
in[n++] = PostFx::Input{static_cast<VAddr>(image.Address()),
|
||||
static_cast<u16>(image.width + 1), static_cast<u16>(image.height + 1),
|
||||
PostFxFormat(image.GetDataFmt(), image.GetNumberFmt())};
|
||||
}
|
||||
return n;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool Rasterizer::PostFxDraw(const GraphicsPipeline* pipeline) {
|
||||
u64 ps = 0, vs = 0;
|
||||
for (const auto* stage : pipeline->GetStages()) {
|
||||
if (!stage) {
|
||||
continue;
|
||||
}
|
||||
if (stage->l_stage == Shader::LogicalStage::Fragment) {
|
||||
ps = stage->pgm_hash;
|
||||
} else if (stage->l_stage == Shader::LogicalStage::Vertex) {
|
||||
vs = stage->pgm_hash;
|
||||
}
|
||||
}
|
||||
if (ps == Dlss::ResolvePsHash) {
|
||||
PostFxPublish(); // the game's own T2x resolve: the end of the scene's post chain
|
||||
return false;
|
||||
}
|
||||
const auto& regs = liverpool->BackRegs();
|
||||
const auto& cb = regs.color_buffers[0];
|
||||
const VAddr target = cb.Address();
|
||||
const auto hint = liverpool->BackCbExtent(0);
|
||||
const u32 width = hint.width ? hint.width : cb.Pitch();
|
||||
const u32 height = hint.height ? hint.height : cb.Height();
|
||||
// A full-screen pass: one triangle or quad into a colour target at least a quarter of the
|
||||
// game's width (blur chains run at 1/2 and 1/4 size).
|
||||
const bool full_screen = target != 0 && regs.num_indices <= 6 &&
|
||||
regs.num_instances.NumInstances() <= 1 &&
|
||||
width * 4 >= Dlss::g_game_width.load(std::memory_order_relaxed);
|
||||
const PostFx::Action action = full_screen ? PostFx::Decide(ps) : PostFx::Action::Draw;
|
||||
const auto& fs = GetBoundStage(Shader::LogicalStage::Fragment);
|
||||
PostFx::Pass pass{};
|
||||
if (full_screen && (PostFx::g_collect.load(std::memory_order_relaxed) || PostFx::LogEnabled())) {
|
||||
pass.hash = ps;
|
||||
pass.vs_hash = vs;
|
||||
pass.target = target;
|
||||
pass.width = static_cast<u16>(width);
|
||||
pass.height = static_cast<u16>(height);
|
||||
pass.fmt = PostFxFormat(cb.GetDataFmt(), cb.GetNumberFmt());
|
||||
pass.action = static_cast<u8>(action);
|
||||
pass.inputs = PostFxInputs(fs, pass.in);
|
||||
if (postfx_frame.size() < PostFx::MaxPasses) {
|
||||
postfx_frame.push_back(pass);
|
||||
}
|
||||
}
|
||||
if (action == PostFx::Action::Draw) {
|
||||
return false;
|
||||
}
|
||||
if (action == PostFx::Action::Copy) {
|
||||
// The pass's own output replaced by its input of the target's size: the picture as it was
|
||||
// before the effect. Without such an input the draw is dropped (the target keeps what it
|
||||
// had), so the test can still show what the pass does.
|
||||
if (pass.hash == 0) {
|
||||
pass.inputs = PostFxInputs(fs, pass.in);
|
||||
}
|
||||
VideoCore::TextureCache::ImageDesc out_desc{cb, hint};
|
||||
auto& out = texture_cache.GetImage(texture_cache.FindImage(out_desc, true));
|
||||
VideoCore::Image* in = nullptr;
|
||||
for (u8 i = 0; i < pass.inputs && !in; ++i) {
|
||||
if (pass.in[i].addr == target) {
|
||||
continue;
|
||||
}
|
||||
if (const VideoCore::ImageId id = texture_cache.FindImageAtSize(
|
||||
pass.in[i].addr, out.info.size.width, out.info.size.height, out.info.pixel_format)) {
|
||||
in = &texture_cache.GetImage(id);
|
||||
}
|
||||
}
|
||||
for (u8 i = 0; i < pass.inputs && !in; ++i) {
|
||||
if (pass.in[i].addr == target) {
|
||||
continue;
|
||||
}
|
||||
if (const VideoCore::ImageId id = texture_cache.FindImageAtSize(
|
||||
pass.in[i].addr, out.info.size.width, out.info.size.height)) {
|
||||
VideoCore::Image& image = texture_cache.GetImage(id);
|
||||
if (!(image.aspect_mask & vk::ImageAspectFlagBits::eDepth)) {
|
||||
in = ℑ
|
||||
}
|
||||
}
|
||||
}
|
||||
if (in) {
|
||||
if (native_batch.open) [[unlikely]] {
|
||||
NativeClose(12);
|
||||
}
|
||||
rs_memo.valid = false;
|
||||
scheduler.EndRendering();
|
||||
if (in->info.pixel_format == out.info.pixel_format) {
|
||||
out.CopyImage(*in);
|
||||
} else {
|
||||
in->Transit(vk::ImageLayout::eTransferSrcOptimal, vk::AccessFlagBits2::eTransferRead, {});
|
||||
out.Transit(vk::ImageLayout::eTransferDstOptimal, vk::AccessFlagBits2::eTransferWrite, {});
|
||||
const vk::ImageSubresourceLayers layers{.aspectMask = vk::ImageAspectFlagBits::eColor,
|
||||
.mipLevel = 0,
|
||||
.baseArrayLayer = 0,
|
||||
.layerCount = 1};
|
||||
const vk::ImageBlit blit{
|
||||
.srcSubresource = layers,
|
||||
.srcOffsets = std::array{vk::Offset3D{0, 0, 0},
|
||||
vk::Offset3D{s32(in->info.size.width),
|
||||
s32(in->info.size.height), 1}},
|
||||
.dstSubresource = layers,
|
||||
.dstOffsets = std::array{vk::Offset3D{0, 0, 0},
|
||||
vk::Offset3D{s32(out.info.size.width),
|
||||
s32(out.info.size.height), 1}},
|
||||
};
|
||||
scheduler.AcquireCommandBuffer().Handle().blitImage(
|
||||
in->GetImage(), vk::ImageLayout::eTransferSrcOptimal, out.GetImage(),
|
||||
vk::ImageLayout::eTransferDstOptimal, blit, vk::Filter::eNearest);
|
||||
}
|
||||
out.flags |= VideoCore::ImageFlagBits::GpuModified;
|
||||
out.flags &= ~VideoCore::ImageFlagBits::Dirty;
|
||||
}
|
||||
}
|
||||
ResetBindings();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Rasterizer::PostFxDispatch(const ComputePipeline* pipeline) {
|
||||
u64 cs = 0;
|
||||
for (const auto* stage : pipeline->GetStages()) {
|
||||
if (stage) {
|
||||
cs = stage->pgm_hash;
|
||||
}
|
||||
}
|
||||
const auto& bound = GetBoundStage(Shader::LogicalStage::Compute);
|
||||
// A full-screen compute pass writes an image at least a quarter of the game's width.
|
||||
VAddr target = 0;
|
||||
u32 width = 0, height = 0;
|
||||
u16 fmt = 0;
|
||||
if (bound.info) {
|
||||
for (const auto& desc : bound.info->images) {
|
||||
if (!desc.is_written) {
|
||||
continue;
|
||||
}
|
||||
const AmdGpu::Image image = desc.GetSharp(bound);
|
||||
if (image.Valid() && image.Address() != 0) {
|
||||
target = image.Address();
|
||||
width = u32(image.width) + 1;
|
||||
height = u32(image.height) + 1;
|
||||
fmt = PostFxFormat(image.GetDataFmt(), image.GetNumberFmt());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const bool full_screen =
|
||||
target != 0 && width * 4 >= Dlss::g_game_width.load(std::memory_order_relaxed);
|
||||
const PostFx::Action action = full_screen ? PostFx::Decide(cs) : PostFx::Action::Draw;
|
||||
if (full_screen && (PostFx::g_collect.load(std::memory_order_relaxed) || PostFx::LogEnabled()) &&
|
||||
postfx_frame.size() < PostFx::MaxPasses) {
|
||||
PostFx::Pass pass{};
|
||||
pass.hash = cs;
|
||||
pass.target = target;
|
||||
pass.width = static_cast<u16>(width);
|
||||
pass.height = static_cast<u16>(height);
|
||||
pass.fmt = fmt;
|
||||
pass.compute = 1;
|
||||
// Compute passes are only dropped: they write in place, so there is nothing to copy.
|
||||
pass.action = static_cast<u8>(action == PostFx::Action::Draw ? action : PostFx::Action::Drop);
|
||||
pass.inputs = PostFxInputs(bound, pass.in);
|
||||
postfx_frame.push_back(pass);
|
||||
}
|
||||
return action != PostFx::Action::Draw;
|
||||
}
|
||||
|
||||
void Rasterizer::PostFxPublish() {
|
||||
if (!PostFx::g_collect.load(std::memory_order_relaxed) && !PostFx::LogEnabled()) {
|
||||
postfx_frame.clear();
|
||||
return;
|
||||
}
|
||||
{
|
||||
std::scoped_lock lock{PostFx::g_list.mutex};
|
||||
PostFx::g_list.last.swap(postfx_frame);
|
||||
++PostFx::g_list.frame;
|
||||
}
|
||||
postfx_frame.clear();
|
||||
if (!PostFx::LogEnabled()) {
|
||||
return;
|
||||
}
|
||||
const u64 now_ns = u64(std::chrono::duration_cast<std::chrono::nanoseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch())
|
||||
.count());
|
||||
if (postfx_log_ns != 0 && now_ns - postfx_log_ns < 1'000'000'000) {
|
||||
return;
|
||||
}
|
||||
postfx_log_ns = now_ns;
|
||||
std::scoped_lock lock{PostFx::g_list.mutex};
|
||||
const auto& list = PostFx::g_list.last;
|
||||
LOG_INFO(Render_Vulkan, "PostFx: {} full-screen passes before the T2x resolve (frame {})",
|
||||
list.size(), PostFx::g_list.frame);
|
||||
static constexpr const char* actions[] = {"", " [copied]", " [dropped]"};
|
||||
for (size_t i = 0; i < list.size(); ++i) {
|
||||
const auto& p = list[i];
|
||||
std::string inputs;
|
||||
for (u8 k = 0; k < p.inputs; ++k) {
|
||||
inputs += fmt::format(" {:#x} {}x{} f{:#x}", p.in[k].addr, p.in[k].width, p.in[k].height,
|
||||
p.in[k].fmt);
|
||||
}
|
||||
LOG_INFO(Render_Vulkan, "PostFx {:2}: {}_{:08x} (vs_{:08x}) -> {:#x} {}x{} f{:#x}; in:{}{}", i,
|
||||
p.compute ? "cs" : "fs", p.hash, p.vs_hash, p.target, p.width, p.height, p.fmt,
|
||||
inputs.empty() ? " none" : inputs, actions[std::min<u8>(p.action, 2)]);
|
||||
}
|
||||
}
|
||||
|
||||
bool Rasterizer::FilterDraw() {
|
||||
// The classifier reads the front's registers (it runs where the pipeline lookup runs); the
|
||||
// actions read the back's through BackRegs().
|
||||
@@ -2483,6 +2740,11 @@ void Rasterizer::Draw(bool is_indexed, u32 index_offset) {
|
||||
return;
|
||||
}
|
||||
BindStageData(pipeline);
|
||||
if (PostFx::Active()) [[unlikely]] {
|
||||
if (PostFxDraw(pipeline)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
DrawTail(pipeline, is_indexed, index_offset, sample, t_entry, t_pipeline, pipeline_ns);
|
||||
}
|
||||
|
||||
@@ -2680,6 +2942,12 @@ void Rasterizer::ExecuteDraw(const SplitDraw& draw,
|
||||
return false;
|
||||
}
|
||||
bound_stages = stages;
|
||||
// issn 11-04: motion blur / chromatic aberration off, the pass list (issn_postfx.h).
|
||||
if (PostFx::Active() && !draw.args.is_indirect) [[unlikely]] {
|
||||
if (PostFxDraw(draw.pipeline)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// SHADPS4_DRAW_REPEAT_STATS=1: census of draws whose binding inputs repeat the previous
|
||||
// draw's (scripts/patch/draw-repeat-census-2026-10-02.py).
|
||||
static const bool repeat_stats = [] {
|
||||
@@ -9112,6 +9380,12 @@ void Rasterizer::DispatchDirectTail(const ComputePipeline* pipeline) {
|
||||
if (ExecuteShaderHLE(cs, liverpool->BackRegs(), cs_program, *this)) {
|
||||
return;
|
||||
}
|
||||
if (PostFx::Active()) [[unlikely]] {
|
||||
if (PostFxDispatch(pipeline)) {
|
||||
ResetBindings();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const bool pb_census = VideoCore::g_pb_census != 0;
|
||||
if (pb_census) [[unlikely]] {
|
||||
PbCensusDispatch(pipeline, 0);
|
||||
@@ -13995,6 +14269,34 @@ RenderState Rasterizer::BeginRendering(const GraphicsPipeline* pipeline) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// issn 11-05 (issues #28, #32): on NVIDIA the GPU is lost right after the first pass with no
|
||||
// attachment at all (no colour, no depth) in the opening village, whatever the anti-aliasing.
|
||||
// ISSN_EMPTY_PASS=skip (test): such passes rasterize nothing (scissors 0, as the water blur
|
||||
// skip above), counted in the log.
|
||||
{
|
||||
static const bool skip_empty = [] {
|
||||
const char* e = std::getenv("ISSN_EMPTY_PASS");
|
||||
const bool on = e && std::strcmp(e, "skip") == 0;
|
||||
if (on) {
|
||||
LOG_WARNING(Render_Vulkan, "ISSN_EMPTY_PASS=skip: passes with no attachment draw nothing (test)");
|
||||
}
|
||||
return on;
|
||||
}();
|
||||
if (skip_empty && !state.depth_stencil_attachment.image_view) [[unlikely]] {
|
||||
bool any_color = false;
|
||||
for (u32 cb = 0; cb < state.num_color_attachments; ++cb) {
|
||||
any_color = any_color || state.color_attachments[cb].image_view;
|
||||
}
|
||||
if (!any_color) {
|
||||
state.skip = 1;
|
||||
static std::atomic<u64> skipped{0};
|
||||
const u64 n = skipped.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
if (n == 1 || n == 100 || n == 10000) {
|
||||
LOG_WARNING(Render_Vulkan, "ISSN_EMPTY_PASS=skip: {} passes skipped", n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The draw's dynamic state is committed before Scheduler::BeginRendering runs: the pass's
|
||||
// scale must be in place now (review 2026-10-14 BUG 1; a no-op while the flag is off).
|
||||
scheduler.GetDynamicState().SetRenderScale(state.scale, state.skip != 0);
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "video_core/page_manager.h"
|
||||
#include "video_core/renderer_vulkan/vk_bindless.h"
|
||||
#include "video_core/renderer_vulkan/vk_native_bindings.h"
|
||||
#include "video_core/renderer_vulkan/issn_postfx.h"
|
||||
#include "video_core/renderer_vulkan/vk_occlusion.h"
|
||||
#include "shader_recompiler/native_emit.h"
|
||||
#include "video_core/renderer_vulkan/vk_pipeline_cache.h"
|
||||
@@ -332,6 +333,15 @@ public:
|
||||
void DeriveNeoIdBuffer(VideoCore::Image& id_image);
|
||||
/// SHADPS4_DLSS: the back-side action that replaces the T2x resolve draw.
|
||||
void DlssResolve();
|
||||
/// issn 11-04 (issn_postfx.h): the switches for motion blur and chromatic aberration and the
|
||||
/// list of full-screen passes. Back thread, with the draw's stages bound; true = the draw is
|
||||
/// done here (copied or dropped).
|
||||
bool PostFxDraw(const GraphicsPipeline* pipeline);
|
||||
bool PostFxDispatch(const ComputePipeline* pipeline);
|
||||
/// The T2x resolve (the game's own or replaced): the frame's list is published.
|
||||
void PostFxPublish();
|
||||
std::vector<PostFx::Pass> postfx_frame;
|
||||
u64 postfx_log_ns{};
|
||||
u64 dlss_calls{};
|
||||
u64 dlss_missing{};
|
||||
u8 dlss_phase_in{}; ///< the phase of the action being executed
|
||||
|
||||
@@ -444,8 +444,14 @@ void Swapchain::Destroy() {
|
||||
vk::Device device = instance.GetDevice();
|
||||
const auto wait_result = WaitForSwapchainIdle(instance);
|
||||
if (wait_result != vk::Result::eSuccess) {
|
||||
LOG_WARNING(Render_Vulkan, "Failed to wait for device to become idle: {}",
|
||||
vk::to_string(wait_result));
|
||||
// issn 11-05: one line per recreation filled a log with 13,800 of them; the first 3, then
|
||||
// every 1000th.
|
||||
static std::atomic<u64> failed{0};
|
||||
const u64 n = failed.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
if (n <= 3 || n % 1000 == 0) {
|
||||
LOG_WARNING(Render_Vulkan, "Failed to wait for device to become idle: {} ({} times)",
|
||||
vk::to_string(wait_result), n);
|
||||
}
|
||||
}
|
||||
|
||||
for (auto& image_view : images_view) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <chrono>
|
||||
#include <unordered_map>
|
||||
@@ -900,12 +901,47 @@ std::tuple<ImageId, int, int> TextureCache::ResolveOverlap(const ImageInfo& imag
|
||||
// Left overlap, the image from cache is a possible subresource of the image requested
|
||||
if (auto mip = cache_image.info.MipOf(image_info); mip >= 0) {
|
||||
if (auto slice = cache_image.info.SliceOf(image_info, mip); slice >= 0) {
|
||||
// issn 11-04 (dark night): what the game rendered into a slice of a layered image
|
||||
// (shadow maps of the city's lights, probes) before the whole image was asked for.
|
||||
// SHADPS4_SLICE_LOG=1: the first 32 of these merges in the log.
|
||||
static const bool slice_log = [] {
|
||||
const char* v = std::getenv("SHADPS4_SLICE_LOG");
|
||||
return v && v[0] == '1';
|
||||
}();
|
||||
if (slice_log) {
|
||||
static std::atomic<u32> logged{0};
|
||||
if (logged.fetch_add(1, std::memory_order_relaxed) < 32) {
|
||||
LOG_WARNING(Render_Vulkan,
|
||||
"SliceMerge: {} {}x{} {} at {:#x} (target {}) -> slice {} mip {} of "
|
||||
"{}x{} {} layers {} at {:#x}{}",
|
||||
vk::to_string(cache_image.info.pixel_format),
|
||||
cache_image.info.size.width, cache_image.info.size.height,
|
||||
+cache_image.info.props.is_depth, cache_image.info.guest_address,
|
||||
+cache_image.binding.is_target, slice, mip, image_info.size.width,
|
||||
image_info.size.height, vk::to_string(image_info.pixel_format),
|
||||
image_info.resources.layers, image_info.guest_address,
|
||||
merged_image_id ? "" : " (no merged image yet: contents lost)");
|
||||
}
|
||||
}
|
||||
// SHADPS4_SLICE_RT_COPY=1 (test, off by default until a run shows it): a slice
|
||||
// bound as a render target also hands its contents to the larger image before it
|
||||
// is freed (only same-kind images: color into color, depth into depth).
|
||||
static const bool copy_targets = [] {
|
||||
const char* v = std::getenv("SHADPS4_SLICE_RT_COPY");
|
||||
return v && v[0] == '1';
|
||||
}();
|
||||
|
||||
// We have a larger image created and a separate one, representing a subres of it
|
||||
// bound as render target. In this case we need to rebind render target.
|
||||
if (cache_image.binding.is_target) {
|
||||
cache_image.binding.needs_rebind = 1u;
|
||||
if (merged_image_id) {
|
||||
GetImage(merged_image_id).binding.is_target = 1u;
|
||||
auto& merged_image = GetImage(merged_image_id);
|
||||
merged_image.binding.is_target = 1u;
|
||||
if (copy_targets &&
|
||||
merged_image.info.props.is_depth == cache_image.info.props.is_depth) {
|
||||
merged_image.CopyMip(cache_image, mip, slice);
|
||||
}
|
||||
}
|
||||
|
||||
FreeImage(cache_image_id);
|
||||
@@ -2135,6 +2171,22 @@ void TextureCache::GarbageCollectImages() {
|
||||
const auto [usage, budget] = instance.GetDeviceMemoryUsageAndBudget();
|
||||
total_used_memory = usage;
|
||||
budget_now = budget;
|
||||
// issn 11-04 (GitHub issue 32, RTX 3060 Laptop 6 GB: usage 5395 MB over a 5091 MB budget,
|
||||
// DLSS refused, device lost 30 s in): ISSN_VRAM_LIMIT_MB (launcher: Video memory limit)
|
||||
// caps the budget the clean-up aims for, below what the driver allows.
|
||||
static const u64 user_limit = [] {
|
||||
const char* v = std::getenv("ISSN_VRAM_LIMIT_MB");
|
||||
const long mb = v ? std::atol(v) : 0;
|
||||
if (mb >= 2048) {
|
||||
std::printf("Video memory: the clean-up keeps the game under %ld MB (launcher)\n", mb);
|
||||
std::fflush(stdout);
|
||||
return u64(mb) << 20;
|
||||
}
|
||||
return u64(0);
|
||||
}();
|
||||
if (user_limit && (budget_now == 0 || user_limit < budget_now)) {
|
||||
budget_now = user_limit;
|
||||
}
|
||||
// A line a minute in the log: how close the game runs to the budget.
|
||||
static auto last_report = std::chrono::steady_clock::time_point{};
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
@@ -2152,7 +2204,9 @@ void TextureCache::GarbageCollectImages() {
|
||||
if (budget_now > 2_GB) {
|
||||
pressure_limit = std::min<u64>(pressure_limit, budget_now - 1_GB);
|
||||
critical_limit = std::min<u64>(critical_limit, budget_now - 512_MB);
|
||||
emergency = total_used_memory + 384_MB >= budget_now;
|
||||
// Cards of 8 GB or less fill up in one loading burst (issue 32): a wider margin there.
|
||||
const u64 margin = budget_now <= 8_GB ? 768_MB : 384_MB;
|
||||
emergency = total_used_memory + margin >= budget_now;
|
||||
}
|
||||
if (emergency) {
|
||||
static auto last_told = std::chrono::steady_clock::time_point{};
|
||||
|
||||
@@ -26,7 +26,8 @@ public:
|
||||
s32 GetGpuId() { static const auto value = s32(Number("ISSN_GPU_ID", -1)); return value; }
|
||||
u32 GetInternalScreenWidth() { static const auto value = u32(Number("ISSN_INTERNAL_WIDTH", 1920)); return value; }
|
||||
u32 GetInternalScreenHeight() { static const auto value = u32(Number("ISSN_INTERNAL_HEIGHT", 1080)); return value; }
|
||||
// issn-axy: ISSN_PRESENT_MODE wins; else the in-game menu's VSync (issn.ini, read at start):
|
||||
// issn-axy: ISSN_PRESENT_MODE wins; else the in-game menu's VSync (read live: the swapchain
|
||||
// checks it every 30 presents and is rebuilt when it changes):
|
||||
// on = Fifo (the display's rate), off = Immediate (Mailbox is missing on AMD's Windows driver,
|
||||
// and the core falls back from it to Fifo).
|
||||
std::string GetPresentMode() {
|
||||
@@ -40,14 +41,12 @@ public:
|
||||
// ISSN_VBLANK_HZ (> 0) wins; else the menu's frame rate limit (issn.ini fps_limit: 30, 60, 120,
|
||||
// 240; 0 = unlimited, 240 Hz as in axylpup's build). Second Son steps its simulation by the
|
||||
// frame time, so it keeps its speed above 60 (tester: 100 FPS in the city).
|
||||
// Read live: the vblank thread follows it (driver.cpp), so the menu's limit applies at once.
|
||||
u32 GetVblankFrequency() {
|
||||
static const u32 value = [] {
|
||||
const long hz = Number("ISSN_VBLANK_HZ", 0);
|
||||
if (hz > 0) return u32(std::max<long>(hz, 30));
|
||||
const int limit = IssnFpsLimit();
|
||||
return limit > 0 ? u32(std::max(limit, 30)) : 240u;
|
||||
}();
|
||||
return value;
|
||||
static const long forced = Number("ISSN_VBLANK_HZ", 0);
|
||||
if (forced > 0) return u32(std::max<long>(forced, 30));
|
||||
const int limit = IssnFpsLimit();
|
||||
return limit > 0 ? u32(std::max(limit, 30)) : 240u;
|
||||
}
|
||||
bool IsCopyGpuBuffers() { static const auto value = Flag("ISSN_COPY_GPU_BUFFERS", false); return value; }
|
||||
bool IsDirectMemoryAccessEnabled() { static const auto value = Flag("ISSN_DIRECT_MEMORY_ACCESS", false); return value; }
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// issn 11-04: the exposure meter's mailbox (src/exposure_hook.h). The game's thread writes it
|
||||
// through the cave; the menu reads it and can hold the value (dark-night diagnosis).
|
||||
#include "issn_exposure.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#include "../../src/exposure_hook.h"
|
||||
#include "common/memory_patcher.h"
|
||||
|
||||
namespace {
|
||||
alignas(64) IssnExposureMailbox g_box{};
|
||||
std::atomic<bool> g_attached{false};
|
||||
|
||||
float Load(const float& f) {
|
||||
float v;
|
||||
std::memcpy(&v, const_cast<const float*>(&f), sizeof(v));
|
||||
return v;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
extern "C" void* issngpu_exposure_mailbox(void) {
|
||||
return &g_box;
|
||||
}
|
||||
|
||||
extern "C" void issngpu_exposure_attached(void) {
|
||||
g_attached = true;
|
||||
}
|
||||
|
||||
namespace IssnExposure {
|
||||
|
||||
Reading Read() {
|
||||
Reading r;
|
||||
r.attached = g_attached.load();
|
||||
const volatile IssnExposureMailbox& box = g_box;
|
||||
r.value = Load(const_cast<const IssnExposureMailbox&>(box).value);
|
||||
r.sum = Load(const_cast<const IssnExposureMailbox&>(box).sum);
|
||||
r.rows = box.rows;
|
||||
r.calls = box.calls;
|
||||
r.held = box.held != 0;
|
||||
return r;
|
||||
}
|
||||
|
||||
void Hold(bool on) {
|
||||
volatile IssnExposureMailbox& box = g_box;
|
||||
if (!on) {
|
||||
box.held = 0;
|
||||
std::printf("Exposure meter: following the scene again\n");
|
||||
} else {
|
||||
float v = Read().value;
|
||||
uint32_t bits;
|
||||
std::memcpy(&bits, &v, 4);
|
||||
box.held = bits ? bits : 1u;
|
||||
std::printf("Exposure meter: held at %g\n", v);
|
||||
}
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
namespace {
|
||||
constexpr uintptr_t TodState = 0x7f8e800; // eboot .bss: current, fallback, wanted id
|
||||
std::atomic<uint64_t> g_last_good{0};
|
||||
|
||||
uint64_t Read64(uintptr_t address) {
|
||||
uint64_t v;
|
||||
std::memcpy(&v, reinterpret_cast<const void*>(address), 8);
|
||||
return v;
|
||||
}
|
||||
// A guest heap pointer: inside the 64-bit guest space the port maps (not null, aligned).
|
||||
bool Plausible(uint64_t p) {
|
||||
return p >= 0x10000 && p < (1ull << 47) && (p & 7) == 0;
|
||||
}
|
||||
bool PlausibleText(uint64_t p) {
|
||||
return p >= 0x10000 && p < (1ull << 47);
|
||||
}
|
||||
// A guest C string (printable ASCII only, cut at size-1).
|
||||
void CopyText(uint64_t p, char* out, size_t size) {
|
||||
out[0] = 0;
|
||||
if (!PlausibleText(p)) {
|
||||
return;
|
||||
}
|
||||
const char* s = reinterpret_cast<const char*>(p);
|
||||
size_t i = 0;
|
||||
for (; i + 1 < size && s[i]; ++i) {
|
||||
const unsigned char c = static_cast<unsigned char>(s[i]);
|
||||
out[i] = c >= 0x20 && c < 0x7f ? static_cast<char>(c) : '?';
|
||||
}
|
||||
out[i] = 0;
|
||||
}
|
||||
uint32_t Read32(uintptr_t address) {
|
||||
uint32_t v;
|
||||
std::memcpy(&v, reinterpret_cast<const void*>(address), 4);
|
||||
return v;
|
||||
}
|
||||
// The manager's table: (id, name, node) of each entry, up to max.
|
||||
struct TodNode {
|
||||
uint64_t id;
|
||||
uint64_t obj;
|
||||
};
|
||||
int WalkTable(uintptr_t state, TodNode* out, int max) {
|
||||
const uint32_t count = Read32(state + 0x30);
|
||||
const uint64_t buckets = Read64(state + 0x38);
|
||||
if (count == 0 || count > 65536 || !Plausible(buckets)) {
|
||||
return 0;
|
||||
}
|
||||
int n = 0;
|
||||
for (uint32_t b = 0; b < count && n < max; ++b) {
|
||||
uint64_t node = Read64(buckets + 8ull * b);
|
||||
for (int guard = 0; Plausible(node) && guard < 64 && n < max; ++guard) {
|
||||
out[n++] = {Read64(node + 8), Read64(node + 0x10)};
|
||||
node = Read64(node + 0x18);
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
void NameOf(uint64_t obj, char* out, size_t size) {
|
||||
out[0] = 0;
|
||||
if (!Plausible(obj)) {
|
||||
return;
|
||||
}
|
||||
const uint64_t data = Read64(obj + 0x28);
|
||||
if (Plausible(data)) {
|
||||
CopyText(Read64(data + 0x58), out, size);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TimeOfDay ReadTimeOfDay() {
|
||||
TimeOfDay t;
|
||||
const uintptr_t base = MemoryPatcher::g_eboot_address;
|
||||
if (base == 0) {
|
||||
return t;
|
||||
}
|
||||
t.known = true;
|
||||
const uintptr_t state = base + TodState;
|
||||
const uint64_t current = Read64(state), fallback = Read64(state + 8);
|
||||
t.wanted = Read64(state + 0x10);
|
||||
t.current = ~0ull;
|
||||
if (Plausible(current)) {
|
||||
const uint64_t data = Read64(current + 0x28);
|
||||
if (Plausible(data)) {
|
||||
t.current = Read64(data + 0x18);
|
||||
t.lighting = Read64(data + 0x68) != 0;
|
||||
}
|
||||
t.fallback = current == fallback;
|
||||
}
|
||||
if (t.lighting && !t.fallback && t.current != ~0ull) {
|
||||
g_last_good = t.current;
|
||||
}
|
||||
t.last_good = g_last_good.load();
|
||||
return t;
|
||||
}
|
||||
|
||||
bool ForceLastGoodTimeOfDay() {
|
||||
const TimeOfDay t = ReadTimeOfDay();
|
||||
if (!t.known || t.last_good == 0) {
|
||||
return false;
|
||||
}
|
||||
const uint64_t id = t.last_good;
|
||||
std::memcpy(reinterpret_cast<void*>(MemoryPatcher::g_eboot_address + TodState + 0x10), &id, 8);
|
||||
std::printf("Time of day: asked the game for %#llx (test)\n", static_cast<unsigned long long>(id));
|
||||
std::fflush(stdout);
|
||||
return true;
|
||||
}
|
||||
|
||||
TodDetail ReadTodDetail() {
|
||||
TodDetail d;
|
||||
const uintptr_t base = MemoryPatcher::g_eboot_address;
|
||||
if (base == 0) {
|
||||
return d;
|
||||
}
|
||||
d.known = true;
|
||||
const uintptr_t state = base + TodState;
|
||||
d.wanted = Read64(state + 0x10);
|
||||
TodNode nodes[64];
|
||||
const int n = WalkTable(state, nodes, 64);
|
||||
d.entries = static_cast<uint32_t>(n);
|
||||
size_t used = 0;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
char name[40];
|
||||
NameOf(nodes[i].obj, name, sizeof(name));
|
||||
if (nodes[i].id == d.wanted) {
|
||||
d.in_table = true;
|
||||
std::snprintf(d.name, sizeof(d.name), "%s", name);
|
||||
}
|
||||
if (used + 1 < sizeof(d.list)) {
|
||||
const int w = std::snprintf(d.list + used, sizeof(d.list) - used, "%s%s", i ? ", " : "",
|
||||
name[0] ? name : "?");
|
||||
used += w > 0 ? static_cast<size_t>(w) : 0;
|
||||
}
|
||||
}
|
||||
if (!d.in_table || !d.name[0]) {
|
||||
return d;
|
||||
}
|
||||
// The section name and its hash (eboot 0x5ed7c0: h = h * 101 + tolower(c); "Nil" = none).
|
||||
char world[48];
|
||||
CopyText(Read64(base + 0x7f111e0 + 0x1c0a0) + 2, world, sizeof(world));
|
||||
std::snprintf(d.section, sizeof(d.section), "%s_x_%s", world, d.name);
|
||||
uint64_t h = 0;
|
||||
for (const char* c = d.section; *c; ++c) {
|
||||
const unsigned char u = static_cast<unsigned char>(*c);
|
||||
h = h * 101 + static_cast<uint64_t>(u >= 'A' && u <= 'Z' ? u + 32 : u);
|
||||
}
|
||||
// The world's section table (eboot 0x3c4e40): 997 buckets at +0x198, node {next, id, +0x10 index}.
|
||||
const uintptr_t table = base + 0x7d37610;
|
||||
const uint32_t key = static_cast<uint32_t>(h ^ (h >> 32)) % 997u;
|
||||
uint64_t node = Read64(table + 0x198 + 8ull * key);
|
||||
for (int guard = 0; Plausible(node) && guard < 256; ++guard) {
|
||||
if (Read64(node + 8) == h) {
|
||||
break;
|
||||
}
|
||||
node = Read64(node);
|
||||
}
|
||||
if (!Plausible(node) || Read64(node + 8) != h) {
|
||||
return d; // no such section: the game does not wait for one
|
||||
}
|
||||
int16_t raw;
|
||||
std::memcpy(&raw, reinterpret_cast<const void*>(node + 0x10), 2);
|
||||
d.section_index = raw >> 8;
|
||||
// eboot 0x3c4db0: ready unless the section object says it is still on its way.
|
||||
const uint32_t count = Read32(table + 0xd0);
|
||||
const uint64_t array = Read64(table + 0xc8);
|
||||
if (static_cast<uint32_t>(d.section_index) >= count || !Plausible(array)) {
|
||||
d.section_state = 1;
|
||||
return d;
|
||||
}
|
||||
const uint64_t sec = Read64(array + 8ull * static_cast<uint32_t>(d.section_index));
|
||||
if (!Plausible(sec)) {
|
||||
d.section_state = 3;
|
||||
return d;
|
||||
}
|
||||
std::memcpy(d.flags, reinterpret_cast<const void*>(sec + 0x44), 4);
|
||||
bool ready;
|
||||
if (d.flags[3]) {
|
||||
ready = true;
|
||||
} else if (d.flags[2]) {
|
||||
ready = false;
|
||||
} else if (Read64(table + 0xf8) == 0) {
|
||||
ready = true;
|
||||
} else {
|
||||
ready = d.flags[1] && d.flags[0];
|
||||
}
|
||||
d.section_state = ready ? 1 : 2;
|
||||
return d;
|
||||
}
|
||||
|
||||
bool ForceNextTimeOfDay() {
|
||||
const uintptr_t base = MemoryPatcher::g_eboot_address;
|
||||
if (base == 0) {
|
||||
return false;
|
||||
}
|
||||
const uintptr_t state = base + TodState;
|
||||
TodNode nodes[64];
|
||||
const int n = WalkTable(state, nodes, 64);
|
||||
if (n == 0) {
|
||||
return false;
|
||||
}
|
||||
const uint64_t wanted = Read64(state + 0x10);
|
||||
int next = 0;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (nodes[i].id == wanted) {
|
||||
next = (i + 1) % n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const uint64_t id = nodes[next].id;
|
||||
char name[40];
|
||||
NameOf(nodes[next].obj, name, sizeof(name));
|
||||
std::memcpy(reinterpret_cast<void*>(state + 0x10), &id, 8);
|
||||
std::printf("Time of day: asked the game for %#llx '%s' (test, %d of %d)\n",
|
||||
static_cast<unsigned long long>(id), name, next + 1, n);
|
||||
std::fflush(stdout);
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
// issn 11-05 (3): the black-city fix. Tested by João on Lvcn's save (24%, "Drogas no Lixo"): the
|
||||
// game wants time_of_day_dismal, whose world section (world_seaport_x_time_of_day_dismal) never
|
||||
// finishes streaming in (flags 3 1 1 0: on its way, for ever), so the manager waits for ever and
|
||||
// no time of day is in use: no sky, no ambient light, black city. Asking for early_morning lit it
|
||||
// at once. Fix: when the wanted one has waited for its section with nothing in use for 30 s, ask
|
||||
// for another (and the next one when that one also hangs 15 s). ISSN_TOD_RESCUE=0 turns it off.
|
||||
constexpr const char* kRescueOrder[] = {
|
||||
"time_of_day_blustery", "time_of_day_early_morning", "time_of_day_hazy_sunset",
|
||||
"time_of_day_sunset_candy", "time_of_day_hdr_night",
|
||||
};
|
||||
struct RescueState {
|
||||
uint64_t stuck_id = 0;
|
||||
std::chrono::steady_clock::time_point since{};
|
||||
uint64_t asked[8] = {};
|
||||
int asked_count = 0;
|
||||
bool last_was_ours = false;
|
||||
char status[160] = "nothing to do";
|
||||
};
|
||||
RescueState g_rescue;
|
||||
std::atomic<int> g_rescues{0};
|
||||
|
||||
bool RescueEnabled() {
|
||||
static const bool on = [] {
|
||||
const char* e = std::getenv("ISSN_TOD_RESCUE");
|
||||
return !(e && e[0] == '0');
|
||||
}();
|
||||
return on;
|
||||
}
|
||||
|
||||
void RescueTick() {
|
||||
if (!RescueEnabled() || MemoryPatcher::g_eboot_address == 0) {
|
||||
return;
|
||||
}
|
||||
using Clock = std::chrono::steady_clock;
|
||||
static auto next = Clock::now();
|
||||
const auto now = Clock::now();
|
||||
if (now < next) {
|
||||
return;
|
||||
}
|
||||
next = now + std::chrono::seconds(1);
|
||||
auto& r = g_rescue;
|
||||
const TimeOfDay t = ReadTimeOfDay();
|
||||
const TodDetail d = ReadTodDetail();
|
||||
const bool stuck = d.in_table && d.section_state == 2 && t.current == ~0ull;
|
||||
if (!stuck) {
|
||||
if (r.stuck_id != 0 && t.lighting) {
|
||||
std::snprintf(r.status, sizeof(r.status), "%s", g_rescues.load() ? "fixed (see the log)" : "nothing to do");
|
||||
}
|
||||
r.stuck_id = 0;
|
||||
return;
|
||||
}
|
||||
if (d.wanted != r.stuck_id) {
|
||||
r.last_was_ours = false;
|
||||
for (int i = 0; i < r.asked_count; ++i) {
|
||||
r.last_was_ours = r.last_was_ours || r.asked[i] == d.wanted;
|
||||
}
|
||||
r.stuck_id = d.wanted;
|
||||
r.since = now;
|
||||
std::printf("Black-city fix: '%s' waits for its section '%s' and no time of day is in use\n",
|
||||
d.name, d.section);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
const auto waited = std::chrono::duration_cast<std::chrono::seconds>(now - r.since).count();
|
||||
const long long limit = r.last_was_ours ? 15 : 30;
|
||||
if (waited < limit) {
|
||||
std::snprintf(r.status, sizeof(r.status), "'%s' stuck for %llds (acts at %llds)", d.name,
|
||||
static_cast<long long>(waited), limit);
|
||||
return;
|
||||
}
|
||||
// The first one of the list in the game's table, not tried yet this session.
|
||||
const uintptr_t state = MemoryPatcher::g_eboot_address + TodState;
|
||||
TodNode nodes[64];
|
||||
const int n = WalkTable(state, nodes, 64);
|
||||
for (const char* want : kRescueOrder) {
|
||||
for (int i = 0; i < n; ++i) {
|
||||
char name[40];
|
||||
NameOf(nodes[i].obj, name, sizeof(name));
|
||||
if (std::strcmp(name, want) != 0 || nodes[i].id == d.wanted) {
|
||||
continue;
|
||||
}
|
||||
bool tried = false;
|
||||
for (int k = 0; k < r.asked_count; ++k) {
|
||||
tried = tried || r.asked[k] == nodes[i].id;
|
||||
}
|
||||
if (tried) {
|
||||
continue;
|
||||
}
|
||||
const uint64_t id = nodes[i].id;
|
||||
std::memcpy(reinterpret_cast<void*>(state + 0x10), &id, 8);
|
||||
if (r.asked_count < 8) {
|
||||
r.asked[r.asked_count++] = id;
|
||||
}
|
||||
g_rescues.fetch_add(1);
|
||||
std::printf("Black-city fix: '%s' hung %llds waiting for its section; asked the game for '%s'\n",
|
||||
d.name, static_cast<long long>(waited), name);
|
||||
std::fflush(stdout);
|
||||
std::snprintf(r.status, sizeof(r.status), "'%s' hung: asked for '%s'", d.name, name);
|
||||
r.stuck_id = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
std::snprintf(r.status, sizeof(r.status), "'%s' stuck, no other time of day left to try", d.name);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
const char* RescueStatus() {
|
||||
return RescueEnabled() ? g_rescue.status : "off (ISSN_TOD_RESCUE=0)";
|
||||
}
|
||||
|
||||
void Tick() {
|
||||
RescueTick();
|
||||
if (!g_attached.load(std::memory_order_relaxed)) {
|
||||
return;
|
||||
}
|
||||
using Clock = std::chrono::steady_clock;
|
||||
static const auto start = Clock::now();
|
||||
static auto next = start + std::chrono::seconds(5);
|
||||
const auto now = Clock::now();
|
||||
if (now < next) {
|
||||
return;
|
||||
}
|
||||
const bool early = now - start < std::chrono::minutes(2);
|
||||
next = now + std::chrono::seconds(early ? 5 : 30);
|
||||
const Reading r = Read();
|
||||
const TimeOfDay t = ReadTimeOfDay();
|
||||
std::printf("Exposure meter: value %g, tile sum %g, %u tile rows, %u frames%s; time of day wanted "
|
||||
"%#llx, in use %#llx%s, lighting %s\n",
|
||||
r.value, r.sum, r.rows, r.calls, r.held ? " (held)" : "",
|
||||
static_cast<unsigned long long>(t.wanted), static_cast<unsigned long long>(t.current),
|
||||
t.fallback ? " (the game's fallback)" : "", t.lighting ? "loaded" : "NONE");
|
||||
// The reason, when it changes (and the table once).
|
||||
const TodDetail d = ReadTodDetail();
|
||||
static uint64_t last_wanted = 1;
|
||||
static int last_state = -1;
|
||||
static bool listed = false;
|
||||
if (d.known && (d.wanted != last_wanted || d.section_state != last_state || !listed)) {
|
||||
last_wanted = d.wanted;
|
||||
last_state = d.section_state;
|
||||
static const char* const states[] = {"no section to wait for", "section ready",
|
||||
"WAITING for the section (not streamed in)",
|
||||
"section object missing"};
|
||||
std::printf("Time of day detail: wanted %#llx %s '%s'; section '%s' index %d: %s (flags %u %u %u %u)\n",
|
||||
static_cast<unsigned long long>(d.wanted),
|
||||
d.in_table ? "is in the table as" : "is NOT in the table", d.name, d.section,
|
||||
d.section_index, states[d.section_state & 3], d.flags[0], d.flags[1], d.flags[2],
|
||||
d.flags[3]);
|
||||
if (!listed && d.entries) {
|
||||
std::printf("Time of day detail: the table has %u: %s\n", d.entries, d.list);
|
||||
listed = true;
|
||||
}
|
||||
}
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
} // namespace IssnExposure
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// issn 11-04: the exposure meter's diagnostic hook (src/exposure_hook.h), for the menu.
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace IssnExposure {
|
||||
|
||||
struct Reading {
|
||||
bool attached = false; ///< the probe put the cave in
|
||||
float value = 0.0f; ///< the metered value the game stored last
|
||||
float sum = 0.0f; ///< the raw sum of the tiles
|
||||
uint32_t rows = 0; ///< tile rows (render height / 72)
|
||||
uint32_t calls = 0; ///< frames metered so far
|
||||
bool held = false; ///< the menu holds the value
|
||||
};
|
||||
|
||||
Reading Read();
|
||||
/// On: the game keeps the value metered now; off: it meters again.
|
||||
void Hold(bool on);
|
||||
/// Called each presented frame: the black-city fix (once a second), and a log line now and then
|
||||
/// (every 5 s for 2 minutes, then every 30 s).
|
||||
void Tick();
|
||||
|
||||
/// issn 11-05: the game's time-of-day (atmosphere) state, eboot .bss 0x7f8e800 (manager at
|
||||
/// 0x59cf80): the dark saves never load a time of day's lighting files (sh-, skydome, imposter-).
|
||||
struct TimeOfDay {
|
||||
bool known = false; ///< the eboot address is set
|
||||
uint64_t wanted = 0; ///< the id the game asks for (+0x10; ~0 = none)
|
||||
uint64_t current = 0; ///< the id of the one in use (~0 = none)
|
||||
bool fallback = false; ///< in use is the game's "ensured time of day" (no files of its own)
|
||||
bool lighting = false; ///< the one in use has lighting data (what loads sh-/imposter-)
|
||||
uint64_t last_good = 0; ///< the last id seen with lighting data this session (0 = none)
|
||||
};
|
||||
TimeOfDay ReadTimeOfDay();
|
||||
/// Test: ask the game for the last good time of day (false when there is none).
|
||||
bool ForceLastGoodTimeOfDay();
|
||||
|
||||
/// issn 11-05 (2): why the wanted one is not used. The manager looks the wanted id up in its table
|
||||
/// (+0x30 count, +0x38 buckets); found, it waits until the world section "<world>_x_<name>" is
|
||||
/// streamed in (eboot 0x3c4e40/0x3c4db0 on the table at .bss 0x7d37610); not found, it falls back.
|
||||
struct TodDetail {
|
||||
bool known = false;
|
||||
uint64_t wanted = 0;
|
||||
uint32_t entries = 0; ///< times of day in the manager's table
|
||||
bool in_table = false; ///< the wanted id is one of them
|
||||
char name[48] = {}; ///< its name (atmosphere)
|
||||
char section[112] = {}; ///< the world section the game waits for
|
||||
int section_index = -1; ///< -1: no such section (the game does not wait)
|
||||
int section_state = 0; ///< 0 no section, 1 ready, 2 waiting (not streamed in), 3 no object
|
||||
uint8_t flags[4] = {}; ///< the section's bytes +0x44..+0x47
|
||||
char list[400] = {}; ///< "id name, ..." of the table (for the log)
|
||||
};
|
||||
TodDetail ReadTodDetail();
|
||||
/// Test: ask the game for the next time of day in its table (cycles; false when the table is empty).
|
||||
bool ForceNextTimeOfDay();
|
||||
/// The black-city fix (in Tick, on by default; ISSN_TOD_RESCUE=0 off): what it is doing now.
|
||||
const char* RescueStatus();
|
||||
|
||||
} // namespace IssnExposure
|
||||
@@ -262,7 +262,11 @@ static void ApplyLightListCapacity(u32 width, u32 height) {
|
||||
std::memcpy(&mov_imm, at(0x16be1f), 4);
|
||||
if (std::memcmp(at(0x16be04), imul_head, 3) != 0 || std::memcmp(at(0x16be1d), mov_head, 2) != 0 ||
|
||||
imul_imm != Original || mov_imm != Original) {
|
||||
std::printf("Render size: light list not enlarged (the code differs in this eboot.bin)\n");
|
||||
if (imul_imm == mov_imm && imul_imm >= entries) {
|
||||
std::printf("Render size: light list already %u entries (patch)\n", imul_imm);
|
||||
} else {
|
||||
std::printf("Render size: light list not enlarged (the code differs in this eboot.bin)\n");
|
||||
}
|
||||
return;
|
||||
}
|
||||
std::memcpy(at(0x16be07), &entries, 4);
|
||||
@@ -270,6 +274,43 @@ static void ApplyLightListCapacity(u32 width, u32 height) {
|
||||
std::printf("Render size: light list %u entries for %ux%u\n", entries, width, height);
|
||||
}
|
||||
|
||||
// issn 11-04: the exposure meter (CPU sum at eboot 0x171610) reads one float per 128x72-pixel tile
|
||||
// from buffers made for 625 tiles (2500 bytes, eboot 0x171310 and 0x172320): enough up to 1800p.
|
||||
// At 4K (900 tiles) it summed bytes past them, and the picture went dark and back (tester logs,
|
||||
// GitHub issue 15). The patch "Bigger light list" enlarges them too; without it, this does.
|
||||
static void ApplyExposureCapacity(u32 width, u32 height) {
|
||||
const u64 tiles = u64((width + 127) / 128) * ((height + 71) / 72);
|
||||
if (tiles <= 625) {
|
||||
return;
|
||||
}
|
||||
static constexpr u64 sizes[4] = {0x17132b, 0x171375, 0x172341, 0x172388}; // mov esi, 0x9c4
|
||||
static constexpr u64 counts[4] = {0x17134f, 0x171399, 0x172364, 0x1723ab}; // mov ecx, 0x271
|
||||
const auto at = [](u64 offset) { return reinterpret_cast<u8*>(g_eboot_address + offset); };
|
||||
bool original = true, patched = true;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
u32 size = 0, count = 0;
|
||||
std::memcpy(&size, at(sizes[i]), 4);
|
||||
std::memcpy(&count, at(counts[i]), 4);
|
||||
original = original && at(sizes[i])[-1] == 0xbe && at(counts[i])[-1] == 0xb9 && size == 0x9c4 &&
|
||||
count == 0x271;
|
||||
patched = patched && size >= tiles * 4 && count >= tiles;
|
||||
}
|
||||
if (patched) {
|
||||
std::printf("Exposure meter: room for %llu tiles (patch)\n", static_cast<unsigned long long>(tiles));
|
||||
return;
|
||||
}
|
||||
if (!original) {
|
||||
std::printf("Exposure meter: not enlarged (the code differs in this eboot.bin): the picture may go dark\n");
|
||||
return;
|
||||
}
|
||||
const u32 count = 2048, size = count * 4;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
std::memcpy(at(sizes[i]), &size, 4);
|
||||
std::memcpy(at(counts[i]), &count, 4);
|
||||
}
|
||||
std::printf("Exposure meter: %u tiles for %ux%u\n", count, width, height);
|
||||
}
|
||||
|
||||
/// The render size at 16:9-basis height h0 in the shape `aspect` (axylpup's SizeAt): wide shapes
|
||||
/// keep the height (up to 32:9), narrow ones keep the 16:9 width (down to 1:1).
|
||||
static void SizeAt(u32 h0, double aspect, u32& width, u32& height) {
|
||||
@@ -413,6 +454,7 @@ static void ApplyRenderSize() {
|
||||
std::memcpy(reinterpret_cast<void*>(g_eboot_address + imm.offset), &value, 4);
|
||||
}
|
||||
ApplyLightListCapacity(width, height);
|
||||
ApplyExposureCapacity(width, height);
|
||||
if (narrow) {
|
||||
const float two_over_k = float(2.0 / (base / aspect));
|
||||
std::memcpy(reinterpret_cast<void*>(g_eboot_address + 0x581c84), &two_over_k, 4);
|
||||
|
||||
+120
-3
@@ -3,9 +3,14 @@
|
||||
// probe's SetHitPoints stub), infinite powers and the Karma Bomb meter always full. The probe
|
||||
// verifies the 01.07 eboot, installs the stub and calls issn_hero_attach; a thread then follows
|
||||
// the menu's switches and refills the meters of the hero the game currently has.
|
||||
// 11-05: infinite health also stops the hits themselves (no gray screen), infinite missiles, and
|
||||
// the Karma Bomb is the karmic streak (the old switch filled the shield).
|
||||
// 11-04 (src/dash_hook.h): the neon run and the video flight while the dash button is held (two
|
||||
// probe stubs read the flags below), and a long smoke dash (the hero's MAX_SMOKE_DASH_RATIO).
|
||||
#include "issn_settings.h"
|
||||
|
||||
#include "../../src/hero_hook.h"
|
||||
#include "../../src/dash_hook.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
@@ -18,6 +23,14 @@ namespace {
|
||||
unsigned char* image = nullptr;
|
||||
// Read by the SetHitPoints stub: 1 while infinite health is on.
|
||||
volatile unsigned char health_flag = 0;
|
||||
// Read by the dash stubs (src/dash_hook.h): 1 while the switch is on.
|
||||
volatile unsigned char neon_flag = 0, tv_flag = 0;
|
||||
|
||||
// The long smoke dash: the game's ratio (kept while the switch is on) times SmokeFactor. The
|
||||
// value is the game's again when the switch goes off, unless the game wrote another meanwhile.
|
||||
constexpr float SmokeFactor = 3.0f;
|
||||
unsigned char* smoke_hero = nullptr;
|
||||
float smoke_game = 1.0f, smoke_written = 0.0f;
|
||||
|
||||
// A pointer the game keeps, read as it is now (null when there is no hero: menus, loading).
|
||||
unsigned char* Pointer(const unsigned char* at) {
|
||||
@@ -40,14 +53,96 @@ void Fill(unsigned char* object, std::uint32_t current, std::uint32_t maximum) {
|
||||
}
|
||||
}
|
||||
|
||||
void SmokeDash(unsigned char* hero, bool on) {
|
||||
if (!on || hero != smoke_hero) {
|
||||
// Off, or another hero object (a new one after loading): give the old one its value back.
|
||||
if (smoke_hero && hero == smoke_hero && Get(hero, ISSN_DASH_SMOKE_RATIO) == smoke_written) {
|
||||
std::memcpy(hero + ISSN_DASH_SMOKE_RATIO, &smoke_game, 4);
|
||||
}
|
||||
smoke_hero = nullptr;
|
||||
if (!on || !hero) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!hero) {
|
||||
return; // no hero yet (boot, loading): with the switch on and nothing remembered, the
|
||||
// check above let a null hero through (crash at start, 11-04)
|
||||
}
|
||||
const float now = Get(hero, ISSN_DASH_SMOKE_RATIO);
|
||||
if (!(now > 0.0f && now < 100.0f)) {
|
||||
return; // not a ratio: leave it alone
|
||||
}
|
||||
if (hero != smoke_hero || now != smoke_written) {
|
||||
smoke_hero = hero; // first tick on, or the game set a new ratio (an upgrade)
|
||||
smoke_game = now;
|
||||
smoke_written = now * SmokeFactor;
|
||||
std::memcpy(hero + ISSN_DASH_SMOKE_RATIO, &smoke_written, 4);
|
||||
}
|
||||
}
|
||||
|
||||
std::int32_t GetInt(const unsigned char* object, std::uint32_t offset) {
|
||||
std::int32_t v;
|
||||
std::memcpy(&v, object + offset, 4);
|
||||
return v;
|
||||
}
|
||||
|
||||
void SetInt(unsigned char* object, std::uint32_t offset, std::int32_t v) {
|
||||
std::memcpy(object + offset, &v, 4);
|
||||
}
|
||||
|
||||
// The missiles' ammo (src/hero_hook.h): full, with the change counted as the game's own setter
|
||||
// (0x1e35a0) counts it, so the HUD and the save see it.
|
||||
void FillMissiles(unsigned char* hero) {
|
||||
const std::int32_t top = GetInt(hero, ISSN_HERO_HEAVY_MAX);
|
||||
unsigned char* player = image + ISSN_HERO_PLAYER;
|
||||
if (top <= 0 || top > 1000 || GetInt(player, ISSN_HERO_HEAVY) >= top) {
|
||||
return;
|
||||
}
|
||||
SetInt(player, ISSN_HERO_HEAVY, top);
|
||||
if (player[0x20ac9]) {
|
||||
SetInt(player, 0x20ad0, GetInt(player, 0x20ad0) + 1);
|
||||
if (GetInt(player, 0x20ad4) == std::int32_t(0xffffc7c0)) {
|
||||
SetInt(player, 0x20ad4, GetInt(image + ISSN_HERO_CLOCK, 0x24));
|
||||
}
|
||||
SetInt(player, 0x20acc, GetInt(player, 0x20acc) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// The Karma Bomb (src/hero_hook.h): the karmic streak at what this side needs, ready with no time
|
||||
// limit. Using the bomb clears it; the next tick (50 ms) fills it again.
|
||||
void FillKarma(unsigned char* hero) {
|
||||
const std::int32_t side = GetInt(image + ISSN_HERO_PLAYER, ISSN_HERO_PLAYER_SIDE);
|
||||
if (side < 0 || side > 1) {
|
||||
return;
|
||||
}
|
||||
unsigned char* streak = hero + ISSN_HERO_STREAK;
|
||||
const std::int32_t need =
|
||||
GetInt(streak, ISSN_HERO_STREAK_REQUIRED + std::uint32_t(side) * ISSN_HERO_STREAK_SIDE_STRIDE);
|
||||
if (need <= 0 || need > 1000) {
|
||||
return; // no Karma Bomb yet (early story) or not a count
|
||||
}
|
||||
if (GetInt(streak, ISSN_HERO_STREAK_COUNT) < need) {
|
||||
SetInt(streak, ISSN_HERO_STREAK_COUNT, need);
|
||||
}
|
||||
if (GetInt(streak, ISSN_HERO_STREAK_READY_UNTIL) != 0x7fffffff) {
|
||||
SetInt(streak, ISSN_HERO_STREAK_READY_UNTIL, 0x7fffffff);
|
||||
}
|
||||
}
|
||||
|
||||
void Tick() {
|
||||
const auto& s = IssnSettings::Get();
|
||||
health_flag = s.hero_health.load() ? 1 : 0;
|
||||
const bool powers = s.hero_powers.load(), karma = s.hero_karma.load();
|
||||
if (!health_flag && !powers && !karma) {
|
||||
neon_flag = s.hero_neon_run.load() ? 1 : 0;
|
||||
tv_flag = s.hero_video_flight.load() ? 1 : 0;
|
||||
const bool powers = s.hero_powers.load();
|
||||
const bool karma = s.hero_karma.load() && s.hero_karma_supported.load();
|
||||
const bool missiles = s.hero_missiles.load() && s.hero_missiles_supported.load();
|
||||
const bool smoke = s.hero_smoke_dash.load();
|
||||
if (!health_flag && !powers && !karma && !missiles && !smoke && !smoke_hero) {
|
||||
return;
|
||||
}
|
||||
unsigned char* hero = Pointer(image + ISSN_HERO_GLOBAL);
|
||||
SmokeDash(hero, smoke);
|
||||
if (!hero) {
|
||||
return;
|
||||
}
|
||||
@@ -60,8 +155,11 @@ void Tick() {
|
||||
if (powers) {
|
||||
Fill(hero, ISSN_HERO_MAGIC, ISSN_HERO_MAGIC_MAX);
|
||||
}
|
||||
if (missiles) {
|
||||
FillMissiles(hero);
|
||||
}
|
||||
if (karma) {
|
||||
Fill(hero, ISSN_HERO_KARMA, ISSN_HERO_KARMA_MAX);
|
||||
FillKarma(hero);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +169,25 @@ extern "C" const volatile unsigned char* issn_hero_health_flag(void) {
|
||||
return &health_flag;
|
||||
}
|
||||
|
||||
extern "C" const volatile unsigned char* issn_hero_neon_flag(void) {
|
||||
return &neon_flag;
|
||||
}
|
||||
|
||||
extern "C" const volatile unsigned char* issn_hero_tv_flag(void) {
|
||||
return &tv_flag;
|
||||
}
|
||||
|
||||
extern "C" void issn_hero_dash_supported(int neon, int tv) {
|
||||
IssnSettings::Get().hero_neon_supported = neon != 0;
|
||||
IssnSettings::Get().hero_video_supported = tv != 0;
|
||||
}
|
||||
|
||||
extern "C" void issn_hero_extra_supported(int missiles, int karma, int no_hit) {
|
||||
IssnSettings::Get().hero_missiles_supported = missiles != 0;
|
||||
IssnSettings::Get().hero_karma_supported = karma != 0;
|
||||
IssnSettings::Get().hero_no_hit_supported = no_hit != 0;
|
||||
}
|
||||
|
||||
extern "C" void issn_hero_attach(unsigned char* guest_image) {
|
||||
image = guest_image;
|
||||
IssnSettings::Get().hero_supported = true;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// ApplyLiveChoices). Apart from issn_settings.cpp, whose tests build without the video core.
|
||||
#include "issn_settings.h"
|
||||
#include "video_core/renderer_vulkan/issn_framegen.h"
|
||||
#include "video_core/renderer_vulkan/issn_postfx.h"
|
||||
#include "video_core/texture_cache/sampler.h"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -16,6 +17,10 @@ void ApplyLiveChoices() {
|
||||
if (Vulkan::FrameGen::Wanted()) {
|
||||
Vulkan::FrameGen::SetEnabled(v.menu_frame_gen.load() > 0);
|
||||
}
|
||||
// issn 11-04: the game's motion blur and chromatic aberration (issn_postfx.h).
|
||||
Vulkan::PostFx::g_off[Vulkan::PostFx::MotionBlur].store(!v.motion_blur.load(), std::memory_order_relaxed);
|
||||
Vulkan::PostFx::g_off[Vulkan::PostFx::Chromatic].store(!v.chromatic_aberration.load(),
|
||||
std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
} // namespace IssnSettings
|
||||
+602
-55
@@ -10,6 +10,7 @@
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -20,6 +21,7 @@
|
||||
#include "issn_actors.h"
|
||||
#include "issn_graphics.h"
|
||||
#include "issn_perf_stats.h"
|
||||
#include "issn_exposure.h"
|
||||
#include "../../src/actor_hook.h"
|
||||
|
||||
extern IssnActorSlot issn_actor_slots[2];
|
||||
@@ -33,9 +35,11 @@ extern "C" const char* runtime_pad_key_name(const char* button);
|
||||
#include "imgui_impl_vulkan.h"
|
||||
#include "video_core/renderdoc.h"
|
||||
#include "video_core/renderer_vulkan/vk_dlss.h"
|
||||
#include "video_core/renderer_vulkan/issn_postfx.h"
|
||||
#include "video_core/renderer_vulkan/issn_framegen.h"
|
||||
#include "video_core/renderer_vulkan/vk_instance.h"
|
||||
#include "video_core/renderer_vulkan/vk_scheduler.h"
|
||||
#include "video_core/renderer_vulkan/vk_swapchain.h"
|
||||
|
||||
// DejaVu Sans (Cyrillic), embedded (third_party/fonts, Bitstream Vera license).
|
||||
#ifdef _WIN32
|
||||
@@ -289,6 +293,18 @@ void InfoRow(const char* label, const char* text) {
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
// issn 11-04: a float setting as a slider (sensitivities), live.
|
||||
void FloatSliderRow(const char* label, std::atomic<float>& value, float lo, float hi, const char* format,
|
||||
const char* help) {
|
||||
Row(label);
|
||||
float v = value;
|
||||
if (ImGui::SliderFloat("##value", &v, lo, hi, format, ImGuiSliderFlags_AlwaysClamp)) {
|
||||
Store(value, std::clamp(v, lo, hi), true);
|
||||
}
|
||||
Help(help);
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
void SliderRow(const char* label, std::atomic<int>& value, int lo, int hi, const char* format, const char* help) {
|
||||
Row(label);
|
||||
int v = value;
|
||||
@@ -366,22 +382,49 @@ void GameTab() {
|
||||
ImGui::SeparatorText("Cheats");
|
||||
if (s.hero_supported && BeginRows("##hero")) {
|
||||
Row("God Mode");
|
||||
bool god = s.hero_health.load() && s.hero_powers.load();
|
||||
const bool missiles_row = s.hero_missiles_supported.load();
|
||||
bool god = s.hero_health.load() && s.hero_powers.load() &&
|
||||
(!missiles_row || s.hero_missiles.load());
|
||||
if (ImGui::Checkbox("##value", &god)) {
|
||||
s.hero_health = god;
|
||||
s.hero_powers = god;
|
||||
if (missiles_row) {
|
||||
s.hero_missiles = god;
|
||||
}
|
||||
dirty = true;
|
||||
}
|
||||
Help("Infinite health and infinite powers together.");
|
||||
Help("Infinite health, powers and missiles together.");
|
||||
ImGui::PopID();
|
||||
CheckRow("Infinite health", s.hero_health,
|
||||
"Delsin does not lose health (falls and scripted deaths still happen). Only Delsin "
|
||||
"is protected.");
|
||||
s.hero_no_hit_supported.load()
|
||||
? "Enemy hits do not reach Delsin: no health loss and no gray screen. Scripted "
|
||||
"deaths still happen. Only Delsin is protected."
|
||||
: "Delsin does not lose health (falls and scripted deaths still happen). Only "
|
||||
"Delsin is protected.");
|
||||
CheckRow("Infinite powers", s.hero_powers,
|
||||
"The energy of the power in use stays full: no need to drain smoke, neon or other "
|
||||
"sources.");
|
||||
CheckRow("Karma Bomb always ready", s.hero_karma,
|
||||
"The Karma Bomb meter stays full.");
|
||||
if (missiles_row) {
|
||||
CheckRow("Infinite missiles", s.hero_missiles,
|
||||
"The missiles (the heavy shots with their own ammo) never run out.");
|
||||
}
|
||||
if (s.hero_karma_supported.load()) {
|
||||
CheckRow("Karma Bomb always ready", s.hero_karma,
|
||||
"The Karma Bomb stays ready: after using it, it is ready again at once.");
|
||||
}
|
||||
if (s.hero_neon_supported) {
|
||||
CheckRow("Endless neon run", s.hero_neon_run,
|
||||
"Neon: hold the dash button and the run does not stop after 2 seconds. "
|
||||
"Releasing it stops the run.");
|
||||
}
|
||||
if (s.hero_video_supported) {
|
||||
CheckRow("Endless video flight", s.hero_video_flight,
|
||||
"Video: hold the dash button and the flight does not stop after 1.5 seconds. "
|
||||
"Releasing it ends the flight.");
|
||||
}
|
||||
CheckRow("Long smoke dash", s.hero_smoke_dash,
|
||||
"Smoke: each dash goes 3 times as far. The smoke dash goes to a point picked when it "
|
||||
"starts, so holding the button does not make it longer.");
|
||||
ImGui::EndTable();
|
||||
} else if (!any_cheat) {
|
||||
ImGui::TextDisabled("No cheats for this game build.");
|
||||
@@ -503,6 +546,17 @@ void AssignKey(int row, SDL_Scancode code) {
|
||||
|
||||
void ControlsTab() {
|
||||
auto& s = IssnSettings::Get();
|
||||
ImGui::SeparatorText("Sensitivity (changes at once)");
|
||||
if (BeginRows("##sensitivity")) {
|
||||
FloatSliderRow("Mouse", s.mouse_sensitivity, 0.1f, 5.0f, "%.2fx",
|
||||
"How far the camera turns per mouse movement. - / = also change it in play.");
|
||||
FloatSliderRow("Controller (right stick)", s.pad_sensitivity, 0.25f, 3.0f, "%.2fx",
|
||||
"Below 1: slower camera. Above 1: the camera reacts more to small stick moves.");
|
||||
CheckRow("Spray can: tilt the pad", s.spray_pose,
|
||||
"For controllers without motion sensors (Xbox, 8BitDo): holds the pad tilted for the "
|
||||
"graffiti; L1 shakes the can, the right stick aims. Turn it off when done. H on the keyboard.");
|
||||
ImGui::EndTable();
|
||||
}
|
||||
ImGui::SeparatorText("Keyboard");
|
||||
ImGui::TextWrapped("Click Change, then press the key. Esc cancels. The mouse buttons stay L2 (aim) and R2 "
|
||||
"(shoot); W/A/S/D move and the mouse turns the camera.");
|
||||
@@ -569,9 +623,8 @@ void PictureRows() {
|
||||
static const float fov_values[] = {0.9f, 1.0f, 1.1f, 1.15f, 1.2f, 1.3f, 1.4f};
|
||||
if (s.fov_supported) {
|
||||
FloatChoiceRow("Field of view", s.fov, fov_labels, fov_values, 7,
|
||||
"How much of the city the camera shows. Picture only: aiming and the camera "
|
||||
"itself stay as they are.");
|
||||
} // hidden otherwise (src/probe.c install_fov: off unless ISSN_FOV=1 for now)
|
||||
"How much of the city the camera shows. Aiming follows the wider view.");
|
||||
} // hidden otherwise (another eboot build, a field of view patch on, or ISSN_FOV_MENU=0)
|
||||
static const char* const distance_labels[] = {"Short (faster)", "Original", "Long (less pop-in)",
|
||||
"Very long (slower)"};
|
||||
if (s.view_supported) {
|
||||
@@ -592,6 +645,22 @@ void PictureRows() {
|
||||
"Ground and walls seen at an angle stay sharp in the distance.");
|
||||
if (s.menu_aniso != before) IssnSettings::ApplyLiveChoices();
|
||||
}
|
||||
// issn 11-04 (Discord poll): the game's motion blur and chromatic aberration, live.
|
||||
namespace P = Vulkan::PostFx;
|
||||
const auto effect_row = [&](const char* label, std::atomic<bool>& value, P::Effect effect,
|
||||
const char* help) {
|
||||
if (P::Known(effect)) {
|
||||
const bool before = value;
|
||||
CheckRow(label, value, help);
|
||||
if (value != before) IssnSettings::ApplyLiveChoices();
|
||||
} else {
|
||||
InfoRow(label, "not found yet (Find post effects, below)");
|
||||
}
|
||||
};
|
||||
effect_row("Motion blur", s.motion_blur, P::MotionBlur,
|
||||
"The blur when the camera or Delsin moves fast. Off: a sharp picture in motion.");
|
||||
effect_row("Chromatic aberration", s.chromatic_aberration, P::Chromatic,
|
||||
"The red and blue fringes toward the edges of the screen.");
|
||||
static const char* const fg_labels[] = {"Off", "On (test)"};
|
||||
if (Vulkan::FrameGen::Wanted()) {
|
||||
const int before = s.menu_frame_gen;
|
||||
@@ -605,12 +674,14 @@ void PictureRows() {
|
||||
}
|
||||
ImGui::EndTable();
|
||||
}
|
||||
ImGui::SeparatorText("Next start (restart the game from the launcher)");
|
||||
ImGui::SeparatorText("Needs a restart (Apply and restart, below)");
|
||||
if (BeginRows("##next_start")) {
|
||||
static const char* const aa_labels[] = {"The game's own", "FSR 4 (AMD)", "XeSS (Intel)", "DLSS (NVIDIA RTX)"};
|
||||
ChoiceRow("Anti-aliasing / upscaler", s.menu_anti_aliasing, aa_labels, nullptr, 4,
|
||||
static const char* const aa_labels[] = {"The game's own", "FSR 4 (AMD)", "XeSS (Intel)", "DLSS (NVIDIA RTX)",
|
||||
"Off (none)"};
|
||||
ChoiceRow("Anti-aliasing / upscaler", s.menu_anti_aliasing, aa_labels, nullptr, 5,
|
||||
"FSR 4, XeSS or DLSS give the sharpest picture and allow the quality below and frame "
|
||||
"generation. The launcher fetches XeSS / DLSS files when needed.");
|
||||
"generation. The launcher fetches XeSS / DLSS files when needed. Off: no anti-aliasing "
|
||||
"at all (sharp, with jagged edges).");
|
||||
static const char* const quality_labels[] = {"Off (game resolution)", "Quality (screen / 1.5)",
|
||||
"Balanced (screen / 1.7)", "Performance (screen / 2)",
|
||||
"Ultra Performance (screen / 3)"};
|
||||
@@ -626,10 +697,87 @@ void PictureRows() {
|
||||
(!Vulkan::FrameGen::Wanted() && s.menu_frame_gen != s.startup_frame_gen);
|
||||
if (changed) {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.78f, 0.3f, 1.0f),
|
||||
"Changed: close the game and press Play in the launcher to use them.");
|
||||
"Changed: press \"Apply and restart\" below (unsaved progress is lost).");
|
||||
}
|
||||
}
|
||||
|
||||
// issn 11-04 (video_core issn_postfx.h): the full-screen passes of the last frame up to the T2x
|
||||
// resolve, each with a live "off", to find which ones are the motion blur and the chromatic
|
||||
// aberration. Collected only while this header is open.
|
||||
void PostFxTest() {
|
||||
namespace P = Vulkan::PostFx;
|
||||
const bool open = ImGui::CollapsingHeader("Find post effects (test)");
|
||||
Help("Lists the game's full-screen passes after the scene and before the HUD. Turn them off one at "
|
||||
"a time and watch what disappears; send the hash of the motion blur and of the colour fringes.");
|
||||
P::g_collect.store(open, std::memory_order_relaxed);
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
std::vector<P::Pass> passes;
|
||||
u64 frame = 0;
|
||||
{
|
||||
std::scoped_lock lock{P::g_list.mutex};
|
||||
passes = P::g_list.last;
|
||||
frame = P::g_list.frame;
|
||||
}
|
||||
if (BeginRows("##postfx_mode")) {
|
||||
static const char* const mode_labels[] = {"Copy its input (as the switches do)", "Skip the draw"};
|
||||
ChoiceRow("When off", P::g_test_mode, mode_labels, nullptr, 2,
|
||||
"Copy: the pass's target gets the picture it read, so the effect alone goes away. "
|
||||
"Skip: nothing is drawn (can leave an old picture).");
|
||||
ImGui::EndTable();
|
||||
}
|
||||
if (passes.empty()) {
|
||||
ImGui::TextDisabled(frame ? "No full-screen pass before the resolve in the last frame."
|
||||
: "Waiting for a frame (play, not a menu or loading screen).");
|
||||
return;
|
||||
}
|
||||
if (ImGui::Button("Write the list to the log")) {
|
||||
std::printf("PostFx: %zu full-screen passes before the T2x resolve (frame %llu)\n", passes.size(),
|
||||
static_cast<unsigned long long>(frame));
|
||||
for (size_t i = 0; i < passes.size(); ++i) {
|
||||
const auto& p = passes[i];
|
||||
std::printf("PostFx %2zu: %s_%08llx -> %#llx %ux%u f%#x, %u inputs%s\n", i,
|
||||
p.compute ? "cs" : "fs", static_cast<unsigned long long>(p.hash),
|
||||
static_cast<unsigned long long>(p.target), unsigned(p.width), unsigned(p.height),
|
||||
unsigned(p.fmt), unsigned(p.inputs),
|
||||
P::IsTestOff(p.hash) ? " [off]" : "");
|
||||
for (unsigned k = 0; k < p.inputs; ++k) {
|
||||
std::printf(" in %#llx %ux%u f%#x\n", static_cast<unsigned long long>(p.in[k].addr),
|
||||
unsigned(p.in[k].width), unsigned(p.in[k].height), unsigned(p.in[k].fmt));
|
||||
}
|
||||
}
|
||||
std::fflush(stdout);
|
||||
}
|
||||
Help("Writes this list (with the inputs) to user\\last_run.log.");
|
||||
if (!ImGui::BeginTable("##postfx", 4, ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_RowBg)) {
|
||||
return;
|
||||
}
|
||||
ImGui::TableSetupColumn("#", ImGuiTableColumnFlags_WidthStretch, 0.08f);
|
||||
ImGui::TableSetupColumn("shader", ImGuiTableColumnFlags_WidthStretch, 0.34f);
|
||||
ImGui::TableSetupColumn("target", ImGuiTableColumnFlags_WidthStretch, 0.40f);
|
||||
ImGui::TableSetupColumn("off", ImGuiTableColumnFlags_WidthStretch, 0.18f);
|
||||
for (size_t i = 0; i < passes.size(); ++i) {
|
||||
const auto& p = passes[i];
|
||||
ImGui::TableNextRow();
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::Text("%zu", i);
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::Text("%s_%08llx", p.compute ? "cs" : "fs", static_cast<unsigned long long>(p.hash));
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::Text("%ux%u, %u in", unsigned(p.width), unsigned(p.height), unsigned(p.inputs));
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
bool off = P::IsTestOff(p.hash);
|
||||
if (ImGui::Checkbox("##off", &off)) {
|
||||
P::SetTestOff(p.hash, off);
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
ImGui::EndTable();
|
||||
}
|
||||
|
||||
void ImageTab() {
|
||||
namespace G = IssnGraphics;
|
||||
auto& s = IssnSettings::Get();
|
||||
@@ -638,7 +786,7 @@ void ImageTab() {
|
||||
if (BeginRows("##display")) {
|
||||
ChoiceRow("Display mode", s.display_mode, G::DisplayModeNames.data(), nullptr, G::DisplayModeCount,
|
||||
"Windowed, borderless window or exclusive fullscreen.");
|
||||
CheckRow("VSync (on restart)", s.vsync, "Waits for the monitor: no tearing, a little more input delay.");
|
||||
CheckRow("VSync", s.vsync, "Waits for the monitor: no tearing, a little more input delay.");
|
||||
ImGui::EndTable();
|
||||
}
|
||||
const int iw = s.image_width, ih = s.image_height, ww = s.window_width, wh = s.window_height;
|
||||
@@ -651,20 +799,57 @@ void ImageTab() {
|
||||
InfoRow("Window", text);
|
||||
ImGui::EndTable();
|
||||
}
|
||||
}
|
||||
|
||||
// issn-axy: the new GPU core reads the frame rate limit (its vblank rate) and VSync (the present
|
||||
// mode) when it starts, so both apply on restart; the old core's live options are gone.
|
||||
struct StartupGraphics {
|
||||
int fps_limit = -1;
|
||||
bool vsync = false;
|
||||
};
|
||||
StartupGraphics& Startup() {
|
||||
static StartupGraphics startup = [] {
|
||||
const auto& s = IssnSettings::Get();
|
||||
return StartupGraphics{s.fps_limit.load(), s.vsync.load()};
|
||||
}();
|
||||
return startup;
|
||||
PostFxTest();
|
||||
// issn 11-04: the game's exposure meter (src/exposure_hook.h), for the dark-night reports.
|
||||
if (const auto e = IssnExposure::Read(); e.attached) {
|
||||
ImGui::SeparatorText("Exposure (test)");
|
||||
if (BeginRows("##exposure")) {
|
||||
char text[96];
|
||||
std::snprintf(text, sizeof(text), "%.4g (tile sum %.4g, %u rows)", e.value, e.sum, e.rows);
|
||||
InfoRow("Metered now", text);
|
||||
Row("Hold this exposure");
|
||||
bool held = e.held;
|
||||
if (ImGui::Checkbox("##hold", &held)) {
|
||||
IssnExposure::Hold(held);
|
||||
}
|
||||
Help("A test for the dark night: hold it where the picture looks right (in daylight), then walk "
|
||||
"into the dark place. If it is not dark any more, the darkness comes from the metering. Turn "
|
||||
"it off afterwards (it is not saved).");
|
||||
ImGui::PopID();
|
||||
// issn 11-05: the time of day's lighting (the dark saves never load it).
|
||||
const auto t = IssnExposure::ReadTimeOfDay();
|
||||
if (t.known) {
|
||||
std::snprintf(text, sizeof(text), "%#llx%s, lighting %s",
|
||||
static_cast<unsigned long long>(t.current), t.fallback ? " (fallback)" : "",
|
||||
t.lighting ? "loaded" : "NONE");
|
||||
InfoRow("Time of day", text);
|
||||
InfoRow("Black-city fix", IssnExposure::RescueStatus());
|
||||
const auto d = IssnExposure::ReadTodDetail();
|
||||
char why[160];
|
||||
if (!d.in_table) {
|
||||
std::snprintf(why, sizeof(why), "not in the game's list (%u there)", d.entries);
|
||||
} else {
|
||||
static const char* const states[] = {"no section to wait for", "section ready",
|
||||
"waiting for its section", "section missing"};
|
||||
std::snprintf(why, sizeof(why), "%s: %s", d.name, states[d.section_state & 3]);
|
||||
}
|
||||
InfoRow("Why", why);
|
||||
if (d.section[0]) {
|
||||
InfoRow("Section", d.section);
|
||||
}
|
||||
Row("Try the next one (test)");
|
||||
ImGui::BeginDisabled(d.entries == 0);
|
||||
if (ImGui::SmallButton("Next##tod")) {
|
||||
IssnExposure::ForceNextTimeOfDay();
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
Help("Asks the game for the next time of day in its list (it cycles). The black-city fix "
|
||||
"does this by itself after 30 s when the game's choice never loads.");
|
||||
ImGui::PopID();
|
||||
}
|
||||
ImGui::EndTable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const char* AntiAliasingName() {
|
||||
@@ -680,14 +865,16 @@ const char* AntiAliasingName() {
|
||||
if (xess && xess[0] == '1') {
|
||||
return "XeSS (Intel)";
|
||||
}
|
||||
if (Vulkan::Dlss::AaOff()) {
|
||||
return "Off";
|
||||
}
|
||||
return "The game's own (SMAA T2x)";
|
||||
}
|
||||
|
||||
void PerformanceTab() {
|
||||
namespace G = IssnGraphics;
|
||||
auto& s = IssnSettings::Get();
|
||||
Startup();
|
||||
ImGui::SeparatorText("Applied on restart");
|
||||
ImGui::SeparatorText("Changes at once");
|
||||
if (BeginRows("##restart")) {
|
||||
static const char* const fps_labels[] = {"30", "60", "120", "240", "Unlimited"};
|
||||
ChoiceRow("Frame rate limit", s.fps_limit, fps_labels, G::FpsLimits.data(), int(G::FpsLimits.size()),
|
||||
@@ -697,7 +884,17 @@ void PerformanceTab() {
|
||||
ImGui::SeparatorText("Information");
|
||||
if (BeginRows("##perf_info")) {
|
||||
InfoRow("Anti-aliasing", AntiAliasingName());
|
||||
InfoRow("Change it in", "the Image tab (next start) or the launcher");
|
||||
InfoRow("Change it in", "the Image tab (Apply and restart) or the launcher");
|
||||
// Frame pacing (launcher, SHADPS4_VSYNC_DIVIDER) shows every game frame N refreshes with
|
||||
// V-Sync forced on: the game tops out at refresh / N whatever the limit and VSync say, and
|
||||
// the counter shows the screen's rate (tester 10-31: "100 FPS (game 50)" at limit 60).
|
||||
if (const u32 divider = Vulkan::VsyncDivider(); divider > 1) {
|
||||
static char pacing[96];
|
||||
std::snprintf(pacing, sizeof(pacing), "on: game = screen refresh / %u", divider);
|
||||
InfoRow("Frame pacing", pacing);
|
||||
InfoRow("Note", "keeps VSync on, ignores the limit");
|
||||
InfoRow("Turn it off in", "the launcher (Frame pacing: Normal)");
|
||||
}
|
||||
ImGui::EndTable();
|
||||
}
|
||||
}
|
||||
@@ -706,9 +903,11 @@ void OverlayTab() {
|
||||
namespace P = IssnPerfStats;
|
||||
auto& s = IssnSettings::Get();
|
||||
if (BeginRows("##overlay_on")) {
|
||||
CheckRow("Show overlay", s.show_fps, "The performance overlay in a corner of the screen.");
|
||||
CheckRow("Show overlay", s.show_fps, "The performance overlay in a corner of the screen. F11 hides or shows it in play.");
|
||||
CheckRow("\"Game saved\" note", s.save_note,
|
||||
"A short note at the top of the screen each time the game writes a save.");
|
||||
CheckRow("Trophy popup", s.trophy_note,
|
||||
"The trophy's icon and name in a top corner for a few seconds when the game unlocks one.");
|
||||
ImGui::EndTable();
|
||||
}
|
||||
ImGui::BeginDisabled(!s.show_fps);
|
||||
@@ -779,8 +978,11 @@ void OverlayTab() {
|
||||
// Startup options changed since this launch (applied by "Apply and restart").
|
||||
int RestartChanges() {
|
||||
const auto& s = IssnSettings::Get();
|
||||
const auto& startup = Startup();
|
||||
return (s.fps_limit != startup.fps_limit) + (s.vsync != startup.vsync);
|
||||
// issn-axy: the anti-aliasing, upscaler quality and (when it is not live) frame generation
|
||||
// chosen in the Image tab; run.py starts them on a menu restart (graphics_options.py).
|
||||
// Frame rate limit and VSync change at once (the vblank thread and the swapchain follow them).
|
||||
return (s.menu_anti_aliasing != s.startup_anti_aliasing) + (s.menu_upscale_quality != s.startup_upscale_quality) +
|
||||
(!Vulkan::FrameGen::Wanted() && s.menu_frame_gen != s.startup_frame_gen);
|
||||
}
|
||||
|
||||
void Menu() {
|
||||
@@ -1003,27 +1205,227 @@ void ChoiceBox() {
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
// issn-axy (04/11): the port's own shader loading screen. The background is the player's dump
|
||||
// art (sce_sys/pic1.png, ISSN_LOADING_ART from run.py: read at run time, never shipped); without
|
||||
// it, a dark neon gradient. Texts follow the game language (ISSN_LANGUAGE: PS4 ids).
|
||||
enum class LoadLang { En, Pt, Es };
|
||||
|
||||
LoadLang LoadingLanguage() {
|
||||
static const LoadLang lang = [] {
|
||||
const char* v = std::getenv("ISSN_LANGUAGE");
|
||||
const int id = v ? std::atoi(v) : 1;
|
||||
return id == 7 || id == 17 ? LoadLang::Pt : id == 3 || id == 20 ? LoadLang::Es : LoadLang::En;
|
||||
}();
|
||||
return lang;
|
||||
}
|
||||
|
||||
const char* LoadText(const char* en, const char* pt, const char* es) {
|
||||
switch (LoadingLanguage()) {
|
||||
case LoadLang::Pt: return pt;
|
||||
case LoadLang::Es: return es;
|
||||
default: return en;
|
||||
}
|
||||
}
|
||||
|
||||
struct LoadingTip {
|
||||
const char *en, *pt, *es;
|
||||
};
|
||||
// Only things the port does today (tested features of the menu and controls).
|
||||
constexpr LoadingTip LoadingTips[] = {
|
||||
{"Insert (or R3 + L2) opens the port's menu at any time.",
|
||||
"Insert (ou R3 + L2) abre o menu do port a qualquer momento.",
|
||||
"Insert (o R3 + L2) abre el menú del port en cualquier momento."},
|
||||
{"Cheats for health, powers and the Karma Bomb are in Insert > Game.",
|
||||
"Cheats de vida, poderes e Karma Bomb ficam em Insert > Game.",
|
||||
"Los trucos de vida, poderes y Karma Bomb están en Insert > Game."},
|
||||
{"No motion sensor? The spray can works with the H key or the switch in Insert > Controls.",
|
||||
"Controle sem sensor? A lata de spray funciona com a tecla H ou a chave em Insert > Controls.",
|
||||
"¿Mando sin sensor? El spray funciona con la tecla H o el interruptor de Insert > Controls."},
|
||||
{"F11 hides the on-screen overlay.",
|
||||
"F11 esconde o overlay da tela.",
|
||||
"F11 oculta el overlay de la pantalla."},
|
||||
{"The shader cache grows as you play: the next starts load faster.",
|
||||
"O cache de shaders cresce enquanto você joga: os próximos inícios carregam mais rápido.",
|
||||
"La caché de shaders crece mientras juegas: los próximos inicios cargan más rápido."},
|
||||
};
|
||||
constexpr float LoadingTipSeconds = 7.0f;
|
||||
|
||||
// The background art: decoded and made a texture on the present thread (imgui_mutex held,
|
||||
// before NewFrame); freed through choice_retired when the loading screen ends.
|
||||
ImTextureData* loading_art = nullptr;
|
||||
bool loading_art_tried = false;
|
||||
|
||||
void UpdateLoadingArt() {
|
||||
if (loading && !loading_art_tried) {
|
||||
loading_art_tried = true;
|
||||
const char* path = std::getenv("ISSN_LOADING_ART");
|
||||
IssnPng::Image picture;
|
||||
if (path && *path && IssnPng::LoadRgba(path, picture) && !picture.rgba.empty()) {
|
||||
ImTextureData* tex = IM_NEW(ImTextureData)();
|
||||
tex->Create(ImTextureFormat_RGBA32, picture.width, picture.height);
|
||||
std::memcpy(tex->GetPixels(), picture.rgba.data(), picture.rgba.size());
|
||||
ImGui::RegisterUserTexture(tex);
|
||||
loading_art = tex;
|
||||
std::printf("Loading screen: art %dx%d\n", picture.width, picture.height);
|
||||
} else {
|
||||
std::printf("Loading screen: no art (%s)\n", path && *path ? "unreadable" : "not set");
|
||||
}
|
||||
std::fflush(stdout);
|
||||
}
|
||||
if (!loading && loading_art) {
|
||||
loading_art->WantDestroyNextFrame = true;
|
||||
choice_retired.push_back(loading_art);
|
||||
choice_retiring = true;
|
||||
loading_art = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
ImU32 Rgba(int r, int g, int b, float a) {
|
||||
return IM_COL32(r, g, b, int(std::clamp(a, 0.0f, 1.0f) * 255.0f + 0.5f));
|
||||
}
|
||||
|
||||
void LoadingScreen() {
|
||||
const ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
ImGui::SetNextWindowPos(ImVec2(viewport->WorkPos.x + viewport->WorkSize.x * 0.5f,
|
||||
viewport->WorkPos.y + viewport->WorkSize.y * 0.5f),
|
||||
ImGuiCond_Always, ImVec2(0.5f, 0.5f));
|
||||
ImGui::SetNextWindowSize(ImVec2(viewport->WorkSize.x * 0.4f, 0.0f), ImGuiCond_Always);
|
||||
ImGui::SetNextWindowBgAlpha(0.0f);
|
||||
ImGui::Begin("##loading", nullptr,
|
||||
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs |
|
||||
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoFocusOnAppearing);
|
||||
const u32 done = loading_done, total = loading_total;
|
||||
ImGui::SetWindowFontScale(1.6f);
|
||||
ImGui::TextUnformatted(total ? "Loading shader cache..." : "Preparing graphics...");
|
||||
ImGui::SetWindowFontScale(1.0f);
|
||||
if (total) {
|
||||
char count[64];
|
||||
std::snprintf(count, sizeof(count), "%u%%", u32(u64(done) * 100 / total));
|
||||
ImGui::ProgressBar(float(done) / float(total), ImVec2(-1.0f, 0.0f), count);
|
||||
ImDrawList* draw = ImGui::GetBackgroundDrawList(const_cast<ImGuiViewport*>(viewport));
|
||||
const ImVec2 p0 = viewport->Pos;
|
||||
const ImVec2 size = viewport->Size;
|
||||
const ImVec2 p1(p0.x + size.x, p0.y + size.y);
|
||||
const float s = base_scale;
|
||||
const float t = float(ImGui::GetTime());
|
||||
|
||||
// Background: the art covering the screen (cropped, never stretched), else a gradient.
|
||||
if (loading_art && loading_art->Width > 0 && loading_art->Height > 0) {
|
||||
const float art_aspect = float(loading_art->Width) / float(loading_art->Height);
|
||||
const float screen_aspect = size.x / std::max(size.y, 1.0f);
|
||||
ImVec2 uv0(0.0f, 0.0f), uv1(1.0f, 1.0f);
|
||||
if (art_aspect > screen_aspect) {
|
||||
const float keep = screen_aspect / art_aspect;
|
||||
uv0.x = (1.0f - keep) * 0.5f;
|
||||
uv1.x = 1.0f - uv0.x;
|
||||
} else {
|
||||
const float keep = art_aspect / screen_aspect;
|
||||
uv0.y = (1.0f - keep) * 0.5f;
|
||||
uv1.y = 1.0f - uv0.y;
|
||||
}
|
||||
draw->AddImage(loading_art->GetTexRef(), p0, p1, uv0, uv1, Rgba(200, 200, 205, 1.0f));
|
||||
} else {
|
||||
draw->AddRectFilledMultiColor(p0, p1, Rgba(18, 10, 28, 1.0f), Rgba(8, 8, 16, 1.0f),
|
||||
Rgba(4, 4, 8, 1.0f), Rgba(30, 8, 30, 1.0f));
|
||||
// A slow neon glow drifting across the lower half.
|
||||
const float glow_x = p0.x + size.x * (0.3f + 0.2f * std::sin(t * 0.25f));
|
||||
// Many faint discs, so the steps between them do not show as rings.
|
||||
for (int i = 24; i > 0; --i) {
|
||||
const float r = size.y * 0.023f * float(i);
|
||||
draw->AddCircleFilled(ImVec2(glow_x, p0.y + size.y * 0.78f), r,
|
||||
Rgba(230, 40, 140, 0.009f), 64);
|
||||
}
|
||||
}
|
||||
ImGui::TextDisabled("The first start after an update takes longer.");
|
||||
ImGui::End();
|
||||
// Shade the left and the bottom so the texts read over any art.
|
||||
draw->AddRectFilledMultiColor(p0, ImVec2(p0.x + size.x * 0.6f, p1.y), Rgba(0, 0, 0, 0.55f),
|
||||
Rgba(0, 0, 0, 0.0f), Rgba(0, 0, 0, 0.0f), Rgba(0, 0, 0, 0.55f));
|
||||
draw->AddRectFilledMultiColor(ImVec2(p0.x, p0.y + size.y * 0.5f), p1, Rgba(0, 0, 0, 0.0f),
|
||||
Rgba(0, 0, 0, 0.0f), Rgba(0, 0, 0, 0.88f), Rgba(0, 0, 0, 0.88f));
|
||||
|
||||
ImFont* font = ImGui::GetFont();
|
||||
const ImU32 neon = Rgba(255, 64, 160, 1.0f), violet = Rgba(150, 70, 255, 1.0f);
|
||||
const ImU32 white = Rgba(245, 245, 250, 1.0f), dim = Rgba(200, 200, 210, 0.72f);
|
||||
const float margin = 64.0f * s;
|
||||
const float bar_y = p1.y - margin;
|
||||
const float bar_h = 6.0f * s;
|
||||
const float left = p0.x + margin, right = p1.x - margin;
|
||||
|
||||
// Version, top right.
|
||||
if (const char* version = std::getenv("ISSN_PORT_VERSION"); version && *version) {
|
||||
char line[64];
|
||||
std::snprintf(line, sizeof(line), "issn_pc v%s", version);
|
||||
const float fs = 16.0f * s;
|
||||
const ImVec2 ts = font->CalcTextSizeA(fs, FLT_MAX, 0.0f, line);
|
||||
draw->AddText(font, fs, ImVec2(right - ts.x, p0.y + 28.0f * s), dim, line);
|
||||
}
|
||||
|
||||
// Progress, eased toward the warm-up's count (it moves in steps).
|
||||
const u32 done = loading_done, total = loading_total;
|
||||
static float shown = 0.0f;
|
||||
const float target = total ? std::clamp(float(done) / float(total), 0.0f, 1.0f) : 0.0f;
|
||||
shown += (target - shown) * std::min(1.0f, ImGui::GetIO().DeltaTime * 6.0f);
|
||||
if (target < shown) {
|
||||
shown = target;
|
||||
}
|
||||
|
||||
// Texts above the bar: tag, headline, note; the percentage on the right.
|
||||
float y = bar_y - 22.0f * s;
|
||||
const char* note = LoadText("The first start after an update takes longer.",
|
||||
"O primeiro início depois de uma atualização demora mais.",
|
||||
"El primer inicio después de una actualización tarda más.");
|
||||
const float note_fs = 18.0f * s;
|
||||
y -= note_fs;
|
||||
draw->AddText(font, note_fs, ImVec2(left, y), dim, note);
|
||||
const char* headline = total ? LoadText("Loading shaders", "Carregando shaders", "Cargando shaders")
|
||||
: LoadText("Preparing graphics", "Preparando os gráficos",
|
||||
"Preparando los gráficos");
|
||||
const float head_fs = 40.0f * s;
|
||||
y -= head_fs + 8.0f * s;
|
||||
// Three dots that cycle, drawn after the headline.
|
||||
char head_line[96];
|
||||
const int dots = int(t * 2.0f) % 4;
|
||||
std::snprintf(head_line, sizeof(head_line), "%s%.*s", headline, dots, "...");
|
||||
draw->AddText(font, head_fs, ImVec2(left, y), white, head_line);
|
||||
const float tag_fs = 15.0f * s;
|
||||
y -= tag_fs + 10.0f * s;
|
||||
draw->AddRectFilled(ImVec2(left, y + tag_fs * 0.2f), ImVec2(left + 3.0f * s, y + tag_fs * 1.05f), neon);
|
||||
draw->AddText(font, tag_fs, ImVec2(left + 10.0f * s, y), neon,
|
||||
LoadText("INFAMOUS SECOND SON · PC PORT", "INFAMOUS SECOND SON · PORT DE PC",
|
||||
"INFAMOUS SECOND SON · PORT DE PC"));
|
||||
if (total) {
|
||||
char percent[16];
|
||||
std::snprintf(percent, sizeof(percent), "%u%%", u32(shown * 100.0f + 0.5f));
|
||||
const float pct_fs = 40.0f * s;
|
||||
const ImVec2 ts = font->CalcTextSizeA(pct_fs, FLT_MAX, 0.0f, percent);
|
||||
draw->AddText(font, pct_fs, ImVec2(right - ts.x, bar_y - 22.0f * s - note_fs - 8.0f * s - pct_fs),
|
||||
white, percent);
|
||||
}
|
||||
|
||||
// The bar: a faint track, a neon fill with a glow, and a light that sweeps the fill.
|
||||
const float rounding = bar_h * 0.5f;
|
||||
draw->AddRectFilled(ImVec2(left, bar_y), ImVec2(right, bar_y + bar_h), Rgba(255, 255, 255, 0.14f),
|
||||
rounding);
|
||||
if (total) {
|
||||
const float fill_x = left + (right - left) * shown;
|
||||
if (fill_x > left + 1.0f) {
|
||||
draw->AddRectFilled(ImVec2(left, bar_y - 4.0f * s), ImVec2(fill_x, bar_y + bar_h + 4.0f * s),
|
||||
Rgba(255, 64, 160, 0.18f), rounding * 3.0f);
|
||||
draw->AddRectFilledMultiColor(ImVec2(left, bar_y), ImVec2(fill_x, bar_y + bar_h), violet,
|
||||
neon, neon, violet);
|
||||
const float sweep = std::fmod(t * 0.6f, 1.0f);
|
||||
const float sx = left + (fill_x - left) * sweep;
|
||||
const float half = std::min(60.0f * s, (fill_x - left) * 0.5f);
|
||||
draw->AddRectFilledMultiColor(ImVec2(std::max(left, sx - half), bar_y),
|
||||
ImVec2(sx, bar_y + bar_h), Rgba(255, 255, 255, 0.0f),
|
||||
Rgba(255, 255, 255, 0.5f), Rgba(255, 255, 255, 0.5f),
|
||||
Rgba(255, 255, 255, 0.0f));
|
||||
}
|
||||
} else {
|
||||
// No count yet: a short segment that slides back and forth.
|
||||
const float w = (right - left) * 0.18f;
|
||||
const float pos = 0.5f + 0.5f * std::sin(t * 1.8f);
|
||||
const float x = left + (right - left - w) * pos;
|
||||
draw->AddRectFilledMultiColor(ImVec2(x, bar_y), ImVec2(x + w, bar_y + bar_h), violet, neon,
|
||||
neon, violet);
|
||||
}
|
||||
|
||||
// A tip under the bar, changing every few seconds (fades in and out).
|
||||
constexpr int tip_count = int(sizeof(LoadingTips) / sizeof(LoadingTips[0]));
|
||||
const int tip = int(t / LoadingTipSeconds) % tip_count;
|
||||
const float phase = std::fmod(t, LoadingTipSeconds);
|
||||
const float fade = std::clamp(std::min(phase, LoadingTipSeconds - phase) / 0.6f, 0.0f, 1.0f);
|
||||
const LoadingTip& tip_text = LoadingTips[tip];
|
||||
const float tip_fs = 17.0f * s;
|
||||
const float tip_y = bar_y + bar_h + 14.0f * s;
|
||||
const char* label = LoadText("TIP", "DICA", "CONSEJO");
|
||||
draw->AddText(font, tip_fs, ImVec2(left, tip_y), Rgba(255, 64, 160, fade), label);
|
||||
const float label_w = font->CalcTextSizeA(tip_fs, FLT_MAX, 0.0f, label).x + 12.0f * s;
|
||||
draw->AddText(font, tip_fs, ImVec2(left + label_w, tip_y), Rgba(220, 220, 230, 0.85f * fade),
|
||||
LoadText(tip_text.en, tip_text.pt, tip_text.es));
|
||||
}
|
||||
|
||||
void FpsCounter() {
|
||||
@@ -1092,6 +1494,121 @@ void SaveNote() {
|
||||
ImGui::PopStyleVar();
|
||||
}
|
||||
|
||||
// issn 11-04: trophy popups (NoteTrophy). One at a time in a top corner (the one the
|
||||
// performance overlay is not in): the icon from the game's trophy file and the name, or
|
||||
// "Trophy N" without the player's trophy key. A burst (a save that already earned several)
|
||||
// goes faster; the platinum stays longer.
|
||||
struct TrophyPopup {
|
||||
int id = 0;
|
||||
std::string name, grade;
|
||||
IssnPng::Image icon;
|
||||
};
|
||||
std::mutex trophy_mutex;
|
||||
std::deque<TrophyPopup> trophy_queue; ///< waiting; icons are made textures when shown
|
||||
std::atomic<int> trophy_pending{0}; ///< waiting + shown (Visible)
|
||||
TrophyPopup trophy_shown; ///< present thread, imgui_mutex held
|
||||
ImTextureData* trophy_texture = nullptr;
|
||||
std::int64_t trophy_until = 0; ///< steady_clock milliseconds; 0 = none shown
|
||||
std::int64_t trophy_length = 0;
|
||||
constexpr std::int64_t TrophyNoteMs = 5000, TrophyQuickMs = 2000, TrophyPlatinumMs = 8000;
|
||||
|
||||
// imgui_mutex held, before NewFrame: ends the popup shown and takes the next one.
|
||||
void UpdateTrophyNote() {
|
||||
const std::int64_t now = NowMs();
|
||||
if (trophy_until != 0 && now < trophy_until) {
|
||||
return;
|
||||
}
|
||||
if (trophy_until != 0) {
|
||||
trophy_until = 0;
|
||||
if (trophy_texture) {
|
||||
trophy_texture->WantDestroyNextFrame = true;
|
||||
choice_retired.push_back(trophy_texture);
|
||||
choice_retiring = true;
|
||||
trophy_texture = nullptr;
|
||||
}
|
||||
--trophy_pending;
|
||||
}
|
||||
if (loading || menu_open) {
|
||||
return; // the next one waits for the game picture
|
||||
}
|
||||
std::size_t left = 0;
|
||||
{
|
||||
std::scoped_lock lock{trophy_mutex};
|
||||
if (trophy_queue.empty()) {
|
||||
return;
|
||||
}
|
||||
trophy_shown = std::move(trophy_queue.front());
|
||||
trophy_queue.pop_front();
|
||||
left = trophy_queue.size();
|
||||
}
|
||||
if (!trophy_shown.icon.rgba.empty()) {
|
||||
ImTextureData* tex = IM_NEW(ImTextureData)();
|
||||
tex->Create(ImTextureFormat_RGBA32, trophy_shown.icon.width, trophy_shown.icon.height);
|
||||
std::memcpy(tex->GetPixels(), trophy_shown.icon.rgba.data(), trophy_shown.icon.rgba.size());
|
||||
ImGui::RegisterUserTexture(tex);
|
||||
trophy_texture = tex;
|
||||
trophy_shown.icon = {};
|
||||
}
|
||||
trophy_length = trophy_shown.grade == "P" ? TrophyPlatinumMs : left >= 2 ? TrophyQuickMs : TrophyNoteMs;
|
||||
trophy_until = now + trophy_length;
|
||||
}
|
||||
|
||||
ImVec4 TrophyColor(const std::string& grade) {
|
||||
return grade == "P" ? ImVec4(0.78f, 0.9f, 1.0f, 1.0f)
|
||||
: grade == "G" ? ImVec4(1.0f, 0.8f, 0.3f, 1.0f)
|
||||
: grade == "S" ? ImVec4(0.82f, 0.84f, 0.9f, 1.0f)
|
||||
: grade == "B" ? ImVec4(0.86f, 0.56f, 0.36f, 1.0f)
|
||||
: ImVec4(0.45f, 0.9f, 1.0f, 1.0f);
|
||||
}
|
||||
|
||||
void TrophyNote() {
|
||||
const std::int64_t left = trophy_until - NowMs();
|
||||
if (trophy_until == 0 || left <= 0) {
|
||||
return;
|
||||
}
|
||||
const std::int64_t shown = trophy_length - left;
|
||||
const float fade = std::clamp(std::min(float(shown) / 250.0f, float(left) / 500.0f), 0.0f, 1.0f);
|
||||
const bool platinum = trophy_shown.grade == "P";
|
||||
const auto& s = IssnSettings::Get();
|
||||
const bool fps_top_right = s.show_fps && s.overlay_corner == 1;
|
||||
const ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
const float pad = 16.0f * base_scale;
|
||||
const float x = fps_top_right ? viewport->WorkPos.x + pad : viewport->WorkPos.x + viewport->WorkSize.x - pad;
|
||||
ImGui::SetNextWindowPos(ImVec2(x, viewport->WorkPos.y + pad), ImGuiCond_Always,
|
||||
ImVec2(fps_top_right ? 0.0f : 1.0f, 0.0f));
|
||||
ImGui::SetNextWindowBgAlpha(0.8f * fade);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, fade);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f * base_scale);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 1.0f);
|
||||
ImGui::PushStyleColor(ImGuiCol_Border, TrophyColor(trophy_shown.grade));
|
||||
ImGui::Begin("##trophy_note", nullptr,
|
||||
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize |
|
||||
ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav |
|
||||
ImGuiWindowFlags_NoFocusOnAppearing);
|
||||
const float icon = (platinum ? 80.0f : 64.0f) * base_scale;
|
||||
if (trophy_texture) {
|
||||
ImGui::Image(trophy_texture->GetTexRef(), ImVec2(icon, icon));
|
||||
ImGui::SameLine();
|
||||
}
|
||||
ImGui::BeginGroup();
|
||||
ImGui::Dummy(ImVec2(0.0f, platinum ? 6.0f * base_scale : 8.0f * base_scale));
|
||||
ImGui::TextColored(TrophyColor(trophy_shown.grade), "%s",
|
||||
platinum ? LoadText("Platinum! Every trophy unlocked", "Platinado com sucesso!",
|
||||
"¡Platino! Todos los trofeos")
|
||||
: LoadText("Trophy unlocked", "Troféu desbloqueado", "Trofeo desbloqueado"));
|
||||
if (!trophy_shown.name.empty()) {
|
||||
ImGui::TextUnformatted(trophy_shown.name.c_str());
|
||||
} else {
|
||||
const std::string label = std::string(LoadText("Trophy", "Troféu", "Trofeo")) + " " +
|
||||
std::to_string(trophy_shown.id);
|
||||
ImGui::TextUnformatted(label.c_str());
|
||||
}
|
||||
ImGui::EndGroup();
|
||||
ImGui::End();
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopStyleVar(3);
|
||||
}
|
||||
|
||||
void BackgroundLoading() {
|
||||
const u32 done = background_done, total = std::max(background_total.load(), 1u);
|
||||
const ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
@@ -1306,6 +1823,16 @@ bool HandleEvent(const SDL_Event& event) {
|
||||
std::fflush(stdout);
|
||||
return true;
|
||||
}
|
||||
if (down && !event.key.repeat && event.key.key == SDLK_F11) {
|
||||
// issn: F11 hides / shows the performance overlay (players who do not want the numbers
|
||||
// on screen, 11-04); the menu's Overlay tab and the launcher have the same switch.
|
||||
auto& s = IssnSettings::Get();
|
||||
s.show_fps = !s.show_fps.load();
|
||||
IssnSettings::Save();
|
||||
std::printf("Performance overlay: %s (F11)\n", s.show_fps.load() ? "on" : "off");
|
||||
std::fflush(stdout);
|
||||
return true;
|
||||
}
|
||||
if (down && !event.key.repeat && event.key.key == SDLK_F12) {
|
||||
// issn-axy: screenshot of the game picture (user/screenshots, PNG).
|
||||
VideoCore::RequestScreenshot(VideoCore::ScreenshotRequest::GameOnly);
|
||||
@@ -1443,8 +1970,9 @@ void SetLoading(bool active, u32 done, u32 total) {
|
||||
|
||||
bool Visible() {
|
||||
const auto& s = IssnSettings::Get();
|
||||
return initialized && (loading || background_total || menu_open || text_entry_active || choice_active ||
|
||||
choice_retiring || s.show_fps || save_note_until.load() != 0 ||
|
||||
return initialized && (loading || loading_art || background_total || menu_open || text_entry_active ||
|
||||
choice_active || choice_retiring || s.show_fps || save_note_until.load() != 0 ||
|
||||
trophy_pending.load() != 0 ||
|
||||
(s.actor_watch_supported && s.enemy_health_bar));
|
||||
}
|
||||
|
||||
@@ -1489,11 +2017,25 @@ void NoteGameSaved(const std::string& what) {
|
||||
save_note_until = NowMs() + SaveNoteMs;
|
||||
}
|
||||
|
||||
void NoteTrophy(int id, const std::string& name, const std::string& grade, IssnPng::Image icon) {
|
||||
std::printf("Trophy %d unlocked%s%s\n", id, name.empty() ? "" : ": ", name.c_str());
|
||||
std::fflush(stdout);
|
||||
if (!IssnSettings::Get().trophy_note) {
|
||||
return;
|
||||
}
|
||||
{
|
||||
std::scoped_lock lock{trophy_mutex};
|
||||
trophy_queue.push_back({id, name, grade, std::move(icon)});
|
||||
}
|
||||
++trophy_pending;
|
||||
}
|
||||
|
||||
void NoteGameFrame() {
|
||||
game_frames.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void Render(vk::CommandBuffer cmdbuf, vk::ImageView view, vk::Extent2D extent) {
|
||||
IssnExposure::Tick();
|
||||
// Game frame interval for the FPS readout (measured also while nothing is drawn). A present
|
||||
// that repeats the last frame (frame pacing) is not a new frame; before the driver's first
|
||||
// Flip every present counts.
|
||||
@@ -1534,6 +2076,8 @@ void Render(vk::CommandBuffer cmdbuf, vk::ImageView view, vk::Extent2D extent) {
|
||||
base_scale = scale;
|
||||
}
|
||||
|
||||
UpdateLoadingArt();
|
||||
UpdateTrophyNote();
|
||||
UpdateChoiceTextures();
|
||||
ImGui_ImplVulkan_NewFrame();
|
||||
ImGui::NewFrame();
|
||||
@@ -1555,6 +2099,9 @@ void Render(vk::CommandBuffer cmdbuf, vk::ImageView view, vk::Extent2D extent) {
|
||||
if (save_note_until.load() != 0 && !loading && !menu_open) {
|
||||
SaveNote();
|
||||
}
|
||||
if (trophy_until != 0 && !loading && !menu_open) {
|
||||
TrophyNote();
|
||||
}
|
||||
if (text_entry_active) {
|
||||
TextEntryBox();
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ void NoteGameFrame();
|
||||
/// for a few seconds (settings: save_note).
|
||||
void NoteGameSaved(const std::string& what);
|
||||
|
||||
/// issn 11-04: a trophy was unlocked: its icon and name in a corner for a few seconds, one after
|
||||
/// another when several come together (settings: trophy_note). `name` empty: "Trophy N".
|
||||
void NoteTrophy(int id, const std::string& name, const std::string& grade, IssnPng::Image icon);
|
||||
|
||||
/// The menu is open or a text entry is shown: the game's input is held neutral.
|
||||
bool CapturesInput();
|
||||
|
||||
|
||||
@@ -137,16 +137,24 @@ void Set(Values& v, const std::string& key, const std::string& value) {
|
||||
v.reactive_max = Clamp(f, 0.0f, 1.0f);
|
||||
} else if (key == "debug_view") {
|
||||
v.debug_view = std::clamp(i, 0, DebugViewCount - 1);
|
||||
} else if (key == "motion_blur") {
|
||||
v.motion_blur = i != 0;
|
||||
} else if (key == "chromatic_aberration") {
|
||||
v.chromatic_aberration = i != 0;
|
||||
} else if (key == "show_fps") {
|
||||
v.show_fps = i != 0;
|
||||
} else if (key == "save_note") {
|
||||
v.save_note = i != 0;
|
||||
} else if (key == "trophy_note") {
|
||||
v.trophy_note = i != 0;
|
||||
} else if (key == "mouse_enabled") {
|
||||
v.mouse_enabled = i != 0;
|
||||
} else if (key == "mouse_invert_y") {
|
||||
v.mouse_invert_y = i != 0;
|
||||
} else if (key == "mouse_sensitivity") {
|
||||
v.mouse_sensitivity = Clamp(value.empty() ? 1.0f : f, 0.05f, 10.0f);
|
||||
} else if (key == "pad_sensitivity") {
|
||||
v.pad_sensitivity = Clamp(value.empty() ? 1.0f : f, 0.25f, 3.0f);
|
||||
} else if (key == "mouse_smoothing") {
|
||||
v.mouse_smoothing = Clamp(f, 0.0f, 0.95f);
|
||||
} else if (key == "mouse_accel") {
|
||||
@@ -217,6 +225,14 @@ void Set(Values& v, const std::string& key, const std::string& value) {
|
||||
v.hero_powers = i != 0;
|
||||
} else if (key == "hero_karma") {
|
||||
v.hero_karma = i != 0;
|
||||
} else if (key == "hero_neon_run") {
|
||||
v.hero_neon_run = i != 0;
|
||||
} else if (key == "hero_video_flight") {
|
||||
v.hero_video_flight = i != 0;
|
||||
} else if (key == "hero_smoke_dash") {
|
||||
v.hero_smoke_dash = i != 0;
|
||||
} else if (key == "hero_missiles") {
|
||||
v.hero_missiles = i != 0;
|
||||
} else if (key == "fov") {
|
||||
v.fov = IssnView::ClampFov(value.empty() ? 1.0f : f);
|
||||
} else if (key == "menu_anti_aliasing") {
|
||||
@@ -283,7 +299,7 @@ void Load() {
|
||||
{"ISSN_STALE_READBACK", "stale_readback"}, {"ISSN_FSR1", "fsr1"},
|
||||
{"ISSN_RCAS", "rcas"}, {"ISSN_FRAMES_AHEAD", "frames_queued"},
|
||||
{"ISSN_SHOW_FPS", "show_fps"}, {"ISSN_SENSOR_AIM", "sensor_aim"},
|
||||
{"ISSN_MOUSE", "mouse_enabled"}, {"ISSN_MOUSE_SENS", "mouse_sensitivity"},
|
||||
{"ISSN_MOUSE", "mouse_enabled"}, {"ISSN_MOUSE_SENS", "mouse_sensitivity"}, {"ISSN_PAD_SENS", "pad_sensitivity"},
|
||||
{"ISSN_MOUSE_INVERT_Y", "mouse_invert_y"}, {"ISSN_MOUSE_SMOOTHING", "mouse_smoothing"},
|
||||
{"ISSN_MOUSE_ACCEL", "mouse_accel"}, {"ISSN_MOUSE_DEADZONE", "mouse_deadzone"},
|
||||
{"ISSN_LOD_SCALE", "lod_scale"}, {"ISSN_LOD_BANDS", "lod_bands"},
|
||||
@@ -315,7 +331,8 @@ void ReadLaunchChoices() {
|
||||
auto& v = Get();
|
||||
// The launcher passes its choices as these variables (launcher/issn_launcher_win.py
|
||||
// game_environment); the menu starts from them where issn.ini has no menu_* key yet.
|
||||
v.startup_anti_aliasing = Env("SHADPS4_DLSS")[0] == '1' ? 3
|
||||
v.startup_anti_aliasing = Env("ISSN_AA_OFF")[0] == '1' ? 4
|
||||
: Env("SHADPS4_DLSS")[0] == '1' ? 3
|
||||
: Env("ISSN_XESS")[0] == '1' ? 2
|
||||
: Env("ISSN_FSR4")[0] == '1' ? 1
|
||||
: 0;
|
||||
@@ -454,8 +471,15 @@ void Save() {
|
||||
v.far_clip.load(), v.mip_lod_bias.load(), v.lod_bands[0].load(), v.lod_bands[1].load(),
|
||||
v.lod_bands[2].load(), v.lod_bands[3].load(), v.lod_bands[4].load(), v.lod_bands[5].load());
|
||||
std::fprintf(file, "fov=%.3f\nsave_note=%d\n", v.fov.load(), int(v.save_note.load()));
|
||||
std::fprintf(file, "motion_blur=%d\nchromatic_aberration=%d\n", int(v.motion_blur.load()),
|
||||
int(v.chromatic_aberration.load()));
|
||||
std::fprintf(file, "trophy_note=%d\n", int(v.trophy_note.load()));
|
||||
std::fprintf(file, "hero_health=%d\nhero_powers=%d\nhero_karma=%d\n", int(v.hero_health.load()),
|
||||
int(v.hero_powers.load()), int(v.hero_karma.load()));
|
||||
std::fprintf(file, "hero_neon_run=%d\nhero_video_flight=%d\nhero_smoke_dash=%d\n",
|
||||
int(v.hero_neon_run.load()), int(v.hero_video_flight.load()),
|
||||
int(v.hero_smoke_dash.load()));
|
||||
std::fprintf(file, "hero_missiles=%d\n", int(v.hero_missiles.load()));
|
||||
// For the launcher (next start): its own setting names and values.
|
||||
const auto pick = [](const auto& keys, int index) {
|
||||
const int n = int(sizeof(keys) / sizeof(keys[0]));
|
||||
@@ -477,9 +501,10 @@ void Save() {
|
||||
}
|
||||
std::fprintf(file,
|
||||
"mouse_enabled=%d\nmouse_sensitivity=%.2f\nmouse_invert_y=%d\nmouse_smoothing=%.2f\n"
|
||||
"mouse_accel=%.2f\nmouse_deadzone=%.2f\nkey_map=%s\n",
|
||||
"mouse_accel=%.2f\nmouse_deadzone=%.2f\npad_sensitivity=%.2f\nkey_map=%s\n",
|
||||
int(v.mouse_enabled.load()), v.mouse_sensitivity.load(), int(v.mouse_invert_y.load()),
|
||||
v.mouse_smoothing.load(), v.mouse_accel.load(), v.mouse_deadzone.load(), v.key_map.c_str());
|
||||
v.mouse_smoothing.load(), v.mouse_accel.load(), v.mouse_deadzone.load(),
|
||||
v.pad_sensitivity.load(), v.key_map.c_str());
|
||||
// Read by run.sh at start.
|
||||
std::fprintf(file, "live_resolution=%s\n", v.live_resolution < 0 ? "auto"
|
||||
: v.live_resolution ? "1" : "0");
|
||||
|
||||
@@ -51,6 +51,8 @@ struct Values {
|
||||
std::atomic<bool> show_fps{false};
|
||||
/// "Game saved" note in a corner when the game writes a save (issn_overlay.cpp).
|
||||
std::atomic<bool> save_note{true};
|
||||
/// issn 11-04: the trophy popup (icon and name) when the game unlocks a trophy.
|
||||
std::atomic<bool> trophy_note{true};
|
||||
/// Sensor mode's aiming speed (the graffiti cursor), percent of the default: 25..200.
|
||||
std::atomic<int> sensor_aim{100};
|
||||
/// The graffiti guide was shown once; the menu's Controls tab opens it again.
|
||||
@@ -143,6 +145,17 @@ struct Values {
|
||||
/// Delsin's cheats (src/hero_hook.h, issn_hero.cpp), live; hero_supported once hooked.
|
||||
std::atomic<bool> hero_health{false}, hero_powers{false}, hero_karma{false};
|
||||
std::atomic<bool> hero_supported{false};
|
||||
/// issn-axy 11-04 (src/dash_hook.h): neon run and video flight while the dash button is held,
|
||||
/// a long smoke dash; *_supported once the probe's stubs are in.
|
||||
std::atomic<bool> hero_neon_run{false}, hero_video_flight{false}, hero_smoke_dash{false};
|
||||
std::atomic<bool> hero_neon_supported{false}, hero_video_supported{false};
|
||||
/// 11-05: infinite missiles (src/hero_hook.h); which of the probe's checks passed.
|
||||
std::atomic<bool> hero_missiles{false};
|
||||
std::atomic<bool> hero_missiles_supported{false}, hero_karma_supported{false},
|
||||
hero_no_hit_supported{false};
|
||||
/// issn 11-04 (video_core issn_postfx.h): the game's motion blur and chromatic aberration,
|
||||
/// live from the menu and the launcher (issn.ini motion_blur / chromatic_aberration).
|
||||
std::atomic<bool> motion_blur{true}, chromatic_aberration{true};
|
||||
/// Field of view factor (src/fov_hook.h, issn_view.cpp), live; fov_supported once hooked.
|
||||
std::atomic<float> fov{1.0f};
|
||||
std::atomic<bool> fov_supported{false};
|
||||
@@ -159,6 +172,10 @@ struct Values {
|
||||
// the mouse turns the camera (right stick). Live from the menu.
|
||||
std::atomic<bool> mouse_enabled{true}, mouse_invert_y{false};
|
||||
std::atomic<float> mouse_sensitivity{1.0f}, mouse_smoothing{0.5f}, mouse_accel{0.0f}, mouse_deadzone{0.1f};
|
||||
/// issn 11-04: right stick sensitivity (runtime_pad.c), live; 1 = the game's own feel.
|
||||
std::atomic<float> pad_sensitivity{1.0f};
|
||||
/// issn 11-04: the spray can pose for pads without motion sensors (runtime_pad.c), not saved.
|
||||
std::atomic<bool> spray_pose{false};
|
||||
/// Keyboard and mouse button remap (ISSN_KEY_MAP syntax); written by Load only, read once.
|
||||
std::string key_map;
|
||||
};
|
||||
@@ -181,7 +198,7 @@ bool GraphicsNeedRestart();
|
||||
void Save();
|
||||
|
||||
/// The launcher's choices the menu offers (menu_* keys; the values are the launcher's own).
|
||||
inline constexpr const char* AntiAliasingKeys[] = {"game", "fsr4", "xess", "dlss"};
|
||||
inline constexpr const char* AntiAliasingKeys[] = {"game", "fsr4", "xess", "dlss", "off"};
|
||||
inline constexpr const char* UpscaleQualityKeys[] = {"", "1.5", "1.7", "2", "3"};
|
||||
inline constexpr const char* AnisoKeys[] = {"", "4", "8", "16"};
|
||||
inline constexpr const char* DrawDistanceKeys[] = {"0.6", "", "1.5", "2.0"};
|
||||
|
||||
+51
-9
@@ -7,6 +7,7 @@
|
||||
#include "issn_view.h"
|
||||
|
||||
#include "../../src/view_hook.h"
|
||||
#include "../../src/fov_hook.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
@@ -115,22 +116,63 @@ void Apply() {
|
||||
|
||||
} // namespace
|
||||
|
||||
// Field of view (src/fov_hook.h): the float the camera's four sites read, in a read-write page
|
||||
// next to the image; 0.5 x the menu's factor. A thread of its own (the draw distance one exists
|
||||
// only when that hook is in place).
|
||||
// Field of view (src/fov_hook.h): the cave the camera's four sites and the angle readers read, in
|
||||
// a read-write page next to the image: 0.5 x the menu's factor, the factor, and the camera SetFov
|
||||
// last saw. A thread of its own (the draw distance one exists only when that hook is in place).
|
||||
namespace {
|
||||
float* fov_value = nullptr;
|
||||
IssnFovCave* fov_cave = nullptr;
|
||||
|
||||
// The camera works its tan out only when its angle (+0x590) differs from the target (+0x5d4):
|
||||
// a nudge to the angle makes its next update do it again with the new factor.
|
||||
float ReadFloat(const unsigned char* at) {
|
||||
float v;
|
||||
std::memcpy(&v, at, 4);
|
||||
return v;
|
||||
}
|
||||
|
||||
// A slot holds a camera when its state agrees with itself: angle and target in range, an aspect,
|
||||
// and a tan its update made from the angle with one of the menu's factors (0.9x to 1.4x; a camera
|
||||
// that missed earlier nudges still has an older one).
|
||||
bool LooksLikeCamera(const unsigned char* camera) {
|
||||
const float angle = ReadFloat(camera + 0x590), target = ReadFloat(camera + 0x5d4);
|
||||
const float aspect = ReadFloat(camera + 0x59c), tan_half = ReadFloat(camera + 0x5a4);
|
||||
if (!(angle > 0.05f && angle < 3.5f && target > 0.05f && target < 3.5f && aspect > 0.3f && aspect < 5.0f)) {
|
||||
return false;
|
||||
}
|
||||
return tan_half > std::tan(0.5f * 0.89f * angle) - 1e-3f && tan_half < std::tan(0.5f * 1.41f * angle) + 1e-3f;
|
||||
}
|
||||
|
||||
void NudgeCameras(float factor) {
|
||||
int nudged = 0, seen = 0;
|
||||
for (const std::uint64_t at : fov_cave->camera) {
|
||||
if (at < (1ull << 32) || at >= (1ull << 47)) {
|
||||
continue;
|
||||
}
|
||||
++seen;
|
||||
auto* camera = reinterpret_cast<unsigned char*>(at);
|
||||
if (!LooksLikeCamera(camera)) {
|
||||
continue;
|
||||
}
|
||||
const float nudged_angle = ReadFloat(camera + 0x5d4) * 1.001f;
|
||||
std::memcpy(camera + 0x590, &nudged_angle, 4);
|
||||
++nudged;
|
||||
}
|
||||
std::printf("Field of view: x%.2f, %d of %d cameras seen asked to work their view out again\n", factor,
|
||||
nudged, seen);
|
||||
}
|
||||
|
||||
void ApplyFov() {
|
||||
const float value = 0.5f * IssnView::ClampFov(IssnSettings::Get().fov);
|
||||
if (*fov_value != value) {
|
||||
*fov_value = value;
|
||||
const float factor = IssnView::ClampFov(IssnSettings::Get().fov);
|
||||
if (fov_cave->factor != factor) {
|
||||
fov_cave->factor = factor;
|
||||
fov_cave->half_factor = 0.5f * factor;
|
||||
NudgeCameras(factor);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
extern "C" void issn_fov_attach(float* value) {
|
||||
fov_value = value;
|
||||
extern "C" void issn_fov_attach(IssnFovCave* cave) {
|
||||
fov_cave = cave;
|
||||
ApplyFov(); // before the game's first frame
|
||||
IssnSettings::Get().fov_supported = true;
|
||||
std::printf("Field of view: menu control ready (x%.2f)\n", IssnSettings::Get().fov.load());
|
||||
|
||||
@@ -677,10 +677,40 @@ extern "C" const char* issngpu_saved_key_map(void) {
|
||||
return copy.c_str();
|
||||
}
|
||||
|
||||
extern "C" float issngpu_mouse_sensitivity(void) {
|
||||
return IssnSettings::Get().mouse_sensitivity.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
extern "C" void issngpu_set_mouse_sensitivity(float value) {
|
||||
IssnSettings::Get().mouse_sensitivity = std::clamp(value, 0.05f, 10.0f);
|
||||
IssnSettings::Save();
|
||||
}
|
||||
|
||||
extern "C" int issngpu_spray_pose(void) {
|
||||
return IssnSettings::Get().spray_pose.load(std::memory_order_relaxed) ? 1 : 0;
|
||||
}
|
||||
|
||||
extern "C" void issngpu_set_spray_pose(int on) {
|
||||
IssnSettings::Get().spray_pose = on != 0;
|
||||
}
|
||||
|
||||
extern "C" float issngpu_pad_sensitivity(void) {
|
||||
return IssnSettings::Get().pad_sensitivity.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
extern "C" void issngpu_note_game_saved(const char* what) {
|
||||
IssnOverlay::NoteGameSaved(what ? what : "");
|
||||
}
|
||||
|
||||
extern "C" void issngpu_note_trophy(int id, const char* name, const char* grade, const void* png, uint64_t size) {
|
||||
// The icon is decoded here, on the game's thread; the overlay makes it a texture.
|
||||
IssnPng::Image icon;
|
||||
if (png && size && !IssnPng::DecodeRgba(static_cast<const unsigned char*>(png), size_t(size), icon)) {
|
||||
LOG_WARNING(Lib_NpTrophy, "trophy {}: icon not decoded", id);
|
||||
}
|
||||
IssnOverlay::NoteTrophy(id, name ? name : "", grade ? grade : "", std::move(icon));
|
||||
}
|
||||
|
||||
extern "C" int issngpu_overlay_captures_input(void) {
|
||||
return IssnOverlay::CapturesInput() ? 1 : 0;
|
||||
}
|
||||
|
||||
+841
-1
@@ -6,11 +6,23 @@ Only the files needed are read out of each release zip: the zip's directory and
|
||||
fetched with HTTP range requests, so XeSS costs ~60 MB of the 73 MB zip and DLSS ~33 MB
|
||||
of 263 MB. Each file is checked against the SHA-256 of that release before it is kept.
|
||||
|
||||
No Tk here (tests/test_launcher_downloads.py).
|
||||
OptiScaler (optiscaler/OptiScaler, GPL-3.0, a separate program the player may add): the newest
|
||||
stable release's .7z is downloaded whole, read with the 7z reader below (standard library only:
|
||||
LZMA, LZMA2 and 7-Zip's BCJ2 filter, what its releases use) and put into bin\\ beside
|
||||
issn-probe.exe, its OptiScaler.dll as winmm.dll. optiscaler.json there lists what was put, for the
|
||||
Remove button.
|
||||
|
||||
No Tk here (tests/test_launcher_downloads.py, tests/test_launcher_optiscaler.py).
|
||||
"""
|
||||
import binascii
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import lzma
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
@@ -163,3 +175,831 @@ def fetch(package, folder, progress=None, opener=None):
|
||||
raise OSError(f'{name}: the download does not match the {package.name} release')
|
||||
os.replace(part, target)
|
||||
return remote.fetched
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# A .7z reader (no encryption, no multi-volume): LZMA, LZMA2, BCJ2 and stored data, which is what
|
||||
# OptiScaler's releases use (v0.9.4: the text files in an LZMA2 block, the DLLs in an
|
||||
# LZMA2 + LZMA + BCJ2 block). Python has no 7z module and the launcher adds no packages.
|
||||
|
||||
SIGNATURE = b'7z\xbc\xaf\x27\x1c'
|
||||
COPY, LZMA1, LZMA2, BCJ2 = b'\x00', b'\x03\x01\x01', b'\x21', b'\x03\x03\x01\x1b'
|
||||
CHUNK = 8 << 20
|
||||
|
||||
|
||||
class Bytes:
|
||||
def __init__(self, data):
|
||||
self.data, self.pos = data, 0
|
||||
|
||||
def byte(self):
|
||||
if self.pos >= len(self.data):
|
||||
raise OSError('7z: the header ends too soon')
|
||||
self.pos += 1
|
||||
return self.data[self.pos - 1]
|
||||
|
||||
def take(self, n):
|
||||
if self.pos + n > len(self.data):
|
||||
raise OSError('7z: the header ends too soon')
|
||||
self.pos += n
|
||||
return self.data[self.pos - n:self.pos]
|
||||
|
||||
def number(self):
|
||||
first, mask, value = self.byte(), 0x80, 0
|
||||
for i in range(8):
|
||||
if not first & mask:
|
||||
return value | ((first & (mask - 1)) << (8 * i))
|
||||
value |= self.byte() << (8 * i)
|
||||
mask >>= 1
|
||||
return value
|
||||
|
||||
def bits(self, count):
|
||||
out, byte, mask = [], 0, 0
|
||||
for _ in range(count):
|
||||
if not mask:
|
||||
byte, mask = self.byte(), 0x80
|
||||
out.append(bool(byte & mask))
|
||||
mask >>= 1
|
||||
return out
|
||||
|
||||
def defined(self, count):
|
||||
return [True] * count if self.byte() else self.bits(count)
|
||||
|
||||
|
||||
class Folder:
|
||||
def __init__(self):
|
||||
self.coders = [] # (method id, in streams, out streams, properties)
|
||||
self.bonds = [] # (in index, out index)
|
||||
self.packed = [] # in indices fed by packed streams, in pack order
|
||||
self.sizes = [] # unpacked size of every out stream
|
||||
self.crc = None
|
||||
|
||||
|
||||
def _folder(r):
|
||||
folder = Folder()
|
||||
for _ in range(r.number()):
|
||||
flag = r.byte()
|
||||
method = r.take(flag & 0x0f)
|
||||
ins, outs = (r.number(), r.number()) if flag & 0x10 else (1, 1)
|
||||
props = r.take(r.number()) if flag & 0x20 else b''
|
||||
if flag & 0x80:
|
||||
raise OSError('7z: alternative methods are not supported')
|
||||
folder.coders.append((method, ins, outs, props))
|
||||
total_in = sum(c[1] for c in folder.coders)
|
||||
total_out = sum(c[2] for c in folder.coders)
|
||||
folder.bonds = [(r.number(), r.number()) for _ in range(total_out - 1)]
|
||||
count = total_in - len(folder.bonds)
|
||||
if count == 1:
|
||||
bound = {i for i, _o in folder.bonds}
|
||||
folder.packed = [i for i in range(total_in) if i not in bound]
|
||||
else:
|
||||
folder.packed = [r.number() for _ in range(count)]
|
||||
return folder
|
||||
|
||||
|
||||
def _streams(r):
|
||||
"""StreamsInfo -> (pack position, pack sizes, folders, per-folder substream sizes and CRCs)."""
|
||||
pack_pos, pack_sizes, folders = 0, [], []
|
||||
sub_counts, sub_sizes, sub_crcs = None, None, None
|
||||
while True:
|
||||
kind = r.byte()
|
||||
if kind == 0x00:
|
||||
break
|
||||
if kind == 0x06: # PackInfo
|
||||
pack_pos = r.number()
|
||||
count = r.number()
|
||||
while (k := r.byte()) != 0x00:
|
||||
if k == 0x09:
|
||||
pack_sizes = [r.number() for _ in range(count)]
|
||||
elif k == 0x0a:
|
||||
defined = r.defined(count)
|
||||
[r.take(4) for d in defined if d]
|
||||
else:
|
||||
raise OSError(f'7z: unknown pack property {k}')
|
||||
elif kind == 0x07: # UnpackInfo
|
||||
if r.byte() != 0x0b:
|
||||
raise OSError('7z: folders expected')
|
||||
count = r.number()
|
||||
if r.byte():
|
||||
raise OSError('7z: external folders are not supported')
|
||||
folders = [_folder(r) for _ in range(count)]
|
||||
if r.byte() != 0x0c:
|
||||
raise OSError('7z: unpack sizes expected')
|
||||
for folder in folders:
|
||||
folder.sizes = [r.number() for _ in range(sum(c[2] for c in folder.coders))]
|
||||
while (k := r.byte()) != 0x00:
|
||||
if k == 0x0a:
|
||||
defined = r.defined(len(folders))
|
||||
for folder, d in zip(folders, defined):
|
||||
if d:
|
||||
folder.crc = struct.unpack('<I', r.take(4))[0]
|
||||
else:
|
||||
raise OSError(f'7z: unknown unpack property {k}')
|
||||
elif kind == 0x08: # SubStreamsInfo
|
||||
sub_counts = [1] * len(folders)
|
||||
while (k := r.byte()) != 0x00:
|
||||
if k == 0x0d:
|
||||
sub_counts = [r.number() for _ in folders]
|
||||
elif k == 0x09:
|
||||
sub_sizes = []
|
||||
for folder, n in zip(folders, sub_counts):
|
||||
if not n:
|
||||
sub_sizes.append([])
|
||||
continue
|
||||
sizes = [r.number() for _ in range(n - 1)]
|
||||
sizes.append(_final_size(folder) - sum(sizes))
|
||||
sub_sizes.append(sizes)
|
||||
elif k == 0x0a:
|
||||
unknown = sum(n for f, n in zip(folders, sub_counts) if not (n == 1 and f.crc is not None))
|
||||
defined = iter(r.defined(unknown))
|
||||
sub_crcs = []
|
||||
for folder, n in zip(folders, sub_counts):
|
||||
if n == 1 and folder.crc is not None:
|
||||
sub_crcs.append([folder.crc])
|
||||
else:
|
||||
sub_crcs.append([struct.unpack('<I', r.take(4))[0] if next(defined) else None
|
||||
for _ in range(n)])
|
||||
else:
|
||||
raise OSError(f'7z: unknown substream property {k}')
|
||||
else:
|
||||
raise OSError(f'7z: unknown streams property {kind}')
|
||||
if sub_counts is None:
|
||||
sub_counts = [1] * len(folders)
|
||||
if sub_sizes is None:
|
||||
sub_sizes = [[_final_size(f)] if n else [] for f, n in zip(folders, sub_counts)]
|
||||
if sub_crcs is None:
|
||||
sub_crcs = [[f.crc] if n == 1 else [None] * n for f, n in zip(folders, sub_counts)]
|
||||
return pack_pos, pack_sizes, folders, sub_sizes, sub_crcs
|
||||
|
||||
|
||||
def _final_size(folder):
|
||||
bound = {o for _i, o in folder.bonds}
|
||||
outs = [i for i in range(len(folder.sizes)) if i not in bound]
|
||||
return folder.sizes[outs[0]]
|
||||
|
||||
|
||||
def _files(r):
|
||||
count = r.number()
|
||||
empty_stream, empty_file, names = [False] * count, [], [''] * count
|
||||
while (kind := r.byte()) != 0x00:
|
||||
data = Bytes(r.take(r.number()))
|
||||
if kind == 0x0e:
|
||||
empty_stream = data.bits(count)
|
||||
elif kind == 0x0f:
|
||||
empty_file = data.bits(sum(empty_stream))
|
||||
elif kind == 0x11:
|
||||
if data.byte():
|
||||
raise OSError('7z: external names are not supported')
|
||||
names = data.data[1:].decode('utf-16-le').split('\0')[:count]
|
||||
empty_file = iter(empty_file + [False] * count)
|
||||
return [(name, empty, empty and not next(empty_file))
|
||||
for name, empty in zip(names, empty_stream)] # (name, no data, is a folder)
|
||||
|
||||
|
||||
class Entry:
|
||||
def __init__(self, name, size, crc, is_dir, folder):
|
||||
self.name, self.size, self.crc, self.is_dir, self.folder = name.replace('\\', '/'), size, crc, is_dir, folder
|
||||
|
||||
|
||||
def _lzma_filters(method, props, size):
|
||||
limit = max(4096, min(size, 1 << 30))
|
||||
if method == LZMA2:
|
||||
p = props[0] if props else 0
|
||||
dictionary = 0xFFFFFFFF if p >= 40 else (2 | (p & 1)) << (p // 2 + 11)
|
||||
return [{'id': lzma.FILTER_LZMA2, 'dict_size': max(4096, min(dictionary, limit))}]
|
||||
d = props[0]
|
||||
dictionary = struct.unpack('<I', props[1:5])[0]
|
||||
return [{'id': lzma.FILTER_LZMA1, 'lc': d % 9, 'lp': d // 9 % 5, 'pb': d // 45,
|
||||
'dict_size': max(4096, min(dictionary, limit))}]
|
||||
|
||||
|
||||
def _lzma(source, filters, size):
|
||||
decoder = lzma.LZMADecompressor(lzma.FORMAT_RAW, filters=filters)
|
||||
left = size
|
||||
|
||||
def take(out):
|
||||
nonlocal left
|
||||
out = out[:left]
|
||||
left -= len(out)
|
||||
return out
|
||||
for data in source:
|
||||
out = decoder.decompress(data, CHUNK)
|
||||
if out:
|
||||
yield take(out)
|
||||
while left and not decoder.needs_input and not decoder.eof:
|
||||
out = decoder.decompress(b'', CHUNK)
|
||||
if not out:
|
||||
break
|
||||
yield take(out)
|
||||
if not left:
|
||||
return
|
||||
while left and not decoder.eof:
|
||||
out = decoder.decompress(b'', CHUNK)
|
||||
if not out:
|
||||
break
|
||||
yield take(out)
|
||||
if left:
|
||||
raise OSError('7z: a stream ends too soon')
|
||||
|
||||
|
||||
_JUMP = re.compile(rb'[\xe8\xe9]|\x0f[\x80-\x8f]')
|
||||
|
||||
|
||||
def _bcj2(main, call, jump, rc, size):
|
||||
"""7-Zip's BCJ2 x86 branch converter (the 0303011B method), decoding."""
|
||||
if len(rc) < 5:
|
||||
raise OSError('7z: BCJ2 range coder stream too short')
|
||||
code, rng, rpos = int.from_bytes(rc[1:5], 'big'), 0xFFFFFFFF, 5
|
||||
probs = [1024] * 258
|
||||
cpos = jpos = 0
|
||||
done, prev = 0, 0
|
||||
search = _JUMP.search
|
||||
for buf in main:
|
||||
n, pos, pieces = len(buf), 0, []
|
||||
while pos < n:
|
||||
b = buf[pos]
|
||||
if (b & 0xFE) == 0xE8 or (prev == 0x0F and (b & 0xF0) == 0x80):
|
||||
j, before = pos, prev
|
||||
else:
|
||||
m = search(buf, pos)
|
||||
if m is None:
|
||||
pieces.append(buf[pos:])
|
||||
done += n - pos
|
||||
prev = buf[n - 1]
|
||||
break
|
||||
j = m.start()
|
||||
if buf[j] == 0x0F:
|
||||
j += 1
|
||||
before = buf[j - 1]
|
||||
b = buf[j]
|
||||
pieces.append(buf[pos:j + 1])
|
||||
done += j + 1 - pos
|
||||
pos = j + 1
|
||||
if done >= size:
|
||||
break
|
||||
index = before if b == 0xE8 else 256 if b == 0xE9 else 257
|
||||
ttt = probs[index]
|
||||
bound = (rng >> 11) * ttt
|
||||
if code < bound:
|
||||
rng = bound
|
||||
probs[index] = ttt + ((2048 - ttt) >> 5)
|
||||
prev = b
|
||||
else:
|
||||
rng -= bound
|
||||
code -= bound
|
||||
probs[index] = ttt - (ttt >> 5)
|
||||
if b == 0xE8:
|
||||
src = call[cpos:cpos + 4]
|
||||
cpos += 4
|
||||
else:
|
||||
src = jump[jpos:jpos + 4]
|
||||
jpos += 4
|
||||
if len(src) != 4:
|
||||
raise OSError('7z: BCJ2 call stream too short')
|
||||
dest = (int.from_bytes(src, 'big') - done - 4) & 0xFFFFFFFF
|
||||
pieces.append(dest.to_bytes(4, 'little'))
|
||||
done += 4
|
||||
prev = dest >> 24
|
||||
if rng < 0x1000000:
|
||||
if rpos >= len(rc):
|
||||
raise OSError('7z: BCJ2 range coder stream too short')
|
||||
rng = (rng << 8) & 0xFFFFFFFF
|
||||
code = ((code << 8) | rc[rpos]) & 0xFFFFFFFF
|
||||
rpos += 1
|
||||
if pieces:
|
||||
yield b''.join(pieces)
|
||||
if done >= size:
|
||||
return
|
||||
if done < size:
|
||||
raise OSError('7z: BCJ2 main stream too short')
|
||||
|
||||
|
||||
class SevenZip:
|
||||
"""The files of a .7z archive (no encryption, no multi-volume): entries, then extract()."""
|
||||
|
||||
def __init__(self, path):
|
||||
self.file = open(path, 'rb')
|
||||
try:
|
||||
self._read_header()
|
||||
except (struct.error, UnicodeDecodeError, IndexError, lzma.LZMAError) as error:
|
||||
self.file.close()
|
||||
raise OSError(f'7z: unreadable archive ({error})') from error
|
||||
except BaseException:
|
||||
self.file.close()
|
||||
raise
|
||||
|
||||
def close(self):
|
||||
self.file.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
return False
|
||||
|
||||
def _read_header(self):
|
||||
start = self.file.read(32)
|
||||
if len(start) != 32 or start[:6] != SIGNATURE:
|
||||
raise OSError('not a 7z archive')
|
||||
if binascii.crc32(start[12:32]) != struct.unpack('<I', start[8:12])[0]:
|
||||
raise OSError('7z: damaged start header')
|
||||
offset, size, crc = struct.unpack('<QQI', start[12:32])
|
||||
self.file.seek(32 + offset)
|
||||
header = self.file.read(size)
|
||||
if len(header) != size or binascii.crc32(header) != crc:
|
||||
raise OSError('7z: damaged header (incomplete download?)')
|
||||
r = Bytes(header)
|
||||
kind = r.byte()
|
||||
while kind == 0x17: # the header itself is packed
|
||||
pack_pos, pack_sizes, folders, _sizes, _crcs = _streams(r)
|
||||
if not folders:
|
||||
raise OSError('7z: empty packed header')
|
||||
data = b''.join(self._folder_output(folders[0], 32 + pack_pos, pack_sizes))
|
||||
if folders[0].crc is not None and binascii.crc32(data) != folders[0].crc:
|
||||
raise OSError('7z: damaged packed header')
|
||||
r = Bytes(data)
|
||||
kind = r.byte()
|
||||
if kind != 0x01:
|
||||
raise OSError('7z: no header')
|
||||
self.pack_pos, self.pack_sizes, self.folders = 0, [], []
|
||||
sub_sizes, sub_crcs, files = [], [], []
|
||||
while (kind := r.byte()) != 0x00:
|
||||
if kind == 0x02: # archive properties
|
||||
while r.byte():
|
||||
r.take(r.number())
|
||||
elif kind == 0x04:
|
||||
self.pack_pos, self.pack_sizes, self.folders, sub_sizes, sub_crcs = _streams(r)
|
||||
elif kind == 0x05:
|
||||
files = _files(r)
|
||||
else:
|
||||
raise OSError(f'7z: header property {kind} is not supported')
|
||||
streams = [(i, size, crc) for i, (sizes, crcs) in enumerate(zip(sub_sizes, sub_crcs))
|
||||
for size, crc in zip(sizes, crcs)]
|
||||
self.entries, at = [], 0
|
||||
for name, empty, is_dir in files:
|
||||
if empty:
|
||||
self.entries.append(Entry(name, 0, None, is_dir, None))
|
||||
continue
|
||||
if at >= len(streams):
|
||||
raise OSError('7z: more files than data streams')
|
||||
folder, size, crc = streams[at]
|
||||
at += 1
|
||||
self.entries.append(Entry(name, size, crc, False, folder))
|
||||
|
||||
def _packed(self, offset, size):
|
||||
left = size
|
||||
while left:
|
||||
self.file.seek(offset)
|
||||
data = self.file.read(min(left, 1 << 20))
|
||||
if not data:
|
||||
raise OSError('7z: the archive ends too soon (incomplete download?)')
|
||||
offset += len(data)
|
||||
left -= len(data)
|
||||
yield data
|
||||
|
||||
def _folder_output(self, folder, base, pack_sizes, first_pack=0):
|
||||
"""Chunks of the folder's unpacked data. base: where its first packed stream starts."""
|
||||
offsets, at = [], base + sum(pack_sizes[:first_pack])
|
||||
for k in range(len(folder.packed)):
|
||||
offsets.append((at, pack_sizes[first_pack + k]))
|
||||
at += pack_sizes[first_pack + k]
|
||||
packed = dict(zip(folder.packed, offsets))
|
||||
bonds = dict(folder.bonds) # in index -> out index
|
||||
in_first, out_first, out_owner = [], [], {}
|
||||
i = o = 0
|
||||
for index, (_m, ins, outs, _p) in enumerate(folder.coders):
|
||||
in_first.append(i)
|
||||
out_first.append(o)
|
||||
for k in range(outs):
|
||||
out_owner[o + k] = index
|
||||
i += ins
|
||||
o += outs
|
||||
|
||||
def stream_in(index):
|
||||
if index in packed:
|
||||
return self._packed(*packed[index])
|
||||
if index not in bonds:
|
||||
raise OSError('7z: unbound coder input')
|
||||
return stream_out(bonds[index])
|
||||
|
||||
def stream_out(index):
|
||||
coder = out_owner[index]
|
||||
method, ins, outs, props = folder.coders[coder]
|
||||
size = folder.sizes[index]
|
||||
first = in_first[coder]
|
||||
if outs != 1:
|
||||
raise OSError('7z: coders with several outputs are not supported')
|
||||
if method in (LZMA1, LZMA2) and ins == 1:
|
||||
return _lzma(stream_in(first), _lzma_filters(method, props, size), size)
|
||||
if method == COPY and ins == 1:
|
||||
return stream_in(first)
|
||||
if method == BCJ2 and ins == 4:
|
||||
side = [b''.join(stream_in(first + k)) for k in (1, 2, 3)]
|
||||
return _bcj2(stream_in(first), side[0], side[1], side[2], size)
|
||||
raise OSError(f'7z: compression method {method.hex()} is not supported')
|
||||
bound = {o for _i, o in folder.bonds}
|
||||
final = [k for k in range(len(folder.sizes)) if k not in bound]
|
||||
if len(final) != 1:
|
||||
raise OSError('7z: a folder with several outputs')
|
||||
return stream_out(final[0])
|
||||
|
||||
def extract(self, wanted, progress=None):
|
||||
"""wanted(entry) -> a target Path or None. Every file kept is checked against its CRC32
|
||||
and written as <target>.part first. progress(done, total) in unpacked bytes. Raises
|
||||
OSError (a damaged archive too)."""
|
||||
try:
|
||||
self._extract(wanted, progress)
|
||||
except (lzma.LZMAError, struct.error, IndexError, ValueError) as error:
|
||||
raise OSError(f'7z: damaged archive ({error})') from error
|
||||
|
||||
def _extract(self, wanted, progress):
|
||||
targets = {id(e): wanted(e) for e in self.entries if not e.is_dir}
|
||||
folders = sorted({e.folder for e in self.entries if e.folder is not None and targets[id(e)]})
|
||||
total = sum(_final_size(self.folders[k]) for k in folders)
|
||||
done = 0
|
||||
for target in (t for e in self.entries if e.folder is None and not e.is_dir
|
||||
for t in [targets[id(e)]] if t):
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(b'')
|
||||
first_pack = [0]
|
||||
for folder in self.folders:
|
||||
first_pack.append(first_pack[-1] + len(folder.packed))
|
||||
for k in folders:
|
||||
members = [e for e in self.entries if e.folder == k]
|
||||
chunks = self._folder_output(self.folders[k], 32 + self.pack_pos, self.pack_sizes, first_pack[k])
|
||||
buffer, offset = b'', 0
|
||||
for entry in members:
|
||||
target = targets[id(entry)]
|
||||
out = None
|
||||
if target:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
out = open(target.with_name(target.name + '.part'), 'wb')
|
||||
crc, left = 0, entry.size
|
||||
try:
|
||||
while left:
|
||||
if offset >= len(buffer):
|
||||
buffer, offset = next(chunks, None), 0
|
||||
if buffer is None:
|
||||
raise OSError('7z: the data ends too soon')
|
||||
done += len(buffer)
|
||||
if progress:
|
||||
progress(done, total)
|
||||
piece = buffer[offset:offset + left]
|
||||
offset += len(piece)
|
||||
left -= len(piece)
|
||||
if out:
|
||||
out.write(piece)
|
||||
crc = binascii.crc32(piece, crc)
|
||||
except BaseException:
|
||||
if out:
|
||||
out.close()
|
||||
target.with_name(target.name + '.part').unlink(missing_ok=True)
|
||||
raise
|
||||
if out:
|
||||
out.close()
|
||||
part = target.with_name(target.name + '.part')
|
||||
if entry.crc is not None and crc != entry.crc:
|
||||
part.unlink(missing_ok=True)
|
||||
raise OSError(f'{entry.name}: damaged in the archive (CRC)')
|
||||
part.replace(target)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# OptiScaler (github.com/optiscaler/OptiScaler, GPL-3.0): never in the port's zip; the launcher
|
||||
# downloads the project's own release on the player's request and can take it out again.
|
||||
# How it meets the port: issn-probe.exe imports WINMM.dll, so OptiScaler.dll named winmm.dll in
|
||||
# bin\ is loaded by it; OptiScaler then catches the port's libxess.dll and gets the colour, depth,
|
||||
# motion vectors and jitter of the port's XeSS (anti-aliasing XeSS), on any card.
|
||||
|
||||
OPTISCALER_PAGE = 'https://github.com/optiscaler/OptiScaler'
|
||||
OPTISCALER_LICENSE = 'https://github.com/optiscaler/OptiScaler/blob/master/LICENSE'
|
||||
OPTISCALER_API = 'https://api.github.com/repos/optiscaler/OptiScaler/releases/latest'
|
||||
OPTISCALER_DOWNLOADS = 'https://github.com/optiscaler/OptiScaler/releases/download/'
|
||||
MANIFEST = 'optiscaler.json'
|
||||
MENU_KEY = '0x77' # F8: Insert opens the port's menu and Home switches its anti-aliasing
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Release:
|
||||
tag: str
|
||||
url: str
|
||||
sha256: str # '' when GitHub gave none
|
||||
size: int
|
||||
|
||||
|
||||
# Used when GitHub's API cannot be reached (rate limit, proxy): v0.9.4, the one João tested on
|
||||
# 2026-11-02/04 (the same OptiScaler.dll, SHA-256 fbfb6676…).
|
||||
OPTISCALER_KNOWN = Release(
|
||||
'v0.9.4', OPTISCALER_DOWNLOADS + 'v0.9.4/Optiscaler_0.9.4-final.20260718._MM.7z',
|
||||
'575cb4df866116093df75af607e37fd70e10f5163e0f23fd5c804142e80ef0ad', 55016448)
|
||||
|
||||
# What the anti-aliasing chooser offers -> the VulkanUpscaler values, newest name first (0.9.4
|
||||
# writes fsr31 / fsr31_12; the master branch's README calls them ffx / ffx_12).
|
||||
OPTISCALER_OUTPUTS = {'fsr4': ('fsr31_12', 'ffx_12'), 'fsr31': ('fsr31', 'ffx'), 'xess': ('xess',),
|
||||
'dlss': ('dlss',), 'fsr22': ('fsr22',)}
|
||||
# Release files not put into bin\ (instructions for other games, setup scripts).
|
||||
SKIPPED = ('!! README_EXTRACT ALL FILES TO GAME FOLDER !!.txt', 'setup_linux.sh', 'setup_windows.bat')
|
||||
# The files of a release unpacked by hand (no optiscaler.json): what Remove takes away then. The
|
||||
# names of v0.9.4, plus what OptiScaler writes itself; nothing of the port is called like this.
|
||||
HAND_FILES = ('winmm.dll', 'OptiScaler.ini', 'OptiScaler.log', 'amd_fidelityfx_dx12.dll',
|
||||
'amd_fidelityfx_framegeneration_dx12.dll', 'amd_fidelityfx_upscaler_dx12.dll',
|
||||
'amd_fidelityfx_vk.dll', 'D3D12_Optiscaler/D3D12Core.dll', 'dlssg_to_fsr3_amd_is_better.dll',
|
||||
'dlssg_to_fsr3.log', 'fakenvapi.dll', 'fakenvapi.ini', 'fakenvapi.log', 'libxell.dll',
|
||||
'libxess.dll', 'libxess_dx11.dll', 'libxess_fg.dll', 'Licenses/DirectX_LICENSE.txt',
|
||||
'Licenses/FidelityFX_v1_LICENSE.md', 'Licenses/FidelityFX_v2_LICENSE.md',
|
||||
'Licenses/XeSS_LICENSE.txt') + SKIPPED
|
||||
# Written next to what was installed, and taken away by Remove whatever the manifest says.
|
||||
WRITTEN = ('OptiScaler.ini', 'OptiScaler.log', 'dlssg_to_fsr3.log', 'fakenvapi.log', MANIFEST)
|
||||
|
||||
|
||||
def optiscaler_release(opener=None):
|
||||
"""The newest stable release (GitHub's 'latest': no pre-releases, no nightlies) and its .7z.
|
||||
Raises OSError or ValueError."""
|
||||
request = urllib.request.Request(OPTISCALER_API, headers={'Accept': 'application/vnd.github+json',
|
||||
'User-Agent': USER_AGENT})
|
||||
with (opener or urllib.request.urlopen)(request, timeout=20) as response:
|
||||
release = json.load(response)
|
||||
if release.get('prerelease') or release.get('draft'):
|
||||
raise ValueError('the latest OptiScaler release is a pre-release')
|
||||
tag = str(release.get('tag_name', ''))
|
||||
for asset in release.get('assets', []):
|
||||
name, url = str(asset.get('name', '')), str(asset.get('browser_download_url', ''))
|
||||
if name.lower().startswith('optiscaler') and name.lower().endswith('.7z') \
|
||||
and url.startswith(OPTISCALER_DOWNLOADS):
|
||||
digest = str(asset.get('digest') or '')
|
||||
sha = digest[7:].lower() if digest.startswith('sha256:') else ''
|
||||
if tag == OPTISCALER_KNOWN.tag and not sha:
|
||||
sha = OPTISCALER_KNOWN.sha256
|
||||
return Release(tag, url, sha, int(asset.get('size') or 0))
|
||||
raise ValueError(f'OptiScaler {tag}: no .7z in the release')
|
||||
|
||||
|
||||
def download(release, path, progress=None, opener=None):
|
||||
"""The release's file into `path` (through path.part), checked against its SHA-256."""
|
||||
if not release.sha256:
|
||||
raise OSError(f'OptiScaler {release.tag}: GitHub gave no checksum for the file, not installed')
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
part = path.with_name(path.name + '.part')
|
||||
request = urllib.request.Request(release.url, headers={'User-Agent': USER_AGENT})
|
||||
digest, done = hashlib.sha256(), 0
|
||||
try:
|
||||
with (opener or urllib.request.urlopen)(request, timeout=60) as response, open(part, 'wb') as out:
|
||||
total = int(response.headers.get('Content-Length') or release.size or 0)
|
||||
while chunk := response.read(1 << 20):
|
||||
out.write(chunk)
|
||||
digest.update(chunk)
|
||||
done += len(chunk)
|
||||
if progress:
|
||||
progress(done, total)
|
||||
if digest.hexdigest() != release.sha256:
|
||||
raise OSError(f'OptiScaler {release.tag}: the download does not match the release (checksum)')
|
||||
os.replace(part, path)
|
||||
finally:
|
||||
part.unlink(missing_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def ini_set(text, section, key, value):
|
||||
"""The INI text with `key` of [section] set to `value`, comments and order kept (OptiScaler
|
||||
writes 'Key=value' and, from its menu, 'Key = value'). A missing key or section is added."""
|
||||
newline = '\r\n' if '\r\n' in text else '\n'
|
||||
lines = text.split(newline)
|
||||
current, section_end, found = None, None, False
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('[') and stripped.endswith(']'):
|
||||
if current == section.lower() and section_end is None:
|
||||
section_end = i
|
||||
current = stripped[1:-1].strip().lower()
|
||||
continue
|
||||
if current == section.lower() and not stripped.startswith(';') and '=' in stripped:
|
||||
name = stripped.split('=', 1)[0].strip()
|
||||
if name.lower() == key.lower():
|
||||
spaced = ' = ' if re.match(r'\s*[^=]*\s=\s', line) else '='
|
||||
lines[i] = f'{name}{spaced}{value}'
|
||||
found = True
|
||||
if not found:
|
||||
if current == section.lower() and section_end is None:
|
||||
section_end = len(lines)
|
||||
while section_end and not lines[section_end - 1].strip():
|
||||
section_end -= 1
|
||||
if section_end is None:
|
||||
lines += ['', f'[{section}]', f'{key}={value}']
|
||||
else:
|
||||
lines.insert(section_end, f'{key}={value}')
|
||||
return newline.join(lines)
|
||||
|
||||
|
||||
def ini_get(text, section, key):
|
||||
current = None
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('[') and stripped.endswith(']'):
|
||||
current = stripped[1:-1].strip().lower()
|
||||
elif current == section.lower() and not stripped.startswith(';') and '=' in stripped:
|
||||
name, value = stripped.split('=', 1)
|
||||
if name.strip().lower() == key.lower():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def vulkan_upscaler_value(text, output):
|
||||
"""The VulkanUpscaler value for one of OPTISCALER_OUTPUTS, by the names this release's own
|
||||
OptiScaler.ini lists in the comment right above the key."""
|
||||
names = OPTISCALER_OUTPUTS.get(output, OPTISCALER_OUTPUTS['fsr22'])
|
||||
current, comment = None, []
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('[') and stripped.endswith(']'):
|
||||
current, comment = stripped[1:-1].strip().lower(), []
|
||||
elif current != 'upscalers':
|
||||
continue
|
||||
elif stripped.startswith(';'):
|
||||
comment.append(stripped.lower())
|
||||
elif stripped.split('=', 1)[0].strip().lower() == 'vulkanupscaler':
|
||||
listed = set(re.findall(r'\b([a-z][a-z0-9_]*)\s*\(', ' '.join(comment)))
|
||||
return next((name for name in names if name in listed), names[0])
|
||||
else:
|
||||
comment = []
|
||||
return names[0]
|
||||
|
||||
|
||||
def configure(text, output):
|
||||
"""The port's settings in an OptiScaler.ini: the upscaler for Vulkan and the menu on F8."""
|
||||
text = ini_set(text, 'Upscalers', 'VulkanUpscaler', vulkan_upscaler_value(text, output))
|
||||
return ini_set(text, 'Menu', 'ShortcutKey', MENU_KEY)
|
||||
|
||||
|
||||
def _read_text(path):
|
||||
return Path(path).read_bytes().decode('utf-8', errors='surrogateescape')
|
||||
|
||||
|
||||
def _write_text(path, text):
|
||||
part = Path(path).with_name(Path(path).name + '.part')
|
||||
part.write_bytes(text.encode('utf-8', errors='surrogateescape'))
|
||||
os.replace(part, path)
|
||||
|
||||
|
||||
def _installed_name(member):
|
||||
"""Where a file of the release goes in bin\\ (None: not installed)."""
|
||||
name = member.replace('\\', '/').strip('/')
|
||||
parts = name.split('/')
|
||||
if not name or name.startswith('/') or ':' in name or any(p in ('', '.', '..') for p in parts):
|
||||
raise OSError(f'OptiScaler: unsafe name in the archive: {member!r}')
|
||||
if name in SKIPPED:
|
||||
return None
|
||||
if name.lower() == 'optiscaler.dll':
|
||||
return 'winmm.dll'
|
||||
if parts[0].lower() == 'licenses':
|
||||
return '/'.join(['optiscaler-licenses'] + parts[1:])
|
||||
return name
|
||||
|
||||
|
||||
def is_optiscaler(dll):
|
||||
try:
|
||||
with open(dll, 'rb') as file:
|
||||
return b'OptiScaler' in file.read()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _dll_version(dll):
|
||||
try:
|
||||
match = re.search(rb'OptiScaler v(\d+\.\d+[0-9A-Za-z.\-]*)', Path(dll).read_bytes())
|
||||
except OSError:
|
||||
return ''
|
||||
return 'v' + match.group(1).decode() if match else ''
|
||||
|
||||
|
||||
def optiscaler_state(bin_dir):
|
||||
"""None, or {'version', 'by_hand' (no optiscaler.json), 'upscaler' (VulkanUpscaler),
|
||||
'menu_key' (ShortcutKey), 'foreign' (a winmm.dll that is not OptiScaler)}."""
|
||||
bin_dir = Path(bin_dir)
|
||||
dll = bin_dir / 'winmm.dll'
|
||||
manifest = _manifest(bin_dir)
|
||||
if not dll.is_file():
|
||||
return None
|
||||
if manifest is None and not is_optiscaler(dll):
|
||||
return {'version': '', 'by_hand': True, 'upscaler': None, 'menu_key': None, 'foreign': True}
|
||||
try:
|
||||
text = _read_text(bin_dir / 'OptiScaler.ini')
|
||||
except OSError:
|
||||
text = ''
|
||||
version = (manifest or {}).get('version') or _dll_version(dll)
|
||||
return {'version': version, 'by_hand': manifest is None, 'upscaler': ini_get(text, 'Upscalers', 'VulkanUpscaler'),
|
||||
'menu_key': ini_get(text, 'Menu', 'ShortcutKey'), 'foreign': False}
|
||||
|
||||
|
||||
def _manifest(bin_dir):
|
||||
try:
|
||||
data = json.loads((Path(bin_dir) / MANIFEST).read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return data if isinstance(data, dict) and isinstance(data.get('files'), list) else None
|
||||
|
||||
|
||||
def install(archive, bin_dir, output, version, progress=None, dlss_dll=None):
|
||||
"""OptiScaler from its release .7z into bin_dir: OptiScaler.dll as winmm.dll, its DLLs beside
|
||||
it, its licenses in optiscaler-licenses\\, OptiScaler.ini set for the port (configure()).
|
||||
Unpacked into bin\\.optiscaler-new first (every file checked); then moved into place.
|
||||
Returns the list of files put (relative to bin_dir)."""
|
||||
bin_dir = Path(bin_dir)
|
||||
current = optiscaler_state(bin_dir)
|
||||
if current and current['foreign']:
|
||||
raise OSError(f'{bin_dir / "winmm.dll"} is another program, not OptiScaler: not replaced')
|
||||
staging = bin_dir / '.optiscaler-new'
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
try:
|
||||
with SevenZip(archive) as seven:
|
||||
plan = {}
|
||||
for entry in seven.entries:
|
||||
if not entry.is_dir:
|
||||
target = _installed_name(entry.name)
|
||||
if target:
|
||||
plan[entry.name] = target
|
||||
if 'winmm.dll' not in plan.values() or 'OptiScaler.ini' not in plan.values():
|
||||
raise OSError('OptiScaler: OptiScaler.dll or OptiScaler.ini is not in the download')
|
||||
seven.extract(lambda e: staging / plan[e.name] if e.name in plan else None, progress)
|
||||
ini = staging / 'OptiScaler.ini'
|
||||
_write_text(ini, configure(_read_text(ini), output))
|
||||
files = sorted(plan.values(), key=str.lower)
|
||||
if output == 'dlss' and dlss_dll and Path(dlss_dll).is_file():
|
||||
shutil.copyfile(dlss_dll, staging / 'nvngx_dlss.dll')
|
||||
files.append('nvngx_dlss.dll')
|
||||
old = _manifest(bin_dir) or {}
|
||||
# Over a copy unpacked by hand, its leftovers (README, setup scripts, Licenses\) join the
|
||||
# list so Remove takes them too.
|
||||
by_hand = [n for n in HAND_FILES if current and current['by_hand'] and (bin_dir / n).is_file()]
|
||||
listed = sorted(set(files) | set(old.get('files', [])) | set(by_hand), key=str.lower)
|
||||
# The list first, so even a move cut short (a file in use) can be removed.
|
||||
_write_text(bin_dir / MANIFEST, json.dumps({'version': version, 'files': listed}, indent=2))
|
||||
for name in files:
|
||||
target = bin_dir / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.replace(staging / name, target)
|
||||
except OSError as error:
|
||||
raise OSError(f'{target}: {error.strerror or error} (close the game and try again)') from error
|
||||
return files
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
def _inside(bin_dir, name):
|
||||
"""bin_dir / name, or None for a name that would leave bin_dir."""
|
||||
parts = str(name).replace('\\', '/').split('/')
|
||||
if not name or ':' in str(name) or any(p in ('', '.', '..') for p in parts):
|
||||
return None
|
||||
return Path(bin_dir).joinpath(*parts)
|
||||
|
||||
|
||||
def remove(bin_dir):
|
||||
"""Takes OptiScaler out of bin_dir: what optiscaler.json lists (or, for a copy unpacked by
|
||||
hand, HAND_FILES) and what it writes itself. Nothing else is touched. Returns the files
|
||||
removed; raises OSError naming the files that could not be (in use by the game)."""
|
||||
bin_dir = Path(bin_dir)
|
||||
manifest = _manifest(bin_dir)
|
||||
if manifest is not None:
|
||||
names = [str(n) for n in manifest['files']]
|
||||
else:
|
||||
state = optiscaler_state(bin_dir)
|
||||
names = list(HAND_FILES) if state and not state['foreign'] else []
|
||||
if manifest is not None or names:
|
||||
names += [n for n in WRITTEN if n != MANIFEST and n not in names]
|
||||
removed, failed = [], []
|
||||
for name in names:
|
||||
target = _inside(bin_dir, name)
|
||||
if target is None or not target.is_file():
|
||||
continue
|
||||
try:
|
||||
target.unlink()
|
||||
removed.append(name)
|
||||
except OSError:
|
||||
failed.append(name)
|
||||
for folder in ('D3D12_Optiscaler', 'optiscaler-licenses', 'Licenses'):
|
||||
try:
|
||||
(bin_dir / folder).rmdir() # only when empty: nothing else is ever deleted
|
||||
except OSError:
|
||||
pass
|
||||
if failed:
|
||||
raise OSError('could not remove (close the game and try again): ' + ', '.join(failed))
|
||||
(bin_dir / MANIFEST).unlink(missing_ok=True)
|
||||
return removed
|
||||
|
||||
|
||||
def set_output(bin_dir, output, dlss_dll=None):
|
||||
"""The installed OptiScaler.ini's VulkanUpscaler for `output`; with DLSS, the port's
|
||||
nvngx_dlss.dll copied next to it (OptiScaler looks for it there). Returns the value set."""
|
||||
bin_dir = Path(bin_dir)
|
||||
ini = bin_dir / 'OptiScaler.ini'
|
||||
text = _read_text(ini)
|
||||
value = vulkan_upscaler_value(text, output)
|
||||
_write_text(ini, ini_set(text, 'Upscalers', 'VulkanUpscaler', value))
|
||||
if output == 'dlss' and dlss_dll and Path(dlss_dll).is_file() and not (bin_dir / 'nvngx_dlss.dll').is_file():
|
||||
shutil.copyfile(dlss_dll, bin_dir / 'nvngx_dlss.dll')
|
||||
manifest = _manifest(bin_dir)
|
||||
if manifest is not None and 'nvngx_dlss.dll' not in manifest['files']:
|
||||
manifest['files'].append('nvngx_dlss.dll')
|
||||
_write_text(bin_dir / MANIFEST, json.dumps(manifest, indent=2))
|
||||
return value
|
||||
+237
-3
@@ -835,7 +835,7 @@ EXTRA = {
|
||||
'Balanced: about 13% more FPS in the city': 'Equilibrado: cerca de 13% mais FPS na cidade',
|
||||
'Maximum: about 19% more FPS, fewer pedestrians': 'Máximo: cerca de 19% mais FPS, menos pedestres',
|
||||
"The game's own lower settings, from axylpup's 1.1: shorter detail distance, one shadow level less, lighter particles and fewer high-resolution textures nearby. Figures are from his PC in the heaviest city view.": 'Os ajustes mais leves do próprio jogo, da versão 1.1 do axylpup: distância de detalhe menor, um nível de sombra a menos, partículas mais leves e menos texturas em alta resolução por perto. Os números são do PC dele, na vista mais pesada da cidade.',
|
||||
'For Xbox, 8BitDo and other pads without a touchpad or motion sensors. Touchpad click: Select/Back (with L1: the left side). Swipe: hold Select/Back and push the right stick. Two fingers: hold Select/Back + RB. Spray can: L3 + R3 together tilts the pad, LB rattles it, the right stick aims. Automatic turns it off on a DualShock 4 or DualSense.': 'Para controles Xbox, 8BitDo e outros sem touchpad ou sensor de movimento. Clique no touchpad: Select/Back (com L1: o lado esquerdo). Deslizar: segure Select/Back e empurre o analógico direito. Dois dedos: segure Select/Back + RB. Lata de spray: L3 + R3 juntos inclinam o controle, LB chacoalha, o analógico direito mira. No Automático ele desliga num DualShock 4 ou DualSense.',
|
||||
'For Xbox, 8BitDo and other pads without a touchpad or motion sensors. Touchpad click: Select/Back (with L1: the left side). Swipe: hold Select/Back and push the right stick. Two fingers: hold Select/Back + RB. Spray can: Insert > Controls > Spray can tilts the pad, LB rattles it, the right stick aims. Automatic turns it off on a DualShock 4 or DualSense.': 'Para controles Xbox, 8BitDo e outros sem touchpad ou sensor de movimento. Clique no touchpad: Select/Back (com L1: o lado esquerdo). Deslizar: segure Select/Back e empurre o analógico direito. Dois dedos: segure Select/Back + RB. Lata de spray: Insert > Controls > Spray can inclina o controle, LB chacoalha, o analógico direito mira. No Automático ele desliga num DualShock 4 ou DualSense.',
|
||||
"Keyboard and mouse: WASD moves, IJKL turns the camera. Click in the game window and the mouse aims (1:1, as in axylpup's build); Y or F7 lets the mouse go and takes it back, - / = change its speed. Left click R2 (shoot), right click L2 (aim). Spray can: hold Z to tilt the pad, tap Z or hold X to rattle it, move the mouse to aim. R: the touchpad (held, both thumbs: the drains, the car on Reggie); R + W/A/S/D: a swipe (doors, wreckage, the Mobile Command Center core: hold R + W and click to hit it); G: its left side (photo mode). Arrows: d-pad. F12: screenshot. Insert: the port's menu.": 'Teclado e mouse: WASD anda, IJKL gira a câmera. Clique na janela do jogo e o mouse mira (1:1, como no build do axylpup); Y ou F7 solta o mouse e pega de volta, - / = mudam a velocidade. Clique esquerdo R2 (atirar), direito L2 (mirar). Lata de spray: segure Z para inclinar o controle, toque Z ou segure X para chacoalhar, mova o mouse para mirar. R: o touchpad (segurado, os dois polegares: os drenos, o carro em cima do Reggie); R + W/A/S/D: deslizar (portas, destroços, o núcleo do Centro de Comando Móvel: segure R + W e clique para bater); G: o lado esquerdo (modo foto). Setas: direcional. F12: print. Insert: menu do port.',
|
||||
'Steam is open. Close Steam, add the game and open Steam again?': 'O Steam está aberto. Fechar o Steam, adicionar o jogo e abrir o Steam de novo?',
|
||||
'Steam did not close. Close it (Steam > Exit) and click again.': 'O Steam não fechou. Feche-o (Steam > Sair) e clique de novo.',
|
||||
@@ -862,10 +862,23 @@ EXTRA = {
|
||||
'Automatic (on for pads without a touchpad)': 'Automático (liga em controles sem touchpad)',
|
||||
'On': 'Ligado',
|
||||
'Off': 'Desligado',
|
||||
'For Xbox, 8BitDo and other pads without a touchpad or motion sensors. Touchpad click: Select/Back (with L1: the left side). Swipe: hold Select/Back and push the right stick. Two fingers: hold Select/Back + RB. Spray can: L3 + R3 together tilts the pad, LB rattles it, the right stick aims. Automatic turns it off on a DualShock 4 or DualSense.': 'Para controles Xbox, 8BitDo e outros sem touchpad ou sensor de movimento. Clique no touchpad: Select/Back (com L1: o lado esquerdo). Deslizar: segure Select/Back e empurre o analógico direito. Dois dedos: segure Select/Back + RB. Lata de spray: L3 + R3 juntos inclinam o controle, LB sacode, o analógico direito mira. No Automático ele desliga num DualShock 4 ou DualSense.',
|
||||
'For Xbox, 8BitDo and other pads without a touchpad or motion sensors. Touchpad click: Select/Back (with L1: the left side). Swipe: hold Select/Back and push the right stick. Two fingers: hold Select/Back + RB. Spray can: Insert > Controls > Spray can tilts the pad, LB rattles it, the right stick aims. Automatic turns it off on a DualShock 4 or DualSense.': 'Para controles Xbox, 8BitDo e outros sem touchpad ou sensor de movimento. Clique no touchpad: Select/Back (com L1: o lado esquerdo). Deslizar: segure Select/Back e empurre o analógico direito. Dois dedos: segure Select/Back + RB. Lata de spray: Insert > Controls > Spray can inclina o controle, LB sacode, o analógico direito mira. No Automático ele desliga num DualShock 4 ou DualSense.',
|
||||
'Spray can stick speed': 'Velocidade do analógico na lata',
|
||||
'Stick dead zone': 'Zona morta dos analógicos',
|
||||
'Skip the intro logos': 'Pular as logos da abertura',
|
||||
'Video memory limit': 'Limite de memória de vídeo',
|
||||
'Automatic (what the driver allows)': 'Automático (o que o driver permite)',
|
||||
'3.5 GB (4 GB cards)': '3,5 GB (placas de 4 GB)',
|
||||
'5 GB (6 GB cards)': '5 GB (placas de 6 GB)',
|
||||
'6.5 GB (8 GB cards)': '6,5 GB (placas de 8 GB)',
|
||||
'9.5 GB (10-12 GB cards)': '9,5 GB (placas de 10 a 12 GB)',
|
||||
'How much video memory the game may fill before it frees textures it is not using. Lower it if the game closes with "Device lost" or the log says the video memory is nearly full, mainly on 4-8 GB cards and laptops.': 'Quanta memória de vídeo o jogo pode ocupar antes de liberar texturas que não está usando. Diminua se o jogo fechar com "Device lost" ou se o log disser que a memória de vídeo está quase cheia, principalmente em placas de 4 a 8 GB e notebooks.',
|
||||
'Controller sensitivity': 'Sensibilidade do controle',
|
||||
'Mouse sensitivity': 'Sensibilidade do mouse',
|
||||
'The right stick (camera). Below 1: slower. Above 1: small stick moves turn the camera more. Also in the game: Insert > Controls.': 'O analógico direito (câmera). Abaixo de 1: mais lenta. Acima de 1: movimentos pequenos do analógico giram mais a câmera. Também no jogo: Insert > Controls.',
|
||||
'How far the camera turns per mouse movement. Also in the game: Insert > Controls, or - / =.': 'Quanto a câmera gira por movimento do mouse. Também no jogo: Insert > Controls, ou - / =.',
|
||||
'Back up saves before a start': 'Fazer backup dos saves antes de jogar',
|
||||
'A copy of your saves before each start when they changed (kept in user\\savedata_backups). Off: no new copies; the old ones stay and can still be restored.': 'Uma cópia dos seus saves antes de cada início, quando eles mudaram (fica em user\\savedata_backups). Desligado: nenhuma cópia nova; as antigas ficam e ainda podem ser restauradas.',
|
||||
'Frame pacing': 'Ritmo dos quadros',
|
||||
"What's new": 'Novidades',
|
||||
'Help': 'Ajuda',
|
||||
@@ -1012,7 +1025,7 @@ EXTRA = {
|
||||
'Slow': 'Lenta',
|
||||
'Fast': 'Rápida',
|
||||
'Very fast (0.3.4 and earlier)': 'Muito rápida (0.3.4 e anteriores)',
|
||||
'How fast the right stick turns the spray can while the pad is tilted (L3 + R3). A light push moves it a little, a full push at this speed. In the game, - / = change it too.': 'Quão rápido o analógico direito vira a lata de spray com o controle inclinado (L3 + R3). Um toque leve move pouco; empurrado até o fim, nesta velocidade. No jogo, - / = também mudam.',
|
||||
'How fast the right stick turns the spray can while the pad is tilted (Insert > Controls > Spray can). A light push moves it a little, a full push at this speed. In the game, - / = change it too.': 'Quão rápido o analógico direito vira a lata de spray com o controle inclinado (L3 + R3). Um toque leve move pouco; empurrado até o fim, nesta velocidade. No jogo, - / = também mudam.',
|
||||
'Automatic (launcher language)': 'Automático (idioma do launcher)',
|
||||
'Portuguese (Brazil)': 'Português (Brasil)',
|
||||
'Portuguese (Portugal)': 'Português (Portugal)',
|
||||
@@ -1204,6 +1217,81 @@ EXTRA = {
|
||||
'Original (default)': 'Original (padrão)',
|
||||
'Match my controller': 'Igual ao meu controle',
|
||||
"Replaces the PlayStation button icons inside the game with the ones of your controller. Works on a copy of two game files in the port's data folder (about 70 MB); your game folder is never changed. Applied when the game starts.": 'Troca os ícones de botões PlayStation dentro do jogo pelos do seu controle. Funciona com uma cópia de dois arquivos do jogo na pasta de dados do port (cerca de 70 MB); a pasta do jogo nunca é alterada. Aplicado quando o jogo inicia.',
|
||||
# 0.3.7: OptiScaler (optional), installed by the launcher.
|
||||
'OptiScaler (optional)':
|
||||
'OptiScaler (opcional)',
|
||||
"A separate program by the OptiScaler team that takes the port's XeSS input and turns it into another upscaler. Not part of the port: the launcher downloads it from the project's GitHub when you ask and can take it out again.":
|
||||
'Um programa à parte, da equipe do OptiScaler, que pega a entrada do XeSS do port e transforma em outro upscaler. Não faz parte do port: o launcher baixa do GitHub do projeto quando você pede e pode tirar de novo.',
|
||||
'OptiScaler output':
|
||||
'Saída do OptiScaler',
|
||||
'What OptiScaler makes of the XeSS input. FSR 2.2 is the one tested with the port; FSR 4 goes through DirectX 12 and needs a Radeon RX 7000 or 9000. Written to OptiScaler.ini at once; it applies when the game starts.':
|
||||
'O que o OptiScaler faz com a entrada do XeSS. O FSR 2.2 é o testado com o port; o FSR 4 passa pelo DirectX 12 e precisa de uma Radeon RX 7000 ou 9000. Gravado no OptiScaler.ini na hora; vale quando o jogo abrir.',
|
||||
'FSR 4 (Radeon RX 7000 / 9000, RDNA 3/4)':
|
||||
'FSR 4 (Radeon RX 7000 / 9000, RDNA 3/4)',
|
||||
'FSR 2.2 (tested with the port)':
|
||||
'FSR 2.2 (testado com o port)',
|
||||
'Download and install':
|
||||
'Baixar e instalar',
|
||||
'Update / install again':
|
||||
'Atualizar / instalar de novo',
|
||||
'Remove':
|
||||
'Remover',
|
||||
'How to use':
|
||||
'Como usar',
|
||||
'Not installed.':
|
||||
'Não instalado.',
|
||||
'bin\\winmm.dll is another program, not OptiScaler: the launcher leaves it alone.':
|
||||
'bin\\winmm.dll é outro programa, não o OptiScaler: o launcher não mexe nele.',
|
||||
'Installed: OptiScaler {} · output {} · its menu in the game: {}.':
|
||||
'Instalado: OptiScaler {} · saída {} · menu dele no jogo: {}.',
|
||||
"Unpacked by hand: Remove takes its files out; Download and install replaces it with the launcher's, set for the port.":
|
||||
'Instalado à mão: Remover tira os arquivos dele; Baixar e instalar troca pelo do launcher, já configurado para o port.',
|
||||
'{} is also a key of the port: install it again from here to move its menu to F8.':
|
||||
'{} também é uma tecla do port: instale de novo por aqui para o menu dele ir para o F8.',
|
||||
"⚠ The port's anti-aliasing is not XeSS, so OptiScaler gets nothing: choose XeSS in Anti-aliasing above.":
|
||||
'⚠ O anti-aliasing do port não está em XeSS, então o OptiScaler não recebe nada: escolha XeSS em Anti-aliasing acima.',
|
||||
'Close the game first: it is using the OptiScaler files.':
|
||||
'Feche o jogo primeiro: ele está usando os arquivos do OptiScaler.',
|
||||
'OptiScaler.ini could not be changed: {}':
|
||||
'Não deu para mudar o OptiScaler.ini: {}',
|
||||
'OptiScaler output: {} (the next time the game starts).':
|
||||
'Saída do OptiScaler: {} (na próxima vez que o jogo abrir).',
|
||||
"OptiScaler is a separate program by the OptiScaler team (GPL-3.0), not part of the port.\n\nThe launcher downloads its newest stable release from github.com/optiscaler/OptiScaler (about 55 MB), checks it and puts it in {}, set for the port: its menu on F8, the output you chose. The port's anti-aliasing is set to XeSS, which is what OptiScaler reads.\n\nDownload and install it now?":
|
||||
'O OptiScaler é um programa à parte, da equipe do OptiScaler (GPL-3.0), e não faz parte do port.\n\nO launcher baixa a versão estável mais nova de github.com/optiscaler/OptiScaler (uns 55 MB), confere e coloca em {}, já configurado para o port: menu dele no F8 e a saída que você escolheu. O anti-aliasing do port passa para XeSS, que é o que o OptiScaler lê.\n\nBaixar e instalar agora?',
|
||||
'Downloading OptiScaler {}… {{}}%':
|
||||
'Baixando o OptiScaler {}… {{}}%',
|
||||
'Installing OptiScaler… {}%':
|
||||
'Instalando o OptiScaler… {}%',
|
||||
'OptiScaler was not installed: {}':
|
||||
'O OptiScaler não foi instalado: {}',
|
||||
"OptiScaler {} is installed. The port's anti-aliasing is now XeSS.\n\nIn the game, F8 opens OptiScaler's menu (Insert stays the port's). Remove takes it out again.":
|
||||
'O OptiScaler {} está instalado. O anti-aliasing do port agora é XeSS.\n\nNo jogo, o F8 abre o menu do OptiScaler (o Insert continua sendo do port). Remover tira ele de novo.',
|
||||
'Looking for the newest OptiScaler…':
|
||||
'Procurando o OptiScaler mais novo…',
|
||||
"Remove OptiScaler? Its files leave the bin folder and the port runs as before. The port's anti-aliasing stays XeSS; change it above if you want.":
|
||||
'Remover o OptiScaler? Os arquivos dele saem da pasta bin e o port fica como antes. O anti-aliasing do port continua em XeSS; mude acima se quiser.',
|
||||
'OptiScaler was not removed completely: {}':
|
||||
'O OptiScaler não foi removido por completo: {}',
|
||||
'OptiScaler removed ({} files).':
|
||||
'OptiScaler removido ({} arquivos).',
|
||||
'OptiScaler {} installed.':
|
||||
'OptiScaler {} instalado.',
|
||||
"OptiScaler is installed, but the port's anti-aliasing is not XeSS, so OptiScaler gets nothing and the game runs without it.\n\nSwitch the anti-aliasing to XeSS now?":
|
||||
'O OptiScaler está instalado, mas o anti-aliasing do port não está em XeSS, então o OptiScaler não recebe nada e o jogo roda sem ele.\n\nMudar o anti-aliasing para XeSS agora?',
|
||||
"OptiScaler (optional)\n\nWhat it is: a separate program (OptiScaler team, GPL-3.0) that takes the picture, depth and motion the port gives XeSS and runs another upscaler on them: FSR 4, FSR 3.1, XeSS, DLSS or FSR 2.2.\n\n1. Download and install. The port's anti-aliasing must be XeSS (the launcher sets it): with anything else OptiScaler gets nothing.\n2. Choose the output here (it applies when the game starts). The size the game draws at is still the port's 'FSR 4 / DLSS quality'.\n3. In the game, F8 opens OptiScaler's menu (output, sharpness, debug views). Insert is still the port's menu and Home its anti-aliasing switch. Page Up, Page Down and End are OptiScaler's (its FPS counter and frame generation).\n4. To go back: Remove. Every file it added leaves the bin folder.\n\nThird-party: problems with OptiScaler itself go to its project, not to the port.\nProject: github.com/optiscaler/OptiScaler · License: GPL-3.0\nOpen the project page (source code and license)?":
|
||||
"OptiScaler (opcional)\n\nO que é: um programa à parte (equipe do OptiScaler, GPL-3.0) que pega a imagem, a profundidade e o movimento que o port entrega ao XeSS e roda outro upscaler em cima: FSR 4, FSR 3.1, XeSS, DLSS ou FSR 2.2.\n\n1. Baixar e instalar. O anti-aliasing do port precisa estar em XeSS (o launcher já coloca): com qualquer outro, o OptiScaler não recebe nada.\n2. Escolha a saída aqui (vale quando o jogo abrir). O tamanho em que o jogo desenha continua sendo o 'Qualidade do FSR 4 / DLSS' do port.\n3. No jogo, o F8 abre o menu do OptiScaler (saída, nitidez, visões de debug). O Insert continua sendo o menu do port e o Home a troca de anti-aliasing. Page Up, Page Down e End são do OptiScaler (contador de FPS e geração de quadros dele).\n4. Para voltar: Remover. Todo arquivo que ele colocou sai da pasta bin.\n\nDe terceiros: problemas do próprio OptiScaler vão para o projeto dele, não para o port.\nProjeto: github.com/optiscaler/OptiScaler · Licença: GPL-3.0\nAbrir a página do projeto (código e licença)?",
|
||||
'Off (no anti-aliasing)':
|
||||
'Desligado (sem anti-aliasing)',
|
||||
'Off: no anti-aliasing at all, the sharpest picture with jagged edges (no smearing in motion).':
|
||||
'Desligado: nenhum anti-aliasing, a imagem mais nítida com bordas serrilhadas (sem borrão em movimento).',
|
||||
'Motion blur':
|
||||
'Desfoque de movimento',
|
||||
'Chromatic aberration':
|
||||
'Aberração cromática',
|
||||
'The blur when the camera or Delsin moves fast. Off gives a sharp picture in motion; it also changes at once in the game (Insert > Image).':
|
||||
'O borrão quando a câmera ou o Delsin se movem rápido. Desligado deixa a imagem nítida em movimento; também muda na hora no jogo (Insert > Image).',
|
||||
'The red and blue fringes toward the edges of the screen. Turning it off helps players who feel sick with it; it also changes at once in the game (Insert > Image).':
|
||||
'As franjas vermelhas e azuis perto das bordas da tela. Desligar ajuda quem passa mal com elas; também muda na hora no jogo (Insert > Image).',
|
||||
},
|
||||
'es': {
|
||||
'Game multisampling': 'Multisampling del juego',
|
||||
@@ -1422,9 +1510,155 @@ EXTRA = {
|
||||
'Match my controller': 'Igual que mi mando',
|
||||
"Replaces the PlayStation button icons inside the game with the ones of your controller. Works on a copy of two game files in the port's data folder (about 70 MB); your game folder is never changed. Applied when the game starts.":
|
||||
'Sustituye los iconos de botones de PlayStation dentro del juego por los de tu mando. Trabaja sobre una copia de dos archivos del juego en la carpeta de datos del port (unos 70 MB); tu carpeta del juego nunca se modifica. Se aplica al iniciar el juego.',
|
||||
# 0.3.7: OptiScaler (optional), installed by the launcher.
|
||||
'OptiScaler (optional)':
|
||||
'OptiScaler (opcional)',
|
||||
"A separate program by the OptiScaler team that takes the port's XeSS input and turns it into another upscaler. Not part of the port: the launcher downloads it from the project's GitHub when you ask and can take it out again.":
|
||||
'Un programa aparte, del equipo de OptiScaler, que toma la entrada de XeSS del port y la convierte en otro escalador. No forma parte del port: el lanzador lo descarga del GitHub del proyecto cuando lo pides y puede quitarlo de nuevo.',
|
||||
'OptiScaler output':
|
||||
'Salida de OptiScaler',
|
||||
'What OptiScaler makes of the XeSS input. FSR 2.2 is the one tested with the port; FSR 4 goes through DirectX 12 and needs a Radeon RX 7000 or 9000. Written to OptiScaler.ini at once; it applies when the game starts.':
|
||||
'Lo que OptiScaler hace con la entrada de XeSS. FSR 2.2 es el probado con el port; FSR 4 pasa por DirectX 12 y necesita una Radeon RX 7000 o 9000. Se escribe en OptiScaler.ini al momento; vale al iniciar el juego.',
|
||||
'FSR 4 (Radeon RX 7000 / 9000, RDNA 3/4)':
|
||||
'FSR 4 (Radeon RX 7000 / 9000, RDNA 3/4)',
|
||||
'FSR 2.2 (tested with the port)':
|
||||
'FSR 2.2 (probado con el port)',
|
||||
'Download and install':
|
||||
'Descargar e instalar',
|
||||
'Update / install again':
|
||||
'Actualizar / instalar de nuevo',
|
||||
'Remove':
|
||||
'Quitar',
|
||||
'How to use':
|
||||
'Cómo usarlo',
|
||||
'Not installed.':
|
||||
'No instalado.',
|
||||
'bin\\winmm.dll is another program, not OptiScaler: the launcher leaves it alone.':
|
||||
'bin\\winmm.dll es otro programa, no OptiScaler: el lanzador no lo toca.',
|
||||
'Installed: OptiScaler {} · output {} · its menu in the game: {}.':
|
||||
'Instalado: OptiScaler {} · salida {} · su menú en el juego: {}.',
|
||||
"Unpacked by hand: Remove takes its files out; Download and install replaces it with the launcher's, set for the port.":
|
||||
'Instalado a mano: Quitar saca sus archivos; Descargar e instalar lo cambia por el del lanzador, configurado para el port.',
|
||||
'{} is also a key of the port: install it again from here to move its menu to F8.':
|
||||
'{} también es una tecla del port: instálalo de nuevo desde aquí para pasar su menú a F8.',
|
||||
"⚠ The port's anti-aliasing is not XeSS, so OptiScaler gets nothing: choose XeSS in Anti-aliasing above.":
|
||||
'⚠ El antialiasing del port no está en XeSS, así que OptiScaler no recibe nada: elige XeSS en Anti-aliasing, arriba.',
|
||||
'Close the game first: it is using the OptiScaler files.':
|
||||
'Cierra el juego primero: está usando los archivos de OptiScaler.',
|
||||
'OptiScaler.ini could not be changed: {}':
|
||||
'No se pudo cambiar OptiScaler.ini: {}',
|
||||
'OptiScaler output: {} (the next time the game starts).':
|
||||
'Salida de OptiScaler: {} (la próxima vez que se inicie el juego).',
|
||||
"OptiScaler is a separate program by the OptiScaler team (GPL-3.0), not part of the port.\n\nThe launcher downloads its newest stable release from github.com/optiscaler/OptiScaler (about 55 MB), checks it and puts it in {}, set for the port: its menu on F8, the output you chose. The port's anti-aliasing is set to XeSS, which is what OptiScaler reads.\n\nDownload and install it now?":
|
||||
'OptiScaler es un programa aparte, del equipo de OptiScaler (GPL-3.0), y no forma parte del port.\n\nEl lanzador descarga su versión estable más nueva de github.com/optiscaler/OptiScaler (unos 55 MB), la comprueba y la pone en {}, configurada para el port: su menú en F8 y la salida que elegiste. El antialiasing del port pasa a XeSS, que es lo que lee OptiScaler.\n\n¿Descargarlo e instalarlo ahora?',
|
||||
'Downloading OptiScaler {}… {{}}%':
|
||||
'Descargando OptiScaler {}… {{}}%',
|
||||
'Installing OptiScaler… {}%':
|
||||
'Instalando OptiScaler… {}%',
|
||||
'OptiScaler was not installed: {}':
|
||||
'OptiScaler no se instaló: {}',
|
||||
"OptiScaler {} is installed. The port's anti-aliasing is now XeSS.\n\nIn the game, F8 opens OptiScaler's menu (Insert stays the port's). Remove takes it out again.":
|
||||
'OptiScaler {} está instalado. El antialiasing del port ahora es XeSS.\n\nEn el juego, F8 abre el menú de OptiScaler (Insert sigue siendo del port). Quitar lo saca de nuevo.',
|
||||
'Looking for the newest OptiScaler…':
|
||||
'Buscando el OptiScaler más nuevo…',
|
||||
"Remove OptiScaler? Its files leave the bin folder and the port runs as before. The port's anti-aliasing stays XeSS; change it above if you want.":
|
||||
'¿Quitar OptiScaler? Sus archivos salen de la carpeta bin y el port queda como antes. El antialiasing del port sigue en XeSS; cámbialo arriba si quieres.',
|
||||
'OptiScaler was not removed completely: {}':
|
||||
'OptiScaler no se quitó del todo: {}',
|
||||
'OptiScaler removed ({} files).':
|
||||
'OptiScaler quitado ({} archivos).',
|
||||
'OptiScaler {} installed.':
|
||||
'OptiScaler {} instalado.',
|
||||
"OptiScaler is installed, but the port's anti-aliasing is not XeSS, so OptiScaler gets nothing and the game runs without it.\n\nSwitch the anti-aliasing to XeSS now?":
|
||||
'OptiScaler está instalado, pero el antialiasing del port no está en XeSS, así que OptiScaler no recibe nada y el juego va sin él.\n\n¿Cambiar el antialiasing a XeSS ahora?',
|
||||
"OptiScaler (optional)\n\nWhat it is: a separate program (OptiScaler team, GPL-3.0) that takes the picture, depth and motion the port gives XeSS and runs another upscaler on them: FSR 4, FSR 3.1, XeSS, DLSS or FSR 2.2.\n\n1. Download and install. The port's anti-aliasing must be XeSS (the launcher sets it): with anything else OptiScaler gets nothing.\n2. Choose the output here (it applies when the game starts). The size the game draws at is still the port's 'FSR 4 / DLSS quality'.\n3. In the game, F8 opens OptiScaler's menu (output, sharpness, debug views). Insert is still the port's menu and Home its anti-aliasing switch. Page Up, Page Down and End are OptiScaler's (its FPS counter and frame generation).\n4. To go back: Remove. Every file it added leaves the bin folder.\n\nThird-party: problems with OptiScaler itself go to its project, not to the port.\nProject: github.com/optiscaler/OptiScaler · License: GPL-3.0\nOpen the project page (source code and license)?":
|
||||
"OptiScaler (opcional)\n\nQué es: un programa aparte (equipo de OptiScaler, GPL-3.0) que toma la imagen, la profundidad y el movimiento que el port da a XeSS y ejecuta otro escalador sobre ellos: FSR 4, FSR 3.1, XeSS, DLSS o FSR 2.2.\n\n1. Descargar e instalar. El antialiasing del port debe estar en XeSS (el lanzador lo pone): con cualquier otro, OptiScaler no recibe nada.\n2. Elige la salida aquí (vale al iniciar el juego). El tamaño al que dibuja el juego sigue siendo 'FSR 4 / DLSS quality' del port.\n3. En el juego, F8 abre el menú de OptiScaler (salida, nitidez, vistas de depuración). Insert sigue siendo el menú del port e Inicio su cambio de antialiasing. Re Pág, Av Pág y Fin son de OptiScaler (su contador de FPS y su generación de fotogramas).\n4. Para volver: Quitar. Todo archivo que añadió sale de la carpeta bin.\n\nDe terceros: los problemas del propio OptiScaler van a su proyecto, no al port.\nProyecto: github.com/optiscaler/OptiScaler · Licencia: GPL-3.0\n¿Abrir la página del proyecto (código y licencia)?",
|
||||
'Off (no anti-aliasing)':
|
||||
'Desactivado (sin antialiasing)',
|
||||
'Off: no anti-aliasing at all, the sharpest picture with jagged edges (no smearing in motion).':
|
||||
'Desactivado: ningún antialiasing, la imagen más nítida con bordes dentados (sin borrón en movimiento).',
|
||||
'Effects':
|
||||
'Efectos',
|
||||
'Motion blur':
|
||||
'Desenfoque de movimiento',
|
||||
'Chromatic aberration':
|
||||
'Aberración cromática',
|
||||
'The blur when the camera or Delsin moves fast. Off gives a sharp picture in motion; it also changes at once in the game (Insert > Image).':
|
||||
'El desenfoque cuando la cámara o Delsin se mueven rápido. Desactivado da una imagen nítida en movimiento; también cambia al momento en el juego (Insert > Image).',
|
||||
'The red and blue fringes toward the edges of the screen. Turning it off helps players who feel sick with it; it also changes at once in the game (Insert > Image).':
|
||||
'Los bordes rojos y azules cerca de los lados de la pantalla. Desactivarla ayuda a quien se marea con ella; también cambia al momento en el juego (Insert > Image).',
|
||||
},
|
||||
}
|
||||
|
||||
# issn 11-04: the Achievements page (trophies). Kept apart from EXTRA so its additions do not
|
||||
# collide with other pages' texts.
|
||||
TROPHY_TEXTS = {
|
||||
'pt': {
|
||||
'Achievements': 'Conquistas',
|
||||
"The game's trophies: the ones unlocked in this port are in color, with the date.":
|
||||
'Os troféus do jogo: os liberados neste port ficam coloridos, com a data.',
|
||||
'In the game': 'No jogo',
|
||||
'Trophy popup': 'Aviso de troféu',
|
||||
"The trophy's icon and name in a top corner for a few seconds when the game unlocks one. Also in Insert > Overlay.":
|
||||
'O ícone e o nome do troféu num canto de cima por alguns segundos quando o jogo libera um. Também em Insert > Overlay.',
|
||||
'Trophy names (optional)': 'Nomes dos troféus (opcional)',
|
||||
"The names and descriptions in the game's trophy file are encrypted. Without a key the port uses its own list (English, Portuguese and Spanish, written from public trophy lists). If you have your own PS4 trophy key (the same one shadPS4 asks for), paste it here to see the game's own texts: it stays only on this PC, in user\\trophy-key.txt, and the port never ships or sends it. A name shows once its trophy is unlocked.":
|
||||
'Os nomes e as descrições no arquivo de troféus do jogo são criptografados. Sem uma chave, o port usa a lista própria dele (inglês, português e espanhol, escrita a partir de listas públicas de troféus). Se você tem a sua própria chave de troféus do PS4 (a mesma que o shadPS4 pede), cole aqui para ver os textos do próprio jogo: ela fica só neste PC, em user\\trophy-key.txt, e o port nunca a distribui nem envia. O nome aparece quando o troféu é liberado.',
|
||||
'Save key': 'Salvar chave',
|
||||
'Remove key': 'Remover chave',
|
||||
'Trophy key': 'Chave de troféus',
|
||||
'No key: names are not shown.': 'Sem chave: os nomes não aparecem.',
|
||||
"Key saved on this PC: the names are the game's own.": 'Chave salva neste PC: os nomes são os do próprio jogo.',
|
||||
"No key: the names come from the port's own list.": 'Sem chave: os nomes vêm da lista própria do port.',
|
||||
'The key is saved but the names could not be read: {}': 'A chave está salva, mas os nomes não puderam ser lidos: {}',
|
||||
'No trophy file in the game folder (sce_sys\\trophy\\trophy00.trp, or Sc0\\trophy beside it). Choose the game folder on the Game page.':
|
||||
'Nenhum arquivo de troféus na pasta do jogo (sce_sys\\trophy\\trophy00.trp, ou Sc0\\trophy ao lado). Escolha a pasta do jogo na página Jogo.',
|
||||
'{} of {} unlocked ({}%)': '{} de {} liberados ({}%)',
|
||||
'Platinum! Every trophy unlocked': 'Platinado com sucesso!',
|
||||
'Platinum': 'Platina', 'Gold': 'Ouro', 'Silver': 'Prata', 'Bronze': 'Bronze',
|
||||
'Trophy {}': 'Troféu {}',
|
||||
'Unlocked {}': 'Liberado em {}',
|
||||
'Locked': 'Bloqueado',
|
||||
'A trophy key is 32 hexadecimal digits (0-9, A-F).': 'A chave de troféus tem 32 dígitos hexadecimais (0-9, A-F).',
|
||||
'This key does not open the trophy names: {}': 'Esta chave não abre os nomes dos troféus: {}',
|
||||
'Could not save the key: {}': 'Não foi possível salvar a chave: {}',
|
||||
'Could not remove the key: {}': 'Não foi possível remover a chave: {}',
|
||||
},
|
||||
'es': {
|
||||
'Achievements': 'Logros',
|
||||
"The game's trophies: the ones unlocked in this port are in color, with the date.":
|
||||
'Los trofeos del juego: los desbloqueados en este port aparecen en color, con la fecha.',
|
||||
'In the game': 'En el juego',
|
||||
'Trophy popup': 'Aviso de trofeo',
|
||||
"The trophy's icon and name in a top corner for a few seconds when the game unlocks one. Also in Insert > Overlay.":
|
||||
'El icono y el nombre del trofeo en una esquina superior durante unos segundos cuando el juego desbloquea uno. También en Insert > Overlay.',
|
||||
'Trophy names (optional)': 'Nombres de los trofeos (opcional)',
|
||||
"The names and descriptions in the game's trophy file are encrypted. Without a key the port uses its own list (English, Portuguese and Spanish, written from public trophy lists). If you have your own PS4 trophy key (the same one shadPS4 asks for), paste it here to see the game's own texts: it stays only on this PC, in user\\trophy-key.txt, and the port never ships or sends it. A name shows once its trophy is unlocked.":
|
||||
'Los nombres y las descripciones del archivo de trofeos del juego están cifrados. Sin una clave, el port usa su propia lista (inglés, portugués y español, escrita a partir de listas públicas de trofeos). Si tienes tu propia clave de trofeos de PS4 (la misma que pide shadPS4), pégala aquí para ver los textos del propio juego: se queda solo en este PC, en user\\trophy-key.txt, y el port nunca la distribuye ni la envía. El nombre aparece cuando se desbloquea el trofeo.',
|
||||
'Save key': 'Guardar clave',
|
||||
'Remove key': 'Quitar clave',
|
||||
'Trophy key': 'Clave de trofeos',
|
||||
'No key: names are not shown.': 'Sin clave: los nombres no se muestran.',
|
||||
"Key saved on this PC: the names are the game's own.": 'Clave guardada en este PC: los nombres son los del propio juego.',
|
||||
"No key: the names come from the port's own list.": 'Sin clave: los nombres vienen de la lista propia del port.',
|
||||
'The key is saved but the names could not be read: {}': 'La clave está guardada, pero no se pudieron leer los nombres: {}',
|
||||
'No trophy file in the game folder (sce_sys\\trophy\\trophy00.trp, or Sc0\\trophy beside it). Choose the game folder on the Game page.':
|
||||
'No hay archivo de trofeos en la carpeta del juego (sce_sys\\trophy\\trophy00.trp, o Sc0\\trophy al lado). Elige la carpeta del juego en la página Juego.',
|
||||
'{} of {} unlocked ({}%)': '{} de {} desbloqueados ({}%)',
|
||||
'Platinum! Every trophy unlocked': '¡Platino! Todos los trofeos',
|
||||
'Platinum': 'Platino', 'Gold': 'Oro', 'Silver': 'Plata', 'Bronze': 'Bronce',
|
||||
'Trophy {}': 'Trofeo {}',
|
||||
'Unlocked {}': 'Desbloqueado el {}',
|
||||
'Locked': 'Bloqueado',
|
||||
'A trophy key is 32 hexadecimal digits (0-9, A-F).': 'Una clave de trofeos tiene 32 dígitos hexadecimales (0-9, A-F).',
|
||||
'This key does not open the trophy names: {}': 'Esta clave no abre los nombres de los trofeos: {}',
|
||||
'Could not save the key: {}': 'No se pudo guardar la clave: {}',
|
||||
'Could not remove the key: {}': 'No se pudo quitar la clave: {}',
|
||||
},
|
||||
}
|
||||
for _language, _texts in TROPHY_TEXTS.items():
|
||||
EXTRA.setdefault(_language, {}).update(_texts)
|
||||
|
||||
_tables = {}
|
||||
|
||||
|
||||
|
||||
+555
-17
@@ -43,6 +43,7 @@ PORT_DIR = Path(sys.executable).resolve().parent if FROZEN else Path(__file__).r
|
||||
sys.path.insert(0, str(PORT_DIR / 'scripts'))
|
||||
import save_backup # noqa: E402 (scripts/)
|
||||
import steam_shortcut # noqa: E402 (scripts/)
|
||||
import trophies # noqa: E402 (scripts/)
|
||||
from patches import game_profile, game_sfo # noqa: E402 (scripts/)
|
||||
from prepare import load_sfo # noqa: E402 (scripts/)
|
||||
DATA_DIR = Path(os.environ.get('ISSN_DATA_DIR', PORT_DIR))
|
||||
@@ -57,7 +58,7 @@ NO_WINDOW = getattr(subprocess, 'CREATE_NO_WINDOW', 0)
|
||||
NEW_GROUP = {} if WINDOWS else {'start_new_session': True}
|
||||
UI_FONT, MONO_FONT, SERIF_FONT = 'Segoe UI', 'Consolas', 'Georgia'
|
||||
# This build; GitHub release tags are windows-v<VERSION>.
|
||||
VERSION = '0.3.6.3'
|
||||
VERSION = '0.3.7'
|
||||
# This port's own test builds (never the God of War III port's: they must not be installed over
|
||||
# it). The source repository is private; the builds are published in a public repository that
|
||||
# holds only releases (tag windows-v<VERSION>, the zip from packaging/windows/package.sh).
|
||||
@@ -358,14 +359,16 @@ def heading_text(text, every_language=False):
|
||||
# launcher's own settings.json (passed to run.py as environment variables).
|
||||
|
||||
INI_FLAGS = {'cheat_double_jumps', 'cheat_very_hard', 'object_motion', 'show_fps', 'vsync', 'async_shaders', 'deferred_readback',
|
||||
'stale_readback'}
|
||||
'stale_readback', 'motion_blur', 'chromatic_aberration', 'trophy_note'}
|
||||
# The graphics keys are also in the in-game menu (Insert / R3+L2); run.py reads the startup ones.
|
||||
INI_DEFAULTS = {'upscaler': 'fsr3', 'preset': '1',
|
||||
INI_DEFAULTS = {'upscaler': 'fsr3', 'preset': '1', 'trophy_note': '1',
|
||||
'object_motion': '1', 'show_fps': '1', 'output_res': '1920x1080',
|
||||
'live_resolution': 'auto', 'display_mode': 'windowed', 'vsync': '1', 'fps_limit': '0',
|
||||
'async_shaders': '1', 'deferred_readback': '1', 'stale_readback': '1',
|
||||
'render_resolution': 'native', 'engine_fps': '120',
|
||||
'frames_queued': '1', 'cheat_very_hard': '0', 'cheat_double_jumps': '0'}
|
||||
'frames_queued': '1', 'cheat_very_hard': '0', 'cheat_double_jumps': '0',
|
||||
'mouse_sensitivity': '1.00', 'pad_sensitivity': '1.00',
|
||||
'motion_blur': '1', 'chromatic_aberration': '1'}
|
||||
DISPLAY_MODES = [('windowed', ('Windowed',)), ('borderless', ('Borderless',)), ('fullscreen', ('Fullscreen',))]
|
||||
# issn: frame pacing on screens without G-Sync / FreeSync (axylpup's 1.1 "Fifo (half refresh)",
|
||||
# vk_swapchain.cpp): every game frame is presented N times with V-Sync, so each stays exactly N
|
||||
@@ -375,11 +378,12 @@ FRAME_PACING = [('', ('Normal: a new frame at every refresh it is ready (default
|
||||
('3', ('A third of the refresh rate, even (48 FPS on 144 Hz, 80 on 240 Hz)',))]
|
||||
FPS_LIMITS = [('30', ('30',)), ('60', ('60',)), ('120', ('120',)), ('240', ('240',)), ('0', ('Unlimited',))]
|
||||
# The sidebar's pages, top to bottom, and the methods that refresh a page's lists when it is shown.
|
||||
PAGES = ('home', 'news', 'graphics', 'performance', 'game', 'advanced', 'help')
|
||||
PAGE_REFRESH = {'home': ('refresh_status',), 'news': ('refresh_news',), 'help': ('run_help',), 'game': ('refresh_lists', 'refresh_game_patches')}
|
||||
PAGES = ('home', 'news', 'graphics', 'performance', 'game', 'trophies', 'advanced', 'help')
|
||||
PAGE_REFRESH = {'home': ('refresh_status',), 'news': ('refresh_news',), 'help': ('run_help',), 'game': ('refresh_lists', 'refresh_game_patches'),
|
||||
'trophies': ('refresh_trophies',)}
|
||||
APP_DEFAULTS = {'ui_language': '', 'game_dir': os.environ.get('ISSN_GAME_DIR', str(PORT_DIR.parent / 'CUSA00004') if WINDOWS else ''), 'update_dir': '', 'experimental_upscaler': False,
|
||||
'video_backend': 'auto', 'gamemode': False, 'mangohud': False, 'desktop_integration': True, 'user_dir': '',
|
||||
'mods_dir': '', 'mods_enabled': True, 'patches_dir': '', 'language': '1', 'game_language': '', 'pad_emulation': '', 'spray_stick': '', 'stick_deadzone': '', 'skip_intro': False, 'frame_pacing': '', 'frame_gen': False, 'seed_any_profile': False,
|
||||
'mods_dir': '', 'mods_enabled': True, 'patches_dir': '', 'language': '1', 'game_language': '', 'pad_emulation': '', 'spray_stick': '', 'stick_deadzone': '', 'skip_intro': False, 'save_backups': True, 'vram_limit': '', 'frame_pacing': '', 'frame_gen': False, 'seed_any_profile': False,
|
||||
'player_name': '', 'hdr': False, 'draw_pipe': '', 'readbacks': '', 'no_msaa': '', 'stream_memory': 'cached',
|
||||
'gpu_id': '', 'background_warmup': True, 'parallel_warmup': True, 'pipeline_binaries': False,
|
||||
'perf_diag': False, 'frame_capture_key': False, 'frame_stats': False, 'gpu_profile': False,
|
||||
@@ -387,7 +391,7 @@ APP_DEFAULTS = {'ui_language': '', 'game_dir': os.environ.get('ISSN_GAME_DIR', s
|
||||
'check_updates': False, 'game_patches': {}, 'controls': {}, 'pad_style': 'playstation',
|
||||
'glyph_style': 'off', 'compute_priority': True, 'ordered_readback': False, 'draw_distance': '',
|
||||
'perf_mode': '', 'render_height': '', 'upscale_quality': '', 'aniso': '', 'shader_compile': '', 'screen_shape': '',
|
||||
'anti_aliasing': 'game'}
|
||||
'anti_aliasing': 'game', 'optiscaler_output': 'fsr22'}
|
||||
|
||||
# PS4 button, default key, default gamepad button (SDL names; src/runtime_pad.c). L2/R2 come
|
||||
# from the analog triggers and the left touchpad click (photo mode) from L1 + the right click's
|
||||
@@ -541,6 +545,11 @@ SPRAY_STICK = [('', ('Normal (default)',)), ('15', ('Slow',)), ('60', ('Fast',))
|
||||
('115', ('Very fast (0.3.4 and earlier)',))]
|
||||
# issn: the analog sticks' dead zone (runtime_pad.c, ISSN_STICK_DEADZONE; the runtime's default
|
||||
# is 0.05). A worn stick that rests off center drifts in game (tester, Xbox One S, 10-29).
|
||||
# issn 11-04: ISSN_VRAM_LIMIT_MB, the video memory the texture clean-up keeps the game under (GitHub
|
||||
# issue 32: a 6 GB laptop card ran out and lost the device). '' = what the driver allows.
|
||||
VRAM_LIMITS = [('', ('Automatic (what the driver allows)',)), ('3584', ('3.5 GB (4 GB cards)',)),
|
||||
('5120', ('5 GB (6 GB cards)',)), ('6656', ('6.5 GB (8 GB cards)',)),
|
||||
('9728', ('9.5 GB (10-12 GB cards)',))]
|
||||
STICK_DEADZONE = [('', ('Small: 5% (default)',)), ('0.10', ('Medium: 10%',)), ('0.15', ('Large: 15%',)),
|
||||
('0.20', ('Very large: 20% (worn sticks)',))]
|
||||
# Launcher language -> PS4 language id for the Automatic game language.
|
||||
@@ -623,7 +632,8 @@ SHADER_COMPILE = [('', ('In the background: no stutter, new things can appear a
|
||||
# port (third_party/fsr4); DLSS needs NVIDIA's Streamline files in dlss_folder(), XeSS Intel's
|
||||
# libxess.dll in xess_folder().
|
||||
ANTI_ALIASING = [('game', ("The game's own (SMAA T2x)",)), ('fsr4', ('FSR 4 (AMD Radeon RX 7000 / 9000)',)),
|
||||
('xess', ('XeSS (Intel; also Radeon and GeForce)',)), ('dlss', ('DLSS (NVIDIA RTX)',))]
|
||||
('xess', ('XeSS (Intel; also Radeon and GeForce)',)), ('dlss', ('DLSS (NVIDIA RTX)',)),
|
||||
('off', ('Off (no anti-aliasing)',))]
|
||||
# NVIDIA's Streamline (DLSS) and Intel's libxess.dll are Windows only: on Linux they are not offered.
|
||||
if not WINDOWS:
|
||||
ANTI_ALIASING = [option for option in ANTI_ALIASING if option[0] not in ('dlss', 'xess')]
|
||||
@@ -701,6 +711,61 @@ def xess_missing():
|
||||
return [name for name in XESS_FILES if not (folder / name).is_file()]
|
||||
|
||||
|
||||
# OptiScaler (optional, a separate program: issn_downloads): what it turns the port's XeSS input
|
||||
# into, written as VulkanUpscaler in bin\OptiScaler.ini. FSR 2.2 is the one tested on the port
|
||||
# (João, RX 9060 XT, 2026-11-04); FSR 4 goes through DX12 (RDNA 3/4); DLSS is offered on
|
||||
# NVIDIA RTX only.
|
||||
OPTISCALER_CHOICES = [('fsr4', ('FSR 4 (Radeon RX 7000 / 9000, RDNA 3/4)',)), ('fsr31', ('FSR 3.1',)),
|
||||
('xess', ('XeSS',)), ('dlss', ('DLSS (NVIDIA RTX)',)),
|
||||
('fsr22', ('FSR 2.2 (tested with the port)',))]
|
||||
|
||||
|
||||
OPTISCALER_SHORT = {'fsr4': 'FSR 4', 'fsr31': 'FSR 3.1', 'xess': 'XeSS', 'dlss': 'DLSS', 'fsr22': 'FSR 2.2'}
|
||||
|
||||
|
||||
def optiscaler_output_name(value):
|
||||
"""A VulkanUpscaler value as the player knows it ('fsr31_12' -> 'FSR 4'); other values as they are."""
|
||||
for key, names in issn_downloads.OPTISCALER_OUTPUTS.items():
|
||||
if str(value or '').lower() in names:
|
||||
return OPTISCALER_SHORT[key]
|
||||
return value or '?'
|
||||
|
||||
|
||||
def optiscaler_bin():
|
||||
"""Where OptiScaler goes: beside issn-probe.exe, which imports WINMM.dll."""
|
||||
return dlss_folder().parent
|
||||
|
||||
|
||||
def optiscaler_choices(pc):
|
||||
"""The outputs for this graphics card (pc None: not checked yet). DLSS only on NVIDIA RTX;
|
||||
FSR 4 not on NVIDIA or Intel cards."""
|
||||
vendor = pc.vendor if pc is not None else ''
|
||||
return [(key, text) for key, text in OPTISCALER_CHOICES
|
||||
if not (key == 'dlss' and not (pc is not None and pc.dlss))
|
||||
and not (key == 'fsr4' and vendor in ('nvidia', 'intel'))]
|
||||
|
||||
|
||||
def optiscaler_log_line(settings):
|
||||
"""The run log's first line when bin\\winmm.dll is there ('' otherwise)."""
|
||||
if not WINDOWS:
|
||||
return ''
|
||||
try:
|
||||
state = issn_downloads.optiscaler_state(optiscaler_bin())
|
||||
except OSError:
|
||||
return ''
|
||||
if not state:
|
||||
return ''
|
||||
if state['foreign']:
|
||||
return 'OptiScaler: not installed, but bin\\winmm.dll is there (another program, loaded by the game)\n'
|
||||
line = ('OptiScaler {} installed {}: bin\\winmm.dll, VulkanUpscaler={}, menu key {} (OptiScaler.ini)'
|
||||
.format(state['version'] or '(version unknown)', 'by hand' if state['by_hand'] else 'by the launcher',
|
||||
state['upscaler'] or '?', state['menu_key'] or '?'))
|
||||
aa = settings.get('anti_aliasing', 'game')
|
||||
if aa != 'xess':
|
||||
line += f"; the port's anti-aliasing is {aa}, not XeSS: OptiScaler gets no input"
|
||||
return line + '\n'
|
||||
|
||||
|
||||
def fsr4_folder():
|
||||
"""Next to the port (the packages ship it, the AppImage too); in the data directory when the
|
||||
port is read-only and has none."""
|
||||
@@ -1029,10 +1094,12 @@ def game_environment(s):
|
||||
if s['user_dir']:
|
||||
env['ISSN_USER_DIR'] = s['user_dir']
|
||||
# issn-axy: the anti-aliasing choice (ANTI_ALIASING); an inherited switch never mixes them.
|
||||
for name in ('SHADPS4_DLSS', 'SHADPS4_DLSS_UPSCALE', 'ISSN_FSR4', 'ISSN_XESS'):
|
||||
for name in ('SHADPS4_DLSS', 'SHADPS4_DLSS_UPSCALE', 'ISSN_FSR4', 'ISSN_XESS', 'ISSN_AA_OFF'):
|
||||
env.pop(name, None)
|
||||
anti_aliasing = s.get('anti_aliasing', 'game')
|
||||
if anti_aliasing == 'fsr4':
|
||||
if anti_aliasing == 'off':
|
||||
env['ISSN_AA_OFF'] = '1' # issn 11-04: the T2x resolve becomes a plain copy, no jitter
|
||||
elif anti_aliasing == 'fsr4':
|
||||
env['ISSN_FSR4'] = '1'
|
||||
elif anti_aliasing == 'xess' and WINDOWS:
|
||||
env['ISSN_XESS'] = '1'
|
||||
@@ -1048,6 +1115,7 @@ def game_environment(s):
|
||||
env['ISSN_PATCHES_CONFIG'] = str(DATA_DIR / 'patches.json')
|
||||
# issn: a new key ('language' was saved as English by earlier launchers).
|
||||
env['ISSN_LANGUAGE'] = game_language(s)
|
||||
env['ISSN_PORT_VERSION'] = VERSION # the shader loading screen shows it
|
||||
if str(s['player_name']).strip():
|
||||
env['ISSN_USER_NAME'] = str(s['player_name']).strip()
|
||||
if s['hdr']:
|
||||
@@ -1055,6 +1123,9 @@ def game_environment(s):
|
||||
# The saved UI choice wins over inherited diagnostic overrides, including when reverting.
|
||||
stream_memory = s.get('stream_memory', APP_DEFAULTS['stream_memory'])
|
||||
env['ISSN_STREAM_MEMORY'] = stream_memory if stream_memory in ('cached', 'host', 'device') else 'cached'
|
||||
env.pop('ISSN_VRAM_LIMIT_MB', None)
|
||||
if s.get('vram_limit') in {value for value, _text in VRAM_LIMITS if value}:
|
||||
env['ISSN_VRAM_LIMIT_MB'] = s['vram_limit']
|
||||
gpu_id = str(s.get('gpu_id', ''))
|
||||
env.pop('ISSN_GPU_ID', None)
|
||||
if gpu_id.isdigit():
|
||||
@@ -1100,9 +1171,14 @@ def game_environment(s):
|
||||
env.pop('ISSN_SKIP_INTRO', None)
|
||||
if s.get('skip_intro'):
|
||||
env['ISSN_SKIP_INTRO'] = '1'
|
||||
# issn: the save backup run.py makes before a start (scripts/save_backup.py); a player asked to
|
||||
# turn it off (Discord 11-01).
|
||||
env.pop('ISSN_SAVE_BACKUP', None)
|
||||
if not s.get('save_backups', True):
|
||||
env['ISSN_SAVE_BACKUP'] = '0'
|
||||
# issn-axy: FSR 3.1 frame generation (gpu issn_framegen.h); it needs FSR 4, XeSS or DLSS.
|
||||
env.pop('ISSN_FRAME_GEN', None)
|
||||
if s.get('frame_gen') and s.get('anti_aliasing', 'game') != 'game':
|
||||
if s.get('frame_gen') and s.get('anti_aliasing', 'game') not in ('game', 'off'):
|
||||
env['ISSN_FRAME_GEN'] = '1'
|
||||
env.pop('ISSN_STICK_DEADZONE', None)
|
||||
if s.get('stick_deadzone') in {value for value, _text in STICK_DEADZONE if value}:
|
||||
@@ -1134,7 +1210,7 @@ NEON_PINK = '#ff3ea5'
|
||||
NAV_ACTIVE, NAV_HOVER = '#16181f', '#111319' # side bar rows: the page shown, under the mouse
|
||||
# Segoe Fluent Icons / Segoe MDL2 Assets code points for the side bar (Windows only).
|
||||
NAV_ICONS = {'home': '\ue80f', 'news': '\ue789', 'graphics': '\ue7f4', 'performance': '\uec4a',
|
||||
'game': '\ue7fc', 'advanced': '\ue713', 'help': '\ue897'}
|
||||
'game': '\ue7fc', 'trophies': '\ue734', 'advanced': '\ue713', 'help': '\ue897'}
|
||||
FONT_FAMILIES = set() # filled once the window exists (pick_fonts)
|
||||
# issn_theme's colours (rounded widgets): fields, hovers and borders between PANEL and TEXT.
|
||||
THEME_COLORS = {'bg': '#09090c', 'panel': '#101116', 'card': '#181a21', 'field': '#14161d', 'card_hover': '#1d2029',
|
||||
@@ -1507,7 +1583,7 @@ class Launcher:
|
||||
self.nav, self.current_page = {}, None
|
||||
titles = {'home': _('Home'), 'news': _("What's new", 'Что нового'), 'graphics': _('Graphics', 'Графика'),
|
||||
'performance': _('Performance'),
|
||||
'game': _('Game', 'Игра'), 'advanced': _('Advanced', 'Дополнительно'), 'help': _('Help', 'Помощь')}
|
||||
'game': _('Game', 'Игра'), 'trophies': _('Achievements'), 'advanced': _('Advanced', 'Дополнительно'), 'help': _('Help', 'Помощь')}
|
||||
icon_font = ('Segoe Fluent Icons' if 'Segoe Fluent Icons' in FONT_FAMILIES else 'Segoe MDL2 Assets', 12)
|
||||
for name in PAGES:
|
||||
row = tk.Frame(side, bg=BG, cursor='hand2', height=self.px(42))
|
||||
@@ -1568,6 +1644,7 @@ class Launcher:
|
||||
self.build_graphics()
|
||||
self.build_performance()
|
||||
self.build_game()
|
||||
self.build_trophies()
|
||||
self.build_advanced()
|
||||
self.build_help()
|
||||
self.root.bind_all('<MouseWheel>', self.wheel)
|
||||
@@ -1858,6 +1935,7 @@ class Launcher:
|
||||
|
||||
def show_pc(self, pc):
|
||||
self.pc = pc
|
||||
self.fill_optiscaler_box()
|
||||
unknown = _('not recognised')
|
||||
lines = [_('Graphics card: {}').format(pc.gpu_name or unknown),
|
||||
_('Processor: {}').format(pc.cpu_name or unknown),
|
||||
@@ -1899,7 +1977,8 @@ class Launcher:
|
||||
_('Frame rate limit: {}').format(next((_(*text) for value, text in FPS_LIMITS
|
||||
if value == chosen['ini']['fps_limit']),
|
||||
chosen['ini']['fps_limit'])),
|
||||
_('Anti-aliasing: {}').format('FSR 4' if app['anti_aliasing'] == 'fsr4' else _("the game's own")),
|
||||
_('Anti-aliasing: {}').format('FSR 4' if app['anti_aliasing'] == 'fsr4' else _('Off')
|
||||
if app['anti_aliasing'] == 'off' else _("the game's own")),
|
||||
_('Performance mode: {}').format({'': _('off'), 'low': _('Balanced'), 'office': _('Maximum')}[app['perf_mode']]),
|
||||
_('Texture filtering: {}x').format(app['aniso'])]
|
||||
if goal == 'recommended':
|
||||
@@ -1952,6 +2031,16 @@ class Launcher:
|
||||
"game keeps its own.") if WINDOWS else
|
||||
_("Replaces the game's own temporal anti-aliasing. FSR 4 (included) needs a Radeon RX 7000 or "
|
||||
"9000 and a recent Mesa (RADV); without it the game keeps its own."), restart=True)
|
||||
self.note(f, _('Off: no anti-aliasing at all, the sharpest picture with jagged edges (no smearing in '
|
||||
'motion).'), top=2)
|
||||
# issn 11-04 (Discord poll): the game's own effects (gpu issn_postfx.h), also in the game's menu.
|
||||
self.section(f, _('Effects'))
|
||||
self.check(f, 'motion_blur', 'ini', _('Motion blur'),
|
||||
_('The blur when the camera or Delsin moves fast. Off gives a sharp picture in motion; '
|
||||
'it also changes at once in the game (Insert > Image).'))
|
||||
self.check(f, 'chromatic_aberration', 'ini', _('Chromatic aberration'),
|
||||
_('The red and blue fringes toward the edges of the screen. Turning it off helps players '
|
||||
'who feel sick with it; it also changes at once in the game (Insert > Image).'))
|
||||
if not WINDOWS: # DLSS (Streamline) is Windows only
|
||||
self.section(f, _('Overlay'))
|
||||
self.check(f, 'show_fps', 'ini', _('Show performance overlay'),
|
||||
@@ -1975,12 +2064,270 @@ class Launcher:
|
||||
"launcher downloads them from Intel's and NVIDIA's GitHub releases (XeSS SDK 3.0.2, about "
|
||||
"60 MB; Streamline 2.14.16, about 35 MB) when you pick one, and checks each file."), top=6)
|
||||
self.var('anti_aliasing', 'app').trace_add('write', lambda *_a: self.anti_aliasing_picked())
|
||||
self.build_optiscaler(f)
|
||||
self.section(f, _('Overlay'))
|
||||
self.check(f, 'show_fps', 'ini', _('Show performance overlay'),
|
||||
_('Items, size and corner: in-game menu (Insert / R3+L2) > Overlay.'))
|
||||
self.note(f, _('Screenshots: F12 (saved in the user\\screenshots folder).'), top=10)
|
||||
self.refresh_dlss_state()
|
||||
|
||||
# ---- OptiScaler (optional, a separate program; issn_downloads does the files) -------------
|
||||
def build_optiscaler(self, f):
|
||||
"""The OptiScaler section: its output, Download and install / Remove / How to use, the state."""
|
||||
ttk = self.ttk
|
||||
self.section(f, _('OptiScaler (optional)'))
|
||||
self.note(f, _('A separate program by the OptiScaler team that takes the port\'s XeSS input and turns it '
|
||||
'into another upscaler. Not part of the port: the launcher downloads it from the project\'s '
|
||||
'GitHub when you ask and can take it out again.'), top=2)
|
||||
var = self.var('optiscaler_output', 'app')
|
||||
self.optiscaler_box = ttk.Combobox(f, state='readonly', width=44)
|
||||
self.optiscaler_box.bind('<<ComboboxSelected>>', lambda _e: var.set(
|
||||
self.optiscaler_values[self.optiscaler_box.current()]))
|
||||
self.row(f, _('OptiScaler output'), self.optiscaler_box,
|
||||
_('What OptiScaler makes of the XeSS input. FSR 2.2 is the one tested with the port; FSR 4 '
|
||||
'goes through DirectX 12 and needs a Radeon RX 7000 or 9000. Written to OptiScaler.ini at '
|
||||
'once; it applies when the game starts.'), restart=True)
|
||||
self.fill_optiscaler_box()
|
||||
var.trace_add('write', lambda *_a: self.optiscaler_output_picked())
|
||||
holder = ttk.Frame(f)
|
||||
holder.grid(row=self.next_row(f), column=0, columnspan=2, sticky='w', pady=(10, 0))
|
||||
self.optiscaler_install_button = ttk.Button(holder, text=_('Download and install'),
|
||||
command=self.install_optiscaler)
|
||||
self.optiscaler_install_button.pack(side='left')
|
||||
self.optiscaler_remove_button = ttk.Button(holder, text=_('Remove'), command=self.remove_optiscaler)
|
||||
self.optiscaler_remove_button.pack(side='left', padx=6)
|
||||
ttk.Button(holder, text=_('How to use'), command=self.optiscaler_help).pack(side='left')
|
||||
self.optiscaler_label = ttk.Label(f, text='', style='Muted.TLabel', wraplength=self.px(680), justify='left')
|
||||
self.optiscaler_label.grid(row=self.next_row(f), column=0, columnspan=2, sticky='w', pady=(6, 0))
|
||||
self.optiscaler_busy = False
|
||||
self.var('anti_aliasing', 'app').trace_add('write', lambda *_a: self.refresh_optiscaler())
|
||||
self.refresh_optiscaler()
|
||||
|
||||
def fill_optiscaler_box(self):
|
||||
"""The outputs for this card (DLSS on NVIDIA RTX only); the choice stays when it is offered."""
|
||||
box = getattr(self, 'optiscaler_box', None)
|
||||
if box is None:
|
||||
return
|
||||
pc = getattr(self, 'pc', None)
|
||||
var = self.var('optiscaler_output', 'app')
|
||||
current = var.get()
|
||||
options = optiscaler_choices(pc)
|
||||
if current not in [key for key, _t in options]:
|
||||
if pc is None: # the card is not checked yet: the saved choice stays offered
|
||||
options = [option for option in OPTISCALER_CHOICES if option in options or option[0] == current]
|
||||
else:
|
||||
current = 'fsr22'
|
||||
self.optiscaler_values = [key for key, _t in options]
|
||||
box.configure(values=[_(*text) for _k, text in options])
|
||||
if current not in self.optiscaler_values:
|
||||
current = 'fsr22'
|
||||
box.current(self.optiscaler_values.index(current))
|
||||
if var.get() != current:
|
||||
var.set(current) # a choice this card cannot use (DLSS on a Radeon)
|
||||
|
||||
def optiscaler_text(self, state):
|
||||
if state is None:
|
||||
return _('Not installed.')
|
||||
if state['foreign']:
|
||||
return _('bin\\winmm.dll is another program, not OptiScaler: the launcher leaves it alone.')
|
||||
key = {'0x77': 'F8', '0x2d': 'Insert', '0x24': 'Home', 'auto': 'Insert'}.get(
|
||||
(state['menu_key'] or 'auto').lower(), state['menu_key'] or 'Insert')
|
||||
text = _('Installed: OptiScaler {} · output {} · its menu in the game: {}.').format(
|
||||
state['version'] or '?', optiscaler_output_name(state['upscaler']), key)
|
||||
if state['by_hand']:
|
||||
text += ' ' + _('Unpacked by hand: Remove takes its files out; Download and install replaces it '
|
||||
'with the launcher\'s, set for the port.')
|
||||
if key in ('Insert', 'Home'):
|
||||
text += ' ' + _('{} is also a key of the port: install it again from here to move its menu to F8.').format(key)
|
||||
if self.var('anti_aliasing', 'app').get() != 'xess':
|
||||
text += ' ' + _("⚠ The port's anti-aliasing is not XeSS, so OptiScaler gets nothing: choose XeSS in "
|
||||
'Anti-aliasing above.')
|
||||
return text
|
||||
|
||||
def refresh_optiscaler(self):
|
||||
label = getattr(self, 'optiscaler_label', None)
|
||||
if label is None or self.optiscaler_busy:
|
||||
return
|
||||
try:
|
||||
state = issn_downloads.optiscaler_state(optiscaler_bin())
|
||||
except OSError:
|
||||
state = None
|
||||
label.configure(text=self.optiscaler_text(state))
|
||||
installed = state is not None and not state['foreign']
|
||||
self.optiscaler_install_button.configure(
|
||||
text=_('Update / install again') if installed else _('Download and install'),
|
||||
state='disabled' if state and state['foreign'] else 'normal')
|
||||
self.optiscaler_remove_button.configure(state='normal' if installed else 'disabled')
|
||||
|
||||
def optiscaler_show(self, text):
|
||||
label = getattr(self, 'optiscaler_label', None)
|
||||
if label is not None:
|
||||
label.configure(text=text)
|
||||
if not self.process:
|
||||
self.status.configure(text=text, fg=TEXT)
|
||||
|
||||
def optiscaler_game_open(self):
|
||||
if self.process:
|
||||
self.messagebox.showinfo(APP_NAME, _('Close the game first: it is using the OptiScaler files.'))
|
||||
return True
|
||||
return False
|
||||
|
||||
def optiscaler_output_picked(self):
|
||||
"""A new output: into the installed OptiScaler.ini at once (DLSS: NVIDIA's files first)."""
|
||||
output = self.var('optiscaler_output', 'app').get()
|
||||
box = getattr(self, 'optiscaler_box', None)
|
||||
if box is not None and output in getattr(self, 'optiscaler_values', []):
|
||||
box.current(self.optiscaler_values.index(output)) # set from elsewhere (defaults)
|
||||
try:
|
||||
state = issn_downloads.optiscaler_state(optiscaler_bin())
|
||||
except OSError:
|
||||
state = None
|
||||
if not state or state['foreign'] or self.optiscaler_busy:
|
||||
return
|
||||
if output == 'dlss' and upscaler_missing('dlss'):
|
||||
self.download_upscaler('dlss', then=lambda: self.apply_optiscaler_output(output))
|
||||
return
|
||||
self.apply_optiscaler_output(output)
|
||||
|
||||
def apply_optiscaler_output(self, output):
|
||||
try:
|
||||
value = issn_downloads.set_output(optiscaler_bin(), output, dlss_folder() / 'nvngx_dlss.dll')
|
||||
except OSError as error:
|
||||
self.optiscaler_show(_('OptiScaler.ini could not be changed: {}').format(error))
|
||||
return
|
||||
self.refresh_optiscaler()
|
||||
self.status.configure(text=_('OptiScaler output: {} (the next time the game starts).').format(
|
||||
optiscaler_output_name(value)), fg=TEXT)
|
||||
|
||||
def install_optiscaler(self):
|
||||
"""Download and install: the newest stable release from the project's GitHub, set for the
|
||||
port (issn_downloads.install); then the port's anti-aliasing goes to XeSS."""
|
||||
if self.optiscaler_busy or self.optiscaler_game_open():
|
||||
return
|
||||
bin_dir = optiscaler_bin()
|
||||
if not self.messagebox.askyesno(APP_NAME, _(
|
||||
'OptiScaler is a separate program by the OptiScaler team (GPL-3.0), not part of the port.\n\n'
|
||||
'The launcher downloads its newest stable release from github.com/optiscaler/OptiScaler '
|
||||
'(about 55 MB), checks it and puts it in {}, set for the port: its menu on F8, the output you '
|
||||
"chose. The port's anti-aliasing is set to XeSS, which is what OptiScaler reads.\n\n"
|
||||
'Download and install it now?').format(bin_dir)):
|
||||
return
|
||||
self.optiscaler_busy = True
|
||||
self.optiscaler_install_button.configure(state='disabled')
|
||||
self.optiscaler_remove_button.configure(state='disabled')
|
||||
output = self.var('optiscaler_output', 'app').get()
|
||||
dlss_dll = dlss_folder() / 'nvngx_dlss.dll'
|
||||
|
||||
def progress(what):
|
||||
def report(done, total):
|
||||
percent = done * 100 // total if total else 0
|
||||
if percent != report.shown:
|
||||
report.shown = percent
|
||||
self.ui_calls.put(lambda p=percent: self.optiscaler_show(what.format(p)))
|
||||
report.shown = -1
|
||||
return report
|
||||
|
||||
def work():
|
||||
archive = None
|
||||
try:
|
||||
try:
|
||||
release = issn_downloads.optiscaler_release()
|
||||
except (OSError, ValueError):
|
||||
release = issn_downloads.OPTISCALER_KNOWN # GitHub's API unreachable: the tested one
|
||||
name = re.sub(r'[^A-Za-z0-9._-]', '_', release.url.rsplit('/', 1)[-1]) or 'optiscaler.7z'
|
||||
archive = Path(tempfile.gettempdir()) / 'issn-optiscaler' / name
|
||||
issn_downloads.download(release, archive, progress(_('Downloading OptiScaler {}… {{}}%')
|
||||
.format(release.tag)))
|
||||
issn_downloads.install(archive, bin_dir, output, release.tag,
|
||||
progress(_('Installing OptiScaler… {}%')), dlss_dll)
|
||||
except (OSError, ValueError) as error:
|
||||
self.ui_calls.put(lambda e=error: finish(e))
|
||||
return
|
||||
finally:
|
||||
if archive is not None:
|
||||
try:
|
||||
archive.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
self.ui_calls.put(lambda: finish(None, release.tag))
|
||||
|
||||
def finish(error, tag=''):
|
||||
self.optiscaler_busy = False
|
||||
self.refresh_optiscaler()
|
||||
if error:
|
||||
self.optiscaler_show(_('OptiScaler was not installed: {}').format(error))
|
||||
return
|
||||
if self.var('anti_aliasing', 'app').get() != 'xess':
|
||||
self.var('anti_aliasing', 'app').set('xess') # fetches XeSS's files when missing
|
||||
self.collect()
|
||||
if output == 'dlss' and upscaler_missing('dlss'):
|
||||
self.download_upscaler('dlss', then=lambda: self.apply_optiscaler_output('dlss'))
|
||||
self.refresh_optiscaler()
|
||||
if not self.process: # the bar still showed "Installing… 100%"
|
||||
self.status.configure(text=_('OptiScaler {} installed.').format(tag), fg=TEXT)
|
||||
self.messagebox.showinfo(APP_NAME, _(
|
||||
"OptiScaler {} is installed. The port's anti-aliasing is now XeSS.\n\nIn the game, F8 opens "
|
||||
"OptiScaler's menu (Insert stays the port's). Remove takes it out again.").format(tag))
|
||||
self.optiscaler_show(_('Looking for the newest OptiScaler…'))
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def remove_optiscaler(self):
|
||||
if self.optiscaler_busy or self.optiscaler_game_open():
|
||||
return
|
||||
if not self.messagebox.askyesno(APP_NAME, _(
|
||||
"Remove OptiScaler? Its files leave the bin folder and the port runs as before. The port's "
|
||||
'anti-aliasing stays XeSS; change it above if you want.')):
|
||||
return
|
||||
try:
|
||||
removed = issn_downloads.remove(optiscaler_bin())
|
||||
except OSError as error:
|
||||
self.refresh_optiscaler()
|
||||
self.messagebox.showerror(APP_NAME, _('OptiScaler was not removed completely: {}').format(error))
|
||||
return
|
||||
self.refresh_optiscaler()
|
||||
self.optiscaler_show(_('OptiScaler removed ({} files).').format(len(removed)))
|
||||
|
||||
def offer_optiscaler_xess(self):
|
||||
"""At Play: OptiScaler installed but the port's anti-aliasing is not XeSS (it then gets
|
||||
nothing). Asked once per launcher session; yes sets XeSS (its files download before the start)."""
|
||||
if not WINDOWS or self.app.get('anti_aliasing') == 'xess' or getattr(self, 'optiscaler_asked', False):
|
||||
return
|
||||
try:
|
||||
state = issn_downloads.optiscaler_state(optiscaler_bin())
|
||||
except OSError:
|
||||
return
|
||||
if not state or state['foreign']:
|
||||
return
|
||||
self.optiscaler_asked = True
|
||||
if self.messagebox.askyesno(APP_NAME, _(
|
||||
"OptiScaler is installed, but the port's anti-aliasing is not XeSS, so OptiScaler gets "
|
||||
'nothing and the game runs without it.\n\nSwitch the anti-aliasing to XeSS now?')):
|
||||
self.quiet_anti_aliasing = True # Play fetches XeSS's files itself, then starts
|
||||
try:
|
||||
self.var('anti_aliasing', 'app').set('xess')
|
||||
finally:
|
||||
self.quiet_anti_aliasing = False
|
||||
self.collect()
|
||||
|
||||
def optiscaler_help(self):
|
||||
if self.messagebox.askyesno(APP_NAME, _(
|
||||
'OptiScaler (optional)\n\n'
|
||||
"What it is: a separate program (OptiScaler team, GPL-3.0) that takes the picture, depth and "
|
||||
'motion the port gives XeSS and runs another upscaler on them: FSR 4, FSR 3.1, XeSS, DLSS or '
|
||||
'FSR 2.2.\n\n'
|
||||
"1. Download and install. The port's anti-aliasing must be XeSS (the launcher sets it): with "
|
||||
'anything else OptiScaler gets nothing.\n'
|
||||
'2. Choose the output here (it applies when the game starts). The size the game draws at is '
|
||||
"still the port's 'FSR 4 / DLSS quality'.\n"
|
||||
"3. In the game, F8 opens OptiScaler's menu (output, sharpness, debug views). Insert is still "
|
||||
"the port's menu and Home its anti-aliasing switch. Page Up, Page Down and End are OptiScaler's (its "
|
||||
"FPS counter and frame generation).\n"
|
||||
'4. To go back: Remove. Every file it added leaves the bin folder.\n\n'
|
||||
'Third-party: problems with OptiScaler itself go to its project, not to the port.\n'
|
||||
'Project: github.com/optiscaler/OptiScaler · License: GPL-3.0\n'
|
||||
'Open the project page (source code and license)?')):
|
||||
open_url(issn_downloads.OPTISCALER_PAGE)
|
||||
|
||||
def refresh_dlss_state(self):
|
||||
for key, label in getattr(self, 'vendor_state', {}).items():
|
||||
if key in self.upscaler_downloads:
|
||||
@@ -1993,6 +2340,8 @@ class Launcher:
|
||||
def anti_aliasing_picked(self):
|
||||
"""XeSS or DLSS chosen: their files download at once (not at Play)."""
|
||||
key = self.var('anti_aliasing', 'app').get()
|
||||
if getattr(self, 'quiet_anti_aliasing', False):
|
||||
return
|
||||
if key in UPSCALER_PACKAGES and WINDOWS and upscaler_missing(key):
|
||||
self.download_upscaler(key)
|
||||
|
||||
@@ -2093,6 +2442,10 @@ class Launcher:
|
||||
_('The game\'s own lower settings, from axylpup\'s 1.1: shorter detail distance, one shadow '
|
||||
'level less, lighter particles and fewer high-resolution textures nearby. Figures are '
|
||||
'from his PC in the heaviest city view.'), restart=True)
|
||||
self.row(f, _('Video memory limit'), self.choice(f, 'vram_limit', 'app', VRAM_LIMITS),
|
||||
_('How much video memory the game may fill before it frees textures it is not using. Lower it '
|
||||
'if the game closes with "Device lost" or the log says the video memory is nearly full, '
|
||||
'mainly on 4-8 GB cards and laptops.'), restart=True)
|
||||
self.row(f, _('Shader compilation'), self.choice(f, 'shader_compile', 'app', SHADER_COMPILE),
|
||||
_('The first time a place or effect appears its shaders are compiled. In the background the '
|
||||
'game keeps running and what they draw shows up a moment later; in the frame the game '
|
||||
@@ -2139,6 +2492,164 @@ class Launcher:
|
||||
def user_dir(self):
|
||||
return Path(self.var('user_dir', 'app').get() or DATA_DIR / 'user')
|
||||
|
||||
# ---- issn 11-04: achievements (the game's trophies, scripts/trophies.py) -----------------------
|
||||
def build_trophies(self):
|
||||
"""The game's trophies: icons from the player's own trophy file, which ones this PC unlocked
|
||||
(user/trophies.txt, written by the game) and when; names with the player's own key."""
|
||||
tk, ttk = self.tk, self.ttk
|
||||
f = self.scrolled_page('trophies', _('Achievements'),
|
||||
_("The game's trophies: the ones unlocked in this port are in color, with the date."))
|
||||
self.trophy_summary = ttk.Label(f, text='', wraplength=self.px(680), justify='left')
|
||||
self.trophy_summary.grid(row=self.next_row(f), column=0, columnspan=2, sticky='w', pady=(4, 0))
|
||||
self.trophy_progress = ttk.Progressbar(f, orient='horizontal', length=self.px(420), maximum=100)
|
||||
self.trophy_progress.grid(row=self.next_row(f), column=0, columnspan=2, sticky='w', pady=(6, 4))
|
||||
self.trophy_list = tk.Frame(f, bg=PANEL)
|
||||
self.trophy_list.grid(row=self.next_row(f), column=0, columnspan=2, sticky='we', pady=(8, 0))
|
||||
self.trophy_list.columnconfigure(0, weight=1)
|
||||
self.section(f, _('In the game'))
|
||||
self.check(f, 'trophy_note', 'ini', _('Trophy popup'),
|
||||
_("The trophy's icon and name in a top corner for a few seconds when the game unlocks one. "
|
||||
'Also in Insert > Overlay.'))
|
||||
self.section(f, _('Trophy names (optional)'))
|
||||
self.note(f, _("The names and descriptions in the game's trophy file are encrypted. Without a key the port "
|
||||
'uses its own list (English, Portuguese and Spanish, written from public trophy lists). '
|
||||
'If you have your own PS4 trophy key (the same one shadPS4 asks for), paste it here to see '
|
||||
"the game's own texts: it stays only on this PC, in user\\trophy-key.txt, and the port never "
|
||||
'ships or sends it. A name shows once its trophy is unlocked.'), top=4)
|
||||
holder = ttk.Frame(f)
|
||||
self.trophy_key_entry = ttk.Entry(holder, width=40, show='•')
|
||||
self.trophy_key_entry.pack(side='left')
|
||||
ttk.Button(holder, text=_('Save key'), command=self.save_trophy_key).pack(side='left', padx=(6, 0))
|
||||
ttk.Button(holder, text=_('Remove key'), command=self.remove_trophy_key).pack(side='left', padx=(6, 0))
|
||||
self.row(f, _('Trophy key'), holder)
|
||||
self.trophy_key_state = ttk.Label(f, text='', style='Muted.TLabel', wraplength=self.px(680), justify='left')
|
||||
self.trophy_key_state.grid(row=self.next_row(f), column=0, columnspan=2, sticky='w', pady=(4, 0))
|
||||
self.trophy_icons, self.trophy_cache = [], {}
|
||||
|
||||
def trophy_folders(self):
|
||||
return [self.var('update_dir', 'app').get(), self.var('game_dir', 'app').get()]
|
||||
|
||||
def trophy_texts(self, trp):
|
||||
"""({id: text} in the game language, a note on where the names come from): the game's own
|
||||
texts with the saved key, else the port's list (scripts/trophy_list.py)."""
|
||||
key = trophies.read_key(self.user_dir())
|
||||
language = game_language({**self.app, 'game_language': self.var('game_language', 'app').get()})
|
||||
cache = ('texts', str(trp), key, language)
|
||||
if cache not in self.trophy_cache:
|
||||
try:
|
||||
texts, source = trophies.game_texts(trp, language, *self.trophy_folders(), key=key)
|
||||
note = (_('Key saved on this PC: the names are the game\'s own.') if source == 'key' else
|
||||
_("No key: the names come from the port's own list.") if source == 'list' else
|
||||
_('No key: names are not shown.'))
|
||||
self.trophy_cache[cache] = (texts, note)
|
||||
except (OSError, ValueError, KeyError, trophies.ElementTree.ParseError) as error:
|
||||
self.trophy_cache[cache] = ({}, _('The key is saved but the names could not be read: {}').format(error))
|
||||
return self.trophy_cache[cache]
|
||||
|
||||
def trophy_icon(self, trp, entries, trophy_id, unlocked):
|
||||
"""The icon as a small PhotoImage: in color when unlocked, dim and grey when not."""
|
||||
cache = ('icon', str(trp), trophy_id, unlocked)
|
||||
if cache not in self.trophy_cache:
|
||||
photo = None
|
||||
data = trophies.icon(trp, entries, trophy_id)
|
||||
if data:
|
||||
try:
|
||||
import io
|
||||
from PIL import Image, ImageEnhance, ImageOps, ImageTk
|
||||
size = self.px(56)
|
||||
image = Image.open(io.BytesIO(data)).convert('RGBA').resize((size, size), Image.LANCZOS)
|
||||
if not unlocked:
|
||||
alpha = image.getchannel('A')
|
||||
image = ImageEnhance.Brightness(ImageOps.grayscale(image).convert('RGBA')).enhance(0.45)
|
||||
image.putalpha(alpha)
|
||||
backdrop = Image.new('RGBA', image.size, CARD)
|
||||
photo = ImageTk.PhotoImage(Image.alpha_composite(backdrop, image).convert('RGB'), master=self.root)
|
||||
except (ImportError, OSError, ValueError):
|
||||
photo = None
|
||||
self.trophy_cache[cache] = photo
|
||||
return self.trophy_cache[cache]
|
||||
|
||||
def refresh_trophies(self):
|
||||
tk = self.tk
|
||||
for child in self.trophy_list.winfo_children():
|
||||
child.destroy()
|
||||
self.trophy_icons = []
|
||||
trp, ids, unlocked = trophies.summary(self.user_dir(), *self.trophy_folders())
|
||||
texts, key_note = self.trophy_texts(trp) if trp else ({}, '')
|
||||
self.trophy_key_state.configure(text=key_note)
|
||||
if not trp:
|
||||
self.trophy_summary.configure(text=_('No trophy file in the game folder (sce_sys\\trophy\\trophy00.trp, '
|
||||
'or Sc0\\trophy beside it). Choose the game folder on the Game page.'))
|
||||
self.trophy_progress.configure(value=0)
|
||||
return
|
||||
entries = trophies.read_entries(trp)
|
||||
done = sum(1 for i in ids if i in unlocked)
|
||||
percent = round(100 * done / len(ids)) if ids else 0
|
||||
platinum = next((i for i, t in texts.items() if t['grade'] == 'P'), 0 if ids and ids[0] == 0 else None)
|
||||
line = _('{} of {} unlocked ({}%)').format(done, len(ids), percent)
|
||||
if platinum is not None and platinum in unlocked:
|
||||
line += ' · ' + _('Platinum! Every trophy unlocked')
|
||||
self.trophy_summary.configure(text=line)
|
||||
self.trophy_progress.configure(value=percent)
|
||||
grades = {'P': (_('Platinum'), '#c8e6ff'), 'G': (_('Gold'), '#ffcc4d'), 'S': (_('Silver'), '#d2d6e0'),
|
||||
'B': (_('Bronze'), '#db8f5c')}
|
||||
# Unlocked first, newest first; then the others in the game's order.
|
||||
newest = sorted((i for i in ids if i in unlocked), key=lambda i: (unlocked[i], i), reverse=True)
|
||||
for row, trophy_id in enumerate(newest + [i for i in ids if i not in unlocked]):
|
||||
got = trophy_id in unlocked
|
||||
text = texts.get(trophy_id, {})
|
||||
card = tk.Frame(self.trophy_list, bg=CARD, highlightthickness=1, highlightbackground=LINE)
|
||||
card.grid(row=row, column=0, sticky='we', pady=(0, self.px(4)))
|
||||
card.columnconfigure(1, weight=1)
|
||||
photo = self.trophy_icon(trp, entries, trophy_id, got)
|
||||
if photo:
|
||||
self.trophy_icons.append(photo)
|
||||
tk.Label(card, image=photo, bg=CARD, bd=0).grid(row=0, column=0, rowspan=2, padx=self.px(8),
|
||||
pady=self.px(6))
|
||||
# issn 11-05 (João): a trophy's name and description show once it is unlocked; until
|
||||
# then only its number, icon and grade.
|
||||
name = text.get('name') if got and text.get('name') else _('Trophy {}').format(trophy_id)
|
||||
tk.Label(card, text=name, bg=CARD, fg=TEXT if got else MUTED, font=(UI_FONT, 11, 'bold'),
|
||||
anchor='w').grid(row=0, column=1, sticky='w', pady=(self.px(8), 0))
|
||||
detail = text.get('detail', '') if got else ''
|
||||
when = _('Unlocked {}').format(unlocked[trophy_id]) if got else _('Locked')
|
||||
tk.Label(card, text=f'{detail}\n{when}' if detail else when, bg=CARD, fg=MUTED, font=(UI_FONT, 9),
|
||||
anchor='w', justify='left', wraplength=self.px(520)).grid(row=1, column=1, sticky='w',
|
||||
pady=(0, self.px(8)))
|
||||
grade = grades.get(text.get('grade') or ('P' if trophy_id == platinum else ''))
|
||||
if grade:
|
||||
tk.Label(card, text=grade[0], bg=CARD, fg=grade[1] if got else MUTED,
|
||||
font=(UI_FONT, 9, 'bold')).grid(row=0, column=2, rowspan=2, padx=self.px(12))
|
||||
|
||||
def save_trophy_key(self):
|
||||
key = trophies.parse_key(self.trophy_key_entry.get())
|
||||
if key is None:
|
||||
self.messagebox.showerror(APP_NAME, _('A trophy key is 32 hexadecimal digits (0-9, A-F).'))
|
||||
return
|
||||
trp = trophies.trophy_file(*self.trophy_folders())
|
||||
if trp: # a key that does not open this game's file is not kept
|
||||
try:
|
||||
trophies.read_texts(trp, trophies.npbind_file(*self.trophy_folders()), key, '1')
|
||||
except (OSError, ValueError, KeyError, trophies.ElementTree.ParseError) as error:
|
||||
self.messagebox.showerror(APP_NAME, _('This key does not open the trophy names: {}').format(error))
|
||||
return
|
||||
try:
|
||||
trophies.save_key(self.user_dir(), key)
|
||||
except OSError as error:
|
||||
self.messagebox.showerror(APP_NAME, _('Could not save the key: {}').format(error))
|
||||
return
|
||||
self.trophy_key_entry.delete(0, 'end')
|
||||
self.refresh_trophies()
|
||||
|
||||
def remove_trophy_key(self):
|
||||
try:
|
||||
trophies.save_key(self.user_dir(), None)
|
||||
(self.user_dir() / trophies.NAMES_FILE).unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
self.messagebox.showerror(APP_NAME, _('Could not remove the key: {}').format(error))
|
||||
return
|
||||
self.refresh_trophies()
|
||||
|
||||
def build_save_backups(self, f):
|
||||
"""run.py backs the saves up before every start; one of them can be restored here."""
|
||||
ttk = self.ttk
|
||||
@@ -2147,6 +2658,9 @@ class Launcher:
|
||||
self.backup_choice.pack(side='left')
|
||||
ttk.Button(holder, text=_('Restore', 'Восстановить'), command=self.restore_backup).pack(side='left', padx=(6, 0))
|
||||
ttk.Button(holder, text=_('Refresh', 'Обновить'), command=self.refresh_backups).pack(side='left', padx=(6, 0))
|
||||
self.check(f, 'save_backups', 'app', _('Back up saves before a start'),
|
||||
_('A copy of your saves before each start when they changed (kept in '
|
||||
'user\\savedata_backups). Off: no new copies; the old ones stay and can still be restored.'))
|
||||
self.row(f, _('Save backups', 'Резервные копии'), holder,
|
||||
_('Made before every start when the saves changed, newest first. Kept: the last {}, one a '
|
||||
'day for {} days and the ones made before a restore or an import. Restoring backs up the '
|
||||
@@ -2306,7 +2820,7 @@ class Launcher:
|
||||
self.row(f, _('Controller without touchpad'), self.choice(f, 'pad_emulation', 'app', PAD_EMULATION),
|
||||
_('For Xbox, 8BitDo and other pads without a touchpad or motion sensors. Touchpad click: '
|
||||
'Select/Back (with L1: the left side). Swipe: hold Select/Back and push the right stick. '
|
||||
'Two fingers: hold Select/Back + RB. Spray can: L3 + R3 together tilts the pad, LB '
|
||||
'Two fingers: hold Select/Back + RB. Spray can: Insert > Controls > Spray can tilts the pad, LB '
|
||||
'rattles it, the right stick aims. Automatic turns it off on a DualShock 4 or DualSense.'),
|
||||
restart=True)
|
||||
self.row(f, _('Button icons'), self.choice(f, 'glyph_style', 'app', GLYPH_STYLES, width=44),
|
||||
@@ -2314,12 +2828,22 @@ class Launcher:
|
||||
'(they follow your key changes above) and the mouse buttons; Automatic picks the keyboard '
|
||||
'when no controller is connected at start, Xbox letters for Xbox and other pads.'),
|
||||
restart=True)
|
||||
# issn 11-04: the same sensitivities as the in-game menu (Insert > Controls), in issn.ini.
|
||||
for key, title, low, high, hint in (
|
||||
('pad_sensitivity', _('Controller sensitivity'), 0.25, 3.0,
|
||||
_('The right stick (camera). Below 1: slower. Above 1: small stick moves turn the camera more. '
|
||||
'Also in the game: Insert > Controls.')),
|
||||
('mouse_sensitivity', _('Mouse sensitivity'), 0.1, 5.0,
|
||||
_('How far the camera turns per mouse movement. Also in the game: Insert > Controls, or - / =.'))):
|
||||
spin = self.ttk.Spinbox(f, from_=low, to=high, increment=0.05, format='%.2f', width=8,
|
||||
textvariable=self.var(key, 'ini'))
|
||||
self.row(f, title, spin, hint)
|
||||
self.row(f, _('Stick dead zone'), self.choice(f, 'stick_deadzone', 'app', STICK_DEADZONE),
|
||||
_('How far a stick must move before the game sees it. Raise it if Delsin walks or the '
|
||||
'camera turns on its own while you do not touch the stick (drift).'),
|
||||
restart=True)
|
||||
self.row(f, _('Spray can stick speed'), self.choice(f, 'spray_stick', 'app', SPRAY_STICK),
|
||||
_('How fast the right stick turns the spray can while the pad is tilted (L3 + R3). A light '
|
||||
_('How fast the right stick turns the spray can while the pad is tilted (Insert > Controls > Spray can). A light '
|
||||
'push moves it a little, a full push at this speed. In the game, - / = change it too.'),
|
||||
restart=True)
|
||||
self.note(f, _('Keyboard and mouse: WASD moves, IJKL turns the camera. Click in the game window and the '
|
||||
@@ -2895,6 +3419,7 @@ class Launcher:
|
||||
return
|
||||
if not self.offer_system_files():
|
||||
return
|
||||
self.offer_optiscaler_xess()
|
||||
# XeSS / DLSS chosen without their files: fetched first, then the game starts (with the
|
||||
# game's own anti-aliasing if the download failed: the GPU library falls back).
|
||||
upscaler = self.app.get('anti_aliasing')
|
||||
@@ -2930,6 +3455,16 @@ class Launcher:
|
||||
# file itself (its output goes straight to the file, which outlives the launcher).
|
||||
# Tester 2026-10-23: three runs in that mode left last_run.log at an older run.
|
||||
direct_log = open_direct_log(self.app) if self.app.get('close_on_play') else None
|
||||
first_line = optiscaler_log_line(self.app) # OptiScaler in bin\: said at the top of the log
|
||||
if first_line:
|
||||
self.append(first_line)
|
||||
run_log.write(first_line)
|
||||
if direct_log:
|
||||
try:
|
||||
direct_log.write(first_line.encode('utf-8'))
|
||||
direct_log.flush()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
self.process = subprocess.Popen(run_command(), cwd=PORT_DIR, env=game_environment(self.app),
|
||||
stdin=subprocess.DEVNULL,
|
||||
@@ -3389,6 +3924,9 @@ def play_without_window(settings):
|
||||
when there is one and to <saves folder>/last_run.log."""
|
||||
attach_stdio()
|
||||
with open_run_log(settings) as log:
|
||||
first_line = optiscaler_log_line(settings)
|
||||
if first_line:
|
||||
log.write(first_line)
|
||||
process = subprocess.Popen(run_command(), cwd=PORT_DIR, env=game_environment(settings),
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, creationflags=NO_WINDOW)
|
||||
|
||||
@@ -56,7 +56,7 @@ Reporting problems
|
||||
each thread was doing.
|
||||
|
||||
Credits: axylpup's shadPS4 build for inFAMOUS Second Son (graphics), earlier inFAMOUS work by
|
||||
Ayoub and HayatoG, the God of War III Remastered port (gow3_pc, Felipe Prado) and bloodborne_pc
|
||||
Ayoub and HayatoG (iss_pc, github.com/HayatoG/iss_pc: video core, draw distance, sensor mode), the God of War III Remastered port (gow3_pc, Felipe Prado) and bloodborne_pc
|
||||
(deadinside28, Supermedo) it is built on, the shadPS4 project. FSR 4: AMD FidelityFX (MIT).
|
||||
Launcher title face: Bebas Neue (SIL Open Font License). Source: github.com/Liondarkz/issn_pc
|
||||
(GPL-2.0-or-later).
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"version": "0.3.7",
|
||||
"en": {
|
||||
"sub": "PC PORT / UPDATE 0.3.7",
|
||||
"hero": "THE CITY LIGHTS UP",
|
||||
"hero_sub": "Saves stuck in a pitch-black Seattle now light up by themselves: the port spots the time of day that never loads and swaps in a similar one.",
|
||||
"hero_pts": [
|
||||
"Black city fix: on by itself after 30 seconds",
|
||||
"4K: no more dark picture that comes and goes"
|
||||
],
|
||||
"shot": "BLACK CITY FIXED",
|
||||
"new": "WHAT'S NEW",
|
||||
"cards": [
|
||||
[
|
||||
"gold",
|
||||
"Achievements",
|
||||
"Trophy popups with icons, a page in the launcher."
|
||||
],
|
||||
[
|
||||
"neon",
|
||||
"New cheats",
|
||||
"No damage, infinite missiles, Karma Bomb ready."
|
||||
],
|
||||
[
|
||||
"pink",
|
||||
"Field of view",
|
||||
"Wider view in the menu, the aim follows it."
|
||||
],
|
||||
[
|
||||
"green",
|
||||
"New loading screen",
|
||||
"The shader screen got art, a bar and tips."
|
||||
],
|
||||
[
|
||||
"red",
|
||||
"OptiScaler built in",
|
||||
"Install, remove and set it in the launcher."
|
||||
],
|
||||
[
|
||||
"neon",
|
||||
"Anti-aliasing Off",
|
||||
"No T2x blur and a still camera."
|
||||
]
|
||||
],
|
||||
"also": "ALSO",
|
||||
"also_pts": [
|
||||
"Dash cheats: endless neon run, video flight, long smoke",
|
||||
"Mouse and pad sensitivity; F11 hides the overlay"
|
||||
],
|
||||
"next": "COMING NEXT",
|
||||
"next_tag": "planned for the next updates",
|
||||
"next_pts": [
|
||||
"Fix for the NVIDIA crash in the opening village",
|
||||
"Turn off motion blur and chromatic aberration",
|
||||
"Sharper FSR 4, DLSS and XeSS",
|
||||
"Save now button (Ctrl + Shift + S)"
|
||||
],
|
||||
"disc": "JOIN THE DISCORD",
|
||||
"disc_sub": "News, help and test builds"
|
||||
},
|
||||
"pt": {
|
||||
"sub": "PORT PARA PC / ATUALIZAÇÃO 0.3.7",
|
||||
"hero": "A CIDADE ACENDEU",
|
||||
"hero_sub": "Saves presos numa Seattle toda preta agora acendem sozinhos: o port percebe o horário que nunca carrega e troca por um parecido.",
|
||||
"hero_pts": [
|
||||
"Correção da cidade preta: age sozinha em 30 s",
|
||||
"4K: acabou a tela que escurece e volta"
|
||||
],
|
||||
"shot": "CIDADE PRETA CORRIGIDA",
|
||||
"new": "NOVIDADES",
|
||||
"cards": [
|
||||
[
|
||||
"gold",
|
||||
"Conquistas",
|
||||
"Troféus com ícone na tela e página no launcher."
|
||||
],
|
||||
[
|
||||
"neon",
|
||||
"Cheats novos",
|
||||
"Sem dano, mísseis infinitos, Karma Bomb pronta."
|
||||
],
|
||||
[
|
||||
"pink",
|
||||
"Campo de visão",
|
||||
"Visão mais aberta no menu, a mira acompanha."
|
||||
],
|
||||
[
|
||||
"green",
|
||||
"Tela de carregamento",
|
||||
"A tela dos shaders ganhou arte, barra e dicas."
|
||||
],
|
||||
[
|
||||
"red",
|
||||
"OptiScaler no launcher",
|
||||
"Instale, remova e configure pelo launcher."
|
||||
],
|
||||
[
|
||||
"neon",
|
||||
"Anti-aliasing Off",
|
||||
"Sem o borrão do T2x e câmera parada."
|
||||
]
|
||||
],
|
||||
"also": "E MAIS",
|
||||
"also_pts": [
|
||||
"Cheats de dash: neon sem fim, voo do vídeo, fumaça longa",
|
||||
"Sensibilidade do mouse e controle; F11 esconde o overlay"
|
||||
],
|
||||
"next": "PRÓXIMA ATUALIZAÇÃO",
|
||||
"next_tag": "planejado para as próximas versões",
|
||||
"next_pts": [
|
||||
"Correção do crash da NVIDIA na vila do começo",
|
||||
"Desligar motion blur e aberração cromática",
|
||||
"FSR 4, DLSS e XeSS mais nítidos",
|
||||
"Botão Salvar agora (Ctrl + Shift + S)"
|
||||
],
|
||||
"disc": "ENTRE NO DISCORD",
|
||||
"disc_sub": "Novidades, ajuda e builds de teste"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
inFAMOUS Second Son for PC (issn_pc) - TEST BUILD 0.3.6.3
|
||||
========================================================
|
||||
inFAMOUS Second Son for PC (issn_pc) - TEST BUILD 0.3.7
|
||||
======================================================
|
||||
|
||||
A test build: the game plays from the start, at about 90-105 FPS standing in the open city on a
|
||||
Ryzen 7 3700X + RX 9060 XT. The first start prepares the shaders once (a few minutes, with a
|
||||
@@ -28,6 +28,55 @@ XeSS runs on Intel Arc, Radeon RX 5000 and later and GeForce GTX 10 and later; D
|
||||
NVIDIA RTX card. XeSS needs the Visual C++ 2015-2022 runtime (vc_redist.x64). Without the files
|
||||
the game keeps its own anti-aliasing.
|
||||
|
||||
OptiScaler (optional, third-party)
|
||||
OptiScaler (https://github.com/optiscaler/OptiScaler, GPL-3.0) is a separate program by its own
|
||||
team, not part of the port and never in this zip. It takes the picture, depth and motion the port
|
||||
gives XeSS and runs another upscaler on them: FSR 4 (Radeon RX 7000 / 9000, through DirectX 12),
|
||||
FSR 3.1, XeSS, DLSS (NVIDIA RTX) or FSR 2.2 (the one tested with the port).
|
||||
- Install: Graphics > OptiScaler (optional) > Download and install. The launcher downloads the
|
||||
newest stable release from the project's GitHub (about 55 MB), checks it and puts it in bin\:
|
||||
OptiScaler.dll as winmm.dll, its DLLs beside it, OptiScaler.ini set for the port.
|
||||
- The port's anti-aliasing must be XeSS (Graphics > Anti-aliasing; the install sets it): with
|
||||
anything else OptiScaler gets nothing. The size the game draws at is still the port's
|
||||
FSR 4 / DLSS quality.
|
||||
- Output: Graphics > OptiScaler output, written to bin\OptiScaler.ini (VulkanUpscaler); it applies
|
||||
when the game starts.
|
||||
- In the game, F8 opens OptiScaler's menu. Insert stays the port's menu and Home its anti-aliasing
|
||||
switch (a copy unpacked by hand uses Insert: install it from the launcher to move it to F8).
|
||||
Page Up, Page Down and End are OptiScaler's own keys (its FPS counter and frame generation).
|
||||
- Remove: Graphics > OptiScaler (optional) > Remove takes out every file it put (listed in
|
||||
bin\optiscaler.json) and the port runs as before. Close the game first.
|
||||
- last_run.log starts with a line when OptiScaler is installed. Problems with OptiScaler itself
|
||||
go to its project.
|
||||
|
||||
What's new in 0.3.7
|
||||
- Black city fix: in some saves the game asks for a time of day (the grey "dismal" sky) whose part
|
||||
of the map never finishes loading, so no sky or ambient light is in use and the city stays
|
||||
black. After 30 seconds the port asks the game for a similar one (rainy "blustery") and the city
|
||||
lights up by itself. Insert > Image > Exposure shows what it did (ISSN_TOD_RESCUE=0 turns it off).
|
||||
- 4K: the game's exposure meter gets room for 4K, so the picture no longer goes dark and back.
|
||||
- Achievements: the game's trophies pop up with their icon, are kept on this PC
|
||||
(user\trophies.txt) and the launcher has an Achievements page with the percentage.
|
||||
- Field of view in the menu, with the aim following it (on by default).
|
||||
- The port's own shader loading screen (art, progress bar, tips).
|
||||
- Cheats (Game tab): infinite health now also stops the hits themselves (no gray screen), a new
|
||||
"Infinite missiles" switch (God Mode turns it on too), the Karma Bomb stays ready; endless neon
|
||||
run and video flight while the dash button is held, long smoke dash.
|
||||
- Anti-aliasing "Off" (launcher and menu): no T2x and a still camera.
|
||||
- OptiScaler (optional): install, remove and choose its output from the launcher (see above).
|
||||
- In-game menu: VSync and the frame rate limit change at once; Apply and restart starts the
|
||||
anti-aliasing, quality and frame generation chosen there; a note says when the launcher's frame
|
||||
pacing caps the game.
|
||||
- Controls: mouse and controller sensitivity (menu and launcher); F11 hides the performance
|
||||
overlay; L3 + R3 always reach the game, the spray can pose of pads without a motion sensor is a
|
||||
menu switch (and the H key).
|
||||
- Video memory limit in the launcher (Performance), for cards of 6 GB or less.
|
||||
- FSR 4 only turns on with AMD Radeon cards.
|
||||
- Save backup: a switch in the launcher, and none when the savedata folder is far bigger than the
|
||||
game's saves.
|
||||
- A crash right after the start with a game folder whose all_shaders.xpps does not match the
|
||||
01.07 eboot.bin now says so in the log.
|
||||
|
||||
What's new in 0.3.6.3
|
||||
- In-game menu (Insert, or R3 + L2 on a controller), Image tab: draw distance, texture filtering
|
||||
and frame generation change at once; anti-aliasing and upscaler quality are chosen there for the
|
||||
@@ -214,7 +263,7 @@ a folder that contains eboot.bin, sce_module and sce_sys. How to dump it: docs/i
|
||||
Built on the God of War III Remastered port (gow3_pc, by Felipe Prado), which comes from
|
||||
bloodborne_pc (deadinside28, Supermedo). Graphics: axylpup's shadPS4 build for inFAMOUS Second Son
|
||||
(github.com/axylpup/shadPS4), from the shadPS4 video core, with earlier work by Ayoub
|
||||
(shadPS4-AYOUB1080p) and HayatoG (iss_pc). FSR 4: AMD FidelityFX (MIT). Launcher title face: Bebas
|
||||
(shadPS4-AYOUB1080p) and HayatoG (iss_pc, github.com/HayatoG/iss_pc: video core, draw distance, sensor mode). FSR 4: AMD FidelityFX (MIT). Launcher title face: Bebas
|
||||
Neue (SIL Open Font License).
|
||||
|
||||
Requirements
|
||||
@@ -233,8 +282,8 @@ Controller
|
||||
light bar work as on the PS4.
|
||||
- Pads without a touchpad or motion sensors (Xbox, 8BitDo, ...), on by itself for them (launcher:
|
||||
Game > Controls > Controller without touchpad):
|
||||
- Spray can: L3 + R3 together tilts the pad like the spray can; L1 rattles it, the right
|
||||
stick aims (launcher: Spray can stick speed; - / = in the game). L3 + R3 again levels it.
|
||||
- Spray can: Insert > Controls > "Spray can: tilt the pad" tilts it like the spray can; L1 rattles it, the right
|
||||
stick aims (launcher: Spray can stick speed; - / = in the game). Turn the switch off to level it (H on the keyboard does both).
|
||||
- Touchpad swipe (lift, push): hold Select/Back and push the right stick that way.
|
||||
- Two fingers on the touchpad (lifting the tree): hold Select/Back + RB (R1).
|
||||
- Touchpad click: Select/Back.
|
||||
|
||||
@@ -29,10 +29,21 @@
|
||||
<Line Type="bytes" Address="0x0072fa15" Value="E9A65C99006690" OriginalHash="3ee41d36233286e4"/>
|
||||
</PatchList>
|
||||
</Metadata>
|
||||
<Metadata Title="inFAMOUS Second Son" Name="Bigger light list (needed for native 4K)" Note="The game sizes its light lists for 25 600 tiles of 16 pixels (enough up to 1800p); 4K needs 32 400. FR : liste des lumieres agrandie, necessaire pour la 4K native." Author="AYOUB1080p" PatchVer="1.0" AppVer="01.00" AppElf="eboot.bin" isEnabled="true">
|
||||
<Metadata Title="inFAMOUS Second Son" Name="Bigger light list (needed for native 4K)" Note="The game sizes its light lists for 25 600 tiles of 16 pixels (enough up to 1800p); 4K needs 32 400. Also the exposure meter (625 tiles of 128x72 px; 4K needs 900, else the picture goes dark). FR : liste des lumieres agrandie, necessaire pour la 4K native." Author="AYOUB1080p" PatchVer="1.0" AppVer="01.00" AppElf="eboot.bin" isEnabled="true">
|
||||
<PatchList>
|
||||
<Line Type="bytes" Address="0x0056be07" Value="00004800" OriginalHash="4cf5e27f9da5a7b7"/>
|
||||
<Line Type="bytes" Address="0x0056be1f" Value="00004800" OriginalHash="4cf5e27f9da5a7b7"/>
|
||||
<!-- issn 11-04: the exposure meter's tile buffers (four, 2500 bytes / 625 tiles of 128x72 px:
|
||||
enough up to 1800p; 4K needs 900, so the camera's exposure read garbage past them and
|
||||
the picture went dark and back). 8192 bytes / 2048 tiles each. -->
|
||||
<Line Type="bytes" Address="0x0057132b" Value="00200000" OriginalHash="05de299f0f9c851e"/>
|
||||
<Line Type="bytes" Address="0x00571375" Value="00200000" OriginalHash="05de299f0f9c851e"/>
|
||||
<Line Type="bytes" Address="0x00572341" Value="00200000" OriginalHash="05de299f0f9c851e"/>
|
||||
<Line Type="bytes" Address="0x00572388" Value="00200000" OriginalHash="05de299f0f9c851e"/>
|
||||
<Line Type="bytes" Address="0x0057134f" Value="00080000" OriginalHash="bd7e02077b7bafea"/>
|
||||
<Line Type="bytes" Address="0x00571399" Value="00080000" OriginalHash="bd7e02077b7bafea"/>
|
||||
<Line Type="bytes" Address="0x00572364" Value="00080000" OriginalHash="bd7e02077b7bafea"/>
|
||||
<Line Type="bytes" Address="0x005723ab" Value="00080000" OriginalHash="bd7e02077b7bafea"/>
|
||||
</PatchList>
|
||||
</Metadata>
|
||||
<Metadata Title="inFAMOUS Second Son" Name="Streaming: closest city blocks first" Note="Lower priority penalties of the world block streamer (3000/2000/2000 -> 1000/700/700): the blocks nearest to the player are loaded and kept in high detail sooner. FR : streaming, les blocs de la ville les plus proches passent en priorite." Author="AYOUB1080p" PatchVer="1.0" AppVer="01.00" AppElf="eboot.bin" isEnabled="false">
|
||||
|
||||
@@ -257,6 +257,16 @@ def main():
|
||||
if key not in env:
|
||||
env[key] = value
|
||||
derived.append(key)
|
||||
# issn-axy: a restart from the in-game menu starts its anti-aliasing / quality / frame gen.
|
||||
ini = graphics_options.read_ini(env['ISSN_CONFIG'])
|
||||
if ini.get('restart_unconfirmed') == '1' and not reverted:
|
||||
menu_env, menu_off = graphics_options.menu_environment(ini, WINDOWS)
|
||||
for key in menu_off:
|
||||
env.pop(key, None)
|
||||
env.update(menu_env)
|
||||
if menu_env or menu_off:
|
||||
print('Graphics: the in-game menu\'s anti-aliasing for this restart: '
|
||||
+ (', '.join(f'{k}={v}' for k, v in menu_env.items()) or "the game's own"), flush=True)
|
||||
if not env.get('ISSN_FSR411_DIR') and not (PORT / 'fsr4_411').is_dir() and (data / 'fsr4_411').is_dir():
|
||||
env['ISSN_FSR411_DIR'] = str(data / 'fsr4_411')
|
||||
|
||||
@@ -290,6 +300,14 @@ def main():
|
||||
game = update_view
|
||||
print(f'Game and update combined from {env["ISSN_PATCH_DIR"]}', flush=True)
|
||||
original_game = game.resolve()
|
||||
# issn 11-05 (#33): the shader package the game reads first; its size and the folder it really
|
||||
# comes from (the update or the base game) tell a mismatched dump at a glance.
|
||||
shaders = game / 'art' / 'cache' / 'all_shaders.xpps'
|
||||
try:
|
||||
print(f'Game data: all_shaders.xpps {shaders.stat().st_size:,} bytes from {shaders.resolve().parent.parent.parent}',
|
||||
flush=True)
|
||||
except OSError as error:
|
||||
print(f'Game data: art/cache/all_shaders.xpps missing or unreadable ({error})', flush=True)
|
||||
# issn_pc: the port is made for the 01.07 update (code patches, mouse hook, seed). A base 01.00
|
||||
# dump ends in an unsupported system call after the intro (sceKernelLoadStartModule, tester log
|
||||
# 10-24) - say so here instead. ISSN_ALLOW_OLD_VERSION=1 tries anyway.
|
||||
@@ -351,6 +369,12 @@ def main():
|
||||
env.setdefault('ISSN_WINDOW_ICON', str(out / 'window_icon.bmp'))
|
||||
except (ImportError, OSError):
|
||||
pass
|
||||
# issn-axy: the shader loading screen shows the dump's own background art (read from the
|
||||
# player's game folder at run time, never shipped with the port).
|
||||
for art in (game / 'sce_sys' / 'pic1.png', original_game / 'sce_sys' / 'pic1.png'):
|
||||
if art.is_file():
|
||||
env.setdefault('ISSN_LOADING_ART', str(art))
|
||||
break
|
||||
# The temporal upscalers and motion vectors are not calibrated for this game yet: they stay
|
||||
# off unless the launcher's experimental switch is on (then issn.ini decides).
|
||||
if env.get('ISSN_EXPERIMENTAL_UPSCALER') != '1':
|
||||
@@ -446,9 +470,18 @@ def main():
|
||||
print(f'Shader cache: {cache_name}' + ('' if seed_name else
|
||||
f" (shader-seed.txt {'names ' + listed + ', which is missing' if listed else 'is missing'})")
|
||||
+ (f"; other cache folders: {', '.join(others)}" if others else ''), flush=True)
|
||||
# issn 11-04: the game's trophy file (icons for the popup, read from the player's dump) and,
|
||||
# with the player's own trophy key, the names (scripts/trophies.py).
|
||||
import trophies
|
||||
try:
|
||||
print(trophies.prepare(env, user_dir), flush=True)
|
||||
except OSError as error:
|
||||
print(f'Trophies: {error}', flush=True)
|
||||
from save_backup import backup
|
||||
try:
|
||||
if made := backup(user_dir):
|
||||
if env.get('ISSN_SAVE_BACKUP') == '0':
|
||||
print('Save backup: off (launcher)', flush=True)
|
||||
elif made := backup(user_dir):
|
||||
print(f'Saves backed up to {made}', flush=True)
|
||||
except OSError as error:
|
||||
print(f'Save backup failed, starting anyway: {error}', flush=True)
|
||||
|
||||
@@ -129,3 +129,36 @@ def environment(options):
|
||||
return {'ISSN_DEFERRED_READBACK': options['deferred_readback'],
|
||||
'ISSN_STALE_READBACK': options['stale_readback'],
|
||||
'ISSN_ASYNC_SHADERS': options['async_shaders']}
|
||||
|
||||
|
||||
# issn-axy (tester 10-31): "Apply and restart" in the menu also starts the anti-aliasing, the
|
||||
# upscaler quality and frame generation chosen there (menu_* keys, the launcher's values). The
|
||||
# launcher's own start keeps its environment; a menu restart (restart_unconfirmed=1) takes these.
|
||||
AA_SWITCHES = ('SHADPS4_DLSS', 'SHADPS4_DLSS_UPSCALE', 'ISSN_FSR4', 'ISSN_XESS', 'ISSN_AA_OFF')
|
||||
UPSCALER_SWITCHES = AA_SWITCHES[:4] # frame generation needs one of these
|
||||
UPSCALE_FACTORS = ('1.5', '1.7', '2', '3')
|
||||
|
||||
|
||||
def menu_environment(ini, windows):
|
||||
"""(set, remove) for the environment of a restart from the in-game menu, as the launcher's
|
||||
game_environment would make them from the same choices; ({}, ()) when the ini has none."""
|
||||
if 'menu_anti_aliasing' not in ini:
|
||||
return {}, ()
|
||||
aa = ini.get('menu_anti_aliasing', 'game')
|
||||
env = {}
|
||||
if aa == 'off':
|
||||
env['ISSN_AA_OFF'] = '1'
|
||||
elif aa == 'fsr4':
|
||||
env['ISSN_FSR4'] = '1'
|
||||
elif aa == 'xess' and windows:
|
||||
env['ISSN_XESS'] = '1'
|
||||
elif aa == 'dlss' and windows:
|
||||
env['SHADPS4_DLSS'] = '1'
|
||||
env['SHADPS4_DLSS_UPSCALE'] = '1'
|
||||
if ini.get('menu_frame_gen') == '1' and any(k in env for k in UPSCALER_SWITCHES):
|
||||
env['ISSN_FRAME_GEN'] = '1'
|
||||
if ini.get('menu_upscale_quality') in UPSCALE_FACTORS:
|
||||
env['ISSN_UPSCALE_FACTOR'] = ini['menu_upscale_quality']
|
||||
remove = tuple(k for k in (*AA_SWITCHES, 'ISSN_UPSCALE_FACTOR', 'ISSN_FRAME_GEN') if k not in env)
|
||||
return env, remove
|
||||
|
||||
+36
-1
@@ -84,14 +84,49 @@ def _copy(user_dir, now, tag=''):
|
||||
target, n = root / f'{base}{suffix}', 2
|
||||
while target.exists():
|
||||
target, n = root / f'{base}-{n}{suffix}', n + 1
|
||||
try:
|
||||
_check_size(savedata)
|
||||
except TooBig:
|
||||
# A savedata stuffed with other files (Discord 11-01: the import's copy of ~17,000 files
|
||||
# froze the launcher): only the game's own folders, savedata/<user id>/CUSAxxxxx[.memory].
|
||||
games = [p for p in savedata.glob('*/*') if p.is_dir() and _TITLE_NAME.match(p.name)]
|
||||
for game in games:
|
||||
_check_size(game)
|
||||
for game in games:
|
||||
shutil.copytree(game, target / game.relative_to(savedata))
|
||||
return target if games else None
|
||||
shutil.copytree(savedata, target)
|
||||
return target
|
||||
|
||||
|
||||
# The game's saves are a few slots of a few files (well under a hundred files, a few MB). A player's
|
||||
# savedata held some 17,000 files (Discord 11-01: the backups came to ~500,000 files, slow to start
|
||||
# and to delete), most likely another folder copied in by hand. Past these limits no copy is made.
|
||||
MAX_FILES = 2000
|
||||
MAX_BYTES = 512 << 20
|
||||
|
||||
|
||||
class TooBig(OSError):
|
||||
"""savedata is far bigger than the game's saves: no automatic backup."""
|
||||
|
||||
|
||||
def _check_size(savedata):
|
||||
files = size = 0
|
||||
for p in savedata.rglob('*'):
|
||||
if p.is_file():
|
||||
files += 1
|
||||
size += p.stat().st_size
|
||||
if files > MAX_FILES or size > MAX_BYTES:
|
||||
raise TooBig(f'{savedata} holds more than {MAX_FILES} files or {MAX_BYTES >> 20} MB, far more '
|
||||
'than the game\'s saves (another folder copied into it?): no automatic backup')
|
||||
|
||||
|
||||
def backup(user_dir, now=None, keep=KEEP):
|
||||
"""Copies savedata; returns the new folder, or None when there is nothing to save or the saves
|
||||
are the same as in the newest backup."""
|
||||
are the same as in the newest backup. Raises TooBig when savedata is far bigger than saves."""
|
||||
savedata = Path(user_dir) / 'savedata'
|
||||
if _has_saves(savedata):
|
||||
_check_size(savedata)
|
||||
names = list_backups(user_dir)
|
||||
if names and _has_saves(savedata):
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
"""The game's trophies (run.py, launcher's Achievements page).
|
||||
|
||||
- The trophy file: sce_sys/trophy/trophy00.trp of the player's dump (or Sc0/trophy/ beside it,
|
||||
where PKG extractors put it). Its icons (TROPnnn.PNG) are plain PNG files and their count is
|
||||
the number of trophies. Read from the player's folder at run time; never copied into the port.
|
||||
- user/trophies.txt: the trophies unlocked on this PC, one "id date" line each (written by the
|
||||
game, src/runtime_trophy.c).
|
||||
- The texts (names, descriptions, grades) are encrypted (TROP*.ESFM). They open only with the
|
||||
player's own trophy key, which the launcher keeps in user/trophy-key.txt on their PC; the port
|
||||
never ships one. Without it, inFAMOUS Second Son's names come from trophy_list.py (written by
|
||||
hand from public lists). Either way run.py writes user/trophy-names.txt for the in-game popup.
|
||||
|
||||
ESFM: AES-128-CBC. The file key is the NP communication id (npbind.dat, "NPWRnnnnn_nn" padded to
|
||||
16 bytes with zeros) encrypted with the trophy key (CBC, zero IV); each ESFM is a 16-byte IV and
|
||||
the encrypted XML, which ends at its last '>'.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import struct
|
||||
import xml.etree.ElementTree as ElementTree
|
||||
|
||||
TRP_MAGIC = 0xDCA24D00
|
||||
TROPHY_FILE = 'trophy/trophy00.trp'
|
||||
UNLOCKED_FILE = 'trophies.txt'
|
||||
NAMES_FILE = 'trophy-names.txt'
|
||||
KEY_FILE = 'trophy-key.txt'
|
||||
GRADES = {'P': 'platinum', 'G': 'gold', 'S': 'silver', 'B': 'bronze'}
|
||||
|
||||
|
||||
# ---- where the files are ---------------------------------------------------------------------
|
||||
def system_file(relative, *folders):
|
||||
"""`relative` (e.g. 'trophy/trophy00.trp') in a folder's sce_sys/, else in the Sc0/ beside it
|
||||
(PKG extractors: Image0/ holds the game, Sc0/ the system files); the first found, or None."""
|
||||
for folder in folders:
|
||||
if not folder:
|
||||
continue
|
||||
folder = Path(folder)
|
||||
places = [folder / 'sce_sys']
|
||||
try:
|
||||
places += [entry for entry in folder.parent.iterdir()
|
||||
if entry.is_dir() and entry.name.casefold() == 'sc0' and entry != folder]
|
||||
except OSError:
|
||||
pass
|
||||
for place in places:
|
||||
if (place / relative).is_file():
|
||||
return place / relative
|
||||
return None
|
||||
|
||||
|
||||
def trophy_file(*folders):
|
||||
return system_file(TROPHY_FILE, *folders)
|
||||
|
||||
|
||||
def npbind_file(*folders):
|
||||
return system_file('npbind.dat', *folders)
|
||||
|
||||
|
||||
# ---- the trophy file -------------------------------------------------------------------------
|
||||
def read_entries(path):
|
||||
"""{name: (offset, length, flag)} of a .trp file; ValueError when it is not one."""
|
||||
with open(path, 'rb') as f:
|
||||
head = f.read(0x60)
|
||||
if len(head) < 0x60 or struct.unpack_from('>I', head)[0] != TRP_MAGIC:
|
||||
raise ValueError(f'{path}: not a trophy file')
|
||||
count, size = struct.unpack_from('>II', head, 16)
|
||||
if count > 4096 or not 52 <= size <= 1024:
|
||||
raise ValueError(f'{path}: not a trophy file')
|
||||
table = f.read(count * size)
|
||||
if len(table) < count * size:
|
||||
raise ValueError(f'{path}: cut short')
|
||||
entries = {}
|
||||
for i in range(count):
|
||||
entry = table[i * size:(i + 1) * size]
|
||||
name = entry[:32].split(b'\0', 1)[0].decode('ascii', 'replace')
|
||||
offset, length, flag = struct.unpack_from('>QQI', entry, 32)
|
||||
entries[name] = (offset, length, flag)
|
||||
return entries
|
||||
|
||||
|
||||
def icon_id(name):
|
||||
"""The trophy id of an entry named TROPnnn.PNG, else None."""
|
||||
match = re.fullmatch(r'TROP(\d{3})\.PNG', name)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def trophy_ids(entries):
|
||||
return sorted(i for i in map(icon_id, entries) if i is not None)
|
||||
|
||||
|
||||
def read_entry(path, entry):
|
||||
offset, length, _flag = entry
|
||||
with open(path, 'rb') as f:
|
||||
f.seek(offset)
|
||||
data = f.read(length)
|
||||
if len(data) != length:
|
||||
raise ValueError(f'{path}: cut short')
|
||||
return data
|
||||
|
||||
|
||||
def icon(path, entries, trophy_id):
|
||||
"""PNG bytes of a trophy's icon, or None."""
|
||||
entry = entries.get(f'TROP{trophy_id:03d}.PNG')
|
||||
try:
|
||||
data = read_entry(path, entry) if entry else None
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return data if data and data.startswith(b'\x89PNG') else None
|
||||
|
||||
|
||||
# ---- what this PC unlocked ---------------------------------------------------------------------
|
||||
def load_unlocked(user_dir):
|
||||
"""{id: 'YYYY-MM-DD HH:MM:SS'} from user/trophies.txt ({} when there is none)."""
|
||||
unlocked = {}
|
||||
try:
|
||||
lines = (Path(user_dir) / UNLOCKED_FILE).read_text(encoding='utf-8', errors='replace').splitlines()
|
||||
except OSError:
|
||||
return unlocked
|
||||
for line in lines:
|
||||
match = re.match(r'\s*(\d+)\s*(.*)$', line)
|
||||
if match and int(match.group(1)) < 128:
|
||||
unlocked.setdefault(int(match.group(1)), match.group(2).strip())
|
||||
return unlocked
|
||||
|
||||
|
||||
# ---- AES-128 (the texts; standard library only: the launcher is a PyInstaller build) ----------
|
||||
def _tables():
|
||||
sbox, inverse = [0] * 256, [0] * 256
|
||||
p = q = 1
|
||||
while True: # p runs over GF(2^8)* by 3, q over the inverses by 1/3
|
||||
p ^= (p << 1) ^ (0x1B if p & 0x80 else 0)
|
||||
p &= 0xFF
|
||||
q ^= q << 1
|
||||
q ^= q << 2
|
||||
q ^= q << 4
|
||||
q &= 0xFF
|
||||
if q & 0x80:
|
||||
q ^= 0x09
|
||||
value = q ^ ((q << 1 | q >> 7) & 0xFF) ^ ((q << 2 | q >> 6) & 0xFF) \
|
||||
^ ((q << 3 | q >> 5) & 0xFF) ^ ((q << 4 | q >> 4) & 0xFF) ^ 0x63
|
||||
sbox[p] = value
|
||||
inverse[value] = p
|
||||
if p == 1:
|
||||
break
|
||||
sbox[0], inverse[0x63] = 0x63, 0
|
||||
return sbox, inverse
|
||||
|
||||
|
||||
SBOX, INV_SBOX = _tables()
|
||||
|
||||
|
||||
def _mul(a, b):
|
||||
result = 0
|
||||
while b:
|
||||
if b & 1:
|
||||
result ^= a
|
||||
a = ((a << 1) ^ (0x1B if a & 0x80 else 0)) & 0xFF
|
||||
b >>= 1
|
||||
return result
|
||||
|
||||
|
||||
MUL = {n: [_mul(i, n) for i in range(256)] for n in (2, 3, 9, 11, 13, 14)}
|
||||
|
||||
|
||||
def expand_key(key):
|
||||
if len(key) != 16:
|
||||
raise ValueError('AES-128 needs a 16-byte key')
|
||||
words = [list(key[i:i + 4]) for i in range(0, 16, 4)]
|
||||
rcon = 1
|
||||
for i in range(4, 44):
|
||||
word = list(words[i - 1])
|
||||
if i % 4 == 0:
|
||||
word = [SBOX[b] for b in word[1:] + word[:1]]
|
||||
word[0] ^= rcon
|
||||
rcon = _mul(rcon, 2)
|
||||
words.append([a ^ b for a, b in zip(words[i - 4], word)])
|
||||
return [sum(words[r * 4:r * 4 + 4], []) for r in range(11)]
|
||||
|
||||
|
||||
def _add(state, key):
|
||||
return [a ^ b for a, b in zip(state, key)]
|
||||
|
||||
|
||||
def encrypt_block(keys, block):
|
||||
s = _add(list(block), keys[0])
|
||||
for r in range(1, 11):
|
||||
s = [SBOX[b] for b in s]
|
||||
s = [s[(i + 4 * (i % 4)) % 16] for i in range(16)] # ShiftRows (column-major state)
|
||||
if r < 10:
|
||||
m2, m3 = MUL[2], MUL[3]
|
||||
out = []
|
||||
for c in range(4):
|
||||
a0, a1, a2, a3 = s[4 * c:4 * c + 4]
|
||||
out += [m2[a0] ^ m3[a1] ^ a2 ^ a3, a0 ^ m2[a1] ^ m3[a2] ^ a3,
|
||||
a0 ^ a1 ^ m2[a2] ^ m3[a3], m3[a0] ^ a1 ^ a2 ^ m2[a3]]
|
||||
s = out
|
||||
s = _add(s, keys[r])
|
||||
return bytes(s)
|
||||
|
||||
|
||||
def decrypt_block(keys, block):
|
||||
s = _add(list(block), keys[10])
|
||||
for r in range(9, -1, -1):
|
||||
s = [s[(i - 4 * (i % 4)) % 16] for i in range(16)] # InvShiftRows
|
||||
s = [INV_SBOX[b] for b in s]
|
||||
s = _add(s, keys[r])
|
||||
if r > 0:
|
||||
m9, m11, m13, m14 = MUL[9], MUL[11], MUL[13], MUL[14]
|
||||
out = []
|
||||
for c in range(4):
|
||||
a0, a1, a2, a3 = s[4 * c:4 * c + 4]
|
||||
out += [m14[a0] ^ m11[a1] ^ m13[a2] ^ m9[a3], m9[a0] ^ m14[a1] ^ m11[a2] ^ m13[a3],
|
||||
m13[a0] ^ m9[a1] ^ m14[a2] ^ m11[a3], m11[a0] ^ m13[a1] ^ m9[a2] ^ m14[a3]]
|
||||
s = out
|
||||
return bytes(s)
|
||||
|
||||
|
||||
def cbc_encrypt(key, iv, data):
|
||||
keys, previous, out = expand_key(key), bytes(iv), bytearray()
|
||||
for i in range(0, len(data) - len(data) % 16, 16):
|
||||
previous = encrypt_block(keys, bytes(a ^ b for a, b in zip(data[i:i + 16], previous)))
|
||||
out += previous
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def cbc_decrypt(key, iv, data):
|
||||
keys, previous, out = expand_key(key), bytes(iv), bytearray()
|
||||
for i in range(0, len(data) - len(data) % 16, 16):
|
||||
block = data[i:i + 16]
|
||||
out += bytes(a ^ b for a, b in zip(decrypt_block(keys, block), previous))
|
||||
previous = block
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# ---- the texts -----------------------------------------------------------------------------------
|
||||
def parse_key(text):
|
||||
"""The trophy key as 16 bytes from 32 hex digits (spaces, dashes and 0x allowed), else None."""
|
||||
digits = re.sub(r'0x|[\s:-]', '', str(text or ''), flags=re.IGNORECASE)
|
||||
return bytes.fromhex(digits) if re.fullmatch(r'[0-9a-fA-F]{32}', digits) else None
|
||||
|
||||
|
||||
def read_key(user_dir):
|
||||
try:
|
||||
return parse_key((Path(user_dir) / KEY_FILE).read_text(encoding='ascii', errors='replace'))
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def save_key(user_dir, key):
|
||||
"""Keeps the key on this PC only (user/trophy-key.txt); None removes it."""
|
||||
path = Path(user_dir) / KEY_FILE
|
||||
if key is None:
|
||||
path.unlink(missing_ok=True)
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(key.hex().upper() + '\n', encoding='ascii')
|
||||
|
||||
|
||||
def np_comm_id(data):
|
||||
"""The NP communication id in an npbind.dat ('NPWR12345_00'), or None."""
|
||||
match = re.search(rb'NPWR\d{5}_\d{2}', data or b'')
|
||||
return match.group().decode() if match else None
|
||||
|
||||
|
||||
def file_key(trophy_key, comm_id):
|
||||
return cbc_encrypt(trophy_key, bytes(16), comm_id.encode('ascii').ljust(16, b'\0')[:16])
|
||||
|
||||
|
||||
def decrypt_esfm(data, key):
|
||||
"""An ESFM entry as XML bytes; ValueError when the key does not open it."""
|
||||
if len(data) < 32:
|
||||
raise ValueError('trophy text too short')
|
||||
plain = cbc_decrypt(key, data[:16], data[16:])
|
||||
if not plain.lstrip(b'\xef\xbb\xbf').startswith(b'<?xml'):
|
||||
raise ValueError('the trophy key does not open this trophy file')
|
||||
return plain[:plain.rfind(b'>') + 1]
|
||||
|
||||
|
||||
def _trophies_of(xml_bytes):
|
||||
root = ElementTree.fromstring(xml_bytes)
|
||||
return {int(node.get('id', '-1')): node for node in root.iter('trophy') if node.get('id', '').strip().isdigit()}
|
||||
|
||||
|
||||
def read_texts(trp, npbind, key, language=1):
|
||||
"""{id: {'grade', 'base', 'hidden', 'name', 'detail'}} in the game language (PS4 language id:
|
||||
TROP_17.ESFM for Brazilian Portuguese; TROP.ESFM when the game has no such file).
|
||||
ValueError with the reason when they cannot be read."""
|
||||
if key is None:
|
||||
raise ValueError('no trophy key')
|
||||
try:
|
||||
comm = np_comm_id(Path(npbind).read_bytes()) if npbind else None
|
||||
except OSError:
|
||||
comm = None
|
||||
if not comm:
|
||||
raise ValueError('no NP communication id (npbind.dat) in the game folder')
|
||||
entries = read_entries(trp)
|
||||
secret = file_key(key, comm)
|
||||
conf = _trophies_of(decrypt_esfm(read_entry(trp, entries['TROPCONF.ESFM']), secret)) \
|
||||
if 'TROPCONF.ESFM' in entries else {}
|
||||
name = f'TROP_{int(language):02d}.ESFM'
|
||||
texts_entry = entries.get(name) or entries.get('TROP.ESFM')
|
||||
if texts_entry is None:
|
||||
raise ValueError('no trophy texts in the trophy file')
|
||||
texts = _trophies_of(decrypt_esfm(read_entry(trp, texts_entry), secret))
|
||||
result = {}
|
||||
for trophy_id in sorted(set(conf) | set(texts)):
|
||||
c, t = conf.get(trophy_id), texts.get(trophy_id)
|
||||
grade = (c.get('ttype', '') if c is not None else '').upper()[:1]
|
||||
pid = c.get('pid') if c is not None else None
|
||||
result[trophy_id] = {
|
||||
'grade': grade if grade in GRADES else '',
|
||||
'base': grade != 'P' and pid is not None and pid.strip().lstrip('-').isdigit() and int(pid) != -1,
|
||||
'hidden': (c.get('hidden', 'no') if c is not None else 'no').lower() == 'yes',
|
||||
'name': ' '.join(((t if t is not None else c).findtext('name') or '').split()),
|
||||
'detail': ' '.join(((t if t is not None else c).findtext('detail') or '').split()),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def game_ids(*folders):
|
||||
"""(title ID, NP communication id) of the game in the folders; None for what is not found."""
|
||||
title = comm = None
|
||||
sfo = system_file('param.sfo', *folders)
|
||||
if sfo is not None:
|
||||
try:
|
||||
from prepare import load_sfo
|
||||
title = load_sfo(sfo).get('TITLE_ID')
|
||||
except (ImportError, OSError, ValueError):
|
||||
title = None
|
||||
npbind = npbind_file(*folders)
|
||||
if npbind is not None:
|
||||
try:
|
||||
comm = np_comm_id(npbind.read_bytes())
|
||||
except OSError:
|
||||
comm = None
|
||||
return title, comm
|
||||
|
||||
|
||||
def builtin_texts(trp, language, *folders):
|
||||
"""The port's own list (trophy_list.py) when the trophy file is inFAMOUS Second Son's: the
|
||||
same number of trophies, and its title ID or NP communication id; else None."""
|
||||
import trophy_list
|
||||
try:
|
||||
if len(trophy_ids(read_entries(trp))) != len(trophy_list.TROPHIES):
|
||||
return None
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
title, comm = game_ids(*folders)
|
||||
if title not in trophy_list.TITLE_IDS and comm != trophy_list.COMM_ID:
|
||||
return None
|
||||
return trophy_list.texts(language)
|
||||
|
||||
|
||||
def game_texts(trp, language, *folders, key=None):
|
||||
"""(texts, source): the game's own texts with the player's key ('key'), else the port's list
|
||||
('list'), else ({}, None). ValueError from a key that does not open the file is passed on."""
|
||||
if key is not None:
|
||||
return read_texts(trp, npbind_file(*folders), key, language), 'key'
|
||||
texts = builtin_texts(trp, language, *folders)
|
||||
return (texts, 'list') if texts else ({}, None)
|
||||
|
||||
|
||||
def write_names(user_dir, texts):
|
||||
"""user/trophy-names.txt for the game (src/runtime_trophy.c): id, grade, base, name."""
|
||||
lines = ['# Trophy names from the player\'s own key (not shipped). id\tgrade\tbase\tname']
|
||||
for trophy_id, text in sorted(texts.items()):
|
||||
name = text['name'].replace('\t', ' ')
|
||||
lines.append(f"{trophy_id}\t{text['grade'] or '-'}\t{int(bool(text['base']))}\t{name}")
|
||||
path = Path(user_dir) / NAMES_FILE
|
||||
part = path.with_name(path.name + '.part')
|
||||
part.write_text('\n'.join(lines) + '\n', encoding='utf-8')
|
||||
os.replace(part, path)
|
||||
|
||||
|
||||
def prepare(env, user_dir):
|
||||
"""run.py, before a start: ISSN_TROPHY_FILE for the game, user/trophy-names.txt from the key
|
||||
(removed without one, so names never outlive the key). Returns the line for the log."""
|
||||
folders = [env.get('ISSN_PATCH_DIR'), env.get('ISSN_GAME_DIR')]
|
||||
trp = trophy_file(*folders)
|
||||
names = Path(user_dir) / NAMES_FILE
|
||||
unlocked = len(load_unlocked(user_dir))
|
||||
if trp is None:
|
||||
env.pop('ISSN_TROPHY_FILE', None)
|
||||
names.unlink(missing_ok=True)
|
||||
return f'Trophies: no trophy file (sce_sys/{TROPHY_FILE}) in the game folder; {unlocked} unlocked on this PC'
|
||||
env['ISSN_TROPHY_FILE'] = str(trp)
|
||||
try:
|
||||
count = len(trophy_ids(read_entries(trp)))
|
||||
except (OSError, ValueError) as error:
|
||||
return f'Trophies: {error}'
|
||||
key = read_key(user_dir)
|
||||
language = env.get('ISSN_LANGUAGE', '1')
|
||||
try:
|
||||
texts, source = game_texts(trp, language, *folders, key=key)
|
||||
except (OSError, ValueError, KeyError, ElementTree.ParseError) as error:
|
||||
texts, source = {}, None
|
||||
note = f'no names ({error})'
|
||||
else:
|
||||
note = 'no names (no trophy key)'
|
||||
if texts:
|
||||
write_names(user_dir, texts)
|
||||
origin = 'the trophy key' if source == 'key' else "the port's list"
|
||||
note = f'names: {sum(1 for t in texts.values() if t["name"])} ({origin})'
|
||||
else:
|
||||
names.unlink(missing_ok=True)
|
||||
return f'Trophies: {count} in {trp}, {unlocked} unlocked on this PC, {note}'
|
||||
|
||||
|
||||
def summary(user_dir, *folders):
|
||||
"""For the launcher: (trophy file or None, [ids], {id: date unlocked})."""
|
||||
trp = trophy_file(*folders)
|
||||
ids = []
|
||||
if trp is not None:
|
||||
try:
|
||||
ids = trophy_ids(read_entries(trp))
|
||||
except (OSError, ValueError):
|
||||
trp = None
|
||||
return trp, ids, load_unlocked(user_dir)
|
||||
@@ -0,0 +1,188 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
"""inFAMOUS Second Son's trophy names, for players without a trophy key (scripts/trophies.py).
|
||||
|
||||
Written by hand from public trophy lists, not taken from the game's files:
|
||||
- English names, descriptions, grades and order: PlayStation's own list as shown by PlatPursuit
|
||||
(NPWR05740_00); the order matches the icons of the game's trophy file (0 platinum cup, 2/3 good
|
||||
and evil Delsin, 14 open lock = "Everybody Out", 39 car = "Total CARnage", ...).
|
||||
- Portuguese: PlayStation Blast (2015 article with the Brazilian texts); its typo "Estillhaçado"
|
||||
is corrected. The platinum's Portuguese name was not in it: "Aproveite seus poderes" is our own.
|
||||
- Spanish: Xataka México's trophy guide (38 of them); the ten story trophies it left out
|
||||
(4-13) are our own translations.
|
||||
Names that are our own may differ from the game's; the player's own key (Achievements page) gives
|
||||
the game's texts instead.
|
||||
"""
|
||||
|
||||
COMM_ID = 'NPWR05740_00'
|
||||
TITLE_IDS = ('CUSA00004', 'CUSA00223')
|
||||
|
||||
# (grade, English name, English description, Portuguese name, Portuguese description,
|
||||
# Spanish name, Spanish description), by trophy id.
|
||||
TROPHIES = [
|
||||
('P', 'Enjoy Your Powers', 'Collect all trophies',
|
||||
'Aproveite seus poderes', 'Obtenha todos os troféus',
|
||||
'Disfruta tus poderes', 'Obtén todos los trofeos.'),
|
||||
('S', 'Unstoppable', 'Finish the game on Expert difficulty',
|
||||
'Imbatível', 'Termine o jogo na dificuldade Especialista',
|
||||
'Imparable', 'Termina el juego en nivel Experto.'),
|
||||
('B', 'Sacrifice', 'Risk your freedom to protect those you care about',
|
||||
'Sacrifício', 'Arrisque sua liberdade para proteger aqueles com quem se importa',
|
||||
'Sacrificio', 'Arriesga tu libertad por proteger a los que quieres.'),
|
||||
('B', 'Ruthless', 'Look out for yourself, no matter the consequences',
|
||||
'Implacável', 'Proteja-se, não importam as consequências',
|
||||
'Despiadado', 'Mira solo por ti, pase lo que pase.'),
|
||||
('B', 'Temperance', 'Practice self-control',
|
||||
'Moderação', 'Pratique o autocontrole',
|
||||
'Templanza', 'Practica el autocontrol.'),
|
||||
('B', 'Malice', 'Get them before they get you',
|
||||
'Malícia', 'Pegue eles antes que te peguem',
|
||||
'Malicia', 'Atrápalos antes de que te atrapen.'),
|
||||
('B', 'Courage', 'Stand up for the helpless',
|
||||
'Coragem', 'Levante-se pelos indefesos',
|
||||
'Valor', 'Defiende a los indefensos.'),
|
||||
('B', 'Contempt', "Don't fear the weak",
|
||||
'Desobediência', 'Não tema os fracos',
|
||||
'Desprecio', 'No temas a los débiles.'),
|
||||
('B', 'Mercy', 'Everyone deserves a second chance',
|
||||
'Misericórdia', 'Todos merecem uma segunda chance',
|
||||
'Piedad', 'Todos merecen una segunda oportunidad.'),
|
||||
('B', 'Revenge', 'An eye for an eye, a tooth for a tooth',
|
||||
'Vingança', 'Olho por olho, dente por dente',
|
||||
'Venganza', 'Ojo por ojo, diente por diente.'),
|
||||
('B', 'Justice', 'Expose the corruption of the powerful',
|
||||
'Justiça', 'Exponha a corrupção dos poderosos',
|
||||
'Justicia', 'Expón la corrupción de los poderosos.'),
|
||||
('B', 'Dominance', 'Show the world that nobody can protect them',
|
||||
'Domínio', 'Mostre ao mundo que ninguém pode protegê-los',
|
||||
'Dominio', 'Muestra al mundo que nadie puede protegerlos.'),
|
||||
('G', 'Reconciliation', 'Finish the story with Good Karma',
|
||||
'Reconciliação', 'Termine a história com carma do bem',
|
||||
'Reconciliación', 'Termina la historia con karma del bien.'),
|
||||
('G', 'Rebellion', 'Finish the story with Evil Karma',
|
||||
'Rebelião', 'Termine a história com carma do mal',
|
||||
'Rebelión', 'Termina la historia con karma del mal.'),
|
||||
('B', 'Everybody Out', 'Rescue 10 suspects from suspicion pens',
|
||||
'Todo mundo solto', 'Resgate 10 suspeitos das celas de suspeitos',
|
||||
'Todos fuera', 'Rescata a 10 sospechosos de las celdas.'),
|
||||
('B', 'Spin-off', 'Beat down 10 sign twirlers',
|
||||
'Chega de cartazes', 'Dê porrada em 10 pessoas com placas',
|
||||
'Basta de publicidad', 'Derrota a 10 portadores de anuncios.'),
|
||||
('B', "Everyone's a Critic", 'Take out 10 street musicians',
|
||||
'Todo mundo é crítico', 'Acabe com 10 músicos de rua',
|
||||
'Todos somos críticos', 'Elimina a 10 músicos callejeros.'),
|
||||
('B', 'Just Say No', 'Bust 10 drug dealers',
|
||||
'Drogas, tô fora', 'Pegue 10 traficantes',
|
||||
'Simplemente di no', 'Detén a 10 narcotraficantes.'),
|
||||
('B', 'Freedom of Speech', 'Disrupt 10 activist rallies',
|
||||
'Liberdade de expressão', 'Atrapalhe 10 manifestações ativistas',
|
||||
'Libertad de expresión', 'Interrumpe 10 reuniones de activistas.'),
|
||||
('B', 'What Did You Call Me?', 'Kill 10 obnoxious Akuran gang members',
|
||||
'Do que você me chamou?', 'Mate 10 membros idiotas da gangue Akulan',
|
||||
'¿Qué me has llamado?', 'Mata a 10 molestos miembros de la banda akulana.'),
|
||||
('B', 'Someone Your Own Size', 'Rescue 10 escaped conduits from angry mobs',
|
||||
'Alguém do seu tamanho', 'Resgate 10 condutores fugitivos de multidões enfurecidas',
|
||||
'Alguien de tu tamaño', 'Rescata a 10 conductores escapados de las turbas violentas.'),
|
||||
('B', 'Walk It Off', 'Heal 10 wounded civilians',
|
||||
'Caminha que passa', 'Cure 10 civis feridos',
|
||||
'Ya estás bien', 'Cura a 10 civiles heridos.'),
|
||||
('B', 'Kicking and Screaming', 'Finish off 10 wounded enemies or civilians',
|
||||
'Agonizando', 'Finalize 10 inimigos feridos',
|
||||
'Remátalos', 'Acaba con 10 enemigos o civiles heridos.'),
|
||||
('S', 'True Hero', 'Achieve Level 5 Good Karma',
|
||||
'Herói Verdadeiro', 'Atinja carma do bem no nível 5',
|
||||
'Héroe de verdad', 'Alcanza el nivel 5 de karma del bien.'),
|
||||
('S', 'Infamous', 'Achieve Level 5 Evil Karma',
|
||||
'Infame', 'Atinja carma do mal no nível 5',
|
||||
'Infame', 'Alcanza el nivel 5 de karma del mal.'),
|
||||
('B', 'Bweep! Bweep! Bweep!', 'Collect half the Blast Shards',
|
||||
'Bipe! Bipe! Bipe!', 'Colete metade dos estilhaços de explosões',
|
||||
'¡Bip! ¡Bip! ¡Bip!', 'Obtén la mitad de los fragmentos de la explosión.'),
|
||||
('S', 'Shardcore', 'Collect all the Blast Shards',
|
||||
'Estilhaçado', 'Colete todos os estilhaços de explosões',
|
||||
'Fragmentado', 'Reúne todos los fragmentos de la explosión.'),
|
||||
('B', 'Nothing To See Here', 'Destroy half the D.U.P. security cameras',
|
||||
'Nada para ver aqui', 'Destrua metade das câmeras de segurança do DUP',
|
||||
'Se acabó la función', 'Destruye la mitad de las cámaras de seguridad del D.U.P.'),
|
||||
('S', 'Blinded', 'Destroy all of the D.U.P. security cameras',
|
||||
'Cego', 'Destrua todas as câmeras de segurança do DUP',
|
||||
'Cegados', 'Destruye todas las cámaras de seguridad del D.U.P.'),
|
||||
('S', 'Inside Track', 'Collect all the Informant Audio Logs',
|
||||
'Nos trilhos', 'Colete todos os registros de áudio de informantes',
|
||||
'Grabación interna', 'Reúne todos los diarios de audio del informante.'),
|
||||
('S', 'Blown Cover', 'Defeat all the D.U.P. Secret Agents',
|
||||
'Disfarce descoberto', 'Derrote todos os agentes secretos do DUP',
|
||||
'Sin tapadera', 'Derrota a todos los agentes secretos del D.U.P.'),
|
||||
('S', 'Big Brother', 'Destroy all the D.U.P. Hidden Cameras',
|
||||
'Big Brother', 'Destrua todas as câmeras secretas do DUP',
|
||||
'Gran hermano', 'Destruye todas las cámaras ocultas del D.U.P.'),
|
||||
('S', 'Coloring Inside the Lines', 'Complete all the Stencil Art',
|
||||
'Pintando dentro da linha', 'Conclua todas as artes de estêncil',
|
||||
'No te salgas de los puntos', 'Completa todos los grafitis con plantilla.'),
|
||||
('B', 'Prank Call', 'Complete a District Showdown',
|
||||
'Trote', 'Conclua um confronto no distrito',
|
||||
'Broma telefónica', 'Completa un Enfrentamiento de distrito.'),
|
||||
('B', 'Hello Operator', 'Complete Showdowns in half of the districts',
|
||||
'Alô, operadora', 'Conclua confrontos em metade dos distritos',
|
||||
'Hola, operadora', 'Completa Enfrentamientos en la mitad de los distritos.'),
|
||||
('S', 'Final Showdown', 'Complete all of the District Showdowns in the city',
|
||||
'Último confronto', 'Conclua os confrontos em todos os distritos da cidade',
|
||||
'Enfrentamiento final', 'Completa todos los Enfrentamientos de distrito de la ciudad.'),
|
||||
('B', 'One Down', 'Clear the D.U.P. completely out of a district',
|
||||
'Menos um', 'Expulse o DUP completamente de um distrito',
|
||||
'Uno menos', 'Expulsa por completo al D.U.P. de un distrito.'),
|
||||
('B', 'Home Field Advantage', 'Clear the D.U.P. out of half the districts in the city',
|
||||
'Jogando em casa', 'Expulse o DUP de metade dos distritos da cidade',
|
||||
'Jugar en casa', 'Expulsa al D.U.P. de la mitad de los distritos de la ciudad.'),
|
||||
('S', 'Clean Sweep', 'Clear the D.U.P. out of the entire city',
|
||||
'Limpeza total', 'Expulse o DUP da cidade inteira',
|
||||
'Limpieza', 'Expulsa al D.U.P. de toda la ciudad.'),
|
||||
('B', 'Total CARnage', 'Destroy 5 cars in 5 seconds',
|
||||
'Carnificina total', 'Destrua 5 carros em 5 segundos',
|
||||
'Autocarnicería total', 'Destruye 5 coches en 5 segundos.'),
|
||||
('B', 'Drop Everything', 'Comet Drop over 170 meters',
|
||||
'Como um cometa', 'Execute o cometa por mais de 170 metros',
|
||||
'Realmente certero', 'Realiza un Cometa de más de 170 metros.'),
|
||||
('B', 'Keep It Up', 'Stay in the air for more than 45 seconds',
|
||||
'Continue assim', 'Fique no ar por mais de 45 segundos',
|
||||
'Sigue así', 'Permanece en el aire más de 45 segundos.'),
|
||||
('B', 'Put That Anywhere', 'Defeat 10 enemies by detonating ammo crates',
|
||||
'Coloque isso em qualquer lugar', 'Derrote 10 inimigos ao detonar caixas de munição',
|
||||
'Pon eso donde sea', 'Derrota a 10 enemigos detonando cajas de munición.'),
|
||||
('B', 'Hat Trick', 'Defeat an enemy using three different power sources',
|
||||
'Truque de mestre', 'Derrote um inimigo usando três fontes de poder diferentes',
|
||||
'Truco ofensivo', 'Derrota a un enemigo utilizando tres fuentes de poder diferentes.'),
|
||||
('B', 'Double Smoke Stack Attack',
|
||||
'Dash through two red air vents without touching the ground and then Comet Drop on an enemy',
|
||||
'Ataque duplo de fumaça em sequência',
|
||||
'Passe rápido por duas saídas de ar vermelhas sem tocar o chão e caia como um cometa no inimigo',
|
||||
'Ataque de chimenea doble',
|
||||
'Corre por dos conductos de ventilación rojos sin tocar el suelo y haz un Cometa sobre un enemigo.'),
|
||||
('B', 'Cough Drop', 'Comet Drop on three coughing enemies',
|
||||
'Pastilha para tosse', 'Execute o cometa em 3 inimigos com tosse',
|
||||
'Caída antitós', 'Realiza un Cometa sobre 3 enemigos que tosen.'),
|
||||
('B', 'Make Fetch Proud', 'Hit three weak points with Neon Beam in 6 seconds',
|
||||
'Dê orgulho a Fetch', 'Atinja 3 pontos fracos com feixe de neon em 6 segundos',
|
||||
'Que Fetch esté orgullosa', 'Alcanza 3 puntos débiles con Rayo de neón en 6 segundos.'),
|
||||
('B', 'Excessive Force', 'Use a Karmic Streak attack to defeat exactly one enemy',
|
||||
'Força excessiva', 'Use um ataque de sequência cármica para derrotar exatamente um inimigo',
|
||||
'Fuerza excesiva', 'Usa un ataque de Racha kármica para derrotar exactamente a un enemigo.'),
|
||||
]
|
||||
|
||||
|
||||
def language_column(language):
|
||||
"""0 English, 1 Portuguese, 2 Spanish from a PS4 language id (ISSN_LANGUAGE)."""
|
||||
try:
|
||||
value = int(language)
|
||||
except (TypeError, ValueError):
|
||||
value = 1
|
||||
return 1 if value in (7, 17) else 2 if value in (3, 20) else 0
|
||||
|
||||
|
||||
def texts(language=1):
|
||||
"""{id: {'grade', 'base', 'hidden', 'name', 'detail'}} like trophies.read_texts."""
|
||||
column = language_column(language)
|
||||
result = {}
|
||||
for trophy_id, (grade, *names) in enumerate(TROPHIES):
|
||||
result[trophy_id] = {'grade': grade, 'base': grade != 'P', 'hidden': False,
|
||||
'name': names[2 * column], 'detail': names[2 * column + 1]}
|
||||
return result
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later */
|
||||
/* inFAMOUS Second Son (CUSA00223 01.07 eboot): the dash cheats of the menu (gpu/shim/issn_hero.cpp).
|
||||
* Found 11-04 from the script bindings and the skill classes' type table (0xdac130: type id =
|
||||
* 0x2000 + index) and their factories (0xcf2da8...: DASH_SKILL 0x1ae = smoke, NEON_DASH_SKILL
|
||||
* 0x1af, TV_DASH_SKILL 0x1b0 = video). Vtable slot 32 updates a dash while it runs; the clock at
|
||||
* 0xe16290+4 counts 1440 ticks a second; the skill's start time is at +0xf0.
|
||||
* - Neon (update 0x2ef7e0): with the dash button released the run ends; held, it ends after
|
||||
* 2.0 s unless ability 0x59 is on (0x1e2e70(&abilities, 0x59, 0)). The neon stub answers
|
||||
* "on" for that check while the flag is set, so holding the button keeps the run going.
|
||||
* - Video (update 0x55dd90): after 0.15 s, the dash button released or more than 1.5 s sets
|
||||
* "end" (+0x55e). The video stub leaves out the 1.5 s while the flag is set.
|
||||
* - Smoke (DASH_SKILL): a dash to a point picked at the start; its length is multiplied by the
|
||||
* hero's MAX_SMOKE_DASH_RATIO (hero +0x1f7e0, 1.0 at creation, set by the game's scripts;
|
||||
* read by 0x227ac0). The shim scales it for the long smoke dash (no hold to continue). */
|
||||
#ifndef ISSN_DASH_HOOK_H
|
||||
#define ISSN_DASH_HOOK_H
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
#define ISSN_DASH_SMOKE_RATIO 0x1f7e0u /* hero: float MAX_SMOKE_DASH_RATIO */
|
||||
|
||||
/* Neon: lea 0xefacf8(%rip),%rdi; mov $0x59,%esi | xor %edx,%edx; call 0x1e2e70; test %al,%al;
|
||||
* je end. The stub replaces the first 12 bytes. */
|
||||
#define ISSN_DASH_NEON_SITE 0x2ef9a2u
|
||||
#define ISSN_DASH_NEON_SITE_LENGTH 12u
|
||||
#define ISSN_DASH_NEON_BACK 0x2ef9aeu /* xor %edx,%edx: the ability check */
|
||||
#define ISSN_DASH_NEON_CONTINUE 0x2ef9c0u /* the run goes on */
|
||||
#define ISSN_DASH_ABILITIES 0xefacf8u
|
||||
static const unsigned char issn_dash_neon_code[] = {
|
||||
0x48, 0x8d, 0x3d, 0x4f, 0xb3, 0xc0, 0x00, 0xbe, 0x59, 0x00, 0x00, 0x00,
|
||||
0x31, 0xd2, 0xe8, 0xbb, 0x34, 0xef, 0xff, 0x84, 0xc0, 0x74, 0x35};
|
||||
|
||||
/* Video: vucomiss 0xae2214(%rip),%xmm0 (1.5); ja set_end | xor $1,%al; je skip; jmp set_end */
|
||||
#define ISSN_DASH_TV_SITE 0x55ddf7u
|
||||
#define ISSN_DASH_TV_SITE_LENGTH 10u
|
||||
#define ISSN_DASH_TV_LIMIT 0xae2214u /* float 1.5 */
|
||||
#define ISSN_DASH_TV_HELD 0x55de01u /* xor $1,%al: the button check */
|
||||
#define ISSN_DASH_TV_SET_END 0x55de11u /* movb $1,0x55e(%r12) */
|
||||
static const unsigned char issn_dash_tv_code[] = {
|
||||
0xc5, 0xf8, 0x2e, 0x05, 0x15, 0x44, 0x58, 0x00, 0x77, 0x10, 0x34, 0x01, 0x74, 0x15, 0xeb, 0x0a};
|
||||
|
||||
static inline int issn_dash_neon_match(const unsigned char *image, uint64_t size) {
|
||||
if (size < ISSN_DASH_NEON_SITE + sizeof(issn_dash_neon_code)) return 0;
|
||||
if (memcmp(image + ISSN_DASH_NEON_SITE, issn_dash_neon_code, sizeof(issn_dash_neon_code))) return 0;
|
||||
int32_t disp;
|
||||
memcpy(&disp, image + ISSN_DASH_NEON_SITE + 3, 4);
|
||||
return (int64_t)ISSN_DASH_NEON_SITE + 7 + disp == (int64_t)ISSN_DASH_ABILITIES;
|
||||
}
|
||||
|
||||
static inline int issn_dash_tv_match(const unsigned char *image, uint64_t size) {
|
||||
if (size < ISSN_DASH_TV_SITE + sizeof(issn_dash_tv_code) || size < ISSN_DASH_TV_LIMIT + 4) return 0;
|
||||
if (memcmp(image + ISSN_DASH_TV_SITE, issn_dash_tv_code, sizeof(issn_dash_tv_code))) return 0;
|
||||
int32_t disp;
|
||||
memcpy(&disp, image + ISSN_DASH_TV_SITE + 4, 4);
|
||||
if ((int64_t)ISSN_DASH_TV_SITE + 8 + disp != (int64_t)ISSN_DASH_TV_LIMIT) return 0;
|
||||
float limit;
|
||||
memcpy(&limit, image + ISSN_DASH_TV_LIMIT, 4);
|
||||
return limit == 1.5f;
|
||||
}
|
||||
|
||||
/* jmp *0(%rip) followed by its target: 14 bytes. */
|
||||
static inline unsigned issn_dash_jump(unsigned char *out, const unsigned char *target) {
|
||||
const uint64_t to = (uint64_t)(uintptr_t)target;
|
||||
out[0] = 0xff; out[1] = 0x25; out[2] = out[3] = out[4] = out[5] = 0;
|
||||
memcpy(out + 6, &to, 8);
|
||||
return 14;
|
||||
}
|
||||
|
||||
/* Neon stub. rax is free here (the check's result overwrites it); rdi and esi are the check's
|
||||
* arguments, set again for the original path. Returns the stub's length. */
|
||||
static inline unsigned issn_dash_build_neon_stub(unsigned char *out, const unsigned char *image,
|
||||
const volatile unsigned char *flag) {
|
||||
const uint64_t flag_at = (uint64_t)(uintptr_t)flag;
|
||||
const uint64_t abilities = (uint64_t)(uintptr_t)(image + ISSN_DASH_ABILITIES);
|
||||
unsigned n = 0;
|
||||
out[n++] = 0x48; out[n++] = 0xb8; memcpy(out + n, &flag_at, 8); n += 8; /* movabs $flag,%rax */
|
||||
out[n++] = 0x80; out[n++] = 0x38; out[n++] = 0x00; /* cmpb $0,(%rax) */
|
||||
out[n++] = 0x75; const unsigned j = n++; /* jne on */
|
||||
out[n++] = 0x48; out[n++] = 0xbf; memcpy(out + n, &abilities, 8); n += 8; /* movabs $abilities,%rdi */
|
||||
out[n++] = 0xbe; out[n++] = 0x59; out[n++] = 0; out[n++] = 0; out[n++] = 0; /* mov $0x59,%esi */
|
||||
n += issn_dash_jump(out + n, image + ISSN_DASH_NEON_BACK);
|
||||
out[j] = (unsigned char)(n - (j + 1));
|
||||
n += issn_dash_jump(out + n, image + ISSN_DASH_NEON_CONTINUE); /* on: keep running */
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Video stub. al holds the button check's result (used at HELD), so the 1.5 s constant is read
|
||||
* through r11, saved around it; pop leaves the flags of vucomiss for ja. */
|
||||
static inline unsigned issn_dash_build_tv_stub(unsigned char *out, const unsigned char *image,
|
||||
const volatile unsigned char *flag) {
|
||||
const uint64_t flag_at = (uint64_t)(uintptr_t)flag;
|
||||
const uint64_t limit = (uint64_t)(uintptr_t)(image + ISSN_DASH_TV_LIMIT);
|
||||
unsigned n = 0;
|
||||
out[n++] = 0x41; out[n++] = 0x53; /* push %r11 */
|
||||
out[n++] = 0x49; out[n++] = 0xbb; memcpy(out + n, &flag_at, 8); n += 8; /* movabs $flag,%r11 */
|
||||
out[n++] = 0x41; out[n++] = 0x80; out[n++] = 0x3b; out[n++] = 0x00; /* cmpb $0,(%r11) */
|
||||
out[n++] = 0x75; const unsigned j_on = n++; /* jne on */
|
||||
out[n++] = 0x49; out[n++] = 0xbb; memcpy(out + n, &limit, 8); n += 8; /* movabs $limit,%r11 */
|
||||
out[n++] = 0xc4; out[n++] = 0xc1; out[n++] = 0x78; out[n++] = 0x2e; out[n++] = 0x03; /* vucomiss (%r11),%xmm0 */
|
||||
out[n++] = 0x41; out[n++] = 0x5b; /* pop %r11 */
|
||||
out[n++] = 0x77; out[n++] = 14; /* ja set_end */
|
||||
n += issn_dash_jump(out + n, image + ISSN_DASH_TV_HELD);
|
||||
n += issn_dash_jump(out + n, image + ISSN_DASH_TV_SET_END);
|
||||
out[j_on] = (unsigned char)(n - (j_on + 1));
|
||||
out[n++] = 0x41; out[n++] = 0x5b; /* on: pop %r11 */
|
||||
n += issn_dash_jump(out + n, image + ISSN_DASH_TV_HELD); /* only the button counts */
|
||||
return n;
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,52 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later */
|
||||
/* inFAMOUS Second Son (CUSA00223 01.07 eboot): the exposure meter, for the dark-night diagnosis
|
||||
* (11-04). The CPU function at 0x171610 sums the GPU's per-tile luminance (one float per 128x72
|
||||
* pixels, from the buffer the GPU filled a frame before), turns the sum into the metered value
|
||||
* (call 0x984530) and stores it through r12 at 0x17187d. A cave there copies the value, the raw
|
||||
* sum and the tile rows into a mailbox the in-game menu shows, and, while the menu's "hold"
|
||||
* switch is on, stores the held value instead (the exposure stops following the scene: a test
|
||||
* of whether the darkness comes from the metering). Offsets are eboot virtual addresses; the
|
||||
* hook goes in only when the bytes match. */
|
||||
#ifndef ISSN_EXPOSURE_HOOK_H
|
||||
#define ISSN_EXPOSURE_HOOK_H
|
||||
#include <stdint.h>
|
||||
|
||||
#define ISSN_EXPOSURE_SITE 0x17187du /* vmovss %xmm0,(%r12); mov %bl,%al (8 bytes) */
|
||||
#define ISSN_EXPOSURE_SITE_LENGTH 8u
|
||||
#define ISSN_EXPOSURE_EXPECTED_AT 0x171878u
|
||||
static const unsigned char issn_exposure_expected[13] = {
|
||||
0xe8, 0xb3, 0x2c, 0x81, 0x00, /* call 0x984530 */
|
||||
0xc4, 0xc1, 0x7a, 0x11, 0x04, 0x24, /* vmovss %xmm0,(%r12) */
|
||||
0x88, 0xd8, /* mov %bl,%al */
|
||||
};
|
||||
|
||||
/* rcx and rdx are free at the site (the function returns right after; al is the result). */
|
||||
static const unsigned char issn_exposure_cave[63] = {
|
||||
0x48, 0xb9, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, /* movabs $mailbox,%rcx */
|
||||
0x8b, 0x51, 0x10, /* mov 0x10(%rcx),%edx (held) */
|
||||
0x85, 0xd2, /* test %edx,%edx */
|
||||
0x74, 0x04, /* je 1f */
|
||||
0xc5, 0xf9, 0x6e, 0xc2, /* vmovd %edx,%xmm0 */
|
||||
0xc4, 0xc1, 0x7a, 0x11, 0x04, 0x24, /* 1: vmovss %xmm0,(%r12) */
|
||||
0x88, 0xd8, /* mov %bl,%al */
|
||||
0xc5, 0xfa, 0x11, 0x01, /* vmovss %xmm0,(%rcx) */
|
||||
0x8b, 0x54, 0x24, 0x40, /* mov 0x40(%rsp),%edx (sum) */
|
||||
0x89, 0x51, 0x04, /* mov %edx,4(%rcx) */
|
||||
0x8b, 0x14, 0x24, /* mov (%rsp),%edx (tile rows) */
|
||||
0x89, 0x51, 0x08, /* mov %edx,8(%rcx) */
|
||||
0xff, 0x41, 0x0c, /* incl 12(%rcx) */
|
||||
0xff, 0x25, 0x00, 0x00, 0x00, 0x00, /* jmp *0(%rip) */
|
||||
0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, /* return address */
|
||||
};
|
||||
#define ISSN_EXPOSURE_PATCH_MAILBOX 2u
|
||||
|
||||
/* The mailbox (gpu/shim/issngpu.cpp): written by the game's thread, read by the menu. */
|
||||
typedef struct {
|
||||
float value; /* the metered value the game stored */
|
||||
float sum; /* the raw sum of the tiles */
|
||||
uint32_t rows; /* tile rows (render height / 72) */
|
||||
uint32_t calls; /* frames metered */
|
||||
uint32_t held; /* nonzero: the bits of the value stored instead */
|
||||
} IssnExposureMailbox;
|
||||
|
||||
#endif
|
||||
+118
@@ -53,4 +53,122 @@ static inline int issn_fov_redirect(unsigned char *image, const float *value) {
|
||||
memcpy(image + issn_fov_sites[i] + 4, &disp[i], 4);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* The cave page (read-write): [0] 0.5 x factor (the four sites above), [1] the factor itself
|
||||
* (the readers below), then the cameras the game gave an angle (SetFov's rdi), one slot per
|
||||
* hash of the address: the game has more than one (the render camera copies the angle of the
|
||||
* gameplay one through SetFov, and recomputes only when the angle differs). */
|
||||
#define ISSN_FOV_CAMERA_SLOTS 16u
|
||||
typedef struct { float half_factor, factor; uint64_t camera[ISSN_FOV_CAMERA_SLOTS]; } IssnFovCave;
|
||||
|
||||
/* Code that reads the camera's angle (+0x590) itself, not the tan the four sites build: each gets
|
||||
* a stub that replays the read and multiplies (divides, for a vdivss) by the factor, so it agrees
|
||||
* with the wider picture. Factor 1 (Original) changes nothing. Which of them aims is not known
|
||||
* yet: ISSN_FOV_AIM (bit i = reader i) picks them, default the three next to the aspect (+0x59c). */
|
||||
#define ISSN_FOV_READER_COUNT 7u
|
||||
typedef struct { uint32_t at; uint8_t length, xmm, divide, ret; uint8_t code[9]; } IssnFovReader;
|
||||
static const IssnFovReader issn_fov_readers[ISSN_FOV_READER_COUNT] = {
|
||||
{0x22c71bu, 8, 2, 0, 0, {0xc5, 0xfa, 0x10, 0x93, 0x90, 0x05, 0x00, 0x00}}, /* vmovss xmm2,[rbx+590h] (+59c) */
|
||||
{0x14a89bu, 8, 0, 0, 0, {0xc5, 0xfa, 0x10, 0x83, 0x90, 0x05, 0x00, 0x00}}, /* vmovss xmm0,[rbx+590h] (+59c) */
|
||||
{0x144fb3u, 9, 0, 0, 0, {0xc4, 0xc1, 0x7a, 0x10, 0x86, 0x90, 0x05, 0x00, 0x00}}, /* vmovss xmm0,[r14+590h] (+59c, +694) */
|
||||
{0x2e5460u, 9, 0, 0, 1, {0xc5, 0xfa, 0x10, 0x87, 0x90, 0x05, 0x00, 0x00, 0xc3}}, /* GetFov: vmovss xmm0,[rdi+590h]; ret */
|
||||
{0x43ac25u, 9, 0, 1, 0, {0xc4, 0xc1, 0x7a, 0x5e, 0x87, 0x90, 0x05, 0x00, 0x00}}, /* vdivss xmm0,xmm0,[r15+590h] */
|
||||
{0x133159u, 8, 2, 0, 0, {0xc5, 0xfa, 0x10, 0x93, 0x90, 0x05, 0x00, 0x00}}, /* vmovss xmm2,[rbx+590h] */
|
||||
{0x145745u, 8, 0, 0, 0, {0xc5, 0xfa, 0x10, 0x80, 0x90, 0x05, 0x00, 0x00}}, /* vmovss xmm0,[rax+590h] */
|
||||
};
|
||||
#define ISSN_FOV_AIM_DEFAULT 0x0u /* none: the aim is the two sites below (tester 10-31) */
|
||||
|
||||
/* The aim: two places build the shot's direction through the reticle from the field of view the
|
||||
* controller hands the camera (+0x468 + +0x358), not from the camera: tan(0.5 x angle) x the
|
||||
* camera's aspect x the reticle's screen position. The reticle sits above the centre, so with
|
||||
* only the picture wider the shots landed below it (1.15x a little, 1.4x more). Their 0.5 goes to
|
||||
* the same cave float as the camera's four sites. */
|
||||
#define ISSN_FOV_AIM_SITE_COUNT 2u
|
||||
static const uint32_t issn_fov_aim_sites[ISSN_FOV_AIM_SITE_COUNT] = {0x14ebf2u, 0x14a5e9u};
|
||||
static const unsigned char issn_fov_aim_opcodes[ISSN_FOV_AIM_SITE_COUNT][4] = {
|
||||
{0xc5, 0xfa, 0x59, 0x05}, /* vmulss xmm0,xmm0,[rip+X] */
|
||||
{0xc5, 0xea, 0x59, 0x05}};/* vmulss xmm0,xmm2,[rip+X] */
|
||||
static const uint32_t issn_fov_aim_constants[ISSN_FOV_AIM_SITE_COUNT] = {0x9960a4u, 0x996040u};
|
||||
|
||||
/* Points the aim sites at `value`; returns how many were (0 when the code differs). */
|
||||
static inline unsigned issn_fov_redirect_aim(unsigned char *image, uint64_t size, const float *value) {
|
||||
unsigned done = 0;
|
||||
for (unsigned i = 0; i < ISSN_FOV_AIM_SITE_COUNT; ++i) {
|
||||
const uint32_t at = issn_fov_aim_sites[i];
|
||||
if ((uint64_t)at + ISSN_FOV_SITE_LENGTH > size || memcmp(image + at, issn_fov_aim_opcodes[i], 4)) continue;
|
||||
int32_t disp;
|
||||
memcpy(&disp, image + at + 4, 4);
|
||||
if ((int64_t)at + ISSN_FOV_SITE_LENGTH + disp != (int64_t)issn_fov_aim_constants[i]) continue;
|
||||
float original;
|
||||
memcpy(&original, image + issn_fov_aim_constants[i], 4);
|
||||
if (original != ISSN_FOV_ORIGINAL) continue;
|
||||
const int64_t delta = (int64_t)(uintptr_t)value - (int64_t)(uintptr_t)(image + at + ISSN_FOV_SITE_LENGTH);
|
||||
if (delta < INT32_MIN || delta > INT32_MAX) continue;
|
||||
disp = (int32_t)delta;
|
||||
memcpy(image + at + 4, &disp, 4);
|
||||
++done;
|
||||
}
|
||||
return done;
|
||||
}
|
||||
|
||||
/* SetFov(camera, angle) at 0x580580: its first two instructions after the prologue are replayed
|
||||
* after `mov [camera slot], rdi`, so the menu can ask that camera to work its angle out again. */
|
||||
#define ISSN_FOV_SETFOV_SITE 0x580586u
|
||||
#define ISSN_FOV_SETFOV_LENGTH 11u
|
||||
static const unsigned char issn_fov_setfov_code[ISSN_FOV_SETFOV_LENGTH] = {
|
||||
0x48, 0x89, 0xfb, 0xc5, 0xfa, 0x11, 0x83, 0xd4, 0x05, 0x00, 0x00}; /* mov rbx,rdi; vmovss [rbx+5d4h],xmm0 */
|
||||
|
||||
static inline int issn_rel32(const unsigned char *next, const void *target, int32_t *out) {
|
||||
const int64_t d = (int64_t)(uintptr_t)target - (int64_t)(uintptr_t)next;
|
||||
if (d < INT32_MIN || d > INT32_MAX) return 0;
|
||||
*out = (int32_t)d;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* A reader's stub at `out`: the read, vmulss/vdivss xmmN,xmmN,[rip -> factor], then ret (GetFov)
|
||||
* or a jmp back. Returns its length, 0 when out of reach. */
|
||||
static inline unsigned issn_fov_build_reader(unsigned char *out, const unsigned char *image,
|
||||
const IssnFovReader *r, const float *factor) {
|
||||
unsigned n = 0;
|
||||
const unsigned body = r->ret ? r->length - 1u : r->length;
|
||||
memcpy(out, r->code, body); n += body;
|
||||
out[n++] = 0xc5;
|
||||
out[n++] = (uint8_t)(0x80 | ((~r->xmm & 15) << 3) | 0x02); /* R=1, vvvv=xmmN, L=0, F3 */
|
||||
out[n++] = r->divide ? 0x5e : 0x59;
|
||||
out[n++] = (uint8_t)((r->xmm & 7) << 3 | 5); /* [rip+disp32] */
|
||||
int32_t d;
|
||||
if (!issn_rel32(out + n + 4, factor, &d)) return 0;
|
||||
memcpy(out + n, &d, 4); n += 4;
|
||||
if (r->ret) { out[n++] = 0xc3; return n; }
|
||||
out[n++] = 0xe9;
|
||||
if (!issn_rel32(out + n + 4, image + r->at + r->length, &d)) return 0;
|
||||
memcpy(out + n, &d, 4); n += 4;
|
||||
return n;
|
||||
}
|
||||
|
||||
/* SetFov's stub: push rax/rcx; rax = (rdi >> 6) & 15; rcx = &slots; mov [rcx+rax*8], rdi;
|
||||
* pop; the two replaced instructions; jmp back. Flags are dead here (the vucomiss after the
|
||||
* replayed store sets them) and SetFov has its frame, so the pushes below rsp are free. */
|
||||
static inline unsigned issn_fov_build_setfov(unsigned char *out, const unsigned char *image, uint64_t *slots) {
|
||||
static const unsigned char head[] = {
|
||||
0x50, 0x51, /* push rax; push rcx */
|
||||
0x48, 0x89, 0xf8, /* mov rax, rdi */
|
||||
0x48, 0xc1, 0xe8, 0x06, /* shr rax, 6 */
|
||||
0x83, 0xe0, 0x0f, /* and eax, 15 */
|
||||
0x48, 0x8d, 0x0d}; /* lea rcx, [rip+disp32] */
|
||||
static const unsigned char tail[] = {
|
||||
0x48, 0x89, 0x3c, 0xc1, /* mov [rcx+rax*8], rdi */
|
||||
0x59, 0x58}; /* pop rcx; pop rax */
|
||||
unsigned n = 0;
|
||||
int32_t d;
|
||||
memcpy(out, head, sizeof head); n += sizeof head;
|
||||
if (!issn_rel32(out + n + 4, slots, &d)) return 0;
|
||||
memcpy(out + n, &d, 4); n += 4;
|
||||
memcpy(out + n, tail, sizeof tail); n += sizeof tail;
|
||||
memcpy(out + n, issn_fov_setfov_code, ISSN_FOV_SETFOV_LENGTH); n += ISSN_FOV_SETFOV_LENGTH;
|
||||
out[n++] = 0xe9;
|
||||
if (!issn_rel32(out + n + 4, image + ISSN_FOV_SETFOV_SITE + ISSN_FOV_SETFOV_LENGTH, &d)) return 0;
|
||||
memcpy(out + n, &d, 4); n += 4;
|
||||
return n;
|
||||
}
|
||||
#endif
|
||||
+110
-4
@@ -7,8 +7,10 @@
|
||||
* - character: hit points at +0xc04, the maximum at +0xc00. Every change goes through
|
||||
* SetHitPoints(character, float hp, source) at 0x4f38b0 (clamps to >= 0, stores, runs death);
|
||||
* - hero: power energy ("magic") at +0x1a1e8, its maximum at +0x1a1f0 (AdjustMagic 0x214b10 and
|
||||
* the cast debit 0x20c930 clamp between them); the Karma Bomb meter ("heavy magic") at
|
||||
* +0x1ee50, its maximum at +0x1ee54.
|
||||
* the cast debit 0x20c930 clamp between them).
|
||||
* 11-05: +0x1ee50/+0x1ee54, filled before as "the Karma Bomb meter", are CURRENT_SHIELD_POINTS and
|
||||
* MAX_SHIELD_POINTS (the binding table's data sits in the entry before each name); the Karma Bomb
|
||||
* is the karmic streak below (João 11-05: the old switch did not keep it ready).
|
||||
* Infinite health: a stub at SetHitPoints keeps the hero's character from losing hit points
|
||||
* (xmm0 = max(xmm0, current)) while the flag is on; the shim also refills both meters. */
|
||||
#ifndef ISSN_HERO_HOOK_H
|
||||
@@ -22,8 +24,83 @@
|
||||
#define ISSN_HERO_HP_MAX 0xc00u /* character: float maximum */
|
||||
#define ISSN_HERO_MAGIC 0x1a1e8u /* hero: float power energy */
|
||||
#define ISSN_HERO_MAGIC_MAX 0x1a1f0u
|
||||
#define ISSN_HERO_KARMA 0x1ee50u /* hero: float Karma Bomb meter */
|
||||
#define ISSN_HERO_KARMA_MAX 0x1ee54u
|
||||
/* The Karma Bomb: the karmic streak object at hero +0x1ef30 (KARMIC_STREAK_CURRENT 0x2e43c0 reads
|
||||
* its count at +0x28; KARMIC_STREAK_REQUIRED 0x266b00 reads +0x60 + side * 0x28, the side being the
|
||||
* player state's +0xc4, 0 or 1). When the count reaches it, 0x266310 sets +0x38 to the clock time
|
||||
* the bomb stays ready until (0x7fffffff: no limit) and IS_IONIC_AVAILABLE (0x2e43a0) is "+0x38
|
||||
* later than now"; using the bomb calls 0x266310 with 0, which clears both. */
|
||||
#define ISSN_HERO_STREAK 0x1ef30u
|
||||
#define ISSN_HERO_STREAK_COUNT 0x28u
|
||||
#define ISSN_HERO_STREAK_READY_UNTIL 0x38u
|
||||
#define ISSN_HERO_STREAK_REQUIRED 0x60u
|
||||
#define ISSN_HERO_STREAK_SIDE_STRIDE 0x28u
|
||||
#define ISSN_HERO_PLAYER_SIDE 0xc4u
|
||||
#define ISSN_HERO_STREAK_GETTER 0x2e43c0u
|
||||
#define ISSN_HERO_STREAK_REQUIRED_GETTER 0x266b00u
|
||||
static const unsigned char issn_hero_streak_getter_code[] = {0x8b, 0x87, 0x58, 0xef, 0x01, 0x00, 0xc3};
|
||||
/* lea X(%rip),%rax (X = the player state); movslq 0xc4(%rax),%rcx; xor %eax,%eax; cmp $1,%rcx; ja;
|
||||
* lea (%rcx,%rcx,4),%rax; mov 0x60(%rdi,%rax,8),%eax */
|
||||
static const unsigned char issn_hero_streak_required_tail[] = {
|
||||
0x48, 0x63, 0x88, 0xc4, 0x00, 0x00, 0x00, 0x31, 0xc0, 0x48, 0x83, 0xf9, 0x01, 0x77, 0x08,
|
||||
0x48, 0x8d, 0x04, 0x89, 0x8b, 0x44, 0xc7, 0x60};
|
||||
|
||||
/* 11-05: the missiles' ammo ("heavy magic") is an int in the player state (bss 0xefacf8 + 0xdc;
|
||||
* getter 0x214e40, setter 0x1e35a0, which also counts the change in +0x20acc for the save); the
|
||||
* hero holds its maximum (+0x1ee4c). DebitMagicForCast (0x20c740) takes each power's heavy cost
|
||||
* (+0x1a204 per power slot) from it, so infinite powers did not cover the missiles. */
|
||||
#define ISSN_HERO_PLAYER 0xefacf8u /* player state (bss) */
|
||||
#define ISSN_HERO_HEAVY 0xdcu /* player state: int missiles left */
|
||||
#define ISSN_HERO_HEAVY_MAX 0x1ee4cu /* hero: int maximum */
|
||||
#define ISSN_HERO_HEAVY_GETTER 0x214e40u
|
||||
#define ISSN_HERO_HEAVY_SETTER 0x1e35a0u
|
||||
#define ISSN_HERO_CLOCK 0xe16290u /* the game clock (+0x24 is read by the setter) */
|
||||
/* lea X(%rip),%rax (X = the player state); mov 0xdc(%rax),%eax; ret */
|
||||
static const unsigned char issn_hero_heavy_getter_tail[] = {0x8b, 0x80, 0xdc, 0x00, 0x00, 0x00, 0xc3};
|
||||
/* cmp %esi,0xdc(%rdi); je; mov %esi,0xdc(%rdi); cmpb $0,0x20ac9(%rdi) */
|
||||
static const unsigned char issn_hero_heavy_setter_code[] = {
|
||||
0x39, 0xb7, 0xdc, 0x00, 0x00, 0x00, 0x74, 0x43, 0x89, 0xb7, 0xdc, 0x00, 0x00, 0x00,
|
||||
0x80, 0xbf, 0xc9, 0x0a, 0x02, 0x00, 0x00};
|
||||
|
||||
static inline int issn_hero_heavy_match(const unsigned char *image, uint64_t size) {
|
||||
if (size < ISSN_HERO_PLAYER + 0x20ad8) return 0;
|
||||
if (memcmp(image + ISSN_HERO_HEAVY_GETTER, "\x48\x8d\x05", 3) ||
|
||||
memcmp(image + ISSN_HERO_HEAVY_GETTER + 7, issn_hero_heavy_getter_tail,
|
||||
sizeof(issn_hero_heavy_getter_tail))) return 0;
|
||||
int32_t disp;
|
||||
memcpy(&disp, image + ISSN_HERO_HEAVY_GETTER + 3, 4);
|
||||
if ((int64_t)ISSN_HERO_HEAVY_GETTER + 7 + disp != (int64_t)ISSN_HERO_PLAYER) return 0;
|
||||
return !memcmp(image + ISSN_HERO_HEAVY_SETTER, issn_hero_heavy_setter_code,
|
||||
sizeof(issn_hero_heavy_setter_code));
|
||||
}
|
||||
|
||||
static inline int issn_hero_karma_match(const unsigned char *image, uint64_t size) {
|
||||
if (size < ISSN_HERO_PLAYER + 0x100) return 0;
|
||||
if (memcmp(image + ISSN_HERO_STREAK_GETTER, issn_hero_streak_getter_code,
|
||||
sizeof(issn_hero_streak_getter_code))) return 0;
|
||||
if (memcmp(image + ISSN_HERO_STREAK_REQUIRED_GETTER, "\x48\x8d\x05", 3) ||
|
||||
memcmp(image + ISSN_HERO_STREAK_REQUIRED_GETTER + 7, issn_hero_streak_required_tail,
|
||||
sizeof(issn_hero_streak_required_tail))) return 0;
|
||||
int32_t disp;
|
||||
memcpy(&disp, image + ISSN_HERO_STREAK_REQUIRED_GETTER + 3, 4);
|
||||
return (int64_t)ISSN_HERO_STREAK_REQUIRED_GETTER + 7 + disp == (int64_t)ISSN_HERO_PLAYER;
|
||||
}
|
||||
|
||||
/* 11-05: a hit reaches a character through its slot-60 method (0x4f7520, the same for the
|
||||
* hero's character class, vtable 0xcdb7d0, and the plain one): it notes the hit time (+0x1afc),
|
||||
* adds the damage share (+0x1b00), calls AdjustHitPoints (0x4f4380: the recent-damage history at
|
||||
* +0xc20 and the hit time +0xc18, then SetHitPoints) and sends the damage response (0x46aa00).
|
||||
* The SetHitPoints stub kept the health, but the rest still ran (the gray screen). With infinite
|
||||
* health the damage stub returns at once for the hero's character: the hit does not happen. */
|
||||
#define ISSN_HERO_TAKE_HIT 0x4f7520u
|
||||
#define ISSN_HERO_TAKE_HIT_LENGTH 10u /* push rbp; mov rsp,rbp; push r15; push r14; push r12 */
|
||||
static const unsigned char issn_hero_take_hit_code[] = {
|
||||
0x55, 0x48, 0x89, 0xe5, 0x41, 0x57, 0x41, 0x56, 0x41, 0x54, 0x53, 0x48, 0x83, 0xec, 0x20,
|
||||
0x4c, 0x8b, 0x25};
|
||||
|
||||
static inline int issn_hero_take_hit_match(const unsigned char *image, uint64_t size) {
|
||||
return size >= ISSN_HERO_TAKE_HIT + sizeof(issn_hero_take_hit_code) &&
|
||||
!memcmp(image + ISSN_HERO_TAKE_HIT, issn_hero_take_hit_code, sizeof(issn_hero_take_hit_code));
|
||||
}
|
||||
|
||||
#define ISSN_HERO_SET_HP 0x4f38b0u /* SetHitPoints */
|
||||
#define ISSN_HERO_SITE 0x4f38beu /* the 10 bytes the stub replays */
|
||||
@@ -86,4 +163,33 @@ static inline unsigned issn_hero_build_stub(unsigned char *out, const unsigned c
|
||||
memcpy(out + n, &back, 8); n += 8;
|
||||
return n;
|
||||
}
|
||||
/* The damage stub at the slot-60 method: with the flag on and rdi the hero's character, return
|
||||
* at once (the method returns nothing); otherwise the replaced instructions and a jump back. rax
|
||||
* is free at a function's start. Returns the stub's length. */
|
||||
static inline unsigned issn_hero_build_damage_stub(unsigned char *out, const unsigned char *image,
|
||||
const volatile unsigned char *flag) {
|
||||
const uint64_t global = (uint64_t)(uintptr_t)(image + ISSN_HERO_GLOBAL);
|
||||
const uint64_t flag_at = (uint64_t)(uintptr_t)flag;
|
||||
const uint64_t back = (uint64_t)(uintptr_t)(image + ISSN_HERO_TAKE_HIT + ISSN_HERO_TAKE_HIT_LENGTH);
|
||||
unsigned n = 0;
|
||||
out[n++] = 0x48; out[n++] = 0xb8; memcpy(out + n, &flag_at, 8); n += 8; /* movabs $flag,%rax */
|
||||
out[n++] = 0x80; out[n++] = 0x38; out[n++] = 0x00; /* cmpb $0,(%rax) */
|
||||
out[n++] = 0x74; const unsigned j1 = n++; /* je replay */
|
||||
out[n++] = 0x48; out[n++] = 0xb8; memcpy(out + n, &global, 8); n += 8; /* movabs $global,%rax */
|
||||
out[n++] = 0x48; out[n++] = 0x8b; out[n++] = 0x00; /* mov (%rax),%rax */
|
||||
out[n++] = 0x48; out[n++] = 0x85; out[n++] = 0xc0; /* test %rax,%rax */
|
||||
out[n++] = 0x74; const unsigned j2 = n++; /* je replay */
|
||||
out[n++] = 0x48; out[n++] = 0x8b; out[n++] = 0x40; out[n++] = ISSN_HERO_CHARACTER; /* mov 0x38(%rax),%rax */
|
||||
out[n++] = 0x48; out[n++] = 0x39; out[n++] = 0xf8; /* cmp %rdi,%rax */
|
||||
out[n++] = 0x75; const unsigned j3 = n++; /* jne replay */
|
||||
out[n++] = 0xc3; /* ret: no hit */
|
||||
const unsigned replay = n;
|
||||
out[j1] = (unsigned char)(replay - (j1 + 1));
|
||||
out[j2] = (unsigned char)(replay - (j2 + 1));
|
||||
out[j3] = (unsigned char)(replay - (j3 + 1));
|
||||
memcpy(out + n, image + ISSN_HERO_TAKE_HIT, ISSN_HERO_TAKE_HIT_LENGTH); n += ISSN_HERO_TAKE_HIT_LENGTH;
|
||||
out[n++] = 0xff; out[n++] = 0x25; out[n++] = 0; out[n++] = 0; out[n++] = 0; out[n++] = 0; /* jmp *0(%rip) */
|
||||
memcpy(out + n, &back, 8); n += 8;
|
||||
return n;
|
||||
}
|
||||
#endif
|
||||
+123
-11
@@ -212,6 +212,16 @@ static LONG fatal_exception(EXCEPTION_POINTERS *info) {
|
||||
fprintf(stderr,"Fault: exception 0x%08lx at %s (thread %lu)\n",record->ExceptionCode,where,GetCurrentThreadId());
|
||||
fprintf(stderr," rax=%016llx rbx=%016llx rcx=%016llx rdx=%016llx\n rsi=%016llx rdi=%016llx rbp=%016llx rsp=%016llx\n",
|
||||
c->Rax,c->Rbx,c->Rcx,c->Rdx,c->Rsi,c->Rdi,c->Rbp,c->Rsp);
|
||||
/* issn 11-05 (#33, uriel): right after art/cache/all_shaders.xpps is read, the game looks up
|
||||
* its shaders by name (eboot 0x44b170) and finds the table empty (rcx = 0 entries): the
|
||||
* package holds none of the shaders this eboot.bin asks for. Seen only with dumps that were
|
||||
* not the merged 01.07 game; most likely an update folder without its own all_shaders.xpps
|
||||
* over an older base game, or a damaged file. */
|
||||
if (image && c->Rip-(uintptr_t)image==0x44b1e0 && c->Rcx==0)
|
||||
fprintf(stderr,"STOP: the game found none of its shaders in art/cache/all_shaders.xpps. This "
|
||||
"file most likely does not match the game's 01.07 eboot.bin (an update folder "
|
||||
"without it over an older base game) or is damaged. Choose a game folder that "
|
||||
"already has the 01.07 update merged in, or dump the game and its update again.\n");
|
||||
if (gpu_enabled) issngpu_dump_guest_writes(info);
|
||||
/* rbp chain (guest and host code keep frame pointers). */
|
||||
uintptr_t rbp=(uintptr_t)c->Rbp;
|
||||
@@ -774,8 +784,36 @@ static void install_view_tuning(Segment *segments, uint64_t count, uint64_t size
|
||||
}
|
||||
|
||||
#include "hero_hook.h"
|
||||
#include "exposure_hook.h"
|
||||
extern const volatile unsigned char *issn_hero_health_flag(void);
|
||||
extern void issn_hero_attach(unsigned char *guest_image);
|
||||
#include "dash_hook.h"
|
||||
extern const volatile unsigned char *issn_hero_neon_flag(void);
|
||||
extern const volatile unsigned char *issn_hero_tv_flag(void);
|
||||
extern void issn_hero_dash_supported(int neon, int tv);
|
||||
extern void issn_hero_extra_supported(int missiles, int karma, int no_hit);
|
||||
|
||||
/* issn-axy: the neon run and the video flight go on while the dash button is held
|
||||
* (src/dash_hook.h): two stubs that skip the game's time limits while the menu's switches are on.
|
||||
* Each one only when its code is the expected one. */
|
||||
static void install_dash_cheats(Segment *segments, uint64_t count, uint64_t size) {
|
||||
const int neon = mapped(segments, count, ISSN_DASH_NEON_SITE, sizeof(issn_dash_neon_code)) &&
|
||||
mapped(segments, count, ISSN_DASH_ABILITIES, 4) && issn_dash_neon_match(image, size);
|
||||
const int tv = mapped(segments, count, ISSN_DASH_TV_SITE, sizeof(issn_dash_tv_code)) &&
|
||||
mapped(segments, count, ISSN_DASH_TV_LIMIT, 4) && issn_dash_tv_match(image, size);
|
||||
int neon_on = 0, tv_on = 0;
|
||||
if (neon || tv) {
|
||||
unsigned char *stubs = allocate_cave(page_size);
|
||||
if (neon) issn_dash_build_neon_stub(stubs, image, issn_hero_neon_flag());
|
||||
if (tv) issn_dash_build_tv_stub(stubs + 256, image, issn_hero_tv_flag());
|
||||
protect(stubs, page_size, 5);
|
||||
neon_on = neon && install_jump(ISSN_DASH_NEON_SITE, ISSN_DASH_NEON_SITE_LENGTH, stubs);
|
||||
tv_on = tv && install_jump(ISSN_DASH_TV_SITE, ISSN_DASH_TV_SITE_LENGTH, stubs + 256);
|
||||
}
|
||||
printf("Cheats: neon run %s, video flight %s\n", neon_on ? "ready" : "unavailable",
|
||||
tv_on ? "ready" : "unavailable");
|
||||
issn_hero_dash_supported(neon_on, tv_on);
|
||||
}
|
||||
|
||||
/* issn: Delsin's cheats (src/hero_hook.h): a stub at SetHitPoints for infinite health; the shim
|
||||
* refills health, powers and the Karma Bomb meter. Silent for other builds. */
|
||||
@@ -797,21 +835,42 @@ static void install_hero_cheats(Segment *segments, uint64_t count, uint64_t size
|
||||
printf("Cheats: unavailable (stub outside relative-jump range)\n");
|
||||
return;
|
||||
}
|
||||
install_dash_cheats(segments, count, size);
|
||||
/* 11-05: with infinite health the hit itself is stopped (no gray screen); missiles and the
|
||||
* Karma Bomb are refilled by the shim when their code is the expected one. */
|
||||
int no_hit = 0;
|
||||
if (mapped(segments, count, ISSN_HERO_TAKE_HIT, sizeof(issn_hero_take_hit_code)) &&
|
||||
issn_hero_take_hit_match(image, size)) {
|
||||
unsigned char *damage = allocate_cave(page_size);
|
||||
issn_hero_build_damage_stub(damage, image, issn_hero_health_flag());
|
||||
protect(damage, page_size, 5);
|
||||
no_hit = install_jump(ISSN_HERO_TAKE_HIT, ISSN_HERO_TAKE_HIT_LENGTH, damage);
|
||||
}
|
||||
const int missiles = mapped(segments, count, ISSN_HERO_HEAVY_GETTER, 14) &&
|
||||
mapped(segments, count, ISSN_HERO_HEAVY_SETTER, sizeof(issn_hero_heavy_setter_code)) &&
|
||||
mapped(segments, count, ISSN_HERO_PLAYER, 0x20ad8) &&
|
||||
issn_hero_heavy_match(image, size);
|
||||
const int karma = mapped(segments, count, ISSN_HERO_STREAK_GETTER, sizeof(issn_hero_streak_getter_code)) &&
|
||||
mapped(segments, count, ISSN_HERO_STREAK_REQUIRED_GETTER, 30) &&
|
||||
issn_hero_karma_match(image, size);
|
||||
printf("Cheats: no-hit %s, missiles %s, Karma Bomb %s\n", no_hit ? "ready" : "unavailable",
|
||||
missiles ? "ready" : "unavailable", karma ? "ready" : "unavailable");
|
||||
issn_hero_extra_supported(missiles, karma, no_hit);
|
||||
issn_hero_attach(image);
|
||||
}
|
||||
|
||||
#include "fov_hook.h"
|
||||
extern void issn_fov_attach(float *value);
|
||||
extern void issn_fov_attach(IssnFovCave *cave);
|
||||
|
||||
/* issn: field of view (src/fov_hook.h): the camera's four 0.5 constants are read from a float in
|
||||
* a page of its own, kept read-write; the menu writes 0.5 x its factor there (issn_view.cpp).
|
||||
* Silent for other builds; with a field of view patch on, the patch's fixed angle stays. */
|
||||
* Code that reads the camera's angle itself gets the factor too (ISSN_FOV_AIM, the aim), and
|
||||
* SetFov leaves the camera's address so a menu change shows at once. Silent for other builds;
|
||||
* with a field of view patch on, the patch's fixed angle stays. ISSN_FOV_MENU=0 leaves the game's
|
||||
* camera alone (tested 10-31: the shots land on the reticle at 1.15x and 1.4x). */
|
||||
static void install_fov(Segment *segments, uint64_t count, uint64_t size) {
|
||||
/* Off unless ISSN_FOV=1 (0.3.6.3, tester 10-31): the camera computes its tan only when the game
|
||||
* changes the angle (+0x590), so a menu change waits for the next one, and code that reads the
|
||||
* angle itself (aim among them) keeps the original: shots land below the reticle. */
|
||||
const char *on = getenv("ISSN_FOV");
|
||||
if (!on || on[0] != '1') return;
|
||||
const char *menu = getenv("ISSN_FOV_MENU"); /* not ISSN_FOV: that one sets the factor itself */
|
||||
if (menu && menu[0] == '0') return;
|
||||
const char *off = getenv("ISSN_FOV_HOOK");
|
||||
if (off && off[0] == '0') return;
|
||||
for (unsigned i = 0; i < ISSN_FOV_SITE_COUNT; ++i)
|
||||
@@ -821,13 +880,39 @@ static void install_fov(Segment *segments, uint64_t count, uint64_t size) {
|
||||
"patch is on, or another eboot build)\n");
|
||||
return;
|
||||
}
|
||||
float *value = (float *)allocate_cave(page_size); /* stays read-write: data only */
|
||||
*value = ISSN_FOV_ORIGINAL;
|
||||
if (!issn_fov_redirect(image, value)) {
|
||||
IssnFovCave *cave = (IssnFovCave *)allocate_cave(page_size); /* stays read-write: data only */
|
||||
cave->half_factor = ISSN_FOV_ORIGINAL;
|
||||
cave->factor = 1.0f;
|
||||
memset(cave->camera, 0, sizeof cave->camera);
|
||||
if (!issn_fov_redirect(image, &cave->half_factor)) {
|
||||
printf("Field of view: menu control unavailable (outside relative reach)\n");
|
||||
return;
|
||||
}
|
||||
issn_fov_attach(value);
|
||||
const char *aim_off = getenv("ISSN_FOV_AIM_SITES");
|
||||
const unsigned aim = aim_off && aim_off[0] == '0' ? 0 : issn_fov_redirect_aim(image, size, &cave->half_factor);
|
||||
const char *mask_text = getenv("ISSN_FOV_AIM");
|
||||
const unsigned mask = mask_text ? (unsigned)strtoul(mask_text, NULL, 0) : ISSN_FOV_AIM_DEFAULT;
|
||||
unsigned char *stubs = allocate_cave(page_size);
|
||||
unsigned used = 0, readers = 0;
|
||||
for (unsigned i = 0; i < ISSN_FOV_READER_COUNT; ++i) {
|
||||
const IssnFovReader *r = &issn_fov_readers[i];
|
||||
if (!(mask >> i & 1) || !mapped(segments, count, r->at, r->length) ||
|
||||
memcmp(image + r->at, r->code, r->length)) continue;
|
||||
const unsigned n = issn_fov_build_reader(stubs + used, image, r, &cave->factor);
|
||||
if (!n || !install_jump(r->at, r->length, stubs + used)) continue;
|
||||
used += (n + 15u) & ~15u;
|
||||
++readers;
|
||||
}
|
||||
int live = 0;
|
||||
if (mapped(segments, count, ISSN_FOV_SETFOV_SITE, ISSN_FOV_SETFOV_LENGTH) &&
|
||||
!memcmp(image + ISSN_FOV_SETFOV_SITE, issn_fov_setfov_code, ISSN_FOV_SETFOV_LENGTH)) {
|
||||
const unsigned n = issn_fov_build_setfov(stubs + used, image, cave->camera);
|
||||
live = n && install_jump(ISSN_FOV_SETFOV_SITE, ISSN_FOV_SETFOV_LENGTH, stubs + used);
|
||||
}
|
||||
protect(stubs, page_size, 5);
|
||||
printf("Field of view: aim follows the factor at %u of %u sites; %u angle readers (ISSN_FOV_AIM=0x%x); "
|
||||
"live change %s\n", aim, ISSN_FOV_AIM_SITE_COUNT, readers, mask, live ? "on" : "off");
|
||||
issn_fov_attach(cave);
|
||||
}
|
||||
|
||||
extern IssnMouseMailbox *runtime_mouse_aim_mailbox(void);
|
||||
@@ -869,6 +954,32 @@ static void install_mouse_aim(Segment *segments, uint64_t count, uint64_t size)
|
||||
printf("Mouse aim: camera hook ready (tilt clamp %s)\n", free_tilt ? "off" : "on");
|
||||
}
|
||||
|
||||
/* issn 11-04: the exposure meter's diagnostic hook (src/exposure_hook.h): the menu shows what the
|
||||
* game meters and can hold it. ISSN_EXPOSURE_HOOK=0 leaves the code alone. */
|
||||
static void install_exposure_meter(Segment *segments, uint64_t count) {
|
||||
const char *off = getenv("ISSN_EXPOSURE_HOOK");
|
||||
if (off && off[0] == '0') return;
|
||||
if (!mapped(segments, count, ISSN_EXPOSURE_EXPECTED_AT, sizeof(issn_exposure_expected)) ||
|
||||
memcmp(image + ISSN_EXPOSURE_EXPECTED_AT, issn_exposure_expected, sizeof(issn_exposure_expected))) {
|
||||
printf("Exposure meter: diagnostic hook unavailable (the code differs in this eboot.bin)\n");
|
||||
return;
|
||||
}
|
||||
unsigned char *cave = allocate_cave(page_size);
|
||||
if (!cave) return;
|
||||
memcpy(cave, issn_exposure_cave, sizeof(issn_exposure_cave));
|
||||
const uint64_t mailbox = (uint64_t)(uintptr_t)issngpu_exposure_mailbox();
|
||||
const uint64_t resume = (uint64_t)(uintptr_t)(image + ISSN_EXPOSURE_SITE + ISSN_EXPOSURE_SITE_LENGTH);
|
||||
memcpy(cave + ISSN_EXPOSURE_PATCH_MAILBOX, &mailbox, 8);
|
||||
memcpy(cave + sizeof(issn_exposure_cave) - 8, &resume, 8);
|
||||
protect(cave, page_size, 5);
|
||||
if (!install_jump(ISSN_EXPOSURE_SITE, ISSN_EXPOSURE_SITE_LENGTH, cave)) {
|
||||
printf("Exposure meter: diagnostic hook unavailable (cave out of jump range)\n");
|
||||
return;
|
||||
}
|
||||
issngpu_exposure_attached();
|
||||
printf("Exposure meter: diagnostic hook ready (Insert > Image > Exposure)\n");
|
||||
}
|
||||
|
||||
/* Damage dealt/taken and green/blue/gold orb multipliers (src/damage_hook.h, src/orb_hook.h):
|
||||
* the stubs read the menu's floats directly, so changes apply on the next hit or pickup. */
|
||||
static void install_stat_multipliers(Segment *segments, uint64_t count, uint64_t size) {
|
||||
@@ -1330,6 +1441,7 @@ int main(int argc, char **argv) {
|
||||
if (gpu_enabled) install_view_tuning(segments, ns, size);
|
||||
if (gpu_enabled) install_mouse_aim(segments, ns, size);
|
||||
if (gpu_enabled) install_fov(segments, ns, size);
|
||||
if (gpu_enabled) install_exposure_meter(segments, ns);
|
||||
if (gpu_enabled) install_hero_cheats(segments, ns, size);
|
||||
if (gpu_enabled) issngpu_apply_game_patches();
|
||||
#ifdef _WIN32
|
||||
|
||||
+43
-17
@@ -9,7 +9,7 @@
|
||||
* powers, the drains.)
|
||||
*
|
||||
* issn: synthetic motion (axylpup's SyntheticMotionLocked) for a keyboard or a pad without motion
|
||||
* sensors, for inFAMOUS Second Son's spray can: Z held (or L3+R3 / H to toggle) tilts the pad on
|
||||
* sensors, for inFAMOUS Second Son's spray can: Z held (or H / the menu's switch to toggle) tilts the pad on
|
||||
* its side, a tap of Z or X held (L1 while tilted on a pad) rattles it, and the mouse or the right
|
||||
* stick aims while tilted. Pose, gravity and angular velocity come from one quaternion, so the
|
||||
* accelerometer, the gyroscope and the orientation always agree.
|
||||
@@ -43,7 +43,7 @@ enum {
|
||||
CLICK_LEFT=0x40000000, CLICK_RIGHT=0x80000000,
|
||||
/* issn: rattles the pad (synth_motion) while held: keyboard X, or a mapped pad button. */
|
||||
SHAKE=0x20000000,
|
||||
/* issn: turns the spray pose on and off (H; L3+R3 together on a pad). */
|
||||
/* issn: turns the spray pose on and off (H; the menu's switch, issngpu_spray_pose). */
|
||||
SPRAY_POSE=0x10000000,
|
||||
};
|
||||
typedef struct { uint16_t x, y; uint8_t id, reserve[3]; } PadTouch;
|
||||
@@ -112,7 +112,7 @@ static void integrate_orientation(float q[4], const float w[3], float dt) {
|
||||
if (n>0.0f) for (int i=0;i<4;++i) q[i]/=n;
|
||||
}
|
||||
|
||||
/* issn: the L3+R3 / H toggle's edge (the synthetic motion sensor, synth_motion). */
|
||||
/* issn: the H toggle's edge (the synthetic motion sensor, synth_motion). */
|
||||
static int spray_chord_held;
|
||||
|
||||
/* The pad's light bar takes the last color the game set (ISSN_LIGHTBAR=0 leaves it alone); sent
|
||||
@@ -128,9 +128,9 @@ static void apply_light(SDL_Gamepad *g) {
|
||||
}
|
||||
|
||||
/* issn: ISSN_PAD_EMULATION (launcher: Controls > Controller without touchpad): the touchpad
|
||||
* swipes and two fingers (Select/Back + right stick, + R1) and the spray pose (L3+R3) for pads
|
||||
* swipes and two fingers (Select/Back + right stick, + R1) and the spray pose (the menu's switch) for pads
|
||||
* without a touchpad or motion sensors. auto (default): on when the pad lacks a touchpad or
|
||||
* motion sensors (Xbox, 8BitDo, ...), off on a DualShock 4 / DualSense, whose L3+R3 then reach the
|
||||
* motion sensors (Xbox, 8BitDo, ...), off on a DualShock 4 / DualSense, whose buttons then reach the
|
||||
* game; 1: always; 0: never. */
|
||||
static int pad_emulation_auto=1; /* set when a gamepad opens */
|
||||
static bool pad_emulation(void) {
|
||||
@@ -151,7 +151,7 @@ static void open_sensors(SDL_Gamepad *g) {
|
||||
printf("Runtime: gamepad motion sensors: %s%s%s\n",sensors ? "" : "none",
|
||||
(sensors & SENSOR_ACCEL) ? "accelerometer " : "", (sensors & SENSOR_GYRO) ? "gyroscope" : "");
|
||||
pad_emulation_auto=SDL_GetNumGamepadTouchpads(g)<=0 || !(sensors & SENSOR_ACCEL);
|
||||
printf("Runtime: gamepad %s: touchpad emulation (Select + right stick, L3+R3 spray) %s\n",
|
||||
printf("Runtime: gamepad %s: touchpad emulation (Select + right stick; spray can: Insert > Controls) %s\n",
|
||||
SDL_GetNumGamepadTouchpads(g)>0 ? "with a touchpad" : "without a touchpad",
|
||||
pad_emulation() ? "on" : "off");
|
||||
light.applied=0;
|
||||
@@ -349,6 +349,23 @@ static void play_swipe(PadData *d) {
|
||||
}
|
||||
}
|
||||
|
||||
/* issn 11-04: the menu's controller sensitivity (Insert > Controls) on the right stick, after the
|
||||
* swipes and the spray can took what they need from it. Below 1 the stick is scaled down (a slower
|
||||
* camera); above 1 it follows a curve, |v|^(1/s): a full push stays a full push (the game's top
|
||||
* speed), small moves count for more. */
|
||||
static void apply_pad_sensitivity(PadData *d) {
|
||||
const float s=issngpu_pad_sensitivity();
|
||||
if (!(s>0.0f) || fabsf(s-1.0f)<0.01f) return;
|
||||
uint8_t *axes[2]={&d->right_x,&d->right_y};
|
||||
for (int i=0;i<2;++i) {
|
||||
const float v=((float)*axes[i]-127.5f)/127.5f;
|
||||
if (fabsf(v)<0.004f) continue;
|
||||
const float m=s<1.0f ? fabsf(v)*s : powf(fabsf(v),1.0f/s);
|
||||
const int x=(int)lroundf(copysignf(fminf(m,1.0f),v)*127.5f+127.5f);
|
||||
*axes[i]=(uint8_t)(x<0 ? 0 : x>255 ? 255 : x);
|
||||
}
|
||||
}
|
||||
|
||||
/* issn: what SDL sees, once 3 s after the first read and whenever the device count changes
|
||||
* (tester 2026-10-22: "gamepad=none" with an 8BitDo Ultimate C plugged in). A joystick SDL has
|
||||
* no gamepad mapping for (a pad in DirectInput mode) is listed as "not a gamepad". */
|
||||
@@ -588,7 +605,7 @@ static void mouse_aim_tick(uint64_t now) {
|
||||
static void mouse_aim_motion(float dx, float dy, uint64_t now) {
|
||||
if (dx==0.0f && dy==0.0f) return;
|
||||
aim_last_motion=now;
|
||||
const float k=aim_sens*(3.14159265f/180.0f)/100.0f;
|
||||
const float k=aim_sens*issngpu_mouse_sensitivity()*(3.14159265f/180.0f)/100.0f;
|
||||
float yaw=dx*k*aim_sign_x, pitch=dy*k*aim_sign_y;
|
||||
if (aim_invert_x && *aim_invert_x) yaw=-yaw;
|
||||
if (aim_invert_y && *aim_invert_y) pitch=-pitch;
|
||||
@@ -634,8 +651,10 @@ static void sample_mouse(PadData *d) {
|
||||
printf("Spray can: %.2f degrees per 100 px, right stick %.0f degrees a second (- / =)\n",
|
||||
spray_sens,spray_stick);
|
||||
} else {
|
||||
aim_sens=fmaxf(0.5f,fminf(100.0f,aim_sens*(minus ? 0.8f : 1.25f)));
|
||||
printf("Mouse aim: %.2f degrees per 100 px (- / =)\n",aim_sens);
|
||||
/* issn 11-04: the menu's Mouse sensitivity (Insert > Controls), saved */
|
||||
const float f=fmaxf(0.1f,fminf(5.0f,issngpu_mouse_sensitivity()*(minus ? 0.8f : 1.25f)));
|
||||
issngpu_set_mouse_sensitivity(f);
|
||||
printf("Mouse aim: sensitivity %.2fx, %.2f degrees per 100 px (- / =)\n",f,aim_sens*f);
|
||||
}
|
||||
fflush(stdout);
|
||||
}
|
||||
@@ -968,17 +987,23 @@ static void sample(PadData *d) {
|
||||
uint8_t *axes[4]={&d->left_x,&d->left_y,&d->right_x,&d->right_y};
|
||||
for (int i=0;i<4;++i) if (injected.stick[i]>=0) *axes[i]=(uint8_t)injected.stick[i];
|
||||
/* issn: the motion sensor of a pad without one, and of the keyboard (synth_motion). The pad
|
||||
* is tilted while Z is held, while L3+R3 (pads without sensors) or H has toggled it on, and
|
||||
* while it rattles; it rattles while X (a SHAKE source) is held, for 600 ms after a quick tap
|
||||
* of Z, and on L1 while the pose is toggled on. A pad with real sensors keeps them. */
|
||||
const bool chord=pad_emulation() && (d->buttons & BTN_L3) && (d->buttons & BTN_R3);
|
||||
if (chord || (d->buttons & SPRAY_POSE)) {
|
||||
* is tilted while Z is held, while the spray pose is on, and while it rattles; it rattles
|
||||
* while X (a SHAKE source) is held, for 600 ms after a quick tap of Z, and on L1 while the pose
|
||||
* is on. A pad with real sensors keeps them.
|
||||
* issn 11-04: the spray pose is the menu's switch (Insert > Controls) and H on the keyboard.
|
||||
* L3+R3 no longer toggles it: both sticks clicked together in a fight turned it on, and the
|
||||
* right stick then aimed an invisible can instead of the camera ("the controller goes stiff");
|
||||
* L3 and R3 now always reach the game. */
|
||||
synth.pose_toggle=issngpu_spray_pose()!=0;
|
||||
if (d->buttons & SPRAY_POSE) {
|
||||
if (!spray_chord_held) {
|
||||
synth.pose_toggle=!synth.pose_toggle;
|
||||
printf("Runtime: pad tilted (spray can) %s\n",synth.pose_toggle ? "on" : "off");
|
||||
issngpu_set_spray_pose(synth.pose_toggle);
|
||||
printf("Runtime: pad tilted (spray can) %s (H)\n",synth.pose_toggle ? "on" : "off");
|
||||
fflush(stdout);
|
||||
}
|
||||
spray_chord_held=1;
|
||||
d->buttons&=~(BTN_L3|BTN_R3|SPRAY_POSE);
|
||||
d->buttons&=~SPRAY_POSE;
|
||||
} else {
|
||||
spray_chord_held=0;
|
||||
}
|
||||
@@ -993,7 +1018,7 @@ static void sample(PadData *d) {
|
||||
const bool tilt=z || synth.pose_toggle || rattle;
|
||||
const bool real_sensors=gamepad && (sensors & SENSOR_ACCEL) && motion_enabled;
|
||||
pad_tilted=tilt;
|
||||
if (!real_sensors || tilt) { /* Z / X / L3+R3 also drive a pad that has sensors */
|
||||
if (!real_sensors || tilt) { /* Z / X / the pose also drive a pad that has sensors */
|
||||
const bool stick_aims=tilt && !(d->buttons & CLICK_RIGHT);
|
||||
float ax=0.0f, ay=0.0f;
|
||||
if (stick_aims) {
|
||||
@@ -1009,6 +1034,7 @@ static void sample(PadData *d) {
|
||||
if (!key_touch_held && finger.active) { finger.active=0; finger.travel=0.0f; } /* R let go, mouse still */
|
||||
if ((pad_emulation() || key_touch_held) && !finger.active) play_swipe(d); /* the keyboard has no touch surface either */
|
||||
play_click(d);
|
||||
apply_pad_sensitivity(d);
|
||||
if (hold_after_capture) {
|
||||
if (d->buttons) d->buttons=0;
|
||||
else hold_after_capture=0;
|
||||
|
||||
+93
-7
@@ -7,6 +7,7 @@
|
||||
#define _GNU_SOURCE
|
||||
#include "runtime.h"
|
||||
#include "gpu/issngpu.h"
|
||||
#include "runtime_trophy.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -436,19 +437,97 @@ static ABI int32_t ime_result(uint32_t *result) {
|
||||
}
|
||||
static ABI int32_t ime_term(void) { memset(&ime,0,sizeof(ime)); return 0; }
|
||||
|
||||
/* ---- Trophies: accepted locally, recorded in the log ---- */
|
||||
/* ---- Trophies: kept on this PC (runtime_trophy.c), shown by the overlay ---- */
|
||||
#define TROPHY_INVALID_ID ((int32_t)0x8055160A)
|
||||
#define TROPHY_ALREADY_UNLOCKED ((int32_t)0x8055160C)
|
||||
#define TROPHY_PLATINUM_CANNOT_UNLOCK ((int32_t)0x8055160D)
|
||||
static pthread_mutex_t trophy_lock=PTHREAD_MUTEX_INITIALIZER;
|
||||
static TrophyBook trophies;
|
||||
static int trophies_ready;
|
||||
static void trophy_path(char *out,size_t size,const char *name) {
|
||||
snprintf(out,size,"%s/%s",runtime_file_user_dir(),name);
|
||||
}
|
||||
static void trophy_now(char *out,size_t size) {
|
||||
time_t now=time(NULL); struct tm local;
|
||||
#ifdef _WIN32
|
||||
localtime_s(&local,&now);
|
||||
#else
|
||||
localtime_r(&now,&local);
|
||||
#endif
|
||||
strftime(out,size,"%Y-%m-%d %H:%M:%S",&local);
|
||||
}
|
||||
/* The popup: icon from the game's trophy file (read now, never copied), name when known. */
|
||||
static void trophy_show(int id) {
|
||||
size_t size=0;
|
||||
unsigned char *png=trophy_trp_icon(getenv("ISSN_TROPHY_FILE"),id,&size);
|
||||
char grade[2]={trophies.grade[id],0};
|
||||
issngpu_note_trophy(id,trophies.name[id],grade,png,size);
|
||||
free(png);
|
||||
}
|
||||
static void trophy_save(void) {
|
||||
char path[1024]; trophy_path(path,sizeof(path),"trophies.txt");
|
||||
if (trophy_book_save(&trophies,path)) printf("Runtime: trophies: could not write %s\n",path);
|
||||
}
|
||||
/* trophy_lock held: the book, read once (the platinum a save already earned is given now). */
|
||||
static void trophies_open(void) {
|
||||
if (trophies_ready) return;
|
||||
trophies_ready=1;
|
||||
const char *file=getenv("ISSN_TROPHY_FILE");
|
||||
int total=trophy_trp_count(file);
|
||||
trophy_book_init(&trophies,total);
|
||||
char path[1024];
|
||||
trophy_path(path,sizeof(path),"trophy-names.txt");
|
||||
int names=trophy_book_names(&trophies,path);
|
||||
trophy_path(path,sizeof(path),"trophies.txt");
|
||||
trophy_book_load(&trophies,path);
|
||||
if (total>0) printf("Runtime: trophies: %d in the game's file, %d unlocked on this PC, %s\n",
|
||||
total,trophy_book_count(&trophies),names ? "with names" : "no names (no trophy key)");
|
||||
else printf("Runtime: trophies: no trophy file (%s), %d unlocked on this PC\n",
|
||||
file && *file ? file : "not found",trophy_book_count(&trophies));
|
||||
int platinum=trophy_book_platinum_due(&trophies);
|
||||
if (platinum>=0) {
|
||||
char when[20]; trophy_now(when,sizeof(when));
|
||||
trophies.flags[platinum>>5]|=1u<<(platinum&31);
|
||||
snprintf(trophies.when[platinum],sizeof(trophies.when[platinum]),"%s",when);
|
||||
printf("Runtime: trophy %d unlocked (platinum)\n",platinum);
|
||||
trophy_save();
|
||||
trophy_show(platinum);
|
||||
}
|
||||
fflush(stdout);
|
||||
}
|
||||
static ABI int32_t trophy_context(int32_t *ctx,int32_t user,uint32_t label,uint64_t options) {
|
||||
(void)user; (void)label; (void)options;
|
||||
if (!ctx) return TROPHY_INVALID;
|
||||
*ctx=new_id(); return 0;
|
||||
}
|
||||
static ABI int32_t trophy_handle(int32_t *handle) { if (!handle) return TROPHY_INVALID; *handle=new_id(); return 0; }
|
||||
static ABI int32_t trophy_register(int32_t ctx,int32_t handle,uint64_t options) { (void)ctx; (void)handle; (void)options; return 0; }
|
||||
static ABI int32_t trophy_register(int32_t ctx,int32_t handle,uint64_t options) {
|
||||
(void)ctx; (void)handle; (void)options;
|
||||
pthread_mutex_lock(&trophy_lock); trophies_open(); pthread_mutex_unlock(&trophy_lock);
|
||||
return 0;
|
||||
}
|
||||
/* Unlocked once: again gives ALREADY_UNLOCKED, as on the console. The last trophy the platinum
|
||||
* needs brings the platinum too (*platinum = its id). */
|
||||
static ABI int32_t trophy_unlock(int32_t ctx,int32_t handle,int32_t id,int32_t *platinum) {
|
||||
(void)ctx; (void)handle;
|
||||
printf("Runtime: trophy %d unlocked\n",id);
|
||||
if (platinum) *platinum=-1;
|
||||
return 0;
|
||||
char when[20]; trophy_now(when,sizeof(when));
|
||||
int earned=-1;
|
||||
pthread_mutex_lock(&trophy_lock);
|
||||
trophies_open();
|
||||
int result=trophy_book_unlock(&trophies,id,when,&earned);
|
||||
if (result==1) { printf("Runtime: trophy %d asked again (already unlocked)\n",id); fflush(stdout); }
|
||||
if (result==0) {
|
||||
printf("Runtime: trophy %d unlocked\n",id);
|
||||
if (earned>=0) printf("Runtime: trophy %d unlocked (platinum)\n",earned);
|
||||
fflush(stdout);
|
||||
trophy_save();
|
||||
trophy_show(id);
|
||||
if (earned>=0) trophy_show(earned);
|
||||
}
|
||||
pthread_mutex_unlock(&trophy_lock);
|
||||
if (platinum) *platinum=earned;
|
||||
return result==0 ? 0 : result==1 ? TROPHY_ALREADY_UNLOCKED
|
||||
: result==-2 ? TROPHY_PLATINUM_CANNOT_UNLOCK : TROPHY_INVALID_ID;
|
||||
}
|
||||
static ABI int32_t trophy_game_info(int32_t ctx,int32_t handle,void *details,void *data) {
|
||||
(void)ctx; (void)handle;
|
||||
@@ -459,11 +538,18 @@ static ABI int32_t trophy_game_info(int32_t ctx,int32_t handle,void *details,voi
|
||||
static ABI int32_t trophy_info(int32_t ctx,int32_t handle,int32_t id,void *details,void *data) {
|
||||
(void)id; return trophy_game_info(ctx,handle,details,data);
|
||||
}
|
||||
/* Nothing is unlocked on this console: an empty 128-trophy flag set. */
|
||||
/* The trophies unlocked on this PC (user/trophies.txt); count: the trophies of the game. */
|
||||
static ABI int32_t trophy_unlock_state(int32_t ctx,int32_t handle,uint32_t *flags,uint32_t *count) {
|
||||
if (!flags || !count) return TROPHY_INVALID;
|
||||
if (ctx<=0 || handle<=0) return TROPHY_INVALID;
|
||||
memset(flags,0,16); *count=0;
|
||||
static int asked;
|
||||
pthread_mutex_lock(&trophy_lock);
|
||||
trophies_open();
|
||||
memcpy(flags,trophies.flags,sizeof(trophies.flags));
|
||||
*count=(uint32_t)(trophies.total>0 ? trophies.total : 0);
|
||||
/* issn 11-05: whether and when the game checks (a save made elsewhere unlocked nothing). */
|
||||
if (asked++<8) { printf("Runtime: trophies: the game asked which are unlocked (%d)\n",trophy_book_count(&trophies)); fflush(stdout); }
|
||||
pthread_mutex_unlock(&trophy_lock);
|
||||
return 0;
|
||||
}
|
||||
static ABI int32_t trophy_release(int32_t id) { return id>0 ? 0 : TROPHY_INVALID; }
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/* issn: the game's trophies, kept on this PC (see runtime_trophy.h). Plain C, no guest state:
|
||||
* runtime_services.c calls it from the sceNpTrophy functions. */
|
||||
#define _GNU_SOURCE
|
||||
#include "runtime_trophy.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
/* ---- the game's trophy file (big-endian: header 0x60 bytes, then entries) ---- */
|
||||
#define TRP_MAGIC 0xDCA24D00u
|
||||
#define TRP_HEADER 0x60
|
||||
|
||||
static uint32_t be32(const unsigned char *p) { return (uint32_t)p[0]<<24 | (uint32_t)p[1]<<16 | (uint32_t)p[2]<<8 | p[3]; }
|
||||
static uint64_t be64(const unsigned char *p) { return (uint64_t)be32(p)<<32 | be32(p+4); }
|
||||
|
||||
/* The id of an entry named TROPnnn.PNG, else -1. */
|
||||
static int icon_id(const char *name) {
|
||||
if (strncmp(name,"TROP",4) || strcmp(name+7,".PNG")) return -1;
|
||||
int id=0;
|
||||
for (int i=4;i<7;++i) {
|
||||
if (name[i]<'0' || name[i]>'9') return -1;
|
||||
id=id*10+(name[i]-'0');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/* Calls `found` for every icon entry; returns the number of entries or -1. */
|
||||
static int trp_walk(FILE *f,int (*found)(int id,uint64_t pos,uint64_t len,void *ctx),void *ctx) {
|
||||
unsigned char head[TRP_HEADER];
|
||||
if (fread(head,1,sizeof(head),f)!=sizeof(head) || be32(head)!=TRP_MAGIC) return -1;
|
||||
uint32_t count=be32(head+16), size=be32(head+20);
|
||||
if (count>4096 || size<52 || size>1024) return -1;
|
||||
unsigned char entry[1024];
|
||||
for (uint32_t i=0;i<count;++i) {
|
||||
if (fseek(f,(long)(TRP_HEADER+(uint64_t)i*size),SEEK_SET) || fread(entry,1,size,f)!=size) return -1;
|
||||
char name[33]; memcpy(name,entry,32); name[32]=0;
|
||||
int id=icon_id(name);
|
||||
if (id>=0 && found(id,be64(entry+32),be64(entry+40),ctx)) break;
|
||||
}
|
||||
return (int)count;
|
||||
}
|
||||
|
||||
static int count_icon(int id,uint64_t pos,uint64_t len,void *ctx) {
|
||||
(void)pos; (void)len;
|
||||
int *highest=ctx;
|
||||
if (id<TROPHY_MAX && id+1>*highest) *highest=id+1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trophy_trp_count(const char *path) {
|
||||
FILE *f=path && *path ? fopen(path,"rb") : NULL;
|
||||
if (!f) return -1;
|
||||
int total=0, entries=trp_walk(f,count_icon,&total);
|
||||
fclose(f);
|
||||
return entries<0 ? -1 : total;
|
||||
}
|
||||
|
||||
typedef struct { int id; uint64_t pos, len; int hit; } IconFind;
|
||||
static int find_icon(int id,uint64_t pos,uint64_t len,void *ctx) {
|
||||
IconFind *want=ctx;
|
||||
if (id!=want->id) return 0;
|
||||
want->pos=pos; want->len=len; want->hit=1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
unsigned char *trophy_trp_icon(const char *path,int id,size_t *size) {
|
||||
if (size) *size=0;
|
||||
FILE *f=path && *path ? fopen(path,"rb") : NULL;
|
||||
if (!f) return NULL;
|
||||
IconFind want={id,0,0,0};
|
||||
unsigned char *png=NULL;
|
||||
if (trp_walk(f,find_icon,&want)>=0 && want.hit && want.len>8 && want.len<(16u<<20)
|
||||
&& !fseek(f,(long)want.pos,SEEK_SET) && (png=malloc(want.len))) {
|
||||
if (fread(png,1,want.len,f)!=want.len || memcmp(png,"\x89PNG",4)) { free(png); png=NULL; }
|
||||
else if (size) *size=(size_t)want.len;
|
||||
}
|
||||
fclose(f);
|
||||
return png;
|
||||
}
|
||||
|
||||
/* ---- the book of unlocked trophies ---- */
|
||||
static int valid(const TrophyBook *book,int id) {
|
||||
return id>=0 && id<TROPHY_MAX && (book->total<=0 || id<book->total);
|
||||
}
|
||||
|
||||
void trophy_book_init(TrophyBook *book,int total) {
|
||||
memset(book,0,sizeof(*book));
|
||||
book->total=total>0 && total<=TROPHY_MAX ? total : 0;
|
||||
book->platinum=book->total>1 ? 0 : -1;
|
||||
for (int i=1;i<book->total;++i) book->base[i]=1;
|
||||
if (book->platinum==0) book->grade[0]='P';
|
||||
}
|
||||
|
||||
int trophy_book_unlocked(const TrophyBook *book,int id) {
|
||||
return id>=0 && id<TROPHY_MAX && (book->flags[id>>5]>>(id&31)&1);
|
||||
}
|
||||
|
||||
int trophy_book_count(const TrophyBook *book) {
|
||||
int n=0;
|
||||
for (int i=0;i<TROPHY_MAX;++i) n+=trophy_book_unlocked(book,i);
|
||||
return n;
|
||||
}
|
||||
|
||||
static void mark(TrophyBook *book,int id,const char *when) {
|
||||
book->flags[id>>5]|=1u<<(id&31);
|
||||
snprintf(book->when[id],sizeof(book->when[id]),"%s",when ? when : "");
|
||||
}
|
||||
|
||||
int trophy_book_load(TrophyBook *book,const char *path) {
|
||||
FILE *f=path ? fopen(path,"r") : NULL;
|
||||
if (!f) return 0;
|
||||
char line[256]; int read=0;
|
||||
while (fgets(line,sizeof(line),f)) {
|
||||
char *end; long id=strtol(line,&end,10);
|
||||
if (end==line || line[0]=='#' || !valid(book,(int)id)) continue;
|
||||
while (*end==' ' || *end=='\t') ++end;
|
||||
end[strcspn(end,"\r\n")]=0;
|
||||
if (!trophy_book_unlocked(book,(int)id)) { mark(book,(int)id,end); ++read; }
|
||||
}
|
||||
fclose(f);
|
||||
return read;
|
||||
}
|
||||
|
||||
int trophy_book_names(TrophyBook *book,const char *path) {
|
||||
FILE *f=path ? fopen(path,"r") : NULL;
|
||||
if (!f) return 0;
|
||||
char line[2048]; int read=0, platinum=-1, graded=0;
|
||||
unsigned char base[TROPHY_MAX]={0};
|
||||
while (fgets(line,sizeof(line),f)) {
|
||||
line[strcspn(line,"\r\n")]=0;
|
||||
char *field[4]={line,NULL,NULL,NULL};
|
||||
for (int i=1;i<4;++i) {
|
||||
char *tab=field[i-1] ? strchr(field[i-1],'\t') : NULL;
|
||||
if (!tab) break;
|
||||
*tab=0; field[i]=tab+1;
|
||||
}
|
||||
char *end; long id=strtol(field[0],&end,10);
|
||||
if (line[0]=='#' || end==field[0] || *end || !field[3] || !valid(book,(int)id)) continue;
|
||||
char grade=field[1][0];
|
||||
if (grade=='P' || grade=='G' || grade=='S' || grade=='B') {
|
||||
book->grade[id]=grade; graded=1;
|
||||
if (grade=='P') platinum=(int)id;
|
||||
}
|
||||
base[id]=field[2][0]=='1';
|
||||
snprintf(book->name[id],sizeof(book->name[id]),"%s",field[3]);
|
||||
++read;
|
||||
}
|
||||
fclose(f);
|
||||
if (graded) { /* the game's own list (TROPCONF) decides the platinum and what it needs */
|
||||
book->platinum=platinum;
|
||||
memcpy(book->base,base,sizeof(base));
|
||||
if (platinum>=0) book->base[platinum]=0;
|
||||
}
|
||||
return read;
|
||||
}
|
||||
|
||||
int trophy_book_save(const TrophyBook *book,const char *path) {
|
||||
char part[1024];
|
||||
if (!path || snprintf(part,sizeof(part),"%s.part",path)>=(int)sizeof(part)) return -1;
|
||||
FILE *f=fopen(part,"w");
|
||||
if (!f) return -1;
|
||||
fprintf(f,"# Trophies unlocked in this port: id and local date. Removing a line locks it again.\n");
|
||||
for (int i=0;i<TROPHY_MAX;++i)
|
||||
if (trophy_book_unlocked(book,i)) fprintf(f,"%d %s\n",i,book->when[i]);
|
||||
int bad=ferror(f);
|
||||
if (fclose(f) || bad) { remove(part); return -1; }
|
||||
#ifdef _WIN32
|
||||
if (!MoveFileExA(part,path,MOVEFILE_REPLACE_EXISTING)) { remove(part); return -1; }
|
||||
#else
|
||||
if (rename(part,path)) { remove(part); return -1; }
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trophy_book_platinum_due(const TrophyBook *book) {
|
||||
int p=book->platinum, needed=0;
|
||||
if (p<0 || trophy_book_unlocked(book,p)) return -1;
|
||||
for (int i=0;i<TROPHY_MAX;++i) {
|
||||
if (!book->base[i] || i==p) continue;
|
||||
++needed;
|
||||
if (!trophy_book_unlocked(book,i)) return -1;
|
||||
}
|
||||
return needed ? p : -1;
|
||||
}
|
||||
|
||||
int trophy_book_unlock(TrophyBook *book,int id,const char *when,int *platinum) {
|
||||
if (platinum) *platinum=-1;
|
||||
if (!valid(book,id)) return -1;
|
||||
if (trophy_book_unlocked(book,id)) return 1;
|
||||
if (id==book->platinum) return -2;
|
||||
mark(book,id,when);
|
||||
int p=trophy_book_platinum_due(book);
|
||||
if (p>=0) {
|
||||
mark(book,p,when);
|
||||
if (platinum) *platinum=p;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/* issn: the game's trophies, kept on this PC (runtime_trophy.c).
|
||||
* - user/trophies.txt: the trophies unlocked here, one "id date" line each (the launcher's
|
||||
* Achievements page reads it too);
|
||||
* - the game's sce_sys/trophy/trophy00.trp (ISSN_TROPHY_FILE, found by run.py): its icons are
|
||||
* plain PNG files and their count is the number of trophies; its texts are encrypted;
|
||||
* - user/trophy-names.txt: names and grades, written by run.py only when the player gave the
|
||||
* launcher their own trophy key (never shipped). Without it a trophy is "Trophy N". */
|
||||
#ifndef ISSN_RUNTIME_TROPHY_H
|
||||
#define ISSN_RUNTIME_TROPHY_H
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define TROPHY_MAX 128
|
||||
|
||||
typedef struct {
|
||||
uint32_t flags[TROPHY_MAX / 32]; /* bit id%32 of word id/32: unlocked (sceNpTrophy layout) */
|
||||
char when[TROPHY_MAX][20]; /* local time of the unlock, "YYYY-MM-DD HH:MM:SS" */
|
||||
int total; /* trophies in the game's file; 0 = unknown */
|
||||
int platinum; /* the platinum's id; -1 = none or unknown */
|
||||
unsigned char base[TROPHY_MAX]; /* 1: needed for the platinum */
|
||||
char grade[TROPHY_MAX]; /* 'P', 'G', 'S', 'B'; 0 = unknown */
|
||||
char name[TROPHY_MAX][128]; /* UTF-8; "" = unknown */
|
||||
} TrophyBook;
|
||||
|
||||
/* Number of trophies in a .trp file (TROPnnn.PNG entries, ids 0..n-1); -1 when the file is
|
||||
* missing or not a trophy file. */
|
||||
int trophy_trp_count(const char *path);
|
||||
/* The PNG bytes of trophy `id`'s icon (malloc'd, the caller frees) or NULL. */
|
||||
unsigned char *trophy_trp_icon(const char *path, int id, size_t *size);
|
||||
|
||||
/* An empty book for `total` trophies: without names, the platinum is id 0 (the trophy file
|
||||
* format puts it first) and every other trophy counts toward it. */
|
||||
void trophy_book_init(TrophyBook *book, int total);
|
||||
/* Adds the unlocked trophies of user/trophies.txt; returns how many were read (0: no file). */
|
||||
int trophy_book_load(TrophyBook *book, const char *path);
|
||||
/* Adds names and grades from user/trophy-names.txt ("id\tgrade\tbase\tname" lines); returns how
|
||||
* many were read. A file that names a platinum also decides which trophy that is. */
|
||||
int trophy_book_names(TrophyBook *book, const char *path);
|
||||
/* Writes user/trophies.txt whole (a new file then a rename); 0 on success. */
|
||||
int trophy_book_save(const TrophyBook *book, const char *path);
|
||||
int trophy_book_unlocked(const TrophyBook *book, int id);
|
||||
int trophy_book_count(const TrophyBook *book);
|
||||
/* Marks `id` unlocked at `when`. Returns 0 (new), 1 (was unlocked), -1 (not a trophy of this
|
||||
* game), -2 (the platinum: only the system unlocks it). When this was the last trophy the
|
||||
* platinum needs, the platinum is unlocked too and *platinum gets its id (else -1). */
|
||||
int trophy_book_unlock(TrophyBook *book, int id, const char *when, int *platinum);
|
||||
/* The platinum's id when every trophy it needs is unlocked and it is not yet; else -1. */
|
||||
int trophy_book_platinum_due(const TrophyBook *book);
|
||||
#endif
|
||||
@@ -70,5 +70,33 @@ class GraphicsOptionsTest(unittest.TestCase):
|
||||
self.assertEqual(env, {'ISSN_DEFERRED_READBACK': '1', 'ISSN_STALE_READBACK': '0', 'ISSN_ASYNC_SHADERS': '1'})
|
||||
|
||||
|
||||
class MenuEnvironmentTest(unittest.TestCase):
|
||||
"""issn-axy 10-31: "Apply and restart" starts the menu's anti-aliasing, quality and frame gen."""
|
||||
|
||||
def test_dlss_with_quality_and_frame_gen(self):
|
||||
env, off = g.menu_environment(
|
||||
{'menu_anti_aliasing': 'dlss', 'menu_upscale_quality': '1.5', 'menu_frame_gen': '1'}, True)
|
||||
self.assertEqual(env, {'SHADPS4_DLSS': '1', 'SHADPS4_DLSS_UPSCALE': '1', 'ISSN_FRAME_GEN': '1',
|
||||
'ISSN_UPSCALE_FACTOR': '1.5'})
|
||||
self.assertEqual(set(off), {'ISSN_FSR4', 'ISSN_XESS', 'ISSN_AA_OFF'})
|
||||
|
||||
def test_off_has_no_frame_gen(self):
|
||||
# issn 11-04: anti-aliasing "Off" - no T2x, and frame generation needs an upscaler.
|
||||
env, off = g.menu_environment({'menu_anti_aliasing': 'off', 'menu_frame_gen': '1'}, True)
|
||||
self.assertEqual(env, {'ISSN_AA_OFF': '1'})
|
||||
self.assertIn('ISSN_FRAME_GEN', off)
|
||||
self.assertIn('SHADPS4_DLSS', off)
|
||||
|
||||
def test_games_own_turns_the_switches_off(self):
|
||||
env, off = g.menu_environment({'menu_anti_aliasing': 'game', 'menu_frame_gen': '1'}, True)
|
||||
self.assertEqual(env, {})
|
||||
self.assertIn('ISSN_FRAME_GEN', off)
|
||||
self.assertIn('SHADPS4_DLSS', off)
|
||||
|
||||
def test_xess_is_windows_only_and_no_menu_keys_change_nothing(self):
|
||||
self.assertEqual(g.menu_environment({'menu_anti_aliasing': 'xess'}, False)[0], {})
|
||||
self.assertEqual(g.menu_environment({}, True), ({}, ()))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -86,13 +86,17 @@ int main() {
|
||||
assert(v.async_shaders);
|
||||
{
|
||||
std::ofstream out(path);
|
||||
out << "cheat_double_jumps=1\ncheat_very_hard=1\n";
|
||||
out << "cheat_double_jumps=1\ncheat_very_hard=1\nmotion_blur=0\n";
|
||||
}
|
||||
IssnSettings::Load();
|
||||
// issn 11-04: the game's motion blur and chromatic aberration (on unless the file says 0).
|
||||
assert(!v.motion_blur && v.chromatic_aberration);
|
||||
IssnSettings::Save();
|
||||
const std::string cheats = Read(path);
|
||||
assert(cheats.find("cheat_double_jumps=1\n") != std::string::npos);
|
||||
assert(cheats.find("cheat_very_hard=1\n") != std::string::npos);
|
||||
assert(cheats.find("motion_blur=0\n") != std::string::npos);
|
||||
assert(cheats.find("chromatic_aberration=1\n") != std::string::npos);
|
||||
std::remove(path);
|
||||
std::puts("PASS: graphics settings");
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later */
|
||||
/* src/dash_hook.h and src/hero_hook.h: the neon, video and damage stubs run on a fake image whose
|
||||
* jump targets are small landing functions, with the flag off and on; the missile and Karma Bomb
|
||||
* checks run on written bytes (no game code is used). */
|
||||
#include "dash_hook.h"
|
||||
#include "hero_hook.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <sys/mman.h>
|
||||
#endif
|
||||
|
||||
#define IMAGE_SIZE 0x1e00000u
|
||||
#define STUB_AT 0x100000u
|
||||
|
||||
static int failures;
|
||||
#define CHECK(c) \
|
||||
do { \
|
||||
if (!(c)) { \
|
||||
fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #c); \
|
||||
++failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static unsigned char *map_rwx(size_t size) {
|
||||
#ifdef _WIN32
|
||||
return VirtualAlloc(NULL, size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
|
||||
#else
|
||||
void *p = mmap(NULL, size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
|
||||
return p == MAP_FAILED ? NULL : p;
|
||||
#endif
|
||||
}
|
||||
|
||||
static void put(unsigned char *image, uint32_t at, const unsigned char *code, size_t n) {
|
||||
memcpy(image + at, code, n);
|
||||
}
|
||||
|
||||
/* Calls `stub` with rdi set; returns rax at its ret. */
|
||||
static uint64_t run_rdi(const unsigned char *stub, uint64_t rdi) {
|
||||
uint64_t out;
|
||||
__asm__ volatile("call *%[fn]"
|
||||
: "=a"(out), "+D"(rdi)
|
||||
: [fn] "r"(stub)
|
||||
: "rcx", "rdx", "rsi", "r8", "r9", "r10", "r11", "xmm0", "memory", "cc");
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Calls `stub` with al and xmm0 set; returns rax at the landing function's ret. */
|
||||
static uint64_t run(const unsigned char *stub, uint64_t rax_in, float xmm0) {
|
||||
uint32_t bits;
|
||||
memcpy(&bits, &xmm0, 4);
|
||||
uint64_t out;
|
||||
__asm__ volatile("vmovd %k[bits], %%xmm0\n\t"
|
||||
"call *%[fn]"
|
||||
: "=a"(out)
|
||||
: [bits] "r"(bits), [fn] "r"(stub), "a"(rax_in)
|
||||
: "xmm0", "rcx", "rdx", "rsi", "rdi", "r8", "r9", "r10", "r11", "memory", "cc");
|
||||
return out;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
unsigned char *image = map_rwx(IMAGE_SIZE);
|
||||
if (!image) {
|
||||
fprintf(stderr, "cannot map the fake image\n");
|
||||
return 1;
|
||||
}
|
||||
memset(image, 0xcc, IMAGE_SIZE);
|
||||
put(image, ISSN_DASH_NEON_SITE, issn_dash_neon_code, sizeof issn_dash_neon_code);
|
||||
put(image, ISSN_DASH_TV_SITE, issn_dash_tv_code, sizeof issn_dash_tv_code);
|
||||
const float limit = 1.5f;
|
||||
memcpy(image + ISSN_DASH_TV_LIMIT, &limit, 4);
|
||||
CHECK(issn_dash_neon_match(image, IMAGE_SIZE));
|
||||
CHECK(issn_dash_tv_match(image, IMAGE_SIZE));
|
||||
CHECK(!issn_dash_neon_match(image, ISSN_DASH_NEON_SITE + 4));
|
||||
|
||||
/* Landing functions. neon back: rax = rdi + rsi (the check's two arguments). */
|
||||
static const unsigned char back[] = {0x48, 0x8d, 0x04, 0x37, 0xc3}; /* lea (%rdi,%rsi),%rax; ret */
|
||||
static const unsigned char cont[] = {0x48, 0xc7, 0xc0, 0xff, 0xff, 0xff, 0xff, 0xc3}; /* mov $-1,%rax; ret */
|
||||
/* video: rax = al | 0x100 (button check) or al | 0x200 (end set) */
|
||||
static const unsigned char held[] = {0x0f, 0xb6, 0xc0, 0x0d, 0x00, 0x01, 0x00, 0x00, 0xc3};
|
||||
static const unsigned char set_end[] = {0x0f, 0xb6, 0xc0, 0x0d, 0x00, 0x02, 0x00, 0x00, 0xc3};
|
||||
put(image, ISSN_DASH_NEON_BACK, back, sizeof back);
|
||||
put(image, ISSN_DASH_NEON_CONTINUE, cont, sizeof cont);
|
||||
put(image, ISSN_DASH_TV_HELD, held, sizeof held);
|
||||
put(image, ISSN_DASH_TV_SET_END, set_end, sizeof set_end);
|
||||
|
||||
static volatile unsigned char neon_flag = 0, tv_flag = 0;
|
||||
unsigned char *neon = image + STUB_AT, *tv = image + STUB_AT + 256;
|
||||
const unsigned neon_n = issn_dash_build_neon_stub(neon, image, &neon_flag);
|
||||
const unsigned tv_n = issn_dash_build_tv_stub(tv, image, &tv_flag);
|
||||
CHECK(neon_n <= 256 && tv_n <= 256);
|
||||
|
||||
const uint64_t check_args = (uint64_t)(uintptr_t)(image + ISSN_DASH_ABILITIES) + 0x59;
|
||||
neon_flag = 0;
|
||||
CHECK(run(neon, 0x1234, 0.0f) == check_args); /* off: the game's ability check */
|
||||
neon_flag = 1;
|
||||
CHECK(run(neon, 0x1234, 0.0f) == UINT64_MAX); /* on: the run goes on */
|
||||
|
||||
tv_flag = 0;
|
||||
CHECK(run(tv, 0x01, 1.0f) == 0x101); /* held, under 1.5 s: the button check, al kept */
|
||||
CHECK(run(tv, 0x01, 1.6f) == 0x201); /* held, past 1.5 s: end */
|
||||
CHECK(run(tv, 0x00, 1.0f) == 0x100); /* released: the button check (it ends there) */
|
||||
tv_flag = 1;
|
||||
CHECK(run(tv, 0x01, 1.6f) == 0x101); /* on: past 1.5 s only the button counts */
|
||||
CHECK(run(tv, 0x00, 9.0f) == 0x100);
|
||||
CHECK(run(tv, 0x01, 100.0f) == 0x101);
|
||||
|
||||
/* Damage stub: the method's first instructions, then a landing that undoes them. */
|
||||
put(image, ISSN_HERO_TAKE_HIT, issn_hero_take_hit_code, sizeof issn_hero_take_hit_code);
|
||||
CHECK(issn_hero_take_hit_match(image, IMAGE_SIZE));
|
||||
static const unsigned char hit_back[] = {0x41, 0x5c, 0x41, 0x5e, 0x41, 0x5f, 0x5d, /* pop r12,r14,r15,rbp */
|
||||
0xb8, 0x01, 0, 0, 0, 0xc3}; /* mov $1,%eax; ret */
|
||||
put(image, ISSN_HERO_TAKE_HIT + ISSN_HERO_TAKE_HIT_LENGTH, hit_back, sizeof hit_back);
|
||||
static unsigned char hero_object[0x100], character_object[0x10], other_object[0x10];
|
||||
uint64_t hero_at = (uint64_t)(uintptr_t)hero_object, character_at = (uint64_t)(uintptr_t)character_object;
|
||||
memcpy(hero_object + ISSN_HERO_CHARACTER, &character_at, 8);
|
||||
static volatile unsigned char health_flag = 0;
|
||||
unsigned char *damage = image + STUB_AT + 512;
|
||||
CHECK(issn_hero_build_damage_stub(damage, image, &health_flag) <= 256);
|
||||
memset(image + ISSN_HERO_GLOBAL, 0, 8);
|
||||
CHECK(run_rdi(damage, character_at) == 1); /* no hero: the hit */
|
||||
memcpy(image + ISSN_HERO_GLOBAL, &hero_at, 8);
|
||||
CHECK(run_rdi(damage, character_at) == 1); /* flag off: the hit */
|
||||
health_flag = 1;
|
||||
CHECK(run_rdi(damage, character_at) == character_at); /* on: returns at once */
|
||||
CHECK(run_rdi(damage, (uint64_t)(uintptr_t)other_object) == 1); /* others still get hit */
|
||||
|
||||
/* Missile and Karma Bomb checks: the getters and setter as the 01.07 eboot has them. */
|
||||
CHECK(!issn_hero_heavy_match(image, IMAGE_SIZE));
|
||||
unsigned char lea[7] = {0x48, 0x8d, 0x05};
|
||||
int32_t disp = (int32_t)(ISSN_HERO_PLAYER - (ISSN_HERO_HEAVY_GETTER + 7));
|
||||
memcpy(lea + 3, &disp, 4);
|
||||
put(image, ISSN_HERO_HEAVY_GETTER, lea, 7);
|
||||
put(image, ISSN_HERO_HEAVY_GETTER + 7, issn_hero_heavy_getter_tail, sizeof issn_hero_heavy_getter_tail);
|
||||
put(image, ISSN_HERO_HEAVY_SETTER, issn_hero_heavy_setter_code, sizeof issn_hero_heavy_setter_code);
|
||||
CHECK(issn_hero_heavy_match(image, IMAGE_SIZE));
|
||||
CHECK(!issn_hero_heavy_match(image, ISSN_HERO_PLAYER));
|
||||
disp = (int32_t)(ISSN_HERO_PLAYER - (ISSN_HERO_STREAK_REQUIRED_GETTER + 7));
|
||||
memcpy(lea + 3, &disp, 4);
|
||||
put(image, ISSN_HERO_STREAK_GETTER, issn_hero_streak_getter_code, sizeof issn_hero_streak_getter_code);
|
||||
put(image, ISSN_HERO_STREAK_REQUIRED_GETTER, lea, 7);
|
||||
CHECK(!issn_hero_karma_match(image, IMAGE_SIZE));
|
||||
put(image, ISSN_HERO_STREAK_REQUIRED_GETTER + 7, issn_hero_streak_required_tail,
|
||||
sizeof issn_hero_streak_required_tail);
|
||||
CHECK(issn_hero_karma_match(image, IMAGE_SIZE));
|
||||
|
||||
if (failures) {
|
||||
fprintf(stderr, "hero-stubs-test: %d failure(s)\n", failures);
|
||||
return 1;
|
||||
}
|
||||
puts("hero-stubs-test: ok");
|
||||
return 0;
|
||||
}
|
||||
@@ -27,6 +27,12 @@ BASE_SFO = sfo_bytes(TITLE_ID='CUSA01623', APP_VER='01.00')
|
||||
|
||||
|
||||
class ControlMapTest(unittest.TestCase):
|
||||
def test_effects_default_on_and_are_flags(self):
|
||||
# issn 11-04: motion blur and chromatic aberration (issn.ini, gpu issn_postfx.h).
|
||||
for key in ('motion_blur', 'chromatic_aberration'):
|
||||
self.assertEqual(launcher.INI_DEFAULTS[key], '1')
|
||||
self.assertIn(key, launcher.INI_FLAGS)
|
||||
|
||||
def test_new_cheats_default_to_disabled(self):
|
||||
self.assertEqual(launcher.INI_DEFAULTS['cheat_double_jumps'], '0')
|
||||
self.assertEqual(launcher.INI_DEFAULTS['cheat_very_hard'], '0')
|
||||
@@ -51,6 +57,13 @@ class ControlMapTest(unittest.TestCase):
|
||||
env = launcher.game_environment({**base, 'anti_aliasing': 'fsr4'})
|
||||
self.assertEqual(env['ISSN_FSR4'], '1')
|
||||
self.assertNotIn('SHADPS4_DLSS', env)
|
||||
# issn 11-04: Off - no anti-aliasing at all, and no frame generation without an upscaler.
|
||||
env = launcher.game_environment({**base, 'anti_aliasing': 'off', 'frame_gen': True})
|
||||
self.assertEqual(env['ISSN_AA_OFF'], '1')
|
||||
self.assertNotIn('ISSN_FSR4', env)
|
||||
self.assertNotIn('ISSN_FRAME_GEN', env)
|
||||
self.assertIn('off', [value for value, _text in launcher.ANTI_ALIASING])
|
||||
self.assertNotIn('ISSN_AA_OFF', launcher.game_environment({**base, 'anti_aliasing': 'fsr4'}))
|
||||
with unittest.mock.patch.object(launcher, 'WINDOWS', True):
|
||||
env = launcher.game_environment({**base, 'anti_aliasing': 'dlss', 'ui_language': 'en'})
|
||||
self.assertEqual((env['SHADPS4_DLSS'], env['SHADPS4_DLSS_UPSCALE']), ('1', '1'))
|
||||
@@ -248,6 +261,15 @@ class ControlMapTest(unittest.TestCase):
|
||||
self.assertNotIn('ISSN_SKIP_INTRO', launcher.game_environment({**launcher.APP_DEFAULTS}))
|
||||
self.assertEqual(launcher.game_environment({**launcher.APP_DEFAULTS, 'skip_intro': True})['ISSN_SKIP_INTRO'], '1')
|
||||
|
||||
def test_vram_limit_setting(self):
|
||||
self.assertNotIn('ISSN_VRAM_LIMIT_MB', launcher.game_environment({**launcher.APP_DEFAULTS}))
|
||||
self.assertEqual(launcher.game_environment({**launcher.APP_DEFAULTS, 'vram_limit': '5120'})['ISSN_VRAM_LIMIT_MB'], '5120')
|
||||
self.assertNotIn('ISSN_VRAM_LIMIT_MB', launcher.game_environment({**launcher.APP_DEFAULTS, 'vram_limit': '99'}))
|
||||
|
||||
def test_save_backups_setting(self):
|
||||
self.assertNotIn('ISSN_SAVE_BACKUP', launcher.game_environment({**launcher.APP_DEFAULTS}))
|
||||
self.assertEqual(launcher.game_environment({**launcher.APP_DEFAULTS, 'save_backups': False})['ISSN_SAVE_BACKUP'], '0')
|
||||
|
||||
def test_stick_deadzone_setting(self):
|
||||
self.assertNotIn('ISSN_STICK_DEADZONE', launcher.game_environment({**launcher.APP_DEFAULTS}))
|
||||
env = launcher.game_environment({**launcher.APP_DEFAULTS, 'stick_deadzone': '0.15'})
|
||||
@@ -677,11 +699,12 @@ class GpuChoiceTest(unittest.TestCase):
|
||||
class ReleaseVersionTest(unittest.TestCase):
|
||||
def test_updates_come_from_this_ports_releases(self):
|
||||
# issn: own version line and own releases; the God of War III port's are never offered.
|
||||
self.assertEqual(launcher.VERSION, '0.3.6.3')
|
||||
self.assertEqual(launcher.VERSION, '0.3.7')
|
||||
self.assertIn('/Liondarkz/issn_pc/releases', launcher.RELEASES_API)
|
||||
self.assertNotIn('gow3_pc', launcher.RELEASES_API + launcher.RELEASES_PAGE)
|
||||
self.assertGreater(launcher.version_tuple('windows-v0.3.7'), launcher.version_tuple(launcher.VERSION))
|
||||
self.assertEqual(launcher.version_tuple('windows-v0.3.6.3'), launcher.version_tuple(launcher.VERSION))
|
||||
self.assertGreater(launcher.version_tuple('windows-v0.3.7.1'), launcher.version_tuple(launcher.VERSION))
|
||||
self.assertEqual(launcher.version_tuple('windows-v0.3.7'), launcher.version_tuple(launcher.VERSION))
|
||||
self.assertGreater(launcher.version_tuple(launcher.VERSION), launcher.version_tuple('windows-v0.3.6.3'))
|
||||
self.assertGreater(launcher.version_tuple(launcher.VERSION), launcher.version_tuple('windows-v0.3.6'))
|
||||
self.assertGreater(launcher.version_tuple(launcher.VERSION), launcher.version_tuple('windows-v0.3.5'))
|
||||
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
"""launcher/issn_downloads.py: OptiScaler from its release .7z into bin\\, set for the port, and out
|
||||
again. No network: the release archive is written here (LZMA2 + BCJ2, the layout of v0.9.4)."""
|
||||
import binascii
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import lzma
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from paths import ROOT
|
||||
sys.path.insert(0, str(ROOT / 'launcher'))
|
||||
import issn_downloads as dl # noqa: E402
|
||||
import issn_hardware # noqa: E402
|
||||
import issn_launcher_win as launcher # noqa: E402
|
||||
|
||||
|
||||
# ---- a small 7z writer (what 7-Zip makes of a release: text in LZMA2, DLLs through BCJ2) ----
|
||||
|
||||
def num(n):
|
||||
for k in range(8):
|
||||
if n < 1 << (7 * (k + 1)):
|
||||
return bytes([(0xFF << (8 - k)) & 0xFF | (n >> (8 * k))]) + n.to_bytes(8, 'little')[:k]
|
||||
return b'\xff' + n.to_bytes(8, 'little')
|
||||
|
||||
|
||||
def bits(flags):
|
||||
out = bytearray((len(flags) + 7) // 8)
|
||||
for i, flag in enumerate(flags):
|
||||
if flag:
|
||||
out[i // 8] |= 0x80 >> (i % 8)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
class RangeEncoder:
|
||||
def __init__(self):
|
||||
self.low, self.range, self.cache, self.cache_size, self.out = 0, 0xFFFFFFFF, 0, 1, bytearray()
|
||||
|
||||
def bit(self, probs, index, value):
|
||||
p = probs[index]
|
||||
bound = (self.range >> 11) * p
|
||||
if value:
|
||||
self.low += bound
|
||||
self.range -= bound
|
||||
probs[index] = p - (p >> 5)
|
||||
else:
|
||||
self.range = bound
|
||||
probs[index] = p + ((2048 - p) >> 5)
|
||||
while self.range < 1 << 24:
|
||||
self.range <<= 8
|
||||
self.shift_low()
|
||||
|
||||
def shift_low(self):
|
||||
if (self.low & 0xFFFFFFFF) < 0xFF000000 or self.low >> 32:
|
||||
carry, temp = self.low >> 32, self.cache
|
||||
while True:
|
||||
self.out.append((temp + carry) & 0xFF)
|
||||
temp = 0xFF
|
||||
self.cache_size -= 1
|
||||
if not self.cache_size:
|
||||
break
|
||||
self.cache = (self.low >> 24) & 0xFF
|
||||
self.cache_size += 1
|
||||
self.low = (self.low & 0x00FFFFFF) << 8
|
||||
|
||||
def finish(self):
|
||||
for _ in range(5):
|
||||
self.shift_low()
|
||||
return bytes(self.out)
|
||||
|
||||
|
||||
def bcj2_encode(data, choose=lambda pos: pos % 3 != 1):
|
||||
"""7-Zip's BCJ2 x86 filter, encoding: (main, call, jump, rc). choose(pos) picks the jumps
|
||||
converted (the others stay in main), so both branches of the decoder run."""
|
||||
main, call, jump, rc, probs = bytearray(), bytearray(), bytearray(), RangeEncoder(), [1024] * 258
|
||||
i, prev = 0, 0
|
||||
while i < len(data):
|
||||
b = data[i]
|
||||
main.append(b)
|
||||
i += 1
|
||||
if not ((b & 0xFE) == 0xE8 or (prev == 0x0F and (b & 0xF0) == 0x80)):
|
||||
prev = b
|
||||
continue
|
||||
if i == len(data):
|
||||
break # the last byte: no bit (the decoder stops there)
|
||||
index = prev if b == 0xE8 else 256 if b == 0xE9 else 257
|
||||
if i + 4 <= len(data) and choose(i):
|
||||
rc.bit(probs, index, 1)
|
||||
rel = int.from_bytes(data[i:i + 4], 'little')
|
||||
(call if b == 0xE8 else jump).extend(((rel + i + 4) & 0xFFFFFFFF).to_bytes(4, 'big'))
|
||||
prev = data[i + 3]
|
||||
i += 4
|
||||
else:
|
||||
rc.bit(probs, index, 0)
|
||||
prev = b
|
||||
return bytes(main), bytes(call), bytes(jump), rc.finish()
|
||||
|
||||
|
||||
def lzma1(data):
|
||||
return lzma.compress(data, format=lzma.FORMAT_RAW, filters=[
|
||||
{'id': lzma.FILTER_LZMA1, 'dict_size': 1 << 16, 'lc': 3, 'lp': 0, 'pb': 2}])
|
||||
|
||||
|
||||
def lzma2(data):
|
||||
return lzma.compress(data, format=lzma.FORMAT_RAW, filters=[{'id': lzma.FILTER_LZMA2, 'dict_size': 1 << 16}])
|
||||
|
||||
|
||||
def coder(method, props=b'', ins=1, outs=1):
|
||||
flag = len(method) | (0x10 if (ins, outs) != (1, 1) else 0) | (0x20 if props else 0)
|
||||
out = bytes([flag]) + method
|
||||
if flag & 0x10:
|
||||
out += num(ins) + num(outs)
|
||||
if props:
|
||||
out += num(len(props)) + props
|
||||
return out
|
||||
|
||||
|
||||
LZMA1_PROPS = bytes([93]) + (1 << 16).to_bytes(4, 'little')
|
||||
LZMA2_PROPS = bytes([8]) # 64 KiB
|
||||
|
||||
|
||||
def make_7z(text_files, binary_files, folders=(), damage=None):
|
||||
"""A .7z like OptiScaler's: text_files in an LZMA2 folder, binary_files through BCJ2 (call
|
||||
and jump in LZMA, main in LZMA2, the range coder stored), `folders` as empty directories."""
|
||||
packs, folder_records, unpack_sizes, sub_counts, sub_sizes, crcs = [], [], [], [], [], []
|
||||
text = b''.join(text_files.values())
|
||||
packs.append(lzma2(text))
|
||||
folder_records.append(num(1) + coder(dl.LZMA2, LZMA2_PROPS))
|
||||
unpack_sizes.append([len(text)])
|
||||
sub_counts.append(len(text_files))
|
||||
sub_sizes.append([len(d) for d in text_files.values()])
|
||||
crcs += [binascii.crc32(d) for d in text_files.values()]
|
||||
binary = b''.join(binary_files.values())
|
||||
main, call, jump, rc = bcj2_encode(binary)
|
||||
packs += [lzma2(main), rc, lzma1(jump), lzma1(call)]
|
||||
folder_records.append(
|
||||
num(4) + coder(dl.LZMA1, LZMA1_PROPS) + coder(dl.LZMA1, LZMA1_PROPS) + coder(dl.LZMA2, LZMA2_PROPS)
|
||||
+ coder(dl.BCJ2, ins=4) + num(3) + num(2) + num(4) + num(0) + num(5) + num(1)
|
||||
+ num(2) + num(6) + num(1) + num(0))
|
||||
unpack_sizes.append([len(call), len(jump), len(main), len(binary)])
|
||||
sub_counts.append(len(binary_files))
|
||||
sub_sizes.append([len(d) for d in binary_files.values()])
|
||||
crcs += [binascii.crc32(d) for d in binary_files.values()]
|
||||
header = b'\x01\x04\x06' + num(0) + num(len(packs)) + b'\x09' + b''.join(num(len(p)) for p in packs) + b'\x00'
|
||||
header += b'\x07\x0b' + num(len(folder_records)) + b'\x00' + b''.join(folder_records) + b'\x0c'
|
||||
header += b''.join(num(s) for sizes in unpack_sizes for s in sizes) + b'\x00'
|
||||
header += b'\x08\x0d' + b''.join(num(n) for n in sub_counts) + b'\x09'
|
||||
header += b''.join(num(s) for sizes in sub_sizes for s in sizes[:-1])
|
||||
header += b'\x0a\x01' + b''.join(struct.pack('<I', c) for c in crcs) + b'\x00\x00'
|
||||
names = list(folders) + list(text_files) + list(binary_files)
|
||||
empty = [True] * len(folders) + [False] * (len(text_files) + len(binary_files))
|
||||
header += b'\x05' + num(len(names))
|
||||
if folders:
|
||||
header += b'\x0e' + num(len(bits(empty))) + bits(empty)
|
||||
header += b'\x0f' + num(len(bits([False] * len(folders)))) + bits([False] * len(folders))
|
||||
encoded = b'\x00' + b''.join(n.encode('utf-16-le') + b'\x00\x00' for n in names)
|
||||
header += b'\x11' + num(len(encoded)) + encoded + b'\x00\x00'
|
||||
body = b''.join(packs)
|
||||
if damage is not None:
|
||||
body = bytearray(body)
|
||||
body[damage] ^= 0x55
|
||||
body = bytes(body)
|
||||
tail = struct.pack('<QQI', len(body), len(header), binascii.crc32(header))
|
||||
return dl.SIGNATURE + b'\x00\x04' + struct.pack('<I', binascii.crc32(tail)) + tail + body + header
|
||||
|
||||
|
||||
def fake_code(size, seed):
|
||||
"""Bytes with plenty of E8/E9/0F 8x, like x86 code."""
|
||||
rnd = random.Random(seed)
|
||||
out = bytearray()
|
||||
while len(out) < size:
|
||||
out += rnd.choice([b'\xe8', b'\xe9', b'\x0f\x84', b'\x0f', b'\x48\x89'])
|
||||
out += rnd.randbytes(rnd.randint(0, 6))
|
||||
out[-1] = 0xE8 # a jump as the very last byte
|
||||
return bytes(out[:size - 1]) + b'\xe8'
|
||||
|
||||
|
||||
INI = '''; -------------------------------------------------------
|
||||
[Upscalers]
|
||||
; -------------------------------------------------------
|
||||
; Select Upscaler for Dx12 games
|
||||
; xess, fsr21, fsr22, fsr31 (also for FSR4), dlss - Default (auto) is xess
|
||||
Dx12Upscaler=auto
|
||||
|
||||
; Select Upscaler for Vulkan games
|
||||
; fsr21 (native VK), fsr22 (native VK), fsr31 (native VK), xess (native VK), fsr21_12 (VKon12), fsr31_12 (VKon12, FSR4), dlss - Default (auto) is fsr22
|
||||
VulkanUpscaler=auto
|
||||
|
||||
[Menu]
|
||||
; Integer value - Default (auto) is 0x2D which equals VK_INSERT/Insert key
|
||||
ShortcutKey=auto
|
||||
|
||||
; Shortcut key for FG enabled/disabled
|
||||
FGShortcutKey=auto
|
||||
'''.replace('\n', '\r\n')
|
||||
|
||||
|
||||
def release_7z(ini=INI, dll=b'MZ OptiScaler v0.9.4-final (7534ad0) ' + b'\x90' * 64):
|
||||
text = {'!! README_EXTRACT ALL FILES TO GAME FOLDER !!.txt': b'readme', 'OptiScaler.ini': ini.encode(),
|
||||
'Licenses/XeSS_LICENSE.txt': b'terms', 'setup_windows.bat': b'@echo off'}
|
||||
binary = {'amd_fidelityfx_vk.dll': fake_code(20000, 1), 'D3D12_Optiscaler/D3D12Core.dll': fake_code(3000, 2),
|
||||
'OptiScaler.dll': dll + fake_code(9000, 3)}
|
||||
return make_7z(text, binary, folders=('D3D12_Optiscaler', 'Licenses')), text, binary
|
||||
|
||||
|
||||
def port_bin(root):
|
||||
"""A bin\\ with the port's own files, which nothing here may touch."""
|
||||
bin_dir = Path(root) / 'bin'
|
||||
(bin_dir / 'xess').mkdir(parents=True)
|
||||
(bin_dir / 'dlss').mkdir()
|
||||
for name in ('issn-probe.exe', 'SDL3.dll', 'xess/libxess.dll', 'dlss/nvngx_dlss.dll'):
|
||||
(bin_dir / name).write_bytes(b'port ' + name.encode())
|
||||
return bin_dir
|
||||
|
||||
|
||||
def tree(folder):
|
||||
return sorted(p.relative_to(folder).as_posix() for p in Path(folder).rglob('*') if p.is_file())
|
||||
|
||||
|
||||
PORT_FILES = ['SDL3.dll', 'dlss/nvngx_dlss.dll', 'issn-probe.exe', 'xess/libxess.dll']
|
||||
|
||||
|
||||
class SevenZipTest(unittest.TestCase):
|
||||
def test_reads_lzma2_and_bcj2_folders_and_checks_every_file(self):
|
||||
blob, text, binary = release_7z()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = Path(tmp) / 'r.7z'
|
||||
archive.write_bytes(blob)
|
||||
seen = []
|
||||
with dl.SevenZip(archive) as seven:
|
||||
self.assertEqual([e.name for e in seven.entries if e.is_dir], ['D3D12_Optiscaler', 'Licenses'])
|
||||
seven.extract(lambda e: Path(tmp) / 'x' / e.name, progress=lambda d, t: seen.append((d, t)))
|
||||
for name, data in {**text, **binary}.items():
|
||||
self.assertEqual((Path(tmp) / 'x' / name).read_bytes(), data, name)
|
||||
self.assertEqual(seen[-1][0], seen[-1][1])
|
||||
self.assertFalse(list(Path(tmp).rglob('*.part')))
|
||||
|
||||
def test_bcj2_converts_and_keeps_jumps(self):
|
||||
data = fake_code(50000, 7)
|
||||
main, call, jump, rc = bcj2_encode(data)
|
||||
self.assertTrue(call and jump and len(main) < len(data)) # both kinds were converted
|
||||
chunks = [main[i:i + 777] for i in range(0, len(main), 777)] # cuts land anywhere
|
||||
self.assertEqual(b''.join(dl._bcj2(iter(chunks), call, jump, rc, len(data))), data)
|
||||
|
||||
def test_a_damaged_archive_is_an_error_and_keeps_nothing(self):
|
||||
text, binary = {'a.txt': random.Random(4).randbytes(3000)}, {'b.dll': fake_code(5000, 5)}
|
||||
size = len(lzma2(text['a.txt']))
|
||||
for damage in (size // 2, size + 200): # in the LZMA2 text, in the BCJ2 main stream
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = Path(tmp) / 'r.7z'
|
||||
archive.write_bytes(make_7z(text, binary, damage=damage))
|
||||
with self.assertRaises(OSError), dl.SevenZip(archive) as seven:
|
||||
seven.extract(lambda e: Path(tmp) / 'x' / e.name)
|
||||
kept = [name for name in tree(Path(tmp) / 'x')] if (Path(tmp) / 'x').exists() else []
|
||||
self.assertEqual([name for name in kept if (Path(tmp) / 'x' / name).read_bytes()
|
||||
== {**text, **binary}.get(name)], kept) # only whole, checked files
|
||||
|
||||
def test_not_a_7z_or_cut_short(self):
|
||||
blob, _t, _b = release_7z()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for data in (b'PK\x03\x04 zip', blob[:len(blob) // 2]):
|
||||
archive = Path(tmp) / 'r.7z'
|
||||
archive.write_bytes(data)
|
||||
with self.assertRaises(OSError):
|
||||
dl.SevenZip(archive)
|
||||
|
||||
@unittest.skipUnless(os.environ.get('ISSN_OPTISCALER_7Z'), 'ISSN_OPTISCALER_7Z: a real release to read')
|
||||
def test_a_real_release(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
files = dl.install(os.environ['ISSN_OPTISCALER_7Z'], port_bin(tmp), 'fsr22', 'real')
|
||||
self.assertIn('winmm.dll', files)
|
||||
|
||||
|
||||
class IniTest(unittest.TestCase):
|
||||
def test_configure_sets_the_upscaler_and_the_menu_key_and_nothing_else(self):
|
||||
out = dl.configure(INI, 'fsr4')
|
||||
changed = [(a, b) for a, b in zip(INI.split('\r\n'), out.split('\r\n')) if a != b]
|
||||
self.assertEqual(changed, [('VulkanUpscaler=auto', 'VulkanUpscaler=fsr31_12'),
|
||||
('ShortcutKey=auto', 'ShortcutKey=0x77')])
|
||||
self.assertEqual(dl.ini_get(out, 'Menu', 'FGShortcutKey'), 'auto')
|
||||
|
||||
def test_output_names_follow_the_release(self):
|
||||
for output, value in (('fsr4', 'fsr31_12'), ('fsr31', 'fsr31'), ('xess', 'xess'), ('dlss', 'dlss'),
|
||||
('fsr22', 'fsr22')):
|
||||
self.assertEqual(dl.vulkan_upscaler_value(INI, output), value)
|
||||
master = INI.replace('fsr31 (native VK)', 'ffx (native VK)').replace('fsr31_12 (VKon12, FSR4)',
|
||||
'ffx_12 (VKon12, FSR4)')
|
||||
self.assertEqual(dl.vulkan_upscaler_value(master, 'fsr4'), 'ffx_12')
|
||||
self.assertEqual(dl.vulkan_upscaler_value(master, 'fsr31'), 'ffx')
|
||||
|
||||
def test_the_spaced_form_of_its_menu_and_missing_keys(self):
|
||||
spaced = '[Upscalers]\nVulkanUpscaler = fsr22\n'
|
||||
self.assertEqual(dl.ini_set(spaced, 'upscalers', 'vulkanupscaler', 'xess'), '[Upscalers]\nVulkanUpscaler = xess\n')
|
||||
added = dl.ini_set(spaced, 'Menu', 'ShortcutKey', '0x77')
|
||||
self.assertEqual(dl.ini_get(added, 'Menu', 'ShortcutKey'), '0x77')
|
||||
self.assertEqual(dl.ini_get(added, 'Upscalers', 'VulkanUpscaler'), 'fsr22')
|
||||
self.assertEqual(dl.ini_get(dl.ini_set('[A]\nx=1\n\n[B]\n', 'A', 'y', '2'), 'A', 'y'), '2')
|
||||
|
||||
|
||||
class InstallTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.tmp.name)
|
||||
self.bin = port_bin(self.root)
|
||||
self.blob, self.text, self.binary = release_7z()
|
||||
self.archive = self.root / 'release.7z'
|
||||
self.archive.write_bytes(self.blob)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_install_puts_winmm_and_its_files_beside_the_port(self):
|
||||
files = dl.install(self.archive, self.bin, 'fsr22', 'v0.9.4')
|
||||
self.assertEqual(sorted(files, key=str.lower), sorted(
|
||||
['winmm.dll', 'OptiScaler.ini', 'amd_fidelityfx_vk.dll', 'D3D12_Optiscaler/D3D12Core.dll',
|
||||
'optiscaler-licenses/XeSS_LICENSE.txt'], key=str.lower))
|
||||
self.assertEqual(tree(self.bin), sorted(PORT_FILES + files + ['optiscaler.json']))
|
||||
self.assertEqual((self.bin / 'winmm.dll').read_bytes(), self.binary['OptiScaler.dll'])
|
||||
for name in PORT_FILES:
|
||||
self.assertEqual((self.bin / name).read_bytes(), b'port ' + name.encode())
|
||||
ini = (self.bin / 'OptiScaler.ini').read_bytes().decode()
|
||||
self.assertIn('\r\nVulkanUpscaler=fsr22\r\n', ini)
|
||||
self.assertIn('\r\nShortcutKey=0x77\r\n', ini)
|
||||
self.assertEqual(dl.optiscaler_state(self.bin),
|
||||
{'version': 'v0.9.4', 'by_hand': False, 'upscaler': 'fsr22', 'menu_key': '0x77',
|
||||
'foreign': False})
|
||||
self.assertFalse((self.bin / '.optiscaler-new').exists())
|
||||
|
||||
def test_remove_takes_out_everything_it_put_and_what_optiscaler_wrote(self):
|
||||
dl.install(self.archive, self.bin, 'fsr4', 'v0.9.4')
|
||||
(self.bin / 'OptiScaler.log').write_text('log')
|
||||
removed = dl.remove(self.bin)
|
||||
self.assertIn('winmm.dll', removed)
|
||||
self.assertIn('OptiScaler.log', removed)
|
||||
self.assertEqual(tree(self.bin), PORT_FILES)
|
||||
self.assertEqual(sorted(p.name for p in self.bin.iterdir()), ['SDL3.dll', 'dlss', 'issn-probe.exe', 'xess'])
|
||||
self.assertIsNone(dl.optiscaler_state(self.bin))
|
||||
|
||||
def test_a_copy_unpacked_by_hand_is_recognised_and_removed(self):
|
||||
with dl.SevenZip(self.archive) as seven: # what João did: everything into bin\, the DLL renamed
|
||||
seven.extract(lambda e: self.bin / ('winmm.dll' if e.name == 'OptiScaler.dll' else e.name))
|
||||
state = dl.optiscaler_state(self.bin)
|
||||
self.assertEqual((state['version'], state['by_hand'], state['foreign']), ('v0.9.4-final', True, False))
|
||||
dl.remove(self.bin)
|
||||
self.assertEqual(tree(self.bin), PORT_FILES)
|
||||
|
||||
def test_installing_over_a_copy_by_hand_then_removing_leaves_nothing_of_it(self):
|
||||
with dl.SevenZip(self.archive) as seven:
|
||||
seven.extract(lambda e: self.bin / ('winmm.dll' if e.name == 'OptiScaler.dll' else e.name))
|
||||
dl.install(self.archive, self.bin, 'fsr22', 'v0.9.4')
|
||||
self.assertEqual(dl.optiscaler_state(self.bin)['menu_key'], '0x77')
|
||||
dl.remove(self.bin)
|
||||
self.assertEqual(tree(self.bin), PORT_FILES)
|
||||
|
||||
def test_another_winmm_is_left_alone(self):
|
||||
(self.bin / 'winmm.dll').write_bytes(b'MZ something else')
|
||||
self.assertTrue(dl.optiscaler_state(self.bin)['foreign'])
|
||||
with self.assertRaises(OSError):
|
||||
dl.install(self.archive, self.bin, 'fsr22', 'v0.9.4')
|
||||
self.assertEqual(dl.remove(self.bin), [])
|
||||
self.assertEqual((self.bin / 'winmm.dll').read_bytes(), b'MZ something else')
|
||||
|
||||
def test_reinstall_and_changing_the_output(self):
|
||||
dl.install(self.archive, self.bin, 'fsr22', 'v0.9.3')
|
||||
dl.install(self.archive, self.bin, 'xess', 'v0.9.4')
|
||||
self.assertEqual(dl.optiscaler_state(self.bin)['version'], 'v0.9.4')
|
||||
self.assertEqual(dl.set_output(self.bin, 'fsr31'), 'fsr31')
|
||||
self.assertEqual(dl.optiscaler_state(self.bin)['upscaler'], 'fsr31')
|
||||
self.assertEqual(dl.set_output(self.bin, 'dlss', dlss_dll=self.bin / 'dlss' / 'nvngx_dlss.dll'), 'dlss')
|
||||
self.assertEqual((self.bin / 'nvngx_dlss.dll').read_bytes(), b'port dlss/nvngx_dlss.dll')
|
||||
self.assertIn('nvngx_dlss.dll', json.loads((self.bin / 'optiscaler.json').read_text())['files'])
|
||||
dl.remove(self.bin)
|
||||
self.assertEqual(tree(self.bin), PORT_FILES) # the copy goes, the port's own DLSS stays
|
||||
|
||||
def test_an_unsafe_name_in_the_archive_is_refused(self):
|
||||
archive = self.root / 'bad.7z'
|
||||
archive.write_bytes(make_7z({'OptiScaler.ini': b'x', '../evil.txt': b'x'}, {'OptiScaler.dll': b'MZ'}))
|
||||
with self.assertRaises(OSError):
|
||||
dl.install(archive, self.bin, 'fsr22', 'v1')
|
||||
self.assertFalse((self.root / 'evil.txt').exists())
|
||||
self.assertIsNone(dl.optiscaler_state(self.bin))
|
||||
|
||||
def test_a_release_without_optiscaler_dll_is_refused(self):
|
||||
archive = self.root / 'other.7z'
|
||||
archive.write_bytes(make_7z({'readme.txt': b'x'}, {'other.dll': b'MZ'}))
|
||||
with self.assertRaises(OSError):
|
||||
dl.install(archive, self.bin, 'fsr22', 'v1')
|
||||
self.assertEqual(tree(self.bin), PORT_FILES)
|
||||
|
||||
|
||||
class FakeResponse(io.BytesIO):
|
||||
def __init__(self, data):
|
||||
super().__init__(data)
|
||||
self.headers = {'Content-Length': str(len(data))}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
class ReleaseTest(unittest.TestCase):
|
||||
def api(self, **release):
|
||||
body = json.dumps({'tag_name': 'v0.9.5', 'prerelease': False, 'assets': [
|
||||
{'name': 'Optiscaler_0.9.5-final.7z', 'size': 10, 'digest': 'sha256:' + 'ab' * 32,
|
||||
'browser_download_url': dl.OPTISCALER_DOWNLOADS + 'v0.9.5/Optiscaler_0.9.5-final.7z'}],
|
||||
**release}).encode()
|
||||
return lambda request, timeout=0: FakeResponse(body)
|
||||
|
||||
def test_latest_stable_release_and_its_checksum(self):
|
||||
release = dl.optiscaler_release(self.api())
|
||||
self.assertEqual((release.tag, release.sha256, release.size), ('v0.9.5', 'ab' * 32, 10))
|
||||
self.assertTrue(release.url.startswith('https://github.com/optiscaler/OptiScaler/releases/download/'))
|
||||
|
||||
def test_pre_releases_and_files_elsewhere_are_refused(self):
|
||||
with self.assertRaises(ValueError):
|
||||
dl.optiscaler_release(self.api(prerelease=True))
|
||||
with self.assertRaises(ValueError):
|
||||
dl.optiscaler_release(self.api(assets=[{'name': 'Optiscaler.7z', 'digest': 'sha256:' + '0' * 64,
|
||||
'browser_download_url': 'https://example.invalid/Optiscaler.7z'}]))
|
||||
|
||||
def test_the_known_release_is_pinned(self):
|
||||
known = dl.OPTISCALER_KNOWN
|
||||
self.assertTrue(known.url.startswith(dl.OPTISCALER_DOWNLOADS))
|
||||
self.assertEqual(len(known.sha256), 64)
|
||||
|
||||
def test_download_checks_the_file(self):
|
||||
data = b'7z' * 1000
|
||||
opener = lambda request, timeout=0: FakeResponse(data) # noqa: E731
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target = Path(tmp) / 'o.7z'
|
||||
good = dl.Release('v1', 'https://x', hashlib.sha256(data).hexdigest(), len(data))
|
||||
seen = []
|
||||
dl.download(good, target, progress=lambda d, t: seen.append((d, t)), opener=opener)
|
||||
self.assertEqual(target.read_bytes(), data)
|
||||
self.assertEqual(seen[-1], (len(data), len(data)))
|
||||
target.unlink()
|
||||
for bad in (dl.Release('v1', 'https://x', '0' * 64, 0), dl.Release('v1', 'https://x', '', 0)):
|
||||
with self.assertRaises(OSError):
|
||||
dl.download(bad, target, opener=opener)
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [])
|
||||
|
||||
|
||||
class LauncherTest(unittest.TestCase):
|
||||
"""The launcher's side: what it offers, and the line at the top of last_run.log."""
|
||||
|
||||
def test_outputs_follow_the_graphics_card(self):
|
||||
def keys(name):
|
||||
return [k for k, _t in launcher.optiscaler_choices(issn_hardware.Pc(gpu_name=name) if name else None)]
|
||||
self.assertEqual(keys('AMD Radeon RX 9060 XT'), ['fsr4', 'fsr31', 'xess', 'fsr22'])
|
||||
self.assertEqual(keys('NVIDIA GeForce RTX 4070'), ['fsr31', 'xess', 'dlss', 'fsr22'])
|
||||
self.assertEqual(keys('NVIDIA GeForce GTX 1660'), ['fsr31', 'xess', 'fsr22'])
|
||||
self.assertEqual(keys('Intel(R) Arc(TM) A770'), ['fsr31', 'xess', 'fsr22'])
|
||||
self.assertEqual(keys(''), ['fsr4', 'fsr31', 'xess', 'fsr22']) # not checked yet
|
||||
self.assertEqual(launcher.APP_DEFAULTS['optiscaler_output'], 'fsr22')
|
||||
self.assertEqual(set(dict(launcher.OPTISCALER_CHOICES)),
|
||||
set(dl.OPTISCALER_OUTPUTS))
|
||||
|
||||
def test_outputs_are_shown_by_their_names(self):
|
||||
for value, name in (('fsr31_12', 'FSR 4'), ('ffx_12', 'FSR 4'), ('FFX', 'FSR 3.1'), ('fsr31', 'FSR 3.1'),
|
||||
('xess', 'XeSS'), ('dlss', 'DLSS'), ('fsr22', 'FSR 2.2'), ('fsr21', 'fsr21'),
|
||||
(None, '?')):
|
||||
self.assertEqual(launcher.optiscaler_output_name(value), name, value)
|
||||
|
||||
def test_the_log_says_when_optiscaler_is_there(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
bin_dir = port_bin(tmp)
|
||||
archive = Path(tmp) / 'r.7z'
|
||||
archive.write_bytes(release_7z()[0])
|
||||
with mock.patch.object(launcher, 'WINDOWS', True), \
|
||||
mock.patch.object(launcher, 'optiscaler_bin', return_value=bin_dir):
|
||||
self.assertEqual(launcher.optiscaler_log_line({'anti_aliasing': 'xess'}), '')
|
||||
dl.install(archive, bin_dir, 'fsr4', 'v0.9.4')
|
||||
line = launcher.optiscaler_log_line({'anti_aliasing': 'xess'})
|
||||
self.assertEqual(line, 'OptiScaler v0.9.4 installed by the launcher: bin\\winmm.dll, '
|
||||
'VulkanUpscaler=fsr31_12, menu key 0x77 (OptiScaler.ini)\n')
|
||||
self.assertIn('not XeSS: OptiScaler gets no input', launcher.optiscaler_log_line({'anti_aliasing': 'fsr4'}))
|
||||
dl.remove(bin_dir)
|
||||
(bin_dir / 'winmm.dll').write_bytes(b'MZ other')
|
||||
self.assertIn('another program', launcher.optiscaler_log_line({}))
|
||||
with mock.patch.object(launcher, 'WINDOWS', False):
|
||||
self.assertEqual(launcher.optiscaler_log_line({}), '')
|
||||
|
||||
def test_play_writes_the_line_first(self):
|
||||
owner = launcher.Launcher.__new__(launcher.Launcher)
|
||||
owner.process = None
|
||||
owner.app = {**launcher.APP_DEFAULTS, 'game_dir': 'synthetic-game', 'anti_aliasing': 'game'}
|
||||
for name in ('collect', 'offer_system_files', 'set_log', 'play_button', 'status', 'append',
|
||||
'offer_optiscaler_xess'):
|
||||
setattr(owner, name, mock.Mock(return_value=True))
|
||||
log = mock.Mock()
|
||||
with mock.patch.object(launcher, 'game_info', return_value=('inFAMOUS Second Son', '01.07')), \
|
||||
mock.patch.object(launcher, 'missing_game_files', return_value=[]), \
|
||||
mock.patch.object(launcher, 'open_play_log', return_value=log), \
|
||||
mock.patch.object(launcher, 'optiscaler_log_line', return_value='OptiScaler v0.9.4 installed\n'), \
|
||||
mock.patch.object(launcher.subprocess, 'Popen', return_value=mock.Mock()), \
|
||||
mock.patch.object(launcher, 'GameJob'), \
|
||||
mock.patch.object(launcher.threading, 'Thread'):
|
||||
owner.play()
|
||||
log.write.assert_called_once_with('OptiScaler v0.9.4 installed\n')
|
||||
owner.offer_optiscaler_xess.assert_called_once_with()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -8,13 +8,15 @@ import issn_launcher_win as launcher # noqa: E402
|
||||
|
||||
|
||||
class PagesTest(unittest.TestCase):
|
||||
def test_sidebar_lists_seven_pages_in_order(self):
|
||||
self.assertEqual(launcher.PAGES, ('home', 'news', 'graphics', 'performance', 'game', 'advanced', 'help'))
|
||||
def test_sidebar_lists_eight_pages_in_order(self):
|
||||
self.assertEqual(launcher.PAGES, ('home', 'news', 'graphics', 'performance', 'game', 'trophies', 'advanced',
|
||||
'help'))
|
||||
|
||||
def test_showing_a_page_refreshes_what_it_holds(self):
|
||||
self.assertEqual(launcher.PAGE_REFRESH['home'], ('refresh_status',))
|
||||
self.assertNotIn('graphics', launcher.PAGE_REFRESH) # nothing on it is a list to rebuild
|
||||
self.assertEqual(launcher.PAGE_REFRESH['game'], ('refresh_lists', 'refresh_game_patches'))
|
||||
self.assertEqual(launcher.PAGE_REFRESH['trophies'], ('refresh_trophies',)) # the game may have unlocked more
|
||||
|
||||
def test_every_refreshed_page_is_a_sidebar_page(self):
|
||||
self.assertTrue(set(launcher.PAGE_REFRESH) <= set(launcher.PAGES))
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""The launcher's Achievements page: the trophies of the player's trophy file, which ones this PC
|
||||
unlocked, the popup switch (issn.ini trophy_note) and a key that is only kept when it opens the
|
||||
file (a synthetic file and a made-up key; no real one is in the port)."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import paths # noqa: F401
|
||||
import trophies
|
||||
from test_launcher_controls import launcher
|
||||
from test_trophies import CONF, COMM, KEY, PT, esfm, make_trp, png
|
||||
|
||||
|
||||
class LauncherTrophiesTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
try:
|
||||
self.root = tk.Tk()
|
||||
except tk.TclError as error:
|
||||
self.skipTest(f'Tk display unavailable: {error}')
|
||||
self.root.withdraw()
|
||||
self.addCleanup(self.root.destroy)
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
folder = Path(temporary.name)
|
||||
self.game, self.user = folder / 'dump/Image0', folder / 'user'
|
||||
(self.game / 'sce_sys').mkdir(parents=True)
|
||||
self.user.mkdir()
|
||||
make_trp(folder / 'dump/Sc0/trophy/trophy00.trp',
|
||||
[('TROP000.PNG', png(0), 0), ('TROP001.PNG', png(1), 0), ('TROP002.PNG', png(2), 0),
|
||||
('TROP003.PNG', png(3), 0), ('TROPCONF.ESFM', esfm(CONF), 3), ('TROP.ESFM', esfm(CONF), 3),
|
||||
('TROP_17.ESFM', esfm(PT), 3)])
|
||||
(folder / 'dump/Sc0/npbind.dat').write_bytes(bytes(64) + COMM.encode() + bytes(20))
|
||||
(self.user / trophies.UNLOCKED_FILE).write_text('2 2026-11-04 23:50:12\n', encoding='utf-8')
|
||||
config = folder / 'config'
|
||||
config.mkdir()
|
||||
config_file = config / 'settings.json'
|
||||
config_file.write_text(json.dumps({**launcher.APP_DEFAULTS, 'game_dir': str(self.game), 'user_dir': str(self.user),
|
||||
'game_language': '17', 'ui_language': 'en', 'check_updates': False}),
|
||||
encoding='utf-8')
|
||||
for name, value in [('CONFIG_DIR', config), ('CONFIG_FILE', config_file), ('DATA_DIR', folder)]:
|
||||
patch = mock.patch.object(launcher, name, value)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
gpu = mock.patch.object(launcher, 'list_gpus', return_value=[])
|
||||
gpu.start()
|
||||
self.addCleanup(gpu.stop)
|
||||
self.app = launcher.Launcher(self.root, tk, ttk, filedialog, messagebox)
|
||||
self.app.messagebox = mock.Mock()
|
||||
|
||||
def cards(self):
|
||||
return self.app.trophy_list.winfo_children()
|
||||
|
||||
def labels(self, card):
|
||||
return [w.cget('text') for w in card.winfo_children() if w.winfo_class() == 'Label' and w.cget('text')]
|
||||
|
||||
def test_page_lists_every_trophy_unlocked_first(self):
|
||||
self.app.show('trophies')
|
||||
self.assertEqual(self.app.trophy_summary.cget('text'), '1 of 4 unlocked (25%)')
|
||||
self.assertEqual(len(self.cards()), 4)
|
||||
self.assertEqual(self.labels(self.cards()[0])[:2], ['Trophy 2', 'Unlocked 2026-11-04 23:50:12'])
|
||||
self.assertEqual(self.labels(self.cards()[1])[:2], ['Trophy 0', 'Locked'])
|
||||
self.assertIn('No key', self.app.trophy_key_state.cget('text'))
|
||||
self.assertTrue(self.app.var('trophy_note', 'ini').get()) # the popup is on by default
|
||||
|
||||
def test_a_key_is_kept_only_when_it_opens_the_file(self):
|
||||
self.app.show('trophies')
|
||||
self.app.trophy_key_entry.insert(0, '00' * 16)
|
||||
self.app.save_trophy_key()
|
||||
self.assertIsNone(trophies.read_key(self.user))
|
||||
self.app.messagebox.showerror.assert_called_once()
|
||||
self.app.trophy_key_entry.delete(0, 'end')
|
||||
self.app.trophy_key_entry.insert(0, KEY.hex())
|
||||
self.app.save_trophy_key()
|
||||
self.assertEqual(trophies.read_key(self.user), KEY)
|
||||
# The name in the game language (Brazilian Portuguese: TROP_17) only for the unlocked trophy.
|
||||
texts = [self.labels(card)[0] for card in self.cards()]
|
||||
self.assertEqual(texts, ['Segundo', 'Trophy 0', 'Trophy 1', 'Trophy 3'])
|
||||
self.assertEqual(self.labels(self.cards()[0])[1], 'Oculto\nUnlocked 2026-11-04 23:50:12')
|
||||
self.assertEqual(self.labels(self.cards()[1])[1], 'Locked')
|
||||
self.assertIn('Key saved', self.app.trophy_key_state.cget('text'))
|
||||
self.app.remove_trophy_key()
|
||||
self.assertIsNone(trophies.read_key(self.user))
|
||||
self.assertEqual(self.labels(self.cards()[0])[0], 'Trophy 2') # not inFAMOUS: no list either
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+29
-1
@@ -10,6 +10,13 @@ volatile int runtime_snapshot_request; /* runtime_thread.c */
|
||||
static int capture;
|
||||
int issngpu_overlay_captures_input(void) { return capture; }
|
||||
const char *issngpu_saved_key_map(void) { return ""; }
|
||||
static float test_pad_sens=1.0f, test_mouse_sens=1.0f;
|
||||
float issngpu_pad_sensitivity(void) { return test_pad_sens; }
|
||||
float issngpu_mouse_sensitivity(void) { return test_mouse_sens; }
|
||||
void issngpu_set_mouse_sensitivity(float value) { test_mouse_sens=value; }
|
||||
static int test_spray_pose;
|
||||
int issngpu_spray_pose(void) { return test_spray_pose; }
|
||||
void issngpu_set_spray_pose(int on) { test_spray_pose=on; }
|
||||
uintptr_t runtime_lookup(const RuntimeExport *table, size_t count, const char *name) {
|
||||
(void)table; (void)count; (void)name;
|
||||
return 0;
|
||||
@@ -313,9 +320,30 @@ int main(void) {
|
||||
usleep(20000);
|
||||
data=read_pad();
|
||||
assert(fabsf(data.angular_velocity[0])>0.09f && fabsf(data.angular_velocity[0])<0.18f);
|
||||
/* issn 11-04: L3+R3 reach the game and leave the pose alone; the pose source (H, the menu)
|
||||
* turns it off. */
|
||||
inject(path,"l3 r3");
|
||||
data=read_pad();
|
||||
assert(!synth.pose_toggle && !(data.buttons & (BTN_L3|BTN_R3)));
|
||||
assert(synth.pose_toggle && (data.buttons & (BTN_L3|BTN_R3))==(BTN_L3|BTN_R3));
|
||||
inject(path,"spray_pose");
|
||||
data=read_pad();
|
||||
assert(!synth.pose_toggle && !test_spray_pose && !(data.buttons & SPRAY_POSE));
|
||||
inject(path,"");
|
||||
data=read_pad();
|
||||
/* issn 11-04: the menu's controller sensitivity on the right stick: 0.5 halves a full push,
|
||||
* 2 lifts a quarter push to about half (a full push stays full). */
|
||||
inject(path,"rx=255");
|
||||
test_pad_sens=0.5f;
|
||||
data=read_pad();
|
||||
assert(data.right_x>=185 && data.right_x<=197);
|
||||
inject(path,"rx=160");
|
||||
test_pad_sens=2.0f;
|
||||
data=read_pad();
|
||||
assert(data.right_x>=185 && data.right_x<=199);
|
||||
inject(path,"rx=255");
|
||||
data=read_pad();
|
||||
assert(data.right_x==255);
|
||||
test_pad_sens=1.0f;
|
||||
inject(path,"");
|
||||
for (int i=0;i<30;++i) { data=read_pad(); usleep(15000); }
|
||||
assert(near(data.acceleration[1],1.0f,0.05f));
|
||||
|
||||
@@ -6,6 +6,13 @@ volatile int runtime_snapshot_request; /* runtime_thread.c */
|
||||
|
||||
int issngpu_overlay_captures_input(void) { return 0; }
|
||||
const char *issngpu_saved_key_map(void) { return ""; }
|
||||
static float test_pad_sens=1.0f, test_mouse_sens=1.0f;
|
||||
float issngpu_pad_sensitivity(void) { return test_pad_sens; }
|
||||
float issngpu_mouse_sensitivity(void) { return test_mouse_sens; }
|
||||
void issngpu_set_mouse_sensitivity(float value) { test_mouse_sens=value; }
|
||||
static int test_spray_pose;
|
||||
int issngpu_spray_pose(void) { return test_spray_pose; }
|
||||
void issngpu_set_spray_pose(int on) { test_spray_pose=on; }
|
||||
uintptr_t runtime_lookup(const RuntimeExport *table, size_t count, const char *name) {
|
||||
(void)table; (void)count; (void)name;
|
||||
return 0;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// issn 11-04: the motion blur / chromatic aberration switches (gpu issn_postfx.h).
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include "video_core/renderer_vulkan/issn_postfx.h"
|
||||
|
||||
namespace P = Vulkan::PostFx;
|
||||
|
||||
int main() {
|
||||
// The hash lists: built-in ones plus ISSN_POSTFX_MB / ISSN_POSTFX_CA (read once, at first use).
|
||||
#ifdef _WIN32
|
||||
_putenv_s("ISSN_POSTFX_MB", "abcd1234, deadbeef,0");
|
||||
#else
|
||||
setenv("ISSN_POSTFX_MB", "abcd1234, deadbeef,0", 1);
|
||||
#endif
|
||||
assert(P::Known(P::MotionBlur) && P::Programs()[P::MotionBlur].count == 2);
|
||||
assert(!P::Known(P::Chromatic));
|
||||
P::EffectPrograms parsed{};
|
||||
P::ParseHashList("x1,2;;3", parsed);
|
||||
assert(parsed.count == 3 && parsed.hashes[0] == 1 && parsed.hashes[2] == 3);
|
||||
|
||||
// Everything on: nothing to do on the back.
|
||||
assert(!P::Active() && P::Decide(0xdeadbeef) == P::Action::Draw);
|
||||
P::g_off[P::MotionBlur] = true;
|
||||
assert(P::Active());
|
||||
assert(P::Decide(0xdeadbeef) == P::Action::Copy && P::Decide(0xabcd1234) == P::Action::Copy);
|
||||
assert(P::Decide(0x1234) == P::Action::Draw && P::Decide(0) == P::Action::Draw);
|
||||
P::g_off[P::MotionBlur] = false;
|
||||
|
||||
// The test list: copy by default, drop when asked, back on.
|
||||
P::SetTestOff(0x77, true);
|
||||
P::SetTestOff(0x77, true); // once only
|
||||
assert(P::IsTestOff(0x77) && P::Decide(0x77) == P::Action::Copy);
|
||||
P::g_test_mode = 1;
|
||||
assert(P::Decide(0x77) == P::Action::Drop);
|
||||
P::SetTestOff(0x77, false);
|
||||
assert(!P::IsTestOff(0x77) && P::Decide(0x77) == P::Action::Draw && !P::Active());
|
||||
for (u64 h = 1; h <= 20; ++h) {
|
||||
P::SetTestOff(h, true); // 16 slots: the rest are not kept
|
||||
}
|
||||
assert(P::IsTestOff(16) && !P::IsTestOff(17));
|
||||
std::puts("PASS: post effects");
|
||||
}
|
||||
@@ -32,6 +32,35 @@ class SaveBackupTest(unittest.TestCase):
|
||||
self.assertEqual(made.name, '20261009-180100')
|
||||
self.assertEqual((made / self.save.relative_to(self.user / 'savedata')).read_text(), 'v1')
|
||||
|
||||
def test_no_backup_of_a_huge_savedata(self):
|
||||
write(self.save, 'v1')
|
||||
for i in range(5):
|
||||
write(self.user / 'savedata' / 'junk' / f'{i}.bin', 'x')
|
||||
old = save_backup.MAX_FILES
|
||||
save_backup.MAX_FILES = 4
|
||||
try:
|
||||
with self.assertRaises(save_backup.TooBig):
|
||||
save_backup.backup(self.user, now=self.tick())
|
||||
finally:
|
||||
save_backup.MAX_FILES = old
|
||||
self.assertEqual(save_backup.list_backups(self.user), [])
|
||||
|
||||
def test_import_of_a_huge_savedata_backs_up_the_game_folders_only(self):
|
||||
write(self.save, 'mine')
|
||||
for i in range(5):
|
||||
write(self.user / 'savedata' / 'junk' / f'{i}.bin', 'x')
|
||||
other = self.user / 'other' / 'savedata' / '1' / 'CUSA00223' / 'infamousssAuto0' / 'data.bin'
|
||||
write(other, 'theirs')
|
||||
old = save_backup.MAX_FILES
|
||||
save_backup.MAX_FILES = 4
|
||||
try:
|
||||
safety, slots = save_backup.import_saves(self.user, self.user / 'other', now=self.tick())
|
||||
finally:
|
||||
save_backup.MAX_FILES = old
|
||||
self.assertEqual(sorted(p.name for p in safety.iterdir()), ['1'])
|
||||
self.assertEqual((safety / self.save.relative_to(self.user / 'savedata')).read_text(), 'mine')
|
||||
self.assertEqual(slots, 1)
|
||||
|
||||
def test_no_backup_without_saves(self):
|
||||
self.assertIsNone(save_backup.backup(self.user, now=self.tick()))
|
||||
(self.user / 'savedata').mkdir()
|
||||
|
||||
+22
-1
@@ -2,6 +2,7 @@
|
||||
#undef NDEBUG /* the checks are asserts; release builds define NDEBUG */
|
||||
#include "../src/runtime_services.c"
|
||||
#include <assert.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
uintptr_t runtime_lookup(const RuntimeExport *table, size_t count, const char *nid) { (void)table; (void)count; (void)nid; return 0; }
|
||||
int issngpu_text_input_begin(const char *text, const char *prompt) { (void)text; (void)prompt; return 0; }
|
||||
@@ -13,6 +14,12 @@ static int choice_next=-1;
|
||||
int issngpu_choice_begin(const char *title, const char *const *items, const char *const *images, int count, int focus) { (void)title; (void)items; (void)images; (void)focus; return count>0; }
|
||||
int issngpu_choice_poll(void) { return choice_next; }
|
||||
void issngpu_note_game_saved(const char *what) { (void)what; }
|
||||
static int trophy_popups, trophy_popup_last=-1;
|
||||
void issngpu_note_trophy(int id, const char *name, const char *grade, const void *png, uint64_t size) {
|
||||
(void)name; (void)grade; (void)png; (void)size; ++trophy_popups; trophy_popup_last=id;
|
||||
}
|
||||
static char user_dir[256];
|
||||
const char *runtime_file_user_dir(void) { return user_dir; }
|
||||
int runtime_savedata_param(int32_t user, const char *dir, void *param) { (void)user; (void)dir; (void)param; return -1; }
|
||||
int runtime_savedata_icon(int32_t user, const char *dir, char *out, size_t size) { (void)user; (void)dir; (void)out; (void)size; return -1; }
|
||||
|
||||
@@ -77,10 +84,24 @@ int main(void) {
|
||||
assert(playgo_todo(handle,&chunk,1)==0 && playgo_todo(handle,NULL,1)==PLAYGO_BAD_POINTER);
|
||||
assert(playgo_close(handle)==0 && playgo_close(handle)==PLAYGO_BAD_HANDLE && playgo_terminate()==0);
|
||||
|
||||
/* Trophies: nothing unlocked. */
|
||||
/* Trophies: none on a new PC; an unlock is kept in user/trophies.txt, shown once, and a
|
||||
* second unlock of it is refused like on the console. No trophy file: count unknown (0). */
|
||||
snprintf(user_dir,sizeof(user_dir),"out/services-test-user");
|
||||
mkdir(user_dir,0755);
|
||||
char book[300]; snprintf(book,sizeof(book),"%s/trophies.txt",user_dir); remove(book);
|
||||
static char no_file[]="ISSN_TROPHY_FILE="; putenv(no_file);
|
||||
uint32_t flags[4]={~0u,~0u,~0u,~0u}, count=7;
|
||||
assert(trophy_unlock_state(1,1,flags,&count)==0 && !flags[0] && !flags[3] && count==0);
|
||||
assert(trophy_unlock_state(1,1,NULL,&count)==TROPHY_INVALID && trophy_release(0)==TROPHY_INVALID);
|
||||
int32_t platinum=7;
|
||||
assert(trophy_unlock(1,1,5,&platinum)==0 && platinum==-1 && trophy_popups==1 && trophy_popup_last==5);
|
||||
assert(trophy_unlock(1,1,5,&platinum)==TROPHY_ALREADY_UNLOCKED && trophy_popups==1);
|
||||
assert(trophy_unlock(1,1,40,&platinum)==0 && trophy_popups==2);
|
||||
assert(trophy_unlock_state(1,1,flags,&count)==0 && flags[0]==1u<<5 && flags[1]==1u<<8);
|
||||
FILE *kept=fopen(book,"r"); char line[128]; int lines=0;
|
||||
assert(kept);
|
||||
while (fgets(line,sizeof(line),kept)) if (line[0]!='#') ++lines;
|
||||
fclose(kept); assert(lines==2);
|
||||
|
||||
float ratio=0; assert(safe_area(&ratio)==0 && ratio==1.0f && safe_area(NULL)==SYSTEM_PARAMETER);
|
||||
puts("services test: ok");
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""The game's trophies for run.py and the launcher (scripts/trophies.py): the trophy file found
|
||||
beside the dump, its icons, AES as published (FIPS-197, SP 800-38A), and texts opened with a key
|
||||
(a made-up key and trophy file here: no real key is in the port)."""
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import paths # noqa: F401
|
||||
import trophies
|
||||
import trophy_list
|
||||
|
||||
KEY = bytes.fromhex('00112233445566778899AABBCCDDEEFF') # made up for the test
|
||||
COMM = 'NPWR99999_00'
|
||||
|
||||
CONF = ('<?xml version="1.0" encoding="UTF-8"?>\n<trophyconf version="1.1" platform="ps4">'
|
||||
'<npcommid>NPWR99999_00</npcommid>'
|
||||
'<trophy id="000" hidden="no" ttype="P" pid="-1"><name>All</name><detail>Every trophy</detail></trophy>'
|
||||
'<trophy id="001" hidden="no" ttype="B" pid="000"><name>First</name><detail>Do it</detail></trophy>'
|
||||
'<trophy id="002" hidden="yes" ttype="G" pid="000"><name>Second</name><detail>Hidden</detail></trophy>'
|
||||
'<trophy id="003" hidden="no" ttype="S"><name>Extra</name><detail>Not for the platinum</detail></trophy>'
|
||||
'</trophyconf>')
|
||||
PT = ('<?xml version="1.0" encoding="UTF-8"?>\n<trophyconf>'
|
||||
'<trophy id="000"><name>Tudo</name><detail>Todos</detail></trophy>'
|
||||
'<trophy id="001"><name>Primeiro passo</name><detail>Faça</detail></trophy>'
|
||||
'<trophy id="002"><name>Segundo</name><detail>Oculto</detail></trophy>'
|
||||
'<trophy id="003"><name>Extra\tum</name><detail>Fora</detail></trophy></trophyconf>')
|
||||
|
||||
|
||||
def esfm(xml, key=KEY, comm=COMM):
|
||||
data = xml.encode('utf-8')
|
||||
data += b'\0' * (-len(data) % 16)
|
||||
iv = bytes(range(16, 32))
|
||||
return iv + trophies.cbc_encrypt(trophies.file_key(key, comm), iv, data)
|
||||
|
||||
|
||||
def make_trp(path, entries):
|
||||
"""entries: [(name, bytes, flag)] laid out as the PS4 does (big-endian, 64-byte entries)."""
|
||||
data_start = 0x60 + 64 * len(entries)
|
||||
table, blobs, offset = b'', b'', data_start
|
||||
for name, blob, flag in entries:
|
||||
table += name.encode().ljust(32, b'\0') + struct.pack('>QQI', offset, len(blob), flag).ljust(32, b'\0')
|
||||
blobs += blob
|
||||
offset += len(blob)
|
||||
head = struct.pack('>IIQIII', trophies.TRP_MAGIC, 3, data_start + len(blobs), len(entries), 64, 0).ljust(0x60, b'\0')
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(head + table + blobs)
|
||||
|
||||
|
||||
def png(n):
|
||||
return b'\x89PNG\r\n\x1a\n' + bytes([n]) * 8
|
||||
|
||||
|
||||
class AesTest(unittest.TestCase):
|
||||
def test_published_vectors(self):
|
||||
keys = trophies.expand_key(bytes(range(16)))
|
||||
block = bytes.fromhex('00112233445566778899aabbccddeeff')
|
||||
self.assertEqual(trophies.encrypt_block(keys, block).hex(), '69c4e0d86a7b0430d8cdb78070b4c55a')
|
||||
self.assertEqual(trophies.decrypt_block(keys, trophies.encrypt_block(keys, block)), block)
|
||||
key, iv = bytes.fromhex('2b7e151628aed2a6abf7158809cf4f3c'), bytes(range(16))
|
||||
plain = bytes.fromhex('6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e51')
|
||||
cipher = trophies.cbc_encrypt(key, iv, plain)
|
||||
self.assertEqual(cipher.hex(), '7649abac8119b246cee98e9b12e9197d5086cb9b507219ee95db113a917678b2')
|
||||
self.assertEqual(trophies.cbc_decrypt(key, iv, cipher), plain)
|
||||
|
||||
|
||||
class TrophyFileTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
root = Path(self.tmp.name)
|
||||
# A PKG extractor's layout: the game in Image0/, the system files in Sc0/ beside it.
|
||||
self.game, self.user = root / 'dump/Image0', root / 'user'
|
||||
(self.game / 'sce_sys/trophy').mkdir(parents=True) # empty, like the tester's dump
|
||||
self.user.mkdir()
|
||||
self.trp = root / 'dump/Sc0/trophy/trophy00.trp'
|
||||
make_trp(self.trp, [('ICON0.PNG', png(9), 0), ('TROP000.PNG', png(0), 0), ('TROP001.PNG', png(1), 0),
|
||||
('TROP002.PNG', png(2), 0), ('TROP003.PNG', png(3), 0),
|
||||
('TROPCONF.ESFM', esfm(CONF), 3), ('TROP.ESFM', esfm(CONF), 3),
|
||||
('TROP_17.ESFM', esfm(PT), 3)])
|
||||
(root / 'dump/Sc0/npbind.dat').write_bytes(b'\xd2\x94\xa0\x18' + bytes(60) + COMM.encode() + bytes(20))
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_the_file_beside_the_dump_gives_count_and_icons(self):
|
||||
self.assertEqual(trophies.trophy_file(None, self.game), self.trp)
|
||||
entries = trophies.read_entries(self.trp)
|
||||
self.assertEqual(trophies.trophy_ids(entries), [0, 1, 2, 3])
|
||||
self.assertEqual(trophies.icon(self.trp, entries, 2), png(2))
|
||||
self.assertIsNone(trophies.icon(self.trp, entries, 4))
|
||||
self.assertEqual(trophies.npbind_file(self.game).name, 'npbind.dat')
|
||||
with self.assertRaises(ValueError):
|
||||
trophies.read_entries(self.game.parent / 'Sc0/npbind.dat')
|
||||
|
||||
def test_the_file_in_sce_sys_comes_first(self):
|
||||
own = self.game / 'sce_sys' / trophies.TROPHY_FILE
|
||||
own.write_bytes(self.trp.read_bytes())
|
||||
self.assertEqual(trophies.trophy_file(self.game), own)
|
||||
|
||||
def test_texts_open_only_with_the_right_key(self):
|
||||
npbind = trophies.npbind_file(self.game)
|
||||
texts = trophies.read_texts(self.trp, npbind, KEY, '17')
|
||||
self.assertEqual(texts[0], {'grade': 'P', 'base': False, 'hidden': False, 'name': 'Tudo', 'detail': 'Todos'})
|
||||
self.assertEqual(texts[1]['name'], 'Primeiro passo')
|
||||
self.assertTrue(texts[2]['hidden'] and texts[2]['base'] and texts[2]['grade'] == 'G')
|
||||
self.assertFalse(texts[3]['base']) # no pid: not needed for the platinum
|
||||
self.assertEqual(trophies.read_texts(self.trp, npbind, KEY, 1)[1]['name'], 'First') # no TROP_01: TROP.ESFM
|
||||
with self.assertRaisesRegex(ValueError, 'does not open'):
|
||||
trophies.read_texts(self.trp, npbind, bytes(16), 17)
|
||||
with self.assertRaisesRegex(ValueError, 'npbind'):
|
||||
trophies.read_texts(self.trp, None, KEY, 17)
|
||||
|
||||
def test_names_file_for_the_game(self):
|
||||
trophies.write_names(self.user, trophies.read_texts(self.trp, trophies.npbind_file(self.game), KEY, 17))
|
||||
lines = (self.user / trophies.NAMES_FILE).read_text(encoding='utf-8').splitlines()
|
||||
self.assertTrue(lines[0].startswith('#'))
|
||||
self.assertEqual(lines[1:], ['0\tP\t0\tTudo', '1\tB\t1\tPrimeiro passo', '2\tG\t1\tSegundo', '3\tS\t0\tExtra um'])
|
||||
|
||||
def test_key_text_and_its_file(self):
|
||||
self.assertEqual(trophies.parse_key('0x00112233-44556677 8899aabb:ccddeeff'), KEY)
|
||||
self.assertIsNone(trophies.parse_key('0011'))
|
||||
self.assertIsNone(trophies.parse_key('zz' * 16))
|
||||
self.assertIsNone(trophies.read_key(self.user))
|
||||
trophies.save_key(self.user, KEY)
|
||||
self.assertEqual(trophies.read_key(self.user), KEY)
|
||||
trophies.save_key(self.user, None)
|
||||
self.assertFalse((self.user / trophies.KEY_FILE).exists())
|
||||
|
||||
def test_unlocked_list_written_by_the_game(self):
|
||||
(self.user / trophies.UNLOCKED_FILE).write_text(
|
||||
'# comment\n3 2026-11-04 23:50:12\n1 2026-11-05 00:01:00\nbad\n200 x\n', encoding='utf-8')
|
||||
self.assertEqual(trophies.load_unlocked(self.user), {3: '2026-11-04 23:50:12', 1: '2026-11-05 00:01:00'})
|
||||
trp, ids, unlocked = trophies.summary(self.user, self.game)
|
||||
self.assertEqual((trp, ids, len(unlocked)), (self.trp, [0, 1, 2, 3], 2))
|
||||
|
||||
def test_prepare_for_a_start(self):
|
||||
env = {'ISSN_GAME_DIR': str(self.game), 'ISSN_LANGUAGE': '17'}
|
||||
line = trophies.prepare(env, self.user)
|
||||
self.assertEqual(env['ISSN_TROPHY_FILE'], str(self.trp))
|
||||
self.assertIn('4 in', line)
|
||||
self.assertIn('no trophy key', line)
|
||||
self.assertFalse((self.user / trophies.NAMES_FILE).exists())
|
||||
trophies.save_key(self.user, KEY)
|
||||
self.assertIn('names: 4', trophies.prepare(env, self.user))
|
||||
self.assertTrue((self.user / trophies.NAMES_FILE).is_file())
|
||||
trophies.save_key(self.user, bytes(16)) # a wrong key: the old names go
|
||||
self.assertIn('does not open', trophies.prepare(env, self.user))
|
||||
self.assertFalse((self.user / trophies.NAMES_FILE).exists())
|
||||
env = {'ISSN_GAME_DIR': str(self.user), 'ISSN_TROPHY_FILE': 'stale'}
|
||||
self.assertIn('no trophy file', trophies.prepare(env, self.user))
|
||||
self.assertNotIn('ISSN_TROPHY_FILE', env)
|
||||
|
||||
|
||||
class BuiltinListTest(unittest.TestCase):
|
||||
"""The port's own names for inFAMOUS Second Son (no key): 48 trophies, the platinum first."""
|
||||
|
||||
def test_list_shape(self):
|
||||
self.assertEqual(len(trophy_list.TROPHIES), 48)
|
||||
grades = [t[0] for t in trophy_list.TROPHIES]
|
||||
self.assertEqual((grades.count('P'), grades.count('G'), grades.count('S'), grades.count('B')), (1, 2, 11, 34))
|
||||
self.assertEqual(grades[0], 'P')
|
||||
for row in trophy_list.TROPHIES:
|
||||
self.assertEqual(len(row), 7)
|
||||
self.assertTrue(all(text.strip() == text and text for text in row[1:]), row)
|
||||
|
||||
def test_languages(self):
|
||||
self.assertEqual(trophy_list.texts(17)[6]['name'], 'Coragem')
|
||||
self.assertEqual(trophy_list.texts('1')[14]['name'], 'Everybody Out')
|
||||
self.assertEqual(trophy_list.texts(20)[39]['name'], 'Autocarnicería total')
|
||||
self.assertEqual(trophy_list.texts('x')[0]['name'], 'Enjoy Your Powers')
|
||||
texts = trophy_list.texts(17)
|
||||
self.assertFalse(texts[0]['base'])
|
||||
self.assertTrue(all(texts[i]['base'] for i in range(1, 48)))
|
||||
|
||||
def test_used_only_for_this_game(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
game, user = root / 'dump/Image0', root / 'user'
|
||||
(game / 'sce_sys').mkdir(parents=True)
|
||||
user.mkdir()
|
||||
trp = root / 'dump/Sc0/trophy/trophy00.trp'
|
||||
make_trp(trp, [(f'TROP{i:03d}.PNG', png(i), 0) for i in range(48)])
|
||||
env = {'ISSN_GAME_DIR': str(game), 'ISSN_LANGUAGE': '17'}
|
||||
# Another game (no title ID, no NP id): no names.
|
||||
self.assertIn('no names', trophies.prepare(env, user))
|
||||
self.assertFalse((user / trophies.NAMES_FILE).exists())
|
||||
# inFAMOUS Second Son's NP id in npbind.dat beside it: the port's list.
|
||||
(root / 'dump/Sc0/npbind.dat').write_bytes(bytes(64) + trophy_list.COMM_ID.encode() + bytes(20))
|
||||
self.assertIn("names: 48 (the port's list)", trophies.prepare(env, user))
|
||||
lines = (user / trophies.NAMES_FILE).read_text(encoding='utf-8').splitlines()
|
||||
self.assertEqual(lines[1], '0\tP\t0\tAproveite seus poderes')
|
||||
self.assertEqual(lines[7], '6\tB\t1\tCoragem')
|
||||
# A different number of trophies is another game, whatever the ids say.
|
||||
make_trp(trp, [(f'TROP{i:03d}.PNG', png(i), 0) for i in range(10)])
|
||||
self.assertIsNone(trophies.builtin_texts(trp, 17, game))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,93 @@
|
||||
/* Trophies kept on this PC (src/runtime_trophy.c): the game's trophy file (a synthetic one here:
|
||||
* three icons and an encrypted-looking entry), the unlocked list and the platinum. */
|
||||
#undef NDEBUG /* the checks are asserts; release builds define NDEBUG */
|
||||
#include "../src/runtime_trophy.h"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#ifdef _WIN32
|
||||
#include <direct.h>
|
||||
#define make_dir(path) _mkdir(path)
|
||||
#else
|
||||
#define make_dir(path) mkdir(path, 0755)
|
||||
#endif
|
||||
|
||||
static void put32(unsigned char *p, uint32_t v) { p[0]=v>>24; p[1]=v>>16; p[2]=v>>8; p[3]=v; }
|
||||
static void put64(unsigned char *p, uint64_t v) { put32(p,(uint32_t)(v>>32)); put32(p+4,(uint32_t)v); }
|
||||
|
||||
/* A .trp as the PS4 lays it out: big-endian header, 64-byte entries, data after them. */
|
||||
static void write_trp(const char *path) {
|
||||
static const char *names[]={"ICON0.PNG","TROP000.PNG","TROP001.PNG","TROP002.PNG","TROPCONF.ESFM"};
|
||||
enum { COUNT=5, DATA=0x60+COUNT*64, LEN=24 };
|
||||
unsigned char file[DATA+COUNT*LEN]; memset(file,0,sizeof(file));
|
||||
put32(file,0xDCA24D00u); put32(file+4,3); put64(file+8,sizeof(file)); put32(file+16,COUNT); put32(file+20,64);
|
||||
for (int i=0;i<COUNT;++i) {
|
||||
unsigned char *e=file+0x60+i*64, *d=file+DATA+i*LEN;
|
||||
strcpy((char *)e,names[i]);
|
||||
put64(e+32,DATA+i*LEN); put64(e+40,LEN); put32(e+48,i==COUNT-1 ? 3 : 0);
|
||||
if (i<COUNT-1) { memcpy(d,"\x89PNG\r\n\x1a\n",8); d[8]=(unsigned char)i; } else memset(d,0xa5,LEN);
|
||||
}
|
||||
FILE *f=fopen(path,"wb"); assert(f); fwrite(file,1,sizeof(file),f); fclose(f);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
make_dir("out"); make_dir("out/trophy-data");
|
||||
const char *trp="out/trophy-data/trophy00.trp", *list="out/trophy-data/trophies.txt",
|
||||
*names="out/trophy-data/trophy-names.txt";
|
||||
write_trp(trp);
|
||||
remove(list);
|
||||
|
||||
/* The file: three trophies (ids 0..2); icons come out as PNG bytes; others are not icons. */
|
||||
assert(trophy_trp_count(trp)==3);
|
||||
assert(trophy_trp_count("out/trophy-data/missing.trp")==-1 && trophy_trp_count(list)==-1);
|
||||
size_t size=0; unsigned char *png=trophy_trp_icon(trp,2,&size);
|
||||
assert(png && size==24 && !memcmp(png,"\x89PNG",4) && png[8]==3); /* entry 3 is TROP002 */
|
||||
free(png);
|
||||
assert(!trophy_trp_icon(trp,3,&size) && size==0);
|
||||
|
||||
/* Without names: the platinum is id 0 and needs 1 and 2. */
|
||||
TrophyBook book; int platinum=5;
|
||||
trophy_book_init(&book,3);
|
||||
assert(book.platinum==0 && book.base[1] && book.base[2] && !book.base[0]);
|
||||
assert(trophy_book_unlock(&book,0,"2026-11-04 10:00:00",&platinum)==-2 && platinum==-1);
|
||||
assert(trophy_book_unlock(&book,3,"x",&platinum)==-1);
|
||||
assert(trophy_book_unlock(&book,1,"2026-11-04 10:00:00",&platinum)==0 && platinum==-1);
|
||||
assert(trophy_book_unlock(&book,1,"2026-11-04 10:00:01",&platinum)==1);
|
||||
assert(trophy_book_platinum_due(&book)==-1);
|
||||
assert(trophy_book_unlock(&book,2,"2026-11-04 10:05:00",&platinum)==0 && platinum==0);
|
||||
assert(trophy_book_unlocked(&book,0) && trophy_book_count(&book)==3);
|
||||
assert(!strcmp(book.when[0],"2026-11-04 10:05:00") && !strcmp(book.when[1],"2026-11-04 10:00:00"));
|
||||
|
||||
/* Kept in user/trophies.txt and read back with the dates. */
|
||||
assert(trophy_book_save(&book,list)==0);
|
||||
TrophyBook again; trophy_book_init(&again,3);
|
||||
assert(trophy_book_load(&again,list)==3 && !strcmp(again.when[2],"2026-11-04 10:05:00"));
|
||||
assert(!memcmp(again.flags,book.flags,sizeof(book.flags)));
|
||||
assert(trophy_book_save(&again,list)==0); /* replaces the file */
|
||||
|
||||
/* A file from an older run with a platinum due: it is given at start (not by the game). */
|
||||
FILE *f=fopen(list,"w"); fputs("# comment\n1 2026-01-01 00:00:00\n2 2026-01-02 00:00:00\nnot a line\n9 x\n",f); fclose(f);
|
||||
trophy_book_init(&again,3);
|
||||
assert(trophy_book_load(&again,list)==2 && trophy_book_platinum_due(&again)==0);
|
||||
|
||||
/* Names from the player's key: the list decides which trophy is the platinum (here 2) and
|
||||
* which ones count toward it (1 only). */
|
||||
f=fopen(names,"w");
|
||||
fputs("# id\tgrade\tbase\tname\n0\tG\t1\tFirst\n1\tB\t1\tSecond, with ção\n2\tP\t0\tAll of them\n",f);
|
||||
fclose(f);
|
||||
trophy_book_init(&again,3);
|
||||
assert(trophy_book_names(&again,names)==3 && again.platinum==2 && again.grade[1]=='B');
|
||||
assert(!strcmp(again.name[1],"Second, with ção") && !again.base[2] && again.base[0] && again.base[1]);
|
||||
assert(trophy_book_unlock(&again,0,"a",&platinum)==0 && platinum==-1);
|
||||
assert(trophy_book_unlock(&again,1,"b",&platinum)==0 && platinum==2);
|
||||
assert(trophy_book_unlock(&again,2,"c",&platinum)==1);
|
||||
|
||||
/* No trophy file: any id the console allows is kept, nothing is a platinum. */
|
||||
trophy_book_init(&again,-1);
|
||||
assert(again.total==0 && again.platinum==-1);
|
||||
assert(trophy_book_unlock(&again,47,"d",&platinum)==0 && platinum==-1 && trophy_book_unlock(&again,128,"d",&platinum)==-1);
|
||||
puts("trophy test: ok");
|
||||
return 0;
|
||||
}
|
||||
Reference in new issue
Block a user