Compare commits
58 Commits
8c353c6c66
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e14d3cd063 | |||
| f4fc832ace | |||
| 6aab279244 | |||
| d3451be17b | |||
| 0300af3333 | |||
| 2c572e918f | |||
| f608dbc438 | |||
| 43c460741b | |||
| 5eed124b85 | |||
| e3ed8c298e | |||
| 5181e103dc | |||
| 433a9b22dd | |||
| 0701ac94e1 | |||
| 025122ec9a | |||
| b91e707a7e | |||
| c3d0e56f69 | |||
| e7893a0162 | |||
| a2b0c32a70 | |||
| e49c1f211c | |||
| 96f24e799a | |||
| f315f16e8e | |||
| ead0426ea0 | |||
| 74768693ec | |||
| 6bbc0eaf4f | |||
| 09bb2dc306 | |||
| 42229e5782 | |||
| d929efdffe | |||
| 98f30931a0 | |||
| a5e5628039 | |||
| 0e200758f0 | |||
| 2fc335c37d | |||
| 25b7089c54 | |||
| 8983998707 | |||
| 96610ccb16 | |||
| 704482be84 | |||
| 0997dd3b60 | |||
| 743b20b00b | |||
| e48d659bce | |||
| a86d21ec79 | |||
| f40e8587ac | |||
| 5bf2c7ddc1 | |||
| d146f9c3fc | |||
| d4a39ba75d | |||
| 9cc5188e5c | |||
| 6baa673252 | |||
| eefa98c961 | |||
| 88ae754b0f | |||
| e0f1e379b7 | |||
| 6d89d62e41 | |||
| 40e53ed02c | |||
| b55dd16a46 | |||
| 11b7991d81 | |||
| e18304d365 | |||
| 8614acff57 | |||
| 3ff09ae62c | |||
| 34be38260d | |||
| 1e0124c197 | |||
| 286a44461d |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,934 @@
|
||||
k|J|
|
||||
tz^WU
|
||||
Gb\X[
|
||||
,j|L
|
||||
cXXXX
|
||||
gzW[rp
|
||||
)l``b`
|
||||
c^^^^
|
||||
zrbnz
|
||||
r--)
|
||||
&jzzx
|
||||
Jl```
|
||||
c^^^\
|
||||
----
|
||||
k|X\^_
|
||||
c\Xxx
|
||||
K|bjk
|
||||
cxxxx
|
||||
---%
|
||||
{xx|
|
||||
cxxxz
|
||||
Frxz
|
||||
{VVVW
|
||||
cpxz~
|
||||
gr*:
|
||||
sTVUU
|
||||
cxz^W
|
||||
[5555
|
||||
px~M
|
||||
cUUU5
|
||||
cUU-
|
||||
gz((+
|
||||
&rX`
|
||||
&kVX
|
||||
cUUUx
|
||||
&r^\
|
||||
%%%%
|
||||
&kUW
|
||||
f[UUW
|
||||
gcE[
|
||||
$gcE[
|
||||
cUU%
|
||||
UUWT
|
||||
xxxx
|
||||
FsUU^
|
||||
$ecF[
|
||||
icD[
|
||||
T\Rb
|
||||
sUW|
|
||||
UUU\
|
||||
VUUU
|
||||
BIGF
|
||||
L286
|
||||
Apt Data:1:7:8
|
||||
game/globalComponents/globalComponents
|
||||
game.globalComponents.ImageLoader
|
||||
game/components/SelectTeam
|
||||
game.components.SelectTeam
|
||||
Coins
|
||||
TournamentData
|
||||
BackingFUT
|
||||
VersusFUT
|
||||
external.ion_fut.screens.futSelectTeam
|
||||
__Packages.external.ion_fut.screens.futSelectTeam
|
||||
__Packages.ion.manager.HelpProperties
|
||||
EACondBold
|
||||
10.000
|
||||
Screen
|
||||
RtIL
|
||||
RYgO
|
||||
7uOO
|
||||
XsOY
|
||||
2sOY
|
||||
<@OY
|
||||
BG&Y
|
||||
3NuIL
|
||||
7NuI
|
||||
3NuI
|
||||
3NstY
|
||||
7uOY
|
||||
&v>Y
|
||||
&v>t
|
||||
3NYN
|
||||
7NYN
|
||||
tOYZ
|
||||
BG&v
|
||||
NZGO
|
||||
NZGOZu
|
||||
uOZu
|
||||
mcCup
|
||||
txtPrizeHeading
|
||||
txtCoins
|
||||
mcCoin
|
||||
mcBacking
|
||||
txtVs
|
||||
mcTournamentInfo
|
||||
mcSelectTeam
|
||||
mcVersusFUT
|
||||
publishObject
|
||||
dpID
|
||||
nHomeKitID
|
||||
nAwayKitID
|
||||
keyCode
|
||||
controllerId
|
||||
nSide
|
||||
arrKitIDs
|
||||
teamId
|
||||
kitToResolve
|
||||
side
|
||||
isUser
|
||||
arrKits
|
||||
objProperties
|
||||
Void
|
||||
nXPos
|
||||
DDS |
|
||||
NVTT
|
||||
DXT5
|
||||
8VTTT
|
||||
UUVT
|
||||
TTTU
|
||||
0TUVT
|
||||
TTUW
|
||||
$$r
|
||||
%UUU
|
||||
WUUU
|
||||
UUUSP
|
||||
UUUM
|
||||
UUUNK
|
||||
72Ib
|
||||
*72Ib
|
||||
U;8I
|
||||
WWWW?>I
|
||||
UIFI
|
||||
WWWWLKI
|
||||
WWWVQNI
|
||||
VVYVI
|
||||
`]IB
|
||||
daIB
|
||||
heIB
|
||||
VlhI
|
||||
vtI"I
|
||||
UU%!I
|
||||
*;8I
|
||||
U?>I
|
||||
ULKI
|
||||
Apt1
|
||||
_global
|
||||
external
|
||||
Object
|
||||
ion_fut
|
||||
screens
|
||||
futSelectTeam
|
||||
futSelectTeam::futSelectTeam()
|
||||
OnExitScreen
|
||||
cafe
|
||||
utility
|
||||
Delegate
|
||||
Create
|
||||
game
|
||||
globalClasses
|
||||
ScreenManager
|
||||
SetOnExitScreenCallback
|
||||
m_nFlowState
|
||||
EA_ZONE
|
||||
gScreenFlowManager
|
||||
getFlowState
|
||||
ION_Platform
|
||||
IsFinal
|
||||
CardNotification
|
||||
eState
|
||||
FUT_OFFLINE_DRAFT
|
||||
FUT_OFFLINE_TOURNAMENT
|
||||
FUT_OFFLINE_SEASON
|
||||
m_bAllowSelectAnyTeam
|
||||
FUT/ALLOW_ANY_CPU_TEAM
|
||||
ION_Customization
|
||||
GetAardvarkIntValue
|
||||
mcPanelHome
|
||||
mcPanelAway
|
||||
mcReadyHome
|
||||
mcReadyAway
|
||||
mcKitHome
|
||||
mcKitAway
|
||||
mcLockHome
|
||||
mcLockAway
|
||||
InitComponents
|
||||
InitializeScreen
|
||||
Initialize
|
||||
screen
|
||||
BaseScreen
|
||||
prototype
|
||||
futSelectTeam::InitializeScreen()
|
||||
_visible
|
||||
HOME_SIDE
|
||||
GameServices
|
||||
eTeamSide
|
||||
SIDE_HOME
|
||||
AWAY_SIDE
|
||||
SIDE_AWAY
|
||||
NEUTRAL_SIDE
|
||||
SIDE_NEUTRAL
|
||||
m_arrPanelData
|
||||
Array
|
||||
m_arrKitPanelData
|
||||
futSelectTeam::InitComponents()
|
||||
InitializeKitConfig
|
||||
InitializeTeamConfig
|
||||
SetTeamAndKitConfigs
|
||||
UIFDataProviderList
|
||||
FUT_USER_CLUB_DATA_DP
|
||||
UIFUtility
|
||||
RegisterDataProvider
|
||||
FUT_OPPONENT_CLUBS_LIST_DP
|
||||
FUT_OPPONENTS_SQUADS_LIST_DP
|
||||
FUT_OPPONENT_SQUAD_LINEUP_DP
|
||||
FUT_USER_SQUAD_LINEUP_DP
|
||||
FUT_CREATE_MATCH_DP
|
||||
FUT_GET_MATCH_KITS_DP
|
||||
SetupReadyTexts
|
||||
initSideInfo
|
||||
SetPanels
|
||||
m_arrPanels
|
||||
m_arrKitPanels
|
||||
KitSelectDP
|
||||
TeamSetupDP
|
||||
AnimateIn
|
||||
AnimateInComplete
|
||||
BeginAnimateIn
|
||||
futSelectTeam::AnimateInComplete()
|
||||
m_bHasAnimatedIn
|
||||
checkForDisconnect
|
||||
gScreenNotAborted
|
||||
LocalEventHandler
|
||||
InputManager
|
||||
AddLocalEventHandler
|
||||
SetHandlerId
|
||||
refreshCurrentConnectionStatus
|
||||
HelpManager
|
||||
Update
|
||||
futSelectTeam::OnExitScreen()
|
||||
AnimationManager
|
||||
ClearAnimations
|
||||
UnregisterDataProvider
|
||||
INJURY_POPUP_ID
|
||||
PopupManager
|
||||
DeletePopup
|
||||
TOTW_BELOW_MIN_POPUP_ID
|
||||
USER_BELOW_MIN_POPUP_ID
|
||||
OPP_BELOW_MIN_POPUP_ID
|
||||
OPP_HAS_NO_VALID_SQUADS_ID
|
||||
Shutdown
|
||||
ClearSavedOpponentData
|
||||
SQUAD_ID
|
||||
UUID_UPPER
|
||||
UUID_LOWER
|
||||
UIFActionList
|
||||
ACTION_SAVE_OPPONENT_DATA
|
||||
SendActionObj
|
||||
Publish
|
||||
futSelectTeam::Publish()
|
||||
header
|
||||
USER_CLUB_DATA
|
||||
SetUserClubData
|
||||
initVersusFUTComponents
|
||||
OPPONENT_CLUBS
|
||||
m_arrOpponentClubs
|
||||
data
|
||||
MATCH_CREATED
|
||||
FUT_PAFC_GAME
|
||||
GetCurrentCountryIndex
|
||||
SQUADS
|
||||
GetCurrentLeagueIndex
|
||||
ACTION_ADVANCE
|
||||
SendAction
|
||||
eSoundEvent
|
||||
PRIMARY_SELECT
|
||||
playSound
|
||||
m_bShouldWaitForPublish
|
||||
OPP_SQUADS_LIST
|
||||
SetOpponentSquadListData
|
||||
SQUAD_LINEUP_LOADED
|
||||
IS_USER
|
||||
SetSquadLineup
|
||||
KITS_AVAILABLE
|
||||
LENGTH
|
||||
KIT_
|
||||
push
|
||||
futSelectTeam::InitializeKitConfig()
|
||||
SetupTeamsInfo
|
||||
GetHomeTeamId
|
||||
ACTION_MATCHDAY_HOME_TEAM_CHANGE
|
||||
GetAwayTeamId
|
||||
ACTION_MATCHDAY_AWAY_TEAM_CHANGE
|
||||
ACTION_MATCHDAY_ADVANCE_KIT_SETUP
|
||||
SetReadyStatus
|
||||
FadeOut
|
||||
GetKitArrayForFUT
|
||||
HOME_KIT_ID
|
||||
AWAY_KIT_ID
|
||||
ION_Uniform
|
||||
IsKitSelectCreated
|
||||
EnterKitSelect
|
||||
IsAlternatingMode
|
||||
GetUnhighlightedSide
|
||||
SetKitUnReady
|
||||
InitializeKitsFromArray
|
||||
Unhighlight
|
||||
SetDisabled
|
||||
SetHighlightedSide
|
||||
GetHighlightedSide
|
||||
Highlight
|
||||
futSelectTeam::InitializeTeamConfig()
|
||||
LEAGUE_ID
|
||||
components
|
||||
TeamSetupControl
|
||||
TEAM_TOGGLE
|
||||
GetUserSideForFUT
|
||||
m_isInFUT
|
||||
InitData
|
||||
GetToggleValue
|
||||
UpdateTeamInfo
|
||||
m_bOpponentTeamInvalid
|
||||
m_OppHasSquads
|
||||
SetChemistryValue
|
||||
ResetTeamInfo
|
||||
FadeIn
|
||||
SetupMouseSupport
|
||||
SetWomenTeamsOnlyFilter
|
||||
SetMenTeamsOnlyFilter
|
||||
DeactivateReady
|
||||
futSelectTeam::SetupTeamsInfo()
|
||||
USER_TEAM_ID
|
||||
ION_GameSetup
|
||||
GetTeam
|
||||
SetHomeTeamId
|
||||
SetAwayTeamId
|
||||
setCustomSelectionArray
|
||||
Team
|
||||
eAttribute
|
||||
ION_Team
|
||||
GetAttributes
|
||||
futSelectTeam::LocalEventHandler()
|
||||
WARNING: Preventing the user to move until a Publish occurs.
|
||||
IsInTransition
|
||||
Stop spamming buttons, the team select screen is in a transition.
|
||||
GetUserControllerSide
|
||||
GetScreenState
|
||||
DataProviders
|
||||
STATE_TEAM
|
||||
InputCodes
|
||||
LEFT
|
||||
RIGHT
|
||||
GetReadyStatus
|
||||
DOWN
|
||||
BACK
|
||||
ADVANCE
|
||||
OPTION_TOP
|
||||
OPTION_LEFT
|
||||
IsSwitchSidesActive
|
||||
STATE_KIT
|
||||
SetUniform
|
||||
ExitKitSelect
|
||||
RemoveKitLocks
|
||||
ACTION_BACKOUT
|
||||
CANCEL
|
||||
SetKit
|
||||
SaveKitsForMatch
|
||||
FUT_TOTW_GAME
|
||||
SetGoingToKickoffHub
|
||||
SetHomeKitId
|
||||
SetAwayKitId
|
||||
GetHomeKitId
|
||||
GetAwayKitId
|
||||
ACTION_CREATE_MATCH
|
||||
SetReady
|
||||
SetKitReady
|
||||
FUT_OPP_HAS_NO_VALID_SQUADS
|
||||
PopupData
|
||||
Okay_abbr2
|
||||
AddButton
|
||||
ShowPopup
|
||||
ValidateFullLineUp
|
||||
m_sInjuryOrSuspendedWarning
|
||||
m_bConceptPlayersInSquad
|
||||
FUT_DB_Players_Not_Playable
|
||||
FUT_TOTW_BELOW_MIN_PLAYERS
|
||||
FUT_BELOW_MIN_PLAYERS
|
||||
FUT_OPP_BELOW_MIN_PLAYERS
|
||||
COUNTRY_TOGGLE
|
||||
LEAGUE_TOGGLE
|
||||
ACTION_GET_USER_SQUAD_LINEUP
|
||||
ACTION_GET_OPPONENT_SQUAD_LINEUP
|
||||
GoToViewSquad
|
||||
PlatformManager
|
||||
IsMicrosoft
|
||||
USER_NAME
|
||||
length
|
||||
gEaso
|
||||
showGamercard
|
||||
getHelpContext
|
||||
futSelectTeam::getHelpContext()
|
||||
STATE_INVALID
|
||||
FUT_VIEW_SQUAD_HOME
|
||||
ltxt
|
||||
manager
|
||||
HelpItem
|
||||
CreateHelpItem
|
||||
FUT_VIEW_SQUAD_AWAY
|
||||
ViewGamerCard
|
||||
CreateHelpTickerItem
|
||||
futSelectTeam::InitializeKitsFromArray()
|
||||
GetAllAttributes
|
||||
TYPE_UPPER
|
||||
ITEM_NAME
|
||||
ITEM_ID
|
||||
ASSET_ID
|
||||
StyleManager
|
||||
FONT_TILE_HS
|
||||
SetTitleTextFormat
|
||||
SetToggleOffset
|
||||
globalComponents
|
||||
BasePanel
|
||||
STYLE_FIFTEEN
|
||||
SetBasePanelStyle
|
||||
KIT_SCALE
|
||||
kits
|
||||
ToggleWithImage
|
||||
STYLE_TOGGLE
|
||||
SetStrokeVisibility
|
||||
CheckIsKitLocked
|
||||
futSelectTeam::GetKitArrayForFUT()
|
||||
GetNonConflictingUniformID
|
||||
eSortType
|
||||
SORT_ASCENDING
|
||||
Uniform
|
||||
eSortColumn
|
||||
SORT_NONE
|
||||
eFilter
|
||||
FILTER_UNFILTERED
|
||||
GetIDs
|
||||
LOCKED
|
||||
NAME
|
||||
shift
|
||||
futSelectTeam::initVersusFUTComponents()
|
||||
text
|
||||
Versus_abbr
|
||||
_height
|
||||
FUT_Tournament
|
||||
GetOfflineActiveTournamentId
|
||||
GetOfflineTournamentInfo
|
||||
TROPHY_ID
|
||||
trophy
|
||||
getArtAssetPath
|
||||
SCALE_ASPECT_CENTER
|
||||
setScaling
|
||||
setSize
|
||||
setImage
|
||||
FUT_UC_TOURNAMENT_BONUS
|
||||
PRIZE_FINAL
|
||||
ION_Localization
|
||||
LocalizeInteger
|
||||
_width
|
||||
textWidth
|
||||
FUT_COINS_OFFSET
|
||||
futSelectTeam::GoToViewSquad()
|
||||
isUserTeam
|
||||
CLUB_NAME
|
||||
BADGE_TEAM_ID
|
||||
SQUAD_NAME
|
||||
RATING
|
||||
SQUAD_RATING
|
||||
CHEMISTRY
|
||||
SQUAD_CHEMISTRY
|
||||
SHOW_CHEM_LINE
|
||||
SCREEN
|
||||
VIEW_SQUADS
|
||||
setContextDataObject
|
||||
loadOverlayScreen
|
||||
futSelectTeam::SetUserClubData()
|
||||
m_arrUserClubs
|
||||
PUBLIC
|
||||
CLUB_ABBR
|
||||
EST_DATE
|
||||
ACTIVE_SQUAD_ID
|
||||
SIDE_NAME
|
||||
Away_Side
|
||||
Home_Side
|
||||
futSelectTeam::SetOpponentSquadListData()
|
||||
split
|
||||
FUT_NO_VALID_SQUADS
|
||||
futSelectTeam::SetSquadLineup()
|
||||
SetTeam
|
||||
futSelectTeam::GetCurrentCountryIndex()
|
||||
futSelectTeam::GetCurrentLeagueIndex()
|
||||
futSelectTeam::GetUserSideForFUT()
|
||||
bIsDemo
|
||||
GetLockRules
|
||||
SIDE_LOCK
|
||||
futSelectTeam::ValidateFullLineUp()
|
||||
FUT_SquadManagement
|
||||
GetOpponentSquadLineup
|
||||
GetSquadLineup
|
||||
FUT_NUM_PLAYERS_IN_SQUAD
|
||||
CARD_ID
|
||||
ION_Card
|
||||
GetPlayerCardInfo
|
||||
IS_DREAM_PLAYER
|
||||
FUT_NUM_PLAYERS_IN_SQUAD_EXTENDED
|
||||
gFutHelpers
|
||||
GetInjuryOrSuspendedSquadWarning
|
||||
futSelectTeam::SaveKitsForMatch()
|
||||
SIDE
|
||||
NUM_KITS
|
||||
ACTION_SAVE_MATCH_KIT
|
||||
FUT_TOURNAMENT_CUP_SCALE
|
||||
INJURY_OR_SUSPENDED_POPUP
|
||||
TOTW_NUM_PLAYERS_BELOW_MIN_POPUP
|
||||
USER_NUM_PLAYERS_BELOW_MIN_POPUP
|
||||
OPP_NUM_PLAYERS_BELOW_MIN_POPUP
|
||||
OPP_HAS_NO_VALID_SQUADS
|
||||
SCALE_NONE
|
||||
SCALE_ASPECT
|
||||
SCALE_ABSOLUTE
|
||||
ASSetPropFlags
|
||||
HelpProperties
|
||||
mXPos
|
||||
GetXPos
|
||||
SetXPos
|
||||
registerClass
|
||||
hj\W
|
||||
hj/U
|
||||
UUUV
|
||||
'Z`XV
|
||||
j`XVW
|
||||
b$9^
|
||||
UW^}
|
||||
hb>x
|
||||
DA__\X
|
||||
UWW^
|
||||
HbCA
|
||||
hjCA/
|
||||
+{dA
|
||||
b#9X7*
|
||||
I^^|x
|
||||
(Z``pP
|
||||
Zxp`
|
||||
\j~X
|
||||
&j\xp
|
||||
jXXXX
|
||||
Fn%Vb=
|
||||
xxxp
|
||||
xh``
|
||||
WWWW
|
||||
r\XXX
|
||||
xxz_
|
||||
{XPpr
|
||||
Hb__^\
|
||||
`x|x
|
||||
``xX
|
||||
]{jp
|
||||
xxhh
|
||||
X\\\
|
||||
U\T_
|
||||
`pz~
|
||||
_^^^
|
||||
cq,6
|
||||
-/+*+
|
||||
jjjj
|
||||
jJJj
|
||||
_^Xp
|
||||
WV\p
|
||||
TTWU
|
||||
```h
|
||||
pWUU
|
||||
\UUU
|
||||
$I">
|
||||
x^UU
|
||||
/UUU
|
||||
$IR`m
|
||||
$IL#
|
||||
cAxW
|
||||
dI/U
|
||||
&b%W
|
||||
|UWV\
|
||||
j_uyQ
|
||||
|UUVT
|
||||
|WT\\
|
||||
j`ppp
|
||||
|\\\\
|
||||
bpppp
|
||||
)---
|
||||
||x||
|
||||
bzzzz
|
||||
s"#)5
|
||||
K{&R
|
||||
D(tFR```
|
||||
j5555
|
||||
){zxh`
|
||||
hlUU_`
|
||||
$Ithd
|
||||
Ithd
|
||||
hlUWVT
|
||||
s(ljj
|
||||
HR{O
|
||||
IBww
|
||||
@Bbb
|
||||
Hl\\Xx
|
||||
zzhh
|
||||
@`pP
|
||||
Htxxxx
|
||||
hlHd
|
||||
Ht%%%5
|
||||
ZZ\\
|
||||
H|xxxx
|
||||
Hthd
|
||||
xxxX
|
||||
TV_]
|
||||
H|hd
|
||||
Xxx`
|
||||
_\|p
|
||||
pppP
|
||||
H|xxxz
|
||||
i|%%%5
|
||||
pX\T
|
||||
H|xzzz
|
||||
(dHt
|
||||
H|`xz_
|
||||
UU^p
|
||||
$G|(t
|
||||
G|(t
|
||||
(tG\
|
||||
VTTT
|
||||
kUUU5
|
||||
(pXxx
|
||||
XXXX
|
||||
KOKK
|
||||
'cUU^
|
||||
hs'c
|
||||
$Gk(c
|
||||
Gk(c
|
||||
~ZZX
|
||||
GkUWx
|
||||
GkUUU\
|
||||
'Gk(c
|
||||
p``H
|
||||
zUU~
|
||||
X`pxZ
|
||||
sUWx
|
||||
c```
|
||||
@@@@
|
||||
WVT\
|
||||
Vw~U
|
||||
_^_j
|
||||
\XPp
|
||||
^|~^
|
||||
c``pX\
|
||||
````
|
||||
UUUU
|
||||
\\\\
|
||||
????
|
||||
p~UU
|
||||
Ib'b
|
||||
X\WU
|
||||
A*++
|
||||
UUUX
|
||||
VWUU
|
||||
z^VW
|
||||
W^x
|
||||
\\\\j
|
||||
TWVV
|
||||
\^xx
|
||||
W^~
|
||||
VWVt
|
||||
cI^xxp
|
||||
k$)WWVT
|
||||
)W_VT
|
||||
$1VTVT
|
||||
(\\\\
|
||||
\\\\"
|
||||
$1\\XX
|
||||
pr`z
|
||||
AXPp`
|
||||
yU^r^
|
||||
$I2,r
|
||||
PZrC
|
||||
U{Bz
|
||||
{||Z
|
||||
kkki
|
||||
c`p^
|
||||
cx6l
|
||||
\\\\]
|
||||
dIb`@@
|
||||
pvv]
|
||||
'z@@
|
||||
XVUU
|
||||
e9`p
|
||||
UVVV
|
||||
(.-5
|
||||
JJJJ
|
||||
cQxxx
|
||||
(%-)+
|
||||
VVVV
|
||||
#9ZZxx
|
||||
i-)-
|
||||
1U_|
|
||||
#9=*
|
||||
VVTT
|
||||
T\\X
|
||||
X^__
|
||||
5-)+
|
||||
$I"'r
|
||||
rrbJ
|
||||
8)-%5
|
||||
ZZZZ
|
||||
jjjk
|
||||
1^UUU
|
||||
g1G)^
|
||||
!XX\V
|
||||
BIGF0
|
||||
Apt Data:1:5:8
|
||||
U555
|
||||
Urpp
|
||||
~B'j
|
||||
5555
|
||||
m*((
|
||||
;RRRR
|
||||
m***
|
||||
:RRRR
|
||||
`15555
|
||||
sZPPP
|
||||
`95555
|
||||
Apppp
|
||||
95555
|
||||
{PPPR
|
||||
RRRR
|
||||
rrp_
|
||||
&j2'
|
||||
pppp
|
||||
Ns%!U
|
||||
%)%%%%
|
||||
f)%!
|
||||
` 6dC.kE!
|
||||
Z%)70
|
||||
1E!W
|
||||
1E!U
|
||||
f1E!
|
||||
F1Xp*
|
||||
9f)U
|
||||
xUU\
|
||||
JV~No
|
||||
(n{$!
|
||||
iJPPpp
|
||||
<W\^
|
||||
AqUW
|
||||
{Cq_
|
||||
U]P\
|
||||
Pppp
|
||||
TTTT
|
||||
PPPp
|
||||
,(;k
|
||||
z^\x
|
||||
\^VT
|
||||
???/
|
||||
btTVV
|
||||
M{-/75
|
||||
x~_^
|
||||
TUWW
|
||||
%555
|
||||
(^xp`
|
||||
UUWV
|
||||
jR\T\\
|
||||
1xp``
|
||||
b\\\X
|
||||
b557/
|
||||
jZ'5
|
||||
WWWh
|
||||
^XPZ
|
||||
zxxxx
|
||||
1UWVT
|
||||
h4Vb%
|
||||
\^U?
|
||||
\\\X
|
||||
I*.$
|
||||
Hb'A
|
||||
9UU\
|
||||
i--+
|
||||
Wka@
|
||||
P|WWW
|
||||
UW^x
|
||||
@PW^
|
||||
czXX
|
||||
++-5
|
||||
`x@p
|
||||
brp`
|
||||
U%%%
|
||||
\VUW
|
||||
XPXX
|
||||
WTTV
|
||||
PPXX
|
||||
zc9~^z
|
||||
I"1-+
|
||||
!*+*
|
||||
TTVT
|
||||
----Y
|
||||
73 &
|
||||
Av|z
|
||||
`^UJ
|
||||
xx~p
|
||||
&jB1_
|
||||
Y444$
|
||||
xWU0
|
||||
$_nO
|
||||
G1BBBB
|
||||
xxx^
|
||||
xxz~
|
||||
---=
|
||||
***J
|
||||
O"'@
|
||||
7 '>
|
||||
U`X\Y
|
||||
h035^
|
||||
Lw!f
|
||||
&T@a
|
||||
]8RR
|
||||
[OAq
|
||||
D/Oz%F
|
||||
+1.^-
|
||||
_,_Y
|
||||
..^O
|
||||
CG|!@
|
||||
;}>|
|
||||
nHT*
|
||||
a8Nj
|
||||
?'Un70
|
||||
^[zM2Bj @
|
||||
6nd[N
|
||||
Z)MBc
|
||||
wY=A
|
||||
8p(a
|
||||
:m"D
|
||||
[dbt
|
||||
E'0S
|
||||
nT+bJuZ
|
||||
V-:t
|
||||
v)(n
|
||||
(*s?p
|
||||
cc?r
|
||||
B%{r
|
||||
-4Yi
|
||||
sci,Iy
|
||||
|3;=
|
||||
<KB6
|
||||
cCFVJ
|
||||
J|jg
|
||||
4VvVV6
|
||||
p$$e&
|
||||
4%1{
|
||||
~%ew==_.
|
||||
EFGFFED
|
||||
[FFB}9
|
||||
$"dOz%^-
|
||||
mw77
|
||||
ct3r
|
||||
ecGB
|
||||
*\JV
|
||||
c&WV
|
||||
w6GMq
|
||||
13aB
|
||||
$X~_
|
||||
mMx%
|
||||
;11sZR
|
||||
'&'n
|
||||
q&##>o
|
||||
+:Z/Y
|
||||
]:AY
|
||||
$(+~
|
||||
,^:(,
|
||||
kp>C
|
||||
luqYql
|
||||
wf_q
|
||||
XYX\
|
||||
@p77
|
||||
--X&q
|
||||
{ cf
|
||||
waF,
|
||||
znrn;
|
||||
VRwCE
|
||||
5=#&
|
||||
/J"}
|
||||
_A4Z
|
||||
gnB7%
|
||||
q`Y
|
||||
Q|!+
|
||||
[MMA
|
||||
**rV
|
||||
)~U(w*,)m
|
||||
Z*SVd
|
||||
$#&qi
|
||||
qmUW=
|
||||
F"MN
|
||||
HaA%e%
|
||||
(T!]5(\
|
||||
IK#k
|
||||
v wQ
|
||||
C(\M
|
||||
];%P
|
||||
7f&=kJ
|
||||
%(oRtK
|
||||
gRr?
|
||||
+rq_
|
||||
/==7
|
||||
,IUk
|
||||
D?t(zC,
|
||||
\}oe
|
||||
'^YY
|
||||
nwzu
|
||||
jdf,
|
||||
i5hFc
|
||||
z@xOrFp
|
||||
aqgv
|
||||
y^oT+
|
||||
dZ13
|
||||
d{g~
|
||||
ttzi
|
||||
p,@B
|
||||
upqH
|
||||
1{pl0
|
||||
J4)~
|
||||
&W<;@
|
||||
p77Es
|
||||
:aPw
|
||||
`>(^&
|
||||
lnW|
|
||||
~+w8
|
||||
@@ -0,0 +1,278 @@
|
||||
# FIFA17.exe runtime command/event id -> name registry
|
||||
# Recovered 2026-08-24 from live pid 44405 (Denuvo-decrypted, /proc/PID/mem, read-only).
|
||||
# CardsDLL live base 0x6ffffc0f0000; registration loop at live 0x147dd0000-0x147df8000.
|
||||
# NOTE: this is a DIFFERENT namespace from CardsDLL's DataProvider id table.
|
||||
# the same numeric id has a different name in each, matching the APT's split
|
||||
# between game.uif.UIFDataProviderList and the action/command list.
|
||||
#
|
||||
0x0207 %d
|
||||
0x0bb9 back
|
||||
0x0bbb preScreenSucceeded
|
||||
0x0bbc preScreenFailed
|
||||
0x0bc0 clearTeamSheets
|
||||
0x0be7 selectTab
|
||||
0x0c15 optionSelected
|
||||
0x0c2a leaveGameGroup
|
||||
0x0c2c quitToHub
|
||||
0x0dac UpdateStadiumCrests
|
||||
0x0dac startStoryMode
|
||||
0x2713 matchdayFixtureChange
|
||||
0x271a evt_set_matchDay_offline_fixture
|
||||
0x271b evt_team_setup_state
|
||||
0x271c advanceDefault
|
||||
0x271d advanceDefaultWithTeam
|
||||
0x271e advancePran
|
||||
0x271f feInitialized
|
||||
0x2720 skipBootflow
|
||||
0x2721 startBootflow
|
||||
0x2722 bootflowStarted
|
||||
0x2723 bootflowFinished
|
||||
0x2724 bootflowSaveLoadFailed
|
||||
0x2725 returnToPressStart
|
||||
0x2726 showPressStart
|
||||
0x2727 evt_load_personal_settings
|
||||
0x2728 evt_settings_load_complete
|
||||
0x2729 assetUpdate
|
||||
0x272a pranUpload
|
||||
0x272b pranDownload
|
||||
0x272c controllerConfig
|
||||
0x272d activateGameModeIntro
|
||||
0x272e ActivateFullGame
|
||||
0x272f startIntroFlow
|
||||
0x2730 offlineEulaProfileSuccess
|
||||
0x2731 offlineEulaProfileFail
|
||||
0x2732 startIntroMatch
|
||||
0x2733 abortIntroMatch
|
||||
0x2735 setCareerType
|
||||
0x2736 exitTitle
|
||||
0x2737 evt_set_fullscreen
|
||||
0x273e enterSubPanel
|
||||
0x273f exitSubPanel
|
||||
0x2742 evt_invite_accepted
|
||||
0x2743 profileSignOut
|
||||
0x2744 profilePrepareForSave
|
||||
0x2745 logTelemetry
|
||||
0x2746 enterPracticeArena
|
||||
0x2748 navigationBackoutStart
|
||||
0x2749 navigationBackoutContinue
|
||||
0x274a navigationBackoutComplete
|
||||
0x274b checkSpeechData
|
||||
0x274c newsSharingSettings
|
||||
0x274d leaveBootFlow
|
||||
0x274e mainMenuProfileCreationDone
|
||||
0x274f nonLeadProfileCreation
|
||||
0x2750 nonLeadProfileLoad
|
||||
0x2755 teamSheetAction
|
||||
0x2758 evt_set_lead_profile
|
||||
0x2759 evt_sign_out
|
||||
0x275a notifySignOut
|
||||
0x275b notifySignOutReady
|
||||
0x275c notifySignOutTitleScreen
|
||||
0x275d evt_sign_out_flow_ready
|
||||
0x275e evt_sign_out_flow_not_ready
|
||||
0x275f showSignOutPopup
|
||||
0x2760 showSignOutTitleScreenPopup
|
||||
0x2761 evt_dismiss_sign_out_popup
|
||||
0x2762 evt_show_account_picker
|
||||
0x2763 evt_lead_profile_recovered
|
||||
0x2764 triggerSignOut
|
||||
0x2765 checkLeadProfilePairing
|
||||
0x2766 evt_lead_profile_paired
|
||||
0x2767 evt_lead_profile_unpaired
|
||||
0x2768 evt_lead_profile_controller_changed
|
||||
0x2769 beginProfileCheck
|
||||
0x276a endProfileCheck
|
||||
0x276b evt_controller_disconnect
|
||||
0x276c evt_notify_controller_disconnect
|
||||
0x276d evt_controller_disconnect_flow_ready
|
||||
0x276e evt_controller_disconnect_flow_not_ready
|
||||
0x276f showLoadPersonalSettingsPopup
|
||||
0x2770 showSavePersonalSettingsPopup
|
||||
0x2771 feRenderInGame
|
||||
0x2772 pvProfilerStart
|
||||
0x2773 pvProfilerStop
|
||||
0x2775 enterMatchDayTab
|
||||
0x2776 exitMatchDayTab
|
||||
0x2777 restartWithNewTeams
|
||||
0x2778 playSecondLegFixture
|
||||
0x2779 setupSecondLegFixture
|
||||
0x277a welcomeToMatchDayLive
|
||||
0x277b exitMatchDayLivePanel
|
||||
0x277c enableAardvark
|
||||
0x277d disableAardvark
|
||||
0x277e conditionAardvark
|
||||
0x2780 adaptiveDifficultyDetectedPopup
|
||||
0x2781 adaptiveDifficultyUpPopup
|
||||
0x2782 adaptiveDifficultyDownPopup
|
||||
0x2783 adaptiveDifficultyDetected
|
||||
0x2784 adaptiveDifficultyUp
|
||||
0x2785 adaptiveDifficultyDown
|
||||
0x2786 adaptiveDifficultyDisable
|
||||
0x2787 adaptiveDifficultyReset
|
||||
0x2788 adaptiveDifficultyKeep
|
||||
0x2789 adaptiveDifficultyOverride
|
||||
0x278c evt_countdown_done
|
||||
0x278d evt_countdown_restart
|
||||
0x278e evt_start_stadium_change
|
||||
0x278f evt_wait_for_stadium_change
|
||||
0x2790 evt_wait_for_stadium_change_bootflow
|
||||
0x2791 evt_advance_to_wait_popup
|
||||
0x2792 evt_advance_to_wait
|
||||
0x2793 evt_stadium_background_loaded
|
||||
0x2795 setupTournament
|
||||
0x2796 createTournament
|
||||
0x2797 createWomenTournament
|
||||
0x2799 setWomenTournament
|
||||
0x279a evt_sl_operation_started
|
||||
0x279b evt_sl_operation_complete
|
||||
0x279c evt_sl_operation_load
|
||||
0x279d evt_sl_operation_boot_load
|
||||
0x279e evt_sl_operation_save
|
||||
0x279f evt_sl_operation_delete
|
||||
0x27a0 FUTLoginComplete
|
||||
0x27a1 requestDownload
|
||||
0x27a2 backendEnter
|
||||
0x27a3 backendExit
|
||||
0x27a4 onlineLoginToEaPopup
|
||||
0x27a5 onlineBootLoginToEaPopup
|
||||
0x27a6 evt_onlineAlertPopup
|
||||
0x27a7 evt_onlineBootLoginFailurePopup
|
||||
0x27a8 evt_onlineLoginFailurePopup
|
||||
0x27a9 onlineLoginPopupHide
|
||||
0x27aa onlineLoginPopupShow
|
||||
0x27ab evt_invite_flow_ready
|
||||
0x27ac evt_invite_flow_not_ready
|
||||
0x27ad inviteFlowAbortSaveLoad
|
||||
0x27ae evt_verify_invite_nav_cleanup
|
||||
0x27af downloadComplete
|
||||
0x27b0 downloadFailed
|
||||
0x27b1 spevnetNotAvailable
|
||||
0x27b2 spevnetNotRegistered
|
||||
0x27b3 spevnetNotRegisteredBeta
|
||||
0x27b4 userBanned
|
||||
0x27b5 showExitConfirmPopup
|
||||
0x27b6 hideExitConfirmPopup
|
||||
0x27b7 confirmExit
|
||||
0x27b8 showRegisterConfirmPopup
|
||||
0x27b9 hideRegisterConfirmPopup
|
||||
0x27ba setStadiumPosition
|
||||
0x27bb liveCompCountryDecision
|
||||
0x27bc liveCompAllCountriesSelect
|
||||
0x27bd liveCompLimitedCountriesSelect
|
||||
0x27be liveCompAdvanceToTeamSelect
|
||||
0x27bf liveCompRegistrationConfirm
|
||||
0x27c0 liveCompEventListSuccess
|
||||
0x27c1 liveCompEventListFail
|
||||
0x27c2 postMatchHighlightExit
|
||||
0x27c3 postMatchHighlightComplete
|
||||
0x27c4 postMatchHighlightSelect
|
||||
0x27c5 postMatchHighlightReelSelect
|
||||
0x27c6 postMatchHighlightIRSelect
|
||||
0x27cf leaveUpsell
|
||||
0x27d0 purchase
|
||||
0x27d1 advanceFromPMA
|
||||
0x27d2 evt_transitionToPMADone
|
||||
0x27d3 cutSceneCommand
|
||||
0x27d4 cutScenePlay
|
||||
0x27d5 loadCutScenesSubLevel
|
||||
0x27d6 unloadCutScenesSubLevel
|
||||
0x27d7 evt_enable_skip_cutscene
|
||||
0x27d8 gmCutSceneStarted
|
||||
0x27d9 gmCutSceneEnded
|
||||
0x27da gmCutScenesSublevelLoaded
|
||||
0x27db gmCutScenesSublevelUnloaded
|
||||
0x27dc gmAirlockToGameplayEnded
|
||||
0x27dd gmAirlockLoadComplete
|
||||
0x27de evt_quit_to_training_hub
|
||||
0x27e0 evt_training_allow_advance_to_game
|
||||
0x27e1 checkOriginConnected
|
||||
0x27e2 OriginIsOnline
|
||||
0x27e3 OriginIsOffline
|
||||
0x27e4 OIGOpened
|
||||
0x27e5 OIGClosed
|
||||
0x27e6 overrideOnlineStadium
|
||||
0x27e7 smLoadFEStadium
|
||||
0x27e8 smActivateFreeRoam
|
||||
0x27e9 smGameOver
|
||||
0x27ea smScenePrime
|
||||
0x27eb smScenePrimeAndPrep
|
||||
0x27ec smScenePause
|
||||
0x27ed smSceneResume
|
||||
0x27ee smMoment
|
||||
0x27ef smMomentRepeat
|
||||
0x27f0 smMomentComplete
|
||||
0x27f1 smExitMomentState
|
||||
0x27f2 smOnPlayScene
|
||||
0x27f3 smConversation
|
||||
0x27f4 smConversationComplete
|
||||
0x27f5 smConversationNotification
|
||||
0x27f6 smConversationNotificationComplete
|
||||
0x27f7 smGameplayStartLoad
|
||||
0x27f8 smGameplayLoadOver
|
||||
0x27f9 smGameplayStart
|
||||
0x27fa smGameplayOver
|
||||
0x27fb smGameplayPause
|
||||
0x27fc smGameplayResume
|
||||
0x27ff smTweetConsume
|
||||
0x2800 smHeroLoanedOut
|
||||
0x2801 smSetupAcademyMatch
|
||||
0x2802 smSetupAcademyTeams
|
||||
0x2803 smStartIntroFlow
|
||||
0x2804 smStartSeason
|
||||
0x2805 smPlayMatch
|
||||
0x2806 smEndMatch
|
||||
0x2807 smGetTrainingSet
|
||||
0x2808 smEnterTrainingTeamHub
|
||||
0x2809 smEnterTraining
|
||||
0x280a smPlayTrainingSessionVO
|
||||
0x280b smPrepareTraining
|
||||
0x280c smPlayTraining
|
||||
0x280d smStopTraining
|
||||
0x280e smStartSkillGame
|
||||
0x280f smSimTraining
|
||||
0x2810 smEndTraining
|
||||
0x2811 smSave
|
||||
0x2812 smAutoSave
|
||||
0x2814 smLoad
|
||||
0x2815 smSetScreenFlowLocation
|
||||
0x2816 smGetScreenFlowLocation
|
||||
0x2817 smGetHomeHubLocation
|
||||
0x2818 smGetHeroLeague
|
||||
0x2819 smCompleteMatchday
|
||||
0x281a smEndInterviewPeriod
|
||||
0x281b smHeroRemovedFromMatch
|
||||
0x281c smEpisodicUploadCheck
|
||||
0x281d smRetryEpisodicUpload
|
||||
0x281e smNotifyMatchNotPlayed
|
||||
0x281f matchFlowStart
|
||||
0x2820 matchFlowHalftime
|
||||
0x2821 matchFlowPostgame
|
||||
0x2822 matchFlowEnd
|
||||
0x2823 enterGameplay
|
||||
0x2824 leaveGameplay
|
||||
0x2825 forfeitMatch
|
||||
0x2826 matchSetType
|
||||
0x2827 simMatch
|
||||
0x2828 simStarted
|
||||
0x2829 simStopped
|
||||
0x282a fbStartFlowEvent
|
||||
0x282b stopSavedInput
|
||||
0x282c changeSonyStoreBrowseMode
|
||||
0x282d trialCheck
|
||||
0x282e gotoTrialUpsell
|
||||
0x754d retrieveManagerQuestData
|
||||
0x7560 futWidgetShow
|
||||
0x7561 futWidgetHide
|
||||
0x7562 futWidgetLoad
|
||||
0x7563 futWidgetUnload
|
||||
0x7567 inviteAcceptedFUT
|
||||
0x7568 futAddCriticalSection
|
||||
0x7569 futRemoveCriticalSection
|
||||
0x7572 exitDraftMode
|
||||
0x7579 useSavedMatchData
|
||||
0x757a useSavedMatchKits
|
||||
0x7580 exitSbcMode
|
||||
0x7587 setFUTServerEnvironment
|
||||
0x9cc1 discardTeamSheet
|
||||
0x9cc1 resetReady
|
||||
0x9cd0 showKeyboard
|
||||
@@ -518,13 +518,60 @@ CORRECTED 2026-08-06 (live diff + deserializer frame arithmetic, record_off = 0x
|
||||
```
|
||||
|
||||
**`+0x60`, extended 2026-08-21.** "Assigned by the owning list, not parsed" is
|
||||
right, and the stronger statement is now measured: the pre-match kit selector
|
||||
gates on `+0x60 == 4` at `0x1801c34f2`, and **nothing anywhere stores 4 into that
|
||||
field** — not in CardsDLL (29 immediate stores, constants `{-2,0,1,908,0x3f800000}`),
|
||||
not in FIFA17.exe (zero across 79 MB), and no resident record has ever held it
|
||||
(live: `{1: players, 0: staff}`). Every OTHER input to that gate is already
|
||||
served. So the empty kit-selection screen is a client dead end, not a missing
|
||||
wire field. Tool: `fifa17-recon/tools/kit_gate_probe.py`.
|
||||
right. The pre-match kit selector gates on `+0x60 == 4` at `0x1801c34f2`, and no
|
||||
instruction in CardsDLL stores that constant immediately (29 stores, constants
|
||||
`{-2,0,1,908,0x3f800000}`), nor does FIFA17.exe across 79 MB.
|
||||
Tool: `fifa17-recon/tools/kit_gate_probe.py`.
|
||||
|
||||
**CORRECTED 2026-08-23 (live, pid 8793, read-only `/proc/PID/mem`).** The
|
||||
2026-08-21 entry went on to call the kit selector "a client dead end, not a
|
||||
missing wire field", on the grounds that "every OTHER input to that gate is
|
||||
already served". That conclusion is WITHDRAWN. It rested on two mistakes.
|
||||
|
||||
1. **`+0x60 == 4` does occur.** A live record reached the art-clone driver
|
||||
`FUN_1801c3480` holding `+0x4c == 2`, `+0x60 == 4`. So the value arrives by
|
||||
some path the immediate-store scan cannot see (register copy or computed),
|
||||
and "nothing can ever satisfy the gate" is false. What the static scan
|
||||
actually licenses is the narrower claim above.
|
||||
2. **cardtype 7 was never verified to be produced at all.** The probe annotates
|
||||
`cmp [rdi+0x4c], 7` with "<- we produce this". Nothing measured that. Its own
|
||||
live half showed `{1: players, 0: staff}` -- i.e. zero cardtype-7 records --
|
||||
and that was read as "the only thing missing is +0x60".
|
||||
|
||||
**What is actually measured now.** With the client parked on the kit selector,
|
||||
scanning all 3047 MiB of readable process memory for the exact u32 values the
|
||||
server sent:
|
||||
|
||||
```
|
||||
resident (record-shaped, sane fields):
|
||||
player resourceId 83906881 -> cardtype 1, itemState 1, teamid 243, +0x60 1
|
||||
staff resourceId 9000081 -> cardtype 2
|
||||
staff resourceId 3000083 -> cardtype 4, subtype 8
|
||||
staff resourceId 1000509 -> cardtype 2, subtype 4, teamid 241
|
||||
NOT resident, by resourceId AND by instance id, zero hits each:
|
||||
kit 6300006 / 100004874 (cardsubtypeid 9)
|
||||
kit 6400003 / 100004873 (cardsubtypeid 9)
|
||||
badge 6000005 / 100004875 (cardsubtypeid 11)
|
||||
stadium 6200000 / 100004876 (cardsubtypeid 10)
|
||||
```
|
||||
|
||||
The client fetched `?type=kit` at 17:50:09 this session and the host logged
|
||||
`total=2 emitted=2`. Both kits were delivered and NEITHER produced a record.
|
||||
Every cardtype-7 family is absent while cardtype 1/2/4 are resident.
|
||||
|
||||
So the blocker is upstream of the `+0x60` gate: no cardtype-7 record is ever
|
||||
created, therefore the club scan `FUN_1800d73d0` (`+0x4c==7 && +0x50==9 &&
|
||||
`+0x5c in {101,102}`) has nothing to match, `KIT_DESC` never fires, and
|
||||
`KITS_AVAILABLE` reads 0. Whether that is a bad wire shape (the cardtype-7 parse
|
||||
arm wants `name`/`localizedName`/`description`, which OpenFUT does not send) or
|
||||
cardtype-7 items being transient by design is NOT yet settled -- do not record
|
||||
either as fact.
|
||||
|
||||
**Method note.** `kit_gate_probe.py`'s live half is unreliable as written: on
|
||||
pid 8793 it printed "CardsDb is empty (no FUT session loaded)" while a byte scan
|
||||
found 1966 resident players. Its structural chain is stale, so its record counts
|
||||
(including the original "27 resident records") understate reality. Prefer the
|
||||
value scan until the chain is re-derived.
|
||||
|
||||
**`definitionId is NOT AN ATOM`, confirmed a fourth way 2026-08-21.** Every real
|
||||
atom name appears exactly once in CardsDLL's `.rdata` — `resourceId`,
|
||||
|
||||
Executable
+697
@@ -0,0 +1,697 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decoder for EA APT (compiled ActionScript) as shipped in FIFA 17.
|
||||
|
||||
Clean-room implementation. The byte-level format facts (opcode numbers, operand
|
||||
widths, alignment rule, branch base, DefineFunction2 field order) were taken from
|
||||
a written specification derived from OpenSAGE, which is GPL-3.0 with EA
|
||||
additional terms. No OpenSAGE code was copied or transliterated; only the format
|
||||
description -- an interface specification -- was used. Reference read at
|
||||
OpenSAGE/OpenSAGE commit 588ac477367a0022adf29f20a084e8873014e6ce and
|
||||
OpenSAGE/AptEditor commit 09f73c655c45a781f883b623a93d2e8f5b065a6c.
|
||||
|
||||
FIFA 17 ships a 64-BIT variant of the format. Differences from the 32-bit SAGE
|
||||
layout described by the reference, all established by measurement against
|
||||
futSelectTeam and asserted by --selftest:
|
||||
|
||||
* Container pointers and counts are u64, not u32.
|
||||
* Parameterised instructions align their operand block to 8 bytes, not 4.
|
||||
Proven by the ConstantPool at 0xd38: aligning to 4 yields garbage, aligning
|
||||
to 8 yields count=401 with an index array that ends exactly on the
|
||||
parameter-list region.
|
||||
* The constant pool lives in a separate "Apt1" container member rather than a
|
||||
".const" sibling file. Entries are 16 bytes: {u64 type, u64 value}; type 1
|
||||
is a string whose value is an absolute offset inside that same member.
|
||||
* DefineFunction2's operand block is 48 bytes rather than 28, and the
|
||||
0x1234567898765432 trailer is stored as two u64 halves.
|
||||
* Branch displacements remain i32 and remain relative to the end of the
|
||||
branch record, exactly as in the 32-bit format.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
APT1_MAGIC = b"Apt1"
|
||||
APTDATA_MAGIC = b"Apt Data:1:7:8\x1a\x00"
|
||||
|
||||
# Trailer sentinel on DefineFunction/DefineFunction2, stored as two u64 halves.
|
||||
FUNC_SENTINEL_LO = 0x98765432
|
||||
FUNC_SENTINEL_HI = 0x12345678
|
||||
|
||||
ALIGN = 8
|
||||
|
||||
# Operand kinds.
|
||||
NONE = "none" # no operand block
|
||||
U8REG = "u8reg" # 1 raw byte, register index
|
||||
U8CONST = "u8const" # 1 raw byte, constant-pool index
|
||||
U16CONST = "u16const" # 2 raw bytes, constant-pool index
|
||||
U8LIT = "u8lit" # 1 raw byte, literal integer
|
||||
U16LIT = "u16lit" # 2 raw bytes, literal integer
|
||||
BRANCH = "branch" # aligned i32, relative to end of record
|
||||
U32 = "u32" # aligned u32
|
||||
F32 = "f32" # aligned f32
|
||||
STR64 = "str64" # aligned u64 absolute offset to NUL-terminated string
|
||||
POOL = "pool" # aligned u64 count + u64 array offset (array of u64 ids)
|
||||
FUNC2 = "func2" # aligned DefineFunction2 record
|
||||
FUNC1 = "func1" # aligned DefineFunction record
|
||||
|
||||
# opcode -> (mnemonic, operand kind)
|
||||
OPCODES: dict[int, tuple[str, str]] = {
|
||||
0x00: ("End", NONE),
|
||||
0x04: ("NextFrame", NONE),
|
||||
0x06: ("Play", NONE),
|
||||
0x07: ("Stop", NONE),
|
||||
0x0A: ("Add", NONE),
|
||||
0x0B: ("Subtract", NONE),
|
||||
0x0C: ("Multiply", NONE),
|
||||
0x0D: ("Divide", NONE),
|
||||
0x12: ("Not", NONE),
|
||||
0x13: ("StringEquals", NONE),
|
||||
0x17: ("Pop", NONE),
|
||||
0x18: ("ToInteger", NONE),
|
||||
0x1C: ("GetVariable", NONE),
|
||||
0x1D: ("SetVariable", NONE),
|
||||
0x21: ("StringConcat", NONE),
|
||||
0x22: ("GetProperty", NONE),
|
||||
0x23: ("SetProperty", NONE),
|
||||
0x26: ("Trace", NONE),
|
||||
0x30: ("Random", NONE),
|
||||
0x3A: ("Delete", NONE),
|
||||
0x3B: ("Delete2", NONE),
|
||||
0x3C: ("DefineLocal", NONE),
|
||||
0x3D: ("CallFunction", NONE),
|
||||
0x3E: ("Return", NONE),
|
||||
0x3F: ("Modulo", NONE),
|
||||
0x40: ("NewObject", NONE),
|
||||
0x41: ("Var", NONE),
|
||||
0x42: ("InitArray", NONE),
|
||||
0x43: ("InitObject", NONE),
|
||||
0x44: ("TypeOf", NONE),
|
||||
0x47: ("Add2", NONE),
|
||||
0x48: ("LessThan2", NONE),
|
||||
0x49: ("Equals2", NONE),
|
||||
0x4A: ("ToNumber", NONE),
|
||||
0x4B: ("ToString", NONE),
|
||||
0x4C: ("PushDuplicate", NONE),
|
||||
0x4E: ("GetMember", NONE),
|
||||
0x4F: ("SetMember", NONE),
|
||||
0x50: ("Increment", NONE),
|
||||
0x51: ("Decrement", NONE),
|
||||
0x52: ("CallMethod", NONE),
|
||||
# 0x53 appears in the reference enum as NewMethod but the reference never
|
||||
# parses it. Standard AVM1 ActionNewMethod carries no operand block;
|
||||
# decoding it as zero-length keeps this artifact synchronised with every
|
||||
# branch still landing on an instruction boundary, which is the check that
|
||||
# would break first if the width were wrong.
|
||||
0x53: ("NewMethod", NONE),
|
||||
0x54: ("InstanceOf", NONE),
|
||||
0x55: ("Enumerate2", NONE),
|
||||
0x56: ("PushThis", NONE),
|
||||
0x59: ("PushZero", NONE),
|
||||
0x5A: ("PushOne", NONE),
|
||||
0x5B: ("CallFuncPop", NONE),
|
||||
0x5C: ("CallFunc", NONE),
|
||||
0x5D: ("CallMethodPop", NONE),
|
||||
0x62: ("BitwiseXOr", NONE),
|
||||
0x66: ("StrictEqual", NONE),
|
||||
0x67: ("Greater", NONE),
|
||||
0x69: ("Extends", NONE),
|
||||
0x70: ("PushThisVar", NONE),
|
||||
0x71: ("PushGlobalVar", NONE),
|
||||
0x72: ("ZeroVar", NONE),
|
||||
0x73: ("PushTrue", NONE),
|
||||
0x74: ("PushFalse", NONE),
|
||||
0x75: ("PushNull", NONE),
|
||||
0x76: ("PushUndefined", NONE),
|
||||
0x87: ("SetRegister", U32),
|
||||
0x88: ("ConstantPool", POOL),
|
||||
0x8C: ("GotoLabel", STR64),
|
||||
0x8E: ("DefineFunction2", FUNC2),
|
||||
0x96: ("PushData", POOL),
|
||||
0x99: ("BranchAlways", BRANCH),
|
||||
0x9B: ("DefineFunction", FUNC1),
|
||||
0x9D: ("BranchIfTrue", BRANCH),
|
||||
0x9F: ("GotoFrame2", U32),
|
||||
0xA1: ("PushString", STR64),
|
||||
0xA2: ("PushConstantByte", U8CONST),
|
||||
0xA3: ("PushConstantWord", U16CONST),
|
||||
0xA4: ("GetStringVar", STR64),
|
||||
0xA5: ("GetStringMember", STR64),
|
||||
0xA6: ("SetStringVar", STR64),
|
||||
0xA7: ("SetStringMember", STR64),
|
||||
0xAE: ("PushValueOfVar", U8CONST),
|
||||
0xAF: ("GetNamedMember", U8CONST),
|
||||
0xB0: ("CallNamedFuncPop", U8CONST),
|
||||
0xB1: ("CallNamedFunc", U8CONST),
|
||||
0xB2: ("CallNamedMethodPop", U8CONST),
|
||||
0xB3: ("CallNamedMethod", U8CONST),
|
||||
0xB4: ("PushFloat", F32),
|
||||
0xB5: ("PushByte", U8LIT),
|
||||
0xB6: ("PushShort", U16LIT),
|
||||
0xB8: ("BranchIfFalse", BRANCH),
|
||||
0xB9: ("PushRegister", U8REG),
|
||||
}
|
||||
|
||||
ALIGNED_KINDS = {BRANCH, U32, F32, STR64, POOL, FUNC2, FUNC1}
|
||||
|
||||
|
||||
class DecodeError(Exception):
|
||||
"""Raised when the stream cannot be decoded without guessing."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Instr:
|
||||
offset: int
|
||||
opcode: int
|
||||
mnemonic: str
|
||||
length: int # opcode byte through end of operand block, incl. padding
|
||||
operands: dict
|
||||
raw: bytes
|
||||
target: int | None = None # resolved branch destination
|
||||
comment: str = ""
|
||||
|
||||
def render(self, width: int = 22) -> str:
|
||||
ops = self.comment or ""
|
||||
return f" {self.offset:#07x} {self.mnemonic:<{width}} {ops}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Function:
|
||||
name: str
|
||||
record_offset: int # offset of the DefineFunction* opcode byte
|
||||
body_start: int
|
||||
body_end: int
|
||||
n_params: int
|
||||
n_registers: int
|
||||
flags: int
|
||||
params: list = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def anonymous(self) -> bool:
|
||||
return not self.name
|
||||
|
||||
|
||||
PRELOAD_FLAGS = [
|
||||
(0x010000, "PreloadExtern"),
|
||||
(0x008000, "PreloadParent"),
|
||||
(0x004000, "PreloadRoot"),
|
||||
(0x002000, "SupressSuper"),
|
||||
(0x001000, "PreloadSuper"),
|
||||
(0x000800, "SupressArguments"),
|
||||
(0x000400, "PreloadArguments"),
|
||||
(0x000200, "SupressThis"),
|
||||
(0x000100, "PreloadThis"),
|
||||
(0x000001, "PreloadGlobal"),
|
||||
]
|
||||
|
||||
# Registers preloaded by the VM, in flag order, starting at index 1.
|
||||
PRELOAD_ORDER = [
|
||||
(0x000100, "this"),
|
||||
(0x000400, "arguments"),
|
||||
(0x001000, "super"),
|
||||
(0x004000, "_root"),
|
||||
(0x008000, "_parent"),
|
||||
(0x000001, "_global"),
|
||||
(0x010000, "extern"),
|
||||
]
|
||||
|
||||
|
||||
def flag_names(flags: int) -> str:
|
||||
got = [n for bit, n in PRELOAD_FLAGS if flags & bit]
|
||||
return "|".join(got) if got else "0"
|
||||
|
||||
|
||||
def register_map(fn: Function) -> dict[int, str]:
|
||||
"""Reproduce the VM's register preload order, then bound parameters."""
|
||||
regs: dict[int, str] = {}
|
||||
idx = 1
|
||||
for bit, name in PRELOAD_ORDER:
|
||||
if fn.flags & bit:
|
||||
regs[idx] = name
|
||||
idx += 1
|
||||
for reg, pname in fn.params:
|
||||
if reg:
|
||||
regs[reg] = pname
|
||||
return regs
|
||||
|
||||
|
||||
class ConstPool:
|
||||
"""The 'Apt1' container member: header, 16-byte entries, string table."""
|
||||
|
||||
def __init__(self, data: bytes):
|
||||
if data[:4] != APT1_MAGIC:
|
||||
raise DecodeError(f"not an Apt1 member: {data[:4]!r}")
|
||||
self.data = data
|
||||
self.count = struct.unpack_from("<Q", data, 0x20)[0]
|
||||
self.first = struct.unpack_from("<Q", data, 0x28)[0]
|
||||
self.entries: list[tuple[int, int, str | None]] = []
|
||||
for i in range(self.count):
|
||||
off = self.first + i * 16
|
||||
if off + 16 > len(data):
|
||||
raise DecodeError(f"const entry {i} at {off:#x} runs past end")
|
||||
etype, value = struct.unpack_from("<QQ", data, off)
|
||||
text = None
|
||||
if etype == 1:
|
||||
if not (0 < value < len(data)):
|
||||
raise DecodeError(
|
||||
f"const entry {i}: string offset {value:#x} outside member"
|
||||
)
|
||||
end = data.find(b"\0", value)
|
||||
if end < 0:
|
||||
raise DecodeError(f"const entry {i}: unterminated string")
|
||||
text = data[value:end].decode("latin1")
|
||||
self.entries.append((etype, value, text))
|
||||
|
||||
def string(self, index: int) -> str:
|
||||
if not (0 <= index < len(self.entries)):
|
||||
raise DecodeError(f"const index {index} out of range (0..{len(self.entries)-1})")
|
||||
etype, _, text = self.entries[index]
|
||||
if etype != 1 or text is None:
|
||||
raise DecodeError(f"const index {index} is type {etype}, not a string")
|
||||
return text
|
||||
|
||||
def find(self, needle: str) -> list[int]:
|
||||
return [i for i, (_, _, t) in enumerate(self.entries) if t == needle]
|
||||
|
||||
|
||||
class AptData:
|
||||
"""The 'Apt Data' container member: movie structures plus action streams."""
|
||||
|
||||
def __init__(self, data: bytes, pool: ConstPool):
|
||||
if not data.startswith(APTDATA_MAGIC[:8]):
|
||||
raise DecodeError(f"not an Apt Data member: {data[:16]!r}")
|
||||
self.data = data
|
||||
self.pool = pool
|
||||
self.scope: list[str] = [] # installed by ConstantPool
|
||||
self.functions: list[Function] = []
|
||||
|
||||
# -- helpers ---------------------------------------------------------
|
||||
def cstr(self, off: int) -> str:
|
||||
if not (0 <= off < len(self.data)):
|
||||
raise DecodeError(f"string offset {off:#x} outside Apt Data")
|
||||
end = self.data.find(b"\0", off)
|
||||
if end < 0:
|
||||
raise DecodeError(f"unterminated string at {off:#x}")
|
||||
return self.data[off:end].decode("latin1")
|
||||
|
||||
def const(self, index: int) -> str:
|
||||
"""Resolve through the scope pool installed by the most recent 0x88."""
|
||||
if self.scope:
|
||||
if not (0 <= index < len(self.scope)):
|
||||
raise DecodeError(
|
||||
f"scope-pool index {index} out of range (0..{len(self.scope)-1})"
|
||||
)
|
||||
return self.scope[index]
|
||||
return self.pool.string(index)
|
||||
|
||||
def install_pool(self, ids: list[int]) -> None:
|
||||
self.scope = [self.pool.string(i) for i in ids]
|
||||
|
||||
# -- instruction decoding --------------------------------------------
|
||||
def decode_one(self, pos: int) -> Instr:
|
||||
d = self.data
|
||||
if pos >= len(d):
|
||||
raise DecodeError(f"position {pos:#x} past end of stream")
|
||||
op = d[pos]
|
||||
entry = OPCODES.get(op)
|
||||
if entry is None:
|
||||
raise DecodeError(
|
||||
f"unknown opcode {op:#04x} at {pos:#07x} "
|
||||
f"(raw {d[pos:pos+8].hex(' ')}) - refusing to guess its length"
|
||||
)
|
||||
mnem, kind = entry
|
||||
p = pos + 1
|
||||
if kind in ALIGNED_KINDS:
|
||||
p = (p + ALIGN - 1) & ~(ALIGN - 1)
|
||||
|
||||
ops: dict = {}
|
||||
comment = ""
|
||||
target = None
|
||||
|
||||
def need(n: int) -> None:
|
||||
if p + n > len(d):
|
||||
raise DecodeError(f"{mnem} at {pos:#07x} truncated: needs {n} bytes")
|
||||
|
||||
if kind == NONE:
|
||||
pass
|
||||
elif kind in (U8REG, U8LIT):
|
||||
need(1)
|
||||
ops["value"] = d[p]
|
||||
p += 1
|
||||
comment = f"r{ops['value']}" if kind == U8REG else str(ops["value"])
|
||||
elif kind == U8CONST:
|
||||
need(1)
|
||||
ops["index"] = d[p]
|
||||
p += 1
|
||||
comment = f"{ops['index']:#04x} -> {self.const(ops['index'])!r}"
|
||||
elif kind == U16CONST:
|
||||
need(2)
|
||||
ops["index"] = struct.unpack_from("<H", d, p)[0]
|
||||
p += 2
|
||||
comment = f"{ops['index']:#06x} -> {self.const(ops['index'])!r}"
|
||||
elif kind == U16LIT:
|
||||
need(2)
|
||||
ops["value"] = struct.unpack_from("<H", d, p)[0]
|
||||
p += 2
|
||||
comment = str(ops["value"])
|
||||
elif kind == U32:
|
||||
need(4)
|
||||
ops["value"] = struct.unpack_from("<I", d, p)[0]
|
||||
p += 4
|
||||
comment = str(ops["value"])
|
||||
elif kind == F32:
|
||||
need(4)
|
||||
ops["value"] = struct.unpack_from("<f", d, p)[0]
|
||||
p += 4
|
||||
comment = repr(ops["value"])
|
||||
elif kind == BRANCH:
|
||||
need(4)
|
||||
disp = struct.unpack_from("<i", d, p)[0]
|
||||
p += 4
|
||||
ops["displacement"] = disp
|
||||
target = p + disp # base = end of record
|
||||
comment = f"{disp:+d} -> {target:#07x}"
|
||||
elif kind == STR64:
|
||||
need(8)
|
||||
off = struct.unpack_from("<Q", d, p)[0]
|
||||
p += 8
|
||||
ops["offset"] = off
|
||||
ops["text"] = self.cstr(off)
|
||||
comment = f"{ops['text']!r}"
|
||||
elif kind == POOL:
|
||||
need(16)
|
||||
count, arr = struct.unpack_from("<QQ", d, p)
|
||||
p += 16
|
||||
if arr + count * 8 > len(d):
|
||||
raise DecodeError(f"{mnem} at {pos:#07x}: array {arr:#x}[{count}] overruns")
|
||||
ids = list(struct.unpack_from(f"<{count}Q", d, arr))
|
||||
ops["count"], ops["array"], ops["ids"] = count, arr, ids
|
||||
comment = f"count={count} array={arr:#x}"
|
||||
elif kind in (FUNC2, FUNC1):
|
||||
if kind == FUNC2:
|
||||
need(48)
|
||||
name_off, n_params = struct.unpack_from("<QI", d, p)
|
||||
n_reg = d[p + 12]
|
||||
flags = int.from_bytes(d[p + 13:p + 16], "little")
|
||||
plist, body = struct.unpack_from("<QQ", d, p + 16)
|
||||
lo, hi = struct.unpack_from("<QQ", d, p + 32)
|
||||
p += 48
|
||||
else:
|
||||
need(40)
|
||||
name_off, n_params, plist, body = struct.unpack_from("<QQQQ", d, p)
|
||||
n_reg, flags = 4, 0
|
||||
lo, hi = struct.unpack_from("<QQ", d, p + 32)
|
||||
p += 40
|
||||
if (lo, hi) != (FUNC_SENTINEL_LO, FUNC_SENTINEL_HI):
|
||||
raise DecodeError(
|
||||
f"{mnem} at {pos:#07x}: bad trailer {lo:#x}/{hi:#x}, "
|
||||
"record layout is wrong"
|
||||
)
|
||||
name = self.cstr(name_off)
|
||||
params = []
|
||||
for i in range(n_params):
|
||||
e = plist + i * 16
|
||||
if e + 16 > len(d):
|
||||
raise DecodeError(f"{mnem} at {pos:#07x}: param {i} overruns")
|
||||
reg, pn = struct.unpack_from("<QQ", d, e)
|
||||
params.append((reg, self.cstr(pn)))
|
||||
ops.update(name=name, n_params=n_params, n_registers=n_reg,
|
||||
flags=flags, params=params, body_size=body)
|
||||
comment = (f"{name or '<anonymous>'}({', '.join(n for _, n in params)}) "
|
||||
f"nRegs={n_reg} flags={flag_names(flags)} bodySize={body}")
|
||||
ops["body_start"] = p
|
||||
ops["body_end"] = p + body
|
||||
else:
|
||||
raise DecodeError(f"internal: unhandled kind {kind}")
|
||||
|
||||
return Instr(pos, op, mnem, p - pos, ops, d[pos:p], target, comment)
|
||||
|
||||
def decode_stream(self, start: int, limit: int | None = None) -> list[Instr]:
|
||||
"""Linear decode using the reference termination rule.
|
||||
|
||||
Stops when the last instruction was End AND we are past every branch
|
||||
destination seen so far. A stream may legitimately continue past an End.
|
||||
"""
|
||||
out: list[Instr] = []
|
||||
pos = start
|
||||
furthest = start
|
||||
while True:
|
||||
if limit is not None and pos >= limit:
|
||||
break
|
||||
ins = self.decode_one(pos)
|
||||
out.append(ins)
|
||||
if ins.target is not None:
|
||||
furthest = max(furthest, ins.target)
|
||||
if ins.mnemonic == "ConstantPool":
|
||||
self.install_pool(ins.operands["ids"])
|
||||
if ins.mnemonic in ("DefineFunction2", "DefineFunction"):
|
||||
fn = Function(
|
||||
name=ins.operands["name"],
|
||||
record_offset=ins.offset,
|
||||
body_start=ins.operands["body_start"],
|
||||
body_end=ins.operands["body_end"],
|
||||
n_params=ins.operands["n_params"],
|
||||
n_registers=ins.operands["n_registers"],
|
||||
flags=ins.operands["flags"],
|
||||
params=ins.operands["params"],
|
||||
)
|
||||
self.functions.append(fn)
|
||||
furthest = max(furthest, fn.body_end)
|
||||
pos = ins.offset + ins.length
|
||||
if ins.mnemonic == "End" and pos > furthest:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def load(apt1_path: str, aptdata_path: str) -> tuple[ConstPool, AptData]:
|
||||
pool = ConstPool(open(apt1_path, "rb").read())
|
||||
movie = AptData(open(aptdata_path, "rb").read(), pool)
|
||||
return pool, movie
|
||||
|
||||
|
||||
def find_streams(movie: AptData) -> list[int]:
|
||||
"""Seed stream starts: every ConstantPool record that validates."""
|
||||
seeds = []
|
||||
d = movie.data
|
||||
for p in range(len(d)):
|
||||
if d[p] != 0x88:
|
||||
continue
|
||||
try:
|
||||
ins = movie.decode_one(p)
|
||||
except DecodeError:
|
||||
continue
|
||||
if ins.operands.get("count", 0) and ins.operands["ids"] == list(
|
||||
range(ins.operands["count"])
|
||||
):
|
||||
seeds.append(p)
|
||||
return seeds
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--apt1", default="fifa17-recon/data/apt/futSelectTeam_Apt1.bin")
|
||||
ap.add_argument("--aptdata", default="fifa17-recon/data/apt/futSelectTeam_AptData.bin")
|
||||
ap.add_argument("--stream", type=lambda s: int(s, 0), help="decode one stream at offset")
|
||||
ap.add_argument("--function", help="decode the named function's body")
|
||||
ap.add_argument("--list-functions", action="store_true")
|
||||
ap.add_argument("--report", action="store_true", help="structural validation report")
|
||||
ap.add_argument("--strings", action="store_true", help="dump the constant pool")
|
||||
ap.add_argument("--selftest", action="store_true")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
pool, movie = load(args.apt1, args.aptdata)
|
||||
|
||||
if args.selftest:
|
||||
return selftest(pool, movie)
|
||||
|
||||
if args.strings:
|
||||
for i, (t, v, s) in enumerate(pool.entries):
|
||||
print(f" #{i:3d} type={t} @{v:#07x} {s!r}")
|
||||
return 0
|
||||
|
||||
seeds = find_streams(movie)
|
||||
if args.stream is not None:
|
||||
seeds = [args.stream]
|
||||
|
||||
all_instrs: list[Instr] = []
|
||||
for s in seeds:
|
||||
all_instrs.extend(movie.decode_stream(s))
|
||||
|
||||
if args.list_functions:
|
||||
for fn in movie.functions:
|
||||
regs = register_map(fn)
|
||||
rs = " ".join(f"r{k}={v}" for k, v in sorted(regs.items()))
|
||||
print(f" {fn.body_start:#07x}-{fn.body_end:#07x} "
|
||||
f"{fn.name or '<anonymous>':<34} {rs}")
|
||||
return 0
|
||||
|
||||
if args.function:
|
||||
for fn in movie.functions:
|
||||
if fn.name == args.function:
|
||||
print(f"; {fn.name} body {fn.body_start:#x}..{fn.body_end:#x} "
|
||||
f"flags={flag_names(fn.flags)} nRegs={fn.n_registers}")
|
||||
regs = register_map(fn)
|
||||
for k, v in sorted(regs.items()):
|
||||
print(f"; r{k} = {v}")
|
||||
for ins in movie.decode_stream(fn.body_start, fn.body_end):
|
||||
print(ins.render())
|
||||
return 0
|
||||
print(f"function {args.function!r} not found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.report:
|
||||
return report(movie, seeds, all_instrs)
|
||||
|
||||
for ins in all_instrs:
|
||||
print(ins.render())
|
||||
return 0
|
||||
|
||||
|
||||
def report(movie: AptData, seeds: list[int], instrs: list[Instr]) -> int:
|
||||
import collections
|
||||
hist = collections.Counter(i.mnemonic for i in instrs)
|
||||
covered = set()
|
||||
for i in instrs:
|
||||
covered.update(range(i.offset, i.offset + i.length))
|
||||
branches = [i for i in instrs if i.target is not None]
|
||||
boundaries = {i.offset for i in instrs}
|
||||
bad = [i for i in branches if i.target not in boundaries]
|
||||
print(f" streams decoded : {len(seeds)} {[hex(s) for s in seeds]}")
|
||||
print(f" instructions : {len(instrs)}")
|
||||
print(f" bytes covered : {len(covered)} of {len(movie.data)}")
|
||||
print(f" functions : {len(movie.functions)}")
|
||||
print(f" branches : {len(branches)}")
|
||||
print(f" invalid branch targets: {len(bad)}")
|
||||
for i in bad[:10]:
|
||||
print(f" {i.offset:#07x} {i.mnemonic} -> {i.target:#07x}")
|
||||
print(f" distinct opcodes : {len(hist)}")
|
||||
for m, n in hist.most_common():
|
||||
print(f" {m:<22} {n}")
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
def selftest(pool: ConstPool, movie: AptData) -> int:
|
||||
"""Assertions that pin the measured format facts."""
|
||||
ok = True
|
||||
|
||||
def check(label: str, cond: bool, detail: str = "") -> None:
|
||||
nonlocal ok
|
||||
print(f" [{'PASS' if cond else 'FAIL'}] {label}{(' - ' + detail) if detail else ''}")
|
||||
ok = ok and cond
|
||||
|
||||
check("Apt1 entry count", pool.count == 414, f"{pool.count}")
|
||||
check("Apt1 all entries are strings",
|
||||
all(t == 1 for t, _, _ in pool.entries))
|
||||
check("Apt1 entry array abuts string table",
|
||||
pool.first + pool.count * 16 == min(v for t, v, _ in pool.entries if t == 1))
|
||||
|
||||
# Phase 3: exact pointer -> string resolution for known symbols.
|
||||
for name in ("CheckIsKitLocked", "KITS_AVAILABLE", "FUT_GET_MATCH_KITS_DP",
|
||||
"mcLockHome"):
|
||||
idx = pool.find(name)
|
||||
check(f"string resolves: {name}", len(idx) == 1 and pool.string(idx[0]) == name,
|
||||
f"index {idx}")
|
||||
|
||||
# Bad pointers must raise, not fuzzy-match.
|
||||
for bad in (-1, 10 ** 6):
|
||||
try:
|
||||
pool.string(bad)
|
||||
check(f"bad const index {bad} rejected", False)
|
||||
except DecodeError:
|
||||
check(f"bad const index {bad} rejected", True)
|
||||
|
||||
# Phase 4 fixtures for the two EA opcodes.
|
||||
movie.scope = ["alpha", "beta"] + [f"c{i}" for i in range(2, 300)]
|
||||
fixtures = [
|
||||
(bytes([0xB9, 0x00]), "PushRegister", 2, "r0"),
|
||||
(bytes([0xB9, 0x05]), "PushRegister", 2, "r5"),
|
||||
(bytes([0xB9, 0xFF]), "PushRegister", 2, "r255"),
|
||||
(bytes([0xAF, 0x00]), "GetNamedMember", 2, "'alpha'"),
|
||||
(bytes([0xAF, 0x01]), "GetNamedMember", 2, "'beta'"),
|
||||
(bytes([0xA2, 0x01]), "PushConstantByte", 2, "'beta'"),
|
||||
]
|
||||
for raw, mnem, length, needle in fixtures:
|
||||
probe = AptData(APTDATA_MAGIC + raw.ljust(16, b"\0"), pool)
|
||||
probe.scope = movie.scope
|
||||
ins = probe.decode_one(16)
|
||||
check(f"fixture {raw.hex()} -> {mnem}",
|
||||
ins.mnemonic == mnem and ins.length == length and needle in ins.comment,
|
||||
f"{ins.mnemonic} len={ins.length} {ins.comment}")
|
||||
|
||||
# Truncated records must fail closed.
|
||||
for raw in (bytes([0xB9]), bytes([0xAF]), bytes([0xA3, 0x01])):
|
||||
probe = AptData(APTDATA_MAGIC + raw, pool)
|
||||
probe.scope = movie.scope
|
||||
try:
|
||||
probe.decode_one(16)
|
||||
check(f"truncated {raw.hex()} fails closed", False)
|
||||
except DecodeError:
|
||||
check(f"truncated {raw.hex()} fails closed", True)
|
||||
|
||||
# Out-of-range pool index must fail closed, not silently clamp.
|
||||
probe = AptData(APTDATA_MAGIC + bytes([0xAF, 0x10]), pool)
|
||||
probe.scope = ["only-one"]
|
||||
try:
|
||||
probe.decode_one(16)
|
||||
check("out-of-range scope index rejected", False)
|
||||
except DecodeError:
|
||||
check("out-of-range scope index rejected", True)
|
||||
|
||||
# Unknown opcode must refuse rather than resynchronise.
|
||||
probe = AptData(APTDATA_MAGIC + bytes([0xEE, 0x00]), pool)
|
||||
try:
|
||||
probe.decode_one(16)
|
||||
check("unknown opcode refuses to guess length", False)
|
||||
except DecodeError as e:
|
||||
check("unknown opcode refuses to guess length", "refusing to guess" in str(e))
|
||||
|
||||
# Whole-artifact decode.
|
||||
movie.scope = []
|
||||
movie.functions = []
|
||||
seeds = find_streams(movie)
|
||||
instrs: list[Instr] = []
|
||||
try:
|
||||
for s in seeds:
|
||||
instrs.extend(movie.decode_stream(s))
|
||||
check("whole artifact decodes", True, f"{len(instrs)} instructions")
|
||||
except DecodeError as e:
|
||||
check("whole artifact decodes", False, str(e))
|
||||
return 1
|
||||
|
||||
boundaries = {i.offset for i in instrs}
|
||||
bad = [i for i in instrs if i.target is not None and i.target not in boundaries]
|
||||
check("every branch lands on an instruction boundary", not bad,
|
||||
f"{len(bad)} bad")
|
||||
|
||||
# CheckIsKitLocked is CALLED here, never defined here: it is a method on the
|
||||
# mcSelectTeam child clip, whose class lives in another asset. Assert the
|
||||
# call site is bound exactly, and that this asset defines no such function.
|
||||
called = [i for i in instrs if i.comment and "CheckIsKitLocked" in i.comment]
|
||||
check("CheckIsKitLocked referenced exactly once", len(called) == 1,
|
||||
f"{[hex(i.offset) for i in called]}")
|
||||
check("CheckIsKitLocked reference is PushConstantWord (pool index > u8)",
|
||||
bool(called) and called[0].mnemonic == "PushConstantWord")
|
||||
check("CheckIsKitLocked is not defined in this asset",
|
||||
"CheckIsKitLocked" not in {f.name for f in movie.functions})
|
||||
|
||||
# The gate contract the native DP builder must satisfy.
|
||||
gate = [i for i in instrs if i.comment and "KITS_AVAILABLE" in i.comment]
|
||||
check("KITS_AVAILABLE read exactly once", len(gate) == 1)
|
||||
check("KITS_AVAILABLE read via GetNamedMember on the DP header",
|
||||
bool(gate) and gate[0].mnemonic == "GetNamedMember")
|
||||
|
||||
# 8-byte alignment is load-bearing: prove 4 would break the pool record.
|
||||
p4 = (0xD38 + 1 + 3) & ~3
|
||||
c4 = struct.unpack_from("<Q", movie.data, p4)[0]
|
||||
check("alignment is 8 not 4", c4 != 401, f"align4 count would be {c4:#x}")
|
||||
|
||||
print(f"\n {'ALL PASS' if ok else 'FAILURES PRESENT'}")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+587
@@ -0,0 +1,587 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Interpret FIFA 17's atom -> field-id dispatch functions instead of pattern-scanning them.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
CardsDLL turns a JSON key into an "atom index" (a position in the string-pointer
|
||||
table at .data 0x1802d2760), then a per-response-family mapper converts that index
|
||||
into an internal field id with a chain of integer compares and jump tables.
|
||||
|
||||
A previous attempt to recover each mapper's accepted atoms by scanning for
|
||||
`sub ecx,K` / `cmp ecx,L` / `ja` patterns produced a confidently wrong answer: it
|
||||
reported that no mapper accepts atom 424 (`manager`), while a live client plainly
|
||||
holds a resident manager record. Pattern scanning cannot see control flow, so it
|
||||
cannot tell which compares are actually reachable.
|
||||
|
||||
This module executes the mappers instead. The modelled subset is exactly what these
|
||||
functions use: the resolver call, integer cmp/sub/add/dec, conditional and computed
|
||||
jumps, jump-table loads out of the image, lea, movsxd, and `mov eax,imm; ret`.
|
||||
Anything outside that subset raises Unsupported, so a wrong field id is never
|
||||
returned silently.
|
||||
|
||||
TWO DECODER TRAPS THIS MODULE IS REQUIRED TO HANDLE
|
||||
---------------------------------------------------
|
||||
1. ModRM rm==5 with mod!=0 is [rbp+disp], NOT RIP-relative. Only mod==0 with rm==5
|
||||
is RIP-relative. Treating all rm==5 as RIP-relative hides rbp-based DTO accesses.
|
||||
Covered by test_rbp_relative_is_not_rip_relative.
|
||||
2. A constant frequently arrives in a register (`mov r8d,0x4` ... later stored), so
|
||||
searching for an immediate-to-memory store misses it. The interpreter tracks
|
||||
register values, so propagated constants are followed.
|
||||
Covered by test_constant_propagated_through_register.
|
||||
|
||||
Run `--selftest` to execute the positive controls. Negative results from this tool
|
||||
are only admissible when the selftest passes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import bisect
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REGS = ("rax", "rcx", "rdx", "rbx", "rsp", "rbp", "rsi", "rdi",
|
||||
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15")
|
||||
|
||||
ATOM_TABLE_BASE = 0x1802D2760 # validated against 6 known anchors, see anchors()
|
||||
ATOM_RESOLVER = 0x180180D00 # key string -> atom index, returns in eax
|
||||
ITEM_MAPPER = 0x18012FD40 # the DTO/item mapper: atom 568 'players' -> 1
|
||||
|
||||
|
||||
class Unsupported(Exception):
|
||||
"""The mapper used an instruction or address outside the modelled subset."""
|
||||
|
||||
|
||||
def s32(v: int) -> int:
|
||||
v &= 0xFFFFFFFF
|
||||
return v - 0x100000000 if v & 0x80000000 else v
|
||||
|
||||
|
||||
class Image:
|
||||
"""A parsed PE, with VA<->file mapping and .pdata function bounds."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.buf = path.read_bytes()
|
||||
b = self.buf
|
||||
pe = struct.unpack_from("<I", b, 0x3C)[0]
|
||||
if b[pe:pe + 4] != b"PE\0\0":
|
||||
raise ValueError(f"{path} is not a PE image")
|
||||
nsec = struct.unpack_from("<H", b, pe + 6)[0]
|
||||
optsz = struct.unpack_from("<H", b, pe + 20)[0]
|
||||
self.base = struct.unpack_from("<Q", b, pe + 24 + 24)[0]
|
||||
self.sections = []
|
||||
for i in range(nsec):
|
||||
o = pe + 24 + optsz + 40 * i
|
||||
name = b[o:o + 8].rstrip(b"\0").decode(errors="replace")
|
||||
vsz, va, rsz, raw = struct.unpack_from("<IIII", b, o + 8)
|
||||
self.sections.append((name, va, vsz, raw, rsz))
|
||||
self._funcs = None
|
||||
|
||||
def va2off(self, va: int):
|
||||
rva = va - self.base
|
||||
for _name, sva, vsz, raw, rsz in self.sections:
|
||||
if sva <= rva < sva + max(vsz, rsz):
|
||||
off = raw + (rva - sva)
|
||||
if off < len(self.buf):
|
||||
return off
|
||||
return None
|
||||
|
||||
def rd8(self, va: int) -> int:
|
||||
o = self.va2off(va)
|
||||
if o is None:
|
||||
raise Unsupported(f"unmapped byte read 0x{va:x}")
|
||||
return self.buf[o]
|
||||
|
||||
def rd32(self, va: int) -> int:
|
||||
o = self.va2off(va)
|
||||
if o is None:
|
||||
raise Unsupported(f"unmapped dword read 0x{va:x}")
|
||||
return struct.unpack_from("<I", self.buf, o)[0]
|
||||
|
||||
def cstr(self, va: int, maxlen: int = 96):
|
||||
o = self.va2off(va)
|
||||
if o is None:
|
||||
return None
|
||||
end = self.buf.find(b"\0", o, o + maxlen)
|
||||
if end < 0:
|
||||
return None
|
||||
try:
|
||||
return self.buf[o:end].decode("ascii")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
# ---- .pdata gives exact function bounds; never guess a prologue ----
|
||||
def functions(self):
|
||||
if self._funcs is None:
|
||||
sec = next(s for s in self.sections if s[0] == ".pdata")
|
||||
_n, _va, vsz, raw, _rsz = sec
|
||||
out = []
|
||||
for i in range(vsz // 12):
|
||||
beg, end, _unw = struct.unpack_from("<III", self.buf, raw + 12 * i)
|
||||
if beg or end:
|
||||
out.append((self.base + beg, self.base + end))
|
||||
out.sort()
|
||||
self._funcs = out
|
||||
return self._funcs
|
||||
|
||||
def function_of(self, va: int):
|
||||
fs = self.functions()
|
||||
starts = [f[0] for f in fs]
|
||||
i = bisect.bisect_right(starts, va) - 1
|
||||
if i >= 0 and fs[i][0] <= va < fs[i][1]:
|
||||
return fs[i]
|
||||
return None
|
||||
|
||||
def atom(self, index: int):
|
||||
ptr = struct.unpack_from("<Q", self.buf, self.va2off(ATOM_TABLE_BASE) + 8 * index)[0]
|
||||
return self.cstr(ptr)
|
||||
|
||||
def atom_index(self, name: str):
|
||||
off = self.va2off(ATOM_TABLE_BASE)
|
||||
for i in range(4096):
|
||||
ptr = struct.unpack_from("<Q", self.buf, off + 8 * i)[0]
|
||||
if self.cstr(ptr) == name:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
class Mapper:
|
||||
"""Executes one dispatch function for a given atom index."""
|
||||
|
||||
def __init__(self, image: Image, resolver: int = ATOM_RESOLVER):
|
||||
self.img = image
|
||||
self.resolver = resolver
|
||||
|
||||
def _ea(self, k: int, rex: int, r: dict):
|
||||
"""Decode ModRM[+SIB][+disp].
|
||||
|
||||
Returns (nbytes, dst_reg, addr, src_reg). addr is an int, or the marker
|
||||
("rip", disp) which the caller resolves once it knows the instruction
|
||||
length, or None for a register-form operand.
|
||||
|
||||
TRAP 1: rm==5 is RIP-relative ONLY when mod==0. With mod 1 or 2 it is
|
||||
[rbp+disp] and must be resolved from rbp.
|
||||
"""
|
||||
b = self.img.buf
|
||||
modrm = b[k]
|
||||
mod, rm = modrm >> 6, modrm & 7
|
||||
dst = REGS[(((modrm >> 3) & 7) | ((rex & 4) << 1)) & 15]
|
||||
n = 1
|
||||
if mod == 3:
|
||||
return n, dst, None, REGS[(rm | ((rex & 1) << 3)) & 15]
|
||||
base_v = idx_v = disp = 0
|
||||
if rm == 4:
|
||||
sib = b[k + 1]
|
||||
n += 1
|
||||
scale = 1 << (sib >> 6)
|
||||
ir = ((sib >> 3) & 7) | ((rex & 2) << 2)
|
||||
br = (sib & 7) | ((rex & 1) << 3)
|
||||
if (ir & 15) != 4:
|
||||
idx_v = r[REGS[ir & 15]] * scale
|
||||
if (sib & 7) == 5 and mod == 0:
|
||||
disp = struct.unpack_from("<i", b, k + n)[0]
|
||||
n += 4
|
||||
else:
|
||||
base_v = r[REGS[br & 15]]
|
||||
elif rm == 5 and mod == 0:
|
||||
disp = struct.unpack_from("<i", b, k + 1)[0]
|
||||
return n + 4, dst, ("rip", disp), None
|
||||
else:
|
||||
base_v = r[REGS[(rm | ((rex & 1) << 3)) & 15]]
|
||||
if mod == 1:
|
||||
disp = struct.unpack_from("<b", b, k + n)[0]
|
||||
n += 1
|
||||
elif mod == 2:
|
||||
disp = struct.unpack_from("<i", b, k + n)[0]
|
||||
n += 4
|
||||
return n, dst, (base_v + idx_v + disp) & 0xFFFFFFFFFFFFFFFF, None
|
||||
|
||||
@staticmethod
|
||||
def _cond(cc: int, last) -> bool:
|
||||
a, b = last
|
||||
sa, sb = s32(a), s32(b)
|
||||
ua, ub = a & 0xFFFFFFFF, b & 0xFFFFFFFF
|
||||
if cc == 0x4: return sa == sb
|
||||
if cc == 0x5: return sa != sb
|
||||
if cc == 0xF: return sa > sb
|
||||
if cc == 0xD: return sa >= sb
|
||||
if cc == 0xC: return sa < sb
|
||||
if cc == 0xE: return sa <= sb
|
||||
if cc == 0x7: return ua > ub
|
||||
if cc == 0x3: return ua >= ub
|
||||
if cc == 0x2: return ua < ub
|
||||
if cc == 0x6: return ua <= ub
|
||||
if cc == 0x8: return sa < sb
|
||||
if cc == 0x9: return sa >= sb
|
||||
raise Unsupported(f"condition code 0x{cc:x}")
|
||||
|
||||
def run(self, start: int, atom: int, limit: int = 5000) -> int:
|
||||
b = self.img.buf
|
||||
r = {k: 0 for k in REGS}
|
||||
last = (0, 0)
|
||||
va = start
|
||||
for _ in range(limit):
|
||||
i0 = self.img.va2off(va)
|
||||
if i0 is None:
|
||||
raise Unsupported(f"pc unmapped 0x{va:x}")
|
||||
j = i0
|
||||
while b[j] in (0x66, 0x67, 0xF2, 0xF3):
|
||||
j += 1
|
||||
rex = 0
|
||||
if 0x40 <= b[j] <= 0x4F:
|
||||
rex = b[j]
|
||||
j += 1
|
||||
op = b[j]
|
||||
pre = j - i0
|
||||
|
||||
if op == 0xC3:
|
||||
return r["rax"] & 0xFFFFFFFF
|
||||
if op == 0xCC:
|
||||
raise Unsupported(f"int3 at 0x{va:x}: ran off the end of the function")
|
||||
if op == 0xE8:
|
||||
tgt = va + pre + 5 + struct.unpack_from("<i", b, j + 1)[0]
|
||||
if tgt != self.resolver:
|
||||
raise Unsupported(f"call to non-resolver 0x{tgt:x} at 0x{va:x}")
|
||||
r["rax"] = atom & 0xFFFFFFFF # resolver returns the atom index
|
||||
va += pre + 5
|
||||
continue
|
||||
if op == 0xE9:
|
||||
va += pre + 5 + struct.unpack_from("<i", b, j + 1)[0]
|
||||
continue
|
||||
if op == 0xEB:
|
||||
va += pre + 2 + struct.unpack_from("<b", b, j + 1)[0]
|
||||
continue
|
||||
if 0x70 <= op <= 0x7F:
|
||||
nxt = va + pre + 2
|
||||
rel = struct.unpack_from("<b", b, j + 1)[0]
|
||||
va = nxt + rel if self._cond(op & 0xF, last) else nxt
|
||||
continue
|
||||
if op == 0x0F and 0x80 <= b[j + 1] <= 0x8F:
|
||||
nxt = va + pre + 6
|
||||
rel = struct.unpack_from("<i", b, j + 2)[0]
|
||||
va = nxt + rel if self._cond(b[j + 1] & 0xF, last) else nxt
|
||||
continue
|
||||
if 0xB8 <= op <= 0xBF:
|
||||
r[REGS[((op - 0xB8) | ((rex & 1) << 3)) & 15]] = struct.unpack_from("<I", b, j + 1)[0]
|
||||
va += pre + 5
|
||||
continue
|
||||
if op in (0x05, 0x2D, 0x3D):
|
||||
# accumulator short forms: add/sub/cmp eax, imm32
|
||||
imm = struct.unpack_from("<i", b, j + 1)[0]
|
||||
cur = r["rax"] & 0xFFFFFFFF
|
||||
if op == 0x3D:
|
||||
last = (cur, imm & 0xFFFFFFFF)
|
||||
elif op == 0x2D:
|
||||
r["rax"] = (cur - imm) & 0xFFFFFFFF
|
||||
last = (r["rax"], 0)
|
||||
else:
|
||||
r["rax"] = (cur + imm) & 0xFFFFFFFF
|
||||
last = (r["rax"], 0)
|
||||
va += pre + 5
|
||||
continue
|
||||
if op in (0x81, 0x83):
|
||||
w = 4 if op == 0x81 else 1
|
||||
modrm = b[j + 1]
|
||||
if modrm >> 6 != 3:
|
||||
raise Unsupported(f"{op:02x} memory form at 0x{va:x}")
|
||||
reg = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
|
||||
imm = struct.unpack_from("<i" if w == 4 else "<b", b, j + 2)[0]
|
||||
ext = (modrm >> 3) & 7
|
||||
cur = r[reg] & 0xFFFFFFFF
|
||||
if ext == 7:
|
||||
last = (cur, imm & 0xFFFFFFFF)
|
||||
elif ext == 5:
|
||||
r[reg] = (cur - imm) & 0xFFFFFFFF
|
||||
last = (r[reg], 0)
|
||||
elif ext == 0:
|
||||
r[reg] = (cur + imm) & 0xFFFFFFFF
|
||||
last = (r[reg], 0)
|
||||
else:
|
||||
raise Unsupported(f"{op:02x} /{ext} at 0x{va:x}")
|
||||
va += pre + 2 + w
|
||||
continue
|
||||
if op == 0xFF and b[j + 1] >> 6 == 3:
|
||||
ext = (b[j + 1] >> 3) & 7
|
||||
reg = REGS[((b[j + 1] & 7) | ((rex & 1) << 3)) & 15]
|
||||
if ext == 1:
|
||||
r[reg] = (r[reg] - 1) & 0xFFFFFFFF
|
||||
last = (r[reg], 0)
|
||||
va += pre + 2
|
||||
continue
|
||||
if ext == 4:
|
||||
va = r[reg]
|
||||
continue
|
||||
raise Unsupported(f"ff /{ext} at 0x{va:x}")
|
||||
if op == 0x0F and b[j + 1] == 0xB6:
|
||||
n, dst, addr, src = self._ea(j + 2, rex, r)
|
||||
end = va + pre + 2 + n
|
||||
if isinstance(addr, tuple):
|
||||
addr = end + addr[1]
|
||||
r[dst] = self.img.rd8(addr) if addr is not None else r[src] & 0xFF
|
||||
va = end
|
||||
continue
|
||||
if op in (0x8B, 0x8D):
|
||||
n, dst, addr, src = self._ea(j + 1, rex, r)
|
||||
end = va + pre + 1 + n
|
||||
if isinstance(addr, tuple):
|
||||
addr = end + addr[1]
|
||||
if op == 0x8D:
|
||||
if addr is None:
|
||||
raise Unsupported(f"lea with register operand at 0x{va:x}")
|
||||
r[dst] = addr
|
||||
else:
|
||||
if addr is None:
|
||||
# register form: mov r32, r32 (e.g. 8b c8 = mov ecx,eax)
|
||||
r[dst] = r[src] if rex & 8 else r[src] & 0xFFFFFFFF
|
||||
else:
|
||||
r[dst] = self.img.rd32(addr)
|
||||
va = end
|
||||
continue
|
||||
if op == 0x89:
|
||||
modrm = b[j + 1]
|
||||
if modrm >> 6 != 3:
|
||||
raise Unsupported(f"89 memory store at 0x{va:x}")
|
||||
src = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15]
|
||||
dst = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
|
||||
r[dst] = r[src] if rex & 8 else r[src] & 0xFFFFFFFF
|
||||
va += pre + 2
|
||||
continue
|
||||
if op == 0x63:
|
||||
modrm = b[j + 1]
|
||||
if modrm >> 6 != 3:
|
||||
raise Unsupported(f"63 memory form at 0x{va:x}")
|
||||
src = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
|
||||
dst = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15]
|
||||
r[dst] = s32(r[src]) & 0xFFFFFFFFFFFFFFFF
|
||||
va += pre + 2
|
||||
continue
|
||||
if op in (0x01, 0x03, 0x29, 0x2B, 0x39, 0x3B,
|
||||
0x09, 0x0B, 0x21, 0x23, 0x31, 0x33, 0x85):
|
||||
modrm = b[j + 1]
|
||||
if modrm >> 6 != 3:
|
||||
raise Unsupported(f"{op:02x} memory form at 0x{va:x}")
|
||||
a = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
|
||||
c = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15]
|
||||
m = 0xFFFFFFFFFFFFFFFF if rex & 8 else 0xFFFFFFFF
|
||||
if op == 0x01:
|
||||
r[a] = (r[a] + r[c]) & m
|
||||
elif op == 0x03:
|
||||
r[c] = (r[c] + r[a]) & m
|
||||
elif op == 0x29:
|
||||
r[a] = (r[a] - r[c]) & m
|
||||
last = (r[a] & 0xFFFFFFFF, 0)
|
||||
elif op == 0x2B:
|
||||
r[c] = (r[c] - r[a]) & m
|
||||
last = (r[c] & 0xFFFFFFFF, 0)
|
||||
elif op in (0x09, 0x0B, 0x21, 0x23, 0x31, 0x33):
|
||||
fn = {0x09: lambda x, y: x | y, 0x0B: lambda x, y: x | y,
|
||||
0x21: lambda x, y: x & y, 0x23: lambda x, y: x & y,
|
||||
0x31: lambda x, y: x ^ y, 0x33: lambda x, y: x ^ y}[op]
|
||||
if op in (0x09, 0x21, 0x31):
|
||||
r[a] = fn(r[a], r[c]) & m
|
||||
last = (r[a] & 0xFFFFFFFF, 0)
|
||||
else:
|
||||
r[c] = fn(r[c], r[a]) & m
|
||||
last = (r[c] & 0xFFFFFFFF, 0)
|
||||
elif op == 0x85:
|
||||
last = ((r[a] & r[c]) & 0xFFFFFFFF, 0)
|
||||
elif op == 0x39:
|
||||
last = (r[a] & 0xFFFFFFFF, r[c] & 0xFFFFFFFF)
|
||||
else:
|
||||
last = (r[c] & 0xFFFFFFFF, r[a] & 0xFFFFFFFF)
|
||||
va += pre + 2
|
||||
continue
|
||||
if op == 0x90:
|
||||
va += pre + 1
|
||||
continue
|
||||
if op == 0x0F and b[j + 1] == 0x1F:
|
||||
n, _d, _a, _s = self._ea(j + 2, rex, r)
|
||||
va += pre + 2 + n
|
||||
continue
|
||||
raise Unsupported(f"opcode {op:02x} at 0x{va:x}")
|
||||
raise Unsupported("instruction limit reached")
|
||||
|
||||
|
||||
def find_mappers(img: Image, resolver: int = ATOM_RESOLVER):
|
||||
"""Every function containing a direct call to the atom resolver."""
|
||||
sec = next(s for s in img.sections if s[0] == ".text")
|
||||
_n, tva, _vsz, traw, trsz = sec
|
||||
out = {}
|
||||
for i in range(traw, traw + trsz - 5):
|
||||
if img.buf[i] != 0xE8:
|
||||
continue
|
||||
va = img.base + tva + (i - traw)
|
||||
if va + 5 + struct.unpack_from("<i", img.buf, i + 1)[0] == resolver:
|
||||
f = img.function_of(va)
|
||||
if f:
|
||||
out.setdefault(f[0], []).append(va)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# selftest: the two decoder traps plus the live-verified positive controls
|
||||
# --------------------------------------------------------------------------
|
||||
def test_atom_anchors(img: Image) -> list:
|
||||
"""The atom table base must reproduce known anchors, or every index is wrong."""
|
||||
anchors = {11: "actives", 363: "itemData", 376: "kicktakers",
|
||||
424: "manager", 568: "players", 718: "squadActives"}
|
||||
fails = []
|
||||
for idx, want in anchors.items():
|
||||
got = img.atom(idx)
|
||||
if got != want:
|
||||
fails.append(f"atom[{idx}] = {got!r}, expected {want!r}")
|
||||
return fails
|
||||
|
||||
|
||||
def test_rbp_relative_is_not_rip_relative(img: Image) -> list:
|
||||
"""TRAP 1. mod!=0 with rm==5 must resolve as [rbp+disp], not RIP-relative.
|
||||
|
||||
Encoding under test: 8b 4d 20 == mov ecx,[rbp+0x20] (mod=01, rm=101).
|
||||
A decoder that treats rm==5 as RIP-relative computes a wildly different
|
||||
address and silently reads the wrong memory.
|
||||
"""
|
||||
m = Mapper(img)
|
||||
r = {k: 0 for k in REGS}
|
||||
r["rbp"] = 0x140000000
|
||||
saved = img.buf
|
||||
try:
|
||||
img.buf = bytes.fromhex("8b4d20")
|
||||
n, dst, addr, _src = m._ea(1, 0, r)
|
||||
finally:
|
||||
img.buf = saved
|
||||
fails = []
|
||||
if isinstance(addr, tuple):
|
||||
fails.append("mod=01 rm=101 decoded as RIP-relative; must be [rbp+disp]")
|
||||
elif addr != 0x140000020:
|
||||
fails.append(f"[rbp+0x20] resolved to 0x{addr:x}, expected 0x140000020")
|
||||
if dst != "rcx":
|
||||
fails.append(f"destination decoded as {dst}, expected rcx")
|
||||
if n != 2:
|
||||
fails.append(f"modrm+disp8 consumed {n} bytes, expected 2")
|
||||
return fails
|
||||
|
||||
|
||||
def test_constant_propagated_through_register(img: Image) -> list:
|
||||
"""TRAP 2. A constant reaching a use through a register must be followed.
|
||||
|
||||
Program: mov eax,0; mov r8d,4; mov eax,r8d; ret -> must yield 4, which is
|
||||
only observable if register values propagate. Scanning for an immediate
|
||||
store would see nothing.
|
||||
"""
|
||||
m = Mapper(img)
|
||||
saved = img.buf
|
||||
prog = bytes.fromhex("b800000000" "41b804000000" "4489c0" "c3")
|
||||
try:
|
||||
img.buf = prog
|
||||
img_va2off = img.va2off
|
||||
img.va2off = lambda va: va if 0 <= va < len(prog) else None
|
||||
got = m.run(0, 0)
|
||||
finally:
|
||||
img.buf = saved
|
||||
img.va2off = img_va2off
|
||||
return [] if got == 4 else [f"register-propagated constant yielded {got}, expected 4"]
|
||||
|
||||
|
||||
def test_item_mapper_controls(img: Image) -> list:
|
||||
"""Live/disassembly-verified behaviour of the item mapper."""
|
||||
m = Mapper(img)
|
||||
fails = []
|
||||
got = m.run(ITEM_MAPPER, 568)
|
||||
if got != 1:
|
||||
fails.append(f"item mapper atom 568 'players' -> {got}, expected 1")
|
||||
got = m.run(ITEM_MAPPER, 11)
|
||||
if got != 0:
|
||||
fails.append(f"item mapper atom 11 'actives' -> {got}, expected 0")
|
||||
return fails
|
||||
|
||||
|
||||
def test_manager_424_is_accepted_somewhere(img: Image) -> list:
|
||||
"""MANDATORY control. A live client holds a resident manager record, so some
|
||||
mapper must map atom 424 to a non-zero field id. The previous pattern-scan
|
||||
method failed exactly here, and any replacement must not."""
|
||||
m = Mapper(img)
|
||||
accepting = []
|
||||
for start in find_mappers(img):
|
||||
try:
|
||||
if m.run(start, 424):
|
||||
accepting.append(start)
|
||||
except Unsupported:
|
||||
continue
|
||||
if not accepting:
|
||||
return ["no mapper maps atom 424 'manager' to a non-zero field id, "
|
||||
"which contradicts the live resident manager record"]
|
||||
return []
|
||||
|
||||
|
||||
def selftest(img: Image) -> int:
|
||||
checks = [
|
||||
("atom table anchors", test_atom_anchors),
|
||||
("trap 1: rbp-relative modrm", test_rbp_relative_is_not_rip_relative),
|
||||
("trap 2: constant via register", test_constant_propagated_through_register),
|
||||
("item mapper positive controls", test_item_mapper_controls),
|
||||
("mandatory: manager atom 424 accepted", test_manager_424_is_accepted_somewhere),
|
||||
]
|
||||
bad = 0
|
||||
for name, fn in checks:
|
||||
try:
|
||||
fails = fn(img)
|
||||
except Exception as exc: # noqa: BLE001 - report, don't mask
|
||||
fails = [f"raised {type(exc).__name__}: {exc}"]
|
||||
if fails:
|
||||
bad += 1
|
||||
print(f" FAIL {name}")
|
||||
for f in fails:
|
||||
print(f" {f}")
|
||||
else:
|
||||
print(f" ok {name}")
|
||||
print("\n ALL PASS" if not bad else f"\n {bad} CHECK(S) FAILED - negative results are NOT admissible")
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("image", type=Path, help="CardsDLL_Win64_retail.dll")
|
||||
ap.add_argument("--selftest", action="store_true")
|
||||
ap.add_argument("--atom", type=int, action="append", default=[],
|
||||
help="atom index to resolve through every mapper")
|
||||
ap.add_argument("--name", action="append", default=[],
|
||||
help="atom name to resolve through every mapper")
|
||||
args = ap.parse_args()
|
||||
img = Image(args.image)
|
||||
|
||||
if args.selftest:
|
||||
return selftest(img)
|
||||
|
||||
atoms = list(args.atom)
|
||||
for nm in args.name:
|
||||
idx = img.atom_index(nm)
|
||||
if idx is None:
|
||||
print(f" atom {nm!r} not found in the table")
|
||||
return 2
|
||||
atoms.append(idx)
|
||||
if not atoms:
|
||||
ap.error("give --atom/--name, or --selftest")
|
||||
|
||||
m = Mapper(img)
|
||||
mappers = find_mappers(img)
|
||||
print(f" {len(mappers)} mapper function(s) found\n")
|
||||
for a in atoms:
|
||||
print(f" === atom {a} ({img.atom(a)!r}) ===")
|
||||
rows, unsup = [], 0
|
||||
for start in sorted(mappers):
|
||||
try:
|
||||
fid = m.run(start, a)
|
||||
except Unsupported:
|
||||
unsup += 1
|
||||
continue
|
||||
if fid:
|
||||
rows.append((start, fid))
|
||||
for start, fid in rows:
|
||||
print(f" mapper 0x{start:x} -> field id {fid} (0x{fid:x})")
|
||||
print(f" {len(rows)} mapper(s) accept it; {unsup} not modelled\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Canonical FIFA 17 kit map, joined from the extracted client tables.
|
||||
|
||||
Authority for every kit question that a table can answer, so nobody has to
|
||||
reverse a binary for a fact that is sitting in a JSON row. Reads only:
|
||||
|
||||
fifa17-recon/data/tables/fcc_kitcards.json the FUT KIT CARD definitions
|
||||
fifa17-recon/data/tables/teamkits.json the ENGINE kit rows
|
||||
|
||||
Everything printed is TABLE_PROVEN unless the line says otherwise: it is a
|
||||
direct count over the full table, not a sample.
|
||||
|
||||
Usage:
|
||||
python3 audit_fifa17_kits.py human report
|
||||
python3 audit_fifa17_kits.py --json machine-readable, for tests/tools
|
||||
python3 audit_fifa17_kits.py --team 21 drill into one team
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
TABLES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables")
|
||||
|
||||
# TABLE_PROVEN, established by this script's own discriminating test (see
|
||||
# category_type_evidence): a kit CARD's `category` selects the engine kit ROW's
|
||||
# `teamkittypetechid` at the same (team, year).
|
||||
CATEGORY_TO_KIT_TYPE = {2: 0, 3: 1, 5: 2}
|
||||
KIT_TYPE_NAME = {0: "HOME", 1: "AWAY", 2: "THIRD", 3: "FOURTH", 5: "GK", 6: "SPECIAL6", 7: "SPECIAL7"}
|
||||
|
||||
|
||||
def load(name):
|
||||
with open(os.path.join(TABLES, name), "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
return data if isinstance(data, list) else data.get("rows", data)
|
||||
|
||||
|
||||
def band(carddbid: int) -> int:
|
||||
"""The 6_300_000 / 6_400_000 id band."""
|
||||
return (carddbid // 100_000) * 100_000
|
||||
|
||||
|
||||
def category_type_evidence(cards, kits):
|
||||
"""The DISCRIMINATING test behind CATEGORY_TO_KIT_TYPE.
|
||||
|
||||
Asserting "category 3 means away" because away kits usually exist is not
|
||||
evidence -- types 0/1/2 are present for most teams, so the claim is true by
|
||||
construction. What discriminates is the teams that LACK a type: if category 3
|
||||
really means type 1, then no category-3 card may exist for a (team, year)
|
||||
that has no type-1 row. Same for category 5 and type 2.
|
||||
"""
|
||||
kits_by = defaultdict(set)
|
||||
for r in kits:
|
||||
kits_by[(r["teamtechid"], r["year"])].add(r["teamkittypetechid"])
|
||||
cards_by = defaultdict(list)
|
||||
for r in cards:
|
||||
cards_by[(r["teamid"], r["year"])].append(r)
|
||||
|
||||
out = {}
|
||||
for cat, want in CATEGORY_TO_KIT_TYPE.items():
|
||||
# keys that HAVE teamkits rows but not the wanted type
|
||||
lacking = [k for k, t in kits_by.items() if t and want not in t]
|
||||
counterexamples = [
|
||||
r["carddbid"] for k in lacking for r in cards_by.get(k, []) if r["category"] == cat
|
||||
]
|
||||
out[cat] = {
|
||||
"kit_type": want,
|
||||
"name": KIT_TYPE_NAME[want],
|
||||
"keys_lacking_type": len(lacking),
|
||||
"counterexamples": counterexamples,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def audit():
|
||||
cards = load("fcc_kitcards.json")
|
||||
kits = load("teamkits.json")
|
||||
|
||||
kits_by = defaultdict(list)
|
||||
for r in kits:
|
||||
kits_by[(r["teamtechid"], r["year"])].append(r)
|
||||
|
||||
rows = []
|
||||
for c in cards:
|
||||
key = (c["teamid"], c["year"])
|
||||
want = CATEGORY_TO_KIT_TYPE.get(c["category"])
|
||||
match = next((k for k in kits_by.get(key, []) if k["teamkittypetechid"] == want), None)
|
||||
rows.append(
|
||||
{
|
||||
"carddbid": c["carddbid"],
|
||||
"band": band(c["carddbid"]),
|
||||
"teamid": c["teamid"],
|
||||
"year": c["year"],
|
||||
"category": c["category"],
|
||||
"kit_type": want,
|
||||
"kit_type_name": KIT_TYPE_NAME.get(want, "?"),
|
||||
"assetid": c["assetid"],
|
||||
"cardassetid": c["cardassetid"],
|
||||
"value": c["value"],
|
||||
"weightrare": c["weightrare"],
|
||||
# These are BYTE OFFSETS into the table's string blob, not ids.
|
||||
# The blob is not among the extracted tables, so a kit's own
|
||||
# name string is NOT recoverable from data/tables alone.
|
||||
"name_offset": c["name"],
|
||||
"header_offset": c["header"],
|
||||
"description_offset": c["description"],
|
||||
"teamkitid": match["teamkitid"] if match else None,
|
||||
"teamkit_islocked": match["islocked"] if match else None,
|
||||
"teamkit_embargoed": match["isembargoed"] if match else None,
|
||||
}
|
||||
)
|
||||
|
||||
dupes = [k for k, n in Counter((r["teamid"], r["year"], r["category"]) for r in rows).items() if n > 1]
|
||||
|
||||
return {
|
||||
"counts": {"fcc_kitcards": len(cards), "teamkits": len(kits)},
|
||||
"bands": dict(sorted(Counter(r["band"] for r in rows).items())),
|
||||
"band_x_assetid": {f"{b}/{a}": n for (b, a), n in
|
||||
sorted(Counter((r["band"], r["assetid"]) for r in rows).items())},
|
||||
"band_x_category": {f"{b}/{c}": n for (b, c), n in
|
||||
sorted(Counter((r["band"], r["category"]) for r in rows).items())},
|
||||
"category_counts": dict(sorted(Counter(r["category"] for r in rows).items())),
|
||||
"cardassetid": sorted({r["cardassetid"] for r in rows}),
|
||||
"category_type_evidence": category_type_evidence(cards, kits),
|
||||
"unmatched": [r["carddbid"] for r in rows if r["teamkitid"] is None],
|
||||
"duplicate_team_year_category": dupes,
|
||||
"teamkits_islocked": dict(Counter(r["islocked"] for r in kits)),
|
||||
"teamkits_embargoed": dict(Counter(r["isembargoed"] for r in kits)),
|
||||
"teamkits_types": dict(sorted(Counter(r["teamkittypetechid"] for r in kits).items())),
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--json", action="store_true")
|
||||
ap.add_argument("--team", type=int)
|
||||
args = ap.parse_args()
|
||||
|
||||
a = audit()
|
||||
if args.json:
|
||||
json.dump(a, sys.stdout, indent=2)
|
||||
return
|
||||
|
||||
print("FIFA 17 kit map — TABLE_PROVEN from the extracted client tables")
|
||||
print(f" fcc_kitcards rows : {a['counts']['fcc_kitcards']}")
|
||||
print(f" teamkits rows : {a['counts']['teamkits']}")
|
||||
|
||||
print("\nid bands")
|
||||
for b, n in a["bands"].items():
|
||||
print(f" {b}: {n}")
|
||||
print("\nband/assetid (assetid is fully determined by band)")
|
||||
for k, n in a["band_x_assetid"].items():
|
||||
print(f" {k}: {n}")
|
||||
print("\nband/category")
|
||||
for k, n in a["band_x_category"].items():
|
||||
print(f" {k}: {n}")
|
||||
print(f"\ncardassetid values: {a['cardassetid']} (the FUT card frame, not the kit art)")
|
||||
|
||||
print("\ncategory -> engine kit type, with the discriminating test")
|
||||
for cat, ev in a["category_type_evidence"].items():
|
||||
verdict = "HOLDS" if not ev["counterexamples"] else f"FAILS ({len(ev['counterexamples'])})"
|
||||
print(f" category {cat} -> type {ev['kit_type']} {ev['name']:6s} "
|
||||
f"| {ev['keys_lacking_type']:4d} (team,year) keys lack that type, "
|
||||
f"{len(ev['counterexamples'])} counterexample(s) -> {verdict}")
|
||||
|
||||
print("\nengine kit types present in teamkits")
|
||||
for t, n in a["teamkits_types"].items():
|
||||
print(f" type {t} {KIT_TYPE_NAME.get(t,'?'):8s}: {n}")
|
||||
|
||||
print(f"\nteamkits islocked : {a['teamkits_islocked']} <- every row, so NOT the selector lock")
|
||||
print(f"teamkits embargoed : {a['teamkits_embargoed']}")
|
||||
|
||||
print(f"\nanomalies")
|
||||
print(f" cards with no matching teamkits row : {len(a['unmatched'])}")
|
||||
print(f" duplicate (team,year,category) : {len(a['duplicate_team_year_category'])}")
|
||||
|
||||
if args.team is not None:
|
||||
print(f"\n=== team {args.team} ===")
|
||||
print(f" {'carddbid':10s} {'cat':4s} {'type':7s} {'year':6s} {'assetid':8s} {'teamkitid':10s} locked")
|
||||
for r in sorted((r for r in a["rows"] if r["teamid"] == args.team), key=lambda r: r["carddbid"]):
|
||||
print(f" {r['carddbid']:<10} {r['category']:<4} {r['kit_type_name']:<7} {r['year']:<6} "
|
||||
f"{r['assetid']:<8} {str(r['teamkitid']):<10} {r['teamkit_islocked']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Recover the kit caption/localisation vocabulary from the UNPACKED CardsDLL.
|
||||
|
||||
Why CardsDLL and not FIFA17.exe: CardsDLL is not packed, so a MISS here is
|
||||
meaningful. FIFA17.exe is Denuvo-packed and only partially readable -- a hit
|
||||
there is useful, a miss proves nothing. Every run therefore prints a positive
|
||||
control first; if the control fails, the run is void and no negative may be
|
||||
quoted from it.
|
||||
|
||||
Usage: python3 cardsdll_kit_strings.py [path-to-CardsDLL]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
DEFAULT = os.path.expanduser(
|
||||
"~/.cache/openfut-investigation/bin/CardsDLL_Win64_retail.dll"
|
||||
)
|
||||
|
||||
# Strings that MUST be present. If any is missing the search is broken.
|
||||
CONTROLS = [b"activeHomeKit", b"cardsubtypeid", b"resourceId", b"activeAwayKit"]
|
||||
|
||||
# The kit caption vocabulary this project has referred to, plus neighbours worth
|
||||
# knowing about either way.
|
||||
PROBES = [
|
||||
b"FUT_UC_KITS", b"TeamName_Abbr15_", b"TeamName_Abbr15", b"TeamName_",
|
||||
b"FUT_UC_", b"StadiumName_", b"Badge", b"Stadium",
|
||||
b"activeBadge", b"activeBall", b"activeStadium",
|
||||
b"kit", b"Kit", b"KIT",
|
||||
b"home", b"Home", b"HOME", b"away", b"Away", b"AWAY",
|
||||
b"locked", b"Locked", b"LOCKED", b"unlock",
|
||||
b"category", b"year", b"teamid", b"teamId",
|
||||
b"DataProvider", b"itemData", b"itemType", b"itemState",
|
||||
]
|
||||
|
||||
|
||||
def ascii_strings(data, minlen=4):
|
||||
for m in re.finditer(rb"[ -~]{%d,}" % minlen, data):
|
||||
yield m.start(), m.group()
|
||||
|
||||
|
||||
def main():
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
|
||||
data = open(path, "rb").read()
|
||||
print(f"{os.path.basename(path)} {len(data)} bytes")
|
||||
|
||||
print("\n-- positive control (a miss voids every negative below) --")
|
||||
ok = True
|
||||
for c in CONTROLS:
|
||||
n = data.count(c)
|
||||
print(f" {c.decode():16s} {n}")
|
||||
if n == 0:
|
||||
ok = False
|
||||
if not ok:
|
||||
print(" CONTROL FAILED — do not quote negatives from this run.")
|
||||
return 1
|
||||
|
||||
print("\n-- probe counts --")
|
||||
for p in PROBES:
|
||||
print(f" {p.decode():18s} {data.count(p)}")
|
||||
|
||||
# Whole-string table: every standalone string containing kit-ish substrings.
|
||||
print("\n-- standalone strings matching kit/team/caption vocabulary --")
|
||||
pat = re.compile(rb"(?i)(kit|teamname|abbr|stadiumname|fut_uc|locked|unlock)")
|
||||
seen = set()
|
||||
for off, s in ascii_strings(data, 5):
|
||||
if pat.search(s) and s not in seen:
|
||||
seen.add(s)
|
||||
print(f" @{off:#08x} {s.decode('latin1')[:110]}")
|
||||
print(f" ({len(seen)} distinct)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Classify call sites of the 130000/130001 provider stubs.
|
||||
|
||||
A call whose result is COMPARED implements a predicate ("is this the FUT custom
|
||||
club?"). Only a call whose result is STORED can assign a team id. This turns an
|
||||
unreadable 81-site list into the handful that could actually introduce 130000
|
||||
into a struct.
|
||||
|
||||
classify_calls.py <asmfile> <target_va_hex> [more_targets...]
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
|
||||
asm = sys.argv[1]
|
||||
targets = [t.lower().lstrip("0x") for t in sys.argv[2:]]
|
||||
|
||||
lines = []
|
||||
for l in open(asm, errors="replace"):
|
||||
m = re.match(r"\s*([0-9a-f]+):\s+((?:[0-9a-f]{2} )+)\s*(.*)", l)
|
||||
if m:
|
||||
lines.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
idx = {a: i for i, (a, _t) in enumerate(lines)}
|
||||
|
||||
STORE = re.compile(r"^mov\s+(?:DWORD PTR |QWORD PTR )?\[[^\]]+\],(eax|rax)\b")
|
||||
CMP = re.compile(r"^(cmp|sub|test)\b.*\b(eax|rax)\b")
|
||||
MOVREG = re.compile(r"^mov\s+(e[a-z]{2}|r\d+d|r[a-z]{2}),(eax|rax)\b")
|
||||
|
||||
for tgt in targets:
|
||||
print(f"\n ===== callers of 0x{tgt} =====")
|
||||
stores, cmps, other = [], [], []
|
||||
for i, (a, txt) in enumerate(lines):
|
||||
if not txt.startswith("call") or tgt not in txt:
|
||||
continue
|
||||
# look at the next few instructions for the fate of eax
|
||||
window = [lines[j][1] for j in range(i + 1, min(i + 7, len(lines)))]
|
||||
verdict, detail = "other", window[0] if window else ""
|
||||
for w in window:
|
||||
if STORE.match(w):
|
||||
verdict, detail = "STORE", w
|
||||
break
|
||||
if CMP.match(w):
|
||||
verdict, detail = "compare", w
|
||||
break
|
||||
if MOVREG.match(w):
|
||||
verdict, detail = "movreg", w
|
||||
break
|
||||
rec = (a, detail)
|
||||
(stores if verdict == "STORE" else cmps if verdict == "compare" else other).append(rec)
|
||||
print(f" STORE (can assign) : {len(stores)}")
|
||||
for a, d in stores:
|
||||
print(f" 0x{a:x} {d}")
|
||||
print(f" compare (predicate) : {len(cmps)}")
|
||||
print(f" other/moved to reg : {len(other)}")
|
||||
for a, d in other[:14]:
|
||||
print(f" 0x{a:x} {d}")
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only probe v3: discriminate "kits never ingested" from "ingested then freed".
|
||||
|
||||
Staff was refetched by the client at 18:40:38, four minutes before the scan, and
|
||||
players are resident. If staff/badge/stadium records are resident but the two
|
||||
kits are not, the kits are being dropped specifically.
|
||||
"""
|
||||
import re
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
NEEDLES = {
|
||||
"PLAYER resourceId 83906881 (control, resident)": 83906881,
|
||||
"STAFF resourceId 9000081 (headcoach-ish)": 9000081,
|
||||
"STAFF resourceId 3000083 (x2)": 3000083,
|
||||
"STAFF resourceId 1000509": 1000509,
|
||||
"STAFF instance 100004870": 100004870,
|
||||
"BADGE resourceId 6000005": 6000005,
|
||||
"BADGE instance 100004875": 100004875,
|
||||
"STADIUM resourceId 6200000": 6200000,
|
||||
"STADIUM instance 100004876": 100004876,
|
||||
"KIT resourceId 6300006 (home)": 6300006,
|
||||
"KIT resourceId 6400003 (away)": 6400003,
|
||||
"KIT instance 100004874 (home)": 100004874,
|
||||
"KIT instance 100004873 (away)": 100004873,
|
||||
"KIT cardassetid 35": 35,
|
||||
}
|
||||
|
||||
|
||||
def find_pid():
|
||||
out = subprocess.run(["pgrep", "-f", "FIFA17.exe"], capture_output=True, text=True).stdout.split()
|
||||
for p in out:
|
||||
try:
|
||||
with open(f"/proc/{p}/maps") as fh:
|
||||
if "CardsDLL" in fh.read():
|
||||
return int(p)
|
||||
except OSError:
|
||||
continue
|
||||
return int(out[0]) if out else None
|
||||
|
||||
|
||||
def main():
|
||||
pid = find_pid()
|
||||
if not pid:
|
||||
sys.exit("FIFA17.exe not running")
|
||||
print(f"pid={pid}")
|
||||
|
||||
regs = []
|
||||
with open(f"/proc/{pid}/maps") as fh:
|
||||
for line in fh:
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) (\S{4}) \S+ \S+ \S+\s*(.*)", line)
|
||||
if not m:
|
||||
continue
|
||||
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)
|
||||
if "r" in perms and not path.startswith("/dev/") and (hi - lo) <= (512 << 20):
|
||||
regs.append((lo, hi))
|
||||
|
||||
hits = {k: [] for k in NEEDLES}
|
||||
pats = {k: struct.pack("<I", v) for k, v in NEEDLES.items()}
|
||||
mib = 0
|
||||
|
||||
with open(f"/proc/{pid}/mem", "rb", buffering=0) as mem:
|
||||
for lo, hi in regs:
|
||||
try:
|
||||
mem.seek(lo)
|
||||
buf = mem.read(hi - lo)
|
||||
except (OSError, ValueError, OverflowError):
|
||||
continue
|
||||
if not buf:
|
||||
continue
|
||||
mib += len(buf)
|
||||
for k, needle in pats.items():
|
||||
start = 0
|
||||
while len(hits[k]) < 5000:
|
||||
i = buf.find(needle, start)
|
||||
if i < 0:
|
||||
break
|
||||
hits[k].append(lo + i)
|
||||
start = i + 4
|
||||
|
||||
print(f"read {mib/(1<<20):.0f} MiB\n" + "=" * 66)
|
||||
|
||||
def rd(base, off, size=4):
|
||||
try:
|
||||
mem.seek(base + off)
|
||||
raw = mem.read(size)
|
||||
return int.from_bytes(raw, "little") if len(raw) == size else None
|
||||
except (OSError, ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
for k in NEEDLES:
|
||||
addrs = hits[k]
|
||||
# count how many look like real item records (plausible cardtype)
|
||||
recs = []
|
||||
for a in addrs[:3000]:
|
||||
base = a - 0x18
|
||||
ct = rd(base, 0x4C)
|
||||
if ct in (1, 2, 3, 4, 5, 6, 7, 9):
|
||||
recs.append((base, ct))
|
||||
flag = "" if addrs else " <-- ZERO"
|
||||
print(f" {len(addrs):6d} raw / {len(recs):4d} record-shaped {k}{flag}")
|
||||
for base, ct in recs[:3]:
|
||||
print(f" @{base:#x} cardtype={ct} subtype={rd(base,0x50)} "
|
||||
f"itemState={rd(base,0x5c)} +0x60={rd(base,0x60)} "
|
||||
f"teamid={rd(base,0x94)} cat={rd(base,0xb8)} year={rd(base,0xba,2)}")
|
||||
print("=" * 66)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+332
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17 Screen event 0x30 through command 0x128 and ScenarioModeStart.
|
||||
|
||||
The generated GDB program uses hardware breakpoints, only reads registers and
|
||||
client memory, logs, and continues. Seven breakpoints are rotated so no more
|
||||
than four are enabled. It never calls client functions, writes client memory,
|
||||
emits events, or drives input.
|
||||
|
||||
command_128_trace.py [pid] [--output PATH]
|
||||
command_128_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
MANAGER_SELECT_ACTION_SOURCE_RVA = 0x0705A620
|
||||
MANAGER_SELECT_ACTION_RESULT_RVA = 0x07CDC4A6
|
||||
SCREEN_EVENT_CHANNEL_ROUTER_RVA = 0x080CE230
|
||||
SCREEN_EVENT_DISPATCH_RVA = 0x080CF790
|
||||
SKILL_INSTRUCTIONS_SCREEN_RVA = 0x07DCA400
|
||||
GAMEPLAY_COMMAND_DISPATCH_RVA = 0x07A8F6C0
|
||||
FREE_ROAM_COMMAND_128_RVA = 0x07A92B0F
|
||||
SCENARIO_SCHEDULER_RVA = 0x07AC3A40
|
||||
SCENARIO_MANAGER_START_RVA = 0x07B1C2B0
|
||||
MODE_ZERO_SCENARIO_START_RVA = 0x07B1C190
|
||||
GAMEPLAY_GLOBAL_RVA = 0x04BFB910
|
||||
SCREEN_VTABLE_RVA = 0x03B3ECC0
|
||||
FREE_ROAM_VTABLE_RVA = 0x03AEDF58
|
||||
MODE_ZERO_CHILD_VTABLE_RVA = 0x03AE9C00
|
||||
|
||||
|
||||
def addresses(base: int) -> dict[str, int]:
|
||||
return {
|
||||
"manager_select_source": base + MANAGER_SELECT_ACTION_SOURCE_RVA,
|
||||
"manager_select_action": base + MANAGER_SELECT_ACTION_RESULT_RVA,
|
||||
"screen_event_router": base + SCREEN_EVENT_CHANNEL_ROUTER_RVA,
|
||||
"screen_event_dispatch": base + SCREEN_EVENT_DISPATCH_RVA,
|
||||
"instructions_screen": base + SKILL_INSTRUCTIONS_SCREEN_RVA,
|
||||
"command_dispatch": base + GAMEPLAY_COMMAND_DISPATCH_RVA,
|
||||
"free_roam_case": base + FREE_ROAM_COMMAND_128_RVA,
|
||||
"scheduler": base + SCENARIO_SCHEDULER_RVA,
|
||||
"manager_start": base + SCENARIO_MANAGER_START_RVA,
|
||||
"scenario_start": base + MODE_ZERO_SCENARIO_START_RVA,
|
||||
"gameplay_global": base + GAMEPLAY_GLOBAL_RVA,
|
||||
"screen_vtable": base + SCREEN_VTABLE_RVA,
|
||||
"free_roam_vtable": base + FREE_ROAM_VTABLE_RVA,
|
||||
"mode_zero_child_vtable": base + MODE_ZERO_CHILD_VTABLE_RVA,
|
||||
}
|
||||
|
||||
|
||||
def gdb_prelude(pid: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted off
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
"""
|
||||
|
||||
|
||||
def build_script(pid: int, fifa_base: int, output: str) -> str:
|
||||
address = addresses(fifa_base)
|
||||
return (
|
||||
gdb_prelude(pid, output)
|
||||
+ f"""define snapshot_gameplay
|
||||
set $snap_gameplay_global = *(void**)0x{address['gameplay_global']:x}
|
||||
set $snap_listener_manager = 0
|
||||
set $snap_listener_table = 0
|
||||
set $snap_listener_index = -1
|
||||
set $snap_free_roam = 0
|
||||
set $snap_free_state = -1
|
||||
set $snap_free_111 = -1
|
||||
set $snap_free_112 = -1
|
||||
set $snap_free_124 = -1
|
||||
set $snap_selected = 0
|
||||
set $snap_selected_vtable = 0
|
||||
set $snap_selected_mode = -1
|
||||
if $snap_gameplay_global != 0
|
||||
set $snap_listener_manager = *(void**)($snap_gameplay_global+0x58)
|
||||
end
|
||||
if $snap_listener_manager != 0
|
||||
set $snap_listener_table = *(void**)$snap_listener_manager
|
||||
end
|
||||
if $snap_listener_table != 0
|
||||
set $snap_free_roam = *(void**)$snap_listener_table
|
||||
set $snap_listener_index = *(int*)($snap_listener_table+0x20)
|
||||
if $snap_listener_index >= 0 && $snap_listener_index < 3
|
||||
set $snap_selected = *(void**)($snap_listener_table+$snap_listener_index*8)
|
||||
end
|
||||
end
|
||||
if $snap_free_roam != 0
|
||||
set $snap_free_state = *(int*)($snap_free_roam+0x30)
|
||||
set $snap_free_111 = *(unsigned char*)($snap_free_roam+0x111)
|
||||
set $snap_free_112 = *(unsigned char*)($snap_free_roam+0x112)
|
||||
set $snap_free_124 = *(int*)($snap_free_roam+0x124)
|
||||
end
|
||||
if $snap_selected != 0
|
||||
set $snap_selected_vtable = *(void**)$snap_selected
|
||||
set $snap_selected_mode = *(int*)($snap_selected+0x18)
|
||||
end
|
||||
end
|
||||
|
||||
set $action_count = 0
|
||||
hbreak *0x{address['manager_select_action']:x}
|
||||
commands
|
||||
silent
|
||||
set $action_count = $action_count+1
|
||||
set $provider = $rbx
|
||||
snapshot_gameplay
|
||||
if $action_count <= 128
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d MANAGER_SELECT_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d ordinal=%d instruction=%p caller_return=%p provider=%p provider_vtable=%p action_id=%#x free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $action_count, $pc, *(void**)($rsp+0x58), $provider, *(void**)$provider, $eax, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
end
|
||||
if $eax == 0x30
|
||||
bt 16
|
||||
end
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['instructions_screen']:x}
|
||||
condition 2 $edx == 0x30 && *(void**)$rcx == 0x{address['screen_vtable']:x}
|
||||
commands
|
||||
silent
|
||||
set $screen = $rcx
|
||||
set $screen_owner = *(void**)($screen+0x140)
|
||||
set $screen_owner_vtable = 0
|
||||
if $screen_owner != 0
|
||||
set $screen_owner_vtable = *(void**)$screen_owner
|
||||
end
|
||||
snapshot_gameplay
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d INSTRUCTIONS_SCREEN_EVENT_30" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d handler=%p caller_return=%p screen=%p screen_vtable=%p event=%#x payload=%p allow_advance138=%d owner140=%p owner_vtable=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $screen, *(void**)$screen, $edx, $r8, *(int*)($screen+0x138), $screen_owner, $screen_owner_vtable, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
bt 16
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['command_dispatch']:x}
|
||||
condition 3 $edx == 0x128
|
||||
commands
|
||||
silent
|
||||
set $command_dispatcher = $rcx
|
||||
set $command_table = *(void**)$command_dispatcher
|
||||
snapshot_gameplay
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d GAMEPLAY_COMMAND_128" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p dispatcher=%p command=%#x payload=%p arg_r9=%p table=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $command_dispatcher, $edx, $r8, $r9, $command_table, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 1
|
||||
disable 2
|
||||
disable 3
|
||||
enable 5
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['free_roam_case']:x}
|
||||
commands
|
||||
silent
|
||||
set $owner = $rbx
|
||||
snapshot_gameplay
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d FREE_ROAM_COMMAND_128" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d callsite=%p caller_return=%p owner=%p owner_vtable=%p command=%#x payload=%p state=%d previous=%d free111=%d free112=%d free124=%d manager=%p selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $owner, *(void**)$owner, $esi, $rdi, *(int*)($owner+0x30), *(int*)($owner+0x34), *(unsigned char*)($owner+0x111), *(unsigned char*)($owner+0x112), *(int*)($owner+0x124), *(void**)($owner+0x168), $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['scheduler']:x}
|
||||
disable 5
|
||||
commands
|
||||
silent
|
||||
set $owner = $rcx
|
||||
set $manager = *(void**)($owner+0x168)
|
||||
set $manager_vtable = 0
|
||||
set $manager_mode = -1
|
||||
set $child = 0
|
||||
set $child_vtable = 0
|
||||
if $manager != 0
|
||||
set $manager_vtable = *(void**)$manager
|
||||
set $manager_mode = *(int*)($manager+0x50)
|
||||
set $child = *(void**)($manager+0x8)
|
||||
end
|
||||
if $child != 0
|
||||
set $child_vtable = *(void**)$child
|
||||
end
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d SCENARIO_SCHEDULER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p owner=%p owner_vtable=%p free124=%d command=%#x payload=%p manager=%p manager_vtable=%p manager_mode=%d child=%p child_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $owner, *(void**)$owner, *(int*)($owner+0x124), $edx, $r8, $manager, $manager_vtable, $manager_mode, $child, $child_vtable
|
||||
disable 4
|
||||
disable 5
|
||||
enable 6
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['manager_start']:x}
|
||||
disable 6
|
||||
commands
|
||||
silent
|
||||
set $manager = $rcx
|
||||
set $child = *(void**)($manager+0x8)
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d SCENARIO_MANAGER_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p manager=%p manager_vtable=%p requested_countdown=%d mode=%d child=%p child_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $manager, *(void**)$manager, $rdx & 0xff, *(int*)($manager+0x50), $child, $child ? *(void**)$child : 0
|
||||
disable 6
|
||||
enable 7
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['scenario_start']:x}
|
||||
disable 7
|
||||
commands
|
||||
silent
|
||||
set $ctx = $rcx
|
||||
snapshot_gameplay
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d MODE_ZERO_SCENARIO_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p descriptor=%p scenario_index=%d requested_countdown=%d flag40_before=%d callback_owner78=%p callback_vtable48=%p dispatcher_vtable80=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $rdx, $r8d, $r9 & 0xff, *(unsigned char*)($ctx+0x40), *(void**)($ctx+0x78), *(void**)($ctx+0x48), *(void**)($ctx+0x80), $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 7
|
||||
continue
|
||||
end
|
||||
|
||||
printf "COMMAND128 ARMED pid={pid} action_id=0x{address['manager_select_action']:x} screen_handler=0x{address['instructions_screen']:x} command_dispatch=0x{address['command_dispatch']:x} free_roam=0x{address['free_roam_case']:x} scheduler=0x{address['scheduler']:x} manager=0x{address['manager_start']:x} scenario=0x{address['scenario_start']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def effective_environment(pid: int) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for item in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0"):
|
||||
if not item.startswith(b"OPENFUT_FIFA17_"):
|
||||
continue
|
||||
key, _, value = item.decode("utf-8", errors="replace").partition("=")
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = addresses(0x140000000)
|
||||
script = build_script(1234, 0x140000000, "/tmp/command-128.log")
|
||||
assert address["manager_select_source"] == 0x14705A620
|
||||
assert address["manager_select_action"] == 0x147CDC4A6
|
||||
assert address["instructions_screen"] == 0x147DCA400
|
||||
assert address["command_dispatch"] == 0x147A8F6C0
|
||||
assert address["free_roam_case"] == 0x147A92B0F
|
||||
assert address["scheduler"] == 0x147AC3A40
|
||||
assert address["manager_start"] == 0x147B1C2B0
|
||||
assert address["scenario_start"] == 0x147B1C190
|
||||
assert script.count("hbreak *") == 7
|
||||
assert "set $action_count = 0" in script
|
||||
assert "MANAGER_SELECT_ACTION" in script
|
||||
assert "condition 2 $edx == 0x30" in script
|
||||
assert "condition 3 $edx == 0x128" in script
|
||||
assert "disable 4" in script
|
||||
assert "disable 5" in script and "enable 5" in script
|
||||
assert "disable 6" in script and "enable 6" in script
|
||||
assert "disable 7" in script and "enable 7" in script
|
||||
assert "set *(" not in script
|
||||
print("command_128_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(
|
||||
fifa_path,
|
||||
advance.PINNED_FIFA_SHA256,
|
||||
advance.FIFA_MODULE,
|
||||
)
|
||||
cards_base = 0
|
||||
cards_path = "<not-loaded>"
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
transition.validate_cards(cards_path)
|
||||
output = args.output or f"/tmp/fifa17-command-128-{pid}.log"
|
||||
script = build_script(pid, fifa_base, output)
|
||||
environment = effective_environment(pid)
|
||||
print(
|
||||
"COMMAND128 PREPARED "
|
||||
f"pid={pid} fifa_base={fifa_base:#x} cards_base={cards_base:#x} "
|
||||
f"cards_path={cards_path} "
|
||||
f"team_compat={environment.get('OPENFUT_FIFA17_SEASON_TEAM_COMPAT', '<absent>')} "
|
||||
f"pma_fix={environment.get('OPENFUT_FIFA17_OFFLINE_SEASONS_PMA_FIX', '<absent>')}"
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-command-128-{pid}.gdb"
|
||||
Path(script_path).write_text(script, encoding="utf-8")
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Find an APT/ActionScript symbol inside the FIFA 17 Frostbite .cas archives.
|
||||
|
||||
Frosty is a GUI-only tool and its Legacy Explorer is the documented way to reach
|
||||
these assets, but the chunks holding APT ActionScript are stored plainly enough to
|
||||
grep — so a screen can be identified, and its whole symbol table recovered,
|
||||
without driving the GUI at all.
|
||||
|
||||
ALWAYS passes a control first: `KitAssignmentPopup` is a string from an
|
||||
already-exported BIG, so if it misses, the archives are packed differently than
|
||||
assumed and no negative from this tool may be quoted.
|
||||
|
||||
python3 find_apt_in_cas.py FUT_GET_MATCH_KITS_DP
|
||||
python3 find_apt_in_cas.py --dump 0x3707ecd7 fifa_installpackage_01/cas_01.cas
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = "/mnt/games/FIFA 17"
|
||||
CONTROL = b"KitAssignmentPopup"
|
||||
|
||||
|
||||
def cas_files():
|
||||
return sorted(glob.glob(os.path.join(ROOT, "**", "*.cas"), recursive=True))
|
||||
|
||||
|
||||
def find(needle: bytes):
|
||||
control_total = 0
|
||||
hits = []
|
||||
for p in cas_files():
|
||||
d = open(p, "rb").read()
|
||||
control_total += d.count(CONTROL)
|
||||
start = 0
|
||||
while True:
|
||||
i = d.find(needle, start)
|
||||
if i < 0:
|
||||
break
|
||||
hits.append((p, i))
|
||||
start = i + 1
|
||||
return control_total, hits
|
||||
|
||||
|
||||
def dump(path, off, span=90000):
|
||||
with open(path, "rb") as f:
|
||||
f.seek(max(0, off - span // 2))
|
||||
d = f.read(span)
|
||||
seen = []
|
||||
for m in re.finditer(rb"[ -~]{4,}", d):
|
||||
t = m.group().decode("latin1")
|
||||
if t not in seen:
|
||||
seen.append(t)
|
||||
return seen
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("needle", nargs="?")
|
||||
ap.add_argument("--dump", metavar="OFFSET")
|
||||
ap.add_argument("--file")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.dump:
|
||||
path = args.file if os.path.isabs(args.file or "") else os.path.join(
|
||||
ROOT, "Data/Win32/superbundlelayout", args.file or "")
|
||||
for s in dump(path, int(args.dump, 0)):
|
||||
print(s)
|
||||
return 0
|
||||
|
||||
if not args.needle:
|
||||
ap.error("needle required")
|
||||
ctl, hits = find(args.needle.encode())
|
||||
print(f"control {CONTROL.decode()}: {ctl} hit(s)")
|
||||
if ctl == 0:
|
||||
print("CONTROL FAILED — archives not greppable this way; no negative is valid.")
|
||||
return 1
|
||||
print(f"{args.needle}: {len(hits)} hit(s)")
|
||||
for p, i in hits[:20]:
|
||||
print(f" {os.path.relpath(p, ROOT)} @ {i:#x}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Hardware-only trace of the engine-local overwrite wrapper entry.
|
||||
|
||||
Breaks before the prologue of FUN_147ce47e0, where [rsp] is the exact direct
|
||||
caller return address and R8D is the team ID later written to the final match
|
||||
record. This closes the one frame Wine PE unwinding could not recover.
|
||||
|
||||
No INT3/software breakpoints. No client memory writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
WRAPPER_VA = 0x147CE47E0
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _u64(address: int) -> int | None:
|
||||
data = _read(address, 8)
|
||||
return struct.unpack("<Q", data)[0] if data else None
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _registers() -> dict:
|
||||
names = (
|
||||
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||
)
|
||||
return {name: _reg(name) for name in names}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
self.index = 0
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.index += 1
|
||||
thread = _thread()
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.index,
|
||||
"time_unix": time.time(),
|
||||
"thread": thread,
|
||||
**payload,
|
||||
}
|
||||
with open(self.path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
|
||||
class WrapperBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State):
|
||||
self.state = state
|
||||
super().__init__(
|
||||
f"*0x{WRAPPER_VA:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
stack = _reg("rsp")
|
||||
caller_return = _u64(stack)
|
||||
self.state.log(
|
||||
"engine_overwrite_wrapper_entry",
|
||||
wrapper_va=WRAPPER_VA,
|
||||
caller_return_address=caller_return,
|
||||
source_team_id=_reg("r8") & 0xFFFFFFFF,
|
||||
side_argument=_reg("rdx") & 0xFFFFFFFF,
|
||||
registers=_registers(),
|
||||
caller_disassembly=(
|
||||
gdb.execute(f"x/12i 0x{caller_return - 32:x}", to_string=True)
|
||||
if caller_return else None
|
||||
),
|
||||
backtrace=gdb.execute("bt 32", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error", where="engine_overwrite_wrapper", error=str(exc),
|
||||
traceback=traceback.format_exc()
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, _cards_base: int):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path)
|
||||
breakpoint = WrapperBreakpoint(_STATE)
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints={"engine_overwrite_wrapper": {"number": breakpoint.number, "va": WRAPPER_VA}},
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""GDB payload for the LIVE-PROVEN engine match-team +0x14 writer.
|
||||
|
||||
READ-ONLY hardware debug only:
|
||||
|
||||
0x147c652ce mov dword [rdx + rcx + 0x44], r8d
|
||||
|
||||
At the first team-like source value, derives both fixed-stride record fields
|
||||
from live RCX and arms 4-byte WRITE watchpoints on:
|
||||
|
||||
teamId A = rcx + 0x44
|
||||
teamId B = rcx + 0x44 + 0x45c
|
||||
|
||||
The execute breakpoint records the intended source value before every call. The
|
||||
watchpoints then capture both the expected write and any later overwrite, even
|
||||
if the overwrite comes from a different function.
|
||||
|
||||
No INT3/software breakpoints. No client memory writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
WRITER_VA = 0x147C652CE
|
||||
POST_WRITER_VA = 0x147C652D3
|
||||
SIDE_STRIDE = 0x45C
|
||||
TEAM_FIELD_OFF = 0x44
|
||||
RECORD_FIELD_OFF = 0x14
|
||||
TEAM_LIKE = {73, 240, 241, 243, 130000, 130001}
|
||||
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _i32(address: int) -> int | None:
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<i", data)[0] if data else None
|
||||
|
||||
|
||||
def _registers() -> dict:
|
||||
names = (
|
||||
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||
)
|
||||
return {name: _reg(name) for name in names}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, log_path: str):
|
||||
self.log_path = log_path
|
||||
self.event_index = 0
|
||||
self.engine_base = None
|
||||
self.watch_a = None
|
||||
self.watch_b = None
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.event_index += 1
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.event_index,
|
||||
"time_unix": time.time(),
|
||||
"thread": _thread(),
|
||||
**payload,
|
||||
}
|
||||
with open(self.log_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
def arm_fields(self, engine_base: int):
|
||||
if self.engine_base == engine_base and self.watch_a and self.watch_b:
|
||||
return
|
||||
for watchpoint in (self.watch_a, self.watch_b):
|
||||
if watchpoint is not None:
|
||||
try:
|
||||
watchpoint.delete()
|
||||
except gdb.error:
|
||||
pass
|
||||
self.engine_base = engine_base
|
||||
self.watch_a = TeamFieldWatchpoint(self, 0, engine_base + TEAM_FIELD_OFF)
|
||||
self.watch_b = TeamFieldWatchpoint(
|
||||
self, 1, engine_base + TEAM_FIELD_OFF + SIDE_STRIDE
|
||||
)
|
||||
self.log(
|
||||
"team_field_watchpoints_armed",
|
||||
engine_base=engine_base,
|
||||
team_id_a_address=self.watch_a.address,
|
||||
team_id_b_address=self.watch_b.address,
|
||||
watchpoint_a=self.watch_a.number,
|
||||
watchpoint_b=self.watch_b.number,
|
||||
)
|
||||
|
||||
|
||||
class TeamFieldWatchpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, side: int, address: int):
|
||||
self.state = state
|
||||
self.side = side
|
||||
self.address = address
|
||||
super().__init__(
|
||||
f"*(int*)0x{address:x}",
|
||||
type=gdb.BP_WATCHPOINT,
|
||||
wp_class=gdb.WP_WRITE,
|
||||
internal=False,
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
pc = _reg("rip")
|
||||
writer = WRITER_VA if pc == POST_WRITER_VA else None
|
||||
record_start = self.address - RECORD_FIELD_OFF
|
||||
record = _read(record_start, 0x7C)
|
||||
self.state.log(
|
||||
"final_team_field_write_post",
|
||||
side=self.side,
|
||||
watch_address=self.address,
|
||||
value=_i32(self.address),
|
||||
stopped_pc=pc,
|
||||
writer_va=writer,
|
||||
record_start=record_start,
|
||||
record_hex=record.hex() if record else None,
|
||||
registers=_registers(),
|
||||
disassembly=gdb.execute("x/12i $pc-32", to_string=True),
|
||||
backtrace=gdb.execute("bt 32", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error",
|
||||
where="team_field_watchpoint",
|
||||
error=str(exc),
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class FinalWriterBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State):
|
||||
self.state = state
|
||||
super().__init__(
|
||||
f"*0x{WRITER_VA:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
engine_base = _reg("rcx")
|
||||
side_offset = _reg("rdx")
|
||||
source_value = _reg("r8") & 0xFFFFFFFF
|
||||
if source_value in TEAM_LIKE:
|
||||
self.state.arm_fields(engine_base)
|
||||
destination = engine_base + side_offset + TEAM_FIELD_OFF
|
||||
side = side_offset // SIDE_STRIDE if side_offset in (0, SIDE_STRIDE) else None
|
||||
self.state.log(
|
||||
"final_writer_pre",
|
||||
instruction_va=WRITER_VA,
|
||||
engine_base=engine_base,
|
||||
side_offset=side_offset,
|
||||
side=side,
|
||||
destination=destination,
|
||||
record_start=destination - RECORD_FIELD_OFF,
|
||||
source_register="r8d",
|
||||
source_value=source_value,
|
||||
prior_value=_i32(destination),
|
||||
team_id_a_address=engine_base + TEAM_FIELD_OFF,
|
||||
team_id_b_address=engine_base + TEAM_FIELD_OFF + SIDE_STRIDE,
|
||||
team_id_a_before=_i32(engine_base + TEAM_FIELD_OFF),
|
||||
team_id_b_before=_i32(engine_base + TEAM_FIELD_OFF + SIDE_STRIDE),
|
||||
registers=_registers(),
|
||||
disassembly=gdb.execute("x/6i $pc", to_string=True),
|
||||
backtrace=gdb.execute("bt 32", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error",
|
||||
where="final_writer",
|
||||
error=str(exc),
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, _cards_base: int):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path)
|
||||
writer = FinalWriterBreakpoint(_STATE)
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints={
|
||||
"final_writer": {"number": writer.number, "va": WRITER_VA},
|
||||
},
|
||||
side_stride=SIDE_STRIDE,
|
||||
team_field_offset=TEAM_FIELD_OFF,
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Hardware-only origin trace for the exact SetTeam team context.
|
||||
|
||||
Matches the typed integer context pointer selected by SetTeam to the constructor
|
||||
invocation that produced it. No client memory writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
CONTEXT_REUSE = 0x1477C17FC
|
||||
CONTEXT_ALLOCATED = 0x1477C18C1
|
||||
SET_TEAM_STUB = 0x147060A80
|
||||
LOCKED_SETTER_RETURN = 0x1477C2415
|
||||
CONTEXT_STACK_COUNT = 0x144BCEDA0
|
||||
CONTEXT_STACK_ARRAY = 0x144BCEDA8
|
||||
INTERESTING = {73, 130000, 130001}
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name):
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _read(address, size):
|
||||
if not address or address < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _u64(address):
|
||||
data = _read(address, 8)
|
||||
return struct.unpack("<Q", data)[0] if data else None
|
||||
|
||||
|
||||
def _i32(address):
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<i", data)[0] if data else None
|
||||
|
||||
|
||||
def _thread():
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.index = 0
|
||||
self.total_constructor_hits = 0
|
||||
self.interesting_constructor_hits = 0
|
||||
self.pending_allocations = {}
|
||||
self.origins = deque(maxlen=4096)
|
||||
|
||||
def log(self, kind, **payload):
|
||||
self.index += 1
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.index,
|
||||
"time_unix": time.time(),
|
||||
"thread": _thread(),
|
||||
**payload,
|
||||
}
|
||||
with open(self.path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
def thread_key(self):
|
||||
return tuple(_thread().get("ptid", ()))
|
||||
|
||||
def remember_origin(self, context, origin):
|
||||
if context:
|
||||
self.origins.append({**origin, "context": context})
|
||||
|
||||
def find_origin(self, context):
|
||||
return next((origin for origin in reversed(self.origins)
|
||||
if origin["context"] == context), None)
|
||||
|
||||
|
||||
class HardwareBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state, address):
|
||||
self.state = state
|
||||
self.address = address
|
||||
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||
self.silent = True
|
||||
|
||||
|
||||
class ContextReuseBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
self.state.total_constructor_hits += 1
|
||||
try:
|
||||
value = _reg("rcx") & 0xFFFFFFFF
|
||||
if value not in INTERESTING:
|
||||
return False
|
||||
self.state.interesting_constructor_hits += 1
|
||||
rsp = _reg("rsp")
|
||||
direct_return = _u64(rsp + 0x28)
|
||||
origin = {
|
||||
"value": value,
|
||||
"direct_return_address": direct_return,
|
||||
"upstream_return_address": (
|
||||
_u64(rsp + 0x68)
|
||||
if direct_return == LOCKED_SETTER_RETURN
|
||||
else direct_return
|
||||
),
|
||||
"constructor_stack_hex": (_read(rsp, 0x100) or b"").hex(),
|
||||
"constructor_hit": self.state.total_constructor_hits,
|
||||
}
|
||||
context = _reg("rax")
|
||||
if context:
|
||||
self.state.remember_origin(context, origin)
|
||||
else:
|
||||
self.state.pending_allocations[self.state.thread_key()] = origin
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error",
|
||||
where="context_reuse",
|
||||
error=str(exc),
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class ContextAllocatedBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
origin = self.state.pending_allocations.pop(self.state.thread_key(), None)
|
||||
if origin is not None:
|
||||
self.state.remember_origin(_reg("rdx"), origin)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error",
|
||||
where="context_allocated",
|
||||
error=str(exc),
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class SetTeamStubBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
count = _i32(CONTEXT_STACK_COUNT)
|
||||
array = _u64(CONTEXT_STACK_ARRAY)
|
||||
team_context = (
|
||||
_u64(array + (count - 2) * 8)
|
||||
if array and count is not None and count >= 2
|
||||
else None
|
||||
)
|
||||
side_context = (
|
||||
_u64(array + (count - 1) * 8)
|
||||
if array and count is not None and count >= 1
|
||||
else None
|
||||
)
|
||||
rsp = _reg("rsp")
|
||||
self.state.log(
|
||||
"set_team_stub_entry",
|
||||
context_stack_count=count,
|
||||
team_context=team_context,
|
||||
team_context_hex=(_read(team_context, 0x40) or b"").hex(),
|
||||
team_value=_i32(team_context + 0x10) if team_context else None,
|
||||
side_context=side_context,
|
||||
side_value=_i32(side_context + 0x10) if side_context else None,
|
||||
matched_origin=self.state.find_origin(team_context),
|
||||
caller_return_address=_u64(rsp),
|
||||
entry_registers={
|
||||
name: _reg(name)
|
||||
for name in ("rcx", "rdx", "r8", "r9")
|
||||
},
|
||||
backtrace=gdb.execute("bt 32", to_string=True),
|
||||
total_constructor_hits=self.state.total_constructor_hits,
|
||||
interesting_constructor_hits=self.state.interesting_constructor_hits,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error",
|
||||
where="set_team_stub",
|
||||
error=str(exc),
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log(
|
||||
"inferior_exited",
|
||||
detail=str(event),
|
||||
total_constructor_hits=_STATE.total_constructor_hits,
|
||||
interesting_constructor_hits=_STATE.interesting_constructor_hits,
|
||||
)
|
||||
|
||||
|
||||
def start_trace(log_path, _cards_base):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path)
|
||||
points = {
|
||||
"context_reuse": ContextReuseBreakpoint(_STATE, CONTEXT_REUSE),
|
||||
"context_allocated": ContextAllocatedBreakpoint(_STATE, CONTEXT_ALLOCATED),
|
||||
"set_team_stub": SetTeamStubBreakpoint(_STATE, SET_TEAM_STUB),
|
||||
}
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints={
|
||||
name: {"number": point.number, "va": point.address}
|
||||
for name, point in points.items()
|
||||
},
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
matching="exact_context_pointer",
|
||||
)
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Hardware-only trace of engine game-setup context selection.
|
||||
|
||||
Captures the function that requests team/side, selector indices 1/0, selected
|
||||
transient context objects, and the typed value getter. No client writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
DISPATCH = 0x147060D00
|
||||
SELECT_VALUE = 0x147572C50
|
||||
CONTEXT_SELECTED = 0x1477C845D
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _u64(address: int) -> int | None:
|
||||
data = _read(address, 8)
|
||||
return struct.unpack("<Q", data)[0] if data else None
|
||||
|
||||
|
||||
def _i32(address: int) -> int | None:
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<i", data)[0] if data else None
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _printable_pointers(address: int, data: bytes) -> dict:
|
||||
found = {}
|
||||
for offset in range(0, len(data) - 7, 8):
|
||||
pointer = struct.unpack_from("<Q", data, offset)[0]
|
||||
raw = _read(pointer, 128)
|
||||
if not raw:
|
||||
continue
|
||||
value = raw.split(b"\0", 1)[0]
|
||||
try:
|
||||
text = value.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if len(text) >= 3 and all(char.isprintable() for char in text):
|
||||
found[hex(offset)] = {"pointer": pointer, "text": text}
|
||||
return found
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
self.index = 0
|
||||
self.requested_indices = {}
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.index += 1
|
||||
event = {"event": kind, "event_index": self.index, "time_unix": time.time(),
|
||||
"thread": _thread(), **payload}
|
||||
with open(self.path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush(); os.fsync(handle.fileno())
|
||||
|
||||
def key(self):
|
||||
return tuple(_thread().get("ptid", ()))
|
||||
|
||||
|
||||
class HardwareBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, address: int):
|
||||
self.state = state
|
||||
self.address = address
|
||||
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||
self.silent = True
|
||||
|
||||
|
||||
class DispatchBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
rsp = _reg("rsp")
|
||||
caller = _u64(rsp)
|
||||
self.state.log(
|
||||
"game_setup_dispatch_entry",
|
||||
caller_return_address=caller,
|
||||
caller_disassembly=(gdb.execute(f"x/12i 0x{caller-32:x}", to_string=True)
|
||||
if caller else None),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="dispatch", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class SelectValueBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
index = _reg("rcx") & 0xFFFFFFFF
|
||||
self.state.requested_indices[self.state.key()] = index
|
||||
self.state.log("context_value_request", index=index)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="select_value", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class ContextSelectedBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
index = _reg("rdi") & 0xFFFFFFFF
|
||||
context = _reg("rbx")
|
||||
data = _read(context, 0x80) or b""
|
||||
self.state.log(
|
||||
"context_selected",
|
||||
requested_index=self.state.requested_indices.get(self.state.key()),
|
||||
selector_index=index,
|
||||
context=context,
|
||||
type_flags=_i32(context + 8),
|
||||
value_i32=_i32(context + 0x10),
|
||||
value_qword=_u64(context + 0x10),
|
||||
context_hex=data.hex(),
|
||||
printable_pointers=_printable_pointers(context, data),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="context_selected", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, _cards_base: int):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path)
|
||||
points = {
|
||||
"dispatch": DispatchBreakpoint(_STATE, DISPATCH),
|
||||
"select_value": SelectValueBreakpoint(_STATE, SELECT_VALUE),
|
||||
"context_selected": ContextSelectedBreakpoint(_STATE, CONTEXT_SELECTED),
|
||||
}
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints={name: {"number": bp.number, "va": bp.address} for name, bp in points.items()},
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Hardware-only trace of CardsGameSetupAdapter query 13 and overwrite input.
|
||||
|
||||
Breakpoints:
|
||||
|
||||
FUN_180031340 entry incoming teamId/side/context
|
||||
0x18003148f pre-call query id, selector, output/count pointers
|
||||
0x180031495 post-call complete 48-byte records and count
|
||||
0x180031861 submit original incoming teamId sent to engine
|
||||
|
||||
This proves whether query 13 influences the overwrite. No INT3/software
|
||||
breakpoints, client writes, or game input.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
CARDS_IMAGE_BASE = 0x180000000
|
||||
ENTRY = 0x180031340
|
||||
QUERY_PRE = 0x18003148F
|
||||
QUERY_POST = 0x180031495
|
||||
SUBMIT = 0x180031861
|
||||
MAX_RECORDS = 100
|
||||
RECORD_SIZE = 48
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0 or size < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _u64(address: int) -> int | None:
|
||||
data = _read(address, 8)
|
||||
return struct.unpack("<Q", data)[0] if data else None
|
||||
|
||||
|
||||
def _i32(address: int) -> int | None:
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<i", data)[0] if data else None
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _registers() -> dict:
|
||||
names = (
|
||||
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||
)
|
||||
return {name: _reg(name) for name in names}
|
||||
|
||||
|
||||
def _printable_pointer(pointer: int) -> str | None:
|
||||
data = _read(pointer, 96)
|
||||
if not data:
|
||||
return None
|
||||
raw = data.split(b"\0", 1)[0]
|
||||
if len(raw) < 3:
|
||||
return None
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
return text if all(char.isprintable() for char in text) else None
|
||||
|
||||
|
||||
def _decode_record(data: bytes, address: int) -> dict:
|
||||
words = list(struct.unpack("<12i", data))
|
||||
qwords = list(struct.unpack("<6Q", data))
|
||||
strings = {}
|
||||
for index, pointer in enumerate(qwords):
|
||||
text = _printable_pointer(pointer)
|
||||
if text:
|
||||
strings[f"qword_{index}"] = {"pointer": pointer, "text": text}
|
||||
interesting = {
|
||||
str(value): [index * 4 for index, word in enumerate(words) if word == value]
|
||||
for value in (73, 240, 241, 243, 130000, 130001)
|
||||
if value in words
|
||||
}
|
||||
return {
|
||||
"address": address,
|
||||
"hex": data.hex(),
|
||||
"i32": words,
|
||||
"u32": [value & 0xFFFFFFFF for value in words],
|
||||
"f32": list(struct.unpack("<12f", data)),
|
||||
"qwords": qwords,
|
||||
"strings": strings,
|
||||
"interesting_values": interesting,
|
||||
}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, path: str, cards_base: int):
|
||||
self.path = path
|
||||
self.cards_base = cards_base
|
||||
self.index = 0
|
||||
self.calls = {}
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.index += 1
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.index,
|
||||
"time_unix": time.time(),
|
||||
"thread": _thread(),
|
||||
**payload,
|
||||
}
|
||||
with open(self.path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
def thread_key(self):
|
||||
return tuple(_thread().get("ptid", ()))
|
||||
|
||||
|
||||
class HardwareBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, image_va: int):
|
||||
self.state = state
|
||||
self.image_va = image_va
|
||||
address = state.cards_base + (image_va - CARDS_IMAGE_BASE)
|
||||
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||
self.silent = True
|
||||
|
||||
|
||||
class EntryBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
self.state.log(
|
||||
"game_setup_entry",
|
||||
incoming_context=_reg("rcx"),
|
||||
incoming_side=_reg("rdx") & 0xFFFFFFFF,
|
||||
incoming_team_id=_reg("r8") & 0xFFFFFFFF,
|
||||
incoming_r9=_reg("r9"),
|
||||
registers=_registers(),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="entry", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class QueryPreBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
rsp = _reg("rsp")
|
||||
adapter = _reg("rcx")
|
||||
vtable = _u64(adapter)
|
||||
count_pointer = _u64(rsp + 0x20)
|
||||
state = {
|
||||
"adapter": adapter,
|
||||
"adapter_vtable": vtable,
|
||||
"query_target": _u64(vtable + 0xE0) if vtable else None,
|
||||
"query_id": _reg("rdx") & 0xFFFFFFFF,
|
||||
"selector": _reg("r8") & 0xFFFFFFFF,
|
||||
"output_buffer": _reg("r9"),
|
||||
"count_pointer": count_pointer,
|
||||
"sixth_argument": _u64(rsp + 0x28),
|
||||
"count_before": _i32(count_pointer) if count_pointer else None,
|
||||
"saved_incoming_team_id": _i32(rsp + 0x34),
|
||||
"saved_side": _i32(rsp + 0x50),
|
||||
"saved_engine_context": _u64(rsp + 0x68),
|
||||
"adapter_prefix_hex": (_read(adapter, 0x100) or b"").hex(),
|
||||
}
|
||||
self.state.calls[self.state.thread_key()] = state
|
||||
self.state.log(
|
||||
"query13_pre",
|
||||
**state,
|
||||
registers=_registers(),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="query_pre", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class QueryPostBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
state = self.state.calls.get(self.state.thread_key(), {})
|
||||
count_pointer = state.get("count_pointer")
|
||||
output = state.get("output_buffer")
|
||||
count = _i32(count_pointer) if count_pointer else None
|
||||
safe_count = min(max(count or 0, 0), MAX_RECORDS)
|
||||
records = []
|
||||
for index in range(safe_count):
|
||||
address = output + index * RECORD_SIZE
|
||||
data = _read(address, RECORD_SIZE)
|
||||
if data and len(data) == RECORD_SIZE:
|
||||
records.append(_decode_record(data, address))
|
||||
self.state.log(
|
||||
"query13_post",
|
||||
query_state=state,
|
||||
count_after=count,
|
||||
records=records,
|
||||
saved_incoming_team_id_after=_i32(_reg("rsp") + 0x34),
|
||||
saved_side_after=_i32(_reg("rsp") + 0x50),
|
||||
registers=_registers(),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="query_post", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class SubmitBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
rsp = _reg("rsp")
|
||||
self.state.log(
|
||||
"game_setup_submit",
|
||||
submitted_team_id=_reg("r8") & 0xFFFFFFFF,
|
||||
submitted_side=_reg("rdx") & 0xFFFFFFFF,
|
||||
engine_context=_reg("rcx"),
|
||||
saved_incoming_team_id=_i32(rsp + 0x34),
|
||||
saved_side=_i32(rsp + 0x50),
|
||||
registers=_registers(),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="submit", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, cards_base: int):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path, cards_base)
|
||||
points = {
|
||||
"entry": EntryBreakpoint(_STATE, ENTRY),
|
||||
"query_pre": QueryPreBreakpoint(_STATE, QUERY_PRE),
|
||||
"query_post": QueryPostBreakpoint(_STATE, QUERY_POST),
|
||||
"submit": SubmitBreakpoint(_STATE, SUBMIT),
|
||||
}
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints={name: {"number": bp.number, "image_va": bp.image_va} for name, bp in points.items()},
|
||||
record_size=RECORD_SIZE,
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
@@ -0,0 +1,388 @@
|
||||
"""GDB Python payload for read-only FIFA17 match-team writer tracing.
|
||||
|
||||
Loaded by trace_match_team_writer.py. Uses hardware execute breakpoints and a
|
||||
4-byte hardware WRITE watchpoint only; never inserts INT3 and never writes game
|
||||
memory.
|
||||
|
||||
Breakpoints (CardsDLL image VAs):
|
||||
|
||||
* FUN_1800fc500 entry -- derives output pair from RDX and arms *(int*)(rdx+4).
|
||||
* 0x1800fc595 -- pre-write opponent lookup into pair[1].
|
||||
* 0x1800fc5b8 -- mirrored pre-write opponent lookup into pair[0].
|
||||
|
||||
The dynamic watchpoint catches the exact write establishing pair[1], whether it
|
||||
is the opponent lookup at 0x1800fc595 or the own-club store at 0x1800fc5a0.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
CARDS_IMAGE_BASE = 0x180000000
|
||||
ENTRY_RVA = 0x0FC500
|
||||
LOOKUP_TO_TEAM1_RVA = 0x0FC595
|
||||
LOOKUP_TO_TEAM0_RVA = 0x0FC5B8
|
||||
TEAM1_POST_PC_TO_WRITER = {
|
||||
0x1800FC599: 0x1800FC595, # mov [r14+4],ecx
|
||||
0x1800FC5A4: 0x1800FC5A0, # mov [r14+4],eax
|
||||
}
|
||||
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0 or size < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _u8(address: int) -> int | None:
|
||||
data = _read(address, 1)
|
||||
return data[0] if data else None
|
||||
|
||||
|
||||
def _u32(address: int) -> int | None:
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<I", data)[0] if data else None
|
||||
|
||||
|
||||
def _i32(address: int) -> int | None:
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<i", data)[0] if data else None
|
||||
|
||||
|
||||
def _u64(address: int) -> int | None:
|
||||
data = _read(address, 8)
|
||||
return struct.unpack("<Q", data)[0] if data else None
|
||||
|
||||
|
||||
def _cstring(address: int, maximum: int = 256) -> str | None:
|
||||
data = _read(address, maximum)
|
||||
if not data:
|
||||
return None
|
||||
return data.split(b"\0", 1)[0].decode("utf-8", "replace")
|
||||
|
||||
|
||||
def _rtti_name(vtable: int, cards_base: int) -> str | None:
|
||||
"""MSVC x64 RTTI name from vtable[-1] CompleteObjectLocator.
|
||||
|
||||
PE RVAs in the locator are module-relative. Failure is evidence-free and is
|
||||
logged as null; no pointer is named from an offset coincidence.
|
||||
"""
|
||||
locator = _u64(vtable - 8) if vtable else None
|
||||
if not locator:
|
||||
return None
|
||||
raw = _read(locator, 24)
|
||||
if not raw:
|
||||
return None
|
||||
_signature, _offset, _cd_offset, type_rva, _hier_rva, self_rva = struct.unpack(
|
||||
"<IIIiii", raw
|
||||
)
|
||||
if not (0 <= type_rva < 0x10000000 and 0 <= self_rva < 0x10000000):
|
||||
return None
|
||||
image_base = locator - self_rva
|
||||
if abs(image_base - cards_base) > 0x100000:
|
||||
return None
|
||||
return _cstring(image_base + type_rva + 16)
|
||||
|
||||
|
||||
def _object(address: int, cards_base: int) -> dict:
|
||||
vtable = _u64(address) if address else None
|
||||
return {
|
||||
"address": address,
|
||||
"vtable": vtable,
|
||||
"vtable_image_va": (
|
||||
CARDS_IMAGE_BASE + (vtable - cards_base)
|
||||
if vtable and cards_base <= vtable < cards_base + 0x400000
|
||||
else None
|
||||
),
|
||||
"rtti": _rtti_name(vtable, cards_base) if vtable else None,
|
||||
}
|
||||
|
||||
|
||||
def _registers() -> dict:
|
||||
names = (
|
||||
"rax",
|
||||
"rbx",
|
||||
"rcx",
|
||||
"rdx",
|
||||
"rsi",
|
||||
"rdi",
|
||||
"rbp",
|
||||
"rsp",
|
||||
"r8",
|
||||
"r9",
|
||||
"r10",
|
||||
"r11",
|
||||
"r12",
|
||||
"r13",
|
||||
"r14",
|
||||
"r15",
|
||||
"rip",
|
||||
)
|
||||
return {name: _reg(name) for name in names}
|
||||
|
||||
|
||||
def _provenance(state, destination: int | None = None) -> dict:
|
||||
"""Recover the candidate's live input chain without naming the objects."""
|
||||
regs = _registers()
|
||||
context = regs["rbx"]
|
||||
output_pair = regs["r14"]
|
||||
obj = regs["rbp"]
|
||||
nested = _u64(obj + 0xB0) if obj else None
|
||||
field_2e8 = nested + 0x2E8 if nested else None
|
||||
source_base = _u64(field_2e8) if field_2e8 else None
|
||||
participant_holder = regs["r12"]
|
||||
participant = _u64(participant_holder) if participant_holder else None
|
||||
index_70 = _u8(participant + 0x70) if participant else None
|
||||
source_address = (
|
||||
source_base + index_70 * 16
|
||||
if source_base is not None and index_70 is not None
|
||||
else None
|
||||
)
|
||||
source_bytes = _read(source_address, 16) if source_address else None
|
||||
decoded = None
|
||||
if source_bytes and len(source_bytes) == 16:
|
||||
team_id, byte4, byte5, pad, word8, wordc = struct.unpack("<iBBHii", source_bytes)
|
||||
decoded = {
|
||||
"team_id": team_id,
|
||||
"byte_4": byte4,
|
||||
"byte_5": byte5,
|
||||
"pad_6": pad,
|
||||
"word_8": word8,
|
||||
"word_c": wordc,
|
||||
}
|
||||
pair_bytes = _read(output_pair, 8) if output_pair else None
|
||||
return {
|
||||
"destination": destination,
|
||||
"context": _object(context, state.cards_base),
|
||||
"entry_context": _object(state.current_entry.get("context", 0), state.cards_base),
|
||||
"output_pair": output_pair,
|
||||
"entry_output_pair": state.current_entry.get("output_pair"),
|
||||
"output_pair_bytes": pair_bytes.hex() if pair_bytes else None,
|
||||
"output_team_id_0": _i32(output_pair) if output_pair else None,
|
||||
"output_team_id_1": _i32(output_pair + 4) if output_pair else None,
|
||||
"obj": _object(obj, state.cards_base),
|
||||
"nested_at_obj_plus_b0": _object(nested or 0, state.cards_base),
|
||||
"field_plus_2e8_address": field_2e8,
|
||||
"source_array_base": source_base,
|
||||
"participant_holder": participant_holder,
|
||||
"participant": _object(participant or 0, state.cards_base),
|
||||
"participant_plus_70": index_70,
|
||||
"source_record_address": source_address,
|
||||
"source_record_hex": source_bytes.hex() if source_bytes else None,
|
||||
"source_record": decoded,
|
||||
"registers": regs,
|
||||
}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, log_path: str, cards_base: int):
|
||||
self.log_path = log_path
|
||||
self.cards_base = cards_base
|
||||
self.current_entry: dict = {}
|
||||
self.watchpoint = None
|
||||
self.event_index = 0
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.event_index += 1
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.event_index,
|
||||
"time_unix": time.time(),
|
||||
"thread": _thread(),
|
||||
**payload,
|
||||
}
|
||||
with open(self.log_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
|
||||
class Team1Watchpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, address: int):
|
||||
self.state = state
|
||||
self.address = address
|
||||
super().__init__(
|
||||
f"*(int*)0x{address:x}",
|
||||
type=gdb.BP_WATCHPOINT,
|
||||
wp_class=gdb.WP_WRITE,
|
||||
internal=False,
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
pc = _reg("rip")
|
||||
image_pc = CARDS_IMAGE_BASE + (pc - self.state.cards_base)
|
||||
writer = TEAM1_POST_PC_TO_WRITER.get(image_pc)
|
||||
source_value = None
|
||||
if writer == 0x1800FC595:
|
||||
source_value = _reg("rcx") & 0xFFFFFFFF
|
||||
elif writer == 0x1800FC5A0:
|
||||
source_value = _reg("rax") & 0xFFFFFFFF
|
||||
self.state.log(
|
||||
"team1_write_post",
|
||||
watch_address=self.address,
|
||||
value=_i32(self.address),
|
||||
stopped_pc=pc,
|
||||
stopped_image_va=image_pc,
|
||||
writer_image_va=writer,
|
||||
source_value=source_value,
|
||||
disassembly=gdb.execute("x/10i $pc-32", to_string=True),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
provenance=_provenance(self.state, self.address),
|
||||
)
|
||||
if writer is not None:
|
||||
# The output pair is a short-lived stack buffer. Leaving the
|
||||
# watchpoint active after the candidate's exact write produced
|
||||
# 114k unrelated events when that stack memory was reused.
|
||||
# The two hardware lookup breakpoints remain armed, so disabling
|
||||
# only this completed one-shot watch loses no provenance.
|
||||
self.enabled = False
|
||||
self.state.log(
|
||||
"team1_watchpoint_disabled",
|
||||
watch_address=self.address,
|
||||
reason="candidate exact write captured",
|
||||
)
|
||||
except Exception as exc: # GDB must continue even if evidence rendering fails.
|
||||
self.state.log("trace_error", where="team1_watchpoint", error=str(exc),
|
||||
traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class EntryBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, address: int):
|
||||
self.state = state
|
||||
super().__init__(
|
||||
f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
context, output_pair = _reg("rcx"), _reg("rdx")
|
||||
self.state.current_entry = {
|
||||
"context": context,
|
||||
"output_pair": output_pair,
|
||||
"entry_thread": _thread(),
|
||||
}
|
||||
if self.state.watchpoint is not None:
|
||||
try:
|
||||
self.state.watchpoint.delete()
|
||||
except gdb.error:
|
||||
pass
|
||||
initial = _i32(output_pair + 4)
|
||||
self.state.watchpoint = Team1Watchpoint(self.state, output_pair + 4)
|
||||
self.state.log(
|
||||
"candidate_entry",
|
||||
entry_image_va=0x1800FC500,
|
||||
context=_object(context, self.state.cards_base),
|
||||
output_pair=output_pair,
|
||||
team_id_1_address=output_pair + 4,
|
||||
team_id_1_initial=initial,
|
||||
watchpoint_number=self.state.watchpoint.number,
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
registers=_registers(),
|
||||
)
|
||||
self.state.log(
|
||||
"team1_watchpoint_armed",
|
||||
watch_address=output_pair + 4,
|
||||
watchpoint_number=self.state.watchpoint.number,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="candidate_entry", error=str(exc),
|
||||
traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class LookupStoreBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, address: int, image_va: int, destination_offset: int):
|
||||
self.state = state
|
||||
self.image_va = image_va
|
||||
self.destination_offset = destination_offset
|
||||
super().__init__(
|
||||
f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
destination = _reg("r14") + self.destination_offset
|
||||
self.state.log(
|
||||
"opponent_lookup_store_pre",
|
||||
writer_image_va=self.image_va,
|
||||
destination=destination,
|
||||
destination_offset=self.destination_offset,
|
||||
source_register="ecx",
|
||||
source_value=_reg("rcx") & 0xFFFFFFFF,
|
||||
disassembly=gdb.execute("x/5i $pc", to_string=True),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
provenance=_provenance(self.state, destination),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="lookup_store", error=str(exc),
|
||||
traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, cards_base: int):
|
||||
"""Called from the supervisor's gdb command file after attach."""
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path, cards_base)
|
||||
entry = EntryBreakpoint(_STATE, cards_base + ENTRY_RVA)
|
||||
lookup_team1 = LookupStoreBreakpoint(
|
||||
_STATE,
|
||||
cards_base + LOOKUP_TO_TEAM1_RVA,
|
||||
0x1800FC595,
|
||||
4,
|
||||
)
|
||||
lookup_team0 = LookupStoreBreakpoint(
|
||||
_STATE,
|
||||
cards_base + LOOKUP_TO_TEAM0_RVA,
|
||||
0x1800FC5B8,
|
||||
0,
|
||||
)
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
cards_base=cards_base,
|
||||
breakpoints={
|
||||
"candidate_entry": {"number": entry.number, "image_va": 0x1800FC500},
|
||||
"lookup_to_team1": {
|
||||
"number": lookup_team1.number,
|
||||
"image_va": 0x1800FC595,
|
||||
},
|
||||
"lookup_to_team0": {
|
||||
"number": lookup_team0.number,
|
||||
"image_va": 0x1800FC5B8,
|
||||
},
|
||||
},
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Hardware-only origin trace for CardsDLL team-pair submissions.
|
||||
|
||||
Distinguishes the three callers of the engine team-id service that can submit a
|
||||
full two-team pair, plus the mode-76 builder that prepares its pair:
|
||||
|
||||
0x1800c7583 correct fixture pair control
|
||||
0x1800c6c23 generic pair submitter
|
||||
0x1800c8dc1 mode-76 pair submitter
|
||||
0x1800c8bf0 mode-76 pair builder entry
|
||||
|
||||
No INT3/software breakpoints. No client memory writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
CARDS_IMAGE_BASE = 0x180000000
|
||||
SITES = {
|
||||
0x1800C7583: ("fixture_pair_submit", "r14", "rsi"),
|
||||
0x1800C6C23: ("generic_pair_submit", "r14", "rsi"),
|
||||
0x1800C8DC1: ("mode76_pair_submit", "r15", "rbp"),
|
||||
}
|
||||
MODE76_BUILDER = 0x1800C8BF0
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _pair(address: int) -> list[int] | None:
|
||||
data = _read(address, 8)
|
||||
return list(struct.unpack("<2i", data)) if data else None
|
||||
|
||||
|
||||
def _registers() -> dict:
|
||||
names = (
|
||||
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||
)
|
||||
return {name: _reg(name) for name in names}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, log_path: str, cards_base: int):
|
||||
self.log_path = log_path
|
||||
self.cards_base = cards_base
|
||||
self.event_index = 0
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.event_index += 1
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.event_index,
|
||||
"time_unix": time.time(),
|
||||
"thread": _thread(),
|
||||
**payload,
|
||||
}
|
||||
with open(self.log_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
|
||||
class PairSubmitBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, image_va: int, name: str, pointer_reg: str, index_reg: str):
|
||||
self.state = state
|
||||
self.image_va = image_va
|
||||
self.name = name
|
||||
self.pointer_reg = pointer_reg
|
||||
self.index_reg = index_reg
|
||||
address = state.cards_base + (image_va - CARDS_IMAGE_BASE)
|
||||
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
pointer = _reg(self.pointer_reg)
|
||||
index = _reg(self.index_reg) & 0xFFFFFFFF
|
||||
pair_base = pointer - index * 4
|
||||
self.state.log(
|
||||
self.name,
|
||||
instruction_image_va=self.image_va,
|
||||
source_value=_reg("r8") & 0xFFFFFFFF,
|
||||
side=_reg("rdx") & 0xFF,
|
||||
engine_base=_reg("rcx"),
|
||||
pair_pointer=pointer,
|
||||
pair_index=index,
|
||||
pair_base=pair_base,
|
||||
pair=_pair(pair_base),
|
||||
registers=_registers(),
|
||||
disassembly=gdb.execute("x/5i $pc", to_string=True),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error", where=self.name, error=str(exc),
|
||||
traceback=traceback.format_exc()
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class Mode76BuilderBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State):
|
||||
self.state = state
|
||||
address = state.cards_base + (MODE76_BUILDER - CARDS_IMAGE_BASE)
|
||||
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
self.state.log(
|
||||
"mode76_builder_entry",
|
||||
instruction_image_va=MODE76_BUILDER,
|
||||
object=_reg("rcx"),
|
||||
registers=_registers(),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error", where="mode76_builder", error=str(exc),
|
||||
traceback=traceback.format_exc()
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, cards_base: int):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path, cards_base)
|
||||
breakpoints = {}
|
||||
for image_va, (name, pointer_reg, index_reg) in SITES.items():
|
||||
bp = PairSubmitBreakpoint(_STATE, image_va, name, pointer_reg, index_reg)
|
||||
breakpoints[name] = {"number": bp.number, "image_va": image_va}
|
||||
builder = Mode76BuilderBreakpoint(_STATE)
|
||||
breakpoints["mode76_builder"] = {"number": builder.number, "image_va": MODE76_BUILDER}
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints=breakpoints,
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Find IMMEDIATE stores of a constant to a struct offset, in a live module.
|
||||
|
||||
immstore.py <imm_dec> [disp_hex|any] [--exe]
|
||||
|
||||
Only `C7 /0` (mov dword [reg+disp], imm32) can INTRODUCE a constant into a
|
||||
field; `89 /r` merely propagates one. Emits image VAs so they can be fed to
|
||||
ldis.py. Read-only.
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
CARDS_IMG = 0x180000000
|
||||
EXE_IMG = 0x140000000
|
||||
|
||||
|
||||
def pid():
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||
return int(os.path.basename(d))
|
||||
except OSError:
|
||||
pass
|
||||
raise SystemExit("FIFA17.exe not running")
|
||||
|
||||
|
||||
P = pid()
|
||||
|
||||
|
||||
def module_base(n):
|
||||
for l in open(f"/proc/{P}/maps"):
|
||||
if n.lower() in l.lower():
|
||||
return int(l.split("-")[0], 16)
|
||||
raise SystemExit(f"{n} not mapped")
|
||||
|
||||
|
||||
def text_spans(base):
|
||||
out = []
|
||||
started = False
|
||||
for l in open(f"/proc/{P}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", l)
|
||||
if not m:
|
||||
continue
|
||||
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)
|
||||
if lo == base:
|
||||
started = True
|
||||
continue
|
||||
if started:
|
||||
if not path.strip() and "x" in perms:
|
||||
out.append((lo, hi))
|
||||
elif out:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
args = [a for a in sys.argv[1:] if a != "--exe"]
|
||||
exe = "--exe" in sys.argv
|
||||
imm = int(args[0], 0)
|
||||
want_disp = None if len(args) < 2 or args[1] == "any" else int(args[1], 16)
|
||||
img = EXE_IMG if exe else CARDS_IMG
|
||||
base = module_base("FIFA17.exe" if exe else "CardsDLL")
|
||||
|
||||
mem = open(f"/proc/{P}/mem", "rb", 0)
|
||||
immb = struct.pack("<i", imm)
|
||||
hits = 0
|
||||
for lo, hi in text_spans(base):
|
||||
mem.seek(lo)
|
||||
buf = mem.read(hi - lo)
|
||||
img_lo = img + (lo - base)
|
||||
i = buf.find(b"\xc7", 0)
|
||||
while i >= 0:
|
||||
modrm = buf[i + 1] if i + 1 < len(buf) else 0
|
||||
if (modrm & 0x38) == 0: # /0
|
||||
mod, rm = modrm >> 6, modrm & 7
|
||||
if mod == 1 and i + 7 <= len(buf): # disp8
|
||||
disp, ib = buf[i + 2], i + 3
|
||||
sz = 7
|
||||
elif mod == 2 and i + 10 <= len(buf): # disp32
|
||||
disp, ib = struct.unpack_from("<i", buf, i + 2)[0], i + 6
|
||||
sz = 10
|
||||
elif mod == 0 and rm not in (4, 5) and i + 6 <= len(buf):
|
||||
disp, ib = 0, i + 2
|
||||
sz = 6
|
||||
else:
|
||||
disp = None
|
||||
if disp is not None and buf[ib:ib + 4] == immb:
|
||||
if want_disp is None or disp == want_disp:
|
||||
print(f" image 0x{img_lo+i:x} mov dword [reg+0x{disp:x}], {imm} ({sz}B)")
|
||||
hits += 1
|
||||
i = buf.find(b"\xc7", i + 1)
|
||||
print(f" {hits} immediate store(s) of {imm}"
|
||||
+ (f" at +0x{want_disp:x}" if want_disp is not None else ""))
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only live disassembler for the FIFA17 client (CardsDLL / FIFA17.exe).
|
||||
|
||||
ldis.py <image_va_hex> [nbytes] [--exe] disassemble
|
||||
ldis.py --bytes <image_va_hex> [nbytes] hexdump
|
||||
ldis.py --map show module bases
|
||||
|
||||
CardsDLL image base 0x180000000; FIFA17.exe image base 0x140000000.
|
||||
Live address = module_base + (image_va - img_base). Sections map 1:1 for both,
|
||||
but this is recomputed and printed so the offset trap stays visible.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
PID = None
|
||||
CARDS_IMG = 0x180000000
|
||||
EXE_IMG = 0x140000000
|
||||
|
||||
|
||||
def pid():
|
||||
global PID
|
||||
if PID is None:
|
||||
import glob, os
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||
PID = int(os.path.basename(d))
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
if PID is None:
|
||||
raise SystemExit("FIFA17.exe not running")
|
||||
return PID
|
||||
|
||||
|
||||
def module_base(needle):
|
||||
"""Base = the NAMED PE-header mapping for the module (Wine maps the rest
|
||||
anonymously, so never trust the mapping that merely CONTAINS an address)."""
|
||||
for l in open(f"/proc/{pid()}/maps"):
|
||||
if needle.lower() in l.lower():
|
||||
return int(l.split("-")[0], 16)
|
||||
raise SystemExit(f"module {needle} not mapped")
|
||||
|
||||
|
||||
def live(va, exe=False):
|
||||
if exe:
|
||||
return module_base("FIFA17.exe") + (va - EXE_IMG)
|
||||
return module_base("CardsDLL") + (va - CARDS_IMG)
|
||||
|
||||
|
||||
def read(va, n, exe=False):
|
||||
la = live(va, exe)
|
||||
with open(f"/proc/{pid()}/mem", "rb", 0) as m:
|
||||
m.seek(la)
|
||||
return la, m.read(n)
|
||||
|
||||
|
||||
def main():
|
||||
a = sys.argv[1:]
|
||||
if not a or a[0] == "--map":
|
||||
print(f" pid = {pid()}")
|
||||
print(f" CardsDLL = 0x{module_base('CardsDLL'):x} (image 0x{CARDS_IMG:x})")
|
||||
print(f" FIFA17.exe = 0x{module_base('FIFA17.exe'):x} (image 0x{EXE_IMG:x})")
|
||||
return
|
||||
hexdump = a[0] == "--bytes"
|
||||
if hexdump:
|
||||
a = a[1:]
|
||||
exe = "--exe" in a
|
||||
a = [x for x in a if x != "--exe"]
|
||||
va = int(a[0], 16)
|
||||
n = int(a[1]) if len(a) > 1 else 160
|
||||
la, buf = read(va, n, exe)
|
||||
print(f" image 0x{va:x} -> live 0x{la:x} ({len(buf)} bytes)")
|
||||
if hexdump:
|
||||
for i in range(0, len(buf), 16):
|
||||
c = buf[i:i + 16]
|
||||
print(f" 0x{va+i:x}: {' '.join(f'{b:02x}' for b in c):<47} "
|
||||
+ "".join(chr(b) if 32 <= b < 127 else "." for b in c))
|
||||
return
|
||||
with tempfile.NamedTemporaryFile(suffix=".bin") as f:
|
||||
f.write(buf)
|
||||
f.flush()
|
||||
out = subprocess.run(
|
||||
["objdump", "-D", "-b", "binary", "-m", "i386:x86-64", "-M", "intel",
|
||||
f"--adjust-vma=0x{va:x}", f.name],
|
||||
capture_output=True, text=True).stdout
|
||||
for line in out.splitlines():
|
||||
if re.match(r"\s+[0-9a-f]+:", line):
|
||||
print(" " + line.strip())
|
||||
|
||||
|
||||
main()
|
||||
Executable
+114
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only census of FIFA 17's RESIDENT club-item vector.
|
||||
|
||||
Chain, every link from CardsDLL static RE:
|
||||
[CardsDLL+0x2e6398] -> owner object (FUN_18011a830)
|
||||
owner->vtable[0x4e8] -> getter returning mgr (call *0x4e8(%rdx))
|
||||
mgr+0x108 .. mgr+0x110 -> club-item vector, stride 24
|
||||
element+0x10 -> the item record pointer (FUN_1800d73d0)
|
||||
record+0x4c cardtype (derived from cardsubtypeid by FUN_1800d8330: 9/10/11 -> 7)
|
||||
record+0x50 cardsubtypeid
|
||||
record+0x5c itemState (101 activeHomeKit, 102 activeAwayKit)
|
||||
record+0x60 category (clone driver FUN_1801c3480 requires 4)
|
||||
record+0x94 teamid
|
||||
record+0xba teamkittypetechid (u16)
|
||||
Offsets not in that list are labelled UNVERIFIED and only dumped raw.
|
||||
No writes. Ever.
|
||||
"""
|
||||
import re, struct, sys, collections
|
||||
|
||||
PID = int(sys.argv[1]) if len(sys.argv) > 1 else 44405
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
def rd(a, n):
|
||||
mem.seek(a); return mem.read(n)
|
||||
def q(a):
|
||||
return struct.unpack("<Q", rd(a, 8))[0]
|
||||
def i32(b, o):
|
||||
return struct.unpack_from("<i", b, o)[0]
|
||||
|
||||
# locate CardsDLL by its NEAREST PRECEDING NAMED mapping (Wine maps PE sections anon)
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m: named.append((int(m.group(1),16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = None
|
||||
for s, p in named:
|
||||
if p.endswith("CardsDLL_Win64_retail.dll"):
|
||||
base = s; break
|
||||
if base is None:
|
||||
print(" CardsDLL mapping not found"); sys.exit(1)
|
||||
print(f" CardsDLL base = {base:#x}")
|
||||
def live(static): return base + (static - 0x180000000)
|
||||
|
||||
# sanity: the 0x7575 sender immediate must be where static RE says
|
||||
probe = rd(live(0x180026fea), 6)
|
||||
print(f" sanity @0x180026fea: {probe.hex(' ')} (expect ba 75 75 00 00)")
|
||||
if probe[:5] != bytes.fromhex("ba75750000"):
|
||||
print(" SANITY FAILED - base wrong, aborting"); sys.exit(1)
|
||||
|
||||
owner = q(live(0x1802e6398))
|
||||
print(f" owner object = {owner:#x}")
|
||||
vt = q(owner)
|
||||
getter = q(vt + 0x4e8)
|
||||
print(f" vtable = {vt:#x}")
|
||||
print(f" vtable[0x4e8] = {getter:#x} bytes: {rd(getter,12).hex(' ')}")
|
||||
# expect: mov rax,[rcx+off] ; ret -> 48 8b 81 off32 c3 or 48 8b 41 off8 c3
|
||||
b = rd(getter, 12)
|
||||
mgr = None
|
||||
if b[0:3] == bytes.fromhex("488d81"):
|
||||
off = struct.unpack_from("<I", b, 3)[0]; mgr = owner + off
|
||||
print(f" getter returns owner+{off:#x} (EMBEDDED subobject) -> mgr = {mgr:#x}")
|
||||
elif b[0:3] == bytes.fromhex("488d41"):
|
||||
off = b[3]; mgr = owner + off
|
||||
print(f" getter returns owner+{off:#x} (EMBEDDED subobject) -> mgr = {mgr:#x}")
|
||||
elif b[0:3] == bytes.fromhex("488b81"):
|
||||
off = struct.unpack_from("<I", b, 3)[0]; mgr = q(owner + off)
|
||||
print(f" getter returns [owner+{off:#x}] -> mgr = {mgr:#x}")
|
||||
elif b[0:3] == bytes.fromhex("488b41"):
|
||||
off = b[3]; mgr = q(owner + off)
|
||||
print(f" getter returns [owner+{off:#x}] -> mgr = {mgr:#x}")
|
||||
elif b[0:2] == bytes.fromhex("488b") and b[2] == 0xc1:
|
||||
mgr = owner; print(" getter returns owner itself")
|
||||
else:
|
||||
print(" getter shape unrecognised; trying owner as mgr")
|
||||
mgr = owner
|
||||
|
||||
for label, mgr_try in (("resolved", mgr), ("owner", owner)):
|
||||
try:
|
||||
beg, end = q(mgr_try + 0x108), q(mgr_try + 0x110)
|
||||
except OSError:
|
||||
print(f" [{label}] +0x108/0x110 unreadable"); continue
|
||||
if not (0 < beg <= end) or (end - beg) % 24 or (end - beg) > 24*100000:
|
||||
print(f" [{label}] vector implausible: {beg:#x}..{end:#x}")
|
||||
continue
|
||||
n = (end - beg) // 24
|
||||
print(f"\n === club-item vector via {label}: {beg:#x}..{end:#x} {n} slot(s) ===")
|
||||
hist = collections.Counter(); rows = []
|
||||
for k in range(n):
|
||||
try:
|
||||
rec = q(beg + k*24 + 0x10)
|
||||
except OSError:
|
||||
continue
|
||||
if not rec:
|
||||
hist[("<null slot>", None)] += 1; continue
|
||||
try:
|
||||
r = rd(rec, 0xC0)
|
||||
except OSError:
|
||||
continue
|
||||
if len(r) < 0xC0: continue
|
||||
ct, sub, st, cat = i32(r,0x4c), i32(r,0x50), i32(r,0x5c), i32(r,0x60)
|
||||
team = i32(r,0x94); kt = struct.unpack_from("<H", r, 0xba)[0]
|
||||
hist[(ct, sub)] += 1
|
||||
rows.append((rec, ct, sub, st, cat, team, kt))
|
||||
print(f" (cardtype, cardsubtypeid) histogram:")
|
||||
for key, c in sorted(hist.items(), key=lambda x: -x[1]):
|
||||
tag = " <== KIT (selector needs this)" if key == (7, 9) else ""
|
||||
print(f" {str(key):<18} x{c}{tag}")
|
||||
print(f" cardtype 7 records: {sum(c for (ct,_),c in hist.items() if ct==7)}")
|
||||
print(f"\n first 12 records:")
|
||||
print(f" {'ptr':>14} {'ctype':>5} {'subtype':>7} {'state':>5} {'cat':>4} {'team':>5} {'kittype':>7}")
|
||||
for rec, ct, sub, st, cat, team, kt in rows[:12]:
|
||||
print(f" {rec:#14x} {ct:>5} {sub:>7} {st:>5} {cat:>4} {team:>5} {kt:>7}")
|
||||
break
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Byte-level diff of the two resident kit records in a live FIFA17 client.
|
||||
|
||||
The pre-match selector draws each kit from a clone query keyed on the record's
|
||||
own fields, so if both tiles render identically the question is precisely: which
|
||||
bytes of the home record differ from the away record? This prints every differing
|
||||
offset with the known field names attached, and dumps the fields the decoded
|
||||
clone query consumes.
|
||||
|
||||
Read-only. Never writes to the process.
|
||||
|
||||
Decoded query (FUN_1801c3480 -> FUN_1801c44b0):
|
||||
teamtechid == record+0x94
|
||||
teamkittypetechid == derived from itemState (101 -> 0 home, 102 -> 1 away)
|
||||
year == record+0xba
|
||||
"""
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
PID = int(sys.argv[1])
|
||||
WANT = [int(a) for a in sys.argv[2:]] or [100004874, 100004873]
|
||||
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
|
||||
def rd(a, n):
|
||||
mem.seek(a)
|
||||
return mem.read(n)
|
||||
|
||||
|
||||
def q(a):
|
||||
return struct.unpack("<Q", rd(a, 8))[0]
|
||||
|
||||
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m:
|
||||
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = next((s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll")), None)
|
||||
if base is None:
|
||||
sys.exit("CardsDLL mapping not found")
|
||||
|
||||
|
||||
def live(static):
|
||||
return base + (static - 0x180000000)
|
||||
|
||||
|
||||
if rd(live(0x180026FEA), 5) != bytes.fromhex("ba75750000"):
|
||||
sys.exit("SANITY FAILED - wrong base")
|
||||
print(f" CardsDLL base = {base:#x} (sanity ok)")
|
||||
|
||||
owner = q(live(0x1802E6398))
|
||||
sentinel = owner + 0x160C8
|
||||
root = q(owner + 0x160D8)
|
||||
|
||||
# Known record fields, offset -> (name, width)
|
||||
FIELDS = {
|
||||
0x08: ("id", 8),
|
||||
0x18: ("resourceId/definitionId", 4),
|
||||
# Offsets per club_items.json `_record_map`, which is authoritative:
|
||||
# cardassetid is +0x1c and assetId is +0x20 — NOT the other way round.
|
||||
0x1C: ("cardassetid", 4),
|
||||
0x20: ("assetId", 4),
|
||||
0x38: ("discardValue", 4),
|
||||
0x4C: ("cardtype", 4),
|
||||
0x50: ("cardsubtypeid", 4),
|
||||
0x5C: ("itemState", 4),
|
||||
0x60: ("category(club slot)", 4),
|
||||
0x8C: ("contract", 4),
|
||||
0x94: ("teamid", 4),
|
||||
0xB4: ("rating", 4),
|
||||
0xB8: ("wire category", 1),
|
||||
0xBA: ("year", 2),
|
||||
0x148: ("nation", 4),
|
||||
0x154: ("leagueId", 4),
|
||||
}
|
||||
|
||||
|
||||
def walk(node, out):
|
||||
if not node or node == sentinel:
|
||||
return
|
||||
walk(q(node + 0x00), out)
|
||||
# The record is EMBEDDED at node+0x28 — NOT a pointer stored there.
|
||||
out.append((struct.unpack("<q", rd(node + 0x20, 8))[0], node + 0x28))
|
||||
walk(q(node + 0x08), out)
|
||||
|
||||
|
||||
nodes = []
|
||||
walk(root, nodes)
|
||||
recs = {k: v for k, v in nodes}
|
||||
|
||||
found = [(w, recs[w]) for w in WANT if w in recs]
|
||||
if len(found) < 2:
|
||||
sys.exit(f" need two resident kit records, found {[w for w, _ in found]}")
|
||||
|
||||
(id_a, ptr_a), (id_b, ptr_b) = found[0], found[1]
|
||||
a = rd(ptr_a, 0x180)
|
||||
b = rd(ptr_b, 0x180)
|
||||
print(f" A = {id_a} @ {ptr_a:#x}")
|
||||
print(f" B = {id_b} @ {ptr_b:#x}")
|
||||
|
||||
print("\n --- fields the clone query consumes ---")
|
||||
for off in (0x94, 0x5C, 0xBA):
|
||||
name = FIELDS[off][0]
|
||||
w = FIELDS[off][1]
|
||||
va = int.from_bytes(a[off : off + w], "little")
|
||||
vb = int.from_bytes(b[off : off + w], "little")
|
||||
flag = "" if va != vb else " <== IDENTICAL"
|
||||
print(f" +{off:#05x} {name:24} A={va:<12} B={vb:<12}{flag}")
|
||||
|
||||
print("\n --- every differing byte range ---")
|
||||
diffs = [i for i in range(0x180) if a[i] != b[i]]
|
||||
runs = []
|
||||
for i in diffs:
|
||||
if runs and i == runs[-1][1] + 1:
|
||||
runs[-1][1] = i
|
||||
else:
|
||||
runs.append([i, i])
|
||||
for s, e in runs:
|
||||
named_field = next(
|
||||
(n for o, (n, w) in FIELDS.items() if o <= s < o + w), "(unmapped)"
|
||||
)
|
||||
va = int.from_bytes(a[s : e + 1], "little")
|
||||
vb = int.from_bytes(b[s : e + 1], "little")
|
||||
print(f" +{s:#05x}..{e:#05x} {named_field:24} A={va:<12} B={vb}")
|
||||
print(f"\n {len(diffs)} differing bytes in {len(runs)} runs")
|
||||
|
||||
print("\n --- known fields, side by side ---")
|
||||
for off in sorted(FIELDS):
|
||||
name, w = FIELDS[off]
|
||||
va = int.from_bytes(a[off : off + w], "little")
|
||||
vb = int.from_bytes(b[off : off + w], "little")
|
||||
mark = " DIFFERS" if va != vb else ""
|
||||
print(f" +{off:#05x} {name:24} A={va:<12} B={vb:<12}{mark}")
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
# Remove the port-8081 DNAT rule that hijacks FIFA 17's roster/squad-update TLS.
|
||||
#
|
||||
# Why: the FUT squad update is https://winter15.gosredirector.ea.com:8081/fifa17/fut/rosterupdate.xml
|
||||
# (TLS on port 8081). A DNAT rule rewriting dport 8081 -> 8299 sends that TLS
|
||||
# handshake to the plain-HTTP staging UTAS host, which closes the connection.
|
||||
# Proven: a probe to 10.10.0.120:8081 from this box arrives at the server as
|
||||
# dport 8299. Result: "An error occurred downloading the FUT squad update."
|
||||
#
|
||||
# The rule also never redirected UTAS, which lives on :8443, not :8081.
|
||||
#
|
||||
# Read-only until it deletes; deletes only nat rules whose target port is 8299.
|
||||
set -u
|
||||
|
||||
echo "== nat OUTPUT rules mentioning 8081 or 8299 =="
|
||||
iptables -t nat -S OUTPUT 2>/dev/null | grep -E '8081|8299' || echo " (none)"
|
||||
|
||||
echo
|
||||
echo "== deleting DNAT rules that redirect to port 8299 =="
|
||||
removed=0
|
||||
# Delete by spec, repeatedly, until no matching rule remains.
|
||||
while :; do
|
||||
rule=$(iptables -t nat -S OUTPUT 2>/dev/null | grep -m1 -E '\-\-dport 8081 .*8299|to-destination [0-9.]+:8299')
|
||||
[ -z "$rule" ] && break
|
||||
spec=$(printf '%s' "$rule" | sed 's/^-A /-D /')
|
||||
# shellcheck disable=SC2086
|
||||
if iptables -t nat $spec 2>/dev/null; then
|
||||
echo " removed: $rule"
|
||||
removed=$((removed + 1))
|
||||
else
|
||||
echo " FAILED to remove: $rule" >&2
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ "$removed" -eq 0 ] && echo " (no matching rule found)"
|
||||
|
||||
echo
|
||||
echo "== remaining nat OUTPUT rules mentioning 8081 or 8299 =="
|
||||
iptables -t nat -S OUTPUT 2>/dev/null | grep -E '8081|8299' || echo " (none)"
|
||||
|
||||
echo
|
||||
echo "== verifying the roster endpoint now presents the correct certificate =="
|
||||
python3 - <<'PY'
|
||||
import socket, ssl
|
||||
host, port, sni = "10.10.0.120", 8081, "winter15.gosredirector.ea.com"
|
||||
try:
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
with socket.create_connection((host, port), 8) as s:
|
||||
with ctx.wrap_socket(s, server_hostname=sni) as t:
|
||||
der = t.getpeercert(True)
|
||||
cn = dict(x[0] for x in t.getpeercert().get("subject", ()))
|
||||
print(f" PASS {host}:{port} sni={sni} {t.version()} der={len(der)}B subject={cn}")
|
||||
except Exception as e:
|
||||
print(f" FAIL {host}:{port} sni={sni} -> {type(e).__name__}: {e}")
|
||||
print(" The roster path is still broken; do not relaunch yet.")
|
||||
PY
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hunt for specific wire instance ids anywhere in the client's writable memory.
|
||||
|
||||
Answers whether a served item was materialised into a record at all, versus
|
||||
materialised but not attached to a collection. A record is recognised by its
|
||||
established layout: id at +0x08, resourceId at +0x18, cardtype at +0x4c.
|
||||
|
||||
Read-only. Never writes.
|
||||
|
||||
usage: probe_hunt.py PID id [id ...]
|
||||
"""
|
||||
import re, struct, sys
|
||||
|
||||
PID = int(sys.argv[1])
|
||||
IDS = [int(a) for a in sys.argv[2:]]
|
||||
if not IDS:
|
||||
sys.exit("give at least one wire id")
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
regions = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) (\S{4}) \S+ \S+ \S+\s*(.*)", ln)
|
||||
if not m:
|
||||
continue
|
||||
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4).strip()
|
||||
if "w" not in perms:
|
||||
continue
|
||||
if path.startswith("/") and not path.endswith(".dll") and not path.endswith(".exe"):
|
||||
continue
|
||||
regions.append((lo, hi, perms, path))
|
||||
total = sum(hi - lo for lo, hi, _, _ in regions)
|
||||
print(f" {len(regions)} writable regions, {total/2**20:.0f} MiB to scan")
|
||||
|
||||
needles = {struct.pack("<I", i): i for i in IDS}
|
||||
hits = {i: [] for i in IDS}
|
||||
CHUNK = 8 << 20
|
||||
scanned = 0
|
||||
for lo, hi, perms, path in regions:
|
||||
a = lo
|
||||
while a < hi:
|
||||
n = min(CHUNK, hi - a)
|
||||
try:
|
||||
mem.seek(a)
|
||||
data = mem.read(n)
|
||||
except OSError:
|
||||
a += n
|
||||
continue
|
||||
if not data:
|
||||
a += n
|
||||
continue
|
||||
scanned += len(data)
|
||||
for nd, wid in needles.items():
|
||||
start = 0
|
||||
while True:
|
||||
j = data.find(nd, start)
|
||||
if j < 0:
|
||||
break
|
||||
start = j + 1
|
||||
va = a + j
|
||||
# a record would place this id at +0x08
|
||||
rec = va - 0x08
|
||||
try:
|
||||
mem.seek(rec)
|
||||
r = mem.read(0x100)
|
||||
except OSError:
|
||||
continue
|
||||
if len(r) < 0x100:
|
||||
continue
|
||||
ct = struct.unpack_from("<i", r, 0x4c)[0]
|
||||
res = struct.unpack_from("<I", r, 0x18)[0]
|
||||
sub = struct.unpack_from("<i", r, 0x50)[0]
|
||||
cat = struct.unpack_from("<i", r, 0x60)[0]
|
||||
looks = 0 <= ct <= 32 and res > 1000
|
||||
hits[wid].append((va, rec, ct, sub, cat, res, looks))
|
||||
a += n
|
||||
print(f" scanned {scanned/2**20:.0f} MiB\n")
|
||||
for wid in IDS:
|
||||
hs = hits[wid]
|
||||
recs = [h for h in hs if h[6]]
|
||||
print(f" id {wid}: {len(hs)} raw occurrence(s), {len(recs)} record-shaped")
|
||||
for va, rec, ct, sub, cat, res, _ in recs[:6]:
|
||||
print(f" record {rec:#x}: cardtype={ct} subtype={sub} category={cat} resourceId={res}")
|
||||
if not recs:
|
||||
print(" NOT MATERIALISED as a record anywhere in writable memory")
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Identify every resident record by its wire instance id.
|
||||
|
||||
Record layout established from known wire values:
|
||||
+0x08 id (wire instance) +0x18 resourceId +0x1c/+0x20 assetId
|
||||
+0x38 discardValue +0x4c cardtype +0x50 cardsubtypeid
|
||||
+0x5c itemState +0x60 category +0x94 teamid
|
||||
+0xb4 rating +0xba teamkittypetechid (u16)
|
||||
|
||||
Walks the contiguous 0x180-stride pool around the manager slot record so records
|
||||
that are resident but not in any collection are still seen. Read-only.
|
||||
|
||||
usage: probe_ids.py PID [expected_id ...]
|
||||
"""
|
||||
import re, struct, sys
|
||||
|
||||
PID = int(sys.argv[1])
|
||||
WANT = {int(a) for a in sys.argv[2:]}
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
def rd(a, n):
|
||||
mem.seek(a); return mem.read(n)
|
||||
def q(a):
|
||||
return struct.unpack("<Q", rd(a, 8))[0]
|
||||
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m:
|
||||
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
|
||||
live = lambda s: base + (s - 0x180000000)
|
||||
if rd(live(0x180026fea), 5) != bytes.fromhex("ba75750000"):
|
||||
sys.exit("SANITY FAILED")
|
||||
owner = q(live(0x1802e6398))
|
||||
mgr = owner + 0x1f9d8
|
||||
RECSZ = 0x180
|
||||
|
||||
def dec(rec):
|
||||
r = rd(rec, 0x180)
|
||||
g = lambda o: struct.unpack_from("<i", r, o)[0]
|
||||
return dict(id=struct.unpack_from("<I", r, 0x8)[0], res=struct.unpack_from("<I", r, 0x18)[0],
|
||||
ct=g(0x4c), sub=g(0x50), st=g(0x5c), cat=g(0x60), team=g(0x94),
|
||||
rating=struct.unpack_from("<I", r, 0xb4)[0],
|
||||
kt=struct.unpack_from("<H", r, 0xba)[0])
|
||||
|
||||
mgr_rec = q(mgr + 0xc0 + 0x10)
|
||||
print(f" manager-slot record = {mgr_rec:#x}")
|
||||
anchor = mgr_rec if mgr_rec else q(q(mgr + 0xd8) + 0x10)
|
||||
|
||||
# walk backwards to the start of the contiguous run, then forwards
|
||||
lo = anchor
|
||||
for _ in range(64):
|
||||
prev = lo - RECSZ
|
||||
try:
|
||||
d = dec(prev)
|
||||
except OSError:
|
||||
break
|
||||
if not (0 < d["ct"] < 64) or d["id"] == 0:
|
||||
break
|
||||
lo = prev
|
||||
|
||||
print(f" pool run starts at {lo:#x}\n")
|
||||
print(f" {'idx':>3} {'addr':>12} {'id':>10} {'resource':>9} {'ct':>3} {'sub':>4} "
|
||||
f"{'st':>3} {'cat':>4} {'team':>5} {'rate':>5} {'kt':>6}")
|
||||
found = {}
|
||||
k = 0
|
||||
addr = lo
|
||||
while k < 48:
|
||||
try:
|
||||
d = dec(addr)
|
||||
except OSError:
|
||||
break
|
||||
if d["id"] == 0 and d["ct"] == 0:
|
||||
break
|
||||
tag = ""
|
||||
if d["ct"] == 7:
|
||||
tag = " <== CARDTYPE 7"
|
||||
if d["id"] in WANT:
|
||||
tag += " <== WANTED"
|
||||
found[d["id"]] = addr
|
||||
slot = " [manager slot]" if addr == mgr_rec else ""
|
||||
print(f" {k:>3} {addr:#12x} {d['id']:>10} {d['res']:>9} {d['ct']:>3} {d['sub']:>4} "
|
||||
f"{d['st']:>3} {d['cat']:>4} {d['team']:>5} {d['rating']:>5} {d['kt']:>6}{tag}{slot}")
|
||||
addr += RECSZ
|
||||
k += 1
|
||||
|
||||
if WANT:
|
||||
print(f"\n wanted ids: {sorted(WANT)}")
|
||||
for w in sorted(WANT):
|
||||
print(f" {w}: {'FOUND at ' + hex(found[w]) if w in found else 'NOT RESIDENT'}")
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Map FIFA 17 resident record offsets using UNIQUE wire values as ground truth.
|
||||
|
||||
v2: identifies each record by its wire instance id (large, unique) and only
|
||||
accepts a field mapping when the value is distinctive (>= 16) and the same
|
||||
offset holds the right value for EVERY identified record. This avoids the v1
|
||||
failure where cardsubtypeid == 0 matched every zeroed field in the struct.
|
||||
|
||||
Read-only. Never writes.
|
||||
|
||||
usage: probe_layout2.py PID squad_active.json
|
||||
"""
|
||||
import re, struct, sys, json, collections
|
||||
|
||||
PID = int(sys.argv[1])
|
||||
SQUAD = json.load(open(sys.argv[2]))
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
def rd(a, n):
|
||||
mem.seek(a); return mem.read(n)
|
||||
def q(a):
|
||||
return struct.unpack("<Q", rd(a, 8))[0]
|
||||
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m:
|
||||
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
|
||||
live = lambda s: base + (s - 0x180000000)
|
||||
if rd(live(0x180026fea), 5) != bytes.fromhex("ba75750000"):
|
||||
sys.exit("SANITY FAILED")
|
||||
owner = q(live(0x1802e6398))
|
||||
mgr = owner + 0x1f9d8
|
||||
RECSZ = 0x180
|
||||
|
||||
beg, end = q(mgr + 0xd8), q(mgr + 0xe0)
|
||||
recs = [r for r in (q(beg + k*24 + 0x10) for k in range((end - beg)//24)) if r]
|
||||
|
||||
wire = {}
|
||||
for p in SQUAD["players"]:
|
||||
it = p.get("itemData") or {}
|
||||
if it.get("id"):
|
||||
wire[it["id"]] = it
|
||||
|
||||
# --- identify each record by its wire instance id ---
|
||||
ident = {}
|
||||
for rec in recs:
|
||||
r = rd(rec, RECSZ)
|
||||
for off in range(0, RECSZ - 4, 4):
|
||||
v = struct.unpack_from("<I", r, off)[0]
|
||||
if v in wire:
|
||||
ident.setdefault(rec, (v, off))
|
||||
break
|
||||
print(f" resident player records: {len(recs)}, identified: {len(ident)}")
|
||||
id_offs = collections.Counter(o for _, o in ident.values())
|
||||
print(f" wire-id offset candidates: {[(hex(o), c) for o, c in id_offs.most_common()]}")
|
||||
|
||||
FIELDS = ("id", "resourceId", "assetId", "definitionId", "cardassetid", "rating",
|
||||
"teamid", "nation", "leagueId", "contract", "fitness", "playStyle",
|
||||
"discardValue", "cardsubtypeid", "owners", "rareflag")
|
||||
# --- for every offset, does it hold field F for every identified record? ---
|
||||
consistent = {}
|
||||
for off in range(0, RECSZ - 4, 4):
|
||||
for f in FIELDS:
|
||||
ok = 0; total = 0; distinct = set()
|
||||
for rec, (wid, _) in ident.items():
|
||||
it = wire[wid]
|
||||
v = it.get(f)
|
||||
if not isinstance(v, int) or v < 16: # require distinctive values
|
||||
continue
|
||||
total += 1
|
||||
got = struct.unpack_from("<I", rd(rec, RECSZ), off)[0]
|
||||
if got == v:
|
||||
ok += 1; distinct.add(v)
|
||||
if total >= 5 and ok == total and len(distinct) >= 2:
|
||||
consistent.setdefault(off, []).append((f, total, len(distinct)))
|
||||
|
||||
print(f"\n === offsets consistently holding a distinctive wire field ===")
|
||||
for off in sorted(consistent):
|
||||
for f, total, nd in consistent[off]:
|
||||
print(f" +0x{off:<4x} {f:14s} (matched {total}/{total} records, {nd} distinct values)")
|
||||
|
||||
# --- dump the manager and the three club staff for comparison ---
|
||||
print(f"\n === cardtype-2 slot (manager) ===")
|
||||
h = q(mgr + 0xc0 + 0x10)
|
||||
if h:
|
||||
r = rd(h, RECSZ)
|
||||
for off in sorted(consistent):
|
||||
f = consistent[off][0][0]
|
||||
print(f" +0x{off:<4x} {f:14s} = {struct.unpack_from('<I', r, off)[0]}")
|
||||
for name, off, sz in (("cardtype", 0x4c, 4), ("cardsubtypeid", 0x50, 4),
|
||||
("itemState", 0x5c, 4), ("category", 0x60, 4)):
|
||||
print(f" +0x{off:<4x} {name:14s} = {struct.unpack_from('<i', r, off)[0]}")
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enumerate FIFA 17's resident item map authoritatively.
|
||||
|
||||
Layout recovered from the lower_bound at 0x180119640:
|
||||
owner+0x160c8 sentinel / end marker
|
||||
owner+0x160d8 root
|
||||
owner+0x160e8 count
|
||||
node+0x00, node+0x08 children
|
||||
node+0x20 key = wire instance id (qword)
|
||||
node+0x28 the item record
|
||||
On miss the client returns the static sentinel 0x1802c2a28 whose +0x10 is NULL.
|
||||
|
||||
Read-only. usage: probe_map2.py PID [id ...]
|
||||
"""
|
||||
import re, struct, sys, collections
|
||||
|
||||
PID = int(sys.argv[1]); WANT = {int(a) for a in sys.argv[2:]}
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
def rd(a, n):
|
||||
mem.seek(a); return mem.read(n)
|
||||
def q(a): return struct.unpack("<Q", rd(a, 8))[0]
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m: named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
|
||||
if rd(base + (0x180026fea - 0x180000000), 5) != bytes.fromhex("ba75750000"):
|
||||
sys.exit("SANITY FAILED")
|
||||
owner = q(base + (0x1802e6398 - 0x180000000))
|
||||
SENT, ROOT, COUNT = owner + 0x160c8, q(owner + 0x160d8), q(owner + 0x160e8) & 0xffffffff
|
||||
print(f" owner={owner:#x} sentinel={SENT:#x} root={ROOT:#x} count={COUNT}")
|
||||
|
||||
nodes, seen, stack = [], set(), [ROOT]
|
||||
while stack:
|
||||
n = stack.pop()
|
||||
if not n or n == SENT or n in seen or len(seen) > 5000:
|
||||
continue
|
||||
seen.add(n)
|
||||
try:
|
||||
h = rd(n, 0x30)
|
||||
except OSError:
|
||||
continue
|
||||
if len(h) < 0x30:
|
||||
continue
|
||||
nodes.append(n)
|
||||
stack.append(struct.unpack_from("<Q", h, 0)[0])
|
||||
stack.append(struct.unpack_from("<Q", h, 8)[0])
|
||||
print(f" nodes reached: {len(nodes)} (count field says {COUNT})\n")
|
||||
|
||||
print(f" {'key':>11} {'record':>12} {'id':>10} {'resource':>10} {'ct':>3} {'sub':>4} {'st':>4} {'cat':>4}")
|
||||
hist = collections.Counter(); found = {}
|
||||
rows = []
|
||||
for n in nodes:
|
||||
key = q(n + 0x20)
|
||||
rec = n + 0x28
|
||||
try: r = rd(rec, 0x180)
|
||||
except OSError: continue
|
||||
if len(r) < 0x180: continue
|
||||
g = lambda o: struct.unpack_from("<i", r, o)[0]
|
||||
rid = struct.unpack_from("<I", r, 0x8)[0]
|
||||
res = struct.unpack_from("<I", r, 0x18)[0]
|
||||
ct, sub, st, cat = g(0x4c), g(0x50), g(0x5c), g(0x60)
|
||||
hist[ct] += 1
|
||||
if rid in WANT: found[rid] = rec
|
||||
rows.append((key, rec, rid, res, ct, sub, st, cat))
|
||||
for key, rec, rid, res, ct, sub, st, cat in sorted(rows):
|
||||
tag = " <== CARDTYPE 7" if ct == 7 else (" <== WANTED" if rid in WANT else "")
|
||||
print(f" {key:>11} {rec:#12x} {rid:>10} {res:>10} {ct:>3} {sub:>4} {st:>4} {cat:>4}{tag}")
|
||||
print(f"\n cardtype histogram: {dict(sorted(hist.items()))} total={sum(hist.values())}")
|
||||
for w in sorted(WANT):
|
||||
print(f" id {w}: {'RESIDENT' if w in found else 'ABSENT'}")
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only scan of the record pool embedded in the club-model owner object.
|
||||
|
||||
The 18 resident player records sit at a fixed stride of 0x180 inside the owner
|
||||
object, below the embedded manager subobject at owner+0x1f9d8. This walks that
|
||||
pool to see whether storage for the five club items exists and what it holds.
|
||||
Read-only. Never writes.
|
||||
"""
|
||||
import re, struct, sys
|
||||
|
||||
PID = int(sys.argv[1])
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
def rd(a, n):
|
||||
mem.seek(a); return mem.read(n)
|
||||
def q(a):
|
||||
return struct.unpack("<Q", rd(a, 8))[0]
|
||||
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m:
|
||||
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
|
||||
def live(s):
|
||||
return base + (s - 0x180000000)
|
||||
|
||||
owner = q(live(0x1802e6398))
|
||||
mgr = owner + 0x1f9d8
|
||||
beg, end = q(mgr + 0xd8), q(mgr + 0xe0)
|
||||
first = None
|
||||
for k in range((end - beg) // 24):
|
||||
r = q(beg + k * 24 + 0x10)
|
||||
if r:
|
||||
first = r; break
|
||||
if first is None:
|
||||
sys.exit("no populated player record to anchor the pool")
|
||||
|
||||
print(f" owner = {owner:#x} mgr = {mgr:#x} first record = {first:#x}")
|
||||
print(f" record - owner = {first - owner:#x} pool room to mgr = {(mgr - first) // 0x180} slots of 0x180")
|
||||
print()
|
||||
hdr = f" {'idx':>3} {'addr':>12} {'ctype':>6} {'subtyp':>6} {'state':>6} {'cat':>4} {'team':>5} {'kittyp':>6} set"
|
||||
print(hdr)
|
||||
n = (mgr - first) // 0x180
|
||||
for k in range(min(n, 40)):
|
||||
a = first + k * 0x180
|
||||
try:
|
||||
r = rd(a, 0xC0)
|
||||
except OSError:
|
||||
print(f" {k:>3} {a:#12x} unreadable"); break
|
||||
if len(r) < 0xC0:
|
||||
break
|
||||
ct, sub, st, cat, team = (struct.unpack_from("<i", r, o)[0] for o in (0x4c, 0x50, 0x5c, 0x60, 0x94))
|
||||
kt = struct.unpack_from("<H", r, 0xba)[0]
|
||||
nz = sum(1 for b in r if b)
|
||||
flag = ""
|
||||
if ct == 7:
|
||||
flag = " <== CARDTYPE 7"
|
||||
elif nz == 0:
|
||||
flag = " (all zero)"
|
||||
print(f" {k:>3} {a:#12x} {ct:>6} {sub:>6} {st:>6} {cat:>4} {team:>5} {kt:>6} {nz:>3}/192{flag}")
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only dump of RESIDENT record fields, for both the player and club-item vectors.
|
||||
|
||||
Purpose: the kit clone driver FUN_1801c3480 gates on record+0x60 (category) == 4.
|
||||
No instruction in CardsDLL writes immediate 4 there, so this reads what value a
|
||||
genuinely resident record actually carries. Read-only. Never writes.
|
||||
|
||||
mgr+0x0c0 cardtype-2 single slot
|
||||
mgr+0x0d8..0x0e0 cardtype-1 (player) vector
|
||||
mgr+0x108..0x110 club-item vector
|
||||
record+0x4c cardtype +0x50 cardsubtypeid +0x5c itemState
|
||||
record+0x60 category +0x94 teamid +0xba teamkittypetechid (u16)
|
||||
"""
|
||||
import re, struct, sys, collections
|
||||
|
||||
PID = int(sys.argv[1])
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
def rd(a, n):
|
||||
mem.seek(a); return mem.read(n)
|
||||
def q(a):
|
||||
return struct.unpack("<Q", rd(a, 8))[0]
|
||||
def i32(b, o):
|
||||
return struct.unpack_from("<i", b, o)[0]
|
||||
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m:
|
||||
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = next((s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll")), None)
|
||||
if base is None:
|
||||
sys.exit("CardsDLL mapping not found")
|
||||
def live(static):
|
||||
return base + (static - 0x180000000)
|
||||
|
||||
if rd(live(0x180026fea), 5) != bytes.fromhex("ba75750000"):
|
||||
sys.exit("SANITY FAILED - wrong base")
|
||||
print(f" CardsDLL base = {base:#x} (sanity ok)")
|
||||
|
||||
owner = q(live(0x1802e6398))
|
||||
b = rd(q(owner) + 0x4e8, 12)
|
||||
b = rd(struct.unpack("<Q", struct.pack("<Q", q(q(owner) + 0x4e8)))[0], 12)
|
||||
getter = q(q(owner) + 0x4e8)
|
||||
gb = rd(getter, 12)
|
||||
if gb[0:3] == bytes.fromhex("488d81"):
|
||||
mgr = owner + struct.unpack_from("<I", gb, 3)[0]
|
||||
elif gb[0:3] == bytes.fromhex("488d41"):
|
||||
mgr = owner + gb[3]
|
||||
else:
|
||||
sys.exit(f"unexpected getter shape {gb.hex(' ')}")
|
||||
print(f" owner = {owner:#x} mgr = {mgr:#x}")
|
||||
|
||||
FIELDS = ("ctype", "subtype", "state", "cat", "team", "kittype")
|
||||
def decode(rec):
|
||||
r = rd(rec, 0xC0)
|
||||
if len(r) < 0xC0:
|
||||
return None
|
||||
return (i32(r, 0x4c), i32(r, 0x50), i32(r, 0x5c), i32(r, 0x60),
|
||||
i32(r, 0x94), struct.unpack_from("<H", r, 0xba)[0])
|
||||
|
||||
for label, vbeg, vend in (("players (cardtype 1)", mgr + 0xd8, mgr + 0xe0),
|
||||
("club items", mgr + 0x108, mgr + 0x110)):
|
||||
try:
|
||||
beg, end = q(vbeg), q(vend)
|
||||
except OSError:
|
||||
print(f"\n {label}: vector unreadable")
|
||||
continue
|
||||
span = end - beg
|
||||
print(f"\n === {label}: {beg:#x}..{end:#x} span={span} ===")
|
||||
if not (0 < beg <= end) or span > 24 * 100000:
|
||||
print(" implausible vector, skipping")
|
||||
continue
|
||||
# resolve stride: the element must contain a plausible heap pointer
|
||||
for stride, ptr_off in ((24, 0x10), (16, 0x08), (8, 0x00)):
|
||||
if span % stride:
|
||||
continue
|
||||
n = span // stride
|
||||
recs, nulls = [], []
|
||||
ok = True
|
||||
for k in range(n):
|
||||
try:
|
||||
rec = q(beg + k * stride + ptr_off)
|
||||
except OSError:
|
||||
ok = False; break
|
||||
if not rec:
|
||||
nulls.append(k); continue
|
||||
d = decode(rec)
|
||||
if d is None:
|
||||
ok = False; break
|
||||
recs.append((k, rec, d))
|
||||
if not ok:
|
||||
continue
|
||||
print(f" stride {stride} (ptr at +{ptr_off:#x}): {n} slots, {len(recs)} populated, {len(nulls)} null")
|
||||
if not recs and len(nulls) != n:
|
||||
continue
|
||||
hist = collections.Counter(d[0:2] for _, _, d in recs)
|
||||
for key, c in sorted(hist.items(), key=lambda x: -x[1]):
|
||||
print(f" (cardtype,subtype)={key} x{c}")
|
||||
# The SLOT INDEX is load-bearing evidence: the squad parser's `actives`
|
||||
# arm writes element i to slot `r15d + i`, and r15d is shared scratch
|
||||
# that other atom handlers clobber. Which slots are filled therefore
|
||||
# reveals the index the parse actually started from.
|
||||
print(f" {'slot':>4} {'ptr':>14} " + " ".join(f"{f:>8}" for f in FIELDS))
|
||||
for k, rec, d in recs[:8]:
|
||||
print(f" {k:>4} {rec:#14x} " + " ".join(f"{v:>8}" for v in d))
|
||||
if nulls:
|
||||
print(f" empty slots: {nulls[:16]}")
|
||||
cats = collections.Counter(d[3] for _, _, d in recs)
|
||||
if cats:
|
||||
print(f" CATEGORY (+0x60) distribution: {dict(cats)}")
|
||||
break
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
# Native proof for the FIFA 17 kit milestone: does the client now hold resident
|
||||
# cardtype-7 records, and are the served kit ids among them?
|
||||
#
|
||||
# Auto-detects the live FIFA17.exe pid and walks the resident item map at
|
||||
# owner+0x160c8 (root +0x160d8, key = wire instance id at node+0x20, record at
|
||||
# node+0x28, count at owner+0x160e8). Read-only; never writes to the process.
|
||||
#
|
||||
# BEFORE this fix the map held 22 records with cardtype histogram {1:18, 2:1,
|
||||
# 4:2, 10:1} and both kit ids ABSENT.
|
||||
set -u
|
||||
|
||||
PID=$(for p in /proc/[0-9]*; do
|
||||
[ "$(cat "$p/comm" 2>/dev/null)" = "FIFA17.exe" ] && echo "${p#/proc/}"
|
||||
done | head -1)
|
||||
|
||||
if [ -z "$PID" ]; then
|
||||
echo " FIFA17.exe is not running - launch the game and enter FUT first"
|
||||
exit 1
|
||||
fi
|
||||
echo " live FIFA17 pid = $PID"
|
||||
echo
|
||||
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
python3 probe_map2.py "$PID" 100004873 100004874 100004870
|
||||
|
||||
echo
|
||||
echo " ================ squad survival + slot indices ================"
|
||||
# The kit milestone is only real if the REST of the squad survives with it.
|
||||
# A populated `squad.actives` was once seen to leave the map holding just the
|
||||
# 2 kits with a fully null 23-slot player vector and an empty starting 11, so
|
||||
# the player-vector fill below is a PASS/FAIL gate, not decoration.
|
||||
#
|
||||
# The club-item slot indices are the other half: the parser writes element i to
|
||||
# slot r15d+i, and r15d is scratch other atom handlers clobber. Kits landing
|
||||
# somewhere other than slots 0 and 1 means the index did not start at zero.
|
||||
python3 probe_resident_fields.py "$PID"
|
||||
Executable
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Watch FIFA 17's resident club-item store and log every change, with timestamps.
|
||||
|
||||
Read-only. Waits for FIFA17.exe to appear, re-resolves the store each tick (the
|
||||
manager is reallocated across logins), and appends one line per CHANGE so the
|
||||
output can be aligned against the staging host's route log by wall clock.
|
||||
|
||||
Purpose: answer "after which response does a resident club item first appear?"
|
||||
without reversing the constructor first. Pair with
|
||||
|
||||
journalctl -u openfut-staging-host --since <start> -o short-iso
|
||||
|
||||
and compare timestamps.
|
||||
|
||||
Usage: watch_residency.py [--interval 1.0] [--out /path/log] [--once]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
CARDS_DLL = "CardsDLL_Win64_retail.dll"
|
||||
OWNER_GLOBAL = 0x1802E6398 # FUN_18011a830: mov rax,[this]; ret
|
||||
SANITY_VA = 0x180026FEA # mov edx,0x7575
|
||||
SANITY_BYTES = bytes.fromhex("ba75750000")
|
||||
IMAGE_BASE = 0x180000000
|
||||
|
||||
# item-record offsets, all previously proven (see Vault: Kit Selector APT Decode)
|
||||
OFF = {"cardtype": 0x4C, "cardsubtypeid": 0x50, "itemState": 0x5C,
|
||||
"category": 0x60, "teamid": 0x94}
|
||||
OFF_KITTYPE_U16 = 0xBA
|
||||
|
||||
|
||||
class Target:
|
||||
"""One live FIFA17.exe, with the store chain resolved."""
|
||||
|
||||
def __init__(self, pid: int):
|
||||
self.pid = pid
|
||||
self.mem = open(f"/proc/{pid}/mem", "rb", buffering=0)
|
||||
self.base = self._cards_base()
|
||||
if self.base is None:
|
||||
raise RuntimeError("CardsDLL mapping not found")
|
||||
probe = self.rd(self.live(SANITY_VA), 5)
|
||||
if probe != SANITY_BYTES:
|
||||
raise RuntimeError(f"base sanity failed: {probe.hex(' ')}")
|
||||
owner = self.q(self.live(OWNER_GLOBAL))
|
||||
if not owner:
|
||||
raise RuntimeError("owner object is null (not logged in yet)")
|
||||
vt = self.q(owner)
|
||||
getter = self.q(vt + 0x4E8)
|
||||
b = self.rd(getter, 8)
|
||||
# lea rax,[rcx+imm32] ; ret / lea rax,[rcx+imm8] ; ret
|
||||
if b[0:3] == bytes.fromhex("488d81"):
|
||||
self.mgr = owner + struct.unpack_from("<I", b, 3)[0]
|
||||
elif b[0:3] == bytes.fromhex("488d41"):
|
||||
self.mgr = owner + b[3]
|
||||
elif b[0:3] == bytes.fromhex("488b81"):
|
||||
self.mgr = self.q(owner + struct.unpack_from("<I", b, 3)[0])
|
||||
else:
|
||||
raise RuntimeError(f"unrecognised getter: {b.hex(' ')}")
|
||||
|
||||
# -- raw access ------------------------------------------------------
|
||||
def rd(self, a: int, n: int) -> bytes:
|
||||
self.mem.seek(a)
|
||||
return self.mem.read(n)
|
||||
|
||||
def q(self, a: int) -> int:
|
||||
return struct.unpack("<Q", self.rd(a, 8))[0]
|
||||
|
||||
def live(self, static: int) -> int:
|
||||
return self.base + (static - IMAGE_BASE)
|
||||
|
||||
def _cards_base(self):
|
||||
named = []
|
||||
for ln in open(f"/proc/{self.pid}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m:
|
||||
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
# NEAREST PRECEDING NAMED mapping: Wine maps PE sections anonymously and
|
||||
# the Wine heap is also rwx, so permissions cannot identify a module.
|
||||
for start, path in sorted(named):
|
||||
if path.endswith(CARDS_DLL):
|
||||
return start
|
||||
return None
|
||||
|
||||
# -- the store -------------------------------------------------------
|
||||
def vector(self, off_begin: int):
|
||||
beg, end = self.q(self.mgr + off_begin), self.q(self.mgr + off_begin + 8)
|
||||
if not (0 < beg <= end) or (end - beg) % 24 or (end - beg) > 24 * 200000:
|
||||
return None, 0
|
||||
return beg, (end - beg) // 24
|
||||
|
||||
def records(self, off_begin: int):
|
||||
beg, n = self.vector(off_begin)
|
||||
out = []
|
||||
if beg is None:
|
||||
return out
|
||||
for k in range(n):
|
||||
try:
|
||||
rec = self.q(beg + k * 24 + 0x10)
|
||||
except OSError:
|
||||
continue
|
||||
if not rec:
|
||||
out.append(None)
|
||||
continue
|
||||
try:
|
||||
r = self.rd(rec, 0xC0)
|
||||
except OSError:
|
||||
out.append(None)
|
||||
continue
|
||||
if len(r) < 0xC0:
|
||||
out.append(None)
|
||||
continue
|
||||
f = {k2: struct.unpack_from("<i", r, v)[0] for k2, v in OFF.items()}
|
||||
f["teamkittypetechid"] = struct.unpack_from("<H", r, OFF_KITTYPE_U16)[0]
|
||||
f["ptr"] = rec
|
||||
out.append(f)
|
||||
return out
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
club = self.records(0x108)
|
||||
players = self.records(0xD8)
|
||||
hist = collections.Counter(
|
||||
(r["cardtype"], r["cardsubtypeid"]) for r in club if r
|
||||
)
|
||||
return {
|
||||
"club_slots": len(club),
|
||||
"club_filled": sum(1 for r in club if r),
|
||||
"club_hist": dict(hist),
|
||||
"club_records": [r for r in club if r],
|
||||
"player_slots": len(players),
|
||||
"player_filled": sum(1 for r in players if r),
|
||||
}
|
||||
|
||||
|
||||
def find_pid() -> int | None:
|
||||
for d in os.listdir("/proc"):
|
||||
if not d.isdigit():
|
||||
continue
|
||||
try:
|
||||
with open(f"/proc/{d}/comm") as f:
|
||||
if f.read().strip() == "FIFA17.exe":
|
||||
return int(d)
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def fmt(snap: dict) -> str:
|
||||
parts = [
|
||||
f"club={snap['club_filled']}/{snap['club_slots']}",
|
||||
f"players={snap['player_filled']}/{snap['player_slots']}",
|
||||
]
|
||||
if snap["club_hist"]:
|
||||
parts.append("hist=" + ",".join(
|
||||
f"(ct{a},st{b})x{c}" for (a, b), c in sorted(snap["club_hist"].items())))
|
||||
for r in snap["club_records"]:
|
||||
parts.append(
|
||||
"KIT[" if (r["cardtype"], r["cardsubtypeid"]) == (7, 9) else "rec[")
|
||||
parts[-1] += (f"ptr={r['ptr']:#x} ct={r['cardtype']} st={r['cardsubtypeid']} "
|
||||
f"state={r['itemState']} cat={r['category']} "
|
||||
f"team={r['teamid']} kittype={r['teamkittypetechid']}]")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--interval", type=float, default=1.0)
|
||||
ap.add_argument("--out", default="/home/alex/openfut-live/residency.log")
|
||||
ap.add_argument("--once", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
sink = sys.stdout if a.out == "-" else open(a.out, "a", buffering=1)
|
||||
|
||||
def emit(msg: str) -> None:
|
||||
line = f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} {msg}"
|
||||
print(line, file=sink)
|
||||
if sink is not sys.stdout:
|
||||
print(line, flush=True)
|
||||
|
||||
emit("watch: start")
|
||||
target = None
|
||||
last = None
|
||||
while True:
|
||||
if target is None:
|
||||
pid = find_pid()
|
||||
if pid is None:
|
||||
if a.once:
|
||||
emit("watch: no FIFA17.exe"); return 1
|
||||
time.sleep(a.interval); continue
|
||||
try:
|
||||
target = Target(pid)
|
||||
emit(f"watch: attached pid={pid} cardsdll={target.base:#x} "
|
||||
f"mgr={target.mgr:#x}")
|
||||
last = None
|
||||
except (OSError, RuntimeError) as e:
|
||||
# not logged in yet, or the process died mid-resolve
|
||||
if a.once:
|
||||
emit(f"watch: not ready: {e}"); return 1
|
||||
target = None
|
||||
time.sleep(a.interval); continue
|
||||
try:
|
||||
snap = target.snapshot()
|
||||
except (OSError, struct.error) as e:
|
||||
emit(f"watch: detached ({e})")
|
||||
target = None
|
||||
if a.once:
|
||||
return 1
|
||||
continue
|
||||
key = fmt(snap)
|
||||
if key != last:
|
||||
emit(key)
|
||||
last = key
|
||||
if a.once:
|
||||
return 0
|
||||
time.sleep(a.interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+252
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Is the squad manager REGISTERED (not merely parsed) in a live FIFA17 client?
|
||||
|
||||
READ-ONLY. Opens /proc/<pid>/mem for reading and scans. Writes nothing, sends
|
||||
no input to the game, and never opens 'r+b'.
|
||||
|
||||
manager_coldproof.py [pid] [--manager-wire N] [--manager-resource N]
|
||||
[--control WIRE:RESOURCE ...]
|
||||
|
||||
Defaults describe the staging profile used to close the manager milestone; pass
|
||||
the flags for any other profile.
|
||||
|
||||
WHAT THIS DECIDES
|
||||
-----------------
|
||||
FIFA17's squad parser (FUN_18013d1f0) reaches the item parser FUN_18013fe00 by
|
||||
two different routes:
|
||||
|
||||
players : atom 568 -> per-element atoms 355 index / 363 itemData /
|
||||
378 kitNumber; the 363 arm at 0x18013d8d9 calls the item parser
|
||||
on the NESTED itemData object.
|
||||
manager : atom 424 -> array loop at 0x18013da29 calls that same item parser
|
||||
DIRECTLY on the array ELEMENT, into squad+0xC0. No itemData step.
|
||||
|
||||
So `squad.manager[]` elements must be BARE ITEM OBJECTS. When they were served
|
||||
as {id, itemData:{...}, dream} the parser read only the two keys that happen to
|
||||
be item atoms -- id and dream -- and left resourceId at 0. resourceId is the
|
||||
merge key, compared RAW against carddbid (fut_staff.py::manager_item, +0x18),
|
||||
so 0 resolves no manager: no name, no rating, no art, empty slot. Fixed in
|
||||
OpenFUT b91e707; see Vault "FIFA 17/Squad Manager Wire Shape.md".
|
||||
|
||||
CONTROLS
|
||||
--------
|
||||
manager wire id the instance id. Present even when BROKEN, because `id` is
|
||||
an item atom the parser reads at element level. Its
|
||||
presence proves the element was parsed and therefore proves
|
||||
nothing about registration -- do not use it as the verdict.
|
||||
manager resourceId THE VERDICT. Resident => the merge key survived the load.
|
||||
player wire id and positive controls. Players demonstrably render, so if their
|
||||
player resourceId resourceIds are absent the squad simply is not loaded yet
|
||||
and the run is INCONCLUSIVE, not a failure.
|
||||
|
||||
RESIDENT-MANAGER HIT
|
||||
--------------------
|
||||
A 4-byte-aligned little-endian i32 equal to the manager resourceId, anywhere in
|
||||
a readable private mapping. Corroborate with the record context printed below:
|
||||
a real item record carries resourceId eight words ahead of its wire id, which
|
||||
is the layout the player controls exhibit. Hits without that shape are usually
|
||||
id lists or unrelated integers -- the layout, not the raw count, is the proof.
|
||||
|
||||
LAYOUT ASSUMPTION (the only one)
|
||||
--------------------------------
|
||||
Item records place resourceId 0x20 bytes before the wire id. Measured, both
|
||||
sides:
|
||||
|
||||
before b91e707 (pid 126936) -- manager parsed, merge key absent
|
||||
player @0xb85dbf48: 83906881 1 0 0 0 0 0 0 | 100002878 0 | 7
|
||||
player @0xb85dbd68: 84053575 1 0 0 0 0 0 0 | 100003237 0 | 7
|
||||
manager @0xb85dc1b8: 0 0 0 0 0 0 0 0 | 100004870 0 | 7
|
||||
|
||||
after b91e707 (pid 134118) -- same layout, key present
|
||||
player @0xb8740fd8: 84053575 1 0 0 0 0 0 0 | 100003237 0 | 7 0
|
||||
player @0xb87411b8: 83906881 1 0 0 0 0 0 0 | 100002878 0 | 7 0
|
||||
manager @0xb8741428: 1000509 2 0 0 0 0 0 0 | 100004870 0 | 7 0
|
||||
|
||||
Addresses shift every session and are recorded only as provenance; nothing here
|
||||
depends on them. The tool re-derives everything by scanning.
|
||||
|
||||
EXIT CODES (fail-closed)
|
||||
------------------------
|
||||
0 PASS manager resourceId resident, controls present
|
||||
1 FAIL controls present, manager resourceId absent
|
||||
2 NO PROCESS no FIFA17.exe, or /proc/<pid>/mem unreadable
|
||||
3 INCONCLUSIVE controls absent -- squad not loaded yet; re-run at the
|
||||
squad screen. Deliberately NOT 0: absent controls mean the
|
||||
probe proved nothing.
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
# Staging profile defaults (override on the command line).
|
||||
DEF_MANAGER_WIRE = 100004870
|
||||
DEF_MANAGER_RESOURCE = 1000509
|
||||
DEF_CONTROLS = [(100002878, 83906881), (100003237, 84053575)]
|
||||
|
||||
# Item record layout: resourceId sits this far BEFORE the wire id.
|
||||
RESOURCE_BACK_OFF = 0x20
|
||||
|
||||
|
||||
def find_pid():
|
||||
"""The Wine process whose comm is FIFA17.exe (same rule as memtool.py)."""
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
with open(os.path.join(d, "comm")) as fh:
|
||||
if fh.read().strip() == "FIFA17.exe":
|
||||
return int(os.path.basename(d))
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def regions(pid):
|
||||
"""Readable private mappings worth scanning.
|
||||
|
||||
Skips device/memfd mappings and anything over 512 MiB (the big reserved
|
||||
ranges are not where parsed records live and dominate the runtime).
|
||||
"""
|
||||
out = []
|
||||
with open(f"/proc/{pid}/maps") as fh:
|
||||
for line in fh:
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", line)
|
||||
if not m:
|
||||
continue
|
||||
lo, hi = int(m.group(1), 16), int(m.group(2), 16)
|
||||
perms, path = m.group(3), m.group(4)
|
||||
if perms[0] != "r" or path.startswith(("/dev", "/memfd")):
|
||||
continue
|
||||
if hi - lo > 512 * 1024 * 1024:
|
||||
continue
|
||||
out.append((lo, hi))
|
||||
return out
|
||||
|
||||
|
||||
def scan(pid, needles, ctx_before=0x40, ctx_after=0x40):
|
||||
"""4-byte-aligned little-endian i32 search; keeps a window around each hit."""
|
||||
found = {n: [] for n in needles}
|
||||
pats = {n: struct.pack("<i", n) for n in needles}
|
||||
with open(f"/proc/{pid}/mem", "rb", 0) as mem:
|
||||
for lo, hi in regions(pid):
|
||||
try:
|
||||
mem.seek(lo)
|
||||
buf = mem.read(hi - lo)
|
||||
except (OSError, ValueError, OverflowError):
|
||||
continue # torn-down or unreadable mapping; not a failure
|
||||
for n, pat in pats.items():
|
||||
i = buf.find(pat)
|
||||
while i >= 0:
|
||||
if i % 4 == 0:
|
||||
found[n].append(
|
||||
(lo + i, buf[max(0, i - ctx_before): i + ctx_after], min(i, ctx_before))
|
||||
)
|
||||
i = buf.find(pat, i + 4)
|
||||
return found
|
||||
|
||||
|
||||
def words(blob, centre, before=8, after=4):
|
||||
cells = []
|
||||
for k in range(-before, after):
|
||||
o = centre + k * 4
|
||||
if 0 <= o <= len(blob) - 4:
|
||||
cells.append(str(struct.unpack_from("<i", blob, o)[0]))
|
||||
return " ".join(cells)
|
||||
|
||||
|
||||
def record_shaped(blob, centre, resource):
|
||||
"""True when resourceId sits RESOURCE_BACK_OFF before the id -- the real
|
||||
item-record layout, as opposed to an incidental integer match."""
|
||||
o = centre - RESOURCE_BACK_OFF
|
||||
if o < 0 or o > len(blob) - 4:
|
||||
return False
|
||||
return struct.unpack_from("<i", blob, o)[0] == resource
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="read-only manager registration probe")
|
||||
ap.add_argument("pid", nargs="?", type=int, help="FIFA17 pid (default: auto)")
|
||||
ap.add_argument("--manager-wire", type=int, default=DEF_MANAGER_WIRE)
|
||||
ap.add_argument("--manager-resource", type=int, default=DEF_MANAGER_RESOURCE)
|
||||
ap.add_argument(
|
||||
"--control",
|
||||
action="append",
|
||||
metavar="WIRE:RESOURCE",
|
||||
help="player positive control; repeatable (default: the staging pair)",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
controls = DEF_CONTROLS
|
||||
if args.control:
|
||||
try:
|
||||
controls = [tuple(int(x) for x in c.split(":", 1)) for c in args.control]
|
||||
except ValueError:
|
||||
print(" --control must be WIRE:RESOURCE", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
pid = args.pid or find_pid()
|
||||
if not pid:
|
||||
print(" NO FIFA17 PROCESS (comm == FIFA17.exe) -- is the client running?")
|
||||
return 2
|
||||
if not os.access(f"/proc/{pid}/mem", os.R_OK):
|
||||
print(f" /proc/{pid}/mem is not readable -- wrong user, or the process exited")
|
||||
return 2
|
||||
print(f" pid={pid}")
|
||||
|
||||
needles = [args.manager_wire, args.manager_resource]
|
||||
for w, r in controls:
|
||||
needles += [w, r]
|
||||
try:
|
||||
res = scan(pid, sorted(set(needles)))
|
||||
except OSError as e:
|
||||
print(f" cannot read /proc/{pid}/mem: {e}")
|
||||
return 2
|
||||
|
||||
print("\n ===== hit counts =====")
|
||||
print(f" {'manager wire (parsed?)':32} {args.manager_wire:<12} hits={len(res[args.manager_wire])}")
|
||||
print(f" {'manager resourceId (VERDICT)':32} {args.manager_resource:<12} "
|
||||
f"hits={len(res[args.manager_resource])}")
|
||||
for w, r in controls:
|
||||
print(f" {'player wire (control)':32} {w:<12} hits={len(res[w])}")
|
||||
print(f" {'player resourceId (control)':32} {r:<12} hits={len(res[r])}")
|
||||
|
||||
print("\n ===== record context (8 words before the id, then the id) =====")
|
||||
shaped = {"manager": 0}
|
||||
for tag, wire, resource in (
|
||||
[("manager", args.manager_wire, args.manager_resource)]
|
||||
+ [(f"player{i}", w, r) for i, (w, r) in enumerate(controls)]
|
||||
):
|
||||
marked = 0
|
||||
for addr, blob, centre in res[wire]:
|
||||
ok = record_shaped(blob, centre, resource)
|
||||
if ok:
|
||||
marked += 1
|
||||
if marked <= 2 or ok:
|
||||
print(f" {tag:8} @0x{addr:x}{' <- item-record layout' if ok else ''}: "
|
||||
f"{words(blob, centre)}")
|
||||
if marked >= 2:
|
||||
break
|
||||
shaped[tag] = marked
|
||||
|
||||
ctl_keys = sum(len(res[r]) for _w, r in controls)
|
||||
mgr_keys = len(res[args.manager_resource])
|
||||
|
||||
print("\n ===== verdict =====")
|
||||
if ctl_keys == 0:
|
||||
print(" INCONCLUSIVE: no player resourceId control is resident, so the squad")
|
||||
print(" is not loaded. Reach the squad screen and re-run. (Nothing proven.)")
|
||||
return 3
|
||||
if mgr_keys == 0:
|
||||
print(f" FAIL: manager resourceId {args.manager_resource} is absent while "
|
||||
f"{ctl_keys} player")
|
||||
print(" resourceId control hit(s) are resident -> PARSED_BUT_NOT_REGISTERED.")
|
||||
return 1
|
||||
print(f" PASS: manager resourceId {args.manager_resource} is resident "
|
||||
f"({mgr_keys} hits, {shaped['manager']} in item-record layout).")
|
||||
print(" The merge key survived the load; the broken projection had 0.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17 provider dispatch and ACTION_ADVANCE delivery boundaries.
|
||||
|
||||
This probe correlates the global UI dispatch of FUT_CREATE_MATCH_DP and
|
||||
FUT_GET_MATCH_KITS_DP, the subscribed CardsDLL provider, the internal 0x7546
|
||||
create-response callback that can replay FUT_CREATE_MATCH_DP, and the final
|
||||
native-to-UI bridge. At global dispatch, r8d is the provider ID and rdx is the
|
||||
payload; neither register is a screen key.
|
||||
|
||||
The generated GDB program uses hardware-assisted execution breakpoints only.
|
||||
It never writes client memory and never drives game input.
|
||||
|
||||
match_advance_trace.py [pid] [--output PATH]
|
||||
match_advance_trace.py --print-script [pid]
|
||||
match_advance_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_transition_trace as transition
|
||||
|
||||
FIFA_MODULE = "FIFA17.exe"
|
||||
PINNED_FIFA_SHA256 = "29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899"
|
||||
GLOBAL_UI_DISPATCH_RVA = 0x80D1070
|
||||
CREATE_MATCH_CONTROLLER_RVA = 0xBF950
|
||||
PROVIDER_BRIDGE_CALL_RVA = 0x1A4D41
|
||||
|
||||
|
||||
def module_mapping(pid: int, module: str) -> tuple[int, str]:
|
||||
with open(f"/proc/{pid}/maps", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
fields = line.split(maxsplit=5)
|
||||
path = fields[5].rstrip() if len(fields) == 6 else ""
|
||||
if not path.endswith(module):
|
||||
continue
|
||||
return int(fields[0].split("-", 1)[0], 16), path
|
||||
raise RuntimeError(f"{module} is not mapped in PID {pid}")
|
||||
|
||||
|
||||
def validate_file(path: str, expected: str, label: str) -> None:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
actual = digest.hexdigest()
|
||||
if actual != expected:
|
||||
raise RuntimeError(f"unsupported {label}: sha256={actual}; expected={expected}")
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"provider": cards_base + transition.PROVIDER_DISPATCH_RVA,
|
||||
"global_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
|
||||
"controller": cards_base + CREATE_MATCH_CONTROLLER_RVA,
|
||||
"bridge": cards_base + PROVIDER_BRIDGE_CALL_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(pid: int, cards_base: int, fifa_base: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base, fifa_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
|
||||
hbreak *0x{address['provider']:x}
|
||||
condition 1 $edx == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edx == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d PROVIDER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x payload=%p controller=%p caller=%p\\n", $_thread, $edx, $r8, $rcx, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['global_dispatch']:x}
|
||||
condition 2 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d GLOBAL_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x payload=%p manager=%p caller=%p\\n", $_thread, $r8d, $rdx, $rcx, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['controller']:x}
|
||||
condition 3 $edx == 0x7546
|
||||
commands
|
||||
silent
|
||||
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d CREATE_MATCH_CONTROLLER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d event=%#x controller=%p caller=%p\\n", $_thread, $edx, $rcx, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['bridge']:x}
|
||||
condition 4 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d PROVIDER_BRIDGE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x target=%p bridge=%p callback=%p\\n", $_thread, $edi, $rsi, $rbx, *(void**)(*(void**)$rbx+0x48)
|
||||
continue
|
||||
end
|
||||
|
||||
printf "ADVTRACE ARMED pid={pid} provider=0x{address['provider']:x} global=0x{address['global_dispatch']:x} controller=0x{address['controller']:x} bridge=0x{address['bridge']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000, 0x140000000)
|
||||
assert address == {
|
||||
"provider": 0x1801A4CD0,
|
||||
"global_dispatch": 0x1480D1070,
|
||||
"controller": 0x1800BF950,
|
||||
"bridge": 0x1801A4D41,
|
||||
}
|
||||
script = build_gdb_script(28804, 0x180000000, 0x140000000, "/tmp/advance.log")
|
||||
assert script.count("hbreak *") == 4
|
||||
assert f"$edx == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$edx == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert "CREATE_MATCH_CONTROLLER" in script
|
||||
assert "PROVIDER_BRIDGE" in script
|
||||
assert "set *(" not in script
|
||||
print("match_advance_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
fifa_base, fifa_path = module_mapping(pid, FIFA_MODULE)
|
||||
validate_file(fifa_path, PINNED_FIFA_SHA256, FIFA_MODULE)
|
||||
output = args.output or f"/tmp/fifa17-match-advance-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, fifa_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-advance-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace the FIFA17 ACTION_CREATE_MATCH-to-provider lifecycle.
|
||||
|
||||
The probe correlates:
|
||||
|
||||
* the select-team action handler for UIF action IDs 0x7574..0x757b;
|
||||
* DataManager's request dispatch for FutCreateMatchServerResponse (0x7546);
|
||||
* the concrete FutCreateMatchServerResponse data-source request method;
|
||||
* FIFA's global UI dispatch of providers 0x7563 and 0x7565.
|
||||
|
||||
Static decoding identifies action 0x7577 as the branch that constructs the
|
||||
create-match request and calls DataManager for source 0x7546. The trace proves
|
||||
whether that authentic trigger executes in the failing flow. It uses four
|
||||
hardware-assisted execution breakpoints, never writes client memory, and never
|
||||
drives game input.
|
||||
|
||||
match_create_action_trace.py [pid] [--output PATH]
|
||||
match_create_action_trace.py --print-script [pid]
|
||||
match_create_action_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
SELECT_TEAM_ACTION_HANDLER_RVA = 0x0BFCC0
|
||||
DATA_MANAGER_REQUEST_RVA = 0x80D2340
|
||||
DATA_SOURCE_REQUEST_RVA = 0x120270
|
||||
GLOBAL_UI_DISPATCH_RVA = advance.GLOBAL_UI_DISPATCH_RVA
|
||||
FIRST_SELECT_TEAM_ACTION = 0x7574
|
||||
LAST_SELECT_TEAM_ACTION = 0x757B
|
||||
ACTION_CREATE_MATCH = 0x7577
|
||||
CREATE_DATA_SOURCE = 0x7546
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"action_handler": cards_base + SELECT_TEAM_ACTION_HANDLER_RVA,
|
||||
"manager_request": fifa_base + DATA_MANAGER_REQUEST_RVA,
|
||||
"data_source_request": cards_base + DATA_SOURCE_REQUEST_RVA,
|
||||
"ui_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(
|
||||
pid: int, cards_base: int, fifa_base: int, output: str
|
||||
) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base, fifa_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
set $create_action_seen = 0
|
||||
set $manager_request_seen = 0
|
||||
set $data_source_request_seen = 0
|
||||
|
||||
hbreak *0x{address['action_handler']:x}
|
||||
condition 1 $edx >= 0x{FIRST_SELECT_TEAM_ACTION:x} && $edx <= 0x{LAST_SELECT_TEAM_ACTION:x}
|
||||
commands
|
||||
silent
|
||||
if $edx == 0x{ACTION_CREATE_MATCH:x}
|
||||
set $create_action_seen = 1
|
||||
end
|
||||
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d SELECT_TEAM_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d action=%#x is_create=%d controller=%p payload=%p create_seen=%d\\n", $_thread, $edx, $edx==0x{ACTION_CREATE_MATCH:x}, $rcx, $r8, $create_action_seen
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['manager_request']:x}
|
||||
condition 2 $edx == 0x{CREATE_DATA_SOURCE:x}
|
||||
commands
|
||||
silent
|
||||
set $manager_request_seen = 1
|
||||
set $tree_sentinel = $rcx + 0x10
|
||||
set $tree_cursor = *(void**)($rcx+0x20)
|
||||
set $data_node = $tree_sentinel
|
||||
while $tree_cursor != 0 && $tree_cursor != $tree_sentinel
|
||||
if *(unsigned int*)($tree_cursor+0x20) >= 0x{CREATE_DATA_SOURCE:x}
|
||||
set $data_node = $tree_cursor
|
||||
set $tree_cursor = *(void**)($tree_cursor+0x08)
|
||||
else
|
||||
set $tree_cursor = *(void**)$tree_cursor
|
||||
end
|
||||
end
|
||||
set $data_source = 0
|
||||
set $request_method = 0
|
||||
if $data_node != $tree_sentinel && *(unsigned int*)($data_node+0x20) == 0x{CREATE_DATA_SOURCE:x}
|
||||
set $data_source = *(void**)($data_node+0x28)
|
||||
if $data_source != 0
|
||||
set $request_method = *(void**)(*(void**)$data_source+0x18)
|
||||
end
|
||||
end
|
||||
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d MANAGER_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d source=%#x manager=%p request=%p node=%p data_source=%p request_method=%p create_seen=%d\\n", $_thread, $edx, $rcx, $r8, $data_node, $data_source, $request_method, $create_action_seen
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['data_source_request']:x}
|
||||
commands
|
||||
silent
|
||||
set $data_source_request_seen = 1
|
||||
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d DATA_SOURCE_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d response=%p data_source=%p request=%p ready_before=%#x create_seen=%d manager_seen=%d\\n", $_thread, $rcx-0x50, $rcx, $rdx, *(unsigned char*)($rcx+0x38), $create_action_seen, $manager_request_seen
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['ui_dispatch']:x}
|
||||
condition 4 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d UI_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x payload=%p ui_manager=%p create_seen=%d manager_seen=%d data_source_seen=%d\\n", $_thread, $r8d, $rdx, $rcx, $create_action_seen, $manager_request_seen, $data_source_request_seen
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
printf "ACTIONTRACE ARMED pid={pid} action_handler=0x{address['action_handler']:x} manager_request=0x{address['manager_request']:x} data_source_request=0x{address['data_source_request']:x} ui_dispatch=0x{address['ui_dispatch']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000, 0x140000000)
|
||||
assert address == {
|
||||
"action_handler": 0x1800BFCC0,
|
||||
"manager_request": 0x1480D2340,
|
||||
"data_source_request": 0x180120270,
|
||||
"ui_dispatch": 0x1480D1070,
|
||||
}
|
||||
script = build_gdb_script(
|
||||
45949, 0x180000000, 0x140000000, "/tmp/create-action.log"
|
||||
)
|
||||
assert script.count("hbreak *") == 4
|
||||
assert f"$edx == 0x{ACTION_CREATE_MATCH:x}" in script
|
||||
assert f"$edx == 0x{CREATE_DATA_SOURCE:x}" in script
|
||||
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert "request_method" in script
|
||||
assert "set *(" not in script
|
||||
print("match_create_action_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
|
||||
output = args.output or f"/tmp/fifa17-match-create-action-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, fifa_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-create-action-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17 provider delivery lookup without heap-address assumptions.
|
||||
|
||||
The probe anchors the real CardsDLL call sequence in FUN_1801a4cd0 and the
|
||||
provider-specific FUT_CREATE_MATCH_DP readiness check in FUN_1800be500:
|
||||
|
||||
vslot +0x38 call -> create gate return -> returned target -> UI bridge
|
||||
|
||||
For FUT_CREATE_MATCH_DP and FUT_GET_MATCH_KITS_DP it records the live controller
|
||||
vtable, concrete lookup function, event service, readiness-gate implementation,
|
||||
every register input, returned target, and whether the native-to-UI bridge
|
||||
executes. No post-event object identity is used.
|
||||
|
||||
The generated GDB program uses hardware-assisted execution breakpoints only.
|
||||
It never writes client memory and never drives game input.
|
||||
|
||||
match_delivery_lifecycle_trace.py [pid] [--output PATH]
|
||||
match_delivery_lifecycle_trace.py --print-script [pid]
|
||||
match_delivery_lifecycle_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
LOOKUP_CALL_RVA = transition.PROVIDER_DISPATCH_RVA + 0x2C
|
||||
LOOKUP_RETURN_RVA = transition.PROVIDER_DISPATCH_RVA + 0x2F
|
||||
BRIDGE_CALL_RVA = transition.PROVIDER_DISPATCH_RVA + 0x71
|
||||
CREATE_GATE_RETURN_RVA = 0x0BE647
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"lookup_call": cards_base + LOOKUP_CALL_RVA,
|
||||
"gate_return": cards_base + CREATE_GATE_RETURN_RVA,
|
||||
"lookup_return": cards_base + LOOKUP_RETURN_RVA,
|
||||
"bridge": cards_base + BRIDGE_CALL_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(pid: int, cards_base: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
set $current_provider = 0
|
||||
set $current_payload = 0
|
||||
set $current_controller = 0
|
||||
set $current_vtable = 0
|
||||
set $current_lookup = 0
|
||||
set $current_service = 0
|
||||
set $current_service_vtable = 0
|
||||
set $current_gate = 0
|
||||
|
||||
hbreak *0x{address['lookup_call']:x}
|
||||
condition 1 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
set $current_provider = $edi
|
||||
set $current_payload = $rbp
|
||||
set $current_controller = $rcx
|
||||
set $current_vtable = *(void**)$rcx
|
||||
set $current_lookup = *(void**)(*(void**)$rcx+0x38)
|
||||
set $current_service = *(void**)($rcx+0x18)
|
||||
set $current_service_vtable = *(void**)$current_service
|
||||
set $current_gate = *(void**)($current_service_vtable+0x58)
|
||||
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d LOOKUP_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x this=%p outer_controller=%p payload=%p vtable=%p lookup_fn=%p service=%p service_vtable=%p gate_fn=%p controller_mode=%#x controller_flag=%#x rdx=%p r8=%p r9=%p state_rbx=%p state_rbp=%p\\n", $_thread, $edi, $rcx, $rbx, $rbp, $current_vtable, $current_lookup, $current_service, $current_service_vtable, $current_gate, *(unsigned int*)($rcx+0x140), *(unsigned char*)($rcx+0x152), $rdx, $r8, $r9, $rbx, $rbp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['gate_return']:x}
|
||||
condition 2 $current_provider == 0x{transition.FUT_CREATE_MATCH_DP:x} && $rbx == $current_controller
|
||||
commands
|
||||
silent
|
||||
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d CREATE_GATE_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x controller=%p service=%p service_vtable=%p gate_fn=%p selector=0x7546 result_al=%#x\\n", $_thread, $current_provider, $current_controller, $current_service, $current_service_vtable, $current_gate, $al
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['lookup_return']:x}
|
||||
condition 3 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d LOOKUP_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x controller=%p payload=%p vtable=%p lookup_fn=%p result=%p\\n", $_thread, $edi, $rbx, $rbp, $current_vtable, $current_lookup, $rax
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['bridge']:x}
|
||||
condition 4 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d BRIDGE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x controller=%p payload=%p target=%p bridge=%p callback=%p\\n", $_thread, $edi, $current_controller, $current_payload, $rsi, $rbx, *(void**)(*(void**)$rbx+0x48)
|
||||
continue
|
||||
end
|
||||
|
||||
printf "LOOKUPTRACE ARMED pid={pid} lookup_call=0x{address['lookup_call']:x} gate_return=0x{address['gate_return']:x} lookup_return=0x{address['lookup_return']:x} bridge=0x{address['bridge']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000)
|
||||
assert address == {
|
||||
"lookup_call": 0x1801A4CFC,
|
||||
"gate_return": 0x1800BE647,
|
||||
"lookup_return": 0x1801A4CFF,
|
||||
"bridge": 0x1801A4D41,
|
||||
}
|
||||
script = build_gdb_script(35632, 0x180000000, "/tmp/lookup.log")
|
||||
assert script.count("hbreak *") == 4
|
||||
assert f"$edi == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert "LOOKUP_CALL" in script
|
||||
assert "CREATE_GATE_RETURN" in script
|
||||
assert "LOOKUP_RETURN" in script
|
||||
assert "gate_fn" in script
|
||||
assert "BRIDGE" in script
|
||||
assert "set *(" not in script
|
||||
print("match_delivery_lifecycle_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
_fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
|
||||
output = args.output or f"/tmp/fifa17-match-provider-lookup-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-provider-lookup-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17's post-kit handoff into the gameplay loading state.
|
||||
|
||||
The probe anchors the second ACTION_SAVE_MATCH_KIT (0x7576), captures the
|
||||
select-team deleting destructor with its real caller, records entry to the
|
||||
Gameplay::ScenarioModeStart consumer with the state it would advance, and
|
||||
identifies the first TestingGame update after the boundary.
|
||||
|
||||
The generated GDB program uses four hardware-assisted execution breakpoints.
|
||||
It never writes client memory, calls client functions, drives input, emits
|
||||
actions, or changes timing deliberately.
|
||||
|
||||
match_drill_transition_trace.py [pid] [--output PATH]
|
||||
match_drill_transition_trace.py --print-script [pid]
|
||||
match_drill_transition_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
SAVE_KIT_ACTION = 0x7576
|
||||
SAVE_ACTION_RVA = 0x0BFCC0
|
||||
SELECT_TEAM_DELETING_DESTRUCTOR_RVA = 0x0BE020
|
||||
TESTING_GAME_UPDATE_RVA = 0x05A410C8
|
||||
SCENARIO_MODE_START_HANDLER_RVA = 0x05A58EC0
|
||||
TESTING_GAME_VTABLE_RVA = 0x035C58A8
|
||||
TESTING_GAME_STATE_VTABLE_RVA = 0x035C2EE0
|
||||
|
||||
OWNER_STATE_OFFSET = 0x1958
|
||||
STATE_GAME_DATABASE_OFFSET = 0x17450
|
||||
STATE_PHASE_OFFSET = 0x27BEC
|
||||
STATE_SCENARIO_MODE_START_GATE_OFFSET = 0x359E8
|
||||
DATABASE_IS_SKILL_GAME_OFFSET = 0x7382
|
||||
DATABASE_TEAM_PAIR_OFFSET = 0x73C4
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"save_action": cards_base + SAVE_ACTION_RVA,
|
||||
"deleting_destructor": cards_base + SELECT_TEAM_DELETING_DESTRUCTOR_RVA,
|
||||
"testing_game_update": fifa_base + TESTING_GAME_UPDATE_RVA,
|
||||
"scenario_mode_start_handler": fifa_base + SCENARIO_MODE_START_HANDLER_RVA,
|
||||
"testing_game_vtable": fifa_base + TESTING_GAME_VTABLE_RVA,
|
||||
"testing_game_state_vtable": fifa_base + TESTING_GAME_STATE_VTABLE_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(
|
||||
pid: int,
|
||||
cards_base: int,
|
||||
fifa_base: int,
|
||||
output: str,
|
||||
) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base, fifa_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
set $save_count = 0
|
||||
set $current_controller = 0
|
||||
set $engine_seen = 0
|
||||
|
||||
hbreak *0x{address['save_action']:x}
|
||||
commands
|
||||
silent
|
||||
if $edx == 0x{SAVE_KIT_ACTION:x}
|
||||
set $save_count = $save_count + 1
|
||||
set $current_controller = $rcx
|
||||
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SAVE_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d ordinal=%d action=%#x controller=%p payload=%p second_boundary=%d\\n", $_thread, $save_count, $edx, $rcx, $r8, $save_count==2
|
||||
if $save_count == 2
|
||||
disable 1
|
||||
end
|
||||
end
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['deleting_destructor']:x}
|
||||
condition 2 $save_count >= 2 && $rcx == $current_controller
|
||||
commands
|
||||
silent
|
||||
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SELECT_TEAM_DELETING_DESTRUCTOR" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d controller=%p delete_flags=%#x caller_return=%p vtable=%p\\n", $_thread, $rcx, $edx, *(void**)$rsp, *(void**)$rcx
|
||||
x/16gx $rsp
|
||||
bt 12
|
||||
disable 2
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['scenario_mode_start_handler']:x}
|
||||
commands
|
||||
silent
|
||||
set $scenario_wrapper = $rcx
|
||||
set $scenario_state = *(void**)($scenario_wrapper+0x30)
|
||||
set $scenario_payload = $r9
|
||||
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SCENARIO_MODE_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
if $scenario_state != 0
|
||||
set $scenario_database = *(void**)($scenario_state+0x{STATE_GAME_DATABASE_OFFSET:x})
|
||||
if $scenario_database != 0
|
||||
printf "thread=%d wrapper=%p state=%p payload=%p phase=%d alternate_gate=%d is_skill_game=%d caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_state, $scenario_payload, *(unsigned int*)($scenario_state+0x{STATE_PHASE_OFFSET:x}), *(unsigned char*)($scenario_state+0x{STATE_SCENARIO_MODE_START_GATE_OFFSET:x}), *(unsigned char*)($scenario_database+0x{DATABASE_IS_SKILL_GAME_OFFSET:x}), *(void**)$rsp
|
||||
else
|
||||
printf "thread=%d wrapper=%p state=%p payload=%p database=0 caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_state, $scenario_payload, *(void**)$rsp
|
||||
end
|
||||
else
|
||||
printf "thread=%d wrapper=%p state=0 payload=%p caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_payload, *(void**)$rsp
|
||||
end
|
||||
bt 12
|
||||
disable 3
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['testing_game_update']:x}
|
||||
condition 4 $save_count >= 2 && $engine_seen == 0
|
||||
commands
|
||||
silent
|
||||
set $owner = $rsi
|
||||
set $state = *(void**)($owner+0x{OWNER_STATE_OFFSET:x})
|
||||
if $state != 0 && *(void**)$owner == 0x{address['testing_game_vtable']:x} && *(void**)$state == 0x{address['testing_game_state_vtable']:x}
|
||||
set $database = *(void**)($state+0x{STATE_GAME_DATABASE_OFFSET:x})
|
||||
if $database != 0
|
||||
set $engine_seen = 1
|
||||
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d ENGINE_HANDOFF" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d owner=%p owner_vtable=%p state=%p state_vtable=%p database=%p phase=%d is_skill_game=%d teams=%d,%d\\n", $_thread, $owner, *(void**)$owner, $state, *(void**)$state, $database, *(unsigned int*)($state+0x{STATE_PHASE_OFFSET:x}), *(unsigned char*)($database+0x{DATABASE_IS_SKILL_GAME_OFFSET:x}), *(unsigned int*)($database+0x{DATABASE_TEAM_PAIR_OFFSET:x}), *(unsigned int*)($database+0x{DATABASE_TEAM_PAIR_OFFSET + 4:x})
|
||||
bt 12
|
||||
disable 4
|
||||
end
|
||||
end
|
||||
continue
|
||||
end
|
||||
|
||||
printf "DRILLTRACE ARMED pid={pid} save_action=0x{address['save_action']:x} deleting_destructor=0x{address['deleting_destructor']:x} scenario_mode_start_handler=0x{address['scenario_mode_start_handler']:x} testing_game_update=0x{address['testing_game_update']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000, 0x140000000)
|
||||
assert address == {
|
||||
"save_action": 0x1800BFCC0,
|
||||
"deleting_destructor": 0x1800BE020,
|
||||
"testing_game_update": 0x145A410C8,
|
||||
"scenario_mode_start_handler": 0x145A58EC0,
|
||||
"testing_game_vtable": 0x1435C58A8,
|
||||
"testing_game_state_vtable": 0x1435C2EE0,
|
||||
}
|
||||
script = build_gdb_script(
|
||||
49938,
|
||||
0x180000000,
|
||||
0x140000000,
|
||||
"/tmp/drill-transition.log",
|
||||
)
|
||||
assert script.count("hbreak *") == 4
|
||||
assert "SELECT_TEAM_DELETING_DESTRUCTOR" in script
|
||||
assert "SCENARIO_MODE_START" in script
|
||||
assert "wrapper=%p state=%p payload=%p" in script
|
||||
assert "alternate_gate=%d" in script
|
||||
assert "skill_game_start_constructor" not in script
|
||||
assert "ENGINE_HANDOFF" in script
|
||||
assert "GameplayGameDatabase.IsSkillGame" not in script
|
||||
assert "set *(" not in script
|
||||
print("match_drill_transition_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(
|
||||
fifa_path,
|
||||
advance.PINNED_FIFA_SHA256,
|
||||
advance.FIFA_MODULE,
|
||||
)
|
||||
output = args.output or f"/tmp/fifa17-match-drill-transition-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, fifa_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-drill-transition-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+185
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17's post-kit boundary without changing client behavior.
|
||||
|
||||
The probe anchors both ACTION_SAVE_MATCH_KIT (0x7576) actions, their concrete
|
||||
native save call, the action-handler return, and select-team provider teardown.
|
||||
The second 0x7576 action is the temporal boundary for later drill/game-loader
|
||||
instrumentation.
|
||||
|
||||
The generated GDB program uses four hardware-assisted execution breakpoints. It
|
||||
never writes client memory, calls client functions, drives input, emits actions,
|
||||
or alters timing deliberately.
|
||||
|
||||
match_post_kit_trace.py [pid] [--output PATH]
|
||||
match_post_kit_trace.py --print-script [pid]
|
||||
match_post_kit_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
SAVE_KIT_ACTION = 0x7576
|
||||
ACTION_HANDLER_RVA = 0x0BFCC0
|
||||
SAVE_CALL_RVA = 0x0BFF25
|
||||
ACTION_RETURN_RVA = 0x0C00AE
|
||||
SELECT_TEAM_DESTRUCTOR_RVA = 0x0BDEC0
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"action": cards_base + ACTION_HANDLER_RVA,
|
||||
"save_call": cards_base + SAVE_CALL_RVA,
|
||||
"action_return": cards_base + ACTION_RETURN_RVA,
|
||||
"destructor": cards_base + SELECT_TEAM_DESTRUCTOR_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(pid: int, cards_base: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
set $save_count = 0
|
||||
set $current_action = 0
|
||||
set $current_controller = 0
|
||||
set $current_payload = 0
|
||||
set $second_save_epoch = 0
|
||||
|
||||
hbreak *0x{address['action']:x}
|
||||
condition 1 $edx == 0x{SAVE_KIT_ACTION:x}
|
||||
commands
|
||||
silent
|
||||
set $save_count = $save_count + 1
|
||||
set $current_action = $edx
|
||||
set $current_controller = $rcx
|
||||
set $current_payload = $r8
|
||||
python import time, gdb; now = time.time_ns(); gdb.set_convenience_variable("event_epoch", now); print("POSTKIT epoch_ns=%d mono_ns=%d SAVE_ACTION" % (now, time.monotonic_ns()), end=" ")
|
||||
if $save_count == 2
|
||||
set $second_save_epoch = $event_epoch
|
||||
end
|
||||
printf "thread=%d ordinal=%d action=%#x controller=%p payload=%p payload_vtable=%p mode=%#x flags_150=%#x flags_151=%#x flags_152=%#x flags_155=%#x second_boundary=%d\\n", $_thread, $save_count, $edx, $rcx, $r8, *(void**)$r8, *(unsigned int*)($rcx+0x140), *(unsigned char*)($rcx+0x150), *(unsigned char*)($rcx+0x151), *(unsigned char*)($rcx+0x152), *(unsigned char*)($rcx+0x155), $save_count==2
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['save_call']:x}
|
||||
condition 2 $current_action == 0x{SAVE_KIT_ACTION:x}
|
||||
commands
|
||||
silent
|
||||
set $save_target = *(void**)(*(void**)$rcx+0x1d0)
|
||||
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d NATIVE_SAVE_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d ordinal=%d central=%p central_vtable=%p target=%p side=%#x request=%p payload=%p\\n", $_thread, $save_count, $rcx, *(void**)$rcx, $save_target, $r8d, $rdx, $current_payload
|
||||
x/12gx $rdx
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['action_return']:x}
|
||||
condition 3 $current_action == 0x{SAVE_KIT_ACTION:x} && $rsi == $current_controller
|
||||
commands
|
||||
silent
|
||||
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d ACTION_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d ordinal=%d controller=%p handled=%#x mode=%#x flags_150=%#x flags_151=%#x flags_152=%#x flags_155=%#x\\n", $_thread, $save_count, $rsi, $al, *(unsigned int*)($rsi+0x140), *(unsigned char*)($rsi+0x150), *(unsigned char*)($rsi+0x151), *(unsigned char*)($rsi+0x152), *(unsigned char*)($rsi+0x155)
|
||||
set $current_action = 0
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['destructor']:x}
|
||||
condition 4 $save_count >= 2 && $rcx == $current_controller
|
||||
commands
|
||||
silent
|
||||
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d SELECT_TEAM_DESTRUCTOR" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d controller=%p save_count=%d second_save_epoch=%lld vtable=%p mode=%#x\\n", $_thread, $rcx, $save_count, $second_save_epoch, *(void**)$rcx, *(unsigned int*)($rcx+0x140)
|
||||
bt 12
|
||||
continue
|
||||
end
|
||||
|
||||
printf "POSTKIT ARMED pid={pid} action=0x{address['action']:x} save_call=0x{address['save_call']:x} action_return=0x{address['action_return']:x} destructor=0x{address['destructor']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000)
|
||||
assert address == {
|
||||
"action": 0x1800BFCC0,
|
||||
"save_call": 0x1800BFF25,
|
||||
"action_return": 0x1800C00AE,
|
||||
"destructor": 0x1800BDEC0,
|
||||
}
|
||||
script = build_gdb_script(47872, 0x180000000, "/tmp/post-kit.log")
|
||||
assert script.count("hbreak *") == 4
|
||||
assert f"$edx == 0x{SAVE_KIT_ACTION:x}" in script
|
||||
assert "second_boundary" in script
|
||||
assert "NATIVE_SAVE_CALL" in script
|
||||
assert "SELECT_TEAM_DESTRUCTOR" in script
|
||||
assert "set *(" not in script
|
||||
print("match_post_kit_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
_fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
|
||||
output = args.output or f"/tmp/fifa17-match-post-kit-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-post-kit-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17 create-response readiness versus UI provider dispatch.
|
||||
|
||||
The probe correlates four concrete lifecycle boundaries:
|
||||
|
||||
* FutCreateMatchServerResponse data-source request;
|
||||
* the POST /match network response callback;
|
||||
* the response readiness/completion callback;
|
||||
* FIFA's global UI dispatch of providers 0x7563 and 0x7565.
|
||||
|
||||
This distinguishes network completion from the separate DataManager readiness
|
||||
lifecycle without assuming any screen or heap-object identity. The generated GDB
|
||||
program uses hardware-assisted execution breakpoints only. It never writes
|
||||
client memory and never drives game input.
|
||||
|
||||
match_provider_producer_trace.py [pid] [--output PATH]
|
||||
match_provider_producer_trace.py --print-script [pid]
|
||||
match_provider_producer_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
DATA_SOURCE_REQUEST_RVA = 0x120270
|
||||
NETWORK_RESPONSE_RVA = transition.RESPONSE_CALLBACK_RVA
|
||||
CREATE_COMPLETE_RVA = 0x120000
|
||||
GLOBAL_UI_DISPATCH_RVA = advance.GLOBAL_UI_DISPATCH_RVA
|
||||
CREATE_RESPONSE_OFFSET = 0xA0
|
||||
CREATE_DATA_SOURCE_OFFSET = CREATE_RESPONSE_OFFSET + 0x50
|
||||
CREATE_READY_OFFSET = CREATE_RESPONSE_OFFSET + 0x88
|
||||
ACTIVE_CALLBACK_OFFSET = 0x47D0
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"data_source_request": cards_base + DATA_SOURCE_REQUEST_RVA,
|
||||
"network_response": cards_base + NETWORK_RESPONSE_RVA,
|
||||
"create_complete": cards_base + CREATE_COMPLETE_RVA,
|
||||
"ui_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(
|
||||
pid: int, cards_base: int, fifa_base: int, output: str
|
||||
) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base, fifa_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
set $last_central = 0
|
||||
set $last_response = 0
|
||||
set $last_data_source = 0
|
||||
set $last_descriptor = 0
|
||||
|
||||
hbreak *0x{address['data_source_request']:x}
|
||||
commands
|
||||
silent
|
||||
set $request_data_source = $rcx
|
||||
set $request_response = $rcx - 0x50
|
||||
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d DATA_SOURCE_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d response=%p data_source=%p request=%p ready_before=%#x callback_adapter=%p callback_context=%p callback_target=%p\\n", $_thread, $request_response, $request_data_source, $rdx, *(unsigned char*)($request_data_source+0x38), *(void**)($request_response+0x90), *(void**)($request_response+0x98), *(void**)($request_response+0xa0)
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['network_response']:x}
|
||||
commands
|
||||
silent
|
||||
set $last_central = $rcx
|
||||
set $last_response = $rcx + 0x{CREATE_RESPONSE_OFFSET:x}
|
||||
set $last_data_source = $rcx + 0x{CREATE_DATA_SOURCE_OFFSET:x}
|
||||
set $last_descriptor = $rdx
|
||||
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d NETWORK_RESPONSE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
if $rdx == 0
|
||||
printf "thread=%d central=%p descriptor=(nil) status=UNKNOWN wire_payload=(nil) response=%p data_source=%p ready=%#x active_adapter=%p active_context=%p active_target=%p\\n", $_thread, $last_central, $last_response, $last_data_source, *(unsigned char*)($last_central+0x{CREATE_READY_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 8:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 16:x})
|
||||
else
|
||||
printf "thread=%d central=%p descriptor=%p status=%#x wire_payload=%p response=%p data_source=%p ready=%#x active_adapter=%p active_context=%p active_target=%p\\n", $_thread, $last_central, $rdx, *(unsigned int*)($rdx+0x1c), *(void**)($rdx+0x28), $last_response, $last_data_source, *(unsigned char*)($last_central+0x{CREATE_READY_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 8:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 16:x})
|
||||
end
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['create_complete']:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d CREATE_COMPLETE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
if $rdx == 0
|
||||
printf "thread=%d response=%p data_source=%p ready_before=%#x descriptor=(nil) status=UNKNOWN last_response=%p same_response=%d\\n", $_thread, $rcx, $rcx+0x50, *(unsigned char*)($rcx+0x88), $last_response, $rcx==$last_response
|
||||
else
|
||||
printf "thread=%d response=%p data_source=%p ready_before=%#x descriptor=%p status=%#x last_response=%p same_response=%d\\n", $_thread, $rcx, $rcx+0x50, *(unsigned char*)($rcx+0x88), $rdx, *(unsigned int*)($rdx+0x1c), $last_response, $rcx==$last_response
|
||||
end
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['ui_dispatch']:x}
|
||||
condition 4 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d UI_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
if $last_response == 0
|
||||
printf "thread=%d provider=%#x payload=%p ui_manager=%p last_response=(nil) ready=UNKNOWN\\n", $_thread, $r8d, $rdx, $rcx
|
||||
else
|
||||
printf "thread=%d provider=%#x payload=%p ui_manager=%p last_response=%p data_source=%p ready=%#x descriptor=%p\\n", $_thread, $r8d, $rdx, $rcx, $last_response, $last_data_source, *(unsigned char*)($last_response+0x88), $last_descriptor
|
||||
end
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
printf "RESPTRACE ARMED pid={pid} data_source_request=0x{address['data_source_request']:x} network_response=0x{address['network_response']:x} create_complete=0x{address['create_complete']:x} ui_dispatch=0x{address['ui_dispatch']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000, 0x140000000)
|
||||
assert address == {
|
||||
"data_source_request": 0x180120270,
|
||||
"network_response": 0x180114D90,
|
||||
"create_complete": 0x180120000,
|
||||
"ui_dispatch": 0x1480D1070,
|
||||
}
|
||||
script = build_gdb_script(
|
||||
38872, 0x180000000, 0x140000000, "/tmp/response-lifecycle.log"
|
||||
)
|
||||
assert script.count("hbreak *") == 4
|
||||
assert "DATA_SOURCE_REQUEST" in script
|
||||
assert "NETWORK_RESPONSE" in script
|
||||
assert "CREATE_COMPLETE" in script
|
||||
assert "UI_DISPATCH" in script
|
||||
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert "set *(" not in script
|
||||
print("match_provider_producer_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
|
||||
output = args.output or f"/tmp/fifa17-match-response-lifecycle-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, fifa_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-response-lifecycle-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+233
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace the FIFA17 create-match publish boundary with hardware breakpoints.
|
||||
|
||||
The tracer covers the client-local path after POST /match:
|
||||
|
||||
response callback -> deserializer -> controller event 0x7546
|
||||
-> FUT_CREATE_MATCH_DP 0x7563
|
||||
|
||||
FUT_GET_MATCH_KITS_DP 0x7565 is captured as the positive control through the
|
||||
same native dispatcher. The generated GDB program uses only hardware-assisted
|
||||
execution breakpoints. It never writes client memory and never drives game
|
||||
input.
|
||||
|
||||
match_transition_trace.py [pid] [--output PATH]
|
||||
match_transition_trace.py --print-script [pid]
|
||||
match_transition_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
CARDS_MODULE = "CardsDLL_Win64_retail.dll"
|
||||
PINNED_CARDS_SHA256 = "4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c"
|
||||
RESPONSE_CALLBACK_RVA = 0x114D90
|
||||
DESERIALIZE_SUCCESS_RVA = 0x118940
|
||||
CREATE_MATCH_CONTROLLER_RVA = 0xBF950
|
||||
PROVIDER_DISPATCH_RVA = 0x1A4CD0
|
||||
CREATE_MATCH_CONTROLLER_EVENT = 0x7546
|
||||
FUT_CREATE_MATCH_DP = 0x7563
|
||||
FUT_GET_MATCH_KITS_DP = 0x7565
|
||||
|
||||
|
||||
def find_pid() -> int | None:
|
||||
found = []
|
||||
for directory in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
with open(os.path.join(directory, "comm"), encoding="utf-8") as handle:
|
||||
if handle.read().strip() != "FIFA17.exe":
|
||||
continue
|
||||
pid = int(os.path.basename(directory))
|
||||
with open(os.path.join(directory, "statm"), encoding="utf-8") as handle:
|
||||
resident_pages = int(handle.read().split()[1])
|
||||
found.append((resident_pages, pid))
|
||||
except (OSError, ValueError, IndexError):
|
||||
continue
|
||||
return max(found)[1] if found else None
|
||||
|
||||
|
||||
def parse_cards_mapping(lines) -> tuple[int, str]:
|
||||
for line in lines:
|
||||
fields = line.split(maxsplit=5)
|
||||
path = fields[5].rstrip() if len(fields) == 6 else ""
|
||||
if not path.endswith(CARDS_MODULE):
|
||||
continue
|
||||
start = int(fields[0].split("-", 1)[0], 16)
|
||||
return start, path
|
||||
raise RuntimeError(f"{CARDS_MODULE} is not mapped")
|
||||
|
||||
|
||||
def cards_mapping(pid: int) -> tuple[int, str]:
|
||||
with open(f"/proc/{pid}/maps", encoding="utf-8") as handle:
|
||||
try:
|
||||
return parse_cards_mapping(handle)
|
||||
except RuntimeError as error:
|
||||
raise RuntimeError(f"{error} in PID {pid}") from error
|
||||
|
||||
|
||||
def sha256_file(path: str) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def validate_cards(path: str) -> None:
|
||||
actual = sha256_file(path)
|
||||
if actual != PINNED_CARDS_SHA256:
|
||||
raise RuntimeError(
|
||||
f"unsupported {CARDS_MODULE}: sha256={actual}; expected={PINNED_CARDS_SHA256}"
|
||||
)
|
||||
|
||||
|
||||
def trace_addresses(base: int) -> dict[str, int]:
|
||||
return {
|
||||
"response": base + RESPONSE_CALLBACK_RVA,
|
||||
"deserialize": base + DESERIALIZE_SUCCESS_RVA,
|
||||
"controller": base + CREATE_MATCH_CONTROLLER_RVA,
|
||||
"provider": base + PROVIDER_DISPATCH_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(pid: int, base: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
|
||||
hbreak *0x{address['response']:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T3_RESPONSE_CALLBACK" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
if $rdx != 0
|
||||
printf "thread=%d manager=%p status_obj=%p status=%u wire_payload=%p caller=%p\\n", $_thread, $rcx, $rdx, *(unsigned int*)($rdx+0x1c), *(void**)($rdx+0x28), *(void**)$rsp
|
||||
else
|
||||
printf "thread=%d manager=%p status_obj=0 caller=%p\\n", $_thread, $rcx, *(void**)$rsp
|
||||
end
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['deserialize']:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T4_DESERIALIZE_SUCCESS" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d manager=%p payload=%p caller=%p\\n", $_thread, $rcx, $rdx, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['controller']:x}
|
||||
condition 3 $edx == 0x{CREATE_MATCH_CONTROLLER_EVENT:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T5_CREATE_MATCH_CONTROLLER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d controller_subobject=%p event=%#x caller=%p\\n", $_thread, $rcx, $edx, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['provider']:x}
|
||||
condition 4 $edx == 0x{FUT_CREATE_MATCH_DP:x} || $edx == 0x{FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T6_PROVIDER_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d controller=%p provider=%#x payload=%p callback=%p\\n", $_thread, $rcx, $edx, $r8, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
printf "HWTRACE ARMED pid={pid} response=0x{address['response']:x} deserialize=0x{address['deserialize']:x} controller=0x{address['controller']:x} provider=0x{address['provider']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
base = 0x180000000
|
||||
address = trace_addresses(base)
|
||||
assert address == {
|
||||
"response": 0x180114D90,
|
||||
"deserialize": 0x180118940,
|
||||
"controller": 0x1800BF950,
|
||||
"provider": 0x1801A4CD0,
|
||||
}
|
||||
mapping = parse_cards_mapping(
|
||||
[
|
||||
"6ffffc0f0000-6ffffc0f1000 r--p 00000000 00:37 2941670 "
|
||||
"/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll\n"
|
||||
]
|
||||
)
|
||||
assert mapping == (
|
||||
0x6FFFFC0F0000,
|
||||
"/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll",
|
||||
)
|
||||
script = build_gdb_script(25718, base, "/tmp/match-transition.log")
|
||||
assert script.count("hbreak *") == 4
|
||||
assert f"$edx == 0x{CREATE_MATCH_CONTROLLER_EVENT:x}" in script
|
||||
assert f"$edx == 0x{FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$edx == 0x{FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert "T3_RESPONSE_CALLBACK" in script
|
||||
assert "T4_DESERIALIZE_SUCCESS" in script
|
||||
assert "T5_CREATE_MATCH_CONTROLLER" in script
|
||||
assert "T6_PROVIDER_DISPATCH" in script
|
||||
assert "set *(" not in script
|
||||
print("match_transition_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
base, cards_path = cards_mapping(pid)
|
||||
validate_cards(cards_path)
|
||||
output = args.output or f"/tmp/fifa17-match-transition-{pid}.log"
|
||||
script = build_gdb_script(pid, base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-transition-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+306
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only dynamic locator for FIFA17 Offline Seasons match state.
|
||||
|
||||
Never relies on heap addresses or allocator handles. It identifies:
|
||||
|
||||
* the 10 x 16-byte parsed fixture array from its complete wire-derived record
|
||||
sequence (teamId/difficulty/roundId/rewardMult/coins),
|
||||
* match-team records from the corrected invariant prefix (11,7,0,0,76), never
|
||||
from the transient +0x18 handle,
|
||||
* the match-config team pair from structural fields around it, not its team ids.
|
||||
|
||||
offline_match_locator.py [pid] [--fixture-index 0] [--json]
|
||||
offline_match_locator.py --selftest
|
||||
|
||||
READ-ONLY: /proc/<pid>/mem is opened 'rb'. No debugger and no game input.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
DEFAULT_TEAMS = (73, 240, 241, 243, 73, 240, 241, 243, 73, 240)
|
||||
MATCH_HEADER = struct.pack("<5i", 11, 7, 0, 0, 76)
|
||||
PARTICIPANT_PREFIX = struct.pack("<8i", -1, -2, -1, -2, -1, -2, -1, -2)
|
||||
F01 = 0x3DCCCCCD
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fixture:
|
||||
address: int
|
||||
selected_address: int
|
||||
selected_index: int
|
||||
selected_team_id: int
|
||||
records: list[dict[str, int]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchTeam:
|
||||
address: int
|
||||
team_id: int
|
||||
marker_18: int
|
||||
marker_1c: int
|
||||
xi: list[int]
|
||||
substitutes: list[int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchConfig:
|
||||
pair_address: int
|
||||
team_id_0: int
|
||||
team_id_1: int
|
||||
player_count_0: int
|
||||
player_count_1: int
|
||||
|
||||
|
||||
def find_pids() -> list[int]:
|
||||
"""All live FIFA17.exe processes, largest resident set first.
|
||||
|
||||
The UMU/Proton launch chain briefly creates a small process with the same
|
||||
comm before the real game. Returning the first /proc glob match attached
|
||||
the trace supervisor to that short-lived process and missed the match.
|
||||
"""
|
||||
found = []
|
||||
for directory in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
with open(os.path.join(directory, "comm")) as handle:
|
||||
if handle.read().strip() != "FIFA17.exe":
|
||||
continue
|
||||
pid = int(os.path.basename(directory))
|
||||
with open(os.path.join(directory, "statm")) as handle:
|
||||
resident_pages = int(handle.read().split()[1])
|
||||
found.append((resident_pages, pid))
|
||||
except (OSError, ValueError, IndexError):
|
||||
continue
|
||||
return [pid for _resident, pid in sorted(found, reverse=True)]
|
||||
|
||||
|
||||
def find_pid() -> int | None:
|
||||
pids = find_pids()
|
||||
return pids[0] if pids else None
|
||||
|
||||
|
||||
def fixture_bytes(teams: tuple[int, ...] = DEFAULT_TEAMS) -> bytes:
|
||||
return b"".join(
|
||||
struct.pack("<iBBHii", team_id, 1, round_id, 0, 1, 400)
|
||||
for round_id, team_id in enumerate(teams)
|
||||
)
|
||||
|
||||
|
||||
def readable_regions(pid: int, *, writable_anon_only: bool = False):
|
||||
with open(f"/proc/{pid}/maps") as maps:
|
||||
for line in maps:
|
||||
match = re.match(
|
||||
r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)",
|
||||
line,
|
||||
)
|
||||
if not match:
|
||||
continue
|
||||
lo, hi = int(match.group(1), 16), int(match.group(2), 16)
|
||||
perms, path = match.group(3), match.group(4).strip()
|
||||
if perms[0] != "r" or path.startswith(("/dev", "/memfd")):
|
||||
continue
|
||||
if hi - lo > 512 * 1024 * 1024:
|
||||
continue
|
||||
if writable_anon_only and (perms[1] != "w" or path):
|
||||
continue
|
||||
yield lo, hi, perms, path
|
||||
|
||||
|
||||
def _i32(buf: bytes, offset: int) -> int:
|
||||
return struct.unpack_from("<i", buf, offset)[0]
|
||||
|
||||
|
||||
def scan_fixture_buffer(buf: bytes, base: int, selected_index: int) -> list[Fixture]:
|
||||
pattern = fixture_bytes()
|
||||
found = []
|
||||
offset = buf.find(pattern)
|
||||
while offset >= 0:
|
||||
records = []
|
||||
for round_id in range(len(DEFAULT_TEAMS)):
|
||||
at = offset + round_id * 16
|
||||
team_id, difficulty, parsed_round, _pad, reward_mult, coins = struct.unpack_from(
|
||||
"<iBBHii", buf, at
|
||||
)
|
||||
records.append(
|
||||
{
|
||||
"team_id": team_id,
|
||||
"difficulty": difficulty,
|
||||
"round_id": parsed_round,
|
||||
"reward_mult": reward_mult,
|
||||
"coins": coins,
|
||||
}
|
||||
)
|
||||
found.append(
|
||||
Fixture(
|
||||
address=base + offset,
|
||||
selected_address=base + offset + selected_index * 16,
|
||||
selected_index=selected_index,
|
||||
selected_team_id=records[selected_index]["team_id"],
|
||||
records=records,
|
||||
)
|
||||
)
|
||||
offset = buf.find(pattern, offset + 4)
|
||||
return found
|
||||
|
||||
|
||||
def scan_match_team_buffer(buf: bytes, base: int) -> list[MatchTeam]:
|
||||
found = []
|
||||
offset = buf.find(MATCH_HEADER)
|
||||
while offset >= 0:
|
||||
if offset + 0x7C <= len(buf):
|
||||
found.append(
|
||||
MatchTeam(
|
||||
address=base + offset,
|
||||
team_id=_i32(buf, offset + 0x14),
|
||||
marker_18=_i32(buf, offset + 0x18),
|
||||
marker_1c=_i32(buf, offset + 0x1C),
|
||||
xi=list(struct.unpack_from("<11i", buf, offset + 0x20)),
|
||||
substitutes=list(struct.unpack_from("<12i", buf, offset + 0x4C)),
|
||||
)
|
||||
)
|
||||
offset = buf.find(MATCH_HEADER, offset + 4)
|
||||
return found
|
||||
|
||||
|
||||
def _valid_config(buf: bytes, pair: int) -> bool:
|
||||
required = pair + 0x50
|
||||
if pair < 0 or required > len(buf):
|
||||
return False
|
||||
return (
|
||||
tuple(struct.unpack_from("<4I", buf, pair + 0x1C)) == (F01, F01, F01, F01)
|
||||
and _i32(buf, pair + 0x38) == 11
|
||||
and _i32(buf, pair + 0x3C) == 11
|
||||
and _i32(buf, pair + 0x40) == 0
|
||||
and _i32(buf, pair + 0x44) == 5
|
||||
)
|
||||
|
||||
|
||||
def scan_match_config_buffer(buf: bytes, base: int) -> list[MatchConfig]:
|
||||
found = []
|
||||
offset = buf.find(PARTICIPANT_PREFIX)
|
||||
while offset >= 0:
|
||||
pair = offset + len(PARTICIPANT_PREFIX)
|
||||
if _valid_config(buf, pair):
|
||||
found.append(
|
||||
MatchConfig(
|
||||
pair_address=base + pair,
|
||||
team_id_0=_i32(buf, pair),
|
||||
team_id_1=_i32(buf, pair + 4),
|
||||
player_count_0=_i32(buf, pair + 0x38),
|
||||
player_count_1=_i32(buf, pair + 0x3C),
|
||||
)
|
||||
)
|
||||
offset = buf.find(PARTICIPANT_PREFIX, offset + 4)
|
||||
return found
|
||||
|
||||
|
||||
def scan_process(
|
||||
pid: int,
|
||||
selected_index: int,
|
||||
*,
|
||||
include_fixture: bool = True,
|
||||
writable_anon_only: bool = False,
|
||||
) -> dict[str, list]:
|
||||
result: dict[str, list] = {"fixtures": [], "match_teams": [], "match_configs": []}
|
||||
with open(f"/proc/{pid}/mem", "rb", 0) as memory:
|
||||
for lo, hi, _perms, _path in readable_regions(
|
||||
pid, writable_anon_only=writable_anon_only
|
||||
):
|
||||
try:
|
||||
memory.seek(lo)
|
||||
buf = memory.read(hi - lo)
|
||||
except (OSError, ValueError, OverflowError):
|
||||
continue
|
||||
if include_fixture:
|
||||
result["fixtures"].extend(scan_fixture_buffer(buf, lo, selected_index))
|
||||
result["match_teams"].extend(scan_match_team_buffer(buf, lo))
|
||||
result["match_configs"].extend(scan_match_config_buffer(buf, lo))
|
||||
return result
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
fixture = fixture_bytes()
|
||||
team = bytearray(0x7C)
|
||||
team[:20] = MATCH_HEADER
|
||||
struct.pack_into("<iii", team, 0x14, 130000, 0x54001, 0x54002)
|
||||
struct.pack_into("<11i", team, 0x20, *range(11))
|
||||
struct.pack_into("<12i", team, 0x4C, *range(20, 32))
|
||||
config = bytearray(0x20 + 0x50)
|
||||
config[:0x20] = PARTICIPANT_PREFIX
|
||||
pair = 0x20
|
||||
struct.pack_into("<ii", config, pair, 130000, 130000)
|
||||
struct.pack_into("<4I", config, pair + 0x1C, F01, F01, F01, F01)
|
||||
struct.pack_into("<iiii", config, pair + 0x38, 11, 11, 0, 5)
|
||||
buf = b"X" * 32 + fixture + b"Y" * 32 + team + b"Z" * 32 + config
|
||||
fixtures = scan_fixture_buffer(buf, 0x1000, 0)
|
||||
teams = scan_match_team_buffer(buf, 0x1000)
|
||||
configs = scan_match_config_buffer(buf, 0x1000)
|
||||
assert len(fixtures) == 1 and fixtures[0].selected_team_id == 73
|
||||
assert len(teams) == 1 and teams[0].team_id == 130000
|
||||
assert len(configs) == 1 and configs[0].team_id_1 == 130000
|
||||
# The transient handle is never part of the anchor.
|
||||
struct.pack_into("<i", team, 0x18, -1)
|
||||
assert len(scan_match_team_buffer(bytes(team), 0)) == 1
|
||||
print("offline_match_locator selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--fixture-index", type=int, default=0)
|
||||
parser.add_argument("--json", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
parser.add_argument("--writable-anon-only", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
pid = args.pid or find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
if not 0 <= args.fixture_index < len(DEFAULT_TEAMS):
|
||||
print("--fixture-index must be 0..9", file=sys.stderr)
|
||||
return 2
|
||||
result = scan_process(
|
||||
pid,
|
||||
args.fixture_index,
|
||||
writable_anon_only=args.writable_anon_only,
|
||||
)
|
||||
serial = {key: [asdict(value) for value in values] for key, values in result.items()}
|
||||
serial["pid"] = pid
|
||||
if args.json:
|
||||
print(json.dumps(serial, sort_keys=True))
|
||||
return 0
|
||||
print(f"pid={pid}")
|
||||
for fixture in result["fixtures"]:
|
||||
print(
|
||||
f"fixture @0x{fixture.address:x}; selected index {fixture.selected_index} "
|
||||
f"@0x{fixture.selected_address:x} teamId={fixture.selected_team_id}"
|
||||
)
|
||||
for config in result["match_configs"]:
|
||||
print(
|
||||
f"match config pair @0x{config.pair_address:x}: "
|
||||
f"[{config.team_id_0}, {config.team_id_1}]"
|
||||
)
|
||||
for team in result["match_teams"]:
|
||||
print(
|
||||
f"match team @0x{team.address:x}: teamId={team.team_id} "
|
||||
f"handles=[{team.marker_18}, {team.marker_1c}]"
|
||||
)
|
||||
print(
|
||||
f"counts: fixtures={len(result['fixtures'])} "
|
||||
f"configs={len(result['match_configs'])} teams={len(result['match_teams'])}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+394
@@ -0,0 +1,394 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17's PMA ScenarioModeStart-to-event-5 producer chain.
|
||||
|
||||
The generated GDB program uses hardware breakpoints, only reads registers and
|
||||
client memory, logs, and continues. Breakpoints are rotated so no more than four
|
||||
are enabled. It never calls client functions, writes client memory, emits an
|
||||
event, or drives input.
|
||||
|
||||
pma_producer_trace.py [pid] [--variant mode0|alternate] [--output PATH]
|
||||
pma_producer_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
VARIANTS = {
|
||||
"mode0": {
|
||||
"scenario_rva": 0x07B1C190,
|
||||
"writer_rva": 0x07B1C26B,
|
||||
"register_rva": 0x07B1C282,
|
||||
"writer_context": "$rsi",
|
||||
"writer_async_requested": "1",
|
||||
"arm_condition": "1",
|
||||
},
|
||||
"alternate": {
|
||||
"scenario_rva": 0x07B1C050,
|
||||
"writer_rva": 0x07B1C12F,
|
||||
"register_rva": 0x07B1C146,
|
||||
"writer_context": "$rbp",
|
||||
"writer_async_requested": "$sil",
|
||||
"arm_condition": "$tracked_ctx != 0 && $rcx == $tracked_ctx",
|
||||
},
|
||||
}
|
||||
PMA_COMPLETION_ARM_RVA = 0x07B1AE60
|
||||
PMA_COMPLETION_ARM_WRITER_RVA = 0x07B1AF33
|
||||
ASYNC_COMPLETION_RVA = 0x07B046C0
|
||||
CALLBACK_DISPATCHER_RVA = 0x07AC87B0
|
||||
PMA_INSTRUCTIONS_HANDLER_RVA = 0x07AC91E0
|
||||
GAMEPLAY_GLOBAL_RVA = 0x04BFB910
|
||||
PMA_INSTRUCTIONS_VTABLE_RVA = 0x03AF2750
|
||||
|
||||
|
||||
def addresses(base: int, variant: str) -> dict[str, int]:
|
||||
config = VARIANTS[variant]
|
||||
return {
|
||||
"scenario": base + config["scenario_rva"],
|
||||
"writer": base + config["writer_rva"],
|
||||
"register": base + config["register_rva"],
|
||||
"arm": base + PMA_COMPLETION_ARM_RVA,
|
||||
"arm_writer": base + PMA_COMPLETION_ARM_WRITER_RVA,
|
||||
"completion": base + ASYNC_COMPLETION_RVA,
|
||||
"dispatcher": base + CALLBACK_DISPATCHER_RVA,
|
||||
"instructions": base + PMA_INSTRUCTIONS_HANDLER_RVA,
|
||||
"gameplay_global": base + GAMEPLAY_GLOBAL_RVA,
|
||||
"instructions_vtable": base + PMA_INSTRUCTIONS_VTABLE_RVA,
|
||||
}
|
||||
|
||||
|
||||
def gdb_prelude(pid: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted off
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
"""
|
||||
|
||||
|
||||
def build_script(pid: int, fifa_base: int, output: str, variant: str) -> str:
|
||||
address = addresses(fifa_base, variant)
|
||||
config = VARIANTS[variant]
|
||||
return (
|
||||
gdb_prelude(pid, output)
|
||||
+ f"""define snapshot_pma_context
|
||||
set $snap_ctx = $arg0
|
||||
set $snap_flag40 = -1
|
||||
set $snap_callback_vtable = 0
|
||||
set $snap_callback_owner = 0
|
||||
set $snap_dispatcher = 0
|
||||
set $snap_dispatcher_vtable = 0
|
||||
set $snap_pma = 0
|
||||
set $snap_pma_flag18 = -1
|
||||
set $snap_pma_parent = 0
|
||||
set $snap_pma_machine = 0
|
||||
set $snap_pma_current = 0
|
||||
if $snap_ctx != 0
|
||||
set $snap_flag40 = *(unsigned char*)($snap_ctx+0x40)
|
||||
set $snap_callback_vtable = *(void**)($snap_ctx+0x48)
|
||||
set $snap_callback_owner = *(void**)($snap_ctx+0x78)
|
||||
set $snap_dispatcher = $snap_ctx+0x80
|
||||
set $snap_dispatcher_vtable = *(void**)$snap_dispatcher
|
||||
set $snap_sentinel = $snap_ctx+0x88
|
||||
set $snap_node = *(void**)$snap_sentinel
|
||||
set $snap_scan = 0
|
||||
while $snap_node != 0 && $snap_node != $snap_sentinel && $snap_scan < 8
|
||||
set $snap_candidate = *(void**)($snap_node+0x10)
|
||||
if $snap_candidate != 0
|
||||
if *(void**)$snap_candidate == 0x{address['instructions_vtable']:x}
|
||||
set $snap_pma = $snap_candidate
|
||||
end
|
||||
end
|
||||
set $snap_node = *(void**)$snap_node
|
||||
set $snap_scan = $snap_scan+1
|
||||
end
|
||||
if $snap_pma != 0
|
||||
set $snap_pma_flag18 = *(unsigned char*)($snap_pma+0x18)
|
||||
set $snap_pma_parent = *(void**)($snap_pma+0x8)
|
||||
if $snap_pma_parent != 0
|
||||
set $snap_pma_machine = *(void**)($snap_pma_parent+0x8)
|
||||
end
|
||||
if $snap_pma_machine != 0
|
||||
set $snap_pma_current = *(void**)($snap_pma_machine+0x10)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
define snapshot_gameplay
|
||||
set $snap_gameplay_global = *(void**)0x{address['gameplay_global']:x}
|
||||
set $snap_listener_manager = 0
|
||||
set $snap_listener_table = 0
|
||||
set $snap_listener_index = -1
|
||||
set $snap_free_roam = 0
|
||||
set $snap_free_roam_state = -1
|
||||
set $snap_free_roam_111 = -1
|
||||
set $snap_free_roam_112 = -1
|
||||
set $snap_free_roam_124 = -1
|
||||
set $snap_selected = 0
|
||||
set $snap_selected_vtable = 0
|
||||
set $snap_selected_mode = -1
|
||||
if $snap_gameplay_global != 0
|
||||
set $snap_listener_manager = *(void**)($snap_gameplay_global+0x58)
|
||||
end
|
||||
if $snap_listener_manager != 0
|
||||
set $snap_listener_table = *(void**)$snap_listener_manager
|
||||
end
|
||||
if $snap_listener_table != 0
|
||||
set $snap_free_roam = *(void**)$snap_listener_table
|
||||
set $snap_listener_index = *(int*)($snap_listener_table+0x20)
|
||||
if $snap_listener_index >= 0 && $snap_listener_index < 3
|
||||
set $snap_selected = *(void**)($snap_listener_table+$snap_listener_index*8)
|
||||
end
|
||||
end
|
||||
if $snap_free_roam != 0
|
||||
set $snap_free_roam_state = *(int*)($snap_free_roam+0x30)
|
||||
set $snap_free_roam_111 = *(unsigned char*)($snap_free_roam+0x111)
|
||||
set $snap_free_roam_112 = *(unsigned char*)($snap_free_roam+0x112)
|
||||
set $snap_free_roam_124 = *(int*)($snap_free_roam+0x124)
|
||||
end
|
||||
if $snap_selected != 0
|
||||
set $snap_selected_vtable = *(void**)$snap_selected
|
||||
set $snap_selected_mode = *(int*)($snap_selected+0x18)
|
||||
end
|
||||
end
|
||||
set $tracked_ctx = 0
|
||||
|
||||
|
||||
hbreak *0x{address['scenario']:x}
|
||||
commands
|
||||
silent
|
||||
set $ctx = $rcx
|
||||
set $tracked_ctx = $ctx
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d SCENARIO_MODE_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p arg_descriptor=%p arg_scenario=%p async_requested=%d flag40=%d callback_vtable=%p callback_owner=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_parent=%p pma_machine=%p pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $rdx, $r8, $r9b, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_parent, $snap_pma_machine, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 1
|
||||
enable 2
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['writer']:x}
|
||||
condition 2 $tracked_ctx != 0 && {config['writer_context']} == $tracked_ctx
|
||||
disable 2
|
||||
commands
|
||||
silent
|
||||
set $ctx = {config['writer_context']}
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d CONTEXT_ARM_WRITER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d instruction=%p caller_return=%p ctx=%p original_async_requested=%d flag40_before=%d callback_vtable=%p callback_owner_before=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, {config['writer_async_requested']}, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 2
|
||||
enable 3
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['register']:x}
|
||||
condition 3 $tracked_ctx != 0 && $rdx == $tracked_ctx+0x48
|
||||
disable 3
|
||||
commands
|
||||
silent
|
||||
set $callback = $rdx
|
||||
set $ctx = $callback-0x48
|
||||
snapshot_pma_context $ctx
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d ASYNC_REGISTER_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d callsite=%p caller_return=%p service=%p service_vtable=%p callback=%p callback_vtable=%p ctx=%p flag40=%d callback_owner=%p dispatcher=%p dispatcher_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $rcx, *(void**)$rcx, $callback, *(void**)$callback, $ctx, $snap_flag40, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable
|
||||
disable 3
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['arm']:x}
|
||||
condition 4 {config['arm_condition']}
|
||||
commands
|
||||
silent
|
||||
set $ctx = $rcx
|
||||
if $tracked_ctx == 0
|
||||
set $tracked_ctx = $ctx
|
||||
end
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d COMPLETION_ARM_ENTRY" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p flag40_before=%d callback_vtable=%p callback_owner=%p dispatcher=%p dispatcher_vtable=%p result_source=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, *(void**)($ctx+0xa8), $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 4
|
||||
enable 5
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['arm_writer']:x}
|
||||
condition 5 $tracked_ctx != 0 && $rsi == $tracked_ctx
|
||||
disable 5
|
||||
commands
|
||||
silent
|
||||
set $ctx = $rsi
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d COMPLETION_ARM_WRITER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d instruction=%p caller_return=%p ctx=%p flag40_before=%d result_object=%p result_state28=%d callback_owner=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, $snap_flag40, $rax, *(int*)($rax+0x28), $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 5
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['completion']:x}
|
||||
condition 6 $tracked_ctx != 0 && $rcx == $tracked_ctx+0x48
|
||||
commands
|
||||
silent
|
||||
set $callback = $rcx
|
||||
set $ctx = *(void**)($callback+0x30)
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d ASYNC_COMPLETION" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p callback=%p callback_vtable=%p ctx=%p callback_matches_ctx48=%d flag40_before=%d arg_rdx=%p arg_r8=%p arg_r9=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $callback, *(void**)$callback, $ctx, $callback == $ctx+0x48, $snap_flag40, $rdx, $r8, $r9, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['dispatcher']:x}
|
||||
condition 7 $tracked_ctx != 0 && $rcx == $tracked_ctx+0x80 && $edx == 5
|
||||
commands
|
||||
silent
|
||||
set $ctx = $rcx-0x80
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d DISPATCHER_EVENT_5" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p dispatcher=%p event=%d arg_r8=%p arg_r9=%p ctx=%p flag40=%d callback_owner=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $rcx, $edx, $r8, $r9, $ctx, $snap_flag40, $snap_callback_owner, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 7
|
||||
enable 8
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['instructions']:x}
|
||||
disable 8
|
||||
commands
|
||||
silent
|
||||
set $listener = $rcx
|
||||
set $parent = *(void**)($listener+0x8)
|
||||
set $machine = 0
|
||||
set $current = 0
|
||||
if $parent != 0
|
||||
set $machine = *(void**)($parent+0x8)
|
||||
end
|
||||
if $machine != 0
|
||||
set $current = *(void**)($machine+0x10)
|
||||
end
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d INSTRUCTIONS_AFTER_EVENT_5" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d handler=%p caller_return=%p listener=%p listener_vtable=%p event=%d flag18=%d parent=%p machine=%p current=%p current_vtable=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $listener, *(void**)$listener, $edx, *(unsigned char*)($listener+0x18), $parent, $machine, $current, $current ? *(void**)$current : 0, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 6
|
||||
continue
|
||||
end
|
||||
|
||||
printf "PMAPRODUCER ARMED pid={pid} variant={variant} scenario=0x{address['scenario']:x} writer=0x{address['writer']:x} register=0x{address['register']:x} arm=0x{address['arm']:x} arm_writer=0x{address['arm_writer']:x} completion=0x{address['completion']:x} dispatcher=0x{address['dispatcher']:x} instructions=0x{address['instructions']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def effective_environment(pid: int) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for item in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0"):
|
||||
if not item.startswith(b"OPENFUT_FIFA17_"):
|
||||
continue
|
||||
key, _, value = item.decode("utf-8", errors="replace").partition("=")
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
mode0 = addresses(0x140000000, "mode0")
|
||||
alternate = addresses(0x140000000, "alternate")
|
||||
script = build_script(1234, 0x140000000, "/tmp/pma-producer.log", "mode0")
|
||||
assert mode0["scenario"] == 0x147B1C190
|
||||
assert mode0["writer"] == 0x147B1C26B
|
||||
assert mode0["register"] == 0x147B1C282
|
||||
assert alternate["scenario"] == 0x147B1C050
|
||||
assert alternate["writer"] == 0x147B1C12F
|
||||
assert alternate["register"] == 0x147B1C146
|
||||
assert mode0["completion"] == 0x147B046C0
|
||||
assert mode0["dispatcher"] == 0x147AC87B0
|
||||
assert mode0["instructions"] == 0x147AC91E0
|
||||
assert mode0["arm"] == 0x147B1AE60
|
||||
assert mode0["arm_writer"] == 0x147B1AF33
|
||||
assert script.count("hbreak *") == 8
|
||||
assert "condition 7 $tracked_ctx != 0" in script
|
||||
assert "disable 2" in script and "enable 2" in script
|
||||
assert "disable 3" in script and "enable 3" in script
|
||||
assert "disable 5" in script and "enable 5" in script
|
||||
assert "disable 8" in script and "enable 8" in script
|
||||
assert "set *(" not in script
|
||||
print("pma_producer_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--variant", choices=tuple(VARIANTS), default="mode0")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(
|
||||
fifa_path,
|
||||
advance.PINNED_FIFA_SHA256,
|
||||
advance.FIFA_MODULE,
|
||||
)
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
output = args.output or f"/tmp/fifa17-pma-producer-{args.variant}-{pid}.log"
|
||||
script = build_script(pid, fifa_base, output, args.variant)
|
||||
environment = effective_environment(pid)
|
||||
print(
|
||||
"PMAPRODUCER PREPARED "
|
||||
f"pid={pid} variant={args.variant} fifa_base={fifa_base:#x} cards_base={cards_base:#x} "
|
||||
f"team_compat={environment.get('OPENFUT_FIFA17_SEASON_TEAM_COMPAT', '<absent>')} "
|
||||
f"pma_fix={environment.get('OPENFUT_FIFA17_OFFLINE_SEASONS_PMA_FIX', '<absent>')}"
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-pma-producer-{args.variant}-{pid}.gdb"
|
||||
Path(script_path).write_text(script, encoding="utf-8")
|
||||
import os
|
||||
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scan for FIFA17 match-team records by the invariant header prefix.
|
||||
|
||||
Anchors ONLY on (11,7,0,0,76) at +0x00..+0x10. Never filter on +0x18: it is a
|
||||
per-record marker whose value varies between sessions (-1 on 2026-08-24,
|
||||
344065/344064 on 2026-08-25), and filtering on it produced a false negative.
|
||||
|
||||
scan_mt.py [pid]
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
PAT = struct.pack("<5i", 11, 7, 0, 0, 76)
|
||||
|
||||
|
||||
def find_pid():
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||
return int(os.path.basename(d))
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
pid = int(sys.argv[1]) if len(sys.argv) > 1 else find_pid()
|
||||
if not pid:
|
||||
print(" no FIFA17.exe")
|
||||
raise SystemExit(2)
|
||||
|
||||
mem = open(f"/proc/{pid}/mem", "rb", 0)
|
||||
found = []
|
||||
for line in open(f"/proc/{pid}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", line)
|
||||
if not m or m.group(3)[0] != "r":
|
||||
continue
|
||||
lo, hi, path = int(m.group(1), 16), int(m.group(2), 16), m.group(4)
|
||||
if path.startswith(("/dev", "/memfd")) or hi - lo > 512 * 1024 * 1024:
|
||||
continue
|
||||
try:
|
||||
mem.seek(lo)
|
||||
buf = mem.read(hi - lo)
|
||||
except (OSError, ValueError, OverflowError):
|
||||
continue
|
||||
i = buf.find(PAT)
|
||||
while i >= 0:
|
||||
rec = buf[i:i + 0x80]
|
||||
if len(rec) >= 0x80:
|
||||
tid = struct.unpack_from("<i", rec, 0x14)[0]
|
||||
m18 = struct.unpack_from("<i", rec, 0x18)[0]
|
||||
m1c = struct.unpack_from("<i", rec, 0x1c)[0]
|
||||
xi = list(struct.unpack_from("<11i", rec, 0x20))
|
||||
subs = list(struct.unpack_from("<12i", rec, 0x4c))
|
||||
found.append((lo + i, tid, m18, m1c, xi, subs))
|
||||
i = buf.find(PAT, i + 4)
|
||||
|
||||
print(f" pid={pid} {len(found)} match-team record(s)")
|
||||
for addr, tid, m18, m1c, xi, subs in found:
|
||||
print(f"\n @0x{addr:x}")
|
||||
print(f" +0x14 teamId = {tid}")
|
||||
print(f" +0x18 marker = {m18} +0x1c marker = {m1c}")
|
||||
print(f" XI = {xi}")
|
||||
print(f" subs = {subs}")
|
||||
print(f"\n distinct teamIds: {sorted({t for _a, t, *_r in found})}")
|
||||
+1101
File diff suppressed because it is too large
Load Diff
+512
@@ -0,0 +1,512 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Supervise one hardware-only FIFA17 match-team writer capture.
|
||||
|
||||
This is the robust fresh-client entry point. It waits for the largest-RSS
|
||||
FIFA17.exe process that has CardsDLL loaded, attaches gdb before FUT navigation
|
||||
can construct match teams, and loads a hardware-only GDB Python payload.
|
||||
|
||||
The concurrent read-only structural locator proves when the fixture and final
|
||||
match-team records exist. A zero-hit result is trusted only if gdb is still
|
||||
alive, TracerPid is the gdb process, the payload reported `trace_armed`, no
|
||||
records pre-existed the trace, and two final records then appeared.
|
||||
|
||||
The default payload traces FUN_1800fc500 and derives a 4-byte teamId[1]
|
||||
watchpoint from live RDX. Other payloads trace the final engine writer or its
|
||||
caller; all expose the same `start_trace(log, cards_base)` entry point.
|
||||
|
||||
No INT3/software breakpoints. No client memory writes. /proc/<pid>/mem is opened
|
||||
'rb'. The operator alone drives the game.
|
||||
|
||||
trace_match_team_writer.py --status /tmp/mt-status.json \
|
||||
--trace /tmp/mt-trace.jsonl --gdb-log /tmp/mt-gdb.log --fixture-index 0
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
from offline_match_locator import find_pids, scan_process
|
||||
|
||||
CARDS_IMAGE_BASE = 0x180000000
|
||||
DEFAULT_TIMEOUT = 45 * 60
|
||||
|
||||
|
||||
def cards_base(pid: int) -> int | None:
|
||||
try:
|
||||
with open(f"/proc/{pid}/maps") as maps:
|
||||
for line in maps:
|
||||
if "CardsDLL_Win64_retail.dll" in line:
|
||||
return int(line.split("-", 1)[0], 16)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def tracer_pid(pid: int) -> int | None:
|
||||
try:
|
||||
with open(f"/proc/{pid}/status") as status:
|
||||
for line in status:
|
||||
if line.startswith("TracerPid:"):
|
||||
return int(line.split()[1])
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def target_state(pid: int) -> str | None:
|
||||
try:
|
||||
with open(f"/proc/{pid}/status") as status:
|
||||
for line in status:
|
||||
if line.startswith("State:"):
|
||||
return line.split()[1]
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def read_events(path: Path) -> list[dict]:
|
||||
if not path.exists():
|
||||
return []
|
||||
events = []
|
||||
try:
|
||||
with path.open(encoding="utf-8", errors="replace") as handle:
|
||||
for line in handle:
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except OSError:
|
||||
return []
|
||||
return events
|
||||
|
||||
|
||||
def event_counts(events: list[dict]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for event in events:
|
||||
kind = event.get("event", "unknown")
|
||||
counts[kind] = counts.get(kind, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
class Status:
|
||||
def __init__(self, path: Path, monitor_log: Path):
|
||||
self.path = path
|
||||
self.monitor_log = monitor_log
|
||||
self.data: dict = {"started_unix": time.time(), "state": "starting"}
|
||||
self.write()
|
||||
|
||||
def write(self, **updates):
|
||||
self.data.update(updates)
|
||||
self.data["updated_unix"] = time.time()
|
||||
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
temporary.write_text(json.dumps(self.data, indent=2, sort_keys=True) + "\n")
|
||||
os.replace(temporary, self.path)
|
||||
|
||||
def log(self, message: str, **payload):
|
||||
record = {"time_unix": time.time(), "message": message, **payload}
|
||||
with self.monitor_log.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(record, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
print(message, flush=True)
|
||||
|
||||
|
||||
def gdb_commands(pid: int, cards: int, payload: Path, trace: Path) -> str:
|
||||
# Wine uses these signals for thread suspension/runtime plumbing. They must
|
||||
# pass through, or batch gdb stops and silently detaches.
|
||||
signals = ["SIGUSR1", "SIGUSR2", "SIGPIPE", "SIGCHLD"] + [
|
||||
f"SIG{number}" for number in range(32, 40)
|
||||
]
|
||||
lines = [
|
||||
"set confirm off",
|
||||
"set pagination off",
|
||||
"set height 0",
|
||||
"set width 0",
|
||||
f"attach {pid}",
|
||||
]
|
||||
lines.extend(f"handle {name} nostop noprint pass" for name in signals)
|
||||
lines.extend(
|
||||
[
|
||||
f"source {payload}",
|
||||
f'python start_trace({json.dumps(str(trace))}, {cards})',
|
||||
"continue",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def serialise_locations(locations: dict) -> dict:
|
||||
return {key: [asdict(value) for value in values] for key, values in locations.items()}
|
||||
|
||||
|
||||
def terminate_gdb(process: subprocess.Popen, status: Status, pid: int):
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=12)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
deadline = time.time() + 8
|
||||
while time.time() < deadline and tracer_pid(pid):
|
||||
time.sleep(0.25)
|
||||
status.log(
|
||||
"gdb detached",
|
||||
gdb_returncode=process.returncode,
|
||||
tracer_pid=tracer_pid(pid),
|
||||
target_state=target_state(pid),
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--status", type=Path, required=True)
|
||||
parser.add_argument("--trace", type=Path, required=True)
|
||||
parser.add_argument("--gdb-log", type=Path, required=True)
|
||||
parser.add_argument("--monitor-log", type=Path, default=Path("/tmp/mt-monitor.jsonl"))
|
||||
parser.add_argument("--fixture-index", type=int, default=0)
|
||||
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
|
||||
parser.add_argument("--post-record-wait", type=int, default=12)
|
||||
parser.add_argument(
|
||||
"--arm-check-seconds",
|
||||
type=int,
|
||||
default=0,
|
||||
help="attach, prove hardware breakpoints arm, then detach without claiming a capture",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wait-for-record-clear",
|
||||
action="store_true",
|
||||
help="keep tracing through abandon; accept creation only after old records disappear",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exclude-pid",
|
||||
action="append",
|
||||
type=int,
|
||||
default=[],
|
||||
help="ignore an existing FIFA process and attach only after process replacement",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--payload",
|
||||
default="gdb_match_team_writer_trace.py",
|
||||
help="GDB Python payload in this tool directory; must expose start_trace(log, cards_base)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
for path in (args.status, args.trace, args.gdb_log, args.monitor_log):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
for path in (args.trace, args.gdb_log, args.monitor_log):
|
||||
path.unlink(missing_ok=True)
|
||||
status = Status(args.status, args.monitor_log)
|
||||
payload = Path(__file__).with_name(args.payload).resolve()
|
||||
if not payload.exists():
|
||||
status.write(state="failed", error=f"missing gdb payload: {payload}")
|
||||
return 2
|
||||
|
||||
deadline = time.time() + args.timeout
|
||||
status.write(state="waiting_for_ready_process", excluded_pids=args.exclude_pid)
|
||||
status.log(
|
||||
"waiting for FIFA17.exe with CardsDLL",
|
||||
excluded_pids=args.exclude_pid,
|
||||
)
|
||||
pid = None
|
||||
cards = None
|
||||
while time.time() < deadline:
|
||||
# UMU/Proton creates a short-lived small FIFA17.exe before the real
|
||||
# client. Never bind to the first comm match. Require CardsDLL and prefer
|
||||
# the largest-RSS process (find_pids is ordered that way).
|
||||
for candidate in find_pids():
|
||||
if candidate in args.exclude_pid:
|
||||
continue
|
||||
candidate_cards = cards_base(candidate)
|
||||
if candidate_cards:
|
||||
pid, cards = candidate, candidate_cards
|
||||
break
|
||||
if pid:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
if not pid or not cards:
|
||||
status.write(state="timed_out", phase="ready_process")
|
||||
return 3
|
||||
|
||||
status.write(state="ready_process_found", pid=pid, cards_base=cards)
|
||||
status.log("real FIFA17.exe with CardsDLL found", pid=pid, cards_base=cards)
|
||||
|
||||
command_path = Path(tempfile.gettempdir()) / f"mt-trace-{pid}.gdb"
|
||||
command_path.write_text(gdb_commands(pid, cards, payload, args.trace))
|
||||
gdb_handle = args.gdb_log.open("w", encoding="utf-8")
|
||||
process = subprocess.Popen(
|
||||
["gdb", "-q", "-nx", "-x", str(command_path)],
|
||||
stdout=gdb_handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
status.write(
|
||||
state="attaching",
|
||||
pid=pid,
|
||||
cards_base=cards,
|
||||
cards_image_base=CARDS_IMAGE_BASE,
|
||||
gdb_pid=process.pid,
|
||||
gdb_command_file=str(command_path),
|
||||
payload=args.payload,
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
status.log("gdb launched", pid=pid, gdb_pid=process.pid, cards_base=cards)
|
||||
|
||||
armed = False
|
||||
arm_deadline = min(deadline, time.time() + 60)
|
||||
while time.time() < arm_deadline:
|
||||
if process.poll() is not None:
|
||||
break
|
||||
events = read_events(args.trace)
|
||||
if any(event.get("event") == "trace_armed" for event in events):
|
||||
armed = True
|
||||
break
|
||||
time.sleep(0.25)
|
||||
if not armed:
|
||||
gdb_handle.close()
|
||||
status.write(
|
||||
state="failed",
|
||||
phase="arm",
|
||||
gdb_returncode=process.poll(),
|
||||
tracer_pid=tracer_pid(pid),
|
||||
trace_events=event_counts(read_events(args.trace)),
|
||||
)
|
||||
if process.poll() is None:
|
||||
terminate_gdb(process, status, pid)
|
||||
return 4
|
||||
|
||||
attached = tracer_pid(pid) == process.pid
|
||||
status.write(
|
||||
state="armed",
|
||||
tracer_pid=tracer_pid(pid),
|
||||
target_state=target_state(pid),
|
||||
trace_events=event_counts(read_events(args.trace)),
|
||||
execution_breakpoints_armed=True,
|
||||
team1_watchpoint_armed=False,
|
||||
)
|
||||
status.log("trace armed", attached=attached, tracer_pid=tracer_pid(pid))
|
||||
if not attached:
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
status.write(state="failed", phase="attach_verification")
|
||||
return 4
|
||||
if args.arm_check_seconds > 0:
|
||||
time.sleep(args.arm_check_seconds)
|
||||
events = read_events(args.trace)
|
||||
counts = event_counts(events)
|
||||
still_attached = tracer_pid(pid) == process.pid and process.poll() is None
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
passed = (
|
||||
still_attached
|
||||
and counts.get("trace_armed", 0) == 1
|
||||
and counts.get("trace_error", 0) == 0
|
||||
and tracer_pid(pid) == 0
|
||||
and target_state(pid) != "T"
|
||||
)
|
||||
status.write(
|
||||
state="arm_check_passed" if passed else "arm_check_failed",
|
||||
trace_events=counts,
|
||||
attached_before_detach=still_attached,
|
||||
tracer_pid_after_detach=tracer_pid(pid),
|
||||
target_state_after_detach=target_state(pid),
|
||||
)
|
||||
status.log("arm check complete", passed=passed, trace_events=counts)
|
||||
return 0 if passed else 5
|
||||
|
||||
# A final record that already exists before arming cannot prove execution
|
||||
# crossed creation under the debugger. Fail closed instead of converting an
|
||||
# already-built match into a trusted zero-hit result.
|
||||
initial_heap = scan_process(
|
||||
pid,
|
||||
args.fixture_index,
|
||||
include_fixture=False,
|
||||
writable_anon_only=True,
|
||||
)
|
||||
records_preexisting = len(initial_heap["match_teams"]) >= 2
|
||||
records_cleared = not records_preexisting
|
||||
if records_preexisting and args.wait_for_record_clear:
|
||||
status.write(
|
||||
state="waiting_for_record_clear",
|
||||
locations=serialise_locations(initial_heap),
|
||||
target_crossed_match_team_creation=False,
|
||||
)
|
||||
status.log(
|
||||
"trace armed; waiting for old match-team records to disappear",
|
||||
team_ids=[team.team_id for team in initial_heap["match_teams"]],
|
||||
)
|
||||
while time.time() < deadline:
|
||||
if process.poll() is not None or not Path(f"/proc/{pid}").exists():
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
status.write(state="failed", phase="record_clear")
|
||||
return 5
|
||||
heap = scan_process(
|
||||
pid,
|
||||
args.fixture_index,
|
||||
include_fixture=False,
|
||||
writable_anon_only=True,
|
||||
)
|
||||
if not heap["match_teams"]:
|
||||
records_cleared = True
|
||||
status.write(
|
||||
state="records_cleared",
|
||||
cleared_unix=time.time(),
|
||||
tracer_pid=tracer_pid(pid),
|
||||
gdb_alive=process.poll() is None,
|
||||
target_state=target_state(pid),
|
||||
)
|
||||
status.log(
|
||||
"old match-team records disappeared; next records are a fresh creation",
|
||||
tracer_pid=tracer_pid(pid),
|
||||
)
|
||||
break
|
||||
time.sleep(2)
|
||||
if not records_cleared:
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
status.write(state="timed_out", phase="record_clear")
|
||||
return 3
|
||||
elif records_preexisting:
|
||||
counts = event_counts(read_events(args.trace))
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
status.write(
|
||||
state="armed_too_late",
|
||||
phase="preexisting_records",
|
||||
trace_events=counts,
|
||||
locations=serialise_locations(initial_heap),
|
||||
target_crossed_match_team_creation=False,
|
||||
tracer_pid_after_detach=tracer_pid(pid),
|
||||
target_state_after_detach=target_state(pid),
|
||||
)
|
||||
status.log(
|
||||
"match-team records pre-existed trace; no writer claim",
|
||||
team_ids=[team.team_id for team in initial_heap["match_teams"]],
|
||||
)
|
||||
return 6
|
||||
|
||||
|
||||
fixture = None
|
||||
latest_locations = {"fixtures": [], "match_teams": [], "match_configs": []}
|
||||
last_fixture_scan = 0.0
|
||||
records_seen_at = None
|
||||
record_control = None
|
||||
try:
|
||||
while time.time() < deadline:
|
||||
if process.poll() is not None or not Path(f"/proc/{pid}").exists():
|
||||
status.write(
|
||||
state="failed",
|
||||
phase="monitor",
|
||||
gdb_returncode=process.poll(),
|
||||
target_exists=Path(f"/proc/{pid}").exists(),
|
||||
)
|
||||
return 5
|
||||
|
||||
now = time.time()
|
||||
if fixture is None and now - last_fixture_scan >= 8:
|
||||
full = scan_process(pid, args.fixture_index, include_fixture=True)
|
||||
last_fixture_scan = now
|
||||
if full["fixtures"]:
|
||||
fixture = full["fixtures"][0]
|
||||
latest_locations["fixtures"] = full["fixtures"]
|
||||
status.log(
|
||||
"fixture located",
|
||||
address=fixture.address,
|
||||
selected_address=fixture.selected_address,
|
||||
selected_index=fixture.selected_index,
|
||||
selected_team_id=fixture.selected_team_id,
|
||||
)
|
||||
|
||||
heap = scan_process(
|
||||
pid,
|
||||
args.fixture_index,
|
||||
include_fixture=False,
|
||||
writable_anon_only=True,
|
||||
)
|
||||
latest_locations["match_teams"] = heap["match_teams"]
|
||||
latest_locations["match_configs"] = heap["match_configs"]
|
||||
events = read_events(args.trace)
|
||||
counts = event_counts(events)
|
||||
is_attached = tracer_pid(pid) == process.pid
|
||||
watch_armed = counts.get("team1_watchpoint_armed", 0) > 0
|
||||
status.write(
|
||||
state="capturing" if len(heap["match_teams"]) < 2 else "records_observed",
|
||||
tracer_pid=tracer_pid(pid),
|
||||
gdb_alive=process.poll() is None,
|
||||
target_state=target_state(pid),
|
||||
trace_events=counts,
|
||||
team1_watchpoint_armed=watch_armed,
|
||||
locations=serialise_locations(latest_locations),
|
||||
)
|
||||
|
||||
if len(heap["match_teams"]) >= 2:
|
||||
if records_seen_at is None:
|
||||
if not is_attached or process.poll() is not None:
|
||||
status.write(
|
||||
state="failed",
|
||||
phase="record_creation_control",
|
||||
tracer_pid=tracer_pid(pid),
|
||||
gdb_alive=process.poll() is None,
|
||||
trace_events=counts,
|
||||
)
|
||||
return 5
|
||||
records_seen_at = now
|
||||
record_control = {
|
||||
"gdb_alive": process.poll() is None,
|
||||
"tracer_pid": tracer_pid(pid),
|
||||
"attached": is_attached,
|
||||
"execution_breakpoints_armed": counts.get("trace_armed", 0) == 1,
|
||||
"team1_watchpoint_armed": watch_armed,
|
||||
}
|
||||
status.log(
|
||||
"two match-team records located",
|
||||
team_ids=[team.team_id for team in heap["match_teams"]],
|
||||
trace_events=counts,
|
||||
**record_control,
|
||||
)
|
||||
if now - records_seen_at >= args.post_record_wait:
|
||||
break
|
||||
time.sleep(3)
|
||||
finally:
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
|
||||
events = read_events(args.trace)
|
||||
counts = event_counts(events)
|
||||
final = {
|
||||
"state": "captured",
|
||||
"pid": pid,
|
||||
"cards_base": cards,
|
||||
"fixture": asdict(fixture) if fixture else None,
|
||||
"locations": serialise_locations(latest_locations),
|
||||
"trace_events": counts,
|
||||
"record_creation_control": record_control,
|
||||
"gdb_alive_at_record_creation": bool(
|
||||
record_control and record_control["gdb_alive"] and record_control["attached"]
|
||||
),
|
||||
"target_crossed_match_team_creation": len(latest_locations["match_teams"]) >= 2,
|
||||
"candidate_entry_hit": counts.get("candidate_entry", 0) > 0,
|
||||
"team1_write_hit": counts.get("team1_write_post", 0) > 0,
|
||||
"opponent_lookup_store_hit": counts.get("opponent_lookup_store_pre", 0) > 0,
|
||||
"tracer_pid_after_detach": tracer_pid(pid),
|
||||
"target_state_after_detach": target_state(pid),
|
||||
"records_preexisting": records_preexisting,
|
||||
"records_cleared_before_capture": records_cleared,
|
||||
}
|
||||
status.write(**final)
|
||||
status.log("capture complete", **final)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dump a CardsDLL vtable as image VAs, and find sibling vtables that hold a
|
||||
different function in the same slot (a type/mode dispatch).
|
||||
|
||||
vtab.py <slot_image_va_hex> [before] [after]
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
CARDS_IMG = 0x180000000
|
||||
|
||||
|
||||
def pid():
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||
return int(os.path.basename(d))
|
||||
except OSError:
|
||||
pass
|
||||
raise SystemExit("no FIFA17.exe")
|
||||
|
||||
|
||||
P = pid()
|
||||
BASE = [int(l.split("-")[0], 16) for l in open(f"/proc/{P}/maps") if "CardsDLL" in l][0]
|
||||
|
||||
|
||||
def img2live(va):
|
||||
return BASE + (va - CARDS_IMG)
|
||||
|
||||
|
||||
def live2img(la):
|
||||
return CARDS_IMG + (la - BASE)
|
||||
|
||||
|
||||
slot = int(sys.argv[1], 16)
|
||||
before = int(sys.argv[2]) if len(sys.argv) > 2 else 10
|
||||
after = int(sys.argv[3]) if len(sys.argv) > 3 else 10
|
||||
|
||||
mem = open(f"/proc/{P}/mem", "rb", 0)
|
||||
start = slot - before * 8
|
||||
mem.seek(img2live(start))
|
||||
buf = mem.read((before + after) * 8)
|
||||
print(f" vtable neighbourhood of image 0x{slot:x}")
|
||||
target = None
|
||||
for k in range(0, len(buf) - 7, 8):
|
||||
a = start + k
|
||||
p = struct.unpack_from("<Q", buf, k)[0]
|
||||
ivа = live2img(p) if BASE <= p < BASE + 0x400000 else None
|
||||
mark = " <== the team-pair assigner" if a == slot else ""
|
||||
if a == slot:
|
||||
target = ivа
|
||||
print(f" 0x{a:x} [{a-slot:+#5x}] -> "
|
||||
+ (f"image 0x{ivа:x}" if ivа else f"raw 0x{p:x}") + mark)
|
||||
|
||||
# Find every other .rdata slot pointing at a DIFFERENT function but whose
|
||||
# neighbours overlap this vtable -> sibling implementations of the same slot.
|
||||
print("\n === sibling vtables: same neighbour, different slot function ===")
|
||||
mem.seek(img2live(0x1801e5000))
|
||||
rdata = mem.read(0x28a000 - 0x1e5000)
|
||||
# take the two neighbours around the slot as a signature
|
||||
sig_prev = struct.unpack_from("<Q", buf, (before - 1) * 8)[0]
|
||||
sig_next = struct.unpack_from("<Q", buf, (before + 1) * 8)[0]
|
||||
found = 0
|
||||
for name, sig in (("preceding", sig_prev), ("following", sig_next)):
|
||||
pat = struct.pack("<Q", sig)
|
||||
i = rdata.find(pat)
|
||||
while i >= 0:
|
||||
if i % 8 == 0:
|
||||
here = 0x1801e5000 + i
|
||||
# the slot in THIS vtable at the same relative position
|
||||
off = i + (8 if name == "preceding" else -8)
|
||||
if 0 <= off <= len(rdata) - 8:
|
||||
fn = struct.unpack_from("<Q", rdata, off)[0]
|
||||
if BASE <= fn < BASE + 0x400000:
|
||||
fimg = live2img(fn)
|
||||
if fimg != target:
|
||||
print(f" vtable @image 0x{here:x} ({name} matches) "
|
||||
f"slot -> image 0x{fimg:x} DIFFERENT")
|
||||
found += 1
|
||||
i = rdata.find(pat, i + 1)
|
||||
print(f" {found} sibling implementation(s)")
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Find references to an image VA inside a live module's .text/.rdata/.data.
|
||||
|
||||
xref.py <target_image_va_hex> [--exe]
|
||||
|
||||
Reports:
|
||||
call rel32 (e8) / jmp rel32 (e9) -- direct callers
|
||||
lea rip-rel (48 8d 0x) -- address-taken
|
||||
absolute 8-byte pointer -- vtable / table slot
|
||||
|
||||
Read-only. Section ranges are recomputed from /proc/<pid>/maps every run.
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
CARDS_IMG = 0x180000000
|
||||
EXE_IMG = 0x140000000
|
||||
|
||||
|
||||
def pid():
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||
return int(os.path.basename(d))
|
||||
except OSError:
|
||||
pass
|
||||
raise SystemExit("FIFA17.exe not running")
|
||||
|
||||
|
||||
P = pid()
|
||||
|
||||
|
||||
def module_base(needle):
|
||||
for l in open(f"/proc/{P}/maps"):
|
||||
if needle.lower() in l.lower():
|
||||
return int(l.split("-")[0], 16)
|
||||
raise SystemExit(f"{needle} not mapped")
|
||||
|
||||
|
||||
def spans(base, limit=0x400000):
|
||||
"""Contiguous mappings belonging to this module, as (live_lo, live_hi, perms)."""
|
||||
out = []
|
||||
for l in open(f"/proc/{P}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", l)
|
||||
if not m:
|
||||
continue
|
||||
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)
|
||||
if lo == base:
|
||||
out.append((lo, hi, perms))
|
||||
continue
|
||||
if out and lo == out[-1][1] and not path.strip():
|
||||
out.append((lo, hi, perms))
|
||||
elif out and lo > out[-1][1]:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
a = [x for x in sys.argv[1:] if x != "--exe"]
|
||||
exe = "--exe" in sys.argv
|
||||
target = int(a[0], 16)
|
||||
img = EXE_IMG if exe else CARDS_IMG
|
||||
base = module_base("FIFA17.exe" if exe else "CardsDLL")
|
||||
tgt_live = base + (target - img)
|
||||
|
||||
mem = open(f"/proc/{P}/mem", "rb", 0)
|
||||
print(f" pid={P} module_base=0x{base:x} target image 0x{target:x} live 0x{tgt_live:x}")
|
||||
hits = 0
|
||||
for lo, hi, perms in spans(base):
|
||||
try:
|
||||
mem.seek(lo)
|
||||
buf = mem.read(hi - lo)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
img_lo = img + (lo - base)
|
||||
# rel32 call/jmp
|
||||
for op, name in ((0xE8, "call"), (0xE9, "jmp ")):
|
||||
i = buf.find(bytes([op]))
|
||||
while i >= 0:
|
||||
if i + 5 <= len(buf):
|
||||
rel = struct.unpack_from("<i", buf, i + 1)[0]
|
||||
if img_lo + i + 5 + rel == target:
|
||||
print(f" {name} rel32 from image 0x{img_lo+i:x} [{perms}]")
|
||||
hits += 1
|
||||
i = buf.find(bytes([op]), i + 1)
|
||||
# lea reg,[rip+rel32] (48 8d /r with mod=00 rm=101)
|
||||
i = buf.find(b"\x48\x8d")
|
||||
while i >= 0:
|
||||
if i + 7 <= len(buf):
|
||||
modrm = buf[i + 2]
|
||||
if (modrm & 0xC7) == 0x05:
|
||||
rel = struct.unpack_from("<i", buf, i + 3)[0]
|
||||
if img_lo + i + 7 + rel == target:
|
||||
print(f" lea rip-rel from image 0x{img_lo+i:x} [{perms}]")
|
||||
hits += 1
|
||||
i = buf.find(b"\x48\x8d", i + 1)
|
||||
# absolute pointer (live address stored in a table)
|
||||
pat = struct.pack("<Q", tgt_live)
|
||||
i = buf.find(pat)
|
||||
while i >= 0:
|
||||
if i % 8 == 0:
|
||||
print(f" abs ptr slot at image 0x{img_lo+i:x} [{perms}]")
|
||||
hits += 1
|
||||
i = buf.find(pat, i + 1)
|
||||
print(f" {hits} reference(s)")
|
||||
|
||||
|
||||
main()
|
||||
@@ -41,9 +41,40 @@ pub struct Fifa17CardIdentity {
|
||||
/// FIFA card-art class. Players default to `asset_id`; kit definitions carry
|
||||
/// the verified `fcc_kitcards.cardassetid` value (`35`).
|
||||
pub card_asset_id: u32,
|
||||
/// The wire `assetId` for a CLUB item (record `+0x20`), which is family
|
||||
/// specific and is NOT the carddbid: a kit carries the art class from
|
||||
/// `fcc_kitcards.assetid` (`14` home/third band, `15` away band), a badge
|
||||
/// carries its team id, a stadium and a ball their own asset number.
|
||||
///
|
||||
/// Distinct from [`Self::asset_id`], which for these definitions is the
|
||||
/// carddbid and is what `resource_id` is derived from — so the two cannot be
|
||||
/// the same field. Shipping the carddbid here is what left the client
|
||||
/// holding `assetId 6300006` at record `+0x20` where its own table says
|
||||
/// `14`, with both pre-match kit tiles rendering identically.
|
||||
///
|
||||
/// Defaults to `asset_id` when a catalog does not specify it, which is the
|
||||
/// pre-existing behaviour and is correct for every non-club kind.
|
||||
pub club_asset_id: u32,
|
||||
/// Source team id for a club kit, or a manager's real club. Zero for content
|
||||
/// kinds that do not use it.
|
||||
pub team_id: i64,
|
||||
/// Kit slot family (`club_items.json → kits[].category`): `2` home, `3`
|
||||
/// away, `5` third. Zero for definitions that do not use it.
|
||||
///
|
||||
/// Load-bearing for the pre-match kit selector, not cosmetic. The client's
|
||||
/// active-kit resolver (`FUN_1800d73d0`) reads it at record `+0xb8` and maps
|
||||
/// it to the engine's kit SLOT — 2→0, 3→1, 5→3 — which then forms part of
|
||||
/// the `(teamid, year, slot)` triple the kit descriptor
|
||||
/// (`sub_180033430`) must match. Omit it and the triple cannot match, so the
|
||||
/// engine falls through to its own catalogue kit and reports the kit as
|
||||
/// locked.
|
||||
pub category: i64,
|
||||
/// Kit season (`club_items.json → kits[].year`), `0` for a current-season
|
||||
/// kit and e.g. `2002` for a historical one.
|
||||
///
|
||||
/// Record `+0xba`, atom `0x389`. The third member of the identity triple
|
||||
/// above, and the key the runtime `teamkits` clone queries on.
|
||||
pub year: i64,
|
||||
/// Manager chemistry nation (`managercards.nation`), zero when unused.
|
||||
///
|
||||
/// The client NEVER supplies this: the managercards merge (`FUN_1801356c0`)
|
||||
@@ -189,6 +220,9 @@ struct RawCard {
|
||||
/// Separate card-art id for non-player definitions; absent → `asset_id`.
|
||||
#[serde(default)]
|
||||
card_asset_id: Option<u32>,
|
||||
/// Wire `assetId` for a club item; defaults to `asset_id`. See
|
||||
/// [`Fifa17CardIdentity::club_asset_id`].
|
||||
club_asset_id: Option<u32>,
|
||||
/// Source team id for a kit or manager definition; absent → `0`.
|
||||
#[serde(default)]
|
||||
team_id: Option<i64>,
|
||||
@@ -207,6 +241,12 @@ struct RawCard {
|
||||
/// Contract-card grant (atom 0xb8); absent → key omitted.
|
||||
#[serde(default)]
|
||||
contract: Option<i64>,
|
||||
/// Kit slot family (2 home / 3 away / 5 third); absent → `0`.
|
||||
#[serde(default)]
|
||||
category: Option<i64>,
|
||||
/// Kit season; absent → `0` (current season).
|
||||
#[serde(default)]
|
||||
year: Option<i64>,
|
||||
}
|
||||
|
||||
fn default_rareflag() -> i64 {
|
||||
@@ -264,7 +304,10 @@ impl Fifa17CardCatalog {
|
||||
kind: ContentKind::from_str(&rc.kind),
|
||||
subtype: rc.subtype,
|
||||
card_asset_id: rc.card_asset_id.unwrap_or(rc.asset_id),
|
||||
club_asset_id: rc.club_asset_id.unwrap_or(rc.asset_id),
|
||||
team_id: rc.team_id.unwrap_or(0),
|
||||
category: rc.category.unwrap_or(0),
|
||||
year: rc.year.unwrap_or(0),
|
||||
nation: rc.nation.unwrap_or(0),
|
||||
league_id: rc.league_id.unwrap_or(0),
|
||||
rating: rc.rating,
|
||||
@@ -355,6 +398,49 @@ mod tests {
|
||||
assert_eq!(cat.lookup("card_missing"), None);
|
||||
}
|
||||
|
||||
/// A club item's wire `assetId` is family specific and is NOT the carddbid.
|
||||
///
|
||||
/// Regression: the catalog shipped `asset_id` (the carddbid) as the wire
|
||||
/// `assetId`, so the client held `assetId 6300006` at record `+0x20` where
|
||||
/// its own `fcc_kitcards` says `14`, and both pre-match kit tiles rendered
|
||||
/// identically. `resource_id` is derived from `asset_id`, and every home kit
|
||||
/// shares art class 14, so the two genuinely cannot be one field.
|
||||
#[test]
|
||||
fn club_items_carry_their_own_wire_asset_id_distinct_from_the_carddbid() {
|
||||
let cat = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"fifa17_6300006":{"asset_id":6300006,"kind":"kit","subtype":9,
|
||||
"card_asset_id":35,"club_asset_id":14,"team_id":21,"category":2,"year":0},
|
||||
"fifa17_6400003":{"asset_id":6400003,"kind":"kit","subtype":9,
|
||||
"card_asset_id":35,"club_asset_id":15,"team_id":21,"category":3,"year":0},
|
||||
"fifa17_20801":{"asset_id":20801}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let home = cat.lookup("fifa17_6300006").unwrap();
|
||||
let away = cat.lookup("fifa17_6400003").unwrap();
|
||||
|
||||
// resourceId stays the carddbid — it is what the staff/kit merge keys on.
|
||||
assert_eq!(home.resource_id, 6300006);
|
||||
assert_eq!(away.resource_id, 6400003);
|
||||
// The card frame art is shared by the whole kit family.
|
||||
assert_eq!(home.card_asset_id, 35);
|
||||
assert_eq!(away.card_asset_id, 35);
|
||||
// The art class is what distinguishes home from away on the wire.
|
||||
assert_eq!(home.club_asset_id, 14);
|
||||
assert_eq!(away.club_asset_id, 15);
|
||||
assert_ne!(
|
||||
home.club_asset_id, away.club_asset_id,
|
||||
"home and away must not present the same assetId"
|
||||
);
|
||||
|
||||
// Absent: defaults to asset_id, which is correct for every non-club kind
|
||||
// and preserves the behaviour of a catalog that predates the field.
|
||||
let player = cat.lookup("fifa17_20801").unwrap();
|
||||
assert_eq!(player.club_asset_id, 20801);
|
||||
}
|
||||
|
||||
/// The non-player definition fields a consumable needs, and the ABSENCE that
|
||||
/// must stay an absence: a defaulted `amount` would draw "-1" on the card and
|
||||
/// a defaulted `contract` would invent the number of matches a card grants.
|
||||
|
||||
@@ -527,6 +527,8 @@ mod tests {
|
||||
card_asset_id: 35,
|
||||
subtype: 9,
|
||||
team_id,
|
||||
category: 2,
|
||||
year: 0,
|
||||
};
|
||||
let ident = KindMapIdentity {
|
||||
ids: HashMap::new(),
|
||||
@@ -561,7 +563,7 @@ mod tests {
|
||||
assert_eq!(body["itemData"][0]["itemState"], "activeHomeKit");
|
||||
assert_eq!(body["itemData"][1]["itemState"], "activeAwayKit");
|
||||
assert!(body["itemData"][0].get("attributeList").is_none());
|
||||
assert!(body["itemData"][0].get("itemType").is_none());
|
||||
assert_eq!(body["itemData"][0]["itemType"], "kit");
|
||||
}
|
||||
|
||||
/// Kit, badge and stadium are one cardtype-7 record and MUST all project.
|
||||
@@ -579,6 +581,8 @@ mod tests {
|
||||
card_asset_id: art,
|
||||
subtype,
|
||||
team_id: 21,
|
||||
category: 2,
|
||||
year: 0,
|
||||
};
|
||||
let ident = KindMapIdentity {
|
||||
ids: HashMap::new(),
|
||||
|
||||
@@ -26,7 +26,7 @@ use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::content_taxonomy::{
|
||||
consumable_family, consumable_needs, ConsumableNeeds, ContentKind, BADGE_SUBTYPE, KIT_SUBTYPE,
|
||||
MANAGER_SUBTYPE,
|
||||
MANAGER_SUBTYPE, STADIUM_SUBTYPE,
|
||||
};
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item_state;
|
||||
@@ -94,6 +94,11 @@ pub struct Fifa17KitIdentity {
|
||||
pub card_asset_id: u32,
|
||||
pub subtype: i64,
|
||||
pub team_id: i64,
|
||||
/// Kit slot family: `2` home, `3` away, `5` third. Read at record `+0xb8`
|
||||
/// and mapped to the engine kit SLOT (2→0, 3→1, 5→3).
|
||||
pub category: i64,
|
||||
/// Kit season; `0` = current season. Record `+0xba`.
|
||||
pub year: i64,
|
||||
}
|
||||
|
||||
/// FIFA-side identity fields needed to render an owned staff card (manager or
|
||||
@@ -364,6 +369,46 @@ pub fn shape_item(
|
||||
})
|
||||
}
|
||||
|
||||
/// Wire `itemType` (atom 0x173) for a cardtype-7 club item.
|
||||
///
|
||||
/// Sent for WIRE FIDELITY only. Every real EA item in the capture corpus carries
|
||||
/// `itemType`, and the two families OpenFUT already shaped (`player`, `staff`)
|
||||
/// carry it, so omitting it on the club families was an inconsistency. Tokens
|
||||
/// come from the `?type=` vocabulary decoded from the `FUN_18012ec50` jump table
|
||||
/// (`kit` 12, `stadium` 13, `badge` 11).
|
||||
///
|
||||
/// It does NOT fix the pre-match kit selector, and the reasoning that first
|
||||
/// introduced it was WRONG. That reasoning was: kit/badge/stadium omitted
|
||||
/// `itemType` and were not resident as item records, while player and staff sent
|
||||
/// it and were, so `itemType` must gate ingestion. Adding it changed nothing —
|
||||
/// the client was relaunched, `?type=kit` answered `total=2 emitted=2` with
|
||||
/// `itemType` present, and still no cardtype-7 record was resident.
|
||||
///
|
||||
/// The correlation was an artefact of the CONTROL, not the field. Measured
|
||||
/// 2026-08-23 read-only over `/proc/PID/mem`: the "resident" players and staff
|
||||
/// were all SQUAD members, which arrive via `userMassInfo`. Testing players that
|
||||
/// appear in `/club?type=player` but NOT in `userMassInfo` shows they are not
|
||||
/// resident either — 0 records for 6 of 6 sampled, 5 with no byte match at all,
|
||||
/// out of 1966 served. So residency tracks the ROUTE, not this field:
|
||||
/// `/club?type=` responses do not enter the persistent card collection, and no
|
||||
/// value of `itemType` changes that.
|
||||
///
|
||||
/// `CARD_SYSTEM.md`'s "parsed into a heap string and never stored" therefore
|
||||
/// stands unchallenged; the earlier note here that it was "evidence the string is
|
||||
/// consulted" is withdrawn.
|
||||
///
|
||||
/// INFERRED, not proven: no capture of a real EA club item exists anywhere in the
|
||||
/// corpus, so the exact token for these three families is taken from the atom
|
||||
/// vocabulary rather than observed on the wire.
|
||||
fn club_item_type(subtype: i64) -> &'static str {
|
||||
match subtype {
|
||||
KIT_SUBTYPE => "kit",
|
||||
STADIUM_SUBTYPE => "stadium",
|
||||
BADGE_SUBTYPE => "badge",
|
||||
_ => "misc",
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one FIFA 17 **cardtype-7 club item**: a kit (subtype 9), a badge (11)
|
||||
/// or a stadium (10). `item_state` is the proven wire enum token — `free`, or
|
||||
/// one of the `active*` designations the client deserializes to 100..104.
|
||||
@@ -392,6 +437,7 @@ pub fn shape_club_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
|
||||
"assetId": id.asset_id,
|
||||
"cardassetid": id.card_asset_id,
|
||||
"cardsubtypeid": id.subtype,
|
||||
"itemType": club_item_type(id.subtype),
|
||||
"itemState": item_state,
|
||||
"owners": 1,
|
||||
"untradeable": false,
|
||||
@@ -399,6 +445,19 @@ pub fn shape_club_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
|
||||
if matches!(id.subtype, KIT_SUBTYPE | BADGE_SUBTYPE) {
|
||||
item["teamid"] = json!(id.team_id);
|
||||
}
|
||||
// Kits only. `category` and `year` complete the `(teamid, year, slot)`
|
||||
// identity the client's active-kit resolver builds at record `+0xb8`/`+0xba`
|
||||
// (`FUN_1800d73d0`), and which the engine's kit descriptor
|
||||
// (`sub_180033430`) compares against before it will name — rather than
|
||||
// lock — a kit. Without them the triple can never match and the pre-match
|
||||
// selector reports "This kit is currently locked".
|
||||
//
|
||||
// Deliberately NOT emitted for badges or stadiums: the slot mapping is
|
||||
// kit-specific, and those families resolve their caption by other fields.
|
||||
if id.subtype == KIT_SUBTYPE {
|
||||
item["category"] = json!(id.category);
|
||||
item["year"] = json!(id.year);
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
@@ -877,6 +936,8 @@ mod tests {
|
||||
card_asset_id: 35,
|
||||
subtype: 9,
|
||||
team_id: 21,
|
||||
category: 2,
|
||||
year: 0,
|
||||
};
|
||||
for state in [
|
||||
item_state::FREE,
|
||||
@@ -914,6 +975,8 @@ mod tests {
|
||||
card_asset_id: 39,
|
||||
subtype,
|
||||
team_id: 21,
|
||||
category: 2,
|
||||
year: 0,
|
||||
};
|
||||
for subtype in [KIT_SUBTYPE, BADGE_SUBTYPE] {
|
||||
let it = shape_club_item(ident(subtype), item_state::FREE);
|
||||
@@ -956,4 +1019,90 @@ mod tests {
|
||||
assert!(stadium.get(key).is_none(), "club item must not carry {key}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The pre-match kit selector needs the WHOLE identity triple, not just
|
||||
/// `teamid`.
|
||||
///
|
||||
/// The client's active-kit resolver `FUN_1800d73d0` reads `category` at
|
||||
/// record `+0xb8` (mapping 2→slot 0, 3→slot 1, 5→slot 3) and `year` at
|
||||
/// `+0xba`, and the engine's kit descriptor `sub_180033430` will only NAME a
|
||||
/// kit whose decoded `(teamid, year, slot)` equals the club's active home or
|
||||
/// away triple. A descriptor it does not match is left completely unwritten
|
||||
/// and the engine reports "This kit is currently locked" instead.
|
||||
///
|
||||
/// Regression: we shipped kits with `teamid` alone, which cannot match.
|
||||
#[test]
|
||||
fn a_kit_carries_the_full_identity_triple_the_selector_matches_on() {
|
||||
let kit = Fifa17KitIdentity {
|
||||
item_id: 100_004_874,
|
||||
asset_id: 6_300_006,
|
||||
resource_id: 6_300_006,
|
||||
card_asset_id: 35,
|
||||
subtype: KIT_SUBTYPE,
|
||||
team_id: 21,
|
||||
category: 2,
|
||||
year: 0,
|
||||
};
|
||||
let home = shape_club_item(kit, item_state::ACTIVE_HOME_KIT);
|
||||
assert_eq!(home["teamid"], 21);
|
||||
assert_eq!(home["category"], 2, "category is the SLOT source");
|
||||
assert_eq!(home["year"], 0, "year completes the triple");
|
||||
assert_eq!(home["itemState"], item_state::ACTIVE_HOME_KIT);
|
||||
|
||||
// A historical kit must round-trip its real season, not be flattened.
|
||||
let historical = shape_club_item(
|
||||
Fifa17KitIdentity {
|
||||
year: 2002,
|
||||
category: 5,
|
||||
..kit
|
||||
},
|
||||
item_state::ACTIVE_HOME_KIT,
|
||||
);
|
||||
assert_eq!(historical["year"], 2002);
|
||||
assert_eq!(historical["category"], 5);
|
||||
|
||||
// Badges and stadiums must NOT gain the kit-only fields: the slot map is
|
||||
// kit-specific and this project has frozen the client before by sending
|
||||
// a family a field its resolver does not read.
|
||||
for subtype in [BADGE_SUBTYPE, STADIUM_SUBTYPE] {
|
||||
let other = shape_club_item(Fifa17KitIdentity { subtype, ..kit }, item_state::FREE);
|
||||
assert!(other.get("category").is_none(), "subtype {subtype}");
|
||||
assert!(other.get("year").is_none(), "subtype {subtype}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every cardtype-7 family MUST carry a DISTINCT `itemType`.
|
||||
///
|
||||
/// Measured live 2026-08-23: the two families that carry `itemType`
|
||||
/// (player, staff) become resident item records and the three that omitted
|
||||
/// it (kit, badge, stadium) did not, leaving the pre-match kit selector with
|
||||
/// an empty DataProvider and a "kit is currently locked" dialog.
|
||||
///
|
||||
/// The distinctness half is not pedantry. `STADIUM_SUBTYPE` was initially
|
||||
/// unimported here, so `match` read it as a fresh binding rather than a
|
||||
/// constant, silently made the stadium arm irrefutable, and typed badges as
|
||||
/// `"stadium"`. It compiled with only an unused-variable warning. Asserting
|
||||
/// three different tokens is what catches that class of mistake.
|
||||
#[test]
|
||||
fn every_cardtype7_family_carries_its_own_item_type() {
|
||||
let base = Fifa17KitIdentity {
|
||||
item_id: 100004874,
|
||||
asset_id: 6300006,
|
||||
resource_id: 6300006,
|
||||
card_asset_id: 35,
|
||||
subtype: KIT_SUBTYPE,
|
||||
team_id: 21,
|
||||
category: 2,
|
||||
year: 0,
|
||||
};
|
||||
let type_of = |subtype| {
|
||||
shape_club_item(Fifa17KitIdentity { subtype, ..base }, item_state::FREE)["itemType"]
|
||||
.as_str()
|
||||
.expect("itemType is always emitted")
|
||||
.to_string()
|
||||
};
|
||||
assert_eq!(type_of(KIT_SUBTYPE), "kit");
|
||||
assert_eq!(type_of(STADIUM_SUBTYPE), "stadium");
|
||||
assert_eq!(type_of(BADGE_SUBTYPE), "badge");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ pub const SEASON_ROUNDS: i64 = 10;
|
||||
/// resolves to a team the client can actually render. They are cycled rather
|
||||
/// than randomised so a season's schedule is stable across reloads — the client
|
||||
/// re-reads `season/list` and a shifting schedule would renumber fixtures.
|
||||
///
|
||||
/// The club's OWN kit team is filtered out at schedule time — see
|
||||
/// [`season_list_body`]. Fixing this list to exclude one id would not do, because
|
||||
/// which team the club wears is ownership state, not a constant.
|
||||
const OPPONENT_TEAM_IDS: &[i64] = &[21, 73, 240, 241, 243];
|
||||
|
||||
/// One scheduled offline-season round.
|
||||
@@ -90,9 +94,9 @@ pub struct SeasonUser {
|
||||
pub data: &'static str,
|
||||
}
|
||||
|
||||
fn round(index: i64) -> SeasonMatch {
|
||||
fn round(index: i64, opponents: &[i64]) -> SeasonMatch {
|
||||
SeasonMatch {
|
||||
team_id: OPPONENT_TEAM_IDS[(index as usize) % OPPONENT_TEAM_IDS.len()],
|
||||
team_id: opponents[(index as usize) % opponents.len()],
|
||||
// Difficulty and reward multiplier are per-round bytes; a flat schedule
|
||||
// is the honest default until the retail ladder is captured.
|
||||
difficulty: 1,
|
||||
@@ -104,13 +108,35 @@ fn round(index: i64) -> SeasonMatch {
|
||||
|
||||
/// `GET …/season/list` — the offline competitions the club can enter, as wire
|
||||
/// text (see the module note on key order).
|
||||
pub fn season_list_body(season_id: i64, division_id: i64) -> String {
|
||||
///
|
||||
/// `own_kit_team_id` is the team whose kit the club wears, taken from its active
|
||||
/// kit items. That team is EXCLUDED from the schedule, because the pre-match kit
|
||||
/// clone resolves both sides out of the same `teamkits` table keyed on
|
||||
/// `teamtechid`: drawing your own kit team makes the opponent render your kit, so
|
||||
/// both sides appear in identical strips. It is also simply wrong data — a club
|
||||
/// would be playing itself.
|
||||
///
|
||||
/// Passing `None` (or a team not in the rotation) keeps the full schedule.
|
||||
pub fn season_list_body(season_id: i64, division_id: i64, own_kit_team_id: Option<i64>) -> String {
|
||||
let opponents: Vec<i64> = OPPONENT_TEAM_IDS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| Some(*id) != own_kit_team_id)
|
||||
.collect();
|
||||
// Never emit an empty rotation: `matches` must be non-empty or StartSeason
|
||||
// dereferences NULL (see the module note), so an exclusion that would empty
|
||||
// the list is ignored rather than allowed to crash the client.
|
||||
let opponents: &[i64] = if opponents.is_empty() {
|
||||
OPPONENT_TEAM_IDS
|
||||
} else {
|
||||
&opponents
|
||||
};
|
||||
let list = SeasonList {
|
||||
seasons: vec![SeasonElement {
|
||||
kind: "OFFLINE",
|
||||
id: season_id,
|
||||
division_id,
|
||||
matches: (0..SEASON_ROUNDS).map(round).collect(),
|
||||
matches: (0..SEASON_ROUNDS).map(|i| round(i, opponents)).collect(),
|
||||
}],
|
||||
};
|
||||
serde_json::to_string(&list).expect("season list serialises")
|
||||
@@ -146,7 +172,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn list_emits_a_full_round_schedule() {
|
||||
let body = parsed(&season_list_body(1, 10));
|
||||
let body = parsed(&season_list_body(1, 10, None));
|
||||
let season = &body["seasons"][0];
|
||||
assert_eq!(season["type"], "OFFLINE");
|
||||
assert_eq!(season["id"], 1);
|
||||
@@ -161,7 +187,7 @@ mod tests {
|
||||
/// (CardsDLL+0xfc5b5), so the schedule can never be empty.
|
||||
#[test]
|
||||
fn matches_are_never_empty_and_every_round_has_a_team() {
|
||||
let body = parsed(&season_list_body(3, 7));
|
||||
let body = parsed(&season_list_body(3, 7, None));
|
||||
let matches = body["seasons"][0]["matches"].as_array().unwrap();
|
||||
assert!(!matches.is_empty());
|
||||
for (i, m) in matches.iter().enumerate() {
|
||||
@@ -181,7 +207,7 @@ mod tests {
|
||||
/// order them alphabetically and break this.
|
||||
#[test]
|
||||
fn type_is_serialised_before_division_id() {
|
||||
let text = season_list_body(1, 10);
|
||||
let text = season_list_body(1, 10, None);
|
||||
let type_at = text.find("\"type\"").expect("type key");
|
||||
let division_at = text.find("\"divisionId\"").expect("divisionId key");
|
||||
assert!(
|
||||
@@ -190,6 +216,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The pre-match kit clone resolves both sides out of the same `teamkits`
|
||||
/// table keyed on `teamtechid`, so drawing the club's own kit team puts the
|
||||
/// opponent in the club's strip. It is also a club playing itself.
|
||||
#[test]
|
||||
fn own_kit_team_is_never_scheduled_as_an_opponent() {
|
||||
let own = OPPONENT_TEAM_IDS[0];
|
||||
let body = parsed(&season_list_body(1, 10, Some(own)));
|
||||
let matches = body["seasons"][0]["matches"].as_array().unwrap();
|
||||
assert_eq!(matches.len(), SEASON_ROUNDS as usize, "still a full ladder");
|
||||
for m in matches {
|
||||
assert_ne!(
|
||||
m["teamId"].as_i64().unwrap(),
|
||||
own,
|
||||
"the club's own kit team must not be an opponent: {m}"
|
||||
);
|
||||
}
|
||||
// The remaining teams are still cycled, so the schedule stays stable and
|
||||
// every round names a renderable team.
|
||||
for (i, m) in matches.iter().enumerate() {
|
||||
assert_eq!(m["roundId"], i as i64);
|
||||
assert!(m["teamId"].as_i64().is_some_and(|t| t > 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Excluding a team that would empty the rotation must NOT produce an empty
|
||||
/// `matches` array, because that crashes StartSeason.
|
||||
#[test]
|
||||
fn an_exclusion_that_would_empty_the_rotation_is_ignored() {
|
||||
// Stand-in for the degenerate case: pretend every id is the own team by
|
||||
// excluding each in turn and asserting the ladder is always full.
|
||||
for own in OPPONENT_TEAM_IDS {
|
||||
let body = parsed(&season_list_body(1, 10, Some(*own)));
|
||||
let matches = body["seasons"][0]["matches"].as_array().unwrap();
|
||||
assert_eq!(matches.len(), SEASON_ROUNDS as usize);
|
||||
assert!(!matches.is_empty(), "matches must never be empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_state_carries_the_season_position() {
|
||||
let body = parsed(&season_user_body(1, 10, 3, 6));
|
||||
|
||||
@@ -200,6 +200,67 @@ pub fn parse_squad_put(body: &[u8]) -> Result<Fifa17SquadPut, SquadError> {
|
||||
serde_json::from_slice(body).map_err(|e| SquadError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
/// A role-only squad update: the client changed the captain and/or the
|
||||
/// kick-taker assignments without touching the squad itself.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Fifa17SquadRolePatch {
|
||||
#[serde(default)]
|
||||
pub id: i64,
|
||||
/// Opaque 33-int array, verbatim. Differs from the replacement's `custom`
|
||||
/// in the captures (the role screen writes per-slot values into it), so it
|
||||
/// MUST be carried through rather than preserved from the stored copy.
|
||||
#[serde(default)]
|
||||
pub custom: Option<String>,
|
||||
#[serde(default)]
|
||||
pub captain: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub kicktakers: Vec<SquadKicktaker>,
|
||||
}
|
||||
|
||||
/// Which mutation a `PUT …/squad/<id>` body actually expresses.
|
||||
///
|
||||
/// FIFA 17 sends two different operations down one path, so the BODY SHAPE is
|
||||
/// the operation discriminator. Across 73 captured squad PUTs spanning five
|
||||
/// captures there are exactly two shapes:
|
||||
///
|
||||
/// * 68x with `players` — a full replacement, also carrying `squadName`,
|
||||
/// `formation`, `squadType`, `manager`, `chemistry`/`rating`/`starRating`,
|
||||
/// and (redundantly) `captain`/`kicktakers`.
|
||||
/// * 5x without `players` — `{id, custom, captain, kicktakers}` only, emitted
|
||||
/// by the captain/kick-taker screen.
|
||||
///
|
||||
/// `players` is therefore the discriminator: its PRESENCE means "this body
|
||||
/// describes the whole squad". Its ABSENCE means the squad was not part of the
|
||||
/// edit at all and must be left alone — which is NOT the same as an empty
|
||||
/// `players` array, and that distinction is the whole point. Serde's
|
||||
/// `#[serde(default)]` collapses both to an empty vec, so key presence is
|
||||
/// tested on the raw JSON before deserialising.
|
||||
///
|
||||
/// An explicit `"players": []` still classifies as a replacement, so the
|
||||
/// empty-replacement guard in Core keeps seeing it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SquadMutation {
|
||||
/// Full replacement of the squad's slots and metadata.
|
||||
Replace(Box<Fifa17SquadPut>),
|
||||
/// Role-only patch: captain and/or kick-takers, nothing else.
|
||||
PatchRoles(Fifa17SquadRolePatch),
|
||||
}
|
||||
|
||||
/// Classify a squad PUT body. See [`SquadMutation`] for the discriminator and
|
||||
/// the capture evidence behind it.
|
||||
pub fn classify_squad_put(body: &[u8]) -> Result<SquadMutation, SquadError> {
|
||||
let raw: serde_json::Value =
|
||||
serde_json::from_slice(body).map_err(|e| SquadError::Parse(e.to_string()))?;
|
||||
let has_players = raw.as_object().is_some_and(|o| o.contains_key("players"));
|
||||
if has_players {
|
||||
return parse_squad_put(body).map(|p| SquadMutation::Replace(Box::new(p)));
|
||||
}
|
||||
serde_json::from_slice(body)
|
||||
.map(SquadMutation::PatchRoles)
|
||||
.map_err(|e| SquadError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
/// Resolve a parsed save into a **canonical** [`ProposedSquad`]: drop empty
|
||||
/// (`id == 0`) slots, reverse-map each occupied slot's wire id to a Core
|
||||
/// `owned_card_id`, flag the captain, derive the bench split from the fixed
|
||||
|
||||
@@ -61,7 +61,7 @@ pub struct KicktakerRef {
|
||||
}
|
||||
|
||||
/// FIFA 17 Squad Extension, version 1. Serialized to the opaque payload Core stores.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Fifa17SquadExtensionV1 {
|
||||
/// Opaque 33-int array as a JSON-encoded string, verbatim. Never decoded.
|
||||
#[serde(default)]
|
||||
|
||||
@@ -35,11 +35,14 @@ use std::collections::HashMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::club_response::ActiveKitAssignments;
|
||||
use crate::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item::{
|
||||
shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver, STAFF_CONTRACT,
|
||||
shape_club_item, shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver,
|
||||
STAFF_CONTRACT,
|
||||
};
|
||||
use crate::fut::item_state;
|
||||
use crate::fut::squad::FIFA17_SQUAD_SLOTS;
|
||||
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
|
||||
|
||||
@@ -185,23 +188,37 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
}
|
||||
}
|
||||
|
||||
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref
|
||||
// AND carrying its item, as `[{id, itemData, dream}]`.
|
||||
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref and
|
||||
// emitted as a BARE ITEM OBJECT with `dream` beside the item's own fields —
|
||||
// NOT wrapped in `itemData`.
|
||||
//
|
||||
// The bare `[{id, dream}]` form is NOT sufficient, which cost a real
|
||||
// debugging round: the operator picked a manager in the hub, the save
|
||||
// persisted (Core `squad_managers` row written, `outcome=ok`, no unresolved
|
||||
// ref), and the pre-match squad still showed no manager. Every retail
|
||||
// capture that shows the bare form has `id: 0` — an EMPTY manager — so none
|
||||
// of them ever demonstrated that a POPULATED ref resolves without its item.
|
||||
// This is a wire-shape contract, recovered from the client rather than
|
||||
// guessed, after two earlier shapes both failed:
|
||||
//
|
||||
// The squad response is self-contained for players: `players[].itemData`
|
||||
// carries the whole card rather than an id the client resolves out of band.
|
||||
// The manager is the same kind of slot in the same object, and the one
|
||||
// implementation that ever drove a working manager (the Python oracle's
|
||||
// squad) emits `id` BESIDE `itemData` exactly like this. Note the element
|
||||
// shape differs from a player slot: `{index, itemData, kitNumber}` there,
|
||||
// `{id, itemData, dream}` here.
|
||||
// `[{id, dream}]` — no merge key, so nothing resolves.
|
||||
// `[{id, itemData, dream}]` — `itemData` is never read on this path.
|
||||
//
|
||||
// The squad parser FUN_18013d1f0 treats the two slots differently, and that
|
||||
// is the whole point:
|
||||
//
|
||||
// players: atom 568 -> per-element atoms 355 `index`, 363 `itemData`,
|
||||
// 378 `kitNumber`; the 363 arm (0x18013d8d9) calls the ITEM
|
||||
// parser FUN_18013fe00 on the NESTED itemData object.
|
||||
// manager: atom 424 -> array loop at 0x18013da29 calls that same item
|
||||
// parser DIRECTLY on the array ELEMENT, into squad+0xC0. There is
|
||||
// no `itemData` step at all.
|
||||
//
|
||||
// So a manager element IS an item. Nesting the fields one level deeper left
|
||||
// the parser reading only the two keys that happen to be item atoms — `id`
|
||||
// (0x14c) and `dream` (0xe7) — and leaving `resourceId` at 0. Measured on a
|
||||
// cold client: the manager record existed at squad+0xC0 with the correct id
|
||||
// and `resourceId == 0`, while sibling players in the same response carried
|
||||
// theirs (83906881, 84053575). `resourceId` is the merge key compared RAW
|
||||
// against `carddbid`, so zero can never hit the managercards table: no name,
|
||||
// no rating, no art, and an empty manager slot in the UI.
|
||||
//
|
||||
// The client's own save corroborates the shape: it PUTs
|
||||
// `"manager":[{"id":…,"dream":false}]` — flat, and both keys are item atoms.
|
||||
//
|
||||
// An owned manager with no resolvable FIFA staff identity is omitted
|
||||
// (non-fatal, like /club dropping an unrenderable card) rather than emitted
|
||||
@@ -211,11 +228,13 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
.as_ref()
|
||||
.and_then(|m| ident.resolve_staff(m).map(|id| (m, id)))
|
||||
{
|
||||
Some((mgr, id)) => json!([{
|
||||
"id": id.item_id,
|
||||
"itemData": shape_staff_item(id, mgr.contract_matches.unwrap_or(STAFF_CONTRACT)),
|
||||
"dream": false,
|
||||
}]),
|
||||
Some((mgr, id)) => {
|
||||
let mut item = shape_staff_item(id, mgr.contract_matches.unwrap_or(STAFF_CONTRACT));
|
||||
if let Some(obj) = item.as_object_mut() {
|
||||
obj.insert("dream".to_string(), json!(false));
|
||||
}
|
||||
json!([item])
|
||||
}
|
||||
None => json!([]),
|
||||
};
|
||||
let squad = json!({
|
||||
@@ -235,15 +254,76 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
Ok(SquadProjection::Projected(squad))
|
||||
}
|
||||
|
||||
/// The active club items for `squad.actives`, in slot order.
|
||||
///
|
||||
/// ## Why this exists, and why it is NOT `[]`
|
||||
///
|
||||
/// `actives` is the ONLY carrier that makes a club item resident in FIFA 17.
|
||||
/// The squad parser's arm for atom 11 (`actives`) computes the address of the
|
||||
/// i-th element of the client's five-element club-item array and hands it to the
|
||||
/// item deserializer as the out-handle:
|
||||
///
|
||||
/// ```text
|
||||
/// cmp edi,0x5 ; at most five entries are read
|
||||
/// jge <skip>
|
||||
/// mov rax,QWORD PTR [r13+0x108] ; the club-item array
|
||||
/// lea rcx,[rax+rcx*8] ; &array[edi] (edi * 24)
|
||||
/// call 0x18013fe00 ; the item deserializer, writing that slot
|
||||
/// ```
|
||||
///
|
||||
/// That deserializer inserts the record into the client's resident item map
|
||||
/// (keyed by wire instance id, and its only gate is a non-zero id) and binds the
|
||||
/// slot handle to it. So each element must be a FULL item object, exactly like
|
||||
/// a `squad.manager[]` element — which reaches this same deserializer the same
|
||||
/// way, called directly on the array element with no `itemData` step. An id
|
||||
/// reference alone installs nothing, because the installer looks its id up in
|
||||
/// that same map and does nothing when it misses.
|
||||
///
|
||||
/// An empty array makes the client read the array-end token immediately and
|
||||
/// parse nothing, which leaves all five slots null. Every later consumer then
|
||||
/// resolves to the client's static not-found sentinel, whose item pointer is
|
||||
/// NULL — which is exactly why the pre-match kit selector had no kits.
|
||||
///
|
||||
/// Elements are shaped by the shared [`shape_club_item`], the same primitive
|
||||
/// `/club?type=kit` uses, so the two routes cannot drift. Ordering is positional
|
||||
/// on the wire but not semantic: both client consumers (the activate path and
|
||||
/// the store lookup) search the five slots by content — itemState, or
|
||||
/// cardtype/cardsubtypeid — never by index.
|
||||
///
|
||||
/// A designated kit whose owned row or FIFA kit identity cannot be resolved is
|
||||
/// omitted rather than emitted with a fabricated id, matching `/club`.
|
||||
pub fn squad_actives<I: ItemIdentityResolver + ?Sized>(
|
||||
owned: &HashMap<String, CoreOwnedItem>,
|
||||
ident: &I,
|
||||
active_kits: ActiveKitAssignments<'_>,
|
||||
) -> Value {
|
||||
let mut out = Vec::new();
|
||||
for (owned_card_id, state) in [
|
||||
(active_kits.home, item_state::ACTIVE_HOME_KIT),
|
||||
(active_kits.away, item_state::ACTIVE_AWAY_KIT),
|
||||
] {
|
||||
let Some(owned_card_id) = owned_card_id else {
|
||||
continue;
|
||||
};
|
||||
let Some(item) = owned.get(owned_card_id) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(id) = ident.resolve_kit(item) {
|
||||
out.push(shape_club_item(id, state));
|
||||
}
|
||||
}
|
||||
Value::Array(out)
|
||||
}
|
||||
|
||||
/// Wrap a projected squad object into the `userMassInfo.squad` shape, injecting
|
||||
/// the session-envelope fields the projector does not own (`personaId`, plus the
|
||||
/// observed constants `changed: 0`, `actives: []`).
|
||||
pub fn user_mass_info_squad(projected: Value, persona_id: i64) -> Value {
|
||||
/// the session-envelope fields the projector does not own: `personaId`, the
|
||||
/// observed constant `changed: 0`, and `actives` from [`squad_actives`].
|
||||
pub fn user_mass_info_squad(projected: Value, persona_id: i64, actives: Value) -> Value {
|
||||
let mut obj = projected;
|
||||
if let Value::Object(map) = &mut obj {
|
||||
map.insert("personaId".into(), json!(persona_id));
|
||||
map.insert("changed".into(), json!(0));
|
||||
map.insert("actives".into(), json!([]));
|
||||
map.insert("actives".into(), actives);
|
||||
}
|
||||
obj
|
||||
}
|
||||
@@ -525,14 +605,13 @@ mod tests {
|
||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||
panic!("expected Projected");
|
||||
};
|
||||
// The item must ride ALONG with the ref: a bare `{id, dream}` left the
|
||||
// pre-match squad with no manager even though the assignment had been
|
||||
// saved, because nothing in the response described the card.
|
||||
// The element IS the item: the squad parser's manager branch calls the
|
||||
// item parser on the array element itself, with no `itemData` step, so
|
||||
// the fields must be flat. Nesting them left `resourceId` — the merge
|
||||
// key — at 0 on a cold client and the slot rendered empty.
|
||||
assert_eq!(
|
||||
v["manager"],
|
||||
json!([{
|
||||
"id": 100000427,
|
||||
"itemData": {
|
||||
"id": 100000427,
|
||||
"resourceId": 1_000_509,
|
||||
"cardsubtypeid": 4,
|
||||
@@ -544,10 +623,20 @@ mod tests {
|
||||
"itemState": "free",
|
||||
"owners": 1,
|
||||
"untradeable": false,
|
||||
},
|
||||
"dream": false,
|
||||
}]),
|
||||
"manager is the ownership-backed wire ref WITH its item"
|
||||
"manager element is a bare item object carrying `dream`"
|
||||
);
|
||||
let element = &v["manager"][0];
|
||||
assert!(
|
||||
element.get("itemData").is_none(),
|
||||
"an `itemData` wrapper is never descended into on the manager path, \
|
||||
so its presence means the merge key is invisible to the client"
|
||||
);
|
||||
assert_eq!(
|
||||
element["resourceId"], 1_000_509,
|
||||
"resourceId must be readable at element level: it is the merge key \
|
||||
compared RAW against carddbid, and 0 resolves no manager"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,16 +24,18 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use openfut_adapter_fifa17::fut::club_response::ActiveKitAssignments;
|
||||
use openfut_adapter_fifa17::fut::item::{
|
||||
CoreOwnedItem, Fifa17Identity, Fifa17StaffIdentity, ItemIdentityResolver, STAFF_CONTRACT,
|
||||
CoreOwnedItem, Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver,
|
||||
STAFF_CONTRACT,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, Fifa17SquadPut, SquadWireResolver};
|
||||
use openfut_adapter_fifa17::fut::squad_ext::{build_squad_write, SquadWriteBuild};
|
||||
use openfut_adapter_fifa17::fut::squad_projection::{
|
||||
project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
|
||||
project_squad, squad_actives, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
|
||||
SquadProjection, SquadProjectionInput,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const PUT_BASELINE: &str = include_str!("../fixtures/utas/squad_put_f442.json");
|
||||
const PUT_SWAP: &str = include_str!("../fixtures/utas/squad_put_swap_f442.json");
|
||||
@@ -408,10 +410,13 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
||||
}
|
||||
// EXTENSION + SHADOW: sourced from the read, so they round-trip identically.
|
||||
assert_eq!(projected["custom"], oracle["custom"]);
|
||||
// The manager REF round-trips; the item now rides with it. The capture this
|
||||
// oracle came from carried a bare `{id, dream}`, but its manager was the
|
||||
// dangling one every retail capture has, so it never showed that a populated
|
||||
// ref renders on its own — and in practice it did not.
|
||||
// The manager REF round-trips; the item now rides AT ELEMENT LEVEL. The
|
||||
// capture this oracle came from carried a bare `{id, dream}`, but its
|
||||
// manager was the dangling one every retail capture has, so it never showed
|
||||
// that a populated ref renders on its own — and in practice it did not.
|
||||
// Wrapping the fields in `itemData` did not work either: the squad parser's
|
||||
// manager branch calls the item parser on the element itself, so a nested
|
||||
// item is never read and the merge key stays 0.
|
||||
assert_eq!(
|
||||
projected["manager"][0]["id"], oracle["manager"][0]["id"],
|
||||
"the manager wire ref itself must still round-trip"
|
||||
@@ -420,8 +425,11 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
||||
projected["manager"][0]["dream"],
|
||||
oracle["manager"][0]["dream"]
|
||||
);
|
||||
let mgr_item = &projected["manager"][0]["itemData"];
|
||||
assert_eq!(mgr_item["id"], oracle["manager"][0]["id"]);
|
||||
let mgr_item = &projected["manager"][0];
|
||||
assert!(
|
||||
mgr_item.get("itemData").is_none(),
|
||||
"the manager element IS the item; a wrapper hides the merge key"
|
||||
);
|
||||
assert_eq!(mgr_item["cardsubtypeid"], 4);
|
||||
assert_eq!(mgr_item["resourceId"], 1_000_509);
|
||||
assert_eq!(
|
||||
@@ -503,11 +511,17 @@ fn one_projector_serves_every_endpoint_no_divergence() {
|
||||
&ident,
|
||||
);
|
||||
|
||||
// userMassInfo.squad = the projected object + session envelope.
|
||||
let ummi = user_mass_info_squad(projected.clone(), 33068179);
|
||||
// userMassInfo.squad = the projected object + session envelope. `actives` is
|
||||
// supplied by the caller now, so the envelope must carry it through verbatim
|
||||
// rather than hardcoding an empty array.
|
||||
let actives = json!([{ "id": 100004874, "itemState": "activeHomeKit" }]);
|
||||
let ummi = user_mass_info_squad(projected.clone(), 33068179, actives.clone());
|
||||
assert_eq!(ummi["personaId"], 33068179);
|
||||
assert_eq!(ummi["changed"], 0);
|
||||
assert!(ummi["actives"].is_array());
|
||||
assert_eq!(
|
||||
ummi["actives"], actives,
|
||||
"the envelope must pass actives through, not replace it"
|
||||
);
|
||||
assert_eq!(ummi["players"], projected["players"], "same projected body");
|
||||
assert_eq!(ummi["formation"], projected["formation"]);
|
||||
|
||||
@@ -530,3 +544,153 @@ fn one_projector_serves_every_endpoint_no_divergence() {
|
||||
// The summary carries only those six keys — no divergent squad shape.
|
||||
assert_eq!(entry.as_object().unwrap().len(), 6);
|
||||
}
|
||||
|
||||
// ---- squad.actives: the only carrier that makes a club item resident --------
|
||||
|
||||
/// A resolver that can answer `resolve_kit`, which the default trait method
|
||||
/// cannot (it returns `None` for player-only resolvers).
|
||||
struct KitIdentity(HashMap<String, Fifa17KitIdentity>);
|
||||
impl ItemIdentityResolver for KitIdentity {
|
||||
fn resolve(&self, _it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_kit(&self, it: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
|
||||
self.0.get(&it.owned_card_id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
fn kit_owned(owned_card_id: &str) -> CoreOwnedItem {
|
||||
CoreOwnedItem {
|
||||
owned_card_id: owned_card_id.to_string(),
|
||||
card_id: format!("def-{owned_card_id}"),
|
||||
rating: 0,
|
||||
position: String::new(),
|
||||
nation: String::new(),
|
||||
league: String::new(),
|
||||
club: String::new(),
|
||||
attributes: [0; 6],
|
||||
contract_matches: None,
|
||||
source_rating: None,
|
||||
core_content_kind: Some("kit".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn home_away_fixture() -> (HashMap<String, CoreOwnedItem>, KitIdentity) {
|
||||
let owned = HashMap::from([
|
||||
("oc-home".to_string(), kit_owned("oc-home")),
|
||||
("oc-away".to_string(), kit_owned("oc-away")),
|
||||
]);
|
||||
// Real `fcc_kitcards` rows for team 21: 6300006 is the home card (category 2,
|
||||
// assetid 14) and 6400003 the away card (category 3, assetid 15).
|
||||
let ident = KitIdentity(HashMap::from([
|
||||
(
|
||||
"oc-home".to_string(),
|
||||
Fifa17KitIdentity {
|
||||
item_id: 100004874,
|
||||
asset_id: 14,
|
||||
resource_id: 6300006,
|
||||
card_asset_id: 35,
|
||||
subtype: 9,
|
||||
team_id: 21,
|
||||
category: 2,
|
||||
year: 0,
|
||||
},
|
||||
),
|
||||
(
|
||||
"oc-away".to_string(),
|
||||
Fifa17KitIdentity {
|
||||
item_id: 100004873,
|
||||
asset_id: 15,
|
||||
resource_id: 6400003,
|
||||
card_asset_id: 35,
|
||||
subtype: 9,
|
||||
team_id: 21,
|
||||
category: 3,
|
||||
year: 0,
|
||||
},
|
||||
),
|
||||
]));
|
||||
(owned, ident)
|
||||
}
|
||||
|
||||
/// The client's squad parser reads at most five `actives` entries and parses each
|
||||
/// one straight into a slot of its five-element club-item array, so each element
|
||||
/// must be a full item object carrying a non-zero `id` — an id reference alone
|
||||
/// installs nothing.
|
||||
#[test]
|
||||
fn squad_actives_emits_full_items_for_the_designated_kits() {
|
||||
let (owned, ident) = home_away_fixture();
|
||||
let actives = squad_actives(
|
||||
&owned,
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("oc-home"),
|
||||
away: Some("oc-away"),
|
||||
},
|
||||
);
|
||||
let arr = actives.as_array().expect("actives is an array");
|
||||
assert_eq!(arr.len(), 2, "one entry per designated kit");
|
||||
|
||||
assert_eq!(arr[0]["id"], 100004874);
|
||||
assert_eq!(arr[0]["itemState"], "activeHomeKit");
|
||||
assert_eq!(arr[0]["resourceId"], 6300006);
|
||||
assert_eq!(arr[1]["id"], 100004873);
|
||||
assert_eq!(arr[1]["itemState"], "activeAwayKit");
|
||||
assert_eq!(arr[1]["resourceId"], 6400003);
|
||||
|
||||
for entry in arr {
|
||||
assert_eq!(entry["itemType"], "kit");
|
||||
assert_eq!(
|
||||
entry["cardsubtypeid"], 9,
|
||||
"cardsubtypeid 9 derives cardtype 7"
|
||||
);
|
||||
assert_eq!(entry["teamid"], 21, "the clone path keys kit art on teamid");
|
||||
assert_ne!(entry["id"], 0, "a zero id is never made resident");
|
||||
}
|
||||
}
|
||||
|
||||
/// Undesignated slots contribute nothing, and an unresolvable designation is
|
||||
/// omitted rather than emitted with a fabricated id — the same policy `/club`
|
||||
/// applies when a card has no FIFA identity.
|
||||
#[test]
|
||||
fn squad_actives_omits_absent_and_unresolvable_designations() {
|
||||
let (owned, ident) = home_away_fixture();
|
||||
|
||||
let home_only = squad_actives(
|
||||
&owned,
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("oc-home"),
|
||||
away: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(home_only.as_array().unwrap().len(), 1);
|
||||
assert_eq!(home_only[0]["itemState"], "activeHomeKit");
|
||||
|
||||
// Designated but not present in the owned collection.
|
||||
let dangling = squad_actives(
|
||||
&owned,
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("oc-missing"),
|
||||
away: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(dangling.as_array().unwrap().len(), 0);
|
||||
|
||||
// Present and designated, but with no resolvable FIFA kit identity.
|
||||
let unresolvable = squad_actives(
|
||||
&HashMap::from([("oc-x".to_string(), kit_owned("oc-x"))]),
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("oc-x"),
|
||||
away: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(unresolvable.as_array().unwrap().len(), 0);
|
||||
|
||||
// Nothing designated at all is an empty array, which is what left every
|
||||
// club-item slot null before this projector existed.
|
||||
let none = squad_actives(&owned, &ident, ActiveKitAssignments::default());
|
||||
assert_eq!(none.as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
+1
-1
Submodule openfut-core updated: 90210702c3...20e281e0cf
+1
-1
Submodule openfut-launcher updated: 5294f589ad...bee97055db
@@ -59,6 +59,30 @@ pub struct HostConfig {
|
||||
/// Disabled by default. Non-off values require three explicit staging guards;
|
||||
/// see [`parse_sbc_post_commit_fault`].
|
||||
pub sbc_post_commit_fault: SbcPostCommitFault,
|
||||
/// Emit the club's active club items in `squad.actives`. **Enabled by
|
||||
/// default** — this is normal, correct FIFA 17 behaviour, not an experiment.
|
||||
///
|
||||
/// `actives` is the carrier that makes a club item resident: the squad
|
||||
/// parser writes each element straight into a native club-item slot. With it
|
||||
/// populated the pre-match kit selector works, proven end to end on a retail
|
||||
/// client — 29 resident nodes with both cardtype-7 kits in club slots 0 and 1
|
||||
/// (`itemState` 101/102, category 4) alongside 23/23 players and the manager,
|
||||
/// and the selector rendering the correct distinct home and away kits.
|
||||
///
|
||||
/// Scope is deliberately narrow: [`squad_actives`] emits ONLY the home and
|
||||
/// away kit, both resolved from Core's own active designations and required
|
||||
/// to be genuinely owned. Badge, ball and stadium are never emitted, so
|
||||
/// enabling this cannot surface an unproven active family.
|
||||
///
|
||||
/// History, so the default is not naively flipped back: an early build was
|
||||
/// once seen to empty the squad when this array was populated (resident map
|
||||
/// down to the 2 kits, player vector fully null). That never reproduced, and
|
||||
/// it can no longer cause durable damage — both squad write-back paths are
|
||||
/// guarded in Core (`refuse to empty a populated squad`, and an absent
|
||||
/// manager field no longer meaning "clear").
|
||||
///
|
||||
/// Set `OPENFUT_FIFA17_SQUAD_ACTIVES=0` to force it off for diagnostics.
|
||||
pub squad_actives: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -91,6 +115,17 @@ fn required_i64_nonzero(key: &str) -> Result<i64, ConfigError> {
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
/// Whether to emit `squad.actives`. Correct FIFA 17 behaviour is ON, so an
|
||||
/// absent or unrecognised value means ON; only an explicit `0`/`false`/`off`/`no`
|
||||
/// turns it off, which exists for diagnostics. See
|
||||
/// [`HostConfig::squad_actives`].
|
||||
fn parse_squad_actives(raw: Option<&str>) -> bool {
|
||||
!matches!(
|
||||
raw.unwrap_or_default().trim().to_ascii_lowercase().as_str(),
|
||||
"0" | "false" | "off" | "no"
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_sbc_post_commit_fault(
|
||||
raw: Option<&str>,
|
||||
environment: Option<&str>,
|
||||
@@ -177,6 +212,9 @@ impl HostConfig {
|
||||
clientdata_path,
|
||||
account_path,
|
||||
sbc_post_commit_fault,
|
||||
squad_actives: parse_squad_actives(
|
||||
env::var("OPENFUT_FIFA17_SQUAD_ACTIVES").ok().as_deref(),
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -205,6 +243,34 @@ fn default_account_path(identity_store_path: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `squad.actives` is ON without any environment variable.
|
||||
///
|
||||
/// Emitting the club's active home/away kit is normal FIFA 17 behaviour —
|
||||
/// it is what lets the client make the kits resident and render the
|
||||
/// pre-match selector — so a correct deployment must not have to opt in.
|
||||
/// The off switch survives only as a diagnostic.
|
||||
#[test]
|
||||
fn squad_actives_is_on_by_default_and_only_explicitly_disabled() {
|
||||
// The case that matters: nothing configured at all.
|
||||
assert!(
|
||||
parse_squad_actives(None),
|
||||
"a deployment that sets nothing must still emit squad.actives"
|
||||
);
|
||||
assert!(parse_squad_actives(Some("")));
|
||||
assert!(parse_squad_actives(Some(" ")));
|
||||
|
||||
// Explicit disable, for diagnostics.
|
||||
for off in ["0", "false", "off", "no", "OFF", " False "] {
|
||||
assert!(!parse_squad_actives(Some(off)), "{off} must disable");
|
||||
}
|
||||
|
||||
// Explicit enable stays valid, and anything unrecognised stays ON
|
||||
// rather than silently disabling the feature.
|
||||
for on in ["1", "true", "on", "yes", "TRUE", "banana"] {
|
||||
assert!(parse_squad_actives(Some(on)), "{on} must leave it enabled");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sbc_post_commit_faults_require_all_staging_guards() {
|
||||
assert_eq!(
|
||||
|
||||
+606
-54
@@ -76,12 +76,16 @@ use openfut_adapter_fifa17::fut::owned_query::{
|
||||
use openfut_adapter_fifa17::fut::pack_content::GeneratedCandidate;
|
||||
use openfut_adapter_fifa17::fut::sbc as fifa17_sbc;
|
||||
use openfut_adapter_fifa17::fut::season_wire;
|
||||
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, save_ack, SquadWireResolver};
|
||||
use openfut_adapter_fifa17::fut::squad::{
|
||||
classify_squad_put, save_ack, Fifa17SquadPut, Fifa17SquadRolePatch, SquadMutation,
|
||||
SquadWireResolver,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::squad_ext::{
|
||||
build_squad_write, Fifa17SquadExtensionV1, SquadBuildError, EXT_NAMESPACE, EXT_SCHEMA_VERSION,
|
||||
build_squad_write, Fifa17SquadExtensionV1, KicktakerRef, SquadBuildError, WireItemRef,
|
||||
EXT_NAMESPACE, EXT_SCHEMA_VERSION,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::squad_projection::{
|
||||
project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
|
||||
project_squad, squad_actives, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
|
||||
SquadProjection, SquadProjectionInput,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::store_catalog::{
|
||||
@@ -188,6 +192,12 @@ pub enum Route {
|
||||
/// the oracle's `defs_route`/`item_def`. The client renders the real card from
|
||||
/// its LOCAL DB, so a valid-shaped placeholder is exact parity.
|
||||
ItemDefs,
|
||||
/// `GET …/item` — FutViewCards. DISTINCT from [`Route::ItemDefs`]: the client
|
||||
/// builds `?idList=%lld` from OWNED INSTANCE ids and expects the owned items
|
||||
/// back, carrying their real `resourceId`, `cardsubtypeid` and `itemState`.
|
||||
/// Answering it with definition placeholders tells the client its active kit
|
||||
/// is a free player. See [`UtasHost::handle_view_cards`].
|
||||
ViewCards,
|
||||
/// `GET …/marketdata` and `…/marketdata/pricelimits` — suggested pricing.
|
||||
/// `/pricelimits` MUST be a bare ARRAY (one `{defId,minPrice,maxPrice}` per
|
||||
/// queried defId); plain `/marketdata` MUST be an OBJECT `{minPrice,maxPrice}`.
|
||||
@@ -200,8 +210,8 @@ pub enum Route {
|
||||
|
||||
/// Classify a request ONCE, before execution. Rust owns complete route families;
|
||||
/// there is no "try Rust then Python", so a mutation can never be double-applied.
|
||||
/// Numeric `GET …/squad/<n>` follows the oracle's single-current-squad behavior:
|
||||
/// every numeric id returns the one Core-backed active squad.
|
||||
/// Numeric `…/squad/<n>` resolves to the one Core-backed squad. That is SAFE
|
||||
/// rather than authentic — see [`is_numeric_squad_tail`].
|
||||
pub fn classify(method: &str, path: &str) -> Route {
|
||||
let get = method.eq_ignore_ascii_case("GET");
|
||||
let put = method.eq_ignore_ascii_case("PUT");
|
||||
@@ -231,7 +241,15 @@ pub fn classify(method: &str, path: &str) -> Route {
|
||||
Some("squad/list") if get => Route::SquadList,
|
||||
Some("squad/active") if get => Route::SquadActive,
|
||||
Some(tail) if get && is_numeric_squad_tail(tail) => Route::SquadActive,
|
||||
Some("userMassInfo") if get => Route::UserMassInfo,
|
||||
// The retail client sends BOTH casings: a lowercase `usermassinfo`
|
||||
// followed immediately by the canonical `userMassInfo`. Matching the
|
||||
// literal only meant the lowercase one fell through to the Python
|
||||
// upstream — a 502 here, but on a deployment with Python alive it would
|
||||
// be ANSWERED there, silently splitting authority away from Rust for a
|
||||
// route Core owns. Matched case-insensitively for this tail only; the
|
||||
// rest of the table stays exact, since no other route has shown a
|
||||
// casing variant.
|
||||
Some(t) if get && t.eq_ignore_ascii_case("usermassinfo") => Route::UserMassInfo,
|
||||
Some("user/club") if put || post => Route::ClubRename,
|
||||
Some(tail) if tail.starts_with("clientdata/") => Route::ClientData,
|
||||
Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup,
|
||||
@@ -292,8 +310,30 @@ pub fn classify(method: &str, path: &str) -> Route {
|
||||
Some("champion") if get => Route::FeatureOffEmpty,
|
||||
Some("clubUser") if get => Route::FeatureOffEmpty,
|
||||
Some("user/list") if get => Route::FeatureOffEmpty,
|
||||
Some("item/resource") if get => Route::ItemDefs,
|
||||
Some("defid") if get => Route::ItemDefs,
|
||||
Some(t) if get && (t == "item/resource" || t.starts_with("item/resource?")) => {
|
||||
Route::ItemDefs
|
||||
}
|
||||
Some(t) if get && (t == "defid" || t.starts_with("defid?")) => Route::ItemDefs,
|
||||
// `GET ut/%s/item` is FutViewCards (deser `0x1801293d0`, top-level
|
||||
// `itemData` via the shared card element `0x18013fe00`). It is NOT the
|
||||
// definition lookup, and answering it as one is a real defect — see
|
||||
// [`Self::handle_view_cards`]. The client builds it as `?idList=%lld`
|
||||
// (CardsDLL `.rdata` 0x220080) carrying OWNED INSTANCE ids.
|
||||
//
|
||||
// It was unclaimed entirely until 2026-08-23, so it fell through to the
|
||||
// Python upstream: invisible in production where the oracle answers, and a
|
||||
// 502 on staging — the same dead-route class already found four times
|
||||
// (season/list, watchList, the handle_static_ack tails, tournament/user).
|
||||
//
|
||||
// The `?` forms are not decoration: `ut_tail` does NOT strip the query, so
|
||||
// a bare equality arm silently misses every real request while passing a
|
||||
// no-query unit test. That is how this stayed unclaimed. The oracle's own
|
||||
// pattern is `item(\?|$)` (`tools/utas_server.py:1419`).
|
||||
//
|
||||
// MUST stay below `item/resource` (matched first) and must not swallow
|
||||
// `item/<id>`, which is DELETE-only Quick Sell, nor PUT `item`, which is
|
||||
// FutMoveCard on the economy path.
|
||||
Some(t) if get && (t == "item" || t.starts_with("item?")) => Route::ViewCards,
|
||||
Some(t) if get && (t == "marketdata" || t.starts_with("marketdata/")) => Route::MarketData,
|
||||
_ => Route::Passthrough,
|
||||
}
|
||||
@@ -367,9 +407,35 @@ fn is_exact_club_path(path: &str) -> bool {
|
||||
ut_tail(path) == Some("club")
|
||||
}
|
||||
|
||||
/// `squad/<digits>` — the numeric full-squad target used by PUT and GET. Core
|
||||
/// stores one current squad, matching the oracle: every numeric GET returns that
|
||||
/// same squad regardless of the requested id.
|
||||
/// `squad/<digits>` — the numeric full-squad target used by PUT and GET.
|
||||
///
|
||||
/// Every numeric id resolves to the one Core-backed squad. Be precise about why
|
||||
/// that is acceptable: it is SAFE, not authentic.
|
||||
///
|
||||
/// FIFA 17 genuinely has multi-squad semantics. The client ships
|
||||
/// `SelectSquadById`, `RetrieveSquad` as an action DISTINCT from
|
||||
/// `LoadActiveSquad`, plus `CreateSquadWithName`, `RenameSquad`, `DeleteSquad`,
|
||||
/// `CopySquad`, indexed `SQUAD_ID-%d` list entries and a
|
||||
/// `FUT_MAX_NUM_SQUAD_REACHED` string. The number is therefore a real squad
|
||||
/// identifier, not a placeholder.
|
||||
///
|
||||
/// It is unreachable here because we advertise exactly ONE squad:
|
||||
/// [`ACTIVE_SQUAD_WIRE_ID`] is a constant `0`, `/squad/list` returns a
|
||||
/// single-element array carrying that id, and no create/rename/delete/copy
|
||||
/// route exists. The client can only ever echo back the id we gave it — every
|
||||
/// numeric path observed in retained captures is `squad/0`, all of them PUTs
|
||||
/// whose body `id` also reads 0, and no numeric GET has ever been recorded.
|
||||
///
|
||||
/// So collapsing the id costs nothing TODAY and is pinned by
|
||||
/// `numeric_squad_routing_is_safe_only_while_one_squad_is_advertised`. The
|
||||
/// moment a second squad becomes addressable, that test must fail and this
|
||||
/// routing must gain a real lookup. Do NOT widen squad support without
|
||||
/// revisiting it.
|
||||
///
|
||||
/// Unknown-id behaviour is deliberately NOT invented: no FIFA 17 evidence shows
|
||||
/// what the client expects for an id it was never given. (The FIFA 14 oracle
|
||||
/// Impulsum14 returns an empty squad carrying that id, never the active one —
|
||||
/// hypothesis only, not FIFA 17 truth.)
|
||||
fn is_numeric_squad_tail(tail: &str) -> bool {
|
||||
match tail.strip_prefix("squad/") {
|
||||
Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),
|
||||
@@ -748,6 +814,21 @@ pub struct CoreReplaceRequest {
|
||||
pub ext_payload: String,
|
||||
}
|
||||
|
||||
/// A role-only squad patch: captain plus the opaque extension, nothing else.
|
||||
///
|
||||
/// Deliberately has no `slots`, `name`, `formation` or manager field — the
|
||||
/// absence is structural, so this request cannot express a squad replacement
|
||||
/// even by mistake.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoreRolePatchRequest {
|
||||
/// Owned instance to flag as captain. `None` leaves the captain unchanged;
|
||||
/// it is never a request to clear it.
|
||||
pub captain_owned_card_id: Option<String>,
|
||||
pub ext_namespace: String,
|
||||
pub ext_schema_version: i64,
|
||||
pub ext_payload: String,
|
||||
}
|
||||
|
||||
/// Client-reported shadow evaluation carried through to Core (never Core's
|
||||
/// authoritative evaluation).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -816,6 +897,18 @@ pub trait CoreAccess: Send + Sync {
|
||||
/// Replace the active squad's canonical slots + opaque extension atomically.
|
||||
fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError>;
|
||||
|
||||
/// Patch ONLY the active squad's role assignments (captain) + opaque
|
||||
/// extension. Never touches player assignments, the manager, or actives.
|
||||
///
|
||||
/// Separate from [`Self::replace_squad`] because they are different
|
||||
/// operations: a role-only client update carries no slot array, and sending
|
||||
/// it as a replacement presents zero slots to Core's empty-replacement
|
||||
/// guard. Default is `Status(501)` so a transport that has not implemented
|
||||
/// it fails loudly instead of silently degrading into a replacement.
|
||||
fn patch_squad_roles(&self, _req: &CoreRolePatchRequest) -> Result<(), CoreError> {
|
||||
Err(CoreError::Status(501))
|
||||
}
|
||||
|
||||
/// The owned instance id assigned as the active squad's **manager**, or
|
||||
/// `None` (`GET /club/manager`). Default: `None` — a transport without the
|
||||
/// endpoint simply projects no manager (non-fatal, like an absent
|
||||
@@ -824,11 +917,19 @@ pub trait CoreAccess: Send + Sync {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Assign (`Some`) or clear (`None`) the active squad's **manager**
|
||||
/// (`PUT /club/manager`). Default: unimplemented — the production
|
||||
/// `HttpCoreClient` overrides it; a transport that cannot persist the
|
||||
/// assignment MUST fail loudly rather than silently drop it.
|
||||
fn set_squad_manager(&self, _owned_card_id: Option<&str>) -> Result<(), CoreError> {
|
||||
/// ASSIGN the active squad's **manager** (`PUT /club/manager`). Default:
|
||||
/// unimplemented — the production `HttpCoreClient` overrides it; a transport
|
||||
/// that cannot persist the assignment MUST fail loudly rather than silently
|
||||
/// drop it.
|
||||
///
|
||||
/// Deliberately takes `&str`, NOT `Option<&str>`: there is no FIFA 17 wire
|
||||
/// shape that removes a manager. The client always sends a manager ref, so
|
||||
/// "no ownership-backed manager in this save" means the ref was missing or
|
||||
/// unmappable — never that the user cleared the slot. Passing `None` here
|
||||
/// used to be forwarded as an explicit null and DELETED the assignment; that
|
||||
/// is how a client with a destroyed squad model wiped a real manager row.
|
||||
/// The capability is gone at the type level so the host cannot express it.
|
||||
fn set_squad_manager(&self, _owned_card_id: &str) -> Result<(), CoreError> {
|
||||
Err(CoreError::Parse(
|
||||
"Core squad manager write is not implemented".into(),
|
||||
))
|
||||
@@ -980,6 +1081,29 @@ impl CoreAccess for HttpCoreClient {
|
||||
})
|
||||
}
|
||||
|
||||
fn patch_squad_roles(&self, req: &CoreRolePatchRequest) -> Result<(), CoreError> {
|
||||
let url = format!("{}/squad/roles", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.put(&url)
|
||||
.header("X-OpenFUT-Game", &self.game)
|
||||
.json(&json!({
|
||||
"captain_owned_card_id": req.captain_owned_card_id,
|
||||
"extension": {
|
||||
"namespace": req.ext_namespace,
|
||||
"schema_version": req.ext_schema_version,
|
||||
"payload": req.ext_payload,
|
||||
},
|
||||
}))
|
||||
.send()
|
||||
.map_err(|e| CoreError::Http(e.to_string()))?;
|
||||
let status = resp.status().as_u16();
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(CoreError::Status(status));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
|
||||
let url = format!("{}/club/manager", self.base_url);
|
||||
let resp = self
|
||||
@@ -1011,7 +1135,7 @@ impl CoreAccess for HttpCoreClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> {
|
||||
fn set_squad_manager(&self, owned_card_id: &str) -> Result<(), CoreError> {
|
||||
let url = format!("{}/club/manager", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
@@ -2092,11 +2216,15 @@ impl ItemIdentityResolver for Fifa17IdentityResolver {
|
||||
}
|
||||
Some(Fifa17KitIdentity {
|
||||
item_id: self.wire_for(item)?,
|
||||
asset_id: ident.asset_id,
|
||||
// The club-item wire `assetId` is family specific and is NOT the
|
||||
// carddbid — see `Fifa17CardIdentity::club_asset_id`.
|
||||
asset_id: ident.club_asset_id,
|
||||
resource_id: ident.resource_id,
|
||||
card_asset_id: ident.card_asset_id,
|
||||
subtype: ident.subtype,
|
||||
team_id: ident.team_id,
|
||||
category: ident.category,
|
||||
year: ident.year,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2309,17 +2437,39 @@ fn club_type_filter(token: Option<&str>) -> Option<ClubTypeFilter> {
|
||||
Some("stadium") => ClubTypeFilter::kind("stadium", ContentKind::Stadium),
|
||||
Some("ball") => ClubTypeFilter::kind("ball", ContentKind::Ball),
|
||||
Some("misc") => ClubTypeFilter::kind("misc", ContentKind::Misc),
|
||||
// WITHHELD, each for a recorded reason.
|
||||
Some("equippables") => ClubTypeFilter {
|
||||
label: "equippables",
|
||||
// The combined customisation view, and the one response that has ever
|
||||
// crashed this client: 30 items across five families at once
|
||||
// (2026-08-05). A single-family answer here is not available either —
|
||||
// the view is by definition multi-family — and the kit swap it feeds
|
||||
// needs `item+0x60 == 4`, which a server can never produce (it can
|
||||
// only ever produce 1 = club and 6 = purchased). So this stays empty
|
||||
// until that is a client-side change, not a wire one.
|
||||
selector: ClubSelector::Withheld("multi_family_crash_2026_08_05"),
|
||||
// The combined club-customisation view, and the screen the My Club
|
||||
// KITS tab actually asks for. It is ALSO the one response that has
|
||||
// ever crashed this client — but the crash was 30 items across five
|
||||
// families at once (2026-08-05), not this arm.
|
||||
//
|
||||
// RESOLVED 2026-08-23, operator-confirmed on the retail client: with
|
||||
// this arm answering KITS ONLY, the My Club kit tab renders both kits
|
||||
// for the first time. Before it, the tab asked `?type=equippables`,
|
||||
// got the withheld empty body, and showed nothing — which is exactly
|
||||
// what "0 kits in my club" was.
|
||||
//
|
||||
// Two things had to be true together, so neither alone is the fix:
|
||||
// * this arm answers, and
|
||||
// * `GET ut/%s/item` (FutViewCards) returns the OWNED INSTANCE
|
||||
// rather than a definition placeholder, so the kit arrives with
|
||||
// `cardsubtypeid` 9 and `itemState` active{Home,Away}Kit instead
|
||||
// of "a free player" (see [`UtasHost::handle_view_cards`]).
|
||||
//
|
||||
// Still KITS ONLY, deliberately. Serving the other four families here
|
||||
// is the shape that crashed, and nothing has retested it.
|
||||
//
|
||||
// `OPENFUT_FIFA17_EQUIPPABLES=0` restores the withheld body with a
|
||||
// restart and no rebuild, because the ONLY live evidence is a club
|
||||
// holding exactly TWO kits. A club holding many is untested, and the
|
||||
// crash that motivated the original withholding was about response
|
||||
// size and family mixing.
|
||||
selector: if equippables_disabled() {
|
||||
ClubSelector::Withheld("multi_family_crash_2026_08_05")
|
||||
} else {
|
||||
ClubSelector::Kind(ContentKind::Kit)
|
||||
},
|
||||
},
|
||||
Some("leaguelogos") => ClubTypeFilter {
|
||||
label: "leaguelogos",
|
||||
@@ -2600,6 +2750,8 @@ pub struct SquadDeps<'a> {
|
||||
pub core: &'a dyn CoreAccess,
|
||||
pub resolver: &'a Fifa17IdentityResolver,
|
||||
pub entities: &'a Fifa17Entities,
|
||||
/// Emit `squad.actives`. Default OFF — see [`crate::config::HostConfig`].
|
||||
pub squad_actives: bool,
|
||||
}
|
||||
|
||||
/// Secret-free structured log line for a handled squad request.
|
||||
@@ -2611,8 +2763,10 @@ pub struct SquadLog {
|
||||
|
||||
/// The active squad projected from Core, with the freshness policy applied.
|
||||
enum HostProjection {
|
||||
/// Fresh: the projected FIFA squad object (before any endpoint envelope).
|
||||
Squad(Value),
|
||||
/// Fresh: the projected FIFA squad object (before any endpoint envelope),
|
||||
/// plus the active club items for its `actives` array. They travel together
|
||||
/// because both are derived from the SAME bounded Core fetch.
|
||||
Squad { squad: Value, actives: Value },
|
||||
/// Stored extension is stale vs the canonical squad — NEVER applied.
|
||||
Stale,
|
||||
/// No extension stored — nothing fabricated.
|
||||
@@ -2695,8 +2849,38 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection {
|
||||
owned: &owned_by_id,
|
||||
manager,
|
||||
};
|
||||
// The active club designations Core already owns (`/club/active-items`), shaped
|
||||
// into the `actives` array that makes a club item resident.
|
||||
//
|
||||
// DEFAULT OFF (`HostConfig::squad_actives`). Populating this array does make
|
||||
// the kits resident and the pre-match selector work, but it was also observed
|
||||
// to cost the rest of the squad: the resident item map fell from 24 entries to
|
||||
// just the 2 kits, the 23-slot player vector came back fully null, and the
|
||||
// starting-11 screen was empty. `actives` sorts first in the squad object, so
|
||||
// everything after it is lost. Until that is understood an empty squad is far
|
||||
// worse than a missing kit.
|
||||
let actives = if !deps.squad_actives {
|
||||
json!([])
|
||||
} else {
|
||||
match deps.core.get_active_kits() {
|
||||
Ok(assignments) => squad_actives(
|
||||
&owned_by_id,
|
||||
deps.resolver,
|
||||
ActiveKitAssignments {
|
||||
home: assignments.home_owned_card_id.as_deref(),
|
||||
away: assignments.away_owned_card_id.as_deref(),
|
||||
},
|
||||
),
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"utas-host WARN active club items unavailable, squad.actives empty: {error}"
|
||||
);
|
||||
json!([])
|
||||
}
|
||||
}
|
||||
};
|
||||
match project_squad(&input, deps.resolver, deps.entities) {
|
||||
Ok(SquadProjection::Projected(v)) => HostProjection::Squad(v),
|
||||
Ok(SquadProjection::Projected(squad)) => HostProjection::Squad { squad, actives },
|
||||
Ok(SquadProjection::Stale) => HostProjection::Stale,
|
||||
Ok(SquadProjection::Missing) => HostProjection::Missing,
|
||||
Err(e) => HostProjection::Error(e.to_string()),
|
||||
@@ -2714,26 +2898,35 @@ fn error_response(status: u16, code: &str) -> WireResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUT …/squad/<n>` — parse the full replacement, reverse-resolve every wire id,
|
||||
/// AUTHORIZE every resolved item against the active club, then commit the
|
||||
/// canonical squad + FIFA extension to Core in one transaction. On any failure
|
||||
/// it returns an error and NEVER falls back to Python (no double mutation).
|
||||
/// `PUT …/squad/<n>` — dispatch on which mutation the body actually expresses.
|
||||
///
|
||||
/// FIFA 17 sends two operations down this one path and distinguishes them only
|
||||
/// by body shape (see [`SquadMutation`]). Classifying FIRST is the whole fix: a
|
||||
/// role-only update carries no `players`, and routing it into the replacement
|
||||
/// path presents zero slots to Core's empty-replacement guard, which correctly
|
||||
/// refuses it — silently losing the user's captain/kick-taker change.
|
||||
pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
|
||||
let put = match parse_squad_put(body) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return (
|
||||
match classify_squad_put(body) {
|
||||
Ok(SquadMutation::Replace(put)) => handle_squad_replace(&put, deps),
|
||||
Ok(SquadMutation::PatchRoles(patch)) => handle_squad_role_patch(&patch, deps),
|
||||
Err(e) => (
|
||||
error_response(400, "parse_error"),
|
||||
SquadLog {
|
||||
outcome: "parse_error",
|
||||
detail: e.to_string(),
|
||||
},
|
||||
)
|
||||
),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// The full-replacement path: reverse-resolve every wire id, AUTHORIZE every
|
||||
/// resolved item against the active club, then commit the canonical squad +
|
||||
/// FIFA extension to Core in one transaction. On any failure it returns an
|
||||
/// error and NEVER falls back to Python (no double mutation).
|
||||
fn handle_squad_replace(put: &Fifa17SquadPut, deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
|
||||
// Reverse-resolve wire→owned and shape canonical + extension. Refuses on an
|
||||
// unresolved wire id or the same owned item placed twice.
|
||||
let build = match build_squad_write(&put, deps.resolver) {
|
||||
let build = match build_squad_write(put, deps.resolver) {
|
||||
Ok(b) => b,
|
||||
Err(SquadBuildError::UnresolvedWireIds(ids)) => {
|
||||
return (
|
||||
@@ -2839,10 +3032,18 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
|
||||
// Persist the ownership-backed manager assignment (migration 0023). It was
|
||||
// authorized above and Core re-validates club ownership; fail loudly on a
|
||||
// transport error rather than silently dropping the manager.
|
||||
if let Err(e) = deps
|
||||
.core
|
||||
.set_squad_manager(build.canonical.manager_owned_card_id.as_deref())
|
||||
{
|
||||
// Only written when this save actually carried an ownership-backed manager.
|
||||
//
|
||||
// A save with no resolvable manager is NOT a request to remove the current
|
||||
// one. FIFA 17 has no "remove manager" wire shape — the client always sends a
|
||||
// ref — so `None` here means the ref was absent, zero, or unmappable, i.e.
|
||||
// this save says nothing about the manager. Forwarding that absence as an
|
||||
// explicit clear is exactly how a client whose squad model had been destroyed
|
||||
// deleted a real manager row (WAL commit 468, squad_managers 1 -> 0), so the
|
||||
// assignment is left untouched instead.
|
||||
match build.canonical.manager_owned_card_id.as_deref() {
|
||||
Some(mgr) => {
|
||||
if let Err(e) = deps.core.set_squad_manager(mgr) {
|
||||
return (
|
||||
error_response(502, "core_error"),
|
||||
SquadLog {
|
||||
@@ -2851,6 +3052,12 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
None => eprintln!(
|
||||
"utas-host owner=RUST route=squad-replace manager_write_skipped \
|
||||
(save carried no ownership-backed manager; existing assignment left unchanged)"
|
||||
),
|
||||
}
|
||||
(
|
||||
json_response(&save_ack(put.id)),
|
||||
SquadLog {
|
||||
@@ -2860,13 +3067,160 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
|
||||
)
|
||||
}
|
||||
|
||||
/// `PUT …/squad/<n>` carrying NO `players` — the role-only patch path.
|
||||
///
|
||||
/// Observed real-client shape: `{id, custom, captain, kicktakers[5]}`, emitted
|
||||
/// by the captain/kick-taker screen. Omission is the contract here: the absent
|
||||
/// `players`, `manager` and actives mean UNCHANGED, never cleared. That is
|
||||
/// enforced structurally — [`CoreRolePatchRequest`] has no field able to express
|
||||
/// any of them, and Core's patch issues no statement that can touch them.
|
||||
///
|
||||
/// The extension is MERGED, not overwritten: the patch body carries only
|
||||
/// `custom` and `kicktakers`, so kit numbers, squad type and the client-reported
|
||||
/// evaluation are preserved from the stored copy. Overwriting would silently
|
||||
/// drop every kit number in the squad.
|
||||
fn handle_squad_role_patch(
|
||||
patch: &Fifa17SquadRolePatch,
|
||||
deps: &SquadDeps<'_>,
|
||||
) -> (WireResponse, SquadLog) {
|
||||
// Start from the stored extension so omitted FIFA-only state survives. A
|
||||
// stale extension is still the right base: its kit numbers belong to the
|
||||
// same squad, and this patch re-anchors the fingerprint on commit.
|
||||
let read = match deps.core.read_squad_ext(EXT_NAMESPACE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return (
|
||||
error_response(502, "core_error"),
|
||||
SquadLog {
|
||||
outcome: "core_error",
|
||||
detail: e.to_string(),
|
||||
},
|
||||
)
|
||||
}
|
||||
};
|
||||
let mut ext = match &read.ext {
|
||||
CoreExtState::Fresh {
|
||||
schema_version,
|
||||
payload,
|
||||
}
|
||||
| CoreExtState::Stale {
|
||||
schema_version,
|
||||
payload,
|
||||
} => Fifa17SquadExtensionV1::from_payload(*schema_version, payload).unwrap_or_default(),
|
||||
CoreExtState::Missing => Fifa17SquadExtensionV1::default(),
|
||||
};
|
||||
|
||||
// Carry the patch's own fields through. `custom` genuinely differs between
|
||||
// the two shapes -- the role screen writes per-slot values into it -- so it
|
||||
// is taken from the patch, not preserved.
|
||||
if patch.custom.is_some() {
|
||||
ext.custom = patch.custom.clone();
|
||||
}
|
||||
ext.kicktakers = patch
|
||||
.kicktakers
|
||||
.iter()
|
||||
.map(|k| KicktakerRef {
|
||||
index: k.index,
|
||||
item: WireItemRef {
|
||||
id: k.id,
|
||||
dream: k.dream,
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Resolve + AUTHORIZE the captain before any write. Identity resolution is
|
||||
// not authorization: a globally-valid wire id belonging to another profile
|
||||
// must not become this club's captain.
|
||||
let mut captain_owned_card_id = None;
|
||||
if let Some(wire) = patch.captain.filter(|c| *c != 0) {
|
||||
match deps.resolver.owned_id_for_wire(wire) {
|
||||
Some(owned) => {
|
||||
let owned_set: std::collections::HashSet<String> = match deps.core.all_owned() {
|
||||
Ok(v) => v.into_iter().map(|i| i.owned_card_id).collect(),
|
||||
Err(e) => {
|
||||
return (
|
||||
error_response(502, "core_error"),
|
||||
SquadLog {
|
||||
outcome: "core_error",
|
||||
detail: e.to_string(),
|
||||
},
|
||||
)
|
||||
}
|
||||
};
|
||||
if !owned_set.contains(&owned) {
|
||||
return (
|
||||
error_response(403, "not_owned"),
|
||||
SquadLog {
|
||||
outcome: "unauthorized_captain",
|
||||
detail: owned,
|
||||
},
|
||||
);
|
||||
}
|
||||
captain_owned_card_id = Some(owned);
|
||||
}
|
||||
None => {
|
||||
// Refuse rather than silently patch the kicktakers alone: the
|
||||
// user asked for a captain and would otherwise be told it
|
||||
// succeeded while the captain never moved.
|
||||
return (
|
||||
error_response(400, "unresolved_captain"),
|
||||
SquadLog {
|
||||
outcome: "unresolved_captain",
|
||||
detail: wire.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let req = CoreRolePatchRequest {
|
||||
captain_owned_card_id,
|
||||
ext_namespace: EXT_NAMESPACE.to_string(),
|
||||
ext_schema_version: EXT_SCHEMA_VERSION,
|
||||
ext_payload: ext.to_payload(),
|
||||
};
|
||||
if let Err(e) = deps.core.patch_squad_roles(&req) {
|
||||
// A Core 400 means the REQUEST was invalid (e.g. a captain not fielded
|
||||
// in this squad). Reporting that as 502 would blame the server for a
|
||||
// client error and hide it behind "upstream unavailable".
|
||||
let (status, code) = match e {
|
||||
CoreError::Status(400) => (400, "invalid_role_patch"),
|
||||
_ => (502, "core_error"),
|
||||
};
|
||||
return (
|
||||
error_response(status, code),
|
||||
SquadLog {
|
||||
outcome: if status == 400 {
|
||||
"invalid_role_patch"
|
||||
} else {
|
||||
"core_error"
|
||||
},
|
||||
detail: e.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=squad-roles captain={:?} kicktakers={} \
|
||||
(players/manager/actives untouched)",
|
||||
req.captain_owned_card_id,
|
||||
ext.kicktakers.len()
|
||||
);
|
||||
(
|
||||
json_response(&save_ack(patch.id)),
|
||||
SquadLog {
|
||||
outcome: "ok",
|
||||
detail: String::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// `GET …/squad/list` — served from Core + the ONE projector. Stale/Missing are
|
||||
/// integrity failures for the migrated dev profile: logged prominently, degraded
|
||||
/// to an empty list, NEVER served from Python and NEVER projected from stale ext.
|
||||
pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
|
||||
match project_active_squad(deps) {
|
||||
HostProjection::Squad(v) => (
|
||||
json_response(&squad_list(&v)),
|
||||
HostProjection::Squad { squad, .. } => (
|
||||
json_response(&squad_list(&squad)),
|
||||
SquadLog {
|
||||
outcome: "ok",
|
||||
detail: String::new(),
|
||||
@@ -2903,8 +3257,8 @@ pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
|
||||
/// (never 401/403, never a Python fallback that could mask split authority).
|
||||
pub fn handle_squad_active(deps: &SquadDeps<'_>, persona_id: i64) -> (WireResponse, SquadLog) {
|
||||
match project_active_squad(deps) {
|
||||
HostProjection::Squad(v) => (
|
||||
json_response(&user_mass_info_squad(v, persona_id)),
|
||||
HostProjection::Squad { squad, actives } => (
|
||||
json_response(&user_mass_info_squad(squad, persona_id, actives)),
|
||||
SquadLog {
|
||||
outcome: "ok",
|
||||
detail: String::new(),
|
||||
@@ -3029,8 +3383,8 @@ pub fn handle_user_mass_info(
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let (squad_val, log) = match project_active_squad(deps) {
|
||||
HostProjection::Squad(v) => (
|
||||
user_mass_info_squad(v, persona),
|
||||
HostProjection::Squad { squad, actives } => (
|
||||
user_mass_info_squad(squad, persona, actives),
|
||||
SquadLog {
|
||||
outcome: "ok",
|
||||
detail: String::new(),
|
||||
@@ -3483,6 +3837,8 @@ pub struct Server {
|
||||
economy_gate: Arc<Mutex<()>>,
|
||||
/// Staging-only simulation of losing the successful SBC submit receipt.
|
||||
sbc_post_commit_fault: SbcPostCommitFault,
|
||||
/// Emit `squad.actives`. Default OFF; see `HostConfig::squad_actives`.
|
||||
squad_actives: bool,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
@@ -3507,6 +3863,7 @@ impl Server {
|
||||
clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())),
|
||||
economy_gate: Arc::new(Mutex::new(())),
|
||||
sbc_post_commit_fault: SbcPostCommitFault::Off,
|
||||
squad_actives: false,
|
||||
current_match: Arc::new(PlMutex::new(None)),
|
||||
}
|
||||
}
|
||||
@@ -3574,6 +3931,10 @@ impl Server {
|
||||
eprintln!(
|
||||
"utas-host sbc_post_commit_fault={:?}",
|
||||
cfg.sbc_post_commit_fault
|
||||
);
|
||||
eprintln!(
|
||||
"utas-host squad_actives={} (ON emits the club's active home/away kit so the client can make them resident; set OPENFUT_FIFA17_SQUAD_ACTIVES=0 to disable)",
|
||||
cfg.squad_actives
|
||||
);
|
||||
Ok(Server::new(
|
||||
core,
|
||||
@@ -3585,7 +3946,8 @@ impl Server {
|
||||
.with_economy(economy)
|
||||
.with_clientdata(clientdata)
|
||||
.with_account(account)
|
||||
.with_sbc_post_commit_fault(cfg.sbc_post_commit_fault))
|
||||
.with_sbc_post_commit_fault(cfg.sbc_post_commit_fault)
|
||||
.with_squad_actives(cfg.squad_actives))
|
||||
}
|
||||
|
||||
/// Assemble the shared squad dependencies (Core access + the one production
|
||||
@@ -3595,6 +3957,7 @@ impl Server {
|
||||
core: self.core.as_ref(),
|
||||
resolver: self.resolver.as_ref(),
|
||||
entities: self.entities.as_ref(),
|
||||
squad_actives: self.squad_actives,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3620,6 +3983,14 @@ impl Server {
|
||||
self
|
||||
}
|
||||
|
||||
/// Emit `squad.actives`. Default OFF: a populated array makes the kits
|
||||
/// resident but was observed to cost the rest of the squad (see
|
||||
/// `HostConfig::squad_actives`).
|
||||
fn with_squad_actives(mut self, on: bool) -> Self {
|
||||
self.squad_actives = on;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_sbc_post_commit_fault(mut self, fault: SbcPostCommitFault) -> Self {
|
||||
self.sbc_post_commit_fault = fault;
|
||||
self
|
||||
@@ -4307,6 +4678,7 @@ impl Server {
|
||||
Route::FeatureOffEmpty => self.handle_feature_off_empty(path),
|
||||
Route::Season => self.handle_season(path),
|
||||
Route::ItemDefs => self.handle_item_defs(target),
|
||||
Route::ViewCards => self.handle_view_cards(target),
|
||||
Route::MarketData => self.handle_marketdata(path, target),
|
||||
Route::SecurityQuestion => self.handle_security_question(method, target, headers),
|
||||
Route::Passthrough => self.passthrough(method, target, headers, body),
|
||||
@@ -4503,7 +4875,9 @@ impl Server {
|
||||
};
|
||||
let deps = self.squad_deps();
|
||||
let (squad, squad_outcome) = match project_active_squad(&deps) {
|
||||
HostProjection::Squad(v) => (user_mass_info_squad(v, self.persona_id), "ok"),
|
||||
HostProjection::Squad { squad, actives } => {
|
||||
(user_mass_info_squad(squad, self.persona_id, actives), "ok")
|
||||
}
|
||||
HostProjection::Stale => (empty_squad_overlay(self.persona_id), "stale_integrity"),
|
||||
HostProjection::Missing => (empty_squad_overlay(self.persona_id), "missing_integrity"),
|
||||
HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"),
|
||||
@@ -4887,6 +5261,28 @@ impl Server {
|
||||
json_status(200, &non_economy::feature_off_body())
|
||||
}
|
||||
|
||||
/// The team whose kit this club currently wears, from its active kit items.
|
||||
///
|
||||
/// The pre-match kit clone resolves BOTH sides out of the client's own
|
||||
/// `teamkits` table keyed on `teamtechid`, with only `teamkittypetechid`
|
||||
/// distinguishing home from away. So if a season fixture names the club's own
|
||||
/// kit team, the opponent renders the club's kit and both sides appear in
|
||||
/// identical strips — which is also just wrong data, a club playing itself.
|
||||
/// [`season_wire::season_list_body`] excludes this team from the schedule.
|
||||
///
|
||||
/// Best-effort: any Core hiccup yields `None` and the full rotation, which is
|
||||
/// the pre-existing behaviour rather than a failed request.
|
||||
fn own_kit_team_id(&self) -> Option<i64> {
|
||||
let active = self.core.get_active_kits().ok()?;
|
||||
let designated = active.home_owned_card_id.or(active.away_owned_card_id)?;
|
||||
let page = self.core.query_owned(&[]).ok()?;
|
||||
let item = page
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.owned_card_id == designated)?;
|
||||
self.resolver.resolve_kit(item).map(|kit| kit.team_id)
|
||||
}
|
||||
|
||||
/// `…/season…` — FIFA 17 offline Seasons.
|
||||
///
|
||||
/// The client will not open the mode until it has a schedule: `season/list`
|
||||
@@ -4910,7 +5306,7 @@ impl Server {
|
||||
let (kind, body) = match sub {
|
||||
"list" => (
|
||||
"list",
|
||||
season_wire::season_list_body(SEASON_ID, DIVISION_ID),
|
||||
season_wire::season_list_body(SEASON_ID, DIVISION_ID, self.own_kit_team_id()),
|
||||
),
|
||||
"user" => (
|
||||
"user",
|
||||
@@ -4939,6 +5335,105 @@ impl Server {
|
||||
json_status(200, &non_economy::item_defs_body(&ids))
|
||||
}
|
||||
|
||||
/// `GET …/item?idList=…` — FutViewCards.
|
||||
///
|
||||
/// The ids here are OWNED INSTANCE ids, not definition ids. The client builds
|
||||
/// the query as `?idList=%lld` (CardsDLL `.rdata` `0x220080`) from ids it
|
||||
/// already holds, and reads the returned items' real fields back.
|
||||
///
|
||||
/// This is on the active-kit path. The FUT "Assign Kit" popup
|
||||
/// (`external.ion_fut.components.KitAssignmentPopup`, recovered from
|
||||
/// `KitAssignmentPopup.BIG`) drives `OSDKCards_ViewCards` and
|
||||
/// `OSDKCards_ActivateCard`, tracks `mHomeKitID`/`mAwayKitID`, and filters its
|
||||
/// local card inventory by `SEARCH_STATE_ACTIVE_HOME_KIT` /
|
||||
/// `SEARCH_STATE_ACTIVE_AWAY_KIT`. Those states can only come from the
|
||||
/// `itemState` this route returns.
|
||||
///
|
||||
/// THE DEFECT THIS FIXES: answering with the definition body
|
||||
/// ([`Self::handle_item_defs`]) echoes the queried id back as `resourceId` and
|
||||
/// emits `cardsubtypeid: 0`, `itemType: "player"`, `itemState: "free"`. Asked
|
||||
/// about the active home kit (instance 100004874) the server replied that it
|
||||
/// was a free player — so no kit can ever be seen as active. Measured on
|
||||
/// staging 2026-08-23.
|
||||
///
|
||||
/// Owned instances are shaped by the SAME projector `/club` uses, so a kit
|
||||
/// carries `resourceId` 6300006, `cardsubtypeid` 9, `itemState`
|
||||
/// `activeHomeKit`, `teamid`, `category` and `year` exactly as it does there —
|
||||
/// one shaping, no second wire dialect to drift.
|
||||
///
|
||||
/// Ids that are NOT owned instances fall back to the definition placeholder,
|
||||
/// preserving the oracle's behaviour for definition-style queries and for the
|
||||
/// empty query (`{"itemData": []}`).
|
||||
fn handle_view_cards(&self, target: &str) -> WireResponse {
|
||||
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
|
||||
let ids = extract_long_ints(query);
|
||||
if ids.is_empty() {
|
||||
eprintln!("utas-host owner=RUST route=view-cards count=0 owned=0 status=200");
|
||||
return json_status(200, &json!({ "itemData": [] }));
|
||||
}
|
||||
|
||||
// Shape the whole owned set once with the club projector, then select the
|
||||
// requested instances from it. Selecting first is not possible: the wire
|
||||
// instance id is assigned BY the projector, so there is nothing to match
|
||||
// against until the items are shaped.
|
||||
let wanted: std::collections::HashSet<i64> = ids.iter().copied().collect();
|
||||
let mut owned: Vec<Value> = Vec::new();
|
||||
if let Ok(page) = self.core.query_owned(&[]) {
|
||||
let hidden = self.club_hidden_ids();
|
||||
let active_kits = self.core.get_active_kits().unwrap_or_default();
|
||||
let visible: Vec<CoreOwnedItem> = page
|
||||
.items
|
||||
.into_iter()
|
||||
.filter(|item| !hidden.contains(&item.owned_card_id))
|
||||
.collect();
|
||||
let (body, _) = shape_club_response_with_kits(
|
||||
&visible,
|
||||
self.entities.as_ref(),
|
||||
self.resolver.as_ref(),
|
||||
ActiveKitAssignments {
|
||||
home: active_kits.home_owned_card_id.as_deref(),
|
||||
away: active_kits.away_owned_card_id.as_deref(),
|
||||
},
|
||||
);
|
||||
owned = body
|
||||
.get("itemData")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter(|it| {
|
||||
it.get("id")
|
||||
.and_then(Value::as_i64)
|
||||
.is_some_and(|id| wanted.contains(&id))
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
let found: std::collections::HashSet<i64> = owned
|
||||
.iter()
|
||||
.filter_map(|it| it.get("id").and_then(Value::as_i64))
|
||||
.collect();
|
||||
let missing: Vec<i64> = ids.into_iter().filter(|id| !found.contains(id)).collect();
|
||||
let mut items = owned;
|
||||
if !missing.is_empty() {
|
||||
if let Some(defs) = non_economy::item_defs_body(&missing)
|
||||
.get("itemData")
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
items.extend(defs.iter().cloned());
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=view-cards count={} owned={} defs={} status=200",
|
||||
items.len(),
|
||||
found.len(),
|
||||
missing.len()
|
||||
);
|
||||
json_status(200, &json!({ "itemData": items }))
|
||||
}
|
||||
|
||||
/// `POST …/item/resource/<resourceId>` — apply one consumable to one owned
|
||||
/// target (`ApplyCardByRes`, task id `0x0e`), live-captured 2026-08-21 as
|
||||
/// `{"apply":[{"id":<target>}]}`.
|
||||
@@ -5569,6 +6064,17 @@ fn discard_table_enabled() -> bool {
|
||||
*ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_DISCARD_TABLE").as_deref() == Ok("1"))
|
||||
}
|
||||
|
||||
/// Withhold `club?type=equippables` again (`OPENFUT_FIFA17_EQUIPPABLES=0`).
|
||||
///
|
||||
/// Default is now ON: the My Club kit tab requests this family, and answering it
|
||||
/// with a KITS-ONLY body is what made both kits render on the retail client
|
||||
/// (operator-confirmed 2026-08-23). This is the OFF switch, kept because the only
|
||||
/// live evidence is a club holding exactly two kits.
|
||||
fn equippables_disabled() -> bool {
|
||||
static DISABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*DISABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_EQUIPPABLES").as_deref() == Ok("0"))
|
||||
}
|
||||
|
||||
/// A JSON response with an explicit status.
|
||||
fn json_status(status: u16, v: &Value) -> WireResponse {
|
||||
let body = serde_json::to_vec(v).unwrap_or_default();
|
||||
@@ -7013,6 +7519,52 @@ mod tests {
|
||||
Route::Passthrough
|
||||
);
|
||||
}
|
||||
/// `GET ut/%s/item` (FutViewCards) MUST be claimed, and claiming it must not
|
||||
/// disturb the three other verbs that share the `item` prefix.
|
||||
///
|
||||
/// This route was unclaimed and fell through to the Python upstream, which is
|
||||
/// invisible in production (the oracle answers) and appears only on staging as
|
||||
/// a 502. That is the recurring dead-route defect in this project, so the
|
||||
/// point of this test is that a handler existing is not the same as a request
|
||||
/// reaching it.
|
||||
#[test]
|
||||
fn get_item_is_claimed_and_the_other_item_verbs_are_unaffected() {
|
||||
// FutViewCards, with and without the definition query the client builds.
|
||||
assert_eq!(classify("GET", "/ut/game/fifa17/item"), Route::ViewCards);
|
||||
assert_eq!(
|
||||
classify("GET", "/ut/game/fifa17/item?idList=100000003,100000004"),
|
||||
Route::ViewCards
|
||||
);
|
||||
// v2 sku form resolves identically.
|
||||
assert_eq!(classify("GET", "/ut/v2/game/fifa17/item"), Route::ViewCards);
|
||||
// The more specific definition routes still win, and — the bug this test
|
||||
// exists for — they must survive a query string too. `ut_tail` does not
|
||||
// strip the query, so an equality-only arm misses every real request while
|
||||
// looking correct in a no-query unit test.
|
||||
assert_eq!(
|
||||
classify("GET", "/ut/game/fifa17/item/resource"),
|
||||
Route::ItemDefs
|
||||
);
|
||||
assert_eq!(
|
||||
classify("GET", "/ut/game/fifa17/item/resource?resourceId=5003012"),
|
||||
Route::ItemDefs
|
||||
);
|
||||
assert_eq!(
|
||||
classify("GET", "/ut/game/fifa17/defid?definitionId=200389"),
|
||||
Route::ItemDefs
|
||||
);
|
||||
// PUT `item` is FutMoveCard on the economy path, NOT a definition read.
|
||||
assert_eq!(classify("PUT", "/ut/game/fifa17/item"), Route::Passthrough);
|
||||
assert_eq!(
|
||||
classify_economy("PUT", "/ut/game/fifa17/item"),
|
||||
Some(EconomyRoute::MoveItems)
|
||||
);
|
||||
// DELETE `item/<id>` is single-card Quick Sell and must not be swallowed.
|
||||
assert_eq!(
|
||||
classify_economy("DELETE", "/ut/game/fifa17/item/100000240"),
|
||||
Some(EconomyRoute::QuickSellPath)
|
||||
);
|
||||
}
|
||||
|
||||
/// Retail FIFA 17 issues part of the item family under `/ut/v2/game/`, so the
|
||||
/// same three verbs must land identically under both prefixes: `ut_tail`
|
||||
|
||||
@@ -902,6 +902,7 @@ fn from_config(base: &str, dir: &std::path::Path) -> openfut_utas_host::config::
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
sbc_post_commit_fault: openfut_utas_host::config::SbcPostCommitFault::Off,
|
||||
squad_actives: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@ use openfut_utas_host::account_store::AccountStore;
|
||||
use openfut_utas_host::{
|
||||
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
|
||||
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState,
|
||||
CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreSquadRead,
|
||||
CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route, Server, SquadDeps,
|
||||
CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreRolePatchRequest,
|
||||
CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route,
|
||||
Server, SquadDeps,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
@@ -51,6 +52,10 @@ struct FakeCore {
|
||||
/// full squad replacement writes it (or clears it with `None`).
|
||||
manager: Mutex<Option<String>>,
|
||||
replaced: Mutex<Vec<StoredReplace>>,
|
||||
/// Recorded role-only patches: (captain_owned_card_id, ext_payload). Kept
|
||||
/// separate from `replaced` so a test can assert a patch NEVER went through
|
||||
/// the replacement path.
|
||||
role_patches: Mutex<Vec<(Option<String>, String)>>,
|
||||
panic_if_called: bool,
|
||||
return_err: bool,
|
||||
}
|
||||
@@ -95,6 +100,9 @@ impl FakeCore {
|
||||
fn last(&self) -> Vec<(String, String)> {
|
||||
self.last_params.lock().clone()
|
||||
}
|
||||
fn role_patches(&self) -> Vec<(Option<String>, String)> {
|
||||
self.role_patches.lock().clone()
|
||||
}
|
||||
fn manager(&self) -> Option<String> {
|
||||
self.manager.lock().clone()
|
||||
}
|
||||
@@ -132,6 +140,45 @@ impl CoreAccess for FakeCore {
|
||||
self.squad.lock().clone().ok_or(CoreError::Status(404))
|
||||
}
|
||||
|
||||
fn patch_squad_roles(&self, req: &CoreRolePatchRequest) -> Result<(), CoreError> {
|
||||
assert!(
|
||||
!self.panic_if_called,
|
||||
"Core must NOT be called on this path"
|
||||
);
|
||||
if self.return_err {
|
||||
return Err(CoreError::Status(500));
|
||||
}
|
||||
// Mirror Core: the captain must already be fielded, otherwise 400.
|
||||
if let Some(captain) = &req.captain_owned_card_id {
|
||||
let fielded = self
|
||||
.squad
|
||||
.lock()
|
||||
.as_ref()
|
||||
.map(|s| s.slots.iter().any(|sl| &sl.owned_card_id == captain))
|
||||
.unwrap_or(false);
|
||||
if !fielded {
|
||||
return Err(CoreError::Status(400));
|
||||
}
|
||||
}
|
||||
self.role_patches
|
||||
.lock()
|
||||
.push((req.captain_owned_card_id.clone(), req.ext_payload.clone()));
|
||||
// Apply to the stored squad WITHOUT touching slots or the manager, so a
|
||||
// read-after-patch shows exactly what Core would show.
|
||||
if let Some(sq) = self.squad.lock().as_mut() {
|
||||
if let Some(captain) = &req.captain_owned_card_id {
|
||||
for slot in sq.slots.iter_mut() {
|
||||
slot.is_captain = &slot.owned_card_id == captain;
|
||||
}
|
||||
}
|
||||
sq.ext = CoreExtState::Fresh {
|
||||
schema_version: req.ext_schema_version,
|
||||
payload: req.ext_payload.clone(),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError> {
|
||||
assert!(
|
||||
!self.panic_if_called,
|
||||
@@ -198,11 +245,11 @@ impl CoreAccess for FakeCore {
|
||||
Ok(self.manager.lock().clone())
|
||||
}
|
||||
|
||||
fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> {
|
||||
fn set_squad_manager(&self, owned_card_id: &str) -> Result<(), CoreError> {
|
||||
if self.return_err {
|
||||
return Err(CoreError::Status(500));
|
||||
}
|
||||
*self.manager.lock() = owned_card_id.map(str::to_string);
|
||||
*self.manager.lock() = Some(owned_card_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1079,8 +1126,41 @@ fn classify_squad_and_usermassinfo_routes() {
|
||||
classify("GET", "/ut/game/fifa17/userMassInfo"),
|
||||
Route::UserMassInfo
|
||||
);
|
||||
// GET /squad/active and every numeric GET are the one Core-backed current
|
||||
// squad, matching the oracle's single-squad response regardless of URL id.
|
||||
// The retail client sends the lowercase tail too, immediately before the
|
||||
// canonical one. It must reach the SAME Rust-owned handler: falling through
|
||||
// to Python is a 502 here and, with Python alive, an authority split.
|
||||
assert_eq!(
|
||||
classify("GET", "/ut/game/fifa17/usermassinfo"),
|
||||
Route::UserMassInfo,
|
||||
"lowercase usermassinfo is a real retail request, observed twice"
|
||||
);
|
||||
assert_eq!(
|
||||
classify("GET", "/ut/game/fifa17/USERMASSINFO"),
|
||||
Route::UserMassInfo
|
||||
);
|
||||
// Adjacent tails must NOT be swept up by the case-insensitive arm.
|
||||
assert_eq!(
|
||||
classify("GET", "/ut/game/fifa17/usermassinfox"),
|
||||
Route::Passthrough,
|
||||
"the alias must match the whole tail, not a prefix"
|
||||
);
|
||||
assert_eq!(
|
||||
classify("GET", "/ut/game/fifa17/usermass"),
|
||||
Route::Passthrough
|
||||
);
|
||||
// Method semantics are unchanged: only GET is this route.
|
||||
assert_eq!(
|
||||
classify("PUT", "/ut/game/fifa17/usermassinfo"),
|
||||
Route::Passthrough
|
||||
);
|
||||
assert_eq!(
|
||||
classify("POST", "/ut/game/fifa17/userMassInfo"),
|
||||
Route::Passthrough
|
||||
);
|
||||
|
||||
// GET /squad/active and every numeric GET resolve to the one Core-backed
|
||||
// squad. SAFE, not authentic — see is_numeric_squad_tail and the invariant
|
||||
// test below.
|
||||
assert_eq!(
|
||||
classify("GET", "/ut/game/fifa17/squad/active"),
|
||||
Route::SquadActive
|
||||
@@ -1150,6 +1230,7 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = put_body(
|
||||
"f442",
|
||||
@@ -1179,11 +1260,16 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
|
||||
assert_eq!(r.chemistry, Some(52), "client-reported shadow carried");
|
||||
}
|
||||
|
||||
/// The squad's manager is an ownership-backed assignment, not an opaque wire
|
||||
/// echo: a save assigns the owned instance behind the ref, and a later save
|
||||
/// without a manager CLEARS it (a full replacement replaces the manager too).
|
||||
/// The REAL captured partial body must succeed and take the PATCH path, not the
|
||||
/// replacement path.
|
||||
///
|
||||
/// Captured 2026-08-25 00:42:42 from the retail client's captain/kick-taker
|
||||
/// screen (`offline-seasons-squadexp-20260825T0018Z.pcap`). It carries no
|
||||
/// `players`, so the old code handed Core a replacement with zero slots; the
|
||||
/// empty-replacement guard refused it (400) and the host reported 502, losing
|
||||
/// the user's change. The body shape is the operation discriminator.
|
||||
#[test]
|
||||
fn put_assigns_the_owned_manager_and_a_later_save_clears_it() {
|
||||
fn put_partial_captain_and_kicktakers_patches_roles_without_replacing() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
@@ -1192,6 +1278,144 @@ fn put_assigns_the_owned_manager_and_a_later_save_clears_it() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
// Establish a squad first, so there is something to patch.
|
||||
let full = put_body(
|
||||
"f442",
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
|
||||
"[1,2,3]",
|
||||
Some(w["oc-b"]),
|
||||
);
|
||||
let (resp, log) = handle_put_squad(&full, &deps);
|
||||
assert_eq!(resp.status, 200, "{log:?}");
|
||||
assert_eq!(core.replaced().len(), 1);
|
||||
|
||||
// The captured partial shape: no players, no manager, no formation.
|
||||
let partial = format!(
|
||||
r#"{{"id":0,"custom":"[0,8,16,16,8,134220032]","captain":{},"kicktakers":[
|
||||
{{"index":0,"id":{},"dream":false}},{{"index":1,"id":{},"dream":false}},
|
||||
{{"index":2,"id":{},"dream":false}},{{"index":3,"id":{},"dream":false}},
|
||||
{{"index":4,"id":{},"dream":false}}]}}"#,
|
||||
w["oc-b"], w["oc-a"], w["oc-a"], w["oc-b"], w["oc-b"], w["oc-a"]
|
||||
);
|
||||
let (resp, log) = handle_put_squad(partial.as_bytes(), &deps);
|
||||
assert_eq!(resp.status, 200, "the partial shape must succeed: {log:?}");
|
||||
assert_eq!(resp.body, br#"{"id":0}"#, "same ack as a full save");
|
||||
|
||||
// It went through the PATCH path, never the replacement path.
|
||||
assert_eq!(
|
||||
core.replaced().len(),
|
||||
1,
|
||||
"a role patch must NOT reach replace_squad — that is what tripped the guard"
|
||||
);
|
||||
let patches = core.role_patches();
|
||||
assert_eq!(patches.len(), 1);
|
||||
assert_eq!(
|
||||
patches[0].0.as_deref(),
|
||||
Some("oc-b"),
|
||||
"captain resolved to the Core owned id, never the wire id"
|
||||
);
|
||||
// Omitted state is UNCHANGED, not cleared.
|
||||
assert_eq!(
|
||||
core.manager().as_deref(),
|
||||
Some("oc-b"),
|
||||
"a role patch must not touch the manager"
|
||||
);
|
||||
// The patch's opaque custom is carried, and kit numbers from the earlier
|
||||
// full save survive the merge rather than being overwritten away.
|
||||
assert!(patches[0].1.contains("134220032"), "patch custom carried");
|
||||
assert!(
|
||||
patches[0].1.contains("kit_numbers"),
|
||||
"kit numbers preserved from the stored extension: {}",
|
||||
patches[0].1
|
||||
);
|
||||
}
|
||||
|
||||
/// An explicit `"players": []` is still a REPLACEMENT and must still be refused
|
||||
/// by Core's guard — the patch path must not become a way to smuggle a
|
||||
/// destructive write past it.
|
||||
#[test]
|
||||
fn put_explicit_empty_players_is_still_a_replacement_not_a_patch() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, _w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = br#"{"id":0,"formation":"f442","custom":"[]","players":[],"manager":[]}"#;
|
||||
let (resp, log) = handle_put_squad(body, &deps);
|
||||
// Reaches the replacement path (Core decides), and NEVER the patch path.
|
||||
assert_eq!(
|
||||
core.role_patches().len(),
|
||||
0,
|
||||
"an explicit empty players array must not be treated as a role patch: {log:?}"
|
||||
);
|
||||
assert!(
|
||||
resp.status == 200 || resp.status >= 400,
|
||||
"handled by the replacement path"
|
||||
);
|
||||
assert_eq!(
|
||||
core.replaced().len(),
|
||||
1,
|
||||
"it went to replace_squad, where the empty-replacement guard lives"
|
||||
);
|
||||
}
|
||||
|
||||
/// A captain the resolver cannot map must refuse the whole patch, not silently
|
||||
/// apply the kick-takers and report success.
|
||||
#[test]
|
||||
fn put_partial_with_unresolvable_captain_refuses_the_whole_patch() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let full = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[1]", None);
|
||||
assert_eq!(handle_put_squad(&full, &deps).0.status, 200);
|
||||
|
||||
let partial = br#"{"id":0,"custom":"[9]","captain":999999999,"kicktakers":[]}"#;
|
||||
let (resp, log) = handle_put_squad(partial, &deps);
|
||||
assert_eq!(resp.status, 400, "unresolvable captain is a client error");
|
||||
assert_eq!(log.outcome, "unresolved_captain");
|
||||
assert_eq!(
|
||||
core.role_patches().len(),
|
||||
0,
|
||||
"nothing may be written when the captain cannot be resolved"
|
||||
);
|
||||
}
|
||||
|
||||
/// The squad's manager is an ownership-backed assignment, not an opaque wire
|
||||
/// echo: a save assigns the owned instance behind the ref. A later save that
|
||||
/// carries NO manager ref does NOT clear it.
|
||||
///
|
||||
/// This test previously asserted the opposite ("a full replacement replaces the
|
||||
/// manager too"). That was the bug: FIFA 17 has no wire shape that removes a
|
||||
/// manager — the client always sends a ref — so an absent ref means the save
|
||||
/// says nothing about the manager, not that the user cleared the slot. A client
|
||||
/// whose squad model had been destroyed sent exactly that shape and deleted a
|
||||
/// real manager row (WAL commit 468, squad_managers 1 -> 0).
|
||||
#[test]
|
||||
fn put_assigns_the_owned_manager_and_a_later_save_without_one_leaves_it() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
|
||||
let with_manager = put_body(
|
||||
@@ -1209,14 +1433,18 @@ fn put_assigns_the_owned_manager_and_a_later_save_clears_it() {
|
||||
"manager persisted as the Core owned id, never the wire id"
|
||||
);
|
||||
|
||||
// The exact destructive shape: `"manager": []`.
|
||||
let without_manager = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None);
|
||||
let (resp, log) = handle_put_squad(&without_manager, &deps);
|
||||
assert_eq!(resp.status, 200, "{log:?}");
|
||||
assert_eq!(
|
||||
core.manager(),
|
||||
None,
|
||||
"a full replacement without a manager clears the assignment"
|
||||
core.manager().as_deref(),
|
||||
Some("oc-b"),
|
||||
"a save carrying no manager ref must LEAVE the existing assignment alone"
|
||||
);
|
||||
|
||||
// And the squad itself still committed — the manager decision is separate.
|
||||
assert_eq!(core.replace_calls(), 2, "both saves committed their slots");
|
||||
}
|
||||
|
||||
/// The REAL client always sends a manager ref, and on a real profile it does not
|
||||
@@ -1234,6 +1462,7 @@ fn put_saves_the_squad_when_the_manager_ref_does_not_resolve() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
|
||||
let body = put_body(
|
||||
@@ -1279,6 +1508,7 @@ fn put_rejects_wire_id_owned_by_another_profile_core_unchanged() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = put_body(
|
||||
"f442",
|
||||
@@ -1305,6 +1535,7 @@ fn put_rejects_unknown_wire_id() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
// 999_999_999 was never allocated → unresolvable.
|
||||
let body = put_body(
|
||||
@@ -1330,6 +1561,7 @@ fn put_rejects_duplicate_owned_item() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = put_body(
|
||||
"f442",
|
||||
@@ -1355,6 +1587,7 @@ fn put_core_failure_returns_error_never_python() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None);
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
@@ -1372,6 +1605,7 @@ fn repeated_identical_put_is_idempotent() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = put_body(
|
||||
"f442",
|
||||
@@ -1408,6 +1642,7 @@ fn coupled_read_after_write_list_and_usermassinfo_agree() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = put_body(
|
||||
"f442",
|
||||
@@ -1513,6 +1748,7 @@ fn squad_active_serves_core_backed_object_with_configured_persona() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
// Commit a squad so Core has a fresh canonical squad + extension.
|
||||
let body = put_body(
|
||||
@@ -1560,6 +1796,7 @@ fn read_path_is_bounded_no_per_slot_lookup() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = put_body(
|
||||
"f442",
|
||||
@@ -1614,6 +1851,7 @@ fn stale_extension_is_not_applied_on_reads() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let (resp, log) = handle_squad_list(&deps);
|
||||
assert_eq!(log.outcome, "stale_integrity");
|
||||
@@ -1635,6 +1873,7 @@ fn missing_extension_is_explicit_on_reads() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let (resp, log) = handle_squad_list(&deps);
|
||||
assert_eq!(log.outcome, "missing_integrity");
|
||||
@@ -1656,6 +1895,7 @@ fn usermassinfo_never_serves_python_squad_on_integrity_failure() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let py_body = json!({
|
||||
"userInfo": {"personaId": 7},
|
||||
@@ -1730,6 +1970,7 @@ fn duplicate_definition_instances_stay_distinct_through_host() {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
squad_actives: false,
|
||||
};
|
||||
let body = put_body(
|
||||
"f442",
|
||||
@@ -2230,7 +2471,6 @@ fn position_tabs_serve_only_their_own_position_group() {
|
||||
#[test]
|
||||
fn withheld_club_type_arms_are_empty_with_a_recorded_reason() {
|
||||
for (query, reason) in [
|
||||
("type=equippables", "multi_family_crash_2026_08_05"),
|
||||
("type=leaguelogos", "subtype_by_elimination_unprobed"),
|
||||
("type=healing", "served_by_club_consumables_route"),
|
||||
("type=contract", "served_by_club_consumables_route"),
|
||||
@@ -2254,6 +2494,35 @@ fn withheld_club_type_arms_are_empty_with_a_recorded_reason() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `?type=equippables` is the family the MY CLUB kit tab actually asks for, and it
|
||||
/// answers with the KIT family only. Withholding it WAS the "0 kits in my club"
|
||||
/// symptom — the tab asked, got the withheld empty body, and drew nothing. Serving
|
||||
/// it is operator-confirmed on the retail client.
|
||||
///
|
||||
/// Kits only, deliberately: the 2026-08-05 crash was 30 items across five families
|
||||
/// at once, and nothing has retested that shape.
|
||||
///
|
||||
/// NOT exercised here: `OPENFUT_FIFA17_EQUIPPABLES=0`, which restores the withheld
|
||||
/// body. It is read through a `OnceLock`, so flipping the process-wide env inside
|
||||
/// one test would leak into every other test sharing this binary.
|
||||
#[test]
|
||||
fn equippables_serves_the_kit_family_because_that_is_the_my_club_kit_tab() {
|
||||
let (items, _, outcome) = club_query("type=equippables");
|
||||
assert_eq!(outcome, "ok");
|
||||
assert_eq!(items.len(), 2, "the fixture club owns exactly two kits");
|
||||
for it in &items {
|
||||
assert_eq!(it["itemType"], "kit");
|
||||
assert_eq!(
|
||||
it["cardsubtypeid"], 9,
|
||||
"kits only: mixing families is the shape that crashed the client"
|
||||
);
|
||||
}
|
||||
|
||||
// The two tabs are the same question, so they must never diverge.
|
||||
let (kits, _, _) = club_query("type=kit");
|
||||
assert_eq!(items, kits, "equippables and kit must serve one body");
|
||||
}
|
||||
|
||||
/// The three club-customisation families Core can own are MAPPED (so the arm asks
|
||||
/// Core for the right rows) but their item record is still withheld, so the answer
|
||||
/// is an empty list rather than a guessed record — and never another family's.
|
||||
@@ -2739,3 +3008,45 @@ fn no_shipped_training_card_exceeds_the_declared_ceiling() {
|
||||
}
|
||||
assert_eq!(rare, 6, "all 6 rare all-six cards must resolve");
|
||||
}
|
||||
|
||||
/// Collapsing every numeric `squad/<id>` onto one squad is safe ONLY while
|
||||
/// exactly one squad is advertised. This is the tripwire for that assumption.
|
||||
///
|
||||
/// FIFA 17 has real multi-squad semantics — the client ships `SelectSquadById`,
|
||||
/// `RetrieveSquad` (distinct from `LoadActiveSquad`), `CreateSquadWithName`,
|
||||
/// `RenameSquad`, `DeleteSquad` and indexed `SQUAD_ID-%d` entries. We get away
|
||||
/// with ignoring the id purely because the client can never learn another one:
|
||||
/// the wire id is a constant 0 and `/squad/list` advertises a single squad.
|
||||
///
|
||||
/// If either fact stops holding, the numeric route needs a real lookup and this
|
||||
/// test must be the thing that says so.
|
||||
#[test]
|
||||
fn numeric_squad_routing_is_safe_only_while_one_squad_is_advertised() {
|
||||
assert_eq!(
|
||||
openfut_utas_host::ACTIVE_SQUAD_WIRE_ID,
|
||||
0,
|
||||
"the single advertised squad id is what makes id-collapsing safe"
|
||||
);
|
||||
|
||||
let projected = serde_json::json!({
|
||||
"id": openfut_utas_host::ACTIVE_SQUAD_WIRE_ID,
|
||||
"squadName": "OpenFUT",
|
||||
"formation": "f442",
|
||||
"squadType": "REGULAR_SQUAD",
|
||||
"rating": 90,
|
||||
"chemistry": 52,
|
||||
});
|
||||
let list = openfut_adapter_fifa17::fut::squad_projection::squad_list(&projected);
|
||||
let squads = list["squad"].as_array().expect("squad list is an array");
|
||||
assert_eq!(
|
||||
squads.len(),
|
||||
1,
|
||||
"more than one advertised squad means the client can address a second \
|
||||
id, and every numeric GET/PUT collapsing onto one squad becomes wrong"
|
||||
);
|
||||
assert_eq!(
|
||||
squads[0]["id"],
|
||||
openfut_utas_host::ACTIVE_SQUAD_WIRE_ID,
|
||||
"the advertised id must be the one the client echoes back"
|
||||
);
|
||||
}
|
||||
|
||||
+28
-16
@@ -182,9 +182,15 @@ DISPOSABLE_CARD = "fifa17_232273" # Nelson Atiagli LB 51, rareflag 1
|
||||
# Two authoritative modern kit-card definitions for one real source team. These
|
||||
# are staging fixtures derived from fcc_kitcards, not synthetic FIFA identities.
|
||||
KIT_TEAM_ID = 21
|
||||
# The trailing value is the client's own `fcc_kitcards.assetid`, the wire
|
||||
# `assetId` (record +0x20). It is the ART CLASS, not the carddbid: every
|
||||
# 63xxxxx (home/third) kit carries 14 and every 64xxxxx (away) kit carries 15,
|
||||
# per club_items.json and fifa17-kit-map.json's band_x_assetid evidence
|
||||
# (6300000/14 x828, 6400000/15 x654). Shipping the carddbid here left both
|
||||
# pre-match kit tiles rendering identically.
|
||||
STAGING_KITS = [
|
||||
("home", "owned-a-kit-home", "fifa17_6300006", 6_300_006),
|
||||
("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003),
|
||||
("home", "owned-a-kit-home", "fifa17_6300006", 6_300_006, 14),
|
||||
("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003, 15),
|
||||
]
|
||||
|
||||
# The remaining club-item families, so the rig exercises EVERY ownable class
|
||||
@@ -198,11 +204,15 @@ STAGING_KITS = [
|
||||
# badge 39, logo 40), which is what the importer gates on. A league logo has no
|
||||
# equipped slot, so it is owned as generic `misc` content.
|
||||
STAGING_CLUB_ITEMS = [
|
||||
# (slot, owned_id, card_id, resource_id, kind, subtype, card_asset_id, team_id)
|
||||
("badge", "owned-a-badge", "fifa17_6000005", 6_000_005, "badge", 11, 39, 21),
|
||||
("ball", "owned-a-ball", "fifa17_8120194", 8_120_194, "ball", 30, 37, None),
|
||||
("stadium", "owned-a-stadium", "fifa17_6200000", 6_200_000, "stadium", 10, 36, None),
|
||||
(None, "owned-a-leaguelogo", "fifa17_8010015", 8_010_015, "misc", 31, 40, None),
|
||||
# (slot, owned_id, card_id, resource_id, kind, subtype, card_asset_id, team_id,
|
||||
# club_asset_id)
|
||||
# club_asset_id is the wire `assetId` from club_items.json, family specific
|
||||
# and never the carddbid: badge 6000005 -> its teamid 21, ball 8120194 -> 100,
|
||||
# stadium 6200000 -> 1. A league logo has no equipped slot and keeps its own.
|
||||
("badge", "owned-a-badge", "fifa17_6000005", 6_000_005, "badge", 11, 39, 21, 21),
|
||||
("ball", "owned-a-ball", "fifa17_8120194", 8_120_194, "ball", 30, 37, None, 100),
|
||||
("stadium", "owned-a-stadium", "fifa17_6200000", 6_200_000, "stadium", 10, 36, None, 1),
|
||||
(None, "owned-a-leaguelogo", "fifa17_8010015", 8_010_015, "misc", 31, 40, None, None),
|
||||
]
|
||||
|
||||
# The club manager. FIFA refuses to start a match without one ("your player or
|
||||
@@ -524,7 +534,7 @@ def materialise(lay: Layout) -> None:
|
||||
with open(safe_path(lay.catalog)) as fh:
|
||||
catalog = json.load(fh)
|
||||
existing = {definition["id"] for definition in definitions}
|
||||
for _slot, _owned_id, card_id, resource_id in STAGING_KITS:
|
||||
for _slot, _owned_id, card_id, resource_id, club_asset_id in STAGING_KITS:
|
||||
if card_id not in existing:
|
||||
definitions.append({
|
||||
"id": card_id,
|
||||
@@ -550,10 +560,11 @@ def materialise(lay: Layout) -> None:
|
||||
"kind": "kit",
|
||||
"subtype": 9,
|
||||
"card_asset_id": 35,
|
||||
"club_asset_id": club_asset_id,
|
||||
"team_id": KIT_TEAM_ID,
|
||||
}
|
||||
|
||||
for _slot, _owned, card_id, resource_id, kind, subtype, art, team in STAGING_CLUB_ITEMS:
|
||||
for _slot, _owned, card_id, resource_id, kind, subtype, art, team, club_asset in STAGING_CLUB_ITEMS:
|
||||
if card_id not in existing:
|
||||
definitions.append({
|
||||
"id": card_id,
|
||||
@@ -580,6 +591,8 @@ def materialise(lay: Layout) -> None:
|
||||
"subtype": subtype,
|
||||
"card_asset_id": art,
|
||||
}
|
||||
if club_asset is not None:
|
||||
entry["club_asset_id"] = club_asset
|
||||
if team is not None:
|
||||
entry["team_id"] = team
|
||||
catalog["cards"][card_id] = entry
|
||||
@@ -701,7 +714,7 @@ def assert_seed_cards_resolvable(lay: Layout, real_club: dict | None) -> None:
|
||||
wanted = set(
|
||||
[card for _, card in SELLER_SQUAD_CARDS]
|
||||
+ [DISPOSABLE_CARD]
|
||||
+ [card for _, _, card, _ in STAGING_KITS]
|
||||
+ [card for _, _, card, _, _ in STAGING_KITS]
|
||||
+ [STAGING_MANAGER["card_id"]]
|
||||
)
|
||||
what = f"all {len(wanted)} fixture seed card ids"
|
||||
@@ -712,7 +725,7 @@ def assert_seed_cards_resolvable(lay: Layout, real_club: dict | None) -> None:
|
||||
conn.execute("SELECT DISTINCT card_id FROM owned_cards")}
|
||||
finally:
|
||||
conn.close()
|
||||
wanted |= {card for _, _, card, _ in STAGING_KITS}
|
||||
wanted |= {card for _, _, card, _, _ in STAGING_KITS}
|
||||
wanted.add(STAGING_MANAGER["card_id"])
|
||||
what = (f"all {len(wanted)} distinct card ids owned by the real club "
|
||||
"(plus the kit and manager fixtures)")
|
||||
@@ -983,7 +996,7 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
|
||||
# calling a kit a player, and Core is the ownership authority.
|
||||
owned = [
|
||||
(owned_id, seller_club, card_id, "kit", TS)
|
||||
for _slot, owned_id, card_id, _resource_id in STAGING_KITS
|
||||
for _slot, owned_id, card_id, _resource_id, _ca in STAGING_KITS
|
||||
]
|
||||
owned.append(
|
||||
(
|
||||
@@ -996,7 +1009,7 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
|
||||
)
|
||||
owned.extend(
|
||||
(owned_id, seller_club, card_id, kind, TS)
|
||||
for _slot, owned_id, card_id, _rid, kind, _st, _art, _team
|
||||
for _slot, owned_id, card_id, _rid, kind, _st, _art, _team, _ca
|
||||
in STAGING_CLUB_ITEMS
|
||||
)
|
||||
if real_club is None:
|
||||
@@ -1010,7 +1023,6 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
|
||||
"content_kind, acquired_at) VALUES (?, ?, ?, 0, ?, ?)",
|
||||
owned,
|
||||
)
|
||||
|
||||
if real_club is None:
|
||||
conn.execute(
|
||||
"INSERT INTO squads (id, club_id, name, formation, created_at, "
|
||||
@@ -1034,14 +1046,14 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
[
|
||||
(seller_club, f"{slot}_kit", owned_id, TS)
|
||||
for slot, owned_id, _card_id, _resource_id in STAGING_KITS
|
||||
for slot, owned_id, _card_id, _resource_id, _ca in STAGING_KITS
|
||||
]
|
||||
# badge / ball / stadium are slot-keyed exactly like the kits.
|
||||
# A league logo has no slot, so it stays owned-but-unequipped —
|
||||
# which is itself worth exercising.
|
||||
+ [
|
||||
(seller_club, slot, owned_id, TS)
|
||||
for slot, owned_id, _c, _r, _k, _s, _a, _t in STAGING_CLUB_ITEMS
|
||||
for slot, owned_id, _c, _r, _k, _s, _a, _t, _ca in STAGING_CLUB_ITEMS
|
||||
if slot is not None
|
||||
],
|
||||
)
|
||||
|
||||
+80
-15
@@ -19,23 +19,32 @@ documents the native launch/routing/rollback model.
|
||||
| `stp-selector.exe` | ssl/LSX selector companion. |
|
||||
| `openfut.cfg` | operator-facing routing override (see below). |
|
||||
|
||||
## Launch (native - there is NO launcher script by design)
|
||||
## Launch — the OpenFUT Launcher is the normal entry point
|
||||
|
||||
Run `C:\FIFA 17\_fifa17.exe` **as Administrator**. The correct, reproducible way:
|
||||
Use the **"OpenFUT Launcher"** shortcut (Desktop / Start Menu). It reconciles
|
||||
`openfut.cfg` from its settings, shows backend health, and starts the game, so
|
||||
Windows and Linux are driven exactly the same way.
|
||||
|
||||
- Double-click the **"FIFA 17 (OpenFUT)"** shortcut (Desktop and Start Menu).
|
||||
It targets `_fifa17.exe`, working dir `C:\FIFA 17`, with the RunAsAdmin bit set.
|
||||
- `_fifa17.exe` is also flagged `RUNASADMIN` in
|
||||
`HKCU\Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers`,
|
||||
so any launch (Explorer double-click included) elevates via UAC.
|
||||
Launching the game directly still works and is the smaller-blast-radius option
|
||||
when validating a hook change, because it does not rewrite `openfut.cfg`:
|
||||
double-click the **"FIFA 17 (OpenFUT)"** shortcut, which targets `_fifa17.exe`
|
||||
with working dir `C:\FIFA 17`.
|
||||
|
||||
**Elevation comes from the shortcuts, not from AppCompatFlags.** Both `.lnk`
|
||||
files carry the RunAsAdmin bit (byte 21, flag `0x20`); there is NO `RUNASADMIN`
|
||||
entry under
|
||||
`HKCU\Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers`.
|
||||
So running `_fifa17.exe` straight from Explorer does **not** elevate, and the
|
||||
hook then fails to write `C:\openfut_hook.log`. Always go through a shortcut, or
|
||||
right-click → Run as administrator. The launcher elevates once and the game it
|
||||
spawns inherits that token, so there is no second UAC prompt.
|
||||
|
||||
On launch the Windows loader maps `version.dll` from the game directory (hijack),
|
||||
`stp-origin_emu.dll` emulates Origin login for the configured persona, and the
|
||||
hook redirects EA endpoints to the OpenFUT backend.
|
||||
|
||||
> Do **not** wrap the launch in a script. The elevation + shortcut is the
|
||||
> supported mechanism. FIFA under native Windows also ignores synthetic input,
|
||||
> so in-game steps are performed by the operator one at a time.
|
||||
> Do **not** wrap the launch in a script. FIFA under native Windows ignores
|
||||
> synthetic input, so in-game steps are performed by the operator one at a time.
|
||||
|
||||
### OpenFUT Launcher (GUI)
|
||||
|
||||
@@ -44,11 +53,15 @@ The `openfut-launcher` egui app runs natively on Windows (built for
|
||||
one-button front end: it checks the backend, reconciles `openfut.cfg` from its
|
||||
settings, and starts the game.
|
||||
|
||||
- Binary: `C:\OpenFUT\openfut-launcher.exe`; config: `%APPDATA%\openfut-launcher\config.json`.
|
||||
- Launch it from the **"OpenFUT Launcher"** shortcut (Desktop / Start Menu). The
|
||||
launcher itself is flagged `RUNASADMIN`, so it elevates once at start; the game
|
||||
it spawns inherits that elevation (no second UAC prompt, no crack loader
|
||||
"run as admin" failure).
|
||||
- Binary: `C:\openfut\openfut-launcher.exe`; config: `%APPDATA%\openfut-launcher\config.json`.
|
||||
- `hook_dll_path` MUST point at a **source** copy of the hook, not at the
|
||||
deployed `C:\FIFA 17\version.dll`. Pointing it at the destination makes
|
||||
"Deploy" a self-copy, and Remove-then-Deploy leaves no source to deploy from.
|
||||
Canonical location: `C:\openfut\openfut_hook.dll`.
|
||||
- The Deploy button only appears when no `version.dll` is present, so a normal
|
||||
launch never overwrites a hand-deployed hook.
|
||||
- `game_profile.runner` is unused on Windows (`validate()` only requires it on
|
||||
unix); `executable` + `game_dir` are what matter.
|
||||
- On Windows the launcher does NOT spawn LSX/autopatch (they are in-process:
|
||||
`stp-origin_emu.dll` + the `version.dll` hook) and does NOT arm the host
|
||||
(routing is purely `openfut.cfg`, which it writes into `C:\FIFA 17`).
|
||||
@@ -85,6 +98,58 @@ Copy-Item 'C:\FIFA 17\version.dll.stale-849k.bak' 'C:\FIFA 17\version.dll' -Forc
|
||||
Always keep a `*.bak` of the live hook before redeploying (the preflight checks
|
||||
that a rollback backup exists and differs from the live DLL).
|
||||
|
||||
## Roster / "FUT Squad Update" — handled in-client, no client DNS changes
|
||||
|
||||
If FUT fails with **"An error occurred downloading the FUT Squad Update"**, the
|
||||
client could not fetch `https://<roster>/fifa17/fut/rosterupdate.xml`.
|
||||
|
||||
FIFA 17's ProtoSSL verifies that certificate by **dNSName only**. Pointing the
|
||||
roster at an IP does **not** work even though our certificate carries
|
||||
`IP Address:10.10.0.120` in its SANs — retested on Windows 2026-08-23 and
|
||||
rejected, confirming the divergence on Windows and not just under Wine. Do not
|
||||
retry an IP roster host and do not reissue the certificate for an IP SAN.
|
||||
|
||||
**The hook solves this at the socket.** `ea_ports::FIFA17_ROSTER` (8081) is a
|
||||
recognised signature, so the existing `connect`/`WSAConnect`/`ConnectEx` detour
|
||||
rewrites the roster dial to the configured server while leaving the URL — and
|
||||
therefore SNI — as `winter15.gosredirector.ea.com`. The certificate still
|
||||
validates because the *name* never changed; only the destination did. Nothing is
|
||||
required on the client: no hosts entry, no NRPT rule, no external resolver.
|
||||
|
||||
Confirm from the hook log (`C:\openfut_hook.log`); note it prints octets
|
||||
reversed:
|
||||
|
||||
```
|
||||
connect_hook: call 20.51.153.159:8081 <- 159.153.51.20, the dial
|
||||
connect_hook: v4 :8081 → 10.10.0.120:8081 <- the rewrite
|
||||
```
|
||||
|
||||
Serve the roster on the destination port and start Blaze with
|
||||
`OPENFUT_ROSTER_HOST=winter15.gosredirector.ea.com:8081`. Use `roster_port=` in
|
||||
`openfut.cfg` only to move the destination; the parser rejects unknown keys, so
|
||||
that key is written only when it differs from the default.
|
||||
|
||||
### Contingency: EA's DNS record disappears
|
||||
|
||||
One dependency survives: the client must resolve `winter15.gosredirector.ea.com`
|
||||
to *something* to reach `connect()` at all. EA's record is live today. If it is
|
||||
ever withdrawn, resolution fails before the hook can act, and the fallback is
|
||||
`tools/windows/scoped-dns.py` — a resolver that pins that one name to us and
|
||||
forwards every other query upstream verbatim, so it cannot strand the client:
|
||||
|
||||
```bash
|
||||
sudo python3 tools/windows/scoped-dns.py
|
||||
```
|
||||
|
||||
Point the client at it with an NRPT rule, never a hosts entry (a hosts edit on
|
||||
this machine previously took its whole internet down):
|
||||
|
||||
```powershell
|
||||
Add-DnsClientNrptRule -Namespace "winter15.gosredirector.ea.com" -NameServers "10.10.0.120"
|
||||
# revert:
|
||||
Get-DnsClientNrptRule | Where-Object { $_.Namespace -like "*winter15*" } | Remove-DnsClientNrptRule -Force
|
||||
```
|
||||
|
||||
## Preflight
|
||||
|
||||
`openfut-client-preflight.ps1` is **read-only**: it never launches the game,
|
||||
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A deliberately tiny DNS responder that answers ONE name and forwards the rest.
|
||||
|
||||
WHY THIS EXISTS. FIFA 17's ProtoSSL verifies the roster certificate by dNSName
|
||||
only, so the client must reach the roster as `winter15.gosredirector.ea.com`
|
||||
rather than by IP -- an IP-addressed roster is rejected and the player gets
|
||||
"An error occurred downloading the FUT Squad Update". The usual fix is a hosts
|
||||
entry on the client, which on this operator's machine previously took the whole
|
||||
machine's internet down.
|
||||
|
||||
So instead of editing the client's hosts file, the client points ONE name at
|
||||
this resolver through a Windows NRPT rule. Everything else on the client keeps
|
||||
using its normal DNS and is never routed here at all.
|
||||
|
||||
SAFETY PROPERTY THAT MATTERS: even for the queries that do arrive, an unknown
|
||||
name is FORWARDED upstream verbatim rather than refused or NXDOMAINed. So the
|
||||
worst case if the NRPT scope is ever widened by accident is added latency, not a
|
||||
broken resolver -- this cannot repeat the earlier outage.
|
||||
|
||||
Answers A records only; every other qtype for the pinned name is forwarded too,
|
||||
because inventing an answer for a type we do not model is how a resolver starts
|
||||
lying.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import socketserver
|
||||
import struct
|
||||
import sys
|
||||
|
||||
PINNED_NAME = os.environ.get("SCOPED_DNS_NAME", "winter15.gosredirector.ea.com").rstrip(".").lower()
|
||||
PINNED_ADDR = os.environ.get("SCOPED_DNS_ADDR", "10.10.0.120")
|
||||
BIND_ADDR = os.environ.get("SCOPED_DNS_BIND", "0.0.0.0")
|
||||
BIND_PORT = int(os.environ.get("SCOPED_DNS_PORT", "53"))
|
||||
TTL = 60
|
||||
|
||||
QTYPE_A = 1
|
||||
QCLASS_IN = 1
|
||||
|
||||
|
||||
def upstream_servers() -> list[str]:
|
||||
"""Real resolvers, read from resolv.conf, minus ourselves."""
|
||||
out = []
|
||||
try:
|
||||
with open("/etc/resolv.conf") as fh:
|
||||
for line in fh:
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0] == "nameserver" and parts[1] != PINNED_ADDR:
|
||||
out.append(parts[1])
|
||||
except OSError:
|
||||
pass
|
||||
return out or ["1.1.1.1", "8.8.8.8"]
|
||||
|
||||
|
||||
UPSTREAM = upstream_servers()
|
||||
|
||||
|
||||
def parse_question(data: bytes):
|
||||
"""Return (qname, qtype, end_offset) or None if the packet is not parseable."""
|
||||
if len(data) < 12:
|
||||
return None
|
||||
qdcount = struct.unpack("!H", data[4:6])[0]
|
||||
if qdcount < 1:
|
||||
return None
|
||||
labels, off = [], 12
|
||||
while off < len(data):
|
||||
ln = data[off]
|
||||
if ln == 0:
|
||||
off += 1
|
||||
break
|
||||
# A pointer in the QUESTION section is malformed; forward and let the
|
||||
# upstream decide rather than guessing.
|
||||
if ln & 0xC0:
|
||||
return None
|
||||
off += 1
|
||||
labels.append(data[off:off + ln].decode("ascii", "replace"))
|
||||
off += ln
|
||||
if off + 4 > len(data):
|
||||
return None
|
||||
qtype, _qclass = struct.unpack("!HH", data[off:off + 4])
|
||||
return ".".join(labels).lower(), qtype, off + 4
|
||||
|
||||
|
||||
def build_answer(query: bytes, qend: int) -> bytes:
|
||||
"""Echo the question and append one A record for the pinned address."""
|
||||
txid = query[:2]
|
||||
# QR=1, RD copied from the query, RA=1.
|
||||
rd = query[2] & 0x01
|
||||
flags = struct.pack("!H", 0x8000 | (rd << 8) | 0x0080)
|
||||
counts = struct.pack("!HHHH", 1, 1, 0, 0)
|
||||
question = query[12:qend]
|
||||
rr = (
|
||||
b"\xc0\x0c" # name -> pointer to the question
|
||||
+ struct.pack("!HHIH", QTYPE_A, QCLASS_IN, TTL, 4)
|
||||
+ socket.inet_aton(PINNED_ADDR)
|
||||
)
|
||||
return txid + flags + counts + question + rr
|
||||
|
||||
|
||||
def forward(query: bytes) -> bytes | None:
|
||||
for server in UPSTREAM:
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.settimeout(3.0)
|
||||
s.sendto(query, (server, 53))
|
||||
return s.recv(4096)
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
class Handler(socketserver.BaseRequestHandler):
|
||||
def handle(self):
|
||||
data, sock = self.request
|
||||
parsed = parse_question(data)
|
||||
if parsed:
|
||||
qname, qtype, qend = parsed
|
||||
if qname == PINNED_NAME and qtype == QTYPE_A:
|
||||
sock.sendto(build_answer(data, qend), self.client_address)
|
||||
print(f"ANSWER {qname} A -> {PINNED_ADDR} (from {self.client_address[0]})", flush=True)
|
||||
return
|
||||
reply = forward(data)
|
||||
if reply:
|
||||
sock.sendto(reply, self.client_address)
|
||||
if parsed:
|
||||
print(f"forward {parsed[0]} qtype={parsed[1]} (from {self.client_address[0]})", flush=True)
|
||||
|
||||
|
||||
class Server(socketserver.ThreadingUDPServer):
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(
|
||||
f"scoped-dns: pinning {PINNED_NAME} -> {PINNED_ADDR}; "
|
||||
f"forwarding everything else to {', '.join(UPSTREAM)}",
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
with Server((BIND_ADDR, BIND_PORT), Handler) as srv:
|
||||
print(f"scoped-dns: LISTENING on {BIND_ADDR}:{BIND_PORT}", flush=True)
|
||||
srv.serve_forever()
|
||||
except PermissionError:
|
||||
sys.exit(f"scoped-dns: cannot bind {BIND_ADDR}:{BIND_PORT} (needs root)")
|
||||
Reference in New Issue
Block a user