diff --git a/README.es.md b/README.es.md index 653e8114..7f8e2ac2 100644 --- a/README.es.md +++ b/README.es.md @@ -273,6 +273,13 @@ En un control sin touchpad (Xbox y otros), View abre el menú de pausa (el clic y L1 + View abre el modo foto (el clic izquierdo). En un DualSense el touchpad funciona como en la PS4, y Share/Create funciona como View. +**Ataques de cambio con L1 + cruceta y L1 + Cruz** (desactivado por defecto, página Controls): L1 + +una dirección de la cruceta cambia a esa arma con su ataque de cambio, y el arma que Kratos ya tiene en +la mano repite su ataque; L1 + Cruz hace el ataque de cambio del arma en la mano en lugar de pasar a la +siguiente arma. El ataque de cambio de la misma arma otra vez espera 0,8 segundos después del último +(el de otra arma no espera). La cruceta sola cambia como antes. Los tutoriales de cambio de arma lo dicen así en todos los idiomas +(en copias de los archivos de idioma en la carpeta de datos del port). Sin el launcher, definí `SPARTAN_SWITCH_ATTACKS=1`. + Los **íconos de botones en el juego** (desactivados por defecto) se eligen en la página Controls del launcher, en *In-game button icons*: *Match my controller*, *Xbox*, *Nintendo* o *Keyboard*. Reemplazan los íconos de PlayStation en los tutoriales, la lista de movimientos, las indicaciones de quick-time events y, con los estilos diff --git a/README.md b/README.md index 991444e8..3925ab84 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,14 @@ On a controller without a touchpad (Xbox and others), View opens the pause menu touchpad click) and L1 + View opens photo mode (the left click). On a DualSense the touchpad works as on the PS4, and Share/Create works like View. +**Switch attacks on L1 + d-pad and L1 + Cross** (off by default, Controls page): L1 + a d-pad +direction switches to that weapon with its switch attack, and the weapon in Kratos' hands does its +attack again; L1 + Cross does the switch attack of the weapon in his hands instead of cycling to the +next weapon. The same weapon's switch attack again waits 0.8 seconds after the last one (another +weapon's does not). The d-pad alone switches as before. The Weapon Switching tutorials say so in every language +(on copies of the language files in the port's data folder). Without the launcher, set `SPARTAN_SWITCH_ATTACKS=1`. +How the game picks Kratos' moves, for further changes: [docs/moves.md](docs/moves.md). + **In-game button icons** (off by default) are chosen on the launcher's Controls page, under *In-game button icons*: *Match my controller*, *Xbox*, *Nintendo* or *Keyboard*. They replace the PlayStation icons in the tutorial, move-list and quick-time prompts and, for the controller styles, on the Muses' lyre in the diff --git a/README.pt-BR.md b/README.pt-BR.md index b58ed9f3..8dc14807 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -269,6 +269,13 @@ Em um controle sem touchpad (Xbox e outros), View abre o menu de pausa (o clique touchpad) e L1 + View abre o modo foto (o clique esquerdo). No DualSense o touchpad funciona como no PS4, e Share/Create funciona como View. +**Ataques de troca com L1 + direcional e L1 + Xis** (desligado por padrão, tela Controls): L1 + uma +direção do direcional troca para essa arma com o ataque de troca dela, e a arma que já está na mão de +Kratos repete o ataque; L1 + Xis faz o ataque de troca da arma na mão em vez de passar para a próxima +arma. O ataque de troca da mesma arma de novo espera 0,8 segundo depois do último (o de outra arma +não espera). O direcional sozinho troca como antes. Os tutoriais de troca de arma passam a dizer isso em todos os idiomas +(em cópias dos arquivos de idioma na pasta de dados do port). Sem o launcher, defina `SPARTAN_SWITCH_ATTACKS=1`. + Os **ícones de botões no jogo** (desligados por padrão) são escolhidos na tela Controls do launcher, em *Ícones dos botões no jogo*: *Igual ao meu controle*, *Xbox*, *Nintendo* ou *Teclado*. Eles trocam os ícones PlayStation dos tutoriais, da lista de golpes, dos eventos rápidos e, nos estilos de controle, da lira das Musas diff --git a/docs/moves.md b/docs/moves.md new file mode 100644 index 00000000..9df2a8ae --- /dev/null +++ b/docs/moves.md @@ -0,0 +1,126 @@ +# Kratos' moves: how the game picks the next one + +Notes from the switch attacks (`src/switch_hook.h`, `src/runtime_pad.c`, `scripts/switch_text.py`), +for anyone changing what a button does in combat. Addresses are image offsets in the v01.02 +eboot (CUSA01715); the hooks find their sites by signature. Data offsets are confirmed in game +unless marked *(unverified)*. + +## How this was found + +Two blind attempts (an input macro, then hooks built on guessed branch rules) failed in game. What +worked was a **record-only build**: hooks that copy what the game does into a ring buffer, the pad +thread writing it to `last_run.log` next to the buttons as they change, and one short session of +the moves in question. That build hooked seven places (the weapon check, the branch test's exit, +the input queue, the d-pad select, weapon applied and `SCR_EnableWeapon`) with stubs in the style +of `tools/switch_stubs.s`, in about 200 lines; built again the same way, it is the quickest way to +answer the next question about the move system. Pitfalls: log only +changes (the weapon select and apply functions run every frame), keep the line cap high, and note +that `0xe3910` queues its winner inline, not through `0xec660`. + +## Player stats + +The object the weapon functions take in `rdi` (also reachable from the hero at `[+0x20] -> +0x238`): + +| Offset | Meaning | +| --- | --- | +| `+0x168` u64 | flags: bit 4 a weapon change is pending (set by the d-pad); bit 33 the pending change may be applied now (cleared while L1 is held, which is why a d-pad switch waits for L1's release); any of bits 29–31 makes the branches' weapon tests see no weapon in hand | +| `+0x1c4` u32 | items owned: bit `item - 1` (Cestus is bit 15) | +| `+0x20a` u8 | weapon selected (the d-pad's choice) | +| `+0x20b` u8 | weapon in his hands | +| `+0x20c` u8 | non-zero: the d-pad does not select | + +Weapons are items `0x10` Nemean Cestus, `0x11` Nemesis Whip, `0x12` Blades of Exile, `0x13` Claws of +Hades. + +- `0x6b240` the d-pad weapon select, every frame: an edge of the game's input bits `0x400000` (Down) + selects the Cestus, `0x100000` (Left) the Whip, `0x200000` (Up) the Blades, the fourth (Right) the + Claws, if owned; it plays `SND_SELECT_*`, writes `+0x20a` and sets `+0x168` bit 4. +- `0x6ffe0` applies a pending change (writes `+0x20b`) when bit 33 allows it, then revalidates the + input queue (`0xec5d0`, below). +- `0x6b5b0` selects a weapon for `SCR_EnableWeapon` (`0x141cdb`): what a switch attack's script calls. +- `0x6ff50` the weapon in his hands; `0x6b100` that weapon's level. + +## Move data + +Kratos' moves live in `R_HERO00.WAD`, loaded as one block: every reference inside is a rel32 from +the field that holds it, so the block can sit anywhere. Move names (`MOV_CestusTransition`, ...) +are hashed; scripts refer to moves by hash. + +A **move** starts with a header: `+0x10` rel32 to its branch array (from `+0x10`), `+0x14` the +number of branches. The array holds rel32s (from each slot) to the branches. + +A **branch** (0x38 bytes) is one way out of a move: + +| Offset | Meaning | +| --- | --- | +| `+0x00` rel32 | the move it goes to | +| `+0x04` rel32 | a target some callers match against (0 in the branches below) | +| `+0x08` u32 | flags: `0x20000000` fires once per move; `0x200000`/`0x400000` its priority class in the queue; `0x100000`, `0x1000000`, `0x2000000` extra conditions *(unverified)* | +| `+0x0c` u32 | mask matched against the caller's state *(unverified)* | +| `+0x10` u16 | weapon flags, two bits per weapon (Cestus `0x80`/`0x40`, Whip `0x200`/`0x100`, Blades `0x800`/`0x400`, Claws `0x2000`/`0x1000`): the high bit "any", else the low bit "only with" / "never with"; `0xaaaa` is any weapon | +| `+0x12` u16 | buttons that must be held | +| `+0x14`, `+0x16` half | the window in the move's normalized time when the branch can be taken | +| `+0x2c` i16 | button for the generic button test (-1: none) | +| `+0x2f` u8 | the item it is for (0 none); ownership is checked (`0xeb460`) | +| `+0x30` u8 | input kind: 1–11 pick a mask from the table at `0x2eb940` (`0x40 0x80 0x10 0x20 0x4 0x1 0x200 0x8 0x2 0x400 0x600`); 1 is Cross; 12 and 47 are special | +| `+0x31` u8 | how: 1 just pressed, 2 just released, 3/4 held, 5 not held | +| `+0x33`, `+0x34` i8 | the weapon level range | +| `+0x35` u8 | 3: handled by `0xeb6c0` instead | + +## Picking a branch + +Each frame, `0xe3910` takes a move state (the current move at `[state+0xa8]`) and for every branch +of the current move, in order: + +1. skips it if it already fired (bit per branch at `state+0x24`) or the move's time is outside + `+0x14..+0x16`; +2. `0xeb290`, the input condition: compares the mask of `+0x30` with the input state the caller + gives (`+0x94` current, `+0x98` previous) as `+0x31` says, then tail-calls `0xeaa90` for the + rest; +3. `0xec0e0`, the general conditions: item owned, weapon level and flags (`0xec000` -> `0xeb5b0`), + held buttons, ranges, ... + +Of the branches that pass, the best priority class wins; equal ones are picked at random. The +winner goes into the **input queue** of the move state: a ring of 16-byte slots (branch, time) at +`[state+0xc8]`, size `state+0xd0`, head `+0xd1`, tail `+0xd2` (`0xec660` pushes the same way). The +move system takes it at the next point the current move allows, which is what buffers an input +pressed during an attack. When the weapon changes, `0xec5d0` drops a queued branch whose weapon +flags no longer pass. `0xe9a70` runs the same input check over another list (`0xe9e86`), and +`0xec6c0` is a branch loop that matches a target move. The switch attacks feature answers the +switch branches only for `0xe3910` and never takes them from `0xe9a70`, so neither can cycle. + +## Switch attacks + +The hero data has 42 branches to switch attacks, all alike: input kind 1 (Cross), just pressed +(36) or held (6, in moves that lead to the rarer switch attacks, from 0.15 of the move on), +item the weapon, weapon flags `0xaaaa`, button -1. They sit in the block moves (L1 held is a block +move) and a few others, and list only the weapons that follow in the game's cycle (Blades' block: +Claws; Claws' block: Cestus, then Blades if the Cestus is not owned; ...): the first owned one wins, +which is the vanilla L1 + Cross cycle. Each weapon has two switch attack moves, one most branches +go to and a rarer one (a few moves use it); the move's script selects the weapon with +`SCR_EnableWeapon`. + +The switch attacks feature keeps all of that and changes two things: the input check of those +branches answers the pad's request (L1 + d-pad or L1 + Cross) instead of Cross, and the branch the +move chose is swapped, at the queue, for one of the game's own branches to the requested weapon's +switch attack, of the same kind and timing. The same weapon's switch attack again waits 0.8 seconds +(`SWITCH_REPEAT_US`): in the game every L1 + Cross also changed the weapon, so the attacks' short +recovery could not be repeated. The branches are found once by scanning the hero data +around the first one the game asks about (`switch_scan`). + +## Hooks that call C + +The stubs (`tools/switch_stubs.s`, assembled into the arrays of `src/switch_hook.h`) call C +functions marked `ABI` (`sysv_abi`): the game's code is System V. A stub saves the System V +caller-saved registers that are live at its site (arguments at a function entry; everything but +`rax` mid-function, `xmm0`–`xmm15` included), steps over the red zone, aligns the stack to 16, +calls, restores, and replays the instructions its jump replaced. `tests/test_switch_hook.cpp` runs +each stub from frames laid out like the game's. + +## Text + +`R_LANG_.WAD` (EN, UK, DE, FR, IT, ES, LA, PT, BR, NL, PL, RU, TR, JA, KO, ZH) holds the +language's text in its `MSGS_TXT` entry: UTF-8, CRLF lines, `**` before each text (`**T` +for a tutorial card), button tags like `[L1Button]`. The entry can grow (size field, padding to +16, everything after it moves). Each language's font holds only the characters its text uses, so +new text must stick to those (`scripts/switch_text.py` checks). diff --git a/docs/specs/2026-10-22-rust-runner-parity.md b/docs/specs/2026-10-22-rust-runner-parity.md index c4f1f6f1..2e712c23 100644 --- a/docs/specs/2026-10-22-rust-runner-parity.md +++ b/docs/specs/2026-10-22-rust-runner-parity.md @@ -1,10 +1,11 @@ # Rust runner parity: Python tests → Rust tests -Every test method of the 18 Python files that test the game runner (`tests/test_prepare.py`, +Every test method of the 19 Python files that test the game runner (`tests/test_prepare.py`, `test_link_libc.py`, `test_link_modules.py`, `test_isa_rewrite.py`, `test_run_isa.py`, `test_patches.py`, `test_mods.py`, `test_mod_conflicts.py`, `test_glyphs.py`, `test_content.py`, `test_guest_memory.py`, `test_graphics_options.py`, `test_save_backup.py`, `test_pause_menu.py`, -`test_native_cheats.py`, `test_legacy.py`, `test_discord_presence.py`, `test_game_update.py`; the +`test_native_cheats.py`, `test_legacy.py`, `test_discord_presence.py`, `test_game_update.py`, +`test_switch_text.py`; the Python runner 2.7.0, reference `003e42b`) with the Rust test of `launcher-rust/` that covers its behaviour (spec `2026-10-22-rust-runner-design.md` §7). `tests/test_runner_parity_doc.py` keeps this map honest through `tests/parity_doc.py`, the checker it shares with the launcher map @@ -54,6 +55,9 @@ the second definition is the live one and has the only row (33 definitions, 32 r `test_patches.py::test_unavailable_frame_rate_patch_falls_back_to_the_highest_lower_one` tests `graphics_options.restrict_to_available`; its row names the Rust test of `graphics_options.rs`. `test_game_update.py` has its own rows against `src/runner/game_update.rs`. +`test_switch_text.py` (the switch attacks' tutorial texts, after 2.7.0) has its own rows against +`src/runner/switch_text.rs`; `tests/dump.rs::real_switch_text_wads_match_python` compares the copies of +the dump's language WADs with the Python's. The rows against `src/core/legacy.rs`, `src/core/save_backup.rs` and the discovery, conflict and info tests of `src/core/mods.rs` are ports from the launcher work (sub-project 1) that the runner kept using. ## Counts @@ -78,7 +82,8 @@ The rows against `src/core/legacy.rs`, `src/core/save_backup.rs` and the discove | `tests/test_legacy.py` | 34 | 18 | 6 | 0 | 0 | 10 | 0 | | `tests/test_discord_presence.py` | 26 | 22 | 3 | 0 | 0 | 1 | 0 | | `tests/test_game_update.py` | 6 | 6 | 0 | 0 | 0 | 0 | 0 | -| **Total** | **326** | **289** | **21** | **4** | **0** | **12** | **0** | +| `tests/test_switch_text.py` | 4 | 4 | 0 | 0 | 0 | 0 | 0 | +| **Total** | **330** | **293** | **21** | **4** | **0** | **12** | **0** | ## Map @@ -412,6 +417,10 @@ The rows against `src/core/legacy.rs`, `src/core/save_backup.rs` and the discove | `tests/test_game_update.py::GameUpdateTests::test_dumps_are_not_modified` | `src/runner/game_update.rs::tests::dumps_are_not_modified` | ported | | `tests/test_game_update.py::GameUpdateTests::test_a_network_share_without_junctions_mirrors_the_tree` | `src/runner/game_update.rs::tests::a_network_share_without_junctions_mirrors_the_tree` | ported | | `tests/test_game_update.py::GameUpdateTests::test_different_titles_and_nested_folders_are_refused` | `src/runner/game_update.rs::tests::different_titles_and_nested_folders_are_refused` | ported | +| `tests/test_switch_text.py::SwitchTextTest::test_cards_end_with_the_new_lines_and_what_follows_moves` | `src/runner/switch_text.rs::cards_end_with_the_new_lines_and_what_follows_moves` | ported | +| `tests/test_switch_text.py::SwitchTextTest::test_any_wording_of_the_old_sentence` | `src/runner/switch_text.rs::any_wording_of_the_old_sentence` | ported | +| `tests/test_switch_text.py::SwitchTextTest::test_a_language_whose_font_lacks_a_new_character_keeps_its_text` | `src/runner/switch_text.rs::a_language_whose_font_lacks_a_new_character_keeps_its_text` | ported | +| `tests/test_switch_text.py::SwitchTextTest::test_build_mounts_copies_and_never_writes_the_game` | `src/runner/switch_text.rs::build_mounts_copies_and_never_writes_the_game` | ported | ## Known deviations from the Python diff --git a/gpu/CMakeLists.txt b/gpu/CMakeLists.txt index 564c8508..bd77ee08 100644 --- a/gpu/CMakeLists.txt +++ b/gpu/CMakeLists.txt @@ -434,6 +434,8 @@ target_include_directories(actor-hook-test PRIVATE shim) target_compile_options(actor-hook-test PRIVATE -UNDEBUG -Wall -Wextra -Werror) add_executable(qte-hook-test EXCLUDE_FROM_ALL ../tests/test_qte_hook.cpp) target_compile_options(qte-hook-test PRIVATE -UNDEBUG -Wall -Wextra -Werror) +add_executable(switch-hook-test EXCLUDE_FROM_ALL ../tests/test_switch_hook.cpp) +target_compile_options(switch-hook-test PRIVATE -UNDEBUG -Wall -Wextra -Werror -mno-red-zone) add_executable(menu-hook-test EXCLUDE_FROM_ALL ../tests/test_menu_hook.cpp) target_compile_options(menu-hook-test PRIVATE -UNDEBUG -Wall -Wextra -Werror) add_executable(graphics-hook-test EXCLUDE_FROM_ALL ../tests/test_graphics_hook.cpp) diff --git a/launcher-rust/fixtures/collected/settings.json b/launcher-rust/fixtures/collected/settings.json index 68b86dd5..e3b283c9 100644 --- a/launcher-rust/fixtures/collected/settings.json +++ b/launcher-rust/fixtures/collected/settings.json @@ -53,5 +53,6 @@ "rage_one_input": true, "stick_prompts": "keyboard", "controls_layout": 4, + "switch_attacks": false, "glyph_style": "off" } \ No newline at end of file diff --git a/launcher-rust/fixtures/defaults/settings.json b/launcher-rust/fixtures/defaults/settings.json index b2e435d5..81b902ff 100644 --- a/launcher-rust/fixtures/defaults/settings.json +++ b/launcher-rust/fixtures/defaults/settings.json @@ -41,5 +41,6 @@ "rage_one_input": true, "stick_prompts": "keyboard", "controls_layout": 4, + "switch_attacks": false, "glyph_style": "off" } \ No newline at end of file diff --git a/launcher-rust/fixtures/edited/settings.json b/launcher-rust/fixtures/edited/settings.json index 17c79bca..a5388697 100644 --- a/launcher-rust/fixtures/edited/settings.json +++ b/launcher-rust/fixtures/edited/settings.json @@ -54,5 +54,6 @@ "rage_one_input": true, "stick_prompts": "keyboard", "controls_layout": 4, + "switch_attacks": false, "glyph_style": "off" } \ No newline at end of file diff --git a/launcher-rust/fixtures/legacy_controls/settings.json b/launcher-rust/fixtures/legacy_controls/settings.json index b44ae918..cc17e507 100644 --- a/launcher-rust/fixtures/legacy_controls/settings.json +++ b/launcher-rust/fixtures/legacy_controls/settings.json @@ -46,5 +46,6 @@ "rage_one_input": false, "stick_prompts": "keyboard", "controls_layout": 4, + "switch_attacks": false, "glyph_style": "off" } \ No newline at end of file diff --git a/launcher-rust/snapshots/controls-1280x800-ar.png b/launcher-rust/snapshots/controls-1280x800-ar.png index a772a7d0..7cb5e9fe 100644 Binary files a/launcher-rust/snapshots/controls-1280x800-ar.png and b/launcher-rust/snapshots/controls-1280x800-ar.png differ diff --git a/launcher-rust/snapshots/controls-1280x800-en.png b/launcher-rust/snapshots/controls-1280x800-en.png index d0beb2ea..7f5ffb1b 100644 Binary files a/launcher-rust/snapshots/controls-1280x800-en.png and b/launcher-rust/snapshots/controls-1280x800-en.png differ diff --git a/launcher-rust/snapshots/controls-1280x800-pt.png b/launcher-rust/snapshots/controls-1280x800-pt.png index 55078f53..e6b3873a 100644 Binary files a/launcher-rust/snapshots/controls-1280x800-pt.png and b/launcher-rust/snapshots/controls-1280x800-pt.png differ diff --git a/launcher-rust/snapshots/controls-1920x1080-ar.png b/launcher-rust/snapshots/controls-1920x1080-ar.png index 3a62c76f..7e2ff1f2 100644 Binary files a/launcher-rust/snapshots/controls-1920x1080-ar.png and b/launcher-rust/snapshots/controls-1920x1080-ar.png differ diff --git a/launcher-rust/snapshots/controls-1920x1080-en.png b/launcher-rust/snapshots/controls-1920x1080-en.png index 67b45707..35a21ca5 100644 Binary files a/launcher-rust/snapshots/controls-1920x1080-en.png and b/launcher-rust/snapshots/controls-1920x1080-en.png differ diff --git a/launcher-rust/snapshots/controls-1920x1080-pt.png b/launcher-rust/snapshots/controls-1920x1080-pt.png index 25dbd88e..d066cb80 100644 Binary files a/launcher-rust/snapshots/controls-1920x1080-pt.png and b/launcher-rust/snapshots/controls-1920x1080-pt.png differ diff --git a/launcher-rust/src/core/catalog.rs b/launcher-rust/src/core/catalog.rs index a293a3da..1e2de5c2 100644 --- a/launcher-rust/src/core/catalog.rs +++ b/launcher-rust/src/core/catalog.rs @@ -308,6 +308,13 @@ pub static SETTINGS: &[Setting] = &[ env("SPARTAN_STICK_PROMPTS", ""), ), app("controls_layout", Kind::Int, Int(4), None), + // Off unless set: the runner patches the tutorials only for "1" (`run.py` `switch_text_mounts`). + app( + "switch_attacks", + Flag, + Bool(false), + env("SPARTAN_SWITCH_ATTACKS", ""), + ), app( "glyph_style", Pick(GLYPH_STYLES), @@ -530,6 +537,7 @@ mod tests { assert_eq!(run_default("SPARTAN_MODS_ENABLED"), Some("1")); assert_eq!(run_default("SPARTAN_STREAM_MEMORY"), Some("cached")); assert_eq!(run_default("SPARTAN_GLYPH_STYLE"), Some("off")); + assert_eq!(run_default("SPARTAN_SWITCH_ATTACKS"), Some("")); assert_eq!(run_default("SPARTAN_NOT_A_SETTING"), None); } diff --git a/launcher-rust/src/core/env.rs b/launcher-rust/src/core/env.rs index 3a9cfc5e..bc4dff93 100644 --- a/launcher-rust/src/core/env.rs +++ b/launcher-rust/src/core/env.rs @@ -268,6 +268,9 @@ pub fn environment( if !value.is_empty() { env.insert("SPARTAN_PAD_MAP".into(), value); } + // The saved choice wins over an inherited variable, as in the Python. + env.shift_remove(var("switch_attacks")); + put_if_on(&mut env, settings, "switch_attacks"); // A saved value that is not a choice, or is the runner's own default, is left out. let put_choice = |env: &mut IndexMap, key: &str| { let name = var(key); @@ -479,6 +482,23 @@ mod tests { assert_eq!(env["SPARTAN_STREAM_MEMORY"], "host"); } + #[test] + fn switch_attacks_follow_the_saved_choice() { + // Off by default: no variable, also over an inherited one. + let (settings, paths, _dir) = setup(json!({})); + let inherited = base(&[("SPARTAN_SWITCH_ATTACKS", "1")]); + let env = game_environment(&settings, &paths, "en", &inherited, true); + assert!(!env.contains_key("SPARTAN_SWITCH_ATTACKS")); + let (on, paths, _dir) = setup(json!({"switch_attacks": true})); + let env = game_environment(&on, &paths, "en", &IndexMap::new(), true); + assert_eq!(env["SPARTAN_SWITCH_ATTACKS"], "1"); + // In the Python's order: after the key and gamepad maps. + let keys: Vec<&str> = env.keys().map(String::as_str).collect(); + let at = keys.iter().position(|key| *key == "SPARTAN_SWITCH_ATTACKS"); + let key_map = keys.iter().position(|key| *key == "SPARTAN_KEY_MAP"); + assert!(key_map.is_some() && key_map < at, "{keys:?}"); + } + #[test] fn trophy_texts_file_has_35_rows_and_the_labels() { let dir = tempfile::tempdir().unwrap(); diff --git a/launcher-rust/src/core/i18n.rs b/launcher-rust/src/core/i18n.rs index a43e46ee..0686057f 100644 --- a/launcher-rust/src/core/i18n.rs +++ b/launcher-rust/src/core/i18n.rs @@ -87,6 +87,21 @@ fn lookup(lang: &str, en: &str) -> Option { /// Rust-only UI strings absent from the Python tables, as (English, [(language, text)]). Other /// languages fall back to English: a translator gap to fill. static UI_EXTRA: &[(&str, &[(&str, &str)])] = &[ + // The switch attacks of the Controls page, in the Python's Portuguese and Spanish. + ( + "Switch attacks on L1 + d-pad and L1 + Cross", + &[ + ("pt", "Ataques de troca com L1 + direcional e L1 + X"), + ("es", "Ataques de cambio con L1 + cruceta y L1 + X"), + ], + ), + ( + "L1 + a d-pad direction switches to that weapon with its switch attack (the weapon in Kratos' hands does its attack again). L1 + Cross does the switch attack of the weapon in his hands instead of cycling to the next weapon. The d-pad alone switches as before. Applied when the game starts.", + &[ + ("pt", "L1 + uma direção do direcional troca para essa arma com o ataque de troca dela (a arma que já está na mão de Kratos repete o ataque). L1 + X faz o ataque de troca da arma na mão em vez de passar para a próxima arma. O direcional sozinho troca como antes. Aplicado ao iniciar o jogo."), + ("es", "L1 + una dirección de la cruceta cambia a esa arma con su ataque de cambio (el arma que Kratos ya tiene en la mano repite su ataque). L1 + X hace el ataque de cambio del arma en la mano en lugar de pasar a la siguiente arma. La cruceta sola cambia como antes. Se aplica al iniciar el juego."), + ], + ), ( "Graphics card not checked: the game picks one.", &[("pt", "Placa de vídeo não verificada: o jogo escolhe uma.")], diff --git a/launcher-rust/src/core/settings.rs b/launcher-rust/src/core/settings.rs index 273d8038..0784e419 100644 --- a/launcher-rust/src/core/settings.rs +++ b/launcher-rust/src/core/settings.rs @@ -150,9 +150,13 @@ mod tests { #[test] fn defaults_are_in_python_order() { let defaults = app_defaults(&no_env); - assert_eq!(defaults.len(), 43); + assert_eq!(defaults.len(), 44); assert_eq!(defaults.keys().next().unwrap(), "ui_language"); assert_eq!(defaults.keys().next_back().unwrap(), "glyph_style"); + // The switch attacks sit right before the glyphs, as in the Python's APP_DEFAULTS. + let at = defaults.keys().position(|key| key == "switch_attacks"); + assert_eq!(at, Some(42)); + assert_eq!(defaults["switch_attacks"], json!(false)); // The 2.7.0 shader cache switches sit between the screen effects and the warm-up. let keys: Vec<&str> = defaults.keys().map(String::as_str).collect(); let at = keys diff --git a/launcher-rust/src/runner/mod.rs b/launcher-rust/src/runner/mod.rs index 276f6f17..b9244c1e 100644 --- a/launcher-rust/src/runner/mod.rs +++ b/launcher-rust/src/runner/mod.rs @@ -15,6 +15,7 @@ pub mod plan; pub mod prepare; pub mod probe_image; pub mod sce; +pub mod switch_text; pub mod window_icon; /// The synthetic probe inputs the runner's tests build (`tests/test_probe.py::package` and more). @@ -756,9 +757,10 @@ impl<'a> Runner<'a> { .map_err(|error| self.fail(&format!("Patches: {error}"))) } - /// Step 13: the probe arguments of the glyph copies and of the pause menu's copy; sets - /// `SPARTAN_GLYPHS` (the in-game menu names the buttons the same way). Failures only reach the - /// output: the game then keeps its own icons, or starts without the row. + /// Step 13: the probe arguments of the glyph copies, of the pause menu's copy and of the + /// switch attacks' tutorial texts; sets `SPARTAN_GLYPHS` (the in-game menu names the buttons the + /// same way). Failures only reach the output: the game then keeps its own icons, starts without + /// the row, or keeps its tutorial texts. fn mounts(&mut self, plan: &RunPlan, probe: &str, game: &Path) -> Vec { let (port, out) = (&plan.port, &plan.out); let assets = glyphs::Assets::in_port(port); @@ -792,7 +794,8 @@ impl<'a> Runner<'a> { &lookup, &mut |line| say(log, &line), ); - [glyph_args, menu_args].concat() + let text_args = switch_text_mounts(env, game, out, &mut |line| say(log, &line)); + [glyph_args, menu_args, text_args].concat() } /// Starts the probe with `command` and waits for it, with the Discord presence while it runs: @@ -1007,6 +1010,41 @@ pub fn pause_menu_args( } } +/// Probe arguments for the Weapon Switching tutorials that match the switch attacks (`run.py` +/// `switch_text_mounts`): patched copies of the language WADs (`switch_text::build`), only with +/// `SPARTAN_SWITCH_ATTACKS=1`; without it old copies are removed. The game folder is never written; +/// what fails only reaches the Log, and the tutorials then keep their own text. +pub fn switch_text_mounts( + env: &IndexMap, + game: &Path, + out: &Path, + log: &mut dyn FnMut(String), +) -> Vec { + if env.get("SPARTAN_SWITCH_ATTACKS").map(String::as_str) != Some("1") { + let _ = std::fs::remove_dir_all(out.join("switch_text")); + return Vec::new(); + } + let mounts = match switch_text::build(game, out, log) { + Ok(mounts) => mounts, + Err(error) => { + log(format!( + "Switch attacks: {error}; the tutorials keep their own text" + )); + return Vec::new(); + } + }; + if !mounts.is_empty() { + log(format!( + "Switch attacks: tutorial text on copies in {}; game files untouched", + out.join("switch_text").display() + )); + } + mounts + .iter() + .flat_map(|(guest, path)| ["--mount".to_string(), format!("{guest}={}", path.display())]) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -1323,4 +1361,82 @@ mod tests { "{log:?}" ); } + + // -- switch_text_mounts (run.py): the tutorials' copies, only with SPARTAN_SWITCH_ATTACKS=1 ---- + + /// A game folder with the synthetic English language WAD, and an `out` holding a copy left + /// from an earlier run. + fn text_run() -> (tempfile::TempDir, PathBuf, PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let (game, out) = (temp.path().join("game"), temp.path().join("out")); + let folder = game.join(switch_text::FOLDER); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(folder.join("R_LANG_EN.WAD"), switch_text::tests::wad()).unwrap(); + std::fs::create_dir_all(out.join("switch_text")).unwrap(); + std::fs::write(out.join("switch_text/R_LANG_EN.WAD"), b"old").unwrap(); + (temp, game, out) + } + + /// What `switch_text_mounts` returned and the Log lines. + fn text_mounts(env: &[(&str, &str)], game: &Path, out: &Path) -> (Vec, Vec) { + let mut log = Vec::new(); + let args = switch_text_mounts(&env_map(env), game, out, &mut |line| log.push(line)); + (args, log) + } + + #[test] + fn switch_text_off_mounts_nothing_and_removes_old_copies() { + for env in [vec![], vec![("SPARTAN_SWITCH_ATTACKS", "0")]] { + let (_temp, game, out) = text_run(); + let (args, log) = text_mounts(&env, &game, &out); + assert!(args.is_empty() && log.is_empty(), "{args:?} {log:?}"); + assert!(!out.join("switch_text").exists()); + } + } + + #[test] + fn switch_text_on_mounts_the_copies() { + let (_temp, game, out) = text_run(); + let (args, log) = text_mounts(&[("SPARTAN_SWITCH_ATTACKS", "1")], &game, &out); + let copy = out.join("switch_text").join("R_LANG_EN.WAD"); + assert_eq!( + args, + [ + "--mount".to_string(), + format!("/app0/EXEC/WAD/PS4_LE/R_LANG_EN.WAD={}", copy.display()), + ] + ); + assert_eq!( + log, + [format!( + "Switch attacks: tutorial text on copies in {}; game files untouched", + out.join("switch_text").display() + )] + ); + let mut expected = switch_text::tests::wad(); + assert_eq!(switch_text::patch(&mut expected, &switch_text::ENGLISH), 2); + assert_eq!(std::fs::read(©).unwrap(), expected); + assert_eq!( + std::fs::read(game.join(switch_text::FOLDER).join("R_LANG_EN.WAD")).unwrap(), + switch_text::tests::wad() + ); + } + + #[test] + fn switch_text_failures_start_the_game_with_its_own_text() { + let (_temp, game, out) = text_run(); + // out/ is a file, so out/switch_text cannot be made. + let file = out.join("switch_text").join("R_LANG_EN.WAD"); + let (args, log) = text_mounts(&[("SPARTAN_SWITCH_ATTACKS", "1")], &game, &file); + assert!(args.is_empty()); + assert!( + log.len() == 1 + && log[0].starts_with("Switch attacks: ") + && log[0].ends_with("; the tutorials keep their own text"), + "{log:?}" + ); + // No language of the game in the folder: nothing to mount, nothing said. + let (args, log) = text_mounts(&[("SPARTAN_SWITCH_ATTACKS", "1")], &out, &out); + assert!(args.is_empty() && log.is_empty(), "{args:?} {log:?}"); + } } diff --git a/launcher-rust/src/runner/switch_text.rs b/launcher-rust/src/runner/switch_text.rs new file mode 100644 index 00000000..449b8cc6 --- /dev/null +++ b/launcher-rust/src/runner/switch_text.rs @@ -0,0 +1,475 @@ +//! The Weapon Switching tutorials for the switch attacks (`SPARTAN_SWITCH_ATTACKS=1`, +//! `src/switch_hook.h`; Python `scripts/switch_text.py`). +//! +//! With the option, L1 + Cross no longer cycles through the weapons: it repeats the switch attack +//! of the weapon in Kratos' hands, and L1 with a d-pad direction switches to that weapon with its +//! switch attack. The tutorial cards (texts 190, 283 and 286: a title, "Press for +//! " lines, then the L1 + Cross sentence) say so instead of the old sentence. [`build`] +//! writes patched copies of the language WADs and returns the guest paths the runner mounts them +//! on; the game folder is never written. +//! +//! The text is the `MSGS_TXT` entry of each `R_LANG_.WAD`, UTF-8: CRLF lines, `**` (with +//! a T for a tutorial) before each text. In each card the lines from the first one with +//! `[L1Button]` to the end of the card are replaced, so the patch does not depend on how a language +//! worded or wrapped the old sentence. The entry grows (its size field, its padding to 16, and +//! everything after it moves). A language is left as it is when its text lacks one of the new +//! characters: each language's font only has the characters its own text uses. + +use std::collections::HashSet; +use std::io; +use std::path::{Path, PathBuf}; + +use indexmap::IndexMap; +use serde_json::{json, Value}; + +use crate::core::fs::atomic_write; +use crate::core::python::python_json; +use crate::runner::glyphs::entries; + +/// Of the text: a change rewrites the cached copies. +pub const VERSION: u32 = 2; +pub const FOLDER: &str = "EXEC/WAD/PS4_LE"; +/// The Weapon Switching cards. +pub const CARDS: [u32; 3] = [190, 283, 286]; + +pub const ENGLISH: [&str; 2] = [ + "Hold [L1Button] to attack when switching weapons.", + "Use [L1Button] and [XButton] to repeat switch attacks.", +]; +const SPANISH: [&str; 2] = [ + "Mantén [L1Button] para atacar al cambiar de arma.", + "Usa [L1Button] y [XButton] para repetir el ataque de cambio.", +]; + +/// `R_LANG_.WAD` -> the lines that end the cards, in the Python's order. +pub const TEXT: [(&str, [&str; 2]); 16] = [ + ("EN", ENGLISH), + ("UK", ENGLISH), + ( + "DE", + [ + "Halte [L1Button] gedrückt, um beim Waffenwechsel anzugreifen.", + "Drücke [L1Button] und [XButton], um Wechselangriffe zu wiederholen.", + ], + ), + ( + "FR", + [ + "Maintenez [L1Button] pour attaquer en changeant d'arme.", + "Utilisez [L1Button] et [XButton] pour répéter l'attaque de changement.", + ], + ), + ( + "IT", + [ + "Tieni premuto [L1Button] per attaccare mentre cambi arma.", + "Usa [L1Button] e [XButton] per ripetere l'attacco di cambio.", + ], + ), + ("ES", SPANISH), + ("LA", SPANISH), + ( + "PT", + [ + "Mantém [L1Button] premido para atacar ao trocar de arma.", + "Usa [L1Button] e [XButton] para repetir o ataque de troca.", + ], + ), + ( + "BR", + [ + "Segure [L1Button] para atacar ao trocar de arma.", + "Use [L1Button] e [XButton] para repetir o ataque de troca.", + ], + ), + ( + "NL", + [ + "Houd [L1Button] vast om aan te vallen bij een wapenwissel.", + "Gebruik [L1Button] en [XButton] om wisselaanvallen te herhalen.", + ], + ), + ( + "PL", + [ + "Przytrzymaj [L1Button], aby zaatakować przy zmianie broni.", + "Użyj [L1Button] i [XButton], aby powtórzyć atak zmiany broni.", + ], + ), + ( + "RU", + [ + "Удерживайте [L1Button], чтобы атаковать при смене оружия.", + "Нажмите [L1Button] и [XButton], чтобы повторить атаку смены оружия.", + ], + ), + ( + "TR", + [ + "Silah değiştirirken saldırmak için [L1Button] tuşunu basılı tut.", + "Değiştirme saldırısını tekrarlamak için [L1Button] ve [XButton] kullan.", + ], + ), + ( + "JA", + [ + "[L1Button]を押しながら武器を切り替えると攻撃します。", + "[L1Button]と[XButton]で切り替え攻撃を繰り返します。", + ], + ), + ( + "KO", + [ + "[L1Button] 버튼을 누른 채 무기를 바꾸면 공격합니다.", + "[L1Button] 버튼과 [XButton] 버튼으로 전환 공격을 반복합니다.", + ], + ), + ( + "ZH", + [ + "按住[L1Button]切換武器時會發動攻擊。", + "使用[L1Button]和[XButton]重複切換攻擊。", + ], + ), +]; + +/// The lines of the language `code`, if the game has it. +pub fn lines_of(code: &str) -> Option<[&'static str; 2]> { + TEXT.iter() + .find(|(name, _)| *name == code) + .map(|(_, lines)| *lines) +} + +/// A card mark of the text (Python `CARD`, `\r\n\*(\d+)\*T?\r\n`): its start, its end and its id +/// (`None` for an id too long to be one of [`CARDS`]). +struct Mark { + start: usize, + end: usize, + id: Option, +} + +/// The mark that matches at byte `at` of `text`, if one does. +fn mark_at(text: &str, at: usize) -> Option { + let rest = text[at..].strip_prefix("\r\n*")?; + let digits = rest.bytes().take_while(u8::is_ascii_digit).count(); + if digits == 0 { + return None; + } + let after = rest[digits..].strip_prefix('*')?; + let after = after.strip_prefix('T').unwrap_or(after); + let after = after.strip_prefix("\r\n")?; + Some(Mark { + start: at, + end: text.len() - after.len(), + id: rest[..digits].parse().ok(), + }) +} + +/// Every mark of `text`, as `re.finditer` finds them: left to right, none overlapping. +fn marks(text: &str) -> Vec { + let mut found = Vec::new(); + let mut at = 0; + while let Some(next) = text[at..].find("\r\n*") { + let start = at + next; + match mark_at(text, start) { + Some(mark) => { + at = mark.end; + found.push(mark); + } + None => at = start + 1, + } + } + found +} + +/// The `MSGS_TXT` text with the cards ending in `lines`, and how many cards changed; (text, 0) +/// when no card has an `[L1Button]` line, or the language's text lacks a character of `lines`. +pub fn rewrite(text: &str, lines: &[&str]) -> (String, usize) { + let used: HashSet = text.chars().collect(); + if lines + .iter() + .flat_map(|line| line.chars()) + .any(|c| !used.contains(&c)) + { + return (text.to_string(), 0); + } + let marks = marks(text); + let mut out = String::with_capacity(text.len() + 256); + let (mut at, mut count) = (0, 0); + for (index, mark) in marks.iter().enumerate() { + if !mark.id.is_some_and(|id| CARDS.contains(&id)) { + continue; + } + let end = marks.get(index + 1).map_or(text.len(), |next| next.start); + let body: Vec<&str> = text[mark.end..end].split("\r\n").collect(); + let Some(first) = body.iter().position(|line| line.contains("[L1Button]")) else { + continue; + }; + out.push_str(&text[at..mark.end]); + let kept = body[..first].iter().chain(lines.iter()); + out.push_str(&kept.copied().collect::>().join("\r\n")); + at = end; + count += 1; + } + out.push_str(&text[at..]); + (out, count) +} + +/// Ends the Weapon Switching cards of the WAD in `data` with `lines`, growing its `MSGS_TXT` +/// entry; returns how many cards changed. +pub fn patch(data: &mut Vec, lines: &[&str]) -> usize { + let Some(entry) = entries(data).into_iter().find(|e| e.name == "MSGS_TXT") else { + return 0; + }; + let (start, size) = (entry.offset + 0x40, entry.size); + let end = start + size; + let Ok(text) = std::str::from_utf8(&data[start..end]) else { + return 0; + }; + let (text, count) = rewrite(text, lines); + if count > 0 { + let raw = text.into_bytes(); + let padded = (end + (16 - size % 16) % 16).min(data.len()); + let fill = (16 - raw.len() % 16) % 16; + let length = raw.len(); + data.splice( + start..padded, + raw.into_iter().chain(std::iter::repeat_n(0, fill)), + ); + // The size field is a u32: a text past 4 GB is no WAD's. + let length = u32::try_from(length).unwrap_or(u32::MAX); + data[entry.offset + 4..entry.offset + 8].copy_from_slice(&length.to_le_bytes()); + } + count +} + +fn mtime_ns(meta: &std::fs::Metadata) -> io::Result { + let modified = meta.modified()?; + match modified.duration_since(std::time::UNIX_EPOCH) { + Ok(after) => i64::try_from(after.as_nanos()), + Err(before) => i64::try_from(before.duration().as_nanos()).map(|nanos| -nanos), + } + .map_err(io::Error::other) +} + +/// `{guest path: patched copy}` for the runner to mount. The copies in `out/switch_text` are kept +/// until the game's language WADs change; `stamp.json` records them as the Python's `json.dumps` +/// does, so a copy either launcher made is used by the other. +pub fn build( + game: &Path, + out: &Path, + log: &mut dyn FnMut(String), +) -> io::Result> { + let target = out.join("switch_text"); + let sources: Vec<(String, &str, PathBuf)> = TEXT + .iter() + .map(|(code, _)| { + let file = format!("R_LANG_{code}.WAD"); + ( + format!("{FOLDER}/{file}"), + *code, + game.join(FOLDER).join(&file), + ) + }) + .filter(|(_, _, path)| path.is_file()) + .collect(); + let mut listed = Vec::new(); + for (name, _, path) in &sources { + let meta = std::fs::metadata(path)?; + listed.push(json!([name, meta.len(), mtime_ns(&meta)?])); + } + let stamp = python_json(&json!([VERSION, Value::Array(listed)]))?; + let stamp_file = target.join("stamp.json"); + let file_name = |name: &str| name.rsplit('/').next().unwrap_or(name).to_string(); + let mut made: Vec = std::fs::read_dir(&target) + .map(|dir| { + dir.filter_map(Result::ok) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(".WAD")) + .collect() + }) + .unwrap_or_default(); + if std::fs::read_to_string(&stamp_file).ok().as_deref() != Some(stamp.as_str()) { + let _ = std::fs::remove_dir_all(&target); + // As the Python's mkdir(parents=True): a folder that could not be removed is an error. + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::create_dir(&target)?; + made.clear(); + for (name, code, path) in &sources { + let mut data = std::fs::read(path)?; + let lines = lines_of(code).unwrap_or(ENGLISH); + let file = file_name(name); + if patch(&mut data, &lines) > 0 { + atomic_write(&target.join(&file), &data)?; + made.push(file); + } else { + log(format!("Switch attacks: {file} keeps its tutorial text")); + } + } + atomic_write(&stamp_file, stamp.as_bytes())?; // last: an interrupted build is redone + } + Ok(sources + .iter() + .filter(|(name, _, _)| made.contains(&file_name(name))) + .map(|(name, _, _)| (format!("/app0/{name}"), target.join(file_name(name)))) + .collect()) +} + +#[cfg(test)] +pub(crate) mod tests { + //! `tests/test_switch_text.py` on a synthetic WAD; the runner's step is in `runner/mod.rs`. + use super::*; + + fn entry(name: &str, payload: &[u8], tag: u16) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&tag.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&u32::try_from(payload.len()).unwrap().to_le_bytes()); + let mut field = name.as_bytes().to_vec(); + field.resize(56, 0); + out.extend_from_slice(&field); + out.extend_from_slice(payload); + out.resize(out.len() + (16 - payload.len() % 16) % 16, 0); + out + } + + pub(crate) const OLD: &str = + "Use [L1Button] and [XButton] to rapidly\r\nswitch weapons while attacking."; + + /// Every letter of the new English lines is in it. + pub(crate) fn text() -> String { + format!( + "5384 7373\r\n*190*T\r\n[*1]Weapon Switching\r\nPress [RightButton] for Claws.\r\n{OLD}\ + \r\n*191*\r\nPress [L1Button] to block.\r\n*283*T\r\n[*1]Weapon Switching\r\n{OLD}\ + \r\n*284*\r\nOnyx\r\n*286*T\r\n[*1]Weapon Switching\r\nPress [LeftButton] for Whip.\r\n\ + *5000*\r\nHold to repeat attacks when using weapons.\r\n\0" + ) + } + + pub(crate) fn wad_of(text: &str) -> Vec { + [ + entry("WAD_R_Lang", &[0; 8], 0x14), + entry("WAD_R_Lang", &[], 0x12), + entry("MSGS_TXT", text.as_bytes(), 7), + entry("TXR_GoWIII_en", &[0xee; 100], 1), + entry("SMF_0", &[b"SMF2".as_slice(), &[0x11; 20]].concat(), 7), + ] + .concat() + } + + pub(crate) fn wad() -> Vec { + wad_of(&text()) + } + + #[test] + fn cards_end_with_the_new_lines_and_what_follows_moves() { + let mut data = wad(); + assert_eq!(patch(&mut data, &ENGLISH), 2); // 286 has no L1 line here + let found: IndexMap = entries(&data) + .into_iter() + .map(|e| (e.name, (e.offset, e.size))) + .collect(); + assert_eq!( + found.keys().collect::>(), + ["WAD_R_Lang", "MSGS_TXT", "TXR_GoWIII_en", "SMF_0"] + ); + let (offset, size) = found["MSGS_TXT"]; + let text = std::str::from_utf8(&data[offset + 0x40..offset + 0x40 + size]).unwrap(); + assert_eq!(text, self::text().replace(OLD, &ENGLISH.join("\r\n"))); + assert!(text.contains("*191*\r\nPress [L1Button] to block.")); // not a Weapon Switching card + assert_eq!( + text.matches("\r\n").count(), + self::text().matches("\r\n").count() + ); // the same lines + assert_eq!(found["TXR_GoWIII_en"].0 % 16, 0); + let tail = found["SMF_0"].0; + assert_eq!(&data[tail + 0x40..tail + 0x44], b"SMF2"); + } + + #[test] + fn any_wording_of_the_old_sentence() { + let text = "1 1\r\n*1*\r\nHalte gedrückt, um bei Angriffen Wechselangriffe zu wiederholen.\r\n*283*T\r\n[*1]Waffenwechsel\r\nDrücke [DownButton] für den Cestus.\r\nMit [L1Button] und [XButton]\r\nwechselst du schnell\r\nim Angriff.\r\n*284*\r\nOnyx\r\n"; + let german = lines_of("DE").unwrap(); + let (new, count) = rewrite(text, &german); + assert_eq!(count, 1); + assert_eq!( + new, + format!( + "1 1\r\n*1*\r\nHalte gedrückt, um bei Angriffen Wechselangriffe zu wiederholen.\r\n*283*T\r\n[*1]Waffenwechsel\r\nDrücke [DownButton] für den Cestus.\r\n{}\r\n*284*\r\nOnyx\r\n", + german.join("\r\n") + ) + ); + } + + #[test] + fn a_language_whose_font_lacks_a_new_character_keeps_its_text() { + let text = self::text().replace("rapidly", "rapidement"); // no ç, é or ' in this "French" + assert_eq!(rewrite(&text, &lines_of("FR").unwrap()), (text.clone(), 0)); + let empty = "5384 7373\r\n*1*\r\nEmpty\r\n\0"; + let mut data = wad_of(empty); + assert_eq!(patch(&mut data, &ENGLISH), 0); + assert_eq!(data, wad_of(empty)); + } + + #[test] + fn marks_are_found_as_the_pythons_regex_finds_them() { + // `*12x*` and `*7*` without its CRLF are no marks; a T is optional; ids parse as numbers. + let text = "a\r\n*12x*\r\nb\r\n*7*c\r\n*0190*T\r\nd\r\n*283*\r\n"; + let found: Vec<_> = marks(text).iter().map(|m| (m.start, m.end, m.id)).collect(); + assert_eq!(found, [(17, 28, Some(190)), (29, 38, Some(283))]); + assert_eq!(&text[17..28], "\r\n*0190*T\r\n"); + } + + #[test] + fn build_mounts_copies_and_never_writes_the_game() { + let temp = tempfile::tempdir().unwrap(); + let (game, out) = (temp.path().join("game"), temp.path().join("out")); + let folder = game.join(FOLDER); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(folder.join("R_LANG_EN.WAD"), wad()).unwrap(); + std::fs::write(folder.join("R_LANG_JA.WAD"), wad()).unwrap(); // no Japanese characters: kept + std::fs::write(folder.join("R_LANG_XX.WAD"), wad()).unwrap(); // not a language of the game + let mut log = Vec::new(); + let mounts = build(&game, &out, &mut |line| log.push(line)).unwrap(); + let guest = "/app0/EXEC/WAD/PS4_LE/R_LANG_EN.WAD"; + assert_eq!(mounts.keys().collect::>(), [guest]); + let copy = std::fs::read(&mounts[guest]).unwrap(); + assert!(copy + .windows(ENGLISH[1].len()) + .any(|window| window == ENGLISH[1].as_bytes())); + assert_eq!(std::fs::read(folder.join("R_LANG_EN.WAD")).unwrap(), wad()); + assert_eq!( + log, + ["Switch attacks: R_LANG_JA.WAD keeps its tutorial text"] + ); + // Cached: nothing is patched or said again. + let mut again = Vec::new(); + assert_eq!( + build(&game, &out, &mut |line| again.push(line)).unwrap(), + mounts + ); + assert!(again.is_empty()); + } + + #[test] + fn the_stamp_is_the_pythons_json() { + let temp = tempfile::tempdir().unwrap(); + let (game, out) = (temp.path().join("game"), temp.path().join("out")); + let folder = game.join(FOLDER); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(folder.join("R_LANG_DE.WAD"), wad()).unwrap(); + build(&game, &out, &mut |_| {}).unwrap(); + let meta = std::fs::metadata(folder.join("R_LANG_DE.WAD")).unwrap(); + let expected = format!( + "[2, [[\"EXEC/WAD/PS4_LE/R_LANG_DE.WAD\", {}, {}]]]", + meta.len(), + mtime_ns(&meta).unwrap() + ); + assert_eq!( + std::fs::read_to_string(out.join("switch_text/stamp.json")).unwrap(), + expected + ); + } +} diff --git a/launcher-rust/src/ui/pages/controls.rs b/launcher-rust/src/ui/pages/controls.rs index f8c26e2a..ffde41e9 100644 --- a/launcher-rust/src/ui/pages/controls.rs +++ b/launcher-rust/src/ui/pages/controls.rs @@ -84,6 +84,8 @@ pub const PAD_STYLE_NAMES: &[(&str, &str)] = &[ ]; const GLYPH_HINT: &str = "Replaces the PlayStation button icons inside the game with the ones of your controller. Works on a copy of up to three game files in the port's data folder (about 410 MB); your game folder is never changed. Applied when the game starts."; +const SWITCH_CHECK: &str = "Switch attacks on L1 + d-pad and L1 + Cross"; +const SWITCH_HINT: &str = "L1 + a d-pad direction switches to that weapon with its switch attack (the weapon in Kratos' hands does its attack again). L1 + Cross does the switch attack of the weapon in his hands instead of cycling to the next weapon. The d-pad alone switches as before. Applied when the game starts."; const RAGE_CHECK: &str = "Rage of Sparta on one input (L3 + R3 together)"; const KEY_NOTE: &str = "Click a box, then press a key, click a mouse button or turn the wheel (it gives up after {} seconds). An input already used by another button swaps with it; the arrow puts one button back on its default. Rage of Sparta presses L3 and R3 together: tick the box above it to use one input instead of two. Grab presses L1 and Circle together. Dodge evades the way you are moving, or back when standing still. Touchpad right opens the game's pause menu. Insert always opens the port's menu and cannot be bound. Applied when the game starts."; const STICK_HINT: &str = "Keyboard: enter the rotation with your movement inputs. With default keys, the sequences are A W D / D W A, D S A / A S D and D S A W / A S D W. Remapping movement changes these inputs too. Adaptive mouse: draw the turn with the mouse."; @@ -938,6 +940,14 @@ pub fn view(state: &State) -> Element<'_, Message> { ), Some(&tr(lang, GLYPH_HINT)), ), + // For both devices, under the icons as in the Python's page. + checkbox( + state.rtl, + "switch_attacks", + &tr(lang, SWITCH_CHECK), + state.store.flag("switch_attacks"), + Some(&tr(lang, SWITCH_HINT)), + ), row(state.rtl, &tr(lang, "Device"), devices.into(), None), device_section, ] diff --git a/launcher-rust/tests/dump.rs b/launcher-rust/tests/dump.rs index 3c948b53..d56ecba3 100644 --- a/launcher-rust/tests/dump.rs +++ b/launcher-rust/tests/dump.rs @@ -17,7 +17,9 @@ use spartanport_launcher::core::patches::{apply, plan, External, PlanInput, Sele use spartanport_launcher::runner::link_modules::DEFAULT_MODULES; use spartanport_launcher::runner::pause_menu::{self, decode, MenuError, Op, Record, Script}; use spartanport_launcher::runner::prepare::{prepare, read_self}; -use spartanport_launcher::runner::{glyphs, isa_rewrite, link_libc, link_modules, pause_menu_args}; +use spartanport_launcher::runner::{ + glyphs, isa_rewrite, link_libc, link_modules, pause_menu_args, switch_text_mounts, +}; /// The interpreter the comparisons against the frozen Python use: `SPARTANPORT_PYTHON` as one /// program path, else `py -3` on Windows when `py.exe` is on `PATH`, `python` on Windows @@ -629,6 +631,87 @@ fn real_menu_wad_matches_python() { } } +// -- Switch attacks (scripts/switch_text.py): the Weapon Switching tutorials of the dump's +// language WADs ------------------------------------------------------------------------------------ + +/// Runs `switch_text.build(game, out)` of `scripts/switch_text.py`: what it printed (`None` when no +/// interpreter starts). +fn python_switch_text(game: &Path, out: &Path) -> Option { + let env = |key: &str| std::env::var(key).ok(); + let command = python_command(&env).ok()?; + let (program, args) = command.split_first()?; + let script = "import sys; sys.path.insert(0, sys.argv[1]); import switch_text; \ + switch_text.build(sys.argv[2], sys.argv[3])"; + let output = Command::new(program) + .args(args) + .args(["-B", "-c", script]) + .arg(repo("scripts")) + .arg(game) + .arg(out) + .env("PYTHONIOENCODING", "utf-8") + .output() + .ok()?; + assert!( + output.status.success(), + "switch_text.build failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// The files of `folder`, by name. +fn files_of(folder: &Path) -> Vec<(String, Vec)> { + let mut files: Vec<_> = fs::read_dir(folder) + .unwrap() + .map(|entry| entry.unwrap().path()) + .map(|path| { + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + (name, fs::read(&path).unwrap()) + }) + .collect(); + files.sort(); + files +} + +#[test] +fn real_switch_text_wads_match_python() { + let Some(game) = dump() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let (python, rust) = (dir.path().join("python"), dir.path().join("rust")); + let Some(printed) = python_switch_text(&game, &python) else { + return; + }; + let env = [("SPARTAN_SWITCH_ATTACKS".to_string(), "1".to_string())] + .into_iter() + .collect(); + let mut log = Vec::new(); + let args = switch_text_mounts(&env, &game, &rust, &mut |line| log.push(line)); + // The languages the Python left as they are, said the same way. + let kept: Vec<&str> = log + .iter() + .map(String::as_str) + .filter(|line| line.ends_with(" keeps its tutorial text")) + .collect(); + assert_eq!(kept, printed.lines().collect::>()); + let ours = files_of(&rust.join("switch_text")); + let theirs = files_of(&python.join("switch_text")); + let names = |files: &[(String, Vec)]| files.iter().map(|f| f.0.clone()).collect::>(); + assert_eq!(names(&ours), names(&theirs)); + for ((name, ours), (_, theirs)) in ours.iter().zip(&theirs) { + let first = ours.iter().zip(theirs).position(|(a, b)| a != b); + assert!( + ours == theirs, + "{name} differs (sizes {} and {}, first difference at {first:?})", + ours.len(), + theirs.len() + ); + } + // One mount per copy (the stamp is no WAD). + assert_eq!(args.len(), 2 * (theirs.len() - 1)); +} + /// The dump's `R_UI.WAD` and its copy patched in English (`GameScript.setUp`, /// `NativeCheats.setUpClass`), made once. struct Menu { diff --git a/launcher/spartanport_lang.py b/launcher/spartanport_lang.py index dd5b6eb8..3c8d92f4 100644 --- a/launcher/spartanport_lang.py +++ b/launcher/spartanport_lang.py @@ -1033,6 +1033,9 @@ EXTRA = { 'How the stick-rotation prompts are answered. Keyboard: type the turn instead, half turns from the top D S A (clockwise) or A S D, from the bottom A W D or D W A, full turns D S A W or A S D W; a key out of turn starts again. Adaptive mouse: draw the turn with the mouse.': 'Como os eventos de girar o analógico são respondidos. Teclado: digite o giro, meias-voltas a partir de cima D S A (horário) ou A S D, a partir de baixo A W D ou D W A, voltas completas D S A W ou A S D W; uma tecla fora de ordem recomeça. Mouse adaptativo: desenhe o giro com o mouse.', 'Joystick prompts': 'Prompts do analógico', + 'Switch attacks on L1 + d-pad and L1 + Cross': 'Ataques de troca com L1 + direcional e L1 + X', + "L1 + a d-pad direction switches to that weapon with its switch attack (the weapon in Kratos' hands does its attack again). L1 + Cross does the switch attack of the weapon in his hands instead of cycling to the next weapon. The d-pad alone switches as before. Applied when the game starts.": + "L1 + uma direção do direcional troca para essa arma com o ataque de troca dela (a arma que já está na mão de Kratos repete o ataque). L1 + X faz o ataque de troca da arma na mão em vez de passar para a próxima arma. O direcional sozinho troca como antes. Aplicado ao iniciar o jogo.", 'Keep the mouse in the game window': 'Manter o mouse na janela do jogo', 'Key not supported': 'Tecla não suportada', 'Keyboard & Mouse Mapping': 'Mapeamento de teclado e mouse', @@ -1319,6 +1322,9 @@ EXTRA = { 'How the stick-rotation prompts are answered. Keyboard: type the turn instead, half turns from the top D S A (clockwise) or A S D, from the bottom A W D or D W A, full turns D S A W or A S D W; a key out of turn starts again. Adaptive mouse: draw the turn with the mouse.': 'Cómo se responden los eventos de girar el stick. Teclado: escribe el giro, medias vueltas desde arriba D S A (horario) o A S D, desde abajo A W D o D W A, vueltas completas D S A W o A S D W; una tecla fuera de orden vuelve a empezar. Ratón adaptativo: dibuja el giro con el ratón.', 'Joystick prompts': 'Eventos del stick', + 'Switch attacks on L1 + d-pad and L1 + Cross': 'Ataques de cambio con L1 + cruceta y L1 + X', + "L1 + a d-pad direction switches to that weapon with its switch attack (the weapon in Kratos' hands does its attack again). L1 + Cross does the switch attack of the weapon in his hands instead of cycling to the next weapon. The d-pad alone switches as before. Applied when the game starts.": + "L1 + una dirección de la cruceta cambia a esa arma con su ataque de cambio (el arma que Kratos ya tiene en la mano repite su ataque). L1 + X hace el ataque de cambio del arma en la mano en lugar de pasar a la siguiente arma. La cruceta sola cambia como antes. Se aplica al iniciar el juego.", 'Keep the mouse in the game window': 'Mantener el ratón en la ventana del juego', 'Key not supported': 'Tecla no compatible', 'Keyboard & Mouse Mapping': 'Asignación de teclado y ratón', diff --git a/launcher/spartanport_launcher_win.py b/launcher/spartanport_launcher_win.py index 5d68a929..131b3a5a 100644 --- a/launcher/spartanport_launcher_win.py +++ b/launcher/spartanport_launcher_win.py @@ -500,7 +500,7 @@ APP_DEFAULTS = {'ui_language': 'en', 'ui_scale': 'auto', 'game_dir': os.environ. 'vk_validation': False, 'close_on_play': False, 'discord_rpc': False, 'check_updates': False, 'game_patches': {}, 'controls': {}, 'pad_style': 'playstation', 'rage_one_input': True, 'stick_prompts': 'keyboard', 'controls_layout': CONTROLS_LAYOUT, - 'glyph_style': 'off'} + 'switch_attacks': False, 'glyph_style': 'off'} # 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 @@ -1343,6 +1343,9 @@ def game_environment(s): env['SPARTAN_KEY_MAP'] = value if value := control_map(controls, 'pad'): env['SPARTAN_PAD_MAP'] = value + env.pop('SPARTAN_SWITCH_ATTACKS', None) + if s.get('switch_attacks'): + env['SPARTAN_SWITCH_ATTACKS'] = '1' env.pop('SPARTAN_STICK_PROMPTS', None) if s.get('stick_prompts') in dict(STICK_PROMPTS): env['SPARTAN_STICK_PROMPTS'] = s['stick_prompts'] @@ -2581,6 +2584,11 @@ class Launcher: hint=_('Replaces the PlayStation button icons inside the game with the ones of your controller. ' 'Works on a copy of up to three game files in the port\'s data folder (about 410 MB); your game folder ' 'is never changed. Applied when the game starts.')) + self.check(page, 'switch_attacks', 'app', _('Switch attacks on L1 + d-pad and L1 + Cross'), + _('L1 + a d-pad direction switches to that weapon with its switch attack (the weapon in Kratos\' ' + 'hands does its attack again). L1 + Cross does the switch attack of the weapon in his hands ' + 'instead of cycling to the next weapon. The d-pad alone switches as before. Applied when the ' + 'game starts.')) saved_device = self.app.get('controls_device') self.device = tk.StringVar(value=saved_device if saved_device in dict(CONTROL_DEVICES) else 'keyboard') self.device_chosen = saved_device in dict(CONTROL_DEVICES) diff --git a/run.py b/run.py index 4139c6f0..c1691185 100644 --- a/run.py +++ b/run.py @@ -144,6 +144,25 @@ def glyph_mounts(env, probe, game, out): return [part for guest, path in mounts.items() for part in ('--mount', f'{guest}={path}')], style +def switch_text_mounts(env, game, out): + """Probe arguments for the Weapon Switching tutorials that match the switch attacks: patched + copies of the language WADs (scripts/switch_text.py), only with SPARTAN_SWITCH_ATTACKS=1; without + it old copies are removed. The game folder is never written.""" + if env.get('SPARTAN_SWITCH_ATTACKS') != '1': + shutil.rmtree(Path(out) / 'switch_text', ignore_errors=True) + return [] + try: + import switch_text + mounts = switch_text.build(game, out) + except Exception as error: # noqa: BLE001 (text only: whatever fails, the game still starts) + print(f'Switch attacks: {error}; the tutorials keep their own text', flush=True) + return [] + if mounts: + print(f'Switch attacks: tutorial text on copies in {Path(out) / "switch_text"}; game files untouched', + flush=True) + return [part for guest, path in mounts.items() for part in ('--mount', f'{guest}={path}')] + + def pause_menu_args(game, out, mounts, language=None): """Probe arguments for the copy of R_UI.WAD with the pause menu's Exit to Desktop row (scripts/pause_menu.py), made from the glyphs' copy when `mounts` has one. The probe mounts it @@ -318,7 +337,8 @@ def main(): if glyph_style: # the in-game menu names the buttons the same way env['SPARTAN_GLYPHS'] = glyph_style derived.append('SPARTAN_GLYPHS') - mount_args = glyph_args + pause_menu_args(game, out, glyph_args, env.get('SPARTAN_LANGUAGE')) + mount_args = (glyph_args + pause_menu_args(game, out, glyph_args, env.get('SPARTAN_LANGUAGE')) + + switch_text_mounts(env, game, out)) env['SPARTAN_DERIVED'] = ','.join(derived) # The guest memory pool is committed in full at start: a resolution patch above 1080p # can need more than 16 GB of RAM, and the game then closes when the page file cannot grow. diff --git a/scripts/switch_text.py b/scripts/switch_text.py new file mode 100644 index 00000000..ef74bb9b --- /dev/null +++ b/scripts/switch_text.py @@ -0,0 +1,126 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +"""The Weapon Switching tutorials for the switch attacks (SPARTAN_SWITCH_ATTACKS=1, src/switch_hook.h). + +With the option, L1 + Cross no longer cycles through the weapons: it repeats the switch attack of +the weapon in Kratos' hands, and L1 with a d-pad direction switches to that weapon with its switch +attack. The tutorial cards (texts 190, 283 and 286: a title, "Press for " +lines, then the L1 + Cross sentence) say so instead of the old sentence. build() writes patched +copies of the language WADs and returns the guest paths run.py mounts them on; the game folder is +never written. + +The text is the MSGS_TXT entry of each R_LANG_.WAD, UTF-8: CRLF lines, `**` (with a T +for a tutorial) before each text. In each card the lines from the first one with [L1Button] to the +end of the card are replaced, so the patch does not depend on how a language worded or wrapped the +old sentence. The entry grows (its size field, its padding to 16, and everything after it moves). +A language is left as it is when its text lacks one of the new characters: each language's font +only has the characters its own text uses.""" +import json +from pathlib import Path +import re +import shutil +import struct + +from glyphs import entries + +VERSION = 2 # of the text: a change rewrites the cached copies +FOLDER = 'EXEC/WAD/PS4_LE' +CARDS = (190, 283, 286) +# R_LANG_.WAD -> the lines that end the cards. +ENGLISH = ('Hold [L1Button] to attack when switching weapons.', + 'Use [L1Button] and [XButton] to repeat switch attacks.') +SPANISH = ('Mantén [L1Button] para atacar al cambiar de arma.', + 'Usa [L1Button] y [XButton] para repetir el ataque de cambio.') +TEXT = { + 'EN': ENGLISH, 'UK': ENGLISH, + 'DE': ('Halte [L1Button] gedrückt, um beim Waffenwechsel anzugreifen.', + 'Drücke [L1Button] und [XButton], um Wechselangriffe zu wiederholen.'), + 'FR': ('Maintenez [L1Button] pour attaquer en changeant d\'arme.', + 'Utilisez [L1Button] et [XButton] pour répéter l\'attaque de changement.'), + 'IT': ('Tieni premuto [L1Button] per attaccare mentre cambi arma.', + 'Usa [L1Button] e [XButton] per ripetere l\'attacco di cambio.'), + 'ES': SPANISH, 'LA': SPANISH, + 'PT': ('Mantém [L1Button] premido para atacar ao trocar de arma.', + 'Usa [L1Button] e [XButton] para repetir o ataque de troca.'), + 'BR': ('Segure [L1Button] para atacar ao trocar de arma.', + 'Use [L1Button] e [XButton] para repetir o ataque de troca.'), + 'NL': ('Houd [L1Button] vast om aan te vallen bij een wapenwissel.', + 'Gebruik [L1Button] en [XButton] om wisselaanvallen te herhalen.'), + 'PL': ('Przytrzymaj [L1Button], aby zaatakować przy zmianie broni.', + 'Użyj [L1Button] i [XButton], aby powtórzyć atak zmiany broni.'), + 'RU': ('Удерживайте [L1Button], чтобы атаковать при смене оружия.', + 'Нажмите [L1Button] и [XButton], чтобы повторить атаку смены оружия.'), + 'TR': ('Silah değiştirirken saldırmak için [L1Button] tuşunu basılı tut.', + 'Değiştirme saldırısını tekrarlamak için [L1Button] ve [XButton] kullan.'), + 'JA': ('[L1Button]を押しながら武器を切り替えると攻撃します。', + '[L1Button]と[XButton]で切り替え攻撃を繰り返します。'), + 'KO': ('[L1Button] 버튼을 누른 채 무기를 바꾸면 공격합니다.', + '[L1Button] 버튼과 [XButton] 버튼으로 전환 공격을 반복합니다.'), + 'ZH': ('按住[L1Button]切換武器時會發動攻擊。', + '使用[L1Button]和[XButton]重複切換攻擊。'), +} +CARD = re.compile(r'\r\n\*(\d+)\*T?\r\n') + + +def rewrite(text, lines): + """The MSGS_TXT text with the cards ending in `lines`, and how many cards changed; (text, 0) + when no card has an [L1Button] line, or the language's text lacks a character of `lines`.""" + used = set(text) + if any(c not in used for line in lines for c in line): + return text, 0 + marks = list(CARD.finditer(text)) + out, at, count = [], 0, 0 + for mark, following in zip(marks, marks[1:] + [None]): + if int(mark.group(1)) not in CARDS: + continue + end = following.start() if following else len(text) + body = text[mark.end():end].split('\r\n') + first = next((i for i, line in enumerate(body) if '[L1Button]' in line), None) + if first is None: + continue + out += [text[at:mark.end()], '\r\n'.join(body[:first] + list(lines))] + at, count = end, count + 1 + return ''.join(out) + text[at:], count + + +def patch(data, lines): + """Ends the Weapon Switching cards of the WAD in `data` (a bytearray) with `lines`, growing its + MSGS_TXT entry; returns how many cards changed.""" + for offset, size, name in entries(data): + if name != 'MSGS_TXT': + continue + start, end = offset + 0x40, offset + 0x40 + size + try: + text = bytes(data[start:end]).decode('utf-8') + except UnicodeDecodeError: + return 0 + text, count = rewrite(text, lines) + if count: + raw = text.encode('utf-8') + data[start:end + -size % 16] = raw + bytes(-len(raw) % 16) + struct.pack_into('site); + if (sites[i] == UINT64_MAX || !mapped(segments, count, sites[i], site->length) || + memcmp(image + sites[i], site->original, site->length)) { + printf("Switch attacks: unavailable (%s not found in this eboot.bin)\n", site->name); + return; + } + } + const uint64_t pass_at = spartanport_signature_find(image, size, &spartanport_switch_pass_site); + const uint64_t caller = spartanport_signature_find(image, size, &spartanport_switch_caller_site); + if (pass_at == UINT64_MAX || caller == UINT64_MAX || !mapped(segments, count, pass_at - 1, 5)) { + printf("Switch attacks: unavailable (input check not found in this eboot.bin)\n"); + return; + } + int32_t pass_rel; + memcpy(&pass_rel, image + pass_at, 4); + const uint64_t pass = pass_at + 4 + (int64_t)pass_rel; + const size_t stub_size = round_page(SPARTAN_SWITCH_SITES * SPARTAN_SWITCH_SLOT); + unsigned char *stubs = allocate(stub_size); + int32_t relative[SPARTAN_SWITCH_SITES]; + for (unsigned i = 0; i < SPARTAN_SWITCH_SITES; ++i) { + const int64_t delta = (int64_t)(uintptr_t)(stubs + i * SPARTAN_SWITCH_SLOT) - + (int64_t)(uintptr_t)(image + sites[i] + 5); + if (delta < INT32_MIN || delta > INT32_MAX) { + printf("Switch attacks: unavailable (hook outside relative-jump range)\n"); + return; + } + relative[i] = (int32_t)delta; + } + ((SpartanSwitchState *)spartanport_switch_state_address())->caller = (uint64_t)(uintptr_t)(image + caller); + const uintptr_t calls[SPARTAN_SWITCH_SITES] = {(uintptr_t)&spartanport_switch_input, (uintptr_t)&spartanport_switch_queue, 0}; + for (unsigned i = 0; i < SPARTAN_SWITCH_SITES; ++i) + spartanport_switch_build_stub(stubs + i * SPARTAN_SWITCH_SLOT, &spartanport_switch_sites[i], spartanport_switch_state_address(), + calls[i], (uintptr_t)(image + pass), + (uintptr_t)(image + sites[i] + spartanport_switch_sites[i].length)); + protect(stubs, stub_size, 5); + for (unsigned i = 0; i < SPARTAN_SWITCH_SITES; ++i) { + image[sites[i]] = 0xe9; + memcpy(image + sites[i] + 1, &relative[i], 4); + memset(image + sites[i] + 5, 0x90, spartanport_switch_sites[i].length - 5); + } + spartanport_switch_status(1); + printf("Switch attacks: hooks ready (input check 0x%llx, input queue 0x%llx, d-pad select 0x%llx)\n", + (unsigned long long)sites[0], (unsigned long long)sites[1], (unsigned long long)sites[2]); +} + static void install_orb_multiplier(Segment *segments, uint64_t count, uint64_t size) { uint64_t calls[3]; const uint64_t setter_at = spartanport_orb_find(image, size, calls); @@ -969,7 +1027,7 @@ int main(int argc, char **argv) { int cpu_only = 0, strict_imports = 0, check_imports = 0; unsigned timeout_seconds = 10; const char *content_profile=NULL, *app0=NULL, *user_dir=NULL, *patch_file=NULL, *isa_file=NULL, *pause_menu=NULL; - const char *mounts[8]; int mount_count=0; + const char *mounts[24]; int mount_count=0; /* glyphs (3) and the switch attack tutorials (16) */ for (int i = 2; i < argc; ++i) { if (!strcmp(argv[i], "--cpu-only")) cpu_only = 1; else if (!strcmp(argv[i], "--strict-imports")) strict_imports = 1; @@ -983,7 +1041,7 @@ int main(int argc, char **argv) { else if (!strcmp(argv[i], "--mount") && i+1 #include #include @@ -789,6 +790,151 @@ static void apply_weapon_cycle(PadData *d) { if (weapon_cycle.pressing) d->buttons|=weapon_cycle.button; } +/* Switch attacks (SPARTAN_SWITCH_ATTACKS=1, src/switch_hook.h). L1 + Cross asks for the switch attack + * of the weapon in his hands, L1 + a d-pad direction for that weapon's (the game selects the + * weapon as always). The pad only leaves the request; the hooks let the move he is in take its + * branch to a switch attack for it (a block move has them) and swap that branch for one of the + * game's branches to the requested weapon's switch attack, of the same kind. A request no move + * takes lapses after SWITCH_REQUEST_US. Cross alone no longer cycles through the weapons. + * The same weapon's switch attack again waits SWITCH_REPEAT_US after the last one: in the game + * each L1 + Cross also changed the weapon, so the attacks' short recovery was not spammable. A + * switch attack of another weapon is not held back. + * + * The branches are found once, in the hero's move data around the first one the game asks about + * (all of them within SWITCH_SCAN_BYTES): per weapon, the switch attack most of them go to is + * the common kind, the other one the rare kind. */ +enum { SWITCH_REQUEST_US=200000, SWITCH_REPEAT_US=800000, SWITCH_CURRENT=0xff, SWITCH_FOUND=128, SWITCH_STEP=0x10000, + SWITCH_SCAN_BYTES=8<<20 }; +int runtime_memory_is_mapped(uintptr_t address, uint64_t size); +static SpartanSwitchState spartanport_switch; +typedef struct { const uint8_t *branch, *target; } SwitchFound; +static struct { + int loaded, enabled, hooks; + uint32_t previous, request; /* request: 0, a weapon, or SWITCH_CURRENT */ + uint64_t until; + int scanned; /* the rest: game thread */ + unsigned last; /* the weapon of the last switch attack, and when */ + uint64_t last_at; + unsigned found; + SwitchFound branches[SWITCH_FOUND]; + const uint8_t *attack[4][2]; /* per weapon: its switch attack moves, common kind first */ +} sw; +uintptr_t spartanport_switch_state_address(void) { return (uintptr_t)&spartanport_switch; } +void spartanport_switch_status(int supported) { sw.hooks=supported; } +static const char *switch_name(unsigned w) { + static const char *names[4]={"Nemean Cestus","Nemesis Whip","Blades of Exile","Claws of Hades"}; + return w>=0x10 && w<=0x13 ? names[w-0x10] : "?"; +} +static const uint8_t *switch_target(const uint8_t *b) { + int32_t rel; memcpy(&rel,b,4); + return rel ? b+rel : NULL; +} +static void switch_scan(const uint8_t *seen) { + sw.scanned=1; + const uintptr_t at=(uintptr_t)seen & ~(uintptr_t)(SWITCH_STEP-1); + uintptr_t lo=at, hi=at+SWITCH_STEP; + while (at-lo=SWITCH_STEP && runtime_memory_is_mapped(lo-SWITCH_STEP,SWITCH_STEP)) lo-=SWITCH_STEP; + while (hi-at=hi) continue; + sw.branches[sw.found++]=(SwitchFound){b,target}; + const unsigned w=b[0x2f]-0x10; + for (unsigned i=0;i<4;++i) { + if (targets[w][i]==target || !targets[w][i]) { targets[w][i]=target; ++counts[w][i]; break; } + } + } + for (unsigned w=0;w<4;++w) { + for (unsigned k=0;k<2;++k) { + unsigned best=4; + for (unsigned i=0;i<4;++i) + if (targets[w][i] && targets[w][i]!=sw.attack[w][0] && (best==4 || counts[w][i]>counts[w][best])) best=i; + if (best<4) sw.attack[w][k]=targets[w][best]; + } + } + printf("Switch attacks: %u branches to switch attacks in %llu KB of move data (Cestus %d, Whip %d, Blades %d, Claws %d kinds)\n", + sw.found,(unsigned long long)((hi-lo)>>10),!!sw.attack[0][0]+!!sw.attack[0][1],!!sw.attack[1][0]+!!sw.attack[1][1], + !!sw.attack[2][0]+!!sw.attack[2][1],!!sw.attack[3][0]+!!sw.attack[3][1]); +} +/* The weapon a live request is for, if he owns it and it is not a repeat too soon. */ +static unsigned switch_requested(void) { + const uint32_t request=__atomic_load_n(&sw.request,__ATOMIC_ACQUIRE); + const uint64_t now=now_us(); + if (!request || now>__atomic_load_n(&sw.until,__ATOMIC_RELAXED)) return 0; + const uint8_t *stats=(const uint8_t *)(uintptr_t)__atomic_load_n(&spartanport_switch.stats,__ATOMIC_RELAXED); + if (!stats) return 0; + const unsigned w=request==SWITCH_CURRENT ? stats[0x20b] : request; + uint32_t owned; memcpy(&owned,stats+0x1c4,4); + if (w==sw.last && now-sw.last_at=0x10 && w<=0x13 && (owned>>(w-1) & 1) ? w : 0; +} +/* One of the game's branches to weapon w's switch attack, of the kind branch b goes to. */ +static const uint8_t *switch_pick(const uint8_t *b, unsigned w) { + if (b[0x2f]==w) return b; + const uint8_t *target=switch_target(b); + const unsigned kind=target && target==sw.attack[b[0x2f]-0x10][1]; + const uint8_t *want=sw.attack[w-0x10][kind] ? sw.attack[w-0x10][kind] : sw.attack[w-0x10][0]; + const uint8_t *pick=NULL; + for (unsigned i=0;ibranch[0x2f]!=w || f->target!=want) continue; + if (!memcmp(f->branch+0x14,b+0x14,4)) return f->branch; /* the same timing */ + if (!pick) pick=f->branch; + } + return pick; +} +ABI int spartanport_switch_input(const unsigned char *b, uintptr_t caller) { + if (!spartanport_switch_branch(b)) return 2; + if (caller!=spartanport_switch.caller) return 0; /* 0xe9a70 would take them on Cross: no cycling there either */ + if (!sw.scanned) switch_scan(b); + const unsigned w=switch_requested(); + return w && switch_pick(b,w); +} +ABI const unsigned char *spartanport_switch_queue(const unsigned char *b) { + if (!spartanport_switch_branch(b)) return b; + const unsigned w=switch_requested(); + const uint8_t *pick=w ? switch_pick(b,w) : NULL; + if (!pick) return b; + const uint8_t *stats=(const uint8_t *)(uintptr_t)spartanport_switch.stats; + __atomic_store_n(&sw.request,0,__ATOMIC_RELEASE); + sw.last=w; + sw.last_at=now_us(); + printf("Runtime: switch attack of the %s (in his hands: %s)\n",switch_name(w),switch_name(stats[0x20b])); + return pick; +} + +static void reset_switch_attack(void) { __atomic_store_n(&sw.request,0,__ATOMIC_RELEASE); } +static void apply_switch_attack(const PadData *d) { + if (!sw.loaded) { + sw.loaded=1; + const char *value=getenv("SPARTAN_SWITCH_ATTACKS"); + sw.enabled=value && !strcmp(value,"1"); + if (sw.enabled) printf("Runtime: switch attacks on L1 + d-pad and L1 + Cross%s\n", + sw.hooks ? "" : " unavailable (hooks not installed)"); + } + if (!sw.enabled || !sw.hooks) return; + const uint32_t pressed=d->buttons & ~sw.previous; + sw.previous=d->buttons; + const uint32_t request=__atomic_load_n(&sw.request,__ATOMIC_ACQUIRE); + if (request && d->timestamp>sw.until) { + printf("Runtime: switch attack request lapsed (no move could switch)\n"); + reset_switch_attack(); + } + if (!(d->buttons & BTN_L1)) return; + static const struct { uint32_t button, weapon; } asks[]={ + {BTN_CROSS,SWITCH_CURRENT},{BTN_DOWN,0x10},{BTN_LEFT,0x11},{BTN_UP,0x12},{BTN_RIGHT,0x13}}; + for (unsigned i=0;itimestamp+SWITCH_REQUEST_US,__ATOMIC_RELAXED); + __atomic_store_n(&sw.request,asks[i].weapon,__ATOMIC_RELEASE); + break; + } +} + /* R3 + L1 + R1 goes through the engine frame rates live (60 for spots that break at a higher * rate). The three stay hidden from the game until each one is released: a stray R1 grabs. */ enum { ENGINE_FPS_COMBO=BTN_R3|BTN_L1|BTN_R1 }; @@ -809,7 +955,7 @@ static void engine_fps_combo(PadData *d) { static int hold_after_capture; static void sample(PadData *d) { sample_host(d); - if (spartanportgpu_overlay_captures_input()) { hold_after_capture=1; click.side=-1; reset_dodge(); reset_weapon_cycle(); return; } + if (spartanportgpu_overlay_captures_input()) { hold_after_capture=1; click.side=-1; reset_dodge(); reset_weapon_cycle(); reset_switch_attack(); return; } record_sample(d); /* the click sources as they are: a replay plays them again */ read_inject(); replay_sample(d); @@ -820,6 +966,7 @@ static void sample(PadData *d) { for (int i=0;i<4;++i) if (injected.stick[i]>=0) *axes[i]=(uint8_t)injected.stick[i]; note_game_menu(d); /* before the actions turn into the buttons they press */ apply_combos(d); + apply_switch_attack(d); apply_dodge(d); apply_weapon_cycle(d); apply_mouse_aim(d); diff --git a/src/switch_hook.h b/src/switch_hook.h new file mode 100644 index 00000000..67561b6c --- /dev/null +++ b/src/switch_hook.h @@ -0,0 +1,148 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later */ +#ifndef SPARTAN_SWITCH_HOOK_H +#define SPARTAN_SWITCH_HOOK_H +#include +#include +#include +#include "signature.h" + +/* Switch attacks (runtime_pad.c, SPARTAN_SWITCH_ATTACKS). Offsets as found in a v01.02 build; builds + * are found by signature. + * + * Kratos' moves change through branches: a branch names the move it goes to (rel32 at +0x00), + * the input that takes it (+0x30 input, 1 = the button 0x40, Cross; +0x31 how, 1 = just pressed, + * 4 = held) and the item it is for (+0x2f). A weapon's switch attack is a move of its own (two of them: one + * for most moves, one for a few others), reached from the block moves by branches for Cross just + * pressed (and, in a few moves, Cross held), item 0x10 Nemean Cestus, 0x11 Nemesis Whip, 0x12 Blades of Exile or 0x13 Claws of + * Hades. A block move only has branches to the weapons that follow in the game's cycle; the + * switch attack's script selects its weapon (SCR_EnableWeapon). + * + * Three hooks: + * - input check (0xeb290, rdx the branch): a branch to a switch attack is taken by the pad's + * request (spartanport_switch_input) instead of Cross; the move's other conditions stay the game's. + * Only from 0xe3910, the move's branches for the input queue; from the other caller (0xe9a70) + * they are never taken, or Cross would still cycle the weapons there; + * - input queue (0xe4063 in 0xe3910, rax the branch the move chose): the branch is swapped for + * one of the game's branches to the requested weapon's switch attack, the same kind + * (spartanport_switch_queue); + * - d-pad weapon select (0x6b240, rdi his player stats): notes the stats. + * The stubs are assembled from tools/switch_stubs.s. */ +enum { SPARTAN_SWITCH_SITES = 3, SPARTAN_SWITCH_SLOT = 512 }; + +/* Shared with the stubs. */ +typedef struct { + uint64_t caller; /* the input check's return into 0xe3910, the only caller it answers */ + uint64_t stats; /* +8: his player stats, from the d-pad select */ +} SpartanSwitchState; +#ifdef __cplusplus +static_assert(offsetof(SpartanSwitchState, stats) == 8, "the stub writes the stats at +8"); +#else +_Static_assert(offsetof(SpartanSwitchState, stats) == 8, "the stub writes the stats at +8"); +#endif + +static const unsigned char spartanport_switch_stub_input_entry[306] = { + 0x80,0x7a,0x30,0x01,0x0f,0x85,0x22,0x01,0x00,0x00,0x80,0x7a,0x31,0x01,0x74,0x0a, + 0x80,0x7a,0x31,0x04,0x0f,0x85,0x12,0x01,0x00,0x00,0x80,0x7a,0x2e,0x0f,0x0f,0x85, + 0x08,0x01,0x00,0x00,0x0f,0xb6,0x42,0x2f,0x83,0xe8,0x10,0x83,0xf8,0x03,0x0f,0x87, + 0xf8,0x00,0x00,0x00,0x55,0x48,0x89,0xe5,0x48,0x83,0xe4,0xf0,0x48,0x81,0xec,0xc0, + 0x00,0x00,0x00,0x48,0x89,0x3c,0x24,0x48,0x89,0x74,0x24,0x08,0x48,0x89,0x54,0x24, + 0x10,0x48,0x89,0x4c,0x24,0x18,0x4c,0x89,0x44,0x24,0x20,0x4c,0x89,0x4c,0x24,0x28, + 0xf3,0x0f,0x7f,0x44,0x24,0x40,0xf3,0x0f,0x7f,0x4c,0x24,0x50,0xf3,0x0f,0x7f,0x54, + 0x24,0x60,0xf3,0x0f,0x7f,0x5c,0x24,0x70,0xf3,0x0f,0x7f,0xa4,0x24,0x80,0x00,0x00, + 0x00,0xf3,0x0f,0x7f,0xac,0x24,0x90,0x00,0x00,0x00,0xf3,0x0f,0x7f,0xb4,0x24,0xa0, + 0x00,0x00,0x00,0xf3,0x0f,0x7f,0xbc,0x24,0xb0,0x00,0x00,0x00,0x48,0x89,0xd7,0x48, + 0x8b,0x75,0x08,0x48,0xb8,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0xff,0xd0,0x48, + 0x8b,0x3c,0x24,0x48,0x8b,0x74,0x24,0x08,0x48,0x8b,0x54,0x24,0x10,0x48,0x8b,0x4c, + 0x24,0x18,0x4c,0x8b,0x44,0x24,0x20,0x4c,0x8b,0x4c,0x24,0x28,0xf3,0x0f,0x6f,0x44, + 0x24,0x40,0xf3,0x0f,0x6f,0x4c,0x24,0x50,0xf3,0x0f,0x6f,0x54,0x24,0x60,0xf3,0x0f, + 0x6f,0x5c,0x24,0x70,0xf3,0x0f,0x6f,0xa4,0x24,0x80,0x00,0x00,0x00,0xf3,0x0f,0x6f, + 0xac,0x24,0x90,0x00,0x00,0x00,0xf3,0x0f,0x6f,0xb4,0x24,0xa0,0x00,0x00,0x00,0xf3, + 0x0f,0x6f,0xbc,0x24,0xb0,0x00,0x00,0x00,0x48,0x89,0xec,0x5d,0x83,0xf8,0x02,0x74, + 0x1b,0x85,0xc0,0x74,0x14,0x48,0x89,0xd6,0x48,0x89,0xca,0xff,0x25,0x00,0x00,0x00, + 0x00,0x33,0x33,0x33,0x33,0x33,0x33,0x33,0x33,0x31,0xc0,0xc3,0x55,0x48,0x89,0xe5, + 0x41,0x57, +}; +static const unsigned char spartanport_switch_stub_queue_site[456] = { + 0x80,0x78,0x30,0x01,0x0f,0x85,0xb5,0x01,0x00,0x00,0x80,0x78,0x31,0x01,0x74,0x0a, + 0x80,0x78,0x31,0x04,0x0f,0x85,0xa5,0x01,0x00,0x00,0x80,0x78,0x2e,0x0f,0x0f,0x85, + 0x9b,0x01,0x00,0x00,0x0f,0xb6,0x50,0x2f,0x83,0xea,0x10,0x83,0xfa,0x03,0x0f,0x87, + 0x8b,0x01,0x00,0x00,0x48,0x8d,0x64,0x24,0x80,0x55,0x48,0x89,0xe5,0x48,0x83,0xe4, + 0xf0,0x48,0x81,0xec,0x40,0x01,0x00,0x00,0x48,0x89,0x0c,0x24,0x48,0x89,0x74,0x24, + 0x08,0x48,0x89,0x7c,0x24,0x10,0x4c,0x89,0x44,0x24,0x18,0x4c,0x89,0x4c,0x24,0x20, + 0x4c,0x89,0x54,0x24,0x28,0x4c,0x89,0x5c,0x24,0x30,0xf3,0x0f,0x7f,0x44,0x24,0x40, + 0xf3,0x0f,0x7f,0x4c,0x24,0x50,0xf3,0x0f,0x7f,0x54,0x24,0x60,0xf3,0x0f,0x7f,0x5c, + 0x24,0x70,0xf3,0x0f,0x7f,0xa4,0x24,0x80,0x00,0x00,0x00,0xf3,0x0f,0x7f,0xac,0x24, + 0x90,0x00,0x00,0x00,0xf3,0x0f,0x7f,0xb4,0x24,0xa0,0x00,0x00,0x00,0xf3,0x0f,0x7f, + 0xbc,0x24,0xb0,0x00,0x00,0x00,0xf3,0x44,0x0f,0x7f,0x84,0x24,0xc0,0x00,0x00,0x00, + 0xf3,0x44,0x0f,0x7f,0x8c,0x24,0xd0,0x00,0x00,0x00,0xf3,0x44,0x0f,0x7f,0x94,0x24, + 0xe0,0x00,0x00,0x00,0xf3,0x44,0x0f,0x7f,0x9c,0x24,0xf0,0x00,0x00,0x00,0xf3,0x44, + 0x0f,0x7f,0xa4,0x24,0x00,0x01,0x00,0x00,0xf3,0x44,0x0f,0x7f,0xac,0x24,0x10,0x01, + 0x00,0x00,0xf3,0x44,0x0f,0x7f,0xb4,0x24,0x20,0x01,0x00,0x00,0xf3,0x44,0x0f,0x7f, + 0xbc,0x24,0x30,0x01,0x00,0x00,0x48,0x89,0xc7,0x48,0xb8,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0xff,0xd0,0x48,0x8b,0x0c,0x24,0x48,0x8b,0x74,0x24,0x08,0x48,0x8b, + 0x7c,0x24,0x10,0x4c,0x8b,0x44,0x24,0x18,0x4c,0x8b,0x4c,0x24,0x20,0x4c,0x8b,0x54, + 0x24,0x28,0x4c,0x8b,0x5c,0x24,0x30,0xf3,0x0f,0x6f,0x44,0x24,0x40,0xf3,0x0f,0x6f, + 0x4c,0x24,0x50,0xf3,0x0f,0x6f,0x54,0x24,0x60,0xf3,0x0f,0x6f,0x5c,0x24,0x70,0xf3, + 0x0f,0x6f,0xa4,0x24,0x80,0x00,0x00,0x00,0xf3,0x0f,0x6f,0xac,0x24,0x90,0x00,0x00, + 0x00,0xf3,0x0f,0x6f,0xb4,0x24,0xa0,0x00,0x00,0x00,0xf3,0x0f,0x6f,0xbc,0x24,0xb0, + 0x00,0x00,0x00,0xf3,0x44,0x0f,0x6f,0x84,0x24,0xc0,0x00,0x00,0x00,0xf3,0x44,0x0f, + 0x6f,0x8c,0x24,0xd0,0x00,0x00,0x00,0xf3,0x44,0x0f,0x6f,0x94,0x24,0xe0,0x00,0x00, + 0x00,0xf3,0x44,0x0f,0x6f,0x9c,0x24,0xf0,0x00,0x00,0x00,0xf3,0x44,0x0f,0x6f,0xa4, + 0x24,0x00,0x01,0x00,0x00,0xf3,0x44,0x0f,0x6f,0xac,0x24,0x10,0x01,0x00,0x00,0xf3, + 0x44,0x0f,0x6f,0xb4,0x24,0x20,0x01,0x00,0x00,0xf3,0x44,0x0f,0x6f,0xbc,0x24,0x30, + 0x01,0x00,0x00,0x48,0x89,0xec,0x5d,0x48,0x8d,0xa4,0x24,0x80,0x00,0x00,0x00,0x8b, + 0x50,0x08,0xf7,0xc2,0x00,0x00,0x00,0x20, +}; +static const unsigned char spartanport_switch_stub_dpad_entry[24] = { + 0x41,0x53,0x49,0xbb,0x11,0x11,0x11,0x11,0x11,0x11,0x11,0x11,0x49,0x89,0x7b,0x08, + 0x41,0x5b,0x55,0x48,0x89,0xe5,0x53,0x50, +}; + +typedef struct { + const char *name; + SpartanSignature site; + unsigned length; /* bytes replaced (jmp rel32 + nops) */ + const unsigned char *original; + const unsigned char *stub; + unsigned stub_length; + int state_at, call_at, pass_at; /* placeholders in the stub, -1 for none */ +} SpartanSwitchSite; +static const unsigned char spartanport_switch_input_original[] = {0x55, 0x48,0x89,0xe5, 0x41,0x57}; +static const unsigned char spartanport_switch_queue_original[] = {0x8b,0x50,0x08, 0xf7,0xc2,0x00,0x00,0x00,0x20}; +static const unsigned char spartanport_switch_dpad_original[] = {0x55, 0x48,0x89,0xe5, 0x53, 0x50}; +enum { SPARTAN_SWITCH_INPUT, SPARTAN_SWITCH_QUEUE, SPARTAN_SWITCH_DPAD }; +static const SpartanSwitchSite spartanport_switch_sites[SPARTAN_SWITCH_SITES] = { + {"input check", {"55 48 89 e5 41 57 41 56 41 54 53 49 89 ce 49 89 d4 49 89 ff 31 ff 4d 85 f6 74 04 49 8b 7e 08 41 0f b6 4c 24 30 48 85 c9", 0}, + 6, spartanport_switch_input_original, spartanport_switch_stub_input_entry, sizeof(spartanport_switch_stub_input_entry), -1, 165, 289}, + {"input queue", {"48 63 c8 48 8b 84 cd 50 fe ff ff 48 85 c0 0f 84 ?? ?? ?? ?? 8b 50 08 f7 c2 00 00 00 20 4c 89 f7 74 31", 20}, + 9, spartanport_switch_queue_original, spartanport_switch_stub_queue_site, sizeof(spartanport_switch_stub_queue_site), -1, 251, -1}, + {"d-pad select", {"55 48 89 e5 53 50 48 89 fb 48 8d 05 ?? ?? ?? ?? 83 38 00 0f 85 ?? ?? ?? ?? b8 03 01 00 00 c4 e2 78 f7 83 68 01 00 00", 0}, + 6, spartanport_switch_dpad_original, spartanport_switch_stub_dpad_entry, sizeof(spartanport_switch_stub_dpad_entry), 4, -1, -1}, +}; +/* Where a passed input check goes on: the tail jump of 0xeb290 (at, its rel32). */ +static const SpartanSignature spartanport_switch_pass_site = + {"4c 89 ff 4c 89 e6 4c 89 f2 5b 41 5c 41 5e 41 5f 5d e9 ?? ?? ?? ?? 31 c0 5b 41 5c 41 5e 41 5f 5d c3", 18}; + +/* The input check's call in 0xe3910 (at, its return). */ +static const SpartanSignature spartanport_switch_caller_site = + {"48 89 bd 30 fe ff ff 48 8b bd 38 fe ff ff 4c 89 e2 48 8b 8d 28 fe ff ff e8 ?? ?? ?? ?? 85 c0 74 6b", 0x1d}; + +/* The stub, then jmp [rip] back to the game after the replaced bytes. */ +static inline unsigned spartanport_switch_build_stub(unsigned char *out, const SpartanSwitchSite *site, uintptr_t state, + uintptr_t call, uintptr_t pass, uintptr_t resume) { + static const unsigned char jump[] = {0xff,0x25,0,0,0,0}; /* jmp [rip+0] */ + memcpy(out, site->stub, site->stub_length); + if (site->state_at >= 0) memcpy(out + site->state_at, &state, 8); + if (site->call_at >= 0) memcpy(out + site->call_at, &call, 8); + if (site->pass_at >= 0) memcpy(out + site->pass_at, &pass, 8); + memcpy(out + site->stub_length, jump, sizeof(jump)); + memcpy(out + site->stub_length + sizeof(jump), &resume, 8); + return site->stub_length + sizeof(jump) + 8; +} + +/* A branch to a weapon's switch attack, as the game's data has them all. */ +static inline int spartanport_switch_branch(const unsigned char *b) { + return b[0x30] == 1 && (b[0x31] == 1 || b[0x31] == 4) && b[0x2e] == 0x0f && b[0x2f] >= 0x10 && b[0x2f] <= 0x13 && + b[0x2c] == 0xff && b[0x2d] == 0xff && b[0x10] == 0xaa && b[0x11] == 0xaa; +} +#endif diff --git a/tests/test_pad.c b/tests/test_pad.c index 8e002158..86e07b25 100644 --- a/tests/test_pad.c +++ b/tests/test_pad.c @@ -47,7 +47,112 @@ static int touch_at(const PadData *d, int x) { } static void pump(void) { SDL_UpdateJoysticks(); SDL_UpdateGamepads(); } +/* Switch attacks: move data in a buffer the scan may read, his stats, and the pad's requests. */ +static uint8_t *switch_data; +int runtime_memory_is_mapped(uintptr_t address, uint64_t size) { + const uintptr_t lo=(uintptr_t)switch_data; + return switch_data && address>=lo && address+size<=lo+0x20000; +} +static uint8_t *switch_put(unsigned at, uint8_t weapon, unsigned target, uint16_t window_end) { + uint8_t *b=switch_data+at; + const int32_t rel=(int32_t)target-(int32_t)at; + memcpy(b,&rel,4); + b[0x10]=b[0x11]=0xaa; b[0x2c]=b[0x2d]=0xff; b[0x2e]=0x0f; b[0x2f]=weapon; b[0x30]=b[0x31]=1; + memcpy(b+0x16,&window_end,2); + return b; +} +static void test_switch_attack(void) { + setenv("SPARTAN_SWITCH_ATTACKS","1",1); + spartanport_switch_status(1); + switch_data=aligned_alloc(0x10000,0x20000); + memset(switch_data,0,0x20000); + /* Switch attack moves: Cestus 0x100 (common) and 0x200, Blades 0x300 and 0x400, Claws 0x500. */ + const uint8_t *claws_to_cestus=switch_put(0x1000,0x10,0x100,0x3c00); + const uint8_t *claws_to_blades=switch_put(0x1038,0x12,0x300,0x3c00); /* the Claws block */ + switch_put(0x2000,0x12,0x300,0x3c00); + const uint8_t *blades_to_claws=switch_put(0x3000,0x13,0x500,0x3c00); + const uint8_t *blades_to_claws_short=switch_put(0x3100,0x13,0x500,0x3266); + switch_put(0x4000,0x10,0x100,0x3266); + const uint8_t *cestus_to_blades_short=switch_put(0x4038,0x12,0x300,0x3266); + const uint8_t *rare_to_cestus=switch_put(0x5000,0x10,0x200,0x3c00); + const uint8_t *rare_to_blades=switch_put(0x5038,0x12,0x400,0x3c00); + switch_put(0x6000,0x12,0x300,0x3c00); + switch_data[0x6000+0x31]=4; /* Cross held: found too */ + uint8_t other[0x38]={0}; + other[0x30]=other[0x31]=1; other[0x2f]=0x06; + static uint8_t stats[0x300]; + stats[0x20b]=0x12; /* the Blades in his hands */ + const uint32_t owned=1u<<15 | 1u<<17 | 1u<<18; /* Cestus, Blades, Claws */ + memcpy(stats+0x1c4,&owned,4); + spartanport_switch.stats=(uintptr_t)stats; + spartanport_switch.caller=0x1234; + + /* No request: switch branches fail (Cross alone no longer cycles), others are the game's. */ + PadData d={0}; + d.timestamp=now_us(); + apply_switch_attack(&d); + assert(spartanport_switch_input(blades_to_claws,0x1234)==0 && sw.scanned && sw.found==10); + assert(spartanport_switch_input(other,0x1234)==2 && spartanport_switch_queue(other)==other); + assert(spartanport_switch_input(blades_to_claws,0x5678)==0); /* another caller: never (no cycling) */ + assert(spartanport_switch_input(other,0x5678)==2); + assert(sw.attack[0][0]==switch_data+0x100 && sw.attack[0][1]==switch_data+0x200); + assert(sw.attack[2][0]==switch_data+0x300 && sw.attack[2][1]==switch_data+0x400 && sw.attack[3][0]==switch_data+0x500); + + /* L1 + d-pad down: the Cestus. The Blades block's branch to the Claws takes it, swapped for one + * to the Cestus's common switch attack, with the same timing. */ + d.buttons=BTN_L1; d.timestamp=now_us(); apply_switch_attack(&d); + d.buttons=BTN_L1|BTN_DOWN; d.timestamp=now_us(); apply_switch_attack(&d); + assert(sw.request==0x10); + assert(spartanport_switch_input(blades_to_claws,0x1234)==1); + assert(spartanport_switch_queue(blades_to_claws)==claws_to_cestus && !sw.request); + assert(spartanport_switch_input(blades_to_claws,0x1234)==0); /* taken once */ + + /* L1 + Cross: the weapon in his hands, the Blades, from the Claws block's branch to the Cestus. */ + sw.last_at=0; + d.buttons=BTN_L1; d.timestamp=now_us(); apply_switch_attack(&d); + d.buttons=BTN_L1|BTN_CROSS; d.timestamp=now_us(); apply_switch_attack(&d); + assert(sw.request==SWITCH_CURRENT && spartanport_switch_input(claws_to_cestus,0x1234)==1); + assert(spartanport_switch_queue(claws_to_cestus)==claws_to_blades && !sw.request); + /* The same weapon again within SWITCH_REPEAT_US: held back; another weapon is not. */ + d.buttons=BTN_L1; apply_switch_attack(&d); + d.buttons=BTN_L1|BTN_CROSS; d.timestamp=now_us(); apply_switch_attack(&d); + assert(spartanport_switch_input(claws_to_cestus,0x1234)==0 && spartanport_switch_queue(claws_to_cestus)==claws_to_cestus); + d.buttons=BTN_L1; apply_switch_attack(&d); + d.buttons=BTN_L1|BTN_RIGHT; d.timestamp=now_us(); apply_switch_attack(&d); + assert(spartanport_switch_input(blades_to_claws,0x1234)==1 && spartanport_switch_queue(blades_to_claws)==blades_to_claws); + sw.last_at=0; + /* A branch with the short timing gets one with the short timing. */ + d.buttons=BTN_L1; apply_switch_attack(&d); + d.buttons=BTN_L1|BTN_CROSS; d.timestamp=now_us(); apply_switch_attack(&d); + assert(spartanport_switch_queue(blades_to_claws_short)==cestus_to_blades_short); + /* The rare kind leads to the rare kind; a branch to the requested weapon stays. */ + sw.last_at=0; + d.buttons=BTN_L1; apply_switch_attack(&d); + d.buttons=BTN_L1|BTN_DOWN; d.timestamp=now_us(); apply_switch_attack(&d); + assert(spartanport_switch_queue(rare_to_blades)==rare_to_cestus); + sw.last_at=0; + d.buttons=BTN_L1; apply_switch_attack(&d); + d.buttons=BTN_L1|BTN_RIGHT; d.timestamp=now_us(); apply_switch_attack(&d); + assert(spartanport_switch_queue(blades_to_claws)==blades_to_claws && !sw.request); + + /* Not owned (the Whip, d-pad left), or without L1: nothing. A request no move takes lapses. */ + d.buttons=BTN_L1; apply_switch_attack(&d); + d.buttons=BTN_L1|BTN_LEFT; d.timestamp=now_us(); apply_switch_attack(&d); + assert(sw.request==0x11 && spartanport_switch_input(blades_to_claws,0x1234)==0 && spartanport_switch_queue(blades_to_claws)==blades_to_claws); + d.buttons=0; d.timestamp+=SWITCH_REQUEST_US+1000; apply_switch_attack(&d); + assert(!sw.request); + d.buttons=BTN_DOWN; d.timestamp=now_us(); apply_switch_attack(&d); + assert(!sw.request); + + spartanport_switch.stats=0; + free(switch_data); + switch_data=NULL; + setenv("SPARTAN_SWITCH_ATTACKS","0",1); + sw.loaded=0; +} + int main(void) { + test_switch_attack(); const char *tmp=getenv("TMPDIR") ? getenv("TMPDIR") : getenv("TEMP") ? getenv("TEMP") : "/tmp"; char path[1024]; snprintf(path,sizeof(path),"%s/spartanport-pad-test-XXXXXX",tmp); @@ -242,5 +347,5 @@ int main(void) { assert(SDL_DetachVirtualJoystick(id)); SDL_Quit(); unlink(path); - puts("PASS: pad ABI, staged left/right clicks, L1 chord, engine frame rate combo, SDL touch coordinates, overlay capture"); + puts("PASS: pad ABI, staged left/right clicks, L1 chord, engine frame rate combo, SDL touch coordinates, overlay capture, switch attacks"); } diff --git a/tests/test_runner_parity_doc.py b/tests/test_runner_parity_doc.py index 381f3123..2b10e49f 100644 --- a/tests/test_runner_parity_doc.py +++ b/tests/test_runner_parity_doc.py @@ -13,7 +13,7 @@ FILES = [ 'test_run_isa.py', 'test_patches.py', 'test_mods.py', 'test_mod_conflicts.py', 'test_glyphs.py', 'test_content.py', 'test_guest_memory.py', 'test_graphics_options.py', 'test_save_backup.py', 'test_pause_menu.py', 'test_native_cheats.py', 'test_legacy.py', - 'test_discord_presence.py', 'test_game_update.py', + 'test_discord_presence.py', 'test_game_update.py', 'test_switch_text.py', ] # Mechanics with no Rust equivalent, each named by its test (or class) and explained in its row: diff --git a/tests/test_switch_hook.cpp b/tests/test_switch_hook.cpp new file mode 100644 index 00000000..061c8711 --- /dev/null +++ b/tests/test_switch_hook.cpp @@ -0,0 +1,166 @@ +// Runs the switch-attack stubs (src/switch_hook.h) as the game reaches them: the input check asks +// the pad's C function about branches to switch attacks only, and goes on, fails or runs the +// game's check as it answers; the queue site lets it swap the branch; the d-pad select notes the +// stats. With the prepared eboot.elf as argument it also finds the sites. +#include +#include +#include +#include +#include +#include +#include +#include +#include "../src/switch_hook.h" + +// Volatile: only the stubs call the fake functions, which the compiler cannot see. +static volatile int input_answer; +static const unsigned char* volatile input_seen; +static volatile uintptr_t input_caller; +static const unsigned char* volatile queue_swap; +static const unsigned char* volatile queue_seen; +extern "C" __attribute__((sysv_abi)) int fake_input(const unsigned char* branch, uintptr_t caller) { + input_seen = branch; + input_caller = caller; + return input_answer; +} +extern "C" __attribute__((sysv_abi)) const unsigned char* fake_queue(const unsigned char* branch) { + queue_seen = branch; + return queue_swap ? queue_swap : branch; +} + +// Landings: the game's own input check (after the replayed push rbp; mov rbp,rsp; push r15), +// the rest of the conditions (rsi branch, rdx the fourth argument), and the queue site's resume. +extern "C" void land_original(); +extern "C" void land_pass(); +extern "C" void land_queue(); +extern "C" void land_dpad(); // after push rbp; mov rbp,rsp; push rbx; push rax +extern "C" uint64_t pass_rsi, pass_rdx, queue_rdx, queue_regs_ok; +// run_queue(stub, branch): rax = branch, rcx/rsi/rdi/r8 set, jump to the stub; returns rax at the +// landing (the branch that goes on), edx and whether the registers came through. +extern "C" uint64_t run_queue(unsigned char* stub, const void* branch); +asm(".data\n.globl pass_rsi\npass_rsi: .quad 0\n.globl pass_rdx\npass_rdx: .quad 0\n" + ".globl queue_rdx\nqueue_rdx: .quad 0\n.globl queue_regs_ok\nqueue_regs_ok: .quad 0\n.text\n" + ".globl land_original\nland_original:\n pop %r15\n pop %rbp\n mov $0x77, %eax\n ret\n" + ".globl land_dpad\nland_dpad:\n pop %rax\n pop %rbx\n pop %rbp\n mov $0x77, %eax\n ret\n" + ".globl land_pass\nland_pass:\n mov %rsi, pass_rsi(%rip)\n mov %rdx, pass_rdx(%rip)\n mov $0x55, %eax\n ret\n" + ".globl run_queue\nrun_queue:\n" + " push %rbx\n push %r12\n sub $8, %rsp\n mov %rsi, %rax\n mov $0x1c1c, %ecx\n mov $0x5e5e, %esi\n" + " mov $0xd1d1, %r12d\n mov %rdi, %rbx\n mov $0xd1d1, %edi\n mov $0x8888, %r8d\n jmp *%rbx\n" + ".globl land_queue\nland_queue:\n" + " mov %rdx, queue_rdx(%rip)\n movq $0, queue_regs_ok(%rip)\n" + " cmp $0x1c1c, %rcx\n jne 1f\n cmp $0x5e5e, %rsi\n jne 1f\n cmp $0xd1d1, %rdi\n jne 1f\n" + " cmp $0x8888, %r8\n jne 1f\n cmp $0xd1d1, %r12\n jne 1f\n movq $1, queue_regs_ok(%rip)\n" + "1:\n add $8, %rsp\n pop %r12\n pop %rbx\n ret\n"); + +__attribute__((noinline)) static uint64_t call_input(unsigned char* stub, const void* branch, uint64_t rcx, uint64_t* rdi_after, + uint64_t* rcx_after) { + uint64_t result; + uint64_t rdi = 0xd1d1; + uint64_t rsi = 0x5151; + asm volatile("call *%[stub]" + : "=a"(result), "+D"(rdi), "+S"(rsi), "+d"(branch), "+c"(rcx) + : [stub] "r"(stub) + : "r8", "r9", "r10", "r11", "xmm0", "xmm1", "xmm2", "xmm3", "xmm4", "xmm5", "xmm6", "xmm7", + "xmm8", "xmm9", "xmm10", "xmm11", "xmm12", "xmm13", "xmm14", "xmm15", "memory", "cc"); + *rdi_after = rdi; + *rcx_after = rcx; + return result; +} + +int main(int argc, char** argv) { + alignas(16) static SpartanSwitchState state; + auto* code = static_cast(mmap(nullptr, SPARTAN_SWITCH_SITES * SPARTAN_SWITCH_SLOT, + PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0)); + assert(code != MAP_FAILED); + unsigned char* input = code; + unsigned char* queue = code + SPARTAN_SWITCH_SLOT; + unsigned char* dpad = code + 2 * SPARTAN_SWITCH_SLOT; + const uintptr_t s = reinterpret_cast(&state); + assert(spartanport_switch_build_stub(input, &spartanport_switch_sites[SPARTAN_SWITCH_INPUT], s, reinterpret_cast(&fake_input), + reinterpret_cast(&land_pass), reinterpret_cast(&land_original)) <= + SPARTAN_SWITCH_SLOT); + assert(spartanport_switch_build_stub(queue, &spartanport_switch_sites[SPARTAN_SWITCH_QUEUE], s, reinterpret_cast(&fake_queue), 0, + reinterpret_cast(&land_queue)) <= SPARTAN_SWITCH_SLOT); + assert(spartanport_switch_build_stub(dpad, &spartanport_switch_sites[SPARTAN_SWITCH_DPAD], s, 0, 0, + reinterpret_cast(&land_dpad)) <= SPARTAN_SWITCH_SLOT); + + // A switch branch as the game's data has them (to the Blades), another one, and an ordinary one. + alignas(16) static unsigned char to_blades[0x38], to_claws[0x38], other[0x38]; + for (unsigned char* b : {to_blades, to_claws}) { + b[0x10] = b[0x11] = 0xaa; + b[0x2c] = b[0x2d] = 0xff; + b[0x2e] = 0x0f; + b[0x30] = b[0x31] = 1; + } + to_blades[0x2f] = 0x12; + to_claws[0x2f] = 0x13; + to_blades[8] = 0x11; // the flags the queue site tests (bit 29 clear) + to_claws[11] = 0x20; // bit 29 set + assert(spartanport_switch_branch(to_blades) && spartanport_switch_branch(to_claws) && !spartanport_switch_branch(other)); + other[0x30] = other[0x31] = 1; + other[0x2f] = 0x06; // Cross just pressed, for another item + uint64_t rdi, rcx; + + // Input check: ordinary branches never reach the C function. + input_seen = nullptr; + assert(call_input(input, other, 0xc0c0, &rdi, &rcx) == 0x77 && !input_seen && rdi == 0xd1d1 && rcx == 0xc0c0); + // A switch branch: the answer decides. + input_answer = 1; + assert(call_input(input, to_blades, 0xc0c0, &rdi, &rcx) == 0x55 && input_seen == to_blades); + assert(input_caller > reinterpret_cast(&call_input) && + input_caller < reinterpret_cast(&call_input) + 0x200); // its return address + assert(pass_rsi == reinterpret_cast(to_blades) && pass_rdx == 0xc0c0 && rdi == 0xd1d1); + input_answer = 0; + assert(call_input(input, to_blades, 0xc0c0, &rdi, &rcx) == 0); + to_blades[0x31] = 4; // Cross held: a switch branch too + assert(call_input(input, to_blades, 0xc0c0, &rdi, &rcx) == 0 && input_seen == to_blades); + to_blades[0x31] = 2; // Cross released: not one + input_seen = nullptr; + assert(call_input(input, to_blades, 0xc0c0, &rdi, &rcx) == 0x77 && !input_seen); + to_blades[0x31] = 1; + input_answer = 2; + assert(call_input(input, to_blades, 0xc0c0, &rdi, &rcx) == 0x77 && rdi == 0xd1d1 && rcx == 0xc0c0); + + // Queue site: switch branches go through the C function, which may swap them. + queue_seen = nullptr; + assert(run_queue(queue, other) == reinterpret_cast(other) && !queue_seen && queue_regs_ok); + queue_swap = nullptr; + assert(run_queue(queue, to_blades) == reinterpret_cast(to_blades) && queue_seen == to_blades && + queue_regs_ok && queue_rdx == 0x11); + queue_swap = to_claws; + assert(run_queue(queue, to_blades) == reinterpret_cast(to_claws) && queue_regs_ok && + queue_rdx == 0x20000000); + + // d-pad select: the stats noted, the function goes on. + { + alignas(16) static unsigned char stats[0x300]; + uint64_t r; + register void* rdi_r asm("rdi") = stats; + asm volatile("call *%[s]" : "=a"(r), "+r"(rdi_r) : [s] "r"(dpad) : "rcx", "rdx", "rsi", "r8", "r9", "r10", "r11", "memory", "cc"); + assert(r == 0x77 && state.stats == reinterpret_cast(stats) && rdi_r == stats); + } + + if (argc > 1) { + // The code segment starts at file offset 0x4000 (image offset 0). + std::ifstream file(argv[1], std::ios::binary); + std::vector data((std::istreambuf_iterator(file)), {}); + assert(data.size() > 0x4000); + const unsigned char* image = data.data() + 0x4000; + const uint64_t size = data.size() - 0x4000; + for (const SpartanSwitchSite& site : spartanport_switch_sites) { + const uint64_t at = spartanport_signature_find(image, size, &site.site); + assert(at != UINT64_MAX && !memcmp(image + at, site.original, site.length)); + std::printf("Switch site %s 0x%llx\n", site.name, static_cast(at)); + } + const uint64_t caller = spartanport_signature_find(image, size, &spartanport_switch_caller_site); + assert(caller != UINT64_MAX && image[caller - 5] == 0xe8); + std::printf("Input check called back to 0x%llx\n", static_cast(caller)); + const uint64_t pass_at = spartanport_signature_find(image, size, &spartanport_switch_pass_site); + assert(pass_at != UINT64_MAX); + int32_t rel; + memcpy(&rel, image + pass_at, 4); + std::printf("Input check goes on at 0x%llx\n", static_cast(pass_at + 4 + rel)); + } + munmap(code, SPARTAN_SWITCH_SITES * SPARTAN_SWITCH_SLOT); + std::puts("PASS: switch attack hooks"); +} diff --git a/tests/test_switch_text.py b/tests/test_switch_text.py new file mode 100644 index 00000000..e707fcd1 --- /dev/null +++ b/tests/test_switch_text.py @@ -0,0 +1,75 @@ +"""Weapon Switching tutorials for the switch attacks (scripts/switch_text.py) on a synthetic WAD.""" +import struct +import tempfile +import unittest +from pathlib import Path + +from paths import ROOT # noqa: F401 (puts scripts/ on the path) +import switch_text +from glyphs import entries + + +def entry(name, payload=b'', tag=1): + return (struct.pack('