Entry 297
Gorre's harness: the brothers' orbit, Order and ChangeAction checked against the game
Documented in Bosses - Gorre.
Filescrates/piney-battle/src/boss/gorre.rscrates/piney-battle/src/boss/gorre/brother.rscrates/piney-battle/src/boss.rscrates/piney-battle/examples/battle_probe/gorre.rscrates/piney-battle/examples/battle_probe/main.rscrates/piney-gen/src/manifest.rscrates/piney-data/src/tables/combat.rstools/test_battle_gorre_rs.py
Gorre (ccBoss05 + ccBoss05Brother, 296) went into the last session
without a harness. Built one this session: tools/test_battle_gorre_rs.py
(from test_battle_fidchell_rs.py's own pattern) and
crates/piney-battle/examples/battle_probe/gorre.rs (from fidchell.rs),
registered in battle_probe/main.rs. It builds ccBoss05 (which builds
both ccBoss05Brothers) with the game's own constructor at a fixed VA and
runs Main (which runs each brother's own Main under it) frame by
frame against the port, comparing Gorre's own generic ccBoss fields,
each brother's the same plus its own m_posB/id, the effects alive
(unordered - see below), the party, the calls and the menu. PINEY_VOLUME=outbreak python3 tools/test_battle_gorre_rs.py bulk N runs it.
Getting the harness to run at all surfaced several real bugs the harness alone would not have caught without decoding the functions it disagreed on:
The brothers do not turn with Gorre's heading. ccBoss05::TransPosB2W
(OUT gcmn, called once from the constructor after Init) is a plain
translate - unitMatrix, TransMatrix(mat, mat, this->pos),
ApplyMatrix(out, mat, in) - no rotation. The existing port's
brother::mov rotated the formation offset by Gorre's current heading
every frame; the game does not. m_posB (Brother::offset, +0x29400) is
a plain world-space addend, pos = master.pos + m_posB, changed only by
whatever Move or a think function explicitly adds to it that frame. This
also meant pos_p should not be set at construction (Init never calls
ccTransPosW2P, only the first Move derives it from pos) and that
body_hit_sw starts at 0, not 1 (no OnBodyHit in Init; the harness
caught both at frame -1, the state right after the constructor).
ccBoss05::ChangeAction (0x004985c0) is its own override, not the
base's: the base's ChangeAction, then br0->Order(act) and
br1->Order(act) with the same act number, then
m_actSubCount/m_actSubProccess cleared. The port had never modelled
this - it ordered brothers only from the specific attacks that already
did so by hand (on_wave, on_drain, on_tornade, on_dead), which
happen to use the same act numbers ChangeAction would have used anyway
(brother::act::WAVE == 4 == gorre::act::WAVE, and so on for
EPITAPH/EPITAPH_WAVE/DEAD/TORNADE). Every other act Gorre takes -
DATA_DRAIN_ATK, KERSE, TALK, SKILL, MAGIC, DMG0/DMG1, the
patterns the base's own ExecPatternIndex handles - never reached the
brothers at all, so they sat frozen in whatever act they last had while
Gorre fought on. Added gorre::change_action, a wrapper around
Boss::change_action that also orders both brothers, and routed every
call site through it except the constructor's first (before the brothers
exist). The base fallback in ExecPatternIndex calls
Boss::base_exec_pattern_index directly (shared code, cannot be wrapped
from inside); ordered the brothers there too, but only by comparing
act_num before and after the call.
ccBoss05Brother::Order(on) (0x0049e020) has a real switch, not a
plain ChangeAction at forbid 1: 12/21/14 keep their own act at forbid 3;
3 and 5 (Gorre's own Kerse and Talk) become act 15, an act with no case in
Think (a plain watch, same as OnThinkNeutral's default); 4 (WAVE)
also resets m_bWaveEnd and sets the anm to "ANM_xx11wave" (not ported:
OnThinkWave's own case 0 sets the same anm a moment later, so this is
harmless); 15 and 0 go to a plain neutral at forbid 1; anything else (most
of Gorre's own acts, reached only through ChangeAction's new forbidding)
falls to a default branch that sign-extends on as the act and passes
through whatever forbid/af happened to be left in $a2/$a3 by
ChangeAction's own preceding call to the base ChangeAction (which
itself ends by calling SetAnm). Traced empirically rather than by
reading SetAnm's own tail: a case with Gorre in DATA_DRAIN_ATK (18)
showed the brother's own act_forbid at 3, matching every other named
case, so the default now also uses forbid 3.
Each brother's own bossTbl row is its own, not Gorre's row 4:
ccBoss05Brother::Init calls ccGetBossParam(id ? 41 : 40). Rows 40 and
41 share row 4's HP/SP/PP/level/type but differ from row 4 and from each
other in their elemental resistance and item-drop bytes (raw row bytes
compared: 4 != 40 != 41). Wired GorreData::BROTHER_ROW = [40, 41]
through new.
OnThinkNeutral (0x0049cb50) and OnThinkEpitaph (0x0049cc50, act 12)
turn to face the master and slowly orbit it, not "hold formation facing
Gorre's heading" as the port had it: stopped or held
(this->stop/cond::HOLD), a report back (SendMessage msg 3, not
ported) and, for OnThinkNeutral only, no turn; else ccSetDirc(&dirc[2], ccGetDirc(pos_p, master.pos_p), 256) (a smoothed turn toward the master,
not a copy of Gorre's own heading), then m_posB rotated a fixed
0x3c8efa35 radians around Z (OnThinkEpitaph always turns first, then
gates only the orbit on stopped/held). This is what makes the brothers
visibly circle Gorre while it is idle. Added brother::face_master,
brother::orbit and rewrote on_neutral/on_epitaph; on_wave (a
still-unwired approximation, see below) now at least calls
face_master instead of copying Gorre's raw heading.
ccBossEffFinalPhotonFlashCreate's life is 72 frames, not the 45
carried over from WaveShock: its own Draw (0x0046f460) counts a life
(1000.0, -100.0 a Draw) down past 0 on the 11th call (ringing and
starting a 61-call countdown, the old counter reaching 60, before
m_bEnabled clears): 11 + 61 = 72. Measured by running the game's own
Create and Draw natively, the same way test_effect_lives already
does for the other kinds.
A WaveShock or FinalPhotonFlash the port fires from a custom
position/direction (on_wave's effect at each brother's place,
on_tornade's and on_kerse's at the target) went out through a bare
cx.out(Out::Effect { id: -1, .. }), never registered in Boss::effects.
The game's own _g_bossEffManager is one array shared by Gorre and both
brothers, so it would show these; the harness's "eff" comparison
(unordered - the port keeps each character's own Effects, so slot
indices never line up with the game's single array anyway) would have
silently missed them. Switched those three call sites to
Boss::effect_at (which does track), matching how every other boss's
own effect calls already work.
Added gorre_brother_anims/gorre_brother2_anims to the manifest and
regenerated combat.rs/the data store (cargo run -p piney-gen -- gen --group combat) for boss05SlaveAnmTbl1/2 (0x5ebf80/0x5ebff0, both
25-entry array(opt(cstr()), 25) like Boss05AnmTbl): the brothers had
no anm table at all before this, so change_action never set a clip on
them. brother::new now sets b.anm_tbl from the right one
(CheckSlaveID's id) and calls Boss::change_action itself (with
cx.me swapped to the brother) instead of poking act_num directly, so
the initial "ANM_ex51nut"/whatever-id-1's-own-neutral-clip-is is set the
same way the game sets it.
With all of the above, bulk 1's frame -1 (right after the constructor)
and the neutral idle that follows match exactly for a good while; the
first random case (seed 9900) now parts company around frame 91, in
Brother's own orbit: the port's heading has turned about one
ORBIT_STEP (roughly a degree) further than the game's by then, an
exact-multiple drift that looks like an off-by-one in how many times
orbit has run rather than a wrong constant, not found this session.
bulk 20 does not reach 0 mismatches. Left unfixed and documented in
docs/engine/boss-gorre.md's Checks and Unknown: OnThinkWave's and
OnThinkTornade's own state machines for the brothers (decoded down to
m_moveSpdB/m_fRad easing and their own SendMessage calls, msgs 2, 5,
6, 7, but not wired into the port, which still holds its own 90/60-frame
timers instead of waiting on the brothers), SendMessage's full switch,
OnThinkDead's exact camera math, and ccBossCam's MaxRenge (+0xD8),
whose real source was not found even for Fidchell's own harness (its
value there, 600, is not written by anything the stand-in InitBossCamera
runs or that this session could find) - dropped from the comparison for
both bosses' probes rather than left wrong.
Ran PINEY_SURVEY_ONLY=218 PINEY_SURVEY_GOD=1 PINEY_SURVEY_FRAMES=150000 cargo test --release -p piney-game outbreak_story_survey -- --ignored --nocapture after these changes: it does not reach "done" - story 218: blocks 0xe8f, stuck cycling menus in "The World - area 1 field 5" (event
218's own field), with walk_to also logging Kite stuck in two rooms
during the run. Could not cleanly compare against the pre-session code:
the data store's own format changed underfoot (gorre_brother_anims
added), so the old combat.rs panics against the now-larger store
(.hack//Outbreak's combat: ends at 49846 of 49847 wanting 4 more) rather
than giving a comparable run. Whether the stuck survey is new or
pre-existing, and whether it is even Gorre's doing rather than an
unrelated walk_to pathing issue in the same field, is open.
All of piney-battle's existing unit tests (106, including Gorre's own and
every other boss's) and piney-data's and piney-gen's pass unchanged;
cargo fmt/clippy clean on piney-battle, piney-gen, piney-data.
the orbit's one-step drift past ~90 frames; whether the harness reaches 0 mismatches once that and the Wave/Tornade/SendMessage gaps above are closed; ccBossCam's real MaxRenge source; whether the survey's stuck field-5 menu loop is Gorre's own doing, a pre-existing walk_to issue, or both.