Files
KandClaude Opus 5.5 68012b2bc3 Feat(Controls/Gameplay Enhancement): Switch Attacks On-Demand Instead of Cycled (#749)
An opt-in Controls option (GOW3_SWITCH_ATTACKS=1). L1 + 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. The d-pad alone switches as
before.

# Switch attacks on L1 + d-pad and L1 + Cross

[Video
Showcase.webm](https://github.com/user-attachments/assets/306dece5-b2e4-4bbb-865d-bbaba9c214ed)

In the game, L1 + Cross cycles to the next weapon with that weapon's
switch attack, and the d-pad switches weapons with no attack. This PR
adds an opt-in option, *Switch attacks on L1 + d-pad and L1 + Cross* on
the Controls page (`SPARTAN_SWITCH_ATTACKS=1`), off by default, that
puts every switch attack on demand:

- **L1 + a d-pad direction** switches to that weapon with its switch
attack. With the weapon already in Kratos' hands, it does its attack
again.
- **L1 + Cross** does the switch attack of the weapon in his hands
instead of cycling to the next one.
- **The same weapon's switch attack again** waits 0.8 seconds after the
last one. In the game each L1 + Cross also changed the weapon, so the
attacks' short recovery could not be repeated. Switching to another
weapon's attack is not held back.
- **The d-pad alone** switches as before.

Nothing presses buttons for the player. The game's own move system takes
the game's own branches to the switch attacks. Only the input that takes
them and the weapon they go to change:

- **The data.** Holding L1 puts Kratos in a block move. Block moves hold
ordinary "Cross just pressed" (and in a few moves "Cross held") branches
to the switch-attack moves (`MOV_*Transition`), one per weapon that
follows in the cycle. Each weapon has two switch-attack moves, a common
one and a rarer one. The switch-attack move's script selects its weapon
(`SCR_EnableWeapon`).
- **Input check hook (`0xeb290`).** Those branches answer the pad's
request instead of Cross. The game also checks them from a second loop
(`0xe9a70`, in the air among others), where they are never taken, so
Cross cycles nowhere. Every other condition of the move stays the
game's: when he can switch, ownership, the timing window.
- **Input queue hook (`0xe4063`).** The branch the move chose is swapped
for one of the game's own branches to the requested weapon's switch
attack, of the same kind and, when there is one, the same timing. The
branches are found once, by scanning the hero's move data (all 42 are
found in game: 36 for Cross just pressed, 6 for Cross held).
- **D-pad select hook (`0x6b240`).** Notes the player stats (weapon in
hand, weapons owned).
- **Stubs that call C.** The hooks are written in `tools/switch_stubs.s`
and call C functions in `runtime_pad.c` through `sysv_abi`, saving the
registers that are live at each site. `tests/test_switch_hook.cpp` runs
every stub from frames laid out like the game's, and finds the sites in
the reference eboot.
- **Tutorial text.** The Weapon Switching tutorial cards (texts 190,
283, 286) now end with "Hold [L1Button] to attack when switching
weapons. / Use [L1Button] and [XButton] to repeat switch attacks." This
covers all 16 `R_LANG_*.WAD` files, on copies mounted by `run.py` only
while the option is on (`scripts/switch_text.py`). The game folder is
never written. A language whose font lacks one of the new characters
keeps its own text.
- **More mounts.** The probe now takes up to 24 `--mount` arguments (was
8) and the runtime up to 32 mounts (was 16), so the glyphs' copies and
the 16 language copies fit together.
- **Research for whoever changes combat next.** `docs/moves.md` records
how the game picks Kratos' next move: the move and branch layouts, input
kinds, the input queue, the player stats offsets, the d-pad and weapon
functions, and the method that found them. That method was a record-only
build that logs what the move system does, described there so it can be
rebuilt.

Tested in game: L1 + Cross with each weapon, L1 + each owned direction
from each weapon (standing and mid-combo), and the d-pad alone, with the
Cestus, Claws and Blades. The log shows every request reaching the
requested weapon's switch attack. A press during a move that cannot
switch does nothing, as in the game (L1 + Triangle, then L1 + Cross,
jumps there as it does without the option). The English tutorial text
has been checked in game. German has been reviewed by a native speaker.
The other languages are my translations and would benefit from a native
check. The C++ tests pass; the Python suite fails only the tests that
fail on an unmodified main in the same environment (tkinter, network
share).

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-28 20:37:24 -03:00

7.8 KiB
Raw Permalink Blame History

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_<code>.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, *<id>* before each text (*<id>*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).